arcadia / ghost-lap

Ghost Lap

game.js52.9 KB
1// Ghost Lap - a one-touch neon racer where every lap you finish becomes a deadly ghost.
2(() => {
3 "use strict";
4 const W = 540, H = 960, TAU = Math.PI * 2;
5 const c = document.getElementById("c");
6 const ctx = c.getContext("2d");
7 const FONT = '"Avenir Next","Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif';
8
9 // ---------- helpers ----------
10 const store = {
11 get(k, d) { try { const v = localStorage.getItem(k); return v === null || v === undefined ? d : v; } catch (e) { return d; } },
12 set(k, v) { try { localStorage.setItem(k, String(v)); } catch (e) {} },
13 };
14 const clamp = (v, a, b) => (v < a ? a : v > b ? b : v);
15 const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
16 const lerp = (a, b, t) => a + (b - a) * t;
17 const rand = (a, b) => a + Math.random() * (b - a);
18 const angDiff = (a, b) => { let d = (b - a) % TAU; if (d > Math.PI) d -= TAU; else if (d < -Math.PI) d += TAU; return d; };
19 const ease = (t) => 1 - Math.pow(1 - clamp01(t), 3);
20 function mulberry(a) { return function () { 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; }; }
21 function rr(x, y, w, h, r) {
22 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);
23 ctx.arcTo(x, y + h, x, y, r); ctx.arcTo(x, y, x + w, y, r); ctx.closePath();
24 }
25 function txt(s, x, y, size, col, align, weight, glow, gcol, italic) {
26 ctx.font = (italic ? "italic " : "") + (weight || 800) + " " + size + "px " + FONT;
27 ctx.textAlign = align || "center"; ctx.textBaseline = "alphabetic";
28 if (glow) { ctx.shadowColor = gcol || col; ctx.shadowBlur = glow; }
29 ctx.fillStyle = col; ctx.fillText(s, x, y); ctx.shadowBlur = 0;
30 }
31 function spaced(s, x, y, size, col, sp, align, weight) {
32 ctx.font = (weight || 700) + " " + size + "px " + FONT; ctx.textBaseline = "alphabetic"; ctx.textAlign = "left";
33 let w = 0; const ws = [];
34 for (const ch of s) { const m = ctx.measureText(ch).width; ws.push(m); w += m + sp; }
35 w -= sp;
36 let cx = align === "center" ? x - w / 2 : align === "right" ? x - w : x;
37 ctx.fillStyle = col; let i = 0;
38 for (const ch of s) { ctx.fillText(ch, cx, y); cx += ws[i++] + sp; }
39 }
40
41 // ---------- constants ----------
42 const TW = 118, HALF = TW / 2, LIM = HALF - 3;
43 const CD = 19, NEAR = 42, MAXG = 4, SAMPLE = 1 / 30;
44 const TURN = 3.1;
45 const PLAYER = "#2ef2ff";
46 const GHOST_COLS = ["#ff4d6d", "#ffb703", "#b388ff", "#ff7bd5", "#7dffb0"];
47 const ARCH = [
48 { name: "NEON DELTA", h: [[2, 0.11], [3, 0.16]], edge: "#3d8bff", rim: "#9cc8ff", bg0: "#10214d", bg1: "#03050d", grid: "rgba(90,140,255,0.075)", asphalt: "#0a1126" },
49 { name: "MAGENTA LOOP", h: [[3, 0.09], [4, 0.13]], edge: "#e83dff", rim: "#ffa8f5", bg0: "#2d1145", bg1: "#060310", grid: "rgba(220,90,255,0.07)", asphalt: "#130a26" },
50 { name: "TOXIC SPINE", h: [[2, 0.17], [5, 0.07]], edge: "#2dffa8", rim: "#b6ffe2", bg0: "#0c3a3a", bg1: "#020b0c", grid: "rgba(60,255,190,0.06)", asphalt: "#08171d" },
51 ];
52
53 // ---------- canvas fit ----------
54 let VS = 1, track = null, trackLayer = null;
55 function fit() {
56 const s = Math.min(innerWidth / W, innerHeight / H), d = Math.min(window.devicePixelRatio || 1, 3);
57 c.style.width = W * s + "px"; c.style.height = H * s + "px";
58 c.width = Math.max(1, Math.round(W * s * d)); c.height = Math.max(1, Math.round(H * s * d));
59 VS = s * d;
60 if (track) renderTrackLayer();
61 }
62
63 // ---------- track generation ----------
64 function resample(raw, N) {
65 const M = raw.length, cum = new Float64Array(M + 1);
66 for (let i = 0; i < M; i++) { const a = raw[i], b = raw[(i + 1) % M]; cum[i + 1] = cum[i] + Math.hypot(b[0] - a[0], b[1] - a[1]); }
67 const L = cum[M], out = []; let j = 0;
68 for (let i = 0; i < N; i++) {
69 const s = (i * L) / N; while (cum[j + 1] < s) j++;
70 const a = raw[j], b = raw[(j + 1) % M], f = (s - cum[j]) / (cum[j + 1] - cum[j] || 1);
71 out.push([a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f]);
72 }
73 return { pts: out, L };
74 }
75 function radiusAt(P, N, i, k) {
76 const a = P[(i - k + N) % N], b = P[i], d = P[(i + k) % N];
77 const ab = Math.hypot(b[0] - a[0], b[1] - a[1]), bc = Math.hypot(d[0] - b[0], d[1] - b[1]), ca = Math.hypot(d[0] - a[0], d[1] - a[1]);
78 const cr = Math.abs((b[0] - a[0]) * (d[1] - a[1]) - (b[1] - a[1]) * (d[0] - a[0]));
79 return cr < 1e-6 ? 1e9 : (ab * bc * ca) / (2 * cr);
80 }
81 function buildTrack(kind) {
82 const A = ARCH[kind], rnd = mulberry((Math.random() * 1e9) | 0);
83 const N = 640, BX = W / 2 - HALF - 16, BY = (H - 138 - 14) / 2 - HALF - 4, CY = 138 + (H - 138 - 14) / 2;
84 let res = null;
85 for (let att = 0; att < 140 && !res; att++) {
86 const shrink = Math.max(0, 1 - att * 0.009);
87 const ph = A.h.map(() => rnd() * TAU), am = A.h.map((h) => h[1] * (0.75 + rnd() * 0.5) * shrink);
88 const M = 720, raw = []; let mnx = 1e9, mxx = -1e9, mny = 1e9, mxy = -1e9;
89 for (let j = 0; j < M; j++) {
90 const th = (j / M) * TAU; let r = 1;
91 for (let k = 0; k < A.h.length; k++) r += am[k] * Math.cos(A.h[k][0] * th + ph[k]);
92 const x = Math.cos(th) * r, y = Math.sin(th) * r * 1.75;
93 raw.push([x, y]); mnx = Math.min(mnx, x); mxx = Math.max(mxx, x); mny = Math.min(mny, y); mxy = Math.max(mxy, y);
94 }
95 const sx = (2 * BX) / (mxx - mnx), sy = (2 * BY) / (mxy - mny), cx = (mxx + mnx) / 2, cy = (mxy + mny) / 2;
96 for (const p of raw) { p[0] = W / 2 + (p[0] - cx) * sx; p[1] = CY + (p[1] - cy) * sy; }
97 const rs = resample(raw, N), P = rs.pts;
98 let ok = true;
99 for (let i = 0; i < N && ok; i += 2) if (radiusAt(P, N, i, 4) < 104) ok = false;
100 for (let i = 0; i < N && ok; i++) for (let j = i + 1; j < N; j++) {
101 const circ = Math.min(j - i, N - (j - i)); if (circ < N * 0.13) continue;
102 if (Math.hypot(P[i][0] - P[j][0], P[i][1] - P[j][1]) < TW + 30) { ok = false; break; }
103 }
104 if (ok) res = rs;
105 }
106 if (!res) {
107 const raw = []; for (let j = 0; j < 720; j++) { const th = (j / 720) * TAU; raw.push([W / 2 + Math.cos(th) * BX, CY + Math.sin(th) * BY]); }
108 res = resample(raw, N);
109 }
110 let P = res.pts;
111 if (rnd() < 0.5) P.reverse();
112 if (rnd() < 0.5) P = P.map((p) => [W - p[0], p[1]]);
113 // start where the track is straightest
114 const curv = P.map((_, i) => 1 / radiusAt(P, N, i, 4));
115 let bi = 0, bv = 1e9;
116 for (let i = 0; i < N; i++) { let s = 0; for (let o = -34; o <= 34; o += 2) s += curv[(i + o + N) % N]; if (s < bv) { bv = s; bi = i; } }
117 P = P.slice(bi).concat(P.slice(0, bi));
118 const px = new Float32Array(N), py = new Float32Array(N), tx = new Float32Array(N), ty = new Float32Array(N);
119 for (let i = 0; i < N; i++) { px[i] = P[i][0]; py[i] = P[i][1]; }
120 for (let i = 0; i < N; i++) {
121 const a = (i - 1 + N) % N, b = (i + 1) % N, dx = px[b] - px[a], dy = py[b] - py[a], l = Math.hypot(dx, dy) || 1;
122 tx[i] = dx / l; ty[i] = dy / l;
123 }
124 return { kind, N, L: res.L, ds: res.L / N, px, py, tx, ty, theme: A, name: A.name };
125 }
126 function posAt(f) {
127 const N = track.N; f = ((f % N) + N) % N;
128 const i0 = Math.floor(f), i1 = (i0 + 1) % N, fr = f - i0;
129 const x = lerp(track.px[i0], track.px[i1], fr), y = lerp(track.py[i0], track.py[i1], fr);
130 return { x, y, th: Math.atan2(lerp(track.ty[i0], track.ty[i1], fr), lerp(track.tx[i0], track.tx[i1], fr)) };
131 }
132
133 // ---------- static layer (track, glow) ----------
134 function renderTrackLayer() {
135 const q = Math.min(2.5, Math.max(1, VS));
136 const cv = trackLayer || (trackLayer = document.createElement("canvas"));
137 cv.width = Math.round(W * q); cv.height = Math.round(H * q);
138 const g = cv.getContext("2d"); g.setTransform(q, 0, 0, q, 0, 0);
139 const th = track.theme, N = track.N;
140 const bg = g.createRadialGradient(W / 2, H * 0.48, 40, W / 2, H * 0.5, H * 0.78);
141 bg.addColorStop(0, th.bg0); bg.addColorStop(1, th.bg1); g.fillStyle = bg; g.fillRect(0, 0, W, H);
142 g.strokeStyle = th.grid; g.lineWidth = 1; g.beginPath();
143 for (let x = 0; x <= W; x += 45) { g.moveTo(x + 0.5, 0); g.lineTo(x + 0.5, H); }
144 for (let y = 0; y <= H; y += 45) { g.moveTo(0, y + 0.5); g.lineTo(W, y + 0.5); }
145 g.stroke();
146 const r = mulberry(track.kind * 77 + 5);
147 g.fillStyle = "#fff";
148 for (let i = 0; i < 70; i++) { g.globalAlpha = 0.05 + r() * 0.2; g.fillRect(r() * W, r() * H, 1.5, 1.5); }
149 g.globalAlpha = 1;
150 const P = new Path2D();
151 P.moveTo(track.px[0], track.py[0]);
152 for (let i = 1; i < N; i++) P.lineTo(track.px[i], track.py[i]);
153 P.closePath();
154 g.lineJoin = "round"; g.lineCap = "round";
155 g.strokeStyle = th.edge;
156 g.globalAlpha = 0.05; g.lineWidth = TW + 110; g.stroke(P);
157 g.globalAlpha = 0.08; g.lineWidth = TW + 66; g.stroke(P);
158 g.globalAlpha = 0.14; g.lineWidth = TW + 34; g.shadowColor = th.edge; g.shadowBlur = 36; g.stroke(P);
159 g.globalAlpha = 1; g.lineWidth = TW + 8; g.shadowBlur = 20; g.stroke(P);
160 g.shadowBlur = 0;
161 g.strokeStyle = th.rim; g.globalAlpha = 0.5; g.setLineDash([12, 12]); g.lineWidth = TW + 8; g.stroke(P);
162 g.setLineDash([]); g.globalAlpha = 1;
163 g.strokeStyle = th.asphalt; g.lineWidth = TW; g.stroke(P);
164 g.strokeStyle = "rgba(255,255,255,0.03)"; g.lineWidth = TW - 30; g.stroke(P);
165 g.strokeStyle = "rgba(255,255,255,0.05)"; g.lineWidth = 16; g.stroke(P);
166 g.strokeStyle = "rgba(255,255,255,0.24)"; g.lineWidth = 2; g.setLineDash([16, 24]); g.stroke(P); g.setLineDash([]);
167 // direction chevrons
168 g.strokeStyle = th.edge; g.lineWidth = 3; g.lineCap = "butt";
169 for (let i = 40; i < N; i += 64) {
170 g.save(); g.translate(track.px[i], track.py[i]); g.rotate(Math.atan2(track.ty[i], track.tx[i]));
171 g.globalAlpha = 0.28; g.beginPath(); g.moveTo(-7, -13); g.lineTo(4, 0); g.lineTo(-7, 13); g.stroke();
172 g.globalAlpha = 0.14; g.beginPath(); g.moveTo(-19, -13); g.lineTo(-8, 0); g.lineTo(-19, 13); g.stroke();
173 g.restore();
174 }
175 g.globalAlpha = 1;
176 // start / finish line
177 g.save(); g.translate(track.px[0], track.py[0]); g.rotate(Math.atan2(track.ty[0], track.tx[0]));
178 const cw = TW / 12;
179 for (let row = 0; row < 2; row++) for (let k = 0; k < 12; k++) {
180 g.fillStyle = (k + row) % 2 ? "rgba(255,255,255,0.92)" : "rgba(10,14,30,0.95)";
181 g.fillRect(-9 + row * 9, -HALF + k * cw, 9, cw);
182 }
183 g.shadowColor = "#fff"; g.shadowBlur = 16; g.strokeStyle = "rgba(255,255,255,0.7)"; g.lineWidth = 1.5;
184 g.strokeRect(-9, -HALF, 18, TW); g.restore();
185 }
186
187 // ---------- audio ----------
188 const AU = (() => {
189 let ac = null, master = null, eng = null, noiseBuf = null, muted = store.get("ghostlap.mute", "0") === "1";
190 function init() {
191 try {
192 if (!ac) {
193 const AC = window.AudioContext || window.webkitAudioContext; if (!AC) return;
194 ac = new AC(); master = ac.createGain(); master.gain.value = muted ? 0 : 0.6; master.connect(ac.destination);
195 noiseBuf = ac.createBuffer(1, Math.floor(ac.sampleRate * 1.5), ac.sampleRate);
196 const d = noiseBuf.getChannelData(0); for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
197 const f = ac.createBiquadFilter(); f.type = "lowpass"; f.frequency.value = 400; f.Q.value = 2;
198 const eg = ac.createGain(); eg.gain.value = 0;
199 const o1 = ac.createOscillator(), o2 = ac.createOscillator(), o3 = ac.createOscillator();
200 o1.type = "sawtooth"; o2.type = "square"; o3.type = "sine";
201 const g1 = ac.createGain(), g2 = ac.createGain(), g3 = ac.createGain(); g1.gain.value = 0.5; g2.gain.value = 0.18; g3.gain.value = 0.7;
202 o1.connect(g1); o2.connect(g2); o3.connect(g3); g1.connect(f); g2.connect(f); g3.connect(f); f.connect(eg); eg.connect(master);
203 o1.start(); o2.start(); o3.start();
204 const ns = ac.createBufferSource(); ns.buffer = noiseBuf; ns.loop = true;
205 const bp = ac.createBiquadFilter(); bp.type = "bandpass"; bp.frequency.value = 1700; bp.Q.value = 5;
206 const sg = ac.createGain(); sg.gain.value = 0; ns.connect(bp); bp.connect(sg); sg.connect(master); ns.start();
207 eng = { o1, o2, o3, f, eg, sg };
208 }
209 if (ac.state === "suspended") ac.resume();
210 } catch (e) {}
211 }
212 function tone(f, d, type, vol, to, delay) {
213 if (!ac || muted) return;
214 try {
215 const t = ac.currentTime + (delay || 0), o = ac.createOscillator(), g = ac.createGain();
216 o.type = type; o.frequency.setValueAtTime(f, t); if (to) o.frequency.exponentialRampToValueAtTime(to, t + d);
217 g.gain.setValueAtTime(0.0001, t); g.gain.exponentialRampToValueAtTime(vol, t + 0.012); g.gain.exponentialRampToValueAtTime(0.0001, t + d);
218 o.connect(g); g.connect(master); o.start(t); o.stop(t + d + 0.03);
219 } catch (e) {}
220 }
221 function noise(d, vol, f0, f1) {
222 if (!ac || muted) return;
223 try {
224 const t = ac.currentTime, s = ac.createBufferSource(), g = ac.createGain(), fl = ac.createBiquadFilter();
225 s.buffer = noiseBuf; fl.type = "lowpass"; fl.frequency.setValueAtTime(f0, t); fl.frequency.exponentialRampToValueAtTime(f1, t + d);
226 g.gain.setValueAtTime(vol, t); g.gain.exponentialRampToValueAtTime(0.0001, t + d);
227 s.connect(fl); fl.connect(g); g.connect(master); s.start(t, Math.random()); s.stop(t + d + 0.05);
228 } catch (e) {}
229 }
230 return {
231 init,
232 get muted() { return muted; },
233 toggle() {
234 muted = !muted; store.set("ghostlap.mute", muted ? "1" : "0");
235 if (master) master.gain.setTargetAtTime(muted ? 0 : 0.6, ac.currentTime, 0.02);
236 init();
237 },
238 suspend() { try { if (ac) ac.suspend(); } catch (e) {} },
239 resume() { try { if (ac && ac.state === "suspended") ac.resume(); } catch (e) {} },
240 engine(on, sp, slip) {
241 if (!eng || !ac) return;
242 const t = ac.currentTime, base = 46 + 100 * sp;
243 eng.o1.frequency.setTargetAtTime(base, t, 0.06); eng.o2.frequency.setTargetAtTime(base * 1.503, t, 0.06); eng.o3.frequency.setTargetAtTime(base * 0.5, t, 0.06);
244 eng.f.frequency.setTargetAtTime(240 + 1300 * sp, t, 0.08);
245 eng.eg.gain.setTargetAtTime(on ? 0.1 + 0.1 * sp : 0, t, 0.12);
246 eng.sg.gain.setTargetAtTime(on ? clamp01(slip) * 0.05 : 0, t, 0.05);
247 },
248 lap() { tone(523, 0.16, "triangle", 0.18); tone(659, 0.16, "triangle", 0.18, 0, 0.07); tone(784, 0.28, "triangle", 0.2, 0, 0.14); tone(1046, 0.4, "sine", 0.12, 0, 0.2); },
249 near(n) { tone(640 + Math.min(n, 12) * 55, 0.09, "square", 0.05, 900 + Math.min(n, 12) * 60); },
250 wall() { noise(0.22, 0.6, 1400, 160); tone(140, 0.18, "sine", 0.35, 50); },
251 crash() { noise(0.9, 0.95, 3200, 90); tone(220, 0.7, "sawtooth", 0.28, 38); tone(90, 0.8, "sine", 0.4, 30); },
252 spawn() { tone(260, 0.5, "sine", 0.14, 1000); },
253 dissolve() { tone(900, 0.5, "sine", 0.07, 180); },
254 click() { tone(500, 0.08, "triangle", 0.12, 800); },
255 go() { tone(392, 0.12, "square", 0.06); tone(784, 0.25, "square", 0.07, 0, 0.1); },
256 };
257 })();
258
259 // ---------- particles & fx ----------
260 const parts = [], rings = [], skids = [], slines = [], pops = [];
261 function burst(x, y, n, col, smin, smax, life, ang, spread, size) {
262 for (let i = 0; i < n && parts.length < 700; i++) {
263 const a = ang === undefined ? rand(0, TAU) : ang + rand(-spread, spread), s = rand(smin, smax), l = life * rand(0.6, 1.2);
264 parts.push({ x, y, vx: Math.cos(a) * s, vy: Math.sin(a) * s, life: l, max: l, col, size: size || 2.2, type: 0 });
265 }
266 }
267 function ring(x, y, col, r0, vr, life, w) { rings.push({ x, y, r: r0, vr, life, max: life, col, w: w || 3 }); }
268 function popup(s, x, y, col, size, life) { pops.push({ x: clamp(x, 60, W - 60), y, s, col, size: size || 22, life: life || 1.1, max: life || 1.1 }); }
269
270 // ---------- state ----------
271 let mode = "title", G = null, kind = (Math.random() * 3) | 0, best = Number(store.get("ghostlap.best", 0)) || 0;
272 let paused = false, tNow = 0, titleT = 0, overT = 0, shake = 0, flash = 0, flashCol = "255,255,255", runsCount = Number(store.get("ghostlap.runs", 0)) || 0;
273 const dbg = { god: false };
274 const keys = { l: false, r: false }, pointers = new Map();
275 const MUTE = { x: W - 40, y: 46, r: 22 };
276
277 function prepareTrack() {
278 kind = (kind + 1) % 3; track = buildTrack(kind); parts.length = 0; skids.length = 0; rings.length = 0; pops.length = 0; slines.length = 0;
279 renderTrackLayer();
280 }
281
282 function startRun() {
283 const th0 = Math.atan2(track.ty[0], track.tx[0]);
284 parts.length = 0; skids.length = 0; rings.length = 0; pops.length = 0; slines.length = 0;
285 const tut = store.get("ghostlap.tut", "0") !== "1";
286 G = {
287 x: track.px[0], y: track.py[0], th: th0, v: 150, vx: Math.cos(th0) * 150, vy: Math.sin(th0) * 150, steer: 0,
288 idx: 0, prog: 0, lap: 0, lapClock: 0, total: 0, hits: 0, hitCd: 0, clean: true, streak: 0,
289 score: 0, disp: 0, combo: 0, comboT: 0, maxCombo: 0, ghosts: [], ghostsMade: 0, rec: [track.px[0], track.py[0], th0], recAcc: 0,
290 lock: 0.3, wait: tut, tut, goT: tut ? 0 : 1.2, trail: [], pw: null, alive: true, why: "", crashT: 0, slow: 1, drift: 0, slip: 0, toastGhost: false,
291 lapFlash: 0, hitFlash: 0, lastLapTime: 0, driftAcc: 0,
292 };
293 mode = "play"; overT = 0; shake = 0; flash = 0;
294 if (!tut) AU.go();
295 runsCount++; store.set("ghostlap.runs", runsCount);
296 }
297
298 function steerInput() {
299 let l = keys.l, r = keys.r;
300 for (const s of pointers.values()) { if (s < 0) l = true; else r = true; }
301 return (r ? 1 : 0) - (l ? 1 : 0);
302 }
303
304 function ghostPos(gh, t) {
305 const p = gh.path, n = p.length / 3, f = clamp(t, 0, gh.dur) / SAMPLE;
306 let i0 = Math.floor(f); const fr = f - i0; i0 = Math.min(i0, n - 1); const i1 = Math.min(i0 + 1, n - 1);
307 return { x: lerp(p[i0 * 3], p[i1 * 3], fr), y: lerp(p[i0 * 3 + 1], p[i1 * 3 + 1], fr), th: p[i0 * 3 + 2] + angDiff(p[i0 * 3 + 2], p[i1 * 3 + 2]) * fr };
308 }
309
310 function comboMult() { return Math.min(8, 1 + Math.floor(G.combo / 2)); }
311
312 function award(gh, dmin) {
313 const g = G; g.combo++; g.maxCombo = Math.max(g.maxCombo, g.combo); g.comboT = 3.4;
314 const close = clamp01(1 - (dmin - CD) / (NEAR - CD)), m = comboMult(), pts = Math.round((8 + 22 * close) * m);
315 g.score += pts;
316 popup((close > 0.7 ? "CLOSE! +" : "+") + pts, g.x, g.y - 30, close > 0.7 ? "#ffe26a" : "#9ff4ff", close > 0.7 ? 26 : 20);
317 burst(g.x, g.y, 10, gh.col, 90, 260, 0.45, undefined, 0, 2);
318 AU.near(g.combo);
319 }
320
321 function lapComplete() {
322 const g = G, lt = g.lapClock;
323 g.rec.push(g.x, g.y, g.th);
324 const dur = Math.max(1.5, (g.rec.length / 3 - 1) * SAMPLE);
325 const alive = g.ghosts.filter((o) => !o.dying);
326 while (alive.length >= MAXG) { const o = alive.shift(); o.dying = 1.6; AU.dissolve(); popup("GHOST FADES", o.x || W / 2, o.y || H / 2, o.col, 16, 1.0); }
327 g.ghosts.push({ path: Float32Array.from(g.rec), dur, t: -1.3, col: GHOST_COLS[g.ghostsMade % GHOST_COLS.length], id: g.ghostsMade, dying: 0, near: false, nearMin: 99, nearT: 0, x: track.px[0], y: track.py[0], th: 0, a: 0, active: false });
328 g.ghostsMade++; g.lap++;
329 const mult = g.clean ? Math.min(5, 1 + g.streak) : 1;
330 g.streak = g.clean ? g.streak + 1 : 0;
331 const tb = Math.max(0, Math.round((track.L / 300 - lt) * 30)), pts = 100 * mult + tb;
332 g.score += pts; g.lastLapTime = lt; g.lapFlash = 1;
333 popup("LAP " + g.lap + " +" + pts, W / 2, 190, "#ffffff", 30, 1.6);
334 popup(g.clean ? "CLEAN LAP x" + mult : "LAP TIME " + lt.toFixed(1) + "s", W / 2, 222, g.clean ? "#7dffb0" : "#9fb4ff", 18, 1.6);
335 if (g.hits > 0) { g.hits--; popup("SHIELD RESTORED", W / 2, 250, "#ffd23f", 15, 1.4); }
336 ring(track.px[0], track.py[0], "#ffffff", 20, 260, 0.6, 3);
337 g.clean = true; g.lapClock = 0; g.rec = [g.x, g.y, g.th]; g.recAcc = 0;
338 AU.lap(); AU.spawn();
339 if (!g.toastGhost && runsCount <= 3) { g.toastGhost = true; popup("THAT LAP IS NOW A GHOST", W / 2, 290, "#ff9db0", 20, 2.4); }
340 }
341
342 function crash(why) {
343 const g = G; if (!g.alive) return;
344 g.alive = false; g.why = why; mode = "crash"; g.crashT = 0; g.slow = 0.3;
345 shake = 28; flash = 1; flashCol = "255,255,255";
346 burst(g.x, g.y, 90, PLAYER, 100, 560, 1.1, undefined, 0, 2.6);
347 burst(g.x, g.y, 40, "#ffffff", 60, 380, 0.8, undefined, 0, 2);
348 burst(g.x, g.y, 30, "#ff3d6a", 60, 300, 1.0, undefined, 0, 3);
349 ring(g.x, g.y, PLAYER, 10, 520, 0.7, 5); ring(g.x, g.y, "#ff3d6a", 6, 340, 0.9, 3);
350 AU.crash(); AU.engine(false, 0, 0);
351 if (g.score > best) { best = g.score; g.newBest = true; store.set("ghostlap.best", best); }
352 try { if (window.Arcadia) { Arcadia.submitScore(g.score); if (Arcadia.gameOver) Arcadia.gameOver(); } } catch (e) {}
353 }
354
355 function updateGhosts(dt) {
356 const g = G;
357 for (let k = g.ghosts.length - 1; k >= 0; k--) {
358 const gh = g.ghosts[k];
359 if (gh.dying) {
360 gh.dying -= dt;
361 if (gh.dying <= 0) { g.ghosts.splice(k, 1); continue; }
362 }
363 const p0x = gh.path[0], p0y = gh.path[1];
364 if (gh.t < 0 && gh.t + dt >= 0 && g.alive && !gh.dying && Math.hypot(g.x - p0x, g.y - p0y) < 130) gh.t = -0.0001;
365 else gh.t += dt;
366 if (gh.t >= gh.dur + 0.3) gh.t = -0.7;
367 if (gh.t > 0 && gh.t < 0.05 && gh.t - dt <= 0 && !gh.dying) ring(p0x, p0y, gh.col, 8, 90, 0.5, 2);
368 const p = ghostPos(gh, gh.t); gh.x = p.x; gh.y = p.y; gh.th = p.th;
369 gh.a = gh.t < 0 ? 0 : clamp01(gh.t / 0.5) * clamp01((gh.dur - gh.t) / 0.5);
370 gh.active = !gh.dying && gh.t > 0.5 && gh.t < gh.dur - 0.25;
371 if (gh.dying && Math.random() < 0.6) burst(gh.x, gh.y, 1, gh.col, 20, 120, 0.6, undefined, 0, 2);
372 if (!g.alive) continue;
373 const d = Math.hypot(g.x - gh.x, g.y - gh.y);
374 if (gh.active) {
375 if (d < CD && !dbg.god) { crash("ghost"); return; }
376 if (d < NEAR) {
377 if (!gh.near) { gh.near = true; gh.nearMin = d; gh.nearT = 0; } else gh.nearMin = Math.min(gh.nearMin, d);
378 gh.nearT += dt;
379 if (Math.random() < dt * 30) burst((g.x + gh.x) / 2, (g.y + gh.y) / 2, 1, gh.col, 40, 160, 0.35, undefined, 0, 1.6);
380 if (gh.nearT >= 0.65) { award(gh, gh.nearMin); gh.nearT = 0; gh.nearMin = d; }
381 } else if (gh.near) { gh.near = false; if (d < NEAR + 40 && gh.nearT > 0.05) award(gh, gh.nearMin); }
382 } else gh.near = false;
383 }
384 }
385
386 function simStep(dt) {
387 const g = G;
388 g.total += dt; g.lapClock += dt; g.hitCd -= dt; g.lock -= dt;
389 g.comboT -= dt; if (g.comboT <= 0 && g.combo) g.combo = 0;
390 const inp = g.lock > 0 ? 0 : steerInput();
391 g.steer += (inp - g.steer) * Math.min(1, dt * 14);
392 const target = Math.min(340, 232 + 11 * g.lap);
393 g.v += (target - g.v) * Math.min(1, dt * 0.9) - Math.abs(g.steer) * dt * 14;
394 g.th += g.steer * TURN * dt;
395 const hx = Math.cos(g.th), hy = Math.sin(g.th), grip = 9 - 4 * Math.abs(g.steer);
396 g.vx += (hx * g.v - g.vx) * Math.min(1, grip * dt); g.vy += (hy * g.v - g.vy) * Math.min(1, grip * dt);
397 g.x += g.vx * dt; g.y += g.vy * dt;
398 const sp = Math.hypot(g.vx, g.vy);
399 g.slip = Math.abs(g.vx * hy - g.vy * hx) / Math.max(40, sp);
400 // recording
401 g.recAcc += dt;
402 while (g.recAcc >= SAMPLE) { g.recAcc -= SAMPLE; g.rec.push(g.x, g.y, g.th); }
403 // trail
404 g.trail.push(g.x, g.y); if (g.trail.length > 44) g.trail.splice(0, 2);
405 // nearest track point
406 const N = track.N;
407 let bi = g.idx, bd = 1e18;
408 for (let o = -24; o <= 24; o++) {
409 const i = (g.idx + o + N) % N, dx = track.px[i] - g.x, dy = track.py[i] - g.y, d2 = dx * dx + dy * dy;
410 if (d2 < bd) { bd = d2; bi = i; }
411 }
412 if (bd > 130 * 130) { bd = 1e18; for (let i = 0; i < N; i++) { const dx = track.px[i] - g.x, dy = track.py[i] - g.y, d2 = dx * dx + dy * dy; if (d2 < bd) { bd = d2; bi = i; } } }
413 let di = bi - g.idx; if (di > N / 2) di -= N; else if (di < -N / 2) di += N;
414 g.prog += di; g.idx = bi;
415 const d = Math.sqrt(bd);
416 // walls
417 if (d > LIM) {
418 const cx = track.px[bi], cy = track.py[bi], nx = (cx - g.x) / d, ny = (cy - g.y) / d;
419 g.x = cx - nx * (LIM - 2); g.y = cy - ny * (LIM - 2);
420 const vin = g.vx * nx + g.vy * ny;
421 if (vin < 0) { g.vx -= 1.55 * vin * nx; g.vy -= 1.55 * vin * ny; }
422 g.vx *= 0.82; g.vy *= 0.82; g.v *= 0.86;
423 g.th += angDiff(g.th, Math.atan2(g.vy, g.vx)) * 0.7;
424 burst(g.x - nx * 2, g.y - ny * 2, 5, "#ffd27a", 80, 320, 0.35, Math.atan2(ny, nx), 1.2, 2);
425 if (g.hitCd <= 0) {
426 g.hitCd = 0.55; g.hits++; g.clean = false; g.combo = 0; g.comboT = 0; g.hitFlash = 1;
427 shake = Math.max(shake, 9); flash = Math.max(flash, 0.25); flashCol = "255,120,80";
428 AU.wall();
429 if (g.hits >= 3) { crash("wall"); return; }
430 popup(g.hits === 2 ? "LAST SHIELD!" : "WALL HIT", g.x, g.y - 34, "#ffb35a", 20, 0.9);
431 }
432 }
433 if (g.prog >= N) { g.prog -= N; lapComplete(); }
434 // drift / skid
435 const skidding = g.slip > 0.2 && sp > 120 && Math.abs(g.steer) > 0.3;
436 if (skidding) {
437 const px = -hy, py = hx, bx = g.x - hx * 8, by = g.y - hy * 8;
438 const w = [bx + px * 5.5, by + py * 5.5, bx - px * 5.5, by - py * 5.5];
439 if (g.pw) { skids.push({ x1: g.pw[0], y1: g.pw[1], x2: w[0], y2: w[1], life: 7 }, { x1: g.pw[2], y1: g.pw[3], x2: w[2], y2: w[3], life: 7 }); if (skids.length > 700) skids.splice(0, skids.length - 700); }
440 g.pw = w;
441 if (Math.random() < dt * 40) parts.push({ x: bx, y: by, vx: -hx * 20 + rand(-14, 14), vy: -hy * 20 + rand(-14, 14), life: 0.5, max: 0.5, col: "#ffffff", size: rand(4, 8), type: 1 });
442 if (Math.random() < dt * 22) burst(bx, by, 1, "#ffd9a0", 40, 160, 0.3, Math.atan2(-hy, -hx), 0.8, 1.6);
443 if (g.slip > 0.27) {
444 g.driftAcc += dt * 7 * comboMult();
445 if (g.combo) g.comboT = Math.max(g.comboT, 0.8);
446 if (g.driftAcc >= 1) { const p = Math.floor(g.driftAcc); g.score += p; g.driftAcc -= p; }
447 }
448 } else g.pw = null;
449 g.drift = skidding ? 1 : 0;
450 updateGhosts(dt);
451 }
452
453 function update(rawDt) {
454 tNow += rawDt; flash = Math.max(0, flash - rawDt * 2.6); shake = Math.max(0, shake - rawDt * 38);
455 if (mode === "title") { titleT += rawDt; }
456 // fx (real time-ish)
457 const fdt = rawDt * (G && mode !== "title" ? G.slow : 1);
458 for (let i = parts.length - 1; i >= 0; i--) {
459 const p = parts[i]; p.life -= fdt; if (p.life <= 0) { parts.splice(i, 1); continue; }
460 if (p.type === 0) { p.vx *= 1 - 2.2 * fdt; p.vy *= 1 - 2.2 * fdt; } else { p.size += fdt * 22; }
461 p.x += p.vx * fdt; p.y += p.vy * fdt;
462 }
463 for (let i = rings.length - 1; i >= 0; i--) { const r = rings[i]; r.life -= fdt; r.r += r.vr * fdt; r.vr *= 1 - 3 * fdt; if (r.life <= 0) rings.splice(i, 1); }
464 for (let i = pops.length - 1; i >= 0; i--) { const p = pops[i]; p.life -= rawDt; p.y -= 26 * rawDt; if (p.life <= 0) pops.splice(i, 1); }
465 for (let i = slines.length - 1; i >= 0; i--) { const s = slines[i]; s.life -= rawDt; if (s.life <= 0) slines.splice(i, 1); }
466 for (let i = skids.length - 1; i >= 0; i--) { skids[i].life -= rawDt; if (skids[i].life <= 0) skids.splice(i, 1); }
467 if (!G || mode === "title") { AU.engine(false, 0, 0); return; }
468 const g = G;
469 g.disp += (g.score - g.disp) * Math.min(1, rawDt * 9); if (Math.abs(g.score - g.disp) < 0.5) g.disp = g.score;
470 g.lapFlash = Math.max(0, g.lapFlash - rawDt * 2.5); g.hitFlash = Math.max(0, g.hitFlash - rawDt * 3);
471 if (g.goT > 0) g.goT -= rawDt;
472 if (mode === "play") {
473 if (g.wait) { AU.engine(false, 0, 0); return; }
474 const dt = rawDt;
475 const n = Math.max(1, Math.ceil(dt / (1 / 90))), h = dt / n;
476 for (let i = 0; i < n && mode === "play"; i++) simStep(h);
477 if (mode === "play") {
478 const sp = Math.hypot(g.vx, g.vy);
479 AU.engine(true, clamp01((sp - 120) / 240), g.drift ? g.slip * 3 : 0);
480 if (sp > 240 && Math.random() < rawDt * 45 * (sp - 220) / 120) {
481 const a = rand(0, TAU), r0 = rand(70, 160);
482 slines.push({ x: g.x + Math.cos(a) * r0, y: g.y + Math.sin(a) * r0, a, len: rand(26, 70), life: 0.28, max: 0.28 });
483 }
484 }
485 } else {
486 // crash / over: ghosts keep driving in slow motion
487 g.crashT += rawDt;
488 g.slow = mode === "crash" ? lerp(0.3, 1, clamp01((g.crashT - 0.5) / 0.6)) : 1;
489 updateGhostsFree(rawDt * g.slow);
490 if (mode === "crash" && g.crashT > 1.15) { mode = "over"; overT = 0; }
491 if (mode === "over") overT += rawDt;
492 }
493 }
494 function updateGhostsFree(dt) {
495 const keep = G.alive; G.alive = false; updateGhosts(dt); G.alive = keep;
496 }
497
498 // ---------- input ----------
499 function toLogical(e) {
500 const r = c.getBoundingClientRect();
501 return { x: ((e.clientX - r.left) / r.width) * W, y: ((e.clientY - r.top) / r.height) * H };
502 }
503 function goTitle() { prepareTrack(); mode = "title"; titleT = 0; G = null; AU.engine(false, 0, 0); }
504 function retry() { prepareTrack(); startRun(); }
505 function anyStart() {
506 if (mode === "title") { AU.click(); startRun(); }
507 else if (mode === "over" && overT > 0.75) { AU.click(); retry(); }
508 }
509 c.addEventListener("pointerdown", (e) => {
510 e.preventDefault(); if (e.pointerType === "mouse") AU.init();
511 const p = toLogical(e);
512 if (Math.hypot(p.x - MUTE.x, p.y - MUTE.y) < 32) { AU.toggle(); AU.click(); return; }
513 if (mode === "title") { anyStart(); return; }
514 if (mode === "over") {
515 if (overT > 0.75) { if (p.y > 745 && p.y < 800 && Math.abs(p.x - W / 2) < 90) { AU.click(); goTitle(); } else anyStart(); }
516 return;
517 }
518 if (mode === "play") {
519 pointers.set(e.pointerId, p.x < W / 2 ? -1 : 1);
520 if (G.wait) { G.wait = false; store.set("ghostlap.tut", "1"); G.goT = 1.0; G.lock = 0.15; AU.go(); }
521 try { c.setPointerCapture(e.pointerId); } catch (er) {}
522 }
523 });
524 c.addEventListener("pointermove", (e) => {
525 if (pointers.has(e.pointerId)) pointers.set(e.pointerId, toLogical(e).x < W / 2 ? -1 : 1);
526 });
527 const up = (e) => { pointers.delete(e.pointerId); AU.init(); };
528 c.addEventListener("pointerup", up); c.addEventListener("pointercancel", up); c.addEventListener("lostpointercapture", up);
529 c.addEventListener("touchend", () => AU.init(), { passive: true });
530 c.addEventListener("contextmenu", (e) => e.preventDefault());
531 addEventListener("blur", () => { pointers.clear(); keys.l = keys.r = false; });
532 addEventListener("keydown", (e) => {
533 AU.init();
534 const k = e.key;
535 if (k === "ArrowLeft" || k === "a" || k === "A") { keys.l = true; e.preventDefault(); if (G && G.wait && mode === "play") { G.wait = false; store.set("ghostlap.tut", "1"); G.goT = 1.0; G.lock = 0.15; } }
536 else if (k === "ArrowRight" || k === "d" || k === "D") { keys.r = true; e.preventDefault(); if (G && G.wait && mode === "play") { G.wait = false; store.set("ghostlap.tut", "1"); G.goT = 1.0; G.lock = 0.15; } }
537 else if (k === "m" || k === "M") AU.toggle();
538 else if (k === " " || k === "Enter") { e.preventDefault(); if (mode === "play" && G && G.wait) { G.wait = false; store.set("ghostlap.tut", "1"); G.goT = 1.0; } else anyStart(); }
539 else if (k === "Escape" && mode === "over" && overT > 0.5) goTitle();
540 else if (mode === "title" && (k === "ArrowUp" || k === "ArrowDown")) anyStart();
541 });
542 addEventListener("keyup", (e) => {
543 const k = e.key;
544 if (k === "ArrowLeft" || k === "a" || k === "A") keys.l = false;
545 else if (k === "ArrowRight" || k === "d" || k === "D") keys.r = false;
546 });
547 function setPaused(p) { paused = p; if (p) { AU.suspend(); pointers.clear(); } else { AU.resume(); last = performance.now(); } }
548 if (window.Arcadia) { try { Arcadia.onPause(() => setPaused(true)); Arcadia.onResume(() => setPaused(false)); } catch (e) {} }
549 document.addEventListener("visibilitychange", () => { if (document.hidden) setPaused(true); else setPaused(false); });
550
551 // ---------- drawing ----------
552 const BODY = new Path2D("M17 0 L9 -5.6 L-8 -7.2 L-13 -5.2 L-13 5.2 L-8 7.2 L9 5.6 Z");
553 const CABIN = new Path2D("M5 -3.6 L-3 -4.8 L-6 -3.4 L-6 3.4 L-3 4.8 L5 3.6 Z");
554 function drawCar(x, y, th, col, a, glow, fill) {
555 ctx.save(); ctx.translate(x, y); ctx.rotate(th); ctx.globalAlpha = a; ctx.lineJoin = "round";
556 ctx.shadowColor = col; ctx.shadowBlur = glow;
557 ctx.fillStyle = fill || "#08101f"; ctx.fill(BODY);
558 ctx.strokeStyle = col; ctx.lineWidth = 2.2; ctx.stroke(BODY);
559 ctx.shadowBlur = 0;
560 ctx.fillStyle = col; ctx.globalAlpha = a * 0.55; ctx.fill(CABIN);
561 ctx.globalAlpha = a; ctx.fillStyle = "#fff"; ctx.fillRect(13, -4.2, 2, 2); ctx.fillRect(13, 2.2, 2, 2);
562 ctx.fillStyle = col; ctx.fillRect(-14.5, -7, 3, 14);
563 ctx.restore();
564 }
565
566 function drawWorld() {
567 const g = G;
568 ctx.drawImage(trackLayer, 0, 0, W, H);
569 // skid marks
570 ctx.lineCap = "round"; ctx.strokeStyle = "#cfe6ff"; ctx.lineWidth = 2.4;
571 for (const s of skids) { ctx.globalAlpha = Math.min(1, s.life / 4) * 0.22; ctx.beginPath(); ctx.moveTo(s.x1, s.y1); ctx.lineTo(s.x2, s.y2); ctx.stroke(); }
572 ctx.globalAlpha = 1;
573 if (!g) return;
574 // ghost portals + ghosts
575 ctx.globalCompositeOperation = "lighter";
576 for (const gh of g.ghosts) {
577 if (gh.t < 0 && !gh.dying && gh.t > -1.2) {
578 const k = clamp01(1 + gh.t / 1.2), px = gh.path[0], py = gh.path[1];
579 ctx.strokeStyle = gh.col; ctx.globalAlpha = 0.15 + 0.5 * k; ctx.lineWidth = 2 + 2 * k;
580 ctx.beginPath(); ctx.arc(px, py, 30 - 14 * k + Math.sin(tNow * 18) * 1.5, 0, TAU); ctx.stroke();
581 ctx.globalAlpha = 0.1 * k; ctx.fillStyle = gh.col; ctx.beginPath(); ctx.arc(px, py, 28, 0, TAU); ctx.fill();
582 }
583 }
584 ctx.globalAlpha = 1; ctx.globalCompositeOperation = "source-over";
585 const alive = g.ghosts.filter((o) => !o.dying).sort((a, b) => b.id - a.id);
586 for (const gh of g.ghosts) {
587 if (gh.a <= 0.01) continue;
588 const rank = alive.indexOf(gh);
589 const fade = gh.dying ? clamp01(gh.dying / 1.6) * (0.5 + 0.5 * Math.sin(tNow * 40)) : 1;
590 const base = gh.dying ? 0.8 : 1 - Math.max(0, rank) * 0.09;
591 const a = gh.a * base * fade;
592 // trail
593 ctx.globalCompositeOperation = "lighter"; ctx.lineCap = "round"; ctx.strokeStyle = gh.col;
594 let prev = { x: gh.x, y: gh.y };
595 for (let k = 1; k <= 9; k++) {
596 const p = ghostPos(gh, gh.t - k * 0.05);
597 ctx.globalAlpha = a * (1 - k / 10) * 0.55; ctx.lineWidth = 9 * (1 - k / 11);
598 ctx.beginPath(); ctx.moveTo(prev.x, prev.y); ctx.lineTo(p.x, p.y); ctx.stroke(); prev = p;
599 }
600 ctx.globalCompositeOperation = "source-over";
601 if (gh.active) { // danger halo
602 ctx.globalAlpha = a * 0.28; ctx.fillStyle = gh.col; ctx.beginPath(); ctx.arc(gh.x, gh.y, 19, 0, TAU); ctx.fill();
603 }
604 drawCar(gh.x, gh.y, gh.th, gh.col, a * (gh.active ? 0.95 : 0.55), 16, "rgba(20,10,30,0.7)");
605 }
606 ctx.globalAlpha = 1;
607 // speed lines
608 ctx.globalCompositeOperation = "lighter"; ctx.strokeStyle = "#bfeaff"; ctx.lineWidth = 1.6; ctx.lineCap = "round";
609 for (const s of slines) {
610 const k = s.life / s.max, r0 = (1 - k) * 60;
611 ctx.globalAlpha = Math.sin(k * Math.PI) * 0.35;
612 ctx.beginPath(); ctx.moveTo(s.x + Math.cos(s.a) * r0, s.y + Math.sin(s.a) * r0); ctx.lineTo(s.x + Math.cos(s.a) * (r0 + s.len), s.y + Math.sin(s.a) * (r0 + s.len)); ctx.stroke();
613 }
614 // player trail
615 if (g.alive) {
616 const t = g.trail;
617 for (let i = 2; i < t.length; i += 2) {
618 const k = i / t.length;
619 ctx.strokeStyle = PLAYER; ctx.globalAlpha = k * 0.35; ctx.lineWidth = 12 * k;
620 ctx.beginPath(); ctx.moveTo(t[i - 2], t[i - 1]); ctx.lineTo(t[i], t[i + 1]); ctx.stroke();
621 ctx.strokeStyle = "#fff"; ctx.globalAlpha = k * 0.4; ctx.lineWidth = 3 * k;
622 ctx.beginPath(); ctx.moveTo(t[i - 2], t[i - 1]); ctx.lineTo(t[i], t[i + 1]); ctx.stroke();
623 }
624 }
625 ctx.globalAlpha = 1; ctx.globalCompositeOperation = "source-over";
626 // smoke puffs
627 for (const p of parts) if (p.type === 1) { ctx.globalAlpha = (p.life / p.max) * 0.16; ctx.fillStyle = "#dfefff"; ctx.beginPath(); ctx.arc(p.x, p.y, p.size, 0, TAU); ctx.fill(); }
628 ctx.globalAlpha = 1;
629 // player
630 if (g.alive) {
631 ctx.globalCompositeOperation = "lighter";
632 const lg = ctx.createLinearGradient(g.x, g.y, g.x + Math.cos(g.th) * 110, g.y + Math.sin(g.th) * 110);
633 lg.addColorStop(0, "rgba(120,240,255,0.22)"); lg.addColorStop(1, "rgba(120,240,255,0)");
634 ctx.fillStyle = lg; ctx.beginPath(); ctx.moveTo(g.x + Math.cos(g.th) * 12, g.y + Math.sin(g.th) * 12);
635 ctx.lineTo(g.x + Math.cos(g.th - 0.32) * 110, g.y + Math.sin(g.th - 0.32) * 110);
636 ctx.lineTo(g.x + Math.cos(g.th + 0.32) * 110, g.y + Math.sin(g.th + 0.32) * 110); ctx.closePath(); ctx.fill();
637 const rg = ctx.createRadialGradient(g.x, g.y, 2, g.x, g.y, 46);
638 rg.addColorStop(0, "rgba(46,242,255,0.28)"); rg.addColorStop(1, "rgba(46,242,255,0)");
639 ctx.fillStyle = rg; ctx.beginPath(); ctx.arc(g.x, g.y, 46, 0, TAU); ctx.fill();
640 ctx.globalCompositeOperation = "source-over";
641 const blink = g.hitCd > 0 && Math.floor(tNow * 24) % 2 ? 0.45 : 1;
642 drawCar(g.x, g.y, g.th, PLAYER, blink, 20, "#0a1a2c");
643 // combo proximity ring
644 }
645 // rings
646 ctx.globalCompositeOperation = "lighter";
647 for (const r of rings) { ctx.globalAlpha = clamp01(r.life / r.max); ctx.strokeStyle = r.col; ctx.lineWidth = r.w; ctx.beginPath(); ctx.arc(r.x, r.y, Math.max(0.1, r.r), 0, TAU); ctx.stroke(); }
648 // sparks
649 ctx.lineCap = "round";
650 for (const p of parts) {
651 if (p.type !== 0) continue;
652 const a = p.life / p.max; ctx.globalAlpha = a; ctx.strokeStyle = p.col; ctx.lineWidth = p.size * (0.4 + a * 0.6);
653 ctx.beginPath(); ctx.moveTo(p.x, p.y); ctx.lineTo(p.x - p.vx * 0.04, p.y - p.vy * 0.04); ctx.stroke();
654 }
655 ctx.globalAlpha = 1; ctx.globalCompositeOperation = "source-over";
656 // popups
657 for (const p of pops) {
658 const k = p.life / p.max, sc = 1 + 0.25 * Math.max(0, k - 0.8) * 5;
659 ctx.globalAlpha = clamp01(k * 3) ; ctx.save(); ctx.translate(p.x, p.y); ctx.scale(sc, sc);
660 txt(p.s, 0, 0, p.size, p.col, "center", 900, 12, p.col, true); ctx.restore();
661 }
662 ctx.globalAlpha = 1;
663 }
664
665 function drawMute() {
666 const m = MUTE;
667 ctx.fillStyle = "rgba(255,255,255,0.07)"; ctx.strokeStyle = "rgba(255,255,255,0.35)"; ctx.lineWidth = 1.5;
668 ctx.beginPath(); ctx.arc(m.x, m.y, m.r, 0, TAU); ctx.fill(); ctx.stroke();
669 ctx.save(); ctx.translate(m.x - 2, m.y);
670 ctx.fillStyle = "#e8f3ff"; ctx.beginPath(); ctx.moveTo(-8, -4); ctx.lineTo(-3, -4); ctx.lineTo(3, -9); ctx.lineTo(3, 9); ctx.lineTo(-3, 4); ctx.lineTo(-8, 4); ctx.closePath(); ctx.fill();
671 ctx.lineWidth = 2; ctx.lineCap = "round";
672 if (AU.muted) {
673 ctx.strokeStyle = "#ff5c7a"; ctx.beginPath(); ctx.moveTo(7, -5); ctx.lineTo(14, 5); ctx.moveTo(14, -5); ctx.lineTo(7, 5); ctx.stroke();
674 } else {
675 ctx.strokeStyle = "#e8f3ff"; ctx.beginPath(); ctx.arc(3, 0, 6.5, -0.85, 0.85); ctx.stroke(); ctx.beginPath(); ctx.arc(3, 0, 11.5, -0.85, 0.85); ctx.stroke();
676 }
677 ctx.restore();
678 }
679
680 function drawHUD() {
681 const g = G, th = track.theme;
682 const bar = ctx.createLinearGradient(0, 0, 0, 132);
683 bar.addColorStop(0, "rgba(2,4,12,0.92)"); bar.addColorStop(0.7, "rgba(2,4,12,0.6)"); bar.addColorStop(1, "rgba(2,4,12,0)");
684 ctx.fillStyle = bar; ctx.fillRect(0, 0, W, 132);
685 spaced("LAP", 28, 34, 13, "rgba(200,220,255,0.6)", 3, "left", 700);
686 txt(String(g.lap + 1), 28, 82, 50, "#ffffff", "left", 900, 14, th.edge, true);
687 spaced("SCORE", W / 2, 34, 13, "rgba(200,220,255,0.6)", 3, "center", 700);
688 const pulse = 1 + (g.score - g.disp > 3 ? 0.05 : 0);
689 ctx.save(); ctx.translate(W / 2, 82); ctx.scale(pulse, pulse);
690 txt(String(Math.round(g.disp)), 0, 0, 50, "#ffffff", "center", 900, 16, PLAYER, true); ctx.restore();
691 drawMute();
692 // combo meter
693 const m = comboMult(), active = g.combo > 0, bw = 200, bx = W / 2 - bw / 2, by = 100;
694 ctx.fillStyle = "rgba(255,255,255,0.1)"; rr(bx, by, bw, 8, 4); ctx.fill();
695 if (active) {
696 const k = clamp01(g.comboT / 3.4), cg = ctx.createLinearGradient(bx, 0, bx + bw, 0);
697 cg.addColorStop(0, "#2ef2ff"); cg.addColorStop(1, m >= 5 ? "#ff3df0" : "#ffe26a");
698 ctx.fillStyle = cg; ctx.shadowColor = "#ffe26a"; ctx.shadowBlur = 10; rr(bx, by, Math.max(8, bw * k), 8, 4); ctx.fill(); ctx.shadowBlur = 0;
699 txt("x" + m, bx + bw + 14, by + 12, 22 + m, m >= 5 ? "#ff8df6" : "#ffe26a", "left", 900, 12, undefined, true);
700 spaced("CLOSE CALLS " + g.combo, W / 2, by + 26, 10, "rgba(255,240,180,0.85)", 2, "center", 700);
701 } else spaced("GRAZE GHOSTS FOR COMBO", W / 2, by + 26, 10, "rgba(200,220,255,0.35)", 2, "center", 700);
702 // ghost pips
703 spaced("GHOSTS", 28, 108, 10, "rgba(200,220,255,0.55)", 2, "left", 700);
704 const live = g.ghosts.filter((o) => !o.dying);
705 for (let i = 0; i < MAXG; i++) {
706 const gh = live[i]; ctx.save(); ctx.translate(30 + i * 22, 120); ctx.scale(0.62, 0.62);
707 ctx.lineWidth = 2.6; ctx.strokeStyle = gh ? gh.col : "rgba(255,255,255,0.2)"; ctx.fillStyle = gh ? gh.col : "rgba(0,0,0,0)";
708 if (gh) { ctx.shadowColor = gh.col; ctx.shadowBlur = 8; ctx.globalAlpha = 0.5; ctx.fill(BODY); ctx.globalAlpha = 1; }
709 ctx.stroke(BODY); ctx.restore();
710 }
711 // shields
712 spaced("SHIELD", W - 28, 108, 10, "rgba(200,220,255,0.55)", 2, "right", 700);
713 for (let i = 0; i < 3; i++) {
714 const on = i < 3 - g.hits, x = W - 40 + (i - 2) * 0 - (2 - i) * 22 + 12, y = 121;
715 ctx.save(); ctx.translate(x, y); ctx.beginPath();
716 for (let k = 0; k < 6; k++) { const a = (k / 6) * TAU + Math.PI / 6; ctx.lineTo(Math.cos(a) * 8, Math.sin(a) * 8); }
717 ctx.closePath();
718 if (on) { ctx.fillStyle = g.hits === 2 && Math.floor(tNow * 6) % 2 ? "#ff5c7a" : "#ffd23f"; ctx.shadowColor = "#ffd23f"; ctx.shadowBlur = 10; ctx.fill(); }
719 else { ctx.strokeStyle = "rgba(255,255,255,0.22)"; ctx.lineWidth = 2; ctx.stroke(); }
720 ctx.restore();
721 }
722 // steer zone hints
723 const s = steerInput();
724 for (const side of [-1, 1]) {
725 const on = s === side, cx = W / 2 + side * 180;
726 ctx.globalAlpha = on ? 0.5 : 0.13; ctx.strokeStyle = "#cfe6ff"; ctx.lineWidth = 5; ctx.lineCap = "round"; ctx.lineJoin = "round";
727 ctx.beginPath(); ctx.moveTo(cx - side * 10, 915); ctx.lineTo(cx + side * 10, 930); ctx.lineTo(cx - side * 10, 945); ctx.stroke();
728 }
729 ctx.globalAlpha = 1;
730 // GO banner
731 if (g.goT > 0 && !g.wait) {
732 const k = g.goT / 1.2; ctx.globalAlpha = clamp01(k * 2);
733 txt("GO!", W / 2, 840, 64 + (1 - k) * 20, "#ffffff", "center", 900, 24, PLAYER, true);
734 spaced(track.name, W / 2, 878, 15, th.rim, 5, "center", 700);
735 ctx.globalAlpha = 1;
736 }
737 }
738
739 function drawTutorial() {
740 const g = G, k = 0.6 + 0.4 * Math.sin(tNow * 4);
741 ctx.fillStyle = "rgba(3,5,14,0.62)"; ctx.fillRect(0, 0, W, H);
742 // steering zones
743 for (const side of [-1, 1]) {
744 const x0 = side < 0 ? 0 : W / 2, gr = ctx.createLinearGradient(0, 560, 0, 960);
745 gr.addColorStop(0, "rgba(46,242,255,0)"); gr.addColorStop(1, "rgba(46,242,255," + (0.16 + 0.08 * k) + ")");
746 ctx.fillStyle = gr; ctx.fillRect(x0, 560, W / 2, 400);
747 const cx = W / 2 + side * 135;
748 ctx.strokeStyle = PLAYER; ctx.lineCap = "round"; ctx.lineJoin = "round"; ctx.shadowColor = PLAYER; ctx.shadowBlur = 16; ctx.lineWidth = 8;
749 for (let i = 0; i < 2; i++) {
750 ctx.globalAlpha = 0.35 + 0.65 * ((Math.sin(tNow * 5 - i * 1.2) + 1) / 2);
751 const ox = side * (i * 26 - 13);
752 ctx.beginPath(); ctx.moveTo(cx + ox - side * 14, 730); ctx.lineTo(cx + ox + side * 14, 765); ctx.lineTo(cx + ox - side * 14, 800); ctx.stroke();
753 }
754 ctx.globalAlpha = 1; ctx.shadowBlur = 0;
755 txt(side < 0 ? "HOLD LEFT" : "HOLD RIGHT", cx, 850, 24, "#ffffff", "center", 900, 10, PLAYER, true);
756 txt(side < 0 ? "steer left" : "steer right", cx, 878, 15, "rgba(200,225,255,0.7)", "center", 600);
757 }
758 ctx.strokeStyle = "rgba(255,255,255,0.18)"; ctx.lineWidth = 2; ctx.setLineDash([8, 10]); ctx.beginPath(); ctx.moveTo(W / 2, 640); ctx.lineTo(W / 2, 930); ctx.stroke(); ctx.setLineDash([]);
759 // card
760 const cy = 200;
761 rr(50, cy, W - 100, 330, 26); ctx.fillStyle = "rgba(8,14,34,0.88)"; ctx.fill(); ctx.strokeStyle = "rgba(120,180,255,0.4)"; ctx.lineWidth = 2; ctx.stroke();
762 txt("HOW TO RACE", W / 2, cy + 52, 28, "#ffffff", "center", 900, 14, PLAYER, true);
763 const rows = [
764 [PLAYER, "Your car drives itself. Steer to stay on track."],
765 ["#ff4d6d", "Every lap you finish becomes a GHOST"],
766 ["#ff4d6d", "that replays your line forever. Touch one = crash."],
767 ["#ffe26a", "Skim past ghosts for close-call combos."],
768 ["#ffb35a", "3 wall hits end the run. Clean laps heal."],
769 ];
770 for (let i = 0; i < rows.length; i++) {
771 const y = cy + 100 + i * 44;
772 ctx.fillStyle = rows[i][0]; ctx.shadowColor = rows[i][0]; ctx.shadowBlur = 10; ctx.beginPath(); ctx.arc(84, y - 6, 5, 0, TAU); ctx.fill(); ctx.shadowBlur = 0;
773 txt(rows[i][1], 102, y, 16, "#dfeaff", "left", 600);
774 }
775 ctx.globalAlpha = 0.5 + 0.5 * k;
776 txt("TOUCH LEFT OR RIGHT TO BEGIN", W / 2, 610, 20, "#ffffff", "center", 900, 12, PLAYER, true);
777 ctx.globalAlpha = 1;
778 }
779
780 function drawOver() {
781 const g = G, k = ease(overT / 0.5);
782 ctx.fillStyle = "rgba(2,3,10," + 0.66 * k + ")"; ctx.fillRect(0, 0, W, H);
783 ctx.save(); ctx.globalAlpha = k; ctx.translate(0, (1 - k) * 30);
784 const title = g.why === "wall" ? "OFF THE TRACK" : "GHOSTED";
785 txt(title, W / 2, 262, g.why === "wall" ? 58 : 78, "#ff3d6a", "center", 900, 30, "#ff3d6a", true);
786 txt(title, W / 2, 262, g.why === "wall" ? 58 : 78, "#ffffff", "center", 900, 0, undefined, true);
787 spaced(g.why === "wall" ? "THREE WALL HITS" : "YOU RAN INTO YOUR OWN PAST", W / 2, 296, 13, "rgba(255,190,205,0.8)", 3, "center", 700);
788 rr(60, 330, W - 120, 262, 28); ctx.fillStyle = "rgba(8,14,34,0.82)"; ctx.fill(); ctx.strokeStyle = "rgba(120,180,255,0.35)"; ctx.lineWidth = 2; ctx.stroke();
789 spaced("SCORE", W / 2, 378, 14, "rgba(200,220,255,0.65)", 4, "center", 700);
790 const sc = Math.round(g.score * ease(overT / 0.9));
791 txt(String(sc), W / 2, 468, 92, "#ffffff", "center", 900, 24, PLAYER, true);
792 if (g.newBest) txt("NEW BEST!", W / 2, 510, 26, "#ffe26a", "center", 900, 16, "#ffb703", true);
793 else spaced("BEST " + best, W / 2, 506, 15, "rgba(255,226,106,0.9)", 3, "center", 800);
794 ctx.strokeStyle = "rgba(255,255,255,0.12)"; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(90, 534); ctx.lineTo(W - 90, 534); ctx.stroke();
795 const st = [["LAPS", g.lap], ["GHOSTS", g.ghostsMade], ["BEST COMBO", g.maxCombo]];
796 for (let i = 0; i < 3; i++) {
797 const x = 150 + i * 120;
798 txt(String(st[i][1]), x, 568, 30, "#ffffff", "center", 900);
799 spaced(st[i][0], x, 588, 10, "rgba(200,220,255,0.6)", 2, "center", 700);
800 }
801 const p = 1 + Math.sin(tNow * 5) * 0.03;
802 ctx.save(); ctx.translate(W / 2, 668); ctx.scale(p, p);
803 rr(-150, -36, 300, 72, 36);
804 const bg = ctx.createLinearGradient(-150, 0, 150, 0); bg.addColorStop(0, "#19c8ff"); bg.addColorStop(1, "#b04dff");
805 ctx.fillStyle = bg; ctx.shadowColor = "#5b8dff"; ctx.shadowBlur = 24; ctx.fill(); ctx.shadowBlur = 0;
806 txt(overT > 0.75 ? "TAP TO RETRY" : "...", 0, 9, 26, "#ffffff", "center", 900);
807 ctx.restore();
808 spaced("NEXT TRACK: " + ARCH[(kind + 1) % 3].name, W / 2, 735, 12, "rgba(200,220,255,0.55)", 3, "center", 700);
809 txt("MENU", W / 2, 782, 18, "rgba(200,220,255,0.7)", "center", 800);
810 ctx.restore();
811 }
812
813 function drawTitle() {
814 ctx.drawImage(trackLayer, 0, 0, W, H);
815 ctx.fillStyle = "rgba(3,5,14,0.55)"; ctx.fillRect(0, 0, W, H);
816 const th = track.theme, t = titleT;
817 // demo cars
818 const spd = 250 / track.ds;
819 for (let k = 4; k >= 0; k--) {
820 const f = (t * spd - k * 30) % track.N, p = posAt(f < 0 ? f + track.N : f);
821 const col = k === 0 ? PLAYER : GHOST_COLS[(k - 1) % 5];
822 if (k > 0) { ctx.globalCompositeOperation = "lighter"; ctx.lineCap = "round"; ctx.strokeStyle = col;
823 for (let j = 1; j <= 7; j++) { const a = posAt(f - j * 2.2), b = posAt(f - (j - 1) * 2.2); ctx.globalAlpha = 0.4 * (1 - j / 8); ctx.lineWidth = 8 * (1 - j / 9); ctx.beginPath(); ctx.moveTo(b.x, b.y); ctx.lineTo(a.x, a.y); ctx.stroke(); }
824 ctx.globalCompositeOperation = "source-over"; }
825 drawCar(p.x, p.y, p.th, col, k === 0 ? 1 : 0.8, k === 0 ? 20 : 14, k === 0 ? "#0a1a2c" : "rgba(20,10,30,0.7)");
826 }
827 ctx.globalAlpha = 1;
828 // logo band
829 const lb = ctx.createLinearGradient(0, 190, 0, 560);
830 lb.addColorStop(0, "rgba(3,5,14,0)"); lb.addColorStop(0.25, "rgba(3,5,14,0.72)"); lb.addColorStop(0.75, "rgba(3,5,14,0.72)"); lb.addColorStop(1, "rgba(3,5,14,0)");
831 ctx.fillStyle = lb; ctx.fillRect(0, 190, W, 370);
832 const intro = ease(t / 0.7);
833 const logo = (s, y, size) => {
834 ctx.save(); ctx.translate(W / 2 + 10, y + (1 - intro) * 40); ctx.transform(1, 0, -0.16, 1, 0, 0); ctx.globalAlpha = intro;
835 for (let i = 5; i >= 1; i--) {
836 const off = i * (10 + 4 * Math.sin(t * 1.7 + i * 0.8));
837 ctx.globalAlpha = intro * 0.5 * (1 - i / 6);
838 txt(s, -off, 0, size, GHOST_COLS[(i - 1) % 5], "center", 900, 0, undefined, true);
839 }
840 ctx.globalAlpha = intro;
841 const gr = ctx.createLinearGradient(0, -size * 0.7, 0, size * 0.1);
842 gr.addColorStop(0, "#ffffff"); gr.addColorStop(0.55, "#bff9ff"); gr.addColorStop(1, PLAYER);
843 txt(s, 0, 0, size, gr, "center", 900, 30, PLAYER, true);
844 ctx.lineWidth = 2; ctx.strokeStyle = "rgba(255,255,255,0.9)"; ctx.font = "italic 900 " + size + "px " + FONT; ctx.textAlign = "center"; ctx.strokeText(s, 0, 0);
845 const ph = t % 3.4; // glitch slice
846 if (ph < 0.14) {
847 ctx.save(); ctx.beginPath(); ctx.rect(-260, -size * 0.55 + ((ph * 900) % (size * 0.6)), 520, 14); ctx.clip();
848 txt(s, 12, 0, size, "#ff3df0", "center", 900, 0, undefined, true); ctx.restore();
849 }
850 ctx.restore();
851 };
852 logo("GHOST", 336, 122); logo("LAP", 452, 122);
853 ctx.globalAlpha = intro; spaced("ONE TOUCH - ENDLESS ECHOES", W / 2, 508, 15, "rgba(200,230,255,0.85)", 5, "center", 700); ctx.globalAlpha = 1;
854 // start button
855 const p = 1 + Math.sin(t * 4.5) * 0.035;
856 ctx.save(); ctx.translate(W / 2, 668); ctx.scale(p, p);
857 rr(-160, -40, 320, 80, 40);
858 const bg = ctx.createLinearGradient(-160, 0, 160, 0); bg.addColorStop(0, "#19c8ff"); bg.addColorStop(1, "#b04dff");
859 ctx.fillStyle = bg; ctx.shadowColor = "#5b8dff"; ctx.shadowBlur = 30; ctx.fill(); ctx.shadowBlur = 0;
860 rr(-158, -38, 316, 76, 38); ctx.strokeStyle = "rgba(255,255,255,0.4)"; ctx.lineWidth = 2; ctx.stroke();
861 txt("TAP TO RACE", 0, 10, 30, "#ffffff", "center", 900);
862 ctx.restore();
863 // best
864 spaced("BEST SCORE", W / 2, 770, 13, "rgba(200,220,255,0.6)", 4, "center", 700);
865 txt(String(best), W / 2, 820, 46, "#ffe26a", "center", 900, 16, "#ffb703", true);
866 spaced("TRACK " + track.name, W / 2, 866, 13, th.rim, 4, "center", 700);
867 // controls hint
868 for (const side of [-1, 1]) {
869 const cx = W / 2 + side * 150, a = 0.25 + 0.2 * Math.sin(t * 3 + (side > 0 ? 1.5 : 0));
870 ctx.globalAlpha = a + 0.1; ctx.strokeStyle = "#cfe6ff"; ctx.lineWidth = 4; ctx.lineCap = "round"; ctx.lineJoin = "round";
871 ctx.beginPath(); ctx.moveTo(cx - side * 8, 910); ctx.lineTo(cx + side * 8, 924); ctx.lineTo(cx - side * 8, 938); ctx.stroke();
872 }
873 ctx.globalAlpha = 1;
874 spaced("HOLD LEFT / RIGHT TO STEER", W / 2, 930, 12, "rgba(200,220,255,0.6)", 3, "center", 700);
875 drawMute();
876 }
877
878 function draw() {
879 ctx.setTransform(VS, 0, 0, VS, 0, 0);
880 ctx.globalAlpha = 1; ctx.globalCompositeOperation = "source-over"; ctx.shadowBlur = 0;
881 ctx.fillStyle = "#04060f"; ctx.fillRect(0, 0, W, H);
882 if (mode === "title") { drawTitle(); return; }
883 ctx.save();
884 if (shake > 0.2) ctx.translate(rand(-shake, shake) * 0.5, rand(-shake, shake) * 0.5);
885 drawWorld();
886 ctx.restore();
887 if (flash > 0.01) { ctx.fillStyle = "rgba(" + flashCol + "," + (flash * 0.5) + ")"; ctx.fillRect(0, 0, W, H); }
888 if (G.lapFlash > 0) { ctx.strokeStyle = "rgba(125,255,176," + G.lapFlash * 0.6 + ")"; ctx.lineWidth = 10; ctx.strokeRect(5, 5, W - 10, H - 10); }
889 if (G.hitFlash > 0) { const vg = ctx.createRadialGradient(W / 2, H / 2, 300, W / 2, H / 2, 680); vg.addColorStop(0, "rgba(255,80,60,0)"); vg.addColorStop(1, "rgba(255,80,60," + G.hitFlash * 0.45 + ")"); ctx.fillStyle = vg; ctx.fillRect(0, 0, W, H); }
890 drawHUD();
891 if (mode === "play" && G.wait) drawTutorial();
892 if (mode === "over") drawOver();
893 }
894
895 // ---------- loop ----------
896 let last = performance.now();
897 function frame(now) {
898 requestAnimationFrame(frame);
899 let dt = (now - last) / 1000; last = now;
900 if (!(dt > 0)) return;
901 if (dt > 0.05) dt = 0.05;
902 if (!paused) update(dt);
903 draw();
904 }
905
906 // debug/test hooks (harmless in production)
907 window.__gl = {
908 info() { const g = G; return { mode, best, kind, N: track.N, lap: g ? g.lap : 0, score: g ? g.score : 0, hits: g ? g.hits : 0, ghosts: g ? g.ghosts.length : 0, x: g ? g.x : 0, y: g ? g.y : 0, th: g ? g.th : 0, v: g ? g.v : 0, why: g ? g.why : "", wait: g ? g.wait : false, combo: g ? g.combo : 0, maxCombo: g ? g.maxCombo : 0, idx: g ? g.idx : 0 }; },
909 steerHint(off) {
910 const g = G; if (!g) return 0;
911 const N = track.N, la = Math.round(16 + g.v / 14), i = (g.idx + la) % N, o = off || 0;
912 const want = Math.atan2(track.py[i] - track.tx[i] * o - g.y, track.px[i] + track.ty[i] * o - g.x), df = angDiff(g.th, want);
913 return df > 0.05 ? 1 : df < -0.05 ? -1 : 0;
914 },
915 toGhost() { const g = G; const gh = g && g.ghosts.find((o) => o.active); if (!gh) return false; g.x = gh.x; g.y = gh.y; return true; },
916 god(v) { dbg.god = !!v; },
917 resetTut() { store.set("ghostlap.tut", "0"); },
918 };
919
920 prepareTrack();
921 fit();
922 addEventListener("resize", fit);
923 addEventListener("orientationchange", () => setTimeout(fit, 120));
924 requestAnimationFrame(frame);
925})();
926