Zum Inhalt

src/presets/storage.ts

// src/presets/storage.ts
import type { Preset } from '~/engine/types';
import { PresetSchema } from './schema';

const KEY = 'physarum:presets:custom';
const LAST_KEY = 'physarum:presets:lastSelected';

/** Lifts an old single-species preset into the multi-species schema. */
function migrateLegacyPreset(raw: unknown): unknown {
  if (typeof raw !== 'object' || raw === null) return raw;
  const p = raw as Record<string, unknown>;
  const params = p.params as Record<string, unknown> | undefined;
  if (!params || !('sensorAngle' in params)) return raw; // not a legacy preset

  const main = {
    color: params.trailColor,
    populationShare: 1,
    sensorAngle: params.sensorAngle,
    sensorDistance: params.sensorDistance,
    turnSpeed: params.turnSpeed,
    moveSpeed: params.moveSpeed,
    depositAmount: params.depositAmount,
  };
  const empty = { ...main, color: [0, 0, 0], populationShare: 0 };

  return {
    ...p,
    params: {
      agentCount: params.agentCount,
      spawnPattern: 'random',
      species: [main, empty, empty],
      interactionMatrix: [
        [1, 0, 0],
        [0, 1, 0],
        [0, 0, 1],
      ],
      decayRate: 0.97,
      diffusionFreq: 3,
      fadeStrength: 0.1,
      trailSaturation: 255,
      attractionStrength: 1.0,
      repulsionStrength: -0.2,
      chaosInterval: 0,
      chaosStrength: 0,
      rhythmAmplitude: 0.05,
      backgroundColor: params.backgroundColor,
      exposure: params.exposure,
      feedbackAmount: 0.2,
      bloomThreshold: 0.25,
      bloomIntensity: 0.9,
      vignette: 0.3,
      saturation: 1.1,
      contrast: 1.05,
      audioReactive: params.audioReactive ?? {
        enabled: false,
        gain: 1,
        autoGain: false,
        routes: [],
      },
    },
  };
}

export function loadCustomPresets(): Preset[] {
  try {
    const raw = localStorage.getItem(KEY);
    if (!raw) return [];
    const parsed = JSON.parse(raw);
    if (!Array.isArray(parsed)) return [];
    const valid: Preset[] = [];
    for (const item of parsed) {
      let result = PresetSchema.safeParse(item);
      if (!result.success) result = PresetSchema.safeParse(migrateLegacyPreset(item));
      if (result.success) valid.push(result.data as Preset);
    }
    return valid;
  } catch {
    return [];
  }
}

export function saveCustomPreset(preset: Preset): void {
  // Update in place when the preset already exists so re-saving an
  // existing preset doesn't bounce it to the end of the list — users
  // expect a "Save" on a tweaked preset to leave the library order alone.
  const all = loadCustomPresets();
  const idx = all.findIndex((p) => p.id === preset.id);
  const entry = { ...preset, builtIn: false };
  if (idx === -1) all.push(entry);
  else all[idx] = entry;
  localStorage.setItem(KEY, JSON.stringify(all));
}

export function deleteCustomPreset(id: string): void {
  const filtered = loadCustomPresets().filter((p) => p.id !== id);
  localStorage.setItem(KEY, JSON.stringify(filtered));
}

/** Atomically replaces the custom-preset list. Used by reorder operations. */
export function saveAllCustomPresets(presets: readonly Preset[]): void {
  const normalised = presets.map((p) => ({ ...p, builtIn: false }));
  localStorage.setItem(KEY, JSON.stringify(normalised));
}

/** Stores the id of the last selected preset so the next session can
 *  restore it. Silent on failure — localStorage may be unavailable in
 *  tests or private-browsing mode. */
export function saveLastSelectedId(id: string): void {
  try {
    localStorage.setItem(LAST_KEY, id);
  } catch {
    /* ignore */
  }
}

export function loadLastSelectedId(): string | null {
  try {
    return localStorage.getItem(LAST_KEY);
  } catch {
    return null;
  }
}

export function newCustomId(name: string): string {
  const slug = name
    .toLowerCase()
    .trim()
    .replace(/[^a-z0-9]+/g, '-')
    .replace(/(^-|-$)/g, '');
  const stamp = Date.now().toString(36);
  return `custom-${slug || 'preset'}-${stamp}`;
}