Files
minecraft-clone/js/panel.js
T
minecraft-cloneandClaude 951cb26f3f feat: dirty-Markierung + Standard-Hinweis + Bloom 0.01-Schritte im Panel
- Werte abweichend vom Standard werden amber markiert
- klickbarer Hinweis '↺ <Standard>' zeigt Originalwert (Bugfix: _defaultEl wurde nie zugewiesen)
- Bloom-Schwelle jetzt in 0.01-Schritten einstellbar

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-21 18:24:35 +02:00

478 lines
19 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, 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 <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);
}