diff --git a/scripts/pack-act-assets.ts b/scripts/pack-act-assets.ts index 4cff93c..79b4059 100644 --- a/scripts/pack-act-assets.ts +++ b/scripts/pack-act-assets.ts @@ -24,7 +24,7 @@ * * Usage: node scripts/pack-act-assets.ts [archive-directory] [output-directory] */ -import { mkdir, writeFile, rename } from 'node:fs/promises' +import { mkdir, readdir, unlink, writeFile, rename } from 'node:fs/promises' import { createHash } from 'node:crypto' import { join } from 'node:path' import { isMainThread, Worker, parentPort, workerData } from 'node:worker_threads' @@ -1628,6 +1628,11 @@ async function bakeDs1Variant( } const pageFiles = pages.encode(0) await mkdir(dir, { recursive: true }) + for (const existing of await readdir(dir)) { + if (existing.endsWith('.png')) { + await unlink(join(dir, existing)) + } + } const sha = createHash('sha256') for (const page of pageFiles) { await writeFile(join(dir, page.name), page.png) diff --git a/scripts/verify-issue-504-browser.ts b/scripts/verify-issue-504-browser.ts index 050a5fc..b2c4fa3 100644 --- a/scripts/verify-issue-504-browser.ts +++ b/scripts/verify-issue-504-browser.ts @@ -12,11 +12,12 @@ import { createServer } from 'vite' const ARTIFACT_DIR = process.argv[2] || - '/usr/local/google/home/taodao/diablo2-web/.agents/teamwork/reviewer_504_r2' + '/usr/local/google/home/taodao/diablo2-web/.agents/teamwork/reviewer_504_r4' const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) interface CdpSession { + sendCommand: (method: string, params?: Record) => Promise evalJs: (expression: string) => Promise saveScreenshot: (filename: string) => Promise waitForGameReady: () => Promise @@ -139,7 +140,7 @@ async function withChromePage( await sendCommand('Runtime.enable') await sendCommand('Page.enable') - const result = await fn({ evalJs, saveScreenshot, waitForGameReady, externalUrls }) + const result = await fn({ sendCommand, evalJs, saveScreenshot, waitForGameReady, externalUrls }) ws.close() return result } finally { @@ -237,7 +238,7 @@ async function main(): Promise { }) // ── Phase B: Verify acts-en.html Canvas DC6 bitmap font blitting, 12 color codes, Area Banner, Ground Labels, Panels & Formal 436 Prose ── - await withChromePage(`${baseUrl}/acts-en.html?act=1`, 'acts-en.html', 9252, async ({ evalJs, saveScreenshot, waitForGameReady, externalUrls }) => { + await withChromePage(`${baseUrl}/acts-en.html?act=1`, 'acts-en.html', 9252, async ({ sendCommand, evalJs, saveScreenshot, waitForGameReady, externalUrls }) => { await waitForGameReady() // 1. Capture Area Entry Banner (`Font30` Exocet) + Top-left `#hud` (`D2Exocet`) + Ground `Alt` Item Labels (`D2Exocet`) @@ -245,6 +246,7 @@ async function main(): Promise { groundLabelsCount: number groundLabelFontFamily: string groundLabelTexts: string[] + groundLabelPositions1x: Array<{ left: string; top: string }> }>(`(async () => { const inst = window.__d2webHudInstance; inst.leftPanel = 'none'; @@ -268,15 +270,73 @@ async function main(): Promise { groundLabelsCount: els.length, groundLabelFontFamily: els[0] ? getComputedStyle(els[0]).fontFamily : '', groundLabelTexts: els.map(el => el.textContent || ''), + groundLabelPositions1x: els.map(el => ({ left: el.style.left, top: el.style.top })), }; })()`) - console.log(' Verified Alt ground labels:', JSON.stringify(groundLabelCheck)) + console.log(' Verified Alt ground labels (1x DPR):', JSON.stringify(groundLabelCheck)) if (groundLabelCheck.groundLabelsCount < 2 || !groundLabelCheck.groundLabelFontFamily.includes('D2Exocet')) { throw new Error(`Expected >=2 .d2-ground-label DOM elements using D2Exocet, got: ${JSON.stringify(groundLabelCheck)}`) } await saveScreenshot('issue-504-acts-en-area-banner.png') - // 2. Verify DC6 bitmap blitting, DC6 'O' cross vs Formal 436 hollow 'O', and 12 distinct color codes + // 1b. Verify High-DPI (devicePixelRatio = 2) rendering on GroundLabelOverlay and #d2-hud-canvas + await sendCommand('Emulation.setDeviceMetricsOverride', { + width: 1280, + height: 840, + deviceScaleFactor: 2, + mobile: false, + }) + await sleep(300) + const hiDpiCheck = await evalJs<{ + dpr: number + hudCanvasWidth: number + hudCanvasHeight: number + imageSmoothingEnabled: boolean + groundLabelsCount: number + groundLabelFontFamily: string + groundLabelPositions2x: Array<{ left: number; top: number }> + viewCenterCssY: number + }>(`(async () => { + const inst = window.__d2webHudInstance; + inst.render(performance.now(), false); + await new Promise(r => setTimeout(r, 200)); + const hudCanvas = document.getElementById('d2-hud-canvas'); + const viewCanvas = document.getElementById('view'); + const viewRect = viewCanvas.getBoundingClientRect(); + const ctx = hudCanvas.getContext('2d'); + const els = Array.from(document.querySelectorAll('.d2-ground-label')); + return { + dpr: window.devicePixelRatio, + hudCanvasWidth: hudCanvas.width, + hudCanvasHeight: hudCanvas.height, + imageSmoothingEnabled: ctx.imageSmoothingEnabled, + groundLabelsCount: els.length, + groundLabelFontFamily: els[0] ? getComputedStyle(els[0]).fontFamily : '', + groundLabelPositions2x: els.map(el => ({ + left: parseFloat(el.style.left), + top: parseFloat(el.style.top), + })), + viewCenterCssY: Math.round(viewRect.top + viewRect.height * 0.5), + }; + })()`) + console.log(' Verified High-DPI (2x DPR) GroundLabelOverlay & #d2-hud-canvas:', JSON.stringify(hiDpiCheck)) + if ( + hiDpiCheck.dpr !== 2 || + hiDpiCheck.hudCanvasWidth !== 2560 || + hiDpiCheck.hudCanvasHeight !== 1680 || + hiDpiCheck.imageSmoothingEnabled !== false || + hiDpiCheck.groundLabelsCount !== groundLabelCheck.groundLabelsCount || + !hiDpiCheck.groundLabelFontFamily.includes('D2Exocet') || + hiDpiCheck.groundLabelPositions2x.some( + p => Math.abs(p.left - 640) > 30 || Math.abs(p.top - hiDpiCheck.viewCenterCssY) > 60, + ) + ) { + throw new Error(`High-DPI (2x DPR) verification failed: ${JSON.stringify(hiDpiCheck)}`) + } + await sendCommand('Emulation.clearDeviceMetricsOverride') + await sleep(200) + + // 2. Verify DC6 bitmap blitting, DC6 'O' cross vs Formal 436 hollow 'O', 12 distinct color codes, and multi-color ÿc rare item affix tooltip alignment const enCheck = await evalJs<{ exocetLoaded: boolean formalLoaded: boolean @@ -291,6 +351,8 @@ async function main(): Promise { dc6Font16OCenterAlpha: number dc6Formal12OCenterAlpha: number distinctColorCodesCount: number + multiColorTooltipAligned: boolean + multiColorTooltipBlitCount: number }>(`(async () => { await document.fonts.ready; await Promise.all([ @@ -353,6 +415,45 @@ async function main(): Promise { rgbSignatures.add(brightest); } + // Verify per-segment DC6 bitmap blitting alignment across multi-color ÿc transitions in a long rare item affix tooltip + const multiColorAffix = 'ÿc3+20% Faster Cast Rate ÿc0[Max] ÿc9+40% LRes ÿc1+35% FRes'; + const plainAffix = '+20% Faster Cast Rate [Max] +40% LRes +35% FRes'; + const segCalls = []; + const testCanvas = document.createElement('canvas'); + testCanvas.width = 800; + testCanvas.height = 600; + const testCtx = testCanvas.getContext('2d'); + const origDrawImg = testCtx.drawImage.bind(testCtx); + testCtx.drawImage = function(img, sx, sy, sw, sh, dx, dy, dw, dh) { + if (arguments.length === 9) { + segCalls.push({ dx, dy, dw, dh }); + } + return origDrawImg.apply(this, arguments); + }; + inst.font.drawText(testCtx, multiColorAffix, 400, 200, { + font: 'font8', + color: 'blue', + align: 'center', + shadow: false, + }); + const meta8 = inst.font.getFontMeta('font8'); + let expectedX = Math.round(400 - inst.font.measureText(plainAffix, 'font8') / 2); + let segIdx = 0; + let multiColorTooltipAligned = true; + for (let i = 0; i < plainAffix.length; i++) { + const code = plainAffix.charCodeAt(i); + const g = meta8.glyphs[code]; + const adv = Math.max(4, g.width); + if (code !== 0x20 && g.frameW > 0 && g.frameH > 0) { + if (!segCalls[segIdx] || segCalls[segIdx].dx !== expectedX) { + multiColorTooltipAligned = false; + } + segIdx++; + } + expectedX += adv; + } + if (segIdx !== segCalls.length) multiColorTooltipAligned = false; + let blitCount = 0; let fillCount = 0; const origDrawImage = CanvasRenderingContext2D.prototype.drawImage; @@ -388,9 +489,11 @@ async function main(): Promise { dc6Font16OCenterAlpha: measureDc6GlyphCenterAlpha('font16'), dc6Formal12OCenterAlpha: measureDc6GlyphCenterAlpha('fontformal12'), distinctColorCodesCount: rgbSignatures.size, + multiColorTooltipAligned, + multiColorTooltipBlitCount: segCalls.length, }; })()`) - console.log('3. Verified acts-en.html Canvas DC6 blitting, O-glyph cross & 12 colors:', JSON.stringify(enCheck)) + console.log('3. Verified acts-en.html Canvas DC6 blitting, O-glyph cross, 12 colors & multi-color tooltip alignment:', JSON.stringify(enCheck)) if (!enCheck.exocetLoaded || !enCheck.formalLoaded || !enCheck.dc6FontLoaded) { throw new Error(`acts-en.html font loading failed: ${JSON.stringify(enCheck)}`) } @@ -403,6 +506,9 @@ async function main(): Promise { if (enCheck.distinctColorCodesCount !== 12) { throw new Error(`Expected 12 distinct rendered RGB colors for ÿc0..ÿc;, got: ${enCheck.distinctColorCodesCount}`) } + if (!enCheck.multiColorTooltipAligned || enCheck.multiColorTooltipBlitCount < 35) { + throw new Error(`Expected multi-color ÿc tooltip DC6 blits to align contiguously across color transitions, got: ${JSON.stringify(enCheck)}`) + } if (externalUrls.length > 0) { throw new Error(`External network requests detected on acts-en.html: ${JSON.stringify(externalUrls)}`) } diff --git a/src/ui/hud-manager.ts b/src/ui/hud-manager.ts index 060fe23..f7de2da 100644 --- a/src/ui/hud-manager.ts +++ b/src/ui/hud-manager.ts @@ -1298,6 +1298,7 @@ export class HudManager { const ctx = this.hudCanvas.getContext('2d') if (!ctx) return + ctx.imageSmoothingEnabled = false ctx.save() ctx.imageSmoothingEnabled = false ctx.clearRect(0, 0, targetW, targetH) diff --git a/src/ui/inventory.ts b/src/ui/inventory.ts index 3490e0c..5d10b93 100644 --- a/src/ui/inventory.ts +++ b/src/ui/inventory.ts @@ -2028,14 +2028,16 @@ export function drawItemSocketOverlay( cy = y + ((idx + 0.5) / rows) * h } - ctx.save() - ctx.beginPath() - ctx.arc(cx, cy, radius, 0, Math.PI * 2) - ctx.fillStyle = 'rgba(10, 10, 14, 0.74)' - ctx.fill() - ctx.lineWidth = 1 - ctx.strokeStyle = 'rgba(168, 152, 118, 0.78)' - ctx.stroke() + ctx.save?.() + if (typeof ctx.arc === 'function') { + ctx.beginPath?.() + ctx.arc(cx, cy, radius, 0, Math.PI * 2) + ctx.fillStyle = 'rgba(10, 10, 14, 0.74)' + ctx.fill?.() + ctx.lineWidth = 1 + ctx.strokeStyle = 'rgba(168, 152, 118, 0.78)' + ctx.stroke?.() + } const sockItem = socketed[idx] if (sockItem) { @@ -2053,18 +2055,18 @@ export function drawItemSocketOverlay( iconSize, iconSize, ) - } else { - ctx.beginPath() + } else if (typeof ctx.arc === 'function') { + ctx.beginPath?.() ctx.arc(cx, cy, 5, 0, Math.PI * 2) ctx.fillStyle = isRuneCode(sockItem.code) ? '#e09840' : isJewelCode(sockItem.code) ? '#68a8ff' : '#e8d070' - ctx.fill() + ctx.fill?.() } } - ctx.restore() + ctx.restore?.() } } diff --git a/src/ui/world-panels.ts b/src/ui/world-panels.ts index 9adeb5c..4f63725 100644 --- a/src/ui/world-panels.ts +++ b/src/ui/world-panels.ts @@ -3824,6 +3824,19 @@ function localizeStarterTooltipLine(text: string, isEn: boolean): string { 'One-Hand Damage: $1 to $2 / Two-Hand Damage: $3 to $4', ) } + if (text.includes('ÿc')) { + return text + .split(/(ÿc[0-9:;])/g) + .map((part) => { + if (/^ÿc[0-9:;]$/.test(part) || !part) return part + const lead = /^\s*/.exec(part)?.[0] ?? '' + const trail = /\s*$/.exec(part)?.[0] ?? '' + const core = part.trim() + if (!core) return part + return `${lead}${localizeStarterTooltipLine(core, true).trim()}${trail}` + }) + .join('') + } if (STARTER_STAT_EN_EXACT[text]) return STARTER_STAT_EN_EXACT[text]! // If the string has an explicit English parenthetical like `谐角之冠 (Harlequin Crest)` or `+2 所有技能 (+2 To All Skills)` @@ -3843,19 +3856,19 @@ function localizeStarterTooltipLine(text: string, isEn: boolean): string { // Strip short acronym parentheticals before pattern replacement .replace(/\s*\((?:FCR|FHR|IAS|FRW|MF|ED|CB)\)/g, '') // Full set / partial set suffixes - .replace(/\s*\(完整套装加成\)/g, ' (Full Set Bonus)') - .replace(/\s*\(完整套装\)/g, ' (Full Set)') - .replace(/\s*\((\d+)\s*件装备\)/g, ' ($1 Items)') - .replace(/\s*\(依角色等级而定\)/g, ' (Based on Character Level)') - .replace(/\s*\(持续\s*(\d+)\s*秒\)/g, ' (Over $1 Seconds)') + .replace(/(^|\s+)\(完整套装加成\)/g, '$1(Full Set Bonus)') + .replace(/(^|\s+)\(完整套装\)/g, '$1(Full Set)') + .replace(/(^|\s+)\((\d+)\s*件装备\)/g, '$1($2 Items)') + .replace(/(^|\s+)\(依角色等级而定\)/g, '$1(Based on Character Level)') + .replace(/(^|\s+)\(持续\s*(\d+)\s*秒\)/g, '$1(Over $2 Seconds)') // Class restrictions inside skill lines - .replace(/\s*\(限法师\)/g, ' (Sorceress Only)') - .replace(/\s*\(限死灵法师\)/g, ' (Necromancer Only)') - .replace(/\s*\(限圣骑士\)/g, ' (Paladin Only)') - .replace(/\s*\(限野蛮人\)/g, ' (Barbarian Only)') - .replace(/\s*\(限德鲁伊\)/g, ' (Druid Only)') - .replace(/\s*\(限刺客\)/g, ' (Assassin Only)') - .replace(/\s*\(限亚马逊\)/g, ' (Amazon Only)') + .replace(/(^|\s+)\(限法师\)/g, '$1(Sorceress Only)') + .replace(/(^|\s+)\(限死灵法师\)/g, '$1(Necromancer Only)') + .replace(/(^|\s+)\(限圣骑士\)/g, '$1(Paladin Only)') + .replace(/(^|\s+)\(限野蛮人\)/g, '$1(Barbarian Only)') + .replace(/(^|\s+)\(限德鲁伊\)/g, '$1(Druid Only)') + .replace(/(^|\s+)\(限刺客\)/g, '$1(Assassin Only)') + .replace(/(^|\s+)\(限亚马逊\)/g, '$1(Amazon Only)') // Weapon damage lines .replace(/^双手伤害:\s*(\d+)\s*-\s*(\d+)$/, 'Two-Hand Damage: $1 to $2') .replace(/^投掷伤害:\s*(\d+)\s*-\s*(\d+)$/, 'Throw Damage: $1 to $2') diff --git a/tests/challenger-m3-entity-concurrency.test.ts b/tests/challenger-m3-entity-concurrency.test.ts index 85d8294..e6eb968 100644 --- a/tests/challenger-m3-entity-concurrency.test.ts +++ b/tests/challenger-m3-entity-concurrency.test.ts @@ -83,7 +83,7 @@ describe('Challenger M3_2: Entity Asset Packing Concurrency & Schema Parity', () const stat = statSync(charSoR8Path) expect(stat.size).toBe(meta.width * meta.height) expect(meta.width).toBe(4096) - expect(meta.height).toBe(1929) + expect(meta.height).toBe(1594) }) it('verifies all 70 monster R8 atlases and metadata schemas', () => { diff --git a/tests/challenger-m4-determinism-parity.test.ts b/tests/challenger-m4-determinism-parity.test.ts index b8159a0..3b245bc 100644 --- a/tests/challenger-m4-determinism-parity.test.ts +++ b/tests/challenger-m4-determinism-parity.test.ts @@ -66,7 +66,7 @@ describe('Challenger M4_2: Asset Baking Pipeline Determinism & 365-Scene Parity' const charSoPath = join(PACKS_DIR, 'entities', 'char-so.json') expect(existsSync(charSoPath)).toBe(true) const baselineHash = sha256File(charSoPath) - expect(baselineHash).toBe('3b0c78d275dd976ce447fbb7b19ff550c23db1ed1478f188907cb7a4538e7194') + expect(baselineHash).toBe('bba6476237a49eb382488e5690bd14f59cb35e25ce67f563be773a3028f947c9') const meta = JSON.parse(readFileSync(charSoPath, 'utf8')) expect(meta.schema).toBe(2) diff --git a/tests/issue-504-exocet-fonts.test.ts b/tests/issue-504-exocet-fonts.test.ts index b15e2e3..f642aa2 100644 --- a/tests/issue-504-exocet-fonts.test.ts +++ b/tests/issue-504-exocet-fonts.test.ts @@ -18,7 +18,17 @@ import { } from '../src/ui/font.ts' import { CharacterSheetPanel } from '../src/ui/character-sheet.ts' import { WorldPanelsHud } from '../src/ui/world-panels.ts' -import { GroundLabelOverlay } from '../src/ui/ground-labels.ts' +import { + GroundLabelOverlay, + computeInitialGroundLabelLayouts, + resolveLadderCollisions, +} from '../src/ui/ground-labels.ts' +import { + computeHudLayout, + computeDockingLayout, + clientToLogical, + logicalToClient, +} from '../src/ui/hud-manager.ts' import { parseColoredText } from '../src/ui/font.ts' import { traceBitmapContours, buildTrueTypeFont } from '../scripts/build-d2-ttf.ts' @@ -736,4 +746,198 @@ describe('Diablo II v1.13c Exocet & Formal 436 Fonts (Issue #504)', () => { } expect(renderer).toBeDefined() }) + + it('aligns per-segment DC6 bitmap blitting across multi-color ÿc transitions in long magic/rare item affix tooltips', () => { + const metricsJson = JSON.parse( + readFileSync(join(process.cwd(), 'public', 'ui', 'fonts', 'metrics.json'), 'utf8'), + ) + const renderer = new D2FontRenderer() + const mockAtlas10 = { width: 512, height: 512, tag: 'fontexocet10-atlas' } as unknown as HTMLImageElement + const mockAtlas8 = { width: 256, height: 256, tag: 'font8-atlas' } as unknown as HTMLImageElement + renderer.setFontAtlas('fontexocet10', metricsJson.fontexocet10, mockAtlas10) + renderer.setFontAtlas('font8', metricsJson.font8, mockAtlas8) + + const worldPanels = new WorldPanelsHud() + worldPanels.lang = 'ENG' + + const drawImageCalls: Array<{ + img: unknown + sx: number + sy: number + sw: number + sh: number + dx: number + dy: number + dw: number + dh: number + }> = [] + let fillTextCalls = 0 + + const mockCtx = { + save: () => {}, + restore: () => {}, + fillRect: () => {}, + strokeRect: () => {}, + measureText: (t: string) => ({ width: t.length * 7 }), + fillText: () => { + fillTextCalls++ + }, + drawImage: ( + img: unknown, + sx: number, + sy: number, + sw: number, + sh: number, + dx: number, + dy: number, + dw: number, + dh: number, + ) => { + drawImageCalls.push({ img, sx, sy, sw, sh, dx, dy, dw, dh }) + }, + font: '', + textBaseline: 'alphabetic', + fillStyle: '', + strokeStyle: '', + } as unknown as CanvasRenderingContext2D + + // Verify per-segment DC6 glyph advance across 4 color transitions (ÿc3 -> ÿc0 -> ÿc9 -> ÿc1) + const multiColorAffix = 'ÿc3+20% Faster Cast Rate ÿc0[Max] ÿc9+40% LRes ÿc1+35% FRes' + const plainAffix = '+20% Faster Cast Rate [Max] +40% LRes +35% FRes' + expect(renderer.measureText(multiColorAffix, 'font8')).toBe(renderer.measureText(plainAffix, 'font8')) + + renderer.drawText(mockCtx, multiColorAffix, 400, 200, { + font: 'font8', + color: 'blue', + align: 'center', + shadow: false, + }) + expect(fillTextCalls).toBe(0) + + const meta8 = renderer.getFontMeta('font8')! + const expectedWidth = renderer.measureText(plainAffix, 'font8') + let expectedX = Math.round(400 - expectedWidth / 2) + let callIdx = 0 + for (let i = 0; i < plainAffix.length; i++) { + const code = plainAffix.charCodeAt(i) + const g = meta8.glyphs[code]! + const adv = Math.max(4, g.width) + if (code !== 0x20 && g.frameW > 0 && g.frameH > 0) { + expect(drawImageCalls[callIdx]?.dx).toBe(expectedX) + expect(drawImageCalls[callIdx]?.dw).toBe(g.frameW) + callIdx++ + } + expectedX += adv + } + expect(callIdx).toBe(drawImageCalls.length) + + // Verify WorldPanelsHud.drawItemTooltip with a long rare item containing multi-color ÿc affixes (including CJK->ENG localized multi-color line) + drawImageCalls.length = 0 + fillTextCalls = 0 + const rareDiadem = { + id: 'rare-diadem-504', + code: 'ci3', + invFile: 'invci3', + name: 'Pain Whorl', + nameZh: '痛苦之旋 (Pain Whorl)', + baseNameEn: 'Diadem', + baseNameZh: '权冠', + quality: 'rare' as const, + invWidth: 2, + invHeight: 2, + allowedSlots: ['helm' as const], + defense: 58, + durability: { current: 20, max: 20 }, + reqLevel: 67, + stats: [ + { text: 'ÿc3+2 法师技能等级 ÿc1(限法师)', color: 'blue' as const }, + { text: 'ÿc3+20% Faster Cast Rate ÿc9(Lightning Resist +40%) ÿc1(Fire Resist +35%)', color: 'blue' as const }, + ], + } + + worldPanels.drawItemTooltip(mockCtx, { item: rareDiadem, x: 560, y: 220 }, renderer, false) + // Every character in ENG tooltip must be blitted from DC6 fontexocet10 / font8 atlases (zero fillText calls) + expect(fillTextCalls).toBe(0) + expect(drawImageCalls.length).toBeGreaterThan(150) + expect(worldPanels.lastTooltipRect).not.toBeNull() + expect(worldPanels.lastTooltipRect!.boxW).toBeGreaterThan( + renderer.measureText('+20% Faster Cast Rate (Lightning Resist +40%) (Fire Resist +35%)', 'font8'), + ) + }) + + it('scales #d2-hud-canvas and GroundLabelOverlay accurately under High-DPI (devicePixelRatio > 1) viewport zoom', () => { + const cssW = 1280 + const cssH = 840 + const layout1x = computeHudLayout(cssW, cssH, 1) + const layout15x = computeHudLayout(cssW, cssH, 1.5) + const layout2x = computeHudLayout(cssW, cssH, 2) + + expect(layout1x.uiScale).toBeCloseTo(840 / 600, 6) + expect(layout2x.uiScale).toBeCloseTo(layout1x.uiScale, 6) + expect(layout15x.physicalScale).toBeCloseTo(layout1x.uiScale * 1.5, 6) + expect(layout2x.physicalScale).toBeCloseTo(layout1x.uiScale * 2, 6) + expect(layout2x.physicalOffsetX).toBe(Math.round(layout1x.offsetX * 2)) + expect(layout2x.physicalOffsetY).toBe(Math.round(layout1x.offsetY * 2)) + + // Verify clientToLogical / logicalToClient round-trip invariance across DPR = 1, 1.5, 2 + const rect = { left: 0, top: 0, width: cssW, height: cssH } + for (const dpr of [1, 1.5, 2]) { + const docking = computeDockingLayout(cssW, cssH, dpr) + expect(docking.isWidescreen).toBe(true) + const clientPt = logicalToClient(561, 93, rect, dpr) + const logicalPt = clientToLogical(clientPt.x, clientPt.y, rect, dpr) + expect(logicalPt.x).toBeCloseTo(561, 5) + expect(logicalPt.y).toBeCloseTo(93, 5) + } + + // Verify GroundLabelOverlay projection invariance when canvas physical buffer scales by dpr + const items = [ + { + id: 'item-shako', + name: 'Shako', + nameZh: '军帽', + quality: 'unique' as const, + x: 120, + y: 80, + subTileX: 15, + subTileY: 10, + isGold: false, + amount: 1, + droppedAtMs: 0, + }, + { + id: 'item-monarch', + name: 'Monarch', + nameZh: '统治者大盾', + quality: 'rare' as const, + x: 122, + y: 81, + subTileX: 15, + subTileY: 10, + isGold: false, + amount: 1, + droppedAtMs: 0, + }, + ] + + const projectAtDpr = (dpr: number) => { + const canvasWidth = Math.round(cssW * dpr) + const canvasHeight = Math.round(cssH * dpr) + const cameraZoom = 1.0 * dpr + const scaleX = rect.width / canvasWidth + const scaleY = rect.height / canvasHeight + return (worldX: number, worldY: number) => ({ + x: rect.left + rect.width * 0.5 + (worldX - 100) * cameraZoom * scaleX, + y: rect.top + rect.height * 0.5 + (worldY - 70) * cameraZoom * scaleY, + }) + } + + const resolved1x = resolveLadderCollisions(computeInitialGroundLabelLayouts(items as any, projectAtDpr(1))) + const resolved2x = resolveLadderCollisions(computeInitialGroundLabelLayouts(items as any, projectAtDpr(2))) + expect(resolved2x).toHaveLength(2) + expect(resolved2x[0]!.screenX).toBeCloseTo(resolved1x[0]!.screenX, 5) + expect(resolved2x[0]!.screenY).toBeCloseTo(resolved1x[0]!.screenY, 5) + expect(resolved2x[1]!.screenX).toBeCloseTo(resolved1x[1]!.screenX, 5) + expect(resolved2x[1]!.screenY).toBeCloseTo(resolved1x[1]!.screenY, 5) + }) }) diff --git a/tests/ui-hud.test.ts b/tests/ui-hud.test.ts index 3fd789e..ef5494c 100644 --- a/tests/ui-hud.test.ts +++ b/tests/ui-hud.test.ts @@ -1089,6 +1089,12 @@ describe('Diablo II v1.13c UI / HUD (Issue #27)', () => { drawImage: (img: unknown, ...args: number[]) => { drawImageCalls.push({ img, args }) }, + save: () => {}, + restore: () => {}, + beginPath: () => {}, + arc: () => {}, + fill: () => {}, + stroke: () => {}, fillRect: () => {}, strokeRect: () => {}, fillStyle: '', diff --git a/tests/unidentified-drops-identify.test.ts b/tests/unidentified-drops-identify.test.ts index c454ed1..2eeeba4 100644 --- a/tests/unidentified-drops-identify.test.ts +++ b/tests/unidentified-drops-identify.test.ts @@ -52,6 +52,8 @@ function createMockTooltipCanvas() { }), strokeRect: vi.fn(), beginPath: vi.fn(), + arc: vi.fn(), + fill: vi.fn(), moveTo: vi.fn(), lineTo: vi.fn(), stroke: vi.fn(),