fix(ui,tests): cover multi-color ÿc tooltip alignment, High-DPI HUD/label scaling, and socket/pack test parity (Fixes #504)

This commit is contained in:
troytt 2026-09-28 14:20:22 +00:00
parent fc1085418c
commit a05c97c438
10 changed files with 373 additions and 34 deletions

View File

@ -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)

View File

@ -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<void>(resolve => setTimeout(resolve, ms))
interface CdpSession {
sendCommand: <R = any>(method: string, params?: Record<string, unknown>) => Promise<R>
evalJs: <T = any>(expression: string) => Promise<T>
saveScreenshot: (filename: string) => Promise<void>
waitForGameReady: () => Promise<void>
@ -139,7 +140,7 @@ async function withChromePage<T>(
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<void> {
})
// ── 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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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)}`)
}

View File

@ -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)

View File

@ -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?.()
}
}

View File

@ -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')

View File

@ -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', () => {

View File

@ -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)

View File

@ -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)
})
})

View File

@ -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: '',

View File

@ -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(),