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
+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);
});
});