Time only moves when you move, built by Gemini 4 Argon
Hostedvia DaBenchOpenRouter
$0.17
Show the code the model wroteHide the code
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CHRONO // BRUTAL — Time Only Moves When You Move</title>
<style>
:root {
--cyan: #00f0ff;
--amber: #ff5511;
--crimson: #ff1e38;
--bg-dark: rgba(6, 9, 14, 0.82);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
user-select: none;
-webkit-user-select: none;
}
html, body {
width: 100%;
height: 100%;
overflow: hidden;
background: #05070a;
font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, Roboto, Helvetica, monospace;
color: #e8eef5;
}
#game-canvas {
position: fixed;
inset: 0;
width: 100%;
height: 100%;
display: block;
}
/* HUD */
#hud {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 10;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 28px 36px;
transition: opacity 0.3s ease;
}
.hud-top {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.hud-panel {
background: linear-gradient(135deg, rgba(10, 15, 24, 0.78), rgba(6, 9, 14, 0.55));
border-left: 3px solid var(--cyan);
padding: 10px 18px;
backdrop-filter: blur(6px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
letter-spacing: 0.12em;
text-transform: uppercase;
}
.hud-label {
font-size: 10px;
color: rgba(200, 220, 240, 0.6);
margin-bottom: 4px;
font-weight: 600;
}
.hud-value {
font-size: 22px;
font-weight: 800;
color: #ffffff;
font-variant-numeric: tabular-nums;
text-shadow: 0 0 12px rgba(0, 240, 255, 0.4);
}
#hearts-container {
display: flex;
gap: 10px;
align-items: center;
margin-top: 2px;
}
.heart-icon {
width: 24px;
height: 22px;
fill: var(--crimson);
filter: drop-shadow(0 0 8px rgba(255, 30, 56, 0.75));
transition: transform 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275), opacity 0.25s ease;
}
.heart-icon.lost {
fill: rgba(255, 255, 255, 0.12);
filter: none;
transform: scale(0.82);
}
/* Center Crosshair */
#crosshair-wrap {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
pointer-events: none;
z-index: 11;
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
}
.ch-dot {
width: 4px;
height: 4px;
border-radius: 50%;
background: #fff;
box-shadow: 0 0 6px var(--cyan);
transition: transform 0.1s ease, background 0.1s ease;
}
.ch-line {
position: absolute;
background: rgba(255, 255, 255, 0.85);
box-shadow: 0 0 4px rgba(0, 240, 255, 0.6);
transition: transform 0.08s ease, background 0.12s ease;
}
.ch-top { width: 2px; height: 7px; top: 6px; }
.ch-bottom { width: 2px; height: 7px; bottom: 6px; }
.ch-left { width: 7px; height: 2px; left: 6px; }
.ch-right { width: 7px; height: 2px; right: 6px; }
#crosshair-wrap.hit .ch-line,
#crosshair-wrap.hit .ch-dot {
background: var(--crimson);
box-shadow: 0 0 10px var(--crimson);
}
/* Bottom HUD & Time Dial */
.hud-bottom {
display: flex;
justify-content: space-between;
align-items: flex-end;
}
#time-dial-wrap {
display: flex;
align-items: center;
gap: 16px;
background: linear-gradient(135deg, rgba(10, 15, 24, 0.82), rgba(6, 9, 14, 0.6));
border-left: 3px solid var(--amber);
padding: 12px 22px;
backdrop-filter: blur(6px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
}
#time-dial-svg {
width: 58px;
height: 58px;
transform: rotate(-90deg);
}
.dial-bg {
fill: none;
stroke: rgba(255, 255, 255, 0.12);
stroke-width: 5;
}
.dial-fg {
fill: none;
stroke: var(--cyan);
stroke-width: 5;
stroke-linecap: round;
stroke-dasharray: 150.8;
stroke-dashoffset: 140;
transition: stroke 0.15s ease;
filter: drop-shadow(0 0 6px var(--cyan));
}
.time-readout {
display: flex;
flex-direction: column;
}
#time-pct {
font-size: 24px;
font-weight: 800;
font-variant-numeric: tabular-nums;
letter-spacing: 0.06em;
}
#time-state-label {
font-size: 10px;
letter-spacing: 0.18em;
color: var(--cyan);
font-weight: 700;
}
/* Demo Bot Indicator / Button */
#bot-toggle-btn {
pointer-events: auto;
cursor: pointer;
background: rgba(12, 18, 28, 0.85);
border: 1px solid rgba(0, 240, 255, 0.35);
border-right: 3px solid var(--cyan);
color: #d8e8f8;
padding: 10px 16px;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
transition: all 0.2s ease;
backdrop-filter: blur(6px);
}
#bot-toggle-btn:hover {
background: rgba(0, 240, 255, 0.18);
box-shadow: 0 0 16px rgba(0, 240, 255, 0.3);
}
#bot-toggle-btn.active {
border-color: var(--amber);
border-right-width: 3px;
color: #fff;
background: rgba(255, 85, 17, 0.22);
box-shadow: 0 0 20px rgba(255, 85, 17, 0.45);
}
/* Center Frozen Prompt */
#chrono-banner {
position: fixed;
top: 18%;
left: 50%;
transform: translateX(-50%);
pointer-events: none;
z-index: 10;
font-size: 13px;
font-weight: 800;
letter-spacing: 0.45em;
color: rgba(0, 240, 255, 0.75);
text-shadow: 0 0 14px rgba(0, 240, 255, 0.6);
opacity: 0;
transition: opacity 0.25s ease;
text-transform: uppercase;
}
/* Modal Overlays (Start, Clear, Death) */
.overlay-card {
position: fixed;
inset: 0;
z-index: 20;
display: flex;
align-items: center;
justify-content: center;
background: radial-gradient(circle at center, rgba(8, 13, 20, 0.68) 0%, rgba(3, 5, 8, 0.92) 100%);
backdrop-filter: blur(8px);
transition: opacity 0.35s ease;
}
.overlay-card.hidden {
opacity: 0;
pointer-events: none;
}
.card-inner {
max-width: 560px;
width: 90%;
background: linear-gradient(160deg, rgba(14, 20, 30, 0.95), rgba(7, 10, 16, 0.98));
border: 1px solid rgba(255, 255, 255, 0.1);
border-top: 3px solid var(--amber);
padding: 40px 44px;
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.85);
text-align: left;
}
.card-tag {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.28em;
color: var(--amber);
margin-bottom: 8px;
text-transform: uppercase;
}
.card-title {
font-size: 42px;
font-weight: 900;
letter-spacing: 0.08em;
line-height: 1.05;
margin-bottom: 16px;
color: #fff;
text-transform: uppercase;
text-shadow: 0 0 24px rgba(255, 85, 17, 0.35);
}
.card-sub {
font-size: 14px;
line-height: 1.65;
color: rgba(215, 230, 245, 0.78);
margin-bottom: 26px;
}
.controls-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px 18px;
margin-bottom: 28px;
padding: 14px 16px;
background: rgba(0, 0, 0, 0.35);
border-left: 2px solid var(--cyan);
font-size: 12px;
}
.ctrl-item span {
color: var(--cyan);
font-weight: 700;
margin-right: 6px;
}
.btn-row {
display: flex;
gap: 14px;
flex-wrap: wrap;
}
.btn-primary, .btn-secondary {
cursor: pointer;
padding: 14px 26px;
font-size: 13px;
font-weight: 800;
letter-spacing: 0.16em;
text-transform: uppercase;
border: none;
transition: all 0.2s ease;
}
.btn-primary {
background: var(--amber);
color: #fff;
box-shadow: 0 0 24px rgba(255, 85, 17, 0.5);
}
.btn-primary:hover {
background: #ff6e30;
transform: translateY(-1px);
box-shadow: 0 0 32px rgba(255, 85, 17, 0.8);
}
.btn-secondary {
background: rgba(0, 240, 255, 0.12);
color: var(--cyan);
border: 1px solid rgba(0, 240, 255, 0.45);
}
.btn-secondary:hover {
background: rgba(0, 240, 255, 0.24);
box-shadow: 0 0 20px rgba(0, 240, 255, 0.35);
}
</style>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"
}
}
</script>
</head>
<body>
<canvas id="game-canvas"></canvas>
<!-- Minimalist Tactical HUD -->
<div id="hud">
<div class="hud-top">
<div style="display:flex; gap: 18px;">
<div class="hud-panel">
<div class="hud-label">Vitality</div>
<div id="hearts-container">
<svg class="heart-icon" viewBox="0 0 24 22"><path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/></svg>
<svg class="heart-icon" viewBox="0 0 24 22"><path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/></svg>
<svg class="heart-icon" viewBox="0 0 24 22"><path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/></svg>
</div>
</div>
<div class="hud-panel">
<div class="hud-label">Hostiles Left</div>
<div class="hud-value" id="enemies-left-val">08</div>
</div>
</div>
<button id="bot-toggle-btn" title="Press [B] at any time to toggle autonomous AI Demo Bot">[B] DEMO BOT: OFF</button>
</div>
<div id="chrono-banner">CHRONO STASIS // 0.05x</div>
<div class="hud-bottom">
<div id="time-dial-wrap">
<svg id="time-dial-svg" viewBox="0 0 56 56">
<circle class="dial-bg" cx="28" cy="28" r="24"></circle>
<circle class="dial-fg" id="time-dial-arc" cx="28" cy="28" r="24"></circle>
</svg>
<div class="time-readout">
<div class="hud-label" style="margin-bottom:1px;">TIME SCALE</div>
<div id="time-pct">0.05x</div>
<div id="time-state-label">STASIS</div>
</div>
</div>
<div class="hud-panel" style="border-left:none; border-right: 3px solid var(--cyan); text-align:right;">
<div class="hud-label">Directive</div>
<div style="font-size:12px; font-weight:700; letter-spacing:0.12em; color:#cfe4f7;">TIME MOVES WHEN YOU MOVE</div>
</div>
</div>
</div>
<!-- Crosshair -->
<div id="crosshair-wrap">
<div class="ch-dot"></div>
<div class="ch-line ch-top" id="ch-t"></div>
<div class="ch-line ch-bottom" id="ch-b"></div>
<div class="ch-line ch-left" id="ch-l"></div>
<div class="ch-line ch-right" id="ch-r"></div>
</div>
<!-- Start / Status Modal -->
<div id="overlay-screen" class="overlay-card">
<div class="card-inner">
<div class="card-tag" id="modal-tag">SECTOR 04 // RAIN COURTYARD</div>
<div class="card-title" id="modal-title">CHRONO // BRUTAL</div>
<div class="card-sub" id="modal-desc">
Time is bound to your momentum. Stand still to freeze incoming crossfire at <strong>5% speed</strong>—study the glowing lattice of plasma bolts, thread the gap, and shatter all hostiles.
</div>
<div class="controls-grid" id="modal-controls">
<div class="ctrl-item"><span>WASD</span> Move / Drive Time</div>
<div class="ctrl-item"><span>MOUSE</span> Look / Aim</div>
<div class="ctrl-item"><span>CLICK</span> Fire Chrono-Carbine</div>
<div class="ctrl-item"><span>[B] KEY</span> Toggle AI Demo Bot</div>
</div>
<div class="btn-row">
<button class="btn-primary" id="btn-start">CLICK TO DEPLOY</button>
<button class="btn-secondary" id="btn-demo">WATCH AI DEMO BOT</button>
</div>
</div>
</div>
<script type="module">
import * as THREE from 'three';
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
import { ShaderPass } from 'three/addons/postprocessing/ShaderPass.js';
// ============================================================================
// 1. PROCEDURAL PBR TEXTURE GENERATOR (NO EXTERNAL IMAGES)
// ============================================================================
function createBrutalistConcreteTextures() {
const size = 512;
const diffCanvas = document.createElement('canvas');
const roughCanvas = document.createElement('canvas');
const bumpCanvas = document.createElement('canvas');
diffCanvas.width = roughCanvas.width = bumpCanvas.width = size;
diffCanvas.height = roughCanvas.height = bumpCanvas.height = size;
const dCtx = diffCanvas.getContext('2d');
const rCtx = roughCanvas.getContext('2d');
const bCtx = bumpCanvas.getContext('2d');
// Base concrete tone
dCtx.fillStyle = '#383d44';
dCtx.fillRect(0, 0, size, size);
rCtx.fillStyle = '#b8b8b8';
rCtx.fillRect(0, 0, size, size);
bCtx.fillStyle = '#808080';
bCtx.fillRect(0, 0, size, size);
// Horizontal board-formed planks
const planks = 8;
const plankH = size / planks;
for (let i = 0; i < planks; i++) {
const y = i * plankH;
const shade = (Math.random() - 0.5) * 14;
dCtx.fillStyle = `rgb(${54 + shade}, ${59 + shade}, ${66 + shade})`;
dCtx.fillRect(0, y, size, plankH);
// Plank seam
dCtx.fillStyle = '#1e2227';
dCtx.fillRect(0, y, size, 3);
bCtx.fillStyle = '#202020';
bCtx.fillRect(0, y, size, 3);
rCtx.fillStyle = '#e8e8e8';
rCtx.fillRect(0, y, size, 3);
}
// Fine aggregate & rain streaks
const dImg = dCtx.getImageData(0, 0, size, size);
const rImg = rCtx.getImageData(0, 0, size, size);
const bImg = bCtx.getImageData(0, 0, size, size);
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
const idx = (y * size + x) * 4;
const n = (Math.sin(x * 0.17 + y * 0.03) * Math.cos(y * 0.23 - x * 0.07) + (Math.random() - 0.5) * 1.4) * 12;
// Vertical rain damp streak
const streak = Math.sin(x * 0.045 + Math.sin(x * 0.015) * 3.0) * Math.cos(y * 0.008);
const wetFactor = streak > 0.35 ? (streak - 0.35) * 55 : 0;
dImg.data[idx] = Math.max(15, Math.min(255, dImg.data[idx] + n - wetFactor * 0.35));
dImg.data[idx + 1] = Math.max(15, Math.min(255, dImg.data[idx + 1] + n - wetFactor * 0.3));
dImg.data[idx + 2] = Math.max(18, Math.min(255, dImg.data[idx + 2] + n - wetFactor * 0.2));
rImg.data[idx] = rImg.data[idx + 1] = rImg.data[idx + 2] =
Math.max(30, Math.min(245, rImg.data[idx] - wetFactor * 2.1 + n * 0.8));
bImg.data[idx] = bImg.data[idx + 1] = bImg.data[idx + 2] =
Math.max(10, Math.min(245, bImg.data[idx] + n * 1.6));
}
}
dCtx.putImageData(dImg, 0, 0);
rCtx.putImageData(rImg, 0, 0);
bCtx.putImageData(bImg, 0, 0);
// Formwork tie-bolt holes
for (let row = 0; row < planks; row++) {
for (let col = 0; col < 4; col++) {
const cx = (col + 0.5) * (size / 4);
const cy = (row + 0.5) * plankH;
dCtx.beginPath();
dCtx.arc(cx, cy, 6, 0, Math.PI * 2);
dCtx.fillStyle = '#14171b';
dCtx.fill();
bCtx.beginPath();
bCtx.arc(cx, cy, 7, 0, Math.PI * 2);
bCtx.fillStyle = '#080808';
bCtx.fill();
}
}
const wrap = (canvas, srgb = false) => {
const t = new THREE.CanvasTexture(canvas);
t.wrapS = t.wrapT = THREE.RepeatWrapping;
if (srgb) t.colorSpace = THREE.SRGBColorSpace;
t.needsUpdate = true;
return t;
};
return {
map: wrap(diffCanvas, true),
roughnessMap: wrap(roughCanvas, false),
bumpMap: wrap(bumpCanvas, false)
};
}
function createWetFloorTextures() {
const size = 512;
const diffCanvas = document.createElement('canvas');
const roughCanvas = document.createElement('canvas');
const bumpCanvas = document.createElement('canvas');
diffCanvas.width = roughCanvas.width = bumpCanvas.width = size;
diffCanvas.height = roughCanvas.height = bumpCanvas.height = size;
const dCtx = diffCanvas.getContext('2d');
const rCtx = roughCanvas.getContext('2d');
const bCtx = bumpCanvas.getContext('2d');
const dImg = dCtx.createImageData(size, size);
const rImg = rCtx.createImageData(size, size);
const bImg = bCtx.createImageData(size, size);
// Multi-octave procedural puddles + brutalist paving grid
const tile = 64;
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
const idx = (y * size + x) * 4;
const nx = x / size * Math.PI * 2;
const ny = y / size * Math.PI * 2;
// Seamless periodic wave for puddles
const puddleWave =
Math.sin(nx * 2 + Math.cos(ny * 2)) * 0.45 +
Math.cos(ny * 3 - Math.sin(nx * 3)) * 0.35 +
Math.sin((nx + ny) * 4) * 0.2;
const grain = (Math.random() - 0.5) * 16;
const isGroove = (x % tile < 2) || (y % tile < 2);
const isPuddle = puddleWave > 0.12;
let baseCol = isPuddle ? 20 : 36;
if (isGroove) baseCol = 14;
dImg.data[idx] = Math.max(8, Math.min(255, baseCol + grain * 0.5));
dImg.data[idx + 1] = Math.max(10, Math.min(255, baseCol + 3 + grain * 0.5));
dImg.data[idx + 2] = Math.max(14, Math.min(255, baseCol + 8 + grain * 0.5));
dImg.data[idx + 3] = 255;
// Puddles are mirror-smooth (roughness ~12/255), damp stone is rougher (~175/255)
let rough = isPuddle ? 14 : Math.min(220, 155 + grain * 2);
if (isGroove) rough = 230;
rImg.data[idx] = rImg.data[idx + 1] = rImg.data[idx + 2] = rough;
rImg.data[idx + 3] = 255;
let bump = isPuddle ? 128 : (128 + grain * 2.5);
if (isGroove) bump = 30;
bImg.data[idx] = bImg.data[idx + 1] = bImg.data[idx + 2] = Math.max(0, Math.min(255, bump));
bImg.data[idx + 3] = 255;
}
}
dCtx.putImageData(dImg, 0, 0);
rCtx.putImageData(rImg, 0, 0);
bCtx.putImageData(bImg, 0, 0);
const wrap = (canvas, srgb = false) => {
const t = new THREE.CanvasTexture(canvas);
t.wrapS = t.wrapT = THREE.RepeatWrapping;
t.repeat.set(10, 10);
if (srgb) t.colorSpace = THREE.SRGBColorSpace;
t.needsUpdate = true;
return t;
};
return {
map: wrap(diffCanvas, true),
roughnessMap: wrap(roughCanvas, false),
bumpMap: wrap(bumpCanvas, false)
};
}
function createSignTexture(textTop, textMain, colorHex) {
const c = document.createElement('canvas');
c.width = 512;
c.height = 128;
const ctx = c.getContext('2d');
ctx.fillStyle = '#05070b';
ctx.fillRect(0, 0, 512, 128);
ctx.strokeStyle = colorHex;
ctx.lineWidth = 4;
ctx.strokeRect(8, 8, 496, 112);
ctx.fillStyle = colorHex;
ctx.font = '700 18px Segoe UI, monospace';
ctx.fillText(textTop, 26, 40);
ctx.font = '900 44px Segoe UI, sans-serif';
ctx.fillText(textMain, 24, 94);
const t = new THREE.CanvasTexture(c);
t.colorSpace = THREE.SRGBColorSpace;
return t;
}
function createContactShadowTexture() {
const c = document.createElement('canvas');
c.width = 128;
c.height = 128;
const ctx = c.getContext('2d');
const g = ctx.createRadialGradient(64, 64, 6, 64, 64, 60);
g.addColorStop(0, 'rgba(0, 0, 0, 0.85)');
g.addColorStop(0.55, 'rgba(0, 0, 0, 0.45)');
g.addColorStop(1, 'rgba(0, 0, 0, 0.0)');
ctx.fillStyle = g;
ctx.fillRect(0, 0, 128, 128);
return new THREE.CanvasTexture(c);
}
// ============================================================================
// 2. WEBAUDIO SYNTHESIZER (PITCH-BENT BY TIME SCALE)
// ============================================================================
class ChronoAudioEngine {
constructor() {
this.ctx = null;
this.initialized = false;
this.ambientOsc1 = null;
this.ambientOsc2 = null;
this.ambientFilter = null;
this.rainFilter = null;
this.rainGain = null;
this.noiseBuffer = null;
this.timeScale = 0.05;
}
init() {
if (this.initialized) {
if (this.ctx.state === 'suspended') this.ctx.resume();
return;
}
const AudioCtx = window.AudioContext || window.webkitAudioContext;
this.ctx = new AudioCtx();
// Shared 2-second white/pink noise buffer
const sr = this.ctx.sampleRate;
this.noiseBuffer = this.ctx.createBuffer(1, sr * 2, sr);
const data = this.noiseBuffer.getChannelData(0);
let b0 = 0, b1 = 0, b2 = 0;
for (let i = 0; i < data.length; i++) {
const w = Math.random() * 2 - 1;
b0 = 0.997 * b0 + w * 0.029;
b1 = 0.985 * b1 + w * 0.032;
b2 = 0.950 * b2 + w * 0.048;
data[i] = (b0 + b1 + b2 + w * 0.18) * 0.5;
}
// Master compressor
this.master = this.ctx.createDynamicsCompressor();
this.master.threshold.value = -14;
this.master.ratio.value = 4;
this.master.connect(this.ctx.destination);
// Continuous Dark Brutalist Sub-Drone (Pitch-bent by timeScale)
this.ambientFilter = this.ctx.createBiquadFilter();
this.ambientFilter.type = 'lowpass';
this.ambientFilter.frequency.value = 110;
this.ambientFilter.Q.value = 3.5;
const droneGain = this.ctx.createGain();
droneGain.gain.value = 0.22;
this.ambientFilter.connect(droneGain);
droneGain.connect(this.master);
this.ambientOsc1 = this.ctx.createOscillator();
this.ambientOsc2 = this.ctx.createOscillator();
this.ambientOsc1.type = 'sawtooth';
this.ambientOsc2.type = 'triangle';
this.ambientOsc1.frequency.value = 42;
this.ambientOsc2.frequency.value = 63;
this.ambientOsc1.connect(this.ambientFilter);
this.ambientOsc2.connect(this.ambientFilter);
this.ambientOsc1.start();
this.ambientOsc2.start();
// Rain / Courtyard Air Bed (Filter & playback rate bent by timeScale)
this.rainSource = this.ctx.createBufferSource();
this.rainSource.buffer = this.noiseBuffer;
this.rainSource.loop = true;
this.rainFilter = this.ctx.createBiquadFilter();
this.rainFilter.type = 'bandpass';
this.rainFilter.frequency.value = 600;
this.rainFilter.Q.value = 0.8;
this.rainGain = this.ctx.createGain();
this.rainGain.gain.value = 0.12;
this.rainSource.connect(this.rainFilter);
this.rainFilter.connect(this.rainGain);
this.rainGain.connect(this.master);
this.rainSource.start();
this.initialized = true;
}
update(timeScale) {
this.timeScale = timeScale;
if (!this.initialized) return;
const now = this.ctx.currentTime;
// Pitch-bend the world drone from a subterranean 28Hz growl (frozen) up to 68Hz (full motion)
const f1 = 27 + timeScale * 41;
const f2 = f1 * 1.498; // slightly detuned fifth
this.ambientOsc1.frequency.setTargetAtTime(f1, now, 0.05);
this.ambientOsc2.frequency.setTargetAtTime(f2, now, 0.05);
this.ambientFilter.frequency.setTargetAtTime(65 + timeScale * 260, now, 0.05);
// Rain bed bends in playbackRate and filter frequency
this.rainSource.playbackRate.setTargetAtTime(0.18 + timeScale * 0.82, now, 0.05);
this.rainFilter.frequency.setTargetAtTime(160 + timeScale * 1100, now, 0.05);
this.rainGain.gain.setTargetAtTime(0.05 + timeScale * 0.12, now, 0.05);
}
playPlayerShoot() {
if (!this.initialized) return;
const now = this.ctx.currentTime;
// 1. Punchy low-end transient kick
const osc = this.ctx.createOscillator();
const kickGain = this.ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(190, now);
osc.frequency.exponentialRampToValueAtTime(34, now + 0.14);
kickGain.gain.setValueAtTime(0.65, now);
kickGain.gain.exponentialRampToValueAtTime(0.001, now + 0.16);
osc.connect(kickGain);
kickGain.connect(this.master);
osc.start(now);
osc.stop(now + 0.17);
// 2. Crisp gunshot crack + courtyard tail (tail stretches if time drops!)
const stretch = 1.4 - this.timeScale * 0.65;
const noise = this.ctx.createBufferSource();
noise.buffer = this.noiseBuffer;
noise.playbackRate.setValueAtTime(0.75 + this.timeScale * 0.45, now);
const filter = this.ctx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.setValueAtTime(3800, now);
filter.frequency.exponentialRampToValueAtTime(280, now + 0.32 * stretch);
const nGain = this.ctx.createGain();
nGain.gain.setValueAtTime(0.55, now);
nGain.gain.exponentialRampToValueAtTime(0.001, now + 0.35 * stretch);
noise.connect(filter);
filter.connect(nGain);
nGain.connect(this.master);
noise.start(now);
noise.stop(now + 0.36 * stretch);
}
playEnemyShoot(distFactor = 1.0) {
if (!this.initialized) return;
const now = this.ctx.currentTime;
const pitch = 0.35 + this.timeScale * 0.65;
const dur = 0.26 / Math.max(0.25, pitch);
const osc = this.ctx.createOscillator();
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(520 * pitch, now);
osc.frequency.exponentialRampToValueAtTime(85 * pitch, now + dur);
const filter = this.ctx.createBiquadFilter();
filter.type = 'bandpass';
filter.frequency.setValueAtTime(900 * pitch, now);
filter.Q.value = 2.5;
const gain = this.ctx.createGain();
const vol = Math.min(0.35, 0.35 * distFactor);
gain.gain.setValueAtTime(vol, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + dur);
osc.connect(filter);
filter.connect(gain);
gain.connect(this.master);
osc.start(now);
osc.stop(now + dur + 0.02);
}
playEnemyShatter() {
if (!this.initialized) return;
const now = this.ctx.currentTime;
const pitch = 0.35 + this.timeScale * 0.65;
const dur = 0.55 / Math.max(0.28, pitch);
// Sub impact + crystalline resonant chime
const sub = this.ctx.createOscillator();
const subGain = this.ctx.createGain();
sub.type = 'triangle';
sub.frequency.setValueAtTime(140 * pitch, now);
sub.frequency.exponentialRampToValueAtTime(24, now + dur);
subGain.gain.setValueAtTime(0.6, now);
subGain.gain.exponentialRampToValueAtTime(0.001, now + dur);
sub.connect(subGain);
subGain.connect(this.master);
sub.start(now);
sub.stop(now + dur);
// High crystalline shatter
[1420, 1980, 2640].forEach((freq, i) => {
const o = this.ctx.createOscillator();
const g = this.ctx.createGain();
o.type = 'sine';
o.frequency.setValueAtTime(freq * (0.55 + this.timeScale * 0.45), now + i * 0.02);
o.frequency.exponentialRampToValueAtTime(freq * 0.4, now + dur * 0.7);
g.gain.setValueAtTime(0.14, now + i * 0.02);
g.gain.exponentialRampToValueAtTime(0.001, now + dur * 0.7);
o.connect(g);
g.connect(this.master);
o.start(now + i * 0.02);
o.stop(now + dur * 0.72);
});
}
playImpact(isFlesh = false) {
if (!this.initialized) return;
const now = this.ctx.currentTime;
const pitch = 0.4 + this.timeScale * 0.6;
const noise = this.ctx.createBufferSource();
noise.buffer = this.noiseBuffer;
noise.playbackRate.setValueAtTime((isFlesh ? 0.6 : 1.3) * pitch, now);
const filter = this.ctx.createBiquadFilter();
filter.type = 'bandpass';
filter.frequency.setValueAtTime((isFlesh ? 700 : 1900) * pitch, now);
const gain = this.ctx.createGain();
gain.gain.setValueAtTime(0.28, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.14 / pitch);
noise.connect(filter);
filter.connect(gain);
gain.connect(this.master);
noise.start(now);
noise.stop(now + 0.15 / pitch);
}
playPlayerDamage() {
if (!this.initialized) return;
const now = this.ctx.currentTime;
const osc = this.ctx.createOscillator();
const g = this.ctx.createGain();
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(115, now);
osc.frequency.exponentialRampToValueAtTime(32, now + 0.35);
g.gain.setValueAtTime(0.65, now);
g.gain.exponentialRampToValueAtTime(0.001, now + 0.38);
osc.connect(g);
g.connect(this.master);
osc.start(now);
osc.stop(now + 0.4);
}
}
const audio = new ChronoAudioEngine();
// ============================================================================
// 3. SCENE, RENDERER & POST-PROCESSING PIPELINE
// ============================================================================
const canvas = document.getElementById('game-canvas');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: false, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.08;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x060a10);
scene.fog = new THREE.FogExp2(0x070b12, 0.024);
const camera = new THREE.PerspectiveCamera(72, window.innerWidth / window.innerHeight, 0.08, 130);
camera.rotation.order = 'YXZ';
scene.add(camera);
// Post-Processing: RenderPass -> UnrealBloomPass -> Custom ChronoCinemaGradeShader
const composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
const bloomPass = new UnrealBloomPass(
new THREE.Vector2(window.innerWidth, window.innerHeight),
0.75, // strength
0.45, // radius
0.88 // high threshold: only genuine emissives (> 0.88 luminance) bloom!
);
composer.addPass(bloomPass);
const ChronoGradeShader = {
uniforms: {
tDiffuse: { value: null },
uTimeScale: { value: 0.05 },
uTime: { value: 0.0 },
uDamagePulse: { value: 0.0 },
uDamageDir: { value: new THREE.Vector2(0, 0) }
},
vertexShader: /* glsl */`
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: /* glsl */`
uniform sampler2D tDiffuse;
uniform float uTimeScale;
uniform float uTime;
uniform float uDamagePulse;
uniform vec2 uDamageDir;
varying vec2 vUv;
float hash(vec2 p) {
return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453);
}
void main() {
vec2 uv = vUv;
vec2 center = uv - 0.5;
float dist = length(center);
// Subtle chromatic aberration at edges when frozen or damaged
float freezeFactor = clamp(1.0 - uTimeScale, 0.0, 1.0);
float caOffset = (0.0018 * freezeFactor + 0.008 * uDamagePulse) * dist;
float r = texture2D(tDiffuse, uv + center * caOffset).r;
float g = texture2D(tDiffuse, uv).g;
float b = texture2D(tDiffuse, uv - center * caOffset).b;
vec3 col = vec3(r, g, b);
// Luma & Time-Scale Color Grade
float luma = dot(col, vec3(0.299, 0.587, 0.114));
// Frozen state cools and desaturates ~38%; motion warms it back up
vec3 coolGrade = mix(vec3(luma), col, 0.62) * vec3(0.86, 0.96, 1.12);
vec3 warmGrade = mix(vec3(luma), col, 1.08) * vec3(1.04, 0.99, 0.95);
col = mix(coolGrade, warmGrade, smoothstep(0.05, 0.9, uTimeScale));
// S-curve contrast enhancement
col = mix(col, smoothstep(0.0, 1.0, col), 0.18);
// Directional damage flinch vignette
if (uDamagePulse > 0.001) {
vec2 dirUv = normalize(center + 1e-5);
float dirBias = max(0.0, dot(dirUv, uDamageDir)) * 0.55 + 0.45;
float dmgMask = smoothstep(0.2, 0.78, dist) * uDamagePulse * dirBias;
col = mix(col, vec3(0.95, 0.05, 0.1), clamp(dmgMask * 1.35, 0.0, 0.85));
}
// Cinema Vignette
float vig = smoothstep(0.86, 0.28, dist);
col *= mix(0.38, 1.0, vig);
// Subtle animated film grain
float grain = (hash(uv * 500.0 + fract(uTime * 17.3)) - 0.5) * 0.028;
col += grain;
gl_FragColor = vec4(max(col, 0.0), 1.0);
}
`
};
const gradePass = new ShaderPass(ChronoGradeShader);
composer.addPass(gradePass);
// ============================================================================
// 4. LIGHTING, BRUTALIST ARENA & CONTACT SHADOWS
// ============================================================================
const concreteTex = createBrutalistConcreteTextures();
const floorTex = createWetFloorTextures();
const contactShadowTex = createContactShadowTexture();
const brutalistMat = new THREE.MeshStandardMaterial({
map: concreteTex.map,
roughnessMap: concreteTex.roughnessMap,
bumpMap: concreteTex.bumpMap,
bumpScale: 0.04,
roughness: 0.82,
metalness: 0.08
});
const wetFloorMat = new THREE.MeshStandardMaterial({
map: floorTex.map,
roughnessMap: floorTex.roughnessMap,
bumpMap: floorTex.bumpMap,
bumpScale: 0.015,
roughness: 0.65,
metalness: 0.22
});
const darkMetalMat = new THREE.MeshStandardMaterial({
color: 0x1a2028,
roughness: 0.35,
metalness: 0.85
});
const emitAmberMat = new THREE.MeshStandardMaterial({
color: 0xff5511,
emissive: 0xff4400,
emissiveIntensity: 3.4,
roughness: 0.2
});
const emitCyanMat = new THREE.MeshStandardMaterial({
color: 0x00f0ff,
emissive: 0x00d8ff,
emissiveIntensity: 3.2,
roughness: 0.2
});
const contactShadowMat = new THREE.MeshBasicMaterial({
map: contactShadowTex,
transparent: true,
depthWrite: false,
opacity: 0.82
});
// Hemisphere + Strong Key Directional Light with Real-Time Shadows
const hemiLight = new THREE.HemisphereLight(0x243854, 0x080a0f, 0.75);
scene.add(hemiLight);
const dirLight = new THREE.DirectionalLight(0x9ec5ff, 2.6);
dirLight.position.set(22, 36, 18);
dirLight.castShadow = true;
dirLight.shadow.mapSize.width = 2048;
dirLight.shadow.mapSize.height = 2048;
dirLight.shadow.camera.near = 4;
dirLight.shadow.camera.far = 90;
const dCam = 30;
dirLight.shadow.camera.left = -dCam;
dirLight.shadow.camera.right = dCam;
dirLight.shadow.camera.top = dCam;
dirLight.shadow.camera.bottom = -dCam;
dirLight.shadow.bias = -0.0005;
scene.add(dirLight);
// Courtyard Floor
const ARENA_HALF = 24;
const floorMesh = new THREE.Mesh(new THREE.PlaneGeometry(ARENA_HALF * 2, ARENA_HALF * 2), wetFloorMat);
floorMesh.rotation.x = -Math.PI / 2;
floorMesh.receiveShadow = true;
scene.add(floorMesh);
// Obstacle Colliders Array (AABB boxes in XZ plane: { minX, maxX, minZ, maxZ, maxY, mesh })
const obstacles = [];
const environmentMeshes = [floorMesh];
function addContactShadow(x, z, sx, sz) {
const m = new THREE.Mesh(new THREE.PlaneGeometry(sx, sz), contactShadowMat);
m.rotation.x = -Math.PI / 2;
m.position.set(x, 0.015, z);
scene.add(m);
return m;
}
function addBrutalistBlock(x, y, z, w, h, d, hasLightStrip = false, isAmber = true, collidable = true) {
const geo = new THREE.BoxGeometry(w, h, d);
// Scale UVs roughly to world dimensions
const uv = geo.attributes.uv;
for (let i = 0; i < uv.count; i++) {
uv.setXY(i, uv.getX(i) * Math.max(w, d) * 0.25, uv.getY(i) * h * 0.25);
}
const mesh = new THREE.Mesh(geo, brutalistMat);
mesh.position.set(x, y, z);
mesh.castShadow = true;
mesh.receiveShadow = true;
scene.add(mesh);
environmentMeshes.push(mesh);
if (y - h / 2 <= 0.1) {
addContactShadow(x, z, w + 1.4, d + 1.4);
}
if (collidable) {
obstacles.push({
minX: x - w / 2,
maxX: x + w / 2,
minZ: z - d / 2,
maxZ: z + d / 2,
maxY: y + h / 2
});
}
if (hasLightStrip) {
const stripMat = isAmber ? emitAmberMat : emitCyanMat;
const stripGeo = new THREE.BoxGeometry(w + 0.06, 0.14, d + 0.06);
const strip = new THREE.Mesh(stripGeo, stripMat);
strip.position.set(x, Math.min(h - 0.5, 3.2), z);
scene.add(strip);
}
return mesh;
}
// Build Perimeter Brutalist Courtyard Walls & Overhangs
const wallH = 10;
addBrutalistBlock(0, wallH / 2, -ARENA_HALF, ARENA_HALF * 2, wallH, 1.5, false, true, true);
addBrutalistBlock(0, wallH / 2, ARENA_HALF, ARENA_HALF * 2, wallH, 1.5, false, true, true);
addBrutalistBlock(-ARENA_HALF, wallH / 2, 0, 1.5, wallH, ARENA_HALF * 2, false, true, true);
addBrutalistBlock( ARENA_HALF, wallH / 2, 0, 1.5, wallH, ARENA_HALF * 2, false, true, true);
// Architectural Brutalist Buttresses along perimeter
for (let i = -18; i <= 18; i += 9) {
addBrutalistBlock(i, 5.5, -ARENA_HALF + 1.1, 1.8, 11, 1.6, i % 18 === 0, true, true);
addBrutalistBlock(i, 5.5, ARENA_HALF - 1.1, 1.8, 11, 1.6, i % 18 === 0, false, true);
addBrutalistBlock(-ARENA_HALF + 1.1, 5.5, i, 1.6, 11, 1.8, i % 18 === 0, false, true);
addBrutalistBlock( ARENA_HALF - 1.1, 5.5, i, 1.6, 11, 1.8, i % 18 === 0, true, true);
}
// Central Brutalist Monolith Archway & Tactical Cover Pillars
addBrutalistBlock(-3.8, 4.0, 0, 2.2, 8.0, 2.2, true, true, true);
addBrutalistBlock( 3.8, 4.0, 0, 2.2, 8.0, 2.2, true, true, true);
addBrutalistBlock( 0.0, 7.2, 0, 9.8, 1.8, 2.6, false, true, false); // Overhead lintel
// 8 Symmetrical Courtyard Pillars & Low Cover Walls
const pillarCoords = [
[-11, -11, 2.4, 7.5, 2.4, true, false],
[ 11, -11, 2.4, 7.5, 2.4, true, false],
[-11, 11, 2.4, 7.5, 2.4, true, true],
[ 11, 11, 2.4, 7.5, 2.4, true, true],
[ 0, -13, 5.0, 2.4, 1.4, false, false],
[ 0, 13, 5.0, 2.4, 1.4, false, false],
[-14, 0, 1.4, 2.4, 5.0, false, false],
[ 14, 0, 1.4, 2.4, 5.0, false, false]
];
for (const [px, pz, pw, ph, pd, strip, isAmb] of pillarCoords) {
addBrutalistBlock(px, ph / 2, pz, pw, ph, pd, strip, isAmb, true);
}
// Emissive Brutalist Practical Signage + Static Accent Lights
const signTex1 = createSignTexture('SECTOR 04 // CHRONO-GRID', 'STASIS PROTOCOL', '#ff5511');
const signMat1 = new THREE.MeshStandardMaterial({
map: signTex1,
emissiveMap: signTex1,
emissive: 0xffffff,
emissiveIntensity: 2.4,
roughness: 0.3
});
const signMesh1 = new THREE.Mesh(new THREE.PlaneGeometry(6.4, 1.6), signMat1);
signMesh1.position.set(0, 6.2, -ARENA_HALF + 1.95);
scene.add(signMesh1);
const signTex2 = createSignTexture('WARNING // LIVE FIRE ZONE', 'MOTION = TIME', '#00f0ff');
const signMat2 = new THREE.MeshStandardMaterial({
map: signTex2,
emissiveMap: signTex2,
emissive: 0xffffff,
emissiveIntensity: 2.4,
roughness: 0.3
});
const signMesh2 = new THREE.Mesh(new THREE.PlaneGeometry(6.4, 1.6), signMat2);
signMesh2.rotation.y = Math.PI;
signMesh2.position.set(0, 6.2, ARENA_HALF - 1.95);
scene.add(signMesh2);
// Practical Point Lights illuminating wet puddles
const practicalAmberLight = new THREE.PointLight(0xff5511, 18, 22, 1.6);
practicalAmberLight.position.set(0, 4.8, 0);
scene.add(practicalAmberLight);
const practicalCyanLight1 = new THREE.PointLight(0x00d8ff, 14, 20, 1.6);
practicalCyanLight1.position.set(-11, 4.2, -11);
scene.add(practicalCyanLight1);
const practicalCyanLight2 = new THREE.PointLight(0x00d8ff, 14, 20, 1.6);
practicalCyanLight2.position.set(11, 4.2, -11);
scene.add(practicalCyanLight2);
// Suspended Rain Needles (Freeze in midair when time slows!)
const RAIN_COUNT = 750;
const rainGeo = new THREE.BufferGeometry();
const rainPositions = new Float32Array(RAIN_COUNT * 6);
const rainCoords = [];
for (let i = 0; i < RAIN_COUNT; i++) {
const rx = (Math.random() - 0.5) * 44;
const ry = Math.random() * 14;
const rz = (Math.random() - 0.5) * 44;
rainCoords.push({ x: rx, y: ry, z: rz, speed: 15 + Math.random() * 6 });
}
rainGeo.setAttribute('position', new THREE.BufferAttribute(rainPositions, 3));
const rainMat = new THREE.LineBasicMaterial({
color: 0x7aa8cc,
transparent: true,
opacity: 0.32
});
const rainLines = new THREE.LineSegments(rainGeo, rainMat);
scene.add(rainLines);
function updateRain(worldDt, timeScale) {
const pos = rainGeo.attributes.position.array;
const streakLen = 0.14 + timeScale * 0.48; // Shortens into suspended droplets when frozen
for (let i = 0; i < RAIN_COUNT; i++) {
const r = rainCoords[i];
r.y -= r.speed * worldDt;
if (r.y < 0.1) r.y = 14.0;
const idx = i * 6;
pos[idx] = r.x;
pos[idx + 1] = r.y;
pos[idx + 2] = r.z;
pos[idx + 3] = r.x + 0.03;
pos[idx + 4] = r.y + streakLen;
pos[idx + 5] = r.z;
}
rainGeo.attributes.position.needsUpdate = true;
}
// ============================================================================
// 5. POOLED DYNAMIC POINT LIGHTS (ZERO SHADER RECOMPILATION)
// ============================================================================
const PROJ_LIGHT_POOL_SIZE = 8;
const projLightPool = [];
for (let i = 0; i < PROJ_LIGHT_POOL_SIZE; i++) {
const pl = new THREE.PointLight(0xff3300, 0, 7.5, 1.8);
pl.position.set(0, -50, 0);
scene.add(pl);
projLightPool.push(pl);
}
const muzzleFlashLight = new THREE.PointLight(0xffaa44, 0, 14, 1.5);
scene.add(muzzleFlashLight);
const explosionLight = new THREE.PointLight(0xff3311, 0, 11, 1.6);
scene.add(explosionLight);
// ============================================================================
// 6. FIRST-PERSON VIEWMODEL WEAPON (CHRONO-CARBINE)
// ============================================================================
const weaponRig = new THREE.Group();
camera.add(weaponRig);
const gunBodyGroup = new THREE.Group();
gunBodyGroup.position.set(0.24, -0.21, -0.42);
weaponRig.add(gunBodyGroup);
const gunReceiver = new THREE.Mesh(new THREE.BoxGeometry(0.068, 0.085, 0.36), darkMetalMat);
gunBodyGroup.add(gunReceiver);
const gunBarrel = new THREE.Mesh(new THREE.CylinderGeometry(0.014, 0.014, 0.28, 12), darkMetalMat);
gunBarrel.rotation.x = Math.PI / 2;
gunBarrel.position.set(0, 0.015, -0.22);
gunBodyGroup.add(gunBarrel);
// Reciprocating top slide
const gunSlide = new THREE.Mesh(new THREE.BoxGeometry(0.072, 0.042, 0.28), brutalistMat);
gunSlide.position.set(0, 0.048, -0.02);
gunBodyGroup.add(gunSlide);
// Glowing Chrono-Core strip along gun side (scales brightness with timeScale)
const gunCoreMat = new THREE.MeshStandardMaterial({
color: 0x00f0ff,
emissive: 0x00e5ff,
emissiveIntensity: 2.8
});
const gunCore = new THREE.Mesh(new THREE.BoxGeometry(0.076, 0.012, 0.22), gunCoreMat);
gunCore.position.set(0, 0.012, -0.04);
gunBodyGroup.add(gunCore);
// Glowing iron sights
const sightRearL = new THREE.Mesh(new THREE.BoxGeometry(0.012, 0.018, 0.012), emitCyanMat);
sightRearL.position.set(-0.02, 0.074, 0.11);
const sightRearR = sightRearL.clone();
sightRearR.position.x = 0.02;
const sightFront = new THREE.Mesh(new THREE.BoxGeometry(0.01, 0.02, 0.01), emitAmberMat);
sightFront.position.set(0, 0.074, -0.15);
gunBodyGroup.add(sightRearL, sightRearR, sightFront);
// Muzzle Flash 3D Star Mesh
const muzzleFlashMat = new THREE.MeshBasicMaterial({
color: 0xffddaa,
transparent: true,
opacity: 0.0,
depthWrite: false
});
const muzzleFlashMesh = new THREE.Group();
const mfPlane1 = new THREE.Mesh(new THREE.PlaneGeometry(0.28, 0.09), muzzleFlashMat);
const mfPlane2 = new THREE.Mesh(new THREE.PlaneGeometry(0.28, 0.09), muzzleFlashMat);
mfPlane2.rotation.z = Math.PI / 2;
muzzleFlashMesh.add(mfPlane1, mfPlane2);
muzzleFlashMesh.position.set(0, 0.015, -0.38);
gunBodyGroup.add(muzzleFlashMesh);
let gunRecoil = 0;
let muzzleFlashTimer = 0;
let swayX = 0, swayY = 0;
let walkBobPhase = 0;
// ============================================================================
// 7. ARTICULATED HUMANOID ENEMIES
// ============================================================================
const enemyArmorMat = new THREE.MeshStandardMaterial({
color: 0x1e222a,
roughness: 0.42,
metalness: 0.68
});
const enemyJointMat = new THREE.MeshStandardMaterial({
color: 0x101318,
roughness: 0.65,
metalness: 0.35
});
const enemyVisorMat = new THREE.MeshStandardMaterial({
color: 0xff2200,
emissive: 0xff1e00,
emissiveIntensity: 3.8,
roughness: 0.15
});
// Shared primitive geometries for articulated enemies
const geoPelvis = new THREE.BoxGeometry(0.36, 0.16, 0.22);
const geoTorso = new THREE.BoxGeometry(0.44, 0.50, 0.26);
const geoHead = new THREE.BoxGeometry(0.23, 0.25, 0.24);
const geoVisor = new THREE.BoxGeometry(0.24, 0.065, 0.16);
const geoLimbU = new THREE.BoxGeometry(0.13, 0.36, 0.13);
const geoLimbL = new THREE.BoxGeometry(0.11, 0.36, 0.11);
const geoGun = new THREE.BoxGeometry(0.08, 0.12, 0.52);
class ArticulatedEnemy {
constructor(spawnPos, patrolTarget) {
this.root = new THREE.Group();
this.root.position.copy(spawnPos);
scene.add(this.root);
// Grounding Contact Shadow
this.shadowDisc = new THREE.Mesh(new THREE.PlaneGeometry(1.35, 1.35), contactShadowMat);
this.shadowDisc.rotation.x = -Math.PI / 2;
this.shadowDisc.position.y = 0.02;
this.root.add(this.shadowDisc);
// Pelvis (root of body at hip height 0.92m)
this.hips = new THREE.Group();
this.hips.position.y = 0.92;
this.root.add(this.hips);
const pelvisMesh = new THREE.Mesh(geoPelvis, enemyJointMat);
pelvisMesh.castShadow = true;
pelvisMesh.receiveShadow = true;
this.hips.add(pelvisMesh);
// Torso
this.torso = new THREE.Group();
this.torso.position.y = 0.08;
this.hips.add(this.torso);
const torsoMesh = new THREE.Mesh(geoTorso, enemyArmorMat);
torsoMesh.position.y = 0.26;
torsoMesh.castShadow = true;
torsoMesh.receiveShadow = true;
this.torso.add(torsoMesh);
// Glowing chest core
const chestCore = new THREE.Mesh(new THREE.BoxGeometry(0.16, 0.12, 0.28), enemyVisorMat);
chestCore.position.y = 0.30;
this.torso.add(chestCore);
// Head + Glowing Visor
this.head = new THREE.Group();
this.head.position.y = 0.56;
this.torso.add(this.head);
const headMesh = new THREE.Mesh(geoHead, enemyArmorMat);
headMesh.position.y = 0.12;
headMesh.castShadow = true;
this.head.add(headMesh);
this.visorMesh = new THREE.Mesh(geoVisor, enemyVisorMat.clone());
this.visorMesh.position.set(0, 0.13, 0.065);
this.head.add(this.visorMesh);
// Arms
this.armL = this.createLimb(-0.29, 0.44);
this.armR = this.createLimb( 0.29, 0.44);
this.torso.add(this.armL.upper, this.armR.upper);
// Enemy Plasma Carbine attached to Right Forearm
this.gunMesh = new THREE.Mesh(geoGun, darkMetalMat);
this.gunMesh.position.set(0, -0.32, 0.22);
this.armR.lower.add(this.gunMesh);
this.muzzleNode = new THREE.Object3D();
this.muzzleNode.position.set(0, -0.32, 0.52);
this.armR.lower.add(this.muzzleNode);
// Legs
this.legL = this.createLimb(-0.12, -0.04);
this.legR = this.createLimb( 0.12, -0.04);
this.hips.add(this.legL.upper, this.legR.upper);
// Hitbox Cylinder for clean raycasting
this.hitbox = new THREE.Mesh(
new THREE.CylinderGeometry(0.38, 0.38, 1.85, 8),
new THREE.MeshBasicMaterial({ visible: false })
);
this.hitbox.position.y = 0.92;
this.hitbox.userData.enemy = this;
this.root.add(this.hitbox);
// Telegraph Sight Line
const lineGeo = new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(), new THREE.Vector3(0, 0, 1)]);
this.telegraphLine = new THREE.Line(
lineGeo,
new THREE.LineBasicMaterial({ color: 0xff2a00, transparent: true, opacity: 0.0 })
);
scene.add(this.telegraphLine);
// AI State
this.alive = true;
this.patrolA = spawnPos.clone();
this.patrolB = patrolTarget.clone();
this.movingToB = true;
this.state = 'PATROL'; // PATROL, ALERT, TELEGRAPH
this.animPhase = Math.random() * Math.PI * 2;
this.cooldown = 0.8 + Math.random() * 1.2;
this.telegraphTimer = 0;
this.burstRemaining = 0;
this.burstDelay = 0;
this.strafeDir = Math.random() < 0.5 ? 1 : -1;
this.strafeSwitchTimer = 1.5 + Math.random() * 2.0;
}
createLimb(offsetX, offsetY) {
const upper = new THREE.Group();
upper.position.set(offsetX, offsetY, 0);
const uMesh = new THREE.Mesh(geoLimbU, enemyArmorMat);
uMesh.position.y = -0.18;
uMesh.castShadow = true;
upper.add(uMesh);
const lower = new THREE.Group();
lower.position.y = -0.36;
const lMesh = new THREE.Mesh(geoLimbL, enemyJointMat);
lMesh.position.y = -0.18;
lMesh.castShadow = true;
lower.add(lMesh);
upper.add(lower);
return { upper, lower };
}
hasLineOfSight(playerPos, raycaster) {
const eyePos = this.root.position.clone().add(new THREE.Vector3(0, 1.55, 0));
const toPlayer = playerPos.clone().sub(eyePos);
const dist = toPlayer.length();
if (dist > 38) return false;
toPlayer.normalize();
raycaster.set(eyePos, toPlayer);
raycaster.far = dist;
const hits = raycaster.intersectObjects(environmentMeshes, false);
return hits.length === 0;
}
update(worldDt, playerPos, raycaster) {
if (!this.alive) return;
const canSee = this.hasLineOfSight(playerPos, raycaster);
const toPlayer = new THREE.Vector3(playerPos.x - this.root.position.x, 0, playerPos.z - this.root.position.z);
const distToPlayer = toPlayer.length();
if (canSee) {
if (this.state === 'PATROL') {
this.state = 'ALERT';
this.cooldown = 0.45 + Math.random() * 0.5;
}
} else if (this.state !== 'PATROL' && distToPlayer > 18) {
// Slowly advance toward player's last known direction
}
let moveSpeed = 0;
let targetYaw = this.root.rotation.y;
if (this.state === 'PATROL') {
const goal = this.movingToB ? this.patrolB : this.patrolA;
const toGoal = new THREE.Vector3(goal.x - this.root.position.x, 0, goal.z - this.root.position.z);
if (toGoal.length() < 0.6) {
this.movingToB = !this.movingToB;
} else {
toGoal.normalize();
targetYaw = Math.atan2(toGoal.x, toGoal.z);
moveSpeed = 2.4;
this.tryMove(toGoal.x * moveSpeed * worldDt, toGoal.z * moveSpeed * worldDt);
}
this.telegraphLine.material.opacity = 0;
this.visorMesh.material.emissiveIntensity = 3.2;
} else {
// Face player
targetYaw = Math.atan2(toPlayer.x, toPlayer.z);
// Tactical strafing or closing distance
this.strafeSwitchTimer -= worldDt;
if (this.strafeSwitchTimer <= 0) {
this.strafeDir *= -1;
this.strafeSwitchTimer = 1.6 + Math.random() * 1.8;
}
const fwd = toPlayer.clone().normalize();
const right = new THREE.Vector3(-fwd.z, 0, fwd.x);
let moveVec = right.multiplyScalar(this.strafeDir * 2.1);
if (distToPlayer > 13) moveVec.addScaledVector(fwd, 1.8);
else if (distToPlayer < 6.5) moveVec.addScaledVector(fwd, -1.6);
moveSpeed = moveVec.length();
this.tryMove(moveVec.x * worldDt, moveVec.z * worldDt);
// Telegraph & Burst Fire
if (this.state === 'ALERT') {
this.cooldown -= worldDt;
this.telegraphLine.material.opacity = 0;
this.visorMesh.material.emissiveIntensity = 3.8;
if (this.cooldown <= 0 && canSee) {
this.state = 'TELEGRAPH';
this.telegraphTimer = 0.52; // 0.52s telegraph charge before firing
this.burstRemaining = 2;
}
} else if (this.state === 'TELEGRAPH') {
this.telegraphTimer -= worldDt;
// Pulse visor & show laser sight line
this.visorMesh.material.emissiveIntensity = 8.5;
const muzzleWorld = new THREE.Vector3();
this.muzzleNode.getWorldPosition(muzzleWorld);
const aimPoint = playerPos.clone().add(new THREE.Vector3(0, -0.18, 0));
const posAttr = this.telegraphLine.geometry.attributes.position;
posAttr.setXYZ(0, muzzleWorld.x, muzzleWorld.y, muzzleWorld.z);
posAttr.setXYZ(1, aimPoint.x, aimPoint.y, aimPoint.z);
posAttr.needsUpdate = true;
this.telegraphLine.material.opacity = 0.55;
if (this.telegraphTimer <= 0) {
this.burstDelay -= worldDt;
if (this.burstDelay <= 0) {
this.fireProjectile(playerPos);
this.burstRemaining--;
this.burstDelay = 0.22;
if (this.burstRemaining <= 0) {
this.state = 'ALERT';
this.cooldown = 1.35 + Math.random() * 1.1;
this.telegraphLine.material.opacity = 0;
}
}
}
}
}
// Smooth yaw rotation
let diff = targetYaw - this.root.rotation.y;
while (diff > Math.PI) diff -= Math.PI * 2;
while (diff < -Math.PI) diff += Math.PI * 2;
this.root.rotation.y += diff * Math.min(1, worldDt * 9);
// Articulated Walk & Aim Animation (runs on worldDt!)
this.animPhase += worldDt * (moveSpeed * 2.8 + 1.2);
const stride = Math.min(1, moveSpeed / 2.4) * 0.55;
const s = Math.sin(this.animPhase);
const c = Math.cos(this.animPhase);
this.hips.position.y = 0.92 + Math.abs(s) * stride * 0.06;
this.legL.upper.rotation.x = s * stride;
this.legL.lower.rotation.x = Math.max(0, -c * stride * 0.85);
this.legR.upper.rotation.x = -s * stride;
this.legR.lower.rotation.x = Math.max(0, c * stride * 0.85);
if (this.state === 'PATROL') {
this.armL.upper.rotation.x = -s * stride * 0.6;
this.armL.lower.rotation.x = -0.35;
this.armR.upper.rotation.x = s * stride * 0.4 - 0.35;
this.armR.lower.rotation.x = -0.65;
this.torso.rotation.y = s * 0.08;
} else {
// Combat Aim Pose: raise weapon toward player
this.torso.rotation.y = 0.18;
this.armR.upper.rotation.x = -1.25;
this.armR.upper.rotation.y = -0.15;
this.armR.lower.rotation.x = -0.32;
this.armL.upper.rotation.x = -1.15;
this.armL.upper.rotation.y = 0.42;
this.armL.lower.rotation.x = -0.45;
}
}
tryMove(dx, dz) {
const r = 0.45;
const nx = this.root.position.x + dx;
const nz = this.root.position.z + dz;
if (!checkCircleObstacleCollision(nx, this.root.position.z, r)) {
this.root.position.x = nx;
}
if (!checkCircleObstacleCollision(this.root.position.x, nz, r)) {
this.root.position.z = nz;
}
}
fireProjectile(playerPos) {
const muzzleWorld = new THREE.Vector3();
this.muzzleNode.getWorldPosition(muzzleWorld);
const target = playerPos.clone().add(new THREE.Vector3(
(Math.random() - 0.5) * 0.35,
-0.12 + (Math.random() - 0.5) * 0.2,
(Math.random() - 0.5) * 0.35
));
const dir = target.sub(muzzleWorld).normalize();
spawnEnemyProjectile(muzzleWorld, dir);
const dist = muzzleWorld.distanceTo(playerPos);
audio.playEnemyShoot(Math.max(0.2, 1.0 - dist / 38));
}
shatter(hitPoint, shotDir) {
if (!this.alive) return;
this.alive = false;
this.telegraphLine.material.opacity = 0;
this.root.visible = false;
spawnEnemyShatterExplosion(this.root.position.clone().add(new THREE.Vector3(0, 1.05, 0)), shotDir);
audio.playEnemyShatter();
}
dispose() {
scene.remove(this.root);
scene.remove(this.telegraphLine);
}
}
// ============================================================================
// 8. GLOWING PROJECTILES, TRAILS, TRACERS & EMBER PARTICLES
// ============================================================================
const enemyProjectiles = [];
const projectileCoreGeo = new THREE.CylinderGeometry(0.038, 0.038, 0.75, 8);
projectileCoreGeo.rotateX(Math.PI / 2);
const projectileCoreMat = new THREE.MeshStandardMaterial({
color: 0xffffff,
emissive: 0xff3300,
emissiveIntensity: 6.5,
roughness: 0.1
});
const projectileTrailGeo = new THREE.CylinderGeometry(0.012, 0.032, 2.6, 6);
projectileTrailGeo.rotateX(Math.PI / 2);
projectileTrailGeo.translate(0, 0, -1.4);
const projectileTrailMat = new THREE.MeshBasicMaterial({
color: 0xff2200,
transparent: true,
opacity: 0.52,
depthWrite: false
});
function spawnEnemyProjectile(origin, dir) {
const group = new THREE.Group();
group.position.copy(origin);
group.lookAt(origin.clone().add(dir));
const core = new THREE.Mesh(projectileCoreGeo, projectileCoreMat);
const trail = new THREE.Mesh(projectileTrailGeo, projectileTrailMat);
group.add(core, trail);
scene.add(group);
enemyProjectiles.push({
mesh: group,
pos: origin.clone(),
vel: dir.clone().multiplyScalar(14.5), // Slow enough to weave through in stasis, lethal at 1.0x!
life: 4.5
});
}
// Player Tracer Streaks
const playerTracers = [];
const tracerMat = new THREE.MeshBasicMaterial({
color: 0x66f7ff,
transparent: true,
opacity: 0.9,
depthWrite: false
});
function spawnPlayerTracer(start, end) {
const dist = start.distanceTo(end);
const geo = new THREE.CylinderGeometry(0.012, 0.012, dist, 6);
geo.rotateX(Math.PI / 2);
const mesh = new THREE.Mesh(geo, tracerMat.clone());
mesh.position.copy(start).lerp(end, 0.5);
mesh.lookAt(end);
scene.add(mesh);
playerTracers.push({ mesh, life: 0.18, maxLife: 0.18 });
}
// Scaled-Time Particles (Embers, Armor Shards, Sparks, Volumetric Smoke)
const particles = [];
const shardGeo = new THREE.DodecahedronGeometry(0.11, 0);
const emberGeo = new THREE.BoxGeometry(0.055, 0.055, 0.055);
const smokeGeo = new THREE.DodecahedronGeometry(0.18, 1);
const emberMat = new THREE.MeshStandardMaterial({
color: 0xffaa44,
emissive: 0xff4400,
emissiveIntensity: 5.5
});
const sparkMat = new THREE.MeshStandardMaterial({
color: 0xffe699,
emissive: 0xff8800,
emissiveIntensity: 5.0
});
const smokeMatBase = new THREE.MeshStandardMaterial({
color: 0x222830,
roughness: 0.95,
transparent: true,
opacity: 0.55,
depthWrite: false
});
let explosionLightTimer = 0;
function spawnEnemyShatterExplosion(center, shotDir) {
explosionLight.position.copy(center);
explosionLight.intensity = 28;
explosionLightTimer = 0.28;
// 28 Dark Armor Shards
for (let i = 0; i < 28; i++) {
const m = new THREE.Mesh(shardGeo, i % 3 === 0 ? enemyVisorMat : enemyArmorMat);
m.position.copy(center).add(new THREE.Vector3(
(Math.random() - 0.5) * 0.5,
(Math.random() - 0.5) * 1.1,
(Math.random() - 0.5) * 0.5
));
const s = 0.6 + Math.random() * 1.1;
m.scale.set(s, s * 0.7, s);
scene.add(m);
const vel = new THREE.Vector3(
(Math.random() - 0.5) * 6.5 + shotDir.x * 4.0,
1.8 + Math.random() * 5.0,
(Math.random() - 0.5) * 6.5 + shotDir.z * 4.0
);
particles.push({
mesh: m,
vel,
rotVel: new THREE.Vector3(Math.random() * 10, Math.random() * 10, Math.random() * 10),
gravity: 11.5,
drag: 0.98,
life: 1.8 + Math.random() * 0.9,
maxLife: 2.5,
type: 'shard'
});
}
// 45 Glowing Embers
for (let i = 0; i < 45; i++) {
const m = new THREE.Mesh(emberGeo, emberMat);
m.position.copy(center).add(new THREE.Vector3(
(Math.random() - 0.5) * 0.4,
(Math.random() - 0.5) * 0.9,
(Math.random() - 0.5) * 0.4
));
scene.add(m);
const vel = new THREE.Vector3(
(Math.random() - 0.5) * 9.5 + shotDir.x * 3.0,
(Math.random() - 0.2) * 8.0,
(Math.random() - 0.5) * 9.5 + shotDir.z * 3.0
);
particles.push({
mesh: m,
vel,
rotVel: new THREE.Vector3(Math.random() * 12, Math.random() * 12, 0),
gravity: 4.5,
drag: 0.94,
life: 0.9 + Math.random() * 0.9,
maxLife: 1.8,
type: 'ember'
});
}
}
function spawnImpactSparksAndSmoke(point, normal) {
// 14 Sparks
for (let i = 0; i < 14; i++) {
const m = new THREE.Mesh(emberGeo, sparkMat);
m.scale.setScalar(0.55);
m.position.copy(point);
scene.add(m);
const vel = normal.clone().multiplyScalar(2.5 + Math.random() * 5.0).add(new THREE.Vector3(
(Math.random() - 0.5) * 5.5,
1.0 + Math.random() * 3.5,
(Math.random() - 0.5) * 5.5
));
particles.push({
mesh: m,
vel,
rotVel: new THREE.Vector3(8, 8, 8),
gravity: 12.0,
drag: 0.95,
life: 0.35 + Math.random() * 0.35,
maxLife: 0.7,
type: 'ember'
});
}
// 5 Expanding Smoke Puffs
for (let i = 0; i < 5; i++) {
const m = new THREE.Mesh(smokeGeo, smokeMatBase.clone());
m.position.copy(point).addScaledVector(normal, 0.12);
const initScale = 0.4 + Math.random() * 0.4;
m.scale.setScalar(initScale);
scene.add(m);
const vel = normal.clone().multiplyScalar(0.7 + Math.random() * 0.8).add(new THREE.Vector3(
(Math.random() - 0.5) * 0.8,
0.6 + Math.random() * 0.9,
(Math.random() - 0.5) * 0.8
));
particles.push({
mesh: m,
vel,
rotVel: new THREE.Vector3(Math.random() * 2, Math.random() * 2, Math.random() * 2),
gravity: -0.6, // rises slowly
drag: 0.92,
life: 0.9 + Math.random() * 0.5,
maxLife: 1.4,
type: 'smoke'
});
}
}
// ============================================================================
// 9. PLAYER PHYSICS, TIME-SCALE ENGINE, AI DEMO BOT & GAME LOOP
// ============================================================================
const PLAYER_HEIGHT = 1.68;
const PLAYER_RADIUS = 0.42;
const player = {
pos: new THREE.Vector3(0, PLAYER_HEIGHT, 18.5),
vel: new THREE.Vector3(),
yaw: 0,
pitch: 0,
hp: 3,
alive: true,
fireCooldown: 0,
shotTimeSpike: 0,
lookSpeedImpulse: 0,
damageFlinch: 0,
damageDir2D: new THREE.Vector2(0, 0)
};
let timeScale = 0.05;
let gameState = 'MENU'; // MENU, PLAYING, CLEAR, DEAD
let demoBotEnabled = false;
let enemies = [];
const keys = { KeyW: false, KeyA: false, KeyS: false, KeyD: false, ArrowUp: false, ArrowLeft: false, ArrowDown: false, ArrowRight: false };
function checkCircleObstacleCollision(x, z, r) {
if (x - r < -ARENA_HALF + 0.8 || x + r > ARENA_HALF - 0.8 ||
z - r < -ARENA_HALF + 0.8 || z + r > ARENA_HALF - 0.8) {
return true;
}
for (let i = 0; i < obstacles.length; i++) {
const o = obstacles[i];
const cx = Math.max(o.minX, Math.min(x, o.maxX));
const cz = Math.max(o.minZ, Math.min(z, o.maxZ));
const dx = x - cx;
const dz = z - cz;
if (dx * dx + dz * dz < r * r) return true;
}
return false;
}
const ENEMY_SPAWNS = [
[new THREE.Vector3(-16, 0, -16), new THREE.Vector3(-6, 0, -16)],
[new THREE.Vector3( 16, 0, -16), new THREE.Vector3( 6, 0, -16)],
[new THREE.Vector3(-17, 0, -4), new THREE.Vector3(-17, 0, 7)],
[new THREE.Vector3( 17, 0, -4), new THREE.Vector3( 17, 0, 7)],
[new THREE.Vector3( -7, 0, -6), new THREE.Vector3( -7, 0, 5)],
[new THREE.Vector3( 7, 0, -6), new THREE.Vector3( 7, 0, 5)],
[new THREE.Vector3( 0, 0, -18), new THREE.Vector3( 0, 0, -7)],
[new THREE.Vector3(-15, 0, 15), new THREE.Vector3( -6, 0, 15)]
];
function resetArena() {
// Clear old enemies
enemies.forEach(e => e.dispose());
enemies = [];
// Clear projectiles
enemyProjectiles.forEach(p => scene.remove(p.mesh));
enemyProjectiles.length = 0;
// Clear particles
particles.forEach(p => scene.remove(p.mesh));
particles.length = 0;
player.pos.set(0, PLAYER_HEIGHT, 18.5);
player.vel.set(0, 0, 0);
player.yaw = 0;
player.pitch = 0;
player.hp = 3;
player.alive = true;
player.fireCooldown = 0;
player.shotTimeSpike = 0;
player.damageFlinch = 0;
timeScale = 0.05;
for (const [sp, pt] of ENEMY_SPAWNS) {
enemies.push(new ArticulatedEnemy(sp, pt));
}
updateHUD();
}
// HUD References
const heartsEls = document.querySelectorAll('.heart-icon');
const enemiesLeftEl = document.getElementById('enemies-left-val');
const timeDialArc = document.getElementById('time-dial-arc');
const timePctEl = document.getElementById('time-pct');
const timeStateLabel = document.getElementById('time-state-label');
const chronoBanner = document.getElementById('chrono-banner');
const crosshairWrap = document.getElementById('crosshair-wrap');
const chLines = [
document.getElementById('ch-t'),
document.getElementById('ch-b'),
document.getElementById('ch-l'),
document.getElementById('ch-r')
];
const overlayScreen = document.getElementById('overlay-screen');
const modalTag = document.getElementById('modal-tag');
const modalTitle = document.getElementById('modal-title');
const modalDesc = document.getElementById('modal-desc');
const btnStart = document.getElementById('btn-start');
const btnDemo = document.getElementById('btn-demo');
const botToggleBtn = document.getElementById('bot-toggle-btn');
function updateHUD() {
heartsEls.forEach((el, idx) => {
el.classList.toggle('lost', idx >= player.hp);
});
const aliveCount = enemies.filter(e => e.alive).length;
enemiesLeftEl.textContent = String(aliveCount).padStart(2, '0');
}
function showOverlay(state) {
gameState = state;
if (document.pointerLockElement === canvas && state !== 'PLAYING') {
document.exitPointerLock();
}
if (state === 'PLAYING') {
overlayScreen.classList.add('hidden');
return;
}
overlayScreen.classList.remove('hidden');
if (state === 'CLEAR') {
modalTag.textContent = 'SECTOR SECURED // ALL HOSTILES SHATTERED';
modalTitle.textContent = 'LEVEL CLEAR';
modalDesc.innerHTML = 'Every threat in the courtyard has been eliminated. Click below to deploy into a fresh wave.';
btnStart.textContent = 'DEPLOY AGAIN';
} else if (state === 'DEAD') {
modalTag.textContent = 'CRITICAL FAILURE // CHRONO LINK SEVERED';
modalTitle.textContent = 'YOU DIED';
modalDesc.innerHTML = 'You were caught in the crossfire. Stand still to freeze incoming volleys at <strong>5% time</strong>, plan the gap, and step through.<br><br><strong>Click anywhere to restart.</strong>';
btnStart.textContent = 'CLICK TO RESTART';
}
}
// Firing Logic (Never slowed by timeScale!)
const sharedRaycaster = new THREE.Raycaster();
function fireWeapon() {
if (!player.alive || player.fireCooldown > 0) return;
player.fireCooldown = 0.20;
player.shotTimeSpike = 0.85; // Firing impulses time forward briefly
gunRecoil = 1.0;
muzzleFlashTimer = 0.045;
audio.playPlayerShoot();
// Raycast from camera center
sharedRaycaster.setFromCamera(new THREE.Vector2(0, 0), camera);
sharedRaycaster.far = 90;
// Check enemy hitboxes + environment
const hitboxes = enemies.filter(e => e.alive).map(e => e.hitbox);
const allTargets = [...hitboxes, ...environmentMeshes];
const hits = sharedRaycaster.intersectObjects(allTargets, false);
// Also check if player shot an enemy projectile out of mid-air!
for (let i = enemyProjectiles.length - 1; i >= 0; i--) {
const p = enemyProjectiles[i];
const distToRay = sharedRaycaster.ray.distanceSqToPoint(p.pos);
if (distToRay < 0.14) {
spawnImpactSparksAndSmoke(p.pos, sharedRaycaster.ray.direction.clone().negate());
scene.remove(p.mesh);
enemyProjectiles.splice(i, 1);
audio.playImpact(false);
break;
}
}
const muzzleWorld = new THREE.Vector3();
muzzleFlashMesh.getWorldPosition(muzzleWorld);
const shotDir = sharedRaycaster.ray.direction.clone();
let endPoint = camera.position.clone().addScaledVector(shotDir, 60);
if (hits.length > 0) {
const hit = hits[0];
endPoint = hit.point;
const enemy = hit.object.userData.enemy;
if (enemy && enemy.alive) {
enemy.shatter(hit.point, shotDir);
crosshairWrap.classList.add('hit');
setTimeout(() => crosshairWrap.classList.remove('hit'), 140);
updateHUD();
const remaining = enemies.filter(e => e.alive).length;
if (remaining === 0) {
setTimeout(() => {
if (demoBotEnabled && gameState === 'PLAYING') {
resetArena();
} else {
showOverlay('CLEAR');
}
}, 900);
}
} else {
const normal = hit.face ? hit.face.normal.clone().transformDirection(hit.object.matrixWorld) : new THREE.Vector3(0, 1, 0);
spawnImpactSparksAndSmoke(hit.point, normal);
audio.playImpact(false);
}
}
spawnPlayerTracer(muzzleWorld, endPoint);
}
// Autonomous AI Demo Bot (Roams, freezes time to study volleys, dodges, and hunts!)
let botPauseTimer = 0;
let botStrafeSign = 1;
let botWanderAngle = 0;
function updateDemoBot(rawDt, outMoveVec) {
// 1. Evaluate closest incoming projectile danger
let urgentDodge = new THREE.Vector3();
let projNear = false;
for (let i = 0; i < enemyProjectiles.length; i++) {
const p = enemyProjectiles[i];
const toPlayer = player.pos.clone().sub(p.pos);
const dist = toPlayer.length();
if (dist < 11.0) {
const pDir = p.vel.clone().normalize();
const align = pDir.dot(toPlayer.clone().normalize());
if (align > 0.75) {
projNear = true;
// Lateral dodge vector perpendicular to projectile velocity
const perp = new THREE.Vector3(-pDir.z, 0, pDir.x).multiplyScalar(botStrafeSign);
urgentDodge.add(perp);
}
}
}
// 2. Find best visible or nearest enemy target
let bestEnemy = null;
let bestScore = Infinity;
for (let i = 0; i < enemies.length; i++) {
const e = enemies[i];
if (!e.alive) continue;
const d = e.root.position.distanceTo(player.pos);
const vis = e.hasLineOfSight(player.pos, sharedRaycaster);
const score = vis ? d * 0.35 : d + 20;
if (score < bestScore) {
bestScore = score;
bestEnemy = e;
}
}
if (!bestEnemy) return;
const targetAimPos = bestEnemy.root.position.clone().add(new THREE.Vector3(0, 1.32, 0));
const toTarget = targetAimPos.clone().sub(player.pos);
const desiredYaw = Math.atan2(-toTarget.x, -toTarget.z);
const horizDist = Math.hypot(toTarget.x, toTarget.z);
const desiredPitch = Math.atan2(toTarget.y, horizDist);
// Smoothly aim toward target
let yawDiff = desiredYaw - player.yaw;
while (yawDiff > Math.PI) yawDiff -= Math.PI * 2;
while (yawDiff < -Math.PI) yawDiff += Math.PI * 2;
const pitchDiff = desiredPitch - player.pitch;
const aimStepYaw = yawDiff * Math.min(1, rawDt * 8.5);
const aimStepPitch = pitchDiff * Math.min(1, rawDt * 8.5);
player.yaw += aimStepYaw;
player.pitch += aimStepPitch;
player.lookSpeedImpulse = Math.min(0.45, Math.hypot(aimStepYaw, aimStepPitch) * 8);
const canSeeTarget = bestEnemy.hasLineOfSight(player.pos, sharedRaycaster);
const isAimed = Math.abs(yawDiff) < 0.08 && Math.abs(pitchDiff) < 0.08;
// SUPERHOT Tactic: If enemy is visible and we are lining up a shot (and not actively side-stepping a bolt at <4m),
// deliberately STOP MOVING to freeze time at ~5% while locking onto the target!
botPauseTimer -= rawDt;
if (projNear && urgentDodge.lengthSq() > 0.01) {
urgentDodge.normalize();
outMoveVec.copy(urgentDodge);
} else if (canSeeTarget && !isAimed && botPauseTimer > -0.6) {
// Stand still to freeze time while snapping aim!
outMoveVec.set(0, 0, 0);
} else {
if (botPauseTimer <= -0.6) {
botPauseTimer = 0.75 + Math.random() * 0.6;
botStrafeSign = Math.random() < 0.5 ? 1 : -1;
}
// Advance & strafe around cover toward enemy
const fwd = new THREE.Vector3(toTarget.x, 0, toTarget.z).normalize();
const right = new THREE.Vector3(-fwd.z, 0, fwd.x).multiplyScalar(botStrafeSign * 0.75);
botWanderAngle += rawDt * 1.4;
if (horizDist > 11) {
outMoveVec.copy(fwd).add(right).normalize();
} else if (horizDist < 6) {
outMoveVec.copy(fwd).negate().add(right).normalize();
} else {
outMoveVec.copy(right).normalize();
}
}
if (canSeeTarget && isAimed && player.fireCooldown <= 0) {
fireWeapon();
botPauseTimer = 0.55; // Brief pause after shot to watch slow-mo shatter!
}
}
// ============================================================================
// 10. INPUT LISTENERS & POINTER LOCK
// ============================================================================
window.addEventListener('keydown', (e) => {
if (e.code in keys) keys[e.code] = true;
if (e.code === 'KeyB') {
toggleDemoBot(!demoBotEnabled);
}
if (e.code === 'KeyR' && (gameState === 'DEAD' || gameState === 'CLEAR')) {
startGame(demoBotEnabled);
}
});
window.addEventListener('keyup', (e) => {
if (e.code in keys) keys[e.code] = false;
});
window.addEventListener('mousemove', (e) => {
if (gameState !== 'PLAYING' || demoBotEnabled) return;
if (document.pointerLockElement !== canvas) return;
const sens = 0.0021;
player.yaw -= e.movementX * sens;
player.pitch = Math.max(-1.45, Math.min(1.45, player.pitch - e.movementY * sens));
swayX = Math.max(-0.04, Math.min(0.04, swayX - e.movementX * 0.0004));
swayY = Math.max(-0.04, Math.min(0.04, swayY - e.movementY * 0.0004));
// Turning head gently nudges timeScale up
const angularDelta = Math.hypot(e.movementX, e.movementY);
player.lookSpeedImpulse = Math.min(0.72, player.lookSpeedImpulse + angularDelta * 0.011);
});
window.addEventListener('mousedown', (e) => {
if (e.target === botToggleBtn) return;
audio.init();
if (gameState === 'PLAYING') {
if (!demoBotEnabled && document.pointerLockElement !== canvas) {
canvas.requestPointerLock();
} else if (!demoBotEnabled && e.button === 0) {
fireWeapon();
}
} else if (gameState === 'DEAD') {
startGame(demoBotEnabled);
}
});
function toggleDemoBot(state) {
demoBotEnabled = state;
botToggleBtn.textContent = `[B] DEMO BOT: ${demoBotEnabled ? 'ON' : 'OFF'}`;
botToggleBtn.classList.toggle('active', demoBotEnabled);
if (demoBotEnabled && document.pointerLockElement === canvas) {
document.exitPointerLock();
}
}
botToggleBtn.addEventListener('click', (e) => {
e.stopPropagation();
audio.init();
toggleDemoBot(!demoBotEnabled);
if (gameState !== 'PLAYING') {
startGame(true);
}
});
function startGame(withBot = false) {
audio.init();
toggleDemoBot(withBot);
resetArena();
showOverlay('PLAYING');
if (!withBot) {
canvas.requestPointerLock();
}
}
btnStart.addEventListener('click', (e) => {
e.stopPropagation();
startGame(false);
});
btnDemo.addEventListener('click', (e) => {
e.stopPropagation();
startGame(true);
});
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
composer.setSize(window.innerWidth, window.innerHeight);
});
// ============================================================================
// 11. MAIN REAL-TIME & SCALED-TIME SIMULATION LOOP
// ============================================================================
resetArena();
let prevTime = performance.now();
const DIAL_CIRCUMFERENCE = 150.8;
function animate(now) {
requestAnimationFrame(animate);
const rawDt = Math.min(0.05, (now - prevTime) / 1000);
prevTime = now;
// --- 1. Player Input / Autonomous Bot Movement (Runs on rawDt) ---
const moveInput = new THREE.Vector3();
if (gameState === 'PLAYING' && player.alive) {
if (demoBotEnabled) {
updateDemoBot(rawDt, moveInput);
} else {
const fwd = 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));
if (keys.KeyW || keys.ArrowUp) moveInput.add(fwd);
if (keys.KeyS || keys.ArrowDown) moveInput.sub(fwd);
if (keys.KeyD || keys.ArrowRight) moveInput.add(right);
if (keys.KeyA || keys.ArrowLeft) moveInput.sub(right);
if (moveInput.lengthSq() > 0) moveInput.normalize();
}
const maxSpeed = 6.8;
const targetVx = moveInput.x * maxSpeed;
const targetVz = moveInput.z * maxSpeed;
player.vel.x += (targetVx - player.vel.x) * Math.min(1, rawDt * 14);
player.vel.z += (targetVz - player.vel.z) * Math.min(1, rawDt * 14);
// Apply sliding collision against brutalist pillars & walls
const nx = player.pos.x + player.vel.x * rawDt;
const nz = player.pos.z + player.vel.z * rawDt;
if (!checkCircleObstacleCollision(nx, player.pos.z, PLAYER_RADIUS)) {
player.pos.x = nx;
} else {
player.vel.x = 0;
}
if (!checkCircleObstacleCollision(player.pos.x, nz, PLAYER_RADIUS)) {
player.pos.z = nz;
} else {
player.vel.z = 0;
}
} else {
player.vel.set(0, 0, 0);
}
// --- 2. Continuous SUPERHOT Time-Scale Calculation ---
const moveRatio = Math.min(1.0, Math.hypot(player.vel.x, player.vel.z) / 6.2);
player.lookSpeedImpulse = Math.max(0, player.lookSpeedImpulse - rawDt * 2.4);
player.shotTimeSpike = Math.max(0, player.shotTimeSpike - rawDt * 2.8);
let targetTimeScale = 0.05; // 5% speed when standing still
if (gameState === 'PLAYING' && player.alive) {
targetTimeScale = Math.max(0.05, Math.min(1.0, moveRatio * 0.95 + 0.05, Math.max(moveRatio, player.lookSpeedImpulse, player.shotTimeSpike)));
if (moveRatio > 0.15) targetTimeScale = Math.max(targetTimeScale, Math.min(1.0, 0.05 + moveRatio * 0.95));
} else {
targetTimeScale = 0.08;
}
const rate = targetTimeScale > timeScale ? 14.0 : 9.5;
timeScale += (targetTimeScale - timeScale) * Math.min(1, rawDt * rate);
const worldDt = rawDt * timeScale;
// Update Audio Pitch-Bend
audio.update(timeScale);
// Update Time Dial HUD
const offset = DIAL_CIRCUMFERENCE * (1.0 - timeScale);
timeDialArc.style.strokeDashoffset = offset.toFixed(1);
timeDialArc.style.stroke = timeScale < 0.22 ? '#00f0ff' : '#ff5511';
timePctEl.textContent = `${timeScale.toFixed(2)}x`;
if (timeScale < 0.18) {
timeStateLabel.textContent = 'CHRONO STASIS';
timeStateLabel.style.color = '#00f0ff';
chronoBanner.style.opacity = gameState === 'PLAYING' ? '1' : '0';
} else {
timeStateLabel.textContent = 'FLOW ACTIVE';
timeStateLabel.style.color = '#ff5511';
chronoBanner.style.opacity = '0';
}
// --- 3. Camera, Viewmodel Recoil & Sway (Runs on rawDt so aiming is always crisp) ---
player.fireCooldown = Math.max(0, player.fireCooldown - rawDt);
player.damageFlinch = Math.max(0, player.damageFlinch - rawDt * 2.4);
gunRecoil = Math.max(0, gunRecoil - rawDt * 8.5);
const speedLen = Math.hypot(player.vel.x, player.vel.z);
walkBobPhase += rawDt * speedLen * 1.9;
const bobX = Math.cos(walkBobPhase) * 0.008 * moveRatio;
const bobY = Math.abs(Math.sin(walkBobPhase)) * 0.012 * moveRatio;
camera.position.copy(player.pos);
camera.position.y += bobY * 2.2;
camera.rotation.y = player.yaw;
camera.rotation.x = player.pitch + gunRecoil * 0.035 + player.damageFlinch * 0.06;
camera.rotation.z = player.damageFlinch * 0.05 * (player.damageDir2D.x >= 0 ? 1 : -1);
swayX *= Math.pow(0.001, rawDt);
swayY *= Math.pow(0.001, rawDt);
gunBodyGroup.position.set(0.24 + swayX + bobX, -0.21 + swayY - bobY, -0.42 + gunRecoil * 0.065);
gunBodyGroup.rotation.x = gunRecoil * 0.22;
gunSlide.position.z = -0.02 + gunRecoil * 0.055;
gunCoreMat.emissiveIntensity = 1.2 + timeScale * 3.8;
// Dynamic Crosshair Spread
const spread = 6 + Math.round(gunRecoil * 8 + moveRatio * 4);
chLines[0].style.top = `${12 - spread}px`;
chLines[1].style.bottom = `${12 - spread}px`;
chLines[2].style.left = `${12 - spread}px`;
chLines[3].style.right = `${12 - spread}px`;
// Muzzle Flash Light (illuminates scene for 1-2 frames)
if (muzzleFlashTimer > 0) {
muzzleFlashTimer -= rawDt;
muzzleFlashMat.opacity = 0.95;
muzzleFlashMesh.rotation.z = Math.random() * Math.PI;
const mfPos = new THREE.Vector3();
muzzleFlashMesh.getWorldPosition(mfPos);
muzzleFlashLight.position.copy(mfPos);
muzzleFlashLight.intensity = 24;
} else {
muzzleFlashMat.opacity = 0;
muzzleFlashLight.intensity = 0;
}
// --- 4. Scaled-Time World Updates (Enemies, Projectiles, Rain, Particles) ---
updateRain(worldDt, timeScale);
for (let i = 0; i < enemies.length; i++) {
enemies[i].update(worldDt, player.pos, sharedRaycaster);
}
// Update Enemy Projectiles
for (let i = enemyProjectiles.length - 1; i >= 0; i--) {
const p = enemyProjectiles[i];
p.life -= worldDt;
const step = p.vel.clone().multiplyScalar(worldDt);
const nextPos = p.pos.clone().add(step);
// Check obstacle collision
let hitWall = false;
if (checkCircleObstacleCollision(nextPos.x, nextPos.z, 0.08)) {
// Check height against obstacle maxY
for (let j = 0; j < obstacles.length; j++) {
const o = obstacles[j];
if (nextPos.x >= o.minX && nextPos.x <= o.maxX && nextPos.z >= o.minZ && nextPos.z <= o.maxZ && nextPos.y <= o.maxY) {
hitWall = true;
break;
}
}
if (Math.abs(nextPos.x) > ARENA_HALF - 0.9 || Math.abs(nextPos.z) > ARENA_HALF - 0.9) hitWall = true;
}
// Check player hit
const distToPlayerH = Math.hypot(nextPos.x - player.pos.x, nextPos.z - player.pos.z);
const distToPlayerV = Math.abs(nextPos.y - (player.pos.y - 0.25));
if (player.alive && distToPlayerH < 0.42 && distToPlayerV < 0.75) {
player.hp--;
player.damageFlinch = 1.0;
// Compute directional hit indicator in camera local space
const hitVec = p.pos.clone().sub(player.pos).normalize();
const camRight = new THREE.Vector3(Math.cos(player.yaw), 0, -Math.sin(player.yaw));
const camFwd = new THREE.Vector3(-Math.sin(player.yaw), 0, -Math.cos(player.yaw));
player.damageDir2D.set(hitVec.dot(camRight), hitVec.dot(camFwd)).normalize();
audio.playPlayerDamage();
updateHUD();
scene.remove(p.mesh);
enemyProjectiles.splice(i, 1);
if (player.hp <= 0) {
player.alive = false;
showOverlay('DEAD');
}
continue;
}
if (hitWall || nextPos.y <= 0.05 || p.life <= 0) {
spawnImpactSparksAndSmoke(p.pos, p.vel.clone().negate().normalize());
scene.remove(p.mesh);
enemyProjectiles.splice(i, 1);
continue;
}
p.pos.copy(nextPos);
p.mesh.position.copy(p.pos);
}
// Assign Pooled Point Lights to closest active projectiles
for (let i = 0; i < PROJ_LIGHT_POOL_SIZE; i++) {
const pl = projLightPool[i];
if (i < enemyProjectiles.length) {
pl.position.copy(enemyProjectiles[i].pos);
pl.intensity = 9.5;
} else {
pl.intensity = 0;
}
}
// Update Player Tracer Streaks (fade in scaled time)
for (let i = playerTracers.length - 1; i >= 0; i--) {
const t = playerTracers[i];
t.life -= rawDt * (0.35 + timeScale * 0.65);
if (t.life <= 0) {
scene.remove(t.mesh);
t.mesh.geometry.dispose();
playerTracers.splice(i, 1);
} else {
t.mesh.material.opacity = (t.life / t.maxLife) * 0.85;
}
}
// Update Explosion Point Light
if (explosionLightTimer > 0) {
explosionLightTimer -= worldDt;
explosionLight.intensity = Math.max(0, (explosionLightTimer / 0.28) * 28);
} else {
explosionLight.intensity = 0;
}
// Update Scaled-Time Particles (Shards, Embers, Smoke)
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i];
p.life -= worldDt;
if (p.life <= 0) {
scene.remove(p.mesh);
particles.splice(i, 1);
continue;
}
p.vel.y -= p.gravity * worldDt;
p.vel.multiplyScalar(Math.pow(p.drag, worldDt * 60));
p.mesh.position.addScaledVector(p.vel, worldDt);
if (p.mesh.position.y < 0.06 && p.vel.y < 0) {
p.mesh.position.y = 0.06;
p.vel.y *= -0.45;
p.vel.x *= 0.7;
p.vel.z *= 0.7;
}
p.mesh.rotation.x += p.rotVel.x * worldDt;
p.mesh.rotation.y += p.rotVel.y * worldDt;
p.mesh.rotation.z += p.rotVel.z * worldDt;
const lifeRatio = p.life / p.maxLife;
if (p.type === 'ember') {
p.mesh.scale.setScalar(Math.max(0.01, lifeRatio));
} else if (p.type === 'smoke') {
p.mesh.scale.addScalar(worldDt * 1.4);
p.mesh.material.opacity = lifeRatio * 0.42;
}
}
// --- 5. Post-Processing Uniforms & Render ---
gradePass.uniforms.uTimeScale.value = timeScale;
gradePass.uniforms.uTime.value = now * 0.001;
gradePass.uniforms.uDamagePulse.value = player.damageFlinch;
gradePass.uniforms.uDamageDir.value.copy(player.damageDir2D);
composer.render();
}
requestAnimationFrame(animate);
</script>
</body>
</html>Same prompt, other models
One email when a new model lands on the board or in the showcase. Unsubscribe anytime.