diablo2-web/src/client/main.ts

891 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Online Play Entry Point (`src/client/main.ts` for `play.html`, Milestone M5 — F16, F17, F18, F20).
*
* Integrates:
* - Persistent 32px Top Toolbar (`Toolbar` + `SettingsStore`)
* - Battle.net Pre-Game & Live Lobby (`FrontendRenderer` + `BnetFlowState` + `OnlineSession`)
* - In-Game Isometric World + Automap + Lighting (`ClientWorldSceneSource`, `AutomapView`)
* - Authentic D2 HUD Overlay (`HudManager` + `HudModel` + `CommandMapper` across 800×600 & 1024×768)
* - Dockable Packet Inspector & Deterministic `.d2cap` Capture Replay (`PacketInspector`)
* - Graceful disconnect & error recovery banner (`#play-disconnect-banner`)
*
* Strict import boundary: never imports `src/netproto/{bncs,mcp,d2gs,crypto}/**`.
*/
import {
applyOnlineCharactersToFlowState,
createInitialFlowState,
stepFlowTick,
type BnetFlowState,
} from './frontend/flow-state.ts'
import { FrontendRenderer } from './frontend/frontend-renderer.ts'
import { LightGrid } from '../common/drlg/light-grid.ts'
import type { LoadedMonsterArt } from './render/monster-art.ts'
import { setLang, type GameLang } from './i18n/lang.ts'
import type { CharClassId } from '../netproto/index.ts'
import { SpriteRenderer, type AtlasHandle } from './render/renderer.ts'
import {
DEFAULT_BASES,
loadCharacterArt,
loadMonsterArtMap,
type LoadedCharacter,
} from './scene/entity-art-loader.ts'
import { loadItemsAtlas } from './scene/ground-item-renderer.ts'
import {
loadMissileArtMap,
loadOverlayArtMap,
type LoadedMissileArt,
type LoadedOverlayArt,
} from './scene/missile-overlay-renderer.ts'
import {
TileAtlasGpuCache,
renderSceneSourceFrame,
} from './scene/world-renderer.ts'
import { HudManager } from './ui/hud-manager.ts'
import { Minimap } from './ui/minimap.ts'
import { CommandMapper } from './input/command-mapper.ts'
import { PacketInspector } from './inspector/packet-inspector.ts'
import { MapService } from './map/map-service.ts'
import { OnlineSession } from './session/online-session.ts'
import { getGlobalSettingsStore, type SettingsStore } from './settings/settings-store.ts'
import { Toolbar } from './toolbar/toolbar.ts'
import { HudModel } from './ui-model/hud-model.ts'
import type { SceneFrameSnapshot } from './view/scene-source.ts'
import {
applyViewportProfileToDom,
getViewportProfile,
type ViewportProfile,
} from './view/viewport-profile.ts'
const PRELOAD_HERO_TOKENS = ['so', 'am', 'ne', 'pa', 'ba', 'dz', 'ai'] as const
const PRELOAD_MONSTER_IDS = [
'fallen1',
'FallenShaman1',
'quillrat1',
'zombie1',
'skeleton1',
'bighead1',
'foulcrow1',
'corruptrogue1',
'goatman1',
'brute1',
] as const
export interface PlayAppController {
readonly ready: boolean
readonly settings: SettingsStore
readonly settingsStore: SettingsStore
readonly session: OnlineSession
readonly toolbar: Toolbar
readonly inspector: PacketInspector
readonly hudManager: HudManager
readonly hudModel: HudModel
readonly commandMapper: CommandMapper
readonly flowState: BnetFlowState
readonly frontendRenderer: FrontendRenderer
readonly renderer: SpriteRenderer
readonly tileGpuCache: TileAtlasGpuCache
readonly lightGrid: LightGrid
readonly minimap: Minimap
readonly assetsReadyPromise: Promise<void>
readonly getLastRenderSnapshot: () => SceneFrameSnapshot | null
readonly isInGameView: () => boolean
readonly returnToLobby: () => void
readonly sendChat: (message: string) => void
readonly replayD2Cap: (jsonlText: string) => number
readonly simulateDisconnect: (reason?: string) => Promise<void>
readonly renderFrame: (nowMs?: number) => void
}
declare global {
interface Window {
__d2SkipAutoBoot?: boolean
__d2PlayApp?: PlayAppController
__d2Play?: PlayAppController
}
}
function drawEscapeMenuOverlay(
ctx: CanvasRenderingContext2D,
width: number,
height: number,
): void {
ctx.save()
ctx.fillStyle = 'rgba(0, 0, 0, 0.52)'
ctx.fillRect(0, 0, width, height)
const boxW = 300
const boxH = 170
const boxX = Math.round((width - boxW) / 2)
const boxY = Math.round((height - boxH) / 2) - 24
ctx.fillStyle = 'rgba(14, 11, 8, 0.94)'
ctx.strokeStyle = '#c8a858'
ctx.lineWidth = 2
ctx.fillRect(boxX, boxY, boxW, boxH)
ctx.strokeRect(boxX, boxY, boxW, boxH)
ctx.textAlign = 'center'
ctx.font = 'bold 20px "D2Exocet", serif'
ctx.fillStyle = '#d8b868'
ctx.fillText('OPTIONS', width / 2, boxY + 46)
ctx.fillStyle = '#f2e6c8'
ctx.fillText('SAVE AND EXIT GAME', width / 2, boxY + 92)
ctx.fillStyle = '#d8b868'
ctx.fillText('RETURN TO GAME', width / 2, boxY + 138)
ctx.restore()
}
async function bootPlayApp(): Promise<PlayAppController> {
window.__d2SkipAutoBoot = true
const toolbarContainer = document.querySelector<HTMLElement>('#d2-toolbar-container')
const inspectorContainer = document.querySelector<HTMLElement>('#d2-inspector-container')
const stageEl = document.querySelector<HTMLElement>('#play-stage')
const bnetShell = document.querySelector<HTMLElement>('#bnet-shell')
const bnetCanvas = document.querySelector<HTMLCanvasElement>('#bnet-canvas')
const viewCanvas = document.querySelector<HTMLCanvasElement>('#view')
const hudCanvas = document.querySelector<HTMLCanvasElement>('#d2-hud-canvas')
const disconnectBanner = document.querySelector<HTMLElement>('#play-disconnect-banner')
if (!stageEl || !bnetShell || !bnetCanvas || !viewCanvas || !hudCanvas) {
throw new Error('Missing required DOM elements in play.html')
}
const settingsStore = getGlobalSettingsStore()
const initialSettings = settingsStore.get()
setLang(initialSettings.lang)
const minimap = new Minimap()
const renderer = new SpriteRenderer(viewCanvas)
const lightGrid = new LightGrid()
const tileGpuCache = new TileAtlasGpuCache(renderer)
const characterArtByToken = new Map<string, LoadedCharacter>()
const monsterArtMap = new Map<string, LoadedMonsterArt>()
let missileArtMap = new Map<string, LoadedMissileArt>()
let overlayArtMap = new Map<string, LoadedOverlayArt>()
let itemsAtlasHandle: AtlasHandle | undefined
let lastRenderSnapshot: SceneFrameSnapshot | null = null
let sessionRef: OnlineSession | null = null
const hudManager = new HudManager(hudCanvas, {
onRenderAutomapOverlay: (ctx, font, logicalWidth, logicalHeight) => {
minimap.drawHudOverlay(ctx, font, logicalWidth, logicalHeight)
},
onToggleAutomap: () => {
sessionRef?.hudModel.toggleAutomap()
},
onToggleAutomapMode: () => {
minimap.toggleMode()
},
onCycleAutomapFade: () => {
minimap.cycleFade()
},
onToggleAutomapNames: () => {
minimap.toggleNames()
},
onResetAutomapPan: () => {
minimap.resetPan()
},
onPanAutomap: (dx, dy) => minimap.pan(dx, dy),
})
await hudManager.load('/ui')
let mapService: MapService | null = null
try {
mapService = await MapService.fromPackedUrl('/act-packs/tiles')
} catch {
mapService = null
}
// Kick off asynchronous GPU atlas preloading for WebGL2 world rendering
tileGpuCache.ensureActPalette(1)
const dummyPalette = { rgb: new Uint8Array(768), size: 256 as const }
const assetsReadyPromise = (async (): Promise<void> => {
await Promise.all([
minimap.loadAtlases(renderer, 'samples/d2-packs').catch(() => {}),
loadItemsAtlas(renderer, 'samples/d2-packs')
.then((h) => {
itemsAtlasHandle = h
})
.catch(() => {}),
Promise.all(
PRELOAD_HERO_TOKENS.map(async (token) => {
try {
const art = await loadCharacterArt(
DEFAULT_BASES,
null,
renderer,
'samples/d2-packs',
1,
token,
)
if (art) {
characterArtByToken.set(token.toLowerCase(), art)
characterArtByToken.set(token.toUpperCase(), art)
}
} catch {
// ignore individual token failure
}
}),
),
loadMonsterArtMap(
DEFAULT_BASES,
PRELOAD_MONSTER_IDS,
dummyPalette,
renderer,
'samples/d2-packs',
1,
)
.then((res) => {
for (const [k, art] of res.map.entries()) {
monsterArtMap.set(k.toLowerCase(), art)
monsterArtMap.set(`token:${art.token.toLowerCase()}`, art)
}
})
.catch(() => {}),
loadMissileArtMap(renderer, 'samples/d2-packs')
.then((map) => {
missileArtMap = map
})
.catch(() => {}),
loadOverlayArtMap(renderer, 'samples/d2-packs')
.then((map) => {
overlayArtMap = map
})
.catch(() => {}),
])
})()
// In-game chat input bar (toggled by Enter key in-game)
const chatInputEl = document.createElement('input')
chatInputEl.id = 'd2-ingame-chat-input'
chatInputEl.type = 'text'
chatInputEl.maxLength = 120
chatInputEl.placeholder = 'Say...'
chatInputEl.hidden = true
chatInputEl.style.cssText =
'position:absolute;left:24px;bottom:64px;width:360px;z-index:25;background:rgba(10,8,6,0.92);border:1px solid #c8a858;color:#f2e6c8;padding:4px 8px;font:13px "D2Formal436",serif;outline:none;'
stageEl.appendChild(chatInputEl)
const toggleInGameChatInput = (): void => {
if (!chatInputEl.hidden) {
const msg = chatInputEl.value.trim()
chatInputEl.value = ''
chatInputEl.hidden = true
chatInputEl.blur()
if (msg && sessionRef) {
sessionRef.dispatchCommand({ type: 'Chat', text: msg })
}
} else {
chatInputEl.hidden = false
chatInputEl.value = ''
chatInputEl.focus()
}
}
chatInputEl.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault()
e.stopPropagation()
toggleInGameChatInput()
} else if (e.key === 'Escape') {
e.preventDefault()
e.stopPropagation()
chatInputEl.value = ''
chatInputEl.hidden = true
chatInputEl.blur()
}
})
const session = new OnlineSession({
settingsStore,
hudManager,
mapService,
defaultHost: initialSettings.serverHost,
onToggleChatInput: toggleInGameChatInput,
onEscapeMenu: () => {
session.hudModel.togglePanel('escapeMenu')
},
})
sessionRef = session
// Mount PacketInspector inside #d2-inspector-container if available
const inspector = session.packetInspector
if (inspectorContainer && inspectorContainer.firstChild === null) {
const existingInspectorEl = document.querySelector('#d2-packet-inspector')
if (existingInspectorEl && existingInspectorEl.parentElement !== inspectorContainer) {
inspectorContainer.appendChild(existingInspectorEl)
}
}
const toolbar = new Toolbar({
store: settingsStore,
container: toolbarContainer,
callbacks: {
onLangChange: (lang: GameLang) => {
setLang(lang)
session.hudManager.setLang(lang)
},
onViewportChange: (profile: ViewportProfile) => {
applyViewportProfileToDom(profile, {
stageEl,
glCanvas: viewCanvas,
hudCanvas,
})
session.hudManager.resize(profile.width, profile.height)
session.camera.resize(profile)
},
onAutomapRevealChange: (mode) => {
session.automapView.setMode(mode)
},
onInspectorToggle: (open) => {
inspector.setOpen(open)
},
onServerHostReconnect: (newHost) => {
void session.disconnect(`Reconnecting to ${newHost}`)
},
},
})
// Apply initial viewport dimensions (800×600 or 1024×768)
const initialProfile = getViewportProfile(initialSettings.viewport)
applyViewportProfileToDom(initialProfile, {
stageEl,
glCanvas: viewCanvas,
hudCanvas,
})
session.hudManager.resize(initialProfile.width, initialProfile.height)
session.camera.resize(initialProfile)
settingsStore.subscribe((next, prev) => {
if (next.viewport !== prev.viewport) {
const profile = getViewportProfile(next.viewport)
applyViewportProfileToDom(profile, {
stageEl,
glCanvas: viewCanvas,
hudCanvas,
})
session.hudManager.resize(profile.width, profile.height)
session.camera.resize(profile)
}
if (next.lang !== prev.lang) {
setLang(next.lang)
session.hudManager.setLang(next.lang)
}
if (next.automapReveal !== prev.automapReveal) {
session.automapView.setMode(next.automapReveal)
}
if (next.inspectorOpen !== prev.inspectorOpen) {
inspector.setOpen(next.inspectorOpen)
}
})
const flowState = createInitialFlowState()
const frontendRenderer = new FrontendRenderer(bnetCanvas, flowState)
let inGameView = false
const syncVisibility = (): void => {
bnetShell.style.display = inGameView ? 'none' : 'flex'
if (disconnectBanner) {
if (session.disconnectReason) {
disconnectBanner.textContent = session.disconnectReason
disconnectBanner.hidden = false
} else {
disconnectBanner.hidden = true
}
}
}
const returnToLobby = (): void => {
inGameView = false
flowState.stage = 'bnet_lobby'
toolbar.setConnectionState({ phase: 'mcp', rttMs: session.pingMs })
syncVisibility()
}
frontendRenderer.setAsyncHandlers({
onConnect: async (st) => {
st.statusText = 'CONNECTING TO BATTLE.NET...'
st.lastError = null
st.loginError = null
frontendRenderer.render(performance.now())
},
onLogin: async (st, createNewAccount) => {
const username = st.accountName.trim()
const password = st.accountPassword
if (!username) {
st.loginError = 'PLEASE ENTER AN ACCOUNT NAME.'
st.lastError = st.loginError
frontendRenderer.render(performance.now())
return
}
st.statusText = createNewAccount
? 'CREATING ACCOUNT & LOGGING IN...'
: 'AUTHENTICATING WITH BATTLE.NET & MCP...'
st.loginError = null
st.lastError = null
frontendRenderer.render(performance.now())
const targetHost = settingsStore.get().serverHost || st.gateway || 'www.laiseek.xyz'
try {
const res = createNewAccount
? await session.createAccountAndLogin({ username, password, host: targetHost })
: await session.login({ username, password, host: targetHost })
if (res.realms[0]) {
st.gateway = res.realms[0].title
}
applyOnlineCharactersToFlowState(st, res.characters)
st.statusText = ''
} catch (err) {
st.statusText = ''
st.loginError = err instanceof Error ? err.message : String(err)
st.lastError = st.loginError
}
frontendRenderer.render(performance.now())
},
onCreateCharacter: async (st) => {
const name = st.charName.trim()
if (!name || name.length < 2 || !st.selectedClass) {
st.charCreateError = 'CHARACTER NAME MUST BE 2-15 LETTERS.'
st.lastError = st.charCreateError
frontendRenderer.render(performance.now())
return
}
st.statusText = 'CREATING CHARACTER ON REALM...'
st.charCreateError = null
st.lastError = null
frontendRenderer.render(performance.now())
try {
const classMap: Record<string, CharClassId> = {
am: 0,
so: 1,
ne: 2,
pa: 3,
ba: 4,
dz: 5,
as: 6,
}
const chars = await session.createCharacter({
name,
classId: classMap[st.selectedClass] ?? 1,
hardcore: st.isHardcore,
expansion: st.isExpansion,
ladder: st.isLadder,
})
applyOnlineCharactersToFlowState(st, chars)
st.statusText = ''
} catch (err) {
st.statusText = ''
st.charCreateError = err instanceof Error ? err.message : String(err)
st.lastError = st.charCreateError
}
frontendRenderer.render(performance.now())
},
onDeleteCharacter: async (st, charName) => {
st.statusText = 'DELETING CHARACTER...'
st.lastError = null
frontendRenderer.render(performance.now())
try {
const chars = await session.deleteCharacter(charName)
applyOnlineCharactersToFlowState(st, chars)
st.statusText = ''
} catch (err) {
st.statusText = ''
st.lastError = err instanceof Error ? err.message : String(err)
}
frontendRenderer.render(performance.now())
},
onConvertCharacter: async (st, charName) => {
st.statusText = 'CONVERTING CHARACTER TO EXPANSION...'
st.lastError = null
frontendRenderer.render(performance.now())
try {
const chars = await session.convertCharacterToExpansion(charName)
applyOnlineCharactersToFlowState(st, chars)
st.statusText = ''
} catch (err) {
st.statusText = ''
st.lastError = err instanceof Error ? err.message : String(err)
}
frontendRenderer.render(performance.now())
},
onSelectCharacter: async (st) => {
const selected = st.roster[st.selectedSlotIndex]
if (!selected) return
st.statusText = 'LOGGING CHARACTER INTO REALM...'
st.lastError = null
frontendRenderer.render(performance.now())
try {
await session.selectCharacter(selected.name)
st.statusText = ''
st.stage = 'bnet_lobby'
st.lobbySubPanel = 'create'
st.activeInput = 'gameName'
} catch (err) {
st.statusText = ''
st.lastError = err instanceof Error ? err.message : String(err)
}
frontendRenderer.render(performance.now())
},
onListGames: async (st) => {
st.lobbySubPanel = 'join'
st.activeInput = 'gameName'
st.statusText = 'QUERYING ACTIVE GAMES...'
st.lastError = null
frontendRenderer.render(performance.now())
try {
const games = await session.listGames('')
st.realmGames = games.map((g, idx) => ({
index: g.index ?? idx,
name: g.name,
description: g.description,
playerCount: g.playerCount,
flags: g.status,
}))
if (st.realmGames[0] && !st.selectedGameName) {
st.selectedGameName = st.realmGames[0].name
st.gameName = st.realmGames[0].name
}
st.statusText = ''
} catch (err) {
st.statusText = ''
st.lastError = err instanceof Error ? err.message : String(err)
}
frontendRenderer.render(performance.now())
},
onCreateGame: async (st) => {
const gameName = st.gameName.trim()
if (!gameName) {
st.createGameError = 'PLEASE ENTER A GAME NAME.'
st.lastError = st.createGameError
frontendRenderer.render(performance.now())
return
}
const diffNum: 0 | 1 | 2 =
st.difficulty === 'hell' ? 2 : st.difficulty === 'nightmare' ? 1 : 0
st.stage = 'door_loading'
st.stageTicks = 0
st.doorFrame = 0
st.createGameError = null
st.lastError = null
st.statusText = 'CREATING & JOINING GAME...'
frontendRenderer.render(performance.now())
try {
await session.enterGame({
mode: 'create',
spec: {
name: gameName,
password: st.gamePassword,
description: st.gameDescription,
difficulty: diffNum,
maxPlayers: Math.max(1, Math.min(8, Number.parseInt(st.maxPlayers, 10) || 8)),
},
})
st.statusText = ''
st.sceneReady = true
st.stage = 'in_game'
inGameView = true
syncVisibility()
} catch (err) {
st.statusText = ''
st.stage = 'bnet_lobby'
st.createGameError = err instanceof Error ? err.message : String(err)
st.lastError = st.createGameError
frontendRenderer.render(performance.now())
}
},
onJoinGame: async (st, gameName, gamePassword) => {
st.stage = 'door_loading'
st.stageTicks = 0
st.doorFrame = 0
st.createGameError = null
st.lastError = null
st.statusText = 'JOINING GAME SERVER...'
frontendRenderer.render(performance.now())
try {
await session.enterGame({
mode: 'join',
name: gameName,
password: gamePassword,
})
st.statusText = ''
st.sceneReady = true
st.stage = 'in_game'
inGameView = true
syncVisibility()
} catch (err) {
st.statusText = ''
st.stage = 'bnet_lobby'
st.createGameError = err instanceof Error ? err.message : String(err)
st.lastError = st.createGameError
frontendRenderer.render(performance.now())
}
},
onSendChat: async (st, text) => {
try {
await session.sendLobbyChat(text)
} catch (err) {
st.lastError = err instanceof Error ? err.message : String(err)
}
frontendRenderer.render(performance.now())
},
})
await frontendRenderer.load('/ui/frontend')
// Subscribe to OnlineSession state changes & disconnects
session.onStateChange((snap) => {
if (snap.phase === 'ingame' || snap.phase === 'replay') {
inGameView = true
flowState.sceneReady = true
flowState.stage = 'in_game'
toolbar.setConnectionState({ phase: 'd2gs', rttMs: snap.pingMs })
} else if (snap.phase === 'lobby') {
toolbar.setConnectionState({ phase: 'mcp', rttMs: snap.pingMs })
} else if (snap.phase === 'connecting') {
toolbar.setConnectionState({ phase: 'connecting', rttMs: null })
} else if (snap.phase === 'disconnected' || snap.phase === 'error') {
if (inGameView || flowState.stage === 'in_game' || flowState.stage === 'door_loading') {
inGameView = false
flowState.stage = 'bnet_lobby'
}
toolbar.setConnectionState({
phase: snap.disconnectReason ? 'error' : 'disconnected',
rttMs: null,
diagnosticKind: snap.disconnectReason ? 'custom' : 'none',
diagnosticMessage: snap.disconnectReason ?? undefined,
})
}
syncVisibility()
})
session.onDisconnect((reason) => {
if (inGameView || flowState.stage === 'in_game' || flowState.stage === 'door_loading') {
inGameView = false
flowState.stage = 'bnet_lobby'
}
toolbar.setConnectionState({
phase: 'error',
rttMs: null,
diagnosticKind: 'custom',
diagnosticMessage: reason,
})
syncVisibility()
})
// Bind mouse & keyboard input
const toStageCoords = (e: MouseEvent): { readonly x: number; readonly y: number } => {
const rect = viewCanvas.getBoundingClientRect()
const profile = getViewportProfile(settingsStore.get().viewport)
const scaleX = rect.width > 0 ? profile.width / rect.width : 1
const scaleY = rect.height > 0 ? profile.height / rect.height : 1
return {
x: (e.clientX - rect.left) * scaleX,
y: (e.clientY - rect.top) * scaleY,
}
}
stageEl.addEventListener('mousemove', (e) => {
if (!inGameView) {
const pt = frontendRenderer.eventToCanvasCoords(e)
frontendRenderer.handleMouseMove(pt.x, pt.y, performance.now())
return
}
const pt = toStageCoords(e)
session.commandMapper.handlePointerMove(pt.x, pt.y, performance.now())
})
stageEl.addEventListener('mousedown', (e) => {
if (!inGameView) {
if (e.button === 0) {
const pt = frontendRenderer.eventToCanvasCoords(e)
frontendRenderer.handleMouseDown(pt.x, pt.y, performance.now())
}
return
}
const pt = toStageCoords(e)
if (session.hudModel.getSnapshot().panels.escapeMenu && e.button === 0) {
const profile = getViewportProfile(settingsStore.get().viewport)
const boxW = 300
const boxH = 170
const boxX = Math.round((profile.width - boxW) / 2)
const boxY = Math.round((profile.height - boxH) / 2) - 24
if (pt.x >= boxX && pt.x <= boxX + boxW && pt.y >= boxY && pt.y <= boxY + boxH) {
if (pt.y >= boxY + 70 && pt.y <= boxY + 108) {
session.hudModel.togglePanel('escapeMenu')
void session.disconnect('Returned to Battle.net Lobby')
return
}
if (pt.y >= boxY + 115 && pt.y <= boxY + 155) {
session.hudModel.togglePanel('escapeMenu')
return
}
}
}
session.commandMapper.handlePointerDown(pt.x, pt.y, e.button, performance.now())
})
stageEl.addEventListener('mouseup', (e) => {
if (!inGameView) {
if (e.button === 0) {
const pt = frontendRenderer.eventToCanvasCoords(e)
frontendRenderer.handleMouseUp(pt.x, pt.y, performance.now())
}
return
}
session.commandMapper.handlePointerUp(e.button)
})
stageEl.addEventListener('contextmenu', (e) => {
e.preventDefault()
})
// Capture-phase keydown listener so pre-game text inputs and CommandMapper hotkeys
// do not double-trigger HudManager's bubble-phase listener.
window.addEventListener(
'keydown',
(e) => {
const activeEl = document.activeElement
if (
activeEl &&
(activeEl.tagName === 'INPUT' ||
activeEl.tagName === 'SELECT' ||
activeEl.tagName === 'TEXTAREA')
) {
return
}
if (!inGameView) {
e.stopImmediatePropagation()
frontendRenderer.handleKeyDown(e)
return
}
const handled = session.commandMapper.handleKeyDown(e, performance.now())
if (handled) {
e.stopImmediatePropagation()
}
},
true,
)
window.addEventListener(
'keyup',
(e) => {
if (inGameView) {
session.commandMapper.handleKeyUp(e)
}
},
true,
)
const hudCtx = hudCanvas.getContext('2d')
let lastFrameMs = performance.now()
const renderFrame = (nowMs = performance.now()): void => {
const dtMs = Math.max(0, Math.min(100, nowMs - lastFrameMs))
lastFrameMs = nowMs
if (!inGameView) {
stepFlowTick(flowState)
if (flowState.stage === 'in_game' && (session.phase === 'ingame' || session.phase === 'replay')) {
inGameView = true
syncVisibility()
} else {
frontendRenderer.render(nowMs)
return
}
}
session.tick(dtMs || 40, nowMs)
const currentSettings = settingsStore.get()
const profile = getViewportProfile(currentSettings.viewport)
if (viewCanvas.width !== profile.width || viewCanvas.height !== profile.height) {
viewCanvas.width = profile.width
viewCanvas.height = profile.height
}
lastRenderSnapshot = renderSceneSourceFrame({
source: session.sceneSource,
lightingPreset: currentSettings.lightingPreset,
lightingTargets: { lightGrid, renderer },
renderer,
tileGpuCache,
characterArtByToken,
monsterArtMap,
missileArtMap,
overlayArtMap,
itemsAtlasHandle,
minimap,
automapView: session.automapView,
hudManager: session.hudManager,
nowMs,
playerMoving: session.predictor.getSnapshot().moving,
})
if (hudCtx && session.hudModel.getSnapshot().panels.escapeMenu) {
drawEscapeMenuOverlay(hudCtx, profile.width, profile.height)
}
}
const loop = (now: number): void => {
renderFrame(now)
requestAnimationFrame(loop)
}
renderFrame(performance.now())
requestAnimationFrame(loop)
const controller: PlayAppController = {
ready: true,
settings: settingsStore,
settingsStore,
session,
toolbar,
inspector,
hudManager: session.hudManager,
hudModel: session.hudModel,
commandMapper: session.commandMapper,
flowState,
frontendRenderer,
renderer,
tileGpuCache,
lightGrid,
minimap,
assetsReadyPromise,
getLastRenderSnapshot: () => lastRenderSnapshot,
isInGameView: () => inGameView,
returnToLobby,
sendChat: (message: string): void => {
session.dispatchCommand({ type: 'Chat', text: message })
},
replayD2Cap: (jsonlText: string): number => {
inGameView = true
const count = session.loadD2CapReplay(jsonlText)
session.replayAllD2Cap()
syncVisibility()
renderFrame(performance.now())
return count
},
simulateDisconnect: async (reason = 'Lost connection to Battle.net / D2GS'): Promise<void> => {
await session.disconnect(reason)
syncVisibility()
renderFrame(performance.now())
},
renderFrame,
}
window.__d2PlayApp = controller
window.__d2Play = controller
return controller
}
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
bootPlayApp().catch((err) => {
;(window as unknown as { __d2BootError?: string }).__d2BootError =
err instanceof Error ? `${err.message}\n${err.stack ?? ''}` : String(err)
console.error('[play.html] Boot error:', err)
})
}