feat(ui): integrate authentic 1.13c Exocet & Formal 436 DC6 bitmap & TrueType fonts (#504)

This commit is contained in:
troytt 2026-09-28 09:58:22 +00:00
parent 768a2cc920
commit 3ced5b08b9
22 changed files with 2131 additions and 352 deletions

View File

@ -5,34 +5,53 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>d2web · Diablo II v1.13c</title>
<style>
:root { color-scheme: dark; }
@font-face {
font-family: "D2Exocet";
src: url("/ui/fonts/D2Exocet.ttf") format("truetype");
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "D2Formal436";
src: url("/ui/fonts/D2Formal436.ttf") format("truetype");
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
:root {
color-scheme: dark;
--font-display: "D2Exocet", "Cinzel", "Times New Roman", "Liberation Serif", "Noto Serif", "Songti SC", "Noto Serif SC", serif;
--font-body: "D2Formal436", "Times New Roman", "Liberation Serif", "Noto Serif", "Songti SC", "Noto Serif SC", serif;
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; background: #0b0906; color: #e8dcc8;
font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow: hidden; }
font: 13px/1.5 var(--font-body); overflow: hidden; }
#view { position: fixed; inset: 0; width: 100%; height: 100%; display: block; background: #000; cursor: none; }
.overlay { position: fixed; z-index: 2; background: rgba(11, 9, 6, .82); border: 1px solid #33291f;
border-radius: 6px; backdrop-filter: blur(3px); }
header { top: 0; left: 0; right: 0; padding: 6px 12px; border-radius: 0; border-width: 0 0 1px 0;
display: flex; gap: 14px; align-items: baseline; flex-wrap: wrap; }
h1 { font-size: 13px; margin: 0; color: #c8a15a; letter-spacing: .06em; }
h1 { font-family: var(--font-display); font-size: 13px; margin: 0; color: #c8a15a; letter-spacing: .06em; }
a { color: #c8a15a; }
#hud { color: #9c8d78; }
#hud { font-family: var(--font-display); color: #9c8d78; }
#controls { top: 4px; right: 12px; padding: 3px 8px; display: flex; gap: 8px;
align-items: center; flex-wrap: wrap; max-width: 74vw; font-size: 12px; z-index: 4; }
#controls label { color: #9c8d78; }
align-items: center; flex-wrap: wrap; max-width: 74vw; font-family: var(--font-display); font-size: 12px; z-index: 4; }
#controls label { font-family: var(--font-display); color: #9c8d78; }
select { background: #1c1813; color: #e8dcc8; border: 1px solid #33291f;
border-radius: 4px; padding: 2px 5px; font: inherit; max-width: 22vw; }
border-radius: 4px; padding: 2px 5px; font: inherit; font-family: var(--font-body); max-width: 22vw; }
select.non-default, .non-default {
border: 1px solid #e74c3c !important;
color: #ff6b6b !important;
}
optgroup { background: #1c1813; color: #c8a15a; }
option { background: #1c1813; }
#status { left: 12px; top: 34px; padding: 3px 8px; max-width: 55vw; color: #9c8d78; font-size: 11px; opacity: 0.85; pointer-events: none; z-index: 3; }
optgroup { background: #1c1813; color: #c8a15a; font-family: var(--font-body); }
option { background: #1c1813; font-family: var(--font-body); }
#status { left: 12px; top: 34px; padding: 3px 8px; max-width: 55vw; color: #9c8d78; font-family: var(--font-body); font-size: 11px; opacity: 0.85; pointer-events: none; z-index: 3; }
#status:empty { display: none; }
#dialog { right: 12px; top: 42px; padding: 8px 12px; min-width: 240px; color: #e8dcc8; white-space: pre-wrap; font-size: 14px; background: rgba(30, 26, 21, .9); border-color: #c8a15a; z-index: 5; }
#dialog { right: 12px; top: 42px; padding: 8px 12px; min-width: 240px; color: #e8dcc8; white-space: pre-wrap; font-family: var(--font-body); font-size: 14px; background: rgba(30, 26, 21, .9); border-color: #c8a15a; z-index: 5; }
#labels, .ground-label { font-family: var(--font-display); }
#d2-hud-canvas { position: fixed; inset: 0; width: 100%; height: 100%; display: block; z-index: 6; pointer-events: none; image-rendering: pixelated; cursor: none; }
.hint { color: #6d5a34; }
.hint { font-family: var(--font-body); color: #6d5a34; }
.lang-btn {
display: inline-block;
padding: 2px 7px;
@ -41,6 +60,7 @@
border: 1px solid #5c4628;
border-radius: 4px;
text-decoration: none;
font-family: var(--font-display);
font-size: 11px;
cursor: pointer;
}

View File

@ -5,34 +5,53 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>d2web · 暗黑破坏神 2 v1.13c</title>
<style>
:root { color-scheme: dark; }
@font-face {
font-family: "D2Exocet";
src: url("/ui/fonts/D2Exocet.ttf") format("truetype");
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "D2Formal436";
src: url("/ui/fonts/D2Formal436.ttf") format("truetype");
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
:root {
color-scheme: dark;
--font-display: "D2Exocet", "Cinzel", "Times New Roman", "Liberation Serif", "Noto Serif", "Songti SC", "Noto Serif SC", serif;
--font-body: "D2Formal436", "Times New Roman", "Liberation Serif", "Noto Serif", "Songti SC", "Noto Serif SC", serif;
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; background: #0b0906; color: #e8dcc8;
font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow: hidden; }
font: 13px/1.5 var(--font-body); overflow: hidden; }
#view { position: fixed; inset: 0; width: 100%; height: 100%; display: block; background: #000; cursor: none; }
.overlay { position: fixed; z-index: 2; background: rgba(11, 9, 6, .82); border: 1px solid #33291f;
border-radius: 6px; backdrop-filter: blur(3px); }
header { top: 0; left: 0; right: 0; padding: 6px 12px; border-radius: 0; border-width: 0 0 1px 0;
display: flex; gap: 14px; align-items: baseline; flex-wrap: wrap; }
h1 { font-size: 13px; margin: 0; color: #c8a15a; letter-spacing: .06em; }
h1 { font-family: var(--font-display); font-size: 13px; margin: 0; color: #c8a15a; letter-spacing: .06em; }
a { color: #c8a15a; }
#hud { color: #9c8d78; }
#hud { font-family: var(--font-display); color: #9c8d78; }
#controls { top: 4px; right: 12px; padding: 3px 8px; display: flex; gap: 8px;
align-items: center; flex-wrap: wrap; max-width: 74vw; font-size: 12px; z-index: 4; }
#controls label { color: #9c8d78; }
align-items: center; flex-wrap: wrap; max-width: 74vw; font-family: var(--font-display); font-size: 12px; z-index: 4; }
#controls label { font-family: var(--font-display); color: #9c8d78; }
select { background: #1c1813; color: #e8dcc8; border: 1px solid #33291f;
border-radius: 4px; padding: 2px 5px; font: inherit; max-width: 22vw; }
border-radius: 4px; padding: 2px 5px; font: inherit; font-family: var(--font-body); max-width: 22vw; }
select.non-default, .non-default {
border: 1px solid #e74c3c !important;
color: #ff6b6b !important;
}
optgroup { background: #1c1813; color: #c8a15a; }
option { background: #1c1813; }
#status { left: 12px; top: 34px; padding: 3px 8px; max-width: 55vw; color: #9c8d78; font-size: 11px; opacity: 0.85; pointer-events: none; z-index: 3; }
optgroup { background: #1c1813; color: #c8a15a; font-family: var(--font-body); }
option { background: #1c1813; font-family: var(--font-body); }
#status { left: 12px; top: 34px; padding: 3px 8px; max-width: 55vw; color: #9c8d78; font-family: var(--font-body); font-size: 11px; opacity: 0.85; pointer-events: none; z-index: 3; }
#status:empty { display: none; }
#dialog { right: 12px; top: 42px; padding: 8px 12px; min-width: 240px; color: #e8dcc8; white-space: pre-wrap; font-size: 14px; background: rgba(30, 26, 21, .9); border-color: #c8a15a; z-index: 5; }
#dialog { right: 12px; top: 42px; padding: 8px 12px; min-width: 240px; color: #e8dcc8; white-space: pre-wrap; font-family: var(--font-body); font-size: 14px; background: rgba(30, 26, 21, .9); border-color: #c8a15a; z-index: 5; }
#labels, .ground-label { font-family: var(--font-display); }
#d2-hud-canvas { position: fixed; inset: 0; width: 100%; height: 100%; display: block; z-index: 6; pointer-events: none; image-rendering: pixelated; cursor: none; }
.hint { color: #6d5a34; }
.hint { font-family: var(--font-body); color: #6d5a34; }
.lang-btn {
display: inline-block;
padding: 2px 7px;
@ -41,6 +60,7 @@
border: 1px solid #5c4628;
border-radius: 4px;
text-decoration: none;
font-family: var(--font-display);
font-size: 11px;
cursor: pointer;
}

View File

@ -5,34 +5,53 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>d2web · 暗黑破坏神 2 v1.13c</title>
<style>
:root { color-scheme: dark; }
@font-face {
font-family: "D2Exocet";
src: url("/ui/fonts/D2Exocet.ttf") format("truetype");
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "D2Formal436";
src: url("/ui/fonts/D2Formal436.ttf") format("truetype");
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
:root {
color-scheme: dark;
--font-display: "D2Exocet", "Cinzel", "Times New Roman", "Liberation Serif", "Noto Serif", "Songti SC", "Noto Serif SC", serif;
--font-body: "D2Formal436", "Times New Roman", "Liberation Serif", "Noto Serif", "Songti SC", "Noto Serif SC", serif;
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; background: #0b0906; color: #e8dcc8;
font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow: hidden; }
font: 13px/1.5 var(--font-body); overflow: hidden; }
#view { position: fixed; inset: 0; width: 100%; height: 100%; display: block; background: #000; cursor: none; }
.overlay { position: fixed; z-index: 2; background: rgba(11, 9, 6, .82); border: 1px solid #33291f;
border-radius: 6px; backdrop-filter: blur(3px); }
header { top: 0; left: 0; right: 0; padding: 6px 12px; border-radius: 0; border-width: 0 0 1px 0;
display: flex; gap: 14px; align-items: baseline; flex-wrap: wrap; }
h1 { font-size: 13px; margin: 0; color: #c8a15a; letter-spacing: .06em; }
h1 { font-family: var(--font-display); font-size: 13px; margin: 0; color: #c8a15a; letter-spacing: .06em; }
a { color: #c8a15a; }
#hud { color: #9c8d78; }
#hud { font-family: var(--font-display); color: #9c8d78; }
#controls { top: 4px; right: 12px; padding: 3px 8px; display: flex; gap: 8px;
align-items: center; flex-wrap: wrap; max-width: 74vw; font-size: 12px; z-index: 4; }
#controls label { color: #9c8d78; }
align-items: center; flex-wrap: wrap; max-width: 74vw; font-family: var(--font-display); font-size: 12px; z-index: 4; }
#controls label { font-family: var(--font-display); color: #9c8d78; }
select { background: #1c1813; color: #e8dcc8; border: 1px solid #33291f;
border-radius: 4px; padding: 2px 5px; font: inherit; max-width: 22vw; }
border-radius: 4px; padding: 2px 5px; font: inherit; font-family: var(--font-body); max-width: 22vw; }
select.non-default, .non-default {
border: 1px solid #e74c3c !important;
color: #ff6b6b !important;
}
optgroup { background: #1c1813; color: #c8a15a; }
option { background: #1c1813; }
#status { left: 12px; top: 34px; padding: 3px 8px; max-width: 55vw; color: #9c8d78; font-size: 11px; opacity: 0.85; pointer-events: none; z-index: 3; }
optgroup { background: #1c1813; color: #c8a15a; font-family: var(--font-body); }
option { background: #1c1813; font-family: var(--font-body); }
#status { left: 12px; top: 34px; padding: 3px 8px; max-width: 55vw; color: #9c8d78; font-family: var(--font-body); font-size: 11px; opacity: 0.85; pointer-events: none; z-index: 3; }
#status:empty { display: none; }
#dialog { right: 12px; top: 42px; padding: 8px 12px; min-width: 240px; color: #e8dcc8; white-space: pre-wrap; font-size: 14px; background: rgba(30, 26, 21, .9); border-color: #c8a15a; z-index: 5; }
#dialog { right: 12px; top: 42px; padding: 8px 12px; min-width: 240px; color: #e8dcc8; white-space: pre-wrap; font-family: var(--font-body); font-size: 14px; background: rgba(30, 26, 21, .9); border-color: #c8a15a; z-index: 5; }
#labels, .ground-label { font-family: var(--font-display); }
#d2-hud-canvas { position: fixed; inset: 0; width: 100%; height: 100%; display: block; z-index: 6; pointer-events: none; image-rendering: pixelated; cursor: none; }
.hint { color: #6d5a34; }
.hint { font-family: var(--font-body); color: #6d5a34; }
.lang-btn {
display: inline-block;
padding: 2px 7px;
@ -41,6 +60,7 @@
border: 1px solid #5c4628;
border-radius: 4px;
text-decoration: none;
font-family: var(--font-display);
font-size: 11px;
cursor: pointer;
}

View File

@ -5,8 +5,24 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Diablo II Web · 暗黑破坏神 II 网页版 (v1.13c)</title>
<style>
@font-face {
font-family: "D2Exocet";
src: url("/ui/fonts/D2Exocet.ttf") format("truetype");
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "D2Formal436";
src: url("/ui/fonts/D2Formal436.ttf") format("truetype");
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
:root {
color-scheme: dark;
--font-display: "D2Exocet", "Cinzel", "Times New Roman", "Liberation Serif", "Noto Serif", "Songti SC", "Noto Serif SC", serif;
--font-body: "D2Formal436", "Times New Roman", "Liberation Serif", "Noto Serif", "Songti SC", "Noto Serif SC", serif;
}
* {
box-sizing: border-box;
@ -18,7 +34,7 @@
background:
radial-gradient(circle at 50% 24%, rgba(168, 92, 24, 0.22) 0%, rgba(18, 12, 8, 0.92) 56%, #070503 100%);
color: #e8dcc8;
font-family: 'Exocet', 'Cinzel', 'Georgia', 'Songti SC', 'SimSun', serif;
font-family: var(--font-display);
display: flex;
align-items: center;
justify-content: center;
@ -49,6 +65,7 @@
border: 1px solid #6e5430;
background: rgba(34, 24, 14, 0.85);
color: #c8a15a;
font-family: var(--font-display);
font-size: 12px;
letter-spacing: 0.18em;
text-transform: uppercase;
@ -56,6 +73,7 @@
}
h1 {
margin: 0 0 8px;
font-family: var(--font-display);
font-size: clamp(26px, 4vw, 38px);
color: #d8b46a;
letter-spacing: 0.08em;
@ -64,6 +82,7 @@
.subtitle {
margin: 0 0 28px;
color: #a7967d;
font-family: var(--font-body);
font-size: 15px;
letter-spacing: 0.05em;
}
@ -98,6 +117,7 @@
outline: none;
}
.lang-badge {
font-family: var(--font-display);
font-size: 11px;
letter-spacing: 0.16em;
color: #9c8458;
@ -105,6 +125,7 @@
margin-bottom: 8px;
}
.lang-title {
font-family: var(--font-display);
font-size: 22px;
font-weight: 700;
color: #f0d596;
@ -112,6 +133,7 @@
letter-spacing: 0.04em;
}
.lang-desc {
font-family: var(--font-body);
font-size: 13px;
color: #9c8d78;
line-height: 1.5;
@ -122,10 +144,12 @@
background: rgba(200, 161, 90, 0.14);
border: 1px solid #8c6e3e;
color: #e8c478;
font-family: var(--font-display);
font-size: 12px;
letter-spacing: 0.08em;
}
.footer-meta {
font-family: var(--font-body);
font-size: 12px;
color: #6d5e4b;
letter-spacing: 0.04em;

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because it is too large Load Diff

661
scripts/build-d2-ttf.ts Normal file
View File

@ -0,0 +1,661 @@
/**
* Authentic Diablo II v1.13c TrueType (`.ttf`) WebFont Builder.
*
* Vectorizes the 256-glyph DC6 bitmap font tables from `d2data.mpq`
* (`data\local\font\latin\font42.dc6` + `font42.tbl` for `D2Exocet`, and
* `data\local\font\latin\fontformal12.dc6` + `fontformal12.tbl` for `D2Formal436`)
* into standards-compliant TrueType (`sfnt` 0x00010000) font files with zero
* external network dependencies.
*/
import type { SpriteFrame } from '../src/formats/sprite.ts'
interface Point {
readonly x: number
readonly y: number
}
/**
* Trace closed polygon contours (clockwise outer boundaries, counter-clockwise holes
* in +X right, +Y up coordinates) from a binary foreground mask `mask[y * width + x]`.
*/
export function traceBitmapContours(
mask: Uint8Array,
width: number,
height: number,
baselineY: number,
scale: number,
): Point[][] {
interface Edge {
x0: number
y0: number
x1: number
y1: number
dir: number // 0=up, 1=right, 2=down, 3=left
used: boolean
}
const edgesByStart = new Map<string, Edge[]>()
const allEdges: Edge[] = []
const addEdge = (x0: number, y0: number, x1: number, y1: number, dir: number): void => {
const e: Edge = { x0, y0, x1, y1, dir, used: false }
allEdges.push(e)
const k = `${x0},${y0}`
let list = edgesByStart.get(k)
if (!list) {
list = []
edgesByStart.set(k, list)
}
list.push(e)
}
const get = (x: number, y: number): number => {
if (x < 0 || x >= width || y < 0 || y >= height) return 0
return mask[y * width + x] ?? 0
}
for (let y = 0; y < height; y++) {
const yUp = baselineY - y
for (let x = 0; x < width; x++) {
if (!get(x, y)) continue
if (!get(x - 1, y)) addEdge(x, yUp - 1, x, yUp, 0)
if (!get(x, y - 1)) addEdge(x, yUp, x + 1, yUp, 1)
if (!get(x + 1, y)) addEdge(x + 1, yUp, x + 1, yUp - 1, 2)
if (!get(x, y + 1)) addEdge(x + 1, yUp - 1, x, yUp - 1, 3)
}
}
const contours: Point[][] = []
for (const startEdge of allEdges) {
if (startEdge.used) continue
const rawPts: { x: number; y: number; dir: number }[] = []
let cur: Edge | undefined = startEdge
while (cur && !cur.used) {
cur.used = true
rawPts.push({ x: cur.x0, y: cur.y0, dir: cur.dir })
const candidates = edgesByStart.get(`${cur.x1},${cur.y1}`)
if (!candidates) break
let next: Edge | undefined
const prefDirs = [(cur.dir + 1) % 4, cur.dir, (cur.dir + 3) % 4]
for (const pd of prefDirs) {
next = candidates.find(c => !c.used && c.dir === pd)
if (next) break
}
if (!next) {
next = candidates.find(c => !c.used)
}
cur = next
}
if (rawPts.length < 4) continue
const simplified: Point[] = []
for (let i = 0; i < rawPts.length; i++) {
const prev = rawPts[(i + rawPts.length - 1) % rawPts.length]!
const curr = rawPts[i]!
if (prev.dir !== curr.dir) {
simplified.push({
x: Math.round(curr.x * scale),
y: Math.round(curr.y * scale),
})
}
}
if (simplified.length >= 3) {
contours.push(simplified)
}
}
return contours
}
export interface Dc6FrameLike {
readonly width: number
readonly height: number
readonly indices: Uint8Array
}
export interface BuildTrueTypeFontOptions {
readonly familyName: string
readonly postScriptName: string
readonly frames: readonly Dc6FrameLike[]
readonly tblBytes: Uint8Array
readonly baselineY: number
readonly scale: number
readonly isForeground: (paletteIdx: number) => boolean
}
/**
* Build a complete TrueType (`.ttf`) binary buffer from a 256-glyph DC6 sheet and `.tbl` metrics.
*/
export function buildTrueTypeFont(options: BuildTrueTypeFontOptions): Uint8Array {
const { familyName, postScriptName, frames, tblBytes, baselineY, scale, isForeground } = options
interface BuiltGlyph {
contours: Point[][]
advanceWidth: number
xMin: number
yMin: number
xMax: number
yMax: number
}
const glyphs: BuiltGlyph[] = []
const unicodeToGlyph = new Map<number, number>()
// Glyph 0: .notdef
glyphs.push({
contours: [
[
{ x: 40, y: 0 },
{ x: 40, y: 600 },
{ x: 440, y: 600 },
{ x: 440, y: 0 },
],
[
{ x: 100, y: 60 },
{ x: 380, y: 60 },
{ x: 380, y: 540 },
{ x: 100, y: 540 },
],
],
advanceWidth: 500,
xMin: 40,
yMin: 0,
xMax: 440,
yMax: 600,
})
const win1252Map: Record<number, number> = {
0x80: 0x20ac,
0x82: 0x201a,
0x83: 0x0192,
0x84: 0x201e,
0x85: 0x2026,
0x86: 0x2020,
0x87: 0x2021,
0x88: 0x02c6,
0x89: 0x2030,
0x8a: 0x0160,
0x8b: 0x2039,
0x8c: 0x0152,
0x8e: 0x017d,
0x91: 0x2018,
0x92: 0x2019,
0x93: 0x201c,
0x94: 0x201d,
0x95: 0x2022,
0x96: 0x2013,
0x97: 0x2014,
0x98: 0x02dc,
0x99: 0x2122,
0x9a: 0x0161,
0x9b: 0x203a,
0x9c: 0x0153,
0x9e: 0x017e,
0x9f: 0x0178,
}
const qRecOff = 12 + 0x3f * 14
const qFrameIdx = qRecOff + 8 < tblBytes.length ? (tblBytes[qRecOff + 8] ?? 0x3f) : 0x3f
const placeholderCandidates = [
frames[qFrameIdx] ?? frames[0x3f],
frames[0x7f],
frames[0x81],
frames[0x8d],
frames[0x8f],
frames[0x90],
frames[0x9d],
].filter((f): f is Dc6FrameLike => Boolean(f && f.width > 0 && f.height > 0))
const isSameFrame = (a: Dc6FrameLike | undefined, b: Dc6FrameLike | undefined): boolean => {
if (!a || !b || a.width === 0 || b.width === 0) return false
if (a.width !== b.width || a.height !== b.height) return false
for (let i = 0; i < a.indices.length; i++) {
if (isForeground(a.indices[i] ?? 0) !== isForeground(b.indices[i] ?? 0)) return false
}
return true
}
const isPlaceholderQuestionMark = (f: Dc6FrameLike | undefined): boolean =>
placeholderCandidates.some(cand => isSameFrame(f, cand))
const dotRecOff = 12 + 0x2e * 14
const dotMetricW = dotRecOff + 3 < tblBytes.length ? (tblBytes[dotRecOff + 3] || 6) : 6
const dotFrameIdx = dotRecOff + 8 < tblBytes.length ? (tblBytes[dotRecOff + 8] ?? 0x2e) : 0x2e
const dotFrame = frames[dotFrameIdx] ?? frames[0x2e]
const dashRecOff = 12 + 0x2d * 14
const dashMetricW = dashRecOff + 3 < tblBytes.length ? (tblBytes[dashRecOff + 3] || 8) : 8
const dashFrameIdx = dashRecOff + 8 < tblBytes.length ? (tblBytes[dashRecOff + 8] ?? 0x2d) : 0x2d
const dashFrame = frames[dashFrameIdx] ?? frames[0x2d]
for (let code = 0x20; code <= 0xff; code++) {
const recOff = 12 + code * 14
let metricWidth = recOff + 3 < tblBytes.length ? (tblBytes[recOff + 3] || 8) : 8
const frameIdx = recOff + 8 < tblBytes.length ? (tblBytes[recOff + 8] ?? code) : code
let frame = frames[frameIdx] ?? frames[code]
let glyphBaselineY = baselineY
let yShiftUnits = 0
if ((code === 0xb7 || code === 0x95) && dotFrame && dotFrame.width > 0 && dotFrame.height > 0) {
frame = dotFrame
metricWidth = dotMetricW
yShiftUnits = Math.round(baselineY * 0.35 * scale)
} else if (code !== 0x3f && code !== 0x20 && code !== 0xa0 && isPlaceholderQuestionMark(frame)) {
if ((code === 0x96 || code === 0x97) && dashFrame && dashFrame.width > 0 && dashFrame.height > 0) {
frame = dashFrame
metricWidth = dashMetricW
} else {
continue
}
}
let contours: Point[][] = []
if (code !== 0x20 && code !== 0xa0 && frame && frame.width > 0 && frame.height > 0) {
const mask = new Uint8Array(frame.width * frame.height)
for (let i = 0; i < mask.length; i++) {
mask[i] = isForeground(frame.indices[i] ?? 0) ? 1 : 0
}
contours = traceBitmapContours(mask, frame.width, frame.height, glyphBaselineY, scale)
if (yShiftUnits !== 0) {
contours = contours.map(c => c.map(p => ({ x: p.x, y: p.y + yShiftUnits })))
}
}
let xMin = 0
let yMin = 0
let xMax = 0
let yMax = 0
if (contours.length > 0) {
xMin = Infinity
yMin = Infinity
xMax = -Infinity
yMax = -Infinity
for (const c of contours) {
for (const p of c) {
if (p.x < xMin) xMin = p.x
if (p.y < yMin) yMin = p.y
if (p.x > xMax) xMax = p.x
if (p.y > yMax) yMax = p.y
}
}
}
const advanceWidth = Math.max(
Math.round(metricWidth * scale),
contours.length > 0 ? xMax + Math.round(scale) : Math.round(6 * scale),
)
const gIdx = glyphs.length
glyphs.push({
contours,
advanceWidth,
xMin,
yMin,
xMax,
yMax,
})
if (code < 0x7f || code >= 0xa0) {
unicodeToGlyph.set(code, gIdx)
} else if (win1252Map[code]) {
unicodeToGlyph.set(win1252Map[code]!, gIdx)
}
}
const glyfChunks: Uint8Array[] = []
const locaOffsets: number[] = []
let currentGlyfOffset = 0
let fontXMin = Infinity
let fontYMin = Infinity
let fontXMax = -Infinity
let fontYMax = -Infinity
let maxPoints = 0
let maxContours = 0
let advanceWidthMax = 0
let minLsb = Infinity
let minRsb = Infinity
let xMaxExtent = -Infinity
for (const g of glyphs) {
locaOffsets.push(currentGlyfOffset)
if (g.advanceWidth > advanceWidthMax) advanceWidthMax = g.advanceWidth
if (g.contours.length === 0) {
minLsb = Math.min(minLsb, 0)
minRsb = Math.min(minRsb, g.advanceWidth)
xMaxExtent = Math.max(xMaxExtent, 0)
continue
}
if (g.xMin < fontXMin) fontXMin = g.xMin
if (g.yMin < fontYMin) fontYMin = g.yMin
if (g.xMax > fontXMax) fontXMax = g.xMax
if (g.yMax > fontYMax) fontYMax = g.yMax
if (g.xMin < minLsb) minLsb = g.xMin
const rsb = g.advanceWidth - g.xMax
if (rsb < minRsb) minRsb = rsb
if (g.xMax > xMaxExtent) xMaxExtent = g.xMax
const numContours = g.contours.length
if (numContours > maxContours) maxContours = numContours
const allPts: Point[] = []
const endPts: number[] = []
for (const c of g.contours) {
for (const p of c) allPts.push(p)
endPts.push(allPts.length - 1)
}
if (allPts.length > maxPoints) maxPoints = allPts.length
const rawLen = 10 + numContours * 2 + 2 + allPts.length * 5
const paddedLen = (rawLen + 3) & ~3
const buf = new Uint8Array(paddedLen)
const dv = new DataView(buf.buffer)
dv.setInt16(0, numContours, false)
dv.setInt16(2, g.xMin, false)
dv.setInt16(4, g.yMin, false)
dv.setInt16(6, g.xMax, false)
dv.setInt16(8, g.yMax, false)
let off = 10
for (const ep of endPts) {
dv.setUint16(off, ep, false)
off += 2
}
dv.setUint16(off, 0, false) // instructionLength = 0
off += 2
for (let i = 0; i < allPts.length; i++) {
buf[off++] = 0x01
}
let prevX = 0
for (const pt of allPts) {
dv.setInt16(off, pt.x - prevX, false)
prevX = pt.x
off += 2
}
let prevY = 0
for (const pt of allPts) {
dv.setInt16(off, pt.y - prevY, false)
prevY = pt.y
off += 2
}
glyfChunks.push(buf)
currentGlyfOffset += paddedLen
}
locaOffsets.push(currentGlyfOffset)
const glyfTable = new Uint8Array(currentGlyfOffset)
let gPos = 0
for (const chunk of glyfChunks) {
glyfTable.set(chunk, gPos)
gPos += chunk.length
}
const locaTable = new Uint8Array(locaOffsets.length * 4)
const locaDv = new DataView(locaTable.buffer)
locaOffsets.forEach((o, idx) => locaDv.setUint32(idx * 4, o, false))
const headTable = new Uint8Array(54)
const headDv = new DataView(headTable.buffer)
headDv.setUint32(0, 0x00010000, false)
headDv.setUint32(4, 0x00010000, false)
headDv.setUint32(8, 0, false)
headDv.setUint32(12, 0x5f0f3cf5, false)
headDv.setUint16(16, 0x000b, false)
headDv.setUint16(18, 1024, false)
headDv.setInt16(36, fontXMin, false)
headDv.setInt16(38, fontYMin, false)
headDv.setInt16(40, fontXMax, false)
headDv.setInt16(42, fontYMax, false)
headDv.setUint16(44, 0, false)
headDv.setUint16(46, 8, false)
headDv.setInt16(48, 2, false)
headDv.setInt16(50, 1, false)
headDv.setInt16(52, 0, false)
const ascender = Math.max(832, fontYMax)
const descender = Math.min(-192, fontYMin)
const hheaTable = new Uint8Array(36)
const hheaDv = new DataView(hheaTable.buffer)
hheaDv.setUint32(0, 0x00010000, false)
hheaDv.setInt16(4, ascender, false)
hheaDv.setInt16(6, descender, false)
hheaDv.setInt16(8, 0, false)
hheaDv.setUint16(10, advanceWidthMax, false)
hheaDv.setInt16(12, minLsb, false)
hheaDv.setInt16(14, minRsb, false)
hheaDv.setInt16(16, xMaxExtent, false)
hheaDv.setInt16(18, 1, false)
hheaDv.setInt16(20, 0, false)
hheaDv.setInt16(22, 0, false)
hheaDv.setInt16(32, 0, false)
hheaDv.setUint16(34, glyphs.length, false)
const maxpTable = new Uint8Array(32)
const maxpDv = new DataView(maxpTable.buffer)
maxpDv.setUint32(0, 0x00010000, false)
maxpDv.setUint16(4, glyphs.length, false)
maxpDv.setUint16(6, maxPoints, false)
maxpDv.setUint16(8, maxContours, false)
maxpDv.setUint16(10, 0, false)
maxpDv.setUint16(12, 0, false)
maxpDv.setUint16(14, 2, false)
const hmtxTable = new Uint8Array(glyphs.length * 4)
const hmtxDv = new DataView(hmtxTable.buffer)
glyphs.forEach((g, idx) => {
hmtxDv.setUint16(idx * 4, g.advanceWidth, false)
hmtxDv.setInt16(idx * 4 + 2, g.contours.length > 0 ? g.xMin : 0, false)
})
const os2Table = new Uint8Array(96)
const os2Dv = new DataView(os2Table.buffer)
const avgWidth = Math.round(
glyphs.reduce((acc, g) => acc + g.advanceWidth, 0) / Math.max(1, glyphs.length),
)
os2Dv.setUint16(0, 4, false)
os2Dv.setInt16(2, avgWidth, false)
os2Dv.setUint16(4, 400, false)
os2Dv.setUint16(6, 5, false)
os2Dv.setUint16(8, 0, false)
os2Dv.setInt16(10, 650, false)
os2Dv.setInt16(12, 600, false)
os2Dv.setInt16(14, 0, false)
os2Dv.setInt16(16, 75, false)
os2Dv.setInt16(18, 650, false)
os2Dv.setInt16(20, 600, false)
os2Dv.setInt16(22, 0, false)
os2Dv.setInt16(24, 350, false)
os2Dv.setInt16(26, 50, false)
os2Dv.setInt16(28, 300, false)
os2Dv.setInt16(30, 0, false)
os2Table.set([2, 0, 5, 3, 0, 0, 0, 0, 0, 0], 32)
os2Dv.setUint32(42, 0x00000003, false)
os2Table.set([0x42, 0x4c, 0x49, 0x5a], 58)
os2Dv.setUint16(62, 0x0040, false)
const sortedCodes = [...unicodeToGlyph.keys()].sort((a, b) => a - b)
os2Dv.setUint16(64, sortedCodes[0] ?? 0x0020, false)
os2Dv.setUint16(66, sortedCodes[sortedCodes.length - 1] ?? 0x00ff, false)
os2Dv.setInt16(68, ascender, false)
os2Dv.setInt16(70, descender, false)
os2Dv.setInt16(72, 0, false)
os2Dv.setUint16(74, ascender, false)
os2Dv.setUint16(76, Math.abs(descender), false)
os2Dv.setUint32(78, 0x00000001, false)
os2Dv.setInt16(86, 512, false)
os2Dv.setInt16(88, 704, false)
os2Dv.setUint16(90, 0, false)
os2Dv.setUint16(92, 0x0020, false)
os2Dv.setUint16(94, 1, false)
interface Seg {
start: number
end: number
delta: number
}
const segs: Seg[] = []
for (const code of sortedCodes) {
const gIdx = unicodeToGlyph.get(code)!
const delta = (gIdx - code) & 0xffff
const prev = segs[segs.length - 1]
if (prev && prev.end + 1 === code && prev.delta === delta) {
prev.end = code
} else {
segs.push({ start: code, end: code, delta })
}
}
segs.push({ start: 0xffff, end: 0xffff, delta: 1 })
const segCount = segs.length
const segCountX2 = segCount * 2
const maxPow2 = 1 << Math.floor(Math.log2(segCount))
const searchRangeCmap = maxPow2 * 2
const entrySelectorCmap = Math.floor(Math.log2(segCount))
const rangeShiftCmap = segCountX2 - searchRangeCmap
const subtableLen = 16 + segCount * 8
const cmapTable = new Uint8Array(4 + 8 * 2 + subtableLen)
const cmapDv = new DataView(cmapTable.buffer)
cmapDv.setUint16(0, 0, false)
cmapDv.setUint16(2, 2, false)
cmapDv.setUint16(4, 0, false)
cmapDv.setUint16(6, 3, false)
cmapDv.setUint32(8, 20, false)
cmapDv.setUint16(12, 3, false)
cmapDv.setUint16(14, 1, false)
cmapDv.setUint32(16, 20, false)
let cOff = 20
cmapDv.setUint16(cOff, 4, false)
cmapDv.setUint16(cOff + 2, subtableLen, false)
cmapDv.setUint16(cOff + 4, 0, false)
cmapDv.setUint16(cOff + 6, segCountX2, false)
cmapDv.setUint16(cOff + 8, searchRangeCmap, false)
cmapDv.setUint16(cOff + 10, entrySelectorCmap, false)
cmapDv.setUint16(cOff + 12, rangeShiftCmap, false)
cOff += 14
for (const s of segs) {
cmapDv.setUint16(cOff, s.end, false)
cOff += 2
}
cmapDv.setUint16(cOff, 0, false)
cOff += 2
for (const s of segs) {
cmapDv.setUint16(cOff, s.start, false)
cOff += 2
}
for (const s of segs) {
cmapDv.setUint16(cOff, s.delta, false)
cOff += 2
}
for (let i = 0; i < segs.length; i++) {
cmapDv.setUint16(cOff, 0, false)
cOff += 2
}
const nameRecords: { nameId: number; str: string }[] = [
{ nameId: 1, str: familyName },
{ nameId: 2, str: 'Regular' },
{ nameId: 3, str: `${familyName}-1.13c` },
{ nameId: 4, str: familyName },
{ nameId: 5, str: 'Version 1.13c' },
{ nameId: 6, str: postScriptName },
]
const encodedStrings: Uint8Array[] = nameRecords.map(r => {
const b = new Uint8Array(r.str.length * 2)
const dv = new DataView(b.buffer)
for (let i = 0; i < r.str.length; i++) {
dv.setUint16(i * 2, r.str.charCodeAt(i), false)
}
return b
})
const storageOffset = 6 + nameRecords.length * 12
const totalStrBytes = encodedStrings.reduce((a, b) => a + b.length, 0)
const nameTable = new Uint8Array(storageOffset + totalStrBytes)
const nameDv = new DataView(nameTable.buffer)
nameDv.setUint16(0, 0, false)
nameDv.setUint16(2, nameRecords.length, false)
nameDv.setUint16(4, storageOffset, false)
let strCur = 0
nameRecords.forEach((rec, idx) => {
const strBytes = encodedStrings[idx]!
const rOff = 6 + idx * 12
nameDv.setUint16(rOff, 3, false)
nameDv.setUint16(rOff + 2, 1, false)
nameDv.setUint16(rOff + 4, 0x0409, false)
nameDv.setUint16(rOff + 6, rec.nameId, false)
nameDv.setUint16(rOff + 8, strBytes.length, false)
nameDv.setUint16(rOff + 10, strCur, false)
nameTable.set(strBytes, storageOffset + strCur)
strCur += strBytes.length
})
const postTable = new Uint8Array(32)
const postDv = new DataView(postTable.buffer)
postDv.setUint32(0, 0x00030000, false)
postDv.setInt32(4, 0, false)
postDv.setInt16(8, -100, false)
postDv.setInt16(10, 50, false)
postDv.setUint32(12, 0, false)
const tables: { tag: string; data: Uint8Array }[] = [
{ tag: 'OS/2', data: os2Table },
{ tag: 'cmap', data: cmapTable },
{ tag: 'glyf', data: glyfTable },
{ tag: 'head', data: headTable },
{ tag: 'hhea', data: hheaTable },
{ tag: 'hmtx', data: hmtxTable },
{ tag: 'loca', data: locaTable },
{ tag: 'maxp', data: maxpTable },
{ tag: 'name', data: nameTable },
{ tag: 'post', data: postTable },
].sort((a, b) => (a.tag < b.tag ? -1 : a.tag > b.tag ? 1 : 0))
const calcChecksum = (bytes: Uint8Array): number => {
const paddedLen = (bytes.length + 3) & ~3
const padded = new Uint8Array(paddedLen)
padded.set(bytes)
const dv = new DataView(padded.buffer)
let sum = 0
for (let i = 0; i < paddedLen; i += 4) {
sum = (sum + dv.getUint32(i, false)) >>> 0
}
return sum
}
const numTables = tables.length
const dirSize = 12 + numTables * 16
let totalSize = dirSize
const offsets: number[] = []
for (const t of tables) {
offsets.push(totalSize)
totalSize += (t.data.length + 3) & ~3
}
const out = new Uint8Array(totalSize)
const outDv = new DataView(out.buffer)
outDv.setUint32(0, 0x00010000, false)
outDv.setUint16(4, numTables, false)
outDv.setUint16(6, 128, false)
outDv.setUint16(8, 3, false)
outDv.setUint16(10, 32, false)
let headOffsetInFile = 0
tables.forEach((t, idx) => {
const entryOff = 12 + idx * 16
for (let c = 0; c < 4; c++) {
out[entryOff + c] = t.tag.charCodeAt(c)
}
const chksum = calcChecksum(t.data)
const fileOff = offsets[idx]!
if (t.tag === 'head') headOffsetInFile = fileOff
outDv.setUint32(entryOff + 4, chksum, false)
outDv.setUint32(entryOff + 8, fileOff, false)
outDv.setUint32(entryOff + 12, t.data.length, false)
out.set(t.data, fileOff)
})
const fileSum = calcChecksum(out)
const checkSumAdjustment = (0xb1b0afba - fileSum) >>> 0
outDv.setUint32(headOffsetInFile + 8, checkSumAdjustment, false)
return out
}

View File

@ -6,7 +6,7 @@
* bitmap fonts, and inventory item graphics into `public/ui/`.
*/
import { mkdirSync, writeFileSync } from 'node:fs'
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { MpqArchive } from '../src/mpq/archive.ts'
import { fileSource } from '../src/mpq/file-source.ts'
@ -16,6 +16,7 @@ import type { SpriteFrame, SpriteSheet } from '../src/formats/sprite.ts'
import { decodePl2 } from '../src/formats/pl2.ts'
import { parseTable } from '../src/game/tables.ts'
import { encodeIndexedPng } from './png.ts'
import { buildTrueTypeFont } from './build-d2-ttf.ts'
interface BlitPlacement {
readonly frame: SpriteFrame
@ -133,12 +134,57 @@ function buildFontAtlas(name: string, dc6: SpriteSheet, tblBytes: Uint8Array): {
const placements: BlitPlacement[] = []
const glyphs: FontGlyphMetric[] = []
const qRecOff = 12 + 0x3f * 14
const qFrameIdx = qRecOff + 8 < tblBytes.length ? (tblBytes[qRecOff + 8] ?? 0x3f) : 0x3f
const placeholderCandidates = [
frames[qFrameIdx] ?? frames[0x3f],
frames[0x7f],
frames[0x81],
frames[0x8d],
frames[0x8f],
frames[0x90],
frames[0x9d],
].filter((f): f is SpriteFrame => Boolean(f && f.width > 0 && f.height > 0))
const isSameFrame = (a: SpriteFrame | undefined, b: SpriteFrame | undefined): boolean => {
if (!a || !b || a.width === 0 || b.width === 0) return false
if (a.width !== b.width || a.height !== b.height) return false
for (let k = 0; k < a.indices.length; k++) {
if (a.indices[k] !== b.indices[k]) return false
}
return true
}
const isPlaceholderQuestionMark = (f: SpriteFrame | undefined): boolean =>
placeholderCandidates.some(cand => isSameFrame(f, cand))
const dotRecOff = 12 + 0x2e * 14
const dotMetricW = dotRecOff + 3 < tblBytes.length ? (tblBytes[dotRecOff + 3] || 6) : 6
const dotFrameIdx = dotRecOff + 8 < tblBytes.length ? (tblBytes[dotRecOff + 8] ?? 0x2e) : 0x2e
const dotFrame = frames[dotFrameIdx] ?? frames[0x2e]
for (let i = 0; i < 256; i++) {
const recOff = 12 + i * 14
const metricWidth = recOff + 3 < tblBytes.length ? (tblBytes[recOff + 3] || 6) : 6
let metricWidth = recOff + 3 < tblBytes.length ? (tblBytes[recOff + 3] || 6) : 6
const metricHeight = recOff + 4 < tblBytes.length ? (tblBytes[recOff + 4] || lineHeight) : lineHeight
const frameIdx = recOff + 8 < tblBytes.length ? (tblBytes[recOff + 8] ?? i) : i
const frame = frames[frameIdx] ?? frames[i]
let frame: SpriteFrame | undefined = frames[frameIdx] ?? frames[i]
if (i === 0xb7 && dotFrame) {
const shiftY = Math.max(1, Math.round(dotFrame.height * 0.35))
const shiftedIndices = new Uint8Array(dotFrame.width * dotFrame.height)
for (let y = 0; y < dotFrame.height; y++) {
const srcY = y + shiftY
if (srcY < dotFrame.height) {
for (let x = 0; x < dotFrame.width; x++) {
shiftedIndices[y * dotFrame.width + x] = dotFrame.indices[srcY * dotFrame.width + x]!
}
}
}
frame = { ...dotFrame, indices: shiftedIndices }
metricWidth = dotMetricW
} else if (i !== 0x3f && i >= 0x7f && isPlaceholderQuestionMark(frame)) {
frame = undefined
}
const gx = (i % 16) * cellW
const gy = Math.floor(i / 16) * cellH
if (frame) {
@ -155,6 +201,16 @@ function buildFontAtlas(name: string, dc6: SpriteSheet, tblBytes: Uint8Array): {
})
}
const pixels = stitchFrames(atlasWidth, atlasHeight, placements)
if (name === 'fontexocet10') {
for (let p = 0; p < pixels.length; p++) {
const idx = pixels[p]!
if (idx >= 15 && idx <= 32) {
pixels[p] = Math.max(15, Math.min(31, 31 - (idx - 15)))
}
}
}
return {
meta: {
name,
@ -164,7 +220,7 @@ function buildFontAtlas(name: string, dc6: SpriteSheet, tblBytes: Uint8Array): {
atlasHeight,
glyphs,
},
pixels: stitchFrames(atlasWidth, atlasHeight, placements),
pixels,
}
}
@ -401,6 +457,34 @@ async function main(): Promise<void> {
}
writeFileSync(join(fontsDir, 'metrics.json'), JSON.stringify(fontMetas, null, 2))
// 7b. Generate authentic local TrueType WebFonts (`D2Exocet.ttf` & `D2Formal436.ttf`) from 1.13c DC6 font sheets
const f42Dc6 = await decodeUiDc6('data\\local\\font\\latin\\font42.dc6')
const f42Tbl = await archives.read('data\\local\\font\\latin\\font42.tbl')
const exocetFg = new Set([4, 6, 7, 12, 13, 14, 19, 20, 23, 24, 26, 28, 29, 30])
const exocetTtf = buildTrueTypeFont({
familyName: 'D2Exocet',
postScriptName: 'D2Exocet-Regular',
frames: f42Dc6.groups[0]!.frames,
tblBytes: f42Tbl,
baselineY: 32,
scale: 28,
isForeground: idx => exocetFg.has(idx),
})
writeFileSync(join(fontsDir, 'D2Exocet.ttf'), exocetTtf)
const ff12Dc6 = await decodeUiDc6('data\\local\\font\\latin\\fontformal12.dc6')
const ff12Tbl = await archives.read('data\\local\\font\\latin\\fontformal12.tbl')
const formalTtf = buildTrueTypeFont({
familyName: 'D2Formal436',
postScriptName: 'D2Formal436-Regular',
frames: ff12Dc6.groups[0]!.frames,
tblBytes: ff12Tbl,
baselineY: 26,
scale: 56,
isForeground: idx => idx >= 21,
})
writeFileSync(join(fontsDir, 'D2Formal436.ttf'), formalTtf)
// 8. Extract Item Inventory Sprites (`data\global\items\inv*.dc6`)
const codeToInvFile: Record<string, string> = {}
const uniqueInvFiles = new Set<string>()
@ -451,7 +535,17 @@ async function main(): Promise<void> {
const itemAtlasPixels = stitchFrames(ATLAS_W, atlasH, itemPlacements)
savePng('items-atlas.png', ATLAS_W, atlasH, itemAtlasPixels)
const manifestPath = join(outDir, 'manifest.json')
let existingManifest: Record<string, unknown> = {}
if (existsSync(manifestPath)) {
try {
existingManifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
} catch {
existingManifest = {}
}
}
const manifest = {
...existingManifest,
version: '1.13c',
decodedDc6Count: decodedCount,
dc6DecodeFailures: 0,
@ -460,7 +554,7 @@ async function main(): Promise<void> {
codeToInvFile,
itemRects,
}
writeFileSync(join(outDir, 'manifest.json'), JSON.stringify(manifest, null, 2))
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2))
console.log(`Successfully decoded ${decodedCount} DC6 sheets (0 failures) and generated public/ui/ assets!`)
}

View File

@ -0,0 +1,261 @@
/**
* Automated Headless Browser Verification & Visual Audit for Issue #504:
* Authentic Diablo II v1.13c Exocet (`D2Exocet`) & Formal 436 (`D2Formal436`)
* Typography across Canvas HUD (`D2FontRenderer` DC6 bitmap blitting) and DOM/CSS
* (`index.html`, `acts-en.html`, `acts.html`, `acts-zh.html`).
*/
import { spawn } from 'node:child_process'
import { mkdirSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { createServer } from 'vite'
const ARTIFACT_DIR =
process.argv[2] ||
'/usr/local/google/home/taodao/diablo2-web/.agents/teamwork/implementer_504_r1'
const sleep = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms))
interface CdpSession {
evalJs: <T = any>(expression: string) => Promise<T>
saveScreenshot: (filename: string) => Promise<void>
waitForGameReady: () => Promise<void>
}
async function withChromePage<T>(
url: string,
matchSubstr: string,
debugPort: number,
fn: (session: CdpSession) => Promise<T>,
): Promise<T> {
const chromeProc = spawn('/usr/bin/google-chrome', [
'--headless=new',
`--remote-debugging-port=${debugPort}`,
'--no-sandbox',
'--disable-dev-shm-usage',
'--enable-webgl',
'--ignore-gpu-blocklist',
'--use-gl=angle',
'--use-angle=swiftshader',
'--window-size=1280,840',
url,
])
try {
let wsUrl: string | null = null
for (let i = 0; i < 60; i++) {
await sleep(300)
try {
const res = await fetch(`http://127.0.0.1:${debugPort}/json/list`)
const pages = (await res.json()) as { type: string; url: string; webSocketDebuggerUrl?: string }[]
const page = pages.find(p => p.type === 'page' && p.url.includes(matchSubstr))
if (page?.webSocketDebuggerUrl) {
wsUrl = page.webSocketDebuggerUrl
break
}
} catch {
// Wait for Chrome CDP
}
}
if (!wsUrl) {
throw new Error(`Failed to connect to Chrome CDP for ${url}`)
}
const ws = new WebSocket(wsUrl)
await new Promise<void>(resolve => {
ws.onopen = () => resolve()
})
let idCounter = 1
const sendCommand = <R = any>(method: string, params: Record<string, unknown> = {}): Promise<R> =>
new Promise((resolve, reject) => {
const id = idCounter++
const handler = (event: MessageEvent) => {
const msg = JSON.parse(String(event.data))
if (msg.id === id) {
ws.removeEventListener('message', handler)
if (msg.error) reject(new Error(JSON.stringify(msg.error)))
else resolve(msg.result as R)
}
}
ws.addEventListener('message', handler)
ws.send(JSON.stringify({ id, method, params }))
})
const evalJs = async <R = any>(expression: string): Promise<R> => {
const res = await sendCommand<{ result: { value: R }; exceptionDetails?: any }>('Runtime.evaluate', {
expression,
awaitPromise: true,
returnByValue: true,
})
if (res.exceptionDetails) {
throw new Error(`JS exception: ${JSON.stringify(res.exceptionDetails)}`)
}
return res.result.value
}
const saveScreenshot = async (filename: string): Promise<void> => {
const shot = await sendCommand<{ data: string }>('Page.captureScreenshot', { format: 'png' })
writeFileSync(join(ARTIFACT_DIR, filename), Buffer.from(shot.data, 'base64'))
console.log(` Saved screenshot: ${filename}`)
}
const waitForGameReady = async (): Promise<void> => {
for (let i = 0; i < 120; i++) {
const ready = await evalJs<boolean>(
`Boolean(window.__d2webAct?.ready && window.__d2webHud?.ready && window.__d2webHudInstance && window.__d2webHud?.assetsLoaded?.ctrlPnl)`,
)
if (ready) {
await sleep(500)
return
}
await sleep(300)
}
throw new Error(`Timed out waiting for game ready at ${url}`)
}
await sendCommand('Runtime.enable')
await sendCommand('Page.enable')
const result = await fn({ evalJs, saveScreenshot, waitForGameReady })
ws.close()
return result
} finally {
chromeProc.kill('SIGKILL')
await sleep(200)
}
}
async function main(): Promise<void> {
mkdirSync(ARTIFACT_DIR, { recursive: true })
const server = await createServer({
root: process.cwd(),
server: { port: 0, host: '127.0.0.1' },
})
await server.listen()
const address = server.httpServer?.address()
const port = typeof address === 'object' && address ? address.port : 5173
const baseUrl = `http://127.0.0.1:${port}`
console.log(`1. Vite dev server listening at ${baseUrl}`)
try {
// ── Phase A: Verify index.html WebFont loading & computed typography ──
await withChromePage(`${baseUrl}/index.html`, 'index.html', 9251, async ({ evalJs, saveScreenshot }) => {
await sleep(500)
const indexFonts = await evalJs<{
exocetLoaded: boolean
formalLoaded: boolean
h1FontFamily: string
descFontFamily: string
}>(`(async () => {
await document.fonts.ready;
await Promise.all([
document.fonts.load('16px D2Exocet'),
document.fonts.load('16px D2Formal436'),
]);
return {
exocetLoaded: document.fonts.check('16px D2Exocet'),
formalLoaded: document.fonts.check('16px D2Formal436'),
h1FontFamily: getComputedStyle(document.querySelector('h1')).fontFamily,
descFontFamily: getComputedStyle(document.querySelector('.lang-desc')).fontFamily,
};
})()`)
console.log('2. Verified index.html WebFonts:', JSON.stringify(indexFonts))
if (!indexFonts.exocetLoaded || !indexFonts.formalLoaded) {
throw new Error(`index.html failed to load D2Exocet or D2Formal436: ${JSON.stringify(indexFonts)}`)
}
if (!indexFonts.h1FontFamily.includes('D2Exocet') || !indexFonts.descFontFamily.includes('D2Formal436')) {
throw new Error(`index.html unexpected font-family: ${JSON.stringify(indexFonts)}`)
}
await saveScreenshot('issue-504-index.png')
})
// ── Phase B: Verify acts-en.html Canvas DC6 bitmap font blitting & DOM/CSS WebFonts ──
await withChromePage(`${baseUrl}/acts-en.html?act=1`, 'acts-en.html', 9252, async ({ evalJs, saveScreenshot, waitForGameReady }) => {
await waitForGameReady()
const enCheck = await evalJs<{
exocetLoaded: boolean
formalLoaded: boolean
hudFontFamily: string
controlsFontFamily: string
dialogFontFamily: string
dialogTitleFontFamily: string
dc6FontLoaded: boolean
dc6BlitCount: number
fillTextCount: number
}>(`(async () => {
await document.fonts.ready;
await Promise.all([
document.fonts.load('16px D2Exocet'),
document.fonts.load('16px D2Formal436'),
]);
const inst = window.__d2webHudInstance;
let blitCount = 0;
let fillCount = 0;
const origDrawImage = CanvasRenderingContext2D.prototype.drawImage;
const origFillText = CanvasRenderingContext2D.prototype.fillText;
CanvasRenderingContext2D.prototype.drawImage = function(...args) {
if (args.length === 9) blitCount++;
return origDrawImage.apply(this, args);
};
CanvasRenderingContext2D.prototype.fillText = function(...args) {
fillCount++;
return origFillText.apply(this, args);
};
try {
if (inst.leftPanel !== 'char') inst.toggleLeftPanel('char');
if (inst.rightPanel !== 'inv') inst.toggleRightPanel('inv');
inst.inventory.handleMouseMove(561, 93);
inst.render(performance.now(), false);
} finally {
CanvasRenderingContext2D.prototype.drawImage = origDrawImage;
CanvasRenderingContext2D.prototype.fillText = origFillText;
}
return {
exocetLoaded: document.fonts.check('16px D2Exocet'),
formalLoaded: document.fonts.check('16px D2Formal436'),
hudFontFamily: getComputedStyle(document.getElementById('hud')).fontFamily,
controlsFontFamily: getComputedStyle(document.getElementById('controls')).fontFamily,
dialogFontFamily: getComputedStyle(document.getElementById('dialog')).fontFamily,
dialogTitleFontFamily: getComputedStyle(document.getElementById('status')).fontFamily,
dc6FontLoaded: Boolean(inst.font?.loaded),
dc6BlitCount: blitCount,
fillTextCount: fillCount,
};
})()`)
console.log('3. Verified acts-en.html Canvas DC6 blitting & WebFonts:', JSON.stringify(enCheck))
if (!enCheck.exocetLoaded || !enCheck.formalLoaded || !enCheck.dc6FontLoaded) {
throw new Error(`acts-en.html font loading failed: ${JSON.stringify(enCheck)}`)
}
if (enCheck.dc6BlitCount < 100 || enCheck.fillTextCount !== 0) {
throw new Error(`Expected acts-en.html HUD to use DC6 bitmap blitting (>100 blits, 0 fillText), got: ${JSON.stringify(enCheck)}`)
}
await saveScreenshot('issue-504-acts-en-panels.png')
// Open Quest Log + Skill Tree + NPC Dialog to verify Formal 436 & Exocet in all panels
await evalJs(`(() => {
const inst = window.__d2webHudInstance;
inst.toggleLeftPanel('quest');
inst.toggleRightPanel('skill');
const dialog = document.getElementById('dialog');
if (dialog) {
dialog.innerHTML = '<div class="dialog-title">Akara · High Priestess of the Sisterhood</div><div class="dialog-body">I am Akara, High Priestess of the Sisterhood of the Sightless Eye. I welcome you, traveler, to our camp, but I am afraid I can offer you but poor shelter within these rickety walls.</div>';
dialog.hidden = false;
dialog.style.display = 'block';
}
inst.render(performance.now(), false);
})()`)
await sleep(300)
await saveScreenshot('issue-504-acts-en-quests-skills-dialog.png')
})
console.log('✅ All Issue #504 Headless Browser E2E Checks PASSED!')
} finally {
await server.close()
}
}
main().catch(err => {
console.error('❌ Issue #504 browser verification failed:', err)
process.exit(1)
})

View File

@ -4085,7 +4085,7 @@ export function showNotification(message: string, durationMs = 2000, status?: HT
banner.style.left = '50%'
banner.style.transform = 'translateX(-50%)'
banner.style.color = '#e03030'
banner.style.fontFamily = 'monospace, sans-serif'
banner.style.fontFamily = 'var(--font-display, "D2Exocet", "Cinzel", "Times New Roman", "Liberation Serif", "Noto Serif", "Songti SC", "Noto Serif SC", serif)'
banner.style.fontSize = '20px'
banner.style.fontWeight = 'bold'
banner.style.textShadow = '0 0 8px rgba(0,0,0,0.9), 1px 1px 2px #000'

View File

@ -174,14 +174,14 @@ export class CharacterSheetPanel {
})
// 4. Level, Experience, Next Level
font.drawText(ctx, isEn ? 'Level' : '等级', ox + 42, oy + 48, { font: 'font8', color: 'tan', align: 'center' })
font.drawText(ctx, String(this.attrs.level), ox + 42, oy + 64, { font: 'fontexocet10', color: 'white', align: 'center' })
font.drawText(ctx, isEn ? 'Level' : '等级', ox + 42, oy + 46, { font: isEn ? 'font6' : 'font8', color: 'tan', align: 'center' })
font.drawText(ctx, String(this.attrs.level), ox + 42, oy + 63, { font: 'fontexocet10', color: 'white', align: 'center' })
font.drawText(ctx, isEn ? 'Experience' : '当前经验值', ox + 135, oy + 48, { font: 'font8', color: 'tan', align: 'center' })
font.drawText(ctx, this.attrs.xp.toLocaleString(), ox + 135, oy + 64, { font: 'font8', color: 'white', align: 'center' })
font.drawText(ctx, isEn ? 'Experience' : '当前经验值', ox + 135, oy + 46, { font: isEn ? 'font6' : 'font8', color: 'tan', align: 'center' })
font.drawText(ctx, this.attrs.xp.toLocaleString(), ox + 135, oy + 61, { font: 'font6', color: 'white', align: 'center' })
font.drawText(ctx, isEn ? 'Next Level' : '升级所需经验', ox + 245, oy + 48, { font: 'font8', color: 'tan', align: 'center' })
font.drawText(ctx, this.attrs.nextLevelXp.toLocaleString(), ox + 245, oy + 64, { font: 'font8', color: 'white', align: 'center' })
font.drawText(ctx, isEn ? 'Next Level' : '升级所需经验', ox + 245, oy + 46, { font: isEn ? 'font6' : 'font8', color: 'tan', align: 'center' })
font.drawText(ctx, this.attrs.nextLevelXp.toLocaleString(), ox + 245, oy + 61, { font: 'font6', color: 'white', align: 'center' })
// 5. Four Primary Attributes (`Str`, `Dex`, `Vit`, `Ene`)
const statRows: readonly { key: BaseStatKey; label: string; y: number; val: number }[] = [
@ -211,7 +211,24 @@ export class CharacterSheetPanel {
// Remaining Stat Points
if (this.attrs.statPoints > 0) {
font.drawText(ctx, isEn ? 'Stat Points Remaining' : '剩余属性点', ox + 56, oy + 366, { font: 'font8', color: 'red', align: 'center' })
if (isEn) {
font.drawText(ctx, 'Stat Points', ox + 56, oy + 361, {
font: 'font6',
color: 'red',
align: 'center',
})
font.drawText(ctx, 'Remaining', ox + 56, oy + 371, {
font: 'font6',
color: 'red',
align: 'center',
})
} else {
font.drawText(ctx, '剩余属性点', ox + 56, oy + 366, {
font: 'font8',
color: 'red',
align: 'center',
})
}
font.drawText(ctx, String(this.attrs.statPoints), ox + 122, oy + 366, {
font: 'fontexocet10',
color: 'red',
@ -220,16 +237,48 @@ export class CharacterSheetPanel {
}
// 6. Combat Ratings, Stamina, Life, Mana
font.drawText(ctx, isEn ? 'Left Attack Damage' : '左键技能伤害', ox + 166, oy + 91, { font: 'font8', color: 'tan' })
font.drawText(ctx, liveRuntime.leftSkillDmg, ox + 285, oy + 91, {
font: 'font8',
if (isEn) {
font.drawText(ctx, 'Left Attack', ox + 214, oy + 87, {
font: 'font6',
color: 'tan',
align: 'center',
})
font.drawText(ctx, 'Damage', ox + 214, oy + 95, {
font: 'font6',
color: 'tan',
align: 'center',
})
} else {
font.drawText(ctx, '左键技能伤害', ox + 164, oy + 91, {
font: 'font8',
color: 'tan',
})
}
font.drawText(ctx, liveRuntime.leftSkillDmg, ox + 288, oy + 91, {
font: 'font6',
color: 'blue',
align: 'center',
})
font.drawText(ctx, isEn ? 'Right Attack Damage' : '右键技能伤害', ox + 166, oy + 115, { font: 'font8', color: 'tan' })
font.drawText(ctx, liveRuntime.rightSkillDmg, ox + 285, oy + 115, {
font: 'font8',
if (isEn) {
font.drawText(ctx, 'Right Attack', ox + 214, oy + 111, {
font: 'font6',
color: 'tan',
align: 'center',
})
font.drawText(ctx, 'Damage', ox + 214, oy + 119, {
font: 'font6',
color: 'tan',
align: 'center',
})
} else {
font.drawText(ctx, '右键技能伤害', ox + 164, oy + 115, {
font: 'font8',
color: 'tan',
})
}
font.drawText(ctx, liveRuntime.rightSkillDmg, ox + 288, oy + 115, {
font: 'font6',
color: 'blue',
align: 'center',
})
@ -286,13 +335,37 @@ export class CharacterSheetPanel {
// 7. Four Elemental Resistances
const resRows = [
{ label: isEn ? 'Fire Resistance' : '火焰抗性', y: 341, val: this.attrs.fireRes },
{ label: isEn ? 'Cold Resistance' : '冰冷抗性', y: 365, val: this.attrs.coldRes },
{ label: isEn ? 'Lightning Resistance' : '闪电抗性', y: 389, val: this.attrs.lightRes },
{ label: isEn ? 'Poison Resistance' : '毒素抗性', y: 413, val: this.attrs.poisRes },
{ labelEn: ['Fire Resistance'], labelZh: '火焰抗性', y: 341, val: this.attrs.fireRes },
{ labelEn: ['Cold Resistance'], labelZh: '冰冷抗性', y: 365, val: this.attrs.coldRes },
{ labelEn: ['Lightning', 'Resistance'], labelZh: '闪电抗性', y: 389, val: this.attrs.lightRes },
{ labelEn: ['Poison Resistance'], labelZh: '毒素抗性', y: 413, val: this.attrs.poisRes },
] as const
for (const res of resRows) {
font.drawText(ctx, res.label, ox + 166, oy + res.y, { font: 'font8', color: 'tan' })
if (isEn) {
if (res.labelEn.length === 2) {
font.drawText(ctx, res.labelEn[0], ox + 214, oy + res.y - 4, {
font: 'font6',
color: 'tan',
align: 'center',
})
font.drawText(ctx, res.labelEn[1], ox + 214, oy + res.y + 5, {
font: 'font6',
color: 'tan',
align: 'center',
})
} else {
font.drawText(ctx, res.labelEn[0], ox + 214, oy + res.y, {
font: 'font6',
color: 'tan',
align: 'center',
})
}
} else {
font.drawText(ctx, res.labelZh, ox + 166, oy + res.y, {
font: 'font8',
color: 'tan',
})
}
font.drawText(ctx, `${res.val}%`, ox + 291, oy + res.y, {
font: 'fontexocet10',
color: res.val >= 75 ? 'gold' : res.val < 0 ? 'red' : 'white',

View File

@ -139,7 +139,7 @@ export function parseColoredText(input: string, defaultColor: D2ColorCode = 'whi
return spans
}
const FONT_PX_SIZE: Record<D2FontName, number> = {
export const FONT_PX_SIZE: Record<D2FontName, number> = {
font6: 10,
font8: 11,
fontexocet10: 13,
@ -148,12 +148,39 @@ const FONT_PX_SIZE: Record<D2FontName, number> = {
font30: 26,
}
/**
* Frame-relative alphabetic baseline row (`y_frame`) for each 1.13c DC6 font sheet
* (`data\local\font\latin\font*.dc6`), aligning uppercase letters (`'A'`, `'O'`, `'T'`)
* and digits (`'0'..'9'`) with `baseY = Math.round(y)`.
*/
export const FONT_BASELINE_OFFSET: Record<D2FontName, number> = {
font6: 8,
font8: 11,
fontexocet10: 13,
font16: 13,
font30: 23,
fontformal12: 25,
}
export const D2_EXOCET_FONT_STACK =
'"D2Exocet", "Cinzel", "Times New Roman", "Liberation Serif", "Noto Serif", "Songti SC", "Noto Serif SC", serif'
export const D2_FORMAL_FONT_STACK =
'"D2Formal436", "Times New Roman", "Liberation Serif", "Noto Serif", "Songti SC", "Noto Serif SC", serif'
function parseHexRgb(hex: string): readonly [number, number, number] {
const clean = hex.startsWith('#') ? hex.slice(1) : hex
const num = parseInt(clean, 16)
if (Number.isNaN(num)) return [232, 228, 220]
return [(num >> 16) & 0xff, (num >> 8) & 0xff, num & 0xff]
}
/**
* 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 images = new Map<string, HTMLImageElement | HTMLCanvasElement>()
private tintedCache = new Map<string, HTMLCanvasElement>()
loaded = false
@ -183,10 +210,38 @@ export class D2FontRenderer {
}
}
/**
* Register or override a loaded font atlas and metric table (used by tests and offline verifiers).
*/
setFontAtlas(
font: D2FontName,
imageOrMeta: HTMLImageElement | HTMLCanvasElement | FontAtlasMeta,
maybeImage?: HTMLImageElement | HTMLCanvasElement | FontAtlasMeta,
): void {
if ('glyphs' in imageOrMeta && 'atlasWidth' in imageOrMeta) {
this.metas[font] = imageOrMeta
if (maybeImage && !('glyphs' in maybeImage)) {
this.images.set(font, maybeImage)
}
} else {
this.images.set(font, imageOrMeta)
if (maybeImage && 'glyphs' in maybeImage) {
this.metas[font] = maybeImage
}
}
for (const k of [...this.tintedCache.keys()]) {
if (k.startsWith(`${font}:`)) this.tintedCache.delete(k)
}
this.loaded = this.images.size > 0
}
getFontMeta(font: D2FontName): FontAtlasMeta | undefined {
return this.metas[font]
}
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
@ -197,8 +252,43 @@ export class D2FontRenderer {
const ctx = canvas.getContext('2d')
if (!ctx) return baseImg
ctx.drawImage(baseImg, 0, 0)
const hex = D2_COLOR_HEX[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))
} 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
}
}
ctx.putImageData(imgData, 0, 0)
this.tintedCache.set(key, canvas)
return canvas
} catch {
// Fall through to composite operation when canvas is tainted or mock lacks pixel data
}
}
ctx.globalCompositeOperation = 'source-atop'
ctx.fillStyle = D2_COLOR_HEX[color]
ctx.fillStyle = hex
ctx.fillRect(0, 0, canvas.width, canvas.height)
ctx.globalCompositeOperation = 'source-over'
this.tintedCache.set(key, canvas)
@ -216,7 +306,7 @@ export class D2FontRenderer {
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]) {
if (code < 256 && meta?.glyphs[code]) {
total += Math.max(4, meta.glyphs[code]!.width)
} else {
total += code > 255 ? pxSize : Math.round(pxSize * 0.58)
@ -226,6 +316,19 @@ export class D2FontRenderer {
return total
}
private canBlitAllSpans(font: D2FontName, spans: readonly TextSpan[]): boolean {
if (!this.loaded || !this.images.has(font)) return false
const meta = this.metas[font]
if (!meta) return false
for (const span of spans) {
for (let i = 0; i < span.text.length; i++) {
const code = span.text.charCodeAt(i)
if (code >= 256 || !meta.glyphs[code]) return false
}
}
return true
}
/**
* Draw text onto a CanvasRenderingContext2D using genuine DC6 glyphs for ASCII/Latin
* and sharp outlined gothic/serif typography for CJK characters.
@ -248,7 +351,7 @@ export class D2FontRenderer {
const shadow = options.shadow ?? true
const totalWidth = this.measureText(text, font)
let curX = x
let curX = Math.round(x)
if (align === 'center') curX = Math.round(x - totalWidth / 2)
else if (align === 'right') curX = Math.round(x - totalWidth)
const baseY = Math.round(y)
@ -258,9 +361,53 @@ export class D2FontRenderer {
ctx.save()
const isTitleFont = font === 'font16' || font === 'font30' || font === 'fontexocet10'
ctx.font = `${isTitleFont ? 'bold ' : ''}${pxSize}px "Cinzel", "Times New Roman", "Liberation Serif", "Noto Serif", "Songti SC", "Noto Serif SC", serif`
const fontStack = font === 'fontformal12' ? D2_FORMAL_FONT_STACK : D2_EXOCET_FONT_STACK
ctx.font = `${isTitleFont ? 'bold ' : ''}${pxSize}px ${fontStack}`
ctx.textBaseline = 'alphabetic'
if (typeof ctx.drawImage === 'function' && this.canBlitAllSpans(font, spans)) {
const meta = this.metas[font]!
const dstY = baseY - FONT_BASELINE_OFFSET[font]
const shadowAtlas = shadow ? this.getTintedAtlas(font, 'black') : null
for (const span of spans) {
const tinted = this.getTintedAtlas(font, span.color)
for (let i = 0; i < span.text.length; i++) {
const code = span.text.charCodeAt(i)
const g = meta.glyphs[code]!
const adv = Math.max(4, g.width)
if (code !== 0x20 && code !== 0xa0 && g.frameW > 0 && g.frameH > 0 && tinted) {
if (shadow && span.color !== 'black' && shadowAtlas) {
ctx.drawImage(
shadowAtlas,
g.x,
g.y,
g.frameW,
g.frameH,
curX + 1,
dstY + 1,
g.frameW,
g.frameH,
)
}
ctx.drawImage(
tinted,
g.x,
g.y,
g.frameW,
g.frameH,
curX,
dstY,
g.frameW,
g.frameH,
)
}
curX += adv
}
}
ctx.restore()
return
}
let measuredTotal = 0
for (const s of spans) measuredTotal += ctx.measureText(s.text).width
if (align === 'center') curX = Math.round(x - measuredTotal / 2)

View File

@ -419,12 +419,12 @@ export class GroundLabelOverlay {
let el = this.labelPool[count]
if (el === undefined) {
el = document.createElement('div')
el.className = 'd2-ground-label'
el.className = 'd2-ground-label ground-label'
el.style.position = 'absolute'
el.style.transform = 'translate(-50%, -50%)'
el.style.background = 'rgba(0, 0, 0, 0.88)'
el.style.padding = '2px 6px'
el.style.fontFamily = "'Exocet', 'Formal', 'SimSun', sans-serif"
el.style.fontFamily = '"D2Exocet", "Cinzel", "Times New Roman", "Liberation Serif", "Noto Serif", "Songti SC", "Noto Serif SC", serif'
el.style.fontSize = '13px'
el.style.lineHeight = '14px'
el.style.whiteSpace = 'nowrap'

View File

@ -2848,6 +2848,33 @@ export class WorldPanelsHud {
for (const p of paragraphs) {
if (linesDrawn >= maxLines) break
if (p.includes(' ')) {
const words = p.split(/\s+/).filter(Boolean)
let line = ''
for (const word of words) {
const candidate = line ? `${line} ${word}` : word
if (line && font.measureText(candidate, 'font8') > maxW) {
if (linesDrawn + 1 >= maxLines) {
const truncated = line.slice(0, Math.max(0, line.length - 2)) + '...'
font.drawText(ctx, truncated, x, curY, { font: 'font8', color: 'white' })
return
}
font.drawText(ctx, line, x, curY, { font: 'font8', color: 'white' })
linesDrawn++
line = word
curY += lineH
} else {
line = candidate
}
}
if (line && linesDrawn < maxLines) {
font.drawText(ctx, line, x, curY, { font: 'font8', color: 'white' })
linesDrawn++
curY += lineH
}
continue
}
let line = ''
for (let i = 0; i < p.length; i++) {
const ch = p[i]!

View File

@ -0,0 +1,432 @@
/**
* Unit & Parity Tests for Issue #504:
* Authentic Diablo II v1.13c Exocet & Formal 436 Typography across Canvas HUD & DOM/CSS.
*/
import { describe, expect, it } from 'vitest'
import { existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import {
D2FontRenderer,
D2_COLOR_HEX,
D2_EXOCET_FONT_STACK,
D2_FORMAL_FONT_STACK,
FONT_BASELINE_OFFSET,
FONT_PX_SIZE,
type D2ColorCode,
type D2FontName,
} from '../src/ui/font.ts'
import { CharacterSheetPanel } from '../src/ui/character-sheet.ts'
import { GroundLabelOverlay } from '../src/ui/ground-labels.ts'
import { traceBitmapContours, buildTrueTypeFont } from '../scripts/build-d2-ttf.ts'
function signedContourArea(pts: Array<{ x: number; y: number }>): number {
let area = 0
for (let i = 0; i < pts.length; i++) {
const p1 = pts[i]!
const p2 = pts[(i + 1) % pts.length]!
area += p1.x * p2.y - p2.x * p1.y
}
return area / 2
}
describe('Diablo II v1.13c Exocet & Formal 436 Fonts (Issue #504)', () => {
it('bundles valid TrueType D2Exocet.ttf and D2Formal436.ttf in public/ui/fonts/ with required OpenType tables', () => {
const fontsDir = join(process.cwd(), 'public', 'ui', 'fonts')
const exocetPath = join(fontsDir, 'D2Exocet.ttf')
const formalPath = join(fontsDir, 'D2Formal436.ttf')
expect(existsSync(exocetPath)).toBe(true)
expect(existsSync(formalPath)).toBe(true)
for (const ttfPath of [exocetPath, formalPath]) {
const buf = readFileSync(ttfPath)
expect(buf.byteLength).toBeGreaterThan(20_000)
const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength)
// TrueType sfnt version 0x00010000
expect(view.getUint32(0, false)).toBe(0x00010000)
const numTables = view.getUint16(4, false)
expect(numTables).toBe(10)
const tags: string[] = []
for (let i = 0; i < numTables; i++) {
const recOff = 12 + i * 16
const tag = String.fromCharCode(
view.getUint8(recOff),
view.getUint8(recOff + 1),
view.getUint8(recOff + 2),
view.getUint8(recOff + 3),
)
const offset = view.getUint32(recOff + 8, false)
const length = view.getUint32(recOff + 12, false)
expect(offset + length).toBeLessThanOrEqual(buf.byteLength)
tags.push(tag)
}
expect(tags).toEqual([
'OS/2',
'cmap',
'glyf',
'head',
'hhea',
'hmtx',
'loca',
'maxp',
'name',
'post',
])
}
})
it('traces clockwise outer contours and counter-clockwise inner holes for Exocet cross-inside-O (⊕) and Formal hollow O', () => {
// 1. Construct a 9x9 Exocet cross-inside-O (⊕): outer ring + central cross (vertical x=4, horizontal y=4)
const w = 9
const h = 9
const exocetO = new Uint8Array(w * h)
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const isBorder = x === 0 || x === w - 1 || y === 0 || y === h - 1
const isCross = x === 4 || y === 4
if (isBorder || isCross) {
exocetO[y * w + x] = 1
}
}
}
const exocetContours = traceBitmapContours(exocetO, w, h, h, 10)
// 1 outer contour + 4 quadrant holes around the central cross = 5 contours
expect(exocetContours).toHaveLength(5)
const exocetAreas = exocetContours.map(signedContourArea)
// In Y-up font coordinates, clockwise outer contour has negative signed area,
// and counter-clockwise inner holes have positive signed area.
const outerContours = exocetAreas.filter(a => a < 0)
const innerHoles = exocetAreas.filter(a => a > 0)
expect(outerContours).toHaveLength(1)
expect(innerHoles).toHaveLength(4)
// 2. Construct a 9x9 Formal hollow O: outer ring only
const formalO = new Uint8Array(w * h)
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const isBorder = x === 0 || x === w - 1 || y === 0 || y === h - 1
if (isBorder) {
formalO[y * w + x] = 1
}
}
}
const formalContours = traceBitmapContours(formalO, w, h, h, 10)
expect(formalContours).toHaveLength(2)
expect(formalContours.map(signedContourArea).filter(a => a < 0)).toHaveLength(1)
expect(formalContours.map(signedContourArea).filter(a => a > 0)).toHaveLength(1)
// 3. Verify buildTrueTypeFont produces a valid sfnt binary from DC6 frames + tblBytes
const frames = Array.from({ length: 256 }, (_, code) => ({
width: code === 79 ? w : 0,
height: code === 79 ? h : 0,
offsetX: 0,
offsetY: 0,
indices: code === 79 ? exocetO : new Uint8Array(0),
}))
const tblBytes = new Uint8Array(12 + 256 * 14)
tblBytes[12 + 79 * 14 + 3] = 10
tblBytes[12 + 79 * 14 + 8] = 79
const ttf = buildTrueTypeFont({
familyName: 'D2ExocetTest',
postScriptName: 'D2ExocetTest-Regular',
frames,
tblBytes,
baselineY: 8,
scale: 50,
isForeground: idx => idx > 0,
})
expect(ttf.byteLength).toBeGreaterThan(500)
const view = new DataView(ttf.buffer, ttf.byteOffset, ttf.byteLength)
expect(view.getUint32(0, false)).toBe(0x00010000)
})
it('registers local @font-face D2Exocet and D2Formal436 across HTML pages and GroundLabelOverlay with zero external CDN links', () => {
const htmlFiles = ['index.html', 'acts-en.html', 'acts.html', 'acts-zh.html']
for (const file of htmlFiles) {
const content = readFileSync(join(process.cwd(), file), 'utf8')
expect(content, `${file} must declare D2Exocet @font-face`).toMatch(/font-family:\s*['"]D2Exocet['"]/)
expect(content, `${file} must reference local /ui/fonts/D2Exocet.ttf`).toMatch(/url\(['"]\/ui\/fonts\/D2Exocet\.ttf['"]\)/)
expect(content, `${file} must declare D2Formal436 @font-face`).toMatch(/font-family:\s*['"]D2Formal436['"]/)
expect(content, `${file} must reference local /ui/fonts/D2Formal436.ttf`).toMatch(/url\(['"]\/ui\/fonts\/D2Formal436\.ttf['"]\)/)
expect(content, `${file} must define --font-display with D2Exocet`).toContain('--font-display: "D2Exocet"')
expect(content, `${file} must define --font-body with D2Formal436`).toContain('--font-body: "D2Formal436"')
// Zero external CDN links
expect(content).not.toContain('fonts.googleapis.com')
expect(content).not.toContain('fonts.gstatic.com')
expect(content).not.toContain('https://')
expect(content).not.toContain('http://')
}
for (const actHtml of ['acts-en.html', 'acts.html', 'acts-zh.html']) {
const content = readFileSync(join(process.cwd(), actHtml), 'utf8')
expect(content).toMatch(/#hud\s*\{[^}]*font-family:\s*var\(--font-display\)/s)
expect(content).toMatch(/#controls\s*\{[^}]*font-family:\s*var\(--font-display\)/s)
expect(content).toMatch(/#dialog\s*\{[^}]*font-family:\s*var\(--font-body\)/s)
}
const groundLabelsSrc = readFileSync(join(process.cwd(), 'src', 'ui', 'ground-labels.ts'), 'utf8')
expect(groundLabelsSrc).toContain('"D2Exocet"')
expect(groundLabelsSrc).toContain('"Times New Roman"')
expect(groundLabelsSrc).not.toContain('Georgia')
})
it('configures D2FontRenderer with D2Exocet and D2Formal436 stacks (never Georgia) and blits genuine 1.13c DC6 glyphs when loaded', () => {
expect(D2_EXOCET_FONT_STACK).toContain('"D2Exocet"')
expect(D2_EXOCET_FONT_STACK).toContain('"Times New Roman"')
expect(D2_EXOCET_FONT_STACK).not.toContain('Georgia')
expect(D2_FORMAL_FONT_STACK).toContain('"D2Formal436"')
expect(D2_FORMAL_FONT_STACK).toContain('"Times New Roman"')
expect(D2_FORMAL_FONT_STACK).not.toContain('Georgia')
const renderer = new D2FontRenderer()
// 1. Before load() or for CJK fallback, ctx.font uses D2_EXOCET_FONT_STACK for Exocet sizes and D2_FORMAL_FONT_STACK for fontformal12
const capturedFonts: Record<string, string> = {}
const fillTextCalls: Array<{ text: string; x: number; y: number; fillStyle: string }> = []
const fallbackCtx = {
save: () => {},
restore: () => {},
measureText: (t: string) => ({ width: t.length * 7 }),
fillText: function (this: { fillStyle: string }, text: string, x: number, y: number) {
fillTextCalls.push({ text, x, y, fillStyle: this.fillStyle })
},
font: '',
textBaseline: 'alphabetic',
fillStyle: '',
} as unknown as CanvasRenderingContext2D
const allSizes: D2FontName[] = ['font6', 'font8', 'fontexocet10', 'font16', 'font30', 'fontformal12']
for (const size of allSizes) {
renderer.drawText(fallbackCtx, 'Rogue O', 100, 100, { font: size, color: 'gold' })
capturedFonts[size] = fallbackCtx.font
}
for (const nonBoldExocetSize of ['font6', 'font8'] as D2FontName[]) {
expect(capturedFonts[nonBoldExocetSize]).toBe(`${FONT_PX_SIZE[nonBoldExocetSize]}px ${D2_EXOCET_FONT_STACK}`)
}
for (const boldExocetSize of ['fontexocet10', 'font16', 'font30'] as D2FontName[]) {
expect(capturedFonts[boldExocetSize]).toBe(`bold ${FONT_PX_SIZE[boldExocetSize]}px ${D2_EXOCET_FONT_STACK}`)
}
expect(capturedFonts.fontformal12).toBe(`${FONT_PX_SIZE.fontformal12}px ${D2_FORMAL_FONT_STACK}`)
// 2. When loaded with a font atlas + metrics.json, ASCII/Latin text blits genuine DC6 bitmap glyphs via ctx.drawImage
const metricsJson = JSON.parse(
readFileSync(join(process.cwd(), 'public', 'ui', 'fonts', 'metrics.json'), 'utf8'),
)
const mockAtlas = { width: 512, height: 512, tag: 'font16-atlas' } as unknown as HTMLImageElement
renderer.setFontAtlas('font16', metricsJson.font16, mockAtlas)
const drawImageCalls: Array<{
img: unknown
sx: number
sy: number
sw: number
sh: number
dx: number
dy: number
dw: number
dh: number
}> = []
let fillTextCount = 0
const blitCtx = {
save: () => {},
restore: () => {},
measureText: (t: string) => ({ width: t.length * 8 }),
fillText: () => {
fillTextCount++
},
drawImage: (
img: unknown,
sx: number,
sy: number,
sw: number,
sh: number,
dx: number,
dy: number,
dw: number,
dh: number,
) => {
drawImageCalls.push({ img, sx, sy, sw, sh, dx, dy, dw, dh })
},
font: '',
textBaseline: 'alphabetic',
fillStyle: '',
} as unknown as CanvasRenderingContext2D
// Render "OT" centered at (200, 100) with shadow=true
renderer.drawText(blitCtx, 'OT', 200, 100, {
font: 'font16',
color: 'gold',
align: 'center',
shadow: true,
})
// Must use DC6 bitmap blitting (0 fillText calls, 4 drawImage calls: 2 shadow + 2 foreground for 'O' and 'T')
expect(fillTextCount).toBe(0)
expect(drawImageCalls).toHaveLength(4)
const meta16 = renderer.getFontMeta('font16')!
const glyphO = meta16.glyphs['O'.charCodeAt(0)]!
const glyphT = meta16.glyphs['T'.charCodeAt(0)]!
const advO = Math.max(4, glyphO.width)
const advT = Math.max(4, glyphT.width)
const expectedWidth = advO + advT
const expectedStartX = Math.round(200 - expectedWidth / 2)
const expectedDstY = Math.round(100 - FONT_BASELINE_OFFSET.font16)
// First glyph ('O'): shadow at (expectedStartX + 1, expectedDstY + 1), foreground at (expectedStartX, expectedDstY)
expect(drawImageCalls[0]).toMatchObject({
sx: glyphO.x,
sy: glyphO.y,
sw: glyphO.frameW,
sh: glyphO.frameH,
dx: expectedStartX + 1,
dy: expectedDstY + 1,
dw: glyphO.frameW,
dh: glyphO.frameH,
})
expect(drawImageCalls[1]).toMatchObject({
sx: glyphO.x,
sy: glyphO.y,
sw: glyphO.frameW,
sh: glyphO.frameH,
dx: expectedStartX,
dy: expectedDstY,
dw: glyphO.frameW,
dh: glyphO.frameH,
})
// Second glyph ('T'): shadow at (expectedStartX + advO + 1, expectedDstY + 1), foreground at (expectedStartX + advO, expectedDstY)
expect(drawImageCalls[2]).toMatchObject({
sx: glyphT.x,
sy: glyphT.y,
sw: glyphT.frameW,
sh: glyphT.frameH,
dx: expectedStartX + advO + 1,
dy: expectedDstY + 1,
dw: glyphT.frameW,
dh: glyphT.frameH,
})
expect(drawImageCalls[3]).toMatchObject({
sx: glyphT.x,
sy: glyphT.y,
sw: glyphT.frameW,
sh: glyphT.frameH,
dx: expectedStartX + advO,
dy: expectedDstY,
dw: glyphT.frameW,
dh: glyphT.frameH,
})
// 3. CJK string ("罗格营地") automatically falls back to ctx.fillText even when DC6 atlas is loaded
drawImageCalls.length = 0
fillTextCount = 0
renderer.drawText(blitCtx, '罗格营地', 200, 100, {
font: 'font16',
color: 'gold',
align: 'center',
shadow: true,
})
expect(drawImageCalls).toHaveLength(0)
expect(fillTextCount).toBeGreaterThan(0)
})
it('fits English Character Sheet labels inside stone recesses using DC6 font6 metrics', () => {
const metricsJson = JSON.parse(
readFileSync(join(process.cwd(), 'public', 'ui', 'fonts', 'metrics.json'), 'utf8'),
)
const renderer = new D2FontRenderer()
renderer.setFontAtlas('font6', metricsJson.font6, { width: 256, height: 256 } as unknown as HTMLImageElement)
renderer.setFontAtlas('font8', metricsJson.font8, { width: 256, height: 256 } as unknown as HTMLImageElement)
const sheet = new CharacterSheetPanel()
sheet.lang = 'ENG'
sheet.visible = true
const drawnLabels: Array<{ text: string; font: D2FontName; widthPx: number }> = []
const mockCtx = {
drawImage: () => {},
fillRect: () => {},
strokeRect: () => {},
save: () => {},
restore: () => {},
measureText: (t: string) => ({ width: t.length * 6 }),
fillText: () => {},
font: '',
textBaseline: 'alphabetic',
fillStyle: '',
strokeStyle: '',
} as unknown as CanvasRenderingContext2D
const spyFont = {
drawText: (
_ctx: CanvasRenderingContext2D,
text: string,
_x: number,
_y: number,
opts?: { font?: D2FontName },
) => {
const f = opts?.font ?? 'font8'
drawnLabels.push({
text,
font: f,
widthPx: renderer.measureText(text, f),
})
},
measureText: (text: string, font?: D2FontName) => renderer.measureText(text, font),
} as unknown as D2FontRenderer
sheet.draw(
mockCtx,
{
hp: 340,
maxHp: 340,
mana: 420,
maxMana: 420,
stamina: 180,
maxStamina: 180,
leftSkillDmg: '3-6',
rightSkillDmg: '50-100',
},
{
borderLeftImg: null,
charSheetImg: null,
buySellBtnImg: null,
},
spyFont,
)
// Check that multi-word English labels use font6 and fit strictly within their 104px stone recesses (ox+162..ox+266)
const compactLabels = [
'Left Attack',
'Right Attack',
'Damage',
'Stat Points',
'Remaining',
'Fire Resistance',
'Cold Resistance',
'Lightning',
'Resistance',
'Poison Resistance',
]
for (const labelText of compactLabels) {
const match = drawnLabels.find(d => d.text === labelText)
expect(match, `Expected CharacterSheetPanel to draw "${labelText}" in English mode`).toBeDefined()
expect(match!.font).toBe('font6')
expect(match!.widthPx).toBeLessThan(renderer.measureText(labelText, 'font8'))
expect(match!.widthPx).toBeLessThanOrEqual(104)
}
// Verify middle dot U+00B7 (0xB7) in metrics.json uses period width (<= 10px) rather than '?' placeholder width
expect(metricsJson.fontformal12.glyphs[0xb7].width).toBe(metricsJson.fontformal12.glyphs[0x2e].width)
expect(metricsJson.font30.glyphs[0xb7].width).toBe(metricsJson.font30.glyphs[0x2e].width)
})
})