Feature/vehicle part editor (#14)
Some checks are pending
Build / build (push) Waiting to run

* Add vehicle part color editor to save editor

- Add Vehicles tab with 3D preview of customizable vehicle parts
- Support all 43 colorable parts across 4 vehicles (dune buggy, helicopter, jetski, race car)
- Implement shared LOD resolution for proper rendering of all parts
- Extract reusable NavButton and ResetButton components
- Remove debug console.log statements from ScoreCube

* Reserve space for reset button in vehicle editor to prevent layout shift

* Add tooltip to vehicle editor explaining click-to-cycle interaction

* Add scroll-into-view behavior for name slots on mobile

When name slots overflow on narrow screens, focusing a partially visible
slot now smoothly scrolls it into view. Scrollbar is hidden for cleaner UI.

* Extract EditorTooltip component for consistent tooltip positioning

Refactored tooltip markup into reusable EditorTooltip component used by
both ScoreCube and VehicleEditor. Tooltip now consistently appears in
top right corner of the editor section.

* Add transparency support to vehicle part rendering

Apply mesh alpha values from WDB to Three.js materials. In the original
game, alpha=0 means opaque while alpha>0 enables transparency. Disable
depthWrite for transparent meshes to prevent z-fighting.

* Use MeshLambertMaterial for original game-like rendering

Switch from MeshStandardMaterial (PBR) to MeshLambertMaterial for flat,
vibrant colors matching the original game. Simplify lighting setup for
solid colors without visible shadows.

* Fix WDB texture parsing for parts vs models

Parts and models have different texture info formats - models include a
skipTextures field that parts don't have. Add isModel parameter to
parseTextureInfo to handle this difference correctly. Also remove silent
catch blocks and overly defensive checks.
This commit is contained in:
Christian Semmler 2026-02-01 17:08:12 -08:00 committed by GitHub
parent 36a6e0fde9
commit c85eeef56a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 1008 additions and 155 deletions

View File

@ -37,10 +37,11 @@ export class WdbParser {
const nameLen = this.reader.readS32(); const nameLen = this.reader.readS32();
const name = this.reader.readString(nameLen).replace(/\0/g, ''); const name = this.reader.readString(nameLen).replace(/\0/g, '');
// Parse parts (skip for now) // Parse parts
const numParts = this.reader.readS32(); const numParts = this.reader.readS32();
const parts = [];
for (let i = 0; i < numParts; i++) { for (let i = 0; i < numParts; i++) {
this.skipPartReference(); parts.push(this.parsePartReference());
} }
// Parse models // Parse models
@ -50,14 +51,15 @@ export class WdbParser {
models.push(this.parseModelEntry()); models.push(this.parseModelEntry());
} }
return { name, numParts, models }; return { name, numParts, parts, models };
} }
skipPartReference() { parsePartReference() {
const nameLen = this.reader.readU32(); const nameLen = this.reader.readU32();
this.reader.skip(nameLen); // name const name = this.reader.readString(nameLen).replace(/\0/g, '');
this.reader.skip(4); // data_length const dataLength = this.reader.readU32();
this.reader.skip(4); // data_offset const dataOffset = this.reader.readU32();
return { name, dataLength, dataOffset };
} }
parseModelEntry() { parseModelEntry() {
@ -90,6 +92,39 @@ export class WdbParser {
return this.reader.readString(length).replace(/\0/g, ''); return this.reader.readString(length).replace(/\0/g, '');
} }
/**
* Parse part data blob at specified offset
* Parts have a simpler structure than models - no animation, direct LOD data
* @param {number} offset - Absolute file offset
* @returns {{ parts: Array, textures: Array }}
*/
parsePartData(offset) {
this.reader.seek(offset);
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(offset + textureInfoOffset);
const textures = this.parseTextureInfo();
return { parts, textures };
}
/** /**
* Parse model_data blob at specified offset * Parse model_data blob at specified offset
* @param {number} offset - Absolute file offset * @param {number} offset - Absolute file offset
@ -112,9 +147,8 @@ export class WdbParser {
// Parse ROI hierarchy // Parse ROI hierarchy
const roi = this.parseRoi(); const roi = this.parseRoi();
// Parse textures at textureInfoOffset
this.reader.seek(offset + textureInfoOffset); this.reader.seek(offset + textureInfoOffset);
const textures = this.parseTextureInfo(); const textures = this.parseTextureInfo(true); // Models have skipTextures field
return { version, anim, roi, textures }; return { version, anim, roi, textures };
} }
@ -264,7 +298,7 @@ export class WdbParser {
children.push(this.parseRoi()); children.push(this.parseRoi());
} }
return { name, boundingSphere, boundingBox, textureName, lods, children }; return { name, boundingSphere, boundingBox, textureName, sharedLodList: sharedLodList !== 0, lods, children };
} }
parseLod() { parseLod() {
@ -366,9 +400,19 @@ export class WdbParser {
return { color, alpha, shading, useAlias, textureName, materialName }; return { color, alpha, shading, useAlias, textureName, materialName };
} }
parseTextureInfo() { /**
* Parse texture info block
* @param {boolean} isModel - If true, read skipTextures field (models have it, parts don't)
*/
parseTextureInfo(isModel = false) {
const numTextures = this.reader.readU32(); const numTextures = this.reader.readU32();
const skipTextures = this.reader.readU32();
// Models have an extra skipTextures field that parts don't have
// See legomodelpresenter.cpp vs legopartpresenter.cpp in LEGO1 source
if (isModel) {
this.reader.readU32(); // skipTextures - skip over this field
}
const textures = []; const textures = [];
for (let i = 0; i < numTextures; i++) { for (let i = 0; i < numTextures; i++) {
@ -437,3 +481,48 @@ export function findRoi(roi, name) {
} }
return null; return null;
} }
/**
* Resolve LODs for an ROI, handling shared LOD lists
* This mirrors how the game's ViewLODListManager resolves shared parts
* @param {object} roi - ROI data with lods and sharedLodList flag
* @param {Map} partsMap - Map of part name (lowercase) -> part data with lods
* @returns {Array} - Array of LODs (may be empty)
*/
export function resolveLods(roi, partsMap) {
// If ROI has its own LODs, use them
if (roi.lods && roi.lods.length > 0) {
return roi.lods;
}
// If ROI uses shared LOD list, look up by name (strip trailing digits)
// This matches the game's logic in LegoROI::Read
if (roi.sharedLodList && roi.name && partsMap) {
const baseName = roi.name.replace(/\d+$/, '').toLowerCase();
const part = partsMap.get(baseName);
if (part && part.lods && part.lods.length > 0) {
return part.lods;
}
}
return [];
}
/**
* Build a parts lookup map from a world's parts array
* @param {WdbParser} parser - Parser instance for reading part data
* @param {Array} worldParts - Array of part references from world entry
* @returns {Map} - Map of part name (lowercase) -> part data
*/
export function buildPartsMap(parser, worldParts) {
const partsMap = new Map();
if (!worldParts || worldParts.length === 0) return partsMap;
for (const partRef of worldParts) {
const partData = parser.parsePartData(partRef.dataOffset);
for (const part of partData.parts) {
partsMap.set(part.name.toLowerCase(), part);
}
}
return partsMap;
}

View File

@ -0,0 +1,339 @@
import * as THREE from 'three';
import { LegoColors } from '../savegame/constants.js';
import { resolveLods } from '../formats/WdbParser.js';
/**
* Specialized renderer for LEGO vehicle parts
* Renders ROI with proper textures - only colors meshes with INH prefix in textureName/materialName
*/
export class VehiclePartRenderer {
constructor(canvas) {
this.canvas = canvas;
this.animating = false;
this.modelGroup = null;
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);
}
/**
* Check if a mesh has INH prefix in textureName or materialName
* This indicates the mesh should inherit color from the ROI
*/
hasInhPrefix(mesh) {
const texName = mesh.properties?.textureName?.toLowerCase() || '';
const matName = mesh.properties?.materialName?.toLowerCase() || '';
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;
return texture;
}
/**
* Load part geometry with proper textures and colorable mesh detection
* @param {object} roiData - Parsed ROI data with lods
* @param {string} colorName - LEGO color name for colorable parts
* @param {object[]} textureList - Array of texture data from model
* @param {Map} partsMap - Map of part name -> part data for shared LOD resolution
*/
loadPartWithColor(roiData, colorName, textureList = [], partsMap = new Map()) {
this.clearModel();
this.modelGroup = new THREE.Group();
this.colorableMeshes = [];
this.partsMap = partsMap;
// Build texture lookup map (case-insensitive)
this.textures.clear();
for (const tex of textureList) {
if (tex.name) {
this.textures.set(tex.name.toLowerCase(), this.createTexture(tex));
}
}
const legoColor = LegoColors[colorName] || LegoColors['lego red'];
const threeLegoColor = new THREE.Color(legoColor.r / 255, legoColor.g / 255, legoColor.b / 255);
this.createMeshesFromROI(roiData, threeLegoColor);
this.centerAndScaleModel();
this.scene.add(this.modelGroup);
this.renderer.render(this.scene, this.camera);
}
/**
* Recursively create meshes from ROI and its children
*/
createMeshesFromROI(roiData, legoColor) {
const lods = resolveLods(roiData, this.partsMap);
if (lods.length > 0) {
// Use highest quality LOD (last in array has most vertices)
const lod = lods[lods.length - 1];
for (const mesh of lod.meshes) {
const geometry = this.createGeometry(mesh, lod);
if (!geometry) continue;
const isColorable = this.hasInhPrefix(mesh);
const hasUVs = mesh.textureIndices && mesh.textureIndices.length > 0;
const meshTextureName = mesh.properties?.textureName?.toLowerCase();
let material;
// Get alpha from mesh properties
// In the original game: alpha = 0 means opaque, alpha > 0 means transparent
const meshAlpha = mesh.properties?.alpha || 0;
const isTransparent = meshAlpha > 0;
const opacity = isTransparent ? meshAlpha : 1;
if (isColorable) {
// Mesh has INH prefix - use the LEGO color
material = new THREE.MeshLambertMaterial({
color: legoColor,
side: THREE.DoubleSide,
transparent: isTransparent,
opacity: opacity,
depthWrite: !isTransparent
});
this.colorableMeshes.push(null); // Placeholder, will set after mesh creation
} else if (hasUVs && meshTextureName && this.textures.has(meshTextureName)) {
// Mesh has its own texture
material = new THREE.MeshLambertMaterial({
map: this.textures.get(meshTextureName),
side: THREE.DoubleSide,
transparent: isTransparent,
opacity: opacity,
depthWrite: !isTransparent
});
} else {
// Fallback to mesh's vertex color
const meshColor = mesh.properties?.color || { r: 128, g: 128, b: 128 };
material = new THREE.MeshLambertMaterial({
color: new THREE.Color(meshColor.r / 255, meshColor.g / 255, meshColor.b / 255),
side: THREE.DoubleSide,
transparent: isTransparent,
opacity: opacity,
depthWrite: !isTransparent
});
}
const threeMesh = new THREE.Mesh(geometry, material);
this.modelGroup.add(threeMesh);
// Track colorable meshes
if (isColorable) {
this.colorableMeshes[this.colorableMeshes.length - 1] = threeMesh;
}
}
}
// Process children recursively
for (const child of roiData.children || []) {
this.createMeshesFromROI(child, legoColor);
}
}
/**
* 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 color of colorable meshes without reloading geometry
*/
updateColor(colorName) {
if (!this.modelGroup || this.colorableMeshes.length === 0) return;
const legoColor = LegoColors[colorName] || LegoColors['lego red'];
const threeColor = new THREE.Color(legoColor.r / 255, legoColor.g / 255, legoColor.b / 255);
for (const mesh of this.colorableMeshes) {
if (mesh && mesh.material) {
mesh.material.color = threeColor;
}
}
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;
}
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

@ -35,14 +35,12 @@ export class WdbModelRenderer {
* Setup scene lighting - override to customize * Setup scene lighting - override to customize
*/ */
setupLighting() { setupLighting() {
// Flat, even lighting similar to in-game const ambient = new THREE.AmbientLight(0xffffff, 0.8);
const ambient = new THREE.AmbientLight(0xffffff, 1.5);
this.scene.add(ambient); this.scene.add(ambient);
// Soft front light const sunLight = new THREE.DirectionalLight(0xffffff, 0.6);
const frontLight = new THREE.DirectionalLight(0xffffff, 0.3); sunLight.position.set(1, 2, 3);
frontLight.position.set(0, 0, 5); this.scene.add(sunLight);
this.scene.add(frontLight);
} }
/** /**
@ -62,22 +60,18 @@ export class WdbModelRenderer {
this.texture.magFilter = THREE.LinearFilter; this.texture.magFilter = THREE.LinearFilter;
if (texturedGeometry) { if (texturedGeometry) {
const texturedMaterial = new THREE.MeshStandardMaterial({ const texturedMaterial = new THREE.MeshLambertMaterial({
map: this.texture, map: this.texture,
side: THREE.DoubleSide, side: THREE.DoubleSide
roughness: 0.8,
metalness: 0.1
}); });
this.texturedMesh = new THREE.Mesh(texturedGeometry, texturedMaterial); this.texturedMesh = new THREE.Mesh(texturedGeometry, texturedMaterial);
this.modelGroup.add(this.texturedMesh); this.modelGroup.add(this.texturedMesh);
} }
for (const { geometry, color } of nonTexturedGeometries) { for (const { geometry, color } of nonTexturedGeometries) {
const material = new THREE.MeshStandardMaterial({ const material = new THREE.MeshLambertMaterial({
color: new THREE.Color(color.r / 255, color.g / 255, color.b / 255), color: new THREE.Color(color.r / 255, color.g / 255, color.b / 255),
side: THREE.DoubleSide, side: THREE.DoubleSide
roughness: 0.8,
metalness: 0.1
}); });
const mesh = new THREE.Mesh(geometry, material); const mesh = new THREE.Mesh(geometry, material);
this.modelGroup.add(mesh); this.modelGroup.add(mesh);
@ -126,7 +120,6 @@ export class WdbModelRenderer {
} }
} }
// Build mesh vertices following brickolini-island logic
const meshVertices = []; const meshVertices = [];
const meshNormals = []; const meshNormals = [];
const meshUvs = []; const meshUvs = [];

View File

@ -189,3 +189,95 @@ export const HISTORY_FILE = 'History.gsi';
export function getSaveFileName(slot) { export function getSaveFileName(slot) {
return `G${slot}.GS`; return `G${slot}.GS`;
} }
// LEGO brick colors (from legoroi.cpp)
export const LegoColors = 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 color display names and order
export const LegoColorNames = ['lego black', 'lego blue', 'lego green', 'lego red', 'lego white', 'lego yellow'];
// Vehicle build world names in WDB
export const VehicleWorlds = Object.freeze({
dunebuggy: 'BLDD',
helicopter: 'BLDH',
jetski: 'BLDJ',
racecar: 'BLDR'
});
// Vehicle model names within each world
export const VehicleModels = Object.freeze({
dunebuggy: 'Dunebld',
helicopter: 'Chptrbld',
jetski: 'Jetbld',
racecar: 'bldrace'
});
// Vehicle display names
export const VehicleNames = Object.freeze({
dunebuggy: 'Dune Buggy',
helicopter: 'Helicopter',
jetski: 'Jetski',
racecar: 'Race Car'
});
// Vehicle part color definitions - 43 parts total (from legogamestate.cpp)
export const VehiclePartColors = Object.freeze({
dunebuggy: [
{ part: 'dbbkfny0', variable: 'c_dbbkfny0', label: 'Back Fender', defaultColor: 'lego red' },
{ part: 'dbbkxlY0', variable: 'c_dbbkxly0', label: 'Back Axle', defaultColor: 'lego white' },
{ part: 'dbfbrdY0', variable: 'c_dbfbrdy0', label: 'Body', defaultColor: 'lego red' },
{ part: 'dbflagY0', variable: 'c_dbflagy0', label: 'Flag', defaultColor: 'lego yellow' },
{ part: 'dbfrfny4', variable: 'c_dbfrfny4', label: 'Front Fender', defaultColor: 'lego red' },
{ part: 'dbfrxlY0', variable: 'c_dbfrxly0', label: 'Front Axle', defaultColor: 'lego white' },
{ part: 'dbhndln0', variable: 'c_dbhndly0', label: 'Handlebar', defaultColor: 'lego white' },
{ part: 'dbltbrY0', variable: 'c_dbltbry0', label: 'Rear Lights', defaultColor: 'lego white' }
],
helicopter: [
{ part: 'chbasey0', variable: 'c_chbasey0', label: 'Base', defaultColor: 'lego black' },
{ part: 'chbacky0', variable: 'c_chbacky0', label: 'Back', defaultColor: 'lego black' },
{ part: 'chdishy0', variable: 'c_chdishy0', label: 'Dish', defaultColor: 'lego white' },
{ part: 'chhorny0', variable: 'c_chhorny0', label: 'Horn', defaultColor: 'lego black' },
{ part: 'chljety1', variable: 'c_chljety1', label: 'Left Jet', defaultColor: 'lego black' },
{ part: 'chrjety1', variable: 'c_chrjety1', label: 'Right Jet', defaultColor: 'lego black' },
{ part: 'chmidly0', variable: 'c_chmidly0', label: 'Middle', defaultColor: 'lego black' },
{ part: 'chmotry0', variable: 'c_chmotry0', label: 'Motor', defaultColor: 'lego blue' },
{ part: 'chsidly0', variable: 'c_chsidly0', label: 'Left Side', defaultColor: 'lego black' },
{ part: 'chsidry0', variable: 'c_chsidry0', label: 'Right Side', defaultColor: 'lego black' },
{ part: 'chstuty0', variable: 'c_chstuty0', label: 'Skids', defaultColor: 'lego black' },
{ part: 'chtaily0', variable: 'c_chtaily0', label: 'Tail', defaultColor: 'lego black' },
{ part: 'chwindy1', variable: 'c_chwindy1', label: 'Windshield', defaultColor: 'lego black' },
{ part: 'chblady0', variable: 'c_chblady0', label: 'Blades', defaultColor: 'lego black' },
{ part: 'chseaty0', variable: 'c_chseaty0', label: 'Seat', defaultColor: 'lego white' }
],
jetski: [
{ part: 'jsdashy0', variable: 'c_jsdashy0', label: 'Dashboard', defaultColor: 'lego white' },
{ part: 'jsexhy0', variable: 'c_jsexhy0', label: 'Exhaust', defaultColor: 'lego black' },
{ part: 'jsfrnty5', variable: 'c_jsfrnty5', label: 'Front', defaultColor: 'lego black' },
{ part: 'jshndln0', variable: 'c_jshndly0', label: 'Handlebar', defaultColor: 'lego red' },
{ part: 'jslsidy0', variable: 'c_jslsidy0', label: 'Left Side', defaultColor: 'lego black' },
{ part: 'jsrsidy0', variable: 'c_jsrsidy0', label: 'Right Side', defaultColor: 'lego black' },
{ part: 'jsskiby0', variable: 'c_jsskiby0', label: 'Ski Body', defaultColor: 'lego red' },
{ part: 'jswnshy5', variable: 'c_jswnshy5', label: 'Windshield', defaultColor: 'lego white' },
{ part: 'jsbasey0', variable: 'c_jsbasey0', label: 'Base', defaultColor: 'lego white' }
],
racecar: [
{ part: 'rcbacky6', variable: 'c_rcbacky6', label: 'Back', defaultColor: 'lego green' },
{ part: 'rcedgey0', variable: 'c_rcedgey0', label: 'Edge', defaultColor: 'lego green' },
{ part: 'rcfrmey0', variable: 'c_rcfrmey0', label: 'Frame', defaultColor: 'lego red' },
{ part: 'rcfrnty6', variable: 'c_rcfrnty6', label: 'Front', defaultColor: 'lego green' },
{ part: 'rcmotry0', variable: 'c_rcmotry0', label: 'Motor', defaultColor: 'lego white' },
{ part: 'rcsidey0', variable: 'c_rcsidey0', label: 'Side', defaultColor: 'lego green' },
{ part: 'rcstery0', variable: 'c_rcstery0', label: 'Steering Wheel', defaultColor: 'lego white' },
{ part: 'rcstrpy0', variable: 'c_rcstrpy0', label: 'Stripe', defaultColor: 'lego yellow' },
{ part: 'rctailya', variable: 'c_rctailya', label: 'Tail', defaultColor: 'lego white' },
{ part: 'rcwhl1y0', variable: 'c_rcwhl1y0', label: 'Wheels 1', defaultColor: 'lego white' },
{ part: 'rcwhl2y0', variable: 'c_rcwhl2y0', label: 'Wheels 2', defaultColor: 'lego white' }
]
});

View File

@ -1,5 +1,6 @@
<script> <script>
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import NavButton from './NavButton.svelte';
export let gap = 10; export let gap = 10;
@ -85,17 +86,7 @@
</script> </script>
<div class="carousel"> <div class="carousel">
<button <NavButton direction="left" onclick={scrollLeft} disabled={!canScrollLeft} />
type="button"
class="carousel-arrow carousel-arrow-left"
class:disabled={!canScrollLeft}
onclick={scrollLeft}
aria-label="Scroll left"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M10 12L6 8l4-4" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions a11y_no_noninteractive_element_interactions --> <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions a11y_no_noninteractive_element_interactions -->
<div <div
class="carousel-track" class="carousel-track"
@ -112,17 +103,7 @@
> >
<slot /> <slot />
</div> </div>
<button <NavButton direction="right" onclick={scrollRight} disabled={!canScrollRight} />
type="button"
class="carousel-arrow carousel-arrow-right"
class:disabled={!canScrollRight}
onclick={scrollRight}
aria-label="Scroll right"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M6 4l4 4-4 4" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</div> </div>
<style> <style>
@ -155,30 +136,4 @@
.carousel-track:not(.dragging) { .carousel-track:not(.dragging) {
cursor: grab; cursor: grab;
} }
.carousel-arrow {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
background: var(--gradient-panel);
border: 1px solid var(--color-border-medium);
border-radius: 50%;
color: var(--color-text-light);
cursor: pointer;
flex-shrink: 0;
transition: all 0.2s ease;
}
.carousel-arrow:hover:not(.disabled) {
border-color: var(--color-border-light);
background: var(--gradient-hover);
}
.carousel-arrow.disabled {
opacity: 0.3;
pointer-events: none;
cursor: default;
}
</style> </style>

View File

@ -0,0 +1,32 @@
<script>
export let text = '';
</script>
<div class="editor-tooltip-wrapper">
<span class="tooltip-trigger">?
<span class="tooltip-content">{text}</span>
</span>
<div class="editor-tooltip-content">
<slot />
</div>
</div>
<style>
.editor-tooltip-wrapper {
position: relative;
width: 100%;
}
.tooltip-trigger {
position: absolute;
top: 0;
right: 0;
z-index: 1;
}
.editor-tooltip-content {
display: flex;
flex-direction: column;
align-items: center;
}
</style>

52
src/lib/NavButton.svelte Normal file
View File

@ -0,0 +1,52 @@
<script>
/** @type {'left' | 'right'} */
export let direction = 'left';
export let onclick = () => {};
export let disabled = false;
</script>
<button
type="button"
class="nav-btn"
class:disabled
{onclick}
aria-label={direction === 'left' ? 'Previous' : 'Next'}
>
{#if direction === 'left'}
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M10 12L6 8l4-4" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
{:else}
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M6 4l4 4-4 4" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
{/if}
</button>
<style>
.nav-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
background: var(--gradient-panel);
border: 1px solid var(--color-border-medium);
border-radius: 50%;
color: var(--color-text-light);
cursor: pointer;
flex-shrink: 0;
transition: all 0.2s ease;
}
.nav-btn:hover:not(.disabled) {
border-color: var(--color-border-light);
background: var(--gradient-hover);
}
.nav-btn.disabled {
opacity: 0.3;
pointer-events: none;
cursor: default;
}
</style>

View File

@ -0,0 +1,23 @@
<script>
export let onclick = () => {};
</script>
<button type="button" class="reset-btn" {onclick}>Reset to default</button>
<style>
.reset-btn {
display: block;
margin-top: 8px;
font-size: 0.8em;
color: var(--color-text-muted);
background: none;
border: none;
cursor: pointer;
padding: 0;
text-decoration: underline;
}
.reset-btn:hover {
color: var(--color-text-light);
}
</style>

View File

@ -5,6 +5,7 @@
import MissionScoresEditor from './save-editor/MissionScoresEditor.svelte'; import MissionScoresEditor from './save-editor/MissionScoresEditor.svelte';
import SkyColorEditor from './save-editor/SkyColorEditor.svelte'; import SkyColorEditor from './save-editor/SkyColorEditor.svelte';
import LightPositionEditor from './save-editor/LightPositionEditor.svelte'; import LightPositionEditor from './save-editor/LightPositionEditor.svelte';
import VehicleEditor from './save-editor/VehicleEditor.svelte';
import { saveEditorState, currentPage } from '../stores.js'; import { saveEditorState, currentPage } from '../stores.js';
import { listSaveSlots, updateSaveSlot, updatePlayerName } from '../core/savegame/index.js'; import { listSaveSlots, updateSaveSlot, updatePlayerName } from '../core/savegame/index.js';
import { Actor, ActorNames } from '../core/savegame/constants.js'; import { Actor, ActorNames } from '../core/savegame/constants.js';
@ -21,7 +22,8 @@
const saveTabs = [ const saveTabs = [
{ id: 'player', label: 'Player', firstSection: 'name' }, { id: 'player', label: 'Player', firstSection: 'name' },
{ id: 'scores', label: 'Scores', firstSection: null }, { id: 'scores', label: 'Scores', firstSection: null },
{ id: 'island', label: 'Island', firstSection: 'skycolor' } { id: 'island', label: 'Island', firstSection: 'skycolor' },
{ id: 'vehicles', label: 'Vehicles', firstSection: null }
]; ];
// Reset state when navigating to this page // Reset state when navigating to this page
@ -213,6 +215,7 @@
function handleSlotFocus(e) { function handleSlotFocus(e) {
e.target.select(); e.target.select();
e.target.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' });
} }
// Character handler // Character handler
@ -395,6 +398,13 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Vehicles Tab -->
<div class:hidden={activeTab !== 'vehicles'}>
{#if $currentPage === 'save-editor'}
<VehicleEditor slot={currentSlot} onUpdate={handleVariableUpdate} />
{/if}
</div>
</div> </div>
{/if} {/if}
</div> </div>
@ -487,11 +497,20 @@
.section-inner { .section-inner {
padding-top: 4px; padding-top: 4px;
min-width: 0;
} }
.name-slots { .name-slots {
display: flex; display: flex;
gap: 4px; gap: 4px;
max-width: 100%;
overflow-x: auto;
scrollbar-width: none;
-ms-overflow-style: none;
}
.name-slots::-webkit-scrollbar {
display: none;
} }
.name-slot { .name-slot {

View File

@ -1,4 +1,6 @@
<script> <script>
import ResetButton from '../ResetButton.svelte';
export let slot; export let slot;
export let onUpdate = () => {}; export let onUpdate = () => {};
@ -46,7 +48,7 @@
{/each} {/each}
</div> </div>
{#if !isDefault} {#if !isDefault}
<button type="button" class="reset-btn" onclick={handleReset}>Reset to default</button> <ResetButton onclick={handleReset} />
{/if} {/if}
</div> </div>
@ -85,22 +87,6 @@
image-rendering: pixelated; image-rendering: pixelated;
} }
.reset-btn {
display: block;
margin-top: 8px;
font-size: 0.8em;
color: var(--color-text-muted);
background: none;
border: none;
cursor: pointer;
padding: 0;
text-decoration: underline;
}
.reset-btn:hover {
color: var(--color-text-light);
}
@media (max-width: 400px) { @media (max-width: 400px) {
.globe-btn img { .globe-btn img {
width: 40px; width: 40px;

View File

@ -2,6 +2,7 @@
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import { ScoreCubeRenderer } from '../../core/rendering/ScoreCubeRenderer.js'; import { ScoreCubeRenderer } from '../../core/rendering/ScoreCubeRenderer.js';
import { WdbParser, findRoi } from '../../core/formats/WdbParser.js'; import { WdbParser, findRoi } from '../../core/formats/WdbParser.js';
import EditorTooltip from '../EditorTooltip.svelte';
export let missions = {}; export let missions = {};
export let onUpdate = () => {}; export let onUpdate = () => {};
@ -27,16 +28,12 @@
const parser = new WdbParser(buffer); const parser = new WdbParser(buffer);
const wdb = parser.parse(); const wdb = parser.parse();
console.log('Parsed worlds:', wdb.worlds.map(w => w.name));
// Find ICUBE world and scormain model // Find ICUBE world and scormain model
const icubeWorld = wdb.worlds.find(w => w.name === 'ICUBE'); const icubeWorld = wdb.worlds.find(w => w.name === 'ICUBE');
if (!icubeWorld) { if (!icubeWorld) {
throw new Error('ICUBE world not found in WDB'); throw new Error('ICUBE world not found in WDB');
} }
console.log('ICUBE models:', icubeWorld.models.map(m => m.name));
const scormainModel = icubeWorld.models.find(m => const scormainModel = icubeWorld.models.find(m =>
m.name.toLowerCase().includes('scormain') m.name.toLowerCase().includes('scormain')
); );
@ -44,22 +41,15 @@
throw new Error('scormain model not found in ICUBE world'); throw new Error('scormain model not found in ICUBE world');
} }
console.log('scormain model:', scormainModel);
// Parse the model_data blob // Parse the model_data blob
const modelData = parser.parseModelData(scormainModel.dataOffset); const modelData = parser.parseModelData(scormainModel.dataOffset);
console.log('Model data ROI:', modelData.roi?.name);
console.log('Model data textures:', modelData.textures?.map(t => t.name));
// Find scorcube ROI // Find scorcube ROI
const scorcubeRoi = findRoi(modelData.roi, 'scorcube'); const scorcubeRoi = findRoi(modelData.roi, 'scorcube');
if (!scorcubeRoi) { if (!scorcubeRoi) {
throw new Error('scorcube ROI not found'); throw new Error('scorcube ROI not found');
} }
console.log('scorcube ROI:', scorcubeRoi.name, 'lods:', scorcubeRoi.lods?.length);
// Find bigcube texture // Find bigcube texture
const bigcubeTexture = modelData.textures.find(t => const bigcubeTexture = modelData.textures.find(t =>
t.name.toLowerCase() === 'bigcube.gif' t.name.toLowerCase() === 'bigcube.gif'
@ -68,8 +58,6 @@
throw new Error('bigcube.gif texture not found'); throw new Error('bigcube.gif texture not found');
} }
console.log('bigcube texture:', bigcubeTexture.width, 'x', bigcubeTexture.height);
// Initialize renderer // Initialize renderer
renderer = new ScoreCubeRenderer(canvas); renderer = new ScoreCubeRenderer(canvas);
renderer.loadModel(scorcubeRoi, bigcubeTexture); renderer.loadModel(scorcubeRoi, bigcubeTexture);
@ -133,12 +121,8 @@
} }
</script> </script>
<EditorTooltip text="Click on the cube to cycle high scores. Changes are automatically saved.">
<div class="score-cube-container"> <div class="score-cube-container">
<div class="score-cube-header">
<span class="tooltip-trigger">?
<span class="tooltip-content">Click on the cube to cycle high scores. Changes are automatically saved.</span>
</span>
</div>
<canvas <canvas
bind:this={canvas} bind:this={canvas}
width="200" width="200"
@ -158,19 +142,13 @@
<div class="overlay error">Error: {error}</div> <div class="overlay error">Error: {error}</div>
{/if} {/if}
</div> </div>
</EditorTooltip>
<style> <style>
.score-cube-container { .score-cube-container {
display: flex;
flex-direction: column;
align-items: center;
position: relative; position: relative;
min-width: 0; min-width: 0;
} margin-top: 16px;
.score-cube-header {
align-self: flex-end;
margin-bottom: 4px;
} }
canvas { canvas {

View File

@ -1,5 +1,6 @@
<script> <script>
import { parseBackgroundColor, formatBackgroundColor, hsvToHex } from '../../core/savegame/colorUtils.js'; import { parseBackgroundColor, formatBackgroundColor, hsvToHex } from '../../core/savegame/colorUtils.js';
import ResetButton from '../ResetButton.svelte';
export let slot; export let slot;
export let onUpdate = () => {}; export let onUpdate = () => {};
@ -97,7 +98,7 @@
</div> </div>
</div> </div>
{#if !isDefault} {#if !isDefault}
<button type="button" class="reset-btn" onclick={handleReset}>Reset to default</button> <ResetButton onclick={handleReset} />
{/if} {/if}
</div> </div>
@ -193,22 +194,6 @@
flex-shrink: 0; flex-shrink: 0;
} }
.reset-btn {
display: block;
margin-top: 8px;
font-size: 0.8em;
color: var(--color-text-muted);
background: none;
border: none;
cursor: pointer;
padding: 0;
text-decoration: underline;
}
.reset-btn:hover {
color: var(--color-text-light);
}
@media (max-width: 400px) { @media (max-width: 400px) {
.editor-content { .editor-content {
flex-direction: column; flex-direction: column;

View File

@ -0,0 +1,310 @@
<script>
import { onMount, onDestroy } from 'svelte';
import { VehiclePartRenderer } from '../../core/rendering/VehiclePartRenderer.js';
import { WdbParser, findRoi, buildPartsMap } from '../../core/formats/WdbParser.js';
import {
LegoColorNames,
VehicleWorlds,
VehicleModels,
VehicleNames,
VehiclePartColors
} from '../../core/savegame/constants.js';
import NavButton from '../NavButton.svelte';
import ResetButton from '../ResetButton.svelte';
import EditorTooltip from '../EditorTooltip.svelte';
export let slot;
export let onUpdate = () => {};
// Build flat list of all parts across all vehicles
const vehicleList = ['dunebuggy', 'helicopter', 'jetski', 'racecar'];
const allParts = vehicleList.flatMap(v =>
(VehiclePartColors[v] || []).map(p => ({ ...p, vehicle: v }))
);
let canvas;
let renderer = null;
let loading = true;
let error = null;
let partError = null;
// Cached WDB data
let wdbParser = null;
let wdbData = null;
let globalIndex = 0;
// Track current loaded part to avoid redundant reloads
let loadedPartKey = null;
// Current part info from flat list
$: currentEntry = allParts[globalIndex];
$: vehicle = currentEntry?.vehicle || 'dunebuggy';
$: currentPart = currentEntry;
// Get current color from slot variables
$: currentColorValue = currentPart
? slot?.variables?.get(currentPart.variable)?.value || currentPart.defaultColor
: 'lego red';
// Check if current color differs from default
$: isDefault = currentPart && currentColorValue === currentPart.defaultColor;
onMount(async () => {
try {
// Load and parse WDB once
const response = await fetch('/LEGO/data/WORLD.WDB');
if (!response.ok) {
throw new Error(`Failed to load WORLD.WDB: ${response.status}`);
}
const buffer = await response.arrayBuffer();
wdbParser = new WdbParser(buffer);
wdbData = wdbParser.parse();
// Initialize renderer
renderer = new VehiclePartRenderer(canvas);
// Load initial part
await loadCurrentPart();
renderer.start();
loading = false;
} catch (e) {
console.error('VehiclePartColorEditor initialization error:', e);
error = e.message;
loading = false;
}
});
onDestroy(() => {
renderer?.dispose();
});
// Reload part when index changes
$: if (renderer && !loading && currentPart) {
const partKey = `${vehicle}-${globalIndex}`;
if (partKey !== loadedPartKey) {
loadCurrentPart();
}
}
// Update color when variable changes (without reloading geometry)
$: if (renderer && !loading && currentColorValue && loadedPartKey) {
renderer.updateColor(currentColorValue);
}
async function loadCurrentPart() {
if (!wdbData || !wdbParser || !currentPart || !renderer) return;
partError = null;
const partKey = `${vehicle}-${globalIndex}`;
try {
const worldName = VehicleWorlds[vehicle];
const modelName = VehicleModels[vehicle];
// Find the vehicle world
const world = wdbData.worlds.find(w => w.name === worldName);
if (!world) {
partError = `World ${worldName} not found`;
return;
}
// Find the vehicle model
const model = world.models.find(m =>
m.name.toLowerCase() === modelName.toLowerCase()
);
if (!model) {
partError = `Model ${modelName} not found`;
return;
}
// Parse model data
const modelData = wdbParser.parseModelData(model.dataOffset);
// Find the part ROI
const partRoi = findRoi(modelData.roi, currentPart.part);
if (!partRoi) {
partError = `Part not found`;
return;
}
// Build parts map for shared LOD resolution
const partsMap = buildPartsMap(wdbParser, world.parts);
// Load part with current color, textures, and parts map for shared LOD lookup
renderer.loadPartWithColor(partRoi, currentColorValue, modelData.textures || [], partsMap);
loadedPartKey = partKey;
} catch (e) {
console.error('Failed to load part:', e);
partError = e.message;
}
}
function prevPart() {
globalIndex = globalIndex > 0 ? globalIndex - 1 : allParts.length - 1;
loadedPartKey = null;
}
function nextPart() {
globalIndex = globalIndex < allParts.length - 1 ? globalIndex + 1 : 0;
loadedPartKey = null;
}
function cycleColor() {
if (!currentPart || partError) return;
// Find current color index and cycle to next
const currentIdx = LegoColorNames.indexOf(currentColorValue);
const nextIdx = (currentIdx + 1) % LegoColorNames.length;
const nextColor = LegoColorNames[nextIdx];
onUpdate({
variable: {
name: currentPart.variable,
value: nextColor
}
});
}
function resetColor() {
if (!currentPart) return;
onUpdate({
variable: {
name: currentPart.variable,
value: currentPart.defaultColor
}
});
}
</script>
<EditorTooltip text="Click on the part to cycle through colors. Changes are automatically saved.">
<!-- 3D Preview (clickable to cycle color) -->
<div class="preview-container">
<canvas
bind:this={canvas}
width="190"
height="190"
class:hidden={loading || error}
onclick={cycleColor}
role="button"
tabindex="0"
aria-label="Click to change color"
></canvas>
{#if loading}
<div class="preview-overlay">
<div class="spinner"></div>
</div>
{:else if error}
<div class="preview-overlay error">{error}</div>
{:else if partError}
<div class="preview-overlay error">{partError}</div>
{/if}
</div>
<!-- Part navigation below canvas -->
<div class="part-nav">
<NavButton direction="left" onclick={prevPart} />
<div class="part-info">
<span class="vehicle-name">{VehicleNames[vehicle]}</span>
<span class="part-name">{currentPart?.label || 'Unknown'}</span>
</div>
<NavButton direction="right" onclick={nextPart} />
</div>
<div class="reset-container">
{#if !isDefault && !loading && !error && !partError}
<ResetButton onclick={resetColor} />
{/if}
</div>
</EditorTooltip>
<style>
.preview-container {
position: relative;
}
canvas {
display: block;
border-radius: 8px;
cursor: pointer;
max-width: 100%;
}
canvas:focus {
outline: none;
}
canvas.hidden {
visibility: hidden;
}
.preview-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-bg-input);
border-radius: 8px;
}
.preview-overlay.error {
color: var(--color-error, #e74c3c);
font-size: 0.75em;
padding: 12px;
text-align: center;
}
.spinner {
width: 32px;
height: 32px;
border-radius: 50%;
background:
radial-gradient(transparent 55%, transparent 56%),
conic-gradient(var(--color-primary, #FFD700) 0deg 90deg, var(--color-border-dark, #333) 90deg 360deg);
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.part-nav {
display: flex;
align-items: center;
gap: 12px;
margin-top: 10px;
}
.part-info {
text-align: center;
min-width: 100px;
}
.vehicle-name {
display: block;
font-size: 0.7em;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.part-name {
display: block;
font-size: 0.9em;
color: var(--color-text-light);
}
.reset-container {
height: 1.6em;
}
</style>