Merge branch 'issue-393' into main: fix waypoint menu icons parity (#393)
This commit is contained in:
commit
807585a615
|
|
@ -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 }
|
||||
|
|
@ -4351,6 +4359,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
|
||||
|
|
@ -4564,6 +4575,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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -4739,8 +4755,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)) {
|
||||
|
|
|
|||
|
|
@ -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<number, boolean>()
|
||||
|
||||
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<number>): 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') {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue