mirror of
https://github.com/isledecomp/isle.pizza.git
synced 2026-02-28 05:47:39 +00:00
Some checks are pending
Build / build (push) Waiting to run
* Add vehicle part color editor to save editor - Add Vehicles tab with 3D preview of customizable vehicle parts - Support all 43 colorable parts across 4 vehicles (dune buggy, helicopter, jetski, race car) - Implement shared LOD resolution for proper rendering of all parts - Extract reusable NavButton and ResetButton components - Remove debug console.log statements from ScoreCube * Reserve space for reset button in vehicle editor to prevent layout shift * Add tooltip to vehicle editor explaining click-to-cycle interaction * Add scroll-into-view behavior for name slots on mobile When name slots overflow on narrow screens, focusing a partially visible slot now smoothly scrolls it into view. Scrollbar is hidden for cleaner UI. * Extract EditorTooltip component for consistent tooltip positioning Refactored tooltip markup into reusable EditorTooltip component used by both ScoreCube and VehicleEditor. Tooltip now consistently appears in top right corner of the editor section. * Add transparency support to vehicle part rendering Apply mesh alpha values from WDB to Three.js materials. In the original game, alpha=0 means opaque while alpha>0 enables transparency. Disable depthWrite for transparent meshes to prevent z-fighting. * Use MeshLambertMaterial for original game-like rendering Switch from MeshStandardMaterial (PBR) to MeshLambertMaterial for flat, vibrant colors matching the original game. Simplify lighting setup for solid colors without visible shadows. * Fix WDB texture parsing for parts vs models Parts and models have different texture info formats - models include a skipTextures field that parts don't have. Add isModel parameter to parseTextureInfo to handle this difference correctly. Also remove silent catch blocks and overly defensive checks.
529 lines
16 KiB
JavaScript
529 lines
16 KiB
JavaScript
import { BinaryReader } from './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
|
|
const numParts = this.reader.readS32();
|
|
const parts = [];
|
|
for (let i = 0; i < numParts; i++) {
|
|
parts.push(this.parsePartReference());
|
|
}
|
|
|
|
// Parse models
|
|
const numModels = this.reader.readS32();
|
|
const models = [];
|
|
for (let i = 0; i < numModels; i++) {
|
|
models.push(this.parseModelEntry());
|
|
}
|
|
|
|
return { name, numParts, parts, models };
|
|
}
|
|
|
|
parsePartReference() {
|
|
const nameLen = this.reader.readU32();
|
|
const name = this.reader.readString(nameLen).replace(/\0/g, '');
|
|
const dataLength = this.reader.readU32();
|
|
const dataOffset = this.reader.readU32();
|
|
return { name, dataLength, dataOffset };
|
|
}
|
|
|
|
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 part data blob at specified offset
|
|
* Parts have a simpler structure than models - no animation, direct LOD data
|
|
* @param {number} offset - Absolute file offset
|
|
* @returns {{ parts: Array, textures: Array }}
|
|
*/
|
|
parsePartData(offset) {
|
|
this.reader.seek(offset);
|
|
|
|
const textureInfoOffset = this.reader.readU32();
|
|
const numRois = this.reader.readU32();
|
|
const parts = [];
|
|
|
|
for (let i = 0; i < numRois; i++) {
|
|
const nameLen = this.reader.readU32();
|
|
const name = this.readCleanString(nameLen);
|
|
const numLods = this.reader.readU32();
|
|
const roiInfoOffset = this.reader.readU32();
|
|
|
|
const lods = [];
|
|
for (let j = 0; j < numLods; j++) {
|
|
lods.push(this.parseLod());
|
|
}
|
|
|
|
parts.push({ name, lods });
|
|
}
|
|
|
|
this.reader.seek(offset + textureInfoOffset);
|
|
const textures = this.parseTextureInfo();
|
|
|
|
return { parts, textures };
|
|
}
|
|
|
|
/**
|
|
* Parse model_data blob at specified offset
|
|
* @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();
|
|
|
|
this.reader.seek(offset + textureInfoOffset);
|
|
const textures = this.parseTextureInfo(true); // Models have skipTextures field
|
|
|
|
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, sharedLodList: sharedLodList !== 0, 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 };
|
|
}
|
|
|
|
/**
|
|
* Parse texture info block
|
|
* @param {boolean} isModel - If true, read skipTextures field (models have it, parts don't)
|
|
*/
|
|
parseTextureInfo(isModel = false) {
|
|
const numTextures = this.reader.readU32();
|
|
|
|
// Models have an extra skipTextures field that parts don't have
|
|
// See legomodelpresenter.cpp vs legopartpresenter.cpp in LEGO1 source
|
|
if (isModel) {
|
|
this.reader.readU32(); // skipTextures - skip over this field
|
|
}
|
|
|
|
const textures = [];
|
|
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Resolve LODs for an ROI, handling shared LOD lists
|
|
* This mirrors how the game's ViewLODListManager resolves shared parts
|
|
* @param {object} roi - ROI data with lods and sharedLodList flag
|
|
* @param {Map} partsMap - Map of part name (lowercase) -> part data with lods
|
|
* @returns {Array} - Array of LODs (may be empty)
|
|
*/
|
|
export function resolveLods(roi, partsMap) {
|
|
// If ROI has its own LODs, use them
|
|
if (roi.lods && roi.lods.length > 0) {
|
|
return roi.lods;
|
|
}
|
|
|
|
// If ROI uses shared LOD list, look up by name (strip trailing digits)
|
|
// This matches the game's logic in LegoROI::Read
|
|
if (roi.sharedLodList && roi.name && partsMap) {
|
|
const baseName = roi.name.replace(/\d+$/, '').toLowerCase();
|
|
const part = partsMap.get(baseName);
|
|
if (part && part.lods && part.lods.length > 0) {
|
|
return part.lods;
|
|
}
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* Build a parts lookup map from a world's parts array
|
|
* @param {WdbParser} parser - Parser instance for reading part data
|
|
* @param {Array} worldParts - Array of part references from world entry
|
|
* @returns {Map} - Map of part name (lowercase) -> part data
|
|
*/
|
|
export function buildPartsMap(parser, worldParts) {
|
|
const partsMap = new Map();
|
|
if (!worldParts || worldParts.length === 0) return partsMap;
|
|
|
|
for (const partRef of worldParts) {
|
|
const partData = parser.parsePartData(partRef.dataOffset);
|
|
for (const part of partData.parts) {
|
|
partsMap.set(part.name.toLowerCase(), part);
|
|
}
|
|
}
|
|
return partsMap;
|
|
}
|