moto2002 / EffectSystem

Effect System is a powerful numerical calculator based on EffectType. It possesses high flexibility, maintainable states, the ability to implement custom logic, trigger conditions, and visual management.

Home Page:https://macacagames.github.io/EffectSystem/

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

See Document for more detail.

Overview

Effect System is a powerful numerical calculator based on EffectType. It possesses high flexibility, maintainable states, the ability to implement custom logic, trigger conditions, and visual management. It can achieve the following example functionalities:

  • Increase ATK by 50 points.
  • Increase HP by 10%.
  • Boost DEF by 5% for 50 seconds.
  • Reduce the ATK of a specified enemy by 100, usable once every 30 seconds.
  • When successfully blocking, decrease the opponent's DEF by 50%.

The table is used to collage various buffs, debuffs or skill effects based on EffectInfo as the basic unit, and it's convenient to use between different projects. Engineers only need to implement EffectType and register the timing of activation and deactivation.


Features

  • Add or adjust skills through Excel
  • Combine different skills through EffectSubInfo

Installation

Option 1: Install via OpenUPM (recommended)

openupm add com.macacagames.effectsystem

Option 2: Unity Package file

Add to the editor's manifest.json:

{
    "dependencies": {
        "com.macacagames.utility": "https://github.com/MacacaGames/MacacaUtility.git",
        "com.macacagames.effectsystem": "https://github.com/MacacaGames/EffectSystem.git"
    }
}

Option 3: Git SubModule

git submodule add https://github.com/MacacaGames/EffectSystem.git Assets/MacacaEffectSystem

Note: EffectSystem is dependent on MacacaUtility, so MacacaUtility must also be added to the git submodule.

git submodule add https://github.com/MacacaGames/MacacaUtility.git Assets/MacacaUtility

Conecpt

Life

graph LR
EffectSystem.AddEffect[EffectSystem.AddEffect]---->Start[Start] 
Start[Start] --ActiveCondition--> Active(Active)
Active(Active)  --MaintainTime Checking--> Deactive(Deactive)
Active(Active)  --Deactive Condition--> Deactive(Deactive)
Deactive(Deactive) --Active Condition--> Active(Active) 
Deactive(Deactive) --CD Checking--> Active(Active) 
Deactive(Deactive) --EffectSystem.CleanEffectableObject--> End(End)

Time Manage

Due to different game have different time unit, For example: the ACT or RPG game may use the seconds as the time unit, the round-based game may use Round as the time unit etc.

You need to implement the time calculation logic in your project.

Currently each Effect Instance ownes the time manage itself, call the API the Tick the time on the Effect Instance.

See the example:

// For seconds based game, uaually can tick the timer in Update, the delta should Time.deltaTime
void Update(){
    EffectSystem.Instance.TickEffectTimer(Time.deltaTime);
}

// For Round based game, Tick the timer in the callback of a Round, the dalta may be 1(round)
IEnumerator Round(){
    while(true){
         EffectSystem.Instance.TickEffectTimer(1);
        yield return new WaitForNextRound();
    }
}

EffectType

EffectType is the basic unit to define a feature during the runtime, for instance AddAtk, AddAtkByRatio, Blocking etc.

EffectInfo

EffectInfo is the define of an effect, it only store the perference of an effect, but not handle the runtime behaviour.

One EffectInfo should only focus on one simple thing, such as calculating the ATK etc.

Field Data Type Description
id string The ID of an effect
type string The EffectType
maintainTime float The keep going time of the effect
activeCondition string The active trigger timing of condition
activeRequirementLists string[] The qualifications of an active trigger condition (user-defined)
activeProbability float The probability (0-100) of the effect being activated
deactiveCondition string The deactive trigger timing of condition
deactiveRequirementLists string[] The qualifications of a deactive trigger condition
deactiveProbability float The probability (0-100) of the effect being deactivated
cooldownTime float The remaining time in seconds until the effect can be activated again
logic enum Life cycle management preset logic, see TriggerTransType for more info
triggerTransType enum An enumeration to manage the logic when the effect tries to trigger twice, see EffectLifeCycleLogic for more info
tags string[] Pre-defined tags for an effect
subInfoIds string[] Effect IDs of SubInfo. SubInfo is useful when there is a requirement to trigger another effect
viewInfoIds string[] The View data IDs for an effect
parameters Dictionary<string, string> Custom parameters for your own project

