feat(ui): authentic D2 1.13c waypoint tabs and icons (Fixes #378)
This commit is contained in:
parent
0c4af3770d
commit
6f3cf6b8bb
|
|
@ -338,6 +338,9 @@ export class HudManager {
|
||||||
levelSocket: `${baseUrl}/level-socket.png`,
|
levelSocket: `${baseUrl}/level-socket.png`,
|
||||||
skillPoints: `${baseUrl}/skillpoints.png`,
|
skillPoints: `${baseUrl}/skillpoints.png`,
|
||||||
skillTabs: `${baseUrl}/skill-tree-tabs.png`,
|
skillTabs: `${baseUrl}/skill-tree-tabs.png`,
|
||||||
|
waypointTabs: `${baseUrl}/waypoint-tabs.png`,
|
||||||
|
waypointIcons: `${baseUrl}/waypoint-icons.png`,
|
||||||
|
questTabs: `${baseUrl}/quest-tabs.png`,
|
||||||
genericSkills: `${baseUrl}/generic-skills.png`,
|
genericSkills: `${baseUrl}/generic-skills.png`,
|
||||||
cursorHand: `${baseUrl}/cursor-hand.png`,
|
cursorHand: `${baseUrl}/cursor-hand.png`,
|
||||||
cursorAtlas: `${baseUrl}/cursor.png`,
|
cursorAtlas: `${baseUrl}/cursor.png`,
|
||||||
|
|
@ -858,6 +861,9 @@ export class HudManager {
|
||||||
buySellBtnImg: this.images.get('buySellBtn') ?? null,
|
buySellBtnImg: this.images.get('buySellBtn') ?? null,
|
||||||
questsAtlasImg: this.images.get('questsAtlas') ?? null,
|
questsAtlasImg: this.images.get('questsAtlas') ?? null,
|
||||||
itemsAtlasImg: this.images.get('itemsAtlas') ?? null,
|
itemsAtlasImg: this.images.get('itemsAtlas') ?? null,
|
||||||
|
waypointTabsImg: this.images.get('waypointTabs') ?? null,
|
||||||
|
waypointIconsImg: this.images.get('waypointIcons') ?? null,
|
||||||
|
questTabsImg: this.images.get('questTabs') ?? null,
|
||||||
},
|
},
|
||||||
this.font,
|
this.font,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -200,6 +200,7 @@ export class WorldPanelsHud {
|
||||||
|
|
||||||
stashItems: GridPlacement[] = []
|
stashItems: GridPlacement[] = []
|
||||||
hoveredStashItem: { item: UiInventoryItem; x: number; y: number } | null = null
|
hoveredStashItem: { item: UiInventoryItem; x: number; y: number } | null = null
|
||||||
|
hoveredWaypointIdx: number | null = null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check whether an item of (w, h) can be placed at (col, row) in the 6x8 stash grid.
|
* Check whether an item of (w, h) can be placed at (col, row) in the 6x8 stash grid.
|
||||||
|
|
@ -308,6 +309,21 @@ export class WorldPanelsHud {
|
||||||
|
|
||||||
handleMouseMove(logicalX: number, logicalY: number): void {
|
handleMouseMove(logicalX: number, logicalY: number): void {
|
||||||
this.hoveredStashItem = null
|
this.hoveredStashItem = null
|
||||||
|
this.hoveredWaypointIdx = null
|
||||||
|
|
||||||
|
const ox = 80
|
||||||
|
const oy = 60
|
||||||
|
if (logicalX >= ox + 22 && logicalX <= ox + 298) {
|
||||||
|
const wps = ACT_WAYPOINTS[this.selectedActTab] ?? ACT_WAYPOINTS[1]!
|
||||||
|
for (let i = 0; i < wps.length; i++) {
|
||||||
|
const wy = oy + 62 + i * 36
|
||||||
|
if (logicalY >= wy && logicalY <= wy + 31) {
|
||||||
|
this.hoveredWaypointIdx = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
logicalX >= STASH_GRID_ORIGIN.x &&
|
logicalX >= STASH_GRID_ORIGIN.x &&
|
||||||
logicalX < STASH_GRID_ORIGIN.x + STASH_GRID_ORIGIN.cols * STASH_GRID_ORIGIN.cellPx &&
|
logicalX < STASH_GRID_ORIGIN.x + STASH_GRID_ORIGIN.cols * STASH_GRID_ORIGIN.cellPx &&
|
||||||
|
|
@ -383,6 +399,7 @@ export class WorldPanelsHud {
|
||||||
const wy = oy + 62 + i * 36
|
const wy = oy + 62 + i * 36
|
||||||
if (logicalX >= ox + 22 && logicalX <= ox + 298 && logicalY >= wy && logicalY <= wy + 32) {
|
if (logicalX >= ox + 22 && logicalX <= ox + 298 && logicalY >= wy && logicalY <= wy + 32) {
|
||||||
const wp = list[i]!
|
const wp = list[i]!
|
||||||
|
if (!wp.unlocked) return true
|
||||||
callbacks.onWaypointTeleport(wp.act, wp.slug, wp.levelId)
|
callbacks.onWaypointTeleport(wp.act, wp.slug, wp.levelId)
|
||||||
callbacks.onClose()
|
callbacks.onClose()
|
||||||
return true
|
return true
|
||||||
|
|
@ -473,6 +490,9 @@ export class WorldPanelsHud {
|
||||||
buySellBtnImg: HTMLImageElement | null
|
buySellBtnImg: HTMLImageElement | null
|
||||||
questsAtlasImg?: HTMLImageElement | null
|
questsAtlasImg?: HTMLImageElement | null
|
||||||
itemsAtlasImg?: HTMLImageElement | null
|
itemsAtlasImg?: HTMLImageElement | null
|
||||||
|
waypointTabsImg?: HTMLImageElement | null
|
||||||
|
waypointIconsImg?: HTMLImageElement | null
|
||||||
|
questTabsImg?: HTMLImageElement | null
|
||||||
},
|
},
|
||||||
font: D2FontRenderer,
|
font: D2FontRenderer,
|
||||||
): void {
|
): void {
|
||||||
|
|
@ -486,7 +506,7 @@ export class WorldPanelsHud {
|
||||||
|
|
||||||
if (kind === 'quest') {
|
if (kind === 'quest') {
|
||||||
if (assets.questBgImg) ctx.drawImage(assets.questBgImg, ox, oy)
|
if (assets.questBgImg) ctx.drawImage(assets.questBgImg, ox, oy)
|
||||||
this.drawActTabs(ctx, ox, oy, font)
|
this.drawActTabs(ctx, ox, oy, font, assets.questTabsImg)
|
||||||
|
|
||||||
const quests = ACT_QUESTS[this.selectedActTab] ?? ACT_QUESTS[1]!
|
const quests = ACT_QUESTS[this.selectedActTab] ?? ACT_QUESTS[1]!
|
||||||
if (this.selectedQuestIdx >= quests.length) {
|
if (this.selectedQuestIdx >= quests.length) {
|
||||||
|
|
@ -558,22 +578,36 @@ export class WorldPanelsHud {
|
||||||
}
|
}
|
||||||
} else if (kind === 'waypoint') {
|
} else if (kind === 'waypoint') {
|
||||||
if (assets.waypointBgImg) ctx.drawImage(assets.waypointBgImg, ox, oy)
|
if (assets.waypointBgImg) ctx.drawImage(assets.waypointBgImg, ox, oy)
|
||||||
this.drawActTabs(ctx, ox, oy, font)
|
this.drawActTabs(ctx, ox, oy, font, assets.waypointTabsImg)
|
||||||
const wps = ACT_WAYPOINTS[this.selectedActTab] ?? ACT_WAYPOINTS[1]!
|
const wps = ACT_WAYPOINTS[this.selectedActTab] ?? ACT_WAYPOINTS[1]!
|
||||||
|
const activeIconIdx = Math.max(0, Math.min(4, this.selectedActTab - 1))
|
||||||
|
const iconSx = activeIconIdx * 30
|
||||||
|
|
||||||
for (let i = 0; i < wps.length; i++) {
|
for (let i = 0; i < wps.length; i++) {
|
||||||
const wp = wps[i]!
|
const wp = wps[i]!
|
||||||
const wy = oy + 62 + i * 36
|
const wy = oy + 62 + i * 36
|
||||||
ctx.fillStyle = 'rgba(18, 16, 13, 0.85)'
|
const isHovered = this.hoveredWaypointIdx === i
|
||||||
|
|
||||||
|
ctx.fillStyle = isHovered ? 'rgba(42, 34, 22, 0.88)' : 'rgba(18, 16, 13, 0.85)'
|
||||||
ctx.fillRect(ox + 22, wy, 276, 31)
|
ctx.fillRect(ox + 22, wy, 276, 31)
|
||||||
ctx.strokeStyle = '#5a4a32'
|
ctx.strokeStyle = isHovered ? '#e8c26b' : '#5a4a32'
|
||||||
ctx.strokeRect(ox + 22.5, wy + 0.5, 275, 30)
|
ctx.strokeRect(ox + 22.5, wy + 0.5, 275, 30)
|
||||||
|
|
||||||
// Blue active waypoint diamond icon
|
// Authentic D2 waypoint icon from waygateicons.dc6 (30x30)
|
||||||
ctx.fillStyle = '#4888ff'
|
if (assets.waypointIconsImg && assets.waypointIconsImg.complete && assets.waypointIconsImg.naturalWidth > 0) {
|
||||||
ctx.fillRect(ox + 32, wy + 9, 12, 12)
|
ctx.save()
|
||||||
font.drawText(ctx, `${wp.nameZh} · ${wp.nameEn}`, ox + 56, wy + 21, {
|
if (!wp.unlocked) ctx.globalAlpha = 0.35
|
||||||
|
ctx.drawImage(assets.waypointIconsImg, iconSx, 0, 30, 30, ox + 24, wy + 1, 30, 30)
|
||||||
|
ctx.restore()
|
||||||
|
} else {
|
||||||
|
// Fallback stone diamond
|
||||||
|
ctx.fillStyle = wp.unlocked ? '#4888ff' : '#4a4a4a'
|
||||||
|
ctx.fillRect(ox + 32, wy + 9, 12, 12)
|
||||||
|
}
|
||||||
|
|
||||||
|
font.drawText(ctx, `${wp.nameZh} · ${wp.nameEn}`, ox + 58, wy + 21, {
|
||||||
font: 'fontexocet10',
|
font: 'fontexocet10',
|
||||||
color: 'gold',
|
color: wp.unlocked ? (isHovered ? 'white' : 'gold') : 'gray',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} else if (kind === 'stash') {
|
} else if (kind === 'stash') {
|
||||||
|
|
@ -704,7 +738,25 @@ export class WorldPanelsHud {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private drawActTabs(ctx: CanvasRenderingContext2D, ox: number, oy: number, font: D2FontRenderer): void {
|
private drawActTabs(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
ox: number,
|
||||||
|
oy: number,
|
||||||
|
font: D2FontRenderer,
|
||||||
|
tabsImg?: HTMLImageElement | null,
|
||||||
|
): void {
|
||||||
|
if (tabsImg && tabsImg.complete && tabsImg.naturalWidth > 0) {
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const act = i + 1
|
||||||
|
const active = this.selectedActTab === act
|
||||||
|
const frameIdx = active ? 5 + i : i
|
||||||
|
const sx = frameIdx * 63
|
||||||
|
const tx = ox + 4 + i * 62
|
||||||
|
ctx.drawImage(tabsImg, sx, 0, 63, 31, tx, oy + 8, 63, 31)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const numerals = ['I', 'II', 'III', 'IV', 'V'] as const
|
const numerals = ['I', 'II', 'III', 'IV', 'V'] as const
|
||||||
for (let i = 0; i < 5; i++) {
|
for (let i = 0; i < 5; i++) {
|
||||||
const act = i + 1
|
const act = i + 1
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,184 @@
|
||||||
|
/**
|
||||||
|
* tests/world-panels.test.ts
|
||||||
|
*
|
||||||
|
* Unit tests for Diablo II 1.13c Waypoint and World Panels UI Parity (Issue #378).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { existsSync, readFileSync } from 'node:fs'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { WorldPanelsHud, ACT_WAYPOINTS } from '../src/ui/world-panels.ts'
|
||||||
|
import { D2FontRenderer } from '../src/ui/font.ts'
|
||||||
|
|
||||||
|
function getPngDimensions(filePath: string): { width: number; height: number } {
|
||||||
|
const buf = readFileSync(filePath)
|
||||||
|
if (buf.toString('ascii', 1, 4) !== 'PNG') throw new Error('Not a PNG file')
|
||||||
|
const width = buf.readUInt32BE(16)
|
||||||
|
const height = buf.readUInt32BE(20)
|
||||||
|
return { width, height }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Issue #378: Diablo II 1.13c Waypoint Panel Tabs & Icons', () => {
|
||||||
|
it('verifies waypoint-tabs.png, waypoint-icons.png, and quest-tabs.png asset dimensions', () => {
|
||||||
|
const waypointTabsPath = join(process.cwd(), 'public/ui/waypoint-tabs.png')
|
||||||
|
const waypointIconsPath = join(process.cwd(), 'public/ui/waypoint-icons.png')
|
||||||
|
const questTabsPath = join(process.cwd(), 'public/ui/quest-tabs.png')
|
||||||
|
|
||||||
|
expect(existsSync(waypointTabsPath)).toBe(true)
|
||||||
|
expect(existsSync(waypointIconsPath)).toBe(true)
|
||||||
|
expect(existsSync(questTabsPath)).toBe(true)
|
||||||
|
|
||||||
|
// 10 frames of 63x31 = 630x31
|
||||||
|
const wpTabsDim = getPngDimensions(waypointTabsPath)
|
||||||
|
expect(wpTabsDim.width).toBe(630)
|
||||||
|
expect(wpTabsDim.height).toBe(31)
|
||||||
|
|
||||||
|
// 5 frames of 30x30 = 150x30
|
||||||
|
const wpIconsDim = getPngDimensions(waypointIconsPath)
|
||||||
|
expect(wpIconsDim.width).toBe(150)
|
||||||
|
expect(wpIconsDim.height).toBe(30)
|
||||||
|
|
||||||
|
// 10 frames of 63x31 = 630x31
|
||||||
|
const qTabsDim = getPngDimensions(questTabsPath)
|
||||||
|
expect(qTabsDim.width).toBe(630)
|
||||||
|
expect(qTabsDim.height).toBe(31)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders waypoint tabs and icons using drawImage slices when assets are present', () => {
|
||||||
|
const hud = new WorldPanelsHud()
|
||||||
|
const font = new D2FontRenderer()
|
||||||
|
|
||||||
|
const drawImageCalls: any[] = []
|
||||||
|
const ctx = {
|
||||||
|
drawImage: vi.fn((...args: any[]) => {
|
||||||
|
drawImageCalls.push(args)
|
||||||
|
}),
|
||||||
|
fillRect: vi.fn(),
|
||||||
|
strokeRect: vi.fn(),
|
||||||
|
fillText: vi.fn(),
|
||||||
|
save: vi.fn(),
|
||||||
|
restore: vi.fn(),
|
||||||
|
measureText: vi.fn(() => ({ width: 60 })),
|
||||||
|
fillStyle: '',
|
||||||
|
strokeStyle: '',
|
||||||
|
globalAlpha: 1,
|
||||||
|
} as unknown as CanvasRenderingContext2D
|
||||||
|
|
||||||
|
const mockTabsImg = {
|
||||||
|
complete: true,
|
||||||
|
naturalWidth: 630,
|
||||||
|
naturalHeight: 31,
|
||||||
|
} as unknown as HTMLImageElement
|
||||||
|
|
||||||
|
const mockIconsImg = {
|
||||||
|
complete: true,
|
||||||
|
naturalWidth: 150,
|
||||||
|
naturalHeight: 30,
|
||||||
|
} as unknown as HTMLImageElement
|
||||||
|
|
||||||
|
hud.selectedActTab = 2
|
||||||
|
|
||||||
|
hud.drawLeftDockPanel(
|
||||||
|
ctx,
|
||||||
|
'waypoint',
|
||||||
|
{
|
||||||
|
questBgImg: null,
|
||||||
|
waypointBgImg: null,
|
||||||
|
borderLeftImg: null,
|
||||||
|
stashBgImg: null,
|
||||||
|
cubeBgImg: null,
|
||||||
|
vendorBgImg: null,
|
||||||
|
buySellBtnImg: null,
|
||||||
|
waypointTabsImg: mockTabsImg,
|
||||||
|
waypointIconsImg: mockIconsImg,
|
||||||
|
},
|
||||||
|
font,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Verify 5 tab draws from mockTabsImg:
|
||||||
|
// Act 1 (inactive, frame 0 -> sx 0)
|
||||||
|
// Act 2 (active, frame 6 -> sx 6 * 63 = 378)
|
||||||
|
// Act 3 (inactive, frame 2 -> sx 2 * 63 = 126)
|
||||||
|
// Act 4 (inactive, frame 3 -> sx 3 * 63 = 189)
|
||||||
|
// Act 5 (inactive, frame 4 -> sx 4 * 63 = 252)
|
||||||
|
const tabDraws = drawImageCalls.filter((c) => c[0] === mockTabsImg)
|
||||||
|
expect(tabDraws.length).toBe(5)
|
||||||
|
expect(tabDraws[0][1]).toBe(0) // Act 1 sx
|
||||||
|
expect(tabDraws[1][1]).toBe(6 * 63) // Act 2 (active) sx = 378
|
||||||
|
expect(tabDraws[2][1]).toBe(2 * 63) // Act 3 sx = 126
|
||||||
|
|
||||||
|
// Verify waypoint icons drawn from mockIconsImg for Act 2 (frame index 1 -> sx 30)
|
||||||
|
const iconDraws = drawImageCalls.filter((c) => c[0] === mockIconsImg)
|
||||||
|
const act2WpCount = (ACT_WAYPOINTS[2] ?? []).length
|
||||||
|
expect(iconDraws.length).toBe(act2WpCount)
|
||||||
|
for (const call of iconDraws) {
|
||||||
|
expect(call[1]).toBe(30) // sx = 30 for Act 2
|
||||||
|
expect(call[3]).toBe(30) // sw = 30
|
||||||
|
expect(call[4]).toBe(30) // sh = 30
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updates hoveredWaypointIdx on mouse move and highlights hovered waypoint', () => {
|
||||||
|
const hud = new WorldPanelsHud()
|
||||||
|
hud.selectedActTab = 1
|
||||||
|
const ox = 80
|
||||||
|
const oy = 60
|
||||||
|
|
||||||
|
// Hover over first waypoint (i = 0): wy = oy + 62 = 122 .. 153
|
||||||
|
hud.handleMouseMove(ox + 50, oy + 70)
|
||||||
|
expect(hud.hoveredWaypointIdx).toBe(0)
|
||||||
|
|
||||||
|
// Move away
|
||||||
|
hud.handleMouseMove(ox + 50, oy + 500)
|
||||||
|
expect(hud.hoveredWaypointIdx).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles clicking waypoint tabs and locked vs unlocked waypoints', () => {
|
||||||
|
const hud = new WorldPanelsHud()
|
||||||
|
const onWaypointTeleport = vi.fn()
|
||||||
|
const onClose = vi.fn()
|
||||||
|
|
||||||
|
const ox = 80
|
||||||
|
const oy = 60
|
||||||
|
|
||||||
|
// 1. Click Act 3 tab (tab 3: logicalX approx ox + 8 + 2 * 61 + 10 = ox + 140, logicalY oy + 20)
|
||||||
|
const tabClickHandled = hud.handleLeftDockClick('waypoint', ox + 140, oy + 20, {
|
||||||
|
onWaypointTeleport,
|
||||||
|
onClose,
|
||||||
|
})
|
||||||
|
expect(tabClickHandled).toBe(true)
|
||||||
|
expect(hud.selectedActTab).toBe(3)
|
||||||
|
|
||||||
|
// 2. In Act 3:
|
||||||
|
// First waypoint (Kurast Docks) is unlocked
|
||||||
|
const wps = ACT_WAYPOINTS[3]!
|
||||||
|
expect(wps[0]!.unlocked).toBe(true)
|
||||||
|
|
||||||
|
// Temporarily mark index 1 as locked
|
||||||
|
;(wps[1] as any).unlocked = false
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Click locked waypoint: should NOT trigger teleport or close
|
||||||
|
const lockedWy = oy + 62 + 1 * 36 + 10
|
||||||
|
const lockedClick = hud.handleLeftDockClick('waypoint', ox + 50, lockedWy, {
|
||||||
|
onWaypointTeleport,
|
||||||
|
onClose,
|
||||||
|
})
|
||||||
|
expect(lockedClick).toBe(true)
|
||||||
|
expect(onWaypointTeleport).not.toHaveBeenCalled()
|
||||||
|
expect(onClose).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
// Click unlocked waypoint (index 0): should trigger teleport and close
|
||||||
|
const unlockedWy = oy + 62 + 0 * 36 + 10
|
||||||
|
const unlockedClick = hud.handleLeftDockClick('waypoint', ox + 50, unlockedWy, {
|
||||||
|
onWaypointTeleport,
|
||||||
|
onClose,
|
||||||
|
})
|
||||||
|
expect(unlockedClick).toBe(true)
|
||||||
|
expect(onWaypointTeleport).toHaveBeenCalledWith(3, wps[0]!.slug, wps[0]!.levelId)
|
||||||
|
expect(onClose).toHaveBeenCalled()
|
||||||
|
} finally {
|
||||||
|
;(wps[1] as any).unlocked = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue