Gameplaycom.zoa.abilities · v0.1.0

ZOA Abilities

A phase machine for activated abilities: cast, active, recovery, cooldown, with charges, costs and cancellation.

An ability here is a small state machine with a schedule. AbilityTiming declares how long each phase lasts, AbilityInstance walks the phases against delta time, and everything else in the package is either a rule about whether the machine may start, or a description of what it costs when it does.

The five phases are Idle, Casting, Active, Recovery and Cooldown, and an ability moves through whichever of them its timing gives a non-zero duration. A grenade with only a cooldown fires and goes straight to Cooldown within a single activation call. A channelled hack with a cast time waits in Casting, fires, holds Active for its duration, and only then starts recovering. The same instance covers both, because the phase graph is data rather than code.

The package does not execute effects. Activation raises events and the instance tracks its own state; what a grenade actually does belongs to the project. What Abilities owns is the gating: cooldowns, charges, resource costs through IAbilityResourceProvider, condition requirements against com.zoa.conditions, and cancellation under a flags-based policy.

Depended on by (0)

Nothing yet. This is a leaf.

How it works

Concepts

The phase machine

TryActivate is the entry point and it is guarded. A non-toggle ability must be Idle and must have at least one charge; otherwise it returns false and nothing changes. On success a charge is consumed immediately, and then the machine branches: if CastTime is above zero it transitions into Casting and waits, and if not it fires straight away.

Fire moves to Active and raises OnFired. If the ability has an ActiveDuration it stays there until the timer runs out. If it does not, and it is not a toggle, Fire immediately falls through to Recovery when RecoveryTime is set, then to Cooldown when Cooldown is set, then back to Idle. Because of that fallthrough, an instant ability with a cooldown can pass through Active and land in Cooldown inside a single TryActivate call. A listener that samples Phase on the next frame will never observe Active for such an ability, so hang the reaction on OnFired instead.

Cooldown counts down like any other phase, and when it expires the instance returns to Idle and raises OnCompleted. The service surfaces that as both Completed and CooldownReady, which is the signal a HUD watches to un-grey an ability button.

TransitionTo sets the phase timer from the new phase's authored duration, so PhaseTimeRemaining and the normalised PhaseProgress are meaningful in every phase including Cooldown. A phase with no duration reports progress of one.

Categories change the shape of the machine

Toggle reroutes the logic. A toggle ability that is off and Idle turns on, transitions to Active and fires. A toggle that is on turns off and transitions directly to Cooldown regardless of where it was. While a toggle is on, Tick returns early before touching the phase switch, so the ability sits in Active indefinitely and ActiveDuration is ignored.

CanActivate reflects that split: for a toggle it is true when the ability is Idle or already toggled on, because turning something off is itself an activation, and for everything else it is true only when Idle with charges remaining.

Instant, Channeled and Charged share the non-toggle path and differ only in their timing. Instant has no cast and no active duration. Channeled has a cast time and an active duration. Charged uses the cast time as the maximum charge-up window. The static factories AbilityTiming.Instant, AbilityTiming.Channeled and AbilityTiming.Charged build the matching timing structs, and Passive is a category for abilities that are never activated at all.

Charges regenerate only while idle

MaxCharges is how many activations can be banked, and it is clamped to a minimum of one, so a single-charge ability is just the degenerate case. Activation decrements the count and the instance can keep activating while charges remain, subject to being Idle.

Regeneration is gated on being Idle: the regen timer only advances when the instance is in the Idle phase and below its maximum. That means a charge does not tick back during a cast, an active window, recovery, or a cooldown. ChargeRegenTime is a separate number from Cooldown, so a dash can carry a short per-use cooldown and a much slower refill of its second charge.

The regen timer is armed on the transition into Idle when charges are below maximum, so the wait starts when the ability becomes available again, not at the moment of use.

Cancellation is a flags intersection

AbilityCancelPolicy is a Flags enum and it is used twice: once as the definition's declaration of what may cancel this ability, and once as the reason passed into TryCancel. Cancellation is allowed when those two agree.

Two of the flags are phase-based. DuringCast permits cancellation while the instance is Casting and DuringActive permits it while Active, and each is checked against the current phase directly. The other four, OnDamage, OnMove, OnOtherAbility and OnWeaponSwitch, are cause-based: the caller asserts the cause in the reason argument and the cancel succeeds only if the definition also lists that cause. A caller that passes a cause the definition does not carry gets false, and the ability continues.

