# @yagejs-addons/abilities

Phase-based abilities plus a concrete hit contract for YAGE. The root entry is
headless and requires `@yagejs/core` + `@yagejs/physics`. The optional
`@yagejs-addons/abilities/input` entry requires `@yagejs/input`. No renderer,
Pixi, presenter, plugin, or runtime dependency is bundled.

## Install and entries

```bash
npm install @yagejs-addons/abilities @yagejs/core @yagejs/physics
# Only for AbilityDriver:
npm install @yagejs/input
```

```ts
import {
  Abilities,
  Facing,
  hitbox,
  Health,
  HitReceiver,
} from "@yagejs-addons/abilities";
import {
  AbilityDriver,
  AbilityDriverComponent,
} from "@yagejs-addons/abilities/input";
```

Register `PhysicsPlugin` for hitboxes, projectiles, touch damage, and default
knockback. The addon itself has no plugin.

## Minimal setup

```ts yage-context="scene"
import { Entity, ProcessComponent, Transform, trait } from "@yagejs/core";
import { ColliderComponent, RigidBodyComponent } from "@yagejs/physics";
import {
  Abilities,
  Facing,
  Health,
  Hittable,
  HitReceiver,
  Stagger,
  hitbox,
} from "@yagejs-addons/abilities";
import type { AbilityDef, Hit, HitResult } from "@yagejs-addons/abilities";

const SLASH: AbilityDef = {
  id: "slash",
  cooldown: 0.45,
  timeline: [
    hitbox({
      from: 0.08,
      to: 0.2,
      shape: { type: "capsule", halfHeight: 18, radius: 10, axis: "x" },
      offset: { x: 30, y: 0 },
      hit: { damage: 18, knockback: 260, stun: 0.3 },
    }),
  ],
};

@trait(Hittable)
class Fighter extends Entity {
  receiveHit(hit: Hit): HitResult {
    return this.get(HitReceiver).receive(hit);
  }
  setup(): void {
    this.add(new Transform());
    this.add(new ProcessComponent());
    this.add(new RigidBodyComponent({ type: "dynamic" }));
    this.add(new ColliderComponent({ shape: { type: "circle", radius: 12 } }));
    this.add(new Facing()); // default +x aim when `aim` is omitted
    this.add(new Health({ max: 100 }));
    this.add(new Stagger());
    this.add(new HitReceiver({ team: "player", iframes: 0.15 }));
    this.add(new Abilities([SLASH]));
  }
}

const fighter = scene.spawn(Fighter);
fighter.get(Abilities).send("slash");
```

## Definition schema

```ts
import type {
  AbilityStep,
  CancelWindow as BaseCancelWindow,
  PhaseDef as BasePhaseDef,
  StepContext,
} from "@yagejs-addons/abilities";

type Scalar = number | ((ctx: StepContext) => number);
type AbilityMatcher = string | { readonly tag: string };

interface AbilityDefBase {
  id: string;
  tags?: readonly string[];
  lane?: string; // default "main"
  priority?: number; // default 0
  cooldown?: Scalar; // finite seconds, resolved once on entry; <= 0 disables cooldown
  cancels?: readonly CancelWindow[];
  entry?: Readonly<Record<string, string>>; // intent -> phase
}

interface TimelineAbilityDef extends AbilityDefBase {
  timeline: readonly AbilityStep[];
  duration?: number;
}

interface PhasedAbilityDef extends AbilityDefBase {
  phases: Readonly<Record<string, PhaseDef>>;
  start?: string; // default first own phase key
}

type AbilityDef = TimelineAbilityDef | PhasedAbilityDef;

interface PhaseDef extends BasePhaseDef {
  timeline: readonly AbilityStep[];
  duration?: number;
  hold?: boolean | { max?: number };
  priority?: number;
  cancels?: readonly CancelWindow[];
  on?: Readonly<Record<string, string | PhaseTransition>>;
  next?: string;
  after?: { at: number; to: string };
}

type PhaseTransition =
  | { to: string; from?: number; until?: number; for?: never }
  | { to: string; from?: number | "end"; for: number; until?: never };

interface CancelWindow extends BaseCancelWindow {
  from: number;
  to?: number; // default phase end
  into?: readonly AbilityMatcher[]; // default any; strings match def ids
}
```

A `timeline` definition is one phase named `"main"`. A phased definition is
one run across named phases. `on` maps intents to transitions. A declared
intent outside its time guard returns `"noMatch"` and does not fall through to
definition entry. An undeclared intent may enter another definition.

`until` past the phase duration creates linger for the excess. For a window
that starts when a fixed phase finishes, prefer
`{ to, from: "end", for: seconds }`. `from: "end"` is invalid on a hold phase.
A matching intent during linger creates a new activation at the target phase
without a cooldown check or re-arm. These `on` ranges are transition windows.

`release(intent, { lane })` checks only that lane. Without a lane, the first
matching hold is released. A non-matching lane returns `false`.

Static cooldowns are checked when definitions compile. Function cooldowns run
once at activation with the live `StepContext`; their result must be finite
before cooldown state changes. Finite values at or below zero mean no cooldown.

`hold` binds the phase to the intent that entered it. `release(intent)` flows
to `next`, or completes the run when `next` is absent. `hold.max` caps the
phase. `after` advances on its own phase-local clock.

One activation occupies each lane. An idle lane admits entry. A busy lane
admits a strictly higher entry-phase priority or a matching `cancels` window.
`{ tag }` cancel matchers test `AbilityDef.tags`; string matchers test resolved
definition ids, not entry aliases.

## Timeline steps

```ts
import type { Entity, SceneTime } from "@yagejs/core";
import type {
  Abilities,
  AbilityActivation,
  AbilityDef,
  PointStep as BasePointStep,
  StepContext as BaseStepContext,
  WindowStep as BaseWindowStep,
} from "@yagejs-addons/abilities";

interface PointStep<P extends object = object> extends BasePointStep<P> {
  kind: string;
  at: number;
  params: P;
  hooks: { fire(params: P, ctx: StepContext): void };
}

interface WindowStep<P extends object = object> extends BaseWindowStep<P> {
  kind: string;
  from: number;
  to: number | "end";
  every?: number;
  params: P;
  hooks: {
    enter?(params: P, ctx: StepContext): void;
    exit?(params: P, ctx: StepContext, cancelled: boolean): void;
    tick?(params: P, ctx: StepContext): void;
    onDisable?(params: P, ctx: StepContext): void;
    onEnable?(params: P, ctx: StepContext): void;
  };
}

interface StepContext extends BaseStepContext {
  entity: Entity;
  def: AbilityDef;
  abilities: Abilities;
  activation: AbilityActivation;
  time: SceneTime;
}
```

`"end"` means the phase boundary. It is elastic in a hold phase. Point steps
fire once. Window `enter`/`exit` fire once; `tick` fires at `every` intervals
strictly before the end. Phase transitions and natural completion close
windows with `cancelled: false`; cancellation and interruption pass `true`.
When `Abilities` becomes ineffective, `onDisable` temporarily releases an open
window's live resources. `onEnable` restores them when the component becomes
effective again. These hooks do not close the window or reset its clock.

Custom step factory:

```ts
import { Vec2 } from "@yagejs/core";
import { RigidBodyComponent } from "@yagejs/physics";
import { Facing, defineStep } from "@yagejs-addons/abilities";

const lunge = defineStep<{ speed: number }>("lunge", {
  enter({ speed }, ctx) {
    const direction = ctx.entity.get(Facing).unit;
    ctx.entity.get(RigidBodyComponent).setVelocity(direction.scale(speed));
  },
  exit(_params, ctx) {
    ctx.entity.get(RigidBodyComponent).setVelocity(Vec2.ZERO);
  },
});

const step = lunge({ from: 0.1, to: 0.25, speed: 300 });
```

For a step with no params, write `defineStep<Record<never, never>>(...)` or omit
the type argument. `Record<string, never>` does NOT work: its `[string]: never`
index signature also covers the timing fields `defineStep` adds to the factory
argument (`at` for a point step; `from` / `to` / `every?` for a window step),
forcing them to `never` so the factory can't be called.

Built-in step factories:

- `anim({ at, name })`: starts the named renderer-free `KeyframeAnimator`
  animation. Sprite and renderer animation controllers remain game-owned.
- `hitbox<TData>({ from, to, every?, shape, offset?, aim?, team?, hit, tags?, layers?, mask?, follow? })`.
- `spawn<TClass, TData>({ at, entity, params, acquire?, position?, aim?, team?, hit?, tags?, offset? })`.
- `guard<TData>({ from, to, outcome, policy, punish? })`.
- `parry({ from, to, punish? })`: always negates as `"parried"`.
- `block({ from, to, damageScale?, knockbackScale?, stunScale? })`: mutates data and continues as `"hit"`; scales default to 0.
- `invulnerable({ from, to })`.
- `slowmo({ from, to, scale, includeOwner?, key?, label? })`: cancellation-bound window.
- `slowmo({ at, for, scale, includeOwner?, key?, label? })`: raw-time request that may outlive the phase or cancellation.
- `staggerMotion({ from, to, direction, knockback, stun })`.
- `staggerReaction({ direction, knockback, stun, priority? })`: returns the default forced reaction definition. `priority` defaults to `REACTION_PRIORITY` (100).

Delivery steps omit `aim` to read sibling `Facing`; explicit `Aim` is a
`Vec2Like` or fire-time `(ctx) => Vec2Like`. `resolveAim` normalizes and throws
for missing/zero direction. `aimAt(getTarget, { face? })` builds a fire-time
target resolver.

## `Abilities` API

`Abilities` requires a sibling `ProcessComponent`: its phases, linger, and
cooldowns run as processes on it.

```ts
import type { ProcessClock } from "@yagejs/core";
import { Abilities as BaseAbilities } from "@yagejs-addons/abilities";
import type {
  AbilitiesOptions as BaseAbilitiesOptions,
  AbilityActivation,
  AbilityDef,
  AbilityReleaseOptions as BaseAbilityReleaseOptions,
} from "@yagejs-addons/abilities";

declare class Abilities extends BaseAbilities {
  constructor(defs: readonly AbilityDef[], options?: AbilitiesOptions);
  readonly clock: ProcessClock; // options.clock, default "fixed"
  addDefinitions(defs: readonly AbilityDef[]): void;
  replaceDefinitions(defs: readonly AbilityDef[]): void;

  send(intent: string, options?: { data?: unknown; lane?: string }): PlayResult;
  canSend(
    intent: string,
    options?: { lane?: string; interrupts?: boolean },
  ): boolean;
  release(intent: string, options?: AbilityReleaseOptions): boolean;
  force(def: AbilityDef): PlayResult;
  cancel(lane?: string): void;
  cancelAll(): void;

  active(lane?: string): AbilityActivation | null;
  activeId(lane?: string): string | null;
  isActive(lane?: string): boolean;
  elapsed(lane?: string): number | null;
  cooldownRemaining(id: string): number;
  cooldownRatio(id: string): number;
}

type PlayRejection = "cooldown" | "busy" | "noMatch";
interface AbilityReleaseOptions extends BaseAbilityReleaseOptions {
  lane?: string;
}
type PlayResult =
  | { readonly ok: true; readonly activation: AbilityActivation }
  | { readonly ok: false; readonly reason: PlayRejection };

interface AbilitiesOptions extends BaseAbilitiesOptions {
  // "fixed" (default): timelines, linger, and cooldowns advance on the
  // fixed timestep, matching physics timing. "frame": rendered-frame time,
  // for purely presentation-driven timelines. Other processes on the
  // sibling ProcessComponent keep the clocks they were scheduled with
  // (KeyframeAnimator playback stays on rendered-frame time unless the
  // animation's def sets `clock: "fixed"`). Input is sampled per
  // rendered frame; an intent sent this frame starts a timeline that
  // advances on the fixed step. `abilities.clock` is readable — custom
  // steps pass it to `pc.run(p, { clock })` for their own gameplay timers.
  clock?: "frame" | "fixed";
}
```

The other addon timers always count fixed-step time regardless of this
option: `HitReceiver` i-frames, `Stagger`, `TouchDamage` intervals,
`Projectile` lifetime, and hitbox `follow` tracking (the components run in
`fixedUpdate`).

Intent resolution: active phase `on` -> linger -> cross-definition entry.
Unknown intents throw. `data` becomes `activation.payload` on entry or
transition.

`canSend` excludes priority interruption by default so buffered sends wait.
`{ interrupts: true }` asks whether direct `send` would succeed including
priority preemption.

A `send()` from a component's `fixedUpdate` first advances on the next fixed
step: fixed-clock processes run before component fixed updates. See the core
frame-order reference when coordinating physics and timeline entry.

`force` is for imposed reactions. It skips cooldown check/arm and permits a
same-definition restart. Player/AI actions use `send`.

```ts
import type { Entity } from "@yagejs/core";
import type {
  AbilityActivation as BaseAbilityActivation,
  AbilityDef,
} from "@yagejs-addons/abilities";

interface AbilityActivation extends BaseAbilityActivation {
  readonly def: AbilityDef;
  readonly lane: string;
  readonly entity: Entity;
  readonly phase: string;
  readonly phaseElapsed: number;
  readonly phaseDuration: number;
  readonly isHolding: boolean;
  isStepActive(kind: string): boolean;
  readonly elapsed: number;
  elapsedIn(phase: string): number;
  readonly payload: unknown;
  readonly state: "active" | "completed" | "cancelled";
  readonly forced: boolean;
}
```

Stable entity-event tokens:

- `AbilityStarted`: `{ activation }`.
- `AbilityPhaseChanged`: `{ activation, from, to }`.
- `AbilityEnded`: `{ activation, cancelled }`.

Events are deferred until the current runner entry settles. Listeners observe
settled lane state. One run has one start/end pair; phase changes do not end it.

Disabling `Abilities`, or deactivating its entity, pauses active phases, linger,
and cooldown clocks. It calls the disable hooks of open windows without
changing any sibling component's `enabled` value. `send`, `canSend`, `force`,
and `release` refuse new work while dormant. Enabling `Abilities` restores the
same activation, clocks, and open-window effects. Built-in hitbox, guard,
invulnerability, slow-motion, and stagger windows implement this lifecycle.

Point fire and window enter, tick, and exit hooks are attributed callbacks.
The first throwing hook stops the operation and is rethrown; later hooks do
not run. Closing a window attributes its exit as `Ability window exit hook`.

### Definition replacement

`addDefinitions(next)` validates the complete existing-plus-next set before
installing it. It preserves active runs, cooldowns, linger, and existing
definitions. Any collision leaves the runner untouched.

`replaceDefinitions(next)` eagerly compiles and validates `next`. A failure
leaves the runner untouched. Success cancels all lanes, clears linger and every
cooldown process, installs the new id/intent indexes, then delivers queued
`AbilityEnded` events. New definitions start ready.

The method does not reload `AbilityDriver`:

```ts
import type { Entity } from "@yagejs/core";
import { Abilities } from "@yagejs-addons/abilities";
import type { AbilityDef } from "@yagejs-addons/abilities";
import { AbilityDriverComponent } from "@yagejs-addons/abilities/input";
import type { AbilityDriverOptions } from "@yagejs-addons/abilities/input";

interface Loadout {
  defs: readonly AbilityDef[];
  input: AbilityDriverOptions;
}

function equip(entity: Entity, loadout: Loadout): void {
  const abilities = entity.get(Abilities);
  const driverComponent = entity.get(AbilityDriverComponent);

  abilities.replaceDefinitions(loadout.defs);
  driverComponent.replace(loadout.input);
}
```

Driver replacement discards edges, pending sends, and held-input ownership.
Held actions must be released and pressed again. No in-place driver reload,
tag-filtered partial replacement, or bulk process-tag removal is included.

## Optional input entry

```ts
import type {
  AbilityBinding as BaseAbilityBinding,
  AbilityDriverOptions as BaseAbilityDriverOptions,
  AbilityFireContext,
  AbilityGestureContext,
  AbilitySend as BaseAbilitySend,
} from "@yagejs-addons/abilities/input";

type AbilityGesture = "press" | "tap" | "hold" | "release";
type AbilityData = unknown;

interface AbilitySend<
  TAction extends string,
  TIntent extends string,
> extends BaseAbilitySend<TAction, TIntent> {
  send: TIntent;
  buffer?: number; // raw seconds from this interaction's edge
  data?:
    | AbilityData
    | ((ctx: AbilityGestureContext<TAction, TIntent>) => unknown);
}

interface AbilityTap<
  TAction extends string,
  TIntent extends string,
> extends AbilitySend<TAction, TIntent> {
  within?: number;
}

interface AbilityHold<
  TAction extends string,
  TIntent extends string,
> extends AbilitySend<TAction, TIntent> {
  at?: number;
  fromNeutral?: boolean;
  resume?: boolean;
  release?: AbilitySend<TAction, TIntent>;
}

interface AbilityBinding<
  TAction extends string,
  TIntent extends string,
> extends BaseAbilityBinding<TAction, TIntent> {
  lane?: string;
  press?: AbilitySend<TAction, TIntent>;
  tap?: AbilityTap<TAction, TIntent>;
  hold?: AbilityHold<TAction, TIntent>;
  gate?: (ctx: AbilityFireContext<TAction, TIntent>) => boolean;
}

interface AbilityDriverOptions<
  TAction extends string,
  TIntent extends string,
> extends BaseAbilityDriverOptions<TAction, TIntent> {
  defaults?: { tapWithin?: number; holdAt?: number };
  bindings: Readonly<
    Partial<Record<TAction, AbilityBinding<TAction, TIntent>>>
  >;
  beforeFire?: (ctx: AbilityFireContext<TAction, TIntent>) => void;
}
```

```ts
import { Entity, ProcessComponent } from "@yagejs/core";
import { Abilities } from "@yagejs-addons/abilities";
import type { AbilityDef } from "@yagejs-addons/abilities";
import { AbilityDriverComponent } from "@yagejs-addons/abilities/input";

declare const defs: readonly AbilityDef[]; // attack, charge, charge-release, dash

const options = {
  defaults: { tapWithin: 0.22, holdAt: 0.5 },
  bindings: {
    attack: {
      tap: { send: "attack", buffer: 0.18 },
      hold: {
        send: "charge",
        fromNeutral: true,
        resume: true,
        release: { send: "charge-release", buffer: 0.4 },
      },
    },
    dash: { press: { send: "dash", buffer: 0.12 } },
  },
};

class Fighter extends Entity {
  setup(): void {
    this.add(new ProcessComponent()); // Abilities runs its phases on it
    this.add(new Abilities(defs));
    this.add(new AbilityDriverComponent(options));
  }
}
```

`AbilityDriverComponent` resolves `InputManagerKey`, calls the plain driver's
`update`, and disposes listeners, recorded edges, buffers, and holds when the
component becomes dormant or is removed. It binds again when the component or
entity becomes active. `replace(options)` swaps the owned driver without
replacing the component. Use `new AbilityDriver(input, abilities, options)`
only when another object owns those lifecycle calls; call its `update()` in
normal component update and `dispose()` on removal. Gesture timing,
buffer deadlines, and `heldFor` use `InputManager.getClockTime()` raw seconds.
Ability phases use scaled scene time.

`press` triggers on down. `tap` triggers on release within its threshold.
`hold` triggers at its threshold and suppresses tap. `fromNeutral` requires the
lane to be idle both on press and at the threshold. `resume` retries a cancelled
hold while the action remains held. Omit `hold.release` to call
`abilities.release(hold.send, { lane: activation.lane })` automatically; include it when release is a
separate intent.

Every interaction accepts one edge-relative `buffer`. Retry uses polite
`canSend`, so it never preempts by priority. Data resolves at the input edge
and is retained across retries. `gate` runs before each attempt;
`beforeFire` runs after admission and immediately before `send`.

## Hit types and receipt

```ts
import type { Entity, TraitToken, Vec2 } from "@yagejs/core";
import type {
  Hit as BaseHit,
  HitContact as BaseHitContact,
  StandardHitData as BaseStandardHitData,
} from "@yagejs-addons/abilities";

interface StandardHitData extends BaseStandardHitData {
  damage?: number;
  knockback?: number; // px/s
  stun?: number; // seconds
  hitstop?: number; // delivery carries; game applies
}

interface Hit<TData = StandardHitData> extends BaseHit<TData> {
  readonly source: Entity;
  readonly direction: Vec2; // unit, from the delivery origin toward the target
  readonly team?: string;
  readonly tags: readonly string[];
  readonly data: TData;
  readonly contact?: HitContact; // where the hit touched the target, when measured
}

interface HitContact extends BaseHitContact {
  readonly point: Vec2; // world px, on the target collider's surface
  readonly normal: Vec2; // unit, out of the target's surface toward the attacking shape
}

type HitResult = "hit" | "ignored" | "blocked" | "parried";
declare const Hittable: TraitToken<{ receiveHit(hit: Hit): HitResult }>;
```

`createHitDelivery({ source, team?, tags?, data? })` returns
`{ deliver(target, from, contact?): HitResult }`. It skips the source and
non-`Hittable` entities. Data is shallow-copied per victim so mutating stages
do not leak between contacts. `contact` travels on the hit unchanged.

Contact geometry. `hitbox` and a sensor `Projectile` measure the sensor/target
collider pair that fired the trigger (`ColliderComponent.contactWith`); a
solid `Projectile` or `TouchDamage` body uses the collision's own contact;
repeat hits (`every`, the touch interval) measure again. `hit.contact` is
absent when nothing could be measured. A hitbox whose window opens already
overlapping the target reports a surface point from the current overlap, not
the first impact of the swing. `direction` is unchanged by all of this. For a
custom overlap source:

```ts yage-context="entity"
import { Transform } from "@yagejs/core";
import { ColliderComponent } from "@yagejs/physics";
import {
  createHitDelivery,
  queryHitContact,
  resolveHitContact,
} from "@yagejs-addons/abilities";

const collider = entity.get(ColliderComponent);
const delivery = createHitDelivery({ source: entity, data: { damage: 5 } });

collider.onTrigger((ev) => {
  const origin = entity.get(Transform).worldPosition;
  if (ev.entered)
    delivery.deliver(ev.other, origin, resolveHitContact(collider, ev));
});
// resolveHitContact(self, ev): a CollisionEvent's contactPoint/contactNormal
// when present, else contactWith on the event's shape pair.
// queryHitContact(self, { otherCollider, selfShapeIndex, otherShapeIndex }):
// re-measure a remembered pair for a repeat delivery.
```

Presentation reads it from either side:

```ts
import { Transform } from "@yagejs/core";
import type { Entity, Vec2 } from "@yagejs/core";
import { HitDealt } from "@yagejs-addons/abilities";

declare const attacker: Entity;
declare const sparks: { burstAt(at: Vec2, normal?: Vec2): void }; // game presentation

attacker.on(HitDealt, ({ target, contact }) => {
  const at = contact?.point ?? target.get(Transform).worldPosition;
  sparks.burstAt(at, contact?.normal);
});
```

```ts
import { HitReceiver as BaseHitReceiver } from "@yagejs-addons/abilities";
import type {
  Hit,
  HitReceiverOptions as BaseHitReceiverOptions,
  HitResult,
  HitStage,
  StandardHitData,
} from "@yagejs-addons/abilities";

interface HitReceiverOptions<
  TData = StandardHitData,
> extends BaseHitReceiverOptions<TData> {
  team?: string;
  iframes?: number;
  filter?: (hit: Hit<TData>, receiver: HitReceiver<TData>) => boolean;
  steps?: readonly HitStage<TData, HitReceiver<TData>>[];
}

declare class HitReceiver<
  TData = StandardHitData,
> extends BaseHitReceiver<TData> {
  team: string | undefined;
  get iframesRemaining(): number;
  get isInvulnerable(): boolean;
  receive(hit: Hit<TData>): HitResult;
}
```

Receipt order: filter -> i-frames -> guards -> ordered stages. The default
filter rejects a hit only when both teams exist and match. Default stages are
`damageStep` then `reactionStep`. `damageStep` calls sibling `Health` when
`damage` exists. `reactionStep` reacts only when `stun` is positive. With a
sibling `Abilities`, it prefers a forced `staggerReaction`; a sibling
`Stagger` is the direct fallback when no runner is present. Knockback without
positive stun does not start a reaction.

Events:

- `HitReceived`: `{ hit, guardOutcomes }` after a hit lands and stages run.
- `HitGuarded`: `{ hit, outcome }` for every engaged guard.
- `HitDealt`: attacker-side `{ result, data, target, ability?, contact? }`
  from `createReportingDelivery` and built-in reporting paths. `contact` is
  captured before the receiver runs, so a killing hit still carries it.
- `HealthDamaged`: `{ amount, hp }`.
- `HealthHealed`: `{ amount, hp }`.
- `HealthDied`: no payload; once when HP reaches 0.

```ts
import type { Vec2Like } from "@yagejs/core";
import {
  Health as BaseHealth,
  Stagger as BaseStagger,
} from "@yagejs-addons/abilities";

declare class Health extends BaseHealth {
  hp: number;
  max: number;
  get isDead(): boolean;
  takeDamage(amount: number): number; // actual applied amount
  heal(amount: number): number; // actual applied amount; dead cannot heal
}

declare class Stagger extends BaseStagger {
  get active(): boolean;
  begin(options: {
    direction: Vec2Like;
    knockback: number;
    stun: number;
  }): void;
  end(): void;
}
```

Controllers must not write velocity while `Stagger.active`. Give every
game-specific movement window one shared kind, such as `"velocity"`, and let
the controller write idle velocity only when
`!abilities.active()?.isStepActive("velocity")` and Stagger is inactive.
Disabling `Stagger`, or deactivating its entity, writes zero velocity and keeps
the remaining stun. Enabling it restores the current knockback ramp.

## Deliveries

### Hitbox

```ts
import type { Vec2Like } from "@yagejs/core";
import type { ColliderShape } from "@yagejs/physics";
import type {
  Aim,
  HitboxParams as BaseHitboxParams,
  HitSpec,
  StandardHitData,
} from "@yagejs-addons/abilities";

interface HitboxParams<
  TData = StandardHitData,
> extends BaseHitboxParams<TData> {
  shape: ColliderShape;
  offset?: Vec2Like;
  aim?: Aim;
  team?: string;
  hit: HitSpec<TData>; // TData | ((ctx: StepContext) => TData)
  tags?: readonly string[];
  layers?: number;
  mask?: number;
  follow?: boolean;
}
```

Window entry creates a detached sensor; exit destroys it. Without `every`, one
delivery reaches each target at most once per window. With `every`, contact
delivers immediately and current overlaps receive another delivery at each
interval; exit stops repeats and re-entry delivers immediately. `follow`
updates position from the caster but keeps spawn-time rotation/offset.
Layers/mask pass through to physics; omission uses Rapier's all-layers default.

### Spawn and projectile

