Chain Bloom
game.js56.6 KB
1// Chain Bloom - one tap, one bloom. Rendering, audio, UI. Simulation lives in sim.js.2(() => {3 "use strict";4 const CB = window.CB, W = CB.W, H = CB.H, TICK = CB.TICK, TY = CB.TY;5 const c = document.getElementById("c");6 const ctx = c.getContext("2d");7 const FONT = '"Trebuchet MS","Segoe UI","Avenir Next",system-ui,sans-serif';8 const LEVELS = 40, PER_PACK = 10;9 const TAU = Math.PI * 2;10 11 // ---------- storage ----------12 const store = {13 get(k) { try { return localStorage.getItem(k); } catch (e) { return null; } },14 set(k, v) { try { localStorage.setItem(k, v); } catch (e) {} },15 };16 let prog = { levels: {} };17 try { const p = JSON.parse(store.get("chainbloom.progress") || "null"); if (p && p.levels) prog = p; } catch (e) {}18 function saveProg() {19 store.set("chainbloom.progress", JSON.stringify(prog));20 if (window.Arcadia && Arcadia.save) { try { Arcadia.save(prog); } catch (e) {} }21 }22 const stars = (i) => (prog.levels[i] ? prog.levels[i].s : 0);23 const unlocked = (i) => i === 0 || stars(i - 1) > 0;24 const totalScore = () => Object.values(prog.levels).reduce((a, b) => a + (b.sc || 0), 0);25 const totalStars = () => Object.values(prog.levels).reduce((a, b) => a + (b.s || 0), 0);26 let bestZen = Number(store.get("chainbloom.best") || 0);27 let muted = store.get("chainbloom.muted") === "1";28 29 // ---------- view ----------30 let view = { s: 1, ox: 0, oy: 0, dpr: 1, cw: 0, ch: 0 }, bgCanvas = null;31 function fit() {32 const dpr = Math.min(2, window.devicePixelRatio || 1), iw = innerWidth, ih = innerHeight;33 const s = Math.min(iw / W, ih / H);34 view = { s, ox: (iw - W * s) / 2, oy: (ih - H * s) / 2, dpr, cw: iw, ch: ih };35 c.style.width = iw + "px"; c.style.height = ih + "px";36 c.width = Math.round(iw * dpr); c.height = Math.round(ih * dpr);37 buildBg();38 }39 function applyView() { ctx.setTransform(view.s * view.dpr, 0, 0, view.s * view.dpr, view.ox * view.dpr, view.oy * view.dpr); }40 const ext = () => ({ l: -view.ox / view.s, r: W + view.ox / view.s, t: -view.oy / view.s, b: H + view.oy / view.s });41 42 // ---------- sprites ----------43 const glowCache = {};44 function glow(h, s = 100, l = 60) {45 const k = h + "_" + s + "_" + l;46 if (glowCache[k]) return glowCache[k];47 const cv = document.createElement("canvas"); cv.width = cv.height = 64;48 const g = cv.getContext("2d"), gr = g.createRadialGradient(32, 32, 0, 32, 32, 32);49 gr.addColorStop(0, `hsla(${h},${s}%,${l + 15}%,1)`); gr.addColorStop(0.35, `hsla(${h},${s}%,${l}%,.45)`); gr.addColorStop(1, `hsla(${h},${s}%,${l}%,0)`);50 g.fillStyle = gr; g.fillRect(0, 0, 64, 64);51 return (glowCache[k] = cv);52 }53 function drawGlow(x, y, r, h, a, s, l) { ctx.globalAlpha = a; ctx.drawImage(glow(Math.round(h / 6) * 6, s, l), x - r, y - r, r * 2, r * 2); ctx.globalAlpha = 1; }54 const mistSprite = (() => {55 const cv = document.createElement("canvas"); cv.width = cv.height = 128;56 const g = cv.getContext("2d"), gr = g.createRadialGradient(64, 64, 0, 64, 64, 64);57 gr.addColorStop(0, "rgba(150,230,230,.55)"); gr.addColorStop(1, "rgba(150,230,230,0)");58 g.fillStyle = gr; g.fillRect(0, 0, 128, 128); return cv;59 })();60 61 function buildBg() {62 bgCanvas = document.createElement("canvas");63 bgCanvas.width = c.width; bgCanvas.height = c.height;64 const g = bgCanvas.getContext("2d"), e = ext();65 g.setTransform(view.s * view.dpr, 0, 0, view.s * view.dpr, view.ox * view.dpr, view.oy * view.dpr);66 const gr = g.createLinearGradient(0, e.t, 0, e.b);67 gr.addColorStop(0, "#0b0f3a"); gr.addColorStop(0.35, "#0a2452"); gr.addColorStop(0.7, "#07404a"); gr.addColorStop(1, "#04182a");68 g.fillStyle = gr; g.fillRect(e.l, e.t, e.r - e.l, e.b - e.t);69 let rg = g.createRadialGradient(270, 520, 0, 270, 520, 520);70 rg.addColorStop(0, "rgba(20,200,190,.24)"); rg.addColorStop(0.6, "rgba(20,120,160,.10)"); rg.addColorStop(1, "rgba(0,0,0,0)");71 g.fillStyle = rg; g.fillRect(e.l, e.t, e.r - e.l, e.b - e.t);72 // lily pads73 const r = CB.rng(77);74 for (let i = 0; i < 34; i++) {75 const x = e.l - 60 + r() * (e.r - e.l + 120), y = e.t - 60 + r() * (e.b - e.t + 120), rad = 26 + r() * 52, an = r() * TAU;76 // keep the pond centre clearer77 if (x > 90 && x < 450 && y > 170 && y < 830 && r() < 0.85) continue;78 g.save(); g.translate(x, y); g.rotate(an);79 const pg = g.createRadialGradient(0, 0, 0, 0, 0, rad);80 pg.addColorStop(0, "rgba(16,92,86,.85)"); pg.addColorStop(1, "rgba(8,48,58,.85)");81 g.fillStyle = pg; g.beginPath(); g.moveTo(0, 0); g.arc(0, 0, rad, 0.22, TAU - 0.22); g.closePath(); g.fill();82 g.strokeStyle = "rgba(120,255,225,.16)"; g.lineWidth = 1.5; g.stroke();83 g.strokeStyle = "rgba(120,255,225,.08)"; g.beginPath();84 for (let k = 0; k < 6; k++) { const a = 0.6 + k * 0.95; g.moveTo(0, 0); g.lineTo(Math.cos(a) * rad * 0.9, Math.sin(a) * rad * 0.9); }85 g.stroke(); g.restore();86 if (r() < 0.22) { g.globalCompositeOperation = "lighter"; g.globalAlpha = 0.55; g.drawImage(glow(320, 90, 62), x - 30, y - 30, 60, 60); g.globalAlpha = 1; g.globalCompositeOperation = "source-over"; }87 }88 // vignette89 rg = g.createRadialGradient(270, 480, 300, 270, 480, Math.max(900, (e.r - e.l) * 0.8));90 rg.addColorStop(0, "rgba(2,6,20,0)"); rg.addColorStop(1, "rgba(2,6,20,.72)");91 g.fillStyle = rg; g.fillRect(e.l, e.t, e.r - e.l, e.b - e.t);92 }93 94 // ---------- audio ----------95 const AU = { ctx: null };96 const PENT = [0, 2, 4, 7, 9];97 function noteFreq(i) {98 let j = i % 32; if (j > 16) j = 32 - j;99 return 261.63 * Math.pow(2, (PENT[j % 5] + 12 * Math.floor(j / 5)) / 12);100 }101 function auInit() {102 try {103 if (AU.ctx) { if (AU.ctx.state === "suspended") AU.ctx.resume(); return; }104 const AC = window.AudioContext || window.webkitAudioContext; if (!AC) return;105 const a = new AC(); AU.ctx = a; AU.voices = 0;106 const master = a.createGain(); master.gain.value = muted ? 0 : 0.85;107 const comp = a.createDynamicsCompressor(); master.connect(comp); comp.connect(a.destination);108 const verb = a.createConvolver(), len = Math.floor(a.sampleRate * 2.2), ir = a.createBuffer(2, len, a.sampleRate);109 for (let ch = 0; ch < 2; ch++) { const d = ir.getChannelData(ch); for (let i = 0; i < len; i++) d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / len, 2.6); }110 verb.buffer = ir; const wet = a.createGain(); wet.gain.value = 0.34; verb.connect(wet); wet.connect(master);111 AU.master = master; AU.verb = verb;112 const nl = a.sampleRate, nb = a.createBuffer(1, nl, a.sampleRate), nd = nb.getChannelData(0);113 for (let i = 0; i < nl; i++) nd[i] = Math.random() * 2 - 1;114 AU.noise = nb;115 [65.41, 98, 130.8].forEach((f, i) => {116 const o = a.createOscillator(), g = a.createGain(); o.type = "sine"; o.frequency.value = f; g.gain.value = 0.018 - i * 0.004;117 o.connect(g); g.connect(master); o.start();118 });119 } catch (e) {}120 }121 function tone(f, t0, dur, vol, type, send, slideTo) {122 const a = AU.ctx; if (!a || AU.voices > 40) return;123 try {124 const o = a.createOscillator(), g = a.createGain(); o.type = type || "sine";125 o.frequency.setValueAtTime(f, t0); if (slideTo) o.frequency.exponentialRampToValueAtTime(slideTo, t0 + dur);126 g.gain.setValueAtTime(0.0001, t0); g.gain.exponentialRampToValueAtTime(vol, t0 + 0.006); g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);127 o.connect(g); g.connect(AU.master); if (send !== 0) { const s = a.createGain(); s.gain.value = send == null ? 0.6 : send; g.connect(s); s.connect(AU.verb); }128 AU.voices++; o.onended = () => AU.voices--; o.start(t0); o.stop(t0 + dur + 0.05);129 } catch (e) {}130 }131 function thud(t0, dur, vol, freq) {132 const a = AU.ctx; if (!a) return;133 try {134 const s = a.createBufferSource(), f = a.createBiquadFilter(), g = a.createGain(); s.buffer = AU.noise; f.type = "lowpass"; f.frequency.value = freq || 300;135 g.gain.setValueAtTime(vol, t0); g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur); s.connect(f); f.connect(g); g.connect(AU.master); s.start(t0); s.stop(t0 + dur);136 } catch (e) {}137 }138 let auStagger = 0, auLast = 0;139 function sfx(name, note, type) {140 const a = AU.ctx; if (!a || muted) return;141 const now = a.currentTime; if (now - auLast > 0.05) auStagger = 0; auLast = now;142 const t = now + 0.005 + auStagger; auStagger += 0.028;143 switch (name) {144 case "bloom": {145 const f = noteFreq(note);146 tone(f, t, 1.4, 0.15, "sine"); tone(f * 2, t, 0.6, 0.05, "triangle"); tone(f * 4.01, t, 0.25, 0.018, "sine", 0.3);147 if (type === "p") tone(f / 2, t, 1.6, 0.2, "sine");148 if (type === "i") { tone(f * 4, t, 0.9, 0.05, "triangle"); tone(f * 6, t + 0.05, 0.6, 0.03, "sine"); }149 if (type === "m") tone(f * 0.5, t, 0.8, 0.12, "triangle");150 if (type === "b") { thud(t, 0.9, 0.7, 180); tone(f / 2, t, 1.5, 0.22, "sine"); }151 break;152 }153 case "seed": { const f = noteFreq(note); tone(f * 2, t, 0.5, 0.08, "triangle"); break; }154 case "spark": tone(330, t, 0.35, 0.14, "sine", 0.6, 990); tone(660, t, 0.4, 0.06, "triangle"); break;155 case "thorn": tone(110, t, 0.35, 0.16, "sawtooth", 0.2, 55); thud(t, 0.3, 0.35, 500); break;156 case "dud": thud(t, 0.4, 0.5, 220); tone(90, t, 0.4, 0.14, "sine", 0.2, 45); break;157 case "freeze": tone(2200, t, 0.5, 0.05, "sine"); tone(3300, t + 0.04, 0.4, 0.03, "sine"); break;158 case "charge": tone(160, t, 0.6, 0.1, "triangle", 0.3, 480); break;159 case "goal": [0, 4, 7].forEach((s, i) => tone(523.25 * Math.pow(2, s / 12), t + i * 0.07, 0.9, 0.09, "triangle")); break;160 case "win": [0, 2, 4, 7, 9, 12].forEach((s, i) => tone(261.63 * Math.pow(2, s / 12), t + i * 0.09, 1.6, 0.13, "sine")); break;161 case "star": tone(523.25 * Math.pow(2, note / 12), t, 1.2, 0.14, "triangle"); tone(1046.5 * Math.pow(2, note / 12), t, 0.6, 0.05, "sine"); break;162 case "fail": [7, 3, 0, -5].forEach((s, i) => tone(261.63 * Math.pow(2, s / 12), t + i * 0.16, 1.2, 0.12, "sine")); break;163 case "ui": tone(880, t, 0.18, 0.08, "sine", 0.3); break;164 }165 }166 function setMuted(m) {167 muted = m; store.set("chainbloom.muted", m ? "1" : "0");168 if (AU.master) AU.master.gain.value = m ? 0 : 0.85;169 }170 171 // ---------- state ----------172 let state = "title", G = null, paused = false, T = 0, mouse = null, shake = 0, flash = 0;173 const ui = { btns: [], press: {} };174 const levelCache = {};175 function getLevel(i) { return levelCache[i] || (levelCache[i] = CB.genLevel(i)); }176 const rampHue = (d) => (190 + d * 26) % 360;177 const ramp = (d, l = 62, s = 95, a = 1) => `hsla(${rampHue(d)},${s}%,${l}%,${a})`;178 const clamp = (v, a, b) => Math.max(a, Math.min(b, v));179 const fmt = (n) => Math.round(n).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");180 181 // visual systems182 let parts = [], ripples = [], texts = [], mist = [], pollen = [];183 { const r = CB.rng(5);184 for (let i = 0; i < 9; i++) mist.push({ x: r() * 900 - 180, y: r() * 1100 - 70, s: 260 + r() * 300, v: (4 + r() * 10) * (r() < 0.5 ? -1 : 1), a: 0.05 + r() * 0.06, layer: i % 3 });185 for (let i = 0; i < 46; i++) pollen.push({ x: r() * 540, y: r() * 960, v: 6 + r() * 14, ph: r() * TAU, s: 0.8 + r() * 1.8 }); }186 function ripple(x, y, h, maxR, life) { ripples.push({ x, y, h, maxR, t: 0, life: life || 1.6 }); if (ripples.length > 60) ripples.shift(); }187 function petals(x, y, h, n, spd) {188 for (let i = 0; i < n && parts.length < 420; i++) {189 const a = Math.random() * TAU, v = (0.4 + Math.random()) * spd;190 parts.push({ x, y, vx: Math.cos(a) * v, vy: Math.sin(a) * v, rot: Math.random() * TAU, vr: (Math.random() - 0.5) * 8, t: 0, life: 0.9 + Math.random() * 0.9, size: 5 + Math.random() * 6, h: h + (Math.random() - 0.5) * 30 });191 }192 }193 function floatText(x, y, s, h, size) { texts.push({ x, y, s, h, t: 0, size: size || 16 }); if (texts.length > 40) texts.shift(); }194 195 // ---------- game flow ----------196 function goto(s) { state = s; ui.btns = []; }197 function newPlay(mode, idx, level) {198 const w = CB.makeWorld(level);199 const prev = G && G.mode === mode && G.idx === idx ? G : null;200 G = Object.assign(G && mode === "zen" && G.mode === "zen" ? { zen: G.zen } : {}, {201 mode, idx, level, world: w, phase: "ready", ts: 1, slowT: 0, finale: 0, combo: { chain: 0, mult: 1, pop: 0, best: 0 }, fails: prev ? prev.fails : 0,202 tipT: 0, res: null, resT: 0, showGoalPulse: 0, taps0: level.taps, scoreShown: 0,203 });204 parts = []; ripples = []; texts = [];205 goto("play");206 }207 function loadThen(fn) {208 state = "loading"; ui.btns = [];209 setTimeout(() => setTimeout(fn, 20), 20);210 }211 function startLevel(i) {212 if (levelCache[i]) { newPlay("campaign", i, levelCache[i]); return; }213 G = { mode: "campaign", idx: i }; loadThen(() => newPlay("campaign", i, getLevel(i)));214 }215 function dailySeed() { const d = new Date(); return d.getFullYear() * 10000 + (d.getMonth() + 1) * 100 + d.getDate(); }216 function zenLevel(round) {217 const key = "z" + dailySeed() + "_" + round;218 return levelCache[key] || (levelCache[key] = CB.genLevel(200 + round, { e: Math.min(8 + round, 44), seed: CB.hash(dailySeed(), round + 1), oneTap: true }));219 }220 function startZen() {221 G = { mode: "zen", zen: { round: 0, lives: 3, total: 0, cleared: 0 } };222 loadThen(() => newPlay("zen", 0, zenLevel(0)));223 }224 function zenNext() {225 const z = G.zen; G = { mode: "zen", zen: z };226 loadThen(() => newPlay("zen", z.round, zenLevel(z.round)));227 }228 function retry() {229 if (state !== "play" || !G || !G.level) return;230 if (G.mode === "zen" && G.phase === "result" && G.res && !G.res.win) { zenAfterFail(); return; }231 if (G.mode === "zen" && G.phase === "result" && G.res && G.res.win) { zenNext(); return; }232 if (G.mode === "zen" && G.phase !== "ready" && G.phase !== "result") { /* zen: no free retry mid-round */ return; }233 if (G.mode === "zen" && G.phase === "ready") { const l = G.level; newPlay("zen", G.idx, l); return; }234 const fails = G.fails; newPlay(G.mode, G.idx, G.level); G.fails = fails;235 }236 function zenAfterFail() {237 const z = G.zen;238 if (z.lives <= 0) { zenOver(); return; }239 zenNext();240 }241 function zenOver() {242 const z = G.zen;243 if (z.total > bestZen) { bestZen = z.total; store.set("chainbloom.best", String(bestZen)); }244 if (window.Arcadia) { try { Arcadia.submitScore(z.total); Arcadia.gameOver && Arcadia.gameOver(); } catch (e) {} }245 G.phase = "zenover"; goto("zenover");246 }247 function nextLevel() {248 if (G.idx + 1 < LEVELS) startLevel(G.idx + 1); else { goto("map"); mapPage = PACK_LAST; }249 }250 const PACK_LAST = LEVELS / PER_PACK - 1;251 let mapPage = 0;252 253 function finishRound() {254 const w = G.world, L = G.level, win = w.count >= L.goal;255 const st = win ? 1 + (w.count >= L.s2 ? 1 : 0) + (w.count >= L.s3 ? 1 : 0) : 0;256 const bonus = win ? w.tapsLeft * 250 + st * 300 : 0;257 const score = w.score + bonus;258 G.res = { win, stars: st, score, base: w.score, bonus, count: w.count, maxChain: w.maxChain };259 G.phase = "result"; G.resT = 0; G.starShown = 0;260 if (G.mode === "campaign") {261 if (win) {262 const old = prog.levels[G.idx];263 G.res.newBest = !old || score > old.sc;264 prog.levels[G.idx] = { s: Math.max(st, old ? old.s : 0), sc: Math.max(score, old ? old.sc : 0) };265 saveProg();266 if ((G.idx + 1) % PER_PACK === 0 || G.idx === LEVELS - 1) if (window.Arcadia) { try { Arcadia.submitScore(totalScore()); } catch (e) {} }267 } else G.fails++;268 } else {269 const z = G.zen;270 if (win) { z.total += score; z.round++; z.cleared++; } else z.lives--;271 if (z.total > bestZen) { bestZen = z.total; store.set("chainbloom.best", String(bestZen)); }272 }273 sfx(win ? "win" : "fail");274 if (!win) shake = 0;275 if (win) flash = 0.5;276 }277 278 // ---------- input ----------279 function toLogical(e) { return { x: (e.clientX - view.ox) / view.s, y: (e.clientY - view.oy) / view.s }; }280 function hitBtn(p) { for (let i = ui.btns.length - 1; i >= 0; i--) { const b = ui.btns[i]; if (p.x >= b.x && p.x <= b.x + b.w && p.y >= b.y && p.y <= b.y + b.h) return b; } return null; }281 let swipe = null;282 c.addEventListener("pointerdown", (e) => {283 e.preventDefault(); auInit();284 const p = toLogical(e); mouse = p;285 const b = hitBtn(p);286 if (b) { ui.press[b.id] = 0.15; sfx("ui"); b.fn(); return; }287 swipe = { x: p.x, y: p.y };288 if (state === "play" && G && G.phase === "ready" && G.world.tapsLeft > 0) {289 plant(clamp(p.x, 30, W - 30), clamp(p.y, 150, 900));290 }291 if (state === "title" && !b) { /* taps on title do nothing except audio init */ }292 });293 c.addEventListener("pointermove", (e) => { mouse = toLogical(e); });294 addEventListener("pointerup", (e) => {295 if (swipe && state === "map") {296 const p = toLogical(e), dx = p.x - swipe.x;297 if (Math.abs(dx) > 70) mapPage = clamp(mapPage + (dx < 0 ? 1 : -1), 0, PACK_LAST);298 }299 swipe = null;300 });301 c.addEventListener("contextmenu", (e) => e.preventDefault());302 addEventListener("keydown", (e) => {303 auInit();304 const k = e.key;305 if (k === "m" || k === "M") { setMuted(!muted); return; }306 if (k === " " || k === "r" || k === "R") { e.preventDefault(); if (state === "play") retry(); else if (state === "title") goto("map"); return; }307 if (k === "Enter") {308 if (state === "title") goto("map");309 else if (state === "play" && G.phase === "result" && G.res.win) { G.mode === "zen" ? zenNext() : nextLevel(); }310 else if (state === "zenover") startZen();311 }312 if (k === "Escape") { if (state === "play") exitPlay(); else if (state === "map" || state === "zenover") goto("title"); }313 });314 function exitPlay() { if (G && G.mode === "zen" && G.zen.cleared + (G.zen.lives < 3 ? 1 : 0) > 0) { zenOver(); return; } if (G && G.mode === "campaign") mapPage = Math.floor(G.idx / PER_PACK); goto(G && G.mode === "zen" ? "title" : "map"); }315 if (window.Arcadia) { try { Arcadia.onPause(() => (paused = true)); Arcadia.onResume(() => (paused = false)); } catch (e) {} }316 document.addEventListener("visibilitychange", () => { if (document.hidden) paused = true; else if (!window.Arcadia) paused = false; });317 addEventListener("blur", () => { if (!window.Arcadia) paused = true; });318 addEventListener("focus", () => { paused = false; });319 320 function plant(x, y) {321 const w = G.world;322 if (CB.tap(w, x, y)) { G.phase = "chain"; G.tipT = 1; }323 }324 325 // ---------- update ----------326 function handleEvents(w) {327 for (const e of w.events) {328 switch (e.k) {329 case "spark": ripple(e.x, e.y, 190, 130, 1.4); petals(e.x, e.y, 190, 10, 120); sfx("spark"); break;330 case "bloom": {331 const h = rampHue(e.depth);332 petals(e.x, e.y, h, e.type === "p" || e.type === "b" ? 22 : 9, e.type === "p" ? 190 : 120);333 ripple(e.x, e.y, h, TY[e.type].R * 1.5, 1.7);334 floatText(e.x, e.y - 14, "+" + fmt(e.pts), h, e.chain > 10 ? 20 : 16);335 G.combo.chain = e.chain; G.combo.mult = e.mult; G.combo.pop = 1; G.combo.best = Math.max(G.combo.best, e.chain);336 sfx("bloom", e.note, e.type);337 if (e.type === "p" || e.type === "b") shake = Math.max(shake, 5);338 break;339 }340 case "seed": {341 const h = rampHue(e.depth); petals(e.x, e.y, h, 6, 90); ripple(e.x, e.y, h, 90, 1.2);342 floatText(e.x, e.y - 10, "+" + fmt(e.pts), h, 14); G.combo.chain = e.chain; G.combo.mult = e.mult; G.combo.pop = 1;343 sfx("seed", e.note); break;344 }345 case "thorn": petals(e.x, e.y, 290, 12, 130); ripple(e.x, e.y, 300, 70, 1); floatText(e.x, e.y - 12, "COMBO BROKEN", 330, 15); G.combo.chain = 0; G.combo.mult = 1; G.combo.pop = 1; sfx("thorn"); shake = Math.max(shake, 4); G.hitstop = 0.09; break;346 case "dud": petals(e.x, e.y, 20, 10, 90); ripple(e.x, e.y, 20, 60, 1); floatText(e.x, e.y - 12, "FIZZLED", 20, 15); G.combo.chain = 0; G.combo.mult = 1; G.combo.pop = 1; sfx("dud"); break;347 case "boom": shake = 9; flash = Math.max(flash, 0.25); floatText(e.x, e.y - 30, "DETONATED!", 45, 22); break;348 case "freeze": sfx("freeze"); ripple(e.x, e.y, 200, 50, 1); break;349 case "charge": sfx("charge"); ripple(e.x, e.y, 320, 100, 1); break;350 case "goal": G.showGoalPulse = 1; sfx("goal"); floatText(W / 2, 200, "GOAL REACHED", 140, 22); break;351 }352 }353 w.events.length = 0;354 }355 function stepWorld(dt) {356 const w = G.world;357 let scale = G.ts;358 const active = w.blooms.length + w.seeds.length;359 let target = active >= 6 ? 0.55 - Math.min(0.2, (active - 6) * 0.03) : 1;360 if (G.phase === "finale") target = 0.5;361 G.ts += (target - G.ts) * Math.min(1, dt * 6);362 if (G.hitstop > 0) { G.hitstop -= dt; scale = 0.1; }363 G.acc = (G.acc || 0) + dt * scale;364 let n = 0;365 while (G.acc >= TICK && n < 8) { G.acc -= TICK; CB.step(w); n++; }366 handleEvents(w);367 }368 function update(dt) {369 T += dt;370 shake = Math.max(0, shake - dt * 22); flash = Math.max(0, flash - dt * 1.4);371 for (const k in ui.press) { ui.press[k] -= dt; if (ui.press[k] <= 0) delete ui.press[k]; }372 // ambient visuals always373 for (const p of parts) { p.t += dt; p.x += p.vx * dt; p.y += p.vy * dt; p.vx *= 1 - dt * 1.6; p.vy *= 1 - dt * 1.6; p.rot += p.vr * dt; }374 parts = parts.filter((p) => p.t < p.life);375 for (const r of ripples) r.t += dt; ripples = ripples.filter((r) => r.t < r.life);376 for (const t of texts) t.t += dt; texts = texts.filter((t) => t.t < 1.3);377 for (const m of mist) { m.x += m.v * dt * (0.5 + m.layer * 0.5); if (m.x > 720) m.x = -260; if (m.x < -260) m.x = 720; }378 for (const p of pollen) { p.y -= p.v * dt; if (p.y < -10) { p.y = 970; p.x = Math.random() * 540; } }379 if (state === "title") updateAttract(dt);380 if (state !== "play" || !G || !G.world) return;381 const w = G.world;382 G.combo.pop = Math.max(0, G.combo.pop - dt * 4);383 G.showGoalPulse = Math.max(0, G.showGoalPulse - dt * 1.5);384 G.tipT = Math.max(0, G.tipT - dt * 0.9);385 G.scoreShown += (w.score - G.scoreShown) * Math.min(1, dt * 8);386 if (G.phase === "ready" || G.phase === "chain" || G.phase === "finale") stepWorld(dt);387 else if (G.phase === "result") { G.ts = 1; G.acc = (G.acc || 0) + dt; let n = 0; while (G.acc >= TICK && n < 4) { G.acc -= TICK; CB.step(w); n++; } w.events.length = 0; }388 if (G.phase === "chain" && CB.isSettled(w)) {389 if (w.count >= G.level.goal || w.tapsLeft <= 0) { G.phase = "finale"; G.finale = 0.9; }390 else G.phase = "ready";391 }392 if (G.phase === "finale") { G.finale -= dt; if (G.finale <= 0) finishRound(); }393 if (G.phase === "result") {394 const prev = G.resT; G.resT += dt;395 if (G.res.win) {396 for (let s = 1; s <= G.res.stars; s++) if (prev < 0.5 + s * 0.4 && G.resT >= 0.5 + s * 0.4) { sfx("star", s * 2 + 2); petals(W / 2 + (s - 2) * 100, 405, 45, 14, 160); }397 if (G.mode === "campaign" && G.resT > 2.4 && !G.pref && G.idx + 1 < LEVELS && !levelCache[G.idx + 1]) { G.pref = true; setTimeout(() => getLevel(G.idx + 1), 30); }398 }399 }400 }401 402 // attract mode (title background)403 let att = null;404 function updateAttract(dt) {405 if (!att || att.reset) {406 const seed = att ? att.seed + 1 : 3;407 const lvl = { index: 0, taps: 1, goal: 999, crystals: CB.genLevel ? attractLayout(seed) : [] };408 att = { world: CB.makeWorld(lvl), seed, t: 0, tapped: false, acc: 0, settledT: 0 };409 }410 const w = att.world; att.t += dt;411 if (!att.tapped && att.t > 2.2) {412 let best = null, bs = -1;413 for (const cr of w.crystals) {414 let n = 0; for (const o of w.crystals) if (Math.hypot(o.x - cr.x, o.y - cr.y) < 140) n++;415 if (n > bs) { bs = n; best = cr; }416 }417 CB.tap(w, best.x + 6, best.y + 4); att.tapped = true; playAttractEvents.on = true;418 }419 att.acc += dt * (w.blooms.length > 5 ? 0.6 : 1);420 let n = 0; while (att.acc >= TICK && n < 6) { att.acc -= TICK; CB.step(w); n++; }421 for (const e of w.events) attractEvent(e);422 w.events.length = 0;423 if (att.tapped && CB.isSettled(w)) { att.settledT += dt; if (att.settledT > 2.2) att.reset = true; }424 }425 const playAttractEvents = { on: false };426 function attractEvent(e) {427 if (e.k === "bloom") { const h = rampHue(e.depth); petals(e.x, e.y, h, 7, 110); ripple(e.x, e.y, h, TY[e.type].R * 1.4, 1.6); if (AU.ctx && !muted) sfx("bloom", e.note, e.type); }428 if (e.k === "spark") { ripple(e.x, e.y, 190, 120, 1.4); }429 }430 function attractLayout(seed) {431 const r = CB.rng(seed * 991), out = [];432 for (let i = 0, tries = 0; i < 26 && tries < 900; tries++) {433 const x = 40 + r() * 460, y = 60 + r() * 850;434 if (out.some((o) => Math.hypot(o.ax - x, o.ay - y) < 62)) continue;435 if (x > 40 && x < 500 && ((y > 110 && y < 540) || (y > 585 && y < 790))) continue; // keep logo and buttons airy436 const type = r() < 0.12 ? "p" : "n"; i++;437 out.push({ type, ax: x, ay: y, Ax: 8 + r() * 10, Ay: 8 + r() * 10, wx: 0.3 + r() * 0.4, wy: 0.3 + r() * 0.4, px: r() * TAU, py: r() * TAU, hue: 178 + Math.floor(r() * 120), rot: r() * TAU });438 }439 return out;440 }441 442 // ---------- drawing primitives ----------443 function rr(x, y, w, h, r) {444 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); ctx.arcTo(x, y + h, x, y, r); ctx.arcTo(x, y, x + w, y, r); ctx.closePath();445 }446 function text(s, x, y, size, col, align, weight, glowCol, spacing) {447 ctx.font = `${weight || "700"} ${size}px ${FONT}`; ctx.textAlign = align || "center"; ctx.textBaseline = "middle";448 try { ctx.letterSpacing = (spacing || 0) + "px"; } catch (e) {}449 if (glowCol) { ctx.shadowColor = glowCol; ctx.shadowBlur = 14; }450 ctx.fillStyle = col || "#fff"; ctx.fillText(s, x, y);451 ctx.shadowBlur = 0; try { ctx.letterSpacing = "0px"; } catch (e) {}452 }453 function facet(len, wid, cl, cr) {454 ctx.fillStyle = cl; ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(-wid, -len * 0.42); ctx.lineTo(0, -len); ctx.closePath(); ctx.fill();455 ctx.fillStyle = cr; ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(wid, -len * 0.42); ctx.lineTo(0, -len); ctx.closePath(); ctx.fill();456 }457 function flower(x, y, n, len, wid, h, rot, s, l, alpha, inner) {458 ctx.save(); ctx.translate(x, y); ctx.rotate(rot); ctx.globalAlpha = alpha;459 const step = TAU / n;460 for (let i = 0; i < n; i++) { facet(len, wid, `hsl(${h},${s}%,${l}%)`, `hsl(${h},${s}%,${l - 13}%)`); ctx.rotate(step); }461 if (inner) {462 ctx.rotate(step / 2);463 for (let i = 0; i < n; i++) { facet(len * 0.62, wid * 0.7, `hsl(${h},${s}%,${l + 20}%)`, `hsl(${h},${s}%,${l + 8}%)`); ctx.rotate(step); }464 }465 ctx.restore(); ctx.globalAlpha = 1;466 }467 function star5(x, y, R, fill, stroke) {468 ctx.beginPath();469 for (let i = 0; i < 10; i++) { const a = -Math.PI / 2 + (i * Math.PI) / 5, r = i % 2 ? R * 0.45 : R; ctx.lineTo(x + Math.cos(a) * r, y + Math.sin(a) * r); }470 ctx.closePath(); if (fill) { ctx.fillStyle = fill; ctx.fill(); } if (stroke) { ctx.strokeStyle = stroke; ctx.lineWidth = 2; ctx.stroke(); }471 }472 const TYPEHUE = { p: 46, m: 318, s: 142, i: 196, b: 18 };473 474 function drawCrystal(cr, w) {475 const T0 = TY[cr.type], r = T0.r, x = cr.x, y = cr.y, tt = T + cr.id * 0.7;476 if (cr.state === 3) {477 const age = w.time - cr.t0;478 if (age < 1.4 && cr.type !== "t") { ctx.globalAlpha = 1 - age / 1.4; ctx.fillStyle = "#2a1408"; ctx.beginPath(); ctx.arc(x, y, r * (1 + age * 0.6), 0, TAU); ctx.fill(); ctx.globalAlpha = 1; }479 return;480 }481 if (cr.state === 2) {482 const age = w.time - cr.t0, op = 1 + Math.min(0.35, age * 1.2), a = Math.max(0.42, 1 - age * 0.5), h = rampHue(cr.depth);483 ctx.globalCompositeOperation = "lighter"; drawGlow(x, y, r * 3.6, h, 0.5 * a); ctx.globalCompositeOperation = "source-over";484 const n = cr.type === "p" ? 8 : cr.type === "i" ? 6 : 7;485 flower(x, y, n, r * 1.85 * op, r * 0.78 * op, h, cr.rot + age * 0.5, 90, 60, a, true);486 ctx.fillStyle = `rgba(255,255,255,${0.9 * a})`; ctx.beginPath(); ctx.arc(x, y, r * 0.3, 0, TAU); ctx.fill();487 return;488 }489 const pulse = 1 + Math.sin(tt * 2.2) * 0.04, rot = cr.rot + T * 0.15;490 const frozen = cr.state === 1;491 let sx = x, sy = y;492 if (frozen) { const k = 1 - cr.timer / cr.timer0; sx += Math.sin(T * 60) * k * 1.6; sy += Math.cos(T * 53) * k * 1.6; }493 const hue = TYPEHUE[cr.type] || cr.hue;494 if (cr.type === "t") {495 ctx.globalCompositeOperation = "lighter"; drawGlow(x, y, r * 3.2, 300, 0.45 + 0.15 * Math.sin(tt * 3), 90, 45); ctx.globalCompositeOperation = "source-over";496 ctx.save(); ctx.translate(x, y); ctx.rotate(-rot * 1.5);497 for (let i = 0; i < 9; i++) {498 ctx.beginPath(); ctx.moveTo(-3, 0); ctx.lineTo(0, -r * (i % 2 ? 1.5 : 2.1)); ctx.lineTo(3, 0); ctx.closePath();499 ctx.fillStyle = "#12061e"; ctx.fill(); ctx.strokeStyle = "rgba(255,70,200,.85)"; ctx.lineWidth = 1.2; ctx.stroke(); ctx.rotate(TAU / 9);500 }501 ctx.restore();502 ctx.fillStyle = "#1c0a2c"; ctx.beginPath(); ctx.arc(x, y, r * 0.55, 0, TAU); ctx.fill(); ctx.strokeStyle = "rgba(255,80,210,.9)"; ctx.lineWidth = 1.5; ctx.stroke();503 return;504 }505 ctx.globalCompositeOperation = "lighter";506 drawGlow(x, y, r * 3.6, hue, 0.5 + 0.12 * Math.sin(tt * 2.4), cr.type === "i" ? 70 : 100, cr.type === "i" ? 72 : 60);507 ctx.globalCompositeOperation = "source-over";508 if (cr.type === "p" || cr.type === "i" || cr.type === "m") {509 ctx.strokeStyle = `hsla(${hue},80%,70%,${0.13 + 0.05 * Math.sin(T * 3)})`; ctx.lineWidth = 1.5; ctx.setLineDash([4, 9]); ctx.lineDashOffset = -T * 10;510 ctx.beginPath(); ctx.arc(x, y, cr.type === "m" ? T0.pullR * 0.55 : T0.R, 0, TAU); ctx.stroke(); ctx.setLineDash([]);511 }512 switch (cr.type) {513 case "n": flower(sx, sy, 6, r * 1.7 * pulse, r * 0.72, cr.hue, rot, 80, 62, 1, true); break;514 case "p": flower(sx, sy, 8, r * 1.9 * pulse, r * 0.7, 46, rot, 95, 60, 1, true); break;515 case "m":516 flower(sx, sy, 5, r * 1.7 * pulse, r * 0.8, 318, rot, 85, 60, 1, true);517 ctx.strokeStyle = "rgba(255,150,240,.8)"; ctx.lineWidth = 2.2;518 for (let k = 0; k < 2; k++) { const a = T * 2.5 + k * Math.PI; ctx.beginPath(); ctx.arc(x, y, r * 2.05, a, a + 1.2); ctx.stroke(); }519 break;520 case "s":521 flower(sx, sy, 5, r * 1.6 * pulse, r * 0.75, 142, rot, 75, 60, 1, true);522 for (let k = 0; k < 3; k++) { const a = T * 1.6 + (k * TAU) / 3; ctx.fillStyle = "#c8ffd8"; ctx.beginPath(); ctx.arc(x + Math.cos(a) * r * 2, y + Math.sin(a) * r * 2, 2.8, 0, TAU); ctx.fill(); }523 break;524 case "i": {525 ctx.save(); ctx.translate(sx, sy); ctx.rotate(rot * 0.6);526 for (let i = 0; i < 6; i++) { facet(r * 2.05, r * 0.36, "hsl(196,75%,86%)", "hsl(200,70%,70%)"); ctx.save(); ctx.translate(0, -r * 1.2); ctx.rotate(0.7); facet(r * 0.7, r * 0.18, "#eafcff", "#a9dcf0"); ctx.rotate(-1.4); facet(r * 0.7, r * 0.18, "#eafcff", "#a9dcf0"); ctx.restore(); ctx.rotate(TAU / 6); }527 ctx.restore();528 if (frozen) {529 const k = 1 - cr.timer / cr.timer0;530 ctx.strokeStyle = "rgba(230,250,255,.95)"; ctx.lineWidth = 3; ctx.beginPath(); ctx.arc(x, y, r * 2.3, -Math.PI / 2, -Math.PI / 2 + k * TAU); ctx.stroke();531 ctx.globalCompositeOperation = "lighter"; drawGlow(x, y, r * 5, 190, 0.5 * k); ctx.globalCompositeOperation = "source-over";532 }533 break;534 }535 case "b": {536 const chain = w.chain, ripe = chain >= CB.LATE, hb = ripe ? 46 + Math.sin(T * 10) * 4 : 16;537 flower(sx, sy, 8, r * 1.7 * pulse, r * 0.95, hb, rot * 0.5, 90, ripe ? 60 : 46, 1, true);538 ctx.fillStyle = "#2a0d08"; ctx.beginPath(); ctx.arc(x, y, r * 0.62, 0, TAU); ctx.fill();539 ctx.strokeStyle = ripe ? "#ffe680" : "rgba(255,190,120,.85)"; ctx.lineWidth = 2.5; ctx.beginPath();540 ctx.arc(x, y, r * 0.62, -Math.PI / 2, -Math.PI / 2 + TAU * Math.min(1, chain / CB.LATE)); ctx.stroke();541 if (ripe) { ctx.globalCompositeOperation = "lighter"; drawGlow(x, y, r * 5, 45, 0.6 + 0.3 * Math.sin(T * 10)); ctx.globalCompositeOperation = "source-over"; }542 break;543 }544 }545 if (cr.type !== "b" && cr.type !== "i") { ctx.fillStyle = "rgba(255,255,255,.92)"; ctx.beginPath(); ctx.arc(sx, sy, r * 0.28, 0, TAU); ctx.fill(); }546 if (cr.type === "m" && frozen) { ctx.strokeStyle = "rgba(255,160,240,.7)"; ctx.lineWidth = 2; const k = 1 - cr.timer / cr.timer0; ctx.beginPath(); ctx.arc(x, y, TY.m.pullR * (1 - k), 0, TAU); ctx.stroke(); }547 }548 function drawBloom(b) {549 if (b.rad <= 0 && !b.noTouch) return;550 const h = b.type === "k" ? 190 : rampHue(b.depth), fade = b.t > b.dur || b.noTouch ? clamp((b.end - b.t) / (b.noTouch ? 0.25 : CB.HOLD), 0, 1) : 1;551 const R = b.rad; if (R < 1) return;552 ctx.globalCompositeOperation = "lighter";553 const g = ctx.createRadialGradient(b.x, b.y, 0, b.x, b.y, R);554 g.addColorStop(0, `hsla(${h},100%,80%,${0.5 * fade})`); g.addColorStop(0.55, `hsla(${h},95%,58%,${0.13 * fade})`); g.addColorStop(1, `hsla(${h},95%,60%,${0.32 * fade})`);555 ctx.fillStyle = g; ctx.beginPath(); ctx.arc(b.x, b.y, R, 0, TAU); ctx.fill();556 // petal rays557 ctx.save(); ctx.translate(b.x, b.y); ctx.rotate(b.t * 0.7 + b.depth); ctx.globalAlpha = 0.5 * fade;558 const n = 12;559 for (let i = 0; i < n; i++) { ctx.rotate(TAU / n); facet(R * 0.98, R * 0.16, `hsl(${h},100%,68%)`, `hsl(${h},100%,52%)`); }560 ctx.rotate(TAU / n / 2); ctx.globalAlpha = 0.35 * fade;561 for (let i = 0; i < n; i++) { ctx.rotate(TAU / n); facet(R * 0.62, R * 0.12, `hsl(${h + 20},100%,85%)`, `hsl(${h + 20},100%,70%)`); }562 ctx.restore(); ctx.globalAlpha = 1;563 ctx.strokeStyle = `hsla(${h},100%,78%,${0.9 * fade})`; ctx.lineWidth = 3; ctx.beginPath(); ctx.arc(b.x, b.y, R, 0, TAU); ctx.stroke();564 ctx.strokeStyle = `hsla(${h},100%,90%,${0.35 * fade})`; ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(b.x, b.y, R * 0.86, 0, TAU); ctx.stroke();565 ctx.globalCompositeOperation = "source-over";566 }567 function drawFx() {568 ctx.globalCompositeOperation = "lighter";569 for (const r of ripples) {570 const k = r.t / r.life, rad = r.maxR * (0.2 + 0.8 * (1 - Math.pow(1 - k, 2)));571 ctx.strokeStyle = `hsla(${r.h},80%,70%,${0.32 * (1 - k)})`; ctx.lineWidth = 2 * (1 - k) + 0.5; ctx.beginPath(); ctx.arc(r.x, r.y, rad, 0, TAU); ctx.stroke();572 ctx.strokeStyle = `hsla(${r.h},80%,70%,${0.16 * (1 - k)})`; ctx.beginPath(); ctx.arc(r.x, r.y, rad * 0.72, 0, TAU); ctx.stroke();573 }574 for (const p of parts) {575 const k = p.t / p.life; ctx.save(); ctx.translate(p.x, p.y); ctx.rotate(p.rot); ctx.globalAlpha = 1 - k;576 facet(p.size * (1 - k * 0.4), p.size * 0.42, `hsl(${p.h},100%,72%)`, `hsl(${p.h},100%,58%)`); ctx.restore();577 }578 ctx.globalAlpha = 1; ctx.globalCompositeOperation = "source-over";579 for (const t of texts) {580 const k = t.t / 1.3; ctx.globalAlpha = 1 - k * k; text(t.s, t.x, t.y - k * 34, t.size, `hsl(${t.h},100%,80%)`, "center", "800", `hsl(${t.h},100%,50%)`); ctx.globalAlpha = 1;581 }582 }583 function drawWorld(w) {584 for (const cr of w.crystals) if (cr.state >= 2) drawCrystal(cr, w);585 for (const cr of w.crystals) if (cr.state < 2) drawCrystal(cr, w);586 for (const s of w.seeds) { ctx.globalCompositeOperation = "lighter"; drawGlow(s.x, s.y, 16, 140, 0.9); ctx.globalCompositeOperation = "source-over"; ctx.fillStyle = "#e8fff0"; ctx.beginPath(); ctx.arc(s.x, s.y, 3.5, 0, TAU); ctx.fill(); }587 for (const b of w.blooms) drawBloom(b);588 }589 function drawBackdrop(dim) {590 ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.drawImage(bgCanvas, 0, 0); applyView();591 ctx.globalCompositeOperation = "lighter";592 for (const m of mist) { ctx.globalAlpha = m.a * (m.layer === 2 ? 0.7 : 1); ctx.drawImage(mistSprite, m.x - m.s / 2, m.y - m.s / 2 + Math.sin(T * 0.2 + m.x) * 12, m.s, m.s * 0.7); }593 ctx.globalAlpha = 1;594 // caustics595 ctx.lineWidth = 1.2;596 for (let i = 0; i < 6; i++) {597 ctx.strokeStyle = `rgba(120,255,235,${0.035 + 0.02 * Math.sin(T * 0.7 + i)})`; ctx.beginPath();598 const y0 = 120 + i * 150;599 for (let x = -40; x <= 580; x += 20) { const y = y0 + Math.sin(x * 0.02 + T * 0.6 + i * 2) * 14 + Math.sin(x * 0.045 - T * 0.8 + i) * 6; x === -40 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); }600 ctx.stroke();601 }602 for (const p of pollen) { const a = 0.25 + 0.25 * Math.sin(T * 1.5 + p.ph); ctx.fillStyle = `rgba(180,255,240,${a})`; ctx.beginPath(); ctx.arc(p.x + Math.sin(T * 0.5 + p.ph) * 14, p.y, p.s, 0, TAU); ctx.fill(); }603 ctx.globalCompositeOperation = "source-over";604 if (dim) { ctx.fillStyle = `rgba(3,8,26,${dim})`; const e = ext(); ctx.fillRect(e.l, e.t, e.r - e.l, e.b - e.t); }605 }606 607 // ---------- UI widgets ----------608 function btn(id, x, y, w, h, fn, drawFn) {609 ui.btns.push({ id, x, y, w, h, fn });610 const pr = ui.press[id] ? 0.94 : 1;611 ctx.save(); ctx.translate(x + w / 2, y + h / 2); ctx.scale(pr, pr); ctx.translate(-w / 2, -h / 2); drawFn(w, h); ctx.restore();612 }613 function pill(label, primary, sub) {614 return (w, h) => {615 rr(0, 0, w, h, h / 2);616 if (primary) {617 const g = ctx.createLinearGradient(0, 0, w, h); g.addColorStop(0, "#19d3c5"); g.addColorStop(1, "#7a5cff");618 ctx.shadowColor = "rgba(90,220,255,.55)"; ctx.shadowBlur = 22; ctx.fillStyle = g; ctx.fill(); ctx.shadowBlur = 0;619 rr(2, 2, w - 4, h / 2 - 2, h / 2 - 2); ctx.fillStyle = "rgba(255,255,255,.14)"; ctx.fill();620 } else {621 ctx.fillStyle = "rgba(255,255,255,.08)"; ctx.fill(); ctx.strokeStyle = "rgba(170,240,255,.35)"; ctx.lineWidth = 1.5; rr(0, 0, w, h, h / 2); ctx.stroke();622 }623 text(label, w / 2, h / 2 + (sub ? -7 : 1), h * 0.36, "#fff", "center", "800", primary ? "rgba(0,40,80,.6)" : null, 3);624 if (sub) text(sub, w / 2, h / 2 + 17, h * 0.2, "rgba(255,255,255,.75)", "center", "600", null, 1.5);625 };626 }627 function iconBtn(kind) {628 return (w, h) => {629 ctx.beginPath(); ctx.arc(w / 2, h / 2, w / 2, 0, TAU); ctx.fillStyle = "rgba(255,255,255,.09)"; ctx.fill(); ctx.strokeStyle = "rgba(170,240,255,.35)"; ctx.lineWidth = 1.5; ctx.stroke();630 ctx.save(); ctx.translate(w / 2, h / 2); ctx.strokeStyle = "#e8fcff"; ctx.fillStyle = "#e8fcff"; ctx.lineWidth = 2.6; ctx.lineCap = "round"; ctx.lineJoin = "round";631 const s = w / 44;632 ctx.scale(s, s);633 if (kind === "back") { ctx.beginPath(); ctx.moveTo(4, -9); ctx.lineTo(-6, 0); ctx.lineTo(4, 9); ctx.stroke(); }634 else if (kind === "next") { ctx.beginPath(); ctx.moveTo(-4, -9); ctx.lineTo(6, 0); ctx.lineTo(-4, 9); ctx.stroke(); }635 else if (kind === "retry") { ctx.beginPath(); ctx.arc(0, 0, 9, -0.6, TAU - 1.2); ctx.stroke(); ctx.beginPath(); ctx.moveTo(6, -13); ctx.lineTo(10, -5); ctx.lineTo(1, -5); ctx.closePath(); ctx.fill(); }636 else if (kind === "sound") {637 ctx.beginPath(); ctx.moveTo(-10, -4); ctx.lineTo(-5, -4); ctx.lineTo(1, -9); ctx.lineTo(1, 9); ctx.lineTo(-5, 4); ctx.lineTo(-10, 4); ctx.closePath(); ctx.fill();638 if (!muted) { ctx.beginPath(); ctx.arc(2, 0, 6, -0.9, 0.9); ctx.stroke(); ctx.beginPath(); ctx.arc(2, 0, 11, -0.9, 0.9); ctx.stroke(); }639 else { ctx.beginPath(); ctx.moveTo(6, -6); ctx.lineTo(14, 6); ctx.moveTo(14, -6); ctx.lineTo(6, 6); ctx.stroke(); }640 }641 ctx.restore();642 };643 }644 function muteBtn(x, y) { btn("mute", x, y, 44, 44, () => setMuted(!muted), iconBtn("sound")); }645 function panel(x, y, w, h, r) {646 rr(x, y, w, h, r || 26); const g = ctx.createLinearGradient(0, y, 0, y + h); g.addColorStop(0, "rgba(20,50,90,.82)"); g.addColorStop(1, "rgba(8,20,50,.9)");647 ctx.fillStyle = g; ctx.shadowColor = "rgba(0,0,0,.5)"; ctx.shadowBlur = 30; ctx.fill(); ctx.shadowBlur = 0;648 ctx.strokeStyle = "rgba(150,240,255,.3)"; ctx.lineWidth = 1.5; rr(x, y, w, h, r || 26); ctx.stroke();649 }650 function drawLogoFlower(x, y, R, t) {651 ctx.globalCompositeOperation = "lighter"; drawGlow(x, y, R * 3.4, rampHue(t * 4), 0.7); ctx.globalCompositeOperation = "source-over";652 for (let ring = 0; ring < 3; ring++) {653 const n = 8 + ring * 2, h = [200, 265, 320][ring] + Math.sin(t * 0.8 + ring) * 18;654 flower(x, y, n, R * (1.15 - ring * 0.27) * (1 + Math.sin(t * 2 + ring) * 0.04), R * (0.5 - ring * 0.08), h, t * 0.3 * (ring % 2 ? -1 : 1) + ring * 0.2, 90, 60 + ring * 8, 0.95, false);655 }656 ctx.fillStyle = "#fff"; ctx.beginPath(); ctx.arc(x, y, R * 0.12, 0, TAU); ctx.fill();657 }658 function drawSeedIcon(x, y, on) {659 ctx.globalCompositeOperation = "lighter"; if (on) drawGlow(x, y, 20, 190, 0.9 + 0.1 * Math.sin(T * 5)); ctx.globalCompositeOperation = "source-over";660 ctx.fillStyle = on ? "#eaffff" : "rgba(255,255,255,.2)"; ctx.beginPath(); ctx.arc(x, y, on ? 6 : 5, 0, TAU); ctx.fill();661 }662 function drawTypeIcon(type, x, y, sc) {663 const fake = { type, x, y, id: 0, state: 0, hue: 210, rot: 0, timer: 1, timer0: 1, t0: 0, depth: 0 };664 const sv = TY[type].r; ctx.save(); ctx.translate(x, y); ctx.scale(sc, sc); ctx.translate(-x, -y);665 drawCrystal(fake, { chain: 0, time: 0 }); ctx.restore();666 }667 668 // ---------- screens ----------669 const TIPS = {670 0: { t: "Tap anywhere to plant a spark", s: "Every crystal it touches blooms too" },671 1: { t: "Bigger chains multiply your score", s: "Hunt for the spot that lights the most" },672 2: { type: "p", t: "Golden Pulse", s: "Blooms huge and reaches far" },673 3: { type: "t", t: "Black Thorn", s: "Withers a bloom and breaks your combo" },674 4: { type: "i", t: "Frost Bud", s: "Waits a moment, then blooms huge" },675 5: { type: "m", t: "Magnet Bloom", s: "Pulls neighbours in before bursting" },676 6: { type: "s", t: "Seed Splitter", s: "Scatters seeds that bloom a beat later" },677 7: { type: "b", t: "Ember Lily", s: "Fizzles early, detonates after 8 blooms" },678 9: { t: "Two sparks this time", s: "The second waits for the garden to settle" },679 };680 function drawTitle() {681 drawBackdrop(0);682 if (att) { ctx.globalAlpha = 0.85; drawWorld(att.world); ctx.globalAlpha = 1; }683 drawFx();684 const bob = Math.sin(T * 1.2) * 4;685 drawLogoFlower(270, 225 + bob, 66, T);686 // wordmark687 ctx.save(); ctx.translate(270, 405 + bob);688 const g = ctx.createLinearGradient(0, -50, 0, 50); g.addColorStop(0, "#9ff7ff"); g.addColorStop(0.5, "#b8a2ff"); g.addColorStop(1, "#ff8ad8");689 ctx.font = `900 92px ${FONT}`; ctx.textAlign = "center"; ctx.textBaseline = "middle"; try { ctx.letterSpacing = "6px"; } catch (e) {}690 ctx.shadowColor = "rgba(120,220,255,.75)"; ctx.shadowBlur = 30; ctx.fillStyle = g; ctx.fillText("CHAIN", 0, -34);691 ctx.shadowColor = "rgba(255,120,220,.7)"; ctx.fillText("BLOOM", 0, 52);692 ctx.shadowBlur = 0; ctx.lineWidth = 1.5; ctx.strokeStyle = "rgba(255,255,255,.55)"; ctx.strokeText("CHAIN", 0, -34); ctx.strokeText("BLOOM", 0, 52);693 try { ctx.letterSpacing = "0px"; } catch (e) {}694 ctx.restore();695 text("ONE TAP - ONE BLOOM", 270, 505 + bob, 17, "rgba(190,240,255,.8)", "center", "700", null, 5);696 btn("play", 100, 600, 340, 78, () => goto("map"), pill("PLAY", true, totalStars() ? totalStars() + " of " + LEVELS * 3 + " stars" : "40 levels"));697 btn("zen", 100, 700, 340, 68, () => startZen(), pill("ZEN", false, "Endless - Daily garden"));698 if (bestZen) text("Best Zen " + fmt(bestZen), 270, 800, 16, "rgba(190,240,255,.7)", "center", "700", null, 2);699 muteBtn(486, 24);700 text("Tap - Space retry - M mute", 270, 925, 13, "rgba(190,240,255,.4)", "center", "600", null, 1);701 }702 const NODE = (i) => { const k = i % PER_PACK, y = 800 - k * 62, x = 270 + Math.sin(k * 0.95 + 0.4) * 150; return [x, y]; };703 function drawMap() {704 drawBackdrop(0.1);705 const pack = mapPage, names = CB.PACKS;706 // path707 ctx.lineCap = "round";708 ctx.strokeStyle = "rgba(120,255,240,.22)"; ctx.lineWidth = 6; ctx.setLineDash([2, 14]); ctx.lineDashOffset = -T * 10; ctx.beginPath();709 for (let k = 0; k < PER_PACK; k++) { const [x, y] = NODE(pack * PER_PACK + k); k ? ctx.lineTo(x, y) : ctx.moveTo(x, y); }710 ctx.stroke(); ctx.setLineDash([]);711 let cur = -1;712 for (let k = 0; k < PER_PACK; k++) {713 const i = pack * PER_PACK + k, [x, y] = NODE(i), ok = unlocked(i), st = stars(i);714 if (ok && st === 0) cur = i;715 ctx.save(); ctx.translate(x, y); ctx.rotate(k * 1.3);716 ctx.fillStyle = ok ? "rgba(20,120,110,.85)" : "rgba(10,50,60,.8)"; ctx.beginPath(); ctx.moveTo(0, 0); ctx.arc(0, 0, 31, 0.25, TAU - 0.25); ctx.closePath(); ctx.fill();717 ctx.strokeStyle = "rgba(120,255,225,.25)"; ctx.lineWidth = 1.5; ctx.stroke(); ctx.restore();718 btn("lv" + i, x - 34, y - 34, 68, 68, () => { if (unlocked(i)) startLevel(i); }, (w, h) => {719 ctx.save(); ctx.translate(w / 2, h / 2);720 if (ok) {721 const hh = st ? 50 : 190; ctx.globalCompositeOperation = "lighter"; drawGlow(0, 0, 44, hh, st ? 0.8 : 0.5); ctx.globalCompositeOperation = "source-over";722 const gg = ctx.createRadialGradient(0, -6, 2, 0, 0, 27); gg.addColorStop(0, st ? "#fff6c8" : "#c8f8ff"); gg.addColorStop(1, st ? "#ffb64d" : "#3fb6d6");723 ctx.fillStyle = gg; ctx.beginPath(); ctx.arc(0, 0, 25, 0, TAU); ctx.fill();724 text(String(i + 1), 0, 1, 24, "#06283a", "center", "900");725 } else {726 ctx.fillStyle = "rgba(20,50,80,.9)"; ctx.beginPath(); ctx.arc(0, 0, 23, 0, TAU); ctx.fill();727 ctx.strokeStyle = "rgba(160,200,230,.55)"; ctx.lineWidth = 2.5; rr(-8, -2, 16, 12, 3); ctx.stroke(); ctx.beginPath(); ctx.arc(0, -3, 6, Math.PI, 0); ctx.stroke();728 }729 ctx.restore();730 });731 if (ok) for (let s = 0; s < 3; s++) star5(x + (s - 1) * 19, y + 42 + (s === 1 ? 3 : 0), 8, s < st ? "#ffd75e" : "rgba(255,255,255,.14)");732 if (i === cur) { const p = (T * 1.1) % 1; ctx.strokeStyle = `rgba(160,255,255,${1 - p})`; ctx.lineWidth = 3; ctx.beginPath(); ctx.arc(x, y, 30 + p * 26, 0, TAU); ctx.stroke(); }733 }734 text(names[pack].toUpperCase(), 270, 88, 30, "#fff", "center", "900", "rgba(120,220,255,.6)", 3);735 text("PACK " + (pack + 1) + " OF " + LEVELS / PER_PACK, 270, 122, 14, "rgba(190,240,255,.65)", "center", "700", null, 3);736 text(totalStars() + " / " + LEVELS * 3 + " STARS", 270, 150, 15, "#ffd75e", "center", "800", null, 2);737 btn("back", 24, 24, 44, 44, () => goto("title"), iconBtn("back")); muteBtn(486, 24);738 if (pack > 0) btn("pp", 20, 890, 60, 44, () => (mapPage = pack - 1), iconBtn("back"));739 if (pack < PACK_LAST) btn("pn", 460, 890, 60, 44, () => (mapPage = pack + 1), iconBtn("next"));740 for (let k = 0; k <= PACK_LAST; k++) { ctx.fillStyle = k === pack ? "#9ff7ff" : "rgba(255,255,255,.25)"; ctx.beginPath(); ctx.arc(270 + (k - PACK_LAST / 2) * 22, 912, k === pack ? 5 : 4, 0, TAU); ctx.fill(); }741 }742 function drawLoading() {743 drawBackdrop(0.2);744 drawLogoFlower(270, 440, 46, T * 2);745 text("Growing the garden...", 270, 550, 22, "rgba(200,245,255,.9)", "center", "700", null, 2);746 }747 function drawHUD() {748 const w = G.world, L = G.level;749 btn("back", 20, 20, 44, 44, () => exitPlay(), iconBtn("back"));750 btn("retry", 424, 20, 44, 44, () => retry(), iconBtn("retry"));751 muteBtn(476, 20);752 if (G.mode === "zen") {753 text("ZEN - ROUND " + (G.zen.round + 1), 270, 34, 18, "#fff", "center", "900", null, 3);754 text("DAILY " + String(dailySeed()).replace(/(\d{4})(\d\d)(\d\d)/, "$1-$2-$3"), 270, 58, 12, "rgba(190,240,255,.6)", "center", "700", null, 2);755 for (let i = 0; i < 3; i++) flower(86 + i * 26, 92, 6, 11, 5, i < G.zen.lives ? 330 : 220, 0, i < G.zen.lives ? 85 : 20, i < G.zen.lives ? 62 : 35, i < G.zen.lives ? 1 : 0.5, false);756 text(fmt(G.zen.total + G.scoreShown), 480, 92, 22, "#fff", "right", "900", "rgba(120,220,255,.5)");757 } else {758 text("LEVEL " + (G.idx + 1), 270, 32, 20, "#fff", "center", "900", null, 3);759 text(L.name.toUpperCase(), 270, 57, 12, "rgba(190,240,255,.6)", "center", "700", null, 2.5);760 }761 // goal bar762 const bx = 60, bw = 420, by = 118;763 if (G.mode !== "zen") text(fmt(G.scoreShown), 480, 92, 22, "#fff", "right", "900", "rgba(120,220,255,.5)");764 text("BLOOM " + w.count + " / " + L.goal, G.mode === "zen" ? 270 : 60, G.mode === "zen" ? 92 : 92, 17, G.showGoalPulse > 0 || w.count >= L.goal ? "#ffe27a" : "#dff", G.mode === "zen" ? "center" : "left", "800", null, 2);765 rr(bx, by, bw, 10, 5); ctx.fillStyle = "rgba(255,255,255,.1)"; ctx.fill();766 const max = Math.max(L.s3, L.M * 0.6), pf = (v) => clamp(v / max, 0, 1);767 if (w.count > 0) {768 const g = ctx.createLinearGradient(bx, 0, bx + bw, 0); g.addColorStop(0, "#19d3c5"); g.addColorStop(0.6, "#8a7cff"); g.addColorStop(1, "#ff8ad8");769 rr(bx, by, Math.max(10, bw * pf(w.count)), 10, 5); ctx.fillStyle = g; ctx.shadowColor = "rgba(120,220,255,.7)"; ctx.shadowBlur = 10; ctx.fill(); ctx.shadowBlur = 0;770 }771 [[L.goal, 1], [L.s2, 2], [L.s3, 3]].forEach(([v, n]) => {772 const x = bx + bw * pf(v), got = w.count >= v;773 star5(x, by - 12 + 0, n === 1 ? 8 : 7, got ? "#ffd75e" : "rgba(255,255,255,.28)");774 ctx.fillStyle = got ? "#ffd75e" : "rgba(255,255,255,.35)"; ctx.fillRect(x - 1, by, 2, 10);775 });776 // sparks777 for (let i = 0; i < G.taps0; i++) drawSeedIcon(270 + (i - (G.taps0 - 1) / 2) * 30, 936, i < w.tapsLeft);778 text(w.tapsLeft ? (G.taps0 > 1 ? w.tapsLeft + " SPARKS LEFT" : "") : "", 270, 912, 11, "rgba(190,240,255,.55)", "center", "700", null, 2);779 }780 function drawCombo() {781 const cb = G.combo; if (!cb.chain && G.phase === "ready") return;782 if (G.phase === "ready" && !cb.chain) return;783 const pop = 1 + cb.pop * 0.22, h = rampHue(cb.chain);784 ctx.save(); ctx.translate(270, 872); ctx.scale(pop, pop);785 text("x" + cb.mult.toFixed(1), 0, 0, 62, `hsl(${h},100%,78%)`, "center", "900", `hsl(${h},100%,55%)`);786 ctx.restore();787 text(cb.chain ? "CHAIN " + cb.chain : "COMBO BROKEN", 270, 918, 15, "rgba(230,250,255,.85)", "center", "800", null, 4);788 }789 function drawPlay() {790 const w = G.world, L = G.level;791 drawBackdrop(0);792 ctx.save();793 if (shake > 0.1) ctx.translate((Math.random() - 0.5) * shake, (Math.random() - 0.5) * shake);794 // hint ring795 const showHint = G.phase === "ready" && w.tapsLeft === G.taps0 && (G.idx === 0 && G.mode === "campaign" || G.fails >= 2);796 if (showHint && L.hint[0]) {797 const hp = L.hint[w.level.taps - w.tapsLeft] || L.hint[0], p = (T * 0.9) % 1;798 ctx.strokeStyle = `rgba(255,240,160,${0.8 - p * 0.6})`; ctx.lineWidth = 3; ctx.beginPath(); ctx.arc(hp.x, hp.y, 14 + p * 30, 0, TAU); ctx.stroke();799 ctx.fillStyle = "rgba(255,240,160,.7)"; ctx.beginPath(); ctx.arc(hp.x, hp.y, 5, 0, TAU); ctx.fill();800 if (G.idx === 0 && G.mode === "campaign") { ctx.save(); ctx.translate(hp.x + 6, hp.y + 16 + Math.sin(T * 5) * 4); ctx.fillStyle = "rgba(255,255,255,.9)"; ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(0, 30); ctx.lineTo(8, 24); ctx.lineTo(14, 36); ctx.lineTo(19, 33); ctx.lineTo(13, 22); ctx.lineTo(22, 22); ctx.closePath(); ctx.fill(); ctx.restore(); }801 }802 drawWorld(w); drawFx();803 if (mouse && G.phase === "ready" && w.tapsLeft > 0 && matchMedia("(hover:hover)").matches) {804 ctx.strokeStyle = "rgba(200,250,255,.45)"; ctx.lineWidth = 2; ctx.setLineDash([5, 7]); ctx.beginPath(); ctx.arc(mouse.x, mouse.y, CB.SPARK.R, 0, TAU); ctx.stroke(); ctx.setLineDash([]);805 }806 ctx.restore();807 drawHUD(); drawCombo();808 // tip card809 const tip = G.mode === "campaign" ? TIPS[G.idx] : null;810 if (tip && G.phase === "ready" && w.tapsLeft === G.taps0) {811 panel(40, 832, 460, 86, 22);812 if (tip.type) { ctx.globalCompositeOperation = "source-over"; drawTypeIcon(tip.type, 92, 875, 1.35); }813 else drawLogoFlower(92, 875, 20, T);814 text(tip.t, 316, 860, 21, "#fff", "center", "900", null, 0);815 text(tip.s, 316, 892, 14, "rgba(200,240,255,.82)", "center", "600");816 }817 if (flash > 0) { ctx.fillStyle = `rgba(255,255,255,${flash * 0.25})`; const e = ext(); ctx.fillRect(e.l, e.t, e.r - e.l, e.b - e.t); }818 if (G.phase === "result") drawResult();819 }820 function drawResult() {821 const R = G.res, k = clamp(G.resT / 0.4, 0, 1), e = 1 - Math.pow(1 - k, 3);822 ctx.fillStyle = `rgba(3,8,26,${0.55 * e})`; const ex = ext(); ctx.fillRect(ex.l, ex.t, ex.r - ex.l, ex.b - ex.t);823 ctx.save(); ctx.globalAlpha = e; ctx.translate(0, (1 - e) * 40);824 const zen = G.mode === "zen";825 panel(50, 250, 440, 500, 34);826 if (R.win) {827 text(R.stars === 3 ? "PERFECT BLOOM" : "BLOOMED!", 270, 305, 34, "#fff", "center", "900", "rgba(120,240,255,.7)", 3);828 text(zen ? "ROUND " + G.zen.round + " CLEARED" : "LEVEL " + (G.idx + 1) + " - " + G.level.name.toUpperCase(), 270, 340, 13, "rgba(190,240,255,.65)", "center", "700", null, 2);829 for (let s = 0; s < 3; s++) {830 const at = 0.5 + (s + 1) * 0.4, on = s < R.stars, t = clamp((G.resT - at) / 0.35, 0, 1), sc = on ? (t < 1 ? 0.3 + 1.0 * t + Math.sin(t * Math.PI) * 0.4 : 1) : 1;831 const x = 270 + (s - 1) * 100, y = 405 - (s === 1 ? 14 : 0);832 ctx.save(); ctx.translate(x, y); ctx.scale(sc, sc); ctx.translate(-x, -y);833 if (on && t > 0) { ctx.globalCompositeOperation = "lighter"; drawGlow(x, y, 70, 48, 0.8 * t); ctx.globalCompositeOperation = "source-over"; star5(x, y, 44, "#ffd75e", "#fff3b0"); }834 else star5(x, y, 44, "rgba(255,255,255,.08)", "rgba(255,255,255,.25)");835 ctx.restore();836 }837 } else {838 text("NOT QUITE", 270, 305, 34, "#fff", "center", "900", "rgba(255,140,200,.6)", 3);839 text(zen ? "ROUND " + (G.zen.round + 1) : "LEVEL " + (G.idx + 1), 270, 340, 13, "rgba(190,240,255,.65)", "center", "700", null, 2);840 flower(270, 415, 8, 44, 16, 220, T * 0.1, 30, 32, 0.9, false);841 }842 text("Bloomed " + R.count + " of " + G.level.goal + " needed", 270, 478, 20, R.win ? "#c8ffe8" : "#ffb8d8", "center", "800");843 const shown = Math.round(R.score * clamp((G.resT - 0.3) / 1.2, 0, 1));844 text(fmt(shown), 270, 538, 46, "#fff", "center", "900", "rgba(120,220,255,.5)");845 let sub = "Longest chain " + R.maxChain + (R.bonus ? " - bonus +" + fmt(R.bonus) : "");846 if (R.win && !zen && R.newBest && G.resT > 1.2) sub = "NEW BEST! " + sub;847 text(sub, 270, 578, 14, "rgba(200,240,255,.75)", "center", "600");848 if (zen) {849 text("Lives " + G.zen.lives + " Total " + fmt(G.zen.total), 270, 606, 14, "rgba(255,215,120,.9)", "center", "700");850 if (R.win) btn("next", 90, 632, 360, 66, () => zenNext(), pill("NEXT ROUND", true));851 else btn("next", 90, 632, 360, 66, () => zenAfterFail(), pill(G.zen.lives > 0 ? "TRY AGAIN" : "SEE RESULT", true));852 } else if (R.win) {853 btn("next", 200, 626, 250, 66, () => nextLevel(), pill(G.idx + 1 < LEVELS ? "NEXT" : "MAP", true));854 btn("retry2", 90, 626, 96, 66, () => retry(), pill("", false));855 drawRetryGlyph(138, 659);856 } else {857 btn("retry2", 90, 626, 360, 66, () => retry(), pill("RETRY", true));858 }859 if (!zen) { btn("maplnk", 190, 710, 160, 40, () => { mapPage = Math.floor(G.idx / PER_PACK); goto("map"); }, (w, h) => text("BACK TO MAP", w / 2, h / 2, 13, "rgba(190,240,255,.8)", "center", "800", null, 2)); }860 ctx.restore();861 }862 function drawRetryGlyph(x, y) { ctx.save(); ctx.translate(x, y); ctx.strokeStyle = "#fff"; ctx.fillStyle = "#fff"; ctx.lineWidth = 3; ctx.lineCap = "round"; ctx.beginPath(); ctx.arc(0, 0, 11, -0.6, TAU - 1.2); ctx.stroke(); ctx.beginPath(); ctx.moveTo(8, -15); ctx.lineTo(13, -5); ctx.lineTo(2, -5); ctx.closePath(); ctx.fill(); ctx.restore(); }863 function drawZenOver() {864 drawBackdrop(0.35);865 const z = G.zen;866 drawLogoFlower(270, 250, 56, T);867 text("GARDEN AT REST", 270, 360, 34, "#fff", "center", "900", "rgba(120,220,255,.6)", 3);868 text("ZEN - " + z.cleared + " ROUNDS", 270, 400, 14, "rgba(190,240,255,.7)", "center", "700", null, 3);869 text(fmt(z.total), 270, 480, 64, "#fff", "center", "900", "rgba(120,220,255,.5)");870 text(z.total >= bestZen && z.total > 0 ? "NEW BEST" : "BEST " + fmt(bestZen), 270, 540, 18, "#ffd75e", "center", "800", null, 3);871 btn("again", 100, 620, 340, 72, () => startZen(), pill("AGAIN", true));872 btn("home", 100, 712, 340, 60, () => goto("title"), pill("MENU", false));873 muteBtn(486, 24);874 }875 function draw() {876 ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.clearRect(0, 0, c.width, c.height); applyView();877 ui.btns = [];878 if (state === "title") drawTitle();879 else if (state === "map") drawMap();880 else if (state === "loading") drawLoading();881 else if (state === "zenover") drawZenOver();882 else if (state === "play" && G && G.world) drawPlay();883 if (paused) {884 ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.fillStyle = "rgba(3,8,26,.7)"; ctx.fillRect(0, 0, c.width, c.height); applyView();885 text("PAUSED", 270, 470, 44, "#fff", "center", "900", "rgba(120,220,255,.6)", 6);886 }887 }888 889 // ---------- loop ----------890 let last = performance.now();891 function loop(now) {892 const dt = Math.min(0.05, (now - last) / 1000); last = now;893 if (!paused) { try { update(dt); } catch (e) { console.error(e); } }894 try { draw(); } catch (e) { console.error(e); }895 requestAnimationFrame(loop);896 }897 addEventListener("resize", fit); fit();898 requestAnimationFrame(loop);899 900 // debug / test hooks901 window.__cb = {902 get state() { return state; }, get G() { return G; }, get prog() { return prog; }, getLevel, startLevel, startZen, goto,903 setPaused(p) { paused = p; }, get tick() { return G && G.world ? G.world.tick : -1; },904 info() { return G && G.world ? { phase: G.phase, tick: G.world.tick, count: G.world.count, goal: G.level.goal, res: G.res, zen: G.zen } : { state }; },905 tapAt(x, y) { if (G && G.phase === "ready") plant(x, y); },906 trySim(i, x, y, tick) {907 const lv = getLevel(i), w = CB.makeWorld(lv); w.quiet = true; CB.warp(w, tick == null ? 60 : tick); CB.tap(w, x, y); CB.runToSettle(w); return w.count;908 },909 };910})();911