first commmit

This commit is contained in:
jakciehan
2026-05-06 08:17:32 +08:00
commit 427a33c55b
269 changed files with 20906 additions and 0 deletions
+149
View File
@@ -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;
}
}