fix(ui,fonts): hybrid DC6+CJK rendering, middle-dot atlas mask, TTF contour refinement, and recess centering (Issue #504 R2 review)
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)}`)
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
107
src/ui/font.ts
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||