mirror of
https://github.com/isledecomp/isle.pizza.git
synced 2026-02-28 13:57:38 +00:00
Feature/vehicle part editor (#14)
Some checks are pending
Build / build (push) Waiting to run
Some checks are pending
Build / build (push) Waiting to run
* Add vehicle part color editor to save editor - Add Vehicles tab with 3D preview of customizable vehicle parts - Support all 43 colorable parts across 4 vehicles (dune buggy, helicopter, jetski, race car) - Implement shared LOD resolution for proper rendering of all parts - Extract reusable NavButton and ResetButton components - Remove debug console.log statements from ScoreCube * Reserve space for reset button in vehicle editor to prevent layout shift * Add tooltip to vehicle editor explaining click-to-cycle interaction * Add scroll-into-view behavior for name slots on mobile When name slots overflow on narrow screens, focusing a partially visible slot now smoothly scrolls it into view. Scrollbar is hidden for cleaner UI. * Extract EditorTooltip component for consistent tooltip positioning Refactored tooltip markup into reusable EditorTooltip component used by both ScoreCube and VehicleEditor. Tooltip now consistently appears in top right corner of the editor section. * Add transparency support to vehicle part rendering Apply mesh alpha values from WDB to Three.js materials. In the original game, alpha=0 means opaque while alpha>0 enables transparency. Disable depthWrite for transparent meshes to prevent z-fighting. * Use MeshLambertMaterial for original game-like rendering Switch from MeshStandardMaterial (PBR) to MeshLambertMaterial for flat, vibrant colors matching the original game. Simplify lighting setup for solid colors without visible shadows. * Fix WDB texture parsing for parts vs models Parts and models have different texture info formats - models include a skipTextures field that parts don't have. Add isModel parameter to parseTextureInfo to handle this difference correctly. Also remove silent catch blocks and overly defensive checks.
This commit is contained in:
parent
36a6e0fde9
commit
c85eeef56a
@ -37,10 +37,11 @@ export class WdbParser {
|
|||||||
const nameLen = this.reader.readS32();
|
const nameLen = this.reader.readS32();
|
||||||
const name = this.reader.readString(nameLen).replace(/\0/g, '');
|
const name = this.reader.readString(nameLen).replace(/\0/g, '');
|
||||||
|
|
||||||
// Parse parts (skip for now)
|
// Parse parts
|
||||||
const numParts = this.reader.readS32();
|
const numParts = this.reader.readS32();
|
||||||
|
const parts = [];
|
||||||
for (let i = 0; i < numParts; i++) {
|
for (let i = 0; i < numParts; i++) {
|
||||||
this.skipPartReference();
|
parts.push(this.parsePartReference());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse models
|
// Parse models
|
||||||
@ -50,14 +51,15 @@ export class WdbParser {
|
|||||||
models.push(this.parseModelEntry());
|
models.push(this.parseModelEntry());
|
||||||
}
|
}
|
||||||
|
|
||||||
return { name, numParts, models };
|
return { name, numParts, parts, models };
|
||||||
}
|
}
|
||||||
|
|
||||||
skipPartReference() {
|
parsePartReference() {
|
||||||
const nameLen = this.reader.readU32();
|
const nameLen = this.reader.readU32();
|
||||||
this.reader.skip(nameLen); // name
|
const name = this.reader.readString(nameLen).replace(/\0/g, '');
|
||||||
this.reader.skip(4); // data_length
|
const dataLength = this.reader.readU32();
|
||||||
this.reader.skip(4); // data_offset
|
const dataOffset = this.reader.readU32();
|
||||||
|
return { name, dataLength, dataOffset };
|
||||||
}
|
}
|
||||||
|
|
||||||
parseModelEntry() {
|
parseModelEntry() {
|
||||||
@ -90,6 +92,39 @@ export class WdbParser {
|
|||||||
return this.reader.readString(length).replace(/\0/g, '');
|
return this.reader.readString(length).replace(/\0/g, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse part data blob at specified offset
|
||||||
|
* Parts have a simpler structure than models - no animation, direct LOD data
|
||||||
|
* @param {number} offset - Absolute file offset
|
||||||
|
* @returns {{ parts: Array, textures: Array }}
|
||||||
|
*/
|
||||||
|
parsePartData(offset) {
|
||||||
|
this.reader.seek(offset);
|
||||||
|
|
||||||
|
const textureInfoOffset = this.reader.readU32();
|
||||||
|
const numRois = this.reader.readU32();
|
||||||
|
const parts = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < numRois; i++) {
|
||||||
|
const nameLen = this.reader.readU32();
|
||||||
|
const name = this.readCleanString(nameLen);
|
||||||
|
const numLods = this.reader.readU32();
|
||||||
|
const roiInfoOffset = this.reader.readU32();
|
||||||
|
|
||||||
|
const lods = [];
|
||||||
|
for (let j = 0; j < numLods; j++) {
|
||||||
|
lods.push(this.parseLod());
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.push({ name, lods });
|
||||||
|
}
|
||||||
|
|
||||||
|
this.reader.seek(offset + textureInfoOffset);
|
||||||
|
const textures = this.parseTextureInfo();
|
||||||
|
|
||||||
|
return { parts, textures };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse model_data blob at specified offset
|
* Parse model_data blob at specified offset
|
||||||
* @param {number} offset - Absolute file offset
|
* @param {number} offset - Absolute file offset
|
||||||
@ -112,9 +147,8 @@ export class WdbParser {
|
|||||||
// Parse ROI hierarchy
|
// Parse ROI hierarchy
|
||||||
const roi = this.parseRoi();
|
const roi = this.parseRoi();
|
||||||
|
|
||||||
// Parse textures at textureInfoOffset
|
|
||||||
this.reader.seek(offset + textureInfoOffset);
|
this.reader.seek(offset + textureInfoOffset);
|
||||||
const textures = this.parseTextureInfo();
|
const textures = this.parseTextureInfo(true); // Models have skipTextures field
|
||||||
|
|
||||||
return { version, anim, roi, textures };
|
return { version, anim, roi, textures };
|
||||||
}
|
}
|
||||||
@ -264,7 +298,7 @@ export class WdbParser {
|
|||||||
children.push(this.parseRoi());
|
children.push(this.parseRoi());
|
||||||
}
|
}
|
||||||
|
|
||||||
return { name, boundingSphere, boundingBox, textureName, lods, children };
|
return { name, boundingSphere, boundingBox, textureName, sharedLodList: sharedLodList !== 0, lods, children };
|
||||||
}
|
}
|
||||||
|
|
||||||
parseLod() {
|
parseLod() {
|
||||||
@ -366,9 +400,19 @@ export class WdbParser {
|
|||||||
return { color, alpha, shading, useAlias, textureName, materialName };
|
return { color, alpha, shading, useAlias, textureName, materialName };
|
||||||
}
|
}
|
||||||
|
|
||||||
parseTextureInfo() {
|
/**
|
||||||
|
* Parse texture info block
|
||||||
|
* @param {boolean} isModel - If true, read skipTextures field (models have it, parts don't)
|
||||||
|
*/
|
||||||
|
parseTextureInfo(isModel = false) {
|
||||||
const numTextures = this.reader.readU32();
|
const numTextures = this.reader.readU32();
|
||||||
const skipTextures = this.reader.readU32();
|
|
||||||
|
// Models have an extra skipTextures field that parts don't have
|
||||||
|
// See legomodelpresenter.cpp vs legopartpresenter.cpp in LEGO1 source
|
||||||
|
if (isModel) {
|
||||||
|
this.reader.readU32(); // skipTextures - skip over this field
|
||||||
|
}
|
||||||
|
|
||||||
const textures = [];
|
const textures = [];
|
||||||
|
|
||||||
for (let i = 0; i < numTextures; i++) {
|
for (let i = 0; i < numTextures; i++) {
|
||||||
@ -437,3 +481,48 @@ export function findRoi(roi, name) {
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve LODs for an ROI, handling shared LOD lists
|
||||||
|
* This mirrors how the game's ViewLODListManager resolves shared parts
|
||||||
|
* @param {object} roi - ROI data with lods and sharedLodList flag
|
||||||
|
* @param {Map} partsMap - Map of part name (lowercase) -> part data with lods
|
||||||
|
* @returns {Array} - Array of LODs (may be empty)
|
||||||
|
*/
|
||||||
|
export function resolveLods(roi, partsMap) {
|
||||||
|
// If ROI has its own LODs, use them
|
||||||
|
if (roi.lods && roi.lods.length > 0) {
|
||||||
|
return roi.lods;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If ROI uses shared LOD list, look up by name (strip trailing digits)
|
||||||
|
// This matches the game's logic in LegoROI::Read
|
||||||
|
if (roi.sharedLodList && roi.name && partsMap) {
|
||||||
|
const baseName = roi.name.replace(/\d+$/, '').toLowerCase();
|
||||||
|
const part = partsMap.get(baseName);
|
||||||
|
if (part && part.lods && part.lods.length > 0) {
|
||||||
|
return part.lods;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a parts lookup map from a world's parts array
|
||||||
|
* @param {WdbParser} parser - Parser instance for reading part data
|
||||||
|
* @param {Array} worldParts - Array of part references from world entry
|
||||||
|
* @returns {Map} - Map of part name (lowercase) -> part data
|
||||||
|
*/
|
||||||
|
export function buildPartsMap(parser, worldParts) {
|
||||||
|
const partsMap = new Map();
|
||||||
|
if (!worldParts || worldParts.length === 0) return partsMap;
|
||||||
|
|
||||||
|
for (const partRef of worldParts) {
|
||||||
|
const partData = parser.parsePartData(partRef.dataOffset);
|
||||||
|
for (const part of partData.parts) {
|
||||||
|
partsMap.set(part.name.toLowerCase(), part);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return partsMap;
|
||||||
|
}
|
||||||
|
|||||||
339
src/core/rendering/VehiclePartRenderer.js
Normal file
339
src/core/rendering/VehiclePartRenderer.js
Normal file
@ -0,0 +1,339 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
import { LegoColors } from '../savegame/constants.js';
|
||||||
|
import { resolveLods } from '../formats/WdbParser.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Specialized renderer for LEGO vehicle parts
|
||||||
|
* Renders ROI with proper textures - only colors meshes with INH prefix in textureName/materialName
|
||||||
|
*/
|
||||||
|
export class VehiclePartRenderer {
|
||||||
|
constructor(canvas) {
|
||||||
|
this.canvas = canvas;
|
||||||
|
this.animating = false;
|
||||||
|
this.modelGroup = null;
|
||||||
|
this.colorableMeshes = []; // Meshes with INH prefix
|
||||||
|
this.textures = new Map(); // Cache for loaded textures
|
||||||
|
|
||||||
|
this.scene = new THREE.Scene();
|
||||||
|
|
||||||
|
this.camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100);
|
||||||
|
this.camera.position.set(0, 0, 3);
|
||||||
|
this.camera.lookAt(0, 0, 0);
|
||||||
|
|
||||||
|
this.renderer = new THREE.WebGLRenderer({
|
||||||
|
canvas,
|
||||||
|
antialias: true,
|
||||||
|
alpha: true
|
||||||
|
});
|
||||||
|
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||||
|
this.renderer.setClearColor(0x000000, 0);
|
||||||
|
|
||||||
|
this.setupLighting();
|
||||||
|
}
|
||||||
|
|
||||||
|
setupLighting() {
|
||||||
|
const ambient = new THREE.AmbientLight(0xffffff, 0.8);
|
||||||
|
this.scene.add(ambient);
|
||||||
|
|
||||||
|
const sunLight = new THREE.DirectionalLight(0xffffff, 0.6);
|
||||||
|
sunLight.position.set(1, 2, 3);
|
||||||
|
this.scene.add(sunLight);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a mesh has INH prefix in textureName or materialName
|
||||||
|
* This indicates the mesh should inherit color from the ROI
|
||||||
|
*/
|
||||||
|
hasInhPrefix(mesh) {
|
||||||
|
const texName = mesh.properties?.textureName?.toLowerCase() || '';
|
||||||
|
const matName = mesh.properties?.materialName?.toLowerCase() || '';
|
||||||
|
return texName.startsWith('inh') || matName.startsWith('inh');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a Three.js texture from parsed texture data
|
||||||
|
*/
|
||||||
|
createTexture(textureData) {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = textureData.width;
|
||||||
|
canvas.height = textureData.height;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
const imageData = ctx.createImageData(textureData.width, textureData.height);
|
||||||
|
for (let i = 0; i < textureData.pixels.length; i++) {
|
||||||
|
const colorIdx = textureData.pixels[i];
|
||||||
|
const color = textureData.palette[colorIdx] || { r: 0, g: 0, b: 0 };
|
||||||
|
imageData.data[i * 4 + 0] = color.r;
|
||||||
|
imageData.data[i * 4 + 1] = color.g;
|
||||||
|
imageData.data[i * 4 + 2] = color.b;
|
||||||
|
imageData.data[i * 4 + 3] = 255;
|
||||||
|
}
|
||||||
|
ctx.putImageData(imageData, 0, 0);
|
||||||
|
|
||||||
|
const texture = new THREE.CanvasTexture(canvas);
|
||||||
|
texture.minFilter = THREE.NearestFilter;
|
||||||
|
texture.magFilter = THREE.NearestFilter;
|
||||||
|
return texture;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load part geometry with proper textures and colorable mesh detection
|
||||||
|
* @param {object} roiData - Parsed ROI data with lods
|
||||||
|
* @param {string} colorName - LEGO color name for colorable parts
|
||||||
|
* @param {object[]} textureList - Array of texture data from model
|
||||||
|
* @param {Map} partsMap - Map of part name -> part data for shared LOD resolution
|
||||||
|
*/
|
||||||
|
loadPartWithColor(roiData, colorName, textureList = [], partsMap = new Map()) {
|
||||||
|
this.clearModel();
|
||||||
|
|
||||||
|
this.modelGroup = new THREE.Group();
|
||||||
|
this.colorableMeshes = [];
|
||||||
|
this.partsMap = partsMap;
|
||||||
|
|
||||||
|
// Build texture lookup map (case-insensitive)
|
||||||
|
this.textures.clear();
|
||||||
|
for (const tex of textureList) {
|
||||||
|
if (tex.name) {
|
||||||
|
this.textures.set(tex.name.toLowerCase(), this.createTexture(tex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const legoColor = LegoColors[colorName] || LegoColors['lego red'];
|
||||||
|
const threeLegoColor = new THREE.Color(legoColor.r / 255, legoColor.g / 255, legoColor.b / 255);
|
||||||
|
|
||||||
|
this.createMeshesFromROI(roiData, threeLegoColor);
|
||||||
|
|
||||||
|
this.centerAndScaleModel();
|
||||||
|
|
||||||
|
this.scene.add(this.modelGroup);
|
||||||
|
this.renderer.render(this.scene, this.camera);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively create meshes from ROI and its children
|
||||||
|
*/
|
||||||
|
createMeshesFromROI(roiData, legoColor) {
|
||||||
|
const lods = resolveLods(roiData, this.partsMap);
|
||||||
|
|
||||||
|
if (lods.length > 0) {
|
||||||
|
// Use highest quality LOD (last in array has most vertices)
|
||||||
|
const lod = lods[lods.length - 1];
|
||||||
|
|
||||||
|
for (const mesh of lod.meshes) {
|
||||||
|
const geometry = this.createGeometry(mesh, lod);
|
||||||
|
if (!geometry) continue;
|
||||||
|
|
||||||
|
const isColorable = this.hasInhPrefix(mesh);
|
||||||
|
const hasUVs = mesh.textureIndices && mesh.textureIndices.length > 0;
|
||||||
|
const meshTextureName = mesh.properties?.textureName?.toLowerCase();
|
||||||
|
|
||||||
|
let material;
|
||||||
|
|
||||||
|
// Get alpha from mesh properties
|
||||||
|
// In the original game: alpha = 0 means opaque, alpha > 0 means transparent
|
||||||
|
const meshAlpha = mesh.properties?.alpha || 0;
|
||||||
|
const isTransparent = meshAlpha > 0;
|
||||||
|
const opacity = isTransparent ? meshAlpha : 1;
|
||||||
|
|
||||||
|
if (isColorable) {
|
||||||
|
// Mesh has INH prefix - use the LEGO color
|
||||||
|
material = new THREE.MeshLambertMaterial({
|
||||||
|
color: legoColor,
|
||||||
|
side: THREE.DoubleSide,
|
||||||
|
transparent: isTransparent,
|
||||||
|
opacity: opacity,
|
||||||
|
depthWrite: !isTransparent
|
||||||
|
});
|
||||||
|
this.colorableMeshes.push(null); // Placeholder, will set after mesh creation
|
||||||
|
} else if (hasUVs && meshTextureName && this.textures.has(meshTextureName)) {
|
||||||
|
// Mesh has its own texture
|
||||||
|
material = new THREE.MeshLambertMaterial({
|
||||||
|
map: this.textures.get(meshTextureName),
|
||||||
|
side: THREE.DoubleSide,
|
||||||
|
transparent: isTransparent,
|
||||||
|
opacity: opacity,
|
||||||
|
depthWrite: !isTransparent
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Fallback to mesh's vertex color
|
||||||
|
const meshColor = mesh.properties?.color || { r: 128, g: 128, b: 128 };
|
||||||
|
material = new THREE.MeshLambertMaterial({
|
||||||
|
color: new THREE.Color(meshColor.r / 255, meshColor.g / 255, meshColor.b / 255),
|
||||||
|
side: THREE.DoubleSide,
|
||||||
|
transparent: isTransparent,
|
||||||
|
opacity: opacity,
|
||||||
|
depthWrite: !isTransparent
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const threeMesh = new THREE.Mesh(geometry, material);
|
||||||
|
this.modelGroup.add(threeMesh);
|
||||||
|
|
||||||
|
// Track colorable meshes
|
||||||
|
if (isColorable) {
|
||||||
|
this.colorableMeshes[this.colorableMeshes.length - 1] = threeMesh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process children recursively
|
||||||
|
for (const child of roiData.children || []) {
|
||||||
|
this.createMeshesFromROI(child, legoColor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a single geometry from mesh data
|
||||||
|
*/
|
||||||
|
createGeometry(mesh, lod) {
|
||||||
|
if (!mesh.polygonIndices || mesh.polygonIndices.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasTexture = mesh.textureIndices && mesh.textureIndices.length > 0;
|
||||||
|
|
||||||
|
const vertexIndicesPacked = [];
|
||||||
|
for (const poly of mesh.polygonIndices) {
|
||||||
|
vertexIndicesPacked.push(poly.a, poly.b, poly.c);
|
||||||
|
}
|
||||||
|
|
||||||
|
const textureIndicesFlat = [];
|
||||||
|
if (hasTexture) {
|
||||||
|
for (const texPoly of mesh.textureIndices) {
|
||||||
|
textureIndicesFlat.push(texPoly.a, texPoly.b, texPoly.c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const meshVertices = [];
|
||||||
|
const meshNormals = [];
|
||||||
|
const meshUvs = [];
|
||||||
|
const indices = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < vertexIndicesPacked.length; i++) {
|
||||||
|
const packed = vertexIndicesPacked[i];
|
||||||
|
|
||||||
|
if ((packed & 0x80000000) !== 0) {
|
||||||
|
indices.push(meshVertices.length);
|
||||||
|
|
||||||
|
const gv = packed & 0xFFFF;
|
||||||
|
const v = lod.vertices[gv] || { x: 0, y: 0, z: 0 };
|
||||||
|
meshVertices.push([-v.x, v.y, v.z]);
|
||||||
|
|
||||||
|
const gn = (packed >>> 16) & 0x7fff;
|
||||||
|
const n = lod.normals[gn] || { x: 0, y: 1, z: 0 };
|
||||||
|
meshNormals.push([-n.x, n.y, n.z]);
|
||||||
|
|
||||||
|
if (hasTexture && lod.textureVertices && lod.textureVertices.length > 0) {
|
||||||
|
const tex = textureIndicesFlat[i];
|
||||||
|
const uv = lod.textureVertices[tex] || { u: 0, v: 0 };
|
||||||
|
meshUvs.push([uv.u, 1 - uv.v]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
indices.push(packed & 0xFFFF);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reverse face winding
|
||||||
|
for (let i = 0; i < indices.length; i += 3) {
|
||||||
|
const temp = indices[i];
|
||||||
|
indices[i] = indices[i + 2];
|
||||||
|
indices[i + 2] = temp;
|
||||||
|
}
|
||||||
|
|
||||||
|
const geometry = new THREE.BufferGeometry();
|
||||||
|
geometry.setAttribute('position', new THREE.Float32BufferAttribute(meshVertices.flat(), 3));
|
||||||
|
geometry.setAttribute('normal', new THREE.Float32BufferAttribute(meshNormals.flat(), 3));
|
||||||
|
geometry.setIndex(indices);
|
||||||
|
|
||||||
|
if (hasTexture && meshUvs.length > 0) {
|
||||||
|
geometry.setAttribute('uv', new THREE.Float32BufferAttribute(meshUvs.flat(), 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
return geometry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update color of colorable meshes without reloading geometry
|
||||||
|
*/
|
||||||
|
updateColor(colorName) {
|
||||||
|
if (!this.modelGroup || this.colorableMeshes.length === 0) return;
|
||||||
|
|
||||||
|
const legoColor = LegoColors[colorName] || LegoColors['lego red'];
|
||||||
|
const threeColor = new THREE.Color(legoColor.r / 255, legoColor.g / 255, legoColor.b / 255);
|
||||||
|
|
||||||
|
for (const mesh of this.colorableMeshes) {
|
||||||
|
if (mesh && mesh.material) {
|
||||||
|
mesh.material.color = threeColor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.renderer.render(this.scene, this.camera);
|
||||||
|
}
|
||||||
|
|
||||||
|
centerAndScaleModel() {
|
||||||
|
if (!this.modelGroup) return;
|
||||||
|
|
||||||
|
const box = new THREE.Box3().setFromObject(this.modelGroup);
|
||||||
|
const center = box.getCenter(new THREE.Vector3());
|
||||||
|
const size = box.getSize(new THREE.Vector3());
|
||||||
|
|
||||||
|
this.modelGroup.position.sub(center);
|
||||||
|
|
||||||
|
const maxDim = Math.max(size.x, size.y, size.z);
|
||||||
|
if (maxDim > 0) {
|
||||||
|
const scale = 1.5 / maxDim;
|
||||||
|
this.modelGroup.scale.setScalar(scale);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clearModel() {
|
||||||
|
if (this.modelGroup) {
|
||||||
|
this.modelGroup.traverse((child) => {
|
||||||
|
if (child instanceof THREE.Mesh) {
|
||||||
|
child.geometry?.dispose();
|
||||||
|
child.material?.dispose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.scene.remove(this.modelGroup);
|
||||||
|
this.modelGroup = null;
|
||||||
|
}
|
||||||
|
this.colorableMeshes = [];
|
||||||
|
|
||||||
|
for (const texture of this.textures.values()) {
|
||||||
|
texture.dispose();
|
||||||
|
}
|
||||||
|
this.textures.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
this.animating = true;
|
||||||
|
this.animate();
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
this.animating = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
animate = () => {
|
||||||
|
if (!this.animating) return;
|
||||||
|
requestAnimationFrame(this.animate);
|
||||||
|
|
||||||
|
if (this.modelGroup) {
|
||||||
|
this.modelGroup.rotation.y += 0.01;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.renderer.render(this.scene, this.camera);
|
||||||
|
}
|
||||||
|
|
||||||
|
resize(width, height) {
|
||||||
|
this.camera.aspect = width / height;
|
||||||
|
this.camera.updateProjectionMatrix();
|
||||||
|
this.renderer.setSize(width, height, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
this.animating = false;
|
||||||
|
this.clearModel();
|
||||||
|
this.renderer?.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -35,14 +35,12 @@ export class WdbModelRenderer {
|
|||||||
* Setup scene lighting - override to customize
|
* Setup scene lighting - override to customize
|
||||||
*/
|
*/
|
||||||
setupLighting() {
|
setupLighting() {
|
||||||
// Flat, even lighting similar to in-game
|
const ambient = new THREE.AmbientLight(0xffffff, 0.8);
|
||||||
const ambient = new THREE.AmbientLight(0xffffff, 1.5);
|
|
||||||
this.scene.add(ambient);
|
this.scene.add(ambient);
|
||||||
|
|
||||||
// Soft front light
|
const sunLight = new THREE.DirectionalLight(0xffffff, 0.6);
|
||||||
const frontLight = new THREE.DirectionalLight(0xffffff, 0.3);
|
sunLight.position.set(1, 2, 3);
|
||||||
frontLight.position.set(0, 0, 5);
|
this.scene.add(sunLight);
|
||||||
this.scene.add(frontLight);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -62,22 +60,18 @@ export class WdbModelRenderer {
|
|||||||
this.texture.magFilter = THREE.LinearFilter;
|
this.texture.magFilter = THREE.LinearFilter;
|
||||||
|
|
||||||
if (texturedGeometry) {
|
if (texturedGeometry) {
|
||||||
const texturedMaterial = new THREE.MeshStandardMaterial({
|
const texturedMaterial = new THREE.MeshLambertMaterial({
|
||||||
map: this.texture,
|
map: this.texture,
|
||||||
side: THREE.DoubleSide,
|
side: THREE.DoubleSide
|
||||||
roughness: 0.8,
|
|
||||||
metalness: 0.1
|
|
||||||
});
|
});
|
||||||
this.texturedMesh = new THREE.Mesh(texturedGeometry, texturedMaterial);
|
this.texturedMesh = new THREE.Mesh(texturedGeometry, texturedMaterial);
|
||||||
this.modelGroup.add(this.texturedMesh);
|
this.modelGroup.add(this.texturedMesh);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const { geometry, color } of nonTexturedGeometries) {
|
for (const { geometry, color } of nonTexturedGeometries) {
|
||||||
const material = new THREE.MeshStandardMaterial({
|
const material = new THREE.MeshLambertMaterial({
|
||||||
color: new THREE.Color(color.r / 255, color.g / 255, color.b / 255),
|
color: new THREE.Color(color.r / 255, color.g / 255, color.b / 255),
|
||||||
side: THREE.DoubleSide,
|
side: THREE.DoubleSide
|
||||||
roughness: 0.8,
|
|
||||||
metalness: 0.1
|
|
||||||
});
|
});
|
||||||
const mesh = new THREE.Mesh(geometry, material);
|
const mesh = new THREE.Mesh(geometry, material);
|
||||||
this.modelGroup.add(mesh);
|
this.modelGroup.add(mesh);
|
||||||
@ -126,7 +120,6 @@ export class WdbModelRenderer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build mesh vertices following brickolini-island logic
|
|
||||||
const meshVertices = [];
|
const meshVertices = [];
|
||||||
const meshNormals = [];
|
const meshNormals = [];
|
||||||
const meshUvs = [];
|
const meshUvs = [];
|
||||||
|
|||||||
@ -189,3 +189,95 @@ export const HISTORY_FILE = 'History.gsi';
|
|||||||
export function getSaveFileName(slot) {
|
export function getSaveFileName(slot) {
|
||||||
return `G${slot}.GS`;
|
return `G${slot}.GS`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LEGO brick colors (from legoroi.cpp)
|
||||||
|
export const LegoColors = Object.freeze({
|
||||||
|
'lego black': { r: 0x21, g: 0x21, b: 0x21 },
|
||||||
|
'lego blue': { r: 0x00, g: 0x54, b: 0x8c },
|
||||||
|
'lego green': { r: 0x00, g: 0x78, b: 0x2d },
|
||||||
|
'lego red': { r: 0xcb, g: 0x12, b: 0x20 },
|
||||||
|
'lego white': { r: 0xfa, g: 0xfa, b: 0xfa },
|
||||||
|
'lego yellow': { r: 0xff, g: 0xb9, b: 0x00 }
|
||||||
|
});
|
||||||
|
|
||||||
|
// LEGO color display names and order
|
||||||
|
export const LegoColorNames = ['lego black', 'lego blue', 'lego green', 'lego red', 'lego white', 'lego yellow'];
|
||||||
|
|
||||||
|
// Vehicle build world names in WDB
|
||||||
|
export const VehicleWorlds = Object.freeze({
|
||||||
|
dunebuggy: 'BLDD',
|
||||||
|
helicopter: 'BLDH',
|
||||||
|
jetski: 'BLDJ',
|
||||||
|
racecar: 'BLDR'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Vehicle model names within each world
|
||||||
|
export const VehicleModels = Object.freeze({
|
||||||
|
dunebuggy: 'Dunebld',
|
||||||
|
helicopter: 'Chptrbld',
|
||||||
|
jetski: 'Jetbld',
|
||||||
|
racecar: 'bldrace'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Vehicle display names
|
||||||
|
export const VehicleNames = Object.freeze({
|
||||||
|
dunebuggy: 'Dune Buggy',
|
||||||
|
helicopter: 'Helicopter',
|
||||||
|
jetski: 'Jetski',
|
||||||
|
racecar: 'Race Car'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Vehicle part color definitions - 43 parts total (from legogamestate.cpp)
|
||||||
|
export const VehiclePartColors = Object.freeze({
|
||||||
|
dunebuggy: [
|
||||||
|
{ part: 'dbbkfny0', variable: 'c_dbbkfny0', label: 'Back Fender', defaultColor: 'lego red' },
|
||||||
|
{ part: 'dbbkxlY0', variable: 'c_dbbkxly0', label: 'Back Axle', defaultColor: 'lego white' },
|
||||||
|
{ part: 'dbfbrdY0', variable: 'c_dbfbrdy0', label: 'Body', defaultColor: 'lego red' },
|
||||||
|
{ part: 'dbflagY0', variable: 'c_dbflagy0', label: 'Flag', defaultColor: 'lego yellow' },
|
||||||
|
{ part: 'dbfrfny4', variable: 'c_dbfrfny4', label: 'Front Fender', defaultColor: 'lego red' },
|
||||||
|
{ part: 'dbfrxlY0', variable: 'c_dbfrxly0', label: 'Front Axle', defaultColor: 'lego white' },
|
||||||
|
{ part: 'dbhndln0', variable: 'c_dbhndly0', label: 'Handlebar', defaultColor: 'lego white' },
|
||||||
|
{ part: 'dbltbrY0', variable: 'c_dbltbry0', label: 'Rear Lights', defaultColor: 'lego white' }
|
||||||
|
],
|
||||||
|
helicopter: [
|
||||||
|
{ part: 'chbasey0', variable: 'c_chbasey0', label: 'Base', defaultColor: 'lego black' },
|
||||||
|
{ part: 'chbacky0', variable: 'c_chbacky0', label: 'Back', defaultColor: 'lego black' },
|
||||||
|
{ part: 'chdishy0', variable: 'c_chdishy0', label: 'Dish', defaultColor: 'lego white' },
|
||||||
|
{ part: 'chhorny0', variable: 'c_chhorny0', label: 'Horn', defaultColor: 'lego black' },
|
||||||
|
{ part: 'chljety1', variable: 'c_chljety1', label: 'Left Jet', defaultColor: 'lego black' },
|
||||||
|
{ part: 'chrjety1', variable: 'c_chrjety1', label: 'Right Jet', defaultColor: 'lego black' },
|
||||||
|
{ part: 'chmidly0', variable: 'c_chmidly0', label: 'Middle', defaultColor: 'lego black' },
|
||||||
|
{ part: 'chmotry0', variable: 'c_chmotry0', label: 'Motor', defaultColor: 'lego blue' },
|
||||||
|
{ part: 'chsidly0', variable: 'c_chsidly0', label: 'Left Side', defaultColor: 'lego black' },
|
||||||
|
{ part: 'chsidry0', variable: 'c_chsidry0', label: 'Right Side', defaultColor: 'lego black' },
|
||||||
|
{ part: 'chstuty0', variable: 'c_chstuty0', label: 'Skids', defaultColor: 'lego black' },
|
||||||
|
{ part: 'chtaily0', variable: 'c_chtaily0', label: 'Tail', defaultColor: 'lego black' },
|
||||||
|
{ part: 'chwindy1', variable: 'c_chwindy1', label: 'Windshield', defaultColor: 'lego black' },
|
||||||
|
{ part: 'chblady0', variable: 'c_chblady0', label: 'Blades', defaultColor: 'lego black' },
|
||||||
|
{ part: 'chseaty0', variable: 'c_chseaty0', label: 'Seat', defaultColor: 'lego white' }
|
||||||
|
],
|
||||||
|
jetski: [
|
||||||
|
{ part: 'jsdashy0', variable: 'c_jsdashy0', label: 'Dashboard', defaultColor: 'lego white' },
|
||||||
|
{ part: 'jsexhy0', variable: 'c_jsexhy0', label: 'Exhaust', defaultColor: 'lego black' },
|
||||||
|
{ part: 'jsfrnty5', variable: 'c_jsfrnty5', label: 'Front', defaultColor: 'lego black' },
|
||||||
|
{ part: 'jshndln0', variable: 'c_jshndly0', label: 'Handlebar', defaultColor: 'lego red' },
|
||||||
|
{ part: 'jslsidy0', variable: 'c_jslsidy0', label: 'Left Side', defaultColor: 'lego black' },
|
||||||
|
{ part: 'jsrsidy0', variable: 'c_jsrsidy0', label: 'Right Side', defaultColor: 'lego black' },
|
||||||
|
{ part: 'jsskiby0', variable: 'c_jsskiby0', label: 'Ski Body', defaultColor: 'lego red' },
|
||||||
|
{ part: 'jswnshy5', variable: 'c_jswnshy5', label: 'Windshield', defaultColor: 'lego white' },
|
||||||
|
{ part: 'jsbasey0', variable: 'c_jsbasey0', label: 'Base', defaultColor: 'lego white' }
|
||||||
|
],
|
||||||
|
racecar: [
|
||||||
|
{ part: 'rcbacky6', variable: 'c_rcbacky6', label: 'Back', defaultColor: 'lego green' },
|
||||||
|
{ part: 'rcedgey0', variable: 'c_rcedgey0', label: 'Edge', defaultColor: 'lego green' },
|
||||||
|
{ part: 'rcfrmey0', variable: 'c_rcfrmey0', label: 'Frame', defaultColor: 'lego red' },
|
||||||
|
{ part: 'rcfrnty6', variable: 'c_rcfrnty6', label: 'Front', defaultColor: 'lego green' },
|
||||||
|
{ part: 'rcmotry0', variable: 'c_rcmotry0', label: 'Motor', defaultColor: 'lego white' },
|
||||||
|
{ part: 'rcsidey0', variable: 'c_rcsidey0', label: 'Side', defaultColor: 'lego green' },
|
||||||
|
{ part: 'rcstery0', variable: 'c_rcstery0', label: 'Steering Wheel', defaultColor: 'lego white' },
|
||||||
|
{ part: 'rcstrpy0', variable: 'c_rcstrpy0', label: 'Stripe', defaultColor: 'lego yellow' },
|
||||||
|
{ part: 'rctailya', variable: 'c_rctailya', label: 'Tail', defaultColor: 'lego white' },
|
||||||
|
{ part: 'rcwhl1y0', variable: 'c_rcwhl1y0', label: 'Wheels 1', defaultColor: 'lego white' },
|
||||||
|
{ part: 'rcwhl2y0', variable: 'c_rcwhl2y0', label: 'Wheels 2', defaultColor: 'lego white' }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import NavButton from './NavButton.svelte';
|
||||||
|
|
||||||
export let gap = 10;
|
export let gap = 10;
|
||||||
|
|
||||||
@ -85,17 +86,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="carousel">
|
<div class="carousel">
|
||||||
<button
|
<NavButton direction="left" onclick={scrollLeft} disabled={!canScrollLeft} />
|
||||||
type="button"
|
|
||||||
class="carousel-arrow carousel-arrow-left"
|
|
||||||
class:disabled={!canScrollLeft}
|
|
||||||
onclick={scrollLeft}
|
|
||||||
aria-label="Scroll left"
|
|
||||||
>
|
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
|
||||||
<path d="M10 12L6 8l4-4" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions a11y_no_noninteractive_element_interactions -->
|
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions a11y_no_noninteractive_element_interactions -->
|
||||||
<div
|
<div
|
||||||
class="carousel-track"
|
class="carousel-track"
|
||||||
@ -112,17 +103,7 @@
|
|||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
<button
|
<NavButton direction="right" onclick={scrollRight} disabled={!canScrollRight} />
|
||||||
type="button"
|
|
||||||
class="carousel-arrow carousel-arrow-right"
|
|
||||||
class:disabled={!canScrollRight}
|
|
||||||
onclick={scrollRight}
|
|
||||||
aria-label="Scroll right"
|
|
||||||
>
|
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
|
||||||
<path d="M6 4l4 4-4 4" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@ -155,30 +136,4 @@
|
|||||||
.carousel-track:not(.dragging) {
|
.carousel-track:not(.dragging) {
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
}
|
}
|
||||||
|
|
||||||
.carousel-arrow {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
background: var(--gradient-panel);
|
|
||||||
border: 1px solid var(--color-border-medium);
|
|
||||||
border-radius: 50%;
|
|
||||||
color: var(--color-text-light);
|
|
||||||
cursor: pointer;
|
|
||||||
flex-shrink: 0;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.carousel-arrow:hover:not(.disabled) {
|
|
||||||
border-color: var(--color-border-light);
|
|
||||||
background: var(--gradient-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.carousel-arrow.disabled {
|
|
||||||
opacity: 0.3;
|
|
||||||
pointer-events: none;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
32
src/lib/EditorTooltip.svelte
Normal file
32
src/lib/EditorTooltip.svelte
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
<script>
|
||||||
|
export let text = '';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="editor-tooltip-wrapper">
|
||||||
|
<span class="tooltip-trigger">?
|
||||||
|
<span class="tooltip-content">{text}</span>
|
||||||
|
</span>
|
||||||
|
<div class="editor-tooltip-content">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.editor-tooltip-wrapper {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-trigger {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-tooltip-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
52
src/lib/NavButton.svelte
Normal file
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 MissionScoresEditor from './save-editor/MissionScoresEditor.svelte';
|
||||||
import SkyColorEditor from './save-editor/SkyColorEditor.svelte';
|
import SkyColorEditor from './save-editor/SkyColorEditor.svelte';
|
||||||
import LightPositionEditor from './save-editor/LightPositionEditor.svelte';
|
import LightPositionEditor from './save-editor/LightPositionEditor.svelte';
|
||||||
|
import VehicleEditor from './save-editor/VehicleEditor.svelte';
|
||||||
import { saveEditorState, currentPage } from '../stores.js';
|
import { saveEditorState, currentPage } from '../stores.js';
|
||||||
import { listSaveSlots, updateSaveSlot, updatePlayerName } from '../core/savegame/index.js';
|
import { listSaveSlots, updateSaveSlot, updatePlayerName } from '../core/savegame/index.js';
|
||||||
import { Actor, ActorNames } from '../core/savegame/constants.js';
|
import { Actor, ActorNames } from '../core/savegame/constants.js';
|
||||||
@ -21,7 +22,8 @@
|
|||||||
const saveTabs = [
|
const saveTabs = [
|
||||||
{ id: 'player', label: 'Player', firstSection: 'name' },
|
{ id: 'player', label: 'Player', firstSection: 'name' },
|
||||||
{ id: 'scores', label: 'Scores', firstSection: null },
|
{ id: 'scores', label: 'Scores', firstSection: null },
|
||||||
{ id: 'island', label: 'Island', firstSection: 'skycolor' }
|
{ id: 'island', label: 'Island', firstSection: 'skycolor' },
|
||||||
|
{ id: 'vehicles', label: 'Vehicles', firstSection: null }
|
||||||
];
|
];
|
||||||
|
|
||||||
// Reset state when navigating to this page
|
// Reset state when navigating to this page
|
||||||
@ -213,6 +215,7 @@
|
|||||||
|
|
||||||
function handleSlotFocus(e) {
|
function handleSlotFocus(e) {
|
||||||
e.target.select();
|
e.target.select();
|
||||||
|
e.target.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Character handler
|
// Character handler
|
||||||
@ -395,6 +398,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Vehicles Tab -->
|
||||||
|
<div class:hidden={activeTab !== 'vehicles'}>
|
||||||
|
{#if $currentPage === 'save-editor'}
|
||||||
|
<VehicleEditor slot={currentSlot} onUpdate={handleVariableUpdate} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@ -487,11 +497,20 @@
|
|||||||
|
|
||||||
.section-inner {
|
.section-inner {
|
||||||
padding-top: 4px;
|
padding-top: 4px;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.name-slots {
|
.name-slots {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name-slots::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.name-slot {
|
.name-slot {
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
<script>
|
<script>
|
||||||
|
import ResetButton from '../ResetButton.svelte';
|
||||||
|
|
||||||
export let slot;
|
export let slot;
|
||||||
export let onUpdate = () => {};
|
export let onUpdate = () => {};
|
||||||
|
|
||||||
@ -46,7 +48,7 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{#if !isDefault}
|
{#if !isDefault}
|
||||||
<button type="button" class="reset-btn" onclick={handleReset}>Reset to default</button>
|
<ResetButton onclick={handleReset} />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -85,22 +87,6 @@
|
|||||||
image-rendering: pixelated;
|
image-rendering: pixelated;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reset-btn {
|
|
||||||
display: block;
|
|
||||||
margin-top: 8px;
|
|
||||||
font-size: 0.8em;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reset-btn:hover {
|
|
||||||
color: var(--color-text-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 400px) {
|
@media (max-width: 400px) {
|
||||||
.globe-btn img {
|
.globe-btn img {
|
||||||
width: 40px;
|
width: 40px;
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { ScoreCubeRenderer } from '../../core/rendering/ScoreCubeRenderer.js';
|
import { ScoreCubeRenderer } from '../../core/rendering/ScoreCubeRenderer.js';
|
||||||
import { WdbParser, findRoi } from '../../core/formats/WdbParser.js';
|
import { WdbParser, findRoi } from '../../core/formats/WdbParser.js';
|
||||||
|
import EditorTooltip from '../EditorTooltip.svelte';
|
||||||
|
|
||||||
export let missions = {};
|
export let missions = {};
|
||||||
export let onUpdate = () => {};
|
export let onUpdate = () => {};
|
||||||
@ -27,16 +28,12 @@
|
|||||||
const parser = new WdbParser(buffer);
|
const parser = new WdbParser(buffer);
|
||||||
const wdb = parser.parse();
|
const wdb = parser.parse();
|
||||||
|
|
||||||
console.log('Parsed worlds:', wdb.worlds.map(w => w.name));
|
|
||||||
|
|
||||||
// Find ICUBE world and scormain model
|
// Find ICUBE world and scormain model
|
||||||
const icubeWorld = wdb.worlds.find(w => w.name === 'ICUBE');
|
const icubeWorld = wdb.worlds.find(w => w.name === 'ICUBE');
|
||||||
if (!icubeWorld) {
|
if (!icubeWorld) {
|
||||||
throw new Error('ICUBE world not found in WDB');
|
throw new Error('ICUBE world not found in WDB');
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('ICUBE models:', icubeWorld.models.map(m => m.name));
|
|
||||||
|
|
||||||
const scormainModel = icubeWorld.models.find(m =>
|
const scormainModel = icubeWorld.models.find(m =>
|
||||||
m.name.toLowerCase().includes('scormain')
|
m.name.toLowerCase().includes('scormain')
|
||||||
);
|
);
|
||||||
@ -44,22 +41,15 @@
|
|||||||
throw new Error('scormain model not found in ICUBE world');
|
throw new Error('scormain model not found in ICUBE world');
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('scormain model:', scormainModel);
|
|
||||||
|
|
||||||
// Parse the model_data blob
|
// Parse the model_data blob
|
||||||
const modelData = parser.parseModelData(scormainModel.dataOffset);
|
const modelData = parser.parseModelData(scormainModel.dataOffset);
|
||||||
|
|
||||||
console.log('Model data ROI:', modelData.roi?.name);
|
|
||||||
console.log('Model data textures:', modelData.textures?.map(t => t.name));
|
|
||||||
|
|
||||||
// Find scorcube ROI
|
// Find scorcube ROI
|
||||||
const scorcubeRoi = findRoi(modelData.roi, 'scorcube');
|
const scorcubeRoi = findRoi(modelData.roi, 'scorcube');
|
||||||
if (!scorcubeRoi) {
|
if (!scorcubeRoi) {
|
||||||
throw new Error('scorcube ROI not found');
|
throw new Error('scorcube ROI not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('scorcube ROI:', scorcubeRoi.name, 'lods:', scorcubeRoi.lods?.length);
|
|
||||||
|
|
||||||
// Find bigcube texture
|
// Find bigcube texture
|
||||||
const bigcubeTexture = modelData.textures.find(t =>
|
const bigcubeTexture = modelData.textures.find(t =>
|
||||||
t.name.toLowerCase() === 'bigcube.gif'
|
t.name.toLowerCase() === 'bigcube.gif'
|
||||||
@ -68,8 +58,6 @@
|
|||||||
throw new Error('bigcube.gif texture not found');
|
throw new Error('bigcube.gif texture not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('bigcube texture:', bigcubeTexture.width, 'x', bigcubeTexture.height);
|
|
||||||
|
|
||||||
// Initialize renderer
|
// Initialize renderer
|
||||||
renderer = new ScoreCubeRenderer(canvas);
|
renderer = new ScoreCubeRenderer(canvas);
|
||||||
renderer.loadModel(scorcubeRoi, bigcubeTexture);
|
renderer.loadModel(scorcubeRoi, bigcubeTexture);
|
||||||
@ -133,44 +121,34 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="score-cube-container">
|
<EditorTooltip text="Click on the cube to cycle high scores. Changes are automatically saved.">
|
||||||
<div class="score-cube-header">
|
<div class="score-cube-container">
|
||||||
<span class="tooltip-trigger">?
|
<canvas
|
||||||
<span class="tooltip-content">Click on the cube to cycle high scores. Changes are automatically saved.</span>
|
bind:this={canvas}
|
||||||
</span>
|
width="200"
|
||||||
</div>
|
height="200"
|
||||||
<canvas
|
onclick={handleClick}
|
||||||
bind:this={canvas}
|
class:hidden={loading || error}
|
||||||
width="200"
|
role="button"
|
||||||
height="200"
|
tabindex="0"
|
||||||
onclick={handleClick}
|
aria-label="Score cube - click to edit scores"
|
||||||
class:hidden={loading || error}
|
></canvas>
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
aria-label="Score cube - click to edit scores"
|
|
||||||
></canvas>
|
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="overlay">
|
<div class="overlay">
|
||||||
<div class="spinner"></div>
|
<div class="spinner"></div>
|
||||||
</div>
|
</div>
|
||||||
{:else if error}
|
{:else if error}
|
||||||
<div class="overlay error">Error: {error}</div>
|
<div class="overlay error">Error: {error}</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
</EditorTooltip>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.score-cube-container {
|
.score-cube-container {
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
margin-top: 16px;
|
||||||
|
|
||||||
.score-cube-header {
|
|
||||||
align-self: flex-end;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
canvas {
|
canvas {
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
<script>
|
<script>
|
||||||
import { parseBackgroundColor, formatBackgroundColor, hsvToHex } from '../../core/savegame/colorUtils.js';
|
import { parseBackgroundColor, formatBackgroundColor, hsvToHex } from '../../core/savegame/colorUtils.js';
|
||||||
|
import ResetButton from '../ResetButton.svelte';
|
||||||
|
|
||||||
export let slot;
|
export let slot;
|
||||||
export let onUpdate = () => {};
|
export let onUpdate = () => {};
|
||||||
@ -97,7 +98,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{#if !isDefault}
|
{#if !isDefault}
|
||||||
<button type="button" class="reset-btn" onclick={handleReset}>Reset to default</button>
|
<ResetButton onclick={handleReset} />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -193,22 +194,6 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reset-btn {
|
|
||||||
display: block;
|
|
||||||
margin-top: 8px;
|
|
||||||
font-size: 0.8em;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reset-btn:hover {
|
|
||||||
color: var(--color-text-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 400px) {
|
@media (max-width: 400px) {
|
||||||
.editor-content {
|
.editor-content {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
310
src/lib/save-editor/VehicleEditor.svelte
Normal file
310
src/lib/save-editor/VehicleEditor.svelte
Normal file
@ -0,0 +1,310 @@
|
|||||||
|
<script>
|
||||||
|
import { onMount, onDestroy } from 'svelte';
|
||||||
|
import { VehiclePartRenderer } from '../../core/rendering/VehiclePartRenderer.js';
|
||||||
|
import { WdbParser, findRoi, buildPartsMap } from '../../core/formats/WdbParser.js';
|
||||||
|
import {
|
||||||
|
LegoColorNames,
|
||||||
|
VehicleWorlds,
|
||||||
|
VehicleModels,
|
||||||
|
VehicleNames,
|
||||||
|
VehiclePartColors
|
||||||
|
} from '../../core/savegame/constants.js';
|
||||||
|
import NavButton from '../NavButton.svelte';
|
||||||
|
import ResetButton from '../ResetButton.svelte';
|
||||||
|
import EditorTooltip from '../EditorTooltip.svelte';
|
||||||
|
|
||||||
|
export let slot;
|
||||||
|
export let onUpdate = () => {};
|
||||||
|
|
||||||
|
// Build flat list of all parts across all vehicles
|
||||||
|
const vehicleList = ['dunebuggy', 'helicopter', 'jetski', 'racecar'];
|
||||||
|
const allParts = vehicleList.flatMap(v =>
|
||||||
|
(VehiclePartColors[v] || []).map(p => ({ ...p, vehicle: v }))
|
||||||
|
);
|
||||||
|
|
||||||
|
let canvas;
|
||||||
|
let renderer = null;
|
||||||
|
let loading = true;
|
||||||
|
let error = null;
|
||||||
|
let partError = null;
|
||||||
|
|
||||||
|
// Cached WDB data
|
||||||
|
let wdbParser = null;
|
||||||
|
let wdbData = null;
|
||||||
|
|
||||||
|
let globalIndex = 0;
|
||||||
|
|
||||||
|
// Track current loaded part to avoid redundant reloads
|
||||||
|
let loadedPartKey = null;
|
||||||
|
|
||||||
|
// Current part info from flat list
|
||||||
|
$: currentEntry = allParts[globalIndex];
|
||||||
|
$: vehicle = currentEntry?.vehicle || 'dunebuggy';
|
||||||
|
$: currentPart = currentEntry;
|
||||||
|
|
||||||
|
// Get current color from slot variables
|
||||||
|
$: currentColorValue = currentPart
|
||||||
|
? slot?.variables?.get(currentPart.variable)?.value || currentPart.defaultColor
|
||||||
|
: 'lego red';
|
||||||
|
|
||||||
|
// Check if current color differs from default
|
||||||
|
$: isDefault = currentPart && currentColorValue === currentPart.defaultColor;
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
// Load and parse WDB once
|
||||||
|
const response = await fetch('/LEGO/data/WORLD.WDB');
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to load WORLD.WDB: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = await response.arrayBuffer();
|
||||||
|
wdbParser = new WdbParser(buffer);
|
||||||
|
wdbData = wdbParser.parse();
|
||||||
|
|
||||||
|
// Initialize renderer
|
||||||
|
renderer = new VehiclePartRenderer(canvas);
|
||||||
|
|
||||||
|
// Load initial part
|
||||||
|
await loadCurrentPart();
|
||||||
|
|
||||||
|
renderer.start();
|
||||||
|
loading = false;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('VehiclePartColorEditor initialization error:', e);
|
||||||
|
error = e.message;
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
renderer?.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reload part when index changes
|
||||||
|
$: if (renderer && !loading && currentPart) {
|
||||||
|
const partKey = `${vehicle}-${globalIndex}`;
|
||||||
|
if (partKey !== loadedPartKey) {
|
||||||
|
loadCurrentPart();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update color when variable changes (without reloading geometry)
|
||||||
|
$: if (renderer && !loading && currentColorValue && loadedPartKey) {
|
||||||
|
renderer.updateColor(currentColorValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCurrentPart() {
|
||||||
|
if (!wdbData || !wdbParser || !currentPart || !renderer) return;
|
||||||
|
|
||||||
|
partError = null;
|
||||||
|
const partKey = `${vehicle}-${globalIndex}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const worldName = VehicleWorlds[vehicle];
|
||||||
|
const modelName = VehicleModels[vehicle];
|
||||||
|
|
||||||
|
// Find the vehicle world
|
||||||
|
const world = wdbData.worlds.find(w => w.name === worldName);
|
||||||
|
if (!world) {
|
||||||
|
partError = `World ${worldName} not found`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the vehicle model
|
||||||
|
const model = world.models.find(m =>
|
||||||
|
m.name.toLowerCase() === modelName.toLowerCase()
|
||||||
|
);
|
||||||
|
if (!model) {
|
||||||
|
partError = `Model ${modelName} not found`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse model data
|
||||||
|
const modelData = wdbParser.parseModelData(model.dataOffset);
|
||||||
|
|
||||||
|
// Find the part ROI
|
||||||
|
const partRoi = findRoi(modelData.roi, currentPart.part);
|
||||||
|
if (!partRoi) {
|
||||||
|
partError = `Part not found`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build parts map for shared LOD resolution
|
||||||
|
const partsMap = buildPartsMap(wdbParser, world.parts);
|
||||||
|
|
||||||
|
// Load part with current color, textures, and parts map for shared LOD lookup
|
||||||
|
renderer.loadPartWithColor(partRoi, currentColorValue, modelData.textures || [], partsMap);
|
||||||
|
loadedPartKey = partKey;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load part:', e);
|
||||||
|
partError = e.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function prevPart() {
|
||||||
|
globalIndex = globalIndex > 0 ? globalIndex - 1 : allParts.length - 1;
|
||||||
|
loadedPartKey = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextPart() {
|
||||||
|
globalIndex = globalIndex < allParts.length - 1 ? globalIndex + 1 : 0;
|
||||||
|
loadedPartKey = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cycleColor() {
|
||||||
|
if (!currentPart || partError) return;
|
||||||
|
|
||||||
|
// Find current color index and cycle to next
|
||||||
|
const currentIdx = LegoColorNames.indexOf(currentColorValue);
|
||||||
|
const nextIdx = (currentIdx + 1) % LegoColorNames.length;
|
||||||
|
const nextColor = LegoColorNames[nextIdx];
|
||||||
|
|
||||||
|
onUpdate({
|
||||||
|
variable: {
|
||||||
|
name: currentPart.variable,
|
||||||
|
value: nextColor
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetColor() {
|
||||||
|
if (!currentPart) return;
|
||||||
|
|
||||||
|
onUpdate({
|
||||||
|
variable: {
|
||||||
|
name: currentPart.variable,
|
||||||
|
value: currentPart.defaultColor
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<EditorTooltip text="Click on the part to cycle through colors. Changes are automatically saved.">
|
||||||
|
<!-- 3D Preview (clickable to cycle color) -->
|
||||||
|
<div class="preview-container">
|
||||||
|
<canvas
|
||||||
|
bind:this={canvas}
|
||||||
|
width="190"
|
||||||
|
height="190"
|
||||||
|
class:hidden={loading || error}
|
||||||
|
onclick={cycleColor}
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
aria-label="Click to change color"
|
||||||
|
></canvas>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="preview-overlay">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
</div>
|
||||||
|
{:else if error}
|
||||||
|
<div class="preview-overlay error">{error}</div>
|
||||||
|
{:else if partError}
|
||||||
|
<div class="preview-overlay error">{partError}</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Part navigation below canvas -->
|
||||||
|
<div class="part-nav">
|
||||||
|
<NavButton direction="left" onclick={prevPart} />
|
||||||
|
<div class="part-info">
|
||||||
|
<span class="vehicle-name">{VehicleNames[vehicle]}</span>
|
||||||
|
<span class="part-name">{currentPart?.label || 'Unknown'}</span>
|
||||||
|
</div>
|
||||||
|
<NavButton direction="right" onclick={nextPart} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="reset-container">
|
||||||
|
{#if !isDefault && !loading && !error && !partError}
|
||||||
|
<ResetButton onclick={resetColor} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</EditorTooltip>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.preview-container {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas {
|
||||||
|
display: block;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.hidden {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--color-bg-input);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-overlay.error {
|
||||||
|
color: var(--color-error, #e74c3c);
|
||||||
|
font-size: 0.75em;
|
||||||
|
padding: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background:
|
||||||
|
radial-gradient(transparent 55%, transparent 56%),
|
||||||
|
conic-gradient(var(--color-primary, #FFD700) 0deg 90deg, var(--color-border-dark, #333) 90deg 360deg);
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.part-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.part-info {
|
||||||
|
text-align: center;
|
||||||
|
min-width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vehicle-name {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.7em;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.part-name {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: var(--color-text-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reset-container {
|
||||||
|
height: 1.6em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Loading…
Reference in New Issue
Block a user