← back to Wild Orbs Opus
Wild Orbs: rebuild from the Opus artifact — 13 orb types, tested
9854c68b1c1b2ea53ccf2bdb1925703a1bcca73f · 2026-09-08 16:07:32 -0700 · Steve Abrams
Built on the Claude Opus Model-Arena artifact rather than replacing it: kept
the orb bestiary, field/effect model and juice, fixed its defects, extended it.
Correctness fixes:
- `continue` inside a nested for..of over black holes continued the INNER loop,
so a bullet was spliced then used after free; later splices removed the wrong
bullet.
- Respawn ran on setTimeout, so the ship could respawn while paused and a stale
timer could fire across a restart. Now frame-counted.
- Ship trail streaked across the screen on every wrap-seam crossing.
- Entities popped at the screen edges: collision was wrap-aware but rendering
was not. Added wrap-aware draw for orbs/ship/bullets/hunters/sentries/pickups.
- Rubber orbs spawned off-screen and snapped to the border on frame one.
- `if(!byChain) G.combo++; G.comboT = 70;` — brace-less if made the second
statement unconditional.
- Orb velocity had no ceiling; wells + shockwaves + rubber boosts could stack an
orb past collision resolution.
- Wave-clear raced its own guard; toast/banner state leaked across restarts.
- A 'nova' sound effect existed with no orb type to trigger it.
Robustness: auto-pause on blur/visibilitychange, synth voice budget so a 20-orb
cascade can't detonate the mixer, adaptive particle cap, and dynamic resolution
scaling (the game is fill-rate bound, so it scales the backing store instead of
dropping frames), prefers-reduced-motion respected.
Content: 3 new orb types (NOVA shockwave, SENTINEL turrets, WARP position-swap),
elder/boss orbs every 5th wave, 5 powerups, pulse bomb, hyperspace, turret
enemies, end-of-run stats, persistent best, pause menu, touch + gamepad.
Tests: test/smoke.mjs drives the real game in Chromium — 55 assertions covering
every orb type at every tier including elders, a 24-orb chain cascade, all
abilities, pause/wave/death/respawn/gameover/restart/resize, failing on any
console error. Perf assertion measures the environment's own rAF ceiling first
(headed Playwright is vsync-capped at 30fps; headless is software-rasterised).
Branched off master so the independently-built version at db229b2 stays intact.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XP13P5ZKs7oWJQ3pjbvpnG
Files touched
M .gitignoreM README.mdM index.htmlA package.jsonA test/profile.mjsA test/smoke.mjs
Diff
commit 9854c68b1c1b2ea53ccf2bdb1925703a1bcca73f
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Tue Sep 8 16:07:32 2026 -0700
Wild Orbs: rebuild from the Opus artifact — 13 orb types, tested
Built on the Claude Opus Model-Arena artifact rather than replacing it: kept
the orb bestiary, field/effect model and juice, fixed its defects, extended it.
Correctness fixes:
- `continue` inside a nested for..of over black holes continued the INNER loop,
so a bullet was spliced then used after free; later splices removed the wrong
bullet.
- Respawn ran on setTimeout, so the ship could respawn while paused and a stale
timer could fire across a restart. Now frame-counted.
- Ship trail streaked across the screen on every wrap-seam crossing.
- Entities popped at the screen edges: collision was wrap-aware but rendering
was not. Added wrap-aware draw for orbs/ship/bullets/hunters/sentries/pickups.
- Rubber orbs spawned off-screen and snapped to the border on frame one.
- `if(!byChain) G.combo++; G.comboT = 70;` — brace-less if made the second
statement unconditional.
- Orb velocity had no ceiling; wells + shockwaves + rubber boosts could stack an
orb past collision resolution.
- Wave-clear raced its own guard; toast/banner state leaked across restarts.
- A 'nova' sound effect existed with no orb type to trigger it.
Robustness: auto-pause on blur/visibilitychange, synth voice budget so a 20-orb
cascade can't detonate the mixer, adaptive particle cap, and dynamic resolution
scaling (the game is fill-rate bound, so it scales the backing store instead of
dropping frames), prefers-reduced-motion respected.
Content: 3 new orb types (NOVA shockwave, SENTINEL turrets, WARP position-swap),
elder/boss orbs every 5th wave, 5 powerups, pulse bomb, hyperspace, turret
enemies, end-of-run stats, persistent best, pause menu, touch + gamepad.
Tests: test/smoke.mjs drives the real game in Chromium — 55 assertions covering
every orb type at every tier including elders, a 24-orb chain cascade, all
abilities, pause/wave/death/respawn/gameover/restart/resize, failing on any
console error. Perf assertion measures the environment's own rAF ceiling first
(headed Playwright is vsync-capped at 30fps; headless is software-rasterised).
Branched off master so the independently-built version at db229b2 stays intact.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XP13P5ZKs7oWJQ3pjbvpnG
---
.gitignore | 1 +
README.md | 134 +-
index.html | 4293 +++++++++++++++++++++++++++++++++---------------------
package.json | 13 +
test/profile.mjs | 62 +
test/smoke.mjs | 276 ++++
6 files changed, 3119 insertions(+), 1660 deletions(-)
diff --git a/.gitignore b/.gitignore
index 1924158..732050a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,3 +6,4 @@ tmp/
dist/
build/
.next/
+test/shots/
diff --git a/README.md b/README.md
index a03d98e..98b02af 100644
--- a/README.md
+++ b/README.md
@@ -1,42 +1,104 @@
# WILD ORBS
-Single-file HTML5 canvas arcade shooter. Asteroids' skeleton, but every orb breaks
-a different rule when you shoot it. No assets, no CDNs, no build step — open
-`index.html` in any modern browser.
+Asteroids' skeleton, except every orb detonates into a **different disaster** when you
+shoot it. One self-contained HTML file — no assets, no CDNs, no build step, no network.
+Open `index.html` in any modern browser.
+
+> Branch note: this branch (`opus-artifact-rebuild`) is a rebuild from the Claude Opus
+> Model-Arena artifact. A separate, independently-built version lives on `master` at
+> commit `db229b2` and is fully intact (`git show db229b2:index.html`).
## Controls
-`WASD` / arrows fly · `S`/`↓` brake · `SPACE` fire (hold) · `P` pause · `M` mute
-## The twelve orbs
-| Orb | On hit |
+| | |
+|---|---|
+| `←` `→` / `A` `D` | rotate |
+| `↑` / `W` | thrust |
+| `↓` / `S` | brake |
+| `SPACE` | fire (hold to auto-fire) |
+| `SHIFT` | pulse bomb — clears the room |
+| `H` | hyperspace — teleport to the safest open space |
+| `P` / `ESC` | pause · `M` mute · `K` screen shake |
+
+Also supports **gamepad** (stick to steer, A thrust, X fire, B hyperspace, Y pulse) and
+**touch** — left thumb is a virtual stick that steers *and* thrusts, right side fires,
+plus on-screen PULSE / WARP buttons.
+
+## The thirteen wild orbs
+
+| Orb | What shooting it does |
|---|---|
-| SPLITTER | cracks into smaller, faster splitters (3 size tiers) |
-| HIVE | bursts into 5 homing swarmlings that hunt the ship |
-| WELL | collapses into a gravity well that bends and eats your shots |
-| PHASE | 3 hp — blinks to a new position every time you clip it |
-| SINGULARITY | opens a black hole that devours orbs, bullets and you, then implodes |
-| INVERTER | reverses your controls for 6 seconds |
-| SHATTER | explodes into 8 shards that ricochet off the walls |
-| STASIS | drops a bubble where everything but you moves at 30% speed |
-| CHAIN BOMB | detonates and sets off every orb in range — cascades |
-| MIRROR | clones itself point-mirrored across the screen (2 generations) |
-| RUBBER | 4 hp — bounces off walls instead of wrapping, faster each hit |
-| NOVA | erupts in a shockwave that damages everything it sweeps through |
-
-Types unlock two per wave over the first six waves.
-
-## Systems
-Inertial ship with screen wrap, fire cooldown and recoil · toroidal (wrap-aware)
-distance for homing, gravity and collisions · force fields applied per entity
-(bullets pulled harder than the ship) · localized time dilation from stasis
-bubbles · pre-rendered radial-gradient glow sprites drawn additively · motion-trail
-frame fade · trauma-based screen shake with rotation · combo multiplier, extra life
-every 5,000, localStorage high score · WebAudio: per-orb-type synthesized hit
-sounds, gated thrust noise, and a bassline whose tempo climbs with the wave.
-
-## Verify
-`node --check` on the extracted script, plus a Playwright suite that starts the
-game from the keyboard, exercises every orb type's on-hit behaviour, runs the
-field effects through a full lifecycle, walks the death → game-over → restart →
-pause paths, resizes mid-game and soaks for 20s. Zero console errors; 73fps under
-load in a real browser.
+| **SWARM** | bursts into homing hunters that chase your hull |
+| **GRAVITY WELL** | collapses into a well that drags your ship *and your bullets* |
+| **PHASE** | blinks to a new position on every non-lethal hit — 3 tags to kill |
+| **VOID** | opens a black hole that eats orbs, bullets and you |
+| **CHAOS** | reverses your steering for six seconds |
+| **SHARD** | shatters into ricocheting glass that kills on contact |
+| **CHRONO** | drops a slow-time bubble; everything inside crawls |
+| **CHAIN BOMB** | detonates every orb nearby on a staggered fuse — cascades hard |
+| **MIRROR** | clones itself twice (twice), then finally breaks |
+| **RUBBER** | never wraps — bounces off the walls 50% faster on every hit, 5 hits to pop |
+| **NOVA** | erupts an expanding shockwave ring that shatters whatever it crosses |
+| **SENTINEL** | deploys tracking turrets that shoot back |
+| **WARP** | rips space open and swaps *your ship* into the orb's position |
+
+**Elder orbs** (tier 4) appear every 5th wave: quadruple size, huge HP, and their wild
+effect fires at double strength. They always drop a powerup.
+
+## Powerups
+
+`W` spread shot · `R` rapid fire · `S` absorb-one-hit shield · `P` +1 pulse charge · `1` extra life
+
+## What changed from the starting artifact
+
+**Correctness**
+
+- `continue` inside a nested `for…of` over black holes continued the *inner* loop, so a
+ bullet was spliced and then execution fell through using the freed object — later
+ splices then removed the wrong bullet.
+- Respawn ran on `setTimeout`, so the ship could respawn *while paused* and a pending
+ timer could fire across a restart. Now frame-counted.
+- Ship trail drew straight across the screen every time the ship crossed a wrap seam.
+- Orbs, ship, bullets, hunters and pickups popped in and out at the screen edges — they
+ now render wrap-aware duplicates, matching the wrap-aware collision that was already there.
+- Rubber orbs spawned off-screen and got snapped to the border on frame one.
+- `if(!byChain) G.combo++; G.comboT = 70;` — a brace-less `if` that made the second
+ statement unconditional.
+- Wave-clear could fire before the guard cleared, and `toast`/wave-banner state leaked
+ across restarts.
+- Orb velocity had no ceiling, so wells + shockwaves + rubber boosts could stack an orb
+ past collision resolution.
+- `nova` had a hand-written sound effect but no orb type to trigger it.
+
+**Robustness**
+
+- Auto-pause on tab blur / visibility change.
+- A voice budget on the synth so a 20-orb chain reaction can't detonate the mixer.
+- Adaptive particle cap and **dynamic resolution scaling** — the game is fill-rate bound,
+ so instead of dropping frames on weak hardware it scales the backing store and holds 60fps.
+- `prefers-reduced-motion` respected; shake/flash toggleable and persisted.
+
+**Content added**
+
+Three new orb types (NOVA, SENTINEL, WARP), elder/boss orbs, five powerups, pulse bomb,
+hyperspace, shockwave physics, turret enemies, end-of-run stats (orbs broken, best chain,
+accuracy, time survived), persistent best score + furthest wave, pause menu with the
+bestiary, touch and gamepad support.
+
+## Tests
+
+```bash
+npm test # headless: 55 assertions
+npm run test:headed # watch it run, writes screenshots to test/shots/
+npm run profile # per-phase render timings + sustained fps
+```
+
+The suite drives the real game in Chromium: it detonates **every orb type at every tier**
+(including elders), runs a 24-orb chain cascade, exercises pulse/hyperspace/powerups,
+pause, wave progression, death, respawn, game over, restart, and resize — and fails on any
+console error or uncaught exception. The framerate assertion measures the environment's
+own rAF ceiling first, because headed Playwright is vsync-capped at 30fps and headless
+uses a software rasteriser; a hardcoded fps number would be meaningless in both.
+
+Playwright is resolved from the global install via symlinks in `node_modules/`
+(gitignored); `npm i -D playwright` also works.
diff --git a/index.html b/index.html
index ff299f5..e6da029 100644
--- a/index.html
+++ b/index.html
@@ -1,1759 +1,2804 @@
<!DOCTYPE html>
<html lang="en">
<head>
-<meta charset="utf-8">
-<meta name="viewport" content="width=device-width,initial-scale=1,user-scalable=no">
-<title>WILD ORBS</title>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, user-scalable=no">
+<meta name="theme-color" content="#04030a">
+<meta name="description" content="Wild Orbs — an Asteroids-style arcade game where every orb breaks differently.">
+<title>WILD ORBS — every orb breaks differently</title>
<style>
- :root{
- --bg:#05060e;
- --fg:#e9f1ff;
- --dim:#7d8aa8;
- --accent:#38f5d0;
- --accent2:#ff2f6e;
- }
- *{box-sizing:border-box}
- html,body{margin:0;padding:0;height:100%;width:100%;overflow:hidden;background:var(--bg);
- font-family:"SF Mono",ui-monospace,Menlo,Consolas,"Courier New",monospace;color:var(--fg);
- -webkit-font-smoothing:antialiased;}
- canvas#c{display:block;position:fixed;inset:0;}
- .screen{
- position:fixed;inset:0;display:none;flex-direction:column;justify-content:center;align-items:center;
- text-align:center;padding:24px;z-index:10;
- background:radial-gradient(ellipse at 50% 45%, rgba(8,10,26,.60) 0%, rgba(3,4,10,.90) 72%);
- backdrop-filter:blur(2px);-webkit-backdrop-filter:blur(2px);
- animation:fade .28s ease both;
- }
- .screen.on{display:flex}
- @keyframes fade{from{opacity:0}to{opacity:1}}
- h1{
- margin:0;font-size:clamp(38px,9vw,104px);letter-spacing:.14em;font-weight:800;
- background:linear-gradient(180deg,#fff 0%,#8fe9ff 42%,#38f5d0 62%,#0f7f8c 100%);
- -webkit-background-clip:text;background-clip:text;color:transparent;
- text-shadow:0 0 60px rgba(56,245,208,.28);
- line-height:1;
- }
- h1 .w{color:transparent}
- .tag{margin:12px 0 0;color:var(--dim);letter-spacing:.32em;font-size:clamp(9px,1.6vw,13px);text-transform:uppercase}
- .keys{margin:22px 0 4px;color:#b9c6e2;font-size:13px;line-height:1.9;letter-spacing:.06em}
- kbd{
- display:inline-block;min-width:26px;padding:2px 7px;margin:0 2px;border-radius:5px;
- background:rgba(255,255,255,.07);border:1px solid rgba(255,255,255,.16);
- border-bottom-width:2px;font-size:11px;color:#fff
- }
- .bestiary{
- display:grid;gap:8px 14px;margin:22px auto 6px;max-width:min(920px,94vw);
- grid-template-columns:repeat(auto-fit,minmax(190px,1fr));
- max-height:38vh;overflow:auto;padding:4px 6px;
- }
- .bestiary::-webkit-scrollbar{width:6px}
- .bestiary::-webkit-scrollbar-thumb{background:rgba(255,255,255,.14);border-radius:3px}
- .chip{display:flex;align-items:center;gap:9px;text-align:left;opacity:.94}
- .chip canvas{width:34px;height:34px;flex:0 0 34px}
- .chip .n{font-size:10.5px;letter-spacing:.14em;font-weight:700}
- .chip .d{font-size:10px;color:var(--dim);letter-spacing:.02em;line-height:1.35}
- .cta{
- margin-top:20px;font-size:13px;letter-spacing:.26em;color:var(--accent);
- animation:pulse 1.5s ease-in-out infinite;text-transform:uppercase
- }
- @keyframes pulse{0%,100%{opacity:.45}50%{opacity:1}}
- button{
- margin-top:16px;font:inherit;font-size:13px;letter-spacing:.2em;text-transform:uppercase;
- padding:13px 30px;border-radius:999px;cursor:pointer;color:#04120f;font-weight:700;
- background:linear-gradient(180deg,#7dffe6,#25d6b4);border:none;
- box-shadow:0 6px 30px rgba(56,245,208,.32);transition:transform .12s ease, box-shadow .12s ease;
- }
- button:hover{transform:translateY(-2px);box-shadow:0 10px 40px rgba(56,245,208,.45)}
- button:active{transform:translateY(0)}
- .big{font-size:clamp(30px,7vw,64px);letter-spacing:.1em;margin:0;color:var(--accent2);
- text-shadow:0 0 44px rgba(255,47,110,.45);font-weight:800}
- .stats{display:flex;gap:34px;margin:24px 0 4px;flex-wrap:wrap;justify-content:center}
- .stat .v{font-size:clamp(22px,4vw,38px);font-weight:700;letter-spacing:.04em}
- .stat .k{font-size:10px;letter-spacing:.24em;color:var(--dim);text-transform:uppercase;margin-top:4px}
- .newbest{color:#ffd75e;font-size:12px;letter-spacing:.24em;margin-top:8px}
- .foot{position:fixed;bottom:14px;left:0;right:0;text-align:center;color:#3d4762;font-size:10px;letter-spacing:.2em}
+ * { margin:0; padding:0; box-sizing:border-box; }
+ html,body {
+ width:100%; height:100%; overflow:hidden; background:#04030a;
+ font-family: "Trebuchet MS", "Segoe UI", system-ui, sans-serif;
+ color:#dfe9ff; user-select:none; -webkit-user-select:none; touch-action:none;
+ overscroll-behavior:none;
+ }
+ #wrap { position:fixed; inset:0; display:block; }
+ canvas { display:block; width:100%; height:100%; touch-action:none; }
+
+ .overlay {
+ position:absolute; inset:0; display:flex; flex-direction:column;
+ align-items:center; justify-content:center; text-align:center;
+ background: radial-gradient(ellipse at 50% 40%, rgba(30,10,60,.62), rgba(2,2,8,.9) 72%);
+ -webkit-backdrop-filter: blur(3px); backdrop-filter: blur(3px);
+ z-index:10; padding:22px 18px; overflow-y:auto;
+ animation: fadein .22s ease;
+ }
+ @keyframes fadein { from { opacity:0 } to { opacity:1 } }
+ .hidden { display:none !important; }
+
+ h1 {
+ font-size: clamp(34px, 8.4vw, 88px);
+ letter-spacing: .14em; font-weight:900; line-height:1.04;
+ background: linear-gradient(180deg,#fff 0%,#8ef7ff 40%,#c56bff 75%,#ff4fd8 100%);
+ -webkit-background-clip:text; background-clip:text; color:transparent;
+ filter: drop-shadow(0 0 26px rgba(140,90,255,.5));
+ margin-bottom:2px;
+ }
+ .sub { font-size: clamp(10px,2.1vw,14px); letter-spacing:.42em; color:#8fa4d8; margin-bottom:18px; text-transform:uppercase; }
+ .panel {
+ max-width: 900px; width:100%;
+ border:1px solid rgba(120,170,255,.20);
+ background: rgba(10,10,26,.55);
+ border-radius:14px; padding:14px 16px; margin-bottom:14px;
+ box-shadow: 0 0 40px rgba(80,40,160,.22) inset;
+ }
+ .panel-title { letter-spacing:.3em; font-size:11px; color:#8fa4d8; margin-bottom:9px; text-transform:uppercase; }
+ .keys { display:flex; gap:12px 16px; flex-wrap:wrap; justify-content:center; }
+ .key { display:flex; align-items:center; gap:7px; font-size:12.5px; color:#b9c8ef; white-space:nowrap; }
+ kbd {
+ display:inline-block; min-width:24px; padding:3px 7px; font-size:10.5px; font-family:inherit;
+ background:#171a33; border:1px solid #39406e; border-bottom-width:3px; border-radius:5px; color:#dce6ff;
+ }
+ .orbgrid {
+ display:grid; grid-template-columns:repeat(auto-fill,minmax(196px,1fr));
+ gap:8px 12px; text-align:left;
+ }
+ .orbrow { display:flex; gap:9px; align-items:flex-start; font-size:11.5px; line-height:1.34; color:#a3b3dd; }
+ .dot { width:15px; height:15px; border-radius:50%; flex:0 0 15px; margin-top:2px; box-shadow:0 0 10px currentColor; }
+ .orbrow b { color:#fff; display:block; font-size:11.5px; letter-spacing:.05em; }
+ .tips { display:grid; grid-template-columns:repeat(auto-fill,minmax(240px,1fr)); gap:6px 16px; text-align:left; font-size:12px; color:#93a3ce; }
+ .tips span b { color:#cfe0ff; }
+ .btnrow { display:flex; gap:12px; flex-wrap:wrap; justify-content:center; align-items:center; }
+ .btn {
+ font-family:inherit; font-weight:800; letter-spacing:.2em; font-size:15px;
+ padding:13px 38px; border-radius:40px; cursor:pointer; text-transform:uppercase;
+ color:#04030a; border:none; -webkit-tap-highlight-color:transparent;
+ background:linear-gradient(135deg,#7df9ff,#b06bff 55%,#ff5ec7);
+ box-shadow:0 0 26px rgba(160,90,255,.55), 0 6px 0 rgba(0,0,0,.35);
+ transition: transform .08s ease, box-shadow .2s ease;
+ }
+ .btn:hover { transform:translateY(-2px); box-shadow:0 0 40px rgba(180,110,255,.8), 0 8px 0 rgba(0,0,0,.35); }
+ .btn:active { transform:translateY(2px); box-shadow:0 0 20px rgba(160,90,255,.6), 0 2px 0 rgba(0,0,0,.35); }
+ .btn.ghost {
+ background:transparent; color:#b9c8ef; border:1px solid rgba(140,170,255,.35);
+ box-shadow:none; font-size:12px; padding:11px 24px; letter-spacing:.18em;
+ }
+ .btn.ghost:hover { color:#fff; border-color:rgba(180,210,255,.7); transform:translateY(-1px); box-shadow:0 0 18px rgba(120,90,220,.4); }
+ .hint { margin-top:12px; font-size:11.5px; color:#67739c; letter-spacing:.14em; }
+ #goTitle { font-size: clamp(30px,7vw,70px); }
+ .statline { font-size:14px; color:#9fb1dd; letter-spacing:.16em; }
+ .bigscore { font-size: clamp(30px,7vw,58px); font-weight:900; color:#fff; text-shadow:0 0 26px rgba(120,220,255,.7); line-height:1.1; }
+ .newbest { color:#ffd76b; font-size:13px; letter-spacing:.3em; min-height:16px; text-shadow:0 0 16px rgba(255,180,60,.7); }
+ .statgrid { display:grid; grid-template-columns:repeat(auto-fit,minmax(104px,1fr)); gap:10px 8px; }
+ .stat { text-align:center; }
+ .stat .v { font-size:20px; font-weight:900; color:#eaf3ff; }
+ .stat .k { font-size:9.5px; letter-spacing:.22em; color:#7787b3; text-transform:uppercase; margin-top:2px; }
+ .flashmsg { font-size:12.5px; color:#ffd76b; letter-spacing:.16em; min-height:16px; margin-top:8px; }
+ .toggles { display:flex; gap:10px; flex-wrap:wrap; justify-content:center; font-size:11px; letter-spacing:.16em; color:#8b9bc8; }
+ .tg { border:1px solid rgba(140,170,255,.25); border-radius:20px; padding:7px 14px; cursor:pointer; }
+ .tg:hover { color:#fff; border-color:rgba(180,210,255,.6); }
+ .tg b { color:#7df9ff; }
+ .tg.off b { color:#6b7699; }
+ @media (max-height: 560px) {
+ .panel { padding:10px 12px; margin-bottom:9px; }
+ h1 { font-size: clamp(26px,7vw,52px); }
+ .sub { margin-bottom:10px; }
+ #legendPanel { display:none; }
+ }
</style>
</head>
<body>
-<canvas id="c"></canvas>
-
-<div class="screen on" id="startScreen">
- <h1>WILD ORBS</h1>
- <p class="tag">every orb breaks the rules differently</p>
- <div class="keys">
- <kbd>W</kbd><kbd>A</kbd><kbd>S</kbd><kbd>D</kbd> / <kbd>←</kbd><kbd>↑</kbd><kbd>→</kbd> fly ·
- <kbd>SPACE</kbd> fire · <kbd>P</kbd> pause · <kbd>M</kbd> mute
+<div id="wrap">
+ <canvas id="cv"></canvas>
+
+ <!-- ============ START SCREEN ============ -->
+ <div id="startScreen" class="overlay">
+ <h1>WILD ORBS</h1>
+ <div class="sub">every orb breaks differently</div>
+
+ <div class="panel">
+ <div class="keys">
+ <span class="key"><kbd>←</kbd><kbd>→</kbd>/<kbd>A</kbd><kbd>D</kbd> rotate</span>
+ <span class="key"><kbd>↑</kbd>/<kbd>W</kbd> thrust</span>
+ <span class="key"><kbd>SPACE</kbd> fire</span>
+ <span class="key"><kbd>SHIFT</kbd> pulse bomb</span>
+ <span class="key"><kbd>H</kbd> hyperspace</span>
+ <span class="key"><kbd>P</kbd> pause</span>
+ <span class="key"><kbd>M</kbd> mute</span>
+ </div>
+ <div style="font-size:11.5px;color:#7d8cb8;letter-spacing:.08em;margin-top:9px">
+ Screen wraps. Inertia is real. Touch: left thumb steers & thrusts, right side fires.
+ </div>
+ </div>
+
+ <div class="panel" id="legendPanel">
+ <div class="panel-title">The thirteen wild orbs</div>
+ <div class="orbgrid" id="orbLegend"></div>
+ </div>
+
+ <div class="panel">
+ <div class="panel-title">How to survive</div>
+ <div class="tips">
+ <span><b>Know your target.</b> Some orbs punish the shot more than the miss.</span>
+ <span><b>Chain kills.</b> Fast consecutive breaks build a score multiplier.</span>
+ <span><b>Grab drops.</b> Broken orbs sometimes cough up spread, rapid, shields.</span>
+ <span><b>Save a pulse.</b> SHIFT clears the room when the swarm closes in.</span>
+ </div>
+ </div>
+
+ <div class="btnrow">
+ <button class="btn" id="startBtn">Launch</button>
+ </div>
+ <div class="hint">press SPACE or ENTER</div>
</div>
- <div class="bestiary" id="bestiary"></div>
- <button onclick="startGame()">LAUNCH</button>
- <div class="cta">press space to start</div>
-</div>
-<div class="screen" id="pauseScreen">
- <h1 style="font-size:clamp(28px,6vw,64px)">PAUSED</h1>
- <div class="cta">press P to resume</div>
-</div>
+ <!-- ============ GAME OVER ============ -->
+ <div id="overScreen" class="overlay hidden">
+ <h1 id="goTitle">GAME OVER</h1>
+ <div class="statline">FINAL SCORE</div>
+ <div class="bigscore" id="finalScore">0</div>
+ <div class="newbest" id="newBest"></div>
+ <div class="panel" style="max-width:680px;margin-top:10px">
+ <div class="statgrid">
+ <div class="stat"><div class="v" id="stWave">1</div><div class="k">Wave</div></div>
+ <div class="stat"><div class="v" id="stOrbs">0</div><div class="k">Orbs broken</div></div>
+ <div class="stat"><div class="v" id="stCombo">0</div><div class="k">Best chain</div></div>
+ <div class="stat"><div class="v" id="stAcc">0%</div><div class="k">Accuracy</div></div>
+ <div class="stat"><div class="v" id="stTime">0:00</div><div class="k">Survived</div></div>
+ </div>
+ <div class="flashmsg" id="finalMsg"></div>
+ <div class="statline" id="finalBest" style="font-size:12px;margin-top:6px">BEST 0</div>
+ </div>
+ <div class="btnrow">
+ <button class="btn" id="againBtn">Again</button>
+ <button class="btn ghost" id="menuBtn">Main menu</button>
+ </div>
+ <div class="hint">press SPACE or ENTER</div>
+ </div>
-<div class="screen" id="overScreen">
- <p class="big">GAME OVER</p>
- <div class="stats">
- <div class="stat"><div class="v" id="finalScore">0</div><div class="k">score</div></div>
- <div class="stat"><div class="v" id="finalWave">1</div><div class="k">wave</div></div>
- <div class="stat"><div class="v" id="finalBest">0</div><div class="k">best</div></div>
+ <!-- ============ PAUSE ============ -->
+ <div id="pauseScreen" class="overlay hidden">
+ <h1 style="font-size:clamp(28px,6.4vw,58px)">PAUSED</h1>
+ <div class="panel" style="max-width:560px">
+ <div class="toggles">
+ <div class="tg" id="tgSound">SOUND <b id="tgSoundV">ON</b></div>
+ <div class="tg" id="tgShake">SCREEN SHAKE <b id="tgShakeV">ON</b></div>
+ </div>
+ </div>
+ <div class="panel" style="max-width:900px">
+ <div class="panel-title">Orb bestiary</div>
+ <div class="orbgrid" id="orbLegend2"></div>
+ </div>
+ <div class="btnrow">
+ <button class="btn" id="resumeBtn">Resume</button>
+ <button class="btn ghost" id="quitBtn">Quit run</button>
+ </div>
+ <div class="hint">press P to resume</div>
</div>
- <div class="newbest" id="newbest" style="display:none">◆ NEW PERSONAL BEST ◆</div>
- <button onclick="startGame()">RUN IT BACK</button>
- <div class="cta">press space to restart</div>
</div>
-<div class="foot">WILD ORBS · single-file canvas arcade</div>
-
<script>
-'use strict';
-/* ==========================================================================
- WILD ORBS — an Asteroids-shaped shooter where every orb misbehaves.
- Single file. No assets, no CDNs. WebAudio synthesis, canvas rendering.
- ========================================================================== */
-
-/* ---------------------------------------------------------------- canvas -- */
-const canvas = document.getElementById('c');
-const ctx = canvas.getContext('2d', { alpha:false });
+"use strict";
+/* ============================================================
+ WILD ORBS — single-file canvas arcade
+ Asteroids, except every orb detonates into a different disaster.
+ ============================================================ */
+
+/* ---------------- canvas + sizing ---------------- */
+const cv = document.getElementById('cv');
+const ctx = cv.getContext('2d', { alpha:false });
+const bgc = document.createElement('canvas'); // pre-rendered background
+const bgx = bgc.getContext('2d');
+
let W = 0, H = 0, DPR = 1;
+/* This game is fill-rate bound, not CPU bound: at devicePixelRatio 2 every
+ full-screen pass costs 4x. Rather than drop frames on weaker hardware we
+ scale the backing store and keep the motion at 60fps. */
+let renderScale = 1, scaleCooldown = 0;
+
+function applyBackingStore(){
+ const base = Math.min(window.devicePixelRatio || 1, 2);
+ DPR = clamp(base * renderScale, 0.6, 2);
+ cv.width = Math.max(1, Math.floor(W * DPR));
+ cv.height = Math.max(1, Math.floor(H * DPR));
+ cv.style.width = W + 'px';
+ cv.style.height = H + 'px';
+ ctx.setTransform(DPR,0,0,DPR,0,0);
+ bgc.width = cv.width; bgc.height = cv.height;
+ bgx.setTransform(DPR,0,0,DPR,0,0);
+ paintBackground();
+ buildScreenGrads();
+}
+
function resize(){
- DPR = Math.min(window.devicePixelRatio || 1, 2);
- W = window.innerWidth; H = window.innerHeight;
- canvas.width = Math.max(1, Math.floor(W * DPR));
- canvas.height = Math.max(1, Math.floor(H * DPR));
- canvas.style.width = W + 'px';
- canvas.style.height = H + 'px';
- ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
- makeStars();
-}
-addEventListener('resize', resize);
-
-/* ------------------------------------------------------------------ math -- */
-const TAU = Math.PI * 2;
-const rand = (a,b) => a + Math.random() * (b - a);
-const randInt = (a,b) => Math.floor(rand(a, b + 1));
-const pick = a => a[(Math.random() * a.length) | 0];
-const clamp = (v,a,b) => v < a ? a : v > b ? b : v;
-const lerp = (a,b,t) => a + (b - a) * t;
-
-// toroidal (screen-wrapped) shortest delta — makes homing/gravity feel right
-function wd(d, size){ if (d > size * 0.5) d -= size; else if (d < -size * 0.5) d += size; return d; }
-function dxy(ax, ay, bx, by){ return [ wd(bx - ax, W), wd(by - ay, H) ]; }
-function dist(ax, ay, bx, by){ const dx = wd(bx-ax,W), dy = wd(by-ay,H); return Math.hypot(dx,dy); }
+ W = Math.max(320, window.innerWidth);
+ H = Math.max(320, window.innerHeight);
+ buildStars();
+ applyBackingStore();
+}
+
+/** nudge the backing-store scale toward whatever holds ~60fps (with hysteresis) */
+function adaptResolution(){
+ if(scaleCooldown>0){ scaleCooldown--; return; }
+ if(frameAvg > 23 && renderScale > 0.55){
+ renderScale = Math.max(0.55, renderScale - 0.15);
+ scaleCooldown = 40; frameAvg = 16.7; applyBackingStore();
+ } else if(frameAvg < 13.5 && renderScale < 1){
+ renderScale = Math.min(1, renderScale + 0.1);
+ scaleCooldown = 300; frameAvg = 16.7; applyBackingStore();
+ }
+}
+window.addEventListener('resize', resize);
+window.addEventListener('orientationchange', ()=> setTimeout(resize, 120));
+
+/* ---------------- math helpers ---------------- */
+const TAU = Math.PI*2;
+const rnd = (a=1,b=0)=> b + Math.random()*(a-b);
+const rndi = (a,b)=> Math.floor(rnd(a,b));
+const pick = arr => arr[(Math.random()*arr.length)|0];
+const clamp= (v,a,b)=> v<a?a:(v>b?b:v);
+const lerp = (a,b,t)=> a+(b-a)*t;
+
function wrapPos(o){
- if (o.x < 0) o.x += W; else if (o.x >= W) o.x -= W;
- if (o.y < 0) o.y += H; else if (o.y >= H) o.y -= H;
-}
-function hexA(h, a){
- const n = parseInt(h.slice(1), 16);
- return 'rgba(' + ((n>>16)&255) + ',' + ((n>>8)&255) + ',' + (n&255) + ',' + a + ')';
-}
-
-/* --------------------------------------------------------- glow sprites --- */
-/* Pre-rendered radial-gradient sprites. Way cheaper than shadowBlur, and the
- whole game is drawn additively on top of them. */
-const glowCache = new Map();
-function glowSprite(color, r){
- r = Math.max(4, Math.round(r / 4) * 4); // quantize → bounded cache
- const key = color + '|' + r;
- let cv = glowCache.get(key);
- if (cv) return cv;
- cv = document.createElement('canvas');
- const s = r * 2;
- cv.width = cv.height = s;
- const g = cv.getContext('2d');
- const grd = g.createRadialGradient(r, r, 0, r, r, r);
- grd.addColorStop(0, hexA(color, 1));
- grd.addColorStop(0.28, hexA(color, 0.55));
- grd.addColorStop(0.62, hexA(color, 0.16));
- grd.addColorStop(1, hexA(color, 0));
- g.fillStyle = grd;
- g.fillRect(0, 0, s, s);
- glowCache.set(key, cv);
- return cv;
-}
-function drawGlow(g, x, y, r, color, alpha){
- const sp = glowSprite(color, r);
- g.globalAlpha = alpha;
- g.drawImage(sp, x - r, y - r, r * 2, r * 2);
- g.globalAlpha = 1;
-}
-
-/* ----------------------------------------------------------------- audio -- */
-const Snd = {
- ctx:null, master:null, musicGain:null, sfxGain:null,
- noiseBuf:null, thrustGain:null, muted:false, ready:false,
- nextBeat:0, beat:0,
+ const r = o.r || 0;
+ if(o.x < -r) o.x += W + r*2;
+ else if(o.x > W + r) o.x -= W + r*2;
+ if(o.y < -r) o.y += H + r*2;
+ else if(o.y > H + r) o.y -= H + r*2;
+}
+/** shortest delta from (x1,y1) to (x2,y2) across the wrapping playfield */
+function wrapDelta(x1,y1,x2,y2){
+ let dx = x2-x1, dy = y2-y1;
+ if(dx > W/2) dx -= W; else if(dx < -W/2) dx += W;
+ if(dy > H/2) dy -= H; else if(dy < -H/2) dy += H;
+ return [dx,dy];
+}
+function wrapDist(a,b){ const [dx,dy]=wrapDelta(a.x,a.y,b.x,b.y); return Math.hypot(dx,dy); }
+
+/** Draw fn once per screen-wrap copy so entities never pop at the edges. */
+function wrapDraw(x,y,r,fn){
+ fn(0,0);
+ const ox = x < r ? W : (x > W-r ? -W : 0);
+ const oy = y < r ? H : (y > H-r ? -H : 0);
+ if(ox){ fn(ox,0); }
+ if(oy){ fn(0,oy); }
+ if(ox && oy){ fn(ox,oy); }
+}
+
+/* ---------------- color helpers ---------------- */
+function hexA(hex,a){
+ const h = hex.replace('#','');
+ const n = parseInt(h.length===3? h.split('').map(c=>c+c).join('') : h, 16);
+ return `rgba(${(n>>16)&255},${(n>>8)&255},${n&255},${a})`;
+}
+function shade(hex,amt){
+ const h=hex.replace('#','');
+ const n=parseInt(h.length===3? h.split('').map(c=>c+c).join(''):h,16);
+ let r=(n>>16)&255,g=(n>>8)&255,b=n&255;
+ if(amt<0){ r=Math.round(r*(1+amt)); g=Math.round(g*(1+amt)); b=Math.round(b*(1+amt)); }
+ else { r=Math.round(r+(255-r)*amt); g=Math.round(g+(255-g)*amt); b=Math.round(b+(255-b)*amt); }
+ return `rgb(${r},${g},${b})`;
+}
+/* ------------------------------------------------------------
+ SPRITE + GRADIENT CACHES
+ Building a CanvasGradient costs real time, and the naive version of
+ this game built two per orb per frame (times up to 4 wrap copies).
+ Radial glows are baked once into small offscreen canvases and blitted;
+ body gradients are built in LOCAL space so they can be shared by every
+ orb of the same kind and size.
+ ------------------------------------------------------------ */
+const _sprites = new Map();
+function sprite(key, size, painter){
+ let c = _sprites.get(key);
+ if(c) return c;
+ c = document.createElement('canvas');
+ c.width = c.height = size;
+ painter(c.getContext('2d'), size);
+ _sprites.set(key, c);
+ return c;
+}
+/** soft radial glow, baked small and scaled up at draw time */
+function glowSprite(col, a0, a1){
+ return sprite('glow|'+col+'|'+a0+'|'+a1, 128, (g,S)=>{
+ const gr = g.createRadialGradient(S/2,S/2,0, S/2,S/2,S/2);
+ gr.addColorStop(0, hexA(col,a0));
+ gr.addColorStop(0.45, hexA(col,a1));
+ gr.addColorStop(1, hexA(col,0));
+ g.fillStyle = gr; g.fillRect(0,0,S,S);
+ });
+}
+/** draw a cached sprite centred on (x,y) with the given radius */
+function blit(spr, x, y, r){ ctx.drawImage(spr, x-r, y-r, r*2, r*2); }
+
+const _grads = new Map();
+function orbBodyGrad(kind, r){
+ const key = kind+'|'+(Math.round(r/2)*2);
+ let g = _grads.get(key);
+ if(g) return g;
+ const T = ORB_TYPES[kind];
+ g = ctx.createRadialGradient(-r*0.34, -r*0.4, r*0.08, 0, 0, r*1.15);
+ g.addColorStop(0, '#ffffff');
+ g.addColorStop(0.18, T.c1);
+ g.addColorStop(0.72, T.c2);
+ g.addColorStop(1, shade(T.c2,-0.55));
+ _grads.set(key, g);
+ return g;
+}
+function sentryBodyGrad(r){
+ const key = 'sentry|'+Math.round(r);
+ let g = _grads.get(key);
+ if(g) return g;
+ g = ctx.createRadialGradient(-r*0.3,-r*0.3,2, 0,0, r);
+ g.addColorStop(0,'#dfe4ff'); g.addColorStop(0.6,'#3b41a4'); g.addColorStop(1,'#171a4a');
+ _grads.set(key,g);
+ return g;
+}
+function bulletSprite(){
+ return sprite('bullet', 64, (g,S)=>{
+ const gr = g.createRadialGradient(S/2,S/2,0, S/2,S/2,S/2);
+ gr.addColorStop(0,'rgba(255,255,255,1)');
+ gr.addColorStop(0.25,'rgba(150,245,255,.9)');
+ gr.addColorStop(1,'rgba(90,180,255,0)');
+ g.fillStyle=gr; g.fillRect(0,0,S,S);
+ });
+}
+function enemyShotSprite(){
+ return sprite('eshot', 64, (g,S)=>{
+ const gr = g.createRadialGradient(S/2,S/2,0, S/2,S/2,S/2);
+ gr.addColorStop(0,'rgba(255,255,255,.95)');
+ gr.addColorStop(0.3,'rgba(180,160,255,.85)');
+ gr.addColorStop(1,'rgba(120,80,255,0)');
+ g.fillStyle=gr; g.fillRect(0,0,S,S);
+ });
+}
+let vignetteGrad = null, dangerGrad = null, shipHullGrad = null, shipFlameGrad = null;
+function buildScreenGrads(){
+ vignetteGrad = ctx.createRadialGradient(W/2,H/2,Math.min(W,H)*0.34, W/2,H/2, Math.max(W,H)*0.78);
+ vignetteGrad.addColorStop(0,'rgba(0,0,0,0)');
+ vignetteGrad.addColorStop(1,'rgba(0,0,0,.72)');
+ dangerGrad = ctx.createRadialGradient(W/2,H/2,Math.min(W,H)*0.3, W/2,H/2, Math.max(W,H)*0.7);
+ dangerGrad.addColorStop(0,'rgba(120,40,220,0)');
+ dangerGrad.addColorStop(1,'rgba(120,40,220,1)');
+}
+
+/* ------------------------------------------------------------
+ ADAPTIVE QUALITY — sheds particles rather than framerate
+ ------------------------------------------------------------ */
+let frameAvg = 16.7;
+function partCap(){ return frameAvg > 32 ? 300 : frameAvg > 22 ? 550 : 1200; }
+
+/* ============================================================
+ SETTINGS (persisted)
+ ============================================================ */
+const prefersReduced = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+const Store = {
+ get(k,d){ try{ const v = localStorage.getItem('wildorbs_'+k); return v===null? d : JSON.parse(v); }catch(e){ return d; } },
+ set(k,v){ try{ localStorage.setItem('wildorbs_'+k, JSON.stringify(v)); }catch(e){} }
+};
+const SET = {
+ muted: Store.get('muted', false),
+ shake: Store.get('shake', !prefersReduced)
+};
+
+/* ============================================================
+ AUDIO — WebAudio synth, no assets
+ ============================================================ */
+const Snd = {
+ ctx:null, master:null, ready:false, voices:0,
init(){
- if (this.ready) return;
- const AC = window.AudioContext || window.webkitAudioContext;
- if (!AC) return;
- this.ctx = new AC();
- this.master = this.ctx.createGain(); this.master.gain.value = 0.62;
- this.master.connect(this.ctx.destination);
- this.sfxGain = this.ctx.createGain(); this.sfxGain.gain.value = 1; this.sfxGain.connect(this.master);
- this.musicGain = this.ctx.createGain(); this.musicGain.gain.value = 0.34; this.musicGain.connect(this.master);
-
- // shared noise buffer
- const len = this.ctx.sampleRate * 2;
- this.noiseBuf = this.ctx.createBuffer(1, len, this.ctx.sampleRate);
- const d = this.noiseBuf.getChannelData(0);
- for (let i = 0; i < len; i++) d[i] = Math.random() * 2 - 1;
-
- // continuous thrust rumble, gain-gated
- const src = this.ctx.createBufferSource();
- src.buffer = this.noiseBuf; src.loop = true;
- const bp = this.ctx.createBiquadFilter();
- bp.type = 'bandpass'; bp.frequency.value = 320; bp.Q.value = 0.9;
- const lp = this.ctx.createBiquadFilter();
- lp.type = 'lowpass'; lp.frequency.value = 900;
- this.thrustGain = this.ctx.createGain(); this.thrustGain.gain.value = 0;
- src.connect(bp); bp.connect(lp); lp.connect(this.thrustGain); this.thrustGain.connect(this.sfxGain);
- src.start();
-
- this.nextBeat = this.ctx.currentTime + 0.2;
- this.ready = true;
- },
- resume(){ if (this.ctx && this.ctx.state === 'suspended') this.ctx.resume(); },
- toggleMute(){
- this.muted = !this.muted;
- if (this.master) this.master.gain.setTargetAtTime(this.muted ? 0 : 0.62, this.ctx.currentTime, 0.02);
+ if(this.ready) return;
+ try{
+ const AC = window.AudioContext || window.webkitAudioContext;
+ if(!AC) return;
+ this.ctx = new AC();
+ this.master = this.ctx.createGain();
+ this.master.gain.value = SET.muted ? 0 : 0.30;
+ const comp = this.ctx.createDynamicsCompressor();
+ comp.threshold.value=-17; comp.knee.value=22; comp.ratio.value=11;
+ comp.attack.value=.003; comp.release.value=.22;
+ this.master.connect(comp); comp.connect(this.ctx.destination);
+ this.ready = true;
+ }catch(e){ /* audio is optional; game runs silent */ }
},
- thrust(on){
- if (!this.ready) return;
- this.thrustGain.gain.setTargetAtTime(on ? 0.16 : 0, this.ctx.currentTime, on ? 0.03 : 0.09);
- },
-
- /* one-shot oscillator with a pitch envelope */
- tone(o){
- if (!this.ready) return;
- const t = this.ctx.currentTime, dur = o.dur ?? 0.14;
- const osc = this.ctx.createOscillator();
- osc.type = o.type || 'square';
- osc.frequency.setValueAtTime(o.f0, t);
- if (o.f1 != null){
- if (o.exp === false) osc.frequency.linearRampToValueAtTime(o.f1, t + dur);
- else osc.frequency.exponentialRampToValueAtTime(Math.max(1, o.f1), t + dur);
- }
- if (o.detune) osc.detune.value = o.detune;
- const g = this.ctx.createGain();
- const v = (o.vol ?? 0.2);
- g.gain.setValueAtTime(0.0001, t + (o.at ?? 0));
- g.gain.exponentialRampToValueAtTime(v, t + (o.at ?? 0) + 0.008);
- g.gain.exponentialRampToValueAtTime(0.0001, t + (o.at ?? 0) + dur);
- let node = osc;
- if (o.filter){
- const f = this.ctx.createBiquadFilter();
- f.type = o.filter; f.frequency.value = o.ff ?? 1200; f.Q.value = o.fq ?? 1;
- osc.connect(f); node = f;
- }
- node.connect(g);
- if (o.pan != null && this.ctx.createStereoPanner){
- const p = this.ctx.createStereoPanner(); p.pan.value = o.pan;
- g.connect(p); p.connect(o.bus || this.sfxGain);
- } else g.connect(o.bus || this.sfxGain);
- osc.start(t + (o.at ?? 0));
- osc.stop(t + (o.at ?? 0) + dur + 0.05);
+ resume(){ if(this.ctx && this.ctx.state==='suspended') this.ctx.resume().catch(()=>{}); },
+ applyMute(){ if(this.master) this.master.gain.value = SET.muted ? 0 : 0.30; Engine.set(Engine.level); },
+ t(){ return this.ctx.currentTime; },
+ live(){ return this.ready && !SET.muted; },
+ /** cheap voice budget so a 20-orb chain reaction can't detonate the mixer */
+ budget(){
+ const now = performance.now();
+ if(now - this._win > 100){ this._win = now; this.voices = 0; }
+ return (this.voices++) < 26;
},
-
- /* one-shot filtered noise burst */
- noise(o){
- if (!this.ready) return;
- const t = this.ctx.currentTime, dur = o.dur ?? 0.2;
- const src = this.ctx.createBufferSource();
- src.buffer = this.noiseBuf;
- src.playbackRate.value = o.rate ?? 1;
- const f = this.ctx.createBiquadFilter();
- f.type = o.filter || 'lowpass';
- f.frequency.setValueAtTime(o.f0 ?? 2000, t + (o.at ?? 0));
- if (o.f1 != null) f.frequency.exponentialRampToValueAtTime(Math.max(20, o.f1), t + (o.at ?? 0) + dur);
- f.Q.value = o.q ?? 1;
+ _win:0,
+ env(v,at,dur,attack){
const g = this.ctx.createGain();
- g.gain.setValueAtTime(0.0001, t + (o.at ?? 0));
- g.gain.exponentialRampToValueAtTime(o.vol ?? 0.25, t + (o.at ?? 0) + 0.01);
- g.gain.exponentialRampToValueAtTime(0.0001, t + (o.at ?? 0) + dur);
- src.connect(f); f.connect(g); g.connect(this.sfxGain);
- src.start(t + (o.at ?? 0));
- src.stop(t + (o.at ?? 0) + dur + 0.05);
+ g.gain.setValueAtTime(0.0001, at);
+ g.gain.exponentialRampToValueAtTime(Math.max(v,0.0002), at+(attack||0.008));
+ g.gain.exponentialRampToValueAtTime(0.0001, at+dur);
+ g.connect(this.master);
+ return g;
},
-
- /* --- named sfx --- */
- shoot(){ this.tone({ type:'square', f0:900, f1:260, dur:0.09, vol:0.10 });
- this.noise({ filter:'highpass', f0:1800, dur:0.05, vol:0.05 }); },
- playerHit(){
- this.noise({ filter:'lowpass', f0:1400, f1:60, dur:0.9, vol:0.4, rate:0.7 });
- this.tone({ type:'sawtooth', f0:180, f1:28, dur:0.8, vol:0.24 });
+ tone(freq,dur,type,vol,at,slideTo,detune){
+ if(!this.live() || !this.budget()) return;
+ at = at===undefined? this.t() : at;
+ const o = this.ctx.createOscillator();
+ o.type = type||'sine';
+ o.frequency.setValueAtTime(Math.max(freq,1), at);
+ if(slideTo) o.frequency.exponentialRampToValueAtTime(Math.max(slideTo,1), at+dur);
+ if(detune) o.detune.value = detune;
+ const g = this.env(vol===undefined?.3:vol, at, dur);
+ o.connect(g); o.start(at); o.stop(at+dur+0.05);
},
- respawn(){ this.tone({ type:'triangle', f0:220, f1:880, dur:0.35, vol:0.14 }); },
- waveUp(){
- [0,0.09,0.18].forEach((at,i)=> this.tone({ type:'triangle', f0:[440,660,880][i], dur:0.24, vol:0.13, at }));
+ noise(dur,vol,filterType,f0,f1,at,q){
+ if(!this.live() || !this.budget()) return;
+ at = at===undefined? this.t() : at;
+ const len = Math.max(1,Math.floor(this.ctx.sampleRate*Math.min(dur,2)));
+ const buf = this.ctx.createBuffer(1,len,this.ctx.sampleRate);
+ const d = buf.getChannelData(0);
+ for(let i=0;i<len;i++) d[i] = (Math.random()*2-1) * (1 - i/len);
+ const src = this.ctx.createBufferSource(); src.buffer = buf;
+ const bq = this.ctx.createBiquadFilter();
+ bq.type = filterType||'lowpass';
+ bq.frequency.setValueAtTime(f0||1200, at);
+ if(f1) bq.frequency.exponentialRampToValueAtTime(Math.max(f1,20), at+dur);
+ bq.Q.value = q||1;
+ const g = this.env(vol===undefined?.4:vol, at, dur);
+ src.connect(bq); bq.connect(g); src.start(at); src.stop(at+dur+0.05);
},
- extraLife(){ [0,0.08,0.16,0.24].forEach((at,i)=> this.tone({type:'square',f0:[523,659,784,1047][i],dur:0.18,vol:0.1,at})); },
- gameOver(){
- [0,0.16,0.32,0.5].forEach((at,i)=> this.tone({ type:'sawtooth', f0:[330,262,196,130][i], f1:[300,240,180,60][i], dur:0.5, vol:0.16, at, filter:'lowpass', ff:900 }));
+ chord(freqs,dur,type,vol){
+ if(!this.live()) return;
+ const at=this.t();
+ freqs.forEach((f,i)=> this.tone(f,dur,type||'triangle',(vol||.16), at+i*0.012));
},
- /* --- pulsing bassline; tempo climbs with the wave --- */
- music(){
- if (!this.ready || state !== 'playing') return;
- const t = this.ctx.currentTime;
- const bpm = Math.min(150, 82 + wave * 5);
- const spb = 60 / bpm;
- if (this.nextBeat < t) this.nextBeat = t + 0.02; // never schedule into the past
- while (this.nextBeat < t + 0.12){
- const at = Math.max(0, this.nextBeat - t);
- const step = this.beat % 8;
- const notes = [55, 55, 82.4, 55, 65.4, 55, 73.4, 61.7];
- this.tone({ type:'triangle', f0:notes[step], dur:spb*0.85, vol:0.13, at, bus:this.musicGain, filter:'lowpass', ff:420 });
- if (step % 4 === 0){
- this.noise({ filter:'lowpass', f0:220, f1:50, dur:0.16, vol:0.16, at });
- }
- if (step === 6) this.noise({ filter:'highpass', f0:6000, dur:0.05, vol:0.035, at });
- this.beat++; this.nextBeat += spb;
+ /* ---- named SFX ---- */
+ shoot(){ if(!this.live())return; const at=this.t();
+ this.tone(880,0.09,'square',.11,at,220);
+ this.noise(0.05,.08,'highpass',1800,900,at); },
+ hurt(){ if(!this.live())return; const at=this.t();
+ this.tone(180,0.5,'sawtooth',.33,at,40);
+ this.noise(0.45,.32,'lowpass',900,80,at); },
+ shieldBreak(){ if(!this.live())return; const at=this.t();
+ this.tone(1200,.3,'triangle',.22,at,300);
+ this.noise(.3,.2,'bandpass',2600,700,at,7); },
+ gameover(){ if(!this.live())return; const at=this.t();
+ [440,330,262,175].forEach((f,i)=> this.tone(f,0.5,'triangle',.22, at+i*0.16, f*0.6));
+ this.noise(1.4,.18,'lowpass',700,60,at); },
+ wave(n){ if(!this.live())return; const at=this.t();
+ [523.25,659.25,783.99,1046.5].forEach((f,i)=> this.tone(f*(1+((n-1)%4)*0.02),0.28,'triangle',.19, at+i*0.09)); },
+ extraLife(){ if(!this.live())return; const at=this.t();
+ [523,659,784,1047,1319].forEach((f,i)=> this.tone(f,0.22,'square',.13, at+i*0.06)); },
+ powerup(){ if(!this.live())return; const at=this.t();
+ [660,880,1180].forEach((f,i)=> this.tone(f,0.18,'triangle',.16, at+i*0.05, f*1.35));
+ this.noise(.14,.08,'bandpass',2600,3400,at,8); },
+ pulse(){ if(!this.live())return; const at=this.t();
+ this.tone(90,1.0,'sine',.4,at,26);
+ this.tone(420,.7,'sawtooth',.16,at,60);
+ this.noise(.9,.36,'lowpass',5200,120,at); },
+ hyper(){ if(!this.live())return; const at=this.t();
+ this.tone(220,.28,'sine',.22,at,2600);
+ this.noise(.26,.16,'bandpass',600,4200,at,6); },
+ bounce(){ if(!this.live())return; const at=this.t();
+ this.tone(rnd(900,520),.07,'triangle',.08,at, rnd(1500,900)); },
+ pop(){ if(!this.live())return; const at=this.t();
+ this.tone(rnd(1400,900),.06,'sine',.07,at,300); },
+ turretShot(){ if(!this.live())return; const at=this.t();
+ this.tone(300,.12,'square',.10,at,120);
+ this.noise(.1,.08,'bandpass',1400,600,at,5); },
+
+ orbHit(kind){
+ if(!this.live()) return;
+ const at = this.t();
+ switch(kind){
+ case 'swarm':
+ for(let i=0;i<7;i++) this.tone(500+i*130, .07,'square',.08, at+i*0.026, 1400);
+ this.noise(.22,.12,'bandpass',2600,900,at,6); break;
+ case 'gravity':
+ this.tone(300,.9,'sine',.28,at,38);
+ this.tone(150,.9,'triangle',.18,at,24);
+ this.noise(.8,.14,'lowpass',600,60,at); break;
+ case 'phase':
+ this.tone(1500,.16,'sine',.18,at,120);
+ this.tone(300,.20,'sine',.14,at,2400);
+ this.noise(.12,.10,'bandpass',3000,600,at,10); break;
+ case 'blackhole':
+ this.tone(70,1.6,'sine',.40,at,28);
+ this.tone(105,1.4,'triangle',.16,at,44);
+ this.noise(1.3,.20,'lowpass',400,40,at);
+ this.tone(1800,.9,'sine',.06,at,220); break;
+ case 'reverse':
+ for(let i=0;i<9;i++) this.tone(i%2? 340:620, .09,'sawtooth',.12, at+i*0.05);
+ break;
+ case 'shard':
+ this.noise(.5,.30,'highpass',5200,1400,at,2);
+ for(let i=0;i<9;i++) this.tone(rnd(2600,1100),.16,'triangle',.08, at+i*0.018); break;
+ case 'time':
+ this.chord([392,523.25,659.25,880],1.1,'sine',.12);
+ this.tone(1200,1.1,'sine',.08,at,300); break;
+ case 'chain':
+ this.tone(520,.22,'square',.22,at,90);
+ this.noise(.3,.28,'lowpass',2600,180,at); break;
+ case 'mirror':
+ this.tone(740,.35,'triangle',.15,at,740*1.5);
+ this.tone(740*1.005,.35,'triangle',.15,at+0.03,740*1.5);
+ this.noise(.2,.09,'bandpass',2200,2600,at,8); break;
+ case 'rubber':
+ this.tone(200,.28,'sine',.28,at,760);
+ this.tone(760,.20,'sine',.15,at+0.06,180); break;
+ case 'nova':
+ this.tone(120,1.0,'sawtooth',.32,at,30);
+ this.noise(.9,.36,'lowpass',4000,120,at);
+ this.chord([220,277,330,440],.7,'sawtooth',.09); break;
+ case 'sentry':
+ [740,560,930].forEach((f,i)=> this.tone(f,.12,'square',.13, at+i*0.07));
+ this.noise(.3,.14,'bandpass',900,2200,at,9); break;
+ case 'warp':
+ this.tone(180,.34,'sine',.24,at,2200);
+ this.tone(2400,.34,'sine',.14,at+0.05,160);
+ this.noise(.3,.16,'bandpass',400,3800,at,7); break;
+ default:
+ this.tone(360,.2,'square',.18,at,120); this.noise(.24,.18,'lowpass',1500,200,at);
}
}
};
-/* ------------------------------------------------------------ orb design -- */
-/* Each type = a colour identity, a silhouette, and a rule it breaks. */
-const TYPES = {
- splitter: {
- name:'SPLITTER', c:'#ff2f4e', c2:'#ffc0c8', r:34, hp:1, sc:100, spd:1,
- blurb:'Cracks into smaller, faster splitters',
- sfx(){ Snd.noise({filter:'bandpass', f0:1400, f1:300, q:2, dur:0.28, vol:0.24});
- Snd.tone({type:'square', f0:420, f1:120, dur:0.16, vol:0.12}); },
- destroy(o){
- if (o.tier <= 1) return;
- const n = o.tier === 3 ? 3 : 2;
- for (let i = 0; i < n; i++){
- const a = rand(0, TAU);
- const ch = new Orb(o.x + Math.cos(a) * o.r * 0.6, o.y + Math.sin(a) * o.r * 0.6, 'splitter', o.tier - 1);
- const sp = Math.hypot(o.vx, o.vy) * 1.25 + 60;
- ch.vx = Math.cos(a) * sp; ch.vy = Math.sin(a) * sp;
- orbs.push(ch);
- }
- }
+/* engine hum — continuous, gated on thrust */
+const Engine = {
+ src:null, sub:null, gain:null, filt:null, level:0,
+ start(){
+ if(!Snd.ready || this.src) return;
+ const c = Snd.ctx;
+ this.gain = c.createGain(); this.gain.gain.value = 0;
+ this.filt = c.createBiquadFilter(); this.filt.type='lowpass'; this.filt.frequency.value=760; this.filt.Q.value=3;
+ const len = Math.floor(c.sampleRate*2);
+ const buf = c.createBuffer(1,len,c.sampleRate);
+ const d = buf.getChannelData(0);
+ for(let i=0;i<len;i++) d[i] = Math.random()*2-1;
+ this.src = c.createBufferSource(); this.src.buffer=buf; this.src.loop=true;
+ this.sub = c.createOscillator(); this.sub.type='sawtooth'; this.sub.frequency.value=62;
+ const subG = c.createGain(); subG.gain.value=0.5;
+ this.src.connect(this.filt); this.sub.connect(subG); subG.connect(this.filt);
+ this.filt.connect(this.gain); this.gain.connect(Snd.master);
+ try{ this.src.start(); this.sub.start(); }catch(e){}
},
+ set(v){
+ this.level = v;
+ if(!this.gain || !Snd.ready) return;
+ const target = SET.muted ? 0 : v*0.19;
+ const now = Snd.ctx.currentTime;
+ this.gain.gain.setTargetAtTime(target, now, 0.06);
+ this.filt.frequency.setTargetAtTime(500 + v*900, now, 0.08);
+ }
+};
+/* ============================================================
+ ORB TYPES — the wild bestiary
+ ============================================================ */
+const ORB_TYPES = {
swarm: {
- name:'HIVE', c:'#8dff3a', c2:'#e6ffb8', r:32, hp:1, sc:150, spd:0.85,
- blurb:'Bursts into a homing swarm that hunts you',
- sfx(){ Snd.tone({type:'sawtooth', f0:180, f1:1400, dur:0.3, vol:0.14, filter:'bandpass', ff:900, fq:6});
- Snd.noise({filter:'bandpass', f0:2600, q:8, dur:0.35, vol:0.1}); },
- destroy(o){
- for (let i = 0; i < 5; i++){
- const a = i / 5 * TAU + rand(-0.3, 0.3);
- const s = new Orb(o.x + Math.cos(a) * 24, o.y + Math.sin(a) * 24, 'swarmling', 1);
- s.vx = Math.cos(a) * 150; s.vy = Math.sin(a) * 150;
- orbs.push(s);
- }
- }
+ name:'SWARM ORB', c1:'#ffe27a', c2:'#ff9d2e', glow:'#ffb454', shape:'hive',
+ desc:'Bursts into homing hunters that chase your hull.', pts:110, hp:1
},
-
- swarmling: {
- name:'SWARMLING', c:'#b6ff5e', c2:'#f2ffd6', r:11, hp:1, sc:40, spd:1, hidden:true, life:14,
- blurb:'', sfx(){ Snd.tone({type:'square', f0:1200, f1:400, dur:0.07, vol:0.07}); },
- move(o, dt){
- if (!ship || ship.dead) return;
- const [dx, dy] = dxy(o.x, o.y, ship.x, ship.y);
- const d = Math.max(1, Math.hypot(dx, dy));
- o.vx += (dx / d) * 300 * dt;
- o.vy += (dy / d) * 300 * dt;
- const sp = Math.hypot(o.vx, o.vy), max = 235;
- if (sp > max){ o.vx = o.vx / sp * max; o.vy = o.vy / sp * max; }
- }
- },
-
gravity: {
- name:'WELL', c:'#3b6bff', c2:'#b9cdff', r:33, hp:1, sc:200, spd:0.8,
- blurb:'Collapses into a gravity well that eats your shots',
- sfx(){ Snd.tone({type:'sine', f0:420, f1:38, dur:0.9, vol:0.3});
- Snd.noise({filter:'lowpass', f0:800, f1:80, dur:0.7, vol:0.15}); },
- destroy(o){ effects.push(new GravityWell(o.x, o.y)); }
+ name:'GRAVITY WELL', c1:'#8de7ff', c2:'#1f6bd8', glow:'#57c8ff', shape:'ring',
+ desc:'Collapses into a well that drags your ship and bullets.', pts:140, hp:1
},
-
phase: {
- name:'PHASE', c:'#ffe14d', c2:'#fffbdc', r:30, hp:3, sc:250, spd:1.15,
- blurb:'Blinks somewhere else every time you clip it',
- sfx(){ Snd.tone({type:'sine', f0:1600, f1:300, dur:0.18, vol:0.15});
- Snd.tone({type:'sine', f0:1180, f1:2400, dur:0.18, vol:0.1, detune:12}); },
- hurt(o){
- spawnRing(o.x, o.y, o.r * 1.6, '#ffe14d', 0.35);
- burst(o.x, o.y, '#ffe14d', '#fffbdc', o.r * 0.6, 12);
- let tries = 0, nx, ny;
- do { nx = rand(60, W - 60); ny = rand(60, H - 60); tries++; }
- while (ship && dist(nx, ny, ship.x, ship.y) < 200 && tries < 24);
- o.x = nx; o.y = ny; o.ghostT = 0.5;
- spawnRing(o.x, o.y, o.r * 2.2, '#ffe14d', 0.45);
- Snd.tone({type:'sine', f0:300, f1:1700, dur:0.16, vol:0.12});
- }
+ name:'PHASE ORB', c1:'#c9a6ff', c2:'#6a2fd0', glow:'#b48bff', shape:'glitch',
+ desc:'Blinks elsewhere when hit. Takes three tags to kill.', pts:90, hp:3
},
-
blackhole: {
- name:'SINGULARITY', c:'#a24bff', c2:'#e7c6ff', r:34, hp:1, sc:300, spd:0.7,
- blurb:'Opens a black hole that devours everything near it',
- sfx(){ Snd.noise({filter:'lowpass', f0:400, f1:30, dur:1.4, vol:0.36, rate:0.5});
- Snd.tone({type:'sine', f0:160, f1:18, dur:1.4, vol:0.28}); },
- destroy(o){ effects.push(new BlackHole(o.x, o.y)); }
+ name:'VOID ORB', c1:'#2a2140', c2:'#0a0714', glow:'#a24fff', shape:'void',
+ desc:'Spawns a hungry black hole that eats everything near.', pts:220, hp:2
},
-
- inverter: {
- name:'INVERTER', c:'#ff9a1f', c2:'#3bd7ff', r:31, hp:1, sc:200, spd:1,
- blurb:'Flips your controls for six long seconds',
- sfx(){ Snd.tone({type:'sawtooth', f0:700, f1:180, dur:0.5, vol:0.2, filter:'lowpass', ff:1400});
- Snd.tone({type:'sawtooth', f0:180, f1:700, dur:0.5, vol:0.16, filter:'lowpass', ff:1400}); },
- destroy(o){
- reverseT = 6;
- spawnRing(o.x, o.y, 300, '#ff9a1f', 0.7);
- addShake(0.45);
- floaters.push(new Floater(o.x, o.y - 30, 'CONTROLS REVERSED!', '#ff9a1f', 1.7, 20));
- }
+ reverse: {
+ name:'CHAOS ORB', c1:'#ff8ad4', c2:'#c31f8a', glow:'#ff62c0', shape:'spiral',
+ desc:'Scrambles your steering for six seconds. Good luck.', pts:130, hp:1
},
-
- shatter: {
- name:'SHATTER', c:'#ff43e0', c2:'#ffd4f7', r:31, hp:1, sc:150, spd:1.1,
- blurb:'Explodes into shards that ricochet off the walls',
- sfx(){ Snd.noise({filter:'highpass', f0:2600, dur:0.4, vol:0.24});
- [0,0.05,0.1].forEach((at,i)=>Snd.tone({type:'triangle', f0:[2400,1900,3100][i], f1:900, dur:0.1, vol:0.08, at})); },
- destroy(o){
- const n = 8;
- for (let i = 0; i < n; i++){
- const a = i / n * TAU + rand(-0.2, 0.2);
- shards.push(new Shard(o.x + Math.cos(a) * o.r * 0.5, o.y + Math.sin(a) * o.r * 0.5, a, rand(240, 400)));
- }
- }
+ shard: {
+ name:'SHARD ORB', c1:'#e8fbff', c2:'#7fb6c9', glow:'#c9f4ff', shape:'crystal',
+ desc:'Shatters into ricocheting glass that hurts on contact.', pts:120, hp:1
},
-
- timebubble: {
- name:'STASIS', c:'#28e6ff', c2:'#d3f8ff', r:32, hp:1, sc:220, spd:0.95,
- blurb:'Drops a bubble where time crawls',
- sfx(){ Snd.tone({type:'sine', f0:1500, f1:220, dur:0.9, vol:0.2, filter:'lowpass', ff:2400});
- Snd.tone({type:'sine', f0:760, f1:110, dur:1.1, vol:0.13, detune:-20}); },
- destroy(o){ effects.push(new TimeBubble(o.x, o.y)); }
+ time: {
+ name:'CHRONO ORB', c1:'#9dffc9', c2:'#129c6a', glow:'#66ffb0', shape:'clock',
+ desc:'Drops a slow-time bubble. Everything inside crawls.', pts:150, hp:1
},
-
chain: {
- name:'CHAIN BOMB', c:'#ff6a00', c2:'#ffdda8', r:32, hp:1, sc:180, spd:0.95,
- blurb:'Detonates — and sets off every orb nearby',
- sfx(){ Snd.noise({filter:'lowpass', f0:1800, f1:70, dur:0.5, vol:0.34, rate:0.8});
- Snd.tone({type:'triangle', f0:260, f1:40, dur:0.4, vol:0.2}); },
- destroy(o){ effects.push(new Detonation(o.x, o.y, 200, 0.07)); }
+ name:'CHAIN BOMB', c1:'#ff6a5e', c2:'#a3131f', glow:'#ff7f5c', shape:'bomb',
+ desc:'Detonates and sets off every orb nearby. Chains hard.', pts:160, hp:1
},
-
mirror: {
- name:'MIRROR', c:'#cfe4ff', c2:'#ffffff', r:31, hp:1, sc:200, spd:1,
- blurb:'Clones itself across the screen when broken',
- sfx(){ Snd.tone({type:'square', f0:1400, f1:900, dur:0.14, vol:0.11, pan:-0.8});
- Snd.tone({type:'square', f0:1400, f1:900, dur:0.14, vol:0.11, pan:0.8, at:0.07}); },
- destroy(o){
- if (o.gen >= 2) return;
- const nx = W - o.x, ny = H - o.y;
- const cl = new Orb(nx, ny, 'mirror', 3, o.gen + 1);
- cl.vx = -o.vx; cl.vy = -o.vy;
- orbs.push(cl);
- spawnRing(nx, ny, 90, '#cfe4ff', 0.5);
- }
+ name:'MIRROR ORB', c1:'#f2f6ff', c2:'#8e9ac4', glow:'#dfe9ff', shape:'mirror',
+ desc:'Clones itself twice. Kill the clones or drown in them.', pts:70, hp:1
},
-
rubber: {
- name:'RUBBER', c:'#00ffa2', c2:'#ccffec', r:30, hp:4, sc:280, spd:1.05, bounce:true,
- blurb:'Bounces off walls — faster with every hit it takes',
- sfx(){ Snd.tone({type:'sine', f0:180, f1:900, dur:0.16, vol:0.16});
- Snd.tone({type:'sine', f0:900, f1:200, dur:0.2, vol:0.13, at:0.13}); },
- hurt(o){
- const sp = Math.hypot(o.vx, o.vy) * 1.5 + 40;
- const a = Math.atan2(o.vy, o.vx) + rand(-0.4, 0.4);
- o.vx = Math.cos(a) * sp; o.vy = Math.sin(a) * sp;
- o.squish = 1;
- spawnRing(o.x, o.y, o.r * 1.7, '#00ffa2', 0.3);
- Snd.tone({type:'sine', f0:300, f1:1100, dur:0.1, vol:0.12});
- }
+ name:'RUBBER ORB', c1:'#c3ff5e', c2:'#4d8a11', glow:'#a8ff45', shape:'rubber',
+ desc:'Never wraps — bounces off walls faster on every hit.', pts:100, hp:5
},
-
nova: {
- name:'NOVA', c:'#ffd23d', c2:'#ffffff', r:33, hp:1, sc:240, spd:0.9,
- blurb:'Erupts in a shockwave that guts everything it touches',
- sfx(){ Snd.noise({filter:'bandpass', f0:400, f1:5000, q:1.2, dur:0.55, vol:0.3});
- [0,0.02,0.04].forEach((at,i)=>Snd.tone({type:'sawtooth', f0:[220,330,440][i], f1:[110,165,220][i], dur:0.6, vol:0.12, at, filter:'lowpass', ff:2200})); },
- destroy(o){ effects.push(new Shockwave(o.x, o.y, 380, '#ffd23d')); addShake(0.6); }
+ name:'NOVA ORB', c1:'#fff3c4', c2:'#ff7a18', glow:'#ffb03a', shape:'star',
+ desc:'Erupts a shockwave ring that shatters whatever it crosses.', pts:180, hp:2
+ },
+ sentry: {
+ name:'SENTINEL ORB', c1:'#c3ccff', c2:'#2c318f', glow:'#7a86ff', shape:'eye',
+ desc:'Deploys turrets that track you and shoot back.', pts:170, hp:2
+ },
+ warp: {
+ name:'WARP ORB', c1:'#c9ffee', c2:'#00727a', glow:'#2fe8d5', shape:'portal',
+ desc:'Rips space open and swaps your ship into its place.', pts:120, hp:1
+ }
+};
+const ORB_KEYS = Object.keys(ORB_TYPES);
+
+/* ============================================================
+ POWERUPS
+ ============================================================ */
+const POWERS = {
+ spread: { name:'SPREAD', col:'#7df9ff', glyph:'W', dur:900 },
+ rapid: { name:'RAPID', col:'#ffd76b', glyph:'R', dur:900 },
+ shield: { name:'SHIELD', col:'#8effc0', glyph:'S' },
+ pulse: { name:'PULSE +1',col:'#ff8ad4', glyph:'P' },
+ life: { name:'1UP', col:'#ff6a5e', glyph:'1' }
+};
+const POWER_BAG = ['spread','spread','rapid','rapid','shield','shield','pulse','pulse','life'];
+
+/* ============================================================
+ STATE
+ ============================================================ */
+let orbs=[], bullets=[], parts=[], hunters=[], shards=[], wells=[], holes=[],
+ bubbles=[], rings=[], floats=[], waves=[], sentries=[], enemyShots=[],
+ powerups=[], starsDim=[], starsTwinkle=[];
+
+
+const G = {
+ state:'start', // start | play | pause | over
+ score:0, wave:1, lives:3,
+ best: Store.get('best',0), bestWave: Store.get('bestWave',1),
+ shake:0, shakeX:0, shakeY:0,
+ time:0, flash:0, flashCol:'#fff',
+ reverseT:0, reverseMax:360, spreadT:0, rapidT:0,
+ pulses:1, hyperCool:0,
+ waveBannerT:0, waveBannerTxt:'', waveBannerSub:'',
+ spawnGuard:0, nextLife:5000,
+ hitStop:0, combo:0, comboT:0,
+ respawnT:0,
+ // run stats
+ orbsBroken:0, bestCombo:0, shots:0, hits:0, frames:0
+};
+
+/* ---------------- starfield / background ---------------- */
+function buildStars(){
+ starsDim = []; starsTwinkle = [];
+ const n = clamp(Math.floor((W*H)/5200), 40, 460);
+ for(let i=0;i<n;i++){
+ const s = { x:rnd(W), y:rnd(H), z:rnd(1,.15), s:rnd(1.7,.4), tw:rnd(TAU), tws:rnd(3,.5) };
+ if(i % 3 === 0 && starsTwinkle.length < 150) starsTwinkle.push(s); else starsDim.push(s);
}
+}
+function paintBackground(){
+ const g = bgx.createLinearGradient(0,0,0,H);
+ g.addColorStop(0,'#080618'); g.addColorStop(0.55,'#0a0720'); g.addColorStop(1,'#050410');
+ bgx.fillStyle=g; bgx.fillRect(0,0,W,H);
+
+ bgx.save();
+ bgx.globalCompositeOperation='lighter';
+ const n1 = bgx.createRadialGradient(W*0.22,H*0.28,10,W*0.22,H*0.28,Math.max(W,H)*0.55);
+ n1.addColorStop(0,'rgba(70,30,140,.22)'); n1.addColorStop(1,'rgba(70,30,140,0)');
+ bgx.fillStyle=n1; bgx.fillRect(0,0,W,H);
+ const n2 = bgx.createRadialGradient(W*0.78,H*0.74,10,W*0.78,H*0.74,Math.max(W,H)*0.5);
+ n2.addColorStop(0,'rgba(10,80,140,.18)'); n2.addColorStop(1,'rgba(10,80,140,0)');
+ bgx.fillStyle=n2; bgx.fillRect(0,0,W,H);
+ const n3 = bgx.createRadialGradient(W*0.5,H*0.05,10,W*0.5,H*0.05,Math.max(W,H)*0.4);
+ n3.addColorStop(0,'rgba(120,20,90,.10)'); n3.addColorStop(1,'rgba(120,20,90,0)');
+ bgx.fillStyle=n3; bgx.fillRect(0,0,W,H);
+ bgx.restore();
+
+ for(const s of starsDim){
+ bgx.globalAlpha = 0.22 + 0.5*s.z;
+ bgx.fillStyle = s.z>0.7? '#dfeaff' : (s.z>0.4? '#9fb4e8' : '#6b7bb0');
+ bgx.fillRect(s.x, s.y, s.s, s.s);
+ }
+ bgx.globalAlpha = 1;
+}
+
+/* ============================================================
+ SHIP
+ ============================================================ */
+const ship = {
+ x:0,y:0,vx:0,vy:0,a:-Math.PI/2, r:13,
+ thrust:0, cool:0, inv:0, alive:true, shield:0,
+ trail:[], recoil:0
+};
+function resetShip(){
+ ship.x = W/2; ship.y = H/2; ship.vx=0; ship.vy=0; ship.a=-Math.PI/2;
+ ship.inv = 140; ship.alive=true; ship.trail.length=0; ship.cool=0;
+ G.reverseT = 0;
+}
+
+/* ============================================================
+ INPUT — keyboard + gamepad + touch
+ ============================================================ */
+const keys = Object.create(null);
+const CODES = {
+ left:['ArrowLeft','KeyA'], right:['ArrowRight','KeyD'],
+ up:['ArrowUp','KeyW'], down:['ArrowDown','KeyS'], fire:['Space']
};
-const SPAWNABLE = Object.keys(TYPES).filter(k => !TYPES[k].hidden);
-
-/* Which orbs show up, wave by wave. */
-const UNLOCKS = [
- ['splitter','shatter'],
- ['chain','swarm'],
- ['rubber','phase'],
- ['gravity','mirror'],
- ['timebubble','nova'],
- ['blackhole','inverter']
-];
-function poolFor(w){
- const p = [];
- for (let i = 0; i < UNLOCKS.length; i++) if (w >= i + 1) p.push(...UNLOCKS[i]);
- return p.length ? p : UNLOCKS[0];
-}
-
-/* ------------------------------------------------------- orb silhouettes -- */
-/* Pure drawing, no globals — reused by the game and by the start-screen chips */
-function drawOrbBody(g, key, r, t, o){
- const T = TYPES[key];
- g.save();
- switch(key){
-
- case 'splitter': {
- g.rotate(t * 0.6);
- g.beginPath();
- const n = 10;
- for (let i = 0; i < n; i++){
- const a = i / n * TAU;
- const rr = r * (i % 2 ? 0.6 : 1);
- i ? g.lineTo(Math.cos(a)*rr, Math.sin(a)*rr) : g.moveTo(Math.cos(a)*rr, Math.sin(a)*rr);
- }
- g.closePath();
- const grd = g.createRadialGradient(0,0,r*0.1,0,0,r);
- grd.addColorStop(0, T.c2); grd.addColorStop(0.5, T.c); grd.addColorStop(1, '#5a0512');
- g.fillStyle = grd; g.fill();
- g.strokeStyle = T.c2; g.lineWidth = 1.6; g.stroke();
- g.beginPath(); g.arc(0,0,r*0.26,0,TAU); g.fillStyle = '#fff'; g.globalAlpha = 0.85; g.fill();
- break;
- }
-
- case 'swarm': {
- g.beginPath(); g.arc(0,0,r*0.44,0,TAU);
- const grd = g.createRadialGradient(0,0,0,0,0,r*0.44);
- grd.addColorStop(0,'#fff'); grd.addColorStop(1,T.c);
- g.fillStyle = grd; g.fill();
- for (let i = 0; i < 6; i++){
- const a = t * 1.7 + i / 6 * TAU;
- const rr = r * (0.78 + 0.1 * Math.sin(t * 3 + i));
- const x = Math.cos(a)*rr, y = Math.sin(a)*rr;
- g.beginPath(); g.moveTo(0,0); g.lineTo(x,y);
- g.strokeStyle = hexA(T.c, 0.28); g.lineWidth = 1; g.stroke();
- g.beginPath(); g.arc(x, y, r*0.17, 0, TAU);
- g.fillStyle = T.c2; g.fill();
- }
- break;
- }
-
- case 'swarmling': {
- g.rotate(o ? Math.atan2(o.vy, o.vx) : t * 3);
- g.beginPath(); g.moveTo(r*1.1, 0); g.lineTo(-r*0.7, r*0.7); g.lineTo(-r*0.3, 0); g.lineTo(-r*0.7, -r*0.7);
- g.closePath(); g.fillStyle = T.c; g.fill();
- g.strokeStyle = '#fff'; g.lineWidth = 0.8; g.stroke();
- break;
- }
-
- case 'gravity': {
- g.beginPath(); g.arc(0,0,r,0,TAU);
- const grd = g.createRadialGradient(0,0,r*0.15,0,0,r);
- grd.addColorStop(0,'#0a0f2e'); grd.addColorStop(0.7,'#12205e'); grd.addColorStop(1, T.c);
- g.fillStyle = grd; g.fill();
- for (let i = 0; i < 3; i++){
- const ph = ((t * 0.55) + i / 3) % 1;
- const rr = r * (1.35 - ph * 1.15);
- g.beginPath(); g.arc(0, 0, Math.max(1, rr), 0, TAU);
- g.strokeStyle = hexA(T.c2, 0.75 * ph); g.lineWidth = 1.8; g.stroke();
- }
- g.beginPath(); g.arc(0,0,r*0.2,0,TAU); g.fillStyle = '#fff'; g.fill();
- break;
- }
-
- case 'phase': {
- for (let i = 1; i <= 2; i++){
- const off = Math.sin(t * 5 + i) * r * 0.5;
- g.beginPath(); g.arc(off, -off * 0.4, r * 0.9, 0, TAU);
- g.fillStyle = hexA(T.c, 0.13); g.fill();
- }
- g.beginPath(); g.arc(0,0,r,0,TAU);
- const grd = g.createRadialGradient(0,0,0,0,0,r);
- grd.addColorStop(0, hexA(T.c2, 0.9)); grd.addColorStop(0.6, hexA(T.c, 0.5)); grd.addColorStop(1, hexA(T.c, 0.08));
- g.fillStyle = grd; g.fill();
- g.save(); g.rotate(t * 1.4);
- g.setLineDash([6, 7]); g.beginPath(); g.arc(0,0,r*0.95,0,TAU);
- g.strokeStyle = T.c2; g.lineWidth = 1.8; g.stroke();
- g.restore();
- break;
- }
-
- case 'blackhole': {
- g.save(); g.rotate(t * 1.5);
- for (let i = 0; i < 24; i++){
- const a0 = i / 24 * TAU;
- g.beginPath();
- g.arc(0, 0, r * 0.86, a0, a0 + 0.2);
- g.strokeStyle = hexA(i % 2 ? T.c2 : T.c, 0.35 + 0.6 * Math.abs(Math.sin(a0 * 2 + t)));
- g.lineWidth = r * 0.24; g.stroke();
- }
- g.restore();
- g.beginPath(); g.arc(0,0,r*0.62,0,TAU); g.fillStyle = '#000'; g.fill();
- g.strokeStyle = hexA(T.c2, 0.9); g.lineWidth = 1.4; g.stroke();
- break;
- }
-
- case 'inverter': {
- g.rotate(Math.sin(t * 1.5) * 0.55);
- for (const half of [0, 1]){
- g.beginPath(); g.moveTo(0, 0);
- g.arc(0, 0, r * 0.9, -Math.PI/2 + half * Math.PI, Math.PI/2 + half * Math.PI);
- g.closePath();
- const grd = g.createLinearGradient(0, -r, 0, r);
- grd.addColorStop(0, half ? T.c2 : T.c);
- grd.addColorStop(1, half ? '#07394d' : '#4a2400');
- g.fillStyle = grd; g.fill();
- }
- g.save(); g.rotate(t * 2.2);
- g.beginPath(); g.arc(0,0,r*0.62, 0.35, 2.5); g.strokeStyle = T.c2; g.lineWidth = r*0.15; g.stroke();
- g.restore();
- g.save(); g.rotate(-t * 2.7);
- g.beginPath(); g.arc(0,0,r*0.62, 0.35 + Math.PI, 2.5 + Math.PI); g.strokeStyle = T.c; g.lineWidth = r*0.15; g.stroke();
- g.restore();
- g.fillStyle = '#fff';
- for (const s of [-1, 1]){
- g.beginPath();
- g.moveTo(s * r * 0.16, -r * 0.22); g.lineTo(s * r * 0.46, 0); g.lineTo(s * r * 0.16, r * 0.22);
- g.closePath(); g.fill();
- }
- break;
- }
-
- case 'shatter': {
- g.rotate(t * 0.4);
- const pts = [];
- const JAG = [1, 0.62, 0.94, 0.55, 1.05, 0.68, 0.86];
- for (let i = 0; i < JAG.length; i++){
- const a = i / JAG.length * TAU;
- const rr = r * JAG[i];
- pts.push([Math.cos(a)*rr, Math.sin(a)*rr]);
- }
- g.beginPath(); pts.forEach((p,i)=> i ? g.lineTo(p[0],p[1]) : g.moveTo(p[0],p[1])); g.closePath();
- const grd = g.createLinearGradient(-r,-r,r,r);
- grd.addColorStop(0, T.c2); grd.addColorStop(0.45, T.c); grd.addColorStop(1, '#520045');
- g.fillStyle = grd; g.fill();
- g.strokeStyle = T.c2; g.lineWidth = 1.4; g.stroke();
- g.beginPath();
- pts.forEach(p => { g.moveTo(0,0); g.lineTo(p[0],p[1]); });
- g.strokeStyle = hexA(T.c2, 0.4); g.lineWidth = 0.8; g.stroke();
- break;
- }
-
- case 'timebubble': {
- g.beginPath(); g.arc(0,0,r,0,TAU);
- const grd = g.createRadialGradient(-r*0.3,-r*0.3,r*0.05,0,0,r);
- grd.addColorStop(0,'#ffffff'); grd.addColorStop(0.35,hexA(T.c,0.75)); grd.addColorStop(1,hexA(T.c,0.16));
- g.fillStyle = grd; g.fill();
- g.strokeStyle = hexA('#ffffff',0.75); g.lineWidth = 1.4; g.stroke();
- g.strokeStyle = '#04303a'; g.lineWidth = 2.2; g.lineCap = 'round';
- g.beginPath(); g.moveTo(0,0); g.lineTo(Math.cos(t*2 - 1.57) * r * 0.55, Math.sin(t*2 - 1.57) * r * 0.55); g.stroke();
- g.beginPath(); g.moveTo(0,0); g.lineTo(Math.cos(t*0.5 - 1.57) * r * 0.34, Math.sin(t*0.5 - 1.57) * r * 0.34); g.stroke();
- break;
- }
-
- case 'chain': {
- const bl = 0.5 + 0.5 * Math.sin(t * 9);
- g.beginPath(); g.arc(0,0,r*0.72,0,TAU);
- const grd = g.createRadialGradient(0,0,0,0,0,r*0.72);
- grd.addColorStop(0,'#fff8e6'); grd.addColorStop(0.4, T.c2); grd.addColorStop(1, T.c);
- g.fillStyle = grd; g.fill();
- for (let i = 0; i < 2; i++){
- const ph = ((t * 1.1) + i / 2) % 1;
- g.beginPath(); g.arc(0,0, r * (0.75 + ph * 0.5), 0, TAU);
- g.strokeStyle = hexA(T.c, (1 - ph) * 0.8); g.lineWidth = 2; g.stroke();
- }
- g.beginPath(); g.arc(0,0,r*0.28,0,TAU);
- g.fillStyle = 'rgba(255,255,255,' + (0.35 + bl * 0.65) + ')'; g.fill();
- break;
- }
-
- case 'mirror': {
- g.rotate(t * 0.5);
- g.beginPath(); g.arc(0,0,r,0,TAU);
- const grd = g.createLinearGradient(-r,-r,r,r);
- grd.addColorStop(0,'#ffffff'); grd.addColorStop(0.3,'#7f97b8');
- grd.addColorStop(0.5,'#ffffff'); grd.addColorStop(0.7,'#5d7395'); grd.addColorStop(1,'#ffffff');
- g.fillStyle = grd; g.fill();
- g.beginPath(); g.moveTo(0,-r); g.lineTo(0,r);
- g.strokeStyle = 'rgba(10,16,30,.55)'; g.lineWidth = 1.6; g.stroke();
- const sw = ((t * 0.6) % 1) * 2 - 1;
- g.save(); g.beginPath(); g.arc(0,0,r,0,TAU); g.clip();
- g.beginPath(); g.moveTo(sw*r - r*0.2, -r); g.lineTo(sw*r + r*0.2, -r); g.lineTo(sw*r + r*0.5, r); g.lineTo(sw*r + r*0.1, r);
- g.closePath(); g.fillStyle = 'rgba(255,255,255,.55)'; g.fill();
- g.restore();
- g.beginPath(); g.arc(0,0,r,0,TAU); g.strokeStyle = '#fff'; g.lineWidth = 1.3; g.stroke();
- break;
- }
-
- case 'rubber': {
- const sq = o ? (o.squish || 0) : 0.35 + 0.35 * Math.sin(t * 4);
- const a = o ? Math.atan2(o.vy, o.vx) : t;
- g.rotate(a);
- g.beginPath();
- g.ellipse(0, 0, r * (1 + sq * 0.35), r * (1 - sq * 0.3), 0, 0, TAU);
- const grd = g.createRadialGradient(-r*0.3,-r*0.3,r*0.08,0,0,r);
- grd.addColorStop(0,'#ffffff'); grd.addColorStop(0.35, T.c); grd.addColorStop(1,'#00543a');
- g.fillStyle = grd; g.fill();
- g.strokeStyle = T.c2; g.lineWidth = 1.6; g.stroke();
- if (o && o.hp != null){
- for (let i = 0; i < o.hp; i++){
- g.beginPath(); g.arc(-r*0.35 + i * r*0.26, r*0.42, r*0.09, 0, TAU);
- g.fillStyle = '#022'; g.fill();
+function keyDown(list){ for(const k of list) if(keys[k]) return true; return false; }
+
+const touch = { on:false, stickId:-1, ox:0, oy:0, cx:0, cy:0, fireId:-1, btn:null, lastTapRight:0 };
+
+function primeAudio(){ Snd.init(); Snd.resume(); Engine.start(); }
+
+window.addEventListener('keydown', e=>{
+ if(['ArrowUp','ArrowDown','ArrowLeft','ArrowRight','Space'].includes(e.code)) e.preventDefault();
+ keys[e.code]=true;
+ primeAudio();
+
+ if(e.repeat) return;
+
+ if(e.code==='KeyM'){ toggleSound(); }
+ if(e.code==='KeyK'){ toggleShake(); }
+ if(e.code==='KeyP' || e.code==='Escape'){
+ if(G.state==='play'||G.state==='pause') togglePause();
+ }
+ if(G.state==='play'){
+ if(e.code==='ShiftLeft'||e.code==='ShiftRight') firePulse();
+ if(e.code==='KeyH') hyperspace();
+ }
+ if(e.code==='Space'||e.code==='Enter'||e.code==='NumpadEnter'){
+ if(G.state==='start' || G.state==='over') startGame();
+ }
+}, {passive:false});
+window.addEventListener('keyup', e=>{ keys[e.code]=false; });
+window.addEventListener('blur', ()=>{ for(const k in keys) keys[k]=false; if(G.state==='play') togglePause(); });
+document.addEventListener('visibilitychange', ()=>{
+ if(document.hidden && G.state==='play') togglePause();
+});
+
+/* --- touch --- */
+function touchButtons(){
+ const s = clamp(Math.min(W,H)*0.085, 44, 74);
+ const pad = s*0.55;
+ return [
+ { id:'pulse', x: W - pad - s*0.5, y: H - pad - s*0.5, r: s*0.5, label:'PULSE' },
+ { id:'warp', x: W - pad - s*1.75, y: H - pad - s*0.5, r: s*0.5, label:'WARP' }
+ ];
+}
+function hitButton(x,y){
+ for(const b of touchButtons()) if(Math.hypot(x-b.x,y-b.y) < b.r*1.25) return b;
+ return null;
+}
+cv.addEventListener('pointerdown', e=>{
+ primeAudio();
+ if(e.pointerType==='mouse') return; // virtual stick is touch/pen only
+ touch.on = true;
+ const x = e.clientX, y = e.clientY;
+ if(G.state !== 'play') return;
+ cv.setPointerCapture && cv.setPointerCapture(e.pointerId);
+
+ const b = hitButton(x,y);
+ if(b){
+ touch.btn = b.id;
+ if(b.id==='pulse') firePulse(); else hyperspace();
+ return;
+ }
+ if(x < W*0.5 && touch.stickId===-1){
+ touch.stickId = e.pointerId; touch.ox=x; touch.oy=y; touch.cx=x; touch.cy=y;
+ } else if(touch.fireId===-1){
+ touch.fireId = e.pointerId;
+ }
+}, {passive:true});
+cv.addEventListener('pointermove', e=>{
+ if(e.pointerId===touch.stickId){ touch.cx=e.clientX; touch.cy=e.clientY; }
+}, {passive:true});
+function endPointer(e){
+ if(e.pointerId===touch.stickId) touch.stickId=-1;
+ if(e.pointerId===touch.fireId) touch.fireId=-1;
+ touch.btn = null;
+}
+cv.addEventListener('pointerup', endPointer, {passive:true});
+cv.addEventListener('pointercancel', endPointer, {passive:true});
+cv.addEventListener('contextmenu', e=> e.preventDefault());
+
+/* --- gamepad --- */
+let padPrev = {};
+function readGamepad(inp){
+ if(!navigator.getGamepads) return;
+ const pads = navigator.getGamepads();
+ for(const p of pads){
+ if(!p) continue;
+ const ax = p.axes[0]||0, ay = p.axes[1]||0;
+ if(ax < -0.28) inp.left = true;
+ if(ax > 0.28) inp.right = true;
+ const b = p.buttons;
+ const on = i => b[i] && (b[i].pressed || b[i].value>0.4);
+ if(on(0) || on(7) || ay < -0.5) inp.up = true;
+ if(on(2) || on(6)) inp.fire = true;
+ if(on(1) && !padPrev.hyper) hyperspace();
+ if(on(3) && !padPrev.pulse) firePulse();
+ if(on(9) && !padPrev.start) togglePause();
+ padPrev = { hyper:on(1), pulse:on(3), start:on(9) };
+ return;
+ }
+}
+
+function readInput(){
+ const inp = { left:false, right:false, up:false, down:false, fire:false, aim:null };
+ if(keyDown(CODES.left)) inp.left = true;
+ if(keyDown(CODES.right)) inp.right = true;
+ if(keyDown(CODES.up)) inp.up = true;
+ if(keyDown(CODES.down)) inp.down = true;
+ if(keyDown(CODES.fire)) inp.fire = true;
+ readGamepad(inp);
+ if(touch.stickId!==-1){
+ const dx = touch.cx-touch.ox, dy = touch.cy-touch.oy;
+ const m = Math.hypot(dx,dy);
+ if(m > 10){ inp.aim = Math.atan2(dy,dx); if(m > 26) inp.up = true; }
+ }
+ if(touch.fireId!==-1) inp.fire = true;
+ return inp;
+}
+
+/* ============================================================
+ PARTICLES / FX
+ ============================================================ */
+function spark(x,y,col,opt){
+ if(parts.length > partCap()) return;
+ opt = opt || {};
+ const life = opt.life || rnd(48,18);
+ parts.push({
+ x,y,
+ vx: opt.vx!==undefined? opt.vx : rnd(2.4,-2.4),
+ vy: opt.vy!==undefined? opt.vy : rnd(2.4,-2.4),
+ life, max:life, r: opt.r || rnd(3.2,.9),
+ col, drag: opt.drag===undefined? 0.975 : opt.drag, grav: opt.grav||0
+ });
+}
+function burst(x,y,n,col,spd,opt){
+ opt = opt || {};
+ for(let i=0;i<n;i++){
+ const a = rnd(TAU), s = rnd(spd, spd*0.15);
+ spark(x,y,col,Object.assign({}, opt, { vx:Math.cos(a)*s, vy:Math.sin(a)*s }));
+ }
+}
+function ringFx(x,y,col,r0,r1,life,width){
+ rings.push({x,y,col,r:r0,r1,cur:r0,life,max:life,w:width||3});
+}
+function floatText(x,y,txt,col,size){
+ // during a big cascade the same label fires dozens of times — collapse the
+ // duplicates instead of painting an unreadable stack
+ for(let i=floats.length-1;i>=0;i--){
+ const f=floats[i];
+ if(f.txt===txt && f.life>44 && Math.hypot(f.x-x,f.y-y)<110) return;
+ }
+ if(floats.length>14) floats.shift();
+ floats.push({x,y,txt,col,life:70,max:70,size:size||16,vy:-0.7});
+}
+function shake(v){ if(!SET.shake) return; G.shake = Math.min(40, G.shake + v); }
+function flashScreen(col,amt){ G.flash = Math.max(G.flash, SET.shake? amt : amt*0.45); G.flashCol = col; }
+function hitStop(f){ if(SET.shake) G.hitStop = Math.max(G.hitStop, f); }
+
+let toastTxt='', toastT=0;
+function toast(t){ toastTxt=t; toastT=95; }
+
+/* ============================================================
+ SPAWNING
+ ============================================================ */
+function orbSizeFor(tier){ return tier===4? 74 : tier===3? 52 : tier===2? 34 : 21; }
+
+function waveKinds(){
+ const w = G.wave;
+ const pool = ['swarm','shard','mirror','rubber'];
+ if(w>=2) pool.push('phase','chain');
+ if(w>=3) pool.push('gravity','time');
+ if(w>=4) pool.push('reverse','nova');
+ if(w>=5) pool.push('blackhole','warp');
+ if(w>=6) pool.push('sentry');
+ if(w>=7) return ORB_KEYS.slice();
+ return pool;
+}
+
+function makeOrb(x,y,tier,kind,vx,vy){
+ kind = kind || pick(waveKinds());
+ const T = ORB_TYPES[kind];
+ const r = orbSizeFor(tier);
+ const tierSpd = tier===4?0.62 : tier===3?0.85 : tier===2?1.05 : 1.35;
+ const spd = lerp(0.5,1.7,Math.random()) * (1 + (G.wave-1)*0.055) * tierSpd;
+ const a = rnd(TAU);
+ const hpMul = tier===4?3 : tier===3?1 : tier===2?0.8 : 0.6;
+ const o = {
+ kind, tier, r, x, y,
+ vx: vx!==undefined? vx : Math.cos(a)*spd,
+ vy: vy!==undefined? vy : Math.sin(a)*spd,
+ hp: tier===4 ? Math.max(5, Math.round(T.hp*hpMul)+2) : Math.max(1, Math.round(T.hp*hpMul)),
+ maxhp:0, elder: tier===4,
+ a: rnd(TAU), spin: rnd(0.03,-0.03),
+ pulse: rnd(TAU), t:0, wobble: rnd(TAU),
+ hitFlash:0, bounces:0, clones:0, chainT:-1,
+ verts: []
+ };
+ o.maxhp = o.hp;
+ const nv = 11 + tier*2;
+ for(let i=0;i<nv;i++) o.verts.push(rnd(1.14,0.84));
+ return o;
+}
+
+function edgeSpawn(minDistFromShip){
+ let x,y,guard=0;
+ do{
+ const edge = rndi(0,4);
+ if(edge===0){ x=rnd(W); y=-30; }
+ else if(edge===1){ x=W+30; y=rnd(H); }
+ else if(edge===2){ x=rnd(W); y=H+30; }
+ else { x=-30; y=rnd(H); }
+ guard++;
+ } while(guard<14 && Math.hypot(x-ship.x, y-ship.y) < (minDistFromShip||200));
+ return [x,y];
+}
+
+function spawnWave(){
+ const n = Math.min(12, 3 + Math.floor(G.wave*0.9));
+ for(let i=0;i<n;i++){
+ const [x,y] = edgeSpawn(200);
+ orbs.push(makeOrb(x,y,3));
+ }
+ const elder = G.wave % 5 === 0;
+ if(elder){
+ const [ex,ey] = edgeSpawn(300);
+ const o = makeOrb(ex,ey,4);
+ // rubber elders would be pinned to the bounds on spawn — keep them wrapping-friendly
+ if(o.kind==='rubber'){ o.x = clamp(o.x, o.r+4, W-o.r-4); o.y = clamp(o.y, o.r+4, H-o.r-4); }
+ orbs.push(o);
+ }
+ G.waveBannerT = 140;
+ G.waveBannerTxt = 'WAVE ' + G.wave;
+ G.waveBannerSub = elder ? 'AN ELDER ORB IS AWAKE' : '';
+ Snd.wave(G.wave);
+}
+
+/* ============================================================
+ SCORE
+ ============================================================ */
+function addScore(n, x, y){
+ const mult = 1 + Math.min(10,G.combo)*0.1;
+ const gained = Math.round(n*mult);
+ G.score += gained;
+ if(x!==undefined && n>=60) floatText(x, y-10, '+'+gained, '#ffffff', 15);
+ while(G.score >= G.nextLife){
+ G.lives++; G.nextLife += 6000;
+ Snd.extraLife(); toast('EXTRA LIFE');
+ flashScreen('#8effc0',.2);
+ }
+}
+function bumpCombo(){
+ G.combo++; G.comboT = 75;
+ if(G.combo > G.bestCombo) G.bestCombo = G.combo;
+}
+
+/* ============================================================
+ ORB DESTRUCTION — the wild behaviours
+ ============================================================ */
+function splitOrb(o){
+ if(o.tier<=1 || orbs.length>70) return;
+ for(let i=0;i<2;i++){
+ const a = rnd(TAU);
+ const s = rnd(2.1,0.8);
+ const kind = Math.random()<0.35 ? pick(waveKinds()) : o.kind;
+ const child = makeOrb(
+ o.x + Math.cos(a)*o.r*0.5, o.y + Math.sin(a)*o.r*0.5, o.tier-1, kind,
+ o.vx*0.5 + Math.cos(a)*s, o.vy*0.5 + Math.sin(a)*s);
+ if(child.kind==='rubber'){ child.x = clamp(child.x, child.r, W-child.r); child.y = clamp(child.y, child.r, H-child.r); }
+ orbs.push(child);
+ }
+}
+
+function dropPowerup(x,y,forced){
+ if(!forced && Math.random() > 0.11) return;
+ const kind = forced ? pick(['spread','rapid','shield','pulse']) : pick(POWER_BAG);
+ powerups.push({
+ x, y, vx:rnd(.7,-.7), vy:rnd(.7,-.7),
+ kind, r:14, life:780, ph:rnd(TAU), a:0
+ });
+}
+
+function spawnShockwave(x,y,rMax,dmg,speed,col){
+ waves.push({ x, y, r:8, rMax, dmg, speed, col:col||'#ffd9a0', band:22, hit:new Set() });
+}
+
+function destroyOrb(o, silentCombo){
+ const idx = orbs.indexOf(o);
+ if(idx<0) return;
+ orbs.splice(idx,1);
+
+ const T = ORB_TYPES[o.kind];
+ const M = o.elder ? 2 : 1; // elder orbs do everything twice as hard
+ const tierPts = o.tier===4?3 : o.tier===3?1 : o.tier===2?1.4 : 2.0;
+ addScore(Math.round(T.pts * tierPts), o.x, o.y);
+ G.orbsBroken++;
+
+ Snd.orbHit(o.kind);
+ ringFx(o.x,o.y,T.glow, o.r*0.5, o.r*3.4, 34, 3);
+ burst(o.x,o.y, 14 + o.tier*8, T.c1, 2.6+o.tier*0.5, {life:rnd(56,24)});
+ burst(o.x,o.y, 8 + o.tier*5, T.c2, 4.2+o.tier, {life:rnd(40,16), r:rnd(2.4,.8)});
+ shake(3 + o.tier*2.2);
+
+ if(o.elder){
+ flashScreen('#ffffff',.28); shake(18); hitStop(5);
+ floatText(o.x, o.y-o.r-18, 'ELDER DOWN', '#fff2b0', 22);
+ spawnShockwave(o.x,o.y, 320, 1, 9, T.glow);
+ dropPowerup(o.x,o.y,true);
+ } else {
+ dropPowerup(o.x,o.y,false);
+ }
+
+ let didSplit = false;
+
+ switch(o.kind){
+ /* 1. SWARM — homing hunters */
+ case 'swarm': {
+ const n = (3 + o.tier*2) * M;
+ for(let i=0;i<n;i++){
+ const a = (i/n)*TAU + rnd(.5);
+ hunters.push({
+ x:o.x+Math.cos(a)*o.r*0.6, y:o.y+Math.sin(a)*o.r*0.6,
+ vx:Math.cos(a)*2.4, vy:Math.sin(a)*2.4,
+ r:6, life:460, max:460, a, wig:rnd(TAU), spd: 0.095 + G.wave*0.0035
+ });
}
+ floatText(o.x,o.y,'SWARM!','#ffcb5e',18);
+ flashScreen('#ffb454',.16);
+ break;
}
- break;
- }
-
- case 'nova': {
- g.rotate(t * 0.9);
- const spikes = 8;
- g.beginPath();
- for (let i = 0; i < spikes * 2; i++){
- const a = i / (spikes * 2) * TAU;
- const rr = i % 2 ? r * 0.42 : r * (1 + 0.14 * Math.sin(t * 6 + i));
- i ? g.lineTo(Math.cos(a)*rr, Math.sin(a)*rr) : g.moveTo(Math.cos(a)*rr, Math.sin(a)*rr);
- }
- g.closePath();
- const grd = g.createRadialGradient(0,0,0,0,0,r);
- grd.addColorStop(0,'#ffffff'); grd.addColorStop(0.4, T.c2); grd.addColorStop(1, T.c);
- g.fillStyle = grd; g.fill();
- g.beginPath(); g.arc(0,0,r*0.3,0,TAU); g.fillStyle = '#fff'; g.fill();
- break;
- }
-
- default: {
- g.beginPath(); g.arc(0,0,r,0,TAU); g.fillStyle = T.c; g.fill();
- }}
- g.restore();
-}
-
-/* ---------------------------------------------------------------- world --- */
-let state = 'start'; // start | playing | paused | gameover
-let score = 0, lives = 3, wave = 0, nextExtra = 5000;
-let best = parseInt(localStorage.getItem('wildorbs.best') || '0', 10) || 0;
-let orbs = [], bullets = [], shards = [], particles = [], effects = [], floaters = [], stars = [];
-let ship = null;
-let shake = 0, reverseT = 0, hitFlash = 0, waveTransition = 0, gameOverDelay = 0;
-let combo = 0, comboTimer = 0, elapsed = 0;
-let bubbles = []; // cached time-bubbles for the slow-field lookup
-
-const PARTICLE_CAP = 900;
-function addShake(v){ shake = clamp(shake + v, 0, 1.2); }
-function comboMult(){ return clamp(1 + Math.floor(combo / 4), 1, 8); }
-
-/* ------------------------------------------------------------------ ship -- */
-class Ship {
- constructor(){
- this.x = W/2; this.y = H/2; this.a = -Math.PI/2;
- this.vx = 0; this.vy = 0; this.r = 13;
- this.cool = 0; this.invuln = 3; this.dead = false; this.deadT = 0;
- this.thrusting = false; this.flame = 0;
- }
- update(dt){
- if (this.dead){
- this.deadT -= dt;
- if (this.deadT <= 0){
- if (lives <= 0){ endGame(); }
- else { this.respawn(); }
- }
- return;
- }
- const rev = reverseT > 0 ? -1 : 1;
- let turn = 0;
- if (keys['ArrowLeft'] || keys['KeyA']) turn -= 1;
- if (keys['ArrowRight'] || keys['KeyD']) turn += 1;
- this.a += turn * rev * 3.7 * dt;
-
- this.thrusting = !!(keys['ArrowUp'] || keys['KeyW']);
- const brake = !!(keys['ArrowDown'] || keys['KeyS']);
- if (this.thrusting){
- const acc = 430 * rev;
- this.vx += Math.cos(this.a) * acc * dt;
- this.vy += Math.sin(this.a) * acc * dt;
- this.flame = Math.min(1, this.flame + dt * 8);
- if (Math.random() < dt * 60){
- const ba = this.a + Math.PI + rand(-0.35, 0.35);
- addParticle(this.x + Math.cos(ba) * 14, this.y + Math.sin(ba) * 14,
- Math.cos(ba) * rand(70, 190) + this.vx * 0.3, Math.sin(ba) * rand(70, 190) + this.vy * 0.3,
- rand(2.5, 5), rand(0.25, 0.5), rev > 0 ? '#38f5d0' : '#ff9a1f');
- }
- } else this.flame = Math.max(0, this.flame - dt * 6);
- Snd.thrust(this.thrusting && state === 'playing');
-
- if (brake){ this.vx *= Math.pow(0.15, dt); this.vy *= Math.pow(0.15, dt); }
-
- applyFields(this, dt, 'ship');
-
- const drag = Math.pow(0.55, dt);
- this.vx *= drag; this.vy *= drag;
- const sp = Math.hypot(this.vx, this.vy), max = 520;
- if (sp > max){ this.vx = this.vx / sp * max; this.vy = this.vy / sp * max; }
-
- const tf = slowAt(this.x, this.y, true);
- this.x += this.vx * dt * tf;
- this.y += this.vy * dt * tf;
- wrapPos(this);
-
- if (this.invuln > 0) this.invuln -= dt;
- if (this.cool > 0) this.cool -= dt;
- if (keys['Space'] && this.cool <= 0) this.fire();
- }
- fire(){
- this.cool = 0.155;
- const nx = Math.cos(this.a), ny = Math.sin(this.a);
- const b = new Bullet(this.x + nx * 16, this.y + ny * 16,
- nx * 660 + this.vx * 0.35, ny * 660 + this.vy * 0.35);
- bullets.push(b);
- this.vx -= nx * 26; this.vy -= ny * 26; // recoil
- for (let i = 0; i < 5; i++){
- const a = this.a + rand(-0.5, 0.5);
- addParticle(this.x + nx * 16, this.y + ny * 16,
- Math.cos(a) * rand(80, 260), Math.sin(a) * rand(80, 260), rand(1.6, 3), rand(0.1, 0.22), '#ffe98a');
- }
- Snd.shoot();
- }
- kill(){
- if (this.dead || this.invuln > 0) return;
- this.dead = true; this.deadT = 1.5;
- lives--;
- hitFlash = 1; addShake(1.0);
- Snd.playerHit(); Snd.thrust(false);
- for (let i = 0; i < 70; i++){
- const a = rand(0, TAU), s = rand(60, 520);
- addParticle(this.x, this.y, Math.cos(a)*s, Math.sin(a)*s, rand(2, 6), rand(0.5, 1.3),
- pick(['#ffffff','#38f5d0','#ff2f6e','#ffe98a']));
- }
- spawnRing(this.x, this.y, 240, '#ffffff', 0.8);
- combo = 0;
- }
- respawn(){
- this.dead = false; this.x = W/2; this.y = H/2;
- this.vx = this.vy = 0; this.a = -Math.PI/2; this.invuln = 3;
- Snd.respawn();
- spawnRing(this.x, this.y, 140, '#38f5d0', 0.6);
- }
- draw(g){
- if (this.dead) return;
- const blink = this.invuln > 0 && (Math.floor(elapsed * 12) % 2 === 0);
- g.save();
- g.translate(this.x, this.y);
- g.rotate(this.a);
- if (this.flame > 0.02){
- const fl = this.flame * (0.7 + Math.random() * 0.5);
- g.beginPath();
- g.moveTo(-9, -5.5); g.lineTo(-9 - 26 * fl, 0); g.lineTo(-9, 5.5);
- g.closePath();
- const grd = g.createLinearGradient(-9, 0, -9 - 30 * fl, 0);
- grd.addColorStop(0, reverseT > 0 ? '#ffd08a' : '#bffff2');
- grd.addColorStop(0.4, reverseT > 0 ? '#ff9a1f' : '#38f5d0');
- grd.addColorStop(1, 'rgba(0,0,0,0)');
- g.fillStyle = grd; g.fill();
- }
- if (!blink){
- g.beginPath();
- g.moveTo(17, 0); g.lineTo(-11, 10.5); g.lineTo(-5, 0); g.lineTo(-11, -10.5);
- g.closePath();
- const grd = g.createLinearGradient(-11, 0, 17, 0);
- grd.addColorStop(0, '#123a4d'); grd.addColorStop(0.6, '#d8fbff'); grd.addColorStop(1, '#ffffff');
- g.fillStyle = grd; g.fill();
- g.strokeStyle = '#8ff6ff'; g.lineWidth = 1.4; g.stroke();
- g.beginPath(); g.arc(2, 0, 3, 0, TAU); g.fillStyle = '#38f5d0'; g.fill();
- }
- g.restore();
- if (this.invuln > 0){
- const p = 0.35 + 0.25 * Math.sin(elapsed * 9);
- g.beginPath(); g.arc(this.x, this.y, 26 + Math.sin(elapsed * 5) * 2, 0, TAU);
- g.strokeStyle = hexA('#38f5d0', p); g.lineWidth = 1.6; g.stroke();
- }
- }
-}
-
-/* --------------------------------------------------------------- bullet --- */
-class Bullet {
- constructor(x, y, vx, vy){
- this.x = x; this.y = y; this.vx = vx; this.vy = vy;
- this.life = 1.25; this.dead = false; this.trail = [];
- }
- update(dt){
- applyFields(this, dt, 'bullet');
- const tf = slowAt(this.x, this.y);
- this.x += this.vx * dt * tf; this.y += this.vy * dt * tf;
- this.trail.push(this.x, this.y);
- if (this.trail.length > 12) this.trail.splice(0, 2);
- wrapPos(this);
- this.life -= dt;
- if (this.life <= 0) this.dead = true;
- }
- draw(g){
- const n = this.trail.length;
- if (n >= 6){
- g.beginPath(); let started = false;
- for (let i = 0; i < n; i += 2){
- const px = this.trail[i], py = this.trail[i+1];
- if (Math.abs(px - this.x) > W * 0.5 || Math.abs(py - this.y) > H * 0.5){ started = false; continue; }
- if (started) g.lineTo(px, py); else { g.moveTo(px, py); started = true; }
+ /* 2. GRAVITY WELL */
+ case 'gravity': {
+ wells.push({ x:o.x, y:o.y, r:(210 + o.tier*40)*(o.elder?1.4:1), str:(0.55 + o.tier*0.18)*M, life:300, max:300, a:0 });
+ floatText(o.x,o.y,'GRAVITY WELL','#57c8ff',17);
+ ringFx(o.x,o.y,'#8de7ff', 10, 260, 60, 5);
+ flashScreen('#57c8ff',.18);
+ break;
+ }
+ /* 3. PHASE — the blinks happen on hit; death is a violet implosion */
+ case 'phase': {
+ for(let i=0;i<3;i++){
+ const a=rnd(TAU);
+ ringFx(o.x+Math.cos(a)*30,o.y+Math.sin(a)*30,'#c9a6ff',4,90,30,2);
}
- g.lineTo(this.x, this.y);
- g.strokeStyle = 'rgba(255,233,138,.30)'; g.lineWidth = 2.2; g.lineCap = 'round'; g.stroke();
- }
- for (let i = 0; i < n; i += 2){
- const t = (i / Math.max(2, n - 2));
- const px = this.trail[i], py = this.trail[i+1];
- if (Math.abs(px - this.x) > W * 0.5 || Math.abs(py - this.y) > H * 0.5) continue;
- drawGlow(g, px, py, 4 + t * 4, '#ffe98a', 0.1 + t * 0.25);
- }
- drawGlow(g, this.x, this.y, 14, '#fff3b0', 0.85);
- g.beginPath(); g.arc(this.x, this.y, 2.4, 0, TAU);
- g.fillStyle = '#ffffff'; g.fill();
- }
-}
-
-/* ---------------------------------------------------------------- shard --- */
-class Shard {
- constructor(x, y, a, sp){
- this.x = x; this.y = y;
- this.vx = Math.cos(a) * sp; this.vy = Math.sin(a) * sp;
- this.r = 7; this.rot = rand(0, TAU); this.spin = rand(-9, 9);
- this.life = 7.5; this.dead = false;
- }
- update(dt){
- const tf = slowAt(this.x, this.y);
- applyFields(this, dt, 'orb');
- this.x += this.vx * dt * tf; this.y += this.vy * dt * tf;
- this.rot += this.spin * dt * tf;
- if (this.x < this.r){ this.x = this.r; this.vx = Math.abs(this.vx); this.bounce(); }
- if (this.x > W - this.r){ this.x = W - this.r; this.vx = -Math.abs(this.vx); this.bounce(); }
- if (this.y < this.r){ this.y = this.r; this.vy = Math.abs(this.vy); this.bounce(); }
- if (this.y > H - this.r){ this.y = H - this.r; this.vy = -Math.abs(this.vy); this.bounce(); }
- this.life -= dt;
- if (this.life <= 0){ this.dead = true; burst(this.x, this.y, '#ff43e0', '#ffd4f7', 8, 6); }
- if (Math.random() < dt * 22)
- addParticle(this.x, this.y, rand(-30,30), rand(-30,30), rand(1,2.4), rand(0.15,0.35), '#ff43e0');
- }
- bounce(){
- Snd.tone({type:'square', f0:1500, f1:600, dur:0.05, vol:0.045});
- burst(this.x, this.y, '#ff43e0', '#ffd4f7', 8, 4);
- }
- draw(g){
- g.save(); g.translate(this.x, this.y); g.rotate(this.rot);
- g.beginPath(); g.moveTo(9, 0); g.lineTo(-5, 6); g.lineTo(-2, 0); g.lineTo(-5, -6);
- g.closePath();
- g.fillStyle = '#ff43e0'; g.fill();
- g.strokeStyle = '#ffd4f7'; g.lineWidth = 1; g.stroke();
- g.restore();
- drawGlow(g, this.x, this.y, 16, '#ff43e0', 0.35);
- }
-}
-
-/* ------------------------------------------------------------------ orb --- */
-const TIER_SCALE = { 1:0.48, 2:0.72, 3:1 };
-class Orb {
- constructor(x, y, type, tier = 3, gen = 0){
- const T = TYPES[type];
- this.x = x; this.y = y; this.type = type; this.tier = tier; this.gen = gen;
- this.r = T.r * (TIER_SCALE[tier] || 1);
- this.hp = T.hp; this.maxhp = T.hp;
- this.spin = rand(0, TAU); this.spinV = rand(-0.9, 0.9);
- this.flash = 0; this.dead = false; this.squish = 0; this.ghostT = 0;
- this.life = T.life || 0;
- const a = rand(0, TAU);
- const base = rand(42, 92) * (T.spd || 1) * (1 + wave * 0.05) * (tier < 3 ? 1.25 : 1);
- this.vx = Math.cos(a) * base; this.vy = Math.sin(a) * base;
- this.born = 0.35; // brief spawn-in
- }
- update(dt){
- const T = TYPES[this.type];
- if (T.move) T.move(this, dt);
- applyFields(this, dt, 'orb');
- const tf = slowAt(this.x, this.y);
- this.x += this.vx * dt * tf;
- this.y += this.vy * dt * tf;
- this.spin += this.spinV * dt * tf;
- if (T.bounce){
- if (this.x < this.r){ this.x = this.r; this.vx = Math.abs(this.vx); this.wallHit(); }
- if (this.x > W - this.r){ this.x = W - this.r; this.vx = -Math.abs(this.vx); this.wallHit(); }
- if (this.y < this.r){ this.y = this.r; this.vy = Math.abs(this.vy); this.wallHit(); }
- if (this.y > H - this.r){ this.y = H - this.r; this.vy = -Math.abs(this.vy); this.wallHit(); }
- } else wrapPos(this);
- if (this.flash > 0) this.flash -= dt;
- if (this.ghostT > 0) this.ghostT -= dt;
- if (this.squish > 0) this.squish = Math.max(0, this.squish - dt * 2.4);
- if (this.born > 0) this.born -= dt;
- if (this.life > 0){
- this.life -= dt;
- if (this.life <= 0){ this.dead = true; burst(this.x, this.y, TYPES[this.type].c, TYPES[this.type].c2, this.r, 10); }
- }
- if (this.type === 'swarmling' && Math.random() < dt * 30)
- addParticle(this.x, this.y, -this.vx*0.2, -this.vy*0.2, rand(1,2.2), rand(0.15,0.3), '#b6ff5e');
- }
- wallHit(){
- this.squish = 1;
- burst(this.x, this.y, TYPES[this.type].c, TYPES[this.type].c2, this.r * 0.5, 6);
- Snd.tone({type:'sine', f0:420, f1:180, dur:0.08, vol:0.07});
- }
- draw(g){
- const T = TYPES[this.type];
- const spawnScale = this.born > 0 ? lerp(0.2, 1, 1 - this.born / 0.35) : 1;
- const r = this.r * spawnScale;
- drawGlow(g, this.x, this.y, r * 2.3, T.c, 0.30 + (this.flash > 0 ? 0.4 : 0));
- if (this.ghostT > 0) drawGlow(g, this.x, this.y, r * 3.4, T.c2, this.ghostT * 0.5);
- g.save();
- g.translate(this.x, this.y);
- g.scale(spawnScale, spawnScale);
- drawOrbBody(g, this.type, this.r, elapsed + this.spin, this);
- if (this.flash > 0){
- g.globalCompositeOperation = 'lighter';
- g.beginPath(); g.arc(0, 0, this.r, 0, TAU);
- g.fillStyle = 'rgba(255,255,255,' + clamp(this.flash * 5, 0, 0.75) + ')'; g.fill();
- g.globalCompositeOperation = 'source-over';
- }
- g.restore();
- // hp pips for multi-hit orbs
- if (this.maxhp > 1 && this.type !== 'rubber'){
- for (let i = 0; i < this.maxhp; i++){
- g.beginPath();
- g.arc(this.x - (this.maxhp - 1) * 4 + i * 8, this.y - r - 10, 2.2, 0, TAU);
- g.fillStyle = i < this.hp ? T.c2 : 'rgba(255,255,255,.18)';
- g.fill();
+ floatText(o.x,o.y,'PHASE OUT','#b48bff',16);
+ break;
+ }
+ /* 4. BLACK HOLE */
+ case 'blackhole': {
+ holes.push({ x:o.x, y:o.y, r:26, rMax:(34 + o.tier*8), pull:(400 + o.tier*55)*(o.elder?1.3:1),
+ life:360, max:360, a:0, eaten:0 });
+ floatText(o.x,o.y,'VOID OPENED','#a24fff',19);
+ flashScreen('#7a2fff',.30); shake(16);
+ ringFx(o.x,o.y,'#a24fff', 10, 340, 70, 6);
+ break;
+ }
+ /* 5. REVERSE CONTROLS */
+ case 'reverse': {
+ G.reverseT = Math.max(G.reverseT, 360*M);
+ G.reverseMax = Math.max(G.reverseMax||1, G.reverseT);
+ floatText(ship.x, ship.y-40,'CONTROLS REVERSED!','#ff62c0',20);
+ flashScreen('#ff62c0',.30); shake(11);
+ for(let i=0;i<3;i++) ringFx(o.x,o.y,'#ff8ad4', 8+i*20, 200+i*40, 44, 3);
+ break;
+ }
+ /* 6. SHARDS — ricocheting glass */
+ case 'shard': {
+ const n = (5 + o.tier*3) * M;
+ for(let i=0;i<n;i++){
+ const a=(i/n)*TAU+rnd(.4);
+ const s=rnd(5.4,2.6);
+ shards.push({ x:clamp(o.x,6,W-6)+Math.cos(a)*o.r*0.4, y:clamp(o.y,6,H-6)+Math.sin(a)*o.r*0.4,
+ vx:Math.cos(a)*s, vy:Math.sin(a)*s, r:5, life:420, max:420,
+ rot:rnd(TAU), rs:rnd(.28,-.28), bounce:0 });
}
+ floatText(o.x,o.y,'SHATTER','#c9f4ff',17);
+ break;
}
- }
-}
-
-/* --------------------------------------------------------------- effects -- */
-class GravityWell {
- constructor(x, y){ this.x = x; this.y = y; this.t = 0; this.life = 6.5; this.R = 340; this.dead = false; }
- update(dt){
- this.t += dt;
- if (this.t > this.life) this.dead = true;
- if (Math.random() < dt * 30){
- const a = rand(0, TAU), d = rand(this.R * 0.5, this.R);
- addParticle(this.x + Math.cos(a)*d, this.y + Math.sin(a)*d, -Math.cos(a)*160, -Math.sin(a)*160,
- rand(1.5,3), rand(0.5,1.0), '#7fa4ff');
- }
- }
- field(e, dt, kind){
- const [dx, dy] = dxy(e.x, e.y, this.x, this.y);
- const d = Math.hypot(dx, dy);
- if (d > this.R || d < 1) return;
- const fall = 1 - d / this.R;
- const k = kind === 'bullet' ? 1500 : kind === 'ship' ? 470 : 300;
- const f = k * fall * fall * dt;
- e.vx += (dx / d) * f; e.vy += (dy / d) * f;
- }
- draw(g){
- const fade = clamp(Math.min(this.t * 3, (this.life - this.t) * 2), 0, 1);
- drawGlow(g, this.x, this.y, this.R * 0.8, '#3b6bff', 0.14 * fade);
- for (let i = 0; i < 4; i++){
- const ph = ((this.t * 0.5) + i / 4) % 1;
- g.beginPath(); g.arc(this.x, this.y, this.R * (1 - ph), 0, TAU);
- g.strokeStyle = hexA('#8fb0ff', 0.32 * ph * fade); g.lineWidth = 1.6; g.stroke();
- }
- g.save(); g.translate(this.x, this.y); g.rotate(this.t * 1.6);
- for (let i = 0; i < 3; i++){
- g.beginPath();
- const rr = 26 + i * 9;
- g.arc(0, 0, rr, i * 2, i * 2 + 2.2);
- g.strokeStyle = hexA('#cddcff', 0.7 * fade); g.lineWidth = 2; g.stroke();
- }
- g.restore();
- drawGlow(g, this.x, this.y, 40, '#dbe6ff', 0.6 * fade);
- }
-}
-
-class BlackHole {
- constructor(x, y){ this.x = x; this.y = y; this.t = 0; this.life = 5; this.R = 330; this.core = 30; this.dead = false; }
- update(dt){
- this.t += dt;
- // devour
- for (const o of orbs){
- if (o.dead) continue;
- if (dist(o.x, o.y, this.x, this.y) < this.core * 1.1){
- addScore(TYPES[o.type].sc, o.x, o.y);
- o.dead = true;
- burst(o.x, o.y, TYPES[o.type].c, '#ffffff', o.r, 16);
- Snd.tone({type:'sine', f0:600, f1:60, dur:0.25, vol:0.14});
+ /* 7. TIME BUBBLE */
+ case 'time': {
+ bubbles.push({ x:o.x, y:o.y, r:(150 + o.tier*45)*(o.elder?1.35:1), life:380, max:380, ph:0 });
+ floatText(o.x,o.y,'SLOW FIELD','#66ffb0',17);
+ ringFx(o.x,o.y,'#9dffc9', 8, 220, 60, 4);
+ flashScreen('#66ffb0',.14);
+ break;
+ }
+ /* 8. CHAIN BOMB */
+ case 'chain': {
+ const R = (190 + o.tier*45) * M;
+ ringFx(o.x,o.y,'#ff7f5c', 8, R, 30, 7);
+ burst(o.x,o.y,30,'#ffd08a',7,{life:rnd(50,20)});
+ shake(12); flashScreen('#ff7f5c',.24);
+ floatText(o.x,o.y,'CHAIN!','#ff8f6b',20);
+ let d=0;
+ for(const t of orbs){
+ if(t.chainT>=0) continue;
+ if(wrapDist(o,t) < R + t.r) t.chainT = 6 + (d++)*3; // staged for a proper cascade
}
+ break;
}
- for (const b of bullets)
- if (!b.dead && dist(b.x, b.y, this.x, this.y) < this.core) b.dead = true;
- for (const s of shards)
- if (!s.dead && dist(s.x, s.y, this.x, this.y) < this.core) s.dead = true;
- if (ship && !ship.dead && ship.invuln <= 0 && dist(ship.x, ship.y, this.x, this.y) < this.core * 0.8) ship.kill();
-
- if (Math.random() < dt * 90){
- const a = rand(0, TAU), d = rand(this.core, this.R);
- addParticle(this.x + Math.cos(a)*d, this.y + Math.sin(a)*d,
- -Math.cos(a) * 280 + Math.cos(a + 1.57) * 200, -Math.sin(a) * 280 + Math.sin(a + 1.57) * 200,
- rand(1.5, 3.4), rand(0.4, 0.9), pick(['#a24bff','#e7c6ff','#ffffff']));
- }
- if (this.t > this.life){
- this.dead = true;
- effects.push(new Shockwave(this.x, this.y, 300, '#a24bff'));
- addShake(0.7);
- Snd.noise({filter:'bandpass', f0:120, f1:3000, dur:0.5, vol:0.3});
- }
- }
- field(e, dt, kind){
- const [dx, dy] = dxy(e.x, e.y, this.x, this.y);
- const d = Math.hypot(dx, dy);
- if (d > this.R || d < 1) return;
- const fall = 1 - d / this.R;
- const k = kind === 'bullet' ? 2100 : kind === 'ship' ? 900 : 1100;
- const f = k * fall * fall * dt;
- e.vx += (dx / d) * f; e.vy += (dy / d) * f;
- // tangential swirl, so things spiral rather than fall straight in
- e.vx += (-dy / d) * f * 0.55; e.vy += (dx / d) * f * 0.55;
- }
- draw(g){
- const fade = clamp(Math.min(this.t * 4, (this.life - this.t) * 3), 0, 1);
- drawGlow(g, this.x, this.y, this.R * 0.7, '#7a1fff', 0.18 * fade);
- g.save(); g.translate(this.x, this.y); g.rotate(this.t * 2.2);
- for (let i = 0; i < 30; i++){
- const a = i / 30 * TAU;
- g.beginPath(); g.arc(0, 0, this.core * 1.5, a, a + 0.16);
- g.strokeStyle = hexA(i % 2 ? '#e7c6ff' : '#a24bff', (0.25 + 0.7 * Math.abs(Math.sin(a * 3 + this.t * 4))) * fade);
- g.lineWidth = this.core * 0.5; g.stroke();
- }
- g.restore();
- const prevOp = g.globalCompositeOperation;
- g.globalCompositeOperation = 'source-over';
- g.beginPath(); g.arc(this.x, this.y, this.core * fade, 0, TAU);
- g.fillStyle = '#000'; g.fill();
- g.strokeStyle = hexA('#ffffff', 0.85 * fade); g.lineWidth = 1.6; g.stroke();
- g.globalCompositeOperation = prevOp;
- }
-}
-
-class TimeBubble {
- constructor(x, y){ this.x = x; this.y = y; this.t = 0; this.life = 7.5; this.R = 195; this.dead = false; }
- update(dt){
- this.t += dt;
- if (this.t > this.life) this.dead = true;
- if (Math.random() < dt * 18){
- const a = rand(0, TAU), d = rand(0, this.R);
- addParticle(this.x + Math.cos(a)*d, this.y + Math.sin(a)*d, rand(-12,12), rand(-22,-4),
- rand(1.4,2.8), rand(0.9,1.6), '#28e6ff');
- }
- }
- draw(g){
- const fade = clamp(Math.min(this.t * 3, (this.life - this.t) * 2), 0, 1);
- const rr = this.R * (this.t < 0.3 ? this.t / 0.3 : 1);
- drawGlow(g, this.x, this.y, rr, '#28e6ff', 0.10 * fade);
- g.beginPath(); g.arc(this.x, this.y, rr, 0, TAU);
- g.fillStyle = hexA('#28e6ff', 0.05 * fade); g.fill();
- g.strokeStyle = hexA('#a8f2ff', 0.5 * fade); g.lineWidth = 1.8; g.stroke();
- for (let i = 0; i < 2; i++){
- const ph = ((this.t * 0.35) + i / 2) % 1;
- g.beginPath(); g.arc(this.x, this.y, rr * (0.3 + ph * 0.7), 0, TAU);
- g.strokeStyle = hexA('#d3f8ff', 0.22 * (1 - ph) * fade); g.lineWidth = 1; g.stroke();
- }
- // slow clock in the middle
- g.save(); g.translate(this.x, this.y);
- g.beginPath(); g.arc(0, 0, 15, 0, TAU);
- g.strokeStyle = hexA('#d3f8ff', 0.45 * fade); g.lineWidth = 1.4; g.stroke();
- g.beginPath(); g.moveTo(0,0);
- g.lineTo(Math.cos(this.t * 1.1 - 1.57) * 11, Math.sin(this.t * 1.1 - 1.57) * 11);
- g.strokeStyle = hexA('#ffffff', 0.6 * fade); g.lineWidth = 1.6; g.stroke();
- g.restore();
- }
-}
-
-class Shockwave {
- constructor(x, y, maxR = 380, color = '#ff2f6e'){
- this.x = x; this.y = y; this.r = 10; this.maxR = maxR;
- this.color = color; this.hit = new Set(); this.dead = false;
- }
- update(dt){
- const prev = this.r;
- this.r += 780 * dt;
- if (this.r > this.maxR) this.dead = true;
- for (const o of orbs){
- if (o.dead || this.hit.has(o)) continue;
- const d = dist(o.x, o.y, this.x, this.y);
- if (d <= this.r && d >= prev - o.r - 20){
- this.hit.add(o);
- const [dx, dy] = dxy(this.x, this.y, o.x, o.y);
- const dd = Math.max(1, Math.hypot(dx, dy));
- o.vx += dx / dd * 200; o.vy += dy / dd * 200;
- hitOrb(o);
+ /* 9. MIRROR CLONE */
+ case 'mirror': {
+ if(o.clones < 2 && orbs.length < 46){
+ for(let i=0;i<2;i++){
+ const a = o.a + (i? 2.4 : -2.4);
+ const c = makeOrb(o.x + Math.cos(a)*(o.r+16), o.y + Math.sin(a)*(o.r+16),
+ Math.max(1,Math.min(3,o.tier)), 'mirror',
+ Math.cos(a)*rnd(2.4,1.2), Math.sin(a)*rnd(2.4,1.2));
+ c.clones = o.clones+1;
+ c.r = o.r*0.86; c.hp = 1; c.maxhp = 1;
+ orbs.push(c);
+ ringFx(c.x,c.y,'#dfe9ff',4,70,26,2);
+ }
+ floatText(o.x,o.y,'CLONED x2','#dfe9ff',17);
+ } else {
+ floatText(o.x,o.y,'MIRROR BROKE','#dfe9ff',15);
+ splitOrb(o); didSplit = true;
}
+ break;
}
- for (const s of shards){
- if (s.dead || this.hit.has(s)) continue;
- const d = dist(s.x, s.y, this.x, this.y);
- if (d <= this.r){ this.hit.add(s); s.dead = true; addScore(25, s.x, s.y); }
- }
- if (ship && !ship.dead){
- const d = dist(ship.x, ship.y, this.x, this.y);
- if (Math.abs(d - this.r) < 30){
- const [dx, dy] = dxy(this.x, this.y, ship.x, ship.y);
- const dd = Math.max(1, Math.hypot(dx, dy));
- ship.vx += dx / dd * 620 * 0.016; ship.vy += dy / dd * 620 * 0.016;
+ /* 10. RUBBER — confetti pop */
+ case 'rubber': {
+ burst(o.x,o.y,26,'#c3ff5e',6.5,{life:rnd(60,24)});
+ floatText(o.x,o.y,'POP!','#a8ff45',20);
+ break;
+ }
+ /* 11. NOVA — expanding shockwave that shatters what it crosses */
+ case 'nova': {
+ spawnShockwave(o.x,o.y, (240 + o.tier*70)*M, 1, 7.5, '#ffb03a');
+ burst(o.x,o.y,34,'#fff3c4',8,{life:rnd(56,22)});
+ flashScreen('#ffb03a',.30); shake(15); hitStop(3);
+ floatText(o.x,o.y,'NOVA!','#ffd88a',22);
+ break;
+ }
+ /* 12. SENTINEL — deploys turrets */
+ case 'sentry': {
+ const n = (o.tier>=3?2:1) * M;
+ for(let i=0;i<n;i++){
+ const a = (i/n)*TAU + rnd(.6);
+ sentries.push({
+ x:o.x+Math.cos(a)*o.r*0.7, y:o.y+Math.sin(a)*o.r*0.7,
+ vx:Math.cos(a)*0.5, vy:Math.sin(a)*0.5,
+ r:15, a:0, hp:2, cool: 70 + i*26, life: 660, max:660, spin:rnd(.04,-.04)
+ });
}
+ floatText(o.x,o.y,'TURRETS DEPLOYED','#8f9bff',17);
+ flashScreen('#7a86ff',.18); shake(7);
+ break;
}
- }
- draw(g){
- const p = this.r / this.maxR;
- g.beginPath(); g.arc(this.x, this.y, this.r, 0, TAU);
- g.strokeStyle = hexA(this.color, (1 - p) * 0.9); g.lineWidth = 10 * (1 - p) + 1.5; g.stroke();
- g.beginPath(); g.arc(this.x, this.y, this.r * 0.86, 0, TAU);
- g.strokeStyle = hexA('#ffffff', (1 - p) * 0.5); g.lineWidth = 2; g.stroke();
- }
-}
-
-class Detonation {
- constructor(x, y, R, delay){
- this.x = x; this.y = y; this.R = R; this.delay = delay;
- this.t = 0; this.fired = false; this.dead = false;
- }
- update(dt){
- this.t += dt;
- if (!this.fired && this.t >= this.delay){
- this.fired = true;
- addShake(0.45);
- burst(this.x, this.y, '#ff6a00', '#ffdda8', this.R * 0.25, 40);
- spawnRing(this.x, this.y, this.R, '#ff6a00', 0.6);
- for (const o of orbs){
- if (o.dead) continue;
- if (dist(o.x, o.y, this.x, this.y) < this.R){
- const [dx, dy] = dxy(this.x, this.y, o.x, o.y);
- const dd = Math.max(1, Math.hypot(dx, dy));
- o.vx += dx / dd * 160; o.vy += dy / dd * 160;
- hitOrb(o);
- }
+ /* 13. WARP — swaps your ship into the orb's place */
+ case 'warp': {
+ if(ship.alive){
+ const sx = ship.x, sy = ship.y;
+ for(let i=0;i<3;i++) ringFx(sx,sy,'#2fe8d5', 6+i*14, 150+i*30, 34, 3);
+ ship.x = o.x; ship.y = o.y;
+ ship.inv = Math.max(ship.inv, 70); // never warp you straight into a death
+ ship.vx *= 0.4; ship.vy *= 0.4;
+ burst(ship.x,ship.y,26,'#c9ffee',5,{life:rnd(48,20)});
+ ringFx(ship.x,ship.y,'#2fe8d5',150,6,32,4);
+ floatText(ship.x, ship.y-40,'WARPED!','#2fe8d5',20);
+ flashScreen('#2fe8d5',.26); shake(10);
}
- for (const s of shards)
- if (!s.dead && dist(s.x, s.y, this.x, this.y) < this.R){ s.dead = true; addScore(25, s.x, s.y); }
- if (ship && !ship.dead && dist(ship.x, ship.y, this.x, this.y) < this.R * 0.55) ship.kill();
+ break;
}
- if (this.t > this.delay + 0.45) this.dead = true;
- }
- draw(g){
- if (!this.fired) return;
- const p = clamp((this.t - this.delay) / 0.45, 0, 1);
- drawGlow(g, this.x, this.y, this.R * (0.4 + p * 0.7), '#ff6a00', (1 - p) * 0.55);
}
+
+ // generic split for everything that didn't grow its own descendants
+ if(!didSplit && o.kind!=='mirror') splitOrb(o);
+
+ if(!silentCombo) bumpCombo();
}
-class Ripple {
- constructor(x, y, maxR, color, str){
- this.x = x; this.y = y; this.r = 2; this.maxR = maxR;
- this.color = color; this.str = str; this.dead = false;
- }
- update(dt){ this.r += (this.maxR * 2.4) * dt; if (this.r > this.maxR) this.dead = true; }
- draw(g){
- const p = this.r / this.maxR;
- g.beginPath(); g.arc(this.x, this.y, this.r, 0, TAU);
- g.strokeStyle = hexA(this.color, (1 - p) * this.str); g.lineWidth = 3 * (1 - p) + 0.6; g.stroke();
+function hitOrb(o, bx, by, dmg){
+ dmg = dmg || 1;
+ const T = ORB_TYPES[o.kind];
+ o.hitFlash = 12;
+
+ // PHASE — teleports on a non-lethal hit
+ if(o.kind==='phase' && o.hp > dmg){
+ o.hp -= dmg;
+ Snd.orbHit('phase');
+ burst(o.x,o.y,16,'#c9a6ff',3.4,{life:34});
+ ringFx(o.x,o.y,'#b48bff',4,120,26,3);
+ let nx,ny,g=0;
+ do{ nx = rnd(W*0.9, W*0.1); ny = rnd(H*0.9, H*0.1); g++; }
+ while(g<24 && Math.hypot(nx-ship.x,ny-ship.y) < 170);
+ o.x = nx; o.y = ny;
+ o.vx = rnd(2.4,-2.4); o.vy = rnd(2.4,-2.4);
+ burst(o.x,o.y,18,'#e0ccff',3.8,{life:38});
+ ringFx(o.x,o.y,'#c9a6ff',110,4,26,3);
+ floatText(o.x,o.y-o.r-8,'BLINK','#c9a6ff',14);
+ addScore(20,o.x,o.y);
+ return false;
+ }
+
+ // RUBBER — accelerates on every hit
+ if(o.kind==='rubber' && o.hp > dmg){
+ o.hp -= dmg;
+ o.vx *= 1.5; o.vy *= 1.5;
+ o.vx += rnd(.9,-.9); o.vy += rnd(.9,-.9);
+ const sp = Math.hypot(o.vx,o.vy);
+ if(sp < 1.4){ const a=rnd(TAU); o.vx=Math.cos(a)*1.8; o.vy=Math.sin(a)*1.8; }
+ o.bounces++;
+ Snd.orbHit('rubber');
+ burst(o.x,o.y,10,'#c3ff5e',3.2,{life:26});
+ ringFx(o.x,o.y,'#a8ff45',o.r*0.4,o.r*2.2,20,3);
+ floatText(o.x,o.y-o.r-6,'FASTER!','#c3ff5e',14);
+ addScore(15,o.x,o.y);
+ shake(2.5);
+ return false;
+ }
+
+ o.hp -= dmg;
+ if(o.hp>0){
+ Snd.pop();
+ burst(bx===undefined?o.x:bx, by===undefined?o.y:by, 7, T.c1, 2.6, {life:22});
+ addScore(10,o.x,o.y);
+ return false;
}
+ destroyOrb(o);
+ return true;
+}
+
+/* ============================================================
+ SHIP ACTIONS
+ ============================================================ */
+function fire(){
+ if(ship.cool>0 || !ship.alive || G.state!=='play') return;
+ const angles = G.spreadT>0 ? [-0.15, 0, 0.15] : [0];
+ for(const off of angles){
+ const a = ship.a + off;
+ const bx = ship.x + Math.cos(a)*(ship.r+4);
+ const by = ship.y + Math.sin(a)*(ship.r+4);
+ bullets.push({
+ x:bx, y:by,
+ vx: Math.cos(a)*8.4 + ship.vx*0.45,
+ vy: Math.sin(a)*8.4 + ship.vy*0.45,
+ life: 74, r:3.1, trail:[]
+ });
+ G.shots++;
+ burst(bx,by,3,'#9ff6ff',2.4,{life:14,r:1.6});
+ }
+ ship.cool = G.rapidT>0 ? 5 : 9;
+ ship.recoil = 4;
+ ship.vx -= Math.cos(ship.a)*0.22; ship.vy -= Math.sin(ship.a)*0.22;
+ Snd.shoot();
+}
+
+function firePulse(){
+ if(G.state!=='play' || !ship.alive || G.pulses<=0) return;
+ G.pulses--;
+ spawnShockwave(ship.x, ship.y, Math.max(W,H)*0.62, 3, 13, '#ff8ad4');
+ burst(ship.x,ship.y,50,'#ffd6f5',9,{life:rnd(60,26)});
+ ringFx(ship.x,ship.y,'#ff8ad4',10,Math.max(W,H)*0.6,44,7);
+ flashScreen('#ff8ad4',.4); shake(24); hitStop(6);
+ floatText(ship.x, ship.y-46,'PULSE','#ff8ad4',24);
+ Snd.pulse();
+}
+
+function hyperspace(){
+ if(G.state!=='play' || !ship.alive || G.hyperCool>0) return;
+ G.hyperCool = 260;
+ burst(ship.x,ship.y,24,'#b9e8ff',5,{life:34});
+ ringFx(ship.x,ship.y,'#8ef7ff',6,120,26,3);
+ let nx=W/2, ny=H/2, best=-1;
+ for(let i=0;i<26;i++){
+ const cx=rnd(W*0.92,W*0.08), cy=rnd(H*0.92,H*0.08);
+ let near=1e9;
+ for(const o of orbs) near = Math.min(near, Math.hypot(cx-o.x,cy-o.y)-o.r);
+ for(const h of holes) near = Math.min(near, Math.hypot(cx-h.x,cy-h.y)-h.pull*0.5);
+ if(near>best){ best=near; nx=cx; ny=cy; }
+ }
+ ship.x=nx; ship.y=ny; ship.vx*=0.25; ship.vy*=0.25;
+ ship.inv = Math.max(ship.inv, 60);
+ ship.trail.length = 0;
+ burst(ship.x,ship.y,24,'#dff4ff',5,{life:34});
+ ringFx(ship.x,ship.y,'#8ef7ff',120,6,26,3);
+ Snd.hyper();
}
-function spawnRing(x, y, r, color, str){ effects.push(new Ripple(x, y, r, color, str)); }
-/* Every effect that exerts a force gets applied here. */
-function applyFields(e, dt, kind){
- for (let i = 0; i < effects.length; i++){
- const f = effects[i];
- if (f.field) f.field(e, dt, kind);
- }
-}
-/* Time-bubble slow factor at a point. */
-function slowAt(x, y, isShip){
- let f = 1;
- for (let i = 0; i < bubbles.length; i++){
- const b = bubbles[i];
- if (dist(x, y, b.x, b.y) < b.R) f = Math.min(f, isShip ? 0.82 : 0.3);
- }
- return f;
-}
-
-/* ------------------------------------------------------------ particles --- */
-function addParticle(x, y, vx, vy, r, life, color){
- if (particles.length >= PARTICLE_CAP) particles.shift();
- particles.push({ x, y, vx, vy, r, life, max:life, color, dead:false });
-}
-function burst(x, y, c1, c2, size, n){
- n = n || Math.round(14 + size * 0.7);
- for (let i = 0; i < n; i++){
- const a = rand(0, TAU), s = rand(50, 90 + size * 9);
- addParticle(x, y, Math.cos(a)*s, Math.sin(a)*s, rand(1.6, 2 + size*0.13),
- rand(0.3, 0.85), Math.random() < 0.55 ? c1 : c2);
- }
-}
-function updateParticles(dt){
- for (let i = particles.length - 1; i >= 0; i--){
- const p = particles[i];
- applyFields(p, dt, 'particle');
- const tf = slowAt(p.x, p.y);
- p.x += p.vx * dt * tf; p.y += p.vy * dt * tf;
- const drag = Math.pow(0.22, dt);
- p.vx *= drag; p.vy *= drag;
- p.life -= dt;
- if (p.life <= 0) particles.splice(i, 1);
- }
-}
-function drawParticles(g){
- g.globalCompositeOperation = 'lighter';
- for (let i = 0; i < particles.length; i++){
- const p = particles[i];
- const t = p.life / p.max;
- drawGlow(g, p.x, p.y, p.r * (1 + (1 - t) * 2.4), p.color, t * 0.85);
- }
- g.globalCompositeOperation = 'source-over';
-}
-
-/* ------------------------------------------------------------- floaters --- */
-class Floater {
- constructor(x, y, text, color, life, size){
- this.x = x; this.y = y; this.text = text; this.color = color;
- this.life = life || 0.9; this.max = this.life; this.size = size || 13; this.dead = false;
- }
- update(dt){ this.y -= 34 * dt; this.life -= dt; if (this.life <= 0) this.dead = true; }
- draw(g){
- const t = this.life / this.max;
- g.globalAlpha = clamp(t * 1.6, 0, 1);
- g.font = '700 ' + this.size + 'px ui-monospace, Menlo, monospace';
- g.textAlign = 'center';
- g.fillStyle = this.color;
- g.fillText(this.text, this.x, this.y);
- g.globalAlpha = 1;
- g.textAlign = 'left';
- }
-}
-
-/* ---------------------------------------------------------------- stars --- */
-function makeStars(){
- stars = [];
- const n = Math.round((W * H) / 9000);
- for (let i = 0; i < n; i++){
- stars.push({ x: rand(0, W), y: rand(0, H), r: rand(0.4, 1.5), z: rand(0.15, 1), tw: rand(0, TAU) });
- }
+function grantPower(kind,x,y){
+ const P = POWERS[kind];
+ switch(kind){
+ case 'spread': G.spreadT = Math.min(1800, G.spreadT + P.dur); break;
+ case 'rapid': G.rapidT = Math.min(1800, G.rapidT + P.dur); break;
+ case 'shield': ship.shield = 1; break;
+ case 'pulse': G.pulses = Math.min(3, G.pulses+1); break;
+ case 'life': G.lives++; break;
+ }
+ Snd.powerup();
+ floatText(x, y-16, P.name, P.col, 18);
+ ringFx(x,y,P.col,6,80,26,3);
+ burst(x,y,16,P.col,3.4,{life:32});
+ flashScreen(P.col,.14);
}
-function drawStars(g, dt){
- for (let i = 0; i < stars.length; i++){
- const s = stars[i];
- s.x -= s.z * 7 * dt;
- if (s.x < 0) { s.x += W; s.y = rand(0, H); }
- const a = 0.22 + 0.5 * s.z * (0.6 + 0.4 * Math.sin(elapsed * 1.6 + s.tw));
- g.fillStyle = 'rgba(180,205,255,' + a.toFixed(3) + ')';
- g.fillRect(s.x, s.y, s.r, s.r);
- }
-}
-
-/* ---------------------------------------------------------- game control -- */
-function hitOrb(o){
- if (o.dead) return;
- const T = TYPES[o.type];
- o.flash = 0.13;
- o.hp--;
- burst(o.x, o.y, T.c, T.c2, o.r * 0.4, 8);
- if (o.hp > 0){
- if (T.hurt) T.hurt(o);
- else Snd.tone({type:'square', f0:700, f1:400, dur:0.06, vol:0.07});
- addScore(20, o.x, o.y, false);
+
+function killShip(){
+ if(G.state!=='play' || ship.inv>0 || !ship.alive) return;
+
+ if(ship.shield>0){
+ ship.shield = 0;
+ ship.inv = 100;
+ Snd.shieldBreak();
+ ringFx(ship.x,ship.y,'#8effc0',ship.r+8,150,32,4);
+ burst(ship.x,ship.y,26,'#8effc0',5,{life:38});
+ floatText(ship.x,ship.y-38,'SHIELD DOWN','#8effc0',18);
+ flashScreen('#8effc0',.26); shake(12);
return;
}
- destroyOrb(o);
+
+ ship.alive=false;
+ G.lives--;
+ G.respawnT = 58;
+ Snd.hurt();
+ burst(ship.x,ship.y,60,'#ffd07a',6.5,{life:rnd(80,30)});
+ burst(ship.x,ship.y,34,'#ff5a3c',9,{life:rnd(60,24)});
+ burst(ship.x,ship.y,18,'#ffffff',3.5,{life:rnd(44,18),r:rnd(4,2)});
+ ringFx(ship.x,ship.y,'#ffae5c',6,200,44,5);
+ shake(26); flashScreen('#ff8a4c',.42); hitStop(7);
+ G.combo=0; G.spreadT=0; G.rapidT=0;
+ Engine.set(0);
}
-function destroyOrb(o){
- if (o.dead) return;
- o.dead = true;
- const T = TYPES[o.type];
- burst(o.x, o.y, T.c, T.c2, o.r);
- spawnRing(o.x, o.y, o.r * 3.4, T.c, 0.55);
- addShake(clamp(o.r / 90, 0.08, 0.5));
- if (T.sfx) T.sfx(o);
- combo++; comboTimer = 2.4;
- addScore(Math.round(T.sc * (TIER_SCALE[o.tier] || 1)), o.x, o.y);
- if (T.destroy) T.destroy(o);
-}
-function addScore(v, x, y, showFloat = true){
- const m = comboMult();
- const gained = v * m;
- score += gained;
- if (showFloat && x != null){
- floaters.push(new Floater(x, y - 14, (m > 1 ? '+' + gained + ' ×' + m : '+' + gained),
- m > 1 ? '#ffd75e' : '#cfe4ff', 0.9, m > 1 ? 15 : 12));
- }
- grantExtraLives();
-}
-function grantExtraLives(){
- let n = 0;
- while (score >= nextExtra && n < 20){ nextExtra += 5000; lives++; n++; }
- if (n){
- floaters.push(new Floater(ship ? ship.x : W/2, (ship ? ship.y : H/2) - 40,
- n > 1 ? 'EXTRA LIFE ×' + n : 'EXTRA LIFE', '#38f5d0', 1.8, 20));
- Snd.extraLife();
- }
-}
-
-function spawnWave(w){
- const pool = poolFor(w);
- const n = Math.min(22, 3 + Math.round(w * 1.6));
- for (let i = 0; i < n; i++){
- let x, y, tries = 0;
- do {
- if (Math.random() < 0.5){ x = Math.random() < 0.5 ? rand(-20, 40) : rand(W - 40, W + 20); y = rand(0, H); }
- else { x = rand(0, W); y = Math.random() < 0.5 ? rand(-20, 40) : rand(H - 40, H + 20); }
- tries++;
- } while (ship && dist(x, y, ship.x, ship.y) < 240 && tries < 20);
- x = clamp(x, 10, W - 10); y = clamp(y, 10, H - 10);
- const o = new Orb(x, y, pick(pool), 3);
- o.born = 0.5;
- orbs.push(o);
- spawnRing(x, y, 70, TYPES[o.type].c, 0.5);
- }
+
+/* ============================================================
+ GAME FLOW
+ ============================================================ */
+const startScreen = document.getElementById('startScreen');
+const overScreen = document.getElementById('overScreen');
+const pauseScreen = document.getElementById('pauseScreen');
+
+function clearWorld(){
+ orbs.length=0; bullets.length=0; parts.length=0; hunters.length=0; shards.length=0;
+ wells.length=0; holes.length=0; bubbles.length=0; rings.length=0; floats.length=0;
+ waves.length=0; sentries.length=0; enemyShots.length=0; powerups.length=0;
}
function startGame(){
- Snd.init(); Snd.resume();
- score = 0; lives = 3; wave = 0; nextExtra = 5000;
- orbs = []; bullets = []; shards = []; particles = []; effects = []; floaters = []; bubbles = [];
- combo = 0; comboTimer = 0; reverseT = 0; shake = 0; hitFlash = 0;
- waveTransition = 0; gameOverDelay = 0;
- ship = new Ship();
- state = 'playing';
- showScreen(null);
-}
-function endGame(){
- state = 'gameover';
- gameOverDelay = 0.8;
- Snd.thrust(false); Snd.gameOver();
- const isBest = score > best;
- if (isBest){ best = score; localStorage.setItem('wildorbs.best', String(best)); }
- document.getElementById('finalScore').textContent = score.toLocaleString();
- document.getElementById('finalWave').textContent = Math.max(1, wave);
- document.getElementById('finalBest').textContent = best.toLocaleString();
- document.getElementById('newbest').style.display = isBest ? 'block' : 'none';
- showScreen('overScreen');
-}
-function showScreen(id){
- for (const s of ['startScreen','pauseScreen','overScreen'])
- document.getElementById(s).classList.toggle('on', s === id);
- document.querySelector('.foot').style.display = (id === 'startScreen') ? 'block' : 'none';
-}
-
-/* ----------------------------------------------------------- collisions -- */
-function collide(){
- // bullets vs orbs
- for (let i = bullets.length - 1; i >= 0; i--){
- const b = bullets[i];
- if (b.dead) continue;
- for (let j = 0; j < orbs.length; j++){
+ primeAudio();
+ clearWorld();
+ G.score=0; G.wave=1; G.lives=3; G.state='play';
+ G.reverseT=0; G.reverseMax=360; G.spreadT=0; G.rapidT=0; G.pulses=1; G.hyperCool=0;
+ G.shake=0; G.flash=0; G.nextLife=5000; G.combo=0; G.comboT=0; G.hitStop=0;
+ G.spawnGuard=0; G.respawnT=0;
+ G.orbsBroken=0; G.bestCombo=0; G.shots=0; G.hits=0; G.frames=0;
+ ship.shield=0;
+ toastT=0;
+ resetShip();
+ spawnWave();
+ startScreen.classList.add('hidden');
+ overScreen.classList.add('hidden');
+ pauseScreen.classList.add('hidden');
+}
+
+function toMenu(){
+ G.state='start';
+ clearWorld();
+ startScreen.classList.remove('hidden');
+ overScreen.classList.add('hidden');
+ pauseScreen.classList.add('hidden');
+ Engine.set(0);
+}
+
+function togglePause(){
+ if(G.state==='play'){ G.state='pause'; pauseScreen.classList.remove('hidden'); Engine.set(0); }
+ else if(G.state==='pause'){ G.state='play'; pauseScreen.classList.add('hidden'); }
+}
+
+function fmtTime(sec){
+ const m = Math.floor(sec/60), s = Math.floor(sec%60);
+ return m + ':' + String(s).padStart(2,'0');
+}
+
+function gameOver(){
+ G.state='over';
+ const isBest = G.score > G.best;
+ if(isBest){ G.best = G.score; Store.set('best', G.best); }
+ if(G.wave > G.bestWave){ G.bestWave = G.wave; Store.set('bestWave', G.bestWave); }
+
+ document.getElementById('finalScore').textContent = G.score.toLocaleString();
+ document.getElementById('newBest').textContent = isBest ? '★ NEW PERSONAL BEST ★' : '';
+ document.getElementById('stWave').textContent = G.wave;
+ document.getElementById('stOrbs').textContent = G.orbsBroken;
+ document.getElementById('stCombo').textContent = 'x'+G.bestCombo;
+ document.getElementById('stAcc').textContent = (G.shots? Math.round(G.hits/G.shots*100):0) + '%';
+ document.getElementById('stTime').textContent = fmtTime(G.frames/60);
+ document.getElementById('finalBest').textContent =
+ 'BEST ' + G.best.toLocaleString() + ' · FURTHEST WAVE ' + G.bestWave;
+ document.getElementById('finalMsg').textContent =
+ G.wave>=12? 'The void filed a complaint.' :
+ G.wave>=8 ? 'Orb whisperer.' :
+ G.wave>=5 ? 'The void respects you.' :
+ G.wave>=3 ? 'Not bad. The orbs disagree.' : 'The orbs were unimpressed.';
+ overScreen.classList.remove('hidden');
+ Engine.set(0);
+ Snd.gameover();
+}
+
+/* ---------------- UI wiring ---------------- */
+function toggleSound(){
+ SET.muted = !SET.muted; Store.set('muted', SET.muted);
+ Snd.applyMute(); syncToggles();
+ toast(SET.muted? 'MUTED' : 'SOUND ON');
+}
+function toggleShake(){
+ SET.shake = !SET.shake; Store.set('shake', SET.shake);
+ if(!SET.shake){ G.shake=0; G.hitStop=0; }
+ syncToggles();
+ toast('SCREEN SHAKE ' + (SET.shake? 'ON':'OFF'));
+}
+function syncToggles(){
+ const s = document.getElementById('tgSound'), k = document.getElementById('tgShake');
+ document.getElementById('tgSoundV').textContent = SET.muted? 'OFF':'ON';
+ document.getElementById('tgShakeV').textContent = SET.shake? 'ON':'OFF';
+ s.classList.toggle('off', SET.muted);
+ k.classList.toggle('off', !SET.shake);
+}
+document.getElementById('startBtn').addEventListener('click', startGame);
+document.getElementById('againBtn').addEventListener('click', startGame);
+document.getElementById('menuBtn').addEventListener('click', toMenu);
+document.getElementById('resumeBtn').addEventListener('click', togglePause);
+document.getElementById('quitBtn').addEventListener('click', ()=>{ pauseScreen.classList.add('hidden'); gameOver(); });
+document.getElementById('tgSound').addEventListener('click', toggleSound);
+document.getElementById('tgShake').addEventListener('click', toggleShake);
+
+/* ============================================================
+ UPDATE
+ ============================================================ */
+function slowAt(x,y){
+ for(let i=0;i<bubbles.length;i++){
+ const b=bubbles[i];
+ if(Math.hypot(x-b.x, y-b.y) < b.r) return 0.34;
+ }
+ return 1;
+}
+
+function update(){
+ G.time++;
+ if(G.hitStop>0){ G.hitStop--; return; }
+ G.frames++;
+
+ const inp = readInput();
+ const rev = G.reverseT>0 ? -1 : 1;
+ if(G.reverseT>0) G.reverseT--;
+ if(G.spreadT>0) G.spreadT--;
+ if(G.rapidT>0) G.rapidT--;
+ if(G.hyperCool>0) G.hyperCool--;
+
+ let shipSlow = 1;
+ for(const b of bubbles){ if(Math.hypot(ship.x-b.x, ship.y-b.y) < b.r){ shipSlow = 0.42; break; } }
+
+ /* ---------- ship control ---------- */
+ if(ship.alive){
+ const turn = 0.062 * shipSlow;
+ if(inp.aim !== null){
+ // touch stick: steer toward the thumb direction (reverse flips the target)
+ const target = inp.aim + (rev<0 ? Math.PI : 0);
+ let diff = ((target - ship.a + Math.PI*3) % TAU) - Math.PI;
+ ship.a += clamp(diff, -turn*2.2, turn*2.2);
+ } else {
+ if(inp.left) ship.a -= turn*rev;
+ if(inp.right) ship.a += turn*rev;
+ }
+
+ const thrusting = inp.up;
+ ship.thrust = lerp(ship.thrust, thrusting?1:0, 0.22);
+ if(thrusting){
+ const acc = 0.185 * shipSlow;
+ ship.vx += Math.cos(ship.a)*acc;
+ ship.vy += Math.sin(ship.a)*acc;
+ for(let i=0;i<2;i++){
+ const ea = ship.a + Math.PI + rnd(.45,-.45);
+ const es = rnd(3.6,1.6);
+ spark(ship.x + Math.cos(ship.a+Math.PI)*ship.r*0.85,
+ ship.y + Math.sin(ship.a+Math.PI)*ship.r*0.85,
+ Math.random()<0.45? '#9ff0ff' : (Math.random()<0.5?'#ffd88a':'#ff8f4c'),
+ { vx: Math.cos(ea)*es + ship.vx*0.4, vy: Math.sin(ea)*es + ship.vy*0.4,
+ life: rnd(26,10), r: rnd(3.4,1.2), drag:0.93 });
+ }
+ }
+ if(inp.down){ ship.vx *= 0.975; ship.vy *= 0.975; }
+ if(inp.fire) fire();
+ Engine.set(ship.thrust);
+ } else {
+ Engine.set(0);
+ if(G.respawnT>0 && --G.respawnT<=0){
+ if(G.lives<=0){ gameOver(); return; }
+ resetShip();
+ }
+ }
+
+ if(ship.cool>0) ship.cool--;
+ if(ship.inv>0) ship.inv--;
+ if(ship.recoil>0.05) ship.recoil *= 0.8; else ship.recoil = 0;
+
+ /* ---------- ship physics ---------- */
+ if(ship.alive){
+ ship.vx *= 0.9915; ship.vy *= 0.9915;
+ const sp = Math.hypot(ship.vx,ship.vy), MAX=8.6;
+ if(sp>MAX){ ship.vx = ship.vx/sp*MAX; ship.vy = ship.vy/sp*MAX; }
+ const px = ship.x, py = ship.y;
+ ship.x += ship.vx*shipSlow; ship.y += ship.vy*shipSlow;
+ wrapPos(ship);
+ // break the trail when the ship teleports across a wrap seam
+ const jumped = Math.abs(ship.x-px) > W*0.5 || Math.abs(ship.y-py) > H*0.5;
+ ship.trail.push({x:ship.x, y:ship.y, a:ship.a, cut:jumped});
+ if(ship.trail.length>16) ship.trail.shift();
+ }
+
+ /* ---------- gravity wells ---------- */
+ for(let i=wells.length-1;i>=0;i--){
+ const w = wells[i];
+ w.life--; w.a += 0.03;
+ if(ship.alive){
+ const [dx,dy] = wrapDelta(ship.x,ship.y,w.x,w.y);
+ const d = Math.hypot(dx,dy);
+ if(d < w.r && d>1){
+ const f = w.str * (1 - d/w.r) * 0.9;
+ ship.vx += (dx/d)*f; ship.vy += (dy/d)*f;
+ }
+ }
+ if(G.time%2===0){
+ const a=rnd(TAU), rr=w.r*rnd(1,.5);
+ spark(w.x+Math.cos(a)*rr, w.y+Math.sin(a)*rr, '#8de7ff',
+ {vx:-Math.cos(a)*2.4, vy:-Math.sin(a)*2.4, life:26, r:1.6, drag:1});
+ }
+ if(w.life<=0){ ringFx(w.x,w.y,'#8de7ff',w.r,4,26,3); wells.splice(i,1); }
+ }
+
+ /* ---------- black holes ---------- */
+ for(let i=holes.length-1;i>=0;i--){
+ const h = holes[i];
+ h.life--; h.a += 0.09;
+ h.r = h.rMax * (0.75 + 0.18*Math.sin(h.a*1.7));
+ if(h.life<50) h.r *= h.life/50;
+
+ if(ship.alive){
+ const [dx,dy] = wrapDelta(ship.x,ship.y,h.x,h.y);
+ const d = Math.hypot(dx,dy);
+ if(d < h.pull && d>1){
+ const f = 0.85*(1 - d/h.pull);
+ ship.vx += (dx/d)*f*1.1; ship.vy += (dy/d)*f*1.1;
+ if(d < h.r + ship.r*0.4) killShip();
+ }
+ }
+ for(let j=orbs.length-1;j>=0;j--){
+ const o=orbs[j];
+ const [dx,dy]=wrapDelta(o.x,o.y,h.x,h.y); const d=Math.hypot(dx,dy)||1;
+ if(d < h.pull){
+ const f = 0.7*(1-d/h.pull);
+ o.vx += (dx/d)*f; o.vy += (dy/d)*f;
+ if(d < h.r*0.9){
+ h.eaten++;
+ addScore(40,o.x,o.y);
+ G.orbsBroken++;
+ burst(o.x,o.y,10,ORB_TYPES[o.kind].c1,2.2,{life:24});
+ orbs.splice(j,1);
+ Snd.pop();
+ }
+ }
+ }
+ for(let k=0;k<2;k++){
+ const a=rnd(TAU), rr=h.pull*rnd(.9,.3);
+ spark(h.x+Math.cos(a)*rr, h.y+Math.sin(a)*rr, Math.random()<.5?'#c07bff':'#5a2fa8',
+ {vx:-Math.cos(a)*3.4 - Math.sin(a)*2.4, vy:-Math.sin(a)*3.4 + Math.cos(a)*2.4,
+ life:30, r:rnd(2.4,1), drag:0.99});
+ }
+ if(h.life<=0){
+ ringFx(h.x,h.y,'#a24fff',6,300,40,5);
+ burst(h.x,h.y,40,'#c07bff',7,{life:50});
+ shake(10); holes.splice(i,1);
+ }
+ }
+
+ /* ---------- time bubbles ---------- */
+ for(let i=bubbles.length-1;i>=0;i--){
+ const b=bubbles[i]; b.life--; b.ph += 0.04;
+ if(b.life<=0){ ringFx(b.x,b.y,'#9dffc9',b.r,6,26,3); bubbles.splice(i,1); }
+ }
+
+ /* ---------- shockwaves (nova + pulse bomb) ---------- */
+ for(let i=waves.length-1;i>=0;i--){
+ const sw = waves[i];
+ sw.r += sw.speed;
+ if(sw.r > sw.rMax){ waves.splice(i,1); continue; }
+
+ for(let j=orbs.length-1;j>=0;j--){
const o = orbs[j];
- if (o.dead || o.born > 0.2) continue;
- if (dist(b.x, b.y, o.x, o.y) < o.r + 3){
- b.dead = true;
- hitOrb(o);
- break;
+ if(sw.hit.has(o)) continue;
+ const d = wrapDist(o,sw);
+ if(d < sw.r + o.r*0.7 && d > sw.r - sw.band - o.r*0.7){
+ sw.hit.add(o);
+ const [dx,dy] = wrapDelta(sw.x,sw.y,o.x,o.y);
+ const m = Math.hypot(dx,dy)||1;
+ o.vx += (dx/m)*2.2; o.vy += (dy/m)*2.2;
+ o.hitFlash = 12;
+ o.hp -= sw.dmg;
+ if(o.hp<=0) destroyOrb(o);
+ else { burst(o.x,o.y,6,sw.col,3,{life:20}); Snd.pop(); }
}
}
- if (b.dead) continue;
- // bullets vs shards
- for (let j = 0; j < shards.length; j++){
- const s = shards[j];
- if (s.dead) continue;
- if (dist(b.x, b.y, s.x, s.y) < s.r + 4){
- b.dead = true; s.dead = true;
- addScore(25, s.x, s.y);
- burst(s.x, s.y, '#ff43e0', '#ffd4f7', 10, 12);
- Snd.tone({type:'triangle', f0:1800, f1:700, dur:0.08, vol:0.08});
- break;
+ for(let j=hunters.length-1;j>=0;j--){
+ const hh=hunters[j], d=wrapDist(hh,sw);
+ if(d < sw.r && d > sw.r - sw.band - 8){ burst(hh.x,hh.y,8,'#ffcb5e',3,{life:22}); addScore(35,hh.x,hh.y); hunters.splice(j,1); }
+ }
+ for(let j=shards.length-1;j>=0;j--){
+ const sh=shards[j], d=wrapDist(sh,sw);
+ if(d < sw.r && d > sw.r - sw.band - 8){ burst(sh.x,sh.y,7,'#dff8ff',3,{life:20}); addScore(25,sh.x,sh.y); shards.splice(j,1); }
+ }
+ for(let j=sentries.length-1;j>=0;j--){
+ const st=sentries[j], d=wrapDist(st,sw);
+ if(d < sw.r && d > sw.r - sw.band - 12){
+ st.hp -= sw.dmg;
+ if(st.hp<=0){ burst(st.x,st.y,16,'#8f9bff',4,{life:30}); addScore(80,st.x,st.y); sentries.splice(j,1); }
}
}
+ for(let j=enemyShots.length-1;j>=0;j--){
+ const es=enemyShots[j], d=wrapDist(es,sw);
+ if(d < sw.r && d > sw.r - sw.band - 8){ burst(es.x,es.y,5,'#b4a0ff',2.4,{life:16}); enemyShots.splice(j,1); }
+ }
+ if(ship.alive){
+ const [dx,dy]=wrapDelta(sw.x,sw.y,ship.x,ship.y);
+ const d=Math.hypot(dx,dy)||1;
+ if(Math.abs(d - sw.r) < sw.band){
+ ship.vx += (dx/d)*0.9; ship.vy += (dy/d)*0.9; // pushes you, never kills you
+ }
+ }
+ for(let k=0;k<3;k++){
+ const a=rnd(TAU);
+ spark(sw.x+Math.cos(a)*sw.r, sw.y+Math.sin(a)*sw.r, sw.col,
+ {vx:Math.cos(a)*2.2, vy:Math.sin(a)*2.2, life:20, r:rnd(2.6,1)});
+ }
}
- if (!ship || ship.dead) return;
- // ship vs orbs
- for (let j = 0; j < orbs.length; j++){
- const o = orbs[j];
- if (o.dead || o.born > 0.2) continue;
- if (dist(ship.x, ship.y, o.x, o.y) < o.r + ship.r * 0.8){
- if (ship.invuln > 0) continue;
- ship.kill();
- destroyOrb(o); // it still does its wild thing on the way out
- break;
+
+ /* ---------- orbs ---------- */
+ for(let i=orbs.length-1;i>=0;i--){
+ const o = orbs[i];
+ const s = slowAt(o.x,o.y);
+ o.t++;
+ o.a += o.spin*s;
+ o.pulse += 0.07*s;
+ o.wobble += 0.05*s;
+ if(o.hitFlash>0) o.hitFlash--;
+
+ for(const w of wells){
+ const [dx,dy]=wrapDelta(o.x,o.y,w.x,w.y); const d=Math.hypot(dx,dy);
+ if(d<w.r && d>1){ const f=w.str*0.4*(1-d/w.r); o.vx+=(dx/d)*f; o.vy+=(dy/d)*f; }
+ }
+
+ // clamp runaway speed (wells + shockwaves + rubber boosts can stack)
+ const osp = Math.hypot(o.vx,o.vy);
+ if(osp > 11){ o.vx = o.vx/osp*11; o.vy = o.vy/osp*11; }
+
+ o.x += o.vx*s; o.y += o.vy*s;
+
+ if(o.kind==='rubber'){
+ // rubber orbs refuse to wrap — they slam off the walls instead
+ if(o.x < o.r){ o.x=o.r; o.vx=Math.abs(o.vx)*1.02; o.bounces++; Snd.bounce(); burst(o.x,o.y,6,'#c3ff5e',2.4,{life:20}); }
+ if(o.x > W-o.r){ o.x=W-o.r; o.vx=-Math.abs(o.vx)*1.02; o.bounces++; Snd.bounce(); burst(o.x,o.y,6,'#c3ff5e',2.4,{life:20}); }
+ if(o.y < o.r){ o.y=o.r; o.vy=Math.abs(o.vy)*1.02; o.bounces++; Snd.bounce(); burst(o.x,o.y,6,'#c3ff5e',2.4,{life:20}); }
+ if(o.y > H-o.r){ o.y=H-o.r; o.vy=-Math.abs(o.vy)*1.02; o.bounces++; Snd.bounce(); burst(o.x,o.y,6,'#c3ff5e',2.4,{life:20}); }
+ } else {
+ wrapPos(o);
+ }
+
+ if(o.chainT>=0){
+ o.chainT--;
+ o.hitFlash = 8;
+ if(o.chainT<0){ destroyOrb(o,true); continue; }
}
+
+ if(ship.alive && ship.inv<=0 && wrapDist(o,ship) < o.r*0.86 + ship.r*0.7) killShip();
}
- // ship vs shards
- for (let j = 0; j < shards.length; j++){
- const s = shards[j];
- if (s.dead) continue;
- if (dist(ship.x, ship.y, s.x, s.y) < s.r + ship.r * 0.8){
- if (ship.invuln > 0) continue;
- s.dead = true;
- ship.kill();
- break;
+
+ /* ---------- bullets ---------- */
+ for(let i=bullets.length-1;i>=0;i--){
+ const b = bullets[i];
+ let dead = false;
+
+ for(const w of wells){
+ const [dx,dy]=wrapDelta(b.x,b.y,w.x,w.y); const d=Math.hypot(dx,dy);
+ if(d<w.r && d>1){ const f=w.str*1.5*(1-d/w.r); b.vx+=(dx/d)*f; b.vy+=(dy/d)*f; }
+ }
+ for(const h of holes){
+ const [dx,dy]=wrapDelta(b.x,b.y,h.x,h.y); const d=Math.hypot(dx,dy);
+ if(d<h.pull && d>1){
+ const f=1.5*(1-d/h.pull); b.vx+=(dx/d)*f; b.vy+=(dy/d)*f;
+ if(d<h.r*0.8){ burst(h.x,h.y,4,'#c07bff',1.6,{life:16}); dead = true; break; }
+ }
+ }
+ if(dead){ bullets.splice(i,1); continue; }
+
+ const s = slowAt(b.x,b.y);
+ b.x += b.vx*s; b.y += b.vy*s;
+ b.life -= s;
+ b.trail.push({x:b.x,y:b.y});
+ if(b.trail.length>9) b.trail.shift();
+ wrapPos(b);
+ if(b.life<=0){ bullets.splice(i,1); continue; }
+
+ for(let j=orbs.length-1;j>=0 && !dead;j--){
+ const o=orbs[j];
+ if(wrapDist(o,b) < o.r*0.92 + b.r){ hitOrb(o,b.x,b.y); dead=true; G.hits++; }
+ }
+ for(let j=hunters.length-1;j>=0 && !dead;j--){
+ const hh=hunters[j];
+ if(wrapDist(hh,b) < hh.r + b.r + 2){
+ burst(hh.x,hh.y,10,'#ffcb5e',3.2,{life:26});
+ Snd.pop(); addScore(35,hh.x,hh.y);
+ hunters.splice(j,1); dead=true; G.hits++;
+ }
+ }
+ for(let j=shards.length-1;j>=0 && !dead;j--){
+ const sh=shards[j];
+ if(wrapDist(sh,b) < sh.r + b.r + 2){
+ burst(sh.x,sh.y,9,'#dff8ff',3.4,{life:24});
+ Snd.pop(); addScore(25,sh.x,sh.y);
+ shards.splice(j,1); dead=true; G.hits++;
+ }
+ }
+ for(let j=sentries.length-1;j>=0 && !dead;j--){
+ const st=sentries[j];
+ if(wrapDist(st,b) < st.r + b.r){
+ st.hp--; dead=true; G.hits++;
+ burst(b.x,b.y,8,'#c3ccff',3,{life:22});
+ if(st.hp<=0){
+ burst(st.x,st.y,20,'#8f9bff',4.6,{life:36});
+ ringFx(st.x,st.y,'#7a86ff',6,90,26,3);
+ addScore(80,st.x,st.y); Snd.pop();
+ sentries.splice(j,1);
+ } else Snd.pop();
+ }
+ }
+ for(let j=enemyShots.length-1;j>=0 && !dead;j--){
+ const es=enemyShots[j];
+ if(wrapDist(es,b) < es.r + b.r + 2){
+ burst(es.x,es.y,6,'#b4a0ff',2.6,{life:18});
+ addScore(15,es.x,es.y);
+ enemyShots.splice(j,1); dead=true; G.hits++;
+ }
+ }
+ if(dead) bullets.splice(i,1);
+ }
+
+ /* ---------- hunters ---------- */
+ for(let i=hunters.length-1;i>=0;i--){
+ const h=hunters[i];
+ const s = slowAt(h.x,h.y);
+ h.life -= s;
+ if(h.life<=0){ burst(h.x,h.y,8,'#ffb454',2.2,{life:20}); hunters.splice(i,1); continue; }
+ if(ship.alive){
+ const [dx,dy]=wrapDelta(h.x,h.y,ship.x,ship.y);
+ const d=Math.hypot(dx,dy)||1;
+ h.vx += (dx/d)*h.spd; h.vy += (dy/d)*h.spd;
}
+ h.wig += 0.3;
+ h.vx += Math.cos(h.wig)*0.06; h.vy += Math.sin(h.wig*1.3)*0.06;
+ const sp=Math.hypot(h.vx,h.vy), M=3.4;
+ if(sp>M){ h.vx=h.vx/sp*M; h.vy=h.vy/sp*M; }
+ h.x += h.vx*s; h.y += h.vy*s;
+ h.a = Math.atan2(h.vy,h.vx);
+ wrapPos(h);
+ if(G.time%3===0) spark(h.x,h.y,'#ff9d2e',{vx:rnd(.6,-.6),vy:rnd(.6,-.6),life:16,r:1.7,drag:.92});
+ if(ship.alive && ship.inv<=0 && wrapDist(h,ship) < h.r+ship.r*0.72) killShip();
+ }
+
+ /* ---------- shards ---------- */
+ for(let i=shards.length-1;i>=0;i--){
+ const sh=shards[i];
+ const s=slowAt(sh.x,sh.y);
+ sh.life-=s; sh.rot += sh.rs*s;
+ if(sh.life<=0){ burst(sh.x,sh.y,5,'#dff8ff',1.8,{life:16}); shards.splice(i,1); continue; }
+ sh.x += sh.vx*s; sh.y += sh.vy*s;
+ let bounced=false;
+ if(sh.x<sh.r){ sh.x=sh.r; sh.vx=Math.abs(sh.vx); bounced=true; }
+ if(sh.x>W-sh.r){ sh.x=W-sh.r; sh.vx=-Math.abs(sh.vx); bounced=true; }
+ if(sh.y<sh.r){ sh.y=sh.r; sh.vy=Math.abs(sh.vy); bounced=true; }
+ if(sh.y>H-sh.r){ sh.y=H-sh.r; sh.vy=-Math.abs(sh.vy); bounced=true; }
+ if(bounced){ sh.bounce++; Snd.bounce(); burst(sh.x,sh.y,4,'#e8fbff',2,{life:16,r:1.4}); }
+ if(G.time%4===0) spark(sh.x,sh.y,'#bfeeff',{vx:rnd(.3,-.3),vy:rnd(.3,-.3),life:14,r:1.2,drag:.9});
+ if(ship.alive && ship.inv<=0 && wrapDist(sh,ship) < sh.r+ship.r*0.7) killShip();
+ }
+
+ /* ---------- sentries ---------- */
+ for(let i=sentries.length-1;i>=0;i--){
+ const st=sentries[i];
+ const s=slowAt(st.x,st.y);
+ st.life-=s;
+ if(st.life<=0){ burst(st.x,st.y,12,'#8f9bff',3,{life:26}); sentries.splice(i,1); continue; }
+ st.x += st.vx*s*0.5; st.y += st.vy*s*0.5;
+ st.vx *= 0.995; st.vy *= 0.995;
+ wrapPos(st);
+ if(ship.alive){
+ const [dx,dy]=wrapDelta(st.x,st.y,ship.x,ship.y);
+ st.a = Math.atan2(dy,dx);
+ st.cool -= s;
+ if(st.cool<=0){
+ st.cool = Math.max(52, 100 - G.wave*3);
+ const sp = 3.6;
+ enemyShots.push({ x:st.x+Math.cos(st.a)*st.r, y:st.y+Math.sin(st.a)*st.r,
+ vx:Math.cos(st.a)*sp, vy:Math.sin(st.a)*sp, r:4.6, life:200 });
+ Snd.turretShot();
+ burst(st.x+Math.cos(st.a)*st.r, st.y+Math.sin(st.a)*st.r, 5, '#b4a0ff', 2.4, {life:16});
+ }
+ if(ship.inv<=0 && wrapDist(st,ship) < st.r + ship.r*0.7) killShip();
+ }
+ }
+
+ /* ---------- enemy shots ---------- */
+ for(let i=enemyShots.length-1;i>=0;i--){
+ const es=enemyShots[i];
+ const s=slowAt(es.x,es.y);
+ es.life-=s;
+ es.x += es.vx*s; es.y += es.vy*s;
+ wrapPos(es);
+ if(G.time%2===0) spark(es.x,es.y,'#9d86ff',{vx:rnd(.4,-.4),vy:rnd(.4,-.4),life:14,r:1.5,drag:.9});
+ if(es.life<=0){ enemyShots.splice(i,1); continue; }
+ if(ship.alive && ship.inv<=0 && wrapDist(es,ship) < es.r + ship.r*0.66){
+ enemyShots.splice(i,1); killShip();
+ }
+ }
+
+ /* ---------- powerups ---------- */
+ for(let i=powerups.length-1;i>=0;i--){
+ const p=powerups[i];
+ p.life--; p.ph += 0.09; p.a += 0.03;
+ p.x += p.vx; p.y += p.vy;
+ p.vx *= 0.995; p.vy *= 0.995;
+ wrapPos(p);
+ if(G.time%4===0) spark(p.x,p.y,POWERS[p.kind].col,{vx:rnd(.5,-.5),vy:rnd(.5,-.5),life:18,r:1.5,drag:.92});
+ if(p.life<=0){ burst(p.x,p.y,8,POWERS[p.kind].col,2,{life:20}); powerups.splice(i,1); continue; }
+ if(ship.alive && wrapDist(p,ship) < p.r + ship.r + 4){
+ grantPower(p.kind, p.x, p.y);
+ powerups.splice(i,1);
+ }
+ }
+
+ /* ---------- particles / rings / floats ---------- */
+ for(let i=parts.length-1;i>=0;i--){
+ const p=parts[i];
+ if(--p.life<=0){ parts.splice(i,1); continue; }
+ p.vy += p.grav;
+ p.x += p.vx; p.y += p.vy;
+ p.vx *= p.drag; p.vy *= p.drag;
+ }
+ for(let i=rings.length-1;i>=0;i--){
+ const r=rings[i];
+ if(--r.life<=0){ rings.splice(i,1); continue; }
+ const t = 1 - r.life/r.max;
+ r.cur = lerp(r.r, r.r1, 1-Math.pow(1-t,2.6));
+ }
+ for(let i=floats.length-1;i>=0;i--){
+ const f=floats[i]; f.life--; f.y += f.vy; f.vy *= 0.96;
+ if(f.life<=0) floats.splice(i,1);
+ }
+
+ if(G.comboT>0){ G.comboT--; if(G.comboT===0) G.combo=0; }
+
+ /* ---------- wave flow ---------- */
+ if(orbs.length===0 && G.spawnGuard<=0){
+ G.spawnGuard = 80;
+ G.wave++;
+ const bonus = 500 + G.wave*120;
+ addScore(bonus);
+ floatText(W/2,H/2-40,'WAVE CLEAR +'+bonus,'#8ef7ff',24);
+ if(G.wave % 3 === 0) G.pulses = Math.min(3, G.pulses+1);
+ }
+ if(G.spawnGuard>0){
+ G.spawnGuard--;
+ if(G.spawnGuard===0 && orbs.length===0) spawnWave();
+ }
+
+ /* ---------- decay ---------- */
+ G.shake *= 0.88;
+ if(G.shake < 0.15) G.shake = 0;
+ G.shakeX = G.shake? rnd(G.shake,-G.shake) : 0;
+ G.shakeY = G.shake? rnd(G.shake,-G.shake) : 0;
+ G.flash *= 0.88;
+ if(toastT>0) toastT--;
+ if(G.waveBannerT>0) G.waveBannerT--;
+}
+
+/* ============================================================
+ RENDER
+ ============================================================ */
+function drawTwinkle(){
+ ctx.save();
+ for(const s of starsTwinkle){
+ s.tw += 0.03*s.tws;
+ ctx.globalAlpha = 0.22 + 0.6*(0.5+0.5*Math.sin(s.tw))*s.z;
+ ctx.fillStyle = s.z>0.7? '#dfeaff' : (s.z>0.4? '#9fb4e8' : '#6b7bb0');
+ ctx.fillRect(s.x, s.y, s.s, s.s);
}
+ ctx.restore();
}
-/* ------------------------------------------------------------- the loop --- */
-const keys = {};
-let last = performance.now();
+/** orb silhouette in LOCAL space — caller has already translated to the centre */
+function orbPath(o){
+ ctx.beginPath();
+ const n = o.verts.length;
+ for(let i=0;i<=n;i++){
+ const idx = i%n;
+ const ang = (idx/n)*TAU + o.a;
+ const wob = 1 + Math.sin(o.wobble + idx*1.7)*0.05;
+ const rr = o.r * o.verts[idx] * wob;
+ const x = Math.cos(ang)*rr, y = Math.sin(ang)*rr;
+ if(i===0) ctx.moveTo(x,y); else ctx.lineTo(x,y);
+ }
+ ctx.closePath();
+}
-function update(dt){
- elapsed += dt;
- if (shake > 0) shake = Math.max(0, shake - dt * 1.9);
- if (hitFlash > 0) hitFlash = Math.max(0, hitFlash - dt * 2.4);
+function drawOrbAt(o, ox, oy){
+ const T = ORB_TYPES[o.kind];
+ const pulse = 0.5 + 0.5*Math.sin(o.pulse);
+ ctx.save();
+ ctx.translate(o.x+ox, o.y+oy);
- // cache slow-field sources once per frame
- bubbles = effects.filter(e => e instanceof TimeBubble);
+ // outer glow (cached sprite)
+ blit(glowSprite(T.glow, 0.50, 0.16), 0, 0, o.r*1.85);
- if (state === 'playing'){
- if (reverseT > 0) reverseT -= dt;
- if (comboTimer > 0){ comboTimer -= dt; if (comboTimer <= 0) combo = 0; }
- ship.update(dt);
+ // body (cached local-space gradient)
+ orbPath(o);
+ ctx.fillStyle = orbBodyGrad(o.kind, o.r);
+ ctx.fill();
+ ctx.lineWidth = 2;
+ ctx.strokeStyle = hexA(T.c1, 0.85);
+ ctx.stroke();
- if (waveTransition > 0){
- waveTransition -= dt;
- if (waveTransition <= 0) spawnWave(wave);
- } else if (orbs.length === 0){
- wave++;
- waveTransition = 1.7;
- if (wave > 1){
- const bonus = 250 * (wave - 1);
- score += bonus; grantExtraLives();
- floaters.push(new Floater(W/2, H/2 + 46, 'WAVE CLEAR +' + bonus, '#38f5d0', 2.0, 17));
+ // interior decoration
+ ctx.save();
+ ctx.rotate(o.a);
+ const R = o.r;
+ switch(T.shape){
+ case 'hive': {
+ ctx.strokeStyle = hexA('#7a4400',0.55); ctx.lineWidth=1.4;
+ for(let k=0;k<3;k++){
+ const rr = R*(0.28+k*0.22);
+ ctx.beginPath();
+ for(let i=0;i<6;i++){ const a=i/6*TAU + k*0.3;
+ const x=Math.cos(a)*rr,y=Math.sin(a)*rr; i?ctx.lineTo(x,y):ctx.moveTo(x,y); }
+ ctx.closePath(); ctx.stroke();
+ }
+ ctx.fillStyle = hexA('#5a2f00',0.5);
+ for(let i=0;i<5;i++){ const a=i/5*TAU + o.t*0.02;
+ ctx.beginPath(); ctx.arc(Math.cos(a)*R*0.5, Math.sin(a)*R*0.5, R*0.09,0,TAU); ctx.fill(); }
+ break;
+ }
+ case 'ring': {
+ for(let k=0;k<4;k++){
+ ctx.strokeStyle = hexA('#eaffff', 0.5 - k*0.1);
+ ctx.lineWidth = 1.6;
+ ctx.beginPath();
+ ctx.ellipse(0,0, R*(0.28+k*0.19), R*(0.28+k*0.19)*(0.35+0.2*Math.sin(o.pulse+k)), k*0.5,0,TAU);
+ ctx.stroke();
+ }
+ break;
+ }
+ case 'glitch': {
+ ctx.lineWidth=2.4;
+ for(let k=0;k<5;k++){
+ ctx.strokeStyle = hexA(k%2?'#ffffff':'#dcc6ff', 0.5+0.3*pulse);
+ const a0 = (k/5)*TAU + o.t*0.05;
+ ctx.beginPath(); ctx.arc(0,0,R*(0.35+ (k%3)*0.2), a0, a0+0.9); ctx.stroke();
+ }
+ break;
+ }
+ case 'void': {
+ const vg = ctx.createRadialGradient(0,0,0,0,0,R);
+ vg.addColorStop(0,'#000000'); vg.addColorStop(0.6,'#150a28'); vg.addColorStop(1,'#3a1a66');
+ ctx.fillStyle=vg; ctx.beginPath(); ctx.arc(0,0,R*0.92,0,TAU); ctx.fill();
+ ctx.strokeStyle = hexA('#c07bff', 0.6+0.35*pulse); ctx.lineWidth=2;
+ for(let k=0;k<3;k++){
+ ctx.beginPath();
+ ctx.ellipse(0,0,R*(0.5+k*0.16), R*0.18, o.t*0.03 + k*1.1, 0, TAU); ctx.stroke();
+ }
+ break;
+ }
+ case 'spiral': {
+ ctx.strokeStyle = hexA('#ffffff',0.6); ctx.lineWidth=2.2;
+ ctx.beginPath();
+ for(let i=0;i<70;i++){
+ const t=i/70, a=t*TAU*2.6 + o.t*0.04, rr=R*0.92*t;
+ const x=Math.cos(a)*rr, y=Math.sin(a)*rr;
+ i?ctx.lineTo(x,y):ctx.moveTo(x,y);
+ }
+ ctx.stroke();
+ break;
+ }
+ case 'crystal': {
+ ctx.strokeStyle = hexA('#ffffff',0.72); ctx.lineWidth=1.5;
+ for(let i=0;i<7;i++){
+ const a=i/7*TAU;
+ ctx.beginPath(); ctx.moveTo(0,0);
+ ctx.lineTo(Math.cos(a)*R*0.92, Math.sin(a)*R*0.92); ctx.stroke();
+ }
+ ctx.beginPath();
+ for(let i=0;i<7;i++){ const a=i/7*TAU + .45;
+ const x=Math.cos(a)*R*0.55,y=Math.sin(a)*R*0.55; i?ctx.lineTo(x,y):ctx.moveTo(x,y); }
+ ctx.closePath(); ctx.strokeStyle=hexA('#ffffff',0.5); ctx.stroke();
+ break;
+ }
+ case 'clock': {
+ ctx.strokeStyle=hexA('#04321f',0.7); ctx.lineWidth=2.6;
+ ctx.beginPath(); ctx.arc(0,0,R*0.62,0,TAU); ctx.stroke();
+ ctx.lineWidth=3;
+ const t1 = o.t*0.05, t2 = o.t*0.012;
+ ctx.beginPath(); ctx.moveTo(0,0); ctx.lineTo(Math.cos(t1)*R*0.5, Math.sin(t1)*R*0.5); ctx.stroke();
+ ctx.beginPath(); ctx.moveTo(0,0); ctx.lineTo(Math.cos(t2)*R*0.34, Math.sin(t2)*R*0.34); ctx.stroke();
+ ctx.fillStyle=hexA('#04321f',0.55);
+ for(let i=0;i<12;i++){ const a=i/12*TAU;
+ ctx.fillRect(Math.cos(a)*R*0.74-1, Math.sin(a)*R*0.74-1, 2.4,2.4); }
+ break;
+ }
+ case 'bomb': {
+ ctx.fillStyle = hexA('#ffe08a', 0.55+0.4*pulse);
+ ctx.beginPath(); ctx.arc(0,0,R*0.34*(0.85+0.25*pulse),0,TAU); ctx.fill();
+ ctx.strokeStyle=hexA('#ffd9b0',0.8); ctx.lineWidth=2.4;
+ for(let i=0;i<6;i++){ const a=i/6*TAU + o.t*0.03;
+ ctx.beginPath();
+ ctx.moveTo(Math.cos(a)*R*0.46, Math.sin(a)*R*0.46);
+ ctx.lineTo(Math.cos(a)*R*0.82, Math.sin(a)*R*0.82); ctx.stroke(); }
+ break;
+ }
+ case 'mirror': {
+ ctx.save();
+ ctx.beginPath(); ctx.arc(0,0,R*0.92,0,TAU); ctx.clip();
+ ctx.fillStyle = hexA('#ffffff',0.35);
+ ctx.beginPath(); ctx.moveTo(-R,-R); ctx.lineTo(R,-R); ctx.lineTo(-R,R); ctx.closePath(); ctx.fill();
+ ctx.strokeStyle=hexA('#ffffff',0.85); ctx.lineWidth=2;
+ ctx.beginPath(); ctx.moveTo(R*0.9,-R*0.9); ctx.lineTo(-R*0.9,R*0.9); ctx.stroke();
+ ctx.restore();
+ break;
+ }
+ case 'rubber': {
+ ctx.save();
+ ctx.beginPath(); ctx.arc(0,0,R*0.9,0,TAU); ctx.clip();
+ ctx.strokeStyle=hexA('#2f5c04',0.5); ctx.lineWidth=R*0.16;
+ for(let i=-3;i<=3;i++){ ctx.beginPath(); ctx.moveTo(-R, i*R*0.4); ctx.lineTo(R, i*R*0.4 + R*0.5); ctx.stroke(); }
+ ctx.restore();
+ break;
+ }
+ case 'star': {
+ ctx.fillStyle = hexA('#fff6d0', 0.5+0.45*pulse);
+ ctx.beginPath();
+ for(let i=0;i<12;i++){
+ const a = i/12*TAU + o.t*0.02;
+ const rr = R*(i%2? 0.30 : 0.86);
+ const x=Math.cos(a)*rr, y=Math.sin(a)*rr;
+ i?ctx.lineTo(x,y):ctx.moveTo(x,y);
}
- Snd.waveUp();
+ ctx.closePath(); ctx.fill();
+ ctx.strokeStyle = hexA('#ff7a18',0.8); ctx.lineWidth=1.6; ctx.stroke();
+ ctx.fillStyle = hexA('#ffffff',0.85);
+ ctx.beginPath(); ctx.arc(0,0,R*0.18*(0.8+0.4*pulse),0,TAU); ctx.fill();
+ break;
}
- } else if (state === 'start'){
- // attract mode: keep a lazy drift of orbs behind the menu
- if (orbs.length < 11 && Math.random() < dt * 5){
- const o = new Orb(rand(0, W), rand(0, H), pick(SPAWNABLE), 3);
- o.vx *= 0.5; o.vy *= 0.5;
- orbs.push(o);
+ case 'eye': {
+ ctx.fillStyle = hexA('#0c1030',0.85);
+ ctx.beginPath(); ctx.ellipse(0,0,R*0.78,R*0.5,0,0,TAU); ctx.fill();
+ ctx.strokeStyle = hexA('#c3ccff',0.75); ctx.lineWidth=2; ctx.stroke();
+ const look = o.t*0.03;
+ ctx.fillStyle = '#7a86ff';
+ ctx.beginPath(); ctx.arc(Math.cos(look)*R*0.26, Math.sin(look)*R*0.14, R*0.24,0,TAU); ctx.fill();
+ ctx.fillStyle = '#04061a';
+ ctx.beginPath(); ctx.arc(Math.cos(look)*R*0.26, Math.sin(look)*R*0.14, R*0.11,0,TAU); ctx.fill();
+ ctx.strokeStyle = hexA('#7a86ff',0.45); ctx.lineWidth=1.4;
+ for(let i=0;i<4;i++){
+ const a=i/4*TAU + o.t*0.02;
+ ctx.beginPath();
+ ctx.moveTo(Math.cos(a)*R*0.82, Math.sin(a)*R*0.82);
+ ctx.lineTo(Math.cos(a)*R*0.95, Math.sin(a)*R*0.95); ctx.stroke();
+ }
+ break;
+ }
+ case 'portal': {
+ ctx.strokeStyle = hexA('#ffffff',0.55); ctx.lineWidth=2;
+ for(let k=0;k<3;k++){
+ ctx.beginPath();
+ for(let i=0;i<48;i++){
+ const t=i/48, a=t*TAU*1.6 + o.t*0.06 + k*2.1, rr=R*0.9*(1-t*0.8);
+ const x=Math.cos(a)*rr, y=Math.sin(a)*rr;
+ i?ctx.lineTo(x,y):ctx.moveTo(x,y);
+ }
+ ctx.stroke();
+ }
+ const pg = ctx.createRadialGradient(0,0,0,0,0,R*0.5);
+ pg.addColorStop(0,'rgba(255,255,255,.9)'); pg.addColorStop(1,'rgba(47,232,213,0)');
+ ctx.fillStyle=pg; ctx.beginPath(); ctx.arc(0,0,R*0.5,0,TAU); ctx.fill();
+ break;
+ }
+ }
+ ctx.restore();
+
+ // specular
+ ctx.beginPath();
+ ctx.arc(-o.r*0.33, -o.r*0.38, o.r*0.2, 0, TAU);
+ ctx.fillStyle = 'rgba(255,255,255,.55)'; ctx.fill();
+
+ // elder crown
+ if(o.elder){
+ ctx.strokeStyle = `rgba(255,240,180,${0.45+0.35*Math.abs(Math.sin(G.time*0.06))})`;
+ ctx.lineWidth = 3;
+ ctx.setLineDash([10,9]);
+ ctx.beginPath(); ctx.arc(0,0,o.r+13,0,TAU); ctx.stroke();
+ ctx.setLineDash([]);
+ }
+
+ // hp pips
+ if(o.maxhp>1 && o.hp<o.maxhp){
+ const n=Math.min(o.maxhp,10);
+ for(let i=0;i<n;i++){
+ const a = -Math.PI/2 + (i-(n-1)/2)*0.24;
+ ctx.beginPath(); ctx.arc(Math.cos(a)*(o.r+11), Math.sin(a)*(o.r+11), 2.6, 0, TAU);
+ ctx.fillStyle = i<o.hp ? T.c1 : 'rgba(255,255,255,.16)';
+ ctx.fill();
}
}
- for (const o of orbs) if (!o.dead) o.update(dt);
- for (const b of bullets) if (!b.dead) b.update(dt);
- for (const s of shards) if (!s.dead) s.update(dt);
- for (const e of effects) if (!e.dead) e.update(dt);
- for (const f of floaters) if (!f.dead) f.update(dt);
- updateParticles(dt);
+ // chain fuse warning
+ if(o.chainT>=0){
+ ctx.strokeStyle = `rgba(255,120,80,${0.4+0.6*Math.abs(Math.sin(G.time*0.5))})`;
+ ctx.lineWidth = 3;
+ ctx.beginPath(); ctx.arc(0,0,o.r+7,0,TAU); ctx.stroke();
+ }
- if (state === 'playing') collide();
+ // hit flash
+ if(o.hitFlash>0){
+ ctx.globalCompositeOperation='lighter';
+ ctx.fillStyle = `rgba(255,255,255,${o.hitFlash/22})`;
+ orbPath(o); ctx.fill();
+ ctx.globalCompositeOperation='source-over';
+ }
- if (orbs.length) orbs = orbs.filter(o => !o.dead);
- if (bullets.length) bullets = bullets.filter(b => !b.dead);
- if (shards.length) shards = shards.filter(s => !s.dead);
- if (effects.length) effects = effects.filter(e => !e.dead);
- if (floaters.length)floaters= floaters.filter(f => !f.dead);
+ ctx.restore();
+}
- if (gameOverDelay > 0) gameOverDelay -= dt;
- Snd.music();
+function drawOrbs(){
+ for(const o of orbs){
+ if(o.kind==='rubber') drawOrbAt(o,0,0); // never wraps, never needs a mirror copy
+ else wrapDraw(o.x,o.y,o.r*2.4,(ox,oy)=> drawOrbAt(o,ox,oy));
+ }
}
-function render(dt){
- // motion-trail fade instead of a hard clear
- ctx.fillStyle = 'rgba(5,6,14,0.30)';
- ctx.fillRect(0, 0, W, H);
+function shipPath(r){
+ ctx.beginPath();
+ ctx.moveTo(r*1.35, 0);
+ ctx.lineTo(-r*0.72, -r*0.82);
+ ctx.lineTo(-r*0.34, 0);
+ ctx.lineTo(-r*0.72, r*0.82);
+ ctx.closePath();
+}
+function drawShipAt(ox,oy){
+ const blink = ship.inv>0 && (Math.floor(G.time/4)%2===0);
ctx.save();
- if (shake > 0.001){
- const s = shake * shake;
- ctx.translate(rand(-1,1) * s * 16, rand(-1,1) * s * 16);
- ctx.rotate(rand(-1,1) * s * 0.014);
+ ctx.translate(ship.x+ox, ship.y+oy);
+ ctx.rotate(ship.a);
+ if(ship.recoil>0.2) ctx.translate(-ship.recoil,0);
+
+ blit(glowSprite('#78f0ff', 0.45, 0.16), 0, 0, 34);
+
+ if(ship.thrust>0.05){
+ const fl = (0.7+0.5*Math.random())*ship.thrust;
+ if(!shipFlameGrad){
+ shipFlameGrad = ctx.createLinearGradient(-ship.r, 0, -ship.r-30, 0);
+ shipFlameGrad.addColorStop(0,'rgba(255,255,255,.95)');
+ shipFlameGrad.addColorStop(0.3,'rgba(120,230,255,.85)');
+ shipFlameGrad.addColorStop(0.7,'rgba(255,150,60,.6)');
+ shipFlameGrad.addColorStop(1,'rgba(255,60,20,0)');
+ }
+ ctx.fillStyle=shipFlameGrad;
+ ctx.beginPath();
+ ctx.moveTo(-ship.r*0.62, -5.4);
+ ctx.lineTo(-ship.r - 30*fl, 0);
+ ctx.lineTo(-ship.r*0.62, 5.4);
+ ctx.closePath(); ctx.fill();
+ }
+
+ if(!blink){
+ shipPath(ship.r);
+ if(!shipHullGrad){
+ shipHullGrad = ctx.createLinearGradient(-ship.r,0,ship.r,0);
+ shipHullGrad.addColorStop(0,'#1a2a52'); shipHullGrad.addColorStop(0.55,'#dff2ff'); shipHullGrad.addColorStop(1,'#ffffff');
+ }
+ ctx.fillStyle=shipHullGrad; ctx.fill();
+ ctx.lineWidth=2; ctx.strokeStyle= G.reverseT>0? '#ff62c0' : '#7df9ff'; ctx.stroke();
+ ctx.beginPath(); ctx.ellipse(2,0,4.4,3,0,0,TAU);
+ ctx.fillStyle='#0d2b45'; ctx.fill();
+ ctx.strokeStyle='rgba(160,240,255,.9)'; ctx.lineWidth=1; ctx.stroke();
+ }
+
+ if(ship.shield>0){
+ const a = 0.35+0.28*Math.abs(Math.sin(G.time*0.08));
+ ctx.beginPath(); ctx.arc(0,0, ship.r+12, 0, TAU);
+ ctx.strokeStyle = `rgba(142,255,192,${a})`; ctx.lineWidth=2.6; ctx.stroke();
+ ctx.beginPath(); ctx.arc(0,0, ship.r+12, 0, TAU);
+ ctx.fillStyle = `rgba(142,255,192,${a*0.10})`; ctx.fill();
+ }
+ if(ship.inv>0){
+ ctx.beginPath(); ctx.arc(0,0, ship.r+9, 0, TAU);
+ ctx.strokeStyle = `rgba(140,240,255,${0.25+0.35*Math.abs(Math.sin(G.time*0.16))})`;
+ ctx.lineWidth=2; ctx.stroke();
}
+ ctx.restore();
+}
- drawStars(ctx, dt);
+function drawShip(){
+ if(!ship.alive) return;
+ // trail ghosts (wrap-safe: a segment flagged `cut` is skipped)
+ ctx.save();
+ for(let i=0;i<ship.trail.length;i+=2){
+ const t = ship.trail[i];
+ if(t.cut) continue;
+ if(Math.abs(t.x-ship.x)>W*0.5 || Math.abs(t.y-ship.y)>H*0.5) continue;
+ const a = (i/ship.trail.length)*0.20;
+ ctx.save(); ctx.translate(t.x,t.y); ctx.rotate(t.a);
+ ctx.globalAlpha=a; ctx.fillStyle='#5ce8ff';
+ shipPath(ship.r*0.9); ctx.fill(); ctx.restore();
+ }
+ ctx.restore();
+ wrapDraw(ship.x, ship.y, 46, drawShipAt);
+}
- ctx.globalCompositeOperation = 'lighter';
- for (const e of effects) e.draw(ctx);
- ctx.globalCompositeOperation = 'source-over';
+function drawBullets(){
+ ctx.save();
+ ctx.globalCompositeOperation='lighter';
+ for(const b of bullets){
+ for(let i=0;i<b.trail.length;i++){
+ const t=b.trail[i];
+ const a=(i/b.trail.length);
+ if(Math.abs(t.x-b.x)>W/2 || Math.abs(t.y-b.y)>H/2) continue;
+ ctx.beginPath(); ctx.arc(t.x,t.y, b.r*a*0.95, 0, TAU);
+ ctx.fillStyle=`rgba(110,230,255,${a*0.42})`; ctx.fill();
+ }
+ blit(bulletSprite(), b.x, b.y, b.r*4.5);
+ ctx.fillStyle='#ffffff'; ctx.beginPath(); ctx.arc(b.x,b.y,b.r*0.8,0,TAU); ctx.fill();
+ }
+ ctx.restore();
+}
- for (const o of orbs) o.draw(ctx);
- for (const s of shards) s.draw(ctx);
+function drawHunters(){
+ for(const h of hunters){
+ wrapDraw(h.x,h.y,22,(ox,oy)=>{
+ const fade = Math.min(1, h.life/70);
+ ctx.save(); ctx.translate(h.x+ox,h.y+oy); ctx.rotate(h.a);
+ ctx.globalAlpha = fade;
+ blit(glowSprite('#ffbe50', 0.7, 0.26), 0, 0, 16);
+ ctx.beginPath();
+ ctx.moveTo(9,0); ctx.lineTo(-5,-5.4); ctx.lineTo(-2.4,0); ctx.lineTo(-5,5.4);
+ ctx.closePath();
+ ctx.fillStyle='#ffd66b'; ctx.fill();
+ ctx.strokeStyle='#ff8c1e'; ctx.lineWidth=1.4; ctx.stroke();
+ ctx.beginPath(); ctx.arc(3,0,1.7,0,TAU); ctx.fillStyle='#5a1500'; ctx.fill();
+ ctx.restore();
+ });
+ }
+}
- ctx.globalCompositeOperation = 'lighter';
- for (const b of bullets) b.draw(ctx);
- ctx.globalCompositeOperation = 'source-over';
+function drawShards(){
+ for(const s of shards){
+ const fade=Math.min(1,s.life/70);
+ ctx.save(); ctx.translate(s.x,s.y); ctx.rotate(s.rot); ctx.globalAlpha=fade;
+ ctx.globalCompositeOperation='lighter';
+ blit(glowSprite('#dcfaff', 0.65, 0.24), 0, 0, 14);
+ ctx.globalCompositeOperation='source-over';
+ ctx.beginPath();
+ ctx.moveTo(0,-7.5); ctx.lineTo(5.6,1.4); ctx.lineTo(1.7,7.4); ctx.lineTo(-4.4,3.4); ctx.closePath();
+ const lg=ctx.createLinearGradient(-6,-6,6,6);
+ lg.addColorStop(0,'#ffffff'); lg.addColorStop(0.5,'#c9f4ff'); lg.addColorStop(1,'#6fa9c4');
+ ctx.fillStyle=lg; ctx.fill();
+ ctx.strokeStyle='rgba(255,255,255,.9)'; ctx.lineWidth=1; ctx.stroke();
+ ctx.restore();
+ }
+}
- if (ship && state !== 'start') ship.draw(ctx);
- drawParticles(ctx);
- for (const f of floaters) f.draw(ctx);
+function drawSentries(){
+ for(const s of sentries){
+ wrapDraw(s.x,s.y,40,(ox,oy)=>{
+ const fade = Math.min(1, s.life/70);
+ ctx.save(); ctx.translate(s.x+ox,s.y+oy); ctx.globalAlpha=fade;
+ blit(glowSprite('#7886ff', 0.34, 0.12), 0, 0, 34);
+
+ ctx.save();
+ ctx.rotate(s.a);
+ ctx.fillStyle='#39408f';
+ ctx.fillRect(0, -4, s.r+11, 8);
+ ctx.fillStyle='#8f9bff';
+ ctx.fillRect(s.r+3, -3, 8, 6);
+ ctx.restore();
+
+ ctx.beginPath(); ctx.arc(0,0,s.r,0,TAU);
+ ctx.fillStyle = sentryBodyGrad(s.r); ctx.fill();
+ ctx.strokeStyle='#c3ccff'; ctx.lineWidth=1.8; ctx.stroke();
+
+ const ready = s.cool < 22;
+ ctx.beginPath(); ctx.arc(0,0,s.r*0.36,0,TAU);
+ ctx.fillStyle = ready ? `rgba(255,90,120,${0.6+0.4*Math.abs(Math.sin(G.time*0.4))})` : 'rgba(160,175,255,.85)';
+ ctx.fill();
+
+ if(s.hp<2){
+ ctx.strokeStyle='rgba(255,120,120,.8)'; ctx.lineWidth=2;
+ ctx.beginPath(); ctx.arc(0,0,s.r+5,-0.6,1.4); ctx.stroke();
+ }
+ ctx.restore();
+ });
+ }
+}
+function drawEnemyShots(){
+ ctx.save();
+ ctx.globalCompositeOperation='lighter';
+ for(const e of enemyShots){
+ blit(enemyShotSprite(), e.x, e.y, e.r*3.4);
+ ctx.fillStyle='#efe6ff'; ctx.beginPath(); ctx.arc(e.x,e.y,e.r*0.6,0,TAU); ctx.fill();
+ }
ctx.restore();
+}
+
+function drawPowerups(){
+ for(const p of powerups){
+ const P = POWERS[p.kind];
+ const blink = p.life<120 && Math.floor(p.life/6)%2===0;
+ if(blink) continue;
+ wrapDraw(p.x,p.y,36,(ox,oy)=>{
+ const x=p.x+ox, y=p.y+oy;
+ const bob = Math.sin(p.ph)*2.5;
+ ctx.save();
+ ctx.translate(x,y+bob);
+ blit(glowSprite(P.col, 0.5, 0.18), 0, 0, 26);
+ ctx.rotate(p.a);
+ const s=p.r;
+ ctx.beginPath();
+ ctx.moveTo(-s,0); ctx.lineTo(0,-s); ctx.lineTo(s,0); ctx.lineTo(0,s); ctx.closePath();
+ ctx.fillStyle='rgba(8,10,26,.9)'; ctx.fill();
+ ctx.strokeStyle=P.col; ctx.lineWidth=2.2; ctx.stroke();
+ ctx.rotate(-p.a);
+ ctx.fillStyle=P.col;
+ ctx.font='900 13px "Trebuchet MS", sans-serif';
+ ctx.textAlign='center'; ctx.textBaseline='middle';
+ ctx.fillText(P.glyph, 0, 0.5);
+ ctx.restore();
+ });
+ }
+}
+
+function drawWells(){
+ for(const w of wells){
+ const f = Math.min(1, w.life/60);
+ ctx.save();
+ ctx.globalAlpha = 0.85*f;
+ const g = ctx.createRadialGradient(w.x,w.y, w.r*0.05, w.x,w.y, w.r);
+ g.addColorStop(0,'rgba(190,250,255,.42)');
+ g.addColorStop(0.35,'rgba(70,180,255,.16)');
+ g.addColorStop(1,'rgba(30,80,220,0)');
+ ctx.fillStyle=g; ctx.beginPath(); ctx.arc(w.x,w.y,w.r,0,TAU); ctx.fill();
+ ctx.strokeStyle='rgba(140,235,255,.32)'; ctx.lineWidth=1.5;
+ for(let k=0;k<4;k++){
+ ctx.beginPath();
+ const rr = w.r*(0.22+k*0.2) * (0.9+0.1*Math.sin(w.a*2+k));
+ ctx.ellipse(w.x,w.y, rr, rr*0.55, w.a + k*0.7, 0, TAU); ctx.stroke();
+ }
+ const cg=ctx.createRadialGradient(w.x,w.y,0,w.x,w.y,22);
+ cg.addColorStop(0,'rgba(255,255,255,.9)'); cg.addColorStop(1,'rgba(90,200,255,0)');
+ ctx.fillStyle=cg; ctx.beginPath(); ctx.arc(w.x,w.y,22,0,TAU); ctx.fill();
+ ctx.restore();
+ }
+}
+
+function drawHoles(){
+ for(const h of holes){
+ ctx.save();
+ blit(glowSprite('#be6eff', 0.36, 0.14), h.x, h.y, h.pull*0.58);
+
+ ctx.globalCompositeOperation='lighter';
+ for(let k=0;k<5;k++){
+ ctx.strokeStyle=`rgba(${180+k*10},${90+k*20},255,${0.30-k*0.045})`;
+ ctx.lineWidth = 3.4-k*0.5;
+ ctx.beginPath();
+ const rr = h.r*(1.6+k*0.9);
+ ctx.ellipse(h.x,h.y, rr, rr*0.42, h.a*(1+k*0.3), 0.3, 0.3+TAU*0.78);
+ ctx.stroke();
+ }
+ ctx.globalCompositeOperation='source-over';
+
+ ctx.beginPath(); ctx.arc(h.x,h.y,Math.max(2,h.r),0,TAU);
+ ctx.fillStyle='#000'; ctx.fill();
+ ctx.strokeStyle='rgba(220,160,255,.85)'; ctx.lineWidth=2.4; ctx.stroke();
+ ctx.beginPath(); ctx.arc(h.x,h.y,Math.max(3,h.r*1.22),0,TAU);
+ ctx.strokeStyle=`rgba(255,220,255,${0.25+0.2*Math.sin(h.a*3)})`; ctx.lineWidth=1.4; ctx.stroke();
+ ctx.restore();
+ }
+}
- // reversed-controls warning tint
- if (reverseT > 0){
- const p = (0.10 + 0.06 * Math.sin(elapsed * 9)) * clamp(reverseT, 0, 1);
- const g = ctx.createRadialGradient(W/2, H/2, Math.min(W,H) * 0.25, W/2, H/2, Math.max(W,H) * 0.72);
- g.addColorStop(0, 'rgba(255,154,31,0)');
- g.addColorStop(1, 'rgba(255,154,31,' + p.toFixed(3) + ')');
- ctx.fillStyle = g; ctx.fillRect(0, 0, W, H);
+function drawBubbles(){
+ for(const b of bubbles){
+ const f = Math.min(1, b.life/80);
+ ctx.save();
+ ctx.globalAlpha = f;
+ const g=ctx.createRadialGradient(b.x,b.y,b.r*0.2,b.x,b.y,b.r);
+ g.addColorStop(0,'rgba(120,255,190,.05)');
+ g.addColorStop(0.75,'rgba(90,240,180,.10)');
+ g.addColorStop(0.96,'rgba(150,255,210,.28)');
+ g.addColorStop(1,'rgba(150,255,210,0)');
+ ctx.fillStyle=g; ctx.beginPath(); ctx.arc(b.x,b.y,b.r,0,TAU); ctx.fill();
+ ctx.strokeStyle=`rgba(160,255,215,${0.35+0.2*Math.sin(b.ph*2)})`;
+ ctx.lineWidth=2; ctx.beginPath(); ctx.arc(b.x,b.y,b.r,0,TAU); ctx.stroke();
+ ctx.strokeStyle='rgba(160,255,215,.22)'; ctx.lineWidth=1.4;
+ for(let i=0;i<16;i++){
+ const a=i/16*TAU + b.ph*0.3;
+ ctx.beginPath();
+ ctx.moveTo(b.x+Math.cos(a)*b.r*0.88, b.y+Math.sin(a)*b.r*0.88);
+ ctx.lineTo(b.x+Math.cos(a)*b.r*0.96, b.y+Math.sin(a)*b.r*0.96);
+ ctx.stroke();
+ }
+ ctx.restore();
}
- if (hitFlash > 0){
- ctx.fillStyle = 'rgba(255,255,255,' + (hitFlash * 0.32).toFixed(3) + ')';
- ctx.fillRect(0, 0, W, H);
+}
+
+function drawShockwaves(){
+ ctx.save();
+ ctx.globalCompositeOperation='lighter';
+ for(const sw of waves){
+ const t = 1 - sw.r/sw.rMax;
+ ctx.globalAlpha = clamp(t*1.1,0,1);
+ ctx.strokeStyle = sw.col;
+ ctx.lineWidth = 3 + 9*t;
+ ctx.beginPath(); ctx.arc(sw.x,sw.y,sw.r,0,TAU); ctx.stroke();
+ ctx.globalAlpha = clamp(t*0.5,0,1);
+ ctx.lineWidth = 1.5;
+ ctx.beginPath(); ctx.arc(sw.x,sw.y,Math.max(1,sw.r - sw.band),0,TAU); ctx.stroke();
}
+ ctx.restore();
+}
- if (state === 'playing' || state === 'paused') drawHUD();
- if (state === 'start') drawChips();
+function drawParticles(){
+ ctx.save();
+ ctx.globalCompositeOperation='lighter';
+ for(const p of parts){
+ const a = p.life/p.max;
+ ctx.globalAlpha = Math.min(1,a*1.3);
+ ctx.fillStyle = p.col;
+ ctx.beginPath();
+ ctx.arc(p.x,p.y, Math.max(0.3, p.r*a), 0, TAU);
+ ctx.fill();
+ }
+ ctx.restore();
+}
+function drawRings(){
+ ctx.save();
+ ctx.globalCompositeOperation='lighter';
+ for(const r of rings){
+ const t = r.life/r.max;
+ ctx.globalAlpha = t*0.85;
+ ctx.strokeStyle = r.col;
+ ctx.lineWidth = Math.max(0.4, r.w*t);
+ ctx.beginPath(); ctx.arc(r.x,r.y, Math.max(1,r.cur), 0, TAU); ctx.stroke();
+ }
+ ctx.restore();
+}
+function drawFloats(){
+ ctx.save();
+ ctx.textAlign='center'; ctx.textBaseline='middle';
+ for(const f of floats){
+ const a = Math.min(1, f.life/28);
+ ctx.globalAlpha = a;
+ ctx.font = `900 ${f.size}px "Trebuchet MS", sans-serif`;
+ ctx.lineWidth=4; ctx.strokeStyle='rgba(0,0,0,.6)';
+ ctx.strokeText(f.txt, f.x, f.y);
+ ctx.fillStyle=f.col;
+ ctx.shadowColor=f.col; ctx.shadowBlur=14;
+ ctx.fillText(f.txt, f.x, f.y);
+ ctx.shadowBlur=0;
+ }
+ ctx.restore();
}
-/* ------------------------------------------------------------------ HUD --- */
-function shipGlyph(g, x, y, s){
- g.save(); g.translate(x, y); g.scale(s, s);
- g.beginPath(); g.moveTo(8, 0); g.lineTo(-6, 5.5); g.lineTo(-3, 0); g.lineTo(-6, -5.5);
- g.closePath();
- g.fillStyle = '#d8fbff'; g.fill();
- g.restore();
+/* ---------------- HUD ---------------- */
+function drawMeter(x,y,w,pct,col,label){
+ ctx.fillStyle='rgba(255,255,255,.12)';
+ ctx.fillRect(x,y,w,5);
+ ctx.fillStyle=col;
+ ctx.fillRect(x,y,w*clamp(pct,0,1),5);
+ ctx.font='700 9px "Trebuchet MS", sans-serif';
+ ctx.fillStyle=col; ctx.textAlign='left';
+ ctx.fillText(label, x, y-6);
}
+
function drawHUD(){
- const g = ctx;
- g.font = '700 13px ui-monospace, Menlo, monospace';
- g.textAlign = 'left';
-
- g.fillStyle = 'rgba(150,170,205,.55)';
- g.fillText('SCORE', 22, 30);
- g.font = '700 26px ui-monospace, Menlo, monospace';
- g.fillStyle = '#ffffff';
- g.fillText(score.toLocaleString(), 22, 58);
-
- g.font = '700 11px ui-monospace, Menlo, monospace';
- g.fillStyle = 'rgba(150,170,205,.5)';
- g.fillText('BEST ' + Math.max(best, score).toLocaleString(), 22, 76);
-
- for (let i = 0; i < Math.min(lives, 8); i++) shipGlyph(g, 30 + i * 20, 96, 1.15);
- if (lives > 8){
- g.fillStyle = '#d8fbff';
- g.fillText('+' + (lives - 8), 30 + 8 * 20, 100);
- }
-
- // wave + combo, right side
- g.textAlign = 'right';
- g.font = '700 11px ui-monospace, Menlo, monospace';
- g.fillStyle = 'rgba(150,170,205,.55)';
- g.fillText('WAVE', W - 22, 30);
- g.font = '700 26px ui-monospace, Menlo, monospace';
- g.fillStyle = '#38f5d0';
- g.fillText(String(Math.max(1, wave)), W - 22, 58);
-
- const m = comboMult();
- if (m > 1){
- g.font = '700 15px ui-monospace, Menlo, monospace';
- g.fillStyle = '#ffd75e';
- g.fillText('COMBO ×' + m, W - 22, 82);
- const wpx = 90, pct = clamp(comboTimer / 2.4, 0, 1);
- g.fillStyle = 'rgba(255,215,94,.22)'; g.fillRect(W - 22 - wpx, 90, wpx, 3);
- g.fillStyle = '#ffd75e'; g.fillRect(W - 22 - wpx, 90, wpx * pct, 3);
- }
- g.textAlign = 'left';
-
- // status banner
- if (reverseT > 0){
- g.textAlign = 'center';
- g.font = '700 15px ui-monospace, Menlo, monospace';
- g.fillStyle = 'rgba(255,154,31,' + (0.55 + 0.45 * Math.sin(elapsed * 8)).toFixed(2) + ')';
- g.fillText('<-> CONTROLS REVERSED ' + reverseT.toFixed(1) + 's', W/2, H - 34);
- g.textAlign = 'left';
+ ctx.save();
+ ctx.textBaseline='top';
+
+ // score
+ const sc = String(G.score).padStart(6,'0');
+ ctx.font='900 30px "Trebuchet MS", sans-serif';
+ ctx.textAlign='left';
+ ctx.fillStyle='rgba(0,0,0,.5)'; ctx.fillText(sc, 21,19);
+ ctx.fillStyle='#eaf4ff'; ctx.shadowColor='#5ad4ff'; ctx.shadowBlur=14;
+ ctx.fillText(sc, 20,18);
+ ctx.shadowBlur=0;
+
+ ctx.font='700 12px "Trebuchet MS", sans-serif';
+ ctx.fillStyle='#7f8fbd';
+ ctx.fillText('BEST ' + G.best.toLocaleString(), 21, 52);
+
+ // wave + orb count
+ ctx.textAlign='right';
+ ctx.font='900 22px "Trebuchet MS", sans-serif';
+ ctx.fillStyle='#c9d8ff'; ctx.shadowColor='#8a6bff'; ctx.shadowBlur=12;
+ ctx.fillText('WAVE ' + G.wave, W-20, 20);
+ ctx.shadowBlur=0;
+ ctx.font='700 12px "Trebuchet MS", sans-serif';
+ ctx.fillStyle='#7f8fbd';
+ ctx.fillText(orbs.length + ' ORBS', W-21, 48);
+
+ // lives
+ const shown = Math.min(G.lives, 8);
+ for(let i=0;i<shown;i++){
+ ctx.save();
+ ctx.translate(30 + i*26, 84);
+ ctx.rotate(-Math.PI/2);
+ ctx.scale(0.72,0.72);
+ shipPath(11);
+ ctx.fillStyle='#9ff0ff'; ctx.fill();
+ ctx.strokeStyle='#2b7fa8'; ctx.lineWidth=1.6; ctx.stroke();
+ ctx.restore();
+ }
+ if(G.lives>8){
+ ctx.textAlign='left'; ctx.font='800 13px "Trebuchet MS", sans-serif';
+ ctx.fillStyle='#9ff0ff'; ctx.fillText('+'+(G.lives-8), 30+8*26, 78);
+ }
+
+ // pulse charges
+ ctx.textAlign='left';
+ for(let i=0;i<3;i++){
+ const x = 22 + i*17, y = 108;
+ ctx.beginPath();
+ ctx.moveTo(x, y+6); ctx.lineTo(x+6, y-6); ctx.lineTo(x+12, y+6); ctx.closePath();
+ ctx.fillStyle = i<G.pulses ? '#ff8ad4' : 'rgba(255,138,212,.16)';
+ ctx.fill();
+ }
+ ctx.font='700 9px "Trebuchet MS", sans-serif';
+ ctx.fillStyle='#8b6f9e'; ctx.fillText('PULSE (SHIFT)', 22, 118);
+
+ // active power meters
+ let my = 138;
+ if(G.spreadT>0){ drawMeter(22,my,90,G.spreadT/1800,'#7df9ff','SPREAD'); my+=22; }
+ if(G.rapidT>0){ drawMeter(22,my,90,G.rapidT/1800,'#ffd76b','RAPID'); my+=22; }
+ if(G.hyperCool>0){ drawMeter(22,my,90,1-G.hyperCool/260,'#8ef7ff','WARP DRIVE'); my+=22; }
+ if(ship.shield>0){
+ ctx.font='800 11px "Trebuchet MS", sans-serif';
+ ctx.fillStyle='#8effc0'; ctx.fillText('◇ SHIELD ACTIVE', 22, my); my+=18;
+ }
+
+ // combo
+ if(G.combo>1){
+ ctx.textAlign='left';
+ ctx.font='900 17px "Trebuchet MS", sans-serif';
+ ctx.globalAlpha = Math.min(1,G.comboT/30);
+ ctx.fillStyle='#ffd76b'; ctx.shadowColor='#ff9d2e'; ctx.shadowBlur=12;
+ ctx.fillText('x'+G.combo+' CHAIN', 22, my);
+ ctx.shadowBlur=0; ctx.globalAlpha=1;
+ }
+
+ // reverse warning
+ if(G.reverseT>0){
+ const a = 0.6+0.4*Math.sin(G.time*0.3);
+ ctx.textAlign='center';
+ ctx.font='900 clamp(16px,4vw,24px) "Trebuchet MS", sans-serif';
+ ctx.fillStyle=`rgba(255,98,192,${a})`;
+ ctx.shadowColor='#ff62c0'; ctx.shadowBlur=20;
+ ctx.fillText('CONTROLS REVERSED', W/2, H-58);
+ ctx.shadowBlur=0;
+ const bw=Math.min(240,W*0.6), pct=G.reverseT/G.reverseMax;
+ ctx.fillStyle='rgba(255,98,192,.22)'; ctx.fillRect(W/2-bw/2, H-26, bw, 6);
+ ctx.fillStyle='#ff62c0'; ctx.fillRect(W/2-bw/2, H-26, bw*clamp(pct,0,1), 6);
}
// wave banner
- if (waveTransition > 0){
- const p = 1 - Math.abs(waveTransition / 1.7 - 0.5) * 2;
- g.save();
- g.globalAlpha = clamp(p * 1.8, 0, 1);
- g.textAlign = 'center';
- g.font = '800 ' + Math.round(clamp(W * 0.07, 28, 64)) + 'px ui-monospace, Menlo, monospace';
- g.fillStyle = '#ffffff';
- g.fillText('WAVE ' + wave, W/2, H/2 - 6);
- g.font = '700 13px ui-monospace, Menlo, monospace';
- g.fillStyle = '#38f5d0';
- const nu = UNLOCKS[wave - 1];
- g.fillText(nu ? 'NEW: ' + nu.map(k => TYPES[k].name).join(' · ') : 'INCOMING', W/2, H/2 + 26);
- g.restore();
- g.textAlign = 'left';
- }
-
- if (Snd.muted){
- g.font = '700 10px ui-monospace, Menlo, monospace';
- g.fillStyle = 'rgba(150,170,205,.45)';
- g.textAlign = 'center';
- g.fillText('MUTED', W/2, 24);
- g.textAlign = 'left';
- }
-}
-
-/* ------------------------------------------------- start-screen bestiary -- */
-const chips = [];
-function buildBestiary(){
- const host = document.getElementById('bestiary');
- host.innerHTML = '';
- for (const key of SPAWNABLE){
- const T = TYPES[key];
- const el = document.createElement('div');
- el.className = 'chip';
- const cv = document.createElement('canvas');
- cv.width = cv.height = 34 * 2; cv.style.width = cv.style.height = '34px';
- const txt = document.createElement('div');
- txt.innerHTML = '<div class="n" style="color:' + T.c + '">' + T.name + '</div>' +
- '<div class="d">' + T.blurb + '</div>';
- el.appendChild(cv); el.appendChild(txt);
- host.appendChild(el);
- const g = cv.getContext('2d');
- g.setTransform(2, 0, 0, 2, 0, 0);
- chips.push({ g, key });
- }
-}
-function drawChips(){
- for (const c of chips){
- c.g.clearRect(0, 0, 40, 40);
- c.g.save();
- c.g.translate(17, 17);
- c.g.globalCompositeOperation = 'lighter';
- drawGlow(c.g, 0, 0, 17, TYPES[c.key].c, 0.4);
- c.g.globalCompositeOperation = 'source-over';
- drawOrbBody(c.g, c.key, 12, elapsed, null);
- c.g.restore();
- }
-}
-
-/* ----------------------------------------------------------------- input -- */
-const PREVENT = ['Space','ArrowUp','ArrowDown','ArrowLeft','ArrowRight'];
-addEventListener('keydown', e => {
- if (PREVENT.includes(e.code)) e.preventDefault();
- keys[e.code] = true;
- if (e.repeat) return;
- Snd.init(); Snd.resume();
-
- if (e.code === 'KeyM'){ Snd.toggleMute(); return; }
-
- if (state === 'start'){
- if (e.code === 'Space' || e.code === 'Enter') startGame();
- } else if (state === 'gameover'){
- if ((e.code === 'Space' || e.code === 'Enter') && gameOverDelay <= 0) startGame();
- } else if (state === 'playing'){
- if (e.code === 'KeyP' || e.code === 'Escape'){ state = 'paused'; Snd.thrust(false); showScreen('pauseScreen'); }
- } else if (state === 'paused'){
- if (e.code === 'KeyP' || e.code === 'Escape' || e.code === 'Space'){ state = 'playing'; showScreen(null); }
+ if(G.waveBannerT>0){
+ const t = G.waveBannerT/140;
+ const a = t>0.78? (1-t)/0.22 : Math.min(1, t/0.35);
+ ctx.textAlign='center'; ctx.textBaseline='middle';
+ ctx.globalAlpha = clamp(a,0,1);
+ const fs = Math.min(62, W*0.11);
+ ctx.font=`900 ${fs}px "Trebuchet MS", sans-serif`;
+ ctx.strokeStyle='rgba(0,0,0,.55)'; ctx.lineWidth=8;
+ ctx.strokeText(G.waveBannerTxt, W/2, H*0.30);
+ const grd = ctx.createLinearGradient(0,H*0.30-34,0,H*0.30+34);
+ grd.addColorStop(0,'#ffffff'); grd.addColorStop(.5,'#8ef7ff'); grd.addColorStop(1,'#c56bff');
+ ctx.fillStyle=grd; ctx.shadowColor='#7df9ff'; ctx.shadowBlur=26;
+ ctx.fillText(G.waveBannerTxt, W/2, H*0.30);
+ ctx.shadowBlur=0;
+ if(G.waveBannerSub){
+ ctx.font='900 15px "Trebuchet MS", sans-serif';
+ ctx.fillStyle='#ffd76b';
+ ctx.fillText(G.waveBannerSub, W/2, H*0.30 + fs*0.72);
+ }
+ ctx.globalAlpha=1;
+ }
+
+ // toast
+ if(toastT>0){
+ ctx.textAlign='center'; ctx.textBaseline='middle';
+ ctx.globalAlpha = Math.min(1,toastT/25);
+ ctx.font='800 15px "Trebuchet MS", sans-serif';
+ ctx.fillStyle='#9fe9ff';
+ ctx.fillText(toastTxt, W/2, 34);
+ ctx.globalAlpha=1;
+ }
+
+ // touch controls
+ if(touch.on){
+ ctx.globalAlpha = 0.5;
+ for(const b of touchButtons()){
+ ctx.beginPath(); ctx.arc(b.x,b.y,b.r,0,TAU);
+ ctx.strokeStyle = b.id==='pulse'? '#ff8ad4' : '#8ef7ff';
+ ctx.lineWidth=2; ctx.stroke();
+ ctx.fillStyle='rgba(10,12,28,.5)'; ctx.fill();
+ ctx.fillStyle = b.id==='pulse'? '#ff8ad4' : '#8ef7ff';
+ ctx.font='800 9px "Trebuchet MS", sans-serif';
+ ctx.textAlign='center'; ctx.textBaseline='middle';
+ ctx.fillText(b.label, b.x, b.y);
+ }
+ if(touch.stickId!==-1){
+ const dx=touch.cx-touch.ox, dy=touch.cy-touch.oy;
+ const m=Math.min(Math.hypot(dx,dy),56), a=Math.atan2(dy,dx);
+ ctx.globalAlpha=0.35;
+ ctx.strokeStyle='#8ef7ff'; ctx.lineWidth=2;
+ ctx.beginPath(); ctx.arc(touch.ox,touch.oy,56,0,TAU); ctx.stroke();
+ ctx.fillStyle='rgba(142,247,255,.4)';
+ ctx.beginPath(); ctx.arc(touch.ox+Math.cos(a)*m, touch.oy+Math.sin(a)*m, 22,0,TAU); ctx.fill();
+ }
+ ctx.globalAlpha=1;
}
-});
-addEventListener('keyup', e => { keys[e.code] = false; });
-addEventListener('blur', () => { for (const k in keys) keys[k] = false; Snd.thrust(false); });
-addEventListener('pointerdown', () => { Snd.init(); Snd.resume(); });
+ ctx.restore();
+}
+
+function drawVignette(){
+ ctx.fillStyle = vignetteGrad; ctx.fillRect(0,0,W,H);
+ if(holes.length){
+ ctx.save();
+ ctx.globalAlpha = 0.10 + 0.05*Math.sin(G.time*0.09);
+ ctx.fillStyle = dangerGrad; ctx.fillRect(0,0,W,H);
+ ctx.restore();
+ }
+}
+
+function render(){
+ ctx.drawImage(bgc, 0,0, W,H);
+ drawTwinkle();
+
+ ctx.save();
+ ctx.translate(G.shakeX, G.shakeY);
+
+ drawWells();
+ drawBubbles();
+ drawHoles();
+ drawParticles();
+ drawRings();
+ drawShockwaves();
+ drawOrbs();
+ drawPowerups();
+ drawShards();
+ drawSentries();
+ drawHunters();
+ drawEnemyShots();
+ drawBullets();
+ drawShip();
+ drawFloats();
+
+ ctx.restore();
+
+ if(G.flash>0.004){
+ ctx.save();
+ ctx.globalAlpha = Math.min(0.7, G.flash);
+ ctx.fillStyle = G.flashCol;
+ ctx.globalCompositeOperation='lighter';
+ ctx.fillRect(0,0,W,H);
+ ctx.restore();
+ }
+
+ drawVignette();
+ if(G.state==='play' || G.state==='pause') drawHUD();
+}
+
+/* ============================================================
+ ATTRACT MODE (start / game-over backdrop)
+ ============================================================ */
+function attractTick(){
+ if(orbs.length<8 && G.time%14===0){
+ orbs.push(makeOrbForAttract());
+ }
+ for(const o of orbs){
+ o.t++; o.a+=o.spin; o.pulse+=0.06; o.wobble+=0.04;
+ o.x+=o.vx*0.55; o.y+=o.vy*0.55;
+ wrapPos(o);
+ }
+ for(let i=parts.length-1;i>=0;i--){
+ const p=parts[i]; if(--p.life<=0){parts.splice(i,1);continue;}
+ p.x+=p.vx;p.y+=p.vy;p.vx*=p.drag;p.vy*=p.drag;
+ }
+ for(let i=rings.length-1;i>=0;i--){
+ const r=rings[i]; if(--r.life<=0){rings.splice(i,1);continue;}
+ const t=1-r.life/r.max; r.cur=lerp(r.r,r.r1,1-Math.pow(1-t,2.6));
+ }
+ G.shake*=0.9; G.shakeX=0; G.shakeY=0; G.flash*=0.9;
+ G.time++;
+}
+function makeOrbForAttract(){
+ const o = makeOrb(rnd(W), rnd(H), pick([2,3]), pick(ORB_KEYS));
+ o.chainT = -1;
+ return o;
+}
+function renderAttract(){
+ ctx.drawImage(bgc, 0,0, W,H);
+ drawTwinkle();
+ ctx.save(); ctx.globalAlpha=0.9;
+ drawParticles(); drawRings();
+ drawOrbs();
+ ctx.restore();
+ ctx.fillStyle = vignetteGrad; ctx.fillRect(0,0,W,H);
+ ctx.fillStyle = 'rgba(0,0,0,.22)'; ctx.fillRect(0,0,W,H);
+}
-/* ------------------------------------------------------------------ boot -- */
-function frame(now){
- let dt = (now - last) / 1000;
- last = now;
- if (dt > 0.05) dt = 0.05; // never let a stall teleport anything
- if (dt < 0) dt = 0;
- if (state !== 'paused') update(dt);
- render(dt);
- requestAnimationFrame(frame);
+/* ============================================================
+ LEGEND
+ ============================================================ */
+(function buildLegend(){
+ let html='';
+ for(const k of ORB_KEYS){
+ const T = ORB_TYPES[k];
+ html += '<div class="orbrow">'
+ + '<span class="dot" style="background:linear-gradient(135deg,'+T.c1+','+T.c2+');color:'+T.glow+'"></span>'
+ + '<span><b>'+T.name+'</b>'+T.desc+'</span></div>';
+ }
+ document.getElementById('orbLegend').innerHTML = html;
+ document.getElementById('orbLegend2').innerHTML = html;
+})();
+
+/* ============================================================
+ MAIN LOOP — fixed timestep, capped catch-up
+ ============================================================ */
+let last = performance.now(), acc = 0;
+const STEP = 1000/60;
+function loop(now){
+ requestAnimationFrame(loop);
+ let delta = now - last; last = now;
+ if(delta > 240) delta = 240;
+ frameAvg = frameAvg*0.9 + delta*0.1; // drives adaptive particles + resolution
+ adaptResolution();
+ acc += delta;
+ let steps = 0;
+ while(acc >= STEP && steps < 5){
+ acc -= STEP; steps++;
+ if(G.state==='play') update();
+ else if(G.state==='start' || G.state==='over') attractTick();
+ else break; // paused: burn the accumulator, run nothing
+ }
+ if(G.state==='pause') acc = 0;
+ if(G.state==='play' || G.state==='pause') render();
+ else renderAttract();
}
resize();
-buildBestiary();
-ctx.fillStyle = '#05060e'; ctx.fillRect(0, 0, W, H);
-requestAnimationFrame(frame);
+syncToggles();
+requestAnimationFrame(loop);
</script>
</body>
</html>
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..2bb905a
--- /dev/null
+++ b/package.json
@@ -0,0 +1,13 @@
+{
+ "name": "wild-orbs",
+ "version": "1.0.0",
+ "description": "Asteroids, except every orb detonates into a different disaster. One self-contained HTML file.",
+ "private": true,
+ "scripts": {
+ "start": "open index.html",
+ "test": "node test/smoke.mjs",
+ "test:headed": "node test/smoke.mjs --headed --shots",
+ "profile": "node test/profile.mjs"
+ },
+ "license": "MIT"
+}
diff --git a/test/profile.mjs b/test/profile.mjs
new file mode 100644
index 0000000..6ebfb44
--- /dev/null
+++ b/test/profile.mjs
@@ -0,0 +1,62 @@
+/** Per-phase render profiler. `node test/profile.mjs [--headed]` */
+import { chromium } from 'playwright';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+import path from 'node:path';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const GAME = pathToFileURL(path.join(__dirname, '..', 'index.html')).href;
+const headed = process.argv.includes('--headed');
+
+const browser = await chromium.launch({
+ headless: !headed,
+ args: headed ? [] : ['--use-angle=metal', '--enable-gpu', '--ignore-gpu-blocklist']
+});
+const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
+await page.goto(GAME);
+await page.waitForTimeout(400);
+await page.keyboard.press('Enter');
+await page.waitForTimeout(400);
+
+const out = await page.evaluate(async () => {
+ // heavy but representative field
+ orbs.length = 0;
+ for (let i = 0; i < 30; i++) orbs.push(makeOrb(Math.random() * innerWidth, Math.random() * innerHeight, 3));
+ for (let i = 0; i < 10; i++) hunters.push({ x: Math.random() * innerWidth, y: Math.random() * innerHeight, vx: 1, vy: 1, r: 6, life: 9e5, max: 9e5, a: 0, wig: 0, spd: .1 });
+ for (let i = 0; i < 200; i++) burst(Math.random() * innerWidth, Math.random() * innerHeight, 6, '#ffcc88', 3, { life: 9e5 });
+ ship.inv = 9e5;
+
+ const phases = {
+ bg: () => { ctx.drawImage(bgc, 0, 0, W, H); },
+ twinkle: drawTwinkle,
+ orbs: drawOrbs,
+ particles: drawParticles,
+ hunters: drawHunters,
+ bullets: drawBullets,
+ ship: drawShip,
+ vignette: drawVignette,
+ hud: drawHUD,
+ fullFrame: render
+ };
+ const res = {};
+ for (const [name, fn] of Object.entries(phases)) {
+ fn(); // warm
+ const t0 = performance.now();
+ const N = 30;
+ for (let i = 0; i < N; i++) fn();
+ res[name] = +((performance.now() - t0) / N).toFixed(2);
+ }
+ res._counts = { orbs: orbs.length, parts: parts.length, hunters: hunters.length };
+ return res;
+});
+
+console.log(headed ? 'HEADED (real GPU)' : 'HEADLESS');
+console.log(JSON.stringify(out, null, 2));
+
+// real-world sustained fps
+const fps = await page.evaluate(async () => {
+ let n = 0; const t0 = performance.now();
+ await new Promise(res => { const t = () => { n++; (performance.now() - t0 < 2000) ? requestAnimationFrame(t) : res(); }; requestAnimationFrame(t); });
+ return +(n / ((performance.now() - t0) / 1000)).toFixed(1);
+});
+console.log('sustained fps:', fps);
+await browser.close();
diff --git a/test/smoke.mjs b/test/smoke.mjs
new file mode 100644
index 0000000..aeca2d2
--- /dev/null
+++ b/test/smoke.mjs
@@ -0,0 +1,276 @@
+/**
+ * Wild Orbs — headless smoke + regression test.
+ *
+ * Runs the real game in Chromium and:
+ * 1. loads it and asserts the start screen / legend built
+ * 2. plays it with synthetic input
+ * 3. detonates EVERY orb type (all 13 wild behaviours) and lets the
+ * resulting entities live out their full lifetimes
+ * 4. exercises pulse bomb, hyperspace, chain cascades, wave clear,
+ * ship death, respawn, game over, pause and restart
+ * 5. fails on ANY console error or uncaught page exception
+ *
+ * Usage: node test/smoke.mjs [--headed] [--shots]
+ */
+import { chromium } from 'playwright';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+import path from 'node:path';
+import fs from 'node:fs';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const ROOT = path.resolve(__dirname, '..');
+const GAME = pathToFileURL(path.join(ROOT, 'index.html')).href;
+const SHOTS = path.join(ROOT, 'test', 'shots');
+
+const headed = process.argv.includes('--headed');
+const wantShots = process.argv.includes('--shots');
+if (wantShots) fs.mkdirSync(SHOTS, { recursive: true });
+
+const errors = [];
+const results = [];
+let failed = 0;
+
+function check(name, ok, detail) {
+ results.push({ name, ok, detail });
+ if (!ok) failed++;
+ console.log(`${ok ? ' ok ' : ' FAIL '} ${name}${detail ? ' — ' + detail : ''}`);
+}
+
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+
+/** Poll a page predicate until true or the deadline passes. Timing-based
+ * assertions were flaky whenever the host machine was busy; polling makes the
+ * suite measure the game rather than the load average. */
+async function waitFor(page, fn, timeout = 12000, arg) {
+ const t0 = Date.now();
+ for (;;) {
+ if (await page.evaluate(fn, arg)) return true;
+ if (Date.now() - t0 > timeout) return false;
+ await sleep(120);
+ }
+}
+
+(async () => {
+ const browser = await chromium.launch({ headless: !headed });
+ const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
+
+ page.on('console', m => { if (m.type() === 'error') errors.push('console: ' + m.text()); });
+ page.on('pageerror', e => errors.push('pageerror: ' + (e && e.message ? e.message : String(e))));
+
+ await page.goto(GAME);
+ await page.waitForTimeout(600);
+
+ /* ---------- 1. boot ---------- */
+ check('page loaded without errors', errors.length === 0, errors[0]);
+ check('canvas sized', await page.evaluate(() => cv.width > 0 && cv.height > 0));
+ const legendCount = await page.evaluate(() => document.querySelectorAll('#orbLegend .orbrow').length);
+ check('legend lists every orb type', legendCount === 13, `${legendCount} rows`);
+ check('attract mode is animating', await page.evaluate(async () => {
+ const a = orbs.length; await new Promise(r => setTimeout(r, 400));
+ return orbs.length >= a && G.time > 10;
+ }));
+ if (wantShots) await page.screenshot({ path: path.join(SHOTS, '01-start.png') });
+
+ /* ---------- 2. start + play ---------- */
+ await page.keyboard.press('Enter');
+ await page.waitForTimeout(300);
+ check('game started', await page.evaluate(() => G.state === 'play' && orbs.length > 0));
+
+ // fly + shoot for a while
+ await page.keyboard.down('ArrowUp');
+ await page.keyboard.down('ArrowRight');
+ await page.keyboard.down('Space');
+ await page.waitForTimeout(1800);
+ await page.keyboard.up('ArrowRight');
+ await page.waitForTimeout(1200);
+ await page.keyboard.up('ArrowUp');
+ await page.keyboard.up('Space');
+
+ check('bullets were fired', await page.evaluate(() => G.shots > 5), );
+ check('ship moved', await page.evaluate(() => Math.hypot(ship.vx, ship.vy) > 0.05 || ship.x !== innerWidth / 2));
+ check('no errors during play', errors.length === 0, errors[0]);
+ if (wantShots) await page.screenshot({ path: path.join(SHOTS, '02-play.png') });
+
+ /* ---------- 3. every wild behaviour ---------- */
+ const kinds = await page.evaluate(() => ORB_KEYS);
+ check('13 orb kinds registered', kinds.length === 13, kinds.join(','));
+
+ for (const kind of kinds) {
+ const before = errors.length;
+ await page.evaluate(k => {
+ // put the ship somewhere predictable, then detonate one of each tier
+ ship.x = innerWidth * 0.5; ship.y = innerHeight * 0.5;
+ ship.inv = 100000; // invincible so the test can't die mid-sweep
+ for (const tier of [3, 2, 1]) {
+ const o = makeOrb(innerWidth * 0.3, innerHeight * 0.4, tier, k);
+ orbs.push(o);
+ destroyOrb(o);
+ }
+ }, kind);
+ await page.waitForTimeout(220);
+ check(`orb behaviour: ${kind}`, errors.length === before, errors[before]);
+ }
+
+ // elder (tier-4) variant of each kind
+ for (const kind of kinds) {
+ const before = errors.length;
+ await page.evaluate(k => {
+ const o = makeOrb(innerWidth * 0.6, innerHeight * 0.6, 4, k);
+ orbs.push(o); destroyOrb(o);
+ }, kind);
+ await page.waitForTimeout(90);
+ check(`elder behaviour: ${kind}`, errors.length === before, errors[before]);
+ }
+ if (wantShots) await page.screenshot({ path: path.join(SHOTS, '03-chaos.png') });
+
+ // let every spawned hunter / shard / sentry / well / hole live out its life
+ await page.evaluate(() => { ship.inv = 100000; });
+ await page.waitForTimeout(3000);
+ check('survived the full-bestiary blast', errors.length === 0, errors[0]);
+ check('entity counts stayed bounded', await page.evaluate(() =>
+ orbs.length < 200 && parts.length <= 1250 && bullets.length < 200),
+ await page.evaluate(() => `orbs=${orbs.length} parts=${parts.length}`));
+
+ /* ---------- 4. chain cascade stress ---------- */
+ {
+ const before = errors.length;
+ await page.evaluate(() => {
+ for (let i = 0; i < 24; i++) {
+ orbs.push(makeOrb(innerWidth * 0.5 + Math.cos(i) * 90, innerHeight * 0.5 + Math.sin(i) * 90, 2, 'chain'));
+ }
+ destroyOrb(orbs[orbs.length - 1]);
+ });
+ await page.waitForTimeout(2500);
+ check('24-orb chain cascade resolves', errors.length === before, errors[before]);
+ }
+
+ /* ---------- 5. player abilities ---------- */
+ {
+ const before = errors.length;
+ // assert on the effect, not the counter — a wave-clear can refund a charge
+ await page.evaluate(() => { G.pulses = 3; waves.length = 0; });
+ await page.keyboard.press('Shift');
+ const pulseFired = await page.evaluate(() => waves.some(w => w.dmg === 3));
+ check('pulse bomb fires a shockwave', pulseFired);
+ await page.waitForTimeout(400);
+ await page.keyboard.press('KeyH');
+ await page.waitForTimeout(200);
+ check('hyperspace fires', await page.evaluate(() => G.hyperCool > 0));
+ for (const p of ['spread', 'rapid', 'shield', 'pulse', 'life']) {
+ await page.evaluate(k => grantPower(k, innerWidth / 2, innerHeight / 2), p);
+ }
+ await page.waitForTimeout(200);
+ check('powerups grant', await page.evaluate(() => G.spreadT > 0 && G.rapidT > 0 && ship.shield === 1));
+ await page.keyboard.down('Space'); await page.waitForTimeout(500); await page.keyboard.up('Space');
+ check('spread fire works', errors.length === before, errors[before]);
+ }
+
+ /* ---------- 6. pause ---------- */
+ {
+ await page.keyboard.press('KeyP');
+ await page.waitForTimeout(300);
+ check('pause halts simulation', await page.evaluate(async () => {
+ if (G.state !== 'pause') return false;
+ const f = G.frames; await new Promise(r => setTimeout(r, 400)); return G.frames === f;
+ }));
+ check('pause overlay visible', await page.evaluate(() =>
+ !document.getElementById('pauseScreen').classList.contains('hidden')));
+ if (wantShots) await page.screenshot({ path: path.join(SHOTS, '04-pause.png') });
+ await page.keyboard.press('KeyP');
+ await page.waitForTimeout(200);
+ check('resumed', await page.evaluate(() => G.state === 'play'));
+ }
+
+ /* ---------- 7. wave progression ---------- */
+ {
+ const w0 = await page.evaluate(() => { G.wave = 4; orbs.length = 0; hunters.length = 0; return G.wave; });
+ const advanced = await waitFor(page, () => G.wave > 4 && orbs.length > 0);
+ const w1 = await page.evaluate(() => G.wave);
+ check('wave clear advances + respawns', advanced, `wave ${w0} -> ${w1}`);
+ // wave 5 is an elder wave
+ await page.evaluate(() => { G.wave = 4; orbs.length = 0; });
+ check('elder orb spawns on wave 5',
+ await waitFor(page, () => G.wave === 5 && orbs.some(o => o.elder)));
+ }
+
+ /* ---------- 8. death, respawn, game over ---------- */
+ {
+ await page.evaluate(() => { ship.inv = 0; ship.shield = 0; G.lives = 2; killShip(); });
+ check('respawn after death', await waitFor(page, () => ship.alive && G.lives === 1));
+
+ await page.evaluate(() => { ship.inv = 0; ship.shield = 0; G.lives = 1; killShip(); });
+ check('game over at zero lives', await waitFor(page, () => G.state === 'over'));
+ check('game over stats populated', await page.evaluate(() =>
+ document.getElementById('stOrbs').textContent !== '' &&
+ document.getElementById('stAcc').textContent.endsWith('%')));
+ if (wantShots) await page.screenshot({ path: path.join(SHOTS, '05-gameover.png') });
+ }
+
+ /* ---------- 9. restart ---------- */
+ {
+ await page.keyboard.press('Enter');
+ check('restart resets run', await waitFor(page, () =>
+ G.state === 'play' && G.score === 0 && G.wave === 1 && G.lives === 3 &&
+ hunters.length === 0 && holes.length === 0 && orbs.length > 0));
+ }
+
+ /* ---------- 10. resize resilience ---------- */
+ {
+ const before = errors.length;
+ await page.setViewportSize({ width: 480, height: 900 }); // portrait phone
+ await page.waitForTimeout(600);
+ await page.setViewportSize({ width: 1600, height: 700 });
+ await page.waitForTimeout(600);
+ check('survives resize / orientation change', errors.length === before, errors[before]);
+ if (wantShots) await page.screenshot({ path: path.join(SHOTS, '06-resized.png') });
+ }
+
+ /* ---------- 11. sustained perf ---------- */
+ {
+ // Absolute fps is meaningless without knowing the environment's ceiling:
+ // headed Playwright is vsync-capped ~30fps, headless uses a software
+ // rasteriser. Measure the ceiling, then require the loaded game to hold
+ // a healthy fraction of it after adaptive scaling has settled.
+ const ceiling = await page.evaluate(async () => {
+ let n = 0; const t0 = performance.now();
+ await new Promise(res => {
+ const t = () => { n++; (performance.now() - t0 < 1000) ? requestAnimationFrame(t) : res(); };
+ requestAnimationFrame(t);
+ });
+ return n / ((performance.now() - t0) / 1000);
+ });
+
+ await page.evaluate(() => {
+ for (let i = 0; i < 26; i++) orbs.push(makeOrb(Math.random() * innerWidth, Math.random() * innerHeight, 3));
+ ship.inv = 1e6;
+ });
+ await page.waitForTimeout(3000); // let dynamic resolution settle
+
+ const fps = await page.evaluate(async () => {
+ let n = 0; const t0 = performance.now();
+ await new Promise(res => {
+ const t = () => { n++; (performance.now() - t0 < 2000) ? requestAnimationFrame(t) : res(); };
+ requestAnimationFrame(t);
+ });
+ return n / ((performance.now() - t0) / 1000);
+ });
+ const scale = await page.evaluate(() => renderScale);
+ check('holds framerate under load', fps >= Math.min(50, ceiling * 0.55),
+ `${fps.toFixed(1)} fps vs ${ceiling.toFixed(1)} fps ceiling, renderScale ${scale.toFixed(2)}, 30+ orbs`);
+ check('adaptive resolution engages when needed', ceiling < 45 || scale <= 1,
+ `renderScale ${scale.toFixed(2)}`);
+ }
+
+ check('zero console errors for the whole session', errors.length === 0,
+ errors.slice(0, 3).join(' | '));
+
+ await browser.close();
+
+ console.log('\n' + '─'.repeat(58));
+ console.log(`${results.length - failed}/${results.length} checks passed`);
+ if (errors.length) {
+ console.log('\nErrors captured:');
+ errors.slice(0, 12).forEach(e => console.log(' ' + e));
+ }
+ process.exit(failed ? 1 : 0);
+})().catch(e => { console.error(e); process.exit(1); });
← db229b2 Wild Orbs: complete single-file canvas arcade shooter
·
back to Wild Orbs Opus
·
Nova shockwaves chain; lock the cascade bounds in tests 81c6680 →