feat: Minecraft Clone MVP – prozedurale Welt, Blöcke, Abbauen/Setzen
- Weltgenerierung: FBM-Terrain, 3 Biome (Plains/Wald/Wüste), Höhlen, Wasser, Bäume - Blöcke: Grass, Dirt, Stein, Sand, Holz, Blätter, Wasser (+Bedrock) - Interaktion: DDA-Voxel-Raycast, Abbauen (LMB) & Setzen (RMB), Hotbar 1-6 - Spieler: First-Person, Gravitation, AABB-Kollision, Springen, Fly-Mode (F) - Engine: Chunk-Streaming (16x16x64), Face-Culling, opaker+transparenter Pass - Stack: Three.js + simplex-noise via CDN, prozedurale Texturen, kein Build-Step Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
commit
1049253d54
15 files changed
+1436
No files matched your search
+201
@@ -0,0 +1,201 @@
|
||||
// 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;
|
||||
}
|
||||
|
||||
// --- 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;
|
||||
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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user