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 straightest114 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 chevrons168 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 line177 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 // recording401 g.recAcc += dt;402 while (g.recAcc >= SAMPLE) { g.recAcc -= SAMPLE; g.rec.push(g.x, g.y, g.th); }403 // trail404 g.trail.push(g.x, g.y); if (g.trail.length > 44) g.trail.splice(0, 2);405 // nearest track point406 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 // walls417 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 / skid435 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 motion487 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 marks570 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 + ghosts575 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 // trail593 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 halo602 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 lines608 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 trail615 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 puffs627 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 // player630 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 ring644 }645 // rings646 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 // sparks649 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 // popups657 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 meter693 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 pips703 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 // shields712 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 hints723 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 banner731 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 zones743 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 // card760 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 cars818 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 band829 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 slice846 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 button855 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 // best864 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 hint868 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