From 5977d81c2d4e500bc586577743ccf98db9d5add3 Mon Sep 17 00:00:00 2001
From: troytt <47798984@qq.com>
Date: Thu, 1 Oct 2026 13:28:45 +0000
Subject: [PATCH] feat(client): display semantic asset resource type instead of
filename during boot
---
play.html | 4 +-
src/client/main.ts | 8 +-
src/client/ui/boot-progress.ts | 195 +++++++++++++++++++++++++++++----
tests/boot-progress.test.ts | 61 ++++++++---
4 files changed, 225 insertions(+), 43 deletions(-)
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)