1095 lines
38 KiB
HTML
1095 lines
38 KiB
HTML
<!doctype html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>Mythic Blocks</title>
|
|
<link rel="icon" href="data:," />
|
|
<style>
|
|
:root {
|
|
color-scheme: dark;
|
|
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
--ink: #f4f0df;
|
|
--panel: rgba(18, 21, 23, 0.72);
|
|
--line: rgba(255, 255, 255, 0.2);
|
|
--accent: #ffd56d;
|
|
}
|
|
|
|
html,
|
|
body {
|
|
width: 100%;
|
|
height: 100%;
|
|
margin: 0;
|
|
overflow: hidden;
|
|
background: #080b10;
|
|
}
|
|
|
|
canvas {
|
|
display: block;
|
|
}
|
|
|
|
#hud {
|
|
position: fixed;
|
|
inset: 0;
|
|
pointer-events: none;
|
|
color: var(--ink);
|
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8);
|
|
}
|
|
|
|
#crosshair {
|
|
position: absolute;
|
|
left: 50%;
|
|
top: 50%;
|
|
width: 22px;
|
|
height: 22px;
|
|
transform: translate(-50%, -50%);
|
|
}
|
|
|
|
#crosshair::before,
|
|
#crosshair::after {
|
|
content: "";
|
|
position: absolute;
|
|
background: rgba(255, 250, 220, 0.92);
|
|
box-shadow: 0 0 4px rgba(0, 0, 0, 0.9);
|
|
border-radius: 1px;
|
|
}
|
|
|
|
#crosshair::before {
|
|
left: 10px;
|
|
top: 2px;
|
|
width: 2px;
|
|
height: 18px;
|
|
}
|
|
|
|
#crosshair::after {
|
|
left: 2px;
|
|
top: 10px;
|
|
width: 18px;
|
|
height: 2px;
|
|
}
|
|
|
|
#topbar {
|
|
position: absolute;
|
|
top: 14px;
|
|
left: 14px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
padding: 8px 10px;
|
|
border: 1px solid var(--line);
|
|
background: var(--panel);
|
|
backdrop-filter: blur(10px);
|
|
border-radius: 8px;
|
|
font-size: 12px;
|
|
line-height: 1;
|
|
}
|
|
|
|
#biome {
|
|
min-width: 105px;
|
|
color: #fff5c2;
|
|
font-weight: 700;
|
|
}
|
|
|
|
#stats {
|
|
color: rgba(244, 240, 223, 0.78);
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
|
|
#hotbar {
|
|
position: absolute;
|
|
left: 50%;
|
|
bottom: 18px;
|
|
display: grid;
|
|
grid-template-columns: repeat(8, 46px);
|
|
gap: 8px;
|
|
transform: translateX(-50%);
|
|
padding: 8px;
|
|
border: 1px solid var(--line);
|
|
border-radius: 8px;
|
|
background: rgba(10, 13, 14, 0.66);
|
|
backdrop-filter: blur(10px);
|
|
}
|
|
|
|
.slot {
|
|
width: 46px;
|
|
height: 46px;
|
|
position: relative;
|
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
|
border-radius: 6px;
|
|
background: rgba(255, 255, 255, 0.06);
|
|
box-shadow: inset 0 -8px 18px rgba(0, 0, 0, 0.38);
|
|
}
|
|
|
|
.slot.selected {
|
|
border-color: var(--accent);
|
|
box-shadow:
|
|
0 0 0 2px rgba(255, 213, 109, 0.38),
|
|
inset 0 -8px 18px rgba(0, 0, 0, 0.38);
|
|
}
|
|
|
|
.swatch {
|
|
position: absolute;
|
|
inset: 8px;
|
|
border-radius: 4px;
|
|
border: 1px solid rgba(255, 255, 255, 0.25);
|
|
background: var(--c);
|
|
image-rendering: pixelated;
|
|
}
|
|
|
|
.slot::after {
|
|
content: attr(data-key);
|
|
position: absolute;
|
|
right: 5px;
|
|
bottom: 3px;
|
|
font-size: 10px;
|
|
color: rgba(255, 255, 255, 0.76);
|
|
}
|
|
|
|
#veil {
|
|
position: fixed;
|
|
inset: 0;
|
|
display: grid;
|
|
place-items: center;
|
|
background:
|
|
linear-gradient(rgba(5, 8, 9, 0.35), rgba(5, 8, 9, 0.55)),
|
|
radial-gradient(circle at 52% 42%, rgba(255, 226, 142, 0.14), transparent 32%);
|
|
backdrop-filter: blur(1px);
|
|
color: var(--ink);
|
|
cursor: pointer;
|
|
}
|
|
|
|
#veil.hidden {
|
|
display: none;
|
|
}
|
|
|
|
#veil button {
|
|
pointer-events: auto;
|
|
appearance: none;
|
|
border: 1px solid rgba(255, 224, 133, 0.8);
|
|
border-radius: 8px;
|
|
padding: 13px 22px;
|
|
color: #19140a;
|
|
background: linear-gradient(#ffe69d, #d49b36);
|
|
font: inherit;
|
|
font-weight: 800;
|
|
letter-spacing: 0;
|
|
box-shadow: 0 14px 34px rgba(0, 0, 0, 0.35);
|
|
cursor: pointer;
|
|
}
|
|
</style>
|
|
<script type="importmap">
|
|
{
|
|
"imports": {
|
|
"three": "https://unpkg.com/three@0.160.0/build/three.module.js"
|
|
}
|
|
}
|
|
</script>
|
|
</head>
|
|
<body>
|
|
<div id="hud">
|
|
<div id="topbar">
|
|
<span id="biome">Mythic Vale</span>
|
|
<span id="stats">0, 0, 0</span>
|
|
</div>
|
|
<div id="crosshair"></div>
|
|
<div id="hotbar" aria-label="block palette"></div>
|
|
</div>
|
|
<div id="veil"><button>进入世界</button></div>
|
|
|
|
<script type="module">
|
|
import * as THREE from "three";
|
|
|
|
const CHUNK = 16;
|
|
const VIEW_RADIUS = 3;
|
|
const WORLD_SEED = 424242;
|
|
const SEA_LEVEL = 18;
|
|
const MAX_RAY_DISTANCE = 7;
|
|
const PLAYER_HEIGHT = 1.74;
|
|
const PLAYER_RADIUS = 0.33;
|
|
const GRAVITY = 28;
|
|
const JUMP_SPEED = 8.4;
|
|
|
|
const Block = Object.freeze({
|
|
grass: 1,
|
|
dirt: 2,
|
|
stone: 3,
|
|
marble: 4,
|
|
obsidian: 5,
|
|
crystal: 6,
|
|
wood: 7,
|
|
leaves: 8,
|
|
rune: 9,
|
|
water: 10
|
|
});
|
|
|
|
const selectableBlocks = [
|
|
Block.grass,
|
|
Block.dirt,
|
|
Block.stone,
|
|
Block.marble,
|
|
Block.obsidian,
|
|
Block.crystal,
|
|
Block.wood,
|
|
Block.leaves
|
|
];
|
|
|
|
const blockMeta = {
|
|
[Block.grass]: { name: "Grass", color: "#5baa35", solid: true },
|
|
[Block.dirt]: { name: "Dirt", color: "#7a5531", solid: true },
|
|
[Block.stone]: { name: "Stone", color: "#8b8b84", solid: true },
|
|
[Block.marble]: { name: "Marble", color: "#d9d3bf", solid: true },
|
|
[Block.obsidian]: { name: "Obsidian", color: "#252038", solid: true },
|
|
[Block.crystal]: { name: "Crystal", color: "#76f0ff", solid: true, emissive: true },
|
|
[Block.wood]: { name: "Wood", color: "#8a572c", solid: true },
|
|
[Block.leaves]: { name: "Leaves", color: "#2f7d4a", solid: true },
|
|
[Block.rune]: { name: "Runestone", color: "#ddb86a", solid: true, emissive: true },
|
|
[Block.water]: { name: "Water", color: "#376d9d", solid: false, transparent: true }
|
|
};
|
|
|
|
class Perlin2D {
|
|
constructor(seed) {
|
|
this.p = new Uint8Array(512);
|
|
const src = new Uint8Array(256);
|
|
for (let i = 0; i < 256; i++) src[i] = i;
|
|
let s = seed >>> 0;
|
|
const rand = () => {
|
|
s = (s * 1664525 + 1013904223) >>> 0;
|
|
return s / 4294967296;
|
|
};
|
|
for (let i = 255; i >= 0; i--) {
|
|
const j = Math.floor(rand() * (i + 1));
|
|
const tmp = src[i];
|
|
src[i] = src[j];
|
|
src[j] = tmp;
|
|
}
|
|
for (let i = 0; i < 512; i++) this.p[i] = src[i & 255];
|
|
}
|
|
|
|
fade(t) {
|
|
return t * t * t * (t * (t * 6 - 15) + 10);
|
|
}
|
|
|
|
lerp(t, a, b) {
|
|
return a + t * (b - a);
|
|
}
|
|
|
|
grad(hash, x, y) {
|
|
const h = hash & 7;
|
|
const u = h < 4 ? x : y;
|
|
const v = h < 4 ? y : x;
|
|
return ((h & 1) ? -u : u) + ((h & 2) ? -2 * v : 2 * v);
|
|
}
|
|
|
|
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];
|
|
const x1 = this.lerp(u, this.grad(aa, xf, yf), this.grad(ba, xf - 1, yf));
|
|
const x2 = this.lerp(u, this.grad(ab, xf, yf - 1), this.grad(bb, xf - 1, yf - 1));
|
|
return Math.max(-1, Math.min(1, this.lerp(v, x1, x2) * 0.48));
|
|
}
|
|
|
|
fbm(x, y, octaves = 5, lacunarity = 2, gain = 0.5) {
|
|
let amp = 0.5;
|
|
let freq = 1;
|
|
let value = 0;
|
|
let norm = 0;
|
|
for (let i = 0; i < octaves; i++) {
|
|
value += amp * this.noise(x * freq, y * freq);
|
|
norm += amp;
|
|
amp *= gain;
|
|
freq *= lacunarity;
|
|
}
|
|
return value / norm;
|
|
}
|
|
}
|
|
|
|
const noise = new Perlin2D(WORLD_SEED);
|
|
const structureNoise = new Perlin2D(WORLD_SEED ^ 0x9e3779b9);
|
|
|
|
const scene = new THREE.Scene();
|
|
scene.background = new THREE.Color(0x9fc9ef);
|
|
scene.fog = new THREE.FogExp2(0xb4d2f0, 0.011);
|
|
|
|
const camera = new THREE.PerspectiveCamera(76, window.innerWidth / window.innerHeight, 0.05, 650);
|
|
camera.rotation.order = "YXZ";
|
|
|
|
const renderer = new THREE.WebGLRenderer({ antialias: false, powerPreference: "high-performance" });
|
|
renderer.setPixelRatio(Math.min(devicePixelRatio, 1.6));
|
|
renderer.setSize(window.innerWidth, window.innerHeight);
|
|
renderer.shadowMap.enabled = true;
|
|
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
|
document.body.appendChild(renderer.domElement);
|
|
|
|
const hemi = new THREE.HemisphereLight(0xdceeff, 0x263125, 2.2);
|
|
scene.add(hemi);
|
|
|
|
const sun = new THREE.DirectionalLight(0xfff0c2, 2.1);
|
|
sun.position.set(80, 105, 30);
|
|
sun.castShadow = true;
|
|
sun.shadow.mapSize.set(2048, 2048);
|
|
sun.shadow.camera.near = 10;
|
|
sun.shadow.camera.far = 240;
|
|
sun.shadow.camera.left = -90;
|
|
sun.shadow.camera.right = 90;
|
|
sun.shadow.camera.top = 90;
|
|
sun.shadow.camera.bottom = -90;
|
|
scene.add(sun);
|
|
scene.add(sun.target);
|
|
|
|
const moon = new THREE.DirectionalLight(0x80a8ff, 0.35);
|
|
moon.position.set(-50, 60, -80);
|
|
scene.add(moon);
|
|
|
|
const geometry = new THREE.BoxGeometry(1, 1, 1);
|
|
const tmpMatrix = new THREE.Matrix4();
|
|
const tmpColor = new THREE.Color();
|
|
const chunks = new Map();
|
|
const edits = new Map();
|
|
const raycaster = new THREE.Raycaster();
|
|
const pointer = new THREE.Vector2(0, 0);
|
|
|
|
function makeTexture(kind, base, accent) {
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = 32;
|
|
canvas.height = 32;
|
|
const ctx = canvas.getContext("2d");
|
|
const image = ctx.createImageData(32, 32);
|
|
const a = new THREE.Color(base);
|
|
const b = new THREE.Color(accent || base);
|
|
|
|
for (let y = 0; y < 32; y++) {
|
|
for (let x = 0; x < 32; x++) {
|
|
const i = (y * 32 + x) * 4;
|
|
const n = hash01(x * 17 + y * 101 + kind.length * 997);
|
|
const c = a.clone();
|
|
if (kind === "grassTop") {
|
|
c.lerp(b, n * 0.45);
|
|
if ((x + y + Math.floor(n * 7)) % 9 === 0) c.offsetHSL(0.02, 0.12, 0.08);
|
|
} else if (kind === "grassSide") {
|
|
if (y < 9 + n * 4) c.set("#58a83a").lerp(new THREE.Color("#8fe15c"), n * 0.25);
|
|
else c.set("#765033").lerp(new THREE.Color("#4b351e"), n * 0.35);
|
|
} else if (kind === "stone") {
|
|
const vein = Math.abs(noise.noise(x * 0.17, y * 0.17));
|
|
c.offsetHSL(0, 0, (n - 0.5) * 0.13 + vein * 0.1);
|
|
} else if (kind === "marble") {
|
|
const vein = Math.sin(x * 0.45 + y * 0.22 + n * 4);
|
|
c.lerp(new THREE.Color("#b7b1a5"), Math.max(0, vein) * 0.28);
|
|
} else if (kind === "obsidian") {
|
|
c.lerp(new THREE.Color("#4a3c6e"), n * 0.35);
|
|
if ((x * 3 + y * 5) % 29 === 0) c.set("#7564aa");
|
|
} else if (kind === "crystal") {
|
|
const glow = 0.3 + 0.7 * Math.abs(Math.sin((x - y) * 0.4));
|
|
c.set("#4ae6ff").lerp(new THREE.Color("#f4fdff"), glow * 0.55);
|
|
} else if (kind === "wood") {
|
|
const ring = Math.sin(x * 0.65 + Math.sin(y * 0.25) * 2);
|
|
c.lerp(new THREE.Color("#5b3219"), Math.max(0, ring) * 0.38 + n * 0.12);
|
|
} else if (kind === "leaves") {
|
|
c.lerp(new THREE.Color("#5fbf60"), n * 0.35);
|
|
if ((x + y * 2) % 11 === 0) c.offsetHSL(0.04, 0.2, 0.1);
|
|
} else if (kind === "rune") {
|
|
c.lerp(new THREE.Color("#ffdb85"), n * 0.25);
|
|
if ((x === 14 || x === 17 || y === 15) && y > 6 && y < 25) c.set("#fff4a8");
|
|
} else if (kind === "water") {
|
|
c.lerp(new THREE.Color("#78c8ff"), n * 0.35);
|
|
} else {
|
|
c.offsetHSL(0, 0, (n - 0.5) * 0.16);
|
|
}
|
|
image.data[i] = Math.round(c.r * 255);
|
|
image.data[i + 1] = Math.round(c.g * 255);
|
|
image.data[i + 2] = Math.round(c.b * 255);
|
|
image.data[i + 3] = 255;
|
|
}
|
|
}
|
|
ctx.putImageData(image, 0, 0);
|
|
const tex = new THREE.CanvasTexture(canvas);
|
|
tex.colorSpace = THREE.SRGBColorSpace;
|
|
tex.magFilter = THREE.NearestFilter;
|
|
tex.minFilter = THREE.NearestFilter;
|
|
tex.generateMipmaps = false;
|
|
return tex;
|
|
}
|
|
|
|
function material(tex, options = {}) {
|
|
return new THREE.MeshLambertMaterial({
|
|
map: tex,
|
|
transparent: Boolean(options.transparent),
|
|
opacity: options.opacity ?? 1,
|
|
emissive: options.emissive || 0x000000,
|
|
emissiveIntensity: options.emissiveIntensity || 0
|
|
});
|
|
}
|
|
|
|
const textures = {
|
|
grassTop: makeTexture("grassTop", "#4d9c30", "#8dd55a"),
|
|
grassSide: makeTexture("grassSide", "#765033", "#5eb63d"),
|
|
dirt: makeTexture("dirt", "#7b5430", "#51351d"),
|
|
stone: makeTexture("stone", "#85857f", "#b7b7ad"),
|
|
marble: makeTexture("marble", "#ddd7c8", "#a99f90"),
|
|
obsidian: makeTexture("obsidian", "#171324", "#54467b"),
|
|
crystal: makeTexture("crystal", "#4ee4ff", "#e8feff"),
|
|
wood: makeTexture("wood", "#87532a", "#4d2a16"),
|
|
leaves: makeTexture("leaves", "#2e7a45", "#6abd58"),
|
|
rune: makeTexture("rune", "#b78945", "#ffed9a"),
|
|
water: makeTexture("water", "#356e9d", "#81d0ff")
|
|
};
|
|
|
|
const materials = {
|
|
[Block.grass]: [
|
|
material(textures.grassSide),
|
|
material(textures.grassSide),
|
|
material(textures.grassTop),
|
|
material(textures.dirt),
|
|
material(textures.grassSide),
|
|
material(textures.grassSide)
|
|
],
|
|
[Block.dirt]: Array(6).fill(null).map(() => material(textures.dirt)),
|
|
[Block.stone]: Array(6).fill(null).map(() => material(textures.stone)),
|
|
[Block.marble]: Array(6).fill(null).map(() => material(textures.marble)),
|
|
[Block.obsidian]: Array(6).fill(null).map(() => material(textures.obsidian)),
|
|
[Block.crystal]: Array(6).fill(null).map(() => material(textures.crystal, { emissive: 0x1fdfff, emissiveIntensity: 0.35 })),
|
|
[Block.wood]: Array(6).fill(null).map(() => material(textures.wood)),
|
|
[Block.leaves]: Array(6).fill(null).map(() => material(textures.leaves)),
|
|
[Block.rune]: Array(6).fill(null).map(() => material(textures.rune, { emissive: 0xffbd45, emissiveIntensity: 0.28 })),
|
|
[Block.water]: Array(6).fill(null).map(() => material(textures.water, { transparent: true, opacity: 0.58 }))
|
|
};
|
|
|
|
const player = {
|
|
position: new THREE.Vector3(0, 60, 0),
|
|
velocity: new THREE.Vector3(),
|
|
yaw: 0,
|
|
pitch: 0,
|
|
onGround: false,
|
|
flying: false
|
|
};
|
|
|
|
const keys = new Set();
|
|
let selectedIndex = 0;
|
|
let selectedBlock = selectableBlocks[selectedIndex];
|
|
let lastFrame = performance.now();
|
|
let accumulator = 0;
|
|
|
|
const hotbar = document.getElementById("hotbar");
|
|
const stats = document.getElementById("stats");
|
|
const biomeLabel = document.getElementById("biome");
|
|
const veil = document.getElementById("veil");
|
|
|
|
for (let i = 0; i < selectableBlocks.length; i++) {
|
|
const type = selectableBlocks[i];
|
|
const slot = document.createElement("div");
|
|
slot.className = "slot" + (i === selectedIndex ? " selected" : "");
|
|
slot.dataset.key = String(i + 1);
|
|
slot.title = blockMeta[type].name;
|
|
const swatch = document.createElement("div");
|
|
swatch.className = "swatch";
|
|
swatch.style.setProperty("--c", blockMeta[type].color);
|
|
slot.appendChild(swatch);
|
|
hotbar.appendChild(slot);
|
|
}
|
|
|
|
function updateHotbar() {
|
|
[...hotbar.children].forEach((slot, i) => slot.classList.toggle("selected", i === selectedIndex));
|
|
selectedBlock = selectableBlocks[selectedIndex];
|
|
}
|
|
|
|
function hashInt(x, z, salt = 0) {
|
|
let h = (x * 374761393 + z * 668265263 + salt * 2147483647 + WORLD_SEED) >>> 0;
|
|
h = (h ^ (h >>> 13)) >>> 0;
|
|
h = Math.imul(h, 1274126177) >>> 0;
|
|
return (h ^ (h >>> 16)) >>> 0;
|
|
}
|
|
|
|
function hash01(n) {
|
|
n = (n ^ 61) ^ (n >>> 16);
|
|
n = Math.imul(n, 9);
|
|
n = n ^ (n >>> 4);
|
|
n = Math.imul(n, 0x27d4eb2d);
|
|
n = n ^ (n >>> 15);
|
|
return (n >>> 0) / 4294967295;
|
|
}
|
|
|
|
function randCell(cx, cz, salt = 0) {
|
|
return hashInt(cx, cz, salt) / 4294967295;
|
|
}
|
|
|
|
function divFloor(n, d) {
|
|
return Math.floor(n / d);
|
|
}
|
|
|
|
function keyOf(x, y, z) {
|
|
return `${x},${y},${z}`;
|
|
}
|
|
|
|
function chunkKey(cx, cz) {
|
|
return `${cx},${cz}`;
|
|
}
|
|
|
|
function localIndex(x, y, z) {
|
|
return `${x & (CHUNK - 1)},${y},${z & (CHUNK - 1)}`;
|
|
}
|
|
|
|
function biomeAt(x, z) {
|
|
const n = noise.fbm(x * 0.003, z * 0.003, 4);
|
|
const r = structureNoise.fbm(x * 0.008 + 100, z * 0.008 - 50, 3);
|
|
if (n > 0.35) return { id: "crag", label: "Star Crags" };
|
|
if (n < -0.34) return { id: "fen", label: "Moon Fen" };
|
|
if (r > 0.38) return { id: "grove", label: "Elder Grove" };
|
|
if (r < -0.4) return { id: "ruin", label: "Rune Waste" };
|
|
return { id: "vale", label: "Mythic Vale" };
|
|
}
|
|
|
|
function terrainHeight(x, z) {
|
|
const warpX = noise.fbm(x * 0.006, z * 0.006, 3) * 30;
|
|
const warpZ = noise.fbm((x + 800) * 0.006, (z - 600) * 0.006, 3) * 30;
|
|
const plains = noise.fbm((x + warpX) * 0.012, (z + warpZ) * 0.012, 5) * 15;
|
|
const ridges = (1 - Math.abs(noise.fbm((x - 200) * 0.026, (z + 120) * 0.026, 4))) * 11;
|
|
const terraces = Math.floor((plains + ridges) * 0.52) * 1.5;
|
|
let height = 24 + plains + ridges + terraces;
|
|
const biome = biomeAt(x, z).id;
|
|
if (biome === "crag") height += 12 + Math.max(0, ridges) * 1.3;
|
|
if (biome === "fen") height -= 8;
|
|
if (biome === "ruin") height += 3;
|
|
return Math.max(4, Math.min(78, Math.floor(height)));
|
|
}
|
|
|
|
function baseBlockAt(x, y, z) {
|
|
const h = terrainHeight(x, z);
|
|
const biome = biomeAt(x, z).id;
|
|
if (y <= h) {
|
|
if (y === h) {
|
|
if (biome === "crag" && h > 38) return Block.stone;
|
|
if (biome === "ruin") return randCell(divFloor(x, 7), divFloor(z, 7), 16) > 0.8 ? Block.rune : Block.stone;
|
|
if (biome === "fen" && h <= SEA_LEVEL + 1) return Block.dirt;
|
|
return Block.grass;
|
|
}
|
|
if (y > h - 4) return biome === "crag" ? Block.stone : Block.dirt;
|
|
if (y < 7 && randCell(x, z, y) > 0.93) return Block.obsidian;
|
|
return Block.stone;
|
|
}
|
|
if (y <= SEA_LEVEL && biome === "fen") return Block.water;
|
|
return 0;
|
|
}
|
|
|
|
function putLocal(map, cx, cz, x, y, z, type) {
|
|
if (divFloor(x, CHUNK) !== cx || divFloor(z, CHUNK) !== cz || y < 0 || y > 130) return;
|
|
map.set(localIndex(x, y, z), type);
|
|
}
|
|
|
|
function fillBox(map, cx, cz, x1, y1, z1, x2, y2, z2, type) {
|
|
for (let x = x1; x <= x2; x++) {
|
|
for (let y = y1; y <= y2; y++) {
|
|
for (let z = z1; z <= z2; z++) putLocal(map, cx, cz, x, y, z, type);
|
|
}
|
|
}
|
|
}
|
|
|
|
function buildCylinder(map, cx, cz, x0, y0, z0, radius, height, wallType, fillType = 0) {
|
|
const r2 = radius * radius;
|
|
const inner = Math.max(0, radius - 1.2);
|
|
const inner2 = inner * inner;
|
|
for (let x = Math.floor(x0 - radius); x <= Math.ceil(x0 + radius); x++) {
|
|
for (let z = Math.floor(z0 - radius); z <= Math.ceil(z0 + radius); z++) {
|
|
const d2 = (x - x0) * (x - x0) + (z - z0) * (z - z0);
|
|
if (d2 > r2) continue;
|
|
for (let y = y0; y < y0 + height; y++) {
|
|
if (d2 >= inner2 || fillType) putLocal(map, cx, cz, x, y, z, d2 >= inner2 ? wallType : fillType);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function buildTower(map, cx, cz, x0, z0) {
|
|
const ground = terrainHeight(x0, z0);
|
|
const height = 18 + Math.floor(randCell(x0, z0, 3) * 18);
|
|
buildCylinder(map, cx, cz, x0, ground + 1, z0, 5, height, Block.obsidian, 0);
|
|
buildCylinder(map, cx, cz, x0, ground, z0, 7, 2, Block.marble, Block.marble);
|
|
for (let y = ground + 4; y < ground + height; y += 5) {
|
|
putLocal(map, cx, cz, x0 + 5, y, z0, Block.crystal);
|
|
putLocal(map, cx, cz, x0 - 5, y, z0, Block.crystal);
|
|
putLocal(map, cx, cz, x0, y, z0 + 5, Block.crystal);
|
|
putLocal(map, cx, cz, x0, y, z0 - 5, Block.crystal);
|
|
}
|
|
const top = ground + height + 1;
|
|
for (let r = 5; r >= 1; r--) buildCylinder(map, cx, cz, x0, top + (5 - r), z0, r, 1, Block.marble, Block.marble);
|
|
for (let y = top + 4; y <= top + 10; y++) {
|
|
const r = Math.max(1, 4 - Math.abs(y - (top + 7)));
|
|
buildCylinder(map, cx, cz, x0, y, z0, r, 1, Block.crystal, Block.crystal);
|
|
}
|
|
for (let i = -9; i <= 9; i++) {
|
|
putLocal(map, cx, cz, x0 + i, ground + 5 + Math.floor(Math.abs(i) * 0.25), z0 + 8, Block.rune);
|
|
putLocal(map, cx, cz, x0 + 8, ground + 5 + Math.floor(Math.abs(i) * 0.25), z0 + i, Block.rune);
|
|
}
|
|
}
|
|
|
|
function buildTemple(map, cx, cz, x0, z0) {
|
|
const ground = terrainHeight(x0, z0);
|
|
fillBox(map, cx, cz, x0 - 14, ground + 1, z0 - 10, x0 + 14, ground + 3, z0 + 10, Block.marble);
|
|
fillBox(map, cx, cz, x0 - 10, ground + 4, z0 - 6, x0 + 10, ground + 4, z0 + 6, Block.rune);
|
|
for (const sx of [-11, -5, 5, 11]) {
|
|
for (const sz of [-8, 8]) {
|
|
buildCylinder(map, cx, cz, x0 + sx, ground + 4, z0 + sz, 2, 9, Block.marble, Block.marble);
|
|
putLocal(map, cx, cz, x0 + sx, ground + 13, z0 + sz, Block.crystal);
|
|
}
|
|
}
|
|
fillBox(map, cx, cz, x0 - 13, ground + 13, z0 - 9, x0 + 13, ground + 14, z0 + 9, Block.obsidian);
|
|
for (let i = -12; i <= 12; i++) {
|
|
putLocal(map, cx, cz, x0 + i, ground + 15 + Math.floor(Math.abs(i) * 0.18), z0 - 9, Block.marble);
|
|
putLocal(map, cx, cz, x0 + i, ground + 15 + Math.floor(Math.abs(i) * 0.18), z0 + 9, Block.marble);
|
|
}
|
|
buildCylinder(map, cx, cz, x0, ground + 5, z0, 3, 4, Block.crystal, Block.crystal);
|
|
}
|
|
|
|
function buildWorldTree(map, cx, cz, x0, z0) {
|
|
const ground = terrainHeight(x0, z0);
|
|
const trunkHeight = 19 + Math.floor(randCell(x0, z0, 4) * 12);
|
|
buildCylinder(map, cx, cz, x0, ground + 1, z0, 3, trunkHeight, Block.wood, Block.wood);
|
|
for (let y = ground + 8; y < ground + trunkHeight; y += 5) {
|
|
for (const dir of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
|
|
const len = 8 + Math.floor(randCell(x0 + y, z0, dir[0] + dir[1] + 9) * 7);
|
|
for (let i = 1; i <= len; i++) {
|
|
const bx = x0 + dir[0] * i;
|
|
const bz = z0 + dir[1] * i;
|
|
putLocal(map, cx, cz, bx, y + Math.floor(i * 0.22), bz, Block.wood);
|
|
}
|
|
}
|
|
}
|
|
const crownY = ground + trunkHeight;
|
|
for (let x = x0 - 12; x <= x0 + 12; x++) {
|
|
for (let z = z0 - 12; z <= z0 + 12; z++) {
|
|
for (let y = crownY - 6; y <= crownY + 8; y++) {
|
|
const dx = (x - x0) / 12;
|
|
const dz = (z - z0) / 12;
|
|
const dy = (y - crownY) / 8;
|
|
const d = dx * dx + dz * dz + dy * dy;
|
|
if (d < 1.05 && randCell(x, z, y) > 0.17) putLocal(map, cx, cz, x, y, z, Block.leaves);
|
|
}
|
|
}
|
|
}
|
|
for (let i = 0; i < 7; i++) {
|
|
const angle = i * Math.PI * 2 / 7;
|
|
const x = x0 + Math.round(Math.cos(angle) * 8);
|
|
const z = z0 + Math.round(Math.sin(angle) * 8);
|
|
putLocal(map, cx, cz, x, crownY - 2 - i % 3, z, Block.crystal);
|
|
}
|
|
}
|
|
|
|
function buildCrystalCluster(map, cx, cz, x0, z0) {
|
|
const ground = terrainHeight(x0, z0);
|
|
for (let i = 0; i < 10; i++) {
|
|
const ox = Math.floor((randCell(x0, z0, i) - 0.5) * 16);
|
|
const oz = Math.floor((randCell(x0, z0, i + 20) - 0.5) * 16);
|
|
const h = 3 + Math.floor(randCell(x0, z0, i + 40) * 9);
|
|
for (let y = 1; y <= h; y++) {
|
|
const r = Math.max(1, Math.floor((h - y) / 3));
|
|
buildCylinder(map, cx, cz, x0 + ox, ground + y, z0 + oz, r, 1, Block.crystal, Block.crystal);
|
|
}
|
|
}
|
|
}
|
|
|
|
function buildFloatingIsland(map, cx, cz, x0, z0) {
|
|
const baseY = 64 + Math.floor(randCell(x0, z0, 12) * 28);
|
|
const rx = 16 + Math.floor(randCell(x0, z0, 13) * 10);
|
|
const rz = 16 + Math.floor(randCell(x0, z0, 14) * 10);
|
|
for (let x = x0 - rx; x <= x0 + rx; x++) {
|
|
for (let z = z0 - rz; z <= z0 + rz; z++) {
|
|
const dx = (x - x0) / rx;
|
|
const dz = (z - z0) / rz;
|
|
const d = dx * dx + dz * dz;
|
|
if (d > 1) continue;
|
|
const dome = Math.floor((1 - d) * 9 + noise.fbm(x * 0.1, z * 0.1, 3) * 2);
|
|
for (let y = baseY - dome; y <= baseY + 2; y++) {
|
|
const type = y === baseY + 2 ? Block.grass : (y > baseY - 2 ? Block.dirt : Block.stone);
|
|
putLocal(map, cx, cz, x, y, z, type);
|
|
}
|
|
}
|
|
}
|
|
buildCylinder(map, cx, cz, x0, baseY + 3, z0, 4, 8, Block.marble, 0);
|
|
for (let i = 0; i < 4; i++) {
|
|
const angle = i * Math.PI / 2 + Math.PI / 4;
|
|
const px = x0 + Math.round(Math.cos(angle) * 8);
|
|
const pz = z0 + Math.round(Math.sin(angle) * 8);
|
|
buildCylinder(map, cx, cz, px, baseY + 3, pz, 2, 7, Block.rune, Block.rune);
|
|
}
|
|
buildCylinder(map, cx, cz, x0, baseY + 11, z0, 5, 1, Block.crystal, Block.crystal);
|
|
}
|
|
|
|
function buildStructuresForChunk(blocks, cx, cz) {
|
|
const minX = cx * CHUNK;
|
|
const minZ = cz * CHUNK;
|
|
const cellSize = 64;
|
|
const startCellX = divFloor(minX - 40, cellSize);
|
|
const endCellX = divFloor(minX + CHUNK + 40, cellSize);
|
|
const startCellZ = divFloor(minZ - 40, cellSize);
|
|
const endCellZ = divFloor(minZ + CHUNK + 40, cellSize);
|
|
|
|
for (let gx = startCellX; gx <= endCellX; gx++) {
|
|
for (let gz = startCellZ; gz <= endCellZ; gz++) {
|
|
const roll = randCell(gx, gz, 99);
|
|
const sx = gx * cellSize + 12 + Math.floor(randCell(gx, gz, 1) * (cellSize - 24));
|
|
const sz = gz * cellSize + 12 + Math.floor(randCell(gx, gz, 2) * (cellSize - 24));
|
|
if (roll > 0.83) buildTower(blocks, cx, cz, sx, sz);
|
|
else if (roll > 0.68) buildTemple(blocks, cx, cz, sx, sz);
|
|
else if (roll > 0.53) buildWorldTree(blocks, cx, cz, sx, sz);
|
|
else if (roll > 0.39) buildCrystalCluster(blocks, cx, cz, sx, sz);
|
|
if (randCell(gx, gz, 33) > 0.78) buildFloatingIsland(blocks, cx, cz, sx + 18, sz - 18);
|
|
}
|
|
}
|
|
}
|
|
|
|
function generateChunkData(cx, cz) {
|
|
const blocks = new Map();
|
|
const minX = cx * CHUNK;
|
|
const minZ = cz * CHUNK;
|
|
for (let lx = 0; lx < CHUNK; lx++) {
|
|
for (let lz = 0; lz < CHUNK; lz++) {
|
|
const x = minX + lx;
|
|
const z = minZ + lz;
|
|
const h = terrainHeight(x, z);
|
|
for (let y = 0; y <= h; y++) blocks.set(`${lx},${y},${lz}`, baseBlockAt(x, y, z));
|
|
const biome = biomeAt(x, z).id;
|
|
if (biome === "fen") {
|
|
for (let y = h + 1; y <= SEA_LEVEL; y++) blocks.set(`${lx},${y},${lz}`, Block.water);
|
|
}
|
|
if (h > 46 && randCell(x, z, 5) > 0.987) {
|
|
for (let y = h + 1; y < h + 5; y++) blocks.set(`${lx},${y},${lz}`, Block.crystal);
|
|
}
|
|
}
|
|
}
|
|
buildStructuresForChunk(blocks, cx, cz);
|
|
for (const [worldKey, type] of edits) {
|
|
const [x, y, z] = worldKey.split(",").map(Number);
|
|
if (divFloor(x, CHUNK) === cx && divFloor(z, CHUNK) === cz) {
|
|
const lk = localIndex(x, y, z);
|
|
if (type) blocks.set(lk, type);
|
|
else blocks.delete(lk);
|
|
}
|
|
}
|
|
return blocks;
|
|
}
|
|
|
|
function getChunk(cx, cz) {
|
|
return chunks.get(chunkKey(cx, cz));
|
|
}
|
|
|
|
function getBlock(x, y, z) {
|
|
if (y < 0 || y > 140) return 0;
|
|
const edited = edits.get(keyOf(x, y, z));
|
|
if (edited !== undefined) return edited;
|
|
const cx = divFloor(x, CHUNK);
|
|
const cz = divFloor(z, CHUNK);
|
|
const chunk = getChunk(cx, cz);
|
|
if (chunk) return chunk.blocks.get(localIndex(x, y, z)) || 0;
|
|
return baseBlockAt(x, y, z);
|
|
}
|
|
|
|
function isSolid(type) {
|
|
return type && blockMeta[type]?.solid;
|
|
}
|
|
|
|
function isVisibleBlock(x, y, z, type) {
|
|
if (type === Block.water) return true;
|
|
const neighbors = [
|
|
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)
|
|
];
|
|
return neighbors.some((n) => !isSolid(n) || n === Block.water);
|
|
}
|
|
|
|
function disposeChunk(chunk) {
|
|
for (const mesh of chunk.meshes) {
|
|
scene.remove(mesh);
|
|
mesh.geometry.dispose();
|
|
}
|
|
chunks.delete(chunk.key);
|
|
}
|
|
|
|
function rebuildChunk(cx, cz) {
|
|
const key = chunkKey(cx, cz);
|
|
const old = chunks.get(key);
|
|
const blocks = old?.blocks || generateChunkData(cx, cz);
|
|
if (old) {
|
|
for (const mesh of old.meshes) {
|
|
scene.remove(mesh);
|
|
mesh.geometry.dispose();
|
|
}
|
|
}
|
|
|
|
const byType = new Map();
|
|
const minX = cx * CHUNK;
|
|
const minZ = cz * CHUNK;
|
|
for (const [lk, type] of blocks) {
|
|
const [lx, y, lz] = lk.split(",").map(Number);
|
|
const x = minX + lx;
|
|
const z = minZ + lz;
|
|
if (!isVisibleBlock(x, y, z, type)) continue;
|
|
if (!byType.has(type)) byType.set(type, []);
|
|
byType.get(type).push({ x, y, z, type });
|
|
}
|
|
|
|
const meshes = [];
|
|
for (const [type, instances] of byType) {
|
|
if (!instances.length) continue;
|
|
const mesh = new THREE.InstancedMesh(geometry.clone(), materials[type], instances.length);
|
|
mesh.castShadow = type !== Block.water;
|
|
mesh.receiveShadow = true;
|
|
mesh.frustumCulled = false;
|
|
mesh.userData.instances = instances;
|
|
mesh.userData.type = type;
|
|
for (let i = 0; i < instances.length; i++) {
|
|
const block = instances[i];
|
|
tmpMatrix.makeTranslation(block.x + 0.5, block.y + 0.5, block.z + 0.5);
|
|
mesh.setMatrixAt(i, tmpMatrix);
|
|
if (type === Block.grass) {
|
|
const v = 0.92 + hash01(block.x * 3 + block.z * 5 + block.y) * 0.18;
|
|
tmpColor.setRGB(v, v, v);
|
|
mesh.setColorAt(i, tmpColor);
|
|
}
|
|
}
|
|
mesh.instanceMatrix.needsUpdate = true;
|
|
if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true;
|
|
scene.add(mesh);
|
|
meshes.push(mesh);
|
|
}
|
|
chunks.set(key, { key, cx, cz, blocks, meshes });
|
|
}
|
|
|
|
function loadChunk(cx, cz) {
|
|
const key = chunkKey(cx, cz);
|
|
if (chunks.has(key)) return;
|
|
chunks.set(key, { key, cx, cz, blocks: generateChunkData(cx, cz), meshes: [] });
|
|
rebuildChunk(cx, cz);
|
|
}
|
|
|
|
function updateChunks() {
|
|
const pcx = divFloor(Math.floor(player.position.x), CHUNK);
|
|
const pcz = divFloor(Math.floor(player.position.z), CHUNK);
|
|
const needed = new Set();
|
|
for (let dz = -VIEW_RADIUS; dz <= VIEW_RADIUS; dz++) {
|
|
for (let dx = -VIEW_RADIUS; dx <= VIEW_RADIUS; dx++) {
|
|
const dist = Math.max(Math.abs(dx), Math.abs(dz));
|
|
if (dist > VIEW_RADIUS) continue;
|
|
const cx = pcx + dx;
|
|
const cz = pcz + dz;
|
|
needed.add(chunkKey(cx, cz));
|
|
loadChunk(cx, cz);
|
|
}
|
|
}
|
|
for (const [key, chunk] of [...chunks]) {
|
|
if (!needed.has(key)) disposeChunk(chunk);
|
|
}
|
|
}
|
|
|
|
function rebuildAround(x, z) {
|
|
const cx = divFloor(x, CHUNK);
|
|
const cz = divFloor(z, CHUNK);
|
|
for (let dz = -1; dz <= 1; dz++) {
|
|
for (let dx = -1; dx <= 1; dx++) {
|
|
if (chunks.has(chunkKey(cx + dx, cz + dz))) rebuildChunk(cx + dx, cz + dz);
|
|
}
|
|
}
|
|
}
|
|
|
|
function setBlock(x, y, z, type) {
|
|
const cx = divFloor(x, CHUNK);
|
|
const cz = divFloor(z, CHUNK);
|
|
const chunk = getChunk(cx, cz);
|
|
edits.set(keyOf(x, y, z), type || 0);
|
|
if (chunk) {
|
|
const lk = localIndex(x, y, z);
|
|
if (type) chunk.blocks.set(lk, type);
|
|
else chunk.blocks.delete(lk);
|
|
}
|
|
rebuildAround(x, z);
|
|
}
|
|
|
|
function castBlockRay() {
|
|
raycaster.setFromCamera(pointer, camera);
|
|
raycaster.far = MAX_RAY_DISTANCE;
|
|
const meshes = [];
|
|
for (const chunk of chunks.values()) meshes.push(...chunk.meshes);
|
|
const hits = raycaster.intersectObjects(meshes, false);
|
|
for (const hit of hits) {
|
|
const data = hit.object.userData.instances?.[hit.instanceId];
|
|
if (!data || hit.object.userData.type === Block.water) continue;
|
|
return { hit, block: data };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function playerAABB(pos = player.position) {
|
|
return {
|
|
minX: pos.x - PLAYER_RADIUS,
|
|
maxX: pos.x + PLAYER_RADIUS,
|
|
minY: pos.y - PLAYER_HEIGHT,
|
|
maxY: pos.y - 0.05,
|
|
minZ: pos.z - PLAYER_RADIUS,
|
|
maxZ: pos.z + PLAYER_RADIUS
|
|
};
|
|
}
|
|
|
|
function collides(pos) {
|
|
const box = playerAABB(pos);
|
|
for (let x = Math.floor(box.minX); x <= Math.floor(box.maxX); x++) {
|
|
for (let y = Math.floor(box.minY); y <= Math.floor(box.maxY); y++) {
|
|
for (let z = Math.floor(box.minZ); z <= Math.floor(box.maxZ); z++) {
|
|
if (isSolid(getBlock(x, y, z))) return true;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function wouldOverlapPlayer(x, y, z) {
|
|
const box = playerAABB();
|
|
return x + 1 > box.minX && x < box.maxX && y + 1 > box.minY && y < box.maxY && z + 1 > box.minZ && z < box.maxZ;
|
|
}
|
|
|
|
function moveAxis(axis, delta) {
|
|
if (!delta) return;
|
|
const next = player.position.clone();
|
|
next[axis] += delta;
|
|
if (!collides(next) || player.flying) {
|
|
player.position.copy(next);
|
|
} else {
|
|
if (axis === "y" && delta < 0) player.onGround = true;
|
|
if (axis === "y") player.velocity.y = 0;
|
|
}
|
|
}
|
|
|
|
function updateControls(dt) {
|
|
const forward = Number(keys.has("KeyW")) - Number(keys.has("KeyS"));
|
|
const strafe = Number(keys.has("KeyD")) - Number(keys.has("KeyA"));
|
|
const flyY = Number(keys.has("Space")) - Number(keys.has("ShiftLeft") || keys.has("ShiftRight"));
|
|
const speed = keys.has("ControlLeft") || keys.has("ControlRight") ? 10.5 : 6.0;
|
|
const direction = new THREE.Vector3();
|
|
const sin = Math.sin(player.yaw);
|
|
const cos = Math.cos(player.yaw);
|
|
direction.x = strafe * cos - forward * sin;
|
|
direction.z = -strafe * sin - forward * cos; // fix: z sign was flipped — the camera faces -Z at yaw 0, so the old `strafe*sin + forward*cos` mirrored all movement across the X axis (A/D swapped when facing +-X)
|
|
if (direction.lengthSq() > 0) direction.normalize().multiplyScalar(speed * dt);
|
|
|
|
player.onGround = false;
|
|
moveAxis("x", direction.x);
|
|
moveAxis("z", direction.z);
|
|
|
|
if (player.flying) {
|
|
moveAxis("y", flyY * speed * dt);
|
|
player.velocity.y = 0;
|
|
} else {
|
|
player.velocity.y -= GRAVITY * dt;
|
|
moveAxis("y", player.velocity.y * dt);
|
|
}
|
|
|
|
camera.position.copy(player.position);
|
|
camera.rotation.y = player.yaw;
|
|
camera.rotation.x = player.pitch;
|
|
}
|
|
|
|
function handleClick(button) {
|
|
if (!document.pointerLockElement) {
|
|
renderer.domElement.requestPointerLock();
|
|
return;
|
|
}
|
|
const result = castBlockRay();
|
|
if (!result) return;
|
|
const { hit, block } = result;
|
|
if (button === 0) {
|
|
setBlock(block.x, block.y, block.z, 0);
|
|
} else if (button === 2) {
|
|
const n = hit.face.normal.clone().transformDirection(hit.object.matrixWorld);
|
|
const x = block.x + Math.round(n.x);
|
|
const y = block.y + Math.round(n.y);
|
|
const z = block.z + Math.round(n.z);
|
|
if (!wouldOverlapPlayer(x, y, z) && !getBlock(x, y, z)) setBlock(x, y, z, selectedBlock);
|
|
}
|
|
}
|
|
|
|
function placePlayer() {
|
|
const h = terrainHeight(0, 0);
|
|
player.position.set(0.5, h + PLAYER_HEIGHT + 5, 0.5);
|
|
camera.position.copy(player.position);
|
|
}
|
|
|
|
function animate(now) {
|
|
requestAnimationFrame(animate);
|
|
const dt = Math.min(0.045, (now - lastFrame) / 1000);
|
|
lastFrame = now;
|
|
accumulator += dt;
|
|
|
|
updateControls(dt);
|
|
updateChunks();
|
|
|
|
if (accumulator > 0.16) {
|
|
accumulator = 0;
|
|
const x = Math.floor(player.position.x);
|
|
const y = Math.floor(player.position.y - PLAYER_HEIGHT);
|
|
const z = Math.floor(player.position.z);
|
|
const biome = biomeAt(x, z);
|
|
biomeLabel.textContent = biome.label;
|
|
stats.textContent = `${x}, ${y}, ${z} | ${chunks.size} chunks`;
|
|
sun.position.set(player.position.x + 80, 105, player.position.z + 30);
|
|
sun.target?.position.set(player.position.x, player.position.y, player.position.z);
|
|
}
|
|
|
|
renderer.render(scene, camera);
|
|
}
|
|
|
|
window.addEventListener("resize", () => {
|
|
camera.aspect = window.innerWidth / window.innerHeight;
|
|
camera.updateProjectionMatrix();
|
|
renderer.setSize(window.innerWidth, window.innerHeight);
|
|
});
|
|
|
|
window.addEventListener("keydown", (event) => {
|
|
keys.add(event.code);
|
|
if (/^Digit[1-8]$/.test(event.code)) {
|
|
selectedIndex = Number(event.code.slice(5)) - 1;
|
|
updateHotbar();
|
|
}
|
|
if (event.code === "KeyF") player.flying = !player.flying;
|
|
if (event.code === "Space" && player.onGround && !player.flying) {
|
|
player.velocity.y = JUMP_SPEED;
|
|
player.onGround = false;
|
|
}
|
|
});
|
|
|
|
window.addEventListener("keyup", (event) => keys.delete(event.code));
|
|
|
|
window.addEventListener("wheel", (event) => {
|
|
selectedIndex = (selectedIndex + (event.deltaY > 0 ? 1 : -1) + selectableBlocks.length) % selectableBlocks.length;
|
|
updateHotbar();
|
|
}, { passive: true });
|
|
|
|
window.addEventListener("mousemove", (event) => {
|
|
if (!document.pointerLockElement) return;
|
|
player.yaw -= event.movementX * 0.0023;
|
|
player.pitch -= event.movementY * 0.0023;
|
|
player.pitch = Math.max(-Math.PI / 2 + 0.01, Math.min(Math.PI / 2 - 0.01, player.pitch));
|
|
});
|
|
|
|
renderer.domElement.addEventListener("mousedown", (event) => handleClick(event.button));
|
|
renderer.domElement.addEventListener("contextmenu", (event) => event.preventDefault());
|
|
veil.addEventListener("click", () => renderer.domElement.requestPointerLock());
|
|
|
|
document.addEventListener("pointerlockchange", () => {
|
|
veil.classList.toggle("hidden", Boolean(document.pointerLockElement));
|
|
});
|
|
|
|
placePlayer();
|
|
updateChunks();
|
|
requestAnimationFrame(animate);
|
|
</script>
|
|
</body>
|
|
</html>
|