From 1049253d5473490e74f40e6e26ffd7b4e7f15719 Mon Sep 17 00:00:00 2001 From: minecraft-clone Date: Sat, 20 Jun 2026 23:55:12 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Minecraft=20Clone=20MVP=20=E2=80=93=20p?= =?UTF-8?q?rozedurale=20Welt,=20Bl=C3=B6cke,=20Abbauen/Setzen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .gitignore | 19 +++++ README.md | 66 ++++++++++++++++ index.html | 43 ++++++++++ js/constants.js | 93 ++++++++++++++++++++++ js/input.js | 47 +++++++++++ js/main.js | 203 ++++++++++++++++++++++++++++++++++++++++++++++++ js/mesher.js | 145 ++++++++++++++++++++++++++++++++++ js/noise.js | 30 +++++++ js/player.js | 145 ++++++++++++++++++++++++++++++++++ js/raycast.js | 50 ++++++++++++ js/rng.js | 30 +++++++ js/textures.js | 125 +++++++++++++++++++++++++++++ js/world.js | 201 +++++++++++++++++++++++++++++++++++++++++++++++ js/worldgen.js | 132 +++++++++++++++++++++++++++++++ style.css | 107 +++++++++++++++++++++++++ 15 files changed, 1436 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 index.html create mode 100644 js/constants.js create mode 100644 js/input.js create mode 100644 js/main.js create mode 100644 js/mesher.js create mode 100644 js/noise.js create mode 100644 js/player.js create mode 100644 js/raycast.js create mode 100644 js/rng.js create mode 100644 js/textures.js create mode 100644 js/world.js create mode 100644 js/worldgen.js create mode 100644 style.css diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..41c9ab5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# OS / Editor +.DS_Store +Thumbs.db +*.swp +*.swo +*~ +.idea/ +.vscode/ +*.sublime-project +*.sublime-workspace + +# Logs +*.log +npm-debug.log* + +# Lokale Server / Temp (nichts davon wird aktuell erzeugt, aber sicherheitshalber) +*.local +.tmp/ +.cache/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..2060edd --- /dev/null +++ b/README.md @@ -0,0 +1,66 @@ +# Minecraft Clone + +Ein browserbasierter Voxel-Sandbox-Clone mit **Three.js** und prozeduraler Weltgenerierung. +Ohne Build-Step – läuft direkt im Browser über CDN-Module. + +## Starten + +Einfach `index.html` über einen lokalen Webserver öffnen (ES-Module brauchen `http://`, kein `file://`): + +```bash +cd minecraft-clone +python3 -m http.server 8080 +# Browser: http://localhost:8080 +``` + +> Das Projekt liegt unter `…/stacks/web/html/minecraft-clone` und wird i. d. R. schon +> vom Web-Stack ausgeliefert. + +## Features + +- **Weltgenerierung** + - FBM-Terrain (fraktaler Noise) + - **Biome**: Plains, Wald, Wüste (über Temperatur-/Feuchtigkeit-Noise) + - **Höhlen** über 3D-Noise + - **Wasser** auf Meereshöhe + - **Bäume** (Stamm + Blattkrone), chunk-übergreifend +- **Blöcke**: Grass, Dirt, Stein, Sand, Holz, Blätter, Wasser, Bedrock +- **Abbauen** (Linke Maustaste) und **Setzen** (Rechte Maustaste) per DDA-Voxel-Raycast +- **Streaming**: Chunks werden um den Spieler dynamisch geladen/entfernt +- First-Person-Steuerung mit Gravitation, Kollision, Springen, **Fly-Modus** & Schwimmen + +## Steuerung + +| Taste | Aktion | +|---|---| +| `WASD` | Laufen | +| `Maus` | Umsehen | +| `Leertaste` | Springen / im Wasser aufsteigen | +| `Shift` | im Fly nach unten | +| `Strg` | Sprinten | +| `Linke Maustaste` | Block abbauen | +| `Rechte Maustaste` | Block setzen | +| `1`–`6` | Block in der Hotbar wählen | +| `F` | Flug-Modus ein/aus | +| `Esc` | Maus freigeben / Pause | + +## Architektur + +| Datei | Verantwortlichkeit | +|---|---| +| `index.html` / `style.css` | Markup, UI (Overlay, Hotbar, Crosshair, HUD), Importmap | +| `js/constants.js` | Block-/Biome-/Atlas-Konstanten, Tile-Mapping | +| `js/rng.js` | Seeded PRNG (mulberry32) + Hashes | +| `js/noise.js` | Noise-Funktionen (Terrain, Biome, Höhlen) + FBM | +| `js/textures.js` | Prozeduraler 16×16-Texturatlas | +| `js/worldgen.js` | Terrain, Biome, Höhlen, Wasser, Bäume | +| `js/mesher.js` | Face-Culling-Meshing → opake + transparente Geometrie | +| `js/world.js` | Chunk-Storage, Block-Zugriff, Streaming, Mesh-Verwaltung | +| `js/player.js` | Bewegung, Gravitation, AABB-Kollision, Fly/Schwimmen | +| `js/raycast.js` | DDA-Voxel-Raycast (Amanatides & Woo) | +| `js/input.js` | Tastatur-/Maus-Input + Pointer-Lock | +| `js/main.js` | Scene/Renderer/Licht, Game-Loop, Interaktion, HUD | + +## bewusst weggelassen (Out of Scope) + +Crafting, Gegner/NPCs, Erze. diff --git a/index.html b/index.html new file mode 100644 index 0000000..3d1eb56 --- /dev/null +++ b/index.html @@ -0,0 +1,43 @@ + + + + + + Minecraft Clone + + + + + +
+
+

Minecraft Clone

+

Klicke, um zu starten.

+
+
WASD Laufen   Leertaste Springen
+
Maus Umsehen   Shift Sneak / Runter (Fly)
+
Linke Maustaste Abbauen   Rechte Maustaste Setzen
+
F Flug-Modus   1–6 Block wählen   Esc Pause
+
+
+
+ + +
+ + +
+ + +
+ + + + diff --git a/js/constants.js b/js/constants.js new file mode 100644 index 0000000..a19a04b --- /dev/null +++ b/js/constants.js @@ -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', +}; diff --git a/js/input.js b/js/input.js new file mode 100644 index 0000000..f357fbd --- /dev/null +++ b/js/input.js @@ -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); } +} diff --git a/js/main.js b/js/main.js new file mode 100644 index 0000000..142e9e2 --- /dev/null +++ b/js/main.js @@ -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 = `${i + 1}`; + 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); diff --git a/js/mesher.js b/js/mesher.js new file mode 100644 index 0000000..cc6a3ad --- /dev/null +++ b/js/mesher.js @@ -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) }; +} diff --git a/js/noise.js b/js/noise.js new file mode 100644 index 0000000..60a13da --- /dev/null +++ b/js/noise.js @@ -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] +} diff --git a/js/player.js b/js/player.js new file mode 100644 index 0000000..7238388 --- /dev/null +++ b/js/player.js @@ -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; + } + } + } + } + } +} diff --git a/js/raycast.js b/js/raycast.js new file mode 100644 index 0000000..e08437a --- /dev/null +++ b/js/raycast.js @@ -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; +} diff --git a/js/rng.js b/js/rng.js new file mode 100644 index 0000000..b559f61 --- /dev/null +++ b/js/rng.js @@ -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; +} diff --git a/js/textures.js b/js/textures.js new file mode 100644 index 0000000..cb08ad2 --- /dev/null +++ b/js/textures.js @@ -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; +} diff --git a/js/world.js b/js/world.js new file mode 100644 index 0000000..b26367d --- /dev/null +++ b/js/world.js @@ -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; + } +} diff --git a/js/worldgen.js b/js/worldgen.js new file mode 100644 index 0000000..bb6d59c --- /dev/null +++ b/js/worldgen.js @@ -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); + } + } + } +} diff --git a/style.css b/style.css new file mode 100644 index 0000000..92273c0 --- /dev/null +++ b/style.css @@ -0,0 +1,107 @@ +* { margin: 0; padding: 0; box-sizing: border-box; } + +html, body { + width: 100%; + height: 100%; + overflow: hidden; + background: #8fc3ff; + font-family: -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + color: #fff; + user-select: none; +} + +canvas { display: block; } + +/* ---------- Crosshair ---------- */ +#crosshair { + position: fixed; + left: 50%; top: 50%; + width: 22px; height: 22px; + transform: translate(-50%, -50%); + pointer-events: none; + opacity: 0.85; + z-index: 5; +} +#crosshair::before, #crosshair::after { + content: ""; + position: absolute; + background: #fff; + box-shadow: 0 0 2px rgba(0,0,0,0.8); +} +#crosshair::before { left: 50%; top: 0; width: 2px; height: 100%; transform: translateX(-50%); } +#crosshair::after { top: 50%; left: 0; height: 2px; width: 100%; transform: translateY(-50%); } + +/* ---------- HUD ---------- */ +#hud { + position: fixed; + top: 8px; left: 8px; + font-size: 13px; + line-height: 1.5; + text-shadow: 1px 1px 2px rgba(0,0,0,0.9); + pointer-events: none; + z-index: 5; + white-space: pre; +} + +/* ---------- Hotbar ---------- */ +#hotbar { + position: fixed; + left: 50%; bottom: 14px; + transform: translateX(-50%); + display: flex; + gap: 6px; + padding: 6px; + background: rgba(0,0,0,0.35); + border: 2px solid rgba(0,0,0,0.5); + border-radius: 6px; + z-index: 5; +} +.slot { + width: 52px; height: 52px; + border: 2px solid rgba(255,255,255,0.25); + border-radius: 4px; + background: rgba(0,0,0,0.25); + position: relative; + display: flex; + align-items: center; + justify-content: center; +} +.slot.active { border-color: #fff; box-shadow: 0 0 8px rgba(255,255,255,0.6); } +.slot .num { + position: absolute; + top: 2px; left: 4px; + font-size: 11px; + text-shadow: 1px 1px 2px #000; +} +.slot .swatch { + width: 34px; height: 34px; + border-radius: 3px; + border: 1px solid rgba(0,0,0,0.4); + image-rendering: pixelated; +} + +/* ---------- Start-Overlay ---------- */ +#overlay { + position: fixed; + inset: 0; + background: radial-gradient(circle at 50% 40%, rgba(40,70,110,0.85), rgba(10,20,35,0.92)); + display: flex; + align-items: center; + justify-content: center; + z-index: 10; + cursor: pointer; + transition: opacity 0.4s ease; +} +#overlay.hidden { opacity: 0; pointer-events: none; } +.panel { + text-align: center; + background: rgba(0,0,0,0.45); + border: 1px solid rgba(255,255,255,0.15); + padding: 30px 42px; + border-radius: 12px; + max-width: 560px; +} +.panel h1 { font-size: 30px; margin-bottom: 10px; letter-spacing: 1px; } +.panel p { font-size: 16px; margin-bottom: 16px; opacity: 0.9; } +.keys { font-size: 13px; line-height: 1.9; opacity: 0.85; } +.keys b { color: #ffe08a; }