Merge pull request 'fix(ui): restore authentic D2 v1.13c NPC dialog popup menu (Fixes #489)' (#495) from fix/issue-489-npc-menu-ui into main

This commit is contained in:
troytt 2026-09-27 06:41:34 +00:00
commit 57c57c2662
11 changed files with 589 additions and 131 deletions

BIN
public/ui/boxpieces.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
public/ui/focus16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@ -1,6 +1,6 @@
{
"version": "1.13c",
"decodedDc6Count": 681,
"decodedDc6Count": 683,
"dc6DecodeFailures": 0,
"atlasWidth": 1024,
"atlasHeight": 3573,
@ -13135,6 +13135,8 @@
"cubeBg": "/ui/cube-bg.png",
"vendorBg": "/ui/vendor-bg.png",
"vendorTabs": "/ui/vendor-tabs.png",
"boxPieces": "/ui/boxpieces.png",
"focus16": "/ui/focus16.png",
"popbelt": "/ui/popbelt.png",
"minipanel": "/ui/minipanel.png",
"minipanelBtns": "/ui/minipanel-btns.png",

View File

@ -328,6 +328,22 @@ async function main(): Promise<void> {
const bstStrip = stitchHorizontalStrip(bstFrames)
savePng('vendor-tabs.png', bstStrip.width, bstStrip.height, bstStrip.pixels)
const boxPiecesDc6 = await decodeUiDc6('data\\global\\ui\\menu\\boxpieces.dc6')
const bpFrames = boxPiecesDc6.groups[0]?.frames
if (!bpFrames || bpFrames.length !== 22 || bpFrames.some(f => f.width !== 14 || f.height !== 15)) {
throw new Error('Invalid boxpieces.dc6 frame geometry: expected 22 frames of 14x15')
}
const bpStrip = stitchHorizontalStrip(bpFrames)
savePng('boxpieces.png', bpStrip.width, bpStrip.height, bpStrip.pixels)
const focus16Dc6 = await decodeUiDc6('data\\global\\ui\\CURSOR\\focus16.dc6')
const f16Frames = focus16Dc6.groups[0]?.frames
if (!f16Frames || f16Frames.length !== 8 || f16Frames.some(f => f.width !== 20 || f.height !== 20)) {
throw new Error('Invalid focus16.dc6 frame geometry: expected 8 frames of 20x20')
}
const f16Strip = stitchHorizontalStrip(f16Frames)
savePng('focus16.png', f16Strip.width, f16Strip.height, f16Strip.pixels)
const levelBtn = await decodeUiDc6('data\\global\\ui\\Panel\\Level.dc6')
const lvlStrip = stitchHorizontalStrip(levelBtn.groups[0]!.frames)
savePng('level-btn.png', lvlStrip.width, lvlStrip.height, lvlStrip.pixels)

View File

@ -371,6 +371,24 @@ async function main(): Promise<void> {
const bstStrip = stitchHorizontalStrip(bstFrames)
savePng('vendor-tabs.png', bstStrip.width, bstStrip.height, bstStrip.pixels)
// Dialog stone border pieces (`menu/boxpieces.dc6` per D2Client.dll 0x6fb6e4c0, 22 frames of 14x15)
const boxPiecesDc6 = await decodeUiDc6('data/global/ui/menu/boxpieces.dc6')
const bpFrames = boxPiecesDc6.groups[0]?.frames
if (!bpFrames || bpFrames.length !== 22 || bpFrames.some(f => f.width !== 14 || f.height !== 15)) {
throw new Error('Invalid boxpieces.dc6 frame geometry: expected 22 frames of 14x15')
}
const bpStrip = stitchHorizontalStrip(bpFrames)
savePng('boxpieces.png', bpStrip.width, bpStrip.height, bpStrip.pixels)
// Dialog selection pentagram focus (`CURSOR/focus16.dc6` per D2Client.dll 0x6fb539d9, 8 frames of 20x20)
const focus16Dc6 = await decodeUiDc6('data/global/ui/CURSOR/focus16.dc6')
const f16Frames = focus16Dc6.groups[0]?.frames
if (!f16Frames || f16Frames.length !== 8 || f16Frames.some(f => f.width !== 20 || f.height !== 20)) {
throw new Error('Invalid focus16.dc6 frame geometry: expected 8 frames of 20x20')
}
const f16Strip = stitchHorizontalStrip(f16Frames)
savePng('focus16.png', f16Strip.width, f16Strip.height, f16Strip.pixels)
const levelBtn = await decodeUiDc6('data/global/ui/Panel/Level.dc6')
const lvlStrip = stitchHorizontalStrip(levelBtn.groups[0]!.frames)
savePng('level-btn.png', lvlStrip.width, lvlStrip.height, lvlStrip.pixels)
@ -1017,6 +1035,8 @@ async function main(): Promise<void> {
cubeBg: '/ui/cube-bg.png',
vendorBg: '/ui/vendor-bg.png',
vendorTabs: '/ui/vendor-tabs.png',
boxPieces: '/ui/boxpieces.png',
focus16: '/ui/focus16.png',
popbelt: '/ui/popbelt.png',
minipanel: '/ui/minipanel.png',
minipanelBtns: '/ui/minipanel-btns.png',

View File

@ -183,19 +183,35 @@ async function main() {
}),
)
// 2. Open Charsi NPC Menu
// 2. Open Charsi NPC Menu (move player next to Charsi in town and trigger interactWithNpc)
const menuInfo = await evalJs(`(() => {
const hud = window.__d2webHudInstance;
const engine = window.__d2webEngine;
const mc = window.__d2webMouseController;
hud.closeAllPanels();
hud.openNpcMenuByName('Charsi', 400, 220);
const charsi = engine?.npcEntities?.find(n => n.def?.name?.toLowerCase() === 'charsi');
if (charsi && mc) {
engine.world.player.x = charsi.x + 28;
engine.world.player.y = charsi.y + 18;
mc.interactWithNpc(charsi);
} else {
hud.openNpcMenuByName('Charsi', 400, 220);
}
hud.render();
const dialogEl = document.querySelector('#dialog');
return {
npcName: hud.worldPanels.npcMenu?.npcName,
options: hud.worldPanels.npcMenu?.options?.map(o => o.labelZh),
hoveredOptionIdx: hud.worldPanels.npcMenu?.hoveredOptionIdx,
layout: hud.worldPanels.getNpcMenuLayout(hud.font),
domDialogHidden: dialogEl ? dialogEl.hidden : true,
};
})()`)
console.log('NPC Menu:', JSON.stringify(menuInfo))
await sleep(200)
if (!menuInfo.domDialogHidden) {
throw new Error('Legacy #dialog DOM element should be hidden when HudManager is active')
}
await sleep(250)
await takeScreenshot('vendor_npc_menu_charsi')
// 3. Open Charsi Trade/Repair 4-tab 10x10 Vendor UI (buysell.dc6 + buyselltabs.dc6 + [2,4,6,18] buttons)

View File

@ -3626,6 +3626,10 @@ export class SceneMouseController {
if (this.hudManager.isPointInterceptedByHud(logical.x, logical.y)) {
return
}
if (this.hudManager.worldPanels?.npcMenu) {
this.hudManager.worldPanels.npcMenu = null
this.hudManager.syncPublishedState?.()
}
}
this.lastClientPos = { x: e.clientX, y: e.clientY }
@ -3795,9 +3799,31 @@ export class SceneMouseController {
}
}
if (this.hudManager) {
if (this.status && 'hidden' in this.status) {
this.status.hidden = true
}
let menuX = 400
let menuY = 180
if (this.lastClientPos && typeof this.hudManager.clientToLogical === 'function') {
const rect = this.canvas?.getBoundingClientRect?.()
if (
rect &&
rect.width > 0 &&
rect.height > 0 &&
typeof this.hudManager.clientToLogical === 'function'
) {
const camX = this.engine.world.player.x
const camY = this.engine.world.player.y - 16
const scaleX = this.canvas.width > 0 ? rect.width / this.canvas.width : 1
const scaleY = this.canvas.height > 0 ? rect.height / this.canvas.height : 1
const zoom = this.camera?.zoom ?? 1
// D2Client.dll 0x6faf6880 (0x6faf68de: add edi, -150; cmp eax, 20):
// Project 150 world pixels above NPC feet so the dialog box sits cleanly above the NPC sprite
const anchorDomX = rect.left + rect.width * 0.5 + (npc.x - camX) * zoom * scaleX
const anchorDomY = rect.top + rect.height * 0.5 + (npc.y - 150 - camY) * zoom * scaleY
const logicalAnchor = this.hudManager.clientToLogical(anchorDomX, anchorDomY)
menuX = Math.round(logicalAnchor.x)
menuY = Math.max(20, Math.round(logicalAnchor.y))
} else if (this.lastClientPos && typeof this.hudManager.clientToLogical === 'function') {
const logical = this.hudManager.clientToLogical(this.lastClientPos.x, this.lastClientPos.y)
menuX = logical.x
menuY = logical.y
@ -7568,9 +7594,12 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
minimap.reveal(minimapLevel(), walked.x, walked.y)
const dialogPanel = document.querySelector<HTMLElement>('#dialog')
if (dialogPanel !== null) {
dialogPanel.hidden = !state.dialog || state.dialog.length === 0
dialogPanel.hidden = Boolean(hudManager) || !state.dialog || state.dialog.length === 0
dialogPanel.textContent = (state.dialog ?? []).join('\n')
}
if (status && 'hidden' in status) {
status.hidden = Boolean(hudManager?.worldPanels?.npcMenu)
}
},
onRender: (_alpha: number, frameMs: number) => {
const renderStarted = performance.now()
@ -8136,7 +8165,9 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
const scaleY = canvas.height > 0 ? rect.height / canvas.height : 1
const fontSizePx = `${Math.min(24, Math.max(11, Math.round(13 * Math.sqrt(camera.zoom))))}px`
const activeNpcMenuName = hudManager?.worldPanels?.npcMenu?.npcName?.toLowerCase()
for (const npc of engine.npcEntities) {
if (activeNpcMenuName && npc.def.name.toLowerCase() === activeNpcMenuName) continue
const topX = npc.frame ? npc.x + (npc.frame.offsetX ?? 0) + (npc.frame.width ?? 0) / 2 : npc.x
const topY = npc.frame ? npc.y + (npc.frame.offsetY ?? 0) : npc.y - MARKER_HEIGHT
const domX = rect.left + rect.width * 0.5 + (topX - camX) * camera.zoom * scaleX

View File

@ -83,7 +83,7 @@ export const ACT_QUEST_ASSET_MAP: Readonly<Record<number, readonly QuestAssetInf
export const BAKED_UI_MANIFEST: BakedUiManifest = {
version: '1.13c',
decodedDc6Count: 681,
decodedDc6Count: 683,
dc6DecodeFailures: 0,
atlasWidth: 1024,
atlasHeight: 3573,
@ -13218,6 +13218,8 @@ export const BAKED_UI_MANIFEST: BakedUiManifest = {
"cubeBg": "/ui/cube-bg.png",
"vendorBg": "/ui/vendor-bg.png",
"vendorTabs": "/ui/vendor-tabs.png",
"boxPieces": "/ui/boxpieces.png",
"focus16": "/ui/focus16.png",
"popbelt": "/ui/popbelt.png",
"minipanel": "/ui/minipanel.png",
"minipanelBtns": "/ui/minipanel-btns.png",

View File

@ -448,6 +448,8 @@ export class HudManager {
genericSkills: `${baseUrl}/generic-skills.png`,
cursorHand: `${baseUrl}/cursor-hand.png`,
cursorAtlas: `${baseUrl}/cursor.png`,
boxPieces: `${baseUrl}/boxpieces.png`,
focus16: `${baseUrl}/focus16.png`,
itemsAtlas: `${baseUrl}/items-atlas.png`,
questsAtlas: `${baseUrl}/quests-atlas.png`,
}
@ -612,7 +614,7 @@ export class HudManager {
const docking = this.getDockingLayout()
const { marginW } = docking
if (logicalX < -marginW || logicalX > 800 + marginW || logicalY < 0 || logicalY > 600) return false
if (this.worldPanels.npcMenu !== null) return true
if (this.worldPanels.isPointInNpcMenu(logicalX, logicalY, this.font)) return true
if (logicalY >= 540 && logicalX >= 0 && logicalX <= 800) return true
if (logicalX >= 0 && logicalX <= 117 && logicalY >= 496) return true
if (logicalX >= 683 && logicalX <= 800 && logicalY >= 496) return true
@ -709,7 +711,7 @@ export class HudManager {
this.controlBar.handleMouseMove(pt.x, pt.y)
if (this.worldPanels.npcMenu) {
this.worldPanels.handleNpcMenuMove(pt.x, pt.y)
this.worldPanels.handleNpcMenuMove(pt.x, pt.y, this.font)
}
const docking = this.getDockingLayout()
@ -771,6 +773,10 @@ export class HudManager {
const handleHudMouseDown = (e: MouseEvent): void => {
if (e.button !== 0 && e.button !== 2) return
const pt = this.clientToLogical(e.clientX, e.clientY)
if (this.worldPanels.npcMenu && !this.worldPanels.isPointInNpcMenu(pt.x, pt.y, this.font)) {
this.worldPanels.npcMenu = null
this.syncPublishedState()
}
if (!this.isPointInterceptedByHud(pt.x, pt.y)) {
if (this.inventory.isIdentifyMode()) {
e.preventDefault()
@ -796,18 +802,23 @@ export class HudManager {
// Town NPC Stone Menu click
if (this.worldPanels.npcMenu) {
this.worldPanels.handleNpcMenuClick(pt.x, pt.y, {
onOpenVendor: (desc, mode) => {
this.openVendorSession(desc, mode)
this.worldPanels.handleNpcMenuClick(
pt.x,
pt.y,
{
onOpenVendor: (desc, mode) => {
this.openVendorSession(desc, mode)
},
onIdentifyAll: (desc) => {
const count = this.worldPanels.identifyAllUiItems(this.inventory)
this.worldPanels.showAreaBanner(
count > 0 ? `已鉴定 ${count} 件物品` : '背包中没有未鉴定物品',
desc.displayName,
)
},
},
onIdentifyAll: (desc) => {
const count = this.worldPanels.identifyAllUiItems(this.inventory)
this.worldPanels.showAreaBanner(
count > 0 ? `已鉴定 ${count} 件物品` : '背包中没有未鉴定物品',
desc.displayName,
)
},
})
this.font,
)
this.syncPublishedState()
return
}
@ -1289,9 +1300,12 @@ export class HudManager {
// 7. Top-Center Monster Target Health Bar & Plaque
this.worldPanels.drawMonsterTargetBar(ctx, this.targetMonster, this.font)
// 8. Town NPC Stone Interaction Menu (`npcmenu.cpp`)
// 8. Town NPC Stone Interaction Menu (`npcmenu.cpp` + `dialog.cpp` + `boxpieces.dc6`)
if (this.worldPanels.npcMenu) {
this.worldPanels.drawNpcMenu(ctx, this.font)
this.worldPanels.drawNpcMenu(ctx, this.font, {
boxPiecesImg: this.images.get('boxPieces') ?? null,
focus16Img: this.images.get('focus16') ?? null,
})
}
// 9. Area Entry Gothic Banner (`Entering: <Level Name>`, only when no split panels are open)

View File

@ -220,10 +220,168 @@ export type NpcMenuActionId = 'talk' | 'trade' | 'gamble' | 'identify' | 'cancel
export interface NpcMenuOption {
readonly id: NpcMenuActionId
readonly strIdx?: number
readonly labelZh: string
readonly labelEn: string
}
export const BOXPIECES_FRAME_W = 14
export const BOXPIECES_FRAME_H = 15
export interface NpcDialogLayout {
readonly left: number
readonly top: number
readonly width: number
readonly height: number
readonly headerY: number
readonly optionYs: readonly number[]
}
/**
* D2Client.dll `0x6fb53580` (`DIALOG_Layout` in `dialog.cpp`):
* - Uses `font16` (`D2Win_SetFont(1)`)
* - Line 0 (NPC Name Header): `lineStepY = 21` (`0x15`), `selectable = false`
* - Lines 1..N (Menu Options): `lineStepY = 15` (`0x0f`), `selectable = true`
* - `width = maxLineWidth + 20` (`0x14`), `height = totalStepY + 15` (`0x0f`)
* - `left = anchorX - trunc(width / 2)`, `top = anchorY - 21`
* - Clamped to 800x600 viewport (`left ∈ [10, 800 - width]`, `top ∈ [10, 600 - height - 48]`)
*/
export function computeNpcDialogLayout(
npcName: string,
options: readonly NpcMenuOption[],
anchorX: number,
anchorY: number,
font?: Pick<D2FontRenderer, 'measureText'> | null,
): NpcDialogLayout {
const measure = (text: string): number =>
font ? font.measureText(text, 'font16') : text.length * 10
let maxLineWidth = measure(npcName)
for (const opt of options) {
const w = measure(opt.labelZh)
if (w > maxLineWidth) maxLineWidth = w
}
const headerStepY = 21
const optionStepY = 15
const totalStepY = headerStepY + options.length * optionStepY
const width = maxLineWidth + 20
const height = totalStepY + 15
let left = Math.trunc(anchorX) - Math.trunc(width / 2)
let top = Math.trunc(anchorY) - headerStepY
if (left + width > 800 - 10) {
left = 800 - width
}
if (top + height > 600 - 58) {
top = 600 - height - 48
}
if (left < 10) left = 10
if (top < 10) top = 10
const headerY = top + headerStepY
const optionYs = options.map((_, idx) => top + headerStepY + (idx + 1) * optionStepY)
return {
left,
top,
width,
height,
headerY,
optionYs,
}
}
/**
* D2Client.dll `0x6fb534b0` (`DIALOG_GetLineAtPoint` in `dialog.cpp`):
* Open-interval hit-testing for selectable options:
* `mouseX > left + 15 && mouseX < left + width - 15 && mouseY > rowY - 11 && mouseY < rowY + 4`
*/
export function hitTestNpcDialogOption(
layout: NpcDialogLayout,
logicalX: number,
logicalY: number,
): number | null {
if (logicalX <= layout.left + 15 || logicalX >= layout.left + layout.width - 15) {
return null
}
for (let i = 0; i < layout.optionYs.length; i++) {
const rowY = layout.optionYs[i]!
if (logicalY > rowY - 11 && logicalY < rowY + 4) {
return i
}
}
return null
}
/**
* D2Client.dll `0x6fb53840` (`DIALOG_Draw`) + `0x6fb6e4c0` (`DATA\GLOBAL\UI\menu\boxpieces.dc6`):
* - Draws translucent black background fill at `(left + 1, top, width - 2, height - 2)`
* - Tiles the 22 `14x15` carved stone pieces from `boxpieces.dc6`:
* - Top edge: frames `2..7` (`(esi % 6) + 2`) at `x = left + 12 .. right - 14` step `12`, `y = top + 12`
* - Bottom edge: frames `16..21` (`(esi % 6) + 16`) at `x = left + 13 .. right - 14` step `12`, `y = bottom + 10`
* - Left edge: frames `10..12` (`(esi % 3) + 10`) at `x = left - 4`, `y = top + 22 .. bottom - 2` step `12`
* - Right edge: frames `13..15` (`(esi % 3) + 13`) at `x = right - 7`, `y = top + 24 .. bottom - 2` step `12`
* - Corners: frame `0` `(left, top + 12)`, frame `1` `(right - 12, top + 12)`,
* frame `8` `(left, bottom + 1)`, frame `9` `(right - 12, bottom + 1)`
* - Each `14x15` DC6 cell passed `(nXpos, nYpos)` to `D2GFX_DrawCellContext` is blitted with top-left `(nXpos, nYpos - 15)`.
*/
export function drawStoneBoxFrame(
ctx: CanvasRenderingContext2D,
left: number,
top: number,
width: number,
height: number,
boxPiecesImg: HTMLImageElement | null | undefined,
): void {
if (!boxPiecesImg) {
throw new Error('Missing required baked UI asset boxpieces.png (DATA\\GLOBAL\\UI\\menu\\boxpieces.dc6)')
}
ctx.fillStyle = 'rgba(0, 0, 0, 0.85)'
ctx.fillRect(left + 1, top, Math.max(0, width - 2), Math.max(0, height - 2))
const right = left + width
const bottom = top + height
const drawPiece = (frameIdx: number, x: number, y: number): void => {
ctx.drawImage(
boxPiecesImg,
frameIdx * BOXPIECES_FRAME_W,
0,
BOXPIECES_FRAME_W,
BOXPIECES_FRAME_H,
x,
y - BOXPIECES_FRAME_H,
BOXPIECES_FRAME_W,
BOXPIECES_FRAME_H,
)
}
let esi = 0
for (let x = left + 12; x < right - 13; x += 12) {
esi++
drawPiece((esi % 6) + 2, x, top + 12)
}
for (let x = left + 13; x < right - 13; x += 12) {
esi++
drawPiece((esi % 6) + 16, x, bottom + 10)
}
for (let y = top + 22; y < bottom - 1; y += 12) {
esi++
drawPiece((esi % 3) + 10, left - 4, y)
}
for (let y = top + 24; y < bottom - 1; y += 12) {
esi++
drawPiece((esi % 3) + 13, right - 7, y)
}
drawPiece(0, left, top + 12)
drawPiece(1, right - 12, top + 12)
drawPiece(8, left, bottom + 1)
drawPiece(9, right - 12, bottom + 1)
}
import {
CANONICAL_QUESTS_BY_ACT,
type CanonicalQuestItem,
@ -505,22 +663,33 @@ export class WorldPanelsHud {
this.vendorStatusMsg = { text, color, untilMs: nowMs + 2800 }
}
/**
* D2Client.dll `0x6fba3ba0` (`gNpcMenuTable`) + `0x6faf8c50` (`NPCMENU_Open`):
* Uses official 1.13c `.tbl` string IDs:
* - `3381` (`talk`): `交談` (`talk`)
* - `4020` (`identify`): `辨視物品` (`Identify Items`)
* - `3334` (`trade` with `canRepair`): `交易/修理` (`trade/repair`)
* - `3396` (`trade` without `canRepair`): `交易` (`trade`)
* - `3398` (`gamble`): `賭博` (`gamble`)
* - `4142` (`cancel`): `取消` (`cancel`)
*/
buildNpcMenuOptions(descriptor: TownNpcServiceDescriptor): NpcMenuOption[] {
const opts: NpcMenuOption[] = [{ id: 'talk', labelZh: '交谈', labelEn: 'Talk' }]
const opts: NpcMenuOption[] = [{ id: 'talk', strIdx: 3381, labelZh: '交談', labelEn: 'talk' }]
if (descriptor.canIdentify) {
opts.push({ id: 'identify', labelZh: '鉴定物品', labelEn: 'Identify Items' })
opts.push({ id: 'identify', strIdx: 4020, labelZh: '辨視物品', labelEn: 'Identify Items' })
}
if (descriptor.canTrade) {
opts.push({
id: 'trade',
labelZh: descriptor.canRepair ? '交易 / 修理' : '交易',
labelEn: descriptor.canRepair ? 'Trade / Repair' : 'Trade',
strIdx: descriptor.canRepair ? 3334 : 3396,
labelZh: descriptor.canRepair ? '交易/修理' : '交易',
labelEn: descriptor.canRepair ? 'trade/repair' : 'trade',
})
}
if (descriptor.canGamble) {
opts.push({ id: 'gamble', labelZh: '赌博', labelEn: 'Gamble' })
opts.push({ id: 'gamble', strIdx: 3398, labelZh: '賭博', labelEn: 'gamble' })
}
opts.push({ id: 'cancel', labelZh: '取消', labelEn: 'Cancel' })
opts.push({ id: 'cancel', strIdx: 4142, labelZh: '取消', labelEn: 'cancel' })
return opts
}
@ -530,17 +699,17 @@ export class WorldPanelsHud {
screenY = 220,
): void {
const options = this.buildNpcMenuOptions(descriptor)
const clampedX = Math.max(130, Math.min(670, Math.round(screenX)))
const clampedY = Math.max(70, Math.min(400, Math.round(screenY)))
// D2Client.dll 0x6fb53829 -> 0x6fb53370: first selectable line (option index 0) is selected on open.
// Box position clamping is performed dynamically in computeNpcDialogLayout (0x6fb53580).
this.npcMenu = {
npcName: descriptor.displayName,
x: clampedX,
y: clampedY,
x: Math.round(screenX),
y: Math.round(screenY),
dialogLines: descriptor.greetingLines,
talking: false,
descriptor,
options,
hoveredOptionIdx: null,
hoveredOptionIdx: 0,
}
}
@ -935,6 +1104,7 @@ export class WorldPanelsHud {
/** Runtime quest status overrides: questId -> 'completed' | 'active' | 'locked' */
private questStatuses = new Map<string, 'completed' | 'active' | 'locked'>()
private cachedQuestsAtlasImg: HTMLImageElement | null = null
private cachedBoxPiecesImg: HTMLImageElement | null = null
getQuestStatus(quest: QuestEntry): 'completed' | 'active' | 'locked' {
return this.questStatuses.get(quest.id) ?? quest.status
@ -1006,6 +1176,17 @@ export class WorldPanelsHud {
return null
}
private getBoxPiecesImage(): HTMLImageElement | null {
if (this.cachedBoxPiecesImg) return this.cachedBoxPiecesImg
if (typeof Image !== 'undefined') {
const img = new Image()
img.src = BAKED_UI_MANIFEST.images.boxPieces ?? '/ui/boxpieces.png'
this.cachedBoxPiecesImg = img
return img
}
return null
}
/** Area entry banner state (`Entering: <Level Name>`). */
areaBanner: { titleZh: string; titleEn: string; untilMs: number } | null = null
@ -1434,25 +1615,51 @@ export class WorldPanelsHud {
}
}
handleNpcMenuMove(logicalX: number, logicalY: number): void {
if (!this.npcMenu || this.npcMenu.talking) return
const opts = this.npcMenu.options ?? []
const boxW = 216
const rowH = 26
const headerH = 32
const boxH = headerH + opts.length * rowH + 10
const bx = Math.round(this.npcMenu.x - boxW / 2)
const by = Math.round(this.npcMenu.y - boxH / 2)
getNpcMenuLayout(font?: Pick<D2FontRenderer, 'measureText'> | null): NpcDialogLayout | null {
if (!this.npcMenu) return null
const opts = this.npcMenu.options ?? [
{ id: 'talk', strIdx: 3381, labelZh: '交談', labelEn: 'talk' },
{ id: 'cancel', strIdx: 4142, labelZh: '取消', labelEn: 'cancel' },
]
return computeNpcDialogLayout(this.npcMenu.npcName, opts, this.npcMenu.x, this.npcMenu.y, font)
}
this.npcMenu.hoveredOptionIdx = null
if (logicalX >= bx + 8 && logicalX <= bx + boxW - 8) {
for (let i = 0; i < opts.length; i++) {
const ry = by + headerH + i * rowH
if (logicalY >= ry && logicalY < ry + rowH) {
this.npcMenu.hoveredOptionIdx = i
break
}
}
isPointInNpcMenu(
logicalX: number,
logicalY: number,
font?: Pick<D2FontRenderer, 'measureText'> | null,
): boolean {
if (!this.npcMenu) return false
if (this.npcMenu.talking) {
const boxW = 340
const boxH = 156
const bx = Math.max(20, Math.min(800 - boxW - 20, Math.round(this.npcMenu.x - boxW / 2)))
const by = Math.max(40, Math.min(520 - boxH, Math.round(this.npcMenu.y - boxH / 2)))
return logicalX >= bx && logicalX <= bx + boxW && logicalY >= by && logicalY <= by + boxH
}
const layout = this.getNpcMenuLayout(font)
if (!layout) return false
return (
logicalX >= layout.left &&
logicalX <= layout.left + layout.width &&
logicalY >= layout.top &&
logicalY <= layout.top + layout.height
)
}
handleNpcMenuMove(
logicalX: number,
logicalY: number,
font?: Pick<D2FontRenderer, 'measureText'> | null,
): void {
if (!this.npcMenu || this.npcMenu.talking) return
const layout = this.getNpcMenuLayout(font)
if (!layout) return
// D2Client.dll 0x6fb533c0 -> 0x6fb534b0: update selected option when cursor hits a selectable line;
// when cursor is outside selectable lines (0x6fb533de: je 0x6fb53402), keep existing selection.
const hitIdx = hitTestNpcDialogOption(layout, logicalX, logicalY)
if (hitIdx !== null) {
this.npcMenu.hoveredOptionIdx = hitIdx
}
}
@ -1463,6 +1670,7 @@ export class WorldPanelsHud {
onOpenVendor: (descriptor: TownNpcServiceDescriptor, mode: 'trade' | 'gamble') => void
onIdentifyAll: (descriptor: TownNpcServiceDescriptor) => void
},
font?: Pick<D2FontRenderer, 'measureText'> | null,
): boolean {
if (!this.npcMenu) return false
if (this.npcMenu.talking) {
@ -1470,40 +1678,42 @@ export class WorldPanelsHud {
return true
}
const opts = this.npcMenu.options ?? []
const boxW = 216
const rowH = 26
const headerH = 32
const boxH = headerH + opts.length * rowH + 10
const bx = Math.round(this.npcMenu.x - boxW / 2)
const by = Math.round(this.npcMenu.y - boxH / 2)
const opts = this.npcMenu.options ?? [
{ id: 'talk', strIdx: 3381, labelZh: '交談', labelEn: 'talk' },
{ id: 'cancel', strIdx: 4142, labelZh: '取消', labelEn: 'cancel' },
]
const layout = computeNpcDialogLayout(this.npcMenu.npcName, opts, this.npcMenu.x, this.npcMenu.y, font)
if (logicalX < bx || logicalX > bx + boxW || logicalY < by || logicalY > by + boxH) {
if (
logicalX < layout.left ||
logicalX > layout.left + layout.width ||
logicalY < layout.top ||
logicalY > layout.top + layout.height
) {
this.npcMenu = null
return true
}
for (let i = 0; i < opts.length; i++) {
const ry = by + headerH + i * rowH
if (logicalY >= ry && logicalY < ry + rowH) {
const opt = opts[i]!
const desc = this.npcMenu.descriptor
if (opt.id === 'talk') {
this.npcMenu.talking = true
} else if (opt.id === 'trade' && desc) {
this.npcMenu = null
callbacks.onOpenVendor(desc, 'trade')
} else if (opt.id === 'gamble' && desc) {
this.npcMenu = null
callbacks.onOpenVendor(desc, 'gamble')
} else if (opt.id === 'identify' && desc) {
this.npcMenu = null
callbacks.onIdentifyAll(desc)
} else {
this.npcMenu = null
}
return true
const hitIdx = hitTestNpcDialogOption(layout, logicalX, logicalY)
if (hitIdx !== null) {
this.npcMenu.hoveredOptionIdx = hitIdx
const opt = opts[hitIdx]!
const desc = this.npcMenu.descriptor
if (opt.id === 'talk') {
this.npcMenu.talking = true
} else if (opt.id === 'trade' && desc) {
this.npcMenu = null
callbacks.onOpenVendor(desc, 'trade')
} else if (opt.id === 'gamble' && desc) {
this.npcMenu = null
callbacks.onOpenVendor(desc, 'gamble')
} else if (opt.id === 'identify' && desc) {
this.npcMenu = null
callbacks.onIdentifyAll(desc)
} else {
this.npcMenu = null
}
return true
}
return true
}
@ -2217,10 +2427,18 @@ export class WorldPanelsHud {
}
/**
* Draw Town NPC Stone Interaction Menu (`npcmenu.cpp`) and Greeting Dialog Box.
* Draw Town NPC Stone Interaction Menu (`dialog.cpp` `0x6fb53840` + `npcmenu.cpp` `0x6faf8c50` + `boxpieces.dc6` `0x6fb6e4c0`).
*/
drawNpcMenu(ctx: CanvasRenderingContext2D, font: D2FontRenderer): void {
drawNpcMenu(
ctx: CanvasRenderingContext2D,
font: D2FontRenderer,
assets?: {
boxPiecesImg?: HTMLImageElement | null
focus16Img?: HTMLImageElement | null
},
): void {
if (!this.npcMenu) return
const boxPiecesImg = assets?.boxPiecesImg ?? this.getBoxPiecesImage()
if (this.npcMenu.talking) {
const boxW = 340
@ -2228,72 +2446,46 @@ export class WorldPanelsHud {
const bx = Math.max(20, Math.min(800 - boxW - 20, Math.round(this.npcMenu.x - boxW / 2)))
const by = Math.max(40, Math.min(520 - boxH, Math.round(this.npcMenu.y - boxH / 2)))
ctx.fillStyle = 'rgba(10, 8, 6, 0.94)'
ctx.fillRect(bx, by, boxW, boxH)
ctx.strokeStyle = '#a88c54'
ctx.lineWidth = 2
ctx.strokeRect(bx + 1, by + 1, boxW - 2, boxH - 2)
ctx.lineWidth = 1
drawStoneBoxFrame(ctx, bx, by, boxW, boxH, boxPiecesImg)
font.drawText(ctx, this.npcMenu.npcName, bx + boxW / 2, by + 24, {
font: 'fontexocet10',
font.drawText(ctx, this.npcMenu.npcName, bx + Math.trunc(boxW / 2), by + 24, {
font: 'font16',
color: 'gold',
align: 'center',
})
const bodyText = this.npcMenu.dialogLines.join('\n')
this.drawWrappedText(ctx, font, bodyText, bx + 18, by + 48, boxW - 36, 16, 5)
font.drawText(ctx, '[点击继续 / Click to continue]', bx + boxW / 2, by + boxH - 12, {
font: 'font8',
color: 'tan',
align: 'center',
})
this.drawWrappedText(ctx, font, bodyText, bx + 18, by + 48, boxW - 36, 16, 6)
return
}
const opts = this.npcMenu.options ?? [
{ id: 'talk', labelZh: '交谈', labelEn: 'Talk' },
{ id: 'cancel', labelZh: '取消', labelEn: 'Cancel' },
{ id: 'talk', strIdx: 3381, labelZh: '交談', labelEn: 'talk' },
{ id: 'cancel', strIdx: 4142, labelZh: '取消', labelEn: 'cancel' },
]
const boxW = 216
const rowH = 26
const headerH = 32
const boxH = headerH + opts.length * rowH + 10
const bx = Math.round(this.npcMenu.x - boxW / 2)
const by = Math.round(this.npcMenu.y - boxH / 2)
const layout = computeNpcDialogLayout(this.npcMenu.npcName, opts, this.npcMenu.x, this.npcMenu.y, font)
ctx.fillStyle = 'rgba(12, 10, 8, 0.94)'
ctx.fillRect(bx, by, boxW, boxH)
ctx.strokeStyle = '#9c824c'
ctx.lineWidth = 2
ctx.strokeRect(bx + 1, by + 1, boxW - 2, boxH - 2)
ctx.lineWidth = 1
// 1. D2Client.dll 0x6fb53890 + 0x6fb6e4c0: dark translucent fill + carved stone boxpieces.dc6 border
drawStoneBoxFrame(ctx, layout.left, layout.top, layout.width, layout.height, boxPiecesImg)
// Header divider
ctx.strokeStyle = '#5a4a30'
ctx.beginPath()
ctx.moveTo(bx + 10, by + headerH - 4)
ctx.lineTo(bx + boxW - 10, by + headerH - 4)
ctx.stroke()
const centerX = layout.left + Math.trunc(layout.width / 2)
font.drawText(ctx, this.npcMenu.npcName, bx + boxW / 2, by + 21, {
font: 'fontexocet10',
// 2. D2Client.dll 0x6faf8c67 + 0x6fb53912: Line 0 NPC Name Header in Font16 Gold (color = 4, step = 21)
font.drawText(ctx, this.npcMenu.npcName, centerX, layout.headerY, {
font: 'font16',
color: 'gold',
align: 'center',
})
// 3. D2Client.dll 0x6faf8f81 + 0x6fb53988..0x6fb53a05: Options in Font16, Blue ('blue', 3) when selected else White ('white', 0)
const selectedIdx = this.npcMenu.hoveredOptionIdx ?? 0
for (let i = 0; i < opts.length; i++) {
const opt = opts[i]!
const ry = by + headerH + i * rowH
const hovered = this.npcMenu.hoveredOptionIdx === i
if (hovered) {
ctx.fillStyle = 'rgba(64, 50, 28, 0.75)'
ctx.fillRect(bx + 6, ry + 2, boxW - 12, rowH - 4)
}
font.drawText(ctx, `${opt.labelZh} (${opt.labelEn})`, bx + boxW / 2, ry + 18, {
font: 'fontexocet10',
color: hovered ? 'white' : 'gold',
const rowY = layout.optionYs[i]!
const selected = selectedIdx === i
font.drawText(ctx, opt.labelZh, centerX, rowY, {
font: 'font16',
color: selected ? 'blue' : 'white',
align: 'center',
})
}

View File

@ -10,7 +10,11 @@ import { MpqArchive } from '../src/mpq/archive.ts'
import { fileSource } from '../src/mpq/file-source.ts'
import { MountedArchives } from '../src/mpq/mount.ts'
import { BAKED_UI_MANIFEST } from '../src/ui/baked-ui-meta.ts'
<<<<<<< HEAD
import type { D2ColorCode, D2FontName as FontSizeName, D2FontRenderer } from '../src/ui/font.ts'
=======
import type { D2ColorCode, D2FontRenderer, D2FontName } from '../src/ui/font.ts'
>>>>>>> ce00574 (fix(ui): restore authentic D2 v1.13c NPC dialog popup menu (Fixes #489))
import { InventoryPanel, type UiInventoryItem } from '../src/ui/inventory.ts'
import {
BLACKSMITH_CLASS_IDS,
@ -24,6 +28,9 @@ import {
VENDOR_GRID_ORIGIN,
VENDOR_TABS,
WorldPanelsHud,
computeNpcDialogLayout,
drawStoneBoxFrame,
hitTestNpcDialogOption,
resolveVendorButtonLayout,
} from '../src/ui/world-panels.ts'
@ -88,7 +95,7 @@ interface FontCall {
text: string
x: number
y: number
size: FontSizeName
size: D2FontName
color: D2ColorCode | undefined
}
@ -129,7 +136,7 @@ function createMockCanvasAndFont() {
text: string,
x: number,
y: number,
opts?: { font?: FontSizeName; color?: D2ColorCode; align?: 'left' | 'center' | 'right' },
opts?: { font?: D2FontName; color?: D2ColorCode; align?: 'left' | 'center' | 'right' },
) => {
fontCalls.push({
type: 'drawText',
@ -147,7 +154,7 @@ function createMockCanvasAndFont() {
text: string,
rightX: number,
y: number,
size: FontSizeName = 'font16',
size: D2FontName = 'font16',
color: D2ColorCode = 'white',
) => {
fontCalls.push({ type: 'drawRightText', text, x: rightX, y, size, color })
@ -156,7 +163,7 @@ function createMockCanvasAndFont() {
drawFramedTooltip: vi.fn(
(
_ctx: CanvasRenderingContext2D,
lines: Array<{ text: string; color?: D2ColorCode; size?: FontSizeName }>,
lines: Array<{ text: string; color?: D2ColorCode; size?: D2FontName }>,
centerX: number,
bottomY: number,
) => {
@ -473,4 +480,162 @@ describe('Diablo II 1.13c Vendor / BuySell Panel UI Parity', () => {
expect(VENDOR_BUTTON_SPECS.repair.labelZh).toBe('修復')
expect(VENDOR_BUTTON_SPECS.close.labelZh).toBe('關閉')
})
it('verifies baked boxpieces.png and focus16.png dimensions and MPQ byte parity', async () => {
expect(BAKED_UI_MANIFEST.images.boxPieces).toBe('/ui/boxpieces.png')
expect(BAKED_UI_MANIFEST.images.focus16).toBe('/ui/focus16.png')
const publicUiDir = resolve(process.cwd(), 'public/ui')
const boxPiecesBytes = new Uint8Array(readFileSync(resolve(publicUiDir, 'boxpieces.png')))
const focus16Bytes = new Uint8Array(readFileSync(resolve(publicUiDir, 'focus16.png')))
expect(readPngHeaderSize(boxPiecesBytes)).toEqual({ width: 308, height: 15 })
expect(readPngHeaderSize(focus16Bytes)).toEqual({ width: 160, height: 20 })
const d2dataPath = resolve(process.cwd(), 'samples/d2/d2data.mpq')
if (existsSync(d2dataPath)) {
const archives = new MountedArchives()
archives.add('d2data.mpq', await MpqArchive.open(await fileSource(d2dataPath)))
const pl2 = decodePl2(await archives.read('data/global/palette/ACT1/pal.pl2'))
const bpSheet = decodeDc6(await archives.read('data/global/ui/menu/boxpieces.dc6'))
const bpFrames = bpSheet.groups[0]!.frames
expect(bpFrames).toHaveLength(22)
const expectedBpPng = encodeIndexedPng({
width: 308,
height: 15,
pixels: stitchIndexed(
308,
15,
bpFrames.slice(0, 22).map((frame, i) => ({ frame, x: i * 14, y: 0 })),
),
palette: pl2.rgb,
transparentIndex: 0,
})
expect(Buffer.compare(Buffer.from(boxPiecesBytes), Buffer.from(expectedBpPng))).toBe(0)
const focusSheet = decodeDc6(await archives.read('data/global/ui/CURSOR/focus16.dc6'))
const focusFrames = focusSheet.groups[0]!.frames
expect(focusFrames).toHaveLength(8)
const expectedFocusPng = encodeIndexedPng({
width: 160,
height: 20,
pixels: stitchIndexed(
160,
20,
focusFrames.slice(0, 8).map((frame, i) => ({ frame, x: i * 20, y: 0 })),
),
palette: pl2.rgb,
transparentIndex: 0,
})
expect(Buffer.compare(Buffer.from(focus16Bytes), Buffer.from(expectedFocusPng))).toBe(0)
}
})
it('verifies D2Client.dll 0x6fb53580 computeNpcDialogLayout auto-sizing, anchor, and 800x600 clamping', () => {
const wp = new WorldPanelsHud()
expect(wp.openNpcMenuByName('Charsi', 400, 220)).toBe(true)
const { font } = createMockCanvasAndFont()
const opts = wp.npcMenu!.options!
// Charsi options: 交談 (3381), 交易/修理 (3334), 取消 (4142) -> 3 options
expect(opts.map(o => ({ id: o.id, strIdx: o.strIdx, labelZh: o.labelZh }))).toEqual([
{ id: 'talk', strIdx: 3381, labelZh: '交談' },
{ id: 'trade', strIdx: 3334, labelZh: '交易/修理' },
{ id: 'cancel', strIdx: 4142, labelZh: '取消' },
])
// Initial selection is option 0 ('talk') per 0x6fb53829 -> 0x6fb53370
expect(wp.npcMenu!.hoveredOptionIdx).toBe(0)
// With mock font (10px per char): 'Charsi' is 60px, '交易/修理' is 50px -> maxLineWidth = 60
// width = 60 + 20 = 80; height = (21 + 3 * 15) + 15 = 81
// left = 400 - 40 = 360; top = 220 - 21 = 199
const layout = computeNpcDialogLayout('Charsi', opts, 400, 220, font)
expect(layout).toEqual({
left: 360,
top: 199,
width: 80,
height: 81,
headerY: 220,
optionYs: [235, 250, 265],
})
// Viewport clamping (0x6fb53624..0x6fb5366c):
const clampedTopLeft = computeNpcDialogLayout('Charsi', opts, 5, 5, font)
expect(clampedTopLeft.left).toBe(10)
expect(clampedTopLeft.top).toBe(10)
const clampedBottomRight = computeNpcDialogLayout('Charsi', opts, 795, 590, font)
expect(clampedBottomRight.left).toBe(800 - 80)
expect(clampedBottomRight.top).toBe(600 - 81 - 48)
})
it('draws NPC popup menu with boxpieces.dc6 stone frame (0x6fb6e4c0), Font16 gold header, blue selected option, white unselected options, and no CSS strokeRect or divider', () => {
const wp = new WorldPanelsHud()
expect(wp.openNpcMenuByName('Charsi', 400, 220)).toBe(true)
const { ctx, font, drawImageCalls, fillRectCalls, strokeRectCalls, fontCalls } = createMockCanvasAndFont()
const boxPiecesImg = createFakeImage(308, 15, 'boxPieces')
// Fail-fast if boxPiecesImg is missing in drawStoneBoxFrame
expect(() => drawStoneBoxFrame(ctx, 360, 199, 80, 81, null)).toThrow(
/Missing required baked UI asset boxpieces\.png/,
)
wp.drawNpcMenu(ctx, font, { boxPiecesImg })
// 1. Dark translucent background fill at (left + 1, top, width - 2, height - 2) -> (361, 199, 78, 79), NO hover fillRect!
expect(fillRectCalls).toEqual([[361, 199, 78, 79]])
expect(strokeRectCalls).toHaveLength(0)
expect(ctx.beginPath).not.toHaveBeenCalled()
// 2. Stone border pieces drawn from boxPiecesImg, ending with the 4 corners (frames 0, 1, 8, 9)
const bpCalls = drawImageCalls.filter(c => c.tag === 'boxPieces')
expect(bpCalls.length).toBeGreaterThanOrEqual(12)
expect(bpCalls.slice(-4).map(c => c.args)).toEqual([
[0 * 14, 0, 14, 15, 360, 199 + 12 - 15, 14, 15],
[1 * 14, 0, 14, 15, 360 + 80 - 12, 199 + 12 - 15, 14, 15],
[8 * 14, 0, 14, 15, 360, 199 + 81 + 1 - 15, 14, 15],
[9 * 14, 0, 14, 15, 360 + 80 - 12, 199 + 81 + 1 - 15, 14, 15],
])
// 3. Font16 text lines: Gold header ('Charsi'), Blue selected option 0 ('交談'), White unselected options ('交易/修理', '取消')
expect(fontCalls).toEqual([
{ type: 'drawText', text: 'Charsi', x: 400, y: 220, size: 'font16', color: 'gold' },
{ type: 'drawText', text: '交談', x: 400, y: 235, size: 'font16', color: 'blue' },
{ type: 'drawText', text: '交易/修理', x: 400, y: 250, size: 'font16', color: 'white' },
{ type: 'drawText', text: '取消', x: 400, y: 265, size: 'font16', color: 'white' },
])
// 4. Open-interval hit-testing (0x6fb534b0): move to option 1 (rowY = 250, hit y ∈ (239, 254), hit x ∈ (375, 425))
const layout = wp.getNpcMenuLayout(font)!
expect(hitTestNpcDialogOption(layout, 375, 248)).toBeNull()
expect(hitTestNpcDialogOption(layout, 425, 248)).toBeNull()
expect(hitTestNpcDialogOption(layout, 400, 239)).toBeNull()
expect(hitTestNpcDialogOption(layout, 400, 254)).toBeNull()
expect(hitTestNpcDialogOption(layout, 400, 248)).toBe(1)
wp.handleNpcMenuMove(400, 248, font)
expect(wp.npcMenu!.hoveredOptionIdx).toBe(1)
// Moving outside selectable options preserves current selection per 0x6fb533de
wp.handleNpcMenuMove(365, 210, font)
expect(wp.npcMenu!.hoveredOptionIdx).toBe(1)
// Clicking option 1 ('交易/修理') opens vendor in 'trade' mode and closes npcMenu
let openedVendorMode: string | null = null
wp.handleNpcMenuClick(
400,
248,
{
onOpenVendor: (_desc, mode) => {
openedVendorMode = mode
},
onIdentifyAll: () => {},
},
font,
)
expect(openedVendorMode).toBe('trade')
expect(wp.npcMenu).toBeNull()
})
})