fix(ui): 对齐技能树剩余技能点凹槽、移除底部冗余横幅并归位关闭按钮插槽
This commit is contained in:
parent
e9c3b41768
commit
f3c500978d
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue