feat(client): render boot resource download progress bar with percentage and current filename
This commit is contained in:
parent
ffb45a480f
commit
405bdea4ff
136
play.html
136
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 @@
|
|||
<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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()')
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in New Issue