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>
This commit is contained in:
1 parent
d6f6774815
commit
6e8e207afa
7 files changed
+775
-78
No files matched your search
+1
-1
@@ -25,7 +25,7 @@
|
||||
<div><b>WASD</b> Laufen <b>Leertaste</b> Springen</div>
|
||||
<div><b>Maus</b> Umsehen <b>Shift</b> Sprint / Runter (Fly)</div>
|
||||
<div><b>Linke Maustaste</b> Abbauen <b>Rechte Maustaste</b> Setzen</div>
|
||||
<div><b>F</b> Flug-Modus <b>1–6</b> Block wählen <b>Esc</b> Pause</div>
|
||||
<div><b>F</b> Flug-Modus <b>P</b> / <b>⚙</b> Einstellungen <b>1–6</b> Block wählen <b>Esc</b> Pause</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+111
-59
@@ -7,87 +7,65 @@
|
||||
// constants.js und sollten NICHT geändert werden (sonst
|
||||
// drohen Mesh/Storage-Fehler).
|
||||
//
|
||||
// Nach jeder Änderung: Seite HARD-REFRESHEN (Ctrl+Shift+R),
|
||||
// 👉 Live anpassen: Im Spiel **P** drücken (oder ⚙ oben
|
||||
// rechts). Grafik-Werte greifen sofort, Welt-/Performance-
|
||||
// Werte brauchen einen Neustart (Button im Panel).
|
||||
// Alle Änderungen werden in localStorage gespeichert und
|
||||
// überschreiben diese Defaults beim Laden.
|
||||
//
|
||||
// Nach jeder Direktänderung hier: HARD-REFRESH (Ctrl+Shift+R),
|
||||
// damit der Browser die Module nicht aus dem Cache lädt.
|
||||
// ============================================================
|
||||
|
||||
import * as THREE from 'three';
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// 🌍 WELT
|
||||
// ────────────────────────────────────────────────────────────
|
||||
export const WORLD_SEED = 1337; // gleicher Seed = gleiche Welt. Beliebige ganze Zahl.
|
||||
export const SEA_LEVEL = 26; // Höhe der Wasseroberfläche (muss < CHUNK_HEIGHT=64 bleiben)
|
||||
// Primitives sind `let`, damit gespeicherte Overrides (localStorage)
|
||||
// sie beim Laden überschreiben können. Objekte bleiben `const` und
|
||||
// werden per Object.assign gemutiert.
|
||||
export let WORLD_SEED = 1337; // gleicher Seed = gleiche Welt. Beliebige ganze Zahl.
|
||||
export let SEA_LEVEL = 26; // Höhe der Wasseroberfläche (muss < CHUNK_HEIGHT=64 bleiben)
|
||||
|
||||
// Spawn-Position (X/Z in Blöcken; Y wird automatisch auf den Boden gesetzt).
|
||||
export const SPAWN = { x: 8, z: 8 };
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// ⚡ PERFORMANCE
|
||||
// ────────────────────────────────────────────────────────────
|
||||
export const RENDER_DISTANCE = 6; // Chunk-Radius um den Spieler (höher = weiter sichtbar, mehr Last)
|
||||
export const MAX_OPS_PER_FRAME = 2; // Chunk-Generierung/Meshing pro Frame (höher = lädt schneller, ruckelt eher)
|
||||
export const MAX_PIXEL_RATIO = 2; // Cap der Gerät-Pixelratio (2 = scharf; 1 = schneller auf HiDPI-Screens)
|
||||
export let RENDER_DISTANCE = 6; // Chunk-Radius um den Spieler (höher = weiter sichtbar, mehr Last)
|
||||
export let MAX_OPS_PER_FRAME = 2; // Chunk-Generierung/Meshing pro Frame (höher = lädt schneller, ruckelt eher)
|
||||
export let MAX_PIXEL_RATIO = 2; // Cap der Gerät-Pixelratio (2 = scharf; 1 = schneller auf HiDPI-Screens)
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// 🎨 RENDERER & TONE-MAPPING
|
||||
// ────────────────────────────────────────────────────────────
|
||||
export const ANTIALIAS = true; // MSAA direkt am Renderer (Kantenglättung ohne Composer)
|
||||
export const MSAA_SAMPLES = 4; // MSAA-Stufen im Post-Processing-Composer (WebGL2; 0 = aus)
|
||||
export const TONE_MAPPING = THREE.ACESFilmicToneMapping; // "Color Grading". Alternativen: THREE.Linear / Reinhard / Cineon
|
||||
export const TONE_MAPPING_EXPOSURE = 1.1; // globale Helligkeit (höher = heller). ACES dunkelt leicht ab.
|
||||
export let ANTIALIAS = true; // MSAA direkt am Renderer (Kantenglättung ohne Composer)
|
||||
export let MSAA_SAMPLES = 4; // MSAA-Stufen im Post-Processing-Composer (WebGL2; 0 = aus)
|
||||
export let TONE_MAPPING = THREE.ACESFilmicToneMapping; // "Color Grading". Alternativen: THREE.Linear / Reinhard / Cineon
|
||||
export let TONE_MAPPING_EXPOSURE = 1.1; // globale Helligkeit (höher = heller). ACES dunkelt leicht ab.
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// 🌫️ HIMMEL & NEBEL
|
||||
// ────────────────────────────────────────────────────────────
|
||||
export const SKY_COLOR = 0xaaccff; // Hintergrund- und Nebelfarbe
|
||||
export const FOG_NEAR = 64; // Block-Distanz, ab der Nebel einsetzt (≈ (RENDER_DISTANCE-2)*16)
|
||||
export const FOG_FAR = 96; // Block-Distanz, ab der Nebel ganz undurchsichtig ist (≈ RENDER_DISTANCE*16)
|
||||
// Hinweis: bei Änderung von RENDER_DISTANCE FOG_NEAR/FOG_FAR passend nachziehen.
|
||||
export let SKY_COLOR = 0xaaccff; // Hintergrund- und Nebelfarbe
|
||||
export let FOG_NEAR = 64; // Block-Distanz, ab der Nebel einsetzt (≈ (RENDER_DISTANCE-2)*16)
|
||||
export let FOG_FAR = 96; // Block-Distanz, ab der Nebel ganz undurchsichtig ist (≈ RENDER_DISTANCE*16)
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// 📷 KAMERA
|
||||
// ────────────────────────────────────────────────────────────
|
||||
export const CAMERA_FOV = 72; // Sichtfeld in Grad (höher = weiterer Blickwinkel / Fisheye)
|
||||
export const CAMERA_NEAR = 0.1;
|
||||
export const CAMERA_FAR = 1000;
|
||||
export let CAMERA_FOV = 72; // Sichtfeld in Grad (höher = weiterer Blickwinkel / Fisheye)
|
||||
export let CAMERA_NEAR = 0.1;
|
||||
export let CAMERA_FAR = 1000;
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// 💡 BELEUCHTUNG
|
||||
// ────────────────────────────────────────────────────────────
|
||||
export const HEMISPHERE = { sky: 0xbfdfff, ground: 0x5a6b3a, intensity: 1.05 }; // weiche Aufhellung von oben/unten
|
||||
export const SUN = { color: 0xfff4d6, intensity: 1.2 }; // gerichtetes Hauptlicht
|
||||
// Sonnenrichtung (wird intern normalisiert). Bestimmt, aus welcher Seite Licht/Schatten kommen.
|
||||
export const SUN_DIRECTION = [0.55, 0.72, 0.42];
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// 🌑 SCHATTEN
|
||||
// ────────────────────────────────────────────────────────────
|
||||
export const SHADOWS_ENABLED = true;
|
||||
export const SHADOW_MAP_SIZE = 2048; // Schatten-Textur-Auflösung (höher = schärfer + mehr Last; 1024/2048 typisch)
|
||||
export const SHADOW_AREA = 44; // halbe Kantenlänge des Schatten-Frustums um den Spieler (Blöcke)
|
||||
export const SHADOW_BIAS = 0.0002; // gegen "Shadow Acne" (strichartige Selbstschatten)
|
||||
export const SHADOW_NORMAL_BIAS = 0.02; // gegen Acne auf Voxelflächen (in Normalenrichtung verschieben)
|
||||
export let SHADOWS_ENABLED = true;
|
||||
export let SHADOW_MAP_SIZE = 2048; // Schatten-Textur-Auflösung (höher = schärfer + mehr Last; 1024/2048 typisch)
|
||||
export let SHADOW_AREA = 44; // halbe Kantenlänge des Schatten-Frustums um den Spieler (Blöcke)
|
||||
export let SHADOW_BIAS = 0.0002; // gegen "Shadow Acne" (strichartige Selbstschatten)
|
||||
export let SHADOW_NORMAL_BIAS = 0.02; // gegen Acne auf Voxelflächen (in Normalenrichtung verschieben)
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// ✨ POST-PROCESSING (Bloom / Glanz)
|
||||
// ────────────────────────────────────────────────────────────
|
||||
export const BLOOM_ENABLED = true;
|
||||
export let BLOOM_ENABLED = true;
|
||||
export const BLOOM = { strength: 0.4, radius: 0.5, threshold: 0.55 };
|
||||
// strength = Glanz-Stärke (höher = stärkeres Leuchten)
|
||||
// radius = Ausbreitung des Glanzes
|
||||
// threshold = nur Pixel HELLER als dieser Wert glühen (niedriger = mehr glüht)
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// 🔲 AMBIENT OCCLUSION (Eck-Verdunkelung = "Smooth Lighting")
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// AO-Stufe pro Vertex: Index 0 = stärkste Verdeckung (dunkelste Ecke),
|
||||
// 3 = keine Verdeckung. Niedrigere Werte = dunklere Ecken/Kanten.
|
||||
export const AO_LEVEL = [0.2, 0.4, 0.5, 1.8];
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// 🚶 SPIELER (Physik & Dimensionen)
|
||||
// ────────────────────────────────────────────────────────────
|
||||
export const PLAYER = {
|
||||
HALF: 0.3, // halbe Spielerbreite (X/Z) — AABB-Kollisionskasten
|
||||
HEIGHT: 1.8, // Körperhöhe
|
||||
@@ -100,12 +78,86 @@ export const PLAYER = {
|
||||
MAX_FALL: 50, // max. Fallgeschwindigkeit (Terminal-Speed)
|
||||
};
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// 🖱️ STEUERUNG
|
||||
// ────────────────────────────────────────────────────────────
|
||||
export const MOUSE_SENSITIVITY = 0.0022; // Maus-Look-Empfindlichkeit (höher = schnellere Drehung)
|
||||
// Steuerungs-Werte (live änderbar). Zusammen im Objekt, damit das Panel
|
||||
// die Properties mutieren kann und Spieler/Raycast sie live sehen.
|
||||
export const CONTROLS = {
|
||||
MOUSE_SENSITIVITY: 0.0022, // Maus-Look-Empfindlichkeit (höher = schnellere Drehung)
|
||||
REACH: 6, // Reichweite für Abbauen/Bauen (Blöcke)
|
||||
};
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// 🎯 GAMEPLAY
|
||||
// Defaults-Snapshot (für "Zurücksetzen" im Panel)
|
||||
// ────────────────────────────────────────────────────────────
|
||||
export const REACH = 6; // Reichweite für Abbauen/Bauen (Blöcke)
|
||||
// Wird VOR dem Anwenden gespeicherter Overrides erzeugt und
|
||||
// enthält die ursprünglichen Code-Defaults (als tiefe Kopie).
|
||||
const DEFAULTS = JSON.parse(JSON.stringify({
|
||||
WORLD_SEED, SEA_LEVEL, SPAWN,
|
||||
RENDER_DISTANCE, MAX_OPS_PER_FRAME, MAX_PIXEL_RATIO,
|
||||
ANTIALIAS, MSAA_SAMPLES, TONE_MAPPING, TONE_MAPPING_EXPOSURE,
|
||||
SKY_COLOR, FOG_NEAR, FOG_FAR,
|
||||
CAMERA_FOV, CAMERA_NEAR, CAMERA_FAR,
|
||||
HEMISPHERE, SUN, SUN_DIRECTION,
|
||||
SHADOWS_ENABLED, SHADOW_MAP_SIZE, SHADOW_AREA, SHADOW_BIAS, SHADOW_NORMAL_BIAS,
|
||||
BLOOM_ENABLED, BLOOM,
|
||||
AO_LEVEL,
|
||||
PLAYER,
|
||||
CONTROLS,
|
||||
}));
|
||||
export { DEFAULTS };
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Persistenz (für das Live-Settings-Panel)
|
||||
// ────────────────────────────────────────────────────────────
|
||||
const STORAGE_KEY = 'mc-clone-settings';
|
||||
|
||||
/** Gespeicherte Overrides aus localStorage (oder {}). */
|
||||
export function loadSettings() {
|
||||
try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}'); }
|
||||
catch { return {}; }
|
||||
}
|
||||
|
||||
/** Kompletten Settings-Stand schreiben. */
|
||||
export function saveSettings(obj) {
|
||||
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(obj)); } catch { /* localStorage gesperrt */ }
|
||||
}
|
||||
|
||||
/** Gespeicherte Overrides löschen → nächster Load nutzt Code-Defaults. */
|
||||
export function clearSettings() {
|
||||
try { localStorage.removeItem(STORAGE_KEY); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// Gespeicherte Overrides auf die Exporte anwenden — passiert beim Modul-Laden
|
||||
// (also VOR jedem Konsumenten, da ESM config.js zuerst vollständig auswertet).
|
||||
(() => {
|
||||
const s = loadSettings();
|
||||
if (!s) return;
|
||||
if (s.WORLD_SEED != null) WORLD_SEED = s.WORLD_SEED;
|
||||
if (s.SEA_LEVEL != null) SEA_LEVEL = s.SEA_LEVEL;
|
||||
if (s.RENDER_DISTANCE != null) RENDER_DISTANCE = s.RENDER_DISTANCE;
|
||||
if (s.MAX_OPS_PER_FRAME != null) MAX_OPS_PER_FRAME = s.MAX_OPS_PER_FRAME;
|
||||
if (s.MAX_PIXEL_RATIO != null) MAX_PIXEL_RATIO = s.MAX_PIXEL_RATIO;
|
||||
if (typeof s.ANTIALIAS === 'boolean') ANTIALIAS = s.ANTIALIAS;
|
||||
if (s.MSAA_SAMPLES != null) MSAA_SAMPLES = s.MSAA_SAMPLES;
|
||||
if (s.TONE_MAPPING != null) TONE_MAPPING = s.TONE_MAPPING;
|
||||
if (s.TONE_MAPPING_EXPOSURE != null) TONE_MAPPING_EXPOSURE = s.TONE_MAPPING_EXPOSURE;
|
||||
if (s.SKY_COLOR != null) SKY_COLOR = s.SKY_COLOR;
|
||||
if (s.FOG_NEAR != null) FOG_NEAR = s.FOG_NEAR;
|
||||
if (s.FOG_FAR != null) FOG_FAR = s.FOG_FAR;
|
||||
if (s.CAMERA_FOV != null) CAMERA_FOV = s.CAMERA_FOV;
|
||||
if (s.CAMERA_NEAR != null) CAMERA_NEAR = s.CAMERA_NEAR;
|
||||
if (s.CAMERA_FAR != null) CAMERA_FAR = s.CAMERA_FAR;
|
||||
if (typeof s.SHADOWS_ENABLED === 'boolean') SHADOWS_ENABLED = s.SHADOWS_ENABLED;
|
||||
if (s.SHADOW_MAP_SIZE != null) SHADOW_MAP_SIZE = s.SHADOW_MAP_SIZE;
|
||||
if (s.SHADOW_AREA != null) SHADOW_AREA = s.SHADOW_AREA;
|
||||
if (s.SHADOW_BIAS != null) SHADOW_BIAS = s.SHADOW_BIAS;
|
||||
if (s.SHADOW_NORMAL_BIAS != null) SHADOW_NORMAL_BIAS = s.SHADOW_NORMAL_BIAS;
|
||||
if (typeof s.BLOOM_ENABLED === 'boolean') BLOOM_ENABLED = s.BLOOM_ENABLED;
|
||||
if (s.SPAWN) Object.assign(SPAWN, s.SPAWN);
|
||||
if (s.HEMISPHERE) Object.assign(HEMISPHERE, s.HEMISPHERE);
|
||||
if (s.SUN) Object.assign(SUN, s.SUN);
|
||||
if (s.SUN_DIRECTION) for (let i = 0; i < 3; i++) if (s.SUN_DIRECTION[i] != null) SUN_DIRECTION[i] = s.SUN_DIRECTION[i];
|
||||
if (s.BLOOM) Object.assign(BLOOM, s.BLOOM);
|
||||
if (s.AO_LEVEL) for (let i = 0; i < 4; i++) if (s.AO_LEVEL[i] != null) AO_LEVEL[i] = s.AO_LEVEL[i];
|
||||
if (s.PLAYER) Object.assign(PLAYER, s.PLAYER);
|
||||
if (s.CONTROLS) Object.assign(CONTROLS, s.CONTROLS);
|
||||
})();
|
||||
+60
-5
@@ -15,7 +15,7 @@ import {
|
||||
HEMISPHERE, SUN, SUN_DIRECTION,
|
||||
SHADOWS_ENABLED, SHADOW_MAP_SIZE, SHADOW_AREA, SHADOW_BIAS, SHADOW_NORMAL_BIAS,
|
||||
BLOOM_ENABLED, BLOOM,
|
||||
PLAYER, SPAWN, REACH,
|
||||
PLAYER, SPAWN, CONTROLS,
|
||||
} from './config.js';
|
||||
import { buildAtlasTexture } from './textures.js';
|
||||
import { World } from './world.js';
|
||||
@@ -23,6 +23,7 @@ import { Player } from './player.js';
|
||||
import { Input } from './input.js';
|
||||
import { raycastVoxel } from './raycast.js';
|
||||
import { getBiome } from './worldgen.js';
|
||||
import { initPanel, registerApply } from './panel.js';
|
||||
|
||||
const BIOME_NAME = { [BIOME.PLAINS]: 'Plains', [BIOME.FOREST]: 'Wald', [BIOME.DESERT]: 'Wüste' };
|
||||
|
||||
@@ -98,7 +99,10 @@ const bloom = new UnrealBloomPass(
|
||||
BLOOM.radius,
|
||||
BLOOM.threshold,
|
||||
);
|
||||
if (BLOOM_ENABLED) composer.addPass(bloom);
|
||||
// Bloom-Pass immer anhängen, nur aktivieren/deaktivieren – so lässt er sich
|
||||
// über das Settings-Panel live ein-/ausschalten (pass.enabled).
|
||||
composer.addPass(bloom);
|
||||
bloom.enabled = BLOOM_ENABLED;
|
||||
composer.addPass(new OutputPass());
|
||||
|
||||
// ---------- Welt & Spieler ----------
|
||||
@@ -158,7 +162,12 @@ overlay.addEventListener('click', () => {
|
||||
input.requestLock();
|
||||
});
|
||||
document.addEventListener('pointerlockchange', () => {
|
||||
if (!document.pointerLockElement) overlay.classList.remove('hidden');
|
||||
// Start-Overlay nur zeigen, wenn der Lock verloren ging UND nicht das
|
||||
// Settings-Panel geöffnet ist – das Panel gibt den Pointer-Lock selbst
|
||||
// frei (open()), darf dabei aber nicht die Welt verdecken.
|
||||
if (!document.pointerLockElement && !document.body.classList.contains('panel-open')) {
|
||||
overlay.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// ---------- Resize ----------
|
||||
@@ -194,6 +203,52 @@ function wouldIntersectPlayer(bx, by, bz) {
|
||||
|
||||
const _dir = new THREE.Vector3();
|
||||
|
||||
// ---------- Settings-Panel: Live-Apply-Callbacks ----------
|
||||
// Performance-Werte werden in der Loop gelesen → lokal halten und per
|
||||
// Apply-Callback mutieren (ESM-lets lassen sich von außen nicht schreiben).
|
||||
let renderDistance = RENDER_DISTANCE;
|
||||
let maxOpsPerFrame = MAX_OPS_PER_FRAME;
|
||||
registerApply('RENDER_DISTANCE', (v) => { renderDistance = v; });
|
||||
registerApply('MAX_OPS_PER_FRAME', (v) => { maxOpsPerFrame = v; });
|
||||
registerApply('MAX_PIXEL_RATIO', (v) => renderer.setPixelRatio(Math.min(window.devicePixelRatio, v)));
|
||||
registerApply('TONE_MAPPING_EXPOSURE', (v) => { renderer.toneMappingExposure = v; });
|
||||
registerApply('SKY_COLOR', (v) => { scene.background.setHex(v); scene.fog.color.setHex(v); });
|
||||
registerApply('FOG_NEAR', (v) => { scene.fog.near = v; });
|
||||
registerApply('FOG_FAR', (v) => { scene.fog.far = v; });
|
||||
registerApply('CAMERA_FOV', (v) => { camera.fov = v; camera.updateProjectionMatrix(); });
|
||||
registerApply('CAMERA_NEAR', (v) => { camera.near = v; camera.updateProjectionMatrix(); });
|
||||
registerApply('CAMERA_FAR', (v) => { camera.far = v; camera.updateProjectionMatrix(); });
|
||||
registerApply('HEMISPHERE.intensity', (v) => { hemi.intensity = v; });
|
||||
registerApply('HEMISPHERE.sky', (v) => hemi.color.setHex(v));
|
||||
registerApply('HEMISPHERE.ground', (v) => hemi.groundColor.setHex(v));
|
||||
registerApply('SUN.intensity', (v) => { sun.intensity = v; });
|
||||
registerApply('SUN.color', (v) => sun.color.setHex(v));
|
||||
registerApply('SHADOW_AREA', (v) => {
|
||||
sun.shadow.camera.left = -v; sun.shadow.camera.right = v;
|
||||
sun.shadow.camera.top = v; sun.shadow.camera.bottom = -v;
|
||||
sun.shadow.camera.updateProjectionMatrix();
|
||||
});
|
||||
registerApply('SHADOW_BIAS', (v) => { sun.shadow.bias = v; });
|
||||
registerApply('SHADOW_NORMAL_BIAS', (v) => { sun.shadow.normalBias = v; });
|
||||
registerApply('BLOOM_ENABLED', (v) => { bloom.enabled = v; });
|
||||
registerApply('BLOOM.strength', (v) => { bloom.strength = v; });
|
||||
registerApply('BLOOM.radius', (v) => { bloom.radius = v; });
|
||||
registerApply('BLOOM.threshold', (v) => { bloom.threshold = v; });
|
||||
// AO live: AO_LEVEL ist pro Vertex in die Chunk-Meshes gebacken. Eine Änderung
|
||||
// → debounced Neubau aller geladenen Chunks (panel.js mutiert AO_LEVEL im
|
||||
// Config-Array; das Streaming verteilt den Neubau über mehrere Frames).
|
||||
let aoRemeshTimer = 0;
|
||||
function scheduleAoRemesh() {
|
||||
if (aoRemeshTimer) clearTimeout(aoRemeshTimer);
|
||||
aoRemeshTimer = setTimeout(() => { aoRemeshTimer = 0; world.markAllDirty(); }, 150);
|
||||
}
|
||||
for (const p of ['AO_LEVEL.0', 'AO_LEVEL.1', 'AO_LEVEL.2', 'AO_LEVEL.3']) {
|
||||
registerApply(p, scheduleAoRemesh);
|
||||
}
|
||||
// PLAYER.* und CONTROLS.* braucht keinen Callback: player.js/raycast lesen
|
||||
// die Config-Objekte live; panel.js mutiert sie direkt.
|
||||
initPanel({ onRequestLock: () => input.requestLock() });
|
||||
|
||||
// ---------- Game-Loop ----------
|
||||
let last = performance.now();
|
||||
function frame(now) {
|
||||
@@ -213,7 +268,7 @@ function frame(now) {
|
||||
const hit = raycastVoxel(
|
||||
(x, y, z) => world.getBlock(x, y, z),
|
||||
camera.position.x, camera.position.y, camera.position.z,
|
||||
_dir.x, _dir.y, _dir.z, REACH
|
||||
_dir.x, _dir.y, _dir.z, CONTROLS.REACH
|
||||
);
|
||||
if (hit) {
|
||||
hlEdges.visible = true;
|
||||
@@ -240,7 +295,7 @@ function frame(now) {
|
||||
input.endFrame();
|
||||
|
||||
// Streaming (immer, auch im Pause-Menue, damit die Welt laedt)
|
||||
world.updateStreaming(player.pos[0], player.pos[2], RENDER_DISTANCE, MAX_OPS_PER_FRAME);
|
||||
world.updateStreaming(player.pos[0], player.pos[2], renderDistance, maxOpsPerFrame);
|
||||
|
||||
// Sonne folgt dem Spieler -> Shadow-Frustum bleibt um den Spieler zentriert.
|
||||
sun.position.set(
|
||||
|
||||
+411
@@ -0,0 +1,411 @@
|
||||
// ============================================================
|
||||
// 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);
|
||||
}
|
||||
+16
-13
@@ -1,10 +1,12 @@
|
||||
// First-Person-Spieler: Bewegung, Gravitation, AABB-Kollision, Springen,
|
||||
// Flug-Modus, Schwimmen. yaw/pitch steuern die Kamera (extern gesetzt).
|
||||
//
|
||||
// WICHTIG: PLAYER- und CONTROLS-Werte werden LIVE aus config.js gelesen
|
||||
// (jeder Frame), nicht beim Laden destrukturiert. So kann das Settings-
|
||||
// Panel Geschwindigkeit/Gravitation/Maus-Empfindlichkeit usw. zur
|
||||
// Laufzeit ändern.
|
||||
import { isSolid, BLOCK } from './constants.js';
|
||||
import { PLAYER, MOUSE_SENSITIVITY } from './config.js';
|
||||
|
||||
// Werte aus config.js (PLAYER). Lokal gebunden, damit die Verwendungen kurz bleiben.
|
||||
const { HALF, HEIGHT, EYE, GRAVITY, JUMP_VEL, WALK, SPRINT, FLY_SPEED, MAX_FALL } = PLAYER;
|
||||
import { PLAYER, CONTROLS } from './config.js';
|
||||
|
||||
export class Player {
|
||||
constructor(world) {
|
||||
@@ -23,21 +25,21 @@ export class Player {
|
||||
}
|
||||
|
||||
eyePosition(out) {
|
||||
out.set(this.pos[0], this.pos[1] + EYE, this.pos[2]);
|
||||
out.set(this.pos[0], this.pos[1] + PLAYER.EYE, this.pos[2]);
|
||||
return out;
|
||||
}
|
||||
|
||||
inWater() {
|
||||
// Pruefe den Block auf Augen- und Fuss-Hoehe
|
||||
const ex = Math.floor(this.pos[0]);
|
||||
const ey = Math.floor(this.pos[1] + EYE * 0.6);
|
||||
const ey = Math.floor(this.pos[1] + PLAYER.EYE * 0.6);
|
||||
const ez = Math.floor(this.pos[2]);
|
||||
return this.world.getBlock(ex, ey, ez) === BLOCK.WATER;
|
||||
}
|
||||
|
||||
update(input, dt) {
|
||||
// --- Maus-Look ---
|
||||
const sens = MOUSE_SENSITIVITY;
|
||||
// --- Maus-Look (Empfindlichkeit live aus config) ---
|
||||
const sens = CONTROLS.MOUSE_SENSITIVITY;
|
||||
this.yaw -= input.mouseDX * sens;
|
||||
this.pitch -= input.mouseDY * sens;
|
||||
const lim = Math.PI / 2 - 0.001;
|
||||
@@ -69,7 +71,7 @@ export class Player {
|
||||
// ShiftLeft/Right dient im Fly-Modus gleichzeitig als "Runter" – kein
|
||||
// Konflikt, da Sprint nur beim Gehen greift (Fly nutzt konstante FLY_SPEED).
|
||||
const sprint = input.down('ShiftLeft') || input.down('ShiftRight');
|
||||
const speed = this.flying ? FLY_SPEED : (sprint ? SPRINT : WALK);
|
||||
const speed = this.flying ? PLAYER.FLY_SPEED : (sprint ? PLAYER.SPRINT : PLAYER.WALK);
|
||||
|
||||
if (this.flying) {
|
||||
this.vel[0] = mx * speed;
|
||||
@@ -85,14 +87,14 @@ export class Player {
|
||||
this.vel[2] = mz * move;
|
||||
// Gravitation / Schwimmen
|
||||
if (water) {
|
||||
this.vel[1] -= GRAVITY * 0.28 * dt;
|
||||
this.vel[1] -= PLAYER.GRAVITY * 0.28 * dt;
|
||||
if (this.vel[1] < -6) this.vel[1] = -6;
|
||||
if (input.down('Space')) this.vel[1] = 4.0; // nach oben schwimmen
|
||||
} else {
|
||||
this.vel[1] -= GRAVITY * dt;
|
||||
if (this.vel[1] < -MAX_FALL) this.vel[1] = -MAX_FALL;
|
||||
this.vel[1] -= PLAYER.GRAVITY * dt;
|
||||
if (this.vel[1] < -PLAYER.MAX_FALL) this.vel[1] = -PLAYER.MAX_FALL;
|
||||
if (input.down('Space') && this.onGround) {
|
||||
this.vel[1] = JUMP_VEL;
|
||||
this.vel[1] = PLAYER.JUMP_VEL;
|
||||
this.onGround = false;
|
||||
}
|
||||
}
|
||||
@@ -110,6 +112,7 @@ export class Player {
|
||||
this.pos[axis] += amount;
|
||||
if (amount === 0) return;
|
||||
|
||||
const HALF = PLAYER.HALF, HEIGHT = PLAYER.HEIGHT;
|
||||
const x = this.pos[0], y = this.pos[1], z = this.pos[2];
|
||||
const minX = Math.floor(x - HALF), maxX = Math.floor(x + HALF);
|
||||
const minY = Math.floor(y), maxY = Math.floor(y + HEIGHT - 0.001);
|
||||
|
||||
@@ -97,6 +97,13 @@ export class World {
|
||||
if (c) c.dirty = true;
|
||||
}
|
||||
|
||||
// Alle geladenen Chunks für einen Neubau markieren – z.B. wenn ein global
|
||||
// mesh-relevanter Wert (AO_LEVEL) live geändert wird. updateStreaming baut
|
||||
// sie dann über mehrere Frames (maxOpsPerFrame) verteilt neu.
|
||||
markAllDirty() {
|
||||
for (const c of this.chunks.values()) c.dirty = true;
|
||||
}
|
||||
|
||||
// --- Meshing ---
|
||||
getBlockWorld = (wx, wy, wz) => this.getBlock(wx, wy, wz);
|
||||
|
||||
|
||||
@@ -105,3 +105,172 @@ canvas { display: block; }
|
||||
.panel p { font-size: 16px; margin-bottom: 16px; opacity: 0.9; }
|
||||
.keys { font-size: 13px; line-height: 1.9; opacity: 0.85; }
|
||||
.keys b { color: #ffe08a; }
|
||||
|
||||
/* Settings-Panel offen → Spiel-HUD und Start-Overlay verstecken, damit die
|
||||
Welt unverstellt sichtbar bleibt. Das Overlay verdeckt sonst die ganze Welt,
|
||||
sobald das Panel den Pointer-Lock freigibt (pointerlockchange-Handler). */
|
||||
body.panel-open #overlay,
|
||||
body.panel-open #hud,
|
||||
body.panel-open #hotbar,
|
||||
body.panel-open #crosshair { opacity: 0; pointer-events: none; transition: opacity 0.18s; }
|
||||
|
||||
/* ============================================================
|
||||
Settings-Panel (Drawer von rechts)
|
||||
============================================================ */
|
||||
.gear-btn {
|
||||
position: fixed;
|
||||
top: 12px; right: 12px;
|
||||
width: 40px; height: 40px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255,255,255,0.18);
|
||||
background: rgba(0,0,0,0.45);
|
||||
color: #fff;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
z-index: 20;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: background 0.15s, transform 0.15s;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
.gear-btn:hover { background: rgba(0,0,0,0.65); transform: rotate(30deg); }
|
||||
|
||||
.settings-panel {
|
||||
position: fixed;
|
||||
top: 0; right: 0; bottom: 0;
|
||||
width: 340px;
|
||||
max-width: 92vw;
|
||||
background: rgba(18,22,28,0.96);
|
||||
border-left: 1px solid rgba(255,255,255,0.12);
|
||||
box-shadow: -10px 0 40px rgba(0,0,0,0.5);
|
||||
transform: translateX(105%);
|
||||
transition: transform 0.28s cubic-bezier(0.2,0.8,0.2,1);
|
||||
z-index: 30;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 13px;
|
||||
color: #e8eaed;
|
||||
}
|
||||
.settings-panel.open { transform: translateX(0); }
|
||||
|
||||
.sp-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.1);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.sp-header h2 { font-size: 17px; font-weight: 600; letter-spacing: 0.5px; }
|
||||
.sp-close {
|
||||
background: none; border: none; color: #bdc1c6;
|
||||
font-size: 18px; cursor: pointer; padding: 2px 8px; border-radius: 4px;
|
||||
}
|
||||
.sp-close:hover { background: rgba(255,255,255,0.1); color: #fff; }
|
||||
|
||||
.reload-banner {
|
||||
background: rgba(255,180,60,0.16);
|
||||
border-bottom: 1px solid rgba(255,180,60,0.35);
|
||||
color: #ffd9a0;
|
||||
padding: 9px 16px;
|
||||
font-size: 12px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.sp-body {
|
||||
overflow-y: auto;
|
||||
padding: 8px 14px 16px;
|
||||
flex: 1 1 auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255,255,255,0.25) transparent;
|
||||
}
|
||||
.sp-body::-webkit-scrollbar { width: 8px; }
|
||||
.sp-body::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.22); border-radius: 4px; }
|
||||
|
||||
.sp-group { padding: 10px 0; border-bottom: 1px solid rgba(255,255,255,0.06); }
|
||||
.sp-group:last-child { border-bottom: none; }
|
||||
.sp-group-title {
|
||||
font-size: 11px; text-transform: uppercase; letter-spacing: 1px;
|
||||
color: #8ab4f8; margin-bottom: 8px; font-weight: 600;
|
||||
}
|
||||
|
||||
.ctrl-row { margin: 9px 0; }
|
||||
.ctrl-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 5px; }
|
||||
.ctrl-label { color: #cdd2da; }
|
||||
.ctrl-value {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: #9aa0a6; font-size: 12px;
|
||||
min-width: 56px; text-align: right;
|
||||
}
|
||||
.badge { font-size: 10px; padding: 1px 5px; border-radius: 8px; margin-left: 6px; vertical-align: middle; }
|
||||
.badge.live { background: rgba(80,200,120,0.18); color: #7ee2a0; }
|
||||
.badge.reload { background: rgba(255,180,60,0.18); color: #ffc870; }
|
||||
|
||||
/* Range-Slider */
|
||||
.ctrl-row input[type=range] {
|
||||
width: 100%; height: 5px; -webkit-appearance: none; appearance: none;
|
||||
background: rgba(255,255,255,0.15); border-radius: 3px; outline: none; cursor: pointer;
|
||||
}
|
||||
.ctrl-row input[type=range]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none; appearance: none;
|
||||
width: 16px; height: 16px; border-radius: 50%;
|
||||
background: #8ab4f8; border: 2px solid #1b2a3a; cursor: pointer;
|
||||
}
|
||||
.ctrl-row input[type=range]::-moz-range-thumb {
|
||||
width: 14px; height: 14px; border-radius: 50%;
|
||||
background: #8ab4f8; border: 2px solid #1b2a3a; cursor: pointer;
|
||||
}
|
||||
|
||||
/* Number / Select / Color */
|
||||
.ctrl-row input[type=number] {
|
||||
width: 100%; box-sizing: border-box;
|
||||
background: rgba(255,255,255,0.07); border: 1px solid rgba(255,255,255,0.15);
|
||||
color: #fff; padding: 5px 8px; border-radius: 5px; font-size: 13px;
|
||||
}
|
||||
.ctrl-row select {
|
||||
width: 100%; box-sizing: border-box;
|
||||
background: rgba(255,255,255,0.07); border: 1px solid rgba(255,255,255,0.15);
|
||||
color: #fff; padding: 5px 8px; border-radius: 5px; font-size: 13px;
|
||||
}
|
||||
.ctrl-row input[type=color] {
|
||||
width: 100%; height: 28px; padding: 0; border: 1px solid rgba(255,255,255,0.15);
|
||||
border-radius: 5px; background: transparent; cursor: pointer;
|
||||
}
|
||||
|
||||
/* Toggle-Switch (Checkbox) */
|
||||
.switch { display: inline-flex; align-items: center; cursor: pointer; }
|
||||
.switch input { display: none; }
|
||||
.switch-track {
|
||||
width: 38px; height: 20px; border-radius: 12px;
|
||||
background: rgba(255,255,255,0.18); position: relative; transition: background 0.2s;
|
||||
}
|
||||
.switch-track::after {
|
||||
content: ""; position: absolute; top: 2px; left: 2px;
|
||||
width: 16px; height: 16px; border-radius: 50%; background: #fff;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
.switch input:checked + .switch-track { background: #4caf7d; }
|
||||
.switch input:checked + .switch-track::after { transform: translateX(18px); }
|
||||
.ctrl-row:has(.switch) .ctrl-value { display: none; }
|
||||
|
||||
/* Footer-Buttons */
|
||||
.sp-footer {
|
||||
display: flex; gap: 8px; padding: 12px 14px;
|
||||
border-top: 1px solid rgba(255,255,255,0.1);
|
||||
flex: 0 0 auto; flex-wrap: wrap;
|
||||
}
|
||||
.sp-btn {
|
||||
flex: 1 1 auto; min-width: 90px;
|
||||
background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.15);
|
||||
color: #e8eaed; padding: 8px 6px; border-radius: 6px;
|
||||
font-size: 12px; cursor: pointer; transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
.sp-btn:hover { background: rgba(255,255,255,0.16); }
|
||||
.sp-btn.restart { background: rgba(138,180,248,0.18); border-color: rgba(138,180,248,0.45); }
|
||||
.sp-btn.restart:hover { background: rgba(138,180,248,0.32); }
|
||||
.sp-btn.reset { background: rgba(240,90,90,0.15); border-color: rgba(240,90,90,0.4); }
|
||||
.sp-btn.reset:hover { background: rgba(240,90,90,0.28); }
|
||||
.settings-panel.dirty .sp-btn.restart {
|
||||
animation: pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(138,180,248,0.4); }
|
||||
50% { box-shadow: 0 0 0 5px rgba(138,180,248,0); }
|
||||
}
|
||||
Reference in new issue
Block a user