// ============================================================ // Live-Settings-Panel // ============================================================ // Schiebt sich als Drawer von rechts ein (⚙ oben rechts oder P). // Jeder Wert ist als "live" (greift sofort) oder "reload" (braucht // Neustart) markiert. Live-Werte werden entweder: // • direkt auf dem config-Objekt mutiert (PLAYER.*, CONTROLS.*), // die player.js/raycast live lesen, ODER // • über einen Apply-Callback (registerApply) angewandt, den // main.js für alle Three.js-Objekte registriert. // Reload-Werte werden nur persistiert + der Neustart-Button blinkt. // Alles landet debounced in localStorage (config.js lädt es beim // nächsten Start). // ============================================================ import * as THREE from 'three'; import * as CFG from './config.js'; import { loadSettings, saveSettings, clearSettings, DEFAULTS } from './config.js'; // ---------- Apply-Registry (von main.js gefüllt) ---------- const applyFns = Object.create(null); /** Registriert eine Live-Apply-Funktion für einen config-Pfad. */ export function registerApply(path, fn) { applyFns[path] = fn; } // ---------- Pfad-Helfer ---------- function getPath(obj, path) { return path.split('.').reduce((o, k) => (o == null ? o : o[k]), obj); } function setPath(root, path, value) { const parts = path.split('.'); const last = parts.pop(); const obj = parts.reduce((o, k) => (o[k] = o[k] || (Number.isInteger(+parts[parts.indexOf(k) + 1]) ? [] : {})), root); obj[last] = value; return obj; } // Schreibt einen Wert in die config.js-Exports – aber NUR in geschachtelte // Objekte/Arrays (PLAYER.WALK, CONTROLS.REACH, AO_LEVEL.0, HEMISPHERE.intensity …). // Der Modul-Namespace CFG selbst ist read-only: JEDWEDE Zuweisung auf ein // Top-Level-Binding (TONE_MAPPING, SHADOWS_ENABLED, PLAYER …) wirft // "Cannot assign to read only property" – auch der Zwischenschritt im alten // setPath(reduce) hat das getan, weshalb früher auch PLAYER.*/CONTROLS.* // crashten. Navigation läuft daher nur über Lesezugriffe; die Mutation findet // auf dem Plain-Object/Array statt. Top-Level-Skalare brauchen einen // applyFn-Callback (live) oder sind reload. function mutateNested(path, value) { const parts = path.split('.'); if (parts.length < 2) return; // Top-Level-Skalar → via applyFn/reload behandelt const last = parts.pop(); let obj = CFG; for (const k of parts) { obj = obj[k]; if (obj == null) return; } obj[last] = value; } // ---------- Farbkonvertierung (config speichert Hex-Numbers) ---------- function toHex(n) { return '#' + Math.max(0, n).toString(16).padStart(6, '0'); } function fromHex(s) { return parseInt(String(s).replace('#', ''), 16); } // ---------- SCHEMA ---------- // type: 'range' | 'number' | 'checkbox' | 'color' | 'select' // live: true = greift sofort, false = braucht Neustart const SCHEMA = [ { group: 'Welt', reload: true, items: [ { path: 'WORLD_SEED', label: 'Seed', type: 'number', step: 1 }, { path: 'SEA_LEVEL', label: 'Meereshöhe', type: 'range', min: 0, max: 60, step: 1 }, { path: 'SPAWN.x', label: 'Spawn X', type: 'number', step: 1 }, { path: 'SPAWN.z', label: 'Spawn Z', type: 'number', step: 1 }, ], }, { group: 'Performance', items: [ { path: 'RENDER_DISTANCE', label: 'Sichtweite (Chunks)', type: 'range', min: 2, max: 12, step: 1, live: true }, { path: 'MAX_OPS_PER_FRAME', label: 'Chunks/Frame', type: 'range', min: 1, max: 6, step: 1, live: true }, { path: 'MAX_PIXEL_RATIO', label: 'Pixel-Ratio-Cap', type: 'range', min: 1, max: 3, step: 1, live: true }, ], }, { group: 'Renderer', items: [ { path: 'TONE_MAPPING_EXPOSURE', label: 'Belichtung', type: 'range', min: 0.3, max: 2.5, step: 0.05, live: true }, { path: 'TONE_MAPPING', label: 'Tone-Mapping', type: 'select', live: false, options: [ { label: 'ACES Filmic', value: THREE.ACESFilmicToneMapping }, { label: 'Linear', value: THREE.LinearToneMapping }, { label: 'Reinhard', value: THREE.ReinhardToneMapping }, { label: 'Cineon', value: THREE.CineonToneMapping }, ], }, { path: 'ANTIALIAS', label: 'MSAA (Renderer)', type: 'checkbox', live: false }, { path: 'MSAA_SAMPLES', label: 'MSAA (Composer)', type: 'select', live: false, options: [ { label: 'Aus', value: 0 }, { label: '2×', value: 2 }, { label: '4×', value: 4 }, ], }, ], }, { group: 'Himmel & Nebel', items: [ { path: 'SKY_COLOR', label: 'Himmel', type: 'color', live: true }, { path: 'FOG_NEAR', label: 'Nebel Anfang', type: 'range', min: 10, max: 240, step: 2, live: true }, { path: 'FOG_FAR', label: 'Nebel Ende', type: 'range', min: 40, max: 500, step: 4, live: true }, ], }, { group: 'Kamera', items: [ { path: 'CAMERA_FOV', label: 'Sichtfeld (FOV)', type: 'range', min: 50, max: 110, step: 1, live: true }, { path: 'CAMERA_NEAR', label: 'Near', type: 'range', min: 0.05, max: 1, step: 0.05, live: true }, { path: 'CAMERA_FAR', label: 'Far', type: 'range', min: 200, max: 2000, step: 50, live: true }, ], }, { group: 'Beleuchtung', items: [ { path: 'HEMISPHERE.intensity', label: 'Hemi-Intensität', type: 'range', min: 0, max: 3, step: 0.05, live: true }, { path: 'HEMISPHERE.sky', label: 'Hemi Himmel', type: 'color', live: true }, { path: 'HEMISPHERE.ground', label: 'Hemi Boden', type: 'color', live: true }, { path: 'SUN.intensity', label: 'Sonnen-Intensität', type: 'range', min: 0, max: 3, step: 0.05, live: true }, { path: 'SUN.color', label: 'Sonnenfarbe', type: 'color', live: true }, ], }, { group: 'Schatten', items: [ { path: 'SHADOWS_ENABLED', label: 'Schatten an', type: 'checkbox', live: false }, { path: 'SHADOW_MAP_SIZE', label: 'Karten-Größe', type: 'select', live: false, options: [ { label: '1024', value: 1024 }, { label: '2048', value: 2048 }, { label: '4096', value: 4096 }, ], }, { path: 'SHADOW_AREA', label: 'Frustum-Größe', type: 'range', min: 10, max: 120, step: 2, live: true }, { path: 'SHADOW_BIAS', label: 'Bias', type: 'range', min: 0, max: 0.001, step: 0.00005, live: true }, { path: 'SHADOW_NORMAL_BIAS', label: 'Normal-Bias', type: 'range', min: 0, max: 0.1, step: 0.005, live: true }, ], }, { group: 'Post-Processing', items: [ { path: 'BLOOM_ENABLED', label: 'Bloom an', type: 'checkbox', live: true }, { path: 'BLOOM.strength', label: 'Bloom-Stärke', type: 'range', min: 0, max: 2, step: 0.05, live: true }, { path: 'BLOOM.radius', label: 'Bloom-Radius', type: 'range', min: 0, max: 2, step: 0.05, live: true }, { path: 'BLOOM.threshold', label: 'Bloom-Schwelle', type: 'range', min: 0, max: 2, step: 0.01, live: true }, ], }, { group: 'Ambient Occlusion', items: [ { path: 'AO_LEVEL.0', label: 'Ecke 0 (dunkelste)', type: 'range', min: 0, max: 1, step: 0.05, live: true }, { path: 'AO_LEVEL.1', label: 'Ecke 1', type: 'range', min: 0, max: 1, step: 0.05, live: true }, { path: 'AO_LEVEL.2', label: 'Ecke 2', type: 'range', min: 0, max: 1, step: 0.05, live: true }, { path: 'AO_LEVEL.3', label: 'Ecke 3 (hellste)', type: 'range', min: 0, max: 2, step: 0.05, live: true }, ], }, { group: 'Spieler', items: [ { path: 'PLAYER.WALK', label: 'Laufgeschwindigkeit', type: 'range', min: 1, max: 10, step: 0.1, live: true }, { path: 'PLAYER.SPRINT', label: 'Sprintgeschwindigkeit', type: 'range', min: 2, max: 15, step: 0.1, live: true }, { path: 'PLAYER.FLY_SPEED', label: 'Fluggeschwindigkeit', type: 'range', min: 2, max: 20, step: 0.1, live: true }, { path: 'PLAYER.GRAVITY', label: 'Gravitation', type: 'range', min: 5, max: 60, step: 1, live: true }, { path: 'PLAYER.JUMP_VEL', label: 'Sprungkraft', type: 'range', min: 3, max: 15, step: 0.1, live: true }, { path: 'PLAYER.MAX_FALL', label: 'Max. Fallgeschw.', type: 'range', min: 10, max: 100, step: 1, live: true }, { path: 'PLAYER.HEIGHT', label: 'Körperhöhe', type: 'range', min: 0.5, max: 3, step: 0.05, live: true }, { path: 'PLAYER.HALF', label: 'Halbe Breite', type: 'range', min: 0.1, max: 0.49, step: 0.01, live: true }, { path: 'PLAYER.EYE', label: 'Augenhöhe', type: 'range', min: 0.5, max: 3, step: 0.01, live: true }, ], }, { group: 'Steuerung', items: [ { path: 'CONTROLS.MOUSE_SENSITIVITY', label: 'Maus-Empfindlichkeit', type: 'range', min: 0.0005, max: 0.01, step: 0.0001, live: true }, { path: 'CONTROLS.REACH', label: 'Reichweite', type: 'range', min: 2, max: 12, step: 0.5, live: true }, ], }, ]; // ---------- Zustand ---------- let panelEl, reloadBanner, copyBtn; let settings = loadSettings(); let reloadDirty = false; let wasLocked = false; let saveTimer = 0; let onRequestLockFn = null; let overlayEl = null; // ---------- Persistenz ---------- function persist(path, value) { setPath(settings, path, value); if (saveTimer) clearTimeout(saveTimer); saveTimer = setTimeout(() => { saveSettings(settings); saveTimer = 0; }, 250); } // ---------- Apply ---------- function onControlChange(item, value, skipDisplay) { persist(item.path, value); const live = item.live !== false && !(item.groupReload); if (live) { // Geschachtelte Pfade (PLAYER.*, CONTROLS.*, AO_LEVEL.*, HEMISPHERE.* …) // im Config-Objekt mutieren; Three.js-Objekte zusätzlich über applyFn. mutateNested(item.path, value); if (applyFns[item.path]) applyFns[item.path](value); } else { markReloadNeeded(); } if (!skipDisplay && item._valueEl) item._valueEl.textContent = fmt(item, value); updateDirty(item); } function markReloadNeeded() { if (reloadDirty) return; reloadDirty = true; if (reloadBanner) reloadBanner.hidden = false; panelEl?.classList.add('dirty'); } // ---------- Anzeige-Formatierung ---------- function fmt(item, value) { if (item.type === 'checkbox') return value ? 'an' : 'aus'; if (item.type === 'select') { const o = item.options.find((o) => o.value === value); return o ? o.label : String(value); } if (item.type === 'color') return toHex(value); if (item.step && item.step < 0.001) return (+value).toFixed(5); if (item.step && item.step < 0.01) return (+value).toFixed(4); if (item.step && item.step < 1) return (+value).toFixed(2); return String(value); } // ---------- Effektiver Wert (Override aus settings, sonst config = Default) ---------- // Für Reload-Werte, die erst beim Neustart greifen, steht die letzte // Änderung nur in `settings` (localStorage), nicht im config-Export. // Daher für Anzeige + Dirty-Check immer `settings` zuerst fragen. function effectiveValue(item) { const v = getPath(settings, item.path); return v !== undefined ? v : getPath(CFG, item.path); } // Code-Default (unabhängig von Overrides), aus dem DEFAULTS-Snapshot. function defaultFor(item) { return getPath(DEFAULTS, item.path); } // Gleichheit mit numerischer Toleranz (Slider-Rundungsfehler abfangen, // sonst bliebe ein per Slider auf Default zurückbewegter Wert "dirty"). function valueEquals(a, b) { if (typeof a === 'number' && typeof b === 'number') { if (a === b) return true; return Math.abs(a - b) < 1e-6 * Math.max(1, Math.abs(a), Math.abs(b)); } return a === b; } // ---------- Dirty-Markierung pro Control ---------- // Zeigt an, dass ein Wert vom Standard abweicht: Label + Wert werden // amber, und der klickbare "↺ Standard: X"-Hinweis erscheint. function updateDirty(item) { if (!item._row) return; const dirty = !valueEquals(effectiveValue(item), defaultFor(item)); item._row.classList.toggle('dirty', dirty); if (item._defaultEl) { item._defaultEl.hidden = !dirty; item._defaultEl.textContent = '↺ ' + fmt(item, defaultFor(item)); } } // Auf Default zurücksetzen (Klick auf den Standard-Hinweis). function resetItem(item) { const d = defaultFor(item); const inp = item._input; if (item.type === 'checkbox') { if (inp) inp.checked = !!d; onControlChange(item, !!d); } else if (item.type === 'select') { if (inp) inp.value = String(d); onControlChange(item, Number(d)); } else if (item.type === 'color') { if (inp) inp.value = toHex(d); onControlChange(item, d); } else { const v = Number(d); if (inp) inp.value = v; onControlChange(item, v); } } // ---------- DOM-Aufbau ---------- function el(tag, cls, html) { const e = document.createElement(tag); if (cls) e.className = cls; if (html != null) e.innerHTML = html; return e; } function buildControl(item) { const row = el('div', 'ctrl-row'); item._row = row; const head = el('div', 'ctrl-head'); const label = el('span', 'ctrl-label', item.label); // Klickbarer Standard-Hinweis (nur sichtbar, wenn der Wert abweicht). const defSpan = el('span', 'ctrl-default'); defSpan.title = 'Auf Standard zurücksetzen'; defSpan.addEventListener('click', (e) => { e.stopPropagation(); resetItem(item); }); item._defaultEl = defSpan; const isReload = item.live === false || item.groupReload; const badge = el('span', 'badge ' + (isReload ? 'reload' : 'live'), isReload ? '⟳' : '●'); badge.title = isReload ? 'Braucht Neustart' : 'Greift sofort'; const right = el('span', 'ctrl-head-right'); right.append(defSpan, badge); head.append(label, right); row.append(head); const val = el('span', 'ctrl-value'); const numSpan = el('span', 'cv-num'); val.append(numSpan); item._valueEl = numSpan; let input; if (item.type === 'checkbox') { input = el('label', 'switch'); const cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = !!effectiveValue(item); cb.addEventListener('change', () => onControlChange(item, cb.checked)); const slider = el('span', 'switch-track'); input.append(cb, slider); item._input = cb; numSpan.textContent = cb.checked ? 'an' : 'aus'; row.append(input, val); updateDirty(item); return row; } if (item.type === 'select') { input = document.createElement('select'); item.options.forEach((o) => { const opt = document.createElement('option'); opt.value = o.value; opt.textContent = o.label; input.append(opt); }); input.value = String(effectiveValue(item)); input.addEventListener('change', () => onControlChange(item, Number(input.value))); item._input = input; numSpan.textContent = fmt(item, Number(input.value)); row.append(input, val); updateDirty(item); return row; } input = document.createElement('input'); input.type = item.type; const cv = effectiveValue(item); if (item.type === 'color') { input.value = toHex(cv); input.addEventListener('input', () => onControlChange(item, fromHex(input.value))); numSpan.textContent = toHex(cv); } else if (item.type === 'number') { if (item.min != null) input.min = item.min; if (item.max != null) input.max = item.max; if (item.step != null) input.step = item.step; input.value = cv; input.addEventListener('change', () => onControlChange(item, Number(input.value))); numSpan.textContent = fmt(item, Number(cv)); } else { // range input.min = item.min; input.max = item.max; input.step = item.step; input.value = cv; input.addEventListener('input', () => onControlChange(item, Number(input.value))); numSpan.textContent = fmt(item, Number(cv)); } item._input = input; row.append(input, val); updateDirty(item); return row; } function buildPanel() { // Gear-Button const gear = el('button', 'gear-btn', '⚙'); gear.title = 'Einstellungen (P)'; gear.addEventListener('click', toggle); document.body.appendChild(gear); // Drawer panelEl = el('aside', 'settings-panel'); const header = el('div', 'sp-header'); header.append(el('h2', '', 'Einstellungen'), (() => { const b = el('button', 'sp-close', '✕'); b.title = 'Schließen (P)'; b.addEventListener('click', close); return b; })()); panelEl.append(header); reloadBanner = el('div', 'reload-banner'); reloadBanner.innerHTML = 'Einige Änderungen brauchen einen Neustart, um zu greifen.'; reloadBanner.hidden = true; panelEl.append(reloadBanner); const body = el('div', 'sp-body'); SCHEMA.forEach((grp) => { const section = el('div', 'sp-group'); section.append(el('div', 'sp-group-title', grp.group + (grp.reload ? ' ⟳' : ''))); grp.items.forEach((item) => { item.groupReload = !!grp.reload; // explicit item.live=false beats group reload? Nein: reload-Gruppe erzwingt reload. if (grp.reload) item.live = false; section.append(buildControl(item)); }); body.append(section); }); panelEl.append(body); // Footer const footer = el('div', 'sp-footer'); const restartBtn = el('button', 'sp-btn restart', '⟳ Neu starten'); restartBtn.title = 'Seite neu laden'; restartBtn.addEventListener('click', () => location.reload()); const resetBtn = el('button', 'sp-btn reset', '↺ Auf Standard zurücksetzen'); resetBtn.title = 'Alle Werte löschen und neu laden'; resetBtn.addEventListener('click', () => { if (confirm('Alle Einstellungen auf die Standardwerte zurücksetzen?')) { clearSettings(); location.reload(); } }); copyBtn = el('button', 'sp-btn copy', '⧉ Werte kopieren'); copyBtn.title = 'Aktuelle Werte als JSON in die Zwischenablage'; copyBtn.addEventListener('click', async () => { try { await navigator.clipboard.writeText(JSON.stringify(loadSettings(), null, 2)); copyBtn.textContent = '✓ Kopiert'; setTimeout(() => (copyBtn.textContent = '⧉ Werte kopieren'), 1200); } catch { copyBtn.textContent = '✗ Fehler'; } }); footer.append(restartBtn, resetBtn, copyBtn); panelEl.append(footer); document.body.appendChild(panelEl); } // ---------- Öffnen / Schließen ---------- function open() { if (!panelEl) return; panelEl.classList.add('open'); document.body.classList.add('panel-open'); // HUD/Hotbar/Crosshair verstecken wasLocked = !!document.pointerLockElement; if (document.pointerLockElement) document.exitPointerLock(); } function close() { if (!panelEl) return; panelEl.classList.remove('open'); document.body.classList.remove('panel-open'); if (wasLocked && onRequestLockFn) onRequestLockFn(); } function toggle() { if (!panelEl) return; panelEl.classList.contains('open') ? close() : open(); } // ---------- Tastatur (P) ---------- function onKey(e) { if (e.code !== 'KeyP') return; const t = e.target; if (t && (t.tagName === 'INPUT' || t.tagName === 'SELECT' || t.tagName === 'TEXTAREA')) return; // Vor Spielstart (Overlay sichtbar) nicht umschalten if (overlayEl && !overlayEl.classList.contains('hidden')) return; e.preventDefault(); toggle(); } // ---------- Init ---------- export function initPanel({ onRequestLock } = {}) { onRequestLockFn = onRequestLock || null; overlayEl = document.getElementById('overlay'); buildPanel(); window.addEventListener('keydown', onKey); }