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:
minecraft-cloneandClaude committed 2026-06-20 23:55:12 +02:00
commit 1049253d54
15 files changed
+1436

No files matched your search

+93
View File
@@ -0,0 +1,93 @@
// Globale Konstanten und Block-/Biome-Definitionen
// --- Welt-Dimensionen ---
export const CHUNK_SIZE = 16; // X und Z Ausdehnung eines Chunks
export const CHUNK_HEIGHT = 64; // Y Ausdehnung eines Chunks
export const SEA_LEVEL = 26;
export const WORLD_SEED = 1337;
// --- Block-Typen ---
export const BLOCK = {
AIR: 0,
GRASS: 1,
DIRT: 2,
STONE: 3,
SAND: 4,
WOOD: 5,
LEAVES: 6,
WATER: 7,
BEDROCK: 8,
};
// Welche Bloecke fuer den Spieler nicht solide sind (keine Kollision, durchschaubar)
export function isTransparent(id) {
return id === BLOCK.AIR || id === BLOCK.WATER;
}
export function isSolid(id) {
return id !== BLOCK.AIR && id !== BLOCK.WATER;
}
// Wasser als einziger (halb-)transparenter Renderblock
export function isRenderTransparent(id) {
return id === BLOCK.WATER;
}
// --- Biome ---
export const BIOME = { PLAINS: 0, FOREST: 1, DESERT: 2 };
// --- Texturatlas ---
export const ATLAS_COLS = 16; // Tiles pro Reihe
export const TILE_PX = 16; // Pixel pro Tile
export const ATLAS_PX = ATLAS_COLS * TILE_PX; // 256
// Tile-Indizes im Atlas (Reihe 0)
export const TILE = {
GRASS_TOP: 0,
GRASS_SIDE: 1,
DIRT: 2,
STONE: 3,
SAND: 4,
WOOD_TOP: 5,
WOOD_SIDE: 6,
LEAVES: 7,
WATER: 8,
};
// Liefert (col,row) im Atlas fuer einen Block + Seite ('top'|'bottom'|'side')
export function tileFor(block, side) {
let idx;
switch (block) {
case BLOCK.GRASS:
idx = side === 'top' ? TILE.GRASS_TOP : side === 'bottom' ? TILE.DIRT : TILE.GRASS_SIDE;
break;
case BLOCK.DIRT: idx = TILE.DIRT; break;
case BLOCK.STONE: idx = TILE.STONE; break;
case BLOCK.SAND: idx = TILE.SAND; break;
case BLOCK.WOOD:
idx = (side === 'top' || side === 'bottom') ? TILE.WOOD_TOP : TILE.WOOD_SIDE;
break;
case BLOCK.LEAVES: idx = TILE.LEAVES; break;
case BLOCK.WATER: idx = TILE.WATER; break;
default: idx = TILE.STONE;
}
return { col: idx % ATLAS_COLS, row: Math.floor(idx / ATLAS_COLS) };
}
// Bloecke, die per Hotbar gesetzt werden koennen
export const HOTBAR = [
BLOCK.GRASS,
BLOCK.DIRT,
BLOCK.STONE,
BLOCK.SAND,
BLOCK.WOOD,
BLOCK.LEAVES,
];
// Anzeige-Farben pro Block fuer die Hotbar-Symbole
export const BLOCK_COLOR = {
[BLOCK.GRASS]: '#6ab04c',
[BLOCK.DIRT]: '#8b5a2b',
[BLOCK.STONE]: '#8a8a8a',
[BLOCK.SAND]: '#e6d8a8',
[BLOCK.WOOD]: '#6e4f2a',
[BLOCK.LEAVES]:'#3f8a2f',
};
+47
View File
@@ -0,0 +1,47 @@
// Input-State: Tastatur, Maus-Look (Edge-Flags fuer Klicks).
export class Input {
constructor(domElement) {
this.el = domElement;
this.keys = new Set();
this.mouseDX = 0;
this.mouseDY = 0;
this.leftClick = false; // Edge (diese Frame)
this.rightClick = false; // Edge (diese Frame)
this.locked = false;
window.addEventListener('keydown', (e) => {
this.keys.add(e.code);
if (e.code === 'Space') e.preventDefault();
});
window.addEventListener('keyup', (e) => this.keys.delete(e.code));
document.addEventListener('pointerlockchange', () => {
this.locked = document.pointerLockElement === this.el;
});
this.el.addEventListener('mousedown', (e) => {
if (!this.locked) return;
if (e.button === 0) this.leftClick = true;
if (e.button === 2) this.rightClick = true;
});
this.el.addEventListener('contextmenu', (e) => e.preventDefault());
document.addEventListener('mousemove', (e) => {
if (!this.locked) return;
this.mouseDX += e.movementX || 0;
this.mouseDY += e.movementY || 0;
});
}
requestLock() { this.el.requestPointerLock(); }
// Am Ende jedes Frames aufrufen.
endFrame() {
this.mouseDX = 0;
this.mouseDY = 0;
this.leftClick = false;
this.rightClick = false;
}
down(code) { return this.keys.has(code); }
}
+203
View File
@@ -0,0 +1,203 @@
// Haupt-Einstiegspunkt: Scene/Camera/Renderer, Licht, Welt, Spieler, Loop.
import * as THREE from 'three';
import {
CHUNK_SIZE, BLOCK, BIOME, HOTBAR, BLOCK_COLOR,
} from './constants.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';
const RENDER_DISTANCE = 6; // Chunks in jede Richtung
const REACH = 6; // maximale Bau/Abbau-Reichweite
const MAX_OPS_PER_FRAME = 2; // Chunk-Generierung/Meshing pro Frame
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: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.outputColorSpace = THREE.SRGBColorSpace;
const scene = new THREE.Scene();
const SKY = new THREE.Color(0x8fc3ff);
scene.background = SKY;
scene.fog = new THREE.Fog(SKY, (RENDER_DISTANCE - 2) * CHUNK_SIZE, RENDER_DISTANCE * CHUNK_SIZE);
const camera = new THREE.PerspectiveCamera(72, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.rotation.order = 'YXZ';
// ---------- Licht ----------
const hemi = new THREE.HemisphereLight(0xbfdfff, 0x5a6b3a, 0.9);
scene.add(hemi);
const sun = new THREE.DirectionalLight(0xfff4d6, 0.85);
sun.position.set(60, 120, 30);
scene.add(sun);
// ---------- Texturen & Materialien ----------
const atlas = buildAtlasTexture();
const matOpaque = new THREE.MeshLambertMaterial({ map: atlas });
const matWater = new THREE.MeshLambertMaterial({
map: atlas, transparent: true, depthWrite: false, side: THREE.DoubleSide,
});
// ---------- 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 = 8, sz = 8;
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] + 1.62, 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', () => {
if (!document.pointerLockElement) overlay.classList.remove('hidden');
});
// ---------- Resize ----------
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.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] - 0.3, maxX = player.pos[0] + 0.3;
const minY = player.pos[1], maxY = player.pos[1] + 1.8;
const minZ = player.pos[2] - 0.3, maxZ = player.pos[2] + 0.3;
return (bx + 1 > minX && bx < maxX &&
by + 1 > minY && by < maxY &&
bz + 1 > minZ && bz < maxZ);
}
const _dir = new THREE.Vector3();
// ---------- 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, 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], RENDER_DISTANCE, MAX_OPS_PER_FRAME);
renderer.render(scene, camera);
// 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);
+145
View File
@@ -0,0 +1,145 @@
// Face-Culling-Mesher: baut aus Chunk-Blockdaten + Nachbar-Lookups zwei
// BufferGeometrys (opaque + transparent/Wasser). Nur Faces zu Luft/transparenten
// Nachbarn werden erzeugt.
import * as THREE from 'three';
import {
CHUNK_SIZE, CHUNK_HEIGHT, BLOCK, ATLAS_COLS, tileFor, isRenderTransparent,
} from './constants.js';
// Die 6 Wuerfelseiten mit Eck-Positionen und Tile-UVs (0..1).
// side wird aus dir abgeleitet: dir[1]===1 -> 'top', -1 -> 'bottom', sonst 'side'.
const FACES = [
{ // links -X
dir: [-1, 0, 0],
corners: [
{ pos: [0, 1, 0], uv: [0, 1] },
{ pos: [0, 0, 0], uv: [0, 0] },
{ pos: [0, 1, 1], uv: [1, 1] },
{ pos: [0, 0, 1], uv: [1, 0] },
],
},
{ // rechts +X
dir: [1, 0, 0],
corners: [
{ pos: [1, 1, 1], uv: [0, 1] },
{ pos: [1, 0, 1], uv: [0, 0] },
{ pos: [1, 1, 0], uv: [1, 1] },
{ pos: [1, 0, 0], uv: [1, 0] },
],
},
{ // unten -Y
dir: [0, -1, 0],
corners: [
{ pos: [1, 0, 1], uv: [1, 0] },
{ pos: [0, 0, 1], uv: [0, 0] },
{ pos: [1, 0, 0], uv: [1, 1] },
{ pos: [0, 0, 0], uv: [0, 1] },
],
},
{ // oben +Y
dir: [0, 1, 0],
corners: [
{ pos: [0, 1, 1], uv: [1, 1] },
{ pos: [1, 1, 1], uv: [0, 1] },
{ pos: [0, 1, 0], uv: [1, 0] },
{ pos: [1, 1, 0], uv: [0, 0] },
],
},
{ // hinten -Z
dir: [0, 0, -1],
corners: [
{ pos: [1, 0, 0], uv: [0, 0] },
{ pos: [0, 0, 0], uv: [1, 0] },
{ pos: [1, 1, 0], uv: [0, 1] },
{ pos: [0, 1, 0], uv: [1, 1] },
],
},
{ // vorne +Z
dir: [0, 0, 1],
corners: [
{ pos: [0, 0, 1], uv: [0, 0] },
{ pos: [1, 0, 1], uv: [1, 0] },
{ pos: [0, 1, 1], uv: [0, 1] },
{ pos: [1, 1, 1], uv: [1, 1] },
],
},
];
function sideOf(dir) {
if (dir[1] === 1) return 'top';
if (dir[1] === -1) return 'bottom';
return 'side';
}
// Soll ein Face zwischen Block und Nachbar gezeichnet werden?
function shouldDrawFace(block, neighbor) {
if (neighbor === BLOCK.AIR) return true;
// Wasser/Sichtbarkeit: Face zeichnen, wenn Nachbar transparent ist und
// nicht derselbe Block (vermeidet innere Wasserfaces).
if (isRenderTransparent(neighbor)) return neighbor !== block;
return false;
}
function makeBuffer() {
return { positions: [], normals: [], uvs: [], indices: [] };
}
function addFace(buf, x, y, z, face, block) {
const side = sideOf(face.dir);
const tile = tileFor(block, side);
const ndx = buf.positions.length / 3;
for (const c of face.corners) {
buf.positions.push(x + c.pos[0], y + c.pos[1], z + c.pos[2]);
buf.normals.push(face.dir[0], face.dir[1], face.dir[2]);
const u = (tile.col + c.uv[0]) / ATLAS_COLS;
const v = 1 - (tile.row + 1 - c.uv[1]) / ATLAS_COLS;
buf.uvs.push(u, v);
}
buf.indices.push(ndx, ndx + 1, ndx + 2, ndx + 2, ndx + 1, ndx + 3);
}
function toGeometry(buf) {
if (buf.positions.length === 0) return null;
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.Float32BufferAttribute(buf.positions, 3));
g.setAttribute('normal', new THREE.Float32BufferAttribute(buf.normals, 3));
g.setAttribute('uv', new THREE.Float32BufferAttribute(buf.uvs, 2));
// WICHTIG: setIndex braucht ein BufferAttribute, kein rohes TypedArray.
// In three r0.165 speichert setIndex(rohesTypedArray) es ungepackt, so dass
// der Renderer beim Render auf attribute.array undefined trifft und crasht
// ("Cannot read properties of undefined (reading 'byteLength')").
const maxV = (buf.positions.length / 3) - 1;
const IndexAttr = maxV > 65535 ? THREE.Uint32BufferAttribute : THREE.Uint16BufferAttribute;
g.setIndex(new IndexAttr(buf.indices, 1));
return g;
}
// world.getBlockWorld(x,y,z) liefert Block auch ueber Chunk-Grenzen.
export function buildChunkGeometry(getBlockWorld, chunk) {
const opaque = makeBuffer();
const transparent = makeBuffer();
const baseX = chunk.cx * CHUNK_SIZE;
const baseZ = chunk.cz * CHUNK_SIZE;
for (let y = 0; y < CHUNK_HEIGHT; y++) {
for (let z = 0; z < CHUNK_SIZE; z++) {
for (let x = 0; x < CHUNK_SIZE; x++) {
const block = chunk.get(x, y, z);
if (block === BLOCK.AIR) continue;
const buf = isRenderTransparent(block) ? transparent : opaque;
for (const face of FACES) {
const nx = x + face.dir[0];
const ny = y + face.dir[1];
const nz = z + face.dir[2];
const neighbor = getBlockWorld(baseX + nx, ny, baseZ + nz);
if (shouldDrawFace(block, neighbor)) {
addFace(buf, x, y, z, face, block);
}
}
}
}
}
return { opaque: toGeometry(opaque), transparent: toGeometry(transparent) };
}
+30
View File
@@ -0,0 +1,30 @@
// Noise-Setup: verschiedene Noise-Funktionen fuer Terrain, Biome und Hoehlen
import { createNoise2D, createNoise3D } from 'simplex-noise';
import { WORLD_SEED } from './constants.js';
import { mulberry32 } from './rng.js';
// Wir leiten mehrere unabhaengige Streams aus dem Seed ab, damit
// verschiedene Noise-Quellen (Hoehe, Temperatur, Hoehlen) nicht korrelieren.
const rngTerrain = mulberry32(WORLD_SEED);
const rngTemp = mulberry32(WORLD_SEED ^ 0x9e3779b9);
const rngHumid = mulberry32(WORLD_SEED ^ 0x85ebca6b);
const rngCave = mulberry32(WORLD_SEED ^ 0xc2b2ae35);
const rngDetail = mulberry32(WORLD_SEED ^ 0x27d4eb2f);
export const noise2DTerrain = createNoise2D(rngTerrain);
export const noise2DDetail = createNoise2D(rngDetail);
export const noise2DTemp = createNoise2D(rngTemp);
export const noise2DHumid = createNoise2D(rngHumid);
export const noise3DCave = createNoise3D(rngCave);
// Fraktale Brownian Motion (fbm) in 2D
export function fbm2(noiseFn, x, z, octaves, lacunarity, gain) {
let amp = 1, freq = 1, sum = 0, norm = 0;
for (let o = 0; o < octaves; o++) {
sum += amp * noiseFn(x * freq, z * freq);
norm += amp;
amp *= gain;
freq *= lacunarity;
}
return sum / norm; // [-1,1]
}
+145
View File
@@ -0,0 +1,145 @@
// First-Person-Spieler: Bewegung, Gravitation, AABB-Kollision, Springen,
// Flug-Modus, Schwimmen. yaw/pitch steuern die Kamera (extern gesetzt).
import { isSolid, BLOCK } from './constants.js';
const HALF = 0.3; // halbe Spielerbreite (X/Z)
const HEIGHT = 1.8; // Spielerhoehe
const EYE = 1.62; // Augenposition ueber den Fuessen
const GRAVITY = 26;
const JUMP_VEL = 8.6;
const WALK = 4.7;
const SPRINT = 7.2;
const FLY_SPEED = 9.5;
const MAX_FALL = 50;
export class Player {
constructor(world) {
this.world = world;
this.pos = new Float32Array(3); // x,y,z der Fuesse-Mittelpunkt
this.vel = new Float32Array(3);
this.onGround = false;
this.flying = false;
this.yaw = 0;
this.pitch = 0;
}
setSpawn(x, y, z) {
this.pos[0] = x; this.pos[1] = y; this.pos[2] = z;
this.vel[0] = this.vel[1] = this.vel[2] = 0;
}
eyePosition(out) {
out.set(this.pos[0], this.pos[1] + 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 ez = Math.floor(this.pos[2]);
return this.world.getBlock(ex, ey, ez) === BLOCK.WATER;
}
update(input, dt) {
// --- Maus-Look ---
const sens = 0.0022;
this.yaw -= input.mouseDX * sens;
this.pitch -= input.mouseDY * sens;
const lim = Math.PI / 2 - 0.001;
if (this.pitch > lim) this.pitch = lim;
if (this.pitch < -lim) this.pitch = -lim;
// --- Flug-Toggle ---
if (input.keys.has('KeyF') && !this._fHeld) {
this._fHeld = true;
this.flying = !this.flying;
this.vel[1] = 0;
}
if (!input.keys.has('KeyF')) this._fHeld = false;
// --- Wunschrichtung im XZ-Plane (yaw=0 -> Blickrichtung ist -Z) ---
const fwdX = -Math.sin(this.yaw);
const fwdZ = -Math.cos(this.yaw);
const rightX = -fwdZ; // = cos(yaw)
const rightZ = fwdX; // = -sin(yaw)
let mx = 0, mz = 0;
if (input.down('KeyW')) { mx += fwdX; mz += fwdZ; }
if (input.down('KeyS')) { mx -= fwdX; mz -= fwdZ; }
if (input.down('KeyD')) { mx += rightX; mz += rightZ; }
if (input.down('KeyA')) { mx -= rightX; mz -= rightZ; }
const len = Math.hypot(mx, mz);
if (len > 0) { mx /= len; mz /= len; }
const speed = this.flying ? FLY_SPEED : (input.down('ControlLeft') ? SPRINT : WALK);
if (this.flying) {
this.vel[0] = mx * speed;
this.vel[2] = mz * speed;
let vy = 0;
if (input.down('Space')) vy += 1;
if (input.down('ShiftLeft')) vy -= 1;
this.vel[1] = vy * speed;
} else {
const water = this.inWater();
const move = water ? speed * 0.5 : speed;
this.vel[0] = mx * move;
this.vel[2] = mz * move;
// Gravitation / Schwimmen
if (water) {
this.vel[1] -= 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;
if (input.down('Space') && this.onGround) {
this.vel[1] = JUMP_VEL;
this.onGround = false;
}
}
}
// --- Bewegung mit per-Achsen-Kollision ---
this.onGround = false;
this._moveAxis(0, this.vel[0] * dt);
this._moveAxis(2, this.vel[2] * dt);
this._moveAxis(1, this.vel[1] * dt);
}
// axis: 0=x,1=y,2=z ; amount = Verschiebung auf dieser Achse
_moveAxis(axis, amount) {
this.pos[axis] += amount;
if (amount === 0) return;
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);
const minZ = Math.floor(z - HALF), maxZ = Math.floor(z + HALF);
for (let bx = minX; bx <= maxX; bx++) {
for (let by = minY; by <= maxY; by++) {
for (let bz = minZ; bz <= maxZ; bz++) {
if (!isSolid(this.world.getBlock(bx, by, bz))) continue;
if (axis === 0) {
if (amount > 0) this.pos[0] = Math.min(this.pos[0], bx - HALF - 1e-4);
else this.pos[0] = Math.max(this.pos[0], bx + 1 + HALF + 1e-4);
this.vel[0] = 0;
} else if (axis === 2) {
if (amount > 0) this.pos[2] = Math.min(this.pos[2], bz - HALF - 1e-4);
else this.pos[2] = Math.max(this.pos[2], bz + 1 + HALF + 1e-4);
this.vel[2] = 0;
} else {
if (amount > 0) { // nach oben gegen Decke
this.pos[1] = Math.min(this.pos[1], by - HEIGHT - 1e-4);
} else { // gelandet
this.pos[1] = Math.max(this.pos[1], by + 1 + 1e-4);
this.onGround = true;
}
this.vel[1] = 0;
}
}
}
}
}
}
+50
View File
@@ -0,0 +1,50 @@
// DDA-Voxel-Raycast (Amanatides & Woo). Liefert ersten getroffenen, soliden
// (nicht Luft, nicht Wasser) Block sowie die Eintrittsnormale (face).
import { BLOCK } from './constants.js';
export function raycastVoxel(getBlock, originX, originY, originZ, dirX, dirY, dirZ, maxDist) {
let x = Math.floor(originX);
let y = Math.floor(originY);
let z = Math.floor(originZ);
const stepX = dirX > 0 ? 1 : dirX < 0 ? -1 : 0;
const stepY = dirY > 0 ? 1 : dirY < 0 ? -1 : 0;
const stepZ = dirZ > 0 ? 1 : dirZ < 0 ? -1 : 0;
const tDeltaX = stepX !== 0 ? Math.abs(1 / dirX) : Infinity;
const tDeltaY = stepY !== 0 ? Math.abs(1 / dirY) : Infinity;
const tDeltaZ = stepZ !== 0 ? Math.abs(1 / dirZ) : Infinity;
let tMaxX = stepX > 0 ? (x + 1 - originX) * tDeltaX
: stepX < 0 ? (originX - x) * tDeltaX : Infinity;
let tMaxY = stepY > 0 ? (y + 1 - originY) * tDeltaY
: stepY < 0 ? (originY - y) * tDeltaY : Infinity;
let tMaxZ = stepZ > 0 ? (z + 1 - originZ) * tDeltaZ
: stepZ < 0 ? (originZ - z) * tDeltaZ : Infinity;
let nx = 0, ny = 0, nz = 0;
let t = 0;
// Start-Voxel pruefen
let b = getBlock(x, y, z);
if (b !== BLOCK.AIR && b !== BLOCK.WATER) {
return { x, y, z, block: b, nx: 0, ny: 0, nz: 0 };
}
const maxSteps = 128;
for (let i = 0; i < maxSteps; i++) {
if (tMaxX < tMaxY && tMaxX < tMaxZ) {
x += stepX; t = tMaxX; tMaxX += tDeltaX; nx = -stepX; ny = 0; nz = 0;
} else if (tMaxY < tMaxZ) {
y += stepY; t = tMaxY; tMaxY += tDeltaY; nx = 0; ny = -stepY; nz = 0;
} else {
z += stepZ; t = tMaxZ; tMaxZ += tDeltaZ; nx = 0; ny = 0; nz = -stepZ;
}
if (t > maxDist) break;
b = getBlock(x, y, z);
if (b !== BLOCK.AIR && b !== BLOCK.WATER) {
return { x, y, z, block: b, nx, ny, nz };
}
}
return null;
}
+30
View File
@@ -0,0 +1,30 @@
// Seeded PRNG (mulberry32) + deterministische Hashes fuer Weltgenerierung
// mulberry32: erzeugt aus einem 32-bit Seed eine PRNG-Funktion in [0,1)
export function mulberry32(seed) {
let a = seed >>> 0;
return function () {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// Hash aus String -> 32-bit unsigned (fuer Seed-Eingabe)
export function hashSeed(str) {
let h = 2166136261 >>> 0;
for (let i = 0; i < str.length; i++) {
h ^= str.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return h >>> 0;
}
// Deterministische 2D-Hash -> [0,1), fuer Baum-Platzierung etc.
export function hash2(x, z) {
let h = Math.imul(x | 0, 374761393) ^ Math.imul(z | 0, 668265263);
h = Math.imul(h ^ (h >>> 13), 1274126177);
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
}
+125
View File
@@ -0,0 +1,125 @@
// Prozeduraler Texturatlas: zeichnet alle Block-Tiles auf ein Canvas und
// liefert eine THREE.CanvasTexture (Nearest-Filter, Minecraft-Look).
import * as THREE from 'three';
import { ATLAS_COLS, TILE_PX, ATLAS_PX, TILE, BLOCK } from './constants.js';
import { mulberry32 } from './rng.js';
// Kleine Helfer: clamp + Farbvariation
function clamp(v) { return v < 0 ? 0 : v > 255 ? 255 : v | 0; }
function shade(base, delta) {
return [clamp(base[0] + delta), clamp(base[1] + delta), clamp(base[2] + delta)];
}
// Fuellt ein Tile mit Basisfarbe + Rauschen
function paintNoise(ctx, ox, oy, base, vary, seed) {
const rand = mulberry32(seed);
for (let y = 0; y < TILE_PX; y++) {
for (let x = 0; x < TILE_PX; x++) {
const d = (rand() - 0.5) * 2 * vary;
const c = shade(base, d);
ctx.fillStyle = `rgb(${c[0]},${c[1]},${c[2]})`;
ctx.fillRect(ox + x, oy + y, 1, 1);
}
}
}
export function buildAtlasTexture() {
const canvas = document.createElement('canvas');
canvas.width = ATLAS_PX;
canvas.height = ATLAS_PX;
const ctx = canvas.getContext('2d');
ctx.imageSmoothingEnabled = false;
const t = (index) => ({ ox: (index % ATLAS_COLS) * TILE_PX, oy: Math.floor(index / ATLAS_COLS) * TILE_PX });
// --- DIRT ---
let p = t(TILE.DIRT);
paintNoise(ctx, p.ox, p.oy, [134, 96, 67], 18, 11);
// --- STONE ---
p = t(TILE.STONE);
paintNoise(ctx, p.ox, p.oy, [128, 128, 132], 16, 23);
// ein paar dunklere Adern
const rStone = mulberry32(7);
for (let i = 0; i < 10; i++) {
ctx.fillStyle = `rgb(${clamp(90 + (rStone() - .5) * 20)},${clamp(90 + (rStone() - .5) * 20)},${clamp(94 + (rStone() - .5) * 20)})`;
ctx.fillRect(p.ox + ((rStone() * TILE_PX) | 0), p.oy + ((rStone() * TILE_PX) | 0), 2, 1);
}
// --- SAND ---
p = t(TILE.SAND);
paintNoise(ctx, p.ox, p.oy, [224, 210, 158], 14, 41);
// --- GRASS TOP ---
p = t(TILE.GRASS_TOP);
paintNoise(ctx, p.ox, p.oy, [102, 168, 64], 22, 5);
// --- GRASS SIDE: Dirt mit Gras-Obenrand + Tropfen ---
p = t(TILE.GRASS_SIDE);
paintNoise(ctx, p.ox, p.oy, [134, 96, 67], 18, 13); // dirt
const rGrass = mulberry32(91);
// oberer Grasstreifen (ca. oberste 4 px)
for (let x = 0; x < TILE_PX; x++) {
const h = 3 + ((rGrass() * 3) | 0); // 3..5 px Gras
for (let y = 0; y < h; y++) {
const d = (rGrass() - 0.5) * 24;
const c = shade([102, 168, 64], d);
ctx.fillStyle = `rgb(${c[0]},${c[1]},${c[2]})`;
ctx.fillRect(p.ox + x, p.oy + y, 1, 1);
}
}
// --- WOOD TOP: Jahresringe ---
p = t(TILE.WOOD_TOP);
paintNoise(ctx, p.ox, p.oy, [150, 111, 66], 10, 31);
ctx.strokeStyle = 'rgb(96,68,38)';
ctx.lineWidth = 1;
for (let radius = 2; radius <= 7; radius += 2) {
ctx.beginPath();
ctx.arc(p.ox + 7.5, p.oy + 7.5, radius, 0, Math.PI * 2);
ctx.stroke();
}
// --- WOOD SIDE: Rinde mit vertikalen Streifen ---
p = t(TILE.WOOD_SIDE);
paintNoise(ctx, p.ox, p.oy, [110, 80, 45], 14, 71);
const rBark = mulberry32(123);
for (let x = 0; x < TILE_PX; x++) {
if (rBark() < 0.4) {
ctx.fillStyle = 'rgb(80,56,30)';
for (let y = 0; y < TILE_PX; y++) ctx.fillRect(p.ox + x, p.oy + y, 1, 1);
}
}
// --- LEAVES ---
p = t(TILE.LEAVES);
paintNoise(ctx, p.ox, p.oy, [62, 130, 44], 30, 55);
const rLeaf = mulberry32(333);
for (let i = 0; i < 26; i++) {
const d = (rLeaf() - 0.5) * 40;
const c = shade([44, 100, 30], d);
ctx.fillStyle = `rgb(${c[0]},${c[1]},${c[2]})`;
ctx.fillRect(p.ox + ((rLeaf() * TILE_PX) | 0), p.oy + ((rLeaf() * TILE_PX) | 0), 2, 2);
}
// --- WATER (halbtransparent) ---
p = t(TILE.WATER);
const rWater = mulberry32(999);
for (let y = 0; y < TILE_PX; y++) {
for (let x = 0; x < TILE_PX; x++) {
const d = (rWater() - 0.5) * 18;
const c = shade([54, 102, 196], d);
ctx.fillStyle = `rgba(${c[0]},${c[1]},${c[2]},0.72)`;
ctx.fillRect(p.ox + x, p.oy + y, 1, 1);
}
}
const tex = new THREE.CanvasTexture(canvas);
tex.magFilter = THREE.NearestFilter;
tex.minFilter = THREE.NearestFilter;
tex.generateMipmaps = false;
tex.colorSpace = THREE.SRGBColorSpace;
tex.wrapS = THREE.ClampToEdgeWrapping;
tex.wrapT = THREE.ClampToEdgeWrapping;
return tex;
}
+201
View File
@@ -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;
}
}
+132
View File
@@ -0,0 +1,132 @@
// Weltgenerierung: Terrain, Biome, Hoehlen, Wasser, Baeume.
// Arbeitet direkt auf Chunk-Block-Arrays. Baeume koennen chunk-uebergreifend
// in Nachbarn schreiben (world.setGen / world.getGen).
import {
CHUNK_SIZE, CHUNK_HEIGHT, SEA_LEVEL, BLOCK, BIOME,
} from './constants.js';
import {
noise2DTerrain, noise2DDetail, noise2DTemp, noise2DHumid, noise3DCave, fbm2,
} from './noise.js';
import { hash2 } from './rng.js';
// Biom aus Temperatur & Feuchtigkeit
export function getBiome(wx, wz) {
const temp = noise2DTemp(wx * 0.004, wz * 0.004); // -1..1
const humid = noise2DHumid(wx * 0.004 + 50, wz * 0.004 - 50);
if (temp > 0.35 && humid < 0.0) return BIOME.DESERT;
if (humid > 0.05) return BIOME.FOREST;
return BIOME.PLAINS;
}
// Terrainhoehe (Ganzzahl) pro Weltsaeule in Abhaengigkeit vom Biom
export function terrainHeight(wx, wz, biome) {
// Basis-Huegel
let n = fbm2(noise2DTerrain, wx * 0.0125, wz * 0.0125, 4, 2.0, 0.5); // -1..1
let height;
if (biome === BIOME.DESERT) {
const dunes = noise2DDetail(wx * 0.03, wz * 0.03) * 0.5;
height = SEA_LEVEL + 1 + (n * 0.5 + dunes) * 6; // flach
} else if (biome === BIOME.FOREST) {
height = SEA_LEVEL + 4 + n * 10;
} else {
// Plains mit gelegentlichen Huegeln
const hill = fbm2(noise2DTerrain, wx * 0.005 + 100, wz * 0.005 - 100, 2, 2.0, 0.5);
height = SEA_LEVEL + 2 + n * 7 + hill * 6;
}
return Math.max(2, Math.min(CHUNK_HEIGHT - 6, Math.round(height)));
}
// Setzt einen Baum (Stamm + Blattkrone) an (wx, groundY, wz)
function placeTree(world, wx, groundY, wz) {
const h = 4 + (Math.floor(hash2(wx + 7, wz - 3) * 3)); // 4..6
for (let i = 1; i <= h; i++) {
world.setGen(wx, groundY + i, wz, BLOCK.WOOD);
}
const topY = groundY + h;
// Blattkrone
for (let dy = -2; dy <= 1; dy++) {
const radius = dy <= -1 ? 2 : 1;
for (let dx = -radius; dx <= radius; dx++) {
for (let dz = -radius; dz <= radius; dz++) {
if (dx === 0 && dz === 0 && dy <= 0) continue; // Stammbereich freilassen
// Ecken etwas ausduennen
const dist = Math.abs(dx) + Math.abs(dz) + Math.abs(dy);
if (dist > 3) continue;
if ((Math.abs(dx) === radius && Math.abs(dz) === radius) && hash2(wx + dx * 13, wz + dz * 17) < 0.5) continue;
const ly = topY + dy;
if (ly < 0 || ly >= CHUNK_HEIGHT) continue;
if (world.getGen(wx + dx, ly, wz + dz) === BLOCK.AIR) {
world.setGen(wx + dx, ly, wz + dz, BLOCK.LEAVES);
}
}
}
}
// Spitze
if (world.getGen(wx, topY + 1, wz) === BLOCK.AIR) {
world.setGen(wx, topY + 1, wz, BLOCK.LEAVES);
}
}
// Fuellt Block-Daten eines Chunks (ohne Meshing).
export function generateChunk(world, chunk) {
const baseX = chunk.cx * CHUNK_SIZE;
const baseZ = chunk.cz * CHUNK_SIZE;
for (let lx = 0; lx < CHUNK_SIZE; lx++) {
for (let lz = 0; lz < CHUNK_SIZE; lz++) {
const wx = baseX + lx;
const wz = baseZ + lz;
const biome = getBiome(wx, wz);
const surface = terrainHeight(wx, wz, biome);
for (let y = 0; y <= surface; y++) {
let block = BLOCK.STONE;
if (y === 0) {
block = BLOCK.BEDROCK;
} else if (y === surface) {
block = (biome === BIOME.DESERT || surface <= SEA_LEVEL + 1) ? BLOCK.SAND : BLOCK.GRASS;
} else if (y >= surface - 3) {
block = (biome === BIOME.DESERT || surface <= SEA_LEVEL + 1) ? BLOCK.SAND : BLOCK.DIRT;
} else {
block = BLOCK.STONE;
}
// Hoehlen aushoehlen (nicht ganz unten, nicht an der Oberflaeche)
if (y > 2 && y < surface - 1) {
const c = noise3DCave(wx * 0.055, y * 0.09, wz * 0.055);
if (c > 0.62) block = BLOCK.AIR;
}
if (block !== BLOCK.AIR) chunk.set(lx, y, lz, block);
}
// Wasser auffuellen bis Meereshoehe
if (surface < SEA_LEVEL) {
for (let y = surface + 1; y <= SEA_LEVEL; y++) {
if (chunk.get(lx, y, lz) === BLOCK.AIR) chunk.set(lx, y, lz, BLOCK.WATER);
}
}
}
}
// Baeume (nur in Wald/Plains, oberhalb Wasser, deterministisch)
for (let lx = 0; lx < CHUNK_SIZE; lx++) {
for (let lz = 0; lz < CHUNK_SIZE; lz++) {
const wx = baseX + lx;
const wz = baseZ + lz;
const biome = getBiome(wx, wz);
if (biome === BIOME.DESERT) continue;
const treeChance = biome === BIOME.FOREST ? 0.028 : 0.006;
if (hash2(wx * 3 + 1, wz * 5 + 2) < treeChance) {
// Bodenflaeche finden
let gy = -1;
for (let y = CHUNK_HEIGHT - 1; y >= 0; y--) {
const b = world.getGen(wx, y, wz);
if (b === BLOCK.GRASS) { gy = y; break; }
if (b !== BLOCK.AIR && b !== BLOCK.LEAVES && b !== BLOCK.WATER) break;
}
if (gy > SEA_LEVEL) placeTree(world, wx, gy, wz);
}
}
}
}