arcadia / orbit-survivors

Orbit Survivors

game.js93.7 KB
1// Orbit Survivors - an Arcadia roguelite survival arena. Vanilla canvas, no libraries.
2(() => {
3"use strict";
4const W = 540, H = 960, WW = 1500, WH = 1700;
5const DEBUG = /[?&]debug=1/.test(location.search);
6const TAU = Math.PI * 2, PI = Math.PI;
7const c = document.getElementById("c");
8const ctx = c.getContext("2d", { alpha: false });
9let K = 1, cssScale = 1;
10function fit() {
11 const s = Math.min(innerWidth / W, innerHeight / H) || 1, d = Math.min(window.devicePixelRatio || 1, 1.5);
12 cssScale = s;
13 c.style.width = W * s + "px"; c.style.height = H * s + "px";
14 c.width = Math.round(W * s * d); c.height = Math.round(H * s * d);
15 K = c.width / W;
16}
17addEventListener("resize", fit); fit();
18const FONT = 'system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif';
19const rnd = Math.random, cos = Math.cos, sin = Math.sin, sqrt = Math.sqrt, abs = Math.abs, atan2 = Math.atan2, hypot = Math.hypot;
20const clamp = (v, a, b) => (v < a ? a : v > b ? b : v);
21const lerp = (a, b, t) => a + (b - a) * t;
22const LS = {
23 get(k, d) { try { const v = localStorage.getItem(k); return v == null ? d : v; } catch (e) { return d; } },
24 set(k, v) { try { localStorage.setItem(k, String(v)); } catch (e) {} },
25};
26const getBest = () => Number(LS.get("orbitsurvivors.best", 0)) || 0;
27function loadMeta() {
28 let m = null;
29 try { m = JSON.parse(LS.get("orbitsurvivors.meta", "null")); } catch (e) {}
30 if (!m || typeof m !== "object") m = {};
31 m.shards = m.shards | 0; m.lv = m.lv && typeof m.lv === "object" ? m.lv : {}; m.runs = m.runs | 0;
32 return m;
33}
34const meta = loadMeta();
35const saveMeta = () => LS.set("orbitsurvivors.meta", JSON.stringify(meta));
36const ml = (k) => meta.lv[k] | 0;
37
38// ---------- palette + glow sprites ----------
39const COLS = ["#3df2ff", "#ff3d9a", "#ffe14a", "#5dff8a", "#ff8a3d", "#4a8bff", "#b56bff", "#ffffff", "#ff4055", "#ffd35a"];
40const CY = 0, MG = 1, YE = 2, GR = 3, OR = 4, BL = 5, PU = 6, WT = 7, RD = 8, GO = 9;
41function hexA(h, a) { const n = parseInt(h.slice(1), 16); return "rgba(" + (n >> 16) + "," + ((n >> 8) & 255) + "," + (n & 255) + "," + a + ")"; }
42const GL = COLS.map((col) => {
43 const s = 64, g = document.createElement("canvas"); g.width = g.height = s;
44 const x = g.getContext("2d"), gr = x.createRadialGradient(s / 2, s / 2, 0, s / 2, s / 2, s / 2);
45 gr.addColorStop(0, hexA(col, 1)); gr.addColorStop(0.18, hexA(col, 0.7)); gr.addColorStop(0.5, hexA(col, 0.2)); gr.addColorStop(1, hexA(col, 0));
46 x.fillStyle = gr; x.fillRect(0, 0, s, s); return g;
47});
48function glow(i, x, y, r, a) { ctx.globalAlpha = a; ctx.drawImage(GL[i], x - r, y - r, r * 2, r * 2); }
49
50// background layers (pre-rendered)
51function mkCanvas(w, h) { const e = document.createElement("canvas"); e.width = w; e.height = h; return e; }
52const STAR = [0, 1, 2].map((L) => {
53 const s = 512, g = mkCanvas(s, s), x = g.getContext("2d");
54 const n = [70, 45, 26][L];
55 for (let i = 0; i < n; i++) {
56 const px = rnd() * s, py = rnd() * s, r = [0.9, 1.3, 1.9][L] * (0.6 + rnd() * 0.8);
57 x.fillStyle = ["#7f95ff", "#a8c4ff", "#ffffff"][(rnd() * 3) | 0]; x.globalAlpha = [0.35, 0.5, 0.75][L] * (0.5 + rnd() * 0.5);
58 x.beginPath(); x.arc(px, py, r, 0, TAU); x.fill();
59 }
60 return g;
61});
62const VIG = (() => {
63 const g = mkCanvas(W, H), x = g.getContext("2d"), gr = x.createRadialGradient(W / 2, H / 2, 260, W / 2, H / 2, 620);
64 gr.addColorStop(0, "rgba(2,3,12,0)"); gr.addColorStop(1, "rgba(2,3,12,0.72)");
65 x.fillStyle = gr; x.fillRect(0, 0, W, H); return g;
66})();
67const NEB = [[300, 300, 4, 520], [1200, 500, 6, 480], [700, 1000, 1, 560], [1250, 1450, 5, 500], [250, 1400, 6, 470]];
68
69// ---------- text ----------
70const fonts = {};
71function fnt(size, weight) { const k = size + "_" + (weight || 700); return fonts[k] || (fonts[k] = (weight || 700) + " " + size + "px " + FONT); }
72function txt(s, x, y, size, col, align, weight, alpha) {
73 ctx.font = fnt(size, weight); ctx.textAlign = align || "center"; ctx.textBaseline = "middle";
74 ctx.globalAlpha = alpha == null ? 1 : alpha; ctx.fillStyle = col; ctx.fillText(s, x, y);
75}
76function rrect(x, y, w, h, r) {
77 ctx.beginPath(); ctx.moveTo(x + r, y); ctx.lineTo(x + w - r, y); ctx.arcTo(x + w, y, x + w, y + r, r);
78 ctx.lineTo(x + w, y + h - r); ctx.arcTo(x + w, y + h, x + w - r, y + h, r); ctx.lineTo(x + r, y + h);
79 ctx.arcTo(x, y + h, x, y + h - r, r); ctx.lineTo(x, y + r); ctx.arcTo(x, y, x + r, y, r); ctx.closePath();
80}
81
82// ---------- state ----------
83let state = "title", paused = false, tG = 0, frameN = 0, hintT = 0, muted = false, timeScale = 1;
84try { muted = LS.get("orbitsurvivors.muted", "0") === "1"; } catch (e) {}
85const P = { x: WW / 2, y: WH / 2, vx: 0, vy: 0, hp: 100, mhp: 100, r: 13, inv: 0, dashT: 0, dashCd: 0, dashDx: 0, dashDy: 1, shCd: 0, flash: 0, fx: 0, fy: 1, mv: 0, regen: 0, revived: false, hitFlash: 0 };
86const RUN = { t: 0, kills: 0, level: 1, xp: 0, need: 10, pending: 0, bossKills: 0, bossIdx: 0, nextBoss: 90, wave: 0, spawnAcc: 0, phase: 0, lv: {}, evo: {}, shards: 0, score: 0, newBest: false, zapT: 0, shootT: 0, shootI: 0, singT: 0, banner: "", bannerT: 0, bannerSub: "", warned: false, combo: 0, comboT: 0 };
87const G = { n: 2, baseR: 78, spin: 2.1, power: 1, magR: 70, dashCdMax: 5, xpMul: 1, curR: 78, mult: 1 };
88const cam = { x: 0, y: 0, tx: 0, ty: 0, shake: 0, sx: 0, sy: 0 };
89const ORB_R = 10, ORB_DMG = 11, SPEED = 200, DASHSPD = 760;
90let god = false, botOn = false;
91const bot = { dx: 0, dy: 0 };
92
93// ---------- pools ----------
94const MAXE = 400, MAXB = 280, MAXPB = 140, MAXG = 300, MAXP = 1100, MAXN = 44, MAXR = 30;
95const E = [], EB = [], PB = [], GM = [], PT = [], NUM = [], RG = [];
96for (let i = 0; i < MAXE; i++) E.push({ alive: false, type: 0, x: 0, y: 0, vx: 0, vy: 0, hp: 1, mhp: 1, r: 10, spd: 60, dmg: 8, xp: 1, flash: 0, hurtT: 0, ang: 0, t: 0, st: 0, stT: 0, cd: 0, cell: 0, fx: 1, fy: 0, ax: 1, ay: 0, stun: 0, col: 1, wob: 0 });
97for (let i = 0; i < MAXB; i++) EB.push({ alive: false, x: 0, y: 0, vx: 0, vy: 0, r: 5, dmg: 8, life: 0, col: RD });
98for (let i = 0; i < MAXPB; i++) PB.push({ alive: false, x: 0, y: 0, vx: 0, vy: 0, dmg: 10, life: 0, pierce: 0, last: -1, col: CY, big: 0 });
99for (let i = 0; i < MAXG; i++) GM.push({ alive: false, x: 0, y: 0, vx: 0, vy: 0, v: 1, kind: 0, t: 0, att: false });
100for (let i = 0; i < MAXP; i++) PT.push({ x: 0, y: 0, vx: 0, vy: 0, life: 0, max: 1, size: 2, c: 0, drag: 1 });
101for (let i = 0; i < MAXN; i++) NUM.push({ x: 0, y: 0, vy: 0, life: 0, txt: "", col: "#fff", size: 14 });
102for (let i = 0; i < MAXR; i++) RG.push({ x: 0, y: 0, r: 0, mr: 1, life: 0, max: 1, c: 0, w: 3, a: 1 });
103let pn = 0, nn = 0, rn = 0, enemyCount = 0;
104const findFree = (arr) => { for (let i = 0; i < arr.length; i++) if (!arr[i].alive) return arr[i]; return null; };
105
106function spark(x, y, vx, vy, life, size, c, drag) {
107 const p = PT[pn]; pn = (pn + 1) % MAXP;
108 p.x = x; p.y = y; p.vx = vx; p.vy = vy; p.life = life; p.max = life; p.size = size; p.c = c; p.drag = drag || 1;
109}
110function burst(x, y, c, n, spd, size, life) {
111 for (let i = 0; i < n; i++) { const a = rnd() * TAU, s = spd * (0.25 + rnd() * 0.85); spark(x, y, cos(a) * s, sin(a) * s, life * (0.5 + rnd() * 0.7), size * (0.6 + rnd() * 0.8), c, 2.2); }
112}
113function ring(x, y, mr, life, c, w, a) {
114 const r = RG[rn]; rn = (rn + 1) % MAXR; r.x = x; r.y = y; r.r = 4; r.mr = mr; r.life = life; r.max = life; r.c = c; r.w = w || 3; r.a = a == null ? 1 : a;
115}
116function num(x, y, s, col, size) {
117 const n = NUM[nn]; nn = (nn + 1) % MAXN; n.x = x + (rnd() - 0.5) * 12; n.y = y - 8; n.vy = -46; n.life = 0.75; n.txt = s; n.col = col; n.size = size || 14;
118}
119function shake(a) { if (a > cam.shake) cam.shake = a; }
120
121// ---------- audio glue ----------
122const SFXo = window.SFX || { init() {}, resume() {}, suspend() {}, play() {}, tick() {}, setMuted() {} };
123const sfx = (n, a) => { try { SFXo.play(n, a); } catch (e) {} };
124function audioStart() { try { if (SFXo.init()) { SFXo.setMuted(muted); SFXo.resume(); } } catch (e) {} }
125function setMute(m) { muted = m; LS.set("orbitsurvivors.muted", m ? "1" : "0"); try { SFXo.setMuted(m); if (!m) { audioStart(); sfx("click"); } } catch (e) {} }
126
127// ---------- spatial hash ----------
128const CS = 64, GWc = Math.ceil(WW / CS), GHc = Math.ceil(WH / CS);
129const cellStart = new Int32Array(GWc * GHc + 2), cellCur = new Int32Array(GWc * GHc + 2), cellItems = new Int32Array(MAXE + 4);
130const qb = new Int32Array(MAXE + 4), qb2 = new Int32Array(MAXE + 4);
131function buildGrid() {
132 cellStart.fill(0);
133 for (let i = 0; i < MAXE; i++) {
134 const e = E[i]; if (!e.alive) continue;
135 const cx = clamp((e.x / CS) | 0, 0, GWc - 1), cy = clamp((e.y / CS) | 0, 0, GHc - 1);
136 e.cell = cx + cy * GWc; cellStart[e.cell + 1]++;
137 }
138 for (let i = 1; i < cellStart.length; i++) cellStart[i] += cellStart[i - 1];
139 cellCur.set(cellStart);
140 for (let i = 0; i < MAXE; i++) if (E[i].alive) cellItems[cellCur[E[i].cell]++] = i;
141}
142function query(x, y, r, out) {
143 let x0 = ((x - r) / CS) | 0, x1 = ((x + r) / CS) | 0, y0 = ((y - r) / CS) | 0, y1 = ((y + r) / CS) | 0, n = 0;
144 if (x0 < 0) x0 = 0; if (y0 < 0) y0 = 0; if (x1 >= GWc) x1 = GWc - 1; if (y1 >= GHc) y1 = GHc - 1;
145 for (let cy = y0; cy <= y1; cy++) for (let cx = x0; cx <= x1; cx++) {
146 const ci = cx + cy * GWc;
147 for (let j = cellStart[ci], je = cellStart[ci + 1]; j < je; j++) out[n++] = cellItems[j];
148 }
149 return n;
150}
151
152// ---------- input ----------
153const inp = { id: -1, ax: 0, ay: 0, px: 0, py: 0, dx: 0, dy: 0, down: false, lastUp: 0, lastUpX: 0, lastUpY: 0, downT: 0, sx: 0, sy: 0, moved: 0 };
154const keys = { left: false, right: false, up: false, down: false };
155const btns = [];
156function toLogical(e) { const r = c.getBoundingClientRect(); return [((e.clientX - r.left) / r.width) * W, ((e.clientY - r.top) / r.height) * H]; }
157function hitBtn(x, y) { for (let i = btns.length - 1; i >= 0; i--) { const b = btns[i]; if (x >= b.x && x <= b.x + b.w && y >= b.y && y <= b.y + b.h) return b; } return null; }
158function requestDash() { if (state !== "play") return; if (RUN.lv.dash && P.dashCd <= 0 && P.dashT <= 0) startDash(); }
159c.addEventListener("pointerdown", (e) => {
160 e.preventDefault(); audioStart();
161 try { c.setPointerCapture(e.pointerId); } catch (x) {}
162 const [x, y] = toLogical(e), b = hitBtn(x, y);
163 if (b) { b.fn(); return; }
164 if (state === "play") {
165 if (inp.id === -1) {
166 inp.id = e.pointerId; inp.ax = inp.px = x; inp.ay = inp.py = y; inp.down = true; inp.downT = tG; inp.sx = x; inp.sy = y; inp.moved = 0;
167 if (tG - inp.lastUp < 0.3 && hypot(x - inp.lastUpX, y - inp.lastUpY) < 60) requestDash();
168 } else requestDash();
169 }
170});
171c.addEventListener("pointermove", (e) => {
172 if (e.pointerId !== inp.id) return;
173 const [x, y] = toLogical(e); inp.px = x; inp.py = y;
174});
175function endPtr(e) {
176 if (e.pointerId !== inp.id) return;
177 const short = tG - inp.downT < 0.22 && hypot(inp.px - inp.sx, inp.py - inp.sy) < 14;
178 if (short) { inp.lastUp = tG; inp.lastUpX = inp.px; inp.lastUpY = inp.py; } else inp.lastUp = -9;
179 inp.id = -1; inp.down = false; inp.dx = inp.dy = 0;
180}
181c.addEventListener("pointerup", endPtr); c.addEventListener("pointercancel", endPtr);
182c.addEventListener("contextmenu", (e) => e.preventDefault());
183document.addEventListener("gesturestart", (e) => e.preventDefault());
184document.addEventListener("touchmove", (e) => e.preventDefault(), { passive: false });
185function updateStick() {
186 if (!inp.down) { inp.dx = inp.dy = 0; return; }
187 let dx = inp.px - inp.ax, dy = inp.py - inp.ay; const l = hypot(dx, dy);
188 const MAXR_ = 64;
189 if (l > MAXR_) { inp.ax += (dx / l) * (l - MAXR_); inp.ay += (dy / l) * (l - MAXR_); dx = inp.px - inp.ax; dy = inp.py - inp.ay; }
190 const l2 = hypot(dx, dy);
191 if (l2 < 5) { inp.dx = inp.dy = 0; return; }
192 const m = Math.min(1, (l2 - 5) / 40);
193 inp.dx = (dx / l2) * m; inp.dy = (dy / l2) * m;
194}
195const KM = { ArrowUp: "up", w: "up", W: "up", ArrowDown: "down", s: "down", S: "down", ArrowLeft: "left", a: "left", A: "left", ArrowRight: "right", d: "right", D: "right" };
196addEventListener("keydown", (e) => {
197 audioStart();
198 if (KM[e.key]) { keys[KM[e.key]] = true; e.preventDefault(); return; }
199 if (e.key === " ") { e.preventDefault(); if (state === "play") requestDash(); else if (state === "title") startRun(); else if (state === "over") startRun(); return; }
200 if (e.key === "Enter") { if (state === "title" || state === "over") startRun(); return; }
201 if (e.key === "m" || e.key === "M") { setMute(!muted); return; }
202 if (e.key === "p" || e.key === "P" || e.key === "Escape") { if (state === "play") paused = !paused; else if (state === "meta") state = "title"; return; }
203 if (state === "levelup" && (e.key === "1" || e.key === "2" || e.key === "3")) chooseCard(Number(e.key) - 1);
204});
205addEventListener("keyup", (e) => { if (KM[e.key]) keys[KM[e.key]] = false; });
206addEventListener("blur", () => { keys.left = keys.right = keys.up = keys.down = false; });
207document.addEventListener("visibilitychange", () => { if (document.hidden) { if (state === "play") paused = true; try { SFXo.suspend(); } catch (e) {} } else if (!paused) { try { SFXo.resume(); } catch (e) {} } });
208if (window.Arcadia) {
209 Arcadia.onPause(() => { if (state === "play") paused = true; try { SFXo.suspend(); } catch (e) {} });
210 Arcadia.onResume(() => { paused = false; try { SFXo.resume(); } catch (e) {} });
211}
212
213// ---------- upgrades ----------
214const UPG = [
215 { id: "orb", name: "Extra Orb", max: 8, col: CY, desc: (l) => "+1 orb circling your core" },
216 { id: "wide", name: "Wide Orbit", max: 5, col: BL, desc: (l) => "Orbit radius +18. Covers more, but a looser orbit hits softer" },
217 { id: "spin", name: "Overdrive Spin", max: 5, col: YE, desc: (l) => "Orbs spin 25% faster" },
218 { id: "pow", name: "Orb Power", max: 6, col: OR, desc: (l) => "+30% orb damage" },
219 { id: "shoot", name: "Shooter Orbs", max: 4, col: GR, desc: (l) => l === 0 ? "Orbs fire bolts at nearby enemies" : "Bolts fire faster and hit harder" },
220 { id: "zap", name: "Arc Lightning", max: 4, col: PU, desc: (l) => l === 0 ? "Lightning links neighbouring orbs and shocks enemies between them" : "Stronger, faster arcs" },
221 { id: "shield", name: "Reflect Shield", max: 3, col: CY, desc: (l) => l === 0 ? "A barrier reflects shots and blocks one hit, then recharges" : "Faster recharge, stronger reflections" },
222 { id: "mag", name: "Magnet", max: 4, col: GO, desc: (l) => "+40% gem pickup range" },
223 { id: "dash", name: "Phase Dash", max: 4, col: MG, desc: (l) => l === 0 ? "Double-tap or Space: dash through enemies, invulnerable" : "Shorter cooldown, harder impact" },
224 { id: "vit", name: "Vitality", max: 5, col: RD, desc: (l) => "+25 max HP and heal 35" },
225 { id: "regen", name: "Regeneration", max: 3, col: GR, desc: (l) => "Recover +0.9 HP per second" },
226];
227const EVO = [
228 { id: "web", name: "Tesla Web", col: PU, req: ["zap", 2, "spin", 2], desc: "Lightning also links every orb to your core, +50% arc damage" },
229 { id: "nova", name: "Nova Barrage", col: GR, req: ["shoot", 2, "orb", 3], desc: "Bolts fire in piercing 3-way volleys" },
230 { id: "aegis", name: "Aegis Mirror", col: CY, req: ["shield", 1, "pow", 2], desc: "Reflected shots hit 4x harder and the shield explodes when it breaks" },
231 { id: "sing", name: "Singularity", col: PU, req: ["mag", 2, "wide", 2], desc: "Every 9s a black-hole pulse vacuums gems and crushes nearby foes" },
232 { id: "blink", name: "Blink Blade", col: MG, req: ["dash", 2, "spin", 2], desc: "Dash recharges 40% faster, ends with a slash wave, orbs spin x2 mid-dash" },
233 { id: "vamp", name: "Vampiric Orbs", col: RD, req: ["pow", 3, "regen", 1], desc: "Orb kills heal 1 HP and orbs deal +20% damage" },
234];
235const UBY = {}; UPG.forEach((u) => (UBY[u.id] = u)); EVO.forEach((u) => { u.evo = true; UBY[u.id] = u; });
236const lvOf = (id) => RUN.lv[id] | 0;
237
238function recalc() {
239 const lv = RUN.lv;
240 G.n = 2 + ml("orb") + (lv.orb | 0);
241 G.baseR = 78 + 18 * (lv.wide | 0);
242 G.spin = 2.1 * (1 + 0.25 * (lv.spin | 0));
243 G.power = (1 + 0.3 * (lv.pow | 0)) * (1 + 0.06 * ml("pow")) * (RUN.evo.vamp ? 1.2 : 1);
244 G.magR = 70 * (1 + 0.4 * (lv.mag | 0)) * (1 + 0.12 * ml("mag"));
245 G.dashCdMax = 5.2 * Math.pow(0.8, Math.max(0, (lv.dash | 0) - 1)) * (RUN.evo.blink ? 0.6 : 1);
246 G.xpMul = 1.3 + 0.08 * ml("xp");
247 P.mhp = 100 + 12 * ml("vit") + 25 * (lv.vit | 0);
248}
249function eligibleEvos() {
250 const out = [];
251 for (const e of EVO) { if (RUN.evo[e.id]) continue; if (lvOf(e.req[0]) >= e.req[1] && lvOf(e.req[2]) >= e.req[3]) out.push(e); }
252 return out;
253}
254let cards = [null, null, null], cardT = 0;
255function rollCards() {
256 const pool = [];
257 for (const u of UPG) if (lvOf(u.id) < u.max) pool.push(u);
258 const evos = eligibleEvos();
259 cards = [null, null, null];
260 let k = 0;
261 if (evos.length && rnd() < 0.85) { cards[k++] = evos[(rnd() * evos.length) | 0]; }
262 while (k < 3 && pool.length) {
263 // weight: early game favours orb count; avoid duplicates
264 let tot = 0; const w = pool.map((u) => { let x = 1; if (u.id === "orb" && RUN.level < 8) x = 1.6; if (u.id === "dash" && !lvOf("dash")) x = 0.8; tot += x; return x; });
265 let r = rnd() * tot, idx = 0; for (; idx < pool.length - 1; idx++) { r -= w[idx]; if (r <= 0) break; }
266 cards[k++] = pool[idx]; pool.splice(idx, 1);
267 }
268 for (let i = 0; k < 3; i++, k++) cards[k] = { id: "heal", name: "Repair Kit", col: RD, heal: true, max: 99, desc: () => "Restore 50% of your HP" };
269 // shuffle so evolutions are not always first
270 for (let i = 2; i > 0; i--) { const j = (rnd() * (i + 1)) | 0, t = cards[i]; cards[i] = cards[j]; cards[j] = t; }
271 cardT = 0; prepCards();
272}
273function applyCard(u) {
274 if (u.heal) { P.hp = Math.min(P.mhp, P.hp + P.mhp * 0.5); num(P.x, P.y - 24, "+HP", "#5dff8a", 18); return; }
275 if (u.evo) { RUN.evo[u.id] = true; RUN.lv[u.id] = 1; sfx("evo"); shake(10); ring(P.x, P.y, 300, 0.8, GO, 5); burst(P.x, P.y, GO, 40, 320, 3, 0.9); }
276 else { RUN.lv[u.id] = lvOf(u.id) + 1; sfx("pick"); if (u.id === "vit") { recalc(); P.hp = Math.min(P.mhp, P.hp + 35); } if (u.id === "dash" && lvOf("dash") === 1) P.dashCd = 0; }
277 recalc();
278}
279function chooseCard(i) {
280 if (state !== "levelup" || cardT < 0.35 || !cards[i]) return;
281 applyCard(cards[i]); RUN.pending--;
282 if (RUN.pending > 0) { rollCards(); sfx("levelup"); } else { state = "play"; inp.dx = inp.dy = 0; }
283}
284const xpNeed = (l) => Math.round(5 + l * 4 + l * l * 0.4);
285function addXP(v) {
286 RUN.xp += v * G.xpMul;
287 while (RUN.xp >= RUN.need) {
288 RUN.xp -= RUN.need; RUN.level++; RUN.need = xpNeed(RUN.level); RUN.pending++;
289 ring(P.x, P.y, 200, 0.6, CY, 4); burst(P.x, P.y, CY, 24, 260, 2.5, 0.7);
290 }
291}
292
293// ---------- enemies ----------
294const CH = 0, DA = 1, SP = 2, BR = 3, SH = 4, MI = 5;
295// hp spd r dmg xp col
296const TD = [[12, 80, 10, 8, 1, MG], [10, 92, 9, 12, 2, YE], [30, 56, 15, 10, 2, GR], [105, 46, 20, 18, 6, OR], [18, 62, 11, 8, 3, BL], [5, 120, 7, 6, 1, GR]];
297const B = { alive: false, x: 0, y: 0, vx: 0, vy: 0, hp: 1, mhp: 1, r: 44, type: 0, t: 0, cd1: 0, cd2: 0, cd3: 0, st: 0, stT: 0, flash: 0, intro: 0, ang: 0, name: "", col: MG, ax: 0, ay: 1, dashHit: 0, hitCd: new Float32Array(20), zapCd: 0 };
298function spawnEnemy(type, x, y) {
299 const e = findFree(E); if (!e) return null;
300 const d = TD[type], hs = 1 + (RUN.t / 150) * 0.55;
301 e.alive = true; e.type = type; e.x = x; e.y = y; e.vx = e.vy = 0;
302 e.hp = e.mhp = d[0] * (type === MI ? 1 : hs); e.r = d[2]; e.spd = d[1] * (1 + Math.min(0.45, RUN.t / 450)); e.dmg = d[3] * (1 + RUN.t / 500); e.xp = d[4];
303 e.flash = 0; e.hurtT = 0; e.ang = rnd() * TAU; e.t = rnd() * 10; e.st = 0; e.stT = 0; e.cd = 1 + rnd() * 2; e.stun = 0; e.col = d[5]; e.wob = rnd() * 6;
304 e.fx = 1; e.fy = 0; enemyCount++;
305 return e;
306}
307function edgePos(out) {
308 for (let k = 0; k < 6; k++) {
309 const m = 46, cx = cam.x, cy = cam.y, per = 2 * (W + H), r = rnd() * per; let x, y;
310 if (r < W) { x = cx + r; y = cy - m; } else if (r < W + H) { x = cx + W + m; y = cy + (r - W); } else if (r < 2 * W + H) { x = cx + (r - W - H); y = cy + H + m; } else { x = cx - m; y = cy + (r - 2 * W - H); }
311 x = clamp(x, 26, WW - 26); y = clamp(y, 26, WH - 26);
312 out[0] = x; out[1] = y;
313 if (hypot(x - P.x, y - P.y) > 330) return;
314 }
315}
316const _p = [0, 0];
317function spawnRandom() {
318 const t = RUN.t;
319 const wDa = t > 18 ? 20 + Math.min(30, t / 6) : 0, wSp = t > 30 ? 16 + Math.min(20, t / 10) : 0, wSh = t > 42 ? 12 + Math.min(18, t / 12) : 0, wBr = t > 62 ? 8 + Math.min(20, t / 14) : 0;
320 const tot = 100 + wDa + wSp + wSh + wBr; let r = rnd() * tot, type = CH;
321 if ((r -= 100) >= 0) { if ((r -= wDa) < 0) type = DA; else if ((r -= wSp) < 0) type = SP; else if ((r -= wSh) < 0) type = SH; else type = BR; }
322 edgePos(_p); spawnEnemy(type, _p[0], _p[1]);
323}
324function waveEvent(w) {
325 RUN.banner = "WAVE " + w; RUN.bannerT = 2.2; RUN.bannerSub = ""; sfx("wave");
326 if (w < 2 || enemyCount > 250) return;
327 const k = w % 4;
328 if (k === 2) { const n = 16 + w * 2; RUN.bannerSub = "The swarm closes in"; for (let i = 0; i < n; i++) { const a = (i / n) * TAU; spawnEnemy(CH, clamp(P.x + cos(a) * 400, 30, WW - 30), clamp(P.y + sin(a) * 400, 30, WH - 30)); } }
329 else if (k === 3) { const n = 5 + (w >> 1); RUN.bannerSub = "Dashers incoming"; edgePos(_p); for (let i = 0; i < n; i++) spawnEnemy(DA, clamp(_p[0] + (rnd() - 0.5) * 120, 26, WW - 26), clamp(_p[1] + (rnd() - 0.5) * 120, 26, WH - 26)); }
330 else if (k === 0) { RUN.bannerSub = "Armored column"; edgePos(_p); const nb = 2 + (w >> 3); for (let i = 0; i < nb; i++) spawnEnemy(BR, clamp(_p[0] + (rnd() - 0.5) * 160, 26, WW - 26), clamp(_p[1] + (rnd() - 0.5) * 160, 26, WH - 26)); for (let i = 0; i < 4; i++) spawnEnemy(SH, clamp(_p[0] + (rnd() - 0.5) * 300, 26, WW - 26), clamp(_p[1] + (rnd() - 0.5) * 300, 26, WH - 26)); }
331 else { RUN.bannerSub = "Splitter nest"; edgePos(_p); for (let i = 0; i < 6 + (w >> 2); i++) spawnEnemy(SP, clamp(_p[0] + (rnd() - 0.5) * 220, 26, WW - 26), clamp(_p[1] + (rnd() - 0.5) * 220, 26, WH - 26)); }
332}
333function director(dt) {
334 const t = RUN.t, w = 1 + ((t / 30) | 0);
335 if (w !== RUN.wave) { RUN.wave = w; waveEvent(w); }
336 let rate = 1.5 + t * 0.034; if (B.alive) rate *= 0.4;
337 const cap = Math.min(330, 45 + t * 1.6);
338 RUN.spawnAcc += rate * dt;
339 while (RUN.spawnAcc >= 1) { RUN.spawnAcc -= 1; if (enemyCount < cap) spawnRandom(); }
340 if (!B.alive) {
341 if (!RUN.warned && t >= RUN.nextBoss - 3) { RUN.warned = true; RUN.banner = "WARNING"; RUN.bannerSub = "A boss approaches"; RUN.bannerT = 3; sfx("warn"); shake(4); }
342 if (t >= RUN.nextBoss) { spawnBoss(RUN.bossIdx); RUN.bossIdx++; RUN.nextBoss = t + 90; RUN.warned = false; }
343 }
344}
345function ebullet(x, y, a, spd, r, dmg, col, life) {
346 const b = findFree(EB); if (!b) return;
347 b.alive = true; b.x = x; b.y = y; b.vx = cos(a) * spd; b.vy = sin(a) * spd; b.r = r; b.dmg = dmg; b.col = col; b.life = life || 6;
348}
349function updateEnemies(dt) {
350 const px = P.x, py = P.y;
351 for (let i = 0; i < MAXE; i++) {
352 const e = E[i]; if (!e.alive) continue;
353 e.t += dt; if (e.flash > 0) e.flash -= dt; if (e.hurtT > 0) e.hurtT -= dt;
354 const dx = px - e.x, dy = py - e.y; let d = hypot(dx, dy); if (d < 0.001) d = 0.001;
355 const nx = dx / d, ny = dy / d;
356 let tx = 0, ty = 0, sp = e.spd, kk = 4;
357 if (e.stun > 0) { e.stun -= dt; sp = 0; }
358 switch (e.type) {
359 case CH: case MI: { const a = atan2(ny, nx) + sin(e.t * 2 + e.wob) * (e.type === MI ? 0.2 : 0.38); tx = cos(a) * sp; ty = sin(a) * sp; break; }
360 case DA:
361 e.cd -= dt;
362 if (e.st === 0) { tx = nx * sp; ty = ny * sp; if (d < 300 && e.cd <= 0 && e.stun <= 0) { e.st = 1; e.stT = 0.75; } }
363 else if (e.st === 1) { e.ax = nx; e.ay = ny; e.stT -= dt; tx = -nx * 20; ty = -ny * 20; if (e.stT <= 0) { e.st = 2; e.stT = 0.55; } }
364 else if (e.st === 2) { tx = e.ax * 440; ty = e.ay * 440; kk = 14; e.stT -= dt; if (e.stT <= 0) { e.st = 3; e.stT = 0.8; } }
365 else { tx = nx * sp * 0.3; ty = ny * sp * 0.3; e.stT -= dt; if (e.stT <= 0) { e.st = 0; e.cd = 1.6 + rnd(); } }
366 break;
367 case SP: tx = nx * sp; ty = ny * sp; break;
368 case BR: tx = nx * sp; ty = ny * sp; e.fx = nx; e.fy = ny; break;
369 case SH:
370 e.fx = nx; e.fy = ny;
371 if (d > 320) { tx = nx * sp; ty = ny * sp; } else if (d < 220) { tx = -nx * sp; ty = -ny * sp; } else { tx = -ny * sp * 0.6 * (e.wob > 3 ? 1 : -1); ty = nx * sp * 0.6 * (e.wob > 3 ? 1 : -1); }
372 e.cd -= dt;
373 if (e.st === 0) { if (e.cd <= 0 && d < 560) { e.st = 1; e.stT = 0.55; sfx("telegraph"); } }
374 else { e.stT -= dt; tx *= 0.2; ty *= 0.2; if (e.stT <= 0) { e.st = 0; e.cd = 2.4 + rnd() * 0.8; const a = atan2(ny, nx); for (let j = -1; j <= 1; j++) ebullet(e.x, e.y, a + j * 0.2, 175, 5, 8 + RUN.t / 60, BL, 5); sfx("bossShot"); } }
375 break;
376 }
377 const k = Math.min(1, dt * kk);
378 e.vx += (tx - e.vx) * k; e.vy += (ty - e.vy) * k;
379 e.x += e.vx * dt; e.y += e.vy * dt;
380 if (e.x < 20) e.x = 20; else if (e.x > WW - 20) e.x = WW - 20; if (e.y < 20) e.y = 20; else if (e.y > WH - 20) e.y = WH - 20;
381 if (e.type !== BR && e.type !== SH) { if (e.vx * e.vx + e.vy * e.vy > 64) e.ang = atan2(e.vy, e.vx); } else e.ang = atan2(e.fy, e.fx);
382 if (d < e.r + P.r) {
383 if (P.dashT <= 0 && P.inv <= 0) { hurtPlayer(e.dmg, e.x, e.y); e.vx -= nx * 260; e.vy -= ny * 260; e.stun = 0.4; if (e.type === DA && e.st === 2) { e.st = 3; e.stT = 0.8; } }
384 else if (P.dashT <= 0) { e.vx -= nx * 60; e.vy -= ny * 60; }
385 }
386 }
387}
388function separate() {
389 const par = frameN & 1;
390 for (let i = par; i < MAXE; i += 2) {
391 const e = E[i]; if (!e.alive) continue;
392 const n = query(e.x, e.y, e.r + 20, qb2);
393 for (let j = 0; j < n; j++) {
394 const o = E[qb2[j]]; if (o === e) continue;
395 const dx = e.x - o.x, dy = e.y - o.y, mn = e.r + o.r - 3, d2 = dx * dx + dy * dy;
396 if (d2 < mn * mn && d2 > 0.01) {
397 const d = sqrt(d2), push = (mn - d) * 0.5, wE = o.r / (e.r + o.r);
398 e.x += (dx / d) * push * 2 * wE * 1.1; e.y += (dy / d) * push * 2 * wE * 1.1;
399 }
400 }
401 }
402}
403function hurtE(e, dmg, sx, sy, crit, orb) {
404 if (e.type === BR) {
405 const dx = sx - e.x, dy = sy - e.y, l = hypot(dx, dy) || 1;
406 if ((dx * e.fx + dy * e.fy) / l > 0.45) { dmg *= 0.18; ring(e.x + e.fx * e.r, e.y + e.fy * e.r, 16, 0.2, CY, 2, 0.8); spark(e.x + e.fx * e.r, e.y + e.fy * e.r, (rnd() - 0.5) * 200, (rnd() - 0.5) * 200, 0.3, 2, CY, 3); sfx("reflect"); }
407 }
408 e.hp -= dmg; e.flash = 0.09;
409 if (dmg >= 1) num(e.x, e.y - e.r, String(Math.round(dmg)), crit ? "#ffd35a" : "#ffffff", crit ? 20 : 14);
410 if (e.hp <= 0) killE(e, orb);
411}
412function dropGem(x, y, v, kind) {
413 let g = findFree(GM);
414 if (!g) { g = GM[(rnd() * MAXG) | 0]; if (g.kind < 3) { g.v += v; return; } }
415 g.alive = true; g.x = x; g.y = y; const a = rnd() * TAU, s = 40 + rnd() * 90; g.vx = cos(a) * s; g.vy = sin(a) * s; g.v = v; g.kind = kind || 0; g.t = 0; g.att = false;
416}
417function killE(e, orb) {
418 e.alive = false; enemyCount--; RUN.kills++;
419 const c = e.col, big = e.r > 14;
420 burst(e.x, e.y, c, big ? 22 : 11, big ? 260 : 190, big ? 3.2 : 2.4, 0.55);
421 burst(e.x, e.y, WT, 3, 120, 2, 0.3);
422 ring(e.x, e.y, e.r * 3.2, 0.28, c, 2.5, 0.8);
423 if (big) { shake(4); sfx("bigkill"); } else sfx("kill", Math.min(8, RUN.combo >> 2));
424 RUN.combo++; RUN.comboT = 1.2;
425 dropGem(e.x, e.y, e.xp, 0);
426 if (e.type === SP) for (let i = 0; i < 3; i++) { const a = (i / 3) * TAU + rnd(), m = spawnEnemy(MI, e.x + cos(a) * 10, e.y + sin(a) * 10); if (m) { m.vx = cos(a) * 200; m.vy = sin(a) * 200; m.hp = m.mhp = 5 * (1 + RUN.t / 300); } }
427 else if (e.type !== MI) { const r = rnd(); if (r < 0.018 + (P.hp < P.mhp * 0.4 ? 0.02 : 0)) dropGem(e.x, e.y, 0, 3); else if (r > 0.9965) dropGem(e.x, e.y, 0, 4); }
428 if (orb && RUN.evo.vamp && P.hp < P.mhp) { P.hp = Math.min(P.mhp, P.hp + 1); }
429}
430
431// ---------- boss ----------
432const BOSSES = [{ name: "HYDRA CORE", col: MG }, { name: "PRISM WARDEN", col: YE }, { name: "VOIDMAW", col: GR }];
433function spawnBoss(idx) {
434 const t = idx % 3, def = BOSSES[t];
435 B.alive = true; B.type = t; B.name = def.name; B.col = def.col; B.mhp = B.hp = (1400 + 950 * idx) * (1 + RUN.t / 900);
436 B.r = 44 + (t === 2 ? 6 : 0); B.x = clamp(P.x, 160, WW - 160); B.y = Math.max(70, cam.y - 90); B.vx = B.vy = 0; B.t = 0; B.cd1 = 1.5; B.cd2 = 3; B.cd3 = 6; B.st = 0; B.stT = 4; B.flash = 0; B.intro = 2.4; B.ang = 0; B.dashHit = 0; B.hitCd.fill(0);
437 RUN.banner = def.name; RUN.bannerSub = "BOSS"; RUN.bannerT = 2.6; sfx("warn"); shake(8);
438}
439function bossApproach(dt, dist, sp) {
440 const dx = P.x - B.x, dy = P.y - B.y, d = hypot(dx, dy) || 1, nx = dx / d, ny = dy / d;
441 let tx, ty; if (d > dist + 30) { tx = nx * sp; ty = ny * sp; } else if (d < dist - 30) { tx = -nx * sp; ty = -ny * sp; } else { tx = -ny * sp * 0.6; ty = nx * sp * 0.6; }
442 B.vx += (tx - B.vx) * Math.min(1, dt * 2); B.vy += (ty - B.vy) * Math.min(1, dt * 2);
443 B.x = clamp(B.x + B.vx * dt, 60, WW - 60); B.y = clamp(B.y + B.vy * dt, 60, WH - 60);
444}
445function updateBoss(dt) {
446 B.t += dt; B.ang += dt * 0.8; if (B.flash > 0) B.flash -= dt; if (B.dashHit > 0) B.dashHit -= dt;
447 for (let i = 0; i < 20; i++) if (B.hitCd[i] > 0) B.hitCd[i] -= dt;
448 if (B.intro > 0) { B.intro -= dt; B.y += (Math.max(120, cam.y + 200) - B.y) * Math.min(1, dt * 1.5); return; }
449 const hpf = B.hp / B.mhp, rage = hpf < 0.5 ? 0.7 : 1;
450 const aim = atan2(P.y - B.y, P.x - B.x);
451 if (B.type === 0) {
452 bossApproach(dt, 290, 60);
453 B.cd1 -= dt; if (B.cd1 <= 0) { B.cd1 = 2.5 * rage; const n = hpf < 0.5 ? 22 : 15, off = B.t * 0.7; for (let i = 0; i < n; i++) ebullet(B.x, B.y, off + (i * TAU) / n, 140, 6, 10, MG, 7); sfx("bossShot"); ring(B.x, B.y, 90, 0.4, MG, 4); }
454 B.cd2 -= dt; if (B.cd2 <= 0) { B.cd2 = 5 * rage; for (let j = -2; j <= 2; j++) ebullet(B.x, B.y, aim + j * 0.17, 230, 5, 10, RD, 6); sfx("bossShot"); }
455 B.cd3 -= dt; if (B.cd3 <= 0) { B.cd3 = 8.5 * rage; for (let i = 0; i < 4; i++) spawnEnemy(CH, B.x + (rnd() - 0.5) * 90, B.y + (rnd() - 0.5) * 90); ring(B.x, B.y, 120, 0.5, MG, 3); }
456 } else if (B.type === 1) {
457 if (B.st === 0) {
458 bossApproach(dt, 300, 50); B.ang += dt * 2;
459 B.cd1 -= dt; while (B.cd1 <= 0) { B.cd1 += 0.1 * (hpf < 0.5 ? 0.8 : 1); const a0 = B.t * 2.5; for (let arm = 0; arm < 3; arm++) ebullet(B.x, B.y, a0 + (arm * TAU) / 3, 165, 5, 9, YE, 7); }
460 B.stT -= dt; if (B.stT <= 0) { B.st = 1; B.stT = 1.0; sfx("telegraph"); }
461 } else if (B.st === 1) {
462 B.vx *= 0.9; B.vy *= 0.9; B.stT -= dt; if (B.stT > 0.25) { B.ax = cos(aim); B.ay = sin(aim); }
463 if (B.stT <= 0) { B.st = 2; B.stT = 0.55; sfx("dash"); }
464 } else if (B.st === 2) {
465 B.x = clamp(B.x + B.ax * 820 * dt, 60, WW - 60); B.y = clamp(B.y + B.ay * 820 * dt, 60, WH - 60); B.stT -= dt;
466 if (((frameN & 3) === 0)) burst(B.x, B.y, YE, 3, 60, 3, 0.4);
467 if (B.stT <= 0) { B.st = 3; B.stT = 1.1; for (let i = 0; i < 12; i++) ebullet(B.x, B.y, (i * TAU) / 12, 190, 5, 10, YE, 5); shake(6); ring(B.x, B.y, 150, 0.4, YE, 4); }
468 } else { B.stT -= dt; B.vx *= 0.9; B.vy *= 0.9; if (B.stT <= 0) { B.st = 0; B.stT = 4.5 * rage; } }
469 } else {
470 bossApproach(dt, 260, 50);
471 B.cd1 -= dt; if (B.cd1 <= 0) { B.cd1 = 3.4 * rage; const gap = aim + (rnd() - 0.5) * 0.6, n = 28; for (let i = 0; i < n; i++) { const a = (i * TAU) / n; let da = a - gap; da = atan2(sin(da), cos(da)); if (abs(da) < 0.36) continue; ebullet(B.x, B.y, a, 125, 6, 11, GR, 8); } ring(B.x, B.y, 140, 0.5, GR, 5); sfx("boom"); shake(5); }
472 B.cd2 -= dt; if (B.cd2 <= 0) { B.cd2 = 2.7 * rage; for (let j = -3; j <= 3; j++) ebullet(B.x, B.y, aim + j * 0.14, 205, 5, 9, OR, 6); sfx("bossShot"); }
473 B.cd3 -= dt; if (B.cd3 <= 0) { B.cd3 = 9 * rage; for (let i = 0; i < 5; i++) { const a = (i / 5) * TAU + rnd(); spawnEnemy(SP, B.x + cos(a) * 70, B.y + sin(a) * 70); } ring(B.x, B.y, 130, 0.5, GR, 3); }
474 }
475 if (!(B.type === 1 && B.st === 2)) { B.x = clamp(B.x, cam.x + 80, cam.x + W - 80); B.y = clamp(B.y, cam.y + 190, cam.y + H - 150); }
476 const dx = P.x - B.x, dy = P.y - B.y, d = hypot(dx, dy);
477 if (d < B.r + P.r) { if (hurtPlayer(20 + RUN.bossIdx * 3, B.x, B.y)) { P.vx += (dx / d) * 300; P.vy += (dy / d) * 300; } }
478}
479function hurtBoss(dmg, crit, x, y) {
480 if (!B.alive) return;
481 if (B.intro > 0) { return; }
482 B.hp -= dmg; B.flash = 0.07;
483 num(x == null ? B.x : x, (y == null ? B.y : y) - 10, String(Math.round(dmg)), crit ? "#ffd35a" : "#ffffff", crit ? 22 : 15);
484 sfx("bossHit");
485 if (B.hp <= 0) killBoss();
486}
487function killBoss() {
488 B.alive = false; RUN.bossKills++; timeScale = 0.3; RUN.slowT = 1.0;
489 sfx("bossDie"); shake(18);
490 for (let i = 0; i < 4; i++) { ring(B.x, B.y, 200 + i * 90, 0.7 + i * 0.15, i & 1 ? WT : B.col, 6); }
491 burst(B.x, B.y, B.col, 70, 420, 4, 1.2); burst(B.x, B.y, GO, 40, 300, 3, 1.0); burst(B.x, B.y, WT, 30, 500, 2.5, 0.6);
492 for (let i = 0; i < 12; i++) dropGem(B.x, B.y, 10 + RUN.bossIdx * 2, 0);
493 dropGem(B.x, B.y, 0, 3); dropGem(B.x, B.y, 0, 4);
494 for (let i = 0; i < MAXB; i++) if (EB[i].alive) { burst(EB[i].x, EB[i].y, EB[i].col, 2, 100, 2, 0.3); EB[i].alive = false; }
495 for (let i = 0; i < MAXE; i++) { const e = E[i]; if (e.alive && hypot(e.x - B.x, e.y - B.y) < 260) { e.hp = 0; killE(e, false); } }
496 RUN.banner = "BOSS DEFEATED"; RUN.bannerSub = "+1500"; RUN.bannerT = 3;
497}
498
499// ---------- player ----------
500function startDash() {
501 let dx = inp.dx, dy = inp.dy;
502 if (botOn) { dx = bot.dx; dy = bot.dy; }
503 const kx = (keys.right ? 1 : 0) - (keys.left ? 1 : 0), ky = (keys.down ? 1 : 0) - (keys.up ? 1 : 0);
504 if (kx || ky) { dx = kx; dy = ky; }
505 let l = hypot(dx, dy); if (l < 0.1) { dx = P.fx; dy = P.fy; l = hypot(dx, dy) || 1; }
506 P.dashDx = dx / l; P.dashDy = dy / l; P.dashT = 0.16 + 0.02 * lvOf("dash"); P.dashCd = G.dashCdMax; P.inv = Math.max(P.inv, P.dashT + 0.2);
507 sfx("dash"); ring(P.x, P.y, 60, 0.3, MG, 3); shake(2);
508}
509function hurtPlayer(d, sx, sy) {
510 if (P.inv > 0 || P.dashT > 0 || state !== "play") return false;
511 const sl = lvOf("shield");
512 if (sl && P.shCd <= 0) {
513 P.shCd = Math.max(4, 11.5 - 2.5 * sl); P.inv = 0.6; sfx("shieldPop"); shake(6);
514 ring(P.x, P.y, 170, 0.5, CY, 5); burst(P.x, P.y, CY, 20, 260, 2.5, 0.6);
515 const n = query(P.x, P.y, 140, qb);
516 for (let j = 0; j < n; j++) { const e = E[qb[j]]; if (!e.alive) continue; const dx = e.x - P.x, dy = e.y - P.y, l = hypot(dx, dy) || 1; e.vx += (dx / l) * 420; e.vy += (dy / l) * 420; e.stun = 0.4; if (RUN.evo.aegis) hurtE(e, 60 * G.power, P.x, P.y, false, false); }
517 return false;
518 }
519 P.hp -= d; P.inv = 0.75; P.hitFlash = 1; shake(9 + d * 0.15); sfx("hurt");
520 burst(P.x, P.y, RD, 14, 260, 2.8, 0.5); ring(P.x, P.y, 80, 0.3, RD, 4);
521 num(P.x, P.y - 20, "-" + Math.round(d), "#ff6070", 18);
522 if (god && P.hp < 1) P.hp = 1;
523 if (P.hp <= 0) die();
524 return true;
525}
526let deathT = 0;
527function die() {
528 if (ml("rev") && !P.revived) {
529 P.revived = true; P.hp = P.mhp * 0.5; P.inv = 2.2; sfx("revive"); shake(12);
530 ring(P.x, P.y, 400, 0.8, GO, 6); ring(P.x, P.y, 260, 0.6, WT, 4); burst(P.x, P.y, GO, 40, 400, 3, 0.9);
531 for (let i = 0; i < MAXE; i++) { const e = E[i]; if (e.alive && hypot(e.x - P.x, e.y - P.y) < 300) { e.hp = 0; killE(e, false); } }
532 for (let i = 0; i < MAXB; i++) EB[i].alive = false;
533 RUN.banner = "SECOND CHANCE"; RUN.bannerSub = ""; RUN.bannerT = 2; return;
534 }
535 P.hp = 0; state = "dying"; deathT = 0; inp.dx = inp.dy = 0;
536 sfx("over"); shake(16); burst(P.x, P.y, CY, 60, 420, 3.5, 1.2); burst(P.x, P.y, WT, 30, 300, 2.5, 0.8); ring(P.x, P.y, 320, 0.9, CY, 6); ring(P.x, P.y, 200, 0.7, WT, 3);
537 RUN.score = calcScore();
538 RUN.shards = Math.floor(RUN.t / 8) + Math.floor(RUN.kills / 20) + RUN.bossKills * 25 + RUN.level * 2;
539 const best = getBest(); RUN.newBest = RUN.score > best; if (RUN.newBest) LS.set("orbitsurvivors.best", RUN.score);
540 meta.shards += RUN.shards; meta.runs++; saveMeta(); LS.set("orbitsurvivors.tut", "1");
541 if (window.Arcadia) { try { Arcadia.submitScore(RUN.score); Arcadia.gameOver(); } catch (e) {} }
542}
543const calcScore = () => Math.floor(RUN.t) * 10 + RUN.kills * 5 + RUN.bossKills * 1500;
544
545function updatePlayer(dt) {
546 let dx = 0, dy = 0;
547 if (botOn) { dx = bot.dx; dy = bot.dy; } else {
548 dx = inp.dx; dy = inp.dy;
549 const kx = (keys.right ? 1 : 0) - (keys.left ? 1 : 0), ky = (keys.down ? 1 : 0) - (keys.up ? 1 : 0);
550 if (kx || ky) { const l = hypot(kx, ky); dx = kx / l; dy = ky / l; }
551 }
552 if (dx || dy) { const l = hypot(dx, dy) || 1; P.fx = dx / l; P.fy = dy / l; }
553 const sp = SPEED * (1 + 0.04 * ml("spd"));
554 if (P.dashT > 0) {
555 P.dashT -= dt; P.vx = P.dashDx * DASHSPD; P.vy = P.dashDy * DASHSPD;
556 const n = query(P.x, P.y, 44, qb);
557 for (let j = 0; j < n; j++) { const e = E[qb[j]]; if (!e.alive || e.hurtT > 0) continue; if (hypot(e.x - P.x, e.y - P.y) < e.r + 22) { e.hurtT = 0.3; hurtE(e, (22 + 9 * lvOf("dash")) * G.power, P.x, P.y, false, false); } }
558 if (B.alive && B.dashHit <= 0 && hypot(B.x - P.x, B.y - P.y) < B.r + 24) { B.dashHit = 0.4; hurtBoss((30 + 10 * lvOf("dash")) * G.power, false); }
559 spark(P.x, P.y, 0, 0, 0.35, 9, MG, 1); spark(P.x, P.y, 0, 0, 0.3, 5, WT, 1);
560 if (P.dashT <= 0 && RUN.evo.blink) {
561 sfx("boom"); shake(7); ring(P.x, P.y, 190, 0.45, MG, 6); ring(P.x, P.y, 120, 0.3, WT, 3);
562 const m = query(P.x, P.y, 160, qb);
563 for (let j = 0; j < m; j++) { const e = E[qb[j]]; if (e.alive) { const ex = e.x - P.x, ey = e.y - P.y, l = hypot(ex, ey) || 1; e.vx += (ex / l) * 380; e.vy += (ey / l) * 380; hurtE(e, 40 * G.power, P.x, P.y, false, false); } }
564 if (B.alive && hypot(B.x - P.x, B.y - P.y) < 200) hurtBoss(60 * G.power, false);
565 }
566 } else {
567 const k = 1 - Math.exp(-dt * 7);
568 P.vx += (dx * sp - P.vx) * k; P.vy += (dy * sp - P.vy) * k;
569 }
570 P.x = clamp(P.x + P.vx * dt, 16, WW - 16); P.y = clamp(P.y + P.vy * dt, 16, WH - 16);
571 const spd = hypot(P.vx, P.vy), mvT = spd > 45 ? 1 : 0;
572 P.mv += (mvT - P.mv) * Math.min(1, dt * (mvT ? 8 : 2.6));
573 if (P.inv > 0) P.inv -= dt; if (P.dashCd > 0) P.dashCd -= dt; if (P.shCd > 0) P.shCd -= dt; if (P.hitFlash > 0) P.hitFlash -= dt * 3;
574 if (spd > 60 && (frameN & 1) === 0) spark(P.x - P.vx * 0.03, P.y - P.vy * 0.03, -P.vx * 0.1 + (rnd() - 0.5) * 30, -P.vy * 0.1 + (rnd() - 0.5) * 30, 0.35, 3, CY, 3);
575 const rl = lvOf("regen"); if (rl && P.hp < P.mhp) P.hp = Math.min(P.mhp, P.hp + 0.9 * rl * dt);
576 if (RUN.comboT > 0) { RUN.comboT -= dt; if (RUN.comboT <= 0) RUN.combo = 0; }
577 if (RUN.evo.sing) {
578 RUN.singT -= dt;
579 if (RUN.singT <= 0) {
580 RUN.singT = 9; sfx("boom"); ring(P.x, P.y, 260, 0.7, PU, 6); ring(P.x, P.y, 160, 0.5, WT, 3); RUN.vac = 1.6; shake(6);
581 const n = query(P.x, P.y, 260, qb);
582 for (let j = 0; j < n; j++) { const e = E[qb[j]]; if (e.alive) hurtE(e, 70 * G.power, P.x, P.y, false, false); }
583 if (B.alive && hypot(B.x - P.x, B.y - P.y) < 300) hurtBoss(80 * G.power, false);
584 }
585 }
586}
587
588// ---------- orbs, shooters, lightning ----------
589const TL = 9, ox = new Float32Array(16), oy = new Float32Array(16), otrail = new Float32Array(16 * TL * 2);
590let phase = 0, thead = 0;
591function distSeg(px, py, x1, y1, x2, y2) {
592 const vx = x2 - x1, vy = y2 - y1, l2 = vx * vx + vy * vy;
593 let t = l2 > 0 ? ((px - x1) * vx + (py - y1) * vy) / l2 : 0; t = t < 0 ? 0 : t > 1 ? 1 : t;
594 const cx = x1 + vx * t - px, cy = y1 + vy * t - py; return sqrt(cx * cx + cy * cy);
595}
596function segZap(x1, y1, x2, y2, dmg) {
597 const mx = (x1 + x2) / 2, my = (y1 + y2) / 2, half = hypot(x2 - x1, y2 - y1) / 2 + 24;
598 const n = query(mx, my, half, qb); let hit = 0;
599 for (let j = 0; j < n; j++) {
600 const e = E[qb[j]]; if (!e.alive) continue;
601 if (distSeg(e.x, e.y, x1, y1, x2, y2) < e.r + 8) { hit++; spark(e.x, e.y, (rnd() - 0.5) * 160, (rnd() - 0.5) * 160, 0.25, 2.2, PU, 3); hurtE(e, dmg, mx, my, false, true); }
602 }
603 if (B.alive && B.intro <= 0 && B.zapCd <= 0 && distSeg(B.x, B.y, x1, y1, x2, y2) < B.r + 8) { hurtBoss(dmg, false, B.x + (rnd() - 0.5) * 30, B.y); hit++; }
604 return hit;
605}
606function nearestTarget(x, y, maxD) {
607 let best = -1, bd = maxD * maxD;
608 for (let i = 0; i < MAXE; i++) { const e = E[i]; if (!e.alive) continue; const dx = e.x - x, dy = e.y - y, d2 = dx * dx + dy * dy; if (d2 < bd) { bd = d2; best = i; } }
609 return best;
610}
611function pbullet(x, y, a, spd, dmg, pierce, big) {
612 const b = findFree(PB); if (!b) return null;
613 b.alive = true; b.x = x; b.y = y; b.vx = cos(a) * spd; b.vy = sin(a) * spd; b.dmg = dmg; b.life = 1.1; b.pierce = pierce; b.last = -1; b.col = big ? GO : GR; b.big = big || 0;
614 return b;
615}
616function updateOrbs(dt) {
617 const n = G.n, lv = RUN.lv;
618 const dashMul = P.dashT > 0 && RUN.evo.blink ? 2 : 1;
619 phase += G.spin * dt * dashMul;
620 G.curR = G.baseR * (0.7 + 0.3 * P.mv);
621 G.mult = 1 + 0.9 * clamp((112 - G.curR) / 62, 0, 1);
622 thead = (thead + 1) % TL;
623 for (let i = 0; i < n; i++) {
624 const a = phase + (i * TAU) / n;
625 ox[i] = P.x + cos(a) * G.curR; oy[i] = P.y + sin(a) * G.curR;
626 otrail[(i * TL + thead) * 2] = ox[i]; otrail[(i * TL + thead) * 2 + 1] = oy[i];
627 }
628 if (B.zapCd > 0) B.zapCd -= dt;
629 // collisions
630 const dmgBase = ORB_DMG * G.power * G.mult;
631 for (let i = 0; i < n; i++) {
632 const x = ox[i], y = oy[i], m = query(x, y, ORB_R + 26, qb);
633 for (let j = 0; j < m; j++) {
634 const e = E[qb[j]]; if (!e.alive || e.hurtT > 0) continue;
635 const dx = e.x - x, dy = e.y - y, rr = e.r + ORB_R;
636 if (dx * dx + dy * dy < rr * rr) {
637 const crit = rnd() < 0.1, dmg = dmgBase * (0.9 + rnd() * 0.2) * (crit ? 2 : 1);
638 e.hurtT = 0.19; const l = hypot(dx, dy) || 1; const kb = e.type === BR ? 90 : 190;
639 e.vx += (dx / l) * kb; e.vy += (dy / l) * kb;
640 spark(x, y, (dx / l) * 140 + (rnd() - 0.5) * 100, (dy / l) * 140 + (rnd() - 0.5) * 100, 0.25, 2.2, crit ? GO : CY, 3);
641 sfx(crit ? "crit" : "hit", i);
642 hurtE(e, dmg, x, y, crit, true);
643 }
644 }
645 if (B.alive && B.intro <= 0 && B.hitCd[i] <= 0) {
646 const dx = B.x - x, dy = B.y - y, rr = B.r + ORB_R;
647 if (dx * dx + dy * dy < rr * rr) {
648 B.hitCd[i] = 0.2; const crit = rnd() < 0.1;
649 spark(x, y, (rnd() - 0.5) * 240, (rnd() - 0.5) * 240, 0.3, 2.4, crit ? GO : CY, 3);
650 hurtBoss(dmgBase * (0.9 + rnd() * 0.2) * (crit ? 2 : 1), crit, x, y);
651 }
652 }
653 }
654 // shooter orbs
655 const sl = lv.shoot | 0;
656 if (sl) {
657 RUN.shootT -= dt;
658 if (RUN.shootT <= 0) {
659 RUN.shootT = 1.45 / (1 + 0.4 * (sl - 1));
660 const oi = RUN.shootI++ % n; let ex = -1, ey = 0;
661 const ti = nearestTarget(ox[oi], oy[oi], 420);
662 if (ti >= 0) { ex = E[ti].x; ey = E[ti].y; } else if (B.alive) { ex = B.x; ey = B.y; }
663 if (ex >= 0) {
664 const a = atan2(ey - oy[oi], ex - ox[oi]), dmg = (9 + 5 * sl) * G.power;
665 if (RUN.evo.nova) { for (let j = -1; j <= 1; j++) pbullet(ox[oi], oy[oi], a + j * 0.2, 560, dmg, 3, 1); } else pbullet(ox[oi], oy[oi], a, 560, dmg, 0, 0);
666 sfx("shoot"); spark(ox[oi], oy[oi], 0, 0, 0.15, 8, GR, 1);
667 } else RUN.shootT = 0.2;
668 }
669 }
670 // lightning
671 const zl = lv.zap | 0;
672 if (zl && n >= 2) {
673 RUN.zapT -= dt;
674 if (RUN.zapT <= 0) {
675 RUN.zapT = 0.24 / (1 + 0.18 * (zl - 1));
676 const dmg = (6 + 4 * zl) * G.power * (RUN.evo.web ? 1.5 : 1); let hits = 0;
677 const segs = n === 2 ? 1 : n;
678 for (let i = 0; i < segs; i++) { const j = (i + 1) % n; hits += segZap(ox[i], oy[i], ox[j], oy[j], dmg); }
679 if (RUN.evo.web) for (let i = 0; i < n; i++) hits += segZap(P.x, P.y, ox[i], oy[i], dmg);
680 if (B.alive) B.zapCd = 0;
681 if (hits) sfx("zap");
682 }
683 }
684}
685function updateBullets(dt) {
686 // enemy bullets
687 const shOn = lvOf("shield") > 0 && P.shCd <= 0, shR = G.curR + 20;
688 for (let i = 0; i < MAXB; i++) {
689 const b = EB[i]; if (!b.alive) continue;
690 b.x += b.vx * dt; b.y += b.vy * dt; b.life -= dt;
691 if (b.life <= 0 || b.x < -20 || b.y < -20 || b.x > WW + 20 || b.y > WH + 20) { b.alive = false; continue; }
692 const dx = b.x - P.x, dy = b.y - P.y, d2 = dx * dx + dy * dy;
693 if (shOn && d2 < shR * shR && d2 > 1) {
694 b.alive = false; sfx("reflect"); ring(b.x, b.y, 22, 0.25, CY, 3);
695 const a = atan2(-dy, -dx) + (rnd() - 0.5) * 0.3, dm = 20 * G.power * (RUN.evo.aegis ? 4 : 1);
696 const nb = pbullet(b.x, b.y, a + PI, 480, dm, 2, RUN.evo.aegis ? 1 : 0); if (nb) nb.col = CY;
697 continue;
698 }
699 let dead = false;
700 for (let o = 0; o < G.n; o++) { const ex = b.x - ox[o], ey = b.y - oy[o], rr = b.r + ORB_R; if (ex * ex + ey * ey < rr * rr) { dead = true; burst(b.x, b.y, b.col, 4, 120, 2, 0.3); sfx("boom"); break; } }
701 if (dead) { b.alive = false; continue; }
702 if (d2 < (b.r + P.r - 2) * (b.r + P.r - 2)) { if (P.dashT > 0 || P.inv > 0) { if (P.dashT > 0) continue; continue; } b.alive = false; hurtPlayer(b.dmg, b.x, b.y); }
703 }
704 // player bullets
705 for (let i = 0; i < MAXPB; i++) {
706 const b = PB[i]; if (!b.alive) continue;
707 b.x += b.vx * dt; b.y += b.vy * dt; b.life -= dt;
708 if (b.life <= 0 || b.x < 0 || b.y < 0 || b.x > WW || b.y > WH) { b.alive = false; continue; }
709 if ((frameN & 1) === 0) spark(b.x, b.y, 0, 0, 0.2, b.big ? 5 : 3.5, b.col, 1);
710 const m = query(b.x, b.y, 30, qb); let done = false;
711 for (let j = 0; j < m; j++) {
712 const ei = qb[j], e = E[ei]; if (!e.alive || ei === b.last) continue;
713 const dx = e.x - b.x, dy = e.y - b.y, rr = e.r + 5;
714 if (dx * dx + dy * dy < rr * rr) {
715 hurtE(e, b.dmg, b.x, b.y, false, true); burst(b.x, b.y, b.col, 4, 140, 2, 0.25); sfx("hit", 3);
716 if (b.pierce > 0) { b.pierce--; b.last = ei; } else { b.alive = false; done = true; break; }
717 }
718 }
719 if (done) continue;
720 if (B.alive && B.intro <= 0 && hypot(B.x - b.x, B.y - b.y) < B.r + 5) { hurtBoss(b.dmg, false, b.x, b.y); b.alive = false; burst(b.x, b.y, b.col, 5, 160, 2, 0.3); }
721 }
722}
723function updateGems(dt) {
724 const mr = G.magR, vac = RUN.vac > 0; if (vac) RUN.vac -= dt;
725 for (let i = 0; i < MAXG; i++) {
726 const g = GM[i]; if (!g.alive) continue;
727 g.t += dt;
728 const dx = P.x - g.x, dy = P.y - g.y, d = hypot(dx, dy) || 1;
729 if (!g.att && (d < mr || vac || (g.kind >= 3 && d < mr * 0.7))) g.att = true;
730 if (g.att) { const s = 320 + Math.max(0, 420 - d) * 1.1 + g.t * 20; g.vx = (dx / d) * s; g.vy = (dy / d) * s; }
731 else { const f = Math.max(0, 1 - 3.5 * dt); g.vx *= f; g.vy *= f; }
732 g.x += g.vx * dt; g.y += g.vy * dt;
733 if (g.x < 8) g.x = 8; else if (g.x > WW - 8) g.x = WW - 8; if (g.y < 8) g.y = 8; else if (g.y > WH - 8) g.y = WH - 8;
734 if (d < P.r + 10) {
735 g.alive = false;
736 if (g.kind === 3) { P.hp = Math.min(P.mhp, P.hp + P.mhp * 0.25); sfx("heart"); num(P.x, P.y - 24, "+HP", "#5dff8a", 18); burst(P.x, P.y, GR, 14, 200, 2.5, 0.5); ring(P.x, P.y, 90, 0.4, GR, 3); }
737 else if (g.kind === 4) { RUN.vac = 1.8; sfx("heart"); ring(P.x, P.y, 300, 0.6, PU, 4); num(P.x, P.y - 24, "MAGNET", "#b56bff", 16); }
738 else { addXP(g.v); RUN.gemCombo = (RUN.gemCombo | 0) + 1; sfx("gem", RUN.gemCombo & 15); spark(P.x, P.y, (rnd() - 0.5) * 100, (rnd() - 0.5) * 100, 0.25, 2.5, g.v >= 8 ? GO : CY, 3); }
739 }
740 }
741 RUN.gemT = (RUN.gemT || 0) - dt; if (RUN.gemT <= 0) { RUN.gemT = 0.4; RUN.gemCombo = 0; }
742}
743function updateFx(dt) {
744 for (let i = 0; i < MAXP; i++) {
745 const p = PT[i]; if (p.life <= 0) continue;
746 p.life -= dt; const f = Math.max(0, 1 - p.drag * dt); p.vx *= f; p.vy *= f; p.x += p.vx * dt; p.y += p.vy * dt;
747 }
748 for (let i = 0; i < MAXR; i++) { const r = RG[i]; if (r.life <= 0) continue; r.life -= dt; const t = 1 - r.life / r.max; r.r = 4 + (r.mr - 4) * (1 - (1 - t) * (1 - t)); }
749 for (let i = 0; i < MAXN; i++) { const n = NUM[i]; if (n.life <= 0) continue; n.life -= dt; n.y += n.vy * dt; n.vy *= 0.94; }
750 if (cam.shake > 0) { cam.shake = Math.max(0, cam.shake - dt * 30); cam.sx = (rnd() - 0.5) * cam.shake; cam.sy = (rnd() - 0.5) * cam.shake; } else cam.sx = cam.sy = 0;
751}
752function updateCamera(dt, snap) {
753 cam.tx = clamp(P.x + P.vx * 0.22 - W / 2, -40, WW - W + 40); cam.ty = clamp(P.y + P.vy * 0.22 - H / 2, -40, WH - H + 40);
754 const k = snap ? 1 : 1 - Math.exp(-dt * 3);
755 cam.x += (cam.tx - cam.x) * k; cam.y += (cam.ty - cam.y) * k;
756}
757
758// ---------- bot (debug) ----------
759function botThink() {
760 let ax = 0, ay = 0, crowd = 0;
761 for (let i = 0; i < MAXE; i++) {
762 const e = E[i]; if (!e.alive) continue;
763 const dx = P.x - e.x, dy = P.y - e.y, d = hypot(dx, dy);
764 if (d < 170) { const w = (1 - d / 170); ax += (dx / (d || 1)) * w * w * 2.2; ay += (dy / (d || 1)) * w * w * 2.2; if (d < 70) crowd++; }
765 }
766 for (let i = 0; i < MAXB; i++) { const b = EB[i]; if (!b.alive) continue; const dx = P.x - b.x, dy = P.y - b.y, d = hypot(dx, dy); if (d < 110) { const w = 1 - d / 110; ax += (dx / (d || 1)) * w * 1.5; ay += (dy / (d || 1)) * w * 1.5; } }
767 if (B.alive) { const dx = P.x - B.x, dy = P.y - B.y, d = hypot(dx, dy); if (d < 260) { const w = 1 - d / 260; ax += (dx / (d || 1)) * w * 1.5; ay += (dy / (d || 1)) * w * 1.5; } }
768 let bd = 1e9, bg = null;
769 for (let i = 0; i < MAXG; i++) { const g = GM[i]; if (!g.alive) continue; const d = hypot(g.x - P.x, g.y - P.y); if (d < bd) { bd = d; bg = g; } }
770 if (bg && bd < 600) { ax += ((bg.x - P.x) / bd) * 0.55; ay += ((bg.y - P.y) / bd) * 0.55; }
771 ax += (WW / 2 - P.x) / WW * 0.6; ay += (WH / 2 - P.y) / WH * 0.6;
772 const m = 90; if (P.x < m) ax += 1; if (P.x > WW - m) ax -= 1; if (P.y < m) ay += 1; if (P.y > WH - m) ay -= 1;
773 const l = hypot(ax, ay); if (l > 0.05) { bot.dx = ax / Math.max(1, l); bot.dy = ay / Math.max(1, l); } else bot.dx = bot.dy = 0;
774 if (crowd >= 4) requestDash();
775}
776
777// ---------- run control ----------
778function startRun() {
779 audioStart(); sfx("click");
780 for (let i = 0; i < MAXE; i++) E[i].alive = false;
781 for (let i = 0; i < MAXB; i++) EB[i].alive = false;
782 for (let i = 0; i < MAXPB; i++) PB[i].alive = false;
783 for (let i = 0; i < MAXG; i++) GM[i].alive = false;
784 for (let i = 0; i < MAXP; i++) PT[i].life = 0;
785 for (let i = 0; i < MAXR; i++) RG[i].life = 0;
786 for (let i = 0; i < MAXN; i++) NUM[i].life = 0;
787 enemyCount = 0; B.alive = false;
788 Object.assign(RUN, { t: 0, kills: 0, level: 1, xp: 0, need: xpNeed(1), pending: 0, bossKills: 0, bossIdx: 0, nextBoss: 90, wave: 0, spawnAcc: 0, lv: {}, evo: {}, shards: 0, score: 0, newBest: false, zapT: 0.5, shootT: 1, shootI: 0, singT: 9, banner: "", bannerT: 0, bannerSub: "", warned: false, combo: 0, comboT: 0, vac: 0, slowT: 0, gemT: 0, gemCombo: 0, tut: LS.get("orbitsurvivors.tut", "0") !== "1" });
789 if (ml("dash")) RUN.lv.dash = 1;
790 P.x = WW / 2; P.y = WH / 2; P.vx = P.vy = 0; P.inv = 1; P.dashT = 0; P.dashCd = 0; P.shCd = 0; P.mv = 0; P.hitFlash = 0; P.revived = false; P.fx = 0; P.fy = 1; P.flash = 0;
791 recalc(); P.hp = P.mhp;
792 phase = 0; timeScale = 1; paused = false; hintT = 0;
793 for (let i = 0; i < 16 * TL; i++) { otrail[i * 2] = P.x; otrail[i * 2 + 1] = P.y; }
794 cam.shake = 0; updateCamera(0, true);
795 inp.id = -1; inp.down = false; inp.dx = inp.dy = 0;
796 state = "play";
797}
798function stepGame(dt) {
799 RUN.t += dt; frameN++;
800 if (botOn) botThink(); else updateStick();
801 updatePlayer(dt);
802 if (state !== "play") return;
803 director(dt);
804 updateEnemies(dt);
805 if (B.alive) updateBoss(dt);
806 buildGrid();
807 updateOrbs(dt);
808 updateBullets(dt);
809 separate();
810 updateGems(dt);
811 updateFx(dt);
812 updateCamera(dt);
813 if (RUN.bannerT > 0) RUN.bannerT -= dt;
814 if (RUN.pending > 0 && state === "play") { state = "levelup"; rollCards(); sfx("levelup"); }
815}
816// title demo
817const demo = []; for (let i = 0; i < 7; i++) demo.push({ a: rnd() * TAU, d: 200 + rnd() * 250, type: (rnd() * 5) | 0, spd: 60 + rnd() * 40 });
818let overT = 0, ambT = 0;
819function update(dt) {
820 tG += dt;
821 if (state === "play") {
822 if (RUN.slowT > 0) { RUN.slowT -= dt; if (RUN.slowT <= 0) timeScale = 1; }
823 stepGame(dt * timeScale); hintT += dt;
824 const inten = Math.min(1, enemyCount / 160 + (B.alive ? 0.4 : 0));
825 try { SFXo.tick(true, inten); } catch (e) {}
826 } else if (state === "levelup") {
827 cardT += dt; updateFx(dt * 0.15);
828 if (botOn) { cardT = Math.max(cardT, 0.4); chooseCard((rnd() * 3) | 0); }
829 } else if (state === "dying") {
830 deathT += dt; updateFx(dt * 0.45); if (deathT > 1.5) { state = "over"; overT = 0; }
831 } else if (state === "over") {
832 overT += dt; updateFx(dt);
833 } else {
834 // title / meta
835 cam.x = WW / 2 - W / 2 + sin(tG * 0.13) * 180; cam.y = WH / 2 - H / 2 + cos(tG * 0.09) * 260; cam.shake = 0; cam.sx = cam.sy = 0;
836 ambT -= dt; if (ambT <= 0) { ambT = 0.06; spark(cam.x + rnd() * W, cam.y + H + 10, (rnd() - 0.5) * 20, -30 - rnd() * 60, 5, 1.5 + rnd() * 2, rnd() < 0.5 ? CY : rnd() < 0.5 ? MG : PU, 0); }
837 updateFx(dt);
838 phase += dt * 1.9;
839 for (let i = 0; i < demo.length; i++) {
840 const e = demo[i]; e.d -= e.spd * dt;
841 if (e.d < 86) { const x = 270 + cos(e.a) * e.d, y = 500 + sin(e.a) * e.d, col = TD[e.type][5]; burst(x + cam.x, y + cam.y, col, 12, 180, 2.4, 0.55); ring(x + cam.x, y + cam.y, 34, 0.3, col, 2.5, 0.8); e.d = 380 + rnd() * 160; e.a = rnd() * TAU; e.type = (rnd() * 5) | 0; }
842 }
843 }
844}
845
846// ---------- drawing helpers ----------
847const SHP = [
848 [[1, 0], [-0.8, 0.8], [-0.8, -0.8]],
849 [[1.3, 0], [-0.4, 0.95], [-0.75, 0], [-0.4, -0.95]],
850 [[1, 0], [0.5, 0.87], [-0.5, 0.87], [-1, 0], [-0.5, -0.87], [0.5, -0.87]],
851 [[0.85, 0.85], [-0.85, 0.85], [-0.85, -0.85], [0.85, -0.85]],
852 [[1, 0], [0.7, 0.7], [0, 1], [-0.7, 0.7], [-1, 0], [-0.7, -0.7], [0, -1], [0.7, -0.7]],
853 [[1, 0], [0, 0.8], [-1, 0], [0, -0.8]],
854];
855function addShape(type, x, y, r, ang) {
856 const s = SHP[type], ca = cos(ang), sa = sin(ang);
857 for (let k = 0; k < s.length; k++) {
858 const vx = s[k][0] * r, vy = s[k][1] * r, px = x + vx * ca - vy * sa, py = y + vx * sa + vy * ca;
859 if (k) ctx.lineTo(px, py); else ctx.moveTo(px, py);
860 }
861 ctx.closePath();
862}
863function poly(n, x, y, r, a) { for (let k = 0; k < n; k++) { const t = a + (k * TAU) / n; if (k) ctx.lineTo(x + cos(t) * r, y + sin(t) * r); else ctx.moveTo(x + cos(t) * r, y + sin(t) * r); } ctx.closePath(); }
864function diamond(x, y, s, col) { ctx.fillStyle = col; ctx.beginPath(); ctx.moveTo(x, y - s); ctx.lineTo(x + s * 0.75, y); ctx.lineTo(x, y + s); ctx.lineTo(x - s * 0.75, y); ctx.closePath(); ctx.fill(); }
865const worldT = () => ctx.setTransform(K, 0, 0, K, -(cam.x + cam.sx) * K, -(cam.y + cam.sy) * K);
866const screenT = () => ctx.setTransform(K, 0, 0, K, 0, 0);
867function drawBackground() {
868 screenT(); ctx.globalCompositeOperation = "source-over"; ctx.globalAlpha = 1; ctx.fillStyle = "#050817"; ctx.fillRect(0, 0, W, H);
869 ctx.globalCompositeOperation = "lighter";
870 for (let i = 0; i < NEB.length; i++) { const n = NEB[i]; glow(n[2], n[0] - cam.x * 0.55 - 100, n[1] - cam.y * 0.55 - 200, n[3], 0.11); }
871 const F = [0.1, 0.22, 0.45];
872 for (let L = 0; L < 3; L++) {
873 const ox_ = -(((cam.x + cam.sx) * F[L]) % 512), oy_ = -(((cam.y + cam.sy) * F[L]) % 512); ctx.globalAlpha = 1;
874 for (let x = ox_ - (ox_ > 0 ? 512 : 0); x < W; x += 512) for (let y = oy_ - (oy_ > 0 ? 512 : 0); y < H; y += 512) ctx.drawImage(STAR[L], x, y);
875 }
876 ctx.globalCompositeOperation = "source-over";
877 worldT();
878 // void outside arena
879 ctx.fillStyle = "rgba(1,2,8,0.82)"; ctx.globalAlpha = 1;
880 ctx.fillRect(-600, -600, WW + 1200, 600); ctx.fillRect(-600, WH, WW + 1200, 600); ctx.fillRect(-600, 0, 600, WH); ctx.fillRect(WW, 0, 600, WH);
881 // grid
882 const x0 = Math.floor(cam.x / 100) * 100, y0 = Math.floor(cam.y / 100) * 100;
883 ctx.lineWidth = 1; ctx.strokeStyle = "rgba(90,130,255,0.075)"; ctx.beginPath();
884 for (let x = Math.max(0, x0); x <= Math.min(WW, cam.x + W + 100); x += 100) { ctx.moveTo(x, Math.max(0, cam.y - 10)); ctx.lineTo(x, Math.min(WH, cam.y + H + 10)); }
885 for (let y = Math.max(0, y0); y <= Math.min(WH, cam.y + H + 100); y += 100) { ctx.moveTo(Math.max(0, cam.x - 10), y); ctx.lineTo(Math.min(WW, cam.x + W + 10), y); }
886 ctx.stroke();
887 // glowing boundary
888 ctx.globalCompositeOperation = "lighter";
889 const pulse = 0.75 + 0.25 * sin(tG * 2);
890 ctx.strokeStyle = "#7a4dff"; ctx.globalAlpha = 0.09 * pulse; ctx.lineWidth = 34; ctx.strokeRect(0, 0, WW, WH);
891 ctx.strokeStyle = "#5aa0ff"; ctx.globalAlpha = 0.2 * pulse; ctx.lineWidth = 14; ctx.strokeRect(0, 0, WW, WH);
892 ctx.strokeStyle = "#8ff6ff"; ctx.globalAlpha = 0.95; ctx.lineWidth = 3; ctx.strokeRect(0, 0, WW, WH);
893 ctx.globalAlpha = 1; ctx.globalCompositeOperation = "source-over";
894}
895const vis = new Int16Array(MAXE + 2); let visN = 0;
896function drawIcon(id, x, y, s, col) {
897 ctx.save(); ctx.translate(x, y); ctx.lineWidth = Math.max(2, s * 0.13); ctx.strokeStyle = col; ctx.fillStyle = col; ctx.lineCap = "round"; ctx.lineJoin = "round";
898 const a = tG * 1.5;
899 switch (id) {
900 case "orb": ctx.beginPath(); ctx.arc(0, 0, s * 0.72, 0, TAU); ctx.globalAlpha *= 0.35; ctx.stroke(); ctx.globalAlpha /= 0.35; ctx.beginPath(); ctx.arc(0, 0, s * 0.2, 0, TAU); ctx.fill(); for (let i = 0; i < 3; i++) { ctx.beginPath(); ctx.arc(cos(a + i * 2.094) * s * 0.72, sin(a + i * 2.094) * s * 0.72, s * 0.2, 0, TAU); ctx.fill(); } break;
901 case "wide": ctx.beginPath(); ctx.arc(0, 0, s * 0.3, 0, TAU); ctx.fill(); ctx.beginPath(); ctx.arc(0, 0, s * 0.62, 0.3, 2.6); ctx.stroke(); ctx.beginPath(); ctx.arc(0, 0, s * 0.62, 3.44, 5.74); ctx.stroke(); ctx.beginPath(); ctx.arc(0, 0, s * 0.92, 0.6, 2.3); ctx.stroke(); ctx.beginPath(); ctx.arc(0, 0, s * 0.92, 3.74, 5.44); ctx.stroke(); break;
902 case "spin": ctx.beginPath(); ctx.arc(0, 0, s * 0.62, a, a + 4.6); ctx.stroke(); { const e = a + 4.6; ctx.beginPath(); ctx.moveTo(cos(e) * s * 0.62 + cos(e + 1.9) * s * 0.4, sin(e) * s * 0.62 + sin(e + 1.9) * s * 0.4); ctx.lineTo(cos(e) * s * 0.62, sin(e) * s * 0.62); ctx.lineTo(cos(e - 1.2) * s * 0.9, sin(e - 1.2) * s * 0.9); ctx.stroke(); } break;
903 case "pow": ctx.beginPath(); for (let i = 0; i < 16; i++) { const r = i & 1 ? s * 0.38 : s * 0.9; const t = (i * TAU) / 16 - PI / 2; if (i) ctx.lineTo(cos(t) * r, sin(t) * r); else ctx.moveTo(cos(t) * r, sin(t) * r); } ctx.closePath(); ctx.stroke(); ctx.beginPath(); ctx.arc(0, 0, s * 0.2, 0, TAU); ctx.fill(); break;
904 case "shoot": ctx.beginPath(); ctx.arc(-s * 0.4, 0, s * 0.26, 0, TAU); ctx.fill(); for (let i = -1; i <= 1; i++) { ctx.beginPath(); ctx.moveTo(-s * 0.05, i * s * 0.32); ctx.lineTo(s * 0.85, i * s * 0.5); ctx.stroke(); } break;
905 case "zap": ctx.beginPath(); ctx.moveTo(s * 0.2, -s * 0.95); ctx.lineTo(-s * 0.5, s * 0.1); ctx.lineTo(-s * 0.02, s * 0.1); ctx.lineTo(-s * 0.25, s * 0.95); ctx.lineTo(s * 0.55, -s * 0.2); ctx.lineTo(s * 0.05, -s * 0.2); ctx.closePath(); ctx.stroke(); break;
906 case "shield": ctx.beginPath(); ctx.moveTo(0, -s * 0.95); ctx.lineTo(s * 0.8, -s * 0.5); ctx.lineTo(s * 0.7, s * 0.3); ctx.lineTo(0, s * 0.95); ctx.lineTo(-s * 0.7, s * 0.3); ctx.lineTo(-s * 0.8, -s * 0.5); ctx.closePath(); ctx.stroke(); ctx.beginPath(); ctx.moveTo(-s * 0.35, s * 0.05); ctx.lineTo(s * 0.35, -s * 0.3); ctx.stroke(); break;
907 case "mag": ctx.beginPath(); ctx.arc(0, -s * 0.1, s * 0.6, PI, 0); ctx.lineTo(s * 0.6, s * 0.7); ctx.moveTo(-s * 0.6, -s * 0.1); ctx.lineTo(-s * 0.6, s * 0.7); ctx.stroke(); ctx.beginPath(); ctx.moveTo(-s * 0.85, s * 0.72); ctx.lineTo(-s * 0.35, s * 0.72); ctx.moveTo(s * 0.35, s * 0.72); ctx.lineTo(s * 0.85, s * 0.72); ctx.stroke(); break;
908 case "dash": for (let i = 0; i < 2; i++) { ctx.beginPath(); ctx.moveTo(-s * 0.75 + i * s * 0.6, -s * 0.6); ctx.lineTo(-s * 0.15 + i * s * 0.6, 0); ctx.lineTo(-s * 0.75 + i * s * 0.6, s * 0.6); ctx.stroke(); } break;
909 case "vit": ctx.beginPath(); ctx.moveTo(0, s * 0.85); ctx.bezierCurveTo(-s * 1.2, -s * 0.1, -s * 0.5, -s * 0.95, 0, -s * 0.35); ctx.bezierCurveTo(s * 0.5, -s * 0.95, s * 1.2, -s * 0.1, 0, s * 0.85); ctx.closePath(); ctx.stroke(); break;
910 case "regen": ctx.beginPath(); ctx.moveTo(-s * 0.7, 0); ctx.lineTo(s * 0.7, 0); ctx.moveTo(0, -s * 0.7); ctx.lineTo(0, s * 0.7); ctx.stroke(); ctx.beginPath(); ctx.arc(0, 0, s * 0.95, 0, TAU); ctx.globalAlpha *= 0.4; ctx.stroke(); ctx.globalAlpha /= 0.4; break;
911 case "heal": ctx.beginPath(); ctx.moveTo(-s * 0.7, 0); ctx.lineTo(s * 0.7, 0); ctx.moveTo(0, -s * 0.7); ctx.lineTo(0, s * 0.7); ctx.stroke(); break;
912 case "web": for (let i = 0; i < 5; i++) { const t = (i * TAU) / 5 - PI / 2; ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(cos(t) * s * 0.9, sin(t) * s * 0.9); ctx.stroke(); ctx.beginPath(); ctx.arc(cos(t) * s * 0.9, sin(t) * s * 0.9, s * 0.13, 0, TAU); ctx.fill(); } ctx.beginPath(); poly(5, 0, 0, s * 0.9, -PI / 2); ctx.stroke(); break;
913 case "nova": for (let i = 0; i < 3; i++) { const t = -0.5 + i * 0.5; ctx.beginPath(); ctx.moveTo(-s * 0.7, 0); ctx.lineTo(cos(t) * s * 0.95, sin(t) * s * 0.95); ctx.stroke(); ctx.beginPath(); ctx.arc(cos(t) * s * 0.95, sin(t) * s * 0.95, s * 0.12, 0, TAU); ctx.fill(); } ctx.beginPath(); ctx.arc(-s * 0.7, 0, s * 0.22, 0, TAU); ctx.fill(); break;
914 case "aegis": ctx.beginPath(); ctx.moveTo(0, -s * 0.95); ctx.lineTo(s * 0.8, -s * 0.5); ctx.lineTo(s * 0.7, s * 0.3); ctx.lineTo(0, s * 0.95); ctx.lineTo(-s * 0.7, s * 0.3); ctx.lineTo(-s * 0.8, -s * 0.5); ctx.closePath(); ctx.stroke(); ctx.beginPath(); for (let i = 0; i < 10; i++) { const r = i & 1 ? s * 0.16 : s * 0.42, t = (i * TAU) / 10 - PI / 2; if (i) ctx.lineTo(cos(t) * r, sin(t) * r); else ctx.moveTo(cos(t) * r, sin(t) * r); } ctx.closePath(); ctx.fill(); break;
915 case "sing": ctx.beginPath(); ctx.arc(0, 0, s * 0.3, 0, TAU); ctx.fillStyle = "#000"; ctx.fill(); ctx.stroke(); for (let i = 0; i < 3; i++) { ctx.beginPath(); ctx.arc(0, 0, s * (0.5 + i * 0.22), a + i * 2, a + i * 2 + 2.4); ctx.stroke(); } break;
916 case "blink": ctx.beginPath(); ctx.moveTo(-s * 0.9, s * 0.6); ctx.lineTo(s * 0.9, -s * 0.6); ctx.stroke(); ctx.beginPath(); ctx.moveTo(-s * 0.6, -s * 0.7); ctx.lineTo(s * 0.1, 0); ctx.lineTo(-s * 0.6, s * 0.7); ctx.stroke(); ctx.beginPath(); ctx.arc(s * 0.55, 0, s * 0.18, 0, TAU); ctx.fill(); break;
917 case "vamp": ctx.beginPath(); ctx.moveTo(0, -s * 0.95); ctx.bezierCurveTo(s * 0.9, s * 0.1, s * 0.75, s * 0.9, 0, s * 0.9); ctx.bezierCurveTo(-s * 0.75, s * 0.9, -s * 0.9, s * 0.1, 0, -s * 0.95); ctx.closePath(); ctx.stroke(); ctx.beginPath(); ctx.arc(0, s * 0.3, s * 0.2, 0, TAU); ctx.fill(); break;
918 default: ctx.beginPath(); ctx.arc(0, 0, s * 0.6, 0, TAU); ctx.stroke();
919 }
920 ctx.restore();
921}
922
923// ---------- world rendering ----------
924function drawWorldEntities() {
925 worldT();
926 const vx0 = cam.x - 50, vx1 = cam.x + W + 50, vy0 = cam.y - 50, vy1 = cam.y + H + 50;
927 // gems
928 ctx.globalCompositeOperation = "lighter";
929 for (let i = 0; i < MAXG; i++) {
930 const g = GM[i]; if (!g.alive || g.x < vx0 || g.x > vx1 || g.y < vy0 || g.y > vy1) continue;
931 const ci = g.kind === 3 ? GR : g.kind === 4 ? PU : g.v >= 10 ? GO : g.v >= 4 ? YE : g.v >= 2 ? GR : CY;
932 const pl = 0.75 + 0.25 * sin(tG * 6 + i);
933 glow(ci, g.x, g.y, g.kind >= 3 ? 24 : 12 + Math.min(8, g.v), 0.85 * pl);
934 ctx.globalAlpha = 1; const s = g.kind >= 3 ? 7 : 4.5 + Math.min(3, g.v * 0.3);
935 diamond(g.x, g.y, s, "#ffffff");
936 if (g.kind === 3) { ctx.fillStyle = "#5dff8a"; ctx.fillRect(g.x - 8, g.y - 2, 16, 4); ctx.fillRect(g.x - 2, g.y - 8, 4, 16); }
937 }
938 ctx.globalAlpha = 1; ctx.globalCompositeOperation = "source-over";
939 // enemies (batched by type)
940 visN = 0;
941 for (let i = 0; i < MAXE; i++) { const e = E[i]; if (e.alive && e.x > vx0 && e.x < vx1 && e.y > vy0 && e.y < vy1) vis[visN++] = i; }
942 ctx.globalCompositeOperation = "lighter";
943 for (let j = 0; j < visN; j++) {
944 const e = E[vis[j]], col = TD[e.type][5];
945 glow(col, e.x, e.y, e.r * 2.7, 0.55);
946 if (e.type === DA && e.st === 1) { ctx.globalAlpha = 0.3 + 0.4 * sin(e.t * 30) * sin(e.t * 30); ctx.strokeStyle = "#ffe14a"; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(e.x, e.y); ctx.lineTo(e.x + e.ax * 300, e.y + e.ay * 300); ctx.stroke(); }
947 else if (e.type === SH && e.st === 1) { ctx.globalAlpha = 0.9; ctx.strokeStyle = "#4a8bff"; ctx.lineWidth = 2.5; ctx.beginPath(); ctx.arc(e.x, e.y, e.r + 4 + e.stT * 30, 0, TAU); ctx.stroke(); }
948 }
949 ctx.globalAlpha = 1; ctx.globalCompositeOperation = "source-over";
950 ctx.lineJoin = "round"; ctx.lineWidth = 2.6; ctx.globalAlpha = 1;
951 for (let ty = 0; ty < 6; ty++) {
952 ctx.beginPath(); let any = false;
953 for (let j = 0; j < visN; j++) { const e = E[vis[j]]; if (e.type === ty && e.flash <= 0) { addShape(ty, e.x, e.y, e.r, e.ang); any = true; } }
954 if (any) { ctx.fillStyle = "#0a0f2c"; ctx.fill(); ctx.strokeStyle = COLS[TD[ty][5]]; ctx.stroke(); }
955 }
956 ctx.beginPath(); let anyF = false;
957 for (let j = 0; j < visN; j++) { const e = E[vis[j]]; if (e.flash > 0) { addShape(e.type, e.x, e.y, e.r, e.ang); anyF = true; } }
958 if (anyF) { ctx.fillStyle = "#ffffff"; ctx.fill(); }
959 // bruiser shields + hp, dasher/shooter telegraphs
960 ctx.lineCap = "round";
961 for (let j = 0; j < visN; j++) {
962 const e = E[vis[j]];
963 if (e.type === BR) {
964 ctx.strokeStyle = "#7fe8ff"; ctx.lineWidth = 4; ctx.beginPath(); ctx.arc(e.x, e.y, e.r + 6, e.ang - 0.95, e.ang + 0.95); ctx.stroke();
965 if (e.hp < e.mhp) { ctx.fillStyle = "rgba(0,0,0,0.6)"; ctx.fillRect(e.x - 16, e.y + e.r + 8, 32, 4); ctx.fillStyle = "#ff8a3d"; ctx.fillRect(e.x - 16, e.y + e.r + 8, 32 * Math.max(0, e.hp / e.mhp), 4); }
966 }
967 }
968 ctx.globalAlpha = 1;
969 // boss
970 if (B.alive) drawBoss();
971 // enemy bullets
972 ctx.globalCompositeOperation = "lighter";
973 for (let i = 0; i < MAXB; i++) {
974 const b = EB[i]; if (!b.alive || b.x < vx0 || b.x > vx1 || b.y < vy0 || b.y > vy1) continue;
975 glow(b.col, b.x, b.y, b.r * 3.6, 0.95); ctx.globalAlpha = 1; ctx.fillStyle = "#fff"; ctx.beginPath(); ctx.arc(b.x, b.y, b.r * 0.6, 0, TAU); ctx.fill();
976 }
977 for (let i = 0; i < MAXPB; i++) {
978 const b = PB[i]; if (!b.alive) continue;
979 glow(b.col, b.x, b.y, b.big ? 22 : 15, 0.95); ctx.globalAlpha = 1; ctx.fillStyle = "#fff"; ctx.beginPath(); ctx.arc(b.x, b.y, b.big ? 4 : 3, 0, TAU); ctx.fill();
980 }
981 drawPlayer();
982 // particles
983 ctx.globalCompositeOperation = "lighter";
984 for (let k = 0; k < 10; k++) {
985 ctx.fillStyle = COLS[k];
986 for (let i = 0; i < MAXP; i++) {
987 const p = PT[i]; if (p.life <= 0 || p.c !== k) continue;
988 const f = p.life / p.max, s = p.size * (0.35 + 0.65 * f);
989 ctx.globalAlpha = f > 0.5 ? 1 : f * 2; ctx.fillRect(p.x - s / 2, p.y - s / 2, s, s);
990 }
991 }
992 // rings
993 for (let i = 0; i < MAXR; i++) {
994 const r = RG[i]; if (r.life <= 0) continue;
995 const f = r.life / r.max; ctx.globalAlpha = f * r.a; ctx.strokeStyle = COLS[r.c]; ctx.lineWidth = r.w * (0.4 + f); ctx.beginPath(); ctx.arc(r.x, r.y, r.r, 0, TAU); ctx.stroke();
996 }
997 ctx.globalAlpha = 1; ctx.globalCompositeOperation = "source-over";
998 // damage numbers
999 ctx.textAlign = "center"; ctx.textBaseline = "middle";
1000 for (let i = 0; i < MAXN; i++) {
1001 const n = NUM[i]; if (n.life <= 0) continue;
1002 ctx.font = fnt(n.size, 800); ctx.globalAlpha = Math.min(1, n.life * 3);
1003 ctx.fillStyle = "rgba(0,0,0,0.55)"; ctx.fillText(n.txt, n.x + 1, n.y + 1.5); ctx.fillStyle = n.col; ctx.fillText(n.txt, n.x, n.y);
1004 }
1005 ctx.globalAlpha = 1;
1006}
1007function drawBoss() {
1008 const col = B.col, r = B.r, x = B.x, y = B.y;
1009 ctx.globalCompositeOperation = "lighter";
1010 glow(col, x, y, r * 3.4, 0.55 + 0.15 * sin(tG * 3));
1011 if (B.type === 1 && B.st === 1) { ctx.globalAlpha = 0.35 + 0.35 * sin(tG * 30) * sin(tG * 30); ctx.strokeStyle = "#ffe14a"; ctx.lineWidth = 8; ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + B.ax * 700, y + B.ay * 700); ctx.stroke(); ctx.globalAlpha = 1; }
1012 ctx.globalCompositeOperation = "source-over";
1013 ctx.lineJoin = "round"; ctx.lineWidth = 5;
1014 ctx.beginPath(); poly(8, x, y, r, B.ang); ctx.fillStyle = B.flash > 0 ? "#ffffff" : "#0a0f2c"; ctx.fill(); ctx.strokeStyle = COLS[col]; ctx.stroke();
1015 ctx.lineWidth = 3; ctx.beginPath(); poly(B.type === 1 ? 3 : B.type === 2 ? 6 : 5, x, y, r * 0.66, -B.ang * 1.6); ctx.strokeStyle = COLS[col]; ctx.globalAlpha = 0.85; ctx.stroke(); ctx.globalAlpha = 1;
1016 ctx.globalCompositeOperation = "lighter";
1017 for (let i = 0; i < 3; i++) { const a = B.ang * 2 + (i * TAU) / 3; glow(col, x + cos(a) * r * 1.15, y + sin(a) * r * 1.15, 20, 0.9); ctx.globalAlpha = 1; ctx.fillStyle = "#fff"; ctx.beginPath(); ctx.arc(x + cos(a) * r * 1.15, y + sin(a) * r * 1.15, 4, 0, TAU); ctx.fill(); }
1018 glow(WT, x, y, r * 0.75, 0.55 + 0.35 * sin(tG * 5)); glow(col, x, y, r * 1.1, 0.9);
1019 if (B.intro > 0) { ctx.globalAlpha = 0.5 + 0.3 * sin(tG * 20); ctx.strokeStyle = "#fff"; ctx.lineWidth = 3; ctx.beginPath(); ctx.arc(x, y, r + 14, 0, TAU); ctx.stroke(); }
1020 ctx.globalAlpha = 1; ctx.globalCompositeOperation = "source-over";
1021}
1022function drawPlayer() {
1023 ctx.globalCompositeOperation = "lighter";
1024 const n = G.n, R_ = G.curR;
1025 // orbit guide
1026 ctx.globalAlpha = 0.09 + 0.07 * (G.mult - 1); ctx.strokeStyle = "#8ff6ff"; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.arc(P.x, P.y, R_, 0, TAU); ctx.stroke();
1027 // reflect shield
1028 if (lvOf("shield") && P.shCd <= 0) { ctx.globalAlpha = 0.35 + 0.15 * sin(tG * 5); ctx.strokeStyle = "#3df2ff"; ctx.lineWidth = 3; ctx.beginPath(); ctx.arc(P.x, P.y, R_ + 20, 0, TAU); ctx.stroke(); ctx.globalAlpha = 0.06; ctx.fillStyle = "#3df2ff"; ctx.fill(); }
1029 // lightning
1030 const zl = lvOf("zap");
1031 if (zl && n >= 2) {
1032 const segs = n === 2 ? 1 : n;
1033 for (let pass = 0; pass < 2; pass++) {
1034 ctx.strokeStyle = pass ? "#ffffff" : "#b56bff"; ctx.lineWidth = pass ? 1.6 : 5; ctx.globalAlpha = pass ? 0.95 : 0.5;
1035 ctx.beginPath();
1036 for (let i = 0; i < segs; i++) { const j = (i + 1) % n; jag(ox[i], oy[i], ox[j], oy[j]); }
1037 if (RUN.evo.web) for (let i = 0; i < n; i++) jag(P.x, P.y, ox[i], oy[i]);
1038 ctx.stroke();
1039 }
1040 }
1041 // trails + orbs
1042 ctx.lineCap = "round";
1043 const oc = G.mult > 1.55 ? GO : CY, shooter = lvOf("shoot") > 0;
1044 for (let i = 0; i < n; i++) {
1045 for (let k = 1; k < TL; k++) {
1046 const a = ((thead - k + 1 + TL * 2) % TL), b = ((thead - k + TL * 2) % TL), f = 1 - k / TL;
1047 ctx.globalAlpha = f * 0.6; ctx.strokeStyle = COLS[oc]; ctx.lineWidth = ORB_R * 1.5 * f;
1048 ctx.beginPath(); ctx.moveTo(otrail[(i * TL + a) * 2], otrail[(i * TL + a) * 2 + 1]); ctx.lineTo(otrail[(i * TL + b) * 2], otrail[(i * TL + b) * 2 + 1]); ctx.stroke();
1049 }
1050 }
1051 for (let i = 0; i < n; i++) {
1052 glow(oc, ox[i], oy[i], 32, 1); glow(oc, ox[i], oy[i], 18, 1); ctx.globalAlpha = 1; ctx.fillStyle = "#ffffff"; ctx.beginPath(); ctx.arc(ox[i], oy[i], ORB_R * 0.62, 0, TAU); ctx.fill();
1053 ctx.strokeStyle = shooter ? "#5dff8a" : COLS[oc]; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(ox[i], oy[i], ORB_R, 0, TAU); ctx.stroke();
1054 }
1055 // core
1056 const blink = P.inv > 0 && P.dashT <= 0 && ((tG * 20) | 0) % 2 === 0;
1057 ctx.globalAlpha = blink ? 0.35 : 1;
1058 glow(CY, P.x, P.y, 38 + 4 * sin(tG * 6), 0.9);
1059 if (P.hitFlash > 0) glow(RD, P.x, P.y, 48, P.hitFlash);
1060 ctx.globalAlpha = blink ? 0.5 : 1; ctx.fillStyle = "#ffffff"; ctx.beginPath(); ctx.arc(P.x, P.y, 8, 0, TAU); ctx.fill();
1061 ctx.strokeStyle = "#8ff6ff"; ctx.lineWidth = 2.5; ctx.beginPath(); ctx.arc(P.x, P.y, P.r, 0, TAU); ctx.stroke();
1062 ctx.globalAlpha = 1; ctx.globalCompositeOperation = "source-over";
1063}
1064function jag(x1, y1, x2, y2) {
1065 const dx = x2 - x1, dy = y2 - y1, l = hypot(dx, dy) || 1, px = -dy / l, py = dx / l;
1066 ctx.moveTo(x1, y1);
1067 for (let k = 1; k < 6; k++) { const t = k / 6, o = (rnd() - 0.5) * 16; ctx.lineTo(x1 + dx * t + px * o, y1 + dy * t + py * o); }
1068 ctx.lineTo(x2, y2);
1069}
1070
1071// ---------- HUD ----------
1072function fmtTime(t) { const s = Math.floor(t); return ((s / 60) | 0) + ":" + (s % 60 < 10 ? "0" : "") + (s % 60); }
1073function addBtn(x, y, w, h, fn) { btns.push({ x, y, w, h, fn }); }
1074function muteBtn(x, y) {
1075 screenT(); ctx.globalAlpha = 0.8; ctx.strokeStyle = "#9fb4ff"; ctx.fillStyle = "rgba(10,16,44,0.7)"; ctx.lineWidth = 2;
1076 ctx.beginPath(); ctx.arc(x, y, 17, 0, TAU); ctx.fill(); ctx.stroke();
1077 ctx.beginPath(); ctx.moveTo(x - 8, y - 3); ctx.lineTo(x - 4, y - 3); ctx.lineTo(x + 1, y - 8); ctx.lineTo(x + 1, y + 8); ctx.lineTo(x - 4, y + 3); ctx.lineTo(x - 8, y + 3); ctx.closePath(); ctx.fillStyle = "#cfe0ff"; ctx.fill();
1078 ctx.beginPath();
1079 if (muted) { ctx.moveTo(x + 5, y - 5); ctx.lineTo(x + 12, y + 5); ctx.moveTo(x + 12, y - 5); ctx.lineTo(x + 5, y + 5); ctx.strokeStyle = "#ff6070"; }
1080 else { ctx.arc(x + 1, y, 7, -0.9, 0.9); ctx.moveTo(x + 1 + cos(0.9) * 11, y + sin(0.9) * 11); ctx.arc(x + 1, y, 11, 0.9, -0.9, true); ctx.strokeStyle = "#cfe0ff"; }
1081 ctx.lineWidth = 2; ctx.lineCap = "round"; ctx.stroke(); ctx.globalAlpha = 1;
1082 addBtn(x - 22, y - 22, 44, 44, () => setMute(!muted));
1083}
1084function drawHUD() {
1085 screenT(); ctx.globalCompositeOperation = "source-over"; ctx.globalAlpha = 1;
1086 // hit vignette
1087 if (P.hitFlash > 0) { ctx.globalAlpha = P.hitFlash * 0.12; ctx.fillStyle = "#ff2040"; ctx.fillRect(0, 0, W, H); ctx.globalAlpha = 1; }
1088 // XP bar
1089 ctx.fillStyle = "rgba(255,255,255,0.09)"; ctx.fillRect(0, 0, W, 9);
1090 const xf = clamp(RUN.xp / RUN.need, 0, 1);
1091 ctx.fillStyle = "#3df2ff"; ctx.fillRect(0, 0, W * xf, 9);
1092 ctx.globalCompositeOperation = "lighter"; glow(CY, W * xf, 4, 20, 0.7); ctx.globalCompositeOperation = "source-over"; ctx.globalAlpha = 1;
1093 // level badge
1094 rrect(12, 16, 62, 26, 13); ctx.fillStyle = "rgba(12,20,54,0.85)"; ctx.fill(); ctx.strokeStyle = "#3df2ff"; ctx.lineWidth = 2; ctx.stroke();
1095 txt("LV " + RUN.level, 43, 30, 15, "#c9f8ff", "center", 800);
1096 // timer + score
1097 txt(fmtTime(RUN.t), 270, 30, 28, "#ffffff", "center", 800);
1098 txt("WAVE " + Math.max(1, RUN.wave), 270, 54, 12, "#8b9fd8", "center", 700);
1099 txt("KILLS", 528, 20, 10, "#8b9fd8", "right", 700); txt(String(RUN.kills), 528, 38, 20, "#ffffff", "right", 800);
1100 txt("SCORE " + calcScore(), 528, 58, 12, "#8b9fd8", "right", 700);
1101 // HP bar
1102 const hf = clamp(P.hp / P.mhp, 0, 1), low = hf < 0.3;
1103 rrect(12, 52, 220, 18, 9); ctx.fillStyle = "rgba(10,14,36,0.85)"; ctx.fill();
1104 if (hf > 0) { rrect(12, 52, Math.max(18, 220 * hf), 18, 9); ctx.fillStyle = low ? (((tG * 6) | 0) & 1 ? "#ff3050" : "#ff7080") : "#4dff8f"; ctx.fill(); }
1105 rrect(12, 52, 220, 18, 9); ctx.strokeStyle = "rgba(255,255,255,0.35)"; ctx.lineWidth = 1.5; ctx.stroke();
1106 ctx.font = fnt(12, 800); ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.lineWidth = 3; ctx.strokeStyle = "rgba(0,0,0,0.75)"; ctx.lineJoin = "round"; const hpt = Math.ceil(P.hp) + " / " + Math.ceil(P.mhp); ctx.strokeText(hpt, 122, 61.5); ctx.fillStyle = "#fff"; ctx.fillText(hpt, 122, 61.5);
1107 // orbit bonus
1108 const mcol = G.mult > 1.55 ? "#ffd35a" : "#8ff6ff";
1109 txt("ORBIT x" + G.mult.toFixed(2), 14, 86, 12, mcol, "left", 800);
1110 if (lvOf("shield")) { txt(P.shCd <= 0 ? "SHIELD READY" : "SHIELD " + Math.ceil(P.shCd) + "s", 130, 86, 11, P.shCd <= 0 ? "#3df2ff" : "#5a6a9a", "left", 800); }
1111 // buttons
1112 muteBtn(500, 100);
1113 screenT(); ctx.globalAlpha = 0.8; ctx.fillStyle = "rgba(10,16,44,0.7)"; ctx.strokeStyle = "#9fb4ff"; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(456, 100, 17, 0, TAU); ctx.fill(); ctx.stroke();
1114 ctx.fillStyle = "#cfe0ff"; ctx.fillRect(450, 92, 4.5, 16); ctx.fillRect(459, 92, 4.5, 16); ctx.globalAlpha = 1;
1115 addBtn(434, 78, 44, 44, () => { if (state === "play") paused = true; });
1116 // boss bar
1117 if (B.alive) {
1118 const bx = 70, bw = 400, by = 122, f = clamp(B.hp / B.mhp, 0, 1);
1119 txt(B.name, 270, by - 8, 14, COLS[B.col], "center", 800);
1120 rrect(bx, by, bw, 14, 7); ctx.fillStyle = "rgba(10,14,36,0.85)"; ctx.fill();
1121 if (f > 0) { rrect(bx, by, Math.max(14, bw * f), 14, 7); ctx.fillStyle = COLS[B.col]; ctx.fill(); }
1122 rrect(bx, by, bw, 14, 7); ctx.strokeStyle = "rgba(255,255,255,0.45)"; ctx.lineWidth = 1.5; ctx.stroke();
1123 if (B.intro > 0) txt("INCOMING", 270, by + 7.5, 10, "#fff", "center", 800);
1124 }
1125 // dash button
1126 if (lvOf("dash")) {
1127 const cx = 476, cy = 868, r = 36, rdy = P.dashCd <= 0;
1128 ctx.globalAlpha = 0.9; ctx.fillStyle = "rgba(10,16,44,0.78)"; ctx.beginPath(); ctx.arc(cx, cy, r, 0, TAU); ctx.fill();
1129 ctx.strokeStyle = rdy ? "#ff3d9a" : "#3a4470"; ctx.lineWidth = 3.5; ctx.beginPath(); ctx.arc(cx, cy, r, 0, TAU); ctx.stroke();
1130 if (!rdy) { ctx.strokeStyle = "#ff3d9a"; ctx.beginPath(); ctx.arc(cx, cy, r, -PI / 2, -PI / 2 + TAU * (1 - P.dashCd / G.dashCdMax)); ctx.stroke(); }
1131 ctx.globalAlpha = rdy ? 1 : 0.5; drawIcon("dash", cx, cy - 3, 15, rdy ? "#ffb0dc" : "#7a86b8"); txt("DASH", cx, cy + 22, 10, rdy ? "#ff8ac4" : "#6a76a8", "center", 800);
1132 ctx.globalAlpha = 1; addBtn(cx - 44, cy - 44, 88, 88, requestDash);
1133 }
1134 // joystick
1135 if (inp.down && state === "play" && !botOn) {
1136 ctx.globalAlpha = 0.18; ctx.strokeStyle = "#8ff6ff"; ctx.lineWidth = 3; ctx.beginPath(); ctx.arc(inp.ax, inp.ay, 64, 0, TAU); ctx.stroke();
1137 ctx.globalAlpha = 0.35; ctx.fillStyle = "#8ff6ff"; const l = hypot(inp.px - inp.ax, inp.py - inp.ay), m = Math.min(64, l), kx = l > 0 ? inp.ax + ((inp.px - inp.ax) / l) * m : inp.ax, ky = l > 0 ? inp.ay + ((inp.py - inp.ay) / l) * m : inp.ay;
1138 ctx.beginPath(); ctx.arc(kx, ky, 24, 0, TAU); ctx.fill(); ctx.globalAlpha = 1;
1139 }
1140 // banner
1141 if (RUN.bannerT > 0) {
1142 const a = Math.min(1, RUN.bannerT * 2, (2.6 - RUN.bannerT + 0.05) * 3), big = RUN.banner === "WARNING";
1143 ctx.save(); ctx.shadowColor = big ? "#ff4055" : "#3df2ff"; ctx.shadowBlur = 24;
1144 txt(RUN.banner, 270, 250, big ? 52 : 44, big ? "#ff6070" : "#ffffff", "center", 900, clamp(a, 0, 1)); ctx.restore();
1145 if (RUN.bannerSub) txt(RUN.bannerSub, 270, 292, 18, "#b8c8ff", "center", 700, clamp(a, 0, 1));
1146 }
1147 // tutorial hints
1148 if (RUN.tut && RUN.t < 22 && state === "play") {
1149 const t = RUN.t; let l1 = "", l2 = "";
1150 if (t < 6.5) { l1 = "DRAG ANYWHERE TO MOVE"; l2 = "Your orbs attack automatically"; }
1151 else if (t < 13) { l1 = "COLLECT GEMS TO LEVEL UP"; l2 = "Pick an upgrade every level"; }
1152 else { l1 = "TIGHT ORBIT = MORE DAMAGE"; l2 = "Stand still to tighten it, but it is riskier"; }
1153 const seg = t < 6.5 ? t : t < 13 ? t - 6.5 : t - 13, a = clamp(Math.min(seg * 2, (6.5 - seg) * 2), 0, 1) * 0.95;
1154 rrect(50, 760, 440, 74, 18); ctx.globalAlpha = a * 0.75; ctx.fillStyle = "#070c26"; ctx.fill(); ctx.strokeStyle = "#3df2ff"; ctx.lineWidth = 2; ctx.globalAlpha = a * 0.7; ctx.stroke();
1155 txt(l1, 270, 786, 20, "#ffffff", "center", 800, a); txt(l2, 270, 815, 14, "#a9c2ff", "center", 600, a);
1156 }
1157 ctx.globalAlpha = 1;
1158}
1159
1160// ---------- menus ----------
1161let cardLines = [[], [], []];
1162function wrap(s, maxW, size) {
1163 ctx.font = fnt(size, 500); const words = s.split(" "), out = []; let line = "";
1164 for (const w of words) { const t = line ? line + " " + w : w; if (ctx.measureText(t).width > maxW && line) { out.push(line); line = w; } else line = t; }
1165 if (line) out.push(line); return out;
1166}
1167function prepCards() { for (let i = 0; i < 3; i++) { const u = cards[i]; if (!u) continue; cardLines[i] = wrap(typeof u.desc === "function" ? u.desc(lvOf(u.id)) : u.desc, 296, 15); } }
1168function bigButton(x, y, w, h, label, col, fn, pulse) {
1169 screenT(); const s = pulse ? 1 + 0.025 * sin(tG * 4) : 1, bw = w * s, bh = h * s;
1170 ctx.globalCompositeOperation = "lighter"; glow(col, x, y, bw * 0.62, pulse ? 0.28 : 0.15); ctx.globalCompositeOperation = "source-over";
1171 rrect(x - bw / 2, y - bh / 2, bw, bh, bh / 2); const gr = ctx.createLinearGradient(0, y - bh / 2, 0, y + bh / 2); gr.addColorStop(0, hexA(COLS[col], 0.34)); gr.addColorStop(1, hexA(COLS[col], 0.1));
1172 ctx.globalAlpha = 1; ctx.fillStyle = gr; ctx.fill(); ctx.strokeStyle = COLS[col]; ctx.lineWidth = 3; ctx.stroke();
1173 txt(label, x, y + 1, Math.round(bh * 0.4), "#ffffff", "center", 800);
1174 addBtn(x - w / 2, y - h / 2, w, h, fn);
1175}
1176function drawTitle() {
1177 worldT(); ctx.globalCompositeOperation = "lighter";
1178 for (let k = 0; k < 10; k++) { ctx.fillStyle = COLS[k]; for (let i = 0; i < MAXP; i++) { const p = PT[i]; if (p.life <= 0 || p.c !== k) continue; const f = p.life / p.max; ctx.globalAlpha = Math.min(1, f * 2) * 0.8; ctx.fillRect(p.x - p.size / 2, p.y - p.size / 2, p.size, p.size); } }
1179 for (let i = 0; i < MAXR; i++) { const r = RG[i]; if (r.life <= 0) continue; const f = r.life / r.max; ctx.globalAlpha = f * r.a; ctx.strokeStyle = COLS[r.c]; ctx.lineWidth = r.w * (0.4 + f); ctx.beginPath(); ctx.arc(r.x, r.y, r.r, 0, TAU); ctx.stroke(); }
1180 ctx.globalAlpha = 1; ctx.globalCompositeOperation = "source-over"; screenT();
1181 // demo: core + orbs + incoming enemies
1182 const cx = 270, cy = 500;
1183 ctx.lineJoin = "round"; ctx.lineWidth = 2.6;
1184 for (let i = 0; i < demo.length; i++) { const e = demo[i], x = cx + cos(e.a) * e.d, y = cy + sin(e.a) * e.d; ctx.beginPath(); addShape(e.type, x, y, 11, e.a + PI); ctx.fillStyle = "#0a0f2c"; ctx.fill(); ctx.strokeStyle = COLS[TD[e.type][5]]; ctx.stroke(); ctx.globalCompositeOperation = "lighter"; glow(TD[e.type][5], x, y, 30, 0.55); ctx.globalCompositeOperation = "source-over"; }
1185 ctx.globalCompositeOperation = "lighter";
1186 ctx.globalAlpha = 0.14; ctx.strokeStyle = "#8ff6ff"; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.arc(cx, cy, 62, 0, TAU); ctx.stroke();
1187 for (let i = 0; i < 3; i++) {
1188 const a = phase + (i * TAU) / 3;
1189 for (let k = 1; k < 10; k++) { const b = a - k * 0.075; ctx.globalAlpha = (1 - k / 10) * 0.6; ctx.strokeStyle = "#3df2ff"; ctx.lineWidth = 12 * (1 - k / 10); ctx.lineCap = "round"; ctx.beginPath(); ctx.moveTo(cx + cos(b + 0.075) * 62, cy + sin(b + 0.075) * 62); ctx.lineTo(cx + cos(b) * 62, cy + sin(b) * 62); ctx.stroke(); }
1190 glow(CY, cx + cos(a) * 62, cy + sin(a) * 62, 25, 0.95); ctx.globalAlpha = 1; ctx.fillStyle = "#fff"; ctx.beginPath(); ctx.arc(cx + cos(a) * 62, cy + sin(a) * 62, 5.5, 0, TAU); ctx.fill();
1191 }
1192 glow(CY, cx, cy, 40, 0.9); ctx.globalAlpha = 1; ctx.fillStyle = "#fff"; ctx.beginPath(); ctx.arc(cx, cy, 8, 0, TAU); ctx.fill(); ctx.strokeStyle = "#8ff6ff"; ctx.lineWidth = 2.5; ctx.beginPath(); ctx.arc(cx, cy, 13, 0, TAU); ctx.stroke();
1193 // logo orbit ellipse
1194 ctx.save(); ctx.translate(270, 178); ctx.rotate(-0.1);
1195 ctx.globalAlpha = 0.22; ctx.strokeStyle = "#3df2ff"; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.ellipse(0, 0, 236, 58, 0, 0, TAU); ctx.stroke();
1196 for (let i = 0; i < 3; i++) { const a = tG * 1.3 + (i * TAU) / 3, x = cos(a) * 236, y = sin(a) * 58; if (sin(a) < 0 && i !== 9) { } glow(i === 1 ? MG : i === 2 ? YE : CY, x, y, 20, 0.95); ctx.globalAlpha = 1; ctx.fillStyle = "#fff"; ctx.beginPath(); ctx.arc(x, y, 4.5, 0, TAU); ctx.fill(); }
1197 ctx.restore();
1198 ctx.globalAlpha = 1; ctx.globalCompositeOperation = "source-over";
1199 // logo text
1200 ctx.save(); ctx.textAlign = "center"; ctx.textBaseline = "middle";
1201 ctx.shadowColor = "#3df2ff"; ctx.shadowBlur = 34; ctx.font = fnt(98, 900);
1202 const g1 = ctx.createLinearGradient(0, 130, 0, 230); g1.addColorStop(0, "#ffffff"); g1.addColorStop(0.5, "#aef6ff"); g1.addColorStop(1, "#3d9bff");
1203 ctx.fillStyle = g1; ctx.fillText("ORBIT", 270, 176);
1204 ctx.shadowBlur = 0; ctx.fillStyle = "#ffffff"; ctx.globalAlpha = 0.9; ctx.fillText("ORBIT", 270, 176); ctx.globalAlpha = 1;
1205 ctx.shadowColor = "#ff3d9a"; ctx.shadowBlur = 26; ctx.font = fnt(50, 900); if ("letterSpacing" in ctx) ctx.letterSpacing = "7px";
1206 const g2 = ctx.createLinearGradient(0, 245, 0, 290); g2.addColorStop(0, "#ff8ac4"); g2.addColorStop(1, "#ff3d9a");
1207 ctx.fillStyle = g2; ctx.fillText("SURVIVORS", 274, 266);
1208 if ("letterSpacing" in ctx) ctx.letterSpacing = "0px";
1209 ctx.restore();
1210 txt("Drag to move. Orbs fight for you.", 270, 620, 17, "#a9c2ff", "center", 600);
1211 bigButton(270, 705, 330, 82, "PLAY", CY, startRun, true);
1212 bigButton(270, 800, 330, 58, "UPGRADES", GO, () => { state = "meta"; sfx("click"); }, false);
1213 diamond(146, 800, 8, "#ffd35a"); txt(String(meta.shards), 160, 801, 16, "#ffd35a", "left", 800);
1214 const b = getBest();
1215 txt(b ? "BEST " + b : "Survive. Level up. Evolve.", 270, 880, 17, "#8b9fd8", "center", 700);
1216 txt("WASD / arrows / drag | Space or double-tap: dash", 270, 918, 12, "#5a6a9a", "center", 600);
1217 muteBtn(502, 38);
1218}
1219const META = [
1220 { id: "vit", name: "Reinforced Core", desc: "+12 starting max HP per level", max: 5, cost: [30, 55, 90, 140, 200] },
1221 { id: "pow", name: "Sharper Orbs", desc: "+6% orb damage per level", max: 5, cost: [40, 70, 110, 170, 250] },
1222 { id: "xp", name: "Fast Learner", desc: "+8% XP from gems per level", max: 5, cost: [35, 60, 100, 150, 220] },
1223 { id: "mag", name: "Gravity Well", desc: "+12% gem pickup range per level", max: 4, cost: [25, 45, 80, 130] },
1224 { id: "spd", name: "Ion Thrusters", desc: "+4% move speed per level", max: 4, cost: [30, 55, 95, 150] },
1225 { id: "orb", name: "Third Orb", desc: "Start every run with one extra orb", max: 1, cost: [260] },
1226 { id: "dash", name: "Dash Module", desc: "Start every run with Phase Dash", max: 1, cost: [180] },
1227 { id: "rev", name: "Second Chance", desc: "Revive once per run at half HP", max: 1, cost: [400] },
1228];
1229function drawMeta() {
1230 screenT(); ctx.globalAlpha = 0.94; ctx.fillStyle = "#040616"; ctx.fillRect(0, 0, W, H); ctx.globalAlpha = 1;
1231 txt("PERMANENT UPGRADES", 270, 50, 28, "#ffffff", "center", 900);
1232 diamond(228, 92, 9, "#ffd35a"); txt(meta.shards + " SHARDS", 244, 92, 20, "#ffd35a", "left", 800);
1233 for (let i = 0; i < META.length; i++) {
1234 const m = META[i], y = 130 + i * 84, lv = ml(m.id), maxed = lv >= m.max, cost = maxed ? 0 : m.cost[lv], can = !maxed && meta.shards >= cost;
1235 rrect(22, y, 496, 74, 16); ctx.fillStyle = "rgba(14,22,58,0.88)"; ctx.fill(); ctx.strokeStyle = maxed ? "#ffd35a" : can ? "#3df2ff" : "rgba(120,140,220,0.35)"; ctx.lineWidth = 2; ctx.stroke();
1236 txt(m.name, 42, y + 25, 19, "#ffffff", "left", 800);
1237 if (m.max > 1) for (let k = 0; k < m.max; k++) { ctx.fillStyle = k < lv ? "#ffd35a" : "rgba(160,180,255,0.25)"; ctx.fillRect(388 - (m.max - k) * 16, y + 22, 12, 6); }
1238 txt(m.desc, 42, y + 54, 13, "#8b9fd8", "left", 600);
1239 if (maxed) txt("MAX", 470, y + 37, 18, "#ffd35a", "center", 900);
1240 else {
1241 rrect(398, y + 15, 108, 44, 22); ctx.fillStyle = can ? "rgba(61,242,255,0.22)" : "rgba(80,90,140,0.2)"; ctx.fill(); ctx.strokeStyle = can ? "#3df2ff" : "#4a5686"; ctx.lineWidth = 2; ctx.stroke();
1242 diamond(422, y + 37, 7, can ? "#ffd35a" : "#6a6f90"); txt(String(cost), 436, y + 38, 18, can ? "#ffffff" : "#7c86b0", "left", 800);
1243 addBtn(398, y + 10, 118, 54, () => { if (can) { meta.shards -= cost; meta.lv[m.id] = lv + 1; saveMeta(); sfx("buy"); ring(420, y + 37, 50, 0.4, GO, 3); } else sfx("deny"); });
1244 }
1245 }
1246 bigButton(270, 895, 240, 56, "BACK", CY, () => { state = "title"; sfx("click"); }, false);
1247}
1248function drawCards() {
1249 screenT(); ctx.globalAlpha = 0.74; ctx.fillStyle = "#03050f"; ctx.fillRect(0, 0, W, H); ctx.globalAlpha = 1;
1250 const hd = Math.min(1, cardT * 4);
1251 ctx.save(); ctx.shadowColor = "#ffd35a"; ctx.shadowBlur = 24; txt("LEVEL UP!", 270, 118, 54, "#ffe9a0", "center", 900, hd); ctx.restore();
1252 txt("Level " + RUN.level + " - choose an upgrade", 270, 168, 18, "#a9c2ff", "center", 700, hd);
1253 if (RUN.pending > 1) txt("+" + (RUN.pending - 1) + " more waiting", 270, 194, 13, "#ffd35a", "center", 700, hd);
1254 for (let i = 0; i < 3; i++) {
1255 const u = cards[i]; if (!u) continue;
1256 const t = clamp((cardT - i * 0.09) / 0.28, 0, 1), e = 1 - Math.pow(1 - t, 3), x = 40 + (1 - e) * 560, y = 222 + i * 172, w = 460, h = 156, evo = !!u.evo, col = COLS[u.col];
1257 ctx.globalAlpha = 1;
1258 ctx.globalCompositeOperation = "lighter"; glow(evo ? GO : u.col, x + 230, y + 78, 300, evo ? 0.16 + 0.06 * sin(tG * 5) : 0.08); ctx.globalCompositeOperation = "source-over"; ctx.globalAlpha = 1;
1259 rrect(x, y, w, h, 22); const gr = ctx.createLinearGradient(x, y, x, y + h); gr.addColorStop(0, "rgba(22,32,84,0.97)"); gr.addColorStop(1, "rgba(9,14,44,0.97)"); ctx.fillStyle = gr; ctx.fill();
1260 ctx.strokeStyle = evo ? "#ffd35a" : col; ctx.lineWidth = evo ? 4 : 2.5; ctx.stroke();
1261 // icon plate
1262 ctx.globalCompositeOperation = "lighter"; glow(evo ? GO : u.col, x + 74, y + 78, 62, 0.55); ctx.globalCompositeOperation = "source-over"; ctx.globalAlpha = 1;
1263 ctx.beginPath(); ctx.arc(x + 74, y + 78, 44, 0, TAU); ctx.fillStyle = "rgba(5,8,28,0.85)"; ctx.fill(); ctx.strokeStyle = evo ? "#ffd35a" : col; ctx.lineWidth = 2.5; ctx.stroke();
1264 drawIcon(u.id, x + 74, y + 78, 26, evo ? "#ffe9a0" : col);
1265 const lvl = lvOf(u.id);
1266 txt(u.name, x + 138, y + 36, 24, "#ffffff", "left", 800);
1267 let tag, tcol;
1268 if (evo) { tag = "EVOLUTION"; tcol = "#ffd35a"; } else if (u.heal) { tag = "INSTANT"; tcol = "#5dff8a"; } else if (lvl === 0) { tag = "NEW"; tcol = "#5dff8a"; } else { tag = "LEVEL " + lvl + " > " + (lvl + 1); tcol = col; }
1269 txt(tag, x + 138, y + 62, 13, tcol, "left", 800);
1270 const ls = cardLines[i] || [];
1271 for (let k = 0; k < ls.length && k < 3; k++) txt(ls[k], x + 138, y + 88 + k * 20, 15, "#b9c6ee", "left", 500);
1272 // number key
1273 txt(String(i + 1), x + w - 22, y + 24, 14, "rgba(160,180,255,0.5)", "center", 800);
1274 if (t >= 1 && cardT > 0.35) addBtn(x, y, w, h, () => chooseCard(i));
1275 }
1276 // build chips
1277 const items = []; for (const u of UPG) if (lvOf(u.id)) items.push(u); for (const u of EVO) if (RUN.evo[u.id]) items.push(u);
1278 if (items.length) {
1279 txt("YOUR BUILD", 270, 762, 12, "#6f80b8", "center", 800);
1280 for (let i = 0; i < items.length; i++) { const u = items[i], row = (i / 9) | 0, col = i % 9, cnt = Math.min(9, items.length - row * 9), x = 270 + (col - (cnt - 1) / 2) * 52, y = 800 + row * 56;
1281 ctx.beginPath(); ctx.arc(x, y, 21, 0, TAU); ctx.fillStyle = "rgba(10,16,44,0.9)"; ctx.fill(); ctx.strokeStyle = COLS[u.col]; ctx.lineWidth = 2; ctx.stroke();
1282 drawIcon(u.id, x, y, 11, COLS[u.col]); txt(u.evo ? "*" : String(lvOf(u.id)), x + 14, y + 15, 12, "#fff", "center", 800); }
1283 }
1284 txt("Tap a card or press 1 / 2 / 3", 270, 930, 13, "#5a6a9a", "center", 600);
1285}
1286function drawOver() {
1287 screenT(); const a = clamp(overT * 2.2, 0, 1); ctx.globalAlpha = 0.78 * a; ctx.fillStyle = "#03050f"; ctx.fillRect(0, 0, W, H); ctx.globalAlpha = 1;
1288 const A = a;
1289 ctx.save(); ctx.shadowColor = "#ff4055"; ctx.shadowBlur = 26; txt("RUN OVER", 270, 96, 54, "#ff6d7d", "center", 900, A); ctx.restore();
1290 txt("SCORE", 270, 160, 14, "#8b9fd8", "center", 800, A);
1291 const sc = Math.floor(RUN.score * Math.min(1, overT * 0.9));
1292 ctx.save(); ctx.shadowColor = "#3df2ff"; ctx.shadowBlur = 22; txt(String(sc), 270, 206, 62, "#ffffff", "center", 900, A); ctx.restore();
1293 if (RUN.newBest) { ctx.save(); ctx.shadowColor = "#ffd35a"; ctx.shadowBlur = 16; txt("NEW BEST!", 270, 256, 24, "#ffd35a", "center", 900, A * (0.75 + 0.25 * sin(tG * 8))); ctx.restore(); }
1294 else txt("Best " + getBest(), 270, 254, 17, "#8b9fd8", "center", 700, A);
1295 const stats = [["TIME", fmtTime(RUN.t)], ["KILLS", String(RUN.kills)], ["LEVEL", String(RUN.level)], ["BOSSES", String(RUN.bossKills)]];
1296 rrect(30, 290, 480, 84, 18); ctx.globalAlpha = 0.85 * A; ctx.fillStyle = "rgba(14,22,58,0.9)"; ctx.fill(); ctx.strokeStyle = "rgba(120,150,255,0.4)"; ctx.lineWidth = 2; ctx.stroke(); ctx.globalAlpha = 1;
1297 for (let i = 0; i < 4; i++) { const x = 30 + 60 + i * 120; txt(stats[i][0], x, 315, 12, "#8b9fd8", "center", 800, A); txt(stats[i][1], x, 348, 30, "#ffffff", "center", 900, A); }
1298 txt("YOUR BUILD", 270, 412, 13, "#6f80b8", "center", 800, A);
1299 const items = []; for (const u of UPG) if (lvOf(u.id)) items.push(u); for (const u of EVO) if (RUN.evo[u.id]) items.push(u);
1300 if (!items.length) txt("No upgrades this run", 270, 470, 16, "#5a6a9a", "center", 600, A);
1301 for (let i = 0; i < items.length; i++) { const u = items[i], row = (i / 6) | 0, col = i % 6, cnt = Math.min(6, items.length - row * 6), x = 270 + (col - (cnt - 1) / 2) * 76, y = 462 + row * 84;
1302 ctx.globalAlpha = A; ctx.beginPath(); ctx.arc(x, y, 27, 0, TAU); ctx.fillStyle = "rgba(10,16,44,0.92)"; ctx.fill(); ctx.strokeStyle = u.evo ? "#ffd35a" : COLS[u.col]; ctx.lineWidth = u.evo ? 3.5 : 2.5; ctx.stroke();
1303 drawIcon(u.id, x, y, 14, u.evo ? "#ffe9a0" : COLS[u.col]); txt(u.evo ? "EVO" : "LV " + lvOf(u.id), x, y + 40, 11, u.evo ? "#ffd35a" : "#a9c2ff", "center", 800, A); }
1304 diamond(212, 640, 9, "#ffd35a"); txt("+" + RUN.shards + " SHARDS", 230, 640, 22, "#ffd35a", "left", 900, A);
1305 txt("Total " + meta.shards + " - spend them in Upgrades", 270, 674, 13, "#8b9fd8", "center", 600, A);
1306 if (overT > 0.4) {
1307 bigButton(270, 750, 340, 78, "PLAY AGAIN", CY, startRun, true);
1308 bigButton(270, 836, 340, 56, "UPGRADES", GO, () => { state = "meta"; sfx("click"); }, false);
1309 bigButton(270, 902, 200, 44, "MENU", PU, () => { state = "title"; sfx("click"); }, false);
1310 }
1311}
1312function drawPause() {
1313 screenT(); ctx.globalAlpha = 0.65; ctx.fillStyle = "#03050f"; ctx.fillRect(0, 0, W, H); ctx.globalAlpha = 1;
1314 ctx.save(); ctx.shadowColor = "#3df2ff"; ctx.shadowBlur = 22; txt("PAUSED", 270, 400, 60, "#ffffff", "center", 900); ctx.restore();
1315 txt("Tap anywhere to resume", 270, 470, 18, "#a9c2ff", "center", 600);
1316 bigButton(270, 570, 260, 60, "QUIT RUN", MG, () => { paused = false; state = "title"; }, false);
1317 addBtn(0, 0, W, H, () => { paused = false; try { SFXo.resume(); } catch (e) {} });
1318 // ensure the quit button (added first) wins hit-testing over full-screen resume
1319 const q = btns.splice(btns.length - 2, 1)[0]; btns.push(q);
1320}
1321
1322// ---------- main render ----------
1323function render() {
1324 ctx.setTransform(K, 0, 0, K, 0, 0); ctx.globalCompositeOperation = "source-over"; ctx.globalAlpha = 1;
1325 btns.length = 0;
1326 drawBackground();
1327 if (state === "title" || state === "meta") {
1328 drawTitle();
1329 screenT(); ctx.drawImage(VIG, 0, 0, W, H);
1330 if (state === "meta") { btns.length = 0; drawMeta(); }
1331 return;
1332 }
1333 drawWorldEntities();
1334 screenT(); ctx.drawImage(VIG, 0, 0, W, H);
1335 if (state === "play" || state === "dying") drawHUD();
1336 if (state === "levelup") drawCards();
1337 else if (state === "over") drawOver();
1338 if (paused && state === "play") drawPause();
1339}
1340
1341// ---------- loop ----------
1342let last = 0, fAcc = 0;
1343const perf = { n: 0, sum: 0, max: 0, upd: 0, ren: 0, buf: [] };
1344function loop(ts) {
1345 requestAnimationFrame(loop);
1346 let dt = (ts - last) / 1000; last = ts; if (!(dt > 0)) dt = 1 / 60; if (dt > 0.05) dt = 0.05;
1347 const t0 = performance.now();
1348 if (!paused) update(dt);
1349 const t1 = performance.now();
1350 render();
1351 const t2 = performance.now();
1352 if (DEBUG) { perf.n++; perf.upd += t1 - t0; perf.ren += t2 - t1; const tot = t2 - t0; perf.sum += tot; if (tot > perf.max) perf.max = tot; if (perf.buf.length < 4000) perf.buf.push(tot); }
1353}
1354requestAnimationFrame(loop);
1355
1356if (DEBUG) {
1357 const ff = (sec) => { const steps = Math.round(sec * 30); for (let i = 0; i < steps; i++) { if (state === "levelup") { if (botOn) { cardT = 1; chooseCard((rnd() * 3) | 0); } else break; } if (state === "dying") { deathT += 1 / 30; if (deathT > 1.5) { state = "over"; overT = 0; } continue; } if (state !== "play") break; RUN.slowT > 0 && (RUN.slowT -= 1 / 30, RUN.slowT <= 0 && (timeScale = 1)); stepGame((1 / 30) * timeScale); } };
1358 window.__os = {
1359 ff, P, RUN, G, B, cam,
1360 get state() { return state; },
1361 set state(v) { state = v; },
1362 start: startRun,
1363 setBot(v) { botOn = !!v; }, setGod(v) { god = !!v; },
1364 stats() { return { state, t: RUN.t, kills: RUN.kills, level: RUN.level, hp: P.hp, mhp: P.mhp, enemies: enemyCount, boss: B.alive, bossKills: RUN.bossKills, wave: RUN.wave, lv: RUN.lv, evo: RUN.evo, n: G.n, pending: RUN.pending, score: calcScore(), shards: meta.shards, particles: PT.filter((p) => p.life > 0).length, bullets: EB.filter((b) => b.alive).length }; },
1365 spawnBoss(i) { spawnBoss(i == null ? RUN.bossIdx : i); },
1366 killBoss() { if (B.alive) { B.intro = 0; hurtBoss(1e9, false); } },
1367 spawnMany(n, type) { for (let i = 0; i < n; i++) { edgePos(_p); spawnEnemy(type == null ? (rnd() * 6) | 0 : type, _p[0], _p[1]); } },
1368 swarm(n) { for (let i = 0; i < n; i++) { const a = rnd() * TAU, d = 120 + rnd() * 320; spawnEnemy((rnd() * 6) | 0, clamp(P.x + cos(a) * d, 30, WW - 30), clamp(P.y + sin(a) * d, 30, WH - 30)); } },
1369 addXP(n) { addXP(n); }, upgrade(id, n) { for (let i = 0; i < (n || 1); i++) { const u = UBY[id]; if (u) applyCard(u); } },
1370 pick(i) { cardT = 1; chooseCard(i); }, cards() { return cards.map((c) => c && c.id); },
1371 setTime(t) { RUN.t = t; }, hurt(d) { hurtPlayer(d, P.x, P.y); }, die() { P.hp = 0; die(); },
1372 get perf() { return perf; }, resetPerf() { perf.n = perf.sum = perf.max = perf.upd = perf.ren = 0; perf.buf.length = 0; },
1373 meta, saveMeta, dash: requestDash, pause(v) { paused = v; },
1374 };
1375}
1376})();
1377