194 lines
6.0 KiB
TypeScript
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)
|
|
})
|
|
}
|