Two presets exist for the common shapes. FreelyCancellable is DuringCast plus DuringActive, for an ability the player may simply back out of. Fragile adds OnDamage and OnMove, for a channel that should break the moment anything happens to the caster. The default is None, meaning once it starts it finishes.

A successful cancel clears the toggle flag, raises OnCancelled, and then goes to Cooldown if the ability has one or straight to Idle if it does not. Cancelling never refunds the charge that activation spent. An instance that is Idle or already in Cooldown cannot be cancelled at all.

Costs and conditions come from outside

An AbilityCost names a StatId, an amount, and a PerTick flag distinguishing a one-off activation cost from a drain paid on every tick of a channel. The costs live on the definition, but the resource pool does not: IAbilityResourceProvider is the seam, with GetResource, HasResource and ConsumeResource against an entity id and a stat id. The consuming project supplies the implementation, so stamina, energy and a grenade count can all be costs without this package knowing what any of them are.

Conditions arrive as three separate lists of ConditionId. AppliedConditions are what the ability puts on its user when it activates, so a dash can apply a brief invulnerability. RequiredConditions must be present for the ability to be usable, so a finisher can demand that the target is stunned. BlockingConditions prevent activation while present, which is how a stun stops abilities without every ability needing to know what a stun is.

The definition declares all of this and the instance's own TryActivate checks only phase and charges. Resource checking, condition gating and applying the resulting conditions are the integration layer's job, wired between the resource provider, the condition service and the ability service. The boundary is drawn there: this package answers when the machine may run, and the project answers what running costs.

Input and interaction bindings

A definition can name an InputAction such as tactical or ability_1, and an InteractionVerb such as use or special. Both are plain strings, and both are optional.

IAbilityInputBinding is the contract that turns them into activations. It exposes the binding key it watches, the ability id it activates, and three pieces of frame state: IsHeld for charged abilities that build while a key is down, WasPressed and WasReleased for the edges. Implementations poll the input or interaction system and route into IAbilityService.TryActivate, which keeps the ability package independent of any particular input backend.

In the editor

Screens

Screenshot pending

/screenshots/abilities-wizard-timing.png

The wizard showing cast time, active duration, recovery, cooldown, max charges and charge regen as separate fields, with the cancel policy flags mask beside them and the step rail on the left.

The Ability Wizard on its timing step.

Screenshot pending

/screenshots/abilities-phase-guide.png

The Abilities module with the Phase Guide selected, laying out the five phases in order alongside the five categories and the cancellation policy flags.

The Phase Guide capability.

Setup

Workflow

  1. 01

    Author the ability

    Run the Ability Wizard from Tools, ZOA, Advanced, Define, Characters, Abilities, Ability Wizard, or create an AbilityDefinitionAsset directly. The asset authors the six timing fields individually rather than through the static factories, because the factories collapse fields and a rich authoring path would lose information.

  2. 02

    Build the timing to match the feel

    Work backwards from what the player should perceive. A wind-up before the effect is CastTime. A window during which the effect persists is ActiveDuration. A lockout after the effect where the player is committed but the ability is done is RecoveryTime. The wait before it can be used again is Cooldown. Leaving a field at zero removes that phase from the machine entirely.

  3. 03

    Decide the cancellation contract

    Default None means the ability, once started, always finishes. FreelyCancellable lets the player back out during cast or active. Fragile adds damage and movement so a channel breaks under pressure. Remember that cause-based flags only work if both the definition and the caller assert them, so a project that never passes OnDamage into TryCancel will never see a Fragile ability break on damage.

  4. 04

    Implement the resource provider

    This package will not spend anything for you. Implement IAbilityResourceProvider over whatever holds the entity's pools, and check HasResource before calling TryActivate and ConsumeResource after it succeeds. Costs marked PerTick need the same treatment on each tick of a channel.

  5. 05

    Wire the condition gates

    Before activating, verify that every RequiredConditions id is present on the entity and that no BlockingConditions id is, using the condition service's Has. After a successful activation, apply each of the AppliedConditions ids. Put that in one integration component instead of at every call site, so the gating stays consistent.

  6. 06

    Grant and tick

    Grant abilities to an entity id at spawn or on unlock; Grant is idempotent per ability id, so re-granting returns the instance the entity already had. Then call TickAll once per frame. Nothing advances on its own, and an unticked ability that goes on cooldown stays there forever.

  7. 07

    Drive the HUD from events

    PhaseChanged is the event a cooldown sweep or cast bar wants, and CooldownReady is the one that un-greys the button. For a progress readout, read PhaseProgress rather than deriving it, because it is already normalised against the current phase's authored duration.

Surface

Key types

IAbilityService

service

Grants abilities to entities and drives their lifecycle. Grant is idempotent per ability id, returning the existing instance rather than creating a duplicate.

  • IAbilityInstance Grant(string entityId, IAbilityDefinition definition)
  • bool Revoke(string entityId, AbilityId abilityId)
  • IReadOnlyList<IAbilityInstance> GetAbilities(string entityId)
  • IAbilityInstance GetAbility(string entityId, AbilityId abilityId)
  • bool TryActivate(string entityId, AbilityId abilityId)
  • bool TryCancel(string entityId, AbilityId abilityId, AbilityCancelPolicy reason)
  • void Tick(string entityId, float deltaTime) / void TickAll(float deltaTime)
  • event Action<string, IAbilityInstance> Activated, Cancelled, Completed, CooldownReady
  • event Action<string, IAbilityInstance, AbilityPhase> PhaseChanged

IAbilityDefinition

interface

The static description: identity, category, timing, costs, cancel policy, the three condition lists, optional input and interaction bindings, and tags.

  • AbilityId Id { get; } / string DisplayName { get; } / string Description { get; }
  • AbilityCategory Category { get; } / AbilityTiming Timing { get; }
  • IReadOnlyList<AbilityCost> Costs { get; }
  • AbilityCancelPolicy CancelPolicy { get; }
  • IReadOnlyList<ConditionId> AppliedConditions, RequiredConditions, BlockingConditions { get; }
  • string InteractionVerb { get; } / string InputAction { get; }
  • IReadOnlyList<string> Tags { get; }

IAbilityInstance

interface

The live per-entity state: current phase, time and normalised progress within it, available charges, regen countdown, whether it can activate now, and toggle state.

  • IAbilityDefinition Definition { get; }
  • AbilityPhase Phase { get; } / float PhaseTimeRemaining { get; } / float PhaseProgress { get; }
  • int Charges { get; } / float ChargeRegenRemaining { get; }
  • bool CanActivate { get; } / bool IsToggled { get; }
  • bool TryActivate()
  • bool TryCancel(AbilityCancelPolicy reason)
  • void Tick(float deltaTime)

IAbilityResourceProvider

interface

The seam to whatever holds an entity's resource pools. The project implements it so stamina, energy or a grenade count can all be ability costs without this package knowing what they are.

  • float GetResource(string entityId, StatId resourceId)
  • bool HasResource(string entityId, StatId resourceId, float amount)
  • bool ConsumeResource(string entityId, StatId resourceId, float amount)

IAbilityInputBinding

interface

Bridges input or interaction to activation. Carries the key it watches, the ability it fires, and the three pieces of per-frame state a charged or held ability needs.

  • string BindingKey { get; } / AbilityId AbilityId { get; }
  • bool IsHeld { get; } / bool WasPressed { get; } / bool WasReleased { get; }

AbilityTracker

class

The default service. Holds instances per entity id, wires each instance's internal events out to the service-level events with the entity id attached, and ticks them.

AbilityInstance

class

The default instance and the phase machine itself. Also exposes instance-level events, OnFired, OnPhaseChanged with both old and new phase, OnCompleted and OnCancelled, for callers holding the instance directly.

  • event Action<AbilityInstance> OnFired, OnCompleted, OnCancelled
  • event Action<AbilityInstance, AbilityPhase, AbilityPhase> OnPhaseChanged

AbilityPhase

enum

Idle, Casting, Active, Recovery, Cooldown. Only the phases whose timing is non-zero are actually entered for any given ability.

AbilityCategory

enum

Instant, Channeled, Toggle, Passive, Charged. Toggle changes the machine's behaviour; the rest differ mainly in how their timing is filled in.

AbilityTiming

struct

The phase schedule: cast time, active duration, recovery time, cooldown, maximum charges and the seconds to regain one charge.

  • float CastTime, ActiveDuration, RecoveryTime, Cooldown, ChargeRegenTime; int MaxCharges
  • static AbilityTiming Instant(float cooldown)
  • static AbilityTiming Channeled(float castTime, float activeDuration, float cooldown)
  • static AbilityTiming Charged(float maxChargeTime, float cooldown)

AbilityCancelPolicy

