diff --git a/public/ui/boxpieces.png b/public/ui/boxpieces.png new file mode 100644 index 0000000..6688ebc Binary files /dev/null and b/public/ui/boxpieces.png differ diff --git a/public/ui/focus16.png b/public/ui/focus16.png new file mode 100644 index 0000000..2d9add6 Binary files /dev/null and b/public/ui/focus16.png differ diff --git a/public/ui/manifest.json b/public/ui/manifest.json index 8582bb0..6030431 100644 --- a/public/ui/manifest.json +++ b/public/ui/manifest.json @@ -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", diff --git a/scripts/pack-ui-assets.ts b/scripts/pack-ui-assets.ts index 45ec9d9..bf44c6c 100644 --- a/scripts/pack-ui-assets.ts +++ b/scripts/pack-ui-assets.ts @@ -328,6 +328,22 @@ async function main(): Promise { 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) diff --git a/scripts/pack-ui.ts b/scripts/pack-ui.ts index e215a51..dd5ad0c 100644 --- a/scripts/pack-ui.ts +++ b/scripts/pack-ui.ts @@ -371,6 +371,24 @@ async function main(): Promise { 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 { 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', diff --git a/scripts/verify-vendor-ui.ts b/scripts/verify-vendor-ui.ts index c7ea822..6a25c54 100644 --- a/scripts/verify-vendor-ui.ts +++ b/scripts/verify-vendor-ui.ts @@ -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) diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index 12fcafc..501f020 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -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 @@ -7544,9 +7570,12 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st minimap.reveal(minimapLevel(), walked.x, walked.y) const dialogPanel = document.querySelector('#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() @@ -8112,7 +8141,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 diff --git a/src/ui/baked-ui-meta.ts b/src/ui/baked-ui-meta.ts index ddc13b9..0ba7c42 100644 --- a/src/ui/baked-ui-meta.ts +++ b/src/ui/baked-ui-meta.ts @@ -83,7 +83,7 @@ export const ACT_QUEST_ASSET_MAP: Readonly 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() @@ -764,6 +766,10 @@ export class HudManager { this.hudCanvas.addEventListener('mousedown', (e) => { 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() @@ -789,18 +795,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 } @@ -1280,9 +1291,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: `, only when no split panels are open) diff --git a/src/ui/world-panels.ts b/src/ui/world-panels.ts index 8b42ecd..acb2158 100644 --- a/src/ui/world-panels.ts +++ b/src/ui/world-panels.ts @@ -181,10 +181,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 | 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, @@ -466,22 +624,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 } @@ -491,17 +660,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, } } @@ -896,6 +1065,7 @@ export class WorldPanelsHud { /** Runtime quest status overrides: questId -> 'completed' | 'active' | 'locked' */ private questStatuses = new Map() private cachedQuestsAtlasImg: HTMLImageElement | null = null + private cachedBoxPiecesImg: HTMLImageElement | null = null getQuestStatus(quest: QuestEntry): 'completed' | 'active' | 'locked' { return this.questStatuses.get(quest.id) ?? quest.status @@ -967,6 +1137,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: `). */ areaBanner: { titleZh: string; titleEn: string; untilMs: number } | null = null @@ -1395,25 +1576,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 | 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 | 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 | 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 } } @@ -1424,6 +1631,7 @@ export class WorldPanelsHud { onOpenVendor: (descriptor: TownNpcServiceDescriptor, mode: 'trade' | 'gamble') => void onIdentifyAll: (descriptor: TownNpcServiceDescriptor) => void }, + font?: Pick | null, ): boolean { if (!this.npcMenu) return false if (this.npcMenu.talking) { @@ -1431,40 +1639,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 } @@ -2178,10 +2388,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 @@ -2189,72 +2407,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', }) } diff --git a/tests/vendor-panel-ui.test.ts b/tests/vendor-panel-ui.test.ts index 9be4363..6eabefc 100644 --- a/tests/vendor-panel-ui.test.ts +++ b/tests/vendor-panel-ui.test.ts @@ -10,7 +10,7 @@ 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' -import type { D2ColorCode, D2FontRenderer, FontSizeName } from '../src/ui/font.ts' +import type { D2ColorCode, D2FontRenderer, D2FontName } from '../src/ui/font.ts' import { InventoryPanel, type UiInventoryItem } from '../src/ui/inventory.ts' import { BLACKSMITH_CLASS_IDS, @@ -24,6 +24,9 @@ import { VENDOR_GRID_ORIGIN, VENDOR_TABS, WorldPanelsHud, + computeNpcDialogLayout, + drawStoneBoxFrame, + hitTestNpcDialogOption, resolveVendorButtonLayout, } from '../src/ui/world-panels.ts' @@ -88,7 +91,7 @@ interface FontCall { text: string x: number y: number - size: FontSizeName + size: D2FontName color: D2ColorCode | undefined } @@ -129,7 +132,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 +150,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 +159,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, ) => { @@ -364,17 +367,19 @@ describe('Diablo II 1.13c Vendor / BuySell Panel UI Parity', () => { const helm: UiInventoryItem = { id: 'damaged-cap', code: 'cap', - nameEn: 'Cap', + name: 'Cap', nameZh: '帽子', + baseNameZh: '帽子', quality: 'normal', invFile: 'invcap', invWidth: 2, invHeight: 2, - equipSlots: ['head'], + allowedSlots: ['helm'], + stats: [], identified: true, durability: { current: 4, max: 12 }, } - inv.equipped.head = helm + inv.equipped.helm = helm const expectedCost = wp.getRepairAllCost(inv) expect(expectedCost).toBeGreaterThan(0) @@ -449,7 +454,7 @@ describe('Diablo II 1.13c Vendor / BuySell Panel UI Parity', () => { expect(wp.activeVendorTab).toBe('armor') // Leave only 1 non-permStore item on the armor tab and buy it - const nonPerm = wp.vendorTabPlacements.armor.find(p => !p.item.rawItem?.permStoreItem) + const nonPerm = wp.vendorTabPlacements.armor.find(p => !p.item.rawItem?.permStore) expect(nonPerm).toBeDefined() wp.vendorTabPlacements.armor = [nonPerm!] @@ -471,4 +476,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() + }) }) +