From f488ac8743ff2307a084039086511b548711a947 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Mon, 28 Sep 2026 10:47:23 +0000 Subject: [PATCH] fix(ui,fonts): fix Formal 436 quest/dialog prose, TTF glyph contours, and char-sheet spacing (Issue #504 R1 review) --- public/ui/fonts/D2Exocet.ttf | Bin 82932 -> 78708 bytes public/ui/fonts/D2Formal436.ttf | Bin 38584 -> 37136 bytes scripts/build-d2-ttf.ts | 27 +++- scripts/pack-ui-assets.ts | 5 +- scripts/verify-issue-504-browser.ts | 224 ++++++++++++++++++++++++--- src/ui/character-sheet.ts | 12 +- src/ui/font.ts | 28 +++- src/ui/world-panels.ts | 23 +-- tests/issue-504-exocet-fonts.test.ts | 182 +++++++++++++++++++++- 9 files changed, 451 insertions(+), 50 deletions(-) diff --git a/public/ui/fonts/D2Exocet.ttf b/public/ui/fonts/D2Exocet.ttf index 2776590c1ae2465e36725280bc6aac33db0cc160..32fea7af0ec9bfcf1b797513b35ff565ee5b5fde 100644 GIT binary patch delta 6235 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 delta 9224 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 diff --git a/public/ui/fonts/D2Formal436.ttf b/public/ui/fonts/D2Formal436.ttf index 5a51b1bff5788c8a922023f53a21c4398a4d4b8a..671f559a93ee02cce88fe7c6df183da3e9e68588 100644 GIT binary patch literal 37136 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 literal 38584 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 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 }))) } diff --git a/scripts/pack-ui-assets.ts b/scripts/pack-ui-assets.ts index 97e0188..a2d8399 100644 --- a/scripts/pack-ui-assets.ts +++ b/scripts/pack-ui-assets.ts @@ -460,7 +460,7 @@ async function main(): Promise { // 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 { 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 { tblBytes: ff12Tbl, baselineY: 26, scale: 56, - isForeground: idx => idx >= 21, + isForeground: idx => idx >= 28, }) writeFileSync(join(fontsDir, 'D2Formal436.ttf'), formalTtf) diff --git a/scripts/verify-issue-504-browser.ts b/scripts/verify-issue-504-browser.ts index 174652b..20a8f9c 100644 --- a/scripts/verify-issue-504-browser.ts +++ b/scripts/verify-issue-504-browser.ts @@ -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(resolve => setTimeout(resolve, ms)) @@ -20,6 +20,7 @@ interface CdpSession { evalJs: (expression: string) => Promise saveScreenshot: (filename: string) => Promise waitForGameReady: () => Promise + externalUrls: string[] } async function withChromePage( @@ -66,6 +67,26 @@ async function withChromePage( 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 = (method: string, params: Record = {}): Promise => new Promise((resolve, reject) => { @@ -114,10 +135,11 @@ async function withChromePage( 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 { 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 { 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 { 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 { } 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 { 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 = '
Akara · High Priestess of the Sisterhood
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.
'; - 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() diff --git a/src/ui/character-sheet.ts b/src/ui/character-sheet.ts index 201dc06..6e629eb 100644 --- a/src/ui/character-sheet.ts +++ b/src/ui/character-sheet.ts @@ -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', diff --git a/src/ui/font.ts b/src/ui/font.ts index d4e950b..6ea1862 100644 --- a/src/ui/font.ts +++ b/src/ui/font.ts @@ -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) { diff --git a/src/ui/world-panels.ts b/src/ui/world-panels.ts index 2b73499..5231ada 100644 --- a/src/ui/world-panels.ts +++ b/src/ui/world-panels.ts @@ -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 } diff --git a/tests/issue-504-exocet-fonts.test.ts b/tests/issue-504-exocet-fonts.test.ts index ebc9586..240cacf 100644 --- a/tests/issue-504-exocet-fonts.test.ts +++ b/tests/issue-504-exocet-fonts.test.ts @@ -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 = {} + 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),