Fix bitmap font colors, Chinese DC6 typography, and fail-fast metric loading (Closes #642, #643, #650)
This commit is contained in:
parent
11cd616847
commit
7d6a5bc743
|
|
@ -23,23 +23,60 @@ export type D2ColorCode =
|
|||
| 'yellow'
|
||||
| 'darkgreen'
|
||||
| 'purple'
|
||||
| 'brightgreen'
|
||||
|
||||
export const D2_COLOR_HEX: Record<D2ColorCode, string> = {
|
||||
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<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',
|
||||
'1': 'red',
|
||||
'2': 'green',
|
||||
|
|
@ -52,6 +89,7 @@ const CODE_CHAR_TO_COLOR: Record<string, D2ColorCode> = {
|
|||
'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,9 +203,8 @@ 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) {
|
||||
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 = ''
|
||||
|
|
@ -133,7 +213,6 @@ export function parseColoredText(input: string, defaultColor: D2ColorCode = 'whi
|
|||
i += 2
|
||||
continue
|
||||
}
|
||||
}
|
||||
buf += ch
|
||||
}
|
||||
if (buf.length > 0 || spans.length === 0) {
|
||||
|
|
@ -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.
|
||||
*/
|
||||
|
|
@ -206,29 +658,32 @@ export class D2FontRenderer {
|
|||
loaded = false
|
||||
|
||||
async load(baseUrl = '/ui/fonts'): Promise<void> {
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/metrics.json`)
|
||||
if (!res.ok) return
|
||||
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<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) => {
|
||||
new Promise<void>((resolve, reject) => {
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
this.images.set(name, img)
|
||||
resolve()
|
||||
}
|
||||
img.onerror = () => 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
|
||||
} catch {
|
||||
this.loaded = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ export type D2ColorCode =
|
|||
| 'yellow'
|
||||
| 'darkgreen'
|
||||
| 'purple'
|
||||
| 'brightgreen'
|
||||
|
||||
export interface FormattedTooltipLine {
|
||||
readonly text: string
|
||||
|
|
|
|||
|
|
@ -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 次)')
|
||||
|
|
|
|||
Loading…
Reference in New Issue