diablo2-web/src/game/engine.ts

533 lines
19 KiB
TypeScript

import { tickCombat, createWorld, spawnMonsters, spawnMonsterPacks, damageMonster, rebindPlayer } from './combat.ts'
import type { CombatWorld, CombatOptions, MonsterPack, MonsterStats, SafeZone } 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'
import { MonsterStreamingManager } from './monster-streaming.ts'
import type { MonsterStreamingOptions, StreamingRoomDef, StreamingRoomState } from './monster-streaming.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
}
/**
* Where snapshots are kept.
*
* The engine owns *when* a save happens — on the rising edge of
* {@link EngineInput.saving}, at a tick boundary, after the simulation has
* settled — but not *where* it goes. Browsers have `localStorage`, tests have a
* `Map`, and a future server would have a file; none of that may leak into the
* simulation, which has to stay reproducible from a seed and an input log.
*/
export interface SnapshotStore {
/**
* Read the stored snapshot.
*
* @returns the serialized snapshot, or null when nothing has been saved.
*/
read(): string | null
/**
* Overwrite the stored snapshot.
*
* @param data - a serialized snapshot.
*/
write(data: string): void
}
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
/**
* How many monsters to spawn at construction.
*
* Defaults to 8, which is the sandbox number this engine grew up with. Real
* levels derive it from `Levels.txt` (`NumMon` for how many *kinds*, `MonDen`
* for density), so callers that have a level row pass their own.
*/
monsterCount?: number
/**
* Spawn spread around {@link GameEngineOptions.spawn}, in scene pixels.
*
* Defaults to 260. Real levels scatter across the whole map rather than a
* disc around the entrance, so this is a sandbox affordance, not a rule.
*/
monsterSpread?: number
/**
* Pre-planned packs, which take precedence over
* {@link GameEngineOptions.stats} and {@link GameEngineOptions.monsterCount}.
*
* A level read from `Levels.txt` does not produce a count and a type list; it
* produces groups — two to three Fallen here, a Quill Rat pair there — and
* flattening that back into "spawn 20 monsters from these 3 types" loses the
* grouping, which is most of what makes a level feel populated. Scenes that
* have planned their level pass packs; fixtures that just want some monsters
* to hit keep passing a count.
*/
monsterPacks?: readonly MonsterPack[]
/**
* Optional safe zones (e.g. around spawn point, town bridges, and waypoints)
* where ordinary monster packs must not spawn.
*/
safeZones?: readonly SafeZone[]
/**
* Whether to place NPCs on a synthetic ring around the spawn.
*
* Defaults to true, for fixtures that have NPC definitions but no positions.
* Scenes that know where their NPCs actually stand — a DS1 carries their
* coordinates — pass false and fill {@link GameEngine.npcEntities} themselves,
* so the ring is never built and then thrown away.
*/
npcRingFallback?: boolean
/**
* Backing store for {@link EngineInput.saving} and
* {@link EngineInput.loading}.
*
* Omit it and both inputs are inert — which is what every fixture wants, and
* what {@link GameEngine.saveSnapshot} is for when a caller drives saving
* itself.
*/
snapshotStore?: SnapshotStore
/**
* Optional pre-constructed MonsterStreamingManager for lazy room population
* and active/sleeping monster streaming.
*/
streamingManager?: MonsterStreamingManager | undefined
/**
* Pre-planned rooms for monster streaming. When configured without an explicit
* streamingManager, a MonsterStreamingManager is initialized automatically.
*/
roomPacks?: readonly StreamingRoomDef[] | undefined
/**
* Alias for roomPacks.
*/
streamingRooms?: readonly StreamingRoomDef[] | undefined
/**
* Tuning options for MonsterStreamingManager when created from roomPacks.
*/
streamingOptions?: MonsterStreamingOptions | undefined
}
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: NpcEntity[] = []
dialog: string[] = []
streamingManager?: MonsterStreamingManager
/**
* Whether the save input was held on the previous tick.
*
* Save and load fire on a rising edge. The input is a key state sampled every
* tick, so without this a key held for half a second would save twelve times
* — and a held load key would re-apply the snapshot every tick, pinning the
* player in place.
*/
private savingHeld = false
/** Whether the load input was held on the previous tick. */
private loadingHeld = false
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) {
const rooms = opts.roomPacks ?? opts.streamingRooms
if (opts.streamingManager !== undefined) {
this.streamingManager = opts.streamingManager
this.world = opts.streamingManager.world
} else {
this.world = createWorld(opts.spawn.x, opts.spawn.y)
if (rooms !== undefined && rooms.length > 0) {
this.streamingManager = new MonsterStreamingManager(
this.world,
this.terrain,
rooms,
opts.streamingOptions,
)
}
}
const spread = opts.monsterSpread ?? 260
const count = opts.monsterCount ?? 8
if (this.streamingManager !== undefined) {
// Monsters are lazily populated by streamingManager; avoid frame-0 eager population.
} else if (opts.monsterPacks !== undefined && opts.monsterPacks.length > 0) {
spawnMonsterPacks(this.world, opts.monsterPacks, opts.spawn, spread, this.terrain, opts.safeZones)
} else if (count > 0) {
spawnMonsters(this.world, opts.stats, count, opts.spawn, spread, 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)
if (opts.npcRingFallback ?? true) {
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
// These are the synthetic ring placements used when a scene carries no
// prebaked NPC art, so they never have a sprite of their own to draw.
this.npcEntities.push({ def, x, y, hasSprite: false })
}
}
}
tick(input: EngineInput): void {
const movement = input.movement
const player = this.world.player
const beforeX = player.x
const beforeY = player.y
this.streamingManager?.update(player.x, 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
}
}
}
this.handleSaveLoad(input)
}
/**
* Apply the save and load inputs, once per press.
*
* Runs last so a snapshot always describes a settled tick: movement, combat,
* drops and pickups have all been applied, which is what a player who pressed
* save this frame expects to get back. Loading last likewise means the
* restored state is what the *next* tick starts from, rather than being
* partially overwritten by the rest of this one.
*
* @param input - this tick's input.
*/
private handleSaveLoad(input: EngineInput): void {
const store = this.opts.snapshotStore
if (store !== undefined) {
if (input.saving && !this.savingHeld) {
try {
store.write(this.saveSnapshot())
this.metrics.saves += 1
this.metrics.saveError = null
} catch (error) {
// A full quota or a private-browsing window must not kill the loop.
this.metrics.saveError = error instanceof Error ? error.message : String(error)
}
}
if (input.loading && !this.loadingHeld) {
try {
const data = store.read()
if (data !== null) {
this.loadSnapshot(data)
this.metrics.loads += 1
this.metrics.saveError = null
}
} catch (error) {
// A snapshot from an older schema is a refusal, not a crash.
this.metrics.saveError = error instanceof Error ? error.message : String(error)
}
}
}
this.savingHeld = input.saving
this.loadingHeld = input.loading
}
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 = []
if (this.streamingManager !== undefined) {
this.streamingManager.world = this.world
}
}
}
export interface NpcEntity {
def: NpcDef
x: number
y: number
/**
* Whether this NPC's own art is already drawn by the scene's sprite layer.
*
* The renderer falls back to a coloured placeholder box for NPCs without art.
* Once real art exists the box would sit on top of the sprite, so the two
* paths are mutually exclusive and the renderer needs to know which applies.
*/
hasSprite: boolean
}
export interface EngineViewState {
monstersSpawned?: number
monstersAlive?: number
kills?: number
playerHp?: number
playerMaxHp?: number
playerMana?: number
playerMaxMana?: number
playerLevel?: number
playerXp?: number
playerHits?: number
groundItems?: number
inventoryItems?: number
cellsUsed?: number
cellsTotal?: number
gold?: number
defense?: number
dropsRolled?: number
pickups?: number
inventoryRefusals?: number
lastDrops?: string[]
skills?: string[]
selectedSkill?: number
casts?: number
castRefusals?: number
castHits?: number
projectilesAlive?: number
npcs?: number
npcsNear?: NpcEntity[]
monstersNear?: unknown[]
quests?: unknown[]
dialog?: string[]
questXp?: number
questGold?: number
worldTick?: number
saves?: number
loads?: number
saveError?: string | null
tick?: number
x?: number
y?: number
bagItems?: number
}
export function syncEngineState(engine: GameEngine, state: EngineViewState): 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
}
export { MonsterStreamingManager } from './monster-streaming.ts'
export type { MonsterStreamingOptions, StreamingRoomDef, StreamingRoomState } from './monster-streaming.ts'