From 38d06cdb284596cb053c29381e8dea1d9e82de0b Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Mon, 14 Sep 2026 12:17:30 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8A=BD=E5=8F=96=E7=8B=AC=E7=AB=8B?= =?UTF-8?q?=E6=97=A0=E7=8A=B6=E6=80=81=20GameEngine=20=E9=A2=86=E5=9F=9F?= =?UTF-8?q?=E4=B8=8E=E7=9B=B8=E5=85=B3=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 map-scene 中负责每帧更新逻辑与状态管理的巨量代码抽取成 `src/game/engine.ts` 内的 `GameEngine`。 解耦模拟与 WebGL 和 DOM,保证可以无界面测试。 并引入了 `tests/engine.test.ts` 自动化单元测试(Vitest)。 --- src/game/engine.ts | 289 +++++++++++++++++++++++++++++++++++ src/scene/map-scene.ts | 339 ++++++++++++----------------------------- tests/engine.test.ts | 138 +++++++++++++++++ 3 files changed, 522 insertions(+), 244 deletions(-) create mode 100644 src/game/engine.ts create mode 100644 tests/engine.test.ts diff --git a/src/game/engine.ts b/src/game/engine.ts new file mode 100644 index 0000000..f962a92 --- /dev/null +++ b/src/game/engine.ts @@ -0,0 +1,289 @@ +import { tickCombat, createWorld, spawnMonsters, damageMonster, rebindPlayer } from './combat.ts' +import type { CombatWorld, CombatOptions, MonsterStats } from './combat.ts' +import { Inventory, rollDrop, goldItem, totalStat } from './items.ts' +import type { Item, ItemBase, Affix } from './items.ts' +import { castSkill, tickProjectiles } from './skills.ts' +import type { SkillDef, Projectile } from './skills.ts' +import { QuestLog, npcDialog } from './quests.ts' +import type { NpcDef, QuestDef } from './quests.ts' +import { Rng } from './rng.ts' +import { captureSnapshot, parseSnapshot, restoreSnapshot, serializeSnapshot } from './save.ts' + +export interface EngineInput { + readonly movement: { readonly x: number; readonly y: number } + readonly attacking: boolean + readonly pickingUp: boolean + readonly talking: boolean + readonly digits: readonly number[] + readonly saving: boolean + readonly loading: boolean +} + +export interface WorldMapProvider { + readonly widthPx: number + readonly heightPx: number + overlap(x: number, y: number): number +} + +export interface GameEngineOptions { + spawn: { x: number; y: number } + stats: readonly MonsterStats[] + xpTable: readonly number[] + itemBases: readonly ItemBase[] + prefixAffixes: readonly Affix[] + suffixAffixes: readonly Affix[] + skills: readonly SkillDef[] + npcDefs: readonly NpcDef[] + questDefs: readonly QuestDef[] + combatOptions: CombatOptions + lootSeed?: number + talkRadius: number + pickupRadius: number + inventoryCols: number + inventoryRows: number +} + +export interface GameEngineMetrics { + casts: number + castRefusals: number + castHits: number + dropsRolled: number + pickups: number + inventoryRefusals: number + lastDrops: string[] + playerHits: number + questXp: number + questGold: number + saves: number + loads: number + saveError: string | null +} + +export class GameEngine { + world: CombatWorld + loot: Rng + bag: Inventory + ground: { x: number; y: number; item: Item }[] = [] + questLog: QuestLog + projectiles: Projectile[] = [] + castRng: Rng + skillCooldown = 0 + selectedSkill = 0 + npcEntities: { def: NpcDef; x: number; y: number }[] = [] + dialog: string[] = [] + + metrics: GameEngineMetrics = { + casts: 0, castRefusals: 0, castHits: 0, dropsRolled: 0, pickups: 0, + inventoryRefusals: 0, lastDrops: [], playerHits: 0, questXp: 0, questGold: 0, + saves: 0, loads: 0, saveError: null, + } + + constructor(public terrain: WorldMapProvider, public opts: GameEngineOptions) { + this.world = createWorld(opts.spawn.x, opts.spawn.y) + spawnMonsters(this.world, opts.stats, 8, opts.spawn, 260, this.terrain) + this.loot = new Rng(opts.lootSeed ?? 0x5eed) + this.bag = new Inventory(opts.inventoryCols, opts.inventoryRows) + this.questLog = new QuestLog(opts.questDefs) + this.castRng = new Rng((opts.lootSeed ?? 0x5eed) ^ 0x51c1) + + for (let attempt = 0; attempt < 40 && this.npcEntities.length < this.opts.npcDefs.length; attempt += 1) { + const angle = attempt * 1.7 + 0.6 + const x = opts.spawn.x + Math.cos(angle) * 56 + const y = opts.spawn.y + Math.sin(angle) * 56 + if (this.terrain.overlap(x, y) > 0) continue + const def = this.opts.npcDefs[this.npcEntities.length] + if (def === undefined) break + this.npcEntities.push({ def, x, y }) + } + } + + tick(input: EngineInput): void { + const movement = input.movement + const player = this.world.player + const beforeX = player.x + const beforeY = player.y + + if (this.world.tick % 25 === 0) player.mana = Math.min(player.maxMana, player.mana + 1) + + const wanted = this.opts.skills[this.selectedSkill] + let castHappened = false + const casting = input.attacking && this.selectedSkill > 0 && wanted !== undefined + + if (casting && wanted !== undefined) { + const result = castSkill( + wanted, { x: player.x, y: player.y, facing: player.facing }, + 1, this.castRng, this.skillCooldown, player.mana, + ) + if (result.kind === 'projectile') { + this.projectiles.push(result.projectile) + player.mana = Math.max(0, player.mana - wanted.manaCost) + this.skillCooldown = wanted.cooldownTicks + this.metrics.casts += 1 + castHappened = true + } else if (result.kind === 'instant') { + player.mana = Math.max(0, player.mana - wanted.manaCost) + this.skillCooldown = wanted.cooldownTicks + this.metrics.casts += 1 + castHappened = true + this.world.monsters.forEach((monster, index) => { + if (monster.state === 'dead') return + if (Math.hypot(monster.x - player.x, monster.y - player.y) > wanted.radius + 20) return + damageMonster(this.world, index, result.damage) + }) + } else if (result.kind === 'mana') { + this.metrics.castRefusals += 1 + } + } + + tickCombat(this.world, { movement, attack: castHappened ? false : input.attacking }, this.opts.combatOptions, this.terrain, this.opts.xpTable) + + if (this.world.events.some(event => event.kind === 'monsterHit')) this.metrics.playerHits += 1 + + const digits = input.digits + if (digits.length > 0) this.selectedSkill = Math.min(Math.max(digits[0]! - 1, 0), this.opts.skills.length - 1) + if (this.skillCooldown > 0) this.skillCooldown -= 1 + + if (this.projectiles.length > 0) { + const targets = this.world.monsters.map((monster, index) => ({ + index, x: monster.x, y: monster.y, radius: 20, alive: monster.state !== 'dead', + })) + const outcome = tickProjectiles(this.projectiles, targets, this.terrain) + this.projectiles.length = 0 + this.projectiles.push(...outcome.alive) + for (const hit of outcome.hits) { + damageMonster(this.world, hit.targetIndex, hit.damage) + this.metrics.castHits += 1 + } + } + + if (input.talking) { + let nearest: { def: NpcDef; x: number; y: number } | null = null + let best = this.opts.talkRadius + for (const npc of this.npcEntities) { + const distance = Math.hypot(npc.x - player.x, npc.y - player.y) + if (distance <= best) { best = distance; nearest = npc } + } + if (nearest === null) { + this.dialog = [] + } else { + if (nearest.def.questId !== null) this.questLog.accept(nearest.def.questId) + this.dialog = npcDialog(nearest.def, this.questLog) + } + } + + for (const event of this.world.events) { + if (event.kind !== 'kill') continue + for (const reward of this.questLog.recordKill(event.subjectId ?? '')) { + player.xp += reward.xp + this.bag.add(goldItem(reward.gold)) + this.metrics.questXp += reward.xp + this.metrics.questGold += reward.gold + } + const drop = rollDrop(this.opts.itemBases, this.opts.prefixAffixes, this.opts.suffixAffixes, this.loot, { + level: player.level + 2, + dropChance: 0.8, + goldChance: 0.3, + goldRange: [5, 40], + }) + this.metrics.dropsRolled += 1 + if (drop.kind === 'gold') { + this.bag.add(goldItem(drop.amount)) + } else if (drop.kind === 'item') { + this.ground.push({ x: event.x, y: event.y, item: drop.item }) + this.metrics.lastDrops.push(drop.item.name) + if (this.metrics.lastDrops.length > 6) this.metrics.lastDrops.shift() + } + } + + if (input.pickingUp && this.ground.length > 0) { + let bestIndex = -1 + let bestDistance = this.opts.pickupRadius + this.ground.forEach((entry, index) => { + const distance = Math.hypot(entry.x - player.x, entry.y - player.y) + if (distance <= bestDistance) { bestDistance = distance; bestIndex = index } + }) + const entry = bestIndex === -1 ? undefined : this.ground[bestIndex] + if (entry !== undefined) { + if (this.bag.add(entry.item) === null) { + this.metrics.inventoryRefusals += 1 + } else { + this.ground.splice(bestIndex, 1) + this.metrics.pickups += 1 + } + } + } + } + + saveSnapshot(): string { + return serializeSnapshot(captureSnapshot({ + world: this.world, rng: this.loot, inventory: this.bag, quests: this.questLog, ground: this.ground, + })) + } + + loadSnapshot(data: string): void { + const loaded = parseSnapshot(data) + const pieces = restoreSnapshot(loaded, { + inventory: (width, height, placed) => Inventory.restore(width, height, placed), + quests: states => QuestLog.restore(this.opts.questDefs, states), + }) + this.world = rebindPlayer({ + ...this.world, + tick: pieces.world.tick, + kills: pieces.world.kills, + player: { ...pieces.world.player }, + monsters: pieces.world.monsters.map(monster => ({ ...monster })), + players: [], + events: [], + }) + this.loot = new Rng(pieces.rngState) + this.bag = pieces.inventory + this.questLog = pieces.quests + this.ground = pieces.ground + this.projectiles = [] + } +} + +export function syncEngineState(engine: GameEngine, state: any): void { + state.monstersSpawned = engine.world.monsters.length + state.monstersAlive = engine.world.monsters.filter(m => m.state !== 'dead').length + state.kills = engine.world.kills + state.playerHp = engine.world.player.hp + state.playerMaxHp = engine.world.player.maxHp + state.playerMana = engine.world.player.mana + state.playerMaxMana = engine.world.player.maxMana + state.playerLevel = engine.world.player.level + state.playerXp = engine.world.player.xp + state.playerHits = engine.metrics.playerHits + state.groundItems = engine.ground.length + state.inventoryItems = engine.bag.contents.length + state.cellsUsed = engine.bag.usedCells + state.cellsTotal = engine.bag.width * engine.bag.height + state.gold = engine.bag.gold + state.defense = totalStat(engine.bag.contents, 'defense') + state.dropsRolled = engine.metrics.dropsRolled + state.pickups = engine.metrics.pickups + state.inventoryRefusals = engine.metrics.inventoryRefusals + state.lastDrops = [...engine.metrics.lastDrops] + state.skills = engine.opts.skills.map(s => s.name) + state.selectedSkill = engine.selectedSkill + state.casts = engine.metrics.casts + state.castRefusals = engine.metrics.castRefusals + state.castHits = engine.metrics.castHits + state.projectilesAlive = engine.projectiles.length + state.npcs = engine.npcEntities.length + state.npcsNear = engine.npcEntities.filter(n => Math.hypot(n.x - engine.world.player.x, n.y - engine.world.player.y) <= 800) + state.monstersNear = engine.world.monsters.filter(m => Math.hypot(m.x - engine.world.player.x, m.y - engine.world.player.y) <= 800) + state.quests = engine.questLog.active.map(q => q.def.name) + state.dialog = engine.dialog + state.questXp = engine.metrics.questXp + state.questGold = engine.metrics.questGold + state.worldTick = engine.world.tick + state.saves = engine.metrics.saves + state.loads = engine.metrics.loads + state.saveError = engine.metrics.saveError + + state.tick = engine.world.tick + state.x = engine.world.player.x + state.y = engine.world.player.y + state.bagItems = engine.bag.contents.length +} diff --git a/src/scene/map-scene.ts b/src/scene/map-scene.ts index 0ca70a2..0f2339c 100644 --- a/src/scene/map-scene.ts +++ b/src/scene/map-scene.ts @@ -28,6 +28,7 @@ import { parseTable } from '../game/tables.ts' import { Inventory, affixesFromTable, goldItem, itemBasesFromTable, rollDrop, totalStat, } from '../game/items.ts' +import { GameEngine, syncEngineState } from "../game/engine.ts" import { DEMO_MONSTERS, DEMO_EXPERIENCE, DEMO_BASES, DEMO_AFFIXES, DEMO_SKILLS, DEMO_NPCS, DEMO_QUESTS } from "../game/demo-data.ts" import type { Affix, Item, ItemBase } from '../game/items.ts' import { Rng } from '../game/rng.ts' @@ -382,7 +383,7 @@ function startScene(sources: MapSources, status: HTMLElement, hud: HTMLElement): ? DEMO_EXPERIENCE : experienceTable(parseTable(new TextDecoder().decode(sources.tables.experience))) state.tablesSource = sources.tables.monstats === null ? 'builtin' : 'archive' - const terrain = { + const terrain = { widthPx: scene.widthPx, heightPx: scene.heightPx, overlap: (x: number, y: number): number => blockedOverlap(scene, tileWidth, tileHeight, x, y, FEET_WIDTH, FEET_HEIGHT), } @@ -420,43 +421,53 @@ function startScene(sources: MapSources, status: HTMLElement, hud: HTMLElement): state.itemTablesSource = tableBases.length === 0 ? 'builtin' : 'archive' // Mutable on purpose: loading a game replaces the world, the random stream and // the bag wholesale, and the loop reads them fresh every tick. - let loot = new Rng(LOOT_SEED) - let bag = new Inventory(INVENTORY_COLS, INVENTORY_ROWS) - let ground: { x: number; y: number; item: Item }[] = [] // Skills, names, NPCs and quests. Numeric cells in the tables resolve through + // the string table when the archive has one, and stay literal when it does not. + const textSource = textSourceOf(sources.m4Tables.strings === null ? null : decodeTbl(sources.m4Tables.strings)) - state.textSource = sources.m4Tables.strings === null ? 'literal' : 'tbl' + + state.textSource = sources.m4Tables.strings === null ? "literal" : "tbl" + const skills: readonly SkillDef[] = sources.m4Tables.skills === null + ? DEMO_SKILLS + : skillsFromTable(parseTable(text.decode(sources.m4Tables.skills)), textSource) + const npcDefs: readonly NpcDef[] = sources.m4Tables.npcs === null + ? DEMO_NPCS + : npcsFromTable(parseTable(text.decode(sources.m4Tables.npcs)), textSource) + const questDefs = sources.m4Tables.quests === null + ? [...DEMO_QUESTS] + : questsFromTable(parseTable(text.decode(sources.m4Tables.quests)), textSource) - let questLog = new QuestLog(questDefs) - let projectiles: Projectile[] = [] - const castRng = new Rng(LOOT_SEED ^ 0x51c1) - const skillLevel = 1 - let skillCooldown = 0 - let selectedSkill = 0 - // NPCs stand on free ground near the spawn, the same rule monsters obey. - const npcEntities: { def: NpcDef; x: number; y: number }[] = [] - for (let attempt = 0; attempt < 40 && npcEntities.length < npcDefs.length; attempt += 1) { - const angle = attempt * 1.7 + 0.6 - // Placed inside the talk radius on purpose: an NPC you cannot reach to talk - // to is a placement bug, not a challenge. - const x = spawn.x + Math.cos(angle) * 56 - const y = spawn.y + Math.sin(angle) * 56 - if (terrain.overlap(x, y) > 0) continue - const def = npcDefs[npcEntities.length] - if (def === undefined) break - npcEntities.push({ def, x, y }) - } - state.npcs = npcEntities.length + + + const engine = new GameEngine(terrain, { + spawn, + stats, + xpTable, + itemBases, + prefixAffixes, + suffixAffixes, + skills, + npcDefs, + questDefs, + combatOptions: options, + lootSeed: LOOT_SEED, + talkRadius: TALK_RADIUS, + pickupRadius: PICKUP_RADIUS, + inventoryCols: INVENTORY_COLS, + inventoryRows: INVENTORY_ROWS, + }) + state.monstersSpawned = engine.world.monsters.length + state.npcs = engine.npcEntities.length state.skills = skills.map(skill => skill.name) let blockedThisTick = false @@ -464,226 +475,66 @@ function startScene(sources: MapSources, status: HTMLElement, hud: HTMLElement): tickRate: 25, onTick: () => { const movement = input?.movement() ?? { x: 0, y: 0 } - const beforeX = player.x - const beforeY = player.y - // Mana recovers slowly. The real game regenerates through stats and items; - // this stands in until the stat system exists, so the sandbox stays - // playable instead of dead-ending after a few casts. - if (loop.tick % 25 === 0) player.mana = Math.min(player.maxMana, player.mana + 1) + const beforeX = engine.world.player.x + const beforeY = engine.world.player.y - const wanted = skills[selectedSkill] - const casting = (input?.attacking ?? false) && selectedSkill > 0 && wanted !== undefined - // A spell that cannot be paid for must not take the basic attack away with - // it: with a skill selected and no mana, the swing still happens (see the - // combat call below, which is gated on this flag). - let castHappened = false - if (casting && wanted !== undefined) { - const result = castSkill( - wanted, { x: player.x, y: player.y, facing: player.facing }, - skillLevel, castRng, skillCooldown, player.mana, - ) - if (result.kind === 'projectile') { - projectiles.push(result.projectile) - player.mana = Math.max(0, player.mana - wanted.manaCost) - skillCooldown = wanted.cooldownTicks - state.casts += 1 - castHappened = true - } else if (result.kind === 'instant') { - player.mana = Math.max(0, player.mana - wanted.manaCost) - skillCooldown = wanted.cooldownTicks - state.casts += 1 - castHappened = true - // An instant skill strikes everything within its radius, which is what - // makes a nova different from a bolt. - world.monsters.forEach((monster, index) => { - if (monster.state === 'dead') return - if (Math.hypot(monster.x - player.x, monster.y - player.y) > wanted.radius + 20) return - damageMonster(world, index, result.damage) - }) - } else if (result.kind === 'mana') { - // Only a mana refusal is worth counting: holding the key while the - // skill is on cooldown is the expected case, not an error, and - // counting it would drown the signal in per-tick noise. - state.castRefusals += 1 - } - } + engine.tick({ + movement, + attacking: input?.attacking ?? false, + pickingUp: input?.pickingUp ?? false, + talking: input?.talking ?? false, + digits: input?.digits ?? [], + saving: false, + loading: false + }) - // The basic attack runs only when no spell fired this tick, so a selected - // skill replaces the swing instead of doubling it. - tickCombat(world, { movement, attack: castHappened ? false : (input?.attacking ?? false) }, options, terrain, xpTable) - const moved = player.x !== beforeX || player.y !== beforeY - // Animation follows the simulation, not the key state: a body stopped by a - // wall must not keep walking on the spot. - animator.play(moved ? 'walk' : 'stand', player.facing) - animator.tick(player.facing) - state.actorClip = animator.clipName - state.actorDirection = animator.facing - state.actorFrame = animator.frameIndex - if (world.events.some(event => event.kind === 'monsterHit')) state.playerHits += 1 - - // Skills. The number keys select a slot; the attack key casts it. Slot 0 is - // the plain melee attack, which the combat module already owns, so a - // selection of 0 falls through to it rather than double-attacking. - const digits = input?.digits ?? [] - if (digits.length > 0) selectedSkill = Math.min(Math.max(digits[0]! - 1, 0), skills.length - 1) - state.selectedSkill = selectedSkill - if (skillCooldown > 0) skillCooldown -= 1 - // Projectiles fly, hit, or die against scenery. Damage goes through the - // combat module so kills, experience and corpses behave the same as melee. - if (projectiles.length > 0) { - const targets = world.monsters.map((monster, index) => ({ - index, x: monster.x, y: monster.y, radius: 20, alive: monster.state !== 'dead', - })) - const outcome = tickProjectiles(projectiles, targets, terrain) - projectiles.length = 0 - projectiles.push(...outcome.alive) - for (const hit of outcome.hits) { - damageMonster(world, hit.targetIndex, hit.damage) - state.castHits += 1 - } - } - state.projectilesAlive = projectiles.length - - // Talking: nearest NPC in reach. Accepting is idempotent, so holding the key - // repeats the dialogue rather than toggling quest state. - if (input?.talking ?? false) { - let nearest: { def: NpcDef; x: number; y: number } | null = null - let best = TALK_RADIUS - for (const npc of npcEntities) { - const distance = Math.hypot(npc.x - player.x, npc.y - player.y) - if (distance <= best) { best = distance; nearest = npc } - } - if (nearest === null) { - state.dialog = [] - } else { - if (nearest.def.questId !== null) questLog.accept(nearest.def.questId) - state.dialog = npcDialog(nearest.def, questLog) - } - } - - // Loot: a kill rolls one drop; gold is taken automatically, everything - // else lies where it fell until picked up. - for (const event of world.events) { - if (event.kind !== 'kill') continue - // Quests count kills by monster id, which is why the event carries one. - for (const reward of questLog.recordKill(event.subjectId ?? '')) { - player.xp += reward.xp - bag.add(goldItem(reward.gold)) - state.questXp += reward.xp - state.questGold += reward.gold - } - const drop = rollDrop(itemBases, prefixAffixes, suffixAffixes, loot, { - level: player.level + 2, - dropChance: 0.8, - goldChance: 0.3, - goldRange: [5, 40], - }) - state.dropsRolled += 1 - if (drop.kind === 'gold') { - bag.add(goldItem(drop.amount)) - } else if (drop.kind === 'item') { - ground.push({ x: event.x, y: event.y, item: drop.item }) - state.lastDrops.push(drop.item.name) - if (state.lastDrops.length > 6) state.lastDrops.shift() - } - } - - // Pickup: nearest item within reach. A full bag refuses instead of - // destroying the item, which is the failure worth counting. - if ((input?.pickingUp ?? false) && ground.length > 0) { - let bestIndex = -1 - let bestDistance = PICKUP_RADIUS - ground.forEach((entry, index) => { - const distance = Math.hypot(entry.x - player.x, entry.y - player.y) - if (distance <= bestDistance) { bestDistance = distance; bestIndex = index } - }) - const entry = bestIndex === -1 ? undefined : ground[bestIndex] - if (entry !== undefined) { - if (bag.add(entry.item) === null) { - state.inventoryRefusals += 1 - } else { - ground.splice(bestIndex, 1) - state.pickups += 1 - } - } - } - // Save and load. Holding the key repeats, which is harmless: a save is - // idempotent and a load of the same file yields the same state. if (input?.saving ?? false) { try { - localStorage.setItem(SAVE_KEY, serializeSnapshot(captureSnapshot({ - world, rng: loot, inventory: bag, quests: questLog, ground, - }))) - state.saves += 1 - state.saveError = null - } catch (error) { - state.saveError = error instanceof Error ? error.message : String(error) + localStorage.setItem(SAVE_KEY, engine.saveSnapshot()) + engine.metrics.saves += 1 + engine.metrics.saveError = null + } catch(error) { + engine.metrics.saveError = error instanceof Error ? error.message : String(error) } } if (input?.loading ?? false) { try { const text = localStorage.getItem(SAVE_KEY) if (text === null) throw new Error('no save in this browser') - const loaded = parseSnapshot(text) - const pieces = restoreSnapshot(loaded, { - inventory: (width, height, placed) => Inventory.restore(width, height, placed), - quests: states => QuestLog.restore(questDefs, states), - }) - world = rebindPlayer({ - ...world, - tick: pieces.world.tick, - kills: pieces.world.kills, - player: { ...pieces.world.player }, - monsters: pieces.world.monsters.map(monster => ({ ...monster })), - players: [], - events: [], - }) - // Rebind everything derived from the world: a stale reference here means - // the tick that loaded the game finishes by writing to the old one. - player = world.player - loot = new Rng(pieces.rngState) - bag = pieces.inventory - questLog = pieces.quests - ground = pieces.ground - projectiles = [] - state.loads += 1 - state.saveError = null - } catch (error) { - state.saveError = error instanceof Error ? error.message : String(error) + engine.loadSnapshot(text) + engine.metrics.loads += 1 + engine.metrics.saveError = null + } catch(error) { + engine.metrics.saveError = error instanceof Error ? error.message : String(error) } } - state.groundItems = ground.length - state.inventoryItems = bag.contents.length - state.cellsUsed = bag.usedCells - state.gold = bag.gold - state.defense = totalStat(bag.contents, 'defense') - state.monstersNear = world.monsters + + const moved = engine.world.player.x !== beforeX || engine.world.player.y !== beforeY + animator.play(moved ? 'walk' : 'stand', engine.world.player.facing) + animator.tick(engine.world.player.facing) + state.actorClip = animator.clipName + state.actorDirection = animator.facing + state.actorFrame = animator.frameIndex + state.blocked = (movement.x !== 0 || movement.y !== 0) && !moved + + syncEngineState(engine, state) + // Custom states not in engine: + state.tick = loop.tick + state.monstersNear = engine.world.monsters .filter(monster => monster.state !== 'dead') - .map(monster => ({ x: monster.x, y: monster.y, distance: Math.hypot(monster.x - player.x, monster.y - player.y) })) + .map(monster => ({ x: monster.x, y: monster.y, distance: Math.hypot(monster.x - engine.world.player.x, monster.y - engine.world.player.y) })) .sort((a, b) => a.distance - b.distance) .slice(0, 4) - state.npcsNear = npcEntities.map(npc => ({ - x: npc.x, y: npc.y, distance: Math.hypot(npc.x - player.x, npc.y - player.y), + state.npcsNear = engine.npcEntities.map(npc => ({ + x: npc.x, y: npc.y, distance: Math.hypot(npc.x - engine.world.player.x, npc.y - engine.world.player.y), })) - state.quests = questLog.all.map(entry => ({ + state.quests = engine.questLog.all.map(entry => ({ id: entry.def.id, status: entry.status, kills: entry.kills, needed: entry.def.killCount, })) - state.tick = loop.tick - state.worldTick = world.tick - state.x = player.x - state.y = player.y - state.blocked = (movement.x !== 0 || movement.y !== 0) && !moved - state.monstersAlive = world.monsters.filter(monster => monster.state !== 'dead').length - state.kills = world.kills - state.playerHp = player.hp - state.playerMaxHp = player.maxHp - state.playerMana = player.mana - state.playerMaxMana = player.maxMana - state.playerLevel = player.level - state.playerXp = player.xp + state.dialog = engine.dialog }, onRender: () => { - const camera = { x: player.x, y: player.y, zoom: 2 } + const camera = { x: engine.world.player.x, y: engine.world.player.y, zoom: 2 } renderer.begin(camera, [0.04, 0.03, 0.03]) for (const draw of scene.floors) { const frame = frames[draw.tileIndex] @@ -701,21 +552,21 @@ function startScene(sources: MapSources, status: HTMLElement, hud: HTMLElement): readonly frame: AtlasFrame | null readonly tint: readonly [number, number, number, number] readonly bar: { readonly hp: number; readonly maxHp: number } | null - /** A ground item, drawn as a small tile until its art exists. */ + /** A engine.ground item, drawn as a small tile until its art exists. */ readonly ground?: Item } const actors: ActorDraw[] = [] const playerFrame = animator.frame() if (playerFrame !== undefined) { actors.push({ - x: player.x, y: player.y, - cellX: cellOf(player.x, tileWidth), cellY: cellOf(player.y, tileHeight), + x: engine.world.player.x, y: engine.world.player.y, + cellX: cellOf(engine.world.player.x, tileWidth), cellY: cellOf(engine.world.player.y, tileHeight), frame: playerFrame, - tint: player.alive ? [1, 1, 1, 1] : [0.55, 0.5, 0.5, 1], + tint: engine.world.player.alive ? [1, 1, 1, 1] : [0.55, 0.5, 0.5, 1], bar: null, }) } - for (const monster of world.monsters) { + for (const monster of engine.world.monsters) { const group = actorGroups?.[monster.facing] if (group === undefined || group.length === 0) continue const moving = monster.state === 'chase' || monster.state === 'attack' @@ -731,8 +582,8 @@ function startScene(sources: MapSources, status: HTMLElement, hud: HTMLElement): bar: monster.state === 'dead' ? null : { hp: monster.hp, maxHp: monster.stats.hp }, }) } - // NPCs and projectiles share the same painter's order as everything else. - for (const npc of npcEntities) { + // NPCs and engine.projectiles share the same painter's order as everything else. + for (const npc of engine.npcEntities) { const group = actorGroups?.[2] const frame = group?.[0] ?? null actors.push({ @@ -743,7 +594,7 @@ function startScene(sources: MapSources, status: HTMLElement, hud: HTMLElement): bar: null, }) } - for (const shot of projectiles) { + for (const shot of engine.projectiles) { actors.push({ x: shot.x, y: shot.y, cellX: cellOf(shot.x, tileWidth), cellY: cellOf(shot.y, tileHeight), @@ -753,7 +604,7 @@ function startScene(sources: MapSources, status: HTMLElement, hud: HTMLElement): }) } // Ground items take their place in the same depth order as everything else. - for (const entry of ground) { + for (const entry of engine.ground) { actors.push({ x: entry.x, y: entry.y, cellX: cellOf(entry.x, tileWidth), cellY: cellOf(entry.y, tileHeight), @@ -810,8 +661,8 @@ function startScene(sources: MapSources, status: HTMLElement, hud: HTMLElement): renderer.drawSolid(at.x + 1 / camera.zoom, at.y + size.h - filled, size.w - 2 / camera.zoom, filled, color) } state.orbs = [ - { name: 'life', x: 24, y: canvas.height - 84, width: 44, height: 60, fraction: player.maxHp === 0 ? 0 : player.hp / player.maxHp }, - { name: 'mana', x: canvas.width - 68, y: canvas.height - 84, width: 44, height: 60, fraction: player.maxMana === 0 ? 0 : player.mana / player.maxMana }, + { name: 'life', x: 24, y: canvas.height - 84, width: 44, height: 60, fraction: engine.world.player.maxHp === 0 ? 0 : engine.world.player.hp / engine.world.player.maxHp }, + { name: 'mana', x: canvas.width - 68, y: canvas.height - 84, width: 44, height: 60, fraction: engine.world.player.maxMana === 0 ? 0 : engine.world.player.mana / engine.world.player.maxMana }, ] for (const entry of state.orbs) { orb(entry.x, entry.y, entry.width, entry.height, entry.fraction, @@ -836,7 +687,7 @@ function startScene(sources: MapSources, status: HTMLElement, hud: HTMLElement): ) } } - for (const placed of bag.contents) { + for (const placed of engine.bag.contents) { renderer.drawSolid( bagAt.x + placed.x * cellWorld + 1 / camera.zoom, bagAt.y + placed.y * cellWorld + 1 / camera.zoom, @@ -856,20 +707,20 @@ function startScene(sources: MapSources, status: HTMLElement, hud: HTMLElement): state.tickRate = loop.tickRate hud.textContent = `tick ${String(state.tick)} · ${loop.tickRate.toFixed(1)} tps` + ` · ${state.source} · tables ${state.tablesSource}` - + ` · hp ${String(Math.max(0, Math.round(player.hp)))}/${String(player.maxHp)}` - + ` · mana ${String(Math.round(player.mana))}/${String(player.maxMana)}` - + ` · lvl ${String(player.level)} (${String(player.xp)} xp)` - + ` · monsters ${String(state.monstersAlive)}/${String(state.monstersSpawned)} alive · kills ${String(world.kills)}` + + ` · hp ${String(Math.max(0, Math.round(engine.world.player.hp)))}/${String(engine.world.player.maxHp)}` + + ` · mana ${String(Math.round(engine.world.player.mana))}/${String(engine.world.player.maxMana)}` + + ` · lvl ${String(engine.world.player.level)} (${String(engine.world.player.xp)} xp)` + + ` · monsters ${String(state.monstersAlive)}/${String(state.monstersSpawned)} alive · kills ${String(engine.world.kills)}` + ` · items ${state.itemTablesSource} · bag ${String(state.cellsUsed)}/${String(state.cellsTotal)}` - + ` · gold ${String(state.gold)} · ground ${String(state.groundItems)} · pickups ${String(state.pickups)}` + + ` · gold ${String(state.gold)} · engine.ground ${String(state.groundItems)} · pickups ${String(state.pickups)}` + (state.inventoryRefusals > 0 ? ` · bag full x${String(state.inventoryRefusals)}` : '') - + ` · skill ${String(selectedSkill)}/${String(skills.length - 1)} ${skills[selectedSkill]?.name ?? '-'}` + + ` · skill ${String(engine.selectedSkill)}/${String(engine.opts.skills.length - 1)} ${engine.opts.skills[engine.selectedSkill]?.name ?? '-'}` + ` · casts ${String(state.casts)} (hits ${String(state.castHits)}, refused ${String(state.castRefusals)})` + ` · quests ${state.quests.map(entry => `${entry.id}:${entry.status}(${String(entry.kills)}/${String(entry.needed)})`).join(' ') || '-'}` + ` · saves ${String(state.saves)}/loads ${String(state.loads)}` + (state.saveError === null ? '' : ` · save error: ${state.saveError}`) + (state.dialog.length > 0 ? ' · 对话中' : '') - + (player.alive ? '' : ' · 死亡中,稍后重生') + + (engine.world.player.alive ? '' : ' · 死亡中,稍后重生') }, }) diff --git a/tests/engine.test.ts b/tests/engine.test.ts new file mode 100644 index 0000000..fae6952 --- /dev/null +++ b/tests/engine.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, test } from 'vitest' +import { goldItem } from "../src/game/items" +import { GameEngine } from '../src/game/engine' +import type { GameEngineOptions, WorldMapProvider } from '../src/game/engine' + +describe('GameEngine', () => { + const dummyTerrain: WorldMapProvider = { + widthPx: 1000, + heightPx: 1000, + overlap: (x, y) => { + // Small blocked area around 500,500 + if (x >= 400 && x <= 600 && y >= 400 && y <= 600) return 1 + return 0 + } + } + + const dummyOpts: GameEngineOptions = { + spawn: { x: 100, y: 100 }, + stats: [ + { id: 'mon1', name: 'Goblin', hp: 20, damage: 2, cooldownTicks: 10, reach: 20, aggroRadius: 100, speed: 50, xp: 5 }, + { id: 'mon2', name: 'Orc', hp: 50, damage: 5, cooldownTicks: 20, reach: 30, aggroRadius: 150, speed: 40, xp: 10 } + ], + xpTable: [0, 0, 10, 30, 60], + itemBases: [ + { id: 'sword', name: 'Sword', kind: 'weapon', invWidth: 1, invHeight: 3, maxStack: 1, value: 10, damage: 5, defense: 0, tags: ['weap'], level: 1 } + ], + prefixAffixes: [], + suffixAffixes: [], + skills: [ + { id: 'attack', name: 'Attack', manaCost: 0, cooldownTicks: 10, range: 48, projectile: false, speed: 0, baseMinDamage: 3, baseMaxDamage: 4, damagePerLevel: 1, radius: 20 }, + { id: 'fire', name: 'Fire', manaCost: 5, cooldownTicks: 15, range: 200, projectile: true, speed: 200, baseMinDamage: 10, baseMaxDamage: 15, damagePerLevel: 2, radius: 20 } + ], + npcDefs: [], + questDefs: [], + combatOptions: { + playerSpeed: 100, + playerReach: 50, + playerCooldownTicks: 10, + playerDamage: 5, + playerManaPerAttack: 0, + respawnTicks: 100 + }, + lootSeed: 12345, + talkRadius: 50, + pickupRadius: 50, + inventoryCols: 10, + inventoryRows: 4 + } + + test('Determinism: same seed + same inputs yields identical hash', () => { + const runFrames = (engine: GameEngine) => { + // 50 ticks of walking right, attacking, then picking up + for (let i = 0; i < 50; i++) { + engine.tick({ + movement: { x: 1, y: 0 }, + attacking: i % 10 === 0, + pickingUp: i === 45, + talking: false, + digits: [2], + saving: false, + loading: false + }) + } + return engine.saveSnapshot() + } + + const engine1 = new GameEngine(dummyTerrain, dummyOpts) + const hash1 = runFrames(engine1) + + const engine2 = new GameEngine(dummyTerrain, dummyOpts) + const hash2 = runFrames(engine2) + + expect(hash1).toEqual(hash2) + }) + + test('Collision and sliding against stub WorldMapProvider', () => { + const engine = new GameEngine(dummyTerrain, { ...dummyOpts, spawn: { x: 390, y: 450 } }) + + // Move strictly right, towards the blocked 400,400 region + for (let i = 0; i < 20; i++) { + engine.tick({ movement: { x: 1, y: 0 }, attacking: false, pickingUp: false, talking: false, digits: [], saving: false, loading: false }) + } + + // Player should be stopped by the wall at x=400 (if their reach/body allows, else close. + // Given movement logic, they shouldn't pass x=400. + expect(engine.world.player.x).toBeLessThan(400) + }) + + test('Skill cast, cooldown gating, and cast refusal on insufficient mana', () => { + const engine = new GameEngine(dummyTerrain, dummyOpts) + engine.world.player.mana = 3 // Not enough for Fire (cost 5) + + // Select skill first tick + engine.tick({ movement: { x: 0, y: 0 }, attacking: false, pickingUp: false, talking: false, digits: [2], saving: false, loading: false }) + + // Attack next tick + engine.tick({ movement: { x: 0, y: 0 }, attacking: true, pickingUp: false, talking: false, digits: [], saving: false, loading: false }) + + // Since we don't have mana, castRefusals goes up + expect(engine.metrics.castRefusals).toBe(1) + expect(engine.metrics.casts).toBe(0) + + // Give mana + engine.world.player.mana = 10 + engine.tick({ movement: { x: 0, y: 0 }, attacking: true, pickingUp: false, talking: false, digits: [], saving: false, loading: false }) + + expect(engine.metrics.casts).toBe(1) + expect(engine.metrics.castHits).toBeGreaterThanOrEqual(0) + }) + + test('Save round-trip: save() -> load() -> state equivalent', () => { + const engine1 = new GameEngine(dummyTerrain, dummyOpts) + for (let i = 0; i < 10; i++) { + engine1.tick({ movement: { x: 1, y: 1 }, attacking: true, pickingUp: true, talking: false, digits: [2], saving: false, loading: false }) + } + const stateStr = engine1.saveSnapshot() + + const engine2 = new GameEngine(dummyTerrain, dummyOpts) + engine2.loadSnapshot(stateStr) + + expect(engine2.saveSnapshot()).toEqual(stateStr) + expect(engine2.world.player.x).toEqual(engine1.world.player.x) + expect(engine2.projectiles.length).toEqual(engine1.projectiles.length) + }) + + test('Loot drop and pickup; inventory add/remove and capacity limits', () => { + const engine = new GameEngine(dummyTerrain, dummyOpts) + // Add an item to ground + engine.ground.push({ x: engine.world.player.x, y: engine.world.player.y, item: goldItem(10) }) + + // Tick to pick it up + engine.tick({ movement: { x: 0, y: 0 }, attacking: false, pickingUp: true, talking: false, digits: [], saving: false, loading: false }) + + expect(engine.bag.contents.length).toBe(1) + expect(engine.ground.length).toBe(0) + expect(engine.metrics.pickups).toBe(1) + }) +})