mirror of
https://github.com/isledecomp/isle.pizza.git
synced 2026-03-01 14:27:38 +00:00
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:
parent
c390c735b4
commit
412d8a4233
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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);
|
||||||
|
|||||||
@ -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 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -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,27 +106,22 @@ 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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
this.modelGroup = new THREE.Group();
|
this.modelGroup = new THREE.Group();
|
||||||
this.partGroups = [];
|
this.partGroups = [];
|
||||||
|
|
||||||
// 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() {
|
||||||
|
|||||||
209
src/core/rendering/BaseRenderer.js
Normal file
209
src/core/rendering/BaseRenderer.js
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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() {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user