enum

A Flags enum used both as the definition's declaration and as the reason for a cancel attempt. DuringCast and DuringActive are phase-based; OnDamage, OnMove, OnOtherAbility and OnWeaponSwitch are cause-based. Presets: FreelyCancellable and Fragile.

AbilityCost

struct

One resource cost: which stat, how much, and whether it is paid once on activation or on every tick of a channel.

  • StatId ResourceId; float Amount; bool PerTick

AbilityId

struct

A trimmed, case-insensitive string id with implicit conversion both ways, so a literal works anywhere an AbilityId is expected.

  • string Value
  • bool IsEmpty { get; }

AbilityDefinitionBuilder

class

Fluent construction of an ability definition in code. Produces an immutable definition, and is also what the authoring asset materialises so a caller can layer changes before building.

  • WithCategory / WithTiming / WithCancelPolicy / WithDescription
  • AddCost(StatId resourceId, float amount, bool perTick = false)
  • AddAppliedCondition / AddRequiredCondition / AddBlockingCondition
  • WithInputAction(string) / WithInteractionVerb(string) / AddTag(string)
  • IAbilityDefinition Build()

DefaultAbilities

class

The shipped ability id vocabulary grouped by role: tactical throws and melee, equipment such as DeployTurret and UseStimPack, movement such as Dash and WallClimb, defensive such as ActivateShield and Cloak, and support such as Revive, MarkTarget and HackDevice.

Surface

Authoring assets

AbilityDefinitionAsset

asset

The authoring asset. Identity and description, category, the six timing fields as discrete values, cancel policy, cost rows, the three condition id lists, and the input and interaction bindings. Created via Assets, Create, Tools, ZOA, Abilities, Ability Definition.

  • string AbilityId; string Description; AbilityCategory Category
  • float CastTime, ActiveDuration, RecoveryTime, Cooldown, ChargeRegenTime; int MaxCharges
  • AbilityCancelPolicy CancelPolicy; IReadOnlyList<SerializedAbilityCost> Costs
  • IReadOnlyList<string> AppliedConditions, RequiredConditions, BlockingConditions
  • string InputAction; string InteractionVerb
  • IAbilityDefinition ToRuntimeDefinition() / AbilityDefinitionBuilder ToBuilder()

Usage

Examples

Defining a two-charge dashcsharp
using ZOA.Abilities.Core.Definitions;
using ZOA.Abilities.Core.Models;
using ZOA.Stats;

var dash = new AbilityDefinitionBuilder("dash", "Combat Dash")
    .WithDescription("Burst forward and briefly ignore incoming fire.")
    .WithCategory(AbilityCategory.Instant)
    .WithTiming(new AbilityTiming
    {
        CastTime = 0f,
        ActiveDuration = 0.35f,
        RecoveryTime = 0.15f,
        Cooldown = 1.5f,
        // Two banked uses, but a spent charge takes far longer to come
        // back than the short per-use cooldown.
        MaxCharges = 2,
        ChargeRegenTime = 8f,
    })
    .AddCost(new StatId("stamina"), 15f)
    .AddAppliedCondition("dash_iframes")
    .AddBlockingCondition("stunned")
    .WithInputAction("movement_ability")
    .AddTag("movement")
    .Build();
Gating activation on resources and conditionscsharp
using ZOA.Abilities.Core.Contracts;
using ZOA.Abilities.Core.Models;
using ZOA.Conditions.Core.Contracts;

public sealed class AbilityActivationGate
{
    private readonly IAbilityService _abilities;
    private readonly IConditionService _conditions;
    private readonly IAbilityResourceProvider _resources;

    public AbilityActivationGate(
        IAbilityService abilities,
        IConditionService conditions,
        IAbilityResourceProvider resources)
    {
        _abilities = abilities;
        _conditions = conditions;
        _resources = resources;
    }

