sschmid / Entitas

Entitas is a super fast Entity Component System (ECS) Framework specifically made for C# and Unity
MIT License
7.17k stars 1.11k forks source link

[Design] Integrating animation sequences together with damage calculation in Entitas (RPG battle system) #808

Open AVAVT opened 6 years ago

AVAVT commented 6 years ago

Hi,

Animation and Entitas is an issue I had for a long time, and still couldn't find a definite solution for it. There are several use cases that differ slightly, so I thought I should make an issue instead of asking in the chat.

Most of the problems I couldn't solve are from my experience making this jam game, so if you need a reference to what I'm talking about, please just take a look at the game (it takes like 5 minutes).

Some prerequisite to make the system less broad:

Anyway, going through the problems, order from "partially solved" to "absolutely no clue":

(It's a long wall of text, get some popcorn)

Situation 1: Unit 1 attack unit 2 with single target melee skill.

Expected behavior:

My "solution":

[Game, Event(EventTarget.Self)]
public class AttackingComponent: IComponent {
  public Guid target;
  public float hitTime;
  public string skillType;
}
if (entity.attacking.hitTime<= gameContext.time.timeSinceLoad) {
  var defender = ...
  defender.ReplaceHP(calculatedDamage);
  gameContext.CreateEntity().AddDamageNotification(calculatedDamage, defender.position.value);
}

What I don't like about this:

Any better option?

Situation 2: Unit 1 attack unit 2 with single target projectile-based skill.

Expected behavior:

This is actually not (that) hard. My solution was to consider every skill in the game projectile-based. The previous ResolveAttackSystem now become ShootProjectileSystem, which create a projectile instead:

public class Projectile: IComponent {
  public string type;
}
public class ProjectileFlyingBehaviorComponent: IComponent {
  public string type;
}
public class ProjectileSpeedComponent: IComponent {
  public float value;
}

Melee projectiles has a movementSpeed of 0 and flies to target instantly, while ranged projectile have an arbitary movementSpeed and a distinct fly behavior (straight or curved etc). Upon landing (sqrDistance < sqrDeltaSpeed) I now create an AttackResult, which is used by ResolveAttackSystem:

[Game, Cleanup(CleanupMode.DestroyEntity)]
public class AttackResultComponent: IComponent {
  public Guid attacker;
  public Guid defender;
  public float damage;
}

1 small note here is that because it's an RPG, the defender doesn't move, so for melee attack to work I only need to set meleeProjectile.ReplacePosition(defender.position.value); and it will trigger immediately. I am not sure if this will still work in a dynamic game, where the defender may run around (possibly even teleport).

Situation 3: unit attack many units with AoE damage skill.

You may think it's just a simple conversion to 1-to-n relationship, but it's not that simple. You see, in this screenshot:

screenshot 2018-10-07 20 59 19

If I make all floating numbers to popup at the same time, it would look bad. So instead I want a delay between numbers showing up, somekind of smooth wave-like cascading flating text instead of 9 numbers flying up in parallel.

Expected behavior:

In the actual game, what I did was to throtte ShowDamageNotificationSystem, so it only create 1 new floating text each 3 frame.

This is a very bad idea because tween choice is limited. The ShowDamageNotificationSystem system doesn't know which notification that is, you can't have each skill do its own distinct floating animation (For example an Explosion!!! skill should have text floating in a circle from the center, while a Charge Mofo!!! skill should have text floating up in a wave from the front toward the back):

I have a feel it's also wrong in a more fundamental way (like it's violating some ECS principles here), but can't put it into word. Maybe someone could tell me if it is.

So, in a later game, I made some changes. I split AttackingComponent into 2:

public SkillInProgressComponent: IComponent {
  public Guid attacker;
  public Guid defender;
  public string skillType;
  public float hitTime;
}

[Game, Event(EventTarget.Self), Cleanup(CleanupMode.RemoveComponent)]
public DoingSkillComponent: IComponent {
  public string skillType;
}

SkillInProgressComponent is used by the game systems to resolve damage, while DoingSkillComponent is subscribed by the view to display corresponding animation. The hitTime of each individual hit can be determined by whichever system creating them, allowing me to do whatever crazy idea I could think of.

What I don't like about it:

The first part is doable with current implementation, I only need to change SkillInProgressComponent it to have public float[] hitTime; together with a public float[] hitCoefficiency;

But still, "something" is ringing here. I don't know why but I have a feeling I'm going down a dark path. Maybe I should not change this to float[], or maybe the component becamse bloated? Again, can't put it into word.

For the second part (keeping defender at last hurt frame), still absolutely no clue. Any idea?

Situation 4: very long chain of back and forth skills that cause turn time to become varied.

Very long chain of back-to-back skill triggers, for example, a Vine skill deal damage, then cause the Poison debuff (visual the debuff after the attack). The defender happen to have a Counterattack so he perform a revenge attack. Attacker's ally have a Protect skill which make him block the attack for the attackers. He also has Counterattack so he does that as well.

Anyway, with the types of skill increasing, the total resolution time of the whole combo become undeterminable at compile time. In the example game, after player choose a skill, I would put a 2 seconds delay before moving on to the next unit's turn, and it worked great, because no skill combination could ever reach 2 seconds (it was a game jam, don't judge)

Expected behavior:

Now this is inside the "totally no clue" zone for me, but I'll try to input some idea: For the 2nd point, I'm thinking about have an NextTurnSystem: IExecuteSystem that count the number of entities that has either

If the both groups are empty for some time (let's say 1 second), the turn will end, when I mark nextUnit.isActing = true;. I have this idea from the way Mount&Blade determine how a battle has ended.

Still, puting a time counter in a system is... idk, again, "danger" bell ringing. I will most likely have to put "exception" cases in the system later, to account for game paused, battle ended etc.

For the 1st pointer, I can't think of any "clean" way to do. And it's a must because without a delay, the counterattack will happen immediately after the initial attack end, and it looks terrible.

So that was it. A lot of problems here and there, looking for input to improve/change. How do you do yours?

c0ffeeartc commented 6 years ago

I read Situation 3 Possible solution is Parametric Animation. It's doable with tweens, behaviour trees, or coroutines. During them create Input/Command entites, this will most likely happen during unity's Update stage, so be careful what entities you create.

Don't really know where to put the hitTime calculation code in. A SkillService? Right now because there's only 2 different kinds of text animation I'm putting it all in the PerformAttackSystem within an if/else block. But obviously this is not scalable.

I write functions into systems at first, then once needed elsewhere, refactor them into service and call from various places.

AVAVT commented 6 years ago

I read Situation 3 Possible solution is Parametric Animation. It's doable with tweens, behaviour trees, or coroutines. During them create Input/Command entites, this will most likely happen during unity's Update stage, so be careful what entities you create.

Which part were you talking about? The delay between AoE hits, or the multi-hits part? (sorry I didn't organize the point very well).

Anyway, this part is an animation visual, so creating InputEntity wouldn't be a sound idea, would it?

c0ffeeartc commented 6 years ago

Which part were you talking about? The delay between AoE hits, or the multi-hits part? (sorry I didn't organize the point very well).

It should work for both parts, it's written in code and quite flexible. The downside it needs recompiling

Anyway, this part is an animation visual, so creating InputEntity wouldn't be a sound idea, would it?

Fine for me. Would like to know more solutions

Arpple commented 6 years ago

I haven't read all of your explanation yet, but for situation 1 (and in other async command) I will use an InputEntity with component that have a callback as property like this

[Input]
public class CmdPlayAnimationComponent : IComponent {
    public GameEntity Entity;
    public string Animation;
    public Action OnAnimationEnd;
}

and the reactive system that catch on this will just have to call the callback when it finished

public class AnimationSystem : ReactiveSystem {
    public void Execute() {
        //PlayAnimation is just function that will play animation and call callback when end
        PlayAnimation(cmd.Animation, cmd.OnAnimationEnd);
        }

and use a command like this

var cmd = CreateInputEntity();
cmd.AddCmdPlayAnimation(entity, "hurt", () => createCommandThatShowDamage());

and I combine the concept above with RSG Promise and it become like this

[Input]
public class CmdPlayAnimationComponent : IComponent {
    public GameEntity Entity;
    public string Animation;
    public Action OnAnimationEnd;
}

public static class CmdPlayAnimationExtension {

    public static IPromise PlayAnimation(this GameEntity entity, string animation) {
        return new Promise((resolve, reject) => {
            Contexts.sharedInstance.input.CreateEntity(e => e.AddCmdPlayAnimation(entity, animation, resolve));
        });
    }
}

and use it

entity.PlayAnimation("hurt")
    .Then(() => createCommandThatShowDamage());

hope this help somehow