From 405bdea4ff72bc8c11bb371499b1fd8982c69c47 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Thu, 1 Oct 2026 01:18:23 +0000 Subject: [PATCH] feat(client): render boot resource download progress bar with percentage and current filename --- play.html | 136 ++++++++ src/client/main.ts | 18 ++ src/client/ui/boot-progress.ts | 555 +++++++++++++++++++++++++++++++++ tests/boot-progress.test.ts | 263 ++++++++++++++++ tools/verify-play.ts | 20 ++ vite.config.ts | 11 + 6 files changed, 1003 insertions(+) create mode 100644 src/client/ui/boot-progress.ts create mode 100644 tests/boot-progress.test.ts 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
+
+
+
0%
+
+
+ 正在下载: + 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
', + '
', + '
', + '
0%
', + '
', + '
', + ' 正在下载:', + ' 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',