fix(ui): align CharacterSheet stone recess centers, bilingual CJK+space wrapping, bnet.html @font-face & pack:ui font parity (Fixes #504)
This commit is contained in:
parent
9d0138f69b
commit
1d7445a64e
22
bnet.html
22
bnet.html
|
|
@ -8,14 +8,32 @@
|
|||
window.__d2SkipAutoBoot = true;
|
||||
</script>
|
||||
<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: #000000;
|
||||
color: #e8dcc8;
|
||||
font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font: 13px/1.5 var(--font-body);
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -548,6 +548,27 @@ async function main(): Promise<void> {
|
|||
writeFileSync(join(fontsDir, 'D2Formal436.ttf'), formalTtf)
|
||||
|
||||
// 8. Extract Item Inventory Sprites (`data\global\items\inv*.dc6`)
|
||||
const manifestPath = join(outDir, 'manifest.json')
|
||||
let existingManifest: Record<string, unknown> = {}
|
||||
if (existsSync(manifestPath)) {
|
||||
try {
|
||||
existingManifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
|
||||
} catch {
|
||||
existingManifest = {}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
existsSync(join(outDir, 'items-atlas.png')) &&
|
||||
existingManifest.codeToFlippyFile &&
|
||||
!process.argv.includes('--repack-base-items')
|
||||
) {
|
||||
console.log(
|
||||
`Successfully decoded ${decodedCount} UI/font DC6 sheets (0 failures) and updated public/ui/fonts/ (preserved full items-atlas.png & manifest.json)!`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const codeToInvFile: Record<string, string> = {}
|
||||
const uniqueInvFiles = new Set<string>()
|
||||
|
||||
|
|
@ -597,15 +618,6 @@ 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',
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { parseTable } from '../src/game/tables.ts'
|
|||
import { loadStringTableChain } from '../src/game/item-names.ts'
|
||||
import { encodeIndexedPng } from './png.ts'
|
||||
import { packCursorAssets } from './pack-cursor.ts'
|
||||
import { buildTrueTypeFont } from './build-d2-ttf.ts'
|
||||
|
||||
interface BlitPlacement {
|
||||
readonly frame: SpriteFrame
|
||||
|
|
@ -208,12 +209,83 @@ 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]
|
||||
|
||||
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 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 || i === 0x95) && dotFrame) {
|
||||
const shiftY = Math.max(1, Math.round(dotFrame.height * 0.22))
|
||||
const shiftedIndices = new Uint8Array(dotFrame.width * dotFrame.height)
|
||||
const shiftedMask = 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++) {
|
||||
const dstIdx = y * dotFrame.width + x
|
||||
const srcIdx = srcY * dotFrame.width + x
|
||||
shiftedIndices[dstIdx] = dotFrame.indices[srcIdx]!
|
||||
shiftedMask[dstIdx] = dotFrame.mask[srcIdx]!
|
||||
}
|
||||
}
|
||||
}
|
||||
frame = { ...dotFrame, indices: shiftedIndices, mask: shiftedMask }
|
||||
metricWidth = dotMetricW
|
||||
} else if (i !== 0x3f && i >= 0x7f && isPlaceholderQuestionMark(frame)) {
|
||||
if ((i === 0x96 || i === 0x97) && dashFrame) {
|
||||
frame = dashFrame
|
||||
metricWidth = dashMetricW
|
||||
} else {
|
||||
frame = undefined
|
||||
}
|
||||
}
|
||||
if (i !== 0x20 && i !== 0xa0 && frame) {
|
||||
let hasVisiblePixel = false
|
||||
for (let k = 0; k < frame.indices.length; k++) {
|
||||
if (frame.mask[k] !== 0 && frame.indices[k] !== 0) {
|
||||
hasVisiblePixel = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!hasVisiblePixel) {
|
||||
frame = undefined
|
||||
}
|
||||
}
|
||||
const gx = (i % 16) * cellW
|
||||
const gy = Math.floor(i / 16) * cellH
|
||||
if (frame) {
|
||||
|
|
@ -230,6 +302,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,
|
||||
|
|
@ -239,7 +321,7 @@ function buildFontAtlas(name: string, dc6: SpriteSheet, tblBytes: Uint8Array): {
|
|||
atlasHeight,
|
||||
glyphs,
|
||||
},
|
||||
pixels: stitchFrames(atlasWidth, atlasHeight, placements),
|
||||
pixels,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -559,6 +641,70 @@ 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, 20, 23, 24, 26, 28, 29, 30, 31, 32])
|
||||
const exocetTtf = buildTrueTypeFont({
|
||||
familyName: 'D2Exocet',
|
||||
postScriptName: 'D2Exocet-Regular',
|
||||
frames: f42Dc6.groups[0]!.frames,
|
||||
tblBytes: f42Tbl,
|
||||
baselineY: 32,
|
||||
scale: 28,
|
||||
isForeground: idx => exocetFg.has(idx),
|
||||
minContourAreaPixels: 2,
|
||||
refineMask: (mask, frame) => {
|
||||
const w = frame.width
|
||||
const h = frame.height
|
||||
for (let y = 1; y < h - 1; y++) {
|
||||
for (let x = 1; x < w - 1; x++) {
|
||||
const i = y * w + x
|
||||
const p = frame.indices[i] ?? 0
|
||||
if (mask[i] && (p === 4 || p === 6) && !mask[i - 1] && !mask[i + 1]) {
|
||||
mask[i] = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let y = 1; y < h - 1; y++) {
|
||||
for (let x = 1; x < w - 1; x++) {
|
||||
const i = y * w + x
|
||||
const p = frame.indices[i] ?? 0
|
||||
if (!mask[i] && (p === 3 || p === 18 || p === 19) && mask[i - 1] && mask[i + 1]) {
|
||||
mask[i] = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
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 >= 28,
|
||||
refineMask: (mask, frame) => {
|
||||
const w = frame.width
|
||||
const h = frame.height
|
||||
const base = mask.slice()
|
||||
for (let y = 1; y < h - 1; y++) {
|
||||
for (let x = 1; x < w - 1; x++) {
|
||||
const i = y * w + x
|
||||
if (!base[i] && frame.indices[i] === 27 && base[i - w] && base[i + w] && base[i - 1] !== base[i + 1]) {
|
||||
mask[i] = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
writeFileSync(join(fontsDir, 'D2Formal436.ttf'), formalTtf)
|
||||
|
||||
// --- STAGE 2: Comprehensive Items, Flippy Ground Sprites & Unique Items Packing ---
|
||||
console.log('Extracting item sprites & flippy ground sprites (including uniqueitems.txt & setitems.txt)...')
|
||||
const codeToInvFile: Record<string, string> = {}
|
||||
|
|
|
|||
|
|
@ -161,27 +161,27 @@ export class CharacterSheetPanel {
|
|||
ctx.fillRect(ox, oy, CHAR_PANEL_ORIGIN.width, CHAR_PANEL_ORIGIN.height)
|
||||
}
|
||||
|
||||
// 3. Top Header: Character Name & Class
|
||||
font.drawText(ctx, this.attrs.name, ox + 90, oy + 21, {
|
||||
// 3. Top Header: Character Name (x=12..181, mid=97) & Class (x=194..309, mid=252)
|
||||
font.drawText(ctx, this.attrs.name, ox + 97, oy + 21, {
|
||||
font: 'fontexocet10',
|
||||
color: 'gold',
|
||||
align: 'center',
|
||||
})
|
||||
font.drawText(ctx, isEn ? this.attrs.classNameEn : this.attrs.classNameZh, ox + 236, oy + 21, {
|
||||
font.drawText(ctx, isEn ? this.attrs.classNameEn : this.attrs.classNameZh, ox + 252, oy + 21, {
|
||||
font: 'fontexocet10',
|
||||
color: 'white',
|
||||
align: 'center',
|
||||
})
|
||||
|
||||
// 4. Level, Experience, Next Level
|
||||
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 + 62, { font: 'fontexocet10', color: 'white', align: 'center' })
|
||||
// 4. Level (x=12..53, mid=33), Experience (x=66..181, mid=124), Next Level (x=194..309, mid=252)
|
||||
font.drawText(ctx, isEn ? 'Level' : '等级', ox + 33, oy + 46, { font: isEn ? 'font6' : 'font8', color: 'tan', align: 'center' })
|
||||
font.drawText(ctx, String(this.attrs.level), ox + 33, oy + 62, { font: 'fontexocet10', 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 + 62, { font: 'font6', color: 'white', align: 'center' })
|
||||
font.drawText(ctx, isEn ? 'Experience' : '当前经验值', ox + 124, oy + 46, { font: isEn ? 'font6' : 'font8', color: 'tan', align: 'center' })
|
||||
font.drawText(ctx, this.attrs.xp.toLocaleString(), ox + 124, oy + 62, { font: 'font6', 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 + 62, { font: 'font6', color: 'white', align: 'center' })
|
||||
font.drawText(ctx, isEn ? 'Next Level' : '升级所需经验', ox + 252, oy + 46, { font: isEn ? 'font6' : 'font8', color: 'tan', align: 'center' })
|
||||
font.drawText(ctx, this.attrs.nextLevelXp.toLocaleString(), ox + 252, oy + 62, { 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 }[] = [
|
||||
|
|
@ -343,27 +343,28 @@ export class CharacterSheetPanel {
|
|||
for (const res of resRows) {
|
||||
if (isEn) {
|
||||
if (res.labelEn.length === 2) {
|
||||
font.drawText(ctx, res.labelEn[0], ox + 214, oy + res.y - 4, {
|
||||
font.drawText(ctx, res.labelEn[0], ox + 222, oy + res.y - 4, {
|
||||
font: 'font6',
|
||||
color: 'tan',
|
||||
align: 'center',
|
||||
})
|
||||
font.drawText(ctx, res.labelEn[1], ox + 214, oy + res.y + 4, {
|
||||
font.drawText(ctx, res.labelEn[1], ox + 222, oy + res.y + 4, {
|
||||
font: 'font6',
|
||||
color: 'tan',
|
||||
align: 'center',
|
||||
})
|
||||
} else {
|
||||
font.drawText(ctx, res.labelEn[0], ox + 214, oy + res.y, {
|
||||
font.drawText(ctx, res.labelEn[0], ox + 222, oy + res.y, {
|
||||
font: 'font6',
|
||||
color: 'tan',
|
||||
align: 'center',
|
||||
})
|
||||
}
|
||||
} else {
|
||||
font.drawText(ctx, res.labelZh, ox + 166, oy + res.y, {
|
||||
font.drawText(ctx, res.labelZh, ox + 222, oy + res.y, {
|
||||
font: 'font8',
|
||||
color: 'tan',
|
||||
align: 'center',
|
||||
})
|
||||
}
|
||||
font.drawText(ctx, `${res.val}%`, ox + 291, oy + res.y, {
|
||||
|
|
|
|||
|
|
@ -2849,37 +2849,12 @@ 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, fontName) > maxW) {
|
||||
if (linesDrawn + 1 >= maxLines) {
|
||||
const truncated = line.slice(0, Math.max(0, line.length - 2)) + '...'
|
||||
font.drawText(ctx, truncated, x, curY, { font: fontName, color: 'white' })
|
||||
return
|
||||
}
|
||||
font.drawText(ctx, line, x, curY, { font: fontName, color: 'white' })
|
||||
linesDrawn++
|
||||
line = word
|
||||
curY += lineH
|
||||
} else {
|
||||
line = candidate
|
||||
}
|
||||
}
|
||||
if (line && linesDrawn < maxLines) {
|
||||
font.drawText(ctx, line, x, curY, { font: fontName, color: 'white' })
|
||||
linesDrawn++
|
||||
curY += lineH
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const rawTokens = p.match(/\s*[\x21-\xff]+|\s*[^\s\x21-\xff]/g) ?? []
|
||||
let line = ''
|
||||
for (let i = 0; i < p.length; i++) {
|
||||
const ch = p[i]!
|
||||
if (font.measureText(line + ch, fontName) > maxW) {
|
||||
for (const rawToken of rawTokens) {
|
||||
const trimmed = rawToken.trimStart()
|
||||
const candidate = line ? `${line}${rawToken.startsWith(' ') ? ' ' : ''}${trimmed}` : trimmed
|
||||
if (line && font.measureText(candidate, fontName) > maxW) {
|
||||
if (linesDrawn + 1 >= maxLines) {
|
||||
const truncated = line.slice(0, Math.max(0, line.length - 2)) + '...'
|
||||
font.drawText(ctx, truncated, x, curY, { font: fontName, color: 'white' })
|
||||
|
|
@ -2887,10 +2862,25 @@ export class WorldPanelsHud {
|
|||
}
|
||||
font.drawText(ctx, line, x, curY, { font: fontName, color: 'white' })
|
||||
linesDrawn++
|
||||
line = ch
|
||||
curY += lineH
|
||||
line = trimmed
|
||||
} else {
|
||||
line += ch
|
||||
line = candidate
|
||||
}
|
||||
while (line && font.measureText(line, fontName) > maxW && line.length > 1) {
|
||||
let splitIdx = 1
|
||||
while (splitIdx < line.length && font.measureText(line.slice(0, splitIdx + 1), fontName) <= maxW) {
|
||||
splitIdx++
|
||||
}
|
||||
if (linesDrawn + 1 >= maxLines) {
|
||||
const truncated = line.slice(0, Math.max(1, splitIdx - 2)) + '...'
|
||||
font.drawText(ctx, truncated, x, curY, { font: fontName, color: 'white' })
|
||||
return
|
||||
}
|
||||
font.drawText(ctx, line.slice(0, splitIdx), x, curY, { font: fontName, color: 'white' })
|
||||
linesDrawn++
|
||||
curY += lineH
|
||||
line = line.slice(splitIdx)
|
||||
}
|
||||
}
|
||||
if (line && linesDrawn < maxLines) {
|
||||
|
|
|
|||
|
|
@ -217,7 +217,7 @@ describe('Diablo II v1.13c Exocet & Formal 436 Fonts (Issue #504)', () => {
|
|||
})
|
||||
|
||||
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']
|
||||
const htmlFiles = ['index.html', 'acts-en.html', 'acts.html', 'acts-zh.html', 'bnet.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['"]/)
|
||||
|
|
@ -531,7 +531,7 @@ describe('Diablo II v1.13c Exocet & Formal 436 Fonts (Issue #504)', () => {
|
|||
expect(npcBodyCalls.map(c => c.text).join(' ')).toContain('great evil')
|
||||
})
|
||||
|
||||
it('fits English Character Sheet labels inside stone recesses using DC6 font6 metrics', () => {
|
||||
it('fits English and Chinese Character Sheet labels inside stone recesses using DC6 font6/font8 metrics', () => {
|
||||
const metricsJson = JSON.parse(
|
||||
readFileSync(join(process.cwd(), 'public', 'ui', 'fonts', 'metrics.json'), 'utf8'),
|
||||
)
|
||||
|
|
@ -543,14 +543,14 @@ describe('Diablo II v1.13c Exocet & Formal 436 Fonts (Issue #504)', () => {
|
|||
sheet.lang = 'ENG'
|
||||
sheet.visible = true
|
||||
|
||||
const drawnLabels: Array<{ text: string; font: D2FontName; widthPx: number; y: number }> = []
|
||||
const drawnLabels: Array<{ text: string; font: D2FontName; widthPx: number; x: number; y: number; align: string }> = []
|
||||
const mockCtx = {
|
||||
drawImage: () => {},
|
||||
fillRect: () => {},
|
||||
strokeRect: () => {},
|
||||
save: () => {},
|
||||
restore: () => {},
|
||||
measureText: (t: string) => ({ width: t.length * 6 }),
|
||||
measureText: (t: string) => ({ width: t.length * 10 }),
|
||||
fillText: () => {},
|
||||
font: '',
|
||||
textBaseline: 'alphabetic',
|
||||
|
|
@ -562,16 +562,18 @@ describe('Diablo II v1.13c Exocet & Formal 436 Fonts (Issue #504)', () => {
|
|||
drawText: (
|
||||
_ctx: CanvasRenderingContext2D,
|
||||
text: string,
|
||||
_x: number,
|
||||
x: number,
|
||||
y: number,
|
||||
opts?: { font?: D2FontName },
|
||||
opts?: { font?: D2FontName; align?: string },
|
||||
) => {
|
||||
const f = opts?.font ?? 'font8'
|
||||
drawnLabels.push({
|
||||
text,
|
||||
font: f,
|
||||
widthPx: renderer.measureText(text, f),
|
||||
x,
|
||||
y,
|
||||
align: opts?.align ?? 'left',
|
||||
})
|
||||
},
|
||||
measureText: (text: string, font?: D2FontName) => renderer.measureText(text, font),
|
||||
|
|
@ -597,7 +599,7 @@ describe('Diablo II v1.13c Exocet & Formal 436 Fonts (Issue #504)', () => {
|
|||
spyFont,
|
||||
)
|
||||
|
||||
// Check that multi-word English labels use font6 and fit strictly within their 104px stone recesses (ox+162..ox+266)
|
||||
// Check that multi-word English labels use font6 and fit strictly within their stone recesses (95px for resistances x=175..269)
|
||||
const compactLabels = [
|
||||
'Left Attack',
|
||||
'Right Attack',
|
||||
|
|
@ -615,7 +617,7 @@ describe('Diablo II v1.13c Exocet & Formal 436 Fonts (Issue #504)', () => {
|
|||
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)
|
||||
expect(match!.widthPx).toBeLessThanOrEqual(97)
|
||||
}
|
||||
|
||||
// Verify middle dot U+00B7 (0xB7), bullet (0x95), and en/em-dashes (0x96/0x97) in metrics.json across all 6 DC6 fonts
|
||||
|
|
@ -628,12 +630,110 @@ describe('Diablo II v1.13c Exocet & Formal 436 Fonts (Issue #504)', () => {
|
|||
expect(metricsJson[fn].glyphs[0x97].frameW).toBeGreaterThan(0)
|
||||
}
|
||||
|
||||
// Verify vertical centering inside stone recesses (oy=60: top recess 10..23 -> baseline 81; Str recess 83..98 -> baseline 154)
|
||||
// Verify horizontal and vertical centering inside invchar6.dc6 stone recesses (ox=80, oy=60)
|
||||
expect(drawnLabels.find(d => d.text === 'Isendra')?.x).toBe(80 + 97)
|
||||
expect(drawnLabels.find(d => d.text === 'Isendra')?.y).toBe(60 + 21)
|
||||
expect(drawnLabels.find(d => d.text === 'Sorceress')?.x).toBe(80 + 252)
|
||||
expect(drawnLabels.find(d => d.text === 'Sorceress')?.y).toBe(60 + 21)
|
||||
expect(drawnLabels.find(d => d.text === 'Level')?.x).toBe(80 + 33)
|
||||
expect(drawnLabels.find(d => d.text === 'Experience')?.x).toBe(80 + 124)
|
||||
expect(drawnLabels.find(d => d.text === 'Next Level')?.x).toBe(80 + 252)
|
||||
expect(drawnLabels.find(d => d.text === 'Fire Resistance')?.x).toBe(80 + 222)
|
||||
expect(drawnLabels.find(d => d.text === 'Fire Resistance')?.align).toBe('center')
|
||||
expect(drawnLabels.find(d => d.text === 'Strength')?.y).toBe(60 + 94)
|
||||
expect(drawnLabels.find(d => d.text === 'Dexterity')?.y).toBe(60 + 156)
|
||||
expect(drawnLabels.find(d => d.text === 'Vitality')?.y).toBe(60 + 242)
|
||||
expect(drawnLabels.find(d => d.text === 'Energy')?.y).toBe(60 + 304)
|
||||
|
||||
// Verify Chinese resistance labels are also centered inside the x=175..269 (mid=222) stone recess
|
||||
drawnLabels.length = 0
|
||||
sheet.lang = 'CHI'
|
||||
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,
|
||||
)
|
||||
for (const zhRes of ['火焰抗性', '冰冷抗性', '闪电抗性', '毒素抗性']) {
|
||||
const match = drawnLabels.find(d => d.text === zhRes)
|
||||
expect(match?.x).toBe(80 + 222)
|
||||
expect(match?.align).toBe('center')
|
||||
}
|
||||
})
|
||||
|
||||
it('wraps mixed CJK + ASCII space paragraphs in Quest Log and NPC Dialog without overflowing maxW', () => {
|
||||
const renderer = new D2FontRenderer()
|
||||
const worldPanels = new WorldPanelsHud()
|
||||
worldPanels.lang = 'CHI'
|
||||
|
||||
const drawnLines: Array<{ text: string; widthPx: number; font: D2FontName }> = []
|
||||
const mockCtx = {
|
||||
drawImage: () => {},
|
||||
fillRect: () => {},
|
||||
strokeRect: () => {},
|
||||
beginPath: () => {},
|
||||
arc: () => {},
|
||||
fill: () => {},
|
||||
stroke: () => {},
|
||||
save: () => {},
|
||||
restore: () => {},
|
||||
measureText: (t: string) => ({ width: [...t].reduce((acc, c) => acc + (c.charCodeAt(0) > 0xff ? 12 : 7), 0) }),
|
||||
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'
|
||||
const widthPx = [...text].reduce((acc, c) => acc + (c.charCodeAt(0) > 0xff ? 12 : 7), 0)
|
||||
drawnLines.push({ text, widthPx, font: f })
|
||||
},
|
||||
measureText: (text: string) =>
|
||||
[...text].reduce((acc, c) => acc + (c.charCodeAt(0) > 0xff ? 12 : 7), 0),
|
||||
} as unknown as D2FontRenderer
|
||||
|
||||
worldPanels.npcMenu = {
|
||||
npcName: '阿卡拉',
|
||||
x: 400,
|
||||
y: 260,
|
||||
talking: true,
|
||||
dialogLines: [
|
||||
'在荒野中有一处极其邪恶的洞穴,目盲之眼修女会的斥候报告那里聚集了大量堕落魔与僵尸。\n[奖励: +1 技能点 (Skill Point) 并解锁修女会营地信任]',
|
||||
],
|
||||
}
|
||||
worldPanels.drawNpcMenu(mockCtx, spyFont, {
|
||||
boxPiecesImg: { width: 256, height: 12, complete: true, naturalWidth: 256 } as unknown as HTMLImageElement,
|
||||
focus16Img: null,
|
||||
})
|
||||
|
||||
const bodyLines = drawnLines.filter(l => l.font === 'fontformal12')
|
||||
expect(bodyLines.length).toBeGreaterThanOrEqual(3)
|
||||
for (const line of bodyLines) {
|
||||
// NPC dialog box maxW is boxW - 36 = 340 - 36 = 304px
|
||||
expect(line.widthPx, `Line "${line.text}" should fit within 304px`).toBeLessThanOrEqual(304)
|
||||
}
|
||||
expect(renderer).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -32,6 +32,10 @@ function createMockCtx() {
|
|||
strokeRect: () => {},
|
||||
drawImage: () => {},
|
||||
beginPath: () => {},
|
||||
closePath: () => {},
|
||||
arc: () => {},
|
||||
fill: () => {},
|
||||
clip: () => {},
|
||||
moveTo: () => {},
|
||||
lineTo: () => {},
|
||||
stroke: () => {},
|
||||
|
|
|
|||
Loading…
Reference in New Issue