```ts
import { Entity, trait } from "@yagejs/core";
import type { Vec2, Vec2Like } from "@yagejs/core";
import { AbilitySpawned, spawn } from "@yagejs-addons/abilities";
import type {
  AbilityActivation,
  AbilitySpawnContext as BaseAbilitySpawnContext,
  HitDelivery,
} from "@yagejs-addons/abilities";

declare function muzzleWorldPosition(caster: Entity): Vec2Like; // game-owned

interface AbilitySpawnContext<
  TParams = unknown,
> extends BaseAbilitySpawnContext<TParams> {
  readonly caster: Entity; // original caster through nested spawns
  readonly aim: Vec2; // unit, fire-time snapshot
  readonly position: Vec2;
  readonly params: TParams;
  readonly team?: string;
  readonly delivery?: HitDelivery;
  readonly activation?: AbilityActivation;
}

@trait(AbilitySpawned)
class Fireball extends Entity {
  abilitySpawnContext: AbilitySpawnContext<{ speed: number }> | undefined;
  setup(ctx: AbilitySpawnContext<{ speed: number }>): void {
    this.abilitySpawnContext = ctx;
    // Add components, presentation, movement, and lifetime.
  }
}

spawn({
  at: 0.2,
  entity: Fireball,
  params: { speed: 300 },
  position: (ctx) => muzzleWorldPosition(ctx.entity),
  hit: { damage: 12 },
});
```

Params infer from the selected class's
`setup(context: AbilitySpawnContext<TParams>)` signature. If
`AbilitySpawnParams<typeof EntityClass>` is `never`, fix that setup signature
and declare the `abilitySpawnContext` field. `position` is an absolute world
position or fire-time resolver; the facing-local `offset` is applied after
that base. The entity owns all behavior and lifetime. `hit` is optional; when
present, context receives a ready reporting delivery. `Projectile` is a
supplied dynamic entity with a zero-gravity sensor by default:

```ts
import { Projectile, spawn } from "@yagejs-addons/abilities";

const layers = 0b0010; // player attacks
const mask = 0b0100; // enemies

spawn({
  at: 0.2,
  entity: Projectile,
  params: {
    speed: 300,
    lifetime: 2,
    shape: { type: "circle", radius: 5 },
    groups: { layers, mask },
  },
  hit: { damage: 12, stun: 0.2 },
});
```

`ProjectileConfig.sensor` defaults to `true`; `gravityScale` defaults to `0`.
`consume?: (result: HitResult, otherIsSensor: boolean) => boolean` defaults to
`shouldConsumeProjectile`: consume on any result except `"ignored"`, or any
non-sensor contact. Ignored sensor overlaps pass through. Speed must be finite;
lifetime must be finite and non-negative. Shape, groups, and gravity use the
physics components' config constraints.

For a solid projectile that lands on one-way platforms, use `sensor: false`,
`gravityScale: 1`, and `consume: (result) => result !== "ignored"`. The platform
owns the collider's `oneWay` config. Solid projectiles handle collision starts;
sensor projectiles handle trigger entries. A custom consume callback is
attributed and a throw is terminal.

`resolveAbilitySource` and
`resolveAbilityTeam` recover original-caster provenance inside nested spawned
attacks.

### Acquiring a pooled spawn

`SpawnParams.acquire?: (context: AbilitySpawnContext<AbilitySpawnParams<TClass>>,
stepContext: StepContext) => InstanceType<TClass> | undefined` replaces the
`Scene.spawn` call. It receives the resolved position, aim, team, params, and
delivery. Returning `undefined` skips the spawn without a fallback. Throws are
attributed and terminal.

Compose it with a game-owned `PoolableEntity`:

```ts yage-context="scene"
import {
  Entity,
  EntityPool,
  Process,
  ProcessComponent,
  Transform,
  trait,
} from "@yagejs/core";
import { ColliderComponent, RigidBodyComponent } from "@yagejs/physics";
import {
  AbilitySpawned,
  resolveHitContact,
  spawn,
  type AbilitySpawnContext,
} from "@yagejs-addons/abilities";

interface AttackParams {
  speed: number;
  lifetime: number;
}

@trait(AbilitySpawned)
class PooledAttack extends Entity {
  abilitySpawnContext: AbilitySpawnContext<AttackParams> | undefined;
  private spent = false;

  override setup(context?: AbilitySpawnContext<AttackParams>): void {
    this.abilitySpawnContext = context;
    this.add(new Transform());
    this.add(new RigidBodyComponent({ type: "dynamic", gravityScale: 0 }));
    this.add(new ProcessComponent());
    this.add(
      new ColliderComponent({
        shape: { type: "circle", radius: 5 },
        sensor: true,
      }),
    ).onTrigger((event) => {
      const shot = this.abilitySpawnContext;
      if (!event.entered || this.spent || !shot || event.other === shot.caster)
        return;
      const result = shot.delivery?.deliver(
        event.other,
        this.get(Transform).worldPosition,
        resolveHitContact(this.get(ColliderComponent), event),
      );
      if (result === undefined || result === "ignored") return;
      this.spent = true;
      attackPool.release(this);
    });
  }

  override onAcquire(context: AbilitySpawnContext<AttackParams>): void {
    this.abilitySpawnContext = context;
    this.spent = false;
    const body = this.get(RigidBodyComponent);
    body.setPosition(context.position.x, context.position.y);
    body.setVelocity(context.aim.scale(context.params.speed));
    this.get(ProcessComponent).run(
      Process.delay(context.params.lifetime, () => attackPool.release(this)),
      { clock: "fixed" },
    );
  }
}

// Construct in scene setup, after physics services are available.
const attackPool = new EntityPool(scene, PooledAttack, {
  prewarm: 16,
  maxSize: 64,
});

spawn({
  at: 0.2,
  entity: PooledAttack,
  params: { speed: 300, lifetime: 2 },
  hit: { damage: 12 },
  acquire: (context) => attackPool.acquire(context),
});
```

