diablo2-web/src/scene/backend/net-simulation.ts

213 lines
8.3 KiB
TypeScript

import type { SpriteSheet } from '../../formats/sprite.ts'
import { monsterStatsFromTable, tickCombatMulti } from '../../game/combat.ts'
import type { CombatInput, CombatOptions, CombatTerrain, CombatWorld, MonsterStats } from '../../game/combat.ts'
import { D2Rng } from '../../game/d2-rng.ts'
import { executeDropPipeline, type DropTables } from '../../game/drop-pipeline.ts'
import { getEmbeddedDropTables } from '../../game/embedded-drop-tables.ts'
import { findIsometricDropPosition, getPickupEdgeDistance } from '../../game/ground-items.ts'
import { Inventory, type Item } from '../../game/items.ts'
import { getMonsterTreasureClass, type Difficulty } from '../../game/monsters.ts'
import { QuestLog } from '../../game/quests.ts'
import { parseTable } from '../../game/tables.ts'
import { computeLockstepHash, type InputFrame, type LockstepSimulation } from '../../net/lockstep.ts'
/** World seed: fixed, because both peers must build the same world. */
export const NET_SEED = 0x0d2c0de
/** Raw members the scene needs. */
export interface NetSources {
readonly ds1: Uint8Array
readonly libraries: readonly Uint8Array[]
readonly palette: Uint8Array
readonly paletteName: string
readonly actor: { readonly name: string; readonly sheet: SpriteSheet } | null
readonly monstats: Uint8Array | null
}
/** The monster table used when the archive has none, so the page is never empty. */
export const DEMO_MONSTERS: MonsterStats[] = monsterStatsFromTable(parseTable([
'Id\tName\tHP\tDamage\tCooldownTicks\tReach\tAggroRadius\tSpeed\tXP',
'fallen\tFallen One\t16\t2\t24\t38\t420\t150\t12',
'zombie\tZombie\t30\t3\t32\t42\t360\t120\t20',
].join('\n')))
/** Fallback experience thresholds: the co-op sandbox is not about levelling. */
export const DEMO_EXPERIENCE: readonly number[] = [0, 0, 250, 700, 1500, 3000]
export function createNetSimulation(
world: CombatWorld,
options: CombatOptions,
terrain: CombatTerrain,
xpTable: readonly number[],
numPeers: number,
seed: number = NET_SEED,
dropTables: DropTables = getEmbeddedDropTables(),
difficulty: Difficulty = 'normal',
) {
const inventories = Array.from({ length: Math.max(numPeers, 1) }, () => new Inventory(10, 4))
const quests = new QuestLog([])
const ground: { x: number; y: number; item: Item }[] = []
world.pendingKills = world.pendingKills ?? []
for (let i = 0; i < world.monsters.length; i += 1) {
const monster = world.monsters[i]!
if (monster.dwInitSeed === undefined) {
monster.dwInitSeed = (((seed ^ Math.imul(monster.index + 1, 0x9e3779b9)) >>> 0) || 1)
}
}
const mapTerrain = 'widthPx' in terrain
? (terrain as any)
: { ...terrain, widthPx: 10000, heightPx: 10000 }
const simulation: LockstepSimulation = {
advance: (inputs: readonly InputFrame[]) => {
const frames: CombatInput[] = inputs.map(input => ({
movement: input.movement,
attack: input.attack,
}))
tickCombatMulti(world, frames, options, terrain, xpTable)
const killEvents = [
...(world.pendingKills ?? []),
...world.events.filter(e => e.kind === 'kill'),
]
if (world.pendingKills !== undefined) {
world.pendingKills.length = 0
}
const activePlayers = Math.max(1, Math.min(8, world.players.filter(p => p.alive).length || numPeers || 1))
for (const event of killEvents) {
if (event.kind !== 'kill') continue
const monster = event.monsterIndex !== undefined
? world.monsters[event.monsterIndex] ?? world.monsters.find(m => m.index === event.monsterIndex)
: world.monsters.find(m => m.stats.id === event.subjectId && Math.hypot(m.x - event.x, m.y - event.y) < 10)
if (monster?.dropRolled) continue
if (monster) {
monster.dropRolled = true
}
const subjectId = (event.subjectId ?? monster?.stats.id ?? '').trim()
const monsterRank = event.monsterRank ?? monster?.stats.rank ?? 'normal'
const isMinion = monsterRank === 'minion'
const monsterType = isMinion
? 3
: (event.monsterType ?? (monsterRank === 'boss' ? 4 : monsterRank === 'unique' ? 3 : monsterRank === 'champion' ? 2 : 1))
const suDef = event.superUniqueId
? (dropTables.superUniques.get(event.superUniqueId) ??
(() => {
const target = event.superUniqueId!.trim().toLowerCase()
for (const [k, v] of dropTables.superUniques) {
if (k.toLowerCase() === target || v.id.toLowerCase() === target || v.nameKey?.toLowerCase() === target) {
return v
}
}
return undefined
})())
: undefined
let tcName = ''
if (!isMinion && suDef) {
tcName = typeof suDef.getTreasureClass === 'function'
? suDef.getTreasureClass(difficulty)
: suDef.treasureClass
}
const monsterKind =
dropTables.monsterKinds.get(subjectId) ??
dropTables.monsterKinds.get(`${subjectId}1`) ??
(suDef ? dropTables.monsterKinds.get(suDef.monsterId) : undefined)
if (!tcName && monsterKind) {
tcName = getMonsterTreasureClass(monsterKind, difficulty, monsterType)
}
if (!tcName && !monsterKind && !suDef) {
tcName = 'Act 1 H2H A'
}
if (!tcName) continue
const kindLevel = typeof (monsterKind as any)?.level === 'number'
? (monsterKind as any).level
: (monsterKind as any)?.level?.[difficulty]
const nLevel = Math.max(1, event.monsterLevel ?? monster?.stats.level ?? kindLevel ?? 1)
const monsterIndex = monster?.index ?? event.monsterIndex ?? 0
const monsterSeed =
monster?.dwInitSeed ??
((((seed ^ Math.imul(monsterIndex + 1, 0x9e3779b9)) >>> 0) || 1))
const droppedItems = executeDropPipeline(dropTables, {
tcName,
nLevel,
monsterType,
difficulty,
gamePlayers: activePlayers,
partyPlayers: activePlayers,
playerMf: 0,
playerGf: 0,
isBoss: isMinion ? false : Boolean(monsterKind?.boss),
isNoRatio: Boolean(monsterKind?.noRatio),
monsterRng: new D2Rng(monsterSeed),
})
const burstOccupied: { x: number; y: number }[] = ground.map(g => ({ x: g.x, y: g.y }))
const alivePlayers = world.players.filter(p => p.alive)
const refPlayers = alivePlayers.length > 0 ? alivePlayers : world.players
let reachableFrom: { x: number; y: number } | undefined
if (refPlayers.length > 0) {
let bestPlayer = refPlayers[0]!
let bestDist = Math.hypot(bestPlayer.x - event.x, bestPlayer.y - event.y)
for (let pIdx = 1; pIdx < refPlayers.length; pIdx += 1) {
const p = refPlayers[pIdx]!
const d = Math.hypot(p.x - event.x, p.y - event.y)
if (d < bestDist) {
bestDist = d
bestPlayer = p
}
}
reachableFrom = { x: bestPlayer.x, y: bestPlayer.y }
}
for (const drop of droppedItems) {
const dropPos = findIsometricDropPosition(
event.x,
event.y,
burstOccupied,
mapTerrain,
undefined,
undefined,
undefined,
undefined,
reachableFrom,
)
burstOccupied.push(dropPos)
ground.push({ x: dropPos.x, y: dropPos.y, item: drop })
}
}
inputs.forEach((input, index) => {
if (input.pickup && ground.length > 0) {
const player = world.players[index]
if (player) {
let bestIndex = -1
let bestEdgeDist = 64
for (let gIdx = 0; gIdx < ground.length; gIdx += 1) {
const g = ground[gIdx]!
const edgeDist = getPickupEdgeDistance(player.x, player.y, g.x, g.y)
if (edgeDist <= bestEdgeDist) {
bestEdgeDist = edgeDist
bestIndex = gIdx
}
}
if (bestIndex !== -1 && inventories[index]!.add(ground[bestIndex]!.item) !== null) {
ground.splice(bestIndex, 1)
}
}
}
})
},
hash: () => computeLockstepHash(world, inventories, ground, quests.all),
}
return { simulation, inventories, quests, ground }
}