- 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>
478 lines
19 KiB
JavaScript
478 lines
19 KiB
JavaScript
// ============================================================
|
||
// 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);
|
||
}
|