Files
2026-06-07 22:10:03 +08:00

149 lines
5.1 KiB
TypeScript

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 = 1) {
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 };
}
// Lost a life but still alive — treat as a downgrade so callers
// can distinguish real death from a mere life-loss.
return { kind: 'downgraded', from: this.state.color, to: PlayerColorState.Red };
}
private startIFrames(): void {
this.state.iframeSec = PLAYER_IFRAME_SECONDS;
}
}