diablo2-web/scripts/pack-cursor.ts

194 lines
6.0 KiB
TypeScript

/**
* scripts/pack-cursor.ts
*
* Extracts authentic Diablo II v1.13c cursor DC6 assets from d2data.mpq:
* - protate.dc6 (8 frames: pointing hand swaying animation)
* - ohand.dc6 (8 frames: finger-to-palm opening transition)
* - orotate.dc6 (8 frames: open palm swaying idle animation)
*
* Packs all 24 frames into public/ui/cursor.png with metadata in src/ui/cursor-meta.ts.
* Enforces Zero Runtime MPQ/DLL Architectural Invariant.
*/
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 { decodeDc6, type Dc6Frame } from '../src/formats/dc6.ts'
import { decodePl2 } from '../src/formats/pl2.ts'
import { encodeIndexedPng } from './png.ts'
export interface CursorFrameMeta {
readonly x: number
readonly y: number
readonly w: number
readonly h: number
readonly offsetX: number
readonly offsetY: number
readonly hotspotX: number
readonly hotspotY: number
}
export interface CursorAtlasMetadata {
readonly atlasWidth: number
readonly atlasHeight: number
readonly frameWidth: number
readonly frameHeight: number
readonly protate: readonly CursorFrameMeta[]
readonly ohand: readonly CursorFrameMeta[]
readonly orotate: readonly CursorFrameMeta[]
readonly frames: Record<string, CursorFrameMeta>
}
export async function packCursorAssets(projectRoot: string = process.cwd()): Promise<CursorAtlasMetadata> {
const mpqPath = join(projectRoot, 'samples', 'd2', 'd2data.mpq')
const mpq = await MpqArchive.open(await fileSource(mpqPath))
const palFile = mpq.find('data/global/palette/ACT1/pal.pl2')
if (!palFile) throw new Error('data/global/palette/ACT1/pal.pl2 not found in d2data.mpq')
const pl2 = decodePl2(await mpq.read(palFile))
const animNames = ['protate', 'ohand', 'orotate'] as const
const decodedSheets: Record<string, readonly Dc6Frame[]> = {}
for (const name of animNames) {
const file = mpq.find(`data/global/ui/CURSOR/${name}.dc6`)
if (!file) throw new Error(`Cursor DC6 not found: data/global/ui/CURSOR/${name}.dc6`)
const sheet = decodeDc6(await mpq.read(file))
const frames = sheet.groups[0]?.frames ?? []
if (frames.length !== 8) {
throw new Error(`Expected 8 frames in ${name}.dc6, got ${frames.length}`)
}
decodedSheets[name] = frames
}
// Layout: 3 rows (protate, ohand, orotate), 8 columns
const CELL_W = 32
const CELL_H = 32
const COLS = 8
const ROWS = 3
const atlasWidth = CELL_W * COLS // 256
const atlasHeight = CELL_H * ROWS // 96
const pixels = new Uint8Array(atlasWidth * atlasHeight)
const metaLists: Record<string, CursorFrameMeta[]> = {
protate: [],
ohand: [],
orotate: [],
}
const allFrames: Record<string, CursorFrameMeta> = {}
animNames.forEach((anim, row) => {
const frames = decodedSheets[anim]!
frames.forEach((frame, col) => {
const dstX = col * CELL_W
const dstY = row * CELL_H
// Blit frame pixels into atlas
for (let sy = 0; sy < frame.height; sy++) {
const ty = dstY + sy
for (let sx = 0; sx < frame.width; sx++) {
const tx = dstX + sx
const srcIdx = sy * frame.width + sx
if (frame.mask[srcIdx] !== 0) {
const color = frame.indices[srcIdx] ?? 0
if (color !== 0) {
pixels[ty * atlasWidth + tx] = color
}
}
}
}
// Hotspot calculation:
// In D2 DC6 coordinates, pointing finger tip is at col 0, row 0 of frame 0.
// For protate (offsetX = -1), the tip is at (0, 0).
// When palm spreads in ohand/orotate (offsetX = -2), the anchor shifts left 1px.
const hotspotX = anim === 'protate' ? 0 : Math.max(0, -frame.offsetX - 1)
const hotspotY = 0
const frameMeta: CursorFrameMeta = {
x: dstX,
y: dstY,
w: frame.width,
h: frame.height,
offsetX: frame.offsetX,
offsetY: frame.offsetY,
hotspotX,
hotspotY,
}
metaLists[anim]!.push(frameMeta)
allFrames[`${anim}_${col}`] = frameMeta
})
})
const atlasMeta: CursorAtlasMetadata = {
atlasWidth,
atlasHeight,
frameWidth: CELL_W,
frameHeight: CELL_H,
protate: metaLists.protate!,
ohand: metaLists.ohand!,
orotate: metaLists.orotate!,
frames: allFrames,
}
// Save public/ui/cursor.png
const outDir = join(projectRoot, 'public', 'ui')
mkdirSync(outDir, { recursive: true })
const pngData = encodeIndexedPng({
width: atlasWidth,
height: atlasHeight,
pixels,
palette: pl2.rgb,
transparentIndex: 0,
})
writeFileSync(join(outDir, 'cursor.png'), pngData)
// Save src/ui/cursor-meta.ts
const metaCode = `/**
* Authentic Diablo II v1.13c Cursor Atlas Metadata.
* Auto-generated by scripts/pack-cursor.ts.
* Enforces Zero Runtime MPQ/DLL Architectural Invariant.
*/
export interface CursorFrameMeta {
readonly x: number
readonly y: number
readonly w: number
readonly h: number
readonly offsetX: number
readonly offsetY: number
readonly hotspotX: number
readonly hotspotY: number
}
export interface CursorAtlasMetadata {
readonly atlasWidth: number
readonly atlasHeight: number
readonly frameWidth: number
readonly frameHeight: number
readonly protate: readonly CursorFrameMeta[]
readonly ohand: readonly CursorFrameMeta[]
readonly orotate: readonly CursorFrameMeta[]
readonly frames: Record<string, CursorFrameMeta>
}
export const CURSOR_METADATA: CursorAtlasMetadata = ${JSON.stringify(atlasMeta, null, 2)} as const
`
writeFileSync(join(projectRoot, 'src', 'ui', 'cursor-meta.ts'), metaCode, 'utf8')
console.log(`Extracted 24 authentic cursor frames -> public/ui/cursor.png (${atlasWidth}x${atlasHeight})`)
console.log('Saved metadata to src/ui/cursor-meta.ts')
return atlasMeta
}
if (process.argv[1]?.endsWith('pack-cursor.ts')) {
packCursorAssets()
.then(() => process.exit(0))
.catch((err) => {
console.error('pack-cursor failed:', err)
process.exit(1)
})
}