mirror of
https://github.com/isledecomp/isle.pizza.git
synced 2026-03-01 06:17:38 +00:00
WIP: Interactive 3D score cube for save editor
This commit is contained in:
parent
41688393a9
commit
3b02784c81
8
package-lock.json
generated
8
package-lock.json
generated
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
439
src/core/formats/WdbParser.js
Normal file
439
src/core/formats/WdbParser.js
Normal 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;
|
||||||
|
}
|
||||||
394
src/core/rendering/ScoreCubeRenderer.js
Normal file
394
src/core/rendering/ScoreCubeRenderer.js
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
|||||||
204
src/lib/save-editor/ScoreCube.svelte
Normal file
204
src/lib/save-editor/ScoreCube.svelte
Normal 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>
|
||||||
Loading…
Reference in New Issue
Block a user