diff --git a/public/ui/fonts/D2Exocet.ttf b/public/ui/fonts/D2Exocet.ttf index 2776590..32fea7a 100644 Binary files a/public/ui/fonts/D2Exocet.ttf and b/public/ui/fonts/D2Exocet.ttf differ diff --git a/public/ui/fonts/D2Formal436.ttf b/public/ui/fonts/D2Formal436.ttf index 5a51b1b..671f559 100644 Binary files a/public/ui/fonts/D2Formal436.ttf and b/public/ui/fonts/D2Formal436.ttf differ diff --git a/scripts/build-d2-ttf.ts b/scripts/build-d2-ttf.ts index 7cbdaee..8ddbdd0 100644 --- a/scripts/build-d2-ttf.ts +++ b/scripts/build-d2-ttf.ts @@ -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 }))) } 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),