diff --git a/public/ui/fonts/D2Exocet.ttf b/public/ui/fonts/D2Exocet.ttf index 32fea7a..a80d0b7 100644 Binary files a/public/ui/fonts/D2Exocet.ttf and b/public/ui/fonts/D2Exocet.ttf differ diff --git a/public/ui/fonts/D2Formal436.ttf b/public/ui/fonts/D2Formal436.ttf index 671f559..341dfb3 100644 Binary files a/public/ui/fonts/D2Formal436.ttf and b/public/ui/fonts/D2Formal436.ttf differ diff --git a/public/ui/fonts/font16.png b/public/ui/fonts/font16.png index 667116e..b49a5fb 100644 Binary files a/public/ui/fonts/font16.png and b/public/ui/fonts/font16.png differ diff --git a/public/ui/fonts/font30.png b/public/ui/fonts/font30.png index d95667a..90e6bdd 100644 Binary files a/public/ui/fonts/font30.png and b/public/ui/fonts/font30.png differ diff --git a/public/ui/fonts/font6.png b/public/ui/fonts/font6.png index 57158f7..0395a81 100644 Binary files a/public/ui/fonts/font6.png and b/public/ui/fonts/font6.png differ diff --git a/public/ui/fonts/font8.png b/public/ui/fonts/font8.png index e323d33..ca92aa3 100644 Binary files a/public/ui/fonts/font8.png and b/public/ui/fonts/font8.png differ diff --git a/public/ui/fonts/fontexocet10.png b/public/ui/fonts/fontexocet10.png index f83c013..00716d5 100644 Binary files a/public/ui/fonts/fontexocet10.png and b/public/ui/fonts/fontexocet10.png differ diff --git a/public/ui/fonts/fontformal12.png b/public/ui/fonts/fontformal12.png index c64f212..63b22d1 100644 Binary files a/public/ui/fonts/fontformal12.png and b/public/ui/fonts/fontformal12.png differ diff --git a/public/ui/fonts/metrics.json b/public/ui/fonts/metrics.json index 218aebf..851add5 100644 --- a/public/ui/fonts/metrics.json +++ b/public/ui/fonts/metrics.json @@ -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, diff --git a/scripts/build-d2-ttf.ts b/scripts/build-d2-ttf.ts index 8ddbdd0..028640b 100644 --- a/scripts/build-d2-ttf.ts +++ b/scripts/build-d2-ttf.ts @@ -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 diff --git a/scripts/pack-ui-assets.ts b/scripts/pack-ui-assets.ts index a2d8399..d11343b 100644 --- a/scripts/pack-ui-assets.ts +++ b/scripts/pack-ui-assets.ts @@ -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 { // 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 { 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 { 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) diff --git a/scripts/verify-issue-504-browser.ts b/scripts/verify-issue-504-browser.ts index 20a8f9c..050a5fc 100644 --- a/scripts/verify-issue-504-browser.ts +++ b/scripts/verify-issue-504-browser.ts @@ -12,7 +12,7 @@ import { createServer } from 'vite' const ARTIFACT_DIR = process.argv[2] || - '/usr/local/google/home/taodao/diablo2-web/.agents/teamwork/reviewer_504_r1' + '/usr/local/google/home/taodao/diablo2-web/.agents/teamwork/reviewer_504_r2' const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) @@ -241,7 +241,11 @@ async function main(): Promise { 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 { 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 { 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 { 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 { 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)}`) diff --git a/src/ui/character-sheet.ts b/src/ui/character-sheet.ts index 6e629eb..3a4cdb9 100644 --- a/src/ui/character-sheet.ts +++ b/src/ui/character-sheet.ts @@ -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', diff --git a/src/ui/font.ts b/src/ui/font.ts index 6ea1862..2b32e33 100644 --- a/src/ui/font.ts +++ b/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() diff --git a/src/ui/world-panels.ts b/src/ui/world-panels.ts index 5231ada..41bd739 100644 --- a/src/ui/world-panels.ts +++ b/src/ui/world-panels.ts @@ -2383,7 +2383,7 @@ export class WorldPanelsHud { ctx.strokeStyle = '#6c5838' ctx.strokeRect(ox + 18.5, oy + 264.5, 283, 115) - font.drawText(ctx, `${qInfo.title} ${qInfo.statusTag}`, ox + 160, oy + 280, { + font.drawText(ctx, `${qInfo.title} ${qInfo.statusTag}`, ox + 160, oy + 282, { font: 'fontexocet10', color: qInfo.status === 'completed' ? 'gold' : 'white', align: 'center', diff --git a/tests/issue-504-exocet-fonts.test.ts b/tests/issue-504-exocet-fonts.test.ts index 240cacf..9e2a38d 100644 --- a/tests/issue-504-exocet-fonts.test.ts +++ b/tests/issue-504-exocet-fonts.test.ts @@ -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) }) })