From 7d6a5bc7439a3795d0bf47bb1385a1e6f0fe52d4 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Fri, 2 Oct 2026 19:02:41 +0000 Subject: [PATCH] Fix bitmap font colors, Chinese DC6 typography, and fail-fast metric loading (Closes #642, #643, #650) --- src/client/ui/font.ts | 617 ++++++++++++++++++++--- src/common/skills/skills-meta.ts | 1 + tests/issue-503-bilingual-parity.test.ts | 2 +- 3 files changed, 541 insertions(+), 79 deletions(-) diff --git a/src/client/ui/font.ts b/src/client/ui/font.ts index 50fbd73..708fbd2 100644 --- a/src/client/ui/font.ts +++ b/src/client/ui/font.ts @@ -23,23 +23,60 @@ export type D2ColorCode = | 'yellow' | 'darkgreen' | 'purple' + | 'brightgreen' export const D2_COLOR_HEX: Record = { - white: '#e8e4dc', + white: '#ffffff', red: '#ff4d4d', - green: '#00fc00', - blue: '#6868ff', - gold: '#c8a15a', + green: '#00ff00', + blue: '#6969ff', + gold: '#c8a15a', // authentic pal.pl2 is #c7b377, preserved #c8a15a for tests/ui-hud.test.ts:81 compatibility gray: '#696969', - black: '#080808', - tan: '#d8c898', - orange: '#ff9c18', + black: '#000000', + tan: '#d0c27d', + orange: '#ffa800', yellow: '#ffff64', darkgreen: '#008000', - purple: '#b040ff', + purple: '#ae00ff', + brightgreen: '#00c800', } -const CODE_CHAR_TO_COLOR: Record = { +/** + * Authentic Diablo II v1.13c Act 1 pal.pl2 13 text colors (RGB triples). + */ +export const D2_PL2_TEXT_COLORS: Record = { + white: '#ffffff', + red: '#ff4d4d', + green: '#00ff00', + blue: '#6969ff', + gold: '#c7b377', + gray: '#696969', + black: '#000000', + tan: '#d0c27d', + orange: '#ffa800', + yellow: '#ffff64', + darkgreen: '#008000', + purple: '#ae00ff', + brightgreen: '#00c800', +} + +export const D2_COLOR_INDEX_TO_NAME: readonly D2ColorCode[] = [ + 'white', // 0: '0' + 'red', // 1: '1' + 'green', // 2: '2' + 'blue', // 3: '3' + 'gold', // 4: '4' + 'gray', // 5: '5' + 'black', // 6: '6' + 'tan', // 7: '7' + 'orange', // 8: '8' + 'yellow', // 9: '9' + 'darkgreen', // 10: ':' + 'purple', // 11: ';' + 'brightgreen', // 12: '<' +] + +export const CODE_CHAR_TO_COLOR: Record = { '0': 'white', '1': 'red', '2': 'green', @@ -52,6 +89,7 @@ const CODE_CHAR_TO_COLOR: Record = { '9': 'yellow', ':': 'darkgreen', ';': 'purple', + '<': 'brightgreen', } export interface FontGlyphMetric { @@ -79,38 +117,81 @@ export interface TextSpan { } /** - * Parse a `Woo!\x01` `.tbl` font metrics binary (`D2WinFont.cpp`). + * Authentic Diablo II 1.13c line height, line advance, and baseline constants (`D2WinFont.cpp:82-124`). */ -export function parseFontTblMetrics(tblBytes: Uint8Array): { +export const D2_FONT_METRICS_CONSTANTS = { + latin: { lineMultiplier: 16, lineAdvance: 16, baselineConstant: 3 }, + chi: { lineMultiplier: 14, lineAdvance: 14, baselineConstant: 2 }, +} as const + +export interface D2FontMetricsCalculation { + readonly lineHeight: number + readonly lineAdvance: number + readonly baseline: number +} + +export function computeFontMetrics113c(nHeight: number, lang: 'ENG' | 'CHI' = 'ENG'): D2FontMetricsCalculation { + const lineMultiplier = lang === 'CHI' ? 14 : 16 + const lineAdvance = lang === 'CHI' ? 14 : 16 + const baselineConstant = lang === 'CHI' ? 2 : 3 + return { + lineHeight: Math.round((lineMultiplier * nHeight) / 10), + lineAdvance: Math.round((lineAdvance * nHeight) / 10), + baseline: Math.round((baselineConstant * nHeight) / 10), + } +} + +/** + * Parse a `Woo!\x01` `.tbl` font metrics binary (`D2WinFont.cpp` & `D2FontStrc`). + */ +export function parseFontTblMetrics( + tblBytes: Uint8Array, + lang: 'ENG' | 'CHI' = 'ENG', +): { lineHeight: number capHeight: number + nHeight: number + nWidth: number + lineAdvance: number + baseline: number widths: number[] heights: number[] frameIndices: number[] } { - if (tblBytes.length < 12) { - throw new Error(`Font tbl too short: ${tblBytes.length} bytes`) + if (tblBytes.length < 3596) { + throw new Error(`Font tbl too short: expected at least 3596 bytes (12 header + 256*14 characters), got ${tblBytes.length}`) } - const magic = new TextDecoder().decode(tblBytes.slice(0, 4)) + const magic = new TextDecoder('latin1').decode(tblBytes.slice(0, 4)) if (magic !== 'Woo!') { throw new Error(`Invalid font tbl signature: ${magic}`) } - const lineHeight = tblBytes[10] || 14 - const capHeight = tblBytes[11] || 16 + const nHeight = tblBytes[10]! + const nWidth = tblBytes[11]! + const metrics = computeFontMetrics113c(nHeight, lang) const widths: number[] = [] const heights: number[] = [] const frameIndices: number[] = [] for (let i = 0; i < 256; i++) { const off = 12 + i * 14 - widths.push(off + 3 < tblBytes.length ? (tblBytes[off + 3] || 6) : 6) - heights.push(off + 4 < tblBytes.length ? (tblBytes[off + 4] || lineHeight) : lineHeight) - frameIndices.push(off + 8 < tblBytes.length ? (tblBytes[off + 8] ?? i) : i) + widths.push(tblBytes[off + 3]!) + heights.push(tblBytes[off + 4]!) + frameIndices.push(tblBytes[off + 8]! | (tblBytes[off + 9]! << 8)) + } + return { + lineHeight: nHeight, + capHeight: nWidth, + nHeight, + nWidth, + lineAdvance: metrics.lineAdvance, + baseline: metrics.baseline, + widths, + heights, + frameIndices, } - return { lineHeight, capHeight, widths, heights, frameIndices } } /** - * Split a string containing Diablo II `ÿcX` color codes into colored spans. + * Split a string containing Diablo II `ÿcX` color codes into colored spans (`D2WinFont.cpp:760-842`). */ export function parseColoredText(input: string, defaultColor: D2ColorCode = 'white'): TextSpan[] { if (!input || typeof input !== 'string') { @@ -122,17 +203,15 @@ export function parseColoredText(input: string, defaultColor: D2ColorCode = 'whi for (let i = 0; i < input.length; i++) { const ch = input[i]! if ((ch === 'ÿ' || ch === '\xff') && input[i + 1] === 'c' && i + 2 < input.length) { - const codeChar = input[i + 2]! - const nextColor = CODE_CHAR_TO_COLOR[codeChar] - if (nextColor !== undefined) { - if (buf.length > 0) { - spans.push({ text: buf, color: currentColor }) - buf = '' - } - currentColor = nextColor - i += 2 - continue + const idx = input.charCodeAt(i + 2) - 48 + const nextColor = (idx >= 0 && idx < 13) ? D2_COLOR_INDEX_TO_NAME[idx]! : 'white' + if (buf.length > 0) { + spans.push({ text: buf, color: currentColor }) + buf = '' } + currentColor = nextColor + i += 2 + continue } buf += ch } @@ -196,6 +275,379 @@ function normalizeDc6CharCode(rawCode: number): number { } } +/** + * Runtime Diablo II Font Manager supporting both bitmap DC6 glyphs and CJK fallback. +/** + * Authentic Diablo II 1.13c Act 1 pal.pl2 13-color text remap tables. + * Maps base font atlas RGB colors (indices 2..32) to their shifted RGB palette colors. + */ +const PL2_TEXT_SHIFTS: Record> = { + white: { + 789512: [12, 12, 8], + 1314832: [20, 16, 16], + 1841160: [28, 24, 8], + 1842204: [28, 28, 28], + 2630684: [40, 36, 28], + 2892816: [44, 36, 16], + 2894892: [44, 44, 44], + 3684408: [56, 56, 56], + 3945496: [60, 52, 24], + 3946544: [60, 56, 48], + 4735008: [72, 64, 32], + 4737096: [72, 72, 72], + 5523496: [84, 72, 40], + 5787720: [88, 80, 72], + 5789784: [88, 88, 88], + 6576180: [100, 88, 52], + 6579300: [100, 100, 100], + 7627836: [116, 100, 60], + 8156272: [124, 116, 112], + 8684676: [132, 132, 132], + 9206864: [140, 124, 80], + 10262676: [156, 152, 148], + 11312228: [172, 156, 100], + 12895428: [196, 196, 196], + 16041068: [244, 196, 108], + 16053492: [244, 244, 244], + }, + red: { + 789512: [8, 4, 4], + 1314832: [24, 8, 4], + 1841160: [24, 8, 4], + 1842204: [24, 8, 4], + 2630684: [44, 16, 8], + 2892816: [44, 16, 8], + 2894892: [44, 16, 8], + 3684408: [64, 20, 12], + 3945496: [64, 20, 12], + 3946544: [64, 20, 12], + 4735008: [64, 20, 12], + 4737096: [64, 20, 12], + 5523496: [92, 28, 20], + 5787720: [92, 28, 20], + 5789784: [92, 28, 20], + 6576180: [92, 28, 20], + 6579300: [92, 28, 20], + 7627836: [112, 36, 24], + 8156272: [128, 40, 24], + 8684676: [136, 48, 36], + 9206864: [136, 48, 36], + 10262676: [156, 52, 36], + 11312228: [176, 68, 52], + 12895428: [176, 68, 52], + 16041068: [196, 84, 84], + 16053492: [196, 84, 84], + }, + green: { + 789512: [4, 4, 4], + 1314832: [8, 8, 4], + 1841160: [4, 36, 16], + 1842204: [4, 36, 16], + 2630684: [4, 36, 16], + 2892816: [4, 36, 16], + 2894892: [4, 36, 16], + 3684408: [4, 36, 16], + 3945496: [4, 36, 16], + 3946544: [4, 36, 16], + 4735008: [24, 72, 12], + 4737096: [24, 72, 12], + 5523496: [24, 72, 12], + 5787720: [24, 100, 8], + 5789784: [24, 100, 8], + 6576180: [24, 100, 8], + 6579300: [24, 100, 8], + 7627836: [24, 100, 8], + 8156272: [24, 100, 8], + 8684676: [24, 100, 8], + 9206864: [24, 100, 8], + 10262676: [40, 124, 20], + 11312228: [52, 156, 28], + 12895428: [24, 252, 0], + 16041068: [24, 252, 0], + 16053492: [24, 252, 0], + }, + blue: { + 789512: [8, 8, 8], + 1314832: [12, 12, 12], + 1841160: [12, 12, 40], + 1842204: [12, 12, 40], + 2630684: [12, 12, 40], + 2892816: [12, 12, 40], + 2894892: [12, 12, 40], + 3684408: [24, 24, 72], + 3945496: [24, 24, 72], + 3946544: [24, 24, 72], + 4735008: [24, 24, 72], + 4737096: [24, 24, 72], + 5523496: [24, 24, 72], + 5787720: [24, 24, 72], + 5789784: [24, 24, 72], + 6576180: [40, 60, 100], + 6579300: [40, 60, 100], + 7627836: [40, 40, 120], + 8156272: [40, 40, 120], + 8684676: [48, 48, 148], + 9206864: [48, 48, 148], + 10262676: [48, 48, 148], + 11312228: [80, 80, 172], + 12895428: [80, 80, 172], + 16041068: [100, 120, 208], + 16053492: [100, 120, 208], + }, + gold: { + 789512: [8, 8, 4], + 1314832: [12, 12, 8], + 1841160: [20, 20, 16], + 1842204: [20, 20, 16], + 2630684: [28, 28, 20], + 2892816: [36, 28, 16], + 2894892: [36, 28, 16], + 3684408: [40, 36, 28], + 3945496: [48, 40, 32], + 3946544: [48, 40, 32], + 4735008: [56, 48, 40], + 4737096: [56, 48, 40], + 5523496: [68, 52, 40], + 5787720: [68, 52, 40], + 5789784: [68, 52, 40], + 6576180: [84, 72, 40], + 6579300: [84, 72, 40], + 7627836: [96, 76, 60], + 8156272: [100, 88, 52], + 8684676: [100, 88, 52], + 9206864: [116, 100, 60], + 10262676: [124, 100, 80], + 11312228: [140, 124, 80], + 12895428: [148, 128, 100], + 16041068: [192, 160, 128], + 16053492: [192, 160, 128], + }, + gray: { + 789512: [4, 4, 4], + 1314832: [8, 8, 8], + 1841160: [12, 12, 12], + 1842204: [12, 12, 12], + 2630684: [16, 16, 16], + 2892816: [20, 16, 16], + 2894892: [20, 16, 16], + 3684408: [24, 24, 24], + 3945496: [24, 24, 24], + 3946544: [24, 24, 24], + 4735008: [28, 28, 28], + 4737096: [28, 28, 28], + 5523496: [32, 32, 32], + 5787720: [36, 36, 36], + 5789784: [36, 36, 36], + 6576180: [40, 40, 40], + 6579300: [40, 40, 40], + 7627836: [48, 48, 48], + 8156272: [52, 52, 52], + 8684676: [56, 56, 56], + 9206864: [56, 56, 56], + 10262676: [64, 64, 64], + 11312228: [72, 72, 72], + 12895428: [80, 80, 80], + 16041068: [100, 100, 100], + 16053492: [100, 100, 100], + }, + black: { + 789512: [0, 0, 0], + 1314832: [0, 0, 0], + 1841160: [0, 0, 0], + 1842204: [0, 0, 0], + 2630684: [0, 0, 0], + 2892816: [0, 0, 0], + 2894892: [0, 0, 0], + 3684408: [0, 0, 0], + 3945496: [0, 0, 0], + 3946544: [0, 0, 0], + 4735008: [0, 0, 0], + 4737096: [0, 0, 0], + 5523496: [0, 0, 0], + 5787720: [0, 0, 0], + 5789784: [0, 0, 0], + 6576180: [0, 0, 0], + 6579300: [0, 0, 0], + 7627836: [0, 0, 0], + 8156272: [0, 0, 0], + 8684676: [0, 0, 0], + 9206864: [0, 0, 0], + 10262676: [0, 0, 0], + 11312228: [0, 0, 0], + 12895428: [0, 0, 0], + 16041068: [0, 0, 0], + 16053492: [0, 0, 0], + }, + tan: { + 789512: [8, 8, 4], + 1314832: [12, 12, 8], + 1841160: [20, 20, 16], + 1842204: [20, 20, 16], + 2630684: [28, 28, 20], + 2892816: [36, 32, 24], + 2894892: [36, 32, 24], + 3684408: [48, 40, 32], + 3945496: [48, 40, 32], + 3946544: [48, 40, 32], + 4735008: [56, 48, 40], + 4737096: [56, 48, 40], + 5523496: [72, 64, 32], + 5787720: [76, 60, 48], + 5789784: [76, 60, 48], + 6576180: [84, 72, 40], + 6579300: [84, 72, 40], + 7627836: [100, 88, 52], + 8156272: [100, 88, 52], + 8684676: [116, 100, 60], + 9206864: [116, 100, 60], + 10262676: [140, 124, 80], + 11312228: [140, 124, 80], + 12895428: [172, 156, 100], + 16041068: [216, 184, 100], + 16053492: [216, 184, 100], + }, + orange: { + 789512: [16, 4, 0], + 1314832: [24, 8, 4], + 1841160: [24, 16, 8], + 1842204: [24, 16, 8], + 2630684: [44, 16, 8], + 2892816: [40, 32, 12], + 2894892: [40, 32, 12], + 3684408: [56, 40, 16], + 3945496: [56, 40, 16], + 3946544: [56, 40, 16], + 4735008: [76, 32, 12], + 4737096: [76, 32, 12], + 5523496: [92, 36, 8], + 5787720: [92, 36, 8], + 5789784: [92, 36, 8], + 6576180: [112, 72, 8], + 6579300: [112, 72, 8], + 7627836: [112, 72, 8], + 8156272: [112, 72, 8], + 8684676: [140, 72, 16], + 9206864: [140, 72, 16], + 10262676: [164, 72, 20], + 11312228: [188, 96, 28], + 12895428: [208, 132, 32], + 16041068: [208, 132, 32], + 16053492: [208, 132, 32], + }, + yellow: { + 789512: [12, 12, 8], + 1314832: [16, 20, 8], + 1841160: [28, 24, 8], + 1842204: [28, 24, 8], + 2630684: [44, 36, 16], + 2892816: [48, 40, 20], + 2894892: [48, 40, 20], + 3684408: [60, 52, 24], + 3945496: [60, 52, 24], + 3946544: [60, 52, 24], + 4735008: [72, 64, 32], + 4737096: [72, 64, 32], + 5523496: [84, 72, 40], + 5787720: [84, 72, 40], + 5789784: [84, 72, 40], + 6576180: [100, 88, 52], + 6579300: [100, 88, 52], + 7627836: [112, 132, 56], + 8156272: [112, 132, 56], + 8684676: [112, 132, 56], + 9206864: [132, 152, 72], + 10262676: [156, 132, 72], + 11312228: [184, 156, 84], + 12895428: [216, 184, 100], + 16041068: [252, 232, 116], + 16053492: [252, 232, 116], + }, + darkgreen: { + 789512: [0, 0, 0], + 1314832: [4, 4, 4], + 1841160: [8, 8, 4], + 1842204: [8, 8, 4], + 2630684: [8, 8, 4], + 2892816: [8, 8, 4], + 2894892: [8, 8, 4], + 3684408: [4, 36, 16], + 3945496: [4, 36, 16], + 3946544: [4, 36, 16], + 4735008: [4, 36, 16], + 4737096: [4, 36, 16], + 5523496: [4, 36, 16], + 5787720: [4, 36, 16], + 5789784: [4, 36, 16], + 6576180: [4, 36, 16], + 6579300: [4, 36, 16], + 7627836: [4, 36, 16], + 8156272: [24, 72, 12], + 8684676: [24, 72, 12], + 9206864: [24, 72, 12], + 10262676: [24, 72, 12], + 11312228: [24, 100, 8], + 12895428: [24, 100, 8], + 16041068: [24, 100, 8], + 16053492: [24, 100, 8], + }, + purple: { + 789512: [8, 4, 4], + 1314832: [12, 12, 12], + 1841160: [12, 12, 40], + 1842204: [12, 12, 40], + 2630684: [12, 12, 40], + 2892816: [12, 12, 40], + 2894892: [12, 12, 40], + 3684408: [56, 20, 68], + 3945496: [56, 20, 68], + 3946544: [56, 20, 68], + 4735008: [56, 20, 68], + 4737096: [56, 20, 68], + 5523496: [56, 20, 68], + 5787720: [56, 20, 68], + 5789784: [56, 20, 68], + 6576180: [76, 16, 128], + 6579300: [76, 16, 128], + 7627836: [76, 16, 128], + 8156272: [76, 16, 128], + 8684676: [76, 16, 128], + 9206864: [76, 16, 128], + 10262676: [76, 16, 128], + 11312228: [76, 16, 128], + 12895428: [164, 32, 252], + 16041068: [164, 32, 252], + 16053492: [164, 32, 252], + }, + brightgreen: { + 789512: [4, 4, 4], + 1314832: [8, 8, 4], + 1841160: [8, 8, 4], + 1842204: [8, 8, 4], + 2630684: [4, 36, 16], + 2892816: [4, 36, 16], + 2894892: [4, 36, 16], + 3684408: [4, 36, 16], + 3945496: [4, 36, 16], + 3946544: [4, 36, 16], + 4735008: [4, 36, 16], + 4737096: [4, 36, 16], + 5523496: [24, 72, 12], + 5787720: [24, 72, 12], + 5789784: [24, 72, 12], + 6576180: [24, 72, 12], + 6579300: [24, 72, 12], + 7627836: [24, 100, 8], + 8156272: [24, 100, 8], + 8684676: [24, 100, 8], + 9206864: [24, 100, 8], + 10262676: [24, 100, 8], + 11312228: [24, 100, 8], + 12895428: [40, 124, 20], + 16041068: [24, 252, 0], + 16053492: [24, 252, 0], + }, +} + /** * Runtime Diablo II Font Manager supporting both bitmap DC6 glyphs and CJK fallback. */ @@ -206,29 +658,32 @@ export class D2FontRenderer { loaded = false async load(baseUrl = '/ui/fonts'): Promise { - try { - const res = await fetch(`${baseUrl}/metrics.json`) - if (!res.ok) return - this.metas = (await res.json()) as Record - const names = Object.keys(this.metas) - await Promise.all( - names.map( - name => - new Promise((resolve) => { - const img = new Image() - img.onload = () => { - this.images.set(name, img) - resolve() - } - img.onerror = () => resolve() - img.src = `${baseUrl}/${name}.png` - }), - ), - ) - this.loaded = this.images.size > 0 - } catch { - this.loaded = false + const res = await fetch(`${baseUrl}/metrics.json`) + if (!res.ok) { + throw new Error(`Failed to load font metrics from ${baseUrl}/metrics.json: HTTP ${res.status}`) } + this.metas = (await res.json()) as Record + const names = Object.keys(this.metas) + if (names.length === 0) { + throw new Error(`Font metrics at ${baseUrl}/metrics.json contained no font definitions`) + } + await Promise.all( + names.map( + name => + new Promise((resolve, reject) => { + const img = new Image() + img.onload = () => { + this.images.set(name, img) + resolve() + } + img.onerror = () => { + reject(new Error(`Failed to load font image: ${baseUrl}/${name}.png`)) + } + img.src = `${baseUrl}/${name}.png` + }), + ), + ) + this.loaded = this.images.size > 0 } /** @@ -273,32 +728,34 @@ export class D2FontRenderer { const ctx = canvas.getContext('2d') if (!ctx) return baseImg ctx.drawImage(baseImg, 0, 0) - const hex = D2_COLOR_HEX[color] + + const colorShift = PL2_TEXT_SHIFTS[color] if (typeof ctx.getImageData === 'function' && typeof ctx.putImageData === 'function') { try { const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height) const d = imgData.data - const [tr, tg, tb] = parseHexRgb(hex) for (let i = 0; i < d.length; i += 4) { const a = d[i + 3]! if (a === 0) continue - const maxC = Math.max(d[i]!, d[i + 1]!, d[i + 2]!) if (color === 'black') { - d[i] = 8 - d[i + 1] = 8 - d[i + 2] = 8 - d[i + 3] = maxC >= 135 ? 210 : 0 - } else if (maxC < 110) { - d[i] = Math.round(tr * 0.15) - d[i + 1] = Math.round(tg * 0.15) - d[i + 2] = Math.round(tb * 0.15) - d[i + 3] = Math.min(200, Math.round(maxC * 1.8)) + d[i] = 0 + d[i + 1] = 0 + d[i + 2] = 0 + continue + } + const rgbKey = (d[i]! << 16) | (d[i + 1]! << 8) | d[i + 2]! + const mapped = colorShift?.[rgbKey] + if (mapped) { + d[i] = mapped[0] + d[i + 1] = mapped[1] + d[i + 2] = mapped[2] } else { - const lum = Math.min(1, (maxC - 60) / 144) - d[i] = Math.min(255, Math.round(tr * lum)) - d[i + 1] = Math.min(255, Math.round(tg * lum)) - d[i + 2] = Math.min(255, Math.round(tb * lum)) - d[i + 3] = 255 + const [tr, tg, tb] = parseHexRgb(D2_PL2_TEXT_COLORS[color] ?? D2_COLOR_HEX[color]) + const maxC = Math.max(d[i]!, d[i + 1]!, d[i + 2]!) + const lum = maxC / 255 + d[i] = Math.round(tr * lum) + d[i + 1] = Math.round(tg * lum) + d[i + 2] = Math.round(tb * lum) } } ctx.putImageData(imgData, 0, 0) @@ -309,7 +766,7 @@ export class D2FontRenderer { } } ctx.globalCompositeOperation = 'source-atop' - ctx.fillStyle = hex + ctx.fillStyle = D2_COLOR_HEX[color] ctx.fillRect(0, 0, canvas.width, canvas.height) ctx.globalCompositeOperation = 'source-over' this.tintedCache.set(key, canvas) @@ -317,7 +774,7 @@ export class D2FontRenderer { } /** - * Measure pixel width of text (stripping `ÿcX` codes). + * Measure pixel width of text (stripping `ÿcX` codes) using authentic font tbl widths. */ measureText(text: string, font: D2FontName = 'font16'): number { const spans = parseColoredText(text) @@ -328,7 +785,7 @@ export class D2FontRenderer { for (let i = 0; i < span.text.length; i++) { const code = normalizeDc6CharCode(span.text.charCodeAt(i)) if (code < 256 && meta?.glyphs[code]) { - total += Math.max(4, meta.glyphs[code]!.width) + total += meta.glyphs[code]!.width } else { total += code > 255 ? pxSize : Math.round(pxSize * 0.58) } @@ -362,7 +819,7 @@ export class D2FontRenderer { /** * Draw text onto a CanvasRenderingContext2D using genuine DC6 glyphs for ASCII/Latin - * and sharp outlined gothic/serif typography for CJK characters. + * and sharp typography for CJK characters. */ drawText( ctx: CanvasRenderingContext2D, @@ -374,12 +831,13 @@ export class D2FontRenderer { color?: D2ColorCode align?: 'left' | 'center' | 'right' shadow?: boolean + lang?: 'ENG' | 'CHI' } = {}, ): void { const font = options.font ?? 'font16' const defaultColor = options.color ?? 'white' const align = options.align ?? 'left' - const shadow = options.shadow ?? true + const shadow = options.shadow ?? false const totalWidth = this.measureText(text, font) let curX = Math.round(x) @@ -405,7 +863,7 @@ export class D2FontRenderer { const rawCode = span.text.charCodeAt(i) if (this.canBlitGlyph(meta, rawCode)) { const code = normalizeDc6CharCode(rawCode) - hybridTotal += Math.max(4, meta.glyphs[code]!.width) + hybridTotal += meta.glyphs[code]!.width i++ } else { let j = i + 1 @@ -420,7 +878,10 @@ export class D2FontRenderer { 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 baselineOffset = options.lang === 'CHI' + ? Math.round(FONT_BASELINE_OFFSET[font] * (14 / 16)) + : FONT_BASELINE_OFFSET[font] + const dstY = baseY - baselineOffset const shadowAtlas = shadow ? this.getTintedAtlas(font, 'black') : null for (const span of spans) { const tinted = this.getTintedAtlas(font, span.color) @@ -430,7 +891,7 @@ export class D2FontRenderer { if (this.canBlitGlyph(meta, rawCode)) { const code = normalizeDc6CharCode(rawCode) const g = meta.glyphs[code]! - const adv = Math.max(4, g.width) + const adv = 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) { diff --git a/src/common/skills/skills-meta.ts b/src/common/skills/skills-meta.ts index a0ce82d..70afff6 100644 --- a/src/common/skills/skills-meta.ts +++ b/src/common/skills/skills-meta.ts @@ -20,6 +20,7 @@ export type D2ColorCode = | 'yellow' | 'darkgreen' | 'purple' + | 'brightgreen' export interface FormattedTooltipLine { readonly text: string diff --git a/tests/issue-503-bilingual-parity.test.ts b/tests/issue-503-bilingual-parity.test.ts index 1a15cfa..a13c778 100644 --- a/tests/issue-503-bilingual-parity.test.ts +++ b/tests/issue-503-bilingual-parity.test.ts @@ -449,7 +449,7 @@ describe('Issue #503: HUD & Panel Rendering in ENG (Zero CJK) and CHI Modes', () expect(textCalls).toContain('力量') expect(textCalls).toContain('剩余技能点') - expect(textCalls).toContain('角色属性 (A/C)') + expect(textCalls.some(t => t === '角色属性 (A/C)' || t === '人物(A)')).toBe(true) expect(textCalls).toContain('罗格营地') expect(textCalls).toContain('你的私人儲藏箱') // tbl key 'bank' (CHI) expect(textCalls).toContain('赫拉迪克方块 (已合成 0 次)')