Pre-define Enums

TriggerTransType

Field Data Description
SkipNewOne Ignore the new one
CutOldOne Apply new one and cut old one

EffectLifeCycleLogic

Field Data Description
None Do nothing
OnlyActiveOnce The Effect Instance will only active once
ReactiveAfterCooldownEnd Automatically reactive after the cd is done

IEffectableObject

The IEffectableObject is the interface to making a C# object can be add/remove an Effect, by complete the interface APIs, you can start using the convience of the system. Such as an Enemy, a character, a card or anything you would like to attach a effect on itself, you should make it into a IEffectableObject.

public interface IEffectableObject
{
    /// <summary>
    /// Get the display name of an IEffectableObject
    /// Not really required, but very helpful when debugging
    /// </summary>
    /// <returns></returns>
    string GetDisplayName();

    Transform GetEffectViewParent(string viewRoot);

    /// <summary>
    /// Detect if a Effect can be request or not
    /// </summary>
    /// <param name="info"></param>
    /// <returns>If false, then system automatically reject a add effect request</returns>
    bool ApprovedAddEffect(EffectInfo info);

    /// <summary>
    /// Fire once when an Effect Instance is Active
    /// </summary>
    /// <param name="info"></param>
    void OnEffectActive(EffectInfo info);

    /// <summary>
    /// Fire once when an Effect Instance is DeActive
    /// </summary>
    /// <param name="info"></param>
    void OnEffectDeactive(EffectInfo info);

    bool IsAlive();

    /// <summary>
    /// Due to the real runtime value is maintain by the IffectableObject, so you should use this to get the acctual value
    /// e.g. Current_ATK = ATK_Constant * ATK_Ratio
    /// So only using EffectSystem.GetEffectSum() is not enough
    /// </summary>
    /// <param name="parameterKey"></param>
    /// <returns></returns>
    float GetRuntimeValue(string parameterKey);

    void DestoryEffectableObject();
}

Effect Instance

Effect Instance is the runtime instance which created by the system following the EffectInfo. Use the API to add a Effect on a IEffectableObject

/// <summary>
/// Add one or more Effect(s) to an IEffectableObject
/// will do the ApprovedAddEffect checking before the effect is added
/// </summary>
/// <param name="owner">The target obejct would like to add the Effect</param>
/// <param name="effectInfos">The EffectInfos you would like to add the the owner</param>
/// <param name="tags">Add the tags on the EffectInstance which is add on this requrest, it is very helpful to manage the Effect Instance, </param>
public void AddRequestedEffects(IEffectableObject owner, IEnumerable<EffectInfo> effectInfos, params string[] tags)

Implement the logic of an Effect Instance

For most of the cases, it's not require to making a logic implementation for an Effect Instance, but you can do it yourself for more customization.

See the example:

// Create a new class and inherit the EffectInstanceBase class
public class Effect_MyCoolEffect : EffectInstanceBase
{
    /// <summary>
    /// Excude when an Effect is attach
    /// </summary>
    protected override void OnStart(){}

    /// <summary>
    /// Excude when an Effect is Active by ActiveCondition
    /// </summary>
    /// <param name="triggerConditionInfo"></param>
    public override void OnActive(EffectTriggerConditionInfo triggerConditionInfo){}

    /// <summary>
    /// Excude when an Effect is Deactive DctiveCondition
    /// </summary>
    /// <param name="triggerConditionInfo"></param>
    public override void OnDeactive(EffectTriggerConditionInfo triggerConditionInfo){}

    /// <summary>
    /// Excude when the colddown is finish
    /// </summary>
    public override void OnColdownEnd(){}
}

// For Trigger base Effect you can choose to inherit the EffectInstanceBase class
public class Effect_MyCoolEffect : EffectTriggerBase
{ 
    /// <summary>
    /// Excude when the Effect is trigger
    /// </summary>
    protected override void OnTrigger(EffectTriggerConditionInfo conditionInfo);
}

And Registe your Implementation to the EffectSystem

Dictionary<string, Type> EffectTypeQuery = new Dictionary<string, Type>
{
    // The key is the effet type
    ["MysterySkill"] = typeof(Effect_MyCoolEffect),
};
EffectDataProvider.RegisteEffectTypeQuery(EffectTypeQuery);

