From c85eeef56ace52f30b1b3d9472b70fad39b27a51 Mon Sep 17 00:00:00 2001 From: Christian Semmler Date: Sun, 1 Feb 2026 17:08:12 -0800 Subject: [PATCH] Feature/vehicle part editor (#14) * 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. --- src/core/formats/WdbParser.js | 113 +++++- src/core/rendering/VehiclePartRenderer.js | 339 ++++++++++++++++++ src/core/rendering/WdbModelRenderer.js | 23 +- src/core/savegame/constants.js | 92 +++++ src/lib/Carousel.svelte | 51 +-- src/lib/EditorTooltip.svelte | 32 ++ src/lib/NavButton.svelte | 52 +++ src/lib/ResetButton.svelte | 23 ++ src/lib/SaveEditorPage.svelte | 21 +- .../save-editor/LightPositionEditor.svelte | 20 +- src/lib/save-editor/ScoreCube.svelte | 68 ++-- src/lib/save-editor/SkyColorEditor.svelte | 19 +- src/lib/save-editor/VehicleEditor.svelte | 310 ++++++++++++++++ 13 files changed, 1008 insertions(+), 155 deletions(-) create mode 100644 src/core/rendering/VehiclePartRenderer.js create mode 100644 src/lib/EditorTooltip.svelte create mode 100644 src/lib/NavButton.svelte create mode 100644 src/lib/ResetButton.svelte create mode 100644 src/lib/save-editor/VehicleEditor.svelte diff --git a/src/core/formats/WdbParser.js b/src/core/formats/WdbParser.js index b5645b6..66eaf50 100644 --- a/src/core/formats/WdbParser.js +++ b/src/core/formats/WdbParser.js @@ -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,39 @@ 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 }); + } + + this.reader.seek(offset + textureInfoOffset); + const textures = this.parseTextureInfo(); + + return { parts, textures }; + } + /** * Parse model_data blob at specified offset * @param {number} offset - Absolute file offset @@ -112,9 +147,8 @@ export class WdbParser { // Parse ROI hierarchy const roi = this.parseRoi(); - // Parse textures at textureInfoOffset this.reader.seek(offset + textureInfoOffset); - const textures = this.parseTextureInfo(); + const textures = this.parseTextureInfo(true); // Models have skipTextures field return { version, anim, roi, textures }; } @@ -264,7 +298,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() { @@ -366,9 +400,19 @@ export class WdbParser { 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 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 = []; for (let i = 0; i < numTextures; i++) { @@ -437,3 +481,48 @@ 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) { + const partData = parser.parsePartData(partRef.dataOffset); + for (const part of partData.parts) { + partsMap.set(part.name.toLowerCase(), part); + } + } + return partsMap; +} diff --git a/src/core/rendering/VehiclePartRenderer.js b/src/core/rendering/VehiclePartRenderer.js new file mode 100644 index 0000000..477f81f --- /dev/null +++ b/src/core/rendering/VehiclePartRenderer.js @@ -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(); + } +} diff --git a/src/core/rendering/WdbModelRenderer.js b/src/core/rendering/WdbModelRenderer.js index 675e3f6..26f47c9 100644 --- a/src/core/rendering/WdbModelRenderer.js +++ b/src/core/rendering/WdbModelRenderer.js @@ -35,14 +35,12 @@ export class WdbModelRenderer { * Setup scene lighting - override to customize */ setupLighting() { - // Flat, even lighting similar to in-game - const ambient = new THREE.AmbientLight(0xffffff, 1.5); + const ambient = new THREE.AmbientLight(0xffffff, 0.8); this.scene.add(ambient); - // Soft front light - const frontLight = new THREE.DirectionalLight(0xffffff, 0.3); - frontLight.position.set(0, 0, 5); - this.scene.add(frontLight); + const sunLight = new THREE.DirectionalLight(0xffffff, 0.6); + sunLight.position.set(1, 2, 3); + this.scene.add(sunLight); } /** @@ -62,22 +60,18 @@ export class WdbModelRenderer { this.texture.magFilter = THREE.LinearFilter; if (texturedGeometry) { - const texturedMaterial = new THREE.MeshStandardMaterial({ + const texturedMaterial = new THREE.MeshLambertMaterial({ map: this.texture, - side: THREE.DoubleSide, - roughness: 0.8, - metalness: 0.1 + side: THREE.DoubleSide }); this.texturedMesh = new THREE.Mesh(texturedGeometry, texturedMaterial); this.modelGroup.add(this.texturedMesh); } 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), - side: THREE.DoubleSide, - roughness: 0.8, - metalness: 0.1 + side: THREE.DoubleSide }); const mesh = new THREE.Mesh(geometry, material); this.modelGroup.add(mesh); @@ -126,7 +120,6 @@ export class WdbModelRenderer { } } - // Build mesh vertices following brickolini-island logic const meshVertices = []; const meshNormals = []; const meshUvs = []; diff --git a/src/core/savegame/constants.js b/src/core/savegame/constants.js index ad93c43..42cf79e 100644 --- a/src/core/savegame/constants.js +++ b/src/core/savegame/constants.js @@ -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' } + ] +}); diff --git a/src/lib/Carousel.svelte b/src/lib/Carousel.svelte index c0804fb..9e03675 100644 --- a/src/lib/Carousel.svelte +++ b/src/lib/Carousel.svelte @@ -1,5 +1,6 @@ diff --git a/src/lib/EditorTooltip.svelte b/src/lib/EditorTooltip.svelte new file mode 100644 index 0000000..c178b3d --- /dev/null +++ b/src/lib/EditorTooltip.svelte @@ -0,0 +1,32 @@ + + +
+ ? + {text} + +
+ +
+
+ + diff --git a/src/lib/NavButton.svelte b/src/lib/NavButton.svelte new file mode 100644 index 0000000..c2bf39a --- /dev/null +++ b/src/lib/NavButton.svelte @@ -0,0 +1,52 @@ + + + + + diff --git a/src/lib/ResetButton.svelte b/src/lib/ResetButton.svelte new file mode 100644 index 0000000..f515df2 --- /dev/null +++ b/src/lib/ResetButton.svelte @@ -0,0 +1,23 @@ + + + + + diff --git a/src/lib/SaveEditorPage.svelte b/src/lib/SaveEditorPage.svelte index d571586..44ec3f6 100644 --- a/src/lib/SaveEditorPage.svelte +++ b/src/lib/SaveEditorPage.svelte @@ -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 @@ -213,6 +215,7 @@ function handleSlotFocus(e) { e.target.select(); + e.target.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' }); } // Character handler @@ -395,6 +398,13 @@ + + +
+ {#if $currentPage === 'save-editor'} + + {/if} +
{/if} @@ -487,11 +497,20 @@ .section-inner { padding-top: 4px; + min-width: 0; } .name-slots { display: flex; gap: 4px; + max-width: 100%; + overflow-x: auto; + scrollbar-width: none; + -ms-overflow-style: none; + } + + .name-slots::-webkit-scrollbar { + display: none; } .name-slot { diff --git a/src/lib/save-editor/LightPositionEditor.svelte b/src/lib/save-editor/LightPositionEditor.svelte index 8c0941b..5c2c07a 100644 --- a/src/lib/save-editor/LightPositionEditor.svelte +++ b/src/lib/save-editor/LightPositionEditor.svelte @@ -1,4 +1,6 @@ -
-
- ? - Click on the cube to cycle high scores. Changes are automatically saved. - -
- + +
+ - {#if loading} -
-
-
- {:else if error} -
Error: {error}
- {/if} -
+ {#if loading} +
+
+
+ {:else if error} +
Error: {error}
+ {/if} +
+