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
This commit is contained in:
Christian Semmler 2026-02-07 21:51:28 -08:00
parent c390c735b4
commit 412d8a4233
No known key found for this signature in database
GPG Key ID: 086DAA1360BEEE5C
9 changed files with 260 additions and 555 deletions

View File

@ -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"
}
}

View File

@ -4,7 +4,8 @@
*/ */
import { SaveGameParser } from './SaveGameParser.js'; import { SaveGameParser } from './SaveGameParser.js';
import { BinaryWriter } from './BinaryWriter.js'; import { BinaryWriter } from './BinaryWriter.js';
import { GameStateTypes, GameStateSizes, Actor, Act1TextureOrder, 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 * Offsets for header fields
@ -466,25 +467,12 @@ export class SaveGameSerializer {
* @param {number} characterIndex - Character index (0-65) * @param {number} characterIndex - Character index (0-65)
* @param {string} field - Field name from CharacterFieldOffsets * @param {string} field - Field name from CharacterFieldOffsets
* @param {number} value - New value * @param {number} value - New value
* @param {ArrayBuffer} [buffer] - Optional buffer to use * @returns {ArrayBuffer} - Modified buffer
* @returns {ArrayBuffer|null} - Modified buffer or null on error
*/ */
updateCharacter(characterIndex, field, value, buffer = null) { updateCharacter(characterIndex, field, value) {
if (characterIndex < 0 || characterIndex > 65) { const workingBuffer = this.createCopy();
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 view = new DataView(workingBuffer);
const offset = this.parsed.charactersOffset + (characterIndex * CHARACTER_RECORD_SIZE) + CharacterFieldOffsets[field];
const offset = this.parsed.charactersOffset + (characterIndex * CHARACTER_RECORD_SIZE) + fieldOffset;
if (field === 'sound' || field === 'move') { if (field === 'sound' || field === 'move') {
view.setInt32(offset, value, true); view.setInt32(offset, value, true);

View File

@ -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 * @param {number} size - Size of global parts block
* @returns {{ parts: Array, textures: Array }} * @returns {{ parts: Array, textures: Array }}
*/ */
parseGlobalParts(size) { parseGlobalParts(size) {
const startOffset = this.reader.tell(); const startOffset = this.reader.tell();
const textureInfoOffset = this.reader.readU32(); const result = this.parsePartData(startOffset);
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); this.reader.seek(startOffset + size);
return result;
return { parts, textures };
} }
/** /**

View File

@ -1,18 +1,8 @@
import * as THREE from 'three'; 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'; import { parseAnimation } from '../formats/AnimationParser.js';
import { BaseRenderer } from './BaseRenderer.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). * Map actor index to animation suffix index (from g_characters[].m_unk0x16).
@ -40,11 +30,6 @@ const ACTOR_SUFFIX_INDEX = (() => {
return map; 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. * g_cycles[11][17] animation name table from legoanimationmanager.cpp.
* Rows = character type suffix index, columns = sound + 4 * move (0-16). * 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. * Renderer for full LEGO characters assembled from WDB global parts.
* Mirrors the game's LegoCharacterManager::CreateActorROI logic. * Mirrors the game's LegoCharacterManager::CreateActorROI logic.
*/ */
export class ActorRenderer { export class ActorRenderer extends BaseRenderer {
constructor(canvas) { constructor(canvas) {
this.canvas = canvas; super(canvas);
this.animating = false;
this.modelGroup = null;
this.partGroups = []; // 10 part groups for click targeting this.partGroups = []; // 10 part groups for click targeting
this.textures = new Map();
this.clock = new THREE.Clock(); this.clock = new THREE.Clock();
this.mixer = null; this.mixer = null;
this.currentAction = null; this.currentAction = null;
this.animationCache = new Map(); // suffix → parsed animation data 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.position.set(2, 0.8, 3.5);
this.camera.lookAt(0, 0.2, 0); 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.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(); this.clearModel();
const actorInfo = ActorInfoInit[actorIndex]; const actorInfo = ActorInfoInit[actorIndex];
if (!actorInfo) return; const charState = characters[actorIndex];
const charState = characters ? characters[actorIndex] : null;
// Build texture lookup // Build texture lookup
this.textures.clear(); this.textures.clear();
if (globalTextures) { for (const tex of globalTextures) {
for (const tex of globalTextures) { if (tex.name) {
if (tex.name) { this.textures.set(tex.name.toLowerCase(), this.createTexture(tex));
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]) // Assemble 10 body parts (matching CreateActorROI loop: i=0..9 maps to ActorLODs[1..10])
for (let i = 0; i < 10; i++) { for (let i = 0; i < 10; i++) {
const lodIdx = partToLODIndex[i]; const actorLOD = ActorLODs[i + 1];
const actorLOD = ActorLODs[lodIdx];
const part = actorInfo.parts[i]; const part = actorInfo.parts[i];
// Resolve part name for body (i=0) and hat (i=1) // Resolve part name for body (i=0) and hat (i=1)
@ -235,7 +162,7 @@ export class ActorRenderer {
this.partGroups[i] = partGroup; this.partGroups[i] = partGroup;
} }
this.centerAndScaleModel(); this.centerAndScaleModel(1.8);
// Rotate 180° around Y so actor faces the camera (negating X for // Rotate 180° around Y so actor faces the camera (negating X for
// left-to-right-handed conversion flips the facing direction) // left-to-right-handed conversion flips the facing direction)
this.modelGroup.rotation.y = Math.PI; this.modelGroup.rotation.y = Math.PI;
@ -251,65 +178,38 @@ export class ActorRenderer {
/** /**
* Resolve which part geometry to use (body variant or hat type). * 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) { resolvePartName(part, charState, partIdx) {
if (!part.partNameIndices || !part.partNames) return null; if (!part.partNameIndices || !part.partNames) return null;
let nameIdx = part.partNameIndex; // default from actorInfoInit let nameIdx = part.partNameIndex;
// 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) { if (partIdx === 1 && charState) {
nameIdx = charState.hatPartNameIndex; nameIdx = charState.hatPartNameIndex;
} }
if (nameIdx >= part.partNameIndices.length) { return part.partNames[part.partNameIndices[nameIdx]];
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. * Resolve the color or texture name for a part.
* Uses nameIndices[nameIndex] names[] (colorAliases or faceTextures/chestTextures)
*/ */
resolveNameValue(part, charState, partIdx) { resolveNameValue(part, charState, partIdx) {
if (!part.nameIndices || !part.names) return null; if (!part.nameIndices || !part.names) return null;
let nameIdx = part.nameIndex; // default let nameIdx = part.nameIndex;
// Save file overrides specific fields
if (charState) { if (charState) {
switch (partIdx) { switch (partIdx) {
case 1: nameIdx = charState.hatNameIndex; break; // hat color case 1: nameIdx = charState.hatNameIndex; break;
case 2: nameIdx = charState.infogronNameIndex; break; // infogron color case 2: nameIdx = charState.infogronNameIndex; break;
case 4: nameIdx = charState.armlftNameIndex; break; // arm left case 4: nameIdx = charState.armlftNameIndex; break;
case 5: nameIdx = charState.armrtNameIndex; break; // arm right case 5: nameIdx = charState.armrtNameIndex; break;
case 8: nameIdx = charState.leglftNameIndex; break; // leg left case 8: nameIdx = charState.leglftNameIndex; break;
case 9: nameIdx = charState.legrtNameIndex; break; // leg right case 9: nameIdx = charState.legrtNameIndex; break;
} }
} }
if (nameIdx >= part.nameIndices.length) { return part.names[part.nameIndices[nameIdx]];
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];
} }
/** /**
@ -337,7 +237,7 @@ export class ActorRenderer {
if ((useColor || bodyUsesDefaultGeom) && !partTexture) { if ((useColor || bodyUsesDefaultGeom) && !partTexture) {
// Resolve LEGO color // 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); 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. * Apply position/direction/up transform from ActorLOD data.
* The game uses CalcLocalTransform with direction/up vectors. * The game uses CalcLocalTransform with direction/up vectors.
*/ */
applyPartTransform(group, actorLOD) { applyPartTransform(group, actorLOD) {
const pos = actorLOD.position; 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) // Negate X for our coordinate system (matching VehiclePartRenderer's -v.x)
group.position.set(-pos[0], pos[1], pos[2]); 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 * Get which body part was clicked
* @returns {number} Part index (0-9) or -1 if nothing hit * @returns {number} Part index (0-9) or -1 if nothing hit
@ -550,9 +354,9 @@ export class ActorRenderer {
const pg = this.partGroups[i]; const pg = this.partGroups[i];
if (!pg) continue; if (!pg) continue;
const lodName = pg.userData.lodName; const lodName = pg.userData.lodName;
const animName = PART_NAME_TO_ANIM_NODE[lodName]; const animNodeName = PART_NAME_TO_ANIM_NODE[lodName];
if (animName) { if (animNodeName) {
nodeToPartGroup.set(animName.toLowerCase(), pg); nodeToPartGroup.set(animNodeName.toLowerCase(), pg);
} }
} }
@ -564,7 +368,7 @@ export class ActorRenderer {
this.currentAction = this.mixer.clipAction(clip); this.currentAction = this.mixer.clipAction(clip);
this.currentAction.play(); this.currentAction.play();
} catch (e) { } 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 ──────────────────────────────────────────── // ─── 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() { clearModel() {
this.stopAnimation(); this.stopAnimation();
super.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;
}
this.partGroups = []; this.partGroups = [];
for (const texture of this.textures.values()) {
texture.dispose();
}
this.textures.clear();
} }
start() { start() {
@ -845,14 +618,7 @@ export class ActorRenderer {
this.animate(); this.animate();
} }
stop() { updateAnimation() {
this.animating = false;
}
animate = () => {
if (!this.animating) return;
requestAnimationFrame(this.animate);
const delta = this.clock.getDelta(); const delta = this.clock.getDelta();
if (this.mixer) { if (this.mixer) {
@ -861,14 +627,6 @@ export class ActorRenderer {
// Fallback: rotate if no animation loaded // Fallback: rotate if no animation loaded
this.modelGroup.rotation.y += 0.01; 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() { dispose() {

View File

@ -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();
}
}

View File

@ -1,43 +1,19 @@
import * as THREE from 'three'; import * as THREE from 'three';
import { LegoColors } from '../savegame/constants.js'; import { LegoColors } from '../savegame/constants.js';
import { resolveLods } from '../formats/WdbParser.js'; import { resolveLods } from '../formats/WdbParser.js';
import { BaseRenderer } from './BaseRenderer.js';
/** /**
* Specialized renderer for LEGO vehicle parts * Specialized renderer for LEGO vehicle parts
* Renders ROI with proper textures - only colors meshes with INH prefix in textureName/materialName * Renders ROI with proper textures - only colors meshes with INH prefix in textureName/materialName
*/ */
export class VehiclePartRenderer { export class VehiclePartRenderer extends BaseRenderer {
constructor(canvas) { constructor(canvas) {
this.canvas = canvas; super(canvas);
this.animating = false;
this.modelGroup = null;
this.colorableMeshes = []; // Meshes with INH prefix 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.position.set(0, 0, 3);
this.camera.lookAt(0, 0, 0); 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'); 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 * Load part geometry with proper textures and colorable mesh detection
* @param {object} roiData - Parsed ROI data with lods * @param {object} roiData - Parsed ROI data with lods
@ -105,7 +53,7 @@ export class VehiclePartRenderer {
this.createMeshesFromROI(roiData, threeLegoColor); this.createMeshesFromROI(roiData, threeLegoColor);
this.centerAndScaleModel(); this.centerAndScaleModel(1.5);
this.scene.add(this.modelGroup); this.scene.add(this.modelGroup);
this.renderer.render(this.scene, this.camera); 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 * Update texture on meshes matching a given texture name
* @param {string} textureName - Texture name to match (case-insensitive) * @param {string} textureName - Texture name to match (case-insensitive)
@ -301,70 +179,8 @@ export class VehiclePartRenderer {
this.renderer.render(this.scene, this.camera); 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() { clearModel() {
if (this.modelGroup) { super.clearModel();
this.modelGroup.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.geometry?.dispose();
child.material?.dispose();
}
});
this.scene.remove(this.modelGroup);
this.modelGroup = null;
}
this.colorableMeshes = []; 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();
} }
} }

View File

@ -39,11 +39,6 @@ export const ActorPart = Object.freeze({
LEGRT: 9 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. * g_actorLODs[11] transform/bounding data for each body part position.
* Fields: name, parentName, flags, boundingSphere[4], boundingBox[6], * 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' '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) // Reference names for the index arrays (used to build part configs)
const HP = 'hatPartIndices'; const HP = 'hatPartIndices';
const PHP = 'pepperHatPartIndices'; const PHP = 'pepperHatPartIndices';

View File

@ -2,12 +2,6 @@
* Constants and enums from KSY save file specifications * 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) // Save game file version (must match for valid saves)
export const SAVEGAME_VERSION = 0x1000c; // 65548 export const SAVEGAME_VERSION = 0x1000c; // 65548
@ -203,7 +197,9 @@ export const LegoColors = Object.freeze({
'lego green': { r: 0x00, g: 0x78, b: 0x2d }, 'lego green': { r: 0x00, g: 0x78, b: 0x2d },
'lego red': { r: 0xcb, g: 0x12, b: 0x20 }, 'lego red': { r: 0xcb, g: 0x12, b: 0x20 },
'lego white': { r: 0xfa, g: 0xfa, b: 0xfa }, '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 // LEGO color display names and order

View File

@ -2,8 +2,7 @@
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import { ActorRenderer } from '../../core/rendering/ActorRenderer.js'; import { ActorRenderer } from '../../core/rendering/ActorRenderer.js';
import { WdbParser, buildGlobalPartsMap } from '../../core/formats/WdbParser.js'; import { WdbParser, buildGlobalPartsMap } from '../../core/formats/WdbParser.js';
import { ActorInfoInit, ActorPart, ActorPartLabels, colorAliases, import { ActorInfoInit, ActorPart } from '../../core/savegame/actorConstants.js';
CharacterFieldOffsets } from '../../core/savegame/actorConstants.js';
import { Actor } from '../../core/savegame/constants.js'; import { Actor } from '../../core/savegame/constants.js';
import NavButton from '../NavButton.svelte'; import NavButton from '../NavButton.svelte';
import EditorTooltip from '../EditorTooltip.svelte'; import EditorTooltip from '../EditorTooltip.svelte';
@ -27,6 +26,9 @@
$: actorName = actorInfo?.name || 'Unknown'; $: actorName = actorInfo?.name || 'Unknown';
$: charState = slot?.characters?.[actorIndex]; $: 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 () => { onMount(async () => {
try { try {
@ -67,9 +69,7 @@
// Reload actor when index or character state changes // Reload actor when index or character state changes
$: if (renderer && !loading && actorInfo && charState) { $: if (renderer && !loading && actorInfo && charState) {
const cs = charState; if (actorKey(slot?.slotNumber, actorIndex, charState) !== loadedActorKey) {
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) {
loadCurrentActor(); loadCurrentActor();
} }
} }
@ -78,8 +78,7 @@
if (!renderer || !globalPartsMap || !slot?.characters) return; if (!renderer || !globalPartsMap || !slot?.characters) return;
renderer.loadActor(actorIndex, slot.characters, globalPartsMap, globalTextures); renderer.loadActor(actorIndex, slot.characters, globalPartsMap, globalTextures);
const cs = slot.characters[actorIndex]; loadedActorKey = actorKey(slot?.slotNumber, actorIndex, 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}`;
} }
function prevActor() { function prevActor() {