fix(ui,fonts): fix Formal 436 quest/dialog prose, TTF glyph contours, and char-sheet spacing (Issue #504 R1 review)

This commit is contained in:
troytt 2026-09-28 10:47:23 +00:00
parent 3ced5b08b9
commit f488ac8743
9 changed files with 451 additions and 50 deletions

Binary file not shown.

Binary file not shown.

View File

@ -120,13 +120,33 @@ export interface BuildTrueTypeFontOptions {
readonly baselineY: number
readonly scale: number
readonly isForeground: (paletteIdx: number) => boolean
readonly minContourAreaPixels?: number
}
function computePolygonSignedArea(pts: readonly Point[]): number {
let area2 = 0
for (let i = 0; i < pts.length; i++) {
const a = pts[i]!
const b = pts[(i + 1) % pts.length]!
area2 += a.x * b.y - b.x * a.y
}
return area2 * 0.5
}
/**
* Build a complete TrueType (`.ttf`) binary buffer from a 256-glyph DC6 sheet and `.tbl` metrics.
*/
export function buildTrueTypeFont(options: BuildTrueTypeFontOptions): Uint8Array {
const { familyName, postScriptName, frames, tblBytes, baselineY, scale, isForeground } = options
const {
familyName,
postScriptName,
frames,
tblBytes,
baselineY,
scale,
isForeground,
minContourAreaPixels = 0,
} = options
interface BuiltGlyph {
contours: Point[][]
@ -255,6 +275,11 @@ export function buildTrueTypeFont(options: BuildTrueTypeFontOptions): Uint8Array
mask[i] = isForeground(frame.indices[i] ?? 0) ? 1 : 0
}
contours = traceBitmapContours(mask, frame.width, frame.height, glyphBaselineY, scale)
if (minContourAreaPixels > 0 && contours.length > 1) {
const minAreaUnits = minContourAreaPixels * scale * scale
const filtered = contours.filter(c => Math.abs(computePolygonSignedArea(c)) > minAreaUnits)
if (filtered.length > 0) contours = filtered
}
if (yShiftUnits !== 0) {
contours = contours.map(c => c.map(p => ({ x: p.x, y: p.y + yShiftUnits })))
}

View File

@ -460,7 +460,7 @@ async function main(): Promise<void> {
// 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, 19, 20, 23, 24, 26, 28, 29, 30])
const exocetFg = new Set([4, 6, 7, 12, 13, 14, 19, 20, 23, 24, 26, 28, 29, 30, 31, 32])
const exocetTtf = buildTrueTypeFont({
familyName: 'D2Exocet',
postScriptName: 'D2Exocet-Regular',
@ -469,6 +469,7 @@ async function main(): Promise<void> {
baselineY: 32,
scale: 28,
isForeground: idx => exocetFg.has(idx),
minContourAreaPixels: 2,
})
writeFileSync(join(fontsDir, 'D2Exocet.ttf'), exocetTtf)
@ -481,7 +482,7 @@ async function main(): Promise<void> {
tblBytes: ff12Tbl,
baselineY: 26,
scale: 56,
isForeground: idx => idx >= 21,
isForeground: idx => idx >= 28,
})
writeFileSync(join(fontsDir, 'D2Formal436.ttf'), formalTtf)

View File

@ -12,7 +12,7 @@ import { createServer } from 'vite'
const ARTIFACT_DIR =
process.argv[2] ||
'/usr/local/google/home/taodao/diablo2-web/.agents/teamwork/implementer_504_r1'
'/usr/local/google/home/taodao/diablo2-web/.agents/teamwork/reviewer_504_r1'
const sleep = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms))
@ -20,6 +20,7 @@ interface CdpSession {
evalJs: <T = any>(expression: string) => Promise<T>
saveScreenshot: (filename: string) => Promise<void>
waitForGameReady: () => Promise<void>
externalUrls: string[]
}
async function withChromePage<T>(
@ -66,6 +67,26 @@ async function withChromePage<T>(
ws.onopen = () => resolve()
})
const externalUrls: string[] = []
ws.addEventListener('message', (event: MessageEvent) => {
try {
const msg = JSON.parse(String(event.data))
if (msg.method === 'Network.requestWillBeSent') {
const reqUrl = String(msg.params?.request?.url ?? '')
if (
reqUrl &&
!reqUrl.startsWith('http://127.0.0.1:') &&
!reqUrl.startsWith('data:') &&
!reqUrl.startsWith('blob:')
) {
externalUrls.push(reqUrl)
}
}
} catch {
// Ignore non-JSON CDP events
}
})
let idCounter = 1
const sendCommand = <R = any>(method: string, params: Record<string, unknown> = {}): Promise<R> =>
new Promise((resolve, reject) => {
@ -114,10 +135,11 @@ async function withChromePage<T>(
throw new Error(`Timed out waiting for game ready at ${url}`)
}
await sendCommand('Network.enable')
await sendCommand('Runtime.enable')
await sendCommand('Page.enable')
const result = await fn({ evalJs, saveScreenshot, waitForGameReady })
const result = await fn({ evalJs, saveScreenshot, waitForGameReady, externalUrls })
ws.close()
return result
} finally {
@ -139,40 +161,103 @@ async function main(): Promise<void> {
console.log(`1. Vite dev server listening at ${baseUrl}`)
try {
// ── Phase A: Verify index.html WebFont loading & computed typography ──
await withChromePage(`${baseUrl}/index.html`, 'index.html', 9251, async ({ evalJs, saveScreenshot }) => {
// ── Phase A: Verify index.html WebFont loading, pixel-level Exocet O cross vs Formal 436 hollow O, & zero external requests ──
await withChromePage(`${baseUrl}/index.html`, 'index.html', 9251, async ({ evalJs, saveScreenshot, externalUrls }) => {
await sleep(500)
const indexFonts = await evalJs<{
exocetLoaded: boolean
formalLoaded: boolean
h1FontFamily: string
descFontFamily: string
exocetOCenterAlpha: number
formalOCenterAlpha: number
}>(`(async () => {
await document.fonts.ready;
await Promise.all([
document.fonts.load('16px D2Exocet'),
document.fonts.load('16px D2Formal436'),
document.fonts.load('48px D2Exocet'),
document.fonts.load('48px D2Formal436'),
]);
const measureGlyphCenterAlpha = (fontSpec) => {
const c = document.createElement('canvas');
c.width = 96;
c.height = 96;
const ctx = c.getContext('2d');
ctx.font = fontSpec;
ctx.textBaseline = 'alphabetic';
ctx.fillStyle = '#ffffff';
ctx.fillText('O', 16, 68);
const d = ctx.getImageData(0, 0, 96, 96).data;
let minX = 96, maxX = 0, minY = 96, maxY = 0;
for (let y = 0; y < 96; y++) {
for (let x = 0; x < 96; x++) {
if (d[(y * 96 + x) * 4 + 3] > 64) {
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
}
}
const cx = Math.round((minX + maxX) / 2);
const cy = Math.round((minY + maxY) / 2);
let maxCenterAlpha = 0;
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
const a = d[((cy + dy) * 96 + (cx + dx)) * 4 + 3];
if (a > maxCenterAlpha) maxCenterAlpha = a;
}
}
return maxCenterAlpha;
};
return {
exocetLoaded: document.fonts.check('16px D2Exocet'),
formalLoaded: document.fonts.check('16px D2Formal436'),
formalLoaded: document.fonts.check('14px D2Formal436'),
h1FontFamily: getComputedStyle(document.querySelector('h1')).fontFamily,
descFontFamily: getComputedStyle(document.querySelector('.lang-desc')).fontFamily,
exocetOCenterAlpha: measureGlyphCenterAlpha('48px "D2Exocet"'),
formalOCenterAlpha: measureGlyphCenterAlpha('48px "D2Formal436"'),
};
})()`)
console.log('2. Verified index.html WebFonts:', JSON.stringify(indexFonts))
console.log('2. Verified index.html WebFonts & O-glyph center pixels:', JSON.stringify(indexFonts))
if (!indexFonts.exocetLoaded || !indexFonts.formalLoaded) {
throw new Error(`index.html failed to load D2Exocet or D2Formal436: ${JSON.stringify(indexFonts)}`)
}
if (!indexFonts.h1FontFamily.includes('D2Exocet') || !indexFonts.descFontFamily.includes('D2Formal436')) {
throw new Error(`index.html unexpected font-family: ${JSON.stringify(indexFonts)}`)
}
if (indexFonts.exocetOCenterAlpha < 128 || indexFonts.formalOCenterAlpha !== 0) {
throw new Error(`Expected D2Exocet 'O' center to have cross pixels (>128) and D2Formal436 'O' center to be hollow (0), got: ${JSON.stringify(indexFonts)}`)
}
if (externalUrls.length > 0) {
throw new Error(`External network requests detected on index.html: ${JSON.stringify(externalUrls)}`)
}
await saveScreenshot('issue-504-index.png')
})
// ── Phase B: Verify acts-en.html Canvas DC6 bitmap font blitting & DOM/CSS WebFonts ──
await withChromePage(`${baseUrl}/acts-en.html?act=1`, 'acts-en.html', 9252, async ({ evalJs, saveScreenshot, waitForGameReady }) => {
// ── 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 waitForGameReady()
// 1. Capture Area Entry Banner (`Font30` Exocet) + Top-left `#hud` (`D2Exocet`) + Ground `Alt` Item Labels (`D2Exocet`)
await evalJs(`(() => {
const inst = window.__d2webHudInstance;
inst.leftPanel = 'none';
inst.rightPanel = 'none';
inst.worldPanels.npcMenu = null;
inst.worldPanels.areaBanner = {
titleEn: 'Rogue Encampment',
titleZh: '罗格营地',
untilMs: performance.now() + 60000,
};
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Alt', code: 'AltLeft', bubbles: true }));
inst.render(performance.now(), false);
})()`)
await sleep(300)
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
const enCheck = await evalJs<{
exocetLoaded: boolean
formalLoaded: boolean
@ -183,13 +268,71 @@ async function main(): Promise<void> {
dc6FontLoaded: boolean
dc6BlitCount: number
fillTextCount: number
dc6Font30OCenterAlpha: number
dc6Font16OCenterAlpha: number
dc6Formal12OCenterAlpha: number
distinctColorCodesCount: number
}>(`(async () => {
await document.fonts.ready;
await Promise.all([
document.fonts.load('16px D2Exocet'),
document.fonts.load('16px D2Formal436'),
document.fonts.load('14px D2Formal436'),
]);
const inst = window.__d2webHudInstance;
const measureDc6GlyphCenterAlpha = (fontName) => {
const c = document.createElement('canvas');
c.width = 64;
c.height = 64;
const ctx = c.getContext('2d');
inst.font.drawText(ctx, 'O', 10, 40, { font: fontName, color: 'white', shadow: false });
const d = ctx.getImageData(0, 0, 64, 64).data;
let minX = 64, maxX = 0, minY = 64, maxY = 0;
for (let y = 0; y < 64; y++) {
for (let x = 0; x < 64; x++) {
if (d[(y * 64 + x) * 4 + 3] > 128) {
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
}
}
const cx = Math.round((minX + maxX) / 2);
const cy = Math.round((minY + maxY) / 2);
let maxCenterAlpha = 0;
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
const a = d[((cy + dy) * 64 + (cx + dx)) * 4 + 3];
if (a > maxCenterAlpha) maxCenterAlpha = a;
}
}
return maxCenterAlpha;
};
const colorCodes = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', ':', ';'];
const rgbSignatures = new Set();
for (const cc of colorCodes) {
const c = document.createElement('canvas');
c.width = 32;
c.height = 32;
const ctx = c.getContext('2d');
inst.font.drawText(ctx, 'ÿc' + cc + 'I', 4, 24, { font: 'font16', shadow: false });
const d = ctx.getImageData(0, 0, 32, 32).data;
let brightest = '0,0,0';
let maxSum = -1;
for (let i = 0; i < d.length; i += 4) {
if (d[i + 3] >= 180) {
const sum = d[i] + d[i + 1] + d[i + 2];
if (sum > maxSum) {
maxSum = sum;
brightest = d[i] + ',' + d[i + 1] + ',' + d[i + 2];
}
}
}
rgbSignatures.add(brightest);
}
let blitCount = 0;
let fillCount = 0;
const origDrawImage = CanvasRenderingContext2D.prototype.drawImage;
@ -203,6 +346,7 @@ async function main(): Promise<void> {
return origFillText.apply(this, args);
};
try {
document.dispatchEvent(new KeyboardEvent('keyup', { key: 'Alt', code: 'AltLeft', bubbles: true }));
if (inst.leftPanel !== 'char') inst.toggleLeftPanel('char');
if (inst.rightPanel !== 'inv') inst.toggleRightPanel('inv');
inst.inventory.handleMouseMove(561, 93);
@ -213,7 +357,7 @@ async function main(): Promise<void> {
}
return {
exocetLoaded: document.fonts.check('16px D2Exocet'),
formalLoaded: document.fonts.check('16px D2Formal436'),
formalLoaded: document.fonts.check('14px D2Formal436'),
hudFontFamily: getComputedStyle(document.getElementById('hud')).fontFamily,
controlsFontFamily: getComputedStyle(document.getElementById('controls')).fontFamily,
dialogFontFamily: getComputedStyle(document.getElementById('dialog')).fontFamily,
@ -221,34 +365,70 @@ async function main(): Promise<void> {
dc6FontLoaded: Boolean(inst.font?.loaded),
dc6BlitCount: blitCount,
fillTextCount: fillCount,
dc6Font30OCenterAlpha: measureDc6GlyphCenterAlpha('font30'),
dc6Font16OCenterAlpha: measureDc6GlyphCenterAlpha('font16'),
dc6Formal12OCenterAlpha: measureDc6GlyphCenterAlpha('fontformal12'),
distinctColorCodesCount: rgbSignatures.size,
};
})()`)
console.log('3. Verified acts-en.html Canvas DC6 blitting & WebFonts:', JSON.stringify(enCheck))
console.log('3. Verified acts-en.html Canvas DC6 blitting, O-glyph cross & 12 colors:', JSON.stringify(enCheck))
if (!enCheck.exocetLoaded || !enCheck.formalLoaded || !enCheck.dc6FontLoaded) {
throw new Error(`acts-en.html font loading failed: ${JSON.stringify(enCheck)}`)
}
if (enCheck.dc6BlitCount < 100 || enCheck.fillTextCount !== 0) {
throw new Error(`Expected acts-en.html HUD to use DC6 bitmap blitting (>100 blits, 0 fillText), got: ${JSON.stringify(enCheck)}`)
}
if (enCheck.dc6Font30OCenterAlpha < 200 || enCheck.dc6Font16OCenterAlpha < 200 || enCheck.dc6Formal12OCenterAlpha !== 0) {
throw new Error(`Expected DC6 font30/font16 'O' center to be solid cross (>200) and fontformal12 'O' center to be hollow (0), got: ${JSON.stringify(enCheck)}`)
}
if (enCheck.distinctColorCodesCount !== 12) {
throw new Error(`Expected 12 distinct rendered RGB colors for ÿc0..ÿc;, got: ${enCheck.distinctColorCodesCount}`)
}
if (externalUrls.length > 0) {
throw new Error(`External network requests detected on acts-en.html: ${JSON.stringify(externalUrls)}`)
}
await saveScreenshot('issue-504-acts-en-panels.png')
// Open Quest Log + Skill Tree + NPC Dialog to verify Formal 436 & Exocet in all panels
// 3. Open Quest Log + Skill Tree + Canvas NPC Speech Dialog to verify Formal 436 (`fontformal12`) & Exocet in all panels
await evalJs(`(() => {
const inst = window.__d2webHudInstance;
inst.toggleLeftPanel('quest');
inst.toggleRightPanel('skill');
const dialog = document.getElementById('dialog');
if (dialog) {
dialog.innerHTML = '<div class="dialog-title">Akara · High Priestess of the Sisterhood</div><div class="dialog-body">I am Akara, High Priestess of the Sisterhood of the Sightless Eye. I welcome you, traveler, to our camp, but I am afraid I can offer you but poor shelter within these rickety walls.</div>';
dialog.hidden = false;
dialog.style.display = 'block';
}
inst.inventory.hoveredItem = null;
if (inst.leftPanel !== 'quest') inst.toggleLeftPanel('quest');
if (inst.rightPanel !== 'skill') inst.toggleRightPanel('skill');
inst.worldPanels.npcMenu = {
npcId: 1,
npcName: 'Akara',
x: 400,
y: 440,
talking: true,
dialogLines: [
'I am Akara, High Priestess of the Sisterhood of the Sightless Eye.',
'Welcome to our camp, traveler. Please cleanse the Den of Evil.',
],
};
inst.render(performance.now(), false);
})()`)
await sleep(300)
await saveScreenshot('issue-504-acts-en-quests-skills-dialog.png')
})
// ── Phase C: Verify acts-zh.html bilingual CJK + Exocet/Formal 436 rendering ──
await withChromePage(`${baseUrl}/acts-zh.html?act=1`, 'acts-zh.html', 9253, async ({ evalJs, saveScreenshot, waitForGameReady, externalUrls }) => {
await waitForGameReady()
await evalJs(`(() => {
const inst = window.__d2webHudInstance;
if (inst.leftPanel !== 'char') inst.toggleLeftPanel('char');
if (inst.rightPanel !== 'inv') inst.toggleRightPanel('inv');
inst.inventory.handleMouseMove(561, 93);
inst.render(performance.now(), false);
})()`)
await sleep(300)
if (externalUrls.length > 0) {
throw new Error(`External network requests detected on acts-zh.html: ${JSON.stringify(externalUrls)}`)
}
await saveScreenshot('issue-504-acts-zh-bilingual.png')
})
console.log('✅ All Issue #504 Headless Browser E2E Checks PASSED!')
} finally {
await server.close()

View File

@ -238,12 +238,12 @@ export class CharacterSheetPanel {
// 6. Combat Ratings, Stamina, Life, Mana
if (isEn) {
font.drawText(ctx, 'Left Attack', ox + 214, oy + 87, {
font.drawText(ctx, 'Left Attack', ox + 214, oy + 90, {
font: 'font6',
color: 'tan',
align: 'center',
})
font.drawText(ctx, 'Damage', ox + 214, oy + 95, {
font.drawText(ctx, 'Damage', ox + 214, oy + 98, {
font: 'font6',
color: 'tan',
align: 'center',
@ -261,12 +261,12 @@ export class CharacterSheetPanel {
})
if (isEn) {
font.drawText(ctx, 'Right Attack', ox + 214, oy + 111, {
font.drawText(ctx, 'Right Attack', ox + 214, oy + 114, {
font: 'font6',
color: 'tan',
align: 'center',
})
font.drawText(ctx, 'Damage', ox + 214, oy + 119, {
font.drawText(ctx, 'Damage', ox + 214, oy + 121, {
font: 'font6',
color: 'tan',
align: 'center',
@ -343,12 +343,12 @@ 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 + 214, oy + res.y - 2, {
font: 'font6',
color: 'tan',
align: 'center',
})
font.drawText(ctx, res.labelEn[1], ox + 214, oy + res.y + 5, {
font.drawText(ctx, res.labelEn[1], ox + 214, oy + res.y + 6, {
font: 'font6',
color: 'tan',
align: 'center',

View File

@ -175,6 +175,24 @@ function parseHexRgb(hex: string): readonly [number, number, number] {
return [(num >> 16) & 0xff, (num >> 8) & 0xff, num & 0xff]
}
function normalizeDc6CharCode(rawCode: number): number {
switch (rawCode) {
case 0x2013:
case 0x2014:
return 0x2d // '-'
case 0x2018:
case 0x2019:
return 0x27 // "'"
case 0x201c:
case 0x201d:
return 0x22 // '"'
case 0x2022:
return 0xb7 // '·'
default:
return rawCode
}
}
/**
* Runtime Diablo II Font Manager supporting both bitmap DC6 glyphs and CJK fallback.
*/
@ -305,7 +323,7 @@ export class D2FontRenderer {
let total = 0
for (const span of spans) {
for (let i = 0; i < span.text.length; i++) {
const code = span.text.charCodeAt(i)
const code = normalizeDc6CharCode(span.text.charCodeAt(i))
if (code < 256 && meta?.glyphs[code]) {
total += Math.max(4, meta.glyphs[code]!.width)
} else {
@ -322,8 +340,10 @@ export class D2FontRenderer {
if (!meta) return false
for (const span of spans) {
for (let i = 0; i < span.text.length; i++) {
const code = span.text.charCodeAt(i)
if (code >= 256 || !meta.glyphs[code]) return false
const code = normalizeDc6CharCode(span.text.charCodeAt(i))
const g = code < 256 ? meta.glyphs[code] : undefined
if (!g) return false
if (code !== 0x20 && code !== 0xa0 && (g.frameW <= 0 || g.frameH <= 0)) return false
}
}
return true
@ -372,7 +392,7 @@ export class D2FontRenderer {
for (const span of spans) {
const tinted = this.getTintedAtlas(font, span.color)
for (let i = 0; i < span.text.length; i++) {
const code = span.text.charCodeAt(i)
const code = normalizeDc6CharCode(span.text.charCodeAt(i))
const g = meta.glyphs[code]!
const adv = Math.max(4, g.width)
if (code !== 0x20 && code !== 0xa0 && g.frameW > 0 && g.frameH > 0 && tinted) {

View File

@ -2383,7 +2383,7 @@ export class WorldPanelsHud {
ctx.strokeStyle = '#6c5838'
ctx.strokeRect(ox + 18.5, oy + 264.5, 283, 115)
font.drawText(ctx, `${qInfo.title} ${qInfo.statusTag}`, ox + 160, oy + 282, {
font.drawText(ctx, `${qInfo.title} ${qInfo.statusTag}`, ox + 160, oy + 280, {
font: 'fontexocet10',
color: qInfo.status === 'completed' ? 'gold' : 'white',
align: 'center',
@ -2391,7 +2391,7 @@ export class WorldPanelsHud {
const rewardSuffix = qInfo.status === 'completed'
? (qInfo.rewardText ? (isEn ? `\n[Reward: ${qInfo.rewardText}]` : `\n[奖励: ${qInfo.rewardText}]`) : '')
: (qInfo.rewardText ? (isEn ? `\n[Objective: ${qInfo.rewardText}]` : `\n[目标: ${qInfo.rewardText}]`) : '')
this.drawWrappedText(ctx, font, qInfo.body + rewardSuffix, ox + 26, oy + 300, 268, 14, 5)
this.drawWrappedText(ctx, font, qInfo.body + rewardSuffix, ox + 26, oy + 300, 268, 14, 6, 'fontformal12')
}
} else if (kind === 'waypoint') {
if (assets.waypointBgImg) ctx.drawImage(assets.waypointBgImg, ox, oy)
@ -2757,7 +2757,7 @@ export class WorldPanelsHud {
? getLocalizedNpcGreetings(this.npcMenu.descriptor, this.lang)
: this.npcMenu.dialogLines
const bodyText = dialogLines.join('\n')
this.drawWrappedText(ctx, font, bodyText, bx + 18, by + 48, boxW - 36, 16, 6)
this.drawWrappedText(ctx, font, bodyText, bx + 18, by + 48, boxW - 36, 16, 6, 'fontformal12')
return
}
@ -2841,6 +2841,7 @@ export class WorldPanelsHud {
maxW: number,
lineH: number,
maxLines = 10,
fontName: 'fontformal12' | 'font8' = 'fontformal12',
): void {
const paragraphs = text.split(/\r?\n/)
let curY = y
@ -2853,13 +2854,13 @@ export class WorldPanelsHud {
let line = ''
for (const word of words) {
const candidate = line ? `${line} ${word}` : word
if (line && font.measureText(candidate, 'font8') > maxW) {
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: 'font8', color: 'white' })
font.drawText(ctx, truncated, x, curY, { font: fontName, color: 'white' })
return
}
font.drawText(ctx, line, x, curY, { font: 'font8', color: 'white' })
font.drawText(ctx, line, x, curY, { font: fontName, color: 'white' })
linesDrawn++
line = word
curY += lineH
@ -2868,7 +2869,7 @@ export class WorldPanelsHud {
}
}
if (line && linesDrawn < maxLines) {
font.drawText(ctx, line, x, curY, { font: 'font8', color: 'white' })
font.drawText(ctx, line, x, curY, { font: fontName, color: 'white' })
linesDrawn++
curY += lineH
}
@ -2878,13 +2879,13 @@ export class WorldPanelsHud {
let line = ''
for (let i = 0; i < p.length; i++) {
const ch = p[i]!
if (font.measureText(line + ch, 'font8') > maxW) {
if (font.measureText(line + ch, fontName) > maxW) {
if (linesDrawn + 1 >= maxLines) {
const truncated = line.slice(0, Math.max(0, line.length - 2)) + '...'
font.drawText(ctx, truncated, x, curY, { font: 'font8', color: 'white' })
font.drawText(ctx, truncated, x, curY, { font: fontName, color: 'white' })
return
}
font.drawText(ctx, line, x, curY, { font: 'font8', color: 'white' })
font.drawText(ctx, line, x, curY, { font: fontName, color: 'white' })
linesDrawn++
line = ch
curY += lineH
@ -2893,7 +2894,7 @@ export class WorldPanelsHud {
}
}
if (line && linesDrawn < maxLines) {
font.drawText(ctx, line, x, curY, { font: 'font8', color: 'white' })
font.drawText(ctx, line, x, curY, { font: fontName, color: 'white' })
linesDrawn++
curY += lineH
}

View File

@ -17,7 +17,9 @@ import {
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 {
@ -30,8 +32,49 @@ function signedContourArea(pts: Array<{ x: number; y: number }>): number {
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', () => {
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')
@ -77,6 +120,26 @@ describe('Diablo II v1.13c Exocet & Formal 436 Fonts (Issue #504)', () => {
'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)
// Solid strokes 'E', 'F', 'H' have 1 outer contour and 0 highlight 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)
const formalBuf = readFileSync(formalPath)
// D2Formal436 'O' (U+004F) and 'e' (U+0065) 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)
// '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', () => {
@ -326,7 +389,18 @@ describe('Diablo II v1.13c Exocet & Formal 436 Fonts (Issue #504)', () => {
dh: glyphT.frameH,
})
// 3. CJK string ("罗格营地") automatically falls back to ctx.fillText even when DC6 atlas is loaded
// 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, {
@ -339,6 +413,105 @@ describe('Diablo II v1.13c Exocet & Formal 436 Fonts (Issue #504)', () => {
expect(fillTextCount).toBeGreaterThan(0)
})
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 Character Sheet labels inside stone recesses using DC6 font6 metrics', () => {
const metricsJson = JSON.parse(
readFileSync(join(process.cwd(), 'public', 'ui', 'fonts', 'metrics.json'), 'utf8'),
@ -351,7 +524,7 @@ 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 }> = []
const drawnLabels: Array<{ text: string; font: D2FontName; widthPx: number; y: number }> = []
const mockCtx = {
drawImage: () => {},
fillRect: () => {},
@ -371,7 +544,7 @@ describe('Diablo II v1.13c Exocet & Formal 436 Fonts (Issue #504)', () => {
_ctx: CanvasRenderingContext2D,
text: string,
_x: number,
_y: number,
y: number,
opts?: { font?: D2FontName },
) => {
const f = opts?.font ?? 'font8'
@ -379,6 +552,7 @@ describe('Diablo II v1.13c Exocet & Formal 436 Fonts (Issue #504)', () => {
text,
font: f,
widthPx: renderer.measureText(text, f),
y,
})
},
measureText: (text: string, font?: D2FontName) => renderer.measureText(text, font),