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'
|
| '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) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 次)')
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue