fix(ui): align CharacterSheet stone recess centers, bilingual CJK+space wrapping, bnet.html @font-face & pack:ui font parity (Fixes #504)

This commit is contained in:
troytt 2026-09-28 12:52:54 +00:00
parent 9d0138f69b
commit 1d7445a64e
7 changed files with 340 additions and 69 deletions

View File

@ -8,14 +8,32 @@
window.__d2SkipAutoBoot = true;
</script>
<style>
:root { color-scheme: dark; }
@font-face {
font-family: "D2Exocet";
src: url("/ui/fonts/D2Exocet.ttf") format("truetype");
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "D2Formal436";
src: url("/ui/fonts/D2Formal436.ttf") format("truetype");
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
:root {
color-scheme: dark;
--font-display: "D2Exocet", "Cinzel", "Times New Roman", "Liberation Serif", "Noto Serif", "Songti SC", "Noto Serif SC", serif;
--font-body: "D2Formal436", "Times New Roman", "Liberation Serif", "Noto Serif", "Songti SC", "Noto Serif SC", serif;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
height: 100%;
background: #000000;
color: #e8dcc8;
font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font: 13px/1.5 var(--font-body);
overflow: hidden;
user-select: none;
}

View File

@ -548,6 +548,27 @@ async function main(): Promise<void> {
writeFileSync(join(fontsDir, 'D2Formal436.ttf'), formalTtf)
// 8. Extract Item Inventory Sprites (`data\global\items\inv*.dc6`)
const manifestPath = join(outDir, 'manifest.json')
let existingManifest: Record<string, unknown> = {}
if (existsSync(manifestPath)) {
try {
existingManifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
} catch {
existingManifest = {}
}
}
if (
existsSync(join(outDir, 'items-atlas.png')) &&
existingManifest.codeToFlippyFile &&
!process.argv.includes('--repack-base-items')
) {
console.log(
`Successfully decoded ${decodedCount} UI/font DC6 sheets (0 failures) and updated public/ui/fonts/ (preserved full items-atlas.png & manifest.json)!`,
)
return
}
const codeToInvFile: Record<string, string> = {}
const uniqueInvFiles = new Set<string>()
@ -597,15 +618,6 @@ async function main(): Promise<void> {
const itemAtlasPixels = stitchFrames(ATLAS_W, atlasH, itemPlacements)
savePng('items-atlas.png', ATLAS_W, atlasH, itemAtlasPixels)
const manifestPath = join(outDir, 'manifest.json')
let existingManifest: Record<string, unknown> = {}
if (existsSync(manifestPath)) {
try {
existingManifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
} catch {
existingManifest = {}
}
}
const manifest = {
...existingManifest,
version: '1.13c',

View File

@ -20,6 +20,7 @@ import { parseTable } from '../src/game/tables.ts'
import { loadStringTableChain } from '../src/game/item-names.ts'
import { encodeIndexedPng } from './png.ts'
import { packCursorAssets } from './pack-cursor.ts'
import { buildTrueTypeFont } from './build-d2-ttf.ts'
interface BlitPlacement {
readonly frame: SpriteFrame
@ -208,12 +209,83 @@ function buildFontAtlas(name: string, dc6: SpriteSheet, tblBytes: Uint8Array): {
const placements: BlitPlacement[] = []
const glyphs: FontGlyphMetric[] = []
const qRecOff = 12 + 0x3f * 14
const qFrameIdx = qRecOff + 8 < tblBytes.length ? (tblBytes[qRecOff + 8] ?? 0x3f) : 0x3f
const placeholderCandidates = [
frames[qFrameIdx] ?? frames[0x3f],
frames[0x7f],
frames[0x81],
frames[0x8d],
frames[0x8f],
frames[0x90],
frames[0x9d],
].filter((f): f is SpriteFrame => Boolean(f && f.width > 0 && f.height > 0))
const isSameFrame = (a: SpriteFrame | undefined, b: SpriteFrame | undefined): boolean => {
if (!a || !b || a.width === 0 || b.width === 0) return false
if (a.width !== b.width || a.height !== b.height) return false
for (let k = 0; k < a.indices.length; k++) {
if (a.indices[k] !== b.indices[k]) return false
}
return true
}
const isPlaceholderQuestionMark = (f: SpriteFrame | undefined): boolean =>
placeholderCandidates.some(cand => isSameFrame(f, cand))
const dotRecOff = 12 + 0x2e * 14
const dotMetricW = dotRecOff + 3 < tblBytes.length ? (tblBytes[dotRecOff + 3] || 6) : 6
const dotFrameIdx = dotRecOff + 8 < tblBytes.length ? (tblBytes[dotRecOff + 8] ?? 0x2e) : 0x2e
const dotFrame = frames[dotFrameIdx] ?? frames[0x2e]
const dashRecOff = 12 + 0x2d * 14
const dashMetricW = dashRecOff + 3 < tblBytes.length ? (tblBytes[dashRecOff + 3] || 8) : 8
const dashFrameIdx = dashRecOff + 8 < tblBytes.length ? (tblBytes[dashRecOff + 8] ?? 0x2d) : 0x2d
const dashFrame = frames[dashFrameIdx] ?? frames[0x2d]
for (let i = 0; i < 256; i++) {
const recOff = 12 + i * 14
const metricWidth = recOff + 3 < tblBytes.length ? (tblBytes[recOff + 3] || 6) : 6
let metricWidth = recOff + 3 < tblBytes.length ? (tblBytes[recOff + 3] || 6) : 6
const metricHeight = recOff + 4 < tblBytes.length ? (tblBytes[recOff + 4] || lineHeight) : lineHeight
const frameIdx = recOff + 8 < tblBytes.length ? (tblBytes[recOff + 8] ?? i) : i
const frame = frames[frameIdx] ?? frames[i]
let frame: SpriteFrame | undefined = frames[frameIdx] ?? frames[i]
if ((i === 0xb7 || i === 0x95) && dotFrame) {
const shiftY = Math.max(1, Math.round(dotFrame.height * 0.22))
const shiftedIndices = new Uint8Array(dotFrame.width * dotFrame.height)
const shiftedMask = new Uint8Array(dotFrame.width * dotFrame.height)
for (let y = 0; y < dotFrame.height; y++) {
const srcY = y + shiftY
if (srcY < dotFrame.height) {
for (let x = 0; x < dotFrame.width; x++) {
const dstIdx = y * dotFrame.width + x
const srcIdx = srcY * dotFrame.width + x
shiftedIndices[dstIdx] = dotFrame.indices[srcIdx]!
shiftedMask[dstIdx] = dotFrame.mask[srcIdx]!
}
}
}
frame = { ...dotFrame, indices: shiftedIndices, mask: shiftedMask }
metricWidth = dotMetricW
} else if (i !== 0x3f && i >= 0x7f && isPlaceholderQuestionMark(frame)) {
if ((i === 0x96 || i === 0x97) && dashFrame) {
frame = dashFrame
metricWidth = dashMetricW
} else {
frame = undefined
}
}
if (i !== 0x20 && i !== 0xa0 && frame) {
let hasVisiblePixel = false
for (let k = 0; k < frame.indices.length; k++) {
if (frame.mask[k] !== 0 && frame.indices[k] !== 0) {
hasVisiblePixel = true
break
}
}
if (!hasVisiblePixel) {
frame = undefined
}
}
const gx = (i % 16) * cellW
const gy = Math.floor(i / 16) * cellH
if (frame) {
@ -230,6 +302,16 @@ function buildFontAtlas(name: string, dc6: SpriteSheet, tblBytes: Uint8Array): {
})
}
const pixels = stitchFrames(atlasWidth, atlasHeight, placements)
if (name === 'fontexocet10') {
for (let p = 0; p < pixels.length; p++) {
const idx = pixels[p]!
if (idx >= 15 && idx <= 32) {
pixels[p] = Math.max(15, Math.min(31, 31 - (idx - 15)))
}
}
}
return {
meta: {
name,
@ -239,7 +321,7 @@ function buildFontAtlas(name: string, dc6: SpriteSheet, tblBytes: Uint8Array): {
atlasHeight,
glyphs,
},
pixels: stitchFrames(atlasWidth, atlasHeight, placements),
pixels,
}
}
@ -559,6 +641,70 @@ async function main(): Promise<void> {
}
writeFileSync(join(fontsDir, 'metrics.json'), JSON.stringify(fontMetas, null, 2))
// 7b. Generate authentic local TrueType WebFonts (`D2Exocet.ttf` & `D2Formal436.ttf`) from 1.13c DC6 font sheets
const f42Dc6 = await decodeUiDc6('data/local/font/latin/font42.dc6')
const f42Tbl = await archives.read('data/local/font/latin/font42.tbl')
const exocetFg = new Set([4, 6, 7, 12, 13, 14, 20, 23, 24, 26, 28, 29, 30, 31, 32])
const exocetTtf = buildTrueTypeFont({
familyName: 'D2Exocet',
postScriptName: 'D2Exocet-Regular',
frames: f42Dc6.groups[0]!.frames,
tblBytes: f42Tbl,
baselineY: 32,
scale: 28,
isForeground: idx => exocetFg.has(idx),
minContourAreaPixels: 2,
refineMask: (mask, frame) => {
const w = frame.width
const h = frame.height
for (let y = 1; y < h - 1; y++) {
for (let x = 1; x < w - 1; x++) {
const i = y * w + x
const p = frame.indices[i] ?? 0
if (mask[i] && (p === 4 || p === 6) && !mask[i - 1] && !mask[i + 1]) {
mask[i] = 0
}
}
}
for (let y = 1; y < h - 1; y++) {
for (let x = 1; x < w - 1; x++) {
const i = y * w + x
const p = frame.indices[i] ?? 0
if (!mask[i] && (p === 3 || p === 18 || p === 19) && mask[i - 1] && mask[i + 1]) {
mask[i] = 1
}
}
}
},
})
writeFileSync(join(fontsDir, 'D2Exocet.ttf'), exocetTtf)
const ff12Dc6 = await decodeUiDc6('data/local/font/latin/fontformal12.dc6')
const ff12Tbl = await archives.read('data/local/font/latin/fontformal12.tbl')
const formalTtf = buildTrueTypeFont({
familyName: 'D2Formal436',
postScriptName: 'D2Formal436-Regular',
frames: ff12Dc6.groups[0]!.frames,
tblBytes: ff12Tbl,
baselineY: 26,
scale: 56,
isForeground: idx => idx >= 28,
refineMask: (mask, frame) => {
const w = frame.width
const h = frame.height
const base = mask.slice()
for (let y = 1; y < h - 1; y++) {
for (let x = 1; x < w - 1; x++) {
const i = y * w + x
if (!base[i] && frame.indices[i] === 27 && base[i - w] && base[i + w] && base[i - 1] !== base[i + 1]) {
mask[i] = 1
}
}
}
},
})
writeFileSync(join(fontsDir, 'D2Formal436.ttf'), formalTtf)
// --- STAGE 2: Comprehensive Items, Flippy Ground Sprites & Unique Items Packing ---
console.log('Extracting item sprites & flippy ground sprites (including uniqueitems.txt & setitems.txt)...')
const codeToInvFile: Record<string, string> = {}

View File

@ -161,27 +161,27 @@ export class CharacterSheetPanel {
ctx.fillRect(ox, oy, CHAR_PANEL_ORIGIN.width, CHAR_PANEL_ORIGIN.height)
}
// 3. Top Header: Character Name & Class
font.drawText(ctx, this.attrs.name, ox + 90, oy + 21, {
// 3. Top Header: Character Name (x=12..181, mid=97) & Class (x=194..309, mid=252)
font.drawText(ctx, this.attrs.name, ox + 97, oy + 21, {
font: 'fontexocet10',
color: 'gold',
align: 'center',
})
font.drawText(ctx, isEn ? this.attrs.classNameEn : this.attrs.classNameZh, ox + 236, oy + 21, {
font.drawText(ctx, isEn ? this.attrs.classNameEn : this.attrs.classNameZh, ox + 252, oy + 21, {
font: 'fontexocet10',
color: 'white',
align: 'center',
})
// 4. Level, Experience, Next Level
font.drawText(ctx, isEn ? 'Level' : '等级', ox + 42, oy + 46, { font: isEn ? 'font6' : 'font8', color: 'tan', align: 'center' })
font.drawText(ctx, String(this.attrs.level), ox + 42, oy + 62, { font: 'fontexocet10', color: 'white', align: 'center' })
// 4. Level (x=12..53, mid=33), Experience (x=66..181, mid=124), Next Level (x=194..309, mid=252)
font.drawText(ctx, isEn ? 'Level' : '等级', ox + 33, oy + 46, { font: isEn ? 'font6' : 'font8', color: 'tan', align: 'center' })
font.drawText(ctx, String(this.attrs.level), ox + 33, oy + 62, { font: 'fontexocet10', color: 'white', align: 'center' })
font.drawText(ctx, isEn ? 'Experience' : '当前经验值', ox + 135, oy + 46, { font: isEn ? 'font6' : 'font8', color: 'tan', align: 'center' })
font.drawText(ctx, this.attrs.xp.toLocaleString(), ox + 135, oy + 62, { font: 'font6', color: 'white', align: 'center' })
font.drawText(ctx, isEn ? 'Experience' : '当前经验值', ox + 124, oy + 46, { font: isEn ? 'font6' : 'font8', color: 'tan', align: 'center' })
font.drawText(ctx, this.attrs.xp.toLocaleString(), ox + 124, oy + 62, { font: 'font6', color: 'white', align: 'center' })
font.drawText(ctx, isEn ? 'Next Level' : '升级所需经验', ox + 245, oy + 46, { font: isEn ? 'font6' : 'font8', color: 'tan', align: 'center' })
font.drawText(ctx, this.attrs.nextLevelXp.toLocaleString(), ox + 245, oy + 62, { font: 'font6', color: 'white', align: 'center' })
font.drawText(ctx, isEn ? 'Next Level' : '升级所需经验', ox + 252, oy + 46, { font: isEn ? 'font6' : 'font8', color: 'tan', align: 'center' })
font.drawText(ctx, this.attrs.nextLevelXp.toLocaleString(), ox + 252, oy + 62, { font: 'font6', color: 'white', align: 'center' })
// 5. Four Primary Attributes (`Str`, `Dex`, `Vit`, `Ene`)
const statRows: readonly { key: BaseStatKey; label: string; y: number; val: number }[] = [
@ -343,27 +343,28 @@ export class CharacterSheetPanel {
for (const res of resRows) {
if (isEn) {
if (res.labelEn.length === 2) {
font.drawText(ctx, res.labelEn[0], ox + 214, oy + res.y - 4, {
font.drawText(ctx, res.labelEn[0], ox + 222, oy + res.y - 4, {
font: 'font6',
color: 'tan',
align: 'center',
})
font.drawText(ctx, res.labelEn[1], ox + 214, oy + res.y + 4, {
font.drawText(ctx, res.labelEn[1], ox + 222, oy + res.y + 4, {
font: 'font6',
color: 'tan',
align: 'center',
})
} else {
font.drawText(ctx, res.labelEn[0], ox + 214, oy + res.y, {
font.drawText(ctx, res.labelEn[0], ox + 222, oy + res.y, {
font: 'font6',
color: 'tan',
align: 'center',
})
}
} else {
font.drawText(ctx, res.labelZh, ox + 166, oy + res.y, {
font.drawText(ctx, res.labelZh, ox + 222, oy + res.y, {
font: 'font8',
color: 'tan',
align: 'center',
})
}
font.drawText(ctx, `${res.val}%`, ox + 291, oy + res.y, {

View File

@ -2849,37 +2849,12 @@ export class WorldPanelsHud {
for (const p of paragraphs) {
if (linesDrawn >= maxLines) break
if (p.includes(' ')) {
const words = p.split(/\s+/).filter(Boolean)
let line = ''
for (const word of words) {
const candidate = line ? `${line} ${word}` : word
if (line && font.measureText(candidate, fontName) > maxW) {
if (linesDrawn + 1 >= maxLines) {
const truncated = line.slice(0, Math.max(0, line.length - 2)) + '...'
font.drawText(ctx, truncated, x, curY, { font: fontName, color: 'white' })
return
}
font.drawText(ctx, line, x, curY, { font: fontName, color: 'white' })
linesDrawn++
line = word
curY += lineH
} else {
line = candidate
}
}
if (line && linesDrawn < maxLines) {
font.drawText(ctx, line, x, curY, { font: fontName, color: 'white' })
linesDrawn++
curY += lineH
}
continue
}
const rawTokens = p.match(/\s*[\x21-\xff]+|\s*[^\s\x21-\xff]/g) ?? []
let line = ''
for (let i = 0; i < p.length; i++) {
const ch = p[i]!
if (font.measureText(line + ch, fontName) > maxW) {
for (const rawToken of rawTokens) {
const trimmed = rawToken.trimStart()
const candidate = line ? `${line}${rawToken.startsWith(' ') ? ' ' : ''}${trimmed}` : trimmed
if (line && font.measureText(candidate, fontName) > maxW) {
if (linesDrawn + 1 >= maxLines) {
const truncated = line.slice(0, Math.max(0, line.length - 2)) + '...'
font.drawText(ctx, truncated, x, curY, { font: fontName, color: 'white' })
@ -2887,10 +2862,25 @@ export class WorldPanelsHud {
}
font.drawText(ctx, line, x, curY, { font: fontName, color: 'white' })
linesDrawn++
line = ch
curY += lineH
line = trimmed
} else {
line += ch
line = candidate
}
while (line && font.measureText(line, fontName) > maxW && line.length > 1) {
let splitIdx = 1
while (splitIdx < line.length && font.measureText(line.slice(0, splitIdx + 1), fontName) <= maxW) {
splitIdx++
}
if (linesDrawn + 1 >= maxLines) {
const truncated = line.slice(0, Math.max(1, splitIdx - 2)) + '...'
font.drawText(ctx, truncated, x, curY, { font: fontName, color: 'white' })
return
}
font.drawText(ctx, line.slice(0, splitIdx), x, curY, { font: fontName, color: 'white' })
linesDrawn++
curY += lineH
line = line.slice(splitIdx)
}
}
if (line && linesDrawn < maxLines) {

View File

@ -217,7 +217,7 @@ describe('Diablo II v1.13c Exocet & Formal 436 Fonts (Issue #504)', () => {
})
it('registers local @font-face D2Exocet and D2Formal436 across HTML pages and GroundLabelOverlay with zero external CDN links', () => {
const htmlFiles = ['index.html', 'acts-en.html', 'acts.html', 'acts-zh.html']
const htmlFiles = ['index.html', 'acts-en.html', 'acts.html', 'acts-zh.html', 'bnet.html']
for (const file of htmlFiles) {
const content = readFileSync(join(process.cwd(), file), 'utf8')
expect(content, `${file} must declare D2Exocet @font-face`).toMatch(/font-family:\s*['"]D2Exocet['"]/)
@ -531,7 +531,7 @@ describe('Diablo II v1.13c Exocet & Formal 436 Fonts (Issue #504)', () => {
expect(npcBodyCalls.map(c => c.text).join(' ')).toContain('great evil')
})
it('fits English Character Sheet labels inside stone recesses using DC6 font6 metrics', () => {
it('fits English and Chinese Character Sheet labels inside stone recesses using DC6 font6/font8 metrics', () => {
const metricsJson = JSON.parse(
readFileSync(join(process.cwd(), 'public', 'ui', 'fonts', 'metrics.json'), 'utf8'),
)
@ -543,14 +543,14 @@ describe('Diablo II v1.13c Exocet & Formal 436 Fonts (Issue #504)', () => {
sheet.lang = 'ENG'
sheet.visible = true
const drawnLabels: Array<{ text: string; font: D2FontName; widthPx: number; y: number }> = []
const drawnLabels: Array<{ text: string; font: D2FontName; widthPx: number; x: number; y: number; align: string }> = []
const mockCtx = {
drawImage: () => {},
fillRect: () => {},
strokeRect: () => {},
save: () => {},
restore: () => {},
measureText: (t: string) => ({ width: t.length * 6 }),
measureText: (t: string) => ({ width: t.length * 10 }),
fillText: () => {},
font: '',
textBaseline: 'alphabetic',
@ -562,16 +562,18 @@ describe('Diablo II v1.13c Exocet & Formal 436 Fonts (Issue #504)', () => {
drawText: (
_ctx: CanvasRenderingContext2D,
text: string,
_x: number,
x: number,
y: number,
opts?: { font?: D2FontName },
opts?: { font?: D2FontName; align?: string },
) => {
const f = opts?.font ?? 'font8'
drawnLabels.push({
text,
font: f,
widthPx: renderer.measureText(text, f),
x,
y,
align: opts?.align ?? 'left',
})
},
measureText: (text: string, font?: D2FontName) => renderer.measureText(text, font),
@ -597,7 +599,7 @@ describe('Diablo II v1.13c Exocet & Formal 436 Fonts (Issue #504)', () => {
spyFont,
)
// Check that multi-word English labels use font6 and fit strictly within their 104px stone recesses (ox+162..ox+266)
// Check that multi-word English labels use font6 and fit strictly within their stone recesses (95px for resistances x=175..269)
const compactLabels = [
'Left Attack',
'Right Attack',
@ -615,7 +617,7 @@ describe('Diablo II v1.13c Exocet & Formal 436 Fonts (Issue #504)', () => {
expect(match, `Expected CharacterSheetPanel to draw "${labelText}" in English mode`).toBeDefined()
expect(match!.font).toBe('font6')
expect(match!.widthPx).toBeLessThan(renderer.measureText(labelText, 'font8'))
expect(match!.widthPx).toBeLessThanOrEqual(104)
expect(match!.widthPx).toBeLessThanOrEqual(97)
}
// Verify middle dot U+00B7 (0xB7), bullet (0x95), and en/em-dashes (0x96/0x97) in metrics.json across all 6 DC6 fonts
@ -628,12 +630,110 @@ describe('Diablo II v1.13c Exocet & Formal 436 Fonts (Issue #504)', () => {
expect(metricsJson[fn].glyphs[0x97].frameW).toBeGreaterThan(0)
}
// Verify vertical centering inside stone recesses (oy=60: top recess 10..23 -> baseline 81; Str recess 83..98 -> baseline 154)
// Verify horizontal and vertical centering inside invchar6.dc6 stone recesses (ox=80, oy=60)
expect(drawnLabels.find(d => d.text === 'Isendra')?.x).toBe(80 + 97)
expect(drawnLabels.find(d => d.text === 'Isendra')?.y).toBe(60 + 21)
expect(drawnLabels.find(d => d.text === 'Sorceress')?.x).toBe(80 + 252)
expect(drawnLabels.find(d => d.text === 'Sorceress')?.y).toBe(60 + 21)
expect(drawnLabels.find(d => d.text === 'Level')?.x).toBe(80 + 33)
expect(drawnLabels.find(d => d.text === 'Experience')?.x).toBe(80 + 124)
expect(drawnLabels.find(d => d.text === 'Next Level')?.x).toBe(80 + 252)
expect(drawnLabels.find(d => d.text === 'Fire Resistance')?.x).toBe(80 + 222)
expect(drawnLabels.find(d => d.text === 'Fire Resistance')?.align).toBe('center')
expect(drawnLabels.find(d => d.text === 'Strength')?.y).toBe(60 + 94)
expect(drawnLabels.find(d => d.text === 'Dexterity')?.y).toBe(60 + 156)
expect(drawnLabels.find(d => d.text === 'Vitality')?.y).toBe(60 + 242)
expect(drawnLabels.find(d => d.text === 'Energy')?.y).toBe(60 + 304)
// Verify Chinese resistance labels are also centered inside the x=175..269 (mid=222) stone recess
drawnLabels.length = 0
sheet.lang = 'CHI'
sheet.draw(
mockCtx,
{
hp: 340,
maxHp: 340,
mana: 420,
maxMana: 420,
stamina: 180,
maxStamina: 180,
leftSkillDmg: '3-6',
rightSkillDmg: '50-100',
},
{
borderLeftImg: null,
charSheetImg: null,
buySellBtnImg: null,
},
spyFont,
)
for (const zhRes of ['火焰抗性', '冰冷抗性', '闪电抗性', '毒素抗性']) {
const match = drawnLabels.find(d => d.text === zhRes)
expect(match?.x).toBe(80 + 222)
expect(match?.align).toBe('center')
}
})
it('wraps mixed CJK + ASCII space paragraphs in Quest Log and NPC Dialog without overflowing maxW', () => {
const renderer = new D2FontRenderer()
const worldPanels = new WorldPanelsHud()
worldPanels.lang = 'CHI'
const drawnLines: Array<{ text: string; widthPx: number; font: D2FontName }> = []
const mockCtx = {
drawImage: () => {},
fillRect: () => {},
strokeRect: () => {},
beginPath: () => {},
arc: () => {},
fill: () => {},
stroke: () => {},
save: () => {},
restore: () => {},
measureText: (t: string) => ({ width: [...t].reduce((acc, c) => acc + (c.charCodeAt(0) > 0xff ? 12 : 7), 0) }),
fillText: () => {},
font: '',
textBaseline: 'alphabetic',
fillStyle: '',
strokeStyle: '',
} as unknown as CanvasRenderingContext2D
const spyFont = {
drawText: (
_ctx: CanvasRenderingContext2D,
text: string,
_x: number,
_y: number,
opts?: { font?: D2FontName },
) => {
const f = opts?.font ?? 'font8'
const widthPx = [...text].reduce((acc, c) => acc + (c.charCodeAt(0) > 0xff ? 12 : 7), 0)
drawnLines.push({ text, widthPx, font: f })
},
measureText: (text: string) =>
[...text].reduce((acc, c) => acc + (c.charCodeAt(0) > 0xff ? 12 : 7), 0),
} as unknown as D2FontRenderer
worldPanels.npcMenu = {
npcName: '阿卡拉',
x: 400,
y: 260,
talking: true,
dialogLines: [
'在荒野中有一处极其邪恶的洞穴,目盲之眼修女会的斥候报告那里聚集了大量堕落魔与僵尸。\n[奖励: +1 技能点 (Skill Point) 并解锁修女会营地信任]',
],
}
worldPanels.drawNpcMenu(mockCtx, spyFont, {
boxPiecesImg: { width: 256, height: 12, complete: true, naturalWidth: 256 } as unknown as HTMLImageElement,
focus16Img: null,
})
const bodyLines = drawnLines.filter(l => l.font === 'fontformal12')
expect(bodyLines.length).toBeGreaterThanOrEqual(3)
for (const line of bodyLines) {
// NPC dialog box maxW is boxW - 36 = 340 - 36 = 304px
expect(line.widthPx, `Line "${line.text}" should fit within 304px`).toBeLessThanOrEqual(304)
}
expect(renderer).toBeDefined()
})
})

View File

@ -32,6 +32,10 @@ function createMockCtx() {
strokeRect: () => {},
drawImage: () => {},
beginPath: () => {},
closePath: () => {},
arc: () => {},
fill: () => {},
clip: () => {},
moveTo: () => {},
lineTo: () => {},
stroke: () => {},