diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index b820179..90f5eda 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -2081,8 +2081,12 @@ export class SceneMouseController { if (this.waypointNetwork.activate(clickedWp.wp.waypointId)) { this.status.textContent = `传送点已激活:${runtime.level}` } - if (this.hudManager && this.hudManager.leftPanel !== 'waypoint') { - this.hudManager.toggleLeftPanel('waypoint') + if (this.hudManager) { + this.hudManager.worldPanels.setCurrentWaypoint(clickedWp.wp.waypointId, runtime.levelId) + this.hudManager.worldPanels.selectedActTab = runtime.act + if (this.hudManager.leftPanel !== 'waypoint') { + this.hudManager.toggleLeftPanel('waypoint') + } } } else { this.pendingInteraction = { kind: 'waypoint', waypoint: clickedWp.wp, worldPos: clickedWp.pos } @@ -2281,8 +2285,12 @@ export class SceneMouseController { if (this.waypointNetwork.activate(waypoint.waypointId)) { this.status.textContent = `传送点已激活:${this.getRuntime().level}` } - if (this.hudManager && this.hudManager.leftPanel !== 'waypoint') { - this.hudManager.toggleLeftPanel('waypoint') + if (this.hudManager) { + this.hudManager.worldPanels.setCurrentWaypoint(waypoint.waypointId, this.getRuntime().levelId) + this.hudManager.worldPanels.selectedActTab = this.getRuntime().act + if (this.hudManager.leftPanel !== 'waypoint') { + this.hudManager.toggleLeftPanel('waypoint') + } } } else { this.navTarget = { x: worldPos.x, y: worldPos.y } @@ -4342,6 +4350,9 @@ if (typeof window !== 'undefined') { }) : null if (hudManager !== null) { + hudManager.worldPanels.setCurrentLevel(runtime.levelId ?? null) + hudManager.worldPanels.setCurrentWaypoint(runtime.waypoints[0]?.waypointId ?? null, runtime.levelId ?? null) + hudManager.worldPanels.selectedActTab = runtime.act try { await hudManager.init('/ui') const initialHeroInfo = HERO_CLASSES[currentHeroToken] ?? HERO_CLASSES.so @@ -4555,6 +4566,11 @@ if (typeof window !== 'undefined') { const names = getLevelNames(next.levelId ?? next.level) hudManager?.worldPanels.showAreaBanner(names.nameZh, names.nameEn) + if (hudManager) { + hudManager.worldPanels.setCurrentLevel(next.levelId ?? null) + hudManager.worldPanels.setCurrentWaypoint(next.waypoints[0]?.waypointId ?? null, next.levelId ?? null) + hudManager.worldPanels.selectedActTab = next.act + } } /** @@ -4730,8 +4746,12 @@ if (typeof window !== 'undefined') { nearWaypoint = true if (lastContactWaypointId !== waypoint.waypointId) { lastContactWaypointId = waypoint.waypointId - if (hudManager && hudManager.leftPanel !== 'waypoint') { - hudManager.toggleLeftPanel('waypoint') + if (hudManager) { + hudManager.worldPanels.setCurrentWaypoint(waypoint.waypointId, runtime.levelId) + hudManager.worldPanels.selectedActTab = runtime.act + if (hudManager.leftPanel !== 'waypoint') { + hudManager.toggleLeftPanel('waypoint') + } } } if (waypointNetwork.activate(waypoint.waypointId)) { diff --git a/src/ui/world-panels.ts b/src/ui/world-panels.ts index 4066c07..2b7fce3 100644 --- a/src/ui/world-panels.ts +++ b/src/ui/world-panels.ts @@ -264,6 +264,69 @@ export class WorldPanelsHud { hoveredStashItem: { item: UiInventoryItem; x: number; y: number } | null = null hoveredWaypointIdx: number | null = null + /** + * The waypoint that opened this menu or where the player currently stands. + * Ground Truth (D2Client.dll 1.13c): + * - 0x6fbacdd0: Current level ID + * - 0x6fbacdd4: Current waypoint ID + */ + currentWaypointId: number | null = null + currentLevelId: number | null = null + + /** Runtime waypoint unlocked overrides: waypointId -> boolean */ + private waypointUnlockedOverrides = new Map() + + isWaypointUnlocked(wpOrId: WaypointEntry | number): boolean { + const wpId = typeof wpOrId === 'number' ? wpOrId : wpOrId.waypointId + const override = this.waypointUnlockedOverrides.get(wpId) + if (override !== undefined) return override + if (typeof wpOrId === 'object' && wpOrId !== null) return wpOrId.unlocked + for (const list of Object.values(ACT_WAYPOINTS)) { + const found = list.find(w => w.waypointId === wpId) + if (found) return found.unlocked + } + return false + } + + setWaypointUnlocked(waypointId: number, unlocked: boolean): void { + this.waypointUnlockedOverrides.set(waypointId, unlocked) + } + + setUnlockedWaypoints(unlockedWaypointIds: Iterable): void { + this.waypointUnlockedOverrides.clear() + const set = new Set(unlockedWaypointIds) + for (const list of Object.values(ACT_WAYPOINTS)) { + for (const wp of list) { + this.waypointUnlockedOverrides.set(wp.waypointId, set.has(wp.waypointId)) + } + } + } + + clearWaypointUnlockedOverrides(): void { + this.waypointUnlockedOverrides.clear() + } + + setCurrentWaypoint(waypointId: number | null, levelId?: number | null): void { + this.currentWaypointId = waypointId + if (levelId !== undefined) { + this.currentLevelId = levelId + } + } + + setCurrentLevel(levelId: number | null): void { + this.currentLevelId = levelId + } + + isCurrentWaypoint(wp: WaypointEntry): boolean { + if (this.currentWaypointId !== null && this.currentWaypointId !== undefined) { + if (wp.waypointId === this.currentWaypointId) return true + } + if (this.currentLevelId !== null && this.currentLevelId !== undefined) { + if (wp.levelId === this.currentLevelId) return true + } + return false + } + /** * Check whether an item of (w, h) can be placed at (col, row) in the 6x8 stash grid. * Throws RangeError if placed out of bounds. Returns all overlapping GridPlacements. @@ -463,7 +526,10 @@ export class WorldPanelsHud { const wy = oy + rowY if (logicalX >= ox + 17 && logicalX <= ox + 298 && logicalY >= wy && logicalY <= wy + 32) { const wp = list[i]! - if (!wp.unlocked) return true + if (!this.isWaypointUnlocked(wp)) return true + // Decompiled D2Client.dll 1.13c 0x6fb5a53d (cmp 0x6fbacdd0, %eax): + // Clicking current waypoint does nothing (player is already at this waypoint) + if (this.isCurrentWaypoint(wp)) return true callbacks.onWaypointTeleport(wp.act, wp.slug, wp.levelId) callbacks.onClose() return true @@ -646,14 +712,14 @@ export class WorldPanelsHud { if (assets.waypointBgImg) ctx.drawImage(assets.waypointBgImg, ox, oy) 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 rowY = WAYPOINT_ROW_OFFSETS_Y[i] ?? (60 + i * 36) const wy = oy + rowY const isHovered = this.hoveredWaypointIdx === i + const isUnlocked = this.isWaypointUnlocked(wp) + const isCurrent = isUnlocked && this.isCurrentWaypoint(wp) ctx.fillStyle = isHovered ? 'rgba(42, 34, 22, 0.88)' : 'rgba(18, 16, 13, 0.85)' ctx.fillRect(ox + 17, wy, 281, 31) @@ -661,20 +727,30 @@ export class WorldPanelsHud { ctx.strokeRect(ox + 17.5, wy + 0.5, 280, 30) // 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 + 17, wy + 1, 30, 30) - ctx.restore() - } else { - // Fallback stone diamond - ctx.fillStyle = wp.unlocked ? '#4888ff' : '#4a4a4a' - ctx.fillRect(ox + 26, wy + 9, 12, 12) + // Decompiled D2Client.dll 1.13c at 0x6fb5b2f4 - 0x6fb5b355: + // - Locked waypoint: skip drawing sprite (je 0x6fb5b355) + // - Current waypoint (cmp 0x6fbacdd0, %esi == 0): + // hovered -> Frame 1 (sx = 30) + // unhovered -> Frame 0 (sx = 0) + // - Other activated waypoint (destination): + // hovered -> Frame 4 (sx = 120) + // unhovered -> Frame 3 (sx = 90) + if (isUnlocked) { + const iconFrameIdx = isCurrent ? (isHovered ? 1 : 0) : (isHovered ? 4 : 3) + const iconSx = iconFrameIdx * 30 + + if (assets.waypointIconsImg && assets.waypointIconsImg.complete && assets.waypointIconsImg.naturalWidth > 0) { + ctx.drawImage(assets.waypointIconsImg, iconSx, 0, 30, 30, ox + 17, wy + 1, 30, 30) + } else { + // Fallback indicator + ctx.fillStyle = isCurrent ? (isHovered ? '#78b4ff' : '#4888ff') : (isHovered ? '#f0d898' : '#c8aa6e') + ctx.fillRect(ox + 26, wy + 9, 12, 12) + } } font.drawText(ctx, `${wp.nameZh} · ${wp.nameEn}`, ox + 80, wy + 21, { font: 'fontexocet10', - color: wp.unlocked ? (isHovered ? 'white' : 'gold') : 'gray', + color: isUnlocked ? (isHovered ? 'white' : 'gold') : 'gray', }) } } else if (kind === 'stash') { diff --git a/tests/world-panels.test.ts b/tests/world-panels.test.ts index 9c86e77..89b5ab2 100644 --- a/tests/world-panels.test.ts +++ b/tests/world-panels.test.ts @@ -77,6 +77,8 @@ describe('Issue #378: Diablo II 1.13c Waypoint Panel Tabs & Icons', () => { } as unknown as HTMLImageElement hud.selectedActTab = 2 + // Set current waypoint to Lut Gholein (waypointId: 9, levelId: 40) + hud.setCurrentWaypoint(9, 40) hud.drawLeftDockPanel( ctx, @@ -107,17 +109,94 @@ describe('Issue #378: Diablo II 1.13c Waypoint Panel Tabs & Icons', () => { expect(tabDraws[1][1]).toBe(2 * 63) // Act 2 (active) sx = 126 expect(tabDraws[2][1]).toBe(5 * 63) // Act 3 (inactive) sx = 315 - // Verify waypoint icons drawn from mockIconsImg for Act 2 (frame index 1 -> sx 30) + // Verify waypoint icons drawn from mockIconsImg for Act 2: + // Ground Truth D2Client.dll 1.13c: + // - Index 0 (Lut Gholein): current waypoint -> Frame 0 (sx = 0) + // - Indices 1..8: activated destination waypoints -> Frame 3 (sx = 90) 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 + expect(iconDraws[0][1]).toBe(0) // Current waypoint: Frame 0 (sx = 0) + for (let i = 1; i < act2WpCount; i++) { + expect(iconDraws[i][1]).toBe(90) // Destination waypoints: Frame 3 (sx = 90) + expect(iconDraws[i][3]).toBe(30) + expect(iconDraws[i][4]).toBe(30) } }) + it('Issue #393: renders hovered states and omits locked waypoint icons per D2 1.13c parity', () => { + const hud = new WorldPanelsHud() + const font = new D2FontRenderer() + + const mockTabsImg = { + complete: true, + naturalWidth: 630, + naturalHeight: 31, + } as unknown as HTMLImageElement + + const mockIconsImg = { + complete: true, + naturalWidth: 150, + naturalHeight: 30, + } as unknown as HTMLImageElement + + const assets = { + questBgImg: null, + waypointBgImg: null, + borderLeftImg: null, + stashBgImg: null, + cubeBgImg: null, + vendorBgImg: null, + buySellBtnImg: null, + waypointTabsImg: mockTabsImg, + waypointIconsImg: mockIconsImg, + } + + // 1. In Act 4 (3 waypoints: Pandemonium Fortress, City of the Damned, River of Flame) + hud.selectedActTab = 4 + hud.setCurrentWaypoint(27, 103) // Pandemonium Fortress is current waypoint + + // Hover over current waypoint (index 0) + hud.hoveredWaypointIdx = 0 + let drawCalls: any[] = [] + const ctx = { + drawImage: vi.fn((...args: any[]) => drawCalls.push(args)), + fillRect: vi.fn(), + strokeRect: vi.fn(), + save: vi.fn(), + restore: vi.fn(), + measureText: vi.fn(() => ({ width: 60 })), + fillText: vi.fn(), + } as unknown as CanvasRenderingContext2D + + hud.drawLeftDockPanel(ctx, 'waypoint', assets, font) + let iconDraws = drawCalls.filter((c) => c[0] === mockIconsImg) + expect(iconDraws.length).toBe(3) + expect(iconDraws[0][1]).toBe(30) // Hovered current waypoint: Frame 1 (sx = 30) + expect(iconDraws[1][1]).toBe(90) // Unhovered destination waypoint: Frame 3 (sx = 90) + expect(iconDraws[2][1]).toBe(90) // Unhovered destination waypoint: Frame 3 (sx = 90) + + // Hover over destination waypoint (index 1: City of the Damned) + hud.hoveredWaypointIdx = 1 + drawCalls = [] + hud.drawLeftDockPanel(ctx, 'waypoint', assets, font) + iconDraws = drawCalls.filter((c) => c[0] === mockIconsImg) + expect(iconDraws.length).toBe(3) + expect(iconDraws[0][1]).toBe(0) // Unhovered current waypoint: Frame 0 (sx = 0) + expect(iconDraws[1][1]).toBe(120) // Hovered destination waypoint: Frame 4 (sx = 120) + expect(iconDraws[2][1]).toBe(90) // Unhovered destination waypoint: Frame 3 (sx = 90) + + // 2. Locked waypoint: locked waypoints have NO icon drawn per D2Client.dll 0x6fb5b308 (je 0x6fb5b355) + hud.hoveredWaypointIdx = null + hud.setWaypointUnlocked(29, false) // Lock River of Flame (index 2) + drawCalls = [] + hud.drawLeftDockPanel(ctx, 'waypoint', assets, font) + iconDraws = drawCalls.filter((c) => c[0] === mockIconsImg) + expect(iconDraws.length).toBe(2) // Only index 0 and 1 drawn, index 2 omitted + expect(iconDraws[0][1]).toBe(0) // Frame 0 + expect(iconDraws[1][1]).toBe(90) // Frame 3 + }) + it('updates hoveredWaypointIdx on mouse move and highlights hovered waypoint', () => { const hud = new WorldPanelsHud() hud.selectedActTab = 1 @@ -150,35 +229,41 @@ describe('Issue #378: Diablo II 1.13c Waypoint Panel Tabs & Icons', () => { expect(hud.selectedActTab).toBe(3) // 2. In Act 3: - // First waypoint (Kurast Docks) is unlocked + // Set Kurast Docks (index 0, waypointId: 18, levelId: 75) as current waypoint + hud.setCurrentWaypoint(18, 75) const wps = ACT_WAYPOINTS[3]! - expect(wps[0]!.unlocked).toBe(true) - // Temporarily mark index 1 as locked - ;(wps[1] as any).unlocked = false + // Mark index 1 (Spider Forest, waypointId: 19) as locked via hud method + hud.setWaypointUnlocked(19, 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 locked waypoint (index 1): 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 - } + // Click current waypoint (index 0, Kurast Docks): should NOT trigger teleport (player already here) + const currentWy = oy + 62 + 0 * 36 + 10 + const currentClick = hud.handleLeftDockClick('waypoint', ox + 50, currentWy, { + onWaypointTeleport, + onClose, + }) + expect(currentClick).toBe(true) + expect(onWaypointTeleport).not.toHaveBeenCalled() + expect(onClose).not.toHaveBeenCalled() + + // Click unlocked destination waypoint (index 2, Great Marsh): should trigger teleport and close + const destWy = oy + 62 + 2 * 36 + 10 + const destClick = hud.handleLeftDockClick('waypoint', ox + 50, destWy, { + onWaypointTeleport, + onClose, + }) + expect(destClick).toBe(true) + expect(onWaypointTeleport).toHaveBeenCalledWith(3, wps[2]!.slug, wps[2]!.levelId) + expect(onClose).toHaveBeenCalled() }) })