Created
July 11, 2026 14:57
-
-
Save senko/94ba9d3a4e02ec7186804a65cd462719 to your computer and use it in GitHub Desktop.
Minecraft clone by GLM-5.2
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Minecraft Clone</title> | |
| <style> | |
| * { margin: 0; padding: 0; box-sizing: border-box; } | |
| html, body { | |
| width: 100%; height: 100%; overflow: hidden; background: #000; | |
| font-family: 'Segoe UI', Tahoma, sans-serif; color: #fff; user-select: none; | |
| } | |
| canvas { display: block; } | |
| #hud { position: fixed; inset: 0; pointer-events: none; z-index: 10; } | |
| #crosshair { | |
| position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); | |
| width: 22px; height: 22px; | |
| } | |
| #crosshair::before, #crosshair::after { | |
| content: ''; position: absolute; background: rgba(255,255,255,0.85); | |
| box-shadow: 0 0 2px rgba(0,0,0,0.9); | |
| } | |
| #crosshair::before { width: 2px; height: 22px; left: 10px; top: 0; } | |
| #crosshair::after { width: 22px; height: 2px; top: 10px; left: 0; } | |
| #hotbar { | |
| position: absolute; bottom: 18px; left: 50%; transform: translateX(-50%); | |
| display: flex; gap: 3px; padding: 4px; | |
| background: rgba(0,0,0,0.55); border: 2px solid rgba(255,255,255,0.25); border-radius: 4px; | |
| } | |
| .slot { | |
| width: 52px; height: 52px; background: rgba(70,70,70,0.55); | |
| border: 2px solid rgba(0,0,0,0.6); border-radius: 2px; | |
| display: flex; align-items: center; justify-content: center; position: relative; | |
| } | |
| .slot.active { border-color: #fff; background: rgba(140,140,140,0.7); box-shadow: 0 0 6px rgba(255,255,255,0.4); } | |
| .slot canvas { width: 36px; height: 36px; image-rendering: pixelated; } | |
| .slot .count { | |
| position: absolute; bottom: 1px; right: 4px; font-size: 13px; font-weight: bold; | |
| text-shadow: 1px 1px 0 #000, -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000; | |
| } | |
| .slot .key { position: absolute; top: 1px; left: 4px; font-size: 10px; opacity: 0.8; text-shadow: 1px 1px 0 #000; } | |
| #stats { | |
| position: absolute; bottom: 80px; left: 50%; transform: translateX(-50%); | |
| display: flex; flex-direction: column; align-items: center; gap: 6px; | |
| } | |
| #health { display: flex; gap: 3px; } | |
| .heart { | |
| width: 18px; height: 18px; background: #e74c3c; | |
| clip-path: polygon(50% 15%, 61% 0%, 100% 0%, 100% 50%, 50% 100%, 0% 50%, 0% 0%, 39% 0%); | |
| filter: drop-shadow(1px 1px 0 #000); | |
| } | |
| .heart.empty { background: #3a3a3a; } | |
| #info { | |
| position: absolute; top: 8px; left: 8px; | |
| font-size: 12px; line-height: 1.6; text-shadow: 1px 1px 0 #000; | |
| background: rgba(0,0,0,0.45); padding: 8px 12px; border-radius: 4px; | |
| font-family: 'Consolas', monospace; | |
| } | |
| #info b { color: #ffeb3b; } | |
| #menu { | |
| position: fixed; inset: 0; z-index: 100; | |
| background: linear-gradient(135deg, #1a3a5c 0%, #2a5a3a 50%, #5a4a2a 100%); | |
| display: flex; flex-direction: column; align-items: center; justify-content: center; | |
| text-align: center; | |
| } | |
| #menu h1 { | |
| font-size: 52px; margin-bottom: 22px; text-shadow: 3px 3px 0 #000; | |
| letter-spacing: 3px; color: #fff; | |
| } | |
| #menu .subtitle { color: #cde; margin-bottom: 22px; font-size: 14px; } | |
| #menu .controls { | |
| background: rgba(0,0,0,0.55); padding: 18px 28px; border-radius: 8px; | |
| margin-bottom: 28px; max-width: 380px; | |
| } | |
| #menu .controls p { margin: 5px 0; font-size: 14px; text-align: left; } | |
| #menu .controls b { color: #ffeb3b; display: inline-block; width: 110px; } | |
| #start-btn { | |
| padding: 14px 44px; font-size: 20px; background: #4caf50; color: #fff; | |
| border: 2px solid #2e7d32; border-radius: 6px; cursor: pointer; | |
| font-weight: bold; letter-spacing: 1px; transition: all 0.2s; | |
| } | |
| #start-btn:hover { background: #66bb6a; transform: scale(1.05); } | |
| #damage-overlay { | |
| position: fixed; inset: 0; z-index: 50; | |
| background: radial-gradient(ellipse at center, transparent 35%, rgba(255,0,0,0.55) 100%); | |
| opacity: 0; pointer-events: none; transition: opacity 0.18s; | |
| } | |
| #message { | |
| position: fixed; top: 28%; left: 50%; transform: translateX(-50%); | |
| font-size: 28px; font-weight: bold; text-shadow: 3px 3px 0 #000; | |
| opacity: 0; transition: opacity 0.5s; z-index: 20; pointer-events: none; | |
| text-align: center; | |
| } | |
| #dead-screen { | |
| position: fixed; inset: 0; z-index: 200; | |
| background: rgba(80,0,0,0.78); display: none; flex-direction: column; | |
| align-items: center; justify-content: center; text-align: center; | |
| } | |
| #dead-screen h1 { font-size: 72px; margin-bottom: 24px; text-shadow: 3px 3px 0 #000; color: #fff; } | |
| #respawn-btn { | |
| padding: 14px 44px; font-size: 20px; background: #c62828; color: #fff; | |
| border: 2px solid #8a0000; border-radius: 6px; cursor: pointer; font-weight: bold; | |
| } | |
| #respawn-btn:hover { background: #e53935; } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="hud"> | |
| <div id="crosshair"></div> | |
| <div id="info"> | |
| <div><b>FPS:</b> <span id="fps">0</span></div> | |
| <div><b>Pos:</b> <span id="pos">0, 0, 0</span></div> | |
| <div><b>Mobs:</b> <span id="mob-count">0</span></div> | |
| <div><b>Time:</b> <span id="time-of-day">Day</span></div> | |
| </div> | |
| <div id="stats"> | |
| <div id="health"></div> | |
| </div> | |
| <div id="hotbar"></div> | |
| </div> | |
| <div id="damage-overlay"></div> | |
| <div id="message"></div> | |
| <div id="menu"> | |
| <h1>MINECRAFT CLONE</h1> | |
| <div class="subtitle">A simple voxel sandbox — build, mine, survive</div> | |
| <div class="controls"> | |
| <p><b>WASD</b> Move around</p> | |
| <p><b>Mouse</b> Look around</p> | |
| <p><b>Space</b> Jump</p> | |
| <p><b>Shift</b> Sprint</p> | |
| <p><b>Left Click</b> Break block / Attack mob</p> | |
| <p><b>Right Click</b> Place block</p> | |
| <p><b>1 - 8</b> Select block type</p> | |
| <p><b>Esc</b> Release mouse</p> | |
| </div> | |
| <button id="start-btn">Click to Play</button> | |
| </div> | |
| <div id="dead-screen"> | |
| <h1>You Died!</h1> | |
| <button id="respawn-btn">Respawn</button> | |
| </div> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script> | |
| <script> | |
| 'use strict'; | |
| // ============================================================ | |
| // CONFIGURATION | |
| // ============================================================ | |
| const WORLD_SIZE = 32; // X/Z extent | |
| const WORLD_HALF = WORLD_SIZE / 2; | |
| const REACH = 6; // block interaction distance | |
| const GRAVITY = -28; | |
| const JUMP_VEL = 9.5; | |
| const WALK_SPEED = 4.5; | |
| const SPRINT_SPEED = 7.5; | |
| const PLAYER_H = 1.8; // player AABB height | |
| const PLAYER_EYE = 1.62; // camera height above feet | |
| const PLAYER_R = 0.3; // player half-width | |
| const MAX_MOBS = 7; | |
| const MOB_SPEED = 2.4; | |
| const MOB_DMG = 12; | |
| const MOB_HP = 20; | |
| const PLAYER_MAX_HP= 100; | |
| const PLAYER_DMG = 8; | |
| const MOB_DETECT = 14; | |
| const MOB_ATTACK_RANGE = 1.4; | |
| const MOB_ATTACK_CD = 1.0; | |
| const REACH_MOB = 4; | |
| const DAY_LENGTH = 120; // seconds for full day-night cycle | |
| // ============================================================ | |
| // BLOCK DEFINITIONS | |
| // tile indices into the texture atlas (col, row) -- row 0 = top of canvas | |
| // ============================================================ | |
| const BLOCKS = { | |
| grass: { top:[0,0], bottom:[2,0], side:[1,0], name:'Grass', drops:'dirt' }, | |
| dirt: { top:[2,0], bottom:[2,0], side:[2,0], name:'Dirt', drops:'dirt' }, | |
| stone: { top:[3,0], bottom:[3,0], side:[3,0], name:'Stone', drops:'cobblestone' }, | |
| cobblestone: { top:[4,0], bottom:[4,0], side:[4,0], name:'Cobblestone', drops:'cobblestone' }, | |
| wood: { top:[5,0], bottom:[5,0], side:[6,0], name:'Wood', drops:'wood' }, | |
| planks: { top:[7,0], bottom:[7,0], side:[7,0], name:'Planks', drops:'planks' }, | |
| leaves: { top:[8,0], bottom:[8,0], side:[8,0], name:'Leaves', drops:'leaves', transparent:true }, | |
| sand: { top:[9,0], bottom:[9,0], side:[9,0], name:'Sand', drops:'sand' }, | |
| }; | |
| const HOTBAR = ['grass','dirt','stone','cobblestone','wood','planks','leaves','sand']; | |
| // Face definitions for building block geometry. | |
| // corners are ordered so that indices [0,1,2, 0,2,3] form two CCW triangles | |
| // when viewed from outside the cube. uv maps corner index -> (u,v) in tile space. | |
| const FACES = [ | |
| { // +X (right) | |
| dir:[ 1, 0, 0], | |
| corners:[[1,1,1],[1,0,1],[1,0,0],[1,1,0]], | |
| uv:[[0,1],[0,0],[1,0],[1,1]] | |
| }, | |
| { // -X (left) | |
| dir:[-1, 0, 0], | |
| corners:[[0,1,0],[0,0,0],[0,0,1],[0,1,1]], | |
| uv:[[0,1],[0,0],[1,0],[1,1]] | |
| }, | |
| { // +Y (top) | |
| dir:[ 0, 1, 0], | |
| corners:[[0,1,1],[1,1,1],[1,1,0],[0,1,0]], | |
| uv:[[0,1],[1,1],[1,0],[0,0]] | |
| }, | |
| { // -Y (bottom) | |
| dir:[ 0,-1, 0], | |
| corners:[[0,0,0],[1,0,0],[1,0,1],[0,0,1]], | |
| uv:[[0,0],[1,0],[1,1],[0,1]] | |
| }, | |
| { // +Z (front) | |
| dir:[ 0, 0, 1], | |
| corners:[[1,1,1],[0,1,1],[0,0,1],[1,0,1]], | |
| uv:[[0,1],[1,1],[1,0],[0,0]] | |
| }, | |
| { // -Z (back) | |
| dir:[ 0, 0,-1], | |
| corners:[[0,1,0],[1,1,0],[1,0,0],[0,0,0]], | |
| uv:[[0,1],[1,1],[1,0],[0,0]] | |
| }, | |
| ]; | |
| // ============================================================ | |
| // GLOBAL STATE | |
| // ============================================================ | |
| let scene, camera, renderer, clock; | |
| let blocks = new Map(); // "x,y,z" -> block type string | |
| let blockMeshes = {}; // type -> THREE.Mesh (merged geometry) | |
| let textureAtlas, textureMaterial, transparentMaterial; | |
| let player; | |
| let input = { keys:{}, mouseDown:false, rightDown:false }; | |
| let pointerLocked = false; | |
| let mobs = []; | |
| let selectedSlot = 0; | |
| let inventory = {}; | |
| let playerHealth = PLAYER_MAX_HP; | |
| let lastMobHitTime = 0; | |
| let gameRunning = false; | |
| let timeOfDay = 0.35; // 0=midnight, 0.25=dawn, 0.5=noon, 0.75=dusk | |
| let sun, moon, ambientLight, hemiLight; | |
| let lastMobSpawnTime = 0; | |
| let fpsAccum = 0, fpsFrames = 0, fpsLast = performance.now(); | |
| let skyMesh; | |
| let lastPlayerAttack = 0; | |
| let highlightMesh; | |
| // ============================================================ | |
| // TEXTURE ATLAS | |
| // ============================================================ | |
| function makeTextureAtlas() { | |
| const TILE = 16, COLS = 16; | |
| const canvas = document.createElement('canvas'); | |
| canvas.width = canvas.height = COLS * TILE; | |
| const ctx = canvas.getContext('2d'); | |
| ctx.imageSmoothingEnabled = false; | |
| function tilePos(col, row) { return [col * TILE, row * TILE]; } | |
| function fillNoise(c, r, g, b, variation = 32) { | |
| for (let y = 0; y < TILE; y++) { | |
| for (let x = 0; x < TILE; x++) { | |
| const n = (Math.random() - 0.5) * variation; | |
| const nr = Math.max(0, Math.min(255, r + n)) | 0; | |
| const ng = Math.max(0, Math.min(255, g + n)) | 0; | |
| const nb = Math.max(0, Math.min(255, b + n)) | 0; | |
| ctx.fillStyle = 'rgb(' + nr + ',' + ng + ',' + nb + ')'; | |
| ctx.fillRect(x, y, 1, 1); | |
| } | |
| } | |
| } | |
| function drawAt(col, row, fn) { | |
| const [tx, ty] = tilePos(col, row); | |
| ctx.save(); | |
| ctx.translate(tx, ty); | |
| fn(); | |
| ctx.restore(); | |
| } | |
| // 0,0: grass top | |
| drawAt(0, 0, () => fillNoise(95, 168, 63, 30)); | |
| // 1,0: grass side (top strip green, rest dirt) | |
| drawAt(1, 0, () => { | |
| fillNoise(134, 96, 67, 28); // dirt base | |
| // green top strip with irregular bottom edge | |
| for (let x = 0; x < TILE; x++) { | |
| const h = 3 + Math.floor(Math.random() * 3); | |
| for (let y = 0; y < h; y++) { | |
| const n = (Math.random() - 0.5) * 30; | |
| ctx.fillStyle = 'rgb(' + ((95+n)|0) + ',' + ((168+n)|0) + ',' + ((63+n)|0) + ')'; | |
| ctx.fillRect(x, y, 1, 1); | |
| } | |
| } | |
| }); | |
| // 2,0: dirt | |
| drawAt(2, 0, () => fillNoise(134, 96, 67, 28)); | |
| // 3,0: stone | |
| drawAt(3, 0, () => fillNoise(128, 128, 128, 26)); | |
| // 4,0: cobblestone | |
| drawAt(4, 0, () => { | |
| fillNoise(110, 110, 110, 18); | |
| ctx.fillStyle = 'rgb(70,70,70)'; | |
| // dark cracks | |
| for (let i = 0; i < 6; i++) { | |
| const x = Math.floor(Math.random() * TILE); | |
| const y = Math.floor(Math.random() * TILE); | |
| ctx.fillRect(x, y, 1 + Math.floor(Math.random()*2), 1); | |
| } | |
| // a few lighter pebbles | |
| for (let i = 0; i < 5; i++) { | |
| const x = Math.floor(Math.random() * TILE); | |
| const y = Math.floor(Math.random() * TILE); | |
| ctx.fillStyle = 'rgb(160,160,160)'; | |
| ctx.fillRect(x, y, 1, 1); | |
| } | |
| }); | |
| // 5,0: wood top (rings) | |
| drawAt(5, 0, () => { | |
| fillNoise(149, 110, 65, 20); | |
| ctx.strokeStyle = 'rgba(80,55,25,0.7)'; | |
| ctx.lineWidth = 1; | |
| for (let r = 2; r <= 7; r += 2) { | |
| ctx.beginPath(); | |
| ctx.arc(8, 8, r, 0, Math.PI * 2); | |
| ctx.stroke(); | |
| } | |
| ctx.fillStyle = 'rgb(90,60,30)'; | |
| ctx.fillRect(7, 7, 2, 2); | |
| }); | |
| // 6,0: wood side (vertical bark) | |
| drawAt(6, 0, () => { | |
| fillNoise(110, 78, 45, 22); | |
| // vertical streaks | |
| for (let x = 0; x < TILE; x += 2) { | |
| ctx.fillStyle = 'rgba(70,45,20,0.55)'; | |
| ctx.fillRect(x, 0, 1, TILE); | |
| } | |
| }); | |
| // 7,0: planks | |
| drawAt(7, 0, () => { | |
| fillNoise(184, 148, 92, 18); | |
| ctx.fillStyle = 'rgba(110,80,40,0.7)'; | |
| // horizontal plank separators | |
| ctx.fillRect(0, 4, TILE, 1); | |
| ctx.fillRect(0, 9, TILE, 1); | |
| ctx.fillRect(0, 14, TILE, 1); | |
| // vertical seams (offset per row) | |
| ctx.fillRect(7, 0, 1, 4); | |
| ctx.fillRect(3, 5, 1, 4); | |
| ctx.fillRect(11, 10, 1, 4); | |
| ctx.fillRect(7, 15, 1, 1); | |
| }); | |
| // 8,0: leaves | |
| drawAt(8, 0, () => { | |
| fillNoise(58, 107, 31, 36); | |
| // darker spots | |
| for (let i = 0; i < 18; i++) { | |
| const x = Math.floor(Math.random() * TILE); | |
| const y = Math.floor(Math.random() * TILE); | |
| ctx.fillStyle = 'rgb(' + (30+Math.random()*20|0) + ',' + (70+Math.random()*30|0) + ',' + (20+Math.random()*15|0) + ')'; | |
| ctx.fillRect(x, y, 1, 1); | |
| } | |
| }); | |
| // 9,0: sand | |
| drawAt(9, 0, () => fillNoise(222, 207, 152, 22)); | |
| const tex = new THREE.CanvasTexture(canvas); | |
| tex.magFilter = THREE.NearestFilter; | |
| tex.minFilter = THREE.NearestFilter; | |
| tex.generateMipmaps = false; | |
| tex.wrapS = tex.wrapT = THREE.ClampToEdgeWrapping; | |
| return tex; | |
| } | |
| // UV bounds for a tile at (col, row) in atlas with 16x16 tiles. | |
| // Returns {u0,v0,u1,v1} where v0 is bottom in UV space. | |
| function tileUV(col, row) { | |
| const s = 1 / 16; | |
| const u0 = col * s; | |
| const u1 = (col + 1) * s; | |
| // canvas row 0 is at the top; UV v=1 is at the top of the texture. | |
| // So canvas row r occupies v range (1 - (r+1)*s, 1 - r*s). | |
| const v1 = 1 - row * s; | |
| const v0 = 1 - (row + 1) * s; | |
| // small inset to avoid bleeding | |
| const e = 0.5 / 256; | |
| return { u0: u0 + e, v0: v0 + e, u1: u1 - e, v1: v1 - e }; | |
| } | |
| // ============================================================ | |
| // SCENE SETUP | |
| // ============================================================ | |
| function initScene() { | |
| scene = new THREE.Scene(); | |
| scene.background = new THREE.Color(0x87ceeb); | |
| scene.fog = new THREE.Fog(0x87ceeb, 30, 80); | |
| camera = new THREE.PerspectiveCamera(72, window.innerWidth / window.innerHeight, 0.1, 1000); | |
| renderer = new THREE.WebGLRenderer({ antialias: false }); | |
| renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| document.body.appendChild(renderer.domElement); | |
| clock = new THREE.Clock(); | |
| ambientLight = new THREE.AmbientLight(0xffffff, 0.55); | |
| scene.add(ambientLight); | |
| hemiLight = new THREE.HemisphereLight(0xbfdfff, 0x556644, 0.5); | |
| scene.add(hemiLight); | |
| sun = new THREE.DirectionalLight(0xffffee, 0.95); | |
| sun.position.set(50, 100, 30); | |
| scene.add(sun); | |
| moon = new THREE.DirectionalLight(0x9fb8d8, 0.0); | |
| scene.add(moon); | |
| // Sun visual | |
| const sunGeo = new THREE.SphereGeometry(3, 12, 12); | |
| const sunMat = new THREE.MeshBasicMaterial({ color: 0xfff7c0 }); | |
| const sunMesh = new THREE.Mesh(sunGeo, sunMat); | |
| sunMesh.position.copy(sun.position); | |
| scene.add(sunMesh); | |
| sun.userData.mesh = sunMesh; | |
| const moonMesh = new THREE.Mesh(new THREE.SphereGeometry(2.2, 12, 12), | |
| new THREE.MeshBasicMaterial({ color: 0xeef2ff })); | |
| scene.add(moonMesh); | |
| moon.userData.mesh = moonMesh; | |
| textureAtlas = makeTextureAtlas(); | |
| textureMaterial = new THREE.MeshLambertMaterial({ map: textureAtlas }); | |
| transparentMaterial = new THREE.MeshLambertMaterial({ | |
| map: textureAtlas, transparent: true, alphaTest: 0.5, side: THREE.DoubleSide | |
| }); | |
| // Block highlight (wireframe) for the targeted block | |
| const hlGeo = new THREE.BoxGeometry(1.002, 1.002, 1.002); | |
| const hlEdges = new THREE.EdgesGeometry(hlGeo); | |
| highlightMesh = new THREE.LineSegments(hlEdges, new THREE.LineBasicMaterial({ color: 0x000000, linewidth: 2 })); | |
| highlightMesh.visible = false; | |
| scene.add(highlightMesh); | |
| window.addEventListener('resize', onResize); | |
| } | |
| function onResize() { | |
| camera.aspect = window.innerWidth / window.innerHeight; | |
| camera.updateProjectionMatrix(); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| } | |
| // ============================================================ | |
| // WORLD GENERATION | |
| // ============================================================ | |
| function key(x, y, z) { return x + ',' + y + ',' + z; } | |
| function parseKey(k) { const p = k.split(','); return [parseInt(p[0]), parseInt(p[1]), parseInt(p[2])]; } | |
| function heightAt(x, z) { | |
| // layered sine waves for rolling hills | |
| let h = 4; | |
| h += Math.sin(x * 0.18) * Math.cos(z * 0.21) * 2.5; | |
| h += Math.sin(x * 0.07 + z * 0.05) * 1.6; | |
| h += Math.sin((x + z) * 0.13) * 0.8; | |
| return Math.max(1, Math.round(h)); | |
| } | |
| function generateWorld() { | |
| for (let x = -WORLD_HALF; x < WORLD_HALF; x++) { | |
| for (let z = -WORLD_HALF; z < WORLD_HALF; z++) { | |
| const h = heightAt(x, z); | |
| for (let y = 0; y <= h; y++) { | |
| let type; | |
| if (y === h) { | |
| type = (h <= 2) ? 'sand' : 'grass'; | |
| } else if (y >= h - 3) { | |
| type = 'dirt'; | |
| } else { | |
| type = 'stone'; | |
| } | |
| blocks.set(key(x, y, z), type); | |
| } | |
| } | |
| } | |
| // scatter trees on grass tops | |
| for (let x = -WORLD_HALF + 2; x < WORLD_HALF - 2; x++) { | |
| for (let z = -WORLD_HALF + 2; z < WORLD_HALF - 2; z++) { | |
| const h = heightAt(x, z); | |
| if (blocks.get(key(x, h, z)) !== 'grass') continue; | |
| if (Math.random() < 0.025) { | |
| placeTree(x, h + 1, z); | |
| } | |
| } | |
| } | |
| } | |
| function placeTree(x, y, z) { | |
| const trunk = 4 + Math.floor(Math.random() * 2); | |
| for (let i = 0; i < trunk; i++) { | |
| blocks.set(key(x, y + i, z), 'wood'); | |
| } | |
| const topY = y + trunk - 1; | |
| // leaf canopy: 5x5x3 with corners trimmed | |
| for (let dy = -1; dy <= 2; dy++) { | |
| const r = dy <= 0 ? 2 : 1; | |
| for (let dx = -r; dx <= r; dx++) { | |
| for (let dz = -r; dz <= r; dz++) { | |
| if (Math.abs(dx) === r && Math.abs(dz) === r && Math.random() < 0.5) continue; | |
| const lx = x + dx, ly = topY + dy, lz = z + dz; | |
| const kk = key(lx, ly, lz); | |
| if (!blocks.has(kk)) blocks.set(kk, 'leaves'); | |
| } | |
| } | |
| } | |
| } | |
| // ============================================================ | |
| // BLOCK GEOMETRY (merged per type, only exposed faces) | |
| // ============================================================ | |
| function isSolid(x, y, z) { | |
| return blocks.has(key(x, y, z)); | |
| } | |
| function buildBlockMesh(type) { | |
| const def = BLOCKS[type]; | |
| const positions = []; | |
| const uvs = []; | |
| const normals = []; | |
| const indices = []; | |
| for (const [k, t] of blocks) { | |
| if (t !== type) continue; | |
| const [bx, by, bz] = parseKey(k); | |
| for (let f = 0; f < 6; f++) { | |
| const face = FACES[f]; | |
| const nx = bx + face.dir[0]; | |
| const ny = by + face.dir[1]; | |
| const nz = bz + face.dir[2]; | |
| // Skip face if neighbor is solid. | |
| // For transparent blocks (leaves), draw face if neighbor is not the same type. | |
| const neighborType = blocks.get(key(nx, ny, nz)); | |
| if (neighborType) { | |
| if (!def.transparent) continue; | |
| if (neighborType === type) continue; | |
| } | |
| const tile = (f === 2) ? def.top : (f === 3) ? def.bottom : def.side; | |
| const uvb = tileUV(tile[0], tile[1]); | |
| const base = positions.length / 3; | |
| for (let c = 0; c < 4; c++) { | |
| const corner = face.corners[c]; | |
| positions.push(bx + corner[0], by + corner[1], bz + corner[2]); | |
| normals.push(face.dir[0], face.dir[1], face.dir[2]); | |
| const uvc = face.uv[c]; | |
| const u = uvb.u0 + (uvb.u1 - uvb.u0) * uvc[0]; | |
| const v = uvb.v0 + (uvb.v1 - uvb.v0) * uvc[1]; | |
| uvs.push(u, v); | |
| } | |
| indices.push(base, base + 1, base + 2, base, base + 2, base + 3); | |
| } | |
| } | |
| const geom = new THREE.BufferGeometry(); | |
| geom.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)); | |
| geom.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2)); | |
| geom.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3)); | |
| geom.setIndex(indices); | |
| return geom; | |
| } | |
| function rebuildAllBlockMeshes() { | |
| for (const type in blockMeshes) { | |
| scene.remove(blockMeshes[type]); | |
| blockMeshes[type].geometry.dispose(); | |
| } | |
| blockMeshes = {}; | |
| for (const type in BLOCKS) { | |
| const geom = buildBlockMesh(type); | |
| const mat = BLOCKS[type].transparent ? transparentMaterial : textureMaterial; | |
| const mesh = new THREE.Mesh(geom, mat); | |
| mesh.name = 'blocks_' + type; | |
| scene.add(mesh); | |
| blockMeshes[type] = mesh; | |
| } | |
| } | |
| // Rebuild only the affected types after a edit (more efficient than full rebuild). | |
| function rebuildBlockMeshTypes(types) { | |
| for (const type of types) { | |
| if (blockMeshes[type]) { | |
| scene.remove(blockMeshes[type]); | |
| blockMeshes[type].geometry.dispose(); | |
| } | |
| const geom = buildBlockMesh(type); | |
| const mat = BLOCKS[type].transparent ? transparentMaterial : textureMaterial; | |
| const mesh = new THREE.Mesh(geom, mat); | |
| scene.add(mesh); | |
| blockMeshes[type] = mesh; | |
| } | |
| } | |
| // ============================================================ | |
| // PLAYER | |
| // ============================================================ | |
| function initPlayer() { | |
| player = { | |
| position: new THREE.Vector3(0, 0, 0), | |
| velocity: new THREE.Vector3(0, 0, 0), | |
| onGround: false, | |
| yaw: 0, | |
| pitch: 0, | |
| }; | |
| // find spawn height | |
| const h = heightAt(0, 0); | |
| player.position.set(0.5, h + 2, 0.5); | |
| } | |
| // Clear any blocks that overlap the player's spawn area (in case a tree | |
| // generated on top of the spawn point). | |
| function clearSpawnArea() { | |
| const h = heightAt(0, 0); | |
| // remove any blocks above the terrain at the 3x3 area around spawn | |
| let changed = false; | |
| for (let x = -1; x <= 1; x++) { | |
| for (let z = -1; z <= 1; z++) { | |
| for (let y = h + 1; y <= h + 6; y++) { | |
| const k = key(x, y, z); | |
| if (blocks.has(k)) { blocks.delete(k); changed = true; } | |
| } | |
| } | |
| } | |
| return changed; | |
| } | |
| function getPlayerAABB() { | |
| return { | |
| minX: player.position.x - PLAYER_R, | |
| maxX: player.position.x + PLAYER_R, | |
| minY: player.position.y, | |
| maxY: player.position.y + PLAYER_H, | |
| minZ: player.position.z - PLAYER_R, | |
| maxZ: player.position.z + PLAYER_R, | |
| }; | |
| } | |
| // Move along one axis and resolve collisions against solid blocks. | |
| function moveAxis(axis, amount) { | |
| if (amount === 0) return; | |
| player.position[axis] += amount; | |
| const aabb = getPlayerAABB(); | |
| const bMinX = Math.floor(aabb.minX), bMaxX = Math.floor(aabb.maxX); | |
| const bMinY = Math.floor(aabb.minY), bMaxY = Math.floor(aabb.maxY); | |
| const bMinZ = Math.floor(aabb.minZ), bMaxZ = Math.floor(aabb.maxZ); | |
| for (let bx = bMinX; bx <= bMaxX; bx++) { | |
| for (let by = bMinY; by <= bMaxY; by++) { | |
| for (let bz = bMinZ; bz <= bMaxZ; bz++) { | |
| if (!isSolid(bx, by, bz)) continue; | |
| // Resolve along the moving axis | |
| if (axis === 'x') { | |
| if (amount > 0) player.position.x = bx - PLAYER_R - 0.0001; | |
| else player.position.x = bx + 1 + PLAYER_R + 0.0001; | |
| player.velocity.x = 0; | |
| } else if (axis === 'y') { | |
| if (amount > 0) { | |
| player.position.y = by - PLAYER_H - 0.0001; | |
| player.velocity.y = 0; | |
| } else { | |
| player.position.y = by + 1 + 0.0001; | |
| player.velocity.y = 0; | |
| player.onGround = true; | |
| } | |
| } else { // z | |
| if (amount > 0) player.position.z = bz - PLAYER_R - 0.0001; | |
| else player.position.z = bz + 1 + PLAYER_R + 0.0001; | |
| player.velocity.z = 0; | |
| } | |
| // After resolving, recompute AABB bounds so further checks in this loop are accurate | |
| const a = getPlayerAABB(); | |
| // Recompute floor ranges in case position changed | |
| } | |
| } | |
| } | |
| } | |
| function updatePlayer(dt) { | |
| // Compute desired horizontal movement from input | |
| const sinY = Math.sin(player.yaw); | |
| const cosY = Math.cos(player.yaw); | |
| let fwd = 0, strafe = 0; | |
| if (input.keys['KeyW']) fwd += 1; | |
| if (input.keys['KeyS']) fwd -= 1; | |
| if (input.keys['KeyD']) strafe += 1; | |
| if (input.keys['KeyA']) strafe -= 1; | |
| // normalize diagonal | |
| const len = Math.hypot(fwd, strafe); | |
| if (len > 0) { fwd /= len; strafe /= len; } | |
| const sprint = input.keys['ShiftLeft'] || input.keys['ShiftRight']; | |
| const speed = sprint ? SPRINT_SPEED : WALK_SPEED; | |
| // Forward is -Z in world space when yaw=0; rotate by yaw. | |
| // Player forward direction at yaw=0 is -Z. After yaw rotation around Y (right-hand rule): | |
| // forward = (-sin(yaw), 0, -cos(yaw)) | |
| // right = ( cos(yaw), 0, -sin(yaw)) | |
| // So movement = forward * fwd + right * strafe. | |
| const moveX = (strafe * cosY - fwd * sinY) * speed; | |
| const moveZ = (-strafe * sinY - fwd * cosY) * speed; | |
| player.velocity.x = moveX; | |
| player.velocity.z = moveZ; | |
| // Jump | |
| if (input.keys['Space'] && player.onGround) { | |
| player.velocity.y = JUMP_VEL; | |
| player.onGround = false; | |
| } | |
| // Gravity | |
| player.velocity.y += GRAVITY * dt; | |
| if (player.velocity.y < -50) player.velocity.y = -50; | |
| // Move axis by axis (helps prevent tunneling) | |
| player.onGround = false; | |
| moveAxis('x', player.velocity.x * dt); | |
| moveAxis('z', player.velocity.z * dt); | |
| moveAxis('y', player.velocity.y * dt); | |
| // Update camera | |
| camera.position.set(player.position.x, player.position.y + PLAYER_EYE, player.position.z); | |
| camera.rotation.order = 'YXZ'; | |
| camera.rotation.y = player.yaw; | |
| camera.rotation.x = player.pitch; | |
| // Out-of-world recovery | |
| if (player.position.y < -20) { | |
| playerHealth = 0; | |
| onPlayerDeath(); | |
| } | |
| } | |
| // ============================================================ | |
| // INPUT | |
| // ============================================================ | |
| function initInput() { | |
| document.addEventListener('keydown', (e) => { | |
| input.keys[e.code] = true; | |
| // hotbar selection 1-8 | |
| if (e.code.startsWith('Digit')) { | |
| const n = parseInt(e.code.slice(5)); | |
| if (n >= 1 && n <= 8) { | |
| selectedSlot = n - 1; | |
| updateHotbarUI(); | |
| } | |
| } | |
| if (e.code === 'Space') e.preventDefault(); | |
| }); | |
| document.addEventListener('keyup', (e) => { input.keys[e.code] = false; }); | |
| document.addEventListener('mousemove', (e) => { | |
| if (!pointerLocked) return; | |
| const s = 0.0024; | |
| player.yaw -= e.movementX * s; | |
| player.pitch -= e.movementY * s; | |
| const lim = Math.PI / 2 - 0.01; | |
| if (player.pitch > lim) player.pitch = lim; | |
| if (player.pitch < -lim) player.pitch = -lim; | |
| }); | |
| document.addEventListener('mousedown', (e) => { | |
| if (!pointerLocked) return; | |
| if (e.button === 0) { | |
| input.mouseDown = true; | |
| onPlayerAttack(); | |
| } else if (e.button === 2) { | |
| input.rightDown = true; | |
| onPlayerPlace(); | |
| } | |
| }); | |
| document.addEventListener('mouseup', (e) => { | |
| if (e.button === 0) input.mouseDown = false; | |
| if (e.button === 2) input.rightDown = false; | |
| }); | |
| document.addEventListener('contextmenu', (e) => e.preventDefault()); | |
| document.addEventListener('pointerlockchange', () => { | |
| pointerLocked = (document.pointerLockElement === renderer.domElement); | |
| if (!pointerLocked && gameRunning) { | |
| // show menu again | |
| document.getElementById('menu').style.display = 'flex'; | |
| } | |
| }); | |
| } | |
| function requestPointerLock() { | |
| renderer.domElement.requestPointerLock(); | |
| } | |
| // ============================================================ | |
| // BLOCK INTERACTION (DDA voxel raycast) | |
| // ============================================================ | |
| function raycastVoxel(maxDist) { | |
| const origin = camera.position; | |
| const dir = new THREE.Vector3(); | |
| camera.getWorldDirection(dir); | |
| let x = Math.floor(origin.x); | |
| let y = Math.floor(origin.y); | |
| let z = Math.floor(origin.z); | |
| const stepX = Math.sign(dir.x); | |
| const stepY = Math.sign(dir.y); | |
| const stepZ = Math.sign(dir.z); | |
| const tDeltaX = stepX !== 0 ? Math.abs(1 / dir.x) : Infinity; | |
| const tDeltaY = stepY !== 0 ? Math.abs(1 / dir.y) : Infinity; | |
| const tDeltaZ = stepZ !== 0 ? Math.abs(1 / dir.z) : Infinity; | |
| let tMaxX = stepX > 0 ? (x + 1 - origin.x) / dir.x : | |
| stepX < 0 ? (x - origin.x) / dir.x : Infinity; | |
| let tMaxY = stepY > 0 ? (y + 1 - origin.y) / dir.y : | |
| stepY < 0 ? (y - origin.y) / dir.y : Infinity; | |
| let tMaxZ = stepZ > 0 ? (z + 1 - origin.z) / dir.z : | |
| stepZ < 0 ? (z - origin.z) / dir.z : Infinity; | |
| let face = null; | |
| let t = 0; | |
| while (t <= maxDist) { | |
| if (isSolid(x, y, z)) { | |
| return { x, y, z, face, place: face ? { x: x - face.dx, y: y - face.dy, z: z - face.dz } : null }; | |
| } | |
| if (tMaxX < tMaxY && tMaxX < tMaxZ) { | |
| x += stepX; t = tMaxX; tMaxX += tDeltaX; | |
| face = { dx: -stepX, dy: 0, dz: 0 }; | |
| } else if (tMaxY < tMaxZ) { | |
| y += stepY; t = tMaxY; tMaxY += tDeltaY; | |
| face = { dx: 0, dy: -stepY, dz: 0 }; | |
| } else { | |
| z += stepZ; t = tMaxZ; tMaxZ += tDeltaZ; | |
| face = { dx: 0, dy: 0, dz: -stepZ }; | |
| } | |
| } | |
| return null; | |
| } | |
| function onPlayerAttack() { | |
| const now = performance.now() / 1000; | |
| if (now - lastPlayerAttack < 0.25) return; | |
| lastPlayerAttack = now; | |
| // First try to hit a mob (raycast against mob meshes) | |
| const ray = new THREE.Raycaster(); | |
| ray.setFromCamera(new THREE.Vector2(0, 0), camera); | |
| ray.far = REACH_MOB; | |
| const mobMeshes = mobs.map(m => m.mesh); | |
| const hits = ray.intersectObjects(mobMeshes, true); | |
| if (hits.length > 0) { | |
| // find the mob that owns the hit mesh | |
| let hitMesh = hits[0].object; | |
| while (hitMesh && !hitMesh.userData.mob) hitMesh = hitMesh.parent; | |
| if (hitMesh && hitMesh.userData.mob) { | |
| const mob = hitMesh.userData.mob; | |
| mob.health -= PLAYER_DMG; | |
| mob.hurtTime = 0.25; | |
| // knockback | |
| const kx = mob.position.x - player.position.x; | |
| const kz = mob.position.z - player.position.z; | |
| const kl = Math.hypot(kx, kz) || 1; | |
| mob.velocity.x += (kx / kl) * 4; | |
| mob.velocity.z += (kz / kl) * 4; | |
| mob.velocity.y += 3; | |
| if (mob.health <= 0) killMob(mob); | |
| showMessage(''); | |
| return; | |
| } | |
| } | |
| // Otherwise, break a block | |
| const hit = raycastVoxel(REACH); | |
| if (!hit) return; | |
| const k = key(hit.x, hit.y, hit.z); | |
| const type = blocks.get(k); | |
| if (!type) return; | |
| blocks.delete(k); | |
| // collect resource | |
| const drop = BLOCKS[type].drops; | |
| inventory[drop] = (inventory[drop] || 0) + 1; | |
| updateHotbarUI(); | |
| // rebuild affected types: this type + neighbors (which may have new exposed faces) | |
| const types = new Set([type]); | |
| const neighbors = [ | |
| [hit.x+1, hit.y, hit.z], [hit.x-1, hit.y, hit.z], | |
| [hit.x, hit.y+1, hit.z], [hit.x, hit.y-1, hit.z], | |
| [hit.x, hit.y, hit.z+1], [hit.x, hit.y, hit.z-1], | |
| ]; | |
| for (const [nx, ny, nz] of neighbors) { | |
| const nt = blocks.get(key(nx, ny, nz)); | |
| if (nt) types.add(nt); | |
| } | |
| rebuildBlockMeshTypes(types); | |
| } | |
| function onPlayerPlace() { | |
| const hit = raycastVoxel(REACH); | |
| if (!hit || !hit.place) return; | |
| const px = hit.place.x, py = hit.place.y, pz = hit.place.z; | |
| // Don't place inside the player | |
| const aabb = getPlayerAABB(); | |
| if (px + 1 > aabb.minX && px < aabb.maxX && | |
| py + 1 > aabb.minY && py < aabb.maxY && | |
| pz + 1 > aabb.minZ && pz < aabb.maxZ) return; | |
| const type = HOTBAR[selectedSlot]; | |
| // Require having the resource in inventory | |
| if (inventory[type] === undefined || inventory[type] <= 0) { | |
| // allow placing "dirt" by default? No - require resource. But allow placing | |
| // any block the player has at least 1 of. If they have 0, show a hint. | |
| showMessage('No ' + BLOCKS[type].name + ' in inventory'); | |
| return; | |
| } | |
| blocks.set(key(px, py, pz), type); | |
| inventory[type]--; | |
| updateHotbarUI(); | |
| const types = new Set([type]); | |
| const neighbors = [ | |
| [px+1, py, pz], [px-1, py, pz], | |
| [px, py+1, pz], [px, py-1, pz], | |
| [px, py, pz+1], [px, py, pz-1], | |
| ]; | |
| for (const [nx, ny, nz] of neighbors) { | |
| const nt = blocks.get(key(nx, ny, nz)); | |
| if (nt) types.add(nt); | |
| } | |
| rebuildBlockMeshTypes(types); | |
| } | |
| // ============================================================ | |
| // MOBS | |
| // ============================================================ | |
| function makeMobMesh() { | |
| const g = new THREE.Group(); | |
| const skin = 0x4a7c3a; | |
| const skinDark = 0x356128; | |
| const shirtColor = 0x3a5a8a; | |
| const head = new THREE.Mesh( | |
| new THREE.BoxGeometry(0.5, 0.5, 0.5), | |
| new THREE.MeshLambertMaterial({ color: skin }) | |
| ); | |
| head.position.y = 1.45; | |
| g.add(head); | |
| const body = new THREE.Mesh( | |
| new THREE.BoxGeometry(0.55, 0.7, 0.3), | |
| new THREE.MeshLambertMaterial({ color: shirtColor }) | |
| ); | |
| body.position.y = 0.85; | |
| g.add(body); | |
| const armL = new THREE.Mesh( | |
| new THREE.BoxGeometry(0.2, 0.7, 0.2), | |
| new THREE.MeshLambertMaterial({ color: skin }) | |
| ); | |
| armL.position.set(-0.38, 0.85, 0); | |
| g.add(armL); | |
| const armR = new THREE.Mesh( | |
| new THREE.BoxGeometry(0.2, 0.7, 0.2), | |
| new THREE.MeshLambertMaterial({ color: skin }) | |
| ); | |
| armR.position.set(0.38, 0.85, 0); | |
| g.add(armR); | |
| const legL = new THREE.Mesh( | |
| new THREE.BoxGeometry(0.22, 0.55, 0.22), | |
| new THREE.MeshLambertMaterial({ color: skinDark }) | |
| ); | |
| legL.position.set(-0.14, 0.275, 0); | |
| g.add(legL); | |
| const legR = new THREE.Mesh( | |
| new THREE.BoxGeometry(0.22, 0.55, 0.22), | |
| new THREE.MeshLambertMaterial({ color: skinDark }) | |
| ); | |
| legR.position.set(0.14, 0.275, 0); | |
| g.add(legR); | |
| g.userData.parts = { head, body, armL, armR, legL, legR }; | |
| return g; | |
| } | |
| function spawnMob() { | |
| // Pick a random surface position near the player but not too close | |
| let tries = 0; | |
| while (tries < 20) { | |
| tries++; | |
| const angle = Math.random() * Math.PI * 2; | |
| const dist = 12 + Math.random() * 12; | |
| const x = Math.round(player.position.x + Math.cos(angle) * dist); | |
| const z = Math.round(player.position.z + Math.sin(angle) * dist); | |
| if (x < -WORLD_HALF + 1 || x >= WORLD_HALF - 1) continue; | |
| if (z < -WORLD_HALF + 1 || z >= WORLD_HALF - 1) continue; | |
| const h = heightAt(x, z); | |
| if (!isSolid(x, h, z)) continue; | |
| const mesh = makeMobMesh(); | |
| const mob = { | |
| mesh, | |
| position: new THREE.Vector3(x + 0.5, h + 1, z + 0.5), | |
| velocity: new THREE.Vector3(0, 0, 0), | |
| health: MOB_HP, | |
| onGround: false, | |
| hurtTime: 0, | |
| attackCooldown: 0, | |
| wanderDir: Math.random() * Math.PI * 2, | |
| wanderTimer: 0, | |
| }; | |
| mesh.userData.mob = mob; | |
| mesh.position.copy(mob.position); | |
| scene.add(mesh); | |
| mobs.push(mob); | |
| return true; | |
| } | |
| return false; | |
| } | |
| function killMob(mob) { | |
| scene.remove(mob.mesh); | |
| mob.mesh.traverse(o => { | |
| if (o.geometry) o.geometry.dispose(); | |
| if (o.material) o.material.dispose(); | |
| }); | |
| const i = mobs.indexOf(mob); | |
| if (i >= 0) mobs.splice(i, 1); | |
| } | |
| function updateMob(mob, dt) { | |
| // AI: chase player if within detection range, otherwise wander | |
| const dx = player.position.x - mob.position.x; | |
| const dz = player.position.z - mob.position.z; | |
| const distToPlayer = Math.hypot(dx, dz); | |
| let desiredX = 0, desiredZ = 0; | |
| if (distToPlayer < MOB_DETECT) { | |
| const inv = 1 / (distToPlayer || 1); | |
| desiredX = dx * inv * MOB_SPEED; | |
| desiredZ = dz * inv * MOB_SPEED; | |
| // face the player | |
| mob.mesh.rotation.y = Math.atan2(dx, dz); | |
| } else { | |
| mob.wanderTimer -= dt; | |
| if (mob.wanderTimer <= 0) { | |
| mob.wanderDir = Math.random() * Math.PI * 2; | |
| mob.wanderTimer = 2 + Math.random() * 3; | |
| } | |
| desiredX = Math.sin(mob.wanderDir) * MOB_SPEED * 0.4; | |
| desiredZ = Math.cos(mob.wanderDir) * MOB_SPEED * 0.4; | |
| mob.mesh.rotation.y = mob.wanderDir; | |
| } | |
| // jump if blocked (simple heuristic: if there's a block at foot+0 level ahead) | |
| if (mob.onGround) { | |
| const ahead = new THREE.Vector3(desiredX, 0, desiredZ).normalize().multiplyScalar(0.6); | |
| const checkX = Math.floor(mob.position.x + ahead.x); | |
| const checkZ = Math.floor(mob.position.z + ahead.z); | |
| const footY = Math.floor(mob.position.y); | |
| if (isSolid(checkX, footY, checkZ) && !isSolid(checkX, footY + 1, checkZ)) { | |
| mob.velocity.y = JUMP_VEL * 0.85; | |
| mob.onGround = false; | |
| } | |
| } | |
| mob.velocity.x = desiredX; | |
| mob.velocity.z = desiredZ; | |
| mob.velocity.y += GRAVITY * dt; | |
| if (mob.velocity.y < -50) mob.velocity.y = -50; | |
| // Move with simple AABB collision against blocks | |
| moveMobAxis(mob, 'x', mob.velocity.x * dt); | |
| moveMobAxis(mob, 'z', mob.velocity.z * dt); | |
| mob.onGround = false; | |
| moveMobAxis(mob, 'y', mob.velocity.y * dt); | |
| // Apply position to mesh | |
| mob.mesh.position.copy(mob.position); | |
| // Walk animation: swing legs/arms based on horizontal speed | |
| const speed = Math.hypot(mob.velocity.x, mob.velocity.z); | |
| const t = performance.now() / 1000; | |
| const swing = Math.sin(t * 8) * Math.min(speed * 0.25, 0.6); | |
| const p = mob.mesh.userData.parts; | |
| if (p) { | |
| p.legL.rotation.x = swing; | |
| p.legR.rotation.x = -swing; | |
| p.armL.rotation.x = -swing * 0.7; | |
| p.armR.rotation.x = swing * 0.7; | |
| } | |
| // hurt flash | |
| if (mob.hurtTime > 0) { | |
| mob.hurtTime -= dt; | |
| mob.mesh.traverse(o => { | |
| if (o.material) { | |
| o.material.emissive = new THREE.Color(0xff0000); | |
| o.material.emissiveIntensity = 0.6; | |
| } | |
| }); | |
| } else { | |
| mob.mesh.traverse(o => { | |
| if (o.material) { | |
| o.material.emissive = new THREE.Color(0x000000); | |
| o.material.emissiveIntensity = 0; | |
| } | |
| }); | |
| } | |
| // Attack player | |
| mob.attackCooldown -= dt; | |
| if (distToPlayer < MOB_ATTACK_RANGE && mob.attackCooldown <= 0 && player.position.y - mob.position.y < 2) { | |
| mob.attackCooldown = MOB_ATTACK_CD; | |
| damagePlayer(MOB_DMG); | |
| } | |
| } | |
| function moveMobAxis(mob, axis, amount) { | |
| if (amount === 0) return; | |
| mob.position[axis] += amount; | |
| // mob AABB: 0.6 wide, 1.7 tall | |
| const r = 0.3, h = 1.7; | |
| const minX = mob.position.x - r, maxX = mob.position.x + r; | |
| const minY = mob.position.y, maxY = mob.position.y + h; | |
| const minZ = mob.position.z - r, maxZ = mob.position.z + r; | |
| for (let bx = Math.floor(minX); bx <= Math.floor(maxX); bx++) { | |
| for (let by = Math.floor(minY); by <= Math.floor(maxY); by++) { | |
| for (let bz = Math.floor(minZ); bz <= Math.floor(maxZ); bz++) { | |
| if (!isSolid(bx, by, bz)) continue; | |
| if (axis === 'x') { | |
| if (amount > 0) mob.position.x = bx - r - 0.0001; | |
| else mob.position.x = bx + 1 + r + 0.0001; | |
| mob.velocity.x = 0; | |
| } else if (axis === 'y') { | |
| if (amount > 0) { mob.position.y = by - h - 0.0001; mob.velocity.y = 0; } | |
| else { mob.position.y = by + 1 + 0.0001; mob.velocity.y = 0; mob.onGround = true; } | |
| } else { | |
| if (amount > 0) mob.position.z = bz - r - 0.0001; | |
| else mob.position.z = bz + 1 + r + 0.0001; | |
| mob.velocity.z = 0; | |
| } | |
| } | |
| } | |
| } | |
| } | |
| // ============================================================ | |
| // COMBAT / HEALTH | |
| // ============================================================ | |
| function damagePlayer(amount) { | |
| if (!gameRunning) return; | |
| playerHealth = Math.max(0, playerHealth - amount); | |
| showDamageFlash(); | |
| updateHealthUI(); | |
| if (playerHealth <= 0) onPlayerDeath(); | |
| } | |
| function showDamageFlash() { | |
| const el = document.getElementById('damage-overlay'); | |
| el.style.opacity = '1'; | |
| setTimeout(() => { el.style.opacity = '0'; }, 180); | |
| } | |
| function onPlayerDeath() { | |
| gameRunning = false; | |
| document.exitPointerLock(); | |
| document.getElementById('dead-screen').style.display = 'flex'; | |
| } | |
| function respawnPlayer() { | |
| playerHealth = PLAYER_MAX_HP; | |
| initPlayer(); | |
| // clear mobs | |
| for (const m of mobs.slice()) killMob(m); | |
| document.getElementById('dead-screen').style.display = 'none'; | |
| gameRunning = true; | |
| requestPointerLock(); | |
| updateHealthUI(); | |
| } | |
| // Slow passive regen out of combat | |
| let lastHurtTime = 0; | |
| function maybeRegen(dt) { | |
| if (playerHealth < PLAYER_MAX_HP && performance.now() / 1000 - lastHurtTime > 6) { | |
| playerHealth = Math.min(PLAYER_MAX_HP, playerHealth + dt * 3); | |
| updateHealthUI(); | |
| } | |
| } | |
| // ============================================================ | |
| // DAY / NIGHT CYCLE | |
| // ============================================================ | |
| function updateDayNight(dt) { | |
| timeOfDay = (timeOfDay + dt / DAY_LENGTH) % 1; | |
| // Sun angle: at t=0.25 sun at horizon east (dawn), t=0.5 sun overhead (noon), | |
| // t=0.75 sunset, t=0/midnight sun below horizon. | |
| const angle = timeOfDay * Math.PI * 2 - Math.PI / 2; | |
| const sunX = Math.cos(angle) * 80; | |
| const sunY = Math.sin(angle) * 80; | |
| const sunZ = 20; | |
| sun.position.set(sunX, sunY, sunZ); | |
| sun.target.position.set(0, 0, 0); | |
| sun.target.updateMatrixWorld(); | |
| if (sun.userData.mesh) sun.userData.mesh.position.set(sunX, sunY, sunZ); | |
| // moon opposite the sun | |
| const moonX = -sunX, moonY = -sunY; | |
| moon.position.set(moonX, moonY, sunZ); | |
| moon.target.position.set(0, 0, 0); | |
| moon.target.updateMatrixWorld(); | |
| if (moon.userData.mesh) moon.userData.mesh.position.set(moonX, moonY, sunZ); | |
| // sunHeight: -1 (midnight) to +1 (noon) | |
| const sunHeight = Math.sin(angle); | |
| const dayFactor = Math.max(0, sunHeight); | |
| const nightFactor = Math.max(0, -sunHeight); | |
| // Light intensities - keep some minimum visibility at night | |
| sun.intensity = dayFactor * 0.95; | |
| moon.intensity = nightFactor * 0.35; | |
| ambientLight.intensity = 0.32 + dayFactor * 0.45; | |
| hemiLight.intensity = 0.25 + dayFactor * 0.4; | |
| // Sky color: smooth transition through night -> dusk -> day -> dusk -> night | |
| const dayColor = new THREE.Color(0x87ceeb); // light blue | |
| const nightColor = new THREE.Color(0x05071a); // very dark navy | |
| const duskColor = new THREE.Color(0xe87b3a); // orange | |
| // Map sunHeight (-1..1) to a sky color. | |
| // - sunHeight > 0.25: full day | |
| // - sunHeight in [-0.25, 0.25]: dawn/dusk transition through orange | |
| // - sunHeight < -0.25: full night | |
| let skyColor; | |
| if (sunHeight > 0.25) { | |
| skyColor = dayColor.clone(); | |
| } else if (sunHeight > -0.25) { | |
| // transition zone: -0.25 (night edge) -> 0 (horizon, full dusk) -> 0.25 (day edge) | |
| const t = (sunHeight + 0.25) / 0.5; // 0 at -0.25, 1 at 0.25 | |
| if (t > 0.5) { | |
| // dusk -> day | |
| skyColor = duskColor.clone().lerp(dayColor, (t - 0.5) * 2); | |
| } else { | |
| // night -> dusk | |
| skyColor = nightColor.clone().lerp(duskColor, t * 2); | |
| } | |
| } else { | |
| // deep night - mostly dark with a slight blue tint based on how deep | |
| skyColor = nightColor.clone(); | |
| } | |
| scene.background = skyColor; | |
| scene.fog.color = skyColor; | |
| // update label | |
| let label; | |
| if (sunHeight > 0.3) label = 'Day'; | |
| else if (sunHeight > -0.1) label = (timeOfDay < 0.5 ? 'Dawn' : 'Dusk'); | |
| else label = 'Night'; | |
| document.getElementById('time-of-day').textContent = label; | |
| // mob spawning at night | |
| if (dayFactor < 0.15 && performance.now() / 1000 - lastMobSpawnTime > 8) { | |
| if (mobs.length < MAX_MOBS) { | |
| spawnMob(); | |
| lastMobSpawnTime = performance.now() / 1000; | |
| } | |
| } | |
| // also occasionally spawn during day if too few mobs | |
| if (mobs.length < 2 && performance.now() / 1000 - lastMobSpawnTime > 15) { | |
| spawnMob(); | |
| lastMobSpawnTime = performance.now() / 1000; | |
| } | |
| } | |
| // ============================================================ | |
| // HUD | |
| // ============================================================ | |
| function makeHotbarIcon(type) { | |
| const c = document.createElement('canvas'); | |
| c.width = c.height = 36; | |
| const ctx = c.getContext('2d'); | |
| ctx.imageSmoothingEnabled = false; | |
| // draw a tiny cube: top, left, right faces | |
| const def = BLOCKS[type]; | |
| const draw = (tile, pts) => { | |
| // fill with base color of the tile (just take a sample color) | |
| const colors = { | |
| grass: 0x5fa83f, dirt: 0x8b6f3f, stone: 0x7a7a7a, cobblestone: 0x6a6a6a, | |
| wood: 0x6b4f2a, planks: 0xb8945c, leaves: 0x3a6b1f, sand: 0xe6d6a8 | |
| }; | |
| const col = colors[type] || 0xffffff; | |
| let r, g, b; | |
| if (tile === def.top) { | |
| // brighter (top face) | |
| r = (col >> 16) & 0xff; g = (col >> 8) & 0xff; b = col & 0xff; | |
| } else if (tile === def.side) { | |
| r = ((col >> 16) & 0xff) * 0.8 | 0; | |
| g = ((col >> 8) & 0xff) * 0.8 | 0; | |
| b = (col & 0xff) * 0.8 | 0; | |
| } else { | |
| r = ((col >> 16) & 0xff) * 0.6 | 0; | |
| g = ((col >> 8) & 0xff) * 0.6 | 0; | |
| b = (col & 0xff) * 0.6 | 0; | |
| } | |
| ctx.fillStyle = 'rgb(' + r + ',' + g + ',' + b + ')'; | |
| ctx.beginPath(); | |
| ctx.moveTo(pts[0][0], pts[0][1]); | |
| for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i][0], pts[i][1]); | |
| ctx.closePath(); | |
| ctx.fill(); | |
| }; | |
| // top | |
| draw(def.top, [[18,2],[34,10],[18,18],[2,10]]); | |
| // left | |
| draw(def.side, [[2,10],[18,18],[18,34],[2,26]]); | |
| // right | |
| draw(def.side, [[18,18],[34,10],[34,26],[18,34]]); | |
| return c; | |
| } | |
| function buildHotbarUI() { | |
| const hb = document.getElementById('hotbar'); | |
| hb.innerHTML = ''; | |
| HOTBAR.forEach((type, i) => { | |
| const slot = document.createElement('div'); | |
| slot.className = 'slot'; | |
| slot.dataset.index = i; | |
| const icon = makeHotbarIcon(type); | |
| slot.appendChild(icon); | |
| const k = document.createElement('div'); | |
| k.className = 'key'; | |
| k.textContent = (i + 1); | |
| slot.appendChild(k); | |
| const count = document.createElement('div'); | |
| count.className = 'count'; | |
| count.id = 'count-' + type; | |
| count.textContent = '0'; | |
| slot.appendChild(count); | |
| slot.addEventListener('click', () => { | |
| selectedSlot = i; | |
| updateHotbarUI(); | |
| }); | |
| hb.appendChild(slot); | |
| }); | |
| updateHotbarUI(); | |
| } | |
| function updateHotbarUI() { | |
| const slots = document.querySelectorAll('#hotbar .slot'); | |
| slots.forEach((s, i) => { | |
| s.classList.toggle('active', i === selectedSlot); | |
| }); | |
| for (const type in inventory) { | |
| const el = document.getElementById('count-' + type); | |
| if (el) el.textContent = inventory[type] || 0; | |
| } | |
| } | |
| function buildHealthUI() { | |
| const h = document.getElementById('health'); | |
| h.innerHTML = ''; | |
| const hearts = 10; // 10 hearts = 100 HP | |
| for (let i = 0; i < hearts; i++) { | |
| const d = document.createElement('div'); | |
| d.className = 'heart'; | |
| h.appendChild(d); | |
| } | |
| updateHealthUI(); | |
| } | |
| function updateHealthUI() { | |
| const hearts = document.querySelectorAll('#health .heart'); | |
| const filled = Math.ceil(playerHealth / 10); | |
| hearts.forEach((h, i) => { | |
| h.classList.toggle('empty', i >= filled); | |
| }); | |
| } | |
| function showMessage(text) { | |
| const el = document.getElementById('message'); | |
| el.textContent = text; | |
| el.style.opacity = text ? '1' : '0'; | |
| } | |
| function updateInfoUI() { | |
| document.getElementById('pos').textContent = | |
| player.position.x.toFixed(1) + ', ' + player.position.y.toFixed(1) + ', ' + player.position.z.toFixed(1); | |
| document.getElementById('mob-count').textContent = mobs.length; | |
| } | |
| // ============================================================ | |
| // MAIN LOOP | |
| // ============================================================ | |
| function animate() { | |
| requestAnimationFrame(animate); | |
| const dt = Math.min(clock.getDelta(), 0.05); | |
| if (gameRunning) { | |
| updatePlayer(dt); | |
| for (const mob of mobs.slice()) updateMob(mob, dt); | |
| maybeRegen(dt); | |
| updateDayNight(dt); | |
| // Update targeted block highlight | |
| const hit = raycastVoxel(REACH); | |
| if (hit) { | |
| highlightMesh.visible = true; | |
| highlightMesh.position.set(hit.x + 0.5, hit.y + 0.5, hit.z + 0.5); | |
| } else { | |
| highlightMesh.visible = false; | |
| } | |
| updateInfoUI(); | |
| } | |
| renderer.render(scene, camera); | |
| // FPS | |
| fpsFrames++; | |
| const now = performance.now(); | |
| if (now - fpsLast > 500) { | |
| const fps = Math.round((fpsFrames * 1000) / (now - fpsLast)); | |
| document.getElementById('fps').textContent = fps; | |
| fpsLast = now; fpsFrames = 0; | |
| } | |
| } | |
| // ============================================================ | |
| // INIT | |
| // ============================================================ | |
| function start() { | |
| initScene(); | |
| generateWorld(); | |
| clearSpawnArea(); // make sure no tree is occupying the player's spawn point | |
| rebuildAllBlockMeshes(); | |
| initPlayer(); | |
| initInput(); | |
| buildHotbarUI(); | |
| buildHealthUI(); | |
| // seed inventory so the player can place blocks immediately | |
| inventory = { | |
| grass: 16, dirt: 16, stone: 16, cobblestone: 16, | |
| wood: 8, planks: 8, leaves: 8, sand: 16, | |
| }; | |
| updateHotbarUI(); | |
| // spawn a few initial mobs | |
| for (let i = 0; i < 3; i++) spawnMob(); | |
| document.getElementById('start-btn').addEventListener('click', () => { | |
| document.getElementById('menu').style.display = 'none'; | |
| gameRunning = true; | |
| requestPointerLock(); | |
| }); | |
| document.getElementById('respawn-btn').addEventListener('click', respawnPlayer); | |
| clock.start(); | |
| animate(); | |
| } | |
| // Wait for Three.js to load, then start. | |
| if (typeof THREE !== 'undefined') { | |
| start(); | |
| } else { | |
| window.addEventListener('load', () => { | |
| if (typeof THREE === 'undefined') { | |
| document.body.innerHTML = '<div style="color:#fff;padding:40px;font-family:sans-serif;">Failed to load Three.js from CDN. Please check your internet connection.</div>'; | |
| } else { | |
| start(); | |
| } | |
| }); | |
| } | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment