Created
July 11, 2026 14:14
-
-
Save senko/ff2871573a160d04c5929e1d1c0cf92b to your computer and use it in GitHub Desktop.
Minecraft clone by Claude Opus 4.8
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, maximum-scale=1, user-scalable=no"> | |
| <title>MiniCraft</title> | |
| <style> | |
| * { margin: 0; padding: 0; box-sizing: border-box; } | |
| html, body { width: 100%; height: 100%; overflow: hidden; background: #87ceeb; font-family: -apple-system, Segoe UI, Roboto, sans-serif; } | |
| #game { position: fixed; inset: 0; display: block; } | |
| /* Crosshair */ | |
| #crosshair { | |
| position: fixed; left: 50%; top: 50%; transform: translate(-50%, -50%); | |
| width: 22px; height: 22px; pointer-events: none; z-index: 10; opacity: .85; | |
| transition: transform .06s ease; | |
| } | |
| #crosshair::before, #crosshair::after { | |
| content: ''; position: absolute; background: #fff; box-shadow: 0 0 2px rgba(0,0,0,.7); | |
| } | |
| #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%); } | |
| #crosshair.hit { transform: translate(-50%, -50%) scale(1.6); opacity: 1; } | |
| /* HUD */ | |
| #hud { position: fixed; inset: 0; pointer-events: none; z-index: 9; } | |
| #topleft { | |
| position: fixed; left: 14px; top: 12px; z-index: 11; color: #fff; | |
| text-shadow: 1px 1px 2px #000, 0 0 3px #000; font-size: 13px; line-height: 1.5; | |
| } | |
| #hearts { font-size: 20px; letter-spacing: 1px; height: 24px; } | |
| #hearts span { color: #ff3b3b; } | |
| #hearts span.empty { color: #3a1414; } | |
| #stats { margin-top: 4px; font-size: 12px; opacity: .9; } | |
| /* Hotbar */ | |
| #hotbar { | |
| position: fixed; left: 50%; bottom: 16px; transform: translateX(-50%); | |
| display: flex; gap: 5px; z-index: 11; padding: 5px; | |
| background: rgba(0,0,0,.35); border-radius: 8px; | |
| } | |
| .slot { | |
| width: 52px; height: 52px; position: relative; border: 2px solid rgba(255,255,255,.25); | |
| border-radius: 6px; background: rgba(0,0,0,.35); image-rendering: pixelated; | |
| background-size: cover; background-position: center; | |
| } | |
| .slot.sel { border-color: #fff; box-shadow: 0 0 0 2px #fff, 0 0 10px rgba(255,255,255,.6); } | |
| .slot .num { position: absolute; top: 1px; left: 3px; font-size: 10px; color: #fff; text-shadow: 1px 1px 1px #000; } | |
| .slot .cnt { position: absolute; bottom: 1px; right: 3px; font-size: 12px; font-weight: bold; color: #fff; text-shadow: 1px 1px 2px #000; } | |
| .slot .cnt.zero { color: #ff8a8a; } | |
| /* Damage flash */ | |
| #flash { position: fixed; inset: 0; background: radial-gradient(ellipse at center, transparent 40%, rgba(180,0,0,.55) 100%); opacity: 0; pointer-events: none; z-index: 12; transition: opacity .1s; } | |
| /* Start / pause screen */ | |
| #overlay { | |
| position: fixed; inset: 0; z-index: 20; display: flex; align-items: center; justify-content: center; | |
| background: rgba(10,15,25,.72); backdrop-filter: blur(3px); color: #eaeaea; text-align: center; | |
| } | |
| #overlay .panel { max-width: 560px; padding: 30px 40px; } | |
| #overlay h1 { font-size: 46px; letter-spacing: 2px; margin-bottom: 6px; text-shadow: 3px 3px 0 #2b4d1e; } | |
| #overlay .sub { color: #9fd07a; margin-bottom: 22px; font-size: 15px; } | |
| #overlay .keys { display: grid; grid-template-columns: auto 1fr; gap: 6px 14px; text-align: left; font-size: 14px; margin: 0 auto 24px; width: fit-content; } | |
| #overlay .keys b { color: #ffe08a; } | |
| #overlay .play { | |
| pointer-events: auto; cursor: pointer; display: inline-block; padding: 14px 40px; font-size: 18px; font-weight: bold; | |
| background: linear-gradient(#7ab04c, #5a9a3c); color: #10240a; border: 3px solid #3b5d1e; border-radius: 8px; | |
| text-shadow: 0 1px 0 rgba(255,255,255,.3); box-shadow: 0 4px 0 #2b4d1e; | |
| } | |
| #overlay .play:active { transform: translateY(3px); box-shadow: 0 1px 0 #2b4d1e; } | |
| #overlay.hidden { display: none; } | |
| .key { display: inline-block; background: #333; border: 1px solid #555; border-bottom-width: 2px; border-radius: 4px; padding: 1px 7px; font-family: monospace; } | |
| #hint { position: fixed; left: 50%; bottom: 78px; transform: translateX(-50%); z-index: 11; color: #fff; font-size: 13px; text-shadow: 1px 1px 2px #000; opacity: .8; } | |
| </style> | |
| </head> | |
| <body> | |
| <canvas id="game"></canvas> | |
| <div id="crosshair"></div> | |
| <div id="flash"></div> | |
| <div id="hud"> | |
| <div id="topleft"> | |
| <div id="hearts"></div> | |
| <div id="stats"></div> | |
| </div> | |
| </div> | |
| <div id="hint">Left-click: mine / attack • Right-click: place • 1–9: select</div> | |
| <div id="hotbar"></div> | |
| <div id="overlay"> | |
| <div class="panel"> | |
| <h1>MINICRAFT</h1> | |
| <div class="sub">a tiny voxel world • build, mine, survive</div> | |
| <div class="keys"> | |
| <span><span class="key">W A S D</span></span><span>Move around</span> | |
| <span><span class="key">Space</span></span><span>Jump</span> | |
| <span><span class="key">Shift</span></span><span>Sprint</span> | |
| <span><span class="key">Mouse</span></span><span>Look</span> | |
| <span><span class="key">L-Click</span></span><span>Mine block / attack mob</span> | |
| <span><span class="key">R-Click</span></span><span>Place selected block</span> | |
| <span><span class="key">1–9</span> / <span class="key">Wheel</span></span><span>Choose block</span> | |
| <span><span class="key">Esc</span></span><span>Pause / release mouse</span> | |
| </div> | |
| <div class="play" id="playBtn">CLICK TO PLAY</div> | |
| </div> | |
| </div> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script> | |
| <script> | |
| "use strict"; | |
| // ============================================================================ | |
| // MiniCraft — a single-file Minecraft-like game | |
| // ============================================================================ | |
| const THREE_OK = typeof THREE !== 'undefined'; | |
| if (!THREE_OK) { document.body.innerHTML = '<div style="color:#fff;padding:40px;font-family:sans-serif">Failed to load Three.js from CDN. Check your connection.</div>'; } | |
| // ---------- Block definitions ---------- | |
| // id 0 = air. Each block: name, texture drawer. transparent -> not occluding / see-through. | |
| const AIR = 0; | |
| const B = { | |
| GRASS: 1, DIRT: 2, STONE: 3, COBBLE: 4, SAND: 5, WOOD: 6, | |
| PLANKS: 7, LEAVES: 8, COAL: 9, IRON: 10, GLASS: 11 | |
| }; | |
| const TRANSPARENT = new Set([B.LEAVES, B.GLASS]); | |
| // A texture drawer paints a 16x16 canvas. | |
| function px(ctx, x, y, c) { ctx.fillStyle = c; ctx.fillRect(x, y, 1, 1); } | |
| function rnd(a, b) { return a + Math.random() * (b - a); } | |
| function shade(hex, amt) { | |
| const n = parseInt(hex.slice(1), 16); | |
| let r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255; | |
| r = Math.max(0, Math.min(255, r + amt)); | |
| g = Math.max(0, Math.min(255, g + amt)); | |
| b = Math.max(0, Math.min(255, b + amt)); | |
| return `rgb(${r|0},${g|0},${b|0})`; | |
| } | |
| function noiseTex(base, vary) { | |
| return (ctx) => { | |
| for (let y = 0; y < 16; y++) for (let x = 0; x < 16; x++) { | |
| px(ctx, x, y, shade(base, (Math.random() * 2 - 1) * vary)); | |
| } | |
| }; | |
| } | |
| function blotchTex(base, vary, blotchColor, count) { | |
| return (ctx) => { | |
| for (let y = 0; y < 16; y++) for (let x = 0; x < 16; x++) | |
| px(ctx, x, y, shade(base, (Math.random() * 2 - 1) * vary)); | |
| for (let i = 0; i < count; i++) { | |
| const cx = (Math.random() * 14) | 0, cy = (Math.random() * 14) | 0, s = 1 + ((Math.random() * 2) | 0); | |
| ctx.fillStyle = shade(blotchColor, (Math.random() * 2 - 1) * 18); | |
| ctx.fillRect(cx, cy, s, s); | |
| } | |
| }; | |
| } | |
| const DRAWERS = { | |
| [B.GRASS]: (ctx) => { | |
| for (let y = 0; y < 16; y++) for (let x = 0; x < 16; x++) | |
| px(ctx, x, y, shade('#5fa73c', (Math.random() * 2 - 1) * 22)); | |
| // a few brighter blades | |
| for (let i = 0; i < 22; i++) px(ctx, (Math.random()*16)|0, (Math.random()*16)|0, shade('#7cc94e', 10)); | |
| }, | |
| [B.DIRT]: noiseTex('#78543a', 20), | |
| [B.STONE]: noiseTex('#828282', 18), | |
| [B.COBBLE]: (ctx) => { | |
| for (let y = 0; y < 16; y++) for (let x = 0; x < 16; x++) | |
| px(ctx, x, y, shade('#6f6f6f', (Math.random() * 2 - 1) * 16)); | |
| ctx.strokeStyle = 'rgba(40,40,40,.55)'; ctx.lineWidth = 1; | |
| for (let gy = 0; gy < 16; gy += 5) { ctx.beginPath(); ctx.moveTo(0, gy + .5); ctx.lineTo(16, gy + .5); ctx.stroke(); } | |
| for (let i = 0; i < 3; i++) { const gx = 3 + i * 5; ctx.beginPath(); ctx.moveTo(gx + .5, 0); ctx.lineTo(gx + .5, 16); ctx.stroke(); } | |
| }, | |
| [B.SAND]: noiseTex('#dcd29a', 12), | |
| [B.WOOD]: (ctx) => { | |
| for (let y = 0; y < 16; y++) for (let x = 0; x < 16; x++) | |
| px(ctx, x, y, shade('#6d5030', (Math.random() * 2 - 1) * 14)); | |
| ctx.fillStyle = 'rgba(50,35,18,.5)'; | |
| for (let x = 2; x < 16; x += 5) ctx.fillRect(x, 0, 1, 16); | |
| }, | |
| [B.PLANKS]: (ctx) => { | |
| for (let y = 0; y < 16; y++) for (let x = 0; x < 16; x++) | |
| px(ctx, x, y, shade('#b07f43', (Math.random() * 2 - 1) * 12)); | |
| ctx.fillStyle = 'rgba(70,45,20,.55)'; | |
| for (let y = 0; y < 16; y += 4) ctx.fillRect(0, y, 16, 1); | |
| ctx.fillRect(5, 0, 1, 4); ctx.fillRect(11, 4, 1, 4); ctx.fillRect(3, 8, 1, 4); ctx.fillRect(9, 12, 1, 4); | |
| }, | |
| [B.LEAVES]: (ctx) => { | |
| for (let y = 0; y < 16; y++) for (let x = 0; x < 16; x++) { | |
| if (Math.random() < 0.12) { px(ctx, x, y, 'rgba(0,0,0,0)'); continue; } // holes | |
| ctx.fillStyle = shade('#3f7a2e', (Math.random() * 2 - 1) * 26); | |
| ctx.fillRect(x, y, 1, 1); | |
| } | |
| }, | |
| [B.COAL]: blotchTex('#828282', 16, '#1c1c1c', 10), | |
| [B.IRON]: blotchTex('#828282', 16, '#c9a06a', 9), | |
| [B.GLASS]: (ctx) => { | |
| ctx.clearRect(0, 0, 16, 16); | |
| ctx.fillStyle = 'rgba(180,220,235,0.12)'; ctx.fillRect(0, 0, 16, 16); | |
| ctx.strokeStyle = 'rgba(200,235,245,0.85)'; ctx.lineWidth = 1; | |
| ctx.strokeRect(0.5, 0.5, 15, 15); | |
| ctx.strokeStyle = 'rgba(255,255,255,0.5)'; | |
| ctx.beginPath(); ctx.moveTo(3, 12); ctx.lineTo(11, 4); ctx.stroke(); | |
| }, | |
| }; | |
| const BLOCK_NAME = { | |
| [B.GRASS]:'Grass',[B.DIRT]:'Dirt',[B.STONE]:'Stone',[B.COBBLE]:'Cobblestone',[B.SAND]:'Sand', | |
| [B.WOOD]:'Wood',[B.PLANKS]:'Planks',[B.LEAVES]:'Leaves',[B.COAL]:'Coal Ore',[B.IRON]:'Iron Ore',[B.GLASS]:'Glass' | |
| }; | |
| // Build textures + materials + hotbar swatches | |
| const texData = {}; // id -> {texture, material, dataURL} | |
| function buildTextures() { | |
| for (const id of Object.values(B)) { | |
| const cv = document.createElement('canvas'); cv.width = cv.height = 16; | |
| const ctx = cv.getContext('2d'); | |
| ctx.imageSmoothingEnabled = false; | |
| DRAWERS[id](ctx); | |
| const tex = new THREE.CanvasTexture(cv); | |
| tex.magFilter = THREE.NearestFilter; tex.minFilter = THREE.NearestFilter; | |
| tex.wrapS = tex.wrapT = THREE.RepeatWrapping; | |
| let mat; | |
| if (id === B.GLASS) { | |
| mat = new THREE.MeshLambertMaterial({ map: tex, transparent: true, opacity: 0.75, depthWrite: true, side: THREE.DoubleSide }); | |
| } else if (id === B.LEAVES) { | |
| mat = new THREE.MeshLambertMaterial({ map: tex, transparent: true, alphaTest: 0.4, side: THREE.DoubleSide }); | |
| } else { | |
| mat = new THREE.MeshLambertMaterial({ map: tex }); | |
| } | |
| texData[id] = { texture: tex, material: mat, dataURL: cv.toDataURL() }; | |
| } | |
| } | |
| // ============================================================================ | |
| // World data | |
| // ============================================================================ | |
| const WORLD_R = 22; // half-size of world footprint (x,z in [-R, R-1]) | |
| const world = new Map(); // "x,y,z" -> block id | |
| const key = (x, y, z) => x + ',' + y + ',' + z; | |
| function getBlock(x, y, z) { return world.get(key(x, y, z)) || AIR; } | |
| function setBlockData(x, y, z, id) { if (id === AIR) world.delete(key(x, y, z)); else world.set(key(x, y, z), id); } | |
| function isSolid(x, y, z) { return getBlock(x, y, z) !== AIR; } | |
| function isOpaque(x, y, z) { const t = getBlock(x, y, z); return t !== AIR && !TRANSPARENT.has(t); } | |
| // deterministic value noise | |
| function hash2(x, z) { | |
| let n = (x | 0) * 374761393 + (z | 0) * 668265263; | |
| n = (n ^ (n >> 13)) * 1274126177; | |
| return ((n ^ (n >> 16)) >>> 0) / 4294967295; | |
| } | |
| function smooth(t) { return t * t * (3 - 2 * t); } | |
| function vnoise(x, z) { | |
| const xi = Math.floor(x), zi = Math.floor(z), xf = x - xi, zf = z - zi; | |
| const a = hash2(xi, zi), b = hash2(xi + 1, zi), c = hash2(xi, zi + 1), d = hash2(xi + 1, zi + 1); | |
| const u = smooth(xf), v = smooth(zf); | |
| return (a * (1 - u) + b * u) * (1 - v) + (c * (1 - u) + d * u) * v; | |
| } | |
| function heightAt(x, z) { | |
| let h = 6 + 5 * vnoise(x * 0.07, z * 0.07) + 2.5 * vnoise(x * 0.19 + 40, z * 0.19 + 40); | |
| return Math.max(1, Math.floor(h)); | |
| } | |
| function generateWorld() { | |
| for (let x = -WORLD_R; x < WORLD_R; x++) { | |
| for (let z = -WORLD_R; z < WORLD_R; z++) { | |
| const h = heightAt(x, z); | |
| const beach = h <= 4; | |
| for (let y = 0; y <= h; y++) { | |
| let id; | |
| if (y === h) id = beach ? B.SAND : B.GRASS; | |
| else if (y >= h - 3) id = beach ? B.SAND : B.DIRT; | |
| else { | |
| id = B.STONE; | |
| const depth = h - y; | |
| if (Math.random() < 0.02 + depth * 0.004) id = B.COAL; | |
| else if (Math.random() < 0.012 + depth * 0.003) id = B.IRON; | |
| } | |
| setBlockData(x, y, z, id); | |
| } | |
| // trees (kept clear of the spawn area) | |
| const nearSpawn = Math.abs(x) <= 2 && Math.abs(z) <= 2; | |
| if (!beach && !nearSpawn && h >= 5 && x > -WORLD_R + 2 && x < WORLD_R - 2 && z > -WORLD_R + 2 && z < WORLD_R - 2) { | |
| if (Math.random() < 0.018) plantTree(x, h + 1, z); | |
| } | |
| } | |
| } | |
| } | |
| function plantTree(x, y, z) { | |
| const th = 4 + ((Math.random() * 2) | 0); | |
| for (let i = 0; i < th; i++) setBlockData(x, y + i, z, B.WOOD); | |
| const top = y + th; | |
| for (let dx = -2; dx <= 2; dx++) for (let dz = -2; dz <= 2; dz++) for (let dy = -2; dy <= 1; dy++) { | |
| if (dx === 0 && dz === 0 && dy < 0) continue; | |
| const d = Math.abs(dx) + Math.abs(dz) + Math.abs(dy); | |
| if (d > 3) continue; | |
| if (getBlock(x + dx, top + dy, z + dz) === AIR && Math.random() > 0.15) | |
| setBlockData(x + dx, top + dy, z + dz, B.LEAVES); | |
| } | |
| } | |
| // highest solid+1 at column | |
| function columnTop(x, z) { | |
| for (let y = 40; y >= 0; y--) if (isSolid(x, y, z)) return y + 1; | |
| return 0; | |
| } | |
| // ============================================================================ | |
| // Three.js setup | |
| // ============================================================================ | |
| let scene, camera, renderer, cubeGeo, highlight, sun, hemi, ambient; | |
| const dummy = new THREE.Object3D(); | |
| const instMeshes = []; | |
| function initThree() { | |
| scene = new THREE.Scene(); | |
| scene.background = new THREE.Color(0x87ceeb); | |
| scene.fog = new THREE.Fog(0x87ceeb, 20, 68); | |
| camera = new THREE.PerspectiveCamera(72, window.innerWidth / window.innerHeight, 0.1, 1000); | |
| camera.rotation.order = 'YXZ'; | |
| renderer = new THREE.WebGLRenderer({ canvas: document.getElementById('game'), antialias: false }); | |
| renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5)); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| hemi = new THREE.HemisphereLight(0xbfd8ff, 0x6b5a3f, 0.75); | |
| scene.add(hemi); | |
| ambient = new THREE.AmbientLight(0xffffff, 0.35); | |
| scene.add(ambient); | |
| sun = new THREE.DirectionalLight(0xffffff, 0.9); | |
| sun.position.set(30, 60, 20); | |
| scene.add(sun); | |
| cubeGeo = new THREE.BoxGeometry(1, 1, 1); | |
| const hi = new THREE.BoxGeometry(1.002, 1.002, 1.002); | |
| highlight = new THREE.LineSegments( | |
| new THREE.EdgesGeometry(hi), | |
| new THREE.LineBasicMaterial({ color: 0x000000, transparent: true, opacity: 0.5 }) | |
| ); | |
| highlight.visible = false; | |
| scene.add(highlight); | |
| } | |
| // Rebuild instanced meshes from world data (only exposed blocks are rendered) | |
| function rebuildMeshes() { | |
| for (const m of instMeshes) { scene.remove(m); m.dispose(); } | |
| instMeshes.length = 0; | |
| const byType = {}; | |
| for (const [k, id] of world) { | |
| const p = k.split(','); | |
| const x = +p[0], y = +p[1], z = +p[2]; | |
| // exposed if any neighbor is non-opaque | |
| if (isOpaque(x + 1, y, z) && isOpaque(x - 1, y, z) && | |
| isOpaque(x, y + 1, z) && isOpaque(x, y - 1, z) && | |
| isOpaque(x, y, z + 1) && isOpaque(x, y, z - 1)) continue; | |
| (byType[id] || (byType[id] = [])).push(x, y, z); | |
| } | |
| for (const idStr in byType) { | |
| const id = +idStr; | |
| const arr = byType[id]; | |
| const n = arr.length / 3; | |
| const mesh = new THREE.InstancedMesh(cubeGeo, texData[id].material, n); | |
| mesh.frustumCulled = false; | |
| for (let i = 0; i < n; i++) { | |
| dummy.position.set(arr[i * 3] + 0.5, arr[i * 3 + 1] + 0.5, arr[i * 3 + 2] + 0.5); | |
| dummy.updateMatrix(); | |
| mesh.setMatrixAt(i, dummy.matrix); | |
| } | |
| mesh.instanceMatrix.needsUpdate = true; | |
| mesh.renderOrder = TRANSPARENT.has(id) ? 1 : 0; | |
| scene.add(mesh); | |
| instMeshes.push(mesh); | |
| } | |
| } | |
| // ============================================================================ | |
| // Player + physics | |
| // ============================================================================ | |
| const HALF = 0.3, HEIGHT = 1.8, EYE = 1.62, EPS = 1e-3; | |
| const GRAVITY = 28, JUMP = 8.8, WALK = 4.6, SPRINT = 7.2; | |
| const player = { | |
| pos: new THREE.Vector3(0, 0, 0), | |
| vel: new THREE.Vector3(0, 0, 0), | |
| onGround: false, | |
| yaw: 0, pitch: 0, | |
| health: 20, maxHealth: 20, | |
| lastHurt: -999, lastAttack: -999, | |
| }; | |
| function spawnPlayer() { | |
| const h = columnTop(0, 0); | |
| player.pos.set(0.5, h + 0.02, 0.5); | |
| player.vel.set(0, 0, 0); | |
| player.yaw = 0; player.pitch = 0; | |
| player.health = player.maxHealth; | |
| } | |
| function resolveAxis(axis, delta) { | |
| const minx = player.pos.x - HALF, maxx = player.pos.x + HALF; | |
| const miny = player.pos.y, maxy = player.pos.y + HEIGHT; | |
| const minz = player.pos.z - HALF, maxz = player.pos.z + HALF; | |
| const x0 = Math.floor(minx), x1 = Math.floor(maxx - 1e-9); | |
| const y0 = Math.floor(miny), y1 = Math.floor(maxy - 1e-9); | |
| const z0 = Math.floor(minz), z1 = Math.floor(maxz - 1e-9); | |
| let best = null; | |
| for (let x = x0; x <= x1; x++) for (let y = y0; y <= y1; y++) for (let z = z0; z <= z1; z++) { | |
| if (!isSolid(x, y, z)) continue; | |
| if (axis === 'x') best = (delta > 0) ? Math.min(best === null ? Infinity : best, x) : Math.max(best === null ? -Infinity : best, x + 1); | |
| else if (axis === 'y') best = (delta > 0) ? Math.min(best === null ? Infinity : best, y) : Math.max(best === null ? -Infinity : best, y + 1); | |
| else best = (delta > 0) ? Math.min(best === null ? Infinity : best, z) : Math.max(best === null ? -Infinity : best, z + 1); | |
| } | |
| if (best === null) return; | |
| if (axis === 'x') { player.pos.x = delta > 0 ? best - HALF - EPS : best + HALF + EPS; player.vel.x = 0; } | |
| else if (axis === 'z') { player.pos.z = delta > 0 ? best - HALF - EPS : best + HALF + EPS; player.vel.z = 0; } | |
| else { | |
| if (delta > 0) player.pos.y = best - HEIGHT - EPS; | |
| else { player.pos.y = best + EPS; player.onGround = true; } | |
| player.vel.y = 0; | |
| } | |
| } | |
| function physics(dt) { | |
| player.onGround = false; | |
| player.vel.y -= GRAVITY * dt; | |
| let dx = player.vel.x * dt; | |
| player.pos.x += dx; resolveAxis('x', dx); | |
| let dz = player.vel.z * dt; | |
| player.pos.z += dz; resolveAxis('z', dz); | |
| let dy = player.vel.y * dt; | |
| player.pos.y += dy; resolveAxis('y', dy); | |
| // void guard | |
| if (player.pos.y < -12) { hurt(6); spawnPlayer(); } | |
| } | |
| // ============================================================================ | |
| // Input | |
| // ============================================================================ | |
| const keys = {}; | |
| let locked = false; | |
| let mouseDown = [false, false, false]; | |
| let breakCd = 0, placeCd = 0; | |
| const overlay = document.getElementById('overlay'); | |
| const canvas = document.getElementById('game'); | |
| document.getElementById('playBtn').addEventListener('click', () => canvas.requestPointerLock()); | |
| overlay.addEventListener('click', (e) => { if (e.target === overlay) canvas.requestPointerLock(); }); | |
| document.addEventListener('pointerlockchange', () => { | |
| locked = document.pointerLockElement === canvas; | |
| overlay.classList.toggle('hidden', locked); | |
| if (locked) started = true; | |
| }); | |
| document.addEventListener('mousemove', (e) => { | |
| if (!locked) return; | |
| const s = 0.0022; | |
| player.yaw -= e.movementX * s; | |
| player.pitch -= e.movementY * s; | |
| player.pitch = Math.max(-1.55, Math.min(1.55, player.pitch)); | |
| }); | |
| canvas.addEventListener('mousedown', (e) => { | |
| if (!locked) return; | |
| mouseDown[e.button] = true; | |
| if (e.button === 0) { doBreakOrAttack(); breakCd = 0.28; } | |
| if (e.button === 2) { doPlace(); placeCd = 0.22; } | |
| }); | |
| document.addEventListener('mouseup', (e) => { mouseDown[e.button] = false; }); | |
| canvas.addEventListener('contextmenu', (e) => e.preventDefault()); | |
| window.addEventListener('keydown', (e) => { | |
| keys[e.code] = true; | |
| if (e.code >= 'Digit1' && e.code <= 'Digit9') selectSlot(+e.code.slice(5) - 1); | |
| if (e.code === 'Space') e.preventDefault(); | |
| }); | |
| window.addEventListener('keyup', (e) => { keys[e.code] = false; }); | |
| window.addEventListener('wheel', (e) => { | |
| if (!locked) return; | |
| selectSlot((selected + (e.deltaY > 0 ? 1 : -1) + HOTBAR.length) % HOTBAR.length); | |
| }); | |
| window.addEventListener('resize', () => { | |
| camera.aspect = window.innerWidth / window.innerHeight; | |
| camera.updateProjectionMatrix(); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| }); | |
| function movementInput(dt) { | |
| const forward = new THREE.Vector3(-Math.sin(player.yaw), 0, -Math.cos(player.yaw)); | |
| const right = new THREE.Vector3(Math.cos(player.yaw), 0, -Math.sin(player.yaw)); | |
| let ix = 0, iz = 0; | |
| if (keys['KeyW']) iz += 1; | |
| if (keys['KeyS']) iz -= 1; | |
| if (keys['KeyD']) ix += 1; | |
| if (keys['KeyA']) ix -= 1; | |
| const wish = new THREE.Vector3(); | |
| wish.addScaledVector(forward, iz).addScaledVector(right, ix); | |
| if (wish.lengthSq() > 0) wish.normalize(); | |
| const speed = keys['ShiftLeft'] || keys['ShiftRight'] ? SPRINT : WALK; | |
| // smooth horizontal velocity toward wish | |
| const targetX = wish.x * speed, targetZ = wish.z * speed; | |
| const accel = player.onGround ? 14 : 5; | |
| player.vel.x += (targetX - player.vel.x) * Math.min(1, accel * dt); | |
| player.vel.z += (targetZ - player.vel.z) * Math.min(1, accel * dt); | |
| if (keys['Space'] && player.onGround) { player.vel.y = JUMP; player.onGround = false; } | |
| } | |
| // ============================================================================ | |
| // Ray casting (DDA voxel traversal) for block targeting | |
| // ============================================================================ | |
| function voxelRay(maxDist) { | |
| const o = camera.position; | |
| const dir = new THREE.Vector3(0, 0, -1).applyQuaternion(camera.quaternion).normalize(); | |
| let x = Math.floor(o.x), y = Math.floor(o.y), z = Math.floor(o.z); | |
| const stepX = dir.x > 0 ? 1 : -1, stepY = dir.y > 0 ? 1 : -1, stepZ = dir.z > 0 ? 1 : -1; | |
| const tDX = dir.x === 0 ? Infinity : Math.abs(1 / dir.x); | |
| const tDY = dir.y === 0 ? Infinity : Math.abs(1 / dir.y); | |
| const tDZ = dir.z === 0 ? Infinity : Math.abs(1 / dir.z); | |
| let tMX = dir.x === 0 ? Infinity : ((stepX > 0 ? x + 1 - o.x : o.x - x) / Math.abs(dir.x)); | |
| let tMY = dir.y === 0 ? Infinity : ((stepY > 0 ? y + 1 - o.y : o.y - y) / Math.abs(dir.y)); | |
| let tMZ = dir.z === 0 ? Infinity : ((stepZ > 0 ? z + 1 - o.z : o.z - z) / Math.abs(dir.z)); | |
| let nx = 0, ny = 0, nz = 0, t = 0; | |
| for (let i = 0; i < 128; i++) { | |
| if (isSolid(x, y, z)) return { x, y, z, place: { x: x + nx, y: y + ny, z: z + nz } }; | |
| if (tMX < tMY && tMX < tMZ) { x += stepX; t = tMX; tMX += tDX; nx = -stepX; ny = 0; nz = 0; } | |
| else if (tMY < tMZ) { y += stepY; t = tMY; tMY += tDY; nx = 0; ny = -stepY; nz = 0; } | |
| else { z += stepZ; t = tMZ; tMZ += tDZ; nx = 0; ny = 0; nz = -stepZ; } | |
| if (t > maxDist) break; | |
| } | |
| return null; | |
| } | |
| // ============================================================================ | |
| // Inventory + hotbar | |
| // ============================================================================ | |
| const HOTBAR = [B.GRASS, B.DIRT, B.STONE, B.COBBLE, B.SAND, B.WOOD, B.PLANKS, B.LEAVES, B.GLASS]; | |
| let selected = 2; | |
| const inventory = {}; | |
| for (const id of Object.values(B)) inventory[id] = 0; | |
| // starter kit so you can build right away | |
| inventory[B.PLANKS] = 20; inventory[B.STONE] = 20; inventory[B.GLASS] = 10; inventory[B.DIRT] = 20; | |
| function selectSlot(i) { selected = i; updateHotbar(); } | |
| const hotbarEl = document.getElementById('hotbar'); | |
| function buildHotbar() { | |
| hotbarEl.innerHTML = ''; | |
| HOTBAR.forEach((id, i) => { | |
| const slot = document.createElement('div'); | |
| slot.className = 'slot'; | |
| slot.style.backgroundImage = `url(${texData[id].dataURL})`; | |
| slot.innerHTML = `<span class="num">${i + 1}</span><span class="cnt"></span>`; | |
| slot.addEventListener('click', () => selectSlot(i)); | |
| hotbarEl.appendChild(slot); | |
| }); | |
| updateHotbar(); | |
| } | |
| function updateHotbar() { | |
| [...hotbarEl.children].forEach((slot, i) => { | |
| slot.classList.toggle('sel', i === selected); | |
| const c = inventory[HOTBAR[i]]; | |
| const cnt = slot.querySelector('.cnt'); | |
| cnt.textContent = c; | |
| cnt.classList.toggle('zero', c === 0); | |
| }); | |
| } | |
| // ============================================================================ | |
| // Actions: break / place / attack | |
| // ============================================================================ | |
| function giveBlock(id, n = 1) { inventory[id] = (inventory[id] || 0) + n; updateHotbar(); } | |
| function doBreakOrAttack() { | |
| // first check for a mob in front | |
| const mob = pickMob(4.2); | |
| if (mob) { damageMob(mob, 5); return; } | |
| const hit = voxelRay(5); | |
| if (!hit) return; | |
| if (hit.y <= 0) { return; } // bedrock floor unbreakable | |
| const id = getBlock(hit.x, hit.y, hit.z); | |
| if (id === AIR) return; | |
| setBlockData(hit.x, hit.y, hit.z, AIR); | |
| giveBlock(id, 1); | |
| spawnParticles(hit.x + 0.5, hit.y + 0.5, hit.z + 0.5, id); | |
| beep(180, 0.05, 'square', 0.05); | |
| rebuildMeshes(); | |
| } | |
| function doPlace() { | |
| const hit = voxelRay(5); | |
| if (!hit) return; | |
| const p = hit.place; | |
| if (getBlock(p.x, p.y, p.z) !== AIR) return; | |
| const id = HOTBAR[selected]; | |
| if (inventory[id] <= 0) { beep(120, 0.06, 'sine', 0.04); return; } | |
| // don't place inside the player | |
| const pminx = player.pos.x - HALF, pmaxx = player.pos.x + HALF; | |
| const pminy = player.pos.y, pmaxy = player.pos.y + HEIGHT; | |
| const pminz = player.pos.z - HALF, pmaxz = player.pos.z + HALF; | |
| if (pmaxx > p.x && pminx < p.x + 1 && pmaxy > p.y && pminy < p.y + 1 && pmaxz > p.z && pminz < p.z + 1) return; | |
| setBlockData(p.x, p.y, p.z, id); | |
| inventory[id]--; updateHotbar(); | |
| beep(330, 0.05, 'square', 0.05); | |
| rebuildMeshes(); | |
| } | |
| // ============================================================================ | |
| // Mobs | |
| // ============================================================================ | |
| const mobs = []; | |
| const mobGroup = new THREE.Group(); | |
| let kills = 0; | |
| const mobBodyMat = new THREE.MeshLambertMaterial({ color: 0x2f8f4f }); | |
| const mobHeadMat = new THREE.MeshLambertMaterial({ color: 0x2a6f3f }); | |
| const mobLimbMat = new THREE.MeshLambertMaterial({ color: 0x246b96 }); | |
| const mobEyeMat = new THREE.MeshBasicMaterial({ color: 0x110000 }); | |
| function makeMobMesh() { | |
| const g = new THREE.Group(); | |
| const body = new THREE.Mesh(new THREE.BoxGeometry(0.55, 0.75, 0.3), mobBodyMat); | |
| body.position.y = 1.0; g.add(body); | |
| const head = new THREE.Mesh(new THREE.BoxGeometry(0.45, 0.45, 0.45), mobHeadMat); | |
| head.position.y = 1.62; g.add(head); | |
| const legL = new THREE.Mesh(new THREE.BoxGeometry(0.22, 0.6, 0.24), mobLimbMat); | |
| legL.position.set(-0.15, 0.3, 0); g.add(legL); | |
| const legR = legL.clone(); legR.position.x = 0.15; g.add(legR); | |
| const armL = new THREE.Mesh(new THREE.BoxGeometry(0.18, 0.6, 0.2), mobLimbMat); | |
| armL.position.set(-0.37, 1.05, 0.15); g.add(armL); | |
| const armR = armL.clone(); armR.position.x = 0.37; g.add(armR); | |
| const eyeL = new THREE.Mesh(new THREE.BoxGeometry(0.1, 0.1, 0.02), mobEyeMat); | |
| eyeL.position.set(-0.1, 1.66, 0.23); g.add(eyeL); | |
| const eyeR = eyeL.clone(); eyeR.position.x = 0.1; g.add(eyeR); | |
| g.userData.parts = { legL, legR, armL, armR, body }; | |
| return g; | |
| } | |
| function spawnMob() { | |
| if (mobs.length >= 8) return; | |
| const ang = Math.random() * Math.PI * 2; | |
| const dist = 12 + Math.random() * 8; | |
| let mx = Math.round(player.pos.x + Math.cos(ang) * dist); | |
| let mz = Math.round(player.pos.z + Math.sin(ang) * dist); | |
| mx = Math.max(-WORLD_R + 1, Math.min(WORLD_R - 1, mx)); | |
| mz = Math.max(-WORLD_R + 1, Math.min(WORLD_R - 1, mz)); | |
| const my = columnTop(mx, mz); | |
| const mesh = makeMobMesh(); | |
| mesh.position.set(mx + 0.5, my, mz + 0.5); | |
| mobGroup.add(mesh); | |
| const mob = { mesh, hp: 14, maxHp: 14, lastAttack: -99, hurtFlash: 0, walkPhase: Math.random() * 6 }; | |
| mesh.traverse(o => o.userData.mob = mob); | |
| mobs.push(mob); | |
| } | |
| function pickMob(maxDist) { | |
| const dir = new THREE.Vector3(0, 0, -1).applyQuaternion(camera.quaternion).normalize(); | |
| const ray = new THREE.Raycaster(camera.position.clone(), dir, 0, maxDist); | |
| const hits = ray.intersectObjects(mobGroup.children, true); | |
| if (hits.length) { let o = hits[0].object; while (o && !o.userData.mob) o = o.parent; return o ? o.userData.mob : null; } | |
| return null; | |
| } | |
| function damageMob(mob, dmg) { | |
| mob.hp -= dmg; | |
| mob.hurtFlash = 0.15; | |
| // knockback | |
| const kb = new THREE.Vector3().subVectors(mob.mesh.position, player.pos).setY(0).normalize().multiplyScalar(0.6); | |
| mob.mesh.position.add(kb); | |
| beep(90, 0.08, 'sawtooth', 0.06); | |
| if (mob.hp <= 0) { | |
| mobGroup.remove(mob.mesh); | |
| mobs.splice(mobs.indexOf(mob), 1); | |
| kills++; | |
| beep(500, 0.12, 'square', 0.06); | |
| } | |
| } | |
| function updateMobs(dt, now) { | |
| for (const mob of mobs) { | |
| const m = mob.mesh; | |
| const to = new THREE.Vector3(player.pos.x - m.position.x, 0, player.pos.z - m.position.z); | |
| const dist = to.length(); | |
| // ground clamp | |
| const gy = columnTop(Math.floor(m.position.x), Math.floor(m.position.z)); | |
| m.position.y += (gy - m.position.y) * Math.min(1, 12 * dt); | |
| if (dist < 22 && dist > 1.2) { | |
| to.normalize(); | |
| const sp = 2.6 * dt; | |
| // avoid walking into a wall taller than 1 block | |
| const nx = m.position.x + to.x * sp, nz = m.position.z + to.z * sp; | |
| const ahead = columnTop(Math.floor(nx), Math.floor(nz)); | |
| if (ahead - gy <= 1.2) { m.position.x = nx; m.position.z = nz; } | |
| m.rotation.y = Math.atan2(to.x, to.z); | |
| mob.walkPhase += dt * 8; | |
| } | |
| // limb animation | |
| const sw = Math.sin(mob.walkPhase) * 0.5; | |
| const p = m.userData.parts; | |
| p.legL.rotation.x = sw; p.legR.rotation.x = -sw; | |
| p.armL.rotation.x = -sw; p.armR.rotation.x = sw; | |
| // attack | |
| if (dist < 1.5 && now - mob.lastAttack > 1.0) { | |
| mob.lastAttack = now; | |
| hurt(3); | |
| const kb = to.clone().multiplyScalar(-3); player.vel.x += kb.x; player.vel.z += kb.z; player.vel.y += 3; | |
| } | |
| // hurt flash color | |
| if (mob.hurtFlash > 0) { | |
| mob.hurtFlash -= dt; | |
| p.body.material = new THREE.MeshLambertMaterial({ color: 0xff5555 }); | |
| } else if (p.body.material !== mobBodyMat) { | |
| p.body.material = mobBodyMat; | |
| } | |
| } | |
| } | |
| // ============================================================================ | |
| // Damage / health | |
| // ============================================================================ | |
| const flashEl = document.getElementById('flash'); | |
| function hurt(dmg) { | |
| const now = clock.getElapsedTime(); | |
| player.health = Math.max(0, player.health - dmg); | |
| player.lastHurt = now; | |
| flashEl.style.opacity = '1'; | |
| setTimeout(() => flashEl.style.opacity = '0', 90); | |
| beep(140, 0.12, 'sawtooth', 0.05); | |
| updateHearts(); | |
| if (player.health <= 0) respawn(); | |
| } | |
| function respawn() { | |
| // clear nearby mobs | |
| for (const m of mobs.slice()) { mobGroup.remove(m.mesh); } | |
| mobs.length = 0; | |
| spawnPlayer(); | |
| updateHearts(); | |
| } | |
| const heartsEl = document.getElementById('hearts'); | |
| function updateHearts() { | |
| let s = ''; | |
| const full = Math.ceil(player.health / 2); | |
| for (let i = 0; i < 10; i++) s += (i < full) ? '<span>♥</span>' : '<span class="empty">♥</span>'; | |
| heartsEl.innerHTML = s; | |
| } | |
| // ============================================================================ | |
| // Particles (block break) | |
| // ============================================================================ | |
| const particles = []; | |
| const pGeo = new THREE.BoxGeometry(0.12, 0.12, 0.12); | |
| function spawnParticles(x, y, z, id) { | |
| const mat = texData[id].material; | |
| for (let i = 0; i < 8; i++) { | |
| const m = new THREE.Mesh(pGeo, mat); | |
| m.position.set(x, y, z); | |
| scene.add(m); | |
| particles.push({ mesh: m, vel: new THREE.Vector3(rnd(-2, 2), rnd(1, 4), rnd(-2, 2)), life: 0.7 }); | |
| } | |
| } | |
| function updateParticles(dt) { | |
| for (let i = particles.length - 1; i >= 0; i--) { | |
| const p = particles[i]; | |
| p.vel.y -= 12 * dt; | |
| p.mesh.position.addScaledVector(p.vel, dt); | |
| p.life -= dt; | |
| p.mesh.scale.setScalar(Math.max(0.01, p.life / 0.7)); | |
| if (p.life <= 0) { scene.remove(p.mesh); particles.splice(i, 1); } | |
| } | |
| } | |
| // ============================================================================ | |
| // Audio (tiny WebAudio blips) | |
| // ============================================================================ | |
| let actx = null; | |
| function beep(freq, dur, type, vol) { | |
| try { | |
| if (!actx) actx = new (window.AudioContext || window.webkitAudioContext)(); | |
| const o = actx.createOscillator(), g = actx.createGain(); | |
| o.type = type || 'square'; o.frequency.value = freq; | |
| g.gain.value = vol || 0.05; | |
| o.connect(g); g.connect(actx.destination); | |
| o.start(); | |
| g.gain.exponentialRampToValueAtTime(0.0001, actx.currentTime + dur); | |
| o.stop(actx.currentTime + dur); | |
| } catch (e) {} | |
| } | |
| // ============================================================================ | |
| // Day/night cycle | |
| // ============================================================================ | |
| let dayTime = 0.28; // 0..1 | |
| const SKY_DAY = new THREE.Color(0x87ceeb), SKY_NIGHT = new THREE.Color(0x0a1030); | |
| const FOG_DAY = new THREE.Color(0x87ceeb), FOG_NIGHT = new THREE.Color(0x0a1030); | |
| function updateSky(dt) { | |
| dayTime = (dayTime + dt / 120) % 1; // 2 minute day | |
| const ang = dayTime * Math.PI * 2; | |
| const sunH = Math.sin(ang - Math.PI / 2 + Math.PI / 2); // approximate elevation | |
| const elev = Math.sin(dayTime * Math.PI * 2 - Math.PI / 2); // -1..1, peak at noon(.5) | |
| const day = Math.max(0, Math.sin(dayTime * Math.PI)); // 0 at night ends, up at day | |
| const t = Math.max(0, Math.min(1, (elev + 0.2) / 1.2)); | |
| sun.position.set(Math.cos(ang) * 60, Math.sin(ang) * 60 + 5, 25); | |
| sun.intensity = 0.15 + 0.85 * t; | |
| hemi.intensity = 0.25 + 0.6 * t; | |
| ambient.intensity = 0.15 + 0.3 * t; | |
| const sky = SKY_NIGHT.clone().lerp(SKY_DAY, t); | |
| scene.background = sky; | |
| scene.fog.color.copy(FOG_NIGHT.clone().lerp(FOG_DAY, t)); | |
| } | |
| // ============================================================================ | |
| // Main loop | |
| // ============================================================================ | |
| const clock = new THREE.Clock(); | |
| let started = false; | |
| let spawnTimer = 0; | |
| const statsEl = document.getElementById('stats'); | |
| const crossEl = document.getElementById('crosshair'); | |
| let fpsSmooth = 60, statAcc = 0; | |
| function loop() { | |
| requestAnimationFrame(loop); | |
| let dt = clock.getDelta(); | |
| if (dt > 0.05) dt = 0.05; | |
| const now = clock.getElapsedTime(); | |
| updateSky(dt); | |
| if (locked) { | |
| movementInput(dt); | |
| physics(dt); | |
| // auto-repeat while holding | |
| breakCd -= dt; placeCd -= dt; | |
| if (mouseDown[0] && breakCd <= 0) { doBreakOrAttack(); breakCd = 0.28; } | |
| if (mouseDown[2] && placeCd <= 0) { doPlace(); placeCd = 0.22; } | |
| // health regen | |
| if (player.health < player.maxHealth && now - player.lastHurt > 4) { | |
| player._regen = (player._regen || 0) + dt; | |
| if (player._regen > 1.5) { player.health = Math.min(player.maxHealth, player.health + 1); player._regen = 0; updateHearts(); } | |
| } | |
| // mob spawning | |
| spawnTimer -= dt; | |
| const nightBoost = scene && sun.intensity < 0.4; | |
| if (spawnTimer <= 0) { spawnMob(); spawnTimer = nightBoost ? 2.5 : 5; } | |
| } | |
| updateMobs(dt, now); | |
| updateParticles(dt); | |
| // camera follows player eyes | |
| camera.position.set(player.pos.x, player.pos.y + EYE, player.pos.z); | |
| camera.rotation.set(player.pitch, player.yaw, 0); | |
| // block highlight | |
| const hit = voxelRay(5); | |
| if (hit) { highlight.visible = true; highlight.position.set(hit.x + 0.5, hit.y + 0.5, hit.z + 0.5); } | |
| else highlight.visible = false; | |
| crossEl.classList.toggle('hit', !!(hit || pickMob(4.2))); | |
| renderer.render(scene, camera); | |
| // stats | |
| statAcc += dt; | |
| fpsSmooth += (1 / Math.max(dt, 0.001) - fpsSmooth) * 0.1; | |
| if (statAcc > 0.25) { | |
| statAcc = 0; | |
| const tod = dayTime < 0.25 || dayTime > 0.75 ? 'Night' : 'Day'; | |
| statsEl.innerHTML = | |
| `Coal: ${inventory[B.COAL]} Iron: ${inventory[B.IRON]}<br>` + | |
| `Kills: ${kills} Mobs: ${mobs.length}<br>` + | |
| `${tod} FPS: ${fpsSmooth | 0}`; | |
| } | |
| } | |
| // ============================================================================ | |
| // Boot | |
| // ============================================================================ | |
| function boot() { | |
| if (!THREE_OK) return; | |
| buildTextures(); | |
| initThree(); | |
| generateWorld(); | |
| rebuildMeshes(); | |
| scene.add(mobGroup); | |
| spawnPlayer(); | |
| buildHotbar(); | |
| updateHearts(); | |
| loop(); | |
| } | |
| boot(); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment