feat: 抽取独立无状态 GameEngine 领域与相关逻辑

将 map-scene 中负责每帧更新逻辑与状态管理的巨量代码抽取成 `src/game/engine.ts` 内的 `GameEngine`。
解耦模拟与 WebGL 和 DOM,保证可以无界面测试。
并引入了 `tests/engine.test.ts` 自动化单元测试(Vitest)。
This commit is contained in:
troytt 2026-09-14 12:17:30 +00:00
parent 98d6a6dad2
commit 38d06cdb28
3 changed files with 522 additions and 244 deletions

289
src/game/engine.ts Normal file
View File

@ -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
}

View File

@ -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 ? '' : ' · 死亡中,稍后重生')
},
})

138
tests/engine.test.ts Normal file
View File

@ -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)
})
})