diablo2-web/src/client/main.ts

956 lines
31 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,
isDifficultyUnlocked,
returnToLoginAfterSessionLoss,
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,
EntityArtCache,
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,
reportMissingArt,
} from './scene/world-renderer.ts'
import {
BootProgressTracker,
type BootProgressSnapshot,
} from './ui/boot-progress.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, SessionLostError } from './session/online-session.ts'
import { getGlobalSettingsStore, type SettingsStore } from './settings/settings-store.ts'
import {
getAssetCacheStatus,
registerAssetCache,
} from './sw/register.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 bootProgress: BootProgressSnapshot
readonly bootProgressTracker: BootProgressTracker
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 entityArtCache: EntityArtCache
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
__d2BootProgress?: BootProgressSnapshot
__d2PlayApp?: PlayAppController
__d2Play?: PlayAppController
}
}
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 stageWrapperEl = document.querySelector<HTMLElement>('#play-stage-wrapper')
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 bootProgress = new BootProgressTracker({ stageEl }).install()
const settingsStore = getGlobalSettingsStore()
const initialSettings = settingsStore.get()
setLang(initialSettings.lang)
let sessionRef: OnlineSession | null = null
const syncStageViewportFit = (
profileOrId: ViewportProfile | typeof initialSettings.viewport = settingsStore.get().viewport,
): ViewportProfile => {
const profile = applyViewportProfileToDom(profileOrId, {
containerEl: stageWrapperEl ?? stageEl.parentElement,
stageEl,
glCanvas: viewCanvas,
hudCanvas,
})
sessionRef?.hudManager.resize(profile.width, profile.height)
sessionRef?.camera.resize(profile)
return profile
}
const toolbar = new Toolbar({
store: settingsStore,
container: toolbarContainer,
callbacks: {
onLangChange: (lang: GameLang) => {
setLang(lang)
sessionRef?.hudManager.setLang(lang)
},
onViewportChange: (profile: ViewportProfile) => {
syncStageViewportFit(profile)
},
onAutomapRevealChange: (mode) => {
sessionRef?.automapView.setMode(mode)
},
onInspectorToggle: (open) => {
sessionRef?.packetInspector.setOpen(open)
syncStageViewportFit()
},
onServerHostReconnect: (newHost) => {
void sessionRef?.disconnect(`Reconnecting to ${newHost}`)
},
},
})
const initialCacheStatus = await registerAssetCache()
toolbar.setAssetCacheUsageBytes(initialCacheStatus.usageBytes)
const minimap = new Minimap()
const renderer = new SpriteRenderer(viewCanvas)
const lightGrid = new LightGrid()
const tileGpuCache = new TileAtlasGpuCache(renderer)
const entityArtCache = new EntityArtCache(renderer, '/assets')
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
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),
onEscapeMenuAction: (action) => {
if (action === 'saveAndExit') {
if (!chatInputEl.hidden) {
chatInputEl.value = ''
chatInputEl.hidden = true
chatInputEl.blur()
}
void sessionRef?.leaveGame()
}
},
})
await hudManager.load('/ui')
bootProgress.setStageFloor(25, '游戏界面资源')
// Fail fast: without the packed act tiles there is no 1.13c map (automap, collision, picking).
let mapService: MapService
try {
mapService = await MapService.fromPackedUrl('/act-packs/tiles')
} catch (err) {
throw new Error(`MapService: failed to load /act-packs/tiles: ${err instanceof Error ? err.message : String(err)}`)
}
bootProgress.setStageFloor(45, '地图地形资源')
// 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((err) => {
reportMissingArt('automap', 'Automap.dc6', String(err))
}),
loadItemsAtlas(renderer, 'samples/d2-packs')
.then((h) => {
itemsAtlasHandle = h
})
.catch((err) => {
reportMissingArt('item', 'items-atlas.png', String(err))
}),
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)
} else {
reportMissingArt('player', token, `Character art not found for token=${token}`)
}
} catch (err) {
reportMissingArt('player', token, String(err))
}
}),
),
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((err) => {
reportMissingArt('monster', 'preload-monsters', String(err))
}),
entityArtCache
.loadIndex()
.then(async () => {
for (const npcId of [146, 147, 148, 150, 154, 155]) {
entityArtCache.getMonsterArt(npcId, 1)
}
for (const objId of [17, 26, 39, 119, 267]) {
entityArtCache.getObjectArt(objId, 1)
}
await entityArtCache.waitForInflight()
})
.catch((err) => {
reportMissingArt('entity-index', 'entity-index.json', String(err))
}),
loadMissileArtMap(renderer, 'samples/d2-packs')
.then((map) => {
missileArtMap = map
})
.catch((err) => {
reportMissingArt('missile', 'missiles-manifest.json', String(err))
}),
loadOverlayArtMap(renderer, 'samples/d2-packs')
.then((map) => {
overlayArtMap = map
})
.catch((err) => {
reportMissingArt('overlay', 'overlays-manifest.json', String(err))
}),
])
})()
// 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)
}
}
void assetsReadyPromise.then(async () => {
const updatedStatus = await getAssetCacheStatus()
toolbar.setAssetCacheUsageBytes(updatedStatus.usageBytes)
})
// Apply initial viewport dimensions (800×600 or 1024×768) stretched to fit browser
syncStageViewportFit(initialSettings.viewport)
window.addEventListener('resize', () => {
syncStageViewportFit()
})
if (typeof ResizeObserver !== 'undefined' && stageWrapperEl) {
const stageResizeObserver = new ResizeObserver(() => {
syncStageViewportFit()
})
stageResizeObserver.observe(stageWrapperEl)
}
settingsStore.subscribe((next, prev) => {
if (next.viewport !== prev.viewport) {
syncStageViewportFit(next.viewport)
}
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)
syncStageViewportFit(next.viewport)
}
})
const flowState = createInitialFlowState()
const frontendRenderer = new FrontendRenderer(bnetCanvas, flowState)
let inGameView = false
/** One login attempt at a time: concurrent attempts used to replace each other's live session. */
let loginInFlight = 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.sceneReady = false
flowState.stage = 'bnet_lobby'
flowState.lobbySubPanel = 'create'
flowState.createGameError = null
flowState.activeInput = 'gameName'
toolbar.setConnectionState({ phase: 'mcp', rttMs: session.pingMs })
syncVisibility()
frontendRenderer.render(performance.now())
}
frontendRenderer.setAsyncHandlers({
onConnect: async (st) => {
st.statusText = ''
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
}
if (loginInFlight) return
loginInFlight = true
st.statusText = ''
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)
} catch (err) {
// A lost session was already routed to the login screen by `onSessionLost`.
if (!(err instanceof SessionLostError)) {
st.loginError = err instanceof Error ? err.message : String(err)
st.lastError = st.loginError
}
} finally {
loginInFlight = false
}
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 = ''
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 classId = st.selectedClass === null ? undefined : classMap[st.selectedClass]
if (classId === undefined) throw new Error(`Unknown selected class ${String(st.selectedClass)}`)
const chars = await session.createCharacter({
name,
classId,
hardcore: st.isHardcore,
expansion: st.isExpansion,
ladder: st.isLadder,
})
applyOnlineCharactersToFlowState(st, chars)
} catch (err) {
if (err instanceof SessionLostError) return
st.charCreateError = err instanceof Error ? err.message : String(err)
st.lastError = st.charCreateError
}
frontendRenderer.render(performance.now())
},
onDeleteCharacter: async (st, charName) => {
st.statusText = ''
st.lastError = null
frontendRenderer.render(performance.now())
try {
const chars = await session.deleteCharacter(charName)
applyOnlineCharactersToFlowState(st, chars)
} catch (err) {
if (err instanceof SessionLostError) return
st.lastError = err instanceof Error ? err.message : String(err)
}
frontendRenderer.render(performance.now())
},
onConvertCharacter: async (st, charName) => {
st.statusText = ''
st.lastError = null
frontendRenderer.render(performance.now())
try {
const chars = await session.convertCharacterToExpansion(charName)
applyOnlineCharactersToFlowState(st, chars)
} catch (err) {
if (err instanceof SessionLostError) return
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 = ''
st.lastError = null
frontendRenderer.render(performance.now())
try {
await session.selectCharacter(selected.name)
st.stage = 'bnet_lobby'
st.lobbySubPanel = 'create'
st.createGameError = null
if (!isDifficultyUnlocked(selected, st.difficulty)) {
st.difficulty = 'normal'
}
st.activeInput = 'gameName'
} catch (err) {
if (err instanceof SessionLostError) return
st.lastError = err instanceof Error ? err.message : String(err)
}
frontendRenderer.render(performance.now())
},
onListGames: async (st) => {
st.lobbySubPanel = 'join'
st.activeInput = 'gameName'
st.statusText = ''
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
if (st.lobbySubPanel === 'join') {
st.gameName = st.realmGames[0].name
}
}
} catch (err) {
if (err instanceof SessionLostError) return
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 maxPlayers = Number.parseInt(st.maxPlayers.trim(), 10)
if (!Number.isFinite(maxPlayers) || maxPlayers < 1 || maxPlayers > 8) {
st.createGameError = 'MAX NUMBER OF PLAYERS MUST BE 1-8.'
st.lastError = st.createGameError
frontendRenderer.render(performance.now())
return
}
let levelDiff = 0xff
if (st.charDifferenceEnabled) {
const parsedDiff = Number.parseInt(st.charDifference.trim(), 10)
if (!Number.isFinite(parsedDiff) || parsedDiff < 1 || parsedDiff > 99) {
st.createGameError = 'CHARACTER DIFFERENCE MUST BE 1-99.'
st.lastError = st.createGameError
frontendRenderer.render(performance.now())
return
}
levelDiff = parsedDiff
}
const selectedChar = st.roster[st.selectedSlotIndex]
if (selectedChar && !isDifficultyUnlocked(selectedChar, st.difficulty)) {
st.difficulty = 'normal'
}
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 = ''
frontendRenderer.render(performance.now())
try {
await session.enterGame({
mode: 'create',
spec: {
name: gameName,
password: st.gamePassword,
description: st.gameDescription,
difficulty: diffNum,
maxPlayers,
levelDiff,
},
})
st.sceneReady = true
st.stage = 'in_game'
inGameView = true
syncVisibility()
} catch (err) {
if (err instanceof SessionLostError) return
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 = ''
frontendRenderer.render(performance.now())
try {
await session.enterGame({
mode: 'join',
name: gameName,
password: gamePassword,
})
st.sceneReady = true
st.stage = 'in_game'
inGameView = true
syncVisibility()
} catch (err) {
if (err instanceof SessionLostError) return
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) {
if (err instanceof SessionLostError) return
st.lastError = err instanceof Error ? err.message : String(err)
}
frontendRenderer.render(performance.now())
},
})
await frontendRenderer.load('/ui/frontend')
bootProgress.setStageFloor(78, '主菜单与大厅界面')
await assetsReadyPromise
bootProgress.setStageFloor(96, '物品图标资源')
await bootProgress.waitForIdle(2500)
bootProgress.complete()
// 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') {
if (inGameView || flowState.stage === 'in_game') {
returnToLobby()
return
}
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') {
toolbar.setConnectionState({
phase: snap.disconnectReason ? 'error' : 'disconnected',
rttMs: null,
diagnosticKind: snap.disconnectReason ? 'custom' : 'none',
diagnosticMessage: snap.disconnectReason ?? undefined,
})
}
syncVisibility()
})
session.onSessionLost((ev) => {
// Single handler for BNCS/MCP/D2GS loss on every screen: the session already disposed all
// connections; show the login screen with the reason (no automatic reconnect).
inGameView = false
returnToLoginAfterSessionLoss(flowState, ev.reason)
toolbar.setConnectionState({
phase: 'error',
rttMs: null,
diagnosticKind: 'custom',
diagnosticMessage: ev.reason,
})
syncVisibility()
frontendRenderer.render(performance.now())
})
// 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
}
if (e.target === hudCanvas) return
const pt = toStageCoords(e)
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,
)
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,
entityArtCache,
characterArtByToken,
monsterArtMap,
missileArtMap,
overlayArtMap,
itemsAtlasHandle,
minimap,
automapView: session.automapView,
hudManager: session.hudManager,
nowMs,
playerMoving: session.predictor.getSnapshot().moving,
})
}
const loop = (now: number): void => {
renderFrame(now)
requestAnimationFrame(loop)
}
renderFrame(performance.now())
requestAnimationFrame(loop)
const controller: PlayAppController = {
ready: true,
bootProgress: bootProgress.getSnapshot(),
bootProgressTracker: bootProgress,
settings: settingsStore,
settingsStore,
session,
toolbar,
inspector,
hudManager: session.hudManager,
hudModel: session.hudModel,
commandMapper: session.commandMapper,
flowState,
frontendRenderer,
renderer,
tileGpuCache,
entityArtCache,
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)
})
}