Skip to content

Instantly share code, notes, and snippets.

@senko
Created July 11, 2026 14:14
Show Gist options
  • Select an option

  • Save senko/ff2871573a160d04c5929e1d1c0cf92b to your computer and use it in GitHub Desktop.

Select an option

Save senko/ff2871573a160d04c5929e1d1c0cf92b to your computer and use it in GitHub Desktop.
Minecraft clone by Claude Opus 4.8
<!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 &nbsp;•&nbsp; Right-click: place &nbsp;•&nbsp; 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]} &nbsp; Iron: ${inventory[B.IRON]}<br>` +
`Kills: ${kills} &nbsp; Mobs: ${mobs.length}<br>` +
`${tod} &nbsp; 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