Files
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

322 lines
13 KiB
JavaScript
Raw Permalink 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.
// Haupt-Einstiegspunkt: Scene/Camera/Renderer, Licht, Welt, Spieler, Loop.
import * as THREE from 'three';
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
import { OutputPass } from 'three/addons/postprocessing/OutputPass.js';
import {
BLOCK, BIOME, HOTBAR, BLOCK_COLOR,
} from './constants.js';
import {
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,
PLAYER, SPAWN, CONTROLS,
} from './config.js';
import { buildAtlasTexture } from './textures.js';
import { World } from './world.js';
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' };
// ---------- Renderer / Scene / Camera ----------
const canvas = document.createElement('canvas');
document.body.appendChild(canvas);
const renderer = new THREE.WebGLRenderer({ canvas, antialias: ANTIALIAS });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, MAX_PIXEL_RATIO));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.shadowMap.enabled = SHADOWS_ENABLED;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = TONE_MAPPING;
renderer.toneMappingExposure = TONE_MAPPING_EXPOSURE;
const scene = new THREE.Scene();
scene.background = new THREE.Color(SKY_COLOR);
scene.fog = new THREE.Fog(SKY_COLOR, FOG_NEAR, FOG_FAR);
const camera = new THREE.PerspectiveCamera(CAMERA_FOV, window.innerWidth / window.innerHeight, CAMERA_NEAR, CAMERA_FAR);
camera.rotation.order = 'YXZ';
// ---------- Licht ----------
const hemi = new THREE.HemisphereLight(HEMISPHERE.sky, HEMISPHERE.ground, HEMISPHERE.intensity);
scene.add(hemi);
const sun = new THREE.DirectionalLight(SUN.color, SUN.intensity);
// Schattenwerfer: orthografische Shadow-Camera, die pro Frame dem Spieler folgt
// (kleines Frustum -> hohe Texeldichte + performant, da ferne Chunks cullt).
sun.castShadow = SHADOWS_ENABLED;
sun.shadow.mapSize.set(SHADOW_MAP_SIZE, SHADOW_MAP_SIZE);
sun.shadow.camera.near = 1;
sun.shadow.camera.far = 260;
// halbe Kantenlaenge (Welt-Einheiten) um den Spieler = SHADOW_AREA aus config
sun.shadow.camera.left = -SHADOW_AREA;
sun.shadow.camera.right = SHADOW_AREA;
sun.shadow.camera.top = SHADOW_AREA;
sun.shadow.camera.bottom = -SHADOW_AREA;
sun.shadow.bias = SHADOW_BIAS;
sun.shadow.normalBias = SHADOW_NORMAL_BIAS;
scene.add(sun);
scene.add(sun.target);
// Konstanter Richtungs-Offset; Position/Target werden in der Loop ans Spieler-Auge gesetzt.
const SUN_DIR = new THREE.Vector3(...SUN_DIRECTION).normalize();
const _sunOffset = SUN_DIR.clone().multiplyScalar(120);
// ---------- Texturen & Materialien ----------
// vertexColors aktiviert: der Mesher liefert pro Vertex einen AO-Wert (Grau-
// stufe), der in den Beleuchtungsterm multipliziert wird -> "Smooth Lighting".
const atlas = buildAtlasTexture();
const matOpaque = new THREE.MeshLambertMaterial({ map: atlas, vertexColors: true });
const matWater = new THREE.MeshLambertMaterial({
map: atlas, vertexColors: true, transparent: true, depthWrite: false, side: THREE.DoubleSide,
});
// ---------- Post-Processing ----------
// EffectComposer: RenderPass -> UnrealBloomPass (subtiler Glanz auf hellen
// Flaechen) -> OutputPass (wendet renderer.toneMapping=ACES + sRGB an).
// HalfFloat-Target verhindert Banding im HDR-Bereich; samples=4 aktiviert MSAA,
// da der Composer ansonsten ohne Kantenglaettung in sein Puffer rendert.
const rt = new THREE.WebGLRenderTarget(window.innerWidth, window.innerHeight, {
type: THREE.HalfFloatType,
});
const composer = new EffectComposer(renderer, rt);
// MSAA auf beiden internen Puffern (WebGL2 erlaubt MSAA auf Float-Targets),
// damit die Szene im Composer kantengeglättet wird. Vorsichtshalber auf beiden
// Targets explizit setzen, falls clone() die Property nicht forterbt.
composer.renderTarget1.samples = MSAA_SAMPLES;
composer.renderTarget2.samples = MSAA_SAMPLES;
composer.addPass(new RenderPass(scene, camera));
const bloom = new UnrealBloomPass(
new THREE.Vector2(window.innerWidth, window.innerHeight),
BLOOM.strength,
BLOOM.radius,
BLOOM.threshold,
);
// 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 ----------
const world = new World(scene, { opaque: matOpaque, water: matWater });
const player = new Player(world);
const input = new Input(canvas);
// Spawn-Punkt
{
const sx = SPAWN.x, sz = SPAWN.z;
const groundY = world.highestSolidY(sx, sz);
player.setSpawn(sx + 0.5, groundY + 2, sz + 0.5);
}
camera.position.set(player.pos[0], player.pos[1] + PLAYER.EYE, player.pos[2]);
// Vorab die naechsten Chunks um den Spawn bauen (Boden unter den Fuessen ist
// durch highestSolidY ohnehin gesichert; hier geht's nur um den Start-Blick).
for (let i = 0; i < 12; i++) world.updateStreaming(player.pos[0], player.pos[2], RENDER_DISTANCE, MAX_OPS_PER_FRAME);
// ---------- Highlight-Box ----------
const hlGeo = new THREE.BoxGeometry(1.002, 1.002, 1.002);
const hlEdges = new THREE.LineSegments(
new THREE.EdgesGeometry(hlGeo),
new THREE.LineBasicMaterial({ color: 0x000000, transparent: true, opacity: 0.5 })
);
hlEdges.visible = false;
scene.add(hlEdges);
// ---------- UI: Hotbar ----------
let selected = 0;
const hotbarEl = document.getElementById('hotbar');
HOTBAR.forEach((block, i) => {
const slot = document.createElement('div');
slot.className = 'slot' + (i === 0 ? ' active' : '');
slot.innerHTML = `<span class="num">${i + 1}</span>`;
const sw = document.createElement('div');
sw.className = 'swatch';
sw.style.background = BLOCK_COLOR[block] || '#888';
slot.appendChild(sw);
slot.dataset.index = i;
hotbarEl.appendChild(slot);
});
function setSelected(i) {
selected = (i + HOTBAR.length) % HOTBAR.length;
[...hotbarEl.children].forEach((c, idx) => c.classList.toggle('active', idx === selected));
}
window.addEventListener('keydown', (e) => {
const m = /^Digit([1-6])$/.exec(e.code);
if (m) setSelected(parseInt(m[1], 10) - 1);
});
// ---------- Overlay / Pointer-Lock ----------
const overlay = document.getElementById('overlay');
overlay.addEventListener('click', () => {
overlay.classList.add('hidden');
input.requestLock();
});
document.addEventListener('pointerlockchange', () => {
// 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 ----------
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
composer.setSize(window.innerWidth, window.innerHeight);
});
// ---------- HUD ----------
const hud = document.getElementById('hud');
let fpsAcc = 0, fpsFrames = 0, fps = 0;
// ---------- Hilfsfunktion: Blickrichtung ----------
const _eye = new THREE.Vector3();
function aimDir(out) {
const cp = Math.cos(player.pitch), sp = Math.sin(player.pitch);
const sy = Math.sin(player.yaw), cy = Math.cos(player.yaw);
out.set(-sy * cp, sp, -cy * cp).normalize();
return out;
}
// Blockplatzierung pruefen: nicht in den Spieler hineinsetzen
function wouldIntersectPlayer(bx, by, bz) {
const minX = player.pos[0] - PLAYER.HALF, maxX = player.pos[0] + PLAYER.HALF;
const minY = player.pos[1], maxY = player.pos[1] + PLAYER.HEIGHT;
const minZ = player.pos[2] - PLAYER.HALF, maxZ = player.pos[2] + PLAYER.HALF;
return (bx + 1 > minX && bx < maxX &&
by + 1 > minY && by < maxY &&
bz + 1 > minZ && bz < maxZ);
}
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) {
const dt = Math.min(0.05, (now - last) / 1000);
last = now;
if (input.locked) {
player.update(input, dt);
// Kamera nach Spieler
player.eyePosition(_eye);
camera.position.copy(_eye);
camera.rotation.set(player.pitch, player.yaw, 0, 'YXZ');
// Raycast + Highlight
aimDir(_dir);
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, CONTROLS.REACH
);
if (hit) {
hlEdges.visible = true;
hlEdges.position.set(hit.x + 0.5, hit.y + 0.5, hit.z + 0.5);
} else {
hlEdges.visible = false;
}
// Interaktion
if (hit && input.leftClick) {
if (hit.block !== BLOCK.BEDROCK) {
world.setBlock(hit.x, hit.y, hit.z, BLOCK.AIR);
}
}
if (hit && input.rightClick) {
const px = hit.x + hit.nx, py = hit.y + hit.ny, pz = hit.z + hit.nz;
const cur = world.getBlock(px, py, pz);
if ((cur === BLOCK.AIR || cur === BLOCK.WATER) && !wouldIntersectPlayer(px, py, pz)) {
world.setBlock(px, py, pz, HOTBAR[selected]);
}
}
}
input.endFrame();
// Streaming (immer, auch im Pause-Menue, damit die Welt laedt)
world.updateStreaming(player.pos[0], player.pos[2], renderDistance, maxOpsPerFrame);
// Sonne folgt dem Spieler -> Shadow-Frustum bleibt um den Spieler zentriert.
sun.position.set(
player.pos[0] + _sunOffset.x, player.pos[1] + _sunOffset.y, player.pos[2] + _sunOffset.z
);
sun.target.position.set(player.pos[0], player.pos[1], player.pos[2]);
sun.target.updateMatrixWorld();
composer.render();
// HUD
fpsAcc += dt; fpsFrames++;
if (fpsAcc >= 0.5) { fps = Math.round(fpsFrames / fpsAcc); fpsAcc = 0; fpsFrames = 0; }
const biome = BIOME_NAME[getBiome(Math.floor(player.pos[0]), Math.floor(player.pos[2]))] || '?';
hud.textContent =
`FPS ${fps}\n` +
`XYZ ${player.pos[0].toFixed(1)} / ${player.pos[1].toFixed(1)} / ${player.pos[2].toFixed(1)}\n` +
`Biome: ${biome}\n` +
`Modus: ${player.flying ? 'Fliegen' : 'Gehen'} Block: ${[...Object.entries(BLOCK).find(([,v]) => v === HOTBAR[selected])][0]}`;
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);