1373 lines
45 KiB
HTML
1373 lines
45 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="zh">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<title>MyCraft - Minecraft Clone</title>
|
|
<style>
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
body { overflow: hidden; background: #000; font-family: 'Courier New', monospace; }
|
|
canvas { display: block; }
|
|
#blocker {
|
|
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
|
|
background: rgba(0,0,0,0.7); display: flex; align-items: center;
|
|
justify-content: center; z-index: 100; flex-direction: column;
|
|
}
|
|
#blocker h1 { color: #4a4; font-size: 48px; text-shadow: 3px 3px #000; margin-bottom: 20px; }
|
|
#blocker p { color: #ccc; font-size: 16px; line-height: 1.8; text-align: center; }
|
|
#blocker .key { color: #ff0; }
|
|
#crosshair {
|
|
position: fixed; top: 50%; left: 50%; transform: translate(-50%,-50%);
|
|
width: 24px; height: 24px; z-index: 50; pointer-events: none; display: none;
|
|
}
|
|
#crosshair::before, #crosshair::after {
|
|
content: ''; position: absolute; background: rgba(255,255,255,0.8);
|
|
}
|
|
#crosshair::before { width: 2px; height: 24px; left: 11px; top: 0; }
|
|
#crosshair::after { width: 24px; height: 2px; top: 11px; left: 0; }
|
|
#hotbar {
|
|
position: fixed; bottom: 10px; left: 50%; transform: translateX(-50%);
|
|
display: none; z-index: 50; background: rgba(0,0,0,0.6);
|
|
border-radius: 4px; padding: 4px; gap: 2px;
|
|
}
|
|
#hotbar.show { display: flex; }
|
|
.hotbar-slot {
|
|
width: 50px; height: 50px; border: 2px solid #555;
|
|
display: flex; align-items: center; justify-content: center;
|
|
position: relative;
|
|
}
|
|
.hotbar-slot.active { border-color: #fff; }
|
|
.hotbar-slot canvas { width: 36px; height: 36px; image-rendering: pixelated; }
|
|
.hotbar-slot .slot-num {
|
|
position: absolute; top: 2px; left: 4px; color: #aaa; font-size: 10px;
|
|
}
|
|
#debug {
|
|
position: fixed; top: 10px; left: 10px; color: #fff; font-size: 12px;
|
|
z-index: 50; display: none; text-shadow: 1px 1px #000; line-height: 1.6;
|
|
}
|
|
#debug.show { display: block; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="blocker">
|
|
<h1>⛏ MyCraft</h1>
|
|
<p>
|
|
<span class="key">点击屏幕</span> 开始游戏<br>
|
|
<span class="key">W A S D</span> 移动 | <span class="key">空格</span> 跳跃 | <span class="key">Shift</span> 下降<br>
|
|
<span class="key">鼠标</span> 环视 | <span class="key">左键</span> 破坏方块 | <span class="key">右键</span> 放置方块<br>
|
|
<span class="key">1-8</span> 或 <span class="key">滚轮</span> 选择方块 | <span class="key">F3</span> 调试信息<br>
|
|
<span class="key">ESC</span> 暂停
|
|
</p>
|
|
</div>
|
|
<div id="crosshair"></div>
|
|
<div id="hotbar"></div>
|
|
<div id="debug"></div>
|
|
|
|
<script type="importmap">
|
|
{
|
|
"imports": {
|
|
"three": "https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js"
|
|
}
|
|
}
|
|
</script>
|
|
<script type="module">
|
|
import * as THREE from 'three';
|
|
|
|
// ==================== PERLIN NOISE ====================
|
|
class PerlinNoise {
|
|
constructor(seed = 42) {
|
|
this.p = new Uint8Array(512);
|
|
const perm = new Uint8Array(256);
|
|
for (let i = 0; i < 256; i++) perm[i] = i;
|
|
let s = seed;
|
|
const rng = () => { s = (s * 16807 + 0) % 2147483647; return (s - 1) / 2147483646; };
|
|
for (let i = 255; i > 0; i--) {
|
|
const j = Math.floor(rng() * (i + 1));
|
|
[perm[i], perm[j]] = [perm[j], perm[i]];
|
|
}
|
|
for (let i = 0; i < 512; i++) this.p[i] = perm[i & 255];
|
|
}
|
|
fade(t) { return t * t * t * (t * (t * 6 - 15) + 10); }
|
|
lerp(a, b, t) { return a + t * (b - a); }
|
|
grad(hash, x, y, z) {
|
|
const h = hash & 15;
|
|
const u = h < 8 ? x : y;
|
|
const v = h < 4 ? y : (h === 12 || h === 14 ? x : z);
|
|
return ((h & 1) === 0 ? u : -u) + ((h & 2) === 0 ? v : -v);
|
|
}
|
|
noise(x, y, z = 0) {
|
|
const X = Math.floor(x) & 255, Y = Math.floor(y) & 255, Z = Math.floor(z) & 255;
|
|
x -= Math.floor(x); y -= Math.floor(y); z -= Math.floor(z);
|
|
const u = this.fade(x), v = this.fade(y), w = this.fade(z);
|
|
const p = this.p;
|
|
const A = p[X] + Y, AA = p[A] + Z, AB = p[A + 1] + Z;
|
|
const B = p[X + 1] + Y, BA = p[B] + Z, BB = p[B + 1] + Z;
|
|
return this.lerp(
|
|
this.lerp(
|
|
this.lerp(this.grad(p[AA], x, y, z), this.grad(p[BA], x - 1, y, z), u),
|
|
this.lerp(this.grad(p[AB], x, y - 1, z), this.grad(p[BB], x - 1, y - 1, z), u), v),
|
|
this.lerp(
|
|
this.lerp(this.grad(p[AA + 1], x, y, z - 1), this.grad(p[BA + 1], x - 1, y, z - 1), u),
|
|
this.lerp(this.grad(p[AB + 1], x, y - 1, z - 1), this.grad(p[BB + 1], x - 1, y - 1, z - 1), u), v), w);
|
|
}
|
|
octave(x, y, octaves = 4, persistence = 0.5, lacunarity = 2) {
|
|
let val = 0, amp = 1, freq = 1, max = 0;
|
|
for (let i = 0; i < octaves; i++) {
|
|
val += this.noise(x * freq, y * freq) * amp;
|
|
max += amp; amp *= persistence; freq *= lacunarity;
|
|
}
|
|
return val / max;
|
|
}
|
|
}
|
|
|
|
// ==================== BLOCK TYPES ====================
|
|
const BLOCK = {
|
|
AIR: 0, GRASS: 1, DIRT: 2, STONE: 3, SAND: 4, WOOD: 5,
|
|
LEAVES: 6, WATER: 7, SNOW: 8, COBBLE: 9, PLANK: 10,
|
|
BRICK: 11, GOLD: 12, CRYSTAL: 13, OBSIDIAN: 14, MOSS: 15,
|
|
MARBLE: 16, LAVA: 17, GLASS: 18, DARK_WOOD: 19
|
|
};
|
|
|
|
const BLOCK_NAMES = {
|
|
[BLOCK.GRASS]: '草方块', [BLOCK.DIRT]: '泥土', [BLOCK.STONE]: '石头',
|
|
[BLOCK.SAND]: '沙子', [BLOCK.WOOD]: '橡木', [BLOCK.COBBLE]: '圆石',
|
|
[BLOCK.PLANK]: '木板', [BLOCK.BRICK]: '砖块',
|
|
};
|
|
|
|
// ==================== TEXTURE GENERATION ====================
|
|
function createBlockTextures() {
|
|
const textures = {};
|
|
const size = 16;
|
|
|
|
function makeCanvas() {
|
|
const c = document.createElement('canvas');
|
|
c.width = size; c.height = size;
|
|
return c;
|
|
}
|
|
|
|
function addNoise(ctx, intensity = 20) {
|
|
const img = ctx.getImageData(0, 0, size, size);
|
|
for (let i = 0; i < img.data.length; i += 4) {
|
|
const n = (Math.random() - 0.5) * intensity;
|
|
img.data[i] += n; img.data[i+1] += n; img.data[i+2] += n;
|
|
}
|
|
ctx.putImageData(img, 0, 0);
|
|
}
|
|
|
|
// Grass top
|
|
const grassTop = makeCanvas();
|
|
let ctx = grassTop.getContext('2d');
|
|
ctx.fillStyle = '#5a8f29';
|
|
ctx.fillRect(0, 0, size, size);
|
|
for (let i = 0; i < 40; i++) {
|
|
ctx.fillStyle = `rgb(${70+Math.random()*30},${130+Math.random()*40},${30+Math.random()*20})`;
|
|
ctx.fillRect(Math.random()*size|0, Math.random()*size|0, 1, 1);
|
|
}
|
|
addNoise(ctx, 10);
|
|
|
|
// Grass side
|
|
const grassSide = makeCanvas();
|
|
ctx = grassSide.getContext('2d');
|
|
ctx.fillStyle = '#8b6914';
|
|
ctx.fillRect(0, 0, size, size);
|
|
ctx.fillStyle = '#5a8f29';
|
|
ctx.fillRect(0, 0, size, 4);
|
|
for (let x = 0; x < size; x++) {
|
|
const h = 3 + (Math.random() * 3 | 0);
|
|
ctx.fillStyle = '#5a8f29';
|
|
ctx.fillRect(x, 0, 1, h);
|
|
}
|
|
addNoise(ctx, 15);
|
|
|
|
// Dirt
|
|
const dirt = makeCanvas();
|
|
ctx = dirt.getContext('2d');
|
|
ctx.fillStyle = '#8b6914';
|
|
ctx.fillRect(0, 0, size, size);
|
|
for (let i = 0; i < 30; i++) {
|
|
ctx.fillStyle = `rgb(${120+Math.random()*30},${85+Math.random()*30},${15+Math.random()*15})`;
|
|
ctx.fillRect(Math.random()*size|0, Math.random()*size|0, 2, 2);
|
|
}
|
|
addNoise(ctx, 15);
|
|
|
|
// Stone
|
|
const stone = makeCanvas();
|
|
ctx = stone.getContext('2d');
|
|
ctx.fillStyle = '#888';
|
|
ctx.fillRect(0, 0, size, size);
|
|
for (let i = 0; i < 20; i++) {
|
|
const g = 100 + Math.random() * 80;
|
|
ctx.fillStyle = `rgb(${g},${g},${g})`;
|
|
ctx.fillRect(Math.random()*size|0, Math.random()*size|0, 2+Math.random()*3|0, 2+Math.random()*3|0);
|
|
}
|
|
ctx.strokeStyle = '#666'; ctx.lineWidth = 0.5;
|
|
for (let i = 0; i < 4; i++) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(Math.random()*size, Math.random()*size);
|
|
ctx.lineTo(Math.random()*size, Math.random()*size);
|
|
ctx.stroke();
|
|
}
|
|
addNoise(ctx, 15);
|
|
|
|
// Sand
|
|
const sand = makeCanvas();
|
|
ctx = sand.getContext('2d');
|
|
ctx.fillStyle = '#e8d68a';
|
|
ctx.fillRect(0, 0, size, size);
|
|
for (let i = 0; i < 50; i++) {
|
|
ctx.fillStyle = `rgb(${220+Math.random()*30},${200+Math.random()*30},${120+Math.random()*30})`;
|
|
ctx.fillRect(Math.random()*size|0, Math.random()*size|0, 1, 1);
|
|
}
|
|
addNoise(ctx, 8);
|
|
|
|
// Wood (oak log side)
|
|
const woodSide = makeCanvas();
|
|
ctx = woodSide.getContext('2d');
|
|
ctx.fillStyle = '#6b4423';
|
|
ctx.fillRect(0, 0, size, size);
|
|
for (let y = 0; y < size; y += 2) {
|
|
ctx.fillStyle = `rgb(${90+Math.random()*20},${55+Math.random()*15},${25+Math.random()*15})`;
|
|
ctx.fillRect(0, y, size, 2);
|
|
}
|
|
addNoise(ctx, 12);
|
|
|
|
// Wood top
|
|
const woodTop = makeCanvas();
|
|
ctx = woodTop.getContext('2d');
|
|
ctx.fillStyle = '#9e7e4e';
|
|
ctx.fillRect(0, 0, size, size);
|
|
ctx.strokeStyle = '#6b4423'; ctx.lineWidth = 1;
|
|
for (let r = 2; r < 8; r += 2) {
|
|
ctx.beginPath(); ctx.arc(8, 8, r, 0, Math.PI * 2); ctx.stroke();
|
|
}
|
|
addNoise(ctx, 10);
|
|
|
|
// Leaves
|
|
const leaves = makeCanvas();
|
|
ctx = leaves.getContext('2d');
|
|
ctx.fillStyle = '#2d6b1e';
|
|
ctx.fillRect(0, 0, size, size);
|
|
for (let i = 0; i < 60; i++) {
|
|
const g = 60 + Math.random() * 80;
|
|
ctx.fillStyle = `rgba(${20+Math.random()*30},${g},${10+Math.random()*20},0.8)`;
|
|
ctx.fillRect(Math.random()*size|0, Math.random()*size|0, 2, 2);
|
|
}
|
|
|
|
// Cobblestone
|
|
const cobble = makeCanvas();
|
|
ctx = cobble.getContext('2d');
|
|
ctx.fillStyle = '#777';
|
|
ctx.fillRect(0, 0, size, size);
|
|
for (let i = 0; i < 12; i++) {
|
|
const g = 80 + Math.random() * 80;
|
|
ctx.fillStyle = `rgb(${g},${g},${g})`;
|
|
const w = 3 + Math.random() * 5 | 0, h = 3 + Math.random() * 5 | 0;
|
|
ctx.fillRect(Math.random()*(size-w)|0, Math.random()*(size-h)|0, w, h);
|
|
}
|
|
ctx.strokeStyle = '#555'; ctx.lineWidth = 0.5;
|
|
for (let i = 0; i < 8; i++) {
|
|
ctx.strokeRect(Math.random()*size|0, Math.random()*size|0, 3+Math.random()*4|0, 3+Math.random()*4|0);
|
|
}
|
|
addNoise(ctx, 15);
|
|
|
|
// Planks
|
|
const plank = makeCanvas();
|
|
ctx = plank.getContext('2d');
|
|
ctx.fillStyle = '#b8945a';
|
|
ctx.fillRect(0, 0, size, size);
|
|
for (let y = 0; y < size; y += 4) {
|
|
ctx.fillStyle = `rgb(${165+Math.random()*20},${130+Math.random()*20},${70+Math.random()*20})`;
|
|
ctx.fillRect(0, y, size, 4);
|
|
ctx.fillStyle = '#8b6914';
|
|
ctx.fillRect(0, y, size, 0.5);
|
|
}
|
|
addNoise(ctx, 10);
|
|
|
|
// Brick
|
|
const brick = makeCanvas();
|
|
ctx = brick.getContext('2d');
|
|
ctx.fillStyle = '#9b4a3c';
|
|
ctx.fillRect(0, 0, size, size);
|
|
ctx.fillStyle = '#bbb';
|
|
for (let y = 0; y < size; y += 4) {
|
|
ctx.fillRect(0, y, size, 1);
|
|
const offset = (y % 8 === 0) ? 0 : 8;
|
|
ctx.fillRect(offset, y, 1, 4);
|
|
ctx.fillRect(offset + 8, y, 1, 4);
|
|
}
|
|
for (let i = 0; i < 20; i++) {
|
|
ctx.fillStyle = `rgb(${140+Math.random()*30},${60+Math.random()*20},${45+Math.random()*20})`;
|
|
ctx.fillRect(Math.random()*size|0, Math.random()*size|0, 1, 1);
|
|
}
|
|
addNoise(ctx, 10);
|
|
|
|
// Gold block
|
|
const gold = makeCanvas();
|
|
ctx = gold.getContext('2d');
|
|
ctx.fillStyle = '#f5c842';
|
|
ctx.fillRect(0, 0, size, size);
|
|
ctx.fillStyle = '#ffd700';
|
|
ctx.fillRect(2, 2, 12, 12);
|
|
ctx.fillStyle = '#e6b800';
|
|
ctx.fillRect(4, 4, 8, 8);
|
|
ctx.fillStyle = '#fff4a3';
|
|
ctx.fillRect(5, 5, 2, 2);
|
|
addNoise(ctx, 8);
|
|
|
|
// Crystal
|
|
const crystal = makeCanvas();
|
|
ctx = crystal.getContext('2d');
|
|
ctx.fillStyle = '#7b4fbf';
|
|
ctx.fillRect(0, 0, size, size);
|
|
for (let i = 0; i < 8; i++) {
|
|
ctx.fillStyle = `rgba(${150+Math.random()*100},${100+Math.random()*80},${200+Math.random()*55},0.6)`;
|
|
ctx.beginPath();
|
|
ctx.moveTo(Math.random()*size, Math.random()*size);
|
|
ctx.lineTo(Math.random()*size, Math.random()*size);
|
|
ctx.lineTo(Math.random()*size, Math.random()*size);
|
|
ctx.fill();
|
|
}
|
|
ctx.fillStyle = 'rgba(255,255,255,0.3)';
|
|
ctx.fillRect(3, 2, 2, 4);
|
|
ctx.fillRect(9, 8, 2, 3);
|
|
|
|
// Obsidian
|
|
const obsidian = makeCanvas();
|
|
ctx = obsidian.getContext('2d');
|
|
ctx.fillStyle = '#1a0a2e';
|
|
ctx.fillRect(0, 0, size, size);
|
|
for (let i = 0; i < 15; i++) {
|
|
ctx.fillStyle = `rgba(${40+Math.random()*30},${10+Math.random()*20},${50+Math.random()*40},0.5)`;
|
|
ctx.fillRect(Math.random()*size|0, Math.random()*size|0, 2+Math.random()*3|0, 2+Math.random()*3|0);
|
|
}
|
|
ctx.fillStyle = 'rgba(100,50,150,0.2)';
|
|
ctx.fillRect(2, 3, 3, 1);
|
|
ctx.fillRect(10, 9, 4, 1);
|
|
addNoise(ctx, 8);
|
|
|
|
// Snow
|
|
const snow = makeCanvas();
|
|
ctx = snow.getContext('2d');
|
|
ctx.fillStyle = '#f0f0f0';
|
|
ctx.fillRect(0, 0, size, size);
|
|
for (let i = 0; i < 30; i++) {
|
|
const v = 230 + Math.random() * 25;
|
|
ctx.fillStyle = `rgb(${v},${v},${v+3})`;
|
|
ctx.fillRect(Math.random()*size|0, Math.random()*size|0, 1, 1);
|
|
}
|
|
|
|
// Moss stone
|
|
const moss = makeCanvas();
|
|
ctx = moss.getContext('2d');
|
|
ctx.fillStyle = '#666';
|
|
ctx.fillRect(0, 0, size, size);
|
|
for (let i = 0; i < 15; i++) {
|
|
ctx.fillStyle = `rgba(${40+Math.random()*40},${80+Math.random()*60},${30+Math.random()*20},0.6)`;
|
|
ctx.fillRect(Math.random()*size|0, Math.random()*size|0, 2+Math.random()*4|0, 2+Math.random()*4|0);
|
|
}
|
|
addNoise(ctx, 12);
|
|
|
|
// Marble
|
|
const marble = makeCanvas();
|
|
ctx = marble.getContext('2d');
|
|
ctx.fillStyle = '#e8e0d8';
|
|
ctx.fillRect(0, 0, size, size);
|
|
ctx.strokeStyle = 'rgba(180,170,160,0.4)'; ctx.lineWidth = 0.5;
|
|
for (let i = 0; i < 6; i++) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(Math.random()*size, Math.random()*size);
|
|
ctx.quadraticCurveTo(Math.random()*size, Math.random()*size, Math.random()*size, Math.random()*size);
|
|
ctx.stroke();
|
|
}
|
|
addNoise(ctx, 6);
|
|
|
|
// Water
|
|
const water = makeCanvas();
|
|
ctx = water.getContext('2d');
|
|
ctx.fillStyle = 'rgba(30,80,180,0.7)';
|
|
ctx.fillRect(0, 0, size, size);
|
|
ctx.fillStyle = 'rgba(60,130,220,0.4)';
|
|
for (let i = 0; i < 5; i++) {
|
|
ctx.fillRect(Math.random()*size|0, Math.random()*size|0, 4+Math.random()*6|0, 1);
|
|
}
|
|
|
|
// Lava
|
|
const lava = makeCanvas();
|
|
ctx = lava.getContext('2d');
|
|
ctx.fillStyle = '#cf4a00';
|
|
ctx.fillRect(0, 0, size, size);
|
|
for (let i = 0; i < 15; i++) {
|
|
ctx.fillStyle = `rgba(${200+Math.random()*55},${50+Math.random()*100},0,0.6)`;
|
|
ctx.fillRect(Math.random()*size|0, Math.random()*size|0, 2+Math.random()*4|0, 2+Math.random()*4|0);
|
|
}
|
|
ctx.fillStyle = 'rgba(255,200,50,0.3)';
|
|
ctx.fillRect(6, 4, 4, 4);
|
|
|
|
// Glass
|
|
const glass = makeCanvas();
|
|
ctx = glass.getContext('2d');
|
|
ctx.fillStyle = 'rgba(200,220,255,0.3)';
|
|
ctx.fillRect(0, 0, size, size);
|
|
ctx.strokeStyle = 'rgba(180,200,240,0.6)'; ctx.lineWidth = 1;
|
|
ctx.strokeRect(0.5, 0.5, size-1, size-1);
|
|
ctx.strokeStyle = 'rgba(255,255,255,0.2)';
|
|
ctx.beginPath(); ctx.moveTo(2, 2); ctx.lineTo(6, 6); ctx.stroke();
|
|
|
|
// Dark wood
|
|
const darkWoodSide = makeCanvas();
|
|
ctx = darkWoodSide.getContext('2d');
|
|
ctx.fillStyle = '#3b2810';
|
|
ctx.fillRect(0, 0, size, size);
|
|
for (let y = 0; y < size; y += 2) {
|
|
ctx.fillStyle = `rgb(${50+Math.random()*15},${30+Math.random()*15},${12+Math.random()*10})`;
|
|
ctx.fillRect(0, y, size, 2);
|
|
}
|
|
addNoise(ctx, 10);
|
|
|
|
function tex(canvas) {
|
|
const t = new THREE.CanvasTexture(canvas);
|
|
t.magFilter = THREE.NearestFilter;
|
|
t.minFilter = THREE.NearestFilter;
|
|
t.colorSpace = THREE.SRGBColorSpace;
|
|
return t;
|
|
}
|
|
|
|
// Build materials: [top, bottom, side] or single
|
|
textures[BLOCK.GRASS] = [tex(grassTop), tex(dirt), tex(grassSide)];
|
|
textures[BLOCK.DIRT] = tex(dirt);
|
|
textures[BLOCK.STONE] = tex(stone);
|
|
textures[BLOCK.SAND] = tex(sand);
|
|
textures[BLOCK.WOOD] = [tex(woodTop), tex(woodTop), tex(woodSide)];
|
|
textures[BLOCK.LEAVES] = tex(leaves);
|
|
textures[BLOCK.WATER] = tex(water);
|
|
textures[BLOCK.SNOW] = tex(snow);
|
|
textures[BLOCK.COBBLE] = tex(cobble);
|
|
textures[BLOCK.PLANK] = tex(plank);
|
|
textures[BLOCK.BRICK] = tex(brick);
|
|
textures[BLOCK.GOLD] = tex(gold);
|
|
textures[BLOCK.CRYSTAL] = tex(crystal);
|
|
textures[BLOCK.OBSIDIAN] = tex(obsidian);
|
|
textures[BLOCK.MOSS] = tex(moss);
|
|
textures[BLOCK.MARBLE] = tex(marble);
|
|
textures[BLOCK.LAVA] = tex(lava);
|
|
textures[BLOCK.GLASS] = tex(glass);
|
|
textures[BLOCK.DARK_WOOD] = [tex(woodTop), tex(woodTop), tex(darkWoodSide)];
|
|
|
|
return textures;
|
|
}
|
|
|
|
function getBlockMaterials(textures, blockType) {
|
|
const t = textures[blockType];
|
|
const transparent = blockType === BLOCK.WATER || blockType === BLOCK.GLASS || blockType === BLOCK.LEAVES;
|
|
if (Array.isArray(t)) {
|
|
const mats = [
|
|
new THREE.MeshLambertMaterial({ map: t[2] }), // right
|
|
new THREE.MeshLambertMaterial({ map: t[2] }), // left
|
|
new THREE.MeshLambertMaterial({ map: t[0] }), // top
|
|
new THREE.MeshLambertMaterial({ map: t[1] }), // bottom
|
|
new THREE.MeshLambertMaterial({ map: t[2] }), // front
|
|
new THREE.MeshLambertMaterial({ map: t[2] }), // back
|
|
];
|
|
if (transparent) mats.forEach(m => { m.transparent = true; m.opacity = blockType === BLOCK.GLASS ? 0.4 : (blockType === BLOCK.WATER ? 0.6 : 0.9); });
|
|
return mats;
|
|
}
|
|
const m = new THREE.MeshLambertMaterial({ map: t });
|
|
if (transparent) { m.transparent = true; m.opacity = blockType === BLOCK.GLASS ? 0.4 : (blockType === BLOCK.WATER ? 0.6 : 0.9); }
|
|
return m;
|
|
}
|
|
|
|
// ==================== WORLD / CHUNK SYSTEM ====================
|
|
const CHUNK_SIZE = 16;
|
|
const WORLD_HEIGHT = 80;
|
|
const RENDER_DIST = 5;
|
|
const SEA_LEVEL = 20;
|
|
|
|
const noise = new PerlinNoise(12345);
|
|
const noise2 = new PerlinNoise(67890);
|
|
const noise3 = new PerlinNoise(11111);
|
|
|
|
const chunks = new Map();
|
|
const chunkMeshes = new Map();
|
|
const blockMeshes = new Map(); // for individual block tracking
|
|
|
|
function chunkKey(cx, cz) { return `${cx},${cz}`; }
|
|
|
|
function getHeight(wx, wz) {
|
|
let h = noise.octave(wx * 0.008, wz * 0.008, 6, 0.5) * 30 + 25;
|
|
// Mountains
|
|
const mountain = Math.max(0, noise2.octave(wx * 0.003, wz * 0.003, 4, 0.6) - 0.2) * 80;
|
|
h += mountain;
|
|
// Valleys
|
|
const valley = noise3.octave(wx * 0.005, wz * 0.005, 3, 0.5);
|
|
if (valley < -0.3) h *= 0.6;
|
|
return Math.floor(h);
|
|
}
|
|
|
|
function getBiome(wx, wz) {
|
|
const temp = noise2.octave(wx * 0.002, wz * 0.002, 3, 0.5);
|
|
const humid = noise3.octave(wx * 0.003 + 100, wz * 0.003 + 100, 3, 0.5);
|
|
if (temp > 0.3) return humid > 0 ? 'jungle' : 'desert';
|
|
if (temp < -0.3) return 'snow';
|
|
if (humid > 0.2) return 'forest';
|
|
return 'plains';
|
|
}
|
|
|
|
// ==================== FANTASY STRUCTURES ====================
|
|
function placeFantasyStructures(chunk, cx, cz, worldX, worldZ) {
|
|
const structureNoise = noise.noise(cx * 0.7, cz * 0.7);
|
|
const structureNoise2 = noise2.noise(cx * 0.5, cz * 0.5);
|
|
|
|
// Only place structures in some chunks
|
|
if (structureNoise < 0.2) return;
|
|
|
|
const baseH = getHeight(worldX + 8, worldZ + 8);
|
|
if (baseH < SEA_LEVEL + 2) return;
|
|
|
|
if (structureNoise > 0.6 && structureNoise2 > 0.3) {
|
|
placeWizardTower(chunk, 6, baseH, 6, worldX, worldZ);
|
|
} else if (structureNoise > 0.45 && structureNoise2 < -0.2) {
|
|
placeAncientTemple(chunk, 4, baseH, 4, worldX, worldZ);
|
|
} else if (structureNoise > 0.3 && structureNoise2 > 0.5) {
|
|
placeGiantTree(chunk, 7, baseH, 7, worldX, worldZ);
|
|
} else if (structureNoise > 0.35 && structureNoise2 < -0.4) {
|
|
placeDragonNest(chunk, 5, baseH, 5, worldX, worldZ);
|
|
} else if (structureNoise > 0.2 && structureNoise2 > 0.1 && structureNoise2 < 0.3) {
|
|
placeRunicCircle(chunk, 6, baseH, 6, worldX, worldZ);
|
|
}
|
|
}
|
|
|
|
function setBlock(chunk, x, y, z, type) {
|
|
if (x < 0 || x >= CHUNK_SIZE || z < 0 || z >= CHUNK_SIZE || y < 0 || y >= WORLD_HEIGHT) return;
|
|
chunk[x + z * CHUNK_SIZE + y * CHUNK_SIZE * CHUNK_SIZE] = type;
|
|
}
|
|
|
|
function placeWizardTower(chunk, bx, by, bz, wx, wz) {
|
|
// Tall crystal tower with spiral
|
|
const height = 20 + (Math.random() * 10 | 0);
|
|
for (let y = 0; y < height; y++) {
|
|
const r = y < 3 ? 3 : (y < height - 3 ? 2 : 3);
|
|
for (let dx = -r; dx <= r; dx++) {
|
|
for (let dz = -r; dz <= r; dz++) {
|
|
if (dx*dx + dz*dz <= r*r) {
|
|
const isWall = dx*dx + dz*dz >= (r-1)*(r-1);
|
|
if (isWall) {
|
|
const block = y % 5 === 0 ? BLOCK.CRYSTAL : (y % 3 === 0 ? BLOCK.OBSIDIAN : BLOCK.COBBLE);
|
|
setBlock(chunk, bx+dx, by+y, bz+dz, block);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Top beacon
|
|
for (let y = height; y < height + 4; y++) {
|
|
setBlock(chunk, bx, by+y, bz, BLOCK.GOLD);
|
|
}
|
|
setBlock(chunk, bx, by+height+4, bz, BLOCK.CRYSTAL);
|
|
// Windows
|
|
for (let y = 5; y < height - 2; y += 4) {
|
|
setBlock(chunk, bx + 2, by + y, bz, BLOCK.GLASS);
|
|
setBlock(chunk, bx - 2, by + y, bz, BLOCK.GLASS);
|
|
setBlock(chunk, bx, by + y, bz + 2, BLOCK.GLASS);
|
|
setBlock(chunk, bx, by + y, bz - 2, BLOCK.GLASS);
|
|
}
|
|
}
|
|
|
|
function placeAncientTemple(chunk, bx, by, bz, wx, wz) {
|
|
// Pyramid-like temple with mossy stones
|
|
const size = 6;
|
|
for (let layer = 0; layer < size; layer++) {
|
|
const s = size - layer;
|
|
for (let dx = -s; dx <= s; dx++) {
|
|
for (let dz = -s; dz <= s; dz++) {
|
|
const isEdge = Math.abs(dx) === s || Math.abs(dz) === s;
|
|
if (isEdge || layer === 0) {
|
|
const block = layer < 2 ? BLOCK.MOSS : (layer % 2 === 0 ? BLOCK.MARBLE : BLOCK.COBBLE);
|
|
setBlock(chunk, bx+dx, by+layer, bz+dz, block);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Inner chamber
|
|
for (let dy = 1; dy <= 3; dy++) {
|
|
for (let dx = -2; dx <= 2; dx++) {
|
|
for (let dz = -2; dz <= 2; dz++) {
|
|
const isWall = Math.abs(dx) === 2 || Math.abs(dz) === 2;
|
|
if (isWall && dy < 3) {
|
|
setBlock(chunk, bx+dx, by+dy, bz+dz, BLOCK.BRICK);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Gold altar in center
|
|
setBlock(chunk, bx, by+1, bz, BLOCK.GOLD);
|
|
setBlock(chunk, bx, by+2, bz, BLOCK.CRYSTAL);
|
|
// Pillars at corners
|
|
for (const [px, pz] of [[-size, -size], [-size, size], [size, -size], [size, size]]) {
|
|
for (let dy = 0; dy < size + 2; dy++) {
|
|
setBlock(chunk, bx+px, by+dy, bz+pz, BLOCK.MARBLE);
|
|
}
|
|
setBlock(chunk, bx+px, by+size+2, bz+pz, BLOCK.GOLD);
|
|
}
|
|
}
|
|
|
|
function placeGiantTree(chunk, bx, by, bz, wx, wz) {
|
|
// Massive fantasy tree
|
|
const trunkH = 15 + (Math.random() * 8 | 0);
|
|
// Trunk
|
|
for (let y = 0; y < trunkH; y++) {
|
|
const r = y < 4 ? 2 : 1;
|
|
for (let dx = -r; dx <= r; dx++) {
|
|
for (let dz = -r; dz <= r; dz++) {
|
|
if (dx*dx + dz*dz <= r*r + 1) {
|
|
setBlock(chunk, bx+dx, by+y, bz+dz, BLOCK.DARK_WOOD || BLOCK.WOOD);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Canopy - multiple layers of leaves
|
|
for (let layer = 0; layer < 5; layer++) {
|
|
const ly = by + trunkH - 2 + layer * 2;
|
|
const r = 6 - layer;
|
|
for (let dx = -r; dx <= r; dx++) {
|
|
for (let dz = -r; dz <= r; dz++) {
|
|
if (dx*dx + dz*dz <= r*r + 2 && Math.random() > 0.15) {
|
|
setBlock(chunk, bx+dx, ly, bz+dz, BLOCK.LEAVES);
|
|
if (Math.random() > 0.5) setBlock(chunk, bx+dx, ly+1, bz+dz, BLOCK.LEAVES);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Glowing fruits
|
|
for (let i = 0; i < 5; i++) {
|
|
const dx = (Math.random() * 6 - 3) | 0;
|
|
const dz = (Math.random() * 6 - 3) | 0;
|
|
const dy = trunkH + (Math.random() * 6) | 0;
|
|
setBlock(chunk, bx+dx, by+dy, bz+dz, BLOCK.GOLD);
|
|
}
|
|
}
|
|
|
|
function placeDragonNest(chunk, bx, by, bz, wx, wz) {
|
|
// Dragon nest - obsidian crater with lava
|
|
const r = 5;
|
|
for (let dx = -r; dx <= r; dx++) {
|
|
for (let dz = -r; dz <= r; dz++) {
|
|
const dist = Math.sqrt(dx*dx + dz*dz);
|
|
if (dist <= r && dist >= r - 2) {
|
|
const h = Math.floor((r - dist) * 2 + 2);
|
|
for (let dy = 0; dy < h; dy++) {
|
|
setBlock(chunk, bx+dx, by+dy, bz+dz, BLOCK.OBSIDIAN);
|
|
}
|
|
}
|
|
if (dist < r - 2) {
|
|
setBlock(chunk, bx+dx, by, bz+dz, BLOCK.LAVA);
|
|
setBlock(chunk, bx+dx, by-1, bz+dz, BLOCK.OBSIDIAN);
|
|
}
|
|
}
|
|
}
|
|
// Dragon egg in center
|
|
for (let dy = 1; dy <= 3; dy++) {
|
|
setBlock(chunk, bx, by+dy, bz, dy === 3 ? BLOCK.CRYSTAL : BLOCK.OBSIDIAN);
|
|
}
|
|
// Bone pillars
|
|
for (const [px, pz] of [[-3, -3], [-3, 3], [3, -3], [3, 3]]) {
|
|
for (let dy = 0; dy < 6; dy++) {
|
|
setBlock(chunk, bx+px, by+dy, bz+pz, BLOCK.SNOW);
|
|
}
|
|
}
|
|
}
|
|
|
|
function placeRunicCircle(chunk, bx, by, bz, wx, wz) {
|
|
// Mysterious runic stone circle
|
|
const r = 5;
|
|
for (let i = 0; i < 8; i++) {
|
|
const angle = (i / 8) * Math.PI * 2;
|
|
const px = Math.round(bx + Math.cos(angle) * r);
|
|
const pz = Math.round(bz + Math.sin(angle) * r);
|
|
const pillarH = 4 + (i % 3);
|
|
for (let dy = 0; dy < pillarH; dy++) {
|
|
setBlock(chunk, px, by+dy, pz, dy === pillarH-1 ? BLOCK.CRYSTAL : BLOCK.MOSS);
|
|
}
|
|
}
|
|
// Center altar
|
|
for (let dx = -1; dx <= 1; dx++) {
|
|
for (let dz = -1; dz <= 1; dz++) {
|
|
setBlock(chunk, bx+dx, by, bz+dz, BLOCK.MARBLE);
|
|
setBlock(chunk, bx+dx, by+1, bz+dz, BLOCK.MARBLE);
|
|
}
|
|
}
|
|
setBlock(chunk, bx, by+2, bz, BLOCK.GOLD);
|
|
setBlock(chunk, bx, by+3, bz, BLOCK.CRYSTAL);
|
|
// Floating crystals (simulated with pillars)
|
|
for (let dy = 5; dy < 8; dy++) {
|
|
setBlock(chunk, bx, by+dy, bz, BLOCK.CRYSTAL);
|
|
}
|
|
}
|
|
|
|
function generateChunk(cx, cz) {
|
|
const key = chunkKey(cx, cz);
|
|
if (chunks.has(key)) return chunks.get(key);
|
|
|
|
const chunk = new Uint8Array(CHUNK_SIZE * CHUNK_SIZE * WORLD_HEIGHT);
|
|
const worldX = cx * CHUNK_SIZE;
|
|
const worldZ = cz * CHUNK_SIZE;
|
|
|
|
for (let x = 0; x < CHUNK_SIZE; x++) {
|
|
for (let z = 0; z < CHUNK_SIZE; z++) {
|
|
const wx = worldX + x;
|
|
const wz = worldZ + z;
|
|
const height = getHeight(wx, wz);
|
|
const biome = getBiome(wx, wz);
|
|
|
|
for (let y = 0; y < WORLD_HEIGHT && y <= height; y++) {
|
|
let blockType;
|
|
if (y === 0) {
|
|
blockType = BLOCK.OBSIDIAN;
|
|
} else if (y < height - 4) {
|
|
blockType = BLOCK.STONE;
|
|
// Ore veins
|
|
if (noise.noise(wx * 0.1, y * 0.1, wz * 0.1) > 0.7) blockType = BLOCK.GOLD;
|
|
} else if (y < height) {
|
|
blockType = biome === 'desert' ? BLOCK.SAND : BLOCK.DIRT;
|
|
} else {
|
|
// Surface
|
|
switch (biome) {
|
|
case 'desert': blockType = BLOCK.SAND; break;
|
|
case 'snow': blockType = BLOCK.SNOW; break;
|
|
case 'jungle': blockType = BLOCK.GRASS; break;
|
|
case 'forest': blockType = BLOCK.GRASS; break;
|
|
default: blockType = BLOCK.GRASS;
|
|
}
|
|
}
|
|
chunk[x + z * CHUNK_SIZE + y * CHUNK_SIZE * CHUNK_SIZE] = blockType;
|
|
}
|
|
|
|
// Water fill
|
|
for (let y = height + 1; y <= SEA_LEVEL; y++) {
|
|
if (chunk[x + z * CHUNK_SIZE + y * CHUNK_SIZE * CHUNK_SIZE] === BLOCK.AIR) {
|
|
chunk[x + z * CHUNK_SIZE + y * CHUNK_SIZE * CHUNK_SIZE] = BLOCK.WATER;
|
|
}
|
|
}
|
|
|
|
// Trees in forest/jungle biomes
|
|
if ((biome === 'forest' || biome === 'jungle') && height > SEA_LEVEL + 1) {
|
|
const treeChance = biome === 'jungle' ? 0.92 : 0.95;
|
|
if (noise.noise(wx * 0.5, wz * 0.5) > treeChance && x > 2 && x < CHUNK_SIZE-2 && z > 2 && z < CHUNK_SIZE-2) {
|
|
const treeH = biome === 'jungle' ? 8 + (Math.random()*4|0) : 5 + (Math.random()*3|0);
|
|
for (let ty = 1; ty <= treeH; ty++) {
|
|
chunk[x + z * CHUNK_SIZE + (height+ty) * CHUNK_SIZE * CHUNK_SIZE] = BLOCK.WOOD;
|
|
}
|
|
// Leaves
|
|
for (let lx = -2; lx <= 2; lx++) {
|
|
for (let lz = -2; lz <= 2; lz++) {
|
|
for (let ly = treeH-2; ly <= treeH+2; ly++) {
|
|
if (lx*lx + lz*lz + (ly-treeH)*(ly-treeH) < 8) {
|
|
const nx = x+lx, nz = z+lz, ny = height+ly;
|
|
if (nx >= 0 && nx < CHUNK_SIZE && nz >= 0 && nz < CHUNK_SIZE && ny < WORLD_HEIGHT) {
|
|
if (chunk[nx + nz * CHUNK_SIZE + ny * CHUNK_SIZE * CHUNK_SIZE] === BLOCK.AIR) {
|
|
chunk[nx + nz * CHUNK_SIZE + ny * CHUNK_SIZE * CHUNK_SIZE] = BLOCK.LEAVES;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Cacti in desert
|
|
if (biome === 'desert' && height > SEA_LEVEL) {
|
|
if (noise.noise(wx * 0.8, wz * 0.8) > 0.93) {
|
|
for (let ty = 1; ty <= 3; ty++) {
|
|
chunk[x + z * CHUNK_SIZE + (height+ty) * CHUNK_SIZE * CHUNK_SIZE] = BLOCK.LEAVES;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Place fantasy structures
|
|
placeFantasyStructures(chunk, cx, cz, worldX, worldZ);
|
|
|
|
chunks.set(key, chunk);
|
|
return chunk;
|
|
}
|
|
|
|
function getBlock(wx, wy, wz) {
|
|
if (wy < 0 || wy >= WORLD_HEIGHT) return BLOCK.AIR;
|
|
const cx = Math.floor(wx / CHUNK_SIZE);
|
|
const cz = Math.floor(wz / CHUNK_SIZE);
|
|
const key = chunkKey(cx, cz);
|
|
const chunk = chunks.get(key);
|
|
if (!chunk) return BLOCK.AIR;
|
|
const lx = ((wx % CHUNK_SIZE) + CHUNK_SIZE) % CHUNK_SIZE;
|
|
const lz = ((wz % CHUNK_SIZE) + CHUNK_SIZE) % CHUNK_SIZE;
|
|
return chunk[lx + lz * CHUNK_SIZE + wy * CHUNK_SIZE * CHUNK_SIZE];
|
|
}
|
|
|
|
function setWorldBlock(wx, wy, wz, type) {
|
|
if (wy < 0 || wy >= WORLD_HEIGHT) return;
|
|
const cx = Math.floor(wx / CHUNK_SIZE);
|
|
const cz = Math.floor(wz / CHUNK_SIZE);
|
|
const key = chunkKey(cx, cz);
|
|
const chunk = chunks.get(key);
|
|
if (!chunk) return;
|
|
const lx = ((wx % CHUNK_SIZE) + CHUNK_SIZE) % CHUNK_SIZE;
|
|
const lz = ((wz % CHUNK_SIZE) + CHUNK_SIZE) % CHUNK_SIZE;
|
|
chunk[lx + lz * CHUNK_SIZE + wy * CHUNK_SIZE * CHUNK_SIZE] = type;
|
|
rebuildChunkMesh(cx, cz);
|
|
// Rebuild adjacent chunks if on border
|
|
if (lx === 0) rebuildChunkMesh(cx-1, cz);
|
|
if (lx === CHUNK_SIZE-1) rebuildChunkMesh(cx+1, cz);
|
|
if (lz === 0) rebuildChunkMesh(cx, cz-1);
|
|
if (lz === CHUNK_SIZE-1) rebuildChunkMesh(cx, cz+1);
|
|
}
|
|
|
|
// ==================== MESH BUILDING ====================
|
|
const faceData = [
|
|
{ dir: [1,0,0], corners: [[1,0,0],[1,1,0],[1,1,1],[1,0,1]], matIdx: 0 }, // right
|
|
{ dir: [-1,0,0], corners: [[0,0,1],[0,1,1],[0,1,0],[0,0,0]], matIdx: 1 }, // left
|
|
{ dir: [0,1,0], corners: [[0,1,1],[1,1,1],[1,1,0],[0,1,0]], matIdx: 2 }, // top
|
|
{ dir: [0,-1,0], corners: [[0,0,0],[1,0,0],[1,0,1],[0,0,1]], matIdx: 3 }, // bottom
|
|
{ dir: [0,0,1], corners: [[0,0,1],[1,0,1],[1,1,1],[0,1,1]], matIdx: 4 }, // front
|
|
{ dir: [0,0,-1], corners: [[1,0,0],[0,0,0],[0,1,0],[1,1,0]], matIdx: 5 }, // back
|
|
];
|
|
|
|
function buildChunkMesh(cx, cz, scene, blockTextures) {
|
|
const key = chunkKey(cx, cz);
|
|
const chunk = chunks.get(key);
|
|
if (!chunk) return;
|
|
|
|
// Remove old mesh
|
|
const oldMeshes = chunkMeshes.get(key);
|
|
if (oldMeshes) {
|
|
oldMeshes.forEach(m => {
|
|
scene.remove(m);
|
|
m.geometry.dispose();
|
|
});
|
|
}
|
|
|
|
// Group faces by block type and material index
|
|
const facesByType = {};
|
|
const worldX = cx * CHUNK_SIZE;
|
|
const worldZ = cz * CHUNK_SIZE;
|
|
|
|
for (let x = 0; x < CHUNK_SIZE; x++) {
|
|
for (let z = 0; z < CHUNK_SIZE; z++) {
|
|
for (let y = 0; y < WORLD_HEIGHT; y++) {
|
|
const blockType = chunk[x + z * CHUNK_SIZE + y * CHUNK_SIZE * CHUNK_SIZE];
|
|
if (blockType === BLOCK.AIR) continue;
|
|
|
|
const wx = worldX + x;
|
|
const wz = worldZ + z;
|
|
|
|
for (const face of faceData) {
|
|
const nx = wx + face.dir[0];
|
|
const ny = y + face.dir[1];
|
|
const nz = wz + face.dir[2];
|
|
const neighbor = getBlock(nx, ny, nz);
|
|
|
|
const isTransparent = neighbor === BLOCK.AIR || neighbor === BLOCK.WATER ||
|
|
neighbor === BLOCK.GLASS || neighbor === BLOCK.LEAVES;
|
|
const skip = !isTransparent || (neighbor === blockType && (blockType === BLOCK.WATER || blockType === BLOCK.GLASS));
|
|
if (skip) continue;
|
|
|
|
if (!facesByType[blockType]) facesByType[blockType] = [[], [], [], [], [], []];
|
|
const verts = face.corners.map(c => [c[0] + x, c[1] + y, c[2] + z]);
|
|
facesByType[blockType][face.matIdx].push(verts);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const meshes = [];
|
|
for (const [btStr, matFaces] of Object.entries(facesByType)) {
|
|
const bt = parseInt(btStr);
|
|
const positions = [];
|
|
const normals = [];
|
|
const uvs = [];
|
|
const indices = [];
|
|
|
|
// Collect all faces across all material indices for this block type
|
|
const allPositions = [];
|
|
const allNormals = [];
|
|
const allUvs = [];
|
|
const allIndices = [];
|
|
const allGroups = [];
|
|
|
|
let vertOffset = 0;
|
|
|
|
for (let mi = 0; mi < 6; mi++) {
|
|
const faces = matFaces[mi];
|
|
const startIdx = allIndices.length;
|
|
|
|
for (const verts of faces) {
|
|
const face = faceData[mi];
|
|
for (const v of verts) {
|
|
allPositions.push(v[0], v[1], v[2]);
|
|
allNormals.push(face.dir[0], face.dir[1], face.dir[2]);
|
|
}
|
|
allUvs.push(0, 0, 0, 1, 1, 1, 1, 0);
|
|
allIndices.push(vertOffset, vertOffset+1, vertOffset+2, vertOffset, vertOffset+2, vertOffset+3);
|
|
vertOffset += 4;
|
|
}
|
|
|
|
allGroups.push({ start: startIdx, count: allIndices.length - startIdx, materialIndex: mi });
|
|
}
|
|
|
|
if (allPositions.length === 0) continue;
|
|
|
|
const geometry = new THREE.BufferGeometry();
|
|
geometry.setAttribute('position', new THREE.Float32BufferAttribute(allPositions, 3));
|
|
geometry.setAttribute('normal', new THREE.Float32BufferAttribute(allNormals, 3));
|
|
geometry.setAttribute('uv', new THREE.Float32BufferAttribute(allUvs, 2));
|
|
geometry.setIndex(allIndices);
|
|
|
|
for (const g of allGroups) {
|
|
geometry.addGroup(g.start, g.count, g.materialIndex);
|
|
}
|
|
|
|
const material = getBlockMaterials(blockTextures, bt);
|
|
const mesh = new THREE.Mesh(geometry, material);
|
|
mesh.position.set(worldX, 0, worldZ);
|
|
scene.add(mesh);
|
|
meshes.push(mesh);
|
|
}
|
|
|
|
chunkMeshes.set(key, meshes);
|
|
}
|
|
|
|
function rebuildChunkMesh(cx, cz) {
|
|
const key = chunkKey(cx, cz);
|
|
if (chunks.has(key)) {
|
|
buildChunkMesh(cx, cz, scene, blockTextures);
|
|
}
|
|
}
|
|
|
|
// ==================== THREE.JS SETUP ====================
|
|
const scene = new THREE.Scene();
|
|
scene.background = new THREE.Color(0x87ceeb);
|
|
scene.fog = new THREE.Fog(0x87ceeb, 50, RENDER_DIST * CHUNK_SIZE * 0.9);
|
|
|
|
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 500);
|
|
const renderer = new THREE.WebGLRenderer({ antialias: false });
|
|
renderer.setSize(window.innerWidth, window.innerHeight);
|
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
|
document.body.appendChild(renderer.domElement);
|
|
|
|
// Lighting
|
|
const ambientLight = new THREE.AmbientLight(0x888899, 1.2);
|
|
scene.add(ambientLight);
|
|
const sunLight = new THREE.DirectionalLight(0xfff5e0, 1.5);
|
|
sunLight.position.set(100, 200, 80);
|
|
scene.add(sunLight);
|
|
const hemiLight = new THREE.HemisphereLight(0x87ceeb, 0x556633, 0.5);
|
|
scene.add(hemiLight);
|
|
|
|
// Block textures
|
|
const blockTextures = createBlockTextures();
|
|
|
|
// ==================== PLAYER ====================
|
|
const player = {
|
|
position: new THREE.Vector3(0, 40, 0),
|
|
velocity: new THREE.Vector3(0, 0, 0),
|
|
onGround: false,
|
|
height: 1.62,
|
|
speed: 5.5,
|
|
jumpForce: 8,
|
|
yaw: 0,
|
|
pitch: 0,
|
|
selectedSlot: 0,
|
|
inventory: [BLOCK.GRASS, BLOCK.DIRT, BLOCK.STONE, BLOCK.SAND, BLOCK.WOOD, BLOCK.COBBLE, BLOCK.PLANK, BLOCK.BRICK]
|
|
};
|
|
|
|
// Find spawn position
|
|
function findSpawnPosition() {
|
|
const h = getHeight(0, 0);
|
|
player.position.set(0.5, Math.max(h + 3, SEA_LEVEL + 3), 0.5);
|
|
}
|
|
|
|
camera.position.copy(player.position);
|
|
camera.position.y += player.height;
|
|
|
|
// ==================== CONTROLS ====================
|
|
const keys = {};
|
|
let isLocked = false;
|
|
|
|
document.addEventListener('keydown', e => {
|
|
keys[e.code] = true;
|
|
if (e.code === 'F3') {
|
|
e.preventDefault();
|
|
document.getElementById('debug').classList.toggle('show');
|
|
}
|
|
// Block selection 1-8
|
|
if (e.code >= 'Digit1' && e.code <= 'Digit8') {
|
|
player.selectedSlot = parseInt(e.code.replace('Digit', '')) - 1;
|
|
updateHotbar();
|
|
}
|
|
});
|
|
document.addEventListener('keyup', e => { keys[e.code] = false; });
|
|
|
|
// Pointer lock
|
|
const blocker = document.getElementById('blocker');
|
|
const crosshair = document.getElementById('crosshair');
|
|
const hotbar = document.getElementById('hotbar');
|
|
|
|
document.addEventListener('click', () => {
|
|
if (!isLocked) renderer.domElement.requestPointerLock();
|
|
});
|
|
|
|
document.addEventListener('pointerlockchange', () => {
|
|
isLocked = document.pointerLockElement === renderer.domElement;
|
|
blocker.style.display = isLocked ? 'none' : 'flex';
|
|
crosshair.style.display = isLocked ? 'block' : 'none';
|
|
hotbar.className = isLocked ? 'show' : '';
|
|
});
|
|
|
|
document.addEventListener('mousemove', e => {
|
|
if (!isLocked) return;
|
|
const sensitivity = 0.002;
|
|
player.yaw -= e.movementX * sensitivity;
|
|
player.pitch -= e.movementY * sensitivity;
|
|
player.pitch = Math.max(-Math.PI / 2 + 0.01, Math.min(Math.PI / 2 - 0.01, player.pitch));
|
|
});
|
|
|
|
// Scroll wheel for block selection
|
|
document.addEventListener('wheel', e => {
|
|
if (!isLocked) return;
|
|
if (e.deltaY > 0) {
|
|
player.selectedSlot = (player.selectedSlot + 1) % player.inventory.length;
|
|
} else {
|
|
player.selectedSlot = (player.selectedSlot - 1 + player.inventory.length) % player.inventory.length;
|
|
}
|
|
updateHotbar();
|
|
});
|
|
|
|
// ==================== HOTBAR UI ====================
|
|
function initHotbar() {
|
|
const hotbarEl = document.getElementById('hotbar');
|
|
hotbarEl.innerHTML = '';
|
|
for (let i = 0; i < player.inventory.length; i++) {
|
|
const slot = document.createElement('div');
|
|
slot.className = 'hotbar-slot' + (i === player.selectedSlot ? ' active' : '');
|
|
|
|
const num = document.createElement('span');
|
|
num.className = 'slot-num';
|
|
num.textContent = i + 1;
|
|
slot.appendChild(num);
|
|
|
|
// Draw block icon
|
|
const icon = document.createElement('canvas');
|
|
icon.width = 16; icon.height = 16;
|
|
const blockType = player.inventory[i];
|
|
const texData = blockTextures[blockType];
|
|
const srcCanvas = Array.isArray(texData) ? texData[2] : texData;
|
|
if (srcCanvas && srcCanvas.getContext) {
|
|
icon.getContext('2d').drawImage(srcCanvas.image || srcCanvas.source.data, 0, 0);
|
|
}
|
|
slot.appendChild(icon);
|
|
hotbarEl.appendChild(slot);
|
|
}
|
|
}
|
|
|
|
function updateHotbar() {
|
|
const slots = document.querySelectorAll('.hotbar-slot');
|
|
slots.forEach((s, i) => s.className = 'hotbar-slot' + (i === player.selectedSlot ? ' active' : ''));
|
|
}
|
|
|
|
// ==================== RAYCASTING ====================
|
|
const raycaster = new THREE.Raycaster();
|
|
raycaster.far = 8;
|
|
|
|
function getTargetBlock() {
|
|
const dir = new THREE.Vector3(0, 0, -1);
|
|
dir.applyQuaternion(camera.quaternion);
|
|
|
|
const pos = camera.position.clone();
|
|
const step = 0.05;
|
|
const maxDist = 8;
|
|
let prevX, prevY, prevZ;
|
|
|
|
for (let d = 0; d < maxDist; d += step) {
|
|
const x = Math.floor(pos.x + dir.x * d);
|
|
const y = Math.floor(pos.y + dir.y * d);
|
|
const z = Math.floor(pos.z + dir.z * d);
|
|
|
|
const block = getBlock(x, y, z);
|
|
if (block !== BLOCK.AIR && block !== BLOCK.WATER) {
|
|
return {
|
|
x, y, z, block,
|
|
placeX: prevX, placeY: prevY, placeZ: prevZ
|
|
};
|
|
}
|
|
prevX = x; prevY = y; prevZ = z;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Block highlight
|
|
const highlightGeo = new THREE.BoxGeometry(1.005, 1.005, 1.005);
|
|
const highlightMat = new THREE.MeshBasicMaterial({
|
|
color: 0x000000, wireframe: true, transparent: true, opacity: 0.5
|
|
});
|
|
const highlightMesh = new THREE.Mesh(highlightGeo, highlightMat);
|
|
scene.add(highlightMesh);
|
|
|
|
// Mouse clicks for block interaction
|
|
document.addEventListener('mousedown', e => {
|
|
if (!isLocked) return;
|
|
|
|
const target = getTargetBlock();
|
|
if (!target) return;
|
|
|
|
if (e.button === 0) {
|
|
// Left click - destroy block
|
|
setWorldBlock(target.x, target.y, target.z, BLOCK.AIR);
|
|
} else if (e.button === 2) {
|
|
// Right click - place block
|
|
if (target.placeX !== undefined) {
|
|
// Check player isn't in the way
|
|
const px = Math.floor(player.position.x);
|
|
const py = Math.floor(player.position.y);
|
|
const pz = Math.floor(player.position.z);
|
|
if (!(target.placeX === px && (target.placeY === py || target.placeY === py+1) && target.placeZ === pz)) {
|
|
setWorldBlock(target.placeX, target.placeY, target.placeZ, player.inventory[player.selectedSlot]);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
document.addEventListener('contextmenu', e => e.preventDefault());
|
|
|
|
// ==================== PHYSICS ====================
|
|
function updatePhysics(dt) {
|
|
const gravity = -20;
|
|
const friction = 0.9;
|
|
|
|
// Movement direction
|
|
const forward = new THREE.Vector3(-Math.sin(player.yaw), 0, -Math.cos(player.yaw));
|
|
const right = new THREE.Vector3(Math.cos(player.yaw), 0, -Math.sin(player.yaw));
|
|
|
|
const moveDir = new THREE.Vector3(0, 0, 0);
|
|
if (keys['KeyW']) moveDir.add(forward);
|
|
if (keys['KeyS']) moveDir.sub(forward);
|
|
if (keys['KeyA']) moveDir.sub(right);
|
|
if (keys['KeyD']) moveDir.add(right);
|
|
|
|
if (moveDir.length() > 0) {
|
|
moveDir.normalize();
|
|
const speed = keys['ShiftLeft'] ? player.speed * 1.5 : player.speed;
|
|
player.velocity.x = moveDir.x * speed;
|
|
player.velocity.z = moveDir.z * speed;
|
|
} else {
|
|
player.velocity.x *= friction;
|
|
player.velocity.z *= friction;
|
|
}
|
|
|
|
// Jump
|
|
if (keys['Space'] && player.onGround) {
|
|
player.velocity.y = player.jumpForce;
|
|
player.onGround = false;
|
|
}
|
|
|
|
// Gravity
|
|
player.velocity.y += gravity * dt;
|
|
|
|
// Collision detection
|
|
const newPos = player.position.clone();
|
|
newPos.x += player.velocity.x * dt;
|
|
newPos.y += player.velocity.y * dt;
|
|
newPos.z += player.velocity.z * dt;
|
|
|
|
// Simple AABB collision
|
|
const playerWidth = 0.3;
|
|
const playerHeight = 1.8;
|
|
|
|
// Y collision
|
|
const feetY = Math.floor(newPos.y);
|
|
const headY = Math.floor(newPos.y + playerHeight);
|
|
const px = Math.floor(newPos.x);
|
|
const pz = Math.floor(newPos.z);
|
|
|
|
if (player.velocity.y < 0) {
|
|
if (getBlock(px, feetY, pz) !== BLOCK.AIR &&
|
|
getBlock(px, feetY, pz) !== BLOCK.WATER) {
|
|
newPos.y = feetY + 1;
|
|
player.velocity.y = 0;
|
|
player.onGround = true;
|
|
} else {
|
|
player.onGround = false;
|
|
}
|
|
}
|
|
if (player.velocity.y > 0) {
|
|
if (getBlock(px, headY, pz) !== BLOCK.AIR &&
|
|
getBlock(px, headY, pz) !== BLOCK.WATER) {
|
|
player.velocity.y = 0;
|
|
}
|
|
}
|
|
|
|
// X collision
|
|
const checkX = Math.floor(newPos.x + (player.velocity.x > 0 ? playerWidth : -playerWidth));
|
|
const checkY1 = Math.floor(newPos.y + 0.1);
|
|
const checkY2 = Math.floor(newPos.y + playerHeight - 0.1);
|
|
const checkZ = Math.floor(newPos.z);
|
|
if (getBlock(checkX, checkY1, checkZ) !== BLOCK.AIR && getBlock(checkX, checkY1, checkZ) !== BLOCK.WATER ||
|
|
getBlock(checkX, checkY2, checkZ) !== BLOCK.AIR && getBlock(checkX, checkY2, checkZ) !== BLOCK.WATER) {
|
|
newPos.x = player.position.x;
|
|
player.velocity.x = 0;
|
|
}
|
|
|
|
// Z collision
|
|
const checkZ2 = Math.floor(newPos.z + (player.velocity.z > 0 ? playerWidth : -playerWidth));
|
|
const checkX2 = Math.floor(newPos.x);
|
|
if (getBlock(checkX2, checkY1, checkZ2) !== BLOCK.AIR && getBlock(checkX2, checkY1, checkZ2) !== BLOCK.WATER ||
|
|
getBlock(checkX2, checkY2, checkZ2) !== BLOCK.AIR && getBlock(checkX2, checkY2, checkZ2) !== BLOCK.WATER) {
|
|
newPos.z = player.position.z;
|
|
player.velocity.z = 0;
|
|
}
|
|
|
|
// Prevent falling into void
|
|
if (newPos.y < -10) {
|
|
newPos.y = 50;
|
|
player.velocity.y = 0;
|
|
}
|
|
|
|
player.position.copy(newPos);
|
|
|
|
// Update camera
|
|
camera.position.set(
|
|
player.position.x,
|
|
player.position.y + player.height,
|
|
player.position.z
|
|
);
|
|
|
|
// Camera rotation
|
|
const euler = new THREE.Euler(player.pitch, player.yaw, 0, 'YXZ');
|
|
camera.quaternion.setFromEuler(euler);
|
|
}
|
|
|
|
// ==================== CHUNK MANAGEMENT ====================
|
|
function updateChunks() {
|
|
const pcx = Math.floor(player.position.x / CHUNK_SIZE);
|
|
const pcz = Math.floor(player.position.z / CHUNK_SIZE);
|
|
|
|
// Generate needed chunks
|
|
for (let dx = -RENDER_DIST; dx <= RENDER_DIST; dx++) {
|
|
for (let dz = -RENDER_DIST; dz <= RENDER_DIST; dz++) {
|
|
if (dx*dx + dz*dz > RENDER_DIST * RENDER_DIST) continue;
|
|
const cx = pcx + dx;
|
|
const cz = pcz + dz;
|
|
const key = chunkKey(cx, cz);
|
|
if (!chunkMeshes.has(key)) {
|
|
generateChunk(cx, cz);
|
|
buildChunkMesh(cx, cz, scene, blockTextures);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Remove far chunks
|
|
for (const [key, meshes] of chunkMeshes) {
|
|
const [cx, cz] = key.split(',').map(Number);
|
|
if (Math.abs(cx - pcx) > RENDER_DIST + 2 || Math.abs(cz - pcz) > RENDER_DIST + 2) {
|
|
meshes.forEach(m => {
|
|
scene.remove(m);
|
|
m.geometry.dispose();
|
|
});
|
|
chunkMeshes.delete(key);
|
|
chunks.delete(key);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ==================== DEBUG ====================
|
|
function updateDebug() {
|
|
const debugEl = document.getElementById('debug');
|
|
if (!debugEl.classList.contains('show')) return;
|
|
|
|
const biome = getBiome(Math.floor(player.position.x), Math.floor(player.position.z));
|
|
const target = getTargetBlock();
|
|
debugEl.innerHTML = `
|
|
XYZ: ${player.position.x.toFixed(1)} / ${player.position.y.toFixed(1)} / ${player.position.z.toFixed(1)}<br>
|
|
Chunk: ${Math.floor(player.position.x/CHUNK_SIZE)}, ${Math.floor(player.position.z/CHUNK_SIZE)}<br>
|
|
Biome: ${biome}<br>
|
|
Block: ${BLOCK_NAMES[player.inventory[player.selectedSlot]] || 'Unknown'}<br>
|
|
Looking: ${target ? `${BLOCK_NAMES[target.block]||'?'} [${target.x},${target.y},${target.z}]` : 'Air'}<br>
|
|
Chunks loaded: ${chunkMeshes.size}<br>
|
|
FPS: ${fps.toFixed(0)}
|
|
`;
|
|
}
|
|
|
|
// ==================== GAME LOOP ====================
|
|
let lastTime = performance.now();
|
|
let fps = 60;
|
|
let chunkTimer = 0;
|
|
let initialized = false;
|
|
|
|
function init() {
|
|
// Generate initial chunks
|
|
const pcx = Math.floor(player.position.x / CHUNK_SIZE);
|
|
const pcz = Math.floor(player.position.z / CHUNK_SIZE);
|
|
for (let dx = -3; dx <= 3; dx++) {
|
|
for (let dz = -3; dz <= 3; dz++) {
|
|
generateChunk(pcx + dx, pcz + dz);
|
|
}
|
|
}
|
|
findSpawnPosition();
|
|
for (let dx = -3; dx <= 3; dx++) {
|
|
for (let dz = -3; dz <= 3; dz++) {
|
|
buildChunkMesh(pcx + dx, pcz + dz, scene, blockTextures);
|
|
}
|
|
}
|
|
initHotbar();
|
|
initialized = true;
|
|
}
|
|
|
|
function animate() {
|
|
requestAnimationFrame(animate);
|
|
|
|
const now = performance.now();
|
|
const dt = Math.min((now - lastTime) / 1000, 0.05);
|
|
lastTime = now;
|
|
fps = 1 / dt;
|
|
|
|
if (!initialized) return;
|
|
if (!isLocked) {
|
|
renderer.render(scene, camera);
|
|
return;
|
|
}
|
|
|
|
updatePhysics(dt);
|
|
|
|
// Update chunks periodically
|
|
chunkTimer += dt;
|
|
if (chunkTimer > 0.5) {
|
|
updateChunks();
|
|
chunkTimer = 0;
|
|
}
|
|
|
|
// Update block highlight
|
|
const target = getTargetBlock();
|
|
if (target) {
|
|
highlightMesh.position.set(target.x + 0.5, target.y + 0.5, target.z + 0.5);
|
|
highlightMesh.visible = true;
|
|
} else {
|
|
highlightMesh.visible = false;
|
|
}
|
|
|
|
updateDebug();
|
|
renderer.render(scene, camera);
|
|
}
|
|
|
|
// Handle resize
|
|
window.addEventListener('resize', () => {
|
|
camera.aspect = window.innerWidth / window.innerHeight;
|
|
camera.updateProjectionMatrix();
|
|
renderer.setSize(window.innerWidth, window.innerHeight);
|
|
});
|
|
|
|
// Start
|
|
init();
|
|
animate();
|
|
|
|
</script>
|
|
</body>
|
|
</html>
|