Feature/island sky color editor (#13)

* Add Island tab with sky color editor

- Add parseVariables() to SaveGameParser to extract variable names, values, and offsets
- Add updateVariable() to SaveGameSerializer for modifying variable values
- Add colorUtils.js with LEGO Island's custom HSV-to-RGB algorithm
- Add SkyColorEditor component with H/S/V sliders and color preview
- Add Island tab to SaveEditorPage with Sky Color section
- Include reset to default button when values differ from default (56/54/68)

* Remove tooltip

* Add light position editor to Island tab

Allows users to select one of 6 sun positions using visual globe
image selectors. Includes converted globe images (BMP to WebP).
This commit is contained in:
Christian Semmler 2026-02-01 14:42:40 -08:00 committed by GitHub
parent 64be72194e
commit 36a6e0fde9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 585 additions and 7 deletions

BIN
public/globe1.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

BIN
public/globe2.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

BIN
public/globe3.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

BIN
public/globe4.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

BIN
public/globe5.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

BIN
public/globe6.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@ -45,7 +45,9 @@ export class SaveGameParser {
stateLocations: [],
stateCountOffset: null,
stateCount: 0,
statesEndOffset: null // Where to insert new states (before previous_area)
statesEndOffset: null, // Where to insert new states (before previous_area)
variables: new Map(), // Map<string, { value, nameOffset, valueOffset, valueLength }>
variablesEndOffset: null // Where END_OF_VARIABLES marker ends
};
}
@ -70,20 +72,30 @@ export class SaveGameParser {
}
/**
* Skip over the variables section
* Parse the variables section, storing name/value pairs with their offsets
* Must be called after parseHeader()
*/
skipVariables() {
parseVariables() {
while (true) {
const nameOffset = this.reader.tell();
const nameLength = this.reader.readU8();
const name = this.reader.readString(nameLength);
if (name === 'END_OF_VARIABLES') {
this.parsed.variablesEndOffset = this.reader.tell();
break;
}
const valueOffset = this.reader.tell();
const valueLength = this.reader.readU8();
this.reader.skip(valueLength);
const value = this.reader.readString(valueLength);
this.parsed.variables.set(name, {
value,
nameOffset,
valueOffset,
valueLength
});
}
}
@ -371,11 +383,11 @@ export class SaveGameParser {
/**
* Full parse for the save editor (header + missions)
* @returns {{ header: SaveGameHeader, missions: MissionScores, stateLocations: GameStateLocation[] }}
* @returns {{ header: SaveGameHeader, missions: MissionScores, stateLocations: GameStateLocation[], variables: Map }}
*/
parse() {
this.parseHeader();
this.skipVariables();
this.parseVariables();
this.skipCharacters();
this.skipPlants();
this.skipBuildings();

View File

@ -285,6 +285,58 @@ export class SaveGameSerializer {
return buffer;
}
/**
* Update a variable value in the save file
* @param {string} name - Variable name
* @param {string} newValue - New value string
* @param {ArrayBuffer} [buffer] - Optional buffer to use (for chaining operations)
* @returns {ArrayBuffer|null} - Modified buffer or null on error
*/
updateVariable(name, newValue, buffer = null) {
const varInfo = this.parsed.variables.get(name);
if (!varInfo) {
console.error(`Variable not found: ${name}`);
return null;
}
const workingBuffer = buffer || this.createCopy();
const array = new Uint8Array(workingBuffer);
const encoder = new TextEncoder();
const oldLength = varInfo.valueLength;
const newLength = newValue.length;
if (oldLength === newLength) {
// In-place update - same length, just replace bytes
const valueBytes = encoder.encode(newValue);
array.set(valueBytes, varInfo.valueOffset + 1); // +1 for length byte
return workingBuffer;
} else {
// Different length - need to rebuild buffer
const oldArray = new Uint8Array(workingBuffer);
const sizeDiff = newLength - oldLength;
const newBuffer = new ArrayBuffer(workingBuffer.byteLength + sizeDiff);
const newArray = new Uint8Array(newBuffer);
// Copy everything up to the value (including the length byte position)
const valueDataStart = varInfo.valueOffset + 1; // After length byte
newArray.set(oldArray.slice(0, varInfo.valueOffset));
// Write new length byte
newArray[varInfo.valueOffset] = newLength;
// Write new value
const valueBytes = encoder.encode(newValue);
newArray.set(valueBytes, valueDataStart);
// Copy everything after the old value
const afterOldValue = valueDataStart + oldLength;
newArray.set(oldArray.slice(afterOldValue), valueDataStart + newLength);
return newBuffer;
}
}
/**
* Get the byte offset for a mission score
* @param {string} missionType

View File

@ -0,0 +1,119 @@
/**
* Color utilities for LEGO Island's custom HSV-based background color
*
* IMPORTANT: LEGO Island uses a NON-STANDARD HSV algorithm (see legoutils.cpp)
* - H (Hue): 0-100 maps to standard hue wheel
* - S (Saturation): NOT standard saturation! S=100 produces WHITE
* - V (Value): Brightness component
*
* For vivid colors, keep S in the 40-70 range. The default sky is "set 56 54 68".
*/
/**
* Parse a backgroundcolor variable value into HSV components
* @param {string} value - Value in format "set H S V"
* @returns {{ h: number, s: number, v: number }} HSV values (0-100)
*/
export function parseBackgroundColor(value) {
const match = value.match(/^set\s+(\d+)\s+(\d+)\s+(\d+)$/);
if (!match) {
// Default sky color
return { h: 56, s: 54, v: 68 };
}
return {
h: parseInt(match[1], 10),
s: parseInt(match[2], 10),
v: parseInt(match[3], 10)
};
}
/**
* Format HSV values into a backgroundcolor variable value
* @param {number} h - Hue (0-100)
* @param {number} s - Saturation (0-100)
* @param {number} v - Value/Brightness (0-100)
* @returns {string} Value in format "set H S V"
*/
export function formatBackgroundColor(h, s, v) {
return `set ${Math.round(h)} ${Math.round(s)} ${Math.round(v)}`;
}
/**
* Convert LEGO Island's custom HSV (0-100 scale) to RGB (0-255)
* Replicates the exact algorithm from legoutils.cpp ConvertHSVToRGB()
*
* WARNING: S=100 always produces white due to the algorithm!
*
* @param {number} h - Hue (0-100)
* @param {number} s - Saturation (0-100)
* @param {number} v - Value/Brightness (0-100)
* @returns {{ r: number, g: number, b: number }} RGB values (0-255)
*/
export function hsvToRgb(h, s, v) {
// Convert 0-100 scale to 0-1 (as the game does with * 0.01)
const hNorm = h / 100;
const sNorm = s / 100;
const vNorm = v / 100;
// LEGO Island's custom algorithm (from legoutils.cpp ConvertHSVToRGB)
let max;
if (sNorm > 0.5) {
max = (1.0 - vNorm) * sNorm + vNorm;
} else {
max = (vNorm + 1.0) * sNorm;
}
if (max <= 0) {
return { r: 0, g: 0, b: 0 };
}
const min = sNorm * 2.0 - max;
const hueSegment = Math.floor(hNorm * 6);
const hueFraction = hNorm * 6.0 - hueSegment;
const delta = hueFraction * ((max - min) / max) * max;
const ascending = min + delta; // Channel value rising from min
const descending = max - delta; // Channel value falling from max
let r, g, b;
switch (hueSegment) {
case 0: // Red to Yellow
r = max; g = ascending; b = min;
break;
case 1: // Yellow to Green
r = descending; g = max; b = min;
break;
case 2: // Green to Cyan
r = min; g = max; b = ascending;
break;
case 3: // Cyan to Blue
r = min; g = descending; b = max;
break;
case 4: // Blue to Magenta
r = ascending; g = min; b = max;
break;
case 5: // Magenta to Red
case 6:
r = max; g = min; b = descending;
break;
default:
r = 0; g = 0; b = 0;
}
return {
r: Math.round(r * 255),
g: Math.round(g * 255),
b: Math.round(b * 255)
};
}
/**
* Convert HSV (0-100 scale) to hex color string
* @param {number} h - Hue (0-100)
* @param {number} s - Saturation (0-100)
* @param {number} v - Value/Brightness (0-100)
* @returns {string} Hex color string (e.g., "#ffcc00")
*/
export function hsvToHex(h, s, v) {
const { r, g, b } = hsvToRgb(h, s, v);
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
}

View File

@ -11,6 +11,7 @@ export { SaveGameSerializer, createSerializer } from '../formats/index.js';
export { PlayersParser, parsePlayers } from '../formats/index.js';
export { PlayersSerializer, createPlayersSerializer } from '../formats/index.js';
export * from './constants.js';
export * from './colorUtils.js';
// Import dependencies
import { readBinaryFile, writeBinaryFile, fileExists, listFiles } from '../opfs.js';
@ -24,6 +25,7 @@ import { getSaveFileName, PLAYERS_FILE, Actor, ActorNames } from './constants.js
* @property {string} fileName - Save file name
* @property {Object|null} header - Parsed header data
* @property {Object|null} missions - Mission scores
* @property {Map|null} variables - Parsed variables (name -> { value, nameOffset, valueOffset, valueLength })
* @property {string|null} playerName - Player name from Players.gsi
* @property {ArrayBuffer|null} buffer - Raw file buffer (for editing)
*/
@ -87,6 +89,7 @@ export async function listSaveSlots() {
fileName,
header: null,
missions: null,
variables: null,
playerName: null,
buffer: null
};
@ -98,6 +101,7 @@ export async function listSaveSlots() {
const parsed = parseSaveGame(buffer);
slot.header = parsed.header;
slot.missions = parsed.missions;
slot.variables = parsed.variables;
slot.buffer = buffer;
// Try to get player name
@ -160,6 +164,7 @@ export async function loadSaveSlot(slotNumber) {
fileName,
header: parsed.header,
missions: parsed.missions,
variables: parsed.variables,
playerName,
buffer
};
@ -219,6 +224,17 @@ export async function updateSaveSlot(slotNumber, updates) {
}
}
// Apply variable update
if (updates.variable) {
const { name, value } = updates.variable;
const varSerializer = createSerializer(newBuffer);
const result = varSerializer.updateVariable(name, value);
if (result) {
newBuffer = result;
modified = true;
}
}
// Only save if something was actually modified
if (!modified) {
return slot;

View File

@ -3,6 +3,8 @@
import BackButton from './BackButton.svelte';
import Carousel from './Carousel.svelte';
import MissionScoresEditor from './save-editor/MissionScoresEditor.svelte';
import SkyColorEditor from './save-editor/SkyColorEditor.svelte';
import LightPositionEditor from './save-editor/LightPositionEditor.svelte';
import { saveEditorState, currentPage } from '../stores.js';
import { listSaveSlots, updateSaveSlot, updatePlayerName } from '../core/savegame/index.js';
import { Actor, ActorNames } from '../core/savegame/constants.js';
@ -18,7 +20,8 @@
const saveTabs = [
{ id: 'player', label: 'Player', firstSection: 'name' },
{ id: 'scores', label: 'Scores', firstSection: null }
{ id: 'scores', label: 'Scores', firstSection: null },
{ id: 'island', label: 'Island', firstSection: 'skycolor' }
];
// Reset state when navigating to this page
@ -107,6 +110,23 @@
}
}
async function handleVariableUpdate(update) {
if (selectedSlot === null) return;
try {
const updated = await updateSaveSlot(selectedSlot, update);
if (updated) {
slots = slots.map(s =>
s.slotNumber === selectedSlot
? { ...s, variables: updated.variables }
: s
);
}
} catch (e) {
console.error('Failed to update variable:', e);
}
}
// Tab/section functions
function switchTab(tab) {
activeTab = tab.id;
@ -354,6 +374,27 @@
onUpdate={handleMissionUpdate}
/>
</div>
<!-- Island Tab -->
<div class:hidden={activeTab !== 'island'}>
<div class="config-section-card">
<button type="button" class="config-card-header" onclick={() => toggleSection('skycolor')}>
Sky Color
</button>
<div class="config-card-content" class:open={openSection === 'skycolor'}>
<SkyColorEditor slot={currentSlot} onUpdate={handleVariableUpdate} />
</div>
</div>
<div class="config-section-card">
<button type="button" class="config-card-header" onclick={() => toggleSection('lightposition')}>
Light Position
</button>
<div class="config-card-content" class:open={openSection === 'lightposition'}>
<LightPositionEditor slot={currentSlot} onUpdate={handleVariableUpdate} />
</div>
</div>
</div>
</div>
{/if}
</div>

View File

@ -0,0 +1,110 @@
<script>
export let slot;
export let onUpdate = () => {};
// Default light position (game default is "2")
const DEFAULT_POSITION = 2;
// Get current position from slot's lightposition variable
$: lightVar = slot?.variables?.get('lightposition');
$: currentPosition = lightVar ? parseInt(lightVar.value, 10) : DEFAULT_POSITION;
// Globe images for each position (0-5 map to globe1-globe6)
const positions = [0, 1, 2, 3, 4, 5];
function handleSelect(position) {
onUpdate({
variable: {
name: 'lightposition',
value: String(position)
}
});
}
function handleReset() {
handleSelect(DEFAULT_POSITION);
}
$: isDefault = currentPosition === DEFAULT_POSITION;
</script>
<div class="light-position-editor">
<div class="globe-grid">
{#each positions as position}
<button
type="button"
class="globe-btn"
class:selected={currentPosition === position}
onclick={() => handleSelect(position)}
title="Position {position}"
>
<img
src="globe{position + 1}.webp"
alt="Light position {position}"
/>
</button>
{/each}
</div>
{#if !isDefault}
<button type="button" class="reset-btn" onclick={handleReset}>Reset to default</button>
{/if}
</div>
<style>
.light-position-editor {
padding-top: 4px;
}
.globe-grid {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.globe-btn {
padding: 4px;
background: var(--color-bg-input);
border: 2px solid var(--color-border-medium);
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
}
.globe-btn:hover {
border-color: var(--color-border-light);
}
.globe-btn.selected {
border-color: var(--color-primary);
}
.globe-btn img {
width: 48px;
height: 48px;
display: block;
image-rendering: pixelated;
}
.reset-btn {
display: block;
margin-top: 8px;
font-size: 0.8em;
color: var(--color-text-muted);
background: none;
border: none;
cursor: pointer;
padding: 0;
text-decoration: underline;
}
.reset-btn:hover {
color: var(--color-text-light);
}
@media (max-width: 400px) {
.globe-btn img {
width: 40px;
height: 40px;
}
}
</style>

View File

@ -0,0 +1,228 @@
<script>
import { parseBackgroundColor, formatBackgroundColor, hsvToHex } from '../../core/savegame/colorUtils.js';
export let slot;
export let onUpdate = () => {};
// Default sky color values
const DEFAULT_HUE = 56;
const DEFAULT_SATURATION = 54;
const DEFAULT_BRIGHTNESS = 68;
// Get initial HSV from slot's backgroundcolor variable
$: bgVar = slot?.variables?.get('backgroundcolor');
$: initialHsv = bgVar ? parseBackgroundColor(bgVar.value) : { h: DEFAULT_HUE, s: DEFAULT_SATURATION, v: DEFAULT_BRIGHTNESS };
// Local state for sliders
let hue = DEFAULT_HUE;
let saturation = DEFAULT_SATURATION;
let brightness = DEFAULT_BRIGHTNESS;
let initialized = false;
// Initialize from slot data when it becomes available
$: if (initialHsv && !initialized) {
hue = initialHsv.h;
saturation = initialHsv.s;
brightness = initialHsv.v;
initialized = true;
}
// Reset initialized flag when slot changes
$: if (slot?.slotNumber !== undefined) {
initialized = false;
}
// Computed preview color
$: previewColor = hsvToHex(hue, saturation, brightness);
function handleChange() {
onUpdate({
variable: {
name: 'backgroundcolor',
value: formatBackgroundColor(hue, saturation, brightness)
}
});
}
function handleReset() {
hue = DEFAULT_HUE;
saturation = DEFAULT_SATURATION;
brightness = DEFAULT_BRIGHTNESS;
handleChange();
}
$: isDefault = hue === DEFAULT_HUE && saturation === DEFAULT_SATURATION && brightness === DEFAULT_BRIGHTNESS;
</script>
<div class="sky-color-editor">
<div class="editor-content">
<div class="color-preview" style="background-color: {previewColor}"></div>
<div class="sliders">
<label class="slider-row">
<span class="slider-label">Hue</span>
<input
type="range"
min="0"
max="100"
bind:value={hue}
onchange={handleChange}
class="slider hue-slider"
/>
<span class="slider-value">{hue}</span>
</label>
<label class="slider-row">
<span class="slider-label">Saturation</span>
<input
type="range"
min="0"
max="100"
bind:value={saturation}
onchange={handleChange}
class="slider"
/>
<span class="slider-value">{saturation}</span>
</label>
<label class="slider-row">
<span class="slider-label">Brightness</span>
<input
type="range"
min="0"
max="100"
bind:value={brightness}
onchange={handleChange}
class="slider"
/>
<span class="slider-value">{brightness}</span>
</label>
</div>
</div>
{#if !isDefault}
<button type="button" class="reset-btn" onclick={handleReset}>Reset to default</button>
{/if}
</div>
<style>
.sky-color-editor {
padding-top: 4px;
}
.editor-content {
display: flex;
gap: 16px;
align-items: flex-start;
}
.color-preview {
width: 60px;
height: 60px;
border-radius: 6px;
border: 2px solid var(--color-border-medium);
flex-shrink: 0;
}
.sliders {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
}
.slider-row {
display: flex;
align-items: center;
gap: 8px;
}
.slider-label {
width: 70px;
font-size: 0.85em;
color: var(--color-text-muted);
flex-shrink: 0;
}
.slider {
flex: 1;
height: 6px;
-webkit-appearance: none;
appearance: none;
background: var(--color-bg-input);
border-radius: 3px;
outline: none;
min-width: 0;
}
.slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--color-primary);
cursor: pointer;
border: 2px solid var(--color-bg-dark);
}
.slider::-moz-range-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--color-primary);
cursor: pointer;
border: 2px solid var(--color-bg-dark);
}
.hue-slider {
background: linear-gradient(to right,
hsl(0, 70%, 50%),
hsl(60, 70%, 50%),
hsl(120, 70%, 50%),
hsl(180, 70%, 50%),
hsl(240, 70%, 50%),
hsl(300, 70%, 50%),
hsl(360, 70%, 50%)
);
}
.slider-value {
width: 28px;
text-align: right;
font-size: 0.85em;
color: var(--color-text-light);
font-family: monospace;
flex-shrink: 0;
}
.reset-btn {
display: block;
margin-top: 8px;
font-size: 0.8em;
color: var(--color-text-muted);
background: none;
border: none;
cursor: pointer;
padding: 0;
text-decoration: underline;
}
.reset-btn:hover {
color: var(--color-text-light);
}
@media (max-width: 400px) {
.editor-content {
flex-direction: column;
align-items: stretch;
}
.color-preview {
width: 100%;
height: 40px;
}
.slider-label {
width: 60px;
font-size: 0.8em;
}
}
</style>