Files
minecraft-clone/js/panel.js
T
minecraft-cloneandClaude 6e8e207afa feat: live settings panel (≈45 Werte, Live/Reload, Persistenz, AO live)
- panel.js: Drawer-Panel (P / ⚙), schema-getrieben, ●live/⟳reload-Badges
- Werte direkt im Panel einstellbar statt in config.js grabben
- Live-Apply über mutateNested (nur Plain-Objekte, nie Modul-Namespace)
  + registerApply-Callbacks für Three.js-Objekte (FOV, Belichtung, Fog, Licht, Schatten-Frustum, Bloom)
- PLAYER.*/CONTROLS.* live (player.js liest jeden Frame neu)
- AO jetzt live: debounced world.markAllDirty() remesht alle Chunks ohne F5
- Reload-Pflichtige Werte persistiert + blinkender Neu-Starten-Button
  (Seed, Meereshöhe, Spawn, MSAA, Tone-Mapping, Schatten an/Größe, AO-Level)
- Persistenz: localStorage (debounced), config.js lädt beim Start
- HUD/Overlay ausgeblendet, solange das Panel offen ist (Welt frei sichtbar)
- Buttons: Neu starten, Auf Standard zurücksetzen, Werte kopieren (JSON)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-21 16:31:25 +02:00

412 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============================================================
// 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 } 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.05, 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);
}
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);
}
// ---------- Wert eines Controls aus config lesen ----------
function currentValue(item) {
return getPath(CFG, item.path);
}
// ---------- 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');
const head = el('div', 'ctrl-head');
const label = el('span', 'ctrl-label', item.label);
const badge = el('span', 'badge ' + (item.live === false || item.groupReload ? 'reload' : 'live'),
item.live === false || item.groupReload ? '⟳' : '●');
badge.title = item.live === false || item.groupReload ? 'Braucht Neustart' : 'Greift sofort';
head.append(label, badge);
row.append(head);
const val = el('span', 'ctrl-value');
item._valueEl = val;
let input;
if (item.type === 'checkbox') {
input = el('label', 'switch');
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.checked = !!currentValue(item);
cb.addEventListener('change', () => onControlChange(item, cb.checked));
const slider = el('span', 'switch-track');
input.append(cb, slider);
val.textContent = cb.checked ? 'an' : 'aus';
row.append(input, val);
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(currentValue(item));
input.addEventListener('change', () => onControlChange(item, Number(input.value)));
val.textContent = fmt(item, Number(input.value));
row.append(input, val);
return row;
}
input = document.createElement('input');
input.type = item.type;
const cv = currentValue(item);
if (item.type === 'color') {
input.value = toHex(cv);
input.addEventListener('input', () => onControlChange(item, fromHex(input.value)));
val.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)));
val.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)));
val.textContent = fmt(item, Number(cv));
}
row.append(input, val);
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 <b>Neustart</b>, 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);
}