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 baselineY: number
|
||||||
readonly scale: number
|
readonly scale: number
|
||||||
readonly isForeground: (paletteIdx: number) => boolean
|
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.
|
* Build a complete TrueType (`.ttf`) binary buffer from a 256-glyph DC6 sheet and `.tbl` metrics.
|
||||||
*/
|
*/
|
||||||
export function buildTrueTypeFont(options: BuildTrueTypeFontOptions): Uint8Array {
|
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 {
|
interface BuiltGlyph {
|
||||||
contours: Point[][]
|
contours: Point[][]
|
||||||
|
|
@ -255,6 +275,11 @@ export function buildTrueTypeFont(options: BuildTrueTypeFontOptions): Uint8Array
|
||||||
mask[i] = isForeground(frame.indices[i] ?? 0) ? 1 : 0
|
mask[i] = isForeground(frame.indices[i] ?? 0) ? 1 : 0
|
||||||
}
|
}
|
||||||
contours = traceBitmapContours(mask, frame.width, frame.height, glyphBaselineY, scale)
|
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) {
|
if (yShiftUnits !== 0) {
|
||||||
contours = contours.map(c => c.map(p => ({ x: p.x, y: p.y + yShiftUnits })))
|
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
|
// 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 f42Dc6 = await decodeUiDc6('data\\local\\font\\latin\\font42.dc6')
|
||||||
const f42Tbl = await archives.read('data\\local\\font\\latin\\font42.tbl')
|
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({
|
const exocetTtf = buildTrueTypeFont({
|
||||||
familyName: 'D2Exocet',
|
familyName: 'D2Exocet',
|
||||||
postScriptName: 'D2Exocet-Regular',
|
postScriptName: 'D2Exocet-Regular',
|
||||||
|
|
@ -469,6 +469,7 @@ async function main(): Promise<void> {
|
||||||
baselineY: 32,
|
baselineY: 32,
|
||||||
scale: 28,
|
scale: 28,
|
||||||
isForeground: idx => exocetFg.has(idx),
|
isForeground: idx => exocetFg.has(idx),
|
||||||
|
minContourAreaPixels: 2,
|
||||||
})
|
})
|
||||||
writeFileSync(join(fontsDir, 'D2Exocet.ttf'), exocetTtf)
|
writeFileSync(join(fontsDir, 'D2Exocet.ttf'), exocetTtf)
|
||||||
|
|
||||||
|
|
@ -481,7 +482,7 @@ async function main(): Promise<void> {
|
||||||
tblBytes: ff12Tbl,
|
tblBytes: ff12Tbl,
|
||||||
baselineY: 26,
|
baselineY: 26,
|
||||||
scale: 56,
|
scale: 56,
|
||||||
isForeground: idx => idx >= 21,
|
isForeground: idx => idx >= 28,
|
||||||
})
|
})
|
||||||
writeFileSync(join(fontsDir, 'D2Formal436.ttf'), formalTtf)
|
writeFileSync(join(fontsDir, 'D2Formal436.ttf'), formalTtf)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import { createServer } from 'vite'
|
||||||
|
|
||||||
const ARTIFACT_DIR =
|
const ARTIFACT_DIR =
|
||||||
process.argv[2] ||
|
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))
|
const sleep = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms))
|
||||||
|
|
||||||
|
|
@ -20,6 +20,7 @@ interface CdpSession {
|
||||||
evalJs: <T = any>(expression: string) => Promise<T>
|
evalJs: <T = any>(expression: string) => Promise<T>
|
||||||
saveScreenshot: (filename: string) => Promise<void>
|
saveScreenshot: (filename: string) => Promise<void>
|
||||||
waitForGameReady: () => Promise<void>
|
waitForGameReady: () => Promise<void>
|
||||||
|
externalUrls: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
async function withChromePage<T>(
|
async function withChromePage<T>(
|
||||||
|
|
@ -66,6 +67,26 @@ async function withChromePage<T>(
|
||||||
ws.onopen = () => resolve()
|
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
|
let idCounter = 1
|
||||||
const sendCommand = <R = any>(method: string, params: Record<string, unknown> = {}): Promise<R> =>
|
const sendCommand = <R = any>(method: string, params: Record<string, unknown> = {}): Promise<R> =>
|
||||||
new Promise((resolve, reject) => {
|
new Promise((resolve, reject) => {
|
||||||
|
|
@ -114,10 +135,11 @@ async function withChromePage<T>(
|
||||||
throw new Error(`Timed out waiting for game ready at ${url}`)
|
throw new Error(`Timed out waiting for game ready at ${url}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await sendCommand('Network.enable')
|
||||||
await sendCommand('Runtime.enable')
|
await sendCommand('Runtime.enable')
|
||||||
await sendCommand('Page.enable')
|
await sendCommand('Page.enable')
|
||||||
|
|
||||||
const result = await fn({ evalJs, saveScreenshot, waitForGameReady })
|
const result = await fn({ evalJs, saveScreenshot, waitForGameReady, externalUrls })
|
||||||
ws.close()
|
ws.close()
|
||||||
return result
|
return result
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -139,40 +161,103 @@ async function main(): Promise<void> {
|
||||||
console.log(`1. Vite dev server listening at ${baseUrl}`)
|
console.log(`1. Vite dev server listening at ${baseUrl}`)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// ── Phase A: Verify index.html WebFont loading & computed typography ──
|
// ── 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 }) => {
|
await withChromePage(`${baseUrl}/index.html`, 'index.html', 9251, async ({ evalJs, saveScreenshot, externalUrls }) => {
|
||||||
await sleep(500)
|
await sleep(500)
|
||||||
const indexFonts = await evalJs<{
|
const indexFonts = await evalJs<{
|
||||||
exocetLoaded: boolean
|
exocetLoaded: boolean
|
||||||
formalLoaded: boolean
|
formalLoaded: boolean
|
||||||
h1FontFamily: string
|
h1FontFamily: string
|
||||||
descFontFamily: string
|
descFontFamily: string
|
||||||
|
exocetOCenterAlpha: number
|
||||||
|
formalOCenterAlpha: number
|
||||||
}>(`(async () => {
|
}>(`(async () => {
|
||||||
await document.fonts.ready;
|
await document.fonts.ready;
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
document.fonts.load('16px D2Exocet'),
|
document.fonts.load('48px D2Exocet'),
|
||||||
document.fonts.load('16px D2Formal436'),
|
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 {
|
return {
|
||||||
exocetLoaded: document.fonts.check('16px D2Exocet'),
|
exocetLoaded: document.fonts.check('16px D2Exocet'),
|
||||||
formalLoaded: document.fonts.check('16px D2Formal436'),
|
formalLoaded: document.fonts.check('14px D2Formal436'),
|
||||||
h1FontFamily: getComputedStyle(document.querySelector('h1')).fontFamily,
|
h1FontFamily: getComputedStyle(document.querySelector('h1')).fontFamily,
|
||||||
descFontFamily: getComputedStyle(document.querySelector('.lang-desc')).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) {
|
if (!indexFonts.exocetLoaded || !indexFonts.formalLoaded) {
|
||||||
throw new Error(`index.html failed to load D2Exocet or D2Formal436: ${JSON.stringify(indexFonts)}`)
|
throw new Error(`index.html failed to load D2Exocet or D2Formal436: ${JSON.stringify(indexFonts)}`)
|
||||||
}
|
}
|
||||||
if (!indexFonts.h1FontFamily.includes('D2Exocet') || !indexFonts.descFontFamily.includes('D2Formal436')) {
|
if (!indexFonts.h1FontFamily.includes('D2Exocet') || !indexFonts.descFontFamily.includes('D2Formal436')) {
|
||||||
throw new Error(`index.html unexpected font-family: ${JSON.stringify(indexFonts)}`)
|
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')
|
await saveScreenshot('issue-504-index.png')
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── Phase B: Verify acts-en.html Canvas DC6 bitmap font blitting & DOM/CSS WebFonts ──
|
// ── 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 }) => {
|
await withChromePage(`${baseUrl}/acts-en.html?act=1`, 'acts-en.html', 9252, async ({ evalJs, saveScreenshot, waitForGameReady, externalUrls }) => {
|
||||||
await waitForGameReady()
|
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<{
|
const enCheck = await evalJs<{
|
||||||
exocetLoaded: boolean
|
exocetLoaded: boolean
|
||||||
formalLoaded: boolean
|
formalLoaded: boolean
|
||||||
|
|
@ -183,13 +268,71 @@ async function main(): Promise<void> {
|
||||||
dc6FontLoaded: boolean
|
dc6FontLoaded: boolean
|
||||||
dc6BlitCount: number
|
dc6BlitCount: number
|
||||||
fillTextCount: number
|
fillTextCount: number
|
||||||
|
dc6Font30OCenterAlpha: number
|
||||||
|
dc6Font16OCenterAlpha: number
|
||||||
|
dc6Formal12OCenterAlpha: number
|
||||||
|
distinctColorCodesCount: number
|
||||||
}>(`(async () => {
|
}>(`(async () => {
|
||||||
await document.fonts.ready;
|
await document.fonts.ready;
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
document.fonts.load('16px D2Exocet'),
|
document.fonts.load('16px D2Exocet'),
|
||||||
document.fonts.load('16px D2Formal436'),
|
document.fonts.load('14px D2Formal436'),
|
||||||
]);
|
]);
|
||||||
const inst = window.__d2webHudInstance;
|
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 blitCount = 0;
|
||||||
let fillCount = 0;
|
let fillCount = 0;
|
||||||
const origDrawImage = CanvasRenderingContext2D.prototype.drawImage;
|
const origDrawImage = CanvasRenderingContext2D.prototype.drawImage;
|
||||||
|
|
@ -203,6 +346,7 @@ async function main(): Promise<void> {
|
||||||
return origFillText.apply(this, args);
|
return origFillText.apply(this, args);
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
|
document.dispatchEvent(new KeyboardEvent('keyup', { key: 'Alt', code: 'AltLeft', bubbles: true }));
|
||||||
if (inst.leftPanel !== 'char') inst.toggleLeftPanel('char');
|
if (inst.leftPanel !== 'char') inst.toggleLeftPanel('char');
|
||||||
if (inst.rightPanel !== 'inv') inst.toggleRightPanel('inv');
|
if (inst.rightPanel !== 'inv') inst.toggleRightPanel('inv');
|
||||||
inst.inventory.handleMouseMove(561, 93);
|
inst.inventory.handleMouseMove(561, 93);
|
||||||
|
|
@ -213,7 +357,7 @@ async function main(): Promise<void> {
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
exocetLoaded: document.fonts.check('16px D2Exocet'),
|
exocetLoaded: document.fonts.check('16px D2Exocet'),
|
||||||
formalLoaded: document.fonts.check('16px D2Formal436'),
|
formalLoaded: document.fonts.check('14px D2Formal436'),
|
||||||
hudFontFamily: getComputedStyle(document.getElementById('hud')).fontFamily,
|
hudFontFamily: getComputedStyle(document.getElementById('hud')).fontFamily,
|
||||||
controlsFontFamily: getComputedStyle(document.getElementById('controls')).fontFamily,
|
controlsFontFamily: getComputedStyle(document.getElementById('controls')).fontFamily,
|
||||||
dialogFontFamily: getComputedStyle(document.getElementById('dialog')).fontFamily,
|
dialogFontFamily: getComputedStyle(document.getElementById('dialog')).fontFamily,
|
||||||
|
|
@ -221,34 +365,70 @@ async function main(): Promise<void> {
|
||||||
dc6FontLoaded: Boolean(inst.font?.loaded),
|
dc6FontLoaded: Boolean(inst.font?.loaded),
|
||||||
dc6BlitCount: blitCount,
|
dc6BlitCount: blitCount,
|
||||||
fillTextCount: fillCount,
|
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) {
|
if (!enCheck.exocetLoaded || !enCheck.formalLoaded || !enCheck.dc6FontLoaded) {
|
||||||
throw new Error(`acts-en.html font loading failed: ${JSON.stringify(enCheck)}`)
|
throw new Error(`acts-en.html font loading failed: ${JSON.stringify(enCheck)}`)
|
||||||
}
|
}
|
||||||
if (enCheck.dc6BlitCount < 100 || enCheck.fillTextCount !== 0) {
|
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)}`)
|
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')
|
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(`(() => {
|
await evalJs(`(() => {
|
||||||
const inst = window.__d2webHudInstance;
|
const inst = window.__d2webHudInstance;
|
||||||
inst.toggleLeftPanel('quest');
|
inst.inventory.hoveredItem = null;
|
||||||
inst.toggleRightPanel('skill');
|
if (inst.leftPanel !== 'quest') inst.toggleLeftPanel('quest');
|
||||||
const dialog = document.getElementById('dialog');
|
if (inst.rightPanel !== 'skill') inst.toggleRightPanel('skill');
|
||||||
if (dialog) {
|
inst.worldPanels.npcMenu = {
|
||||||
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>';
|
npcId: 1,
|
||||||
dialog.hidden = false;
|
npcName: 'Akara',
|
||||||
dialog.style.display = 'block';
|
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);
|
inst.render(performance.now(), false);
|
||||||
})()`)
|
})()`)
|
||||||
await sleep(300)
|
await sleep(300)
|
||||||
await saveScreenshot('issue-504-acts-en-quests-skills-dialog.png')
|
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!')
|
console.log('✅ All Issue #504 Headless Browser E2E Checks PASSED!')
|
||||||
} finally {
|
} finally {
|
||||||
await server.close()
|
await server.close()
|
||||||
|
|
|
||||||
|
|
@ -238,12 +238,12 @@ export class CharacterSheetPanel {
|
||||||
|
|
||||||
// 6. Combat Ratings, Stamina, Life, Mana
|
// 6. Combat Ratings, Stamina, Life, Mana
|
||||||
if (isEn) {
|
if (isEn) {
|
||||||
font.drawText(ctx, 'Left Attack', ox + 214, oy + 87, {
|
font.drawText(ctx, 'Left Attack', ox + 214, oy + 90, {
|
||||||
font: 'font6',
|
font: 'font6',
|
||||||
color: 'tan',
|
color: 'tan',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
})
|
})
|
||||||
font.drawText(ctx, 'Damage', ox + 214, oy + 95, {
|
font.drawText(ctx, 'Damage', ox + 214, oy + 98, {
|
||||||
font: 'font6',
|
font: 'font6',
|
||||||
color: 'tan',
|
color: 'tan',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
|
|
@ -261,12 +261,12 @@ export class CharacterSheetPanel {
|
||||||
})
|
})
|
||||||
|
|
||||||
if (isEn) {
|
if (isEn) {
|
||||||
font.drawText(ctx, 'Right Attack', ox + 214, oy + 111, {
|
font.drawText(ctx, 'Right Attack', ox + 214, oy + 114, {
|
||||||
font: 'font6',
|
font: 'font6',
|
||||||
color: 'tan',
|
color: 'tan',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
})
|
})
|
||||||
font.drawText(ctx, 'Damage', ox + 214, oy + 119, {
|
font.drawText(ctx, 'Damage', ox + 214, oy + 121, {
|
||||||
font: 'font6',
|
font: 'font6',
|
||||||
color: 'tan',
|
color: 'tan',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
|
|
@ -343,12 +343,12 @@ export class CharacterSheetPanel {
|
||||||
for (const res of resRows) {
|
for (const res of resRows) {
|
||||||
if (isEn) {
|
if (isEn) {
|
||||||
if (res.labelEn.length === 2) {
|
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',
|
font: 'font6',
|
||||||
color: 'tan',
|
color: 'tan',
|
||||||
align: 'center',
|
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',
|
font: 'font6',
|
||||||
color: 'tan',
|
color: 'tan',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
|
|
|
||||||
|
|
@ -175,6 +175,24 @@ function parseHexRgb(hex: string): readonly [number, number, number] {
|
||||||
return [(num >> 16) & 0xff, (num >> 8) & 0xff, num & 0xff]
|
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.
|
* Runtime Diablo II Font Manager supporting both bitmap DC6 glyphs and CJK fallback.
|
||||||
*/
|
*/
|
||||||
|
|
@ -305,7 +323,7 @@ export class D2FontRenderer {
|
||||||
let total = 0
|
let total = 0
|
||||||
for (const span of spans) {
|
for (const span of spans) {
|
||||||
for (let i = 0; i < span.text.length; i++) {
|
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]) {
|
if (code < 256 && meta?.glyphs[code]) {
|
||||||
total += Math.max(4, meta.glyphs[code]!.width)
|
total += Math.max(4, meta.glyphs[code]!.width)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -322,8 +340,10 @@ export class D2FontRenderer {
|
||||||
if (!meta) return false
|
if (!meta) return false
|
||||||
for (const span of spans) {
|
for (const span of spans) {
|
||||||
for (let i = 0; i < span.text.length; i++) {
|
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]) return false
|
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
|
return true
|
||||||
|
|
@ -372,7 +392,7 @@ export class D2FontRenderer {
|
||||||
for (const span of spans) {
|
for (const span of spans) {
|
||||||
const tinted = this.getTintedAtlas(font, span.color)
|
const tinted = this.getTintedAtlas(font, span.color)
|
||||||
for (let i = 0; i < span.text.length; i++) {
|
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 g = meta.glyphs[code]!
|
||||||
const adv = Math.max(4, g.width)
|
const adv = Math.max(4, g.width)
|
||||||
if (code !== 0x20 && code !== 0xa0 && g.frameW > 0 && g.frameH > 0 && tinted) {
|
if (code !== 0x20 && code !== 0xa0 && g.frameW > 0 && g.frameH > 0 && tinted) {
|
||||||
|
|
|
||||||
|
|
@ -2383,7 +2383,7 @@ export class WorldPanelsHud {
|
||||||
ctx.strokeStyle = '#6c5838'
|
ctx.strokeStyle = '#6c5838'
|
||||||
ctx.strokeRect(ox + 18.5, oy + 264.5, 283, 115)
|
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',
|
font: 'fontexocet10',
|
||||||
color: qInfo.status === 'completed' ? 'gold' : 'white',
|
color: qInfo.status === 'completed' ? 'gold' : 'white',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
|
|
@ -2391,7 +2391,7 @@ export class WorldPanelsHud {
|
||||||
const rewardSuffix = qInfo.status === 'completed'
|
const rewardSuffix = qInfo.status === 'completed'
|
||||||
? (qInfo.rewardText ? (isEn ? `\n[Reward: ${qInfo.rewardText}]` : `\n[奖励: ${qInfo.rewardText}]`) : '')
|
? (qInfo.rewardText ? (isEn ? `\n[Reward: ${qInfo.rewardText}]` : `\n[奖励: ${qInfo.rewardText}]`) : '')
|
||||||
: (qInfo.rewardText ? (isEn ? `\n[Objective: ${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') {
|
} else if (kind === 'waypoint') {
|
||||||
if (assets.waypointBgImg) ctx.drawImage(assets.waypointBgImg, ox, oy)
|
if (assets.waypointBgImg) ctx.drawImage(assets.waypointBgImg, ox, oy)
|
||||||
|
|
@ -2757,7 +2757,7 @@ export class WorldPanelsHud {
|
||||||
? getLocalizedNpcGreetings(this.npcMenu.descriptor, this.lang)
|
? getLocalizedNpcGreetings(this.npcMenu.descriptor, this.lang)
|
||||||
: this.npcMenu.dialogLines
|
: this.npcMenu.dialogLines
|
||||||
const bodyText = dialogLines.join('\n')
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2841,6 +2841,7 @@ export class WorldPanelsHud {
|
||||||
maxW: number,
|
maxW: number,
|
||||||
lineH: number,
|
lineH: number,
|
||||||
maxLines = 10,
|
maxLines = 10,
|
||||||
|
fontName: 'fontformal12' | 'font8' = 'fontformal12',
|
||||||
): void {
|
): void {
|
||||||
const paragraphs = text.split(/\r?\n/)
|
const paragraphs = text.split(/\r?\n/)
|
||||||
let curY = y
|
let curY = y
|
||||||
|
|
@ -2853,13 +2854,13 @@ export class WorldPanelsHud {
|
||||||
let line = ''
|
let line = ''
|
||||||
for (const word of words) {
|
for (const word of words) {
|
||||||
const candidate = line ? `${line} ${word}` : word
|
const candidate = line ? `${line} ${word}` : word
|
||||||
if (line && font.measureText(candidate, 'font8') > maxW) {
|
if (line && font.measureText(candidate, fontName) > maxW) {
|
||||||
if (linesDrawn + 1 >= maxLines) {
|
if (linesDrawn + 1 >= maxLines) {
|
||||||
const truncated = line.slice(0, Math.max(0, line.length - 2)) + '...'
|
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
|
return
|
||||||
}
|
}
|
||||||
font.drawText(ctx, line, x, curY, { font: 'font8', color: 'white' })
|
font.drawText(ctx, line, x, curY, { font: fontName, color: 'white' })
|
||||||
linesDrawn++
|
linesDrawn++
|
||||||
line = word
|
line = word
|
||||||
curY += lineH
|
curY += lineH
|
||||||
|
|
@ -2868,7 +2869,7 @@ export class WorldPanelsHud {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (line && linesDrawn < maxLines) {
|
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++
|
linesDrawn++
|
||||||
curY += lineH
|
curY += lineH
|
||||||
}
|
}
|
||||||
|
|
@ -2878,13 +2879,13 @@ export class WorldPanelsHud {
|
||||||
let line = ''
|
let line = ''
|
||||||
for (let i = 0; i < p.length; i++) {
|
for (let i = 0; i < p.length; i++) {
|
||||||
const ch = p[i]!
|
const ch = p[i]!
|
||||||
if (font.measureText(line + ch, 'font8') > maxW) {
|
if (font.measureText(line + ch, fontName) > maxW) {
|
||||||
if (linesDrawn + 1 >= maxLines) {
|
if (linesDrawn + 1 >= maxLines) {
|
||||||
const truncated = line.slice(0, Math.max(0, line.length - 2)) + '...'
|
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
|
return
|
||||||
}
|
}
|
||||||
font.drawText(ctx, line, x, curY, { font: 'font8', color: 'white' })
|
font.drawText(ctx, line, x, curY, { font: fontName, color: 'white' })
|
||||||
linesDrawn++
|
linesDrawn++
|
||||||
line = ch
|
line = ch
|
||||||
curY += lineH
|
curY += lineH
|
||||||
|
|
@ -2893,7 +2894,7 @@ export class WorldPanelsHud {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (line && linesDrawn < maxLines) {
|
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++
|
linesDrawn++
|
||||||
curY += lineH
|
curY += lineH
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,9 @@ import {
|
||||||
type D2FontName,
|
type D2FontName,
|
||||||
} from '../src/ui/font.ts'
|
} from '../src/ui/font.ts'
|
||||||
import { CharacterSheetPanel } from '../src/ui/character-sheet.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 { GroundLabelOverlay } from '../src/ui/ground-labels.ts'
|
||||||
|
import { parseColoredText } from '../src/ui/font.ts'
|
||||||
import { traceBitmapContours, buildTrueTypeFont } from '../scripts/build-d2-ttf.ts'
|
import { traceBitmapContours, buildTrueTypeFont } from '../scripts/build-d2-ttf.ts'
|
||||||
|
|
||||||
function signedContourArea(pts: Array<{ x: number; y: number }>): number {
|
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
|
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)', () => {
|
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 fontsDir = join(process.cwd(), 'public', 'ui', 'fonts')
|
||||||
const exocetPath = join(fontsDir, 'D2Exocet.ttf')
|
const exocetPath = join(fontsDir, 'D2Exocet.ttf')
|
||||||
const formalPath = join(fontsDir, 'D2Formal436.ttf')
|
const formalPath = join(fontsDir, 'D2Formal436.ttf')
|
||||||
|
|
@ -77,6 +120,26 @@ describe('Diablo II v1.13c Exocet & Formal 436 Fonts (Issue #504)', () => {
|
||||||
'post',
|
'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', () => {
|
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,
|
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
|
drawImageCalls.length = 0
|
||||||
fillTextCount = 0
|
fillTextCount = 0
|
||||||
renderer.drawText(blitCtx, '罗格营地', 200, 100, {
|
renderer.drawText(blitCtx, '罗格营地', 200, 100, {
|
||||||
|
|
@ -339,6 +413,105 @@ describe('Diablo II v1.13c Exocet & Formal 436 Fonts (Issue #504)', () => {
|
||||||
expect(fillTextCount).toBeGreaterThan(0)
|
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', () => {
|
it('fits English Character Sheet labels inside stone recesses using DC6 font6 metrics', () => {
|
||||||
const metricsJson = JSON.parse(
|
const metricsJson = JSON.parse(
|
||||||
readFileSync(join(process.cwd(), 'public', 'ui', 'fonts', 'metrics.json'), 'utf8'),
|
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.lang = 'ENG'
|
||||||
sheet.visible = true
|
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 = {
|
const mockCtx = {
|
||||||
drawImage: () => {},
|
drawImage: () => {},
|
||||||
fillRect: () => {},
|
fillRect: () => {},
|
||||||
|
|
@ -371,7 +544,7 @@ describe('Diablo II v1.13c Exocet & Formal 436 Fonts (Issue #504)', () => {
|
||||||
_ctx: CanvasRenderingContext2D,
|
_ctx: CanvasRenderingContext2D,
|
||||||
text: string,
|
text: string,
|
||||||
_x: number,
|
_x: number,
|
||||||
_y: number,
|
y: number,
|
||||||
opts?: { font?: D2FontName },
|
opts?: { font?: D2FontName },
|
||||||
) => {
|
) => {
|
||||||
const f = opts?.font ?? 'font8'
|
const f = opts?.font ?? 'font8'
|
||||||
|
|
@ -379,6 +552,7 @@ describe('Diablo II v1.13c Exocet & Formal 436 Fonts (Issue #504)', () => {
|
||||||
text,
|
text,
|
||||||
font: f,
|
font: f,
|
||||||
widthPx: renderer.measureText(text, f),
|
widthPx: renderer.measureText(text, f),
|
||||||
|
y,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
measureText: (text: string, font?: D2FontName) => renderer.measureText(text, font),
|
measureText: (text: string, font?: D2FontName) => renderer.measureText(text, font),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue