740 lines
28 KiB
TypeScript
740 lines
28 KiB
TypeScript
/**
|
||
* Unit & Parity Tests for Issue #504:
|
||
* Authentic Diablo II v1.13c Exocet & Formal 436 Typography across Canvas HUD & DOM/CSS.
|
||
*/
|
||
|
||
import { describe, expect, it } from 'vitest'
|
||
import { existsSync, readFileSync } from 'node:fs'
|
||
import { join } from 'node:path'
|
||
import {
|
||
D2FontRenderer,
|
||
D2_COLOR_HEX,
|
||
D2_EXOCET_FONT_STACK,
|
||
D2_FORMAL_FONT_STACK,
|
||
FONT_BASELINE_OFFSET,
|
||
FONT_PX_SIZE,
|
||
type D2ColorCode,
|
||
type D2FontName,
|
||
} 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 { parseColoredText } from '../src/ui/font.ts'
|
||
import { traceBitmapContours, buildTrueTypeFont } from '../scripts/build-d2-ttf.ts'
|
||
|
||
function signedContourArea(pts: Array<{ x: number; y: number }>): number {
|
||
let area = 0
|
||
for (let i = 0; i < pts.length; i++) {
|
||
const p1 = pts[i]!
|
||
const p2 = pts[(i + 1) % pts.length]!
|
||
area += p1.x * p2.y - p2.x * p1.y
|
||
}
|
||
return area / 2
|
||
}
|
||
|
||
function getTrueTypeGlyphContourCount(ttfBuf: Buffer, charCode: number): number {
|
||
const view = new DataView(ttfBuf.buffer, ttfBuf.byteOffset, ttfBuf.byteLength)
|
||
const numTables = view.getUint16(4, false)
|
||
const tables: Record<string, { off: number; len: number }> = {}
|
||
for (let i = 0; i < numTables; i++) {
|
||
const rec = 12 + i * 16
|
||
const tag = String.fromCharCode(
|
||
view.getUint8(rec),
|
||
view.getUint8(rec + 1),
|
||
view.getUint8(rec + 2),
|
||
view.getUint8(rec + 3),
|
||
)
|
||
tables[tag] = {
|
||
off: view.getUint32(rec + 8, false),
|
||
len: view.getUint32(rec + 12, false),
|
||
}
|
||
}
|
||
const cmapOff = tables.cmap!.off
|
||
const subOff = cmapOff + view.getUint32(cmapOff + 8, false)
|
||
const segCount = view.getUint16(subOff + 6, false) / 2
|
||
const endCodeOff = subOff + 14
|
||
const startCodeOff = endCodeOff + segCount * 2 + 2
|
||
const idDeltaOff = startCodeOff + segCount * 2
|
||
let gIdx = 0
|
||
for (let i = 0; i < segCount; i++) {
|
||
const end = view.getUint16(endCodeOff + i * 2, false)
|
||
const start = view.getUint16(startCodeOff + i * 2, false)
|
||
const delta = view.getInt16(idDeltaOff + i * 2, false)
|
||
if (charCode >= start && charCode <= end) {
|
||
gIdx = (charCode + delta) & 0xffff
|
||
break
|
||
}
|
||
}
|
||
const locaOff = tables.loca!.off
|
||
const glyfOff = tables.glyf!.off
|
||
const gStart = view.getUint32(locaOff + gIdx * 4, false)
|
||
const gEnd = view.getUint32(locaOff + (gIdx + 1) * 4, false)
|
||
if (gStart === gEnd) return 0
|
||
return view.getInt16(glyfOff + gStart, false)
|
||
}
|
||
|
||
describe('Diablo II v1.13c Exocet & Formal 436 Fonts (Issue #504)', () => {
|
||
it('bundles valid TrueType D2Exocet.ttf and D2Formal436.ttf in public/ui/fonts/ with required OpenType tables and exact glyph contour topology', () => {
|
||
const fontsDir = join(process.cwd(), 'public', 'ui', 'fonts')
|
||
const exocetPath = join(fontsDir, 'D2Exocet.ttf')
|
||
const formalPath = join(fontsDir, 'D2Formal436.ttf')
|
||
|
||
expect(existsSync(exocetPath)).toBe(true)
|
||
expect(existsSync(formalPath)).toBe(true)
|
||
|
||
for (const ttfPath of [exocetPath, formalPath]) {
|
||
const buf = readFileSync(ttfPath)
|
||
expect(buf.byteLength).toBeGreaterThan(20_000)
|
||
const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength)
|
||
|
||
// TrueType sfnt version 0x00010000
|
||
expect(view.getUint32(0, false)).toBe(0x00010000)
|
||
const numTables = view.getUint16(4, false)
|
||
expect(numTables).toBe(10)
|
||
|
||
const tags: string[] = []
|
||
for (let i = 0; i < numTables; i++) {
|
||
const recOff = 12 + i * 16
|
||
const tag = String.fromCharCode(
|
||
view.getUint8(recOff),
|
||
view.getUint8(recOff + 1),
|
||
view.getUint8(recOff + 2),
|
||
view.getUint8(recOff + 3),
|
||
)
|
||
const offset = view.getUint32(recOff + 8, false)
|
||
const length = view.getUint32(recOff + 12, false)
|
||
expect(offset + length).toBeLessThanOrEqual(buf.byteLength)
|
||
tags.push(tag)
|
||
}
|
||
|
||
expect(tags).toEqual([
|
||
'OS/2',
|
||
'cmap',
|
||
'glyf',
|
||
'head',
|
||
'hhea',
|
||
'hmtx',
|
||
'loca',
|
||
'maxp',
|
||
'name',
|
||
'post',
|
||
])
|
||
}
|
||
|
||
const exocetBuf = readFileSync(exocetPath)
|
||
// D2Exocet 'O' (U+004F) has 1 outer boundary + 4 inner holes around the central cross (⊕) = 5 contours
|
||
expect(getTrueTypeGlyphContourCount(exocetBuf, 'O'.charCodeAt(0))).toBe(5)
|
||
// D2Exocet 'Q' and 'q' (Qoppa) have 1 outer boundary + 1 enclosed inner hole = 2 contours
|
||
expect(getTrueTypeGlyphContourCount(exocetBuf, 'Q'.charCodeAt(0))).toBe(2)
|
||
expect(getTrueTypeGlyphContourCount(exocetBuf, 'q'.charCodeAt(0))).toBe(2)
|
||
// D2Exocet '=' has 2 separate horizontal bar contours
|
||
expect(getTrueTypeGlyphContourCount(exocetBuf, '='.charCodeAt(0))).toBe(2)
|
||
// Solid strokes 'E', 'F', 'H', '5' have 1 outer contour and 0 highlight/shadow pinholes
|
||
expect(getTrueTypeGlyphContourCount(exocetBuf, 'E'.charCodeAt(0))).toBe(1)
|
||
expect(getTrueTypeGlyphContourCount(exocetBuf, 'F'.charCodeAt(0))).toBe(1)
|
||
expect(getTrueTypeGlyphContourCount(exocetBuf, 'H'.charCodeAt(0))).toBe(1)
|
||
expect(getTrueTypeGlyphContourCount(exocetBuf, '5'.charCodeAt(0))).toBe(1)
|
||
|
||
const formalBuf = readFileSync(formalPath)
|
||
// D2Formal436 'O' (U+004F), 'e' (U+0065), and '&' (U+0026) have 1 outer boundary + 1 open counter hole = 2 contours
|
||
expect(getTrueTypeGlyphContourCount(formalBuf, 'O'.charCodeAt(0))).toBe(2)
|
||
expect(getTrueTypeGlyphContourCount(formalBuf, 'e'.charCodeAt(0))).toBe(2)
|
||
expect(getTrueTypeGlyphContourCount(formalBuf, '&'.charCodeAt(0))).toBe(2)
|
||
// '8' and 'B' have 1 outer boundary + 2 counter holes = 3 contours
|
||
expect(getTrueTypeGlyphContourCount(formalBuf, '8'.charCodeAt(0))).toBe(3)
|
||
expect(getTrueTypeGlyphContourCount(formalBuf, 'B'.charCodeAt(0))).toBe(3)
|
||
// '!', '?', 'i' have separate dot + stem = 2 contours
|
||
expect(getTrueTypeGlyphContourCount(formalBuf, '!'.charCodeAt(0))).toBe(2)
|
||
expect(getTrueTypeGlyphContourCount(formalBuf, '?'.charCodeAt(0))).toBe(2)
|
||
expect(getTrueTypeGlyphContourCount(formalBuf, 'i'.charCodeAt(0))).toBe(2)
|
||
})
|
||
|
||
it('traces clockwise outer contours and counter-clockwise inner holes for Exocet cross-inside-O (⊕) and Formal hollow O', () => {
|
||
// 1. Construct a 9x9 Exocet cross-inside-O (⊕): outer ring + central cross (vertical x=4, horizontal y=4)
|
||
const w = 9
|
||
const h = 9
|
||
const exocetO = new Uint8Array(w * h)
|
||
for (let y = 0; y < h; y++) {
|
||
for (let x = 0; x < w; x++) {
|
||
const isBorder = x === 0 || x === w - 1 || y === 0 || y === h - 1
|
||
const isCross = x === 4 || y === 4
|
||
if (isBorder || isCross) {
|
||
exocetO[y * w + x] = 1
|
||
}
|
||
}
|
||
}
|
||
|
||
const exocetContours = traceBitmapContours(exocetO, w, h, h, 10)
|
||
// 1 outer contour + 4 quadrant holes around the central cross = 5 contours
|
||
expect(exocetContours).toHaveLength(5)
|
||
const exocetAreas = exocetContours.map(signedContourArea)
|
||
// In Y-up font coordinates, clockwise outer contour has negative signed area,
|
||
// and counter-clockwise inner holes have positive signed area.
|
||
const outerContours = exocetAreas.filter(a => a < 0)
|
||
const innerHoles = exocetAreas.filter(a => a > 0)
|
||
expect(outerContours).toHaveLength(1)
|
||
expect(innerHoles).toHaveLength(4)
|
||
|
||
// 2. Construct a 9x9 Formal hollow O: outer ring only
|
||
const formalO = new Uint8Array(w * h)
|
||
for (let y = 0; y < h; y++) {
|
||
for (let x = 0; x < w; x++) {
|
||
const isBorder = x === 0 || x === w - 1 || y === 0 || y === h - 1
|
||
if (isBorder) {
|
||
formalO[y * w + x] = 1
|
||
}
|
||
}
|
||
}
|
||
const formalContours = traceBitmapContours(formalO, w, h, h, 10)
|
||
expect(formalContours).toHaveLength(2)
|
||
expect(formalContours.map(signedContourArea).filter(a => a < 0)).toHaveLength(1)
|
||
expect(formalContours.map(signedContourArea).filter(a => a > 0)).toHaveLength(1)
|
||
|
||
// 3. Verify buildTrueTypeFont produces a valid sfnt binary from DC6 frames + tblBytes
|
||
const frames = Array.from({ length: 256 }, (_, code) => ({
|
||
width: code === 79 ? w : 0,
|
||
height: code === 79 ? h : 0,
|
||
offsetX: 0,
|
||
offsetY: 0,
|
||
indices: code === 79 ? exocetO : new Uint8Array(0),
|
||
}))
|
||
const tblBytes = new Uint8Array(12 + 256 * 14)
|
||
tblBytes[12 + 79 * 14 + 3] = 10
|
||
tblBytes[12 + 79 * 14 + 8] = 79
|
||
|
||
const ttf = buildTrueTypeFont({
|
||
familyName: 'D2ExocetTest',
|
||
postScriptName: 'D2ExocetTest-Regular',
|
||
frames,
|
||
tblBytes,
|
||
baselineY: 8,
|
||
scale: 50,
|
||
isForeground: idx => idx > 0,
|
||
})
|
||
expect(ttf.byteLength).toBeGreaterThan(500)
|
||
const view = new DataView(ttf.buffer, ttf.byteOffset, ttf.byteLength)
|
||
expect(view.getUint32(0, false)).toBe(0x00010000)
|
||
})
|
||
|
||
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', '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['"]/)
|
||
expect(content, `${file} must reference local /ui/fonts/D2Exocet.ttf`).toMatch(/url\(['"]\/ui\/fonts\/D2Exocet\.ttf['"]\)/)
|
||
expect(content, `${file} must declare D2Formal436 @font-face`).toMatch(/font-family:\s*['"]D2Formal436['"]/)
|
||
expect(content, `${file} must reference local /ui/fonts/D2Formal436.ttf`).toMatch(/url\(['"]\/ui\/fonts\/D2Formal436\.ttf['"]\)/)
|
||
expect(content, `${file} must define --font-display with D2Exocet`).toContain('--font-display: "D2Exocet"')
|
||
expect(content, `${file} must define --font-body with D2Formal436`).toContain('--font-body: "D2Formal436"')
|
||
|
||
// Zero external CDN links
|
||
expect(content).not.toContain('fonts.googleapis.com')
|
||
expect(content).not.toContain('fonts.gstatic.com')
|
||
expect(content).not.toContain('https://')
|
||
expect(content).not.toContain('http://')
|
||
}
|
||
|
||
for (const actHtml of ['acts-en.html', 'acts.html', 'acts-zh.html']) {
|
||
const content = readFileSync(join(process.cwd(), actHtml), 'utf8')
|
||
expect(content).toMatch(/#hud\s*\{[^}]*font-family:\s*var\(--font-display\)/s)
|
||
expect(content).toMatch(/#controls\s*\{[^}]*font-family:\s*var\(--font-display\)/s)
|
||
expect(content).toMatch(/#dialog\s*\{[^}]*font-family:\s*var\(--font-body\)/s)
|
||
}
|
||
|
||
const groundLabelsSrc = readFileSync(join(process.cwd(), 'src', 'ui', 'ground-labels.ts'), 'utf8')
|
||
expect(groundLabelsSrc).toContain('"D2Exocet"')
|
||
expect(groundLabelsSrc).toContain('"Times New Roman"')
|
||
expect(groundLabelsSrc).not.toContain('Georgia')
|
||
})
|
||
|
||
it('configures D2FontRenderer with D2Exocet and D2Formal436 stacks (never Georgia) and blits genuine 1.13c DC6 glyphs when loaded', () => {
|
||
expect(D2_EXOCET_FONT_STACK).toContain('"D2Exocet"')
|
||
expect(D2_EXOCET_FONT_STACK).toContain('"Times New Roman"')
|
||
expect(D2_EXOCET_FONT_STACK).not.toContain('Georgia')
|
||
|
||
expect(D2_FORMAL_FONT_STACK).toContain('"D2Formal436"')
|
||
expect(D2_FORMAL_FONT_STACK).toContain('"Times New Roman"')
|
||
expect(D2_FORMAL_FONT_STACK).not.toContain('Georgia')
|
||
|
||
const renderer = new D2FontRenderer()
|
||
|
||
// 1. Before load() or for CJK fallback, ctx.font uses D2_EXOCET_FONT_STACK for Exocet sizes and D2_FORMAL_FONT_STACK for fontformal12
|
||
const capturedFonts: Record<string, string> = {}
|
||
const fillTextCalls: Array<{ text: string; x: number; y: number; fillStyle: string }> = []
|
||
const fallbackCtx = {
|
||
save: () => {},
|
||
restore: () => {},
|
||
measureText: (t: string) => ({ width: t.length * 7 }),
|
||
fillText: function (this: { fillStyle: string }, text: string, x: number, y: number) {
|
||
fillTextCalls.push({ text, x, y, fillStyle: this.fillStyle })
|
||
},
|
||
font: '',
|
||
textBaseline: 'alphabetic',
|
||
fillStyle: '',
|
||
} as unknown as CanvasRenderingContext2D
|
||
|
||
const allSizes: D2FontName[] = ['font6', 'font8', 'fontexocet10', 'font16', 'font30', 'fontformal12']
|
||
for (const size of allSizes) {
|
||
renderer.drawText(fallbackCtx, 'Rogue O', 100, 100, { font: size, color: 'gold' })
|
||
capturedFonts[size] = fallbackCtx.font
|
||
}
|
||
|
||
for (const nonBoldExocetSize of ['font6', 'font8'] as D2FontName[]) {
|
||
expect(capturedFonts[nonBoldExocetSize]).toBe(`${FONT_PX_SIZE[nonBoldExocetSize]}px ${D2_EXOCET_FONT_STACK}`)
|
||
}
|
||
for (const boldExocetSize of ['fontexocet10', 'font16', 'font30'] as D2FontName[]) {
|
||
expect(capturedFonts[boldExocetSize]).toBe(`bold ${FONT_PX_SIZE[boldExocetSize]}px ${D2_EXOCET_FONT_STACK}`)
|
||
}
|
||
expect(capturedFonts.fontformal12).toBe(`${FONT_PX_SIZE.fontformal12}px ${D2_FORMAL_FONT_STACK}`)
|
||
|
||
// 2. When loaded with a font atlas + metrics.json, ASCII/Latin text blits genuine DC6 bitmap glyphs via ctx.drawImage
|
||
const metricsJson = JSON.parse(
|
||
readFileSync(join(process.cwd(), 'public', 'ui', 'fonts', 'metrics.json'), 'utf8'),
|
||
)
|
||
const mockAtlas = { width: 512, height: 512, tag: 'font16-atlas' } as unknown as HTMLImageElement
|
||
renderer.setFontAtlas('font16', metricsJson.font16, mockAtlas)
|
||
|
||
const drawImageCalls: Array<{
|
||
img: unknown
|
||
sx: number
|
||
sy: number
|
||
sw: number
|
||
sh: number
|
||
dx: number
|
||
dy: number
|
||
dw: number
|
||
dh: number
|
||
}> = []
|
||
let fillTextCount = 0
|
||
const blitCtx = {
|
||
save: () => {},
|
||
restore: () => {},
|
||
measureText: (t: string) => ({ width: t.length * 8 }),
|
||
fillText: () => {
|
||
fillTextCount++
|
||
},
|
||
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: '',
|
||
} as unknown as CanvasRenderingContext2D
|
||
|
||
// Render "OT" centered at (200, 100) with shadow=true
|
||
renderer.drawText(blitCtx, 'OT', 200, 100, {
|
||
font: 'font16',
|
||
color: 'gold',
|
||
align: 'center',
|
||
shadow: true,
|
||
})
|
||
|
||
// Must use DC6 bitmap blitting (0 fillText calls, 4 drawImage calls: 2 shadow + 2 foreground for 'O' and 'T')
|
||
expect(fillTextCount).toBe(0)
|
||
expect(drawImageCalls).toHaveLength(4)
|
||
|
||
const meta16 = renderer.getFontMeta('font16')!
|
||
const glyphO = meta16.glyphs['O'.charCodeAt(0)]!
|
||
const glyphT = meta16.glyphs['T'.charCodeAt(0)]!
|
||
const advO = Math.max(4, glyphO.width)
|
||
const advT = Math.max(4, glyphT.width)
|
||
const expectedWidth = advO + advT
|
||
const expectedStartX = Math.round(200 - expectedWidth / 2)
|
||
const expectedDstY = Math.round(100 - FONT_BASELINE_OFFSET.font16)
|
||
|
||
// First glyph ('O'): shadow at (expectedStartX + 1, expectedDstY + 1), foreground at (expectedStartX, expectedDstY)
|
||
expect(drawImageCalls[0]).toMatchObject({
|
||
sx: glyphO.x,
|
||
sy: glyphO.y,
|
||
sw: glyphO.frameW,
|
||
sh: glyphO.frameH,
|
||
dx: expectedStartX + 1,
|
||
dy: expectedDstY + 1,
|
||
dw: glyphO.frameW,
|
||
dh: glyphO.frameH,
|
||
})
|
||
expect(drawImageCalls[1]).toMatchObject({
|
||
sx: glyphO.x,
|
||
sy: glyphO.y,
|
||
sw: glyphO.frameW,
|
||
sh: glyphO.frameH,
|
||
dx: expectedStartX,
|
||
dy: expectedDstY,
|
||
dw: glyphO.frameW,
|
||
dh: glyphO.frameH,
|
||
})
|
||
|
||
// Second glyph ('T'): shadow at (expectedStartX + advO + 1, expectedDstY + 1), foreground at (expectedStartX + advO, expectedDstY)
|
||
expect(drawImageCalls[2]).toMatchObject({
|
||
sx: glyphT.x,
|
||
sy: glyphT.y,
|
||
sw: glyphT.frameW,
|
||
sh: glyphT.frameH,
|
||
dx: expectedStartX + advO + 1,
|
||
dy: expectedDstY + 1,
|
||
dw: glyphT.frameW,
|
||
dh: glyphT.frameH,
|
||
})
|
||
expect(drawImageCalls[3]).toMatchObject({
|
||
sx: glyphT.x,
|
||
sy: glyphT.y,
|
||
sw: glyphT.frameW,
|
||
sh: glyphT.frameH,
|
||
dx: expectedStartX + advO,
|
||
dy: expectedDstY,
|
||
dw: glyphT.frameW,
|
||
dh: glyphT.frameH,
|
||
})
|
||
|
||
// 3. Common Unicode punctuation (en-dash, smart apostrophe, bullet) normalizes to DC6 glyphs and blits via drawImage
|
||
drawImageCalls.length = 0
|
||
fillTextCount = 0
|
||
renderer.drawText(blitCtx, 'Akara’s • 3–6', 200, 100, {
|
||
font: 'font16',
|
||
color: 'white',
|
||
shadow: false,
|
||
})
|
||
expect(fillTextCount).toBe(0)
|
||
expect(drawImageCalls.length).toBeGreaterThan(0)
|
||
|
||
// 4. CJK string ("罗格营地") automatically falls back to ctx.fillText even when DC6 atlas is loaded
|
||
drawImageCalls.length = 0
|
||
fillTextCount = 0
|
||
renderer.drawText(blitCtx, '罗格营地', 200, 100, {
|
||
font: 'font16',
|
||
color: 'gold',
|
||
align: 'center',
|
||
shadow: true,
|
||
})
|
||
expect(drawImageCalls).toHaveLength(0)
|
||
expect(fillTextCount).toBeGreaterThan(0)
|
||
|
||
// 5. Mixed ASCII + CJK string ("+45 防御") blits "+45" via DC6 drawImage (6 calls: 3 shadow + 3 fg) and draws "防御" via fillText
|
||
drawImageCalls.length = 0
|
||
fillTextCount = 0
|
||
renderer.drawText(blitCtx, '+45 防御', 200, 100, {
|
||
font: 'font16',
|
||
color: 'blue',
|
||
align: 'center',
|
||
shadow: true,
|
||
})
|
||
expect(drawImageCalls).toHaveLength(6)
|
||
expect(fillTextCount).toBe(4)
|
||
})
|
||
|
||
it('produces 12 distinct colors and tinted atlases for all 12 Diablo II color codes (ÿc0..ÿc;)', () => {
|
||
const codeSeq = 'ÿc0Wÿc1Rÿc2Gÿc3Bÿc4Dÿc5Yÿc6Kÿc7Tÿc8Oÿc9Lÿc:Eÿc;P'
|
||
const spans = parseColoredText(codeSeq)
|
||
expect(spans).toHaveLength(12)
|
||
const expectedColors: D2ColorCode[] = [
|
||
'white',
|
||
'red',
|
||
'green',
|
||
'blue',
|
||
'gold',
|
||
'gray',
|
||
'black',
|
||
'tan',
|
||
'orange',
|
||
'yellow',
|
||
'darkgreen',
|
||
'purple',
|
||
]
|
||
expect(spans.map(s => s.color)).toEqual(expectedColors)
|
||
|
||
const hexSet = new Set(expectedColors.map(c => D2_COLOR_HEX[c]))
|
||
expect(hexSet.size).toBe(12)
|
||
})
|
||
|
||
it('renders Quest Log description body and NPC speech dialog body in fontformal12 (Formal 436)', () => {
|
||
const worldPanels = new WorldPanelsHud()
|
||
worldPanels.lang = 'ENG'
|
||
worldPanels.selectedActTab = 1
|
||
worldPanels.selectedQuestIdx = 0
|
||
|
||
const drawnCalls: Array<{ text: string; font: D2FontName }> = []
|
||
const mockCtx = {
|
||
drawImage: () => {},
|
||
fillRect: () => {},
|
||
strokeRect: () => {},
|
||
beginPath: () => {},
|
||
moveTo: () => {},
|
||
lineTo: () => {},
|
||
stroke: () => {},
|
||
save: () => {},
|
||
restore: () => {},
|
||
fillStyle: '',
|
||
strokeStyle: '',
|
||
lineWidth: 1,
|
||
} as unknown as CanvasRenderingContext2D
|
||
|
||
const spyFont = {
|
||
drawText: (
|
||
_ctx: CanvasRenderingContext2D,
|
||
text: string,
|
||
_x: number,
|
||
_y: number,
|
||
opts?: { font?: D2FontName },
|
||
) => {
|
||
drawnCalls.push({ text, font: opts?.font ?? 'font16' })
|
||
},
|
||
measureText: (text: string) => text.length * 7,
|
||
} as unknown as D2FontRenderer
|
||
|
||
worldPanels.drawLeftDockPanel(
|
||
mockCtx,
|
||
'quest',
|
||
{
|
||
borderLeftImg: null,
|
||
questBgImg: null,
|
||
waypointBgImg: null,
|
||
stashBgImg: null,
|
||
cubeBgImg: null,
|
||
vendorBgImg: null,
|
||
buySellBtnImg: null,
|
||
},
|
||
spyFont,
|
||
)
|
||
|
||
// Quest title uses fontexocet10, while wrapped Quest body prose uses fontformal12
|
||
expect(drawnCalls.some(c => c.font === 'fontexocet10' && c.text.includes('Den of Evil'))).toBe(true)
|
||
const questBodyCalls = drawnCalls.filter(c => c.font === 'fontformal12')
|
||
expect(questBodyCalls.length).toBeGreaterThan(0)
|
||
expect(questBodyCalls.map(c => c.text).join(' ')).toContain('Evil')
|
||
|
||
// NPC talking dialog body also uses fontformal12 while header uses font16
|
||
drawnCalls.length = 0
|
||
worldPanels.npcMenu = {
|
||
npcName: 'Akara',
|
||
x: 400,
|
||
y: 260,
|
||
talking: true,
|
||
dialogLines: ['There is a place of great evil in the wilderness.'],
|
||
}
|
||
worldPanels.drawNpcMenu(mockCtx, spyFont, {
|
||
boxPiecesImg: { width: 256, height: 12, complete: true, naturalWidth: 256 } as unknown as HTMLImageElement,
|
||
focus16Img: null,
|
||
})
|
||
expect(drawnCalls.some(c => c.font === 'font16' && c.text === 'Akara')).toBe(true)
|
||
const npcBodyCalls = drawnCalls.filter(c => c.font === 'fontformal12')
|
||
expect(npcBodyCalls.length).toBeGreaterThan(0)
|
||
expect(npcBodyCalls.map(c => c.text).join(' ')).toContain('great evil')
|
||
})
|
||
|
||
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'),
|
||
)
|
||
const renderer = new D2FontRenderer()
|
||
renderer.setFontAtlas('font6', metricsJson.font6, { width: 256, height: 256 } as unknown as HTMLImageElement)
|
||
renderer.setFontAtlas('font8', metricsJson.font8, { width: 256, height: 256 } as unknown as HTMLImageElement)
|
||
|
||
const sheet = new CharacterSheetPanel()
|
||
sheet.lang = 'ENG'
|
||
sheet.visible = true
|
||
|
||
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 * 10 }),
|
||
fillText: () => {},
|
||
font: '',
|
||
textBaseline: 'alphabetic',
|
||
fillStyle: '',
|
||
strokeStyle: '',
|
||
} as unknown as CanvasRenderingContext2D
|
||
|
||
const spyFont = {
|
||
drawText: (
|
||
_ctx: CanvasRenderingContext2D,
|
||
text: string,
|
||
x: number,
|
||
y: number,
|
||
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),
|
||
} as unknown as D2FontRenderer
|
||
|
||
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,
|
||
)
|
||
|
||
// 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',
|
||
'Damage',
|
||
'Stat Points',
|
||
'Remaining',
|
||
'Fire Resistance',
|
||
'Cold Resistance',
|
||
'Lightning',
|
||
'Resistance',
|
||
'Poison Resistance',
|
||
]
|
||
for (const labelText of compactLabels) {
|
||
const match = drawnLabels.find(d => d.text === labelText)
|
||
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(97)
|
||
}
|
||
|
||
// Verify middle dot U+00B7 (0xB7), bullet (0x95), and en/em-dashes (0x96/0x97) in metrics.json across all 6 DC6 fonts
|
||
for (const fn of ['font6', 'font8', 'font16', 'font30', 'fontexocet10', 'fontformal12'] as const) {
|
||
expect(metricsJson[fn].glyphs[0xb7].width).toBe(metricsJson[fn].glyphs[0x2e].width)
|
||
expect(metricsJson[fn].glyphs[0xb7].frameW).toBeGreaterThan(0)
|
||
expect(metricsJson[fn].glyphs[0xb7].frameH).toBeGreaterThan(0)
|
||
expect(metricsJson[fn].glyphs[0x95].frameW).toBeGreaterThan(0)
|
||
expect(metricsJson[fn].glyphs[0x96].frameW).toBeGreaterThan(0)
|
||
expect(metricsJson[fn].glyphs[0x97].frameW).toBeGreaterThan(0)
|
||
}
|
||
|
||
// 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()
|
||
})
|
||
})
|