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`,
|
||||
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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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