fix(ui,fonts): hybrid DC6+CJK rendering, middle-dot atlas mask, TTF contour refinement, and recess centering (Issue #504 R2 review)

This commit is contained in:
troytt 2026-09-28 12:11:33 +00:00
parent f488ac8743
commit 9d0138f69b
16 changed files with 326 additions and 129 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

@ -1349,30 +1349,30 @@
},
{
"code": 149,
"width": 7,
"width": 3,
"height": 7,
"x": 80,
"y": 144,
"frameW": 0,
"frameH": 0
"frameW": 9,
"frameH": 11
},
{
"code": 150,
"width": 7,
"width": 4,
"height": 7,
"x": 96,
"y": 144,
"frameW": 0,
"frameH": 0
"frameW": 9,
"frameH": 11
},
{
"code": 151,
"width": 7,
"width": 4,
"height": 7,
"x": 112,
"y": 144,
"frameW": 0,
"frameH": 0
"frameW": 9,
"frameH": 11
},
{
"code": 152,
@ -1560,8 +1560,8 @@
"height": 7,
"x": 192,
"y": 160,
"frameW": 9,
"frameH": 11
"frameW": 0,
"frameH": 0
},
{
"code": 173,
@ -1650,8 +1650,8 @@
"height": 7,
"x": 96,
"y": 176,
"frameW": 9,
"frameH": 11
"frameW": 0,
"frameH": 0
},
{
"code": 183,
@ -3662,7 +3662,7 @@
},
{
"code": 149,
"width": 7,
"width": 4,
"height": 14,
"x": 80,
"y": 144,
@ -5975,30 +5975,30 @@
},
{
"code": 149,
"width": 12,
"width": 5,
"height": 10,
"x": 80,
"y": 144,
"frameW": 0,
"frameH": 0
"frameW": 14,
"frameH": 16
},
{
"code": 150,
"width": 12,
"width": 5,
"height": 10,
"x": 96,
"y": 144,
"frameW": 0,
"frameH": 0
"frameW": 14,
"frameH": 16
},
{
"code": 151,
"width": 12,
"width": 5,
"height": 10,
"x": 112,
"y": 144,
"frameW": 0,
"frameH": 0
"frameW": 14,
"frameH": 16
},
{
"code": 152,
@ -8288,30 +8288,30 @@
},
{
"code": 149,
"width": 21,
"width": 8,
"height": 19,
"x": 145,
"y": 270,
"frameW": 0,
"frameH": 0
"frameW": 29,
"frameH": 30
},
{
"code": 150,
"width": 21,
"width": 10,
"height": 19,
"x": 174,
"y": 270,
"frameW": 0,
"frameH": 0
"frameW": 29,
"frameH": 30
},
{
"code": 151,
"width": 21,
"width": 10,
"height": 19,
"x": 203,
"y": 270,
"frameW": 0,
"frameH": 0
"frameW": 29,
"frameH": 30
},
{
"code": 152,
@ -10601,30 +10601,30 @@
},
{
"code": 149,
"width": 12,
"width": 5,
"height": 10,
"x": 85,
"y": 162,
"frameW": 0,
"frameH": 0
"frameW": 17,
"frameH": 18
},
{
"code": 150,
"width": 12,
"width": 9,
"height": 10,
"x": 102,
"y": 162,
"frameW": 0,
"frameH": 0
"frameW": 17,
"frameH": 18
},
{
"code": 151,
"width": 12,
"width": 9,
"height": 10,
"x": 119,
"y": 162,
"frameW": 0,
"frameH": 0
"frameW": 17,
"frameH": 18
},
{
"code": 152,
@ -12914,7 +12914,7 @@
},
{
"code": 149,
"width": 10,
"width": 6,
"height": 20,
"x": 175,
"y": 270,

View File

@ -121,6 +121,7 @@ export interface BuildTrueTypeFontOptions {
readonly scale: number
readonly isForeground: (paletteIdx: number) => boolean
readonly minContourAreaPixels?: number
readonly refineMask?: (mask: Uint8Array, frame: Dc6FrameLike) => void
}
function computePolygonSignedArea(pts: readonly Point[]): number {
@ -146,6 +147,7 @@ export function buildTrueTypeFont(options: BuildTrueTypeFontOptions): Uint8Array
scale,
isForeground,
minContourAreaPixels = 0,
refineMask,
} = options
interface BuiltGlyph {
@ -274,6 +276,7 @@ export function buildTrueTypeFont(options: BuildTrueTypeFontOptions): Uint8Array
for (let i = 0; i < mask.length; i++) {
mask[i] = isForeground(frame.indices[i] ?? 0) ? 1 : 0
}
refineMask?.(mask, frame)
contours = traceBitmapContours(mask, frame.width, frame.height, glyphBaselineY, scale)
if (minContourAreaPixels > 0 && contours.length > 1) {
const minAreaUnits = minContourAreaPixels * scale * scale

View File

@ -163,27 +163,53 @@ function buildFontAtlas(name: string, dc6: SpriteSheet, tblBytes: Uint8Array): {
const dotFrameIdx = dotRecOff + 8 < tblBytes.length ? (tblBytes[dotRecOff + 8] ?? 0x2e) : 0x2e
const dotFrame = frames[dotFrameIdx] ?? frames[0x2e]
const dashRecOff = 12 + 0x2d * 14
const dashMetricW = dashRecOff + 3 < tblBytes.length ? (tblBytes[dashRecOff + 3] || 8) : 8
const dashFrameIdx = dashRecOff + 8 < tblBytes.length ? (tblBytes[dashRecOff + 8] ?? 0x2d) : 0x2d
const dashFrame = frames[dashFrameIdx] ?? frames[0x2d]
for (let i = 0; i < 256; i++) {
const recOff = 12 + i * 14
let metricWidth = recOff + 3 < tblBytes.length ? (tblBytes[recOff + 3] || 6) : 6
const metricHeight = recOff + 4 < tblBytes.length ? (tblBytes[recOff + 4] || lineHeight) : lineHeight
const frameIdx = recOff + 8 < tblBytes.length ? (tblBytes[recOff + 8] ?? i) : i
let frame: SpriteFrame | undefined = frames[frameIdx] ?? frames[i]
if (i === 0xb7 && dotFrame) {
const shiftY = Math.max(1, Math.round(dotFrame.height * 0.35))
if ((i === 0xb7 || i === 0x95) && dotFrame) {
const shiftY = Math.max(1, Math.round(dotFrame.height * 0.22))
const shiftedIndices = new Uint8Array(dotFrame.width * dotFrame.height)
const shiftedMask = new Uint8Array(dotFrame.width * dotFrame.height)
for (let y = 0; y < dotFrame.height; y++) {
const srcY = y + shiftY
if (srcY < dotFrame.height) {
for (let x = 0; x < dotFrame.width; x++) {
shiftedIndices[y * dotFrame.width + x] = dotFrame.indices[srcY * dotFrame.width + x]!
const dstIdx = y * dotFrame.width + x
const srcIdx = srcY * dotFrame.width + x
shiftedIndices[dstIdx] = dotFrame.indices[srcIdx]!
shiftedMask[dstIdx] = dotFrame.mask[srcIdx]!
}
}
}
frame = { ...dotFrame, indices: shiftedIndices }
frame = { ...dotFrame, indices: shiftedIndices, mask: shiftedMask }
metricWidth = dotMetricW
} else if (i !== 0x3f && i >= 0x7f && isPlaceholderQuestionMark(frame)) {
frame = undefined
if ((i === 0x96 || i === 0x97) && dashFrame) {
frame = dashFrame
metricWidth = dashMetricW
} else {
frame = undefined
}
}
if (i !== 0x20 && i !== 0xa0 && frame) {
let hasVisiblePixel = false
for (let k = 0; k < frame.indices.length; k++) {
if (frame.mask[k] !== 0 && frame.indices[k] !== 0) {
hasVisiblePixel = true
break
}
}
if (!hasVisiblePixel) {
frame = undefined
}
}
const gx = (i % 16) * cellW
const gy = Math.floor(i / 16) * cellH
@ -460,7 +486,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, 31, 32])
const exocetFg = new Set([4, 6, 7, 12, 13, 14, 20, 23, 24, 26, 28, 29, 30, 31, 32])
const exocetTtf = buildTrueTypeFont({
familyName: 'D2Exocet',
postScriptName: 'D2Exocet-Regular',
@ -470,6 +496,28 @@ async function main(): Promise<void> {
scale: 28,
isForeground: idx => exocetFg.has(idx),
minContourAreaPixels: 2,
refineMask: (mask, frame) => {
const w = frame.width
const h = frame.height
for (let y = 1; y < h - 1; y++) {
for (let x = 1; x < w - 1; x++) {
const i = y * w + x
const p = frame.indices[i] ?? 0
if (mask[i] && (p === 4 || p === 6) && !mask[i - 1] && !mask[i + 1]) {
mask[i] = 0
}
}
}
for (let y = 1; y < h - 1; y++) {
for (let x = 1; x < w - 1; x++) {
const i = y * w + x
const p = frame.indices[i] ?? 0
if (!mask[i] && (p === 3 || p === 18 || p === 19) && mask[i - 1] && mask[i + 1]) {
mask[i] = 1
}
}
}
},
})
writeFileSync(join(fontsDir, 'D2Exocet.ttf'), exocetTtf)
@ -483,6 +531,19 @@ async function main(): Promise<void> {
baselineY: 26,
scale: 56,
isForeground: idx => idx >= 28,
refineMask: (mask, frame) => {
const w = frame.width
const h = frame.height
const base = mask.slice()
for (let y = 1; y < h - 1; y++) {
for (let x = 1; x < w - 1; x++) {
const i = y * w + x
if (!base[i] && frame.indices[i] === 27 && base[i - w] && base[i + w] && base[i - 1] !== base[i + 1]) {
mask[i] = 1
}
}
}
},
})
writeFileSync(join(fontsDir, 'D2Formal436.ttf'), formalTtf)

View File

@ -12,7 +12,7 @@ import { createServer } from 'vite'
const ARTIFACT_DIR =
process.argv[2] ||
'/usr/local/google/home/taodao/diablo2-web/.agents/teamwork/reviewer_504_r1'
'/usr/local/google/home/taodao/diablo2-web/.agents/teamwork/reviewer_504_r2'
const sleep = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms))
@ -241,7 +241,11 @@ async function main(): Promise<void> {
await waitForGameReady()
// 1. Capture Area Entry Banner (`Font30` Exocet) + Top-left `#hud` (`D2Exocet`) + Ground `Alt` Item Labels (`D2Exocet`)
await evalJs(`(() => {
const groundLabelCheck = await evalJs<{
groundLabelsCount: number
groundLabelFontFamily: string
groundLabelTexts: string[]
}>(`(async () => {
const inst = window.__d2webHudInstance;
inst.leftPanel = 'none';
inst.rightPanel = 'none';
@ -251,10 +255,25 @@ async function main(): Promise<void> {
titleZh: '罗格营地',
untilMs: performance.now() + 60000,
};
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Alt', code: 'AltLeft', bubbles: true }));
inst.render(performance.now(), false);
if (inst.inventory.equipped?.helm) {
inst.callbacks.onDropItemToGround?.({ ...inst.inventory.equipped.helm, id: 'verify-ground-helm' });
}
if (inst.inventory.equipped?.weapon1) {
inst.callbacks.onDropItemToGround?.({ ...inst.inventory.equipped.weapon1, id: 'verify-ground-weapon' });
}
inst.showGroundLabels = true;
await new Promise(r => setTimeout(r, 300));
const els = Array.from(document.querySelectorAll('.d2-ground-label'));
return {
groundLabelsCount: els.length,
groundLabelFontFamily: els[0] ? getComputedStyle(els[0]).fontFamily : '',
groundLabelTexts: els.map(el => el.textContent || ''),
};
})()`)
await sleep(300)
console.log(' Verified Alt ground labels:', JSON.stringify(groundLabelCheck))
if (groundLabelCheck.groundLabelsCount < 2 || !groundLabelCheck.groundLabelFontFamily.includes('D2Exocet')) {
throw new Error(`Expected >=2 .d2-ground-label DOM elements using D2Exocet, got: ${JSON.stringify(groundLabelCheck)}`)
}
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
@ -279,6 +298,7 @@ async function main(): Promise<void> {
document.fonts.load('14px D2Formal436'),
]);
const inst = window.__d2webHudInstance;
inst.showGroundLabels = false;
const measureDc6GlyphCenterAlpha = (fontName) => {
const c = document.createElement('canvas');
@ -346,7 +366,6 @@ 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);
@ -412,16 +431,44 @@ async function main(): Promise<void> {
await saveScreenshot('issue-504-acts-en-quests-skills-dialog.png')
})
// ── Phase C: Verify acts-zh.html bilingual CJK + Exocet/Formal 436 rendering ──
// ── Phase C: Verify acts-zh.html hybrid DC6 + CJK bilingual rendering ──
await withChromePage(`${baseUrl}/acts-zh.html?act=1`, 'acts-zh.html', 9253, async ({ evalJs, saveScreenshot, waitForGameReady, externalUrls }) => {
await waitForGameReady()
await evalJs(`(() => {
const zhCheck = await evalJs<{
dc6BlitCount: number
fillTextCount: number
}>(`(() => {
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);
let blitCount = 0;
let fillCount = 0;
const origDrawImage = CanvasRenderingContext2D.prototype.drawImage;
const origFillText = CanvasRenderingContext2D.prototype.fillText;
CanvasRenderingContext2D.prototype.drawImage = function(...args) {
if (args.length === 9) blitCount++;
return origDrawImage.apply(this, args);
};
CanvasRenderingContext2D.prototype.fillText = function(...args) {
fillCount++;
return origFillText.apply(this, args);
};
try {
if (inst.leftPanel !== 'char') inst.toggleLeftPanel('char');
if (inst.rightPanel !== 'inv') inst.toggleRightPanel('inv');
inst.inventory.handleMouseMove(561, 93);
inst.render(performance.now(), false);
} finally {
CanvasRenderingContext2D.prototype.drawImage = origDrawImage;
CanvasRenderingContext2D.prototype.fillText = origFillText;
}
return {
dc6BlitCount: blitCount,
fillTextCount: fillCount,
};
})()`)
console.log('4. Verified acts-zh.html hybrid DC6 + CJK rendering:', JSON.stringify(zhCheck))
if (zhCheck.dc6BlitCount < 100 || zhCheck.fillTextCount < 10) {
throw new Error(`Expected acts-zh.html hybrid rendering (>100 DC6 blits and >=10 CJK fillText calls), got: ${JSON.stringify(zhCheck)}`)
}
await sleep(300)
if (externalUrls.length > 0) {
throw new Error(`External network requests detected on acts-zh.html: ${JSON.stringify(externalUrls)}`)

View File

@ -162,12 +162,12 @@ export class CharacterSheetPanel {
}
// 3. Top Header: Character Name & Class
font.drawText(ctx, this.attrs.name, ox + 90, oy + 24, {
font.drawText(ctx, this.attrs.name, ox + 90, oy + 21, {
font: 'fontexocet10',
color: 'gold',
align: 'center',
})
font.drawText(ctx, isEn ? this.attrs.classNameEn : this.attrs.classNameZh, ox + 236, oy + 24, {
font.drawText(ctx, isEn ? this.attrs.classNameEn : this.attrs.classNameZh, ox + 236, oy + 21, {
font: 'fontexocet10',
color: 'white',
align: 'center',
@ -175,20 +175,20 @@ export class CharacterSheetPanel {
// 4. Level, Experience, Next Level
font.drawText(ctx, isEn ? 'Level' : '等级', ox + 42, oy + 46, { font: isEn ? 'font6' : 'font8', color: 'tan', align: 'center' })
font.drawText(ctx, String(this.attrs.level), ox + 42, oy + 63, { font: 'fontexocet10', color: 'white', align: 'center' })
font.drawText(ctx, String(this.attrs.level), ox + 42, oy + 62, { font: 'fontexocet10', color: 'white', align: 'center' })
font.drawText(ctx, isEn ? 'Experience' : '当前经验值', ox + 135, oy + 46, { font: isEn ? 'font6' : 'font8', color: 'tan', align: 'center' })
font.drawText(ctx, this.attrs.xp.toLocaleString(), ox + 135, oy + 61, { font: 'font6', color: 'white', align: 'center' })
font.drawText(ctx, this.attrs.xp.toLocaleString(), ox + 135, oy + 62, { font: 'font6', color: 'white', align: 'center' })
font.drawText(ctx, isEn ? 'Next Level' : '升级所需经验', ox + 245, oy + 46, { font: isEn ? 'font6' : 'font8', color: 'tan', align: 'center' })
font.drawText(ctx, this.attrs.nextLevelXp.toLocaleString(), ox + 245, oy + 61, { font: 'font6', color: 'white', align: 'center' })
font.drawText(ctx, this.attrs.nextLevelXp.toLocaleString(), ox + 245, oy + 62, { font: 'font6', color: 'white', align: 'center' })
// 5. Four Primary Attributes (`Str`, `Dex`, `Vit`, `Ene`)
const statRows: readonly { key: BaseStatKey; label: string; y: number; val: number }[] = [
{ key: 'str', label: isEn ? 'Strength' : '力量', y: 91, val: this.attrs.str },
{ key: 'dex', label: isEn ? 'Dexterity' : '敏捷', y: 153, val: this.attrs.dex },
{ key: 'vit', label: isEn ? 'Vitality' : '体力', y: 239, val: this.attrs.vit },
{ key: 'ene', label: isEn ? 'Energy' : '精力', y: 301, val: this.attrs.ene },
{ key: 'str', label: isEn ? 'Strength' : '力量', y: 94, val: this.attrs.str },
{ key: 'dex', label: isEn ? 'Dexterity' : '敏捷', y: 156, val: this.attrs.dex },
{ key: 'vit', label: isEn ? 'Vitality' : '体力', y: 242, val: this.attrs.vit },
{ key: 'ene', label: isEn ? 'Energy' : '精力', y: 304, val: this.attrs.ene },
]
for (const r of statRows) {
font.drawText(ctx, r.label, ox + 18, oy + r.y, { font: 'font8', color: 'tan' })
@ -212,7 +212,7 @@ export class CharacterSheetPanel {
// Remaining Stat Points
if (this.attrs.statPoints > 0) {
if (isEn) {
font.drawText(ctx, 'Stat Points', ox + 56, oy + 361, {
font.drawText(ctx, 'Stat Points', ox + 56, oy + 363, {
font: 'font6',
color: 'red',
align: 'center',
@ -223,13 +223,13 @@ export class CharacterSheetPanel {
align: 'center',
})
} else {
font.drawText(ctx, '剩余属性点', ox + 56, oy + 366, {
font.drawText(ctx, '剩余属性点', ox + 56, oy + 368, {
font: 'font8',
color: 'red',
align: 'center',
})
}
font.drawText(ctx, String(this.attrs.statPoints), ox + 122, oy + 366, {
font.drawText(ctx, String(this.attrs.statPoints), ox + 122, oy + 368, {
font: 'fontexocet10',
color: 'red',
align: 'center',
@ -249,12 +249,12 @@ export class CharacterSheetPanel {
align: 'center',
})
} else {
font.drawText(ctx, '左键技能伤害', ox + 164, oy + 91, {
font.drawText(ctx, '左键技能伤害', ox + 164, oy + 94, {
font: 'font8',
color: 'tan',
})
}
font.drawText(ctx, liveRuntime.leftSkillDmg, ox + 288, oy + 91, {
font.drawText(ctx, liveRuntime.leftSkillDmg, ox + 288, oy + 94, {
font: 'font6',
color: 'blue',
align: 'center',
@ -266,68 +266,68 @@ export class CharacterSheetPanel {
color: 'tan',
align: 'center',
})
font.drawText(ctx, 'Damage', ox + 214, oy + 121, {
font.drawText(ctx, 'Damage', ox + 214, oy + 122, {
font: 'font6',
color: 'tan',
align: 'center',
})
} else {
font.drawText(ctx, '右键技能伤害', ox + 164, oy + 115, {
font.drawText(ctx, '右键技能伤害', ox + 164, oy + 118, {
font: 'font8',
color: 'tan',
})
}
font.drawText(ctx, liveRuntime.rightSkillDmg, ox + 288, oy + 115, {
font.drawText(ctx, liveRuntime.rightSkillDmg, ox + 288, oy + 118, {
font: 'font6',
color: 'blue',
align: 'center',
})
font.drawText(ctx, isEn ? 'Attack Rating' : '命中率', ox + 166, oy + 152, { font: 'font8', color: 'tan' })
font.drawText(ctx, String(this.attrs.attackRating), ox + 290, oy + 152, {
font.drawText(ctx, isEn ? 'Attack Rating' : '命中率', ox + 166, oy + 156, { font: 'font8', color: 'tan' })
font.drawText(ctx, String(this.attrs.attackRating), ox + 290, oy + 156, {
font: 'font8',
color: 'white',
align: 'center',
})
font.drawText(ctx, isEn ? 'Defense' : '防御力', ox + 166, oy + 201, { font: 'font8', color: 'tan' })
font.drawText(ctx, String(this.attrs.defense), ox + 290, oy + 201, {
font.drawText(ctx, isEn ? 'Defense' : '防御力', ox + 166, oy + 204, { font: 'font8', color: 'tan' })
font.drawText(ctx, String(this.attrs.defense), ox + 290, oy + 204, {
font: 'font8',
color: 'blue',
align: 'center',
})
font.drawText(ctx, isEn ? 'Stamina' : '耐力', ox + 166, oy + 238, { font: 'font8', color: 'tan' })
font.drawText(ctx, `${Math.round(liveRuntime.maxStamina)}`, ox + 251, oy + 238, {
font.drawText(ctx, isEn ? 'Stamina' : '耐力', ox + 166, oy + 242, { font: 'font8', color: 'tan' })
font.drawText(ctx, `${Math.round(liveRuntime.maxStamina)}`, ox + 251, oy + 242, {
font: 'font8',
color: 'white',
align: 'center',
})
font.drawText(ctx, `${Math.round(liveRuntime.stamina)}`, ox + 291, oy + 238, {
font.drawText(ctx, `${Math.round(liveRuntime.stamina)}`, ox + 291, oy + 242, {
font: 'font8',
color: 'white',
align: 'center',
})
font.drawText(ctx, isEn ? 'Life' : '生命', ox + 166, oy + 262, { font: 'font8', color: 'tan' })
font.drawText(ctx, `${Math.round(liveRuntime.maxHp)}`, ox + 251, oy + 262, {
font.drawText(ctx, isEn ? 'Life' : '生命', ox + 166, oy + 266, { font: 'font8', color: 'tan' })
font.drawText(ctx, `${Math.round(liveRuntime.maxHp)}`, ox + 251, oy + 266, {
font: 'font8',
color: 'blue',
align: 'center',
})
font.drawText(ctx, `${Math.round(liveRuntime.hp)}`, ox + 291, oy + 262, {
font.drawText(ctx, `${Math.round(liveRuntime.hp)}`, ox + 291, oy + 266, {
font: 'font8',
color: 'white',
align: 'center',
})
font.drawText(ctx, isEn ? 'Mana' : '法力', ox + 166, oy + 300, { font: 'font8', color: 'tan' })
font.drawText(ctx, `${Math.round(liveRuntime.maxMana)}`, ox + 251, oy + 300, {
font.drawText(ctx, isEn ? 'Mana' : '法力', ox + 166, oy + 304, { font: 'font8', color: 'tan' })
font.drawText(ctx, `${Math.round(liveRuntime.maxMana)}`, ox + 251, oy + 304, {
font: 'font8',
color: 'blue',
align: 'center',
})
font.drawText(ctx, `${Math.round(liveRuntime.mana)}`, ox + 291, oy + 300, {
font.drawText(ctx, `${Math.round(liveRuntime.mana)}`, ox + 291, oy + 304, {
font: 'font8',
color: 'white',
align: 'center',
@ -335,20 +335,20 @@ export class CharacterSheetPanel {
// 7. Four Elemental Resistances
const resRows = [
{ labelEn: ['Fire Resistance'], labelZh: '火焰抗性', y: 341, val: this.attrs.fireRes },
{ labelEn: ['Cold Resistance'], labelZh: '冰冷抗性', y: 365, val: this.attrs.coldRes },
{ labelEn: ['Lightning', 'Resistance'], labelZh: '闪电抗性', y: 389, val: this.attrs.lightRes },
{ labelEn: ['Poison Resistance'], labelZh: '毒素抗性', y: 413, val: this.attrs.poisRes },
{ labelEn: ['Fire Resistance'], labelZh: '火焰抗性', y: 344, val: this.attrs.fireRes },
{ labelEn: ['Cold Resistance'], labelZh: '冰冷抗性', y: 368, val: this.attrs.coldRes },
{ labelEn: ['Lightning', 'Resistance'], labelZh: '闪电抗性', y: 392, val: this.attrs.lightRes },
{ labelEn: ['Poison Resistance'], labelZh: '毒素抗性', y: 416, val: this.attrs.poisRes },
] as const
for (const res of resRows) {
if (isEn) {
if (res.labelEn.length === 2) {
font.drawText(ctx, res.labelEn[0], ox + 214, oy + res.y - 2, {
font.drawText(ctx, res.labelEn[0], ox + 214, oy + res.y - 4, {
font: 'font6',
color: 'tan',
align: 'center',
})
font.drawText(ctx, res.labelEn[1], ox + 214, oy + res.y + 6, {
font.drawText(ctx, res.labelEn[1], ox + 214, oy + res.y + 4, {
font: 'font6',
color: 'tan',
align: 'center',

View File

@ -334,19 +334,27 @@ export class D2FontRenderer {
return total
}
private canBlitAllSpans(font: D2FontName, spans: readonly TextSpan[]): boolean {
private canBlitGlyph(meta: FontAtlasMeta, rawCode: number): boolean {
const code = normalizeDc6CharCode(rawCode)
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
}
private hasAnyBlitableGlyph(font: D2FontName, spans: readonly TextSpan[]): boolean {
if (!this.loaded || !this.images.has(font)) return false
const meta = this.metas[font]
if (!meta) return false
for (const span of spans) {
for (let i = 0; i < span.text.length; i++) {
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
if (code !== 0x20 && code !== 0xa0 && this.canBlitGlyph(meta, code)) {
return true
}
}
}
return true
return false
}
/**
@ -385,43 +393,88 @@ export class D2FontRenderer {
ctx.font = `${isTitleFont ? 'bold ' : ''}${pxSize}px ${fontStack}`
ctx.textBaseline = 'alphabetic'
if (typeof ctx.drawImage === 'function' && this.canBlitAllSpans(font, spans)) {
if (typeof ctx.drawImage === 'function' && this.hasAnyBlitableGlyph(font, spans)) {
const meta = this.metas[font]!
let hybridTotal = 0
for (const span of spans) {
let i = 0
while (i < span.text.length) {
const rawCode = span.text.charCodeAt(i)
if (this.canBlitGlyph(meta, rawCode)) {
const code = normalizeDc6CharCode(rawCode)
hybridTotal += Math.max(4, meta.glyphs[code]!.width)
i++
} else {
let j = i + 1
while (j < span.text.length && !this.canBlitGlyph(meta, span.text.charCodeAt(j))) {
j++
}
hybridTotal += ctx.measureText(span.text.slice(i, j)).width
i = j
}
}
}
if (align === 'center') curX = Math.round(x - hybridTotal / 2)
else if (align === 'right') curX = Math.round(x - hybridTotal)
const dstY = baseY - FONT_BASELINE_OFFSET[font]
const shadowAtlas = shadow ? this.getTintedAtlas(font, 'black') : null
for (const span of spans) {
const tinted = this.getTintedAtlas(font, span.color)
for (let i = 0; i < span.text.length; 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) {
if (shadow && span.color !== 'black' && shadowAtlas) {
let i = 0
while (i < span.text.length) {
const rawCode = span.text.charCodeAt(i)
if (this.canBlitGlyph(meta, rawCode)) {
const code = normalizeDc6CharCode(rawCode)
const g = meta.glyphs[code]!
const adv = Math.max(4, g.width)
const gx = Math.round(curX)
if (code !== 0x20 && code !== 0xa0 && g.frameW > 0 && g.frameH > 0 && tinted) {
if (shadow && span.color !== 'black' && shadowAtlas) {
ctx.drawImage(
shadowAtlas,
g.x,
g.y,
g.frameW,
g.frameH,
gx + 1,
dstY + 1,
g.frameW,
g.frameH,
)
}
ctx.drawImage(
shadowAtlas,
tinted,
g.x,
g.y,
g.frameW,
g.frameH,
curX + 1,
dstY + 1,
gx,
dstY,
g.frameW,
g.frameH,
)
}
ctx.drawImage(
tinted,
g.x,
g.y,
g.frameW,
g.frameH,
curX,
dstY,
g.frameW,
g.frameH,
)
curX += adv
i++
} else {
let j = i + 1
while (j < span.text.length && !this.canBlitGlyph(meta, span.text.charCodeAt(j))) {
j++
}
const cjkRun = span.text.slice(i, j)
const rx = Math.round(curX)
if (shadow) {
ctx.fillStyle = '#000000'
ctx.fillText(cjkRun, rx + 1, baseY + 1)
ctx.fillText(cjkRun, rx - 1, baseY + 1)
ctx.fillText(cjkRun, rx + 1, baseY - 1)
}
ctx.fillStyle = D2_COLOR_HEX[span.color]
ctx.fillText(cjkRun, rx, baseY)
curX += ctx.measureText(cjkRun).width
i = j
}
curX += adv
}
}
ctx.restore()

View File

@ -2383,7 +2383,7 @@ export class WorldPanelsHud {
ctx.strokeStyle = '#6c5838'
ctx.strokeRect(ox + 18.5, oy + 264.5, 283, 115)
font.drawText(ctx, `${qInfo.title} ${qInfo.statusTag}`, ox + 160, oy + 280, {
font.drawText(ctx, `${qInfo.title} ${qInfo.statusTag}`, ox + 160, oy + 282, {
font: 'fontexocet10',
color: qInfo.status === 'completed' ? 'gold' : 'white',
align: 'center',

View File

@ -124,15 +124,22 @@ describe('Diablo II v1.13c Exocet & Formal 436 Fonts (Issue #504)', () => {
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
// D2Exocet 'Q' and 'q' (Qoppa) have 1 outer boundary + 1 enclosed inner hole = 2 contours
expect(getTrueTypeGlyphContourCount(exocetBuf, 'Q'.charCodeAt(0))).toBe(2)
expect(getTrueTypeGlyphContourCount(exocetBuf, 'q'.charCodeAt(0))).toBe(2)
// D2Exocet '=' has 2 separate horizontal bar contours
expect(getTrueTypeGlyphContourCount(exocetBuf, '='.charCodeAt(0))).toBe(2)
// Solid strokes 'E', 'F', 'H', '5' have 1 outer contour and 0 highlight/shadow 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)
expect(getTrueTypeGlyphContourCount(exocetBuf, '5'.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
// D2Formal436 'O' (U+004F), 'e' (U+0065), and '&' (U+0026) 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)
expect(getTrueTypeGlyphContourCount(formalBuf, '&'.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)
@ -411,6 +418,18 @@ describe('Diablo II v1.13c Exocet & Formal 436 Fonts (Issue #504)', () => {
})
expect(drawImageCalls).toHaveLength(0)
expect(fillTextCount).toBeGreaterThan(0)
// 5. Mixed ASCII + CJK string ("+45 防御") blits "+45" via DC6 drawImage (6 calls: 3 shadow + 3 fg) and draws "防御" via fillText
drawImageCalls.length = 0
fillTextCount = 0
renderer.drawText(blitCtx, '+45 防御', 200, 100, {
font: 'font16',
color: 'blue',
align: 'center',
shadow: true,
})
expect(drawImageCalls).toHaveLength(6)
expect(fillTextCount).toBe(4)
})
it('produces 12 distinct colors and tinted atlases for all 12 Diablo II color codes (ÿc0..ÿc;)', () => {
@ -599,8 +618,22 @@ describe('Diablo II v1.13c Exocet & Formal 436 Fonts (Issue #504)', () => {
expect(match!.widthPx).toBeLessThanOrEqual(104)
}
// Verify middle dot U+00B7 (0xB7) in metrics.json uses period width (<= 10px) rather than '?' placeholder width
expect(metricsJson.fontformal12.glyphs[0xb7].width).toBe(metricsJson.fontformal12.glyphs[0x2e].width)
expect(metricsJson.font30.glyphs[0xb7].width).toBe(metricsJson.font30.glyphs[0x2e].width)
// Verify middle dot U+00B7 (0xB7), bullet (0x95), and en/em-dashes (0x96/0x97) in metrics.json across all 6 DC6 fonts
for (const fn of ['font6', 'font8', 'font16', 'font30', 'fontexocet10', 'fontformal12'] as const) {
expect(metricsJson[fn].glyphs[0xb7].width).toBe(metricsJson[fn].glyphs[0x2e].width)
expect(metricsJson[fn].glyphs[0xb7].frameW).toBeGreaterThan(0)
expect(metricsJson[fn].glyphs[0xb7].frameH).toBeGreaterThan(0)
expect(metricsJson[fn].glyphs[0x95].frameW).toBeGreaterThan(0)
expect(metricsJson[fn].glyphs[0x96].frameW).toBeGreaterThan(0)
expect(metricsJson[fn].glyphs[0x97].frameW).toBeGreaterThan(0)
}
// Verify vertical centering inside stone recesses (oy=60: top recess 10..23 -> baseline 81; Str recess 83..98 -> baseline 154)
expect(drawnLabels.find(d => d.text === 'Isendra')?.y).toBe(60 + 21)
expect(drawnLabels.find(d => d.text === 'Sorceress')?.y).toBe(60 + 21)
expect(drawnLabels.find(d => d.text === 'Strength')?.y).toBe(60 + 94)
expect(drawnLabels.find(d => d.text === 'Dexterity')?.y).toBe(60 + 156)
expect(drawnLabels.find(d => d.text === 'Vitality')?.y).toBe(60 + 242)
expect(drawnLabels.find(d => d.text === 'Energy')?.y).toBe(60 + 304)
})
})