Skip to content

Instantly share code, notes, and snippets.

@senko
Created July 11, 2026 14:14
Show Gist options
  • Select an option

  • Save senko/fd521362f55a0ff5396228377e2ee204 to your computer and use it in GitHub Desktop.

Select an option

Save senko/fd521362f55a0ff5396228377e2ee204 to your computer and use it in GitHub Desktop.
Minecraft clone by GPT-5.5 Sol
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Solarcraft — Tiny Voxel Adventure</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='4' fill='%23101a2a'/%3E%3Cpath d='M16 3 29 16 16 29 3 16Z' fill='%23ffd35a'/%3E%3Cpath d='m16 8 8 8-8 8-8-8Z' fill='%23f3a72a'/%3E%3C/svg%3E" />
<style>
:root {
--ink: #f8fbff;
--muted: #c4cddd;
--panel: rgba(9, 15, 25, 0.78);
--panel-solid: #101a2a;
--line: rgba(255, 255, 255, 0.14);
--sun: #ffd35a;
--green: #72e58c;
--red: #ff6377;
--cyan: #62d8ff;
--shadow: 0 18px 50px rgba(0, 0, 0, 0.38);
}
* { box-sizing: border-box; }
html, body {
width: 100%;
height: 100%;
margin: 0;
overflow: hidden;
background: #08101d;
color: var(--ink);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
user-select: none;
}
button, input { font: inherit; }
#game {
position: fixed;
inset: 0;
background: radial-gradient(circle at 50% 35%, #9fe3ff 0, #548fc0 48%, #15253d 100%);
}
canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
image-rendering: pixelated;
outline: none;
}
#hud {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 4;
opacity: 0;
transition: opacity 260ms ease;
}
#hud.visible { opacity: 1; }
.glass {
border: 1px solid var(--line);
background: linear-gradient(145deg, rgba(20, 31, 49, 0.88), rgba(8, 14, 24, 0.74));
box-shadow: var(--shadow), inset 0 1px rgba(255, 255, 255, 0.05);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
#brand {
position: absolute;
top: 22px;
left: 24px;
display: flex;
align-items: center;
gap: 12px;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.45);
}
.brand-mark {
width: 39px;
height: 39px;
position: relative;
transform: rotate(45deg);
border: 2px solid rgba(255, 255, 255, 0.8);
background: linear-gradient(135deg, #fff5ae, #ffb527 65%, #e86f21);
box-shadow: 0 0 25px rgba(255, 197, 66, 0.45);
}
.brand-mark::after {
content: "";
position: absolute;
inset: 8px;
border: 2px solid rgba(75, 36, 9, 0.48);
}
.brand-copy strong {
display: block;
font-size: 18px;
line-height: 1;
letter-spacing: 0.18em;
font-weight: 900;
}
.brand-copy span {
display: block;
margin-top: 5px;
color: rgba(255, 255, 255, 0.7);
font: 700 10px/1 ui-monospace, SFMono-Regular, Menlo, monospace;
letter-spacing: 0.16em;
text-transform: uppercase;
}
#world-info {
position: absolute;
left: 24px;
top: 84px;
display: flex;
gap: 7px;
}
.chip {
padding: 7px 10px;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 4px;
background: rgba(7, 12, 20, 0.56);
color: #dce7f8;
font: 700 10px/1 ui-monospace, SFMono-Regular, Menlo, monospace;
letter-spacing: 0.06em;
box-shadow: 0 5px 18px rgba(0, 0, 0, 0.2);
}
#mission {
position: absolute;
top: 22px;
right: 24px;
width: min(310px, calc(100vw - 48px));
padding: 15px 16px 14px;
border-radius: 6px;
}
.eyebrow {
color: var(--sun);
font: 800 9px/1 ui-monospace, SFMono-Regular, Menlo, monospace;
letter-spacing: 0.2em;
text-transform: uppercase;
}
#mission-title {
margin-top: 8px;
font-weight: 850;
font-size: 15px;
letter-spacing: -0.01em;
}
#mission-copy {
margin-top: 5px;
color: var(--muted);
font-size: 11px;
line-height: 1.5;
}
#mission-meter {
height: 4px;
margin-top: 12px;
overflow: hidden;
border-radius: 99px;
background: rgba(255, 255, 255, 0.1);
}
#mission-meter > span {
display: block;
width: 0%;
height: 100%;
background: linear-gradient(90deg, #ffd35a, #79e38c);
box-shadow: 0 0 12px #ffd35a;
transition: width 300ms ease;
}
#crosshair {
position: absolute;
left: 50%;
top: 50%;
width: 22px;
height: 22px;
transform: translate(-50%, -50%);
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.8));
transition: transform 100ms ease, filter 100ms ease;
}
#crosshair::before, #crosshair::after {
content: "";
position: absolute;
background: rgba(255, 255, 255, 0.92);
border-radius: 1px;
}
#crosshair::before { width: 2px; height: 18px; left: 10px; top: 2px; }
#crosshair::after { width: 18px; height: 2px; left: 2px; top: 10px; }
#crosshair.hostile { transform: translate(-50%, -50%) rotate(45deg) scale(1.15); filter: drop-shadow(0 0 5px var(--red)); }
#crosshair.hostile::before, #crosshair.hostile::after { background: #ff7f8f; }
#target-label {
position: absolute;
left: 50%;
top: calc(50% + 25px);
transform: translateX(-50%);
min-width: 120px;
color: rgba(255, 255, 255, 0.86);
text-align: center;
text-shadow: 0 2px 5px #000;
font: 800 10px/1.2 ui-monospace, SFMono-Regular, Menlo, monospace;
letter-spacing: 0.08em;
text-transform: uppercase;
}
#mine-wrap {
position: absolute;
left: 50%;
top: calc(50% + 43px);
width: 108px;
height: 5px;
transform: translateX(-50%);
overflow: hidden;
border-radius: 10px;
background: rgba(0, 0, 0, 0.48);
opacity: 0;
transition: opacity 100ms;
}
#mine-wrap.active { opacity: 1; }
#mine-fill { width: 0; height: 100%; background: linear-gradient(90deg, var(--cyan), white); box-shadow: 0 0 7px var(--cyan); }
#vitals {
position: absolute;
left: 24px;
bottom: 26px;
display: flex;
align-items: flex-end;
gap: 12px;
}
#hearts { display: flex; gap: 5px; }
.heart {
position: relative;
width: 22px;
height: 20px;
color: #ff5a6e;
font-size: 22px;
line-height: 20px;
text-shadow: 0 2px 0 #791f2e, 0 0 9px rgba(255, 75, 100, 0.5);
transition: transform 120ms, filter 120ms;
}
.heart.empty { color: rgba(255, 255, 255, 0.15); text-shadow: 0 1px 2px #000; }
.heart.half { color: rgba(255, 255, 255, 0.15); text-shadow: 0 1px 2px #000; }
.heart.half::after { content: "♥"; position: absolute; left: 0; width: 52%; overflow: hidden; color: #ff5a6e; text-shadow: 0 2px 0 #791f2e; }
.vital-caption {
margin-top: 8px;
color: rgba(255, 255, 255, 0.55);
font: 700 9px/1 ui-monospace, SFMono-Regular, Menlo, monospace;
letter-spacing: 0.14em;
text-transform: uppercase;
}
#resource-strip {
display: flex;
gap: 7px;
margin-bottom: -1px;
}
.resource {
min-width: 52px;
padding: 7px 9px;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 4px;
background: rgba(6, 11, 19, 0.66);
color: rgba(255, 255, 255, 0.6);
font: 750 9px/1 ui-monospace, SFMono-Regular, Menlo, monospace;
letter-spacing: 0.06em;
}
.resource b { margin-left: 5px; color: white; font-size: 12px; }
#hotbar-wrap {
position: absolute;
left: 50%;
bottom: 22px;
transform: translateX(-50%);
}
#hotbar {
display: flex;
align-items: end;
gap: 5px;
padding: 6px;
border-radius: 7px;
}
.slot {
position: relative;
width: 54px;
height: 54px;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 4px;
background: rgba(2, 7, 13, 0.54);
box-shadow: inset 0 0 18px rgba(0, 0, 0, 0.4);
transition: transform 120ms, border 120ms, background 120ms;
}
.slot.selected {
transform: translateY(-7px);
border-color: #fff0a8;
background: rgba(255, 207, 74, 0.13);
box-shadow: 0 0 0 2px rgba(255, 211, 90, 0.25), 0 9px 20px rgba(0, 0, 0, 0.38), inset 0 0 18px rgba(255, 196, 49, 0.11);
}
.slot-key {
position: absolute;
left: 5px;
top: 4px;
z-index: 2;
color: rgba(255, 255, 255, 0.54);
font: 700 9px/1 ui-monospace, SFMono-Regular, Menlo, monospace;
}
.slot-icon {
position: absolute;
width: 28px;
height: 28px;
left: 13px;
top: 12px;
border: 2px solid rgba(255, 255, 255, 0.16);
box-shadow: inset -5px -6px rgba(0, 0, 0, 0.14), 0 4px 5px rgba(0, 0, 0, 0.35);
transform: rotate(-3deg);
image-rendering: pixelated;
}
.slot-count {
position: absolute;
right: 5px;
bottom: 3px;
z-index: 2;
color: white;
font: 900 12px/1 ui-monospace, SFMono-Regular, Menlo, monospace;
text-shadow: 0 2px 2px #000;
}
#slot-name {
position: absolute;
left: 50%;
bottom: 68px;
transform: translateX(-50%);
color: white;
white-space: nowrap;
text-shadow: 0 2px 5px #000;
font: 800 11px/1 ui-monospace, SFMono-Regular, Menlo, monospace;
letter-spacing: 0.12em;
text-transform: uppercase;
}
#help {
position: absolute;
right: 24px;
bottom: 24px;
padding: 10px 12px;
border-radius: 5px;
color: rgba(255, 255, 255, 0.62);
font: 700 9px/1.75 ui-monospace, SFMono-Regular, Menlo, monospace;
letter-spacing: 0.05em;
text-align: right;
}
#help b { color: white; }
#toast-stack {
position: absolute;
left: 50%;
top: 18%;
width: min(390px, calc(100vw - 40px));
transform: translateX(-50%);
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.toast {
padding: 10px 14px;
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 4px;
background: rgba(7, 13, 22, 0.86);
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.35);
color: white;
font: 750 11px/1.3 ui-monospace, SFMono-Regular, Menlo, monospace;
letter-spacing: 0.04em;
animation: toast-in 250ms ease both, toast-out 350ms ease 2.45s both;
}
@keyframes toast-in { from { opacity: 0; transform: translateY(-8px) scale(0.96); } }
@keyframes toast-out { to { opacity: 0; transform: translateY(-5px) scale(0.98); } }
#damage-flash {
position: absolute;
inset: 0;
opacity: 0;
background: radial-gradient(circle, transparent 35%, rgba(255, 24, 58, 0.56) 110%);
mix-blend-mode: screen;
transition: opacity 420ms ease;
}
#damage-flash.hit { opacity: 1; transition: none; }
.overlay {
position: absolute;
inset: 0;
z-index: 10;
display: none;
align-items: center;
justify-content: center;
padding: 24px;
background: radial-gradient(circle at 50% 30%, rgba(31, 61, 89, 0.25), rgba(2, 6, 12, 0.82));
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
.overlay.active { display: flex; animation: overlay-in 220ms ease both; }
@keyframes overlay-in { from { opacity: 0; } }
.menu-card {
position: relative;
width: min(620px, 100%);
padding: 46px 48px 42px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 8px;
background: linear-gradient(145deg, rgba(19, 31, 49, 0.97), rgba(6, 11, 20, 0.98));
box-shadow: 0 36px 100px rgba(0, 0, 0, 0.58), inset 0 1px rgba(255, 255, 255, 0.06);
}
.menu-card::before {
content: "";
position: absolute;
width: 260px;
height: 260px;
right: -120px;
top: -130px;
transform: rotate(18deg);
background: repeating-linear-gradient(90deg, rgba(255, 210, 75, 0.08) 0 16px, transparent 16px 31px);
border: 1px solid rgba(255, 211, 90, 0.14);
}
.menu-kicker {
color: var(--sun);
font: 850 10px/1 ui-monospace, SFMono-Regular, Menlo, monospace;
letter-spacing: 0.25em;
text-transform: uppercase;
}
.menu-title {
margin: 12px 0 0;
font-size: clamp(48px, 9vw, 76px);
line-height: 0.88;
letter-spacing: -0.065em;
font-weight: 950;
text-transform: uppercase;
text-shadow: 0 6px 0 rgba(0, 0, 0, 0.22);
}
.menu-title em { color: var(--sun); font-style: normal; }
.menu-subtitle {
max-width: 470px;
margin: 20px 0 0;
color: #afbdce;
font-size: 14px;
line-height: 1.65;
}
.menu-actions {
display: flex;
align-items: center;
gap: 18px;
margin-top: 30px;
}
.primary-button {
position: relative;
min-width: 170px;
padding: 14px 21px;
border: 0;
border-radius: 4px;
outline: none;
background: linear-gradient(180deg, #ffe171, #f3ad2a);
color: #2b1a05;
box-shadow: 0 5px 0 #995f15, 0 12px 26px rgba(255, 178, 39, 0.22);
font-weight: 900;
letter-spacing: 0.08em;
text-transform: uppercase;
cursor: pointer;
transition: transform 100ms, box-shadow 100ms, filter 100ms;
}
.primary-button:hover { filter: brightness(1.07); transform: translateY(-1px); box-shadow: 0 6px 0 #995f15, 0 14px 28px rgba(255, 178, 39, 0.3); }
.primary-button:active { transform: translateY(4px); box-shadow: 0 1px 0 #995f15; }
.menu-note {
color: rgba(255, 255, 255, 0.45);
font: 700 10px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
}
.controls-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
margin-top: 30px;
padding-top: 24px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.control-item {
min-height: 61px;
padding: 10px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 4px;
background: rgba(255, 255, 255, 0.025);
}
.key {
color: white;
font: 850 11px/1 ui-monospace, SFMono-Regular, Menlo, monospace;
letter-spacing: 0.1em;
}
.control-item span {
display: block;
margin-top: 7px;
color: rgba(255, 255, 255, 0.45);
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.pause-card { width: min(430px, 100%); text-align: center; }
.pause-card .menu-title { font-size: 52px; }
.pause-card .menu-actions { justify-content: center; }
.pause-card .menu-subtitle { margin-inline: auto; }
#dead-screen { background: radial-gradient(circle, rgba(94, 16, 25, 0.4), rgba(8, 2, 4, 0.9)); }
#dead-screen .menu-kicker { color: #ff7888; }
@media (max-width: 820px) {
#mission { top: 16px; right: 16px; width: 240px; }
#brand { top: 16px; left: 16px; }
#world-info { display: none; }
#help { display: none; }
#vitals { left: 16px; bottom: 92px; }
#resource-strip { display: none; }
#hotbar-wrap { bottom: 16px; }
.slot { width: 47px; height: 47px; }
.slot-icon { width: 24px; height: 24px; left: 11px; top: 11px; }
#slot-name { bottom: 60px; }
}
@media (max-width: 600px) {
#brand .brand-copy span, #mission-copy { display: none; }
#mission { width: 180px; }
.menu-card { padding: 35px 25px 30px; }
.controls-grid { grid-template-columns: repeat(2, 1fr); }
.menu-actions { align-items: flex-start; flex-direction: column; }
#vitals { bottom: 82px; }
.slot { width: 42px; height: 42px; }
.slot-icon { width: 21px; height: 21px; left: 10px; top: 10px; }
#hotbar { gap: 3px; padding: 4px; }
}
</style>
</head>
<body>
<main id="game" aria-label="Solarcraft voxel game">
<div id="hud">
<div id="brand">
<div class="brand-mark" aria-hidden="true"></div>
<div class="brand-copy"><strong>SOLARCRAFT</strong><span>Tiny voxel adventure</span></div>
</div>
<div id="world-info">
<div class="chip" id="coord-chip">XYZ 0 / 0 / 0</div>
<div class="chip" id="time-chip">DAY 01 · 08:00</div>
</div>
<section id="mission" class="glass">
<div class="eyebrow">Field objective</div>
<div id="mission-title">Power the old beacon</div>
<div id="mission-copy">Mine 5 coal and defeat 3 Gloomwalkers.</div>
<div id="mission-meter"><span></span></div>
</section>
<div id="crosshair" aria-hidden="true"></div>
<div id="target-label"></div>
<div id="mine-wrap"><div id="mine-fill"></div></div>
<div id="vitals">
<div>
<div id="hearts" aria-label="Player health"></div>
<div class="vital-caption">Explorer integrity</div>
</div>
<div id="resource-strip">
<div class="resource">COAL <b id="coal-count">0</b></div>
<div class="resource">IRON <b id="iron-count">0</b></div>
<div class="resource">ESS <b id="essence-count">0</b></div>
</div>
</div>
<div id="hotbar-wrap">
<div id="slot-name">Dirt</div>
<div id="hotbar" class="glass"></div>
</div>
<div id="help" class="glass"><b>WASD</b> MOVE · <b>SPACE</b> JUMP · <b>SHIFT</b> SPRINT<br><b>LMB</b> MINE / ATTACK · <b>RMB</b> PLACE · <b>1–6</b> SELECT</div>
<div id="toast-stack"></div>
<div id="damage-flash"></div>
</div>
<section id="start-screen" class="overlay active">
<div class="menu-card">
<div class="menu-kicker">A one-file voxel expedition</div>
<h1 class="menu-title">Solar<em>craft</em></h1>
<p class="menu-subtitle">The beacon is dark and the lowlands are crawling with Gloomwalkers. Break ground, gather what you need, and hold the line until sunrise.</p>
<div class="menu-actions">
<button id="start-button" class="primary-button">Enter the wilds</button>
<div class="menu-note">Headphones recommended<br>Click to capture mouse</div>
</div>
<div class="controls-grid">
<div class="control-item"><div class="key">W A S D</div><span>Move around</span></div>
<div class="control-item"><div class="key">SPACE</div><span>Jump</span></div>
<div class="control-item"><div class="key">LEFT MOUSE</div><span>Mine / fight</span></div>
<div class="control-item"><div class="key">RIGHT MOUSE</div><span>Place block</span></div>
</div>
</div>
</section>
<section id="pause-screen" class="overlay">
<div class="menu-card pause-card">
<div class="menu-kicker">Expedition paused</div>
<h2 class="menu-title">Breathe.</h2>
<p class="menu-subtitle">The wilds will be right where you left them.</p>
<div class="menu-actions"><button id="resume-button" class="primary-button">Return to game</button></div>
</div>
</section>
<section id="dead-screen" class="overlay">
<div class="menu-card pause-card">
<div class="menu-kicker">Signal lost</div>
<h2 class="menu-title">You fell.</h2>
<p class="menu-subtitle">The beacon still needs you. Your gathered resources are safe.</p>
<div class="menu-actions"><button id="respawn-button" class="primary-button">Respawn</button></div>
</div>
</section>
</main>
<script type="module">
import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.160.1/build/three.module.js';
// ---------- DOM and renderer ----------
const $ = (selector) => document.querySelector(selector);
const game = $('#game');
const hud = $('#hud');
const startScreen = $('#start-screen');
const pauseScreen = $('#pause-screen');
const deadScreen = $('#dead-screen');
const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(devicePixelRatio, 1.6));
renderer.setSize(innerWidth, innerHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.05;
game.prepend(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color('#86c9ec');
scene.fog = new THREE.FogExp2('#86c9ec', 0.022);
const camera = new THREE.PerspectiveCamera(74, innerWidth / innerHeight, 0.08, 110);
camera.rotation.order = 'YXZ';
scene.add(camera);
const hemi = new THREE.HemisphereLight('#b9e7ff', '#344126', 1.65);
scene.add(hemi);
const sunLight = new THREE.DirectionalLight('#fff1c2', 2.5);
sunLight.position.set(18, 28, 12);
sunLight.castShadow = true;
sunLight.shadow.mapSize.set(1536, 1536);
sunLight.shadow.camera.left = -24;
sunLight.shadow.camera.right = 24;
sunLight.shadow.camera.top = 24;
sunLight.shadow.camera.bottom = -24;
sunLight.shadow.camera.near = 1;
sunLight.shadow.camera.far = 75;
sunLight.shadow.bias = -0.0004;
scene.add(sunLight);
const sunOrb = new THREE.Mesh(
new THREE.BoxGeometry(2.7, 2.7, 0.55),
new THREE.MeshBasicMaterial({ color: '#ffe387', fog: false })
);
scene.add(sunOrb);
// ---------- Voxel definitions and textures ----------
const WORLD_HALF = 17;
const WORLD_TOP = 22;
const REACH = 6;
const world = new Map();
const keyOf = (x, y, z) => `${x},${y},${z}`;
const getBlock = (x, y, z) => world.get(keyOf(x, y, z)) || null;
const setBlock = (x, y, z, type) => type ? world.set(keyOf(x, y, z), type) : world.delete(keyOf(x, y, z));
const BLOCKS = {
bedrock: { name: 'Bedrock', hardness: Infinity, color: '#3d4249', texture: 'bedrock' },
grass: { name: 'Sungrass', hardness: 0.48, color: '#62bd4c', texture: 'grass_top', drop: 'dirt' },
dirt: { name: 'Dirt', hardness: 0.36, color: '#8e5f3c', texture: 'dirt', drop: 'dirt' },
stone: { name: 'Stone', hardness: 0.92, color: '#8c949d', texture: 'stone', drop: 'stone' },
coalOre: { name: 'Coal Ore', hardness: 1.12, color: '#555b62', texture: 'coal_ore', resource: 'coal' },
ironOre: { name: 'Iron Ore', hardness: 1.34, color: '#a17e67', texture: 'iron_ore', resource: 'iron' },
sand: { name: 'Sun Sand', hardness: 0.28, color: '#ddca82', texture: 'sand', drop: 'sand' },
wood: { name: 'Timber', hardness: 0.72, color: '#8c6235', texture: 'log_side', drop: 'wood' },
leaves: { name: 'Solar Leaves', hardness: 0.2, color: '#3c9c47', texture: 'leaves', drop: 'leaves' }
};
const HOTBAR = ['dirt', 'stone', 'wood', 'sand', 'leaves', 'grass'];
const inventory = { dirt: 12, stone: 6, wood: 4, sand: 0, leaves: 0, grass: 0 };
const resources = { coal: 0, iron: 0, essence: 0 };
let selectedSlot = 0;
const textureCanvases = {};
const textures = {};
const materials = {};
function seeded(seed) {
let n = seed >>> 0;
return () => {
n = (n * 1664525 + 1013904223) >>> 0;
return n / 4294967296;
};
}
function makeTexture(name, base, flecks, mode = 'noise') {
const canvas = document.createElement('canvas');
canvas.width = canvas.height = 32;
const ctx = canvas.getContext('2d');
ctx.fillStyle = base;
ctx.fillRect(0, 0, 32, 32);
const rand = seeded([...name].reduce((a, c) => a + c.charCodeAt(0) * 31, 17));
if (mode === 'grassSide') {
ctx.fillStyle = '#8f5e38';
ctx.fillRect(0, 0, 32, 32);
ctx.fillStyle = '#5daf48';
ctx.fillRect(0, 0, 32, 9);
for (let x = 0; x < 32; x += 3) {
const depth = 3 + Math.floor(rand() * 7);
ctx.fillStyle = rand() > 0.5 ? '#559f40' : '#6ec653';
ctx.fillRect(x, 7, 2, depth);
}
} else if (mode === 'log') {
for (let x = 1; x < 32; x += 5 + Math.floor(rand() * 4)) {
ctx.fillStyle = flecks[Math.floor(rand() * flecks.length)];
ctx.fillRect(x, 0, 2, 32);
}
ctx.fillStyle = 'rgba(255,255,255,.08)';
ctx.fillRect(0, 3, 32, 2);
} else if (mode === 'rings') {
ctx.strokeStyle = flecks[0];
ctx.lineWidth = 2;
for (let s = 6; s < 28; s += 6) ctx.strokeRect((32 - s) / 2, (32 - s) / 2, s, s);
} else if (mode === 'leaves') {
for (let i = 0; i < 100; i++) {
const size = 2 + Math.floor(rand() * 4);
ctx.fillStyle = flecks[Math.floor(rand() * flecks.length)];
ctx.fillRect(Math.floor(rand() * 32), Math.floor(rand() * 32), size, size);
}
ctx.fillStyle = 'rgba(255,240,120,.24)';
for (let i = 0; i < 9; i++) ctx.fillRect(Math.floor(rand() * 31), Math.floor(rand() * 31), 2, 2);
} else {
for (let i = 0; i < 80; i++) {
const size = rand() > 0.78 ? 4 : 2;
ctx.fillStyle = flecks[Math.floor(rand() * flecks.length)];
ctx.globalAlpha = 0.35 + rand() * 0.52;
ctx.fillRect(Math.floor(rand() * 32), Math.floor(rand() * 32), size, size);
}
ctx.globalAlpha = 1;
}
if (mode === 'ore') {
for (let i = 0; i < 12; i++) {
ctx.fillStyle = flecks[i % flecks.length];
const x = Math.floor(rand() * 28);
const y = Math.floor(rand() * 28);
ctx.fillRect(x, y, 4 + (i % 2) * 2, 3 + ((i + 1) % 2) * 2);
}
}
const texture = new THREE.CanvasTexture(canvas);
texture.magFilter = THREE.NearestFilter;
texture.minFilter = THREE.NearestMipmapNearestFilter;
texture.colorSpace = THREE.SRGBColorSpace;
textures[name] = texture;
textureCanvases[name] = canvas;
materials[name] = new THREE.MeshLambertMaterial({ map: texture, color: '#ffffff' });
}
makeTexture('grass_top', '#59b946', ['#4a9f3d', '#79d25a', '#8bdd67', '#367d35']);
makeTexture('grass_side', '#8f5e38', ['#6d472f'], 'grassSide');
makeTexture('dirt', '#8d5d3b', ['#684329', '#a97349', '#c08857']);
makeTexture('stone', '#8b9298', ['#6d757d', '#a7adb1', '#596169']);
makeTexture('coal_ore', '#81888e', ['#22262b', '#343940', '#11151a'], 'ore');
makeTexture('iron_ore', '#858b8e', ['#b87855', '#d39a70', '#714c3b'], 'ore');
makeTexture('sand', '#ddc980', ['#f1dda0', '#baa864', '#fff0b2']);
makeTexture('log_side', '#8d6338', ['#654424', '#ae7a42'], 'log');
makeTexture('log_top', '#a97b49', ['#694a2b'], 'rings');
makeTexture('leaves', '#3f9948', ['#2e7839', '#55b959', '#246a33', '#72cb65'], 'leaves');
makeTexture('bedrock', '#40454a', ['#24282d', '#656b70', '#15191c']);
// ---------- Deterministic terrain ----------
function hash3(x, y, z) {
const s = Math.sin(x * 127.1 + y * 311.7 + z * 74.7) * 43758.5453123;
return s - Math.floor(s);
}
function terrainHeight(x, z) {
const broad = Math.sin(x * 0.19) * 1.25 + Math.cos(z * 0.17) * 1.15;
const ridges = Math.sin((x + z) * 0.105) * 0.9 + Math.cos((x - z) * 0.12) * 0.55;
return Math.max(3, Math.min(9, Math.floor(5.2 + broad + ridges + hash3(x, 0, z) * 0.85)));
}
function generateWorld() {
world.clear();
for (let x = -WORLD_HALF; x < WORLD_HALF; x++) {
for (let z = -WORLD_HALF; z < WORLD_HALF; z++) {
const height = terrainHeight(x, z);
const sandy = height <= 4 && (hash3(x, 22, z) > 0.28 || z < -11);
for (let y = 0; y <= height; y++) {
let type = 'stone';
if (y === 0) type = 'bedrock';
else if (y === height) type = sandy ? 'sand' : 'grass';
else if (y >= height - 2) type = sandy ? 'sand' : 'dirt';
else {
const ore = hash3(x, y * 3.1, z);
if (y <= 5 && ore > 0.953) type = 'coalOre';
else if (y <= 4 && ore < 0.022) type = 'ironOre';
}
setBlock(x, y, z, type);
}
}
}
// Sparse trees, with a guaranteed clear landing zone around the origin.
for (let x = -WORLD_HALF + 2; x < WORLD_HALF - 2; x += 2) {
for (let z = -WORLD_HALF + 2; z < WORLD_HALF - 2; z += 2) {
if (Math.abs(x) < 4 && Math.abs(z) < 4) continue;
const y = terrainHeight(x, z);
if (getBlock(x, y, z) !== 'grass' || hash3(x * 2, 9, z * 2) < 0.82) continue;
const trunk = 3 + (hash3(x, 5, z) > 0.55 ? 1 : 0);
for (let i = 1; i <= trunk; i++) setBlock(x, y + i, z, 'wood');
for (let dx = -2; dx <= 2; dx++) {
for (let dz = -2; dz <= 2; dz++) {
for (let dy = trunk - 1; dy <= trunk + 1; dy++) {
const edge = Math.abs(dx) + Math.abs(dz) + Math.abs(dy - trunk);
if (edge > 4 || (dx === 0 && dz === 0 && dy <= trunk)) continue;
if (hash3(x + dx, y + dy, z + dz) > 0.12) setBlock(x + dx, y + dy, z + dz, 'leaves');
}
}
}
setBlock(x, y + trunk + 2, z, 'leaves');
}
}
// Keep the initial landing meadow flat and open even when nearby terrain or
// a tree canopy would otherwise wall in a first-time player.
for (let x = -3; x <= 3; x++) {
for (let z = -3; z <= 3; z++) {
for (let y = 1; y <= 3; y++) setBlock(x, y, z, 'stone');
setBlock(x, 4, z, 'dirt');
setBlock(x, 5, z, 'dirt');
setBlock(x, 6, z, 'grass');
for (let y = 7; y <= WORLD_TOP; y++) setBlock(x, y, z, null);
}
}
}
const FACES = [
{ n: [1, 0, 0], key: 'px', v: [[1,0,0],[1,1,0],[1,1,1],[1,0,1]] },
{ n: [-1, 0, 0], key: 'nx', v: [[0,0,1],[0,1,1],[0,1,0],[0,0,0]] },
{ n: [0, 1, 0], key: 'py', v: [[0,1,1],[1,1,1],[1,1,0],[0,1,0]] },
{ n: [0, -1, 0], key: 'ny', v: [[0,0,0],[1,0,0],[1,0,1],[0,0,1]] },
{ n: [0, 0, 1], key: 'pz', v: [[1,0,1],[1,1,1],[0,1,1],[0,0,1]] },
{ n: [0, 0, -1], key: 'nz', v: [[0,0,0],[0,1,0],[1,1,0],[1,0,0]] }
];
const UVS = [[0,0],[0,1],[1,1],[1,0]];
const terrainGroup = new THREE.Group();
scene.add(terrainGroup);
function textureForFace(type, faceKey) {
if (type === 'grass') return faceKey === 'py' ? 'grass_top' : faceKey === 'ny' ? 'dirt' : 'grass_side';
if (type === 'wood') return faceKey === 'py' || faceKey === 'ny' ? 'log_top' : 'log_side';
return BLOCKS[type].texture;
}
function rebuildTerrain() {
while (terrainGroup.children.length) {
const old = terrainGroup.children.pop();
old.geometry.dispose();
}
const batches = {};
const batch = (key) => batches[key] ||= { positions: [], normals: [], uvs: [], indices: [], count: 0 };
for (const [key, type] of world) {
const [x, y, z] = key.split(',').map(Number);
for (const face of FACES) {
if (getBlock(x + face.n[0], y + face.n[1], z + face.n[2])) continue;
const data = batch(textureForFace(type, face.key));
const start = data.count;
for (let i = 0; i < 4; i++) {
data.positions.push(x + face.v[i][0], y + face.v[i][1], z + face.v[i][2]);
data.normals.push(...face.n);
data.uvs.push(...UVS[i]);
}
data.indices.push(start, start + 1, start + 2, start, start + 2, start + 3);
data.count += 4;
}
}
for (const [textureKey, data] of Object.entries(batches)) {
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.Float32BufferAttribute(data.positions, 3));
geometry.setAttribute('normal', new THREE.Float32BufferAttribute(data.normals, 3));
geometry.setAttribute('uv', new THREE.Float32BufferAttribute(data.uvs, 2));
geometry.setIndex(data.indices);
geometry.computeBoundingSphere();
const mesh = new THREE.Mesh(geometry, materials[textureKey]);
mesh.receiveShadow = true;
mesh.castShadow = true;
terrainGroup.add(mesh);
}
}
function highestSolid(x, z) {
const bx = Math.floor(x);
const bz = Math.floor(z);
for (let y = WORLD_TOP; y >= 0; y--) if (getBlock(bx, y, bz)) return y;
return -1;
}
// ---------- Atmosphere ----------
const cloudMaterial = new THREE.MeshLambertMaterial({ color: '#f7fbff', transparent: true, opacity: 0.72, depthWrite: false });
const cloudGeometry = new THREE.BoxGeometry(1, 1, 1);
const clouds = [];
for (let i = 0; i < 9; i++) {
const cloud = new THREE.Group();
const rand = seeded(800 + i);
for (let p = 0; p < 3 + Math.floor(rand() * 3); p++) {
const puff = new THREE.Mesh(cloudGeometry, cloudMaterial);
puff.scale.set(2.5 + rand() * 3.2, 0.45 + rand() * 0.42, 1.3 + rand() * 1.3);
puff.position.set((p - 2) * 2.1, rand() * 0.35, (rand() - 0.5) * 1.3);
cloud.add(puff);
}
cloud.position.set(-38 + rand() * 76, 15 + rand() * 4, -28 + rand() * 55);
cloud.userData.speed = 0.35 + rand() * 0.3;
clouds.push(cloud);
scene.add(cloud);
}
const starGeometry = new THREE.BufferGeometry();
const starPositions = [];
const starRand = seeded(9128);
for (let i = 0; i < 350; i++) {
const theta = starRand() * Math.PI * 2;
const phi = starRand() * Math.PI * 0.44;
const r = 65;
starPositions.push(Math.cos(theta) * Math.sin(phi) * r, Math.cos(phi) * r, Math.sin(theta) * Math.sin(phi) * r);
}
starGeometry.setAttribute('position', new THREE.Float32BufferAttribute(starPositions, 3));
const starMaterial = new THREE.PointsMaterial({ color: '#e8f2ff', size: 0.22, transparent: true, opacity: 0, fog: false, depthWrite: false });
const stars = new THREE.Points(starGeometry, starMaterial);
scene.add(stars);
// ---------- Player and camera-held block ----------
const EYE_HEIGHT = 1.62;
const PLAYER_HEIGHT = 1.82;
const PLAYER_RADIUS = 0.29;
const player = {
velocity: new THREE.Vector3(),
yaw: 0,
pitch: -0.12,
health: 10,
onGround: false,
alive: true,
kills: 0,
attackCooldown: 0,
invulnerable: 0,
bob: 0,
stepTimer: 0
};
const hand = new THREE.Group();
const handBlock = new THREE.Mesh(new THREE.BoxGeometry(0.25, 0.25, 0.25), materials.dirt);
handBlock.rotation.set(0.18, -0.45, 0.08);
hand.add(handBlock);
hand.position.set(0.43, -0.38, -0.72);
hand.userData.swing = 0;
camera.add(hand);
const blockOutline = new THREE.LineSegments(
new THREE.EdgesGeometry(new THREE.BoxGeometry(1.012, 1.012, 1.012)),
new THREE.LineBasicMaterial({ color: '#ffffff', transparent: true, opacity: 0.82 })
);
blockOutline.visible = false;
scene.add(blockOutline);
const placeOutline = new THREE.LineSegments(
new THREE.EdgesGeometry(new THREE.BoxGeometry(1.02, 1.02, 1.02)),
new THREE.LineBasicMaterial({ color: '#7dffa0', transparent: true, opacity: 0.42 })
);
placeOutline.visible = false;
scene.add(placeOutline);
function respawnPosition() {
const x = 0.5, z = 0.5;
return new THREE.Vector3(x, highestSolid(x, z) + 1 + EYE_HEIGHT, z);
}
function collidesAt(position) {
const minX = Math.floor(position.x - PLAYER_RADIUS);
const maxX = Math.floor(position.x + PLAYER_RADIUS);
const feet = position.y - EYE_HEIGHT;
const head = feet + PLAYER_HEIGHT;
const minY = Math.floor(feet + 0.001);
const maxY = Math.floor(head - 0.001);
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 movePlayerAxis(axis, amount) {
const steps = Math.max(1, Math.ceil(Math.abs(amount) / 0.08));
const part = amount / steps;
for (let i = 0; i < steps; i++) {
camera.position[axis] += part;
if (collidesAt(camera.position)) {
camera.position[axis] -= part;
if (axis === 'y') {
if (part < 0) player.onGround = true;
player.velocity.y = 0;
} else {
player.velocity[axis] = 0;
}
return;
}
}
}
// ---------- Mobs ----------
const mobs = [];
const mobBodyMaterial = new THREE.MeshLambertMaterial({ color: '#546b46' });
const mobShirtMaterial = new THREE.MeshLambertMaterial({ color: '#283b43' });
const mobEyeMaterial = new THREE.MeshBasicMaterial({ color: '#ffd45a' });
const mobDarkMaterial = new THREE.MeshLambertMaterial({ color: '#26352a' });
const boxGeometry = new THREE.BoxGeometry(1, 1, 1);
function mobPart(group, scale, position, material) {
const mesh = new THREE.Mesh(boxGeometry, material);
mesh.scale.set(...scale);
mesh.position.set(...position);
mesh.castShadow = true;
mesh.receiveShadow = true;
group.add(mesh);
return mesh;
}
function spawnMob(x, z) {
const group = new THREE.Group();
const body = mobPart(group, [0.64, 0.72, 0.38], [0, 1.06, 0], mobShirtMaterial);
const head = mobPart(group, [0.55, 0.55, 0.52], [0, 1.69, 0], mobBodyMaterial);
const leftLeg = mobPart(group, [0.24, 0.7, 0.28], [-0.18, 0.36, 0], mobDarkMaterial);
const rightLeg = mobPart(group, [0.24, 0.7, 0.28], [0.18, 0.36, 0], mobDarkMaterial);
const leftArm = mobPart(group, [0.2, 0.72, 0.22], [-0.43, 1.05, -0.07], mobBodyMaterial);
const rightArm = mobPart(group, [0.2, 0.72, 0.22], [0.43, 1.05, -0.07], mobBodyMaterial);
leftArm.rotation.x = rightArm.rotation.x = -0.72;
mobPart(group, [0.11, 0.08, 0.03], [-0.15, 1.75, -0.27], mobEyeMaterial);
mobPart(group, [0.11, 0.08, 0.03], [0.15, 1.75, -0.27], mobEyeMaterial);
const ground = highestSolid(x, z) + 1;
group.position.set(x, ground, z);
scene.add(group);
mobs.push({
group, body, head, leftLeg, rightLeg, leftArm, rightArm,
health: 3,
dead: false,
hurt: 0,
attackCooldown: 0.4,
wander: hash3(x, 31, z) * Math.PI * 2,
wanderTimer: 1 + hash3(x, 38, z) * 3,
speed: 1.05 + hash3(x, 15, z) * 0.25,
phase: hash3(x, 61, z) * Math.PI * 2
});
}
function spawnInitialMobs() {
[[8,7],[-9,6],[11,-7],[-11,-8],[4,-12],[-6,12]].forEach(([x,z]) => spawnMob(x + 0.5, z + 0.5));
}
// ---------- Ray casting and interactions ----------
const lookDirection = new THREE.Vector3();
let blockTarget = null;
let mobTarget = null;
let leftHeld = false;
let miningKey = '';
let miningProgress = 0;
function raycastVoxels(origin, direction, maxDistance) {
let x = Math.floor(origin.x), y = Math.floor(origin.y), z = Math.floor(origin.z);
const stepX = direction.x >= 0 ? 1 : -1;
const stepY = direction.y >= 0 ? 1 : -1;
const stepZ = direction.z >= 0 ? 1 : -1;
const deltaX = direction.x === 0 ? Infinity : Math.abs(1 / direction.x);
const deltaY = direction.y === 0 ? Infinity : Math.abs(1 / direction.y);
const deltaZ = direction.z === 0 ? Infinity : Math.abs(1 / direction.z);
let maxX = direction.x === 0 ? Infinity : ((stepX > 0 ? x + 1 - origin.x : origin.x - x) * deltaX);
let maxY = direction.y === 0 ? Infinity : ((stepY > 0 ? y + 1 - origin.y : origin.y - y) * deltaY);
let maxZ = direction.z === 0 ? Infinity : ((stepZ > 0 ? z + 1 - origin.z : origin.z - z) * deltaZ);
let distance = 0;
const normal = new THREE.Vector3();
for (let i = 0; i < 128 && distance <= maxDistance; i++) {
const type = getBlock(x, y, z);
if (type) return { x, y, z, type, distance, normal: normal.clone() };
if (maxX < maxY && maxX < maxZ) {
x += stepX; distance = maxX; maxX += deltaX; normal.set(-stepX, 0, 0);
} else if (maxY < maxZ) {
y += stepY; distance = maxY; maxY += deltaY; normal.set(0, -stepY, 0);
} else {
z += stepZ; distance = maxZ; maxZ += deltaZ; normal.set(0, 0, -stepZ);
}
}
return null;
}
function raySphereDistance(origin, direction, center, radius) {
const toCenter = center.clone().sub(origin);
const projected = toCenter.dot(direction);
if (projected < 0) return null;
const closestSq = toCenter.lengthSq() - projected * projected;
const radiusSq = radius * radius;
if (closestSq > radiusSq) return null;
return projected - Math.sqrt(radiusSq - closestSq);
}
function updateTarget() {
camera.getWorldDirection(lookDirection);
blockTarget = raycastVoxels(camera.position, lookDirection, REACH);
mobTarget = null;
let nearest = blockTarget ? blockTarget.distance : REACH;
const center = new THREE.Vector3();
for (const mob of mobs) {
if (mob.dead) continue;
center.copy(mob.group.position).add(new THREE.Vector3(0, 1.05, 0));
const distance = raySphereDistance(camera.position, lookDirection, center, 0.63);
if (distance !== null && distance < nearest && distance <= REACH) {
nearest = distance;
mobTarget = mob;
}
}
const crosshair = $('#crosshair');
const targetLabel = $('#target-label');
if (mobTarget) {
crosshair.classList.add('hostile');
targetLabel.textContent = `Gloomwalker · ${mobTarget.health}/3`;
blockOutline.visible = false;
placeOutline.visible = false;
} else if (blockTarget) {
crosshair.classList.remove('hostile');
targetLabel.textContent = BLOCKS[blockTarget.type].name;
blockOutline.visible = true;
blockOutline.position.set(blockTarget.x + 0.5, blockTarget.y + 0.5, blockTarget.z + 0.5);
const place = {
x: blockTarget.x + blockTarget.normal.x,
y: blockTarget.y + blockTarget.normal.y,
z: blockTarget.z + blockTarget.normal.z
};
placeOutline.visible = canPlaceAt(place.x, place.y, place.z);
placeOutline.position.set(place.x + 0.5, place.y + 0.5, place.z + 0.5);
} else {
crosshair.classList.remove('hostile');
targetLabel.textContent = '';
blockOutline.visible = false;
placeOutline.visible = false;
}
}
function blockIntersectsPlayer(x, y, z) {
const feet = camera.position.y - EYE_HEIGHT;
return x + 1 > camera.position.x - PLAYER_RADIUS && x < camera.position.x + PLAYER_RADIUS &&
z + 1 > camera.position.z - PLAYER_RADIUS && z < camera.position.z + PLAYER_RADIUS &&
y + 1 > feet && y < feet + PLAYER_HEIGHT;
}
function canPlaceAt(x, y, z) {
return x >= -WORLD_HALF && x < WORLD_HALF && z >= -WORLD_HALF && z < WORLD_HALF &&
y > 0 && y < WORLD_TOP && !getBlock(x, y, z) && !blockIntersectsPlayer(x, y, z) && inventory[HOTBAR[selectedSlot]] > 0;
}
function placeSelectedBlock() {
if (!blockTarget || mobTarget) return;
const x = blockTarget.x + blockTarget.normal.x;
const y = blockTarget.y + blockTarget.normal.y;
const z = blockTarget.z + blockTarget.normal.z;
const type = HOTBAR[selectedSlot];
if (!canPlaceAt(x, y, z)) {
tone(105, 0.06, 'square', 0.018);
return;
}
setBlock(x, y, z, type);
inventory[type]--;
rebuildTerrain();
updateHotbar();
burstParticles(x + 0.5, y + 0.5, z + 0.5, BLOCKS[type].color, 5, 0.9);
playPlace();
hand.userData.swing = 1;
}
function mineBlock(target) {
if (!target || target.type === 'bedrock') return;
setBlock(target.x, target.y, target.z, null);
const data = BLOCKS[target.type];
if (data.resource) {
resources[data.resource]++;
toast(`+1 ${data.resource.toUpperCase()} · resource secured`);
} else if (data.drop) {
inventory[data.drop] = (inventory[data.drop] || 0) + 1;
}
burstParticles(target.x + 0.5, target.y + 0.5, target.z + 0.5, data.color, 12, 2.2);
rebuildTerrain();
updateHotbar();
updateMission();
playBreak(target.type);
miningProgress = 0;
miningKey = '';
hand.userData.swing = 1;
}
function hitMob(mob) {
if (!mob || mob.dead || player.attackCooldown > 0) return;
player.attackCooldown = 0.42;
mob.health--;
mob.hurt = 0.22;
hand.userData.swing = 1;
const knock = mob.group.position.clone().sub(camera.position).setY(0).normalize().multiplyScalar(0.42);
mob.group.position.add(knock);
burstParticles(mob.group.position.x, mob.group.position.y + 1.2, mob.group.position.z, '#ffd35a', 8, 2.3);
playAttack(mob.health <= 0);
if (mob.health <= 0) killMob(mob);
}
function killMob(mob) {
mob.dead = true;
player.kills++;
resources.essence++;
mob.group.visible = false;
toast('+1 ESSENCE · Gloomwalker dispersed');
updateMission();
setTimeout(() => {
const edge = hash3(player.kills, 8, resources.essence) > 0.5 ? 1 : -1;
spawnMob(edge * (12 + hash3(player.kills, 3, 1) * 3), (hash3(player.kills, 4, 2) - 0.5) * 24);
}, 8500);
}
// ---------- Particles ----------
const particles = [];
const particleGeometry = new THREE.BoxGeometry(0.1, 0.1, 0.1);
function burstParticles(x, y, z, color, count = 8, power = 1.5) {
const material = new THREE.MeshLambertMaterial({ color });
const rand = seeded(Math.floor((x + 30) * 991 + (y + 8) * 313 + (z + 30) * 127 + performance.now()));
for (let i = 0; i < count; i++) {
const mesh = new THREE.Mesh(particleGeometry, material);
mesh.position.set(x + (rand() - 0.5) * 0.5, y + (rand() - 0.5) * 0.5, z + (rand() - 0.5) * 0.5);
mesh.scale.setScalar(0.7 + rand() * 1.2);
scene.add(mesh);
particles.push({
mesh, material,
velocity: new THREE.Vector3((rand() - 0.5) * power, rand() * power, (rand() - 0.5) * power),
life: 0.55 + rand() * 0.4
});
}
}
function updateParticles(dt) {
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i];
p.life -= dt;
p.velocity.y -= 5.5 * dt;
p.mesh.position.addScaledVector(p.velocity, dt);
p.mesh.rotation.x += dt * 5;
p.mesh.rotation.z += dt * 4;
p.mesh.scale.multiplyScalar(Math.max(0.8, 1 - dt * 1.8));
if (p.life <= 0) {
scene.remove(p.mesh);
particles.splice(i, 1);
if (!particles.some(other => other.material === p.material)) p.material.dispose();
}
}
}
// ---------- Tiny procedural audio ----------
let audio = null;
function ensureAudio() {
if (!audio) audio = new (window.AudioContext || window.webkitAudioContext)();
if (audio.state === 'suspended') audio.resume();
}
function tone(frequency, duration, type = 'sine', volume = 0.025, slide = 0) {
if (!audio) return;
const now = audio.currentTime;
const osc = audio.createOscillator();
const gain = audio.createGain();
osc.type = type;
osc.frequency.setValueAtTime(frequency, now);
if (slide) osc.frequency.exponentialRampToValueAtTime(Math.max(20, frequency + slide), now + duration);
gain.gain.setValueAtTime(volume, now);
gain.gain.exponentialRampToValueAtTime(0.0001, now + duration);
osc.connect(gain).connect(audio.destination);
osc.start(now);
osc.stop(now + duration);
}
function noise(duration = 0.08, volume = 0.018, cutoff = 800) {
if (!audio) return;
const length = Math.ceil(audio.sampleRate * duration);
const buffer = audio.createBuffer(1, length, audio.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < length; i++) data[i] = (Math.random() * 2 - 1) * (1 - i / length);
const source = audio.createBufferSource();
const filter = audio.createBiquadFilter();
const gain = audio.createGain();
source.buffer = buffer;
filter.type = 'lowpass';
filter.frequency.value = cutoff;
gain.gain.value = volume;
source.connect(filter).connect(gain).connect(audio.destination);
source.start();
}
function playBreak(type) { noise(0.12, 0.04, type === 'stone' ? 650 : 1050); tone(type.includes('Ore') ? 420 : 135, 0.09, 'square', 0.018, -45); }
function playPlace() { noise(0.055, 0.025, 650); tone(165, 0.055, 'square', 0.012, -25); }
function playAttack(final) { noise(0.07, 0.025, 1300); tone(final ? 110 : 185, final ? 0.22 : 0.08, 'sawtooth', 0.026, final ? -50 : 70); }
function playStep() { noise(0.04, 0.012, 520); }
// ---------- HUD ----------
function updateHearts() {
const hearts = $('#hearts');
hearts.innerHTML = '';
for (let i = 0; i < 5; i++) {
const el = document.createElement('span');
el.className = 'heart';
el.textContent = '♥';
if (player.health <= i * 2) el.classList.add('empty');
else if (player.health === i * 2 + 1) el.classList.add('half');
hearts.appendChild(el);
}
}
function iconStyle(type) {
const canvas = textureCanvases[BLOCKS[type].texture];
return `background-image:url(${canvas.toDataURL()});background-size:100% 100%;`;
}
function updateHotbar() {
const hotbar = $('#hotbar');
hotbar.innerHTML = '';
HOTBAR.forEach((type, index) => {
const slot = document.createElement('div');
slot.className = `slot${selectedSlot === index ? ' selected' : ''}`;
slot.innerHTML = `<span class="slot-key">${index + 1}</span><span class="slot-icon" style="${iconStyle(type)}"></span><span class="slot-count">${inventory[type] || 0}</span>`;
hotbar.appendChild(slot);
});
const selected = HOTBAR[selectedSlot];
$('#slot-name').textContent = BLOCKS[selected].name;
handBlock.material = materials[BLOCKS[selected].texture];
$('#coal-count').textContent = resources.coal;
$('#iron-count').textContent = resources.iron;
$('#essence-count').textContent = resources.essence;
}
let objectiveDone = false;
function updateMission() {
const coalProgress = Math.min(resources.coal, 5);
const killProgress = Math.min(player.kills, 3);
const progress = (coalProgress + killProgress) / 8;
$('#mission-meter span').style.width = `${progress * 100}%`;
$('#mission-copy').textContent = `Coal ${coalProgress}/5 · Gloomwalkers ${killProgress}/3`;
updateHotbar();
if (progress >= 1 && !objectiveDone) {
objectiveDone = true;
$('#mission-title').textContent = 'Beacon signal restored';
$('#mission-copy').textContent = 'Objective complete — keep exploring and building.';
toast('BEACON ONLINE · Expedition objective complete');
tone(392, 0.18, 'sine', 0.035, 130);
setTimeout(() => tone(523, 0.24, 'sine', 0.035, 130), 160);
}
}
function toast(message) {
const el = document.createElement('div');
el.className = 'toast';
el.textContent = message;
$('#toast-stack').appendChild(el);
setTimeout(() => el.remove(), 2900);
}
function selectSlot(index) {
selectedSlot = (index + HOTBAR.length) % HOTBAR.length;
updateHotbar();
tone(310 + selectedSlot * 22, 0.035, 'square', 0.008);
}
function damagePlayer(amount, sourcePosition) {
if (player.invulnerable > 0 || !player.alive) return;
player.health = Math.max(0, player.health - amount);
player.invulnerable = 0.65;
const push = camera.position.clone().sub(sourcePosition).setY(0).normalize().multiplyScalar(3.2);
player.velocity.x += push.x;
player.velocity.z += push.z;
player.velocity.y = 3.3;
$('#damage-flash').classList.add('hit');
requestAnimationFrame(() => $('#damage-flash').classList.remove('hit'));
tone(95, 0.18, 'sawtooth', 0.038, -40);
updateHearts();
if (player.health <= 0) die();
}
function die() {
player.alive = false;
leftHeld = false;
document.exitPointerLock?.();
pauseScreen.classList.remove('active');
deadScreen.classList.add('active');
}
function respawn() {
player.alive = true;
player.health = 10;
player.velocity.set(0, 0, 0);
player.invulnerable = 1.5;
camera.position.copy(respawnPosition());
updateHearts();
deadScreen.classList.remove('active');
renderer.domElement.requestPointerLock();
toast('Signal reacquired · resources preserved');
}
// ---------- Input ----------
const keys = {};
let started = false;
let locked = false;
addEventListener('keydown', (event) => {
keys[event.code] = true;
if (/^Digit[1-6]$/.test(event.code)) selectSlot(Number(event.code.slice(5)) - 1);
if (event.code === 'KeyQ') selectSlot(selectedSlot - 1);
if (event.code === 'KeyE') selectSlot(selectedSlot + 1);
if (event.code === 'Space') event.preventDefault();
});
addEventListener('keyup', (event) => { keys[event.code] = false; });
addEventListener('wheel', (event) => {
if (!locked) return;
selectSlot(selectedSlot + (event.deltaY > 0 ? 1 : -1));
}, { passive: true });
addEventListener('mousemove', (event) => {
if (!locked || !player.alive) return;
player.yaw -= event.movementX * 0.00215;
player.pitch -= event.movementY * 0.00215;
player.pitch = Math.max(-Math.PI / 2 + 0.03, Math.min(Math.PI / 2 - 0.03, player.pitch));
});
addEventListener('mousedown', (event) => {
if (!locked || !player.alive) return;
if (event.button === 0) {
leftHeld = true;
if (mobTarget) hitMob(mobTarget);
}
if (event.button === 2) placeSelectedBlock();
});
addEventListener('mouseup', (event) => {
if (event.button === 0) {
leftHeld = false;
miningProgress = 0;
miningKey = '';
}
});
addEventListener('contextmenu', (event) => event.preventDefault());
function requestGameLock() {
ensureAudio();
started = true;
startScreen.classList.remove('active');
pauseScreen.classList.remove('active');
renderer.domElement.requestPointerLock();
}
$('#start-button').addEventListener('click', requestGameLock);
$('#resume-button').addEventListener('click', requestGameLock);
$('#respawn-button').addEventListener('click', respawn);
renderer.domElement.addEventListener('click', () => {
if (started && !locked && player.alive && !pauseScreen.classList.contains('active')) renderer.domElement.requestPointerLock();
});
document.addEventListener('pointerlockchange', () => {
locked = document.pointerLockElement === renderer.domElement;
leftHeld = false;
miningProgress = 0;
if (started && player.alive) pauseScreen.classList.toggle('active', !locked);
});
// ---------- Game simulation ----------
const clock = new THREE.Clock();
let elapsed = 0;
function updatePlayer(dt) {
if (!locked || !player.alive) return;
camera.rotation.y = player.yaw;
camera.rotation.x = player.pitch;
const forwardInput = (keys.KeyW ? 1 : 0) - (keys.KeyS ? 1 : 0);
const rightInput = (keys.KeyD ? 1 : 0) - (keys.KeyA ? 1 : 0);
const moving = forwardInput !== 0 || rightInput !== 0;
const sprinting = keys.ShiftLeft || keys.ShiftRight;
const speed = sprinting ? 6.25 : 4.35;
const forward = new THREE.Vector3(-Math.sin(player.yaw), 0, -Math.cos(player.yaw));
const right = new THREE.Vector3(Math.cos(player.yaw), 0, -Math.sin(player.yaw));
const desired = forward.multiplyScalar(forwardInput).add(right.multiplyScalar(rightInput));
if (desired.lengthSq() > 0) desired.normalize().multiplyScalar(speed);
const control = player.onGround ? 14 : 5.5;
player.velocity.x = THREE.MathUtils.damp(player.velocity.x, desired.x, control, dt);
player.velocity.z = THREE.MathUtils.damp(player.velocity.z, desired.z, control, dt);
player.onGround = false;
if (keys.Space && collidesAt(camera.position.clone().add(new THREE.Vector3(0, -0.055, 0)))) {
player.velocity.y = 7.15;
tone(145, 0.07, 'sine', 0.012, 45);
}
player.velocity.y -= 20.5 * dt;
player.velocity.y = Math.max(player.velocity.y, -22);
movePlayerAxis('x', player.velocity.x * dt);
movePlayerAxis('z', player.velocity.z * dt);
movePlayerAxis('y', player.velocity.y * dt);
camera.position.x = THREE.MathUtils.clamp(camera.position.x, -WORLD_HALF + 0.35, WORLD_HALF - 0.35);
camera.position.z = THREE.MathUtils.clamp(camera.position.z, -WORLD_HALF + 0.35, WORLD_HALF - 0.35);
if (camera.position.y < -8) damagePlayer(10, camera.position.clone().add(new THREE.Vector3(1, 0, 0)));
const horizontalSpeed = Math.hypot(player.velocity.x, player.velocity.z);
if (moving && player.onGround && horizontalSpeed > 0.5) {
player.bob += dt * horizontalSpeed * 1.9;
player.stepTimer -= dt;
if (player.stepTimer <= 0) {
playStep();
player.stepTimer = sprinting ? 0.29 : 0.39;
}
} else {
player.stepTimer = 0;
}
player.attackCooldown = Math.max(0, player.attackCooldown - dt);
player.invulnerable = Math.max(0, player.invulnerable - dt);
const bobX = moving && player.onGround ? Math.sin(player.bob) * 0.012 : 0;
const bobY = moving && player.onGround ? Math.abs(Math.cos(player.bob)) * 0.012 : 0;
const swing = hand.userData.swing;
if (swing > 0) hand.userData.swing = Math.max(0, swing - dt * 4.8);
const arc = Math.sin((1 - hand.userData.swing) * Math.PI);
hand.position.set(0.43 + bobX, -0.38 - bobY - arc * 0.2, -0.72 + arc * 0.09);
hand.rotation.set(-arc * 0.75, arc * 0.25, -arc * 0.35);
}
function updateMining(dt) {
const mineWrap = $('#mine-wrap');
if (!locked || !leftHeld || mobTarget || !blockTarget || blockTarget.type === 'bedrock') {
miningProgress = 0;
miningKey = '';
mineWrap.classList.remove('active');
return;
}
const key = keyOf(blockTarget.x, blockTarget.y, blockTarget.z);
if (key !== miningKey) {
miningKey = key;
miningProgress = 0;
}
miningProgress += dt;
const hardness = BLOCKS[blockTarget.type].hardness;
const fraction = Math.min(1, miningProgress / hardness);
mineWrap.classList.add('active');
$('#mine-fill').style.width = `${fraction * 100}%`;
hand.userData.swing = Math.max(hand.userData.swing, 0.18 + Math.sin(elapsed * 18) * 0.08);
if (fraction >= 1) mineBlock({ ...blockTarget });
}
function updateMobs(dt) {
if (!player.alive) return;
const playerFeet = camera.position.clone();
playerFeet.y -= EYE_HEIGHT;
for (const mob of mobs) {
if (mob.dead) continue;
mob.attackCooldown -= dt;
mob.hurt = Math.max(0, mob.hurt - dt);
mob.group.scale.setScalar(mob.hurt > 0 ? 1.08 : 1);
mob.head.material = mob.hurt > 0 ? mobEyeMaterial : mobBodyMaterial;
const toPlayer = playerFeet.clone().sub(mob.group.position);
const distance = Math.hypot(toPlayer.x, toPlayer.z);
let angle;
let speed = mob.speed;
if (distance < 16) {
angle = Math.atan2(toPlayer.x, toPlayer.z);
speed *= distance < 2 ? 0.55 : 1;
} else {
mob.wanderTimer -= dt;
if (mob.wanderTimer <= 0) {
mob.wander += (hash3(elapsed, mob.group.position.x, mob.group.position.z) - 0.5) * 1.8;
mob.wanderTimer = 1.5 + hash3(mob.group.position.z, elapsed, mob.group.position.x) * 3;
}
angle = mob.wander;
speed *= 0.42;
}
const dx = Math.sin(angle) * speed * dt;
const dz = Math.cos(angle) * speed * dt;
const nx = THREE.MathUtils.clamp(mob.group.position.x + dx, -WORLD_HALF + 0.6, WORLD_HALF - 0.6);
const nz = THREE.MathUtils.clamp(mob.group.position.z + dz, -WORLD_HALF + 0.6, WORLD_HALF - 0.6);
const nextGround = highestSolid(nx, nz) + 1;
if (Math.abs(nextGround - mob.group.position.y) <= 1.05 && !getBlock(Math.floor(nx), Math.floor(nextGround + 0.7), Math.floor(nz))) {
mob.group.position.x = nx;
mob.group.position.z = nz;
mob.group.position.y = THREE.MathUtils.damp(mob.group.position.y, nextGround, 10, dt);
} else {
mob.wander += Math.PI * 0.6;
}
mob.group.rotation.y = angle + Math.PI;
const walk = elapsed * speed * 7 + mob.phase;
const legSwing = Math.sin(walk) * 0.55;
mob.leftLeg.rotation.x = legSwing;
mob.rightLeg.rotation.x = -legSwing;
mob.leftArm.rotation.x = -0.72 - legSwing * 0.3;
mob.rightArm.rotation.x = -0.72 + legSwing * 0.3;
mob.body.position.y = 1.06 + Math.abs(Math.sin(walk)) * 0.025;
if (distance < 1.35 && Math.abs(toPlayer.y) < 2 && mob.attackCooldown <= 0 && locked) {
mob.attackCooldown = 1.05;
damagePlayer(1, mob.group.position);
}
}
}
function updateAtmosphere(dt) {
const cycle = (0.16 + elapsed / 300) % 1;
const angle = cycle * Math.PI * 2;
const elevation = Math.sin(angle);
const daylight = THREE.MathUtils.clamp(elevation * 0.7 + 0.48, 0.1, 1);
const skyDay = new THREE.Color('#84c9ed');
const skyDusk = new THREE.Color('#d57d69');
const skyNight = new THREE.Color('#10192f');
const sky = daylight > 0.5 ? skyDusk.clone().lerp(skyDay, (daylight - 0.5) * 2) : skyNight.clone().lerp(skyDusk, daylight * 2);
scene.background.copy(sky);
scene.fog.color.copy(sky);
hemi.intensity = 0.45 + daylight * 1.25;
sunLight.intensity = 0.25 + daylight * 2.3;
sunLight.color.set(daylight > 0.65 ? '#fff2c6' : '#ff9f71');
sunLight.position.set(Math.cos(angle) * 30, Math.max(4, elevation * 30), Math.sin(angle) * 24);
sunOrb.position.copy(camera.position).add(new THREE.Vector3(Math.cos(angle) * 55, Math.max(7, elevation * 55), Math.sin(angle) * 50));
sunOrb.lookAt(camera.position);
starMaterial.opacity = THREE.MathUtils.clamp((0.42 - daylight) * 2.8, 0, 0.85);
stars.position.set(camera.position.x, 0, camera.position.z);
for (const cloud of clouds) {
cloud.position.x += cloud.userData.speed * dt;
if (cloud.position.x > 42) cloud.position.x = -42;
}
const totalMinutes = Math.floor(cycle * 24 * 60);
const hour = String(Math.floor(totalMinutes / 60)).padStart(2, '0');
const minute = String(totalMinutes % 60).padStart(2, '0');
$('#time-chip').textContent = `DAY 01 · ${hour}:${minute}`;
}
function updateHud() {
$('#coord-chip').textContent = `XYZ ${Math.floor(camera.position.x)} / ${Math.floor(camera.position.y - EYE_HEIGHT)} / ${Math.floor(camera.position.z)}`;
}
function animate() {
requestAnimationFrame(animate);
const dt = Math.min(clock.getDelta(), 0.05);
elapsed += dt;
if (locked && player.alive) {
updatePlayer(dt);
updateTarget();
updateMining(dt);
updateMobs(dt);
}
updateParticles(dt);
updateAtmosphere(dt);
updateHud();
renderer.render(scene, camera);
}
// ---------- Boot ----------
generateWorld();
rebuildTerrain();
camera.position.copy(respawnPosition());
camera.rotation.set(player.pitch, player.yaw, 0);
spawnInitialMobs();
updateHearts();
updateHotbar();
updateMission();
hud.classList.add('visible');
animate();
addEventListener('resize', () => {
camera.aspect = innerWidth / innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
renderer.setPixelRatio(Math.min(devicePixelRatio, 1.6));
});
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment