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
+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);
|
||||
Reference in new issue
Block a user