import { PlayerColorState, PLAYER_IFRAME_SECONDS } from '../common/Constants'; import { AttackType } from '../data/Interfaces'; /** * Player color-state machine (req 5.1-5.6) + parry (req 3.7-3.8) + i-frames * (req 10.2-10.3). * * Transitions: * * [Red] --crystal_jade--> [Green] --crystal_jade--> [Yellow] * ^ | | * |---- shuriken|sword --|---- shuriken|sword -----| * | | * ---- fireball / smoke_bomb -----------------> ⚰️ dead * ---- shuriken|sword while red ----------------> ⚰️ dead * * The machine is deliberately engine-agnostic so combat logic can be unit- * tested on either the web or inside a future dedicated simulator. */ export type DamageOutcome = | { kind: 'no_effect'; reason: 'iframe' | 'parried' } | { kind: 'downgraded'; from: PlayerColorState; to: PlayerColorState } | { kind: 'died'; cause: AttackType }; export interface IPlayerState { color: PlayerColorState; lives: number; /** Whether the ninja sword is currently in its parry-active frame window. */ swordActive: boolean; /** Remaining i-frame time (seconds). */ iframeSec: number; isDead: boolean; } export class PlayerStateMachine { private state: IPlayerState; constructor(initialLives = 3) { this.state = { color: PlayerColorState.Red, lives: initialLives, swordActive: false, iframeSec: 0, isDead: false, }; } public get snapshot(): IPlayerState { return { ...this.state }; } public get color(): PlayerColorState { return this.state.color; } public get lives(): number { return this.state.lives; } public get isDead(): boolean { return this.state.isDead; } // ---- external events -------------------------------------------------- /** Player picked up a crystal jade (req 5.1-5.2). */ public pickupCrystalJade(): PlayerColorState { if (this.state.color === PlayerColorState.Red) { this.state.color = PlayerColorState.Green; } else if (this.state.color === PlayerColorState.Green) { this.state.color = PlayerColorState.Yellow; } return this.state.color; } /** Player picked up an 增丸 — permanently +1 life (req 7.5). */ public pickupZengWan(): number { this.state.lives += 1; return this.state.lives; } /** * Toggle sword active window. Called by `AttackController.tick()` for the * duration of a sword swing so the parry window (req 3.7-3.8) stays tight. */ public setSwordActive(active: boolean): void { this.state.swordActive = active; } /** Advance i-frames on every physics tick. */ public tick(dtSec: number): void { if (this.state.iframeSec > 0) { this.state.iframeSec = Math.max(0, this.state.iframeSec - dtSec); } } /** * Apply incoming damage of `attackType`. Returns the resulting outcome so * the caller can render the appropriate FX/HUD change. */ public takeHit(attackType: AttackType): DamageOutcome { if (this.state.iframeSec > 0) { return { kind: 'no_effect', reason: 'iframe' }; } // Sword-active parry applies to shuriken & sword only (req 3.7-3.8). if (this.state.swordActive && (attackType === 'shuriken' || attackType === 'sword')) { this.startIFrames(); return { kind: 'no_effect', reason: 'parried' }; } // Fireball / smoke bomb are always lethal (req 5.5, 10.4-10.5). if (attackType === 'fireball' || attackType === 'smoke_bomb') { return this.consumeLife(attackType); } // Ordinary shuriken / sword damage: downgrade by one tier or die. if (this.state.color === PlayerColorState.Yellow) { this.state.color = PlayerColorState.Red; this.startIFrames(); return { kind: 'downgraded', from: PlayerColorState.Yellow, to: PlayerColorState.Red }; } if (this.state.color === PlayerColorState.Green) { this.state.color = PlayerColorState.Red; this.startIFrames(); return { kind: 'downgraded', from: PlayerColorState.Green, to: PlayerColorState.Red }; } // Red → dead return this.consumeLife(attackType); } // ---- helpers ---------------------------------------------------------- private consumeLife(cause: AttackType): DamageOutcome { this.state.lives = Math.max(0, this.state.lives - 1); this.state.color = PlayerColorState.Red; this.startIFrames(); if (this.state.lives === 0) { this.state.isDead = true; } return { kind: 'died', cause }; } private startIFrames(): void { this.state.iframeSec = PLAYER_IFRAME_SECONDS; } }