{"project":{"id":"fsP5E95","userId":"davidyarham@gmail.com","username":null,"userPicture":null,"name":"Neon Runner 2.5D","thumbnail":"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","visible":true,"contributors":"","githubRepo":null,"forkedFrom":null,"isTemplate":false,"tags":"","files":{"folder":"","files":[{"name":"index.html","content":"<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n  <meta charset=\"UTF-8\">\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0, viewport-fit=cover\">\n  <title>Neon Runner 2.5D</title>\n  <link rel=\"stylesheet\" href=\"/css/styles.css\">\n  <script type=\"importmap\">\n  {\n    \"imports\": {\n      \"three\": \"https://unpkg.com/three@0.160.0/build/three.module.js\",\n      \"three/addons/\": \"https://unpkg.com/three@0.160.0/examples/jsm/\"\n    }\n  }\n  </script>\n</head>\n<body>\n  <div class=\"game\">\n    <canvas id=\"game-canvas\" aria-label=\"Neon Runner platformer game\"></canvas>\n\n    <div class=\"overlay overlay--start\" id=\"start-overlay\">\n      <div class=\"overlay__card\">\n        <div class=\"overlay__kicker\">2.5D Neon Platformer</div>\n        <h1 class=\"overlay__title\">Neon Runner</h1>\n        <p class=\"overlay__sub\">Collect every coin to open the portal, then dash through to the next level.</p>\n        <ul class=\"controls\" aria-label=\"Controls\">\n          <li><kbd>&larr;</kbd><kbd>&rarr;</kbd> <span>Move left / right</span></li>\n          <li><kbd>Space</kbd> <span>Jump</span></li>\n          <li><kbd>Hold into a wall</kbd> <span>Wall-climb &amp; wall-jump</span></li>\n          <li><kbd>R</kbd> <span>Restart level</span></li>\n        </ul>\n        <button class=\"overlay__btn\" id=\"start-btn\" type=\"button\">Press Start to play</button>\n      </div>\n    </div>\n\n    <div class=\"hud\">\n      <div class=\"hud__left\" aria-live=\"polite\">\n        <div class=\"hud__coins\">\n          <span class=\"hud__coin-dot\" aria-hidden=\"true\"></span>\n          <span id=\"coin-count\">0</span><span class=\"hud__coin-total\" id=\"coin-total\">/0</span>\n        </div>\n        <div class=\"hud__timer\" id=\"hud-timer\">0.0s</div>\n        <div class=\"hud__lives\" id=\"hud-lives\" role=\"img\" aria-label=\"3 of 3 lives left\"></div>\n      </div>\n      <div class=\"hud__level\" id=\"hud-level\">Level 1/3 · Neon Heights</div>\n    </div>\n\n    <div class=\"level-banner\" id=\"level-banner\" aria-hidden=\"true\">\n      <div class=\"level-banner__num\" id=\"banner-num\"></div>\n      <div class=\"level-banner__name\" id=\"banner-name\"></div>\n    </div>\n\n    <div class=\"toast\" id=\"toast\" role=\"status\"></div>\n\n    <div class=\"overlay\" id=\"win-overlay\" hidden>\n      <div class=\"overlay__card\">\n        <h1 class=\"overlay__title\">All levels cleared!</h1>\n        <p class=\"overlay__sub\" id=\"win-time\"></p>\n        <p class=\"overlay__best\" id=\"win-best\"></p>\n        <button class=\"overlay__btn\" id=\"restart-btn\" type=\"button\">Run it again (R)</button>\n      </div>\n    </div>\n    <div class=\"touch-controls\" id=\"touch-controls\" aria-hidden=\"true\">\n      <div class=\"touch-controls__move\">\n        <button class=\"tc-btn\" id=\"tc-left\" type=\"button\" aria-label=\"Move left\">&larr;</button>\n        <button class=\"tc-btn\" id=\"tc-right\" type=\"button\" aria-label=\"Move right\">&rarr;</button>\n      </div>\n      <button class=\"tc-btn tc-btn--jump\" id=\"tc-jump\" type=\"button\" aria-label=\"Jump\">&uarr;</button>\n    </div>\n  </div>\n\n  <script type=\"module\" src=\"/js/game.js\"></script>\n</body>\n</html>\n"},{"name":"README.md","content":"# Neon Runner 2.5D\n\nA fast, neon-soaked 2.5D platformer built with [Three.js](https://threejs.org/). Run, jump, and wall-climb through three glowing levels, collecting every coin to unlock the portal and race the clock to the finish.\n\n## How to play\n\nThe game opens on a **start screen** showing the controls. Press the **Start** button (or any key) to begin.\n\n| Key | Action |\n| --- | --- |\n| `←` / `→` | Move left / right |\n| `Space` | Jump |\n| Hold into a wall | Wall-climb & wall-jump |\n| `R` | Restart the current level |\n\n### On mobile / touch\n\nThe game is fully playable on touch devices. Once you press **Start**, on-screen controls appear automatically: **←/→** pads on the left to move, and a large **↑** button on the right to jump (hold it for a higher jump, just like Space).\n\n## Objective\n\n- **Collect every coin** in a level to open its **portal**.\n- Touch the open portal to advance to the next level.\n- Clear all three levels as fast as you can — your run is timed and your **best time** is saved locally.\n\nYou have **3 lives** per level. Touching a spike or falling off the world costs a life; running out restarts the level. Bounce on **spring pads** to reach higher platforms.\n\n## Levels\n\n1. **Neon Heights**\n2. **Ember Works**\n3. **Void Ascent**\n\n## Project structure\n\n| File | Role |\n| --- | --- |\n| `index.html` | Page shell, HUD, start screen & win overlays |\n| `css/styles.css` | All styling (HUD, overlays, start screen) |\n| `js/game.js` | Main loop: physics, HUD, level flow, SFX |\n| `js/player.js` | Player movement, jumping & wall mechanics |\n| `js/input.js` | Keyboard input with jump buffering |\n| `js/levels.js` | Level definitions (platforms, coins, hazards, portals) |\n| `js/scene.js` | Three.js renderer, lighting & level geometry |\n| `js/particles.js` | Particle effects (dust, coins, hits) |\n| `js/audio.js` | Sound effects |\n\n## Tips\n\n- Hold a direction **into a wall** to cling and slide, then jump to kick off it.\n- A short **jump buffer** means a jump pressed just before landing still registers.\n- Collect coins efficiently — the clock is always running once you start moving.\n"}],"folders":[{"folder":"css","files":[{"name":"styles.css","content":":root {\n  color-scheme: dark;\n  --color-bg: #0b0e1a;\n  --color-text: #eef1ff;\n  --color-text-dim: #9aa3c7;\n  --color-accent: #ffd24a;\n  --color-accent-2: #5ee6c8;\n  --color-card: #141a2e;\n  --color-border: #2a3354;\n}\n\n* { margin: 0; padding: 0; box-sizing: border-box; }\n\nhtml, body {\n  height: 100%;\n  overflow: hidden;\n  background: var(--color-bg);\n  color: var(--color-text);\n  font-family: \"Segoe UI\", system-ui, -apple-system, sans-serif;\n}\n\n.game {\n  position: fixed;\n  inset: 0;\n}\n\n#game-canvas {\n  display: block;\n  width: 100%;\n  height: 100%;\n  touch-action: none;\n}\n\n/* HUD */\n.hud {\n  position: absolute;\n  top: 0;\n  left: 0;\n  right: 0;\n  display: flex;\n  align-items: flex-start;\n  justify-content: space-between;\n  gap: 16px;\n  padding: 18px 22px;\n  pointer-events: none;\n}\n\n.hud__left {\n  display: flex;\n  flex-direction: column;\n  gap: 4px;\n}\n\n.hud__coins {\n  display: flex;\n  align-items: center;\n  gap: 10px;\n  font-size: 1.5rem;\n  font-weight: 700;\n  font-variant-numeric: tabular-nums;\n  letter-spacing: 0.02em;\n  text-shadow: 0 2px 12px rgba(0, 0, 0, 0.6);\n}\n\n.hud__coin-dot {\n  width: 18px;\n  height: 18px;\n  border-radius: 50%;\n  background: radial-gradient(circle at 35% 30%, #fff3c4, var(--color-accent) 55%, #c98f00);\n  box-shadow: 0 0 14px rgba(255, 210, 74, 0.7);\n}\n\n.hud__coin-total {\n  color: var(--color-text-dim);\n  font-size: 1.1rem;\n  font-weight: 600;\n}\n\n.hud__timer {\n  font-size: 0.95rem;\n  font-weight: 600;\n  font-variant-numeric: tabular-nums;\n  color: var(--color-text-dim);\n  text-shadow: 0 2px 8px rgba(0, 0, 0, 0.6);\n  padding-left: 2px;\n}\n\n.hud__lives {\n  display: flex;\n  gap: 6px;\n  padding-left: 1px;\n  font-size: 1.25rem;\n  line-height: 1;\n}\n\n.hud__heart {\n  color: #ff5d8f;\n  text-shadow: 0 0 10px rgba(255, 93, 143, 0.8), 0 2px 8px rgba(0, 0, 0, 0.6);\n  transition: opacity 0.25s ease, transform 0.25s ease, filter 0.25s ease;\n}\n\n.hud__heart--lost {\n  opacity: 0.22;\n  filter: grayscale(1);\n  transform: scale(0.78);\n}\n\n.hud__level {\n  font-size: 0.95rem;\n  font-weight: 700;\n  letter-spacing: 0.06em;\n  text-transform: uppercase;\n  color: var(--color-text);\n  background: rgba(11, 14, 26, 0.55);\n  border: 1px solid var(--color-border);\n  border-radius: 999px;\n  padding: 8px 18px;\n  backdrop-filter: blur(6px);\n  text-shadow: 0 1px 6px rgba(0, 0, 0, 0.5);\n}\n\n.hud__hint {\n  max-width: 340px;\n  text-align: right;\n  font-size: 0.8rem;\n  line-height: 1.45;\n  color: var(--color-text-dim);\n  background: rgba(11, 14, 26, 0.55);\n  border: 1px solid var(--color-border);\n  border-radius: 14px;\n  padding: 8px 16px;\n  backdrop-filter: blur(6px);\n  transition: opacity 0.6s ease;\n}\n\n.hud__hint--faded { opacity: 0; }\n\n/* Level banner (flashes on level start) */\n.level-banner {\n  position: absolute;\n  top: 26%;\n  left: 0;\n  right: 0;\n  text-align: center;\n  pointer-events: none;\n  opacity: 0;\n  transition: opacity 0.35s ease;\n}\n\n.level-banner--show { opacity: 1; }\n\n.level-banner__num {\n  font-size: 1rem;\n  font-weight: 700;\n  letter-spacing: 0.3em;\n  text-transform: uppercase;\n  color: var(--color-accent-2);\n  text-shadow: 0 0 18px rgba(94, 230, 200, 0.8);\n  margin-bottom: 6px;\n}\n\n.level-banner__name {\n  font-size: 3rem;\n  font-weight: 800;\n  letter-spacing: 0.04em;\n  text-shadow: 0 4px 30px rgba(0, 0, 0, 0.8), 0 0 40px rgba(94, 230, 200, 0.3);\n}\n\n/* Toast (portal status messages) */\n.toast {\n  position: absolute;\n  bottom: 16%;\n  left: 50%;\n  transform: translateX(-50%);\n  font-size: 1.05rem;\n  font-weight: 700;\n  letter-spacing: 0.03em;\n  color: var(--color-text);\n  background: rgba(11, 14, 26, 0.75);\n  border: 1px solid var(--color-border);\n  border-radius: 999px;\n  padding: 10px 24px;\n  pointer-events: none;\n  opacity: 0;\n  transition: opacity 0.3s ease;\n  text-shadow: 0 2px 10px rgba(0, 0, 0, 0.6);\n  backdrop-filter: blur(6px);\n}\n\n.toast--show { opacity: 1; }\n\n/* Win overlay */\n.overlay {\n  position: absolute;\n  inset: 0;\n  display: grid;\n  place-items: center;\n  background: rgba(7, 9, 18, 0.7);\n  backdrop-filter: blur(8px);\n}\n\n.overlay[hidden] { display: none; }\n\n.overlay__card {\n  background: var(--color-card);\n  border: 1px solid var(--color-border);\n  border-radius: 20px;\n  padding: 40px 56px;\n  text-align: center;\n  box-shadow: 0 24px 60px rgba(0, 0, 0, 0.5);\n}\n\n.overlay__title {\n  font-size: 2rem;\n  margin-bottom: 8px;\n  color: var(--color-accent);\n}\n\n.overlay__sub {\n  color: var(--color-text);\n  margin-bottom: 6px;\n  font-size: 1.15rem;\n  font-weight: 600;\n}\n\n.overlay__best {\n  color: var(--color-text-dim);\n  margin-bottom: 24px;\n  font-size: 0.95rem;\n}\n\n.overlay__btn {\n  font: inherit;\n  font-weight: 700;\n  font-size: 1rem;\n  color: #08101c;\n  background: var(--color-accent-2);\n  border: none;\n  border-radius: 12px;\n  padding: 14px 28px;\n  cursor: pointer;\n  transition: transform 0.12s ease, box-shadow 0.12s ease;\n  box-shadow: 0 6px 20px rgba(94, 230, 200, 0.35);\n}\n\n.overlay__btn:hover {\n  transform: translateY(-2px);\n  box-shadow: 0 10px 26px rgba(94, 230, 200, 0.5);\n}\n\n.overlay__btn:focus-visible {\n  outline: 3px solid var(--color-text);\n  outline-offset: 3px;\n}\n\n/* Start screen */\n.overlay--start .overlay__card {\n  max-width: 440px;\n}\n\n.overlay__kicker {\n  font-size: 0.8rem;\n  font-weight: 700;\n  letter-spacing: 0.3em;\n  text-transform: uppercase;\n  color: var(--color-accent-2);\n  text-shadow: 0 0 18px rgba(94, 230, 200, 0.7);\n  margin-bottom: 6px;\n}\n\n.overlay--start .overlay__title {\n  font-size: 2.6rem;\n  letter-spacing: 0.02em;\n  margin-bottom: 10px;\n}\n\n.overlay--start .overlay__sub {\n  color: var(--color-text-dim);\n  font-weight: 500;\n  font-size: 1rem;\n  line-height: 1.5;\n  margin-bottom: 24px;\n}\n\n.controls {\n  list-style: none;\n  display: flex;\n  flex-direction: column;\n  gap: 12px;\n  margin: 0 auto 28px;\n  text-align: left;\n  max-width: 320px;\n}\n\n.controls li {\n  display: flex;\n  align-items: center;\n  gap: 12px;\n  font-size: 0.95rem;\n  color: var(--color-text);\n}\n\n.controls li span {\n  color: var(--color-text-dim);\n}\n\nkbd {\n  display: inline-block;\n  font: inherit;\n  font-size: 0.8rem;\n  font-weight: 700;\n  line-height: 1;\n  color: var(--color-text);\n  background: rgba(11, 14, 26, 0.8);\n  border: 1px solid var(--color-border);\n  border-bottom-width: 3px;\n  border-radius: 7px;\n  padding: 7px 10px;\n  min-width: 30px;\n  text-align: center;\n  box-shadow: 0 2px 6px rgba(0, 0, 0, 0.4);\n}\n\n/* On-screen touch controls (shown on touch devices while playing) */\n.touch-controls {\n  position: absolute;\n  left: 0;\n  right: 0;\n  bottom: 0;\n  display: none;\n  align-items: flex-end;\n  justify-content: space-between;\n  gap: 16px;\n  padding: 0 24px max(24px, env(safe-area-inset-bottom)) 24px;\n  pointer-events: none;\n  z-index: 5;\n}\n\n.touch-controls__move {\n  display: flex;\n  gap: 16px;\n}\n\n.tc-btn {\n  pointer-events: auto;\n  width: 70px;\n  height: 70px;\n  border-radius: 50%;\n  display: grid;\n  place-items: center;\n  font-size: 1.7rem;\n  font-weight: 700;\n  color: var(--color-text);\n  background: rgba(20, 26, 46, 0.55);\n  border: 1px solid var(--color-border);\n  backdrop-filter: blur(6px);\n  box-shadow: 0 6px 18px rgba(0, 0, 0, 0.45);\n  touch-action: none;\n  user-select: none;\n  -webkit-user-select: none;\n  -webkit-tap-highlight-color: transparent;\n  transition: transform 0.08s ease, background 0.08s ease;\n}\n\n.tc-btn:active {\n  background: rgba(94, 230, 200, 0.3);\n  transform: scale(0.92);\n}\n\n.tc-btn--jump {\n  width: 88px;\n  height: 88px;\n  font-size: 2rem;\n  color: var(--color-accent-2);\n  background: rgba(94, 230, 200, 0.16);\n  border-color: var(--color-accent-2);\n}\n\n/* Reveal touch controls only on touch devices, once play has started */\n@media (hover: none) and (pointer: coarse) {\n  body.playing .touch-controls { display: flex; }\n}\n\n/* Small-screen / phone layout */\n@media (max-width: 560px) {\n  .hud { padding: 12px 14px; gap: 10px; }\n  .hud__coins { font-size: 1.25rem; }\n  .hud__level { font-size: 0.78rem; padding: 6px 12px; }\n\n  .overlay__card { padding: 28px 22px; }\n  .overlay--start .overlay__card { max-width: calc(100vw - 28px); }\n  .overlay--start .overlay__title { font-size: 2rem; }\n  .controls { max-width: 100%; }\n  .level-banner__name { font-size: 2.1rem; }\n}\n"}],"folders":[]},{"folder":"js","files":[{"name":"audio.js","content":"// Tiny WebAudio synth for game SFX — no assets, just oscillators.\n\nlet ctx = null;\n\nexport function initAudio() {\n  if (!ctx) {\n    const AC = window.AudioContext || window.webkitAudioContext;\n    if (!AC) return;\n    ctx = new AC();\n  }\n  if (ctx.state === 'suspended') ctx.resume();\n}\n\nfunction blip({ freq = 440, end = null, dur = 0.12, type = 'square', vol = 0.1, delay = 0 }) {\n  if (!ctx || ctx.state !== 'running') return;\n  const t0 = ctx.currentTime + delay;\n  const osc = ctx.createOscillator();\n  const gain = ctx.createGain();\n  osc.type = type;\n  osc.frequency.setValueAtTime(freq, t0);\n  if (end !== null) osc.frequency.exponentialRampToValueAtTime(Math.max(30, end), t0 + dur);\n  gain.gain.setValueAtTime(vol, t0);\n  gain.gain.exponentialRampToValueAtTime(0.001, t0 + dur);\n  osc.connect(gain).connect(ctx.destination);\n  osc.start(t0);\n  osc.stop(t0 + dur + 0.02);\n}\n\nexport const sfx = {\n  jump() {\n    blip({ freq: 280, end: 460, dur: 0.12, type: 'square', vol: 0.06 });\n  },\n  wallJump() {\n    blip({ freq: 360, end: 620, dur: 0.13, type: 'square', vol: 0.06 });\n  },\n  land() {\n    blip({ freq: 140, end: 90, dur: 0.08, type: 'triangle', vol: 0.07 });\n  },\n  coin(n = 0) {\n    const base = 740 + (n % 8) * 55; // pitch climbs with your streak\n    blip({ freq: base, dur: 0.07, type: 'sine', vol: 0.09 });\n    blip({ freq: base * 1.5, dur: 0.12, type: 'sine', vol: 0.08, delay: 0.06 });\n  },\n  spring() {\n    blip({ freq: 200, end: 900, dur: 0.25, type: 'sawtooth', vol: 0.07 });\n  },\n  death() {\n    blip({ freq: 320, end: 60, dur: 0.35, type: 'sawtooth', vol: 0.09 });\n  },\n  portal() {\n    blip({ freq: 392, dur: 0.4, type: 'sine', vol: 0.08 });\n    blip({ freq: 523, dur: 0.4, type: 'sine', vol: 0.08, delay: 0.09 });\n    blip({ freq: 659, dur: 0.5, type: 'sine', vol: 0.08, delay: 0.18 });\n    blip({ freq: 784, dur: 0.6, type: 'sine', vol: 0.07, delay: 0.27 });\n  },\n  win() {\n    [523, 659, 784, 1047].forEach((f, i) =>\n      blip({ freq: f, dur: 0.5, type: 'triangle', vol: 0.08, delay: i * 0.12 }));\n  },\n};\n"},{"name":"game.js","content":"// Main loop: level manager + physics + rendering + HUD + SFX.\n\nimport { Input } from './input.js';\nimport { Player, PLAYER_H, PLAYER_W } from './player.js';\nimport { ParticleSystem } from './particles.js';\nimport { createRenderer, buildLevel, GAME_Z } from './scene.js';\nimport { LEVELS } from './levels.js';\nimport { initAudio, sfx } from './audio.js';\n\nconst canvas = document.getElementById('game-canvas');\nconst gfx = createRenderer(canvas);\nconst input = new Input();\n\n// HUD elements\nconst coinCountEl = document.getElementById('coin-count');\nconst coinTotalEl = document.getElementById('coin-total');\nconst timerEl = document.getElementById('hud-timer');\nconst levelEl = document.getElementById('hud-level');\nconst hintEl = document.getElementById('hud-hint');\nconst banner = document.getElementById('level-banner');\nconst bannerNum = document.getElementById('banner-num');\nconst bannerName = document.getElementById('banner-name');\nconst toastEl = document.getElementById('toast');\nconst winOverlay = document.getElementById('win-overlay');\nconst startOverlay = document.getElementById('start-overlay');\nconst startBtn = document.getElementById('start-btn');\n\nlet toastTimer = 0;\nfunction showToast(msg) {\n  toastEl.textContent = msg;\n  toastEl.classList.add('toast--show');\n  toastTimer = 2.2;\n}\nconst winTimeEl = document.getElementById('win-time');\nconst winBestEl = document.getElementById('win-best');\nconst restartBtn = document.getElementById('restart-btn');\n\nwindow.addEventListener('keydown', initAudio);\nwindow.addEventListener('pointerdown', initAudio);\n\n// ------------------------------------------------------------ state\nlet levelIndex = 0;\nlet world = null;\nlet player = null;\nlet particles = null;\nlet collected = 0;\nlet elapsed = 0;       // total run time across levels\nlet levelTime = 0;     // time inside current level (drives mover phase)\nlet runStarted = false;\nlet started = false;   // false until the player dismisses the start screen\nlet won = false;\nlet bannerTimer = 0;\n\nconst MAX_LIVES = 3;\nlet lives = MAX_LIVES;\nlet invuln = 0; // post-hit grace period (seconds); player flashes while > 0\n\nconst livesEl = document.getElementById('hud-lives');\nfunction updateLivesHUD() {\n  livesEl.setAttribute('aria-label', `${lives} of ${MAX_LIVES} lives left`);\n  livesEl.innerHTML = '';\n  for (let i = 0; i < MAX_LIVES; i++) {\n    const h = document.createElement('span');\n    h.className = 'hud__heart' + (i < lives ? '' : ' hud__heart--lost');\n    h.setAttribute('aria-hidden', 'true');\n    h.textContent = '♥';\n    livesEl.appendChild(h);\n  }\n}\n\nconst cam = { x: 0, y: 0, lookAhead: 0, shake: 0 };\n\nfunction loadLevel(i) {\n  levelIndex = i;\n  const def = LEVELS[i];\n  world = buildLevel(def);\n  gfx.renderPass.scene = world.scene;\n  particles = new ParticleSystem(world.scene);\n  player = new Player(def.spawn);\n  collected = 0;\n  levelTime = 0;\n  lives = MAX_LIVES;\n  invuln = 0;\n  updateLivesHUD();\n  coinCountEl.textContent = '0';\n  coinTotalEl.textContent = `/${world.coins.length}`;\n  levelEl.textContent = `Level ${i + 1}/${LEVELS.length} · ${def.name}`;\n  cam.x = def.spawn.x;\n  cam.y = def.spawn.y + 3;\n  cam.lookAhead = 0;\n  // banner flash\n  bannerNum.textContent = `Level ${i + 1} of ${LEVELS.length}`;\n  bannerName.textContent = def.name;\n  if (started) showBanner();\n}\n\nfunction showBanner() {\n  banner.classList.add('level-banner--show');\n  bannerTimer = 2;\n}\n\nfunction fullRestart() {\n  won = false;\n  runStarted = false;\n  elapsed = 0;\n  winOverlay.hidden = true;\n  loadLevel(0);\n}\n\nrestartBtn.addEventListener('click', fullRestart);\n\nfunction startGame() {\n  if (started) return;\n  started = true;\n  initAudio();\n  startOverlay.hidden = true;\n  document.body.classList.add('playing'); // reveals on-screen touch controls\n  last = performance.now(); // avoid a huge first-frame dt while the screen was up\n  showBanner(); // flash the level intro now that play has begun\n}\n\nstartBtn.addEventListener('click', startGame);\n// Any key (other than restart) also starts the game from the start screen.\nwindow.addEventListener('keydown', (e) => {\n  if (!started && e.code !== 'KeyR') startGame();\n});\n\nfunction loseLife({ respawn = false, dir = 0 } = {}) {\n  lives--;\n  updateLivesHUD();\n  sfx.death();\n  particles.spawn(player.x, player.y + 0.6, GAME_Z, {\n    count: 16, speed: 5, upBias: 1.3, life: 0.7, color: [1, 0.42, 0.62],\n  });\n  cam.shake = Math.max(cam.shake, 0.16);\n\n  if (lives <= 0) {\n    showToast('Out of lives!');\n    loadLevel(levelIndex); // restart this level (loadLevel refills lives)\n    return;\n  }\n\n  if (respawn) {\n    // fell out of the world — has to go back to the level start, but keeps lives\n    player.respawn();\n  } else {\n    // hit a hazard in place: bounce away and keep playing from right here\n    player.vy = 13;\n    player.vx = dir !== 0 ? dir * 9 : -player.facing * 8;\n    player.onGround = false;\n  }\n  invuln = 1.6;\n}\n\nfunction collectCoins() {\n  for (const c of world.coins) {\n    if (c.collected) continue;\n    const dx = player.x - c.x;\n    const dy = (player.y + PLAYER_H / 2) - c.y;\n    if (dx * dx + dy * dy < 1.1) {\n      c.collected = true;\n      c.mesh.visible = false;\n      collected++;\n      coinCountEl.textContent = String(collected);\n      sfx.coin(collected);\n      particles.spawn(c.x, c.y, GAME_Z, {\n        count: 12, speed: 4.5, upBias: 1.2, life: 0.6, color: [1.0, 0.82, 0.29],\n      });\n      cam.shake = Math.max(cam.shake, 0.08);\n      if (collected === world.coins.length) openPortal();\n    }\n  }\n}\n\nfunction openPortal() {\n  const p = world.portal;\n  p.active = true;\n  p.ringMat.color.set(p.themeColor);\n  p.ringMat.emissive.set(p.themeColor);\n  p.innerMat.color.set(p.themeColor);\n  sfx.portal();\n  showToast('Portal open!');\n  particles.spawn(p.x, p.y, GAME_Z, {\n    count: 20, speed: 5, upBias: 1, life: 0.8,\n    color: [0.6, 0.9, 1],\n  });\n}\n\nlet lockedToastCooldown = 0;\n\nfunction checkPortal(dt) {\n  lockedToastCooldown -= dt;\n  const p = world.portal;\n  const dx = player.x - p.x;\n  const dy = (player.y + PLAYER_H / 2) - p.y;\n  const d2 = dx * dx + dy * dy;\n\n  if (p.active) {\n    // generous trigger: roughly the visual size of the ring + player body\n    if (d2 < 5.5) {\n      if (levelIndex + 1 < LEVELS.length) {\n        sfx.win();\n        loadLevel(levelIndex + 1);\n      } else {\n        finishRun();\n      }\n    }\n  } else if (d2 < 12 && lockedToastCooldown <= 0) {\n    const missing = world.coins.length - collected;\n    showToast(`Portal locked — ${missing} coin${missing === 1 ? '' : 's'} left`);\n    lockedToastCooldown = 2.5;\n  }\n}\n\nfunction finishRun() {\n  won = true;\n  sfx.win();\n  winTimeEl.textContent = `Cleared all ${LEVELS.length} levels in ${elapsed.toFixed(1)}s`;\n  const best = Number(localStorage.getItem('neonRunnerBest') || 0);\n  if (!best || elapsed < best) {\n    localStorage.setItem('neonRunnerBest', elapsed.toFixed(1));\n    winBestEl.textContent = best ? `New best! (previous: ${best.toFixed(1)}s)` : 'New best time!';\n  } else {\n    winBestEl.textContent = `Best: ${best.toFixed(1)}s`;\n  }\n  winOverlay.hidden = false;\n  restartBtn.focus();\n}\n\nfunction updateMovers() {\n  for (const p of world.platforms) {\n    if (!p.move) { p.dx = 0; p.dy = 0; continue; }\n    const { ax = 0, ay = 0, period, phase = 0 } = p.move;\n    const s = Math.sin((levelTime * Math.PI * 2) / period + phase);\n    const nx = p.baseX + s * ax;\n    const ny = p.baseY + s * ay;\n    p.dx = nx - p.x;\n    p.dy = ny - p.y;\n    p.x = nx;\n    p.y = ny;\n    p.place();\n  }\n}\n\nfunction checkSprings() {\n  for (const s of world.springs) {\n    s.bounceT += 1 / 60;\n    // animate pad: quick squash then recover\n    const k = Math.max(0, 0.5 - s.bounceT * 2.2);\n    s.pad.scale.y = 1 + k * 2.4;\n    if (player.vy <= 0 &&\n        Math.abs(player.x - s.x) < s.w / 2 + PLAYER_W / 2 - 0.1 &&\n        player.y >= s.y - 0.3 && player.y <= s.y + 0.55) {\n      player.vy = s.power;\n      player.onGround = false;\n      player.scaleX = 0.55;\n      player.scaleY = 1.6;\n      s.bounceT = 0;\n      sfx.spring();\n      particles.spawn(s.x, s.y + 0.3, GAME_Z, {\n        count: 10, speed: 3.5, upBias: 1.4, life: 0.5, color: [0.6, 1, 0.9],\n      });\n      cam.shake = Math.max(cam.shake, 0.07);\n    }\n  }\n}\n\nfunction checkSpikes() {\n  if (invuln > 0) return; // flashing grace period — can't be hurt\n  const hw = PLAYER_W / 2 - 0.12; // forgiving hitbox\n  for (const z of world.spikes) {\n    if (player.x + hw > z.x && player.x - hw < z.x + z.w &&\n        player.y < z.y + 0.5 && player.y + PLAYER_H > z.y) {\n      loseLife({ dir: Math.sign(player.x - (z.x + z.w / 2)) || -player.facing });\n      return;\n    }\n  }\n}\n\nfunction updateCamera(dt) {\n  const targetLook = player.facing * Math.min(4, Math.abs(player.vx) * 0.35);\n  cam.lookAhead += (targetLook - cam.lookAhead) * Math.min(1, dt * 3.5);\n\n  const targetX = player.x + cam.lookAhead;\n  const targetY = player.y + 3.2;\n\n  cam.x += (targetX - cam.x) * Math.min(1, dt * 9);\n  cam.y += (targetY - cam.y) * Math.min(1, dt * (player.onGround ? 7 : 4.5));\n\n  cam.x = Math.max(world.bounds.minX + 8, Math.min(world.bounds.maxX - 8, cam.x));\n  cam.y = Math.max(2.5, cam.y);\n\n  cam.shake = Math.max(0, cam.shake - dt * 0.6);\n  const sx = cam.shake * Math.sin(elapsed * 91);\n  const sy = cam.shake * Math.sin(elapsed * 113 + 2);\n\n  gfx.camera.position.set(cam.x + sx, cam.y + sy, 17);\n  gfx.camera.lookAt(cam.x + sx, cam.y - 0.5 + sy, GAME_Z);\n\n  world.sun.position.set(cam.x + 10, cam.y + 18, 14);\n  world.sun.target.position.set(cam.x, cam.y, 0);\n\n  for (const layer of world.parallaxLayers) {\n    layer.group.position.x = cam.x * (1 - layer.factor);\n    layer.group.position.y = cam.y * 0.12 * (1 - layer.factor);\n  }\n}\n\nfunction updateVisuals(dt) {\n  // hurt flash: blink the character while invulnerable\n  world.playerGroup.visible = invuln <= 0 || Math.floor(invuln * 12) % 2 === 0;\n  world.playerGroup.position.set(player.x, player.y, GAME_Z);\n  world.playerBody.scale.set(player.scaleX, player.scaleY, 1);\n  world.playerBody.position.y = (PLAYER_H * player.scaleY) / 2;\n  world.eyes.position.y = PLAYER_H * (player.scaleY - 1) * 0.72;\n  const targetRotY = player.facing === 1 ? 0.25 : -0.25;\n  world.playerGroup.rotation.y += (targetRotY - world.playerGroup.rotation.y) * Math.min(1, dt * 10);\n  const targetRotZ = -player.vx * 0.012;\n  world.playerGroup.rotation.z += (targetRotZ - world.playerGroup.rotation.z) * Math.min(1, dt * 8);\n\n  for (const c of world.coins) {\n    if (c.collected) continue;\n    c.mesh.rotation.y = levelTime * 2.4 + c.phase;\n    c.mesh.position.y = c.y + Math.sin(levelTime * 2 + c.phase) * 0.15;\n  }\n\n  // portal: idle shimmer when locked, bright spin when open\n  const p = world.portal;\n  p.group.rotation.z = levelTime * (p.active ? 1.4 : 0.25);\n  if (p.active) {\n    p.ringMat.emissiveIntensity = 1.6 + Math.sin(levelTime * 6) * 0.5;\n    p.innerMat.opacity = 0.3 + Math.sin(levelTime * 4) * 0.1;\n    p.light.intensity = 2.4;\n    if (Math.floor(levelTime * 8) !== Math.floor((levelTime - dt) * 8)) {\n      particles.spawn(p.x, p.y - 1.2, GAME_Z, {\n        count: 1, speed: 1.6, upBias: 2.2, life: 0.9, color: [0.7, 0.95, 1],\n      });\n    }\n  }\n}\n\nfunction emitMovementDust(dt) {\n  if (player.onGround && Math.abs(player.vx) > 6) {\n    dustAccum += dt;\n    if (dustAccum > 0.06) {\n      dustAccum = 0;\n      particles.spawn(player.x - player.facing * 0.4, player.y + 0.05, GAME_Z, {\n        count: 2, speed: 1.6, upBias: 0.9, life: 0.4,\n      });\n    }\n  }\n  if (player.justLanded) {\n    sfx.land();\n    particles.spawn(player.x, player.y + 0.05, GAME_Z, {\n      count: 10, speed: 3.2, spreadX: 1.4, upBias: 0.7, life: 0.5,\n    });\n    cam.shake = Math.max(cam.shake, 0.06);\n  }\n  if (player.justJumped) {\n    sfx.jump();\n    particles.spawn(player.x, player.y + 0.05, GAME_Z, { count: 6, speed: 2.4, upBias: 0.5, life: 0.4 });\n  }\n  if (player.justWallJumped) {\n    sfx.wallJump();\n    particles.spawn(player.x + player.facing * -0.5, player.y + PLAYER_H * 0.5, GAME_Z, {\n      count: 9, speed: 3.5, upBias: 1.1, life: 0.5, color: [0.55, 0.9, 0.8],\n    });\n    cam.shake = Math.max(cam.shake, 0.05);\n  }\n}\n\n// ------------------------------------------------------------ main loop\nlet dustAccum = 0;\nlet last = performance.now();\n\nloadLevel(0);\n\n// debug/QA hook: level select, teleport, collect-all — usable from the console\nwindow.__nrDebug = {\n  goto: (i) => loadLevel(Math.max(0, Math.min(LEVELS.length - 1, i))),\n  tp: (x, y) => { player.x = x; player.y = y; player.vx = 0; player.vy = 0; },\n  pos: () => ({ x: Math.round(player.x * 10) / 10, y: Math.round(player.y * 10) / 10 }),\n  coinsLeft: () => world.coins.filter((c) => !c.collected).map((c) => `(${c.x},${c.y})`).join(' '),\n  grabAll: () => {\n    for (const c of world.coins) {\n      if (!c.collected) { c.collected = true; c.mesh.visible = false; collected++; }\n    }\n    coinCountEl.textContent = String(collected);\n    if (collected === world.coins.length) openPortal();\n  },\n};\n\nfunction frame(now) {\n  requestAnimationFrame(frame);\n  let dt = (now - last) / 1000;\n  last = now;\n  dt = Math.min(dt, 1 / 30);\n\n  input.now = now / 1000;\n\n  if (input.consumeRestart()) {\n    if (won) fullRestart();\n    else loadLevel(levelIndex); // restart current level\n  }\n\n  if (started && !runStarted && input.anyKeyPressed) runStarted = true;\n  if (runStarted && !won) {\n    elapsed += dt;\n    timerEl.textContent = `${elapsed.toFixed(1)}s`;\n  }\n  if (started) levelTime += dt;\n\n  if (bannerTimer > 0) {\n    bannerTimer -= dt;\n    if (bannerTimer <= 0) banner.classList.remove('level-banner--show');\n  }\n\n  if (runStarted && hintEl && !hintEl.classList.contains('hud__hint--faded') && elapsed > 7) {\n    hintEl.classList.add('hud__hint--faded');\n  }\n\n  if (started && !won) {\n    updateMovers();\n    player.update(dt, input, world.platforms);\n    if (player.y < LEVELS[levelIndex].killY) loseLife({ respawn: true });\n    invuln = Math.max(0, invuln - dt);\n    checkSpikes();\n    checkSprings();\n    collectCoins();\n    checkPortal(dt);\n    emitMovementDust(dt);\n  }\n\n  if (toastTimer > 0) {\n    toastTimer -= dt;\n    if (toastTimer <= 0) toastEl.classList.remove('toast--show');\n  }\n\n  particles.update(dt);\n  updateVisuals(dt);\n  updateCamera(dt);\n\n  gfx.composer.render();\n}\n\nrequestAnimationFrame(frame);\n"},{"name":"input.js","content":"// Keyboard input with jump buffering support.\n\nconst LEFT_KEYS = new Set(['ArrowLeft', 'KeyA']);\nconst RIGHT_KEYS = new Set(['ArrowRight', 'KeyD']);\nconst JUMP_KEYS = new Set(['Space', 'ArrowUp', 'KeyW']);\n\nexport class Input {\n  constructor() {\n    this.left = false;\n    this.right = false;\n    this.jumpHeld = false;\n    this.jumpPressedAt = -Infinity; // timestamp (s) of last jump press, for buffering\n    this.restartRequested = false;\n    this.anyKeyPressed = false;\n    this.now = 0;\n\n    window.addEventListener('keydown', (e) => this.onKeyDown(e));\n    window.addEventListener('keyup', (e) => this.onKeyUp(e));\n    window.addEventListener('blur', () => this.reset());\n\n    this.bindTouch();\n  }\n\n  // On-screen buttons for touch devices — mirror the keyboard controls.\n  bindTouch() {\n    const hold = (id, onDown, onUp) => {\n      const el = document.getElementById(id);\n      if (!el) return;\n      const down = (e) => { e.preventDefault(); onDown(); };\n      const up = (e) => { e.preventDefault(); onUp(); };\n      el.addEventListener('pointerdown', down);\n      el.addEventListener('pointerup', up);\n      el.addEventListener('pointercancel', up);\n      el.addEventListener('pointerleave', up);\n      el.addEventListener('contextmenu', (e) => e.preventDefault());\n    };\n\n    hold('tc-left',\n      () => { this.left = true; this.anyKeyPressed = true; },\n      () => { this.left = false; });\n    hold('tc-right',\n      () => { this.right = true; this.anyKeyPressed = true; },\n      () => { this.right = false; });\n    hold('tc-jump',\n      () => { this.jumpPressedAt = this.now; this.jumpHeld = true; this.anyKeyPressed = true; },\n      () => { this.jumpHeld = false; });\n  }\n\n  onKeyDown(e) {\n    if (LEFT_KEYS.has(e.code)) { this.left = true; this.anyKeyPressed = true; }\n    if (RIGHT_KEYS.has(e.code)) { this.right = true; this.anyKeyPressed = true; }\n    if (JUMP_KEYS.has(e.code)) {\n      e.preventDefault();\n      if (!e.repeat) {\n        this.jumpPressedAt = this.now;\n        this.anyKeyPressed = true;\n      }\n      this.jumpHeld = true;\n    }\n    if (e.code === 'KeyR') this.restartRequested = true;\n  }\n\n  onKeyUp(e) {\n    if (LEFT_KEYS.has(e.code)) this.left = false;\n    if (RIGHT_KEYS.has(e.code)) this.right = false;\n    if (JUMP_KEYS.has(e.code)) this.jumpHeld = false;\n  }\n\n  reset() {\n    this.left = false;\n    this.right = false;\n    this.jumpHeld = false;\n  }\n\n  get axis() {\n    return (this.right ? 1 : 0) - (this.left ? 1 : 0);\n  }\n\n  /** True if jump was pressed within the last `window` seconds (jump buffer). */\n  jumpBuffered(windowSec = 0.12) {\n    return this.now - this.jumpPressedAt <= windowSec;\n  }\n\n  consumeJump() {\n    this.jumpPressedAt = -Infinity;\n  }\n\n  consumeRestart() {\n    const r = this.restartRequested;\n    this.restartRequested = false;\n    return r;\n  }\n}\n"},{"name":"levels.js","content":"// Level definitions. Coordinates: x = left, y = bottom, w, h — Y grows upward.\n//\n// platforms: solid boxes. `wall: true` = tall climbable piece (visual only).\n//            `move: {ax, ay, period, phase?}` = sinusoidal motion (± amplitude).\n// springs:   {x: center, y: top-of-platform, w, power} — bounce pads.\n// spikes:    {x: left, y: top-of-platform, w} — deadly zones.\n// portal:    {x, y center} — opens when every coin is collected.\n\nexport const LEVELS = [\n  // ============================================================= LEVEL 1\n  {\n    name: 'Neon Heights',\n    spawn: { x: 2, y: 3 },\n    killY: -14,\n    theme: {\n      bg: '#0b0e1a',\n      ambient: '#5a6aa8', ambientI: 0.55,\n      sun: '#ffe9c4', sunI: 1.6,\n      rim: '#5ee6c8',\n      plat: '#3b4a7a', lip: '#5ee6c8', lipGlow: '#1c8a6e',\n      wall: '#7a5ee6', wallGlow: '#2a1a66',\n      parallax: ['#1d2542', '#151c33', '#101527'],\n      windows: '#3e4f8f', stars: '#8fa3e8',\n      spring: '#5ee6c8', spike: '#e65e6b', portal: '#5ee6c8',\n    },\n    platforms: [\n      { x: -6, y: 0, w: 18, h: 2 },\n      { x: 15, y: 1, w: 5, h: 1.2 },\n      { x: 23, y: 2.5, w: 5, h: 1.2 },\n      { x: 31, y: 4, w: 6, h: 1.2 },\n      { x: 42, y: 0, w: 1.6, h: 14, wall: true },\n      { x: 48.5, y: 2, w: 1.6, h: 14, wall: true },\n      { x: 43.6, y: 0, w: 4.9, h: 1.2 },\n      { x: 50.1, y: 13, w: 6, h: 1.2 },\n      { x: 59, y: 12, w: 4.5, h: 1.1 },\n      { x: 66.5, y: 10.5, w: 4.5, h: 1.1, move: { ax: 0, ay: 1.4, period: 3.2 } },\n      { x: 74, y: 9, w: 5, h: 1.1 },\n      { x: 84, y: 0, w: 8, h: 1.5 },\n      { x: 85, y: 5, w: 1.6, h: 10, wall: true },\n      { x: 92, y: 3, w: 5, h: 1.2 },\n      { x: 100, y: 4.5, w: 4.5, h: 1.2 },\n      { x: 107, y: 6, w: 4.5, h: 1.2 },\n      { x: 114, y: 7.5, w: 9, h: 1.4 },\n    ],\n    springs: [{ x: 89.5, y: 1.5, w: 1.8, power: 26 }],\n    spikes: [],\n    portal: { x: 120, y: 10.7 },\n    coins: [\n      { x: 6, y: 3.2 }, { x: 10, y: 3.2 },\n      { x: 17.5, y: 3.4 }, { x: 25.5, y: 4.9 }, { x: 34, y: 6.4 },\n      { x: 46, y: 4 }, { x: 45, y: 7 }, { x: 46, y: 10 }, { x: 45, y: 13 },\n      { x: 53, y: 15.4 },\n      { x: 61.2, y: 14.4 }, { x: 68.7, y: 12.9 }, { x: 76.5, y: 11.4 },\n      { x: 88, y: 3 }, { x: 94.5, y: 5.4 },\n      { x: 102.2, y: 6.9 }, { x: 109.2, y: 8.4 }, { x: 118.5, y: 10 },\n    ],\n  },\n\n  // ============================================================= LEVEL 2\n  {\n    name: 'Ember Works',\n    spawn: { x: 0, y: 3 },\n    killY: -12,\n    theme: {\n      bg: '#1c0f1d',\n      ambient: '#8a5a72', ambientI: 0.6,\n      sun: '#ffb36b', sunI: 1.8,\n      rim: '#ff5e8a',\n      plat: '#5a3550', lip: '#ffb36b', lipGlow: '#8a4d11',\n      wall: '#e65e8a', wallGlow: '#5c1430',\n      parallax: ['#33182f', '#271226', '#1b0d1c'],\n      windows: '#8f4f3e', stars: '#e8b48f',\n      spring: '#ffd24a', spike: '#ff4a4a', portal: '#ffb36b',\n    },\n    platforms: [\n      { x: -6, y: 0, w: 14, h: 2 },\n      { x: 13.5, y: 2.2, w: 3.6, h: 1, move: { ax: 3, ay: 0, period: 3.4 } },\n      { x: 21.5, y: 3.2, w: 5.5, h: 1.2 },\n      { x: 28.5, y: 11, w: 4.5, h: 1.2 },\n      { x: 35.5, y: 7, w: 3.4, h: 1, move: { ax: 0, ay: 3, period: 3.8 } },\n      { x: 41.5, y: 11.5, w: 5, h: 1.2 },\n      { x: 48.5, y: 8, w: 1.6, h: 11, wall: true },\n      { x: 54.5, y: 10, w: 1.6, h: 11, wall: true },\n      { x: 50.1, y: 8, w: 4.4, h: 1 },\n      { x: 56.1, y: 19.5, w: 5.5, h: 1.2 },\n      { x: 64, y: 3, w: 22, h: 1.6 },\n      { x: 88, y: 11, w: 6.5, h: 1.3 },\n    ],\n    springs: [\n      { x: 25.2, y: 4.4, w: 1.8, power: 32 },\n      { x: 83.5, y: 4.6, w: 1.8, power: 32 },\n    ],\n    spikes: [\n      { x: 69, y: 4.6, w: 3 },\n      { x: 76, y: 4.6, w: 3 },\n    ],\n    portal: { x: 91.5, y: 13.9 },\n    coins: [\n      { x: 3, y: 3.5 }, { x: 8, y: 3.5 },\n      { x: 15.3, y: 4.7 },\n      { x: 24, y: 5.9 },\n      { x: 25.2, y: 8.5 }, { x: 25.2, y: 11.5 },\n      { x: 30.7, y: 13.7 },\n      { x: 37.2, y: 12 },\n      { x: 44, y: 14.2 },\n      { x: 52.3, y: 11 }, { x: 52.9, y: 14 }, { x: 52.3, y: 17 }, { x: 52.9, y: 20 },\n      { x: 58.8, y: 22.2 },\n      { x: 66.5, y: 6.1 }, { x: 70.5, y: 7.4 }, { x: 77.5, y: 7.4 },\n      { x: 83.5, y: 8.5 }, { x: 83.5, y: 11.5 },\n      { x: 89.8, y: 13.6 },\n    ],\n  },\n\n  // ============================================================= LEVEL 3\n  {\n    name: 'Void Ascent',\n    spawn: { x: 0, y: 3 },\n    killY: -10,\n    theme: {\n      bg: '#070612',\n      ambient: '#4a3a8a', ambientI: 0.6,\n      sun: '#c9bfff', sunI: 1.4,\n      rim: '#5e9be6',\n      plat: '#272144', lip: '#9d6bff', lipGlow: '#3a1a8a',\n      wall: '#3a8ae6', wallGlow: '#102a55',\n      parallax: ['#171336', '#110e28', '#0c0a1d'],\n      windows: '#4a3e8f', stars: '#a89fe8',\n      spring: '#5ee6ff', spike: '#ff4a6b', portal: '#9d6bff',\n    },\n    platforms: [\n      { x: -4, y: 0, w: 13, h: 2 },\n      { x: 9.5, y: 1, w: 1.6, h: 9, wall: true },\n      { x: 3.5, y: 9.5, w: 4.5, h: 1 },\n      { x: -3.5, y: 12.8, w: 4.5, h: 1 },\n      { x: -7.5, y: 14, w: 1.6, h: 12, wall: true },\n      { x: -0.5, y: 16, w: 1.6, h: 11, wall: true },\n      { x: 1.1, y: 25.5, w: 4.5, h: 1 },\n      { x: 8, y: 27, w: 3, h: 0.9, move: { ax: 0, ay: 2.5, period: 3.2 } },\n      { x: 13, y: 30, w: 7.5, h: 1.3 },\n    ],\n    springs: [],\n    spikes: [{ x: 4.2, y: 2, w: 3.2 }],\n    portal: { x: 18, y: 32.9 },\n    coins: [\n      { x: -2, y: 3.5 },\n      { x: 5.8, y: 4.8 },\n      { x: 8.8, y: 5 }, { x: 8.8, y: 8 },\n      { x: 5.7, y: 12 },\n      { x: -1.5, y: 15.3 },\n      { x: -3, y: 18 }, { x: -2.4, y: 21 }, { x: -3, y: 24 },\n      { x: 3.3, y: 28 },\n      { x: 9.5, y: 31 },\n      { x: 15.5, y: 32.6 },\n    ],\n  },\n];\n"},{"name":"main.js","content":""},{"name":"particles.js","content":"// Pooled dust particles rendered as a single THREE.Points cloud.\n\nimport * as THREE from 'three';\n\nconst MAX_PARTICLES = 220;\n\nexport class ParticleSystem {\n  constructor(scene) {\n    this.positions = new Float32Array(MAX_PARTICLES * 3);\n    this.velocities = new Float32Array(MAX_PARTICLES * 3);\n    this.life = new Float32Array(MAX_PARTICLES);     // remaining life (s)\n    this.maxLife = new Float32Array(MAX_PARTICLES);\n    this.sizes = new Float32Array(MAX_PARTICLES);\n    this.colors = new Float32Array(MAX_PARTICLES * 3);\n    this.cursor = 0;\n\n    this.geometry = new THREE.BufferGeometry();\n    this.geometry.setAttribute('position', new THREE.BufferAttribute(this.positions, 3));\n    this.geometry.setAttribute('color', new THREE.BufferAttribute(this.colors, 3));\n\n    this.material = new THREE.PointsMaterial({\n      size: 0.28,\n      vertexColors: true,\n      transparent: true,\n      opacity: 0.9,\n      depthWrite: false,\n      sizeAttenuation: true,\n    });\n\n    this.points = new THREE.Points(this.geometry, this.material);\n    this.points.frustumCulled = false;\n    scene.add(this.points);\n\n    // park all particles far away\n    for (let i = 0; i < MAX_PARTICLES; i++) {\n      this.positions[i * 3 + 1] = -9999;\n    }\n  }\n\n  spawn(x, y, z, opts = {}) {\n    const {\n      count = 6,\n      speed = 3,\n      spreadX = 1,\n      upBias = 1.5,\n      color = [0.75, 0.78, 0.9],\n      life = 0.5,\n    } = opts;\n\n    for (let n = 0; n < count; n++) {\n      const i = this.cursor;\n      this.cursor = (this.cursor + 1) % MAX_PARTICLES;\n\n      this.positions[i * 3] = x + (Math.sin(i * 12.9898) * 0.5) * spreadX;\n      this.positions[i * 3 + 1] = y;\n      this.positions[i * 3 + 2] = z + Math.sin(i * 4.7) * 0.3;\n\n      const a = (i * 2.399963) % (Math.PI * 2); // golden-angle scatter\n      this.velocities[i * 3] = Math.cos(a) * speed * (0.4 + ((i * 7) % 10) / 10 * 0.6);\n      this.velocities[i * 3 + 1] = Math.abs(Math.sin(a)) * speed * upBias * (0.3 + ((i * 3) % 10) / 10 * 0.7);\n      this.velocities[i * 3 + 2] = Math.sin(a * 1.7) * speed * 0.3;\n\n      const l = life * (0.6 + ((i * 13) % 10) / 10 * 0.8);\n      this.life[i] = l;\n      this.maxLife[i] = l;\n\n      const tint = 0.85 + ((i * 5) % 10) / 10 * 0.15;\n      this.colors[i * 3] = color[0] * tint;\n      this.colors[i * 3 + 1] = color[1] * tint;\n      this.colors[i * 3 + 2] = color[2] * tint;\n    }\n  }\n\n  update(dt) {\n    for (let i = 0; i < MAX_PARTICLES; i++) {\n      if (this.life[i] <= 0) continue;\n      this.life[i] -= dt;\n      if (this.life[i] <= 0) {\n        this.positions[i * 3 + 1] = -9999;\n        continue;\n      }\n      this.velocities[i * 3 + 1] -= 9 * dt;  // light gravity\n      this.velocities[i * 3] *= 1 - 2.5 * dt;\n      this.positions[i * 3] += this.velocities[i * 3] * dt;\n      this.positions[i * 3 + 1] += this.velocities[i * 3 + 1] * dt;\n      this.positions[i * 3 + 2] += this.velocities[i * 3 + 2] * dt;\n\n      const fade = this.life[i] / this.maxLife[i];\n      this.colors[i * 3] *= 0.5 + fade * 0.5 > 1 ? 1 : 1; // keep color, fade via attribute below\n    }\n    this.geometry.attributes.position.needsUpdate = true;\n    this.geometry.attributes.color.needsUpdate = true;\n  }\n}\n"},{"name":"player.js","content":"// Player physics: run / jump / wall-jump & wall-climb, coyote time, jump\n// buffering, variable jump height, squash-and-stretch, moving-platform riding.\n// Platforms are passed in each frame (runtime objects with current x/y).\n\nconst MOVE_SPEED = 11;        // max horizontal speed\nconst GROUND_ACCEL = 90;\nconst AIR_ACCEL = 55;\nconst GROUND_FRICTION = 70;\nconst GRAVITY = -52;\nconst FALL_GRAVITY = -72;     // heavier when falling = snappy arc\nconst JUMP_VEL = 19.5;\nconst JUMP_CUT = 0.45;        // velocity multiplier when jump released early\nconst MAX_FALL = -34;\nconst COYOTE_TIME = 0.1;\nconst JUMP_BUFFER = 0.12;\nconst WALL_SLIDE_SPEED = -5;\nconst WALL_JUMP_VX = 11;      // kick-away jump (jumping off, away from the wall)\nconst WALL_JUMP_VY = 19;\nconst CLIMB_JUMP_VX = 5.5;    // climb jump (holding INTO the wall) — small push, big height\nconst CLIMB_JUMP_VY = 21;\nconst WALL_STICK_TIME = 0.14; // lockout of input control after kick-away wall jump\nconst CLIMB_STICK_TIME = 0.06; // much shorter lock when climbing, so you drift back to the wall\n\nexport const PLAYER_W = 0.9;\nexport const PLAYER_H = 1.3;\n\nexport class Player {\n  constructor(spawn) {\n    this.spawn = spawn;\n    this.respawn();\n  }\n\n  respawn(spawn) {\n    if (spawn) this.spawn = spawn;\n    this.x = this.spawn.x;\n    this.y = this.spawn.y;\n    this.vx = 0;\n    this.vy = 0;\n    this.onGround = false;\n    this.groundPlat = null;    // runtime platform we're standing on (for riding movers)\n    this.wallDir = 0;          // -1 wall on left, 1 wall on right, 0 none\n    this.coyoteTimer = 0;\n    this.wallCoyoteTimer = 0;\n    this.wallCoyoteDir = 0;\n    this.controlLock = 0;\n    this.facing = 1;\n    this.scaleX = 1;           // squash-and-stretch (1 = neutral)\n    this.scaleY = 1;\n    this.justLanded = false;\n    this.justJumped = false;\n    this.justWallJumped = false;\n  }\n\n  update(dt, input, platforms) {\n    this.justLanded = false;\n    this.justJumped = false;\n    this.justWallJumped = false;\n\n    const wasOnGround = this.onGround;\n    const axis = input.axis;\n\n    // --- ride moving platform (carry by this frame's delta) ---\n    if (this.groundPlat && (this.groundPlat.dx || this.groundPlat.dy)) {\n      this.x += this.groundPlat.dx;\n      this.y += this.groundPlat.dy;\n    }\n\n    // --- horizontal control ---\n    if (this.controlLock > 0) {\n      this.controlLock -= dt;\n    } else {\n      const accel = this.onGround ? GROUND_ACCEL : AIR_ACCEL;\n      if (axis !== 0) {\n        this.vx += axis * accel * dt;\n        this.vx = Math.max(-MOVE_SPEED, Math.min(MOVE_SPEED, this.vx));\n        this.facing = axis;\n      } else if (this.onGround) {\n        const f = GROUND_FRICTION * dt;\n        if (Math.abs(this.vx) <= f) this.vx = 0;\n        else this.vx -= Math.sign(this.vx) * f;\n      } else {\n        this.vx *= 1 - 1.5 * dt; // light air drag\n      }\n    }\n\n    // --- wall slide detection (airborne, falling, pushing into wall) ---\n    const sliding = !this.onGround && this.wallDir !== 0 && axis === this.wallDir && this.vy < 0;\n\n    // --- gravity ---\n    const g = this.vy < 0 ? FALL_GRAVITY : GRAVITY;\n    this.vy += g * dt;\n    if (sliding && this.vy < WALL_SLIDE_SPEED) this.vy = WALL_SLIDE_SPEED;\n    if (this.vy < MAX_FALL) this.vy = MAX_FALL;\n\n    // --- timers ---\n    if (this.onGround) this.coyoteTimer = COYOTE_TIME;\n    else this.coyoteTimer -= dt;\n\n    if (this.wallDir !== 0 && !this.onGround) {\n      this.wallCoyoteTimer = COYOTE_TIME;\n      this.wallCoyoteDir = this.wallDir;\n    } else {\n      this.wallCoyoteTimer -= dt;\n    }\n\n    // --- jumping ---\n    if (input.jumpBuffered(JUMP_BUFFER)) {\n      if (this.coyoteTimer > 0) {\n        this.vy = JUMP_VEL;\n        this.coyoteTimer = 0;\n        this.onGround = false;\n        this.justJumped = true;\n        input.consumeJump();\n      } else if (this.wallCoyoteTimer > 0) {\n        // Holding INTO the wall = climb jump: mostly vertical, short control lock,\n        // so you drift back onto the same wall and can scale it hop by hop.\n        // Holding away (or neutral) = full kick-away jump to cross gaps/chimneys.\n        const climbing = axis === this.wallCoyoteDir;\n        this.vy = climbing ? CLIMB_JUMP_VY : WALL_JUMP_VY;\n        this.vx = -this.wallCoyoteDir * (climbing ? CLIMB_JUMP_VX : WALL_JUMP_VX);\n        this.facing = climbing ? this.wallCoyoteDir : -this.wallCoyoteDir;\n        this.controlLock = climbing ? CLIMB_STICK_TIME : WALL_STICK_TIME;\n        this.wallCoyoteTimer = 0;\n        this.justWallJumped = true;\n        input.consumeJump();\n      }\n    }\n\n    // variable jump height: cut velocity when jump released on the way up\n    if (!input.jumpHeld && this.vy > 0) {\n      this.vy *= 1 - (1 - JUMP_CUT) * Math.min(1, dt * 14);\n    }\n\n    // --- integrate + collide (separate axes) ---\n    this.onGround = false;\n    this.groundPlat = null;\n    this.wallDir = 0;\n\n    this.x += this.vx * dt;\n    this.collideAxis('x', platforms);\n\n    this.y += this.vy * dt;\n    this.collideAxis('y', platforms);\n\n    if (!wasOnGround && this.onGround) this.justLanded = true;\n\n    // --- squash & stretch targets ---\n    let targetX = 1, targetY = 1;\n    if (this.justLanded) {\n      targetX = 1.45; targetY = 0.55;\n    } else if (this.justJumped || this.justWallJumped) {\n      targetX = 0.6; targetY = 1.5;\n    } else if (!this.onGround) {\n      const t = Math.min(1, Math.abs(this.vy) / 26);\n      targetX = 1 - 0.22 * t;\n      targetY = 1 + 0.3 * t;\n    }\n    const snap = this.justLanded || this.justJumped || this.justWallJumped;\n    const ease = snap ? 1 : Math.min(1, dt * 14);\n    this.scaleX += (targetX - this.scaleX) * ease;\n    this.scaleY += (targetY - this.scaleY) * ease;\n  }\n\n  collideAxis(axis, platforms) {\n    const hw = PLAYER_W / 2;\n    for (const p of platforms) {\n      const pl = p.x, pr = p.x + p.w, pb = p.y, pt = p.y + p.h;\n      const left = this.x - hw, right = this.x + hw;\n      const bottom = this.y, top = this.y + PLAYER_H;\n      if (right <= pl || left >= pr || top <= pb || bottom >= pt) continue;\n\n      if (axis === 'x') {\n        if (this.vx > 0) {\n          this.x = pl - hw;\n          this.wallDir = 1;\n        } else if (this.vx < 0) {\n          this.x = pr + hw;\n          this.wallDir = -1;\n        }\n        this.vx = 0;\n      } else {\n        if (this.vy <= 0 && bottom < pt && top > pt && (bottom > pt - 1.2 || this.vy < 0)) {\n          this.y = pt;\n          this.vy = 0;\n          this.onGround = true;\n          this.groundPlat = p;\n        } else if (this.vy > 0) {\n          this.y = pb - PLAYER_H;\n          this.vy = 0;\n        }\n      }\n    }\n  }\n}\n"},{"name":"scene.js","content":"// Three.js rendering: persistent renderer + bloom pipeline, and a per-level\n// scene builder (themed platforms, coins, springs, spikes, portal, parallax).\n\nimport * as THREE from 'three';\nimport { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';\nimport { RenderPass } from 'three/addons/postprocessing/RenderPass.js';\nimport { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';\nimport { OutputPass } from 'three/addons/postprocessing/OutputPass.js';\nimport { RoundedBoxGeometry } from 'three/addons/geometries/RoundedBoxGeometry.js';\nimport { PLAYER_W, PLAYER_H } from './player.js';\n\nexport const GAME_Z = 0;\nconst PLATFORM_DEPTH = 3;\n\n// ---------------------------------------------------------------- renderer\nexport function createRenderer(canvas) {\n  const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });\n  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));\n  renderer.shadowMap.enabled = true;\n  renderer.shadowMap.type = THREE.PCFSoftShadowMap;\n  renderer.toneMapping = THREE.ACESFilmicToneMapping;\n  renderer.toneMappingExposure = 1.1;\n\n  const camera = new THREE.PerspectiveCamera(50, 1, 0.1, 220);\n  camera.position.set(0, 6, 17);\n\n  const composer = new EffectComposer(renderer);\n  const renderPass = new RenderPass(new THREE.Scene(), camera);\n  composer.addPass(renderPass);\n  const bloomPass = new UnrealBloomPass(new THREE.Vector2(1, 1), 0.65, 0.5, 0.62);\n  composer.addPass(bloomPass);\n  composer.addPass(new OutputPass());\n\n  function resize() {\n    const w = canvas.clientWidth, h = canvas.clientHeight;\n    renderer.setSize(w, h, false);\n    composer.setSize(w, h);\n    camera.aspect = w / h;\n    camera.updateProjectionMatrix();\n  }\n  window.addEventListener('resize', resize);\n  resize();\n\n  return { renderer, camera, composer, renderPass, resize };\n}\n\n// ---------------------------------------------------------------- level build\nexport function buildLevel(def) {\n  const t = def.theme;\n  const scene = new THREE.Scene();\n  scene.background = new THREE.Color(t.bg);\n  scene.fog = new THREE.Fog(t.bg, 30, 95);\n\n  // --- lights ---\n  scene.add(new THREE.AmbientLight(t.ambient, t.ambientI));\n\n  const sun = new THREE.DirectionalLight(t.sun, t.sunI);\n  sun.position.set(10, 22, 14);\n  sun.castShadow = true;\n  sun.shadow.mapSize.set(2048, 2048);\n  sun.shadow.camera.left = -24;\n  sun.shadow.camera.right = 24;\n  sun.shadow.camera.top = 24;\n  sun.shadow.camera.bottom = -24;\n  sun.shadow.camera.far = 90;\n  scene.add(sun, sun.target);\n\n  const rim = new THREE.DirectionalLight(t.rim, 0.5);\n  rim.position.set(-8, 6, -10);\n  scene.add(rim);\n\n  // --- platforms (runtime objects track current position for movers) ---\n  const platMat = new THREE.MeshStandardMaterial({ color: t.plat, roughness: 0.7, metalness: 0.15 });\n  const lipMat = new THREE.MeshStandardMaterial({ color: t.lip, roughness: 0.4, metalness: 0.1, emissive: t.lipGlow, emissiveIntensity: 1.1 });\n  const wallMat = new THREE.MeshStandardMaterial({ color: t.wall, roughness: 0.6, metalness: 0.2, emissive: t.wallGlow, emissiveIntensity: 0.7 });\n  const moverMat = new THREE.MeshStandardMaterial({ color: t.plat, roughness: 0.5, metalness: 0.3, emissive: t.lipGlow, emissiveIntensity: 0.5 });\n\n  const platforms = def.platforms.map((p) => {\n    const mesh = new THREE.Mesh(\n      new RoundedBoxGeometry(p.w, p.h, PLATFORM_DEPTH, 2, 0.1),\n      p.wall ? wallMat : (p.move ? moverMat : platMat)\n    );\n    mesh.castShadow = true;\n    mesh.receiveShadow = true;\n    scene.add(mesh);\n\n    let lip = null;\n    if (!p.wall) {\n      lip = new THREE.Mesh(new THREE.BoxGeometry(p.w, 0.12, PLATFORM_DEPTH + 0.06), lipMat);\n      lip.receiveShadow = true;\n      scene.add(lip);\n    }\n\n    const rt = {\n      ...p, baseX: p.x, baseY: p.y, dx: 0, dy: 0, mesh, lip,\n      place() {\n        this.mesh.position.set(this.x + this.w / 2, this.y + this.h / 2, GAME_Z);\n        if (this.lip) this.lip.position.set(this.x + this.w / 2, this.y + this.h + 0.06, GAME_Z);\n      },\n    };\n    rt.place();\n    return rt;\n  });\n\n  // --- coins ---\n  const coinGeo = new THREE.CylinderGeometry(0.42, 0.42, 0.14, 24);\n  const coinMat = new THREE.MeshStandardMaterial({\n    color: '#ffd24a', roughness: 0.25, metalness: 0.85,\n    emissive: '#b8860b', emissiveIntensity: 0.8,\n  });\n  const coins = def.coins.map((c, i) => {\n    const mesh = new THREE.Mesh(coinGeo, coinMat);\n    mesh.rotation.z = Math.PI / 2;\n    mesh.position.set(c.x, c.y, GAME_Z);\n    mesh.castShadow = true;\n    scene.add(mesh);\n    return { mesh, x: c.x, y: c.y, collected: false, phase: i * 0.7 };\n  });\n\n  // --- springs ---\n  const springs = def.springs.map((s) => {\n    const group = new THREE.Group();\n    const base = new THREE.Mesh(\n      new THREE.BoxGeometry(s.w, 0.18, 1.6),\n      new THREE.MeshStandardMaterial({ color: '#1a1f33', roughness: 0.8 })\n    );\n    base.position.y = 0.09;\n    const pad = new THREE.Mesh(\n      new THREE.BoxGeometry(s.w * 0.92, 0.22, 1.4),\n      new THREE.MeshStandardMaterial({ color: t.spring, roughness: 0.3, emissive: t.spring, emissiveIntensity: 0.9 })\n    );\n    pad.position.y = 0.3;\n    pad.castShadow = true;\n    group.add(base, pad);\n    group.position.set(s.x, s.y, GAME_Z);\n    scene.add(group);\n    return { ...s, group, pad, bounceT: 1 };\n  });\n\n  // --- spikes ---\n  const spikeMat = new THREE.MeshStandardMaterial({ color: t.spike, roughness: 0.35, metalness: 0.5, emissive: t.spike, emissiveIntensity: 0.35 });\n  const spikes = def.spikes.map((z) => {\n    const group = new THREE.Group();\n    const n = Math.max(2, Math.round(z.w / 0.6));\n    for (let i = 0; i < n; i++) {\n      for (const zz of [-0.7, 0, 0.7]) {\n        const cone = new THREE.Mesh(new THREE.ConeGeometry(0.26, 0.75, 6), spikeMat);\n        cone.position.set(z.x + (i + 0.5) * (z.w / n), z.y + 0.37, GAME_Z + zz);\n        group.add(cone);\n      }\n    }\n    scene.add(group);\n    return { ...z, group };\n  });\n\n  // --- portal (opens when all coins are collected) ---\n  // starts visibly \"locked\" (dim grey) — switches to the theme colour when opened\n  const ringMat = new THREE.MeshStandardMaterial({ color: '#454e6b', roughness: 0.3, metalness: 0.4, emissive: '#454e6b', emissiveIntensity: 0.12 });\n  const innerMat = new THREE.MeshBasicMaterial({ color: '#454e6b', transparent: true, opacity: 0.06, side: THREE.DoubleSide });\n  const portalGroup = new THREE.Group();\n  const ring = new THREE.Mesh(new THREE.TorusGeometry(1.4, 0.14, 12, 40), ringMat);\n  const inner = new THREE.Mesh(new THREE.CircleGeometry(1.26, 32), innerMat);\n  const portalLight = new THREE.PointLight(t.portal, 0, 14);\n  portalGroup.add(ring, inner, portalLight);\n  portalGroup.position.set(def.portal.x, def.portal.y, GAME_Z);\n  scene.add(portalGroup);\n  const portal = { x: def.portal.x, y: def.portal.y, group: portalGroup, ring, ringMat, innerMat, light: portalLight, active: false, themeColor: t.portal };\n\n  // --- player ---\n  const playerGroup = new THREE.Group();\n  const playerMat = new THREE.MeshStandardMaterial({ color: '#ff6b9d', roughness: 0.35, metalness: 0.1, emissive: '#801a40', emissiveIntensity: 0.45 });\n  const playerBody = new THREE.Mesh(new RoundedBoxGeometry(PLAYER_W, PLAYER_H, 0.9, 3, 0.18), playerMat);\n  playerBody.castShadow = true;\n  playerBody.position.y = PLAYER_H / 2;\n  playerGroup.add(playerBody);\n\n  const eyeMat = new THREE.MeshBasicMaterial({ color: '#ffffff' });\n  const pupilMat = new THREE.MeshBasicMaterial({ color: '#101426' });\n  const eyes = new THREE.Group();\n  for (const side of [-1, 1]) {\n    const eye = new THREE.Mesh(new THREE.SphereGeometry(0.13, 12, 12), eyeMat);\n    eye.position.set(side * 0.18, PLAYER_H * 0.72, 0.42);\n    const pupil = new THREE.Mesh(new THREE.SphereGeometry(0.06, 8, 8), pupilMat);\n    pupil.position.set(side * 0.18 + 0.04, PLAYER_H * 0.72, 0.53);\n    eyes.add(eye, pupil);\n  }\n  playerGroup.add(eyes);\n  scene.add(playerGroup);\n\n  // --- parallax background ---\n  const bounds = computeBounds(def);\n  const parallaxLayers = buildParallax(scene, t, bounds);\n\n  // --- void haze below ---\n  const haze = new THREE.Mesh(\n    new THREE.PlaneGeometry(500, 160),\n    new THREE.MeshBasicMaterial({ color: '#05060c' })\n  );\n  haze.rotation.x = -Math.PI / 2;\n  haze.position.set((bounds.minX + bounds.maxX) / 2, def.killY - 4, 0);\n  scene.add(haze);\n\n  return { scene, sun, platforms, coins, springs, spikes, portal, playerGroup, playerBody, eyes, parallaxLayers, bounds };\n}\n\nfunction computeBounds(def) {\n  let minX = Infinity, maxX = -Infinity;\n  for (const p of def.platforms) {\n    minX = Math.min(minX, p.x);\n    maxX = Math.max(maxX, p.x + p.w);\n  }\n  maxX = Math.max(maxX, def.portal.x + 4);\n  return { minX: minX - 2, maxX: maxX + 2 };\n}\n\nfunction buildParallax(scene, t, bounds) {\n  const layers = [];\n  const layerDefs = [\n    { z: -14, color: t.parallax[0], count: 26, hMin: 6, hMax: 16, factor: 0.85, y: -10 },\n    { z: -28, color: t.parallax[1], count: 22, hMin: 10, hMax: 24, factor: 0.65, y: -12 },\n    { z: -46, color: t.parallax[2], count: 18, hMin: 16, hMax: 34, factor: 0.4, y: -14 },\n  ];\n\n  for (const def of layerDefs) {\n    const group = new THREE.Group();\n    const mat = new THREE.MeshBasicMaterial({ color: def.color, fog: false });\n    const span = Math.max(120, (bounds.maxX - bounds.minX) * 1.8);\n    for (let i = 0; i < def.count; i++) {\n      const tt = i / def.count;\n      const h = def.hMin + ((i * 37) % 17) / 17 * (def.hMax - def.hMin);\n      const w = 3 + ((i * 13) % 7);\n      const tower = new THREE.Mesh(new THREE.BoxGeometry(w, h, 4), mat);\n      tower.position.set(bounds.minX - 20 + tt * span, def.y + h / 2, def.z);\n      group.add(tower);\n      if (def.z === -14 && i % 3 === 0) {\n        const win = new THREE.Mesh(\n          new THREE.PlaneGeometry(w * 0.5, 0.4),\n          new THREE.MeshBasicMaterial({ color: t.windows, fog: false })\n        );\n        win.position.set(tower.position.x, def.y + h * 0.7, def.z + 2.01);\n        group.add(win);\n      }\n    }\n    scene.add(group);\n    layers.push({ group, factor: def.factor });\n  }\n\n  // distant stars\n  const starGeo = new THREE.BufferGeometry();\n  const starPos = new Float32Array(160 * 3);\n  for (let i = 0; i < 160; i++) {\n    starPos[i * 3] = -40 + ((i * 73) % 280);\n    starPos[i * 3 + 1] = 6 + ((i * 31) % 44);\n    starPos[i * 3 + 2] = -60 - ((i * 17) % 20);\n  }\n  starGeo.setAttribute('position', new THREE.BufferAttribute(starPos, 3));\n  const stars = new THREE.Points(starGeo, new THREE.PointsMaterial({ color: t.stars, size: 0.35, fog: false }));\n  scene.add(stars);\n  layers.push({ group: stars, factor: 0.15 });\n\n  return layers;\n}\n"}],"folders":[]},{"folder":"other","files":[{"name":"main.sketch","content":""}],"folders":[]}]},"variants":null,"createdAt":"2026-06-10T12:06:01.659Z","updatedAt":"2026-06-16T07:42:03.021Z"}}