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;
|
window.__d2SkipAutoBoot = true;
|
||||||
</script>
|
</script>
|
||||||
<style>
|
<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; }
|
* { box-sizing: border-box; }
|
||||||
html, body {
|
html, body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: #000000;
|
background: #000000;
|
||||||
color: #e8dcc8;
|
color: #e8dcc8;
|
||||||
font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
font: 13px/1.5 var(--font-body);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -548,6 +548,27 @@ async function main(): Promise<void> {
|
||||||
writeFileSync(join(fontsDir, 'D2Formal436.ttf'), formalTtf)
|
writeFileSync(join(fontsDir, 'D2Formal436.ttf'), formalTtf)
|
||||||
|
|
||||||
// 8. Extract Item Inventory Sprites (`data\global\items\inv*.dc6`)
|
// 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 codeToInvFile: Record<string, string> = {}
|
||||||
const uniqueInvFiles = new Set<string>()
|
const uniqueInvFiles = new Set<string>()
|
||||||
|
|
||||||
|
|
@ -597,15 +618,6 @@ async function main(): Promise<void> {
|
||||||
const itemAtlasPixels = stitchFrames(ATLAS_W, atlasH, itemPlacements)
|
const itemAtlasPixels = stitchFrames(ATLAS_W, atlasH, itemPlacements)
|
||||||
savePng('items-atlas.png', ATLAS_W, atlasH, itemAtlasPixels)
|
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 = {
|
const manifest = {
|
||||||
...existingManifest,
|
...existingManifest,
|
||||||
version: '1.13c',
|
version: '1.13c',
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import { parseTable } from '../src/game/tables.ts'
|
||||||
import { loadStringTableChain } from '../src/game/item-names.ts'
|
import { loadStringTableChain } from '../src/game/item-names.ts'
|
||||||
import { encodeIndexedPng } from './png.ts'
|
import { encodeIndexedPng } from './png.ts'
|
||||||
import { packCursorAssets } from './pack-cursor.ts'
|
import { packCursorAssets } from './pack-cursor.ts'
|
||||||
|
import { buildTrueTypeFont } from './build-d2-ttf.ts'
|
||||||
|
|
||||||
interface BlitPlacement {
|
interface BlitPlacement {
|
||||||
readonly frame: SpriteFrame
|
readonly frame: SpriteFrame
|
||||||
|
|
@ -208,12 +209,83 @@ function buildFontAtlas(name: string, dc6: SpriteSheet, tblBytes: Uint8Array): {
|
||||||
const placements: BlitPlacement[] = []
|
const placements: BlitPlacement[] = []
|
||||||
const glyphs: FontGlyphMetric[] = []
|
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++) {
|
for (let i = 0; i < 256; i++) {
|
||||||
const recOff = 12 + i * 14
|
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 metricHeight = recOff + 4 < tblBytes.length ? (tblBytes[recOff + 4] || lineHeight) : lineHeight
|
||||||
const frameIdx = recOff + 8 < tblBytes.length ? (tblBytes[recOff + 8] ?? i) : i
|
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 gx = (i % 16) * cellW
|
||||||
const gy = Math.floor(i / 16) * cellH
|
const gy = Math.floor(i / 16) * cellH
|
||||||
if (frame) {
|
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 {
|
return {
|
||||||
meta: {
|
meta: {
|
||||||
name,
|
name,
|
||||||
|
|
@ -239,7 +321,7 @@ function buildFontAtlas(name: string, dc6: SpriteSheet, tblBytes: Uint8Array): {
|
||||||
atlasHeight,
|
atlasHeight,
|
||||||
glyphs,
|
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))
|
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 ---
|
// --- STAGE 2: Comprehensive Items, Flippy Ground Sprites & Unique Items Packing ---
|
||||||
console.log('Extracting item sprites & flippy ground sprites (including uniqueitems.txt & setitems.txt)...')
|
console.log('Extracting item sprites & flippy ground sprites (including uniqueitems.txt & setitems.txt)...')
|
||||||
const codeToInvFile: Record<string, string> = {}
|
const codeToInvFile: Record<string, string> = {}
|
||||||
|
|
|
||||||
|
|
@ -161,27 +161,27 @@ export class CharacterSheetPanel {
|
||||||
ctx.fillRect(ox, oy, CHAR_PANEL_ORIGIN.width, CHAR_PANEL_ORIGIN.height)
|
ctx.fillRect(ox, oy, CHAR_PANEL_ORIGIN.width, CHAR_PANEL_ORIGIN.height)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Top Header: Character Name & Class
|
// 3. Top Header: Character Name (x=12..181, mid=97) & Class (x=194..309, mid=252)
|
||||||
font.drawText(ctx, this.attrs.name, ox + 90, oy + 21, {
|
font.drawText(ctx, this.attrs.name, ox + 97, oy + 21, {
|
||||||
font: 'fontexocet10',
|
font: 'fontexocet10',
|
||||||
color: 'gold',
|
color: 'gold',
|
||||||
align: 'center',
|
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',
|
font: 'fontexocet10',
|
||||||
color: 'white',
|
color: 'white',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
})
|
})
|
||||||
|
|
||||||
// 4. Level, Experience, Next Level
|
// 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 + 42, oy + 46, { font: isEn ? 'font6' : 'font8', color: 'tan', align: 'center' })
|
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 + 42, oy + 62, { font: 'fontexocet10', color: 'white', 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, isEn ? 'Experience' : '当前经验值', ox + 124, 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, 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, isEn ? 'Next Level' : '升级所需经验', ox + 252, 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, this.attrs.nextLevelXp.toLocaleString(), ox + 252, oy + 62, { font: 'font6', color: 'white', align: 'center' })
|
||||||
|
|
||||||
// 5. Four Primary Attributes (`Str`, `Dex`, `Vit`, `Ene`)
|
// 5. Four Primary Attributes (`Str`, `Dex`, `Vit`, `Ene`)
|
||||||
const statRows: readonly { key: BaseStatKey; label: string; y: number; val: number }[] = [
|
const statRows: readonly { key: BaseStatKey; label: string; y: number; val: number }[] = [
|
||||||
|
|
@ -343,27 +343,28 @@ export class CharacterSheetPanel {
|
||||||
for (const res of resRows) {
|
for (const res of resRows) {
|
||||||
if (isEn) {
|
if (isEn) {
|
||||||
if (res.labelEn.length === 2) {
|
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',
|
font: 'font6',
|
||||||
color: 'tan',
|
color: 'tan',
|
||||||
align: 'center',
|
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',
|
font: 'font6',
|
||||||
color: 'tan',
|
color: 'tan',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
})
|
})
|
||||||
} else {
|
} 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',
|
font: 'font6',
|
||||||
color: 'tan',
|
color: 'tan',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
font.drawText(ctx, res.labelZh, ox + 166, oy + res.y, {
|
font.drawText(ctx, res.labelZh, ox + 222, oy + res.y, {
|
||||||
font: 'font8',
|
font: 'font8',
|
||||||
color: 'tan',
|
color: 'tan',
|
||||||
|
align: 'center',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
font.drawText(ctx, `${res.val}%`, ox + 291, oy + res.y, {
|
font.drawText(ctx, `${res.val}%`, ox + 291, oy + res.y, {
|
||||||
|
|
|
||||||
|
|
@ -2849,37 +2849,12 @@ export class WorldPanelsHud {
|
||||||
|
|
||||||
for (const p of paragraphs) {
|
for (const p of paragraphs) {
|
||||||
if (linesDrawn >= maxLines) break
|
if (linesDrawn >= maxLines) break
|
||||||
if (p.includes(' ')) {
|
const rawTokens = p.match(/\s*[\x21-\xff]+|\s*[^\s\x21-\xff]/g) ?? []
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
let line = ''
|
let line = ''
|
||||||
for (let i = 0; i < p.length; i++) {
|
for (const rawToken of rawTokens) {
|
||||||
const ch = p[i]!
|
const trimmed = rawToken.trimStart()
|
||||||
if (font.measureText(line + ch, fontName) > maxW) {
|
const candidate = line ? `${line}${rawToken.startsWith(' ') ? ' ' : ''}${trimmed}` : trimmed
|
||||||
|
if (line && font.measureText(candidate, fontName) > maxW) {
|
||||||
if (linesDrawn + 1 >= maxLines) {
|
if (linesDrawn + 1 >= maxLines) {
|
||||||
const truncated = line.slice(0, Math.max(0, line.length - 2)) + '...'
|
const truncated = line.slice(0, Math.max(0, line.length - 2)) + '...'
|
||||||
font.drawText(ctx, truncated, x, curY, { font: fontName, color: 'white' })
|
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' })
|
font.drawText(ctx, line, x, curY, { font: fontName, color: 'white' })
|
||||||
linesDrawn++
|
linesDrawn++
|
||||||
line = ch
|
|
||||||
curY += lineH
|
curY += lineH
|
||||||
|
line = trimmed
|
||||||
} else {
|
} 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) {
|
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', () => {
|
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) {
|
for (const file of htmlFiles) {
|
||||||
const content = readFileSync(join(process.cwd(), file), 'utf8')
|
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 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')
|
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(
|
const metricsJson = JSON.parse(
|
||||||
readFileSync(join(process.cwd(), 'public', 'ui', 'fonts', 'metrics.json'), 'utf8'),
|
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.lang = 'ENG'
|
||||||
sheet.visible = true
|
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 = {
|
const mockCtx = {
|
||||||
drawImage: () => {},
|
drawImage: () => {},
|
||||||
fillRect: () => {},
|
fillRect: () => {},
|
||||||
strokeRect: () => {},
|
strokeRect: () => {},
|
||||||
save: () => {},
|
save: () => {},
|
||||||
restore: () => {},
|
restore: () => {},
|
||||||
measureText: (t: string) => ({ width: t.length * 6 }),
|
measureText: (t: string) => ({ width: t.length * 10 }),
|
||||||
fillText: () => {},
|
fillText: () => {},
|
||||||
font: '',
|
font: '',
|
||||||
textBaseline: 'alphabetic',
|
textBaseline: 'alphabetic',
|
||||||
|
|
@ -562,16 +562,18 @@ describe('Diablo II v1.13c Exocet & Formal 436 Fonts (Issue #504)', () => {
|
||||||
drawText: (
|
drawText: (
|
||||||
_ctx: CanvasRenderingContext2D,
|
_ctx: CanvasRenderingContext2D,
|
||||||
text: string,
|
text: string,
|
||||||
_x: number,
|
x: number,
|
||||||
y: number,
|
y: number,
|
||||||
opts?: { font?: D2FontName },
|
opts?: { font?: D2FontName; align?: string },
|
||||||
) => {
|
) => {
|
||||||
const f = opts?.font ?? 'font8'
|
const f = opts?.font ?? 'font8'
|
||||||
drawnLabels.push({
|
drawnLabels.push({
|
||||||
text,
|
text,
|
||||||
font: f,
|
font: f,
|
||||||
widthPx: renderer.measureText(text, f),
|
widthPx: renderer.measureText(text, f),
|
||||||
|
x,
|
||||||
y,
|
y,
|
||||||
|
align: opts?.align ?? 'left',
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
measureText: (text: string, font?: D2FontName) => renderer.measureText(text, font),
|
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,
|
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 = [
|
const compactLabels = [
|
||||||
'Left Attack',
|
'Left Attack',
|
||||||
'Right 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, `Expected CharacterSheetPanel to draw "${labelText}" in English mode`).toBeDefined()
|
||||||
expect(match!.font).toBe('font6')
|
expect(match!.font).toBe('font6')
|
||||||
expect(match!.widthPx).toBeLessThan(renderer.measureText(labelText, 'font8'))
|
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
|
// 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)
|
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 === '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 === '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 === 'Strength')?.y).toBe(60 + 94)
|
||||||
expect(drawnLabels.find(d => d.text === 'Dexterity')?.y).toBe(60 + 156)
|
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 === 'Vitality')?.y).toBe(60 + 242)
|
||||||
expect(drawnLabels.find(d => d.text === 'Energy')?.y).toBe(60 + 304)
|
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: () => {},
|
strokeRect: () => {},
|
||||||
drawImage: () => {},
|
drawImage: () => {},
|
||||||
beginPath: () => {},
|
beginPath: () => {},
|
||||||
|
closePath: () => {},
|
||||||
|
arc: () => {},
|
||||||
|
fill: () => {},
|
||||||
|
clip: () => {},
|
||||||
moveTo: () => {},
|
moveTo: () => {},
|
||||||
lineTo: () => {},
|
lineTo: () => {},
|
||||||
stroke: () => {},
|
stroke: () => {},
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue