283 lines
8.1 KiB
TypeScript
283 lines
8.1 KiB
TypeScript
/**
|
|
* Diablo II v1.13c Bitmap Font & Color Code Engine (`D2WinFont.cpp`).
|
|
*
|
|
* Supports:
|
|
* - Authentic `Woo!\x01` `.tbl` metric parsing (`data\local\font\latin\font*.tbl`)
|
|
* - 12 canonical Diablo II color control sequences (`ÿc0`..`ÿc;` and `\xffc0`..`\xffc;`)
|
|
* - Pixel-accurate glyph blitting from DC6 font atlases (`font6`, `font8`, `font16`, `font30`, `fontexocet10`, `fontformal12`)
|
|
* - Seamless CJK (Chinese) fallback with 1px black drop-outline matching Exocet/Formal cap heights
|
|
*/
|
|
|
|
export type D2FontName = 'font6' | 'font8' | 'font16' | 'font30' | 'fontexocet10' | 'fontformal12'
|
|
|
|
export type D2ColorCode =
|
|
| 'white'
|
|
| 'red'
|
|
| 'green'
|
|
| 'blue'
|
|
| 'gold'
|
|
| 'gray'
|
|
| 'black'
|
|
| 'tan'
|
|
| 'orange'
|
|
| 'yellow'
|
|
| 'darkgreen'
|
|
| 'purple'
|
|
|
|
export const D2_COLOR_HEX: Record<D2ColorCode, string> = {
|
|
white: '#e8e4dc',
|
|
red: '#ff4d4d',
|
|
green: '#00fc00',
|
|
blue: '#6868ff',
|
|
gold: '#c8a15a',
|
|
gray: '#696969',
|
|
black: '#080808',
|
|
tan: '#d8c898',
|
|
orange: '#ff9c18',
|
|
yellow: '#ffff64',
|
|
darkgreen: '#008000',
|
|
purple: '#b040ff',
|
|
}
|
|
|
|
const CODE_CHAR_TO_COLOR: Record<string, D2ColorCode> = {
|
|
'0': 'white',
|
|
'1': 'red',
|
|
'2': 'green',
|
|
'3': 'blue',
|
|
'4': 'gold',
|
|
'5': 'gray',
|
|
'6': 'black',
|
|
'7': 'tan',
|
|
'8': 'orange',
|
|
'9': 'yellow',
|
|
':': 'darkgreen',
|
|
';': 'purple',
|
|
}
|
|
|
|
export interface FontGlyphMetric {
|
|
readonly code: number
|
|
readonly width: number
|
|
readonly height: number
|
|
readonly x: number
|
|
readonly y: number
|
|
readonly frameW: number
|
|
readonly frameH: number
|
|
}
|
|
|
|
export interface FontAtlasMeta {
|
|
readonly name: string
|
|
readonly lineHeight: number
|
|
readonly capHeight: number
|
|
readonly atlasWidth: number
|
|
readonly atlasHeight: number
|
|
readonly glyphs: readonly FontGlyphMetric[]
|
|
}
|
|
|
|
export interface TextSpan {
|
|
readonly text: string
|
|
readonly color: D2ColorCode
|
|
}
|
|
|
|
/**
|
|
* Parse a `Woo!\x01` `.tbl` font metrics binary (`D2WinFont.cpp`).
|
|
*/
|
|
export function parseFontTblMetrics(tblBytes: Uint8Array): {
|
|
lineHeight: number
|
|
capHeight: number
|
|
widths: number[]
|
|
heights: number[]
|
|
frameIndices: number[]
|
|
} {
|
|
if (tblBytes.length < 12) {
|
|
throw new Error(`Font tbl too short: ${tblBytes.length} bytes`)
|
|
}
|
|
const magic = new TextDecoder().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 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)
|
|
}
|
|
return { lineHeight, capHeight, widths, heights, frameIndices }
|
|
}
|
|
|
|
/**
|
|
* Split a string containing Diablo II `ÿcX` color codes into colored spans.
|
|
*/
|
|
export function parseColoredText(input: string, defaultColor: D2ColorCode = 'white'): TextSpan[] {
|
|
const spans: TextSpan[] = []
|
|
let currentColor: D2ColorCode = defaultColor
|
|
let buf = ''
|
|
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
|
|
}
|
|
}
|
|
buf += ch
|
|
}
|
|
if (buf.length > 0 || spans.length === 0) {
|
|
spans.push({ text: buf, color: currentColor })
|
|
}
|
|
return spans
|
|
}
|
|
|
|
const FONT_PX_SIZE: Record<D2FontName, number> = {
|
|
font6: 10,
|
|
font8: 11,
|
|
fontexocet10: 13,
|
|
fontformal12: 14,
|
|
font16: 16,
|
|
font30: 26,
|
|
}
|
|
|
|
/**
|
|
* Runtime Diablo II Font Manager supporting both bitmap DC6 glyphs and CJK fallback.
|
|
*/
|
|
export class D2FontRenderer {
|
|
private metas: Record<string, FontAtlasMeta> = {}
|
|
private images = new Map<string, HTMLImageElement>()
|
|
private tintedCache = new Map<string, HTMLCanvasElement>()
|
|
loaded = false
|
|
|
|
async load(baseUrl = '/ui/fonts'): Promise<void> {
|
|
try {
|
|
const res = await fetch(`${baseUrl}/metrics.json`)
|
|
if (!res.ok) return
|
|
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
|
|
}
|
|
}
|
|
|
|
private getTintedAtlas(font: D2FontName, color: D2ColorCode): HTMLCanvasElement | HTMLImageElement | null {
|
|
const baseImg = this.images.get(font)
|
|
if (!baseImg) return null
|
|
if (color === 'white') return baseImg
|
|
const key = `${font}:${color}`
|
|
const cached = this.tintedCache.get(key)
|
|
if (cached) return cached
|
|
if (typeof document === 'undefined') return baseImg
|
|
const canvas = document.createElement('canvas')
|
|
canvas.width = baseImg.width
|
|
canvas.height = baseImg.height
|
|
const ctx = canvas.getContext('2d')
|
|
if (!ctx) return baseImg
|
|
ctx.drawImage(baseImg, 0, 0)
|
|
ctx.globalCompositeOperation = 'source-atop'
|
|
ctx.fillStyle = D2_COLOR_HEX[color]
|
|
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
|
ctx.globalCompositeOperation = 'source-over'
|
|
this.tintedCache.set(key, canvas)
|
|
return canvas
|
|
}
|
|
|
|
/**
|
|
* Measure pixel width of text (stripping `ÿcX` codes).
|
|
*/
|
|
measureText(text: string, font: D2FontName = 'font16'): number {
|
|
const spans = parseColoredText(text)
|
|
const meta = this.metas[font]
|
|
const pxSize = FONT_PX_SIZE[font]
|
|
let total = 0
|
|
for (const span of spans) {
|
|
for (let i = 0; i < span.text.length; i++) {
|
|
const code = span.text.charCodeAt(i)
|
|
if (code < 128 && meta?.glyphs[code]) {
|
|
total += Math.max(4, meta.glyphs[code]!.width)
|
|
} else {
|
|
total += code > 255 ? pxSize : Math.round(pxSize * 0.58)
|
|
}
|
|
}
|
|
}
|
|
return total
|
|
}
|
|
|
|
/**
|
|
* Draw text onto a CanvasRenderingContext2D using genuine DC6 glyphs for ASCII/Latin
|
|
* and sharp outlined gothic/serif typography for CJK characters.
|
|
*/
|
|
drawText(
|
|
ctx: CanvasRenderingContext2D,
|
|
text: string,
|
|
x: number,
|
|
y: number,
|
|
options: {
|
|
font?: D2FontName
|
|
color?: D2ColorCode
|
|
align?: 'left' | 'center' | 'right'
|
|
shadow?: boolean
|
|
} = {},
|
|
): void {
|
|
const font = options.font ?? 'font16'
|
|
const defaultColor = options.color ?? 'white'
|
|
const align = options.align ?? 'left'
|
|
const shadow = options.shadow ?? true
|
|
|
|
const totalWidth = this.measureText(text, font)
|
|
let curX = x
|
|
if (align === 'center') curX = Math.round(x - totalWidth / 2)
|
|
else if (align === 'right') curX = Math.round(x - totalWidth)
|
|
const baseY = Math.round(y)
|
|
|
|
const spans = parseColoredText(text, defaultColor)
|
|
const pxSize = FONT_PX_SIZE[font]
|
|
|
|
ctx.save()
|
|
const isTitleFont = font === 'font16' || font === 'font30' || font === 'fontexocet10'
|
|
ctx.font = `${isTitleFont ? 'bold ' : ''}${pxSize}px "Cinzel", "Georgia", "Songti SC", "Noto Serif SC", serif`
|
|
ctx.textBaseline = 'alphabetic'
|
|
|
|
let measuredTotal = 0
|
|
for (const s of spans) measuredTotal += ctx.measureText(s.text).width
|
|
if (align === 'center') curX = Math.round(x - measuredTotal / 2)
|
|
else if (align === 'right') curX = Math.round(x - measuredTotal)
|
|
|
|
for (const span of spans) {
|
|
if (shadow) {
|
|
ctx.fillStyle = '#000000'
|
|
ctx.fillText(span.text, curX + 1, baseY + 1)
|
|
ctx.fillText(span.text, curX - 1, baseY + 1)
|
|
ctx.fillText(span.text, curX + 1, baseY - 1)
|
|
}
|
|
ctx.fillStyle = D2_COLOR_HEX[span.color]
|
|
ctx.fillText(span.text, curX, baseY)
|
|
curX += ctx.measureText(span.text).width
|
|
}
|
|
ctx.restore()
|
|
}
|
|
}
|