533 lines
19 KiB
TypeScript
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'
|