Behaviour

The Effect Instance has following built-in behaviour

Instance Count

The system allow an IEffectableObject ownes multiple Effect Instance.

For instance you can design a effect like this:

Shield Effect
value How much the injured decrease on this shield effect in %

See the example:

var effectShield_Small = new EffectInfo{
    id: "Shield_Small",
    type: "Shield",
    value: 20,
};

var effectShield_Big = new EffectInfo{
    id: "Shield_Big",
    type: "Shield",
    value: 50,
};

IEffectableObject target;

AddRequestedEffects(target, effectShield_Small);
AddRequestedEffects(target, effectShield_Big);

var totalEffects = EffectSystem.Instance.GetEffectsByType(target,"Shield_Big");
// totalEffects.Count is 2

Value

The System automatically summed up all value by the type on one IEffectableObject,

Use the follow API to get the current Value on the IEffectableObject:

/// <summary>
/// Get the sum value of the EffectType on an IEffectableObject
/// </summary>
/// <param name="target">The target IEffectableObject</param>
/// <param name="effectType">The EffectType</param>
/// <returns></returns>
public float GetEffectSum(IEffectableObject target, string effectType);

For instance, you can apply 2 effect on an IEffectableObject The example data, we define 2 effect, but with same EffectType

var effectAddAtkSmall = new EffectInfo{
    id: "AddAtkSmall",
    type: "ATK_Constant",
    value: 100,
    /// ignore other parameters on this example
};

var effectAddAtkMedium = new EffectInfo{
    id: "AddAtkMedium",
    type: "ATK_Constant",
    value: 200,
    /// ignore other parameters on this example
};

IEffectableObject target;

AddRequestedEffects(target, effectAddAtkSmall);
AddRequestedEffects(target, effectAddAtkMedium);

/* or
AddRequestedEffects(
    target, new []{
        effectAddAtkSmall,
        effectAddAtkMedium
    }   
);
*/

var result = GetEffectSum( target, "ATK_Constant");
// result is 300 

Runtime Value

Runtime value is a abstract value, for some project they may design a complicated value calculation design which may calculating the values between different EffectTypes,

See the follow example:

var effectAddAtkSmall = new EffectInfo{
    id: "AddAtkSmall",
    type: "ATK_Constant",
    value: 100,
    /// ignore other parameters on this example
};

var effectAddAtkMedium = new EffectInfo{
    id: "AddAtkMedium",
    type: "ATK_Constant",
    value: 200,
    /// ignore other parameters on this example
};

var effectAddAtkSmall_Ratio = new EffectInfo{
    id: "AddAtkSmall_Ratio",
    type: "ATK_Ratio",
    value: 0.05,
    /// ignore other parameters on this example
};

var effectAddAtkMedium_Ratio = new EffectInfo{
    id: "AddAtkMedium_Ratio",
    type: "ATK_Ratio",
    value: 0.08,
    /// ignore other parameters on this example
};

IEffectableObject target = new MyCharacter();

AddRequestedEffects(target, effectAddAtkSmall);
AddRequestedEffects(target, effectAddAtkMedium);
AddRequestedEffects(target, effectAddAtkSmall_Ratio);
AddRequestedEffects(target, effectAddAtkMedium_Ratio);

public class MyCharcter : IEffectableObject{

    // Use the IEffectableObject.GetRuntimeValue(string) to get the runtime value which you define
    public float GetRuntimeValue(string parameterKey){
        switch(parameterKey){
            case "CurrentATK":
                {
                    var result_constant = GetEffectSum( target, "ATK_Constant");
                    var result_ratio = GetEffectSum( target, "ATK_Ratio");
                    // result_constant is 300 
                    // result_ratio is 0.13 

                    // The Runtime value is define by the IEffectableObject in your own project
                    // In this example we use "ATK_Constant" to define the base value of ATK, use "ATK_Ratio" to define the boost ratio of ATK and then calculating the acctual result in runtime
                    return result_constant * (1f + result_ratio);
                }
                break;
            default:
                return 0;
        }
    }
}

MaintainTime

By default, the system provide a method to manage the Effect time-based lifecycle. Use the maintainTime filed to define the duration of an effect should be take affect, if the maintainTime is greater than 0 then the effect will have a time-based lifecycle. Once the effect have maintainTime parameter, you can use TriggerTransType and EffectLifeCycleLogic to control more detailed behaviour of an Effect.

The unit of a Time is defined by the project, eg. Seconds, Actions, Rounds etc. In below example, we assuming the unit of the Time is seconds

See the Example:

var effectAddAtkSmall = new EffectInfo{
    id: "AddAtkSmall",
    type: "ATK_Constant",
    value: 100,
    maintainTime: 0, // see here!!
    /// ignore other parameters on this example
};

var effectAddAtkMedium = new EffectInfo{
    id: "AddAtkMedium",
    type: "ATK_Constant",
    value: 200,
    maintainTime: 10, // see here!!
    /// ignore other parameters on this example
};

IEffectableObject target;

AddRequestedEffects(target, effectAddAtkSmall);
AddRequestedEffects(target, effectAddAtkMedium);


// Call immediately after AddRequestedEffects()
var result = GetEffectSum( target, "ATK_Constant");
// result is 300 

// Call after 10 secs or more after AddRequestedEffects()
var result = GetEffectSum( target, "ATK_Constant");
// result is 100 

ColddownTime

Like MaintainTime, ColddownTime is another parameter to control the time-based lifecycle, which is focus on the duplicate Add Effect behaviour. It also use TriggerTransType and EffectLifeCycleLogic to control more detailed behaviour of an Effect.

See the Example:

var effectAddAtkSmall = new EffectInfo{
    id: "AddAtkSmall",
    type: "ATK_Constant",
    value: 100,
    maintainTime: 5,
    colddownTime: 10, // see here!!
    /// ignore other parameters on this example
};

IEffectableObject target;

AddRequestedEffects(target, effectAddAtkSmall); // first add call
await Task.Delay(TimeSpan.FromSeconds(1));
AddRequestedEffects(target, effectAddAtkSmall);  // second add call
var result = GetEffectSum( target, "ATK_Constant");
// result is 100, due to the effect take 10 secs to cd, the second add call in the cd time will be ignore

await Task.Delay(TimeSpan.FromSeconds(10));
var result = GetEffectSum( target, "ATK_Constant"); // third add call
// result is 100, the time is over 10 secs so the  third add call success and take affect

Condition

Condition is summary of the following parameter:

Field Data Type Description
activeCondition string The active trigger timing of condition
activeRequirementLists string[] The qualifications of an active trigger condition (user-defined)
activeProbability float The probability (0-100) of the effect being activated
deactiveCondition string The deactive trigger timing of condition
deactiveRequirementLists string[] The qualifications of a deactive trigger condition
deactiveProbability float The probability (0-100) of the effect being deactivated

See the Example:

var effectAddAtkSmall = new EffectInfo{
    id: "TriggerEffect_Sample",
    type: "TriggerEffect_Sample",
    activeCondition: "ConditionOnAttack"
};

MyCharacter character = new MyCharacter();
AddRequestedEffects(target, effectAddAtkSmall); // first add call

class MyCharacter: IEffectableObject {
    void DoAttack(){
        // All the Effect Instance on 'this' object which activeCondition == "ConditionOnAttack" will try to active
        // as the result the effect with id "TriggerEffect_Sample" will be active
        EffectSystem.Instace.EffectTriggerCondition("ConditionOnAttack", this);
    }
}

For some case you may want to send target parameter, here is another example

class MyCharacter: IEffectableObject {
    void DoAttack(IEffectableObject enemy){
        // All the effectInstance on 'this' object which activeCondition == "ConditionOnAttack" will try to active
        // as the result the effect with id "TriggerEffect_Sample" will be active
        EffectSystem.Instace.EffectTriggerCondition("ConditionOnAttack", this, enemy);
    }
}

// Implement the effect behaviour
public class Effect_TriggerEffect_Sample : EffectTriggerBase
{
    protected override void OnTrigger(EffectTriggerConditionInfo conditionInfo)
    {
        if (conditionInfo.target != null)
        {
            // Use the target which is set from the EffectSystem.Instace.EffectTriggerCondition excude;
        }
    }
}

Effect Editor Window

The system provide a very helpful tool to inspect the runtime Effect Instance.

Menu Path : MacacaGames > Effect System > Effect Editor Window

