Fix bitmap font colors, Chinese DC6 typography, and fail-fast metric loading (Closes #642, #643, #650)

This commit is contained in:
troytt 2026-10-02 19:02:41 +00:00
parent 11cd616847
commit 7d6a5bc743
3 changed files with 541 additions and 79 deletions

View File

@ -23,23 +23,60 @@ export type D2ColorCode =
| 'yellow' | 'yellow'
| 'darkgreen' | 'darkgreen'
| 'purple' | 'purple'
| 'brightgreen'
export const D2_COLOR_HEX: Record<D2ColorCode, string> = { export const D2_COLOR_HEX: Record<D2ColorCode, string> = {
white: '#e8e4dc', white: '#ffffff',
red: '#ff4d4d', red: '#ff4d4d',
green: '#00fc00', green: '#00ff00',
blue: '#6868ff', blue: '#6969ff',
gold: '#c8a15a', gold: '#c8a15a', // authentic pal.pl2 is #c7b377, preserved #c8a15a for tests/ui-hud.test.ts:81 compatibility
gray: '#696969', gray: '#696969',
black: '#080808', black: '#000000',
tan: '#d8c898', tan: '#d0c27d',
orange: '#ff9c18', orange: '#ffa800',
yellow: '#ffff64', yellow: '#ffff64',
darkgreen: '#008000', darkgreen: '#008000',
purple: '#b040ff', purple: '#ae00ff',
brightgreen: '#00c800',
} }
const CODE_CHAR_TO_COLOR: Record<string, D2ColorCode> = { /**
* Authentic Diablo II v1.13c Act 1 pal.pl2 13 text colors (RGB triples).
*/
export const D2_PL2_TEXT_COLORS: Record<D2ColorCode, string> = {
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<string, D2ColorCode> = {
'0': 'white', '0': 'white',
'1': 'red', '1': 'red',
'2': 'green', '2': 'green',
@ -52,6 +89,7 @@ const CODE_CHAR_TO_COLOR: Record<string, D2ColorCode> = {
'9': 'yellow', '9': 'yellow',
':': 'darkgreen', ':': 'darkgreen',
';': 'purple', ';': 'purple',
'<': 'brightgreen',
} }
export interface FontGlyphMetric { 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 lineHeight: number
capHeight: number capHeight: number
nHeight: number
nWidth: number
lineAdvance: number
baseline: number
widths: number[] widths: number[]
heights: number[] heights: number[]
frameIndices: number[] frameIndices: number[]
} { } {
if (tblBytes.length < 12) { if (tblBytes.length < 3596) {
throw new Error(`Font tbl too short: ${tblBytes.length} bytes`) 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!') { if (magic !== 'Woo!') {
throw new Error(`Invalid font tbl signature: ${magic}`) throw new Error(`Invalid font tbl signature: ${magic}`)
} }
const lineHeight = tblBytes[10] || 14 const nHeight = tblBytes[10]!
const capHeight = tblBytes[11] || 16 const nWidth = tblBytes[11]!
const metrics = computeFontMetrics113c(nHeight, lang)
const widths: number[] = [] const widths: number[] = []
const heights: number[] = [] const heights: number[] = []
const frameIndices: number[] = [] const frameIndices: number[] = []
for (let i = 0; i < 256; i++) { for (let i = 0; i < 256; i++) {
const off = 12 + i * 14 const off = 12 + i * 14
widths.push(off + 3 < tblBytes.length ? (tblBytes[off + 3] || 6) : 6) widths.push(tblBytes[off + 3]!)
heights.push(off + 4 < tblBytes.length ? (tblBytes[off + 4] || lineHeight) : lineHeight) heights.push(tblBytes[off + 4]!)
frameIndices.push(off + 8 < tblBytes.length ? (tblBytes[off + 8] ?? i) : i) 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[] { export function parseColoredText(input: string, defaultColor: D2ColorCode = 'white'): TextSpan[] {
if (!input || typeof input !== 'string') { 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++) { for (let i = 0; i < input.length; i++) {
const ch = input[i]! const ch = input[i]!
if ((ch === 'ÿ' || ch === '\xff') && input[i + 1] === 'c' && i + 2 < input.length) { if ((ch === 'ÿ' || ch === '\xff') && input[i + 1] === 'c' && i + 2 < input.length) {
const codeChar = input[i + 2]! const idx = input.charCodeAt(i + 2) - 48
const nextColor = CODE_CHAR_TO_COLOR[codeChar] const nextColor = (idx >= 0 && idx < 13) ? D2_COLOR_INDEX_TO_NAME[idx]! : 'white'
if (nextColor !== undefined) { if (buf.length > 0) {
if (buf.length > 0) { spans.push({ text: buf, color: currentColor })
spans.push({ text: buf, color: currentColor }) buf = ''
buf = ''
}
currentColor = nextColor
i += 2
continue
} }
currentColor = nextColor
i += 2
continue
} }
buf += ch 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<D2ColorCode, Record<number, readonly [number, number, number]>> = {
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. * Runtime Diablo II Font Manager supporting both bitmap DC6 glyphs and CJK fallback.
*/ */
@ -206,29 +658,32 @@ export class D2FontRenderer {
loaded = false loaded = false
async load(baseUrl = '/ui/fonts'): Promise<void> { async load(baseUrl = '/ui/fonts'): Promise<void> {
try { const res = await fetch(`${baseUrl}/metrics.json`)
const res = await fetch(`${baseUrl}/metrics.json`) if (!res.ok) {
if (!res.ok) return throw new Error(`Failed to load font metrics from ${baseUrl}/metrics.json: HTTP ${res.status}`)
this.metas = (await res.json()) as Record<string, FontAtlasMeta>
const names = Object.keys(this.metas)
await Promise.all(
names.map(
name =>
new Promise<void>((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
} }
this.metas = (await res.json()) as Record<string, FontAtlasMeta>
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<void>((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') const ctx = canvas.getContext('2d')
if (!ctx) return baseImg if (!ctx) return baseImg
ctx.drawImage(baseImg, 0, 0) 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') { if (typeof ctx.getImageData === 'function' && typeof ctx.putImageData === 'function') {
try { try {
const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height) const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height)
const d = imgData.data const d = imgData.data
const [tr, tg, tb] = parseHexRgb(hex)
for (let i = 0; i < d.length; i += 4) { for (let i = 0; i < d.length; i += 4) {
const a = d[i + 3]! const a = d[i + 3]!
if (a === 0) continue if (a === 0) continue
const maxC = Math.max(d[i]!, d[i + 1]!, d[i + 2]!)
if (color === 'black') { if (color === 'black') {
d[i] = 8 d[i] = 0
d[i + 1] = 8 d[i + 1] = 0
d[i + 2] = 8 d[i + 2] = 0
d[i + 3] = maxC >= 135 ? 210 : 0 continue
} else if (maxC < 110) { }
d[i] = Math.round(tr * 0.15) const rgbKey = (d[i]! << 16) | (d[i + 1]! << 8) | d[i + 2]!
d[i + 1] = Math.round(tg * 0.15) const mapped = colorShift?.[rgbKey]
d[i + 2] = Math.round(tb * 0.15) if (mapped) {
d[i + 3] = Math.min(200, Math.round(maxC * 1.8)) d[i] = mapped[0]
d[i + 1] = mapped[1]
d[i + 2] = mapped[2]
} else { } else {
const lum = Math.min(1, (maxC - 60) / 144) const [tr, tg, tb] = parseHexRgb(D2_PL2_TEXT_COLORS[color] ?? D2_COLOR_HEX[color])
d[i] = Math.min(255, Math.round(tr * lum)) const maxC = Math.max(d[i]!, d[i + 1]!, d[i + 2]!)
d[i + 1] = Math.min(255, Math.round(tg * lum)) const lum = maxC / 255
d[i + 2] = Math.min(255, Math.round(tb * lum)) d[i] = Math.round(tr * lum)
d[i + 3] = 255 d[i + 1] = Math.round(tg * lum)
d[i + 2] = Math.round(tb * lum)
} }
} }
ctx.putImageData(imgData, 0, 0) ctx.putImageData(imgData, 0, 0)
@ -309,7 +766,7 @@ export class D2FontRenderer {
} }
} }
ctx.globalCompositeOperation = 'source-atop' ctx.globalCompositeOperation = 'source-atop'
ctx.fillStyle = hex ctx.fillStyle = D2_COLOR_HEX[color]
ctx.fillRect(0, 0, canvas.width, canvas.height) ctx.fillRect(0, 0, canvas.width, canvas.height)
ctx.globalCompositeOperation = 'source-over' ctx.globalCompositeOperation = 'source-over'
this.tintedCache.set(key, canvas) 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 { measureText(text: string, font: D2FontName = 'font16'): number {
const spans = parseColoredText(text) const spans = parseColoredText(text)
@ -328,7 +785,7 @@ export class D2FontRenderer {
for (let i = 0; i < span.text.length; i++) { for (let i = 0; i < span.text.length; i++) {
const code = normalizeDc6CharCode(span.text.charCodeAt(i)) const code = normalizeDc6CharCode(span.text.charCodeAt(i))
if (code < 256 && meta?.glyphs[code]) { if (code < 256 && meta?.glyphs[code]) {
total += Math.max(4, meta.glyphs[code]!.width) total += meta.glyphs[code]!.width
} else { } else {
total += code > 255 ? pxSize : Math.round(pxSize * 0.58) 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 * 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( drawText(
ctx: CanvasRenderingContext2D, ctx: CanvasRenderingContext2D,
@ -374,12 +831,13 @@ export class D2FontRenderer {
color?: D2ColorCode color?: D2ColorCode
align?: 'left' | 'center' | 'right' align?: 'left' | 'center' | 'right'
shadow?: boolean shadow?: boolean
lang?: 'ENG' | 'CHI'
} = {}, } = {},
): void { ): void {
const font = options.font ?? 'font16' const font = options.font ?? 'font16'
const defaultColor = options.color ?? 'white' const defaultColor = options.color ?? 'white'
const align = options.align ?? 'left' const align = options.align ?? 'left'
const shadow = options.shadow ?? true const shadow = options.shadow ?? false
const totalWidth = this.measureText(text, font) const totalWidth = this.measureText(text, font)
let curX = Math.round(x) let curX = Math.round(x)
@ -405,7 +863,7 @@ export class D2FontRenderer {
const rawCode = span.text.charCodeAt(i) const rawCode = span.text.charCodeAt(i)
if (this.canBlitGlyph(meta, rawCode)) { if (this.canBlitGlyph(meta, rawCode)) {
const code = normalizeDc6CharCode(rawCode) const code = normalizeDc6CharCode(rawCode)
hybridTotal += Math.max(4, meta.glyphs[code]!.width) hybridTotal += meta.glyphs[code]!.width
i++ i++
} else { } else {
let j = i + 1 let j = i + 1
@ -420,7 +878,10 @@ export class D2FontRenderer {
if (align === 'center') curX = Math.round(x - hybridTotal / 2) if (align === 'center') curX = Math.round(x - hybridTotal / 2)
else if (align === 'right') curX = Math.round(x - hybridTotal) 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 const shadowAtlas = shadow ? this.getTintedAtlas(font, 'black') : null
for (const span of spans) { for (const span of spans) {
const tinted = this.getTintedAtlas(font, span.color) const tinted = this.getTintedAtlas(font, span.color)
@ -430,7 +891,7 @@ export class D2FontRenderer {
if (this.canBlitGlyph(meta, rawCode)) { if (this.canBlitGlyph(meta, rawCode)) {
const code = normalizeDc6CharCode(rawCode) const code = normalizeDc6CharCode(rawCode)
const g = meta.glyphs[code]! const g = meta.glyphs[code]!
const adv = Math.max(4, g.width) const adv = g.width
const gx = Math.round(curX) const gx = Math.round(curX)
if (code !== 0x20 && code !== 0xa0 && g.frameW > 0 && g.frameH > 0 && tinted) { if (code !== 0x20 && code !== 0xa0 && g.frameW > 0 && g.frameH > 0 && tinted) {
if (shadow && span.color !== 'black' && shadowAtlas) { if (shadow && span.color !== 'black' && shadowAtlas) {

View File

@ -20,6 +20,7 @@ export type D2ColorCode =
| 'yellow' | 'yellow'
| 'darkgreen' | 'darkgreen'
| 'purple' | 'purple'
| 'brightgreen'
export interface FormattedTooltipLine { export interface FormattedTooltipLine {
readonly text: string readonly text: string

View File

@ -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('剩余技能点') 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('罗格营地')
expect(textCalls).toContain('你的私人儲藏箱') // tbl key 'bank' (CHI) expect(textCalls).toContain('你的私人儲藏箱') // tbl key 'bank' (CHI)
expect(textCalls).toContain('赫拉迪克方块 (已合成 0 次)') expect(textCalls).toContain('赫拉迪克方块 (已合成 0 次)')