mirror of
https://github.com/isledecomp/isle.pizza.git
synced 2026-02-28 13:57:38 +00:00
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:
parent
64be72194e
commit
36a6e0fde9
BIN
public/globe1.webp
Normal file
BIN
public/globe1.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 KiB |
BIN
public/globe2.webp
Normal file
BIN
public/globe2.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.2 KiB |
BIN
public/globe3.webp
Normal file
BIN
public/globe3.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.2 KiB |
BIN
public/globe4.webp
Normal file
BIN
public/globe4.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.2 KiB |
BIN
public/globe5.webp
Normal file
BIN
public/globe5.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.2 KiB |
BIN
public/globe6.webp
Normal file
BIN
public/globe6.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 KiB |
@ -45,7 +45,9 @@ export class SaveGameParser {
|
|||||||
stateLocations: [],
|
stateLocations: [],
|
||||||
stateCountOffset: null,
|
stateCountOffset: null,
|
||||||
stateCount: 0,
|
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()
|
* Must be called after parseHeader()
|
||||||
*/
|
*/
|
||||||
skipVariables() {
|
parseVariables() {
|
||||||
while (true) {
|
while (true) {
|
||||||
|
const nameOffset = this.reader.tell();
|
||||||
const nameLength = this.reader.readU8();
|
const nameLength = this.reader.readU8();
|
||||||
const name = this.reader.readString(nameLength);
|
const name = this.reader.readString(nameLength);
|
||||||
|
|
||||||
if (name === 'END_OF_VARIABLES') {
|
if (name === 'END_OF_VARIABLES') {
|
||||||
|
this.parsed.variablesEndOffset = this.reader.tell();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const valueOffset = this.reader.tell();
|
||||||
const valueLength = this.reader.readU8();
|
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)
|
* 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() {
|
parse() {
|
||||||
this.parseHeader();
|
this.parseHeader();
|
||||||
this.skipVariables();
|
this.parseVariables();
|
||||||
this.skipCharacters();
|
this.skipCharacters();
|
||||||
this.skipPlants();
|
this.skipPlants();
|
||||||
this.skipBuildings();
|
this.skipBuildings();
|
||||||
|
|||||||
@ -285,6 +285,58 @@ export class SaveGameSerializer {
|
|||||||
return buffer;
|
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
|
* Get the byte offset for a mission score
|
||||||
* @param {string} missionType
|
* @param {string} missionType
|
||||||
|
|||||||
119
src/core/savegame/colorUtils.js
Normal file
119
src/core/savegame/colorUtils.js
Normal 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')}`;
|
||||||
|
}
|
||||||
@ -11,6 +11,7 @@ export { SaveGameSerializer, createSerializer } from '../formats/index.js';
|
|||||||
export { PlayersParser, parsePlayers } from '../formats/index.js';
|
export { PlayersParser, parsePlayers } from '../formats/index.js';
|
||||||
export { PlayersSerializer, createPlayersSerializer } from '../formats/index.js';
|
export { PlayersSerializer, createPlayersSerializer } from '../formats/index.js';
|
||||||
export * from './constants.js';
|
export * from './constants.js';
|
||||||
|
export * from './colorUtils.js';
|
||||||
|
|
||||||
// Import dependencies
|
// Import dependencies
|
||||||
import { readBinaryFile, writeBinaryFile, fileExists, listFiles } from '../opfs.js';
|
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 {string} fileName - Save file name
|
||||||
* @property {Object|null} header - Parsed header data
|
* @property {Object|null} header - Parsed header data
|
||||||
* @property {Object|null} missions - Mission scores
|
* @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 {string|null} playerName - Player name from Players.gsi
|
||||||
* @property {ArrayBuffer|null} buffer - Raw file buffer (for editing)
|
* @property {ArrayBuffer|null} buffer - Raw file buffer (for editing)
|
||||||
*/
|
*/
|
||||||
@ -87,6 +89,7 @@ export async function listSaveSlots() {
|
|||||||
fileName,
|
fileName,
|
||||||
header: null,
|
header: null,
|
||||||
missions: null,
|
missions: null,
|
||||||
|
variables: null,
|
||||||
playerName: null,
|
playerName: null,
|
||||||
buffer: null
|
buffer: null
|
||||||
};
|
};
|
||||||
@ -98,6 +101,7 @@ export async function listSaveSlots() {
|
|||||||
const parsed = parseSaveGame(buffer);
|
const parsed = parseSaveGame(buffer);
|
||||||
slot.header = parsed.header;
|
slot.header = parsed.header;
|
||||||
slot.missions = parsed.missions;
|
slot.missions = parsed.missions;
|
||||||
|
slot.variables = parsed.variables;
|
||||||
slot.buffer = buffer;
|
slot.buffer = buffer;
|
||||||
|
|
||||||
// Try to get player name
|
// Try to get player name
|
||||||
@ -160,6 +164,7 @@ export async function loadSaveSlot(slotNumber) {
|
|||||||
fileName,
|
fileName,
|
||||||
header: parsed.header,
|
header: parsed.header,
|
||||||
missions: parsed.missions,
|
missions: parsed.missions,
|
||||||
|
variables: parsed.variables,
|
||||||
playerName,
|
playerName,
|
||||||
buffer
|
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
|
// Only save if something was actually modified
|
||||||
if (!modified) {
|
if (!modified) {
|
||||||
return slot;
|
return slot;
|
||||||
|
|||||||
@ -3,6 +3,8 @@
|
|||||||
import BackButton from './BackButton.svelte';
|
import BackButton from './BackButton.svelte';
|
||||||
import Carousel from './Carousel.svelte';
|
import Carousel from './Carousel.svelte';
|
||||||
import MissionScoresEditor from './save-editor/MissionScoresEditor.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 { saveEditorState, currentPage } from '../stores.js';
|
||||||
import { listSaveSlots, updateSaveSlot, updatePlayerName } from '../core/savegame/index.js';
|
import { listSaveSlots, updateSaveSlot, updatePlayerName } from '../core/savegame/index.js';
|
||||||
import { Actor, ActorNames } from '../core/savegame/constants.js';
|
import { Actor, ActorNames } from '../core/savegame/constants.js';
|
||||||
@ -18,7 +20,8 @@
|
|||||||
|
|
||||||
const saveTabs = [
|
const saveTabs = [
|
||||||
{ id: 'player', label: 'Player', firstSection: 'name' },
|
{ 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
|
// 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
|
// Tab/section functions
|
||||||
function switchTab(tab) {
|
function switchTab(tab) {
|
||||||
activeTab = tab.id;
|
activeTab = tab.id;
|
||||||
@ -354,6 +374,27 @@
|
|||||||
onUpdate={handleMissionUpdate}
|
onUpdate={handleMissionUpdate}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
110
src/lib/save-editor/LightPositionEditor.svelte
Normal file
110
src/lib/save-editor/LightPositionEditor.svelte
Normal 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>
|
||||||
228
src/lib/save-editor/SkyColorEditor.svelte
Normal file
228
src/lib/save-editor/SkyColorEditor.svelte
Normal 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>
|
||||||
Loading…
Reference in New Issue
Block a user