From 0c4af3770db7aed06e6e68ca7d11d4e62eb54cf8 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Tue, 22 Sep 2026 09:50:40 +0000 Subject: [PATCH] feat(ui): replicate authentic D2 1.13c skill tree tabs and skill points banner (Fixes #377) --- public/ui/manifest.json | 3 +- public/ui/skill-tree-tabs.png | Bin 0 -> 28768 bytes scripts/pack-ui.ts | 48 ++++++++ src/ui/baked-ui-meta.ts | 3 +- src/ui/hud-manager.ts | 2 + src/ui/skill-tree-panel.ts | 40 +++++-- tests/skill-tree-panel.test.ts | 198 +++++++++++++++++++++++++++++++++ 7 files changed, 283 insertions(+), 11 deletions(-) create mode 100644 public/ui/skill-tree-tabs.png create mode 100644 tests/skill-tree-panel.test.ts diff --git a/public/ui/manifest.json b/public/ui/manifest.json index 0fed377..b1add97 100644 --- a/public/ui/manifest.json +++ b/public/ui/manifest.json @@ -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", diff --git a/public/ui/skill-tree-tabs.png b/public/ui/skill-tree-tabs.png new file mode 100644 index 0000000000000000000000000000000000000000..508e870d698d33215a762a05e7c6514342b6638b GIT binary patch literal 28768 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 literal 0 HcmV?d00001 diff --git a/scripts/pack-ui.ts b/scripts/pack-ui.ts index 742ed28..60ecf9a 100644 --- a/scripts/pack-ui.ts +++ b/scripts/pack-ui.ts @@ -358,6 +358,53 @@ async function main(): Promise { 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 { 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', diff --git a/src/ui/baked-ui-meta.ts b/src/ui/baked-ui-meta.ts index 631c28e..86c0bcb 100644 --- a/src/ui/baked-ui-meta.ts +++ b/src/ui/baked-ui-meta.ts @@ -81,7 +81,7 @@ export const ACT_QUEST_ASSET_MAP: Readonly 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' - ctx.fillRect(ox + 258, oy + vt.y, 60, 96) - ctx.strokeStyle = active ? '#e8c26b' : '#4a3c28' - ctx.lineWidth = active ? 2 : 1 - ctx.strokeRect(ox + 258.5, oy + vt.y + 0.5, 59, 95) + 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 ? '#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', diff --git a/tests/skill-tree-panel.test.ts b/tests/skill-tree-panel.test.ts new file mode 100644 index 0000000..133e248 --- /dev/null +++ b/tests/skill-tree-panel.test.ts @@ -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(), + ) + }) +})