956 lines
31 KiB
TypeScript
956 lines
31 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,
|
||
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)
|
||
})
|
||
}
|