diff --git a/public/animations/CNs001Bd.ani b/public/animations/CNs001Bd.ani new file mode 100644 index 0000000..b1a5d31 Binary files /dev/null and b/public/animations/CNs001Bd.ani differ diff --git a/public/animations/CNs001Br.ani b/public/animations/CNs001Br.ani new file mode 100644 index 0000000..0daf183 Binary files /dev/null and b/public/animations/CNs001Br.ani differ diff --git a/public/animations/CNs001La.ani b/public/animations/CNs001La.ani new file mode 100644 index 0000000..c2ba4bf Binary files /dev/null and b/public/animations/CNs001La.ani differ diff --git a/public/animations/CNs001Ma.ani b/public/animations/CNs001Ma.ani new file mode 100644 index 0000000..410cc63 Binary files /dev/null and b/public/animations/CNs001Ma.ani differ diff --git a/public/animations/CNs001Ni.ani b/public/animations/CNs001Ni.ani new file mode 100644 index 0000000..8f4ba36 Binary files /dev/null and b/public/animations/CNs001Ni.ani differ diff --git a/public/animations/CNs001Pa.ani b/public/animations/CNs001Pa.ani new file mode 100644 index 0000000..f8d7aea Binary files /dev/null and b/public/animations/CNs001Pa.ani differ diff --git a/public/animations/CNs001Pe.ani b/public/animations/CNs001Pe.ani new file mode 100644 index 0000000..d3f902a Binary files /dev/null and b/public/animations/CNs001Pe.ani differ diff --git a/public/animations/CNs001Pg.ani b/public/animations/CNs001Pg.ani new file mode 100644 index 0000000..b50aeae Binary files /dev/null and b/public/animations/CNs001Pg.ani differ diff --git a/public/animations/CNs001Rd.ani b/public/animations/CNs001Rd.ani new file mode 100644 index 0000000..6957838 Binary files /dev/null and b/public/animations/CNs001Rd.ani differ diff --git a/public/animations/CNs001Sk.ani b/public/animations/CNs001Sk.ani new file mode 100644 index 0000000..335823e Binary files /dev/null and b/public/animations/CNs001Sk.ani differ diff --git a/public/animations/CNs001Sy.ani b/public/animations/CNs001Sy.ani new file mode 100644 index 0000000..209803b Binary files /dev/null and b/public/animations/CNs001Sy.ani differ diff --git a/public/animations/CNs001xx.ani b/public/animations/CNs001xx.ani new file mode 100644 index 0000000..62fbff1 Binary files /dev/null and b/public/animations/CNs001xx.ani differ diff --git a/public/animations/CNs002Br.ani b/public/animations/CNs002Br.ani new file mode 100644 index 0000000..6803749 Binary files /dev/null and b/public/animations/CNs002Br.ani differ diff --git a/public/animations/CNs002La.ani b/public/animations/CNs002La.ani new file mode 100644 index 0000000..ee3e7eb Binary files /dev/null and b/public/animations/CNs002La.ani differ diff --git a/public/animations/CNs002Ma.ani b/public/animations/CNs002Ma.ani new file mode 100644 index 0000000..8bf4b55 Binary files /dev/null and b/public/animations/CNs002Ma.ani differ diff --git a/public/animations/CNs002Ni.ani b/public/animations/CNs002Ni.ani new file mode 100644 index 0000000..00f0462 Binary files /dev/null and b/public/animations/CNs002Ni.ani differ diff --git a/public/animations/CNs002Pa.ani b/public/animations/CNs002Pa.ani new file mode 100644 index 0000000..9e3da0a Binary files /dev/null and b/public/animations/CNs002Pa.ani differ diff --git a/public/animations/CNs002Pe.ani b/public/animations/CNs002Pe.ani new file mode 100644 index 0000000..e952e1f Binary files /dev/null and b/public/animations/CNs002Pe.ani differ diff --git a/public/animations/CNs002xx.ani b/public/animations/CNs002xx.ani new file mode 100644 index 0000000..8f2082a Binary files /dev/null and b/public/animations/CNs002xx.ani differ diff --git a/public/animations/CNs003Br.ani b/public/animations/CNs003Br.ani new file mode 100644 index 0000000..35bb8b0 Binary files /dev/null and b/public/animations/CNs003Br.ani differ diff --git a/public/animations/CNs003La.ani b/public/animations/CNs003La.ani new file mode 100644 index 0000000..0df3497 Binary files /dev/null and b/public/animations/CNs003La.ani differ diff --git a/public/animations/CNs003Ma.ani b/public/animations/CNs003Ma.ani new file mode 100644 index 0000000..e25df2b Binary files /dev/null and b/public/animations/CNs003Ma.ani differ diff --git a/public/animations/CNs003Ni.ani b/public/animations/CNs003Ni.ani new file mode 100644 index 0000000..cde9468 Binary files /dev/null and b/public/animations/CNs003Ni.ani differ diff --git a/public/animations/CNs003Pa.ani b/public/animations/CNs003Pa.ani new file mode 100644 index 0000000..52d2a4f Binary files /dev/null and b/public/animations/CNs003Pa.ani differ diff --git a/public/animations/CNs003Pe.ani b/public/animations/CNs003Pe.ani new file mode 100644 index 0000000..3cbd327 Binary files /dev/null and b/public/animations/CNs003Pe.ani differ diff --git a/public/animations/CNs003xx.ani b/public/animations/CNs003xx.ani new file mode 100644 index 0000000..e59cf0d Binary files /dev/null and b/public/animations/CNs003xx.ani differ diff --git a/public/animations/CNs004Br.ani b/public/animations/CNs004Br.ani new file mode 100644 index 0000000..79802ca Binary files /dev/null and b/public/animations/CNs004Br.ani differ diff --git a/public/animations/CNs004La.ani b/public/animations/CNs004La.ani new file mode 100644 index 0000000..d6e701a Binary files /dev/null and b/public/animations/CNs004La.ani differ diff --git a/public/animations/CNs004Ma.ani b/public/animations/CNs004Ma.ani new file mode 100644 index 0000000..c8563eb Binary files /dev/null and b/public/animations/CNs004Ma.ani differ diff --git a/public/animations/CNs004Ni.ani b/public/animations/CNs004Ni.ani new file mode 100644 index 0000000..4ba7684 Binary files /dev/null and b/public/animations/CNs004Ni.ani differ diff --git a/public/animations/CNs004Pa.ani b/public/animations/CNs004Pa.ani new file mode 100644 index 0000000..1f16fca Binary files /dev/null and b/public/animations/CNs004Pa.ani differ diff --git a/public/animations/CNs004Pe.ani b/public/animations/CNs004Pe.ani new file mode 100644 index 0000000..1254b4c Binary files /dev/null and b/public/animations/CNs004Pe.ani differ diff --git a/public/animations/CNs004xx.ani b/public/animations/CNs004xx.ani new file mode 100644 index 0000000..1939a7c Binary files /dev/null and b/public/animations/CNs004xx.ani differ diff --git a/public/animations/CNs005Br.ani b/public/animations/CNs005Br.ani new file mode 100644 index 0000000..f9967d4 Binary files /dev/null and b/public/animations/CNs005Br.ani differ diff --git a/public/animations/CNs005La.ani b/public/animations/CNs005La.ani new file mode 100644 index 0000000..ee1fd0d Binary files /dev/null and b/public/animations/CNs005La.ani differ diff --git a/public/animations/CNs005Ma.ani b/public/animations/CNs005Ma.ani new file mode 100644 index 0000000..048487c Binary files /dev/null and b/public/animations/CNs005Ma.ani differ diff --git a/public/animations/CNs005Ni.ani b/public/animations/CNs005Ni.ani new file mode 100644 index 0000000..18efc89 Binary files /dev/null and b/public/animations/CNs005Ni.ani differ diff --git a/public/animations/CNs005Pa.ani b/public/animations/CNs005Pa.ani new file mode 100644 index 0000000..00dfdde Binary files /dev/null and b/public/animations/CNs005Pa.ani differ diff --git a/public/animations/CNs005Pe.ani b/public/animations/CNs005Pe.ani new file mode 100644 index 0000000..4d083ca Binary files /dev/null and b/public/animations/CNs005Pe.ani differ diff --git a/public/animations/CNs005xx.ani b/public/animations/CNs005xx.ani new file mode 100644 index 0000000..ab4f62a Binary files /dev/null and b/public/animations/CNs005xx.ani differ diff --git a/public/animations/CNs006Br.ani b/public/animations/CNs006Br.ani new file mode 100644 index 0000000..4a8d473 Binary files /dev/null and b/public/animations/CNs006Br.ani differ diff --git a/public/animations/CNs006La.ani b/public/animations/CNs006La.ani new file mode 100644 index 0000000..d26ccb4 Binary files /dev/null and b/public/animations/CNs006La.ani differ diff --git a/public/animations/CNs006Ma.ani b/public/animations/CNs006Ma.ani new file mode 100644 index 0000000..19d6c63 Binary files /dev/null and b/public/animations/CNs006Ma.ani differ diff --git a/public/animations/CNs006Ni.ani b/public/animations/CNs006Ni.ani new file mode 100644 index 0000000..19caa5e Binary files /dev/null and b/public/animations/CNs006Ni.ani differ diff --git a/public/animations/CNs006Pa.ani b/public/animations/CNs006Pa.ani new file mode 100644 index 0000000..ce6fc4f Binary files /dev/null and b/public/animations/CNs006Pa.ani differ diff --git a/public/animations/CNs006Pe.ani b/public/animations/CNs006Pe.ani new file mode 100644 index 0000000..22cfbf4 Binary files /dev/null and b/public/animations/CNs006Pe.ani differ diff --git a/public/animations/CNs006xx.ani b/public/animations/CNs006xx.ani new file mode 100644 index 0000000..2f184c3 Binary files /dev/null and b/public/animations/CNs006xx.ani differ diff --git a/public/animations/CNs007Br.ani b/public/animations/CNs007Br.ani new file mode 100644 index 0000000..e922568 Binary files /dev/null and b/public/animations/CNs007Br.ani differ diff --git a/public/animations/CNs007La.ani b/public/animations/CNs007La.ani new file mode 100644 index 0000000..45aa6fe Binary files /dev/null and b/public/animations/CNs007La.ani differ diff --git a/public/animations/CNs007Ma.ani b/public/animations/CNs007Ma.ani new file mode 100644 index 0000000..cf32611 Binary files /dev/null and b/public/animations/CNs007Ma.ani differ diff --git a/public/animations/CNs007Ni.ani b/public/animations/CNs007Ni.ani new file mode 100644 index 0000000..53bf5ad Binary files /dev/null and b/public/animations/CNs007Ni.ani differ diff --git a/public/animations/CNs007Pa.ani b/public/animations/CNs007Pa.ani new file mode 100644 index 0000000..2321a87 Binary files /dev/null and b/public/animations/CNs007Pa.ani differ diff --git a/public/animations/CNs007Pe.ani b/public/animations/CNs007Pe.ani new file mode 100644 index 0000000..7437aa7 Binary files /dev/null and b/public/animations/CNs007Pe.ani differ diff --git a/public/animations/CNs007xx.ani b/public/animations/CNs007xx.ani new file mode 100644 index 0000000..e300e24 Binary files /dev/null and b/public/animations/CNs007xx.ani differ diff --git a/public/animations/CNs008Br.ani b/public/animations/CNs008Br.ani new file mode 100644 index 0000000..f076b97 Binary files /dev/null and b/public/animations/CNs008Br.ani differ diff --git a/public/animations/CNs008La.ani b/public/animations/CNs008La.ani new file mode 100644 index 0000000..63ab4d9 Binary files /dev/null and b/public/animations/CNs008La.ani differ diff --git a/public/animations/CNs008Ma.ani b/public/animations/CNs008Ma.ani new file mode 100644 index 0000000..916b7a8 Binary files /dev/null and b/public/animations/CNs008Ma.ani differ diff --git a/public/animations/CNs008Ni.ani b/public/animations/CNs008Ni.ani new file mode 100644 index 0000000..2f59fe3 Binary files /dev/null and b/public/animations/CNs008Ni.ani differ diff --git a/public/animations/CNs008Pa.ani b/public/animations/CNs008Pa.ani new file mode 100644 index 0000000..3dfe5b5 Binary files /dev/null and b/public/animations/CNs008Pa.ani differ diff --git a/public/animations/CNs008Pe.ani b/public/animations/CNs008Pe.ani new file mode 100644 index 0000000..4539055 Binary files /dev/null and b/public/animations/CNs008Pe.ani differ diff --git a/public/animations/CNs008xx.ani b/public/animations/CNs008xx.ani new file mode 100644 index 0000000..18499ab Binary files /dev/null and b/public/animations/CNs008xx.ani differ diff --git a/public/animations/CNs009Br.ani b/public/animations/CNs009Br.ani new file mode 100644 index 0000000..6268e5a Binary files /dev/null and b/public/animations/CNs009Br.ani differ diff --git a/public/animations/CNs009La.ani b/public/animations/CNs009La.ani new file mode 100644 index 0000000..42d188e Binary files /dev/null and b/public/animations/CNs009La.ani differ diff --git a/public/animations/CNs009Ma.ani b/public/animations/CNs009Ma.ani new file mode 100644 index 0000000..8d6447a Binary files /dev/null and b/public/animations/CNs009Ma.ani differ diff --git a/public/animations/CNs009Ni.ani b/public/animations/CNs009Ni.ani new file mode 100644 index 0000000..8f5c7bb Binary files /dev/null and b/public/animations/CNs009Ni.ani differ diff --git a/public/animations/CNs009Pa.ani b/public/animations/CNs009Pa.ani new file mode 100644 index 0000000..d312ca9 Binary files /dev/null and b/public/animations/CNs009Pa.ani differ diff --git a/public/animations/CNs009Pe.ani b/public/animations/CNs009Pe.ani new file mode 100644 index 0000000..47b6861 Binary files /dev/null and b/public/animations/CNs009Pe.ani differ diff --git a/public/animations/CNs009xx.ani b/public/animations/CNs009xx.ani new file mode 100644 index 0000000..9284109 Binary files /dev/null and b/public/animations/CNs009xx.ani differ diff --git a/public/animations/CNs010Br.ani b/public/animations/CNs010Br.ani new file mode 100644 index 0000000..4381c88 Binary files /dev/null and b/public/animations/CNs010Br.ani differ diff --git a/public/animations/CNs010La.ani b/public/animations/CNs010La.ani new file mode 100644 index 0000000..820d174 Binary files /dev/null and b/public/animations/CNs010La.ani differ diff --git a/public/animations/CNs010Ma.ani b/public/animations/CNs010Ma.ani new file mode 100644 index 0000000..55d6268 Binary files /dev/null and b/public/animations/CNs010Ma.ani differ diff --git a/public/animations/CNs010Ni.ani b/public/animations/CNs010Ni.ani new file mode 100644 index 0000000..c588f98 Binary files /dev/null and b/public/animations/CNs010Ni.ani differ diff --git a/public/animations/CNs010Pa.ani b/public/animations/CNs010Pa.ani new file mode 100644 index 0000000..ebd143e Binary files /dev/null and b/public/animations/CNs010Pa.ani differ diff --git a/public/animations/CNs010Pe.ani b/public/animations/CNs010Pe.ani new file mode 100644 index 0000000..0d90583 Binary files /dev/null and b/public/animations/CNs010Pe.ani differ diff --git a/public/animations/CNs010xx.ani b/public/animations/CNs010xx.ani new file mode 100644 index 0000000..71c095a Binary files /dev/null and b/public/animations/CNs010xx.ani differ diff --git a/public/animations/CNs011Br.ani b/public/animations/CNs011Br.ani new file mode 100644 index 0000000..088ce7a Binary files /dev/null and b/public/animations/CNs011Br.ani differ diff --git a/public/animations/CNs011La.ani b/public/animations/CNs011La.ani new file mode 100644 index 0000000..acc759e Binary files /dev/null and b/public/animations/CNs011La.ani differ diff --git a/public/animations/CNs011Ma.ani b/public/animations/CNs011Ma.ani new file mode 100644 index 0000000..f24074e Binary files /dev/null and b/public/animations/CNs011Ma.ani differ diff --git a/public/animations/CNs011Ni.ani b/public/animations/CNs011Ni.ani new file mode 100644 index 0000000..0e16e10 Binary files /dev/null and b/public/animations/CNs011Ni.ani differ diff --git a/public/animations/CNs011Pa.ani b/public/animations/CNs011Pa.ani new file mode 100644 index 0000000..43ab4d3 Binary files /dev/null and b/public/animations/CNs011Pa.ani differ diff --git a/public/animations/CNs011xx.ani b/public/animations/CNs011xx.ani new file mode 100644 index 0000000..692adeb Binary files /dev/null and b/public/animations/CNs011xx.ani differ diff --git a/public/animations/CNs012Br.ani b/public/animations/CNs012Br.ani new file mode 100644 index 0000000..d0e4757 Binary files /dev/null and b/public/animations/CNs012Br.ani differ diff --git a/public/animations/CNs012Ma.ani b/public/animations/CNs012Ma.ani new file mode 100644 index 0000000..51e4466 Binary files /dev/null and b/public/animations/CNs012Ma.ani differ diff --git a/public/animations/CNs012Pa.ani b/public/animations/CNs012Pa.ani new file mode 100644 index 0000000..b80a9c3 Binary files /dev/null and b/public/animations/CNs012Pa.ani differ diff --git a/public/animations/CNs012xx.ani b/public/animations/CNs012xx.ani new file mode 100644 index 0000000..14abd27 Binary files /dev/null and b/public/animations/CNs012xx.ani differ diff --git a/public/animations/CNs013Br.ani b/public/animations/CNs013Br.ani new file mode 100644 index 0000000..027e4f8 Binary files /dev/null and b/public/animations/CNs013Br.ani differ diff --git a/public/animations/CNs013Ma.ani b/public/animations/CNs013Ma.ani new file mode 100644 index 0000000..9e0779a Binary files /dev/null and b/public/animations/CNs013Ma.ani differ diff --git a/public/animations/CNs013Pa.ani b/public/animations/CNs013Pa.ani new file mode 100644 index 0000000..c3a187f Binary files /dev/null and b/public/animations/CNs013Pa.ani differ diff --git a/public/animations/CNs014Br.ani b/public/animations/CNs014Br.ani new file mode 100644 index 0000000..ed7a0bd Binary files /dev/null and b/public/animations/CNs014Br.ani differ diff --git a/public/animations/CNs0x4Ma.ani b/public/animations/CNs0x4Ma.ani new file mode 100644 index 0000000..27ec720 Binary files /dev/null and b/public/animations/CNs0x4Ma.ani differ diff --git a/public/animations/CNs0x4Pa.ani b/public/animations/CNs0x4Pa.ani new file mode 100644 index 0000000..3ad2f7f Binary files /dev/null and b/public/animations/CNs0x4Pa.ani differ diff --git a/public/animations/CNs900Br.ani b/public/animations/CNs900Br.ani new file mode 100644 index 0000000..5214c41 Binary files /dev/null and b/public/animations/CNs900Br.ani differ diff --git a/public/animations/CNs901BR.ani b/public/animations/CNs901BR.ani new file mode 100644 index 0000000..623af20 Binary files /dev/null and b/public/animations/CNs901BR.ani differ diff --git a/public/animations/CNsx11La.ani b/public/animations/CNsx11La.ani new file mode 100644 index 0000000..19cbb2c Binary files /dev/null and b/public/animations/CNsx11La.ani differ diff --git a/public/animations/CNsx11Ni.ani b/public/animations/CNsx11Ni.ani new file mode 100644 index 0000000..cfd4708 Binary files /dev/null and b/public/animations/CNsx11Ni.ani differ diff --git a/public/animations/manifest.json b/public/animations/manifest.json new file mode 100644 index 0000000..8f2c4a8 --- /dev/null +++ b/public/animations/manifest.json @@ -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" + } +} diff --git a/src/core/formats/AnimationParser.js b/src/core/formats/AnimationParser.js new file mode 100644 index 0000000..7140556 --- /dev/null +++ b/src/core/formats/AnimationParser.js @@ -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(); +} diff --git a/src/core/formats/SaveGameParser.js b/src/core/formats/SaveGameParser.js index f3e71fd..494f219 100644 --- a/src/core/formats/SaveGameParser.js +++ b/src/core/formats/SaveGameParser.js @@ -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() { - this.reader.skip(66 * 16); + parseCharacters() { + 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() { this.parseHeader(); this.parseVariables(); - this.skipCharacters(); + this.parseCharacters(); this.skipPlants(); this.skipBuildings(); this.parseGameStates(); diff --git a/src/core/formats/SaveGameSerializer.js b/src/core/formats/SaveGameSerializer.js index c39110e..d96f24b 100644 --- a/src/core/formats/SaveGameSerializer.js +++ b/src/core/formats/SaveGameSerializer.js @@ -4,7 +4,7 @@ */ import { SaveGameParser } from './SaveGameParser.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 @@ -461,6 +461,40 @@ export class SaveGameSerializer { 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 * @param {string} missionType diff --git a/src/core/formats/WdbParser.js b/src/core/formats/WdbParser.js index 3e08deb..c4b3e1c 100644 --- a/src/core/formats/WdbParser.js +++ b/src/core/formats/WdbParser.js @@ -23,14 +23,18 @@ export class WdbParser { } const globalTexturesSize = this.reader.readU32(); - // Skip global textures for now - BIGCUBE.GIF is in model_data - this.reader.skip(globalTexturesSize); + let globalTextures = []; + if (globalTexturesSize > 0) { + globalTextures = this.parseTextureInfo(); + } const globalPartsSize = this.reader.readU32(); - // Skip global parts - this.reader.skip(globalPartsSize); + let globalParts = null; + if (globalPartsSize > 0) { + globalParts = this.parseGlobalParts(globalPartsSize); + } - return { worlds, globalTexturesSize, globalPartsSize }; + return { worlds, globalTexturesSize, globalPartsSize, globalParts, globalTextures }; } parseWorldEntry() { @@ -125,6 +129,40 @@ export class WdbParser { 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 * @param {number} offset - Absolute file offset @@ -510,6 +548,21 @@ export function resolveLods(roi, partsMap) { 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 * @param {WdbParser} parser - Parser instance for reading part data diff --git a/src/core/formats/index.js b/src/core/formats/index.js index a6d3ca4..aaddc97 100644 --- a/src/core/formats/index.js +++ b/src/core/formats/index.js @@ -7,7 +7,7 @@ export { BinaryReader } from './BinaryReader.js'; export { BinaryWriter } from './BinaryWriter.js'; // WDB format -export { WdbParser, findRoi } from './WdbParser.js'; +export { WdbParser, findRoi, buildGlobalPartsMap } from './WdbParser.js'; // Save game format export { SaveGameParser, parseSaveGame } from './SaveGameParser.js'; @@ -19,3 +19,6 @@ export { PlayersSerializer, createPlayersSerializer } from './PlayersSerializer. // Texture format export { parseTex } from './TexParser.js'; + +// Animation format +export { AnimationParser, parseAnimation } from './AnimationParser.js'; diff --git a/src/core/rendering/ActorRenderer.js b/src/core/rendering/ActorRenderer.js new file mode 100644 index 0000000..d96e890 --- /dev/null +++ b/src/core/rendering/ActorRenderer.js @@ -0,0 +1,881 @@ +import * as THREE from 'three'; +import { ActorLODs, ActorLODFlags, ActorInfoInit, partToLODIndex } from '../savegame/actorConstants.js'; +import { parseAnimation } from '../formats/AnimationParser.js'; + +// Extended LEGO colors (includes brown and lt grey not in the vehicle editor) +const ExtendedLegoColors = Object.freeze({ + 'lego black': { r: 0x21, g: 0x21, b: 0x21 }, + 'lego blue': { r: 0x00, g: 0x54, b: 0x8c }, + 'lego green': { r: 0x00, g: 0x78, b: 0x2d }, + 'lego red': { r: 0xcb, g: 0x12, b: 0x20 }, + 'lego white': { r: 0xfa, g: 0xfa, b: 0xfa }, + 'lego yellow': { r: 0xff, g: 0xb9, b: 0x00 }, + 'lego brown': { r: 0x4a, g: 0x23, b: 0x00 }, + 'lego lt grey': { r: 0xc0, g: 0xc0, b: 0xc0 } +}); + +/** + * Map actor index to animation suffix index (from g_characters[].m_unk0x16). + * This maps the 66 ActorInfoInit indices to the g_cycles row index. + */ +const ACTOR_SUFFIX_INDEX = (() => { + // Default all to 0 (xx) + const map = new Array(66).fill(0); + map[0] = 1; // pepper → Pe + map[1] = 2; // mama → Ma + map[2] = 3; // papa → Pa + map[3] = 4; // nick → Ni + map[4] = 5; // laura → La + map[5] = 0; // infoman → xx (not in g_characters, uses default) + map[6] = 6; // brickstr → Br + // 7-35: all 0 (xx) — generic NPCs + // Note: g_characters indices don't perfectly align with ActorInfoInit indices + // for the NPCs after brickstr. The g_characters array has 47 entries + // matching by name. We map the special ones: + map[37] = 9; // rd → Rd (g_characters index 36) + map[38] = 8; // pg → Pg (g_characters index 37) + map[39] = 7; // bd → Bd (g_characters index 38) + map[40] = 10; // sy → Sy (g_characters index 39) + map[56] = 1; // pep → Pe (same as pepper) + return map; +})(); + +/** + * Suffix names indexed by g_cycles row index. + */ +const SUFFIX_NAMES = ['xx', 'Pe', 'Ma', 'Pa', 'Ni', 'La', 'Br', 'Bd', 'Pg', 'Rd', 'Sy']; + +/** + * g_cycles[11][17] — animation name table from legoanimationmanager.cpp. + * Rows = character type suffix index, columns = sound + 4 * move (0-16). + */ +const G_CYCLES = [ + // 0: xx + ['CNs001xx','CNs002xx','CNs003xx','CNs004xx','CNs005xx','CNs007xx','CNs006xx','CNs008xx','CNs009xx','CNs010xx','CNs011xx','CNs012xx',null,null,null,null,null], + // 1: Pe + ['CNs001Pe','CNs002Pe','CNs003Pe','CNs004Pe','CNs005Pe','CNs007Pe','CNs006Pe','CNs008Pe','CNs009Pe','CNs010Pe','CNs001Sk',null,null,null,null,null,null], // CNs001Sk = skateboard + // 2: Ma + ['CNs001Ma','CNs002Ma','CNs003Ma','CNs004Ma','CNs005Ma','CNs007Ma','CNs006Ma','CNs008Ma','CNs009Ma','CNs010Ma','CNs0x4Ma',null,null,'CNs011Ma','CNs012Ma','CNs013Ma',null], + // 3: Pa + ['CNs001Pa','CNs002Pa','CNs003Pa','CNs004Pa','CNs005Pa','CNs007Pa','CNs006Pa','CNs008Pa','CNs009Pa','CNs010Pa','CNs0x4Pa',null,null,'CNs011Pa','CNs012Pa','CNs013Pa',null], + // 4: Ni + ['CNs001Ni','CNs002Ni','CNs003Ni','CNs004Ni','CNs005Ni','CNs007Ni','CNs006Ni','CNs008Ni','CNs009Ni','CNs010Ni','CNs011Ni','CNsx11Ni',null,null,null,null,null], + // 5: La + ['CNs001La','CNs002La','CNs003La','CNs004La','CNs005La','CNs007La','CNs006La','CNs008La','CNs009La','CNs010La','CNs011La','CNsx11La',null,null,null,null,null], + // 6: Br + ['CNs001Br','CNs002Br','CNs003Br','CNs004Br','CNs005Br','CNs007Br','CNs006Br','CNs008Br','CNs009Br','CNs010Br','CNs011Br','CNs900Br','CNs901BR','CNs011Br','CNs012Br','CNs013Br','CNs014Br'], + // 7: Bd + ['CNs001xx','CNs002xx','CNs003xx','CNs004xx','CNs005xx','CNs007xx','CNs006xx','CNs008xx','CNs009xx','CNs010xx','CNs001Bd','CNs012xx',null,null,null,null,null], + // 8: Pg + ['CNs001xx','CNs002xx','CNs003xx','CNs004xx','CNs005xx','CNs007xx','CNs006xx','CNs008xx','CNs009xx','CNs010xx','CNs001Pg','CNs012xx',null,null,null,null,null], + // 9: Rd + ['CNs001xx','CNs002xx','CNs003xx','CNs004xx','CNs005xx','CNs007xx','CNs006xx','CNs008xx','CNs009xx','CNs010xx','CNs001Rd','CNs012xx',null,null,null,null,null], + // 10: Sy + ['CNs001xx','CNs002xx','CNs003xx','CNs004xx','CNs005xx','CNs007xx','CNs006xx','CNs008xx','CNs009xx','CNs010xx','CNs001Sy','CNs012xx',null,null,null,null,null], +]; + +/** + * Map ActorLOD names (used in our part hierarchy) to animation node names. + * Animation files use uppercase names like "BODY", "HEAD", "LEG-RT", etc. + */ +const PART_NAME_TO_ANIM_NODE = { + 'body': 'BODY', + 'infohat': 'INFOHAT', + 'infogron': 'INFOGRON', + 'head': 'HEAD', + 'arm-lft': 'ARM-LFT', + 'arm-rt': 'ARM-RT', + 'claw-lft': 'CLAW-LFT', + 'claw-rt': 'CLAW-RT', + 'leg-lft': 'LEG-LFT', + 'leg-rt': 'LEG-RT' +}; + +/** + * Renderer for full LEGO characters assembled from WDB global parts. + * Mirrors the game's LegoCharacterManager::CreateActorROI logic. + */ +export class ActorRenderer { + constructor(canvas) { + this.canvas = canvas; + this.animating = false; + this.modelGroup = null; + this.partGroups = []; // 10 part groups for click targeting + this.textures = new Map(); + this.clock = new THREE.Clock(); + this.mixer = null; + this.currentAction = null; + this.animationCache = new Map(); // suffix → parsed animation data + + this.scene = new THREE.Scene(); + + this.camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100); + this.camera.position.set(2, 0.8, 3.5); + this.camera.lookAt(0, 0.2, 0); + + this.renderer = new THREE.WebGLRenderer({ + canvas, + antialias: true, + alpha: true + }); + this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + this.renderer.setClearColor(0x000000, 0); + + this.raycaster = new THREE.Raycaster(); + + this.setupLighting(); + } + + setupLighting() { + const ambient = new THREE.AmbientLight(0xffffff, 0.8); + this.scene.add(ambient); + + const sunLight = new THREE.DirectionalLight(0xffffff, 0.6); + sunLight.position.set(1, 2, 3); + this.scene.add(sunLight); + } + + /** + * Create a Three.js texture from parsed texture data + */ + createTexture(textureData) { + const canvas = document.createElement('canvas'); + canvas.width = textureData.width; + canvas.height = textureData.height; + const ctx = canvas.getContext('2d'); + + const imageData = ctx.createImageData(textureData.width, textureData.height); + for (let i = 0; i < textureData.pixels.length; i++) { + const colorIdx = textureData.pixels[i]; + const color = textureData.palette[colorIdx] || { r: 0, g: 0, b: 0 }; + imageData.data[i * 4 + 0] = color.r; + imageData.data[i * 4 + 1] = color.g; + imageData.data[i * 4 + 2] = color.b; + imageData.data[i * 4 + 3] = 255; + } + ctx.putImageData(imageData, 0, 0); + + const texture = new THREE.CanvasTexture(canvas); + texture.minFilter = THREE.NearestFilter; + texture.magFilter = THREE.NearestFilter; + texture.wrapS = THREE.RepeatWrapping; + texture.wrapT = THREE.RepeatWrapping; + return texture; + } + + /** + * Load a full actor from global parts. + * @param {number} actorIndex - Index into ActorInfoInit (0-65) + * @param {Array} characters - Parsed character state from save file (66 entries) + * @param {Map} globalPartsMap - Name→part lookup for global parts + * @param {Array} globalTextures - Global texture list from WDB + */ + loadActor(actorIndex, characters, globalPartsMap, globalTextures) { + this.clearModel(); + + const actorInfo = ActorInfoInit[actorIndex]; + if (!actorInfo) return; + + const charState = characters ? characters[actorIndex] : null; + + // Build texture lookup + this.textures.clear(); + if (globalTextures) { + for (const tex of globalTextures) { + if (tex.name) { + this.textures.set(tex.name.toLowerCase(), this.createTexture(tex)); + } + } + } + + this.modelGroup = new THREE.Group(); + this.partGroups = []; + + // Assemble 10 body parts (matching CreateActorROI loop: i=0..9 maps to ActorLODs[1..10]) + for (let i = 0; i < 10; i++) { + const lodIdx = partToLODIndex[i]; + const actorLOD = ActorLODs[lodIdx]; + const part = actorInfo.parts[i]; + + // Resolve part name for body (i=0) and hat (i=1) + let partName; + if (i === 0 || i === 1) { + partName = this.resolvePartName(part, charState, i); + } else { + partName = actorLOD.parentName; + } + + if (!partName) continue; + + // Find the part's LOD data in global parts + const partData = globalPartsMap.get(partName.toLowerCase()); + if (!partData) continue; + + const partGroup = new THREE.Group(); + partGroup.userData.partIndex = i; + partGroup.userData.partName = partName; + partGroup.userData.lodName = actorLOD.name; // for animation matching + // Name used by Three.js PropertyBinding to match animation tracks + partGroup.name = `part_${actorLOD.name}`; + + // Resolve color/texture for this part + const resolvedName = this.resolveNameValue(part, charState, i); + + // Create meshes from LODs + const lods = partData.lods || []; + if (lods.length > 0) { + const lod = lods[lods.length - 1]; // Highest quality + this.createPartMeshes(lod, actorLOD, part, resolvedName, i, partGroup); + } + + // Position the part using ActorLOD transform + this.applyPartTransform(partGroup, actorLOD); + + this.modelGroup.add(partGroup); + this.partGroups[i] = partGroup; + } + + this.centerAndScaleModel(); + // Rotate 180° around Y so actor faces the camera (negating X for + // left-to-right-handed conversion flips the facing direction) + this.modelGroup.rotation.y = Math.PI; + this.scene.add(this.modelGroup); + + // Load and start animation based on move/sound + const move = charState?.move ?? 0; + const sound = charState?.sound ?? 0; + this.loadAnimationForActor(actorIndex, move, sound); + + this.renderer.render(this.scene, this.camera); + } + + /** + * Resolve which part geometry to use (body variant or hat type). + * For body (i=0): partNameIndices[charState.hatPartNameIndex or default] → bodyPartNames index + * For hat (i=1): partNameIndices[charState.hatPartNameIndex or default] → hatPartNames + * The save file stores the index into the partNameIndices array. + */ + resolvePartName(part, charState, partIdx) { + if (!part.partNameIndices || !part.partNames) return null; + + let nameIdx = part.partNameIndex; // default from actorInfoInit + + // For body (part 0): the partNameIndex selects from bodyPartNames + // For hat (part 1): the partNameIndex selects from hatPartNames + // The save file overrides hatPartNameIndex for part 1 only + if (partIdx === 1 && charState) { + nameIdx = charState.hatPartNameIndex; + } + + if (nameIdx >= part.partNameIndices.length) { + nameIdx = part.partNameIndex; + } + + const resolvedIdx = part.partNameIndices[nameIdx]; + if (resolvedIdx === undefined || resolvedIdx >= part.partNames.length) { + return part.partNames[part.partNameIndices[part.partNameIndex]]; + } + + return part.partNames[resolvedIdx]; + } + + /** + * Resolve the color or texture name for a part. + * Uses nameIndices[nameIndex] → names[] (colorAliases or faceTextures/chestTextures) + */ + resolveNameValue(part, charState, partIdx) { + if (!part.nameIndices || !part.names) return null; + + let nameIdx = part.nameIndex; // default + + // Save file overrides specific fields + if (charState) { + switch (partIdx) { + case 1: nameIdx = charState.hatNameIndex; break; // hat color + case 2: nameIdx = charState.infogronNameIndex; break; // infogron color + case 4: nameIdx = charState.armlftNameIndex; break; // arm left + case 5: nameIdx = charState.armrtNameIndex; break; // arm right + case 8: nameIdx = charState.leglftNameIndex; break; // leg left + case 9: nameIdx = charState.legrtNameIndex; break; // leg right + } + } + + if (nameIdx >= part.nameIndices.length) { + nameIdx = part.nameIndex; + } + + const resolvedIdx = part.nameIndices[nameIdx]; + if (resolvedIdx === undefined || resolvedIdx >= part.names.length) { + return part.names[part.nameIndices[part.nameIndex]]; + } + + return part.names[resolvedIdx]; + } + + /** + * Create meshes for a single body part. + */ + createPartMeshes(lod, actorLOD, part, resolvedName, partIdx, group) { + const useTexture = (actorLOD.flags & ActorLODFlags.USE_TEXTURE) !== 0; + const useColor = (actorLOD.flags & ActorLODFlags.USE_COLOR) !== 0; + + // Special case: body part (i=0) with partNameIndex 0 uses color instead of texture + // (matches the C++ condition: i != 0 || part.m_partNameIndices[part.m_partNameIndex] != 0) + const bodyUsesDefaultGeom = partIdx === 0 && part.partNameIndices && + part.partNameIndices[part.partNameIndex] === 0; + + let partColor = null; + let partTexture = null; + + if (useTexture && !bodyUsesDefaultGeom) { + // Look up texture by resolved name + const texName = resolvedName?.toLowerCase(); + if (texName && this.textures.has(texName)) { + partTexture = this.textures.get(texName); + } + } + + if ((useColor || bodyUsesDefaultGeom) && !partTexture) { + // Resolve LEGO color + const colorEntry = ExtendedLegoColors[resolvedName] || ExtendedLegoColors['lego white']; + partColor = new THREE.Color(colorEntry.r / 255, colorEntry.g / 255, colorEntry.b / 255); + } + + for (const mesh of lod.meshes) { + const geometry = this.createGeometry(mesh, lod); + if (!geometry) continue; + + // Check for per-mesh texture from the WDB geometry + let meshTexture = null; + const meshTexName = mesh.properties?.textureName?.toLowerCase(); + if (meshTexName && this.textures.has(meshTexName)) { + meshTexture = this.textures.get(meshTexName); + } + + let material; + if (partTexture) { + material = new THREE.MeshLambertMaterial({ + map: partTexture, + side: THREE.DoubleSide, + color: 0xffffff + }); + } else if (meshTexture) { + material = new THREE.MeshLambertMaterial({ + map: meshTexture, + side: THREE.DoubleSide, + color: 0xffffff + }); + } else if (partColor) { + material = new THREE.MeshLambertMaterial({ + color: partColor, + side: THREE.DoubleSide + }); + } else { + const meshColor = mesh.properties?.color || { r: 128, g: 128, b: 128 }; + material = new THREE.MeshLambertMaterial({ + color: new THREE.Color(meshColor.r / 255, meshColor.g / 255, meshColor.b / 255), + side: THREE.DoubleSide + }); + } + + const threeMesh = new THREE.Mesh(geometry, material); + group.add(threeMesh); + } + } + + /** + * Create geometry from mesh data (same approach as VehiclePartRenderer) + */ + createGeometry(mesh, lod) { + if (!mesh.polygonIndices || mesh.polygonIndices.length === 0) return null; + + const hasTexture = mesh.textureIndices && mesh.textureIndices.length > 0; + + const vertexIndicesPacked = []; + for (const poly of mesh.polygonIndices) { + vertexIndicesPacked.push(poly.a, poly.b, poly.c); + } + + const textureIndicesFlat = []; + if (hasTexture) { + for (const texPoly of mesh.textureIndices) { + textureIndicesFlat.push(texPoly.a, texPoly.b, texPoly.c); + } + } + + const meshVertices = []; + const meshNormals = []; + const meshUvs = []; + const indices = []; + + for (let i = 0; i < vertexIndicesPacked.length; i++) { + const packed = vertexIndicesPacked[i]; + + if ((packed & 0x80000000) !== 0) { + indices.push(meshVertices.length); + + const gv = packed & 0xFFFF; + const v = lod.vertices[gv] || { x: 0, y: 0, z: 0 }; + meshVertices.push([-v.x, v.y, v.z]); + + const gn = (packed >>> 16) & 0x7fff; + const n = lod.normals[gn] || { x: 0, y: 1, z: 0 }; + meshNormals.push([-n.x, n.y, n.z]); + + if (hasTexture && lod.textureVertices && lod.textureVertices.length > 0) { + const tex = textureIndicesFlat[i]; + const uv = lod.textureVertices[tex] || { u: 0, v: 0 }; + meshUvs.push([uv.u, 1 - uv.v]); + } + } else { + indices.push(packed & 0xFFFF); + } + } + + // Reverse face winding + for (let i = 0; i < indices.length; i += 3) { + const temp = indices[i]; + indices[i] = indices[i + 2]; + indices[i + 2] = temp; + } + + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute('position', new THREE.Float32BufferAttribute(meshVertices.flat(), 3)); + geometry.setAttribute('normal', new THREE.Float32BufferAttribute(meshNormals.flat(), 3)); + geometry.setIndex(indices); + + if (hasTexture && meshUvs.length > 0) { + geometry.setAttribute('uv', new THREE.Float32BufferAttribute(meshUvs.flat(), 2)); + } + + return geometry; + } + + /** + * Apply position/direction/up transform from ActorLOD data. + * The game uses CalcLocalTransform with direction/up vectors. + */ + applyPartTransform(group, actorLOD) { + const pos = actorLOD.position; + const dir = actorLOD.direction; + const up = actorLOD.up; + + // Build right vector = cross(dir, up) + const right = new THREE.Vector3( + dir[1] * up[2] - dir[2] * up[1], + dir[2] * up[0] - dir[0] * up[2], + dir[0] * up[1] - dir[1] * up[0] + ); + + // Negate X for our coordinate system (matching VehiclePartRenderer's -v.x) + group.position.set(-pos[0], pos[1], pos[2]); + } + + /** + * Update a single part's color without full reload + */ + updatePartColor(partIndex, colorName) { + const partGroup = this.partGroups[partIndex]; + if (!partGroup) return; + + const colorEntry = ExtendedLegoColors[colorName] || ExtendedLegoColors['lego white']; + const threeColor = new THREE.Color(colorEntry.r / 255, colorEntry.g / 255, colorEntry.b / 255); + + partGroup.traverse((child) => { + if (child instanceof THREE.Mesh && child.material && !child.material.map) { + child.material.color = threeColor; + } + }); + + this.renderer.render(this.scene, this.camera); + } + + /** + * Get which body part was clicked + * @returns {number} Part index (0-9) or -1 if nothing hit + */ + getClickedPart(mouseEvent) { + if (!this.modelGroup) return -1; + + const rect = this.canvas.getBoundingClientRect(); + const mouse = new THREE.Vector2( + ((mouseEvent.clientX - rect.left) / rect.width) * 2 - 1, + -((mouseEvent.clientY - rect.top) / rect.height) * 2 + 1 + ); + + this.raycaster.setFromCamera(mouse, this.camera); + + for (let i = 0; i < this.partGroups.length; i++) { + const partGroup = this.partGroups[i]; + if (!partGroup) continue; + + const meshes = []; + partGroup.traverse((child) => { + if (child instanceof THREE.Mesh) meshes.push(child); + }); + + const intersects = this.raycaster.intersectObjects(meshes); + if (intersects.length > 0) return i; + } + + return -1; + } + + // ─── Animation System ──────────────────────────────────────────── + + /** + * Load and start animation for the given actor using g_cycles table. + * Animation index = sound + 4 * move. Pre-computes world-space transforms + * by evaluating the animation tree hierarchically, then plays via AnimationMixer. + * Falls back to Y-axis rotation if unavailable. + */ + async loadAnimationForActor(actorIndex, move = 0, sound = 0) { + if (!this.modelGroup) return; + + this.stopAnimation(); + + const suffixIdx = ACTOR_SUFFIX_INDEX[actorIndex] ?? 0; + const animIdx = sound + 4 * move; + const animName = G_CYCLES[suffixIdx]?.[animIdx]; + + if (!animName) return; // null entry in g_cycles — no animation for this combo + + try { + const animData = await this.fetchAnimationByName(animName); + if (!animData || !this.modelGroup) return; + + // Build animation node name → part group lookup + const nodeToPartGroup = new Map(); + for (let i = 0; i < this.partGroups.length; i++) { + const pg = this.partGroups[i]; + if (!pg) continue; + const lodName = pg.userData.lodName; + const animName = PART_NAME_TO_ANIM_NODE[lodName]; + if (animName) { + nodeToPartGroup.set(animName.toLowerCase(), pg); + } + } + + const tracks = this.buildHierarchicalTracks(animData, nodeToPartGroup); + if (tracks.length === 0) return; + + const clip = new THREE.AnimationClip('walk', -1, tracks); + this.mixer = new THREE.AnimationMixer(this.modelGroup); + this.currentAction = this.mixer.clipAction(clip); + this.currentAction.play(); + } catch (e) { + // Animation unavailable — fall back to rotation (handled in animate()) + } + } + + /** + * Fetch and parse an animation file by name (e.g. "CNs001xx"), with caching. + */ + async fetchAnimationByName(animName) { + if (this.animationCache.has(animName)) { + return this.animationCache.get(animName); + } + + const response = await fetch(`/animations/${animName}.ani`); + if (!response.ok) return null; + + const buffer = await response.arrayBuffer(); + const animData = parseAnimation(buffer); + this.animationCache.set(animName, animData); + return animData; + } + + /** + * Build world-space keyframe tracks by evaluating the animation tree + * hierarchically. At each unique keyframe time, walks the tree composing + * parent * child transforms via matrix multiplication, then decomposes + * to world-space position/quaternion for each part. + */ + buildHierarchicalTracks(animData, nodeToPartGroup) { + const duration = animData.duration; + + // Collect all unique keyframe times from the tree + const timesSet = new Set([0]); + this.collectKeyframeTimes(animData.rootNode, timesSet); + const times = [...timesSet].filter(t => t <= duration).sort((a, b) => a - b); + + // For each time, evaluate the full tree and store world-space transforms + const valueMap = new Map(); + const identity = new THREE.Matrix4(); + + for (const time of times) { + this.evaluateNode(animData.rootNode, time, identity, nodeToPartGroup, valueMap, true); + } + + // Convert to Three.js KeyframeTracks + const timesSec = times.map(t => t / 1000); + const tracks = []; + for (const [name, values] of valueMap) { + if (name.endsWith('.position')) { + tracks.push(new THREE.VectorKeyframeTrack(name, timesSec, values)); + } else if (name.endsWith('.quaternion')) { + tracks.push(new THREE.QuaternionKeyframeTrack(name, timesSec, values)); + } + } + return tracks; + } + + /** + * Recursively collect all unique keyframe times from the animation tree. + */ + collectKeyframeTimes(node, timesSet) { + const data = node.data; + for (const key of data.translationKeys) timesSet.add(key.time); + for (const key of data.rotationKeys) timesSet.add(key.time); + for (const key of data.scaleKeys) timesSet.add(key.time); + for (const child of node.children) { + this.collectKeyframeTimes(child, timesSet); + } + } + + /** + * Evaluate a single animation node at a given time, composing its local + * transform with the parent's world matrix. If the node maps to a part + * group, stores the decomposed world-space position and quaternion. + * Recurses into children with the composed matrix. + */ + evaluateNode(node, time, parentMatrix, nodeToPartGroup, valueMap, isRoot = false) { + const data = node.data; + let mat = new THREE.Matrix4(); + + // 1. Scale (applied first) + if (data.scaleKeys.length > 0) { + const scale = this.interpolateVertex(data.scaleKeys, time, false); + if (scale) { + mat.scale(scale); + } + if (data.rotationKeys.length > 0) { + mat = this.evaluateRotation(data.rotationKeys, time).multiply(mat); + } + } else if (data.rotationKeys.length > 0) { + mat = this.evaluateRotation(data.rotationKeys, time); + } + + // 2. Translation (skip on root node so the actor walks in place) + if (!isRoot && data.translationKeys.length > 0) { + const vertex = this.interpolateVertex(data.translationKeys, time, true); + if (vertex) { + mat.elements[12] += vertex.x; + mat.elements[13] += vertex.y; + mat.elements[14] += vertex.z; + } + } + + // 3. Compose with parent: world = parent * local + mat = parentMatrix.clone().multiply(mat); + + // 4. If this node maps to a part group, decompose and store + const nodeName = data.name?.toLowerCase(); + if (nodeName) { + const partGroup = nodeToPartGroup.get(nodeName); + if (partGroup) { + const position = new THREE.Vector3(); + const quaternion = new THREE.Quaternion(); + const scale = new THREE.Vector3(); + mat.decompose(position, quaternion, scale); + + if (Math.abs(scale.x) < 1e-8 || Math.abs(scale.y) < 1e-8 || Math.abs(scale.z) < 1e-8) { + quaternion.identity(); + } + + const trackName = partGroup.name; + this.pushValues(valueMap, `${trackName}.position`, position.toArray()); + this.pushValues(valueMap, `${trackName}.quaternion`, [quaternion.x, quaternion.y, quaternion.z, quaternion.w]); + } + } + + // 5. Recurse into children + for (const child of node.children) { + this.evaluateNode(child, time, mat, nodeToPartGroup, valueMap); + } + } + + /** + * Evaluate rotation keyframes at a given time. + * Handles slerp interpolation between keyframes with flag-based control. + * Coordinate conversion: game (w,x,y,z) → Three.js with X negated. + */ + evaluateRotation(keys, time) { + const { before, after } = this.getBeforeAndAfter(keys, time); + + const toQuat = (key) => new THREE.Quaternion(-key.x, key.y, key.z, key.w); + + if (!after) { + if (before.flags & 0x01) { + return new THREE.Matrix4().makeRotationFromQuaternion(toQuat(before)); + } + return new THREE.Matrix4(); + } + + if ((before.flags & 0x01) || (after.flags & 0x01)) { + const beforeQ = toQuat(before); + + // Flag 0x04: skip interpolation, use before value + if (after.flags & 0x04) { + return new THREE.Matrix4().makeRotationFromQuaternion(beforeQ); + } + + let afterQ = toQuat(after); + // Flag 0x02: negate the after quaternion before slerp + if (after.flags & 0x02) { + afterQ.set(-afterQ.x, -afterQ.y, -afterQ.z, -afterQ.w); + } + + const t = (time - before.time) / (after.time - before.time); + const result = new THREE.Quaternion().slerpQuaternions(beforeQ, afterQ, t); + return new THREE.Matrix4().makeRotationFromQuaternion(result); + } + + return new THREE.Matrix4(); + } + + /** + * Interpolate translation or scale keyframes at a given time. + * For translation: negates X for coordinate system conversion. + * For scale: no negation. + */ + interpolateVertex(keys, time, isTranslation) { + const { before, after } = this.getBeforeAndAfter(keys, time); + + const toVec = (key) => isTranslation + ? new THREE.Vector3(-key.x, key.y, key.z) + : new THREE.Vector3(key.x, key.y, key.z); + + if (!after) { + if (isTranslation && !(before.flags & 0x01)) { + // Check if vertex is non-zero (matching reference behavior) + if (Math.abs(before.x) < 1e-5 && Math.abs(before.y) < 1e-5 && Math.abs(before.z) < 1e-5) { + return null; + } + } + return toVec(before); + } + + if (isTranslation && !(before.flags & 0x01) && !(after.flags & 0x01)) { + // Both inactive — check if vertices are non-zero + const bNonZero = Math.abs(before.x) > 1e-5 || Math.abs(before.y) > 1e-5 || Math.abs(before.z) > 1e-5; + const aNonZero = Math.abs(after.x) > 1e-5 || Math.abs(after.y) > 1e-5 || Math.abs(after.z) > 1e-5; + if (!bNonZero && !aNonZero) return null; + } + + const t = (time - before.time) / (after.time - before.time); + return new THREE.Vector3().lerpVectors(toVec(before), toVec(after), t); + } + + /** + * Find the keyframes immediately before and after the given time. + */ + getBeforeAndAfter(keys, time) { + let idx = keys.findIndex(k => k.time > time); + if (idx < 0) idx = keys.length; + const before = keys[Math.max(0, idx - 1)]; + return { before, after: keys[idx] || null }; + } + + /** + * Append values to a named entry in the value map. + */ + pushValues(map, key, values) { + const existing = map.get(key); + if (!existing) { + map.set(key, [...values]); + } else { + existing.push(...values); + } + } + + stopAnimation() { + if (this.currentAction) { + this.currentAction.stop(); + this.currentAction = null; + } + if (this.mixer) { + this.mixer.stopAllAction(); + this.mixer = null; + } + } + + // ─── Scene Management ──────────────────────────────────────────── + + centerAndScaleModel() { + if (!this.modelGroup) return; + + const box = new THREE.Box3().setFromObject(this.modelGroup); + const center = box.getCenter(new THREE.Vector3()); + const size = box.getSize(new THREE.Vector3()); + + this.modelGroup.position.sub(center); + + const maxDim = Math.max(size.x, size.y, size.z); + if (maxDim > 0) { + const scale = 1.8 / maxDim; + this.modelGroup.scale.setScalar(scale); + } + } + + clearModel() { + this.stopAnimation(); + + if (this.modelGroup) { + this.modelGroup.traverse((child) => { + if (child instanceof THREE.Mesh) { + child.geometry?.dispose(); + child.material?.dispose(); + } + }); + this.scene.remove(this.modelGroup); + this.modelGroup = null; + } + this.partGroups = []; + + for (const texture of this.textures.values()) { + texture.dispose(); + } + this.textures.clear(); + } + + start() { + this.animating = true; + this.clock.start(); + this.animate(); + } + + stop() { + this.animating = false; + } + + animate = () => { + if (!this.animating) return; + requestAnimationFrame(this.animate); + + const delta = this.clock.getDelta(); + + if (this.mixer) { + this.mixer.update(delta); + } else if (this.modelGroup) { + // Fallback: rotate if no animation loaded + this.modelGroup.rotation.y += 0.01; + } + + this.renderer.render(this.scene, this.camera); + } + + resize(width, height) { + this.camera.aspect = width / height; + this.camera.updateProjectionMatrix(); + this.renderer.setSize(width, height, false); + } + + dispose() { + this.animating = false; + this.stopAnimation(); + this.clearModel(); + this.renderer?.dispose(); + this.animationCache.clear(); + } +} diff --git a/src/core/savegame/actorConstants.js b/src/core/savegame/actorConstants.js new file mode 100644 index 0000000..008a1a3 --- /dev/null +++ b/src/core/savegame/actorConstants.js @@ -0,0 +1,669 @@ +/** + * Actor data constants ported from LEGO1 source: + * isle/LEGO1/lego/legoomni/src/common/legoactors.cpp + * isle/LEGO1/lego/legoomni/include/legoactors.h + */ + +// LegoActorLOD flags +export const ActorLODFlags = Object.freeze({ + USE_TEXTURE: 0x01, + USE_COLOR: 0x02 +}); + +// LegoActorLODs enum — indices into ActorLODs[] +export const ActorLODIndex = Object.freeze({ + TOP: 0, + BODY: 1, + INFOHAT: 2, + INFOGRON: 3, + HEAD: 4, + ARMLFT: 5, + ARMRT: 6, + CLAWLFT: 7, + CLAWRT: 8, + LEGLFT: 9, + LEGRT: 10 +}); + +// LegoActorParts enum — indices into the 10-part array on each actor +export const ActorPart = Object.freeze({ + BODY: 0, + INFOHAT: 1, + INFOGRON: 2, + HEAD: 3, + ARMLFT: 4, + ARMRT: 5, + CLAWLFT: 6, + CLAWRT: 7, + LEGLFT: 8, + LEGRT: 9 +}); + +// Mapping from ActorPart index to the ActorLOD that provides its transform. +// ActorLODs[0] ("top") is the root and not directly a part. +// Parts 0..9 map to ActorLODs[1..10] respectively. +export const partToLODIndex = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + +/** + * g_actorLODs[11] — transform/bounding data for each body part position. + * Fields: name, parentName, flags, boundingSphere[4], boundingBox[6], + * position[3], direction[3], up[3] + */ +export const ActorLODs = Object.freeze([ + { name: 'top', parentName: 'top', flags: 0, + boundingSphere: [0.000267, 0.780808, -0.01906, 0.951612], + boundingBox: [-0.461166, -0.002794, -0.299442, 0.4617, 1.56441, 0.261321], + position: [0, 0, 0], direction: [0, 0, 1], up: [0, 1, 0] }, + { name: 'body', parentName: 'body', flags: ActorLODFlags.USE_TEXTURE, + boundingSphere: [0.00158332, 0.401828, -0.00048697, 0.408071], + boundingBox: [-0.287507, 0.150419, -0.147452, 0.289219, 0.649774, 0.14258], + position: [-0.00089, 0.436353, 0.007277], direction: [0, 0, 1], up: [0, 1, 0] }, + { name: 'infohat', parentName: 'infohat', flags: ActorLODFlags.USE_COLOR, + boundingSphere: [0, -0.00938, -0.01955, 0.35], + boundingBox: [-0.231822, -0.140237, -0.320954, 0.234149, 0.076968, 0.249083], + position: [0.000191, 1.519793, 0.001767], direction: [0, 0, 1], up: [0, 1, 0] }, + { name: 'infogron', parentName: 'infogron', flags: ActorLODFlags.USE_COLOR, + boundingSphere: [0, 0.11477, 0.00042, 0.26], + boundingBox: [-0.285558, -0.134391, -0.142231, 0.285507, 0.152986, 0.143071], + position: [-0.00089, 0.436353, 0.007277], direction: [0, 0, 1], up: [0, 1, 0] }, + { name: 'head', parentName: 'head', flags: ActorLODFlags.USE_TEXTURE, + boundingSphere: [0, -0.03006, 0, 0.3], + boundingBox: [-0.189506, -0.209665, -0.189824, 0.189532, 0.228822, 0.194945], + position: [-0.00105, 1.293115, 0.001781], direction: [0, 0, 1], up: [0, 1, 0] }, + { name: 'arm-lft', parentName: 'arm-lft', flags: ActorLODFlags.USE_COLOR, + boundingSphere: [-0.06815, -0.0973747, 0.0154655, 0.237], + boundingBox: [-0.137931, -0.282775, -0.105316, 0.000989, 0.100221, 0.140759], + position: [-0.225678, 0.963312, 0.023286], + direction: [-0.003031, -0.017187, 0.999848], up: [0.173622, 0.984658, 0.017453] }, + { name: 'arm-rt', parentName: 'arm-rt', flags: ActorLODFlags.USE_COLOR, + boundingSphere: [0.0680946, -0.097152, 0.0152722, 0.237], + boundingBox: [0.00141, -0.289604, -0.100831, 0.138786, 0.09291, 0.145437], + position: [0.223494, 0.963583, 0.018302], + direction: [0, 0, 1], up: [-0.173648, 0.984808, 0] }, + { name: 'claw-lft', parentName: 'claw-lft', flags: ActorLODFlags.USE_COLOR, + boundingSphere: [0.000773381, -0.101422, -0.0237761, 0.15], + boundingBox: [-0.089838, -0.246208, -0.117735, 0.091275, 0.000263, 0.07215], + position: [-0.341869, 0.700355, 0.092779], + direction: [0.000001, 0.000003, 1], up: [0.190812, 0.981627, -0.000003] }, + { name: 'claw-rt', parentName: 'claw-lft', flags: ActorLODFlags.USE_COLOR, + boundingSphere: [0.000773381, -0.101422, -0.0237761, 0.15], + boundingBox: [-0.095016, -0.245349, -0.117979, 0.086528, 0.00067, 0.069743], + position: [0.343317, 0.69924, 0.096123], + direction: [0.00606, -0.034369, 0.999391], up: [-0.190704, 0.981027, 0.034894] }, + { name: 'leg-lft', parentName: 'leg', flags: ActorLODFlags.USE_COLOR, + boundingSphere: [0.00433584, -0.177404, -0.0313928, 0.33], + boundingBox: [-0.129782, -0.440428, -0.184207, 0.13817, 0.118415, 0.122607], + position: [-0.156339, 0.436087, 0.006822], direction: [0, 0, 1], up: [0, 1, 0] }, + { name: 'leg-rt', parentName: 'leg', flags: ActorLODFlags.USE_COLOR, + boundingSphere: [0.00433584, -0.177404, -0.0313928, 0.33], + boundingBox: [-0.132864, -0.437138, -0.183944, 0.134614, 0.12043, 0.121888], + position: [0.151154, 0.436296, 0.007373], direction: [0, 0, 1], up: [0, 1, 0] } +]); + +// Index arrays — 0xff marks end-of-list sentinel +export const hatPartIndices = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19]; +export const pepperHatPartIndices = [21, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19]; +export const infomanHatPartIndices = [22]; +export const ghostHatPartIndices = [20]; +export const bodyPartIndices = [0, 1, 2, 3, 4, 5, 6, 7]; +export const hatColorIndices = [0, 1, 2, 3, 4, 5, 6, 7]; +export const faceTextureIndices = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13]; +export const chestTextureIndices = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 27]; +export const armColorIndices = [0, 1, 2, 3, 4, 5, 6, 7]; +export const clawRightColorIndices = [0, 1, 2, 3, 4, 5, 6, 7]; +export const clawLeftColorIndices = [0, 1, 2, 3, 4, 5, 6, 7]; +export const gronColorIndices = [0, 1, 2, 3, 4, 5, 6, 7]; +export const legColorIndices = [0, 1, 2, 3, 4, 5, 6, 7]; + +// Name arrays +export const hatPartNames = [ + 'baseball', 'chef', 'cap', 'cophat', 'helmet', 'ponytail', 'pageboy', 'shrthair', + 'bald', 'flower', 'cboyhat', 'cuphat', 'cathat', 'backbcap', 'pizhat', 'caprc', + 'capch', 'capdb', 'capjs', 'capmd', 'sheet', 'phat', 'icap' +]; + +export const bodyPartNames = [ + 'body', 'bodyred', 'bodyblck', 'bodywhte', 'bodyyllw', 'bodyblue', 'bodygren', 'bodybrwn' +]; + +export const chestTextures = [ + 'peprchst.gif', 'mamachst.gif', 'papachst.gif', 'nickchst.gif', 'norachst.gif', + 'infochst.gif', 'shftchst.gif', 'rac1chst.gif', 'rac2chst.gif', 'bth1chst.gif', + 'bth2chst.gif', 'mech.gif', 'polkadot.gif', 'bowtie.gif', 'postchst.gif', + 'vest.gif', 'doctor.gif', 'copchest.gif', 'l.gif', 'e.gif', + 'g.gif', 'o.gif', 'fruit.gif', 'flowers.gif', 'construct.gif', + 'paint.gif', 'l6.gif', 'unkchst.gif' +]; + +export const faceTextures = [ + 'peprface.gif', 'mamaface.gif', 'papaface.gif', 'nickface.gif', 'noraface.gif', + 'infoface.gif', 'shftface.gif', 'dogface.gif', 'womanshd.gif', 'smileshd.gif', + 'woman.gif', 'smile.gif', 'mustache.gif', 'black.gif' +]; + +export const colorAliases = [ + 'lego white', 'lego black', 'lego yellow', 'lego red', 'lego blue', 'lego brown', 'lego lt grey', 'lego green' +]; + +// Character type classification +export const CharacterType = Object.freeze({ + STANDARD: 0, + PEPPER: 1, + INFOMAN: 2, + GHOST: 3 +}); + +/** + * Determine character type from actor index + */ +export function getCharacterType(actorIndex) { + if (actorIndex === 0 || actorIndex === 56) return CharacterType.PEPPER; // pepper, pep + if (actorIndex === 5) return CharacterType.INFOMAN; + if (actorIndex >= 48 && actorIndex <= 53) return CharacterType.GHOST; // ghost, ghost01..05 + return CharacterType.STANDARD; +} + +// Reference names for the index arrays (used to build part configs) +const HP = 'hatPartIndices'; +const PHP = 'pepperHatPartIndices'; +const IHP = 'infomanHatPartIndices'; +const GHP = 'ghostHatPartIndices'; +const BP = 'bodyPartIndices'; +const HC = 'hatColorIndices'; +const FT = 'faceTextureIndices'; +const CT = 'chestTextureIndices'; +const AC = 'armColorIndices'; +const CRC = 'clawRightColorIndices'; +const CLC = 'clawLeftColorIndices'; +const GC = 'gronColorIndices'; +const LC = 'legColorIndices'; + +// Lookup tables for the index array references +const indexArrays = { + [HP]: hatPartIndices, + [PHP]: pepperHatPartIndices, + [IHP]: infomanHatPartIndices, + [GHP]: ghostHatPartIndices, + [BP]: bodyPartIndices, + [HC]: hatColorIndices, + [FT]: faceTextureIndices, + [CT]: chestTextureIndices, + [AC]: armColorIndices, + [CRC]: clawRightColorIndices, + [CLC]: clawLeftColorIndices, + [GC]: gronColorIndices, + [LC]: legColorIndices +}; + +const nameArrays = { + hatPartNames, + bodyPartNames, + chestTextures, + faceTextures, + colorAliases +}; + +/** + * Helper to build a Part entry from compact form. + * C++ Part has: { partNameIndices, partName, partNameIndex, nameIndices, names, nameIndex } + * We store references to our JS arrays. + */ +function P(partNameIndicesRef, partNamesRef, partNameIndex, nameIndicesRef, namesRef, nameIndex) { + return { + partNameIndices: partNameIndicesRef ? indexArrays[partNameIndicesRef] : null, + partNames: partNamesRef ? nameArrays[partNamesRef] : null, + partNameIndex, + nameIndices: indexArrays[nameIndicesRef], + names: nameArrays[namesRef], + nameIndex + }; +} + +// Short aliases for name array refs +const HPN = 'hatPartNames'; +const BPN = 'bodyPartNames'; +const CTN = 'chestTextures'; +const FTN = 'faceTextures'; +const CA = 'colorAliases'; + +/** + * g_actorInfoInit[66] — All 66 game actors. + * Each entry: { name, sound, move, mood, parts[10] } + * Parts order: body, infohat, infogron, head, armlft, armrt, clawlft, clawrt, leglft, legrt + */ +export const ActorInfoInit = Object.freeze([ + /* 0 */ { name: 'pepper', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 1, CT, CTN, 0), P(PHP, HPN, 0, HC, CA, 0), + P(null, null, 0, GC, CA, 4), P(null, null, 0, FT, FTN, 0), + P(null, null, 0, AC, CA, 3), P(null, null, 0, AC, CA, 3), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 4), P(null, null, 0, LC, CA, 4)] }, + /* 1 */ { name: 'mama', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 1, CT, CTN, 1), P(HP, HPN, 1, HC, CA, 0), + P(null, null, 0, GC, CA, 0), P(null, null, 0, FT, FTN, 1), + P(null, null, 0, AC, CA, 3), P(null, null, 0, AC, CA, 3), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 0), P(null, null, 0, LC, CA, 0)] }, + /* 2 */ { name: 'papa', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 2, CT, CTN, 2), P(HP, HPN, 1, HC, CA, 0), + P(null, null, 0, GC, CA, 1), P(null, null, 0, FT, FTN, 2), + P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 1), P(null, null, 0, LC, CA, 1)] }, + /* 3 */ { name: 'nick', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 2, CT, CTN, 3), P(HP, HPN, 3, HC, CA, 1), + P(null, null, 0, GC, CA, 4), P(null, null, 0, FT, FTN, 3), + P(null, null, 0, AC, CA, 1), P(null, null, 0, AC, CA, 1), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 4), P(null, null, 0, LC, CA, 4)] }, + /* 4 */ { name: 'laura', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 2, CT, CTN, 4), P(HP, HPN, 3, HC, CA, 1), + P(null, null, 0, GC, CA, 4), P(null, null, 0, FT, FTN, 4), + P(null, null, 0, AC, CA, 1), P(null, null, 0, AC, CA, 1), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 4), P(null, null, 0, LC, CA, 4)] }, + /* 5 */ { name: 'infoman', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 1, CT, CTN, 5), P(IHP, HPN, 0, HC, CA, 3), + P(null, null, 0, GC, CA, 0), P(null, null, 0, FT, FTN, 5), + P(null, null, 0, AC, CA, 1), P(null, null, 0, AC, CA, 1), + P(null, null, 0, CLC, CA, 0), P(null, null, 0, CRC, CA, 0), + P(null, null, 0, LC, CA, 0), P(null, null, 0, LC, CA, 0)] }, + /* 6 */ { name: 'brickstr', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 3, CT, CTN, 6), P(HP, HPN, 13, HC, CA, 1), + P(null, null, 0, GC, CA, 1), P(null, null, 0, FT, FTN, 6), + P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0), + P(null, null, 0, CLC, CA, 0), P(null, null, 0, CRC, CA, 4), + P(null, null, 0, LC, CA, 1), P(null, null, 0, LC, CA, 1)] }, + /* 7 */ { name: 'studs', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 1, CT, CTN, 7), P(HP, HPN, 4, HC, CA, 1), + P(null, null, 0, GC, CA, 1), P(null, null, 0, FT, FTN, 7), + P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 1), P(null, null, 0, LC, CA, 1)] }, + /* 8 */ { name: 'rhoda', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 5, CT, CTN, 8), P(HP, HPN, 4, HC, CA, 3), + P(null, null, 0, GC, CA, 1), P(null, null, 0, FT, FTN, 8), + P(null, null, 0, AC, CA, 3), P(null, null, 0, AC, CA, 3), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 1), P(null, null, 0, LC, CA, 1)] }, + /* 9 */ { name: 'valerie', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 1, CT, CTN, 9), P(HP, HPN, 5, HC, CA, 3), + P(null, null, 0, GC, CA, 3), P(null, null, 0, FT, FTN, 8), + P(null, null, 0, AC, CA, 2), P(null, null, 0, AC, CA, 2), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 2), P(null, null, 0, LC, CA, 2)] }, + /* 10 */ { name: 'snap', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 3, CT, CTN, 10), P(HP, HPN, 0, HC, CA, 4), + P(null, null, 0, GC, CA, 1), P(null, null, 0, FT, FTN, 9), + P(null, null, 0, AC, CA, 2), P(null, null, 0, AC, CA, 2), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 2), P(null, null, 0, LC, CA, 2)] }, + /* 11 */ { name: 'pt', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 5, CT, CTN, 11), P(HP, HPN, 6, HC, CA, 1), + P(null, null, 0, GC, CA, 1), P(null, null, 0, FT, FTN, 8), + P(null, null, 0, AC, CA, 4), P(null, null, 0, AC, CA, 4), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 1), P(null, null, 0, LC, CA, 1)] }, + /* 12 */ { name: 'mg', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 1, CT, CTN, 12), P(HP, HPN, 6, HC, CA, 5), + P(null, null, 0, GC, CA, 4), P(null, null, 0, FT, FTN, 10), + P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 4), P(null, null, 0, LC, CA, 4)] }, + /* 13 */ { name: 'bu', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 3, CT, CTN, 13), P(HP, HPN, 7, HC, CA, 5), + P(null, null, 0, GC, CA, 5), P(null, null, 0, FT, FTN, 11), + P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 5), P(null, null, 0, LC, CA, 5)] }, + /* 14 */ { name: 'ml', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 5, CT, CTN, 14), P(HP, HPN, 2, HC, CA, 4), + P(null, null, 0, GC, CA, 1), P(null, null, 0, FT, FTN, 12), + P(null, null, 0, AC, CA, 4), P(null, null, 0, AC, CA, 4), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 1), P(null, null, 0, LC, CA, 1)] }, + /* 15 */ { name: 'nu', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 5, CT, CTN, 11), P(HP, HPN, 7, HC, CA, 1), + P(null, null, 0, GC, CA, 4), P(null, null, 0, FT, FTN, 7), + P(null, null, 0, AC, CA, 4), P(null, null, 0, AC, CA, 4), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 4), P(null, null, 0, LC, CA, 4)] }, + /* 16 */ { name: 'na', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 3, CT, CTN, 15), P(HP, HPN, 10, HC, CA, 3), + P(null, null, 0, GC, CA, 3), P(null, null, 0, FT, FTN, 8), + P(null, null, 0, AC, CA, 4), P(null, null, 0, AC, CA, 4), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 3), P(null, null, 0, LC, CA, 3)] }, + /* 17 */ { name: 'cl', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 3, CT, CTN, 16), P(HP, HPN, 19, HC, CA, 0), + P(null, null, 0, GC, CA, 4), P(null, null, 0, FT, FTN, 12), + P(null, null, 0, AC, CA, 4), P(null, null, 0, AC, CA, 4), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 4), P(null, null, 0, LC, CA, 4)] }, + /* 18 */ { name: 'en', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 3, CT, CTN, 16), P(HP, HPN, 0, HC, CA, 0), + P(null, null, 0, GC, CA, 0), P(null, null, 0, FT, FTN, 11), + P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 0), P(null, null, 0, LC, CA, 0)] }, + /* 19 */ { name: 're', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 3, CT, CTN, 16), P(HP, HPN, 0, HC, CA, 0), + P(null, null, 0, GC, CA, 0), P(null, null, 0, FT, FTN, 11), + P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 0), P(null, null, 0, LC, CA, 0)] }, + /* 20 */ { name: 'ro', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 2, CT, CTN, 17), P(HP, HPN, 3, HC, CA, 1), + P(null, null, 0, GC, CA, 1), P(null, null, 0, FT, FTN, 9), + P(null, null, 0, AC, CA, 1), P(null, null, 0, AC, CA, 1), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 1), P(null, null, 0, LC, CA, 1)] }, + /* 21 */ { name: 'd1', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 5, CT, CTN, 11), P(HP, HPN, 15, HC, CA, 0), + P(null, null, 0, GC, CA, 4), P(null, null, 0, FT, FTN, 11), + P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 4), P(null, null, 0, LC, CA, 4)] }, + /* 22 */ { name: 'd2', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 5, CT, CTN, 11), P(HP, HPN, 16, HC, CA, 0), + P(null, null, 0, GC, CA, 4), P(null, null, 0, FT, FTN, 11), + P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 4), P(null, null, 0, LC, CA, 4)] }, + /* 23 */ { name: 'd3', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 5, CT, CTN, 11), P(HP, HPN, 17, HC, CA, 0), + P(null, null, 0, GC, CA, 4), P(null, null, 0, FT, FTN, 11), + P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 4), P(null, null, 0, LC, CA, 4)] }, + /* 24 */ { name: 'd4', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 5, CT, CTN, 11), P(HP, HPN, 18, HC, CA, 0), + P(null, null, 0, GC, CA, 4), P(null, null, 0, FT, FTN, 11), + P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 4), P(null, null, 0, LC, CA, 4)] }, + /* 25 */ { name: 'l1', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 1, CT, CTN, 18), P(HP, HPN, 5, HC, CA, 1), + P(null, null, 0, GC, CA, 0), P(null, null, 0, FT, FTN, 11), + P(null, null, 0, AC, CA, 2), P(null, null, 0, AC, CA, 2), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 0), P(null, null, 0, LC, CA, 0)] }, + /* 26 */ { name: 'l2', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 1, CT, CTN, 19), P(HP, HPN, 6, HC, CA, 1), + P(null, null, 0, GC, CA, 0), P(null, null, 0, FT, FTN, 12), + P(null, null, 0, AC, CA, 2), P(null, null, 0, AC, CA, 2), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 0), P(null, null, 0, LC, CA, 0)] }, + /* 27 */ { name: 'l3', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 1, CT, CTN, 20), P(HP, HPN, 7, HC, CA, 1), + P(null, null, 0, GC, CA, 0), P(null, null, 0, FT, FTN, 11), + P(null, null, 0, AC, CA, 2), P(null, null, 0, AC, CA, 2), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 0), P(null, null, 0, LC, CA, 0)] }, + /* 28 */ { name: 'l4', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 1, CT, CTN, 21), P(HP, HPN, 7, HC, CA, 1), + P(null, null, 0, GC, CA, 0), P(null, null, 0, FT, FTN, 11), + P(null, null, 0, AC, CA, 2), P(null, null, 0, AC, CA, 2), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 0), P(null, null, 0, LC, CA, 0)] }, + /* 29 */ { name: 'l5', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 1, CT, CTN, 26), P(HP, HPN, 7, HC, CA, 1), + P(null, null, 0, GC, CA, 3), P(null, null, 0, FT, FTN, 12), + P(null, null, 0, AC, CA, 2), P(null, null, 0, AC, CA, 2), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 0), P(null, null, 0, LC, CA, 0)] }, + /* 30 */ { name: 'l6', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 1, CT, CTN, 26), P(HP, HPN, 0, HC, CA, 1), + P(null, null, 0, GC, CA, 3), P(null, null, 0, FT, FTN, 11), + P(null, null, 0, AC, CA, 2), P(null, null, 0, AC, CA, 2), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 0), P(null, null, 0, LC, CA, 0)] }, + /* 31 */ { name: 'b1', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 0, LC, CA, 1), P(HP, HPN, 7, HC, CA, 1), + P(null, null, 0, GC, CA, 1), P(null, null, 0, FT, FTN, 12), + P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 1), P(null, null, 0, LC, CA, 1)] }, + /* 32 */ { name: 'b2', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 0, LC, CA, 1), P(HP, HPN, 5, HC, CA, 1), + P(null, null, 0, GC, CA, 4), P(null, null, 0, FT, FTN, 10), + P(null, null, 0, AC, CA, 3), P(null, null, 0, AC, CA, 3), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 4), P(null, null, 0, LC, CA, 4)] }, + /* 33 */ { name: 'b3', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 0, LC, CA, 4), P(HP, HPN, 7, HC, CA, 5), + P(null, null, 0, GC, CA, 1), P(null, null, 0, FT, FTN, 11), + P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 1), P(null, null, 0, LC, CA, 1)] }, + /* 34 */ { name: 'b4', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 0, LC, CA, 1), P(HP, HPN, 7, HC, CA, 1), + P(null, null, 0, GC, CA, 1), P(null, null, 0, FT, FTN, 9), + P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 1), P(null, null, 0, LC, CA, 1)] }, + /* 35 */ { name: 'cm', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 4, CT, CTN, 22), P(HP, HPN, 9, HC, CA, 2), + P(null, null, 0, GC, CA, 3), P(null, null, 0, FT, FTN, 8), + P(null, null, 0, AC, CA, 2), P(null, null, 0, AC, CA, 2), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 3), P(null, null, 0, LC, CA, 3)] }, + /* 36 */ { name: 'gd', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 0, LC, CA, 1), P(HP, HPN, 7, HC, CA, 1), + P(null, null, 0, GC, CA, 6), P(null, null, 0, FT, FTN, 11), + P(null, null, 0, AC, CA, 6), P(null, null, 0, AC, CA, 6), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 6), P(null, null, 0, LC, CA, 6)] }, + /* 37 */ { name: 'rd', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 0, LC, CA, 3), P(HP, HPN, 0, HC, CA, 3), + P(null, null, 0, GC, CA, 7), P(null, null, 0, FT, FTN, 9), + P(null, null, 0, AC, CA, 3), P(null, null, 0, AC, CA, 3), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 7), P(null, null, 0, LC, CA, 7)] }, + /* 38 */ { name: 'pg', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 0, LC, CA, 3), P(HP, HPN, 5, HC, CA, 3), + P(null, null, 0, GC, CA, 3), P(null, null, 0, FT, FTN, 11), + P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 3), P(null, null, 0, LC, CA, 3)] }, + /* 39 */ { name: 'bd', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 0, LC, CA, 6), P(HP, HPN, 0, HC, CA, 6), + P(null, null, 0, GC, CA, 1), P(null, null, 0, FT, FTN, 12), + P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 1), P(null, null, 0, LC, CA, 1)] }, + /* 40 */ { name: 'sy', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 0, LC, CA, 4), P(HP, HPN, 5, HC, CA, 6), + P(null, null, 0, GC, CA, 4), P(null, null, 0, FT, FTN, 10), + P(null, null, 0, AC, CA, 2), P(null, null, 0, AC, CA, 2), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 4), P(null, null, 0, LC, CA, 4)] }, + /* 41 */ { name: 'gn', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 6, CT, CTN, 13), P(HP, HPN, 7, HC, CA, 5), + P(null, null, 0, GC, CA, 5), P(null, null, 0, FT, FTN, 9), + P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 5), P(null, null, 0, LC, CA, 5)] }, + /* 42 */ { name: 'df', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 5, CT, CTN, 23), P(HP, HPN, 6, HC, CA, 5), + P(null, null, 0, GC, CA, 6), P(null, null, 0, FT, FTN, 8), + P(null, null, 0, AC, CA, 4), P(null, null, 0, AC, CA, 4), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 6), P(null, null, 0, LC, CA, 6)] }, + /* 43 */ { name: 'bs', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 3, CT, CTN, 10), P(HP, HPN, 7, HC, CA, 3), + P(null, null, 0, GC, CA, 7), P(null, null, 0, FT, FTN, 11), + P(null, null, 0, AC, CA, 2), P(null, null, 0, AC, CA, 2), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 2), P(null, null, 0, LC, CA, 2)] }, + /* 44 */ { name: 'lt', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 3, CT, CTN, 10), P(HP, HPN, 7, HC, CA, 1), + P(null, null, 0, GC, CA, 4), P(null, null, 0, FT, FTN, 11), + P(null, null, 0, AC, CA, 2), P(null, null, 0, AC, CA, 2), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 2), P(null, null, 0, LC, CA, 2)] }, + /* 45 */ { name: 'st', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 1, CT, CTN, 9), P(HP, HPN, 5, HC, CA, 5), + P(null, null, 0, GC, CA, 3), P(null, null, 0, FT, FTN, 10), + P(null, null, 0, AC, CA, 2), P(null, null, 0, AC, CA, 2), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 2), P(null, null, 0, LC, CA, 2)] }, + /* 46 */ { name: 'bm', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 3, CT, CTN, 24), P(HP, HPN, 0, HC, CA, 4), + P(null, null, 0, GC, CA, 4), P(null, null, 0, FT, FTN, 7), + P(null, null, 0, AC, CA, 6), P(null, null, 0, AC, CA, 6), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 4), P(null, null, 0, LC, CA, 4)] }, + /* 47 */ { name: 'jk', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 3, CT, CTN, 24), P(HP, HPN, 0, HC, CA, 4), + P(null, null, 0, GC, CA, 4), P(null, null, 0, FT, FTN, 9), + P(null, null, 0, AC, CA, 6), P(null, null, 0, AC, CA, 6), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 4), P(null, null, 0, LC, CA, 4)] }, + /* 48 */ { name: 'ghost', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 0, LC, CA, 0), P(GHP, HPN, 0, HC, CA, 0), + P(null, null, 0, GC, CA, 0), P(null, null, 0, FT, FTN, 13), + P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0), + P(null, null, 0, CLC, CA, 0), P(null, null, 0, CRC, CA, 0), + P(null, null, 0, LC, CA, 0), P(null, null, 0, LC, CA, 0)] }, + /* 49 */ { name: 'ghost01', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 0, LC, CA, 0), P(GHP, HPN, 0, HC, CA, 0), + P(null, null, 0, GC, CA, 0), P(null, null, 0, FT, FTN, 13), + P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0), + P(null, null, 0, CLC, CA, 0), P(null, null, 0, CRC, CA, 0), + P(null, null, 0, LC, CA, 0), P(null, null, 0, LC, CA, 0)] }, + /* 50 */ { name: 'ghost02', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 0, LC, CA, 0), P(GHP, HPN, 0, HC, CA, 0), + P(null, null, 0, GC, CA, 0), P(null, null, 0, FT, FTN, 13), + P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0), + P(null, null, 0, CLC, CA, 0), P(null, null, 0, CRC, CA, 0), + P(null, null, 0, LC, CA, 0), P(null, null, 0, LC, CA, 0)] }, + /* 51 */ { name: 'ghost03', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 0, LC, CA, 0), P(GHP, HPN, 0, HC, CA, 0), + P(null, null, 0, GC, CA, 0), P(null, null, 0, FT, FTN, 13), + P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0), + P(null, null, 0, CLC, CA, 0), P(null, null, 0, CRC, CA, 0), + P(null, null, 0, LC, CA, 0), P(null, null, 0, LC, CA, 0)] }, + /* 52 */ { name: 'ghost04', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 0, LC, CA, 0), P(GHP, HPN, 0, HC, CA, 0), + P(null, null, 0, GC, CA, 0), P(null, null, 0, FT, FTN, 13), + P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0), + P(null, null, 0, CLC, CA, 0), P(null, null, 0, CRC, CA, 0), + P(null, null, 0, LC, CA, 0), P(null, null, 0, LC, CA, 0)] }, + /* 53 */ { name: 'ghost05', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 0, LC, CA, 0), P(GHP, HPN, 0, HC, CA, 0), + P(null, null, 0, GC, CA, 0), P(null, null, 0, FT, FTN, 13), + P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0), + P(null, null, 0, CLC, CA, 0), P(null, null, 0, CRC, CA, 0), + P(null, null, 0, LC, CA, 0), P(null, null, 0, LC, CA, 0)] }, + /* 54 */ { name: 'hg', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 0, LC, CA, 3), P(HP, HPN, 8, HC, CA, 3), + P(null, null, 0, GC, CA, 3), P(null, null, 0, FT, FTN, 8), + P(null, null, 0, AC, CA, 3), P(null, null, 0, AC, CA, 3), + P(null, null, 0, CLC, CA, 3), P(null, null, 0, CRC, CA, 3), + P(null, null, 0, LC, CA, 3), P(null, null, 0, LC, CA, 3)] }, + /* 55 */ { name: 'pntgy', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 0, LC, CA, 3), P(HP, HPN, 0, HC, CA, 3), + P(null, null, 0, GC, CA, 3), P(null, null, 0, FT, FTN, 7), + P(null, null, 0, AC, CA, 3), P(null, null, 0, AC, CA, 3), + P(null, null, 0, CLC, CA, 3), P(null, null, 0, CRC, CA, 3), + P(null, null, 0, LC, CA, 3), P(null, null, 0, LC, CA, 3)] }, + /* 56 */ { name: 'pep', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 1, CT, CTN, 0), P(PHP, HPN, 0, HC, CA, 0), + P(null, null, 0, GC, CA, 4), P(null, null, 0, FT, FTN, 0), + P(null, null, 0, AC, CA, 3), P(null, null, 0, AC, CA, 3), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 4), P(null, null, 0, LC, CA, 4)] }, + /* 57 */ { name: 'cop01', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 2, CT, CTN, 17), P(HP, HPN, 3, HC, CA, 1), + P(null, null, 0, GC, CA, 1), P(null, null, 0, FT, FTN, 9), + P(null, null, 0, AC, CA, 1), P(null, null, 0, AC, CA, 1), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 1), P(null, null, 0, LC, CA, 1)] }, + /* 58 */ { name: 'actor_01', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 0, LC, CA, 4), P(HP, HPN, 5, HC, CA, 6), + P(null, null, 0, GC, CA, 4), P(null, null, 0, FT, FTN, 10), + P(null, null, 0, AC, CA, 2), P(null, null, 0, AC, CA, 2), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 4), P(null, null, 0, LC, CA, 4)] }, + /* 59 */ { name: 'actor_02', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 0, LC, CA, 6), P(HP, HPN, 0, HC, CA, 6), + P(null, null, 0, GC, CA, 1), P(null, null, 0, FT, FTN, 12), + P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 1), P(null, null, 0, LC, CA, 1)] }, + /* 60 */ { name: 'actor_03', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 0, LC, CA, 1), P(HP, HPN, 7, HC, CA, 1), + P(null, null, 0, GC, CA, 6), P(null, null, 0, FT, FTN, 11), + P(null, null, 0, AC, CA, 6), P(null, null, 0, AC, CA, 6), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 6), P(null, null, 0, LC, CA, 6)] }, + /* 61 */ { name: 'actor_04', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 1, CT, CTN, 12), P(HP, HPN, 6, HC, CA, 5), + P(null, null, 0, GC, CA, 4), P(null, null, 0, FT, FTN, 10), + P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 4), P(null, null, 0, LC, CA, 4)] }, + /* 62 */ { name: 'actor_05', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 4, CT, CTN, 22), P(HP, HPN, 9, HC, CA, 2), + P(null, null, 0, GC, CA, 3), P(null, null, 0, FT, FTN, 8), + P(null, null, 0, AC, CA, 2), P(null, null, 0, AC, CA, 2), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 3), P(null, null, 0, LC, CA, 3)] }, + /* 63 */ { name: 'btmncycl', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 0, LC, CA, 3), P(HP, HPN, 5, HC, CA, 3), + P(null, null, 0, GC, CA, 3), P(null, null, 0, FT, FTN, 11), + P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 3), P(null, null, 0, LC, CA, 3)] }, + /* 64 */ { name: 'cboycycl', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 3, CT, CTN, 10), P(HP, HPN, 7, HC, CA, 3), + P(null, null, 0, GC, CA, 7), P(null, null, 0, FT, FTN, 11), + P(null, null, 0, AC, CA, 2), P(null, null, 0, AC, CA, 2), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 2), P(null, null, 0, LC, CA, 2)] }, + /* 65 */ { name: 'boatman', sound: 0, move: 0, mood: 0, parts: [ + P(BP, BPN, 0, LC, CA, 3), P(HP, HPN, 0, HC, CA, 3), + P(null, null, 0, GC, CA, 7), P(null, null, 0, FT, FTN, 9), + P(null, null, 0, AC, CA, 3), P(null, null, 0, AC, CA, 3), + P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2), + P(null, null, 0, LC, CA, 7), P(null, null, 0, LC, CA, 7)] } +]); + +/** + * Save file field offsets within the 16-byte character record. + * The save file stores these per-character values that override defaults: + * sound(S32) + move(S32) + mood(U8) + hatPartNameIndex(U8) + hatNameIndex(U8) + * + infogronNameIndex(U8) + armlftNameIndex(U8) + armrtNameIndex(U8) + * + leglftNameIndex(U8) + legrtNameIndex(U8) + */ +export const CharacterFieldOffsets = Object.freeze({ + sound: 0, // S32 + move: 4, // S32 + mood: 8, // U8 + hatPartNameIndex: 9, // U8 + hatNameIndex: 10, // U8 + infogronNameIndex: 11, // U8 + armlftNameIndex: 12, // U8 + armrtNameIndex: 13, // U8 + leglftNameIndex: 14, // U8 + legrtNameIndex: 15 // U8 +}); + +export const CHARACTER_RECORD_SIZE = 16; + +/** + * Part labels for display + */ +export const ActorPartLabels = Object.freeze({ + 0: 'Body', + 1: 'Hat', + 2: 'Torso Emblem', + 3: 'Head', + 4: 'Left Arm', + 5: 'Right Arm', + 6: 'Left Claw', + 7: 'Right Claw', + 8: 'Left Leg', + 9: 'Right Leg' +}); diff --git a/src/core/savegame/constants.js b/src/core/savegame/constants.js index eb6c8cf..7d82085 100644 --- a/src/core/savegame/constants.js +++ b/src/core/savegame/constants.js @@ -2,6 +2,12 @@ * Constants and enums from KSY save file specifications */ +// Re-export actor constants +export { ActorInfoInit, ActorLODs, ActorPart, ActorLODFlags, ActorPartLabels, + CharacterType, getCharacterType, CharacterFieldOffsets, CHARACTER_RECORD_SIZE, + hatPartNames, bodyPartNames, chestTextures, faceTextures, colorAliases +} from './actorConstants.js'; + // Save game file version (must match for valid saves) export const SAVEGAME_VERSION = 0x1000c; // 65548 diff --git a/src/core/savegame/index.js b/src/core/savegame/index.js index 14e2e7c..9619ac3 100644 --- a/src/core/savegame/index.js +++ b/src/core/savegame/index.js @@ -91,6 +91,8 @@ export async function listSaveSlots() { missions: null, variables: null, act1State: null, + characters: null, + charactersOffset: null, playerName: null, buffer: null }; @@ -104,6 +106,8 @@ export async function listSaveSlots() { slot.missions = parsed.missions; slot.variables = parsed.variables; slot.act1State = parsed.act1State || null; + slot.characters = parsed.characters || null; + slot.charactersOffset = parsed.charactersOffset || null; slot.buffer = buffer; // Try to get player name @@ -168,6 +172,8 @@ export async function loadSaveSlot(slotNumber) { missions: parsed.missions, variables: parsed.variables, act1State: parsed.act1State || null, + characters: parsed.characters || null, + charactersOffset: parsed.charactersOffset || null, playerName, buffer }; @@ -238,6 +244,17 @@ export async function updateSaveSlot(slotNumber, updates) { } } + // Apply character update + if (updates.character) { + const { characterIndex, field, value } = updates.character; + const charSerializer = createSerializer(newBuffer); + const result = charSerializer.updateCharacter(characterIndex, field, value); + if (result) { + newBuffer = result; + modified = true; + } + } + // Apply texture update if (updates.texture) { const { textureName, textureData } = updates.texture; diff --git a/src/lib/SaveEditorPage.svelte b/src/lib/SaveEditorPage.svelte index 8970c86..2d6b206 100644 --- a/src/lib/SaveEditorPage.svelte +++ b/src/lib/SaveEditorPage.svelte @@ -6,6 +6,7 @@ import SkyColorEditor from './save-editor/SkyColorEditor.svelte'; import LightPositionEditor from './save-editor/LightPositionEditor.svelte'; import VehicleEditor from './save-editor/VehicleEditor.svelte'; + import ActorEditor from './save-editor/ActorEditor.svelte'; import { saveEditorState, currentPage } from '../stores.js'; import { listSaveSlots, updateSaveSlot, updatePlayerName } from '../core/savegame/index.js'; import { Actor, ActorNames } from '../core/savegame/constants.js'; @@ -23,7 +24,8 @@ { id: 'player', label: 'Player', firstSection: 'name' }, { id: 'scores', label: 'Scores', firstSection: null }, { id: 'island', label: 'Island', firstSection: 'skycolor' }, - { id: 'vehicles', label: 'Vehicles', firstSection: null } + { id: 'vehicles', label: 'Vehicles', firstSection: null }, + { id: 'actors', label: 'Actors', firstSection: null } ]; // Reset state when navigating to this page @@ -120,7 +122,7 @@ if (updated) { slots = slots.map(s => s.slotNumber === selectedSlot - ? { ...s, variables: updated.variables, act1State: updated.act1State } + ? { ...s, variables: updated.variables, act1State: updated.act1State, characters: updated.characters } : s ); } @@ -405,6 +407,13 @@ {/if} + + +
+ {#if $currentPage === 'save-editor'} + + {/if} +
{/if} diff --git a/src/lib/save-editor/ActorEditor.svelte b/src/lib/save-editor/ActorEditor.svelte new file mode 100644 index 0000000..76b8449 --- /dev/null +++ b/src/lib/save-editor/ActorEditor.svelte @@ -0,0 +1,299 @@ + + + +
+ + + {#if loading} +
+
+
+ {:else if error} +
{error}
+ {/if} +
+ +
+
+ +
+ {actorIndex + 1} / {ActorInfoInit.length} + {actorName} +
+ +
+
+
+ +