diff --git a/play.html b/play.html index 4c16fac..1f78bea 100644 --- a/play.html +++ b/play.html @@ -377,7 +377,7 @@ #d2-boot-progress-file { color: #f0dfb8; - font-family: monospace; + font-family: var(--font-body); font-size: 12px; max-width: 290px; overflow: hidden; @@ -422,7 +422,7 @@
正在下载: - initializing... + 正在初始化... (0/0)
diff --git a/src/client/main.ts b/src/client/main.ts index 5a13693..1587ac1 100644 --- a/src/client/main.ts +++ b/src/client/main.ts @@ -238,7 +238,7 @@ async function bootPlayApp(): Promise { onPanAutomap: (dx, dy) => minimap.pan(dx, dy), }) await hudManager.load('/ui') - bootProgress.setStageFloor(25, 'manifest.json') + bootProgress.setStageFloor(25, '游戏界面资源') // Fail fast: without the packed act tiles there is no 1.13c map (automap, collision, picking). let mapService: MapService @@ -247,7 +247,7 @@ async function bootPlayApp(): Promise { } catch (err) { throw new Error(`MapService: failed to load /act-packs/tiles: ${err instanceof Error ? err.message : String(err)}`) } - bootProgress.setStageFloor(45, 'drlg-bundle.bin') + bootProgress.setStageFloor(45, '地图地形资源') // Kick off asynchronous GPU atlas preloading for WebGL2 world rendering tileGpuCache.ensureActPalette(1) @@ -679,9 +679,9 @@ async function bootPlayApp(): Promise { }) await frontendRenderer.load('/ui/frontend') - bootProgress.setStageFloor(78, 'bg-main-menu.png') + bootProgress.setStageFloor(78, '主菜单与大厅界面') await assetsReadyPromise - bootProgress.setStageFloor(96, 'items-atlas.png') + bootProgress.setStageFloor(96, '物品图标资源') await bootProgress.waitForIdle(2500) bootProgress.complete() diff --git a/src/client/ui/boot-progress.ts b/src/client/ui/boot-progress.ts index 1be90de..8032e88 100644 --- a/src/client/ui/boot-progress.ts +++ b/src/client/ui/boot-progress.ts @@ -8,14 +8,16 @@ * * Renders: * 1. Total download progress percentage (`0%` – `100%`, strictly monotonic). - * 2. The filename of the resource currently being downloaded (`currentFile`). + * 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 - /** Basename of the resource currently being downloaded (e.g. `drlg-bundle.bin`, `items-atlas.png`). */ + /** 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. */ @@ -26,7 +28,7 @@ export interface BootProgressSnapshot { readonly inFlightCount: number /** Lifecycle status of the boot progress tracker. */ readonly status: 'idle' | 'loading' | 'completed' - /** Ordered list of resource filenames that have completed downloading. */ + /** Ordered list of resource types that have completed downloading. */ readonly loadedFiles: readonly string[] } @@ -35,7 +37,7 @@ export interface BootProgressOptions { 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. */ + /** 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 @@ -44,6 +46,7 @@ export interface BootProgressOptions { interface InFlightEntry { readonly id: number readonly fileName: string + readonly resourceType: string readonly filePath: string readonly promise: Promise readonly resolve: () => void @@ -58,9 +61,29 @@ interface InFlightEntry { */ 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 (`#...`). + * Strips query parameters and hash fragments. * * Examples: * - `/act-packs/tiles/drlg-bundle.bin` -> `drlg-bundle.bin` @@ -108,6 +131,133 @@ export function normalizeAssetPath(urlOrPath: string): string { 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 @@ -139,7 +289,7 @@ export class BootProgressTracker { 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.currentFile = options.initialFileLabel ?? '正在初始化...' this.hideOnComplete = options.hideOnComplete ?? true this.bindOrCreateDom() } @@ -164,7 +314,7 @@ export class BootProgressTracker { ' ', '
', ' 正在下载:', - ' initializing...', + ' 正在初始化...', ' (0/0)', '
', '', @@ -337,13 +487,14 @@ export class BootProgressTracker { } /** - * Records the start of a resource download and updates the active filename & percentage. + * 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') { @@ -360,12 +511,13 @@ export class BootProgressTracker { this.inFlight.set(id, { id, fileName, + resourceType, filePath, promise, resolve: resolveFn, }) - this.currentFile = fileName + this.currentFile = resourceType this.currentFilePath = filePath this.recalculatePercent() this.renderDom() @@ -385,21 +537,21 @@ export class BootProgressTracker { if (this.status === 'completed') return this.loadedCount++ - this.loadedFiles.push(entry.fileName) + this.loadedFiles.push(entry.resourceType) - // If other files are still downloading, show the most recently started in-flight file; - // otherwise keep the just-finished file name visible. + // 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.fileName + this.currentFile = latest.resourceType this.currentFilePath = latest.filePath } } else { - this.currentFile = entry.fileName + this.currentFile = entry.resourceType this.currentFilePath = entry.filePath } @@ -431,8 +583,8 @@ export class BootProgressTracker { if (clamped > this.stageFloorPercent) { this.stageFloorPercent = clamped } - if (fallbackFile && (!this.currentFile || this.currentFile === 'initializing...')) { - this.currentFile = extractAssetFileName(fallbackFile) || fallbackFile + if (fallbackFile && (!this.currentFile || INITIAL_PLACEHOLDER_LABELS.has(this.currentFile))) { + this.currentFile = extractAssetResourceType(fallbackFile) || fallbackFile this.currentFilePath = normalizeAssetPath(fallbackFile) || this.currentFile } this.recalculatePercent() @@ -462,7 +614,7 @@ export class BootProgressTracker { complete(): void { for (const entry of this.inFlight.values()) { this.loadedCount++ - this.loadedFiles.push(entry.fileName) + this.loadedFiles.push(entry.resourceType) entry.resolve() } this.inFlight.clear() @@ -470,8 +622,8 @@ export class BootProgressTracker { this.uninstall() this.status = 'completed' this.percent = 100 - if (!this.currentFile || this.currentFile === 'initializing...') { - this.currentFile = this.loadedFiles[this.loadedFiles.length - 1] ?? 'play.html' + if (!this.currentFile || INITIAL_PLACEHOLDER_LABELS.has(this.currentFile)) { + this.currentFile = this.loadedFiles[this.loadedFiles.length - 1] ?? '游戏资源' } this.renderDom() if (this.overlayEl) { @@ -508,7 +660,7 @@ export class BootProgressTracker { } if (this.fileEl) { this.fileEl.textContent = this.currentFile - this.fileEl.setAttribute('title', this.currentFilePath || this.currentFile) + this.fileEl.setAttribute('title', this.currentFile) } if (this.countEl) { this.countEl.textContent = `(${this.loadedCount}/${totalDisplay})` @@ -530,12 +682,13 @@ export class BootProgressTracker { 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: [...this.loadedFiles], + loadedFiles: Array.from(this.loadedFiles), } } diff --git a/tests/boot-progress.test.ts b/tests/boot-progress.test.ts index 3dd3561..4a75ce5 100644 --- a/tests/boot-progress.test.ts +++ b/tests/boot-progress.test.ts @@ -4,6 +4,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { BootProgressTracker, extractAssetFileName, + extractAssetResourceType, normalizeAssetPath, type BootProgressSnapshot, } from '../src/client/ui/boot-progress.ts' @@ -124,6 +125,32 @@ describe('Client Boot Resource Download Progress Bar (boot-progress)', () => { expect(extractAssetFileName('')).toBe('') }) + it('extractAssetResourceType classifies asset paths into semantic resource types instead of raw filenames', () => { + expect(extractAssetResourceType('/ui/fonts/metrics.json')).toBe('字体资源') + expect(extractAssetResourceType('/ui/frontend/fonts/fontformal12-c4.png')).toBe('字体资源') + expect(extractAssetResourceType('/act-packs/tiles/drlg-bundle.bin')).toBe('地图地形资源') + expect(extractAssetResourceType('data\\global\\tiles\\act1\\town\\town.dt1')).toBe('地图地形资源') + expect(extractAssetResourceType('http://127.0.0.1:5173/ui/frontend/bg-main-menu.png#top')).toBe( + '主菜单与大厅界面', + ) + expect(extractAssetResourceType('/ui/ctrlpnl-800.png')).toBe('游戏界面资源') + expect(extractAssetResourceType('samples/d2-packs/items/items-atlas.png?v=123')).toBe( + '物品图标资源', + ) + expect(extractAssetResourceType('/skills/icon_36.png')).toBe('技能图标资源') + expect(extractAssetResourceType('/assets/ui/automap/MaxiMap.png')).toBe('小地图资源') + expect(extractAssetResourceType('samples/d2-packs/entities/char-so.r8')).toBe('角色美术资源') + expect(extractAssetResourceType('samples/d2-packs/entities/monster-fallen1-hth.r8')).toBe( + '怪物美术资源', + ) + expect(extractAssetResourceType('/missiles/firebolt.png')).toBe('投射物特效资源') + expect(extractAssetResourceType('/overlays/AuraDefianceBack.png')).toBe('光环与状态特效') + expect(extractAssetResourceType('./asset-manifest.json')).toBe('资源配置数据') + expect(extractAssetResourceType('data:image/png;base64,iVBORw0KGgo=')).toBe('') + expect(extractAssetResourceType('blob:http://127.0.0.1:5173/abc-123')).toBe('') + expect(extractAssetResourceType('')).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', @@ -131,7 +158,7 @@ describe('Client Boot Resource Download Progress Bar (boot-progress)', () => { 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', () => { + it('tracks downloading resource types (instead of raw filenames), monotonic percentage (0% -> 100%), and updates DOM elements', () => { const dom = createMockDomEnvironment() cleanupDom = dom.cleanup @@ -151,19 +178,21 @@ describe('Client Boot Resource Download Progress Bar (boot-progress)', () => { expect(overlayEl.hidden).toBe(false) expect(percentEl.textContent).toBe('0%') - // Start downloading file 1 & file 2 concurrently + // Start downloading file 1 & file 2 concurrently -> displays resource types instead of filenames const id1 = tracker.beginFile('/ui/fonts/metrics.json') - expect(tracker.getSnapshot().currentFile).toBe('metrics.json') - expect(fileEl.textContent).toBe('metrics.json') + expect(tracker.getSnapshot().currentFile).toBe('字体资源') + expect(tracker.getSnapshot().currentResourceType).toBe('字体资源') + expect(fileEl.textContent).toBe('字体资源') + expect(fileEl.getAttribute('title')).toBe('字体资源') 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') + expect(tracker.getSnapshot().currentFile).toBe('地图地形资源') + expect(fileEl.textContent).toBe('地图地形资源') - // Finish file 2 while file 1 is still in flight -> currentFile falls back to in-flight file 1 + // Finish file 2 while file 1 is still in flight -> currentFile falls back to in-flight file 1's resource type tracker.endFile(id2) expect(tracker.getSnapshot().percent).toBe(25) - expect(tracker.getSnapshot().currentFile).toBe('metrics.json') + expect(tracker.getSnapshot().currentFile).toBe('字体资源') expect(percentEl.textContent).toBe('25%') expect(fillEl.style.width).toBe('25%') @@ -174,12 +203,12 @@ describe('Client Boot Resource Download Progress Bar (boot-progress)', () => { // Start & finish file 3 & file 4 const id3 = tracker.beginFile('/ui/frontend/bg-main-menu.png') - expect(fileEl.textContent).toBe('bg-main-menu.png') + expect(fileEl.textContent).toBe('主菜单与大厅界面') 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') + expect(fileEl.textContent).toBe('物品图标资源') tracker.endFile(id4) expect(tracker.getSnapshot().percent).toBe(99) @@ -190,10 +219,10 @@ describe('Client Boot Resource Download Progress Bar (boot-progress)', () => { 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%') @@ -228,13 +257,13 @@ describe('Client Boot Resource Download Progress Bar (boot-progress)', () => { expect(globalThis.fetch).not.toBe(mockFetch) const res1 = await globalThis.fetch('/act-packs/tiles/index.json') - expect(tracker.getSnapshot().currentFile).toBe('index.json') + expect(tracker.getSnapshot().currentFile).toBe('地图地形资源') 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') + expect(tracker.getSnapshot().currentFile).toBe('主菜单与大厅界面') await res2.json() expect(tracker.getSnapshot().loadedCount).toBe(2)