The editor provide those feature:

  • Display all IEffectableObject in the Memery
  • Inspect the runtime Effect Instance on an IEffectableObject
  • Add/Remove one or more Effect in the Runtime
  • Bake all string parameter into a const variable
  • Preview the Effect Description of a EffectInfo (WIP)

Effect Description

To make the use understand your Effect is very important, the system provide a feature to generate Effect Description by an EffectInfo.

Description Template

You need provide a Description Template first to generate a runtime Description.

See the example:

var myTemplate = "Deal extra {Effect_Atk_Ratio.value} damage to enemies with full HP.";
var myEffect = new EffectInfo{
    id: "TriggerEffect_Sample",
    type: "Atk_Ratio",
    value: 12
};

var result = EffectSystem.Instance.GetCustomEffectsDescription(myTemplate, new[]{myEffect});
// result is "Deal extra 12 damage to enemies with full HP."

The rule of the Template

The Template use the key word to detect which part in the template should be replaced, here is the rule of a key word.

  • Start with { char
  • End with } char
  • Use Effect_ or # char to define the EffectType, for instance #Atk_Ratio means use the Atk_Ratio EffectType
  • Use . to access the member in the EffectInfo, the . can continue with the key follow the table
  • Use subinfo or > to access the EffectInfo in subinfo
Key Description
value use the value member in the EffectInfo
val same as value but simplified
maintainTime use the maintainTime member in the EffectInfo
time same as maintainTime but simplified
cooldownTime use the cooldownTime member in the EffectInfo
cd same as cooldownTime but simplified
activeProbability use the activeProbability member in the EffectInfo
activeProb same as activeProbability but simplified
deactiveProbability use the deactiveProbability member in the EffectInfo
deactiveProb same as deactiveProbability but simplified
:% The value will be display as percentage, the value will *100 first and then display as oo%

Default Description

It is recommand to make Default Description for all EffectType

// First regist the template resource
EffectDataProvider.SetEffectDescriptionStringDelegate(
    (m) =>
    {   
        // m is the EffectType
        switch(m){
            case "Atk_Ratio":
                return "Deal extra {Effect_Atk_Ratio.value} damage to enemies with full HP.";
            case "Defend":
                return "Reduce {Effect_Defend.value} damage taken.";
        }
    }
);

var effect_sample_01 = new EffectInfo{
    id: "effect_sample_01",
    type: "Atk_Ratio",
    value: 123
};
var effect_sample_02 = new EffectInfo{
    id: "effect_sample_02",
    type: "Defend",
    value: 999
};
// After regist the template resource, you can directlly call EffectSystem.Instance.GetDefaultEffectDescription to get the default Description
var result = EffectSystem.Instance.GetDefaultEffectDescription(effect_sample_01);
// result is "Deal extra 123 damage to enemies with full HP."

// Or provide multiple EffectInfo, the system will auto combine all Description line by line
var result = EffectSystem.Instance.GetDefaultEffectDescription(new []{effect_sample_01, effect_sample_02});
/* 
result will be 

Deal extra 123 damage to enemies with full HP.
Reduce 999 damage taken.
*/

A more complicated example

var effect_sample_01 = new EffectInfo{
    id: "effect_sample_01",
    type: "Trigger_Attach",
    value: 0,
    subInfoIds:new []{"effect_sample_subinfo_01"}
};

var effect_sample_02 = new EffectInfo{
    id: "effect_sample_02",
    type: "Atk_Ratio",
    maintainTime: 4.5,
    value: 999
};

// This case the `effect_sample_01` has subInfos, so we need to registe the EffectDataProvider, the subInfo is query during runtime
var effect_sample_subinfo_01 = new EffectInfo{
    id: "effect_sample_subinfo_01",
    type: "Trigger_HitSelf_Constant",
    value: 555
};

var effects = new []{effect_sample_01, effect_sample_02, effect_sample_subinfo_01};
EffectDataProvider.SetEffectInfoDelegate(
    (List<string> effectIds) =>
    {
        return effects.Where(m => effectIds.Contains(m.id)).ToList();
    }
);

var myTemplate = @"Deal extra {Effect_Trigger_Attach>Effect_Trigger_HitSelf_Constant.value} damage to enemies with 50% or less HP.
Increase {Effect_Atk_Ratio.value} Attack for {Effect_Atk_Ratio.time} seconds after killing an enemy.";

