780 lines
31 KiB
TypeScript
780 lines
31 KiB
TypeScript
/**
|
|
* Co-op scene: two browsers, one shared world, one fighter each.
|
|
*
|
|
* The page has three jobs, and the interesting part is what it does *not* do.
|
|
*
|
|
* - **It simulates everything.** No world state, no monster position, no drop and
|
|
* no damage figure crosses the wire. Each page runs the same simulation from the
|
|
* same seed, and the only messages are the four the protocol defines: who I am,
|
|
* what I pressed, what hash I got, goodbye. That is what makes 25 ticks a second
|
|
* cheap enough to run over an ordinary connection.
|
|
* - **It waits rather than guesses.** The tick loop is driven by
|
|
* {@link NetplaySession.pump}, which advances only when both peers' input for the
|
|
* tick has arrived. A missing peer is a held world, never a fabricated input.
|
|
* - **It says when it disagrees.** Both peers hash their state each tick and
|
|
* compare; the first tick that differs is shown on screen with both digests. A
|
|
* desync that is invisible is worse than no check at all.
|
|
*
|
|
* The local player is `players[localPeer]`, which is a *view* decision: peer 0
|
|
* drives player 0 and peer 1 drives player 1, but both pages hold the same two
|
|
* bodies in the same order, which is what the state digest covers.
|
|
*
|
|
* URL parameters: `?data=<fixture dir>` (or `?mpq=<url>`) for the map,
|
|
* `?ws=<relay url>` to play with someone, `?peer=<index>` for this page's index,
|
|
* `?peers=<2..4>` for how many are playing. With no `?ws=` the page runs the same
|
|
* world solo, so it is useful on its own.
|
|
*/
|
|
import { MpqArchive } from '../mpq/archive.ts'
|
|
import { blobSource } from '../mpq/source.ts'
|
|
import { decodeDs1 } from '../formats/ds1.ts'
|
|
import { decodeDt1 } from '../formats/dt1.ts'
|
|
import { decodeDc6 } from '../formats/dc6.ts'
|
|
import { decodePal } from '../formats/pal.ts'
|
|
import { decodePl2 } from '../formats/pl2.ts'
|
|
import type { Palette } from '../formats/pal.ts'
|
|
import type { SpriteSheet } from '../formats/sprite.ts'
|
|
import { PLAYER_SPRITE_WIDTH } from '../formats/sprite.ts'
|
|
import { addPlayer, createWorld, monsterStatsFromTable, spawnMonsters, tickCombatMulti } from '../game/combat.ts'
|
|
import type { CombatInput, CombatOptions, CombatTerrain, CombatWorld, MonsterStats } from '../game/combat.ts'
|
|
import { parseTable } from '../game/tables.ts'
|
|
import { ActorAnimator } from '../game/animation.ts'
|
|
import { buildMapScene, blockedOverlap, findFreeSpawn } from '../game/map.ts'
|
|
import type { MapScene } from '../game/map.ts'
|
|
import { buildAtlas } from '../render/atlas.ts'
|
|
import type { AtlasFrame } from '../render/atlas.ts'
|
|
import { SpriteRenderer } from '../render/renderer.ts'
|
|
import { GameLoop } from '../sim/loop.ts'
|
|
import { KeyboardInput, directionOf } from '../sim/input.ts'
|
|
import { NetplaySession } from '../net/netplay.ts'
|
|
import { socketTransport } from '../net/transport.ts'
|
|
import { LockstepSession, computeLockstepHash } from '../net/lockstep.ts'
|
|
import { Inventory, type Item } from '../game/items.ts'
|
|
import { QuestLog } from '../game/quests.ts'
|
|
import { executeDropPipeline, type DropTables } from '../game/drop-pipeline.ts'
|
|
import { getEmbeddedDropTables } from '../game/embedded-drop-tables.ts'
|
|
import { getMonsterTreasureClass, type Difficulty } from '../game/monsters.ts'
|
|
import { D2Rng } from '../game/d2-rng.ts'
|
|
import { findSafeDropPosition, findIsometricDropPosition, getPickupEdgeDistance } from '../game/ground-items.ts'
|
|
import { worldToCell } from '../game/engine.ts'
|
|
|
|
import type { InputFrame, LockstepSimulation } from '../net/lockstep.ts'
|
|
|
|
/** Collision box at the character's feet. */
|
|
const FEET_WIDTH = 20
|
|
const FEET_HEIGHT = 14
|
|
/** Walk speed in pixels per second. */
|
|
const WALK_SPEED = 180
|
|
/** Monsters spawned between the two players. */
|
|
const MONSTER_COUNT = 10
|
|
/** Ticks of input delay: the latency budget a peer may spend without stalling. */
|
|
const INPUT_DELAY_TICKS = 4
|
|
/** Ticks between exchanged state hashes. */
|
|
const HASH_INTERVAL = 5
|
|
/** Maximum map libraries to merge, matching the campaign scene. */
|
|
const MAX_LIBRARIES = 4
|
|
/** World seed: fixed, because both peers must build the same world. */
|
|
const NET_SEED = 0x0d2c0de
|
|
|
|
/**
|
|
* The page's state, exposed for the harness. Everything the automated checks look
|
|
* at is here rather than scraped out of the canvas.
|
|
*/
|
|
interface NetState {
|
|
source: string
|
|
error: string | null
|
|
/** Whether a relay connection was requested. */
|
|
multiplayer: boolean
|
|
peer: number
|
|
connected: boolean
|
|
handshaked: boolean
|
|
timedOut: boolean
|
|
wsUrl: string | null
|
|
/** Ticks the render loop has run (25 a second, whether or not the world moves). */
|
|
tick: number
|
|
/** Ticks the shared world has actually simulated. */
|
|
worldTick: number
|
|
/** Ticks the lockstep session has cleared, which is the network's own clock. */
|
|
lockstepTick: number
|
|
/** Ticks the session did run, and handshake messages it sent. */
|
|
stepped: number
|
|
helloSent: number
|
|
/** The peer's seed when it disagrees with ours, otherwise null. */
|
|
seedMismatch: number | null
|
|
/** Ticks that could not run for want of the peer's input. */
|
|
waiting: number
|
|
hashesCompared: number
|
|
hashesAgreed: number
|
|
hashesIgnored: number
|
|
/** Whether the peer has acknowledged this page's hello. */
|
|
acknowledged: boolean
|
|
/** The first diverging tick, and both digests, when there is one. */
|
|
desyncTick: number | null
|
|
desyncLocal: number | null
|
|
desyncRemote: number | null
|
|
/** Both fighters, in peer order. */
|
|
players: { x: number; y: number; hp: number; maxHp: number; xp: number; level: number; alive: boolean }[]
|
|
/** Which player this page drives, and how many peers the game expects. */
|
|
localPeer: number
|
|
peers: number
|
|
peersHeard: number
|
|
peersExpected: number
|
|
monsters: number
|
|
kills: number
|
|
tickRate: number
|
|
frame: number
|
|
/** The last WebGL error code seen while rendering, 0 when clean. */
|
|
glError: number
|
|
transportOpen: boolean
|
|
/** Where the actor art came from, or 'marker' when the map has none. */
|
|
actorSource: string
|
|
/** How many monsters the spawn actually placed. */
|
|
monstersSpawned: number
|
|
/** Messages the socket itself delivered, counted in the page. */
|
|
socketFrames: number
|
|
/** Whether the socket reached the open state, and whether it has closed. */
|
|
socketOpened: boolean
|
|
socketClosed: boolean
|
|
/** Messages that arrived but failed to decode. */
|
|
malformed: number
|
|
/**
|
|
* The socket's own view of itself, as a string: ready state, binary type,
|
|
* negotiated extensions, subprotocol and unsent bytes.
|
|
*
|
|
* Kept because "no frames arrive" has several unrelated causes — a socket that
|
|
* never opened, a socket that negotiated an extension this relay does not
|
|
* speak, a socket whose frames are being refused — and this string tells them
|
|
* apart from outside the page.
|
|
*/
|
|
socketInfo: string
|
|
}
|
|
|
|
declare global {
|
|
interface Window { __d2webNet?: NetState }
|
|
}
|
|
|
|
const state: NetState = {
|
|
source: 'none', error: null, multiplayer: false, peer: 0, connected: false, handshaked: false,
|
|
timedOut: false, wsUrl: null, tick: 0, worldTick: 0, lockstepTick: 0, stepped: 0, helloSent: 0,
|
|
seedMismatch: null, acknowledged: false, waiting: 0, hashesCompared: 0, hashesAgreed: 0, hashesIgnored: 0,
|
|
desyncTick: null, desyncLocal: null, desyncRemote: null, players: [], localPeer: 0, peers: 2,
|
|
peersHeard: 0, peersExpected: 1, monsters: 0,
|
|
kills: 0, tickRate: 0, frame: 0, glError: 0, transportOpen: false,
|
|
actorSource: 'marker', monstersSpawned: 0,
|
|
socketFrames: 0, socketOpened: false, socketClosed: false, malformed: 0, socketInfo: 'none',
|
|
}
|
|
if (typeof window !== 'undefined') window.__d2webNet = state
|
|
|
|
/** Raw members the scene needs. */
|
|
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. */
|
|
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')))
|
|
|
|
let running: GameLoop | null = null
|
|
let input: KeyboardInput | null = null
|
|
let activeRenderer: SpriteRenderer | null = null
|
|
|
|
/**
|
|
* Fetch bytes, or null when the file is absent.
|
|
*
|
|
* @param url - the URL.
|
|
* @returns the bytes, or null.
|
|
*/
|
|
async function fetchBytes(url: string): Promise<Uint8Array | null> {
|
|
const response = await fetch(url)
|
|
if (!response.ok) return null
|
|
return new Uint8Array(await response.arrayBuffer())
|
|
}
|
|
|
|
/**
|
|
* Fetch bytes, failing when the file is absent.
|
|
*
|
|
* @param url - the URL.
|
|
* @returns the bytes.
|
|
*/
|
|
async function requireBytes(url: string): Promise<Uint8Array> {
|
|
const bytes = await fetchBytes(url)
|
|
if (bytes === null) throw new Error(`缺少 ${url}`)
|
|
return bytes
|
|
}
|
|
|
|
/**
|
|
* Read the map members from a fixture directory.
|
|
*
|
|
* A fixture directory is the harness's source of truth: it needs no archive and
|
|
* no picker, so an automated check can load the same map on two pages and be sure
|
|
* both are simulating the same terrain.
|
|
*
|
|
* @param dir - the directory.
|
|
* @returns the sources.
|
|
*/
|
|
async function loadFixtureSources(dir: string): Promise<NetSources> {
|
|
const [ds1, dt1, palette, actor, monstats] = await Promise.all([
|
|
requireBytes(`${dir}/fixture.ds1`),
|
|
requireBytes(`${dir}/fixture.dt1`),
|
|
requireBytes(`${dir}/palette.pal`),
|
|
fetchBytes(`${dir}/actor.dc6`),
|
|
fetchBytes(`${dir}/data/global/excel/monstats.txt`),
|
|
])
|
|
return {
|
|
ds1, libraries: [dt1], palette, paletteName: 'palette.pal', monstats,
|
|
actor: actor === null ? null : { name: `${dir}/actor.dc6`, sheet: decodeDc6(actor) },
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Pick an archive member by extension and preferred name fragment.
|
|
*
|
|
* @param names - the archive's members.
|
|
* @param extension - the extension to match.
|
|
* @param preferred - fragments to prefer, best first.
|
|
* @returns the chosen name, or undefined.
|
|
*/
|
|
function pickMember(names: readonly string[], extension: string, preferred: readonly string[]): string | undefined {
|
|
const matching = names.filter(name => name.toLowerCase().endsWith(`.${extension}`))
|
|
const rank = (name: string): number => {
|
|
const lower = name.toLowerCase()
|
|
const index = preferred.findIndex(token => lower.includes(token))
|
|
return index === -1 ? preferred.length : index
|
|
}
|
|
return matching.sort((a, b) => rank(a) - rank(b) || a.localeCompare(b))[0]
|
|
}
|
|
|
|
/**
|
|
* Read the map members from an archive.
|
|
*
|
|
* @param blob - the archive bytes.
|
|
* @param label - a name for the archive, for the HUD.
|
|
* @returns the sources.
|
|
*/
|
|
async function loadArchiveSources(blob: Blob, label: string): Promise<NetSources> {
|
|
const archive = await MpqArchive.open(blobSource(blob, label))
|
|
const names = await archive.listFiles()
|
|
const ds1Name = pickMember(names, 'ds1', ['town', 'act1', 'rogue'])
|
|
if (ds1Name === undefined) throw new Error('归档中没有 .ds1')
|
|
const dt1Names = names.filter(name => name.toLowerCase().endsWith('.dt1'))
|
|
.sort((a, b) => a.localeCompare(b))
|
|
.slice(0, MAX_LIBRARIES)
|
|
if (dt1Names.length === 0) throw new Error('归档中没有 .dt1')
|
|
const paletteName = pickMember(names, 'pl2', ['act1', 'pal']) ?? pickMember(names, 'pal', ['town', 'act1'])
|
|
if (paletteName === undefined) throw new Error('归档中没有 .pl2 或 .pal')
|
|
const monstatsName = names.find(name => name.toLowerCase().endsWith('monstats.txt'))
|
|
state.source = `mpq:${label}`
|
|
return {
|
|
ds1: await archive.readByName(ds1Name),
|
|
libraries: await Promise.all(dt1Names.map(async name => archive.readByName(name))),
|
|
palette: await archive.readByName(paletteName),
|
|
paletteName,
|
|
actor: null,
|
|
monstats: monstatsName === undefined ? null : await archive.readByName(monstatsName),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Decode the palette member, whichever of the two formats it is.
|
|
*
|
|
* @param name - the member name.
|
|
* @param bytes - its bytes.
|
|
* @returns the palette.
|
|
*/
|
|
function decodePaletteMember(name: string, bytes: Uint8Array): Palette {
|
|
if (!name.toLowerCase().endsWith('.pl2')) return decodePal(bytes)
|
|
// A PL2 is a base palette plus a large stack of transform tables; the sprites
|
|
// only need the base, and saying so here keeps the transform tables out of the
|
|
// atlas build.
|
|
const pl2 = decodePl2(bytes)
|
|
return { rgb: pl2.rgb, size: 256 }
|
|
}
|
|
|
|
/**
|
|
* Build and start the scene.
|
|
*
|
|
* @param sources - the decoded-input bytes.
|
|
* @param status - the status line.
|
|
* @param netLine - the network line.
|
|
* @param peersLine - the player list line.
|
|
* @param wsUrl - the relay URL, when playing with someone.
|
|
* @param peer - this page's peer index.
|
|
* @param peers - how many peers the game expects, this page included.
|
|
*/
|
|
function startScene(
|
|
sources: NetSources,
|
|
status: HTMLElement,
|
|
netLine: HTMLElement,
|
|
peersLine: HTMLElement,
|
|
wsUrl: string | null,
|
|
peer: number,
|
|
peers: number,
|
|
): void {
|
|
const canvas = document.querySelector<HTMLCanvasElement>('#view')!
|
|
const level = decodeDs1(sources.ds1)
|
|
const libraries = sources.libraries.map(library => decodeDt1(library))
|
|
const palette = decodePaletteMember(sources.paletteName, sources.palette)
|
|
const first = libraries[0]!
|
|
const merged = libraries.length === 1 ? first : {
|
|
versionMajor: first.versionMajor,
|
|
versionMinor: first.versionMinor,
|
|
tiles: libraries.flatMap(library => library.tiles),
|
|
// Libraries merged for a fixture map carry no structural warnings.
|
|
warnings: [],
|
|
}
|
|
const scene: MapScene = buildMapScene(level, merged)
|
|
const tileWidth = Math.max(...merged.tiles.map(tile => tile.width), 1)
|
|
const tileHeight = Math.max(...merged.tiles.map(tile => Math.abs(tile.height)), 1)
|
|
|
|
running?.stop()
|
|
input?.detach()
|
|
activeRenderer?.dispose()
|
|
const renderer = new SpriteRenderer(canvas, {
|
|
onContextLost: () => {
|
|
status.textContent = 'WebGL 上下文丢失,正在尝试恢复…'
|
|
},
|
|
onContextRestored: () => {
|
|
status.textContent = 'WebGL 上下文已恢复,正在重新加载…'
|
|
location.reload()
|
|
},
|
|
})
|
|
activeRenderer = renderer
|
|
const combined: SpriteSheet = sources.actor === null
|
|
? scene.sheet
|
|
: { groups: [...scene.sheet.groups, ...sources.actor.sheet.groups], width: null }
|
|
const atlas = buildAtlas(combined, palette)
|
|
renderer.setAtlas(atlas)
|
|
const frames = atlas.groups[0] ?? []
|
|
const actorGroups = sources.actor === null ? null : atlas.groups.slice(1)
|
|
state.actorSource = sources.actor?.name ?? 'marker'
|
|
|
|
const terrain = {
|
|
overlap: (x: number, y: number): number =>
|
|
blockedOverlap(scene, tileWidth, tileHeight, x, y, FEET_WIDTH, FEET_HEIGHT),
|
|
}
|
|
const options: CombatOptions = {
|
|
playerSpeed: WALK_SPEED,
|
|
playerReach: 52,
|
|
playerCooldownTicks: 12,
|
|
playerDamage: 8,
|
|
playerManaPerAttack: 0,
|
|
respawnTicks: 60,
|
|
}
|
|
const spawn = findFreeSpawn(scene, tileWidth, tileHeight, FEET_WIDTH, FEET_HEIGHT)
|
|
|
|
// The world is built identically on every page: the players in peer order, then
|
|
// the same monsters from the same seed. Nothing here depends on which page is
|
|
// looking at it, because the simulation must not either.
|
|
const world: CombatWorld = createWorld(spawn.x - (peers - 1) * 18, spawn.y)
|
|
for (let index = 1; index < peers; index += 1) {
|
|
addPlayer(world, spawn.x - (peers - 1) * 18 + index * 36, spawn.y)
|
|
}
|
|
const stats = sources.monstats === null
|
|
? DEMO_MONSTERS
|
|
: monsterStatsFromTable(parseTable(new TextDecoder().decode(sources.monstats)))
|
|
state.monstersSpawned = spawnMonsters(world, stats, MONSTER_COUNT, spawn, 240, terrain, NET_SEED)
|
|
state.localPeer = peer
|
|
state.peers = peers
|
|
state.peersExpected = peers - 1
|
|
state.multiplayer = wsUrl !== null
|
|
|
|
// One animator per body: the two fighters are independent actors, and the peer's
|
|
// avatar turns and steps from its own movement, not from this page's keys.
|
|
const animators = world.players.map(() => {
|
|
const animator = new ActorAnimator<AtlasFrame>()
|
|
if (actorGroups !== null && actorGroups.length > 0) {
|
|
animator.add({ name: 'walk', directions: actorGroups, ticksPerFrame: 2, loop: true })
|
|
animator.add({ name: 'stand', directions: actorGroups.map(group => group.slice(0, 1)), ticksPerFrame: 8, loop: false })
|
|
animator.play('stand', 0)
|
|
}
|
|
return animator
|
|
})
|
|
const previous = world.players.map(player => ({ x: player.x, y: player.y }))
|
|
const facings = world.players.map(() => 0)
|
|
|
|
/**
|
|
* The simulation the lockstep session drives: one tick, both players, then the
|
|
* monsters — exactly once per tick, which is why it is one call and not one per
|
|
* player.
|
|
*/
|
|
const { simulation } = createNetSimulation(world, options, terrain, DEMO_EXPERIENCE, Math.max(peers, 1), NET_SEED)
|
|
|
|
let session: NetplaySession | null = null
|
|
let socketView: WebSocket | null = null
|
|
if (wsUrl !== null) {
|
|
const socket = new WebSocket(wsUrl)
|
|
socketView = socket
|
|
// Counted here as well as inside the transport: "the socket never delivered a
|
|
// frame" and "the transport dropped every frame" are different bugs, and the
|
|
// only way to tell them apart from outside the page is to count both.
|
|
socket.addEventListener('open', () => { state.socketOpened = true })
|
|
socket.addEventListener('close', () => { state.socketClosed = true })
|
|
socket.addEventListener('message', () => { state.socketFrames += 1 })
|
|
session = new NetplaySession(
|
|
{ peer, peers, seed: NET_SEED, inputDelayTicks: INPUT_DELAY_TICKS, hashInterval: HASH_INTERVAL },
|
|
simulation,
|
|
socketTransport(socket),
|
|
)
|
|
session.start()
|
|
state.wsUrl = wsUrl
|
|
socket.addEventListener('error', () => { state.error = 'socket error' })
|
|
}
|
|
|
|
input = new KeyboardInput()
|
|
input.attach()
|
|
|
|
const loop = new GameLoop({
|
|
tickRate: 25,
|
|
onTick: () => {
|
|
const movement = input?.movement() ?? { x: 0, y: 0 }
|
|
const attacking = input?.attacking ?? false
|
|
for (let index = 0; index < world.players.length; index += 1) {
|
|
const player = world.players[index]!
|
|
previous[index] = { x: player.x, y: player.y }
|
|
}
|
|
if (session === null) {
|
|
// Solo: the same simulation, driven by this page's keys alone. Running the
|
|
// identical code path is what makes solo mode a real test of the networked
|
|
// one rather than a second implementation of it.
|
|
simulation.advance([{ tick: world.tick, movement, attack: attacking, pickup: false, talk: false, skill: 0 }])
|
|
} else {
|
|
session.setIntent({ movement, attack: attacking })
|
|
session.pump()
|
|
}
|
|
for (let index = 0; index < world.players.length; index += 1) {
|
|
const player = world.players[index]!
|
|
const before = previous[index]!
|
|
const facing = directionOf({ x: player.x - before.x, y: player.y - before.y })
|
|
if (facing !== null) facings[index] = facing
|
|
const moved = player.x !== before.x || player.y !== before.y
|
|
animators[index]!.play(moved ? 'walk' : 'stand', facings[index]!)
|
|
animators[index]!.tick(facings[index]!)
|
|
}
|
|
updateState(world, session, loop)
|
|
if (socketView !== null) {
|
|
state.socketInfo = `${String(socketView.readyState)}|${socketView.binaryType}|${socketView.extensions}|${socketView.protocol}|${String(socketView.bufferedAmount)}`
|
|
}
|
|
},
|
|
onRender: () => {
|
|
const local = world.players[Math.min(peer, world.players.length - 1)]!
|
|
renderer.begin({ x: local.x, y: local.y, zoom: 2 }, [0.04, 0.03, 0.03])
|
|
for (const draw of scene.floors) {
|
|
const frame = frames[draw.tileIndex]
|
|
if (frame !== undefined) renderer.draw(frame, draw.x, draw.y)
|
|
}
|
|
// The peer's fighter is tinted so two identical characters stay tellable
|
|
// apart on one screen.
|
|
for (let index = 0; index < world.players.length; index += 1) {
|
|
const player = world.players[index]!
|
|
const frame = animators[index]!.frame()
|
|
if (frame === undefined) continue
|
|
const tint: [number, number, number, number] = index === peer
|
|
? [1, 1, 1, 1]
|
|
: [0.72, 0.86, 1, 1]
|
|
// Anchored at the feet, like every other actor in the scene, so the two
|
|
// fighters stand on the floor instead of floating above it.
|
|
renderer.draw(frame, player.x - frame.width / 2, player.y - frame.height, { tint })
|
|
}
|
|
for (const monster of world.monsters) {
|
|
const group = actorGroups?.[monster.facing]
|
|
if (group === undefined || group.length === 0) continue
|
|
const moving = monster.state === 'chase' || monster.state === 'attack'
|
|
const frame = group[moving ? (world.tick + monster.index * 3) % group.length : 0]
|
|
if (frame === undefined) continue
|
|
renderer.draw(frame, monster.x - frame.width / 2, monster.y - frame.height, {
|
|
tint: monster.state === 'dead'
|
|
? [0.42, 0.36, 0.34, 1]
|
|
: monster.hitFlash > 0 ? [1, 0.55, 0.55, 1] : [0.85, 0.8, 0.75, 1],
|
|
})
|
|
}
|
|
// The batcher only reaches the screen when it is flushed: without this the
|
|
// canvas shows nothing but its clear colour, which looks exactly like a
|
|
// scene that never loaded.
|
|
renderer.flush()
|
|
state.frame += 1
|
|
state.glError = renderer.gl.getError()
|
|
},
|
|
})
|
|
running = loop
|
|
loop.start()
|
|
status.textContent = `地图已就绪:${String(scene.floors.length)} 块地砖,${String(world.players.length)} 名玩家,${String(world.monsters.length)} 只怪物。`
|
|
netLine.innerHTML = wsUrl === null
|
|
? '单机模式(加 <code>?ws=ws://…&peer=0</code> 可联机)'
|
|
: `联机中 · 本页 peer <b>${String(peer)}</b> · 共 <b>${String(peers)}</b> 人 · 只传按键,世界本地模拟`
|
|
peersLine.textContent = wsUrl === null
|
|
? ''
|
|
: `已听到 ${String(world.players.length - 1)} 位队友的名字才算齐;齐了才会开始跑 tick——`
|
|
+ '少一人就等,因为「抢先开跑」会让先发出的输入掉进虚空,两边永久互等。'
|
|
}
|
|
|
|
/** Fallback experience thresholds: the co-op sandbox is not about levelling. */
|
|
const DEMO_EXPERIENCE: readonly number[] = [0, 0, 250, 700, 1500, 3000]
|
|
|
|
/**
|
|
* Digest the world.
|
|
*
|
|
* Covers the player list in peer order, never the view's own player, so two pages
|
|
* that disagree only about which fighter is "local" do not look like a desync.
|
|
*
|
|
* @param world - the world.
|
|
* @returns the digest string.
|
|
*/
|
|
|
|
/**
|
|
* Refresh the exposed state and the network HUD.
|
|
*
|
|
* @param world - the world.
|
|
* @param session - the networked session, or null in solo play.
|
|
* @param loop - the loop, for the tick rate.
|
|
*/
|
|
function updateState(world: CombatWorld, session: NetplaySession | null, loop: GameLoop): void {
|
|
state.tick = loop.tick
|
|
state.worldTick = world.tick
|
|
state.tickRate = loop.tickRate
|
|
state.kills = world.kills
|
|
state.monsters = world.monsters.filter(monster => monster.state !== 'dead').length
|
|
state.players = world.players.map(player => ({
|
|
x: player.x, y: player.y, hp: player.hp, maxHp: player.maxHp,
|
|
xp: player.xp, level: player.level, alive: player.alive,
|
|
}))
|
|
if (session === null) {
|
|
state.connected = false
|
|
return
|
|
}
|
|
const stats = session.stats
|
|
state.connected = stats.connected
|
|
state.handshaked = stats.handshaked
|
|
state.timedOut = stats.timedOut
|
|
state.transportOpen = stats.connected
|
|
state.waiting = stats.waiting
|
|
state.malformed = stats.malformed
|
|
state.lockstepTick = session.lockstep.tick
|
|
state.stepped = stats.stepped
|
|
state.helloSent = stats.helloSent
|
|
state.acknowledged = stats.acknowledged
|
|
state.peersHeard = stats.peersHeard
|
|
state.peersExpected = stats.peersExpected
|
|
state.seedMismatch = session.seedMismatch
|
|
state.hashesCompared = stats.hashesCompared
|
|
state.hashesAgreed = stats.hashesAgreed
|
|
state.hashesIgnored = stats.hashesIgnored
|
|
const report = session.desyncReport
|
|
state.desyncTick = report?.tick ?? null
|
|
state.desyncLocal = report?.local ?? null
|
|
state.desyncRemote = report?.remote ?? null
|
|
}
|
|
|
|
/** Wire the page: pick the map source, then start the scene. */
|
|
function boot(): void {
|
|
const status = document.querySelector<HTMLElement>('#status')!
|
|
const netLine = document.querySelector<HTMLElement>('#net')!
|
|
const peersLine = document.querySelector<HTMLElement>('#peers')!
|
|
const params = new URLSearchParams(location.search)
|
|
const dir = params.get('data')
|
|
const mpq = params.get('mpq')
|
|
const ws = params.get('ws')
|
|
const peersParam = Number(params.get('peers') ?? '2')
|
|
// Two to four: one page per peer, and the relay has no opinion about how many
|
|
// it forwards between.
|
|
const peers = Number.isFinite(peersParam) ? Math.min(4, Math.max(2, Math.round(peersParam))) : 2
|
|
const peerParam = Number(params.get('peer') ?? '0')
|
|
const peer = Number.isFinite(peerParam) ? Math.min(peers - 1, Math.max(0, Math.round(peerParam))) : 0
|
|
|
|
const start = (sources: NetSources): void => {
|
|
startScene(sources, status, netLine, peersLine, ws, peer, peers)
|
|
}
|
|
|
|
void (async () => {
|
|
try {
|
|
if (mpq !== null) {
|
|
status.textContent = `正在读取 ${mpq} …`
|
|
const response = await fetch(mpq)
|
|
if (!response.ok) throw new Error(`HTTP ${String(response.status)}`)
|
|
start(await loadArchiveSources(await response.blob(), mpq.split('/').pop() ?? 'archive.mpq'))
|
|
return
|
|
}
|
|
const fixtureDir = dir ?? 'samples/fixtures'
|
|
state.source = `fixture:${fixtureDir}`
|
|
start(await loadFixtureSources(fixtureDir))
|
|
} catch (error) {
|
|
state.error = error instanceof Error ? error.message : String(error)
|
|
status.textContent = `加载失败:${state.error}`
|
|
}
|
|
})()
|
|
}
|
|
|
|
if (typeof window !== 'undefined') {
|
|
window.addEventListener('beforeunload', () => {
|
|
running?.stop()
|
|
input?.detach()
|
|
activeRenderer?.dispose()
|
|
activeRenderer = null
|
|
})
|
|
}
|
|
|
|
/** Whether the page is running inside a document (avoids running under Node). */
|
|
if (typeof document !== 'undefined') boot()
|
|
|
|
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 }))
|
|
for (const drop of droppedItems) {
|
|
const dropPos = findIsometricDropPosition(
|
|
event.x,
|
|
event.y,
|
|
burstOccupied,
|
|
mapTerrain,
|
|
)
|
|
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 }
|
|
}
|