diff --git a/src/ui/hud-manager.ts b/src/ui/hud-manager.ts index 3bd50ea..013df93 100644 --- a/src/ui/hud-manager.ts +++ b/src/ui/hud-manager.ts @@ -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, ) diff --git a/src/ui/world-panels.ts b/src/ui/world-panels.ts index a1f223c..3ea964c 100644 --- a/src/ui/world-panels.ts +++ b/src/ui/world-panels.ts @@ -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 diff --git a/tests/world-panels.test.ts b/tests/world-panels.test.ts new file mode 100644 index 0000000..806312b --- /dev/null +++ b/tests/world-panels.test.ts @@ -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 + } + }) +})