- 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>
212 lines
6.7 KiB
JavaScript
212 lines
6.7 KiB
JavaScript
// World: Chunk-Storage, Block-Zugriff, Generierung, Mesh-Verwaltung, Streaming.
|
||
import * as THREE from 'three';
|
||
import {
|
||
CHUNK_SIZE, CHUNK_HEIGHT, BLOCK, isSolid,
|
||
} from './constants.js';
|
||
import { generateChunk } from './worldgen.js';
|
||
import { buildChunkGeometry } from './mesher.js';
|
||
|
||
class Chunk {
|
||
constructor(cx, cz) {
|
||
this.cx = cx;
|
||
this.cz = cz;
|
||
// Index: y * (S*S) + z * S + x
|
||
this.blocks = new Uint8Array(CHUNK_SIZE * CHUNK_HEIGHT * CHUNK_SIZE);
|
||
this.meshes = null; // { opaque: Mesh|null, water: Mesh|null }
|
||
this.dirty = true; // Mesh muss (neu) gebaut werden
|
||
}
|
||
idx(x, y, z) { return y * CHUNK_SIZE * CHUNK_SIZE + z * CHUNK_SIZE + x; }
|
||
get(x, y, z) { return this.blocks[this.idx(x, y, z)]; }
|
||
set(x, y, z, v) { this.blocks[this.idx(x, y, z)] = v; }
|
||
}
|
||
|
||
export class World {
|
||
constructor(scene, materials, seed) {
|
||
this.scene = scene;
|
||
this.materials = materials; // { opaque: Material, water: Material }
|
||
this.seed = seed;
|
||
this.chunks = new Map();
|
||
}
|
||
|
||
key(cx, cz) { return cx + ',' + cz; }
|
||
|
||
getChunk(cx, cz) { return this.chunks.get(this.key(cx, cz)); }
|
||
|
||
// Generiert den Chunk (falls nicht vorhanden) inkl. Terrain+Baeume.
|
||
// Da der Chunk VOR dem Generieren in die Map eingetragen wird, ist die
|
||
// Rekursion (Baeume in Nachbar-Chunks) beschraenkt und terminiert.
|
||
ensureChunk(cx, cz) {
|
||
const k = this.key(cx, cz);
|
||
let c = this.chunks.get(k);
|
||
if (c) return c;
|
||
c = new Chunk(cx, cz);
|
||
this.chunks.set(k, c);
|
||
generateChunk(this, c);
|
||
return c;
|
||
}
|
||
|
||
// Block-Lesezugriff OHNE Generierung (fuer Rendering/Physik/Raycast).
|
||
getBlock(wx, wy, wz) {
|
||
if (wy < 0 || wy >= CHUNK_HEIGHT) return BLOCK.AIR;
|
||
const cx = Math.floor(wx / CHUNK_SIZE);
|
||
const cz = Math.floor(wz / CHUNK_SIZE);
|
||
const c = this.getChunk(cx, cz);
|
||
if (!c) return BLOCK.AIR;
|
||
return c.get(wx - cx * CHUNK_SIZE, wy, wz - cz * CHUNK_SIZE);
|
||
}
|
||
|
||
// Lesezugriff MIT Generierung (fuer Weltgenerierung / Baeume).
|
||
getGen(wx, wy, wz) {
|
||
if (wy < 0 || wy >= CHUNK_HEIGHT) return BLOCK.AIR;
|
||
const cx = Math.floor(wx / CHUNK_SIZE);
|
||
const cz = Math.floor(wz / CHUNK_SIZE);
|
||
const c = this.ensureChunk(cx, cz);
|
||
return c.get(wx - cx * CHUNK_SIZE, wy, wz - cz * CHUNK_SIZE);
|
||
}
|
||
|
||
// Schreibzugriff waehrend Generierung.
|
||
setGen(wx, wy, wz, v) {
|
||
if (wy < 0 || wy >= CHUNK_HEIGHT) return;
|
||
const cx = Math.floor(wx / CHUNK_SIZE);
|
||
const cz = Math.floor(wz / CHUNK_SIZE);
|
||
const c = this.ensureChunk(cx, cz);
|
||
c.set(wx - cx * CHUNK_SIZE, wy, wz - cz * CHUNK_SIZE, v);
|
||
}
|
||
|
||
// Spieler-Setzen/Abbauen: schreibt Block und markiert betroffene Chunks dirty.
|
||
setBlock(wx, wy, wz, v) {
|
||
if (wy < 0 || wy >= CHUNK_HEIGHT) return false;
|
||
const cx = Math.floor(wx / CHUNK_SIZE);
|
||
const cz = Math.floor(wz / CHUNK_SIZE);
|
||
const lx = wx - cx * CHUNK_SIZE;
|
||
const lz = wz - cz * CHUNK_SIZE;
|
||
const c = this.ensureChunk(cx, cz);
|
||
if (c.get(lx, wy, lz) === v) return false;
|
||
c.set(lx, wy, lz, v);
|
||
c.dirty = true;
|
||
// An Chunk-Grenzen Nachbar-Chunks ebenfalls aktualisieren
|
||
if (lx === 0) this._markDirty(cx - 1, cz);
|
||
if (lx === CHUNK_SIZE - 1) this._markDirty(cx + 1, cz);
|
||
if (lz === 0) this._markDirty(cx, cz - 1);
|
||
if (lz === CHUNK_SIZE - 1) this._markDirty(cx, cz + 1);
|
||
return true;
|
||
}
|
||
|
||
_markDirty(cx, cz) {
|
||
const c = this.getChunk(cx, cz);
|
||
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);
|
||
|
||
buildChunkMesh(chunk) {
|
||
// Fuer korrektes Border-Culling Nachbarn sicherstellen (Blockdaten).
|
||
for (let dz = -1; dz <= 1; dz++) {
|
||
for (let dx = -1; dx <= 1; dx++) {
|
||
this.ensureChunk(chunk.cx + dx, chunk.cz + dz);
|
||
}
|
||
}
|
||
const { opaque, transparent } = buildChunkGeometry(this.getBlockWorld, chunk);
|
||
|
||
// alte Meshes entfernen
|
||
this.disposeChunkMesh(chunk);
|
||
|
||
const ox = chunk.cx * CHUNK_SIZE;
|
||
const oz = chunk.cz * CHUNK_SIZE;
|
||
const meshes = { opaque: null, water: null };
|
||
|
||
if (opaque) {
|
||
const m = new THREE.Mesh(opaque, this.materials.opaque);
|
||
m.position.set(ox, 0, oz);
|
||
m.frustumCulled = true;
|
||
m.castShadow = true;
|
||
m.receiveShadow = true;
|
||
this.scene.add(m);
|
||
meshes.opaque = m;
|
||
}
|
||
if (transparent) {
|
||
const m = new THREE.Mesh(transparent, this.materials.water);
|
||
m.position.set(ox, 0, oz);
|
||
m.renderOrder = 1;
|
||
m.receiveShadow = true; // Wasseroberflaeche kriegt Schatten; casten unnoetig
|
||
this.scene.add(m);
|
||
meshes.water = m;
|
||
}
|
||
chunk.meshes = meshes;
|
||
chunk.dirty = false;
|
||
}
|
||
|
||
disposeChunkMesh(chunk) {
|
||
if (!chunk.meshes) return;
|
||
for (const key of ['opaque', 'water']) {
|
||
const m = chunk.meshes[key];
|
||
if (m) {
|
||
this.scene.remove(m);
|
||
m.geometry.dispose();
|
||
}
|
||
}
|
||
chunk.meshes = null;
|
||
}
|
||
|
||
// --- Streaming: Chunks im Radius um Spieler halten ---
|
||
updateStreaming(px, pz, renderDistance, maxOpsPerFrame) {
|
||
const pcx = Math.floor(px / CHUNK_SIZE);
|
||
const pcz = Math.floor(pz / CHUNK_SIZE);
|
||
|
||
// 1) Entferne zu weit entfernte Chunks
|
||
for (const [k, c] of this.chunks) {
|
||
const dx = c.cx - pcx;
|
||
const dz = c.cz - pcz;
|
||
if (Math.max(Math.abs(dx), Math.abs(dz)) > renderDistance + 1) {
|
||
this.disposeChunkMesh(c);
|
||
this.chunks.delete(k);
|
||
}
|
||
}
|
||
|
||
// 2) Sammle fehlende/dirty Chunks im Radius, sortiert nach Distanz
|
||
const todo = [];
|
||
for (let dz = -renderDistance; dz <= renderDistance; dz++) {
|
||
for (let dx = -renderDistance; dx <= renderDistance; dx++) {
|
||
const cx = pcx + dx;
|
||
const cz = pcz + dz;
|
||
const c = this.getChunk(cx, cz);
|
||
if (!c) {
|
||
todo.push({ cx, cz, d: dx * dx + dz * dz, gen: true });
|
||
} else if (c.dirty) {
|
||
todo.push({ cx, cz, d: dx * dx + dz * dz, gen: false });
|
||
}
|
||
}
|
||
}
|
||
todo.sort((a, b) => a.d - b.d);
|
||
|
||
let ops = 0;
|
||
for (const job of todo) {
|
||
if (ops >= maxOpsPerFrame) break;
|
||
const c = this.ensureChunk(job.cx, job.cz);
|
||
this.buildChunkMesh(c);
|
||
ops++;
|
||
}
|
||
}
|
||
|
||
// Bei Spielstart: Chunk am Spawn sicherstellen (fuer sicheren Boden)
|
||
highestSolidY(wx, wz) {
|
||
const cx = Math.floor(wx / CHUNK_SIZE);
|
||
const cz = Math.floor(wz / CHUNK_SIZE);
|
||
const c = this.ensureChunk(cx, cz);
|
||
const lx = wx - cx * CHUNK_SIZE;
|
||
const lz = wz - cz * CHUNK_SIZE;
|
||
for (let y = CHUNK_HEIGHT - 1; y >= 0; y--) {
|
||
if (isSolid(c.get(lx, y, lz))) return y;
|
||
}
|
||
return CHUNK_HEIGHT - 1;
|
||
}
|
||
}
|