feat: 抽取独立无状态 GameEngine 领域与相关逻辑
将 map-scene 中负责每帧更新逻辑与状态管理的巨量代码抽取成 `src/game/engine.ts` 内的 `GameEngine`。 解耦模拟与 WebGL 和 DOM,保证可以无界面测试。 并引入了 `tests/engine.test.ts` 自动化单元测试(Vitest)。
This commit is contained in:
parent
98d6a6dad2
commit
38d06cdb28
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 ? '' : ' · 死亡中,稍后重生')
|
||||
},
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue