From 412d8a42337d89ac1bb80528451ee11288245b43 Mon Sep 17 00:00:00 2001 From: Christian Semmler Date: Sat, 7 Feb 2026 21:51:28 -0800 Subject: [PATCH] Extract BaseRenderer to deduplicate actor and vehicle renderers - Extract shared Three.js setup, lighting, texture, geometry, and animation loop code into BaseRenderer base class (~170 lines) - Deduplicate WdbParser.parseGlobalParts via parsePartData delegation - Consolidate lego brown/lt grey into shared LegoColors constant - Remove dead code: updatePartColor, SUFFIX_NAMES, CharacterType, getCharacterType, partToLODIndex, unused imports and re-exports - Simplify updateCharacter and resolve methods by removing unnecessary defensive checks on frozen data and bounded UI inputs - Extract actorKey helper in ActorEditor to deduplicate key computation - Delete unused animations/manifest.json --- public/animations/manifest.json | 16 -- src/core/formats/SaveGameSerializer.js | 24 +- src/core/formats/WdbParser.js | 28 +- src/core/rendering/ActorRenderer.js | 298 ++-------------------- src/core/rendering/BaseRenderer.js | 209 +++++++++++++++ src/core/rendering/VehiclePartRenderer.js | 194 +------------- src/core/savegame/actorConstants.js | 23 -- src/core/savegame/constants.js | 10 +- src/lib/save-editor/ActorEditor.svelte | 13 +- 9 files changed, 260 insertions(+), 555 deletions(-) delete mode 100644 public/animations/manifest.json create mode 100644 src/core/rendering/BaseRenderer.js diff --git a/public/animations/manifest.json b/public/animations/manifest.json deleted file mode 100644 index 8f2c4a8..0000000 --- a/public/animations/manifest.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "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/SaveGameSerializer.js b/src/core/formats/SaveGameSerializer.js index d96f24b..f8cdb1b 100644 --- a/src/core/formats/SaveGameSerializer.js +++ b/src/core/formats/SaveGameSerializer.js @@ -4,7 +4,8 @@ */ import { SaveGameParser } from './SaveGameParser.js'; import { BinaryWriter } from './BinaryWriter.js'; -import { GameStateTypes, GameStateSizes, Actor, Act1TextureOrder, CharacterFieldOffsets, CHARACTER_RECORD_SIZE } from '../savegame/constants.js'; +import { GameStateTypes, GameStateSizes, Actor, Act1TextureOrder } from '../savegame/constants.js'; +import { CharacterFieldOffsets, CHARACTER_RECORD_SIZE } from '../savegame/actorConstants.js'; /** * Offsets for header fields @@ -466,25 +467,12 @@ export class SaveGameSerializer { * @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 + * @returns {ArrayBuffer} - Modified buffer */ - 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(); + updateCharacter(characterIndex, field, value) { + const workingBuffer = this.createCopy(); const view = new DataView(workingBuffer); - - const offset = this.parsed.charactersOffset + (characterIndex * CHARACTER_RECORD_SIZE) + fieldOffset; + const offset = this.parsed.charactersOffset + (characterIndex * CHARACTER_RECORD_SIZE) + CharacterFieldOffsets[field]; if (field === 'sound' || field === 'move') { view.setInt32(offset, value, true); diff --git a/src/core/formats/WdbParser.js b/src/core/formats/WdbParser.js index c4b3e1c..ba1f351 100644 --- a/src/core/formats/WdbParser.js +++ b/src/core/formats/WdbParser.js @@ -130,37 +130,15 @@ export class WdbParser { } /** - * Parse global parts block (same structure as parsePartData but inline) + * Parse global parts block (same structure as parsePartData) * @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 + const result = this.parsePartData(startOffset); this.reader.seek(startOffset + size); - - return { parts, textures }; + return result; } /** diff --git a/src/core/rendering/ActorRenderer.js b/src/core/rendering/ActorRenderer.js index d96e890..869ecf8 100644 --- a/src/core/rendering/ActorRenderer.js +++ b/src/core/rendering/ActorRenderer.js @@ -1,18 +1,8 @@ import * as THREE from 'three'; -import { ActorLODs, ActorLODFlags, ActorInfoInit, partToLODIndex } from '../savegame/actorConstants.js'; +import { ActorLODs, ActorLODFlags, ActorInfoInit } from '../savegame/actorConstants.js'; +import { LegoColors } from '../savegame/constants.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 } -}); +import { BaseRenderer } from './BaseRenderer.js'; /** * Map actor index to animation suffix index (from g_characters[].m_unk0x16). @@ -40,11 +30,6 @@ const ACTOR_SUFFIX_INDEX = (() => { 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). @@ -95,72 +80,19 @@ const PART_NAME_TO_ANIM_NODE = { * Renderer for full LEGO characters assembled from WDB global parts. * Mirrors the game's LegoCharacterManager::CreateActorROI logic. */ -export class ActorRenderer { +export class ActorRenderer extends BaseRenderer { constructor(canvas) { - this.canvas = canvas; - this.animating = false; - this.modelGroup = null; + super(canvas); 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; } /** @@ -174,17 +106,13 @@ export class ActorRenderer { this.clearModel(); const actorInfo = ActorInfoInit[actorIndex]; - if (!actorInfo) return; - - const charState = characters ? characters[actorIndex] : null; + const charState = characters[actorIndex]; // 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)); - } + for (const tex of globalTextures) { + if (tex.name) { + this.textures.set(tex.name.toLowerCase(), this.createTexture(tex)); } } @@ -193,8 +121,7 @@ export class ActorRenderer { // 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 actorLOD = ActorLODs[i + 1]; const part = actorInfo.parts[i]; // Resolve part name for body (i=0) and hat (i=1) @@ -235,7 +162,7 @@ export class ActorRenderer { this.partGroups[i] = partGroup; } - this.centerAndScaleModel(); + this.centerAndScaleModel(1.8); // 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; @@ -251,65 +178,38 @@ export class ActorRenderer { /** * 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 + let nameIdx = part.partNameIndex; 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]; + return part.partNames[part.partNameIndices[nameIdx]]; } /** * 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 + let nameIdx = part.nameIndex; - // 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 + case 1: nameIdx = charState.hatNameIndex; break; + case 2: nameIdx = charState.infogronNameIndex; break; + case 4: nameIdx = charState.armlftNameIndex; break; + case 5: nameIdx = charState.armrtNameIndex; break; + case 8: nameIdx = charState.leglftNameIndex; break; + case 9: nameIdx = charState.legrtNameIndex; break; } } - 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]; + return part.names[part.nameIndices[nameIdx]]; } /** @@ -337,7 +237,7 @@ export class ActorRenderer { if ((useColor || bodyUsesDefaultGeom) && !partTexture) { // Resolve LEGO color - const colorEntry = ExtendedLegoColors[resolvedName] || ExtendedLegoColors['lego white']; + const colorEntry = LegoColors[resolvedName] || LegoColors['lego white']; partColor = new THREE.Color(colorEntry.r / 255, colorEntry.g / 255, colorEntry.b / 255); } @@ -383,113 +283,17 @@ export class ActorRenderer { } } - /** - * 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 @@ -550,9 +354,9 @@ export class ActorRenderer { 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 animNodeName = PART_NAME_TO_ANIM_NODE[lodName]; + if (animNodeName) { + nodeToPartGroup.set(animNodeName.toLowerCase(), pg); } } @@ -564,7 +368,7 @@ export class ActorRenderer { this.currentAction = this.mixer.clipAction(clip); this.currentAction.play(); } catch (e) { - // Animation unavailable — fall back to rotation (handled in animate()) + // Animation unavailable — fall back to rotation (handled in updateAnimation()) } } @@ -802,41 +606,10 @@ export class ActorRenderer { // ─── 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; - } + super.clearModel(); this.partGroups = []; - - for (const texture of this.textures.values()) { - texture.dispose(); - } - this.textures.clear(); } start() { @@ -845,14 +618,7 @@ export class ActorRenderer { this.animate(); } - stop() { - this.animating = false; - } - - animate = () => { - if (!this.animating) return; - requestAnimationFrame(this.animate); - + updateAnimation() { const delta = this.clock.getDelta(); if (this.mixer) { @@ -861,14 +627,6 @@ export class ActorRenderer { // 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() { diff --git a/src/core/rendering/BaseRenderer.js b/src/core/rendering/BaseRenderer.js new file mode 100644 index 0000000..3c6f6d5 --- /dev/null +++ b/src/core/rendering/BaseRenderer.js @@ -0,0 +1,209 @@ +import * as THREE from 'three'; + +/** + * Base renderer providing shared Three.js setup, lighting, texture creation, + * geometry building, and animation loop for LEGO model viewers. + */ +export class BaseRenderer { + constructor(canvas) { + this.canvas = canvas; + this.animating = false; + this.modelGroup = null; + this.textures = new Map(); + + this.scene = new THREE.Scene(); + + this.camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100); + + this.renderer = new THREE.WebGLRenderer({ + canvas, + antialias: true, + alpha: true + }); + this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + this.renderer.setClearColor(0x000000, 0); + + 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; + } + + /** + * Create a single geometry from mesh data + */ + 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; + } + + centerAndScaleModel(scaleFactor) { + 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 = scaleFactor / maxDim; + this.modelGroup.scale.setScalar(scale); + } + } + + clearModel() { + 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; + } + + for (const texture of this.textures.values()) { + texture.dispose(); + } + this.textures.clear(); + } + + start() { + this.animating = true; + this.animate(); + } + + stop() { + this.animating = false; + } + + animate = () => { + if (!this.animating) return; + requestAnimationFrame(this.animate); + + this.updateAnimation(); + + this.renderer.render(this.scene, this.camera); + } + + /** + * Override in subclasses for custom animation logic. + * Called each frame before rendering. + */ + updateAnimation() { + if (this.modelGroup) { + this.modelGroup.rotation.y += 0.01; + } + } + + resize(width, height) { + this.camera.aspect = width / height; + this.camera.updateProjectionMatrix(); + this.renderer.setSize(width, height, false); + } + + dispose() { + this.animating = false; + this.clearModel(); + this.renderer?.dispose(); + } +} diff --git a/src/core/rendering/VehiclePartRenderer.js b/src/core/rendering/VehiclePartRenderer.js index 7a872bf..26c7080 100644 --- a/src/core/rendering/VehiclePartRenderer.js +++ b/src/core/rendering/VehiclePartRenderer.js @@ -1,43 +1,19 @@ import * as THREE from 'three'; import { LegoColors } from '../savegame/constants.js'; import { resolveLods } from '../formats/WdbParser.js'; +import { BaseRenderer } from './BaseRenderer.js'; /** * Specialized renderer for LEGO vehicle parts * Renders ROI with proper textures - only colors meshes with INH prefix in textureName/materialName */ -export class VehiclePartRenderer { +export class VehiclePartRenderer extends BaseRenderer { constructor(canvas) { - this.canvas = canvas; - this.animating = false; - this.modelGroup = null; + super(canvas); this.colorableMeshes = []; // Meshes with INH prefix - this.textures = new Map(); // Cache for loaded textures - this.scene = new THREE.Scene(); - - this.camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100); this.camera.position.set(0, 0, 3); this.camera.lookAt(0, 0, 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.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); } /** @@ -50,34 +26,6 @@ export class VehiclePartRenderer { return texName.startsWith('inh') || matName.startsWith('inh'); } - /** - * 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 part geometry with proper textures and colorable mesh detection * @param {object} roiData - Parsed ROI data with lods @@ -105,7 +53,7 @@ export class VehiclePartRenderer { this.createMeshesFromROI(roiData, threeLegoColor); - this.centerAndScaleModel(); + this.centerAndScaleModel(1.5); this.scene.add(this.modelGroup); this.renderer.render(this.scene, this.camera); @@ -187,76 +135,6 @@ export class VehiclePartRenderer { } } - /** - * Create a single geometry from mesh data - */ - 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; - } - /** * Update texture on meshes matching a given texture name * @param {string} textureName - Texture name to match (case-insensitive) @@ -301,70 +179,8 @@ export class VehiclePartRenderer { this.renderer.render(this.scene, this.camera); } - 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.5 / maxDim; - this.modelGroup.scale.setScalar(scale); - } - } - clearModel() { - 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; - } + super.clearModel(); this.colorableMeshes = []; - - for (const texture of this.textures.values()) { - texture.dispose(); - } - this.textures.clear(); - } - - start() { - this.animating = true; - this.animate(); - } - - stop() { - this.animating = false; - } - - animate = () => { - if (!this.animating) return; - requestAnimationFrame(this.animate); - - if (this.modelGroup) { - 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.clearModel(); - this.renderer?.dispose(); } } diff --git a/src/core/savegame/actorConstants.js b/src/core/savegame/actorConstants.js index 008a1a3..2baec7e 100644 --- a/src/core/savegame/actorConstants.js +++ b/src/core/savegame/actorConstants.js @@ -39,11 +39,6 @@ export const ActorPart = Object.freeze({ 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], @@ -145,24 +140,6 @@ 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'; diff --git a/src/core/savegame/constants.js b/src/core/savegame/constants.js index 7d82085..e168c0f 100644 --- a/src/core/savegame/constants.js +++ b/src/core/savegame/constants.js @@ -2,12 +2,6 @@ * 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 @@ -203,7 +197,9 @@ export const LegoColors = Object.freeze({ '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 yellow': { r: 0xff, g: 0xb9, b: 0x00 }, + 'lego brown': { r: 0x4a, g: 0x23, b: 0x00 }, + 'lego lt grey': { r: 0xc0, g: 0xc0, b: 0xc0 } }); // LEGO color display names and order diff --git a/src/lib/save-editor/ActorEditor.svelte b/src/lib/save-editor/ActorEditor.svelte index 76b8449..586c2e8 100644 --- a/src/lib/save-editor/ActorEditor.svelte +++ b/src/lib/save-editor/ActorEditor.svelte @@ -2,8 +2,7 @@ import { onMount, onDestroy } from 'svelte'; import { ActorRenderer } from '../../core/rendering/ActorRenderer.js'; import { WdbParser, buildGlobalPartsMap } from '../../core/formats/WdbParser.js'; - import { ActorInfoInit, ActorPart, ActorPartLabels, colorAliases, - CharacterFieldOffsets } from '../../core/savegame/actorConstants.js'; + import { ActorInfoInit, ActorPart } from '../../core/savegame/actorConstants.js'; import { Actor } from '../../core/savegame/constants.js'; import NavButton from '../NavButton.svelte'; import EditorTooltip from '../EditorTooltip.svelte'; @@ -27,6 +26,9 @@ $: actorName = actorInfo?.name || 'Unknown'; $: charState = slot?.characters?.[actorIndex]; + function actorKey(slotNumber, idx, cs) { + return `${slotNumber}-${idx}-${cs.hatPartNameIndex}-${cs.hatNameIndex}-${cs.infogronNameIndex}-${cs.armlftNameIndex}-${cs.armrtNameIndex}-${cs.leglftNameIndex}-${cs.legrtNameIndex}-${cs.move}-${cs.sound}`; + } onMount(async () => { try { @@ -67,9 +69,7 @@ // Reload actor when index or character state changes $: if (renderer && !loading && actorInfo && charState) { - const cs = charState; - const key = `${slot?.slotNumber}-${actorIndex}-${cs.hatPartNameIndex}-${cs.hatNameIndex}-${cs.infogronNameIndex}-${cs.armlftNameIndex}-${cs.armrtNameIndex}-${cs.leglftNameIndex}-${cs.legrtNameIndex}-${cs.move}-${cs.sound}`; - if (key !== loadedActorKey) { + if (actorKey(slot?.slotNumber, actorIndex, charState) !== loadedActorKey) { loadCurrentActor(); } } @@ -78,8 +78,7 @@ if (!renderer || !globalPartsMap || !slot?.characters) return; renderer.loadActor(actorIndex, slot.characters, globalPartsMap, globalTextures); - const cs = slot.characters[actorIndex]; - loadedActorKey = `${slot?.slotNumber}-${actorIndex}-${cs.hatPartNameIndex}-${cs.hatNameIndex}-${cs.infogronNameIndex}-${cs.armlftNameIndex}-${cs.armrtNameIndex}-${cs.leglftNameIndex}-${cs.legrtNameIndex}-${cs.move}-${cs.sound}`; + loadedActorKey = actorKey(slot?.slotNumber, actorIndex, slot.characters[actorIndex]); } function prevActor() {