149 lines
5.1 KiB
TypeScript
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;
|
|
}
|
|
}
|