Add actor editor with animated 3D character preview

Browse and customize all 66 game actors with a Three.js rendered preview
featuring skeletal walk cycle animations. Click interaction matches the
game's character-dependent behavior (Pepper=hat, Nick=color, etc.).

- Parse WDB global parts and global textures for character assembly
- Parse and serialize character data (66 entries x 16 bytes) in save files
- AnimationParser for .ani binary format with hierarchical keyframe evaluation
- Full g_cycles animation table (11 types x 17 animations) driven by move/sound
- Per-mesh texture support for hats, torso, and face textures
This commit is contained in:
Christian Semmler 2026-02-07 21:33:45 -08:00
parent f7e1a56055
commit c390c735b4
No known key found for this signature in database
GPG Key ID: 086DAA1360BEEE5C
107 changed files with 2238 additions and 13 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,16 @@
{
"walkAnimations": {
"xx": "CNs001xx.ani",
"Pe": "CNs001Pe.ani",
"Ma": "CNs001Ma.ani",
"Pa": "CNs001Pa.ani",
"Ni": "CNs001Ni.ani",
"La": "CNs001La.ani",
"Br": "CNs001Br.ani",
"Bd": "CNs001Bd.ani",
"Pg": "CNs001Pg.ani",
"Rd": "CNs001Rd.ani",
"Sy": "CNs001Sy.ani",
"Sk": "CNs001Sk.ani"
}
}

View File

@ -0,0 +1,217 @@
/**
* Parser for LEGO Island .ani animation files.
* Format spec: isle/docs/animation.ksy
*
* Binary layout:
* S32 magic (0x11)
* F32 boundingRadius, F32 centerX, F32 centerY, F32 centerZ
* S32 hasCameraAnim, S32 unused
* U32 numActors
* For each actor: U32 nameLen, char[nameLen] name, U32 actorType (if nameLen > 0)
* S32 duration (ms)
* [optional camera_anim if hasCameraAnim != 0]
* tree_node root
*
* tree_node:
* node_data data
* U32 numChildren
* tree_node[numChildren] children
*
* node_data:
* U32 nameLen, char[nameLen] name (if nameLen > 0)
* U16 numTranslationKeys, translation_key[...]
* U16 numRotationKeys, rotation_key[...]
* U16 numScaleKeys, scale_key[...]
* U16 numMorphKeys, morph_key[...]
*
* Keys share packed time_and_flags (S32): bits 0-23 = time ms, bits 24-31 = flags
* translation_key: anim_key + F32 x,y,z
* rotation_key: anim_key + F32 angle(w), F32 x, F32 y, F32 z (quaternion)
* scale_key: anim_key + F32 x,y,z
* morph_key: anim_key + U8 visible
*/
import { BinaryReader } from './BinaryReader.js';
// Keyframe flags
const KEY_ACTIVE = 0x01;
const KEY_NEGATE_ROTATION = 0x02;
const KEY_SKIP_INTERPOLATION = 0x04;
export class AnimationParser {
/**
* @param {ArrayBuffer} buffer
*/
constructor(buffer) {
this.reader = new BinaryReader(buffer);
}
parse() {
const magic = this.reader.readS32();
if (magic !== 0x11) {
throw new Error(`Invalid animation magic: 0x${magic.toString(16)}, expected 0x11`);
}
const boundingRadius = this.reader.readF32();
const centerX = this.reader.readF32();
const centerY = this.reader.readF32();
const centerZ = this.reader.readF32();
const hasCameraAnim = this.reader.readS32();
const unused = this.reader.readS32();
const numActors = this.reader.readU32();
const actors = [];
for (let i = 0; i < numActors; i++) {
actors.push(this.parseActorEntry());
}
const duration = this.reader.readS32();
let cameraAnim = null;
if (hasCameraAnim !== 0) {
cameraAnim = this.parseCameraAnim();
}
const rootNode = this.parseTreeNode();
return {
boundingRadius,
center: { x: centerX, y: centerY, z: centerZ },
actors,
duration,
cameraAnim,
rootNode
};
}
parseActorEntry() {
const nameLen = this.reader.readU32();
if (nameLen === 0) {
return { name: '', actorType: 0 };
}
const name = this.reader.readString(nameLen);
const actorType = this.reader.readU32();
return { name, actorType };
}
parseCameraAnim() {
const numTranslationKeys = this.reader.readU16();
const translationKeys = [];
for (let i = 0; i < numTranslationKeys; i++) {
translationKeys.push(this.parseTranslationKey());
}
const numTargetKeys = this.reader.readU16();
const targetKeys = [];
for (let i = 0; i < numTargetKeys; i++) {
targetKeys.push(this.parseTranslationKey());
}
const numRotationKeys = this.reader.readU16();
const rotationKeys = [];
for (let i = 0; i < numRotationKeys; i++) {
rotationKeys.push(this.parseRotationZKey());
}
return { translationKeys, targetKeys, rotationKeys };
}
parseTreeNode() {
const data = this.parseNodeData();
const numChildren = this.reader.readU32();
const children = [];
for (let i = 0; i < numChildren; i++) {
children.push(this.parseTreeNode());
}
return { data, children };
}
parseNodeData() {
const nameLen = this.reader.readU32();
let name = '';
if (nameLen > 0) {
name = this.reader.readString(nameLen);
}
const numTranslationKeys = this.reader.readU16();
const translationKeys = [];
for (let i = 0; i < numTranslationKeys; i++) {
translationKeys.push(this.parseTranslationKey());
}
const numRotationKeys = this.reader.readU16();
const rotationKeys = [];
for (let i = 0; i < numRotationKeys; i++) {
rotationKeys.push(this.parseRotationKey());
}
const numScaleKeys = this.reader.readU16();
const scaleKeys = [];
for (let i = 0; i < numScaleKeys; i++) {
scaleKeys.push(this.parseScaleKey());
}
const numMorphKeys = this.reader.readU16();
const morphKeys = [];
for (let i = 0; i < numMorphKeys; i++) {
morphKeys.push(this.parseMorphKey());
}
return { name, translationKeys, rotationKeys, scaleKeys, morphKeys };
}
parseAnimKey() {
const timeAndFlags = this.reader.readS32();
return {
time: timeAndFlags & 0xFFFFFF,
flags: (timeAndFlags >>> 24) & 0xFF
};
}
parseTranslationKey() {
const key = this.parseAnimKey();
const x = this.reader.readF32();
const y = this.reader.readF32();
const z = this.reader.readF32();
return { ...key, x, y, z };
}
parseRotationKey() {
const key = this.parseAnimKey();
const w = this.reader.readF32(); // angle/scalar component
const x = this.reader.readF32();
const y = this.reader.readF32();
const z = this.reader.readF32();
return { ...key, w, x, y, z };
}
parseScaleKey() {
const key = this.parseAnimKey();
const x = this.reader.readF32();
const y = this.reader.readF32();
const z = this.reader.readF32();
return { ...key, x, y, z };
}
parseMorphKey() {
const key = this.parseAnimKey();
const visible = this.reader.readU8();
return { ...key, visible: visible !== 0 };
}
parseRotationZKey() {
const key = this.parseAnimKey();
const z = this.reader.readF32();
return { ...key, z };
}
}
/**
* Parse an animation buffer.
* @param {ArrayBuffer} buffer
* @returns {Object} Parsed animation data
*/
export function parseAnimation(buffer) {
const parser = new AnimationParser(buffer);
return parser.parse();
}

View File

@ -100,10 +100,31 @@ export class SaveGameParser {
} }
/** /**
* Skip character manager data (66 characters * 16 bytes = 1056 bytes) * Parse character manager data (66 characters * 16 bytes = 1056 bytes)
* Each character: sound(S32) + move(S32) + mood(U8)
* + hatPartNameIndex(U8) + hatNameIndex(U8) + infogronNameIndex(U8)
* + armlftNameIndex(U8) + armrtNameIndex(U8) + leglftNameIndex(U8) + legrtNameIndex(U8)
*/ */
skipCharacters() { parseCharacters() {
this.reader.skip(66 * 16); this.parsed.charactersOffset = this.reader.tell();
const characters = [];
for (let i = 0; i < 66; i++) {
characters.push({
sound: this.reader.readS32(),
move: this.reader.readS32(),
mood: this.reader.readU8(),
hatPartNameIndex: this.reader.readU8(),
hatNameIndex: this.reader.readU8(),
infogronNameIndex: this.reader.readU8(),
armlftNameIndex: this.reader.readU8(),
armrtNameIndex: this.reader.readU8(),
leglftNameIndex: this.reader.readU8(),
legrtNameIndex: this.reader.readU8()
});
}
this.parsed.characters = characters;
} }
/** /**
@ -403,7 +424,7 @@ export class SaveGameParser {
parse() { parse() {
this.parseHeader(); this.parseHeader();
this.parseVariables(); this.parseVariables();
this.skipCharacters(); this.parseCharacters();
this.skipPlants(); this.skipPlants();
this.skipBuildings(); this.skipBuildings();
this.parseGameStates(); this.parseGameStates();

View File

@ -4,7 +4,7 @@
*/ */
import { SaveGameParser } from './SaveGameParser.js'; import { SaveGameParser } from './SaveGameParser.js';
import { BinaryWriter } from './BinaryWriter.js'; import { BinaryWriter } from './BinaryWriter.js';
import { GameStateTypes, GameStateSizes, Actor, Act1TextureOrder } from '../savegame/constants.js'; import { GameStateTypes, GameStateSizes, Actor, Act1TextureOrder, CharacterFieldOffsets, CHARACTER_RECORD_SIZE } from '../savegame/constants.js';
/** /**
* Offsets for header fields * Offsets for header fields
@ -461,6 +461,40 @@ export class SaveGameSerializer {
return newBuffer; return newBuffer;
} }
/**
* Update a character field in the save file
* @param {number} characterIndex - Character index (0-65)
* @param {string} field - Field name from CharacterFieldOffsets
* @param {number} value - New value
* @param {ArrayBuffer} [buffer] - Optional buffer to use
* @returns {ArrayBuffer|null} - Modified buffer or null on error
*/
updateCharacter(characterIndex, field, value, buffer = null) {
if (characterIndex < 0 || characterIndex > 65) {
console.error(`Invalid character index: ${characterIndex}`);
return null;
}
const fieldOffset = CharacterFieldOffsets[field];
if (fieldOffset === undefined) {
console.error(`Unknown character field: ${field}`);
return null;
}
const workingBuffer = buffer || this.createCopy();
const view = new DataView(workingBuffer);
const offset = this.parsed.charactersOffset + (characterIndex * CHARACTER_RECORD_SIZE) + fieldOffset;
if (field === 'sound' || field === 'move') {
view.setInt32(offset, value, true);
} else {
view.setUint8(offset, value);
}
return workingBuffer;
}
/** /**
* Get the byte offset for a mission score * Get the byte offset for a mission score
* @param {string} missionType * @param {string} missionType

View File

@ -23,14 +23,18 @@ export class WdbParser {
} }
const globalTexturesSize = this.reader.readU32(); const globalTexturesSize = this.reader.readU32();
// Skip global textures for now - BIGCUBE.GIF is in model_data let globalTextures = [];
this.reader.skip(globalTexturesSize); if (globalTexturesSize > 0) {
globalTextures = this.parseTextureInfo();
}
const globalPartsSize = this.reader.readU32(); const globalPartsSize = this.reader.readU32();
// Skip global parts let globalParts = null;
this.reader.skip(globalPartsSize); if (globalPartsSize > 0) {
globalParts = this.parseGlobalParts(globalPartsSize);
}
return { worlds, globalTexturesSize, globalPartsSize }; return { worlds, globalTexturesSize, globalPartsSize, globalParts, globalTextures };
} }
parseWorldEntry() { parseWorldEntry() {
@ -125,6 +129,40 @@ export class WdbParser {
return { parts, textures }; return { parts, textures };
} }
/**
* Parse global parts block (same structure as parsePartData but inline)
* @param {number} size - Size of global parts block
* @returns {{ parts: Array, textures: Array }}
*/
parseGlobalParts(size) {
const startOffset = this.reader.tell();
const textureInfoOffset = this.reader.readU32();
const numRois = this.reader.readU32();
const parts = [];
for (let i = 0; i < numRois; i++) {
const nameLen = this.reader.readU32();
const name = this.readCleanString(nameLen);
const numLods = this.reader.readU32();
const roiInfoOffset = this.reader.readU32();
const lods = [];
for (let j = 0; j < numLods; j++) {
lods.push(this.parseLod());
}
parts.push({ name, lods });
}
this.reader.seek(startOffset + textureInfoOffset);
const textures = this.parseTextureInfo();
// Ensure we've consumed the full block
this.reader.seek(startOffset + size);
return { parts, textures };
}
/** /**
* Parse model_data blob at specified offset * Parse model_data blob at specified offset
* @param {number} offset - Absolute file offset * @param {number} offset - Absolute file offset
@ -510,6 +548,21 @@ export function resolveLods(roi, partsMap) {
return []; return [];
} }
/**
* Build a parts lookup map from global parts
* @param {{ parts: Array }} globalParts - Parsed global parts from WdbParser
* @returns {Map} - Map of part name (lowercase) -> part data
*/
export function buildGlobalPartsMap(globalParts) {
const partsMap = new Map();
if (!globalParts || !globalParts.parts) return partsMap;
for (const part of globalParts.parts) {
partsMap.set(part.name.toLowerCase(), part);
}
return partsMap;
}
/** /**
* Build a parts lookup map from a world's parts array * Build a parts lookup map from a world's parts array
* @param {WdbParser} parser - Parser instance for reading part data * @param {WdbParser} parser - Parser instance for reading part data

Some files were not shown because too many files have changed in this diff Show More