Skip to content

Instantly share code, notes, and snippets.

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

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

Select an option

Save senko/94ba9d3a4e02ec7186804a65cd462719 to your computer and use it in GitHub Desktop.
Minecraft clone by GLM-5.2
<!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 &mdash; 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