Site: - Cursor follower fades out over iframes (it froze on top of running artifacts since iframes swallow pointer events) and when the pointer leaves the window - HTML viewer: full-bleed width on xl screens (up to 90rem), height capped to 100dvh-10rem so the frame plus its action row always fits the viewport (big iframes ate wheel events and trapped page scroll), fullscreen button (overlay + footer action), and Run now auto-aligns the frame below the fixed header Artifacts: - grok-build: W/S were inverted against the forward vector (W walked backward); model label renamed to Grok Build - composer-2-5: added G-key cycle teleport to its three generated landmarks (elven tower, dragon temple, sky ruins) with a start-screen hint - they were previously only reachable on foot; ground landmarks arrive 10 blocks out facing the structure, sky ruins land on the deck Run notes updated accordingly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1712 lines
63 KiB
HTML
1712 lines
63 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Aetheria • 我的世界:幻想纪元</title>
|
|
<style>
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
body { overflow: hidden; background: #000; font-family: "Segoe UI", system-ui, -apple-system, sans-serif; }
|
|
#game { display: block; width: 100vw; height: 100vh; }
|
|
#crosshair {
|
|
position: fixed; left: 50%; top: 50%; transform: translate(-50%, -50%);
|
|
width: 20px; height: 20px; pointer-events: none; z-index: 100;
|
|
}
|
|
#crosshair::before, #crosshair::after {
|
|
content: ''; position: absolute; background: rgba(255,255,255,0.95);
|
|
box-shadow: 0 0 4px rgba(0,0,0,0.6);
|
|
}
|
|
#crosshair::before { left: 50%; top: 2px; width: 2px; height: 16px; transform: translateX(-50%); }
|
|
#crosshair::after { top: 50%; left: 2px; height: 2px; width: 16px; transform: translateY(-50%); }
|
|
#hud {
|
|
position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);
|
|
display: flex; gap: 4px; z-index: 200; pointer-events: none;
|
|
}
|
|
.slot {
|
|
width: 52px; height: 52px; background: rgba(0,0,0,0.55); border: 3px solid #555;
|
|
image-rendering: pixelated; position: relative; pointer-events: auto;
|
|
}
|
|
.slot.active { border-color: #fff; box-shadow: 0 0 0 2px #222; }
|
|
.slot .icon {
|
|
position: absolute; inset: 3px; background-size: cover; image-rendering: pixelated;
|
|
}
|
|
#info {
|
|
position: fixed; top: 16px; left: 16px; color: #ddd; font-size: 13px; z-index: 200;
|
|
background: rgba(0,0,0,0.45); padding: 8px 12px; border-radius: 6px; line-height: 1.4;
|
|
pointer-events: none; user-select: none;
|
|
}
|
|
#instructions {
|
|
position: fixed; top: 16px; right: 16px; color: #ccc; font-size: 12px; z-index: 200;
|
|
background: rgba(0,0,0,0.5); padding: 10px 14px; border-radius: 6px; max-width: 260px;
|
|
line-height: 1.45; pointer-events: none;
|
|
}
|
|
#blockName {
|
|
position: fixed; bottom: 82px; left: 50%; transform: translateX(-50%);
|
|
color: #fff; background: rgba(0,0,0,0.6); padding: 2px 10px; border-radius: 3px;
|
|
font-size: 12px; z-index: 200; pointer-events: none;
|
|
}
|
|
.highlight { color: #a8e6cf; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<canvas id="game"></canvas>
|
|
<div id="crosshair"></div>
|
|
<div id="hud"></div>
|
|
<div id="blockName"></div>
|
|
<div id="info">
|
|
<div><span class="highlight">Aetheria</span> — 我的世界幻想纪元</div>
|
|
<div style="margin-top:4px;opacity:0.85">WASD 移动 | 空格跳跃 | 鼠标环视 | 滚轮/数字键 切换方块</div>
|
|
<div>左键破坏 | 右键放置 | P 切换鼠标锁定 | R 重生</div>
|
|
</div>
|
|
<div id="instructions">
|
|
<strong>幻想之地标</strong><br>
|
|
• 永恒尖塔(起始点附近)<br>
|
|
• 低语废墟 • 堕龙遗骸<br>
|
|
• 发光巨菇林 • 黑曜石神碑<br>
|
|
• 浮空圣所与水晶林<br>
|
|
探索并建造你自己的传说!
|
|
</div>
|
|
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r134/three.min.js"></script>
|
|
<script>
|
|
// ============================================================
|
|
// Aetheria — High-quality self-contained Minecraft-like in Three.js
|
|
// Features: Perlin terrain, infinite chunks, first-person, 8 block types,
|
|
// rich hand-crafted + procedural mythical structures.
|
|
// ============================================================
|
|
|
|
// ---------------------- CONFIG ----------------------
|
|
const CHUNK_SIZE = 16;
|
|
const CHUNK_HEIGHT = 128;
|
|
const VIEW_DISTANCE = 5; // chunks in each direction
|
|
const WORLD_SEED = 0xAE7E12; // fix: original `0xAETHER` is not a valid hex literal (T/H/R), which killed the whole script at parse time
|
|
|
|
// Block IDs
|
|
const BLOCK = {
|
|
AIR: 0,
|
|
GRASS: 1,
|
|
DIRT: 2,
|
|
STONE: 3,
|
|
SAND: 4,
|
|
WOOD: 5,
|
|
LEAVES: 6,
|
|
CRYSTAL: 7,
|
|
DARKSTONE: 8,
|
|
MOSS_STONE: 9
|
|
};
|
|
|
|
const BLOCK_NAMES = {
|
|
[BLOCK.GRASS]: '草方块',
|
|
[BLOCK.DIRT]: '泥土',
|
|
[BLOCK.STONE]: '石头',
|
|
[BLOCK.SAND]: '沙子',
|
|
[BLOCK.WOOD]: '橡木',
|
|
[BLOCK.LEAVES]: '树叶',
|
|
[BLOCK.CRYSTAL]: '以太水晶',
|
|
[BLOCK.DARKSTONE]: '暗影石',
|
|
[BLOCK.MOSS_STONE]: '苔石'
|
|
};
|
|
|
|
// ---------------------- PERLIN NOISE (high quality, seeded) ----------------------
|
|
class Perlin {
|
|
constructor(seed = 0) {
|
|
this.seed = seed;
|
|
this.p = new Uint8Array(512);
|
|
const perm = new Uint8Array(256);
|
|
for (let i = 0; i < 256; i++) perm[i] = i;
|
|
// shuffle using seed
|
|
let s = seed >>> 0;
|
|
for (let i = 255; i > 0; i--) {
|
|
s = (s * 16807) % 2147483647;
|
|
const j = s % (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(t, a, b) { 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) {
|
|
const X = Math.floor(x) & 255;
|
|
const Y = Math.floor(y) & 255;
|
|
const Z = Math.floor(z) & 255;
|
|
x -= Math.floor(x);
|
|
y -= Math.floor(y);
|
|
z -= Math.floor(z);
|
|
const u = this.fade(x);
|
|
const v = this.fade(y);
|
|
const w = this.fade(z);
|
|
const A = this.p[X] + Y, AA = this.p[A] + Z, AB = this.p[A + 1] + Z;
|
|
const B = this.p[X + 1] + Y, BA = this.p[B] + Z, BB = this.p[B + 1] + Z;
|
|
return this.lerp(w,
|
|
this.lerp(v,
|
|
this.lerp(u, this.grad(this.p[AA], x, y, z), this.grad(this.p[BA], x - 1, y, z)),
|
|
this.lerp(u, this.grad(this.p[AB], x, y - 1, z), this.grad(this.p[BB], x - 1, y - 1, z))),
|
|
this.lerp(v,
|
|
this.lerp(u, this.grad(this.p[AA + 1], x, y, z - 1), this.grad(this.p[BA + 1], x - 1, y, z - 1)),
|
|
this.lerp(u, this.grad(this.p[AB + 1], x, y - 1, z - 1), this.grad(this.p[BB + 1], x - 1, y - 1, z - 1)))
|
|
);
|
|
}
|
|
fbm(x, y, z, octaves = 5, lacunarity = 2.0, gain = 0.5) {
|
|
let total = 0;
|
|
let amplitude = 1;
|
|
let frequency = 1;
|
|
let maxValue = 0;
|
|
for (let i = 0; i < octaves; i++) {
|
|
total += this.noise(x * frequency, y * frequency, z * frequency) * amplitude;
|
|
maxValue += amplitude;
|
|
amplitude *= gain;
|
|
frequency *= lacunarity;
|
|
}
|
|
return total / maxValue;
|
|
}
|
|
}
|
|
|
|
// ---------------------- TEXTURE ATLAS (pixel art, authentic feel) ----------------------
|
|
function createTextureAtlas() {
|
|
const size = 256;
|
|
const tile = 32;
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = size;
|
|
canvas.height = size;
|
|
const ctx = canvas.getContext('2d', { alpha: true });
|
|
ctx.imageSmoothingEnabled = false;
|
|
|
|
const colors = {
|
|
grassTop: '#5a9c3f',
|
|
grassSide: '#5a9c3f',
|
|
dirt: '#8b5a2b',
|
|
stone: '#7a7a7a',
|
|
darkstone: '#2f2f38',
|
|
sand: '#d9c48a',
|
|
woodTop: '#a67c52',
|
|
woodSide: '#8c6642',
|
|
leaves: '#2f6b2f',
|
|
crystal: '#7ce8ff',
|
|
crystalDark: '#3a7a9e',
|
|
moss: '#4a7040'
|
|
};
|
|
|
|
function fillTile(tx, ty, baseColor, variationFn) {
|
|
ctx.fillStyle = baseColor;
|
|
ctx.fillRect(tx * tile, ty * tile, tile, tile);
|
|
for (let i = 0; i < 180; i++) {
|
|
const x = (tx * tile) + (Math.random() * tile | 0);
|
|
const y = (ty * tile) + (Math.random() * tile | 0);
|
|
const v = variationFn ? variationFn() : (Math.random() * 28 - 14);
|
|
const r = parseInt(baseColor.slice(1,3),16) + v | 0;
|
|
const g = parseInt(baseColor.slice(3,5),16) + v | 0;
|
|
const b = parseInt(baseColor.slice(5,7),16) + v | 0;
|
|
ctx.fillStyle = `rgb(${Math.max(0,Math.min(255,r))},${Math.max(0,Math.min(255,g))},${Math.max(0,Math.min(255,b))})`;
|
|
ctx.fillRect(x, y, 1, 1);
|
|
}
|
|
}
|
|
|
|
function drawGrassTop(tx, ty) {
|
|
fillTile(tx, ty, colors.grassTop, () => Math.random() * 22 - 11);
|
|
// blades
|
|
ctx.fillStyle = '#3d6e2b';
|
|
for (let i = 0; i < 9; i++) {
|
|
const x = (tx * tile) + 3 + (i % 3) * 9;
|
|
const y = (ty * tile) + 4 + ((i / 3) | 0) * 9;
|
|
ctx.fillRect(x, y, 2, 6);
|
|
ctx.fillRect(x + 4, y + 2, 2, 5);
|
|
}
|
|
}
|
|
|
|
function drawGrassSide(tx, ty) {
|
|
// dirt base with grass edge on top
|
|
ctx.fillStyle = colors.dirt;
|
|
ctx.fillRect(tx * tile, ty * tile, tile, tile);
|
|
ctx.fillStyle = colors.grassTop;
|
|
ctx.fillRect(tx * tile, ty * tile, tile, 8);
|
|
// small dirt specks
|
|
ctx.fillStyle = '#6f4623';
|
|
for (let i = 0; i < 18; i++) {
|
|
ctx.fillRect((tx * tile) + (Math.random() * tile | 0), (ty * tile) + 9 + (Math.random() * (tile - 9) | 0), 2, 1);
|
|
}
|
|
// grass edge blades
|
|
ctx.fillStyle = '#3d6e2b';
|
|
for (let i = 0; i < 6; i++) {
|
|
ctx.fillRect((tx * tile) + 2 + i * 5, ty * tile + 5, 2, 4);
|
|
}
|
|
}
|
|
|
|
function drawDirt(tx, ty) {
|
|
fillTile(tx, ty, colors.dirt, () => Math.random() * 18 - 9);
|
|
ctx.fillStyle = '#6f4623';
|
|
for (let i = 0; i < 26; i++) {
|
|
ctx.fillRect((tx * tile) + (Math.random()*tile|0), (ty * tile) + (Math.random()*tile|0), 2, 1);
|
|
}
|
|
}
|
|
|
|
function drawStone(tx, ty, base) {
|
|
fillTile(tx, ty, base, () => Math.random() * 16 - 8);
|
|
ctx.fillStyle = 'rgba(255,255,255,0.08)';
|
|
for (let i = 0; i < 5; i++) {
|
|
ctx.fillRect((tx*tile)+2+i*5, (ty*tile)+6, 4, 1);
|
|
ctx.fillRect((tx*tile)+4+i*5, (ty*tile)+18, 3, 1);
|
|
}
|
|
ctx.fillStyle = 'rgba(0,0,0,0.12)';
|
|
for (let i = 0; i < 8; i++) {
|
|
const px = (tx * tile) + (Math.random() * tile | 0);
|
|
const py = (ty * tile) + (Math.random() * tile | 0);
|
|
ctx.fillRect(px, py, 2, 2);
|
|
}
|
|
}
|
|
|
|
function drawSand(tx, ty) {
|
|
fillTile(tx, ty, colors.sand, () => Math.random() * 12 - 6);
|
|
ctx.fillStyle = '#c3a96a';
|
|
for (let i = 0; i < 40; i++) {
|
|
ctx.fillRect((tx*tile)+(Math.random()*tile|0), (ty*tile)+(Math.random()*tile|0), 1, 1);
|
|
}
|
|
}
|
|
|
|
function drawWoodTop(tx, ty) {
|
|
fillTile(tx, ty, colors.woodTop, () => Math.random() * 10 - 5);
|
|
ctx.strokeStyle = '#6b4e2f';
|
|
ctx.lineWidth = 2;
|
|
ctx.beginPath();
|
|
ctx.arc(tx * tile + 16, ty * tile + 16, 8, 0, Math.PI * 2);
|
|
ctx.stroke();
|
|
ctx.beginPath();
|
|
ctx.arc(tx * tile + 16, ty * tile + 16, 4, 0, Math.PI * 2);
|
|
ctx.stroke();
|
|
}
|
|
|
|
function drawWoodSide(tx, ty) {
|
|
ctx.fillStyle = colors.woodSide;
|
|
ctx.fillRect(tx * tile, ty * tile, tile, tile);
|
|
ctx.fillStyle = '#6b4e2f';
|
|
for (let y = 3; y < tile; y += 5) {
|
|
ctx.fillRect(tx * tile, ty * tile + y, tile, 2);
|
|
}
|
|
ctx.fillStyle = 'rgba(255,255,255,0.07)';
|
|
ctx.fillRect(tx * tile + 4, ty * tile + 2, 3, tile - 4);
|
|
}
|
|
|
|
function drawLeaves(tx, ty) {
|
|
fillTile(tx, ty, colors.leaves, () => Math.random() * 24 - 12);
|
|
ctx.fillStyle = '#1e4a1e';
|
|
for (let i = 0; i < 22; i++) {
|
|
const x = (tx * tile) + (Math.random() * tile | 0);
|
|
const y = (ty * tile) + (Math.random() * tile | 0);
|
|
ctx.fillRect(x, y, 3, 2);
|
|
}
|
|
}
|
|
|
|
function drawCrystal(tx, ty) {
|
|
ctx.fillStyle = '#0f1f2e';
|
|
ctx.fillRect(tx * tile, ty * tile, tile, tile);
|
|
// bright facets
|
|
ctx.fillStyle = colors.crystal;
|
|
ctx.beginPath();
|
|
ctx.moveTo(tx * tile + 5, ty * tile + 4);
|
|
ctx.lineTo(tx * tile + 26, ty * tile + 9);
|
|
ctx.lineTo(tx * tile + 17, ty * tile + 28);
|
|
ctx.lineTo(tx * tile + 7, ty * tile + 22);
|
|
ctx.fill();
|
|
ctx.fillStyle = '#c4f8ff';
|
|
ctx.fillRect(tx * tile + 11, ty * tile + 8, 5, 5);
|
|
ctx.fillRect(tx * tile + 19, ty * tile + 15, 4, 4);
|
|
// dark edges
|
|
ctx.fillStyle = colors.crystalDark;
|
|
ctx.fillRect(tx * tile + 2, ty * tile + 2, 3, tile - 4);
|
|
ctx.fillRect(tx * tile + tile - 5, ty * tile + 2, 3, tile - 4);
|
|
}
|
|
|
|
function drawDarkstone(tx, ty) {
|
|
drawStone(tx, ty, colors.darkstone);
|
|
ctx.fillStyle = '#5a3a7a';
|
|
for (let i = 0; i < 5; i++) {
|
|
const x = (tx * tile) + (Math.random() * tile | 0);
|
|
ctx.fillRect(x, (ty * tile) + 4 + i * 5, 3, 1);
|
|
}
|
|
}
|
|
|
|
function drawMossStone(tx, ty) {
|
|
drawStone(tx, ty, '#5f6f5f');
|
|
ctx.fillStyle = colors.moss;
|
|
for (let i = 0; i < 18; i++) {
|
|
const x = (tx * tile) + (Math.random() * tile | 0);
|
|
const y = (ty * tile) + (Math.random() * tile | 0);
|
|
ctx.fillRect(x, y, 3 + (Math.random()*2|0), 2);
|
|
}
|
|
}
|
|
|
|
// Layout atlas (row major)
|
|
// Row 0
|
|
drawGrassTop(0, 0); // 0: grass top
|
|
drawDirt(1, 0); // 1: dirt
|
|
drawStone(2, 0, colors.stone); // 2: stone
|
|
drawSand(3, 0); // 3: sand
|
|
drawWoodTop(4, 0); // 4: wood top
|
|
drawWoodSide(5, 0); // 5: wood side
|
|
drawLeaves(6, 0); // 6: leaves
|
|
drawCrystal(7, 0); // 7: crystal
|
|
// Row 1
|
|
drawDarkstone(0, 1); // 8: darkstone
|
|
drawMossStone(1, 1); // 9: moss stone
|
|
drawGrassSide(2, 1); // 10: grass side (proper dirt + green top)
|
|
drawDirt(3, 1);
|
|
drawStone(4, 1, '#6b6b6b');
|
|
drawSand(5, 1);
|
|
drawLeaves(6, 1);
|
|
drawCrystal(7, 1);
|
|
|
|
const texture = new THREE.CanvasTexture(canvas);
|
|
texture.flipY = false; // fix: tiles are drawn from the canvas top, but UVs assume a top-left origin; with default flipY=true every face sampled the undrawn (black) bottom rows of the atlas
|
|
texture.magFilter = THREE.NearestFilter;
|
|
texture.minFilter = THREE.NearestFilter;
|
|
texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
|
|
return { texture, tileSize: tile, atlasSize: size };
|
|
}
|
|
|
|
// ---------------------- WORLD & CHUNKS ----------------------
|
|
class World {
|
|
constructor() {
|
|
this.perlin = new Perlin(WORLD_SEED);
|
|
this.chunks = new Map(); // key: "cx,cz"
|
|
this.chunkMeshes = new Map();
|
|
this.blockData = new Map(); // per chunk key -> Uint8Array(CHUNK_SIZE*CHUNK_HEIGHT*CHUNK_SIZE)
|
|
}
|
|
|
|
getChunkKey(cx, cz) { return `${cx},${cz}`; }
|
|
|
|
getChunk(cx, cz) {
|
|
const key = this.getChunkKey(cx, cz);
|
|
return this.chunks.get(key) || null;
|
|
}
|
|
|
|
ensureChunk(cx, cz) {
|
|
const key = this.getChunkKey(cx, cz);
|
|
if (this.chunks.has(key)) return this.chunks.get(key);
|
|
|
|
const data = new Uint8Array(CHUNK_SIZE * CHUNK_HEIGHT * CHUNK_SIZE);
|
|
this.blockData.set(key, data);
|
|
this.chunks.set(key, { cx, cz, data });
|
|
this.generateChunk(cx, cz, data);
|
|
return this.chunks.get(key);
|
|
}
|
|
|
|
// World coordinate -> block
|
|
getBlock(x, y, z) {
|
|
if (y < 0 || y >= CHUNK_HEIGHT) return BLOCK.AIR;
|
|
const cx = Math.floor(x / CHUNK_SIZE);
|
|
const cz = Math.floor(z / CHUNK_SIZE);
|
|
const key = this.getChunkKey(cx, cz);
|
|
const chunk = this.chunks.get(key);
|
|
if (!chunk) return BLOCK.AIR;
|
|
const lx = ((x % CHUNK_SIZE) + CHUNK_SIZE) % CHUNK_SIZE;
|
|
const lz = ((z % CHUNK_SIZE) + CHUNK_SIZE) % CHUNK_SIZE;
|
|
return chunk.data[lx + (y * CHUNK_SIZE) + (lz * CHUNK_SIZE * CHUNK_HEIGHT)];
|
|
}
|
|
|
|
setBlock(x, y, z, type, skipRemesh = false) {
|
|
if (y < 0 || y >= CHUNK_HEIGHT) return false;
|
|
const cx = Math.floor(x / CHUNK_SIZE);
|
|
const cz = Math.floor(z / CHUNK_SIZE);
|
|
const key = this.getChunkKey(cx, cz);
|
|
let chunk = this.chunks.get(key);
|
|
if (!chunk) {
|
|
chunk = this.ensureChunk(cx, cz);
|
|
}
|
|
const lx = ((x % CHUNK_SIZE) + CHUNK_SIZE) % CHUNK_SIZE;
|
|
const lz = ((z % CHUNK_SIZE) + CHUNK_SIZE) % CHUNK_SIZE;
|
|
const idx = lx + (y * CHUNK_SIZE) + (lz * CHUNK_SIZE * CHUNK_HEIGHT);
|
|
if (chunk.data[idx] === type) return false;
|
|
chunk.data[idx] = type;
|
|
|
|
if (!skipRemesh) {
|
|
this.remeshChunk(cx, cz);
|
|
// Remesh neighbors if on edge
|
|
const lxEdge = lx === 0 || lx === CHUNK_SIZE - 1;
|
|
const lzEdge = lz === 0 || lz === CHUNK_SIZE - 1;
|
|
if (lxEdge) this.remeshChunk(cx - 1, cz);
|
|
if (lxEdge) this.remeshChunk(cx + 1, cz);
|
|
if (lzEdge) this.remeshChunk(cx, cz - 1);
|
|
if (lzEdge) this.remeshChunk(cx, cz + 1);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// Core terrain + fantasy generation
|
|
generateChunk(cx, cz, data) {
|
|
const scale = 0.032;
|
|
const mountainScale = 0.011;
|
|
const detail = 0.09;
|
|
|
|
for (let lx = 0; lx < CHUNK_SIZE; lx++) {
|
|
for (let lz = 0; lz < CHUNK_SIZE; lz++) {
|
|
const wx = cx * CHUNK_SIZE + lx;
|
|
const wz = cz * CHUNK_SIZE + lz;
|
|
|
|
// Large scale height
|
|
const base = this.perlin.fbm(wx * scale, wz * scale, 0, 4, 2.1, 0.52);
|
|
const mtn = Math.pow(Math.max(0, this.perlin.fbm(wx * mountainScale, wz * mountainScale, 11, 3, 2.0, 0.55)), 1.4);
|
|
let height = 38 + base * 22 + mtn * 48;
|
|
|
|
// Small detail
|
|
const d = this.perlin.fbm(wx * detail, wz * detail, 47, 3, 2.3, 0.6);
|
|
height += d * 5.5;
|
|
|
|
// Biome influence (dark forest / crystal)
|
|
const biome = this.perlin.fbm(wx * 0.007, wz * 0.007, 123, 2, 2, 0.6);
|
|
const isDark = biome < -0.15;
|
|
const isCrystal = biome > 0.32;
|
|
|
|
const h = Math.max(4, Math.min(CHUNK_HEIGHT - 8, Math.floor(height)));
|
|
|
|
// Base layers
|
|
for (let y = 0; y < CHUNK_HEIGHT; y++) {
|
|
let block = BLOCK.AIR;
|
|
|
|
if (y === 0) block = BLOCK.DARKSTONE;
|
|
else if (y < h - 5) block = BLOCK.STONE;
|
|
else if (y < h - 1) block = BLOCK.DIRT;
|
|
else if (y === h - 1) {
|
|
if (h < 38 && this.perlin.noise(wx * 0.06, wz * 0.06, 8) > 0.4) block = BLOCK.SAND;
|
|
else if (isDark) block = BLOCK.MOSS_STONE;
|
|
else block = BLOCK.GRASS;
|
|
} else if (y < h) {
|
|
block = BLOCK.DIRT;
|
|
}
|
|
|
|
// 3D cave / tunnel noise
|
|
if (y > 3 && y < h - 2) {
|
|
const cave = this.perlin.fbm(wx * 0.055, y * 0.065, wz * 0.055, 3, 2.1, 0.55);
|
|
if (cave < -0.62) block = BLOCK.AIR;
|
|
}
|
|
|
|
// Stone patches in grass for interest
|
|
if (y === h - 1 && this.perlin.noise(wx * 0.2, y * 0.1, wz * 0.2) > 0.7) {
|
|
block = BLOCK.STONE;
|
|
}
|
|
|
|
const idx = lx + (y * CHUNK_SIZE) + (lz * CHUNK_SIZE * CHUNK_HEIGHT);
|
|
data[idx] = block;
|
|
}
|
|
|
|
// Trees and fantasy foliage
|
|
if (h > 5 && h < CHUNK_HEIGHT - 14) {
|
|
const treeNoise = this.perlin.noise(wx * 0.8, h * 0.1, wz * 0.8);
|
|
const treeChance = isDark ? 0.78 : (isCrystal ? 0.12 : 0.38);
|
|
|
|
if (treeNoise > 1 - treeChance * 0.6) {
|
|
this.placeTree(data, lx, h, lz, isDark, isCrystal);
|
|
}
|
|
}
|
|
|
|
// Crystal clusters in crystal biome
|
|
if (isCrystal && h < CHUNK_HEIGHT - 10) {
|
|
if (this.perlin.noise(wx * 1.3, wz * 1.3, 77) > 0.65) {
|
|
this.placeCrystalCluster(data, lx, h, lz);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Large structures only in specific chunks (world landmarks)
|
|
this.placeLandmarks(cx, cz, data);
|
|
}
|
|
|
|
placeTree(data, lx, baseY, lz, isDark, isCrystal) {
|
|
const trunkH = 4 + (isDark ? 3 : 0) + (Math.random() * 3 | 0);
|
|
const leafR = isCrystal ? 2 : (isDark ? 3 : 2);
|
|
|
|
// trunk
|
|
for (let y = 0; y < trunkH; y++) {
|
|
const yy = baseY + y;
|
|
if (yy >= CHUNK_HEIGHT) break;
|
|
if (lx >= 0 && lx < CHUNK_SIZE && lz >= 0 && lz < CHUNK_SIZE) {
|
|
const idx = lx + (yy * CHUNK_SIZE) + (lz * CHUNK_SIZE * CHUNK_HEIGHT);
|
|
data[idx] = BLOCK.WOOD;
|
|
}
|
|
}
|
|
|
|
// leaves
|
|
const top = baseY + trunkH;
|
|
for (let dy = -2; dy <= 2; dy++) {
|
|
const yy = top + dy;
|
|
if (yy < 0 || yy >= CHUNK_HEIGHT) continue;
|
|
const r = leafR + (dy === 0 ? 1 : (Math.abs(dy) === 1 ? 0 : -1));
|
|
for (let dx = -r; dx <= r; dx++) {
|
|
for (let dz = -r; dz <= r; dz++) {
|
|
if (dx * dx + dz * dz > r * r + 0.5) continue;
|
|
const xx = lx + dx;
|
|
const zz = lz + dz;
|
|
if (xx < 0 || xx >= CHUNK_SIZE || zz < 0 || zz >= CHUNK_SIZE) continue;
|
|
const idx = xx + (yy * CHUNK_SIZE) + (zz * CHUNK_SIZE * CHUNK_HEIGHT);
|
|
if (data[idx] === BLOCK.AIR || data[idx] === BLOCK.LEAVES) {
|
|
data[idx] = BLOCK.LEAVES;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Crystal "fruit" on some trees
|
|
if (isCrystal && Math.random() < 0.6) {
|
|
const yy = top + 1;
|
|
if (yy < CHUNK_HEIGHT && lx + 1 < CHUNK_SIZE) {
|
|
const idx = (lx + 1) + (yy * CHUNK_SIZE) + (lz * CHUNK_SIZE * CHUNK_HEIGHT);
|
|
data[idx] = BLOCK.CRYSTAL;
|
|
}
|
|
}
|
|
}
|
|
|
|
placeCrystalCluster(data, lx, baseY, lz) {
|
|
const h = 2 + (Math.random() * 3 | 0);
|
|
for (let y = 0; y < h; y++) {
|
|
const yy = baseY + y;
|
|
if (yy >= CHUNK_HEIGHT) break;
|
|
if (lx >= 0 && lx < CHUNK_SIZE && lz >= 0 && lz < CHUNK_SIZE) {
|
|
const idx = lx + (yy * CHUNK_SIZE) + (lz * CHUNK_SIZE * CHUNK_HEIGHT);
|
|
if (data[idx] === BLOCK.AIR || data[idx] === BLOCK.GRASS) data[idx] = BLOCK.CRYSTAL;
|
|
}
|
|
}
|
|
// small surrounding
|
|
for (let i = 0; i < 4; i++) {
|
|
const dx = (Math.random() * 3 - 1.5) | 0;
|
|
const dz = (Math.random() * 3 - 1.5) | 0;
|
|
const xx = lx + dx, zz = lz + dz;
|
|
if (xx >= 0 && xx < CHUNK_SIZE && zz >= 0 && zz < CHUNK_SIZE) {
|
|
const yy = baseY + (Math.random() * 1.5 | 0);
|
|
if (yy < CHUNK_HEIGHT) {
|
|
const idx = xx + (yy * CHUNK_SIZE) + (zz * CHUNK_SIZE * CHUNK_HEIGHT);
|
|
if (data[idx] === BLOCK.AIR) data[idx] = BLOCK.CRYSTAL;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// === THE RICH FANTASY LANDMARKS ===
|
|
placeLandmarks(cx, cz, data) {
|
|
// We use world chunk coordinates to deterministically place epic structures
|
|
const key = cx + ',' + cz;
|
|
|
|
// 1. THE AETHER SPIRE - majestic starting landmark near origin
|
|
if (cx >= -1 && cx <= 1 && cz >= -1 && cz <= 1) {
|
|
const wx = cx * CHUNK_SIZE;
|
|
const wz = cz * CHUNK_SIZE;
|
|
this.buildAetherSpire(data, wx, wz, cx, cz);
|
|
}
|
|
|
|
// 2. Whispering Ruins (stonehenge-like + temple)
|
|
if (cx === -4 && cz === 5) this.buildWhisperingRuins(data, cx * CHUNK_SIZE, cz * CHUNK_SIZE);
|
|
if (cx === -5 && cz === 5) this.buildWhisperingRuins(data, cx * CHUNK_SIZE, cz * CHUNK_SIZE);
|
|
|
|
// 3. Fallen Dragon skeleton
|
|
if (cx >= 6 && cx <= 9 && cz >= -3 && cz <= 1) {
|
|
this.buildDragonSkeleton(data, cx * CHUNK_SIZE, cz * CHUNK_SIZE, cx, cz);
|
|
}
|
|
|
|
// 4. Giant Lumina Mushroom Grove (dark fantasy)
|
|
if (cx === 2 && cz === -6) this.buildGiantMushroomGrove(data, cx * CHUNK_SIZE, cz * CHUNK_SIZE);
|
|
if (cx === 3 && cz === -6) this.buildGiantMushroomGrove(data, cx * CHUNK_SIZE, cz * CHUNK_SIZE);
|
|
|
|
// 5. Obsidian Monolith
|
|
if (cx === -7 && cz === -4) this.buildObsidianMonolith(data, cx * CHUNK_SIZE, cz * CHUNK_SIZE);
|
|
|
|
// 6. Floating Sanctuary (small floating island)
|
|
if (cx === 4 && cz === 7) this.buildFloatingSanctuary(data, cx * CHUNK_SIZE, cz * CHUNK_SIZE);
|
|
|
|
// 7. Scattered crystal pillars and menhirs (using noise-like deterministic placement)
|
|
this.placeScatteredFantasyFeatures(data, cx, cz);
|
|
}
|
|
|
|
buildAetherSpire(data, baseX, baseZ, cx, cz) {
|
|
// Tall elegant tower with crystal accents
|
|
const centerX = baseX + 8;
|
|
const centerZ = baseZ + 8;
|
|
const height = 38;
|
|
|
|
// Main tower body (darkstone + stone)
|
|
for (let y = 0; y < height; y++) {
|
|
const r = 3 + (y % 7 === 0 ? 1 : 0);
|
|
for (let dx = -r; dx <= r; dx++) {
|
|
for (let dz = -r; dz <= r; dz++) {
|
|
if (dx * dx + dz * dz > r * r + 1) continue;
|
|
const wx = centerX + dx;
|
|
const wz = centerZ + dz;
|
|
const lx = wx - cx * CHUNK_SIZE;
|
|
const lz = wz - cz * CHUNK_SIZE;
|
|
if (lx < 0 || lx >= CHUNK_SIZE || lz < 0 || lz >= CHUNK_SIZE) continue;
|
|
|
|
let block = BLOCK.DARKSTONE;
|
|
if ((dx === 0 || dz === 0) && y % 3 === 0) block = BLOCK.CRYSTAL;
|
|
if (y % 9 === 4) block = BLOCK.MOSS_STONE;
|
|
|
|
const idx = lx + ((y + 12) * CHUNK_SIZE) + (lz * CHUNK_SIZE * CHUNK_HEIGHT);
|
|
data[idx] = block;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Spire top + crystal crown
|
|
for (let y = height; y < height + 7; y++) {
|
|
const r = Math.max(1, 4 - ((y - height) >> 1));
|
|
for (let dx = -r; dx <= r; dx++) {
|
|
for (let dz = -r; dz <= r; dz++) {
|
|
if (Math.abs(dx) + Math.abs(dz) > r + 1) continue;
|
|
const wx = centerX + dx;
|
|
const wz = centerZ + dz;
|
|
const lx = wx - cx * CHUNK_SIZE, lz = wz - cz * CHUNK_SIZE;
|
|
if (lx < 0 || lx >= CHUNK_SIZE || lz < 0 || lz >= CHUNK_SIZE) continue;
|
|
const idx = lx + ((y + 12) * CHUNK_SIZE) + (lz * CHUNK_SIZE * CHUNK_HEIGHT);
|
|
data[idx] = (y > height + 3) ? BLOCK.CRYSTAL : BLOCK.DARKSTONE;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Floating crystal rings around the tower
|
|
for (let ring = 0; ring < 3; ring++) {
|
|
const ry = 14 + ring * 9;
|
|
const rr = 5 + ring;
|
|
for (let a = 0; a < 12; a++) {
|
|
const ang = (a / 12) * Math.PI * 2;
|
|
const fx = Math.round(centerX + Math.cos(ang) * rr);
|
|
const fz = Math.round(centerZ + Math.sin(ang) * rr);
|
|
const lx = fx - cx * CHUNK_SIZE;
|
|
const lz = fz - cz * CHUNK_SIZE;
|
|
if (lx >= 0 && lx < CHUNK_SIZE && lz >= 0 && lz < CHUNK_SIZE) {
|
|
const idx = lx + ((ry + 12) * CHUNK_SIZE) + (lz * CHUNK_SIZE * CHUNK_HEIGHT);
|
|
data[idx] = BLOCK.CRYSTAL;
|
|
// small pillars under
|
|
for (let dy = -3; dy < 0; dy++) {
|
|
const i2 = lx + ((ry + 12 + dy) * CHUNK_SIZE) + (lz * CHUNK_SIZE * CHUNK_HEIGHT);
|
|
if (data[i2] === BLOCK.AIR) data[i2] = BLOCK.DARKSTONE;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
buildWhisperingRuins(data, baseX, baseZ) {
|
|
const cx = Math.floor(baseX / CHUNK_SIZE);
|
|
const cz = Math.floor(baseZ / CHUNK_SIZE);
|
|
|
|
// Large stone circle + central altar
|
|
const cxCenter = baseX + 8;
|
|
const czCenter = baseZ + 8;
|
|
|
|
// Outer ring of tall pillars
|
|
for (let i = 0; i < 12; i++) {
|
|
const ang = (i / 12) * Math.PI * 2;
|
|
const px = Math.round(cxCenter + Math.cos(ang) * 9);
|
|
const pz = Math.round(czCenter + Math.sin(ang) * 9);
|
|
this.setPillar(data, px, pz, cx, cz, 7 + (i % 3), BLOCK.MOSS_STONE);
|
|
}
|
|
|
|
// Inner broken ring
|
|
for (let i = 0; i < 8; i++) {
|
|
const ang = (i / 8) * Math.PI * 2 + 0.3;
|
|
const px = Math.round(cxCenter + Math.cos(ang) * 4.5);
|
|
const pz = Math.round(czCenter + Math.sin(ang) * 4.5);
|
|
this.setPillar(data, px, pz, cx, cz, 4 + (i % 2), BLOCK.STONE);
|
|
}
|
|
|
|
// Central altar
|
|
for (let dx = -1; dx <= 1; dx++) {
|
|
for (let dz = -1; dz <= 1; dz++) {
|
|
for (let y = 0; y < 3; y++) {
|
|
this.forceBlock(data, cxCenter + dx, 22 + y, czCenter + dz, BLOCK.DARKSTONE, cx, cz);
|
|
}
|
|
}
|
|
}
|
|
this.forceBlock(data, cxCenter, 25, czCenter, BLOCK.CRYSTAL, cx, cz);
|
|
}
|
|
|
|
setPillar(data, wx, wz, cx, cz, h, mat) {
|
|
for (let y = 0; y < h; y++) {
|
|
this.forceBlock(data, wx, 20 + y, wz, mat, cx, cz);
|
|
}
|
|
// broken top sometimes
|
|
if (Math.random() < 0.4) {
|
|
this.forceBlock(data, wx, 20 + h, wz, BLOCK.STONE, cx, cz);
|
|
}
|
|
}
|
|
|
|
forceBlock(data, wx, wy, wz, type, cx, cz) {
|
|
const lx = wx - cx * CHUNK_SIZE;
|
|
const lz = wz - cz * CHUNK_SIZE;
|
|
if (lx < 0 || lx >= CHUNK_SIZE || lz < 0 || lz >= CHUNK_SIZE) return;
|
|
if (wy < 0 || wy >= CHUNK_HEIGHT) return;
|
|
const idx = lx + (wy * CHUNK_SIZE) + (lz * CHUNK_SIZE * CHUNK_HEIGHT);
|
|
data[idx] = type;
|
|
}
|
|
|
|
buildDragonSkeleton(data, baseX, baseZ, cx, cz) {
|
|
// A long dragon made of darkstone + crystal "spine"
|
|
const startX = baseX + 3;
|
|
const startZ = baseZ + 8;
|
|
const length = 28;
|
|
|
|
for (let i = 0; i < length; i++) {
|
|
const x = startX + i;
|
|
const z = startZ + Math.sin(i * 0.28) * 2.5 | 0;
|
|
const y = 21 + Math.sin(i * 0.19) * 1.5 | 0;
|
|
|
|
// Spine
|
|
this.forceBlock(data, x, y + 2, z, BLOCK.DARKSTONE, cx, cz);
|
|
this.forceBlock(data, x, y + 3, z, (i % 3 === 0) ? BLOCK.CRYSTAL : BLOCK.DARKSTONE, cx, cz);
|
|
|
|
// Ribs
|
|
if (i % 3 === 0 || i % 4 === 1) {
|
|
for (let r = -3; r <= 3; r++) {
|
|
if (Math.abs(r) === 3 && Math.random() < 0.5) continue;
|
|
this.forceBlock(data, x, y + 1, z + r, BLOCK.STONE, cx, cz);
|
|
if (r !== 0) this.forceBlock(data, x, y, z + r, BLOCK.STONE, cx, cz);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Skull
|
|
const skullX = startX + length + 2;
|
|
const skullZ = startZ;
|
|
const skullY = 23;
|
|
for (let dx = -2; dx <= 2; dx++) {
|
|
for (let dz = -2; dz <= 2; dz++) {
|
|
if (dx * dx + dz * dz > 5) continue;
|
|
this.forceBlock(data, skullX + dx, skullY, skullZ + dz, BLOCK.DARKSTONE, cx, cz);
|
|
this.forceBlock(data, skullX + dx, skullY + 1, skullZ + dz, BLOCK.DARKSTONE, cx, cz);
|
|
}
|
|
}
|
|
// Eyes (crystal)
|
|
this.forceBlock(data, skullX + 1, skullY + 2, skullZ - 1, BLOCK.CRYSTAL, cx, cz);
|
|
this.forceBlock(data, skullX + 1, skullY + 2, skullZ + 1, BLOCK.CRYSTAL, cx, cz);
|
|
// Jaw
|
|
for (let dx = -1; dx <= 1; dx++) {
|
|
this.forceBlock(data, skullX + dx, skullY - 1, skullZ, BLOCK.STONE, cx, cz);
|
|
}
|
|
}
|
|
|
|
buildGiantMushroomGrove(data, baseX, baseZ) {
|
|
const positions = [
|
|
[baseX + 7, baseZ + 6], [baseX + 12, baseZ + 9], [baseX + 5, baseZ + 13],
|
|
[baseX + 14, baseZ + 3], [baseX + 9, baseZ + 15]
|
|
];
|
|
const cx = Math.floor(baseX / CHUNK_SIZE);
|
|
const cz = Math.floor(baseZ / CHUNK_SIZE);
|
|
|
|
positions.forEach(([mx, mz], index) => {
|
|
const stemH = 7 + (index % 3);
|
|
const capR = 4 + (index % 2);
|
|
|
|
// Stem
|
|
for (let y = 0; y < stemH; y++) {
|
|
for (let dx = -1; dx <= 1; dx++) {
|
|
for (let dz = -1; dz <= 1; dz++) {
|
|
if (dx * dx + dz * dz > 1) continue;
|
|
this.forceBlock(data, mx + dx, 18 + y, mz + dz, BLOCK.WOOD, cx, cz);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Cap
|
|
const capY = 18 + stemH;
|
|
for (let dx = -capR; dx <= capR; dx++) {
|
|
for (let dz = -capR; dz <= capR; dz++) {
|
|
if (dx * dx + dz * dz > capR * capR + 1.2) continue;
|
|
const yy = capY + (dx * dx + dz * dz < 3 ? 1 : 0);
|
|
this.forceBlock(data, mx + dx, yy, mz + dz, BLOCK.LEAVES, cx, cz);
|
|
if ((dx + dz) % 3 === 0 && Math.random() < 0.7) {
|
|
this.forceBlock(data, mx + dx, yy + 1, mz + dz, BLOCK.CRYSTAL, cx, cz);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
buildObsidianMonolith(data, baseX, baseZ) {
|
|
const cx = Math.floor(baseX / CHUNK_SIZE);
|
|
const cz = Math.floor(baseZ / CHUNK_SIZE);
|
|
const mx = baseX + 8;
|
|
const mz = baseZ + 8;
|
|
|
|
const height = 44;
|
|
for (let y = 0; y < height; y++) {
|
|
const r = y < 6 ? 3 : (y > height - 7 ? 2 : 2);
|
|
for (let dx = -r; dx <= r; dx++) {
|
|
for (let dz = -r; dz <= r; dz++) {
|
|
if (Math.abs(dx) === r && Math.abs(dz) === r) continue;
|
|
this.forceBlock(data, mx + dx, 18 + y, mz + dz, BLOCK.DARKSTONE, cx, cz);
|
|
}
|
|
}
|
|
// Runes
|
|
if (y % 5 === 2) {
|
|
this.forceBlock(data, mx + 1, 18 + y, mz, BLOCK.CRYSTAL, cx, cz);
|
|
this.forceBlock(data, mx - 1, 18 + y, mz, BLOCK.CRYSTAL, cx, cz);
|
|
this.forceBlock(data, mx, 18 + y, mz + 1, BLOCK.CRYSTAL, cx, cz);
|
|
}
|
|
}
|
|
// Crown
|
|
for (let dx = -3; dx <= 3; dx++) {
|
|
this.forceBlock(data, mx + dx, 18 + height, mz, BLOCK.DARKSTONE, cx, cz);
|
|
}
|
|
}
|
|
|
|
buildFloatingSanctuary(data, baseX, baseZ) {
|
|
const cx = Math.floor(baseX / CHUNK_SIZE);
|
|
const cz = Math.floor(baseZ / CHUNK_SIZE);
|
|
const mx = baseX + 8;
|
|
const mz = baseZ + 7;
|
|
|
|
// Floating island platform
|
|
const islandY = 48;
|
|
for (let dx = -5; dx <= 5; dx++) {
|
|
for (let dz = -5; dz <= 5; dz++) {
|
|
if (dx * dx + dz * dz > 26) continue;
|
|
this.forceBlock(data, mx + dx, islandY, mz + dz, BLOCK.GRASS, cx, cz);
|
|
if (Math.random() < 0.3) this.forceBlock(data, mx + dx, islandY - 1, mz + dz, BLOCK.DIRT, cx, cz);
|
|
}
|
|
}
|
|
|
|
// Small temple on top
|
|
for (let dx = -1; dx <= 1; dx++) {
|
|
for (let dz = -1; dz <= 1; dz++) {
|
|
this.forceBlock(data, mx + dx, islandY + 1, mz + dz, BLOCK.STONE, cx, cz);
|
|
this.forceBlock(data, mx + dx, islandY + 2, mz + dz, BLOCK.STONE, cx, cz);
|
|
}
|
|
}
|
|
this.forceBlock(data, mx, islandY + 3, mz, BLOCK.CRYSTAL, cx, cz);
|
|
|
|
// Hanging crystals
|
|
for (let i = 0; i < 7; i++) {
|
|
const hx = mx + (Math.random() * 7 - 3.5 | 0);
|
|
const hz = mz + (Math.random() * 7 - 3.5 | 0);
|
|
const len = 3 + (Math.random() * 3 | 0);
|
|
for (let y = 0; y < len; y++) {
|
|
this.forceBlock(data, hx, islandY - 1 - y, hz, BLOCK.CRYSTAL, cx, cz);
|
|
}
|
|
}
|
|
}
|
|
|
|
placeScatteredFantasyFeatures(data, cx, cz) {
|
|
// Deterministic but varied standing stones and crystal outcrops
|
|
const wxBase = cx * CHUNK_SIZE;
|
|
const wzBase = cz * CHUNK_SIZE;
|
|
|
|
for (let i = 0; i < 3; i++) {
|
|
const lx = 2 + ((cx * 17 + cz * 31 + i * 7) % 12);
|
|
const lz = 2 + ((cx * 29 + cz * 13 + i * 11) % 12);
|
|
const wx = wxBase + lx;
|
|
const wz = wzBase + lz;
|
|
|
|
const heightNoise = this.perlin.noise(wx * 0.3, wz * 0.3, 9);
|
|
if (heightNoise > 0.4) {
|
|
const h = 3 + ((cx + cz + i) % 3);
|
|
for (let y = 0; y < h; y++) {
|
|
this.forceBlock(data, wx, 20 + y, wz, (i % 2 === 0) ? BLOCK.MOSS_STONE : BLOCK.STONE, cx, cz);
|
|
}
|
|
if (i % 2 === 1) {
|
|
this.forceBlock(data, wx, 20 + h, wz, BLOCK.CRYSTAL, cx, cz);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Occasional giant crystal cluster
|
|
if ((cx * 7 + cz * 11) % 5 === 2) {
|
|
const lx = 5 + ((cx + cz) % 6);
|
|
const lz = 5 + ((cx * 3 + cz) % 6);
|
|
for (let k = 0; k < 5; k++) {
|
|
const ox = lx + ((k % 3) - 1);
|
|
const oz = lz + (((k / 3) | 0) - 1);
|
|
if (ox >= 0 && ox < CHUNK_SIZE && oz >= 0 && oz < CHUNK_SIZE) {
|
|
const yy = 22 + (k % 3);
|
|
this.forceBlock(data, wxBase + ox, yy, wzBase + oz, BLOCK.CRYSTAL, cx, cz);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------- MESH GENERATION ----------------
|
|
buildChunkMesh(cx, cz) {
|
|
const key = this.getChunkKey(cx, cz);
|
|
const chunk = this.chunks.get(key);
|
|
if (!chunk) return null;
|
|
|
|
const { geometry, materials } = this.createChunkGeometry(chunk);
|
|
|
|
if (!geometry) return null;
|
|
|
|
const mesh = new THREE.Mesh(geometry, materials);
|
|
mesh.position.set(cx * CHUNK_SIZE, 0, cz * CHUNK_SIZE);
|
|
mesh.castShadow = true;
|
|
mesh.receiveShadow = true;
|
|
mesh.userData = { cx, cz, isChunk: true };
|
|
return mesh;
|
|
}
|
|
|
|
createChunkGeometry(chunk) {
|
|
const { cx, cz, data } = chunk;
|
|
const vertices = [];
|
|
const normals = [];
|
|
const uvs = [];
|
|
const colors = [];
|
|
let materialGroups = []; // for multi-material
|
|
|
|
const dirs = [
|
|
[1, 0, 0], [-1, 0, 0], [0, 1, 0], [0, -1, 0], [0, 0, 1], [0, 0, -1]
|
|
];
|
|
const faceUVs = [ // per face (atlas coords for top/side/bottom variants)
|
|
// right, left, top, bottom, front, back
|
|
];
|
|
|
|
const addFace = (x, y, z, nx, ny, nz, blockType, faceIndex) => {
|
|
const tileIndex = this.getTileIndexForBlock(blockType, faceIndex);
|
|
const tileX = tileIndex % 8;
|
|
const tileY = Math.floor(tileIndex / 8);
|
|
|
|
const tileSize = 32 / 256; // 0.125
|
|
const ux = tileX * tileSize;
|
|
const uy = tileY * tileSize;
|
|
|
|
// 4 corners of quad
|
|
const corners = this.getFaceCorners(x, y, z, nx, ny, nz);
|
|
const normal = [nx, ny, nz];
|
|
|
|
// Two triangles
|
|
const indices = [0, 1, 2, 0, 2, 3];
|
|
for (let i = 0; i < 6; i++) {
|
|
const c = corners[indices[i]];
|
|
vertices.push(c[0], c[1], c[2]);
|
|
normals.push(...normal);
|
|
}
|
|
|
|
// UVs
|
|
const faceUvs = [[0, 0], [1, 0], [1, 1], [0, 1]];
|
|
for (let i = 0; i < 6; i++) {
|
|
const fi = indices[i];
|
|
uvs.push(ux + faceUvs[fi][0] * tileSize, uy + (1 - faceUvs[fi][1]) * tileSize);
|
|
}
|
|
|
|
// Slight color variation per block type
|
|
const tint = this.getBlockTint(blockType);
|
|
for (let k = 0; k < 6; k++) colors.push(...tint);
|
|
};
|
|
|
|
for (let lx = 0; lx < CHUNK_SIZE; lx++) {
|
|
for (let ly = 0; ly < CHUNK_HEIGHT; ly++) {
|
|
for (let lz = 0; lz < CHUNK_SIZE; lz++) {
|
|
const idx = lx + (ly * CHUNK_SIZE) + (lz * CHUNK_SIZE * CHUNK_HEIGHT);
|
|
const block = data[idx];
|
|
if (block === BLOCK.AIR) continue;
|
|
|
|
const wx = cx * CHUNK_SIZE + lx;
|
|
const wy = ly;
|
|
const wz = cz * CHUNK_SIZE + lz;
|
|
|
|
for (let f = 0; f < 6; f++) {
|
|
const [dx, dy, dz] = dirs[f];
|
|
const neighbor = this.getBlock(wx + dx, wy + dy, wz + dz);
|
|
if (neighbor === BLOCK.AIR || (block === BLOCK.LEAVES && neighbor === BLOCK.LEAVES)) {
|
|
// Special: leaves don't hide other leaves completely
|
|
if (neighbor !== BLOCK.AIR && block !== BLOCK.LEAVES) continue;
|
|
if (neighbor === BLOCK.LEAVES && block === BLOCK.LEAVES) {
|
|
// still render some internal for nice look? skip for perf
|
|
if ((lx + ly + lz) % 3 !== 0) continue;
|
|
}
|
|
addFace(lx, ly, lz, dx, dy, dz, block, f);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (vertices.length === 0) return { geometry: null, materials: null };
|
|
|
|
const geometry = new THREE.BufferGeometry();
|
|
geometry.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3));
|
|
geometry.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3));
|
|
geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
|
|
geometry.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3));
|
|
|
|
// Single material with vertex colors + map
|
|
const material = new THREE.MeshLambertMaterial({
|
|
map: this.atlas.texture,
|
|
vertexColors: true,
|
|
flatShading: false
|
|
});
|
|
|
|
return { geometry, materials: material };
|
|
}
|
|
|
|
getTileIndexForBlock(block, face) {
|
|
// 0 grassTop, 1 dirt, 2 stone, 3 sand, 4 woodTop, 5 woodSide, 6 leaves, 7 crystal, 8 darkstone, 9 moss
|
|
switch (block) {
|
|
case BLOCK.GRASS:
|
|
return (face === 2) ? 0 : (face === 3) ? 1 : 10; // top, bottom, sides
|
|
case BLOCK.DIRT: return 1;
|
|
case BLOCK.STONE: return 2;
|
|
case BLOCK.SAND: return 3;
|
|
case BLOCK.WOOD:
|
|
return (face === 2 || face === 3) ? 4 : 5;
|
|
case BLOCK.LEAVES: return 6;
|
|
case BLOCK.CRYSTAL: return 7;
|
|
case BLOCK.DARKSTONE: return 8;
|
|
case BLOCK.MOSS_STONE: return 9;
|
|
default: return 2;
|
|
}
|
|
}
|
|
|
|
getBlockTint(block) {
|
|
switch (block) {
|
|
case BLOCK.GRASS: return [0.95, 1.0, 0.88];
|
|
case BLOCK.LEAVES: return [0.75, 0.92, 0.72];
|
|
case BLOCK.CRYSTAL: return [0.85, 1.05, 1.25];
|
|
case BLOCK.DARKSTONE: return [0.75, 0.75, 0.82];
|
|
case BLOCK.MOSS_STONE: return [0.82, 0.95, 0.78];
|
|
case BLOCK.WOOD: return [0.95, 0.88, 0.75];
|
|
default: return [1, 1, 1];
|
|
}
|
|
}
|
|
|
|
getFaceCorners(x, y, z, nx, ny, nz) {
|
|
// Return 4 corners of the face in consistent winding
|
|
if (nx === 1) return [[x+1,y,z], [x+1,y,z+1], [x+1,y+1,z+1], [x+1,y+1,z]];
|
|
if (nx === -1) return [[x,y,z+1], [x,y,z], [x,y+1,z], [x,y+1,z+1]];
|
|
if (ny === 1) return [[x,y+1,z], [x+1,y+1,z], [x+1,y+1,z+1], [x,y+1,z+1]];
|
|
if (ny === -1) return [[x,y,z+1], [x+1,y,z+1], [x+1,y,z], [x,y,z]];
|
|
if (nz === 1) return [[x+1,y,z+1], [x,y,z+1], [x,y+1,z+1], [x+1,y+1,z+1]];
|
|
if (nz === -1) return [[x,y,z], [x+1,y,z], [x+1,y+1,z], [x,y+1,z]];
|
|
return [[x,y,z],[x+1,y,z],[x+1,y+1,z],[x,y+1,z]];
|
|
}
|
|
|
|
remeshChunk(cx, cz) {
|
|
const key = this.getChunkKey(cx, cz);
|
|
const oldMesh = this.chunkMeshes.get(key);
|
|
if (oldMesh) {
|
|
this.scene.remove(oldMesh);
|
|
oldMesh.geometry.dispose();
|
|
}
|
|
|
|
const chunk = this.chunks.get(key);
|
|
if (!chunk) return;
|
|
|
|
const newMesh = this.buildChunkMesh(cx, cz);
|
|
if (newMesh) {
|
|
this.scene.add(newMesh);
|
|
this.chunkMeshes.set(key, newMesh);
|
|
}
|
|
}
|
|
|
|
updateVisibleChunks(playerX, playerZ, scene) {
|
|
this.scene = scene;
|
|
const pcx = Math.floor(playerX / CHUNK_SIZE);
|
|
const pcz = Math.floor(playerZ / CHUNK_SIZE);
|
|
|
|
const needed = new Set();
|
|
for (let dx = -VIEW_DISTANCE; dx <= VIEW_DISTANCE; dx++) {
|
|
for (let dz = -VIEW_DISTANCE; dz <= VIEW_DISTANCE; dz++) {
|
|
const cx = pcx + dx;
|
|
const cz = pcz + dz;
|
|
needed.add(this.getChunkKey(cx, cz));
|
|
|
|
if (!this.chunks.has(this.getChunkKey(cx, cz))) {
|
|
this.ensureChunk(cx, cz);
|
|
}
|
|
if (!this.chunkMeshes.has(this.getChunkKey(cx, cz))) {
|
|
const mesh = this.buildChunkMesh(cx, cz);
|
|
if (mesh) {
|
|
scene.add(mesh);
|
|
this.chunkMeshes.set(this.getChunkKey(cx, cz), mesh);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Unload far chunks (keep memory reasonable)
|
|
for (const [key, mesh] of this.chunkMeshes) {
|
|
if (!needed.has(key)) {
|
|
scene.remove(mesh);
|
|
mesh.geometry.dispose();
|
|
this.chunkMeshes.delete(key);
|
|
}
|
|
}
|
|
// We keep chunk data around for now (can be optimized further)
|
|
}
|
|
|
|
// For collision & placement
|
|
getBlockWorld(x, y, z) {
|
|
return this.getBlock(Math.floor(x), Math.floor(y), Math.floor(z));
|
|
}
|
|
}
|
|
|
|
// ---------------------- PLAYER & CONTROLS ----------------------
|
|
class Player {
|
|
constructor(camera, world) {
|
|
this.camera = camera;
|
|
this.world = world;
|
|
this.position = new THREE.Vector3(8, 42, 8);
|
|
this.velocity = new THREE.Vector3();
|
|
this.onGround = false;
|
|
this.height = 1.7;
|
|
this.radius = 0.3;
|
|
this.speed = 7.2;
|
|
this.jumpSpeed = 9.5;
|
|
this.gravity = 26;
|
|
|
|
this.yaw = -0.8;
|
|
this.pitch = 0.1;
|
|
this.sensitivity = 0.0022;
|
|
|
|
this.keys = {};
|
|
this.updateCamera();
|
|
}
|
|
|
|
updateCamera() {
|
|
this.camera.position.copy(this.position);
|
|
this.camera.position.y += this.height;
|
|
this.camera.rotation.order = 'YXZ';
|
|
this.camera.rotation.y = this.yaw;
|
|
this.camera.rotation.x = this.pitch;
|
|
}
|
|
|
|
applyInput(dt) {
|
|
const forward = new THREE.Vector3(
|
|
-Math.sin(this.yaw), 0, -Math.cos(this.yaw)
|
|
);
|
|
const right = new THREE.Vector3(
|
|
Math.cos(this.yaw), 0, -Math.sin(this.yaw)
|
|
);
|
|
|
|
let moveX = 0, moveZ = 0;
|
|
if (this.keys['KeyW']) moveZ += 1; // fix: W/S were inverted — `forward` already points where the camera looks, so W must add +forward (it walked backward before)
|
|
if (this.keys['KeyS']) moveZ -= 1;
|
|
if (this.keys['KeyA']) moveX -= 1;
|
|
if (this.keys['KeyD']) moveX += 1;
|
|
|
|
const len = Math.hypot(moveX, moveZ);
|
|
if (len > 0) {
|
|
moveX /= len; moveZ /= len;
|
|
}
|
|
|
|
const accel = this.speed * (this.onGround ? 1.0 : 0.3);
|
|
const desiredX = (right.x * moveX + forward.x * moveZ) * accel;
|
|
const desiredZ = (right.z * moveX + forward.z * moveZ) * accel;
|
|
|
|
this.velocity.x = this.lerp(this.velocity.x, desiredX, 0.25);
|
|
this.velocity.z = this.lerp(this.velocity.z, desiredZ, 0.25);
|
|
|
|
// Gravity
|
|
this.velocity.y -= this.gravity * dt;
|
|
|
|
// Integrate + collision (axis separated for correctness)
|
|
let nextX = this.position.x + this.velocity.x * dt;
|
|
let nextY = this.position.y + this.velocity.y * dt;
|
|
let nextZ = this.position.z + this.velocity.z * dt;
|
|
|
|
// Resolve X then Z then Y (standard voxel approach)
|
|
nextX = this.resolveAxis(this.position.x, this.position.y, this.position.z, nextX, this.position.y, this.position.z, 'x');
|
|
nextZ = this.resolveAxis(nextX, this.position.y, this.position.z, nextX, this.position.y, nextZ, 'z');
|
|
|
|
const wasY = nextY;
|
|
nextY = this.resolveAxis(nextX, this.position.y, nextZ, nextX, nextY, nextZ, 'y');
|
|
|
|
this.onGround = (wasY > nextY + 0.001);
|
|
if (this.onGround) {
|
|
this.velocity.y = Math.max(0, this.velocity.y);
|
|
}
|
|
|
|
this.position.set(nextX, nextY, nextZ);
|
|
|
|
// head bob subtle
|
|
const bob = this.onGround && (Math.abs(this.velocity.x) + Math.abs(this.velocity.z) > 0.3) ?
|
|
Math.sin(performance.now() / 180) * 0.03 : 0;
|
|
|
|
this.camera.position.set(this.position.x, this.position.y + this.height + bob, this.position.z);
|
|
this.camera.rotation.y = this.yaw;
|
|
this.camera.rotation.x = this.pitch;
|
|
}
|
|
|
|
resolveAxis(px, py, pz, nx, ny, nz, axis) {
|
|
// Resolve collision only on the requested axis while using the final values of the others
|
|
const minX = Math.floor(Math.min(px, nx) - this.radius) - 1;
|
|
const maxX = Math.floor(Math.max(px, nx) + this.radius) + 1;
|
|
const minY = Math.floor(Math.min(py, ny)) - 1;
|
|
const maxY = Math.floor(Math.max(py, ny) + this.height) + 1;
|
|
const minZ = Math.floor(Math.min(pz, nz) - this.radius) - 1;
|
|
const maxZ = Math.floor(Math.max(pz, nz) + this.radius) + 1;
|
|
|
|
let finalX = nx;
|
|
let finalY = ny;
|
|
let finalZ = nz;
|
|
|
|
for (let bx = minX; bx <= maxX; bx++) {
|
|
for (let by = minY; by <= maxY; by++) {
|
|
for (let bz = minZ; bz <= maxZ; bz++) {
|
|
const b = this.world.getBlock(bx, by, bz);
|
|
if (b === BLOCK.AIR) continue;
|
|
|
|
const bMinX = bx, bMaxX = bx + 1;
|
|
const bMinY = by, bMaxY = by + 1;
|
|
const bMinZ = bz, bMaxZ = bz + 1;
|
|
|
|
if (axis === 'x') {
|
|
// Check overlap on Y and Z
|
|
if (finalY + this.height > bMinY && finalY < bMaxY &&
|
|
finalZ + this.radius > bMinZ && finalZ - this.radius < bMaxZ) {
|
|
// Overlap on X?
|
|
if (finalX + this.radius > bMinX && finalX - this.radius < bMaxX) {
|
|
if (px + this.radius <= bMinX) finalX = bMinX - this.radius;
|
|
else if (px - this.radius >= bMaxX) finalX = bMaxX + this.radius;
|
|
}
|
|
}
|
|
} else if (axis === 'z') {
|
|
if (finalY + this.height > bMinY && finalY < bMaxY &&
|
|
finalX + this.radius > bMinX && finalX - this.radius < bMaxX) {
|
|
if (finalZ + this.radius > bMinZ && finalZ - this.radius < bMaxZ) {
|
|
if (pz + this.radius <= bMinZ) finalZ = bMinZ - this.radius;
|
|
else if (pz - this.radius >= bMaxZ) finalZ = bMaxZ + this.radius;
|
|
}
|
|
}
|
|
} else if (axis === 'y') {
|
|
if (finalX + this.radius > bMinX && finalX - this.radius < bMaxX &&
|
|
finalZ + this.radius > bMinZ && finalZ - this.radius < bMaxZ) {
|
|
if (finalY + this.height > bMinY && finalY < bMaxY) {
|
|
if (py + this.height <= bMinY) finalY = bMinY - this.height;
|
|
else if (py >= bMaxY) finalY = bMaxY;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (axis === 'x') return finalX;
|
|
if (axis === 'z') return finalZ;
|
|
return finalY;
|
|
}
|
|
|
|
lerp(a, b, t) { return a + (b - a) * t; }
|
|
|
|
tryJump() {
|
|
if (this.onGround) {
|
|
this.velocity.y = this.jumpSpeed;
|
|
this.onGround = false;
|
|
}
|
|
}
|
|
|
|
// Raycast for block interaction (accurate face detection)
|
|
getTargetBlock(maxDist = 6) {
|
|
const dir = new THREE.Vector3();
|
|
this.camera.getWorldDirection(dir);
|
|
|
|
let x = this.camera.position.x;
|
|
let y = this.camera.position.y;
|
|
let z = this.camera.position.z;
|
|
|
|
const step = 0.02;
|
|
let lastX = Math.floor(x), lastY = Math.floor(y), lastZ = Math.floor(z);
|
|
|
|
for (let d = 0; d < maxDist; d += step) {
|
|
x += dir.x * step;
|
|
y += dir.y * step;
|
|
z += dir.z * step;
|
|
|
|
const bx = Math.floor(x);
|
|
const by = Math.floor(y);
|
|
const bz = Math.floor(z);
|
|
|
|
if (bx !== lastX || by !== lastY || bz !== lastZ) {
|
|
const block = this.world.getBlock(bx, by, bz);
|
|
if (block !== BLOCK.AIR) {
|
|
// Determine which face was hit
|
|
const fx = x - bx - 0.5;
|
|
const fy = y - by - 0.5;
|
|
const fz = z - bz - 0.5;
|
|
|
|
let face = 0;
|
|
let maxComp = Math.abs(fx);
|
|
if (Math.abs(fy) > maxComp) { maxComp = Math.abs(fy); face = 1; }
|
|
if (Math.abs(fz) > maxComp) { maxComp = Math.abs(fz); face = 2; }
|
|
|
|
let nx = 0, ny = 0, nz = 0;
|
|
if (face === 0) nx = fx > 0 ? 1 : -1;
|
|
else if (face === 1) ny = fy > 0 ? 1 : -1;
|
|
else nz = fz > 0 ? 1 : -1;
|
|
|
|
return {
|
|
x: bx, y: by, z: bz,
|
|
normal: { x: nx, y: ny, z: nz },
|
|
block
|
|
};
|
|
}
|
|
lastX = bx; lastY = by; lastZ = bz;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ---------------------- MAIN GAME ----------------------
|
|
let scene, camera, renderer;
|
|
let world, player;
|
|
let atlas;
|
|
let selectedBlock = BLOCK.GRASS;
|
|
const hotbarBlocks = [BLOCK.GRASS, BLOCK.DIRT, BLOCK.STONE, BLOCK.WOOD, BLOCK.SAND, BLOCK.LEAVES, BLOCK.CRYSTAL, BLOCK.DARKSTONE];
|
|
|
|
let isLocked = false;
|
|
let lastTime = performance.now();
|
|
let highlightMesh = null;
|
|
|
|
function init() {
|
|
const canvas = document.getElementById('game');
|
|
renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
|
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
|
renderer.setSize(window.innerWidth, window.innerHeight);
|
|
renderer.shadowMap.enabled = true;
|
|
|
|
scene = new THREE.Scene();
|
|
scene.fog = new THREE.Fog(0x8aa7c0, 42, 128);
|
|
|
|
camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 300);
|
|
|
|
// Lighting - nice daytime fantasy
|
|
const hemi = new THREE.HemisphereLight(0xbfd4ff, 0x3a2f1f, 0.75);
|
|
scene.add(hemi);
|
|
|
|
const sun = new THREE.DirectionalLight(0xfff4d9, 0.95);
|
|
sun.position.set(60, 120, 40);
|
|
sun.castShadow = true;
|
|
sun.shadow.mapSize.width = 1024;
|
|
sun.shadow.mapSize.height = 1024;
|
|
sun.shadow.camera.near = 10;
|
|
sun.shadow.camera.far = 300;
|
|
sun.shadow.camera.left = -80;
|
|
sun.shadow.camera.right = 80;
|
|
sun.shadow.camera.top = 80;
|
|
sun.shadow.camera.bottom = -80;
|
|
scene.add(sun);
|
|
|
|
const ambient = new THREE.AmbientLight(0x556677, 0.35);
|
|
scene.add(ambient);
|
|
|
|
// Sky dome (simple gradient)
|
|
const skyGeo = new THREE.SphereGeometry(200, 32, 32);
|
|
const skyMat = new THREE.ShaderMaterial({
|
|
side: THREE.BackSide,
|
|
uniforms: {
|
|
topColor: { value: new THREE.Color(0x6aa3d9) },
|
|
bottomColor: { value: new THREE.Color(0x9fc4e8) },
|
|
offset: { value: 40 },
|
|
exponent: { value: 0.7 }
|
|
},
|
|
vertexShader: `
|
|
varying vec3 vWorldPosition;
|
|
void main() {
|
|
vec4 worldPosition = modelMatrix * vec4(position, 1.0);
|
|
vWorldPosition = worldPosition.xyz;
|
|
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
|
}
|
|
`,
|
|
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);
|
|
}
|
|
`
|
|
});
|
|
const sky = new THREE.Mesh(skyGeo, skyMat);
|
|
scene.add(sky);
|
|
|
|
// Atlas
|
|
atlas = createTextureAtlas();
|
|
|
|
// World
|
|
world = new World();
|
|
world.atlas = atlas;
|
|
|
|
// Player - spawn near the beautiful Aether Spire
|
|
player = new Player(camera, world);
|
|
// Find solid ground near spire
|
|
let spawnY = 70;
|
|
for (let y = 80; y > 5; y--) {
|
|
if (world.getBlock(8, y, 8) !== BLOCK.AIR && world.getBlock(8, y + 1, 8) === BLOCK.AIR) {
|
|
spawnY = y + 1;
|
|
break;
|
|
}
|
|
}
|
|
player.position.set(7.5, spawnY, 6.5);
|
|
|
|
// Initial chunks around spawn
|
|
world.updateVisibleChunks(player.position.x, player.position.z, scene);
|
|
|
|
// Block highlight (bright selection outline)
|
|
const highlightGeo = new THREE.BoxGeometry(1.004, 1.004, 1.004);
|
|
const highlightMat = new THREE.MeshBasicMaterial({
|
|
color: 0x55eeff,
|
|
wireframe: true,
|
|
transparent: true,
|
|
opacity: 0.75,
|
|
depthTest: false
|
|
});
|
|
highlightMesh = new THREE.Mesh(highlightGeo, highlightMat);
|
|
highlightMesh.visible = false;
|
|
scene.add(highlightMesh);
|
|
|
|
// UI
|
|
setupHotbar();
|
|
updateBlockName();
|
|
|
|
// Events
|
|
window.addEventListener('resize', onResize);
|
|
document.addEventListener('pointerlockchange', onPointerLockChange);
|
|
canvas.addEventListener('click', () => {
|
|
if (!isLocked) canvas.requestPointerLock();
|
|
});
|
|
document.addEventListener('keydown', onKeyDown);
|
|
document.addEventListener('keyup', onKeyUp);
|
|
document.addEventListener('mousemove', onMouseMove);
|
|
document.addEventListener('mousedown', onMouseDown);
|
|
document.addEventListener('wheel', onWheel);
|
|
|
|
// Start message
|
|
setTimeout(() => {
|
|
const info = document.getElementById('info');
|
|
info.style.transition = 'opacity 1.2s';
|
|
}, 8000);
|
|
|
|
animate();
|
|
}
|
|
|
|
function setupHotbar() {
|
|
const hud = document.getElementById('hud');
|
|
hud.innerHTML = '';
|
|
hotbarBlocks.forEach((block, i) => {
|
|
const slot = document.createElement('div');
|
|
slot.className = 'slot' + (i === 0 ? ' active' : '');
|
|
slot.dataset.index = i;
|
|
|
|
const icon = document.createElement('div');
|
|
icon.className = 'icon';
|
|
icon.style.background = getBlockPreviewStyle(block);
|
|
slot.appendChild(icon);
|
|
|
|
slot.addEventListener('click', () => {
|
|
selectBlock(i);
|
|
});
|
|
|
|
hud.appendChild(slot);
|
|
});
|
|
}
|
|
|
|
function getBlockPreviewStyle(block) {
|
|
const colors = {
|
|
[BLOCK.GRASS]: 'linear-gradient(#5a9c3f 55%, #8b5a2b 55%)',
|
|
[BLOCK.DIRT]: '#8b5a2b',
|
|
[BLOCK.STONE]: '#7a7a7a',
|
|
[BLOCK.WOOD]: '#8c6642',
|
|
[BLOCK.SAND]: '#d9c48a',
|
|
[BLOCK.LEAVES]: '#2f6b2f',
|
|
[BLOCK.CRYSTAL]: 'linear-gradient(#7ce8ff, #3a7a9e)',
|
|
[BLOCK.DARKSTONE]: '#2f2f38'
|
|
};
|
|
return colors[block] || '#555';
|
|
}
|
|
|
|
function selectBlock(index) {
|
|
selectedBlock = hotbarBlocks[index];
|
|
document.querySelectorAll('.slot').forEach((s, i) => {
|
|
s.classList.toggle('active', i === index);
|
|
});
|
|
updateBlockName();
|
|
}
|
|
|
|
function updateBlockName() {
|
|
const el = document.getElementById('blockName');
|
|
el.textContent = BLOCK_NAMES[selectedBlock] || '未知方块';
|
|
}
|
|
|
|
function onResize() {
|
|
camera.aspect = window.innerWidth / window.innerHeight;
|
|
camera.updateProjectionMatrix();
|
|
renderer.setSize(window.innerWidth, window.innerHeight);
|
|
}
|
|
|
|
function onPointerLockChange() {
|
|
isLocked = document.pointerLockElement !== null;
|
|
}
|
|
|
|
function onKeyDown(e) {
|
|
player.keys[e.code] = true;
|
|
|
|
if (e.code === 'Space') {
|
|
e.preventDefault();
|
|
player.tryJump();
|
|
}
|
|
if (e.code === 'KeyP') {
|
|
const canvas = document.getElementById('game');
|
|
if (isLocked) document.exitPointerLock();
|
|
else canvas.requestPointerLock();
|
|
}
|
|
if (e.code === 'KeyR') {
|
|
// Respawn near the beautiful spire
|
|
player.position.set(6, 52, 7);
|
|
player.velocity.set(0, 0, 0);
|
|
}
|
|
|
|
// Number keys 1-8
|
|
if (e.code.startsWith('Digit')) {
|
|
const n = parseInt(e.code.slice(5)) - 1;
|
|
if (n >= 0 && n < hotbarBlocks.length) {
|
|
selectBlock(n);
|
|
}
|
|
}
|
|
}
|
|
|
|
function onKeyUp(e) {
|
|
player.keys[e.code] = false;
|
|
}
|
|
|
|
function onMouseMove(e) {
|
|
if (!isLocked) return;
|
|
player.yaw -= e.movementX * player.sensitivity;
|
|
player.pitch -= e.movementY * player.sensitivity;
|
|
player.pitch = Math.max(-1.5, Math.min(1.5, player.pitch));
|
|
}
|
|
|
|
function onMouseDown(e) {
|
|
if (!isLocked) {
|
|
document.getElementById('game').requestPointerLock();
|
|
return;
|
|
}
|
|
|
|
const target = player.getTargetBlock(7);
|
|
if (!target) return;
|
|
|
|
if (e.button === 0) { // Left - break
|
|
if (target.block !== BLOCK.AIR) {
|
|
world.setBlock(target.x, target.y, target.z, BLOCK.AIR);
|
|
// small particle effect
|
|
spawnBreakParticles(target.x + 0.5, target.y + 0.5, target.z + 0.5, target.block);
|
|
}
|
|
} else if (e.button === 2) { // Right - place
|
|
e.preventDefault();
|
|
const nx = target.x + target.normal.x;
|
|
const ny = target.y + target.normal.y;
|
|
const nz = target.z + target.normal.z;
|
|
|
|
// Don't place inside player
|
|
const p = player.position;
|
|
if (nx + 0.5 > p.x - 0.4 && nx + 0.5 < p.x + 0.4 &&
|
|
ny + 0.5 > p.y && ny + 0.5 < p.y + player.height &&
|
|
nz + 0.5 > p.z - 0.4 && nz + 0.5 < p.z + 0.4) {
|
|
return;
|
|
}
|
|
if (world.getBlock(nx, ny, nz) === BLOCK.AIR) {
|
|
world.setBlock(nx, ny, nz, selectedBlock);
|
|
}
|
|
}
|
|
}
|
|
|
|
function onWheel(e) {
|
|
const current = hotbarBlocks.indexOf(selectedBlock);
|
|
let next = current + (e.deltaY > 0 ? 1 : -1);
|
|
next = (next + hotbarBlocks.length) % hotbarBlocks.length;
|
|
selectBlock(next);
|
|
}
|
|
|
|
function spawnBreakParticles(x, y, z, blockType) {
|
|
const color = (blockType === BLOCK.GRASS || blockType === BLOCK.LEAVES) ? 0x5a9c3f :
|
|
(blockType === BLOCK.CRYSTAL) ? 0x7ce8ff : 0x888888;
|
|
|
|
const count = 12;
|
|
const geo = new THREE.BufferGeometry();
|
|
const pos = new Float32Array(count * 3);
|
|
const vel = [];
|
|
|
|
for (let i = 0; i < count; i++) {
|
|
pos[i * 3] = x + (Math.random() - 0.5) * 0.7;
|
|
pos[i * 3 + 1] = y + (Math.random() - 0.5) * 0.7;
|
|
pos[i * 3 + 2] = z + (Math.random() - 0.5) * 0.7;
|
|
vel.push(
|
|
(Math.random() - 0.5) * 4.5,
|
|
Math.random() * 5 + 1,
|
|
(Math.random() - 0.5) * 4.5
|
|
);
|
|
}
|
|
geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
|
|
|
|
const mat = new THREE.PointsMaterial({ size: 0.12, color, transparent: true, opacity: 0.9 });
|
|
const points = new THREE.Points(geo, mat);
|
|
scene.add(points);
|
|
|
|
const start = performance.now();
|
|
const lifetime = 650;
|
|
|
|
function update() {
|
|
const t = (performance.now() - start) / lifetime;
|
|
if (t > 1) {
|
|
scene.remove(points);
|
|
return;
|
|
}
|
|
const positions = geo.attributes.position;
|
|
for (let i = 0; i < count; i++) {
|
|
positions.array[i * 3] += vel[i * 3] * 0.016;
|
|
positions.array[i * 3 + 1] += vel[i * 3 + 1] * 0.016 - 9.8 * t * 0.016;
|
|
positions.array[i * 3 + 2] += vel[i * 3 + 2] * 0.016;
|
|
}
|
|
positions.needsUpdate = true;
|
|
mat.opacity = 0.9 * (1 - t);
|
|
requestAnimationFrame(update);
|
|
}
|
|
update();
|
|
}
|
|
|
|
function updateChunksIfNeeded() {
|
|
world.updateVisibleChunks(player.position.x, player.position.z, scene);
|
|
}
|
|
|
|
function animate() {
|
|
requestAnimationFrame(animate);
|
|
|
|
const now = performance.now();
|
|
const dt = Math.min((now - lastTime) / 1000, 0.05);
|
|
lastTime = now;
|
|
|
|
player.applyInput(dt);
|
|
|
|
// Chunk streaming
|
|
const cx = Math.floor(player.position.x / CHUNK_SIZE);
|
|
const cz = Math.floor(player.position.z / CHUNK_SIZE);
|
|
if (cx !== (window._lastCx || 9999) || cz !== (window._lastCz || 9999) || Math.random() < 0.012) {
|
|
window._lastCx = cx;
|
|
window._lastCz = cz;
|
|
updateChunksIfNeeded();
|
|
}
|
|
|
|
// Update block highlight (target)
|
|
if (highlightMesh) {
|
|
const target = player.getTargetBlock(6.5);
|
|
if (target && target.block !== BLOCK.AIR) {
|
|
highlightMesh.position.set(target.x + 0.5, target.y + 0.5, target.z + 0.5);
|
|
highlightMesh.visible = true;
|
|
} else {
|
|
highlightMesh.visible = false;
|
|
}
|
|
}
|
|
|
|
renderer.render(scene, camera);
|
|
}
|
|
|
|
// Prevent context menu on right click
|
|
document.addEventListener('contextmenu', e => e.preventDefault());
|
|
|
|
// Boot
|
|
window.onload = init;
|
|
</script>
|
|
</body>
|
|
</html>
|