diff --git a/src/ui/skill-tree-panel.ts b/src/ui/skill-tree-panel.ts index 81d5f80..92970f3 100644 --- a/src/ui/skill-tree-panel.ts +++ b/src/ui/skill-tree-panel.ts @@ -41,7 +41,22 @@ export interface SkillNodeDef { } export const SKILL_PANEL_ORIGIN = { x: 400, y: 60, width: 320, height: 432 } as const -export const SKILL_CLOSE_BTN_BOUNDS = { x: 400 + 272, y: 60 + 388, w: 32, h: 32 } as const + +/** + * Authentic D2 v1.13c (`skltree_*_back.DC6` frames 6, 10, 14) 32x32 Close Button socket X offsets + * per class and tab `[tab0, tab1, tab2]` relative to `SKILL_PANEL_ORIGIN.x` (Y offset is always +385). + */ +export const SKILL_CLOSE_BTN_OFFSET_X: Record = { + ama: [171, 101, 16], + sor: [16, 16, 171], + nec: [16, 171, 16], + pal: [16, 101, 16], + bar: [171, 16, 171], + dru: [171, 171, 171], + ass: [101, 171, 16], +} + +export const SKILL_CLOSE_BTN_BOUNDS = { x: 400 + 171, y: 60 + 385, w: 32, h: 32 } as const /** Authentic D2Client.dll 1.13c ground truth grid offsets inside panel (ox=400, oy=60). */ export const SKILL_TREE_COL_OFFSETS_X = [15, 84, 153] as const @@ -287,6 +302,17 @@ export class SkillTreePanel { return { x, y, w: 48, h: 48 } } + getCloseButtonBounds(): { x: number; y: number; w: number; h: number } { + const offsets = SKILL_CLOSE_BTN_OFFSET_X[this.characterClass] ?? SKILL_CLOSE_BTN_OFFSET_X.sor + const offsetX = offsets[this.activeTab] ?? 171 + return { + x: SKILL_PANEL_ORIGIN.x + offsetX, + y: SKILL_PANEL_ORIGIN.y + 385, + w: 32, + h: 32, + } + } + handleMouseMove(logicalX: number, logicalY: number): void { this.hoveredSkill = null if (!this.visible) return @@ -304,19 +330,20 @@ export class SkillTreePanel { if (!this.visible) return false if (logicalX < 400) return false - // Close button + // Close button (authentic per-class/per-tab 32x32 stone socket at y = oy + 385) + const closeBounds = this.getCloseButtonBounds() if ( - logicalX >= SKILL_CLOSE_BTN_BOUNDS.x && - logicalX <= SKILL_CLOSE_BTN_BOUNDS.x + SKILL_CLOSE_BTN_BOUNDS.w && - logicalY >= SKILL_CLOSE_BTN_BOUNDS.y && - logicalY <= SKILL_CLOSE_BTN_BOUNDS.y + SKILL_CLOSE_BTN_BOUNDS.h + logicalX >= closeBounds.x && + logicalX <= closeBounds.x + closeBounds.w && + logicalY >= closeBounds.y && + logicalY <= closeBounds.y + closeBounds.h ) { this.visible = false return true } - // Right vertical tabs (`x = 656..720`) - if (logicalX >= 656 && logicalX <= 720) { + // Right vertical tabs (`x = 630..720`, full 90px tab column `ox + 230..320`) + if (logicalX >= SKILL_PANEL_ORIGIN.x + 230 && logicalX <= SKILL_PANEL_ORIGIN.x + 320) { const vTabs = this.getVerticalTabs() if (logicalY >= 60 + 95 && logicalY < 60 + 195 && vTabs[0]) { this.activeTab = vTabs[0].tab @@ -326,7 +353,7 @@ export class SkillTreePanel { this.activeTab = vTabs[1].tab return true } - if (logicalY >= 60 + 295 && logicalY < 60 + 385 && vTabs[2]) { + if (logicalY >= 60 + 295 && logicalY <= 60 + 432 && vTabs[2]) { this.activeTab = vTabs[2].tab return true } @@ -369,7 +396,8 @@ export class SkillTreePanel { // 2. Draw 256x432 Skill Tree Tab Background (`bg_sor_.png`) const bg = this.tabBgs.get(this.activeTab) - if (bg && bg.complete && bg.naturalWidth > 0) { + const hasBg = Boolean(bg && bg.complete && bg.naturalWidth > 0) + if (hasBg && bg) { ctx.drawImage(bg, ox, oy, 256, 432) } else { ctx.fillStyle = '#181410' @@ -381,7 +409,10 @@ export class SkillTreePanel { const tabColIdx = verticalTabs.findIndex(vt => vt.tab === this.activeTab) const safeColIdx = tabColIdx >= 0 ? tabColIdx : 0 - if (assets.skillTabsImg && assets.skillTabsImg.complete && assets.skillTabsImg.naturalWidth > 0) { + const hasTabsImg = Boolean( + assets.skillTabsImg && assets.skillTabsImg.complete && assets.skillTabsImg.naturalWidth > 0, + ) + if (hasTabsImg && assets.skillTabsImg) { ctx.drawImage(assets.skillTabsImg, safeColIdx * 64, 0, 64, 432, ox + 256, oy, 64, 432) } else { ctx.fillStyle = '#14110d' @@ -390,38 +421,28 @@ export class SkillTreePanel { ctx.strokeRect(ox + 256.5, oy + 0.5, 63, 431) } - // Remaining Skill Points Header Box - font.drawText(ctx, '剩余技能点', ox + 288, oy + 26, { + // Remaining Skill Points Header Plaque (built-in DC6 recessed stone box at x=255..297, y=61..83) + font.drawText(ctx, '剩余技能点', ox + 276, oy + 38, { font: 'font8', color: 'gold', align: 'center', }) - ctx.fillStyle = '#080605' - ctx.fillRect(ox + 270, oy + 34, 36, 24) - ctx.strokeStyle = '#8c7244' - ctx.strokeRect(ox + 270.5, oy + 34.5, 35, 23) - font.drawText(ctx, String(this.unspentSkillPoints), ox + 288, oy + 52, { + if (!hasTabsImg) { + ctx.fillStyle = '#080605' + ctx.fillRect(ox + 255, oy + 61, 43, 23) + ctx.strokeStyle = '#8c7244' + ctx.strokeRect(ox + 255.5, oy + 61.5, 42, 22) + } + font.drawText(ctx, String(this.unspentSkillPoints), ox + 276, oy + 78, { font: 'font16', color: this.unspentSkillPoints > 0 ? 'red' : 'white', 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 for Active Class + // 3 Vertical Tabs for Active Class (90px column x=230..319, center ox + 275) for (const vt of verticalTabs) { const active = this.activeTab === vt.tab - if (!assets.skillTabsImg || !assets.skillTabsImg.complete || assets.skillTabsImg.naturalWidth === 0) { + if (!hasTabsImg) { // Authentic stone-beveled fallback when texture atlas is unavailable ctx.fillStyle = active ? '#382e22' : '#1b1610' ctx.fillRect(ox + 258, oy + vt.y, 60, 96) @@ -429,12 +450,12 @@ export class SkillTreePanel { 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.drawText(ctx, vt.titleZh, ox + 275, oy + vt.y + 52, { font: 'fontexocet10', color: active ? 'gold' : 'tan', align: 'center', }) - font.drawText(ctx, vt.subZh, ox + 288, oy + vt.y + 62, { + font.drawText(ctx, vt.subZh, ox + 275, oy + vt.y + 72, { font: 'fontexocet10', color: active ? 'gold' : 'tan', align: 'center', @@ -465,28 +486,31 @@ export class SkillTreePanel { ctx.strokeRect(pos.x + 0.5, pos.y + 0.5, 47, 47) } - // Level Badge in bottom-right corner of the 48x48 skill icon - ctx.fillStyle = '#090705' - ctx.fillRect(pos.x + 34, pos.y + 34, 20, 16) - ctx.strokeStyle = '#6c5838' - ctx.strokeRect(pos.x + 34.5, pos.y + 34.5, 19, 15) - font.drawText(ctx, String(eff), pos.x + 44, pos.y + 46, { + // Level socket: built-in DC6 stone socket has dark interior at pos.x + 42..60, pos.y + 50..60 + if (!hasBg) { + ctx.fillStyle = '#090705' + ctx.fillRect(pos.x + 42, pos.y + 50, 19, 11) + ctx.strokeStyle = '#6c5838' + ctx.strokeRect(pos.x + 41.5, pos.y + 49.5, 20, 12) + } + font.drawText(ctx, String(eff), pos.x + 51, pos.y + 59, { font: 'font8', color: eff > hard ? 'blue' : hard > 0 ? 'white' : 'gray', align: 'center', }) } - // 5. Close Button (`buysellbtn.dc6` frame 10) + // 5. Close Button (`buysellbtn.dc6` frame 10 in authentic per-tab 32x32 DC6 stone socket) if (assets.buySellBtnImg) { + const closeBounds = this.getCloseButtonBounds() ctx.drawImage( assets.buySellBtnImg, 10 * 32, 0, 32, 32, - SKILL_CLOSE_BTN_BOUNDS.x, - SKILL_CLOSE_BTN_BOUNDS.y, + closeBounds.x, + closeBounds.y, 32, 32, ) diff --git a/tests/skill-tree-panel.test.ts b/tests/skill-tree-panel.test.ts index 133e248..1a78c2a 100644 --- a/tests/skill-tree-panel.test.ts +++ b/tests/skill-tree-panel.test.ts @@ -1,11 +1,16 @@ 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 { + SkillTreePanel, + SKILL_PANEL_ORIGIN, + SKILL_CLOSE_BTN_BOUNDS, + SKILL_CLOSE_BTN_OFFSET_X, +} 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', () => { +describe('Authentic D2 v1.13c Skill Tree Panel Layout & Alignment', () => { 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') @@ -27,15 +32,6 @@ describe('Issue #377: Authentic D2 v1.13c Skill Tree Tabs & Skill Points Banner' 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 @@ -135,10 +131,10 @@ describe('Issue #377: Authentic D2 v1.13c Skill Tree Tabs & Skill Points Banner' ) }) - it('draws authentic skillpoints.png banner when unspentSkillPoints > 0', () => { + it('aligns top-right remaining skill points inside built-in DC6 recessed box and omits redundant bottom skillpoints banner', () => { const panel = new SkillTreePanel() panel.visible = true - panel.unspentSkillPoints = 5 + panel.unspentSkillPoints = 12 const mockCtx = { save: vi.fn(), @@ -157,6 +153,12 @@ describe('Issue #377: Authentic D2 v1.13c Skill Tree Tabs & Skill Points Banner' 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, @@ -167,26 +169,26 @@ describe('Issue #377: Authentic D2 v1.13c Skill Tree Tabs & Skill Points Banner' borderRightImg: null, skillPointsImg: mockSpImg, buySellBtnImg: null, + skillTabsImg: mockTabsImg, }, 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, + // Top-right remaining skill points label and number aligned to recessed stone box center (ox + 276, oy + 78) + expect(mockFont.drawText).toHaveBeenCalledWith( + mockCtx, + '剩余技能点', + SKILL_PANEL_ORIGIN.x + 276, + SKILL_PANEL_ORIGIN.y + 38, + expect.objectContaining({ font: 'font8', color: 'gold', align: 'center' }), + ) + expect(mockFont.drawText).toHaveBeenCalledWith( + mockCtx, + '12', + SKILL_PANEL_ORIGIN.x + 276, + SKILL_PANEL_ORIGIN.y + 78, + expect.objectContaining({ font: 'font16', color: 'red', align: 'center' }), ) - // Verify when unspentSkillPoints = 0, banner is NOT drawn - vi.clearAllMocks() - panel.unspentSkillPoints = 0 - panel.draw(mockCtx, { - borderRightImg: null, - skillPointsImg: mockSpImg, - buySellBtnImg: null, - }, mockFont) - + // Redundant bottom skillpoints.png banner is NOT drawn expect(mockCtx.drawImage).not.toHaveBeenCalledWith( mockSpImg, expect.anything(), @@ -195,4 +197,81 @@ describe('Issue #377: Authentic D2 v1.13c Skill Tree Tabs & Skill Points Banner' expect.anything(), ) }) + + it('places and hit-tests the 32x32 Close Button inside the authentic per-tab DC6 stone socket', () => { + 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 mockBuySellImg = { + complete: true, + naturalWidth: 512, + naturalHeight: 32, + } as unknown as HTMLImageElement + + // Default Sorceress Cold tab (tab 2): socket at ox + 171, oy + 385 + expect(SKILL_CLOSE_BTN_BOUNDS).toEqual({ + x: SKILL_PANEL_ORIGIN.x + 171, + y: SKILL_PANEL_ORIGIN.y + 385, + w: 32, + h: 32, + }) + expect(panel.getCloseButtonBounds()).toEqual(SKILL_CLOSE_BTN_BOUNDS) + + panel.draw(mockCtx, { + borderRightImg: null, + skillPointsImg: null, + buySellBtnImg: mockBuySellImg, + }, mockFont) + + expect(mockCtx.drawImage).toHaveBeenCalledWith( + mockBuySellImg, + 10 * 32, + 0, + 32, + 32, + SKILL_PANEL_ORIGIN.x + 171, + SKILL_PANEL_ORIGIN.y + 385, + 32, + 32, + ) + + // Switch to Sorceress Fire tab (tab 0): socket at ox + 16, oy + 385 + panel.activeTab = 0 + expect(panel.getCloseButtonBounds()).toEqual({ + x: SKILL_PANEL_ORIGIN.x + 16, + y: SKILL_PANEL_ORIGIN.y + 385, + w: 32, + h: 32, + }) + + // Clicking inside the active tab's close button socket closes the panel + expect(panel.handleClick(SKILL_PANEL_ORIGIN.x + 16 + 16, SKILL_PANEL_ORIGIN.y + 385 + 16)).toBe(true) + expect(panel.visible).toBe(false) + + // Verify all 7 classes x 3 tabs have valid authentic socket X offsets (16, 101, or 171) + for (const offsets of Object.values(SKILL_CLOSE_BTN_OFFSET_X)) { + expect(offsets).toHaveLength(3) + for (const ox of offsets) { + expect([16, 101, 171]).toContain(ox) + } + } + }) }) +