feat(ui): replicate authentic D2 1.13c skill tree tabs and skill points banner (Fixes #377)

This commit is contained in:
troytt 2026-09-22 09:50:40 +00:00
parent 43748eac53
commit 0c4af3770d
7 changed files with 283 additions and 11 deletions

View File

@ -1,6 +1,6 @@
{
"version": "1.13c",
"decodedDc6Count": 466,
"decodedDc6Count": 467,
"dc6DecodeFailures": 0,
"atlasWidth": 1024,
"atlasHeight": 1938,
@ -4217,6 +4217,7 @@
"levelBtn": "/ui/level-btn.png",
"levelSocket": "/ui/level-socket.png",
"skillPoints": "/ui/skillpoints.png",
"skillTabs": "/ui/skill-tree-tabs.png",
"genericSkills": "/ui/generic-skills.png",
"attackIcon": "/ui/icon_0.png",
"cursorHand": "/ui/cursor-hand.png",

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View File

@ -358,6 +358,53 @@ async function main(): Promise<void> {
const skillPts = await decodeUiDc6('data/global/ui/Panel/skillpoints.dc6')
savePng('skillpoints.png', skillPts.groups[0]!.frames[0]!.width, skillPts.groups[0]!.frames[0]!.height, skillPts.groups[0]!.frames[0]!.indices)
const skltreeBack = await decodeUiDc6('data/global/ui/SPELLS/skltree_s_back.DC6')
const stF1 = skltreeBack.groups[0]!.frames[1]!
const stF3 = skltreeBack.groups[0]!.frames[3]!
const stF9 = skltreeBack.groups[0]!.frames[9]!
const stF11 = skltreeBack.groups[0]!.frames[11]!
const stF13 = skltreeBack.groups[0]!.frames[13]!
const sttPixels = new Uint8Array(192 * 432)
const pasteTabCol = (colIdx: number) => {
const ox = colIdx * 64
for (let y = 0; y < 256; y++) {
for (let x = 0; x < 64; x++) {
sttPixels[y * 192 + (ox + x)] = stF1.indices[y * 64 + x]!
}
}
for (let y = 0; y < 176; y++) {
for (let x = 0; x < 64; x++) {
sttPixels[(256 + y) * 192 + (ox + x)] = stF3.indices[y * 64 + x]!
}
}
}
// Col 0: Tab 2 active (Top / Cold)
pasteTabCol(0)
// Col 1: Tab 1 active (Middle / Lightning)
pasteTabCol(1)
for (let y = 108; y < 216; y++) {
for (let x = 0; x < 64; x++) {
const idx = stF13.indices[y * 64 + x]!
if (idx !== 0) sttPixels[y * 192 + (64 + x)] = idx
}
}
// Col 2: Tab 0 active (Bottom / Fire)
pasteTabCol(2)
for (let y = 216; y < 256; y++) {
for (let x = 0; x < 64; x++) {
const idx = stF9.indices[y * 64 + x]!
if (idx !== 0) sttPixels[y * 192 + (128 + x)] = idx
}
}
for (let y = 0; y < 68; y++) {
for (let x = 0; x < 64; x++) {
const idx = stF11.indices[y * 64 + x]!
if (idx !== 0) sttPixels[(256 + y) * 192 + (128 + x)] = idx
}
}
savePng('skill-tree-tabs.png', 192, 432, sttPixels)
const expQuestTabs = await decodeUiDc6('data/global/ui/Menu/expquesttabs.dc6')
const eqtStrip = stitchHorizontalStrip(expQuestTabs.groups[0]!.frames)
savePng('quest-tabs.png', eqtStrip.width, eqtStrip.height, eqtStrip.pixels)
@ -701,6 +748,7 @@ async function main(): Promise<void> {
levelBtn: '/ui/level-btn.png',
levelSocket: '/ui/level-socket.png',
skillPoints: '/ui/skillpoints.png',
skillTabs: '/ui/skill-tree-tabs.png',
genericSkills: '/ui/generic-skills.png',
attackIcon: '/ui/icon_0.png',
cursorHand: '/ui/cursor-hand.png',

View File

@ -81,7 +81,7 @@ export const ACT_QUEST_ASSET_MAP: Readonly<Record<number, readonly QuestAssetInf
export const BAKED_UI_MANIFEST: BakedUiManifest = {
version: '1.13c',
decodedDc6Count: 466,
decodedDc6Count: 467,
dc6DecodeFailures: 0,
atlasWidth: 1024,
atlasHeight: 1938,
@ -4298,6 +4298,7 @@ export const BAKED_UI_MANIFEST: BakedUiManifest = {
"levelBtn": "/ui/level-btn.png",
"levelSocket": "/ui/level-socket.png",
"skillPoints": "/ui/skillpoints.png",
"skillTabs": "/ui/skill-tree-tabs.png",
"genericSkills": "/ui/generic-skills.png",
"attackIcon": "/ui/icon_0.png",
"cursorHand": "/ui/cursor-hand.png",

View File

@ -337,6 +337,7 @@ export class HudManager {
levelBtn: `${baseUrl}/level-btn.png`,
levelSocket: `${baseUrl}/level-socket.png`,
skillPoints: `${baseUrl}/skillpoints.png`,
skillTabs: `${baseUrl}/skill-tree-tabs.png`,
genericSkills: `${baseUrl}/generic-skills.png`,
cursorHand: `${baseUrl}/cursor-hand.png`,
cursorAtlas: `${baseUrl}/cursor.png`,
@ -889,6 +890,7 @@ export class HudManager {
{
borderRightImg: this.images.get('borderRight') ?? null,
skillPointsImg: this.images.get('skillPoints') ?? null,
skillTabsImg: this.images.get('skillTabs') ?? null,
buySellBtnImg: this.images.get('buySellBtn') ?? null,
},
this.font,

View File

@ -228,6 +228,7 @@ export class SkillTreePanel {
borderRightImg: HTMLImageElement | null
skillPointsImg: HTMLImageElement | null
buySellBtnImg: HTMLImageElement | null
skillTabsImg?: HTMLImageElement | null
},
font: D2FontRenderer,
): void {
@ -251,10 +252,16 @@ export class SkillTreePanel {
}
// 3. Draw Right 64x432 Vertical Tab Column (`x = 656..720`)
if (assets.skillTabsImg && assets.skillTabsImg.complete && assets.skillTabsImg.naturalWidth > 0) {
// 0 = Cold (Tab 2), 1 = Lightning (Tab 1), 2 = Fire (Tab 0)
const tabColIdx = this.activeTab === 2 ? 0 : this.activeTab === 1 ? 1 : 2
ctx.drawImage(assets.skillTabsImg, tabColIdx * 64, 0, 64, 432, ox + 256, oy, 64, 432)
} else {
ctx.fillStyle = '#14110d'
ctx.fillRect(ox + 256, oy, 64, 432)
ctx.strokeStyle = '#5a4a32'
ctx.strokeRect(ox + 256.5, oy + 0.5, 63, 431)
}
// Remaining Skill Points Header Box
font.drawText(ctx, '剩余技能点', ox + 288, oy + 26, {
@ -272,6 +279,18 @@ export class SkillTreePanel {
align: 'center',
})
// Remaining Skill Points Banner (Authentic D2 skillpoints.dc6 banner at bottom of tree)
if (this.unspentSkillPoints > 0 && assets.skillPointsImg && assets.skillPointsImg.complete && assets.skillPointsImg.naturalWidth > 0) {
const spX = ox + Math.round((256 - 135) / 2)
const spY = oy + 395
ctx.drawImage(assets.skillPointsImg, spX, spY, 135, 23)
font.drawText(ctx, String(this.unspentSkillPoints), spX + 112, spY + 16, {
font: 'font16',
color: 'red',
align: 'center',
})
}
// 3 Vertical Tabs: Cold (2), Lightning (1), Fire (0)
const verticalTabs: readonly { tab: 0 | 1 | 2; titleZh: string; subZh: string; y: number }[] = [
{ tab: 2, titleZh: '冰冷', subZh: '法术', y: 95 },
@ -280,11 +299,14 @@ export class SkillTreePanel {
]
for (const vt of verticalTabs) {
const active = this.activeTab === vt.tab
ctx.fillStyle = active ? '#32281c' : '#1b1610'
if (!assets.skillTabsImg || !assets.skillTabsImg.complete || assets.skillTabsImg.naturalWidth === 0) {
// Authentic stone-beveled fallback when texture atlas is unavailable
ctx.fillStyle = active ? '#382e22' : '#1b1610'
ctx.fillRect(ox + 258, oy + vt.y, 60, 96)
ctx.strokeStyle = active ? '#e8c26b' : '#4a3c28'
ctx.strokeStyle = active ? '#c8a15a' : '#4a3c28'
ctx.lineWidth = active ? 2 : 1
ctx.strokeRect(ox + 258.5, oy + vt.y + 0.5, 59, 95)
}
font.drawText(ctx, vt.titleZh, ox + 288, oy + vt.y + 42, {
font: 'fontexocet10',
color: active ? 'gold' : 'tan',

View File

@ -0,0 +1,198 @@
import { describe, it, expect, vi } from 'vitest'
import { existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { SkillTreePanel, SKILL_PANEL_ORIGIN } from '../src/ui/skill-tree-panel.ts'
import { BAKED_UI_MANIFEST } from '../src/ui/baked-ui-meta.ts'
import type { D2FontRenderer } from '../src/ui/font.ts'
describe('Issue #377: Authentic D2 v1.13c Skill Tree Tabs & Skill Points Banner', () => {
it('manifest contains skillTabs and skillPoints image references', () => {
expect(BAKED_UI_MANIFEST.images.skillTabs).toBe('/ui/skill-tree-tabs.png')
expect(BAKED_UI_MANIFEST.images.skillPoints).toBe('/ui/skillpoints.png')
const tabsPath = join(process.cwd(), 'public', 'ui', 'skill-tree-tabs.png')
const spPath = join(process.cwd(), 'public', 'ui', 'skillpoints.png')
expect(existsSync(tabsPath), 'skill-tree-tabs.png must exist in public/ui').toBe(true)
expect(existsSync(spPath), 'skillpoints.png must exist in public/ui').toBe(true)
})
it('skill-tree-tabs.png has authentic 192x432 strip dimensions (3 states x 64x432)', () => {
const tabsPath = join(process.cwd(), 'public', 'ui', 'skill-tree-tabs.png')
const buf = readFileSync(tabsPath)
// PNG width and height at bytes 16..24
const width = buf.readUInt32BE(16)
const height = buf.readUInt32BE(20)
expect(width).toBe(192)
expect(height).toBe(432)
})
it('skillpoints.png has authentic 135x23 dimensions', () => {
const spPath = join(process.cwd(), 'public', 'ui', 'skillpoints.png')
const buf = readFileSync(spPath)
const width = buf.readUInt32BE(16)
const height = buf.readUInt32BE(20)
expect(width).toBe(135)
expect(height).toBe(23)
})
it('draws authentic 64x432 tab column slice according to activeTab', () => {
const panel = new SkillTreePanel()
panel.visible = true
const mockCtx = {
save: vi.fn(),
restore: vi.fn(),
drawImage: vi.fn(),
fillRect: vi.fn(),
strokeRect: vi.fn(),
fillStyle: '',
strokeStyle: '',
lineWidth: 1,
globalAlpha: 1,
} as unknown as CanvasRenderingContext2D
const mockFont = {
drawText: vi.fn(),
measureText: vi.fn(() => 50),
} as unknown as D2FontRenderer
const mockTabsImg = {
complete: true,
naturalWidth: 192,
naturalHeight: 432,
} as unknown as HTMLImageElement
const mockSpImg = {
complete: true,
naturalWidth: 135,
naturalHeight: 23,
} as unknown as HTMLImageElement
// 1. Tab 2 (Cold Spells, Top Tab): slice sx = 0
panel.activeTab = 2
panel.draw(mockCtx, {
borderRightImg: null,
skillPointsImg: mockSpImg,
buySellBtnImg: null,
skillTabsImg: mockTabsImg,
}, mockFont)
expect(mockCtx.drawImage).toHaveBeenCalledWith(
mockTabsImg,
0, // sx = 0
0,
64,
432,
SKILL_PANEL_ORIGIN.x + 256,
SKILL_PANEL_ORIGIN.y,
64,
432,
)
// 2. Tab 1 (Lightning Spells, Middle Tab): slice sx = 64
vi.clearAllMocks()
panel.activeTab = 1
panel.draw(mockCtx, {
borderRightImg: null,
skillPointsImg: mockSpImg,
buySellBtnImg: null,
skillTabsImg: mockTabsImg,
}, mockFont)
expect(mockCtx.drawImage).toHaveBeenCalledWith(
mockTabsImg,
64, // sx = 64
0,
64,
432,
SKILL_PANEL_ORIGIN.x + 256,
SKILL_PANEL_ORIGIN.y,
64,
432,
)
// 3. Tab 0 (Fire Spells, Bottom Tab): slice sx = 128
vi.clearAllMocks()
panel.activeTab = 0
panel.draw(mockCtx, {
borderRightImg: null,
skillPointsImg: mockSpImg,
buySellBtnImg: null,
skillTabsImg: mockTabsImg,
}, mockFont)
expect(mockCtx.drawImage).toHaveBeenCalledWith(
mockTabsImg,
128, // sx = 128
0,
64,
432,
SKILL_PANEL_ORIGIN.x + 256,
SKILL_PANEL_ORIGIN.y,
64,
432,
)
})
it('draws authentic skillpoints.png banner when unspentSkillPoints > 0', () => {
const panel = new SkillTreePanel()
panel.visible = true
panel.unspentSkillPoints = 5
const mockCtx = {
save: vi.fn(),
restore: vi.fn(),
drawImage: vi.fn(),
fillRect: vi.fn(),
strokeRect: vi.fn(),
fillStyle: '',
strokeStyle: '',
lineWidth: 1,
globalAlpha: 1,
} as unknown as CanvasRenderingContext2D
const mockFont = {
drawText: vi.fn(),
measureText: vi.fn(() => 50),
} as unknown as D2FontRenderer
const mockSpImg = {
complete: true,
naturalWidth: 135,
naturalHeight: 23,
} as unknown as HTMLImageElement
panel.draw(mockCtx, {
borderRightImg: null,
skillPointsImg: mockSpImg,
buySellBtnImg: null,
}, mockFont)
// Verify skillpoints banner drawn at (ox + 60, oy + 395)
expect(mockCtx.drawImage).toHaveBeenCalledWith(
mockSpImg,
SKILL_PANEL_ORIGIN.x + Math.round((256 - 135) / 2),
SKILL_PANEL_ORIGIN.y + 395,
135,
23,
)
// Verify when unspentSkillPoints = 0, banner is NOT drawn
vi.clearAllMocks()
panel.unspentSkillPoints = 0
panel.draw(mockCtx, {
borderRightImg: null,
skillPointsImg: mockSpImg,
buySellBtnImg: null,
}, mockFont)
expect(mockCtx.drawImage).not.toHaveBeenCalledWith(
mockSpImg,
expect.anything(),
expect.anything(),
expect.anything(),
expect.anything(),
)
})
})