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
+210
View File
@@ -0,0 +1,210 @@
/**
* Lightweight `cc` module mock used by Jest to allow platform-agnostic
* modules (`common/*`, `data/*`, some `logic/*`) to be unit-tested without
* pulling in the full Cocos Creator engine.
*
* Anything that actually touches `cc.Node` / scene graph must **not** live
* in a module that is directly tested by Jest; put it behind a thin facade
* instead (see `common/StorageMgr.ts` which isolates `sys.localStorage`).
*/
class FakeEventTarget {
private listeners = new Map<string, Array<(...args: unknown[]) => void>>();
on(event: string, cb: (...args: unknown[]) => void): void {
const list = this.listeners.get(event) ?? [];
list.push(cb);
this.listeners.set(event, list);
}
off(event: string, cb?: (...args: unknown[]) => void): void {
if (!cb) {
this.listeners.delete(event);
return;
}
const list = this.listeners.get(event);
if (!list) return;
this.listeners.set(
event,
list.filter((fn) => fn !== cb)
);
}
emit(event: string, ...args: unknown[]): void {
const list = this.listeners.get(event);
if (!list) return;
for (const fn of list) {
fn(...args);
}
}
}
export const EventTarget = FakeEventTarget;
export const _decorator = {
ccclass: (_name?: string) => (target: unknown) => target as any,
property: (_opts?: unknown) => (_target: unknown, _key: string) => {},
};
/**
* Minimal stand-in for `cc.Node`. Only the surface actually exercised by
* Scene Entry components is modelled so unit tests stay deterministic.
*/
export class Node {
public name: string = '';
public active: boolean = true;
public layer: number = 0;
constructor(name?: string) {
this.name = name ?? '';
}
public addChild(_child: Node): void {}
public on(_ev: string, _cb: (...args: unknown[]) => void, _target?: unknown): void {}
public off(_ev: string, _cb?: (...args: unknown[]) => void): void {}
public getComponent<T>(_ctor: new (...args: unknown[]) => T): T | null { return null; }
public addComponent<T>(_ctor: new (...args: unknown[]) => T): T { return {} as T; }
public setPosition(..._args: unknown[]): void {}
public static EventType = {
TOUCH_START: 'touch-start',
TOUCH_MOVE: 'touch-move',
TOUCH_END: 'touch-end',
TOUCH_CANCEL: 'touch-cancel',
};
}
/**
* Minimal stand-in for `cc.Component`. Provides `.node` so Scene Entry TS
* types resolve. Runtime behaviour is injected by the Cocos Creator editor.
*/
export class Component {
public node: Node = new Node();
public enabled: boolean = true;
}
export const director = {
addPersistRootNode: (..._args: unknown[]) => {},
loadScene: (..._args: unknown[]) => {},
};
export const view = {
setDesignResolutionSize: (..._args: unknown[]) => {},
};
export const screen = {
orientation: 0,
};
export const game = {
frameRate: 60,
};
export const sys = {
localStorage: {
_store: new Map<string, string>(),
getItem(key: string): string | null {
return (sys.localStorage as any)._store.get(key) ?? null;
},
setItem(key: string, value: string): void {
(sys.localStorage as any)._store.set(key, value);
},
removeItem(key: string): void {
(sys.localStorage as any)._store.delete(key);
},
clear(): void {
(sys.localStorage as any)._store.clear();
},
},
};
export const settings = {};
export const Settings = {};
export const profiler = {};
// ---------------------------------------------------------------------
// Additional stubs for types imported by Scene Entry components. All the
// methods below are NO-OPs; the Cocos Creator editor injects the real
// implementations at runtime. Having them declared here keeps the TS
// language server happy and lets Jest exercise scene-entry unit tests.
// ---------------------------------------------------------------------
export class Color {
public r: number;
public g: number;
public b: number;
public a: number;
constructor(r: number = 255, g: number = 255, b: number = 255, a: number = 255) {
this.r = r; this.g = g; this.b = b; this.a = a;
}
public static WHITE = new Color(255, 255, 255, 255);
public static BLACK = new Color(0, 0, 0, 255);
}
export class Vec3 {
public x: number;
public y: number;
public z: number;
constructor(x: number = 0, y: number = 0, z: number = 0) {
this.x = x; this.y = y; this.z = z;
}
}
export class UITransform {
public width: number = 0;
public height: number = 0;
public setContentSize(_w: number, _h: number): void {}
}
export class Label {
public string: string = '';
public fontSize: number = 24;
public lineHeight: number = 28;
public color: Color = Color.WHITE;
public horizontalAlign: number = 0;
public verticalAlign: number = 0;
public useSystemFont: boolean = true;
}
export class Button {
public transition: number = 0;
public target: Node | null = null;
public zoomScale: number = 1;
public static Transition = { NONE: 0, COLOR: 1, SPRITE: 2, SCALE: 3 };
}
export class Graphics {
public fillColor: Color = Color.WHITE;
public strokeColor: Color = Color.BLACK;
public lineWidth: number = 1;
public rect(_x: number, _y: number, _w: number, _h: number): void {}
public fill(): void {}
public stroke(): void {}
}
export class Sprite {
public spriteFrame: unknown = null;
public type: number = 0;
public sizeMode: number = 0;
public static Type = { SIMPLE: 0, SLICED: 1, TILED: 2, FILLED: 3 };
public static SizeMode = { CUSTOM: 0, TRIMMED: 1, RAW: 2 };
}
export class SpriteFrame {
public texture: unknown = null;
}
export class Texture2D {
public image: unknown = null;
public static PixelFormat = { RGBA8888: 35 };
}
export class ImageAsset {
constructor(_opts?: unknown) {}
}
export class JsonAsset {
public json: unknown = null;
}
export class Canvas {}
export class EventTouch {}
export const resources = {
load: (_path: string, _type: unknown, _cb: (err: Error | null, asset: unknown) => void): void => {},
};
+71
View File
@@ -0,0 +1,71 @@
import {
DESIGN_WIDTH,
DESIGN_HEIGHT,
TARGET_FPS,
MOVE_SPEED,
PlayerColorState,
JUMP_HEIGHT_STANDARD,
JUMP_HEIGHT_CHARGED,
JUMP_HEIGHT_YELLOW,
JUMP_PREPARE_DELAY_MS,
PARABOLIC_ANGLE_RIGHT,
PARABOLIC_ANGLE_LEFT,
SHURIKEN_INTERVAL_BASE,
SHURIKEN_INTERVAL_UPGRADED,
SWORD_INTERVAL,
PERF_TOUCH_RESPONSE_MAX_MS,
PERF_COMBO_RECOGNITION_MAX_MS,
STORAGE_KEY,
} from '@common/Constants';
describe('Constants — requirement baseline values', () => {
it('uses 960x540 landscape baseline (req tech-stack)', () => {
expect(DESIGN_WIDTH).toBe(960);
expect(DESIGN_HEIGHT).toBe(540);
expect(DESIGN_WIDTH / DESIGN_HEIGHT).toBeCloseTo(16 / 9, 3);
});
it('locks target frame rate at 30 fps (req 18.1-18.3)', () => {
expect(TARGET_FPS).toBe(30);
});
it('assigns 100/100/150 px/s move speeds per color state (req 5.1-5.2)', () => {
expect(MOVE_SPEED[PlayerColorState.Red]).toBe(100);
expect(MOVE_SPEED[PlayerColorState.Green]).toBe(100);
expect(MOVE_SPEED[PlayerColorState.Yellow]).toBe(150);
});
it('uses 250/375/300 jump heights (req 2.2-2.3)', () => {
expect(JUMP_HEIGHT_STANDARD).toBe(250);
expect(JUMP_HEIGHT_CHARGED).toBe(375);
expect(JUMP_HEIGHT_YELLOW).toBe(300);
});
it('reserves ~150ms crouch delay before leaving ground (req 2.8)', () => {
expect(JUMP_PREPARE_DELAY_MS).toBe(150);
});
it('parabolic angles anchored at 45° and 135° (req 2.5)', () => {
expect(PARABOLIC_ANGLE_RIGHT).toBe(45);
expect(PARABOLIC_ANGLE_LEFT).toBe(135);
});
it('weapon intervals conform to 0.3/0.25/0.5s (req 3.4/3.6)', () => {
expect(SHURIKEN_INTERVAL_BASE).toBe(0.3);
expect(SHURIKEN_INTERVAL_UPGRADED).toBe(0.25);
expect(SWORD_INTERVAL).toBe(0.5);
});
it('enforces <50ms touch and <100ms combo KPI thresholds (req 20.1/20.4)', () => {
expect(PERF_TOUCH_RESPONSE_MAX_MS).toBe(50);
expect(PERF_COMBO_RECOGNITION_MAX_MS).toBe(100);
});
it('defines all storage keys required by persistence (req 17 & 19.5)', () => {
expect(STORAGE_KEY.LevelUnlock).toBeDefined();
expect(STORAGE_KEY.ControlLayout).toBeDefined();
expect(STORAGE_KEY.AudioVolume).toBeDefined();
expect(STORAGE_KEY.TutorialDone).toBeDefined();
expect(STORAGE_KEY.StoryIntroSeen).toBeDefined();
});
});
+98
View File
@@ -0,0 +1,98 @@
import { EventBus } from '@common/EventBus';
describe('EventBus', () => {
let bus: EventBus;
beforeEach(() => {
bus = new EventBus();
});
it('delivers emitted payload to on() subscribers', () => {
const spy = jest.fn();
bus.on<number>('hit', spy);
bus.emit('hit', 42);
expect(spy).toHaveBeenCalledWith(42);
});
it('ignores duplicate subscriptions of the same handler', () => {
const spy = jest.fn();
bus.on('hit', spy);
bus.on('hit', spy);
bus.emit('hit', 1);
expect(spy).toHaveBeenCalledTimes(1);
expect(bus.listenerCount('hit')).toBe(1);
});
it('fires once() handlers exactly one time', () => {
const spy = jest.fn();
bus.once('hit', spy);
bus.emit('hit', 1);
bus.emit('hit', 2);
expect(spy).toHaveBeenCalledTimes(1);
expect(bus.listenerCount('hit')).toBe(0);
});
it('off(event, fn) removes only that handler', () => {
const a = jest.fn();
const b = jest.fn();
bus.on('hit', a);
bus.on('hit', b);
bus.off('hit', a);
bus.emit('hit', 1);
expect(a).not.toHaveBeenCalled();
expect(b).toHaveBeenCalledTimes(1);
});
it('off(event) clears all handlers of that event', () => {
const a = jest.fn();
const b = jest.fn();
bus.on('hit', a);
bus.on('hit', b);
bus.off('hit');
bus.emit('hit', 1);
expect(a).not.toHaveBeenCalled();
expect(b).not.toHaveBeenCalled();
expect(bus.listenerCount('hit')).toBe(0);
});
it('isolates exceptions — one bad listener does not break fan-out', () => {
const err = jest.fn();
bus.setErrorHook(err);
const good = jest.fn();
bus.on('hit', () => {
throw new Error('boom');
});
bus.on('hit', good);
bus.emit('hit', 1);
expect(good).toHaveBeenCalledTimes(1);
expect(err).toHaveBeenCalledTimes(1);
expect(err.mock.calls[0][0]).toBe('hit');
});
it('emit on an unknown event is a no-op', () => {
expect(() => bus.emit('ghost', 1)).not.toThrow();
});
it('clear() drops every handler of every event', () => {
bus.on('a', () => {});
bus.on('b', () => {});
bus.clear();
expect(bus.listenerCount('a')).toBe(0);
expect(bus.listenerCount('b')).toBe(0);
});
it('supports off for a non-existent handler without error', () => {
const spy = jest.fn();
bus.off('hit', spy);
expect(bus.listenerCount('hit')).toBe(0);
});
it('handles a once-handler unsubscribing itself during emit', () => {
const calls: number[] = [];
bus.once<number>('tick', (n) => calls.push(n));
bus.on<number>('tick', (n) => calls.push(n * 10));
bus.emit('tick', 1);
bus.emit('tick', 2);
expect(calls).toEqual([1, 10, 20]);
});
});
+78
View File
@@ -0,0 +1,78 @@
import { Logger, LogLevel } from '@common/Logger';
describe('Logger — leveled logging', () => {
let logger: Logger;
let sink: jest.Mock;
beforeEach(() => {
logger = new Logger();
sink = jest.fn();
logger.setSink(sink);
});
it('suppresses messages below the threshold', () => {
logger.setLevel(LogLevel.Warn);
logger.debug('mod', 'hidden');
logger.info('mod', 'hidden');
logger.warn('mod', 'shown');
logger.error('mod', 'shown');
expect(sink).toHaveBeenCalledTimes(2);
});
it('forwards module + message + rest args to the sink', () => {
logger.setLevel(LogLevel.Debug);
logger.info('Pool', 'size', 5);
expect(sink).toHaveBeenCalledWith(LogLevel.Info, 'Pool', 'size', 5);
});
it('Silent level disables all output', () => {
logger.setLevel(LogLevel.Silent);
logger.error('mod', 'ignored');
expect(sink).not.toHaveBeenCalled();
});
});
describe('Logger — performance metrics', () => {
let logger: Logger;
beforeEach(() => {
logger = new Logger();
});
it('records samples under a metric name', () => {
for (const v of [10, 20, 30, 40, 50]) {
logger.metric({ name: 'touch_ms', value: v });
}
const agg = logger.aggregate('touch_ms');
expect(agg).toBeDefined();
expect(agg!.count).toBe(5);
expect(agg!.min).toBe(10);
expect(agg!.max).toBe(50);
expect(agg!.avg).toBeCloseTo(30);
expect(agg!.p50).toBe(30);
expect(agg!.p95).toBe(50);
});
it('returns undefined aggregate for unknown metric', () => {
expect(logger.aggregate('ghost')).toBeUndefined();
});
it('timerStart/timerEnd records elapsed time', () => {
logger.timerStart('frame', 1000);
const elapsed = logger.timerEnd('frame', 1016);
expect(elapsed).toBe(16);
expect(logger.aggregate('frame')!.avg).toBe(16);
});
it('timerEnd without start returns undefined', () => {
expect(logger.timerEnd('never', 100)).toBeUndefined();
});
it('resetMetrics clears all samples and running timers', () => {
logger.metric({ name: 'a', value: 1 });
logger.timerStart('b', 0);
logger.resetMetrics();
expect(logger.aggregate('a')).toBeUndefined();
expect(logger.timerEnd('b', 10)).toBeUndefined();
});
});
+88
View File
@@ -0,0 +1,88 @@
import { ObjectPool } from '@common/ObjectPool';
interface Bullet {
x: number;
alive: boolean;
}
const makeBullet = (): Bullet => ({ x: 0, alive: false });
const resetBullet = (b: Bullet): void => {
b.x = 0;
b.alive = false;
};
describe('ObjectPool', () => {
it('creates new instances when the free list is empty', () => {
const pool = new ObjectPool<Bullet>({ factory: makeBullet });
const a = pool.acquire();
const b = pool.acquire();
expect(a).not.toBe(b);
expect(pool.stats().created).toBe(2);
expect(pool.borrowedCount).toBe(2);
expect(pool.freeCount).toBe(0);
});
it('reuses instances after release', () => {
const pool = new ObjectPool<Bullet>({ factory: makeBullet, resetter: resetBullet });
const a = pool.acquire();
a.x = 100;
a.alive = true;
pool.release(a);
expect(a.x).toBe(0);
expect(a.alive).toBe(false);
const b = pool.acquire();
expect(b).toBe(a);
expect(pool.stats().recycled).toBe(1);
});
it('pre-allocates instances when preAlloc is set', () => {
const pool = new ObjectPool<Bullet>({ factory: makeBullet, preAlloc: 5 });
expect(pool.freeCount).toBe(5);
expect(pool.stats().created).toBe(5);
});
it('discards excess releases when over maxSize', () => {
const pool = new ObjectPool<Bullet>({ factory: makeBullet, maxSize: 2 });
const a = pool.acquire();
const b = pool.acquire();
const c = pool.acquire();
pool.release(a);
pool.release(b);
pool.release(c); // should be dropped
expect(pool.freeCount).toBe(2);
});
it('fires onDoubleRelease and ignores double-release', () => {
const spy = jest.fn();
const pool = new ObjectPool<Bullet>({
factory: makeBullet,
onDoubleRelease: spy,
});
const a = pool.acquire();
pool.release(a);
pool.release(a);
expect(spy).toHaveBeenCalledTimes(1);
expect(pool.freeCount).toBe(1);
});
it('drain() clears both free and borrowed', () => {
const pool = new ObjectPool<Bullet>({ factory: makeBullet, preAlloc: 3 });
pool.acquire();
pool.drain();
expect(pool.freeCount).toBe(0);
expect(pool.borrowedCount).toBe(0);
});
it('stats track acquired / recycled totals', () => {
const pool = new ObjectPool<Bullet>({ factory: makeBullet });
const a = pool.acquire();
const b = pool.acquire();
pool.release(a);
pool.release(b);
pool.acquire();
const s = pool.stats();
expect(s.acquired).toBe(3);
expect(s.recycled).toBe(2);
expect(s.created).toBe(2);
});
});
+110
View File
@@ -0,0 +1,110 @@
import { Logger } from '@common/Logger';
import { CORE_PERF_THRESHOLDS, PerfMonitor } from '@common/PerfMonitor';
import {
PERF_TOUCH_RESPONSE_MAX_MS,
PERF_COMBO_RECOGNITION_MAX_MS,
MAX_FIRST_PACKAGE_BYTES,
MAX_AUDIO_BUNDLE_BYTES,
MAX_MEMORY_PEAK_BYTES,
} from '@common/Constants';
describe('PerfMonitor — threshold catalog (req 18 & 20)', () => {
it('includes every KPI threshold called out in requirements 20.1-20.5', () => {
const names = CORE_PERF_THRESHOLDS.map((t) => t.metric);
expect(names).toContain('input/touchStart');
expect(names).toContain('jump/state_toggle_ms');
expect(names).toContain('input/combo_recognition_ms');
expect(names).toContain('input/parabolic_accuracy');
expect(names).toContain('jump/air_jump_block_rate');
});
});
describe('PerfMonitor — pass/fail evaluation', () => {
function seedPassing(logger: Logger): void {
for (let i = 0; i < 100; i++) {
logger.metric({ name: 'input/touchStart', value: 20 });
logger.metric({ name: 'jump/state_toggle_ms', value: 25 });
logger.metric({ name: 'input/combo_recognition_ms', value: 40 });
}
logger.metric({ name: 'input/parabolic_accuracy', value: 0.97 });
logger.metric({ name: 'jump/air_jump_block_rate', value: 0.995 });
}
it('passes when all metrics are within budget', () => {
const logger = new Logger();
seedPassing(logger);
const monitor = new PerfMonitor(logger);
const report = monitor.collectReport();
expect(report.allPassing).toBe(true);
});
it('fails when no samples exist for a threshold', () => {
const monitor = new PerfMonitor(new Logger());
const report = monitor.collectReport();
expect(report.allPassing).toBe(false);
expect(report.checks[0].reason).toMatch(/no samples/);
});
it('fails when p95 latency exceeds the limit', () => {
const logger = new Logger();
seedPassing(logger);
// Push a huge batch of slow touches so p95 exceeds budget.
for (let i = 0; i < 200; i++) {
logger.metric({ name: 'input/touchStart', value: PERF_TOUCH_RESPONSE_MAX_MS + 50 });
}
const report = new PerfMonitor(logger).collectReport();
const touchCheck = report.checks.find((c) => c.threshold.metric === 'input/touchStart')!;
expect(touchCheck.passing).toBe(false);
});
it('fails when parabolic accuracy drops below 95%', () => {
const logger = new Logger();
seedPassing(logger);
logger.resetMetrics();
logger.metric({ name: 'input/touchStart', value: 20 });
logger.metric({ name: 'jump/state_toggle_ms', value: 20 });
logger.metric({ name: 'input/combo_recognition_ms', value: 20 });
logger.metric({ name: 'input/parabolic_accuracy', value: 0.8 });
logger.metric({ name: 'jump/air_jump_block_rate', value: 0.999 });
const report = new PerfMonitor(logger).collectReport();
expect(report.allPassing).toBe(false);
});
it('evaluates build-size budget when provided', () => {
const logger = new Logger();
logger.metric({ name: 'input/touchStart', value: 10 });
logger.metric({ name: 'jump/state_toggle_ms', value: 10 });
logger.metric({ name: 'input/combo_recognition_ms', value: 10 });
logger.metric({ name: 'input/parabolic_accuracy', value: 1 });
logger.metric({ name: 'jump/air_jump_block_rate', value: 1 });
const monitor = new PerfMonitor(logger);
const okReport = monitor.collectReport({
firstPackageBytes: MAX_FIRST_PACKAGE_BYTES - 1,
audioBundleBytes: MAX_AUDIO_BUNDLE_BYTES - 1,
memoryPeakBytes: MAX_MEMORY_PEAK_BYTES - 1,
});
expect(okReport.sizeBudgetPassing).toBe(true);
expect(okReport.allPassing).toBe(true);
const badReport = monitor.collectReport({
firstPackageBytes: MAX_FIRST_PACKAGE_BYTES + 1,
audioBundleBytes: MAX_AUDIO_BUNDLE_BYTES + 1,
memoryPeakBytes: MAX_MEMORY_PEAK_BYTES + 1,
});
expect(badReport.sizeBudgetPassing).toBe(false);
expect(badReport.allPassing).toBe(false);
});
it('honours the <= comparator at exactly the boundary', () => {
const logger = new Logger();
logger.metric({ name: 'input/touchStart', value: PERF_TOUCH_RESPONSE_MAX_MS });
logger.metric({ name: 'jump/state_toggle_ms', value: 10 });
logger.metric({ name: 'input/combo_recognition_ms', value: PERF_COMBO_RECOGNITION_MAX_MS });
logger.metric({ name: 'input/parabolic_accuracy', value: 1 });
logger.metric({ name: 'jump/air_jump_block_rate', value: 1 });
const report = new PerfMonitor(logger).collectReport();
const touch = report.checks.find((c) => c.threshold.metric === 'input/touchStart')!;
expect(touch.passing).toBe(true);
});
});
+71
View File
@@ -0,0 +1,71 @@
import { StorageMgr, IStorageDriver } from '@common/StorageMgr';
function makeMemoryDriver(): IStorageDriver {
const m = new Map<string, string>();
return {
getItem: (k) => (m.has(k) ? (m.get(k) as string) : null),
setItem: (k, v) => {
m.set(k, v);
},
removeItem: (k) => {
m.delete(k);
},
};
}
describe('StorageMgr', () => {
it('returns default when key is missing', () => {
const sm = new StorageMgr(makeMemoryDriver());
expect(sm.get('nope', { v: 1 })).toEqual({ v: 1 });
});
it('round-trips structured values via JSON', () => {
const sm = new StorageMgr(makeMemoryDriver());
const layout = { jump: { x: 100, y: 40 }, shuriken: { x: 820, y: 40 } };
sm.set('layout', layout);
expect(sm.get('layout', null)).toEqual(layout);
});
it('returns default when value is malformed JSON (req 17.6)', () => {
const broken: IStorageDriver = {
getItem: () => '{not json',
setItem: () => {},
removeItem: () => {},
};
const sm = new StorageMgr(broken);
expect(sm.get('x', 'fallback')).toBe('fallback');
});
it('does not throw when the driver throws (req 17.6)', () => {
const exploding: IStorageDriver = {
getItem: () => {
throw new Error('I/O error');
},
setItem: () => {
throw new Error('I/O error');
},
removeItem: () => {},
};
const sm = new StorageMgr(exploding);
expect(() => sm.get('x', 'ok')).not.toThrow();
expect(sm.get('x', 'ok')).toBe('ok');
expect(() => sm.set('x', 'value')).not.toThrow();
});
it('remove() deletes the key', () => {
const sm = new StorageMgr(makeMemoryDriver());
sm.set('k', 123);
sm.remove('k');
expect(sm.get('k', -1)).toBe(-1);
});
it('setDriver swaps the underlying driver', () => {
const sm = new StorageMgr(makeMemoryDriver());
sm.set('k', 1);
const fresh = makeMemoryDriver();
sm.setDriver(fresh);
expect(sm.get('k', 0)).toBe(0);
sm.set('k', 2);
expect(sm.get('k', 0)).toBe(2);
});
});
+58
View File
@@ -0,0 +1,58 @@
import { TimeMgr } from '@common/TimeMgr';
describe('TimeMgr', () => {
let tm: TimeMgr;
beforeEach(() => {
tm = new TimeMgr();
});
it('advances both clocks at timeScale 1', () => {
tm.update(1);
tm.update(0.5);
expect(tm.gameTime).toBeCloseTo(1.5);
expect(tm.realTime).toBeCloseTo(1.5);
});
it('freezes gameTime when paused but keeps realTime ticking', () => {
tm.pause();
tm.update(1);
expect(tm.gameTime).toBe(0);
expect(tm.realTime).toBe(1);
tm.resume();
tm.update(0.5);
expect(tm.gameTime).toBeCloseTo(0.5);
expect(tm.realTime).toBeCloseTo(1.5);
});
it('honors timeScale for slow-mo', () => {
tm.setTimeScale(0.5);
tm.update(2);
expect(tm.gameTime).toBeCloseTo(1);
expect(tm.realTime).toBe(2);
});
it('clamps negative timeScale to 0', () => {
tm.setTimeScale(-3);
expect(tm.timeScale).toBe(0);
tm.update(10);
expect(tm.gameTime).toBe(0);
});
it('scaledDelta() reflects pause and timeScale', () => {
tm.setTimeScale(2);
expect(tm.scaledDelta(0.1)).toBeCloseTo(0.2);
tm.pause();
expect(tm.scaledDelta(0.1)).toBe(0);
});
it('reset() zeros everything', () => {
tm.update(1);
tm.pause();
tm.setTimeScale(0.2);
tm.reset();
expect(tm.gameTime).toBe(0);
expect(tm.realTime).toBe(0);
expect(tm.timeScale).toBe(1);
expect(tm.paused).toBe(false);
});
});
+124
View File
@@ -0,0 +1,124 @@
import { ConfigMgr, MapJsonLoader } from '@data/ConfigMgr';
import { EnemyType, ItemType, WeaponType } from '@data/Interfaces';
// Import the real JSON delivered by task 2.1 — if these files are malformed
// the test suite will catch it on CI before any Cocos editor run.
import enemies from '../../assets/resources/configs/enemies.json';
import items from '../../assets/resources/configs/items.json';
import weapons from '../../assets/resources/configs/weapons.json';
import levels from '../../assets/resources/configs/levels.json';
import bosses from '../../assets/resources/configs/bosses.json';
import stories from '../../assets/resources/configs/stories.json';
function makeLoader(overrides: Partial<Record<string, unknown>> = {}) {
const base: Record<string, unknown> = {
'configs/enemies': enemies,
'configs/items': items,
'configs/weapons': weapons,
'configs/levels': levels,
'configs/bosses': bosses,
'configs/stories': stories,
};
return new MapJsonLoader({ ...base, ...overrides });
}
describe('ConfigMgr — happy path with delivered JSON', () => {
it('loads and validates the chapter-1 bundle', async () => {
const mgr = new ConfigMgr(makeLoader());
const bundle = await mgr.load();
expect(bundle.enemies.length).toBe(4);
expect(bundle.items.length).toBe(5);
expect(bundle.weapons.length).toBe(2);
expect(bundle.levels.length).toBe(5);
expect(bundle.bosses.length).toBe(1);
expect(bundle.stories.length).toBe(1);
});
it('resolves every enemy, item, weapon, level, boss, and story by id', async () => {
const mgr = new ConfigMgr(makeLoader());
await mgr.load();
expect(mgr.enemy(EnemyType.QingRen).displayName).toBe('青忍');
expect(mgr.item(ItemType.CrystalJade).displayName).toBe('水晶玉');
expect(mgr.weapon(WeaponType.NinjaSword).canParry).toBe(true);
expect(mgr.level('1-5').objective.kind).toBe('defeat_boss');
expect(mgr.boss('shuang_huan_fang').phases.length).toBe(3);
expect(mgr.story('chapter_1_intro').pages.length).toBe(3);
});
it('throws when accessed before load()', () => {
const mgr = new ConfigMgr(makeLoader());
expect(() => mgr.enemy(EnemyType.QingRen)).toThrow(/load\(\)/);
});
});
describe('ConfigMgr — validation rejects malformed configs', () => {
it('rejects a config bundle that contains the forbidden "casual" token (req 13.6)', async () => {
const polluted = JSON.parse(JSON.stringify(levels));
polluted[0].displayName = 'casual'; // inject disallowed token
const mgr = new ConfigMgr(makeLoader({ 'configs/levels': polluted }));
await expect(mgr.load()).rejects.toThrow(/casual/);
});
it('rejects an enemy entry missing required fields', async () => {
const bad = [{ id: 'qing_ren' }];
const mgr = new ConfigMgr(makeLoader({ 'configs/enemies': bad }));
await expect(mgr.load()).rejects.toThrow(/missing field/);
});
it('rejects an enemy with an unknown EnemyType id', async () => {
const bad = JSON.parse(JSON.stringify(enemies));
bad[0].id = 'not_a_real_enemy';
const mgr = new ConfigMgr(makeLoader({ 'configs/enemies': bad }));
await expect(mgr.load()).rejects.toThrow(/EnemyType/);
});
it('rejects a level referencing an unknown boss', async () => {
const bad = JSON.parse(JSON.stringify(levels));
bad[4].objective.bossId = 'ghost_boss';
const mgr = new ConfigMgr(makeLoader({ 'configs/levels': bad }));
await expect(mgr.load()).rejects.toThrow(/unknown boss/);
});
it('rejects a level spawn referencing an unknown enemy', async () => {
const bad = JSON.parse(JSON.stringify(levels));
bad[0].enemySpawns[0].type = 'white_ninja';
const mgr = new ConfigMgr(makeLoader({ 'configs/levels': bad }));
await expect(mgr.load()).rejects.toThrow(/unknown enemy/);
});
it('rejects boss phases that are not monotonically descending', async () => {
const bad = JSON.parse(JSON.stringify(bosses));
bad[0].phases = [
{ hpThreshold: 0.33, mode: 'a', actionIntervalSec: 1 },
{ hpThreshold: 1.0, mode: 'b', actionIntervalSec: 1 },
];
const mgr = new ConfigMgr(makeLoader({ 'configs/bosses': bad }));
await expect(mgr.load()).rejects.toThrow(/descending hpThreshold/);
});
it('rejects a story with fewer than 3 pages (req 19.2)', async () => {
const bad = JSON.parse(JSON.stringify(stories));
bad[0].pages = bad[0].pages.slice(0, 2);
const mgr = new ConfigMgr(makeLoader({ 'configs/stories': bad }));
await expect(mgr.load()).rejects.toThrow(/≥3 pages/);
});
it('rejects a story whose maxDurationSec exceeds the 30s budget (req 19.1)', async () => {
const bad = JSON.parse(JSON.stringify(stories));
bad[0].maxDurationSec = 45;
const mgr = new ConfigMgr(makeLoader({ 'configs/stories': bad }));
await expect(mgr.load()).rejects.toThrow(/30s budget/);
});
it('rejects a story with non-contiguous page indices', async () => {
const bad = JSON.parse(JSON.stringify(stories));
bad[0].pages[1].index = 5;
const mgr = new ConfigMgr(makeLoader({ 'configs/stories': bad }));
await expect(mgr.load()).rejects.toThrow(/contiguous/);
});
it('rejects an empty enemies list', async () => {
const mgr = new ConfigMgr(makeLoader({ 'configs/enemies': [] }));
await expect(mgr.load()).rejects.toThrow(/enemies list is empty/);
});
});
+106
View File
@@ -0,0 +1,106 @@
import { AttackController, IJumpStateProvider } from '@logic/AttackController';
import { WeaponType } from '@data/Interfaces';
import { PlayerColorState } from '@common/Constants';
function makeJumpState(ts?: number): IJumpStateProvider {
return {
lastJumpPressTs: () => ts,
isGrounded: () => ts === undefined,
};
}
describe('AttackController — mutual exclusion (req 3.1-3.3)', () => {
it('first-pressed weapon wins when both buttons go down together', () => {
const ac = new AttackController();
ac.press(WeaponType.Shuriken, 10);
ac.press(WeaponType.NinjaSword, 11);
expect(ac.getActive()).toBe(WeaponType.Shuriken);
});
it('releasing the active weapon transfers activation to the still-held one', () => {
const ac = new AttackController();
ac.press(WeaponType.Shuriken, 0);
ac.press(WeaponType.NinjaSword, 10);
ac.release(WeaponType.Shuriken);
expect(ac.getActive()).toBe(WeaponType.NinjaSword);
});
it('releasing the only pressed weapon deactivates everything', () => {
const ac = new AttackController();
ac.press(WeaponType.Shuriken, 0);
ac.release(WeaponType.Shuriken);
expect(ac.getActive()).toBe('none');
});
});
describe('AttackController — firing intervals (req 3.4, 3.6)', () => {
it('shuriken fires every 300ms for red/green, 250ms for yellow', () => {
const ac = new AttackController();
ac.press(WeaponType.Shuriken, 0);
expect(ac.tick(0, PlayerColorState.Red).length).toBe(1);
expect(ac.tick(299, PlayerColorState.Red).length).toBe(0);
expect(ac.tick(300, PlayerColorState.Red).length).toBe(1);
const fast = new AttackController();
fast.press(WeaponType.Shuriken, 0);
fast.tick(0, PlayerColorState.Yellow);
expect(fast.tick(249, PlayerColorState.Yellow).length).toBe(0);
expect(fast.tick(250, PlayerColorState.Yellow).length).toBe(1);
});
it('sword fires every 500ms', () => {
const ac = new AttackController();
ac.press(WeaponType.NinjaSword, 0);
ac.tick(0, PlayerColorState.Red);
expect(ac.tick(499, PlayerColorState.Red).length).toBe(0);
expect(ac.tick(500, PlayerColorState.Red).length).toBe(1);
});
it('shuriken burst index caps at SHURIKEN_BURST_MAX', () => {
const ac = new AttackController();
ac.press(WeaponType.Shuriken, 0);
const indexes: number[] = [];
for (let i = 0; i <= 600; i += 300) {
const fires = ac.tick(i, PlayerColorState.Red);
if (fires.length) indexes.push(fires[0].burstIndex);
}
expect(indexes).toEqual([1, 2, 3]);
// One more attempt must still cap at 3 not 4.
const more = ac.tick(900, PlayerColorState.Red);
expect(more[0].burstIndex).toBe(3);
});
});
describe('AttackController — combo window (req 4.1)', () => {
it('comboWithJump is true when jump timestamp is within 100ms', () => {
const ac = new AttackController(makeJumpState(95));
ac.press(WeaponType.Shuriken, 100);
const fires = ac.tick(100, PlayerColorState.Red);
expect(fires[0].comboWithJump).toBe(true);
});
it('comboWithJump is false when jump was pressed >100ms ago', () => {
const ac = new AttackController(makeJumpState(0));
ac.press(WeaponType.Shuriken, 200);
const fires = ac.tick(200, PlayerColorState.Red);
expect(fires[0].comboWithJump).toBe(false);
});
it('comboWithJump is false when the player has not jumped yet', () => {
const ac = new AttackController(makeJumpState(undefined));
ac.press(WeaponType.Shuriken, 0);
const fires = ac.tick(0, PlayerColorState.Red);
expect(fires[0].comboWithJump).toBe(false);
});
});
describe('AttackController — reset()', () => {
it('clears active / pressed / cooldowns', () => {
const ac = new AttackController();
ac.press(WeaponType.Shuriken, 0);
ac.tick(0, PlayerColorState.Red);
ac.reset();
expect(ac.getActive()).toBe('none');
expect(ac.isPressed(WeaponType.Shuriken)).toBe(false);
});
});
+126
View File
@@ -0,0 +1,126 @@
import { BossController } from '@logic/BossController';
import { BANNED_RESCUE_SEQUENCE, ChapterSettlement } from '@logic/ChapterSettlement';
import { IBossConfig } from '@data/Interfaces';
const bossCfg: IBossConfig = {
id: 'shuang_huan_fang',
displayName: '双幻坊',
hp: 3,
butterflyReveal: true,
princessCutsceneAtHpRatio: 0.5,
phases: [
{ hpThreshold: 1.0, mode: 'pair_pincer', actionIntervalSec: 2.2 },
{ hpThreshold: 0.66, mode: 'fireball_spread', actionIntervalSec: 1.8 },
{ hpThreshold: 0.33, mode: 'clone_confuse', actionIntervalSec: 1.4 },
],
};
describe('BossController — butterfly reveal (req 9.1-9.3)', () => {
it('body hits are ignored until butterfly is hit', () => {
const boss = new BossController(bossCfg);
expect(boss.onBodyHit()).toEqual([]);
expect(boss.currentHp).toBe(3);
});
it('butterfly hit emits reveal event once', () => {
const boss = new BossController(bossCfg);
expect(boss.onButterflyHit()).toEqual([{ kind: 'butterfly_revealed' }]);
expect(boss.onButterflyHit()).toEqual([]); // second hit is a no-op
expect(boss.isButterflyRevealed).toBe(true);
});
it('body hits after reveal decrement HP one at a time', () => {
const boss = new BossController(bossCfg);
boss.onButterflyHit();
boss.onBodyHit();
expect(boss.currentHp).toBe(2);
});
});
describe('BossController — phase transitions (req 9.4)', () => {
it('HP drop to 2/3 triggers phase_changed to fireball_spread', () => {
const boss = new BossController(bossCfg);
boss.onButterflyHit();
const events = boss.onBodyHit(); // 3 → 2 (ratio 0.66)
expect(events.some((e) => e.kind === 'phase_changed' && e.phase === 'fireball_spread')).toBe(true);
});
it('HP drop to 1/3 triggers clone_confuse', () => {
const boss = new BossController(bossCfg);
boss.onButterflyHit();
boss.onBodyHit();
const events = boss.onBodyHit(); // 2 → 1 (ratio 0.33)
expect(events.some((e) => e.kind === 'phase_changed' && e.phase === 'clone_confuse')).toBe(true);
});
});
describe('BossController — princess cutscene + death (req 8.6, 14.1)', () => {
it('emits princess_taken_cutscene when HP reaches 1/2', () => {
const boss = new BossController(bossCfg);
boss.onButterflyHit();
const events = boss.onBodyHit(); // 3 → 2 → ratio 0.66 (> 0.5, no cutscene yet)
expect(events.some((e) => e.kind === 'princess_taken_cutscene')).toBe(false);
const events2 = boss.onBodyHit(); // 2 → 1 → ratio 0.33 (< 0.5)
expect(events2.some((e) => e.kind === 'princess_taken_cutscene')).toBe(true);
});
it('emits boss_killed on final hit and marks isDead', () => {
const boss = new BossController(bossCfg);
boss.onButterflyHit();
boss.onBodyHit();
boss.onBodyHit();
const events = boss.onBodyHit();
expect(events.some((e) => e.kind === 'boss_killed')).toBe(true);
expect(boss.isDead).toBe(true);
});
it('further body hits after death are no-ops', () => {
const boss = new BossController(bossCfg);
boss.onButterflyHit();
boss.onBodyHit();
boss.onBodyHit();
boss.onBodyHit();
expect(boss.onBodyHit()).toEqual([]);
});
});
describe('ChapterSettlement — rope-cut ban (req 14.5)', () => {
it('assertCutsceneAllowed throws on every banned id', () => {
const cs = new ChapterSettlement({
totalScore: 0,
stageScore: 0,
comboCount: 0,
flawless: true,
remainingTimeSec: 0,
});
for (const bad of BANNED_RESCUE_SEQUENCE) {
expect(() => cs.assertCutsceneAllowed(bad)).toThrow(/banned/);
}
});
it('allows the legitimate cutscene ids', () => {
const cs = new ChapterSettlement({
totalScore: 0,
stageScore: 0,
comboCount: 0,
flawless: true,
remainingTimeSec: 0,
});
expect(() => cs.assertCutsceneAllowed('princess_taken')).not.toThrow();
expect(() => cs.assertCutsceneAllowed('boss_killed_freeze')).not.toThrow();
expect(() => cs.assertCutsceneAllowed('settlement_screen')).not.toThrow();
});
it('build() returns the "princess taken" closing line, not a rescue one', () => {
const cs = new ChapterSettlement({
totalScore: 1000,
stageScore: 500,
comboCount: 2,
flawless: true,
remainingTimeSec: 10,
});
const r = cs.build();
expect(r.closingLine).toMatch(/公主被带走/);
expect(r.closingLine).not.toMatch(/救/);
});
});
+94
View File
@@ -0,0 +1,94 @@
import { ConfigMgr, MapJsonLoader } from '@data/ConfigMgr';
import { LevelMgr } from '@logic/LevelMgr';
import { cameraFromLevel } from '@logic/CameraScroller';
import { EnemyType } from '@data/Interfaces';
// Reuse delivered JSON.
import enemies from '../../assets/resources/configs/enemies.json';
import items from '../../assets/resources/configs/items.json';
import weapons from '../../assets/resources/configs/weapons.json';
import levels from '../../assets/resources/configs/levels.json';
import bosses from '../../assets/resources/configs/bosses.json';
import stories from '../../assets/resources/configs/stories.json';
async function loadBundle() {
const mgr = new ConfigMgr(
new MapJsonLoader({
'configs/enemies': enemies,
'configs/items': items,
'configs/weapons': weapons,
'configs/levels': levels,
'configs/bosses': bosses,
'configs/stories': stories,
})
);
await mgr.load();
return mgr;
}
describe('Chapter-1 levels — JSON × LevelMgr integration (task 7.2)', () => {
it('exposes all 5 levels (1-1 … 1-5)', async () => {
const mgr = await loadBundle();
for (const id of ['1-1', '1-2', '1-3', '1-4', '1-5']) {
expect(mgr.level(id)).toBeDefined();
}
});
it('1-1 requires killing 3 妖坊 within 75s (req 8.1)', async () => {
const mgr = await loadBundle();
const lv = mgr.level('1-1');
expect(lv.timeLimitSec).toBe(75);
expect(lv.objective).toEqual({ kind: 'kill_count', enemy: EnemyType.YaoFang, count: 3 });
});
it('1-3 is a bi-directional cave stage with 10 青忍 objective (req 8.3)', async () => {
const mgr = await loadBundle();
const lv = mgr.level('1-3');
expect(lv.scrollDirection).toBe('horizontal_bi');
expect(lv.objective).toEqual({ kind: 'kill_count', enemy: EnemyType.QingRen, count: 10 });
});
it('1-4 is a vertical castle-wall stage with reach_top objective (req 8.4)', async () => {
const mgr = await loadBundle();
const lv = mgr.level('1-4');
expect(lv.scrollDirection).toBe('vertical');
expect(lv.objective).toEqual({ kind: 'reach_top' });
});
it('1-5 is a defeat-boss objective pointing at 双幻坊 (req 8.5)', async () => {
const mgr = await loadBundle();
const lv = mgr.level('1-5');
expect(lv.objective.kind).toBe('defeat_boss');
expect(lv.objective.bossId).toBe('shuang_huan_fang');
});
it('CameraScroller instantiates cleanly from each chapter-1 level', async () => {
const mgr = await loadBundle();
for (const id of ['1-1', '1-2', '1-3', '1-4', '1-5']) {
const cam = cameraFromLevel(mgr.level(id));
expect(cam.offsetX).toBe(0);
expect(cam.offsetY).toBe(0);
}
});
it('LevelMgr drives every level to victory via its configured objective', async () => {
const mgr = await loadBundle();
for (const id of ['1-1', '1-2', '1-3', '1-4', '1-5']) {
const lv = new LevelMgr(mgr.level(id));
switch (lv.level.objective.kind) {
case 'kill_count':
for (let k = 0; k < (lv.level.objective.count ?? 0); k++) {
lv.onEnemyKilled(lv.level.objective.enemy!);
}
break;
case 'reach_top':
lv.onReachedTop();
break;
case 'defeat_boss':
lv.onBossKilled();
break;
}
expect(lv.tick(0.016)).toBe('victory');
}
});
});
+86
View File
@@ -0,0 +1,86 @@
import { DamageSystem, FIREBALL_KILL_RADIUS, SMOKE_KILL_RADIUS } from '@logic/DamageSystem';
import { PlayerStateMachine } from '@logic/PlayerStateMachine';
import { PlayerColorState } from '@common/Constants';
function setup() {
const psm = new PlayerStateMachine();
return { psm, ds: new DamageSystem(psm) };
}
describe('DamageSystem — fireball distance gate (req 10.4)', () => {
it('misses when distance exceeds FIREBALL_KILL_RADIUS', () => {
const { ds } = setup();
const r = ds.applyToPlayer({
attackType: 'fireball',
attackerX: 0,
attackerY: 0,
victimX: FIREBALL_KILL_RADIUS + 10,
victimY: 0,
});
expect(r).toBeNull();
});
it('kills when within radius regardless of color', () => {
const { psm, ds } = setup();
psm.pickupCrystalJade();
psm.pickupCrystalJade();
const r = ds.applyToPlayer({
attackType: 'fireball',
attackerX: 0,
attackerY: 0,
victimX: 50,
victimY: 0,
});
expect(r!.kind).toBe('died');
expect(psm.color).toBe(PlayerColorState.Red);
});
});
describe('DamageSystem — smoke bomb distance gate (req 10.5)', () => {
it('misses when distance exceeds SMOKE_KILL_RADIUS', () => {
const { ds } = setup();
const r = ds.applyToPlayer({
attackType: 'smoke_bomb',
attackerX: 0,
attackerY: 0,
victimX: SMOKE_KILL_RADIUS + 1,
victimY: 0,
});
expect(r).toBeNull();
});
it('kills when within radius', () => {
const { ds } = setup();
const r = ds.applyToPlayer({
attackType: 'smoke_bomb',
attackerX: 0,
attackerY: 0,
victimX: 40,
victimY: 0,
});
expect(r!.kind).toBe('died');
});
});
describe('DamageSystem — precedence (req 10.3)', () => {
it('i-frames beat fireball distance check', () => {
const { psm, ds } = setup();
psm.takeHit('shuriken'); // die → consumes a life and starts i-frames
const r = ds.applyToPlayer({
attackType: 'fireball',
attackerX: 0,
attackerY: 0,
victimX: 10,
victimY: 0,
});
expect(r).toEqual({ kind: 'no_effect', reason: 'iframe' });
});
});
describe('DamageSystem — applyToEnemy helper', () => {
it('reduces HP and floors at 0', () => {
const { ds } = setup();
expect(ds.applyToEnemy(3, 2)).toBe(1);
expect(ds.applyToEnemy(1, 5)).toBe(0);
});
});
+67
View File
@@ -0,0 +1,67 @@
import { DropSystem } from '@logic/DropSystem';
import { EnemyType, ItemType } from '@data/Interfaces';
describe('DropSystem — crystal jade deterministic rule (req 7.1)', () => {
it('spawns a crystal jade on exactly the 12th kill', () => {
const ds = new DropSystem({ random: () => 1 });
let crystalEvents = 0;
for (let i = 1; i <= 24; i++) {
const drops = ds.onEnemyKilled(EnemyType.YaoFang, { x: 100, y: 0 });
if (drops.some((d) => d.item === ItemType.CrystalJade)) crystalEvents++;
}
expect(crystalEvents).toBe(2);
});
it('spawns the crystal above the kill point', () => {
const ds = new DropSystem({ random: () => 1 });
for (let i = 0; i < 11; i++) ds.onEnemyKilled(EnemyType.QingRen, { x: 0, y: 0 });
const drops = ds.onEnemyKilled(EnemyType.QingRen, { x: 300, y: 20 });
const crystal = drops.find((d) => d.item === ItemType.CrystalJade);
expect(crystal!.y).toBeGreaterThan(20);
});
});
describe('DropSystem — Chi Ren consecutive rule (req 7.3)', () => {
it('drops dian_wan or shu_wan on the 3rd consecutive Chi Ren kill if RNG <0.5', () => {
const ds = new DropSystem({
dianShuWanProbability: 0.5,
random: (() => {
// probability check then which-item check both pass
const vals = [0.1, 0.2];
let i = 0;
return () => vals[i++ % vals.length];
})(),
});
ds.onEnemyKilled(EnemyType.ChiRen, { x: 0, y: 0 });
ds.onEnemyKilled(EnemyType.ChiRen, { x: 0, y: 0 });
const drops = ds.onEnemyKilled(EnemyType.ChiRen, { x: 50, y: 10 });
expect(drops.find((d) => d.item === ItemType.DianWan)).toBeDefined();
});
it('does not drop when RNG fails probability', () => {
const ds = new DropSystem({ dianShuWanProbability: 0.5, random: () => 0.95 });
ds.onEnemyKilled(EnemyType.ChiRen, { x: 0, y: 0 });
ds.onEnemyKilled(EnemyType.ChiRen, { x: 0, y: 0 });
const drops = ds.onEnemyKilled(EnemyType.ChiRen, { x: 0, y: 0 });
expect(drops.filter((d) => d.item !== ItemType.CrystalJade).length).toBe(0);
});
it('non-Chi-Ren kill resets the consecutive counter', () => {
const ds = new DropSystem({ dianShuWanProbability: 1.0, random: () => 0 });
ds.onEnemyKilled(EnemyType.ChiRen, { x: 0, y: 0 });
ds.onEnemyKilled(EnemyType.ChiRen, { x: 0, y: 0 });
ds.onEnemyKilled(EnemyType.QingRen, { x: 0, y: 0 });
const drops = ds.onEnemyKilled(EnemyType.ChiRen, { x: 0, y: 0 });
// Only 1 Chi Ren kill since reset — below threshold.
expect(drops.filter((d) => d.item === ItemType.DianWan || d.item === ItemType.ShuWan).length).toBe(0);
});
});
describe('DropSystem — reset()', () => {
it('zeroes kill counters', () => {
const ds = new DropSystem();
ds.onEnemyKilled(EnemyType.QingRen, { x: 0, y: 0 });
ds.reset();
expect(ds.kills).toBe(0);
});
});
+116
View File
@@ -0,0 +1,116 @@
import { ChiRenAI, EnemyManager, HeiRenAI, QingRenAI, YaoFangAI } from '@logic/EnemyAI';
import { EnemyType, IEnemyConfig } from '@data/Interfaces';
function cfg(id: EnemyType, intervalSec: number, speed = 0): IEnemyConfig {
return {
id,
displayName: id,
size: { w: 16, h: 16 },
moveSpeed: speed,
attackIntervalSec: intervalSec,
attacks: ['shuriken'],
hp: 1,
};
}
const idlePlayer = { x: 300, y: 16, isGrounded: true };
describe('QingRenAI (req 6.1)', () => {
it('throws a shuriken at the player when out of melee range', () => {
const ai = new QingRenAI(cfg(EnemyType.QingRen, 2.0), 0, 16);
const actions = ai.update({ dtSec: 2.0, nowMs: 0, player: idlePlayer });
expect(actions[0].kind).toBe('fire_bullet');
expect(actions[0].attackType).toBe('shuriken');
expect(actions[0].velX).toBeGreaterThan(0);
});
it('melee swings when player is close', () => {
const ai = new QingRenAI(cfg(EnemyType.QingRen, 2.0), 310, 16);
const actions = ai.update({ dtSec: 2.0, nowMs: 0, player: idlePlayer });
expect(actions[0].kind).toBe('melee_swing');
});
it('respects attack interval — no burst within one tick', () => {
const ai = new QingRenAI(cfg(EnemyType.QingRen, 2.0), 0, 16);
ai.update({ dtSec: 2.0, nowMs: 0, player: idlePlayer });
const actions = ai.update({ dtSec: 0.1, nowMs: 0, player: idlePlayer });
expect(actions.length).toBe(0);
});
});
describe('ChiRenAI (req 6.2-6.3)', () => {
it('moves horizontally toward the player at 120 px/s', () => {
const ai = new ChiRenAI(cfg(EnemyType.ChiRen, 1.5, 120), 0, 16);
ai.update({ dtSec: 1, nowMs: 0, player: idlePlayer });
expect(ai.pos.x).toBeGreaterThan(0);
});
it('throws smoke bombs at the configured cadence', () => {
const ai = new ChiRenAI(cfg(EnemyType.ChiRen, 1.5, 120), 0, 16);
const actions = ai.update({ dtSec: 1.5, nowMs: 0, player: idlePlayer });
expect(actions.some((a) => a.attackType === 'smoke_bomb')).toBe(true);
});
it('bumps upward when the player is idle within intercept range', () => {
const ai = new ChiRenAI(cfg(EnemyType.ChiRen, 1.5, 120), 250, 16);
const before = ai.pos.y;
ai.update({ dtSec: 0.016, nowMs: 0, player: idlePlayer });
expect(ai.pos.y).toBeGreaterThan(before);
});
});
describe('HeiRenAI (req 6.5)', () => {
it('drops exactly one magic flute on kill', () => {
const ai = new HeiRenAI(cfg(EnemyType.HeiRen, 2.5), 100, 16);
const firstDrop = ai.onKilled();
const secondDrop = ai.onKilled();
expect(firstDrop[0].itemId).toBe('mo_di');
expect(secondDrop.length).toBe(0);
});
});
describe('YaoFangAI (req 6.6)', () => {
it('launches straight-line fireballs at 3s cadence', () => {
const ai = new YaoFangAI(cfg(EnemyType.YaoFang, 3.0), 0, 16);
const a = ai.update({ dtSec: 3.0, nowMs: 0, player: idlePlayer });
expect(a[0].attackType).toBe('fireball');
});
});
describe('EnemyManager culling (req 6.7)', () => {
it('skips update for enemies outside the camera cull rect', () => {
const mgr = new EnemyManager();
const far = new YaoFangAI(cfg(EnemyType.YaoFang, 3.0), 5000, 16);
mgr.spawn(far);
const actions = mgr.update(3.0, 0, idlePlayer, {
leftX: 0,
rightX: 960,
topY: 540,
bottomY: 0,
});
expect(actions.length).toBe(0);
});
it('updates enemies inside the cull rect', () => {
const mgr = new EnemyManager();
const near = new YaoFangAI(cfg(EnemyType.YaoFang, 3.0), 200, 16);
mgr.spawn(near);
const actions = mgr.update(3.0, 0, idlePlayer, {
leftX: 0,
rightX: 960,
topY: 540,
bottomY: 0,
});
expect(actions.length).toBe(1);
expect(actions[0].kind).toBe('fire_bullet');
});
it('kill() converts Hei Ren to a magic-flute drop', () => {
const mgr = new EnemyManager();
const hei = new HeiRenAI(cfg(EnemyType.HeiRen, 2.5), 50, 16);
mgr.spawn(hei);
const drops = mgr.kill(hei);
expect(drops[0].itemId).toBe('mo_di');
expect(hei.alive).toBe(false);
});
});
+105
View File
@@ -0,0 +1,105 @@
import { PlayerMotionModel } from '@logic/PlayerMotionModel';
import { JumpController, PARABOLIC_HORIZONTAL_SPEED, heightToImpulse } from '@logic/JumpController';
import {
JUMP_HEIGHT_STANDARD,
JUMP_HEIGHT_CHARGED,
JUMP_HEIGHT_YELLOW,
JUMP_PREPARE_DELAY_MS,
PlayerColorState,
} from '@common/Constants';
function newPair(color: PlayerColorState = PlayerColorState.Red) {
const motion = new PlayerMotionModel({
aabb: { x: 0, y: 16, w: 16, h: 32 },
platforms: [{ topY: 0, leftX: -500, rightX: 500 }],
initialColorState: color,
});
motion.update(0.016); // settle on ground
const jump = new JumpController(motion);
return { motion, jump };
}
describe('JumpController — press / release lifecycle (req 2.2, 2.3, 2.4, 2.8)', () => {
it('refuses to press when airborne (req 2.4)', () => {
const { motion, jump } = newPair();
motion.applyJumpImpulse(500); // lift off manually
const res = jump.pressJump(0);
expect(res.reason).toBe('airborne');
expect(jump.isButtonEnabled()).toBe(false);
});
it('standard-press + quick release → standard jump height', () => {
const { jump } = newPair();
jump.pressJump(0);
const res = jump.releaseJump(100, 'horizontal');
expect(res.height).toBe(JUMP_HEIGHT_STANDARD);
expect(res.phase).toBe('crouching');
});
it('holding ≥500ms produces the charged jump (req 2.3)', () => {
const { jump } = newPair();
jump.pressJump(0);
const res = jump.releaseJump(600, 'horizontal');
expect(res.height).toBe(JUMP_HEIGHT_CHARGED);
});
it('yellow color state uses 300px baseline jump (req 2.2)', () => {
const { jump } = newPair(PlayerColorState.Yellow);
jump.pressJump(0);
const res = jump.releaseJump(50, 'horizontal', PlayerColorState.Yellow);
expect(res.height).toBe(JUMP_HEIGHT_YELLOW);
});
});
describe('JumpController — parabolic impulse (req 2.5)', () => {
it('parabolic_right imparts +PARABOLIC_HORIZONTAL_SPEED', () => {
const { jump } = newPair();
jump.pressJump(0);
const res = jump.releaseJump(100, 'parabolic_right');
expect(res.horizontalImpulse).toBe(PARABOLIC_HORIZONTAL_SPEED);
});
it('parabolic_left imparts PARABOLIC_HORIZONTAL_SPEED', () => {
const { jump } = newPair();
jump.pressJump(0);
const res = jump.releaseJump(100, 'parabolic_left');
expect(res.horizontalImpulse).toBe(-PARABOLIC_HORIZONTAL_SPEED);
});
});
describe('JumpController — crouch delay + launch + re-enable (req 2.8, 2.4)', () => {
it('does not apply impulse until after 150ms crouch delay', () => {
const { motion, jump } = newPair();
jump.pressJump(0);
jump.releaseJump(100, 'horizontal');
// Inside the crouch window — still grounded because impulse not applied.
jump.tick(200);
expect(motion.vy).toBe(0);
expect(motion.isGrounded).toBe(true);
jump.tick(100 + JUMP_PREPARE_DELAY_MS + 1);
expect(motion.vy).toBeGreaterThan(0);
expect(motion.isGrounded).toBe(false);
});
it('re-enables the jump button once the player lands again', () => {
const { motion, jump } = newPair();
jump.pressJump(0);
jump.releaseJump(50, 'horizontal');
jump.tick(50 + JUMP_PREPARE_DELAY_MS + 1);
// Let gravity bring the player back down.
for (let i = 0; i < 120; i++) motion.update(1 / 60);
jump.tick(1_000);
expect(motion.isGrounded).toBe(true);
expect(jump.isButtonEnabled()).toBe(true);
});
});
describe('heightToImpulse — physics math', () => {
it('computes impulse such that peak equals the requested height', () => {
const g = 2500;
const h = 250;
const v0 = heightToImpulse(h, g);
// At apex v=0 ⇒ t = v0/g ⇒ peak = v0 * t - 0.5 * g * t^2 ⇒ v0^2 / (2g)
expect((v0 * v0) / (2 * g)).toBeCloseTo(h, 3);
});
});
+130
View File
@@ -0,0 +1,130 @@
import { CameraScroller, PARALLAX_LAYERS, PARALLAX_RATIOS, cameraFromLevel } from '@logic/CameraScroller';
import { LevelMgr } from '@logic/LevelMgr';
import { EnemyType, ILevelConfig } from '@data/Interfaces';
const HORIZONTAL_LEVEL: ILevelConfig = {
id: '1-1',
chapter: 1,
displayName: '初始森林',
sceneTheme: 'forest',
scrollDirection: 'horizontal',
timeLimitSec: 75,
objective: { kind: 'kill_count', enemy: EnemyType.YaoFang, count: 3 },
levelLengthPx: 3840,
bgm: 'bgm_forest',
enemySpawns: [],
};
describe('CameraScroller — horizontal (req 8.1)', () => {
it('camera scrolls forward as player advances', () => {
const cam = cameraFromLevel(HORIZONTAL_LEVEL, 960, 540);
cam.followPlayer(480, 270);
expect(cam.offsetX).toBe(0);
cam.followPlayer(900, 270);
expect(cam.offsetX).toBe(420);
});
it('camera never rewinds on horizontal scroll (req 8.1)', () => {
const cam = cameraFromLevel(HORIZONTAL_LEVEL);
cam.followPlayer(1500, 270);
const forward = cam.offsetX;
cam.followPlayer(100, 270);
expect(cam.offsetX).toBe(forward); // did not rewind
});
it('camera stops at level end', () => {
const cam = cameraFromLevel(HORIZONTAL_LEVEL);
cam.followPlayer(10_000, 270);
expect(cam.offsetX).toBe(HORIZONTAL_LEVEL.levelLengthPx - 960);
});
});
describe('CameraScroller — parallax layers (req 8.8 — 1:2:4:4)', () => {
it('exposes 4 layers with ratios 1,2,4,4', () => {
expect(PARALLAX_LAYERS).toEqual(['far', 'mid', 'near', 'fx']);
expect([...PARALLAX_RATIOS]).toEqual([1, 2, 4, 4]);
});
it('far/mid/near produce progressively smaller offsets', () => {
const cam = cameraFromLevel(HORIZONTAL_LEVEL);
cam.followPlayer(1200, 270);
const far = cam.offsetForLayer('far');
const mid = cam.offsetForLayer('mid');
const near = cam.offsetForLayer('near');
expect(far.x).toBeGreaterThan(mid.x);
expect(mid.x).toBeGreaterThan(near.x);
});
});
describe('CameraScroller — bi-directional + vertical', () => {
it('horizontal_bi rewinds when the player walks backward', () => {
const cam = new CameraScroller({
direction: 'horizontal_bi',
lengthX: 4800,
viewportW: 960,
viewportH: 540,
});
cam.followPlayer(2000, 0);
cam.followPlayer(400, 0);
expect(cam.offsetX).toBe(0);
});
it('vertical rising clamps at top', () => {
const cam = new CameraScroller({
direction: 'vertical',
lengthX: 960,
lengthY: 3240,
viewportW: 960,
viewportH: 540,
});
cam.followPlayer(480, 6000);
expect(cam.offsetY).toBe(3240 - 540);
});
});
describe('LevelMgr — objective / timer / result', () => {
it('reports victory when kill objective is met', () => {
const lv = new LevelMgr(HORIZONTAL_LEVEL);
lv.onEnemyKilled(EnemyType.YaoFang);
lv.onEnemyKilled(EnemyType.YaoFang);
lv.onEnemyKilled(EnemyType.YaoFang);
expect(lv.tick(0.016)).toBe('victory');
});
it('reports timeout when time-limit expires', () => {
const lv = new LevelMgr(HORIZONTAL_LEVEL);
expect(lv.tick(HORIZONTAL_LEVEL.timeLimitSec + 0.1)).toBe('timeout');
});
it('player_dead is terminal and outranks victory', () => {
const lv = new LevelMgr(HORIZONTAL_LEVEL);
lv.onPlayerDied();
expect(lv.tick(0.1)).toBe('player_dead');
});
it('reach_top objective', () => {
const cfg: ILevelConfig = { ...HORIZONTAL_LEVEL, objective: { kind: 'reach_top' } };
const lv = new LevelMgr(cfg);
lv.onReachedTop();
expect(lv.tick(0.016)).toBe('victory');
});
it('defeat_boss objective', () => {
const cfg: ILevelConfig = {
...HORIZONTAL_LEVEL,
objective: { kind: 'defeat_boss', bossId: 'shuang_huan_fang' },
};
const lv = new LevelMgr(cfg);
lv.onBossKilled();
expect(lv.tick(0.016)).toBe('victory');
});
it('result() returns kills and remaining seconds', () => {
const lv = new LevelMgr(HORIZONTAL_LEVEL);
lv.onEnemyKilled(EnemyType.QingRen);
lv.tick(10);
const r = lv.result();
expect(r.kills[EnemyType.QingRen]).toBe(1);
expect(r.remainingSec).toBeCloseTo(65, 1);
});
});
+106
View File
@@ -0,0 +1,106 @@
import { PlayerMotionModel, IPlatform, DEFAULT_GRAVITY } from '@logic/PlayerMotionModel';
import { MOVE_SPEED, PlayerColorState } from '@common/Constants';
function makeGroundPlatform(): IPlatform {
return { topY: 0, leftX: -1000, rightX: 1000 };
}
function makeModel(color: PlayerColorState = PlayerColorState.Red) {
return new PlayerMotionModel({
aabb: { x: 0, y: 16, w: 16, h: 32 }, // character 16x32 resting on y=0 ground
platforms: [makeGroundPlatform()],
initialColorState: color,
});
}
describe('PlayerMotionModel — horizontal movement (req 2.1, 5.1-5.2)', () => {
it('stands still on initialisation', () => {
const m = makeModel();
m.update(0.016);
expect(m.vx).toBe(0);
expect(m.isGrounded).toBe(true);
});
it('moves at 100 px/s in red state', () => {
const m = makeModel(PlayerColorState.Red);
m.update(0.016); // settle
m.setHorizontalInput(1);
m.update(1);
expect(m.vx).toBe(MOVE_SPEED[PlayerColorState.Red]);
expect(m.aabb.x).toBeCloseTo(100, 1);
});
it('moves at 150 px/s in yellow state', () => {
const m = makeModel(PlayerColorState.Yellow);
m.update(0.016);
m.setHorizontalInput(-1);
m.update(1);
expect(m.vx).toBe(-MOVE_SPEED[PlayerColorState.Yellow]);
});
it('reflects speed immediately when setColorState is called mid-run', () => {
const m = makeModel(PlayerColorState.Red);
m.update(0.016);
m.setHorizontalInput(1);
m.setColorState(PlayerColorState.Yellow);
m.update(1);
expect(m.vx).toBe(150);
});
});
describe('PlayerMotionModel — jump / gravity (req 2.4, 13.4)', () => {
it('applyJumpImpulse is rejected when in the air (req 2.4)', () => {
const m = makeModel();
m.update(0.016);
expect(m.applyJumpImpulse(600)).toBe(true); // first jump succeeds
expect(m.isGrounded).toBe(false);
expect(m.applyJumpImpulse(600)).toBe(false); // second jump in air denied
});
it('gravity pulls the player back to the ground', () => {
const m = makeModel();
m.update(0.016);
m.applyJumpImpulse(600);
// Simulate ~1 second of flight — gravity reclaims the player.
for (let i = 0; i < 120; i++) m.update(1 / 60);
expect(m.isGrounded).toBe(true);
expect(m.vy).toBe(0);
});
it('preserves mid-air vx (起跳定型 — req 13.4)', () => {
const m = makeModel();
m.update(0.016);
// Build horizontal speed + jump.
m.setHorizontalInput(1);
m.update(0.016);
m.applyJumpImpulse(500);
const airVx = m.vx;
// Even if the player now tries to flip direction while airborne, vx stays put.
m.setHorizontalInput(-1);
m.update(0.05);
expect(m.vx).toBe(airVx);
});
it('applyHorizontalImpulse overrides vx (for parabolic jumps, req 2.5)', () => {
const m = makeModel();
m.update(0.016);
m.applyJumpImpulse(600);
m.applyHorizontalImpulse(120);
m.update(0.016);
expect(m.vx).toBe(120);
});
});
describe('PlayerMotionModel — platform switching', () => {
it('setPlatforms clears grounded and re-settles on next update', () => {
const m = makeModel();
m.update(0.016);
expect(m.isGrounded).toBe(true);
m.setPlatforms([{ topY: -500, leftX: -10, rightX: 10 }]);
expect(m.isGrounded).toBe(false);
});
it('gravity constant matches the documented default', () => {
expect(DEFAULT_GRAVITY).toBe(2500);
});
});
+119
View File
@@ -0,0 +1,119 @@
import { PlayerStateMachine } from '@logic/PlayerStateMachine';
import { PlayerColorState, PLAYER_IFRAME_SECONDS } from '@common/Constants';
describe('PlayerStateMachine — auto-upgrade (req 5.1-5.2)', () => {
it('Red → Green on first crystal pickup', () => {
const sm = new PlayerStateMachine();
expect(sm.pickupCrystalJade()).toBe(PlayerColorState.Green);
});
it('Green → Yellow on second crystal pickup', () => {
const sm = new PlayerStateMachine();
sm.pickupCrystalJade();
expect(sm.pickupCrystalJade()).toBe(PlayerColorState.Yellow);
});
it('Yellow → Yellow on additional crystal pickups (no overflow)', () => {
const sm = new PlayerStateMachine();
sm.pickupCrystalJade();
sm.pickupCrystalJade();
expect(sm.pickupCrystalJade()).toBe(PlayerColorState.Yellow);
});
it('Zeng Wan adds one life (req 7.5)', () => {
const sm = new PlayerStateMachine(1);
expect(sm.pickupZengWan()).toBe(2);
});
});
describe('PlayerStateMachine — damage (req 5.3-5.6, 10.4-10.5)', () => {
it('downgrades from Yellow to Red on shuriken hit', () => {
const sm = new PlayerStateMachine();
sm.pickupCrystalJade();
sm.pickupCrystalJade();
const out = sm.takeHit('shuriken');
expect(out).toEqual({ kind: 'downgraded', from: PlayerColorState.Yellow, to: PlayerColorState.Red });
expect(sm.color).toBe(PlayerColorState.Red);
});
it('downgrades from Green to Red on sword hit', () => {
const sm = new PlayerStateMachine();
sm.pickupCrystalJade();
const out = sm.takeHit('sword');
expect(out.kind).toBe('downgraded');
expect(sm.color).toBe(PlayerColorState.Red);
});
it('Red + shuriken → death, consumes one life', () => {
const sm = new PlayerStateMachine(2);
const out = sm.takeHit('shuriken');
expect(out.kind).toBe('died');
expect(sm.lives).toBe(1);
expect(sm.isDead).toBe(false);
});
it('fireball is always lethal regardless of color', () => {
const sm = new PlayerStateMachine(2);
sm.pickupCrystalJade();
sm.pickupCrystalJade();
const out = sm.takeHit('fireball');
expect(out).toEqual({ kind: 'died', cause: 'fireball' });
expect(sm.color).toBe(PlayerColorState.Red);
});
it('smoke bomb is always lethal', () => {
const sm = new PlayerStateMachine();
sm.pickupCrystalJade();
const out = sm.takeHit('smoke_bomb');
expect(out.kind).toBe('died');
});
it('zero lives → isDead=true', () => {
const sm = new PlayerStateMachine(1);
sm.takeHit('sword');
expect(sm.lives).toBe(0);
expect(sm.isDead).toBe(true);
});
});
describe('PlayerStateMachine — iframes & sword parry (req 3.7-3.8, 10.2-10.3)', () => {
it('i-frames start at PLAYER_IFRAME_SECONDS after a hit', () => {
const sm = new PlayerStateMachine();
sm.pickupCrystalJade();
sm.takeHit('shuriken');
expect(sm.snapshot.iframeSec).toBe(PLAYER_IFRAME_SECONDS);
});
it('hits during i-frames are ignored', () => {
const sm = new PlayerStateMachine();
sm.pickupCrystalJade();
sm.takeHit('shuriken');
const out = sm.takeHit('shuriken');
expect(out).toEqual({ kind: 'no_effect', reason: 'iframe' });
});
it('tick() drains i-frames so the player is vulnerable again', () => {
const sm = new PlayerStateMachine();
sm.pickupCrystalJade();
sm.takeHit('shuriken');
sm.tick(PLAYER_IFRAME_SECONDS + 0.01);
expect(sm.snapshot.iframeSec).toBe(0);
});
it('sword parry nullifies shuriken/sword damage', () => {
const sm = new PlayerStateMachine();
sm.setSwordActive(true);
const out = sm.takeHit('shuriken');
expect(out).toEqual({ kind: 'no_effect', reason: 'parried' });
expect(sm.color).toBe(PlayerColorState.Red);
});
it('sword parry does NOT nullify fireball / smoke_bomb (req 3.8, 10.4-10.5)', () => {
const sm = new PlayerStateMachine();
sm.setSwordActive(true);
expect(sm.takeHit('fireball').kind).toBe('died');
const sm2 = new PlayerStateMachine();
sm2.setSwordActive(true);
expect(sm2.takeHit('smoke_bomb').kind).toBe('died');
});
});
+114
View File
@@ -0,0 +1,114 @@
import { TutorialMgr } from '@logic/TutorialMgr';
import { ScoreSystem, BASE_ENEMY_SCORE, COMBO_BONUS } from '@logic/ScoreSystem';
import { WeaponType } from '@data/Interfaces';
import { StorageMgr } from '@common/StorageMgr';
function mem() {
const m = new Map<string, string>();
return {
getItem: (k: string) => (m.has(k) ? (m.get(k) as string) : null),
setItem: (k: string, v: string) => {
m.set(k, v);
},
removeItem: (k: string) => {
m.delete(k);
},
};
}
describe('TutorialMgr — built-in sequences for 1-1..1-3 (req 11.1-11.3)', () => {
it('maybeStart returns the first step of 1-1', () => {
const t = new TutorialMgr(new StorageMgr(mem()));
const step = t.maybeStart('1-1');
expect(step).toBeDefined();
expect(step!.id).toBe('attack');
});
it('reportAction advances through the sequence', () => {
const t = new TutorialMgr(new StorageMgr(mem()));
t.maybeStart('1-1');
expect(t.reportAction('fire_shuriken')).toMatchObject({ id: 'joystick' });
expect(t.reportAction('move')).toMatchObject({ id: 'jump' });
expect(t.reportAction('jump')).toBe('finished');
});
it('completed tutorials are persisted and skipped on replay (req 11.4)', () => {
const storage = new StorageMgr(mem());
const t = new TutorialMgr(storage);
t.maybeStart('1-1');
t.reportAction('fire_shuriken');
t.reportAction('move');
t.reportAction('jump');
expect(t.isCompleted('1-1')).toBe(true);
expect(t.maybeStart('1-1')).toBeNull();
});
it('resetAll clears the completion set (req 11.5)', () => {
const t = new TutorialMgr(new StorageMgr(mem()));
t.maybeStart('1-2');
t.reportAction('parabolic_jump');
t.reportAction('attack_switch');
t.reportAction('parry');
t.reportAction('jump_attack');
t.reportAction('pickup_crystal');
t.resetAll();
expect(t.maybeStart('1-2')).toBeDefined();
});
it('reportAction with wrong action is a no-op', () => {
const t = new TutorialMgr(new StorageMgr(mem()));
t.maybeStart('1-1');
expect(t.reportAction('wrong')).toBe('no_op');
expect(t.currentStep()!.id).toBe('attack');
});
});
describe('ScoreSystem — scoring table (req 12.1-12.6)', () => {
it('sword kill is ×2, shuriken kill is ×1', () => {
const s = new ScoreSystem();
s.recordEnemyKill(WeaponType.NinjaSword);
s.recordEnemyKill(WeaponType.Shuriken);
expect(s.snapshot().baseScore).toBe(BASE_ENEMY_SCORE * 3);
});
it('parry kill is ×3 base', () => {
const s = new ScoreSystem();
s.recordParryKill();
expect(s.snapshot().baseScore).toBe(BASE_ENEMY_SCORE * 3);
});
it('5 blade contacts award a +1500 combo bonus (req 12.4)', () => {
const s = new ScoreSystem();
for (let i = 0; i < 5; i++) s.recordBladeContact();
const snap = s.snapshot();
expect(snap.comboBonus).toBe(COMBO_BONUS);
expect(snap.comboCount).toBe(1);
expect(snap.consecutiveBladeHits).toBe(0);
});
it('breakBladeChain resets the streak', () => {
const s = new ScoreSystem();
s.recordBladeContact();
s.recordBladeContact();
s.breakBladeChain();
s.recordBladeContact();
expect(s.snapshot().consecutiveBladeHits).toBe(1);
});
it('flawless run triples total score; taking damage removes the bonus (req 12.5)', () => {
const s = new ScoreSystem();
s.recordEnemyKill(WeaponType.Shuriken);
const flawlessSnap = s.snapshot();
expect(flawlessSnap.flawlessMultiplier).toBe(3);
s.markTaken();
const damagedSnap = s.snapshot();
expect(damagedSnap.flawlessMultiplier).toBe(1);
});
it('remaining time bonus adds 10 pts / sec (req 12.6)', () => {
const s = new ScoreSystem();
s.recordEnemyKill(WeaponType.Shuriken);
s.setRemainingTimeBonus(30);
expect(s.snapshot().timeBonus).toBe(300);
});
});
+147
View File
@@ -0,0 +1,147 @@
import {
ControlId,
DEFAULT_LAYOUT,
MultiTouchRouter,
applySafeArea,
classifyDirection,
hitTest,
isInsideRect,
joystickDirection,
ZERO_DIRECTION,
} from '@ui/InputModel';
describe('InputModel — layout geometry', () => {
it('default landscape layout places joystick on left, attacks on right', () => {
expect(DEFAULT_LAYOUT.joystick.cx).toBeLessThan(480);
expect(DEFAULT_LAYOUT.shuriken.cx).toBeGreaterThan(480);
expect(DEFAULT_LAYOUT.ninjaSword.cx).toBeGreaterThan(DEFAULT_LAYOUT.shuriken.cx);
});
it('isInsideRect is correct for corners and near-misses', () => {
const r = { cx: 100, cy: 100, w: 40, h: 40 };
expect(isInsideRect(r, 100, 100)).toBe(true);
expect(isInsideRect(r, 120, 120)).toBe(true);
expect(isInsideRect(r, 121, 100)).toBe(false);
expect(isInsideRect(r, 100, 79)).toBe(false);
});
});
describe('InputModel — hitTest priority', () => {
it('routes a finger pressing both an attack and the joystick to the attack', () => {
// Arrange: stretch the joystick rect so it overlaps the shuriken button.
const layout = {
...DEFAULT_LAYOUT,
joystick: { cx: 120, cy: 100, w: 900, h: 200 },
};
const id = hitTest(layout, layout.shuriken.cx, layout.shuriken.cy);
expect(id).toBe(ControlId.Shuriken);
});
it('returns null when the touch misses every control', () => {
expect(hitTest(DEFAULT_LAYOUT, 480, 400)).toBeNull();
});
});
describe('InputModel — joystick dead zone (req 1.5)', () => {
it('returns ZERO_DIRECTION inside the 10px dead-zone', () => {
const dir = joystickDirection(DEFAULT_LAYOUT, DEFAULT_LAYOUT.joystick.cx + 4, DEFAULT_LAYOUT.joystick.cy - 3);
expect(dir).toBe(ZERO_DIRECTION);
});
it('returns a normalised vector with magnitude > deadzone outside it', () => {
const dir = joystickDirection(
DEFAULT_LAYOUT,
DEFAULT_LAYOUT.joystick.cx + 60,
DEFAULT_LAYOUT.joystick.cy + 0
);
expect(dir.magnitude).toBeCloseTo(60);
expect(dir.x).toBeCloseTo(1);
expect(dir.y).toBeCloseTo(0);
});
});
describe('InputModel — parabolic angle classification (req 2.5, 20.3)', () => {
it.each([
[45, 'parabolic_right'],
[50, 'parabolic_right'],
[40, 'parabolic_right'],
[60, 'parabolic_right'],
[135, 'parabolic_left'],
[140, 'parabolic_left'],
[120, 'parabolic_left'],
[0, 'horizontal'],
[180, 'horizontal'],
[90, 'other'],
] as Array<[number, string]>)('degree %p → %p', (deg, klass) => {
const rad = (deg * Math.PI) / 180;
const dir = { x: Math.cos(rad), y: Math.sin(rad), magnitude: 1 };
expect(classifyDirection(dir)).toBe(klass);
});
it('hits ≥95% recognition rate when sampling evenly around 45° ± 15°', () => {
let hits = 0;
const samples = 200;
for (let i = 0; i < samples; i++) {
const deg = 30 + (i / samples) * 30; // 30°..60°
const rad = (deg * Math.PI) / 180;
const k = classifyDirection({ x: Math.cos(rad), y: Math.sin(rad), magnitude: 1 });
if (k === 'parabolic_right') hits++;
}
expect(hits / samples).toBeGreaterThanOrEqual(0.95);
});
});
describe('InputModel — applySafeArea (req 1.7, 18.6)', () => {
it('slides left-group rightwards and right-group leftwards by the insets', () => {
const shifted = applySafeArea(DEFAULT_LAYOUT, { left: 40, right: 60, top: 0, bottom: 0 });
expect(shifted.joystick.cx).toBe(DEFAULT_LAYOUT.joystick.cx + 40);
expect(shifted.jump.cx).toBe(DEFAULT_LAYOUT.jump.cx + 40);
expect(shifted.shuriken.cx).toBe(DEFAULT_LAYOUT.shuriken.cx - 60);
expect(shifted.ninjaSword.cx).toBe(DEFAULT_LAYOUT.ninjaSword.cx - 60);
});
it('keeps every control inside the visible area after shifting', () => {
const shifted = applySafeArea(DEFAULT_LAYOUT, { left: 20, right: 20, top: 20, bottom: 20 });
const all = [shifted.joystick, shifted.jump, shifted.shuriken, shifted.ninjaSword];
for (const r of all) {
expect(r.cx + r.w / 2).toBeLessThanOrEqual(960);
expect(r.cx - r.w / 2).toBeGreaterThanOrEqual(0);
expect(r.cy + r.h / 2).toBeLessThanOrEqual(540);
expect(r.cy - r.h / 2).toBeGreaterThanOrEqual(0);
}
});
});
describe('MultiTouchRouter — req 1.8 (≥3 simultaneous touches)', () => {
it('routes three fingers to joystick + jump + shuriken independently', () => {
const router = new MultiTouchRouter(DEFAULT_LAYOUT);
const j = router.begin(0, DEFAULT_LAYOUT.joystick.cx, DEFAULT_LAYOUT.joystick.cy, 0);
const p = router.begin(1, DEFAULT_LAYOUT.jump.cx, DEFAULT_LAYOUT.jump.cy, 10);
const s = router.begin(2, DEFAULT_LAYOUT.shuriken.cx, DEFAULT_LAYOUT.shuriken.cy, 20);
expect(j).toBe(ControlId.Joystick);
expect(p).toBe(ControlId.Jump);
expect(s).toBe(ControlId.Shuriken);
expect(router.activeTouchCount).toBe(3);
expect(router.isPressed(ControlId.Jump)).toBe(true);
});
it('end() returns the previously-bound control and removes the slot', () => {
const router = new MultiTouchRouter(DEFAULT_LAYOUT);
router.begin(5, DEFAULT_LAYOUT.ninjaSword.cx, DEFAULT_LAYOUT.ninjaSword.cy, 0);
expect(router.end(5)).toBe(ControlId.NinjaSword);
expect(router.isPressed(ControlId.NinjaSword)).toBe(false);
});
it('falls through (returns null) when the touch lands outside all controls (req 1.3)', () => {
const router = new MultiTouchRouter(DEFAULT_LAYOUT);
expect(router.begin(7, 480, 400, 0)).toBeNull();
});
it('earliestPressTs returns the oldest timestamp among the given controls', () => {
const router = new MultiTouchRouter(DEFAULT_LAYOUT);
router.begin(0, DEFAULT_LAYOUT.jump.cx, DEFAULT_LAYOUT.jump.cy, 100);
router.begin(1, DEFAULT_LAYOUT.shuriken.cx, DEFAULT_LAYOUT.shuriken.cy, 80);
expect(router.earliestPressTs([ControlId.Jump, ControlId.Shuriken])).toBe(80);
expect(router.earliestPressTs([ControlId.NinjaSword])).toBeUndefined();
});
});
+129
View File
@@ -0,0 +1,129 @@
import {
DEFAULT_LAYOUT_DELTA,
LAYOUT_DELTA_BOUNDS,
LayoutCustomizer,
applyLayoutDelta,
sanitiseLayoutDelta,
} from '@ui/LayoutCustomizer';
import { DEFAULT_LAYOUT } from '@ui/InputModel';
import { StorageMgr } from '@common/StorageMgr';
import { STORAGE_KEY } from '@common/Constants';
function mem() {
const m = new Map<string, string>();
return {
getItem: (k: string) => (m.has(k) ? (m.get(k) as string) : null),
setItem: (k: string, v: string) => {
m.set(k, v);
},
removeItem: (k: string) => {
m.delete(k);
},
};
}
describe('sanitiseLayoutDelta', () => {
it('returns a defensive copy of the default when given null', () => {
const d = sanitiseLayoutDelta(null);
expect(d).toEqual(DEFAULT_LAYOUT_DELTA);
d.opacity = 0.1;
expect(DEFAULT_LAYOUT_DELTA.opacity).toBe(0.7); // ensure we did not mutate
});
it('clamps offset beyond the allowed range', () => {
const d = sanitiseLayoutDelta({ joystickOffset: { dx: 9999, dy: -9999 } });
expect(d.joystickOffset.dx).toBe(LAYOUT_DELTA_BOUNDS.offsetPxMax);
expect(d.joystickOffset.dy).toBe(-LAYOUT_DELTA_BOUNDS.offsetPxMax);
});
it('clamps size scale and opacity to allowed ranges', () => {
const d = sanitiseLayoutDelta({ buttonSizeScale: 5, opacity: 2 });
expect(d.buttonSizeScale).toBe(LAYOUT_DELTA_BOUNDS.sizeScaleMax);
expect(d.opacity).toBe(LAYOUT_DELTA_BOUNDS.opacityMax);
});
it('replaces NaN-like inputs with safe midpoints', () => {
const d = sanitiseLayoutDelta({ buttonSizeScale: NaN });
expect(d.buttonSizeScale).toBeGreaterThanOrEqual(LAYOUT_DELTA_BOUNDS.sizeScaleMin);
expect(d.buttonSizeScale).toBeLessThanOrEqual(LAYOUT_DELTA_BOUNDS.sizeScaleMax);
});
});
describe('applyLayoutDelta', () => {
it('produces a layout identical to baseline when delta is default', () => {
const result = applyLayoutDelta(DEFAULT_LAYOUT, DEFAULT_LAYOUT_DELTA);
expect(result.joystick.cx).toBe(DEFAULT_LAYOUT.joystick.cx);
expect(result.opacity).toBe(DEFAULT_LAYOUT_DELTA.opacity);
});
it('shifts centres and scales widths', () => {
const delta = sanitiseLayoutDelta({
joystickOffset: { dx: 30, dy: 10 },
buttonSizeScale: 1.2,
opacity: 0.9,
});
const r = applyLayoutDelta(DEFAULT_LAYOUT, delta);
expect(r.joystick.cx).toBe(DEFAULT_LAYOUT.joystick.cx + 30);
expect(r.joystick.cy).toBe(DEFAULT_LAYOUT.joystick.cy + 10);
expect(r.shuriken.w).toBeCloseTo(DEFAULT_LAYOUT.shuriken.w * 1.2);
expect(r.opacity).toBe(0.9);
});
});
describe('LayoutCustomizer — persistence', () => {
it('returns the default layout when nothing is stored (req 17.6)', () => {
const cust = new LayoutCustomizer(DEFAULT_LAYOUT, new StorageMgr(mem()));
const { delta, layout } = cust.loadLayout();
expect(delta).toEqual(DEFAULT_LAYOUT_DELTA);
expect(layout.joystick.cx).toBe(DEFAULT_LAYOUT.joystick.cx);
});
it('round-trips a custom delta through saveDelta() / loadLayout()', () => {
const storage = new StorageMgr(mem());
const cust = new LayoutCustomizer(DEFAULT_LAYOUT, storage);
cust.saveDelta({
joystickOffset: { dx: 20, dy: -15 },
jumpOffset: { dx: 0, dy: 0 },
shurikenOffset: { dx: -10, dy: 5 },
ninjaSwordOffset: { dx: 0, dy: 0 },
buttonSizeScale: 1.1,
opacity: 0.85,
});
const { delta } = cust.loadLayout();
expect(delta.joystickOffset.dx).toBe(20);
expect(delta.shurikenOffset.dx).toBe(-10);
expect(delta.opacity).toBe(0.85);
});
it('reset() clears the stored layout', () => {
const storage = new StorageMgr(mem());
const cust = new LayoutCustomizer(DEFAULT_LAYOUT, storage);
cust.saveDelta({ ...DEFAULT_LAYOUT_DELTA, opacity: 1.0 });
cust.reset();
const { delta } = cust.loadLayout();
expect(delta.opacity).toBe(DEFAULT_LAYOUT_DELTA.opacity);
});
it('falls back to defaults when storage returns corrupted JSON (req 17.6)', () => {
const driver = {
getItem: () => 'not valid json',
setItem: () => {},
removeItem: () => {},
};
const storage = new StorageMgr(driver);
const cust = new LayoutCustomizer(DEFAULT_LAYOUT, storage);
const { delta } = cust.loadLayout();
expect(delta).toEqual(DEFAULT_LAYOUT_DELTA);
});
it('uses the kl_control_layout storage key (req 17.2)', () => {
const driver = mem();
const setSpy = jest.fn(driver.setItem);
driver.setItem = setSpy;
const storage = new StorageMgr(driver);
const cust = new LayoutCustomizer(DEFAULT_LAYOUT, storage);
cust.saveDelta(DEFAULT_LAYOUT_DELTA);
expect(setSpy).toHaveBeenCalled();
expect(setSpy.mock.calls[0][0]).toBe(STORAGE_KEY.ControlLayout);
});
});
+116
View File
@@ -0,0 +1,116 @@
import { StorySceneCtrl, BASE_TYPING_CPS } from '@ui/StorySceneCtrl';
import { StorageMgr } from '@common/StorageMgr';
import { IStorySceneConfig } from '@data/Interfaces';
import { STORAGE_KEY } from '@common/Constants';
const SCENE: IStorySceneConfig = {
id: 'chapter_1_intro',
bgm: 'bgm_story',
maxDurationSec: 30,
pages: [
{ index: 1, illustration: 'p1', text: 'A' },
{ index: 2, illustration: 'p2', text: 'BCDE' },
{ index: 3, illustration: 'p3', text: 'FGH' },
],
};
function mem() {
const m = new Map<string, string>();
return {
getItem: (k: string) => (m.has(k) ? (m.get(k) as string) : null),
setItem: (k: string, v: string) => {
m.set(k, v);
},
removeItem: (k: string) => {
m.delete(k);
},
};
}
describe('StorySceneCtrl — first-time gate (req 19.5)', () => {
it('start() reports "already_seen" when storage flag is set', () => {
const storage = new StorageMgr(mem());
storage.set(STORAGE_KEY.StoryIntroSeen, true);
const ctrl = new StorySceneCtrl(SCENE, storage);
expect(ctrl.start()).toBe('already_seen');
});
it('start() reports "playing" on first run', () => {
const ctrl = new StorySceneCtrl(SCENE, new StorageMgr(mem()));
expect(ctrl.start()).toBe('playing');
expect(ctrl.status).toBe('typing');
});
});
describe('StorySceneCtrl — typewriter (req 19.2-19.3)', () => {
it('reveals characters at BASE_TYPING_CPS', () => {
const ctrl = new StorySceneCtrl(SCENE, new StorageMgr(mem()));
ctrl.start();
// Advance enough time to type "A".
ctrl.tick(1 / BASE_TYPING_CPS + 0.001);
expect(ctrl.visibleText).toBe('A');
// Page "A" complete → waiting_next.
expect(ctrl.status).toBe('waiting_next');
});
it('tap during typing fully reveals the current page', () => {
const ctrl = new StorySceneCtrl(SCENE, new StorageMgr(mem()));
ctrl.start();
ctrl.onTap(); // accelerate page 1 ("A")
expect(ctrl.visibleText).toBe('A');
expect(ctrl.status).toBe('waiting_next');
// Next tap → advance to page 2.
ctrl.onTap();
expect(ctrl.currentPageNumber).toBe(2);
expect(ctrl.status).toBe('typing');
});
});
describe('StorySceneCtrl — skip (req 19.4)', () => {
it('onSkip immediately finishes and marks seen', () => {
const storage = new StorageMgr(mem());
const onFinished = jest.fn();
const ctrl = new StorySceneCtrl(SCENE, storage, { onFinished });
ctrl.start();
ctrl.onSkip();
expect(ctrl.status).toBe('finished');
expect(onFinished).toHaveBeenCalledWith(true);
expect(storage.get(STORAGE_KEY.StoryIntroSeen, false)).toBe(true);
});
it('re-skipping after finish is a no-op', () => {
const ctrl = new StorySceneCtrl(SCENE, new StorageMgr(mem()));
ctrl.start();
ctrl.onSkip();
expect(() => ctrl.onSkip()).not.toThrow();
});
});
describe('StorySceneCtrl — reset() (req 19.6)', () => {
it('clears the "seen" flag', () => {
const storage = new StorageMgr(mem());
const ctrl = new StorySceneCtrl(SCENE, storage);
ctrl.start();
ctrl.onSkip();
expect(ctrl.hasBeenSeen()).toBe(true);
ctrl.reset();
expect(ctrl.hasBeenSeen()).toBe(false);
});
});
describe('StorySceneCtrl — natural finish (all pages)', () => {
it('calls onFinished(false) after advancing past the last page', () => {
const onFinished = jest.fn();
const ctrl = new StorySceneCtrl(SCENE, new StorageMgr(mem()), { onFinished });
ctrl.start();
// Full page 1.
ctrl.onTap();
ctrl.onTap(); // → page 2
ctrl.onTap(); // reveal page 2
ctrl.onTap(); // → page 3
ctrl.onTap(); // reveal page 3
ctrl.onTap(); // → finish
expect(onFinished).toHaveBeenCalledWith(false);
expect(ctrl.status).toBe('finished');
});
});
+61
View File
@@ -0,0 +1,61 @@
import { UIFlowMgr, ISceneEnter } from '@ui/UIFlowMgr';
import { StorageMgr } from '@common/StorageMgr';
import { STORAGE_KEY } from '@common/Constants';
function mem() {
const m = new Map<string, string>();
return {
getItem: (k: string) => (m.has(k) ? (m.get(k) as string) : null),
setItem: (k: string, v: string) => {
m.set(k, v);
},
removeItem: (k: string) => {
m.delete(k);
},
};
}
describe('UIFlowMgr — boot path (req 19.1, 19.5)', () => {
it('routes first-time boot into story_intro', () => {
const events: ISceneEnter[] = [];
const flow = new UIFlowMgr(new StorageMgr(mem()), { onSceneEnter: (e) => events.push(e) });
flow.onBoot();
expect(events[0].scene).toBe('story_intro');
});
it('routes repeat boot into main_menu', () => {
const storage = new StorageMgr(mem());
storage.set(STORAGE_KEY.StoryIntroSeen, true);
const events: ISceneEnter[] = [];
const flow = new UIFlowMgr(storage, { onSceneEnter: (e) => events.push(e) });
flow.onBoot();
expect(events[0].scene).toBe('main_menu');
});
});
describe('UIFlowMgr — level/settlement/dead transitions', () => {
it('onPickLevel → gameplay with payload', () => {
const events: ISceneEnter[] = [];
const flow = new UIFlowMgr(new StorageMgr(mem()), { onSceneEnter: (e) => events.push(e) });
flow.onPickLevel('1-3');
expect(events[0]).toEqual({ scene: 'gameplay', payload: { levelId: '1-3' } });
});
it('onPlayerDied pushes a settlement with dead:true', () => {
const events: ISceneEnter[] = [];
const flow = new UIFlowMgr(new StorageMgr(mem()), { onSceneEnter: (e) => events.push(e) });
flow.onPlayerDied('1-2');
expect(events[0].scene).toBe('settlement');
expect(events[0].payload).toEqual({ levelId: '1-2', dead: true });
});
});
describe('UIFlowMgr — difficulty guardrail (req 13.1)', () => {
it('availableSettingsEntries does NOT include difficulty', () => {
const flow = new UIFlowMgr(new StorageMgr(mem()));
const entries = flow.availableSettingsEntries();
expect(entries).not.toContain('difficulty');
expect(entries).toContain('audio_volume');
expect(entries).toContain('replay_story_intro');
});
});