New model gpt-5-6-terra (Codex CLI, ultra): mythos-craft, pelican-cycling, skeleton-watch (one-shot, unmodified). gpt-5-6-sol-ultra gains alternate one-shot variants for existing cases: mythos-craft 'Block Era', two more pelican SVGs, and a cleanly completed skeleton-watch 'Calibre 01' kept alongside the interruption-recovered AURELIUS S-01. Extra variants attributed to Nagi-ovo. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1835 lines
65 KiB
HTML
1835 lines
65 KiB
HTML
<!doctype html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
|
|
<title>MYTHOS:方块纪元</title>
|
|
<style>
|
|
:root {
|
|
--ink: #eef2dc;
|
|
--muted: #aeb89e;
|
|
--gold: #d8b86c;
|
|
--cyan: #78e0d4;
|
|
--panel: rgba(10, 17, 24, .78);
|
|
--line: rgba(216, 184, 108, .32);
|
|
}
|
|
|
|
* { box-sizing: border-box; }
|
|
|
|
html, body {
|
|
width: 100%;
|
|
height: 100%;
|
|
margin: 0;
|
|
overflow: hidden;
|
|
background: #09131c;
|
|
color: var(--ink);
|
|
font-family: Inter, ui-sans-serif, system-ui, -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
|
|
user-select: none;
|
|
}
|
|
|
|
canvas { display: block; }
|
|
|
|
#game {
|
|
position: fixed;
|
|
inset: 0;
|
|
background: radial-gradient(circle at 50% 35%, #233f4a 0%, #101c29 42%, #071018 100%);
|
|
}
|
|
|
|
#vignette {
|
|
pointer-events: none;
|
|
position: fixed;
|
|
inset: 0;
|
|
z-index: 5;
|
|
background:
|
|
linear-gradient(to bottom, rgba(3, 8, 14, .18), transparent 20%, transparent 72%, rgba(3, 7, 11, .42)),
|
|
radial-gradient(ellipse at center, transparent 48%, rgba(2, 7, 11, .46) 100%);
|
|
}
|
|
|
|
#hud {
|
|
position: fixed;
|
|
inset: 0;
|
|
z-index: 10;
|
|
pointer-events: none;
|
|
opacity: 0;
|
|
transition: opacity .25s ease;
|
|
}
|
|
|
|
body.playing #hud { opacity: 1; }
|
|
|
|
.hud-card {
|
|
background: linear-gradient(145deg, rgba(13, 22, 29, .84), rgba(8, 14, 21, .68));
|
|
border: 1px solid var(--line);
|
|
box-shadow: 0 14px 34px rgba(0, 0, 0, .22), inset 0 1px rgba(255, 255, 255, .04);
|
|
backdrop-filter: blur(12px);
|
|
}
|
|
|
|
#status {
|
|
position: absolute;
|
|
top: 24px;
|
|
left: 24px;
|
|
min-width: 236px;
|
|
padding: 13px 15px 12px;
|
|
border-radius: 3px 13px 3px 13px;
|
|
}
|
|
|
|
.eyebrow {
|
|
color: var(--gold);
|
|
font-size: 10px;
|
|
letter-spacing: .22em;
|
|
font-weight: 800;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
#biome {
|
|
margin-top: 6px;
|
|
font-size: 16px;
|
|
font-weight: 720;
|
|
letter-spacing: .05em;
|
|
}
|
|
|
|
#coords {
|
|
margin-top: 4px;
|
|
color: var(--muted);
|
|
font: 11px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
}
|
|
|
|
#lore {
|
|
position: absolute;
|
|
top: 24px;
|
|
right: 24px;
|
|
width: min(318px, calc(100vw - 48px));
|
|
padding: 15px 17px 16px;
|
|
border-radius: 13px 3px 13px 3px;
|
|
}
|
|
|
|
#lore-title {
|
|
margin-top: 6px;
|
|
color: #f2e7c7;
|
|
font-family: Georgia, "Noto Serif SC", serif;
|
|
font-size: 19px;
|
|
letter-spacing: .06em;
|
|
}
|
|
|
|
#lore-copy {
|
|
margin-top: 7px;
|
|
color: #b9c4b3;
|
|
font-size: 12px;
|
|
line-height: 1.7;
|
|
}
|
|
|
|
#compass {
|
|
position: absolute;
|
|
top: 24px;
|
|
left: 50%;
|
|
width: 270px;
|
|
transform: translateX(-50%);
|
|
text-align: center;
|
|
color: rgba(238, 242, 220, .78);
|
|
font: 11px/1.35 ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
letter-spacing: .12em;
|
|
text-shadow: 0 2px 6px #000;
|
|
}
|
|
|
|
#compass::before {
|
|
content: "";
|
|
display: block;
|
|
width: 1px;
|
|
height: 9px;
|
|
margin: 0 auto 4px;
|
|
background: var(--gold);
|
|
box-shadow: 0 0 10px var(--gold);
|
|
}
|
|
|
|
#crosshair {
|
|
position: absolute;
|
|
top: 50%;
|
|
left: 50%;
|
|
width: 18px;
|
|
height: 18px;
|
|
transform: translate(-50%, -50%);
|
|
filter: drop-shadow(0 1px 2px #000);
|
|
}
|
|
|
|
#crosshair::before,
|
|
#crosshair::after {
|
|
content: "";
|
|
position: absolute;
|
|
background: rgba(242, 239, 213, .92);
|
|
border-radius: 1px;
|
|
}
|
|
|
|
#crosshair::before { left: 8px; top: 2px; width: 2px; height: 14px; }
|
|
#crosshair::after { left: 2px; top: 8px; width: 14px; height: 2px; }
|
|
|
|
#target-label {
|
|
position: absolute;
|
|
left: 50%;
|
|
top: calc(50% + 28px);
|
|
transform: translateX(-50%);
|
|
min-width: 210px;
|
|
color: rgba(241, 242, 221, .84);
|
|
font-size: 10px;
|
|
letter-spacing: .08em;
|
|
text-align: center;
|
|
text-shadow: 0 2px 5px #000;
|
|
}
|
|
|
|
#hotbar-wrap {
|
|
position: absolute;
|
|
left: 50%;
|
|
bottom: 24px;
|
|
transform: translateX(-50%);
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
|
|
#selected-name {
|
|
color: #f2e8c7;
|
|
font-size: 11px;
|
|
font-weight: 700;
|
|
letter-spacing: .12em;
|
|
text-shadow: 0 2px 6px #000;
|
|
}
|
|
|
|
#hotbar {
|
|
display: flex;
|
|
gap: 5px;
|
|
padding: 6px;
|
|
border-radius: 10px 3px 10px 3px;
|
|
background: rgba(6, 12, 17, .72);
|
|
border: 1px solid rgba(235, 210, 139, .28);
|
|
box-shadow: 0 12px 28px rgba(0, 0, 0, .36);
|
|
backdrop-filter: blur(10px);
|
|
}
|
|
|
|
.slot {
|
|
position: relative;
|
|
width: 48px;
|
|
height: 48px;
|
|
padding: 5px;
|
|
border: 1px solid rgba(255, 255, 255, .12);
|
|
background: rgba(255, 255, 255, .035);
|
|
transition: transform .16s ease, border-color .16s ease, background .16s ease;
|
|
}
|
|
|
|
.slot.selected {
|
|
transform: translateY(-4px);
|
|
border-color: var(--gold);
|
|
background: rgba(216, 184, 108, .15);
|
|
box-shadow: 0 0 0 1px rgba(216, 184, 108, .2), 0 0 20px rgba(216, 184, 108, .16);
|
|
}
|
|
|
|
.slot-swatch {
|
|
width: 100%;
|
|
height: 100%;
|
|
image-rendering: pixelated;
|
|
background-size: cover;
|
|
box-shadow: inset -5px -5px rgba(0, 0, 0, .18), inset 3px 3px rgba(255, 255, 255, .08);
|
|
}
|
|
|
|
.slot-key {
|
|
position: absolute;
|
|
top: 2px;
|
|
left: 4px;
|
|
color: rgba(255, 255, 255, .86);
|
|
font: 9px ui-monospace, monospace;
|
|
text-shadow: 0 1px 2px #000;
|
|
}
|
|
|
|
#toast {
|
|
position: absolute;
|
|
left: 50%;
|
|
bottom: 104px;
|
|
max-width: min(440px, calc(100vw - 40px));
|
|
padding: 10px 15px;
|
|
transform: translate(-50%, 12px);
|
|
border: 1px solid rgba(120, 224, 212, .3);
|
|
border-radius: 2px 10px 2px 10px;
|
|
background: rgba(7, 18, 23, .84);
|
|
color: #d9f1e9;
|
|
font-size: 12px;
|
|
letter-spacing: .04em;
|
|
opacity: 0;
|
|
transition: opacity .25s ease, transform .25s ease;
|
|
}
|
|
|
|
#toast.show { opacity: 1; transform: translate(-50%, 0); }
|
|
|
|
#start-screen {
|
|
position: fixed;
|
|
inset: 0;
|
|
z-index: 30;
|
|
display: grid;
|
|
place-items: center;
|
|
padding: 24px;
|
|
background:
|
|
linear-gradient(90deg, rgba(5, 11, 16, .9), rgba(5, 11, 16, .54) 52%, rgba(5, 11, 16, .82)),
|
|
radial-gradient(circle at 65% 30%, rgba(54, 115, 116, .3), transparent 34%),
|
|
radial-gradient(circle at 35% 75%, rgba(111, 73, 128, .22), transparent 38%);
|
|
transition: opacity .35s ease, visibility .35s ease;
|
|
}
|
|
|
|
#start-screen.hidden { opacity: 0; visibility: hidden; }
|
|
|
|
#start-screen::before {
|
|
content: "";
|
|
position: absolute;
|
|
inset: 18px;
|
|
border: 1px solid rgba(216, 184, 108, .18);
|
|
pointer-events: none;
|
|
}
|
|
|
|
.start-panel {
|
|
position: relative;
|
|
width: min(900px, 100%);
|
|
display: grid;
|
|
grid-template-columns: 1.2fr .8fr;
|
|
gap: clamp(30px, 7vw, 100px);
|
|
align-items: end;
|
|
}
|
|
|
|
.sigil {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
color: var(--gold);
|
|
font-size: 10px;
|
|
font-weight: 800;
|
|
letter-spacing: .3em;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.sigil::before {
|
|
content: "";
|
|
width: 28px;
|
|
height: 1px;
|
|
background: currentColor;
|
|
box-shadow: 0 0 8px currentColor;
|
|
}
|
|
|
|
h1 {
|
|
margin: 18px 0 12px;
|
|
font-family: Georgia, "Noto Serif SC", serif;
|
|
font-size: clamp(52px, 8vw, 104px);
|
|
font-weight: 500;
|
|
line-height: .86;
|
|
letter-spacing: -.05em;
|
|
text-shadow: 0 8px 40px rgba(0, 0, 0, .48);
|
|
}
|
|
|
|
h1 span {
|
|
display: block;
|
|
margin-top: 18px;
|
|
color: #c8d8cb;
|
|
font-family: ui-sans-serif, system-ui, sans-serif;
|
|
font-size: .19em;
|
|
font-weight: 500;
|
|
letter-spacing: .42em;
|
|
}
|
|
|
|
.intro {
|
|
max-width: 570px;
|
|
margin: 22px 0 0;
|
|
color: #aebcae;
|
|
font-size: 14px;
|
|
line-height: 1.9;
|
|
}
|
|
|
|
.side-panel {
|
|
padding: 22px;
|
|
border-top: 1px solid rgba(216, 184, 108, .42);
|
|
border-bottom: 1px solid rgba(216, 184, 108, .2);
|
|
background: linear-gradient(90deg, rgba(12, 22, 27, .76), rgba(12, 22, 27, .2));
|
|
}
|
|
|
|
.control-grid {
|
|
display: grid;
|
|
grid-template-columns: auto 1fr;
|
|
gap: 9px 15px;
|
|
color: #a9b5a8;
|
|
font-size: 11px;
|
|
line-height: 1.4;
|
|
}
|
|
|
|
.control-grid b {
|
|
color: #e9e7d1;
|
|
font: 700 10px ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
letter-spacing: .06em;
|
|
}
|
|
|
|
#enter {
|
|
width: 100%;
|
|
margin-top: 22px;
|
|
padding: 14px 18px;
|
|
border: 1px solid #d5bd7e;
|
|
border-radius: 0;
|
|
background: linear-gradient(100deg, #d9bd75, #b99c5c);
|
|
color: #10171a;
|
|
font: 800 11px ui-sans-serif, system-ui, sans-serif;
|
|
letter-spacing: .24em;
|
|
cursor: pointer;
|
|
box-shadow: 0 10px 28px rgba(192, 156, 82, .16);
|
|
transition: transform .18s ease, filter .18s ease;
|
|
}
|
|
|
|
#enter:hover { transform: translateY(-2px); filter: brightness(1.08); }
|
|
|
|
#load-status {
|
|
margin-top: 10px;
|
|
color: #83938a;
|
|
font: 9px ui-monospace, monospace;
|
|
letter-spacing: .08em;
|
|
text-align: center;
|
|
}
|
|
|
|
#fatal {
|
|
display: none;
|
|
position: fixed;
|
|
inset: 0;
|
|
z-index: 100;
|
|
place-items: center;
|
|
padding: 30px;
|
|
background: #081017;
|
|
color: #e6dfc7;
|
|
text-align: center;
|
|
line-height: 1.8;
|
|
}
|
|
|
|
@media (max-width: 720px) {
|
|
.start-panel { grid-template-columns: 1fr; gap: 26px; }
|
|
.intro { font-size: 12px; }
|
|
#lore { top: auto; bottom: 102px; right: 12px; width: 220px; }
|
|
#status { top: 12px; left: 12px; min-width: 190px; }
|
|
#compass { top: 95px; }
|
|
.slot { width: 40px; height: 40px; }
|
|
#hotbar-wrap { bottom: 14px; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="game"></div>
|
|
<div id="vignette"></div>
|
|
|
|
<div id="hud">
|
|
<div id="status" class="hud-card">
|
|
<div class="eyebrow">Realm telemetry</div>
|
|
<div id="biome">永暮高地</div>
|
|
<div id="coords">X 0 · Y 0 · Z 0</div>
|
|
</div>
|
|
|
|
<div id="compass">N · 曙光王城 · 0M</div>
|
|
|
|
<div id="lore" class="hud-card">
|
|
<div class="eyebrow" id="lore-index">Myth archive · 0/5</div>
|
|
<div id="lore-title">无名荒野</div>
|
|
<div id="lore-copy">远古地脉仍在方块之间低语。循着天际的塔影,寻找被遗忘的神话遗迹。</div>
|
|
</div>
|
|
|
|
<div id="crosshair"></div>
|
|
<div id="target-label">寻找方块</div>
|
|
|
|
<div id="hotbar-wrap">
|
|
<div id="selected-name">草方块</div>
|
|
<div id="hotbar"></div>
|
|
</div>
|
|
|
|
<div id="toast">已发现新的神话遗迹</div>
|
|
</div>
|
|
|
|
<div id="start-screen">
|
|
<div class="start-panel">
|
|
<section>
|
|
<div class="sigil">A procedural voxel myth</div>
|
|
<h1>MYTHOS<span>方块纪元</span></h1>
|
|
<p class="intro">
|
|
一座由柏林噪声与失落神话共同生成的无尽世界。越过王城的黑曜石城门,攀上浮空圣殿,
|
|
唤醒世界树的微光,并在龙骨荒原寻找通往月海的门。
|
|
</p>
|
|
</section>
|
|
|
|
<aside class="side-panel">
|
|
<div class="control-grid">
|
|
<b>W A S D</b><span>移动</span>
|
|
<b>鼠标</b><span>环视</span>
|
|
<b>空格</b><span>跳跃</span>
|
|
<b>SHIFT</b><span>疾跑</span>
|
|
<b>左键</b><span>破坏方块</span>
|
|
<b>右键</b><span>放置方块</span>
|
|
<b>1—6 / 滚轮</b><span>选择方块</span>
|
|
<b>ESC</b><span>暂停并释放鼠标</span>
|
|
</div>
|
|
<button id="enter" type="button">进入永暮世界</button>
|
|
<div id="load-status">正在编织地形 · 0%</div>
|
|
</aside>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="fatal">
|
|
<div>
|
|
<strong>无法载入 3D 世界</strong><br />
|
|
请确认网络可访问 cdn.jsdelivr.net,然后刷新页面。
|
|
</div>
|
|
</div>
|
|
|
|
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/build/three.min.js"></script>
|
|
<script>
|
|
(() => {
|
|
'use strict';
|
|
|
|
if (!window.THREE) {
|
|
document.getElementById('fatal').style.display = 'grid';
|
|
return;
|
|
}
|
|
|
|
const $ = (id) => document.getElementById(id);
|
|
const game = $('game');
|
|
const startScreen = $('start-screen');
|
|
const enterButton = $('enter');
|
|
const loadStatus = $('load-status');
|
|
const biomeEl = $('biome');
|
|
const coordsEl = $('coords');
|
|
const compassEl = $('compass');
|
|
const loreTitleEl = $('lore-title');
|
|
const loreCopyEl = $('lore-copy');
|
|
const loreIndexEl = $('lore-index');
|
|
const targetLabelEl = $('target-label');
|
|
const selectedNameEl = $('selected-name');
|
|
const hotbarEl = $('hotbar');
|
|
const toastEl = $('toast');
|
|
|
|
const scene = new THREE.Scene();
|
|
scene.fog = new THREE.FogExp2(0x102331, 0.0125);
|
|
|
|
const camera = new THREE.PerspectiveCamera(72, innerWidth / innerHeight, 0.05, 310);
|
|
camera.rotation.order = 'YXZ';
|
|
|
|
const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
|
|
renderer.setPixelRatio(Math.min(devicePixelRatio, 1.7));
|
|
renderer.setSize(innerWidth, innerHeight);
|
|
renderer.outputEncoding = THREE.sRGBEncoding;
|
|
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
|
renderer.toneMappingExposure = 1.04;
|
|
renderer.shadowMap.enabled = true;
|
|
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
|
game.appendChild(renderer.domElement);
|
|
|
|
const sky = new THREE.Mesh(
|
|
new THREE.SphereGeometry(220, 32, 16),
|
|
new THREE.ShaderMaterial({
|
|
side: THREE.BackSide,
|
|
depthWrite: false,
|
|
uniforms: { uTime: { value: 0 } },
|
|
vertexShader: `
|
|
varying vec3 vPos;
|
|
void main() {
|
|
vPos = position;
|
|
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
|
}
|
|
`,
|
|
fragmentShader: `
|
|
varying vec3 vPos;
|
|
uniform float uTime;
|
|
void main() {
|
|
float h = normalize(vPos).y * .5 + .5;
|
|
vec3 low = vec3(.055, .12, .16);
|
|
vec3 mid = vec3(.12, .25, .29);
|
|
vec3 high = vec3(.025, .045, .09);
|
|
vec3 col = mix(low, mid, smoothstep(.18, .46, h));
|
|
col = mix(col, high, smoothstep(.52, .92, h));
|
|
float band = sin(normalize(vPos).x * 8.0 + uTime * .035) * .5 + .5;
|
|
col += vec3(.025, .08, .07) * band * smoothstep(.52, .8, h) * (1.0 - smoothstep(.82, 1.0, h));
|
|
gl_FragColor = vec4(col, 1.0);
|
|
}
|
|
`
|
|
})
|
|
);
|
|
scene.add(sky);
|
|
|
|
const hemi = new THREE.HemisphereLight(0x99cad0, 0x182015, 1.22);
|
|
scene.add(hemi);
|
|
|
|
const sun = new THREE.DirectionalLight(0xffe5bb, 1.75);
|
|
sun.position.set(-55, 88, 36);
|
|
sun.castShadow = true;
|
|
sun.shadow.mapSize.set(2048, 2048);
|
|
sun.shadow.camera.left = -54;
|
|
sun.shadow.camera.right = 54;
|
|
sun.shadow.camera.top = 54;
|
|
sun.shadow.camera.bottom = -54;
|
|
sun.shadow.camera.near = 1;
|
|
sun.shadow.camera.far = 180;
|
|
sun.shadow.bias = -0.00025;
|
|
scene.add(sun);
|
|
scene.add(sun.target);
|
|
|
|
const celestial = new THREE.Group();
|
|
const moon = new THREE.Mesh(
|
|
new THREE.SphereGeometry(8, 24, 16),
|
|
new THREE.MeshBasicMaterial({ color: 0xd8ebe1, fog: false })
|
|
);
|
|
moon.position.set(72, 80, -145);
|
|
celestial.add(moon);
|
|
const moonHalo = new THREE.Mesh(
|
|
new THREE.RingGeometry(10.5, 14, 48),
|
|
new THREE.MeshBasicMaterial({ color: 0x8ec5c6, transparent: true, opacity: .12, side: THREE.DoubleSide, depthWrite: false, fog: false })
|
|
);
|
|
moonHalo.position.copy(moon.position);
|
|
moonHalo.lookAt(0, 0, 0);
|
|
celestial.add(moonHalo);
|
|
const secondMoon = new THREE.Mesh(
|
|
new THREE.SphereGeometry(3.2, 18, 12),
|
|
new THREE.MeshBasicMaterial({ color: 0xbc9ed1, fog: false })
|
|
);
|
|
secondMoon.position.set(98, 62, -140);
|
|
celestial.add(secondMoon);
|
|
scene.add(celestial);
|
|
|
|
const starGeometry = new THREE.BufferGeometry();
|
|
const starPositions = [];
|
|
let starSeed = 99173;
|
|
const starRand = () => {
|
|
starSeed |= 0;
|
|
starSeed = starSeed + 0x6D2B79F5 | 0;
|
|
let t = Math.imul(starSeed ^ starSeed >>> 15, 1 | starSeed);
|
|
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
|
|
return ((t ^ t >>> 14) >>> 0) / 4294967296;
|
|
};
|
|
for (let i = 0; i < 550; i++) {
|
|
const theta = starRand() * Math.PI * 2;
|
|
const phi = Math.acos(.08 + starRand() * .84);
|
|
const r = 198;
|
|
starPositions.push(Math.sin(phi) * Math.cos(theta) * r, Math.cos(phi) * r, Math.sin(phi) * Math.sin(theta) * r);
|
|
}
|
|
starGeometry.setAttribute('position', new THREE.Float32BufferAttribute(starPositions, 3));
|
|
const stars = new THREE.Points(starGeometry, new THREE.PointsMaterial({ color: 0xdde9dc, size: .48, transparent: true, opacity: .72, depthWrite: false, fog: false }));
|
|
celestial.add(stars);
|
|
|
|
const CHUNK = 16;
|
|
const VIEW_RADIUS = 2;
|
|
const SEA_LEVEL = 6;
|
|
const MAX_BUILD_Y = 48;
|
|
const REACH = 7;
|
|
|
|
const BLOCK = Object.freeze({
|
|
AIR: 0,
|
|
GRASS: 1,
|
|
DIRT: 2,
|
|
STONE: 3,
|
|
MARBLE: 4,
|
|
RUNE: 5,
|
|
CRYSTAL: 6,
|
|
WOOD: 7,
|
|
LEAVES: 8,
|
|
SAND: 9,
|
|
BONE: 10,
|
|
GILDED: 11
|
|
});
|
|
|
|
const BLOCK_INFO = {
|
|
[BLOCK.GRASS]: { name: '草方块', color: 0x668b45 },
|
|
[BLOCK.DIRT]: { name: '泥土', color: 0x785135 },
|
|
[BLOCK.STONE]: { name: '远古石', color: 0x687177 },
|
|
[BLOCK.MARBLE]: { name: '月白石', color: 0xd7d0b8 },
|
|
[BLOCK.RUNE]: { name: '符文石', color: 0x273c56 },
|
|
[BLOCK.CRYSTAL]: { name: '星辉晶体', color: 0x65ddd0 },
|
|
[BLOCK.WOOD]: { name: '世界树木', color: 0x735038 },
|
|
[BLOCK.LEAVES]: { name: '世界树叶', color: 0x456c55 },
|
|
[BLOCK.SAND]: { name: '月砂', color: 0xbda976 },
|
|
[BLOCK.BONE]: { name: '古龙骨', color: 0xd8cfad },
|
|
[BLOCK.GILDED]: { name: '日铸金', color: 0xc79e45 }
|
|
};
|
|
|
|
function mulberry32(seed) {
|
|
return function() {
|
|
let t = 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 PerlinNoise {
|
|
constructor(seed) {
|
|
const random = mulberry32(seed);
|
|
const p = Array.from({ length: 256 }, (_, i) => i);
|
|
for (let i = 255; i > 0; i--) {
|
|
const j = Math.floor(random() * (i + 1));
|
|
[p[i], p[j]] = [p[j], p[i]];
|
|
}
|
|
this.p = new Uint16Array(512);
|
|
for (let i = 0; i < 512; i++) this.p[i] = p[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) {
|
|
switch (hash & 7) {
|
|
case 0: return x + y;
|
|
case 1: return -x + y;
|
|
case 2: return x - y;
|
|
case 3: return -x - y;
|
|
case 4: return x;
|
|
case 5: return -x;
|
|
case 6: return y;
|
|
default: return -y;
|
|
}
|
|
}
|
|
|
|
noise(x, y) {
|
|
const xi = Math.floor(x) & 255;
|
|
const yi = Math.floor(y) & 255;
|
|
const xf = x - Math.floor(x);
|
|
const yf = y - Math.floor(y);
|
|
const u = this.fade(xf);
|
|
const v = this.fade(yf);
|
|
const aa = this.p[this.p[xi] + yi];
|
|
const ab = this.p[this.p[xi] + yi + 1];
|
|
const ba = this.p[this.p[xi + 1] + yi];
|
|
const bb = this.p[this.p[xi + 1] + yi + 1];
|
|
return this.lerp(
|
|
this.lerp(this.grad(aa, xf, yf), this.grad(ba, xf - 1, yf), u),
|
|
this.lerp(this.grad(ab, xf, yf - 1), this.grad(bb, xf - 1, yf - 1), u),
|
|
v
|
|
) * .72;
|
|
}
|
|
}
|
|
|
|
const terrainNoise = new PerlinNoise(0xA57E11);
|
|
const detailNoise = new PerlinNoise(0x51DECA);
|
|
const biomeNoise = new PerlinNoise(0xB10B1E);
|
|
|
|
function fbm(noise, x, z, octaves = 5) {
|
|
let value = 0;
|
|
let amplitude = .5;
|
|
let frequency = 1;
|
|
let total = 0;
|
|
for (let i = 0; i < octaves; i++) {
|
|
value += noise.noise(x * frequency, z * frequency) * amplitude;
|
|
total += amplitude;
|
|
amplitude *= .5;
|
|
frequency *= 2.02;
|
|
}
|
|
return value / total;
|
|
}
|
|
|
|
function hash2(x, z) {
|
|
let h = Math.imul(x | 0, 374761393) + Math.imul(z | 0, 668265263);
|
|
h = Math.imul(h ^ h >>> 13, 1274126177);
|
|
return ((h ^ h >>> 16) >>> 0) / 4294967295;
|
|
}
|
|
|
|
function hash3(x, y, z) {
|
|
return hash2(x + Math.imul(y, 193), z - Math.imul(y, 389));
|
|
}
|
|
|
|
const heightCache = new Map();
|
|
function heightAt(x, z) {
|
|
const cacheKey = x + ',' + z;
|
|
const cached = heightCache.get(cacheKey);
|
|
if (cached !== undefined) return cached;
|
|
|
|
const continent = fbm(terrainNoise, x * .009, z * .009, 5);
|
|
const ridges = 1 - Math.abs(fbm(detailNoise, x * .019, z * .019, 4));
|
|
const small = fbm(detailNoise, x * .058, z * .058, 3);
|
|
let h = Math.floor(9 + continent * 10 + Math.pow(ridges, 4) * 6 + small * 2.4);
|
|
h = Math.max(3, Math.min(27, h));
|
|
|
|
const ax = Math.abs(x);
|
|
const az = Math.abs(z);
|
|
const insideCitadel = ax <= 19 && az <= 15;
|
|
const insideMoat = ax <= 27 && az <= 23;
|
|
if (insideCitadel) h = 15;
|
|
else if (insideMoat) h = Math.min(h, SEA_LEVEL - 2);
|
|
|
|
const treeD = Math.hypot(x + 38, z + 28);
|
|
if (treeD < 13) h = Math.max(h, Math.floor(12 - treeD * .26));
|
|
|
|
const portalD = Math.hypot(x - 36, z - 30);
|
|
if (portalD < 11) h = Math.max(h, Math.floor(10 - portalD * .18));
|
|
|
|
const boneD = Math.hypot(x + 36, z - 30);
|
|
if (boneD < 13) h = Math.min(Math.max(h, 8), 10);
|
|
|
|
heightCache.set(cacheKey, h);
|
|
return h;
|
|
}
|
|
|
|
function terrainBlockAt(x, y, z) {
|
|
if (y < 0) return BLOCK.STONE;
|
|
const h = heightAt(x, z);
|
|
if (y > h) return BLOCK.AIR;
|
|
if (y === h) {
|
|
if (h <= SEA_LEVEL + 1) return BLOCK.SAND;
|
|
if (h >= 23) return BLOCK.STONE;
|
|
return BLOCK.GRASS;
|
|
}
|
|
if (y >= h - 3) return h <= SEA_LEVEL + 1 ? BLOCK.SAND : BLOCK.DIRT;
|
|
if (hash3(x, y, z) > .994 && y > 2) return BLOCK.CRYSTAL;
|
|
return BLOCK.STONE;
|
|
}
|
|
|
|
function citadelBlockAt(x, y, z) {
|
|
if (Math.abs(x) > 31 || z < -17 || z > 32 || y < 14 || y > 42) return BLOCK.AIR;
|
|
|
|
const ax = Math.abs(x);
|
|
const az = Math.abs(z);
|
|
|
|
if (z >= 13 && z <= 30) {
|
|
if (ax <= 3 && y === 16) return (z % 6 === 0 && ax <= 1) ? BLOCK.RUNE : BLOCK.MARBLE;
|
|
if (ax === 3 && y === 17 && z % 2 === 0) return BLOCK.GILDED;
|
|
}
|
|
|
|
if (ax <= 16 && az <= 12 && y === 16) {
|
|
if ((x % 7 === 0 || z % 7 === 0) && hash2(x, z) > .48) return BLOCK.RUNE;
|
|
return BLOCK.MARBLE;
|
|
}
|
|
|
|
const outerEdge = (ax === 16 && az <= 12) || (az === 12 && ax <= 16);
|
|
const southGateVoid = z === 12 && ax <= 2 && y <= 21;
|
|
const slit = y === 20 && ((ax === 16 && Math.abs(z % 5) === 1) || (az === 12 && Math.abs(x % 5) === 1));
|
|
if (outerEdge && y >= 17 && y <= 22 && !southGateVoid && !slit) {
|
|
return (y === 18 && (x + z) % 4 === 0) ? BLOCK.GILDED : BLOCK.MARBLE;
|
|
}
|
|
if (outerEdge && y === 23 && (Math.abs(x + z) % 2 === 0)) return BLOCK.RUNE;
|
|
|
|
const corners = [[16, 12], [16, -12], [-16, 12], [-16, -12]];
|
|
for (const [cx, cz] of corners) {
|
|
const dx = Math.abs(x - cx);
|
|
const dz = Math.abs(z - cz);
|
|
const d = Math.max(dx, dz);
|
|
if (d === 3 && y >= 17 && y <= 27) {
|
|
if (y === 23 && ((dx === 3 && dz === 0) || (dz === 3 && dx === 0))) return BLOCK.CRYSTAL;
|
|
return y % 5 === 0 ? BLOCK.RUNE : BLOCK.MARBLE;
|
|
}
|
|
if (d <= 3 && y === 27) return BLOCK.MARBLE;
|
|
if (d === 3 && y === 28 && ((x + z) & 1) === 0) return BLOCK.GILDED;
|
|
}
|
|
|
|
const keepX = ax;
|
|
const keepZ = Math.abs(z + 1);
|
|
const keepEdge = (keepX === 7 && keepZ <= 6) || (keepZ === 6 && keepX <= 7);
|
|
const keepGateVoid = z === 5 && ax <= 1 && y <= 21;
|
|
const keepWindow = (y === 22 || y === 25) && ((keepX === 7 && Math.abs(z % 4) === 1) || (keepZ === 6 && Math.abs(x % 4) === 1));
|
|
if (keepEdge && y >= 17 && y <= 27 && !keepGateVoid && !keepWindow) {
|
|
return y === 20 ? BLOCK.RUNE : BLOCK.MARBLE;
|
|
}
|
|
if (keepX <= 7 && keepZ <= 6 && y === 17) return BLOCK.MARBLE;
|
|
if (keepX <= 7 && keepZ <= 6 && y === 28) return ((x + z) % 3 === 0) ? BLOCK.GILDED : BLOCK.MARBLE;
|
|
if (keepX <= 6 && keepZ <= 5 && y === 29) return BLOCK.RUNE;
|
|
if (keepX <= 5 && keepZ <= 4 && y === 30) return BLOCK.MARBLE;
|
|
|
|
const spireD = Math.max(ax, Math.abs(z + 1));
|
|
if (spireD === 3 && y >= 30 && y <= 36) {
|
|
if (y === 33 && (x === 0 || z === -1)) return BLOCK.CRYSTAL;
|
|
return y % 3 === 0 ? BLOCK.RUNE : BLOCK.MARBLE;
|
|
}
|
|
if (spireD <= 3 && y === 36) return BLOCK.GILDED;
|
|
if (ax <= Math.max(0, 2 - Math.floor((y - 37) / 2)) && Math.abs(z + 1) <= Math.max(0, 2 - Math.floor((y - 37) / 2)) && y >= 37 && y <= 40) return BLOCK.CRYSTAL;
|
|
|
|
const pylons = [[10, 6], [-10, 6], [10, -6], [-10, -6]];
|
|
for (const [px, pz] of pylons) {
|
|
const pd = Math.max(Math.abs(x - px), Math.abs(z - pz));
|
|
if (pd <= 1 && y >= 17 && y <= 22) return y === 20 ? BLOCK.GILDED : BLOCK.RUNE;
|
|
if (pd === 0 && y >= 23 && y <= 25) return BLOCK.CRYSTAL;
|
|
}
|
|
|
|
if (z === 12 && ax <= 3 && y === 22) return BLOCK.GILDED;
|
|
return BLOCK.AIR;
|
|
}
|
|
|
|
function pointSegmentDistanceSq(px, py, pz, ax, ay, az, bx, by, bz) {
|
|
const abx = bx - ax;
|
|
const aby = by - ay;
|
|
const abz = bz - az;
|
|
const apx = px - ax;
|
|
const apy = py - ay;
|
|
const apz = pz - az;
|
|
const denom = abx * abx + aby * aby + abz * abz;
|
|
const t = Math.max(0, Math.min(1, (apx * abx + apy * aby + apz * abz) / denom));
|
|
const dx = px - (ax + abx * t);
|
|
const dy = py - (ay + aby * t);
|
|
const dz = pz - (az + abz * t);
|
|
return dx * dx + dy * dy + dz * dz;
|
|
}
|
|
|
|
function worldTreeBlockAt(x, y, z) {
|
|
const dx = x + 38;
|
|
const dz = z + 28;
|
|
if (Math.abs(dx) > 13 || Math.abs(dz) > 13 || y < 11 || y > 39) return BLOCK.AIR;
|
|
|
|
if (y >= 13 && y <= 29) {
|
|
const radius = y < 18 ? 2.45 : y < 25 ? 1.75 : 1.15;
|
|
if (dx * dx + dz * dz <= radius * radius + hash3(x, y, z) * .55) return BLOCK.WOOD;
|
|
}
|
|
|
|
const roots = [[9, 0], [-9, 1], [0, 9], [1, -9], [7, 7], [-7, 7], [7, -7], [-7, -7]];
|
|
for (const [rx, rz] of roots) {
|
|
if (pointSegmentDistanceSq(x + .5, y + .5, z + .5, -37.5, 14, -27.5, -37.5 + rx, 12.5, -27.5 + rz) < 1.3) return BLOCK.WOOD;
|
|
}
|
|
|
|
const branches = [
|
|
[9, 31, 1], [-8, 30, 3], [2, 34, 8], [-3, 33, -8], [7, 35, -5], [-7, 35, -4]
|
|
];
|
|
for (const [bx, by, bz] of branches) {
|
|
if (pointSegmentDistanceSq(x + .5, y + .5, z + .5, -37.5, 26.5, -27.5, -37.5 + bx, by, -27.5 + bz) < 1.15) return BLOCK.WOOD;
|
|
}
|
|
|
|
const crowns = [
|
|
[0, 33, 0, 6.4], [7, 33, 1, 4.4], [-7, 32, 3, 4.7], [2, 36, 7, 4.1], [-3, 35, -7, 4.5], [6, 37, -4, 3.7], [-6, 37, -3, 3.8]
|
|
];
|
|
for (const [cx, cy, cz, r] of crowns) {
|
|
const ex = dx - cx;
|
|
const ey = (y - cy) * 1.1;
|
|
const ez = dz - cz;
|
|
const d2 = ex * ex + ey * ey + ez * ez;
|
|
if (d2 <= r * r && hash3(x, y, z) > .12) {
|
|
if (hash3(x + 71, y - 18, z + 9) > .976 && y < cy) return BLOCK.CRYSTAL;
|
|
return BLOCK.LEAVES;
|
|
}
|
|
}
|
|
return BLOCK.AIR;
|
|
}
|
|
|
|
const floatingIslands = [
|
|
{ x: 34, z: -28, y: 32, r: 10 },
|
|
{ x: 47, z: -34, y: 28, r: 5 },
|
|
{ x: 23, z: -39, y: 35, r: 4 },
|
|
{ x: 48, z: -17, y: 38, r: 3.5 }
|
|
];
|
|
|
|
function floatingIslandBlockAt(x, y, z) {
|
|
for (const island of floatingIslands) {
|
|
const dx = x - island.x;
|
|
const dz = z - island.z;
|
|
const radial = Math.sqrt(dx * dx + dz * dz) / island.r;
|
|
if (radial <= 1) {
|
|
const top = island.y + Math.floor(detailNoise.noise(x * .21, z * .21) * 1.5);
|
|
const thickness = Math.max(1, Math.floor((1 - radial) * island.r * .78 + 1));
|
|
if (y <= top && y >= top - thickness) {
|
|
if (y === top) return BLOCK.GRASS;
|
|
if (y >= top - 2) return BLOCK.DIRT;
|
|
return hash3(x, y, z) > .94 ? BLOCK.CRYSTAL : BLOCK.STONE;
|
|
}
|
|
}
|
|
}
|
|
|
|
const dx = x - 34;
|
|
const dz = z + 28;
|
|
const r2 = dx * dx + dz * dz;
|
|
if (r2 <= 42 && y >= 32 && y <= 34) return y === 34 ? BLOCK.MARBLE : BLOCK.RUNE;
|
|
|
|
const columns = [[5, 0], [-5, 0], [0, 5], [0, -5], [4, 4], [-4, 4], [4, -4], [-4, -4]];
|
|
for (const [cx, cz] of columns) {
|
|
const d = Math.max(Math.abs(dx - cx), Math.abs(dz - cz));
|
|
if (d === 0 && y >= 35 && y <= 42) return y === 39 ? BLOCK.GILDED : BLOCK.MARBLE;
|
|
if (d <= 1 && y === 35) return BLOCK.RUNE;
|
|
}
|
|
if (r2 >= 18 && r2 <= 37 && y === 43) return (x + z) % 3 === 0 ? BLOCK.GILDED : BLOCK.MARBLE;
|
|
if (Math.abs(dx) <= 1 && Math.abs(dz) <= 1 && y >= 35 && y <= 41) return BLOCK.CRYSTAL;
|
|
if (dx === 0 && dz === 0 && y === 42) return BLOCK.GILDED;
|
|
return BLOCK.AIR;
|
|
}
|
|
|
|
function moonGateBlockAt(x, y, z) {
|
|
const dx = x - 36;
|
|
const dz = z - 30;
|
|
if (Math.abs(dx) > 10 || Math.abs(dz) > 6 || y < 9 || y > 29) return BLOCK.AIR;
|
|
const base = 11;
|
|
const d2 = dx * dx + dz * dz;
|
|
if (y === base && d2 <= 64) return d2 > 45 ? BLOCK.GILDED : BLOCK.RUNE;
|
|
if (y === base + 1 && d2 <= 25) return BLOCK.MARBLE;
|
|
|
|
if (Math.abs(dz) <= 1) {
|
|
const ny = y - 19;
|
|
const ring = Math.sqrt((dx * dx) / 49 + (ny * ny) / 81);
|
|
if (ring > .82 && ring < 1.12) {
|
|
if ((x + y) % 5 === 0) return BLOCK.CRYSTAL;
|
|
return BLOCK.RUNE;
|
|
}
|
|
}
|
|
if ((Math.abs(dx) === 8 || Math.abs(dx) === 9) && Math.abs(dz) <= 2 && y >= base && y <= base + 3) return BLOCK.MARBLE;
|
|
return BLOCK.AIR;
|
|
}
|
|
|
|
function dragonBoneBlockAt(x, y, z) {
|
|
if (x < -48 || x > -24 || z < 14 || z > 46 || y < 10 || y > 23) return BLOCK.AIR;
|
|
const spineX = -36 + Math.round(Math.sin((z - 18) * .31));
|
|
if (z >= 18 && z <= 42 && Math.abs(x - spineX) <= (z % 5 === 0 ? 1 : 0) && y === 13) return BLOCK.BONE;
|
|
|
|
const ribs = [21, 25, 29, 33, 37];
|
|
for (const rz of ribs) {
|
|
const dx = x + 36;
|
|
if (Math.abs(dx) <= 8 && Math.abs(z - rz) <= (Math.abs(dx) === 8 ? 1 : 0)) {
|
|
const arch = 13 + Math.round(Math.sqrt(Math.max(0, 1 - (dx * dx) / 64)) * 7);
|
|
if (y === arch) return BLOCK.BONE;
|
|
}
|
|
}
|
|
|
|
const sx = x + 36;
|
|
const sz = z - 16;
|
|
if (Math.abs(sx) <= 3 && sz >= -2 && sz <= 2 && y >= 12 && y <= 16) {
|
|
const shell = Math.abs(sx) === 3 || Math.abs(sz) === 2 || y === 16;
|
|
const eye = y === 14 && Math.abs(sx) === 2 && sz === -2;
|
|
if (eye) return BLOCK.RUNE;
|
|
if (shell) return BLOCK.BONE;
|
|
}
|
|
if (pointSegmentDistanceSq(x + .5, y + .5, z + .5, -38, 16, 15, -44, 21, 9) < .9) return BLOCK.BONE;
|
|
if (pointSegmentDistanceSq(x + .5, y + .5, z + .5, -34, 16, 15, -28, 21, 9) < .9) return BLOCK.BONE;
|
|
return BLOCK.AIR;
|
|
}
|
|
|
|
function shrineBlockAt(x, y, z) {
|
|
const cell = 96;
|
|
const cx = Math.floor((x + cell / 2) / cell) * cell;
|
|
const cz = Math.floor((z + cell / 2) / cell) * cell;
|
|
if (Math.abs(cx) < 70 && Math.abs(cz) < 70) return BLOCK.AIR;
|
|
if (hash2(cx / cell, cz / cell) < .48) return BLOCK.AIR;
|
|
const dx = x - cx;
|
|
const dz = z - cz;
|
|
if (Math.abs(dx) > 5 || Math.abs(dz) > 5) return BLOCK.AIR;
|
|
const base = heightAt(cx, cz) + 1;
|
|
if (y === base && Math.max(Math.abs(dx), Math.abs(dz)) <= 4) return (dx === 0 || dz === 0) ? BLOCK.RUNE : BLOCK.MARBLE;
|
|
if ((Math.abs(dx) === 3 && Math.abs(dz) === 3) && y >= base + 1 && y <= base + 6) return y === base + 4 ? BLOCK.GILDED : BLOCK.MARBLE;
|
|
if (y === base + 7 && Math.max(Math.abs(dx), Math.abs(dz)) <= 4 && (Math.abs(dx) + Math.abs(dz) >= 3)) return BLOCK.RUNE;
|
|
if (dx === 0 && dz === 0 && y >= base + 1 && y <= base + 5) return BLOCK.CRYSTAL;
|
|
return BLOCK.AIR;
|
|
}
|
|
|
|
function structureBlockAt(x, y, z) {
|
|
let block = citadelBlockAt(x, y, z);
|
|
if (block) return block;
|
|
block = worldTreeBlockAt(x, y, z);
|
|
if (block) return block;
|
|
block = floatingIslandBlockAt(x, y, z);
|
|
if (block) return block;
|
|
block = moonGateBlockAt(x, y, z);
|
|
if (block) return block;
|
|
block = dragonBoneBlockAt(x, y, z);
|
|
if (block) return block;
|
|
return shrineBlockAt(x, y, z);
|
|
}
|
|
|
|
const edits = new Map();
|
|
function blockKey(x, y, z) { return x + ',' + y + ',' + z; }
|
|
|
|
try {
|
|
const saved = JSON.parse(localStorage.getItem('mythos-voxel-edits-v1') || '[]');
|
|
for (const entry of saved) {
|
|
if (Array.isArray(entry) && entry.length === 2) edits.set(entry[0], entry[1]);
|
|
}
|
|
} catch (_) { /* A sandbox may disable storage. */ }
|
|
|
|
let saveTimer = 0;
|
|
function scheduleSave() {
|
|
clearTimeout(saveTimer);
|
|
saveTimer = setTimeout(() => {
|
|
try { localStorage.setItem('mythos-voxel-edits-v1', JSON.stringify(Array.from(edits.entries()).slice(-4000))); } catch (_) {}
|
|
}, 250);
|
|
}
|
|
|
|
function getBlock(x, y, z) {
|
|
const edited = edits.get(blockKey(x, y, z));
|
|
if (edited !== undefined) return edited;
|
|
const terrain = terrainBlockAt(x, y, z);
|
|
if (terrain) return terrain;
|
|
return structureBlockAt(x, y, z);
|
|
}
|
|
|
|
const textureUrls = {};
|
|
function makePixelTexture(base, accent, painter, seed = 1) {
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = 16;
|
|
canvas.height = 16;
|
|
const ctx = canvas.getContext('2d');
|
|
ctx.fillStyle = base;
|
|
ctx.fillRect(0, 0, 16, 16);
|
|
const random = mulberry32(seed);
|
|
for (let i = 0; i < 62; i++) {
|
|
const alpha = .07 + random() * .18;
|
|
ctx.fillStyle = accent.replace('ALPHA', alpha.toFixed(3));
|
|
ctx.fillRect(Math.floor(random() * 16), Math.floor(random() * 16), random() > .86 ? 2 : 1, 1);
|
|
}
|
|
if (painter) painter(ctx, random);
|
|
const texture = new THREE.CanvasTexture(canvas);
|
|
texture.magFilter = THREE.NearestFilter;
|
|
texture.minFilter = THREE.NearestFilter;
|
|
texture.generateMipmaps = false;
|
|
texture.encoding = THREE.sRGBEncoding;
|
|
textureUrls[seed] = canvas.toDataURL();
|
|
return texture;
|
|
}
|
|
|
|
const tex = {};
|
|
tex.dirt = makePixelTexture('#775136', 'rgba(39,24,18,ALPHA)', null, 11);
|
|
tex.grassTop = makePixelTexture('#5e8c45', 'rgba(194,217,101,ALPHA)', (ctx, random) => {
|
|
for (let i = 0; i < 18; i++) {
|
|
ctx.fillStyle = random() > .5 ? '#426f3d' : '#81a64f';
|
|
ctx.fillRect(Math.floor(random() * 16), Math.floor(random() * 16), 1, 1);
|
|
}
|
|
}, 12);
|
|
tex.grassSide = makePixelTexture('#775136', 'rgba(43,27,19,ALPHA)', (ctx, random) => {
|
|
ctx.fillStyle = '#5d8b45';
|
|
ctx.fillRect(0, 0, 16, 4);
|
|
for (let x = 0; x < 16; x++) {
|
|
if (random() > .27) {
|
|
ctx.fillStyle = random() > .5 ? '#537f42' : '#70984b';
|
|
ctx.fillRect(x, 4, 1, 1 + Math.floor(random() * 3));
|
|
}
|
|
}
|
|
}, 13);
|
|
tex.stone = makePixelTexture('#6d7578', 'rgba(22,31,35,ALPHA)', (ctx) => {
|
|
ctx.fillStyle = 'rgba(178,188,183,.22)';
|
|
ctx.fillRect(2, 3, 5, 1);
|
|
ctx.fillRect(11, 9, 3, 1);
|
|
ctx.fillRect(7, 13, 4, 1);
|
|
}, 14);
|
|
tex.marble = makePixelTexture('#d5d0bd', 'rgba(73,89,94,ALPHA)', (ctx) => {
|
|
ctx.strokeStyle = 'rgba(95,110,113,.34)';
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, 12); ctx.lineTo(5, 9); ctx.lineTo(8, 5); ctx.lineTo(16, 3); ctx.stroke();
|
|
ctx.strokeStyle = 'rgba(255,248,219,.4)';
|
|
ctx.beginPath(); ctx.moveTo(4, 16); ctx.lineTo(9, 11); ctx.lineTo(16, 10); ctx.stroke();
|
|
}, 15);
|
|
tex.rune = makePixelTexture('#26384f', 'rgba(101,157,167,ALPHA)', (ctx) => {
|
|
ctx.strokeStyle = '#5bc4c2';
|
|
ctx.globalAlpha = .7;
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
ctx.moveTo(8, 2); ctx.lineTo(5, 7); ctx.lineTo(9, 7); ctx.lineTo(6, 14);
|
|
ctx.moveTo(8, 2); ctx.lineTo(11, 7); ctx.lineTo(8, 10); ctx.lineTo(11, 14);
|
|
ctx.stroke();
|
|
ctx.globalAlpha = 1;
|
|
}, 16);
|
|
tex.woodSide = makePixelTexture('#6f4a33', 'rgba(31,20,15,ALPHA)', (ctx) => {
|
|
ctx.fillStyle = 'rgba(41,25,17,.42)';
|
|
for (const x of [2, 7, 12]) ctx.fillRect(x, 0, 1, 16);
|
|
ctx.fillStyle = 'rgba(181,123,72,.25)';
|
|
ctx.fillRect(5, 0, 1, 16);
|
|
}, 17);
|
|
tex.woodTop = makePixelTexture('#906442', 'rgba(45,27,18,ALPHA)', (ctx) => {
|
|
ctx.strokeStyle = 'rgba(48,28,18,.55)';
|
|
ctx.strokeRect(3, 3, 10, 10);
|
|
ctx.strokeRect(6, 6, 4, 4);
|
|
}, 18);
|
|
tex.leaves = makePixelTexture('#466b52', 'rgba(147,205,121,ALPHA)', (ctx, random) => {
|
|
for (let i = 0; i < 17; i++) {
|
|
if (random() > .45) ctx.clearRect(Math.floor(random() * 16), Math.floor(random() * 16), 1, 1);
|
|
}
|
|
}, 19);
|
|
tex.sand = makePixelTexture('#b7a475', 'rgba(82,70,47,ALPHA)', null, 20);
|
|
tex.bone = makePixelTexture('#d7cfad', 'rgba(85,72,54,ALPHA)', (ctx) => {
|
|
ctx.fillStyle = 'rgba(248,239,200,.28)';
|
|
ctx.fillRect(2, 4, 7, 1);
|
|
ctx.fillRect(9, 11, 5, 1);
|
|
}, 21);
|
|
tex.gold = makePixelTexture('#bb913b', 'rgba(255,230,128,ALPHA)', (ctx) => {
|
|
ctx.fillStyle = 'rgba(255,235,153,.42)';
|
|
ctx.fillRect(2, 2, 12, 2);
|
|
ctx.fillRect(2, 2, 2, 12);
|
|
}, 22);
|
|
|
|
const mat = (map, options = {}) => new THREE.MeshStandardMaterial({ map, roughness: .88, metalness: 0, ...options });
|
|
const dirtMat = mat(tex.dirt);
|
|
const grassSideMat = mat(tex.grassSide);
|
|
const grassTopMat = mat(tex.grassTop);
|
|
const stoneMat = mat(tex.stone, { roughness: .96 });
|
|
const marbleMat = mat(tex.marble, { roughness: .72 });
|
|
const runeMat = mat(tex.rune, { roughness: .68, emissive: 0x0b3037, emissiveIntensity: .34 });
|
|
const crystalMat = new THREE.MeshStandardMaterial({ color: 0x77e4d8, roughness: .2, metalness: .08, emissive: 0x1ca49d, emissiveIntensity: 1.15, transparent: true, opacity: .9 });
|
|
const woodSideMat = mat(tex.woodSide);
|
|
const woodTopMat = mat(tex.woodTop);
|
|
const leavesMat = mat(tex.leaves, { color: 0x91b787, alphaTest: .36, transparent: true, side: THREE.DoubleSide });
|
|
const sandMat = mat(tex.sand, { roughness: 1 });
|
|
const boneMat = mat(tex.bone, { roughness: .85 });
|
|
const goldMat = mat(tex.gold, { roughness: .42, metalness: .62, emissive: 0x3a2305, emissiveIntensity: .25 });
|
|
|
|
const materials = {
|
|
[BLOCK.GRASS]: [grassSideMat, grassSideMat, grassTopMat, dirtMat, grassSideMat, grassSideMat],
|
|
[BLOCK.DIRT]: dirtMat,
|
|
[BLOCK.STONE]: stoneMat,
|
|
[BLOCK.MARBLE]: marbleMat,
|
|
[BLOCK.RUNE]: runeMat,
|
|
[BLOCK.CRYSTAL]: crystalMat,
|
|
[BLOCK.WOOD]: [woodSideMat, woodSideMat, woodTopMat, woodTopMat, woodSideMat, woodSideMat],
|
|
[BLOCK.LEAVES]: leavesMat,
|
|
[BLOCK.SAND]: sandMat,
|
|
[BLOCK.BONE]: boneMat,
|
|
[BLOCK.GILDED]: goldMat
|
|
};
|
|
|
|
const swatchTextures = {
|
|
[BLOCK.GRASS]: tex.grassTop.image.toDataURL(),
|
|
[BLOCK.DIRT]: tex.dirt.image.toDataURL(),
|
|
[BLOCK.STONE]: tex.stone.image.toDataURL(),
|
|
[BLOCK.MARBLE]: tex.marble.image.toDataURL(),
|
|
[BLOCK.RUNE]: tex.rune.image.toDataURL(),
|
|
[BLOCK.CRYSTAL]: (() => {
|
|
const c = document.createElement('canvas'); c.width = c.height = 16;
|
|
const g = c.getContext('2d');
|
|
const grad = g.createLinearGradient(0, 16, 16, 0);
|
|
grad.addColorStop(0, '#1e7779'); grad.addColorStop(.5, '#7de9d9'); grad.addColorStop(1, '#c7fff0');
|
|
g.fillStyle = grad; g.fillRect(0, 0, 16, 16);
|
|
g.fillStyle = 'rgba(255,255,255,.55)'; g.fillRect(4, 2, 2, 10);
|
|
return c.toDataURL();
|
|
})()
|
|
};
|
|
|
|
const geometry = new THREE.BoxGeometry(1, 1, 1);
|
|
const worldGroup = new THREE.Group();
|
|
scene.add(worldGroup);
|
|
const chunks = new Map();
|
|
let rayMeshes = [];
|
|
const buildQueue = [];
|
|
const queuedChunks = new Set();
|
|
const desiredChunks = new Set();
|
|
const instanceMatrix = new THREE.Matrix4();
|
|
|
|
function chunkKey(cx, cz) { return cx + ',' + cz; }
|
|
function chunkCoord(n) { return Math.floor(n / CHUNK); }
|
|
|
|
function isExposed(x, y, z) {
|
|
return !getBlock(x + 1, y, z) || !getBlock(x - 1, y, z) ||
|
|
!getBlock(x, y + 1, z) || !getBlock(x, y - 1, z) ||
|
|
!getBlock(x, y, z + 1) || !getBlock(x, y, z - 1);
|
|
}
|
|
|
|
function buildChunk(cx, cz) {
|
|
const key = chunkKey(cx, cz);
|
|
if (chunks.has(key)) removeChunk(key);
|
|
const buckets = new Map();
|
|
const x0 = cx * CHUNK;
|
|
const z0 = cz * CHUNK;
|
|
|
|
for (let x = x0; x < x0 + CHUNK; x++) {
|
|
for (let z = z0; z < z0 + CHUNK; z++) {
|
|
for (let y = 0; y <= MAX_BUILD_Y; y++) {
|
|
const type = getBlock(x, y, z);
|
|
if (!type || !isExposed(x, y, z)) continue;
|
|
if (!buckets.has(type)) buckets.set(type, []);
|
|
buckets.get(type).push([x, y, z]);
|
|
}
|
|
}
|
|
}
|
|
|
|
const group = new THREE.Group();
|
|
group.userData.chunkKey = key;
|
|
const meshes = [];
|
|
for (const [type, positions] of buckets) {
|
|
if (!positions.length) continue;
|
|
const mesh = new THREE.InstancedMesh(geometry, materials[type], positions.length);
|
|
mesh.userData.positions = positions;
|
|
mesh.userData.blockType = type;
|
|
for (let i = 0; i < positions.length; i++) {
|
|
const [x, y, z] = positions[i];
|
|
instanceMatrix.makeTranslation(x + .5, y + .5, z + .5);
|
|
mesh.setMatrixAt(i, instanceMatrix);
|
|
}
|
|
mesh.instanceMatrix.needsUpdate = true;
|
|
mesh.castShadow = type !== BLOCK.LEAVES;
|
|
mesh.receiveShadow = true;
|
|
mesh.frustumCulled = true;
|
|
group.add(mesh);
|
|
meshes.push(mesh);
|
|
rayMeshes.push(mesh);
|
|
}
|
|
worldGroup.add(group);
|
|
chunks.set(key, { group, meshes, cx, cz });
|
|
}
|
|
|
|
function removeChunk(key) {
|
|
const chunk = chunks.get(key);
|
|
if (!chunk) return;
|
|
worldGroup.remove(chunk.group);
|
|
const removing = new Set(chunk.meshes);
|
|
rayMeshes = rayMeshes.filter((mesh) => !removing.has(mesh));
|
|
chunks.delete(key);
|
|
}
|
|
|
|
function queueChunk(cx, cz) {
|
|
const key = chunkKey(cx, cz);
|
|
if (chunks.has(key) || queuedChunks.has(key)) return;
|
|
queuedChunks.add(key);
|
|
buildQueue.push({ cx, cz, key });
|
|
}
|
|
|
|
function refreshDesiredChunks(px, pz) {
|
|
const centerX = chunkCoord(px);
|
|
const centerZ = chunkCoord(pz);
|
|
desiredChunks.clear();
|
|
const pending = [];
|
|
for (let dx = -VIEW_RADIUS; dx <= VIEW_RADIUS; dx++) {
|
|
for (let dz = -VIEW_RADIUS; dz <= VIEW_RADIUS; dz++) {
|
|
const cx = centerX + dx;
|
|
const cz = centerZ + dz;
|
|
const key = chunkKey(cx, cz);
|
|
desiredChunks.add(key);
|
|
if (!chunks.has(key) && !queuedChunks.has(key)) pending.push({ cx, cz, d: dx * dx + dz * dz });
|
|
}
|
|
}
|
|
pending.sort((a, b) => a.d - b.d);
|
|
for (const item of pending) queueChunk(item.cx, item.cz);
|
|
|
|
for (const key of Array.from(chunks.keys())) {
|
|
if (!desiredChunks.has(key)) removeChunk(key);
|
|
}
|
|
}
|
|
|
|
function processBuildQueue() {
|
|
while (buildQueue.length) {
|
|
const next = buildQueue.shift();
|
|
queuedChunks.delete(next.key);
|
|
if (!desiredChunks.has(next.key) || chunks.has(next.key)) continue;
|
|
buildChunk(next.cx, next.cz);
|
|
break;
|
|
}
|
|
const progress = Math.min(100, Math.round(chunks.size / ((VIEW_RADIUS * 2 + 1) ** 2) * 100));
|
|
loadStatus.textContent = progress < 100 ? `正在编织地形 · ${progress}%` : '世界已生成 · 地形将随探索延展';
|
|
}
|
|
|
|
function rebuildAtBlock(x, z) {
|
|
const cx = chunkCoord(x);
|
|
const cz = chunkCoord(z);
|
|
const targets = [[cx, cz]];
|
|
if (((x % CHUNK) + CHUNK) % CHUNK === 0) targets.push([cx - 1, cz]);
|
|
if (((x % CHUNK) + CHUNK) % CHUNK === CHUNK - 1) targets.push([cx + 1, cz]);
|
|
if (((z % CHUNK) + CHUNK) % CHUNK === 0) targets.push([cx, cz - 1]);
|
|
if (((z % CHUNK) + CHUNK) % CHUNK === CHUNK - 1) targets.push([cx, cz + 1]);
|
|
for (const [tx, tz] of targets) {
|
|
if (chunks.has(chunkKey(tx, tz))) buildChunk(tx, tz);
|
|
}
|
|
}
|
|
|
|
const water = new THREE.Mesh(
|
|
new THREE.PlaneGeometry(250, 250, 1, 1),
|
|
new THREE.MeshPhysicalMaterial({
|
|
color: 0x246b76,
|
|
transparent: true,
|
|
opacity: .62,
|
|
roughness: .18,
|
|
metalness: .05,
|
|
clearcoat: 1,
|
|
clearcoatRoughness: .15,
|
|
side: THREE.DoubleSide,
|
|
depthWrite: false
|
|
})
|
|
);
|
|
water.rotation.x = -Math.PI / 2;
|
|
water.position.y = SEA_LEVEL + .58;
|
|
water.receiveShadow = true;
|
|
scene.add(water);
|
|
|
|
const portalMaterial = new THREE.ShaderMaterial({
|
|
transparent: true,
|
|
depthWrite: false,
|
|
side: THREE.DoubleSide,
|
|
uniforms: { uTime: { value: 0 } },
|
|
vertexShader: `
|
|
varying vec2 vUv;
|
|
void main() {
|
|
vUv = uv;
|
|
vec3 p = position;
|
|
gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0);
|
|
}
|
|
`,
|
|
fragmentShader: `
|
|
varying vec2 vUv;
|
|
uniform float uTime;
|
|
void main() {
|
|
vec2 p = (vUv - .5) * vec2(1.0, 1.15);
|
|
float r = length(p);
|
|
float a = atan(p.y, p.x);
|
|
float wave = sin(a * 7.0 - uTime * 1.4 + r * 24.0) * .5 + .5;
|
|
float mist = sin((p.x + p.y) * 19.0 + uTime * .7) * .5 + .5;
|
|
float edge = 1.0 - smoothstep(.34, .5, r);
|
|
vec3 col = mix(vec3(.16, .45, .52), vec3(.53, .30, .72), wave);
|
|
col += vec3(.45, .9, .78) * pow(mist, 7.0) * .45;
|
|
gl_FragColor = vec4(col, edge * (.42 + wave * .26));
|
|
}
|
|
`
|
|
});
|
|
const portal = new THREE.Mesh(new THREE.PlaneGeometry(11.3, 14.2, 1, 1), portalMaterial);
|
|
portal.position.set(36.5, 19, 30.53);
|
|
scene.add(portal);
|
|
const portalLight = new THREE.PointLight(0x4ed5c8, 1.8, 24, 2);
|
|
portalLight.position.set(36.5, 18, 29);
|
|
scene.add(portalLight);
|
|
|
|
const cloudMaterial = new THREE.MeshLambertMaterial({ color: 0xe0e5d5, transparent: true, opacity: .16, depthWrite: false });
|
|
const clouds = new THREE.Group();
|
|
const cloudRandom = mulberry32(0xC10DD5);
|
|
for (let i = 0; i < 16; i++) {
|
|
const cloud = new THREE.Group();
|
|
const pieces = 3 + Math.floor(cloudRandom() * 4);
|
|
for (let j = 0; j < pieces; j++) {
|
|
const cube = new THREE.Mesh(new THREE.BoxGeometry(5 + cloudRandom() * 8, 1.8 + cloudRandom() * 2, 4 + cloudRandom() * 6), cloudMaterial);
|
|
cube.position.set(j * 4 - pieces * 2, cloudRandom() * 2, (cloudRandom() - .5) * 5);
|
|
cloud.add(cube);
|
|
}
|
|
cloud.position.set((cloudRandom() - .5) * 180, 39 + cloudRandom() * 15, (cloudRandom() - .5) * 180);
|
|
cloud.userData.speed = .18 + cloudRandom() * .2;
|
|
clouds.add(cloud);
|
|
}
|
|
scene.add(clouds);
|
|
|
|
const moteCount = 180;
|
|
const motePositions = new Float32Array(moteCount * 3);
|
|
const moteSeeds = new Float32Array(moteCount);
|
|
const moteRandom = mulberry32(0xF1AEF1);
|
|
for (let i = 0; i < moteCount; i++) {
|
|
motePositions[i * 3] = (moteRandom() - .5) * 100;
|
|
motePositions[i * 3 + 1] = 6 + moteRandom() * 34;
|
|
motePositions[i * 3 + 2] = (moteRandom() - .5) * 100;
|
|
moteSeeds[i] = moteRandom() * Math.PI * 2;
|
|
}
|
|
const moteGeometry = new THREE.BufferGeometry();
|
|
moteGeometry.setAttribute('position', new THREE.BufferAttribute(motePositions, 3));
|
|
const motes = new THREE.Points(moteGeometry, new THREE.PointsMaterial({ color: 0x8be1c4, size: .14, transparent: true, opacity: .7, depthWrite: false }));
|
|
scene.add(motes);
|
|
|
|
const outline = new THREE.LineSegments(
|
|
new THREE.EdgesGeometry(new THREE.BoxGeometry(1.018, 1.018, 1.018)),
|
|
new THREE.LineBasicMaterial({ color: 0xf4d985, transparent: true, opacity: .95, depthTest: false })
|
|
);
|
|
outline.renderOrder = 20;
|
|
outline.visible = false;
|
|
scene.add(outline);
|
|
|
|
const hotbarTypes = [BLOCK.GRASS, BLOCK.DIRT, BLOCK.STONE, BLOCK.MARBLE, BLOCK.RUNE, BLOCK.CRYSTAL];
|
|
let selectedSlot = 0;
|
|
|
|
for (let i = 0; i < hotbarTypes.length; i++) {
|
|
const type = hotbarTypes[i];
|
|
const slot = document.createElement('div');
|
|
slot.className = 'slot' + (i === 0 ? ' selected' : '');
|
|
const swatch = document.createElement('div');
|
|
swatch.className = 'slot-swatch';
|
|
swatch.style.backgroundImage = `url(${swatchTextures[type]})`;
|
|
const key = document.createElement('span');
|
|
key.className = 'slot-key';
|
|
key.textContent = String(i + 1);
|
|
slot.append(swatch, key);
|
|
hotbarEl.appendChild(slot);
|
|
}
|
|
|
|
function selectSlot(index) {
|
|
selectedSlot = (index + hotbarTypes.length) % hotbarTypes.length;
|
|
Array.from(hotbarEl.children).forEach((slot, i) => slot.classList.toggle('selected', i === selectedSlot));
|
|
selectedNameEl.textContent = BLOCK_INFO[hotbarTypes[selectedSlot]].name;
|
|
}
|
|
|
|
const player = {
|
|
position: new THREE.Vector3(.5, 17, 27.5),
|
|
velocity: new THREE.Vector3(),
|
|
radius: .31,
|
|
height: 1.78,
|
|
eye: 1.62,
|
|
grounded: false,
|
|
yaw: 0,
|
|
pitch: -.04
|
|
};
|
|
|
|
const keys = Object.create(null);
|
|
let pointerLocked = false;
|
|
|
|
function playerCollides(position) {
|
|
const minX = Math.floor(position.x - player.radius);
|
|
const maxX = Math.floor(position.x + player.radius);
|
|
const minY = Math.floor(position.y + .02);
|
|
const maxY = Math.floor(position.y + player.height - .02);
|
|
const minZ = Math.floor(position.z - player.radius);
|
|
const maxZ = Math.floor(position.z + player.radius);
|
|
for (let x = minX; x <= maxX; x++) {
|
|
for (let y = minY; y <= maxY; y++) {
|
|
for (let z = minZ; z <= maxZ; z++) {
|
|
if (getBlock(x, y, z)) return true;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function moveAxis(axis, amount) {
|
|
const steps = Math.max(1, Math.ceil(Math.abs(amount) / .09));
|
|
const step = amount / steps;
|
|
for (let i = 0; i < steps; i++) {
|
|
player.position[axis] += step;
|
|
if (playerCollides(player.position)) {
|
|
player.position[axis] -= step;
|
|
player.velocity[axis] = 0;
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function wouldBlockPlayer(x, y, z) {
|
|
const minX = player.position.x - player.radius;
|
|
const maxX = player.position.x + player.radius;
|
|
const minY = player.position.y;
|
|
const maxY = player.position.y + player.height;
|
|
const minZ = player.position.z - player.radius;
|
|
const maxZ = player.position.z + player.radius;
|
|
return x < maxX && x + 1 > minX && y < maxY && y + 1 > minY && z < maxZ && z + 1 > minZ;
|
|
}
|
|
|
|
const raycaster = new THREE.Raycaster();
|
|
raycaster.far = REACH;
|
|
let currentHit = null;
|
|
let lastRaycast = 0;
|
|
|
|
function updateRaycast(force = false) {
|
|
const now = performance.now();
|
|
if (!force && now - lastRaycast < 70) return;
|
|
lastRaycast = now;
|
|
raycaster.setFromCamera({ x: 0, y: 0 }, camera);
|
|
const hits = raycaster.intersectObjects(rayMeshes, false);
|
|
currentHit = null;
|
|
for (const hit of hits) {
|
|
if (hit.distance > REACH || hit.instanceId === undefined) continue;
|
|
const pos = hit.object.userData.positions[hit.instanceId];
|
|
if (!pos) continue;
|
|
currentHit = { hit, x: pos[0], y: pos[1], z: pos[2], type: hit.object.userData.blockType };
|
|
break;
|
|
}
|
|
if (currentHit) {
|
|
outline.position.set(currentHit.x + .5, currentHit.y + .5, currentHit.z + .5);
|
|
outline.visible = true;
|
|
targetLabelEl.textContent = `${BLOCK_INFO[currentHit.type]?.name || '未知方块'} · 左键破坏 / 右键放置`;
|
|
} else {
|
|
outline.visible = false;
|
|
targetLabelEl.textContent = '探索地平线上的神话遗迹';
|
|
}
|
|
}
|
|
|
|
const effects = [];
|
|
function spawnDebris(x, y, z, type) {
|
|
const color = BLOCK_INFO[type]?.color || 0xffffff;
|
|
for (let i = 0; i < 9; i++) {
|
|
const mesh = new THREE.Mesh(
|
|
new THREE.BoxGeometry(.1 + Math.random() * .09, .1 + Math.random() * .09, .1 + Math.random() * .09),
|
|
new THREE.MeshBasicMaterial({ color, transparent: true })
|
|
);
|
|
mesh.position.set(x + .5 + (Math.random() - .5) * .55, y + .5 + (Math.random() - .5) * .55, z + .5 + (Math.random() - .5) * .55);
|
|
scene.add(mesh);
|
|
effects.push({ mesh, velocity: new THREE.Vector3((Math.random() - .5) * 2.6, 1.5 + Math.random() * 2.2, (Math.random() - .5) * 2.6), life: .75 });
|
|
}
|
|
}
|
|
|
|
let audioContext = null;
|
|
function playTone(kind) {
|
|
try {
|
|
if (!audioContext) audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
|
if (audioContext.state === 'suspended') audioContext.resume();
|
|
const oscillator = audioContext.createOscillator();
|
|
const gain = audioContext.createGain();
|
|
oscillator.type = kind === 'break' ? 'square' : 'sine';
|
|
oscillator.frequency.setValueAtTime(kind === 'break' ? 125 : 210, audioContext.currentTime);
|
|
oscillator.frequency.exponentialRampToValueAtTime(kind === 'break' ? 58 : 335, audioContext.currentTime + .09);
|
|
gain.gain.setValueAtTime(.045, audioContext.currentTime);
|
|
gain.gain.exponentialRampToValueAtTime(.001, audioContext.currentTime + .1);
|
|
oscillator.connect(gain).connect(audioContext.destination);
|
|
oscillator.start();
|
|
oscillator.stop(audioContext.currentTime + .11);
|
|
} catch (_) {}
|
|
}
|
|
|
|
function breakBlock() {
|
|
updateRaycast(true);
|
|
if (!currentHit || currentHit.y <= 0) return;
|
|
const { x, y, z, type } = currentHit;
|
|
edits.set(blockKey(x, y, z), BLOCK.AIR);
|
|
spawnDebris(x, y, z, type);
|
|
playTone('break');
|
|
rebuildAtBlock(x, z);
|
|
scheduleSave();
|
|
currentHit = null;
|
|
}
|
|
|
|
function placeBlock() {
|
|
updateRaycast(true);
|
|
if (!currentHit || !currentHit.hit.face) return;
|
|
const normal = currentHit.hit.face.normal;
|
|
const x = currentHit.x + Math.round(normal.x);
|
|
const y = currentHit.y + Math.round(normal.y);
|
|
const z = currentHit.z + Math.round(normal.z);
|
|
if (getBlock(x, y, z) || wouldBlockPlayer(x, y, z) || y < 0 || y > MAX_BUILD_Y) return;
|
|
const type = hotbarTypes[selectedSlot];
|
|
edits.set(blockKey(x, y, z), type);
|
|
playTone('place');
|
|
rebuildAtBlock(x, z);
|
|
scheduleSave();
|
|
}
|
|
|
|
const landmarks = [
|
|
{
|
|
x: 0, z: 0, radius: 28,
|
|
title: '曙光王城',
|
|
copy: '天空破碎之夜,七位守望者用月白石封存第一缕火。王城中央的星辉塔至今仍为迷途者指路。'
|
|
},
|
|
{
|
|
x: -38, z: -28, radius: 21,
|
|
title: '伊尔明世界树',
|
|
copy: '它的根系穿过所有时代。树冠垂落的晶体并非果实,而是世界尚未发生的记忆。'
|
|
},
|
|
{
|
|
x: 34, z: -28, radius: 22,
|
|
title: '苍穹浮岛圣殿',
|
|
copy: '浮岛被古老引力符文托举。八根月柱围绕星核,记录着天穹诸神最后一次议会。'
|
|
},
|
|
{
|
|
x: 36, z: 30, radius: 20,
|
|
title: '月海之门',
|
|
copy: '门后的潮汐没有水,只有缓慢流动的星光。符文环每隔一个纪元便短暂苏醒。'
|
|
},
|
|
{
|
|
x: -36, z: 30, radius: 22,
|
|
title: '古龙长眠地',
|
|
copy: '最后的云脊龙在这里坠落。它的肋骨仍保持飞行姿态,双角指向消失的北辰。'
|
|
}
|
|
];
|
|
const discovered = new Set();
|
|
let toastTimer = 0;
|
|
let currentLore = null;
|
|
|
|
function showToast(message) {
|
|
toastEl.textContent = message;
|
|
toastEl.classList.add('show');
|
|
clearTimeout(toastTimer);
|
|
toastTimer = setTimeout(() => toastEl.classList.remove('show'), 3000);
|
|
}
|
|
|
|
function updateLore() {
|
|
let nearest = landmarks[0];
|
|
let nearestDistance = Infinity;
|
|
for (const landmark of landmarks) {
|
|
const distance = Math.hypot(player.position.x - landmark.x, player.position.z - landmark.z);
|
|
if (distance < nearestDistance) {
|
|
nearestDistance = distance;
|
|
nearest = landmark;
|
|
}
|
|
if (distance < landmark.radius && !discovered.has(landmark.title)) {
|
|
discovered.add(landmark.title);
|
|
showToast(`神话图鉴已更新:${landmark.title}`);
|
|
}
|
|
}
|
|
if (nearest !== currentLore) {
|
|
currentLore = nearest;
|
|
loreTitleEl.textContent = nearest.title;
|
|
loreCopyEl.textContent = nearest.copy;
|
|
}
|
|
loreIndexEl.textContent = `Myth archive · ${discovered.size}/${landmarks.length}`;
|
|
|
|
const dx = nearest.x - player.position.x;
|
|
const dz = nearest.z - player.position.z;
|
|
const targetAngle = Math.atan2(-dx, -dz);
|
|
let relative = targetAngle - player.yaw;
|
|
while (relative > Math.PI) relative -= Math.PI * 2;
|
|
while (relative < -Math.PI) relative += Math.PI * 2;
|
|
const direction = Math.abs(relative) < .22 ? '正前方' : relative > 0 ? '右前方' : '左前方';
|
|
compassEl.textContent = `${direction} · ${nearest.title} · ${Math.round(nearestDistance)}M`;
|
|
}
|
|
|
|
function biomeAt(x, z) {
|
|
if (Math.hypot(x, z) < 31) return '曙光王城领地';
|
|
if (Math.hypot(x + 38, z + 28) < 24) return '世界树荫庭';
|
|
if (Math.hypot(x - 34, z + 28) < 24) return '苍穹裂谷';
|
|
if (Math.hypot(x - 36, z - 30) < 20) return '月海门庭';
|
|
if (Math.hypot(x + 36, z - 30) < 22) return '龙骨荒原';
|
|
const moisture = biomeNoise.noise(x * .012, z * .012);
|
|
const h = heightAt(Math.floor(x), Math.floor(z));
|
|
if (h <= SEA_LEVEL + 1) return '镜潮浅滩';
|
|
if (h >= 21) return '风蚀峰脊';
|
|
return moisture > .16 ? '翡翠苔原' : moisture < -.18 ? '灰烬旷野' : '永暮高地';
|
|
}
|
|
|
|
document.addEventListener('keydown', (event) => {
|
|
keys[event.code] = true;
|
|
if (event.code === 'Space') event.preventDefault();
|
|
if (/^Digit[1-6]$/.test(event.code)) selectSlot(Number(event.code.slice(5)) - 1);
|
|
});
|
|
|
|
document.addEventListener('keyup', (event) => { keys[event.code] = false; });
|
|
document.addEventListener('mousemove', (event) => {
|
|
if (!pointerLocked) return;
|
|
player.yaw -= event.movementX * .00205;
|
|
player.pitch -= event.movementY * .00205;
|
|
player.pitch = Math.max(-Math.PI / 2 + .015, Math.min(Math.PI / 2 - .015, player.pitch));
|
|
});
|
|
|
|
renderer.domElement.addEventListener('mousedown', (event) => {
|
|
if (!pointerLocked) return;
|
|
if (event.button === 0) breakBlock();
|
|
if (event.button === 2) placeBlock();
|
|
});
|
|
renderer.domElement.addEventListener('contextmenu', (event) => event.preventDefault());
|
|
renderer.domElement.addEventListener('wheel', (event) => {
|
|
if (!pointerLocked) return;
|
|
selectSlot(selectedSlot + (event.deltaY > 0 ? 1 : -1));
|
|
}, { passive: true });
|
|
|
|
enterButton.addEventListener('click', () => {
|
|
renderer.domElement.requestPointerLock();
|
|
try {
|
|
if (!audioContext) audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
|
audioContext.resume();
|
|
} catch (_) {}
|
|
});
|
|
|
|
document.addEventListener('pointerlockchange', () => {
|
|
pointerLocked = document.pointerLockElement === renderer.domElement;
|
|
startScreen.classList.toggle('hidden', pointerLocked);
|
|
document.body.classList.toggle('playing', pointerLocked);
|
|
enterButton.textContent = pointerLocked ? '已进入世界' : '继续探索';
|
|
if (!pointerLocked) {
|
|
keys.KeyW = keys.KeyA = keys.KeyS = keys.KeyD = keys.Space = false;
|
|
}
|
|
});
|
|
|
|
window.addEventListener('resize', () => {
|
|
camera.aspect = innerWidth / innerHeight;
|
|
camera.updateProjectionMatrix();
|
|
renderer.setSize(innerWidth, innerHeight);
|
|
renderer.setPixelRatio(Math.min(devicePixelRatio, 1.7));
|
|
});
|
|
|
|
let lastTime = performance.now();
|
|
let lastChunkX = Infinity;
|
|
let lastChunkZ = Infinity;
|
|
let hudClock = 0;
|
|
|
|
function updatePlayer(dt) {
|
|
const forwardInput = (keys.KeyW ? 1 : 0) - (keys.KeyS ? 1 : 0);
|
|
const rightInput = (keys.KeyD ? 1 : 0) - (keys.KeyA ? 1 : 0);
|
|
const length = Math.hypot(forwardInput, rightInput) || 1;
|
|
const forward = forwardInput / length;
|
|
const right = rightInput / length;
|
|
const speed = keys.ShiftLeft || keys.ShiftRight ? 9.2 : 5.7;
|
|
const desiredX = (-Math.sin(player.yaw) * forward + Math.cos(player.yaw) * right) * speed;
|
|
const desiredZ = (-Math.cos(player.yaw) * forward - Math.sin(player.yaw) * right) * speed;
|
|
const responsiveness = player.grounded ? 15 : 5;
|
|
player.velocity.x += (desiredX - player.velocity.x) * Math.min(1, responsiveness * dt);
|
|
player.velocity.z += (desiredZ - player.velocity.z) * Math.min(1, responsiveness * dt);
|
|
|
|
player.grounded = playerCollides(new THREE.Vector3(player.position.x, player.position.y - .075, player.position.z));
|
|
if (player.grounded && player.velocity.y < 0) player.velocity.y = 0;
|
|
if (player.grounded && keys.Space) {
|
|
player.velocity.y = 8.3;
|
|
player.grounded = false;
|
|
keys.Space = false;
|
|
}
|
|
player.velocity.y -= 23 * dt;
|
|
player.velocity.y = Math.max(player.velocity.y, -32);
|
|
|
|
moveAxis('x', player.velocity.x * dt);
|
|
moveAxis('z', player.velocity.z * dt);
|
|
const verticalMoved = moveAxis('y', player.velocity.y * dt);
|
|
if (!verticalMoved && player.velocity.y <= 0) player.grounded = true;
|
|
|
|
if (player.position.y < -15) {
|
|
player.position.set(.5, 17, 27.5);
|
|
player.velocity.set(0, 0, 0);
|
|
}
|
|
|
|
camera.position.set(player.position.x, player.position.y + player.eye, player.position.z);
|
|
camera.rotation.y = player.yaw;
|
|
camera.rotation.x = player.pitch;
|
|
}
|
|
|
|
function updateEffects(dt) {
|
|
for (let i = effects.length - 1; i >= 0; i--) {
|
|
const effect = effects[i];
|
|
effect.life -= dt;
|
|
effect.velocity.y -= 7 * dt;
|
|
effect.mesh.position.addScaledVector(effect.velocity, dt);
|
|
effect.mesh.rotation.x += dt * 5;
|
|
effect.mesh.rotation.y += dt * 7;
|
|
effect.mesh.material.opacity = Math.max(0, effect.life / .75);
|
|
if (effect.life <= 0) {
|
|
scene.remove(effect.mesh);
|
|
effect.mesh.geometry.dispose();
|
|
effect.mesh.material.dispose();
|
|
effects.splice(i, 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
function animate(now) {
|
|
requestAnimationFrame(animate);
|
|
const dt = Math.min(.05, (now - lastTime) / 1000 || .016);
|
|
lastTime = now;
|
|
|
|
if (pointerLocked) updatePlayer(dt);
|
|
else {
|
|
camera.position.set(player.position.x, player.position.y + player.eye, player.position.z);
|
|
camera.rotation.y = player.yaw;
|
|
camera.rotation.x = player.pitch;
|
|
}
|
|
|
|
const cx = chunkCoord(player.position.x);
|
|
const cz = chunkCoord(player.position.z);
|
|
if (cx !== lastChunkX || cz !== lastChunkZ) {
|
|
lastChunkX = cx;
|
|
lastChunkZ = cz;
|
|
refreshDesiredChunks(player.position.x, player.position.z);
|
|
}
|
|
processBuildQueue();
|
|
updateRaycast();
|
|
updateEffects(dt);
|
|
|
|
hudClock += dt;
|
|
if (hudClock > .18) {
|
|
hudClock = 0;
|
|
biomeEl.textContent = biomeAt(player.position.x, player.position.z);
|
|
coordsEl.textContent = `X ${Math.floor(player.position.x)} · Y ${Math.floor(player.position.y)} · Z ${Math.floor(player.position.z)} · ${chunks.size} 区块`;
|
|
updateLore();
|
|
}
|
|
|
|
const t = now * .001;
|
|
sky.material.uniforms.uTime.value = t;
|
|
portalMaterial.uniforms.uTime.value = t;
|
|
crystalMat.emissiveIntensity = 1.02 + Math.sin(t * 2.1) * .18;
|
|
portalLight.intensity = 1.65 + Math.sin(t * 1.7) * .35;
|
|
water.material.opacity = .59 + Math.sin(t * .7) * .025;
|
|
water.position.x = Math.round(player.position.x / 20) * 20;
|
|
water.position.z = Math.round(player.position.z / 20) * 20;
|
|
sky.position.copy(camera.position);
|
|
celestial.position.set(camera.position.x, camera.position.y, camera.position.z);
|
|
|
|
sun.position.set(player.position.x - 55, 88, player.position.z + 36);
|
|
sun.target.position.set(player.position.x, 0, player.position.z);
|
|
sun.target.updateMatrixWorld();
|
|
|
|
clouds.children.forEach((cloud) => {
|
|
cloud.position.x += cloud.userData.speed * dt;
|
|
const dx = cloud.position.x - player.position.x;
|
|
const dz = cloud.position.z - player.position.z;
|
|
if (dx > 105) cloud.position.x -= 210;
|
|
if (dx < -105) cloud.position.x += 210;
|
|
if (dz > 105) cloud.position.z -= 210;
|
|
if (dz < -105) cloud.position.z += 210;
|
|
});
|
|
|
|
const pos = moteGeometry.attributes.position.array;
|
|
for (let i = 0; i < moteCount; i++) {
|
|
pos[i * 3 + 1] += Math.sin(t * 1.1 + moteSeeds[i]) * .0025;
|
|
pos[i * 3] += Math.sin(t * .43 + moteSeeds[i]) * .0015;
|
|
}
|
|
moteGeometry.attributes.position.needsUpdate = true;
|
|
motes.position.x = Math.round(player.position.x / 70) * 70;
|
|
motes.position.z = Math.round(player.position.z / 70) * 70;
|
|
|
|
renderer.render(scene, camera);
|
|
}
|
|
|
|
refreshDesiredChunks(player.position.x, player.position.z);
|
|
updateLore();
|
|
selectSlot(0);
|
|
requestAnimationFrame(animate);
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|