first commmit
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user