diablo2-web/src/client/main.ts

1026 lines
33 KiB
TypeScript

/**
* 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` for every registered `ViewportProfile`)
* - 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 {
applyCreateAccountServerError,
applyOnlineCharactersToFlowState,
createInitialFlowState,
isDifficultyUnlocked,
resetHeroAvatarAnims,
returnToLoginAfterSessionLoss,
stepFlowFrame,
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 { BncsCreateAccountError, 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 the initial viewport profile's dimensions, stretched to fit the browser at its own aspect ratio
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()
try {
const savedAccount = window.localStorage?.getItem('d2_last_bnet_account')
if (savedAccount && savedAccount.trim()) {
flowState.accountName = savedAccount.trim().slice(0, 15)
}
} catch {
// Ignore localStorage access errors in restricted contexts
}
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'
flowState.stageTicks = 0
flowState.doorFrame = 0
flowState.tickAccumulatorMs = 0
flowState.lastFrameTimeMs = null
resetHeroAvatarAnims(flowState)
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 })
try {
window.localStorage?.setItem('d2_last_bnet_account', username)
} catch {
// Ignore localStorage write errors
}
if (res.realms[0]) {
st.gateway = res.realms[0].title
}
applyOnlineCharactersToFlowState(st, res.characters, {
stayOnCharSelect: Boolean(createNewAccount),
})
} catch (err) {
if (err instanceof BncsCreateAccountError) {
applyCreateAccountServerError(
st,
err.status,
frontendRenderer.manifest?.strings as unknown as Record<string, unknown>,
)
} else if (!(err instanceof SessionLostError)) {
// A lost session was already routed to the login screen by `onSessionLost`.
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.stageTicks = 0
resetHeroAvatarAnims(st)
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.sceneReady = false
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'
st.stageTicks = 0
st.tickAccumulatorMs = 0
st.lastFrameTimeMs = null
inGameView = true
syncVisibility()
} catch (err) {
if (err instanceof SessionLostError) return
st.stage = 'bnet_lobby'
st.stageTicks = 0
st.doorFrame = 0
st.sceneReady = false
st.tickAccumulatorMs = 0
st.lastFrameTimeMs = null
resetHeroAvatarAnims(st)
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.sceneReady = false
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'
st.stageTicks = 0
st.tickAccumulatorMs = 0
st.lastFrameTimeMs = null
inGameView = true
syncVisibility()
} catch (err) {
if (err instanceof SessionLostError) return
st.stage = 'bnet_lobby'
st.stageTicks = 0
st.doorFrame = 0
st.sceneReady = false
st.tickAccumulatorMs = 0
st.lastFrameTimeMs = null
resetHeroAvatarAnims(st)
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'
flowState.stageTicks = 0
flowState.tickAccumulatorMs = 0
flowState.lastFrameTimeMs = null
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()
})
stageEl.addEventListener(
'wheel',
(e) => {
if (!inGameView && flowState.stage === 'bnet_terms_of_use') {
e.preventDefault()
frontendRenderer.handleWheel(e.deltaY)
frontendRenderer.render(performance.now())
}
},
{ passive: false },
)
// 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) {
stepFlowFrame(flowState, nowMs)
if (flowState.stage === 'in_game' && (session.phase === 'ingame' || session.phase === 'replay')) {
inGameView = true
flowState.tickAccumulatorMs = 0
flowState.lastFrameTimeMs = null
syncVisibility()
} else {
frontendRenderer.render(nowMs)
return
}
} else {
flowState.tickAccumulatorMs = 0
flowState.lastFrameTimeMs = null
}
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)
})
}