fix(ui): 对齐技能树剩余技能点凹槽、移除底部冗余横幅并归位关闭按钮插槽

This commit is contained in:
troytt 2026-09-27 05:50:42 +00:00
parent e9c3b41768
commit f3c500978d
2 changed files with 175 additions and 72 deletions

View File

@ -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<CharacterClassCode, readonly [number, number, number]> = {
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_<tab>.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,
)

View File

@ -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)
}
}
})
})