From 3b02784c81f4fe404944fae0665f9a42254734ac Mon Sep 17 00:00:00 2001 From: Christian Semmler Date: Sat, 31 Jan 2026 13:18:19 -0800 Subject: [PATCH] WIP: Interactive 3D score cube for save editor --- package-lock.json | 8 + package.json | 3 + src/core/formats/WdbParser.js | 439 ++++++++++++++++++ src/core/rendering/ScoreCubeRenderer.js | 394 ++++++++++++++++ .../save-editor/MissionScoresEditor.svelte | 198 +------- src/lib/save-editor/ScoreCube.svelte | 204 ++++++++ 6 files changed, 1057 insertions(+), 189 deletions(-) create mode 100644 src/core/formats/WdbParser.js create mode 100644 src/core/rendering/ScoreCubeRenderer.js create mode 100644 src/lib/save-editor/ScoreCube.svelte diff --git a/package-lock.json b/package-lock.json index 7934ee1..eeda022 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,9 @@ "": { "name": "isle.pizza", "version": "1.0.0", + "dependencies": { + "three": "^0.170.0" + }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^4.0.0", "svelte": "^5.0.0", @@ -6879,6 +6882,11 @@ "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": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", diff --git a/package.json b/package.json index dda8867..ec70328 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,9 @@ "preview": "vite preview", "prepare:assets": "node scripts/prepare.js" }, + "dependencies": { + "three": "^0.170.0" + }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^4.0.0", "svelte": "^5.0.0", diff --git a/src/core/formats/WdbParser.js b/src/core/formats/WdbParser.js new file mode 100644 index 0000000..adac915 --- /dev/null +++ b/src/core/formats/WdbParser.js @@ -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; +} diff --git a/src/core/rendering/ScoreCubeRenderer.js b/src/core/rendering/ScoreCubeRenderer.js new file mode 100644 index 0000000..428f975 --- /dev/null +++ b/src/core/rendering/ScoreCubeRenderer.js @@ -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>} 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(); + } +} diff --git a/src/lib/save-editor/MissionScoresEditor.svelte b/src/lib/save-editor/MissionScoresEditor.svelte index 681a73b..2f62bf8 100644 --- a/src/lib/save-editor/MissionScoresEditor.svelte +++ b/src/lib/save-editor/MissionScoresEditor.svelte @@ -1,207 +1,27 @@ -
- {#key missionData} -
-
-
- {#each actors as actor} -
{actor.name}
- {/each} -
- - {#each missions as mission} -
-
- {mission.name} -
- {#each actors as actor} -
- handleScoreChange(mission.key, actor.id, 'score', c)} - title="Score" - /> - handleScoreChange(mission.key, actor.id, 'highScore', c)} - title="High Score" - isHighScore={true} - /> -
- {/each} -
- {/each} -
- {/key} -
- -
- - Grey - - - Yellow - - - Blue - - - Red - - | - H = High Score +
+
diff --git a/src/lib/save-editor/ScoreCube.svelte b/src/lib/save-editor/ScoreCube.svelte new file mode 100644 index 0000000..af187bb --- /dev/null +++ b/src/lib/save-editor/ScoreCube.svelte @@ -0,0 +1,204 @@ + + +
+
+ ? + Click on the cube to cycle high scores. Changes are automatically saved. + +
+ + + {#if loading} +
Loading score cube...
+ {:else if error} +
Error: {error}
+ {/if} +
+ +