import { SHURIKEN_INTERVAL_BASE, SHURIKEN_INTERVAL_UPGRADED, SWORD_INTERVAL, SHURIKEN_BURST_MAX, COMBO_INPUT_WINDOW_MS, PlayerColorState, } from '../common/Constants'; import { WeaponType, AttackType } from '../data/Interfaces'; /** * Attack controller — models the two mutually-exclusive weapon buttons * (手里剑 / 忍者刀) plus the combo recognition window (req 3.1-3.9, 4.1-4.5). * * Outputs a single `IAttackDispatchEvent` per "fire" that gameplay code * applies through `DamageSystem` (task 6.2). Nothing here talks to `cc`; * it is deterministic on `now` timestamps provided by the caller. */ export type ActiveWeapon = 'none' | WeaponType; export interface IAttackDispatchEvent { weapon: WeaponType; attackType: AttackType; /** true when this attack was chained with a jump within the combo window (req 4.1). */ comboWithJump: boolean; /** 1-based index within a shuriken burst. Always 1 for the sword. */ burstIndex: number; /** realtime timestamp of the swing / throw (ms). */ ts: number; } /** Interface used by AttackController to know whether combo window applies. */ export interface IJumpStateProvider { /** Timestamp of the latest jump press (ms). `undefined` means no pending jump. */ lastJumpPressTs(): number | undefined; /** Current grounded flag. */ isGrounded(): boolean; } /** * Null jump-state provider — used when attack is fired on the ground and no * combo is expected (e.g. unit tests). */ export const NullJumpState: IJumpStateProvider = { lastJumpPressTs: () => undefined, isGrounded: () => true, }; export class AttackController { private active: ActiveWeapon = 'none'; /** Earliest press timestamp among currently-held attack buttons. */ private pressedAt = new Map(); /** Next-eligible-fire timestamp per weapon. */ private readyAt = new Map(); /** Current shuriken burst index (1..SHURIKEN_BURST_MAX). */ private shurikenBurstIndex = 0; constructor( private readonly jumpState: IJumpStateProvider = NullJumpState, private readonly comboWindowMs: number = COMBO_INPUT_WINDOW_MS ) {} /** Returns the currently active weapon (or `'none'`). */ public getActive(): ActiveWeapon { return this.active; } public isPressed(weapon: WeaponType): boolean { return this.pressedAt.has(weapon); } /** * Press handler. Implements mutual exclusion (req 3.1-3.3): the first * button pressed wins until it is released. */ public press(weapon: WeaponType, nowMs: number): void { if (!this.pressedAt.has(weapon)) { this.pressedAt.set(weapon, nowMs); } if (this.active === 'none') { this.active = weapon; if (weapon === WeaponType.Shuriken) { this.shurikenBurstIndex = 0; } } } public release(weapon: WeaponType): void { this.pressedAt.delete(weapon); if (this.active === weapon) { // If the other button is still pressed, transfer activation. const remaining = Array.from(this.pressedAt.keys())[0]; this.active = remaining ?? 'none'; if (weapon === WeaponType.Shuriken) { this.shurikenBurstIndex = 0; } } } /** * Called every frame. Returns the list of attacks to dispatch **this * frame** (usually 0 or 1; can be >1 only if dt is huge in tests). */ public tick(nowMs: number, colorState: PlayerColorState): IAttackDispatchEvent[] { if (this.active === 'none') return []; const weapon = this.active; const ready = this.readyAt.get(weapon) ?? 0; if (nowMs < ready) return []; return [this.fire(weapon, nowMs, colorState)]; } /** Cancel everything (scene unload, pause, death). */ public reset(): void { this.active = 'none'; this.pressedAt.clear(); this.readyAt.clear(); this.shurikenBurstIndex = 0; } // ------------------------------------------------------------------ private fire(weapon: WeaponType, nowMs: number, colorState: PlayerColorState): IAttackDispatchEvent { const interval = this.intervalFor(weapon, colorState); const comboWithJump = this.isComboWithJump(nowMs); let burstIndex = 1; if (weapon === WeaponType.Shuriken) { this.shurikenBurstIndex = Math.min(SHURIKEN_BURST_MAX, this.shurikenBurstIndex + 1); burstIndex = this.shurikenBurstIndex; } this.readyAt.set(weapon, nowMs + interval * 1000); const attackType: AttackType = weapon === WeaponType.Shuriken ? 'shuriken' : 'sword'; return { weapon, attackType, comboWithJump, burstIndex, ts: nowMs }; } private intervalFor(weapon: WeaponType, color: PlayerColorState): number { if (weapon === WeaponType.NinjaSword) return SWORD_INTERVAL; return color === PlayerColorState.Yellow ? SHURIKEN_INTERVAL_UPGRADED : SHURIKEN_INTERVAL_BASE; } private isComboWithJump(nowMs: number): boolean { const lastJump = this.jumpState.lastJumpPressTs(); if (lastJump === undefined) return false; const dt = Math.abs(nowMs - lastJump); return dt <= this.comboWindowMs; } }