Voxel Game

Click to play. WASD to move, mouse to look. Left-click to break, right-click to place. Use hotbar (1-7) to select block type.

javascript // ============================================================ // CORE: Block Types, RNG, World Generation, Data, Harness // ============================================================ // --- Block Type Definitions --- const BLOCKS = { AIR: 0, GRASS: 1, DIRT: 2, STONE: 3, WOOD: 4, LEAVES: 5, SAND: 6, WATER: 7 }; const BLOCK_INFO = [ { name: 'air', solid: false, transparent: true, color: '#000000' }, { name: 'grass', solid: true, transparent: false, color: '#55aa33' }, { name: 'dirt', solid: true, transparent: false, color: '#8b6b4a' }, { name: 'stone', solid: true, transparent: false, color: '#888888' }, { name: 'wood', solid: true, transparent: false, color: '#6b4a2a' }, { name: 'leaves', solid: true, transparent: true, color: '#2d7a2d' }, { name: 'sand', solid: true, transparent: false, color: '#e8d8a0' }, { name: 'water', solid: false, transparent: true, color: '#3366cc' } ]; // --- Deterministic RNG (mulberry32) --- function mulberry32(seed) { let a = seed >>> 0; return function() { a |= 0; a = (a + 0x6D2B79F5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } // --- Block Map Data Structure --- const blocks = new Map(); function key(x, y, z) { return x + ',' + y + ',' + z; } function getBlock(x, y, z) { if (y < 0) return BLOCKS.STONE; return blocks.get(key(x, y, z)) || BLOCKS.AIR; } function setBlock(x, y, z, type) { x = Math.floor(x); y = Math.floor(y); z = Math.floor(z); if (type === BLOCKS.AIR) { blocks.delete(key(x, y, z)); } else { blocks.set(key(x, y, z), type); } } // --- World Generation --- let worldSeed = 0; function generateWorld(seed) { worldSeed = seed; const rng = mulberry32(seed); blocks.clear(); const WORLD_SIZE = 64; const HALF = WORLD_SIZE / 2; // Generate heightmap using layered sine waves + RNG variation const heightmap = new Map(); for (let x = -HALF; x < HALF; x++) { for (let z = -HALF; z < HALF; z++) { let h = 4; h += Math.sin(x * 0.1) * 2.0; h += Math.cos(z * 0.1) * 2.0; h += Math.sin((x + z) * 0.05) * 3.0; h += Math.cos((x - z) * 0.08) * 1.5; h += Math.sin(x * 0.3) * 0.5; h += Math.cos(z * 0.3) * 0.5; h += (rng() - 0.5) * 1.0; h = Math.floor(h); heightmap.set(x + ',' + z, h); } } // Place terrain blocks for (let x = -HALF; x < HALF; x++) { for (let z = -HALF; z < HALF; z++) { const h = heightmap.get(x + ',' + z); for (let y = 0; y <= h; y++) { let type; if (y === h) { type = h <= 2 ? BLOCKS.SAND : BLOCKS.GRASS; } else if (y >= h - 3) { type = BLOCKS.DIRT; } else { type = BLOCKS.STONE; } setBlock(x, y, z, type); } // Water in low areas if (h < 2) { for (let y = h + 1; y <= 2; y++) { setBlock(x, y, z, BLOCKS.WATER); } } } } // Generate trees const treeCount = 8 + Math.floor(rng() * 8); for (let i = 0; i < treeCount; i++) { const tx = Math.floor(rng() * WORLD_SIZE) - HALF; const tz = Math.floor(rng() * WORLD_SIZE) - HALF; // Find surface height let surfaceY = -1; for (let y = 30; y >= 0; y--) { const b = getBlock(tx, y, tz); if (b === BLOCKS.GRASS || b === BLOCKS.SAND) { surfaceY = y; break; } } if (surfaceY >= 0 && getBlock(tx, surfaceY, tz) === BLOCKS.GRASS) { const treeHeight = 4 + Math.floor(rng() * 3); // Trunk for (let y = surfaceY + 1; y <= surfaceY + treeHeight; y++) { setBlock(tx, y, tz, BLOCKS.WOOD); } // Leaves canopy (rounded blob) for (let dx = -2; dx <= 2; dx++) { for (let dz = -2; dz <= 2; dz++) { for (let dy = -1; dy <= 1; dy++) { const lx = tx + dx; const ly = surfaceY + treeHeight + dy; const lz = tz + dz; if (dx * dx + dz * dz + dy * dy > 5) continue; if (dx === 0 && dz === 0 && dy <= 0) continue; if (getBlock(lx, ly, lz) === BLOCKS.AIR) { setBlock(lx, ly, lz, BLOCKS.LEAVES); } } } } } } } // --- Player State --- const player = { x: 0, y: 20, z: 0, rx: 0, ry: 0, selectedBlock: BLOCKS.GRASS }; // --- __SGT Harness --- window.__SGT = { reset: function(seed) { const s = seed !== undefined ? seed : 12345; generateWorld(s); player.x = 0; player.y = 20; player.z = 0; player.rx = 0; player.ry = 0; player.selectedBlock = BLOCKS.GRASS; return { seed: worldSeed, blocks: blocks.size }; }, step: function(n) { const steps = n || 1; for (let i = 0; i < steps; i++) { // Simple gravity: fall if nothing solid below const px = Math.floor(player.x); const py = Math.floor(player.y); const pz = Math.floor(player.z); const below = getBlock(px, py - 1, pz); if (below === BLOCKS.AIR || below === BLOCKS.WATER) { player.y -= 0.1; } } return this.state(); }, state: function() { return { seed: worldSeed, player: { x: player.x, y: player.y, z: player.z, rx: player.rx, ry: player.ry }, blockCount: blocks.size, selectedBlock: player.selectedBlock }; }, blocks: function() { const result = []; for (const [k, v] of blocks) { const parts = k.split(','); result.push({ x: parseInt(parts[0]), y: parseInt(parts[1]), z: parseInt(parts[2]), type: v }); } return result; }, placeBlock: function(x, y, z, type) { if (type < 0 || type >= BLOCK_INFO.length) return false; setBlock(x, y, z, type); return true; }, breakBlock: function(x, y, z) { setBlock(x, y, z, BLOCKS.AIR); return true; }, setPos: function(x, y, z) { player.x = x; player.y = y; player.z = z; return true; }, getPos: function() { return { x: player.x, y: player.y, z: player.z }; }, worldSeed: function() { return worldSeed; } }; // Initialize with default seed generateWorld(12345); js // ===== PHYSICS & INTERACTION ===== // Constants const GRAVITY = -9.8; const JUMP_SPEED = 8.5; const WALK_SPEED = 4.3; const PLAYER_WIDTH = 0.6; const PLAYER_HEIGHT = 1.8; const EYE_HEIGHT = 1.62; const REACH_DISTANCE = 5; // Block helpers function getBlock(x, y, z) { return blocks.get(`${x},${y},${z}`); } function setBlock(x, y, z, type) { const key = `${x},${y},${z}`; if (type) blocks.set(key, type); else blocks.delete(key); } function isAir(x, y, z) { return !getBlock(x, y, z); } function isWater(x, y, z) { return getBlock(x, y, z) === 'water'; } function isSolid(x, y, z) { const b = getBlock(x, y, z); return b && b !== 'water'; } // Player AABB collision function checkCollision(px, py, pz) { const minX = px - PLAYER_WIDTH / 2; const maxX = px + PLAYER_WIDTH / 2; const minY = py; const maxY = py + PLAYER_HEIGHT; const minZ = pz - PLAYER_WIDTH / 2; const maxZ = pz + PLAYER_WIDTH / 2; 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)) return true; } } } return false; } // Raycast (DDA) from camera function raycast(origin, direction, maxDist) { let x = Math.floor(origin.x); let y = Math.floor(origin.y); let z = Math.floor(origin.z); const stepX = direction.x > 0 ? 1 : -1; const stepY = direction.y > 0 ? 1 : -1; const stepZ = direction.z > 0 ? 1 : -1; const tDeltaX = direction.x !== 0 ? Math.abs(1 / direction.x) : Infinity; const tDeltaY = direction.y !== 0 ? Math.abs(1 / direction.y) : Infinity; const tDeltaZ = direction.z !== 0 ? Math.abs(1 / direction.z) : Infinity; let tMaxX = direction.x !== 0 ? (direction.x > 0 ? (x + 1 - origin.x) : (origin.x - x)) * tDeltaX : Infinity; let tMaxY = direction.y !== 0 ? (direction.y > 0 ? (y + 1 - origin.y) : (origin.y - y)) * tDeltaY : Infinity; let tMaxZ = direction.z !== 0 ? (direction.z > 0 ? (z + 1 - origin.z) : (origin.z - z)) * tDeltaZ : Infinity; let lastX = x, lastY = y, lastZ = z; let t = 0; while (t <= maxDist) { const b = getBlock(x, y, z); if (b && b !== 'water') { return { hit: true, x, y, z, previous: { x: lastX, y: lastY, z: lastZ }, t }; } lastX = x; lastY = y; lastZ = z; if (tMaxX < tMaxY && tMaxX < tMaxZ) { x += stepX; t = tMaxX; tMaxX += tDeltaX; } else if (tMaxY < tMaxZ) { y += stepY; t = tMaxY; tMaxY += tDeltaY; } else { z += stepZ; t = tMaxZ; tMaxZ += tDeltaZ; } } return { hit: false }; } // Player movement function movePlayer(dt) { // Input vector let forward = 0, strafe = 0; if (keys['KeyW']) forward += 1; if (keys['KeyS']) forward -= 1; if (keys['KeyA']) strafe -= 1; if (keys['KeyD']) strafe += 1; const len = Math.hypot(forward, strafe); if (len > 0) { forward /= len; strafe /= len; } // Camera-relative direction const fx = -Math.sin(player.yaw); const fz = -Math.cos(player.yaw); const rx = Math.cos(player.yaw); const rz = -Math.sin(player.yaw); let dx = fx * forward + rx * strafe; let dz = fz * forward + rz * strafe; const dlen = Math.hypot(dx, dz); if (dlen > 0) { dx /= dlen; dz /= dlen; } player.vx = dx * WALK_SPEED; player.vz = dz * WALK_SPEED; // Gravity player.vy += GRAVITY * dt; // Jump if (keys['Space'] && player.onGround) { player.vy = JUMP_SPEED; player.onGround = false; } // Move X player.x += player.vx * dt; if (checkCollision(player.x, player.y, player.z)) { player.x -= player.vx * dt; player.vx = 0; } // Move Z player.z += player.vz * dt; if (checkCollision(player.x, player.y, player.z)) { player.z -= player.vz * dt; player.vz = 0; } // Move Y player.y += player.vy * dt; if (checkCollision(player.x, player.y, player.z)) { if (player.vy < 0) player.onGround = true; player.y -= player.vy * dt; player.vy = 0; } else { player.onGround = false; } } // Mouse look function handleMouseLook(dx, dy) { const sensitivity = 0.002; player.yaw -= dx * sensitivity; player.pitch -= dy * sensitivity; const limit = Math.PI / 2 - 0.01; player.pitch = Math.max(-limit, Math.min(limit, player.pitch)); } // Water flow (simple deterministic) function updateWater() { const waterBlocks = []; for (const [key, type] of blocks) { if (type === 'water') { const [x, y, z] = key.split(',').map(Number); waterBlocks.push({ x, y, z }); } } waterBlocks.sort((a, b) => a.y - b.y || a.x - b.x || a.z - b.z); for (const w of waterBlocks) { // Fall down if possible if (isAir(w.x, w.y - 1, w.z)) { setBlock(w.x, w.y, w.z, null); setBlock(w.x, w.y - 1, w.z, 'water'); continue; } // Spread horizontally on solid/water support const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1]]; for (const [dx, dz] of dirs) { const nx = w.x + dx; const nz = w.z + dz; if (isAir(nx, w.y, nz) && (isSolid(nx, w.y - 1, nz) || isWater(nx, w.y - 1, nz))) { setBlock(nx, w.y, nz, 'water'); break; } } } } // Place / break function canPlaceAt(x, y, z) { if (!isAir(x, y, z)) return false; const minX = player.x - PLAYER_WIDTH / 2; const maxX = player.x + PLAYER_WIDTH / 2; const minY = player.y; const maxY = player.y + PLAYER_HEIGHT; const minZ = player.z - PLAYER_WIDTH / 2; const maxZ = player.z + PLAYER_WIDTH / 2; return !(x + 1 > minX && x < maxX && y + 1 > minY && y < maxY && z + 1 > minZ && z < maxZ); } function getEyePosition() { return { x: player.x, y: player.y + EYE_HEIGHT, z: player.z }; } function getLookDirection() { const cp = Math.cos(player.pitch); return { x: -Math.sin(player.yaw) * cp, y: Math.sin(player.pitch), z: -Math.cos(player.yaw) * cp }; } function breakBlockAt(x, y, z) { if (isSolid(x, y, z)) { setBlock(x, y, z, null); return true; } return false; } function placeBlockAt(x, y, z, type) { if (canPlaceAt(x, y, z)) { setBlock(x, y, z, type); return true; } return false; } function interactBreak() { const origin = getEyePosition(); const dir = getLookDirection(); const result = raycast(origin, dir, REACH_DISTANCE); if (result.hit) { return breakBlockAt(result.x, result.y, result.z); } return false; } function interactPlace(type) { const origin = getEyePosition(); const dir = getLookDirection(); const result = raycast(origin, dir, REACH_DISTANCE); if (result.hit) { const { x, y, z } = result.previous; return placeBlockAt(x, y, z, type); } return false; } // Physics tick function physicsTick(dt) { movePlayer(dt); updateWater(); } // God-Mode harness window.__SGT = window.__SGT || {}; window.__SGT.step = function(n = 1) { const dt = 1 / 60; for (let i = 0; i < n; i++) { physicsTick(dt); } }; window.__SGT.placeBlock = function(x, y, z, type) { return placeBlockAt(x, y, z, type); }; window.__SGT.breakBlock = function(x, y, z) { return breakBlockAt(x, y, z); }; window.__SGT.setPos = function(x, y, z) { player.x = x; player.y = y; player.z = z; player.vx = 0; player.vy = 0; player.vz = 0; player.onGround = false; }; window.__SGT.getPos = function() { return { x: player.x, y: player.y, z: player.z }; }; window.__SGT.handleMouseLook = handleMouseLook; window.__SGT.physicsTick = physicsTick; // ===== Renderer ===== let canvas = document.getElementById('game'); if (!canvas) { canvas = document.createElement('canvas'); canvas.id = 'game'; document.body.appendChild(canvas); } let hudCanvas = document.getElementById('hud'); if (!hudCanvas) { hudCanvas = document.createElement('canvas'); hudCanvas.id = 'hud'; document.body.appendChild(hudCanvas); } Object.assign(canvas.style, { position:'fixed', top:0, left:0, width:'100%', height:'100%', display:'block' }); Object.assign(hudCanvas.style, { position:'fixed', top:0, left:0, width:'100%', height:'100%', display:'block', pointerEvents:'none' }); function resize() { const w = window.innerWidth, h = window.innerHeight; canvas.width = w; canvas.height = h; hudCanvas.width = w; hudCanvas.height = h; } window.addEventListener('resize', resize); resize(); const gl = canvas.getContext('webgl', { antialias: true }); if (!gl) { alert('WebGL not supported'); throw new Error('WebGL not supported'); } const BLOCK = { AIR:0, GRASS:1, DIRT:2, STONE:3, WOOD:4, LEAVES:5, SAND:6, WATER:7 }; const BLOCK_NAMES = ['Air','Grass','Dirt','Stone','Wood','Leaves','Sand','Water']; const TILE_SIZE = 16; const ATLAS_COLS = 4; const ATLAS_ROWS = 4; const atlasCanvas = document.createElement('canvas'); atlasCanvas.width = TILE_SIZE * ATLAS_COLS; atlasCanvas.height = TILE_SIZE * ATLAS_ROWS; const actx = atlasCanvas.getContext('2d'); const TILE = { GRASS_TOP: 0, GRASS_SIDE: 1, DIRT: 2, STONE: 3, WOOD_SIDE: 4, WOOD_TOP: 5, LEAVES: 6, SAND: 7, WATER: 8 }; function drawNoiseTile(tileIndex, baseColor, variation, pattern) { const x = (tileIndex % ATLAS_COLS) * TILE_SIZE; const y = Math.floor(tileIndex / ATLAS_COLS) * TILE_SIZE; const img = actx.createImageData(TILE_SIZE, TILE_SIZE); for (let i = 0; i < TILE_SIZE * TILE_SIZE; i++) { const px = i % TILE_SIZE; const py = Math.floor(i / TILE_SIZE); let r = baseColor[0], g = baseColor[1], b = baseColor[2]; const v = (Math.random() * 2 - 1) * variation; r += v; g += v; b += v; if (pattern === 'grass_side' && py < 3) { r = 80 + Math.random()*20; g = 160 + Math.random()*40; b = 60 + Math.random()*20; } else if (pattern === 'wood_side') { if (px % 4 === 0) { r *= 0.7; g *= 0.7; b *= 0.7; } } else if (pattern === 'wood_top') { const dx = px - 8, dy = py - 8; const dist = Math.sqrt(dx*dx + dy*dy); if (Math.floor(dist) % 2 === 0) { r *= 0.8; g *= 0.8; b *= 0.8; } } else if (pattern === 'leaves') { if (Math.random() < 0.2) { r *= 0.6; g *= 0.8; b *= 0.6; } } img.data[i*4] = Math.max(0, Math.min(255, r)); img.data[i*4+1] = Math.max(0, Math.min(255, g)); img.data[i*4+2] = Math.max(0, Math.min(255, b)); img.data[i*4+3] = 255; } actx.putImageData(img, x, y); } drawNoiseTile(TILE.GRASS_TOP, [90, 180, 70], 20, 'grass_top'); drawNoiseTile(TILE.GRASS_SIDE, [120, 90, 60], 15, 'grass_side'); drawNoiseTile(TILE.DIRT, [120, 85, 55], 15, 'dirt'); drawNoiseTile(TILE.STONE, [130, 130, 130], 15, 'stone'); drawNoiseTile(TILE.WOOD_SIDE, [100, 70, 40], 10, 'wood_side'); drawNoiseTile(TILE.WOOD_TOP, [110, 80, 50], 10, 'wood_top'); drawNoiseTile(TILE.LEAVES, [50, 120, 40], 25, 'leaves'); drawNoiseTile(TILE.SAND, [210, 190, 140], 10, 'sand'); drawNoiseTile(TILE.WATER, [50, 100, 220], 10, 'water'); const texture = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, texture); gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, atlasCanvas); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); const vertexShaderSource = ` attribute vec3 aPosition; attribute vec3 aNormal; attribute vec2 aUV; uniform mat4 uProjection; uniform mat4 uView; varying vec3 vNormal; varying vec2 vUV; varying vec3 vWorldPos; void main() { vWorldPos = aPosition; gl_Position = uProjection * uView * vec4(aPosition, 1.0); vNormal = aNormal; vUV = aUV; } `; const fragmentShaderSource = ` precision mediump float; uniform sampler2D uTexture; uniform vec3 uCameraPos; uniform vec3 uFogColor; uniform float uFogStart; uniform float uFogEnd; varying vec3 vNormal; varying vec2 vUV; varying vec3 vWorldPos; void main() { vec4 tex = texture2D(uTexture, vUV); vec3 normal = normalize(vNormal); float shade = max(dot(normal, normalize(vec3(0.5, 1.0, 0.3))), 0.25); vec3 color = tex.rgb * shade; float dist = length(vWorldPos - uCameraPos); float fog = clamp((dist - uFogStart) / (uFogEnd - uFogStart), 0.0, 1.0); gl_FragColor = vec4(mix(color, uFogColor, fog), tex.a); } `; function compileShader(type, source) { const shader = gl.createShader(type); gl.shaderSource(shader, source); gl.compileShader(shader); if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { console.error(gl.getShaderInfoLog(shader)); throw new Error('Shader compile error'); } return shader; } const vertexShader = compileShader(gl.VERTEX_SHADER, vertexShaderSource); const fragmentShader = compileShader(gl.FRAGMENT_SHADER, fragmentShaderSource); const program = gl.createProgram(); gl.attachShader(program, vertexShader); gl.attachShader(program, fragmentShader); gl.linkProgram(program); if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { console.error(gl.getProgramInfoLog(program)); throw new Error('Program link error'); } gl.useProgram(program); const aPosition = gl.getAttribLocation(program, 'aPosition'); const aNormal = gl.getAttribLocation(program, 'aNormal'); const aUV = gl.getAttribLocation(program, 'aUV'); const uProjection = gl.getUniformLocation(program, 'uProjection'); const uView = gl.getUniformLocation(program, 'uView'); const uTexture = gl.getUniformLocation(program, 'uTexture'); const uCameraPos = gl.getUniformLocation(program, 'uCameraPos'); const uFogColor = gl.getUniformLocation(program, 'uFogColor'); const uFogStart = gl.getUniformLocation(program, 'uFogStart'); const uFogEnd = gl.getUniformLocation(program, 'uFogEnd'); const positionBuffer = gl.createBuffer(); const normalBuffer = gl.createBuffer(); const uvBuffer = gl.createBuffer(); const indexBuffer = gl.createBuffer(); let meshPositions = []; let meshNormals = []; let meshUVs = []; let meshIndices = []; let meshVertexCount = 0; let meshDirty = true; const FACES = [ { normal: [1,0,0], vertices: [[1,0,1],[1,0,0],[1,1,0],[1,1,1]] }, { normal: [-1,0,0], vertices: [[0,0,0],[0,0,1],[0,1,1],[0,1,0]] }, { normal: [0,1,0], vertices: [[0,1,1],[1,1,1],[1,1,0],[0,1,0]] }, { normal: [0,-1,0], vertices: [[0,0,0],[1,0,0],[1,0,1],[0,0,1]] }, { normal: [0,0,1], vertices: [[0,0,1],[1,0,1],[1,1,1],[0,1,1]] }, { normal: [0,0,-1], vertices: [[1,0,0],[0,0,0],[0,1,0],[1,1,0]] } ]; function getTileIndex(blockType, faceIndex) { switch (blockType) { case BLOCK.GRASS: if (faceIndex === 2) return TILE.GRASS_TOP; if (faceIndex === 3) return TILE.DIRT; return TILE.GRASS_SIDE; case BLOCK.DIRT: return TILE.DIRT; case BLOCK.STONE: return TILE.STONE; case BLOCK.WOOD: if (faceIndex === 2 || faceIndex === 3) return TILE.WOOD_TOP; return TILE.WOOD_SIDE; case BLOCK.LEAVES: return TILE.LEAVES; case BLOCK.SAND: return TILE.SAND; case BLOCK.WATER: return TILE.WATER; default: return TILE.STONE; } } function getBlock(x, y, z) { return blocks.get(x + ',' + y + ',' + z) || 0; } function addFace(x, y, z, faceIndex, blockType) { const face = FACES[faceIndex]; const tileIndex = getTileIndex(blockType, faceIndex); const tileX = (tileIndex % ATLAS_COLS) / ATLAS_COLS; const tileY = Math.floor(tileIndex / ATLAS_COLS) / ATLAS_ROWS; const tileSizeU = 1 / ATLAS_COLS; const tileSizeV = 1 / ATLAS_ROWS; const u0 = tileX; const u1 = tileX + tileSizeU; const v0 = 1 - tileY - tileSizeV; const v1 = 1 - tileY; const uvs = [ [u0, v1], [u1, v1], [u1, v0], [u0, v0] ]; const base = meshVertexCount; for (let i = 0; i < 4; i++) { const v = face.vertices[i]; meshPositions.push(x + v[0], y + v[1], z + v[2]); meshNormals.push(face.normal[0], face.normal[1], face.normal[2]); meshUVs.push(uvs[i][0], uvs[i][1]); } meshIndices.push(base, base+1, base+2, base, base+2, base+3); meshVertexCount += 4; } function buildMesh() { meshPositions = []; meshNormals = []; meshUVs = []; meshIndices = []; meshVertexCount = 0; for (const [key, type] of blocks) { const parts = key.split(','); const x = parseInt(parts[0]); const y = parseInt(parts[1]); const z = parseInt(parts[2]); for (let f = 0; f < 6; f++) { const n = FACES[f].normal; const nx = x + n[0], ny = y + n[1], nz = z + n[2]; if (getBlock(nx, ny, nz) === 0) { addFace(x, y, z, f, type); } } } gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(meshPositions), gl.DYNAMIC_DRAW); gl.bindBuffer(gl.ARRAY_BUFFER, normalBuffer); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(meshNormals), gl.DYNAMIC_DRAW); gl.bindBuffer(gl.ARRAY_BUFFER, uvBuffer); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(meshUVs), gl.DYNAMIC_DRAW); gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer); gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(meshIndices), gl.DYNAMIC_DRAW); meshDirty = false; } function mat4Perspective(out, fovy, aspect, near, far) { const f = 1.0 / Math.tan(fovy / 2); out[0] = f / aspect; out[1] = 0; out[2] = 0; out[3] = 0; out[4] = 0; out[5] = f; out[6] = 0; out[7] = 0; out[8] = 0; out[9] = 0; out[10] = (far + near) / (near - far); out[11] = -1; out[12] = 0; out[13] = 0; out[14] = (2 * far * near) / (near - far); out[15] = 0; } function mat4LookAt(out, eye, center, up) { const z0 = eye[0] - center[0], z1 = eye[1] - center[1], z2 = eye[2] - center[2]; let len = Math.hypot(z0, z1, z2); if (len === 0) len = 1; out[2] = z0 / len; out[6] = z1 / len; out[10] = z2 / len; const x0 = up[1] * out[10] - up[2] * out[6]; const x1 = up[2] * out[2] - up[0] * out[10]; const x2 = up[0] * out[6] - up[1] * out[2]; len = Math.hypot(x0, x1, x2); if (len === 0) len = 1; out[0] = x0 / len; out[4] = x1 / len; out[8] = x2 / len; const y0 = out[6] * out[8] - out[10] * out[4]; const y1 = out[10] * out[0] - out[2] * out[8]; const y2 = out[2] * out[4] - out[6] * out[0]; out[1] = y0; out[5] = y1; out[9] = y2; out[12] = -(out[0] * eye[0] + out[4] * eye[1] + out[8] * eye[2]); out[13] = -(out[1] * eye[0] + out[5] * eye[1] + out[9] * eye[2]); out[14] = -(out[2] * eye[0] + out[6] * eye[1] + out[10] * eye[2]); out[3] = 0; out[7] = 0; out[11] = 0; out[15] = 1; } let mouseDown = false; let mouseButton = 0; let pointerLocked = false; canvas.addEventListener('click', () => { canvas.requestPointerLock(); }); document.addEventListener('pointerlockchange', () => { pointerLocked = document.pointerLockElement === canvas; }); document.addEventListener('mousemove', (e) => { if (!pointerLocked) return; const sensitivity = 0.002; player.yaw -= e.movementX * sensitivity; player.pitch -= e.movementY * sensitivity; const maxPitch = Math.PI / 2 - 0.01; player.pitch = Math.max(-maxPitch, Math.min(maxPitch, player.pitch)); }); document.addEventListener('mousedown', (e) => { if (!pointerLocked) return; mouseDown = true; mouseButton = e.button; handleClick(e.button); }); document.addEventListener('mouseup', () => { mouseDown = false; }); document.addEventListener('contextmenu', (e) => e.preventDefault()); document.addEventListener('wheel', (e) => { if (e.deltaY > 0) selected = (selected + 1) % hotbar.length; else if (e.deltaY < 0) selected = (selected - 1 + hotbar.length) % hotbar.length; }); document.addEventListener('keydown', (e) => { keys[e.code] = true; }); document.addEventListener('keyup', (e) => { keys[e.code] = false; }); function raycast(origin, direction, maxDist) { let x = Math.floor(origin[0]); let y = Math.floor(origin[1]); let z = Math.floor(origin[2]); const stepX = direction[0] > 0 ? 1 : -1; const stepY = direction[1] > 0 ? 1 : -1; const stepZ = direction[2] > 0 ? 1 : -1; const tDeltaX = Math.abs(1 / (direction[0] || 1e-10)); const tDeltaY = Math.abs(1 / (direction[1] || 1e-10)); const tDeltaZ = Math.abs(1 / (direction[2] || 1e-10)); let tMaxX = ((direction[0] > 0 ? x + 1 - origin[0] : origin[0] - x) || 0) * tDeltaX; let tMaxY = ((direction[1] > 0 ? y + 1 - origin[1] : origin[1] - y) || 0) * tDeltaY; let tMaxZ = ((direction[2] > 0 ? z + 1 - origin[2] : origin[2] - z) || 0) * tDeltaZ; let t = 0; let lastX = x, lastY = y, lastZ = z; while (t <= maxDist) { const b = getBlock(x, y, z); if (b !== 0) { return { x, y, z, type: b, nx: lastX, ny: lastY, nz: lastZ }; } lastX = x; lastY = y; lastZ = z; if (tMaxX < tMaxY && tMaxX < tMaxZ) { x += stepX; t = tMaxX; tMaxX += tDeltaX; } else if (tMaxY < tMaxZ) { y += stepY; t = tMaxY; tMaxY += tDeltaY; } else { z += stepZ; t = tMaxZ; tMaxZ += tDeltaZ; } } return null; } function blockIntersectsPlayer(bx, by, bz) { const px = player.x, py = player.y, pz = player.z; return bx < Math.floor(px + 0.3) + 1 && bx > Math.floor(px - 0.3) - 1 && by < Math.floor(py + 1.8) + 1 && by > Math.floor(py) - 1 && bz < Math.floor(pz + 0.3) + 1 && bz > Math.floor(pz - 0.3) - 1; } function handleClick(button) { if (!pointerLocked) return; const eye = [player.x, player.y + 1.62, player.z]; const yaw = player.yaw, pitch = player.pitch; const dir = [ -Math.sin(yaw) * Math.cos(pitch), Math.sin(pitch), -Math.cos(yaw) * Math.cos(pitch) ]; const hit = raycast(eye, dir, 6); if (!hit) return; if (button === 0) { blocks.delete(hit.x + ',' + hit.y + ',' + hit.z); meshDirty = true; } else if (button === 2) { const bx = hit.nx, by = hit.ny, bz = hit.nz; if (getBlock(bx, by, bz) === 0 && !blockIntersectsPlayer(bx, by, bz)) { blocks.set(bx + ',' + by + ',' + bz, hotbar[selected]); meshDirty = true; } } } function collides(x, y, z) { const minX = Math.floor(x - 0.3), maxX = Math.floor(x + 0.3); const minY = Math.floor(y), maxY = Math.floor(y + 1.8); const minZ = Math.floor(z - 0.3), maxZ = Math.floor(z + 0.3); for (let bx = minX; bx <= maxX; bx++) { for (let by = minY; by <= maxY; by++) { for (let bz = minZ; bz <= maxZ; bz++) { if (getBlock(bx, by, bz) !== 0) return true; } } } return false; } function updatePlayer(dt) { const speed = 5; const jumpSpeed = 8; const gravity = -20; let forward = 0, strafe = 0; if (keys['KeyW']) forward += 1; if (keys['KeyS']) forward -= 1; if (keys['KeyA']) strafe -= 1; if (keys['KeyD']) strafe += 1; const sin = Math.sin(player.yaw), cos = Math.cos(player.yaw); const moveX = (-sin * forward + cos * strafe) * speed; const moveZ = (-cos * forward - sin * strafe) * speed; player.vx = moveX; player.vz = moveZ; player.vy += gravity * dt; if (keys['Space'] && player.onGround) { player.vy = jumpSpeed; player.onGround = false; } const dx = player.vx * dt; const dy = player.vy * dt; const dz = player.vz * dt; player.x += dx; if (collides(player.x, player.y, player.z)) { player.x -= dx; player.vx = 0; } player.y += dy; if (collides(player.x, player.y, player.z)) { if (dy < 0) player.onGround = true; player.y -= dy; player.vy = 0; } else { player.onGround = false; } player.z += dz; if (collides(player.x, player.y, player.z)) { player.z -= dz; player.vz = 0; } } function render() { if (meshDirty) buildMesh(); gl.viewport(0, 0, canvas.width, canvas.height); gl.clearColor(0.6, 0.8, 1.0, 1.0); gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); gl.enable(gl.DEPTH_TEST); gl.enable(gl.CULL_FACE); gl.cullFace(gl.BACK); gl.useProgram(program); const projection = new Float32Array(16); mat4Perspective(projection, 70 * Math.PI / 180, canvas.width / canvas.height, 0.1, 200); gl.uniformMatrix4fv(uProjection, false, projection); const view = new Float32Array(16); const eye = [player.x, player.y + 1.62, player.z]; const center = [ eye[0] - Math.sin(player.yaw) * Math.cos(player.pitch), eye[1] + Math.sin(player.pitch), eye[2] - Math.cos(player.yaw) * Math.cos(player.pitch) ]; mat4LookAt(view, eye, center, [0, 1, 0]); gl.uniformMatrix4fv(uView, false, view); gl.uniform3f(uCameraPos, eye[0], eye[1], eye[2]); gl.uniform3f(uFogColor, 0.6, 0.8, 1.0); gl.uniform1f(uFogStart, 30); gl.uniform1f(uFogEnd, 100); gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, texture); gl.uniform1i(uTexture, 0); gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer); gl.enableVertexAttribArray(aPosition); gl.vertexAttribPointer(aPosition, 3, gl.FLOAT, false, 0, 0); gl.bindBuffer(gl.ARRAY_BUFFER, normalBuffer); gl.enableVertexAttribArray(aNormal); gl.vertexAttribPointer(aNormal, 3, gl.FLOAT, false, 0, 0); gl.bindBuffer(gl.ARRAY_BUFFER, uvBuffer); gl.enableVertexAttribArray(aUV); gl.vertexAttribPointer(aUV, 2, gl.FLOAT, false, 0, 0); gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer); gl.drawElements(gl.TRIANGLES, meshIndices.length, gl.UNSIGNED_SHORT, 0); } function drawHUD() { const ctx = hudCanvas.getContext('2d'); const w = hudCanvas.width, h = hudCanvas.height; ctx.clearRect(0, 0, w, h); ctx.strokeStyle = 'white'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(w/2 - 10, h/2); ctx.lineTo(w/2 + 10, h/2); ctx.moveTo(w/2, h/2 - 10); ctx.lineTo(w/2, h/2 + 10); ctx.stroke(); const slotSize = 48; const margin = 4; const totalWidth = hotbar.length * (slotSize + margin) - margin; const startX = (w - totalWidth) / 2; const startY = h - slotSize - 20; for (let i = 0; i < hotbar.length; i++) { const x = startX + i * (slotSize + margin); const y = startY; ctx.fillStyle = 'rgba(0,0,0,0.6)'; ctx.fillRect(x, y, slotSize, slotSize); if (i === selected) { ctx.strokeStyle = 'white'; ctx.lineWidth = 3; ctx.strokeRect(x - 2, y - 2, slotSize + 4, slotSize + 4); } const tileIndex = getTileIndex(hotbar[i], 2); const tileX = (tileIndex % ATLAS_COLS) * TILE_SIZE; const tileY = Math.floor(tileIndex / ATLAS_COLS) * TILE_SIZE; ctx.drawImage(atlasCanvas, tileX, tileY, TILE_SIZE, TILE_SIZE, x + 8, y + 8, slotSize - 16, slotSize - 16); } ctx.fillStyle = 'white'; ctx.font = '16px monospace'; ctx.textAlign = 'center'; ctx.fillText(BLOCK_NAMES[hotbar[selected]], w/2, startY - 10); } let lastTime = performance.now(); function gameLoop(time) { const dt = Math.min((time - lastTime) / 1000, 0.05); lastTime = time; updatePlayer(dt); render(); drawHUD(); requestAnimationFrame(gameLoop); } requestAnimationFrame(gameLoop); window.__SGT = window.__SGT || {}; window.__SGT.getPos = () => ({ x: player.x, y: player.y, z: player.z }); window.__SGT.setPos = (x, y, z) => { player.x = x; player.y = y; player.z = z; }; window.__SGT.blocks = () => { const arr = []; for (const [key, type] of blocks) { const p = key.split(','); arr.push({ x: +p[0], y: +p[1], z: +p[2], type }); } return arr; }; window.__SGT.placeBlock = (x, y, z, type) => { blocks.set(x+','+y+','+z, type); meshDirty = true; }; window.__SGT.breakBlock = (x, y, z) => { blocks.delete(x+','+y+','+z); meshDirty = true; }; window.__SGT.state = () => ({ player: { x: player.x, y: player.y, z: player.z, yaw: player.yaw, pitch: player.pitch }, selected: hotbar[selected] }); window.__SGT.worldSeed = () => (typeof worldSeed !== 'undefined' ? worldSeed : null); window.__SGT.reset = (seed) => { if (typeof generateWorld === 'function') { blocks.clear(); generateWorld(seed); meshDirty = true; } }; window.__SGT.step = (n) => { for (let i = 0; i < n; i++) updatePlayer(1/60); };