arcadia / phase-runner

Phase Runner

game.js63.5 KB
1// Phase Runner - a pseudo-3D two-reality endless runner for Arcadia.
2(() => {
3 "use strict";
4 const W = 540, H = 960;
5 const HZ = 350, GY = 800, D0 = 300, LW = 150, RH = 240, ZFAR = 3400, ROWSP = 300;
6 const FONT = 'system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif';
7 const c = document.getElementById("c");
8 const ctx = c.getContext("2d");
9 let DPR = 1;
10 let bufR = null, bufC = null;
11
12 function fit() {
13 DPR = Math.min(window.devicePixelRatio || 1, 2);
14 const s = Math.min(innerWidth / W, innerHeight / H) || 1;
15 c.style.width = W * s + "px"; c.style.height = H * s + "px";
16 c.width = Math.max(2, Math.round(W * s * DPR)); c.height = Math.max(2, Math.round(H * s * DPR));
17 ctx.setTransform(c.width / W, 0, 0, c.height / H, 0, 0);
18 bufR = bufC = null;
19 }
20 addEventListener("resize", fit); fit();
21
22 // ---------- helpers ----------
23 const clamp = (v, a, b) => (v < a ? a : v > b ? b : v);
24 const lerp = (a, b, t) => a + (b - a) * t;
25 const rnd = (a, b) => a + Math.random() * (b - a);
26 const S = (z) => D0 / (z + D0);
27 const Y = (z) => HZ + (GY - HZ) * S(z);
28 let camX = 0;
29 const X = (wx, z) => W / 2 + (wx - camX) * S(z);
30 function mulberry(a) { return () => { a |= 0; a = (a + 0x6d2b79f5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; }
31 function hex(h) { return [parseInt(h.slice(1, 3), 16), parseInt(h.slice(3, 5), 16), parseInt(h.slice(5, 7), 16)]; }
32 function rr(x, y, w, h, r) {
33 ctx.beginPath(); ctx.moveTo(x + r, y); ctx.arcTo(x + w, y, x + w, y + h, r); ctx.arcTo(x + w, y + h, x, y + h, r);
34 ctx.arcTo(x, y + h, x, y, r); ctx.arcTo(x, y, x + w, y, r); ctx.closePath();
35 }
36 function txt(s, x, y, size, color, align, weight, glow) {
37 ctx.font = (weight || 800) + " " + size + "px " + FONT;
38 ctx.textAlign = align || "center"; ctx.textBaseline = "middle";
39 if (glow) { ctx.shadowColor = glow; ctx.shadowBlur = 14; }
40 ctx.fillStyle = color; ctx.fillText(s, x, y);
41 if (glow) ctx.shadowBlur = 0;
42 }
43 function store(k, v) { try { if (v === undefined) return localStorage.getItem(k); localStorage.setItem(k, String(v)); } catch (e) {} return null; }
44
45 // ---------- palette (day = 0, night = 1) ----------
46 const PAL = {
47 skyTop: ["#ff5f6d", "#04021a"], skyMid: ["#ff9966", "#180b4d"], skyHor: ["#ffe29a", "#3b23a8"],
48 gnd: ["#3a1250", "#070420"], road: ["#2b1238", "#0b0831"], roadFar: ["#6a2a58", "#221570"],
49 edge: ["#ffc15e", "#2ff0ff"], edge2: ["#ff5a5f", "#b44dff"], grid: ["#ff8a5c", "#7a4dff"],
50 bFar: ["#d1507f", "#1d1260"], bMid: ["#a03a6a", "#150e4a"], bNear: ["#6e2559", "#0d0833"],
51 face: ["#8f3565", "#160f4d"], side: ["#55194a", "#0a0628"], accent: ["#ffb020", "#29f0ff"], accent2: ["#ff4d6d", "#c04dff"]
52 };
53 const PA = {}; for (const k in PAL) PA[k] = [hex(PAL[k][0]), hex(PAL[k][1])];
54 let CC = {}, CR = {};
55 function setPalette(t) {
56 for (const k in PA) {
57 const a = PA[k][0], b = PA[k][1];
58 const r = Math.round(lerp(a[0], b[0], t)), g = Math.round(lerp(a[1], b[1], t)), bl = Math.round(lerp(a[2], b[2], t));
59 CR[k] = [r, g, bl]; CC[k] = "rgb(" + r + "," + g + "," + bl + ")";
60 }
61 }
62 const RA = (k, a) => "rgba(" + CR[k][0] + "," + CR[k][1] + "," + CR[k][2] + "," + a + ")";
63 setPalette(0);
64
65 const glowCache = {};
66 function glow(color) {
67 if (glowCache[color]) return glowCache[color];
68 const g = document.createElement("canvas"); g.width = g.height = 128;
69 const x = g.getContext("2d"), gr = x.createRadialGradient(64, 64, 0, 64, 64, 64);
70 gr.addColorStop(0, color); gr.addColorStop(0.35, color.replace("1)", "0.4)")); gr.addColorStop(1, color.replace("1)", "0)"));
71 x.fillStyle = gr; x.fillRect(0, 0, 128, 128);
72 return (glowCache[color] = g);
73 }
74 function drawGlow(color, x, y, r, a) {
75 ctx.globalCompositeOperation = "lighter"; ctx.globalAlpha = a == null ? 1 : a;
76 ctx.drawImage(glow(color), x - r, y - r, r * 2, r * 2);
77 ctx.globalAlpha = 1; ctx.globalCompositeOperation = "source-over";
78 }
79
80 // ---------- state ----------
81 let state = "title"; // title | tutorial | play | dying | over
82 let paused = false, T = 0, tutSeen = store("phaserunner.tut") === "1";
83 let best = Number(store("phaserunner.best") || 0);
84 let muted = store("phaserunner.mute") === "1";
85 let D = 0, runT = 0, speed = 600, coins = 0, coinPts = 0, streak = 0, phaseT = 0, phaseGot = false, phaseMult = 1;
86 let magnetT = 0, shield = false, invulnT = 0, slowT = 0, firstRun = false, newBest = false, overT = 0, hitstop = 0;
87 let rows = [], nextRowZ = 0, chunkCount = 0, lastChunk = -1, lastPowerChunk = 0, script = [];
88 let wb = 0, shake = 0, swapFx = 0, hurtFx = 0, flash = 0, bank = 0, hint = "", hintT = 0;
89 let demoSwapT = 2, demoLaneT = 0.5, demoLane = 1, lastScoreShown = 0, scorePop = 0;
90 const pl = { lane: 1, x: 1, world: 0, vx: 0, bob: 0 };
91 let parts = [], pops = [], rings = [], lines = [];
92
93 function speedAt(t) { return 600 + 850 * (1 - Math.exp(-t / 50)); }
94 const totalScore = () => Math.floor(D / 80) + coinPts;
95
96 // ---------- audio ----------
97 let AC = null, master = null, musicBus = null, musicFilt = null, noiseBuf = null;
98 let mStep = 0, mNext = 0;
99 function initAudio() {
100 try {
101 if (AC) { if (AC.state === "suspended") AC.resume(); return; }
102 const Ctor = window.AudioContext || window.webkitAudioContext; if (!Ctor) return;
103 AC = new Ctor();
104 const comp = AC.createDynamicsCompressor(); comp.connect(AC.destination);
105 master = AC.createGain(); master.gain.value = muted ? 0 : 0.6; master.connect(comp);
106 musicFilt = AC.createBiquadFilter(); musicFilt.type = "lowpass"; musicFilt.frequency.value = 9000; musicFilt.Q.value = 2;
107 musicBus = AC.createGain(); musicBus.gain.value = 0.5; musicBus.connect(musicFilt); musicFilt.connect(master);
108 noiseBuf = AC.createBuffer(1, AC.sampleRate * 0.5, AC.sampleRate);
109 const d = noiseBuf.getChannelData(0); for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
110 mNext = AC.currentTime + 0.1;
111 } catch (e) { AC = null; }
112 }
113 function setMute(m) {
114 muted = m; store("phaserunner.mute", m ? "1" : "0");
115 if (master) master.gain.setTargetAtTime(m ? 0 : 0.6, AC.currentTime, 0.02);
116 }
117 function osc(type, f0, f1, t, dur, vol, dest, filt) {
118 const o = AC.createOscillator(), g = AC.createGain(); o.type = type;
119 o.frequency.setValueAtTime(f0, t); if (f1 && f1 !== f0) o.frequency.exponentialRampToValueAtTime(f1, t + dur);
120 g.gain.setValueAtTime(0.0001, t); g.gain.exponentialRampToValueAtTime(vol, t + 0.008); g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
121 let n = o; if (filt) { const f = AC.createBiquadFilter(); f.type = "lowpass"; f.frequency.value = filt; o.connect(f); n = f; }
122 n.connect(g); g.connect(dest || master); o.start(t); o.stop(t + dur + 0.05);
123 }
124 function noise(t, dur, vol, ftype, f0, f1, dest) {
125 const s = AC.createBufferSource(); s.buffer = noiseBuf; const f = AC.createBiquadFilter(); f.type = ftype;
126 f.frequency.setValueAtTime(f0, t); if (f1) f.frequency.exponentialRampToValueAtTime(f1, t + dur);
127 const g = AC.createGain(); g.gain.setValueAtTime(vol, t); g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
128 s.connect(f); f.connect(g); g.connect(dest || master); s.start(t); s.stop(t + dur + 0.02);
129 }
130 const sfx = {
131 coin(k) { if (!AC) return; const t = AC.currentTime, f = 880 * Math.pow(1.06, Math.min(k, 10)); osc("square", f, f, t, 0.07, 0.12, master, 5000); osc("square", f * 1.5, f * 1.5, t + 0.05, 0.11, 0.12, master, 5000); },
132 swap(w) { if (!AC) return; const t = AC.currentTime; noise(t, 0.2, 0.35, "bandpass", w ? 3800 : 500, w ? 400 : 3800); osc("sine", w ? 900 : 260, w ? 240 : 900, t, 0.18, 0.3); },
133 hit() { if (!AC) return; const t = AC.currentTime; noise(t, 0.5, 0.7, "lowpass", 3000, 80); osc("sine", 120, 28, t, 0.6, 0.7); osc("sawtooth", 300, 40, t, 0.5, 0.25, master, 1500); },
134 power() { if (!AC) return; const t = AC.currentTime; [523, 659, 784, 1046].forEach((f, i) => osc("triangle", f, f, t + i * 0.06, 0.16, 0.2)); },
135 shieldBreak() { if (!AC) return; const t = AC.currentTime; noise(t, 0.35, 0.5, "highpass", 2000, 6000); osc("triangle", 1200, 200, t, 0.3, 0.25); },
136 over() { if (!AC) return; const t = AC.currentTime; [392, 330, 262, 196].forEach((f, i) => osc("sawtooth", f, f * 0.97, t + 0.15 + i * 0.16, 0.3, 0.16, master, 1400)); },
137 start() { if (!AC) return; const t = AC.currentTime; osc("sawtooth", 130, 520, t, 0.35, 0.16, master, 2500); noise(t, 0.3, 0.2, "bandpass", 300, 4000); }
138 };
139 const ROOTS = [130.81, 110, 87.31, 98];
140 function musicTick() {
141 if (!AC || AC.state !== "running") return;
142 const bpm = 112 + clamp((speed - 600) / 850, 0, 1) * 58 + (slowT > 0 ? -25 : 0);
143 const sd = 60 / bpm / 4;
144 if (mNext < AC.currentTime - 0.3) mNext = AC.currentTime + 0.05;
145 const night = wb > 0.5;
146 const target = state === "over" ? 700 : night ? 2400 : 9000;
147 musicFilt.frequency.setTargetAtTime(target, AC.currentTime, 0.05);
148 while (mNext < AC.currentTime + 0.12) {
149 const t = mNext, s = mStep % 16, bar = Math.floor(mStep / 16) % 4, root = ROOTS[bar];
150 const inten = state === "title" || state === "tutorial" ? 0.6 : 1;
151 if (s % 4 === 0) { osc("sine", 150, 45, t, 0.16, 0.9 * inten, musicBus); }
152 if (s === 4 || s === 12) noise(t, 0.12, 0.28 * inten, "bandpass", 1800, 1200, musicBus);
153 if (s % 2 === 0) noise(t, 0.035, (s % 4 === 2 ? 0.2 : 0.09) * inten, "highpass", 7500, 0, musicBus);
154 else if (speed > 900) noise(t, 0.02, 0.06 * inten, "highpass", 8500, 0, musicBus);
155 if (s % 2 === 0 || s === 3 || s === 11) {
156 const bn = [0, 0, 12, 0, 7, 0, 12, 10][(s >> 1) % 8] || 0;
157 osc("sawtooth", root * Math.pow(2, bn / 12), 0, t, sd * 1.6, 0.34 * inten, musicBus, night ? 500 : 800);
158 }
159 const arp = night ? [0, 3, 7, 12, 15, 12, 7, 3] : [0, 4, 7, 12, 16, 12, 7, 4];
160 if (state !== "over" && (state === "play" || state === "dying" || bar % 2 === 1 || s % 2 === 0)) {
161 const nt = arp[s % 8] + (s >= 8 ? 12 : 0) * 0;
162 osc(night ? "sawtooth" : "square", root * 4 * Math.pow(2, nt / 12), 0, t, sd * 0.9, night ? 0.08 : 0.07, musicBus, night ? 1800 : 3600);
163 }
164 mNext += sd; mStep++;
165 }
166 }
167
168 // ---------- chunk generation ----------
169 const CHUNKS = window.PR_CHUNKS, solve = window.PR_solve;
170 const FLIPW = { a: "b", b: "a", g: "h", h: "g", e: "f", f: "e" };
171 function cellItems(ch, lane) {
172 switch (ch) {
173 case "a": return { k: "blk", lane, world: 0 }; case "b": return { k: "blk", lane, world: 1 };
174 case "g": return { k: "gap", lane, world: 0 }; case "h": return { k: "gap", lane, world: 1 };
175 case "e": return { k: "drn", lane, world: 0 }; case "f": return { k: "drn", lane, world: 1 };
176 case "c": return { k: "coin", lane, world: -1 };
177 }
178 return null;
179 }
180 function pushRow(str, coinLane) {
181 const items = [];
182 for (let l = 0; l < 3; l++) { const it = cellItems(str.charAt(l), l); if (it) items.push(it); }
183 if (coinLane != null && !items.some((i) => i.lane === coinLane)) items.push({ k: "coin", lane: coinLane, world: -1 });
184 items.forEach((i) => { i.lx = i.lane; i.taken = false; i.hit = false; });
185 rows.push({ z: nextRowZ, items, str });
186 nextRowZ += ROWSP;
187 }
188 function genChunk() {
189 const lim = runT < 10 ? 0 : runT < 32 ? 1 : runT < 65 ? 2 : 3;
190 let ch, guard = 0;
191 if (script.length) ch = script.shift();
192 else {
193 const pool = []; CHUNKS.forEach((cc, i) => { if (cc.t <= lim && i !== lastChunk) { const w = 1 + cc.t * (lim > 0 ? 1.2 : 0) + (cc.t === lim ? 2 : 0); for (let k = 0; k < w; k++) pool.push(i); } });
194 lastChunk = pool[(Math.random() * pool.length) | 0]; ch = CHUNKS[lastChunk];
195 }
196 let rs = ch.r.slice();
197 if (!ch.fixed) {
198 if (Math.random() < 0.5) rs = rs.map((s) => s.split("").reverse().join(""));
199 if (Math.random() < 0.5) rs = rs.map((s) => s.split("").map((x) => FLIPW[x] || x).join(""));
200 }
201 const hasCoin = rs.some((s) => s.indexOf("c") >= 0);
202 const path = hasCoin ? null : solve(rs, Math.random);
203 // lead-in
204 const lead = path ? path[0][0] : 1;
205 pushRow("...", null); pushRow("...", chunkCount > 0 ? lead : null);
206 rs.forEach((s, i) => pushRow(s, path && Math.random() < 0.7 ? path[i][0] : null));
207 // power-up in the breathing room
208 chunkCount++;
209 if (chunkCount > 3 && chunkCount - lastPowerChunk >= 5 && Math.random() < 0.4 && !ch.fixed) {
210 lastPowerChunk = chunkCount;
211 const r = rows[rows.length - rs.length - 2], types = ["magnet", "shield", "slow"];
212 const pt = types[(Math.random() * 3) | 0];
213 r.items = r.items.filter((i) => i.k !== "coin");
214 r.items.push({ k: "pow", pt, lane: (Math.random() * 3) | 0, lx: 0, world: -1, taken: false, hit: false });
215 r.items[r.items.length - 1].lx = r.items[r.items.length - 1].lane;
216 }
217 }
218
219 // ---------- run control ----------
220 function resetRun() {
221 D = 0; runT = 0; speed = 600; coins = 0; coinPts = 0; streak = 0; phaseT = 0; phaseGot = false; phaseMult = 1;
222 magnetT = 0; shield = false; invulnT = 0; slowT = 0; newBest = false; hitstop = 0; hint = ""; hintT = 0;
223 rows = []; nextRowZ = 900; chunkCount = 0; lastChunk = -1; lastPowerChunk = 0;
224 pl.lane = 1; pl.x = 1; pl.world = 0; pl.vx = 0; wb = 0; parts = []; pops = []; rings = []; shake = 0; swapFx = 0; hurtFx = 0; flash = 0;
225 firstRun = !tutSeen;
226 script = firstRun ? [{ t: 0, fixed: true, r: ["aaa"] }, { t: 0, fixed: true, r: ["bbb"] }, { t: 0, fixed: true, r: [".a.", "...", "b.b"] }] : [];
227 if (!firstRun) script = [{ t: 0, r: ["a..", "...", "..b", "...", ".a."] }];
228 lastScoreShown = 0;
229 while (nextRowZ < ZFAR + 600) genChunk();
230 state = "play";
231 }
232 function endRun() {
233 state = "dying"; overT = 0; hitstop = 0.14; shake = 26; hurtFx = 1; flash = 1; swapFx = 1;
234 sfx.hit();
235 const sc = totalScore();
236 newBest = sc > best;
237 if (newBest) { best = sc; store("phaserunner.best", best); }
238 const px = X((pl.x - 1) * LW, 0), py = GY - 40;
239 for (let i = 0; i < 70; i++) { const a = rnd(0, 6.283), v = rnd(80, 620); parts.push({ x: px, y: py, vx: Math.cos(a) * v, vy: Math.sin(a) * v - 100, life: rnd(0.5, 1.2), max: 1.2, size: rnd(2, 6), col: i % 3 ? (pl.world ? "#29f0ff" : "#ffb020") : "#ffffff", g: 500 }); }
240 if (window.Arcadia) { try { Arcadia.submitScore(sc); Arcadia.gameOver(); } catch (e) {} }
241 }
242 function toOver() { state = "over"; overT = 0; sfx.over(); }
243
244 function doSwap() {
245 if (state !== "play") return;
246 pl.world ^= 1; swapFx = 1; phaseT = 1.0; phaseGot = false; sfx.swap(pl.world);
247 rings.push({ t: 0, world: pl.world });
248 shake = Math.max(shake, 7);
249 for (let i = 0; i < 26; i++) { const a = rnd(0, 6.283), v = rnd(200, 520); parts.push({ x: X((pl.x - 1) * LW, 0), y: GY - 50, vx: Math.cos(a) * v, vy: Math.sin(a) * v, life: rnd(0.25, 0.5), max: 0.5, size: rnd(2, 5), col: pl.world ? "#29f0ff" : "#ffb020", g: 0 }); }
250 }
251 function doLane(d) {
252 if (state !== "play") return;
253 const n = clamp(pl.lane + d, 0, 2); if (n !== pl.lane) { pl.lane = n; bank += d * 0.5; }
254 }
255 function primary() {
256 initAudio();
257 if (state === "title") { if (!tutSeen) { state = "tutorial"; sfx.start(); } else { resetRun(); sfx.start(); } }
258 else if (state === "tutorial") { resetRun(); tutSeen = true; store("phaserunner.tut", "1"); sfx.start(); }
259 else if (state === "over" && overT > 0.7) { resetRun(); sfx.start(); }
260 else doSwap();
261 }
262
263 // ---------- input ----------
264 const MUTE = { x: W - 74, y: 22, w: 52, h: 52 };
265 const ptrs = new Map();
266 function lpos(e) { const r = c.getBoundingClientRect(); return { x: ((e.clientX - r.left) / r.width) * W, y: ((e.clientY - r.top) / r.height) * H }; }
267 c.addEventListener("pointerdown", (e) => {
268 e.preventDefault(); const p = lpos(e);
269 try { c.setPointerCapture(e.pointerId); } catch (er) {}
270 const ui = p.x > MUTE.x - 8 && p.y < MUTE.y + MUTE.h + 8 && p.x < W;
271 ptrs.set(e.pointerId, { x: p.x, y: p.y, ax: p.x, ay: p.y, t: performance.now(), swiped: false, ui, moved: 0 });
272 if (ui) { initAudio(); setMute(!muted); }
273 });
274 c.addEventListener("pointermove", (e) => {
275 const q = ptrs.get(e.pointerId); if (!q || q.ui) return; e.preventDefault();
276 const p = lpos(e), dx = p.x - q.ax, dy = p.y - q.ay;
277 q.moved = Math.max(q.moved, Math.hypot(p.x - q.x, p.y - q.y));
278 if (state !== "play") return;
279 if (Math.abs(dx) >= 34 && Math.abs(dx) > Math.abs(dy) * 0.8) { doLane(Math.sign(dx)); q.ax = p.x; q.ay = p.y; q.swiped = true; }
280 else if (dy <= -60 && Math.abs(dy) > Math.abs(dx)) { doSwap(); q.ax = p.x; q.ay = p.y; q.swiped = true; }
281 });
282 const endPtr = (e) => {
283 const q = ptrs.get(e.pointerId); if (!q) return; ptrs.delete(e.pointerId);
284 if (e.type === "pointerup" && !q.ui && !q.swiped && q.moved < 22 && performance.now() - q.t < 600) primary();
285 };
286 c.addEventListener("pointerup", endPtr); c.addEventListener("pointercancel", endPtr);
287 c.addEventListener("contextmenu", (e) => e.preventDefault());
288 addEventListener("keydown", (e) => {
289 const k = e.key;
290 if (k === "ArrowLeft" || k === "a" || k === "A") { doLane(-1); initAudio(); e.preventDefault(); }
291 else if (k === "ArrowRight" || k === "d" || k === "D") { doLane(1); initAudio(); e.preventDefault(); }
292 else if (k === " " || k === "ArrowUp" || k === "w" || k === "W" || k === "Enter") { if (!e.repeat) primary(); e.preventDefault(); }
293 else if (k === "m" || k === "M") { initAudio(); setMute(!muted); }
294 });
295 addEventListener("touchmove", (e) => e.preventDefault(), { passive: false });
296 if (window.Arcadia) { Arcadia.onPause(() => { paused = true; if (AC) AC.suspend(); }); Arcadia.onResume(() => { paused = false; if (AC && !muted) AC.resume(); last = performance.now(); }); }
297 document.addEventListener("visibilitychange", () => { if (document.hidden) { paused = true; if (AC) AC.suspend(); } else { paused = false; last = performance.now(); if (AC) AC.resume(); } });
298 c.focus();
299
300 // ---------- fx helpers ----------
301 function burst(x, y, col, n, v0) {
302 for (let i = 0; i < n; i++) { const a = rnd(0, 6.283), v = rnd(v0 * 0.4, v0); parts.push({ x, y, vx: Math.cos(a) * v, vy: Math.sin(a) * v - 60, life: rnd(0.3, 0.7), max: 0.7, size: rnd(2, 5), col, g: 300 }); }
303 }
304 function pop(text, x, y, col, size) { pops.push({ text, x, y, col, size: size || 30, t: 0 }); }
305
306 // ---------- update ----------
307 function collect(it) {
308 const px = X((it.lx - 1) * LW, 0), py = GY - 50;
309 it.taken = true;
310 if (it.k === "coin") {
311 coins++;
312 let pts;
313 if (phaseT > 0) {
314 phaseMult = 2 + Math.min(streak, 6);
315 if (!phaseGot) { phaseGot = true; streak++; }
316 pts = 10 * phaseMult; pop("+" + pts + " PHASE x" + phaseMult, px, py - 70, pl.world ? "#7ffcff" : "#ffe27a", 28);
317 burst(px, py, pl.world ? "#29f0ff" : "#ffd23f", 22, 520);
318 } else { pts = 5; burst(px, py, "#ffd23f", 10, 320); }
319 coinPts += pts; sfx.coin(phaseT > 0 ? streak : 0); scorePop = 1;
320 } else {
321 sfx.power(); burst(px, py, "#ffffff", 30, 500);
322 if (it.pt === "magnet") { magnetT = 8; pop("MAGNET", px, py - 80, "#ff5c7a", 34); }
323 else if (it.pt === "shield") { shield = true; pop("SHIELD", px, py - 80, "#7ffcff", 34); }
324 else { slowT = 4; pop("SLOW-MO", px, py - 80, "#c9a4ff", 34); }
325 }
326 }
327 function update(dt) {
328 T += dt;
329 const bt = pl.world;
330 wb += clamp(bt - wb, -dt / 0.15, dt / 0.15);
331 setPalette(wb);
332 swapFx = Math.max(0, swapFx - dt * 3.2); hurtFx = Math.max(0, hurtFx - dt * 1.6); flash = Math.max(0, flash - dt * 3);
333 shake = Math.max(0, shake - dt * 45); scorePop = Math.max(0, scorePop - dt * 4);
334 bank *= Math.exp(-dt * 7);
335 for (let i = rings.length - 1; i >= 0; i--) { rings[i].t += dt / 0.55; if (rings[i].t >= 1) rings.splice(i, 1); }
336 for (let i = parts.length - 1; i >= 0; i--) { const p = parts[i]; p.life -= dt; if (p.life <= 0) { parts.splice(i, 1); continue; } p.vy += (p.g || 0) * dt; p.x += p.vx * dt; p.y += p.vy * dt; }
337 if (parts.length > 500) parts.splice(0, parts.length - 500);
338 for (let i = pops.length - 1; i >= 0; i--) { pops[i].t += dt; if (pops[i].t > 1) pops.splice(i, 1); }
339 // speed lines
340 while (lines.length < 16) lines.push({ a: rnd(0, 6.283), r: rnd(0, 1), l: rnd(0.1, 0.35) });
341
342 if (state === "title" || state === "tutorial") {
343 D += 620 * dt; speed = 620;
344 demoSwapT -= dt; if (demoSwapT <= 0) { demoSwapT = 2.6; pl.world ^= 1; swapFx = 1; rings.push({ t: 0, world: pl.world }); sfx.swap(pl.world); }
345 demoLaneT -= dt; if (demoLaneT <= 0) { demoLaneT = 1.1; demoLane = (demoLane + (Math.random() < 0.5 ? 1 : 2)) % 3; }
346 pl.x += (demoLane - pl.x) * Math.min(1, dt * 5);
347 camX = (pl.x - 1) * LW * 0.5;
348 return;
349 }
350 if (state === "over") { overT += dt; D += 60 * dt; pl.vx = 0; camX = (pl.x - 1) * LW * 0.5; return; }
351 if (hitstop > 0) { hitstop -= dt; return; }
352 if (state === "dying") {
353 overT += dt; D += speed * 0.08 * dt; if (overT > 1.05) toOver();
354 camX = (pl.x - 1) * LW * 0.5; return;
355 }
356 // ---- play ----
357 if (slowT > 0) slowT -= dt;
358 const dw = dt * (slowT > 0 ? 0.55 : 1);
359 runT += dw; speed = speedAt(runT) * (firstRun && runT < 6 ? 0.85 : 1);
360 D += speed * dw;
361 const px0 = pl.x; pl.x += (pl.lane - pl.x) * Math.min(1, dt * 17); pl.vx = (pl.x - px0) / Math.max(dt, 0.001);
362 camX = (pl.x - 1) * LW * 0.5;
363 if (magnetT > 0) magnetT -= dt; if (invulnT > 0) invulnT -= dt;
364 if (phaseT > 0) { phaseT -= dt; if (phaseT <= 0 && !phaseGot) streak = 0; }
365 while (nextRowZ < D + ZFAR + 600) genChunk();
366 while (rows.length && rows[0].z < D - 500) rows.shift();
367 hint = "";
368 for (const r of rows) {
369 const rel = r.z - D;
370 if (rel > ZFAR) break;
371 if (rel < -160) continue;
372 if (firstRun && runT < 30 && rel > 150 && rel < 1500) {
373 const blk = r.items.filter((i) => (i.k === "blk" || i.k === "drn") && i.world === pl.world).length;
374 if (blk >= 3) hint = "TAP to PHASE";
375 else if (r.str === "b.b" || (r.str === ".a." && !hint)) hint = "SWIPE to change lane";
376 }
377 for (const it of r.items) {
378 if (it.taken) continue;
379 if (it.k === "coin" || it.k === "pow") {
380 if (magnetT > 0 && it.k === "coin" && rel < 1000 && rel > 0 && Math.abs(it.lx - pl.x) < 2.1) it.lx += (pl.x - it.lx) * Math.min(1, dt * 7);
381 if (Math.abs(rel) < 70 && Math.abs(it.lx - pl.x) < 0.62) collect(it);
382 } else if (!it.hit && it.world === pl.world) {
383 const win = it.k === "gap" ? 62 : 52;
384 if (Math.abs(rel) < win && Math.abs(it.lane - pl.x) < 0.56) {
385 if (invulnT > 0) continue;
386 if (shield) {
387 shield = false; invulnT = 1.3; it.hit = true; sfx.shieldBreak(); shake = 16; flash = 0.6; swapFx = 1;
388 burst(X((pl.x - 1) * LW, 0), GY - 50, "#7ffcff", 40, 600); pop("SHIELD BROKEN", W / 2, 440, "#7ffcff", 34);
389 streak = 0;
390 } else { endRun(); return; }
391 }
392 }
393 }
394 }
395 // trail
396 if (Math.random() < 0.9) parts.push({ x: X((pl.x - 1) * LW, 0) + rnd(-18, 18), y: GY - 4, vx: rnd(-30, 30), vy: rnd(80, 200), life: rnd(0.2, 0.4), max: 0.4, size: rnd(2, 4), col: pl.world ? "#29f0ff" : "#ffb020", g: 0 });
397 const sc = totalScore(); lastScoreShown = sc;
398 }
399
400 // ---------- drawing: world ----------
401 function skyBands() {
402 const g = ctx.createLinearGradient(0, 0, 0, HZ + 10);
403 g.addColorStop(0, CC.skyTop); g.addColorStop(0.55, CC.skyMid); g.addColorStop(1, CC.skyHor);
404 ctx.fillStyle = g; ctx.fillRect(-100, -100, W + 200, HZ + 110);
405 }
406 const stars = []; { const r = mulberry(7); for (let i = 0; i < 70; i++) stars.push({ x: r() * W, y: r() * (HZ - 90), s: r() * 1.6 + 0.6, p: r() * 6 }); }
407 const clouds = []; { const r = mulberry(11); for (let i = 0; i < 6; i++) clouds.push({ x: r() * W, y: 90 + r() * 180, w: 120 + r() * 160, s: 6 + r() * 10 }); }
408 function drawSun() {
409 ctx.save(); ctx.beginPath(); ctx.rect(-100, -100, W + 200, HZ + 100); ctx.clip();
410 const sx = W / 2 - camX * 0.06;
411 // sun
412 const sy = HZ - 130 + wb * 250;
413 if (wb < 0.98) {
414 ctx.globalAlpha = 1 - wb;
415 drawGlow("rgba(255,170,70,1)", sx, sy, 300, 0.8);
416 ctx.save(); ctx.beginPath(); ctx.rect(0, sy - 120, W, 122);
417 for (let i = 0; i < 6; i++) { const yy = sy + 2 + i * 15 + ((T * 8) % 15); const hh = 3 + i * 1.7; if (yy < sy + 125) ctx.rect(0, yy, W, hh); }
418 ctx.rect(0, sy + 2, 0, 0); ctx.clip();
419 const g = ctx.createLinearGradient(0, sy - 120, 0, sy + 120);
420 g.addColorStop(0, "#fff7c4"); g.addColorStop(0.5, "#ffb84d"); g.addColorStop(1, "#ff4f6d");
421 ctx.fillStyle = g; ctx.beginPath(); ctx.arc(sx, sy, 120, 0, 6.283); ctx.fill(); ctx.restore();
422 ctx.globalAlpha = 1;
423 }
424 // moon
425 const my = HZ - 130 + (1 - wb) * 250, mx = W / 2 - camX * 0.06 + 70;
426 if (wb > 0.02) {
427 ctx.globalAlpha = wb;
428 drawGlow("rgba(80,220,255,1)", mx, my, 280, 0.75);
429 const g = ctx.createRadialGradient(mx - 30, my - 30, 10, mx, my, 105);
430 g.addColorStop(0, "#ffffff"); g.addColorStop(1, "#a7dcff");
431 ctx.fillStyle = g; ctx.beginPath(); ctx.arc(mx, my, 100, 0, 6.283); ctx.fill();
432 ctx.fillStyle = "rgba(90,120,190,0.28)";
433 [[-32, -20, 22], [30, 18, 30], [-6, 42, 14], [38, -40, 12]].forEach((q) => { ctx.beginPath(); ctx.arc(mx + q[0], my + q[1], q[2], 0, 6.283); ctx.fill(); });
434 // phase crescent shadow
435 ctx.fillStyle = RA("skyMid", 0.35); ctx.beginPath(); ctx.arc(mx + 26, my - 6, 92, 0, 6.283); ctx.fill();
436 ctx.globalAlpha = 1;
437 }
438 ctx.restore();
439 }
440 const layers = [];
441 function makeSkyline() {
442 const specs = [{ n: 26, hMin: 30, hMax: 95, key: "bFar", par: 0.04, drift: 3 }, { n: 20, hMin: 50, hMax: 150, key: "bMid", par: 0.09, drift: 8 }, { n: 14, hMin: 70, hMax: 200, key: "bNear", par: 0.16, drift: 16 }];
443 const r = mulberry(99);
444 specs.forEach((sp, li) => {
445 const TW = 1500, bs = []; let x = 0;
446 for (let i = 0; i < sp.n; i++) {
447 const w = TW / sp.n * (0.7 + r() * 0.6), h = sp.hMin + r() * (sp.hMax - sp.hMin), b = { x, w, h, ant: r() < 0.25, win: [] };
448 if (li === 2) for (let k = 0; k < 24; k++) b.win.push([Math.floor(r() * 5), Math.floor(r() * 12), r() < 0.5 ? 0 : 1]);
449 bs.push(b); x += w;
450 }
451 layers.push({ sp, bs, TW: x });
452 });
453 }
454 makeSkyline();
455 function drawSkyline() {
456 layers.forEach((L, li) => {
457 const off = (-camX * L.sp.par * 3 - T * L.sp.drift) % L.TW;
458 ctx.fillStyle = CC[L.sp.key];
459 for (let rep = -1; rep < 3; rep++) {
460 const bx0 = off + rep * L.TW;
461 if (bx0 > W + 50 || bx0 + L.TW < -50) continue;
462 for (const b of L.bs) {
463 const x = bx0 + b.x; if (x > W + 60 || x + b.w < -60) continue;
464 ctx.fillStyle = CC[L.sp.key]; ctx.fillRect(x, HZ - b.h, b.w - 2, b.h + 6);
465 if (b.ant) { ctx.fillRect(x + b.w / 2 - 1, HZ - b.h - 24, 2, 24); }
466 if (li >= 1) { ctx.fillStyle = RA("accent", 0.35 * wb + 0.12 * (1 - wb)); ctx.fillRect(x, HZ - b.h, b.w - 2, 2); }
467 if (li === 2 && wb > 0.05) {
468 ctx.fillStyle = "rgba(41,240,255," + 0.75 * wb + ")";
469 ctx.beginPath();
470 for (const w of b.win) { const wx = x + 5 + w[0] * ((b.w - 14) / 5), wy = HZ - b.h + 10 + w[1] * 12; if (wy < HZ - 4 && w[2] === 0) ctx.rect(wx, wy, 4, 5); }
471 ctx.fill();
472 ctx.fillStyle = "rgba(255,80,200," + 0.7 * wb + ")"; ctx.beginPath();
473 for (const w of b.win) { const wx = x + 5 + w[0] * ((b.w - 14) / 5), wy = HZ - b.h + 10 + w[1] * 12; if (wy < HZ - 4 && w[2] === 1) ctx.rect(wx, wy, 4, 5); }
474 ctx.fill();
475 }
476 }
477 }
478 });
479 }
480 function drawSky() {
481 skyBands();
482 ctx.globalAlpha = wb;
483 ctx.fillStyle = "#fff";
484 for (const s of stars) { ctx.globalAlpha = wb * (0.5 + 0.5 * Math.sin(T * 2 + s.p)); ctx.fillRect(s.x - camX * 0.02, s.y, s.s, s.s); }
485 ctx.globalAlpha = 1;
486 drawSun();
487 if (wb < 0.98) {
488 ctx.globalAlpha = (1 - wb) * 0.5;
489 for (const cl of clouds) { const x = ((cl.x + T * cl.s - camX * 0.05) % (W + 300)) - 150; const g = ctx.createLinearGradient(x, 0, x + cl.w, 0); g.addColorStop(0, "rgba(255,240,220,0)"); g.addColorStop(0.5, "rgba(255,240,220,0.9)"); g.addColorStop(1, "rgba(255,240,220,0)"); ctx.fillStyle = g; ctx.fillRect(x, cl.y, cl.w, 5); ctx.fillRect(x + 30, cl.y + 10, cl.w * 0.6, 3); }
490 ctx.globalAlpha = 1;
491 }
492 drawSkyline();
493 // horizon haze
494 const g = ctx.createLinearGradient(0, HZ - 60, 0, HZ + 30);
495 g.addColorStop(0, RA("skyHor", 0)); g.addColorStop(0.7, RA("skyHor", 0.7)); g.addColorStop(1, RA("skyHor", 0));
496 ctx.fillStyle = g; ctx.fillRect(-100, HZ - 60, W + 200, 90);
497 }
498 function drawGround() {
499 const g = ctx.createLinearGradient(0, HZ, 0, H + 100);
500 g.addColorStop(0, CC.roadFar); g.addColorStop(0.25, CC.gnd); g.addColorStop(1, CC.gnd);
501 ctx.fillStyle = g; ctx.fillRect(-100, HZ, W + 200, H - HZ + 200);
502 // ground grid
503 const GRID = 200, zoff = D % GRID;
504 ctx.lineWidth = 1.5;
505 for (let z = -zoff - 40; z < ZFAR; z += GRID) {
506 if (z < -70) continue;
507 const y = Y(z); ctx.strokeStyle = RA("grid", 0.55 * (1 - z / ZFAR) + 0.05);
508 ctx.beginPath(); ctx.moveTo(-100, y); ctx.lineTo(W + 100, y); ctx.stroke();
509 }
510 for (let i = -9; i <= 9; i++) {
511 const wx = i * 170, x0 = X(wx, ZFAR), y0 = Y(ZFAR), x1 = X(wx, -70), y1 = Y(-70);
512 ctx.strokeStyle = RA("grid", 0.4); ctx.beginPath(); ctx.moveTo(x0, y0); ctx.lineTo(x1, y1); ctx.stroke();
513 }
514 // road
515 const zn = -70, zf = ZFAR;
516 const rg = ctx.createLinearGradient(0, HZ, 0, H);
517 rg.addColorStop(0, RA("roadFar", 0.95)); rg.addColorStop(0.35, RA("road", 0.94)); rg.addColorStop(1, RA("road", 0.98));
518 ctx.fillStyle = rg; ctx.beginPath();
519 ctx.moveTo(X(-RH, zf), Y(zf)); ctx.lineTo(X(RH, zf), Y(zf)); ctx.lineTo(X(RH, zn), Y(zn)); ctx.lineTo(X(-RH, zn), Y(zn)); ctx.closePath(); ctx.fill();
520 // current lane highlight
521 const lw = pl.x - 1;
522 ctx.fillStyle = RA("accent", 0.07); ctx.beginPath();
523 ctx.moveTo(X((lw - 0.5) * LW, zf), Y(zf)); ctx.lineTo(X((lw + 0.5) * LW, zf), Y(zf)); ctx.lineTo(X((lw + 0.5) * LW, zn), Y(zn)); ctx.lineTo(X((lw - 0.5) * LW, zn), Y(zn)); ctx.closePath(); ctx.fill();
524 // cross lines on road
525 for (let z = -zoff - 40; z < ZFAR; z += GRID) {
526 if (z < -70) continue;
527 ctx.strokeStyle = RA("grid", 0.22 * (1 - z / ZFAR)); ctx.lineWidth = 2;
528 ctx.beginPath(); ctx.moveTo(X(-RH, z), Y(z)); ctx.lineTo(X(RH, z), Y(z)); ctx.stroke();
529 }
530 // lane dashes
531 const per = 240, doff = D % per;
532 for (const lx of [-LW / 2, LW / 2]) {
533 for (let z = -doff - per; z < ZFAR; z += per) {
534 const za = Math.max(z, -70), zb = z + 110; if (zb < -70) continue;
535 ctx.strokeStyle = RA("edge", 0.55 * (1 - za / ZFAR) + 0.1); ctx.lineWidth = Math.max(1, 7 * S((za + zb) / 2));
536 ctx.beginPath(); ctx.moveTo(X(lx, za), Y(za)); ctx.lineTo(X(lx, zb), Y(zb)); ctx.stroke();
537 }
538 }
539 // edges
540 for (const sx of [-1, 1]) {
541 ctx.strokeStyle = RA("edge", 0.25); ctx.lineWidth = 16; ctx.beginPath(); ctx.moveTo(X(sx * RH, zf), Y(zf)); ctx.lineTo(X(sx * RH, zn), Y(zn)); ctx.stroke();
542 ctx.strokeStyle = CC.edge; ctx.lineWidth = 4; ctx.stroke();
543 ctx.strokeStyle = RA("edge2", 0.7); ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(X(sx * (RH + 26), zf), Y(zf)); ctx.lineTo(X(sx * (RH + 26), zn), Y(zn)); ctx.stroke();
544 }
545 }
546 function bhash(k) { let x = Math.sin(k * 127.1 + 311.7) * 43758.5453; return x - Math.floor(x); }
547 function drawBuildings() {
548 const BSP = 420, base = Math.floor(D / BSP);
549 for (let i = Math.ceil(ZFAR / BSP); i >= -1; i--) {
550 const k = base + i, zb = k * BSP - D;
551 if (zb > ZFAR || zb < -140) continue;
552 for (const sd of [-1, 1]) {
553 const hh = bhash(k * 2 + (sd > 0 ? 1 : 0));
554 const hgt = 260 + hh * 520, wid = 200 + bhash(k * 3 + sd) * 140, dep = 300, xin = RH + 70 + bhash(k + 9 + sd) * 30;
555 const z0 = zb, z1 = zb + dep;
556 const fog = clamp((ZFAR - z0) / 900, 0, 1);
557 if (fog <= 0) continue;
558 const xi0 = X(sd * xin, z0), xo0 = X(sd * (xin + wid), z0), xi1 = X(sd * xin, z1), yb0 = Y(z0), yb1 = Y(z1);
559 const s0 = S(z0), s1 = S(z1), yt0 = yb0 - hgt * s0, yt1 = yb1 - hgt * s1;
560 ctx.globalAlpha = fog;
561 // inner side face
562 ctx.fillStyle = CC.side; ctx.beginPath(); ctx.moveTo(xi0, yb0); ctx.lineTo(xi1, yb1); ctx.lineTo(xi1, yt1); ctx.lineTo(xi0, yt0); ctx.closePath(); ctx.fill();
563 // front face
564 const fg = ctx.createLinearGradient(0, yt0, 0, yb0); fg.addColorStop(0, CC.face); fg.addColorStop(1, CC.bNear);
565 ctx.fillStyle = fg; ctx.fillRect(Math.min(xi0, xo0), yt0, Math.abs(xo0 - xi0), yb0 - yt0);
566 // top (if eye above roof)
567 if (hgt < GY - HZ) { ctx.fillStyle = CC.bMid; ctx.beginPath(); ctx.moveTo(xi0, yt0); ctx.lineTo(xo0, yt0); ctx.lineTo(X(sd * (xin + wid), z1), yt1); ctx.lineTo(xi1, yt1); ctx.closePath(); ctx.fill(); }
568 // neon strips on inner face
569 ctx.lineWidth = Math.max(1, 3 * s0);
570 const nst = 1 + Math.floor(hh * 4);
571 for (let n = 1; n <= nst; n++) {
572 const hgh = (hgt * n) / (nst + 1);
573 ctx.strokeStyle = n % 2 ? RA("accent", 0.85) : RA("accent2", 0.85);
574 ctx.beginPath(); ctx.moveTo(xi0, yb0 - hgh * s0); ctx.lineTo(xi1, yb1 - hgh * s1); ctx.stroke();
575 }
576 // vertical accent edge on front
577 ctx.strokeStyle = RA("accent", 0.9); ctx.lineWidth = Math.max(1, 4 * s0);
578 ctx.beginPath(); ctx.moveTo(xi0, yt0); ctx.lineTo(xi0, yb0); ctx.stroke();
579 // windows on front
580 ctx.fillStyle = RA("accent", 0.35 + 0.35 * wb); ctx.beginPath();
581 const cols = 4, rws = Math.floor(hgt / 70);
582 const fw = Math.abs(xo0 - xi0);
583 for (let ry = 0; ry < rws; ry++) for (let cx = 0; cx < cols; cx++) {
584 if (bhash(k * 7 + ry * 13 + cx * 5 + sd) < 0.55) continue;
585 const wx = Math.min(xi0, xo0) + fw * (0.1 + cx * 0.22), wy = yb0 - (ry + 0.7) * 70 * s0;
586 ctx.rect(wx, wy, fw * 0.12, 26 * s0);
587 }
588 ctx.fill();
589 ctx.globalAlpha = 1;
590 }
591 }
592 }
593
594 // ---------- drawing: objects ----------
595 const WCOL = [{ main: "#ffb020", hi: "#ffe27a", lo: "#c4501a", glow: "rgba(255,150,40,1)" }, { main: "#7a4dff", hi: "#b9a0ff", lo: "#2a1478", glow: "rgba(60,230,255,1)", line: "#29f0ff" }];
596 function drawBlock(x, gy, s, w, ghost, a) {
597 const bw = 112 * s, bh = 118 * s, dep = 52;
598 const bz = 0; // depth offset in screen via s
599 const topOff = 20 * s;
600 const P = WCOL[w];
601 ctx.globalAlpha = a;
602 if (w === 0) {
603 if (ghost) { ctx.strokeStyle = P.main; ctx.lineWidth = 2; ctx.setLineDash([6, 5]); ctx.strokeRect(x - bw / 2, gy - bh, bw, bh); ctx.setLineDash([]); ctx.globalAlpha = 1; return; }
604 drawGlow(P.glow, x, gy - bh * 0.5, bw * 1.4, 0.35 * a);
605 // top face
606 ctx.fillStyle = P.hi; ctx.beginPath(); ctx.moveTo(x - bw / 2, gy - bh); ctx.lineTo(x + bw / 2, gy - bh); ctx.lineTo(x + bw / 2 - bw * 0.06, gy - bh - topOff); ctx.lineTo(x - bw / 2 + bw * 0.06, gy - bh - topOff); ctx.closePath(); ctx.fill();
607 const g = ctx.createLinearGradient(0, gy - bh, 0, gy); g.addColorStop(0, P.main); g.addColorStop(1, P.lo);
608 ctx.fillStyle = g; ctx.fillRect(x - bw / 2, gy - bh, bw, bh);
609 // hazard chevrons
610 ctx.save(); ctx.beginPath(); ctx.rect(x - bw / 2, gy - bh * 0.62, bw, bh * 0.3); ctx.clip();
611 ctx.fillStyle = "rgba(60,15,20,0.75)";
612 for (let i = -2; i < 8; i++) { const sx = x - bw / 2 + i * bw * 0.22; ctx.beginPath(); ctx.moveTo(sx, gy - bh * 0.32); ctx.lineTo(sx + bw * 0.11, gy - bh * 0.32); ctx.lineTo(sx + bw * 0.22, gy - bh * 0.62); ctx.lineTo(sx + bw * 0.11, gy - bh * 0.62); ctx.closePath(); ctx.fill(); }
613 ctx.restore();
614 ctx.strokeStyle = "#fff2b8"; ctx.lineWidth = Math.max(1, 3 * s); ctx.strokeRect(x - bw / 2, gy - bh, bw, bh);
615 ctx.fillStyle = "#fff6d0"; ctx.beginPath(); ctx.arc(x, gy - bh * 0.83, bh * 0.07, 0, 6.283); ctx.fill();
616 } else {
617 // obelisk
618 const path = () => { ctx.beginPath(); ctx.moveTo(x - bw / 2, gy); ctx.lineTo(x - bw * 0.3, gy - bh * 0.8); ctx.lineTo(x, gy - bh * 1.22); ctx.lineTo(x + bw * 0.3, gy - bh * 0.8); ctx.lineTo(x + bw / 2, gy); ctx.closePath(); };
619 if (ghost) { path(); ctx.strokeStyle = P.line; ctx.lineWidth = 2; ctx.setLineDash([6, 5]); ctx.stroke(); ctx.setLineDash([]); ctx.globalAlpha = 1; return; }
620 drawGlow(P.glow, x, gy - bh * 0.55, bw * 1.5, 0.5 * a);
621 const g = ctx.createLinearGradient(0, gy - bh * 1.2, 0, gy); g.addColorStop(0, "#9a7bff"); g.addColorStop(0.5, P.main); g.addColorStop(1, P.lo);
622 path(); ctx.fillStyle = g; ctx.fill();
623 ctx.fillStyle = "rgba(255,255,255,0.13)"; ctx.beginPath(); ctx.moveTo(x, gy - bh * 1.22); ctx.lineTo(x + bw * 0.3, gy - bh * 0.8); ctx.lineTo(x + bw / 2, gy); ctx.lineTo(x, gy); ctx.closePath(); ctx.fill();
624 path(); ctx.strokeStyle = P.line; ctx.lineWidth = Math.max(1.5, 3 * s); ctx.stroke();
625 ctx.beginPath(); ctx.moveTo(x, gy - bh * 1.22); ctx.lineTo(x, gy); ctx.strokeStyle = "rgba(120,250,255,0.7)"; ctx.lineWidth = Math.max(1, 2 * s); ctx.stroke();
626 for (let i = 1; i < 4; i++) { const yy = gy - bh * 0.25 * i, ww = bw * (0.5 - i * 0.05); ctx.beginPath(); ctx.moveTo(x - ww / 2, yy); ctx.lineTo(x + ww / 2, yy); ctx.strokeStyle = "rgba(41,240,255,0.6)"; ctx.stroke(); }
627 }
628 ctx.globalAlpha = 1;
629 }
630 function drawDrone(x, gy, s, w, ghost, a) {
631 const bob = Math.sin(T * 4 + x * 0.05) * 7 * s, cy = gy - 78 * s + bob, r = 50 * s, P = WCOL[w];
632 ctx.globalAlpha = a;
633 // ground shadow
634 ctx.fillStyle = "rgba(0,0,0," + (ghost ? 0 : 0.35) + ")"; ctx.beginPath(); ctx.ellipse(x, gy, r * 0.9, r * 0.22, 0, 0, 6.283); ctx.fill();
635 if (w === 0) {
636 if (ghost) { ctx.strokeStyle = P.main; ctx.lineWidth = 2; ctx.setLineDash([5, 5]); ctx.beginPath(); ctx.arc(x, cy, r, 0, 6.283); ctx.stroke(); ctx.setLineDash([]); ctx.globalAlpha = 1; return; }
637 drawGlow(P.glow, x, cy, r * 2.6, 0.7 * a);
638 ctx.fillStyle = "#ff7a2a"; const rot = T * 1.5;
639 for (let i = 0; i < 10; i++) { const an = rot + (i * 6.283) / 10; ctx.beginPath(); ctx.moveTo(x + Math.cos(an - 0.13) * r * 0.9, cy + Math.sin(an - 0.13) * r * 0.9); ctx.lineTo(x + Math.cos(an) * r * 1.5, cy + Math.sin(an) * r * 1.5); ctx.lineTo(x + Math.cos(an + 0.13) * r * 0.9, cy + Math.sin(an + 0.13) * r * 0.9); ctx.closePath(); ctx.fill(); }
640 const g = ctx.createRadialGradient(x - r * 0.3, cy - r * 0.3, 1, x, cy, r); g.addColorStop(0, "#fff7c4"); g.addColorStop(0.6, "#ffb020"); g.addColorStop(1, "#e2452a");
641 ctx.fillStyle = g; ctx.beginPath(); ctx.arc(x, cy, r, 0, 6.283); ctx.fill();
642 ctx.fillStyle = "#3a0d1a"; ctx.beginPath(); ctx.ellipse(x, cy, r * 0.55, r * 0.26, 0, 0, 6.283); ctx.fill();
643 ctx.fillStyle = "#ffef9a"; ctx.beginPath(); ctx.arc(x, cy, r * 0.13, 0, 6.283); ctx.fill();
644 } else {
645 const path = () => { ctx.beginPath(); ctx.moveTo(x, cy - r * 1.25); ctx.lineTo(x + r * 1.05, cy); ctx.lineTo(x, cy + r * 1.25); ctx.lineTo(x - r * 1.05, cy); ctx.closePath(); };
646 if (ghost) { path(); ctx.strokeStyle = P.line; ctx.lineWidth = 2; ctx.setLineDash([5, 5]); ctx.stroke(); ctx.setLineDash([]); ctx.globalAlpha = 1; return; }
647 drawGlow(P.glow, x, cy, r * 2.8, 0.8 * a);
648 const g = ctx.createLinearGradient(0, cy - r, 0, cy + r); g.addColorStop(0, "#a58cff"); g.addColorStop(1, "#3a1a9a");
649 path(); ctx.fillStyle = g; ctx.fill(); path(); ctx.strokeStyle = P.line; ctx.lineWidth = Math.max(1.5, 3 * s); ctx.stroke();
650 // wings
651 ctx.beginPath(); ctx.moveTo(x - r * 1.05, cy); ctx.lineTo(x - r * 1.7, cy - r * 0.5 + Math.sin(T * 12) * r * 0.2); ctx.lineTo(x - r * 1.05, cy + r * 0.3);
652 ctx.moveTo(x + r * 1.05, cy); ctx.lineTo(x + r * 1.7, cy - r * 0.5 + Math.sin(T * 12) * r * 0.2); ctx.lineTo(x + r * 1.05, cy + r * 0.3); ctx.strokeStyle = P.line; ctx.stroke();
653 ctx.fillStyle = "#e9ffff"; ctx.beginPath(); ctx.ellipse(x, cy, r * 0.32, r * 0.5, 0, 0, 6.283); ctx.fill();
654 ctx.fillStyle = "#1a0a4a"; ctx.beginPath(); ctx.ellipse(x, cy, r * 0.13, r * 0.4, 0, 0, 6.283); ctx.fill();
655 }
656 ctx.globalAlpha = 1;
657 }
658 function drawGap(lx, z, w, ghost, a) {
659 const za = z - 78, zb = z + 78, hw = LW * 0.5 - 4, wx = (lx - 1) * LW, P = WCOL[w];
660 const q = (zz, d) => [X(wx - hw, zz), Y(zz) + d, X(wx + hw, zz), Y(zz) + d];
661 const A = q(za, 0), B = q(zb, 0);
662 ctx.globalAlpha = a;
663 if (ghost) {
664 ctx.strokeStyle = w ? "#29f0ff" : "#ffb020"; ctx.lineWidth = 2; ctx.setLineDash([7, 6]);
665 ctx.beginPath(); ctx.moveTo(A[0], A[1]); ctx.lineTo(A[2], A[3]); ctx.lineTo(B[2], B[3]); ctx.lineTo(B[0], B[1]); ctx.closePath(); ctx.stroke(); ctx.setLineDash([]);
666 ctx.globalAlpha = 1; return;
667 }
668 // hole
669 ctx.fillStyle = "#000"; ctx.beginPath(); ctx.moveTo(A[0], A[1]); ctx.lineTo(A[2], A[3]); ctx.lineTo(B[2], B[3]); ctx.lineTo(B[0], B[1]); ctx.closePath(); ctx.fill();
670 const sb = S(zb); const dpt = 70 * sb;
671 const g = ctx.createLinearGradient(0, B[1], 0, B[1] + dpt); g.addColorStop(0, w ? "#3a1a9a" : "#ff5a2a"); g.addColorStop(1, "#000");
672 ctx.fillStyle = g; ctx.beginPath(); ctx.moveTo(B[0], B[1]); ctx.lineTo(B[2], B[3]); ctx.lineTo(B[2] - (B[2] - B[0]) * 0.1, B[3] + dpt); ctx.lineTo(B[0] + (B[2] - B[0]) * 0.1, B[1] + dpt); ctx.closePath(); ctx.fill();
673 ctx.fillStyle = "rgba(0,0,0,0.92)"; ctx.beginPath(); ctx.moveTo(A[0], A[1]); ctx.lineTo(A[2], A[3]); ctx.lineTo(B[2], B[3]); ctx.lineTo(B[0], B[1]); ctx.closePath(); ctx.fill();
674 ctx.strokeStyle = w ? "#29f0ff" : "#ffb020"; ctx.lineWidth = Math.max(1.5, 4 * S(z)); ctx.shadowColor = P.glow; ctx.shadowBlur = 0;
675 ctx.beginPath(); ctx.moveTo(A[0], A[1]); ctx.lineTo(A[2], A[3]); ctx.lineTo(B[2], B[3]); ctx.lineTo(B[0], B[1]); ctx.closePath(); ctx.stroke();
676 drawGlow(P.glow, (A[0] + A[2]) / 2, (A[1] + B[1]) / 2, Math.abs(A[2] - A[0]) * 0.9, 0.45 * a);
677 // warning stripes on lip
678 ctx.strokeStyle = w ? "rgba(160,120,255,0.8)" : "rgba(255,230,120,0.9)"; ctx.lineWidth = Math.max(1, 2.5 * S(z));
679 for (let i = 1; i < 5; i++) { const t = i / 5; ctx.beginPath(); ctx.moveTo(lerp(A[0], A[2], t), A[1]); ctx.lineTo(lerp(B[0], B[2], t), B[1]); ctx.stroke(); }
680 ctx.globalAlpha = 1;
681 }
682 function drawCoin(x, gy, s, hot) {
683 const r = 24 * s, cy = gy - 52 * s + Math.sin(T * 5 + x) * 3 * s;
684 if (hot) drawGlow("rgba(255,215,60,1)", x, cy, r * 3.2, 0.9);
685 else drawGlow("rgba(255,200,60,1)", x, cy, r * 2.2, 0.45);
686 const sp = Math.abs(Math.cos(T * 6 + x * 0.02)), rw = Math.max(r * 0.18, r * sp);
687 ctx.fillStyle = "#c98a00"; ctx.beginPath(); ctx.ellipse(x, cy, rw + r * 0.1, r * 1.04, 0, 0, 6.283); ctx.fill();
688 const g = ctx.createLinearGradient(x - rw, cy - r, x + rw, cy + r); g.addColorStop(0, "#fff6b0"); g.addColorStop(0.5, "#ffd23a"); g.addColorStop(1, "#ff9d00");
689 ctx.fillStyle = g; ctx.beginPath(); ctx.ellipse(x, cy, rw, r, 0, 0, 6.283); ctx.fill();
690 if (rw > r * 0.5) { ctx.strokeStyle = "rgba(160,90,0,0.6)"; ctx.lineWidth = Math.max(1, 2 * s); ctx.beginPath(); ctx.ellipse(x, cy, rw * 0.62, r * 0.62, 0, 0, 6.283); ctx.stroke(); }
691 }
692 function drawPower(x, gy, s, pt) {
693 const r = 34 * s, cy = gy - 70 * s + Math.sin(T * 3) * 6 * s;
694 const col = pt === "magnet" ? "rgba(255,80,110,1)" : pt === "shield" ? "rgba(90,240,255,1)" : "rgba(180,130,255,1)";
695 drawGlow(col, x, cy, r * 3.2, 0.9);
696 ctx.save(); ctx.translate(x, cy); ctx.rotate(Math.sin(T * 2) * 0.15);
697 ctx.fillStyle = "rgba(10,8,30,0.85)"; ctx.beginPath(); for (let i = 0; i < 6; i++) { const a = (i * 6.283) / 6 + 0.5236; ctx.lineTo(Math.cos(a) * r * 1.15, Math.sin(a) * r * 1.15); } ctx.closePath(); ctx.fill();
698 ctx.strokeStyle = col.replace("1)", "1)"); ctx.lineWidth = Math.max(2, 4 * s); ctx.stroke();
699 iconPower(pt, r * 0.7, "#fff"); ctx.restore();
700 }
701 function iconPower(pt, r, col) {
702 ctx.strokeStyle = col; ctx.fillStyle = col; ctx.lineWidth = Math.max(2, r * 0.28); ctx.lineCap = "round";
703 if (pt === "magnet") { ctx.beginPath(); ctx.arc(0, -r * 0.1, r * 0.7, 0, Math.PI); ctx.lineTo(-r * 0.7, -r * 0.8); ctx.moveTo(r * 0.7, -r * 0.1); ctx.lineTo(r * 0.7, -r * 0.8); ctx.stroke(); }
704 else if (pt === "shield") { ctx.beginPath(); ctx.moveTo(0, -r); ctx.lineTo(r * 0.85, -r * 0.55); ctx.lineTo(r * 0.7, r * 0.3); ctx.quadraticCurveTo(0, r * 1.1, -r * 0.7, r * 0.3); ctx.lineTo(-r * 0.85, -r * 0.55); ctx.closePath(); ctx.stroke(); }
705 else { ctx.beginPath(); ctx.arc(0, 0, r * 0.85, 0, 6.283); ctx.stroke(); ctx.beginPath(); ctx.moveTo(0, -r * 0.5); ctx.lineTo(0, 0); ctx.lineTo(r * 0.4, r * 0.25); ctx.stroke(); }
706 ctx.lineCap = "butt";
707 }
708 function drawItem(it, rel, isGhostPass) {
709 const s = S(rel), wx = (it.lx - 1) * LW, x = X(wx, rel), gy = Y(rel);
710 const fog = clamp((ZFAR - rel) / 800, 0, 1) * (rel < 0 ? clamp(1 + rel / 140, 0, 1) : 1);
711 if (fog <= 0) return;
712 if (it.k === "coin") { if (!it.taken) drawCoin(x, gy, s, phaseT > 0); return; }
713 if (it.k === "pow") { if (!it.taken) drawPower(x, gy, s, it.pt); return; }
714 if (it.hit) return;
715 const solid = it.world === 0 ? 1 - wb : wb;
716 const ghostA = (1 - solid) * 0.55;
717 if (it.k === "gap") { if (solid > 0.05) drawGap(it.lane, rel, it.world, false, fog * solid); if (ghostA > 0.05) drawGap(it.lane, rel, it.world, true, fog * ghostA); return; }
718 const fn = it.k === "blk" ? drawBlock : drawDrone;
719 if (solid > 0.05) fn(x, gy, s, it.world, false, fog * solid);
720 if (ghostA > 0.05) fn(x, gy, s, it.world, true, fog * ghostA);
721 }
722 function drawRowsPass(front) {
723 // gaps are painted flat first
724 for (let i = rows.length - 1; i >= 0; i--) {
725 const r = rows[i], rel = r.z - D;
726 if (rel > ZFAR || rel < -140) continue;
727 if ((rel >= 0) === front) continue;
728 for (const it of r.items) if (it.k === "gap") drawItem(it, rel);
729 }
730 for (let i = rows.length - 1; i >= 0; i--) {
731 const r = rows[i], rel = r.z - D;
732 if (rel > ZFAR || rel < -140) continue;
733 if ((rel >= 0) === front) continue;
734 for (const it of r.items) if (it.k !== "gap") drawItem(it, rel);
735 }
736 }
737 function drawPlayer(alpha) {
738 const x = X((pl.x - 1) * LW, 0), gy = GY, hov = 34 + Math.sin(T * 6) * 3;
739 const acc = pl.world ? ["#29f0ff", "#a855ff", "rgba(41,240,255,1)"] : ["#ffb020", "#ff4d6d", "rgba(255,150,40,1)"];
740 const tilt = clamp(pl.vx * 0.0012 + (pl.lane - pl.x) * 0.25, -0.5, 0.5);
741 if (invulnT > 0 && Math.floor(T * 20) % 2) alpha *= 0.4;
742 ctx.globalAlpha = alpha;
743 ctx.fillStyle = "rgba(0,0,0,0.45)"; ctx.beginPath(); ctx.ellipse(x, gy + 6, 62, 13, 0, 0, 6.283); ctx.fill();
744 drawGlow(acc[2], x, gy + 4, 90, 0.5);
745 ctx.save(); ctx.translate(x, gy - hov); ctx.rotate(tilt);
746 // flames
747 const fl = 26 + Math.random() * 12 + (speed - 600) * 0.02;
748 for (const ex of [-24, 24]) {
749 drawGlow(acc[2], ex, 6, 44, 0.8);
750 const g = ctx.createLinearGradient(0, 0, 0, fl); g.addColorStop(0, "#ffffff"); g.addColorStop(0.3, acc[0]); g.addColorStop(1, "rgba(0,0,0,0)");
751 ctx.fillStyle = g; ctx.beginPath(); ctx.moveTo(ex - 9, 0); ctx.lineTo(ex, fl); ctx.lineTo(ex + 9, 0); ctx.closePath(); ctx.fill();
752 }
753 // wings
754 const wing = ctx.createLinearGradient(0, -80, 0, 10); wing.addColorStop(0, "#f4f8ff"); wing.addColorStop(0.5, "#b9c4e8"); wing.addColorStop(1, "#5a5f9a");
755 ctx.fillStyle = "#23204a"; ctx.beginPath(); ctx.moveTo(-70, 2); ctx.lineTo(-24, -58); ctx.lineTo(0, -84); ctx.lineTo(24, -58); ctx.lineTo(70, 2); ctx.lineTo(36, 8); ctx.lineTo(0, -4); ctx.lineTo(-36, 8); ctx.closePath(); ctx.fill();
756 ctx.fillStyle = wing; ctx.beginPath(); ctx.moveTo(-64, -3); ctx.lineTo(-22, -56); ctx.lineTo(0, -80); ctx.lineTo(22, -56); ctx.lineTo(64, -3); ctx.lineTo(32, 2); ctx.lineTo(0, -10); ctx.lineTo(-32, 2); ctx.closePath(); ctx.fill();
757 // accent stripes
758 ctx.fillStyle = acc[0]; ctx.beginPath(); ctx.moveTo(-64, -3); ctx.lineTo(-48, -22); ctx.lineTo(-30, -6); ctx.lineTo(-32, 2); ctx.closePath(); ctx.fill();
759 ctx.beginPath(); ctx.moveTo(64, -3); ctx.lineTo(48, -22); ctx.lineTo(30, -6); ctx.lineTo(32, 2); ctx.closePath(); ctx.fill();
760 ctx.fillStyle = acc[1]; ctx.beginPath(); ctx.moveTo(-10, -22); ctx.lineTo(0, -60); ctx.lineTo(10, -22); ctx.lineTo(0, -10); ctx.closePath(); ctx.fill();
761 // canopy
762 const cg = ctx.createLinearGradient(0, -70, 0, -30); cg.addColorStop(0, "#ffffff"); cg.addColorStop(1, acc[0]);
763 ctx.fillStyle = "#12102e"; ctx.beginPath(); ctx.ellipse(0, -42, 13, 21, 0, 0, 6.283); ctx.fill();
764 ctx.fillStyle = cg; ctx.globalAlpha = alpha * 0.85; ctx.beginPath(); ctx.ellipse(0, -46, 8, 14, 0, 0, 6.283); ctx.fill(); ctx.globalAlpha = alpha;
765 ctx.strokeStyle = "rgba(255,255,255,0.85)"; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(-64, -3); ctx.lineTo(-22, -56); ctx.lineTo(0, -80); ctx.lineTo(22, -56); ctx.lineTo(64, -3); ctx.stroke();
766 if (shield) {
767 const pulse = 1 + Math.sin(T * 8) * 0.04;
768 ctx.beginPath(); ctx.arc(0, -36, 84 * pulse, 0, 6.283); const sg = ctx.createRadialGradient(0, -36, 40, 0, -36, 84); sg.addColorStop(0, "rgba(90,240,255,0)"); sg.addColorStop(1, "rgba(90,240,255,0.4)");
769 ctx.fillStyle = sg; ctx.fill(); ctx.strokeStyle = "rgba(160,250,255,0.9)"; ctx.lineWidth = 3; ctx.stroke();
770 }
771 ctx.restore(); ctx.globalAlpha = 1;
772 }
773 function drawSpeedLines() {
774 const k = clamp((speed - 640) / 700, 0, 1) + (slowT > 0 ? 0 : 0);
775 if (k <= 0.02 && state !== "title") return;
776 const vx = W / 2, vy = HZ + 20;
777 ctx.lineCap = "round";
778 for (const l of lines) {
779 l.r += (0.7 + k * 1.8) * 0.016 * (0.6 + l.l * 2); if (l.r > 1) { l.r = 0.05; l.a = rnd(0, 6.283); l.l = rnd(0.1, 0.35); }
780 const r0 = l.r * l.r * 760 + 60, r1 = r0 + l.l * 240 * (0.4 + k) * l.r;
781 const ax = Math.cos(l.a), ay = Math.sin(l.a) * 1.15;
782 if (Math.abs(ax) < 0.2 && ay < 0) continue;
783 ctx.strokeStyle = RA("accent", (0.12 + 0.35 * k) * l.r); ctx.lineWidth = 1 + l.r * 3;
784 ctx.beginPath(); ctx.moveTo(vx + ax * r0, vy + ay * r0); ctx.lineTo(vx + ax * r1, vy + ay * r1); ctx.stroke();
785 }
786 ctx.lineCap = "butt";
787 }
788 function drawParticles() {
789 ctx.globalCompositeOperation = "lighter";
790 for (const p of parts) {
791 const a = clamp(p.life / p.max, 0, 1); ctx.globalAlpha = a; ctx.fillStyle = p.col;
792 ctx.fillRect(p.x - p.size / 2, p.y - p.size / 2, p.size, p.size);
793 }
794 for (const r of rings) {
795 const t = r.t, px = X((pl.x - 1) * LW, 0), py = GY - 50, rad = 20 + t * 900;
796 const col = r.world ? "41,240,255" : "255,176,32";
797 const g = ctx.createRadialGradient(px, py, Math.max(0, rad - 90), px, py, rad + 4);
798 g.addColorStop(0, "rgba(" + col + ",0)"); g.addColorStop(0.85, "rgba(" + col + "," + 0.35 * (1 - t) + ")"); g.addColorStop(1, "rgba(255,255,255," + 0.9 * (1 - t) + ")");
799 ctx.globalAlpha = 1; ctx.fillStyle = g; ctx.beginPath(); ctx.arc(px, py, rad + 4, 0, 6.283); ctx.fill();
800 }
801 ctx.globalAlpha = 1; ctx.globalCompositeOperation = "source-over";
802 }
803 function chroma() {
804 const k = Math.max(swapFx, hurtFx * 0.8);
805 if (k < 0.05) return;
806 const w = c.width, h = c.height;
807 if (!bufR || bufR.width !== w) { bufR = document.createElement("canvas"); bufC = document.createElement("canvas"); bufR.width = bufC.width = w; bufR.height = bufC.height = h; }
808 const a = bufR.getContext("2d"), b = bufC.getContext("2d");
809 a.globalCompositeOperation = "copy"; a.drawImage(c, 0, 0); a.globalCompositeOperation = "multiply"; a.fillStyle = "#ff0000"; a.fillRect(0, 0, w, h);
810 b.globalCompositeOperation = "copy"; b.drawImage(c, 0, 0); b.globalCompositeOperation = "multiply"; b.fillStyle = "#00ffff"; b.fillRect(0, 0, w, h);
811 ctx.save(); ctx.setTransform(1, 0, 0, 1, 0, 0);
812 const d = Math.round(k * 16 * (w / W));
813 ctx.globalCompositeOperation = "source-over"; ctx.fillStyle = "#000"; ctx.fillRect(0, 0, w, h);
814 ctx.globalCompositeOperation = "lighter"; ctx.drawImage(bufR, d, 0); ctx.drawImage(bufC, -d, 0);
815 ctx.restore();
816 }
817
818 // ---------- HUD & screens ----------
819 function drawPill(x, y, w, h, fill, stroke) { rr(x, y, w, h, h / 2); ctx.fillStyle = fill; ctx.fill(); if (stroke) { ctx.strokeStyle = stroke; ctx.lineWidth = 2; ctx.stroke(); } }
820 function drawMute() {
821 const cx = MUTE.x + MUTE.w / 2, cy = MUTE.y + MUTE.h / 2;
822 ctx.fillStyle = "rgba(8,6,30,0.55)"; ctx.beginPath(); ctx.arc(cx, cy, 24, 0, 6.283); ctx.fill();
823 ctx.strokeStyle = "rgba(255,255,255,0.35)"; ctx.lineWidth = 2; ctx.stroke();
824 ctx.fillStyle = "#fff"; ctx.strokeStyle = "#fff"; ctx.lineWidth = 2.5; ctx.lineCap = "round";
825 ctx.beginPath(); ctx.moveTo(cx - 10, cy - 4); ctx.lineTo(cx - 5, cy - 4); ctx.lineTo(cx + 2, cy - 10); ctx.lineTo(cx + 2, cy + 10); ctx.lineTo(cx - 5, cy + 4); ctx.lineTo(cx - 10, cy + 4); ctx.closePath(); ctx.fill();
826 if (muted) { ctx.strokeStyle = "#ff6b7a"; ctx.beginPath(); ctx.moveTo(cx + 7, cy - 6); ctx.lineTo(cx + 16, cy + 6); ctx.moveTo(cx + 16, cy - 6); ctx.lineTo(cx + 7, cy + 6); ctx.stroke(); }
827 else { ctx.beginPath(); ctx.arc(cx + 3, cy, 7, -0.9, 0.9); ctx.stroke(); ctx.beginPath(); ctx.arc(cx + 3, cy, 12, -0.9, 0.9); ctx.stroke(); }
828 ctx.lineCap = "butt";
829 }
830 function drawHUD() {
831 const sc = totalScore();
832 const k = 1 + scorePop * 0.18;
833 ctx.save(); ctx.translate(W / 2, 66); ctx.scale(k, k);
834 txt(String(sc), 0, 0, 62, "#fff", "center", 900, pl.world ? "#29f0ff" : "#ff9a3d"); ctx.restore();
835 txt("BEST " + Math.max(best, sc), W / 2, 112, 18, "rgba(255,255,255,0.75)", "center", 700);
836 // coins
837 drawPill(20, 28, 116, 40, "rgba(8,6,30,0.5)", "rgba(255,255,255,0.25)");
838 ctx.fillStyle = "#ffd23a"; ctx.beginPath(); ctx.arc(44, 48, 11, 0, 6.283); ctx.fill(); ctx.strokeStyle = "#a86a00"; ctx.lineWidth = 2; ctx.stroke();
839 txt(String(coins), 64, 49, 24, "#fff", "left", 800);
840 drawMute();
841 // streak chip
842 if (streak > 0) {
843 const mult = 2 + Math.min(streak - (phaseGot ? 1 : 0), 6);
844 const w = 200; drawPill(W / 2 - w / 2, 134, w, 34, "rgba(8,6,30,0.55)", pl.world ? "#29f0ff" : "#ffb020");
845 txt("PHASE STREAK x" + streak, W / 2, 151, 18, pl.world ? "#7ffcff" : "#ffe27a", "center", 800);
846 if (phaseT > 0) { ctx.fillStyle = pl.world ? "#29f0ff" : "#ffb020"; ctx.fillRect(W / 2 - w / 2 + 14, 166, (w - 28) * phaseT, 3); }
847 } else if (phaseT > 0) {
848 txt("PHASE BONUS", W / 2, 150, 16, "rgba(255,255,255,0.8)", "center", 800);
849 }
850 // power timers
851 let px = 20;
852 const pw = [];
853 if (magnetT > 0) pw.push(["magnet", magnetT / 8, "#ff5c7a"]);
854 if (shield) pw.push(["shield", 1, "#5af0ff"]);
855 if (slowT > 0) pw.push(["slow", slowT / 4, "#c9a4ff"]);
856 for (const p of pw) {
857 ctx.fillStyle = "rgba(8,6,30,0.55)"; ctx.beginPath(); ctx.arc(px + 22, 104, 22, 0, 6.283); ctx.fill();
858 ctx.strokeStyle = "rgba(255,255,255,0.2)"; ctx.lineWidth = 4; ctx.beginPath(); ctx.arc(px + 22, 104, 19, 0, 6.283); ctx.stroke();
859 ctx.strokeStyle = p[2]; ctx.beginPath(); ctx.arc(px + 22, 104, 19, -1.5708, -1.5708 + 6.283 * p[1]); ctx.stroke();
860 ctx.save(); ctx.translate(px + 22, 104); iconPower(p[0], 9, "#fff"); ctx.restore();
861 px += 52;
862 }
863 // world slider
864 const bx = W / 2 - 92, by = H - 54;
865 drawPill(bx, by, 184, 36, "rgba(8,6,30,0.6)", "rgba(255,255,255,0.3)");
866 const kx = lerp(bx + 22, bx + 162, wb);
867 drawGlow(pl.world ? "rgba(41,240,255,1)" : "rgba(255,170,50,1)", kx, by + 18, 34, 0.9);
868 ctx.fillStyle = pl.world ? "#29f0ff" : "#ffb020"; ctx.beginPath(); ctx.arc(kx, by + 18, 14, 0, 6.283); ctx.fill();
869 ctx.fillStyle = "rgba(255,255,255,0.7)"; ctx.font = "800 11px " + FONT; ctx.textAlign = "center";
870 ctx.fillText("DAY", bx + 52, by + 19); ctx.fillText("NIGHT", bx + 132, by + 19);
871 // hint
872 if (hint) { const a = 0.6 + 0.4 * Math.sin(T * 8); ctx.globalAlpha = a; drawPill(W / 2 - 150, 560, 300, 54, "rgba(8,6,30,0.65)", "#fff"); txt(hint, W / 2, 587, 26, "#fff", "center", 900); ctx.globalAlpha = 1; }
873 for (const p of pops) { const a = 1 - p.t; ctx.globalAlpha = clamp(a * 1.6, 0, 1); txt(p.text, p.x, p.y - p.t * 60, p.size, p.col, "center", 900, "rgba(0,0,0,0.8)"); ctx.globalAlpha = 1; }
874 }
875 function logo(cx, y, size) {
876 const words = [["PHASE", 0], ["RUNNER", 1]];
877 ctx.font = "italic 900 " + size + 'px "Arial Black", Impact, ' + FONT; ctx.textAlign = "center"; ctx.textBaseline = "middle";
878 words.forEach((wd, i) => {
879 const yy = y + i * size * 0.98 + Math.sin(T * 2 + i) * 3, off = 6 + Math.sin(T * 3) * 2;
880 const ww = ctx.measureText(wd[0]).width;
881 ctx.globalCompositeOperation = "lighter";
882 ctx.fillStyle = "rgba(255,40,120,0.85)"; ctx.fillText(wd[0], cx - off, yy);
883 ctx.fillStyle = "rgba(20,220,255,0.85)"; ctx.fillText(wd[0], cx + off, yy);
884 ctx.globalCompositeOperation = "source-over";
885 const g = ctx.createLinearGradient(0, yy - size / 2, 0, yy + size / 2);
886 if (i === 0) { g.addColorStop(0, "#fff8d8"); g.addColorStop(0.5, "#ffc95a"); g.addColorStop(1, "#ff6a4a"); } else { g.addColorStop(0, "#e8ffff"); g.addColorStop(0.5, "#56e8ff"); g.addColorStop(1, "#8a5cff"); }
887 // sliced halves (phase glitch)
888 const sl = Math.sin(T * 1.7 + i * 2) * 5 + 4;
889 ctx.save(); ctx.beginPath(); ctx.rect(cx - ww, yy - size, ww * 2, size * 0.56 + 0); ctx.clip();
890 ctx.fillStyle = g; ctx.fillText(wd[0], cx + sl, yy); ctx.restore();
891 ctx.save(); ctx.beginPath(); ctx.rect(cx - ww, yy - size * 0.44, ww * 2, size * 2); ctx.clip();
892 ctx.fillStyle = g; ctx.fillText(wd[0], cx - sl * 0.5, yy); ctx.restore();
893 ctx.strokeStyle = "rgba(255,255,255,0.9)"; ctx.lineWidth = 1.5;
894 });
895 }
896 function drawTitle() {
897 ctx.fillStyle = "rgba(4,2,20,0.35)"; ctx.fillRect(0, 0, W, H);
898 const g = ctx.createLinearGradient(0, 0, 0, 500); g.addColorStop(0, "rgba(4,2,20,0.65)"); g.addColorStop(1, "rgba(4,2,20,0)"); ctx.fillStyle = g; ctx.fillRect(0, 0, W, 500);
899 logo(W / 2, 210, 100);
900 txt(pl.world ? "TWO WORLDS - ONE ROAD" : "TWO WORLDS - ONE ROAD", W / 2, 400, 18, "rgba(255,255,255,0.85)", "center", 700);
901 const a = 0.65 + 0.35 * Math.sin(T * 4);
902 ctx.globalAlpha = a; drawPill(W / 2 - 150, 610, 300, 66, "rgba(255,255,255,0.12)", "#fff"); txt(tutSeen ? "TAP TO START" : "TAP TO PLAY", W / 2, 644, 30, "#fff", "center", 900, pl.world ? "#29f0ff" : "#ff9a3d"); ctx.globalAlpha = 1;
903 txt("BEST " + best, W / 2, 720, 24, "rgba(255,255,255,0.85)", "center", 800);
904 txt("TAP = swap worlds SWIPE = change lane", W / 2, 900, 16, "rgba(255,255,255,0.7)", "center", 600);
905 }
906 function drawTutorial() {
907 ctx.fillStyle = "rgba(4,2,20,0.78)"; ctx.fillRect(0, 0, W, H);
908 txt("HOW TO PHASE", W / 2, 120, 44, "#fff", "center", 900, pl.world ? "#29f0ff" : "#ff9a3d");
909 const items = [
910 ["TAP anywhere", "Swap between DAY and NIGHT", 0],
911 ["SWIPE left / right", "Change lane (or use A / D)", 1],
912 ["SOLID = your world", "Dashed ghosts only exist in the other one", 2],
913 ["PHASE COINS", "Grab coins right after a swap for x2, x3, x4...", 3]
914 ];
915 items.forEach((it, i) => {
916 const y = 210 + i * 130;
917 rr(30, y - 50, W - 60, 108, 22); ctx.fillStyle = "rgba(255,255,255,0.08)"; ctx.fill(); ctx.strokeStyle = "rgba(255,255,255,0.25)"; ctx.lineWidth = 2; ctx.stroke();
918 const ix = 92; ctx.save(); ctx.translate(ix, y + 4);
919 if (it[2] === 0) { const t = (T * 1.2) % 1; ctx.strokeStyle = t < 0.5 ? "#ffb020" : "#29f0ff"; ctx.lineWidth = 4; ctx.beginPath(); ctx.arc(0, 0, 14 + t * 28, 0, 6.283); ctx.globalAlpha = 1 - t; ctx.stroke(); ctx.globalAlpha = 1; ctx.fillStyle = Math.floor(T * 1.2 * 2) % 2 ? "#29f0ff" : "#ffb020"; ctx.beginPath(); ctx.arc(0, 0, 12, 0, 6.283); ctx.fill(); }
920 else if (it[2] === 1) { const o = Math.sin(T * 3) * 18; ctx.fillStyle = "#fff"; ctx.beginPath(); ctx.arc(o, 0, 14, 0, 6.283); ctx.fill(); ctx.strokeStyle = "rgba(255,255,255,0.7)"; ctx.lineWidth = 4; ctx.beginPath(); ctx.moveTo(-34, 0); ctx.lineTo(-24, -8); ctx.moveTo(-34, 0); ctx.lineTo(-24, 8); ctx.moveTo(34, 0); ctx.lineTo(24, -8); ctx.moveTo(34, 0); ctx.lineTo(24, 8); ctx.stroke(); }
921 else if (it[2] === 2) { drawBlock(-14, 34, 0.5, 0, false, 1); drawBlock(24, 34, 0.5, 1, true, 1); }
922 else { drawCoin(0, 40, 1, true); }
923 ctx.restore();
924 txt(it[0], 150, y - 12, 26, "#fff", "left", 900); ctx.font = "600 16px " + FONT; txt(it[1], 150, y + 24, 16, "rgba(255,255,255,0.8)", "left", 600);
925 });
926 const a = 0.65 + 0.35 * Math.sin(T * 4);
927 ctx.globalAlpha = a; drawPill(W / 2 - 130, 790, 260, 62, "rgba(255,255,255,0.15)", "#fff"); txt("TAP TO RUN", W / 2, 821, 28, "#fff", "center", 900); ctx.globalAlpha = 1;
928 }
929 function drawOver() {
930 const k = clamp(overT / 0.45, 0, 1), e = 1 - Math.pow(1 - k, 3);
931 ctx.fillStyle = "rgba(4,2,20," + 0.6 * e + ")"; ctx.fillRect(0, 0, W, H);
932 ctx.save(); ctx.translate(0, (1 - e) * 60); ctx.globalAlpha = e;
933 txt("PHASE LOST", W / 2, 200, 58, "#fff", "center", 900, "#ff3d7a");
934 rr(50, 270, W - 100, 330, 28); ctx.fillStyle = "rgba(12,8,40,0.8)"; ctx.fill(); ctx.strokeStyle = pl.world ? "#29f0ff" : "#ffb020"; ctx.lineWidth = 3; ctx.stroke();
935 txt("SCORE", W / 2, 312, 18, "rgba(255,255,255,0.7)", "center", 800);
936 txt(String(totalScore()), W / 2, 380, 92, "#fff", "center", 900, pl.world ? "#29f0ff" : "#ff9a3d");
937 if (newBest) { const s = 1 + Math.sin(T * 8) * 0.04; ctx.save(); ctx.translate(W / 2, 450); ctx.scale(s, s); drawPill(-90, -18, 180, 36, "#ffd23a"); txt("NEW BEST!", 0, 1, 20, "#3a1a00", "center", 900); ctx.restore(); }
938 else txt("BEST " + best, W / 2, 450, 24, "rgba(255,255,255,0.85)", "center", 800);
939 txt(coins + " coins", W / 2 - 100, 530, 22, "#ffd23a", "center", 800); txt(Math.floor(D / 10) + " m", W / 2 + 100, 530, 22, "#fff", "center", 800);
940 txt("PHASE STREAK " + streak, W / 2, 570, 15, "rgba(255,255,255,0.6)", "center", 700);
941 if (overT > 0.7) { const a = 0.65 + 0.35 * Math.sin(T * 4); ctx.globalAlpha = a; drawPill(W / 2 - 150, 660, 300, 66, "rgba(255,255,255,0.14)", "#fff"); txt("TAP TO RETRY", W / 2, 694, 30, "#fff", "center", 900); }
942 ctx.restore(); ctx.globalAlpha = 1;
943 }
944
945 // ---------- frame ----------
946 function draw() {
947 ctx.setTransform(c.width / W, 0, 0, c.height / H, 0, 0);
948 ctx.save();
949 const sh = shake;
950 ctx.translate(rnd(-sh, sh) * 0.5, rnd(-sh, sh) * 0.5);
951 ctx.translate(W / 2, 700); ctx.rotate(bank * 0.018 + pl.vx * 0.0006 * 0 + (Math.random() - 0.5) * sh * 0.002); ctx.scale(1.03, 1.03); ctx.translate(-W / 2, -700);
952 drawSky(); drawGround(); drawBuildings();
953 drawSpeedLines();
954 drawRowsPass(true);
955 if (state !== "over" && !(state === "dying" && overT > 0.05)) {
956 if (state === "title" || state === "tutorial" || state === "play" || state === "dying") drawPlayer(1);
957 }
958 drawRowsPass(false);
959 drawParticles();
960 // hurt vignette / flash
961 if (hurtFx > 0) { const g = ctx.createRadialGradient(W / 2, H / 2, 200, W / 2, H / 2, 700); g.addColorStop(0, "rgba(255,30,80,0)"); g.addColorStop(1, "rgba(255,30,80," + 0.6 * hurtFx + ")"); ctx.fillStyle = g; ctx.fillRect(-50, -50, W + 100, H + 100); }
962 if (slowT > 0) { const g = ctx.createRadialGradient(W / 2, H / 2, 250, W / 2, H / 2, 700); g.addColorStop(0, "rgba(160,110,255,0)"); g.addColorStop(1, "rgba(160,110,255,0.35)"); ctx.fillStyle = g; ctx.fillRect(-50, -50, W + 100, H + 100); }
963 if (flash > 0) { ctx.fillStyle = "rgba(255,255,255," + 0.45 * flash + ")"; ctx.fillRect(-50, -50, W + 100, H + 100); }
964 ctx.restore();
965 chroma();
966 ctx.setTransform(c.width / W, 0, 0, c.height / H, 0, 0);
967 if (state === "play" || state === "dying") drawHUD();
968 else drawMute();
969 if (state === "title") drawTitle();
970 else if (state === "tutorial") drawTutorial();
971 else if (state === "over") drawOver();
972 }
973
974 let last = performance.now();
975 function frame(now) {
976 requestAnimationFrame(frame);
977 let dt = (now - last) / 1000; last = now;
978 if (paused) return;
979 dt = clamp(dt, 0, 0.05);
980 update(dt); musicTick(); draw();
981 }
982 requestAnimationFrame(frame);
983
984 // test hook (read-only snapshot + power grant)
985 window.__PR = {
986 snap() {
987 return {
988 state, D, speed, runT, score: totalScore(), coins, streak, shield, magnetT, slowT, lane: pl.lane, x: pl.x, world: pl.world, best, firstRun,
989 rows: rows.filter((r) => r.z - D > -70 && r.z - D < 2200).map((r) => ({ rel: r.z - D, str: r.str, items: r.items.filter((i) => !i.taken).map((i) => ({ k: i.k, lane: i.lane, world: i.world })) }))
990 };
991 },
992 grant(t) { if (t === "shield") shield = true; if (t === "magnet") magnetT = 8; if (t === "slow") slowT = 4; }
993 };
994})();
995