WIP: Interactive 3D score cube for save editor

This commit is contained in:
Christian Semmler 2026-01-31 13:18:19 -08:00
parent 41688393a9
commit 3b02784c81
No known key found for this signature in database
GPG Key ID: 086DAA1360BEEE5C
6 changed files with 1057 additions and 189 deletions

8
package-lock.json generated
View File

@ -7,6 +7,9 @@
"": { "": {
"name": "isle.pizza", "name": "isle.pizza",
"version": "1.0.0", "version": "1.0.0",
"dependencies": {
"three": "^0.170.0"
},
"devDependencies": { "devDependencies": {
"@sveltejs/vite-plugin-svelte": "^4.0.0", "@sveltejs/vite-plugin-svelte": "^4.0.0",
"svelte": "^5.0.0", "svelte": "^5.0.0",
@ -6879,6 +6882,11 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/three": {
"version": "0.170.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.170.0.tgz",
"integrity": "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ=="
},
"node_modules/through": { "node_modules/through": {
"version": "2.3.8", "version": "2.3.8",
"resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",

View File

@ -10,6 +10,9 @@
"preview": "vite preview", "preview": "vite preview",
"prepare:assets": "node scripts/prepare.js" "prepare:assets": "node scripts/prepare.js"
}, },
"dependencies": {
"three": "^0.170.0"
},
"devDependencies": { "devDependencies": {
"@sveltejs/vite-plugin-svelte": "^4.0.0", "@sveltejs/vite-plugin-svelte": "^4.0.0",
"svelte": "^5.0.0", "svelte": "^5.0.0",

View File

@ -0,0 +1,439 @@
import { BinaryReader } from '../savegame/BinaryReader.js';
/**
* Parser for LEGO Island WORLD.WDB files
* Based on wdb.ksy specification and source code analysis
*/
export class WdbParser {
constructor(buffer) {
this.reader = new BinaryReader(buffer);
this.buffer = buffer;
}
/**
* Parse the WDB file structure
* @returns {{ worlds: Array, globalTexturesSize: number, globalPartsSize: number }}
*/
parse() {
const numWorlds = this.reader.readS32();
const worlds = [];
for (let i = 0; i < numWorlds; i++) {
worlds.push(this.parseWorldEntry());
}
const globalTexturesSize = this.reader.readU32();
// Skip global textures for now - BIGCUBE.GIF is in model_data
this.reader.skip(globalTexturesSize);
const globalPartsSize = this.reader.readU32();
// Skip global parts
this.reader.skip(globalPartsSize);
return { worlds, globalTexturesSize, globalPartsSize };
}
parseWorldEntry() {
const nameLen = this.reader.readS32();
const name = this.reader.readString(nameLen).replace(/\0/g, '');
// Parse parts (skip for now)
const numParts = this.reader.readS32();
for (let i = 0; i < numParts; i++) {
this.skipPartReference();
}
// Parse models
const numModels = this.reader.readS32();
const models = [];
for (let i = 0; i < numModels; i++) {
models.push(this.parseModelEntry());
}
return { name, numParts, models };
}
skipPartReference() {
const nameLen = this.reader.readU32();
this.reader.skip(nameLen); // name
this.reader.skip(4); // data_length
this.reader.skip(4); // data_offset
}
parseModelEntry() {
const nameLen = this.reader.readU32();
const name = this.reader.readString(nameLen).replace(/\0/g, '');
const dataLength = this.reader.readU32();
const dataOffset = this.reader.readU32();
const presenterLen = this.reader.readU32();
const presenter = this.reader.readString(presenterLen).replace(/\0/g, '');
const location = this.readVertex3();
const direction = this.readVertex3();
const up = this.readVertex3();
const visible = this.reader.readU8();
return { name, dataLength, dataOffset, presenter, location, direction, up, visible };
}
readVertex3() {
return {
x: this.reader.readF32(),
y: this.reader.readF32(),
z: this.reader.readF32()
};
}
/**
* Read string and strip null terminators
*/
readCleanString(length) {
return this.reader.readString(length).replace(/\0/g, '');
}
/**
* Parse model_data blob at specified offset
* @param {number} offset - Absolute file offset
* @returns {{ version: number, anim: object, roi: object, textures: Array }}
*/
parseModelData(offset) {
this.reader.seek(offset);
const version = this.reader.readU32();
if (version !== 19) {
throw new Error(`Unexpected model version: ${version}, expected 19`);
}
const textureInfoOffset = this.reader.readU32();
const numRois = this.reader.readU32();
// Parse animation data
const anim = this.parseModelAnim();
// Parse ROI hierarchy
const roi = this.parseRoi();
// Parse textures at textureInfoOffset
this.reader.seek(offset + textureInfoOffset);
const textures = this.parseTextureInfo();
return { version, anim, roi, textures };
}
parseModelAnim() {
const numActors = this.reader.readU32();
const actors = [];
for (let i = 0; i < numActors; i++) {
const nameLen = this.reader.readU32();
if (nameLen > 0) {
const name = this.readCleanString(nameLen);
const actorType = this.reader.readU32();
actors.push({ name, actorType });
}
}
const duration = this.reader.readS32();
const rootNode = this.parseAnimTreeNode();
return { actors, duration, rootNode };
}
parseAnimTreeNode() {
const data = this.parseAnimNodeData();
const numChildren = this.reader.readU32();
const children = [];
for (let i = 0; i < numChildren; i++) {
children.push(this.parseAnimTreeNode());
}
return { data, children };
}
parseAnimNodeData() {
const nameLen = this.reader.readU32();
const name = nameLen > 0 ? this.readCleanString(nameLen) : '';
// Translation keys
const numTranslationKeys = this.reader.readU16();
const translationKeys = [];
for (let i = 0; i < numTranslationKeys; i++) {
translationKeys.push(this.parseTranslationKey());
}
// Rotation keys
const numRotationKeys = this.reader.readU16();
const rotationKeys = [];
for (let i = 0; i < numRotationKeys; i++) {
rotationKeys.push(this.parseRotationKey());
}
// Scale keys
const numScaleKeys = this.reader.readU16();
const scaleKeys = [];
for (let i = 0; i < numScaleKeys; i++) {
scaleKeys.push(this.parseScaleKey());
}
// Morph keys
const numMorphKeys = this.reader.readU16();
const morphKeys = [];
for (let i = 0; i < numMorphKeys; i++) {
morphKeys.push(this.parseMorphKey());
}
return { name, translationKeys, rotationKeys, scaleKeys, morphKeys };
}
parseAnimKey() {
const timeAndFlags = this.reader.readS32();
const time = timeAndFlags & 0xFFFFFF;
const flags = (timeAndFlags >> 24) & 0xFF;
return { time, flags };
}
parseTranslationKey() {
const key = this.parseAnimKey();
const x = this.reader.readF32();
const y = this.reader.readF32();
const z = this.reader.readF32();
return { ...key, x, y, z };
}
parseRotationKey() {
const key = this.parseAnimKey();
const angle = this.reader.readF32(); // w component
const x = this.reader.readF32();
const y = this.reader.readF32();
const z = this.reader.readF32();
return { ...key, angle, x, y, z };
}
parseScaleKey() {
const key = this.parseAnimKey();
const x = this.reader.readF32();
const y = this.reader.readF32();
const z = this.reader.readF32();
return { ...key, x, y, z };
}
parseMorphKey() {
const key = this.parseAnimKey();
const visible = this.reader.readU8();
return { ...key, visible };
}
parseRoi() {
const nameLen = this.reader.readU32();
const name = this.reader.readString(nameLen).replace(/\0/g, '');
// Bounding sphere
const boundingSphere = {
center: this.readVertex3(),
radius: this.reader.readF32()
};
// Bounding box
const boundingBox = {
min: this.readVertex3(),
max: this.readVertex3()
};
// Texture name (for color/material reference)
const textureNameLen = this.reader.readU32();
const textureName = textureNameLen > 0 ? this.readCleanString(textureNameLen) : null;
// Shared LOD list flag
const sharedLodList = this.reader.readU8();
let lods = [];
if (sharedLodList === 0) {
const numLods = this.reader.readU32();
if (numLods > 0) {
const nextRoiOffset = this.reader.readU32();
for (let i = 0; i < numLods; i++) {
lods.push(this.parseLod());
}
}
}
// Children
const numChildren = this.reader.readU32();
const children = [];
for (let i = 0; i < numChildren; i++) {
children.push(this.parseRoi());
}
return { name, boundingSphere, boundingBox, textureName, lods, children };
}
parseLod() {
const flags = this.reader.readU32();
const numMeshes = this.reader.readU32();
if (numMeshes === 0) {
return { flags, numMeshes, vertices: [], normals: [], textureVertices: [], meshes: [] };
}
// Packed vertex/normal counts
const vertexNormalCounts = this.reader.readU32();
const vertexCount = vertexNormalCounts & 0xFFFF;
const normalCount = (vertexNormalCounts >> 17) & 0x7FFF;
const numTextureVertices = this.reader.readS32();
// Read vertices
const vertices = [];
for (let i = 0; i < vertexCount; i++) {
vertices.push(this.readVertex3());
}
// Read normals
const normals = [];
for (let i = 0; i < normalCount; i++) {
normals.push(this.readVertex3());
}
// Read texture vertices (UVs)
const textureVertices = [];
for (let i = 0; i < numTextureVertices; i++) {
textureVertices.push({
u: this.reader.readF32(),
v: this.reader.readF32()
});
}
// Read meshes
const meshes = [];
for (let i = 0; i < numMeshes; i++) {
meshes.push(this.parseMesh());
}
return { flags, numMeshes, vertexCount, normalCount, vertices, normals, textureVertices, meshes };
}
parseMesh() {
const numPolygons = this.reader.readU16();
const numVertices = this.reader.readU16();
// Polygon indices (vertex/normal pairs)
const polygonIndices = [];
for (let i = 0; i < numPolygons; i++) {
polygonIndices.push({
a: this.reader.readU32(),
b: this.reader.readU32(),
c: this.reader.readU32()
});
}
// Texture indices
const numTextureIndices = this.reader.readU32();
const textureIndices = [];
if (numTextureIndices > 0) {
for (let i = 0; i < numPolygons; i++) {
textureIndices.push({
a: this.reader.readU32(),
b: this.reader.readU32(),
c: this.reader.readU32()
});
}
}
// Mesh properties
const properties = this.parseMeshProperties();
return { numPolygons, numVertices, polygonIndices, textureIndices, properties };
}
parseMeshProperties() {
const color = {
r: this.reader.readU8(),
g: this.reader.readU8(),
b: this.reader.readU8()
};
const alpha = this.reader.readF32();
const shading = this.reader.readU8();
const unknown0x0d = this.reader.readU8();
const unknown0x20 = this.reader.readU8();
const useAlias = this.reader.readU8();
const textureNameLen = this.reader.readU32();
const textureName = textureNameLen > 0 ? this.readCleanString(textureNameLen) : null;
const materialNameLen = this.reader.readU32();
const materialName = materialNameLen > 0 ? this.readCleanString(materialNameLen) : null;
return { color, alpha, shading, useAlias, textureName, materialName };
}
parseTextureInfo() {
const numTextures = this.reader.readU32();
const skipTextures = this.reader.readU32();
const textures = [];
for (let i = 0; i < numTextures; i++) {
const nameLen = this.reader.readU32();
let name = this.readCleanString(nameLen).toLowerCase();
// Handle '^' prefix (hi-res/lo-res pair)
let hasHighRes = false;
if (name.startsWith('^')) {
name = name.substring(1);
hasHighRes = true;
// Read hi-res texture
const hiRes = this.parseLegoImage();
// Skip lo-res texture
this.skipLegoImage();
textures.push({ name, ...hiRes });
} else {
textures.push({ name, ...this.parseLegoImage() });
}
}
return textures;
}
parseLegoImage() {
const width = this.reader.readU32();
const height = this.reader.readU32();
const paletteSize = this.reader.readU32();
const palette = [];
for (let i = 0; i < paletteSize; i++) {
palette.push({
r: this.reader.readU8(),
g: this.reader.readU8(),
b: this.reader.readU8()
});
}
const pixels = new Uint8Array(this.reader.slice(width * height));
return { width, height, paletteSize, palette, pixels };
}
skipLegoImage() {
const width = this.reader.readU32();
const height = this.reader.readU32();
const paletteSize = this.reader.readU32();
this.reader.skip(paletteSize * 3); // palette
this.reader.skip(width * height); // pixels
}
}
/**
* Helper to find an ROI by name in a hierarchy
* @param {object} roi - Root ROI
* @param {string} name - Name to find (case-insensitive)
* @returns {object|null}
*/
export function findRoi(roi, name) {
if (roi.name.toLowerCase() === name.toLowerCase()) {
return roi;
}
for (const child of roi.children || []) {
const found = findRoi(child, name);
if (found) return found;
}
return null;
}

View File

@ -0,0 +1,394 @@
import * as THREE from 'three';
/**
* Three.js renderer for the LEGO Island score cube
*/
export class ScoreCubeRenderer {
constructor(canvas) {
this.canvas = canvas;
this.animating = false;
this.cubeGroup = null; // Group containing textured and non-textured meshes
this.texturedMesh = null; // The mesh with the score texture (for raycasting)
this.texture = null;
this.textureCanvas = null;
this.baseImageData = null;
this.palette = null;
// Setup scene
this.scene = new THREE.Scene();
// Setup camera
this.camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100);
this.camera.position.set(0, 0, 7);
// Setup renderer
this.renderer = new THREE.WebGLRenderer({
canvas,
antialias: true,
alpha: true
});
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
this.renderer.setClearColor(0x000000, 0);
// Lighting
const ambient = new THREE.AmbientLight(0xffffff, 0.7);
this.scene.add(ambient);
const directional = new THREE.DirectionalLight(0xffffff, 0.5);
directional.position.set(5, 5, 5);
this.scene.add(directional);
const backLight = new THREE.DirectionalLight(0xffffff, 0.3);
backLight.position.set(-5, -3, -5);
this.scene.add(backLight);
}
/**
* Load model geometry and texture from parsed WDB data
* @param {object} roiData - Parsed ROI data with lods
* @param {object} textureData - Parsed texture with palette and pixels
*/
loadModel(roiData, textureData) {
this.palette = textureData.palette;
// Create group to hold all meshes
this.cubeGroup = new THREE.Group();
// Create geometries from ROI data (separate textured and non-textured)
const { texturedGeometry, nonTexturedGeometries } = this.createGeometries(roiData);
// Create texture from parsed data
this.textureCanvas = this.createTextureCanvas(textureData);
this.texture = new THREE.CanvasTexture(this.textureCanvas);
this.texture.minFilter = THREE.LinearFilter;
this.texture.magFilter = THREE.LinearFilter;
// Create textured mesh (the score grid face)
if (texturedGeometry) {
const texturedMaterial = new THREE.MeshStandardMaterial({
map: this.texture,
side: THREE.DoubleSide,
roughness: 0.8,
metalness: 0.1
});
this.texturedMesh = new THREE.Mesh(texturedGeometry, texturedMaterial);
this.cubeGroup.add(this.texturedMesh);
}
// Create non-textured meshes (cube frame/edges) with their colors
for (const { geometry, color } of nonTexturedGeometries) {
const material = new THREE.MeshStandardMaterial({
color: new THREE.Color(color.r / 255, color.g / 255, color.b / 255),
side: THREE.DoubleSide,
roughness: 0.8,
metalness: 0.1
});
const mesh = new THREE.Mesh(geometry, material);
this.cubeGroup.add(mesh);
}
this.scene.add(this.cubeGroup);
// Initial render
this.renderer.render(this.scene, this.camera);
}
/**
* Create Three.js BufferGeometries from ROI LOD data
* Based on brickolini-island's wdb.ts implementation
*
* Packed polygon index format (32-bit):
* - Bits 0-15: vertex index (16 bits) into positions array, OR destination index when reusing
* - Bits 16-30: normal index into normals array
* - Bit 31: "create new vertex" flag - when set, create a new mesh vertex;
* when clear, bits 0-15 is the INDEX into the created mesh vertices array
*
* @param {object} roiData - ROI with lods array
* @returns {{ texturedGeometry: THREE.BufferGeometry|null, nonTexturedGeometries: Array }}
*/
createGeometries(roiData) {
if (!roiData.lods || roiData.lods.length === 0) {
console.warn('ROI has no LODs');
return { texturedGeometry: null, nonTexturedGeometries: [] };
}
const lod = roiData.lods[0];
let texturedGeometry = null;
const nonTexturedGeometries = [];
for (const mesh of lod.meshes) {
const hasTexture = mesh.textureIndices && mesh.textureIndices.length > 0;
// Flatten polygon indices
const vertexIndicesPacked = [];
for (const poly of mesh.polygonIndices) {
vertexIndicesPacked.push(poly.a, poly.b, poly.c);
}
// Flatten texture indices if present
const textureIndicesFlat = [];
if (hasTexture) {
for (const texPoly of mesh.textureIndices) {
textureIndicesFlat.push(texPoly.a, texPoly.b, texPoly.c);
}
}
// Build mesh vertices following brickolini-island logic
const meshVertices = [];
const meshNormals = [];
const meshUvs = [];
const indices = [];
for (let i = 0; i < vertexIndicesPacked.length; i++) {
const packed = vertexIndicesPacked[i];
if ((packed & 0x80000000) !== 0) {
// Create flag is set - create new mesh vertex
indices.push(meshVertices.length);
const gv = packed & 0xFFFF; // Vertex index (16 bits)
const v = lod.vertices[gv] || { x: 0, y: 0, z: 0 };
// Negate X for coordinate system conversion (like brickolini)
meshVertices.push([-v.x, v.y, v.z]);
const gn = (packed >>> 16) & 0x7fff; // Normal index (15 bits)
const n = lod.normals[gn] || { x: 0, y: 1, z: 0 };
meshNormals.push([-n.x, n.y, n.z]);
if (hasTexture && 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 {
// Create flag NOT set - reuse existing mesh vertex by index
indices.push(packed & 0xFFFF);
}
}
// Reverse face winding (swap indices 0 and 2 of each triangle)
for (let i = 0; i < indices.length; i += 3) {
const temp = indices[i];
indices[i] = indices[i + 2];
indices[i + 2] = temp;
}
// Create geometry
const geometry = new THREE.BufferGeometry();
const vertices = meshVertices.flat();
const normals = meshNormals.flat();
geometry.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3));
geometry.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3));
geometry.setIndex(indices);
if (hasTexture) {
const uvs = meshUvs.flat();
geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
texturedGeometry = geometry;
} else {
// Get color from mesh properties
const color = mesh.properties?.color || { r: 128, g: 128, b: 128 };
nonTexturedGeometries.push({ geometry, color });
}
}
return { texturedGeometry, nonTexturedGeometries };
}
/**
* Create canvas texture from parsed texture data
* @param {object} textureData - { width, height, palette, pixels }
* @returns {HTMLCanvasElement}
*/
createTextureCanvas(textureData) {
const canvas = document.createElement('canvas');
canvas.width = textureData.width;
canvas.height = textureData.height;
const ctx = canvas.getContext('2d');
// Convert indexed color to RGBA
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);
// Store base image for score updates
this.baseImageData = ctx.getImageData(0, 0, textureData.width, textureData.height);
return canvas;
}
/**
* Update score colors on texture
* Score layout on cube (left to right, top to bottom):
* - Activities (columns): carRace, jetskiRace, pizza, towTrack, ambulance (0-4)
* - Actors (rows): pepper, mama, papa, nick, laura (0-4)
* @param {Array<Array<number>>} scores - 2D array [actor][activity] with values 0-3
*/
updateScores(scores) {
if (!this.textureCanvas || !this.baseImageData || !this.palette) return;
const ctx = this.textureCanvas.getContext('2d');
// Restore base texture first
ctx.putImageData(this.baseImageData, 0, 0);
// Score pixel layout from score.cpp
const areaYOffsets = [0x2b, 0x57, 0x80, 0xab, 0xd6]; // per actor row
const areaHeights = [0x2a, 0x27, 0x29, 0x29, 0x2a];
const areaXOffsets = [0x2f, 0x56, 0x81, 0xaa, 0xd4]; // per activity column
const areaWidths = [0x25, 0x29, 0x27, 0x28, 0x28];
// Palette indices for score colors
const colorIndices = [0x11, 0x0f, 0x08, 0x05]; // grey, yellow, blue, red
for (let actor = 0; actor < 5; actor++) {
for (let activity = 0; activity < 5; activity++) {
const score = scores?.[actor]?.[activity] ?? 0;
const clampedScore = Math.max(0, Math.min(3, score));
const colorIdx = colorIndices[clampedScore];
const color = this.palette[colorIdx];
if (color) {
ctx.fillStyle = `rgb(${color.r}, ${color.g}, ${color.b})`;
ctx.fillRect(
areaXOffsets[activity],
areaYOffsets[actor],
areaWidths[activity],
areaHeights[actor]
);
}
}
}
if (this.texture) {
this.texture.needsUpdate = true;
}
}
/**
* Start animation loop
*/
start() {
this.animating = true;
this.animate();
}
/**
* Stop animation loop
*/
stop() {
this.animating = false;
}
/**
* Animation loop
*/
animate = () => {
if (!this.animating) return;
requestAnimationFrame(this.animate);
// Rotate cube group
if (this.cubeGroup) {
this.cubeGroup.rotation.y += 0.008;
}
this.renderer.render(this.scene, this.camera);
}
/**
* Raycast to find clicked score cell
* @param {MouseEvent} event - Click event
* @returns {{ actor: number, activity: number } | null}
*/
raycast(event) {
if (!this.texturedMesh) return null;
const rect = this.canvas.getBoundingClientRect();
const mouse = new THREE.Vector2(
((event.clientX - rect.left) / rect.width) * 2 - 1,
-((event.clientY - rect.top) / rect.height) * 2 + 1
);
const raycaster = new THREE.Raycaster();
raycaster.setFromCamera(mouse, this.camera);
const intersects = raycaster.intersectObject(this.texturedMesh);
if (intersects.length > 0 && intersects[0].uv) {
const uv = intersects[0].uv;
// Convert UV to pixel coordinates (texture is 256x256)
// UV was flipped in geometry (1-v), so flip back for image coords
const x = uv.x * 256;
const y = (1 - uv.y) * 256;
return this.uvToScoreCell(x, y);
}
return null;
}
/**
* Convert texture pixel coordinates to score cell
* @param {number} x - X coordinate (0-256)
* @param {number} y - Y coordinate (0-256)
* @returns {{ actor: number, activity: number } | null}
*/
uvToScoreCell(x, y) {
const areaXOffsets = [0x2f, 0x56, 0x81, 0xaa, 0xd4];
const areaYOffsets = [0x2b, 0x57, 0x80, 0xab, 0xd6];
const areaWidths = [0x25, 0x29, 0x27, 0x28, 0x28];
const areaHeights = [0x2a, 0x27, 0x29, 0x29, 0x2a];
for (let activity = 0; activity < 5; activity++) {
for (let actor = 0; actor < 5; actor++) {
if (
x >= areaXOffsets[activity] &&
x < areaXOffsets[activity] + areaWidths[activity] &&
y >= areaYOffsets[actor] &&
y < areaYOffsets[actor] + areaHeights[actor]
) {
return { actor, activity };
}
}
}
return null;
}
/**
* Resize renderer to match canvas size
* @param {number} width
* @param {number} height
*/
resize(width, height) {
this.camera.aspect = width / height;
this.camera.updateProjectionMatrix();
this.renderer.setSize(width, height, false);
}
/**
* Clean up resources
*/
dispose() {
this.animating = false;
if (this.cubeGroup) {
this.cubeGroup.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.geometry?.dispose();
child.material?.dispose();
}
});
this.scene.remove(this.cubeGroup);
}
this.texture?.dispose();
this.renderer?.dispose();
}
}

View File

@ -1,207 +1,27 @@
<script> <script>
import ScoreColorButton from './ScoreColorButton.svelte'; import ScoreCube from './ScoreCube.svelte';
import { Actor, ActorNames, MissionNames } from '../../core/savegame/constants.js';
export let slot; export let slot;
export let onUpdate = () => {}; export let onUpdate = () => {};
const missions = [
{ key: 'pizza', name: MissionNames.pizza, icon: 'pizza.webp' },
{ key: 'carRace', name: MissionNames.carRace, icon: 'race.webp' },
{ key: 'jetskiRace', name: MissionNames.jetskiRace, icon: 'boat.webp' },
{ key: 'towTrack', name: MissionNames.towTrack, icon: 'gas.webp' },
{ key: 'ambulance', name: MissionNames.ambulance, icon: 'med.webp' }
];
const actors = [
{ id: Actor.PEPPER, name: ActorNames[Actor.PEPPER] },
{ id: Actor.MAMA, name: ActorNames[Actor.MAMA] },
{ id: Actor.PAPA, name: ActorNames[Actor.PAPA] },
{ id: Actor.NICK, name: ActorNames[Actor.NICK] },
{ id: Actor.LAURA, name: ActorNames[Actor.LAURA] }
];
// Reactive mission data - re-evaluated when slot changes // Reactive mission data - re-evaluated when slot changes
$: missionData = slot?.missions || {}; $: missionData = slot?.missions || {};
function handleScoreChange(missionKey, actorId, scoreType, newColor) { function handleCubeUpdate(update) {
onUpdate({ onUpdate(update);
missionType: missionKey,
actorId,
scoreType,
value: newColor
});
}
function getScore(missionKey, actorId, scoreType) {
const data = missionData[missionKey];
if (!data) return 0;
if (scoreType === 'score') {
return data.scores?.[actorId] ?? 0;
} else {
return data.highScores?.[actorId] ?? 0;
}
} }
</script> </script>
<div class="scores-table-wrapper"> <div class="scores-editor">
{#key missionData} <ScoreCube
<div class="scores-table"> missions={missionData}
<div class="scores-header"> onUpdate={handleCubeUpdate}
<div class="mission-col"></div> />
{#each actors as actor}
<div class="actor-col">{actor.name}</div>
{/each}
</div>
{#each missions as mission}
<div class="scores-row">
<div class="mission-col">
<img src={mission.icon} alt={mission.name} class="mission-icon" title={mission.name} />
</div>
{#each actors as actor}
<div class="actor-col">
<ScoreColorButton
color={getScore(mission.key, actor.id, 'score')}
onChange={(c) => handleScoreChange(mission.key, actor.id, 'score', c)}
title="Score"
/>
<ScoreColorButton
color={getScore(mission.key, actor.id, 'highScore')}
onChange={(c) => handleScoreChange(mission.key, actor.id, 'highScore', c)}
title="High Score"
isHighScore={true}
/>
</div>
{/each}
</div>
{/each}
</div>
{/key}
</div>
<div class="scores-legend">
<span class="legend-item">
<span class="legend-color grey"></span> Grey
</span>
<span class="legend-item">
<span class="legend-color yellow"></span> Yellow
</span>
<span class="legend-item">
<span class="legend-color blue"></span> Blue
</span>
<span class="legend-item">
<span class="legend-color red"></span> Red
</span>
<span class="legend-divider">|</span>
<span class="legend-note">H = High Score</span>
</div> </div>
<style> <style>
.scores-table-wrapper { .scores-editor {
overflow-x: auto;
}
.scores-table {
display: flex; display: flex;
flex-direction: column;
gap: 8px;
min-width: 500px;
}
.scores-header,
.scores-row {
display: grid;
grid-template-columns: 50px repeat(5, 1fr);
gap: 8px;
align-items: center;
}
.scores-header {
font-weight: bold;
color: var(--color-text-light);
font-size: 0.8em;
padding-bottom: 8px;
border-bottom: 1px solid var(--color-border-dark);
}
.mission-col {
display: flex;
align-items: center;
justify-content: center; justify-content: center;
} }
.mission-icon {
width: 32px;
height: 32px;
image-rendering: pixelated;
}
.actor-col {
display: flex;
gap: 4px;
justify-content: center;
}
.scores-header .actor-col {
font-size: 0.75em;
text-align: center;
}
.scores-legend {
display: flex;
gap: 12px;
justify-content: center;
align-items: center;
flex-wrap: wrap;
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--color-border-dark);
}
.legend-item {
display: flex;
align-items: center;
gap: 6px;
color: var(--color-text-muted);
font-size: 0.8em;
}
.legend-color {
width: 16px;
height: 16px;
border-radius: 3px;
border: 1px solid rgba(255, 255, 255, 0.2);
}
.legend-color.grey { background: #808080; }
.legend-color.yellow { background: #FFD700; }
.legend-color.blue { background: #4169E1; }
.legend-color.red { background: #DC143C; }
.legend-divider {
color: var(--color-border-medium);
}
.legend-note {
color: var(--color-text-muted);
font-size: 0.8em;
}
@media (max-width: 600px) {
.scores-header,
.scores-row {
grid-template-columns: 40px repeat(5, 1fr);
}
.scores-header .actor-col {
font-size: 0.65em;
}
.mission-icon {
width: 28px;
height: 28px;
}
}
</style> </style>

View File

@ -0,0 +1,204 @@
<script>
import { onMount, onDestroy } from 'svelte';
import { ScoreCubeRenderer } from '../../core/rendering/ScoreCubeRenderer.js';
import { WdbParser, findRoi } from '../../core/formats/WdbParser.js';
export let missions = {};
export let onUpdate = () => {};
let canvas;
let renderer = null;
let loading = true;
let error = null;
// Activity keys in order matching cube layout (left to right)
// Car Race, Jetski Race, Pizza, Tow Track, Ambulance
const activities = ['carRace', 'jetskiRace', 'pizza', 'towTrack', 'ambulance'];
onMount(async () => {
try {
// Load and parse WDB
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();
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')
);
if (!scormainModel) {
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'
);
if (!bigcubeTexture) {
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);
renderer.updateScores(convertScores(missions));
renderer.start();
loading = false;
} catch (e) {
console.error('ScoreCube initialization error:', e);
error = e.message;
loading = false;
}
});
onDestroy(() => {
renderer?.dispose();
});
// Reactive score updates
$: if (renderer && !loading) {
renderer.updateScores(convertScores(missions));
}
/**
* Convert mission data format to 2D score array
* Input: { pizza: { highScores: { 1: val, 2: val, ... } }, ... }
* Output: [[actor0 scores], [actor1 scores], ...]
*/
function convertScores(missionData) {
const result = [];
for (let actor = 0; actor < 5; actor++) {
result[actor] = [];
for (let activity = 0; activity < 5; activity++) {
const missionKey = activities[activity];
// Actor IDs are 1-indexed in the save data
result[actor][activity] = missionData?.[missionKey]?.highScores?.[actor + 1] ?? 0;
}
}
return result;
}
function handleClick(event) {
if (!renderer || loading) return;
const hit = renderer.raycast(event);
if (hit) {
const missionKey = activities[hit.activity];
const actorId = hit.actor + 1; // Convert to 1-indexed
// Get current score and cycle to next
const currentScore = missions?.[missionKey]?.highScores?.[actorId] ?? 0;
const newScore = (currentScore + 1) % 4;
onUpdate({
missionType: missionKey,
actorId,
scoreType: 'highScore',
value: newScore
});
}
}
</script>
<div class="score-cube-container">
<div class="score-cube-header">
<span class="tooltip-trigger">?
<span class="tooltip-content">Click on the cube to cycle high scores. Changes are automatically saved.</span>
</span>
</div>
<canvas
bind:this={canvas}
width="200"
height="200"
onclick={handleClick}
class:hidden={loading || error}
role="button"
tabindex="0"
aria-label="Score cube - click to edit scores"
></canvas>
{#if loading}
<div class="overlay">Loading score cube...</div>
{:else if error}
<div class="overlay error">Error: {error}</div>
{/if}
</div>
<style>
.score-cube-container {
display: flex;
flex-direction: column;
align-items: center;
position: relative;
}
.score-cube-header {
align-self: flex-end;
margin-bottom: 4px;
}
canvas {
cursor: pointer;
border-radius: 8px;
}
canvas.hidden {
visibility: hidden;
position: absolute;
}
canvas:focus {
outline: none;
}
.overlay {
display: flex;
align-items: center;
justify-content: center;
width: 200px;
height: 200px;
border-radius: 8px;
background: var(--color-bg-secondary, #1a1a2e);
color: var(--color-text-muted, #888);
font-size: 0.9em;
}
.overlay.error {
color: var(--color-error, #e74c3c);
padding: 16px;
text-align: center;
}
</style>