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:
parent
fc1085418c
commit
a05c97c438
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)}`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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?.()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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: '',
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Reference in New Issue