Files
nagi-bench/outputs/grok-build/mythos-craft.html
T
Jesse ZhangandClaude Fable 5 cd2af84f34 Viewer polish, cursor fix, Grok rename + W/S fix, Composer G teleport
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>
2026-06-10 00:34:24 +01:00

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>