1035 lines
34 KiB
TypeScript
1035 lines
34 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.defaultPrevented) 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())
|
|
} else if (inGameView && session?.hudManager) {
|
|
if (session.hudManager.handleWheel(e.deltaY)) {
|
|
e.preventDefault()
|
|
}
|
|
}
|
|
},
|
|
{ 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,
|
|
)
|
|
|
|
window.addEventListener('blur', () => {
|
|
if (inGameView) {
|
|
session.commandMapper.resetModifiers()
|
|
}
|
|
})
|
|
|
|
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, 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,
|
|
})
|
|
}
|
|
|
|
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)
|
|
})
|
|
}
|