feat(client): render boot resource download progress bar with percentage and current filename

This commit is contained in:
troytt 2026-10-01 01:18:23 +00:00
parent ffb45a480f
commit 405bdea4ff
6 changed files with 1003 additions and 0 deletions

136
play.html
View File

@ -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 @@
<div id="play-workspace">
<div id="play-stage-wrapper">
<div id="play-stage">
<div id="d2-boot-loading">
<div class="d2-boot-panel">
<div class="d2-boot-title">DIABLO II: LORD OF DESTRUCTION</div>
<div class="d2-boot-subtitle">正在下载游戏资源 · LOADING RESOURCES</div>
<div
id="d2-boot-progress-bar"
role="progressbar"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow="0"
>
<div id="d2-boot-progress-fill" style="width: 0%"></div>
<div id="d2-boot-progress-percent">0%</div>
</div>
<div class="d2-boot-meta">
<span class="d2-boot-file-label">正在下载:</span>
<span id="d2-boot-progress-file">initializing...</span>
<span id="d2-boot-progress-count">(0/0)</span>
</div>
</div>
</div>
<div id="bnet-shell">
<canvas id="bnet-canvas" width="800" height="600"></canvas>
</div>

View File

@ -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<PlayAppController> {
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<PlayAppController> {
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<PlayAppController> {
} 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<PlayAppController> {
})
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<PlayAppController> {
const controller: PlayAppController = {
ready: true,
bootProgress: bootProgress.getSnapshot(),
bootProgressTracker: bootProgress,
settings: settingsStore,
settingsStore,
session,

View File

@ -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<void>
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<number, InFlightEntry>()
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 = [
'<div class="d2-boot-panel">',
' <div class="d2-boot-title">DIABLO II: LORD OF DESTRUCTION</div>',
' <div class="d2-boot-subtitle">正在下载游戏资源 · LOADING RESOURCES</div>',
' <div id="d2-boot-progress-bar" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">',
' <div id="d2-boot-progress-fill" style="width: 0%"></div>',
' <div id="d2-boot-progress-percent">0%</div>',
' </div>',
' <div class="d2-boot-meta">',
' <span class="d2-boot-file-label">正在下载:</span>',
' <span id="d2-boot-progress-file">initializing...</span>',
' <span id="d2-boot-progress-count">(0/0)</span>',
' </div>',
'</div>',
].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<Response> => {
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<unknown>
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<void>((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<T>(urlOrPath: string, promise: Promise<T>): Promise<T> {
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<void> {
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<void>((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)
}
}
}

263
tests/boot-progress.test.ts Normal file
View File

@ -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<string, string>
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<string, MockElement>
readonly cleanup: () => void
} {
const elementsById = new Map<string, MockElement>()
const makeEl = (id = ''): MockElement => {
const el: MockElement = {
id,
hidden: false,
textContent: '',
innerHTML: '',
style: { width: '0%', display: 'flex' },
attributes: new Map<string, string>(),
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<string, unknown>).document
const prevWin = (globalThis as Record<string, unknown>).window
;(globalThis as Record<string, unknown>).document = {
getElementById(id: string) {
return (elementsById.get(id) ?? null) as unknown as HTMLElement | null
},
createElement() {
return makeEl() as unknown as HTMLElement
},
}
;(globalThis as Record<string, unknown>).window = globalThis
return {
stageEl,
elementsById,
cleanup: () => {
if (prevDoc === undefined) {
delete (globalThis as Record<string, unknown>).document
} else {
;(globalThis as Record<string, unknown>).document = prevDoc
}
if (prevWin === undefined) {
delete (globalThis as Record<string, unknown>).window
} else {
;(globalThis as Record<string, unknown>).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<Response> => {
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()')
})
})

View File

@ -318,6 +318,11 @@ async function main(): Promise<void> {
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<void> {
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<void> {
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<void> {
!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

View File

@ -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',