213 lines
8.3 KiB
TypeScript
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 }
|
|
}
|