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>
1849 lines
66 KiB
HTML
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>
|