`PooledAttack.setup(context?: AbilitySpawnContext<AttackParams>)` constructs
components once. The optional context keeps params inferred and permits pool
prewarming. `onAcquire(context)` stores the new `abilitySpawnContext` and
resets position, velocity, lifetime, and per-target hit state. Release through
the pool when the attack finishes; release cancels the previous lifetime
process. This example uses one fixed sensor shape for every lease. Dispose the
pool with the scene's teardown. The supplied `Projectile` is not poolable.

### Hit target lifetime and events

Hit delivery returns `"ignored"` for a destroyed target before trait lookup or
callbacks, including before deferred teardown. A destroyed caster does not
invalidate an attack that is already in flight.

The exported event tokens use addon-scoped ids. Import the tokens rather than
redeclaring their strings:

| Token           | Id                         |
| --------------- | -------------------------- |
| `HealthDamaged` | `abilities:health:damaged` |
| `HealthHealed`  | `abilities:health:healed`  |
| `HealthDied`    | `abilities:health:died`    |
| `HitReceived`   | `abilities:hit:received`   |
| `HitDealt`      | `abilities:hit:dealt`      |
| `HitGuarded`    | `abilities:hit:guarded`    |

### Touch damage

```ts
import { TouchDamage } from "@yagejs-addons/abilities";

new TouchDamage({
  hit: { damage: 5, knockback: 80, stun: 0.1 },
  team: "enemy", // omit to inherit sibling HitReceiver/team context
  tags: ["contact"],
  interval: 1, // seconds per target; default 1
});
```

Requires a sibling `ColliderComponent`. Uses collision callbacks for solids
and trigger callbacks for sensors. It creates no collider.

## Custom hit data

Raw generic path:

```ts yage-group="element-hit"
import type { Entity } from "@yagejs/core";
import {
  HitReceiver,
  createHitDelivery,
  hitbox,
} from "@yagejs-addons/abilities";
import type {
  HitStage,
  HitboxStepArgs,
  StandardHitData,
} from "@yagejs-addons/abilities";

interface ElementHit extends StandardHitData {
  element: "fire" | "ice";
}

declare const steps: readonly HitStage<ElementHit, HitReceiver<ElementHit>>[];
declare const args: HitboxStepArgs<ElementHit>;
declare const source: Entity;
declare const data: ElementHit;

const receiver = new HitReceiver<ElementHit>({ steps });
const step = hitbox<ElementHit>({
  ...args,
  hit: { element: "fire", damage: 8 },
});
const delivery = createHitDelivery<ElementHit>({ source, data });
```

One-time type pinning:

```ts yage-group="element-hit"
import { createHitTools } from "@yagejs-addons/abilities";
import type {
  AbilitySpawnedClass,
  DeliveryProvenance,
  GuardParams,
  GuardStepArgs,
  Hit,
  HitboxParams,
  HitDelivery,
  HitDeliveryOptions,
  HitReceiverOptions,
  HitTools as BaseHitTools,
  PointStep,
  SpawnParams,
  SpawnStepArgs,
  WindowStep,
} from "@yagejs-addons/abilities";

const hits = createHitTools<ElementHit>({
  isData(data): data is ElementHit {
    return typeof data === "object" && data !== null && "element" in data;
  },
});

// `hits` is a HitTools<ElementHit>:
interface HitTools<TData = StandardHitData> extends BaseHitTools<TData> {
  hitbox(args: HitboxStepArgs<TData>): WindowStep<HitboxParams<TData>>;
  guard(args: GuardStepArgs<TData>): WindowStep<GuardParams<TData>>;
  spawn<TClass extends AbilitySpawnedClass>(
    args: SpawnStepArgs<TClass, TData>,
  ): PointStep<SpawnParams<TClass, TData>>;
  delivery(options: HitDeliveryOptions<TData>): HitDelivery;
  reportingDelivery(
    options: HitDeliveryOptions<TData>,
    provenance?: DeliveryProvenance,
  ): HitDelivery;
  receiver(options?: HitReceiverOptions<TData>): HitReceiver<TData>;
  stage(
    stage: HitStage<TData, HitReceiver<TData>>,
  ): HitStage<TData, HitReceiver<TData>>;
  isData(data: unknown): data is TData;
  isHit(hit: Hit<unknown>): hit is Hit<TData>;
}
```

Use raw generics when only a few call sites need custom data. Use
`createHitTools` when one combat system should pin the same data type across
receivers, steps, deliveries, and boundary predicates. `HitReceived`,
`HitDealt`, and `Hittable` remain singleton boundaries: their public payloads
use the default vocabulary, so custom systems narrow with `hits.isData` or
`hits.isHit` before passing data to typed code. `createHitTools` does not mount
components, mint event tokens, define abilities, or wrap an input driver.

## Stats/resource integration

- Attack numbers: `HitSpec` fire-time builder.
- Defense: prepend a game-authored `HitStage` that mutates `hit.data`, then run
  `defaultHitSteps` or another consequence list.
- Max HP: assign `Health.max`, then clamp/heal `hp` according to game policy.
- Cooldown speed: `Scalar` function, resolved once per cross-definition entry.
- Costs: input binding `gate` checks; `beforeFire` spends after admission. AI
  checks/spends before `send`.

```ts
import type { Entity } from "@yagejs/core";
import { hitbox } from "@yagejs-addons/abilities";
import type { AbilityDef } from "@yagejs-addons/abilities";

// The game's own stats lookup.
declare function statsOf(entity: Entity): {
  attack: number;
  attackSpeed: number;
};

const def: AbilityDef = {
  id: "slash",
  cooldown: (ctx) => 0.8 / statsOf(ctx.entity).attackSpeed,
  timeline: [
    hitbox({
      from: 0.1,
      to: 0.2,
      shape: { type: "capsule", halfHeight: 18, radius: 10, axis: "x" },
      hit: (ctx) => ({ damage: statsOf(ctx.entity).attack, stun: 0.2 }),
    }),
  ],
};
```

