diff --git a/play.html b/play.html
index a6875ef..f15f686 100644
--- a/play.html
+++ b/play.html
@@ -139,6 +139,121 @@
display: none !important;
}
+ #d2-boot-loading {
+ position: absolute;
+ inset: 0;
+ z-index: 28;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ background: radial-gradient(circle at center, #18120c 0%, #050403 78%);
+ color: #e8dcc8;
+ padding: 24px;
+ }
+
+ #d2-boot-loading[hidden] {
+ display: none !important;
+ }
+
+ .d2-boot-panel {
+ width: 480px;
+ max-width: 92%;
+ background: rgba(12, 9, 6, 0.94);
+ border: 1px solid #8c6f39;
+ box-shadow: 0 0 32px rgba(0, 0, 0, 0.95), inset 0 0 14px rgba(140, 111, 57, 0.16);
+ padding: 22px 26px 20px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 10px;
+ }
+
+ .d2-boot-title {
+ font-family: var(--font-display);
+ font-size: 18px;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ color: #d8b868;
+ text-shadow: 0 2px 6px rgba(0, 0, 0, 0.9);
+ text-align: center;
+ }
+
+ .d2-boot-subtitle {
+ font-family: var(--font-body);
+ font-size: 12px;
+ letter-spacing: 0.06em;
+ color: #b7a588;
+ text-align: center;
+ }
+
+ #d2-boot-progress-bar {
+ width: 100%;
+ height: 22px;
+ background: #070504;
+ border: 1px solid #9c7c42;
+ box-shadow: inset 0 0 8px rgba(0, 0, 0, 0.95);
+ position: relative;
+ overflow: hidden;
+ }
+
+ #d2-boot-progress-fill {
+ width: 0%;
+ height: 100%;
+ background: linear-gradient(90deg, #56100c 0%, #9b2217 55%, #d68a28 100%);
+ box-shadow: 0 0 12px rgba(214, 138, 40, 0.45);
+ transition: width 60ms linear;
+ }
+
+ #d2-boot-progress-percent {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-family: var(--font-display);
+ font-size: 12px;
+ font-weight: 700;
+ letter-spacing: 0.06em;
+ color: #f6ebd4;
+ text-shadow: 0 1px 3px #000, 0 0 6px #000;
+ pointer-events: none;
+ }
+
+ .d2-boot-meta {
+ width: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ font-family: var(--font-body);
+ font-size: 12px;
+ color: #ccb996;
+ min-height: 18px;
+ overflow: hidden;
+ }
+
+ .d2-boot-file-label {
+ color: #9e8863;
+ flex: 0 0 auto;
+ }
+
+ #d2-boot-progress-file {
+ color: #f0dfb8;
+ font-family: monospace;
+ font-size: 12px;
+ max-width: 290px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ #d2-boot-progress-count {
+ color: #8f7b58;
+ font-size: 11px;
+ flex: 0 0 auto;
+ }
+
#d2-inspector-container {
flex: 0 0 auto;
max-height: 100%;
@@ -154,6 +269,27 @@
+
+
+
DIABLO II: LORD OF DESTRUCTION
+
正在下载游戏资源 · LOADING RESOURCES
+
+
+ 正在下载:
+ initializing...
+ (0/0)
+
+
+
diff --git a/src/client/main.ts b/src/client/main.ts
index e60fa0d..f15619f 100644
--- a/src/client/main.ts
+++ b/src/client/main.ts
@@ -41,6 +41,10 @@ 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'
@@ -77,6 +81,8 @@ const PRELOAD_MONSTER_IDS = [
export interface PlayAppController {
readonly ready: boolean
+ readonly bootProgress: BootProgressSnapshot
+ readonly bootProgressTracker: BootProgressTracker
readonly settings: SettingsStore
readonly settingsStore: SettingsStore
readonly session: OnlineSession
@@ -104,6 +110,7 @@ export interface PlayAppController {
declare global {
interface Window {
__d2SkipAutoBoot?: boolean
+ __d2BootProgress?: BootProgressSnapshot
__d2PlayApp?: PlayAppController
__d2Play?: PlayAppController
}
@@ -156,6 +163,8 @@ async function bootPlayApp(): Promise
{
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)
@@ -198,6 +207,7 @@ async function bootPlayApp(): Promise {
onPanAutomap: (dx, dy) => minimap.pan(dx, dy),
})
await hudManager.load('/ui')
+ bootProgress.setStageFloor(25, 'manifest.json')
let mapService: MapService | null = null
try {
@@ -205,6 +215,7 @@ async function bootPlayApp(): Promise {
} catch {
mapService = null
}
+ bootProgress.setStageFloor(45, 'drlg-bundle.bin')
// Kick off asynchronous GPU atlas preloading for WebGL2 world rendering
tileGpuCache.ensureActPalette(1)
@@ -646,6 +657,11 @@ async function bootPlayApp(): Promise {
})
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) => {
@@ -850,6 +866,8 @@ async function bootPlayApp(): Promise {
const controller: PlayAppController = {
ready: true,
+ bootProgress: bootProgress.getSnapshot(),
+ bootProgressTracker: bootProgress,
settings: settingsStore,
settingsStore,
session,
diff --git a/src/client/ui/boot-progress.ts b/src/client/ui/boot-progress.ts
new file mode 100644
index 0000000..1be90de
--- /dev/null
+++ b/src/client/ui/boot-progress.ts
@@ -0,0 +1,555 @@
+/**
+ * Client Boot Resource Download Progress Bar (`src/client/ui/boot-progress.ts`).
+ *
+ * Tracks all client boot resource downloads (`fetch` requests and `HTMLImageElement`
+ * image loads across HUD, DRLG tile bundles, palettes, Battle.net frontend UI sprites,
+ * automap atlases, ground items atlas, character/monster `.r8` atlases, and
+ * missile/overlay sprite sheets) when entering `play.html`.
+ *
+ * Renders:
+ * 1. Total download progress percentage (`0%` – `100%`, strictly monotonic).
+ * 2. The filename of the resource currently being downloaded (`currentFile`).
+ */
+
+export interface BootProgressSnapshot {
+ /** Monotonic total download percentage in `[0, 100]`. */
+ readonly percent: number
+ /** Basename of the resource currently being downloaded (e.g. `drlg-bundle.bin`, `items-atlas.png`). */
+ readonly currentFile: string
+ /** Normalized URL/path of the resource currently being downloaded. */
+ readonly currentFilePath: string
+ /** Number of resources that have finished downloading. */
+ readonly loadedCount: number
+ /** Total number of discovered/expected resources in the boot batch. */
+ readonly totalCount: number
+ /** Number of resources currently in flight. */
+ readonly inFlightCount: number
+ /** Lifecycle status of the boot progress tracker. */
+ readonly status: 'idle' | 'loading' | 'completed'
+ /** Ordered list of resource filenames that have completed downloading. */
+ readonly loadedFiles: readonly string[]
+}
+
+export interface BootProgressOptions {
+ /** Container element (e.g. `#play-stage`) where `#d2-boot-loading` resides or will be mounted. */
+ readonly stageEl?: HTMLElement | null
+ /** Baseline expected number of boot resource requests used to prevent early 100% spikes. */
+ readonly expectedTotal?: number
+ /** Initial placeholder filename before the first request starts. */
+ readonly initialFileLabel?: string
+ /** Whether to automatically hide the overlay when `complete()` is called (default: `true`). */
+ readonly hideOnComplete?: boolean
+}
+
+interface InFlightEntry {
+ readonly id: number
+ readonly fileName: string
+ readonly filePath: string
+ readonly promise: Promise
+ readonly resolve: () => void
+}
+
+/**
+ * Default baseline asset count across `bootPlayApp()`:
+ * - `hudManager.load('/ui')` (~550 font/HUD/skill icon/skill tree assets)
+ * - `MapService.fromPackedUrl('/act-packs/tiles')` + palette (~4 assets)
+ * - `frontendRenderer.load('/ui/frontend')` (~179 frontend sprites & font atlases)
+ * - `assetsReadyPromise` (~180 automap, items, hero, monster, missile, overlay assets)
+ */
+export const DEFAULT_EXPECTED_BOOT_ASSETS = 910
+
+/**
+ * Extracts a clean resource filename from a URL or relative path.
+ * Strips query parameters (`?...`) and hash fragments (`#...`).
+ *
+ * Examples:
+ * - `/act-packs/tiles/drlg-bundle.bin` -> `drlg-bundle.bin`
+ * - `samples/d2-packs/items/items-atlas.png?v=1` -> `items-atlas.png`
+ * - `/ui/frontend/bg-main-menu.png#top` -> `bg-main-menu.png`
+ */
+export function extractAssetFileName(urlOrPath: string): string {
+ const trimmed = urlOrPath.trim()
+ if (!trimmed) return ''
+ if (trimmed.startsWith('data:') || trimmed.startsWith('blob:')) {
+ return ''
+ }
+ const noHash = trimmed.split('#')[0] ?? trimmed
+ const noQuery = noHash.split('?')[0] ?? noHash
+ const normalized = noQuery.replace(/\\/g, '/').replace(/\/+$/, '')
+ if (!normalized) return ''
+ const segments = normalized.split('/').filter((s) => s.length > 0)
+ const last = segments[segments.length - 1] ?? ''
+ try {
+ return decodeURIComponent(last)
+ } catch {
+ return last
+ }
+}
+
+/**
+ * Normalizes a fetch/image URL into a readable relative path without origin, query, or hash.
+ */
+export function normalizeAssetPath(urlOrPath: string): string {
+ const trimmed = urlOrPath.trim()
+ if (!trimmed || trimmed.startsWith('data:') || trimmed.startsWith('blob:')) {
+ return ''
+ }
+ let pathPart = trimmed
+ try {
+ if (/^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//.test(trimmed)) {
+ const parsed = new URL(trimmed)
+ pathPart = parsed.pathname
+ }
+ } catch {
+ // Keep original string if URL parsing fails
+ }
+ const noHash = pathPart.split('#')[0] ?? pathPart
+ const noQuery = noHash.split('?')[0] ?? noHash
+ return noQuery.replace(/\\/g, '/')
+}
+
+export class BootProgressTracker {
+ private readonly stageEl: HTMLElement | null
+ private readonly expectedTotal: number
+ private readonly hideOnComplete: boolean
+
+ private overlayEl: HTMLElement | null = null
+ private barEl: HTMLElement | null = null
+ private fillEl: HTMLElement | null = null
+ private percentEl: HTMLElement | null = null
+ private fileEl: HTMLElement | null = null
+ private countEl: HTMLElement | null = null
+
+ private nextId = 1
+ private totalDiscovered = 0
+ private loadedCount = 0
+ private stageFloorPercent = 0
+ private percent = 0
+ private currentFile: string
+ private currentFilePath = ''
+ private status: 'idle' | 'loading' | 'completed' = 'idle'
+ private readonly inFlight = new Map()
+ private readonly loadedFiles: string[] = []
+ private readonly listeners = new Set<(snap: BootProgressSnapshot) => void>()
+
+ private installed = false
+ private originalFetch: typeof globalThis.fetch | null = null
+ private originalImgSrcDescriptor: PropertyDescriptor | null = null
+
+ constructor(options: BootProgressOptions = {}) {
+ this.stageEl = options.stageEl ?? null
+ this.expectedTotal = Math.max(1, options.expectedTotal ?? DEFAULT_EXPECTED_BOOT_ASSETS)
+ this.currentFile = options.initialFileLabel ?? 'initializing...'
+ this.hideOnComplete = options.hideOnComplete ?? true
+ this.bindOrCreateDom()
+ }
+
+ /**
+ * Ensures the `#d2-boot-loading` DOM structure exists and binds element references.
+ */
+ private bindOrCreateDom(): void {
+ if (typeof document === 'undefined') return
+
+ let overlay = document.getElementById('d2-boot-loading')
+ if (!overlay && this.stageEl) {
+ overlay = document.createElement('div')
+ overlay.id = 'd2-boot-loading'
+ overlay.innerHTML = [
+ '',
+ '
DIABLO II: LORD OF DESTRUCTION
',
+ '
正在下载游戏资源 · LOADING RESOURCES
',
+ '
',
+ '
',
+ ' 正在下载:',
+ ' initializing...',
+ ' (0/0)',
+ '
',
+ '
',
+ ].join('\n')
+ this.stageEl.appendChild(overlay)
+ }
+
+ this.overlayEl = overlay
+ this.barEl = document.getElementById('d2-boot-progress-bar')
+ this.fillEl = document.getElementById('d2-boot-progress-fill')
+ this.percentEl = document.getElementById('d2-boot-progress-percent')
+ this.fileEl = document.getElementById('d2-boot-progress-file')
+ this.countEl = document.getElementById('d2-boot-progress-count')
+ this.renderDom()
+ }
+
+ /**
+ * Installs global `fetch` and `HTMLImageElement.prototype.src` hooks to track
+ * all resource downloads initiated during client boot.
+ */
+ install(): this {
+ if (this.status === 'idle') {
+ this.status = 'loading'
+ }
+ if (this.overlayEl) {
+ this.overlayEl.hidden = false
+ this.overlayEl.style.display = 'flex'
+ this.overlayEl.removeAttribute('data-boot-complete')
+ }
+ this.renderDom()
+
+ if (this.installed) return this
+ this.installed = true
+
+ if (typeof globalThis.fetch === 'function') {
+ const origFetch = globalThis.fetch.bind(globalThis)
+ this.originalFetch = globalThis.fetch
+ globalThis.fetch = async (
+ input: RequestInfo | URL,
+ init?: RequestInit,
+ ): Promise => {
+ const rawUrl =
+ typeof input === 'string'
+ ? input
+ : input instanceof URL
+ ? input.href
+ : typeof input === 'object' && input !== null && 'url' in input
+ ? String((input as Request).url)
+ : String(input)
+
+ const id = this.beginFile(rawUrl)
+ if (id === 0) {
+ return origFetch(input, init)
+ }
+
+ try {
+ const response = await origFetch(input, init)
+ if (!response || !response.ok) {
+ this.endFile(id)
+ return response
+ }
+ this.wrapResponseBodyCompletion(response, id)
+ return response
+ } catch (err) {
+ this.endFile(id)
+ throw err
+ }
+ }
+ }
+
+ if (typeof HTMLImageElement !== 'undefined' && HTMLImageElement.prototype) {
+ const desc = Object.getOwnPropertyDescriptor(HTMLImageElement.prototype, 'src')
+ if (desc && typeof desc.set === 'function' && typeof desc.get === 'function') {
+ this.originalImgSrcDescriptor = desc
+ const tracker = this
+ Object.defineProperty(HTMLImageElement.prototype, 'src', {
+ configurable: true,
+ enumerable: desc.enumerable ?? true,
+ get(): string {
+ return desc.get!.call(this) as string
+ },
+ set(value: string) {
+ const rawUrl = String(value ?? '')
+ const id = tracker.beginFile(rawUrl)
+ desc.set!.call(this, value)
+ if (id === 0) return
+
+ const img = this as HTMLImageElement
+ if (img.complete && img.naturalWidth > 0) {
+ queueMicrotask(() => tracker.endFile(id))
+ return
+ }
+ let settled = false
+ const done = (): void => {
+ if (settled) return
+ settled = true
+ img.removeEventListener('load', done)
+ img.removeEventListener('error', done)
+ tracker.endFile(id)
+ }
+ img.addEventListener('load', done, { once: true })
+ img.addEventListener('error', done, { once: true })
+ },
+ })
+ }
+ }
+
+ this.publishGlobal()
+ return this
+ }
+
+ /**
+ * Wraps body consumption methods on a `Response` so the resource is marked complete
+ * once its payload (`json()`, `arrayBuffer()`, `text()`, or `blob()`) has been downloaded.
+ */
+ private wrapResponseBodyCompletion(response: Response, id: number): void {
+ let finished = false
+ const finish = (): void => {
+ if (finished) return
+ finished = true
+ this.endFile(id)
+ }
+
+ const methodNames = ['json', 'arrayBuffer', 'text', 'blob'] as const
+ for (const method of methodNames) {
+ const orig = response[method]
+ if (typeof orig === 'function') {
+ const bound = orig.bind(response) as () => Promise
+ try {
+ Object.defineProperty(response, method, {
+ configurable: true,
+ writable: true,
+ value: async () => {
+ try {
+ return await bound()
+ } finally {
+ finish()
+ }
+ },
+ })
+ } catch {
+ // Response property may be non-configurable in some synthetic mocks; fallback timer handles it
+ }
+ }
+ }
+
+ // Fallback in case caller only inspects headers/status without consuming body
+ setTimeout(finish, 1500)
+ }
+
+ /**
+ * Restores original `fetch` and `HTMLImageElement.prototype.src` globals.
+ */
+ uninstall(): void {
+ if (!this.installed) return
+ this.installed = false
+
+ if (this.originalFetch) {
+ globalThis.fetch = this.originalFetch
+ this.originalFetch = null
+ }
+ if (this.originalImgSrcDescriptor && typeof HTMLImageElement !== 'undefined') {
+ try {
+ Object.defineProperty(HTMLImageElement.prototype, 'src', this.originalImgSrcDescriptor)
+ } catch {
+ // Ignore restore error
+ }
+ this.originalImgSrcDescriptor = null
+ }
+ }
+
+ /**
+ * Records the start of a resource download and updates the active filename & percentage.
+ * Returns a non-zero tracking ID (or `0` if the URL is ignored, e.g. `data:`/`blob:`).
+ */
+ beginFile(urlOrPath: string): number {
+ if (this.status === 'completed') return 0
+ const fileName = extractAssetFileName(urlOrPath)
+ if (!fileName) return 0
+
+ const filePath = normalizeAssetPath(urlOrPath) || fileName
+ if (this.status === 'idle') {
+ this.status = 'loading'
+ }
+
+ const id = this.nextId++
+ let resolveFn!: () => void
+ const promise = new Promise((r) => {
+ resolveFn = r
+ })
+
+ this.totalDiscovered++
+ this.inFlight.set(id, {
+ id,
+ fileName,
+ filePath,
+ promise,
+ resolve: resolveFn,
+ })
+
+ this.currentFile = fileName
+ this.currentFilePath = filePath
+ this.recalculatePercent()
+ this.renderDom()
+ this.emitChange()
+ return id
+ }
+
+ /**
+ * Records the completion of a resource download by ID.
+ */
+ endFile(id: number): void {
+ const entry = this.inFlight.get(id)
+ if (!entry) return
+ this.inFlight.delete(id)
+ entry.resolve()
+
+ if (this.status === 'completed') return
+
+ this.loadedCount++
+ this.loadedFiles.push(entry.fileName)
+
+ // If other files are still downloading, show the most recently started in-flight file;
+ // otherwise keep the just-finished file name visible.
+ if (this.inFlight.size > 0) {
+ let latest: InFlightEntry | undefined
+ for (const item of this.inFlight.values()) {
+ latest = item
+ }
+ if (latest) {
+ this.currentFile = latest.fileName
+ this.currentFilePath = latest.filePath
+ }
+ } else {
+ this.currentFile = entry.fileName
+ this.currentFilePath = entry.filePath
+ }
+
+ this.recalculatePercent()
+ this.renderDom()
+ this.emitChange()
+ }
+
+ /**
+ * Explicitly tracks an arbitrary Promise associated with a resource path/name.
+ */
+ async trackPromise(urlOrPath: string, promise: Promise): Promise {
+ const id = this.beginFile(urlOrPath)
+ try {
+ return await promise
+ } finally {
+ if (id !== 0) {
+ this.endFile(id)
+ }
+ }
+ }
+
+ /**
+ * Advances the minimum percentage floor at a major boot milestone.
+ */
+ setStageFloor(minPercent: number, fallbackFile?: string): void {
+ if (this.status === 'completed') return
+ const clamped = Math.max(0, Math.min(99, Math.floor(minPercent)))
+ if (clamped > this.stageFloorPercent) {
+ this.stageFloorPercent = clamped
+ }
+ if (fallbackFile && (!this.currentFile || this.currentFile === 'initializing...')) {
+ this.currentFile = extractAssetFileName(fallbackFile) || fallbackFile
+ this.currentFilePath = normalizeAssetPath(fallbackFile) || this.currentFile
+ }
+ this.recalculatePercent()
+ this.renderDom()
+ this.emitChange()
+ }
+
+ /**
+ * Waits until all currently in-flight resource downloads have settled, bounded by `timeoutMs`.
+ */
+ async waitForIdle(timeoutMs = 3500): Promise {
+ const deadline = Date.now() + Math.max(50, timeoutMs)
+ while (this.inFlight.size > 0 && Date.now() < deadline) {
+ const pending = Array.from(this.inFlight.values(), (e) => e.promise)
+ const remainingMs = Math.max(10, deadline - Date.now())
+ await Promise.race([
+ Promise.all(pending),
+ new Promise((r) => setTimeout(r, Math.min(250, remainingMs))),
+ ])
+ }
+ }
+
+ /**
+ * Marks all boot resource downloads as 100% complete, updates DOM, unhides the game stage,
+ * and restores patched globals.
+ */
+ complete(): void {
+ for (const entry of this.inFlight.values()) {
+ this.loadedCount++
+ this.loadedFiles.push(entry.fileName)
+ entry.resolve()
+ }
+ this.inFlight.clear()
+
+ this.uninstall()
+ this.status = 'completed'
+ this.percent = 100
+ if (!this.currentFile || this.currentFile === 'initializing...') {
+ this.currentFile = this.loadedFiles[this.loadedFiles.length - 1] ?? 'play.html'
+ }
+ this.renderDom()
+ if (this.overlayEl) {
+ this.overlayEl.setAttribute('data-boot-complete', 'true')
+ if (this.hideOnComplete) {
+ this.overlayEl.hidden = true
+ this.overlayEl.style.display = 'none'
+ }
+ }
+ this.emitChange()
+ }
+
+ private recalculatePercent(): void {
+ if (this.status === 'completed') {
+ this.percent = 100
+ return
+ }
+ const denominator = Math.max(this.expectedTotal, this.totalDiscovered, 1)
+ const ratioPercent = Math.min(99, Math.floor((this.loadedCount / denominator) * 100))
+ const nextPercent = Math.max(this.percent, this.stageFloorPercent, ratioPercent)
+ this.percent = Math.max(0, Math.min(99, nextPercent))
+ }
+
+ private renderDom(): void {
+ const totalDisplay = Math.max(this.loadedCount, this.totalDiscovered)
+ if (this.barEl) {
+ this.barEl.setAttribute('aria-valuenow', String(this.percent))
+ }
+ if (this.fillEl) {
+ this.fillEl.style.width = `${this.percent}%`
+ }
+ if (this.percentEl) {
+ this.percentEl.textContent = `${this.percent}%`
+ }
+ if (this.fileEl) {
+ this.fileEl.textContent = this.currentFile
+ this.fileEl.setAttribute('title', this.currentFilePath || this.currentFile)
+ }
+ if (this.countEl) {
+ this.countEl.textContent = `(${this.loadedCount}/${totalDisplay})`
+ }
+ this.publishGlobal()
+ }
+
+ private publishGlobal(): void {
+ if (typeof window !== 'undefined') {
+ ;(
+ window as unknown as {
+ __d2BootProgress?: BootProgressSnapshot
+ }
+ ).__d2BootProgress = this.getSnapshot()
+ }
+ }
+
+ getSnapshot(): BootProgressSnapshot {
+ return {
+ percent: this.percent,
+ currentFile: this.currentFile,
+ currentFilePath: this.currentFilePath,
+ loadedCount: this.loadedCount,
+ totalCount: Math.max(this.loadedCount, this.totalDiscovered),
+ inFlightCount: this.inFlight.size,
+ status: this.status,
+ loadedFiles: [...this.loadedFiles],
+ }
+ }
+
+ subscribe(listener: (snap: BootProgressSnapshot) => void): () => void {
+ this.listeners.add(listener)
+ return () => {
+ this.listeners.delete(listener)
+ }
+ }
+
+ private emitChange(): void {
+ const snap = this.getSnapshot()
+ for (const cb of this.listeners) {
+ cb(snap)
+ }
+ }
+}
diff --git a/tests/boot-progress.test.ts b/tests/boot-progress.test.ts
new file mode 100644
index 0000000..3dd3561
--- /dev/null
+++ b/tests/boot-progress.test.ts
@@ -0,0 +1,263 @@
+import { readFileSync } from 'node:fs'
+import { resolve } from 'node:path'
+import { afterEach, describe, expect, it } from 'vitest'
+import {
+ BootProgressTracker,
+ extractAssetFileName,
+ normalizeAssetPath,
+ type BootProgressSnapshot,
+} from '../src/client/ui/boot-progress.ts'
+
+const ROOT_DIR = resolve(__dirname, '..')
+
+interface MockElement {
+ id: string
+ hidden: boolean
+ textContent: string
+ innerHTML: string
+ style: { width: string; display: string }
+ attributes: Map
+ setAttribute(name: string, val: string): void
+ removeAttribute(name: string): void
+ getAttribute(name: string): string | null
+ appendChild(child: MockElement): MockElement
+}
+
+function createMockDomEnvironment(): {
+ readonly stageEl: HTMLElement
+ readonly elementsById: Map
+ readonly cleanup: () => void
+} {
+ const elementsById = new Map()
+
+ const makeEl = (id = ''): MockElement => {
+ const el: MockElement = {
+ id,
+ hidden: false,
+ textContent: '',
+ innerHTML: '',
+ style: { width: '0%', display: 'flex' },
+ attributes: new Map(),
+ setAttribute(name: string, val: string) {
+ this.attributes.set(name, val)
+ },
+ removeAttribute(name: string) {
+ this.attributes.delete(name)
+ },
+ getAttribute(name: string) {
+ return this.attributes.get(name) ?? null
+ },
+ appendChild(child: MockElement) {
+ if (child.id) {
+ elementsById.set(child.id, child)
+ }
+ return child
+ },
+ }
+ if (id) {
+ elementsById.set(id, el)
+ }
+ return el
+ }
+
+ // Pre-register the boot progress DOM elements matching play.html
+ makeEl('d2-boot-loading')
+ makeEl('d2-boot-progress-bar')
+ makeEl('d2-boot-progress-fill')
+ makeEl('d2-boot-progress-percent')
+ makeEl('d2-boot-progress-file')
+ makeEl('d2-boot-progress-count')
+ const stageEl = makeEl('play-stage') as unknown as HTMLElement
+
+ const prevDoc = (globalThis as Record).document
+ const prevWin = (globalThis as Record).window
+
+ ;(globalThis as Record).document = {
+ getElementById(id: string) {
+ return (elementsById.get(id) ?? null) as unknown as HTMLElement | null
+ },
+ createElement() {
+ return makeEl() as unknown as HTMLElement
+ },
+ }
+ ;(globalThis as Record).window = globalThis
+
+ return {
+ stageEl,
+ elementsById,
+ cleanup: () => {
+ if (prevDoc === undefined) {
+ delete (globalThis as Record).document
+ } else {
+ ;(globalThis as Record).document = prevDoc
+ }
+ if (prevWin === undefined) {
+ delete (globalThis as Record).window
+ } else {
+ ;(globalThis as Record).window = prevWin
+ }
+ },
+ }
+}
+
+describe('Client Boot Resource Download Progress Bar (boot-progress)', () => {
+ let cleanupDom: (() => void) | null = null
+
+ afterEach(() => {
+ if (cleanupDom) {
+ cleanupDom()
+ cleanupDom = null
+ }
+ })
+
+ it('extractAssetFileName extracts clean filenames and strips query/hash fragments', () => {
+ expect(extractAssetFileName('/act-packs/tiles/drlg-bundle.bin')).toBe('drlg-bundle.bin')
+ expect(extractAssetFileName('samples/d2-packs/items/items-atlas.png?v=123')).toBe(
+ 'items-atlas.png',
+ )
+ expect(extractAssetFileName('http://127.0.0.1:5173/ui/frontend/bg-main-menu.png#top')).toBe(
+ 'bg-main-menu.png',
+ )
+ expect(extractAssetFileName('data\\global\\tiles\\act1\\town\\town.dt1')).toBe('town.dt1')
+ expect(extractAssetFileName('data:image/png;base64,iVBORw0KGgo=')).toBe('')
+ expect(extractAssetFileName('blob:http://127.0.0.1:5173/abc-123')).toBe('')
+ expect(extractAssetFileName('')).toBe('')
+ })
+
+ it('normalizeAssetPath strips origin, query, and hash while preserving path', () => {
+ expect(normalizeAssetPath('http://127.0.0.1:5173/ui/frontend/bg-main-menu.png?t=1#a')).toBe(
+ '/ui/frontend/bg-main-menu.png',
+ )
+ expect(normalizeAssetPath('/act-packs/tiles/index.json')).toBe('/act-packs/tiles/index.json')
+ })
+
+ it('tracks downloading filenames, monotonic percentage (0% -> 100%), and updates DOM elements', () => {
+ const dom = createMockDomEnvironment()
+ cleanupDom = dom.cleanup
+
+ const tracker = new BootProgressTracker({
+ stageEl: dom.stageEl,
+ expectedTotal: 4,
+ }).install()
+
+ const snapshots: BootProgressSnapshot[] = []
+ tracker.subscribe((snap) => snapshots.push(snap))
+
+ const overlayEl = dom.elementsById.get('d2-boot-loading')!
+ const percentEl = dom.elementsById.get('d2-boot-progress-percent')!
+ const fillEl = dom.elementsById.get('d2-boot-progress-fill')!
+ const fileEl = dom.elementsById.get('d2-boot-progress-file')!
+
+ expect(overlayEl.hidden).toBe(false)
+ expect(percentEl.textContent).toBe('0%')
+
+ // Start downloading file 1 & file 2 concurrently
+ const id1 = tracker.beginFile('/ui/fonts/metrics.json')
+ expect(tracker.getSnapshot().currentFile).toBe('metrics.json')
+ expect(fileEl.textContent).toBe('metrics.json')
+
+ const id2 = tracker.beginFile('/act-packs/tiles/drlg-bundle.bin')
+ expect(tracker.getSnapshot().currentFile).toBe('drlg-bundle.bin')
+ expect(fileEl.textContent).toBe('drlg-bundle.bin')
+
+ // Finish file 2 while file 1 is still in flight -> currentFile falls back to in-flight file 1
+ tracker.endFile(id2)
+ expect(tracker.getSnapshot().percent).toBe(25)
+ expect(tracker.getSnapshot().currentFile).toBe('metrics.json')
+ expect(percentEl.textContent).toBe('25%')
+ expect(fillEl.style.width).toBe('25%')
+
+ // Finish file 1
+ tracker.endFile(id1)
+ expect(tracker.getSnapshot().percent).toBe(50)
+ expect(percentEl.textContent).toBe('50%')
+
+ // Start & finish file 3 & file 4
+ const id3 = tracker.beginFile('/ui/frontend/bg-main-menu.png')
+ expect(fileEl.textContent).toBe('bg-main-menu.png')
+ tracker.endFile(id3)
+ expect(tracker.getSnapshot().percent).toBe(75)
+
+ const id4 = tracker.beginFile('samples/d2-packs/items/items-atlas.png')
+ expect(fileEl.textContent).toBe('items-atlas.png')
+ tracker.endFile(id4)
+ expect(tracker.getSnapshot().percent).toBe(99)
+
+ // Complete boot
+ tracker.complete()
+ const finalSnap = tracker.getSnapshot()
+ expect(finalSnap.percent).toBe(100)
+ expect(finalSnap.status).toBe('completed')
+ expect(finalSnap.loadedCount).toBe(4)
+ expect(finalSnap.loadedFiles).toEqual([
+ 'drlg-bundle.bin',
+ 'metrics.json',
+ 'bg-main-menu.png',
+ 'items-atlas.png',
+ ])
+ expect(percentEl.textContent).toBe('100%')
+ expect(fillEl.style.width).toBe('100%')
+ expect(overlayEl.hidden).toBe(true)
+
+ // Verify percentage is strictly monotonic across all emitted snapshots
+ for (let i = 1; i < snapshots.length; i++) {
+ expect(snapshots[i]!.percent).toBeGreaterThanOrEqual(snapshots[i - 1]!.percent)
+ }
+ })
+
+ it('intercepts global fetch calls during boot and restores original fetch upon complete()', async () => {
+ const dom = createMockDomEnvironment()
+ cleanupDom = dom.cleanup
+
+ const mockFetch = async (input: RequestInfo | URL): Promise => {
+ const url = String(input)
+ return new Response(JSON.stringify({ url }), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ })
+ }
+ const prevFetch = globalThis.fetch
+ globalThis.fetch = mockFetch as typeof globalThis.fetch
+
+ try {
+ const tracker = new BootProgressTracker({
+ stageEl: dom.stageEl,
+ expectedTotal: 2,
+ }).install()
+
+ expect(globalThis.fetch).not.toBe(mockFetch)
+
+ const res1 = await globalThis.fetch('/act-packs/tiles/index.json')
+ expect(tracker.getSnapshot().currentFile).toBe('index.json')
+ await res1.json()
+ expect(tracker.getSnapshot().loadedCount).toBe(1)
+ expect(tracker.getSnapshot().percent).toBe(50)
+
+ const res2 = await globalThis.fetch('/ui/frontend/manifest.json')
+ expect(tracker.getSnapshot().currentFile).toBe('manifest.json')
+ await res2.json()
+ expect(tracker.getSnapshot().loadedCount).toBe(2)
+
+ await tracker.waitForIdle(200)
+ tracker.complete()
+
+ expect(tracker.getSnapshot().percent).toBe(100)
+ expect(globalThis.fetch).toBe(mockFetch)
+ } finally {
+ globalThis.fetch = prevFetch
+ }
+ })
+
+ it('play.html includes #d2-boot-loading progress bar elements and main.ts wires BootProgressTracker', () => {
+ const playHtml = readFileSync(resolve(ROOT_DIR, 'play.html'), 'utf8')
+ expect(playHtml).toContain('id="d2-boot-loading"')
+ expect(playHtml).toContain('id="d2-boot-progress-bar"')
+ expect(playHtml).toContain('id="d2-boot-progress-fill"')
+ expect(playHtml).toContain('id="d2-boot-progress-percent"')
+ expect(playHtml).toContain('id="d2-boot-progress-file"')
+
+ const mainTs = readFileSync(resolve(ROOT_DIR, 'src/client/main.ts'), 'utf8')
+ expect(mainTs).toContain('BootProgressTracker')
+ expect(mainTs).toContain('bootProgress.complete()')
+ })
+})
diff --git a/tools/verify-play.ts b/tools/verify-play.ts
index 9969354..6931c0d 100644
--- a/tools/verify-play.ts
+++ b/tools/verify-play.ts
@@ -318,6 +318,11 @@ async function main(): Promise {
hasInspector: boolean
hasLobbyOverlay: boolean
hasBnetCanvas: boolean
+ hasBootProgressBar: boolean
+ bootProgressPercent: number
+ bootLoadedCount: number
+ bootCurrentFile: string
+ bootOverlayHidden: boolean
stage: string
phase: string
bnetNonBlackPixels: number
@@ -328,6 +333,11 @@ async function main(): Promise {
const canvas = document.getElementById('view');
const hudCanvas = document.getElementById('d2-hud-canvas');
const bnetCanvas = document.getElementById('bnet-canvas');
+ const bootOverlay = document.getElementById('d2-boot-loading');
+ const bootBar = document.getElementById('d2-boot-progress-bar');
+ const bootPercentEl = document.getElementById('d2-boot-progress-percent');
+ const bootFileEl = document.getElementById('d2-boot-progress-file');
+ const bp = window.__d2BootProgress ?? app.bootProgress;
let bnetNonBlackPixels = 0;
if (bnetCanvas instanceof HTMLCanvasElement) {
const ctx = bnetCanvas.getContext('2d');
@@ -348,6 +358,11 @@ async function main(): Promise {
hasInspector: Boolean(document.getElementById('d2-inspector-container')),
hasLobbyOverlay: Boolean(document.getElementById('bnet-shell')),
hasBnetCanvas: bnetCanvas instanceof HTMLCanvasElement,
+ hasBootProgressBar: Boolean(bootOverlay && bootBar && bootPercentEl && bootFileEl),
+ bootProgressPercent: bp?.percent ?? 0,
+ bootLoadedCount: bp?.loadedCount ?? 0,
+ bootCurrentFile: bp?.currentFile ?? '',
+ bootOverlayHidden: Boolean(bootOverlay?.hidden || bootOverlay?.style.display === 'none'),
stage: app.flowState.stage,
phase: app.session.phase,
bnetNonBlackPixels,
@@ -363,6 +378,11 @@ async function main(): Promise {
!gate1.hasInspector ||
!gate1.hasLobbyOverlay ||
!gate1.hasBnetCanvas ||
+ !gate1.hasBootProgressBar ||
+ gate1.bootProgressPercent !== 100 ||
+ gate1.bootLoadedCount < 50 ||
+ !gate1.bootCurrentFile ||
+ !gate1.bootOverlayHidden ||
!gate1.hasWebGL2 ||
gate1.stage !== 'main_menu' ||
gate1.bnetNonBlackPixels < 5000
diff --git a/vite.config.ts b/vite.config.ts
index 5875dcd..2b6292c 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -20,6 +20,17 @@ export default defineConfig({
})
},
},
+ {
+ name: 'd2-root-to-play-html',
+ configureServer(server) {
+ server.middlewares.use((req, _res, next) => {
+ if (req.url === '/' || req.url?.startsWith('/?')) {
+ req.url = `/play.html${req.url.slice(1)}`
+ }
+ next()
+ })
+ },
+ },
],
server: {
host: '127.0.0.1',