// Haupt-Einstiegspunkt: Scene/Camera/Renderer, Licht, Welt, Spieler, Loop. import * as THREE from 'three'; import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js'; import { RenderPass } from 'three/addons/postprocessing/RenderPass.js'; import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js'; import { OutputPass } from 'three/addons/postprocessing/OutputPass.js'; import { BLOCK, BIOME, HOTBAR, BLOCK_COLOR, } from './constants.js'; import { RENDER_DISTANCE, MAX_OPS_PER_FRAME, MAX_PIXEL_RATIO, ANTIALIAS, MSAA_SAMPLES, TONE_MAPPING, TONE_MAPPING_EXPOSURE, SKY_COLOR, FOG_NEAR, FOG_FAR, CAMERA_FOV, CAMERA_NEAR, CAMERA_FAR, HEMISPHERE, SUN, SUN_DIRECTION, SHADOWS_ENABLED, SHADOW_MAP_SIZE, SHADOW_AREA, SHADOW_BIAS, SHADOW_NORMAL_BIAS, BLOOM_ENABLED, BLOOM, PLAYER, SPAWN, REACH, } from './config.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 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: ANTIALIAS }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, MAX_PIXEL_RATIO)); renderer.setSize(window.innerWidth, window.innerHeight); renderer.outputColorSpace = THREE.SRGBColorSpace; renderer.shadowMap.enabled = SHADOWS_ENABLED; renderer.shadowMap.type = THREE.PCFSoftShadowMap; renderer.toneMapping = TONE_MAPPING; renderer.toneMappingExposure = TONE_MAPPING_EXPOSURE; const scene = new THREE.Scene(); scene.background = new THREE.Color(SKY_COLOR); scene.fog = new THREE.Fog(SKY_COLOR, FOG_NEAR, FOG_FAR); const camera = new THREE.PerspectiveCamera(CAMERA_FOV, window.innerWidth / window.innerHeight, CAMERA_NEAR, CAMERA_FAR); camera.rotation.order = 'YXZ'; // ---------- Licht ---------- const hemi = new THREE.HemisphereLight(HEMISPHERE.sky, HEMISPHERE.ground, HEMISPHERE.intensity); scene.add(hemi); const sun = new THREE.DirectionalLight(SUN.color, SUN.intensity); // Schattenwerfer: orthografische Shadow-Camera, die pro Frame dem Spieler folgt // (kleines Frustum -> hohe Texeldichte + performant, da ferne Chunks cullt). sun.castShadow = SHADOWS_ENABLED; sun.shadow.mapSize.set(SHADOW_MAP_SIZE, SHADOW_MAP_SIZE); sun.shadow.camera.near = 1; sun.shadow.camera.far = 260; // halbe Kantenlaenge (Welt-Einheiten) um den Spieler = SHADOW_AREA aus config sun.shadow.camera.left = -SHADOW_AREA; sun.shadow.camera.right = SHADOW_AREA; sun.shadow.camera.top = SHADOW_AREA; sun.shadow.camera.bottom = -SHADOW_AREA; sun.shadow.bias = SHADOW_BIAS; sun.shadow.normalBias = SHADOW_NORMAL_BIAS; scene.add(sun); scene.add(sun.target); // Konstanter Richtungs-Offset; Position/Target werden in der Loop ans Spieler-Auge gesetzt. const SUN_DIR = new THREE.Vector3(...SUN_DIRECTION).normalize(); const _sunOffset = SUN_DIR.clone().multiplyScalar(120); // ---------- Texturen & Materialien ---------- // vertexColors aktiviert: der Mesher liefert pro Vertex einen AO-Wert (Grau- // stufe), der in den Beleuchtungsterm multipliziert wird -> "Smooth Lighting". const atlas = buildAtlasTexture(); const matOpaque = new THREE.MeshLambertMaterial({ map: atlas, vertexColors: true }); const matWater = new THREE.MeshLambertMaterial({ map: atlas, vertexColors: true, transparent: true, depthWrite: false, side: THREE.DoubleSide, }); // ---------- Post-Processing ---------- // EffectComposer: RenderPass -> UnrealBloomPass (subtiler Glanz auf hellen // Flaechen) -> OutputPass (wendet renderer.toneMapping=ACES + sRGB an). // HalfFloat-Target verhindert Banding im HDR-Bereich; samples=4 aktiviert MSAA, // da der Composer ansonsten ohne Kantenglaettung in sein Puffer rendert. const rt = new THREE.WebGLRenderTarget(window.innerWidth, window.innerHeight, { type: THREE.HalfFloatType, }); const composer = new EffectComposer(renderer, rt); // MSAA auf beiden internen Puffern (WebGL2 erlaubt MSAA auf Float-Targets), // damit die Szene im Composer kantengeglättet wird. Vorsichtshalber auf beiden // Targets explizit setzen, falls clone() die Property nicht forterbt. composer.renderTarget1.samples = MSAA_SAMPLES; composer.renderTarget2.samples = MSAA_SAMPLES; composer.addPass(new RenderPass(scene, camera)); const bloom = new UnrealBloomPass( new THREE.Vector2(window.innerWidth, window.innerHeight), BLOOM.strength, BLOOM.radius, BLOOM.threshold, ); if (BLOOM_ENABLED) composer.addPass(bloom); composer.addPass(new OutputPass()); // ---------- 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 = SPAWN.x, sz = SPAWN.z; 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] + PLAYER.EYE, 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); composer.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] - PLAYER.HALF, maxX = player.pos[0] + PLAYER.HALF; const minY = player.pos[1], maxY = player.pos[1] + PLAYER.HEIGHT; const minZ = player.pos[2] - PLAYER.HALF, maxZ = player.pos[2] + PLAYER.HALF; 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); // Sonne folgt dem Spieler -> Shadow-Frustum bleibt um den Spieler zentriert. sun.position.set( player.pos[0] + _sunOffset.x, player.pos[1] + _sunOffset.y, player.pos[2] + _sunOffset.z ); sun.target.position.set(player.pos[0], player.pos[1], player.pos[2]); sun.target.updateMatrixWorld(); composer.render(); // 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);