    // The instance itself only checks phase and charges. Cost and
    // condition gating lives here, in one place, rather than at every
    // call site that wants to fire something.
    public bool TryUse(string entityId, AbilityId abilityId)
    {
        var instance = _abilities.GetAbility(entityId, abilityId);
        if (instance == null || !instance.CanActivate) return false;

        var def = instance.Definition;

        for (int i = 0; i < def.BlockingConditions.Count; i++)
            if (_conditions.Has(entityId, def.BlockingConditions[i])) return false;

        for (int i = 0; i < def.RequiredConditions.Count; i++)
            if (!_conditions.Has(entityId, def.RequiredConditions[i])) return false;

        for (int i = 0; i < def.Costs.Count; i++)
        {
            var cost = def.Costs[i];
            if (cost.PerTick) continue;
            if (!_resources.HasResource(entityId, cost.ResourceId, cost.Amount))
                return false;
        }

        if (!instance.TryActivate()) return false;

        for (int i = 0; i < def.Costs.Count; i++)
        {
            var cost = def.Costs[i];
            if (!cost.PerTick)
                _resources.ConsumeResource(entityId, cost.ResourceId, cost.Amount);
        }

        for (int i = 0; i < def.AppliedConditions.Count; i++)
            ApplyCondition(entityId, def.AppliedConditions[i], abilityId.Value);

        return true;
    }

    private void ApplyCondition(string entityId, ConditionId id, string source) { }
}
Cancelling under a causecsharp
using ZOA.Abilities.Core.Contracts;
using ZOA.Abilities.Core.Models;

// The cause has to be asserted by the caller AND listed on the
// definition. A Fragile channel breaks here; one authored as None
// does not, and TryCancel returns false.
public void OnDamageTaken(string entityId)
{
    foreach (var instance in _abilities.GetAbilities(entityId))
    {
        if (instance.Phase == AbilityPhase.Casting || instance.Phase == AbilityPhase.Active)
        {
            _abilities.TryCancel(
                entityId,
                instance.Definition.Id,
                AbilityCancelPolicy.OnDamage);
        }
    }
}

// Cancelling never refunds the charge that activation spent, and the
// ability still pays its cooldown if it has one.
Driving a cooldown readoutcsharp
using ZOA.Abilities.Core.Contracts;
using ZOA.Abilities.Core.Models;

_abilities.PhaseChanged += (entityId, instance, phase) =>
{
    if (entityId != _localPlayerId) return;
    _hud.SetAbilityPhase(instance.Definition.Id, phase);
};

// Completed and CooldownReady both fire when the cooldown expires and
// the instance returns to Idle; CooldownReady is the one to hang the
// "button is usable again" flash on.
_abilities.CooldownReady += (entityId, instance) =>
{
    if (entityId == _localPlayerId)
        _hud.FlashReady(instance.Definition.Id);
};

void UpdateHud(IAbilityInstance instance)
{
    // PhaseProgress is already normalised against the current phase's
    // authored duration: no need to divide by the timing yourself.
    _hud.SetSweep(instance.Definition.Id, instance.PhaseProgress);
    _hud.SetCharges(instance.Definition.Id, instance.Charges);
}
Loading an authored asset at runtimecsharp
using ZOA.Abilities.Core.Contracts;
using ZOA.Abilities.Unity.Definitions;

// The asset is authoring data. Runtime systems consume the built
// IAbilityDefinition so the runtime side stays free of UnityEngine
// types where it can.
IAbilityDefinition definition = asset.ToRuntimeDefinition();
var instance = _abilities.Grant(entityId, definition);

// ToBuilder instead of ToRuntimeDefinition when you want to layer
// something on before building, such as a difficulty-scaled cooldown.
var scaled = asset.ToBuilder()
    .WithTiming(new ZOA.Abilities.Core.Models.AbilityTiming
    {
        CastTime = asset.CastTime,
        ActiveDuration = asset.ActiveDuration,
        RecoveryTime = asset.RecoveryTime,
        Cooldown = asset.Cooldown * difficultyMultiplier,
        MaxCharges = asset.MaxCharges,
        ChargeRegenTime = asset.ChargeRegenTime,
    })
    .Build();

Tooling

Editor tools

Ability Wizard

Tools / ZOA / Advanced / Define / Characters / Abilities / Ability Wizard

Step-based authoring for ability definitions covering identity, timing, costs and conditions, and bindings, built on the Foundry wizard shell and routed through the Workbench.

Abilities Workbench module

ZOA Platform Workbench, Abilities module

Four capabilities: an Abilities browser that edits, validates, duplicates and deletes definitions with their timing, costs and bindings; a Defaults Browser over the built-in ability ids; a Phase Guide documenting phases, categories and cancellation policies; and an architecture Overview.

AbilityWizardAssetEmitter

The headless save path behind the wizard. Writes an AbilityDefinitionAsset from a wizard snapshot into the generated abilities folder, so automation and tests can produce the same assets without the interactive window.

Read this

Notes and caveats

See also