150 lines
5.3 KiB
TypeScript
150 lines
5.3 KiB
TypeScript
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<WeaponType, number>();
|
|
/** Next-eligible-fire timestamp per weapon. */
|
|
private readyAt = new Map<WeaponType, number>();
|
|
/** 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;
|
|
}
|
|
}
|