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
This commit is contained in:
Christian Semmler 2026-02-01 15:22:29 -08:00
parent 36a6e0fde9
commit c0da123d56
No known key found for this signature in database
GPG Key ID: 086DAA1360BEEE5C
11 changed files with 932 additions and 104 deletions

View File

@ -37,10 +37,11 @@ export class WdbParser {
const nameLen = this.reader.readS32();
const name = this.reader.readString(nameLen).replace(/\0/g, '');
// Parse parts (skip for now)
// Parse parts
const numParts = this.reader.readS32();
const parts = [];
for (let i = 0; i < numParts; i++) {
this.skipPartReference();
parts.push(this.parsePartReference());
}
// Parse models
@ -50,14 +51,15 @@ export class WdbParser {
models.push(this.parseModelEntry());
}
return { name, numParts, models };
return { name, numParts, parts, models };
}
skipPartReference() {
parsePartReference() {
const nameLen = this.reader.readU32();
this.reader.skip(nameLen); // name
this.reader.skip(4); // data_length
this.reader.skip(4); // data_offset
const name = this.reader.readString(nameLen).replace(/\0/g, '');
const dataLength = this.reader.readU32();
const dataOffset = this.reader.readU32();
return { name, dataLength, dataOffset };
}
parseModelEntry() {
@ -90,6 +92,45 @@ export class WdbParser {
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 });
}
// Parse textures at textureInfoOffset
this.reader.seek(offset + textureInfoOffset);
let textures = [];
try {
textures = this.parseTextureInfo();
} catch (e) {
// Continue without textures - wheel caps don't need textures for color display
}
return { parts, textures };
}
/**
* Parse model_data blob at specified offset
* @param {number} offset - Absolute file offset
@ -264,7 +305,7 @@ export class WdbParser {
children.push(this.parseRoi());
}
return { name, boundingSphere, boundingBox, textureName, lods, children };
return { name, boundingSphere, boundingBox, textureName, sharedLodList: sharedLodList !== 0, lods, children };
}
parseLod() {
@ -437,3 +478,54 @@ export function findRoi(roi, name) {
}
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) {
try {
const partData = parser.parsePartData(partRef.dataOffset);
if (partData && partData.parts) {
for (const part of partData.parts) {
partsMap.set(part.name.toLowerCase(), part);
}
}
} catch (e) {
// Continue with other parts
}
}
return partsMap;
}

View File

@ -0,0 +1,338 @@
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.6);
this.scene.add(ambient);
const keyLight = new THREE.DirectionalLight(0xffffff, 0.8);
keyLight.position.set(2, 2, 3);
this.scene.add(keyLight);
const fillLight = new THREE.DirectionalLight(0xffffff, 0.4);
fillLight.position.set(-2, 1, 2);
this.scene.add(fillLight);
const rimLight = new THREE.DirectionalLight(0xffffff, 0.3);
rimLight.position.set(0, 1, -3);
this.scene.add(rimLight);
}
/**
* 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;
if (isColorable) {
// Mesh has INH prefix - use the LEGO color
material = new THREE.MeshStandardMaterial({
color: legoColor,
side: THREE.DoubleSide,
roughness: 0.7,
metalness: 0.1
});
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.MeshStandardMaterial({
map: this.textures.get(meshTextureName),
side: THREE.DoubleSide,
roughness: 0.8,
metalness: 0.1
});
} else {
// Fallback to mesh's vertex color
const meshColor = mesh.properties?.color || { r: 128, g: 128, b: 128 };
material = new THREE.MeshStandardMaterial({
color: new THREE.Color(meshColor.r / 255, meshColor.g / 255, meshColor.b / 255),
side: THREE.DoubleSide,
roughness: 0.8,
metalness: 0.1
});
}
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

@ -189,3 +189,95 @@ export const HISTORY_FILE = 'History.gsi';
export function getSaveFileName(slot) {
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>
import { onMount } from 'svelte';
import NavButton from './NavButton.svelte';
export let gap = 10;
@ -85,17 +86,7 @@
</script>
<div class="carousel">
<button
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>
<NavButton direction="left" onclick={scrollLeft} disabled={!canScrollLeft} />
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions a11y_no_noninteractive_element_interactions -->
<div
class="carousel-track"
@ -112,17 +103,7 @@
>
<slot />
</div>
<button
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>
<NavButton direction="right" onclick={scrollRight} disabled={!canScrollRight} />
</div>
<style>
@ -155,30 +136,4 @@
.carousel-track:not(.dragging) {
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>

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 SkyColorEditor from './save-editor/SkyColorEditor.svelte';
import LightPositionEditor from './save-editor/LightPositionEditor.svelte';
import VehicleEditor from './save-editor/VehicleEditor.svelte';
import { saveEditorState, currentPage } from '../stores.js';
import { listSaveSlots, updateSaveSlot, updatePlayerName } from '../core/savegame/index.js';
import { Actor, ActorNames } from '../core/savegame/constants.js';
@ -21,7 +22,8 @@
const saveTabs = [
{ id: 'player', label: 'Player', firstSection: 'name' },
{ 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
@ -395,6 +397,13 @@
</div>
</div>
</div>
<!-- Vehicles Tab -->
<div class:hidden={activeTab !== 'vehicles'}>
{#if $currentPage === 'save-editor'}
<VehicleEditor slot={currentSlot} onUpdate={handleVariableUpdate} />
{/if}
</div>
</div>
{/if}
</div>

View File

@ -1,4 +1,6 @@
<script>
import ResetButton from '../ResetButton.svelte';
export let slot;
export let onUpdate = () => {};
@ -46,7 +48,7 @@
{/each}
</div>
{#if !isDefault}
<button type="button" class="reset-btn" onclick={handleReset}>Reset to default</button>
<ResetButton onclick={handleReset} />
{/if}
</div>
@ -85,22 +87,6 @@
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) {
.globe-btn img {
width: 40px;

View File

@ -27,16 +27,12 @@
const parser = new WdbParser(buffer);
const wdb = parser.parse();
console.log('Parsed worlds:', wdb.worlds.map(w => w.name));
// Find ICUBE world and scormain model
const icubeWorld = wdb.worlds.find(w => w.name === 'ICUBE');
if (!icubeWorld) {
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 =>
m.name.toLowerCase().includes('scormain')
);
@ -44,22 +40,15 @@
throw new Error('scormain model not found in ICUBE world');
}
console.log('scormain model:', scormainModel);
// Parse the model_data blob
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
const scorcubeRoi = findRoi(modelData.roi, 'scorcube');
if (!scorcubeRoi) {
throw new Error('scorcube ROI not found');
}
console.log('scorcube ROI:', scorcubeRoi.name, 'lods:', scorcubeRoi.lods?.length);
// Find bigcube texture
const bigcubeTexture = modelData.textures.find(t =>
t.name.toLowerCase() === 'bigcube.gif'
@ -68,8 +57,6 @@
throw new Error('bigcube.gif texture not found');
}
console.log('bigcube texture:', bigcubeTexture.width, 'x', bigcubeTexture.height);
// Initialize renderer
renderer = new ScoreCubeRenderer(canvas);
renderer.loadModel(scorcubeRoi, bigcubeTexture);

View File

@ -1,5 +1,6 @@
<script>
import { parseBackgroundColor, formatBackgroundColor, hsvToHex } from '../../core/savegame/colorUtils.js';
import ResetButton from '../ResetButton.svelte';
export let slot;
export let onUpdate = () => {};
@ -97,7 +98,7 @@
</div>
</div>
{#if !isDefault}
<button type="button" class="reset-btn" onclick={handleReset}>Reset to default</button>
<ResetButton onclick={handleReset} />
{/if}
</div>
@ -193,22 +194,6 @@
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) {
.editor-content {
flex-direction: column;

View File

@ -0,0 +1,309 @@
<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';
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>
<div class="vehicle-editor">
<!-- 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>
{#if !isDefault && !loading && !error && !partError}
<ResetButton onclick={resetColor} />
{/if}
</div>
<style>
.vehicle-editor {
display: flex;
flex-direction: column;
align-items: center;
}
.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);
}
</style>