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 0000000..508e870 Binary files /dev/null and b/public/ui/skill-tree-tabs.png differ 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(), + ) + }) +})