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>
2117 lines
69 KiB
HTML
2117 lines
69 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>神话方境</title>
|
|
<style>
|
|
:root {
|
|
color-scheme: dark;
|
|
--panel: rgba(10, 14, 26, 0.76);
|
|
--line: rgba(160, 189, 255, 0.28);
|
|
--accent: #9fd6ff;
|
|
--gold: #ffe18c;
|
|
--text: #eef4ff;
|
|
--muted: #b4bfd7;
|
|
}
|
|
|
|
* {
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
html,
|
|
body {
|
|
margin: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
overflow: hidden;
|
|
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
background:
|
|
radial-gradient(circle at 20% 10%, rgba(121, 92, 255, 0.26), transparent 26%),
|
|
radial-gradient(circle at 82% 18%, rgba(66, 196, 255, 0.2), transparent 24%),
|
|
linear-gradient(180deg, #0f1631 0%, #0a0d17 58%, #05070d 100%);
|
|
color: var(--text);
|
|
user-select: none;
|
|
overscroll-behavior: none;
|
|
}
|
|
|
|
#game {
|
|
display: block;
|
|
width: 100%;
|
|
height: 100%;
|
|
cursor: crosshair;
|
|
}
|
|
|
|
#hud {
|
|
position: fixed;
|
|
inset: 0;
|
|
pointer-events: none;
|
|
}
|
|
|
|
#crosshair {
|
|
position: absolute;
|
|
left: 50%;
|
|
top: 50%;
|
|
width: 20px;
|
|
height: 20px;
|
|
transform: translate(-50%, -50%);
|
|
opacity: 0.9;
|
|
filter: drop-shadow(0 0 8px rgba(255, 243, 170, 0.45));
|
|
}
|
|
|
|
#crosshair::before,
|
|
#crosshair::after {
|
|
content: "";
|
|
position: absolute;
|
|
background: rgba(255, 244, 196, 0.92);
|
|
border-radius: 999px;
|
|
}
|
|
|
|
#crosshair::before {
|
|
left: 9px;
|
|
top: 2px;
|
|
width: 2px;
|
|
height: 16px;
|
|
}
|
|
|
|
#crosshair::after {
|
|
left: 2px;
|
|
top: 9px;
|
|
width: 16px;
|
|
height: 2px;
|
|
}
|
|
|
|
.panel {
|
|
position: absolute;
|
|
background: var(--panel);
|
|
border: 1px solid var(--line);
|
|
backdrop-filter: blur(14px);
|
|
box-shadow:
|
|
0 10px 40px rgba(0, 0, 0, 0.28),
|
|
inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
|
}
|
|
|
|
#brief {
|
|
left: 18px;
|
|
top: 18px;
|
|
width: min(390px, calc(100vw - 36px));
|
|
padding: 16px 16px 14px;
|
|
border-radius: 16px;
|
|
}
|
|
|
|
#brief h1 {
|
|
margin: 0 0 6px;
|
|
font-size: 22px;
|
|
letter-spacing: 0.02em;
|
|
}
|
|
|
|
#brief p {
|
|
margin: 0;
|
|
color: var(--muted);
|
|
line-height: 1.45;
|
|
font-size: 13px;
|
|
}
|
|
|
|
#brief .lore {
|
|
margin-top: 10px;
|
|
padding-top: 10px;
|
|
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
|
color: #dce6ff;
|
|
}
|
|
|
|
#status {
|
|
right: 18px;
|
|
top: 18px;
|
|
min-width: 230px;
|
|
max-width: min(320px, calc(100vw - 36px));
|
|
padding: 14px 16px;
|
|
border-radius: 14px;
|
|
text-align: right;
|
|
}
|
|
|
|
#status .label {
|
|
color: var(--accent);
|
|
font-size: 12px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.18em;
|
|
margin-bottom: 6px;
|
|
}
|
|
|
|
#status .value {
|
|
font-size: 14px;
|
|
color: var(--text);
|
|
line-height: 1.45;
|
|
}
|
|
|
|
#poi {
|
|
margin-top: 8px;
|
|
color: var(--gold);
|
|
font-size: 13px;
|
|
}
|
|
|
|
#hotbar {
|
|
position: absolute;
|
|
left: 50%;
|
|
bottom: 20px;
|
|
transform: translateX(-50%);
|
|
display: flex;
|
|
gap: 10px;
|
|
align-items: end;
|
|
}
|
|
|
|
.slot {
|
|
min-width: 88px;
|
|
padding: 10px 12px;
|
|
border-radius: 14px;
|
|
border: 1px solid rgba(185, 201, 255, 0.2);
|
|
background: rgba(5, 9, 18, 0.72);
|
|
color: var(--muted);
|
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
|
transition: transform 120ms ease, border-color 120ms ease, background 120ms ease;
|
|
}
|
|
|
|
.slot.active {
|
|
transform: translateY(-4px);
|
|
background: rgba(44, 68, 120, 0.62);
|
|
color: var(--text);
|
|
border-color: rgba(255, 232, 147, 0.76);
|
|
box-shadow:
|
|
0 10px 26px rgba(0, 0, 0, 0.25),
|
|
0 0 18px rgba(255, 225, 140, 0.18);
|
|
}
|
|
|
|
.slot .num {
|
|
font-size: 11px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.15em;
|
|
color: var(--accent);
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.slot .name {
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
#lockHint {
|
|
position: absolute;
|
|
inset: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background:
|
|
radial-gradient(circle at center, rgba(33, 45, 81, 0.18), rgba(4, 7, 14, 0.64));
|
|
transition: opacity 180ms ease, visibility 180ms ease;
|
|
}
|
|
|
|
#lockHint.hidden {
|
|
opacity: 0;
|
|
visibility: hidden;
|
|
}
|
|
|
|
#lockCard {
|
|
width: min(520px, calc(100vw - 36px));
|
|
padding: 22px 24px;
|
|
border-radius: 18px;
|
|
text-align: center;
|
|
}
|
|
|
|
#lockCard h2 {
|
|
margin: 0 0 10px;
|
|
font-size: 28px;
|
|
letter-spacing: 0.03em;
|
|
}
|
|
|
|
#lockCard p {
|
|
margin: 0;
|
|
color: var(--muted);
|
|
line-height: 1.55;
|
|
font-size: 14px;
|
|
}
|
|
|
|
#footerHint {
|
|
position: absolute;
|
|
left: 50%;
|
|
bottom: 118px;
|
|
transform: translateX(-50%);
|
|
padding: 8px 12px;
|
|
border-radius: 999px;
|
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
background: rgba(7, 10, 18, 0.56);
|
|
color: #cbd8ff;
|
|
font-size: 12px;
|
|
letter-spacing: 0.03em;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
@media (max-width: 780px) {
|
|
#brief {
|
|
width: calc(100vw - 24px);
|
|
left: 12px;
|
|
top: 12px;
|
|
}
|
|
|
|
#status {
|
|
top: auto;
|
|
bottom: 156px;
|
|
right: 12px;
|
|
max-width: calc(100vw - 24px);
|
|
}
|
|
|
|
#footerHint {
|
|
bottom: 128px;
|
|
max-width: calc(100vw - 24px);
|
|
text-align: center;
|
|
white-space: normal;
|
|
}
|
|
|
|
#hotbar {
|
|
gap: 6px;
|
|
width: calc(100vw - 12px);
|
|
justify-content: center;
|
|
}
|
|
|
|
.slot {
|
|
min-width: 0;
|
|
width: calc((100vw - 48px) / 3);
|
|
padding: 8px 10px;
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<canvas id="game"></canvas>
|
|
<div id="hud">
|
|
<div id="crosshair"></div>
|
|
<div id="brief" class="panel">
|
|
<h1>神话方境</h1>
|
|
<p>单文件 HTML5 + Three.js 体素世界:无限分块柏林噪声地形、第一人称漫游、左键破坏、右键放置、六种可选方块与接近原版像素质感的程序化材质。</p>
|
|
<p class="lore">起始圈内置了整套神话奇境:星冠浮城、月井神殿、蚀日石门、世界树庭、坠星晶坑与龙骸山脊;继续远行时,区域还会继续生成空中圣殿、螺旋高塔、符门遗迹与晶簇天坑。</p>
|
|
</div>
|
|
<div id="status" class="panel">
|
|
<div class="label">Realm State</div>
|
|
<div id="statusValue" class="value">初始化世界中...</div>
|
|
<div id="poi">正在感知地标...</div>
|
|
</div>
|
|
<div id="footerHint">WASD 移动,空格跳跃,Shift 冲刺,鼠标环视,左键破坏,右键放置,滚轮或数字键切换方块,R 返回浮城</div>
|
|
<div id="hotbar"></div>
|
|
<div id="lockHint">
|
|
<div id="lockCard" class="panel">
|
|
<h2>进入神话方境</h2>
|
|
<p>点击画面锁定鼠标并开始探索这个由柏林噪声与手工神话地标共同构成的体素世界。你会从星冠浮城出发,在浮空圣殿、世界树、龙骸山脊与晶石遗迹之间穿行。</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script type="module">
|
|
import * as THREE from "https://cdn.jsdelivr.net/npm/three@0.165.0/build/three.module.js";
|
|
|
|
const canvas = document.getElementById("game");
|
|
const hotbar = document.getElementById("hotbar");
|
|
const statusValue = document.getElementById("statusValue");
|
|
const poiLabel = document.getElementById("poi");
|
|
const lockHint = document.getElementById("lockHint");
|
|
|
|
const CHUNK_SIZE = 16;
|
|
const WORLD_HEIGHT = 80;
|
|
const LOAD_RADIUS = 3;
|
|
const PLAYER_HEIGHT = 1.8;
|
|
const PLAYER_RADIUS = 0.35;
|
|
const EYE_HEIGHT = 1.62;
|
|
const WORLD_SEED = 90210613;
|
|
const SPAWN = { x: 0, y: 46, z: 18 };
|
|
const REGION_SIZE = 96;
|
|
|
|
const BLOCK = Object.freeze({
|
|
AIR: 0,
|
|
GRASS: 1,
|
|
DIRT: 2,
|
|
STONE: 3,
|
|
MARBLE: 4,
|
|
RUNE: 5,
|
|
GLOW: 6,
|
|
CRYSTAL: 7,
|
|
WOOD: 8,
|
|
LEAF: 9,
|
|
OBSIDIAN: 10,
|
|
GOLD: 11,
|
|
MARBLE_RUNE: 12,
|
|
MAGMA: 13,
|
|
});
|
|
|
|
const BIOME = Object.freeze({
|
|
MEADOW: 0,
|
|
HIGHLAND: 1,
|
|
ASH: 2,
|
|
CRYSTAL: 3,
|
|
});
|
|
|
|
function clamp(value, min, max) {
|
|
return Math.min(max, Math.max(min, value));
|
|
}
|
|
|
|
function mod(value, size) {
|
|
return ((value % size) + size) % size;
|
|
}
|
|
|
|
function floorDiv(value, size) {
|
|
return Math.floor(value / size);
|
|
}
|
|
|
|
function indexLocal(x, y, z) {
|
|
return y * CHUNK_SIZE * CHUNK_SIZE + z * CHUNK_SIZE + x;
|
|
}
|
|
|
|
function chunkKey(cx, cz) {
|
|
return `${cx},${cz}`;
|
|
}
|
|
|
|
function hashInt(value) {
|
|
let h = value | 0;
|
|
h = Math.imul(h ^ (h >>> 16), 0x7feb352d);
|
|
h = Math.imul(h ^ (h >>> 15), 0x846ca68b);
|
|
h ^= h >>> 16;
|
|
return h >>> 0;
|
|
}
|
|
|
|
function hash2(x, z, seed = 0) {
|
|
return hashInt(Math.imul(x, 374761393) ^ Math.imul(z, 668265263) ^ seed);
|
|
}
|
|
|
|
function hash3(x, y, z, seed = 0) {
|
|
return hashInt(Math.imul(x, 374761393) ^ Math.imul(y, 668265263) ^ Math.imul(z, 2147483647) ^ seed);
|
|
}
|
|
|
|
function rand01(value) {
|
|
return (hashInt(value) & 0xfffffff) / 0xfffffff;
|
|
}
|
|
|
|
function rand2(x, z, seed = 0) {
|
|
return hash2(x, z, seed) / 0xffffffff;
|
|
}
|
|
|
|
function rand3(x, y, z, seed = 0) {
|
|
return hash3(x, y, z, seed) / 0xffffffff;
|
|
}
|
|
|
|
class SeededRng {
|
|
constructor(seed) {
|
|
this.seed = seed >>> 0;
|
|
}
|
|
|
|
next() {
|
|
let t = this.seed += 0x6d2b79f5;
|
|
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
}
|
|
}
|
|
|
|
class Perlin {
|
|
constructor(seed = 1337) {
|
|
this.p = new Uint8Array(512);
|
|
const perm = new Uint8Array(256);
|
|
for (let i = 0; i < 256; i += 1) perm[i] = i;
|
|
const rng = new SeededRng(seed);
|
|
for (let i = 255; i > 0; i -= 1) {
|
|
const j = Math.floor(rng.next() * (i + 1));
|
|
const temp = perm[i];
|
|
perm[i] = perm[j];
|
|
perm[j] = temp;
|
|
}
|
|
for (let i = 0; i < 512; i += 1) {
|
|
this.p[i] = perm[i & 255];
|
|
}
|
|
}
|
|
|
|
fade(t) {
|
|
return t * t * t * (t * (t * 6 - 15) + 10);
|
|
}
|
|
|
|
lerp(a, b, t) {
|
|
return a + (b - a) * t;
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
noise3D(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.p[X] + Y;
|
|
const AA = this.p[A] + Z;
|
|
const AB = this.p[A + 1] + Z;
|
|
const B = this.p[X + 1] + Y;
|
|
const BA = this.p[B] + Z;
|
|
const BB = this.p[B + 1] + Z;
|
|
|
|
return this.lerp(
|
|
this.lerp(
|
|
this.lerp(this.grad(this.p[AA], xf, yf, zf), this.grad(this.p[BA], xf - 1, yf, zf), u),
|
|
this.lerp(this.grad(this.p[AB], xf, yf - 1, zf), this.grad(this.p[BB], xf - 1, yf - 1, zf), u),
|
|
v
|
|
),
|
|
this.lerp(
|
|
this.lerp(this.grad(this.p[AA + 1], xf, yf, zf - 1), this.grad(this.p[BA + 1], xf - 1, yf, zf - 1), u),
|
|
this.lerp(this.grad(this.p[AB + 1], xf, yf - 1, zf - 1), this.grad(this.p[BB + 1], xf - 1, yf - 1, zf - 1), u),
|
|
v
|
|
),
|
|
w
|
|
);
|
|
}
|
|
|
|
noise2D(x, z) {
|
|
return this.noise3D(x, 0, z);
|
|
}
|
|
|
|
octave2D(x, z, octaves = 4, persistence = 0.5, lacunarity = 2) {
|
|
let total = 0;
|
|
let amplitude = 1;
|
|
let frequency = 1;
|
|
let maxValue = 0;
|
|
|
|
for (let i = 0; i < octaves; i += 1) {
|
|
total += this.noise2D(x * frequency, z * frequency) * amplitude;
|
|
maxValue += amplitude;
|
|
amplitude *= persistence;
|
|
frequency *= lacunarity;
|
|
}
|
|
|
|
return total / maxValue;
|
|
}
|
|
}
|
|
|
|
const perlin = new Perlin(WORLD_SEED);
|
|
|
|
const renderer = new THREE.WebGLRenderer({
|
|
canvas,
|
|
antialias: true,
|
|
powerPreference: "high-performance",
|
|
});
|
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
|
renderer.setSize(window.innerWidth, window.innerHeight, false);
|
|
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
|
|
|
const scene = new THREE.Scene();
|
|
scene.background = new THREE.Color(0x0d1326);
|
|
scene.fog = new THREE.Fog(0x0d1326, 50, 210);
|
|
|
|
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 450);
|
|
camera.rotation.order = "YXZ";
|
|
|
|
const ambient = new THREE.AmbientLight(0x4c5da4, 0.36);
|
|
const hemi = new THREE.HemisphereLight(0x97b8ff, 0x1b130c, 1.2);
|
|
const sun = new THREE.DirectionalLight(0xffd9b0, 1.3);
|
|
sun.position.set(120, 150, 50);
|
|
const rim = new THREE.DirectionalLight(0x6bd0ff, 0.55);
|
|
rim.position.set(-80, 60, -120);
|
|
scene.add(ambient, hemi, sun, rim);
|
|
|
|
const moon = new THREE.Mesh(
|
|
new THREE.SphereGeometry(6, 24, 24),
|
|
new THREE.MeshBasicMaterial({ color: 0xe6f2ff })
|
|
);
|
|
moon.position.set(-120, 86, -180);
|
|
scene.add(moon);
|
|
|
|
const starsGeometry = new THREE.BufferGeometry();
|
|
const starPositions = [];
|
|
for (let i = 0; i < 800; i += 1) {
|
|
const theta = Math.random() * Math.PI * 2;
|
|
const phi = Math.random() * 0.55 + 0.08;
|
|
const radius = 250 + Math.random() * 50;
|
|
starPositions.push(
|
|
Math.cos(theta) * Math.sin(phi) * radius,
|
|
Math.cos(phi) * radius + 10,
|
|
Math.sin(theta) * Math.sin(phi) * radius
|
|
);
|
|
}
|
|
starsGeometry.setAttribute("position", new THREE.Float32BufferAttribute(starPositions, 3));
|
|
const stars = new THREE.Points(
|
|
starsGeometry,
|
|
new THREE.PointsMaterial({
|
|
color: 0xebf3ff,
|
|
size: 1.4,
|
|
transparent: true,
|
|
opacity: 0.8,
|
|
sizeAttenuation: true,
|
|
})
|
|
);
|
|
scene.add(stars);
|
|
|
|
const TILE_SIZE = 32;
|
|
const ATLAS_COLS = 4;
|
|
const ATLAS_ROWS = 4;
|
|
const atlasCanvas = document.createElement("canvas");
|
|
atlasCanvas.width = TILE_SIZE * ATLAS_COLS;
|
|
atlasCanvas.height = TILE_SIZE * ATLAS_ROWS;
|
|
const atlasCtx = atlasCanvas.getContext("2d");
|
|
atlasCtx.imageSmoothingEnabled = false;
|
|
|
|
const TEX = Object.freeze({
|
|
GRASS_TOP: 0,
|
|
GRASS_SIDE: 1,
|
|
DIRT: 2,
|
|
STONE: 3,
|
|
RUNE: 4,
|
|
GLOW: 5,
|
|
CRYSTAL: 6,
|
|
WOOD_SIDE: 7,
|
|
WOOD_TOP: 8,
|
|
LEAF: 9,
|
|
OBSIDIAN: 10,
|
|
GOLD: 11,
|
|
MARBLE: 12,
|
|
MARBLE_RUNE: 13,
|
|
MAGMA: 14,
|
|
UNUSED: 15,
|
|
});
|
|
|
|
function tileOrigin(index) {
|
|
return [
|
|
(index % ATLAS_COLS) * TILE_SIZE,
|
|
Math.floor(index / ATLAS_COLS) * TILE_SIZE,
|
|
];
|
|
}
|
|
|
|
function pixel(index, x, y, color) {
|
|
const [ox, oy] = tileOrigin(index);
|
|
atlasCtx.fillStyle = color;
|
|
atlasCtx.fillRect(ox + x, oy + y, 1, 1);
|
|
}
|
|
|
|
function fillTile(index, color) {
|
|
const [ox, oy] = tileOrigin(index);
|
|
atlasCtx.fillStyle = color;
|
|
atlasCtx.fillRect(ox, oy, TILE_SIZE, TILE_SIZE);
|
|
}
|
|
|
|
function paintTile(index, painter) {
|
|
const [ox, oy] = tileOrigin(index);
|
|
for (let y = 0; y < TILE_SIZE; y += 1) {
|
|
for (let x = 0; x < TILE_SIZE; x += 1) {
|
|
atlasCtx.fillStyle = painter(x, y);
|
|
atlasCtx.fillRect(ox + x, oy + y, 1, 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
function buildAtlas() {
|
|
paintTile(TEX.GRASS_TOP, (x, y) => {
|
|
const n = rand3(x, y, 0, 11);
|
|
if ((x + y * 3) % 29 === 0) return "#c5ec79";
|
|
if (n > 0.86) return "#6fcb55";
|
|
if (n > 0.56) return "#4ea544";
|
|
return "#3f8c39";
|
|
});
|
|
|
|
paintTile(TEX.GRASS_SIDE, (x, y) => {
|
|
const n = rand3(x, y, 1, 12);
|
|
if (y < 8) {
|
|
if ((x + y) % 9 === 0) return "#74ca58";
|
|
return n > 0.58 ? "#4d9e44" : "#3b8837";
|
|
}
|
|
if (n > 0.84) return "#835228";
|
|
if (n > 0.53) return "#6f421f";
|
|
return "#5a3418";
|
|
});
|
|
|
|
paintTile(TEX.DIRT, (x, y) => {
|
|
const n = rand3(x, y, 2, 13);
|
|
if ((x * 7 + y * 5) % 37 === 0) return "#9b6b37";
|
|
if (n > 0.82) return "#88532a";
|
|
if (n > 0.48) return "#70411f";
|
|
return "#5b3217";
|
|
});
|
|
|
|
paintTile(TEX.STONE, (x, y) => {
|
|
const n = rand3(x, y, 3, 14);
|
|
if ((x + y * 5) % 19 === 0) return "#b8bcc6";
|
|
if (n > 0.8) return "#999faa";
|
|
if (n > 0.45) return "#808791";
|
|
return "#676c75";
|
|
});
|
|
|
|
paintTile(TEX.RUNE, (x, y) => {
|
|
const n = rand3(x, y, 4, 15);
|
|
const brick = x % 8 === 0 || y % 8 === 0;
|
|
const rune = ((x === 10 || x === 11) && y > 6 && y < 25) ||
|
|
((y === 12 || y === 13) && x > 6 && x < 25) ||
|
|
((x > 17 && x < 22) && (y === 8 || y === 20));
|
|
if (rune) return "#87f0ff";
|
|
if (brick) return "#372343";
|
|
if (n > 0.84) return "#5a3a67";
|
|
if (n > 0.48) return "#452c52";
|
|
return "#311e3d";
|
|
});
|
|
|
|
paintTile(TEX.GLOW, (x, y) => {
|
|
const dx = x - 15.5;
|
|
const dy = y - 15.5;
|
|
const d = Math.sqrt(dx * dx + dy * dy);
|
|
const n = rand3(x, y, 5, 16);
|
|
if (d < 6) return "#fff1ba";
|
|
if (n > 0.78 || d < 10) return "#ffd566";
|
|
if (n > 0.36) return "#e8af3d";
|
|
return "#b87a1f";
|
|
});
|
|
|
|
paintTile(TEX.CRYSTAL, (x, y) => {
|
|
const n = rand3(x, y, 6, 17);
|
|
const diagonal = Math.abs((x - y) - 2) < 2 || Math.abs((x + y) - 25) < 2;
|
|
if (diagonal) return "#e4fdff";
|
|
if (n > 0.8) return "#a6f7ff";
|
|
if (n > 0.58) return "#75cfff";
|
|
if (n > 0.3) return "#6a8cff";
|
|
return "#5a4fd5";
|
|
});
|
|
|
|
paintTile(TEX.WOOD_SIDE, (x, y) => {
|
|
const n = rand3(x, y, 7, 18);
|
|
if (x % 6 === 0 || x % 6 === 1) return "#5d3416";
|
|
if (n > 0.78) return "#915123";
|
|
if (n > 0.42) return "#7e441d";
|
|
return "#6d3918";
|
|
});
|
|
|
|
paintTile(TEX.WOOD_TOP, (x, y) => {
|
|
const dx = x - 15.5;
|
|
const dy = y - 15.5;
|
|
const dist = Math.floor(Math.sqrt(dx * dx + dy * dy));
|
|
if (dist > 13) return "#5b3317";
|
|
if (dist % 4 === 0) return "#9a5a28";
|
|
return "#7d451f";
|
|
});
|
|
|
|
paintTile(TEX.LEAF, (x, y) => {
|
|
const n = rand3(x, y, 9, 19);
|
|
if ((x * 5 + y * 3) % 23 === 0) return "#9cf18b";
|
|
if (n > 0.82) return "#5dbf58";
|
|
if (n > 0.4) return "#3b8f42";
|
|
return "#2e6e34";
|
|
});
|
|
|
|
paintTile(TEX.OBSIDIAN, (x, y) => {
|
|
const n = rand3(x, y, 10, 20);
|
|
if ((x + y) % 17 === 0) return "#5a48b8";
|
|
if (n > 0.86) return "#43397c";
|
|
if (n > 0.52) return "#211a3f";
|
|
return "#120f26";
|
|
});
|
|
|
|
paintTile(TEX.GOLD, (x, y) => {
|
|
const n = rand3(x, y, 11, 21);
|
|
if ((x + y * 3) % 27 === 0) return "#fff8c0";
|
|
if (n > 0.8) return "#ffe17a";
|
|
if (n > 0.48) return "#e3b446";
|
|
return "#b98721";
|
|
});
|
|
|
|
paintTile(TEX.MARBLE, (x, y) => {
|
|
const n = rand3(x, y, 12, 22);
|
|
const vein = (x * 2 + y * 5) % 21 < 2 || (x * 3 - y * 2 + 99) % 29 < 2;
|
|
if (vein) return "#d6e1f4";
|
|
if (n > 0.86) return "#f8fcff";
|
|
if (n > 0.46) return "#dfe8f3";
|
|
return "#cfd7e5";
|
|
});
|
|
|
|
paintTile(TEX.MARBLE_RUNE, (x, y) => {
|
|
const base = (x * 2 + y * 5) % 21 < 2 ? "#dde6ff" : rand3(x, y, 13, 23) > 0.5 ? "#eef6ff" : "#d5dff3";
|
|
const rune = ((x > 7 && x < 24) && (y === 11 || y === 20)) ||
|
|
((y > 8 && y < 23) && (x === 10 || x === 21));
|
|
return rune ? "#80e8ff" : base;
|
|
});
|
|
|
|
paintTile(TEX.MAGMA, (x, y) => {
|
|
const crack = (x + y * 2) % 11 === 0 || (x * 3 - y * 2 + 200) % 13 === 0;
|
|
const n = rand3(x, y, 14, 24);
|
|
if (crack) return "#ffce54";
|
|
if (n > 0.72) return "#ff7b2c";
|
|
if (n > 0.42) return "#892718";
|
|
return "#34110b";
|
|
});
|
|
|
|
fillTile(TEX.UNUSED, "#0b0f18");
|
|
}
|
|
|
|
buildAtlas();
|
|
|
|
const atlasTexture = new THREE.CanvasTexture(atlasCanvas);
|
|
atlasTexture.colorSpace = THREE.SRGBColorSpace;
|
|
atlasTexture.magFilter = THREE.NearestFilter;
|
|
atlasTexture.minFilter = THREE.NearestMipmapNearestFilter;
|
|
atlasTexture.generateMipmaps = true;
|
|
atlasTexture.anisotropy = renderer.capabilities.getMaxAnisotropy();
|
|
|
|
const BLOCK_INFO = {
|
|
[BLOCK.GRASS]: {
|
|
name: "草方块",
|
|
faces: [TEX.GRASS_SIDE, TEX.GRASS_SIDE, TEX.DIRT, TEX.GRASS_TOP, TEX.GRASS_SIDE, TEX.GRASS_SIDE],
|
|
tint: [1, 1, 1],
|
|
},
|
|
[BLOCK.DIRT]: {
|
|
name: "泥土",
|
|
faces: [TEX.DIRT, TEX.DIRT, TEX.DIRT, TEX.DIRT, TEX.DIRT, TEX.DIRT],
|
|
tint: [1, 1, 1],
|
|
},
|
|
[BLOCK.STONE]: {
|
|
name: "石头",
|
|
faces: [TEX.STONE, TEX.STONE, TEX.STONE, TEX.STONE, TEX.STONE, TEX.STONE],
|
|
tint: [1, 1, 1],
|
|
},
|
|
[BLOCK.MARBLE]: {
|
|
name: "星纹大理石",
|
|
faces: [TEX.MARBLE, TEX.MARBLE, TEX.MARBLE, TEX.MARBLE, TEX.MARBLE, TEX.MARBLE],
|
|
tint: [1, 1, 1],
|
|
},
|
|
[BLOCK.RUNE]: {
|
|
name: "符文砖",
|
|
faces: [TEX.RUNE, TEX.RUNE, TEX.RUNE, TEX.RUNE, TEX.RUNE, TEX.RUNE],
|
|
tint: [1, 1, 1],
|
|
},
|
|
[BLOCK.GLOW]: {
|
|
name: "辉石",
|
|
faces: [TEX.GLOW, TEX.GLOW, TEX.GLOW, TEX.GLOW, TEX.GLOW, TEX.GLOW],
|
|
tint: [1, 1, 1],
|
|
},
|
|
[BLOCK.CRYSTAL]: {
|
|
name: "晶簇",
|
|
faces: [TEX.CRYSTAL, TEX.CRYSTAL, TEX.CRYSTAL, TEX.CRYSTAL, TEX.CRYSTAL, TEX.CRYSTAL],
|
|
tint: [1, 1, 1],
|
|
},
|
|
[BLOCK.WOOD]: {
|
|
name: "古树木",
|
|
faces: [TEX.WOOD_SIDE, TEX.WOOD_SIDE, TEX.WOOD_TOP, TEX.WOOD_TOP, TEX.WOOD_SIDE, TEX.WOOD_SIDE],
|
|
tint: [1, 1, 1],
|
|
},
|
|
[BLOCK.LEAF]: {
|
|
name: "灵叶",
|
|
faces: [TEX.LEAF, TEX.LEAF, TEX.LEAF, TEX.LEAF, TEX.LEAF, TEX.LEAF],
|
|
tint: [1, 1, 1],
|
|
},
|
|
[BLOCK.OBSIDIAN]: {
|
|
name: "玄曜石",
|
|
faces: [TEX.OBSIDIAN, TEX.OBSIDIAN, TEX.OBSIDIAN, TEX.OBSIDIAN, TEX.OBSIDIAN, TEX.OBSIDIAN],
|
|
tint: [1, 1, 1],
|
|
},
|
|
[BLOCK.GOLD]: {
|
|
name: "圣金",
|
|
faces: [TEX.GOLD, TEX.GOLD, TEX.GOLD, TEX.GOLD, TEX.GOLD, TEX.GOLD],
|
|
tint: [1, 1, 1],
|
|
},
|
|
[BLOCK.MARBLE_RUNE]: {
|
|
name: "刻纹石",
|
|
faces: [TEX.MARBLE_RUNE, TEX.MARBLE_RUNE, TEX.MARBLE_RUNE, TEX.MARBLE_RUNE, TEX.MARBLE_RUNE, TEX.MARBLE_RUNE],
|
|
tint: [1, 1, 1],
|
|
},
|
|
[BLOCK.MAGMA]: {
|
|
name: "熔纹岩",
|
|
faces: [TEX.MAGMA, TEX.MAGMA, TEX.MAGMA, TEX.MAGMA, TEX.MAGMA, TEX.MAGMA],
|
|
tint: [1, 1, 1],
|
|
},
|
|
};
|
|
|
|
const palette = [BLOCK.GRASS, BLOCK.DIRT, BLOCK.STONE, BLOCK.MARBLE, BLOCK.RUNE, BLOCK.CRYSTAL];
|
|
let selectedIndex = 0;
|
|
|
|
function renderHotbar() {
|
|
hotbar.innerHTML = "";
|
|
palette.forEach((blockId, index) => {
|
|
const slot = document.createElement("div");
|
|
slot.className = `slot${index === selectedIndex ? " active" : ""}`;
|
|
slot.innerHTML = `
|
|
<div class="num">${index + 1}</div>
|
|
<div class="name">${BLOCK_INFO[blockId].name}</div>
|
|
`;
|
|
hotbar.appendChild(slot);
|
|
});
|
|
}
|
|
|
|
renderHotbar();
|
|
|
|
const blockMaterial = new THREE.MeshStandardMaterial({
|
|
map: atlasTexture,
|
|
vertexColors: true,
|
|
roughness: 1,
|
|
metalness: 0,
|
|
});
|
|
|
|
const FACE_DEFS = [
|
|
{
|
|
dir: [-1, 0, 0],
|
|
corners: [
|
|
[0, 1, 1],
|
|
[0, 0, 1],
|
|
[0, 1, 0],
|
|
[0, 0, 0],
|
|
],
|
|
shade: 0.73,
|
|
},
|
|
{
|
|
dir: [1, 0, 0],
|
|
corners: [
|
|
[1, 1, 0],
|
|
[1, 0, 0],
|
|
[1, 1, 1],
|
|
[1, 0, 1],
|
|
],
|
|
shade: 0.82,
|
|
},
|
|
{
|
|
dir: [0, -1, 0],
|
|
corners: [
|
|
[1, 0, 0],
|
|
[0, 0, 0],
|
|
[1, 0, 1],
|
|
[0, 0, 1],
|
|
],
|
|
shade: 0.58,
|
|
},
|
|
{
|
|
dir: [0, 1, 0],
|
|
corners: [
|
|
[0, 1, 0],
|
|
[1, 1, 0],
|
|
[0, 1, 1],
|
|
[1, 1, 1],
|
|
],
|
|
shade: 1.0,
|
|
},
|
|
{
|
|
dir: [0, 0, -1],
|
|
corners: [
|
|
[1, 1, 0],
|
|
[1, 0, 0],
|
|
[0, 1, 0],
|
|
[0, 0, 0],
|
|
],
|
|
shade: 0.76,
|
|
},
|
|
{
|
|
dir: [0, 0, 1],
|
|
corners: [
|
|
[0, 1, 1],
|
|
[0, 0, 1],
|
|
[1, 1, 1],
|
|
[1, 0, 1],
|
|
],
|
|
shade: 0.88,
|
|
},
|
|
];
|
|
|
|
const chunkData = new Map();
|
|
const chunkMeshes = new Map();
|
|
const rebuildQueue = [];
|
|
const queuedChunks = new Set();
|
|
|
|
const selectionBox = new THREE.LineSegments(
|
|
new THREE.EdgesGeometry(new THREE.BoxGeometry(1.02, 1.02, 1.02)),
|
|
new THREE.LineBasicMaterial({
|
|
color: 0xfff0ae,
|
|
transparent: true,
|
|
opacity: 0.95,
|
|
depthTest: false,
|
|
})
|
|
);
|
|
selectionBox.visible = false;
|
|
selectionBox.renderOrder = 10;
|
|
scene.add(selectionBox);
|
|
|
|
const raycaster = new THREE.Raycaster();
|
|
raycaster.far = 8;
|
|
let currentTarget = null;
|
|
|
|
const keys = new Set();
|
|
const player = {
|
|
position: new THREE.Vector3(SPAWN.x, SPAWN.y, SPAWN.z),
|
|
velocity: new THREE.Vector3(),
|
|
yaw: 0,
|
|
pitch: 0,
|
|
onGround: false,
|
|
};
|
|
|
|
const POIS = [
|
|
{ name: "星冠浮城", x: 0, z: 0 },
|
|
{ name: "世界树庭", x: -46, z: -28 },
|
|
{ name: "月井神殿", x: 34, z: -50 },
|
|
{ name: "蚀日石门", x: 60, z: 6 },
|
|
{ name: "坠星晶坑", x: 28, z: 56 },
|
|
{ name: "龙骸山脊", x: -72, z: 34 },
|
|
];
|
|
|
|
function getBlockName(id) {
|
|
return BLOCK_INFO[id]?.name ?? "空气";
|
|
}
|
|
|
|
function isSolid(id) {
|
|
return id !== BLOCK.AIR;
|
|
}
|
|
|
|
function getBiome(x, z) {
|
|
const v = perlin.octave2D(x * 0.0014 + 17, z * 0.0014 - 29, 4, 0.55, 2);
|
|
if (v < -0.32) return BIOME.MEADOW;
|
|
if (v < 0.08) return BIOME.HIGHLAND;
|
|
if (v < 0.42) return BIOME.ASH;
|
|
return BIOME.CRYSTAL;
|
|
}
|
|
|
|
function getTerrainHeight(x, z) {
|
|
const biome = getBiome(x, z);
|
|
const continent = perlin.octave2D(x * 0.0009, z * 0.0009, 5, 0.55, 2);
|
|
const hills = perlin.octave2D(x * 0.012 + 100, z * 0.012 - 80, 4, 0.52, 2);
|
|
const ridges = 1 - Math.abs(perlin.octave2D(x * 0.006 - 140, z * 0.006 + 200, 4, 0.5, 2));
|
|
const warp = perlin.octave2D((x + hills * 36) * 0.021, (z - hills * 24) * 0.021, 3, 0.55, 2);
|
|
|
|
let height = 18 + continent * 12 + hills * 7 + Math.pow(ridges, 2.2) * 8 + warp * 4;
|
|
if (biome === BIOME.MEADOW) height += 2.5;
|
|
if (biome === BIOME.HIGHLAND) height += 5;
|
|
if (biome === BIOME.ASH) height += 1.5 + Math.pow(ridges, 2) * 4;
|
|
if (biome === BIOME.CRYSTAL) height += 3.5;
|
|
|
|
return clamp(Math.floor(height), 6, 38);
|
|
}
|
|
|
|
function getSkyIslandInfo(x, z) {
|
|
const skyNoise = perlin.octave2D(x * 0.008 + 330, z * 0.008 - 410, 4, 0.5, 2);
|
|
if (skyNoise < 0.64) return null;
|
|
const top = 39 + Math.floor(perlin.noise2D(x * 0.02 - 51, z * 0.02 + 77) * 5);
|
|
const thickness = 2 + Math.floor((skyNoise - 0.64) * 12);
|
|
return {
|
|
top,
|
|
bottom: top - thickness,
|
|
};
|
|
}
|
|
|
|
function getBaseBlockForLayer(biome, x, y, z, surfaceY) {
|
|
if (y === surfaceY) {
|
|
if (biome === BIOME.MEADOW) return BLOCK.GRASS;
|
|
if (biome === BIOME.HIGHLAND) return rand2(x, z, 81) > 0.78 ? BLOCK.GRASS : BLOCK.STONE;
|
|
if (biome === BIOME.ASH) return rand2(x, z, 82) > 0.85 ? BLOCK.MAGMA : BLOCK.OBSIDIAN;
|
|
return rand2(x, z, 83) > 0.8 ? BLOCK.CRYSTAL : BLOCK.MARBLE;
|
|
}
|
|
|
|
if (y >= surfaceY - 3) {
|
|
if (biome === BIOME.MEADOW) return BLOCK.DIRT;
|
|
if (biome === BIOME.CRYSTAL && y === surfaceY - 1 && rand2(x, z, 84) > 0.88) return BLOCK.CRYSTAL;
|
|
return BLOCK.STONE;
|
|
}
|
|
|
|
if (y < surfaceY - 4 && y > 8) {
|
|
const vein = perlin.noise3D(x * 0.08, y * 0.08, z * 0.08 + 40);
|
|
if (biome === BIOME.CRYSTAL && vein > 0.64) return BLOCK.CRYSTAL;
|
|
if (biome === BIOME.ASH && vein > 0.63) return BLOCK.MAGMA;
|
|
if (vein > 0.76) return BLOCK.GLOW;
|
|
}
|
|
|
|
return BLOCK.STONE;
|
|
}
|
|
|
|
function setLocal(data, lx, y, lz, id) {
|
|
if (y < 0 || y >= WORLD_HEIGHT) return;
|
|
if (lx < 0 || lx >= CHUNK_SIZE || lz < 0 || lz >= CHUNK_SIZE) return;
|
|
data[indexLocal(lx, y, lz)] = id;
|
|
}
|
|
|
|
function getLocal(data, lx, y, lz) {
|
|
if (y < 0 || y >= WORLD_HEIGHT) return BLOCK.AIR;
|
|
if (lx < 0 || lx >= CHUNK_SIZE || lz < 0 || lz >= CHUNK_SIZE) return BLOCK.AIR;
|
|
return data[indexLocal(lx, y, lz)];
|
|
}
|
|
|
|
function makeWriter(data, cx, cz) {
|
|
return {
|
|
set(x, y, z, id) {
|
|
if (y < 0 || y >= WORLD_HEIGHT) return;
|
|
const lx = x - cx * CHUNK_SIZE;
|
|
const lz = z - cz * CHUNK_SIZE;
|
|
if (lx < 0 || lx >= CHUNK_SIZE || lz < 0 || lz >= CHUNK_SIZE) return;
|
|
data[indexLocal(lx, y, lz)] = id;
|
|
},
|
|
};
|
|
}
|
|
|
|
function chunkOverlapsBox(cx, cz, minX, maxX, minZ, maxZ) {
|
|
const chunkMinX = cx * CHUNK_SIZE;
|
|
const chunkMaxX = chunkMinX + CHUNK_SIZE - 1;
|
|
const chunkMinZ = cz * CHUNK_SIZE;
|
|
const chunkMaxZ = chunkMinZ + CHUNK_SIZE - 1;
|
|
return !(maxX < chunkMinX || minX > chunkMaxX || maxZ < chunkMinZ || minZ > chunkMaxZ);
|
|
}
|
|
|
|
function fillBox(writer, x0, y0, z0, x1, y1, z1, id) {
|
|
const minX = Math.min(x0, x1);
|
|
const maxX = Math.max(x0, x1);
|
|
const minY = Math.min(y0, y1);
|
|
const maxY = Math.max(y0, y1);
|
|
const minZ = Math.min(z0, z1);
|
|
const maxZ = Math.max(z0, z1);
|
|
for (let x = minX; x <= maxX; x += 1) {
|
|
for (let y = minY; y <= maxY; y += 1) {
|
|
for (let z = minZ; z <= maxZ; z += 1) {
|
|
writer.set(x, y, z, id);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function fillDisc(writer, cx, y, cz, radius, id) {
|
|
const r = Math.ceil(radius);
|
|
const r2 = radius * radius + 0.2;
|
|
for (let x = Math.floor(cx - r); x <= Math.ceil(cx + r); x += 1) {
|
|
for (let z = Math.floor(cz - r); z <= Math.ceil(cz + r); z += 1) {
|
|
const dx = x - cx;
|
|
const dz = z - cz;
|
|
if (dx * dx + dz * dz <= r2) {
|
|
writer.set(x, y, z, id);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function fillRing(writer, cx, y, cz, outerRadius, innerRadius, id) {
|
|
const r = Math.ceil(outerRadius);
|
|
const outer2 = outerRadius * outerRadius + 0.2;
|
|
const inner2 = innerRadius * innerRadius - 0.2;
|
|
for (let x = Math.floor(cx - r); x <= Math.ceil(cx + r); x += 1) {
|
|
for (let z = Math.floor(cz - r); z <= Math.ceil(cz + r); z += 1) {
|
|
const dx = x - cx;
|
|
const dz = z - cz;
|
|
const d2 = dx * dx + dz * dz;
|
|
if (d2 <= outer2 && d2 >= inner2) {
|
|
writer.set(x, y, z, id);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function fillCylinder(writer, cx, y, cz, radius, height, id) {
|
|
for (let dy = 0; dy < height; dy += 1) {
|
|
fillDisc(writer, cx, y + dy, cz, radius, id);
|
|
}
|
|
}
|
|
|
|
function fillHollowCylinder(writer, cx, y, cz, radius, height, thickness, id) {
|
|
const outer = radius;
|
|
const inner = Math.max(0, radius - thickness);
|
|
for (let dy = 0; dy < height; dy += 1) {
|
|
fillRing(writer, cx, y + dy, cz, outer, inner, id);
|
|
}
|
|
}
|
|
|
|
function fillSphere(writer, cx, cy, cz, radius, id) {
|
|
const r = Math.ceil(radius);
|
|
const r2 = radius * radius + 0.2;
|
|
for (let x = Math.floor(cx - r); x <= Math.ceil(cx + r); x += 1) {
|
|
for (let y = Math.floor(cy - r); y <= Math.ceil(cy + r); y += 1) {
|
|
for (let z = Math.floor(cz - r); z <= Math.ceil(cz + r); z += 1) {
|
|
const dx = x - cx;
|
|
const dy = y - cy;
|
|
const dz = z - cz;
|
|
if (dx * dx + dy * dy + dz * dz <= r2) {
|
|
writer.set(x, y, z, id);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function lineBrush(writer, ax, ay, az, bx, by, bz, id, brush = 0) {
|
|
const dx = bx - ax;
|
|
const dy = by - ay;
|
|
const dz = bz - az;
|
|
const steps = Math.max(1, Math.ceil(Math.max(Math.abs(dx), Math.abs(dy), Math.abs(dz)) * 2));
|
|
for (let i = 0; i <= steps; i += 1) {
|
|
const t = i / steps;
|
|
const x = THREE.MathUtils.lerp(ax, bx, t);
|
|
const y = THREE.MathUtils.lerp(ay, by, t);
|
|
const z = THREE.MathUtils.lerp(az, bz, t);
|
|
if (brush > 0) {
|
|
fillSphere(writer, x, y, z, brush, id);
|
|
} else {
|
|
writer.set(Math.round(x), Math.round(y), Math.round(z), id);
|
|
}
|
|
}
|
|
}
|
|
|
|
function buildBridge(writer, ax, ay, az, bx, by, bz, halfWidth, deckId, railId, rise = 2.5) {
|
|
const dx = bx - ax;
|
|
const dy = by - ay;
|
|
const dz = bz - az;
|
|
const flatLength = Math.hypot(dx, dz) || 1;
|
|
const nx = -dz / flatLength;
|
|
const nz = dx / flatLength;
|
|
const steps = Math.max(12, Math.ceil(flatLength * 2));
|
|
|
|
for (let i = 0; i <= steps; i += 1) {
|
|
const t = i / steps;
|
|
const arc = Math.sin(t * Math.PI) * rise;
|
|
const x = THREE.MathUtils.lerp(ax, bx, t);
|
|
const y = THREE.MathUtils.lerp(ay, by, t) + arc;
|
|
const z = THREE.MathUtils.lerp(az, bz, t);
|
|
const py = Math.round(y);
|
|
|
|
for (let w = -halfWidth; w <= halfWidth; w += 1) {
|
|
const px = Math.round(x + nx * w);
|
|
const pz = Math.round(z + nz * w);
|
|
writer.set(px, py, pz, deckId);
|
|
if (Math.abs(w) === halfWidth && railId && i % 2 === 0) {
|
|
writer.set(px, py + 1, pz, railId);
|
|
}
|
|
}
|
|
|
|
if (railId && i % 8 === 0) {
|
|
writer.set(Math.round(x), py + 1, Math.round(z), BLOCK.GLOW);
|
|
}
|
|
}
|
|
}
|
|
|
|
function buildCrystalCluster(writer, ox, oz, options = {}) {
|
|
const scale = options.scale ?? 1;
|
|
const baseY = options.baseY ?? getTerrainHeight(ox, oz) + 1;
|
|
const seed = options.seed ?? hash2(ox, oz, 9911);
|
|
const count = 3 + (seed % 4);
|
|
const baseRadius = Math.max(1, Math.round(1.5 * scale));
|
|
fillDisc(writer, ox, baseY, oz, baseRadius, BLOCK.MARBLE_RUNE);
|
|
|
|
for (let i = 0; i < count; i += 1) {
|
|
const angle = (i / count) * Math.PI * 2 + ((seed >>> 4) & 31) * 0.08;
|
|
const dist = ((seed >>> (i * 2)) & 3) * 0.7 * scale;
|
|
const sx = ox + Math.cos(angle) * dist;
|
|
const sz = oz + Math.sin(angle) * dist;
|
|
const height = Math.round((4 + ((seed >>> (i * 3)) & 7)) * scale);
|
|
const tipX = sx + Math.cos(angle) * (1.2 * scale);
|
|
const tipZ = sz + Math.sin(angle) * (1.2 * scale);
|
|
const brush = Math.max(0, Math.round(scale - 0.2));
|
|
lineBrush(writer, sx, baseY, sz, tipX, baseY + height, tipZ, BLOCK.CRYSTAL, brush);
|
|
writer.set(Math.round(tipX), baseY + height + 1, Math.round(tipZ), BLOCK.GLOW);
|
|
}
|
|
}
|
|
|
|
function buildSmallTree(writer, ox, oz, options = {}) {
|
|
const scale = options.scale ?? 1;
|
|
const baseY = options.baseY ?? getTerrainHeight(ox, oz) + 1;
|
|
const seed = options.seed ?? hash2(ox, oz, 712);
|
|
const trunkHeight = 4 + (seed % 3) + Math.round(scale);
|
|
fillCylinder(writer, ox, baseY, oz, 1, trunkHeight, BLOCK.WOOD);
|
|
fillSphere(writer, ox, baseY + trunkHeight + 1, oz, 2.3 * scale, BLOCK.LEAF);
|
|
fillSphere(writer, ox + 2, baseY + trunkHeight, oz, 1.6 * scale, BLOCK.LEAF);
|
|
fillSphere(writer, ox - 2, baseY + trunkHeight, oz + 1, 1.6 * scale, BLOCK.LEAF);
|
|
writer.set(ox, baseY + trunkHeight + 2, oz, BLOCK.GLOW);
|
|
}
|
|
|
|
function buildRunestone(writer, ox, oz, options = {}) {
|
|
const baseY = options.baseY ?? getTerrainHeight(ox, oz) + 1;
|
|
fillDisc(writer, ox, baseY - 1, oz, 1.2, BLOCK.STONE);
|
|
fillBox(writer, ox, baseY, oz, ox, baseY + 4, oz, BLOCK.RUNE);
|
|
writer.set(ox, baseY + 5, oz, BLOCK.GLOW);
|
|
}
|
|
|
|
function buildBasaltSpire(writer, ox, oz, options = {}) {
|
|
const scale = options.scale ?? 1;
|
|
const baseY = options.baseY ?? getTerrainHeight(ox, oz) + 1;
|
|
const seed = options.seed ?? hash2(ox, oz, 613);
|
|
const height = 6 + (seed % 5) + Math.round(scale * 2);
|
|
lineBrush(
|
|
writer,
|
|
ox,
|
|
baseY,
|
|
oz,
|
|
ox + (((seed >>> 3) & 1) ? 2 : -2),
|
|
baseY + height,
|
|
oz + (((seed >>> 4) & 1) ? 1 : -1),
|
|
BLOCK.OBSIDIAN,
|
|
1
|
|
);
|
|
writer.set(ox, baseY + Math.floor(height * 0.55), oz, BLOCK.MAGMA);
|
|
writer.set(ox, baseY + height + 1, oz, BLOCK.GLOW);
|
|
}
|
|
|
|
function buildPavilion(writer, ox, oy, oz, size = 4) {
|
|
fillDisc(writer, ox, oy, oz, size + 1, BLOCK.MARBLE);
|
|
fillDisc(writer, ox, oy + 4, oz, size + 1, BLOCK.OBSIDIAN);
|
|
fillRing(writer, ox, oy + 5, oz, size + 1, size - 0.3, BLOCK.GOLD);
|
|
const posts = [
|
|
[-size + 1, -size + 1],
|
|
[size - 1, -size + 1],
|
|
[-size + 1, size - 1],
|
|
[size - 1, size - 1],
|
|
];
|
|
posts.forEach(([dx, dz]) => fillCylinder(writer, ox + dx, oy + 1, oz + dz, 1, 4, BLOCK.MARBLE_RUNE));
|
|
writer.set(ox, oy + 5, oz, BLOCK.GLOW);
|
|
}
|
|
|
|
function buildSpiralSpire(writer, ox, oz, options = {}) {
|
|
const scale = options.scale ?? 1;
|
|
const baseY = options.baseY ?? getTerrainHeight(ox, oz) + 1;
|
|
const seed = options.seed ?? hash2(ox, oz, 315);
|
|
const coreRadius = Math.max(1, Math.round(2 * scale));
|
|
const coreHeight = Math.round(14 * scale);
|
|
fillCylinder(writer, ox, baseY, oz, coreRadius, coreHeight, BLOCK.MARBLE_RUNE);
|
|
fillDisc(writer, ox, baseY + coreHeight, oz, 4 * scale, BLOCK.MARBLE);
|
|
|
|
const steps = Math.ceil(coreHeight * 2.4);
|
|
for (let i = 0; i <= steps; i += 1) {
|
|
const t = i / steps;
|
|
const angle = t * Math.PI * 4 + (seed & 7);
|
|
const radius = 4 * scale;
|
|
const x = ox + Math.cos(angle) * radius;
|
|
const y = baseY + 1 + t * coreHeight;
|
|
const z = oz + Math.sin(angle) * radius;
|
|
fillSphere(writer, x, y, z, 0.7, i % 3 === 0 ? BLOCK.GOLD : BLOCK.MARBLE);
|
|
}
|
|
|
|
buildCrystalCluster(writer, ox, oz, {
|
|
baseY: baseY + coreHeight,
|
|
scale: 1.15 * scale,
|
|
seed,
|
|
});
|
|
}
|
|
|
|
function buildRuneGate(writer, ox, oz, options = {}) {
|
|
const scale = options.scale ?? 1;
|
|
const baseY = options.baseY ?? getTerrainHeight(ox, oz) + 1;
|
|
const span = Math.round(7 * scale);
|
|
const towerHeight = Math.round(12 * scale);
|
|
fillBox(writer, ox - 12, baseY - 1, oz - 7, ox + 12, baseY, oz + 7, BLOCK.STONE);
|
|
|
|
for (let step = 0; step < 5; step += 1) {
|
|
fillBox(writer, ox - 4, baseY + step - 1, oz + 8 + step, ox + 4, baseY + step - 1, oz + 11 + step, BLOCK.MARBLE);
|
|
}
|
|
|
|
fillBox(writer, ox - span - 2, baseY, oz - 3, ox - span + 2, baseY + towerHeight, oz + 3, BLOCK.OBSIDIAN);
|
|
fillBox(writer, ox + span - 2, baseY, oz - 3, ox + span + 2, baseY + towerHeight, oz + 3, BLOCK.OBSIDIAN);
|
|
fillBox(writer, ox - span + 3, baseY + towerHeight - 2, oz - 3, ox + span - 3, baseY + towerHeight + 1, oz + 3, BLOCK.RUNE);
|
|
fillBox(writer, ox - 5, baseY + 1, oz - 2, ox + 5, baseY + towerHeight - 4, oz + 2, BLOCK.AIR);
|
|
|
|
for (const side of [-1, 1]) {
|
|
for (let y = 0; y < towerHeight; y += 4) {
|
|
writer.set(ox + side * span, baseY + y + 1, oz, BLOCK.GLOW);
|
|
}
|
|
writer.set(ox + side * span, baseY + towerHeight + 2, oz, BLOCK.GOLD);
|
|
}
|
|
}
|
|
|
|
function buildSunkenShrine(writer, ox, oz, options = {}) {
|
|
const scale = options.scale ?? 1;
|
|
const groundY = options.baseY ?? getTerrainHeight(ox, oz);
|
|
const radius = Math.round(8 * scale);
|
|
|
|
for (let dx = -radius - 2; dx <= radius + 2; dx += 1) {
|
|
for (let dz = -radius - 2; dz <= radius + 2; dz += 1) {
|
|
const dist = Math.hypot(dx, dz);
|
|
if (dist > radius + 2) continue;
|
|
const slope = Math.max(0, 1 - dist / (radius + 2));
|
|
const depth = Math.floor(slope * 6 * scale);
|
|
for (let y = groundY + 4; y >= groundY - depth; y -= 1) {
|
|
writer.set(ox + dx, y, oz + dz, BLOCK.AIR);
|
|
}
|
|
const floorBlock = dist < 2 ? BLOCK.GLOW : dist < radius - 2 ? BLOCK.MARBLE : BLOCK.MARBLE_RUNE;
|
|
writer.set(ox + dx, groundY - depth, oz + dz, floorBlock);
|
|
}
|
|
}
|
|
|
|
for (let i = 0; i < 8; i += 1) {
|
|
const angle = (i / 8) * Math.PI * 2;
|
|
const px = Math.round(ox + Math.cos(angle) * (radius - 2));
|
|
const pz = Math.round(oz + Math.sin(angle) * (radius - 2));
|
|
fillCylinder(writer, px, groundY - 2, pz, 1, 5, BLOCK.MARBLE_RUNE);
|
|
writer.set(px, groundY + 3, pz, BLOCK.GLOW);
|
|
}
|
|
}
|
|
|
|
function buildCrystalCrater(writer, ox, oz, options = {}) {
|
|
const scale = options.scale ?? 1;
|
|
const groundY = options.baseY ?? getTerrainHeight(ox, oz);
|
|
const radius = Math.round(9 * scale);
|
|
|
|
for (let dx = -radius - 2; dx <= radius + 2; dx += 1) {
|
|
for (let dz = -radius - 2; dz <= radius + 2; dz += 1) {
|
|
const dist = Math.hypot(dx, dz);
|
|
if (dist > radius + 2) continue;
|
|
const slope = Math.max(0, 1 - dist / (radius + 2));
|
|
const depth = Math.floor(slope * 5 * scale);
|
|
for (let y = groundY + 3; y >= groundY - depth; y -= 1) {
|
|
writer.set(ox + dx, y, oz + dz, BLOCK.AIR);
|
|
}
|
|
const floorBlock = dist < radius - 2 ? BLOCK.OBSIDIAN : BLOCK.MARBLE_RUNE;
|
|
writer.set(ox + dx, groundY - depth, oz + dz, floorBlock);
|
|
}
|
|
}
|
|
|
|
buildCrystalCluster(writer, ox, oz, {
|
|
baseY: groundY - 3,
|
|
scale: 1.9 * scale,
|
|
seed: hash2(ox, oz, 9001),
|
|
});
|
|
}
|
|
|
|
function buildDragonRibs(writer, ox, oz, options = {}) {
|
|
const scale = options.scale ?? 1;
|
|
const direction = options.direction ?? 1;
|
|
const segments = Math.round(6 * scale);
|
|
let prevTop = null;
|
|
|
|
for (let i = 0; i < segments; i += 1) {
|
|
const x = Math.round(ox + i * 4 * direction);
|
|
const z = Math.round(oz + Math.sin(i * 0.75) * 4);
|
|
const groundY = getTerrainHeight(x, z) + 1;
|
|
const ribHeight = Math.round(5 + Math.sin(i * 0.6 + 0.4) * 2 + scale * 3);
|
|
lineBrush(writer, x, groundY, z - 3, x, groundY + ribHeight, z, BLOCK.MARBLE, 1);
|
|
lineBrush(writer, x, groundY + ribHeight, z, x, groundY, z + 3, BLOCK.MARBLE, 1);
|
|
const top = { x, y: groundY + ribHeight, z };
|
|
if (prevTop) {
|
|
lineBrush(writer, prevTop.x, prevTop.y, prevTop.z, top.x, top.y, top.z, BLOCK.STONE, 1);
|
|
}
|
|
prevTop = top;
|
|
}
|
|
|
|
const skullX = ox - 3 * direction;
|
|
const skullY = getTerrainHeight(skullX, oz) + 4;
|
|
fillSphere(writer, skullX, skullY, oz, 2.5, BLOCK.STONE);
|
|
writer.set(skullX + direction, skullY, oz - 1, BLOCK.GLOW);
|
|
writer.set(skullX + direction, skullY, oz + 1, BLOCK.GLOW);
|
|
}
|
|
|
|
function buildSkyTemple(writer, ox, oz, options = {}) {
|
|
const scale = options.scale ?? 1;
|
|
const floating = options.floating ?? true;
|
|
const hero = options.hero ?? false;
|
|
const baseY = options.baseY ?? (floating ? getTerrainHeight(ox, oz) + 14 : getTerrainHeight(ox, oz) + 2);
|
|
const coreRadius = Math.round(7 * scale);
|
|
fillDisc(writer, ox, baseY - 2, oz, coreRadius + 3, BLOCK.STONE);
|
|
fillDisc(writer, ox, baseY - 1, oz, coreRadius + 2, BLOCK.MARBLE);
|
|
fillDisc(writer, ox, baseY, oz, coreRadius + 1, BLOCK.MARBLE_RUNE);
|
|
fillDisc(writer, ox, baseY + 1, oz, coreRadius, BLOCK.MARBLE);
|
|
fillRing(writer, ox, baseY + 1, oz, coreRadius, coreRadius - 1.2, BLOCK.GOLD);
|
|
|
|
if (floating) {
|
|
for (let i = 0; i < 6; i += 1) {
|
|
const angle = (i / 6) * Math.PI * 2;
|
|
const sx = ox + Math.cos(angle) * (coreRadius - 2);
|
|
const sz = oz + Math.sin(angle) * (coreRadius - 2);
|
|
lineBrush(writer, sx, baseY - 2, sz, ox + Math.cos(angle) * 4, baseY - 13 - scale * 4, oz + Math.sin(angle) * 4, BLOCK.STONE, 1);
|
|
}
|
|
}
|
|
|
|
const columnCount = hero ? 8 : 6;
|
|
for (let i = 0; i < columnCount; i += 1) {
|
|
const angle = (i / columnCount) * Math.PI * 2;
|
|
const px = Math.round(ox + Math.cos(angle) * (coreRadius - 2));
|
|
const pz = Math.round(oz + Math.sin(angle) * (coreRadius - 2));
|
|
fillCylinder(writer, px, baseY + 2, pz, 1, Math.round(5 * scale), BLOCK.MARBLE_RUNE);
|
|
fillDisc(writer, px, baseY + 2 + Math.round(5 * scale), pz, 1.5, BLOCK.GOLD);
|
|
writer.set(px, baseY + 3 + Math.round(5 * scale), pz, BLOCK.GLOW);
|
|
}
|
|
|
|
fillHollowCylinder(writer, ox, baseY + 2, oz, Math.max(2, Math.round(3 * scale)), Math.round(10 * scale), 1, BLOCK.RUNE);
|
|
fillRing(writer, ox, baseY + Math.round(11 * scale), oz, Math.round(5 * scale), Math.max(0.5, Math.round(4 * scale)), BLOCK.GOLD);
|
|
fillDisc(writer, ox, baseY + Math.round(11 * scale), oz, Math.max(1, Math.round(1.8 * scale)), BLOCK.GLOW);
|
|
buildCrystalCluster(writer, ox, oz, {
|
|
baseY: baseY + Math.round(11 * scale),
|
|
scale: 1.1 * scale,
|
|
seed: hash2(ox, oz, 3021),
|
|
});
|
|
|
|
if (!hero) return;
|
|
|
|
const satellites = [
|
|
{ dx: 24, dz: 0, kind: "spire" },
|
|
{ dx: -24, dz: 0, kind: "crystal" },
|
|
{ dx: 0, dz: -24, kind: "pavilion" },
|
|
{ dx: 0, dz: 24, kind: "pavilion" },
|
|
];
|
|
|
|
satellites.forEach(({ dx, dz, kind }) => {
|
|
const islandY = baseY - 1 + (Math.abs(dx) > 0 ? 1 : 0);
|
|
fillDisc(writer, ox + dx, islandY - 1, oz + dz, 6, BLOCK.STONE);
|
|
fillDisc(writer, ox + dx, islandY, oz + dz, 5, kind === "pavilion" ? BLOCK.MARBLE : BLOCK.MARBLE_RUNE);
|
|
buildBridge(
|
|
writer,
|
|
ox + Math.sign(dx || 1) * (Math.abs(dx) > 0 ? coreRadius + 1 : 0),
|
|
baseY + 1,
|
|
oz + Math.sign(dz || 1) * (Math.abs(dz) > 0 ? coreRadius + 1 : 0),
|
|
ox + dx - Math.sign(dx || 1) * 5,
|
|
islandY + 1,
|
|
oz + dz - Math.sign(dz || 1) * 5,
|
|
2,
|
|
BLOCK.MARBLE,
|
|
BLOCK.RUNE,
|
|
2
|
|
);
|
|
|
|
if (kind === "spire") {
|
|
buildSpiralSpire(writer, ox + dx, oz + dz, { baseY: islandY + 1, scale: 0.75 });
|
|
} else if (kind === "crystal") {
|
|
buildCrystalCluster(writer, ox + dx, oz + dz, { baseY: islandY + 1, scale: 1.35 });
|
|
} else {
|
|
buildPavilion(writer, ox + dx, islandY + 1, oz + dz, 4);
|
|
}
|
|
});
|
|
|
|
const southGround = getTerrainHeight(0, 50) + 3;
|
|
buildBridge(writer, ox, baseY + 1, oz + coreRadius + 1, 0, southGround, 50, 2, BLOCK.MARBLE, BLOCK.MARBLE_RUNE, 1.5);
|
|
}
|
|
|
|
function applyScatterFeatures(cx, cz, data) {
|
|
const writer = makeWriter(data, cx, cz);
|
|
const worldMinX = cx * CHUNK_SIZE;
|
|
const worldMinZ = cz * CHUNK_SIZE;
|
|
const cellSize = 10;
|
|
const cellMinX = floorDiv(worldMinX - 8, cellSize);
|
|
const cellMaxX = floorDiv(worldMinX + CHUNK_SIZE + 8, cellSize);
|
|
const cellMinZ = floorDiv(worldMinZ - 8, cellSize);
|
|
const cellMaxZ = floorDiv(worldMinZ + CHUNK_SIZE + 8, cellSize);
|
|
|
|
for (let gx = cellMinX; gx <= cellMaxX; gx += 1) {
|
|
for (let gz = cellMinZ; gz <= cellMaxZ; gz += 1) {
|
|
const seed = hash2(gx, gz, 4040);
|
|
if ((seed & 255) < 184) continue;
|
|
const fx = gx * cellSize + 2 + (seed % 6);
|
|
const fz = gz * cellSize + 2 + ((seed >>> 4) % 6);
|
|
if (Math.hypot(fx, fz) < 90) continue;
|
|
const biome = getBiome(fx, fz);
|
|
|
|
if (biome === BIOME.MEADOW) {
|
|
if ((seed & 7) < 3) buildSmallTree(writer, fx, fz, { seed });
|
|
else buildRunestone(writer, fx, fz);
|
|
} else if (biome === BIOME.CRYSTAL) {
|
|
buildCrystalCluster(writer, fx, fz, { seed, scale: 0.9 + ((seed >>> 8) & 3) * 0.18 });
|
|
} else if (biome === BIOME.ASH) {
|
|
buildBasaltSpire(writer, fx, fz, { seed, scale: 0.95 + ((seed >>> 7) & 3) * 0.15 });
|
|
} else if ((seed & 3) === 0) {
|
|
buildRunestone(writer, fx, fz);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function applyCentralLandmarks(cx, cz, data) {
|
|
const writer = makeWriter(data, cx, cz);
|
|
|
|
if (chunkOverlapsBox(cx, cz, -40, 40, -34, 56)) {
|
|
buildSkyTemple(writer, 0, 0, { hero: true, floating: true, scale: 1.25, baseY: 44 });
|
|
}
|
|
if (chunkOverlapsBox(cx, cz, -64, -28, -46, -8)) {
|
|
buildWorldTree(writer, -46, -28, { scale: 1.6 });
|
|
}
|
|
if (chunkOverlapsBox(cx, cz, 16, 52, -68, -30)) {
|
|
buildSunkenShrine(writer, 34, -50, { scale: 1.4 });
|
|
}
|
|
if (chunkOverlapsBox(cx, cz, 44, 76, -10, 24)) {
|
|
buildRuneGate(writer, 60, 6, { scale: 1.4 });
|
|
}
|
|
if (chunkOverlapsBox(cx, cz, 8, 48, 36, 76)) {
|
|
buildCrystalCrater(writer, 28, 56, { scale: 1.45 });
|
|
}
|
|
if (chunkOverlapsBox(cx, cz, -92, -44, 22, 48)) {
|
|
buildDragonRibs(writer, -72, 34, { scale: 1.35, direction: 1 });
|
|
}
|
|
}
|
|
|
|
function buildWorldTree(writer, ox, oz, options = {}) {
|
|
const scale = options.scale ?? 1;
|
|
const baseY = options.baseY ?? getTerrainHeight(ox, oz) + 1;
|
|
const trunkRadius = Math.max(2, Math.round(2.5 * scale));
|
|
const trunkHeight = Math.round(11 * scale);
|
|
fillCylinder(writer, ox, baseY, oz, trunkRadius, trunkHeight, BLOCK.WOOD);
|
|
|
|
for (let i = 0; i < 8; i += 1) {
|
|
const angle = (i / 8) * Math.PI * 2 + (i % 2) * 0.15;
|
|
const length = 8 * scale + (i % 3);
|
|
lineBrush(writer, ox, baseY + 1, oz, ox + Math.cos(angle) * length, baseY - 1, oz + Math.sin(angle) * length, BLOCK.WOOD, 1);
|
|
}
|
|
|
|
const crownY = baseY + trunkHeight + 1;
|
|
fillSphere(writer, ox, crownY, oz, 5 * scale, BLOCK.LEAF);
|
|
fillSphere(writer, ox + 4, crownY - 1, oz + 2, 3.2 * scale, BLOCK.LEAF);
|
|
fillSphere(writer, ox - 4, crownY, oz - 1, 3.1 * scale, BLOCK.LEAF);
|
|
fillSphere(writer, ox + 1, crownY + 2, oz - 4, 2.8 * scale, BLOCK.LEAF);
|
|
|
|
for (let i = 0; i < 6; i += 1) {
|
|
const angle = (i / 6) * Math.PI * 2;
|
|
const bx = ox + Math.cos(angle) * 3;
|
|
const bz = oz + Math.sin(angle) * 3;
|
|
lineBrush(writer, ox, baseY + trunkHeight - 2, oz, bx, crownY - 1, bz, BLOCK.WOOD, 1);
|
|
writer.set(Math.round(bx), crownY - 1, Math.round(bz), BLOCK.GLOW);
|
|
}
|
|
}
|
|
|
|
function applyRegionalLandmarks(cx, cz, data) {
|
|
const writer = makeWriter(data, cx, cz);
|
|
const worldMinX = cx * CHUNK_SIZE;
|
|
const worldMinZ = cz * CHUNK_SIZE;
|
|
const regionMinX = floorDiv(worldMinX - 48, REGION_SIZE);
|
|
const regionMaxX = floorDiv(worldMinX + CHUNK_SIZE + 48, REGION_SIZE);
|
|
const regionMinZ = floorDiv(worldMinZ - 48, REGION_SIZE);
|
|
const regionMaxZ = floorDiv(worldMinZ + CHUNK_SIZE + 48, REGION_SIZE);
|
|
|
|
for (let rx = regionMinX; rx <= regionMaxX; rx += 1) {
|
|
for (let rz = regionMinZ; rz <= regionMaxZ; rz += 1) {
|
|
if (Math.abs(rx) <= 1 && Math.abs(rz) <= 1) continue;
|
|
const seed = hash2(rx, rz, 5555);
|
|
if ((seed & 255) < 150) continue;
|
|
const ox = rx * REGION_SIZE + 24 + (seed % 48);
|
|
const oz = rz * REGION_SIZE + 24 + ((seed >>> 6) % 48);
|
|
const scale = 0.95 + ((seed >>> 11) & 7) * 0.09;
|
|
const type = seed % 5;
|
|
const radius = type === 4 ? 24 : 18;
|
|
|
|
if (!chunkOverlapsBox(cx, cz, ox - radius, ox + radius, oz - radius, oz + radius)) continue;
|
|
|
|
if (type === 0) {
|
|
buildSkyTemple(writer, ox, oz, {
|
|
floating: true,
|
|
scale,
|
|
baseY: getTerrainHeight(ox, oz) + 11 + Math.round(scale * 2),
|
|
});
|
|
} else if (type === 1) {
|
|
buildSpiralSpire(writer, ox, oz, { scale: 0.95 + scale * 0.3, seed });
|
|
} else if (type === 2) {
|
|
buildWorldTree(writer, ox, oz, { scale: 0.95 + scale * 0.55 });
|
|
} else if (type === 3) {
|
|
buildRuneGate(writer, ox, oz, { scale: 0.95 + scale * 0.28 });
|
|
} else {
|
|
if ((seed & 1) === 0) buildCrystalCrater(writer, ox, oz, { scale: 0.9 + scale * 0.25 });
|
|
else buildDragonRibs(writer, ox, oz, { scale: 0.9 + scale * 0.3, direction: (seed & 2) ? 1 : -1 });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function generateChunkData(cx, cz) {
|
|
const data = new Uint8Array(CHUNK_SIZE * CHUNK_SIZE * WORLD_HEIGHT);
|
|
const worldX = cx * CHUNK_SIZE;
|
|
const worldZ = cz * CHUNK_SIZE;
|
|
|
|
for (let lx = 0; lx < CHUNK_SIZE; lx += 1) {
|
|
for (let lz = 0; lz < CHUNK_SIZE; lz += 1) {
|
|
const wx = worldX + lx;
|
|
const wz = worldZ + lz;
|
|
const biome = getBiome(wx, wz);
|
|
const surfaceY = getTerrainHeight(wx, wz);
|
|
|
|
for (let y = 0; y <= surfaceY; y += 1) {
|
|
if (y > 6 && y < surfaceY - 3) {
|
|
const caveNoise = perlin.noise3D(wx * 0.07, y * 0.08, wz * 0.07 + 90);
|
|
const tunnelNoise = perlin.noise3D(wx * 0.03 + 200, y * 0.04, wz * 0.03 - 150);
|
|
if (caveNoise > 0.58 && tunnelNoise > -0.12) continue;
|
|
}
|
|
|
|
const blockId = getBaseBlockForLayer(biome, wx, y, wz, surfaceY);
|
|
setLocal(data, lx, y, lz, blockId);
|
|
}
|
|
|
|
const island = getSkyIslandInfo(wx, wz);
|
|
if (island) {
|
|
for (let y = island.bottom; y <= island.top; y += 1) {
|
|
let blockId = BLOCK.STONE;
|
|
if (y === island.top) blockId = rand2(wx, wz, 94) > 0.84 ? BLOCK.CRYSTAL : BLOCK.MARBLE;
|
|
else if (y >= island.top - 2) blockId = BLOCK.MARBLE_RUNE;
|
|
setLocal(data, lx, y, lz, blockId);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
applyScatterFeatures(cx, cz, data);
|
|
applyCentralLandmarks(cx, cz, data);
|
|
applyRegionalLandmarks(cx, cz, data);
|
|
return data;
|
|
}
|
|
|
|
function ensureChunkData(cx, cz) {
|
|
const key = chunkKey(cx, cz);
|
|
if (!chunkData.has(key)) {
|
|
chunkData.set(key, generateChunkData(cx, cz));
|
|
}
|
|
return chunkData.get(key);
|
|
}
|
|
|
|
function getBlock(x, y, z) {
|
|
if (y < 0 || y >= WORLD_HEIGHT) return BLOCK.AIR;
|
|
const cx = floorDiv(x, CHUNK_SIZE);
|
|
const cz = floorDiv(z, CHUNK_SIZE);
|
|
const data = ensureChunkData(cx, cz);
|
|
const lx = mod(x, CHUNK_SIZE);
|
|
const lz = mod(z, CHUNK_SIZE);
|
|
return getLocal(data, lx, y, lz);
|
|
}
|
|
|
|
function setBlockWorld(x, y, z, id) {
|
|
if (y < 0 || y >= WORLD_HEIGHT) return;
|
|
const cx = floorDiv(x, CHUNK_SIZE);
|
|
const cz = floorDiv(z, CHUNK_SIZE);
|
|
const data = ensureChunkData(cx, cz);
|
|
const lx = mod(x, CHUNK_SIZE);
|
|
const lz = mod(z, CHUNK_SIZE);
|
|
setLocal(data, lx, y, lz, id);
|
|
|
|
for (let ox = -1; ox <= 1; ox += 1) {
|
|
for (let oz = -1; oz <= 1; oz += 1) {
|
|
const key = chunkKey(cx + ox, cz + oz);
|
|
if (chunkMeshes.has(key)) {
|
|
rebuildChunk(cx + ox, cz + oz);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function getTileUvBounds(tileIndex) {
|
|
const tx = tileIndex % ATLAS_COLS;
|
|
const ty = Math.floor(tileIndex / ATLAS_COLS);
|
|
const eps = 0.001;
|
|
const u0 = tx / ATLAS_COLS + eps;
|
|
const v0 = 1 - (ty + 1) / ATLAS_ROWS + eps;
|
|
const u1 = (tx + 1) / ATLAS_COLS - eps;
|
|
const v1 = 1 - ty / ATLAS_ROWS - eps;
|
|
return [u0, v0, u1, v1];
|
|
}
|
|
|
|
function pushFace(arrays, x, y, z, face, tile, tint, variation) {
|
|
const [u0, v0, u1, v1] = getTileUvBounds(tile);
|
|
const quadUvs = [
|
|
[u0, v1],
|
|
[u0, v0],
|
|
[u1, v1],
|
|
[u1, v0],
|
|
];
|
|
const order = [0, 1, 2, 2, 1, 3];
|
|
const brightness = face.shade * variation;
|
|
|
|
for (const index of order) {
|
|
const corner = face.corners[index];
|
|
arrays.positions.push(x + corner[0], y + corner[1], z + corner[2]);
|
|
arrays.normals.push(face.dir[0], face.dir[1], face.dir[2]);
|
|
arrays.uvs.push(quadUvs[index][0], quadUvs[index][1]);
|
|
arrays.colors.push(
|
|
clamp(tint[0] * brightness, 0, 1.2),
|
|
clamp(tint[1] * brightness, 0, 1.2),
|
|
clamp(tint[2] * brightness, 0, 1.2)
|
|
);
|
|
}
|
|
}
|
|
|
|
function rebuildChunk(cx, cz) {
|
|
const key = chunkKey(cx, cz);
|
|
const data = ensureChunkData(cx, cz);
|
|
const arrays = {
|
|
positions: [],
|
|
normals: [],
|
|
uvs: [],
|
|
colors: [],
|
|
};
|
|
|
|
for (let y = 0; y < WORLD_HEIGHT; y += 1) {
|
|
for (let lz = 0; lz < CHUNK_SIZE; lz += 1) {
|
|
for (let lx = 0; lx < CHUNK_SIZE; lx += 1) {
|
|
const blockId = getLocal(data, lx, y, lz);
|
|
if (blockId === BLOCK.AIR) continue;
|
|
|
|
const blockInfo = BLOCK_INFO[blockId];
|
|
const wx = cx * CHUNK_SIZE + lx;
|
|
const wz = cz * CHUNK_SIZE + lz;
|
|
const tint = blockInfo.tint;
|
|
const heightBoost = 0.97 + Math.max(0, y - 14) * 0.004;
|
|
const variation = clamp(heightBoost + perlin.noise3D(wx * 0.18, y * 0.18, wz * 0.18) * 0.08, 0.72, 1.18);
|
|
|
|
for (let faceIndex = 0; faceIndex < FACE_DEFS.length; faceIndex += 1) {
|
|
const face = FACE_DEFS[faceIndex];
|
|
const neighbor = getBlock(wx + face.dir[0], y + face.dir[1], wz + face.dir[2]);
|
|
if (neighbor !== BLOCK.AIR) continue;
|
|
pushFace(arrays, lx, y, lz, face, blockInfo.faces[faceIndex], tint, variation);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const oldMesh = chunkMeshes.get(key);
|
|
if (arrays.positions.length === 0) {
|
|
if (oldMesh) {
|
|
scene.remove(oldMesh);
|
|
oldMesh.geometry.dispose();
|
|
chunkMeshes.delete(key);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const geometry = new THREE.BufferGeometry();
|
|
geometry.setAttribute("position", new THREE.Float32BufferAttribute(arrays.positions, 3));
|
|
geometry.setAttribute("normal", new THREE.Float32BufferAttribute(arrays.normals, 3));
|
|
geometry.setAttribute("uv", new THREE.Float32BufferAttribute(arrays.uvs, 2));
|
|
geometry.setAttribute("color", new THREE.Float32BufferAttribute(arrays.colors, 3));
|
|
geometry.computeBoundingSphere();
|
|
|
|
let mesh = oldMesh;
|
|
if (!mesh) {
|
|
mesh = new THREE.Mesh(geometry, blockMaterial);
|
|
mesh.position.set(cx * CHUNK_SIZE, 0, cz * CHUNK_SIZE);
|
|
scene.add(mesh);
|
|
chunkMeshes.set(key, mesh);
|
|
} else {
|
|
oldMesh.geometry.dispose();
|
|
mesh.geometry = geometry;
|
|
}
|
|
}
|
|
|
|
function queueChunkRebuild(cx, cz, priority = false) {
|
|
const key = chunkKey(cx, cz);
|
|
if (queuedChunks.has(key)) return;
|
|
queuedChunks.add(key);
|
|
const task = { cx, cz, key };
|
|
if (priority) rebuildQueue.unshift(task);
|
|
else rebuildQueue.push(task);
|
|
}
|
|
|
|
function processChunkQueue(limit = 2) {
|
|
for (let i = 0; i < limit && rebuildQueue.length > 0; i += 1) {
|
|
const task = rebuildQueue.shift();
|
|
queuedChunks.delete(task.key);
|
|
rebuildChunk(task.cx, task.cz);
|
|
}
|
|
}
|
|
|
|
let currentChunkX = Infinity;
|
|
let currentChunkZ = Infinity;
|
|
|
|
function updateVisibleChunks(force = false) {
|
|
const pcx = floorDiv(player.position.x, CHUNK_SIZE);
|
|
const pcz = floorDiv(player.position.z, CHUNK_SIZE);
|
|
if (!force && pcx === currentChunkX && pcz === currentChunkZ) return;
|
|
|
|
currentChunkX = pcx;
|
|
currentChunkZ = pcz;
|
|
|
|
for (let dz = -LOAD_RADIUS - 1; dz <= LOAD_RADIUS + 1; dz += 1) {
|
|
for (let dx = -LOAD_RADIUS - 1; dx <= LOAD_RADIUS + 1; dx += 1) {
|
|
ensureChunkData(pcx + dx, pcz + dz);
|
|
}
|
|
}
|
|
|
|
const neededMeshes = new Set();
|
|
const candidates = [];
|
|
for (let dz = -LOAD_RADIUS; dz <= LOAD_RADIUS; dz += 1) {
|
|
for (let dx = -LOAD_RADIUS; dx <= LOAD_RADIUS; dx += 1) {
|
|
const cx = pcx + dx;
|
|
const cz = pcz + dz;
|
|
const key = chunkKey(cx, cz);
|
|
neededMeshes.add(key);
|
|
candidates.push({ cx, cz, dist2: dx * dx + dz * dz });
|
|
}
|
|
}
|
|
|
|
candidates.sort((a, b) => a.dist2 - b.dist2);
|
|
candidates.forEach(({ cx, cz }) => {
|
|
if (!chunkMeshes.has(chunkKey(cx, cz))) {
|
|
queueChunkRebuild(cx, cz);
|
|
}
|
|
});
|
|
|
|
for (const [key, mesh] of chunkMeshes) {
|
|
const [cxText, czText] = key.split(",");
|
|
const cx = Number(cxText);
|
|
const cz = Number(czText);
|
|
if (Math.abs(cx - pcx) > LOAD_RADIUS || Math.abs(cz - pcz) > LOAD_RADIUS) {
|
|
scene.remove(mesh);
|
|
mesh.geometry.dispose();
|
|
chunkMeshes.delete(key);
|
|
} else {
|
|
neededMeshes.add(key);
|
|
}
|
|
}
|
|
}
|
|
|
|
function getPlayerBounds() {
|
|
return {
|
|
minX: player.position.x - PLAYER_RADIUS,
|
|
maxX: player.position.x + PLAYER_RADIUS,
|
|
minY: player.position.y,
|
|
maxY: player.position.y + PLAYER_HEIGHT,
|
|
minZ: player.position.z - PLAYER_RADIUS,
|
|
maxZ: player.position.z + PLAYER_RADIUS,
|
|
};
|
|
}
|
|
|
|
function intersectsBlock(bounds, x, y, z) {
|
|
return (
|
|
bounds.maxX > x &&
|
|
bounds.minX < x + 1 &&
|
|
bounds.maxY > y &&
|
|
bounds.minY < y + 1 &&
|
|
bounds.maxZ > z &&
|
|
bounds.minZ < z + 1
|
|
);
|
|
}
|
|
|
|
function resolveAxis(axis, velocityKey) {
|
|
let bounds = getPlayerBounds();
|
|
const minX = Math.floor(bounds.minX) - 1;
|
|
const maxX = Math.floor(bounds.maxX) + 1;
|
|
const minY = Math.floor(bounds.minY) - 1;
|
|
const maxY = Math.floor(bounds.maxY) + 1;
|
|
const minZ = Math.floor(bounds.minZ) - 1;
|
|
const maxZ = Math.floor(bounds.maxZ) + 1;
|
|
|
|
for (let x = minX; x <= maxX; x += 1) {
|
|
for (let y = minY; y <= maxY; y += 1) {
|
|
for (let z = minZ; z <= maxZ; z += 1) {
|
|
if (!isSolid(getBlock(x, y, z))) continue;
|
|
if (!intersectsBlock(bounds, x, y, z)) continue;
|
|
|
|
if (axis === "x") {
|
|
if (player.velocity[velocityKey] > 0) player.position.x = x - PLAYER_RADIUS;
|
|
else if (player.velocity[velocityKey] < 0) player.position.x = x + 1 + PLAYER_RADIUS;
|
|
player.velocity[velocityKey] = 0;
|
|
} else if (axis === "y") {
|
|
if (player.velocity[velocityKey] > 0) {
|
|
player.position.y = y - PLAYER_HEIGHT;
|
|
} else if (player.velocity[velocityKey] <= 0) {
|
|
player.position.y = y + 1;
|
|
player.onGround = true;
|
|
}
|
|
player.velocity[velocityKey] = 0;
|
|
} else if (axis === "z") {
|
|
if (player.velocity[velocityKey] > 0) player.position.z = z - PLAYER_RADIUS;
|
|
else if (player.velocity[velocityKey] < 0) player.position.z = z + 1 + PLAYER_RADIUS;
|
|
player.velocity[velocityKey] = 0;
|
|
}
|
|
bounds = getPlayerBounds();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function respawnPlayer() {
|
|
player.position.set(SPAWN.x, SPAWN.y, SPAWN.z);
|
|
player.velocity.set(0, 0, 0);
|
|
player.yaw = 0;
|
|
player.pitch = 0;
|
|
}
|
|
|
|
function updatePlayer(dt) {
|
|
const forward = new THREE.Vector3(Math.sin(player.yaw), 0, -Math.cos(player.yaw));
|
|
const right = new THREE.Vector3(Math.cos(player.yaw), 0, Math.sin(player.yaw));
|
|
const input = new THREE.Vector3();
|
|
const sprint = keys.has("ShiftLeft") || keys.has("ShiftRight");
|
|
const targetSpeed = sprint ? 8.8 : 5.8;
|
|
|
|
if (keys.has("KeyW")) input.add(forward);
|
|
if (keys.has("KeyS")) input.sub(forward);
|
|
if (keys.has("KeyA")) input.sub(right);
|
|
if (keys.has("KeyD")) input.add(right);
|
|
if (input.lengthSq() > 0) input.normalize();
|
|
|
|
const targetVX = input.x * targetSpeed;
|
|
const targetVZ = input.z * targetSpeed;
|
|
const damping = player.onGround ? 18 : 5;
|
|
player.velocity.x = THREE.MathUtils.damp(player.velocity.x, targetVX, damping, dt);
|
|
player.velocity.z = THREE.MathUtils.damp(player.velocity.z, targetVZ, damping, dt);
|
|
|
|
if (player.onGround && keys.has("Space")) {
|
|
player.velocity.y = 9.2;
|
|
player.onGround = false;
|
|
}
|
|
|
|
player.velocity.y -= 24 * dt;
|
|
player.onGround = false;
|
|
|
|
player.position.x += player.velocity.x * dt;
|
|
resolveAxis("x", "x");
|
|
|
|
player.position.z += player.velocity.z * dt;
|
|
resolveAxis("z", "z");
|
|
|
|
player.position.y += player.velocity.y * dt;
|
|
resolveAxis("y", "y");
|
|
|
|
if (player.position.y < -30) {
|
|
respawnPlayer();
|
|
}
|
|
|
|
camera.position.set(player.position.x, player.position.y + EYE_HEIGHT, player.position.z);
|
|
camera.rotation.set(player.pitch, player.yaw, 0);
|
|
}
|
|
|
|
function updateSelection() {
|
|
raycaster.setFromCamera(new THREE.Vector2(0, 0), camera);
|
|
const intersections = raycaster.intersectObjects(Array.from(chunkMeshes.values()), false);
|
|
if (!intersections.length) {
|
|
currentTarget = null;
|
|
selectionBox.visible = false;
|
|
return;
|
|
}
|
|
|
|
const hit = intersections[0];
|
|
const normal = hit.face.normal.clone().transformDirection(hit.object.matrixWorld);
|
|
normal.set(Math.round(normal.x), Math.round(normal.y), Math.round(normal.z));
|
|
const insidePoint = hit.point.clone().addScaledVector(normal, -0.01);
|
|
const bx = Math.floor(insidePoint.x);
|
|
const by = Math.floor(insidePoint.y);
|
|
const bz = Math.floor(insidePoint.z);
|
|
|
|
if (!isSolid(getBlock(bx, by, bz))) {
|
|
currentTarget = null;
|
|
selectionBox.visible = false;
|
|
return;
|
|
}
|
|
|
|
currentTarget = { x: bx, y: by, z: bz, normal };
|
|
selectionBox.visible = true;
|
|
selectionBox.position.set(bx + 0.5, by + 0.5, bz + 0.5);
|
|
}
|
|
|
|
function blockWouldCollideWithPlayer(x, y, z) {
|
|
const bounds = getPlayerBounds();
|
|
return intersectsBlock(bounds, x, y, z);
|
|
}
|
|
|
|
function breakBlock() {
|
|
if (!currentTarget) return;
|
|
setBlockWorld(currentTarget.x, currentTarget.y, currentTarget.z, BLOCK.AIR);
|
|
updateSelection();
|
|
}
|
|
|
|
function placeBlock() {
|
|
if (!currentTarget) return;
|
|
const x = currentTarget.x + currentTarget.normal.x;
|
|
const y = currentTarget.y + currentTarget.normal.y;
|
|
const z = currentTarget.z + currentTarget.normal.z;
|
|
if (y < 0 || y >= WORLD_HEIGHT) return;
|
|
if (isSolid(getBlock(x, y, z))) return;
|
|
if (blockWouldCollideWithPlayer(x, y, z)) return;
|
|
setBlockWorld(x, y, z, palette[selectedIndex]);
|
|
updateSelection();
|
|
}
|
|
|
|
function updateHud() {
|
|
let nearest = null;
|
|
let nearestDistance = Infinity;
|
|
for (const poi of POIS) {
|
|
const d = Math.hypot(player.position.x - poi.x, player.position.z - poi.z);
|
|
if (d < nearestDistance) {
|
|
nearestDistance = d;
|
|
nearest = poi;
|
|
}
|
|
}
|
|
|
|
const coords = `${Math.floor(player.position.x)} / ${Math.floor(player.position.y)} / ${Math.floor(player.position.z)}`;
|
|
statusValue.innerHTML =
|
|
`坐标:${coords}<br>` +
|
|
`选中方块:${getBlockName(palette[selectedIndex])}<br>` +
|
|
`已载入区块:${chunkMeshes.size},待构建:${rebuildQueue.length}`;
|
|
|
|
if (nearest && nearestDistance < 72) {
|
|
poiLabel.textContent = `最近地标:${nearest.name} · ${nearestDistance.toFixed(0)} 格`;
|
|
} else {
|
|
poiLabel.textContent = "远方仍有更多浮空圣殿与遗迹在生成。";
|
|
}
|
|
}
|
|
|
|
function onResize() {
|
|
camera.aspect = window.innerWidth / window.innerHeight;
|
|
camera.updateProjectionMatrix();
|
|
renderer.setSize(window.innerWidth, window.innerHeight, false);
|
|
}
|
|
|
|
window.addEventListener("resize", onResize);
|
|
|
|
document.addEventListener("pointerlockchange", () => {
|
|
lockHint.classList.toggle("hidden", document.pointerLockElement === canvas);
|
|
});
|
|
|
|
window.addEventListener("mousemove", (event) => {
|
|
if (document.pointerLockElement !== canvas) return;
|
|
player.yaw -= event.movementX * 0.0022;
|
|
player.pitch -= event.movementY * 0.0022;
|
|
player.pitch = clamp(player.pitch, -Math.PI / 2 + 0.01, Math.PI / 2 - 0.01);
|
|
});
|
|
|
|
window.addEventListener("keydown", (event) => {
|
|
keys.add(event.code);
|
|
if (event.code === "KeyR") {
|
|
respawnPlayer();
|
|
}
|
|
if (event.code.startsWith("Digit")) {
|
|
const index = Number(event.code.slice(5)) - 1;
|
|
if (index >= 0 && index < palette.length) {
|
|
selectedIndex = index;
|
|
renderHotbar();
|
|
}
|
|
}
|
|
});
|
|
|
|
window.addEventListener("keyup", (event) => {
|
|
keys.delete(event.code);
|
|
});
|
|
|
|
window.addEventListener("blur", () => {
|
|
keys.clear();
|
|
});
|
|
|
|
window.addEventListener("wheel", (event) => {
|
|
event.preventDefault();
|
|
selectedIndex = mod(selectedIndex + Math.sign(event.deltaY || 1), palette.length);
|
|
renderHotbar();
|
|
}, { passive: false });
|
|
|
|
window.addEventListener("contextmenu", (event) => {
|
|
event.preventDefault();
|
|
});
|
|
|
|
window.addEventListener("mousedown", (event) => {
|
|
if (document.pointerLockElement !== canvas) {
|
|
canvas.requestPointerLock();
|
|
return;
|
|
}
|
|
if (event.button === 0) breakBlock();
|
|
if (event.button === 2) placeBlock();
|
|
});
|
|
|
|
lockHint.addEventListener("click", () => {
|
|
canvas.requestPointerLock();
|
|
});
|
|
|
|
updateVisibleChunks(true);
|
|
for (let i = 0; i < 10; i += 1) {
|
|
processChunkQueue(1);
|
|
}
|
|
respawnPlayer();
|
|
updateSelection();
|
|
updateHud();
|
|
|
|
let lastTime = performance.now();
|
|
|
|
function animate(now) {
|
|
const dt = Math.min(0.05, (now - lastTime) / 1000 || 0.016);
|
|
lastTime = now;
|
|
|
|
updatePlayer(dt);
|
|
updateVisibleChunks();
|
|
processChunkQueue(2);
|
|
updateSelection();
|
|
updateHud();
|
|
|
|
stars.rotation.y += dt * 0.01;
|
|
renderer.render(scene, camera);
|
|
}
|
|
|
|
renderer.setAnimationLoop(animate);
|
|
</script>
|
|
</body>
|
|
</html>
|