924 lines
30 KiB
TypeScript
924 lines
30 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` 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 {
|
||
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 } 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 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
|
||
}
|
||
}
|
||
|
||
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 bootProgress = new BootProgressTracker({ stageEl }).install()
|
||
|
||
const settingsStore = getGlobalSettingsStore()
|
||
const initialSettings = settingsStore.get()
|
||
setLang(initialSettings.lang)
|
||
|
||
let sessionRef: OnlineSession | null = null
|
||
|
||
const toolbar = new Toolbar({
|
||
store: settingsStore,
|
||
container: toolbarContainer,
|
||
callbacks: {
|
||
onLangChange: (lang: GameLang) => {
|
||
setLang(lang)
|
||
sessionRef?.hudManager.setLang(lang)
|
||
},
|
||
onViewportChange: (profile: ViewportProfile) => {
|
||
applyViewportProfileToDom(profile, {
|
||
stageEl,
|
||
glCanvas: viewCanvas,
|
||
hudCanvas,
|
||
})
|
||
sessionRef?.hudManager.resize(profile.width, profile.height)
|
||
sessionRef?.camera.resize(profile)
|
||
},
|
||
onAutomapRevealChange: (mode) => {
|
||
sessionRef?.automapView.setMode(mode)
|
||
},
|
||
onInspectorToggle: (open) => {
|
||
sessionRef?.packetInspector.setOpen(open)
|
||
},
|
||
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 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),
|
||
})
|
||
await hudManager.load('/ui')
|
||
bootProgress.setStageFloor(25, 'manifest.json')
|
||
|
||
// 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, 'drlg-bundle.bin')
|
||
|
||
// 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)
|
||
}
|
||
}
|
||
|
||
void assetsReadyPromise.then(async () => {
|
||
const updatedStatus = await getAssetCacheStatus()
|
||
toolbar.setAssetCacheUsageBytes(updatedStatus.usageBytes)
|
||
})
|
||
|
||
// 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 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)
|
||
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')
|
||
bootProgress.setStageFloor(78, 'bg-main-menu.png')
|
||
await assetsReadyPromise
|
||
bootProgress.setStageFloor(96, 'items-atlas.png')
|
||
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') {
|
||
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,
|
||
bootProgress: bootProgress.getSnapshot(),
|
||
bootProgressTracker: bootProgress,
|
||
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)
|
||
})
|
||
}
|