- 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>
322 lines
13 KiB
JavaScript
322 lines
13 KiB
JavaScript
// 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);
|