Last active
November 26, 2025 02:57
-
-
Save Siedrix/c04d86af0941d6c37dbb593b6a76e76d to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Use a vanilla javascript project with Three.js on a index.html file. | |
| Create a 3D Minecraft clone. | |
| ## Core Features | |
| - Procedural tree generation approach | |
| - Smooth terrain generation algorithms | |
| - Basic game play mechanics (movement, interaction, terrain manipulation) | |
| ## Project Structure | |
| - Single HTML with full-page canvas layout | |
| - Basic Ul controls | |
| - Styles for full-screen 3D experience | |
| ## Textures | |
| - Texture generation functions with Canvas API | |
| - Draw geometric patterns (lines, circles, angles) | |
| - Unique block textures: | |
| - Grass texture generation | |
| - Dirt texture generation | |
| - Stone texture generation | |
| - Wood texture generation | |
| - Leaf texture generation | |
| ## Terrain Generation | |
| - Multi-octave Simplex noise generator | |
| - Elevation algorithms for: | |
| - Rolling hills | |
| - Plateaus | |
| - Valleys | |
| - Block placement logic for: | |
| - Grass layers | |
| - Dirt layers | |
| - Stone layers | |
| ## Procedural Trees | |
| - Tree generation system | |
| - Trunk block placement | |
| - Canopy leaf distribution | |
| - Custom wood/leaf textures | |
| ## Player Controls | |
| - WASD movement system | |
| - Mouse look with pointer lock | |
| - Jump mechanics | |
| - Fly | |
| - Gravity simulation | |
| - Build collision detection system | |
| ## Block Interactions | |
| - Left-click block breaking | |
| - Right-click block placement | |
| - Terrain modification system | |
| ## Atmosphere | |
| - Gradient skybox system | |
| - Daylight cycle | |
| - Atmospheric depth effects | |
| ## Controls | |
| Control scheme: | |
| - WASD: Movement | |
| - Mouse: Look around | |
| - Space: Jump | |
| - Shift: Crouch | |
| - Left Click: Break blocks | |
| - Right Click: Place blocks | |
| ## Technology Stack | |
| - JavaScript | |
| - Three.js: Use ES Module (ESM) loaded via importmap from jsdelivr | |
| - CSS |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Minecraft Clone</title> | |
| <style> | |
| * { | |
| margin: 0; | |
| padding: 0; | |
| box-sizing: border-box; | |
| } | |
| body { | |
| overflow: hidden; | |
| font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; | |
| background: #000; | |
| } | |
| #game-canvas { | |
| display: block; | |
| width: 100vw; | |
| height: 100vh; | |
| } | |
| #crosshair { | |
| position: fixed; | |
| top: 50%; | |
| left: 50%; | |
| transform: translate(-50%, -50%); | |
| width: 20px; | |
| height: 20px; | |
| pointer-events: none; | |
| z-index: 100; | |
| } | |
| #crosshair::before, | |
| #crosshair::after { | |
| content: ''; | |
| position: absolute; | |
| background: white; | |
| mix-blend-mode: difference; | |
| } | |
| #crosshair::before { | |
| width: 2px; | |
| height: 20px; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| } | |
| #crosshair::after { | |
| width: 20px; | |
| height: 2px; | |
| top: 50%; | |
| transform: translateY(-50%); | |
| } | |
| #ui-container { | |
| position: fixed; | |
| top: 10px; | |
| left: 10px; | |
| z-index: 100; | |
| color: white; | |
| text-shadow: 2px 2px 4px rgba(0,0,0,0.8); | |
| } | |
| #controls-panel { | |
| background: rgba(0, 0, 0, 0.7); | |
| padding: 15px; | |
| border-radius: 8px; | |
| font-size: 12px; | |
| max-width: 250px; | |
| backdrop-filter: blur(5px); | |
| } | |
| #controls-panel h3 { | |
| margin-bottom: 10px; | |
| color: #7fff7f; | |
| font-size: 14px; | |
| } | |
| #controls-panel p { | |
| margin: 4px 0; | |
| color: #ddd; | |
| } | |
| #controls-panel .key { | |
| display: inline-block; | |
| background: rgba(255,255,255,0.2); | |
| padding: 2px 6px; | |
| border-radius: 3px; | |
| font-family: monospace; | |
| margin-right: 5px; | |
| } | |
| #stats-panel { | |
| position: fixed; | |
| top: 10px; | |
| right: 10px; | |
| background: rgba(0, 0, 0, 0.7); | |
| padding: 10px 15px; | |
| border-radius: 8px; | |
| color: white; | |
| font-size: 12px; | |
| text-shadow: 2px 2px 4px rgba(0,0,0,0.8); | |
| backdrop-filter: blur(5px); | |
| } | |
| #stats-panel div { | |
| margin: 3px 0; | |
| } | |
| #hotbar { | |
| position: fixed; | |
| bottom: 20px; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| display: flex; | |
| gap: 4px; | |
| z-index: 100; | |
| } | |
| .hotbar-slot { | |
| width: 50px; | |
| height: 50px; | |
| background: rgba(0, 0, 0, 0.6); | |
| border: 3px solid rgba(100, 100, 100, 0.8); | |
| border-radius: 4px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| position: relative; | |
| } | |
| .hotbar-slot.selected { | |
| border-color: #fff; | |
| box-shadow: 0 0 10px rgba(255,255,255,0.5); | |
| } | |
| .hotbar-slot canvas { | |
| width: 40px; | |
| height: 40px; | |
| image-rendering: pixelated; | |
| } | |
| .hotbar-slot .slot-number { | |
| position: absolute; | |
| top: 2px; | |
| left: 4px; | |
| font-size: 10px; | |
| color: white; | |
| text-shadow: 1px 1px 2px black; | |
| } | |
| #start-screen { | |
| position: fixed; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%); | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| z-index: 1000; | |
| color: white; | |
| } | |
| #start-screen h1 { | |
| font-size: 48px; | |
| margin-bottom: 20px; | |
| text-shadow: 4px 4px 8px rgba(0,0,0,0.5); | |
| color: #7fff7f; | |
| } | |
| #start-screen p { | |
| font-size: 18px; | |
| margin-bottom: 30px; | |
| color: #ccc; | |
| } | |
| #start-btn { | |
| padding: 15px 40px; | |
| font-size: 20px; | |
| background: linear-gradient(135deg, #4a7c59 0%, #3d6b4f 100%); | |
| border: none; | |
| color: white; | |
| border-radius: 8px; | |
| cursor: pointer; | |
| transition: transform 0.2s, box-shadow 0.2s; | |
| box-shadow: 0 4px 15px rgba(0,0,0,0.3); | |
| } | |
| #start-btn:hover { | |
| transform: scale(1.05); | |
| box-shadow: 0 6px 20px rgba(0,0,0,0.4); | |
| } | |
| #loading { | |
| position: fixed; | |
| top: 50%; | |
| left: 50%; | |
| transform: translate(-50%, -50%); | |
| color: white; | |
| font-size: 24px; | |
| z-index: 999; | |
| display: none; | |
| } | |
| #time-display { | |
| position: fixed; | |
| top: 10px; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| background: rgba(0, 0, 0, 0.5); | |
| padding: 5px 15px; | |
| border-radius: 15px; | |
| color: white; | |
| font-size: 14px; | |
| z-index: 100; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="start-screen"> | |
| <h1>Minecraft Clone</h1> | |
| <p>A 3D voxel world built with Three.js</p> | |
| <button id="start-btn">Click to Play</button> | |
| </div> | |
| <div id="loading">Generating World...</div> | |
| <canvas id="game-canvas"></canvas> | |
| <div id="crosshair"></div> | |
| <div id="ui-container"> | |
| <div id="controls-panel"> | |
| <h3>Controls</h3> | |
| <p><span class="key">W A S D</span> Move</p> | |
| <p><span class="key">Mouse</span> Look around</p> | |
| <p><span class="key">Space</span> Jump / Fly up</p> | |
| <p><span class="key">Shift</span> Crouch / Fly down</p> | |
| <p><span class="key">F</span> Toggle fly mode</p> | |
| <p><span class="key">Left Click</span> Break block</p> | |
| <p><span class="key">Right Click</span> Place block</p> | |
| <p><span class="key">1-5</span> Select block</p> | |
| <p><span class="key">Scroll</span> Change block</p> | |
| <p><span class="key">ESC</span> Release mouse</p> | |
| </div> | |
| </div> | |
| <div id="stats-panel"> | |
| <div>FPS: <span id="fps">0</span></div> | |
| <div>Position: <span id="position">0, 0, 0</span></div> | |
| <div>Blocks: <span id="block-count">0</span></div> | |
| <div>Mode: <span id="fly-mode">Walking</span></div> | |
| </div> | |
| <div id="time-display"> | |
| <span id="time-icon">☀️</span> <span id="time-text">12:00</span> | |
| </div> | |
| <div id="hotbar"></div> | |
| <script type="importmap"> | |
| { | |
| "imports": { | |
| "three": "https://cdn.jsdelivr.net/npm/[email protected]/build/three.module.js", | |
| "three/addons/": "https://cdn.jsdelivr.net/npm/[email protected]/examples/jsm/" | |
| } | |
| } | |
| </script> | |
| <script type="module"> | |
| import * as THREE from 'three'; | |
| // ============================================ | |
| // TEXTURE GENERATION SYSTEM | |
| // ============================================ | |
| class TextureGenerator { | |
| constructor() { | |
| this.textureSize = 16; | |
| this.textures = {}; | |
| } | |
| createCanvas() { | |
| const canvas = document.createElement('canvas'); | |
| canvas.width = this.textureSize; | |
| canvas.height = this.textureSize; | |
| return canvas; | |
| } | |
| // Seeded random for consistent textures | |
| seededRandom(seed) { | |
| const x = Math.sin(seed) * 10000; | |
| return x - Math.floor(x); | |
| } | |
| generateGrassTop() { | |
| const canvas = this.createCanvas(); | |
| const ctx = canvas.getContext('2d'); | |
| // Base green | |
| ctx.fillStyle = '#5d9b3a'; | |
| ctx.fillRect(0, 0, 16, 16); | |
| // Add variation | |
| for (let i = 0; i < 60; i++) { | |
| const x = Math.floor(this.seededRandom(i * 3) * 16); | |
| const y = Math.floor(this.seededRandom(i * 5) * 16); | |
| const shade = Math.floor(this.seededRandom(i * 7) * 40) - 20; | |
| const g = Math.min(255, Math.max(0, 155 + shade)); | |
| ctx.fillStyle = `rgb(${93 + shade/2}, ${g}, ${58 + shade/3})`; | |
| ctx.fillRect(x, y, 1, 1); | |
| } | |
| // Add grass blades | |
| for (let i = 0; i < 20; i++) { | |
| const x = Math.floor(this.seededRandom(i * 11) * 16); | |
| const y = Math.floor(this.seededRandom(i * 13) * 16); | |
| ctx.fillStyle = '#4a8030'; | |
| ctx.fillRect(x, y, 1, 2); | |
| } | |
| return canvas; | |
| } | |
| generateGrassSide() { | |
| const canvas = this.createCanvas(); | |
| const ctx = canvas.getContext('2d'); | |
| // Dirt base | |
| ctx.fillStyle = '#8b6b4a'; | |
| ctx.fillRect(0, 0, 16, 16); | |
| // Dirt texture | |
| for (let i = 0; i < 40; i++) { | |
| const x = Math.floor(this.seededRandom(i * 17) * 16); | |
| const y = Math.floor(this.seededRandom(i * 19) * 16); | |
| const shade = Math.floor(this.seededRandom(i * 23) * 30) - 15; | |
| ctx.fillStyle = `rgb(${139 + shade}, ${107 + shade}, ${74 + shade})`; | |
| ctx.fillRect(x, y, 2, 2); | |
| } | |
| // Grass top strip | |
| ctx.fillStyle = '#5d9b3a'; | |
| ctx.fillRect(0, 0, 16, 3); | |
| // Grass overhang | |
| for (let x = 0; x < 16; x++) { | |
| const hang = Math.floor(this.seededRandom(x * 29) * 3); | |
| ctx.fillStyle = '#5d9b3a'; | |
| ctx.fillRect(x, 3, 1, hang); | |
| } | |
| return canvas; | |
| } | |
| generateDirt() { | |
| const canvas = this.createCanvas(); | |
| const ctx = canvas.getContext('2d'); | |
| // Base dirt color | |
| ctx.fillStyle = '#8b6b4a'; | |
| ctx.fillRect(0, 0, 16, 16); | |
| // Add dirt variation | |
| for (let i = 0; i < 80; i++) { | |
| const x = Math.floor(this.seededRandom(i * 31) * 16); | |
| const y = Math.floor(this.seededRandom(i * 37) * 16); | |
| const shade = Math.floor(this.seededRandom(i * 41) * 40) - 20; | |
| ctx.fillStyle = `rgb(${139 + shade}, ${107 + shade}, ${74 + shade})`; | |
| ctx.fillRect(x, y, 2, 2); | |
| } | |
| // Add darker spots | |
| for (let i = 0; i < 15; i++) { | |
| const x = Math.floor(this.seededRandom(i * 43) * 16); | |
| const y = Math.floor(this.seededRandom(i * 47) * 16); | |
| ctx.fillStyle = '#6b4b2a'; | |
| ctx.fillRect(x, y, 2, 2); | |
| } | |
| return canvas; | |
| } | |
| generateStone() { | |
| const canvas = this.createCanvas(); | |
| const ctx = canvas.getContext('2d'); | |
| // Base stone color | |
| ctx.fillStyle = '#8a8a8a'; | |
| ctx.fillRect(0, 0, 16, 16); | |
| // Add stone variation | |
| for (let i = 0; i < 100; i++) { | |
| const x = Math.floor(this.seededRandom(i * 53) * 16); | |
| const y = Math.floor(this.seededRandom(i * 59) * 16); | |
| const shade = Math.floor(this.seededRandom(i * 61) * 50) - 25; | |
| const gray = Math.min(255, Math.max(0, 138 + shade)); | |
| ctx.fillStyle = `rgb(${gray}, ${gray}, ${gray})`; | |
| ctx.fillRect(x, y, 1, 1); | |
| } | |
| // Add crack lines | |
| for (let i = 0; i < 5; i++) { | |
| const x1 = Math.floor(this.seededRandom(i * 67) * 16); | |
| const y1 = Math.floor(this.seededRandom(i * 71) * 16); | |
| ctx.strokeStyle = '#5a5a5a'; | |
| ctx.lineWidth = 1; | |
| ctx.beginPath(); | |
| ctx.moveTo(x1, y1); | |
| ctx.lineTo(x1 + this.seededRandom(i * 73) * 6, y1 + this.seededRandom(i * 79) * 6); | |
| ctx.stroke(); | |
| } | |
| return canvas; | |
| } | |
| generateWood() { | |
| const canvas = this.createCanvas(); | |
| const ctx = canvas.getContext('2d'); | |
| // Base wood color | |
| ctx.fillStyle = '#6b5030'; | |
| ctx.fillRect(0, 0, 16, 16); | |
| // Add wood grain lines | |
| for (let y = 0; y < 16; y += 2) { | |
| const shade = Math.floor(this.seededRandom(y * 83) * 20) - 10; | |
| ctx.fillStyle = `rgb(${107 + shade}, ${80 + shade}, ${48 + shade})`; | |
| ctx.fillRect(0, y, 16, 1); | |
| } | |
| // Add bark texture | |
| for (let i = 0; i < 30; i++) { | |
| const x = Math.floor(this.seededRandom(i * 89) * 16); | |
| const y = Math.floor(this.seededRandom(i * 97) * 16); | |
| ctx.fillStyle = '#4a3520'; | |
| ctx.fillRect(x, y, 1, 2); | |
| } | |
| return canvas; | |
| } | |
| generateWoodTop() { | |
| const canvas = this.createCanvas(); | |
| const ctx = canvas.getContext('2d'); | |
| // Base wood color | |
| ctx.fillStyle = '#9b7050'; | |
| ctx.fillRect(0, 0, 16, 16); | |
| // Draw rings | |
| const cx = 8, cy = 8; | |
| for (let r = 2; r < 8; r += 2) { | |
| ctx.strokeStyle = r % 4 === 0 ? '#6b5030' : '#8b6040'; | |
| ctx.lineWidth = 1; | |
| ctx.beginPath(); | |
| ctx.arc(cx, cy, r, 0, Math.PI * 2); | |
| ctx.stroke(); | |
| } | |
| // Center | |
| ctx.fillStyle = '#5a4020'; | |
| ctx.fillRect(7, 7, 2, 2); | |
| return canvas; | |
| } | |
| generateLeaves() { | |
| const canvas = this.createCanvas(); | |
| const ctx = canvas.getContext('2d'); | |
| // Transparent base | |
| ctx.clearRect(0, 0, 16, 16); | |
| // Add leaf clusters | |
| for (let i = 0; i < 40; i++) { | |
| const x = Math.floor(this.seededRandom(i * 101) * 16); | |
| const y = Math.floor(this.seededRandom(i * 103) * 16); | |
| const shade = Math.floor(this.seededRandom(i * 107) * 40); | |
| ctx.fillStyle = `rgb(${50 + shade}, ${130 + shade/2}, ${50 + shade/2})`; | |
| ctx.fillRect(x, y, 2, 2); | |
| } | |
| // Add darker areas | |
| for (let i = 0; i < 20; i++) { | |
| const x = Math.floor(this.seededRandom(i * 109) * 16); | |
| const y = Math.floor(this.seededRandom(i * 113) * 16); | |
| ctx.fillStyle = '#2a6a2a'; | |
| ctx.fillRect(x, y, 2, 2); | |
| } | |
| // Fill remaining with base green | |
| const imageData = ctx.getImageData(0, 0, 16, 16); | |
| for (let i = 0; i < imageData.data.length; i += 4) { | |
| if (imageData.data[i + 3] === 0) { | |
| imageData.data[i] = 60; | |
| imageData.data[i + 1] = 140; | |
| imageData.data[i + 2] = 60; | |
| imageData.data[i + 3] = 255; | |
| } | |
| } | |
| ctx.putImageData(imageData, 0, 0); | |
| return canvas; | |
| } | |
| generateSand() { | |
| const canvas = this.createCanvas(); | |
| const ctx = canvas.getContext('2d'); | |
| // Base sand color | |
| ctx.fillStyle = '#e6d5a8'; | |
| ctx.fillRect(0, 0, 16, 16); | |
| // Add sand grain variation | |
| for (let i = 0; i < 100; i++) { | |
| const x = Math.floor(this.seededRandom(i * 127) * 16); | |
| const y = Math.floor(this.seededRandom(i * 131) * 16); | |
| const shade = Math.floor(this.seededRandom(i * 137) * 30) - 15; | |
| ctx.fillStyle = `rgb(${230 + shade}, ${213 + shade}, ${168 + shade})`; | |
| ctx.fillRect(x, y, 1, 1); | |
| } | |
| return canvas; | |
| } | |
| generateWater() { | |
| const canvas = this.createCanvas(); | |
| const ctx = canvas.getContext('2d'); | |
| // Base water color with transparency | |
| ctx.fillStyle = 'rgba(30, 100, 200, 0.7)'; | |
| ctx.fillRect(0, 0, 16, 16); | |
| // Add wave highlights | |
| for (let i = 0; i < 20; i++) { | |
| const x = Math.floor(this.seededRandom(i * 139) * 16); | |
| const y = Math.floor(this.seededRandom(i * 149) * 16); | |
| ctx.fillStyle = 'rgba(100, 180, 255, 0.5)'; | |
| ctx.fillRect(x, y, 3, 1); | |
| } | |
| return canvas; | |
| } | |
| createTexture(canvas) { | |
| const texture = new THREE.CanvasTexture(canvas); | |
| texture.magFilter = THREE.NearestFilter; | |
| texture.minFilter = THREE.NearestFilter; | |
| texture.colorSpace = THREE.SRGBColorSpace; | |
| return texture; | |
| } | |
| generateAllTextures() { | |
| this.textures = { | |
| grassTop: this.createTexture(this.generateGrassTop()), | |
| grassSide: this.createTexture(this.generateGrassSide()), | |
| dirt: this.createTexture(this.generateDirt()), | |
| stone: this.createTexture(this.generateStone()), | |
| wood: this.createTexture(this.generateWood()), | |
| woodTop: this.createTexture(this.generateWoodTop()), | |
| leaves: this.createTexture(this.generateLeaves()), | |
| sand: this.createTexture(this.generateSand()), | |
| water: this.createTexture(this.generateWater()) | |
| }; | |
| return this.textures; | |
| } | |
| getHotbarCanvas(blockType) { | |
| switch(blockType) { | |
| case 'grass': return this.generateGrassTop(); | |
| case 'dirt': return this.generateDirt(); | |
| case 'stone': return this.generateStone(); | |
| case 'wood': return this.generateWood(); | |
| case 'leaves': return this.generateLeaves(); | |
| default: return this.generateGrassTop(); | |
| } | |
| } | |
| } | |
| // ============================================ | |
| // SIMPLEX NOISE GENERATOR | |
| // ============================================ | |
| class SimplexNoise { | |
| constructor(seed = Math.random()) { | |
| this.seed = seed; | |
| this.p = this.buildPermutationTable(); | |
| this.perm = new Uint8Array(512); | |
| this.permMod12 = new Uint8Array(512); | |
| for (let i = 0; i < 512; i++) { | |
| this.perm[i] = this.p[i & 255]; | |
| this.permMod12[i] = this.perm[i] % 12; | |
| } | |
| this.grad3 = [ | |
| [1, 1, 0], [-1, 1, 0], [1, -1, 0], [-1, -1, 0], | |
| [1, 0, 1], [-1, 0, 1], [1, 0, -1], [-1, 0, -1], | |
| [0, 1, 1], [0, -1, 1], [0, 1, -1], [0, -1, -1] | |
| ]; | |
| } | |
| buildPermutationTable() { | |
| const p = []; | |
| for (let i = 0; i < 256; i++) p[i] = i; | |
| let n = 256; | |
| let seed = this.seed * 2147483647; | |
| while (n > 1) { | |
| seed = (seed * 16807) % 2147483647; | |
| const k = ((seed - 1) % n) + 1; | |
| n--; | |
| [p[n], p[k]] = [p[k], p[n]]; | |
| } | |
| return p; | |
| } | |
| dot(g, x, y) { | |
| return g[0] * x + g[1] * y; | |
| } | |
| noise2D(xin, yin) { | |
| const F2 = 0.5 * (Math.sqrt(3) - 1); | |
| const G2 = (3 - Math.sqrt(3)) / 6; | |
| let n0, n1, n2; | |
| const s = (xin + yin) * F2; | |
| const i = Math.floor(xin + s); | |
| const j = Math.floor(yin + s); | |
| const t = (i + j) * G2; | |
| const X0 = i - t; | |
| const Y0 = j - t; | |
| const x0 = xin - X0; | |
| const y0 = yin - Y0; | |
| let i1, j1; | |
| if (x0 > y0) { i1 = 1; j1 = 0; } | |
| else { i1 = 0; j1 = 1; } | |
| const x1 = x0 - i1 + G2; | |
| const y1 = y0 - j1 + G2; | |
| const x2 = x0 - 1 + 2 * G2; | |
| const y2 = y0 - 1 + 2 * G2; | |
| const ii = i & 255; | |
| const jj = j & 255; | |
| const gi0 = this.permMod12[ii + this.perm[jj]]; | |
| const gi1 = this.permMod12[ii + i1 + this.perm[jj + j1]]; | |
| const gi2 = this.permMod12[ii + 1 + this.perm[jj + 1]]; | |
| let t0 = 0.5 - x0 * x0 - y0 * y0; | |
| if (t0 < 0) n0 = 0; | |
| else { | |
| t0 *= t0; | |
| n0 = t0 * t0 * this.dot(this.grad3[gi0], x0, y0); | |
| } | |
| let t1 = 0.5 - x1 * x1 - y1 * y1; | |
| if (t1 < 0) n1 = 0; | |
| else { | |
| t1 *= t1; | |
| n1 = t1 * t1 * this.dot(this.grad3[gi1], x1, y1); | |
| } | |
| let t2 = 0.5 - x2 * x2 - y2 * y2; | |
| if (t2 < 0) n2 = 0; | |
| else { | |
| t2 *= t2; | |
| n2 = t2 * t2 * this.dot(this.grad3[gi2], x2, y2); | |
| } | |
| return 70 * (n0 + n1 + n2); | |
| } | |
| // Multi-octave noise for terrain | |
| octaveNoise(x, y, octaves, persistence, lacunarity, scale) { | |
| let total = 0; | |
| let frequency = 1; | |
| let amplitude = 1; | |
| let maxValue = 0; | |
| for (let i = 0; i < octaves; i++) { | |
| total += this.noise2D(x * frequency / scale, y * frequency / scale) * amplitude; | |
| maxValue += amplitude; | |
| amplitude *= persistence; | |
| frequency *= lacunarity; | |
| } | |
| return total / maxValue; | |
| } | |
| } | |
| // ============================================ | |
| // TERRAIN GENERATOR | |
| // ============================================ | |
| class TerrainGenerator { | |
| constructor(seed = 12345) { | |
| this.noise = new SimplexNoise(seed); | |
| this.treeNoise = new SimplexNoise(seed + 1); | |
| // Terrain parameters | |
| this.baseHeight = 20; | |
| this.heightVariation = 15; | |
| this.waterLevel = 15; | |
| } | |
| getHeight(x, z) { | |
| // Multi-octave noise for varied terrain | |
| const elevation = this.noise.octaveNoise(x, z, 4, 0.5, 2, 60); | |
| // Hills and valleys | |
| const hills = this.noise.octaveNoise(x + 1000, z + 1000, 2, 0.5, 2, 30) * 0.5; | |
| // Plateaus (using absolute value creates flat areas) | |
| const plateaus = Math.abs(this.noise.octaveNoise(x + 2000, z + 2000, 1, 0.5, 2, 100)) * 0.3; | |
| // Combine all terrain features | |
| const combined = elevation * 0.6 + hills * 0.3 + plateaus * 0.1; | |
| return Math.floor(this.baseHeight + combined * this.heightVariation); | |
| } | |
| getBlockType(x, y, z, surfaceHeight) { | |
| if (y > surfaceHeight) { | |
| if (y <= this.waterLevel) return 'water'; | |
| return 'air'; | |
| } | |
| if (y === surfaceHeight) { | |
| if (y <= this.waterLevel) return 'sand'; | |
| return 'grass'; | |
| } | |
| if (y >= surfaceHeight - 3) { | |
| return 'dirt'; | |
| } | |
| return 'stone'; | |
| } | |
| shouldPlaceTree(x, z, surfaceHeight) { | |
| if (surfaceHeight <= this.waterLevel) return false; | |
| const treeValue = this.treeNoise.noise2D(x * 0.3, z * 0.3); | |
| return treeValue > 0.7; | |
| } | |
| } | |
| // ============================================ | |
| // BLOCK MANAGER | |
| // ============================================ | |
| class BlockManager { | |
| constructor(textures) { | |
| this.textures = textures; | |
| this.blocks = new Map(); | |
| this.materials = this.createMaterials(); | |
| this.blockGeometry = new THREE.BoxGeometry(1, 1, 1); | |
| } | |
| createMaterials() { | |
| return { | |
| grass: [ | |
| new THREE.MeshLambertMaterial({ map: this.textures.grassSide }), | |
| new THREE.MeshLambertMaterial({ map: this.textures.grassSide }), | |
| new THREE.MeshLambertMaterial({ map: this.textures.grassTop }), | |
| new THREE.MeshLambertMaterial({ map: this.textures.dirt }), | |
| new THREE.MeshLambertMaterial({ map: this.textures.grassSide }), | |
| new THREE.MeshLambertMaterial({ map: this.textures.grassSide }) | |
| ], | |
| dirt: new THREE.MeshLambertMaterial({ map: this.textures.dirt }), | |
| stone: new THREE.MeshLambertMaterial({ map: this.textures.stone }), | |
| wood: [ | |
| new THREE.MeshLambertMaterial({ map: this.textures.wood }), | |
| new THREE.MeshLambertMaterial({ map: this.textures.wood }), | |
| new THREE.MeshLambertMaterial({ map: this.textures.woodTop }), | |
| new THREE.MeshLambertMaterial({ map: this.textures.woodTop }), | |
| new THREE.MeshLambertMaterial({ map: this.textures.wood }), | |
| new THREE.MeshLambertMaterial({ map: this.textures.wood }) | |
| ], | |
| leaves: new THREE.MeshLambertMaterial({ | |
| map: this.textures.leaves, | |
| transparent: false, | |
| side: THREE.DoubleSide | |
| }), | |
| sand: new THREE.MeshLambertMaterial({ map: this.textures.sand }), | |
| water: new THREE.MeshLambertMaterial({ | |
| map: this.textures.water, | |
| transparent: true, | |
| opacity: 0.7 | |
| }) | |
| }; | |
| } | |
| getKey(x, y, z) { | |
| return `${x},${y},${z}`; | |
| } | |
| parseKey(key) { | |
| const parts = key.split(','); | |
| return { | |
| x: parseInt(parts[0]), | |
| y: parseInt(parts[1]), | |
| z: parseInt(parts[2]) | |
| }; | |
| } | |
| addBlock(x, y, z, type, scene) { | |
| const key = this.getKey(x, y, z); | |
| if (this.blocks.has(key)) return null; | |
| if (type === 'air') return null; | |
| const material = this.materials[type]; | |
| const mesh = new THREE.Mesh(this.blockGeometry, material); | |
| mesh.position.set(x, y, z); | |
| mesh.userData.blockType = type; | |
| mesh.userData.blockKey = key; | |
| scene.add(mesh); | |
| this.blocks.set(key, { mesh, type }); | |
| return mesh; | |
| } | |
| removeBlock(x, y, z, scene) { | |
| const key = this.getKey(x, y, z); | |
| const block = this.blocks.get(key); | |
| if (block) { | |
| scene.remove(block.mesh); | |
| this.blocks.delete(key); | |
| return true; | |
| } | |
| return false; | |
| } | |
| getBlock(x, y, z) { | |
| return this.blocks.get(this.getKey(x, y, z)); | |
| } | |
| hasBlock(x, y, z) { | |
| return this.blocks.has(this.getKey(x, y, z)); | |
| } | |
| getBlockCount() { | |
| return this.blocks.size; | |
| } | |
| } | |
| // ============================================ | |
| // TREE GENERATOR | |
| // ============================================ | |
| class TreeGenerator { | |
| constructor(blockManager, scene) { | |
| this.blockManager = blockManager; | |
| this.scene = scene; | |
| } | |
| generateTree(x, baseY, z) { | |
| const trunkHeight = 4 + Math.floor(Math.random() * 3); | |
| // Generate trunk | |
| for (let y = 0; y < trunkHeight; y++) { | |
| this.blockManager.addBlock(x, baseY + y + 1, z, 'wood', this.scene); | |
| } | |
| // Generate canopy | |
| const canopyBase = baseY + trunkHeight - 1; | |
| const canopyHeight = 3; | |
| for (let dy = 0; dy <= canopyHeight; dy++) { | |
| let radius; | |
| if (dy === 0) radius = 2; | |
| else if (dy === 1) radius = 2; | |
| else if (dy === 2) radius = 1; | |
| else radius = 0; | |
| for (let dx = -radius; dx <= radius; dx++) { | |
| for (let dz = -radius; dz <= radius; dz++) { | |
| // Skip corners for rounder canopy | |
| if (Math.abs(dx) === radius && Math.abs(dz) === radius && radius > 1) { | |
| if (Math.random() > 0.5) continue; | |
| } | |
| // Don't place leaves where trunk is (except top) | |
| if (dx === 0 && dz === 0 && dy < canopyHeight - 1) continue; | |
| const lx = x + dx; | |
| const ly = canopyBase + dy; | |
| const lz = z + dz; | |
| if (!this.blockManager.hasBlock(lx, ly, lz)) { | |
| this.blockManager.addBlock(lx, ly, lz, 'leaves', this.scene); | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| // ============================================ | |
| // WORLD GENERATOR | |
| // ============================================ | |
| class WorldGenerator { | |
| constructor(scene, textures, seed = 12345) { | |
| this.scene = scene; | |
| this.blockManager = new BlockManager(textures); | |
| this.terrainGen = new TerrainGenerator(seed); | |
| this.treeGen = new TreeGenerator(this.blockManager, scene); | |
| this.chunkSize = 16; | |
| this.renderDistance = 4; | |
| this.generatedChunks = new Set(); | |
| } | |
| generateChunk(chunkX, chunkZ) { | |
| const chunkKey = `${chunkX},${chunkZ}`; | |
| if (this.generatedChunks.has(chunkKey)) return; | |
| this.generatedChunks.add(chunkKey); | |
| const startX = chunkX * this.chunkSize; | |
| const startZ = chunkZ * this.chunkSize; | |
| const treePositions = []; | |
| // First pass: generate terrain | |
| for (let x = 0; x < this.chunkSize; x++) { | |
| for (let z = 0; z < this.chunkSize; z++) { | |
| const worldX = startX + x; | |
| const worldZ = startZ + z; | |
| const surfaceHeight = this.terrainGen.getHeight(worldX, worldZ); | |
| // Generate blocks from bedrock to surface | |
| for (let y = 0; y <= surfaceHeight; y++) { | |
| const blockType = this.terrainGen.getBlockType(worldX, y, worldZ, surfaceHeight); | |
| if (blockType !== 'air' && blockType !== 'water') { | |
| this.blockManager.addBlock(worldX, y, worldZ, blockType, this.scene); | |
| } | |
| } | |
| // Check for tree placement | |
| if (this.terrainGen.shouldPlaceTree(worldX, worldZ, surfaceHeight)) { | |
| treePositions.push({ x: worldX, y: surfaceHeight, z: worldZ }); | |
| } | |
| } | |
| } | |
| // Second pass: generate trees | |
| for (const pos of treePositions) { | |
| this.treeGen.generateTree(pos.x, pos.y, pos.z); | |
| } | |
| } | |
| generateAroundPlayer(playerX, playerZ) { | |
| const playerChunkX = Math.floor(playerX / this.chunkSize); | |
| const playerChunkZ = Math.floor(playerZ / this.chunkSize); | |
| for (let dx = -this.renderDistance; dx <= this.renderDistance; dx++) { | |
| for (let dz = -this.renderDistance; dz <= this.renderDistance; dz++) { | |
| this.generateChunk(playerChunkX + dx, playerChunkZ + dz); | |
| } | |
| } | |
| } | |
| getSpawnHeight(x, z) { | |
| return this.terrainGen.getHeight(x, z) + 2; | |
| } | |
| } | |
| // ============================================ | |
| // PLAYER CONTROLLER | |
| // ============================================ | |
| class PlayerController { | |
| constructor(camera, blockManager, canvas) { | |
| this.camera = camera; | |
| this.blockManager = blockManager; | |
| this.canvas = canvas; | |
| // Player state | |
| this.position = new THREE.Vector3(0, 30, 0); | |
| this.velocity = new THREE.Vector3(0, 0, 0); | |
| this.rotation = { x: 0, y: 0 }; | |
| // Movement settings | |
| this.moveSpeed = 5; | |
| this.flySpeed = 10; | |
| this.jumpForce = 8; | |
| this.gravity = 20; | |
| this.friction = 0.9; | |
| // Player dimensions | |
| this.height = 1.7; | |
| this.width = 0.6; | |
| this.eyeHeight = 1.6; | |
| // State flags | |
| this.isFlying = false; | |
| this.isGrounded = false; | |
| this.isCrouching = false; | |
| // Input state | |
| this.keys = {}; | |
| this.mouseLocked = false; | |
| this.setupControls(); | |
| } | |
| setupControls() { | |
| // Keyboard | |
| document.addEventListener('keydown', (e) => { | |
| this.keys[e.code] = true; | |
| if (e.code === 'KeyF') { | |
| this.isFlying = !this.isFlying; | |
| this.velocity.y = 0; | |
| } | |
| }); | |
| document.addEventListener('keyup', (e) => { | |
| this.keys[e.code] = false; | |
| }); | |
| // Mouse movement | |
| document.addEventListener('mousemove', (e) => { | |
| if (!this.mouseLocked) return; | |
| const sensitivity = 0.002; | |
| this.rotation.y -= e.movementX * sensitivity; | |
| this.rotation.x -= e.movementY * sensitivity; | |
| // Clamp vertical rotation | |
| this.rotation.x = Math.max(-Math.PI / 2 + 0.01, Math.min(Math.PI / 2 - 0.01, this.rotation.x)); | |
| }); | |
| // Pointer lock | |
| this.canvas.addEventListener('click', () => { | |
| if (!this.mouseLocked) { | |
| this.canvas.requestPointerLock(); | |
| } | |
| }); | |
| document.addEventListener('pointerlockchange', () => { | |
| this.mouseLocked = document.pointerLockElement === this.canvas; | |
| }); | |
| } | |
| checkCollision(x, y, z) { | |
| const margin = this.width / 2; | |
| const positions = [ | |
| [x - margin, y, z - margin], | |
| [x + margin, y, z - margin], | |
| [x - margin, y, z + margin], | |
| [x + margin, y, z + margin], | |
| [x - margin, y + this.height / 2, z - margin], | |
| [x + margin, y + this.height / 2, z - margin], | |
| [x - margin, y + this.height / 2, z + margin], | |
| [x + margin, y + this.height / 2, z + margin], | |
| [x - margin, y + this.height, z - margin], | |
| [x + margin, y + this.height, z - margin], | |
| [x - margin, y + this.height, z + margin], | |
| [x + margin, y + this.height, z + margin] | |
| ]; | |
| for (const [px, py, pz] of positions) { | |
| const block = this.blockManager.getBlock( | |
| Math.floor(px), | |
| Math.floor(py), | |
| Math.floor(pz) | |
| ); | |
| if (block && block.type !== 'water') { | |
| return true; | |
| } | |
| } | |
| return false; | |
| } | |
| update(deltaTime) { | |
| // Get movement direction | |
| const forward = new THREE.Vector3(0, 0, -1); | |
| const right = new THREE.Vector3(1, 0, 0); | |
| forward.applyAxisAngle(new THREE.Vector3(0, 1, 0), this.rotation.y); | |
| right.applyAxisAngle(new THREE.Vector3(0, 1, 0), this.rotation.y); | |
| if (!this.isFlying) { | |
| forward.y = 0; | |
| right.y = 0; | |
| forward.normalize(); | |
| right.normalize(); | |
| } | |
| // Calculate movement | |
| const moveDir = new THREE.Vector3(0, 0, 0); | |
| const speed = this.isFlying ? this.flySpeed : this.moveSpeed; | |
| if (this.keys['KeyW']) moveDir.add(forward); | |
| if (this.keys['KeyS']) moveDir.sub(forward); | |
| if (this.keys['KeyA']) moveDir.sub(right); | |
| if (this.keys['KeyD']) moveDir.add(right); | |
| if (moveDir.length() > 0) { | |
| moveDir.normalize().multiplyScalar(speed); | |
| } | |
| // Apply movement | |
| this.velocity.x = moveDir.x; | |
| this.velocity.z = moveDir.z; | |
| // Vertical movement | |
| if (this.isFlying) { | |
| this.velocity.y = 0; | |
| if (this.keys['Space']) this.velocity.y = this.flySpeed; | |
| if (this.keys['ShiftLeft'] || this.keys['ShiftRight']) this.velocity.y = -this.flySpeed; | |
| } else { | |
| // Gravity | |
| this.velocity.y -= this.gravity * deltaTime; | |
| // Jump | |
| if (this.keys['Space'] && this.isGrounded) { | |
| this.velocity.y = this.jumpForce; | |
| this.isGrounded = false; | |
| } | |
| // Crouch | |
| this.isCrouching = this.keys['ShiftLeft'] || this.keys['ShiftRight']; | |
| } | |
| // Apply velocity with collision | |
| const newX = this.position.x + this.velocity.x * deltaTime; | |
| const newY = this.position.y + this.velocity.y * deltaTime; | |
| const newZ = this.position.z + this.velocity.z * deltaTime; | |
| // X collision | |
| if (!this.checkCollision(newX, this.position.y, this.position.z)) { | |
| this.position.x = newX; | |
| } else { | |
| this.velocity.x = 0; | |
| } | |
| // Z collision | |
| if (!this.checkCollision(this.position.x, this.position.y, newZ)) { | |
| this.position.z = newZ; | |
| } else { | |
| this.velocity.z = 0; | |
| } | |
| // Y collision | |
| if (!this.checkCollision(this.position.x, newY, this.position.z)) { | |
| this.position.y = newY; | |
| this.isGrounded = false; | |
| } else { | |
| if (this.velocity.y < 0) { | |
| this.isGrounded = true; | |
| } | |
| this.velocity.y = 0; | |
| } | |
| // Prevent falling through world | |
| if (this.position.y < -10) { | |
| this.position.y = 50; | |
| this.velocity.y = 0; | |
| } | |
| // Update camera | |
| this.camera.position.copy(this.position); | |
| this.camera.position.y += this.eyeHeight; | |
| this.camera.rotation.order = 'YXZ'; | |
| this.camera.rotation.x = this.rotation.x; | |
| this.camera.rotation.y = this.rotation.y; | |
| } | |
| } | |
| // ============================================ | |
| // BLOCK INTERACTION | |
| // ============================================ | |
| class BlockInteraction { | |
| constructor(camera, blockManager, scene) { | |
| this.camera = camera; | |
| this.blockManager = blockManager; | |
| this.scene = scene; | |
| this.raycaster = new THREE.Raycaster(); | |
| this.maxDistance = 5; | |
| this.selectedBlockType = 'grass'; | |
| this.blockTypes = ['grass', 'dirt', 'stone', 'wood', 'leaves']; | |
| this.selectedIndex = 0; | |
| this.setupControls(); | |
| } | |
| setupControls() { | |
| document.addEventListener('mousedown', (e) => { | |
| if (document.pointerLockElement) { | |
| if (e.button === 0) this.breakBlock(); | |
| if (e.button === 2) this.placeBlock(); | |
| } | |
| }); | |
| document.addEventListener('contextmenu', (e) => e.preventDefault()); | |
| // Number keys for block selection | |
| document.addEventListener('keydown', (e) => { | |
| const num = parseInt(e.key); | |
| if (num >= 1 && num <= 5) { | |
| this.selectedIndex = num - 1; | |
| this.selectedBlockType = this.blockTypes[this.selectedIndex]; | |
| this.updateHotbar(); | |
| } | |
| }); | |
| // Scroll wheel for block selection | |
| document.addEventListener('wheel', (e) => { | |
| if (document.pointerLockElement) { | |
| if (e.deltaY > 0) { | |
| this.selectedIndex = (this.selectedIndex + 1) % this.blockTypes.length; | |
| } else { | |
| this.selectedIndex = (this.selectedIndex - 1 + this.blockTypes.length) % this.blockTypes.length; | |
| } | |
| this.selectedBlockType = this.blockTypes[this.selectedIndex]; | |
| this.updateHotbar(); | |
| } | |
| }); | |
| } | |
| updateHotbar() { | |
| const slots = document.querySelectorAll('.hotbar-slot'); | |
| slots.forEach((slot, i) => { | |
| slot.classList.toggle('selected', i === this.selectedIndex); | |
| }); | |
| } | |
| getTargetBlock() { | |
| this.raycaster.setFromCamera(new THREE.Vector2(0, 0), this.camera); | |
| const meshes = []; | |
| this.blockManager.blocks.forEach(block => { | |
| if (block.type !== 'water') { | |
| meshes.push(block.mesh); | |
| } | |
| }); | |
| const intersects = this.raycaster.intersectObjects(meshes); | |
| if (intersects.length > 0 && intersects[0].distance <= this.maxDistance) { | |
| return intersects[0]; | |
| } | |
| return null; | |
| } | |
| breakBlock() { | |
| const target = this.getTargetBlock(); | |
| if (target) { | |
| const pos = target.object.position; | |
| this.blockManager.removeBlock(pos.x, pos.y, pos.z, this.scene); | |
| } | |
| } | |
| placeBlock() { | |
| const target = this.getTargetBlock(); | |
| if (target) { | |
| const normal = target.face.normal; | |
| const pos = target.object.position; | |
| const newX = pos.x + normal.x; | |
| const newY = pos.y + normal.y; | |
| const newZ = pos.z + normal.z; | |
| // Don't place block inside player | |
| const playerPos = this.camera.position; | |
| const dx = Math.abs(newX - playerPos.x); | |
| const dy = Math.abs(newY - playerPos.y + 0.9); | |
| const dz = Math.abs(newZ - playerPos.z); | |
| if (dx < 0.8 && dy < 1.8 && dz < 0.8) return; | |
| this.blockManager.addBlock(newX, newY, newZ, this.selectedBlockType, this.scene); | |
| } | |
| } | |
| } | |
| // ============================================ | |
| // ATMOSPHERE SYSTEM | |
| // ============================================ | |
| class AtmosphereSystem { | |
| constructor(scene) { | |
| this.scene = scene; | |
| this.dayDuration = 300; // seconds for full day cycle | |
| this.timeOfDay = 0.25; // Start at noon (0 = midnight, 0.5 = noon) | |
| this.sunLight = null; | |
| this.ambientLight = null; | |
| this.setupLighting(); | |
| this.setupSkybox(); | |
| } | |
| setupLighting() { | |
| // Ambient light | |
| this.ambientLight = new THREE.AmbientLight(0xffffff, 0.4); | |
| this.scene.add(this.ambientLight); | |
| // Sun light | |
| this.sunLight = new THREE.DirectionalLight(0xffffff, 1); | |
| this.sunLight.position.set(50, 100, 50); | |
| this.sunLight.castShadow = false; | |
| this.scene.add(this.sunLight); | |
| // Hemisphere light for better ambient | |
| const hemiLight = new THREE.HemisphereLight(0x87ceeb, 0x8b7355, 0.3); | |
| this.scene.add(hemiLight); | |
| } | |
| setupSkybox() { | |
| // Create gradient skybox | |
| const vertexShader = ` | |
| varying vec3 vWorldPosition; | |
| void main() { | |
| vec4 worldPosition = modelMatrix * vec4(position, 1.0); | |
| vWorldPosition = worldPosition.xyz; | |
| gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); | |
| } | |
| `; | |
| const fragmentShader = ` | |
| uniform vec3 topColor; | |
| uniform vec3 bottomColor; | |
| uniform float offset; | |
| uniform float exponent; | |
| varying vec3 vWorldPosition; | |
| void main() { | |
| float h = normalize(vWorldPosition + offset).y; | |
| gl_FragColor = vec4(mix(bottomColor, topColor, max(pow(max(h, 0.0), exponent), 0.0)), 1.0); | |
| } | |
| `; | |
| this.skyUniforms = { | |
| topColor: { value: new THREE.Color(0x0077ff) }, | |
| bottomColor: { value: new THREE.Color(0xaaddff) }, | |
| offset: { value: 33 }, | |
| exponent: { value: 0.6 } | |
| }; | |
| const skyGeo = new THREE.SphereGeometry(500, 32, 15); | |
| const skyMat = new THREE.ShaderMaterial({ | |
| vertexShader: vertexShader, | |
| fragmentShader: fragmentShader, | |
| uniforms: this.skyUniforms, | |
| side: THREE.BackSide | |
| }); | |
| const sky = new THREE.Mesh(skyGeo, skyMat); | |
| this.scene.add(sky); | |
| // Fog for depth | |
| this.scene.fog = new THREE.Fog(0xaaddff, 50, 150); | |
| } | |
| update(deltaTime) { | |
| // Update time | |
| this.timeOfDay += deltaTime / this.dayDuration; | |
| if (this.timeOfDay >= 1) this.timeOfDay -= 1; | |
| // Calculate sun position | |
| const sunAngle = this.timeOfDay * Math.PI * 2 - Math.PI / 2; | |
| const sunHeight = Math.sin(sunAngle); | |
| const sunDistance = 100; | |
| this.sunLight.position.set( | |
| Math.cos(sunAngle) * sunDistance, | |
| sunHeight * sunDistance, | |
| sunDistance * 0.5 | |
| ); | |
| // Day/night colors | |
| const isDaytime = sunHeight > -0.1; | |
| const dayProgress = Math.max(0, Math.min(1, (sunHeight + 0.1) / 1.1)); | |
| // Sky colors | |
| const dayTopColor = new THREE.Color(0x0077ff); | |
| const dayBottomColor = new THREE.Color(0xaaddff); | |
| const nightTopColor = new THREE.Color(0x000022); | |
| const nightBottomColor = new THREE.Color(0x001133); | |
| const sunsetTopColor = new THREE.Color(0xff6600); | |
| const sunsetBottomColor = new THREE.Color(0xff9933); | |
| let topColor, bottomColor; | |
| if (dayProgress > 0.3) { | |
| // Day | |
| topColor = dayTopColor; | |
| bottomColor = dayBottomColor; | |
| } else if (dayProgress > 0.1) { | |
| // Sunset/sunrise | |
| const t = (dayProgress - 0.1) / 0.2; | |
| topColor = sunsetTopColor.clone().lerp(dayTopColor, t); | |
| bottomColor = sunsetBottomColor.clone().lerp(dayBottomColor, t); | |
| } else { | |
| // Night to sunset | |
| const t = dayProgress / 0.1; | |
| topColor = nightTopColor.clone().lerp(sunsetTopColor, t); | |
| bottomColor = nightBottomColor.clone().lerp(sunsetBottomColor, t); | |
| } | |
| this.skyUniforms.topColor.value.copy(topColor); | |
| this.skyUniforms.bottomColor.value.copy(bottomColor); | |
| // Lighting intensity | |
| const lightIntensity = Math.max(0.1, dayProgress); | |
| this.sunLight.intensity = lightIntensity; | |
| this.ambientLight.intensity = 0.2 + lightIntensity * 0.4; | |
| // Sun color | |
| if (dayProgress < 0.3) { | |
| this.sunLight.color.setHex(0xffaa66); | |
| } else { | |
| this.sunLight.color.setHex(0xffffff); | |
| } | |
| // Fog color | |
| this.scene.fog.color.copy(bottomColor); | |
| // Update UI | |
| this.updateTimeDisplay(); | |
| } | |
| updateTimeDisplay() { | |
| const hours = Math.floor(this.timeOfDay * 24); | |
| const minutes = Math.floor((this.timeOfDay * 24 - hours) * 60); | |
| const timeStr = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`; | |
| document.getElementById('time-text').textContent = timeStr; | |
| // Update icon | |
| const icon = document.getElementById('time-icon'); | |
| if (hours >= 6 && hours < 18) { | |
| icon.textContent = '☀️'; | |
| } else { | |
| icon.textContent = '🌙'; | |
| } | |
| } | |
| } | |
| // ============================================ | |
| // MAIN GAME CLASS | |
| // ============================================ | |
| class MinecraftGame { | |
| constructor() { | |
| this.scene = null; | |
| this.camera = null; | |
| this.renderer = null; | |
| this.clock = new THREE.Clock(); | |
| this.textureGen = new TextureGenerator(); | |
| this.worldGen = null; | |
| this.player = null; | |
| this.interaction = null; | |
| this.atmosphere = null; | |
| this.lastFrameTime = performance.now(); | |
| this.frameCount = 0; | |
| this.fps = 0; | |
| } | |
| async init() { | |
| // Setup Three.js | |
| this.setupRenderer(); | |
| this.setupCamera(); | |
| this.setupScene(); | |
| // Generate textures | |
| const textures = this.textureGen.generateAllTextures(); | |
| // Setup world | |
| this.worldGen = new WorldGenerator(this.scene, textures); | |
| // Generate initial world | |
| this.worldGen.generateAroundPlayer(0, 0); | |
| // Setup player | |
| const spawnY = this.worldGen.getSpawnHeight(0, 0); | |
| this.player = new PlayerController( | |
| this.camera, | |
| this.worldGen.blockManager, | |
| this.renderer.domElement | |
| ); | |
| this.player.position.set(0, spawnY, 0); | |
| // Setup interaction | |
| this.interaction = new BlockInteraction( | |
| this.camera, | |
| this.worldGen.blockManager, | |
| this.scene | |
| ); | |
| // Setup atmosphere | |
| this.atmosphere = new AtmosphereSystem(this.scene); | |
| // Setup hotbar | |
| this.setupHotbar(); | |
| // Start game loop | |
| this.animate(); | |
| } | |
| setupRenderer() { | |
| const canvas = document.getElementById('game-canvas'); | |
| this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true }); | |
| this.renderer.setSize(window.innerWidth, window.innerHeight); | |
| this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); | |
| window.addEventListener('resize', () => { | |
| this.camera.aspect = window.innerWidth / window.innerHeight; | |
| this.camera.updateProjectionMatrix(); | |
| this.renderer.setSize(window.innerWidth, window.innerHeight); | |
| }); | |
| } | |
| setupCamera() { | |
| this.camera = new THREE.PerspectiveCamera( | |
| 75, | |
| window.innerWidth / window.innerHeight, | |
| 0.1, | |
| 500 | |
| ); | |
| } | |
| setupScene() { | |
| this.scene = new THREE.Scene(); | |
| } | |
| setupHotbar() { | |
| const hotbar = document.getElementById('hotbar'); | |
| const blockTypes = ['grass', 'dirt', 'stone', 'wood', 'leaves']; | |
| blockTypes.forEach((type, i) => { | |
| const slot = document.createElement('div'); | |
| slot.className = 'hotbar-slot' + (i === 0 ? ' selected' : ''); | |
| const canvas = this.textureGen.getHotbarCanvas(type); | |
| slot.appendChild(canvas); | |
| const num = document.createElement('span'); | |
| num.className = 'slot-number'; | |
| num.textContent = i + 1; | |
| slot.appendChild(num); | |
| hotbar.appendChild(slot); | |
| }); | |
| } | |
| updateStats() { | |
| // FPS calculation | |
| this.frameCount++; | |
| const currentTime = performance.now(); | |
| if (currentTime - this.lastFrameTime >= 1000) { | |
| this.fps = this.frameCount; | |
| this.frameCount = 0; | |
| this.lastFrameTime = currentTime; | |
| document.getElementById('fps').textContent = this.fps; | |
| } | |
| // Position | |
| const pos = this.player.position; | |
| document.getElementById('position').textContent = | |
| `${pos.x.toFixed(1)}, ${pos.y.toFixed(1)}, ${pos.z.toFixed(1)}`; | |
| // Block count | |
| document.getElementById('block-count').textContent = | |
| this.worldGen.blockManager.getBlockCount(); | |
| // Fly mode | |
| document.getElementById('fly-mode').textContent = | |
| this.player.isFlying ? 'Flying' : 'Walking'; | |
| } | |
| animate() { | |
| requestAnimationFrame(() => this.animate()); | |
| const deltaTime = Math.min(this.clock.getDelta(), 0.1); | |
| // Update systems | |
| this.player.update(deltaTime); | |
| this.atmosphere.update(deltaTime); | |
| // Generate world around player | |
| this.worldGen.generateAroundPlayer( | |
| this.player.position.x, | |
| this.player.position.z | |
| ); | |
| // Update stats | |
| this.updateStats(); | |
| // Render | |
| this.renderer.render(this.scene, this.camera); | |
| } | |
| } | |
| // ============================================ | |
| // GAME INITIALIZATION | |
| // ============================================ | |
| const game = new MinecraftGame(); | |
| document.getElementById('start-btn').addEventListener('click', async () => { | |
| document.getElementById('start-screen').style.display = 'none'; | |
| document.getElementById('loading').style.display = 'block'; | |
| // Small delay to show loading | |
| await new Promise(resolve => setTimeout(resolve, 100)); | |
| await game.init(); | |
| document.getElementById('loading').style.display = 'none'; | |
| }); | |
| </script> | |
| </body> | |
| </html> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Minecraft Clone</title> | |
| <style> | |
| * { | |
| margin: 0; | |
| padding: 0; | |
| box-sizing: border-box; | |
| } | |
| body { | |
| overflow: hidden; | |
| font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; | |
| background: #000; | |
| } | |
| #game-canvas { | |
| display: block; | |
| width: 100vw; | |
| height: 100vh; | |
| } | |
| #crosshair { | |
| position: fixed; | |
| top: 50%; | |
| left: 50%; | |
| transform: translate(-50%, -50%); | |
| width: 20px; | |
| height: 20px; | |
| pointer-events: none; | |
| z-index: 100; | |
| } | |
| #crosshair::before, | |
| #crosshair::after { | |
| content: ''; | |
| position: absolute; | |
| background: white; | |
| mix-blend-mode: difference; | |
| } | |
| #crosshair::before { | |
| width: 2px; | |
| height: 20px; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| } | |
| #crosshair::after { | |
| width: 20px; | |
| height: 2px; | |
| top: 50%; | |
| transform: translateY(-50%); | |
| } | |
| #ui-container { | |
| position: fixed; | |
| top: 10px; | |
| left: 10px; | |
| z-index: 100; | |
| color: white; | |
| text-shadow: 2px 2px 4px rgba(0,0,0,0.8); | |
| } | |
| #controls-panel { | |
| background: rgba(0, 0, 0, 0.7); | |
| padding: 15px; | |
| border-radius: 8px; | |
| font-size: 12px; | |
| max-width: 250px; | |
| backdrop-filter: blur(5px); | |
| } | |
| #controls-panel h3 { | |
| margin-bottom: 10px; | |
| color: #7fff7f; | |
| font-size: 14px; | |
| } | |
| #controls-panel p { | |
| margin: 4px 0; | |
| color: #ddd; | |
| } | |
| #controls-panel .key { | |
| display: inline-block; | |
| background: rgba(255,255,255,0.2); | |
| padding: 2px 6px; | |
| border-radius: 3px; | |
| font-family: monospace; | |
| margin-right: 5px; | |
| } | |
| #stats-panel { | |
| position: fixed; | |
| top: 10px; | |
| right: 10px; | |
| background: rgba(0, 0, 0, 0.7); | |
| padding: 10px 15px; | |
| border-radius: 8px; | |
| color: white; | |
| font-size: 12px; | |
| text-shadow: 2px 2px 4px rgba(0,0,0,0.8); | |
| backdrop-filter: blur(5px); | |
| } | |
| #stats-panel div { | |
| margin: 3px 0; | |
| } | |
| #hotbar { | |
| position: fixed; | |
| bottom: 20px; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| display: flex; | |
| gap: 4px; | |
| z-index: 100; | |
| } | |
| .hotbar-slot { | |
| width: 50px; | |
| height: 50px; | |
| background: rgba(0, 0, 0, 0.6); | |
| border: 3px solid rgba(100, 100, 100, 0.8); | |
| border-radius: 4px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| position: relative; | |
| } | |
| .hotbar-slot.selected { | |
| border-color: #fff; | |
| box-shadow: 0 0 10px rgba(255,255,255,0.5); | |
| } | |
| .hotbar-slot canvas { | |
| width: 40px; | |
| height: 40px; | |
| image-rendering: pixelated; | |
| } | |
| .hotbar-slot .slot-number { | |
| position: absolute; | |
| top: 2px; | |
| left: 4px; | |
| font-size: 10px; | |
| color: white; | |
| text-shadow: 1px 1px 2px black; | |
| } | |
| #start-screen { | |
| position: fixed; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%); | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| z-index: 1000; | |
| color: white; | |
| } | |
| #start-screen h1 { | |
| font-size: 48px; | |
| margin-bottom: 20px; | |
| text-shadow: 4px 4px 8px rgba(0,0,0,0.5); | |
| color: #7fff7f; | |
| } | |
| #start-screen p { | |
| font-size: 18px; | |
| margin-bottom: 30px; | |
| color: #ccc; | |
| } | |
| #start-btn { | |
| padding: 15px 40px; | |
| font-size: 20px; | |
| background: linear-gradient(135deg, #4a7c59 0%, #3d6b4f 100%); | |
| border: none; | |
| color: white; | |
| border-radius: 8px; | |
| cursor: pointer; | |
| transition: transform 0.2s, box-shadow 0.2s; | |
| box-shadow: 0 4px 15px rgba(0,0,0,0.3); | |
| } | |
| #start-btn:hover { | |
| transform: scale(1.05); | |
| box-shadow: 0 6px 20px rgba(0,0,0,0.4); | |
| } | |
| #loading { | |
| position: fixed; | |
| top: 50%; | |
| left: 50%; | |
| transform: translate(-50%, -50%); | |
| color: white; | |
| font-size: 24px; | |
| z-index: 999; | |
| display: none; | |
| } | |
| #time-display { | |
| position: fixed; | |
| top: 10px; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| background: rgba(0, 0, 0, 0.5); | |
| padding: 5px 15px; | |
| border-radius: 15px; | |
| color: white; | |
| font-size: 14px; | |
| z-index: 100; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="start-screen"> | |
| <h1>Minecraft Clone</h1> | |
| <p>A 3D voxel world built with Three.js</p> | |
| <button id="start-btn">Click to Play</button> | |
| </div> | |
| <div id="loading">Generating World...</div> | |
| <canvas id="game-canvas"></canvas> | |
| <div id="crosshair"></div> | |
| <div id="ui-container"> | |
| <div id="controls-panel"> | |
| <h3>Controls</h3> | |
| <p><span class="key">W A S D</span> Move</p> | |
| <p><span class="key">Mouse</span> Look around</p> | |
| <p><span class="key">Space</span> Jump / Fly up</p> | |
| <p><span class="key">Shift</span> Crouch / Fly down</p> | |
| <p><span class="key">F</span> Toggle fly mode</p> | |
| <p><span class="key">Left Click</span> Break block</p> | |
| <p><span class="key">Right Click</span> Place block</p> | |
| <p><span class="key">1-5</span> Select block</p> | |
| <p><span class="key">Scroll</span> Change block</p> | |
| <p><span class="key">ESC</span> Release mouse</p> | |
| </div> | |
| </div> | |
| <div id="stats-panel"> | |
| <div>FPS: <span id="fps">0</span></div> | |
| <div>Position: <span id="position">0, 0, 0</span></div> | |
| <div>Chunks: <span id="chunk-count">0</span></div> | |
| <div>Mode: <span id="fly-mode">Walking</span></div> | |
| </div> | |
| <div id="time-display"> | |
| <span id="time-icon">☀️</span> <span id="time-text">12:00</span> | |
| </div> | |
| <div id="hotbar"></div> | |
| <script type="importmap"> | |
| { | |
| "imports": { | |
| "three": "https://cdn.jsdelivr.net/npm/[email protected]/build/three.module.js", | |
| "three/addons/": "https://cdn.jsdelivr.net/npm/[email protected]/examples/jsm/" | |
| } | |
| } | |
| </script> | |
| <script type="module"> | |
| import * as THREE from 'three'; | |
| // ============================================ | |
| // CONSTANTS | |
| // ============================================ | |
| const CHUNK_SIZE = 16; | |
| const CHUNK_HEIGHT = 64; | |
| const RENDER_DISTANCE = 5; | |
| const WATER_LEVEL = 15; | |
| const BLOCK_TYPES = { | |
| 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']; | |
| // ============================================ | |
| // TEXTURE ATLAS GENERATOR | |
| // ============================================ | |
| class TextureAtlas { | |
| constructor() { | |
| this.tileSize = 16; | |
| this.tilesPerRow = 8; | |
| this.canvas = document.createElement('canvas'); | |
| this.canvas.width = this.tileSize * this.tilesPerRow; | |
| this.canvas.height = this.tileSize * this.tilesPerRow; | |
| this.ctx = this.canvas.getContext('2d'); | |
| this.texture = null; | |
| // UV mapping: [top, bottom, sides] for each block type | |
| // Index in atlas: row * tilesPerRow + col | |
| this.uvMap = {}; | |
| } | |
| seededRandom(seed) { | |
| const x = Math.sin(seed) * 10000; | |
| return x - Math.floor(x); | |
| } | |
| drawTile(col, row, drawFunc) { | |
| const x = col * this.tileSize; | |
| const y = row * this.tileSize; | |
| this.ctx.save(); | |
| this.ctx.translate(x, y); | |
| drawFunc(this.ctx, this.tileSize); | |
| this.ctx.restore(); | |
| return row * this.tilesPerRow + col; | |
| } | |
| generateGrassTop(ctx, size) { | |
| ctx.fillStyle = '#5d9b3a'; | |
| ctx.fillRect(0, 0, size, size); | |
| for (let i = 0; i < 60; i++) { | |
| const x = Math.floor(this.seededRandom(i * 3) * size); | |
| const y = Math.floor(this.seededRandom(i * 5) * size); | |
| const shade = Math.floor(this.seededRandom(i * 7) * 40) - 20; | |
| ctx.fillStyle = `rgb(${93 + shade/2}, ${155 + shade}, ${58 + shade/3})`; | |
| ctx.fillRect(x, y, 1, 1); | |
| } | |
| } | |
| generateGrassSide(ctx, size) { | |
| ctx.fillStyle = '#8b6b4a'; | |
| ctx.fillRect(0, 0, size, size); | |
| for (let i = 0; i < 40; i++) { | |
| const x = Math.floor(this.seededRandom(i * 17) * size); | |
| const y = Math.floor(this.seededRandom(i * 19) * size); | |
| const shade = Math.floor(this.seededRandom(i * 23) * 30) - 15; | |
| ctx.fillStyle = `rgb(${139 + shade}, ${107 + shade}, ${74 + shade})`; | |
| ctx.fillRect(x, y, 2, 2); | |
| } | |
| ctx.fillStyle = '#5d9b3a'; | |
| ctx.fillRect(0, 0, size, 3); | |
| for (let x = 0; x < size; x++) { | |
| const hang = Math.floor(this.seededRandom(x * 29) * 3); | |
| ctx.fillRect(x, 3, 1, hang); | |
| } | |
| } | |
| generateDirt(ctx, size) { | |
| ctx.fillStyle = '#8b6b4a'; | |
| ctx.fillRect(0, 0, size, size); | |
| for (let i = 0; i < 80; i++) { | |
| const x = Math.floor(this.seededRandom(i * 31) * size); | |
| const y = Math.floor(this.seededRandom(i * 37) * size); | |
| const shade = Math.floor(this.seededRandom(i * 41) * 40) - 20; | |
| ctx.fillStyle = `rgb(${139 + shade}, ${107 + shade}, ${74 + shade})`; | |
| ctx.fillRect(x, y, 2, 2); | |
| } | |
| } | |
| generateStone(ctx, size) { | |
| ctx.fillStyle = '#8a8a8a'; | |
| ctx.fillRect(0, 0, size, size); | |
| for (let i = 0; i < 100; i++) { | |
| const x = Math.floor(this.seededRandom(i * 53) * size); | |
| const y = Math.floor(this.seededRandom(i * 59) * size); | |
| const shade = Math.floor(this.seededRandom(i * 61) * 50) - 25; | |
| const gray = Math.min(255, Math.max(0, 138 + shade)); | |
| ctx.fillStyle = `rgb(${gray}, ${gray}, ${gray})`; | |
| ctx.fillRect(x, y, 1, 1); | |
| } | |
| } | |
| generateWood(ctx, size) { | |
| ctx.fillStyle = '#6b5030'; | |
| ctx.fillRect(0, 0, size, size); | |
| for (let y = 0; y < size; y += 2) { | |
| const shade = Math.floor(this.seededRandom(y * 83) * 20) - 10; | |
| ctx.fillStyle = `rgb(${107 + shade}, ${80 + shade}, ${48 + shade})`; | |
| ctx.fillRect(0, y, size, 1); | |
| } | |
| } | |
| generateWoodTop(ctx, size) { | |
| ctx.fillStyle = '#9b7050'; | |
| ctx.fillRect(0, 0, size, size); | |
| const cx = size/2, cy = size/2; | |
| for (let r = 2; r < size/2; r += 2) { | |
| ctx.strokeStyle = r % 4 === 0 ? '#6b5030' : '#8b6040'; | |
| ctx.beginPath(); | |
| ctx.arc(cx, cy, r, 0, Math.PI * 2); | |
| ctx.stroke(); | |
| } | |
| } | |
| generateLeaves(ctx, size) { | |
| ctx.fillStyle = '#3c8c3c'; | |
| ctx.fillRect(0, 0, size, size); | |
| for (let i = 0; i < 40; i++) { | |
| const x = Math.floor(this.seededRandom(i * 101) * size); | |
| const y = Math.floor(this.seededRandom(i * 103) * size); | |
| const shade = Math.floor(this.seededRandom(i * 107) * 40); | |
| ctx.fillStyle = `rgb(${50 + shade}, ${130 + shade/2}, ${50 + shade/2})`; | |
| ctx.fillRect(x, y, 2, 2); | |
| } | |
| } | |
| generateSand(ctx, size) { | |
| ctx.fillStyle = '#e6d5a8'; | |
| ctx.fillRect(0, 0, size, size); | |
| for (let i = 0; i < 100; i++) { | |
| const x = Math.floor(this.seededRandom(i * 127) * size); | |
| const y = Math.floor(this.seededRandom(i * 131) * size); | |
| const shade = Math.floor(this.seededRandom(i * 137) * 30) - 15; | |
| ctx.fillStyle = `rgb(${230 + shade}, ${213 + shade}, ${168 + shade})`; | |
| ctx.fillRect(x, y, 1, 1); | |
| } | |
| } | |
| generateWater(ctx, size) { | |
| ctx.fillStyle = '#3070c0'; | |
| ctx.fillRect(0, 0, size, size); | |
| for (let i = 0; i < 20; i++) { | |
| const x = Math.floor(this.seededRandom(i * 139) * size); | |
| const y = Math.floor(this.seededRandom(i * 149) * size); | |
| ctx.fillStyle = '#5090e0'; | |
| ctx.fillRect(x, y, 3, 1); | |
| } | |
| } | |
| generate() { | |
| // Row 0: Grass top, grass side, dirt, stone | |
| const grassTop = this.drawTile(0, 0, (ctx, s) => this.generateGrassTop(ctx, s)); | |
| const grassSide = this.drawTile(1, 0, (ctx, s) => this.generateGrassSide(ctx, s)); | |
| const dirt = this.drawTile(2, 0, (ctx, s) => this.generateDirt(ctx, s)); | |
| const stone = this.drawTile(3, 0, (ctx, s) => this.generateStone(ctx, s)); | |
| // Row 1: Wood side, wood top, leaves, sand | |
| const woodSide = this.drawTile(0, 1, (ctx, s) => this.generateWood(ctx, s)); | |
| const woodTop = this.drawTile(1, 1, (ctx, s) => this.generateWoodTop(ctx, s)); | |
| const leaves = this.drawTile(2, 1, (ctx, s) => this.generateLeaves(ctx, s)); | |
| const sand = this.drawTile(3, 1, (ctx, s) => this.generateSand(ctx, s)); | |
| // Row 2: Water | |
| const water = this.drawTile(0, 2, (ctx, s) => this.generateWater(ctx, s)); | |
| // UV map: [+X, -X, +Y (top), -Y (bottom), +Z, -Z] | |
| this.uvMap = { | |
| [BLOCK_TYPES.GRASS]: [grassSide, grassSide, grassTop, dirt, grassSide, grassSide], | |
| [BLOCK_TYPES.DIRT]: [dirt, dirt, dirt, dirt, dirt, dirt], | |
| [BLOCK_TYPES.STONE]: [stone, stone, stone, stone, stone, stone], | |
| [BLOCK_TYPES.WOOD]: [woodSide, woodSide, woodTop, woodTop, woodSide, woodSide], | |
| [BLOCK_TYPES.LEAVES]: [leaves, leaves, leaves, leaves, leaves, leaves], | |
| [BLOCK_TYPES.SAND]: [sand, sand, sand, sand, sand, sand], | |
| [BLOCK_TYPES.WATER]: [water, water, water, water, water, water] | |
| }; | |
| this.texture = new THREE.CanvasTexture(this.canvas); | |
| this.texture.magFilter = THREE.NearestFilter; | |
| this.texture.minFilter = THREE.NearestFilter; | |
| this.texture.colorSpace = THREE.SRGBColorSpace; | |
| return this.texture; | |
| } | |
| getUV(tileIndex) { | |
| const col = tileIndex % this.tilesPerRow; | |
| const row = Math.floor(tileIndex / this.tilesPerRow); | |
| const size = 1 / this.tilesPerRow; | |
| return { | |
| u: col * size, | |
| v: 1 - (row + 1) * size, | |
| size: size | |
| }; | |
| } | |
| getHotbarCanvas(blockType) { | |
| const canvas = document.createElement('canvas'); | |
| canvas.width = 16; | |
| canvas.height = 16; | |
| const ctx = canvas.getContext('2d'); | |
| switch(blockType) { | |
| case 'grass': this.generateGrassTop(ctx, 16); break; | |
| case 'dirt': this.generateDirt(ctx, 16); break; | |
| case 'stone': this.generateStone(ctx, 16); break; | |
| case 'wood': this.generateWood(ctx, 16); break; | |
| case 'leaves': this.generateLeaves(ctx, 16); break; | |
| } | |
| return canvas; | |
| } | |
| } | |
| // ============================================ | |
| // SIMPLEX NOISE (Optimized) | |
| // ============================================ | |
| class SimplexNoise { | |
| constructor(seed = Math.random()) { | |
| this.p = new Uint8Array(512); | |
| this.perm = new Uint8Array(512); | |
| const p = new Uint8Array(256); | |
| for (let i = 0; i < 256; i++) p[i] = i; | |
| // Fisher-Yates shuffle with seed | |
| let s = seed * 2147483647; | |
| for (let i = 255; i > 0; i--) { | |
| s = (s * 16807) % 2147483647; | |
| const j = s % (i + 1); | |
| [p[i], p[j]] = [p[j], p[i]]; | |
| } | |
| for (let i = 0; i < 512; i++) { | |
| this.perm[i] = p[i & 255]; | |
| } | |
| this.F2 = 0.5 * (Math.sqrt(3) - 1); | |
| this.G2 = (3 - Math.sqrt(3)) / 6; | |
| this.grad3 = new Float32Array([ | |
| 1,1,0, -1,1,0, 1,-1,0, -1,-1,0, | |
| 1,0,1, -1,0,1, 1,0,-1, -1,0,-1, | |
| 0,1,1, 0,-1,1, 0,1,-1, 0,-1,-1 | |
| ]); | |
| } | |
| noise2D(xin, yin) { | |
| const F2 = this.F2, G2 = this.G2; | |
| const perm = this.perm, grad3 = this.grad3; | |
| const s = (xin + yin) * F2; | |
| const i = Math.floor(xin + s); | |
| const j = Math.floor(yin + s); | |
| const t = (i + j) * G2; | |
| const x0 = xin - i + t; | |
| const y0 = yin - j + t; | |
| const i1 = x0 > y0 ? 1 : 0; | |
| const j1 = x0 > y0 ? 0 : 1; | |
| const x1 = x0 - i1 + G2; | |
| const y1 = y0 - j1 + G2; | |
| const x2 = x0 - 1 + 2 * G2; | |
| const y2 = y0 - 1 + 2 * G2; | |
| const ii = i & 255; | |
| const jj = j & 255; | |
| let n0 = 0, n1 = 0, n2 = 0; | |
| let t0 = 0.5 - x0*x0 - y0*y0; | |
| if (t0 > 0) { | |
| const gi0 = (perm[ii + perm[jj]] % 12) * 3; | |
| t0 *= t0; | |
| n0 = t0 * t0 * (grad3[gi0] * x0 + grad3[gi0+1] * y0); | |
| } | |
| let t1 = 0.5 - x1*x1 - y1*y1; | |
| if (t1 > 0) { | |
| const gi1 = (perm[ii + i1 + perm[jj + j1]] % 12) * 3; | |
| t1 *= t1; | |
| n1 = t1 * t1 * (grad3[gi1] * x1 + grad3[gi1+1] * y1); | |
| } | |
| let t2 = 0.5 - x2*x2 - y2*y2; | |
| if (t2 > 0) { | |
| const gi2 = (perm[ii + 1 + perm[jj + 1]] % 12) * 3; | |
| t2 *= t2; | |
| n2 = t2 * t2 * (grad3[gi2] * x2 + grad3[gi2+1] * y2); | |
| } | |
| return 70 * (n0 + n1 + n2); | |
| } | |
| octaveNoise(x, y, octaves, persistence, lacunarity, scale) { | |
| let total = 0, frequency = 1, amplitude = 1, maxValue = 0; | |
| for (let i = 0; i < octaves; i++) { | |
| total += this.noise2D(x * frequency / scale, y * frequency / scale) * amplitude; | |
| maxValue += amplitude; | |
| amplitude *= persistence; | |
| frequency *= lacunarity; | |
| } | |
| return total / maxValue; | |
| } | |
| } | |
| // ============================================ | |
| // GREEDY MESHING CHUNK | |
| // ============================================ | |
| class Chunk { | |
| constructor(chunkX, chunkZ, noise, treeNoise, textureAtlas) { | |
| this.chunkX = chunkX; | |
| this.chunkZ = chunkZ; | |
| this.worldX = chunkX * CHUNK_SIZE; | |
| this.worldZ = chunkZ * CHUNK_SIZE; | |
| this.noise = noise; | |
| this.treeNoise = treeNoise; | |
| this.textureAtlas = textureAtlas; | |
| // Block data stored in flat array for cache efficiency | |
| this.blocks = new Uint8Array(CHUNK_SIZE * CHUNK_HEIGHT * CHUNK_SIZE); | |
| this.heightMap = new Uint8Array(CHUNK_SIZE * CHUNK_SIZE); | |
| this.mesh = null; | |
| this.waterMesh = null; | |
| this.isDirty = true; | |
| this.isGenerated = false; | |
| } | |
| getBlockIndex(x, y, z) { | |
| return y * CHUNK_SIZE * CHUNK_SIZE + z * CHUNK_SIZE + x; | |
| } | |
| getBlock(x, y, z) { | |
| if (x < 0 || x >= CHUNK_SIZE || y < 0 || y >= CHUNK_HEIGHT || z < 0 || z >= CHUNK_SIZE) { | |
| return BLOCK_TYPES.AIR; | |
| } | |
| return this.blocks[this.getBlockIndex(x, y, z)]; | |
| } | |
| setBlock(x, y, z, type) { | |
| if (x < 0 || x >= CHUNK_SIZE || y < 0 || y >= CHUNK_HEIGHT || z < 0 || z >= CHUNK_SIZE) { | |
| return false; | |
| } | |
| this.blocks[this.getBlockIndex(x, y, z)] = type; | |
| this.isDirty = true; | |
| return true; | |
| } | |
| getHeight(localX, localZ) { | |
| const worldX = this.worldX + localX; | |
| const worldZ = this.worldZ + localZ; | |
| const elevation = this.noise.octaveNoise(worldX, worldZ, 4, 0.5, 2, 80); | |
| const hills = this.noise.octaveNoise(worldX + 1000, worldZ + 1000, 2, 0.5, 2, 40) * 0.5; | |
| const combined = elevation * 0.7 + hills * 0.3; | |
| return Math.floor(20 + combined * 15); | |
| } | |
| generate() { | |
| if (this.isGenerated) return; | |
| const treePositions = []; | |
| // Generate terrain | |
| for (let x = 0; x < CHUNK_SIZE; x++) { | |
| for (let z = 0; z < CHUNK_SIZE; z++) { | |
| const height = this.getHeight(x, z); | |
| this.heightMap[z * CHUNK_SIZE + x] = height; | |
| for (let y = 0; y <= height; y++) { | |
| let blockType; | |
| if (y === height) { | |
| blockType = height <= WATER_LEVEL ? BLOCK_TYPES.SAND : BLOCK_TYPES.GRASS; | |
| } else if (y >= height - 3) { | |
| blockType = BLOCK_TYPES.DIRT; | |
| } else { | |
| blockType = BLOCK_TYPES.STONE; | |
| } | |
| this.blocks[this.getBlockIndex(x, y, z)] = blockType; | |
| } | |
| // Check for tree | |
| if (height > WATER_LEVEL) { | |
| const worldX = this.worldX + x; | |
| const worldZ = this.worldZ + z; | |
| const treeVal = this.treeNoise.noise2D(worldX * 0.3, worldZ * 0.3); | |
| if (treeVal > 0.7 && x > 2 && x < CHUNK_SIZE - 3 && z > 2 && z < CHUNK_SIZE - 3) { | |
| treePositions.push({ x, y: height, z }); | |
| } | |
| } | |
| } | |
| } | |
| // Generate trees | |
| for (const pos of treePositions) { | |
| this.generateTree(pos.x, pos.y, pos.z); | |
| } | |
| this.isGenerated = true; | |
| this.isDirty = true; | |
| } | |
| generateTree(x, baseY, z) { | |
| const trunkHeight = 4 + Math.floor(Math.random() * 3); | |
| // Trunk | |
| for (let y = 1; y <= trunkHeight; y++) { | |
| if (baseY + y < CHUNK_HEIGHT) { | |
| this.blocks[this.getBlockIndex(x, baseY + y, z)] = BLOCK_TYPES.WOOD; | |
| } | |
| } | |
| // Canopy | |
| const canopyBase = baseY + trunkHeight - 1; | |
| for (let dy = 0; dy <= 3; dy++) { | |
| const radius = dy < 2 ? 2 : (dy === 2 ? 1 : 0); | |
| for (let dx = -radius; dx <= radius; dx++) { | |
| for (let dz = -radius; dz <= radius; dz++) { | |
| if (Math.abs(dx) === radius && Math.abs(dz) === radius && radius > 1) { | |
| if (Math.random() > 0.5) continue; | |
| } | |
| if (dx === 0 && dz === 0 && dy < 2) continue; | |
| const lx = x + dx, ly = canopyBase + dy, lz = z + dz; | |
| if (lx >= 0 && lx < CHUNK_SIZE && ly < CHUNK_HEIGHT && lz >= 0 && lz < CHUNK_SIZE) { | |
| if (this.blocks[this.getBlockIndex(lx, ly, lz)] === BLOCK_TYPES.AIR) { | |
| this.blocks[this.getBlockIndex(lx, ly, lz)] = BLOCK_TYPES.LEAVES; | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| buildMesh(scene, material, waterMaterial) { | |
| if (!this.isDirty) return; | |
| // Remove old meshes | |
| if (this.mesh) { | |
| scene.remove(this.mesh); | |
| this.mesh.geometry.dispose(); | |
| } | |
| if (this.waterMesh) { | |
| scene.remove(this.waterMesh); | |
| this.waterMesh.geometry.dispose(); | |
| } | |
| const positions = []; | |
| const normals = []; | |
| const uvs = []; | |
| const indices = []; | |
| const waterPositions = []; | |
| const waterNormals = []; | |
| const waterUvs = []; | |
| const waterIndices = []; | |
| let vertexCount = 0; | |
| let waterVertexCount = 0; | |
| // Face data: [dx, dy, dz, nx, ny, nz, vertices...] | |
| const faces = [ | |
| { dir: [1, 0, 0], corners: [[1,0,0], [1,1,0], [1,1,1], [1,0,1]] }, // +X | |
| { dir: [-1, 0, 0], corners: [[0,0,1], [0,1,1], [0,1,0], [0,0,0]] }, // -X | |
| { dir: [0, 1, 0], corners: [[0,1,1], [1,1,1], [1,1,0], [0,1,0]] }, // +Y | |
| { dir: [0, -1, 0], corners: [[0,0,0], [1,0,0], [1,0,1], [0,0,1]] }, // -Y | |
| { dir: [0, 0, 1], corners: [[0,0,1], [1,0,1], [1,1,1], [0,1,1]] }, // +Z | |
| { dir: [0, 0, -1], corners: [[1,0,0], [0,0,0], [0,1,0], [1,1,0]] } // -Z | |
| ]; | |
| for (let y = 0; y < CHUNK_HEIGHT; y++) { | |
| for (let z = 0; z < CHUNK_SIZE; z++) { | |
| for (let x = 0; x < CHUNK_SIZE; x++) { | |
| const block = this.getBlock(x, y, z); | |
| if (block === BLOCK_TYPES.AIR) continue; | |
| const isWater = block === BLOCK_TYPES.WATER; | |
| const targetPositions = isWater ? waterPositions : positions; | |
| const targetNormals = isWater ? waterNormals : normals; | |
| const targetUvs = isWater ? waterUvs : uvs; | |
| const targetIndices = isWater ? waterIndices : indices; | |
| let targetVertexCount = isWater ? waterVertexCount : vertexCount; | |
| for (let faceIdx = 0; faceIdx < 6; faceIdx++) { | |
| const face = faces[faceIdx]; | |
| const nx = x + face.dir[0]; | |
| const ny = y + face.dir[1]; | |
| const nz = z + face.dir[2]; | |
| const neighbor = this.getBlock(nx, ny, nz); | |
| // Skip face if neighbor is solid (or same type for water) | |
| if (neighbor !== BLOCK_TYPES.AIR) { | |
| if (isWater && neighbor === BLOCK_TYPES.WATER) continue; | |
| if (!isWater && neighbor !== BLOCK_TYPES.WATER) continue; | |
| } | |
| // Get UV for this face | |
| const tileIndex = this.textureAtlas.uvMap[block][faceIdx]; | |
| const uv = this.textureAtlas.getUV(tileIndex); | |
| // Add vertices | |
| for (const corner of face.corners) { | |
| targetPositions.push( | |
| this.worldX + x + corner[0], | |
| y + corner[1], | |
| this.worldZ + z + corner[2] | |
| ); | |
| targetNormals.push(face.dir[0], face.dir[1], face.dir[2]); | |
| } | |
| // Add UVs | |
| targetUvs.push( | |
| uv.u, uv.v + uv.size, | |
| uv.u + uv.size, uv.v + uv.size, | |
| uv.u + uv.size, uv.v, | |
| uv.u, uv.v | |
| ); | |
| // Add indices | |
| targetIndices.push( | |
| targetVertexCount, targetVertexCount + 1, targetVertexCount + 2, | |
| targetVertexCount, targetVertexCount + 2, targetVertexCount + 3 | |
| ); | |
| targetVertexCount += 4; | |
| if (isWater) { | |
| waterVertexCount = targetVertexCount; | |
| } else { | |
| vertexCount = targetVertexCount; | |
| } | |
| } | |
| } | |
| } | |
| } | |
| // Create solid mesh | |
| if (positions.length > 0) { | |
| const geometry = new THREE.BufferGeometry(); | |
| geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)); | |
| geometry.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3)); | |
| geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2)); | |
| geometry.setIndex(indices); | |
| this.mesh = new THREE.Mesh(geometry, material); | |
| this.mesh.frustumCulled = true; | |
| scene.add(this.mesh); | |
| } | |
| // Create water mesh | |
| if (waterPositions.length > 0) { | |
| const waterGeometry = new THREE.BufferGeometry(); | |
| waterGeometry.setAttribute('position', new THREE.Float32BufferAttribute(waterPositions, 3)); | |
| waterGeometry.setAttribute('normal', new THREE.Float32BufferAttribute(waterNormals, 3)); | |
| waterGeometry.setAttribute('uv', new THREE.Float32BufferAttribute(waterUvs, 2)); | |
| waterGeometry.setIndex(waterIndices); | |
| this.waterMesh = new THREE.Mesh(waterGeometry, waterMaterial); | |
| this.waterMesh.frustumCulled = true; | |
| scene.add(this.waterMesh); | |
| } | |
| this.isDirty = false; | |
| } | |
| dispose(scene) { | |
| if (this.mesh) { | |
| scene.remove(this.mesh); | |
| this.mesh.geometry.dispose(); | |
| this.mesh = null; | |
| } | |
| if (this.waterMesh) { | |
| scene.remove(this.waterMesh); | |
| this.waterMesh.geometry.dispose(); | |
| this.waterMesh = null; | |
| } | |
| } | |
| } | |
| // ============================================ | |
| // WORLD MANAGER | |
| // ============================================ | |
| class World { | |
| constructor(scene, textureAtlas, seed = 12345) { | |
| this.scene = scene; | |
| this.textureAtlas = textureAtlas; | |
| this.noise = new SimplexNoise(seed); | |
| this.treeNoise = new SimplexNoise(seed + 1); | |
| this.chunks = new Map(); | |
| // Create materials | |
| this.material = new THREE.MeshLambertMaterial({ | |
| map: textureAtlas.texture, | |
| side: THREE.FrontSide | |
| }); | |
| this.waterMaterial = new THREE.MeshLambertMaterial({ | |
| map: textureAtlas.texture, | |
| transparent: true, | |
| opacity: 0.7, | |
| side: THREE.DoubleSide | |
| }); | |
| this.lastPlayerChunkX = null; | |
| this.lastPlayerChunkZ = null; | |
| } | |
| getChunkKey(cx, cz) { | |
| return `${cx},${cz}`; | |
| } | |
| getChunk(cx, cz) { | |
| return this.chunks.get(this.getChunkKey(cx, cz)); | |
| } | |
| getOrCreateChunk(cx, cz) { | |
| const key = this.getChunkKey(cx, cz); | |
| let chunk = this.chunks.get(key); | |
| if (!chunk) { | |
| chunk = new Chunk(cx, cz, this.noise, this.treeNoise, this.textureAtlas); | |
| this.chunks.set(key, chunk); | |
| } | |
| return chunk; | |
| } | |
| getBlock(x, y, z) { | |
| const cx = Math.floor(x / CHUNK_SIZE); | |
| const cz = Math.floor(z / CHUNK_SIZE); | |
| const chunk = this.getChunk(cx, cz); | |
| if (!chunk || !chunk.isGenerated) return BLOCK_TYPES.AIR; | |
| const localX = ((x % CHUNK_SIZE) + CHUNK_SIZE) % CHUNK_SIZE; | |
| const localZ = ((z % CHUNK_SIZE) + CHUNK_SIZE) % CHUNK_SIZE; | |
| return chunk.getBlock(localX, y, localZ); | |
| } | |
| setBlock(x, y, z, type) { | |
| const cx = Math.floor(x / CHUNK_SIZE); | |
| const cz = Math.floor(z / CHUNK_SIZE); | |
| const chunk = this.getChunk(cx, cz); | |
| if (!chunk) return false; | |
| const localX = ((x % CHUNK_SIZE) + CHUNK_SIZE) % CHUNK_SIZE; | |
| const localZ = ((z % CHUNK_SIZE) + CHUNK_SIZE) % CHUNK_SIZE; | |
| const result = chunk.setBlock(localX, y, localZ, type); | |
| // Rebuild mesh | |
| if (result) { | |
| chunk.buildMesh(this.scene, this.material, this.waterMaterial); | |
| // Check if we need to update neighboring chunks | |
| if (localX === 0) this.rebuildChunk(cx - 1, cz); | |
| if (localX === CHUNK_SIZE - 1) this.rebuildChunk(cx + 1, cz); | |
| if (localZ === 0) this.rebuildChunk(cx, cz - 1); | |
| if (localZ === CHUNK_SIZE - 1) this.rebuildChunk(cx, cz + 1); | |
| } | |
| return result; | |
| } | |
| rebuildChunk(cx, cz) { | |
| const chunk = this.getChunk(cx, cz); | |
| if (chunk && chunk.isGenerated) { | |
| chunk.isDirty = true; | |
| chunk.buildMesh(this.scene, this.material, this.waterMaterial); | |
| } | |
| } | |
| // Generate all chunks around a position immediately (for initial load) | |
| generateInitialChunks(playerX, playerZ) { | |
| const playerChunkX = Math.floor(playerX / CHUNK_SIZE); | |
| const playerChunkZ = Math.floor(playerZ / CHUNK_SIZE); | |
| this.lastPlayerChunkX = playerChunkX; | |
| this.lastPlayerChunkZ = playerChunkZ; | |
| // Generate ALL chunks around spawn synchronously | |
| for (let dx = -RENDER_DISTANCE; dx <= RENDER_DISTANCE; dx++) { | |
| for (let dz = -RENDER_DISTANCE; dz <= RENDER_DISTANCE; dz++) { | |
| const cx = playerChunkX + dx; | |
| const cz = playerChunkZ + dz; | |
| const chunk = this.getOrCreateChunk(cx, cz); | |
| chunk.generate(); | |
| chunk.buildMesh(this.scene, this.material, this.waterMaterial); | |
| } | |
| } | |
| } | |
| update(playerX, playerZ) { | |
| const playerChunkX = Math.floor(playerX / CHUNK_SIZE); | |
| const playerChunkZ = Math.floor(playerZ / CHUNK_SIZE); | |
| // Only update if player moved to new chunk | |
| if (playerChunkX === this.lastPlayerChunkX && playerChunkZ === this.lastPlayerChunkZ) { | |
| return; | |
| } | |
| this.lastPlayerChunkX = playerChunkX; | |
| this.lastPlayerChunkZ = playerChunkZ; | |
| // Generate chunks around player | |
| const chunksToGenerate = []; | |
| for (let dx = -RENDER_DISTANCE; dx <= RENDER_DISTANCE; dx++) { | |
| for (let dz = -RENDER_DISTANCE; dz <= RENDER_DISTANCE; dz++) { | |
| const cx = playerChunkX + dx; | |
| const cz = playerChunkZ + dz; | |
| const chunk = this.getOrCreateChunk(cx, cz); | |
| if (!chunk.isGenerated) { | |
| chunksToGenerate.push(chunk); | |
| } else if (chunk.isDirty) { | |
| chunk.buildMesh(this.scene, this.material, this.waterMaterial); | |
| } | |
| } | |
| } | |
| // Generate new chunks (limit per frame for smoothness) | |
| const maxChunksPerFrame = 4; | |
| for (let i = 0; i < Math.min(chunksToGenerate.length, maxChunksPerFrame); i++) { | |
| const chunk = chunksToGenerate[i]; | |
| chunk.generate(); | |
| chunk.buildMesh(this.scene, this.material, this.waterMaterial); | |
| } | |
| // Unload far chunks | |
| const unloadDistance = RENDER_DISTANCE + 2; | |
| for (const [key, chunk] of this.chunks) { | |
| const dx = Math.abs(chunk.chunkX - playerChunkX); | |
| const dz = Math.abs(chunk.chunkZ - playerChunkZ); | |
| if (dx > unloadDistance || dz > unloadDistance) { | |
| chunk.dispose(this.scene); | |
| this.chunks.delete(key); | |
| } | |
| } | |
| } | |
| findSpawnPoint(startX, startZ) { | |
| // Search in a spiral pattern for a clear spawn point | |
| const searchRadius = 15; | |
| for (let r = 0; r <= searchRadius; r++) { | |
| for (let dx = -r; dx <= r; dx++) { | |
| for (let dz = -r; dz <= r; dz++) { | |
| if (r > 0 && Math.abs(dx) !== r && Math.abs(dz) !== r) continue; // Only check perimeter | |
| const x = startX + dx; | |
| const z = startZ + dz; | |
| // Find the actual top of all blocks at this position (including trees) | |
| const baseY = this.getTerrainHeight(x, z); | |
| let topY = baseY; | |
| // Scan upward to find the highest non-air block | |
| for (let y = baseY; y < baseY + 20; y++) { | |
| const block = this.getBlock(x, y, z); | |
| if (block !== BLOCK_TYPES.AIR && block !== BLOCK_TYPES.WATER) { | |
| topY = y; | |
| } | |
| } | |
| // Check if there's clear space above (2 blocks for player height) | |
| const blockAtFeet = this.getBlock(x, topY + 1, z); | |
| const blockAtHead = this.getBlock(x, topY + 2, z); | |
| const blockAtTop = this.getBlock(x, topY + 3, z); | |
| // Make sure we're not spawning on water and have 3 clear blocks above | |
| const groundBlock = this.getBlock(x, topY, z); | |
| const isValidGround = groundBlock === BLOCK_TYPES.GRASS || | |
| groundBlock === BLOCK_TYPES.DIRT || | |
| groundBlock === BLOCK_TYPES.STONE || | |
| groundBlock === BLOCK_TYPES.SAND; | |
| if (isValidGround && | |
| blockAtFeet === BLOCK_TYPES.AIR && | |
| blockAtHead === BLOCK_TYPES.AIR && | |
| blockAtTop === BLOCK_TYPES.AIR) { | |
| return { x, y: topY + 1, z }; | |
| } | |
| } | |
| } | |
| } | |
| // Fallback: return position high above any obstacles | |
| const y = this.getTerrainHeight(startX, startZ); | |
| return { x: startX, y: y + 10, z: startZ }; | |
| } | |
| getTerrainHeight(x, z) { | |
| const cx = Math.floor(x / CHUNK_SIZE); | |
| const cz = Math.floor(z / CHUNK_SIZE); | |
| const chunk = this.getOrCreateChunk(cx, cz); | |
| if (!chunk.isGenerated) { | |
| chunk.generate(); | |
| chunk.buildMesh(this.scene, this.material, this.waterMaterial); | |
| } | |
| const localX = ((x % CHUNK_SIZE) + CHUNK_SIZE) % CHUNK_SIZE; | |
| const localZ = ((z % CHUNK_SIZE) + CHUNK_SIZE) % CHUNK_SIZE; | |
| return chunk.heightMap[localZ * CHUNK_SIZE + localX]; | |
| } | |
| getChunkCount() { | |
| return this.chunks.size; | |
| } | |
| } | |
| // ============================================ | |
| // PLAYER CONTROLLER | |
| // ============================================ | |
| class Player { | |
| constructor(camera, world, canvas) { | |
| this.camera = camera; | |
| this.world = world; | |
| this.canvas = canvas; | |
| this.position = new THREE.Vector3(0, 30, 0); | |
| this.velocity = new THREE.Vector3(); | |
| this.rotation = { x: 0, y: 0 }; | |
| this.moveSpeed = 5; | |
| this.flySpeed = 15; | |
| this.jumpForce = 8; | |
| this.gravity = 25; | |
| this.height = 1.7; | |
| this.width = 0.6; | |
| this.eyeHeight = 1.6; | |
| this.isFlying = false; | |
| this.isGrounded = false; | |
| this.keys = {}; | |
| this.mouseLocked = false; | |
| this.setupControls(); | |
| } | |
| setupControls() { | |
| document.addEventListener('keydown', (e) => { | |
| this.keys[e.code] = true; | |
| if (e.code === 'KeyF') { | |
| this.isFlying = !this.isFlying; | |
| this.velocity.y = 0; | |
| } | |
| }); | |
| document.addEventListener('keyup', (e) => { | |
| this.keys[e.code] = false; | |
| }); | |
| document.addEventListener('mousemove', (e) => { | |
| if (!this.mouseLocked) return; | |
| const sensitivity = 0.002; | |
| this.rotation.y -= e.movementX * sensitivity; | |
| this.rotation.x -= e.movementY * sensitivity; | |
| this.rotation.x = Math.max(-Math.PI/2 + 0.01, Math.min(Math.PI/2 - 0.01, this.rotation.x)); | |
| }); | |
| this.canvas.addEventListener('click', () => { | |
| if (!this.mouseLocked) { | |
| this.canvas.requestPointerLock(); | |
| } | |
| }); | |
| document.addEventListener('pointerlockchange', () => { | |
| this.mouseLocked = document.pointerLockElement === this.canvas; | |
| }); | |
| } | |
| checkCollision(x, y, z) { | |
| const margin = this.width / 2 - 0.01; // Slight inset to prevent edge sticking | |
| // Check at feet, mid-body, and head levels | |
| const yLevels = [y + 0.01, y + 0.9, y + this.height - 0.01]; | |
| for (const py of yLevels) { | |
| // Check all four corners at this height | |
| const corners = [ | |
| [x - margin, py, z - margin], | |
| [x + margin, py, z - margin], | |
| [x - margin, py, z + margin], | |
| [x + margin, py, z + margin] | |
| ]; | |
| for (const [px, checkY, pz] of corners) { | |
| const block = this.world.getBlock(Math.floor(px), Math.floor(checkY), Math.floor(pz)); | |
| if (block !== BLOCK_TYPES.AIR && block !== BLOCK_TYPES.WATER) { | |
| return true; | |
| } | |
| } | |
| } | |
| return false; | |
| } | |
| update(deltaTime) { | |
| const forward = new THREE.Vector3(0, 0, -1); | |
| const right = new THREE.Vector3(1, 0, 0); | |
| forward.applyAxisAngle(new THREE.Vector3(0, 1, 0), this.rotation.y); | |
| right.applyAxisAngle(new THREE.Vector3(0, 1, 0), this.rotation.y); | |
| if (!this.isFlying) { | |
| forward.y = 0; | |
| right.y = 0; | |
| forward.normalize(); | |
| right.normalize(); | |
| } | |
| const moveDir = new THREE.Vector3(); | |
| const speed = this.isFlying ? this.flySpeed : this.moveSpeed; | |
| if (this.keys['KeyW']) moveDir.add(forward); | |
| if (this.keys['KeyS']) moveDir.sub(forward); | |
| if (this.keys['KeyA']) moveDir.sub(right); | |
| if (this.keys['KeyD']) moveDir.add(right); | |
| if (moveDir.length() > 0) { | |
| moveDir.normalize().multiplyScalar(speed); | |
| } | |
| this.velocity.x = moveDir.x; | |
| this.velocity.z = moveDir.z; | |
| if (this.isFlying) { | |
| this.velocity.y = 0; | |
| if (this.keys['Space']) this.velocity.y = this.flySpeed; | |
| if (this.keys['ShiftLeft'] || this.keys['ShiftRight']) this.velocity.y = -this.flySpeed; | |
| } else { | |
| this.velocity.y -= this.gravity * deltaTime; | |
| if (this.keys['Space'] && this.isGrounded) { | |
| this.velocity.y = this.jumpForce; | |
| this.isGrounded = false; | |
| } | |
| } | |
| // Apply velocity with collision | |
| const newX = this.position.x + this.velocity.x * deltaTime; | |
| const newY = this.position.y + this.velocity.y * deltaTime; | |
| const newZ = this.position.z + this.velocity.z * deltaTime; | |
| if (!this.checkCollision(newX, this.position.y, this.position.z)) { | |
| this.position.x = newX; | |
| } | |
| if (!this.checkCollision(this.position.x, this.position.y, newZ)) { | |
| this.position.z = newZ; | |
| } | |
| if (!this.checkCollision(this.position.x, newY, this.position.z)) { | |
| this.position.y = newY; | |
| this.isGrounded = false; | |
| } else { | |
| if (this.velocity.y < 0) this.isGrounded = true; | |
| this.velocity.y = 0; | |
| } | |
| if (this.position.y < -10) { | |
| this.position.y = 50; | |
| this.velocity.y = 0; | |
| } | |
| this.camera.position.copy(this.position); | |
| this.camera.position.y += this.eyeHeight; | |
| this.camera.rotation.order = 'YXZ'; | |
| this.camera.rotation.x = this.rotation.x; | |
| this.camera.rotation.y = this.rotation.y; | |
| } | |
| } | |
| // ============================================ | |
| // BLOCK INTERACTION | |
| // ============================================ | |
| class BlockInteraction { | |
| constructor(camera, world) { | |
| this.camera = camera; | |
| this.world = world; | |
| this.raycaster = new THREE.Raycaster(); | |
| this.raycaster.far = 6; | |
| this.selectedBlockType = BLOCK_TYPES.GRASS; | |
| this.blockTypes = [BLOCK_TYPES.GRASS, BLOCK_TYPES.DIRT, BLOCK_TYPES.STONE, BLOCK_TYPES.WOOD, BLOCK_TYPES.LEAVES]; | |
| this.selectedIndex = 0; | |
| this.setupControls(); | |
| } | |
| setupControls() { | |
| document.addEventListener('mousedown', (e) => { | |
| if (document.pointerLockElement) { | |
| if (e.button === 0) this.breakBlock(); | |
| if (e.button === 2) this.placeBlock(); | |
| } | |
| }); | |
| document.addEventListener('contextmenu', (e) => e.preventDefault()); | |
| document.addEventListener('keydown', (e) => { | |
| const num = parseInt(e.key); | |
| if (num >= 1 && num <= 5) { | |
| this.selectedIndex = num - 1; | |
| this.selectedBlockType = this.blockTypes[this.selectedIndex]; | |
| this.updateHotbar(); | |
| } | |
| }); | |
| document.addEventListener('wheel', (e) => { | |
| if (document.pointerLockElement) { | |
| if (e.deltaY > 0) { | |
| this.selectedIndex = (this.selectedIndex + 1) % this.blockTypes.length; | |
| } else { | |
| this.selectedIndex = (this.selectedIndex - 1 + this.blockTypes.length) % this.blockTypes.length; | |
| } | |
| this.selectedBlockType = this.blockTypes[this.selectedIndex]; | |
| this.updateHotbar(); | |
| } | |
| }); | |
| } | |
| updateHotbar() { | |
| const slots = document.querySelectorAll('.hotbar-slot'); | |
| slots.forEach((slot, i) => { | |
| slot.classList.toggle('selected', i === this.selectedIndex); | |
| }); | |
| } | |
| raycast() { | |
| this.raycaster.setFromCamera(new THREE.Vector2(0, 0), this.camera); | |
| const origin = this.raycaster.ray.origin; | |
| const direction = this.raycaster.ray.direction; | |
| // Simple voxel raycast | |
| 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 = Math.abs(1 / direction.x); | |
| const tDeltaY = Math.abs(1 / direction.y); | |
| const tDeltaZ = Math.abs(1 / direction.z); | |
| let tMaxX = direction.x !== 0 ? (stepX > 0 ? (x + 1 - origin.x) : (origin.x - x)) * tDeltaX : Infinity; | |
| let tMaxY = direction.y !== 0 ? (stepY > 0 ? (y + 1 - origin.y) : (origin.y - y)) * tDeltaY : Infinity; | |
| let tMaxZ = direction.z !== 0 ? (stepZ > 0 ? (z + 1 - origin.z) : (origin.z - z)) * tDeltaZ : Infinity; | |
| let lastX = x, lastY = y, lastZ = z; | |
| for (let i = 0; i < 60; i++) { | |
| const block = this.world.getBlock(x, y, z); | |
| if (block !== BLOCK_TYPES.AIR && block !== BLOCK_TYPES.WATER) { | |
| return { | |
| hit: { x, y, z }, | |
| normal: { x: lastX - x, y: lastY - y, z: lastZ - z }, | |
| blockType: block | |
| }; | |
| } | |
| lastX = x; lastY = y; lastZ = z; | |
| if (tMaxX < tMaxY) { | |
| if (tMaxX < tMaxZ) { | |
| x += stepX; | |
| tMaxX += tDeltaX; | |
| } else { | |
| z += stepZ; | |
| tMaxZ += tDeltaZ; | |
| } | |
| } else { | |
| if (tMaxY < tMaxZ) { | |
| y += stepY; | |
| tMaxY += tDeltaY; | |
| } else { | |
| z += stepZ; | |
| tMaxZ += tDeltaZ; | |
| } | |
| } | |
| // Check distance | |
| const dx = x - origin.x, dy = y - origin.y, dz = z - origin.z; | |
| if (dx*dx + dy*dy + dz*dz > 36) break; | |
| } | |
| return null; | |
| } | |
| breakBlock() { | |
| const result = this.raycast(); | |
| if (result) { | |
| this.world.setBlock(result.hit.x, result.hit.y, result.hit.z, BLOCK_TYPES.AIR); | |
| } | |
| } | |
| placeBlock() { | |
| const result = this.raycast(); | |
| if (result) { | |
| const newX = result.hit.x + result.normal.x; | |
| const newY = result.hit.y + result.normal.y; | |
| const newZ = result.hit.z + result.normal.z; | |
| // Check player collision | |
| const playerPos = this.camera.position; | |
| const dx = Math.abs(newX + 0.5 - playerPos.x); | |
| const dy = Math.abs(newY + 0.5 - playerPos.y + 0.9); | |
| const dz = Math.abs(newZ + 0.5 - playerPos.z); | |
| if (dx < 0.8 && dy < 1.8 && dz < 0.8) return; | |
| this.world.setBlock(newX, newY, newZ, this.selectedBlockType); | |
| } | |
| } | |
| } | |
| // ============================================ | |
| // ATMOSPHERE | |
| // ============================================ | |
| class Atmosphere { | |
| constructor(scene) { | |
| this.scene = scene; | |
| this.dayDuration = 300; | |
| this.timeOfDay = 0.25; | |
| this.setupLighting(); | |
| this.setupSky(); | |
| } | |
| setupLighting() { | |
| this.ambientLight = new THREE.AmbientLight(0xffffff, 0.4); | |
| this.scene.add(this.ambientLight); | |
| this.sunLight = new THREE.DirectionalLight(0xffffff, 1); | |
| this.sunLight.position.set(50, 100, 50); | |
| this.scene.add(this.sunLight); | |
| const hemiLight = new THREE.HemisphereLight(0x87ceeb, 0x8b7355, 0.3); | |
| this.scene.add(hemiLight); | |
| } | |
| setupSky() { | |
| const vertexShader = ` | |
| varying vec3 vWorldPosition; | |
| void main() { | |
| vec4 worldPosition = modelMatrix * vec4(position, 1.0); | |
| vWorldPosition = worldPosition.xyz; | |
| gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); | |
| } | |
| `; | |
| const fragmentShader = ` | |
| uniform vec3 topColor; | |
| uniform vec3 bottomColor; | |
| uniform float offset; | |
| uniform float exponent; | |
| varying vec3 vWorldPosition; | |
| void main() { | |
| float h = normalize(vWorldPosition + offset).y; | |
| gl_FragColor = vec4(mix(bottomColor, topColor, max(pow(max(h, 0.0), exponent), 0.0)), 1.0); | |
| } | |
| `; | |
| this.skyUniforms = { | |
| topColor: { value: new THREE.Color(0x0077ff) }, | |
| bottomColor: { value: new THREE.Color(0xaaddff) }, | |
| offset: { value: 33 }, | |
| exponent: { value: 0.6 } | |
| }; | |
| const skyGeo = new THREE.SphereGeometry(400, 32, 15); | |
| const skyMat = new THREE.ShaderMaterial({ | |
| vertexShader, | |
| fragmentShader, | |
| uniforms: this.skyUniforms, | |
| side: THREE.BackSide | |
| }); | |
| this.scene.add(new THREE.Mesh(skyGeo, skyMat)); | |
| this.scene.fog = new THREE.Fog(0xaaddff, 60, 180); | |
| } | |
| update(deltaTime) { | |
| this.timeOfDay += deltaTime / this.dayDuration; | |
| if (this.timeOfDay >= 1) this.timeOfDay -= 1; | |
| const sunAngle = this.timeOfDay * Math.PI * 2 - Math.PI / 2; | |
| const sunHeight = Math.sin(sunAngle); | |
| this.sunLight.position.set( | |
| Math.cos(sunAngle) * 100, | |
| sunHeight * 100, | |
| 50 | |
| ); | |
| const dayProgress = Math.max(0, Math.min(1, (sunHeight + 0.1) / 1.1)); | |
| const dayTop = new THREE.Color(0x0077ff); | |
| const dayBottom = new THREE.Color(0xaaddff); | |
| const nightTop = new THREE.Color(0x000022); | |
| const nightBottom = new THREE.Color(0x001133); | |
| const sunsetTop = new THREE.Color(0xff6600); | |
| const sunsetBottom = new THREE.Color(0xff9933); | |
| let topColor, bottomColor; | |
| if (dayProgress > 0.3) { | |
| topColor = dayTop; | |
| bottomColor = dayBottom; | |
| } else if (dayProgress > 0.1) { | |
| const t = (dayProgress - 0.1) / 0.2; | |
| topColor = sunsetTop.clone().lerp(dayTop, t); | |
| bottomColor = sunsetBottom.clone().lerp(dayBottom, t); | |
| } else { | |
| const t = dayProgress / 0.1; | |
| topColor = nightTop.clone().lerp(sunsetTop, t); | |
| bottomColor = nightBottom.clone().lerp(sunsetBottom, t); | |
| } | |
| this.skyUniforms.topColor.value.copy(topColor); | |
| this.skyUniforms.bottomColor.value.copy(bottomColor); | |
| this.sunLight.intensity = Math.max(0.1, dayProgress); | |
| this.ambientLight.intensity = 0.2 + dayProgress * 0.4; | |
| this.sunLight.color.setHex(dayProgress < 0.3 ? 0xffaa66 : 0xffffff); | |
| this.scene.fog.color.copy(bottomColor); | |
| // Update UI | |
| const hours = Math.floor(this.timeOfDay * 24); | |
| const minutes = Math.floor((this.timeOfDay * 24 - hours) * 60); | |
| document.getElementById('time-text').textContent = | |
| `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`; | |
| document.getElementById('time-icon').textContent = (hours >= 6 && hours < 18) ? '☀️' : '🌙'; | |
| } | |
| } | |
| // ============================================ | |
| // MAIN GAME | |
| // ============================================ | |
| class Game { | |
| constructor() { | |
| this.scene = null; | |
| this.camera = null; | |
| this.renderer = null; | |
| this.clock = new THREE.Clock(); | |
| this.textureAtlas = new TextureAtlas(); | |
| this.world = null; | |
| this.player = null; | |
| this.interaction = null; | |
| this.atmosphere = null; | |
| this.frameCount = 0; | |
| this.lastFpsUpdate = 0; | |
| this.fps = 0; | |
| } | |
| init() { | |
| // Renderer | |
| const canvas = document.getElementById('game-canvas'); | |
| this.renderer = new THREE.WebGLRenderer({ canvas, antialias: false }); | |
| this.renderer.setSize(window.innerWidth, window.innerHeight); | |
| this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); | |
| // Camera | |
| this.camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 500); | |
| // Scene | |
| this.scene = new THREE.Scene(); | |
| // Generate texture atlas | |
| this.textureAtlas.generate(); | |
| // World | |
| this.world = new World(this.scene, this.textureAtlas); | |
| // Spawn position (center of world) | |
| const spawnX = 8; | |
| const spawnZ = 8; | |
| // Generate ALL initial chunks synchronously before player spawns | |
| this.world.generateInitialChunks(spawnX, spawnZ); | |
| // Find a clear spawn point (not inside trees) | |
| const spawn = this.world.findSpawnPoint(spawnX, spawnZ); | |
| // Player | |
| this.player = new Player(this.camera, this.world, this.renderer.domElement); | |
| this.player.position.set(spawn.x, spawn.y, spawn.z); | |
| // Interaction | |
| this.interaction = new BlockInteraction(this.camera, this.world); | |
| // Atmosphere | |
| this.atmosphere = new Atmosphere(this.scene); | |
| // Hotbar | |
| this.setupHotbar(); | |
| // Resize handler | |
| window.addEventListener('resize', () => { | |
| this.camera.aspect = window.innerWidth / window.innerHeight; | |
| this.camera.updateProjectionMatrix(); | |
| this.renderer.setSize(window.innerWidth, window.innerHeight); | |
| }); | |
| // Start loop | |
| this.animate(); | |
| } | |
| setupHotbar() { | |
| const hotbar = document.getElementById('hotbar'); | |
| const blockNames = ['grass', 'dirt', 'stone', 'wood', 'leaves']; | |
| blockNames.forEach((name, i) => { | |
| const slot = document.createElement('div'); | |
| slot.className = 'hotbar-slot' + (i === 0 ? ' selected' : ''); | |
| const canvas = this.textureAtlas.getHotbarCanvas(name); | |
| slot.appendChild(canvas); | |
| const num = document.createElement('span'); | |
| num.className = 'slot-number'; | |
| num.textContent = i + 1; | |
| slot.appendChild(num); | |
| hotbar.appendChild(slot); | |
| }); | |
| } | |
| updateStats() { | |
| this.frameCount++; | |
| const now = performance.now(); | |
| if (now - this.lastFpsUpdate >= 1000) { | |
| this.fps = this.frameCount; | |
| this.frameCount = 0; | |
| this.lastFpsUpdate = now; | |
| document.getElementById('fps').textContent = this.fps; | |
| } | |
| const pos = this.player.position; | |
| document.getElementById('position').textContent = | |
| `${pos.x.toFixed(1)}, ${pos.y.toFixed(1)}, ${pos.z.toFixed(1)}`; | |
| document.getElementById('chunk-count').textContent = this.world.getChunkCount(); | |
| document.getElementById('fly-mode').textContent = this.player.isFlying ? 'Flying' : 'Walking'; | |
| } | |
| animate() { | |
| requestAnimationFrame(() => this.animate()); | |
| const deltaTime = Math.min(this.clock.getDelta(), 0.1); | |
| this.player.update(deltaTime); | |
| this.world.update(this.player.position.x, this.player.position.z); | |
| this.atmosphere.update(deltaTime); | |
| this.updateStats(); | |
| this.renderer.render(this.scene, this.camera); | |
| } | |
| } | |
| // ============================================ | |
| // INIT | |
| // ============================================ | |
| const game = new Game(); | |
| document.getElementById('start-btn').addEventListener('click', async () => { | |
| document.getElementById('start-screen').style.display = 'none'; | |
| document.getElementById('loading').style.display = 'block'; | |
| await new Promise(resolve => setTimeout(resolve, 50)); | |
| game.init(); | |
| document.getElementById('loading').style.display = 'none'; | |
| }); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment