Vehicle textures (#21)

* Add vehicle texture editing

Parse and serialize Act1State textures in save files. Add a texture
picker modal with default presets (from .tex files) and custom uploads
persisted to IndexedDB per texture name. Quantize uploaded images
against the WDB palette and render texture changes in the 3D preview.
Support resetting textures to the WDB default.

* Fix vehicle texture not updating when switching save slots

Include slot number in the part key so that switching save slots
triggers a full part reload with the new slot's textures.

* Preload default textures for instant texture picker opening

Fetch and parse .tex files in the background when a textured part
loads, and pass the results to TexturePickerModal as a prop. The
modal no longer fetches on mount, eliminating the loading delay.

* Cleanup: parallel fetching, error recovery, dead code removal

- Fetch .tex files in parallel with Promise.all instead of sequentially
- Clear cached IndexedDB promise on rejection so subsequent calls retry
- Remove unused textureOrder array from Act1State parser
- Unify selectDefault/applyCustom into single applyTexture function
- Remove redundant squareTexture call on already-squared wdbTexture

* Add vehicle texture editor to February 2026 changelog

* Fix mouseenter error on non-Element targets
This commit is contained in:
Christian Semmler 2026-02-07 14:34:57 -08:00 committed by GitHub
parent 2c12d5ad5e
commit 39598aa3b9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
52 changed files with 1141 additions and 58 deletions

BIN
public/CHJETL1.tex Normal file

Binary file not shown.

BIN
public/CHJETL2.tex Normal file

Binary file not shown.

BIN
public/CHJETL3.tex Normal file

Binary file not shown.

BIN
public/CHJETL4.tex Normal file

Binary file not shown.

BIN
public/CHJETR1.tex Normal file

Binary file not shown.

BIN
public/CHJETR2.tex Normal file

Binary file not shown.

BIN
public/CHJETR3.tex Normal file

Binary file not shown.

BIN
public/CHJETR4.tex Normal file

Binary file not shown.

BIN
public/CHWIND1.tex Normal file

Binary file not shown.

BIN
public/CHWIND2.tex Normal file

Binary file not shown.

BIN
public/CHWIND3.tex Normal file

Binary file not shown.

BIN
public/CHWIND4.tex Normal file

Binary file not shown.

BIN
public/Dbfrfn1.tex Normal file

Binary file not shown.

BIN
public/Dbfrfn2.tex Normal file

Binary file not shown.

BIN
public/Dbfrfn3.tex Normal file

Binary file not shown.

BIN
public/Dbfrfn4.tex Normal file

Binary file not shown.

BIN
public/JSWNSH1.tex Normal file

Binary file not shown.

BIN
public/JSWNSH2.tex Normal file

Binary file not shown.

BIN
public/JSWNSH3.tex Normal file

Binary file not shown.

BIN
public/JSWNSH4.tex Normal file

Binary file not shown.

BIN
public/jsfrnt1.tex Normal file

Binary file not shown.

BIN
public/jsfrnt2.tex Normal file

Binary file not shown.

BIN
public/jsfrnt3.tex Normal file

Binary file not shown.

BIN
public/jsfrnt4.tex Normal file

Binary file not shown.

BIN
public/rcback1.tex Normal file

Binary file not shown.

BIN
public/rcback2.tex Normal file

Binary file not shown.

BIN
public/rcback3.tex Normal file

Binary file not shown.

BIN
public/rcback4.tex Normal file

Binary file not shown.

BIN
public/rcfrnt1.tex Normal file

Binary file not shown.

BIN
public/rcfrnt2.tex Normal file

Binary file not shown.

BIN
public/rcfrnt3.tex Normal file

Binary file not shown.

BIN
public/rcfrnt4.tex Normal file

Binary file not shown.

BIN
public/rctail1.tex Normal file

Binary file not shown.

BIN
public/rctail2.tex Normal file

Binary file not shown.

BIN
public/rctail3.tex Normal file

Binary file not shown.

BIN
public/rctail4.tex Normal file

Binary file not shown.

View File

@ -69,7 +69,7 @@
// Desktop: position on hover
if (!isTouchDevice) {
document.addEventListener('mouseenter', (e) => {
const trigger = e.target.closest('.tooltip-trigger');
const trigger = e.target.closest?.('.tooltip-trigger');
if (trigger) positionTooltip(trigger);
}, true);
}

View File

@ -280,7 +280,7 @@ export class SaveGameParser {
}
if (name === 'Act1State') {
return this.skipAct1State();
return this.parseAct1State();
}
// Unknown state - this shouldn't happen with valid save files
@ -305,71 +305,86 @@ export class SaveGameParser {
}
/**
* Skip Act1State (variable length with conditional textures)
* @returns {number} - Number of bytes skipped
* Parse Act1State (variable length with conditional textures)
* @returns {number} - Number of bytes consumed
*/
skipAct1State() {
parseAct1State() {
const startOffset = this.reader.tell();
// Read 7 named planes
const planeNameLengths = [];
const planes = [];
for (let i = 0; i < 7; i++) {
const nameLength = this.reader.readS16();
planeNameLengths.push(nameLength);
let name = '';
if (nameLength > 0) {
this.reader.skip(nameLength); // name
name = this.reader.readString(nameLength);
}
this.reader.skip(36); // position(12) + direction(12) + up(12)
planes.push({ name, nameLength });
}
// Conditional textures based on which planes have names
const helicopterHasName = planeNameLengths[3] > 0;
const jetskiHasName = planeNameLengths[4] > 0;
const dunebuggyHasName = planeNameLengths[5] > 0;
const racecarHasName = planeNameLengths[6] > 0;
const textures = new Map();
if (helicopterHasName) {
for (let i = 0; i < 3; i++) {
this.skipAct1Texture();
}
if (planes[3].nameLength > 0) {
for (let i = 0; i < 3; i++) this.readAct1Texture(textures);
}
if (jetskiHasName) {
for (let i = 0; i < 2; i++) {
this.skipAct1Texture();
}
if (planes[4].nameLength > 0) {
for (let i = 0; i < 2; i++) this.readAct1Texture(textures);
}
if (dunebuggyHasName) {
this.skipAct1Texture();
if (planes[5].nameLength > 0) {
this.readAct1Texture(textures);
}
if (racecarHasName) {
for (let i = 0; i < 3; i++) {
this.skipAct1Texture();
}
if (planes[6].nameLength > 0) {
for (let i = 0; i < 3; i++) this.readAct1Texture(textures);
}
// Final fields
this.reader.skip(2); // cpt_click_dialogue_next_index (S16)
this.reader.skip(1); // played_exit_explanation (U8)
const cptClickDialogueNextIndex = this.reader.readS16();
const playedExitExplanation = this.reader.readU8();
this.parsed.act1State = {
planes,
textures,
startOffset,
endOffset: this.reader.tell(),
cptClickDialogueNextIndex,
playedExitExplanation
};
return this.reader.tell() - startOffset;
}
/**
* Skip a single Act1 texture
* Read a single Act1 texture into the textures map
* @param {Map} textures - Map to store texture data
* @returns {string} - Texture name
*/
skipAct1Texture() {
readAct1Texture(textures) {
const nameLength = this.reader.readS16();
let name = '';
if (nameLength > 0) {
this.reader.skip(nameLength); // name
name = this.reader.readString(nameLength);
}
const width = this.reader.readU32();
const height = this.reader.readU32();
const paletteCount = this.reader.readU32();
this.reader.skip(paletteCount * 3); // palette (RGB triplets)
this.reader.skip(width * height); // bitmap data
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));
const nameLower = name.toLowerCase();
textures.set(nameLower, { name, width, height, paletteSize, palette, pixels });
return nameLower;
}
/**

View File

@ -3,7 +3,8 @@
* Uses a "patch in place" approach - copies the original buffer and modifies specific bytes
*/
import { SaveGameParser } from './SaveGameParser.js';
import { GameStateTypes, GameStateSizes, Actor } from '../savegame/constants.js';
import { BinaryWriter } from './BinaryWriter.js';
import { GameStateTypes, GameStateSizes, Actor, Act1TextureOrder } from '../savegame/constants.js';
/**
* Offsets for header fields
@ -337,6 +338,129 @@ export class SaveGameSerializer {
}
}
/**
* Update an Act1State texture in the save file
* @param {string} textureName - Texture name (e.g. 'chwind.gif')
* @param {{ palette: Array<{r,g,b}>, pixels: Uint8Array, width: number, height: number }} newTextureData
* @param {ArrayBuffer} [buffer] - Optional buffer to use
* @returns {ArrayBuffer|null} - Modified buffer or null on error
*/
updateAct1Texture(textureName, newTextureData, buffer = null) {
const workingBuffer = buffer || this.createCopy();
// Re-parse to get fresh Act1State from the working buffer
const freshParser = new SaveGameParser(workingBuffer);
const freshParsed = freshParser.parse();
const act1State = freshParsed.act1State;
if (!act1State) {
console.error('Act1State not found in save file');
return null;
}
const act1Location = freshParsed.stateLocations.find(loc => loc.name === 'Act1State');
if (!act1Location) {
console.error('Act1State location not found');
return null;
}
const targetKey = textureName.toLowerCase();
if (!act1State.textures.has(targetKey)) {
console.error(`Texture not found in Act1State: ${textureName}`);
return null;
}
// Replace texture data, preserving original name
const oldTex = act1State.textures.get(targetKey);
act1State.textures.set(targetKey, {
name: oldTex.name,
width: newTextureData.width,
height: newTextureData.height,
paletteSize: newTextureData.palette.length,
palette: newTextureData.palette,
pixels: newTextureData.pixels
});
return this._rebuildAct1State(workingBuffer, act1Location, act1State);
}
/**
* Rebuild the full buffer with updated Act1State
* @private
*/
_rebuildAct1State(sourceBuffer, act1Location, act1State) {
const writer = new BinaryWriter(sourceBuffer.byteLength + 4096);
const srcArray = new Uint8Array(sourceBuffer);
// Write 7 planes
let readOffset = act1Location.dataOffset;
for (const plane of act1State.planes) {
writer.writeS16(plane.nameLength);
readOffset += 2;
if (plane.nameLength > 0) {
writer.writeString(plane.name);
readOffset += plane.nameLength;
}
// Copy 36 bytes of position/direction/up from source
writer.writeBytes(srcArray.slice(readOffset, readOffset + 36));
readOffset += 36;
}
// Write conditional textures in correct order
const vehicleOrder = ['helicopter', 'jetski', 'dunebuggy', 'racecar'];
const planeIndices = [3, 4, 5, 6];
for (let v = 0; v < vehicleOrder.length; v++) {
const vehicleName = vehicleOrder[v];
const planeIdx = planeIndices[v];
if (act1State.planes[planeIdx].nameLength <= 0) continue;
const textureNames = Act1TextureOrder[vehicleName];
for (const texName of textureNames) {
const texKey = texName.toLowerCase();
const tex = act1State.textures.get(texKey);
if (!tex) continue;
writer.writeS16(tex.name.length);
writer.writeString(tex.name);
writer.writeU32(tex.width);
writer.writeU32(tex.height);
writer.writeU32(tex.paletteSize);
for (const color of tex.palette) {
writer.writeU8(color.r);
writer.writeU8(color.g);
writer.writeU8(color.b);
}
writer.writeBytes(tex.pixels);
}
}
// Write final fields
writer.writeS16(act1State.cptClickDialogueNextIndex);
writer.writeU8(act1State.playedExitExplanation);
const newAct1Data = writer.toUint8Array();
const oldAct1Size = act1Location.dataSize;
const newAct1Size = newAct1Data.length;
const sizeDiff = newAct1Size - oldAct1Size;
// Build final buffer
const newBuffer = new ArrayBuffer(sourceBuffer.byteLength + sizeDiff);
const newArray = new Uint8Array(newBuffer);
// Copy everything before Act1State data
newArray.set(srcArray.slice(0, act1Location.dataOffset));
// Write new Act1State data
newArray.set(newAct1Data, act1Location.dataOffset);
// Copy everything after old Act1State data
const afterOld = act1Location.dataOffset + oldAct1Size;
newArray.set(srcArray.slice(afterOld), act1Location.dataOffset + newAct1Size);
return newBuffer;
}
/**
* Get the byte offset for a mission score
* @param {string} missionType

View File

@ -0,0 +1,44 @@
/**
* Parser for .tex texture files
* Format: U32 num_textures, then per texture:
* U32 name_buffer_length + name (null-terminated within buffer)
* + U32 width + U32 height + U32 palette_size
* + RGB[palette_size] + pixels[width*height]
*/
import { BinaryReader } from './BinaryReader.js';
/**
* Parse a .tex file buffer
* @param {ArrayBuffer} buffer - Raw .tex file contents
* @returns {{ textures: Array<{ name: string, width: number, height: number, paletteSize: number, palette: Array<{r,g,b}>, pixels: Uint8Array }> }}
*/
export function parseTex(buffer) {
const reader = new BinaryReader(buffer);
const numTextures = reader.readU32();
const textures = [];
for (let i = 0; i < numTextures; i++) {
const nameBufferLength = reader.readU32();
const nameRaw = reader.readString(nameBufferLength);
const name = nameRaw.split('\0')[0].toLowerCase();
const width = reader.readU32();
const height = reader.readU32();
const paletteSize = reader.readU32();
const palette = [];
for (let j = 0; j < paletteSize; j++) {
palette.push({
r: reader.readU8(),
g: reader.readU8(),
b: reader.readU8()
});
}
const pixels = new Uint8Array(reader.slice(width * height));
textures.push({ name, width, height, paletteSize, palette, pixels });
}
return { textures };
}

View File

@ -310,8 +310,10 @@ export class WdbParser {
}
// Packed vertex/normal counts
// Game source (legolod.cpp): numVerts = lower16 & MAXSHORT (bits 0-14), bit 15 is a flag
// numNormals = (upper16 >> 1) & MAXSHORT (bits 17-31)
const vertexNormalCounts = this.reader.readU32();
const vertexCount = vertexNormalCounts & 0xFFFF;
const vertexCount = vertexNormalCounts & 0x7FFF;
const normalCount = (vertexNormalCounts >> 17) & 0x7FFF;
const numTextureVertices = this.reader.readS32();

View File

@ -16,3 +16,6 @@ export { SaveGameSerializer, createSerializer } from './SaveGameSerializer.js';
// Players format
export { PlayersParser, parsePlayers } from './PlayersParser.js';
export { PlayersSerializer, createPlayersSerializer } from './PlayersSerializer.js';
// Texture format
export { parseTex } from './TexParser.js';

View File

@ -73,6 +73,8 @@ export class VehiclePartRenderer {
const texture = new THREE.CanvasTexture(canvas);
texture.minFilter = THREE.NearestFilter;
texture.magFilter = THREE.NearestFilter;
texture.wrapS = THREE.RepeatWrapping;
texture.wrapT = THREE.RepeatWrapping;
return texture;
}
@ -167,6 +169,9 @@ export class VehiclePartRenderer {
}
const threeMesh = new THREE.Mesh(geometry, material);
if (meshTextureName) {
threeMesh.userData.textureName = meshTextureName;
}
this.modelGroup.add(threeMesh);
// Track colorable meshes
@ -252,6 +257,32 @@ export class VehiclePartRenderer {
return geometry;
}
/**
* Update texture on meshes matching a given texture name
* @param {string} textureName - Texture name to match (case-insensitive)
* @param {{ width: number, height: number, palette: Array<{r,g,b}>, pixels: Uint8Array }} textureData
*/
updateTexture(textureName, textureData) {
if (!this.modelGroup) return;
const newTexture = this.createTexture(textureData);
const targetName = textureName.toLowerCase();
this.modelGroup.traverse((child) => {
if (!(child instanceof THREE.Mesh)) return;
if (child.userData.textureName !== targetName) return;
const oldMap = child.material.map;
child.material.map = newTexture;
// Set color to white so texture isn't tinted by the fallback color
child.material.color.setRGB(1, 1, 1);
child.material.needsUpdate = true;
if (oldMap && oldMap !== newTexture) oldMap.dispose();
});
this.renderer.render(this.scene, this.camera);
}
/**
* Update color of colorable meshes without reloading geometry
*/

View File

@ -227,6 +227,36 @@ export const VehicleNames = Object.freeze({
racecar: 'Race Car'
});
// Act1State plane indices for each vehicle type (indices into the 7-plane array)
export const Act1PlaneIndices = Object.freeze({
helicopter: 3,
jetski: 4,
dunebuggy: 5,
racecar: 6
});
// Parts that have UV-mapped texture regions in Act1State
// textureName: name stored in Act1State, texFiles: base names of 4 default .tex files
export const TexturedParts = Object.freeze({
chwindy1: { textureName: 'chwind.gif', texFiles: ['CHWIND1', 'CHWIND2', 'CHWIND3', 'CHWIND4'], vehicle: 'helicopter' },
chljety1: { textureName: 'chjetl.gif', texFiles: ['CHJETL1', 'CHJETL2', 'CHJETL3', 'CHJETL4'], vehicle: 'helicopter' },
chrjety1: { textureName: 'chjetr.gif', texFiles: ['CHJETR1', 'CHJETR2', 'CHJETR3', 'CHJETR4'], vehicle: 'helicopter' },
jsfrnty5: { textureName: 'jsfrnt.gif', texFiles: ['jsfrnt1', 'jsfrnt2', 'jsfrnt3', 'jsfrnt4'], vehicle: 'jetski' },
jswnshy5: { textureName: 'jswnsh.gif', texFiles: ['JSWNSH1', 'JSWNSH2', 'JSWNSH3', 'JSWNSH4'], vehicle: 'jetski' },
dbfrfny4: { textureName: 'dbfrfn.gif', texFiles: ['Dbfrfn1', 'Dbfrfn2', 'Dbfrfn3', 'Dbfrfn4'], vehicle: 'dunebuggy' },
rcfrnty6: { textureName: 'rcfrnt.gif', texFiles: ['rcfrnt1', 'rcfrnt2', 'rcfrnt3', 'rcfrnt4'], vehicle: 'racecar' },
rcbacky6: { textureName: 'rcback.gif', texFiles: ['rcback1', 'rcback2', 'rcback3', 'rcback4'], vehicle: 'racecar' },
rctailya: { textureName: 'rctail.gif', texFiles: ['rctail1', 'rctail2', 'rctail3', 'rctail4'], vehicle: 'racecar' }
});
// Texture write order per vehicle in Act1State (from isle.cpp Act1State::Serialize)
export const Act1TextureOrder = Object.freeze({
helicopter: ['chwind.gif', 'chjetl.gif', 'chjetr.gif'],
jetski: ['jsfrnt.gif', 'jswnsh.gif'],
dunebuggy: ['dbfrfn.gif'],
racecar: ['rcfrnt.gif', 'rcback.gif', 'rctail.gif']
});
// Vehicle part color definitions - 43 parts total (from legogamestate.cpp)
export const VehiclePartColors = Object.freeze({
dunebuggy: [

View File

@ -0,0 +1,106 @@
/**
* Image quantization utility for converting uploaded images to palette-indexed format
*/
/**
* Quantize an image to a palette-indexed format suitable for the game.
* The WDB palette is padded to 256 entries with black matching how
* LegoTextureInfo::Create() builds the DirectDraw surface palette:
* indices 0..paletteSize-1 get the WDB colors, the rest are {0,0,0}.
*
* @param {HTMLImageElement} img - Source image
* @param {number} targetWidth - Target width in pixels
* @param {number} targetHeight - Target height in pixels
* @param {Array<{r:number,g:number,b:number}>} basePalette - WDB palette to quantize against
* @returns {{ palette: Array<{r:number,g:number,b:number}>, pixels: Uint8Array }}
*/
export function quantizeImage(img, targetWidth, targetHeight, basePalette) {
// Pad to 256 entries with black, mirroring the game's surface palette
const palette = basePalette.slice();
while (palette.length < 256) {
palette.push({ r: 0, g: 0, b: 0 });
}
// Resize to target dimensions
const canvas = document.createElement('canvas');
canvas.width = targetWidth;
canvas.height = targetHeight;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, targetWidth, targetHeight);
const imageData = ctx.getImageData(0, 0, targetWidth, targetHeight);
const rgba = imageData.data;
const pixelCount = targetWidth * targetHeight;
// Build lookup cache for fast nearest-color matching
const paletteLookup = new Map();
// Map each pixel to nearest palette entry
const pixels = new Uint8Array(pixelCount);
for (let i = 0; i < pixelCount; i++) {
const r = rgba[i * 4];
const g = rgba[i * 4 + 1];
const b = rgba[i * 4 + 2];
const key = (r << 16) | (g << 8) | b;
if (paletteLookup.has(key)) {
pixels[i] = paletteLookup.get(key);
} else {
// Find nearest color in palette
let bestIdx = 0;
let bestDist = Infinity;
for (let j = 0; j < palette.length; j++) {
const dr = r - palette[j].r;
const dg = g - palette[j].g;
const db = b - palette[j].b;
const dist = dr * dr + dg * dg + db * db;
if (dist < bestDist) {
bestDist = dist;
bestIdx = j;
}
}
pixels[i] = bestIdx;
paletteLookup.set(key, bestIdx);
}
}
return { palette, pixels };
}
/**
* Square a palette-indexed texture by duplicating rows or columns.
* Mirrors the game's LegoImage::Read(p_square=TRUE) behavior.
* @param {{ width: number, height: number, palette: Array<{r,g,b}>, pixels: Uint8Array, paletteSize?: number }} tex
* @returns {{ width: number, height: number, palette: Array<{r,g,b}>, pixels: Uint8Array, paletteSize: number }}
*/
export function squareTexture(tex) {
const { width, height, palette, pixels } = tex;
if (width === height) return tex;
const size = Math.max(width, height);
const squared = new Uint8Array(size * size);
if (width > height) {
const factor = width / height;
for (let x = 0; x < width; x++) {
for (let y = 0; y < height; y++) {
const src = pixels[y * width + x];
for (let k = 0; k < factor; k++) {
squared[(y * factor + k) * size + x] = src;
}
}
}
} else {
const factor = height / width;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const src = pixels[y * width + x];
for (let k = 0; k < factor; k++) {
squared[y * size + (x * factor + k)] = src;
}
}
}
}
return { width: size, height: size, palette, pixels: squared, paletteSize: palette.length };
}

View File

@ -90,6 +90,7 @@ export async function listSaveSlots() {
header: null,
missions: null,
variables: null,
act1State: null,
playerName: null,
buffer: null
};
@ -102,6 +103,7 @@ export async function listSaveSlots() {
slot.header = parsed.header;
slot.missions = parsed.missions;
slot.variables = parsed.variables;
slot.act1State = parsed.act1State || null;
slot.buffer = buffer;
// Try to get player name
@ -165,6 +167,7 @@ export async function loadSaveSlot(slotNumber) {
header: parsed.header,
missions: parsed.missions,
variables: parsed.variables,
act1State: parsed.act1State || null,
playerName,
buffer
};
@ -235,6 +238,17 @@ export async function updateSaveSlot(slotNumber, updates) {
}
}
// Apply texture update
if (updates.texture) {
const { textureName, textureData } = updates.texture;
const texSerializer = createSerializer(newBuffer);
const result = texSerializer.updateAct1Texture(textureName, textureData);
if (result) {
newBuffer = result;
modified = true;
}
}
// Only save if something was actually modified
if (!modified) {
return slot;

View File

@ -0,0 +1,94 @@
const DB_NAME = 'isle-pizza-textures';
const DB_VERSION = 1;
const STORE_NAME = 'custom-textures';
let dbPromise = null;
function openDB() {
if (dbPromise) return dbPromise;
dbPromise = new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = () => {
const db = request.result;
if (db.objectStoreNames.contains(STORE_NAME)) {
db.deleteObjectStore(STORE_NAME);
}
const store = db.createObjectStore(STORE_NAME, { keyPath: 'id' });
store.createIndex('textureName', 'textureName', { unique: false });
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
}).catch((err) => {
dbPromise = null;
throw err;
});
return dbPromise;
}
/**
* Save a processed (quantized + squared) custom texture to IndexedDB.
* @param {{ width: number, height: number, paletteSize: number, palette: Array<{r:number,g:number,b:number}>, pixels: Uint8Array }} textureData
* @param {string} textureName - The game texture this was quantized for (e.g. 'rcfrnt.gif')
* @returns {Promise<string>} The generated id
*/
export async function saveCustomTexture(textureData, textureName) {
const db = await openDB();
const id = crypto.randomUUID();
const record = {
id,
timestamp: Date.now(),
textureName,
width: textureData.width,
height: textureData.height,
paletteSize: textureData.paletteSize,
palette: textureData.palette,
pixels: textureData.pixels
};
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
tx.objectStore(STORE_NAME).put(record);
tx.oncomplete = () => resolve(id);
tx.onerror = () => reject(tx.error);
});
}
/**
* List custom textures for a specific game texture, sorted by timestamp descending.
* @param {string} textureName - Filter by game texture name (e.g. 'rcfrnt.gif')
* @returns {Promise<Array>}
*/
export async function listCustomTextures(textureName) {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readonly');
const request = tx.objectStore(STORE_NAME).index('textureName').getAll(textureName);
request.onsuccess = () => {
const results = request.result;
results.sort((a, b) => b.timestamp - a.timestamp);
resolve(results);
};
request.onerror = () => reject(request.error);
});
}
/**
* Delete a custom texture by id.
* @param {string} id
* @returns {Promise<void>}
*/
export async function deleteCustomTexture(id) {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
tx.objectStore(STORE_NAME).delete(id);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}

View File

@ -36,7 +36,8 @@
{ id: 'cl0', title: 'February 2026', items: [
{ type: 'New', text: 'Save Editor lets you view and modify save files — change your player name, character, and high scores directly from the browser' },
{ type: 'New', text: 'Sky Color Editor allows customizing the island sky gradient colors in your save file' },
{ type: 'New', text: 'Vehicle Part Editor enables modifying vehicle parts and colors with a 3D preview' }
{ type: 'New', text: 'Vehicle Part Editor enables modifying vehicle parts and colors with a 3D preview' },
{ type: 'New', text: 'Vehicle Texture Editor lets you customize vehicle textures with default presets or your own uploaded images' }
]},
{ id: 'cl1', title: 'January 2026', items: [
{ type: 'New', text: 'Debug menu for developers and power users. Tap the LEGO Island logo 5 times to unlock OGEL mode and access debug features like teleporting to locations, switching acts, and playing animations' },

View File

@ -120,7 +120,7 @@
if (updated) {
slots = slots.map(s =>
s.slotNumber === selectedSlot
? { ...s, variables: updated.variables }
? { ...s, variables: updated.variables, act1State: updated.act1State }
: s
);
}

View File

@ -0,0 +1,419 @@
<script>
import { onMount } from 'svelte';
import { quantizeImage, squareTexture } from '../../core/savegame/imageQuantizer.js';
import { saveCustomTexture, listCustomTextures, deleteCustomTexture } from '../../core/savegame/textureStorage.js';
import Carousel from '../Carousel.svelte';
export let textureInfo;
export let palette = null;
export let defaults = null;
export let onSelect = () => {};
export let onClose = () => {};
let defaultTextures = [];
let customTextures = [];
let fileInput;
let activeTab = 'default';
let selectedCustomId = null;
// Dimensions from the first loaded default texture
let targetWidth = 128;
let targetHeight = 128;
onMount(async () => {
initDefaults();
await loadCustomTextures();
});
function initDefaults() {
const source = defaults || [];
defaultTextures = source.map(tex => ({
...tex,
dataUrl: textureToDataUrl(tex)
}));
if (defaultTextures.length > 0) {
targetWidth = defaultTextures[0].width;
targetHeight = defaultTextures[0].height;
}
}
async function loadCustomTextures() {
try {
const stored = await listCustomTextures(textureInfo.textureName);
customTextures = stored.map(tex => ({
...tex,
dataUrl: textureToDataUrl(tex)
}));
} catch (e) {
console.error('Failed to load custom textures:', e);
customTextures = [];
}
}
function textureToDataUrl(tex) {
const canvas = document.createElement('canvas');
canvas.width = tex.width;
canvas.height = tex.height;
const ctx = canvas.getContext('2d');
const imageData = ctx.createImageData(tex.width, tex.height);
for (let i = 0; i < tex.pixels.length; i++) {
const colorIdx = tex.pixels[i];
const color = tex.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);
return canvas.toDataURL();
}
function applyTexture(tex) {
onSelect({
width: tex.width,
height: tex.height,
palette: tex.palette,
pixels: tex.pixels,
paletteSize: tex.paletteSize || tex.palette.length
});
}
function selectCustom(tex) {
selectedCustomId = tex.id;
}
function applyCustom() {
const tex = customTextures.find(t => t.id === selectedCustomId);
if (tex) applyTexture(tex);
}
function handleUploadClick() {
fileInput?.click();
}
function handleFileChange(e) {
const file = e.target.files?.[0];
if (!file) return;
// Use the WDB palette (passed as prop) — this is the palette the game's
// DirectDraw surface actually uses. Fall back to first default's palette.
const targetPalette = palette || defaultTextures[0]?.palette;
if (!targetPalette) return;
const img = new Image();
img.onload = async () => {
const result = quantizeImage(img, targetWidth, targetHeight, targetPalette);
const textureData = squareTexture({
width: targetWidth,
height: targetHeight,
palette: result.palette,
pixels: result.pixels,
paletteSize: result.palette.length
});
// Persist to IndexedDB and refresh the carousel
try {
const id = await saveCustomTexture(textureData, textureInfo.textureName);
await loadCustomTextures();
selectedCustomId = id;
} catch (e) {
console.error('Failed to save custom texture:', e);
}
URL.revokeObjectURL(img.src);
};
img.src = URL.createObjectURL(file);
// Reset file input so re-uploading the same file triggers change
e.target.value = '';
}
async function handleDelete() {
if (!selectedCustomId) return;
try {
await deleteCustomTexture(selectedCustomId);
selectedCustomId = null;
await loadCustomTextures();
} catch (e) {
console.error('Failed to delete custom texture:', e);
}
}
function handleBackdropClick(e) {
if (e.target === e.currentTarget) {
onClose();
}
}
function handleKeydown(e) {
if (e.key === 'Escape') {
onClose();
}
}
</script>
<svelte:window on:keydown={handleKeydown} />
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div class="modal-backdrop" onclick={handleBackdropClick}>
<div class="modal-panel">
<div class="modal-header">
<span class="modal-title">Select Texture</span>
<button type="button" class="modal-close" onclick={onClose}>&times;</button>
</div>
<div class="modal-tabs">
<button
type="button"
class="config-tab-btn"
class:active={activeTab === 'default'}
onclick={() => activeTab = 'default'}
>Default</button>
<button
type="button"
class="config-tab-btn"
class:active={activeTab === 'custom'}
onclick={() => activeTab = 'custom'}
>Custom</button>
</div>
<div class="modal-body">
{#if activeTab === 'default'}
<div class="texture-grid">
{#each defaultTextures as tex}
<button
type="button"
class="texture-thumb"
onclick={() => applyTexture(tex)}
title={tex.name}
>
<img src={tex.dataUrl} alt={tex.name} />
</button>
{/each}
</div>
{:else}
{#if customTextures.length > 0}
<div class="custom-carousel">
<Carousel gap={8}>
{#each customTextures as tex}
<button
type="button"
class="texture-thumb carousel-item"
class:selected={selectedCustomId === tex.id}
onclick={() => selectCustom(tex)}
>
<img src={tex.dataUrl} alt="Custom texture" />
</button>
{/each}
</Carousel>
</div>
{:else}
<div class="empty-state">No custom textures yet</div>
{/if}
<input
type="file"
accept="image/*"
class="hidden-input"
bind:this={fileInput}
onchange={handleFileChange}
/>
<button type="button" class="upload-btn" onclick={handleUploadClick}>
Upload Image
</button>
{#if selectedCustomId}
<div class="custom-actions">
<button type="button" class="apply-btn" onclick={applyCustom}>
Apply
</button>
<button type="button" class="delete-btn" onclick={handleDelete}>
Delete
</button>
</div>
{/if}
{/if}
</div>
</div>
</div>
<style>
.modal-backdrop {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-panel {
background: var(--color-bg-panel);
border: 1px solid var(--color-border-medium);
border-radius: 8px;
max-width: 320px;
width: 90%;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
border-bottom: 1px solid var(--color-border-dark);
}
.modal-title {
color: var(--color-text-light);
font-size: 0.9em;
font-weight: bold;
}
.modal-close {
background: none;
border: none;
color: var(--color-text-muted);
font-size: 1.4em;
cursor: pointer;
padding: 0;
line-height: 1;
}
.modal-close:hover {
color: var(--color-text-light);
}
.modal-tabs {
display: flex;
gap: 5px;
border-bottom: 1px solid var(--color-border-dark);
padding: 0 14px;
}
.modal-body {
padding: 14px;
}
.texture-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
margin-bottom: 12px;
}
.texture-thumb {
background: var(--color-bg-input);
border: 2px solid var(--color-border-medium);
border-radius: 6px;
padding: 4px;
cursor: pointer;
transition: border-color 0.2s ease;
}
.texture-thumb:hover {
border-color: var(--color-primary);
}
.texture-thumb.selected {
border-color: var(--color-primary);
box-shadow: 0 0 6px var(--color-primary-glow);
}
.texture-thumb img {
display: block;
width: 100%;
height: auto;
image-rendering: pixelated;
}
.texture-thumb.carousel-item {
flex: 0 0 80px;
}
.texture-thumb.carousel-item img {
width: 80px;
height: 80px;
}
.custom-carousel {
margin-bottom: 12px;
}
.empty-state {
color: var(--color-text-muted);
font-size: 0.85em;
text-align: center;
padding: 20px 0;
}
.hidden-input {
display: none;
}
.upload-btn {
display: block;
width: 100%;
padding: 8px 0;
font-size: 0.85em;
font-family: inherit;
color: var(--color-text-light);
background: var(--gradient-panel);
border: 1px solid var(--color-border-medium);
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
}
.upload-btn:hover {
background: var(--gradient-hover);
border-color: var(--color-border-light);
}
.custom-actions {
display: flex;
gap: 8px;
margin-top: 8px;
}
.apply-btn {
flex: 1;
padding: 8px 0;
font-size: 0.85em;
font-family: inherit;
color: var(--color-text-light);
background: var(--gradient-panel);
border: 1px solid var(--color-primary);
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
}
.apply-btn:hover {
background: var(--gradient-hover);
color: var(--color-primary);
}
.delete-btn {
flex: 1;
padding: 8px 0;
font-size: 0.85em;
font-family: inherit;
color: var(--color-error, #e74c3c);
background: var(--gradient-panel);
border: 1px solid var(--color-border-medium);
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
}
.delete-btn:hover {
border-color: var(--color-error, #e74c3c);
}
</style>

View File

@ -7,11 +7,16 @@
VehicleWorlds,
VehicleModels,
VehicleNames,
VehiclePartColors
VehiclePartColors,
TexturedParts,
Act1PlaneIndices
} from '../../core/savegame/constants.js';
import { squareTexture } from '../../core/savegame/imageQuantizer.js';
import { parseTex } from '../../core/formats/TexParser.js';
import NavButton from '../NavButton.svelte';
import ResetButton from '../ResetButton.svelte';
import EditorTooltip from '../EditorTooltip.svelte';
import TexturePickerModal from './TexturePickerModal.svelte';
export let slot;
export let onUpdate = () => {};
@ -37,6 +42,12 @@
// Track current loaded part to avoid redundant reloads
let loadedPartKey = null;
// Texture modal state
let showTextureModal = false;
let texturePalette = null;
let wdbTexture = null;
let preloadedDefaults = null;
// Current part info from flat list
$: currentEntry = allParts[globalIndex];
$: vehicle = currentEntry?.vehicle || 'dunebuggy';
@ -48,7 +59,20 @@
: 'lego red';
// Check if current color differs from default
$: isDefault = currentPart && currentColorValue === currentPart.defaultColor;
$: isDefaultColor = currentPart && currentColorValue === currentPart.defaultColor;
// Texture info for current part (if it's a textured part)
$: textureInfo = currentPart ? TexturedParts[currentPart.part] || null : null;
// Check if vehicle has a plane in Act1State (vehicle is placed in world)
$: vehicleHasPlane = (() => {
if (!textureInfo || !slot?.act1State) return false;
const planeIdx = Act1PlaneIndices[textureInfo.vehicle];
return planeIdx !== undefined && slot.act1State.planes[planeIdx]?.nameLength > 0;
})();
// Can edit texture: part has texture info AND vehicle plane exists in Act1State
$: canEditTexture = textureInfo && vehicleHasPlane;
onMount(async () => {
try {
@ -81,14 +105,30 @@
renderer?.dispose();
});
// Reload part when index changes
// Reload part when index or slot changes
$: if (renderer && !loading && currentPart) {
const partKey = `${vehicle}-${globalIndex}`;
const partKey = `${slot?.slotNumber}-${vehicle}-${globalIndex}`;
if (partKey !== loadedPartKey) {
loadCurrentPart();
}
}
// Check if current texture matches the WDB default.
// Declared after the loadCurrentPart block: Svelte 5 runs legacy $: effects
// in source order, and wdbTexture must be set before this evaluates.
function isTextureDefault(info, wdbTex, act1Textures) {
if (!info || !wdbTex || !act1Textures) return true;
const act1Tex = act1Textures.get(info.textureName.toLowerCase());
if (!act1Tex) return true;
if (act1Tex.pixels.length !== wdbTex.pixels.length) return false;
for (let i = 0; i < act1Tex.pixels.length; i++) {
if (act1Tex.pixels[i] !== wdbTex.pixels[i]) return false;
}
return true;
}
$: isDefaultTexture = isTextureDefault(textureInfo, wdbTexture, slot?.act1State?.textures);
// Update color when variable changes (without reloading geometry)
$: if (renderer && !loading && currentColorValue && loadedPartKey) {
renderer.updateColor(currentColorValue);
@ -98,7 +138,7 @@
if (!wdbData || !wdbParser || !currentPart || !renderer) return;
partError = null;
const partKey = `${vehicle}-${globalIndex}`;
const partKey = `${slot?.slotNumber}-${vehicle}-${globalIndex}`;
try {
const worldName = VehicleWorlds[vehicle];
@ -133,15 +173,72 @@
// Build parts map for shared LOD resolution
const partsMap = buildPartsMap(wdbParser, world.parts);
// Build texture list, merging Act1State texture if available
let textures = modelData.textures || [];
if (textureInfo && slot?.act1State?.textures) {
const texKey = textureInfo.textureName.toLowerCase();
const act1Tex = slot.act1State.textures.get(texKey);
if (act1Tex) {
const existingIdx = textures.findIndex(t => t.name?.toLowerCase() === texKey);
if (existingIdx >= 0) {
textures = [...textures];
textures[existingIdx] = { ...act1Tex, name: texKey };
} else {
textures = [...textures, { ...act1Tex, name: texKey }];
}
}
}
// Extract palette from the WDB texture (the ground truth) for the
// texture picker modal. The game's LoadBits() only overwrites pixel
// data on the DirectDraw surface — the palette always stays from the
// original WDB load. So custom pixel indices must reference THIS palette.
if (textureInfo) {
const texKey = textureInfo.textureName.toLowerCase();
const wdbTex = (modelData.textures || []).find(t => t.name === texKey);
if (wdbTex) {
texturePalette = wdbTex.palette;
wdbTexture = squareTexture(wdbTex);
} else {
wdbTexture = null;
}
} else {
wdbTexture = null;
}
// Load part with current color, textures, and parts map for shared LOD lookup
renderer.loadPartWithColor(partRoi, currentColorValue, modelData.textures || [], partsMap);
renderer.loadPartWithColor(partRoi, currentColorValue, textures, partsMap)
loadedPartKey = partKey;
// Preload default .tex files in background for the texture picker
if (textureInfo) {
preloadDefaultTextures(textureInfo);
} else {
preloadedDefaults = null;
}
} catch (e) {
console.error('Failed to load part:', e);
partError = e.message;
}
}
async function preloadDefaultTextures(info) {
const results = await Promise.all(info.texFiles.map(async (texFile) => {
const response = await fetch(`/${texFile}.tex`);
if (!response.ok) return null;
const buffer = await response.arrayBuffer();
const parsed = parseTex(buffer);
if (parsed.textures.length > 0) {
return { name: texFile, ...parsed.textures[0] };
}
return null;
}));
// Only apply if textureInfo hasn't changed since we started
if (textureInfo === info) {
preloadedDefaults = results.filter(Boolean);
}
}
function prevPart() {
globalIndex = globalIndex > 0 ? globalIndex - 1 : allParts.length - 1;
loadedPartKey = null;
@ -171,17 +268,61 @@
function resetColor() {
if (!currentPart) return;
onUpdate({
const update = {
variable: {
name: currentPart.variable,
value: currentPart.defaultColor
}
};
// Reset texture to WDB default (equivalent to WriteDefaultTexture in the game).
// wdbTexture is already squared when cached in loadCurrentPart().
if (canEditTexture && wdbTexture && renderer) {
const texKey = textureInfo.textureName.toLowerCase();
renderer.updateTexture(texKey, wdbTexture);
update.texture = {
textureName: textureInfo.textureName,
textureData: wdbTexture
};
}
onUpdate(update);
}
function openTexturePicker() {
if (!canEditTexture) return;
showTextureModal = true;
}
function handleTextureSelect(textureData) {
if (!textureInfo || !renderer) return;
const texKey = textureInfo.textureName.toLowerCase();
// Square the texture for game compatibility — the game's DirectDraw
// surfaces are always square, and LoadBits() expects matching dimensions.
// No-op if already square.
const saveData = squareTexture(textureData);
// Update preview immediately
renderer.updateTexture(texKey, saveData);
// Save to file
onUpdate({
texture: {
textureName: textureInfo.textureName,
textureData: saveData
}
});
showTextureModal = false;
}
</script>
<EditorTooltip text="Click on the part to cycle through colors. Changes are automatically saved.">
<EditorTooltip text="Click on the part to cycle through colors. Use the texture button to customize textures on supported parts (vehicle must be fully built first). Changes are automatically saved.">
<!-- 3D Preview (clickable to cycle color) -->
<div class="preview-container">
<canvas
@ -207,22 +348,48 @@
</div>
<!-- Part navigation below canvas -->
<div class="part-nav">
<NavButton direction="left" onclick={prevPart} />
<div class="part-info">
<span class="vehicle-name">{VehicleNames[vehicle]}</span>
<span class="part-name">{currentPart?.label || 'Unknown'}</span>
<div class="part-nav-wrapper">
<div class="part-nav">
<NavButton direction="left" onclick={prevPart} />
<div class="part-info">
<span class="vehicle-name">{VehicleNames[vehicle]}</span>
<span class="part-name">{currentPart?.label || 'Unknown'}</span>
</div>
<NavButton direction="right" onclick={nextPart} />
</div>
<NavButton direction="right" onclick={nextPart} />
{#if textureInfo}
<button
type="button"
class="texture-btn"
class:disabled={!canEditTexture}
onclick={openTexturePicker}
disabled={!canEditTexture}
title={canEditTexture ? 'Edit texture' : 'Vehicle not placed in world'}
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M14.5 2.5a1.5 1.5 0 0 0-3 0v1h-2v-1a1.5 1.5 0 0 0-3 0v1h-2v-1a1.5 1.5 0 0 0-3 0v2h1v2h-1v2h1v2h-1v2a1.5 1.5 0 0 0 3 0v-1h2v1a1.5 1.5 0 0 0 3 0v-1h2v1a1.5 1.5 0 0 0 3 0v-2h-1v-2h1v-2h-1v-2h1v-2zm-3 2h-2v2h2v-2zm-5 0h-2v2h2v-2zm0 4h-2v2h2v-2zm5 0h-2v2h2v-2z"/>
</svg>
</button>
{/if}
</div>
<div class="reset-container">
{#if !isDefault && !loading && !error && !partError}
{#if (!isDefaultColor || !isDefaultTexture) && !loading && !error && !partError}
<ResetButton onclick={resetColor} />
{/if}
</div>
</EditorTooltip>
{#if showTextureModal && textureInfo}
<TexturePickerModal
{textureInfo}
palette={texturePalette}
defaults={preloadedDefaults}
onSelect={handleTextureSelect}
onClose={() => showTextureModal = false}
/>
{/if}
<style>
.preview-container {
position: relative;
@ -278,11 +445,15 @@
to { transform: rotate(360deg); }
}
.part-nav-wrapper {
position: relative;
margin-top: 10px;
}
.part-nav {
display: flex;
align-items: center;
gap: 12px;
margin-top: 10px;
}
.part-info {
@ -307,4 +478,33 @@
.reset-container {
height: 1.6em;
}
.texture-btn {
position: absolute;
right: -36px;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
background: var(--color-bg-input);
border: 1px solid var(--color-border-medium);
border-radius: 6px;
color: var(--color-text-light);
cursor: pointer;
transition: all 0.2s ease;
}
.texture-btn:hover:not(.disabled) {
border-color: var(--color-primary);
color: var(--color-primary);
}
.texture-btn.disabled {
opacity: 0.35;
cursor: not-allowed;
}
</style>

View File

@ -1,7 +1,7 @@
module.exports = {
globDirectory: 'dist/',
globPatterns: [
'**/*.{js,css,html,webp,wasm,pdf,mp3,gif,png,svg,json}'
'**/*.{js,css,html,webp,wasm,pdf,mp3,gif,png,svg,json,tex}'
],
swSrc: 'src-sw/sw.js',
swDest: 'dist/sw.js',