// Or provide multiple EffectInfo, the system will auto combine all Description line by line
// The subInfo is resolve in runtime, so only send the root EffectInfos
var result = EffectSystem.Instance.GetCustomEffectsDescription(myTemplate, new []{effect_sample_01, effect_sample_02});
/* 
result will be 

Deal extra 555 damage to enemies with 50% or less HP.
Increase 999 Attack for 4.5 seconds after killing an enemy.
*/

It is always recommended to use EffectSystem.Instance.GetCustomEffectsDescription() to generate a human-kindly Description

But the EffectSystem.Instance.GetDefaultEffectDescription() provide a simple way to automatically generate at least a human-readable Description

Prepare Your Data

After understanding the concept of the EffectSystem, it's time to prepare your runtime data, on perious samples, we directly create new EffectInfo with the constructor, but there are some other method to setting up your effect datas.

Unity Serialization

The basic way is to create a List<EffectInfo> in somewhere(MonoBehaviour/ScriptableObject) in your project, then use the Unity Inspector to edit them.

EffectGroup

EffectGroup is a pre-define Unity ScriptableObject to help you store the EffectInfo in your Unity. It provide some very useful feature:

  • Export the current EffectInfo(s) to Json format
  • Import EffectInfo(s) from Json
  • Directly using EffectGroup in most of EffectSystem APIs

Google Sheet Template

For more convience of batch data editing, we design a Google Sheet to help this stuff.

Here's the Table Example: Effect Data Sample Table

Please make a copy of this sample and do any modification yourself!

Pre Baked String for strong Type usage

On the ScriptOptions tab in the Sheet, you can copy directly the A1 field to the Effect Editor Window to pre-baked all string parameter in your sheet, since the system use string as the ID to refer other resource, for more safety usage, it's recommend to use pre-baked string define for all your parameters.

EffectDataProvider

EffectDataProvider provide the runtime data resource for the runtime data providing, for instance, if you have using the subInfo feature, the system require delegate to get the EffectType by Id in runtime. Since different project manage its owne EffectInfos, you need to regist the method to make all feature works.

public static class EffectDataProvider
{
    public static Func<List<string>, List<EffectInfo>> GetEffectInfo { get; private set; }
    public static void SetEffectInfoDelegate(Func<List<string>, List<EffectInfo>> GetEffectInfo)
    {
        EffectDataProvider.GetEffectInfo = GetEffectInfo;
    }

    public static Func<List<string>, List<EffectViewInfo>> GetEffectViewInfo { get; private set; }
    public static void SeEffectViewInfoDelegate(Func<List<string>, List<EffectViewInfo>> GetEffectViewInfo)
    {
        EffectDataProvider.GetEffectViewInfo = GetEffectViewInfo;
    }

    public static Func<string, string> GetEffectDescriptionString { get; private set; }

    public static void SetEffectDescriptionStringDelegate(Func<string, string> GetEffectDescriptionString)
    {
        EffectDataProvider.GetEffectDescriptionString = GetEffectDescriptionString;
    }
}

Effect View

To Be Continue

Serialization

MessagePack

The MessagePack.Csharp should do a code generate first to make the EffectInfo available to use on AOT Platform

First, use the mpc tool to generate the Resolver, here is some example: For more detail, see the MessagePack Document

dotnet new tool-manifest
dotnet tool install MessagePack.Generator
dotnet tool run mpc -i {PATH_TO_YOUR_EFFECTPACKAGE_MODEL_FOLEDR} -o ./Assets/EffectSystemResources/EffectSystem.Generated.cs -r EffectSystemResolver -n MacacaGames.EffectSystem

## Example
## dotnet tool run mpc -i ./MacacaPackages/EffectSystem/Model -o ./Assets/EffectSystemResources/EffectSystem.Generated.cs -r EffectSystemResolver -n MacacaGames.EffectSystem

And add into your StaticCompositeResolver

StaticCompositeResolver.Instance.Register(
    MacacaGames.EffectSystem.Resolvers.EffectSystemResolver.Instance,
);

About

Effect System is a powerful numerical calculator based on EffectType. It possesses high flexibility, maintainable states, the ability to implement custom logic, trigger conditions, and visual management.

https://macacagames.github.io/EffectSystem/


Languages

Language:HTML 74.2%Language:C# 9.4%Language:CSS 8.7%Language:JavaScript 7.7%