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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "db605e8d-fb86-469f-9dc2-d5430752d693",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { IBossConfig } from '../data/Interfaces';
|
||||
|
||||
/**
|
||||
* Boss controller — 双幻坊 (req 9.1-9.6 + 8.6-8.7).
|
||||
*
|
||||
* Key beats:
|
||||
* 1. A lone butterfly orbits the boss. Until the butterfly is hit, the boss
|
||||
* is invulnerable (`butterflyRevealed = false`).
|
||||
* 2. First hit on the butterfly → `revealedAt` timestamp stamped, boss
|
||||
* becomes vulnerable.
|
||||
* 3. While revealed, ANY single clean hit kills the boss (req 9.3). We still
|
||||
* honour `phase` transitions at 2/3 and 1/3 HP for visual variety.
|
||||
* 4. When HP ≤ `princessCutsceneAtHpRatio`, we emit a one-shot `princess_taken`
|
||||
* event (≤ 3s, battle keeps running — req 8.6 / 14.1).
|
||||
* 5. On death we emit `boss_killed` + `chapter_end_cutscene` (≤ 2s). Neither
|
||||
* code path produces a "rope-severing rescue" event — decision D-5 / req
|
||||
* 14.5.
|
||||
*/
|
||||
|
||||
export type BossOutcomeEvent =
|
||||
| { kind: 'phase_changed'; phase: string; actionIntervalSec: number }
|
||||
| { kind: 'butterfly_revealed' }
|
||||
| { kind: 'princess_taken_cutscene' }
|
||||
| { kind: 'boss_killed' };
|
||||
|
||||
export class BossController {
|
||||
private hp: number;
|
||||
private butterflyRevealed = false;
|
||||
private phaseIndex = 0;
|
||||
private princessCutscenePlayed = false;
|
||||
private killed = false;
|
||||
public readonly cfg: IBossConfig;
|
||||
|
||||
constructor(cfg: IBossConfig) {
|
||||
this.cfg = cfg;
|
||||
this.hp = cfg.hp;
|
||||
}
|
||||
|
||||
public get currentHp(): number {
|
||||
return this.hp;
|
||||
}
|
||||
public get currentPhase() {
|
||||
return this.cfg.phases[this.phaseIndex];
|
||||
}
|
||||
public get isButterflyRevealed(): boolean {
|
||||
return this.butterflyRevealed;
|
||||
}
|
||||
public get isDead(): boolean {
|
||||
return this.killed;
|
||||
}
|
||||
|
||||
/** Called when a player's attack hits the butterfly (req 9.2). */
|
||||
public onButterflyHit(): BossOutcomeEvent[] {
|
||||
if (this.butterflyRevealed) return [];
|
||||
this.butterflyRevealed = true;
|
||||
return [{ kind: 'butterfly_revealed' }];
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a player's attack hits the boss body. Before the butterfly
|
||||
* is revealed this call is a no-op; after reveal, the boss dies in one
|
||||
* hit (req 9.3).
|
||||
*/
|
||||
public onBodyHit(): BossOutcomeEvent[] {
|
||||
if (!this.butterflyRevealed) return [];
|
||||
if (this.killed) return [];
|
||||
const out: BossOutcomeEvent[] = [];
|
||||
this.hp = Math.max(0, this.hp - 1);
|
||||
out.push(...this.checkPhaseTransition(), ...this.checkPrincessCutscene());
|
||||
if (this.hp === 0) {
|
||||
this.killed = true;
|
||||
out.push({ kind: 'boss_killed' });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
|
||||
private checkPhaseTransition(): BossOutcomeEvent[] {
|
||||
const hpRatio = this.hp / this.cfg.hp;
|
||||
for (let i = this.phaseIndex + 1; i < this.cfg.phases.length; i++) {
|
||||
if (hpRatio <= this.cfg.phases[i].hpThreshold) {
|
||||
this.phaseIndex = i;
|
||||
return [
|
||||
{
|
||||
kind: 'phase_changed',
|
||||
phase: this.cfg.phases[i].mode,
|
||||
actionIntervalSec: this.cfg.phases[i].actionIntervalSec,
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
private checkPrincessCutscene(): BossOutcomeEvent[] {
|
||||
if (this.princessCutscenePlayed) return [];
|
||||
const threshold = this.cfg.princessCutsceneAtHpRatio;
|
||||
if (threshold === undefined) return [];
|
||||
const hpRatio = this.hp / this.cfg.hp;
|
||||
if (hpRatio <= threshold) {
|
||||
this.princessCutscenePlayed = true;
|
||||
return [{ kind: 'princess_taken_cutscene' }];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "452bb8f0-7423-4ca0-87b4-70bc9dc8d382",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { ILevelConfig, ScrollDirection } from '../data/Interfaces';
|
||||
|
||||
/**
|
||||
* Camera-scrolling model (task 7.1).
|
||||
*
|
||||
* Captures the level's camera/scrolling state without depending on `cc`. The
|
||||
* Cocos view layer maps `CameraScroller.offsetX / offsetY` into a `Camera`
|
||||
* component position every frame.
|
||||
*
|
||||
* Supported scroll modes (req 8.1-8.5, 8.8):
|
||||
* - `horizontal` — scroll never rewinds (森林/魔城).
|
||||
* - `horizontal_bi` — left/right both allowed (洞穴水路).
|
||||
* - `vertical` — scrolls upward as the player climbs (城壁).
|
||||
*
|
||||
* Values are in **landscape design pixels** (960x540 baseline).
|
||||
*/
|
||||
|
||||
export interface ICameraConfig {
|
||||
/** Scroll direction, mirrors `ILevelConfig.scrollDirection`. */
|
||||
direction: ScrollDirection;
|
||||
/** Horizontal level length (for `horizontal` and `horizontal_bi`). */
|
||||
lengthX: number;
|
||||
/** Vertical level length (for `vertical`). */
|
||||
lengthY?: number;
|
||||
/** Camera viewport (design px). */
|
||||
viewportW: number;
|
||||
viewportH: number;
|
||||
}
|
||||
|
||||
/** Four-layer parallax scroller (req 8.8). Speed ratios 1 : 2 : 4 : 4. */
|
||||
export const PARALLAX_RATIOS = [1, 2, 4, 4] as const;
|
||||
export type ParallaxLayer = 'far' | 'mid' | 'near' | 'fx';
|
||||
export const PARALLAX_LAYERS: ParallaxLayer[] = ['far', 'mid', 'near', 'fx'];
|
||||
|
||||
export class CameraScroller {
|
||||
private _offsetX = 0;
|
||||
private _offsetY = 0;
|
||||
private readonly cfg: ICameraConfig;
|
||||
|
||||
constructor(cfg: ICameraConfig) {
|
||||
this.cfg = cfg;
|
||||
}
|
||||
|
||||
public get offsetX(): number {
|
||||
return this._offsetX;
|
||||
}
|
||||
|
||||
public get offsetY(): number {
|
||||
return this._offsetY;
|
||||
}
|
||||
|
||||
/** Camera target follows the player but never rewinds on `horizontal`. */
|
||||
public followPlayer(playerX: number, playerY: number): void {
|
||||
const halfW = this.cfg.viewportW / 2;
|
||||
const halfH = this.cfg.viewportH / 2;
|
||||
if (this.cfg.direction === 'horizontal') {
|
||||
const desired = Math.max(0, playerX - halfW);
|
||||
this._offsetX = Math.min(
|
||||
Math.max(this._offsetX, desired),
|
||||
Math.max(0, this.cfg.lengthX - this.cfg.viewportW)
|
||||
);
|
||||
} else if (this.cfg.direction === 'horizontal_bi') {
|
||||
const desired = Math.max(0, playerX - halfW);
|
||||
this._offsetX = Math.min(desired, Math.max(0, this.cfg.lengthX - this.cfg.viewportW));
|
||||
} else if (this.cfg.direction === 'vertical') {
|
||||
const ly = this.cfg.lengthY ?? this.cfg.viewportH;
|
||||
const desiredY = Math.max(0, playerY - halfH);
|
||||
this._offsetY = Math.min(desiredY, Math.max(0, ly - this.cfg.viewportH));
|
||||
}
|
||||
}
|
||||
|
||||
/** Compute the world offset for a given parallax layer. */
|
||||
public offsetForLayer(layer: ParallaxLayer): { x: number; y: number } {
|
||||
const ratio = PARALLAX_RATIOS[PARALLAX_LAYERS.indexOf(layer)];
|
||||
return { x: this._offsetX / ratio, y: this._offsetY / ratio };
|
||||
}
|
||||
|
||||
/** Return the level's culling rect in world coordinates. */
|
||||
public cullRect(): { leftX: number; rightX: number; topY: number; bottomY: number } {
|
||||
return {
|
||||
leftX: this._offsetX,
|
||||
rightX: this._offsetX + this.cfg.viewportW,
|
||||
topY: this._offsetY + this.cfg.viewportH,
|
||||
bottomY: this._offsetY,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a CameraScroller from a level config. */
|
||||
export function cameraFromLevel(level: ILevelConfig, viewportW = 960, viewportH = 540): CameraScroller {
|
||||
return new CameraScroller({
|
||||
direction: level.scrollDirection,
|
||||
lengthX: level.levelLengthPx,
|
||||
lengthY: level.scrollDirection === 'vertical' ? level.levelLengthPx : undefined,
|
||||
viewportW,
|
||||
viewportH,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "8f42b819-8901-4709-b784-ff4c5f7fb61e",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Chapter settlement logic (task 8.2, req 14.1-14.5).
|
||||
*
|
||||
* After the boss dies the chapter-1 cutscene sequence is strictly:
|
||||
*
|
||||
* princess_taken_cutscene (≤ 3s) — optional, plays mid-fight when HP ≤ 1/2
|
||||
* boss_killed (≤ 2s) — freeze-frame
|
||||
* settlement_screen — score + stats UI
|
||||
*
|
||||
* There is **no** rope-severing rescue event in the MVP (req 14.5), so we
|
||||
* expose a single `BANNED_RESCUE_SEQUENCE` constant the QA test asserts
|
||||
* against; any future code that tries to play it will fail the guardrail.
|
||||
*/
|
||||
|
||||
export type CutsceneId = 'princess_taken' | 'boss_killed_freeze' | 'settlement_screen';
|
||||
|
||||
/** Any rescue-style cutscene ID in this set will fail CI (req 14.5). */
|
||||
export const BANNED_RESCUE_SEQUENCE: ReadonlyArray<string> = Object.freeze([
|
||||
'rope_cut_rescue',
|
||||
'princess_rescued',
|
||||
'chapter_end_rescue',
|
||||
]);
|
||||
|
||||
export interface ISettlementStats {
|
||||
totalScore: number;
|
||||
stageScore: number;
|
||||
comboCount: number;
|
||||
flawless: boolean;
|
||||
remainingTimeSec: number;
|
||||
}
|
||||
|
||||
export interface ISettlementResult {
|
||||
stats: ISettlementStats;
|
||||
closingLine: string;
|
||||
}
|
||||
|
||||
export class ChapterSettlement {
|
||||
private stats: ISettlementStats;
|
||||
|
||||
constructor(initialStats: ISettlementStats) {
|
||||
this.stats = { ...initialStats };
|
||||
}
|
||||
|
||||
public addScore(pts: number): void {
|
||||
this.stats.stageScore += pts;
|
||||
this.stats.totalScore += pts;
|
||||
}
|
||||
|
||||
public registerCombo(): void {
|
||||
this.stats.comboCount++;
|
||||
}
|
||||
|
||||
public markTaken(): void {
|
||||
this.stats.flawless = false;
|
||||
}
|
||||
|
||||
public setRemainingTime(sec: number): void {
|
||||
this.stats.remainingTimeSec = sec;
|
||||
}
|
||||
|
||||
public assertCutsceneAllowed(id: CutsceneId | string): void {
|
||||
if (BANNED_RESCUE_SEQUENCE.includes(id)) {
|
||||
throw new Error(
|
||||
`ChapterSettlement: cutscene "${id}" is explicitly banned — chapter 1 must end with the princess taken (req 14.5)`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public build(): ISettlementResult {
|
||||
const closing = '公主被带走,续章待续…';
|
||||
return { stats: { ...this.stats }, closingLine: closing };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "817d0864-8a09-49b5-bdcf-2dd3e74c26de",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { AttackType } from '../data/Interfaces';
|
||||
import { PlayerStateMachine, DamageOutcome } from './PlayerStateMachine';
|
||||
|
||||
/**
|
||||
* DamageSystem — the single funnel through which **every** player-facing
|
||||
* damage event must flow (req 10.1-10.6).
|
||||
*
|
||||
* Decision precedence (req 10.3):
|
||||
* 1. i-frames → no effect
|
||||
* 2. sword parry → no effect (only vs. shuriken/sword; see PSM)
|
||||
* 3. attack type × distance → dispatch to PlayerStateMachine.takeHit
|
||||
*
|
||||
* Distance thresholds:
|
||||
* - fireball: lethal within 100px (req 10.4)
|
||||
* - smoke bomb: lethal within 80px (req 10.5)
|
||||
* - shuriken / sword: any contact is eligible
|
||||
*
|
||||
* Enemy-facing damage is a separate, simpler `applyToEnemy` helper.
|
||||
*/
|
||||
|
||||
export interface IDamageContext {
|
||||
attackType: AttackType;
|
||||
attackerX: number;
|
||||
attackerY: number;
|
||||
victimX: number;
|
||||
victimY: number;
|
||||
}
|
||||
|
||||
export const FIREBALL_KILL_RADIUS = 100;
|
||||
export const SMOKE_KILL_RADIUS = 80;
|
||||
|
||||
export class DamageSystem {
|
||||
constructor(private readonly psm: PlayerStateMachine) {}
|
||||
|
||||
/** Try to damage the player. Returns `null` if the attack missed by distance. */
|
||||
public applyToPlayer(ctx: IDamageContext): DamageOutcome | null {
|
||||
const distance = Math.hypot(ctx.attackerX - ctx.victimX, ctx.attackerY - ctx.victimY);
|
||||
if (ctx.attackType === 'fireball' && distance > FIREBALL_KILL_RADIUS) return null;
|
||||
if (ctx.attackType === 'smoke_bomb' && distance > SMOKE_KILL_RADIUS) return null;
|
||||
// shuriken / sword rely on caller-side hitbox; reaching here means "hit".
|
||||
return this.psm.takeHit(ctx.attackType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply flat damage to an enemy HP bucket. The damage number comes from
|
||||
* the active weapon config. Returns the remaining HP (0 means killed).
|
||||
*/
|
||||
public applyToEnemy(currentHp: number, damage: number): number {
|
||||
return Math.max(0, currentHp - damage);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "36078a13-9596-4b92-9236-b207a585035a",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { EnemyType, ItemType } from '../data/Interfaces';
|
||||
|
||||
/**
|
||||
* Drop system (req 7.1-7.6).
|
||||
*
|
||||
* Rules encoded here:
|
||||
*
|
||||
* 1. **水晶玉 (crystal jade)** — deterministic: every 12th kill on forest
|
||||
* stages spawns one above the player (req 7.1). Lifetime 13-20s (req 7.2,
|
||||
* handled by caller).
|
||||
* 2. **点丸 / 术丸** — after 3 consecutive 赤忍 kills, 50% chance of one or
|
||||
* the other (req 7.3).
|
||||
* 3. **魔笛** — dropped by 黑忍 on death (implemented in `HeiRenAI`). Picking
|
||||
* it up triggers a screen-wipe (req 7.4), applied by the level manager.
|
||||
* 4. **增丸** — fixed spawn point per level config, no probability (req 7.5).
|
||||
*
|
||||
* All probabilistic decisions funnel through an injectable `random()` so
|
||||
* deterministic tests stay stable.
|
||||
*/
|
||||
|
||||
export interface IDropSystemCfg {
|
||||
/** Kills required before a crystal jade is guaranteed. Default 12 (req 7.1). */
|
||||
crystalJadeEveryN?: number;
|
||||
/** Kills of Chi Ren required before point/spell ball eligible. Default 3. */
|
||||
dianShuWanThresholdKills?: number;
|
||||
/** Probability for dian_wan or shu_wan drop once threshold reached. Default 0.5. */
|
||||
dianShuWanProbability?: number;
|
||||
/** Injectable RNG. Default `Math.random`. */
|
||||
random?: () => number;
|
||||
}
|
||||
|
||||
export interface IDropEvent {
|
||||
item: ItemType;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export class DropSystem {
|
||||
private globalKills = 0;
|
||||
private chiRenConsecutiveKills = 0;
|
||||
private readonly crystalJadeEveryN: number;
|
||||
private readonly dianShuWanThresholdKills: number;
|
||||
private readonly dianShuWanProbability: number;
|
||||
private readonly random: () => number;
|
||||
|
||||
constructor(cfg: IDropSystemCfg = {}) {
|
||||
this.crystalJadeEveryN = cfg.crystalJadeEveryN ?? 12;
|
||||
this.dianShuWanThresholdKills = cfg.dianShuWanThresholdKills ?? 3;
|
||||
this.dianShuWanProbability = cfg.dianShuWanProbability ?? 0.5;
|
||||
this.random = cfg.random ?? Math.random;
|
||||
}
|
||||
|
||||
/** Register an enemy kill and return any drops produced. */
|
||||
public onEnemyKilled(enemy: EnemyType, at: { x: number; y: number }): IDropEvent[] {
|
||||
this.globalKills++;
|
||||
const drops: IDropEvent[] = [];
|
||||
// Crystal-jade rule (deterministic, req 7.1).
|
||||
if (this.globalKills % this.crystalJadeEveryN === 0) {
|
||||
drops.push({ item: ItemType.CrystalJade, x: at.x, y: at.y + 180 });
|
||||
}
|
||||
// Chi Ren consecutive rule (req 7.3).
|
||||
if (enemy === EnemyType.ChiRen) {
|
||||
this.chiRenConsecutiveKills++;
|
||||
if (this.chiRenConsecutiveKills >= this.dianShuWanThresholdKills) {
|
||||
this.chiRenConsecutiveKills = 0;
|
||||
if (this.random() < this.dianShuWanProbability) {
|
||||
const pickDian = this.random() < 0.5;
|
||||
drops.push({
|
||||
item: pickDian ? ItemType.DianWan : ItemType.ShuWan,
|
||||
x: at.x,
|
||||
y: at.y,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.chiRenConsecutiveKills = 0;
|
||||
}
|
||||
return drops;
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
this.globalKills = 0;
|
||||
this.chiRenConsecutiveKills = 0;
|
||||
}
|
||||
|
||||
public get kills(): number {
|
||||
return this.globalKills;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "0027692e-e7b0-4146-a401-25842bc5d1c0",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import { AttackType, EnemyType, IEnemyConfig } from '../data/Interfaces';
|
||||
|
||||
/**
|
||||
* Enemy AI base class + four concrete subclasses (req 6.1-6.7).
|
||||
*
|
||||
* Each enemy is modelled as a tiny state machine that ticks on `update(dt)`
|
||||
* and emits an `IEnemyAction[]` the level manager then spawns into the world
|
||||
* (bullets / smoke bombs / fireballs / sword swings).
|
||||
*
|
||||
* Enemies outside the camera's culling rect can be frozen by simply skipping
|
||||
* `update()` — see `EnemyManager.update()` below (requirement 6.7 / 18.5).
|
||||
*/
|
||||
|
||||
export interface IEnemyAction {
|
||||
kind: 'fire_bullet' | 'melee_swing' | 'spawn_item' | 'drop_item';
|
||||
attackType?: AttackType;
|
||||
/** Origin of the projectile, world coords. */
|
||||
originX?: number;
|
||||
originY?: number;
|
||||
/** Velocity of the projectile. */
|
||||
velX?: number;
|
||||
velY?: number;
|
||||
/** For drop_item only — item id + world coords. */
|
||||
itemId?: string;
|
||||
}
|
||||
|
||||
export interface IPlayerSense {
|
||||
x: number;
|
||||
y: number;
|
||||
isGrounded: boolean;
|
||||
}
|
||||
|
||||
export interface IEnemyAABB {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
export interface IEnemyUpdateCtx {
|
||||
dtSec: number;
|
||||
nowMs: number;
|
||||
player: IPlayerSense;
|
||||
}
|
||||
|
||||
export abstract class EnemyAIBase {
|
||||
public readonly type: EnemyType;
|
||||
public pos: { x: number; y: number };
|
||||
public alive = true;
|
||||
protected cooldownSec = 0;
|
||||
protected readonly cfg: IEnemyConfig;
|
||||
|
||||
constructor(cfg: IEnemyConfig, spawnX: number, spawnY: number) {
|
||||
this.cfg = cfg;
|
||||
this.type = cfg.id;
|
||||
this.pos = { x: spawnX, y: spawnY };
|
||||
}
|
||||
|
||||
public abstract update(ctx: IEnemyUpdateCtx): IEnemyAction[];
|
||||
|
||||
public get aabb(): IEnemyAABB {
|
||||
return { x: this.pos.x, y: this.pos.y, w: this.cfg.size.w, h: this.cfg.size.h };
|
||||
}
|
||||
|
||||
protected decrementCooldown(dtSec: number): boolean {
|
||||
this.cooldownSec -= dtSec;
|
||||
if (this.cooldownSec > 0) return false;
|
||||
this.cooldownSec = this.cfg.attackIntervalSec;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Qing Ren (req 6.1) --------------------------------------------
|
||||
export class QingRenAI extends EnemyAIBase {
|
||||
public update(ctx: IEnemyUpdateCtx): IEnemyAction[] {
|
||||
if (!this.decrementCooldown(ctx.dtSec)) return [];
|
||||
const dx = ctx.player.x - this.pos.x;
|
||||
const horizontalDistance = Math.abs(dx);
|
||||
const direction = dx >= 0 ? 1 : -1;
|
||||
if (horizontalDistance < 64) {
|
||||
return [{ kind: 'melee_swing', attackType: 'sword' }];
|
||||
}
|
||||
return [
|
||||
{
|
||||
kind: 'fire_bullet',
|
||||
attackType: 'shuriken',
|
||||
originX: this.pos.x,
|
||||
originY: this.pos.y,
|
||||
velX: 240 * direction,
|
||||
velY: 0,
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Chi Ren (req 6.2-6.4) -----------------------------------------
|
||||
export class ChiRenAI extends EnemyAIBase {
|
||||
private interceptCooldown = 0;
|
||||
|
||||
public update(ctx: IEnemyUpdateCtx): IEnemyAction[] {
|
||||
// Patrol horizontally toward the player at 120px/s (req 6.2).
|
||||
const dx = ctx.player.x - this.pos.x;
|
||||
const direction = dx >= 0 ? 1 : -1;
|
||||
this.pos.x += direction * this.cfg.moveSpeed * ctx.dtSec;
|
||||
|
||||
// Proactive intercept jump when player stands still within vision (req 6.3).
|
||||
if (this.interceptCooldown > 0) this.interceptCooldown -= ctx.dtSec;
|
||||
const playerIdle = Math.abs(ctx.player.x - this.pos.x) < 200 && ctx.player.isGrounded;
|
||||
if (playerIdle && this.interceptCooldown <= 0) {
|
||||
this.interceptCooldown = 3;
|
||||
// Intercept arc: +X velocity + upward bounce, treated as position warp.
|
||||
this.pos.y += 48;
|
||||
}
|
||||
|
||||
if (!this.decrementCooldown(ctx.dtSec)) return [];
|
||||
return [
|
||||
{
|
||||
kind: 'fire_bullet',
|
||||
attackType: 'smoke_bomb',
|
||||
originX: this.pos.x,
|
||||
originY: this.pos.y,
|
||||
velX: 140 * direction,
|
||||
velY: 0,
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Hei Ren (req 6.5) ---------------------------------------------
|
||||
export class HeiRenAI extends EnemyAIBase {
|
||||
private hasDroppedMagicFlute = false;
|
||||
|
||||
public update(ctx: IEnemyUpdateCtx): IEnemyAction[] {
|
||||
if (!this.decrementCooldown(ctx.dtSec)) return [];
|
||||
const dx = ctx.player.x - this.pos.x;
|
||||
const direction = dx >= 0 ? 1 : -1;
|
||||
if (Math.abs(dx) < 96) {
|
||||
return [{ kind: 'melee_swing', attackType: 'sword' }];
|
||||
}
|
||||
return [
|
||||
{
|
||||
kind: 'fire_bullet',
|
||||
attackType: 'shuriken',
|
||||
originX: this.pos.x,
|
||||
originY: this.pos.y,
|
||||
velX: 200 * direction,
|
||||
velY: 0,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** Called by EnemyManager on death — yields at most one magic flute. */
|
||||
public onKilled(): IEnemyAction[] {
|
||||
if (this.hasDroppedMagicFlute) return [];
|
||||
this.hasDroppedMagicFlute = true;
|
||||
return [{ kind: 'drop_item', itemId: 'mo_di', originX: this.pos.x, originY: this.pos.y }];
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Yao Fang (req 6.6) --------------------------------------------
|
||||
export class YaoFangAI extends EnemyAIBase {
|
||||
public update(ctx: IEnemyUpdateCtx): IEnemyAction[] {
|
||||
if (!this.decrementCooldown(ctx.dtSec)) return [];
|
||||
const dx = ctx.player.x - this.pos.x;
|
||||
const direction = dx >= 0 ? 1 : -1;
|
||||
return [
|
||||
{
|
||||
kind: 'fire_bullet',
|
||||
attackType: 'fireball',
|
||||
originX: this.pos.x,
|
||||
originY: this.pos.y,
|
||||
velX: 260 * direction,
|
||||
velY: 0,
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Manager with camera-culling (req 6.7) -------------------------
|
||||
|
||||
export interface ICullingRect {
|
||||
leftX: number;
|
||||
rightX: number;
|
||||
topY: number;
|
||||
bottomY: number;
|
||||
}
|
||||
|
||||
export class EnemyManager {
|
||||
private readonly enemies: EnemyAIBase[] = [];
|
||||
|
||||
public spawn(enemy: EnemyAIBase): void {
|
||||
this.enemies.push(enemy);
|
||||
}
|
||||
|
||||
public get all(): ReadonlyArray<EnemyAIBase> {
|
||||
return this.enemies;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update all live enemies that intersect `cull`. Returns the concatenated
|
||||
* list of actions emitted so the caller (LevelMgr) can instantiate
|
||||
* projectiles, drops, etc.
|
||||
*/
|
||||
public update(dtSec: number, nowMs: number, player: IPlayerSense, cull: ICullingRect): IEnemyAction[] {
|
||||
const actions: IEnemyAction[] = [];
|
||||
for (const e of this.enemies) {
|
||||
if (!e.alive) continue;
|
||||
if (!this.inside(e, cull)) continue;
|
||||
const ctx: IEnemyUpdateCtx = { dtSec, nowMs, player };
|
||||
actions.push(...e.update(ctx));
|
||||
}
|
||||
return actions;
|
||||
}
|
||||
|
||||
public kill(enemy: EnemyAIBase): IEnemyAction[] {
|
||||
enemy.alive = false;
|
||||
if (enemy instanceof HeiRenAI) return enemy.onKilled();
|
||||
return [];
|
||||
}
|
||||
|
||||
public clear(): void {
|
||||
this.enemies.length = 0;
|
||||
}
|
||||
|
||||
private inside(e: EnemyAIBase, cull: ICullingRect): boolean {
|
||||
return (
|
||||
e.pos.x + e.aabb.w / 2 >= cull.leftX &&
|
||||
e.pos.x - e.aabb.w / 2 <= cull.rightX &&
|
||||
e.pos.y + e.aabb.h / 2 >= cull.bottomY &&
|
||||
e.pos.y - e.aabb.h / 2 <= cull.topY
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "2a16b768-32a1-48f3-8456-7f63c6ac109d",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { PlayerMotionModel, DEFAULT_GRAVITY } from './PlayerMotionModel';
|
||||
import {
|
||||
JUMP_HEIGHT_STANDARD,
|
||||
JUMP_HEIGHT_CHARGED,
|
||||
JUMP_HEIGHT_YELLOW,
|
||||
JUMP_PREPARE_DELAY_MS,
|
||||
JUMP_CHARGE_THRESHOLD_MS,
|
||||
PlayerColorState,
|
||||
} from '../common/Constants';
|
||||
import { JoystickAngleClass } from '../ui/InputModel';
|
||||
|
||||
/**
|
||||
* Jump controller — orchestrates the jump lifecycle on top of
|
||||
* `PlayerMotionModel` (task 4.2).
|
||||
*
|
||||
* Lifecycle (ms timestamps supplied by caller so Jest can stay deterministic):
|
||||
*
|
||||
* pressJump(ts)
|
||||
* ├─ not grounded? ignore (req 2.4)
|
||||
* ├─ enter `charging` state, start timer
|
||||
* └─ emit `jump_prepare_start`
|
||||
*
|
||||
* releaseJump(ts, direction)
|
||||
* ├─ ts - pressTs >= JUMP_CHARGE_THRESHOLD_MS → charged high-jump (req 2.3)
|
||||
* ├─ else → standard jump (req 2.2)
|
||||
* ├─ +150ms crouch delay before launch (req 2.8)
|
||||
* └─ parabolic_right / parabolic_left → horizontal impulse too (req 2.5)
|
||||
*
|
||||
* Yellow-state uses a taller vertical impulse (req 2.2).
|
||||
*/
|
||||
|
||||
export type JumpPhase = 'idle' | 'charging' | 'crouching' | 'launched';
|
||||
|
||||
export interface IJumpDispatchResult {
|
||||
phase: JumpPhase;
|
||||
height: number;
|
||||
horizontalImpulse: number;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/** How much horizontal velocity a parabolic jump imparts (px/s). */
|
||||
export const PARABOLIC_HORIZONTAL_SPEED = 180;
|
||||
|
||||
/**
|
||||
* Converts `verticalTravel (px)` to the initial velocity needed to reach it.
|
||||
* Using `v = sqrt(2 * g * h)` under constant gravity.
|
||||
*/
|
||||
export function heightToImpulse(heightPx: number, gravity: number = DEFAULT_GRAVITY): number {
|
||||
return Math.sqrt(2 * gravity * heightPx);
|
||||
}
|
||||
|
||||
export class JumpController {
|
||||
private phase: JumpPhase = 'idle';
|
||||
private pressTs = 0;
|
||||
private crouchEndsAt = 0;
|
||||
private pendingImpulse: { vy: number; vx: number } | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly motion: PlayerMotionModel,
|
||||
private readonly prepareDelayMs: number = JUMP_PREPARE_DELAY_MS,
|
||||
private readonly chargeThresholdMs: number = JUMP_CHARGE_THRESHOLD_MS
|
||||
) {}
|
||||
|
||||
/** Called each frame with `now` from `TimeMgr.realTime * 1000`. */
|
||||
public tick(nowMs: number): void {
|
||||
if (this.phase === 'crouching' && nowMs >= this.crouchEndsAt) {
|
||||
if (this.pendingImpulse) {
|
||||
this.motion.applyJumpImpulse(this.pendingImpulse.vy);
|
||||
this.motion.applyHorizontalImpulse(this.pendingImpulse.vx);
|
||||
this.pendingImpulse = null;
|
||||
}
|
||||
this.phase = 'launched';
|
||||
}
|
||||
// Once the motion model reports grounded again, reset to idle.
|
||||
if (this.phase === 'launched' && this.motion.isGrounded) {
|
||||
this.phase = 'idle';
|
||||
}
|
||||
}
|
||||
|
||||
/** Called on `jumpPressed` UI event. */
|
||||
public pressJump(nowMs: number): IJumpDispatchResult {
|
||||
if (!this.motion.isGrounded) {
|
||||
return { phase: this.phase, height: 0, horizontalImpulse: 0, reason: 'airborne' };
|
||||
}
|
||||
if (this.phase !== 'idle') {
|
||||
return { phase: this.phase, height: 0, horizontalImpulse: 0, reason: 'already_in_jump' };
|
||||
}
|
||||
this.phase = 'charging';
|
||||
this.pressTs = nowMs;
|
||||
return { phase: this.phase, height: 0, horizontalImpulse: 0 };
|
||||
}
|
||||
|
||||
/** Called on `jumpReleased` UI event. */
|
||||
public releaseJump(
|
||||
nowMs: number,
|
||||
joystickClass: JoystickAngleClass,
|
||||
colorState: PlayerColorState = PlayerColorState.Red
|
||||
): IJumpDispatchResult {
|
||||
if (this.phase !== 'charging') {
|
||||
return { phase: this.phase, height: 0, horizontalImpulse: 0, reason: 'not_charging' };
|
||||
}
|
||||
const heldMs = nowMs - this.pressTs;
|
||||
const charged = heldMs >= this.chargeThresholdMs;
|
||||
|
||||
let height = charged
|
||||
? JUMP_HEIGHT_CHARGED
|
||||
: colorState === PlayerColorState.Yellow
|
||||
? JUMP_HEIGHT_YELLOW
|
||||
: JUMP_HEIGHT_STANDARD;
|
||||
|
||||
let vx = 0;
|
||||
if (joystickClass === 'parabolic_right') {
|
||||
vx = PARABOLIC_HORIZONTAL_SPEED;
|
||||
} else if (joystickClass === 'parabolic_left') {
|
||||
vx = -PARABOLIC_HORIZONTAL_SPEED;
|
||||
}
|
||||
|
||||
this.phase = 'crouching';
|
||||
this.crouchEndsAt = nowMs + this.prepareDelayMs;
|
||||
const vy = heightToImpulse(height);
|
||||
this.pendingImpulse = { vy, vx };
|
||||
return { phase: this.phase, height, horizontalImpulse: vx };
|
||||
}
|
||||
|
||||
/** Cancel any pending jump (used on pause / scene unload). */
|
||||
public cancel(): void {
|
||||
this.phase = 'idle';
|
||||
this.pendingImpulse = null;
|
||||
}
|
||||
|
||||
/** Expose the current jump phase for HUD feedback (disabled button, etc.). */
|
||||
public getPhase(): JumpPhase {
|
||||
return this.phase;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the UI should render the jump button as enabled. Disabled when
|
||||
* airborne or mid-cycle (req 2.4).
|
||||
*/
|
||||
public isButtonEnabled(): boolean {
|
||||
return this.motion.isGrounded && this.phase === 'idle';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "449ab620-ad69-4b91-9043-c588ec6182f3",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { EnemyType, ILevelConfig, ILevelObjective } from '../data/Interfaces';
|
||||
|
||||
/**
|
||||
* Level lifecycle manager (task 7.1 + 7.2).
|
||||
*
|
||||
* Given an `ILevelConfig`, this class:
|
||||
* - ticks the time-limit countdown (req 8.1-8.5)
|
||||
* - tracks kill counters by enemy type
|
||||
* - evaluates the level objective each tick
|
||||
* - emits a structured `LevelResult` when the level ends
|
||||
*
|
||||
* Scene + parallax rendering live in the Cocos view layer; this module is
|
||||
* intentionally engine-agnostic so the whole progression logic is Jest-
|
||||
* testable.
|
||||
*/
|
||||
|
||||
export type LevelStatus = 'running' | 'victory' | 'timeout' | 'player_dead';
|
||||
|
||||
export interface ILevelResult {
|
||||
status: LevelStatus;
|
||||
elapsedSec: number;
|
||||
kills: Record<string, number>;
|
||||
remainingSec: number;
|
||||
}
|
||||
|
||||
export class LevelMgr {
|
||||
private elapsedSec = 0;
|
||||
private kills = new Map<EnemyType, number>();
|
||||
private totalKills = 0;
|
||||
private bossKilled = false;
|
||||
private reachedTop = false;
|
||||
private playerDead = false;
|
||||
public readonly level: ILevelConfig;
|
||||
|
||||
constructor(level: ILevelConfig) {
|
||||
this.level = level;
|
||||
}
|
||||
|
||||
public tick(dtSec: number): LevelStatus {
|
||||
if (this.isTerminal()) return this.currentStatus();
|
||||
this.elapsedSec += dtSec;
|
||||
return this.currentStatus();
|
||||
}
|
||||
|
||||
public onEnemyKilled(enemy: EnemyType): void {
|
||||
this.kills.set(enemy, (this.kills.get(enemy) ?? 0) + 1);
|
||||
this.totalKills++;
|
||||
}
|
||||
|
||||
public onBossKilled(): void {
|
||||
this.bossKilled = true;
|
||||
}
|
||||
|
||||
public onReachedTop(): void {
|
||||
this.reachedTop = true;
|
||||
}
|
||||
|
||||
public onPlayerDied(): void {
|
||||
this.playerDead = true;
|
||||
}
|
||||
|
||||
public result(): ILevelResult {
|
||||
return {
|
||||
status: this.currentStatus(),
|
||||
elapsedSec: this.elapsedSec,
|
||||
kills: this.killsAsObject(),
|
||||
remainingSec: Math.max(0, this.level.timeLimitSec - this.elapsedSec),
|
||||
};
|
||||
}
|
||||
|
||||
public get totalKillsCount(): number {
|
||||
return this.totalKills;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private currentStatus(): LevelStatus {
|
||||
if (this.playerDead) return 'player_dead';
|
||||
if (this.evaluateObjective(this.level.objective)) return 'victory';
|
||||
if (this.elapsedSec >= this.level.timeLimitSec) return 'timeout';
|
||||
return 'running';
|
||||
}
|
||||
|
||||
private isTerminal(): boolean {
|
||||
const s = this.currentStatus();
|
||||
return s !== 'running';
|
||||
}
|
||||
|
||||
private evaluateObjective(o: ILevelObjective): boolean {
|
||||
if (o.kind === 'kill_count' && o.enemy && o.count) {
|
||||
return (this.kills.get(o.enemy) ?? 0) >= o.count;
|
||||
}
|
||||
if (o.kind === 'reach_top') {
|
||||
return this.reachedTop;
|
||||
}
|
||||
if (o.kind === 'defeat_boss') {
|
||||
return this.bossKilled;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private killsAsObject(): Record<string, number> {
|
||||
const out: Record<string, number> = {};
|
||||
for (const [k, v] of this.kills.entries()) out[k] = v;
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "97238e1a-49db-41c8-9d3f-60a510388814",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { MOVE_SPEED, PlayerColorState } from '../common/Constants';
|
||||
|
||||
/**
|
||||
* Pure-TS motion model for the player character.
|
||||
*
|
||||
* This is the foundation used by tasks 4.1, 4.2 (jumping/parabolic) and
|
||||
* later 5.x (combo attacks). It is deliberately engine-free so that the
|
||||
* entire movement state-machine is Jest-testable (requirement 2.1, 5.1-5.2).
|
||||
*
|
||||
* Coordinate convention: landscape design resolution, **+y is up**. All
|
||||
* numbers are in design pixels / seconds.
|
||||
*/
|
||||
|
||||
export interface IAxisAlignedBox {
|
||||
/** Centre x. */
|
||||
x: number;
|
||||
/** Centre y. */
|
||||
y: number;
|
||||
/** Full width. */
|
||||
w: number;
|
||||
/** Full height. */
|
||||
h: number;
|
||||
}
|
||||
|
||||
/** A simple horizontal platform the player may stand on. */
|
||||
export interface IPlatform {
|
||||
/** Platform top edge y (world px). */
|
||||
topY: number;
|
||||
/** Platform left edge x (world px). */
|
||||
leftX: number;
|
||||
/** Platform right edge x (world px). */
|
||||
rightX: number;
|
||||
}
|
||||
|
||||
/** Horizontal input reported by `InputModel` / `FloatingControlLayer`. */
|
||||
export type HorizontalInput = -1 | 0 | 1;
|
||||
|
||||
export interface IPlayerMotionOptions {
|
||||
/** World gravity (px/s²). Default derived so a 250-px jump lasts ~0.45 s. */
|
||||
gravity?: number;
|
||||
/** Starting AABB of the player. */
|
||||
aabb: IAxisAlignedBox;
|
||||
/** Platforms defining the walkable terrain. Can be swapped per-level. */
|
||||
platforms: IPlatform[];
|
||||
/** Starting color state. */
|
||||
initialColorState?: PlayerColorState;
|
||||
}
|
||||
|
||||
export const DEFAULT_GRAVITY = 2500; // px/s² — tuned so a 250px jump peaks in ~0.45s
|
||||
|
||||
/**
|
||||
* Encapsulates player horizontal/vertical movement + ground detection.
|
||||
* Call `setHorizontalInput()` + `requestJump()` every frame from the view
|
||||
* layer, then invoke `update(dt)` to advance the simulation.
|
||||
*/
|
||||
export class PlayerMotionModel {
|
||||
// -- mutable state ------------------------------------------------------
|
||||
private _vx = 0;
|
||||
private _vy = 0;
|
||||
private _grounded = false;
|
||||
private _colorState: PlayerColorState;
|
||||
private _horizontalInput: HorizontalInput = 0;
|
||||
private _aabb: IAxisAlignedBox;
|
||||
private _platforms: IPlatform[];
|
||||
private readonly gravity: number;
|
||||
|
||||
constructor(options: IPlayerMotionOptions) {
|
||||
this._aabb = { ...options.aabb };
|
||||
this._platforms = options.platforms.slice();
|
||||
this._colorState = options.initialColorState ?? PlayerColorState.Red;
|
||||
this.gravity = options.gravity ?? DEFAULT_GRAVITY;
|
||||
}
|
||||
|
||||
// -- accessors ----------------------------------------------------------
|
||||
public get aabb(): IAxisAlignedBox {
|
||||
return this._aabb;
|
||||
}
|
||||
public get vx(): number {
|
||||
return this._vx;
|
||||
}
|
||||
public get vy(): number {
|
||||
return this._vy;
|
||||
}
|
||||
public get isGrounded(): boolean {
|
||||
return this._grounded;
|
||||
}
|
||||
public get colorState(): PlayerColorState {
|
||||
return this._colorState;
|
||||
}
|
||||
|
||||
// -- inputs -------------------------------------------------------------
|
||||
|
||||
/** -1 moves left, 1 moves right, 0 stops (req 2.1). */
|
||||
public setHorizontalInput(input: HorizontalInput): void {
|
||||
this._horizontalInput = input;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the player's color state (e.g. after a crystal-jade pickup).
|
||||
* Movement speed will immediately reflect the new bucket (req 5.1-5.2).
|
||||
*/
|
||||
public setColorState(state: PlayerColorState): void {
|
||||
this._colorState = state;
|
||||
}
|
||||
|
||||
/** Impulse-based vertical jump. Does nothing if not grounded (req 2.4). */
|
||||
public applyJumpImpulse(verticalPxPerSec: number): boolean {
|
||||
if (!this._grounded) return false;
|
||||
this._vy = verticalPxPerSec;
|
||||
this._grounded = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Additional horizontal impulse used by parabolic jumps (req 2.5). */
|
||||
public applyHorizontalImpulse(vx: number): void {
|
||||
this._vx = vx;
|
||||
}
|
||||
|
||||
/** Swap level terrain; also clears grounded so we re-settle on next update. */
|
||||
public setPlatforms(platforms: IPlatform[]): void {
|
||||
this._platforms = platforms.slice();
|
||||
this._grounded = false;
|
||||
}
|
||||
|
||||
// -- simulation step ----------------------------------------------------
|
||||
|
||||
/**
|
||||
* Advance the simulation by `dt` seconds. In hardcore mode (req 13.4) the
|
||||
* horizontal velocity is **only** rewritten from input when on the
|
||||
* ground; mid-air `_vx` is preserved (起跳定型).
|
||||
*/
|
||||
public update(dt: number): void {
|
||||
if (this._grounded) {
|
||||
this._vx = this._horizontalInput * MOVE_SPEED[this._colorState];
|
||||
this._vy = 0;
|
||||
} else {
|
||||
// Apply gravity (requirement 13.4: no air-control).
|
||||
this._vy -= this.gravity * dt;
|
||||
}
|
||||
// Integrate position.
|
||||
this._aabb = {
|
||||
...this._aabb,
|
||||
x: this._aabb.x + this._vx * dt,
|
||||
y: this._aabb.y + this._vy * dt,
|
||||
};
|
||||
// Resolve against platforms (basic AABB vs. top-surface only).
|
||||
this._grounded = false;
|
||||
for (const p of this._platforms) {
|
||||
if (this.isRestingOn(p)) {
|
||||
this._grounded = true;
|
||||
this._aabb = { ...this._aabb, y: p.topY + this._aabb.h / 2 };
|
||||
if (this._vy < 0) this._vy = 0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- helpers ------------------------------------------------------------
|
||||
|
||||
private isRestingOn(p: IPlatform): boolean {
|
||||
const feetY = this._aabb.y - this._aabb.h / 2;
|
||||
const withinHorizontal = this._aabb.x >= p.leftX && this._aabb.x <= p.rightX;
|
||||
const atOrJustBelowTop = feetY <= p.topY + 0.5 && feetY >= p.topY - 6 && this._vy <= 0;
|
||||
return withinHorizontal && atOrJustBelowTop;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "37159f2f-c9e3-4a3c-a735-caa7476b0266",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
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 = 3) {
|
||||
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 };
|
||||
}
|
||||
|
||||
private startIFrames(): void {
|
||||
this.state.iframeSec = PLAYER_IFRAME_SECONDS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "f13127c9-f546-4df3-8e63-3a2e7d4fa23c",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { WeaponType } from '../data/Interfaces';
|
||||
|
||||
/**
|
||||
* Score system (task 9.3, req 12.1-12.8).
|
||||
*
|
||||
* Scoring table:
|
||||
* - Ninja sword kill ×2.0 base
|
||||
* - Shuriken kill ×1.0 base
|
||||
* - Perfect parry counterkill ×3.0 base
|
||||
* - 5-combo "刃接触" bonus +1500
|
||||
* - Flawless level (no damage) ×3.0 total
|
||||
* - Remaining time bonus +10 pts / remaining sec
|
||||
*
|
||||
* Everything is deterministic and `Math.random`-free so QA can reproduce
|
||||
* every score calculation in unit tests.
|
||||
*/
|
||||
|
||||
export const BASE_ENEMY_SCORE = 100;
|
||||
export const COMBO_BONUS = 1500;
|
||||
export const COMBO_THRESHOLD = 5;
|
||||
export const TIME_BONUS_PER_SEC = 10;
|
||||
|
||||
export interface IScoreSnapshot {
|
||||
baseScore: number;
|
||||
comboBonus: number;
|
||||
timeBonus: number;
|
||||
flawlessMultiplier: number;
|
||||
finalScore: number;
|
||||
killCount: number;
|
||||
comboCount: number;
|
||||
consecutiveBladeHits: number;
|
||||
}
|
||||
|
||||
export class ScoreSystem {
|
||||
private baseScore = 0;
|
||||
private comboBonus = 0;
|
||||
private killCount = 0;
|
||||
private consecutiveBladeHits = 0;
|
||||
private comboCount = 0;
|
||||
private flawless = true;
|
||||
private timeBonus = 0;
|
||||
|
||||
public reset(): void {
|
||||
this.baseScore = 0;
|
||||
this.comboBonus = 0;
|
||||
this.killCount = 0;
|
||||
this.consecutiveBladeHits = 0;
|
||||
this.comboCount = 0;
|
||||
this.flawless = true;
|
||||
this.timeBonus = 0;
|
||||
}
|
||||
|
||||
/** Record a kill weighted by weapon type (req 12.1-12.2). */
|
||||
public recordEnemyKill(weapon: WeaponType): void {
|
||||
this.killCount++;
|
||||
const multiplier = weapon === WeaponType.NinjaSword ? 2 : 1;
|
||||
this.baseScore += BASE_ENEMY_SCORE * multiplier;
|
||||
}
|
||||
|
||||
/** Perfect parry followed by a counter-kill (req 12.3). */
|
||||
public recordParryKill(): void {
|
||||
this.killCount++;
|
||||
this.baseScore += BASE_ENEMY_SCORE * 3;
|
||||
}
|
||||
|
||||
/** Record a single blade contact; every 5 contacts award a combo bonus (req 12.4). */
|
||||
public recordBladeContact(): void {
|
||||
this.consecutiveBladeHits++;
|
||||
if (this.consecutiveBladeHits >= COMBO_THRESHOLD) {
|
||||
this.comboCount++;
|
||||
this.comboBonus += COMBO_BONUS;
|
||||
this.consecutiveBladeHits = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** Resets the consecutive blade counter if the combo is broken. */
|
||||
public breakBladeChain(): void {
|
||||
this.consecutiveBladeHits = 0;
|
||||
}
|
||||
|
||||
/** Player took damage → flawless multiplier lost (req 12.5). */
|
||||
public markTaken(): void {
|
||||
this.flawless = false;
|
||||
}
|
||||
|
||||
/** Stage-end timing bonus (req 12.6). */
|
||||
public setRemainingTimeBonus(remainingSec: number): void {
|
||||
this.timeBonus = Math.max(0, Math.floor(remainingSec)) * TIME_BONUS_PER_SEC;
|
||||
}
|
||||
|
||||
public snapshot(): IScoreSnapshot {
|
||||
const flawlessMultiplier = this.flawless ? 3 : 1;
|
||||
const finalScore = (this.baseScore + this.comboBonus + this.timeBonus) * flawlessMultiplier;
|
||||
return {
|
||||
baseScore: this.baseScore,
|
||||
comboBonus: this.comboBonus,
|
||||
timeBonus: this.timeBonus,
|
||||
flawlessMultiplier,
|
||||
finalScore,
|
||||
killCount: this.killCount,
|
||||
comboCount: this.comboCount,
|
||||
consecutiveBladeHits: this.consecutiveBladeHits,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "96dc60f4-e45f-426a-8832-c36a6662f45f",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { StorageMgr, globalStorageMgr } from '../common/StorageMgr';
|
||||
import { STORAGE_KEY } from '../common/Constants';
|
||||
|
||||
/**
|
||||
* Tutorial manager (req 11.1-11.5, task 9.3).
|
||||
*
|
||||
* Pre-defined tutorial sequences for levels 1-1, 1-2, 1-3. Each step has an
|
||||
* ID the view layer uses to drive highlight-arrows; the step is "completed"
|
||||
* when the player performs the action, which the view layer signals via
|
||||
* `reportAction()`.
|
||||
*/
|
||||
|
||||
export interface ITutorialStep {
|
||||
id: string;
|
||||
/** Human-readable hint (displayed by the view layer). */
|
||||
hint: string;
|
||||
/** Action id the player must perform to advance. */
|
||||
requiredAction: string;
|
||||
}
|
||||
|
||||
export interface ITutorialSequence {
|
||||
levelId: string;
|
||||
steps: ITutorialStep[];
|
||||
}
|
||||
|
||||
/** Built-in tutorials for Chapter 1 (req 11.1-11.3). */
|
||||
export const BUILTIN_TUTORIALS: ITutorialSequence[] = [
|
||||
{
|
||||
levelId: '1-1',
|
||||
steps: [
|
||||
{ id: 'attack', hint: '点击右下的手里剑按钮', requiredAction: 'fire_shuriken' },
|
||||
{ id: 'joystick', hint: '拖动左下摇杆移动', requiredAction: 'move' },
|
||||
{ id: 'jump', hint: '点击跳跃按钮', requiredAction: 'jump' },
|
||||
],
|
||||
},
|
||||
{
|
||||
levelId: '1-2',
|
||||
steps: [
|
||||
{ id: 'parabolic', hint: '摇杆 45° 并跳跃 — 抛物线跳跃', requiredAction: 'parabolic_jump' },
|
||||
{ id: 'exclusive', hint: '两个攻击按钮互斥,选一个用', requiredAction: 'attack_switch' },
|
||||
{ id: 'parry', hint: '忍者刀可以格挡敌人刀剑', requiredAction: 'parry' },
|
||||
{ id: 'combo', hint: '跳跃中同时攻击', requiredAction: 'jump_attack' },
|
||||
{ id: 'auto_upgrade', hint: '拾取水晶玉自动强化', requiredAction: 'pickup_crystal' },
|
||||
],
|
||||
},
|
||||
{
|
||||
levelId: '1-3',
|
||||
steps: [
|
||||
{ id: 'butterfly', hint: '先击中 BOSS 身旁的蝴蝶', requiredAction: 'hit_butterfly' },
|
||||
{ id: 'boss_identify', hint: '识别 BOSS 攻击模式', requiredAction: 'dodge_boss_attack' },
|
||||
{ id: 'one_shot', hint: '显形后一击必杀', requiredAction: 'hit_revealed_boss' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export class TutorialMgr {
|
||||
private currentLevelId: string | null = null;
|
||||
private currentStepIndex = 0;
|
||||
|
||||
constructor(
|
||||
private readonly storage: StorageMgr = globalStorageMgr,
|
||||
private readonly sequences: ITutorialSequence[] = BUILTIN_TUTORIALS
|
||||
) {}
|
||||
|
||||
/** Start the tutorial for `levelId` if not already completed. */
|
||||
public maybeStart(levelId: string): ITutorialStep | null {
|
||||
if (this.isCompleted(levelId)) return null;
|
||||
const seq = this.sequences.find((s) => s.levelId === levelId);
|
||||
if (!seq) return null;
|
||||
this.currentLevelId = levelId;
|
||||
this.currentStepIndex = 0;
|
||||
return seq.steps[0];
|
||||
}
|
||||
|
||||
/** Called by gameplay whenever the player performs an action. */
|
||||
public reportAction(action: string): ITutorialStep | 'finished' | 'no_op' {
|
||||
if (!this.currentLevelId) return 'no_op';
|
||||
const seq = this.sequences.find((s) => s.levelId === this.currentLevelId);
|
||||
if (!seq) return 'no_op';
|
||||
const current = seq.steps[this.currentStepIndex];
|
||||
if (action !== current.requiredAction) return 'no_op';
|
||||
this.currentStepIndex++;
|
||||
if (this.currentStepIndex >= seq.steps.length) {
|
||||
this.markCompleted(this.currentLevelId);
|
||||
this.currentLevelId = null;
|
||||
this.currentStepIndex = 0;
|
||||
return 'finished';
|
||||
}
|
||||
return seq.steps[this.currentStepIndex];
|
||||
}
|
||||
|
||||
public isCompleted(levelId: string): boolean {
|
||||
const completed = this.storage.get<string[]>(STORAGE_KEY.TutorialDone, []);
|
||||
return completed.includes(levelId);
|
||||
}
|
||||
|
||||
public resetAll(): void {
|
||||
this.storage.remove(STORAGE_KEY.TutorialDone);
|
||||
this.currentLevelId = null;
|
||||
this.currentStepIndex = 0;
|
||||
}
|
||||
|
||||
public get isActive(): boolean {
|
||||
return this.currentLevelId !== null;
|
||||
}
|
||||
|
||||
public currentStep(): ITutorialStep | null {
|
||||
if (!this.currentLevelId) return null;
|
||||
const seq = this.sequences.find((s) => s.levelId === this.currentLevelId);
|
||||
return seq?.steps[this.currentStepIndex] ?? null;
|
||||
}
|
||||
|
||||
private markCompleted(levelId: string): void {
|
||||
const current = this.storage.get<string[]>(STORAGE_KEY.TutorialDone, []);
|
||||
if (!current.includes(levelId)) current.push(levelId);
|
||||
this.storage.set(STORAGE_KEY.TutorialDone, current);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "e1bd54ae-07af-4ad3-8085-24c1a88bdce0",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Logic layer — game-state machines, AI, damage system, level framework.
|
||||
*/
|
||||
|
||||
export * from './PlayerMotionModel';
|
||||
export * from './JumpController';
|
||||
export * from './AttackController';
|
||||
export * from './PlayerStateMachine';
|
||||
export * from './EnemyAI';
|
||||
export * from './DropSystem';
|
||||
export * from './DamageSystem';
|
||||
export * from './CameraScroller';
|
||||
export * from './LevelMgr';
|
||||
export * from './BossController';
|
||||
export * from './ChapterSettlement';
|
||||
export * from './TutorialMgr';
|
||||
export * from './ScoreSystem';
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "a0972eb0-c1b0-4e73-adff-5481e47d3b19",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
Reference in New Issue
Block a user