feat(ui): authentic D2 1.13c waypoint tabs and icons (Fixes #378)

This commit is contained in:
troytt 2026-09-22 09:59:54 +00:00
parent 0c4af3770d
commit 6f3cf6b8bb
3 changed files with 252 additions and 10 deletions

View File

@ -338,6 +338,9 @@ export class HudManager {
levelSocket: `${baseUrl}/level-socket.png`,
skillPoints: `${baseUrl}/skillpoints.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`,
cursorHand: `${baseUrl}/cursor-hand.png`,
cursorAtlas: `${baseUrl}/cursor.png`,
@ -858,6 +861,9 @@ export class HudManager {
buySellBtnImg: this.images.get('buySellBtn') ?? null,
questsAtlasImg: this.images.get('questsAtlas') ?? 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,
)

View File

@ -200,6 +200,7 @@ export class WorldPanelsHud {
stashItems: GridPlacement[] = []
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.
@ -308,6 +309,21 @@ export class WorldPanelsHud {
handleMouseMove(logicalX: number, logicalY: number): void {
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 (
logicalX >= STASH_GRID_ORIGIN.x &&
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
if (logicalX >= ox + 22 && logicalX <= ox + 298 && logicalY >= wy && logicalY <= wy + 32) {
const wp = list[i]!
if (!wp.unlocked) return true
callbacks.onWaypointTeleport(wp.act, wp.slug, wp.levelId)
callbacks.onClose()
return true
@ -473,6 +490,9 @@ export class WorldPanelsHud {
buySellBtnImg: HTMLImageElement | null
questsAtlasImg?: HTMLImageElement | null
itemsAtlasImg?: HTMLImageElement | null
waypointTabsImg?: HTMLImageElement | null
waypointIconsImg?: HTMLImageElement | null
questTabsImg?: HTMLImageElement | null
},
font: D2FontRenderer,
): void {
@ -486,7 +506,7 @@ export class WorldPanelsHud {
if (kind === 'quest') {
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]!
if (this.selectedQuestIdx >= quests.length) {
@ -558,22 +578,36 @@ export class WorldPanelsHud {
}
} else if (kind === 'waypoint') {
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 activeIconIdx = Math.max(0, Math.min(4, this.selectedActTab - 1))
const iconSx = activeIconIdx * 30
for (let i = 0; i < wps.length; i++) {
const wp = wps[i]!
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.strokeStyle = '#5a4a32'
ctx.strokeStyle = isHovered ? '#e8c26b' : '#5a4a32'
ctx.strokeRect(ox + 22.5, wy + 0.5, 275, 30)
// Blue active waypoint diamond icon
ctx.fillStyle = '#4888ff'
ctx.fillRect(ox + 32, wy + 9, 12, 12)
font.drawText(ctx, `${wp.nameZh} · ${wp.nameEn}`, ox + 56, wy + 21, {
// Authentic D2 waypoint icon from waygateicons.dc6 (30x30)
if (assets.waypointIconsImg && assets.waypointIconsImg.complete && assets.waypointIconsImg.naturalWidth > 0) {
ctx.save()
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',
color: 'gold',
color: wp.unlocked ? (isHovered ? 'white' : 'gold') : 'gray',
})
}
} 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
for (let i = 0; i < 5; i++) {
const act = i + 1

184
tests/world-panels.test.ts Normal file
View File

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