feat(ui): replicate authentic D2 1.13c skill tree tabs and skill points banner (Fixes #377)
This commit is contained in:
parent
43748eac53
commit
0c4af3770d
|
|
@ -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 |
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue