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;
}
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "db605e8d-fb86-469f-9dc2-d5430752d693",
"files": [],
"subMetas": {},
"userData": {}
}
+107
View File
@@ -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": {}
}
+98
View File
@@ -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": {}
}
+73
View File
@@ -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": {}
}
+51
View File
@@ -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": {}
}
+89
View File
@@ -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;
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "0027692e-e7b0-4146-a401-25842bc5d1c0",
"files": [],
"subMetas": {},
"userData": {}
}
+233
View File
@@ -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
);
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "2a16b768-32a1-48f3-8456-7f63c6ac109d",
"files": [],
"subMetas": {},
"userData": {}
}
+143
View File
@@ -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": {}
}
+107
View File
@@ -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;
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "97238e1a-49db-41c8-9d3f-60a510388814",
"files": [],
"subMetas": {},
"userData": {}
}
+166
View File
@@ -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": {}
}
+145
View File
@@ -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": {}
}
+105
View File
@@ -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,
};
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "96dc60f4-e45f-426a-8832-c36a6662f45f",
"files": [],
"subMetas": {},
"userData": {}
}
+118
View File
@@ -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);
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "e1bd54ae-07af-4ad3-8085-24c1a88bdce0",
"files": [],
"subMetas": {},
"userData": {}
}
+17
View File
@@ -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';
+9
View File
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "a0972eb0-c1b0-4e73-adff-5481e47d3b19",
"files": [],
"subMetas": {},
"userData": {}
}