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:
parent
3ced5b08b9
commit
f488ac8743
Binary file not shown.
Binary file not shown.
|
|
@ -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 })))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Reference in New Issue