Files
nagi-bench/outputs/claude-opus-4-7/cursor-max/mythos-craft.html
T
2026-06-14 00:31:16 +01:00

2251 lines
76 KiB
HTML

<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>MythosCraft · 神话之地</title>
<style>
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; overflow: hidden; background: #000; }
body {
font-family: 'Inter', 'PingFang SC', 'Microsoft YaHei', system-ui, -apple-system, sans-serif;
color: #f0e9d8;
user-select: none;
-webkit-user-select: none;
}
canvas#game { display: block; width: 100%; height: 100%; cursor: none; }
/* ===== HUD ===== */
#hud { position: fixed; inset: 0; pointer-events: none; z-index: 10; }
#crosshair {
position: absolute; left: 50%; top: 50%;
width: 18px; height: 18px;
transform: translate(-50%, -50%);
mix-blend-mode: difference;
}
#crosshair::before, #crosshair::after {
content: ''; position: absolute; background: rgba(255,255,255,0.95);
}
#crosshair::before { left: 50%; top: 0; width: 2px; height: 100%; transform: translateX(-50%); }
#crosshair::after { top: 50%; left: 0; height: 2px; width: 100%; transform: translateY(-50%); }
#hotbar {
position: absolute;
bottom: 18px; left: 50%; transform: translateX(-50%);
display: flex; gap: 4px;
padding: 4px;
background: rgba(15,12,20,0.55);
border: 2px solid rgba(255,255,255,0.18);
border-radius: 4px;
backdrop-filter: blur(6px);
}
.slot {
width: 48px; height: 48px;
background: rgba(0,0,0,0.4);
border: 2px solid rgba(255,255,255,0.08);
position: relative;
image-rendering: pixelated;
image-rendering: crisp-edges;
overflow: hidden;
}
.slot.active { border-color: #f8e7a8; box-shadow: 0 0 0 2px rgba(248,231,168,0.4), inset 0 0 0 1px rgba(255,255,255,0.3); transform: translateY(-2px); }
.slot-icon { width: 100%; height: 100%; image-rendering: pixelated; }
.slot-num { position: absolute; top: 2px; left: 4px; font-size: 10px; color: #f8e7a8; font-weight: 700; text-shadow: 1px 1px 0 #000; }
#blockname {
position: absolute; bottom: 78px; left: 50%; transform: translateX(-50%);
font-size: 13px; letter-spacing: 0.04em;
color: #f8e7a8;
background: rgba(15,12,20,0.7);
padding: 4px 10px;
border-radius: 3px;
opacity: 0; transition: opacity 0.5s ease-out 0.5s;
text-shadow: 0 1px 2px rgba(0,0,0,0.8);
}
#blockname.show { opacity: 1; transition: opacity 0.1s ease-in; }
#debug {
position: absolute; top: 8px; left: 8px;
font-family: 'JetBrains Mono', ui-monospace, 'SFMono-Regular', Menlo, monospace;
font-size: 11px;
color: #d4e5f0;
text-shadow: 1px 1px 0 #000, 0 0 4px rgba(0,0,0,0.9);
line-height: 1.5;
pointer-events: none;
opacity: 0.9;
}
#debug.hidden { display: none; }
#compass {
position: absolute; top: 12px; left: 50%; transform: translateX(-50%);
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 12px;
color: rgba(255,255,255,0.55);
text-shadow: 1px 1px 0 rgba(0,0,0,0.8);
letter-spacing: 0.5em;
}
/* ===== Overlay screens ===== */
#overlay {
position: fixed; inset: 0; z-index: 100;
display: flex; align-items: center; justify-content: center;
background: linear-gradient(140deg, #0d0b1a 0%, #1a1428 50%, #251a35 100%);
transition: opacity 0.6s ease;
}
#overlay.hidden { opacity: 0; pointer-events: none; }
#overlay::before {
content: ''; position: absolute; inset: 0;
background-image:
radial-gradient(circle at 20% 30%, rgba(248,231,168,0.08) 0%, transparent 50%),
radial-gradient(circle at 75% 70%, rgba(178,141,255,0.08) 0%, transparent 50%);
}
#overlay > * { position: relative; z-index: 1; }
.intro {
text-align: center; max-width: 720px; padding: 32px;
}
.intro h1 {
font-family: 'Cinzel', 'PingFang SC', serif;
font-size: clamp(40px, 7vw, 76px);
font-weight: 900;
letter-spacing: 0.08em;
background: linear-gradient(180deg, #fff5d6 0%, #f8c878 50%, #8b6428 100%);
-webkit-background-clip: text; background-clip: text;
color: transparent;
margin-bottom: 8px;
filter: drop-shadow(0 4px 12px rgba(248,200,120,0.3));
}
.intro h1 small {
display: block;
font-family: 'PingFang SC', sans-serif;
font-size: 0.35em;
font-weight: 300;
letter-spacing: 0.6em;
color: #b8a98a;
margin-top: 4px;
background: none;
-webkit-text-fill-color: #b8a98a;
}
.intro p.sub {
font-size: 14px;
letter-spacing: 0.2em;
color: #b8a98a;
margin-bottom: 32px;
}
.controls {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 10px;
font-size: 13px;
margin-bottom: 36px;
color: #d4c79c;
}
.controls > div { background: rgba(0,0,0,0.3); border: 1px solid rgba(248,231,168,0.15); padding: 8px 12px; border-radius: 3px; }
.controls kbd {
display: inline-block;
background: linear-gradient(180deg, #3a3550 0%, #251f3a 100%);
border: 1px solid rgba(248,231,168,0.3);
color: #f8e7a8;
padding: 1px 8px;
border-radius: 3px;
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
min-width: 20px; text-align: center;
margin-right: 6px;
box-shadow: 0 2px 0 rgba(0,0,0,0.4);
}
#start {
pointer-events: auto;
font-family: 'Cinzel', 'PingFang SC', serif;
font-size: 16px;
letter-spacing: 0.3em;
font-weight: 700;
color: #1a1428;
background: linear-gradient(180deg, #f8e7a8 0%, #d4a843 100%);
border: none;
padding: 14px 48px;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 4px 20px rgba(248,200,120,0.3), inset 0 1px 0 rgba(255,255,255,0.6);
}
#start:hover {
transform: translateY(-2px);
box-shadow: 0 8px 30px rgba(248,200,120,0.5), inset 0 1px 0 rgba(255,255,255,0.7);
}
#start:active { transform: translateY(0); }
#loading { text-align: center; }
#loading h2 {
font-family: 'Cinzel', serif;
font-weight: 400;
color: #f8e7a8;
margin-bottom: 16px;
letter-spacing: 0.2em;
}
.progress {
width: 320px;
height: 6px;
background: rgba(0,0,0,0.4);
border: 1px solid rgba(248,231,168,0.2);
border-radius: 3px;
overflow: hidden;
margin: 0 auto;
}
#progress-bar {
height: 100%; width: 0%;
background: linear-gradient(90deg, #f8e7a8 0%, #d4a843 100%);
transition: width 0.2s ease;
box-shadow: 0 0 12px rgba(248,200,120,0.6);
}
#progress-text { margin-top: 12px; font-size: 12px; color: #b8a98a; }
#pause { display: none; }
#pause.show { display: flex; }
#pause-card {
background: rgba(15,12,20,0.92);
border: 1px solid rgba(248,231,168,0.3);
padding: 36px 48px;
border-radius: 6px;
text-align: center;
backdrop-filter: blur(8px);
pointer-events: auto;
}
#pause-card h2 {
font-family: 'Cinzel', serif;
color: #f8e7a8;
margin-bottom: 24px;
letter-spacing: 0.3em;
}
#pause-card button {
pointer-events: auto;
background: linear-gradient(180deg, #f8e7a8 0%, #d4a843 100%);
color: #1a1428;
border: none;
padding: 12px 36px;
font-size: 14px;
letter-spacing: 0.2em;
font-family: 'Cinzel', serif;
font-weight: 700;
cursor: pointer;
border-radius: 3px;
}
/* ===== Damage / break effect ===== */
#break-overlay {
position: absolute; left: 50%; top: 50%;
transform: translate(-50%, -50%);
width: 80px; height: 80px;
pointer-events: none;
opacity: 0;
border: 2px dashed rgba(255,255,255,0.8);
border-radius: 2px;
}
#break-overlay.show { opacity: 0.6; animation: shake 0.15s; }
@keyframes shake {
0%,100% { transform: translate(-50%, -50%); }
25% { transform: translate(-52%, -50%); }
75% { transform: translate(-48%, -50%); }
}
/* Vignette */
#vignette {
position: fixed; inset: 0; pointer-events: none; z-index: 9;
background: radial-gradient(ellipse at center, transparent 50%, rgba(0,0,0,0.4) 100%);
}
</style>
</head>
<body>
<canvas id="game"></canvas>
<div id="vignette"></div>
<div id="hud">
<div id="debug" class="hidden"></div>
<div id="compass"></div>
<div id="crosshair"></div>
<div id="break-overlay"></div>
<div id="blockname"></div>
<div id="hotbar"></div>
</div>
<div id="overlay">
<div class="intro" id="intro">
<h1>MYTHOS CRAFT<small>神 话 之 地</small></h1>
<p class="sub">An ancient world of myths, towers, and dragon-temples</p>
<div class="controls">
<div><kbd>W A S D</kbd>移动 / Move</div>
<div><kbd>SPACE</kbd>跳跃 / Jump</div>
<div><kbd>SHIFT</kbd>加速 / Sprint</div>
<div><kbd>MOUSE</kbd>视角 / Look</div>
<div><kbd>L-CLICK</kbd>破坏 / Break</div>
<div><kbd>R-CLICK</kbd>放置 / Place</div>
<div><kbd>1 - 9</kbd>选择方块 / Select</div>
<div><kbd>F</kbd>飞行 / Fly</div>
<div><kbd>F3</kbd>调试信息 / Debug</div>
<div><kbd>ESC</kbd>暂停 / Pause</div>
</div>
<button id="start">点 击 进 入 神 话 之 地</button>
</div>
<div id="loading" style="display:none">
<h2>正 在 生 成 神 话 之 地</h2>
<div class="progress"><div id="progress-bar"></div></div>
<div id="progress-text">生成地形 chunks...</div>
</div>
</div>
<div id="pause" class="hidden">
<div id="pause-card">
<h2>已 暂 停</h2>
<button id="resume">点 击 继 续 探 索</button>
</div>
</div>
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/three@0.160.0/build/three.module.js",
"three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/"
}
}
</script>
<script type="module">
import * as THREE from 'three';
import { PointerLockControls } from 'three/addons/controls/PointerLockControls.js';
/* =========================================================================
Constants
========================================================================= */
const CHUNK_SIZE = 16;
const CHUNK_HEIGHT = 96;
const SEA_LEVEL = 32;
const RENDER_DISTANCE = 6;
const WORLD_SEED = 20260613;
const GRAVITY = 28;
const JUMP_SPEED = 9.5;
const WALK_SPEED = 5.0;
const SPRINT_SPEED = 9.0;
const FLY_SPEED = 14.0;
const PLAYER_HEIGHT = 1.7;
const PLAYER_RADIUS = 0.32;
const REACH = 6.0;
/* =========================================================================
Block Definitions
========================================================================= */
const Block = Object.freeze({
AIR: 0,
GRASS: 1,
DIRT: 2,
STONE: 3,
COBBLE: 4,
SAND: 5,
WOOD: 6, // oak log
LEAVES: 7,
PLANKS: 8,
GLOWSTONE: 9,
CRYSTAL: 10,
OBSIDIAN: 11,
GOLD: 12,
BRICK: 13,
MARBLE: 14,
WATER: 15,
SNOW: 16,
LAVA: 17,
BEDROCK: 18,
});
// Each face index points into the texture atlas (16x16 tiles arranged 8 cols x 4 rows)
// Tex indices:
// 0:grass_top 1:grass_side 2:dirt 3:stone 4:cobble 5:sand 6:wood_side 7:wood_top
// 8:leaves 9:planks 10:glow 11:crystal 12:obsidian 13:gold 14:brick 15:marble
// 16:water 17:snow 18:lava 19:bedrock
const BLOCK_TEX = {
[Block.GRASS]: { top: 0, side: 1, bottom: 2 },
[Block.DIRT]: { all: 2 },
[Block.STONE]: { all: 3 },
[Block.COBBLE]: { all: 4 },
[Block.SAND]: { all: 5 },
[Block.WOOD]: { top: 7, side: 6, bottom: 7 },
[Block.LEAVES]: { all: 8 },
[Block.PLANKS]: { all: 9 },
[Block.GLOWSTONE]:{ all: 10 },
[Block.CRYSTAL]: { all: 11 },
[Block.OBSIDIAN]: { all: 12 },
[Block.GOLD]: { all: 13 },
[Block.BRICK]: { all: 14 },
[Block.MARBLE]: { all: 15 },
[Block.WATER]: { all: 16 },
[Block.SNOW]: { all: 17 },
[Block.LAVA]: { all: 18 },
[Block.BEDROCK]: { all: 19 },
};
const BLOCK_NAME = {
[Block.GRASS]: ['草方块', 'Grass Block'],
[Block.DIRT]: ['泥土', 'Dirt'],
[Block.STONE]: ['石头', 'Stone'],
[Block.COBBLE]: ['圆石', 'Cobblestone'],
[Block.SAND]: ['沙子', 'Sand'],
[Block.WOOD]: ['橡木原木','Oak Log'],
[Block.LEAVES]: ['橡树叶', 'Oak Leaves'],
[Block.PLANKS]: ['橡木板', 'Oak Planks'],
[Block.GLOWSTONE]:['萤石', 'Glowstone'],
[Block.CRYSTAL]: ['水晶矿', 'Crystal Ore'],
[Block.OBSIDIAN]: ['黑曜石', 'Obsidian'],
[Block.GOLD]: ['金块', 'Gold Block'],
[Block.BRICK]: ['红砖', 'Bricks'],
[Block.MARBLE]: ['大理石', 'Marble'],
[Block.WATER]: ['水', 'Water'],
[Block.SNOW]: ['雪', 'Snow'],
[Block.LAVA]: ['熔岩', 'Lava'],
[Block.BEDROCK]: ['基岩', 'Bedrock'],
};
const EMISSIVE_BLOCKS = new Set([Block.GLOWSTONE, Block.LAVA, Block.CRYSTAL]);
const isAir = id => id === Block.AIR;
const isLiquid = id => id === Block.WATER || id === Block.LAVA;
const isOpaque = id => id !== Block.AIR && !isLiquid(id);
const isSolid = id => id !== Block.AIR && !isLiquid(id); // player collides
/* =========================================================================
Texture Atlas (procedural pixel-art textures)
========================================================================= */
const ATLAS = (function makeAtlas() {
const T = 16, COLS = 8, ROWS = 4;
const W = COLS * T, H = ROWS * T;
const canvas = document.createElement('canvas');
canvas.width = W; canvas.height = H;
const ctx = canvas.getContext('2d');
ctx.imageSmoothingEnabled = false;
ctx.fillStyle = '#000'; ctx.fillRect(0, 0, W, H);
// Seeded RNG (Mulberry32) for deterministic textures
let state = 0x12345678 >>> 0;
const seed = (s) => { state = s >>> 0; };
const rnd = () => {
state |= 0; state = (state + 0x6D2B79F5) | 0;
let t = Math.imul(state ^ (state >>> 15), 1 | state);
t = t + Math.imul(t ^ (t >>> 7), 61 | t) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
function tile(idx, fn) {
const col = idx % COLS;
const row = Math.floor(idx / COLS);
const ox = col * T, oy = row * T;
seed(idx * 9301 + 49297); // unique deterministic seed per tile
for (let y = 0; y < T; y++) {
for (let x = 0; x < T; x++) {
const c = fn(x, y);
if (!c) continue;
const [r, g, b, a = 255] = c;
const rr = Math.max(0, Math.min(255, r | 0));
const gg = Math.max(0, Math.min(255, g | 0));
const bb = Math.max(0, Math.min(255, b | 0));
if (a < 255) ctx.fillStyle = `rgba(${rr},${gg},${bb},${(a/255).toFixed(3)})`;
else ctx.fillStyle = `rgb(${rr},${gg},${bb})`;
ctx.fillRect(ox + x, oy + y, 1, 1);
}
}
}
// 0: grass top
tile(0, () => {
const v = rnd();
const dark = rnd() < 0.15;
const r = (dark ? 25 : 45) + v * 25;
const g = (dark ? 95 : 135) + v * 35;
const b = (dark ? 20 : 35) + v * 18;
return [r, g, b];
});
// 1: grass side (top portion green, rest dirt)
tile(1, (x, y) => {
const grassTopHeight = 3 + ((x * 31 + 11) % 13) / 13 * 2;
if (y < grassTopHeight || (y < grassTopHeight + 1 && rnd() < 0.5)) {
const v = rnd();
return [45 + v * 25, 135 + v * 35, 35 + v * 18];
}
const v = rnd();
return [115 + v * 25, 78 + v * 18, 50 + v * 12];
});
// 2: dirt
tile(2, () => {
const v = rnd();
const dark = rnd() < 0.18;
const k = dark ? 0.75 : 1;
return [(120 + v * 28) * k, (78 + v * 22) * k, (50 + v * 15) * k];
});
// 3: stone
tile(3, () => {
const v = rnd();
const dark = rnd() < 0.15;
const k = dark ? 0.75 : 1.0;
const g = (122 + v * 28) * k;
return [g, g, g + 4];
});
// 4: cobblestone (chunky stones with mortar)
tile(4, (x, y) => {
// Pseudo-random chunk pattern
const cx = Math.floor((x + 1) / 4);
const cy = Math.floor((y + 0) / 4);
const cseed = ((cx * 73 + cy * 41) % 7);
const onEdge = (x % 4 === 0 || y % 4 === 0);
const v = rnd();
if (onEdge && rnd() < 0.6) {
const g = 60 + v * 25;
return [g, g, g];
}
const base = 110 + cseed * 12;
const k = 0.85 + v * 0.3;
const g = base * k;
return [g, g, g + 6];
});
// 5: sand
tile(5, () => {
const v = rnd();
const dark = rnd() < 0.1;
const k = dark ? 0.85 : 1;
return [(225 + v * 20) * k, (200 + v * 22) * k, (140 + v * 25) * k];
});
// 6: wood side (bark)
tile(6, (x, y) => {
const vline = Math.sin(x * 1.7 + 0.3) * 0.4 + 0.6;
const knot = (x === 8 && y > 6 && y < 10) ? 0.4 : 1;
const v = rnd() * 0.3 + vline * 0.6;
const k = knot * (0.8 + v * 0.4);
return [(95 + v * 35) * k, (60 + v * 25) * k, (28 + v * 18) * k];
});
// 7: wood top (concentric rings)
tile(7, (x, y) => {
const cx = 8, cy = 8;
const dx = x - cx + 0.5, dy = y - cy + 0.5;
const d = Math.sqrt(dx * dx + dy * dy);
const ring = Math.sin(d * 1.6) * 0.5 + 0.5;
const v = ring * 0.6 + rnd() * 0.4;
return [140 + v * 50, 95 + v * 35, 55 + v * 22];
});
// 8: leaves (mostly opaque with some gaps)
tile(8, () => {
const r = rnd();
if (r < 0.07) return [0, 0, 0, 0]; // small transparent holes for depth
const v = rnd();
const dark = rnd() < 0.35;
const k = dark ? 0.65 : 1;
return [(35 + v * 25) * k, (105 + v * 55) * k, (35 + v * 22) * k, 255];
});
// 9: planks (horizontal boards)
tile(9, (x, y) => {
const plankH = 4;
const inPlank = y % plankH;
const grain = Math.sin((y % plankH) * 1.5 + x * 0.3) * 0.2;
const v = rnd() * 0.25 + grain;
let k = 1;
if (inPlank === plankH - 1) k = 0.65;
return [(170 + v * 30) * k, (122 + v * 25) * k, (72 + v * 20) * k];
});
// 10: glowstone (luminous yellow with bright sparkles)
tile(10, () => {
const r = rnd();
if (r > 0.78) return [255, 235 + rnd() * 20, 165 + rnd() * 40];
if (r > 0.55) return [240 + rnd() * 15, 190 + rnd() * 25, 80 + rnd() * 35];
return [210 + rnd() * 25, 155 + rnd() * 25, 60 + rnd() * 25];
});
// 11: crystal/diamond ore
tile(11, (x, y) => {
const r = rnd();
// Stone base
const base = 105 + rnd() * 25;
let rr = base, gg = base + 6, bb = base + 10;
if (r > 0.55) {
// Crystal cluster
const v = rnd();
if (r > 0.85) { rr = 220 + v * 35; gg = 245; bb = 255; }
else { rr = 95 + v * 50; gg = 200 + v * 40; bb = 230 + v * 25; }
}
return [rr, gg, bb];
});
// 12: obsidian (very dark, purple highlights)
tile(12, () => {
const r = rnd();
const v = rnd();
if (r > 0.92) return [60 + v * 25, 25 + v * 15, 80 + v * 30]; // purple sparkle
return [16 + v * 14, 10 + v * 8, 22 + v * 18];
});
// 13: gold
tile(13, () => {
const r = rnd();
const v = rnd();
if (r > 0.85) return [255, 250 + v * 5, 200 + v * 30]; // bright sparkle
if (r < 0.2) return [200 + v * 20, 140 + v * 20, 35 + v * 25]; // darker spots
return [245 + v * 10, 205 + v * 20, 65 + v * 30];
});
// 14: brick
tile(14, (x, y) => {
const brickH = 4, brickW = 8;
const yRow = Math.floor(y / brickH);
const xOff = (yRow % 2) * 4;
const xMod = (x + xOff) % brickW;
const yMod = y % brickH;
const isMortar = yMod === brickH - 1 || xMod === brickW - 1;
if (isMortar) {
const g = 175 + rnd() * 20;
return [g, g - 10, g - 30];
}
const v = rnd();
return [155 + v * 35, 70 + v * 22, 50 + v * 15];
});
// 15: marble (white with grey veins)
tile(15, (x, y) => {
const n = Math.sin(x * 0.45 + y * 0.3) * Math.cos(y * 0.55 + x * 0.2);
const vein = Math.abs(n) < 0.12 ? 0.7 : 1;
const v = rnd() * 0.1 + 0.9;
const g = 240 * v * vein;
return [g, g + 2, g - 4];
});
// 16: water
tile(16, (x, y) => {
const wave = Math.sin(x * 0.7 + y * 0.4) * 0.15 + 0.85;
const v = rnd() * 0.15 + wave;
return [35 * v, 95 * v, 195 * v, 215];
});
// 17: snow
tile(17, () => {
const v = rnd() * 0.08 + 0.92;
const g = 245 * v;
return [g, g, g + 4];
});
// 18: lava (flowing red/orange)
tile(18, (x, y) => {
const wave = Math.sin(x * 0.4 + y * 0.3) * Math.cos(y * 0.25 + x * 0.5) * 0.4 + 0.6;
const v = rnd() * 0.3 + wave;
return [240 * v + 20, 110 * v + 10, 25 * v + 5];
});
// 19: bedrock
tile(19, () => {
const v = rnd();
const g = 35 + v * 30;
return [g, g - 2, g + 2];
});
const texture = new THREE.CanvasTexture(canvas);
texture.magFilter = THREE.NearestFilter;
texture.minFilter = THREE.NearestMipmapNearestFilter;
texture.generateMipmaps = true;
texture.wrapS = THREE.RepeatWrapping;
texture.wrapT = THREE.RepeatWrapping;
texture.colorSpace = THREE.SRGBColorSpace;
texture.anisotropy = 4;
return { texture, canvas, COLS, ROWS, T };
})();
// UV helpers
function uvForTile(idx) {
const col = idx % ATLAS.COLS;
const row = Math.floor(idx / ATLAS.COLS);
// Inset by a tiny amount to avoid bleeding between tiles
const inset = 0.5 / (ATLAS.COLS * ATLAS.T * 8);
const u0 = col / ATLAS.COLS + inset;
const u1 = (col + 1) / ATLAS.COLS - inset;
const v0 = 1 - (row + 1) / ATLAS.ROWS + inset;
const v1 = 1 - row / ATLAS.ROWS - inset;
return [u0, v0, u1, v1];
}
function texForFace(blockId, face) {
const def = BLOCK_TEX[blockId];
if (!def) return 3;
if (def.all !== undefined) return def.all;
if (face === 'top') return def.top;
if (face === 'bottom') return def.bottom;
return def.side;
}
/* =========================================================================
Face definitions (CCW from outside)
========================================================================= */
const FACES = [
{ name: 'top', nx: 0, ny: 1, nz: 0, verts: [[0,1,0],[0,1,1],[1,1,1],[1,1,0]] },
{ name: 'bottom', nx: 0, ny: -1, nz: 0, verts: [[0,0,0],[1,0,0],[1,0,1],[0,0,1]] },
{ name: 'east', nx: 1, ny: 0, nz: 0, verts: [[1,0,0],[1,1,0],[1,1,1],[1,0,1]] },
{ name: 'west', nx: -1, ny: 0, nz: 0, verts: [[0,0,0],[0,0,1],[0,1,1],[0,1,0]] },
{ name: 'south', nx: 0, ny: 0, nz: 1, verts: [[0,0,1],[1,0,1],[1,1,1],[0,1,1]] },
{ name: 'north', nx: 0, ny: 0, nz: -1, verts: [[1,0,0],[0,0,0],[0,1,0],[1,1,0]] },
];
// Ambient occlusion shading: top brightest, bottom darkest, sides medium
const FACE_AO = {
top: 1.0,
bottom: 0.55,
east: 0.78,
west: 0.82,
south: 0.72,
north: 0.86,
};
/* =========================================================================
Perlin Noise
========================================================================= */
class Perlin {
constructor(seed = 0) {
const p = new Uint8Array(256);
for (let i = 0; i < 256; i++) p[i] = i;
let rng = seed || 1;
for (let i = 255; i > 0; i--) {
rng = (Math.imul(rng, 1664525) + 1013904223) >>> 0;
const j = rng % (i + 1);
[p[i], p[j]] = [p[j], p[i]];
}
this.perm = new Uint8Array(512);
for (let i = 0; i < 512; i++) this.perm[i] = p[i & 255];
}
fade(t) { return t * t * t * (t * (t * 6 - 15) + 10); }
lerp(a, b, t) { return a + t * (b - a); }
grad2(h, x, y) {
h &= 7;
const u = h < 4 ? x : y;
const v = h < 4 ? y : x;
return ((h & 1) ? -u : u) + ((h & 2) ? -2 * v : 2 * v);
}
grad3(h, x, y, z) {
h &= 15;
const u = h < 8 ? x : y;
const v = h < 4 ? y : (h === 12 || h === 14 ? x : z);
return ((h & 1) ? -u : u) + ((h & 2) ? -v : v);
}
noise2(x, y) {
const X = Math.floor(x) & 255;
const Y = Math.floor(y) & 255;
x -= Math.floor(x); y -= Math.floor(y);
const u = this.fade(x), v = this.fade(y);
const A = this.perm[X] + Y, B = this.perm[X + 1] + Y;
return this.lerp(
this.lerp(this.grad2(this.perm[A], x, y), this.grad2(this.perm[B], x - 1, y), u),
this.lerp(this.grad2(this.perm[A + 1], x, y - 1), this.grad2(this.perm[B + 1], x - 1, y - 1), u),
v
);
}
noise3(x, y, z) {
const X = Math.floor(x) & 255, Y = Math.floor(y) & 255, Z = Math.floor(z) & 255;
x -= Math.floor(x); y -= Math.floor(y); z -= Math.floor(z);
const u = this.fade(x), v = this.fade(y), w = this.fade(z);
const A = this.perm[X] + Y, AA = this.perm[A] + Z, AB = this.perm[A + 1] + Z;
const B = this.perm[X + 1] + Y, BA = this.perm[B] + Z, BB = this.perm[B + 1] + Z;
return this.lerp(
this.lerp(
this.lerp(this.grad3(this.perm[AA], x, y, z), this.grad3(this.perm[BA], x - 1, y, z), u),
this.lerp(this.grad3(this.perm[AB], x, y - 1, z), this.grad3(this.perm[BB], x - 1, y - 1, z), u),
v),
this.lerp(
this.lerp(this.grad3(this.perm[AA + 1], x, y, z - 1), this.grad3(this.perm[BA + 1], x - 1, y, z - 1), u),
this.lerp(this.grad3(this.perm[AB + 1], x, y - 1, z - 1), this.grad3(this.perm[BB + 1], x - 1, y - 1, z - 1), u),
v),
w);
}
fbm2(x, y, octaves = 4, lacunarity = 2, persistence = 0.5) {
let t = 0, freq = 1, amp = 1, max = 0;
for (let i = 0; i < octaves; i++) {
t += this.noise2(x * freq, y * freq) * amp;
max += amp;
amp *= persistence;
freq *= lacunarity;
}
return t / max;
}
}
const noise = new Perlin(WORLD_SEED);
const caveNoise = new Perlin(WORLD_SEED ^ 0xabcdef);
const biomeNoise = new Perlin(WORLD_SEED ^ 0x12345);
/* =========================================================================
World / Chunk
========================================================================= */
class Chunk {
constructor(cx, cz) {
this.cx = cx;
this.cz = cz;
this.blocks = new Uint8Array(CHUNK_SIZE * CHUNK_HEIGHT * CHUNK_SIZE);
this.mesh = null;
this.waterMesh = null;
this.group = new THREE.Group();
this.group.position.set(cx * CHUNK_SIZE, 0, cz * CHUNK_SIZE);
this.generated = false;
this.meshed = false;
this.dirty = false;
}
idx(x, y, z) { return (y * CHUNK_SIZE + z) * CHUNK_SIZE + x; }
get(x, y, z) { return this.blocks[this.idx(x, y, z)]; }
set(x, y, z, id) { this.blocks[this.idx(x, y, z)] = id; }
}
class World {
constructor() {
this.chunks = new Map();
this.scene = null;
this.opaqueMat = null;
this.waterMat = null;
this.placedStructures = new Set();
}
key(cx, cz) { return `${cx},${cz}`; }
getChunk(cx, cz) { return this.chunks.get(this.key(cx, cz)); }
ensureChunk(cx, cz) {
const k = this.key(cx, cz);
let c = this.chunks.get(k);
if (!c) {
c = new Chunk(cx, cz);
this.chunks.set(k, c);
}
return c;
}
// Public block access (world coordinates)
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 c = this.getChunk(cx, cz);
if (!c || !c.generated) return Block.AIR;
const lx = ((x % CHUNK_SIZE) + CHUNK_SIZE) % CHUNK_SIZE;
const lz = ((z % CHUNK_SIZE) + CHUNK_SIZE) % CHUNK_SIZE;
return c.get(lx, y, lz);
}
setBlock(x, y, z, id, dirtyMark = true) {
if (y < 0 || y >= CHUNK_HEIGHT) return;
const cx = Math.floor(x / CHUNK_SIZE);
const cz = Math.floor(z / CHUNK_SIZE);
const c = this.ensureChunk(cx, cz);
const lx = ((x % CHUNK_SIZE) + CHUNK_SIZE) % CHUNK_SIZE;
const lz = ((z % CHUNK_SIZE) + CHUNK_SIZE) % CHUNK_SIZE;
c.set(lx, y, lz, id);
if (dirtyMark) {
c.dirty = true;
if (lx === 0) { const n = this.getChunk(cx - 1, cz); if (n) n.dirty = true; }
if (lx === CHUNK_SIZE - 1) { const n = this.getChunk(cx + 1, cz); if (n) n.dirty = true; }
if (lz === 0) { const n = this.getChunk(cx, cz - 1); if (n) n.dirty = true; }
if (lz === CHUNK_SIZE - 1) { const n = this.getChunk(cx, cz + 1); if (n) n.dirty = true; }
}
}
// Terrain height function for a world XZ coordinate
terrainHeight(x, z) {
const continent = noise.fbm2(x * 0.0035, z * 0.0035, 4, 2, 0.5);
const hills = noise.fbm2(x * 0.012, z * 0.012, 4, 2, 0.5);
const detail = noise.noise2(x * 0.07, z * 0.07);
const mountainMask = Math.max(0, noise.fbm2(x * 0.002, z * 0.002, 3, 2, 0.5));
const mountains = mountainMask * mountainMask * 22;
let base = SEA_LEVEL - 2 + continent * 10 + hills * 6 + detail * 1.2 + mountains;
// Spawn island: lift terrain near origin so the spawn area is reliably above sea level
// (smooth radial bump from 12 blocks above sea at center fading out by ~80 blocks)
const distFromOrigin = Math.sqrt(x * x + z * z);
const islandLift = Math.max(0, (90 - distFromOrigin) / 90);
base += islandLift * islandLift * 14;
return Math.max(2, Math.min(CHUNK_HEIGHT - 6, Math.floor(base)));
}
// 0 = plains, 1 = forest, 2 = desert, 3 = mountains
biomeAt(x, z) {
const b = biomeNoise.fbm2(x * 0.005, z * 0.005, 3, 2, 0.5);
const m = noise.fbm2(x * 0.002, z * 0.002, 3, 2, 0.5);
if (m > 0.4) return 3;
if (b < -0.2) return 2;
if (b > 0.1) return 1;
return 0;
}
// Generate the base terrain for a single chunk (no mesh yet)
generateChunk(c) {
if (c.generated) return;
const baseX = c.cx * CHUNK_SIZE;
const baseZ = c.cz * CHUNK_SIZE;
for (let lz = 0; lz < CHUNK_SIZE; lz++) {
for (let lx = 0; lx < CHUNK_SIZE; lx++) {
const x = baseX + lx, z = baseZ + lz;
const h = this.terrainHeight(x, z);
const biome = this.biomeAt(x, z);
for (let y = 0; y <= h; y++) {
let id = Block.STONE;
if (y === 0) id = Block.BEDROCK;
else if (y === h) {
if (biome === 2) id = Block.SAND;
else if (biome === 3 && y > SEA_LEVEL + 16) id = Block.SNOW;
else if (h <= SEA_LEVEL + 1) id = Block.SAND;
else id = Block.GRASS;
} else if (y >= h - 3) {
if (biome === 2) id = Block.SAND;
else id = Block.DIRT;
}
// 3D cave carving
if (id !== Block.BEDROCK && y > 2 && y < h - 1) {
const cv = caveNoise.fbm2(x * 0.06, z * 0.06, 2, 2, 0.5);
const cv2 = caveNoise.noise3(x * 0.08, y * 0.13, z * 0.08);
if (cv2 > 0.55 && cv > -0.1) id = Block.AIR;
}
c.set(lx, y, lz, id);
}
// Fill water up to sea level
if (h < SEA_LEVEL) {
for (let y = h + 1; y <= SEA_LEVEL; y++) {
if (c.get(lx, y, lz) === Block.AIR) c.set(lx, y, lz, Block.WATER);
}
}
// Surface decoration
if (h > SEA_LEVEL && biome === 1 && c.get(lx, h, lz) === Block.GRASS) {
// Pseudo-random tree placement
const r = (Math.abs(noise.noise2(x * 1.7 + 0.1, z * 1.3 + 0.2)) * 1000) % 1;
if (r < 0.014) this.tryPlaceTree(x, h + 1, z);
}
// Crystal ore veins underground
for (let y = 8; y < h - 5; y++) {
if (c.get(lx, y, lz) === Block.STONE) {
const ore = noise.noise3(x * 0.2 + 100, y * 0.25 + 50, z * 0.2 - 100);
if (ore > 0.7) c.set(lx, y, lz, Block.CRYSTAL);
else if (ore < -0.72) c.set(lx, y, lz, Block.GOLD);
}
}
}
}
c.generated = true;
c.dirty = true;
}
tryPlaceTree(x, y, z) {
const h = 4 + Math.floor(Math.abs(noise.noise2(x * 7.3, z * 5.1)) * 3);
for (let i = 0; i < h; i++) this.setBlock(x, y + i, z, Block.WOOD, false);
const ty = y + h;
for (let dy = -1; dy <= 2; dy++) {
const r = dy === -1 || dy === 2 ? 1 : 2;
for (let dz = -r; dz <= r; dz++) {
for (let dx = -r; dx <= r; dx++) {
if (dx === 0 && dz === 0 && dy < 2) continue;
if (Math.abs(dx) === r && Math.abs(dz) === r && dy !== 0) continue;
const cur = this.getBlock(x + dx, ty + dy, z + dz);
if (cur === Block.AIR) this.setBlock(x + dx, ty + dy, z + dz, Block.LEAVES, false);
}
}
}
}
/* ===== Mythological structures ===== */
// Each structure is keyed by a world-space integer position. After being placed once,
// it won't be placed again. Structures are placed when their root chunk first generates,
// but may span into other chunks (which will already be generated due to pre-load).
placeStructuresIfNeeded(c) {
for (const s of STRUCTURES) {
const sk = `${s.id}`;
if (this.placedStructures.has(sk)) continue;
const scx = Math.floor(s.x / CHUNK_SIZE);
const scz = Math.floor(s.z / CHUNK_SIZE);
if (scx === c.cx && scz === c.cz) {
this.placedStructures.add(sk);
s.build(this);
}
}
}
}
/* =========================================================================
Mesher: convert a chunk's blocks into a BufferGeometry
========================================================================= */
function meshChunk(world, c) {
const posOpaque = [];
const norOpaque = [];
const uvOpaque = [];
const colOpaque = [];
const idxOpaque = [];
const posWater = [];
const norWater = [];
const uvWater = [];
const colWater = [];
const idxWater = [];
let vCountOpaque = 0;
let vCountWater = 0;
const baseX = c.cx * CHUNK_SIZE;
const baseZ = c.cz * CHUNK_SIZE;
for (let y = 0; y < CHUNK_HEIGHT; y++) {
for (let z = 0; z < CHUNK_SIZE; z++) {
for (let x = 0; x < CHUNK_SIZE; x++) {
const id = c.get(x, y, z);
if (id === Block.AIR) continue;
const isWater = id === Block.WATER;
const wx = baseX + x, wy = y, wz = baseZ + z;
for (const f of FACES) {
const nx = x + f.nx, ny = y + f.ny, nz = z + f.nz;
let neighborId;
if (ny < 0 || ny >= CHUNK_HEIGHT) {
neighborId = Block.AIR;
} else if (nx >= 0 && nx < CHUNK_SIZE && nz >= 0 && nz < CHUNK_SIZE) {
neighborId = c.get(nx, ny, nz);
} else {
neighborId = world.getBlock(baseX + nx, ny, baseZ + nz);
}
let show = false;
if (isWater) {
show = neighborId === Block.AIR;
} else if (id === Block.LEAVES) {
// Leaves: visible against air/water/other leaves to give density
show = !isOpaque(neighborId) || neighborId === Block.LEAVES;
if (id === neighborId && f.ny < 0) show = false;
} else {
show = !isOpaque(neighborId);
}
if (!show) continue;
const tex = texForFace(id, f.name);
const [u0, v0, u1, v1] = uvForTile(tex);
const aoBase = FACE_AO[f.name];
// light = base AO + small variation per-block from world coords
const tinyVar = (Math.sin((wx + wy * 7 + wz * 13) * 1.3) * 0.5 + 0.5) * 0.02;
let light = aoBase - tinyVar;
if (EMISSIVE_BLOCKS.has(id)) light = Math.min(1, light + 0.15);
const targetPos = isWater ? posWater : posOpaque;
const targetNor = isWater ? norWater : norOpaque;
const targetUv = isWater ? uvWater : uvOpaque;
const targetCol = isWater ? colWater : colOpaque;
const targetIdx = isWater ? idxWater : idxOpaque;
let vCount = isWater ? vCountWater : vCountOpaque;
for (let i = 0; i < 4; i++) {
const v = f.verts[i];
// For water top, lower slightly so it doesn't reach full block top
const yAdjust = (isWater && f.name === 'top' && v[1] === 1) ? -0.12 : 0;
// Use chunk-local coords for vertices; chunk.group.position handles the world offset
targetPos.push(x + v[0], y + v[1] + yAdjust, z + v[2]);
targetNor.push(f.nx, f.ny, f.nz);
// UV order: (0,v0), (1,v0), (1,v1), (0,v1) wraps texture upright on side faces
const uvs = [[u0, v0], [u1, v0], [u1, v1], [u0, v1]];
targetUv.push(uvs[i][0], uvs[i][1]);
targetCol.push(light, light, light);
}
targetIdx.push(vCount, vCount + 1, vCount + 2, vCount, vCount + 2, vCount + 3);
vCount += 4;
if (isWater) vCountWater = vCount; else vCountOpaque = vCount;
}
}
}
}
// Build opaque geometry
if (c.mesh) {
c.group.remove(c.mesh);
c.mesh.geometry.dispose();
c.mesh = null;
}
if (c.waterMesh) {
c.group.remove(c.waterMesh);
c.waterMesh.geometry.dispose();
c.waterMesh = null;
}
if (posOpaque.length > 0) {
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.Float32BufferAttribute(posOpaque, 3));
geo.setAttribute('normal', new THREE.Float32BufferAttribute(norOpaque, 3));
geo.setAttribute('uv', new THREE.Float32BufferAttribute(uvOpaque, 2));
geo.setAttribute('color', new THREE.Float32BufferAttribute(colOpaque, 3));
geo.setIndex(idxOpaque);
geo.computeBoundingSphere();
const m = new THREE.Mesh(geo, world.opaqueMat);
m.position.set(0, 0, 0);
c.mesh = m;
c.group.add(m);
}
if (posWater.length > 0) {
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.Float32BufferAttribute(posWater, 3));
geo.setAttribute('normal', new THREE.Float32BufferAttribute(norWater, 3));
geo.setAttribute('uv', new THREE.Float32BufferAttribute(uvWater, 2));
geo.setAttribute('color', new THREE.Float32BufferAttribute(colWater, 3));
geo.setIndex(idxWater);
geo.computeBoundingSphere();
const m = new THREE.Mesh(geo, world.waterMat);
m.renderOrder = 1;
c.waterMesh = m;
c.group.add(m);
}
c.meshed = true;
c.dirty = false;
}
/* =========================================================================
Mythological Structures
========================================================================= */
function structureBlock(world, x, y, z, id, overwriteOnlyAir = false) {
if (y < 0 || y >= CHUNK_HEIGHT) return;
if (overwriteOnlyAir) {
const cur = world.getBlock(x, y, z);
if (cur !== Block.AIR && cur !== Block.WATER) return;
}
world.setBlock(x, y, z, id, false);
}
// Helper: highest non-air block (top surface)
function surfaceY(world, x, z) {
// World may not have terrain for this column yet; use terrainHeight
return world.terrainHeight(x, z);
}
// === Structure 1: Stonehenge at origin ===
function buildStonehenge(world, ox, oz) {
const cy = surfaceY(world, ox, oz);
// Flatten the ring area plus a south spawn courtyard
for (let dx = -9; dx <= 9; dx++) {
for (let dz = -9; dz <= 14; dz++) {
const dist = Math.sqrt(dx * dx + dz * dz);
const inRing = dist <= 7;
const inSouthPlaza = dx >= -4 && dx <= 4 && dz >= 0 && dz <= 13;
if (!inRing && !inSouthPlaza) continue;
const h = world.terrainHeight(ox + dx, oz + dz);
// Clear any blocks above cy (trees, dirt cliffs) in the courtyard
for (let y = cy + 1; y <= Math.max(h, cy + 5); y++) {
structureBlock(world, ox + dx, y, oz + dz, Block.AIR);
}
// Center stripe of the south path is marble; rest is grass
const isPath = (dx === 0 && dz > 7 && dz <= 13);
structureBlock(world, ox + dx, cy, oz + dz, isPath ? Block.MARBLE : Block.GRASS);
structureBlock(world, ox + dx, cy - 1, oz + dz, Block.DIRT);
}
}
// Stone ring (8 pillars)
const ringR = 5;
const ringCount = 8;
for (let i = 0; i < ringCount; i++) {
const a = (i / ringCount) * Math.PI * 2;
const px = ox + Math.round(Math.cos(a) * ringR);
const pz = oz + Math.round(Math.sin(a) * ringR);
for (let y = 1; y <= 4; y++) structureBlock(world, px, cy + y, pz, Block.COBBLE);
// Lintel/top stone
structureBlock(world, px, cy + 5, pz, Block.MARBLE);
// Connecting lintel to next pillar (top ring)
const aNext = ((i + 1) / ringCount) * Math.PI * 2;
const nx = ox + Math.round(Math.cos(aNext) * ringR);
const nz = oz + Math.round(Math.sin(aNext) * ringR);
// Bresenham-ish line
const steps = Math.max(Math.abs(nx - px), Math.abs(nz - pz));
for (let s = 1; s < steps; s++) {
const t = s / steps;
const lx = Math.round(px + (nx - px) * t);
const lz = Math.round(pz + (nz - pz) * t);
structureBlock(world, lx, cy + 5, lz, Block.MARBLE);
}
}
// Central altar
for (let dx = -1; dx <= 1; dx++) {
for (let dz = -1; dz <= 1; dz++) {
structureBlock(world, ox + dx, cy + 1, oz + dz, Block.OBSIDIAN);
}
}
structureBlock(world, ox, cy + 2, oz, Block.GOLD);
structureBlock(world, ox, cy + 3, oz, Block.GLOWSTONE);
// 4 gold corner accents
structureBlock(world, ox + 1, cy + 2, oz + 1, Block.GOLD);
structureBlock(world, ox - 1, cy + 2, oz - 1, Block.GOLD);
structureBlock(world, ox + 1, cy + 2, oz - 1, Block.GOLD);
structureBlock(world, ox - 1, cy + 2, oz + 1, Block.GOLD);
// Paved pathway from altar to north
for (let dz = 2; dz < 5; dz++) {
structureBlock(world, ox, cy, oz + dz, Block.MARBLE);
}
}
// === Structure 2: Sacred Tree of Life ===
function buildSacredTree(world, ox, oz) {
const cy = surfaceY(world, ox, oz);
// Massive trunk: 5x5 core, 50 blocks tall
const trunkR = 2;
const trunkH = 30;
for (let y = 0; y < trunkH; y++) {
for (let dx = -trunkR; dx <= trunkR; dx++) {
for (let dz = -trunkR; dz <= trunkR; dz++) {
// Tapered at top
const taper = 1 - (y / trunkH) * 0.3;
const dist = Math.sqrt(dx * dx + dz * dz);
if (dist <= trunkR * taper + 0.1) {
structureBlock(world, ox + dx, cy + y, oz + dz, Block.WOOD);
}
}
}
// Hollow core: glowstone heart
if (y > 4 && y < trunkH - 2 && y % 7 === 0) {
structureBlock(world, ox, cy + y, oz, Block.GLOWSTONE);
} else if (y > 1 && y < trunkH - 2 && (y > 4)) {
structureBlock(world, ox, cy + y, oz, Block.CRYSTAL);
}
}
// Massive leaf canopy
const canopyR = 11;
const canopyTop = cy + trunkH + 6;
for (let y = cy + trunkH - 8; y < canopyTop + 2; y++) {
const relY = (y - (cy + trunkH - 8)) / (canopyTop + 2 - (cy + trunkH - 8));
const radius = canopyR * Math.sin(relY * Math.PI) * 0.95 + 1;
for (let dx = -canopyR; dx <= canopyR; dx++) {
for (let dz = -canopyR; dz <= canopyR; dz++) {
const dist = Math.sqrt(dx * dx + dz * dz);
if (dist <= radius && dist > 0.5 + (radius * 0.1)) {
// Some leaves missing (organic look)
const r = (Math.sin(dx * 1.3 + dz * 2.1 + y) * 0.5 + 0.5);
if (r > 0.15) {
structureBlock(world, ox + dx, y, oz + dz, Block.LEAVES, true);
}
}
}
}
}
// Roots radiating out
for (let dir = 0; dir < 8; dir++) {
const a = (dir / 8) * Math.PI * 2;
const dx = Math.cos(a), dz = Math.sin(a);
for (let r = 1; r < 8; r++) {
const x = ox + Math.round(dx * r);
const z = oz + Math.round(dz * r);
structureBlock(world, x, cy, z, Block.WOOD);
if (r < 4) structureBlock(world, x, cy - 1, z, Block.WOOD);
}
}
}
// === Structure 3: Stepped Sand Pyramid ===
function buildPyramid(world, ox, oz) {
const cy = surfaceY(world, ox, oz);
const size = 11;
// Flatten/sand foundation
for (let dx = -size - 1; dx <= size + 1; dx++) {
for (let dz = -size - 1; dz <= size + 1; dz++) {
const h = world.terrainHeight(ox + dx, oz + dz);
for (let y = cy + 1; y <= h; y++) structureBlock(world, ox + dx, y, oz + dz, Block.AIR);
structureBlock(world, ox + dx, cy, oz + dz, Block.SAND);
}
}
// Build pyramid steps
for (let layer = 0; layer < size; layer++) {
const half = size - layer;
for (let dx = -half; dx <= half; dx++) {
for (let dz = -half; dz <= half; dz++) {
const id = (layer % 2 === 0) ? Block.SAND : Block.BRICK;
structureBlock(world, ox + dx, cy + 1 + layer, oz + dz, id);
}
}
}
// Hollow internal chamber
const cAir = (x, y, z) => structureBlock(world, x, y, z, Block.AIR);
for (let dx = -2; dx <= 2; dx++) {
for (let dz = -2; dz <= 2; dz++) {
for (let dy = 1; dy <= 3; dy++) cAir(ox + dx, cy + dy, oz + dz);
}
}
// Treasure room
structureBlock(world, ox, cy + 1, oz, Block.GOLD);
structureBlock(world, ox + 2, cy + 1, oz + 2, Block.GOLD);
structureBlock(world, ox - 2, cy + 1, oz - 2, Block.GOLD);
structureBlock(world, ox + 2, cy + 1, oz - 2, Block.GOLD);
structureBlock(world, ox - 2, cy + 1, oz + 2, Block.GOLD);
// Glowing ceiling
structureBlock(world, ox, cy + 3, oz, Block.GLOWSTONE);
// Entrance corridor going south
for (let dz = 3; dz <= size + 1; dz++) {
for (let dy = 1; dy <= 2; dy++) cAir(ox, cy + dy, oz + dz);
for (let dy = 1; dy <= 2; dy++) cAir(ox + 1, cy + dy, oz + dz);
for (let dy = 1; dy <= 2; dy++) cAir(ox - 1, cy + dy, oz + dz);
}
// Capstone tip
structureBlock(world, ox, cy + size + 1, oz, Block.CRYSTAL);
}
// === Structure 4: Wizard Tower ===
function buildWizardTower(world, ox, oz) {
const cy = surfaceY(world, ox, oz);
const towerR = 4;
const towerH = 26;
// Foundation
for (let dx = -towerR - 1; dx <= towerR + 1; dx++) {
for (let dz = -towerR - 1; dz <= towerR + 1; dz++) {
if (Math.sqrt(dx * dx + dz * dz) < towerR + 1.5) {
structureBlock(world, ox + dx, cy, oz + dz, Block.COBBLE);
}
}
}
// Cylindrical tower body
for (let y = 1; y <= towerH; y++) {
for (let dx = -towerR; dx <= towerR; dx++) {
for (let dz = -towerR; dz <= towerR; dz++) {
const dist = Math.sqrt(dx * dx + dz * dz);
if (dist > towerR - 0.4 && dist < towerR + 0.4) {
const id = (y % 5 === 0) ? Block.MARBLE : Block.COBBLE;
structureBlock(world, ox + dx, cy + y, oz + dz, id);
}
}
}
}
// Hollow inside, with floors at intervals
for (let y = 1; y < towerH; y++) {
for (let dx = -towerR + 1; dx <= towerR - 1; dx++) {
for (let dz = -towerR + 1; dz <= towerR - 1; dz++) {
const dist = Math.sqrt(dx * dx + dz * dz);
if (dist < towerR - 0.5) {
if (y % 6 === 0) {
structureBlock(world, ox + dx, cy + y, oz + dz, Block.PLANKS);
} else {
structureBlock(world, ox + dx, cy + y, oz + dz, Block.AIR);
}
}
}
}
}
// Door (south side)
structureBlock(world, ox, cy + 1, oz + towerR, Block.AIR);
structureBlock(world, ox, cy + 2, oz + towerR, Block.AIR);
// Windows at intervals
for (let lvl = 1; lvl < towerH / 6; lvl++) {
const yy = cy + lvl * 6 + 2;
structureBlock(world, ox + towerR, yy, oz, Block.AIR);
structureBlock(world, ox - towerR, yy, oz, Block.AIR);
structureBlock(world, ox, yy, oz + towerR, Block.AIR);
structureBlock(world, ox, yy, oz - towerR, Block.AIR);
}
// Glowstone interior lighting
for (let lvl = 1; lvl < towerH / 6; lvl++) {
const yy = cy + lvl * 6 + 3;
structureBlock(world, ox, yy, oz, Block.GLOWSTONE);
}
// Conical roof of crystal
for (let y = 0; y < towerR + 3; y++) {
const r = towerR - y * 0.7;
for (let dx = -towerR; dx <= towerR; dx++) {
for (let dz = -towerR; dz <= towerR; dz++) {
const dist = Math.sqrt(dx * dx + dz * dz);
if (dist <= r + 0.3 && dist > r - 0.7) {
structureBlock(world, ox + dx, cy + towerH + y, oz + dz, Block.CRYSTAL);
} else if (dist <= r - 0.7) {
structureBlock(world, ox + dx, cy + towerH + y, oz + dz, Block.AIR);
}
}
}
}
// Spire tip
structureBlock(world, ox, cy + towerH + towerR + 3, oz, Block.GLOWSTONE);
structureBlock(world, ox, cy + towerH + towerR + 4, oz, Block.CRYSTAL);
}
// === Structure 5: Sky Temple (floating island with marble temple) ===
function buildSkyTemple(world, ox, oz) {
const groundY = surfaceY(world, ox, oz);
const islandY = Math.min(CHUNK_HEIGHT - 18, groundY + 25);
// Floating island shape - rough disk
const radius = 9;
for (let dx = -radius - 1; dx <= radius + 1; dx++) {
for (let dz = -radius - 1; dz <= radius + 1; dz++) {
const dist = Math.sqrt(dx * dx + dz * dz);
if (dist > radius) continue;
// Top surface
structureBlock(world, ox + dx, islandY, oz + dz, Block.MARBLE);
structureBlock(world, ox + dx, islandY - 1, oz + dz, Block.STONE);
// Tapered bottom
const taperDepth = Math.max(1, Math.round((1 - dist / radius) * 4));
for (let d = 2; d < taperDepth + 2; d++) {
structureBlock(world, ox + dx, islandY - d, oz + dz, Block.STONE);
}
}
}
// Temple in center: 6x6 footprint with 4 marble columns + golden roof
const tR = 3;
// Floor
for (let dx = -tR; dx <= tR; dx++) {
for (let dz = -tR; dz <= tR; dz++) {
structureBlock(world, ox + dx, islandY + 1, oz + dz, Block.MARBLE);
}
}
// 4 corner columns
const cornerOffsets = [[-tR, -tR], [tR, -tR], [-tR, tR], [tR, tR]];
for (const [cx, cz] of cornerOffsets) {
for (let y = 1; y <= 5; y++) {
structureBlock(world, ox + cx, islandY + y, oz + cz, Block.MARBLE);
}
}
// Roof of gold + crystal trim
for (let dx = -tR; dx <= tR; dx++) {
for (let dz = -tR; dz <= tR; dz++) {
if (Math.abs(dx) === tR || Math.abs(dz) === tR) {
structureBlock(world, ox + dx, islandY + 6, oz + dz, Block.GOLD);
} else {
structureBlock(world, ox + dx, islandY + 6, oz + dz, Block.MARBLE);
}
}
}
// Pyramidal roof above
for (let y = 0; y < 3; y++) {
const r = tR - y - 1;
for (let dx = -r; dx <= r; dx++) {
for (let dz = -r; dz <= r; dz++) {
if (Math.abs(dx) === r || Math.abs(dz) === r) {
structureBlock(world, ox + dx, islandY + 7 + y, oz + dz, Block.GOLD);
}
}
}
}
// Center altar
structureBlock(world, ox, islandY + 2, oz, Block.GOLD);
structureBlock(world, ox, islandY + 3, oz, Block.CRYSTAL);
structureBlock(world, ox, islandY + 4, oz, Block.GLOWSTONE);
// Crystal spike on tip
structureBlock(world, ox, islandY + 10, oz, Block.CRYSTAL);
structureBlock(world, ox, islandY + 11, oz, Block.CRYSTAL);
// Floating crystal pillars around the island edge
for (let i = 0; i < 6; i++) {
const a = (i / 6) * Math.PI * 2;
const px = ox + Math.round(Math.cos(a) * (radius - 1));
const pz = oz + Math.round(Math.sin(a) * (radius - 1));
for (let y = 0; y < 3; y++) {
structureBlock(world, px, islandY + 1 + y, pz, Block.CRYSTAL);
}
}
// Small floating stones around to give a sense of ascension
for (let i = 0; i < 5; i++) {
const a = i * 1.3 + 0.4;
const r = 12 + i * 2;
const px = ox + Math.round(Math.cos(a) * r);
const pz = oz + Math.round(Math.sin(a) * r);
const py = islandY - 6 - i * 2;
if (py > groundY + 2) {
structureBlock(world, px, py, pz, Block.MARBLE);
structureBlock(world, px + 1, py, pz, Block.MARBLE);
structureBlock(world, px, py, pz + 1, Block.MARBLE);
}
}
}
// === Structure 6: Dragon Altar ===
function buildDragonAltar(world, ox, oz) {
const cy = surfaceY(world, ox, oz);
// Flatten 13x13 area
for (let dx = -7; dx <= 7; dx++) {
for (let dz = -7; dz <= 7; dz++) {
const dist = Math.sqrt(dx * dx + dz * dz);
if (dist > 8) continue;
const h = world.terrainHeight(ox + dx, oz + dz);
for (let y = cy + 1; y <= h; y++) structureBlock(world, ox + dx, y, oz + dz, Block.AIR);
structureBlock(world, ox + dx, cy, oz + dz, Block.OBSIDIAN);
}
}
// Tiered altar pedestal
for (let layer = 0; layer < 3; layer++) {
const half = 4 - layer;
for (let dx = -half; dx <= half; dx++) {
for (let dz = -half; dz <= half; dz++) {
const dist = Math.sqrt(dx * dx + dz * dz);
if (dist <= half + 0.2) {
structureBlock(world, ox + dx, cy + 1 + layer, oz + dz, Block.OBSIDIAN);
}
}
}
}
// 4 gold pillars
const pillarOffs = [[5, 0], [-5, 0], [0, 5], [0, -5]];
for (const [px, pz] of pillarOffs) {
for (let y = 1; y <= 7; y++) {
structureBlock(world, ox + px, cy + y, oz + pz, Block.GOLD);
}
structureBlock(world, ox + px, cy + 8, oz + pz, Block.GLOWSTONE);
}
// Lava channels (4 radial)
for (const [dx, dz] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
for (let r = 4; r <= 6; r++) {
structureBlock(world, ox + dx * r, cy + 1, oz + dz * r, Block.LAVA);
}
}
// Central altar
structureBlock(world, ox, cy + 4, oz, Block.GOLD);
structureBlock(world, ox, cy + 5, oz, Block.CRYSTAL);
// Dragon claw motifs (gold blocks in NSEW corners outer)
const motifs = [[6, 6], [-6, 6], [6, -6], [-6, -6]];
for (const [mx, mz] of motifs) {
structureBlock(world, ox + mx, cy + 1, oz + mz, Block.GOLD);
structureBlock(world, ox + mx, cy + 2, oz + mz, Block.OBSIDIAN);
}
}
const STRUCTURES = [
{ id: 'stonehenge', x: 0, z: 0, build: w => buildStonehenge(w, 0, 0) },
{ id: 'sacred_tree', x: -40, z: -32, build: w => buildSacredTree(w, -40, -32) },
{ id: 'pyramid', x: 56, z: 48, build: w => buildPyramid(w, 56, 48) },
{ id: 'wizard_tower', x: -50, z: 36, build: w => buildWizardTower(w, -50, 36) },
{ id: 'sky_temple', x: 40, z: -50, build: w => buildSkyTemple(w, 40, -50) },
{ id: 'dragon_altar', x: -8, z: 60, build: w => buildDragonAltar(w, -8, 60) },
];
/* =========================================================================
Renderer / Scene setup
========================================================================= */
const canvas = document.getElementById('game');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: false, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.05;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x9bb8d4);
scene.fog = new THREE.Fog(0x9bb8d4, RENDER_DISTANCE * CHUNK_SIZE * 0.55, RENDER_DISTANCE * CHUNK_SIZE * 1.05);
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(0, SEA_LEVEL + 12, 14);
// Sky gradient via skydome (simple vertex-colored sphere)
const skyGeo = new THREE.SphereGeometry(600, 32, 16);
const skyMat = new THREE.ShaderMaterial({
uniforms: {
topColor: { value: new THREE.Color(0x4070b4) },
midColor: { value: new THREE.Color(0xa8c5e0) },
bottomColor: { value: new THREE.Color(0xf2dfb8) },
},
vertexShader: `
varying vec3 vWorldPos;
void main() {
vec4 wp = modelMatrix * vec4(position, 1.0);
vWorldPos = wp.xyz;
gl_Position = projectionMatrix * viewMatrix * wp;
}
`,
fragmentShader: `
varying vec3 vWorldPos;
uniform vec3 topColor;
uniform vec3 midColor;
uniform vec3 bottomColor;
void main() {
float h = normalize(vWorldPos).y;
vec3 c;
if (h > 0.0) c = mix(midColor, topColor, smoothstep(0.0, 1.0, h));
else c = mix(midColor, bottomColor, smoothstep(0.0, -0.5, h));
gl_FragColor = vec4(c, 1.0);
}
`,
side: THREE.BackSide,
depthWrite: false,
});
const sky = new THREE.Mesh(skyGeo, skyMat);
scene.add(sky);
// Lighting: sun + hemisphere + tiny ambient
const sun = new THREE.DirectionalLight(0xfff4d6, 1.05);
sun.position.set(80, 140, 50);
scene.add(sun);
const ambient = new THREE.AmbientLight(0xc4d6e8, 0.45);
scene.add(ambient);
const hemiLight = new THREE.HemisphereLight(0xa8c5e0, 0x4b3a2a, 0.35);
scene.add(hemiLight);
/* Block materials */
const opaqueMaterial = new THREE.MeshLambertMaterial({
map: ATLAS.texture,
vertexColors: true,
side: THREE.FrontSide,
alphaTest: 0.5,
});
const waterMaterial = new THREE.MeshLambertMaterial({
map: ATLAS.texture,
vertexColors: true,
side: THREE.DoubleSide,
transparent: true,
opacity: 0.78,
depthWrite: false,
});
const world = new World();
world.scene = scene;
world.opaqueMat = opaqueMaterial;
world.waterMat = waterMaterial;
/* =========================================================================
Block break/place highlight box
========================================================================= */
const highlightGeo = new THREE.BoxGeometry(1.002, 1.002, 1.002);
const highlightEdges = new THREE.EdgesGeometry(highlightGeo);
const highlightMat = new THREE.LineBasicMaterial({ color: 0x000000, linewidth: 2, transparent: true, opacity: 0.55 });
const highlightLines = new THREE.LineSegments(highlightEdges, highlightMat);
highlightLines.visible = false;
scene.add(highlightLines);
/* =========================================================================
Player & Controls
========================================================================= */
const controls = new PointerLockControls(camera, document.body);
scene.add(controls.getObject());
const playerState = {
velocity: new THREE.Vector3(),
onGround: false,
flying: false,
inWater: false,
walkPhase: 0,
bobY: 0,
};
const keys = new Set();
window.addEventListener('keydown', (e) => {
if (e.repeat) return;
keys.add(e.code);
if (e.code === 'F3') {
document.getElementById('debug').classList.toggle('hidden');
e.preventDefault();
}
if (e.code === 'KeyF') {
playerState.flying = !playerState.flying;
playerState.velocity.set(0, 0, 0);
}
if (e.code === 'Space' && playerState.onGround && !playerState.flying) {
playerState.velocity.y = JUMP_SPEED;
}
// Hotbar 1-9
for (let i = 1; i <= 9; i++) {
if (e.code === `Digit${i}`) {
setHotbarIndex(i - 1);
e.preventDefault();
}
}
});
window.addEventListener('keyup', (e) => {
keys.delete(e.code);
});
// Mouse wheel scrolls hotbar
window.addEventListener('wheel', (e) => {
if (!controls.isLocked) return;
const dir = e.deltaY > 0 ? 1 : -1;
setHotbarIndex((HOTBAR_INDEX + dir + HOTBAR.length) % HOTBAR.length);
});
document.addEventListener('mousedown', (e) => {
if (!controls.isLocked) return;
if (e.button === 0) handleBreak();
else if (e.button === 2) handlePlace();
});
document.addEventListener('contextmenu', e => e.preventDefault());
/* =========================================================================
Hotbar
========================================================================= */
const HOTBAR = [
Block.GRASS, Block.DIRT, Block.STONE, Block.COBBLE,
Block.WOOD, Block.PLANKS, Block.LEAVES, Block.GLOWSTONE, Block.CRYSTAL,
];
let HOTBAR_INDEX = 0;
function buildHotbarUI() {
const hb = document.getElementById('hotbar');
hb.innerHTML = '';
HOTBAR.forEach((blockId, i) => {
const slot = document.createElement('div');
slot.className = 'slot' + (i === HOTBAR_INDEX ? ' active' : '');
slot.dataset.index = String(i);
const num = document.createElement('div'); num.className = 'slot-num'; num.textContent = String(i + 1);
slot.appendChild(num);
// Icon: render a tiny canvas from atlas
const ic = document.createElement('canvas');
ic.className = 'slot-icon';
ic.width = ic.height = 48;
const ctx = ic.getContext('2d');
ctx.imageSmoothingEnabled = false;
drawBlockIcon(ctx, blockId, 48);
slot.appendChild(ic);
hb.appendChild(slot);
});
}
function drawBlockIcon(ctx, blockId, size) {
// Render an isometric-like preview using the atlas tile for the top + side
const topIdx = texForFace(blockId, 'top');
const sideIdx = texForFace(blockId, 'side');
const t = ATLAS.T;
const topCol = topIdx % ATLAS.COLS;
const topRow = Math.floor(topIdx / ATLAS.COLS);
const sideCol = sideIdx % ATLAS.COLS;
const sideRow = Math.floor(sideIdx / ATLAS.COLS);
// Background
ctx.clearRect(0, 0, size, size);
// Draw top face as a parallelogram-ish: just scale the top texture to fill upper half
const half = size / 2;
ctx.drawImage(ATLAS.canvas, topCol * t, topRow * t, t, t, 0, 0, size, half);
ctx.globalAlpha = 0.85;
ctx.drawImage(ATLAS.canvas, sideCol * t, sideRow * t, t, t, 0, half, size, half);
ctx.globalAlpha = 1;
}
function setHotbarIndex(i) {
HOTBAR_INDEX = i;
document.querySelectorAll('.slot').forEach((el, idx) => {
el.classList.toggle('active', idx === i);
});
const blockId = HOTBAR[i];
const [zh, en] = BLOCK_NAME[blockId];
const lbl = document.getElementById('blockname');
lbl.textContent = `${zh} · ${en}`;
lbl.classList.add('show');
clearTimeout(setHotbarIndex._t);
setHotbarIndex._t = setTimeout(() => lbl.classList.remove('show'), 1500);
}
/* =========================================================================
Raycast for break/place
========================================================================= */
function raycastBlock() {
// Use camera direction
const origin = camera.getWorldPosition(new THREE.Vector3());
const dir = new THREE.Vector3();
camera.getWorldDirection(dir);
// DDA voxel traversal
const stepX = Math.sign(dir.x);
const stepY = Math.sign(dir.y);
const stepZ = Math.sign(dir.z);
let x = Math.floor(origin.x);
let y = Math.floor(origin.y);
let z = Math.floor(origin.z);
const tDeltaX = stepX !== 0 ? Math.abs(1 / dir.x) : Infinity;
const tDeltaY = stepY !== 0 ? Math.abs(1 / dir.y) : Infinity;
const tDeltaZ = stepZ !== 0 ? Math.abs(1 / dir.z) : Infinity;
let tMaxX = stepX > 0 ? (x + 1 - origin.x) * tDeltaX : stepX < 0 ? (origin.x - x) * tDeltaX : Infinity;
let tMaxY = stepY > 0 ? (y + 1 - origin.y) * tDeltaY : stepY < 0 ? (origin.y - y) * tDeltaY : Infinity;
let tMaxZ = stepZ > 0 ? (z + 1 - origin.z) * tDeltaZ : stepZ < 0 ? (origin.z - z) * tDeltaZ : Infinity;
let t = 0;
let face = null;
while (t <= REACH) {
const id = world.getBlock(x, y, z);
if (id !== Block.AIR && id !== Block.WATER) {
return { x, y, z, face, id };
}
if (tMaxX < tMaxY && tMaxX < tMaxZ) {
t = tMaxX; tMaxX += tDeltaX; x += stepX;
face = stepX > 0 ? 'west' : 'east';
} else if (tMaxY < tMaxZ) {
t = tMaxY; tMaxY += tDeltaY; y += stepY;
face = stepY > 0 ? 'bottom' : 'top';
} else {
t = tMaxZ; tMaxZ += tDeltaZ; z += stepZ;
face = stepZ > 0 ? 'north' : 'south';
}
}
return null;
}
function handleBreak() {
const hit = raycastBlock();
if (!hit) return;
if (hit.id === Block.BEDROCK) return; // unbreakable
world.setBlock(hit.x, hit.y, hit.z, Block.AIR);
spawnBreakParticles(hit.x + 0.5, hit.y + 0.5, hit.z + 0.5, hit.id);
// Flash break overlay
const bo = document.getElementById('break-overlay');
bo.classList.remove('show'); void bo.offsetWidth; bo.classList.add('show');
setTimeout(() => bo.classList.remove('show'), 150);
}
function handlePlace() {
const hit = raycastBlock();
if (!hit) return;
const offsets = {
top: [0, 1, 0],
bottom: [0, -1, 0],
east: [1, 0, 0],
west: [-1, 0, 0],
south: [0, 0, 1],
north: [0, 0, -1],
};
const off = offsets[hit.face];
if (!off) return;
const px = hit.x + off[0], py = hit.y + off[1], pz = hit.z + off[2];
// Check player intersection
if (playerIntersects(px, py, pz)) return;
if (world.getBlock(px, py, pz) !== Block.AIR) return;
world.setBlock(px, py, pz, HOTBAR[HOTBAR_INDEX]);
}
function playerIntersects(bx, by, bz) {
const p = controls.getObject().position;
const r = PLAYER_RADIUS + 0.05;
const top = p.y + 0.2;
const bot = p.y - PLAYER_HEIGHT;
// Block AABB: (bx, by, bz) to (bx+1, by+1, bz+1)
if (p.x + r < bx || p.x - r > bx + 1) return false;
if (p.z + r < bz || p.z - r > bz + 1) return false;
if (top < by || bot > by + 1) return false;
return true;
}
/* =========================================================================
Particles (for block breaking)
========================================================================= */
const particles = [];
const particleGroup = new THREE.Group();
scene.add(particleGroup);
function spawnBreakParticles(x, y, z, blockId) {
const topIdx = texForFace(blockId, 'top');
const tCol = topIdx % ATLAS.COLS;
const tRow = Math.floor(topIdx / ATLAS.COLS);
// Sample colors from canvas
const c = ATLAS.canvas.getContext('2d');
for (let i = 0; i < 6; i++) {
const px = tCol * ATLAS.T + Math.floor(Math.random() * ATLAS.T);
const py = tRow * ATLAS.T + Math.floor(Math.random() * ATLAS.T);
const d = c.getImageData(px, py, 1, 1).data;
const mat = new THREE.MeshBasicMaterial({ color: new THREE.Color(`rgb(${d[0]},${d[1]},${d[2]})`) });
const geo = new THREE.BoxGeometry(0.12, 0.12, 0.12);
const mesh = new THREE.Mesh(geo, mat);
mesh.position.set(x + (Math.random() - 0.5) * 0.4, y + (Math.random() - 0.5) * 0.4, z + (Math.random() - 0.5) * 0.4);
const part = {
mesh,
vel: new THREE.Vector3((Math.random() - 0.5) * 4, 4 + Math.random() * 3, (Math.random() - 0.5) * 4),
life: 0.7 + Math.random() * 0.3,
};
particles.push(part);
particleGroup.add(mesh);
}
}
function updateParticles(dt) {
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i];
p.life -= dt;
if (p.life <= 0) {
particleGroup.remove(p.mesh);
p.mesh.geometry.dispose();
p.mesh.material.dispose();
particles.splice(i, 1);
continue;
}
p.vel.y -= 20 * dt;
p.mesh.position.x += p.vel.x * dt;
p.mesh.position.y += p.vel.y * dt;
p.mesh.position.z += p.vel.z * dt;
p.mesh.rotation.x += dt * 4;
p.mesh.rotation.z += dt * 3;
p.mesh.material.opacity = Math.min(1, p.life * 1.3);
p.mesh.material.transparent = true;
}
}
/* =========================================================================
Physics
========================================================================= */
function checkBlock(x, y, z) {
return isSolid(world.getBlock(Math.floor(x), Math.floor(y), Math.floor(z)));
}
function aabbCollides(x, y, z) {
const r = PLAYER_RADIUS;
const yMin = y - PLAYER_HEIGHT;
const yMax = y + 0.2;
for (let bx = Math.floor(x - r); bx <= Math.floor(x + r); bx++) {
for (let bz = Math.floor(z - r); bz <= Math.floor(z + r); bz++) {
for (let by = Math.floor(yMin); by <= Math.floor(yMax); by++) {
if (isSolid(world.getBlock(bx, by, bz))) return true;
}
}
}
return false;
}
function updatePlayerPhysics(dt) {
const obj = controls.getObject();
// Strip any previous bob offset so physics works on the base position
obj.position.y -= playerState.bobY;
playerState.bobY = 0;
const forward = new THREE.Vector3();
camera.getWorldDirection(forward); forward.y = 0; forward.normalize();
const right = new THREE.Vector3().crossVectors(forward, new THREE.Vector3(0, 1, 0)).normalize();
let dx = 0, dz = 0;
if (keys.has('KeyW')) { dx += forward.x; dz += forward.z; }
if (keys.has('KeyS')) { dx -= forward.x; dz -= forward.z; }
if (keys.has('KeyA')) { dx -= right.x; dz -= right.z; }
if (keys.has('KeyD')) { dx += right.x; dz += right.z; }
const mag = Math.hypot(dx, dz);
if (mag > 0) { dx /= mag; dz /= mag; }
const sprint = keys.has('ShiftLeft') || keys.has('ShiftRight');
let speed = playerState.flying ? FLY_SPEED : (sprint ? SPRINT_SPEED : WALK_SPEED);
if (playerState.inWater) speed *= 0.55;
if (playerState.flying) {
playerState.velocity.x = dx * speed;
playerState.velocity.z = dz * speed;
if (keys.has('Space')) playerState.velocity.y = speed;
else if (keys.has('ControlLeft') || keys.has('ControlRight')) playerState.velocity.y = -speed;
else playerState.velocity.y = 0;
} else {
playerState.velocity.x = dx * speed;
playerState.velocity.z = dz * speed;
playerState.velocity.y -= GRAVITY * dt;
if (playerState.inWater) {
playerState.velocity.y = Math.max(playerState.velocity.y, -3);
if (keys.has('Space')) playerState.velocity.y = 4;
}
playerState.velocity.y = Math.max(playerState.velocity.y, -55);
}
const newPos = obj.position.clone();
newPos.x += playerState.velocity.x * dt;
if (aabbCollides(newPos.x, newPos.y, newPos.z)) {
newPos.x = obj.position.x;
playerState.velocity.x = 0;
}
newPos.z += playerState.velocity.z * dt;
if (aabbCollides(newPos.x, newPos.y, newPos.z)) {
newPos.z = obj.position.z;
playerState.velocity.z = 0;
}
newPos.y += playerState.velocity.y * dt;
if (aabbCollides(newPos.x, newPos.y, newPos.z)) {
if (playerState.velocity.y < 0) {
playerState.onGround = true;
}
newPos.y = obj.position.y;
playerState.velocity.y = 0;
} else {
playerState.onGround = false;
}
obj.position.copy(newPos);
// Water detection (player feet in water)
const feetBlock = world.getBlock(Math.floor(obj.position.x), Math.floor(obj.position.y - PLAYER_HEIGHT + 0.5), Math.floor(obj.position.z));
playerState.inWater = feetBlock === Block.WATER;
// Subtle walk bob applied as a transient offset (does not affect physics state)
const walking = (Math.abs(playerState.velocity.x) > 0.5 || Math.abs(playerState.velocity.z) > 0.5);
if (controls.isLocked && walking && playerState.onGround && !playerState.flying) {
playerState.walkPhase += dt * 8;
playerState.bobY = Math.sin(playerState.walkPhase) * 0.05;
} else {
playerState.walkPhase *= 0.85;
}
obj.position.y += playerState.bobY;
// Falling into the void
if (obj.position.y < -10) {
obj.position.set(0, SEA_LEVEL + 8, 14);
playerState.velocity.set(0, 0, 0);
playerState.bobY = 0;
}
}
/* =========================================================================
Chunk streaming
========================================================================= */
const generationQueue = [];
function queueChunks(playerX, playerZ) {
const pcx = Math.floor(playerX / CHUNK_SIZE);
const pcz = Math.floor(playerZ / CHUNK_SIZE);
// Add needed chunks sorted by distance
const pending = [];
for (let dz = -RENDER_DISTANCE; dz <= RENDER_DISTANCE; dz++) {
for (let dx = -RENDER_DISTANCE; dx <= RENDER_DISTANCE; dx++) {
const cx = pcx + dx, cz = pcz + dz;
const dist = dx * dx + dz * dz;
const k = world.key(cx, cz);
const c = world.chunks.get(k);
if (!c || !c.generated || c.dirty || !c.meshed) {
pending.push({ cx, cz, dist });
}
}
}
pending.sort((a, b) => a.dist - b.dist);
for (const p of pending) {
const k = world.key(p.cx, p.cz);
if (!generationQueue.find(q => q.key === k)) {
generationQueue.push({ key: k, cx: p.cx, cz: p.cz });
}
}
// Hide chunks that are too far
for (const [k, c] of world.chunks) {
const dx = Math.abs(c.cx - pcx);
const dz = Math.abs(c.cz - pcz);
if (Math.max(dx, dz) > RENDER_DISTANCE + 1) {
if (c.group.parent === scene) scene.remove(c.group);
} else {
if (c.group.parent !== scene) scene.add(c.group);
}
}
}
function processGeneration(maxMs = 6) {
const start = performance.now();
while (generationQueue.length > 0 && performance.now() - start < maxMs) {
const task = generationQueue.shift();
const c = world.ensureChunk(task.cx, task.cz);
if (!c.generated) {
world.generateChunk(c);
world.placeStructuresIfNeeded(c);
}
if (c.dirty || !c.meshed) {
meshChunk(world, c);
if (c.group.parent !== scene) scene.add(c.group);
}
}
}
/* =========================================================================
Pre-generate spawn area (around origin to include all structures)
========================================================================= */
async function preloadSpawn(progressCb) {
// Spawn radius covers all known structures within ~64 blocks of origin
const R = 6; // 13x13 chunks
const tasks = [];
for (let dz = -R; dz <= R; dz++) {
for (let dx = -R; dx <= R; dx++) {
tasks.push({ cx: dx, cz: dz });
}
}
// Sort by distance from origin
tasks.sort((a, b) => (a.cx * a.cx + a.cz * a.cz) - (b.cx * b.cx + b.cz * b.cz));
// Pass 1: generate base terrain
for (let i = 0; i < tasks.length; i++) {
const c = world.ensureChunk(tasks[i].cx, tasks[i].cz);
world.generateChunk(c);
if (i % 8 === 0) {
progressCb(0.4 * i / tasks.length, '生成地形 · Generating terrain');
await new Promise(r => setTimeout(r, 0));
}
}
// Pass 2: place structures
for (let i = 0; i < tasks.length; i++) {
const c = world.chunks.get(world.key(tasks[i].cx, tasks[i].cz));
if (c) world.placeStructuresIfNeeded(c);
if (i % 16 === 0) {
progressCb(0.4 + 0.2 * i / tasks.length, '建造神话遗迹 · Erecting myth-temples');
await new Promise(r => setTimeout(r, 0));
}
}
// Pass 3: mesh chunks
for (let i = 0; i < tasks.length; i++) {
const c = world.chunks.get(world.key(tasks[i].cx, tasks[i].cz));
if (c) {
meshChunk(world, c);
scene.add(c.group);
}
if (i % 4 === 0) {
progressCb(0.6 + 0.4 * i / tasks.length, '编织网格 · Weaving meshes');
await new Promise(r => setTimeout(r, 0));
}
}
progressCb(1, '完成 · Ready');
}
/* =========================================================================
Game loop
========================================================================= */
const clock = new THREE.Clock();
let fpsAccum = 0, fpsFrames = 0, fpsDisplay = 0;
function animate() {
const dt = Math.min(clock.getDelta(), 0.066);
if (controls.isLocked) {
updatePlayerPhysics(dt);
}
updateParticles(dt);
const obj = controls.getObject();
queueChunks(obj.position.x, obj.position.z);
processGeneration(8);
// Block highlight
const hit = raycastBlock();
if (hit) {
highlightLines.visible = true;
highlightLines.position.set(hit.x + 0.5, hit.y + 0.5, hit.z + 0.5);
} else {
highlightLines.visible = false;
}
// Sun position follows the world a bit
sun.position.set(obj.position.x + 80, 140, obj.position.z + 50);
// Sky moves with player
sky.position.copy(obj.position);
renderer.render(scene, camera);
// FPS
fpsAccum += dt; fpsFrames++;
if (fpsAccum >= 0.5) {
fpsDisplay = Math.round(fpsFrames / fpsAccum);
fpsAccum = 0; fpsFrames = 0;
updateDebug();
}
requestAnimationFrame(animate);
}
function updateDebug() {
const dbg = document.getElementById('debug');
if (dbg.classList.contains('hidden')) return;
const p = controls.getObject().position;
const dir = new THREE.Vector3();
camera.getWorldDirection(dir);
const compass = (function() {
const a = (Math.atan2(dir.x, -dir.z) * 180 / Math.PI + 360) % 360;
if (a < 22.5 || a >= 337.5) return 'N';
if (a < 67.5) return 'NE';
if (a < 112.5) return 'E';
if (a < 157.5) return 'SE';
if (a < 202.5) return 'S';
if (a < 247.5) return 'SW';
if (a < 292.5) return 'W';
return 'NW';
})();
dbg.innerHTML = `
<div>MythosCraft · ${fpsDisplay} FPS</div>
<div>XYZ: ${p.x.toFixed(1)} / ${p.y.toFixed(1)} / ${p.z.toFixed(1)}</div>
<div>Chunk: ${Math.floor(p.x / CHUNK_SIZE)} / ${Math.floor(p.z / CHUNK_SIZE)} · loaded ${world.chunks.size}</div>
<div>Facing: ${compass} (${dir.x.toFixed(2)}, ${dir.z.toFixed(2)})</div>
<div>Mode: ${playerState.flying ? '飞行 / Flying' : (playerState.inWater ? '游泳 / Swimming' : '步行 / Walking')}</div>
<div>Hotbar: ${BLOCK_NAME[HOTBAR[HOTBAR_INDEX]][0]} · ${BLOCK_NAME[HOTBAR[HOTBAR_INDEX]][1]}</div>
`;
}
// Compass at top
function updateCompass() {
const dir = new THREE.Vector3();
camera.getWorldDirection(dir);
const a = (Math.atan2(dir.x, -dir.z) * 180 / Math.PI + 360) % 360;
const dirs = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
document.getElementById('compass').textContent = dirs[Math.round(a / 45) % 8];
requestAnimationFrame(updateCompass);
}
updateCompass();
/* =========================================================================
Resize
========================================================================= */
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
/* =========================================================================
Init & start flow
========================================================================= */
const overlay = document.getElementById('overlay');
const introEl = document.getElementById('intro');
const loadingEl = document.getElementById('loading');
const pauseEl = document.getElementById('pause');
buildHotbarUI();
setHotbarIndex(0);
document.getElementById('start').addEventListener('click', async () => {
introEl.style.display = 'none';
loadingEl.style.display = 'block';
const bar = document.getElementById('progress-bar');
const ptext = document.getElementById('progress-text');
await preloadSpawn((p, text) => {
bar.style.width = (p * 100).toFixed(0) + '%';
ptext.textContent = text;
});
// Spawn on the south plaza of Stonehenge, facing the altar.
const sx = 0, sz = 11;
// Find the true ground (skip non-solid and tree blocks)
let groundY = CHUNK_HEIGHT - 1;
while (groundY > 0) {
const b = world.getBlock(sx, groundY, sz);
if (b !== Block.AIR && b !== Block.WATER && b !== Block.LEAVES && b !== Block.WOOD) break;
groundY--;
}
// Clear a wider 5x5x4 safe area above ground so trees never block the spawn view
for (let dy = 1; dy <= 4; dy++) {
for (let dx = -2; dx <= 2; dx++) {
for (let dz = -2; dz <= 2; dz++) {
const cur = world.getBlock(sx + dx, groundY + dy, sz + dz);
if (cur !== Block.AIR && cur !== Block.WATER) {
world.setBlock(sx + dx, groundY + dy, sz + dz, Block.AIR);
}
}
}
}
const feetY = groundY + 1;
controls.getObject().position.set(sx + 0.5, feetY + PLAYER_HEIGHT, sz + 0.5);
playerState.velocity.set(0, 0, 0);
playerState.bobY = 0;
// Aim the camera at the Stonehenge altar (looking slightly down at it, facing -Z)
camera.rotation.set(-0.18, 0, 0);
camera.quaternion.setFromEuler(camera.rotation);
overlay.classList.add('hidden');
setTimeout(() => { overlay.style.display = 'none'; }, 600);
controls.lock();
clock.start();
animate();
});
// Expose for debugging
window.__mythos = { controls, camera, world, scene, playerState, Block };
controls.addEventListener('lock', () => {
pauseEl.classList.remove('show');
});
controls.addEventListener('unlock', () => {
// Only show pause if we've started the game
if (overlay.style.display === 'none') pauseEl.classList.add('show');
});
document.getElementById('resume').addEventListener('click', () => controls.lock());
</script>
</body>
</html>