mirror of
https://github.com/isledecomp/isle.pizza.git
synced 2026-03-01 06:17:38 +00:00
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:
parent
36a6e0fde9
commit
c0da123d56
@ -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;
|
||||
}
|
||||
|
||||
338
src/core/rendering/VehiclePartRenderer.js
Normal file
338
src/core/rendering/VehiclePartRenderer.js
Normal 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();
|
||||
}
|
||||
}
|
||||
@ -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' }
|
||||
]
|
||||
});
|
||||
|
||||
@ -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
52
src/lib/NavButton.svelte
Normal 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>
|
||||
23
src/lib/ResetButton.svelte
Normal file
23
src/lib/ResetButton.svelte
Normal 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>
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
|
||||
309
src/lib/save-editor/VehicleEditor.svelte
Normal file
309
src/lib/save-editor/VehicleEditor.svelte
Normal 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>
|
||||
Loading…
Reference in New Issue
Block a user