<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Shadow of the Fell Crown</title>
<style>
:root {
--gold: #d8b25c;
--red: #9f2f2f;
--blue: #56b7ff;
--green: #6de28c;
--panel: rgba(7, 10, 14, 0.78);
}
* { box-sizing: border-box; }
html, body {
width: 100%;
height: 100%;
margin: 0;
overflow: hidden;
background: #05070a;
color: #eee;
font-family: Georgia, "Times New Roman", serif;
user-select: none;
}
canvas { display: block; }
#hud {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 10;
}
#bars {
position: absolute;
left: 24px;
bottom: 24px;
width: min(360px, calc(100vw - 48px));
}
.bar-label {
display: flex;
justify-content: space-between;
margin: 8px 3px 4px;
font-size: 14px;
letter-spacing: 0.08em;
text-shadow: 0 2px 3px #000;
}
.bar {
height: 18px;
border: 2px solid rgba(230, 220, 190, 0.55);
background: rgba(0,0,0,.58);
box-shadow: 0 3px 12px rgba(0,0,0,.55);
overflow: hidden;
}
.fill {
height: 100%;
width: 100%;
transition: width .15s linear;
}
#healthFill { background: linear-gradient(90deg, #651a1a, #df4545); }
#manaFill { background: linear-gradient(90deg, #173b67, #45a9ff); }
#staminaFill { background: linear-gradient(90deg, #245b30, #72dd85); }
#topInfo {
position: absolute;
top: 18px;
left: 50%;
transform: translateX(-50%);
min-width: 310px;
padding: 10px 18px;
text-align: center;
border: 1px solid rgba(216,178,92,.5);
background: linear-gradient(180deg, rgba(25,20,12,.86), rgba(7,8,11,.72));
box-shadow: 0 8px 30px rgba(0,0,0,.45);
letter-spacing: .06em;
}
#waveText {
color: var(--gold);
font-weight: bold;
font-size: 19px;
}
#scoreText {
margin-top: 4px;
font-size: 13px;
color: #ddd4bd;
}
#spellPanel {
position: absolute;
right: 22px;
bottom: 22px;
display: grid;
gap: 8px;
width: 260px;
}
.spell {
display: grid;
grid-template-columns: 35px 1fr auto;
align-items: center;
min-height: 46px;
padding: 6px 9px;
background: var(--panel);
border: 1px solid rgba(255,255,255,.18);
box-shadow: 0 5px 18px rgba(0,0,0,.35);
opacity: .64;
transition: .15s;
}
.spell.active {
opacity: 1;
border-color: var(--gold);
transform: translateX(-6px);
box-shadow: 0 0 18px rgba(216,178,92,.2);
}
.spell.cooling { filter: grayscale(.75); }
.key {
width: 28px;
height: 28px;
display: grid;
place-items: center;
border: 1px solid #ab9563;
color: #f6df9d;
background: #191712;
font-weight: bold;
}
.spell-name { font-size: 14px; }
.spell-cost { font-size: 12px; color: #83cfff; }
#crosshair {
position: absolute;
left: 50%;
top: 50%;
width: 24px;
height: 24px;
transform: translate(-50%, -50%);
}
#crosshair::before,
#crosshair::after {
content: "";
position: absolute;
background: rgba(255,255,255,.9);
box-shadow: 0 0 5px #000;
}
#crosshair::before {
width: 2px;
height: 18px;
left: 11px;
top: 3px;
}
#crosshair::after {
height: 2px;
width: 18px;
top: 11px;
left: 3px;
}
#damageFlash {
position: absolute;
inset: 0;
background: radial-gradient(circle, transparent 35%, rgba(185,0,0,.65));
opacity: 0;
transition: opacity .08s;
}
#message {
position: absolute;
left: 50%;
top: 31%;
transform: translate(-50%, -50%);
font-size: clamp(22px, 4vw, 52px);
color: #f4deb1;
text-shadow: 0 4px 8px #000;
opacity: 0;
transition: opacity .35s;
text-align: center;
}
#bossBarWrap {
display: none;
position: absolute;
top: 86px;
left: 50%;
transform: translateX(-50%);
width: min(540px, 75vw);
text-align: center;
}
#bossName {
margin-bottom: 5px;
color: #ffb26d;
letter-spacing: .12em;
font-weight: bold;
}
#bossBar {
height: 16px;
border: 2px solid #b87954;
background: rgba(0,0,0,.68);
}
#bossFill {
height: 100%;
width: 100%;
background: linear-gradient(90deg, #6c1111, #df5b28);
}
#startScreen,
#gameOver {
position: fixed;
inset: 0;
z-index: 30;
display: grid;
place-items: center;
background:
radial-gradient(circle at 50% 30%, rgba(76,58,26,.2), transparent 35%),
linear-gradient(rgba(3,5,7,.86), rgba(2,3,5,.96));
}
#gameOver { display: none; }
.card {
width: min(720px, calc(100vw - 36px));
padding: 36px;
border: 1px solid rgba(216,178,92,.7);
outline: 1px solid rgba(216,178,92,.15);
outline-offset: 7px;
text-align: center;
background: rgba(9,10,13,.9);
box-shadow: 0 18px 80px rgba(0,0,0,.75);
}
h1 {
margin: 0 0 10px;
color: #e8cf91;
font-size: clamp(34px, 6vw, 72px);
font-weight: normal;
letter-spacing: .05em;
text-shadow: 0 4px 18px rgba(216,178,92,.22);
}
.subtitle {
color: #a99a7d;
font-style: italic;
margin-bottom: 24px;
}
.instructions {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px 24px;
margin: 22px auto;
max-width: 540px;
text-align: left;
color: #d5d0c5;
font-family: system-ui, sans-serif;
font-size: 14px;
}
button {
margin-top: 18px;
padding: 13px 28px;
border: 1px solid #dfc178;
background: linear-gradient(#4e3c1c, #21180b);
color: #ffe7a4;
font: bold 15px Georgia, serif;
letter-spacing: .12em;
cursor: pointer;
box-shadow: 0 8px 24px rgba(0,0,0,.45);
}
button:hover { filter: brightness(1.25); }
.lan-panel {
max-width: 560px;
margin: 20px auto 0;
padding-top: 18px;
border-top: 1px solid rgba(216,178,92,.25);
}
.lan-row {
display: flex;
gap: 9px;
justify-content: center;
flex-wrap: wrap;
}
.lan-row button { margin-top: 0; }
.lan-row input {
min-width: 235px;
padding: 12px 14px;
border: 1px solid rgba(216,178,92,.55);
outline: none;
background: rgba(0,0,0,.45);
color: #fff0c0;
font: 14px system-ui, sans-serif;
}
#networkStatus {
min-height: 20px;
margin: 12px 0 0;
color: #bdb49f;
font: 13px system-ui, sans-serif;
}
#networkBadge {
position: absolute;
top: 18px;
right: 20px;
padding: 7px 10px;
border: 1px solid rgba(216,178,92,.4);
background: rgba(5,7,10,.72);
color: #d8c78f;
font: 12px system-ui, sans-serif;
letter-spacing: .05em;
}
#footerHint {
position: absolute;
left: 50%;
bottom: 7px;
transform: translateX(-50%);
font: 12px system-ui, sans-serif;
color: rgba(255,255,255,.45);
}
@media (max-width: 760px) {
#spellPanel { width: 190px; right: 10px; bottom: 10px; }
#bars { left: 10px; bottom: 10px; width: 210px; }
.instructions { grid-template-columns: 1fr; }
.card { padding: 24px; }
}
</style>
</head>
<body>
<div id="startScreen">
<div class="card">
<h1>Shadow of the Fell Crown</h1>
<div class="subtitle">A dark-fantasy arena of steel, sorcery, and endless foes</div>
<div class="instructions">
<div><b>WASD</b> — Move</div>
<div><b>Mouse</b> — Aim</div>
<div><b>Click</b> — Cast selected spell</div>
<div><b>1–4 / Wheel</b> — Select spell</div>
<div><b>Shift</b> — Sprint</div>
<div><b>Space</b> — Jump</div>
<div><b>Q</b> — Arcane shockwave</div>
<div><b>Esc</b> — Release cursor</div>
</div>
<button id="startButton">PLAY OFFLINE</button>
<div class="lan-panel">
<div class="lan-row">
<button id="hostButton">HOST LAN GAME</button>
<input id="joinIp" value="127.0.0.1:8787" spellcheck="false" aria-label="Host IP and port" />
<button id="joinButton">JOIN LAN GAME</button>
</div>
<div id="networkStatus">For LAN play, run <b>node fell-crown-lan-server.js</b> on the host computer.</div>
</div>
</div>
</div>
<div id="gameOver">
<div class="card">
<h1>The Shadow Prevails</h1>
<div id="finalScore" class="subtitle"></div>
<button id="restartButton">RISE AGAIN</button>
</div>
</div>
<div id="hud">
<div id="networkBadge">OFFLINE</div>
<div id="damageFlash"></div>
<div id="message"></div>
<div id="topInfo">
<div id="waveText">Wave 1</div>
<div id="scoreText">Score 0 · Enemies 0</div>
</div>
<div id="bossBarWrap">
<div id="bossName">FELL CAPTAIN</div>
<div id="bossBar"><div id="bossFill"></div></div>
</div>
<div id="crosshair"></div>
<div id="bars">
<div class="bar-label"><span>VITALITY</span><span id="healthText">100 / 100</span></div>
<div class="bar"><div class="fill" id="healthFill"></div></div>
<div class="bar-label"><span>ARCANE POWER</span><span id="manaText">100 / 100</span></div>
<div class="bar"><div class="fill" id="manaFill"></div></div>
<div class="bar-label"><span>STAMINA</span><span id="staminaText">100 / 100</span></div>
<div class="bar"><div class="fill" id="staminaFill"></div></div>
</div>
<div id="spellPanel">
<div class="spell active" data-spell="0"><span class="key">1</span><span class="spell-name">Ember Bolt</span><span class="spell-cost">8</span></div>
<div class="spell" data-spell="1"><span class="key">2</span><span class="spell-name">Frost Lance</span><span class="spell-cost">14</span></div>
<div class="spell" data-spell="2"><span class="key">3</span><span class="spell-name">Storm Orb</span><span class="spell-cost">22</span></div>
<div class="spell" data-spell="3"><span class="key">4</span><span class="spell-name">Sunfire</span><span class="spell-cost">35</span></div>
</div>
<div id="footerHint">Collect glowing crystals for health and mana. Survive as long as you can.</div>
</div>
<script type="module">
import * as THREE from "https://cdn.jsdelivr.net/npm/three@0.166.1/build/three.module.js";
// ---------- Setup ----------
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x060912);
scene.fog = new THREE.FogExp2(0x070a10, 0.022);
const camera = new THREE.PerspectiveCamera(76, innerWidth / innerHeight, 0.1, 400);
camera.rotation.order = "YXZ";
const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: "high-performance" });
renderer.setSize(innerWidth, innerHeight);
renderer.setPixelRatio(Math.min(devicePixelRatio, 1.75));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.outputColorSpace = THREE.SRGBColorSpace;
document.body.prepend(renderer.domElement);
const clock = new THREE.Clock();
const raycaster = new THREE.Raycaster();
const arenaRadius = 46;
const hemi = new THREE.HemisphereLight(0x4b6285, 0x151008, 1.15);
scene.add(hemi);
const moon = new THREE.DirectionalLight(0xa8c7ff, 2.25);
moon.position.set(-25, 40, -15);
moon.castShadow = true;
moon.shadow.mapSize.set(2048, 2048);
moon.shadow.camera.left = -65;
moon.shadow.camera.right = 65;
moon.shadow.camera.top = 65;
moon.shadow.camera.bottom = -65;
scene.add(moon);
// ---------- Utility ----------
const rand = (a, b) => a + Math.random() * (b - a);
const clamp = THREE.MathUtils.clamp;
function cylinder(radiusTop, radiusBottom, height, color, segments = 8) {
const m = new THREE.Mesh(
new THREE.CylinderGeometry(radiusTop, radiusBottom, height, segments),
new THREE.MeshStandardMaterial({ color, roughness: 0.85, metalness: 0.08 })
);
m.castShadow = true;
m.receiveShadow = true;
return m;
}
function addTorch(x, z, y = 2.8) {
const holder = cylinder(.13, .16, 2.3, 0x33271e, 6);
holder.position.set(x, y - 1.15, z);
scene.add(holder);
const flame = new THREE.Mesh(
new THREE.SphereGeometry(.25, 8, 8),
new THREE.MeshBasicMaterial({ color: 0xff8f32 })
);
flame.scale.y = 1.55;
flame.position.set(x, y, z);
scene.add(flame);
const light = new THREE.PointLight(0xff7b2d, 3.2, 15, 2);
light.position.set(x, y, z);
scene.add(light);
torches.push({ flame, light, seed: Math.random() * 10 });
}
function makeTextSprite(text, color = "#ffffff") {
const canvas = document.createElement("canvas");
canvas.width = 256;
canvas.height = 64;
const ctx = canvas.getContext("2d");
ctx.font = "bold 30px Georgia";
ctx.textAlign = "center";
ctx.strokeStyle = "#000";
ctx.lineWidth = 6;
ctx.strokeText(text, 128, 42);
ctx.fillStyle = color;
ctx.fillText(text, 128, 42);
const texture = new THREE.CanvasTexture(canvas);
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: texture, transparent: true }));
sprite.scale.set(4, 1, 1);
return sprite;
}
// ---------- LAN multiplayer ----------
const net = {
mode: "offline",
socket: null,
id: null,
connected: false,
isHost: false,
peers: new Map(),
poseTimer: 0,
snapshotTimer: 0
};
function setNetworkStatus(text, bad = false) {
const el = document.getElementById("networkStatus");
el.textContent = text;
el.style.color = bad ? "#ff8b78" : "#b8dcae";
}
function setNetworkBadge(text) {
document.getElementById("networkBadge").textContent = text;
}
function wsAddress(raw) {
let value = raw.trim();
if (!value) value = "127.0.0.1:8787";
if (!/^wss?:\/\//i.test(value)) value = `ws://${value}`;
return value;
}
function netSend(message) {
if (net.socket?.readyState === WebSocket.OPEN) {
net.socket.send(JSON.stringify(message));
}
}
function makeRemotePlayer(id) {
const group = new THREE.Group();
const robe = cylinder(.42, .62, 1.45, 0x39485f, 8);
robe.position.y = .8;
group.add(robe);
const head = new THREE.Mesh(
new THREE.SphereGeometry(.34, 10, 8),
new THREE.MeshStandardMaterial({ color: 0xc6a27e, roughness: .8 })
);
head.position.y = 1.75;
head.castShadow = true;
group.add(head);
const hat = new THREE.Mesh(
new THREE.ConeGeometry(.48, 1.2, 10),
new THREE.MeshStandardMaterial({ color: 0x1c2534, roughness: .9 })
);
hat.position.y = 2.38;
hat.rotation.z = -.08;
group.add(hat);
const staff = new THREE.Mesh(
new THREE.CylinderGeometry(.045, .06, 2.15, 7),
new THREE.MeshStandardMaterial({ color: 0x513820, roughness: .9 })
);
staff.position.set(.62, 1.03, 0);
staff.rotation.z = -.15;
group.add(staff);
const crystal = new THREE.Mesh(
new THREE.OctahedronGeometry(.16),
new THREE.MeshBasicMaterial({ color: 0x70cfff })
);
crystal.position.set(.78, 2.08, 0);
group.add(crystal);
group.position.set(0, 0, 12);
scene.add(group);
const peer = {
id,
group,
targetPosition: group.position.clone(),
targetYaw: 0
};
net.peers.set(id, peer);
return peer;
}
function removeRemotePlayer(id) {
const peer = net.peers.get(id);
if (!peer) return;
scene.remove(peer.group);
net.peers.delete(id);
}
function applyWorldSnapshot(data) {
if (net.isHost) return;
wave = data.wave;
score = data.score;
kills = data.kills;
const incoming = new Set();
for (const state of data.enemies || []) {
incoming.add(state.id);
let enemy = enemies.find(e => e.networkId === state.id);
if (!enemy) enemy = createEnemy(state.type, state.x, state.z, state.id);
enemy.group.position.set(state.x, state.y || 0, state.z);
enemy.group.rotation.y = state.rotY || 0;
enemy.hp = state.hp;
enemy.maxHp = state.maxHp;
if (enemy.type === "boss") {
boss = enemy;
document.getElementById("bossBarWrap").style.display = "block";
document.getElementById("bossFill").style.width =
`${Math.max(0, enemy.hp / enemy.maxHp * 100)}%`;
}
}
for (const enemy of [...enemies]) {
if (!incoming.has(enemy.networkId)) {
if (enemy === boss) {
boss = null;
document.getElementById("bossBarWrap").style.display = "none";
}
scene.remove(enemy.group);
enemies.splice(enemies.indexOf(enemy), 1);
}
}
updateHUD();
}
function broadcastWorldSnapshot() {
netSend({
type: "snapshot",
wave,
score,
kills,
enemies: enemies.map(e => ({
id: e.networkId,
type: e.type,
x: e.group.position.x,
y: e.group.position.y,
z: e.group.position.z,
rotY: e.group.rotation.y,
hp: e.hp,
maxHp: e.maxHp
}))
});
}
function spawnSpellProjectile(spellIndex, origin, direction, cosmetic = false) {
const s = spells[spellIndex];
const dir = direction.clone().normalize();
const mesh = new THREE.Mesh(
new THREE.SphereGeometry(s.radius, 12, 10),
new THREE.MeshBasicMaterial({ color: s.color })
);
mesh.position.copy(origin);
scene.add(mesh);
const light = new THREE.PointLight(s.color, spellIndex === 3 ? 5 : 3, spellIndex === 3 ? 12 : 7);
mesh.add(light);
if (s.kind === "frost") mesh.scale.set(.55, .55, 2.2);
projectiles.push({
mesh,
velocity: dir.multiplyScalar(s.speed),
life: 3.2,
spell: s,
cosmetic
});
spawnBurst(origin, s.color, spellIndex === 3 ? 16 : 6);
}
function handleNetworkMessage(message) {
if (message.type === "welcome") {
net.id = message.id;
return;
}
if (message.type === "host-accepted") {
net.connected = true;
net.isHost = true;
net.mode = "host";
setNetworkStatus("Hosting successfully. Other players can join using this computer's LAN IP.");
setNetworkBadge("LAN HOST");
beginGame();
return;
}
if (message.type === "join-accepted") {
net.connected = true;
net.isHost = false;
net.mode = "client";
setNetworkStatus("Connected to the host.");
setNetworkBadge("LAN CLIENT");
beginGame();
return;
}
if (message.type === "error") {
setNetworkStatus(message.message || "Connection failed.", true);
return;
}
if (message.type === "pose" && message.id !== net.id) {
const peer = net.peers.get(message.id) || makeRemotePlayer(message.id);
peer.targetPosition.set(message.x, 0, message.z);
peer.targetYaw = message.yaw;
return;
}
if (message.type === "peer-left") {
removeRemotePlayer(message.id);
return;
}
if (message.type === "cast" && message.id !== net.id) {
const origin = new THREE.Vector3(message.origin.x, message.origin.y, message.origin.z);
const direction = new THREE.Vector3(message.direction.x, message.direction.y, message.direction.z);
spawnSpellProjectile(message.spellIndex, origin, direction, !net.isHost);
return;
}
if (message.type === "snapshot") {
applyWorldSnapshot(message);
return;
}
if (message.type === "host-left") {
setNetworkBadge("HOST DISCONNECTED");
showMessage("THE HOST HAS LEFT", 4);
}
}
function connectLan(asHost) {
if (net.socket) {
try { net.socket.close(); } catch {}
}
const raw = asHost ? "127.0.0.1:8787" : document.getElementById("joinIp").value;
const address = wsAddress(raw);
setNetworkStatus(`Connecting to ${address}...`);
const socket = new WebSocket(address);
net.socket = socket;
socket.addEventListener("open", () => {
netSend({ type: asHost ? "claim-host" : "join" });
});
socket.addEventListener("message", event => {
try { handleNetworkMessage(JSON.parse(event.data)); }
catch (error) { console.warn("Bad LAN packet", error); }
});
socket.addEventListener("error", () => {
setNetworkStatus(
asHost
? "Could not reach the LAN server. Run: node fell-crown-lan-server.js"
: "Could not connect. Check the IP, port, and that the host server is running.",
true
);
});
socket.addEventListener("close", () => {
if (net.connected && !gameEnded) {
setNetworkBadge("LAN DISCONNECTED");
}
net.connected = false;
});
}
function updateNetworking(dt) {
if (!net.connected) return;
net.poseTimer += dt;
if (net.poseTimer >= .08) {
net.poseTimer = 0;
netSend({
type: "pose",
x: player.position.x,
y: player.position.y,
z: player.position.z,
yaw: player.yaw
});
}
if (net.isHost) {
net.snapshotTimer += dt;
if (net.snapshotTimer >= .1) {
net.snapshotTimer = 0;
broadcastWorldSnapshot();
}
}
for (const peer of net.peers.values()) {
peer.group.position.lerp(peer.targetPosition, 1 - Math.exp(-14 * dt));
let delta = peer.targetYaw - peer.group.rotation.y;
delta = Math.atan2(Math.sin(delta), Math.cos(delta));
peer.group.rotation.y += delta * (1 - Math.exp(-14 * dt));
}
}
// ---------- World ----------
const torches = [];
const obstacles = [];
const ground = new THREE.Mesh(
new THREE.CircleGeometry(arenaRadius + 5, 72),
new THREE.MeshStandardMaterial({ color: 0x171a18, roughness: .96, metalness: 0 })
);
ground.rotation.x = -Math.PI / 2;
ground.receiveShadow = true;
scene.add(ground);
// Stone rings.
for (let r = 8; r < arenaRadius; r += 7) {
const ring = new THREE.Mesh(
new THREE.RingGeometry(r - .08, r + .08, 72),
new THREE.MeshBasicMaterial({ color: 0x30352f, transparent: true, opacity: .55, side: THREE.DoubleSide })
);
ring.rotation.x = -Math.PI / 2;
ring.position.y = .012;
scene.add(ring);
}
// Fortress wall.
const wallMat = new THREE.MeshStandardMaterial({ color: 0x252931, roughness: .9 });
for (let i = 0; i < 36; i++) {
const a = i / 36 * Math.PI * 2;
const radius = arenaRadius + 2;
const wall = new THREE.Mesh(new THREE.BoxGeometry(8.6, 8, 3.2), wallMat);
wall.position.set(Math.cos(a) * radius, 4, Math.sin(a) * radius);
wall.rotation.y = -a;
wall.castShadow = true;
wall.receiveShadow = true;
scene.add(wall);
if (i % 4 === 0) {
const tower = cylinder(3.2, 3.8, 13, 0x20252c, 10);
tower.position.set(Math.cos(a) * radius, 6.5, Math.sin(a) * radius);
scene.add(tower);
for (let c = 0; c < 6; c++) {
const crenel = new THREE.Mesh(new THREE.BoxGeometry(1.2, 1.8, 1.2), wallMat);
const ca = c / 6 * Math.PI * 2;
crenel.position.set(
Math.cos(a) * radius + Math.cos(ca) * 2.8,
13.9,
Math.sin(a) * radius + Math.sin(ca) * 2.8
);
crenel.castShadow = true;
scene.add(crenel);
}
}
}
// Central ruin and scattered cover.
const altar = new THREE.Group();
const altarBase = cylinder(4.3, 5.2, 1.2, 0x34383b, 8);
altarBase.position.y = .6;
altar.add(altarBase);
const altarTop = cylinder(2, 2.8, 2.2, 0x282c32, 8);
altarTop.position.y = 1.9;
altar.add(altarTop);
const rune = new THREE.Mesh(
new THREE.TorusGeometry(1.15, .09, 10, 30),
new THREE.MeshBasicMaterial({ color: 0x7b1cff })
);
rune.rotation.x = Math.PI / 2;
rune.position.y = 3.05;
altar.add(rune);
scene.add(altar);
obstacles.push({ x: 0, z: 0, radius: 5.4 });
for (let i = 0; i < 14; i++) {
const a = i / 14 * Math.PI * 2 + rand(-.18, .18);
const r = rand(13, 34);
const h = rand(3, 8);
const pillar = cylinder(rand(.75, 1.2), rand(.95, 1.5), h, 0x30343a, 7);
pillar.position.set(Math.cos(a) * r, h / 2, Math.sin(a) * r);
pillar.rotation.z = rand(-.08, .08);
scene.add(pillar);
obstacles.push({ x: pillar.position.x, z: pillar.position.z, radius: 1.5 });
if (i % 2 === 0) addTorch(Math.cos(a) * (r + 2), Math.sin(a) * (r + 2), 3.5);
}
// Distant mountains.
for (let i = 0; i < 24; i++) {
const a = i / 24 * Math.PI * 2;
const r = rand(75, 110);
const mountain = new THREE.Mesh(
new THREE.ConeGeometry(rand(9, 20), rand(25, 55), 5),
new THREE.MeshStandardMaterial({ color: 0x10141b, roughness: 1 })
);
mountain.position.set(Math.cos(a) * r, mountain.geometry.parameters.height / 2 - 5, Math.sin(a) * r);
mountain.rotation.y = rand(0, Math.PI);
scene.add(mountain);
}
// Stars.
const starGeo = new THREE.BufferGeometry();
const stars = [];
for (let i = 0; i < 900; i++) {
const r = rand(80, 180);
const theta = rand(0, Math.PI * 2);
const phi = rand(0.05, 1.35);
stars.push(
Math.sin(phi) * Math.cos(theta) * r,
Math.cos(phi) * r + 30,
Math.sin(phi) * Math.sin(theta) * r
);
}
starGeo.setAttribute("position", new THREE.Float32BufferAttribute(stars, 3));
scene.add(new THREE.Points(starGeo, new THREE.PointsMaterial({ color: 0x9fb8df, size: .35 })));
// ---------- Player ----------
const player = {
position: new THREE.Vector3(0, 1.7, 15),
velocity: new THREE.Vector3(),
yaw: 0,
pitch: 0,
health: 100,
maxHealth: 100,
mana: 100,
maxMana: 100,
stamina: 100,
maxStamina: 100,
grounded: true,
radius: .65,
shockwaveCooldown: 0
};
camera.position.copy(player.position);
const keys = {};
let locked = false;
let running = false;
let gameEnded = false;
addEventListener("keydown", e => {
keys[e.code] = true;
if (["Digit1","Digit2","Digit3","Digit4"].includes(e.code)) {
selectedSpell = Number(e.code.slice(-1)) - 1;
refreshSpellUI();
}
if (e.code === "KeyQ") castShockwave();
if (e.code === "Space") e.preventDefault();
});
addEventListener("keyup", e => keys[e.code] = false);
document.addEventListener("pointerlockchange", () => {
locked = document.pointerLockElement === renderer.domElement;
});
document.addEventListener("mousemove", e => {
if (!locked || gameEnded) return;
player.yaw -= e.movementX * .0022;
player.pitch -= e.movementY * .0022;
player.pitch = clamp(player.pitch, -1.48, 1.48);
});
renderer.domElement.addEventListener("click", () => {
if (!running || gameEnded) return;
if (!locked) renderer.domElement.requestPointerLock();
else castSelectedSpell();
});
renderer.domElement.addEventListener("wheel", e => {
if (!running) return;
selectedSpell = (selectedSpell + (e.deltaY > 0 ? 1 : 3)) % 4;
refreshSpellUI();
}, { passive: true });
// ---------- Enemies ----------
const enemies = [];
const enemyProjectiles = [];
const pickups = [];
let wave = 0;
let score = 0;
let kills = 0;
let wavePending = false;
let boss = null;
function createEnemy(type, x, z, networkId = null) {
const group = new THREE.Group();
let hp, speed, damage, scale, color, name;
if (type === "orc") {
hp = 48; speed = 3.2; damage = 9; scale = 1; color = 0x536b3d; name = "Raider";
} else if (type === "wraith") {
hp = 70; speed = 2.55; damage = 13; scale = 1.15; color = 0x4d5b78; name = "Wraith";
} else if (type === "brute") {
hp = 135; speed = 1.8; damage = 20; scale = 1.45; color = 0x6c4b3b; name = "Uruk Brute";
} else {
hp = 650 + wave * 60; speed = 1.65; damage = 27; scale = 2.1; color = 0x782f20; name = "Fell Captain";
}
const body = cylinder(.55 * scale, .75 * scale, 1.7 * scale, color, 7);
body.position.y = 1.05 * scale;
group.add(body);
const head = new THREE.Mesh(
new THREE.SphereGeometry(.48 * scale, 10, 8),
new THREE.MeshStandardMaterial({ color: type === "wraith" ? 0x24344a : 0x6d7650, roughness: .8 })
);
head.position.y = 2.15 * scale;
head.castShadow = true;
group.add(head);
const eyes = [];
for (const side of [-1, 1]) {
const eye = new THREE.Mesh(
new THREE.SphereGeometry(.055 * scale, 6, 6),
new THREE.MeshBasicMaterial({ color: type === "wraith" ? 0x73c6ff : 0xff3d1f })
);
eye.position.set(.18 * side * scale, 2.23 * scale, .43 * scale);
group.add(eye);
eyes.push(eye);
}
const weapon = new THREE.Mesh(
new THREE.BoxGeometry(.14 * scale, 1.55 * scale, .14 * scale),
new THREE.MeshStandardMaterial({ color: 0x6f7378, metalness: .65, roughness: .35 })
);
weapon.position.set(.85 * scale, 1.2 * scale, 0);
weapon.rotation.z = -.28;
weapon.castShadow = true;
group.add(weapon);
if (type === "wraith") {
const cloak = new THREE.Mesh(
new THREE.ConeGeometry(.92 * scale, 2.3 * scale, 8, 1, true),
new THREE.MeshStandardMaterial({ color: 0x18202d, transparent: true, opacity: .8, side: THREE.DoubleSide })
);
cloak.position.y = .9 * scale;
group.add(cloak);
}
if (type === "boss") {
const horns = [];
for (const side of [-1, 1]) {
const horn = new THREE.Mesh(
new THREE.ConeGeometry(.14 * scale, .75 * scale, 7),
new THREE.MeshStandardMaterial({ color: 0x171717 })
);
horn.position.set(.38 * side * scale, 2.62 * scale, 0);
horn.rotation.z = side * -.5;
group.add(horn);
horns.push(horn);
}
const label = makeTextSprite("FELL CAPTAIN", "#ff9b62");
label.position.y = 3.35 * scale;
group.add(label);
}
group.position.set(x, 0, z);
scene.add(group);
const enemy = {
group, type, hp, maxHp: hp, speed, damage, scale, name,
networkId: networkId || `enemy-${Date.now()}-${Math.random().toString(36).slice(2)}`,
radius: .72 * scale,
attackCooldown: rand(.4, 1.1),
rangedCooldown: rand(1.5, 3),
hitFlash: 0,
slow: 0,
dead: false,
bob: Math.random() * Math.PI * 2
};
enemies.push(enemy);
if (type === "boss") {
boss = enemy;
document.getElementById("bossBarWrap").style.display = "block";
document.getElementById("bossName").textContent = name.toUpperCase();
}
return enemy;
}
function randomSpawnPoint() {
const a = Math.random() * Math.PI * 2;
const r = rand(35, 42);
return { x: Math.cos(a) * r, z: Math.sin(a) * r };
}
function startWave() {
wave++;
wavePending = false;
showMessage(wave % 5 === 0 ? `WAVE ${wave}\nTHE FELL CAPTAIN COMES` : `WAVE ${wave}`, 2.2);
const count = 4 + Math.floor(wave * 1.55);
for (let i = 0; i < count; i++) {
setTimeout(() => {
if (gameEnded) return;
const p = randomSpawnPoint();
let type = "orc";
const roll = Math.random();
if (wave >= 3 && roll < .22) type = "wraith";
if (wave >= 4 && roll > .82) type = "brute";
createEnemy(type, p.x, p.z);
}, i * 220);
}
if (wave % 5 === 0) {
setTimeout(() => {
if (gameEnded) return;
const p = randomSpawnPoint();
createEnemy("boss", p.x, p.z);
}, count * 220 + 500);
}
updateHUD();
}
function damageEnemy(enemy, amount, impact, kind = "fire") {
if (!enemy || enemy.dead) return;
enemy.hp -= amount;
enemy.hitFlash = .11;
if (kind === "frost") enemy.slow = 2.4;
spawnBurst(impact || enemy.group.position.clone().add(new THREE.Vector3(0, 1.3, 0)),
kind === "frost" ? 0x73d8ff : kind === "lightning" ? 0xb58cff : 0xff6a2f,
kind === "sun" ? 22 : 9
);
if (enemy === boss) {
document.getElementById("bossFill").style.width = `${Math.max(0, enemy.hp / enemy.maxHp * 100)}%`;
}
if (enemy.hp <= 0) killEnemy(enemy);
}
function killEnemy(enemy) {
enemy.dead = true;
score += Math.round(enemy.maxHp * (enemy.type === "boss" ? 3 : 1));
kills++;
if (enemy.type === "boss") {
showMessage("THE CAPTAIN HAS FALLEN", 2.5);
boss = null;
document.getElementById("bossBarWrap").style.display = "none";
}
spawnBurst(enemy.group.position.clone().add(new THREE.Vector3(0, enemy.scale, 0)), 0xff4c24, 30);
scene.remove(enemy.group);
if (Math.random() < .24 || enemy.type === "boss") {
spawnPickup(enemy.group.position.x, enemy.group.position.z, Math.random() < .48 ? "health" : "mana");
}
const index = enemies.indexOf(enemy);
if (index >= 0) enemies.splice(index, 1);
updateHUD();
if (enemies.length === 0 && !wavePending) {
wavePending = true;
setTimeout(() => {
if (!gameEnded && enemies.length === 0) startWave();
else wavePending = false;
}, 2200);
}
}
// ---------- Pickups ----------
function spawnPickup(x, z, type) {
const group = new THREE.Group();
const color = type === "health" ? 0x52ff78 : 0x4dc3ff;
const crystal = new THREE.Mesh(
new THREE.OctahedronGeometry(.48),
new THREE.MeshStandardMaterial({ color, emissive: color, emissiveIntensity: 1.35, roughness: .2 })
);
crystal.castShadow = true;
group.add(crystal);
const ring = new THREE.Mesh(
new THREE.TorusGeometry(.68, .055, 8, 24),
new THREE.MeshBasicMaterial({ color, transparent: true, opacity: .75 })
);
ring.rotation.x = Math.PI / 2;
group.add(ring);
const light = new THREE.PointLight(color, 2.4, 8);
group.add(light);
group.position.set(x, .8, z);
scene.add(group);
pickups.push({ group, type, life: 18, seed: Math.random() * 10 });
}
function collectPickup(p) {
if (p.type === "health") {
player.health = Math.min(player.maxHealth, player.health + 32);
showMessage("+32 VITALITY", 1);
} else {
player.mana = Math.min(player.maxMana, player.mana + 45);
showMessage("+45 ARCANE POWER", 1);
}
scene.remove(p.group);
pickups.splice(pickups.indexOf(p), 1);
updateHUD();
}
// ---------- Magic ----------
const projectiles = [];
const particles = [];
let selectedSpell = 0;
const spells = [
{ name: "Ember Bolt", cost: 8, cooldown: .22, damage: 30, speed: 36, radius: .18, color: 0xff5a21, kind: "fire" },
{ name: "Frost Lance", cost: 14, cooldown: .52, damage: 48, speed: 48, radius: .14, color: 0x6edcff, kind: "frost" },
{ name: "Storm Orb", cost: 22, cooldown: .95, damage: 72, speed: 24, radius: .34, color: 0x956cff, kind: "lightning", splash: 3.8 },
{ name: "Sunfire", cost: 35, cooldown: 1.7, damage: 115, speed: 31, radius: .48, color: 0xffc63d, kind: "sun", splash: 5.3 }
];
const cooldowns = [0, 0, 0, 0];
function refreshSpellUI() {
document.querySelectorAll(".spell").forEach((el, i) => {
el.classList.toggle("active", i === selectedSpell);
});
}
function castSelectedSpell() {
const s = spells[selectedSpell];
if (cooldowns[selectedSpell] > 0 || player.mana < s.cost || gameEnded) return;
player.mana -= s.cost;
cooldowns[selectedSpell] = s.cooldown;
const dir = new THREE.Vector3(0, 0, -1).applyEuler(camera.rotation).normalize();
const origin = camera.position.clone().add(dir.clone().multiplyScalar(.8));
origin.y -= .08;
const isLanClient = net.connected && !net.isHost;
spawnSpellProjectile(selectedSpell, origin, dir, isLanClient);
if (net.connected) {
netSend({
type: "cast",
spellIndex: selectedSpell,
origin: { x: origin.x, y: origin.y, z: origin.z },
direction: { x: dir.x, y: dir.y, z: dir.z }
});
}
updateHUD();
}
function castShockwave() {
if (!running || !locked || player.shockwaveCooldown > 0 || player.mana < 45 || gameEnded) return;
player.mana -= 45;
player.shockwaveCooldown = 7;
const ring = new THREE.Mesh(
new THREE.RingGeometry(.5, .75, 48),
new THREE.MeshBasicMaterial({ color: 0xd8b9ff, transparent: true, opacity: .9, side: THREE.DoubleSide })
);
ring.rotation.x = -Math.PI / 2;
ring.position.set(player.position.x, .12, player.position.z);
scene.add(ring);
particles.push({ mesh: ring, life: .7, maxLife: .7, expand: 16, ring: true });
for (const enemy of [...enemies]) {
const d = enemy.group.position.distanceTo(player.position);
if (d < 9.5) {
damageEnemy(enemy, 62, enemy.group.position.clone().add(new THREE.Vector3(0,1,0)), "lightning");
const away = enemy.group.position.clone().sub(player.position).setY(0).normalize();
enemy.group.position.addScaledVector(away, Math.max(0, 4.5 - d * .2));
}
}
showMessage("ARCANE SHOCKWAVE", 1);
}
function explodeProjectile(p, pos) {
const s = p.spell;
if (s.splash) {
for (const enemy of [...enemies]) {
const d = enemy.group.position.distanceTo(pos);
if (d < s.splash) {
const falloff = 1 - d / s.splash * .55;
damageEnemy(enemy, s.damage * falloff, enemy.group.position.clone().add(new THREE.Vector3(0, 1, 0)), s.kind);
}
}
}
spawnBurst(pos, s.color, s.kind === "sun" ? 40 : 20);
scene.remove(p.mesh);
const i = projectiles.indexOf(p);
if (i >= 0) projectiles.splice(i, 1);
}
function spawnBurst(position, color, count = 10) {
for (let i = 0; i < count; i++) {
const size = rand(.035, .12);
const mesh = new THREE.Mesh(
new THREE.SphereGeometry(size, 5, 5),
new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 1 })
);
mesh.position.copy(position);
scene.add(mesh);
particles.push({
mesh,
velocity: new THREE.Vector3(rand(-3.5, 3.5), rand(-1, 4.5), rand(-3.5, 3.5)),
life: rand(.25, .8),
maxLife: .8
});
}
}
// ---------- Combat and movement ----------
function damagePlayer(amount) {
if (gameEnded) return;
player.health -= amount;
const flash = document.getElementById("damageFlash");
flash.style.opacity = ".68";
setTimeout(() => flash.style.opacity = "0", 90);
updateHUD();
if (player.health <= 0) endGame();
}
function resolvePlayerCollisions(next) {
const horizontal = new THREE.Vector2(next.x, next.z);
const len = horizontal.length();
if (len > arenaRadius - 1.2) {
horizontal.setLength(arenaRadius - 1.2);
next.x = horizontal.x;
next.z = horizontal.y;
}
for (const o of obstacles) {
const dx = next.x - o.x;
const dz = next.z - o.z;
const dist = Math.hypot(dx, dz);
const min = o.radius + player.radius;
if (dist < min) {
const nx = dist > .001 ? dx / dist : 1;
const nz = dist > .001 ? dz / dist : 0;
next.x = o.x + nx * min;
next.z = o.z + nz * min;
}
}
}
function updatePlayer(dt) {
camera.rotation.set(player.pitch, player.yaw, 0);
const forward = new THREE.Vector3(-Math.sin(player.yaw), 0, -Math.cos(player.yaw));
const right = new THREE.Vector3(Math.cos(player.yaw), 0, -Math.sin(player.yaw));
const input = new THREE.Vector3();
if (keys.KeyW) input.add(forward);
if (keys.KeyS) input.sub(forward);
if (keys.KeyD) input.add(right);
if (keys.KeyA) input.sub(right);
if (input.lengthSq() > 0) input.normalize();
const sprinting = (keys.ShiftLeft || keys.ShiftRight) && player.stamina > 0 && input.lengthSq() > 0;
const speed = sprinting ? 10.2 : 6.4;
if (sprinting) player.stamina = Math.max(0, player.stamina - 28 * dt);
else player.stamina = Math.min(player.maxStamina, player.stamina + 18 * dt);
player.velocity.x = THREE.MathUtils.damp(player.velocity.x, input.x * speed, 12, dt);
player.velocity.z = THREE.MathUtils.damp(player.velocity.z, input.z * speed, 12, dt);
if (keys.Space && player.grounded) {
player.velocity.y = 7.2;
player.grounded = false;
}
player.velocity.y -= 19 * dt;
const next = player.position.clone().addScaledVector(player.velocity, dt);
if (next.y <= 1.7) {
next.y = 1.7;
player.velocity.y = 0;
player.grounded = true;
}
resolvePlayerCollisions(next);
player.position.copy(next);
camera.position.copy(player.position);
player.mana = Math.min(player.maxMana, player.mana + 8.5 * dt);
player.shockwaveCooldown = Math.max(0, player.shockwaveCooldown - dt);
}
function updateEnemies(dt, time) {
for (const enemy of [...enemies]) {
if (enemy.dead) continue;
enemy.attackCooldown -= dt;
enemy.rangedCooldown -= dt;
enemy.hitFlash -= dt;
enemy.slow = Math.max(0, enemy.slow - dt);
const bodyMat = enemy.group.children[0]?.material;
if (bodyMat?.emissive) {
bodyMat.emissive.setHex(enemy.hitFlash > 0 ? 0x551111 : 0x000000);
}
const toPlayer = player.position.clone().sub(enemy.group.position);
toPlayer.y = 0;
const dist = toPlayer.length();
const dir = dist > .001 ? toPlayer.normalize() : new THREE.Vector3();
const slowFactor = enemy.slow > 0 ? .42 : 1;
const desired = enemy.type === "wraith" ? 7 : (enemy.type === "boss" ? 4.2 : 1.55);
if (dist > desired) {
enemy.group.position.addScaledVector(dir, enemy.speed * slowFactor * dt);
} else if (enemy.type === "wraith" && dist < 5) {
enemy.group.position.addScaledVector(dir, -enemy.speed * .4 * dt);
}
enemy.group.lookAt(player.position.x, enemy.group.position.y + enemy.scale, player.position.z);
enemy.group.position.y = Math.sin(time * 2.1 + enemy.bob) * (enemy.type === "wraith" ? .22 : .035);
// Prevent enemies stacking directly.
for (const other of enemies) {
if (other === enemy || other.dead) continue;
const d = enemy.group.position.distanceTo(other.group.position);
const min = enemy.radius + other.radius;
if (d < min && d > .001) {
enemy.group.position.addScaledVector(
enemy.group.position.clone().sub(other.group.position).normalize(),
(min - d) * .018
);
}
}
if (dist < 2.1 * enemy.scale && enemy.attackCooldown <= 0) {
damagePlayer(enemy.damage);
enemy.attackCooldown = enemy.type === "boss" ? 1.05 : rand(.8, 1.35);
const push = player.position.clone().sub(enemy.group.position).setY(0).normalize();
player.position.addScaledVector(push, .85);
}
if ((enemy.type === "wraith" || enemy.type === "boss") && dist < 25 && enemy.rangedCooldown <= 0) {
const orb = new THREE.Mesh(
new THREE.SphereGeometry(enemy.type === "boss" ? .34 : .22, 9, 8),
new THREE.MeshBasicMaterial({ color: enemy.type === "boss" ? 0xff441f : 0x54a8ff })
);
const origin = enemy.group.position.clone().add(new THREE.Vector3(0, 1.7 * enemy.scale, 0));
orb.position.copy(origin);
scene.add(orb);
const aim = player.position.clone().sub(origin).normalize();
enemyProjectiles.push({
mesh: orb,
velocity: aim.multiplyScalar(enemy.type === "boss" ? 12 : 9),
damage: enemy.type === "boss" ? 18 : 10,
life: 4
});
enemy.rangedCooldown = enemy.type === "boss" ? rand(1.4, 2.1) : rand(2.4, 3.6);
}
}
}
function updateProjectiles(dt) {
for (const p of [...projectiles]) {
p.life -= dt;
const old = p.mesh.position.clone();
p.mesh.position.addScaledVector(p.velocity, dt);
p.mesh.rotation.z += dt * 10;
// Trail.
if (Math.random() < .72) spawnBurst(p.mesh.position, p.spell.color, 1);
let hit = null;
if (!p.cosmetic) for (const enemy of enemies) {
if (p.mesh.position.distanceTo(enemy.group.position.clone().add(new THREE.Vector3(0, enemy.scale, 0))) < enemy.radius + p.spell.radius) {
hit = enemy;
break;
}
}
if (hit) {
if (p.spell.splash) explodeProjectile(p, p.mesh.position.clone());
else {
damageEnemy(hit, p.spell.damage, p.mesh.position.clone(), p.spell.kind);
scene.remove(p.mesh);
projectiles.splice(projectiles.indexOf(p), 1);
}
} else if (
p.life <= 0 ||
p.mesh.position.y < .1 ||
Math.hypot(p.mesh.position.x, p.mesh.position.z) > arenaRadius + 3
) {
if (p.spell.splash && p.mesh.position.y < .3) explodeProjectile(p, p.mesh.position.clone());
else {
scene.remove(p.mesh);
projectiles.splice(projectiles.indexOf(p), 1);
}
}
}
for (const p of [...enemyProjectiles]) {
p.life -= dt;
p.mesh.position.addScaledVector(p.velocity, dt);
if (p.mesh.position.distanceTo(player.position) < .65) {
damagePlayer(p.damage);
spawnBurst(p.mesh.position.clone(), p.mesh.material.color.getHex(), 14);
scene.remove(p.mesh);
enemyProjectiles.splice(enemyProjectiles.indexOf(p), 1);
} else if (p.life <= 0 || p.mesh.position.y < 0) {
scene.remove(p.mesh);
enemyProjectiles.splice(enemyProjectiles.indexOf(p), 1);
}
}
}
function updateParticles(dt) {
for (const p of [...particles]) {
p.life -= dt;
if (p.ring) {
const progress = 1 - p.life / p.maxLife;
const s = 1 + progress * p.expand;
p.mesh.scale.setScalar(s);
p.mesh.material.opacity = Math.max(0, p.life / p.maxLife);
} else {
p.velocity.y -= 6 * dt;
p.mesh.position.addScaledVector(p.velocity, dt);
p.mesh.material.opacity = Math.max(0, p.life / p.maxLife);
p.mesh.scale.multiplyScalar(.97);
}
if (p.life <= 0) {
scene.remove(p.mesh);
particles.splice(particles.indexOf(p), 1);
}
}
}
function updatePickups(dt, time) {
for (const p of [...pickups]) {
p.life -= dt;
p.group.rotation.y += dt * 1.8;
p.group.position.y = .8 + Math.sin(time * 3 + p.seed) * .18;
if (p.group.position.distanceTo(player.position) < 1.45) collectPickup(p);
else if (p.life <= 0) {
scene.remove(p.group);
pickups.splice(pickups.indexOf(p), 1);
}
}
}
// ---------- UI ----------
let messageTimer = 0;
function showMessage(text, seconds = 1.5) {
const el = document.getElementById("message");
el.innerHTML = text.replace("\n", "<br>");
el.style.opacity = "1";
messageTimer = seconds;
}
function updateHUD() {
document.getElementById("healthFill").style.width = `${Math.max(0, player.health / player.maxHealth * 100)}%`;
document.getElementById("manaFill").style.width = `${player.mana / player.maxMana * 100}%`;
document.getElementById("staminaFill").style.width = `${player.stamina / player.maxStamina * 100}%`;
document.getElementById("healthText").textContent = `${Math.ceil(Math.max(0, player.health))} / ${player.maxHealth}`;
document.getElementById("manaText").textContent = `${Math.floor(player.mana)} / ${player.maxMana}`;
document.getElementById("staminaText").textContent = `${Math.floor(player.stamina)} / ${player.maxStamina}`;
document.getElementById("waveText").textContent = `Wave ${wave || 1}`;
document.getElementById("scoreText").textContent = `Score ${score} · Enemies ${enemies.length} · Kills ${kills}`;
document.querySelectorAll(".spell").forEach((el, i) => el.classList.toggle("cooling", cooldowns[i] > 0));
}
function endGame() {
gameEnded = true;
running = false;
document.exitPointerLock?.();
document.getElementById("finalScore").textContent = `You reached wave ${wave}, slew ${kills} foes, and earned ${score} points.`;
document.getElementById("gameOver").style.display = "grid";
}
function resetGame() {
for (const e of [...enemies]) scene.remove(e.group);
for (const p of [...projectiles]) scene.remove(p.mesh);
for (const p of [...enemyProjectiles]) scene.remove(p.mesh);
for (const p of [...pickups]) scene.remove(p.group);
enemies.length = projectiles.length = enemyProjectiles.length = pickups.length = 0;
player.position.set(0, 1.7, 15);
player.velocity.set(0,0,0);
player.health = player.maxHealth;
player.mana = player.maxMana;
player.stamina = player.maxStamina;
player.yaw = 0;
player.pitch = 0;
player.shockwaveCooldown = 0;
wave = 0; score = 0; kills = 0; boss = null; wavePending = false;
cooldowns.fill(0);
gameEnded = false;
running = true;
document.getElementById("gameOver").style.display = "none";
document.getElementById("bossBarWrap").style.display = "none";
renderer.domElement.requestPointerLock();
if (!net.connected || net.isHost) startWave();
updateHUD();
}
function beginGame() {
document.getElementById("startScreen").style.display = "none";
running = true;
renderer.domElement.requestPointerLock();
if (!net.connected || net.isHost) startWave();
else showMessage("JOINED THE FELLOWSHIP", 2);
}
document.getElementById("startButton").addEventListener("click", () => {
net.mode = "offline";
net.isHost = false;
setNetworkBadge("OFFLINE");
beginGame();
});
document.getElementById("hostButton").addEventListener("click", () => connectLan(true));
document.getElementById("joinButton").addEventListener("click", () => connectLan(false));
document.getElementById("joinIp").addEventListener("keydown", event => {
if (event.key === "Enter") connectLan(false);
});
document.getElementById("restartButton").addEventListener("click", resetGame);
// ---------- Main loop ----------
let hudAccumulator = 0;
function animate() {
requestAnimationFrame(animate);
const dt = Math.min(clock.getDelta(), .04);
const time = clock.elapsedTime;
for (const t of torches) {
const flicker = 1 + Math.sin(time * 13 + t.seed) * .12 + Math.sin(time * 23 + t.seed * 2) * .06;
t.flame.scale.set(.9, 1.4 * flicker, .9);
t.light.intensity = 3.05 * flicker;
}
if (running && !gameEnded) {
updatePlayer(dt);
if (!net.connected || net.isHost) updateEnemies(dt, time);
updateProjectiles(dt);
updateParticles(dt);
updatePickups(dt, time);
updateNetworking(dt);
for (let i = 0; i < cooldowns.length; i++) cooldowns[i] = Math.max(0, cooldowns[i] - dt);
if (messageTimer > 0) {
messageTimer -= dt;
if (messageTimer <= 0) document.getElementById("message").style.opacity = "0";
}
hudAccumulator += dt;
if (hudAccumulator > .08) {
hudAccumulator = 0;
updateHUD();
}
} else {
updateParticles(dt);
}
renderer.render(scene, camera);
}
addEventListener("resize", () => {
camera.aspect = innerWidth / innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
});
updateHUD();
animate();
</script>
</body>
</html>