947 lines
36 KiB
TypeScript
947 lines
36 KiB
TypeScript
/**
|
|
* Offline FrontEnd / Battle.net / CharSelect / Campfire / Loading Screen Asset Packer.
|
|
*
|
|
* Strictly follows Diablo II v1.13c decompiled binary assembly:
|
|
* - D2Launch.dll (Main Menu 0x6fa598b0, Bnet Login 0x6fa51440, CharCreate 0x6fa5d780, CharSelect 0x6fa4af00/0x6fa627e0)
|
|
* - D2Multi.dll (Battle.net Lobby & Create Game 0x6f9d8c90)
|
|
* - D2Client.dll (10-frame Door Loading Screen 0x6fab24e0)
|
|
* - D2Win.dll & D2Gdi.dll (D2Gfx_DrawChar 0x6f876580..0x6f87658f textShifts & D2WinAnimImage 0x6f8f0a50)
|
|
*
|
|
* Extracts all authentic DC6 sprites, palettes, colored bitmap font atlases,
|
|
* 7-class campfire animation strips, 7-class town-neutral (TN) avatar strips,
|
|
* and English TBL strings into `public/ui/frontend/`.
|
|
*/
|
|
|
|
import { mkdirSync, writeFileSync } from 'node:fs'
|
|
import { join } from 'node:path'
|
|
import { MpqArchive } from '../src/mpq/archive.ts'
|
|
import { fileSource } from '../src/mpq/file-source.ts'
|
|
import { MountedArchives } from '../src/mpq/mount.ts'
|
|
import { decodeDc6 } from '../src/formats/dc6.ts'
|
|
import type { SpriteFrame, SpriteSheet } from '../src/formats/sprite.ts'
|
|
import { applyTransform, decodePl2, type Pl2, type Pl2Transform } from '../src/formats/pl2.ts'
|
|
import { decodeTbl } from '../src/formats/tbl.ts'
|
|
import { loadCharacterSheet } from '../src/game/character.ts'
|
|
import { encodeIndexedPng } from './png.ts'
|
|
import type { FontAtlasMeta, FontGlyphMetric } from './pack-ui-assets.ts'
|
|
|
|
interface BlitPlacement {
|
|
readonly frame: SpriteFrame
|
|
readonly x: number
|
|
readonly y: number
|
|
}
|
|
|
|
export interface PackedStripFrameMeta {
|
|
readonly x: number
|
|
readonly y: number
|
|
readonly w: number
|
|
readonly h: number
|
|
readonly anchorX: number
|
|
readonly anchorY: number
|
|
}
|
|
|
|
export interface PackedAnimStripMeta {
|
|
readonly file: string
|
|
readonly width: number
|
|
readonly height: number
|
|
readonly frameCount: number
|
|
readonly frames: readonly PackedStripFrameMeta[]
|
|
}
|
|
|
|
/**
|
|
* Create an indexed pixel buffer and blit multiple DC6 frames onto it.
|
|
* Index 0 is transparent in DC6 UI sprites.
|
|
*/
|
|
function stitchFrames(
|
|
width: number,
|
|
height: number,
|
|
placements: readonly BlitPlacement[],
|
|
transparentZero = true,
|
|
): Uint8Array {
|
|
const out = new Uint8Array(width * height)
|
|
for (const { frame, x: dstX, y: dstY } of placements) {
|
|
for (let sy = 0; sy < frame.height; sy++) {
|
|
const ty = dstY + sy
|
|
if (ty < 0 || ty >= height) continue
|
|
for (let sx = 0; sx < frame.width; sx++) {
|
|
const tx = dstX + sx
|
|
if (tx < 0 || tx >= width) continue
|
|
const srcIdx = sy * frame.width + sx
|
|
if (transparentZero && frame.mask[srcIdx] === 0) continue
|
|
const color = frame.indices[srcIdx] ?? 0
|
|
if (!transparentZero || color !== 0) {
|
|
out[ty * width + tx] = color
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
/**
|
|
* Stitch a 12-frame (4x3 grid: 256+256+256+32 = 800, 256+256+88 = 600) full-screen DC6 background.
|
|
*/
|
|
function stitch800x600Background(sheet: SpriteSheet): Uint8Array {
|
|
const f = sheet.groups[0]!.frames
|
|
if (f.length < 12) {
|
|
throw new Error(`Expected at least 12 frames for 800x600 background, got ${f.length}`)
|
|
}
|
|
const colX = [0, 256, 512, 768]
|
|
const rowY = [0, 256, 512]
|
|
const placements: BlitPlacement[] = []
|
|
for (let r = 0; r < 3; r++) {
|
|
for (let c = 0; c < 4; c++) {
|
|
placements.push({
|
|
frame: f[r * 4 + c]!,
|
|
x: colX[c]!,
|
|
y: rowY[r]!,
|
|
})
|
|
}
|
|
}
|
|
return stitchFrames(800, 600, placements, false)
|
|
}
|
|
|
|
/**
|
|
* Stitch pairs of horizontally split button frames (e.g. WideButtonBlank 256+16 = 272).
|
|
*/
|
|
function stitchButtonStates(
|
|
sheet: SpriteSheet,
|
|
chunksPerState: number,
|
|
): {
|
|
width: number
|
|
height: number
|
|
pixels: Uint8Array
|
|
stateWidth: number
|
|
stateHeight: number
|
|
stateCount: number
|
|
} {
|
|
const frames = sheet.groups[0]!.frames
|
|
const stateCount = Math.min(2, Math.floor(frames.length / chunksPerState))
|
|
let stateWidth = 0
|
|
for (let c = 0; c < chunksPerState; c++) {
|
|
stateWidth += frames[c]!.width
|
|
}
|
|
const stateHeight = frames[0]!.height
|
|
const totalWidth = stateWidth * stateCount
|
|
const placements: BlitPlacement[] = []
|
|
for (let s = 0; s < stateCount; s++) {
|
|
let curX = s * stateWidth
|
|
for (let c = 0; c < chunksPerState; c++) {
|
|
const frame = frames[s * chunksPerState + c]!
|
|
placements.push({ frame, x: curX, y: 0 })
|
|
curX += frame.width
|
|
}
|
|
}
|
|
return {
|
|
width: totalWidth,
|
|
height: stateHeight,
|
|
pixels: stitchFrames(totalWidth, stateHeight, placements),
|
|
stateWidth,
|
|
stateHeight,
|
|
stateCount,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Composite an optional non-additive overlay SpriteSheet onto a base SpriteSheet frame-by-frame
|
|
* in top-left anchor-relative coordinates (anchorX = left, anchorY = top).
|
|
*/
|
|
function compositeOverlayFrames(
|
|
baseFrames: readonly SpriteFrame[],
|
|
overlayFrames: readonly SpriteFrame[] | null,
|
|
): SpriteFrame[] {
|
|
const count = baseFrames.length
|
|
const result: SpriteFrame[] = []
|
|
for (let i = 0; i < count; i++) {
|
|
const base = baseFrames[i]!
|
|
const ov = overlayFrames && i < overlayFrames.length ? overlayFrames[i]! : null
|
|
if (!ov) {
|
|
result.push(base)
|
|
continue
|
|
}
|
|
const baseLeft = base.anchorX ?? 0
|
|
const baseTop = base.anchorY ?? 0
|
|
const baseRight = baseLeft + base.width
|
|
const baseBottom = baseTop + base.height
|
|
|
|
const ovLeft = ov.anchorX ?? 0
|
|
const ovTop = ov.anchorY ?? 0
|
|
const ovRight = ovLeft + ov.width
|
|
const ovBottom = ovTop + ov.height
|
|
|
|
const minLeft = Math.min(baseLeft, ovLeft)
|
|
const minTop = Math.min(baseTop, ovTop)
|
|
const maxRight = Math.max(baseRight, ovRight)
|
|
const maxBottom = Math.max(baseBottom, ovBottom)
|
|
|
|
const unionW = Math.max(1, maxRight - minLeft)
|
|
const unionH = Math.max(1, maxBottom - minTop)
|
|
const indices = new Uint8Array(unionW * unionH)
|
|
const mask = new Uint8Array(unionW * unionH)
|
|
|
|
// Blit base
|
|
const bx0 = baseLeft - minLeft
|
|
const by0 = baseTop - minTop
|
|
for (let sy = 0; sy < base.height; sy++) {
|
|
for (let sx = 0; sx < base.width; sx++) {
|
|
const sIdx = sy * base.width + sx
|
|
if (base.mask[sIdx] === 0) continue
|
|
const c = base.indices[sIdx] ?? 0
|
|
if (c === 0) continue
|
|
const dIdx = (by0 + sy) * unionW + (bx0 + sx)
|
|
indices[dIdx] = c
|
|
mask[dIdx] = 1
|
|
}
|
|
}
|
|
|
|
// Blit overlay
|
|
const ox0 = ovLeft - minLeft
|
|
const oy0 = ovTop - minTop
|
|
for (let sy = 0; sy < ov.height; sy++) {
|
|
for (let sx = 0; sx < ov.width; sx++) {
|
|
const sIdx = sy * ov.width + sx
|
|
if (ov.mask[sIdx] === 0) continue
|
|
const srcColor = ov.indices[sIdx] ?? 0
|
|
if (srcColor === 0) continue
|
|
const dIdx = (oy0 + sy) * unionW + (ox0 + sx)
|
|
indices[dIdx] = srcColor
|
|
mask[dIdx] = 1
|
|
}
|
|
}
|
|
|
|
result.push({
|
|
width: unionW,
|
|
height: unionH,
|
|
anchorX: minLeft,
|
|
anchorY: minTop,
|
|
indices,
|
|
mask,
|
|
})
|
|
}
|
|
return result
|
|
}
|
|
|
|
/**
|
|
* Pack a sequence of SpriteFrames into a grid sprite sheet (max width ~2048) to keep texture sizes compact.
|
|
*/
|
|
function packFramesToGrid(
|
|
file: string,
|
|
frames: readonly SpriteFrame[],
|
|
maxRowWidth = 2048,
|
|
): {
|
|
meta: PackedAnimStripMeta
|
|
pixels: Uint8Array
|
|
} {
|
|
let curX = 0
|
|
let curY = 0
|
|
let rowH = 0
|
|
let maxW = 1
|
|
const placements: BlitPlacement[] = []
|
|
const frameMetas: PackedStripFrameMeta[] = []
|
|
|
|
for (const frame of frames) {
|
|
if (curX > 0 && curX + frame.width > maxRowWidth) {
|
|
curX = 0
|
|
curY += rowH + 1
|
|
rowH = 0
|
|
}
|
|
placements.push({ frame, x: curX, y: curY })
|
|
frameMetas.push({
|
|
x: curX,
|
|
y: curY,
|
|
w: frame.width,
|
|
h: frame.height,
|
|
anchorX: frame.anchorX ?? 0,
|
|
anchorY: frame.anchorY ?? 0,
|
|
})
|
|
curX += frame.width + 1
|
|
if (curX > maxW) maxW = curX
|
|
if (frame.height > rowH) rowH = frame.height
|
|
}
|
|
|
|
const totalH = Math.max(1, curY + rowH)
|
|
const pixels = stitchFrames(maxW, totalH, placements)
|
|
return {
|
|
meta: {
|
|
file,
|
|
width: maxW,
|
|
height: totalH,
|
|
frameCount: frames.length,
|
|
frames: frameMetas,
|
|
},
|
|
pixels,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build a bitmap font atlas from `data\local\font\latin\font*.dc6` and `.tbl`.
|
|
*/
|
|
function buildFontAtlas(
|
|
name: string,
|
|
dc6: SpriteSheet,
|
|
tblBytes: Uint8Array,
|
|
): {
|
|
meta: FontAtlasMeta
|
|
pixels: Uint8Array
|
|
} {
|
|
const magic = new TextDecoder().decode(tblBytes.slice(0, 4))
|
|
if (magic !== 'Woo!') {
|
|
throw new Error(`Invalid font tbl header for ${name}: ${magic}`)
|
|
}
|
|
const lineHeight = tblBytes[10] || 14
|
|
const capHeight = tblBytes[11] || 16
|
|
const frames = dc6.groups[0]!.frames
|
|
const cellW = Math.max(16, ...frames.map(f => f.width))
|
|
const cellH = Math.max(16, ...frames.map(f => f.height))
|
|
const atlasWidth = cellW * 16
|
|
const atlasHeight = cellH * 16
|
|
|
|
const placements: BlitPlacement[] = []
|
|
const glyphs: FontGlyphMetric[] = []
|
|
|
|
for (let i = 0; i < 256; i++) {
|
|
const recOff = 12 + i * 14
|
|
const 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]
|
|
const gx = (i % 16) * cellW
|
|
const gy = Math.floor(i / 16) * cellH
|
|
if (frame) {
|
|
placements.push({ frame, x: gx, y: gy })
|
|
}
|
|
glyphs.push({
|
|
code: i,
|
|
width: metricWidth,
|
|
height: metricHeight,
|
|
x: gx,
|
|
y: gy,
|
|
frameW: frame?.width ?? 0,
|
|
frameH: frame?.height ?? 0,
|
|
})
|
|
}
|
|
|
|
return {
|
|
meta: {
|
|
name,
|
|
lineHeight,
|
|
capHeight,
|
|
atlasWidth,
|
|
atlasHeight,
|
|
glyphs,
|
|
},
|
|
pixels: stitchFrames(atlasWidth, atlasHeight, placements),
|
|
}
|
|
}
|
|
|
|
async function main(): Promise<void> {
|
|
const t0 = performance.now()
|
|
console.log('Mounting Diablo II v1.13c MPQ archives...')
|
|
const archives = new MountedArchives()
|
|
for (const m of ['d2char.mpq', 'd2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) {
|
|
archives.add(m, await MpqArchive.open(await fileSource(`samples/d2/${m}`)))
|
|
}
|
|
|
|
const skyPl2 = decodePl2(await archives.read('data\\global\\palette\\sky\\pal.pl2'))
|
|
const fecharPl2 = decodePl2(await archives.read('data\\global\\palette\\fechar\\pal.pl2'))
|
|
const act1Pl2 = decodePl2(await archives.read('data\\global\\palette\\act1\\pal.pl2'))
|
|
const loadingPl2Bytes = await archives.read('data\\global\\palette\\loading\\pal.pl2')
|
|
const loadingRgb = new Uint8Array(256 * 3)
|
|
for (let i = 0; i < 256; i++) {
|
|
loadingRgb[i * 3] = loadingPl2Bytes[i * 4] ?? 0
|
|
loadingRgb[i * 3 + 1] = loadingPl2Bytes[i * 4 + 1] ?? 0
|
|
loadingRgb[i * 3 + 2] = loadingPl2Bytes[i * 4 + 2] ?? 0
|
|
}
|
|
|
|
const outDir = join(process.cwd(), 'public', 'ui', 'frontend')
|
|
const fontsDir = join(outDir, 'fonts')
|
|
const campfireDir = join(outDir, 'campfire')
|
|
const avatarsDir = join(outDir, 'avatars')
|
|
mkdirSync(outDir, { recursive: true })
|
|
mkdirSync(fontsDir, { recursive: true })
|
|
mkdirSync(campfireDir, { recursive: true })
|
|
mkdirSync(avatarsDir, { recursive: true })
|
|
|
|
let decodedCount = 0
|
|
const decodeUiDc6 = async (path: string): Promise<SpriteSheet> => {
|
|
const bytes = await archives.read(path)
|
|
const sheet = decodeDc6(bytes)
|
|
decodedCount++
|
|
for (const g of sheet.groups) {
|
|
for (const f of g.frames) {
|
|
;(f as { anchorX?: number; anchorY?: number }).anchorX = f.offsetX
|
|
;(f as { anchorX?: number; anchorY?: number }).anchorY = f.offsetY - f.height
|
|
}
|
|
}
|
|
return sheet
|
|
}
|
|
|
|
const savePng = (
|
|
relPath: string,
|
|
width: number,
|
|
height: number,
|
|
pixels: Uint8Array,
|
|
palette: Uint8Array,
|
|
transparent = true,
|
|
): void => {
|
|
const png = encodeIndexedPng({
|
|
width,
|
|
height,
|
|
pixels,
|
|
palette,
|
|
transparentIndex: transparent ? 0 : undefined,
|
|
})
|
|
writeFileSync(join(outDir, relPath), png)
|
|
}
|
|
|
|
// ============================================================================
|
|
// 1. 800x600 Full-Screen Backgrounds
|
|
// ============================================================================
|
|
console.log('Packing 800x600 full-screen backgrounds...')
|
|
const mainBgDc6 = await decodeUiDc6('data\\global\\ui\\FrontEnd\\gameselectscreenEXP.dc6')
|
|
const mainBgPixels = stitch800x600Background(mainBgDc6)
|
|
savePng('bg-main-menu.png', 800, 600, mainBgPixels, skyPl2.rgb, false)
|
|
|
|
const charCreateBgDc6 = await decodeUiDc6('data\\global\\ui\\FrontEnd\\charactercreationscreenEXP.dc6')
|
|
const charCreateBgPixels = stitch800x600Background(charCreateBgDc6)
|
|
savePng('bg-char-create.png', 800, 600, charCreateBgPixels, fecharPl2.rgb, false)
|
|
|
|
const charSelectBgDc6 = await decodeUiDc6('data\\global\\ui\\CharSelect\\characterselectscreenEXP.dc6')
|
|
const charSelectBgPixels = stitch800x600Background(charSelectBgDc6)
|
|
savePng('bg-char-select.png', 800, 600, charSelectBgPixels, skyPl2.rgb, false)
|
|
|
|
const waitingRoomBgDc6 = await decodeUiDc6('data\\global\\ui\\BIGMENU\\waitingroombkgd.dc6')
|
|
const waitingRoomBgPixels = stitch800x600Background(waitingRoomBgDc6)
|
|
savePng('bg-waiting-room.png', 800, 600, waitingRoomBgPixels, act1Pl2.rgb, false)
|
|
|
|
// ============================================================================
|
|
// 2. Dialogs, Popups & Selection Boxes
|
|
// ============================================================================
|
|
console.log('Packing dialogs, popups, and buttons...')
|
|
const popupDc6 = await decodeUiDc6('data\\global\\ui\\FrontEnd\\PopUp_340x224.dc6')
|
|
const pf = popupDc6.groups[0]!.frames
|
|
const popupPixels = stitchFrames(340, 224, [
|
|
{ frame: pf[0]!, x: 0, y: 0 },
|
|
{ frame: pf[1]!, x: 256, y: 0 },
|
|
], false)
|
|
savePng('popup-340x224.png', 340, 224, popupPixels, skyPl2.rgb, false)
|
|
|
|
const createGameBgDc6 = await decodeUiDc6('data\\global\\ui\\BIGMENU\\creategamebckg.dc6')
|
|
const cgf = createGameBgDc6.groups[0]!.frames
|
|
const createGameBgPixels = stitchFrames(373, 373, [
|
|
{ frame: cgf[0]!, x: 0, y: 0 },
|
|
{ frame: cgf[1]!, x: 256, y: 0 },
|
|
{ frame: cgf[2]!, x: 0, y: 256 },
|
|
{ frame: cgf[3]!, x: 256, y: 256 },
|
|
], false)
|
|
savePng('create-game-bg.png', 373, 373, createGameBgPixels, act1Pl2.rgb, false)
|
|
|
|
const charSelectBoxDc6 = await decodeUiDc6('data\\global\\ui\\CharSelect\\charselectbox.dc6')
|
|
const csbf = charSelectBoxDc6.groups[0]!.frames
|
|
const charSelectBoxPixels = stitchFrames(272, 93, [
|
|
{ frame: csbf[0]!, x: 0, y: 0 },
|
|
{ frame: csbf[1]!, x: 256, y: 0 },
|
|
], true)
|
|
savePng('charselectbox.png', 272, 93, charSelectBoxPixels, skyPl2.rgb, true)
|
|
|
|
const realmSelectDc6 = await decodeUiDc6('data\\global\\ui\\CharSelect\\Realmselect.dc6')
|
|
const rsf = realmSelectDc6.groups[0]!.frames[0]!
|
|
savePng('realmselect.png', rsf.width, rsf.height, rsf.indices, skyPl2.rgb, true)
|
|
|
|
const realmBtnDc6 = await decodeUiDc6('data\\global\\ui\\CharSelect\\RealmSelectbuttonthin.dc6')
|
|
const realmBtnStrip = stitchButtonStates(realmBtnDc6, 1)
|
|
savePng('btn-realm-thin.png', realmBtnStrip.width, realmBtnStrip.height, realmBtnStrip.pixels, skyPl2.rgb, true)
|
|
|
|
// ============================================================================
|
|
// 3. Buttons, Textboxes, Checkboxes, Radio Buttons & Chat Gem
|
|
// ============================================================================
|
|
const wideBtnDc6 = await decodeUiDc6('data\\global\\ui\\FrontEnd\\WideButtonBlank.dc6')
|
|
const wideBtnStrip = stitchButtonStates(wideBtnDc6, 2) // 272x35 x 2 states = 544x35
|
|
savePng('btn-wide.png', wideBtnStrip.width, wideBtnStrip.height, wideBtnStrip.pixels, skyPl2.rgb, true)
|
|
|
|
const wide3BtnDc6 = await decodeUiDc6('data\\global\\ui\\FrontEnd\\3WideButtonBlank.dc6')
|
|
const wide3BtnStrip = stitchButtonStates(wide3BtnDc6, 2) // 272x35 x 2 states = 544x35
|
|
savePng('btn-3wide.png', wide3BtnStrip.width, wide3BtnStrip.height, wide3BtnStrip.pixels, skyPl2.rgb, true)
|
|
|
|
const wide02BtnDc6 = await decodeUiDc6('data\\global\\ui\\FrontEnd\\WideButtonBlank02.dc6')
|
|
const wide02BtnStrip = stitchButtonStates(wide02BtnDc6, 2) // 272x25 x 2 states = 544x25
|
|
savePng('btn-wide02.png', wide02BtnStrip.width, wide02BtnStrip.height, wide02BtnStrip.pixels, skyPl2.rgb, true)
|
|
|
|
const medBtnDc6 = await decodeUiDc6('data\\global\\ui\\FrontEnd\\MediumButtonBlank.dc6')
|
|
const medBtnStrip = stitchButtonStates(medBtnDc6, 1) // 128x35 x 2 states = 256x35
|
|
savePng('btn-medium.png', medBtnStrip.width, medBtnStrip.height, medBtnStrip.pixels, skyPl2.rgb, true)
|
|
|
|
const shortBtnDc6 = await decodeUiDc6('data\\global\\ui\\FrontEnd\\NarrowButtonBlank.dc6')
|
|
const shortBtnStrip = stitchButtonStates(shortBtnDc6, 1) // 135x25 x 2 states = 270x25
|
|
savePng('btn-short.png', shortBtnStrip.width, shortBtnStrip.height, shortBtnStrip.pixels, skyPl2.rgb, true)
|
|
|
|
const tallBtnDc6 = await decodeUiDc6('data\\global\\ui\\CharSelect\\TallButtonBlank.dc6')
|
|
const tallBtnStrip = stitchButtonStates(tallBtnDc6, 1) // 168x60 x 2 states = 336x60
|
|
savePng('btn-tall.png', tallBtnStrip.width, tallBtnStrip.height, tallBtnStrip.pixels, skyPl2.rgb, true)
|
|
|
|
const popupOkCancelDc6 = await decodeUiDc6('data\\global\\ui\\FrontEnd\\PopUpOkCancel2.dc6')
|
|
const pocf = popupOkCancelDc6.groups[0]!.frames
|
|
const popupOkCancelPixels = stitchFrames(264, 176, [
|
|
{ frame: pocf[0]!, x: 0, y: 0 },
|
|
{ frame: pocf[1]!, x: 256, y: 0 },
|
|
], false)
|
|
savePng('popup-okcancel.png', 264, 176, popupOkCancelPixels, skyPl2.rgb, false)
|
|
|
|
const textboxDc6 = await decodeUiDc6('data\\global\\ui\\FrontEnd\\textbox2.dc6')
|
|
const tbFrame = textboxDc6.groups[0]!.frames[0]!
|
|
savePng('textbox.png', tbFrame.width, tbFrame.height, tbFrame.indices, skyPl2.rgb, true)
|
|
|
|
const clickboxDc6 = await decodeUiDc6('data\\global\\ui\\FrontEnd\\clickbox.dc6')
|
|
const clickboxStrip = stitchButtonStates(clickboxDc6, 1) // 15x16 x 2 states = 30x16
|
|
savePng('clickbox.png', clickboxStrip.width, clickboxStrip.height, clickboxStrip.pixels, fecharPl2.rgb, true)
|
|
|
|
const joinClickboxDc6 = await decodeUiDc6('data\\global\\ui\\BIGMENU\\joingameclickbox.dc6')
|
|
const joinClickboxStrip = stitchButtonStates(joinClickboxDc6, 1)
|
|
savePng('joingameclickbox.png', joinClickboxStrip.width, joinClickboxStrip.height, joinClickboxStrip.pixels, act1Pl2.rgb, true)
|
|
|
|
const radioDc6 = await decodeUiDc6('data\\global\\ui\\BIGMENU\\radiobutton.dc6')
|
|
const radioStrip = stitchButtonStates(radioDc6, 1) // 16x16 x 2 states = 32x16
|
|
savePng('radiobutton.png', radioStrip.width, radioStrip.height, radioStrip.pixels, act1Pl2.rgb, true)
|
|
|
|
const gemBtnDc6 = await decodeUiDc6('data\\global\\ui\\BIGMENU\\gembutton.dc6')
|
|
const gemBtnStrip = stitchButtonStates(gemBtnDc6, 1) // 2 states
|
|
savePng('gembutton.png', gemBtnStrip.width, gemBtnStrip.height, gemBtnStrip.pixels, act1Pl2.rgb, true)
|
|
|
|
const chatLeftBtnsDc6 = await decodeUiDc6('data\\global\\ui\\BIGMENU\\chatleftbuttons.dc6')
|
|
const chatLeftBtnsStrip = stitchButtonStates(chatLeftBtnsDc6, 1) // 80x20 x 2 states
|
|
savePng('btn-chatleft.png', chatLeftBtnsStrip.width, chatLeftBtnsStrip.height, chatLeftBtnsStrip.pixels, act1Pl2.rgb, true)
|
|
|
|
const chatRightTopBtnsDc6 = await decodeUiDc6('data\\global\\ui\\BIGMENU\\chatrighttopbuttons.dc6')
|
|
const chatRightTopBtnsStrip = stitchButtonStates(chatRightTopBtnsDc6, 1) // 119x20 x 2 states
|
|
savePng('btn-chatrighttop.png', chatRightTopBtnsStrip.width, chatRightTopBtnsStrip.height, chatRightTopBtnsStrip.pixels, act1Pl2.rgb, true)
|
|
|
|
const chatRightBtnsDc6 = await decodeUiDc6('data\\global\\ui\\BIGMENU\\chatrightbuttons.dc6')
|
|
const chatRightBtnsStrip = stitchButtonStates(chatRightBtnsDc6, 1) // 79x20 x 2 states
|
|
savePng('btn-chatright.png', chatRightBtnsStrip.width, chatRightBtnsStrip.height, chatRightBtnsStrip.pixels, act1Pl2.rgb, true)
|
|
|
|
const gameBtnDc6 = await decodeUiDc6('data\\global\\ui\\BIGMENU\\GameButtonBlank.dc6')
|
|
const gameBtnStrip = stitchButtonStates(gameBtnDc6, 1) // 172x32 x 2 states
|
|
savePng('btn-game.png', gameBtnStrip.width, gameBtnStrip.height, gameBtnStrip.pixels, act1Pl2.rgb, true)
|
|
|
|
const btnCancelDc6 = await decodeUiDc6('data\\global\\ui\\BIGMENU\\CancelButtonBlank.dc6')
|
|
const btnCancelStrip = stitchButtonStates(btnCancelDc6, 1) // 96x32 x 2 states
|
|
savePng('btn-create-cancel.png', btnCancelStrip.width, btnCancelStrip.height, btnCancelStrip.pixels, act1Pl2.rgb, true)
|
|
|
|
const btnCreateGameDc6 = await decodeUiDc6('data\\global\\ui\\BIGMENU\\creategamebutton.dc6')
|
|
const btnCreateGameStrip = stitchButtonStates(btnCreateGameDc6, 1) // 144x36 x 2 states
|
|
savePng('btn-create-game.png', btnCreateGameStrip.width, btnCreateGameStrip.height, btnCreateGameStrip.pixels, act1Pl2.rgb, true)
|
|
|
|
const greyLevelBoxDc6 = await decodeUiDc6('data\\global\\ui\\BIGMENU\\GreyLevelBox.dc6')
|
|
const glbFrame = greyLevelBoxDc6.groups[0]!.frames[0]!
|
|
savePng('grey-level-box.png', glbFrame.width, glbFrame.height, glbFrame.indices, act1Pl2.rgb, true)
|
|
|
|
// ============================================================================
|
|
// 4. Burning Diablo II Logos (Main Menu & Battle.net Lobby) + Campfire Flames
|
|
// ============================================================================
|
|
console.log('Packing burning Diablo II logos and campfire flame animations...')
|
|
// Main Menu Burning Logo at control anchor (400, 120) on gameselectscreenEXP.dc6
|
|
// Note: D2WinAnimImage with ptAnimTable == NULL subtracts frame0.offset from each frame!
|
|
const logoBlackLDc6 = await decodeUiDc6('data\\global\\ui\\FrontEnd\\D2logoBlackLeft.DC6')
|
|
const logoBlackRDc6 = await decodeUiDc6('data\\global\\ui\\FrontEnd\\D2logoBlackRight.DC6')
|
|
const logoFireLDc6 = await decodeUiDc6('data\\global\\ui\\FrontEnd\\D2logoFireLeft.DC6')
|
|
const logoFireRDc6 = await decodeUiDc6('data\\global\\ui\\FrontEnd\\D2logoFireRight.DC6')
|
|
|
|
const animStrips: Record<string, PackedAnimStripMeta> = {}
|
|
|
|
{
|
|
const blackFrames = compositeOverlayFrames(
|
|
logoBlackLDc6.groups[0]!.frames,
|
|
logoBlackRDc6.groups[0]!.frames,
|
|
)
|
|
const packedBlack = packFramesToGrid('logo-main-black.png', blackFrames, 2048)
|
|
savePng(packedBlack.meta.file, packedBlack.meta.width, packedBlack.meta.height, packedBlack.pixels, skyPl2.rgb, true)
|
|
animStrips['logoMainBlack'] = packedBlack.meta
|
|
|
|
const fireFrames = compositeOverlayFrames(
|
|
logoFireLDc6.groups[0]!.frames,
|
|
logoFireRDc6.groups[0]!.frames,
|
|
)
|
|
const packedFire = packFramesToGrid('logo-main-fire.png', fireFrames, 2048)
|
|
savePng(packedFire.meta.file, packedFire.meta.width, packedFire.meta.height, packedFire.pixels, skyPl2.rgb, true)
|
|
animStrips['logoMainFire'] = packedFire.meta
|
|
}
|
|
|
|
// Battle.net Small Burning Logo at control anchor (84, 52) on waitingroombkgd.dc6
|
|
const bnetBlackDc6 = await decodeUiDc6('data\\global\\ui\\BIGMENU\\D2logoBlackBnet.dc6')
|
|
const bnetFireDc6 = await decodeUiDc6('data\\global\\ui\\BIGMENU\\D2logoBnet.dc6')
|
|
{
|
|
const packedBlack = packFramesToGrid('logo-bnet-black.png', bnetBlackDc6.groups[0]!.frames, 2048)
|
|
savePng(packedBlack.meta.file, packedBlack.meta.width, packedBlack.meta.height, packedBlack.pixels, skyPl2.rgb, true)
|
|
animStrips['logoBnetBlack'] = packedBlack.meta
|
|
|
|
const packedFire = packFramesToGrid('logo-bnet-fire.png', bnetFireDc6.groups[0]!.frames, 2048)
|
|
savePng(packedFire.meta.file, packedFire.meta.width, packedFire.meta.height, packedFire.pixels, skyPl2.rgb, true)
|
|
animStrips['logoBnetFire'] = packedFire.meta
|
|
}
|
|
|
|
// Campfire `fire.dc6` at control anchor (345, 454) on charactercreationscreenEXP.dc6
|
|
const fireDc6 = await decodeUiDc6('data\\global\\ui\\FrontEnd\\fire.dc6')
|
|
{
|
|
const packed = packFramesToGrid('campfire/fire-strip.png', fireDc6.groups[0]!.frames, 2048)
|
|
savePng(packed.meta.file, packed.meta.width, packed.meta.height, packed.pixels, fecharPl2.rgb, true)
|
|
animStrips['campfireFlame'] = packed.meta
|
|
}
|
|
|
|
// ============================================================================
|
|
// 5. 7-Class Campfire Multi-State Animations (fechar/pal.pl2)
|
|
// ============================================================================
|
|
console.log('Packing 7-class campfire multi-state animations...')
|
|
// Per D2Launch.dll 0x6fa68df8 & 0x6fa5d780:
|
|
// Note: ptAnimTable != NULL does NOT subtract frame0.offset!
|
|
interface ClassCampfireSpec {
|
|
readonly id: string
|
|
readonly dir: string
|
|
readonly prefix: string
|
|
readonly states: readonly {
|
|
readonly key: string
|
|
readonly base: string
|
|
readonly overlay?: string
|
|
readonly additiveOverlay?: boolean
|
|
}[]
|
|
}
|
|
|
|
const CAMPFIRE_SPECS: readonly ClassCampfireSpec[] = [
|
|
{
|
|
id: 'am',
|
|
dir: 'amazon',
|
|
prefix: 'am',
|
|
states: [
|
|
{ key: 'nu1', base: 'amnu1.dc6' },
|
|
{ key: 'nu2', base: 'amnu2.dc6' },
|
|
{ key: 'nu3', base: 'amnu3.dc6' },
|
|
{ key: 'fw', base: 'amfw.dc6', overlay: 'amfws.dc6', additiveOverlay: false },
|
|
{ key: 'bw', base: 'ambw.dc6' },
|
|
],
|
|
},
|
|
{
|
|
id: 'as',
|
|
dir: 'assassin',
|
|
prefix: 'as',
|
|
states: [
|
|
{ key: 'nu1', base: 'asnu1.dc6' },
|
|
{ key: 'nu2', base: 'asnu2.dc6' },
|
|
{ key: 'nu3', base: 'asnu3.dc6' },
|
|
{ key: 'fw', base: 'asfw.dc6' },
|
|
{ key: 'bw', base: 'asbw.dc6' },
|
|
],
|
|
},
|
|
{
|
|
id: 'ne',
|
|
dir: 'necromancer',
|
|
prefix: 'ne',
|
|
states: [
|
|
{ key: 'nu1', base: 'nenu1.dc6' },
|
|
{ key: 'nu2', base: 'nenu2.dc6' },
|
|
{ key: 'nu3', base: 'nenu3.dc6', overlay: 'nenu3s.dc6', additiveOverlay: true },
|
|
{ key: 'fw', base: 'nefw.dc6', overlay: 'nefws.dc6', additiveOverlay: true },
|
|
{ key: 'bw', base: 'nebw.dc6', overlay: 'nebws.dc6', additiveOverlay: true },
|
|
],
|
|
},
|
|
{
|
|
id: 'ba',
|
|
dir: 'barbarian',
|
|
prefix: 'ba',
|
|
states: [
|
|
{ key: 'nu1', base: 'banu1.dc6' },
|
|
{ key: 'nu2', base: 'banu2.dc6' },
|
|
{ key: 'nu3', base: 'banu3.dc6' },
|
|
{ key: 'fw', base: 'bafw.dc6', overlay: 'bafws.dc6', additiveOverlay: false },
|
|
{ key: 'bw', base: 'babw.dc6' },
|
|
],
|
|
},
|
|
{
|
|
id: 'pa',
|
|
dir: 'paladin',
|
|
prefix: 'pa',
|
|
states: [
|
|
{ key: 'nu1', base: 'panu1.dc6' },
|
|
{ key: 'nu2', base: 'panu2.dc6' },
|
|
{ key: 'nu3', base: 'panu3.dc6' },
|
|
{ key: 'fw', base: 'pafw.dc6', overlay: 'pafws.dc6', additiveOverlay: false },
|
|
{ key: 'bw', base: 'pabw.dc6' },
|
|
],
|
|
},
|
|
{
|
|
id: 'so',
|
|
dir: 'sorceress',
|
|
prefix: 'so',
|
|
states: [
|
|
{ key: 'nu1', base: 'sonu1.dc6' },
|
|
{ key: 'nu2', base: 'sonu2.dc6' },
|
|
{ key: 'nu3', base: 'sonu3.dc6', overlay: 'sonu3s.dc6', additiveOverlay: true },
|
|
{ key: 'fw', base: 'sofw.dc6', overlay: 'sofws.dc6', additiveOverlay: true },
|
|
{ key: 'bw', base: 'sobw.dc6', overlay: 'sobws.dc6', additiveOverlay: true },
|
|
],
|
|
},
|
|
{
|
|
id: 'dz',
|
|
dir: 'druid',
|
|
prefix: 'dz',
|
|
states: [
|
|
{ key: 'nu1', base: 'dznu1.dc6' },
|
|
{ key: 'nu2', base: 'dznu2.dc6' },
|
|
{ key: 'nu3', base: 'dznu3.dc6' },
|
|
{ key: 'fw', base: 'dzfw.dc6' },
|
|
{ key: 'bw', base: 'dzbw.dc6' },
|
|
],
|
|
},
|
|
]
|
|
|
|
const campfireClasses: Record<string, Record<string, PackedAnimStripMeta>> = {}
|
|
for (const cls of CAMPFIRE_SPECS) {
|
|
campfireClasses[cls.id] = {}
|
|
for (const st of cls.states) {
|
|
const basePath = `data\\global\\ui\\FrontEnd\\${cls.dir}\\${st.base}`
|
|
const baseDc6 = await decodeUiDc6(basePath)
|
|
const ovDc6 = st.overlay
|
|
? await decodeUiDc6(`data\\global\\ui\\FrontEnd\\${cls.dir}\\${st.overlay}`)
|
|
: null
|
|
|
|
if (ovDc6 && !st.additiveOverlay) {
|
|
const composited = compositeOverlayFrames(baseDc6.groups[0]!.frames, ovDc6.groups[0]!.frames)
|
|
const relFile = `campfire/${cls.id}-${st.key}.png`
|
|
const packed = packFramesToGrid(relFile, composited, 2048)
|
|
savePng(relFile, packed.meta.width, packed.meta.height, packed.pixels, fecharPl2.rgb, true)
|
|
campfireClasses[cls.id]![st.key] = packed.meta
|
|
} else {
|
|
const relFile = `campfire/${cls.id}-${st.key}.png`
|
|
const packed = packFramesToGrid(relFile, baseDc6.groups[0]!.frames, 2048)
|
|
savePng(relFile, packed.meta.width, packed.meta.height, packed.pixels, fecharPl2.rgb, true)
|
|
campfireClasses[cls.id]![st.key] = packed.meta
|
|
|
|
if (ovDc6 && st.additiveOverlay) {
|
|
const ovRelFile = `campfire/${cls.id}-${st.key}-ov.png`
|
|
const ovPacked = packFramesToGrid(ovRelFile, ovDc6.groups[0]!.frames, 2048)
|
|
savePng(ovRelFile, ovPacked.meta.width, ovPacked.meta.height, ovPacked.pixels, fecharPl2.rgb, true)
|
|
campfireClasses[cls.id]![`${st.key}_ov`] = ovPacked.meta
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// 6. 10-Frame Door Opening Loading Screen (`Loadingscreen.dc6` + `loading/pal.pl2`)
|
|
// ============================================================================
|
|
console.log('Packing 10-frame Door Opening Loading Screen...')
|
|
const loadingDc6 = await decodeUiDc6('data\\global\\ui\\Loading\\Loadingscreen.dc6')
|
|
const loadingPacked = packFramesToGrid('loading-door-strip.png', loadingDc6.groups[0]!.frames, 2560)
|
|
savePng(
|
|
loadingPacked.meta.file,
|
|
loadingPacked.meta.width,
|
|
loadingPacked.meta.height,
|
|
loadingPacked.pixels,
|
|
loadingRgb,
|
|
false,
|
|
)
|
|
animStrips['loadingDoor'] = loadingPacked.meta
|
|
|
|
// ============================================================================
|
|
// 7. 7-Class Town-Neutral (`TN`) Character Selection Avatars (Direction 0 = South)
|
|
// ============================================================================
|
|
console.log('Packing 7-class Town-Neutral (TN) Character Selection avatar strips...')
|
|
const AVATAR_TOKENS = ['am', 'ai', 'ne', 'ba', 'pa', 'so', 'dz'] as const
|
|
const charAvatars: Record<string, PackedAnimStripMeta> = {}
|
|
for (const token of AVATAR_TOKENS) {
|
|
const tnSheet = await loadCharacterSheet(archives, token, 'tn', 'hth', undefined, true)
|
|
// Group 0 in loadCharacterSheet is direction 0 (dir64=0 -> dccDir=4 = straight South towards camera)
|
|
const southFrames = tnSheet.sheet.groups[0]!.frames
|
|
const relFile = `avatars/char-${token}-tn.png`
|
|
const packed = packFramesToGrid(relFile, southFrames, 2048)
|
|
savePng(relFile, packed.meta.width, packed.meta.height, packed.pixels, skyPl2.rgb, true)
|
|
charAvatars[token] = packed.meta
|
|
}
|
|
|
|
// ============================================================================
|
|
// 8. Colored Bitmap Font Atlases (`D2Gfx_DrawChar` 0x6f876580..0x6f87658f)
|
|
// ============================================================================
|
|
// Color 0 = Base skyPl2.rgb (White / Bright Engraved)
|
|
// Color 1..12 = applyTransform(skyPl2.rgb, skyPl2.textShifts[color])
|
|
// 1 = Red (Hardcore / Error)
|
|
// 2 = Bright Green (Expansion Character)
|
|
// 3 = Blue
|
|
// 4 = Gold / Bronze (Engraved Button Labels, Titles, Character Names)
|
|
// 5 = Dark Gray (Disabled Button Labels)
|
|
// 7 = Tan
|
|
// 8 = Orange
|
|
// 9 = Yellow
|
|
console.log('Packing multi-shade chiseled bitmap font atlases...')
|
|
const FONT_NAMES = [
|
|
'font6',
|
|
'font8',
|
|
'font16',
|
|
'font24',
|
|
'font30',
|
|
'font42',
|
|
'fontexocet8',
|
|
'fontexocet10',
|
|
'fontformal10',
|
|
'fontformal11',
|
|
'fontformal12',
|
|
'fontridiculous',
|
|
] as const
|
|
const FONT_COLORS = [0, 1, 2, 3, 4, 5, 7, 8, 9] as const
|
|
const fontMetas: Record<string, FontAtlasMeta> = {}
|
|
|
|
for (const fn of FONT_NAMES) {
|
|
const fDc6 = await decodeUiDc6(`data\\local\\font\\latin\\${fn}.dc6`)
|
|
const fTbl = await archives.read(`data\\local\\font\\latin\\${fn}.tbl`)
|
|
const built = buildFontAtlas(fn, fDc6, fTbl)
|
|
fontMetas[fn] = built.meta
|
|
|
|
for (const c of FONT_COLORS) {
|
|
const pal = c === 0 ? skyPl2.rgb : applyTransform(skyPl2.rgb, skyPl2.textShifts[c]!)
|
|
savePng(
|
|
`fonts/${fn}-c${c}.png`,
|
|
built.meta.atlasWidth,
|
|
built.meta.atlasHeight,
|
|
built.pixels,
|
|
pal,
|
|
true,
|
|
)
|
|
}
|
|
}
|
|
writeFileSync(join(fontsDir, 'metrics.json'), JSON.stringify(fontMetas, null, 2))
|
|
|
|
// ============================================================================
|
|
// 9. Canonical English Strings from string.tbl / expansionstring.tbl / patchstring.tbl
|
|
// ============================================================================
|
|
console.log('Extracting canonical English TBL strings...')
|
|
const strBase = decodeTbl(await archives.read('data\\local\\lng\\eng\\string.tbl'))
|
|
const strExp = decodeTbl(await archives.read('data\\local\\lng\\eng\\expansionstring.tbl'))
|
|
const strPatch = decodeTbl(await archives.read('data\\local\\lng\\eng\\patchstring.tbl'))
|
|
|
|
const lookupByKey = (key: string): string | undefined => {
|
|
for (const table of [strPatch, strExp, strBase]) {
|
|
const found = table.dict.get(key)
|
|
if (found) return found
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
const strings = {
|
|
singlePlayer: lookupByKey('strSinglePlayer') ?? 'SINGLE PLAYER',
|
|
battleNet: lookupByKey('strBattleNet') ?? 'BATTLE.NET',
|
|
otherMultiplayer: lookupByKey('strOtherMultiplayer') ?? 'OTHER MULTIPLAYER',
|
|
exitDiablo2: lookupByKey('strExitDiablo2') ?? 'EXIT DIABLO II',
|
|
credits: 'CREDITS',
|
|
cinematics: 'CINEMATICS',
|
|
gatewayPrefix: 'GATEWAY: ',
|
|
connectingToFastest: 'Connecting to the fastest server...',
|
|
checkingVersions: 'Checking versions...',
|
|
accessingAccount: 'Accessing your account...',
|
|
accountName: 'Account Name',
|
|
password: 'Password',
|
|
logIn: 'LOG IN',
|
|
accountSettings: 'ACCOUNT SETTINGS',
|
|
createNewAccount: 'CREATE NEW ACCOUNT',
|
|
exit: 'EXIT',
|
|
ok: 'OK',
|
|
cancel: 'CANCEL',
|
|
chooseClassTitle: 'Choose a class for your campaign',
|
|
characterName: 'Character Name',
|
|
expansionCharacter: 'EXPANSION CHARACTER',
|
|
hardcore: 'HARDCORE',
|
|
ladderCharacter: 'LADDER CHARACTER',
|
|
selectCharacterTitle: 'Select a Character',
|
|
createNewCharacter: 'CREATE NEW\nCHARACTER',
|
|
convertToExpansion: 'CONVERT TO\nEXPANSION',
|
|
deleteCharacter: 'DELETE\nCHARACTER',
|
|
expiresIn90Days: 'Expires in 90 days',
|
|
classes: {
|
|
am: {
|
|
name: lookupByKey('Amazon') ?? 'Amazon',
|
|
desc:
|
|
lookupByKey('strAmazonDesc') ??
|
|
'Skilled with the spear and the bow, she is a very versatile fighter.',
|
|
},
|
|
as: {
|
|
name: lookupByKey('Assassin') ?? 'Assassin',
|
|
desc:
|
|
lookupByKey('AssasinDesc') ??
|
|
'Schooled in the Martial Arts, her mind and body are deadly weapons.',
|
|
},
|
|
ne: {
|
|
name: lookupByKey('Necromancer') ?? 'Necromancer',
|
|
desc:
|
|
lookupByKey('strNecroDesc') ??
|
|
'Summoning undead minions and cursing his enemies are his specialties.',
|
|
},
|
|
ba: {
|
|
name: lookupByKey('Barbarian') ?? 'Barbarian',
|
|
desc:
|
|
lookupByKey('strBarbDesc') ??
|
|
'He is unequaled in close-quarters combat and mastery of weapons.',
|
|
},
|
|
pa: {
|
|
name: lookupByKey('Paladin') ?? 'Paladin',
|
|
desc:
|
|
lookupByKey('strPaladinDesc') ??
|
|
'He is a natural party leader, holy man, and blessed warrior.',
|
|
},
|
|
so: {
|
|
name: lookupByKey('Sorceress') ?? 'Sorceress',
|
|
desc:
|
|
lookupByKey('strSorcDesc') ??
|
|
'She has mastered the elemental magics -- fire, lightning, and ice.',
|
|
},
|
|
dz: {
|
|
name: lookupByKey('Druid') ?? 'Druid',
|
|
desc:
|
|
lookupByKey('DruidDesc') ??
|
|
'Commanding the forces of nature, he summons wild beasts and raging storms to his side.',
|
|
},
|
|
},
|
|
lobby: {
|
|
send: 'SEND',
|
|
whisper: 'WHISPER',
|
|
help: 'HELP',
|
|
squelch: 'SQUELCH',
|
|
unsquelch: 'UNSQUELCH',
|
|
emote: 'EMOTE',
|
|
create: 'CREATE',
|
|
join: 'JOIN',
|
|
ladder: 'LADDER',
|
|
channel: 'CHANNEL',
|
|
quit: 'QUIT',
|
|
createGameTitle: 'Create Game',
|
|
gameName: 'Game Name',
|
|
gamePassword: 'Password',
|
|
gameDescription: 'Game Description',
|
|
maxPlayers: 'Max Number of Players',
|
|
charDifference: 'Character Difference',
|
|
selectDifficulty: 'Select Difficulty',
|
|
normal: 'Normal',
|
|
nightmare: 'Nightmare',
|
|
hell: 'Hell',
|
|
createGameBtn: 'CREATE GAME',
|
|
gemActivated: 'Gem Activated',
|
|
gemDeactivated: 'Gem Deactivated',
|
|
},
|
|
}
|
|
|
|
const manifest = {
|
|
version: '1.13c',
|
|
decodedDc6Count: decodedCount,
|
|
animStrips,
|
|
campfireClasses,
|
|
charAvatars,
|
|
fontColors: FONT_COLORS,
|
|
strings,
|
|
}
|
|
|
|
writeFileSync(join(outDir, 'manifest.json'), JSON.stringify(manifest, null, 2))
|
|
console.log(
|
|
`Successfully packed ${decodedCount} DC6 sheets + 7 TN character avatars + ${FONT_NAMES.length * FONT_COLORS.length} colored font atlases in ${(performance.now() - t0).toFixed(0)} ms!`,
|
|
)
|
|
}
|
|
|
|
main().catch(err => {
|
|
console.error('pack-frontend-ui failed:', err)
|
|
process.exit(1)
|
|
})
|