The addon has no attribute, buff/debuff, resource, or cost model.

## Public export index

Root entry, runner:

- Values: `Abilities`, `AbilityStarted`, `AbilityPhaseChanged`, `AbilityEnded`,
  `defineStep`, `resolveScalar`.
- Definition and result types: `AbilityDef`, `TimelineAbilityDef`,
  `PhasedAbilityDef`, `PhaseDef`, `PhaseTransition`,
  `AbsolutePhaseTransition`, `RelativePhaseTransition`, `CancelWindow`,
  `AbilityMatcher`, `Scalar`, `PlayResult`, `PlayRejection`,
  `AbilitySendOptions`, `AbilityCanSendOptions`.
- Timeline and activation types: `AbilityActivation`, `AbilityStep`,
  `PointStep`, `PointStepHooks`, `WindowStep`, `WindowStepHooks`,
  `StepContext`, `AbilityReleaseOptions`.

Root entry, spawning and aim:

- Values: `AbilitySpawned`, `spawn`, `Projectile`, `Facing`, `resolveAim`,
  `aimAt`, `resolveAbilitySource`, `resolveAbilityTeam`.
- Types: `AbilitySpawnContext`, `AbilitySpawnedClass`,
  `AbilitySpawnedEntity`, `AbilitySpawnParams`, `SpawnParams`,
  `SpawnStepArgs`, `SpawnPosition`, `ProjectileConfig`, `Aim`, `Cardinal`.

Root entry, hit delivery and receipt:

- Values: `Hittable`, `createHitDelivery`, `createReportingDelivery`,
  `resolveHitSpec`, `shouldConsumeProjectile`, `resolveHit`, `HitReceiver`,
  `HitReceived`, `HitGuarded`, `HitDealt`, `createHitTools`,
  `resolveHitContact`, `queryHitContact`.
- Envelope and delivery types: `Hit`, `HitContact`, `HitContactPair`,
  `HitResult`, `HitOutcomes`, `StandardHitData`, `HitSpec`, `HitDelivery`,
  `HitDeliveryOptions`, `DeliveryColliderGroups`, `HitStage`,
  `DeliveryProvenance`, `HitDealtPayload`.
- Receiver and typed-tool types: `HitReceiverOptions`, `HitFilter`,
  `HitReceivedPayload`, `GuardOutcome`, `GuardParams`, `GuardPolicy`,
  `CreateHitToolsOptions`, `HitDataPredicate`, `HitTools`.

Root entry, built-in components and steps:

- Values: `Health`, `HealthDamaged`, `HealthHealed`, `HealthDied`, `Stagger`,
  `damageStep`, `reactionStep`, `defaultHitSteps`, `hitbox`, `guard`, `parry`,
  `block`, `invulnerable`, `slowmo`, `anim`, `staggerMotion`,
  `staggerReaction`, `REACTION_PRIORITY`, `TouchDamage`.
- Types: `HitboxParams`, `HitboxStepArgs`,
  `GuardStepArgs`, `SlowmoParams`, `SlowmoWindowArgs`, `TimedSlowmoArgs`,
  `TouchDamageOptions`.

Optional `/input` entry:

- Values: `AbilityDriver`, `AbilityDriverComponent`.
- Types: `AbilityGesture`, `AbilityData`, `AbilityDataResolver`,
  `AbilityGestureContext`, `AbilityFireContext`, `AbilitySend`, `AbilityTap`,
  `AbilityHold`, `AbilityBinding`, `AbilityDriverDefaults`,
  `AbilityDriverOptions`.

## Save boundary

The addon does not contribute state to `@yagejs/save` automatically. A game
that treats health as durable should include `{ hp, max }` in its explicit save
model and construct `Health` from those values. Cooldowns, active phases,
driver edges, i-frames, guards, stagger, and other live combat state remain
runtime concerns unless the game deliberately models them as durable domain
state.

## Death/corpse recipe

`HealthDied` is policy output, not a built-in death system. Removing a
component from its own listener is safe. `setType("static")` makes an
immovable corpse: nothing pushes it and it pushes nothing. The switch clears
its velocity.

```ts yage-context="entity"
import { Component } from "@yagejs/core";
import { RigidBodyComponent } from "@yagejs/physics";
import { HealthDied } from "@yagejs-addons/abilities";

class EnemyController extends Component {} // the game's AI

const body = entity.get(RigidBodyComponent);

entity.on(HealthDied, () => {
  body.setType("static");
  entity.remove(EnemyController);
});
```

Corpses remain hittable unless `HitReceiverOptions.filter` rejects dead
targets. When wrapping the default team rule, accept when either team is
undefined or the two teams differ.

## Not included (game-owned)

The addon ships none of these; the game builds them:

- Presenters, damage numbers, and health bars.
- Generic stats, buffs, and resources.
- AI decisions.
- A helper that decides which component writes velocity. Follow the
  `Stagger` rule above.
- In-place input-driver reload and tag-filtered loadout edits. Replace the
  whole loadout instead.
- Hierarchical or stateful tags. `AbilityDef.tags` are plain strings.
- Bulk process removal.
- Automatic combat-state persistence. See Save boundary.

The in-repo `examples/abilities-addon.html` shows the intended composition,
including complete combo/power loadout replacement.
