Showcase

Time only moves when you move, built by Gemini 4 Argon

Hostedvia DaBenchOpenRouter
Running live in your browserNew tab
Cost to build
$0.17
Show the code the model wrote
HTML83,525 characters
<!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

2 more
Compare side by side

One email when a new model lands on the board or in the showcase. Unsubscribe anytime.