diablo2-web/src/client/ui/boot-progress.ts

709 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 semantic resource type of the asset currently being downloaded (`currentFile` / `currentResourceType`).
*/
export interface BootProgressSnapshot {
/** Monotonic total download percentage in `[0, 100]`. */
readonly percent: number
/** Semantic resource type of the resource currently being downloaded (e.g. `地图地形资源`, `物品图标资源`). */
readonly currentFile: string
/** Semantic resource type of the resource currently being downloaded. */
readonly currentResourceType: 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 types 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 label 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 resourceType: 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
const INITIAL_PLACEHOLDER_LABELS = new Set(['initializing...', '正在初始化...'])
const KNOWN_RESOURCE_TYPES = new Set([
'字体资源',
'小地图资源',
'技能图标资源',
'物品图标资源',
'投射物特效资源',
'光环与状态特效',
'角色美术资源',
'怪物美术资源',
'地图地形资源',
'主菜单与大厅界面',
'游戏界面资源',
'音频资源',
'资源配置数据',
'美术贴图资源',
'游戏资源',
])
/**
* 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, '/')
}
/**
* Classifies a resource URL or path into a human-readable semantic asset type label
* (replacing raw filenames in the homepage boot download progress UI).
*
* Examples:
* - `/ui/fonts/metrics.json` -> `'字体资源'`
* - `/act-packs/tiles/drlg-bundle.bin` -> `'地图地形资源'`
* - `/ui/frontend/bg-main-menu.png` -> `'主菜单与大厅界面'`
* - `samples/d2-packs/items/items-atlas.png` -> `'物品图标资源'`
* - `samples/d2-packs/entities/char-so.r8` -> `'角色美术资源'`
* - `samples/d2-packs/entities/monster-fallen1-hth.r8` -> `'怪物美术资源'`
* - `/missiles/firebolt.png` -> `'投射物特效资源'`
* - `/overlays/AuraDefianceBack.png` -> `'光环与状态特效'`
* - `/skills/icon_36.png` -> `'技能图标资源'`
* - `/assets/ui/automap/MaxiMap.png` -> `'小地图资源'`
* - `/ui/ctrlpnl-800.png` -> `'游戏界面资源'`
*/
export function extractAssetResourceType(urlOrPath: string): string {
const trimmed = urlOrPath.trim()
if (!trimmed || trimmed.startsWith('data:') || trimmed.startsWith('blob:')) {
return ''
}
if (KNOWN_RESOURCE_TYPES.has(trimmed)) {
return trimmed
}
const rawFileName = extractAssetFileName(trimmed)
if (!rawFileName) return ''
const fileName = rawFileName.toLowerCase()
const normPath = normalizeAssetPath(trimmed).toLowerCase()
if (
normPath.includes('/fonts/') ||
normPath.startsWith('fonts/') ||
fileName === 'metrics.json' ||
fileName.startsWith('font') ||
/\.(?:ttf|woff2?|otf)$/i.test(fileName)
) {
return '字体资源'
}
if (
normPath.includes('/automap/') ||
/^(?:automap|maximap|act[24]map|extnmap)/i.test(fileName)
) {
return '小地图资源'
}
if (
normPath.includes('/skills/') ||
normPath.startsWith('skills/') ||
/^(?:skill|generic-skills|bg_(?:ama|ass|bar|dru|nec|pal|sor)_)/i.test(fileName)
) {
return '技能图标资源'
}
if (normPath.includes('/items/') || fileName.startsWith('items-atlas')) {
return '物品图标资源'
}
if (normPath.includes('/missiles/') || normPath.startsWith('missiles/')) {
return '投射物特效资源'
}
if (normPath.includes('/overlays/') || normPath.startsWith('overlays/')) {
return '光环与状态特效'
}
if (normPath.includes('/chars/') || /^char-(?!sheet|select)/i.test(fileName)) {
return '角色美术资源'
}
if (normPath.includes('/entities/') || /^(?:monster-|monstats)/i.test(fileName)) {
return '怪物美术资源'
}
if (
normPath.includes('/tiles/') ||
normPath.startsWith('act-packs/tiles') ||
/(?:^|\/)packs\/act[1-5]\//i.test(normPath) ||
/^(?:drlg-bundle|palette\.bin|world-graph|tiles-|objects-|scene\.json)/i.test(fileName) ||
/\.(?:dt1|ds1|pal|pl2)$/i.test(fileName)
) {
return '地图地形资源'
}
if (
normPath.includes('/ui/frontend/') ||
normPath.startsWith('ui/frontend/') ||
/^(?:bg-main-menu|bg-char-create|bg-char-select|bg-waiting-room|loading-door-strip|create-game-bg|charselectbox|realmselect|popup-|logo-|gembutton|textbox|clickbox|joingameclickbox|radiobutton|grey-level-box|btn-)/i.test(
fileName,
)
) {
return '主菜单与大厅界面'
}
if (
normPath.includes('/ui/') ||
normPath.startsWith('ui/') ||
/^(?:ctrlpnl|globe-|overlap-|border-|char-sheet|inv-sheet|inv-tab|quest-|quests-atlas|waypoint-|stash-bg|cube-bg|vendor-|popbelt|minipanel|runbutton|menubutton|buysellbtn|goldcoinbtn|miniconvert|level-btn|level-socket|cursor|boxpieces|focus16)/i.test(
fileName,
)
) {
return '游戏界面资源'
}
if (
normPath.includes('/audio/') ||
normPath.includes('/sound/') ||
normPath.includes('/music/') ||
/\.(?:mp3|ogg|wav)$/i.test(fileName)
) {
return '音频资源'
}
if (normPath.includes('/anim/') || /\.(?:json|bin|d2|txt|tbl|dat)$/i.test(fileName)) {
return '资源配置数据'
}
if (/\.(?:png|r8|dc6|dcc|pcx|cel)$/i.test(fileName)) {
return '美术贴图资源'
}
return '游戏资源'
}
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 ?? '正在初始化...'
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">正在初始化...</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 resource type & 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 resourceType = extractAssetResourceType(urlOrPath) || '游戏资源'
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,
resourceType,
filePath,
promise,
resolve: resolveFn,
})
this.currentFile = resourceType
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.resourceType)
// If other files are still downloading, show the most recently started in-flight resource type;
// otherwise keep the just-finished resource type visible.
if (this.inFlight.size > 0) {
let latest: InFlightEntry | undefined
for (const item of this.inFlight.values()) {
latest = item
}
if (latest) {
this.currentFile = latest.resourceType
this.currentFilePath = latest.filePath
}
} else {
this.currentFile = entry.resourceType
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 || INITIAL_PLACEHOLDER_LABELS.has(this.currentFile))) {
this.currentFile = extractAssetResourceType(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.resourceType)
entry.resolve()
}
this.inFlight.clear()
this.uninstall()
this.status = 'completed'
this.percent = 100
if (!this.currentFile || INITIAL_PLACEHOLDER_LABELS.has(this.currentFile)) {
this.currentFile = this.loadedFiles[this.loadedFiles.length - 1] ?? '游戏资源'
}
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.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,
currentResourceType: this.currentFile,
currentFilePath: this.currentFilePath,
loadedCount: this.loadedCount,
totalCount: Math.max(this.loadedCount, this.totalDiscovered),
inFlightCount: this.inFlight.size,
status: this.status,
loadedFiles: Array.from(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)
}
}
}