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
+19
@@ -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/
|
||||||
@@ -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.
|
||||||
+43
@@ -0,0 +1,43 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||||
|
<title>Minecraft Clone</title>
|
||||||
|
<link rel="stylesheet" href="style.css" />
|
||||||
|
<script type="importmap">
|
||||||
|
{
|
||||||
|
"imports": {
|
||||||
|
"three": "https://cdn.jsdelivr.net/npm/three@0.165.0/build/three.module.js",
|
||||||
|
"simplex-noise": "https://cdn.jsdelivr.net/npm/simplex-noise@4.0.1/dist/esm/simplex-noise.js"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Lade-/Start-Overlay -->
|
||||||
|
<div id="overlay">
|
||||||
|
<div class="panel">
|
||||||
|
<h1>Minecraft Clone</h1>
|
||||||
|
<p>Klicke, um zu starten.</p>
|
||||||
|
<div class="keys">
|
||||||
|
<div><b>WASD</b> Laufen <b>Leertaste</b> Springen</div>
|
||||||
|
<div><b>Maus</b> Umsehen <b>Shift</b> Sneak / Runter (Fly)</div>
|
||||||
|
<div><b>Linke Maustaste</b> Abbauen <b>Rechte Maustaste</b> Setzen</div>
|
||||||
|
<div><b>F</b> Flug-Modus <b>1–6</b> Block wählen <b>Esc</b> Pause</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Crosshair -->
|
||||||
|
<div id="crosshair"></div>
|
||||||
|
|
||||||
|
<!-- Hotbar -->
|
||||||
|
<div id="hotbar"></div>
|
||||||
|
|
||||||
|
<!-- Debug-HUD -->
|
||||||
|
<div id="hud"></div>
|
||||||
|
|
||||||
|
<script type="module" src="js/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
Reference in new issue
Block a user