Files
nagi-bench/outputs/gpt-5-2/cursor-xhigh/mythos-craft.html
T
Jesse ZhangandClaude Opus 4.8 3d5bf6967c Normalize artifact filenames and register new runs (#17)
Rename all output artifacts to their canonical case-id names
(mythos-craft.html / pelican-cycling.svg / skeleton-watch.html, with
-2 suffixes for variants) and drop the now-redundant "file" overrides
from the corresponding models/*.json runs.

Register new Claude Code runs for Opus 4.6 and 4.7 (new model JSONs),
add the GPT-5.5 Pro skeleton-watch run, and regenerate the README
registry tables. Remove stray .DS_Store files from outputs/.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 23:36:38 +01:00

1849 lines
66 KiB
HTML

<!doctype html>
<html lang="zh-Hans">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>MythicCraft - Voxel Sandbox</title>
<style>
:root {
--bg: #070a12;
--panel: rgba(10, 14, 26, 0.72);
--panel2: rgba(10, 14, 26, 0.9);
--text: rgba(255, 255, 255, 0.92);
--muted: rgba(255, 255, 255, 0.72);
--faint: rgba(255, 255, 255, 0.42);
--accent: #7ee7ff;
--danger: #ff6a6a;
}
html,
body {
height: 100%;
margin: 0;
background: var(--bg);
overflow: hidden;
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial;
}
#wrap {
position: fixed;
inset: 0;
}
canvas {
width: 100%;
height: 100%;
display: block;
image-rendering: pixelated;
cursor: crosshair;
}
#overlay {
position: fixed;
inset: 0;
display: grid;
place-items: center;
background: radial-gradient(1200px 700px at 50% 40%, rgba(126, 231, 255, 0.16), rgba(7, 10, 18, 0.96) 70%);
color: var(--text);
padding: 28px 18px;
user-select: none;
}
#overlay[hidden] {
display: none;
}
.card {
width: min(980px, 94vw);
background: var(--panel);
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 16px;
padding: 18px 18px 14px;
box-shadow: 0 22px 70px rgba(0, 0, 0, 0.45);
backdrop-filter: blur(10px);
}
.titleRow {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 14px;
flex-wrap: wrap;
}
h1 {
margin: 0;
font-size: 22px;
letter-spacing: 0.2px;
}
.sub {
color: var(--muted);
font-size: 12px;
line-height: 1.35;
max-width: 56ch;
}
.grid {
display: grid;
grid-template-columns: 1.1fr 0.9fr;
gap: 14px;
margin-top: 12px;
}
@media (max-width: 860px) {
.grid {
grid-template-columns: 1fr;
}
}
.panel {
background: var(--panel2);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 14px;
padding: 12px 12px 10px;
}
.panel h2 {
margin: 0 0 8px;
font-size: 13px;
font-weight: 650;
color: rgba(255, 255, 255, 0.88);
}
ul {
margin: 0;
padding-left: 18px;
color: var(--muted);
font-size: 12px;
line-height: 1.5;
}
.kbd {
display: inline-block;
padding: 0 6px;
border: 1px solid rgba(255, 255, 255, 0.18);
border-bottom-color: rgba(255, 255, 255, 0.12);
border-radius: 8px;
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.92);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 12px;
line-height: 22px;
height: 22px;
vertical-align: baseline;
white-space: nowrap;
}
.ctaRow {
display: flex;
gap: 10px;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
margin-top: 12px;
}
button {
appearance: none;
border: 1px solid rgba(126, 231, 255, 0.36);
background: rgba(126, 231, 255, 0.12);
color: rgba(255, 255, 255, 0.92);
border-radius: 12px;
padding: 10px 12px;
font-size: 13px;
font-weight: 650;
letter-spacing: 0.2px;
cursor: pointer;
transition: transform 0.08s ease, background 0.16s ease, border-color 0.16s ease;
}
button:hover {
background: rgba(126, 231, 255, 0.18);
border-color: rgba(126, 231, 255, 0.6);
}
button:active {
transform: translateY(1px);
}
.status {
font-size: 12px;
color: var(--muted);
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
.pill {
display: inline-flex;
gap: 8px;
align-items: center;
padding: 6px 10px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(0, 0, 0, 0.18);
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 18px rgba(126, 231, 255, 0.55);
}
.muted {
color: var(--faint);
}
#hud {
position: fixed;
inset: 0;
pointer-events: none;
color: var(--text);
}
#crosshair {
position: absolute;
left: 50%;
top: 50%;
width: 16px;
height: 16px;
transform: translate(-50%, -50%);
opacity: 0.86;
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.55));
}
#crosshair:before,
#crosshair:after {
content: "";
position: absolute;
left: 50%;
top: 50%;
width: 2px;
height: 16px;
transform: translate(-50%, -50%);
background: rgba(255, 255, 255, 0.88);
}
#crosshair:after {
width: 16px;
height: 2px;
}
#hotbar {
position: absolute;
left: 50%;
bottom: 16px;
transform: translateX(-50%);
display: flex;
gap: 8px;
padding: 10px 12px;
border-radius: 14px;
background: rgba(0, 0, 0, 0.28);
border: 1px solid rgba(255, 255, 255, 0.12);
backdrop-filter: blur(6px);
}
.slot {
width: 44px;
height: 44px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.16);
background: rgba(255, 255, 255, 0.06);
position: relative;
overflow: hidden;
}
.slot.sel {
border-color: rgba(126, 231, 255, 0.7);
box-shadow: 0 0 0 2px rgba(126, 231, 255, 0.16);
}
.slot canvas {
width: 100%;
height: 100%;
}
.slot .num {
position: absolute;
left: 6px;
top: 4px;
font-size: 11px;
font-weight: 750;
color: rgba(255, 255, 255, 0.72);
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.7);
}
#debug {
position: absolute;
left: 12px;
top: 10px;
padding: 10px 12px;
border-radius: 14px;
background: rgba(0, 0, 0, 0.26);
border: 1px solid rgba(255, 255, 255, 0.12);
backdrop-filter: blur(6px);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 11px;
line-height: 1.42;
color: rgba(255, 255, 255, 0.84);
white-space: pre;
max-width: min(74ch, 92vw);
}
#toast {
position: absolute;
right: 12px;
top: 10px;
padding: 10px 12px;
border-radius: 14px;
background: rgba(0, 0, 0, 0.32);
border: 1px solid rgba(255, 255, 255, 0.12);
backdrop-filter: blur(6px);
font-size: 12px;
line-height: 1.35;
color: rgba(255, 255, 255, 0.88);
max-width: min(60ch, 92vw);
}
#toast[hidden] {
display: none;
}
.hint {
color: rgba(255, 255, 255, 0.72);
}
</style>
</head>
<body>
<div id="wrap"></div>
<div id="hud" aria-hidden="true">
<div id="crosshair"></div>
<div id="debug"></div>
<div id="toast" hidden></div>
<div id="hotbar" aria-label="hotbar"></div>
</div>
<div id="overlay">
<div class="card">
<div class="titleRow">
<div>
<h1>MythicCraft:体素沙盒(单文件)</h1>
<div class="sub">
Perlin 噪声无限分块地形 + 第一人称(指针锁)+ 拆放方块 + 内置神话幻想地形/建筑群(世界树、浮空神殿、龙骨峡谷、水晶尖塔、古代遗迹)。
<div class="muted" style="margin-top: 6px">
MythicCraft: infinite Perlin terrain, FPS controls, voxel break/place, and built-in mythic landmarks.
</div>
</div>
</div>
<div class="status">
<span class="pill"><span class="dot"></span><span id="loadLine">正在准备世界…</span></span>
<span class="pill muted">提示:点击后会请求鼠标锁定 / Pointer Lock</span>
</div>
</div>
<div class="grid">
<div class="panel">
<h2>操作 / Controls</h2>
<ul>
<li><span class="kbd">W A S D</span> 移动 / Move</li>
<li><span class="kbd">鼠标移动</span> 环视 / Look</li>
<li><span class="kbd">Space</span> 跳跃(行走模式)/ Jump (walk mode)</li>
<li><span class="kbd">F</span> 切换飞行 / Toggle fly</li>
<li><span class="kbd">Shift</span> 下落(飞行)/ Down (fly)</li>
<li><span class="kbd">1-6</span> 选择方块 / Select block</li>
<li><span class="kbd">左键</span> 破坏方块 / Break</li>
<li><span class="kbd">右键</span> 放置方块 / Place</li>
<li><span class="kbd">Esc</span> 释放鼠标 / Unlock</li>
</ul>
</div>
<div class="panel">
<h2>神话地标 / Mythic Landmarks</h2>
<ul>
<li><b>世界树 / World Tree</b>:出生点附近的巨树与符文祭坛</li>
<li><b>浮空神殿 / Sky Temple</b>:东南方向的漂浮岛屿与传送环</li>
<li><b>龙骨峡谷 / Dragonbone Ravine</b>:西北方向的骨架脊梁与肋骨拱门</li>
<li><b>水晶尖塔 / Crystal Spires</b>:西南方向的符文尖塔群</li>
<li><b>古代遗迹 / Ancient Ruins</b>:南方方向的断墙、拱门与柱廊</li>
</ul>
<div class="muted" style="margin-top: 10px; font-size: 12px; line-height: 1.45">
你可以开启飞行快速巡游。坐标与目标方块信息会显示在左上角调试面板。
<div class="hint">Use fly mode for quick sightseeing; coords and target info show in the top-left HUD.</div>
</div>
</div>
</div>
<div class="ctaRow">
<button id="startBtn" type="button">点击开始 / Click to Start</button>
<div class="status">
<span class="pill"><span class="muted">渲染</span> <span id="statFps">--</span> FPS</span>
<span class="pill"><span class="muted">区块</span> <span id="statChunks">--</span></span>
<span class="pill"><span class="muted">种子</span> <span id="statSeed">--</span></span>
</div>
</div>
</div>
</div>
<script src="https://unpkg.com/three@0.164.1/build/three.min.js"></script>
<script>
(() => {
"use strict";
const $ = (sel) => document.querySelector(sel);
if (!window.THREE) {
$("#loadLine").textContent = "Three.js 加载失败(需要网络或允许外链脚本)。";
return;
}
const WORLD_SEED = 1337;
const CHUNK_SIZE = 16;
const CHUNK_HEIGHT = 96;
const VIEW_RADIUS = 5;
const BLOCK = Object.freeze({
AIR: 0,
GRASS: 1,
DIRT: 2,
STONE: 3,
WOOD: 4,
LEAF: 5,
RUNE: 6,
BONE: 7,
OBSIDIAN: 8,
SAND: 9,
});
const BLOCK_NAME = Object.freeze({
[BLOCK.AIR]: "空气 / Air",
[BLOCK.GRASS]: "草方块 / Grass",
[BLOCK.DIRT]: "泥土 / Dirt",
[BLOCK.STONE]: "石头 / Stone",
[BLOCK.WOOD]: "木头 / Wood",
[BLOCK.LEAF]: "树叶 / Leaf",
[BLOCK.RUNE]: "符文石 / Rune",
[BLOCK.BONE]: "龙骨 / Bone",
[BLOCK.OBSIDIAN]: "黑曜石 / Obsidian",
[BLOCK.SAND]: "沙子 / Sand",
});
const PALETTE = [
{ id: BLOCK.GRASS, label: "Grass" },
{ id: BLOCK.DIRT, label: "Dirt" },
{ id: BLOCK.STONE, label: "Stone" },
{ id: BLOCK.WOOD, label: "Wood" },
{ id: BLOCK.LEAF, label: "Leaf" },
{ id: BLOCK.RUNE, label: "Rune" },
];
function clamp(v, a, b) {
return Math.max(a, Math.min(b, v));
}
function floorDiv(n, d) {
return Math.floor(n / d);
}
function mod(n, d) {
const r = n % d;
return r < 0 ? r + d : r;
}
function xorshift32(seed) {
let x = seed | 0;
return () => {
x |= 0;
x ^= x << 13;
x ^= x >>> 17;
x ^= x << 5;
return (x >>> 0) / 4294967296;
};
}
function hash2i(a, b) {
let h = (a | 0) * 374761393;
h = (h + (b | 0) * 668265263) | 0;
h = (h ^ (h >>> 13)) | 0;
h = Math.imul(h, 1274126177);
h ^= WORLD_SEED | 0;
return (h ^ (h >>> 16)) >>> 0;
}
class Perlin {
constructor(seed) {
const p = new Uint8Array(256);
for (let i = 0; i < 256; i++) p[i] = i;
const r = xorshift32(seed ^ 0x9e3779b9);
for (let i = 255; i > 0; i--) {
const j = (r() * (i + 1)) | 0;
const tmp = p[i];
p[i] = p[j];
p[j] = tmp;
}
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(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;
const xf = x - Math.floor(x);
const yf = y - Math.floor(y);
const zf = z - Math.floor(z);
const u = this.fade(xf);
const v = this.fade(yf);
const w = this.fade(zf);
const A = this.perm[X] + Y;
const AA = this.perm[A] + Z;
const AB = this.perm[A + 1] + Z;
const B = this.perm[X + 1] + Y;
const BA = this.perm[B] + Z;
const BB = this.perm[B + 1] + Z;
const x1 = this.lerp(
u,
this.grad(this.perm[AA], xf, yf, zf),
this.grad(this.perm[BA], xf - 1, yf, zf)
);
const x2 = this.lerp(
u,
this.grad(this.perm[AB], xf, yf - 1, zf),
this.grad(this.perm[BB], xf - 1, yf - 1, zf)
);
const y1 = this.lerp(v, x1, x2);
const x3 = this.lerp(
u,
this.grad(this.perm[AA + 1], xf, yf, zf - 1),
this.grad(this.perm[BA + 1], xf - 1, yf, zf - 1)
);
const x4 = this.lerp(
u,
this.grad(this.perm[AB + 1], xf, yf - 1, zf - 1),
this.grad(this.perm[BB + 1], xf - 1, yf - 1, zf - 1)
);
const y2 = this.lerp(v, x3, x4);
return this.lerp(w, y1, y2);
}
noise2(x, z) {
return this.noise(x, 0, z);
}
}
const perlin = new Perlin(WORLD_SEED);
function fbm2(x, z) {
let sum = 0;
let amp = 1;
let freq = 1;
for (let o = 0; o < 4; o++) {
sum += perlin.noise2(x * freq, z * freq) * amp;
amp *= 0.5;
freq *= 2;
}
return sum;
}
function ridged2(x, z) {
const n = perlin.noise2(x, z);
return 1 - Math.abs(n);
}
function heightAt(wx, wz) {
const h0 = fbm2(wx * 0.008, wz * 0.008);
const h1 = ridged2(wx * 0.02, wz * 0.02);
const h2 = perlin.noise2(wx * 0.002, wz * 0.002);
const base = 22;
const hills = h0 * 14;
const ridges = Math.pow(clamp(h1, 0, 1), 2.4) * 20;
const continent = h2 * 8;
let h = base + hills + ridges + continent;
// Mythic sanctuary near origin: keep the World Tree visible and navigable.
const d0 = Math.hypot(wx, wz);
if (d0 < 64) {
const t = clamp((64 - d0) / 64, 0, 1);
const micro = perlin.noise2(wx * 0.05, wz * 0.05) * 2.2;
const target = 28 + micro;
h = h * (1 - t) + target * t;
}
// Reserve headroom for tall myth structures.
return clamp(Math.floor(h), 2, CHUNK_HEIGHT - 30);
}
function biomeAt(wx, wz) {
const b = perlin.noise2(wx * 0.004, wz * 0.004);
const t = clamp((b + 1) * 0.5, 0, 1);
return t;
}
function caveAt(wx, wy, wz) {
const n = perlin.noise(wx * 0.06, wy * 0.06, wz * 0.06);
const m = perlin.noise(wx * 0.13, wy * 0.09, wz * 0.11);
return n * 0.78 + m * 0.22;
}
const ATLAS = (() => {
const tile = 16;
const cols = 4;
const rows = 4;
const canvas = document.createElement("canvas");
canvas.width = tile * cols;
canvas.height = tile * rows;
const ctx = canvas.getContext("2d", { alpha: true });
ctx.imageSmoothingEnabled = false;
function rgb(hex) {
const h = hex.replace("#", "");
return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
}
function mix(a, b, t) {
return [
Math.round(a[0] + (b[0] - a[0]) * t),
Math.round(a[1] + (b[1] - a[1]) * t),
Math.round(a[2] + (b[2] - a[2]) * t),
];
}
function pix(img, x, y, c, a = 255) {
const i = (y * img.width + x) * 4;
img.data[i + 0] = c[0];
img.data[i + 1] = c[1];
img.data[i + 2] = c[2];
img.data[i + 3] = a;
}
function fillTile(tx, ty, paint) {
const img = ctx.createImageData(tile, tile);
paint(img, tx, ty);
ctx.putImageData(img, tx * tile, ty * tile);
}
const C = {
grass1: rgb("#6dbb3a"),
grass2: rgb("#4e9f2b"),
grass3: rgb("#89d24e"),
dirt1: rgb("#8a5a32"),
dirt2: rgb("#6f4528"),
dirt3: rgb("#a06b3c"),
stone1: rgb("#9aa2a9"),
stone2: rgb("#7f8790"),
stone3: rgb("#b3bcc4"),
wood1: rgb("#8a6a3f"),
wood2: rgb("#6e5331"),
wood3: rgb("#a67f4a"),
leaf1: rgb("#3fb34a"),
leaf2: rgb("#2f8f38"),
leaf3: rgb("#57d463"),
runeBase1: rgb("#2b2f3a"),
runeBase2: rgb("#1f2230"),
runeGlow1: rgb("#7ee7ff"),
runeGlow2: rgb("#2ad1ff"),
bone1: rgb("#e9e2d3"),
bone2: rgb("#cfc6b6"),
bone3: rgb("#f6f0e6"),
obs1: rgb("#15101f"),
obs2: rgb("#1f1630"),
obs3: rgb("#2b1b45"),
sand1: rgb("#d8c27a"),
sand2: rgb("#c7ad60"),
sand3: rgb("#ead58e"),
};
function tileRng(tx, ty, salt) {
const h = hash2i(tx * 101 + salt, ty * 137 + salt * 7);
return xorshift32(h);
}
function speckle(img, baseA, baseB, baseC, tx, ty, salt, topCap = 0) {
const r = tileRng(tx, ty, salt);
for (let y = 0; y < tile; y++) {
for (let x = 0; x < tile; x++) {
const t = r();
let c = baseA;
if (t > 0.86) c = baseC;
else if (t > 0.6) c = baseB;
if (topCap && y < topCap) {
const k = clamp((topCap - y) / topCap, 0, 1) * 0.12;
c = mix(c, [255, 255, 255], k);
}
pix(img, x, y, c, 255);
}
}
}
function drawRuneGlyph(img, tx, ty, salt) {
const r = tileRng(tx, ty, salt);
const strokes = 5 + ((r() * 4) | 0);
for (let i = 0; i < strokes; i++) {
const x0 = 2 + ((r() * 12) | 0);
const y0 = 2 + ((r() * 12) | 0);
const dx = -1 + ((r() * 3) | 0);
const dy = -1 + ((r() * 3) | 0);
const len = 4 + ((r() * 9) | 0);
const glow = r() > 0.7 ? C.runeGlow1 : C.runeGlow2;
for (let t = 0; t < len; t++) {
const x = clamp(x0 + dx * t, 1, 14);
const y = clamp(y0 + dy * t, 1, 14);
pix(img, x, y, glow, 255);
if (r() > 0.55) pix(img, clamp(x + 1, 0, 15), y, glow, 160);
if (r() > 0.55) pix(img, x, clamp(y + 1, 0, 15), glow, 160);
}
}
}
const TILE = Object.freeze({
grass_top: [0, 0],
grass_side: [1, 0],
dirt: [2, 0],
stone: [3, 0],
wood: [0, 1],
leaf: [1, 1],
rune: [2, 1],
bone: [3, 1],
obsidian: [0, 2],
sand: [1, 2],
});
fillTile(TILE.grass_top[0], TILE.grass_top[1], (img, tx, ty) => speckle(img, C.grass1, C.grass2, C.grass3, tx, ty, 11));
fillTile(TILE.dirt[0], TILE.dirt[1], (img, tx, ty) => speckle(img, C.dirt1, C.dirt2, C.dirt3, tx, ty, 17));
fillTile(TILE.stone[0], TILE.stone[1], (img, tx, ty) => speckle(img, C.stone1, C.stone2, C.stone3, tx, ty, 23));
fillTile(TILE.grass_side[0], TILE.grass_side[1], (img, tx, ty) => {
const r = tileRng(tx, ty, 29);
for (let y = 0; y < tile; y++) {
for (let x = 0; x < tile; x++) {
let c;
if (y < 5) {
const t = r();
c = t > 0.82 ? C.grass3 : t > 0.55 ? C.grass1 : C.grass2;
} else {
const t = r();
c = t > 0.86 ? C.dirt3 : t > 0.6 ? C.dirt1 : C.dirt2;
}
if (y === 5 && r() > 0.4) c = mix(c, [255, 255, 255], 0.08);
pix(img, x, y, c, 255);
}
}
});
fillTile(TILE.wood[0], TILE.wood[1], (img, tx, ty) => {
const r = tileRng(tx, ty, 31);
for (let y = 0; y < tile; y++) {
for (let x = 0; x < tile; x++) {
const stripe = Math.sin((x + 0.6) * 0.85) * 0.5 + 0.5;
let c = mix(C.wood2, C.wood1, stripe);
if (r() > 0.92) c = C.wood3;
if (x === 7 || x === 8) c = mix(c, [0, 0, 0], 0.1);
pix(img, x, y, c, 255);
}
}
const kx = 3 + ((r() * 10) | 0);
const ky = 3 + ((r() * 10) | 0);
for (let oy = -2; oy <= 2; oy++) {
for (let ox = -2; ox <= 2; ox++) {
const d = ox * ox + oy * oy;
if (d > 4) continue;
const c = d === 0 ? C.wood3 : mix(C.wood1, C.wood2, d / 4);
pix(img, clamp(kx + ox, 0, 15), clamp(ky + oy, 0, 15), c, 255);
}
}
});
fillTile(TILE.leaf[0], TILE.leaf[1], (img, tx, ty) => {
const r = tileRng(tx, ty, 37);
for (let y = 0; y < tile; y++) {
for (let x = 0; x < tile; x++) {
const t = r();
let c = t > 0.9 ? C.leaf3 : t > 0.6 ? C.leaf1 : C.leaf2;
if ((x + y) % 7 === 0) c = mix(c, [0, 0, 0], 0.08);
pix(img, x, y, c, 255);
}
}
});
fillTile(TILE.rune[0], TILE.rune[1], (img, tx, ty) => {
speckle(img, C.runeBase1, C.runeBase2, mix(C.runeBase1, [255, 255, 255], 0.07), tx, ty, 41);
drawRuneGlyph(img, tx, ty, 43);
});
fillTile(TILE.bone[0], TILE.bone[1], (img, tx, ty) => {
const r = tileRng(tx, ty, 47);
for (let y = 0; y < tile; y++) {
for (let x = 0; x < tile; x++) {
const t = r();
let c = t > 0.9 ? C.bone3 : t > 0.5 ? C.bone1 : C.bone2;
if ((x === 2 || x === 13) && r() > 0.35) c = mix(c, [0, 0, 0], 0.12);
pix(img, x, y, c, 255);
}
}
});
fillTile(TILE.obsidian[0], TILE.obsidian[1], (img, tx, ty) => {
const r = tileRng(tx, ty, 53);
for (let y = 0; y < tile; y++) {
for (let x = 0; x < tile; x++) {
const t = r();
let c = t > 0.92 ? C.obs3 : t > 0.6 ? C.obs2 : C.obs1;
if (r() > 0.985) c = mix(C.runeGlow2, C.obs3, 0.35);
pix(img, x, y, c, 255);
}
}
});
fillTile(TILE.sand[0], TILE.sand[1], (img, tx, ty) => speckle(img, C.sand1, C.sand2, C.sand3, tx, ty, 59, 0));
const tex = new THREE.CanvasTexture(canvas);
tex.flipY = false;
tex.magFilter = THREE.NearestFilter;
tex.minFilter = THREE.NearestFilter;
tex.generateMipmaps = false;
tex.colorSpace = THREE.SRGBColorSpace;
return { tile, cols, rows, tex, TILE };
})();
function tileUV(tx, ty) {
const u0 = tx / ATLAS.cols;
const u1 = (tx + 1) / ATLAS.cols;
const v0 = 1 - (ty + 1) / ATLAS.rows;
const v1 = 1 - ty / ATLAS.rows;
return { u0, v0, u1, v1 };
}
function faceTile(blockId, nx, ny, nz) {
const T = ATLAS.TILE;
if (blockId === BLOCK.GRASS) {
if (ny === 1) return T.grass_top;
if (ny === -1) return T.dirt;
return T.grass_side;
}
if (blockId === BLOCK.DIRT) return T.dirt;
if (blockId === BLOCK.STONE) return T.stone;
if (blockId === BLOCK.WOOD) return T.wood;
if (blockId === BLOCK.LEAF) return T.leaf;
if (blockId === BLOCK.RUNE) return T.rune;
if (blockId === BLOCK.BONE) return T.bone;
if (blockId === BLOCK.OBSIDIAN) return T.obsidian;
if (blockId === BLOCK.SAND) return T.sand;
return T.stone;
}
const wrap = $("#wrap");
const renderer = new THREE.WebGLRenderer({ antialias: false, alpha: false, powerPreference: "high-performance" });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.5));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.outputColorSpace = THREE.SRGBColorSpace;
wrap.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x8ecbff);
scene.fog = new THREE.FogExp2(0x8ecbff, 0.026);
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.05, 520);
camera.rotation.order = "YXZ";
const hemi = new THREE.HemisphereLight(0xbfe6ff, 0x25324d, 0.72);
scene.add(hemi);
const sun = new THREE.DirectionalLight(0xffffff, 0.86);
sun.position.set(0.4, 1, 0.25).normalize();
scene.add(sun);
const material = new THREE.MeshLambertMaterial({ map: ATLAS.tex });
const hitOutline = (() => {
const geo = new THREE.EdgesGeometry(new THREE.BoxGeometry(1.02, 1.02, 1.02));
const mat = new THREE.LineBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.85 });
const line = new THREE.LineSegments(geo, mat);
line.visible = false;
scene.add(line);
return line;
})();
class Chunk {
constructor(cx, cz, blocks) {
this.cx = cx;
this.cz = cz;
this.blocks = blocks;
this.modified = false;
this.dirty = true;
this.inMeshQueue = false;
this.mesh = new THREE.Mesh(new THREE.BufferGeometry(), material);
this.mesh.frustumCulled = true;
this.mesh.position.set(cx * CHUNK_SIZE, 0, cz * CHUNK_SIZE);
scene.add(this.mesh);
}
idx(x, y, z) {
return x + CHUNK_SIZE * (z + CHUNK_SIZE * y);
}
getLocal(x, y, z) {
if (x < 0 || z < 0 || y < 0 || x >= CHUNK_SIZE || z >= CHUNK_SIZE || y >= CHUNK_HEIGHT) return BLOCK.AIR;
return this.blocks[this.idx(x, y, z)];
}
setLocal(x, y, z, v) {
if (x < 0 || z < 0 || y < 0 || x >= CHUNK_SIZE || z >= CHUNK_SIZE || y >= CHUNK_HEIGHT) return false;
const i = this.idx(x, y, z);
if (this.blocks[i] === v) return false;
this.blocks[i] = v;
this.modified = true;
this.dirty = true;
return true;
}
dispose() {
if (this.mesh.geometry) this.mesh.geometry.dispose();
scene.remove(this.mesh);
}
}
const chunks = new Map();
const savedBlocks = new Map();
const genQueue = [];
const genSet = new Set();
const meshQueue = [];
function key(cx, cz) {
return cx + "," + cz;
}
function getChunk(cx, cz) {
return chunks.get(key(cx, cz));
}
function getBlock(wx, wy, wz) {
if (wy < 0) return BLOCK.OBSIDIAN;
if (wy >= CHUNK_HEIGHT) return BLOCK.AIR;
const cx = floorDiv(wx, CHUNK_SIZE);
const cz = floorDiv(wz, CHUNK_SIZE);
const c = getChunk(cx, cz);
if (!c) return BLOCK.AIR;
const lx = wx - cx * CHUNK_SIZE;
const lz = wz - cz * CHUNK_SIZE;
return c.getLocal(lx, wy, lz);
}
function setBlock(wx, wy, wz, id) {
if (wy < 0 || wy >= CHUNK_HEIGHT) return false;
const cx = floorDiv(wx, CHUNK_SIZE);
const cz = floorDiv(wz, CHUNK_SIZE);
const c = getChunk(cx, cz);
if (!c) return false;
const lx = wx - cx * CHUNK_SIZE;
const lz = wz - cz * CHUNK_SIZE;
const ok = c.setLocal(lx, wy, lz, id);
if (!ok) return false;
markDirty(cx, cz);
if (lx === 0) markDirty(cx - 1, cz);
if (lx === CHUNK_SIZE - 1) markDirty(cx + 1, cz);
if (lz === 0) markDirty(cx, cz - 1);
if (lz === CHUNK_SIZE - 1) markDirty(cx, cz + 1);
savedBlocks.set(key(cx, cz), c.blocks);
return true;
}
function markDirty(cx, cz) {
const c = getChunk(cx, cz);
if (!c) return;
if (!c.inMeshQueue) {
c.inMeshQueue = true;
meshQueue.push(c);
}
}
const FACES = [
{ n: [1, 0, 0], c: [ [1, 0, 0], [1, 1, 0], [1, 1, 1], [1, 0, 1] ] },
{ n: [-1, 0, 0], c: [ [0, 0, 1], [0, 1, 1], [0, 1, 0], [0, 0, 0] ] },
{ n: [0, 1, 0], c: [ [0, 1, 1], [1, 1, 1], [1, 1, 0], [0, 1, 0] ] },
{ n: [0, -1, 0], c: [ [0, 0, 0], [1, 0, 0], [1, 0, 1], [0, 0, 1] ] },
{ n: [0, 0, 1], c: [ [1, 0, 1], [1, 1, 1], [0, 1, 1], [0, 0, 1] ] },
{ n: [0, 0, -1], c: [ [0, 0, 0], [0, 1, 0], [1, 1, 0], [1, 0, 0] ] },
];
function rebuildChunk(c) {
const pos = [];
const nor = [];
const uv = [];
const cx = c.cx;
const cz = c.cz;
const ox = cx * CHUNK_SIZE;
const oz = 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.getLocal(x, y, z);
if (id === BLOCK.AIR) continue;
const wx = ox + x;
const wz = oz + z;
for (let f = 0; f < 6; f++) {
const face = FACES[f];
const nx = face.n[0], ny = face.n[1], nz = face.n[2];
const nb = getBlock(wx + nx, y + ny, wz + nz);
if (nb !== BLOCK.AIR) continue;
const [tx, ty] = faceTile(id, nx, ny, nz);
const { u0, v0, u1, v1 } = tileUV(tx, ty);
const uvs = [
[u0, v0],
[u1, v0],
[u1, v1],
[u0, v1],
];
const corners = face.c;
const vA = corners[0];
const vB = corners[1];
const vC = corners[2];
const vD = corners[3];
const pushVert = (vx, vy, vz, uu, vv) => {
pos.push(vx, vy, vz);
nor.push(nx, ny, nz);
uv.push(uu, vv);
};
pushVert(x + vA[0], y + vA[1], z + vA[2], uvs[0][0], uvs[0][1]);
pushVert(x + vB[0], y + vB[1], z + vB[2], uvs[1][0], uvs[1][1]);
pushVert(x + vC[0], y + vC[1], z + vC[2], uvs[2][0], uvs[2][1]);
pushVert(x + vA[0], y + vA[1], z + vA[2], uvs[0][0], uvs[0][1]);
pushVert(x + vC[0], y + vC[1], z + vC[2], uvs[2][0], uvs[2][1]);
pushVert(x + vD[0], y + vD[1], z + vD[2], uvs[3][0], uvs[3][1]);
}
}
}
}
const g = new THREE.BufferGeometry();
g.setAttribute("position", new THREE.Float32BufferAttribute(pos, 3));
g.setAttribute("normal", new THREE.Float32BufferAttribute(nor, 3));
g.setAttribute("uv", new THREE.Float32BufferAttribute(uv, 2));
g.computeBoundingSphere();
if (c.mesh.geometry) c.mesh.geometry.dispose();
c.mesh.geometry = g;
c.dirty = false;
c.inMeshQueue = false;
}
function setWorldInChunk(cx, cz, blocks, wx, wy, wz, id) {
if (wy < 0 || wy >= CHUNK_HEIGHT) return;
const ccx = floorDiv(wx, CHUNK_SIZE);
const ccz = floorDiv(wz, CHUNK_SIZE);
if (ccx !== cx || ccz !== cz) return;
const lx = wx - ccx * CHUNK_SIZE;
const lz = wz - ccz * CHUNK_SIZE;
if (lx < 0 || lz < 0 || lx >= CHUNK_SIZE || lz >= CHUNK_SIZE) return;
const i = lx + CHUNK_SIZE * (lz + CHUNK_SIZE * wy);
blocks[i] = id;
}
function stampMajorStructures(cx, cz, blocks) {
const minX = cx * CHUNK_SIZE;
const maxX = minX + CHUNK_SIZE - 1;
const minZ = cz * CHUNK_SIZE;
const maxZ = minZ + CHUNK_SIZE - 1;
const intersects2D = (ax0, az0, ax1, az1) => !(ax1 < minX || ax0 > maxX || az1 < minZ || az0 > maxZ);
const setW = (wx, wy, wz, id) => setWorldInChunk(cx, cz, blocks, wx, wy, wz, id);
// World Tree (near spawn)
{
const ax = 0, az = 0;
const ground = heightAt(ax, az);
const baseY = ground + 1;
const bb = { x0: ax - 24, x1: ax + 24, z0: az - 24, z1: az + 24 };
if (intersects2D(bb.x0, bb.z0, bb.x1, bb.z1)) {
const trunkH = 44;
for (let y = 0; y <= trunkH; y++) {
const r = y < 28 ? 3 : y < 38 ? 2 : 1;
for (let dx = -r; dx <= r; dx++) {
for (let dz = -r; dz <= r; dz++) {
const d2 = dx * dx + dz * dz;
if (d2 > r * r + (y % 7 === 0 ? 1 : 0)) continue;
setW(ax + dx, baseY + y, az + dz, BLOCK.WOOD);
}
}
}
// roots
for (let k = 0; k < 7; k++) {
const ang = (k / 7) * Math.PI * 2;
const len = 10 + ((k * 3) % 7);
for (let t = 0; t < len; t++) {
const dx = Math.round(Math.cos(ang) * t);
const dz = Math.round(Math.sin(ang) * t);
const y = baseY - Math.floor(t * 0.15);
setW(ax + dx, y, az + dz, BLOCK.WOOD);
if (t % 3 === 0) setW(ax + dx, y + 1, az + dz, BLOCK.WOOD);
}
}
// canopy
const topY = baseY + trunkH;
for (let dy = -10; dy <= 10; dy++) {
for (let dx = -14; dx <= 14; dx++) {
for (let dz = -14; dz <= 14; dz++) {
const d = dx * dx + dz * dz + dy * dy * 1.2;
if (d > 14 * 14) continue;
const wy = topY + dy;
if (wy < baseY + 8) continue;
if ((dx + dz + dy) % 5 === 0) continue;
setW(ax + dx, wy, az + dz, BLOCK.LEAF);
}
}
}
// rune altar ring
for (let dx = -7; dx <= 7; dx++) {
for (let dz = -7; dz <= 7; dz++) {
const d2 = dx * dx + dz * dz;
if (d2 < 24 || d2 > 49) continue;
const y = baseY - 1;
setW(ax + dx, y, az + dz, BLOCK.RUNE);
if (d2 > 40 && (dx * dz) % 3 === 0) setW(ax + dx, y + 1, az + dz, BLOCK.RUNE);
}
}
// small stones
for (let dx = -9; dx <= 9; dx++) {
for (let dz = -9; dz <= 9; dz++) {
const d2 = dx * dx + dz * dz;
if (d2 > 90) continue;
if (d2 < 30) continue;
if ((dx + dz) % 7 !== 0) continue;
const y = baseY - 1;
setW(ax + dx, y, az + dz, BLOCK.STONE);
}
}
}
}
// Sky Temple (floating island + ruins)
{
const ax = 160, az = 40;
const ground = heightAt(ax, az);
const baseY = clamp(ground + 26, 32, CHUNK_HEIGHT - 26);
const bb = { x0: ax - 40, x1: ax + 40, z0: az - 40, z1: az + 40 };
if (intersects2D(bb.x0, bb.z0, bb.x1, bb.z1)) {
// floating island
for (let dx = -28; dx <= 28; dx++) {
for (let dz = -28; dz <= 28; dz++) {
const d2 = dx * dx + dz * dz;
if (d2 > 28 * 28) continue;
const rim = Math.sqrt(d2) / 28;
const cap = Math.floor(10 * (1 - rim * rim));
const belly = Math.floor(12 * (1 - rim));
for (let dy = -belly; dy <= cap; dy++) {
const wy = baseY + dy;
if (wy < 2 || wy >= CHUNK_HEIGHT - 1) continue;
const shell = dy === cap ? BLOCK.GRASS : dy > cap - 3 ? BLOCK.DIRT : BLOCK.STONE;
if (dy < -belly + 2 && (dx + dz + dy) % 11 === 0) continue;
setW(ax + dx, wy, az + dz, shell);
}
}
}
// temple platform
const platY = baseY + 10;
for (let dx = -14; dx <= 14; dx++) {
for (let dz = -14; dz <= 14; dz++) {
const edge = Math.max(Math.abs(dx), Math.abs(dz));
const id = edge >= 13 ? BLOCK.RUNE : BLOCK.STONE;
setW(ax + dx, platY, az + dz, id);
if (edge >= 13 && (dx * dz) % 5 === 0) setW(ax + dx, platY + 1, az + dz, BLOCK.RUNE);
}
}
// columns + roof
for (let k = 0; k < 4; k++) {
const sx = k < 2 ? -11 : 11;
const sz = k % 2 === 0 ? -11 : 11;
for (let y = 1; y <= 9; y++) {
setW(ax + sx, platY + y, az + sz, BLOCK.STONE);
if (y % 2 === 0) setW(ax + sx, platY + y, az + sz + (sx > 0 ? 1 : -1), BLOCK.STONE);
}
}
const roofY = platY + 10;
for (let dx = -10; dx <= 10; dx++) {
for (let dz = -10; dz <= 10; dz++) {
const edge = Math.max(Math.abs(dx), Math.abs(dz));
if (edge > 10) continue;
const lift = Math.floor((10 - edge) * 0.35);
const id = edge >= 9 ? BLOCK.RUNE : BLOCK.STONE;
setW(ax + dx, roofY + lift, az + dz, id);
}
}
// portal ring
const ringY = platY + 1;
for (let a = 0; a < 360; a += 6) {
const rad = (a * Math.PI) / 180;
const r = 7;
const x = ax + Math.round(Math.cos(rad) * r);
const z = az + Math.round(Math.sin(rad) * r);
setW(x, ringY, z, BLOCK.OBSIDIAN);
if (a % 18 === 0) setW(x, ringY + 1, z, BLOCK.RUNE);
}
}
}
// Crystal Spires
{
const ax = -80, az = -80;
const bb = { x0: ax - 52, x1: ax + 52, z0: az - 52, z1: az + 52 };
if (intersects2D(bb.x0, bb.z0, bb.x1, bb.z1)) {
const r = xorshift32(hash2i(-9, -11));
for (let s = 0; s < 14; s++) {
const offX = Math.round((r() * 2 - 1) * 36);
const offZ = Math.round((r() * 2 - 1) * 36);
const wx = ax + offX;
const wz = az + offZ;
const g = heightAt(wx, wz);
let h = 14 + ((r() * 34) | 0);
const maxH = CHUNK_HEIGHT - 3 - (g + 1);
if (maxH <= 6) continue;
h = Math.min(h, maxH);
const taper = 2 + ((r() * 3) | 0);
for (let y = 0; y <= h; y++) {
const rr = Math.max(0, taper - Math.floor((y / h) * (taper + 1)));
for (let dx = -rr; dx <= rr; dx++) {
for (let dz = -rr; dz <= rr; dz++) {
if (dx * dx + dz * dz > rr * rr) continue;
setW(wx + dx, g + 1 + y, wz + dz, y > h - 3 ? BLOCK.RUNE : BLOCK.STONE);
}
}
}
}
}
}
// Ancient Ruins
{
const ax = 60, az = -180;
const bb = { x0: ax - 60, x1: ax + 60, z0: az - 60, z1: az + 60 };
if (intersects2D(bb.x0, bb.z0, bb.x1, bb.z1)) {
const base = heightAt(ax, az) + 1;
// broken courtyard
for (let dx = -24; dx <= 24; dx++) {
for (let dz = -18; dz <= 18; dz++) {
if (Math.abs(dx) === 24 || Math.abs(dz) === 18) {
if ((dx + dz) % 5 === 0) continue;
setW(ax + dx, base, az + dz, BLOCK.STONE);
if ((dx * dz) % 11 === 0) setW(ax + dx, base + 1, az + dz, BLOCK.RUNE);
}
if ((dx + dz) % 19 === 0) setW(ax + dx, base - 1, az + dz, BLOCK.STONE);
}
}
// arch
for (let t = -10; t <= 10; t++) {
const wy = base + 4 + Math.floor(Math.cos((t / 10) * Math.PI) * 6);
setW(ax + t, wy, az + 0, BLOCK.STONE);
if (t % 3 === 0) setW(ax + t, wy + 1, az + 0, BLOCK.RUNE);
}
for (let y = 0; y <= 8; y++) {
setW(ax - 10, base + y, az + 0, BLOCK.STONE);
setW(ax + 10, base + y, az + 0, BLOCK.STONE);
if (y % 2 === 0) {
setW(ax - 10, base + y, az + 1, BLOCK.STONE);
setW(ax + 10, base + y, az - 1, BLOCK.STONE);
}
}
// scattered pillars
const rr = xorshift32(hash2i(3, -7));
for (let i = 0; i < 10; i++) {
const px = ax + Math.round((rr() * 2 - 1) * 22);
const pz = az + Math.round((rr() * 2 - 1) * 16);
const ph = 3 + ((rr() * 10) | 0);
const gy = heightAt(px, pz) + 1;
for (let y = 0; y < ph; y++) setW(px, gy + y, pz, rr() > 0.7 ? BLOCK.RUNE : BLOCK.STONE);
}
}
}
// Dragonbone Ravine
{
const ax = -200, az = 120;
const bb = { x0: ax - 80, x1: ax + 80, z0: az - 80, z1: az + 80 };
if (intersects2D(bb.x0, bb.z0, bb.x1, bb.z1)) {
const base = heightAt(ax, az) + 4;
const spineLen = 76;
for (let t = 0; t <= spineLen; t++) {
const x = ax - 36 + t;
const z = az + Math.round(Math.sin(t * 0.14) * 7);
const y = base + Math.round(Math.cos(t * 0.12) * 4);
for (let k = 0; k < 2; k++) {
setW(x, y + k, z, BLOCK.BONE);
setW(x, y + k, z + 1, BLOCK.BONE);
}
if (t % 7 === 0) {
const side = t % 14 === 0 ? 1 : -1;
const ribH = 8 + ((t % 21) | 0) / 3;
for (let r = 0; r <= 10; r++) {
const rx = x + Math.round(Math.cos(r * 0.22) * 3);
const ry = y + 2 + Math.round(Math.sin(r * 0.22) * ribH);
const rz = z + side * (2 + r);
setW(rx, ry, rz, BLOCK.BONE);
if (r % 3 === 0) setW(rx, ry + 1, rz, BLOCK.BONE);
}
}
}
// rune fissure under spine (myth terrain accent)
for (let dx = -22; dx <= 22; dx++) {
for (let dz = -10; dz <= 10; dz++) {
const wx = ax + dx;
const wz = az + dz;
const g = heightAt(wx, wz);
const trench = Math.abs(dz) <= 2 ? 5 : Math.abs(dz) <= 4 ? 3 : 0;
for (let k = 0; k < trench; k++) setW(wx, g - k, wz, BLOCK.AIR);
if (trench && (dx + dz) % 4 === 0) setW(wx, g - trench, wz, BLOCK.RUNE);
}
}
}
}
}
function stampMicroFeatures(cx, cz, blocks) {
const r = xorshift32(hash2i(cx, cz));
const ox = cx * CHUNK_SIZE;
const oz = cz * CHUNK_SIZE;
const setW = (wx, wy, wz, id) => setWorldInChunk(cx, cz, blocks, wx, wy, wz, id);
// Runestones
if (r() > 0.92) {
const wx = ox + ((r() * CHUNK_SIZE) | 0);
const wz = oz + ((r() * CHUNK_SIZE) | 0);
const g = heightAt(wx, wz) + 1;
const h = 3 + ((r() * 4) | 0);
for (let y = 0; y < h; y++) setW(wx, g + y, wz, y === h - 1 ? BLOCK.RUNE : BLOCK.STONE);
if (r() > 0.6) setW(wx + 1, g, wz, BLOCK.STONE);
}
// Small arches
if (r() > 0.955) {
const wx = ox + 2 + ((r() * (CHUNK_SIZE - 4)) | 0);
const wz = oz + 2 + ((r() * (CHUNK_SIZE - 4)) | 0);
const g = heightAt(wx, wz) + 1;
for (let t = -3; t <= 3; t++) {
const wy = g + 1 + Math.floor(Math.cos((t / 3) * Math.PI) * 3);
setW(wx + t, wy, wz, t === 0 ? BLOCK.RUNE : BLOCK.STONE);
}
for (let y = 0; y <= 4; y++) {
setW(wx - 3, g + y, wz, BLOCK.STONE);
setW(wx + 3, g + y, wz, BLOCK.STONE);
}
}
}
function generateChunkBlocks(cx, cz) {
const saved = savedBlocks.get(key(cx, cz));
if (saved) return saved;
const blocks = new Uint8Array(CHUNK_SIZE * CHUNK_HEIGHT * CHUNK_SIZE);
const ox = cx * CHUNK_SIZE;
const oz = cz * CHUNK_SIZE;
for (let x = 0; x < CHUNK_SIZE; x++) {
const wx = ox + x;
for (let z = 0; z < CHUNK_SIZE; z++) {
const wz = oz + z;
const h = heightAt(wx, wz);
const biome = biomeAt(wx, wz);
const top = h > 46 ? BLOCK.STONE : biome < 0.23 ? BLOCK.SAND : BLOCK.GRASS;
for (let y = 0; y <= h; y++) {
let id;
if (y === 0) id = BLOCK.OBSIDIAN;
else if (y === h) id = top;
else if (y >= h - 3) id = top === BLOCK.SAND ? BLOCK.SAND : BLOCK.DIRT;
else id = BLOCK.STONE;
blocks[x + CHUNK_SIZE * (z + CHUNK_SIZE * y)] = id;
}
// caves
for (let y = 4; y <= h - 4; y++) {
if (y < 10 || y > 48) continue;
const c = caveAt(wx, y, wz);
if (c > 0.48) blocks[x + CHUNK_SIZE * (z + CHUNK_SIZE * y)] = BLOCK.AIR;
}
// rune veins in deep stone
if (h > 22 && biome > 0.75) {
for (let y = 10; y <= Math.min(h - 8, 34); y++) {
const v = perlin.noise(wx * 0.09, y * 0.12, wz * 0.09);
if (v > 0.63) blocks[x + CHUNK_SIZE * (z + CHUNK_SIZE * y)] = BLOCK.RUNE;
}
}
}
}
stampMajorStructures(cx, cz, blocks);
stampMicroFeatures(cx, cz, blocks);
return blocks;
}
function requestChunk(cx, cz) {
const k = key(cx, cz);
if (chunks.has(k) || genSet.has(k)) return;
genSet.add(k);
genQueue.push({ cx, cz });
}
function ensureChunksAround(wx, wz) {
const pcx = floorDiv(Math.floor(wx), CHUNK_SIZE);
const pcz = floorDiv(Math.floor(wz), CHUNK_SIZE);
for (let dz = -VIEW_RADIUS; dz <= VIEW_RADIUS; dz++) {
for (let dx = -VIEW_RADIUS; dx <= VIEW_RADIUS; dx++) {
requestChunk(pcx + dx, pcz + dz);
}
}
// unload far
const limit = VIEW_RADIUS + 2;
for (const [k, c] of chunks) {
if (Math.abs(c.cx - pcx) > limit || Math.abs(c.cz - pcz) > limit) {
if (c.modified) savedBlocks.set(k, c.blocks);
c.dispose();
chunks.delete(k);
}
}
}
function processQueues() {
const GEN_PER_FRAME = 1;
const MESH_PER_FRAME = 1;
for (let i = 0; i < GEN_PER_FRAME && genQueue.length; i++) {
const job = genQueue.shift();
const k = key(job.cx, job.cz);
genSet.delete(k);
if (chunks.has(k)) continue;
const blocks = generateChunkBlocks(job.cx, job.cz);
const c = new Chunk(job.cx, job.cz, blocks);
chunks.set(k, c);
markDirty(job.cx, job.cz);
markDirty(job.cx - 1, job.cz);
markDirty(job.cx + 1, job.cz);
markDirty(job.cx, job.cz - 1);
markDirty(job.cx, job.cz + 1);
}
for (let i = 0; i < MESH_PER_FRAME && meshQueue.length; i++) {
const c = meshQueue.shift();
if (!chunks.has(key(c.cx, c.cz))) continue;
rebuildChunk(c);
}
}
const input = {
w: false,
a: false,
s: false,
d: false,
space: false,
shift: false,
wantJump: false,
mouseDownL: false,
mouseDownR: false,
};
const player = {
pos: new THREE.Vector3(0, 40, 0),
vel: new THREE.Vector3(0, 0, 0),
yaw: 0,
pitch: 0,
fly: false,
onGround: false,
};
const PLAYER = Object.freeze({
radius: 0.32,
height: 1.74,
eye: 1.62,
walkSpeed: 6.0,
flySpeed: 10.5,
gravity: 22.0,
jumpV: 8.2,
});
function isSolid(id) {
return id !== BLOCK.AIR;
}
function collidesAt(x, y, z) {
const r = PLAYER.radius;
const h = PLAYER.height;
const minX = x - r;
const maxX = x + r;
const minY = y;
const maxY = y + h;
const minZ = z - r;
const maxZ = z + r;
const x0 = Math.floor(minX);
const x1 = Math.floor(maxX - 1e-6);
const y0 = Math.floor(minY);
const y1 = Math.floor(maxY - 1e-6);
const z0 = Math.floor(minZ);
const z1 = Math.floor(maxZ - 1e-6);
for (let yy = y0; yy <= y1; yy++) {
for (let zz = z0; zz <= z1; zz++) {
for (let xx = x0; xx <= x1; xx++) {
if (isSolid(getBlock(xx, yy, zz))) return true;
}
}
}
return false;
}
function moveAndCollide(dt) {
const dir = new THREE.Vector3();
camera.getWorldDirection(dir);
dir.y = 0;
dir.normalize();
const right = new THREE.Vector3().crossVectors(dir, new THREE.Vector3(0, 1, 0)).normalize();
let mx = 0;
let mz = 0;
if (input.w) { mx += dir.x; mz += dir.z; }
if (input.s) { mx -= dir.x; mz -= dir.z; }
if (input.d) { mx += right.x; mz += right.z; }
if (input.a) { mx -= right.x; mz -= right.z; }
const mLen = Math.hypot(mx, mz);
if (mLen > 1e-6) { mx /= mLen; mz /= mLen; }
if (player.fly) {
const sp = PLAYER.flySpeed;
const dy = (input.space ? 1 : 0) - (input.shift ? 1 : 0);
const dx = mx * sp * dt;
const dz = mz * sp * dt;
const ddy = dy * sp * dt;
player.pos.x += dx;
if (collidesAt(player.pos.x, player.pos.y, player.pos.z)) player.pos.x -= dx;
player.pos.z += dz;
if (collidesAt(player.pos.x, player.pos.y, player.pos.z)) player.pos.z -= dz;
player.pos.y += ddy;
if (collidesAt(player.pos.x, player.pos.y, player.pos.z)) player.pos.y -= ddy;
player.onGround = false;
player.vel.set(0, 0, 0);
return;
}
const speed = PLAYER.walkSpeed;
const dx = mx * speed * dt;
const dz = mz * speed * dt;
player.pos.x += dx;
if (collidesAt(player.pos.x, player.pos.y, player.pos.z)) player.pos.x -= dx;
player.pos.z += dz;
if (collidesAt(player.pos.x, player.pos.y, player.pos.z)) player.pos.z -= dz;
player.vel.y -= PLAYER.gravity * dt;
if (player.onGround && input.wantJump) {
player.vel.y = PLAYER.jumpV;
player.onGround = false;
}
input.wantJump = false;
const dy = player.vel.y * dt;
player.pos.y += dy;
if (collidesAt(player.pos.x, player.pos.y, player.pos.z)) {
player.pos.y -= dy;
if (dy < 0) player.onGround = true;
player.vel.y = 0;
} else {
player.onGround = false;
}
// ground probe
if (!player.onGround && player.vel.y <= 0) {
if (collidesAt(player.pos.x, player.pos.y - 0.05, player.pos.z)) player.onGround = true;
}
}
function intbound(s, ds) {
if (ds > 0) return (Math.floor(s + 1) - s) / ds;
if (ds < 0) return (s - Math.floor(s)) / -ds;
return Infinity;
}
function raycastVoxels(origin, dir, maxDist) {
let x = Math.floor(origin.x);
let y = Math.floor(origin.y);
let z = Math.floor(origin.z);
const dx = dir.x, dy = dir.y, dz = dir.z;
const stepX = dx > 0 ? 1 : dx < 0 ? -1 : 0;
const stepY = dy > 0 ? 1 : dy < 0 ? -1 : 0;
const stepZ = dz > 0 ? 1 : dz < 0 ? -1 : 0;
let tMaxX = intbound(origin.x, dx);
let tMaxY = intbound(origin.y, dy);
let tMaxZ = intbound(origin.z, dz);
const tDeltaX = stepX !== 0 ? Math.abs(1 / dx) : Infinity;
const tDeltaY = stepY !== 0 ? Math.abs(1 / dy) : Infinity;
const tDeltaZ = stepZ !== 0 ? Math.abs(1 / dz) : Infinity;
let nx = 0, ny = 0, nz = 0;
let t = 0;
for (let i = 0; i < 512 && t <= maxDist; i++) {
if (tMaxX < tMaxY) {
if (tMaxX < tMaxZ) {
x += stepX;
t = tMaxX;
tMaxX += tDeltaX;
nx = -stepX; ny = 0; nz = 0;
} else {
z += stepZ;
t = tMaxZ;
tMaxZ += tDeltaZ;
nx = 0; ny = 0; nz = -stepZ;
}
} else {
if (tMaxY < tMaxZ) {
y += stepY;
t = tMaxY;
tMaxY += tDeltaY;
nx = 0; ny = -stepY; nz = 0;
} else {
z += stepZ;
t = tMaxZ;
tMaxZ += tDeltaZ;
nx = 0; ny = 0; nz = -stepZ;
}
}
const id = getBlock(x, y, z);
if (id !== BLOCK.AIR) return { hit: true, x, y, z, nx, ny, nz, id, dist: t };
}
return { hit: false };
}
let selected = 0;
const hotbar = $("#hotbar");
const slotCanvases = [];
function drawSlotIcon(tileXY, canvas) {
const [tx, ty] = tileXY;
const s = 32;
canvas.width = s;
canvas.height = s;
const ctx = canvas.getContext("2d");
ctx.imageSmoothingEnabled = false;
const src = ATLAS.tex.image;
const ts = ATLAS.tile;
const sx = tx * ts;
const sy = ty * ts;
ctx.clearRect(0, 0, s, s);
ctx.drawImage(src, sx, sy, ts, ts, 0, 0, s, s);
}
function buildHotbar() {
hotbar.innerHTML = "";
slotCanvases.length = 0;
for (let i = 0; i < PALETTE.length; i++) {
const slot = document.createElement("div");
slot.className = "slot" + (i === selected ? " sel" : "");
const num = document.createElement("div");
num.className = "num";
num.textContent = String(i + 1);
const c = document.createElement("canvas");
slot.appendChild(c);
slot.appendChild(num);
hotbar.appendChild(slot);
slotCanvases.push({ slot, canvas: c });
}
refreshHotbar();
}
function refreshHotbar() {
for (let i = 0; i < PALETTE.length; i++) {
const isSel = i === selected;
slotCanvases[i].slot.classList.toggle("sel", isSel);
const id = PALETTE[i].id;
const tile = faceTile(id, 0, 1, 0);
drawSlotIcon(tile, slotCanvases[i].canvas);
}
}
buildHotbar();
function toast(msg, ms = 1200) {
const el = $("#toast");
el.hidden = false;
el.textContent = msg;
clearTimeout(toast._t);
toast._t = setTimeout(() => (el.hidden = true), ms);
}
function setSelected(i) {
selected = clamp(i, 0, PALETTE.length - 1);
refreshHotbar();
const id = PALETTE[selected].id;
toast("选择方块 / Selected: " + BLOCK_NAME[id], 1100);
}
function tryBreakOrPlace(button) {
if (document.pointerLockElement !== renderer.domElement) return;
const o = camera.position.clone();
const d = new THREE.Vector3();
camera.getWorldDirection(d);
const hit = raycastVoxels(o, d, 8.2);
if (!hit.hit) return;
if (button === 0) {
if (hit.y <= 0) return;
setBlock(hit.x, hit.y, hit.z, BLOCK.AIR);
} else if (button === 2) {
const px = hit.x + hit.nx;
const py = hit.y + hit.ny;
const pz = hit.z + hit.nz;
if (py <= 0) return;
const placeId = PALETTE[selected].id;
if (getBlock(px, py, pz) !== BLOCK.AIR) return;
// avoid placing inside player
const old = { x: player.pos.x, y: player.pos.y, z: player.pos.z };
if (
px + 1 > old.x - PLAYER.radius &&
px < old.x + PLAYER.radius &&
pz + 1 > old.z - PLAYER.radius &&
pz < old.z + PLAYER.radius &&
py + 1 > old.y &&
py < old.y + PLAYER.height
) {
return;
}
setBlock(px, py, pz, placeId);
}
}
function setSpawn() {
const h = heightAt(0, 0);
player.pos.set(0.5, h + 4, 0.5);
player.vel.set(0, 0, 0);
player.yaw = 0;
player.pitch = 0;
ensureChunksAround(player.pos.x, player.pos.z);
}
setSpawn();
function onResize() {
renderer.setSize(window.innerWidth, window.innerHeight);
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
}
window.addEventListener("resize", onResize);
window.addEventListener("contextmenu", (e) => e.preventDefault(), { passive: false });
window.addEventListener("mousedown", (e) => {
if (document.pointerLockElement !== renderer.domElement) return;
if (e.button === 0 || e.button === 2) tryBreakOrPlace(e.button);
});
window.addEventListener("keydown", (e) => {
if (e.code === "KeyW") input.w = true;
if (e.code === "KeyA") input.a = true;
if (e.code === "KeyS") input.s = true;
if (e.code === "KeyD") input.d = true;
if (e.code === "Space") {
input.space = true;
if (!player.fly) input.wantJump = true;
}
if (e.code === "ShiftLeft" || e.code === "ShiftRight") input.shift = true;
if (e.code === "KeyF") {
player.fly = !player.fly;
player.vel.y = 0;
toast(player.fly ? "飞行模式 / Fly: ON" : "行走模式 / Fly: OFF", 1200);
}
if (e.code.startsWith("Digit")) {
const n = parseInt(e.code.slice(5), 10);
if (Number.isFinite(n) && n >= 1 && n <= PALETTE.length) setSelected(n - 1);
}
});
window.addEventListener("keyup", (e) => {
if (e.code === "KeyW") input.w = false;
if (e.code === "KeyA") input.a = false;
if (e.code === "KeyS") input.s = false;
if (e.code === "KeyD") input.d = false;
if (e.code === "Space") input.space = false;
if (e.code === "ShiftLeft" || e.code === "ShiftRight") input.shift = false;
});
window.addEventListener("mousemove", (e) => {
if (document.pointerLockElement !== renderer.domElement) return;
const sens = 0.0022;
player.yaw -= e.movementX * sens;
player.pitch -= e.movementY * sens;
player.pitch = clamp(player.pitch, -Math.PI / 2 + 0.01, Math.PI / 2 - 0.01);
});
const overlay = $("#overlay");
const startBtn = $("#startBtn");
startBtn.addEventListener("click", () => {
renderer.domElement.requestPointerLock?.();
});
renderer.domElement.addEventListener("click", () => {
if (document.pointerLockElement !== renderer.domElement) renderer.domElement.requestPointerLock?.();
});
document.addEventListener("pointerlockchange", () => {
const locked = document.pointerLockElement === renderer.domElement;
overlay.hidden = locked;
if (!locked) $("#loadLine").textContent = "已释放鼠标。点击继续 / Click to resume.";
});
function updateCamera() {
camera.position.set(player.pos.x, player.pos.y + PLAYER.eye, player.pos.z);
camera.rotation.y = player.yaw;
camera.rotation.x = player.pitch;
}
let last = performance.now();
let fpsSm = 60;
let frames = 0;
let lastStatT = performance.now();
function updateHUD(hitInfo) {
const t = performance.now();
const dt = (t - lastStatT) / 1000;
frames++;
if (dt >= 0.33) {
const fps = frames / dt;
fpsSm = fpsSm * 0.75 + fps * 0.25;
frames = 0;
lastStatT = t;
$("#statFps").textContent = fpsSm.toFixed(0);
$("#statChunks").textContent = String(chunks.size);
$("#statSeed").textContent = String(WORLD_SEED);
}
const pcx = floorDiv(Math.floor(player.pos.x), CHUNK_SIZE);
const pcz = floorDiv(Math.floor(player.pos.z), CHUNK_SIZE);
const selId = PALETTE[selected].id;
const lines = [];
lines.push(`pos: ${player.pos.x.toFixed(2)} ${player.pos.y.toFixed(2)} ${player.pos.z.toFixed(2)}`);
lines.push(`chunk: ${pcx},${pcz} loaded: ${chunks.size} genQ:${genQueue.length} meshQ:${meshQueue.length}`);
lines.push(`mode: ${player.fly ? "FLY" : "WALK"} onGround:${player.onGround ? "1" : "0"} sel:${BLOCK_NAME[selId]}`);
lines.push(`poi: 世界树(0,0) 神殿(160,40) 龙骨(-200,120) 尖塔(-80,-80) 遗迹(60,-180)`);
if (hitInfo && hitInfo.hit) {
lines.push(
`hit: (${hitInfo.x},${hitInfo.y},${hitInfo.z}) face: (${hitInfo.nx},${hitInfo.ny},${hitInfo.nz}) ${BLOCK_NAME[hitInfo.id]}`
);
} else {
lines.push(`hit: none`);
}
$("#debug").textContent = lines.join("\n");
}
function updateOutline(hitInfo) {
if (!hitInfo || !hitInfo.hit) {
hitOutline.visible = false;
return;
}
hitOutline.visible = true;
hitOutline.position.set(hitInfo.x + 0.5, hitInfo.y + 0.5, hitInfo.z + 0.5);
}
function tick(now) {
const dt = Math.min(0.05, (now - last) / 1000);
last = now;
updateCamera();
ensureChunksAround(player.pos.x, player.pos.z);
processQueues();
if (document.pointerLockElement === renderer.domElement) {
moveAndCollide(dt);
}
updateCamera();
const hit = (() => {
const o = camera.position.clone();
const d = new THREE.Vector3();
camera.getWorldDirection(d);
return raycastVoxels(o, d, 8.2);
})();
updateOutline(hit);
updateHUD(hit);
$("#loadLine").textContent =
chunks.size < (2 * VIEW_RADIUS + 1) * (2 * VIEW_RADIUS + 1)
? `生成区块中… / Streaming chunks… (${chunks.size})`
: "世界已就绪。点击开始 / Ready.";
renderer.render(scene, camera);
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
})();
</script>
</body>
</html>