Spaces:
Running
Running
Download game.js from CyberMax-tools/brimbloom: direct link, hf CLI and curl.
- Browser
- Download file 31.8 kB
-
https://huggingface.co/spaces/CyberMax-tools/brimbloom/resolve/main/game.js
- Command line
-
hf download hf://spaces/CyberMax-tools/brimbloom/game.js
-
curl -L -o game.js https://huggingface.co/spaces/CyberMax-tools/brimbloom/resolve/main/game.js
31.8 kB
| // Brimbloom: game screen (fixed-tick loop, canvas, sound, 30 levels, daily/practice/archive, streak, share grid, | |
| // beat-my-score links, Fun Pass, Skin Pack). (c) 2026 CyberMax. | |
| import { makeSim, grid, ringC, judge, radius, HZ, LIMIT, LEVELS, makeLevelSim, levelStars, levelThresholds } from './core.js'; | |
| import * as lv from './levels.js'; | |
| import { dayNumber, dateOf, count, store, toast, intents, download, canvasBlob, pwa, themeToggle } from './cmx.js'; | |
| import * as skins from './skins.js'; | |
| import * as pass from './funpass.js'; | |
| const GAME = 'brimbloom'; | |
| const PATH = '/games/brimbloom/'; | |
| const GUMROAD = 'https://cybermaxtools.gumroad.com/l/cybermax-brimbloom-skins'; | |
| const $ = (id) => document.getElementById(id); | |
| const cv = $('c'); | |
| const g = cv.getContext('2d'); | |
| // tier: free | pass (CyberMax Fun Pass) | pack (Brimbloom Skin Pack, one-time). petals: list of colours or 'hue' (each bloom's own) | |
| const SKINS = { | |
| pond: { name: 'Pond', tier: 'free', bg: '#0E2A2A', band: 'rgba(255,255,255,.14)', sweet: 'rgba(255,255,255,.32)', edge: '#F7F1E3', petals: ['#FF8FB1', '#FFB3C8'], core: '#FFD84D', shape: 'round', text: '#F7F1E3' }, | |
| night: { name: 'Night', tier: 'pass', bg: '#0A0A1F', band: 'rgba(160,140,255,.16)', sweet: 'rgba(160,140,255,.38)', edge: '#C9C0FF', petals: ['#8F7CFF', '#C3B8FF'], core: '#FFF3B0', shape: 'pointed', text: '#ECE9FF' }, | |
| linen: { name: 'Linen', tier: 'pass', bg: '#F5EFE3', band: 'rgba(17,20,27,.08)', sweet: 'rgba(17,20,27,.18)', edge: '#11141B', petals: ['#E4572E', '#F3A712'], core: '#11141B', shape: 'round', text: '#11141B' }, | |
| sakura: { name: 'Sakura', tier: 'pack', bg: '#2B1B2E', band: 'rgba(255,214,228,.14)', sweet: 'rgba(255,214,228,.34)', edge: '#FFE4EE', petals: ['#FFC2D6', '#FFE4EE'], core: '#FF6B9A', shape: 'heart', text: '#FFE4EE' }, | |
| lotus: { name: 'Lotus', tier: 'pack', bg: '#06242E', band: 'rgba(130,240,220,.14)', sweet: 'rgba(130,240,220,.34)', edge: '#BFFFF2', petals: ['#F7A8D8', '#FFFFFF'], core: '#FFE066', shape: 'pointed', text: '#E6FFFA' }, | |
| sunflower: { name: 'Sunflower', tier: 'pack', bg: '#1E3A1A', band: 'rgba(255,230,120,.15)', sweet: 'rgba(255,230,120,.35)', edge: '#FFF2B3', petals: ['#FFC300', '#FFD84D'], core: '#5A3A1A', shape: 'pointed', text: '#FFF8DC' }, | |
| prism: { name: 'Prism', tier: 'pack', bg: '#111111', band: 'rgba(255,255,255,.12)', sweet: 'rgba(255,255,255,.3)', edge: '#FFFFFF', petals: 'hue', core: '#FFFFFF', shape: 'round', text: '#FFFFFF' }, | |
| frost: { name: 'Frost', tier: 'pack', bg: '#0B1D33', band: 'rgba(180,225,255,.15)', sweet: 'rgba(180,225,255,.36)', edge: '#E3F4FF', petals: ['#9AD8FF', '#E3F4FF'], core: '#FFFFFF', shape: 'star', text: '#E3F4FF' }, | |
| ember: { name: 'Ember', tier: 'pack', bg: '#1A0A05', band: 'rgba(255,140,60,.15)', sweet: 'rgba(255,140,60,.36)', edge: '#FFD0A8', petals: ['#FF5A1F', '#FFB03B'], core: '#FFE9A8', shape: 'star', text: '#FFE9D6' }, | |
| }; | |
| const PACK = Object.values(SKINS).filter((s) => s.tier === 'pack'); | |
| const todayIso = new Date().toISOString().slice(0, 10); // UTC day: the same blooms for everyone on Earth | |
| const todayNum = dayNumber(todayIso); | |
| const NL = LEVELS.length; | |
| const MAP = LEVELS.map((l, i) => ({ n: i + 1, name: l.name, twist: l.twist, boss: l.boss })); | |
| const WORLDS = ['Spring pond', 'Summer pond', 'Night garden (Fun Pass)']; | |
| skins.capture({ game: GAME, name: 'Brimbloom', skins: PACK.length, price: '$1.99', gumroad: GUMROAD, preview: PACK.map((s) => (s.petals === 'hue' ? 'conic-gradient(red,yellow,lime,cyan,blue,magenta,red)' : s.petals[0])), | |
| perks: [`${PACK.length} flowers with their own pond and petals: ${PACK.map((s) => s.name).join(', ')}`, 'Every skin also on your share card'] }); | |
| pass.init({ game: GAME, name: 'Brimbloom', perks: ['Levels 21-30: fading rings, thorn rings and the Full bloom finale', 'Every past daily garden in the archive', 'Night and Linen themes'] }); | |
| const canUse = (s) => s.tier === 'free' || (s.tier === 'pass' && pass.isPro()) || (s.tier === 'pack' && skins.owned()); | |
| let skin = SKINS[store('brimbloom:skin')] && canUse(SKINS[store('brimbloom:skin')]) ? store('brimbloom:skin') : 'pond'; | |
| let muted = !!store('brimbloom:mute'); | |
| const sfx = lv.sfx(() => muted); | |
| const prog = lv.progress(GAME, { total: NL, free: 20, isPro: () => pass.isPro() }); | |
| const chal = lv.readChallenge(); // ?seed=L7&score=12 or ?seed=2026-10-08&score=9 ("beat my score") | |
| let mode = { kind: 'daily', date: todayIso }; | |
| let sim, state = 'ready', pending = false, acc = 0, last = 0, deadAt = 0, flash = 0, shakeAmt = 0, burst = 0, lastRun = null, pop = null; | |
| lv.css(); | |
| const install = pwa(GAME, $('install')); | |
| themeToggle($('theme')); | |
| function setMute(v) { muted = v; store('brimbloom:mute', muted); $('mute').textContent = muted ? '🔇' : '🔊'; } | |
| $('mute').textContent = muted ? '🔇' : '🔊'; | |
| $('mute').onclick = () => setMute(!muted); | |
| // ------------------------------------------------------------------ modes, stats, streak | |
| const seedOf = (m) => (m.kind === 'practice' ? m.seed : m.date); | |
| const dayKey = (m) => `brimbloom:day:${m.date}`; | |
| const dayStats = (m) => store(dayKey(m)) || { best: 0, tries: 0, marks: [] }; | |
| const played = (n) => { const s = n >= 1 && store(`brimbloom:day:${dateOf(n)}`); return !!(s && s.tries > 0); }; | |
| /** Days in a row with at least one daily run (counting from today, or from yesterday while today is unplayed). */ | |
| function streakNow() { | |
| let d = played(todayNum) ? todayNum : todayNum - 1, n = 0; | |
| while (played(d)) { n++; d--; } | |
| return n; | |
| } | |
| function anyDaily() { | |
| try { for (let i = 0; i < localStorage.length; i++) if (localStorage.key(i).startsWith('brimbloom:day:')) return true; } catch { /* */ } | |
| return false; | |
| } | |
| const isLv = () => mode.kind === 'level'; | |
| const LD = () => LEVELS[mode.level - 1]; | |
| function renderModes() { | |
| const el = $('modes'); el.innerHTML = ''; | |
| const st = streakNow(); | |
| const items = [ | |
| { t: `Daily #${todayNum}${st ? ` 🔥${st}` : ''}`, on: mode.kind === 'daily' && mode.date === todayIso, go: () => load({ kind: 'daily', date: todayIso }, true) }, | |
| { t: 'Practice', on: mode.kind === 'practice', go: () => load({ kind: 'practice', seed: `p${Date.now()}` }, true) }, | |
| { t: 'Archive', on: mode.kind === 'daily' && mode.date !== todayIso, lock: !pass.isPro(), go: archive }, | |
| // after Practice/Archive so their positions stay where the shared latchkite/brimbloom e2e expects them | |
| { t: `Levels ${prog.cleared()}/${NL} ⭐${prog.totalStars()}`, on: isLv(), go: showMap }, | |
| ]; | |
| for (const it of items) { | |
| const b = document.createElement('button'); | |
| b.className = 'chip'; b.type = 'button'; b.setAttribute('aria-pressed', String(it.on)); | |
| b.innerHTML = `${it.t}${it.lock ? ' <span class="lock">🔒</span>' : ''}`; | |
| b.onclick = it.go; el.appendChild(b); | |
| } | |
| } | |
| function archive() { | |
| if (!pass.isPro()) { pass.open('archive'); return; } | |
| const n = todayNum - 1; | |
| if (n < 1) { toast('The archive fills up day by day; the first archived garden arrives tomorrow.', 4000); return; } | |
| const k = Number(prompt(`Play a past daily garden: enter a number from 1 to ${n}`, String(n))); | |
| if (k >= 1 && k <= n) load({ kind: 'daily', date: dateOf(k) }, true); | |
| } | |
| function showMap() { | |
| const el = $('levels'); | |
| if (!el.hidden) { el.hidden = true; return; } | |
| lv.renderMap($('lvmap'), prog, MAP, { | |
| current: isLv() ? mode.level : 0, worlds: WORLDS, | |
| onPick: (k) => { load({ kind: 'level', level: k }, true); cv.scrollIntoView({ behavior: 'smooth', block: 'center' }); }, | |
| onLocked: (k, why) => (why === 'pass' ? pass.open('level') : toast(`Clear level ${prog.reached()} to open level ${k}.`)), | |
| }); | |
| el.hidden = false; | |
| el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); | |
| } | |
| function fromUrl() { | |
| const q = new URLSearchParams(location.search); | |
| if (chal && chal.level >= 1 && chal.level <= NL) return { kind: 'level', level: chal.level, challenge: true }; | |
| if (chal && chal.date) return { kind: 'daily', date: chal.date <= todayIso && (pass.isPro() || chal.date === todayIso) ? chal.date : todayIso }; | |
| const l = Number(q.get('lv')); | |
| if (l >= 1 && l <= NL) return { kind: 'level', level: l }; | |
| // first visit: level 1 with the 2-second tutorial; returning players land on today's daily garden | |
| if (!anyDaily() && !prog.cleared()) return { kind: 'level', level: 1 }; | |
| return { kind: 'daily', date: todayIso }; | |
| } | |
| function load(m, push = false) { | |
| if (m.kind === 'level') { | |
| const lk = prog.lock(m.level, { challenge: !!m.challenge }); | |
| if (lk === 'progress') { toast(`Clear level ${prog.reached()} first.`); return false; } | |
| if (lk) { pass.open('level'); return false; } | |
| prog.open(m.level); | |
| } | |
| mode = m; | |
| $('num').textContent = isLv() ? `Level ${m.level}` : m.kind === 'practice' ? 'practice' : `#${dayNumber(m.date)}`; | |
| $('over').hidden = true; $('levels').hidden = true; | |
| if (push) { | |
| const u = new URL(location.href); | |
| u.search = isLv() ? `?lv=${m.level}` : ''; | |
| history.replaceState(null, '', u.pathname + u.search); | |
| } | |
| renderModes(); reset(); hud(); goal(); banner(); | |
| if (isLv() && m.level === 1 && !prog.stars(1)) setTimeout(() => lv.tutorial(GAME, cv, { fx: 0.5, fy: 0.62 }), 250); | |
| if (isLv() && LD().boss) sfx.boss(); | |
| return true; | |
| } | |
| /** The goal line: what this run asks for, with live progress. */ | |
| function goal() { | |
| const bits = []; | |
| if (isLv()) { | |
| const D = LD(), t = levelThresholds(mode.level); | |
| if (D.boss) bits.push('👑 Challenge level'); | |
| bits.push(`🎯 Bloom <b id="gp">${sim.cleared}/${D.goal}</b> flowers`); | |
| bits.push(`⭐⭐⭐ at <b>${t[2]}</b> pts`); | |
| if (D.twist) bits.push(`<span class="new-twist">NEW: ${D.twist}</span>`); | |
| } else { | |
| bits.push(`🎯 Bloom as many as you can, one miss ends the run · <b id="gp">${sim.score}</b>`); | |
| if (mode.kind === 'daily' && mode.date === todayIso && streakNow()) bits.push(`🔥 ${streakNow()}-day streak`); | |
| } | |
| $('goal').innerHTML = `<span>${bits.join(' · ')}</span>`; | |
| liveGoal(); | |
| } | |
| function liveGoal() { | |
| const gp = $('gp'); if (gp) gp.textContent = isLv() ? `${sim.cleared}/${sim.goal}` : sim.score; | |
| } | |
| function banner() { | |
| const el = $('challenge'); | |
| const same = chal && chal.score !== null && ((chal.level && isLv() && mode.level === chal.level) || (chal.date && mode.kind === 'daily' && mode.date === chal.date)); | |
| el.hidden = !same; | |
| if (same) el.innerHTML = `<span>🎯 A friend scored <b>${chal.score}</b> ${isLv() ? `points on level ${chal.level}` : `blooms on daily #${dayNumber(chal.date)}`}. Beat it!</span>`; | |
| } | |
| function hud() { | |
| if (isLv()) { | |
| const b = prog.best(mode.level), st = prog.stars(mode.level); | |
| $('stat').innerHTML = `${LD().name} · best <b>${b ?? '–'}</b> pts ${lv.starText(st)}`; | |
| } else if (mode.kind === 'daily') { | |
| const s = dayStats(mode); | |
| $('stat').innerHTML = `Today's best <b id="best">${s.best}</b> · tries <b id="tries">${s.tries || 0}</b>`; | |
| } else $('stat').innerHTML = `Practice best <b>${store('brimbloom:practice-best') || 0}</b>`; | |
| } | |
| // ------------------------------------------------------------------ loop | |
| function reset() { | |
| sim = isLv() ? makeLevelSim(mode.level) : makeSim(seedOf(mode)); | |
| state = 'ready'; pending = false; acc = 0; flash = 0; shakeAmt = 0; burst = 0; pop = null; | |
| } | |
| function start() { | |
| if (mode.kind === 'practice') mode.seed = `p${Date.now()}`; | |
| reset(); state = 'run'; last = performance.now(); | |
| count(GAME, 'play', isLv() ? `level-${mode.level}` : mode.kind === 'daily' ? (mode.date === todayIso ? 'daily' : 'archive') : 'practice'); | |
| $('over').hidden = true; | |
| goal(); | |
| } | |
| /** Instant restart (R key, Retry button, or a tap after the run): straight into a fresh run. */ | |
| function restart() { sfx.tap(); start(); } | |
| function input() { | |
| if (state === 'ready') { sfx.tap(); start(); return; } | |
| if (state === 'run') { pending = true; return; } | |
| if (state === 'dead' && performance.now() - deadAt > (sim.won ? 600 : 250)) restart(); | |
| } | |
| const flowerY = () => (cv.width / 2 + 20) / cv.width; | |
| function petalCols() { const S = SKINS[skin]; return S.petals === 'hue' ? ['#FF5A5A', '#FFD84D', '#5CF2FF', '#C37CFF'] : [...S.petals, S.core, '#FFFFFF']; } | |
| function die() { | |
| state = 'dead'; deadAt = performance.now(); | |
| lastRun = { score: sim.score, perfect: sim.perfect, marks: sim.marks.slice(), points: sim.points, won: !!sim.won, cleared: sim.cleared }; | |
| let newBest = false, res = null; | |
| if (isLv()) { | |
| const st = levelStars(mode.level, sim.points, sim.won); | |
| res = prog.record(mode.level, st, sim.points); | |
| res.got = st; | |
| if (sim.won) { | |
| sfx.win(); for (let k = 0; k < st; k++) sfx.star(k + 1); | |
| lv.burstAt(cv, 0.5, flowerY(), { n: 70, speed: 10, colors: petalCols() }); | |
| count(GAME, 'win', `level-${mode.level}`); | |
| } else { flash = 1; shakeAmt = 12; sfx.lose(); lv.shake(cv, 10, 320); } | |
| newBest = res.improved; | |
| } else { | |
| flash = 1; shakeAmt = 12; sfx.hit(); lv.shake(cv, 10, 320); | |
| if (navigator.vibrate) try { navigator.vibrate([30, 40, 30]); } catch { /* */ } | |
| if (mode.kind === 'daily') { | |
| const s = dayStats(mode); s.tries = (s.tries || 0) + 1; | |
| if (sim.score > (s.best || 0) || (sim.score === s.best && sim.perfect > (s.perfect || 0))) { s.best = sim.score; s.perfect = sim.perfect; s.marks = lastRun.marks; newBest = sim.score > 0; } | |
| store(dayKey(mode), s); | |
| } else if (sim.score > (store('brimbloom:practice-best') || 0)) { store('brimbloom:practice-best', sim.score); newBest = true; } | |
| } | |
| renderModes(); hud(); goal(); showOver(newBest, res); | |
| if (sim.score >= 5) install.offer(); | |
| } | |
| function frame(now) { | |
| requestAnimationFrame(frame); | |
| if (state === 'run') { | |
| acc += Math.min(0.25, (now - last) / 1000); last = now; | |
| let n = 0; | |
| while (acc >= 1 / HZ && n++ < 8) { | |
| acc -= 1 / HZ; | |
| const busy = sim.busy ? sim.busy() : sim.wait > 0; | |
| const tap = pending && !busy; pending = false; | |
| const rAt = sim.r, ev = sim.step(tap); | |
| if (ev) onEvent(ev, rAt); | |
| if (sim.dead) { die(); break; } | |
| } | |
| } else last = now; | |
| draw(now); | |
| } | |
| function onEvent(ev, rAt) { | |
| const k = sim.score; | |
| const fy = flowerY() - Math.min(rAt, 1) * 0.43 * 0.7; | |
| if (ev === 'perfect') { sfx.good(k); burst = 1; lv.burstAt(cv, 0.5, fy, { n: 18, speed: 5, colors: petalCols(), life: 500 }); pop = { t: performance.now(), txt: 'PERFECT', c: '#3DDC84' }; } | |
| else if (ev === 'good') { sfx.tone(560 + Math.min(k, 30) * 14, 0.07, 'triangle', 0.06, 1.4); burst = 0.6; pop = { t: performance.now(), txt: 'GOOD', c: '#FFC93C' }; } | |
| else if (ev === 'miss' && !sim.dead) { sfx.hit(); flash = 0.8; shakeAmt = 10; lv.shake(cv, 8, 260); pop = { t: performance.now(), txt: sim.r > LIMIT ? 'OVERFLOW' : 'MISS', c: '#FF6B6B' }; } | |
| liveGoal(); | |
| } | |
| // ------------------------------------------------------------------ drawing | |
| const TAU = Math.PI * 2; | |
| function petal(ctx, len, wid, shape) { | |
| ctx.beginPath(); | |
| if (shape === 'pointed') { ctx.moveTo(0, 0); ctx.quadraticCurveTo(wid, -len * 0.5, 0, -len); ctx.quadraticCurveTo(-wid, -len * 0.5, 0, 0); } | |
| else if (shape === 'heart') { ctx.moveTo(0, 0); ctx.bezierCurveTo(wid * 1.3, -len * 0.4, wid * 0.8, -len * 1.05, 0, -len * 0.82); ctx.bezierCurveTo(-wid * 0.8, -len * 1.05, -wid * 1.3, -len * 0.4, 0, 0); } | |
| else if (shape === 'star') { ctx.moveTo(0, 0); ctx.lineTo(wid * 0.55, -len * 0.45); ctx.lineTo(0, -len); ctx.lineTo(-wid * 0.55, -len * 0.45); ctx.closePath(); } | |
| else ctx.ellipse(0, -len / 2, wid * 0.7, len / 2, 0, 0, TAU); | |
| ctx.fill(); | |
| } | |
| function flower(ctx, x, y, R, n, S, hue, spinA) { | |
| if (R <= 0.5) return; | |
| const cols = S.petals === 'hue' ? [`hsl(${hue},90%,62%)`, `hsl(${(hue + 40) % 360},90%,72%)`] : S.petals; | |
| ctx.save(); ctx.translate(x, y); ctx.rotate(spinA); | |
| for (let layer = 0; layer < 2; layer++) { | |
| ctx.fillStyle = cols[layer]; | |
| const L = R * (layer ? 0.72 : 1), Wd = Math.max(4, R * (layer ? 0.3 : 0.42) * 6 / n); | |
| for (let k = 0; k < n; k++) { ctx.save(); ctx.rotate(k * TAU / n + layer * Math.PI / n); petal(ctx, L, Wd, S.shape); ctx.restore(); } | |
| } | |
| ctx.fillStyle = S.core; ctx.beginPath(); ctx.arc(0, 0, Math.max(5, R * 0.16), 0, TAU); ctx.fill(); | |
| ctx.restore(); | |
| } | |
| function ring(ctx, x, y, Rmax, c, w, S, lit = false) { | |
| ctx.fillStyle = S.band; ctx.beginPath(); ctx.arc(x, y, (c + w / 2) * Rmax, 0, TAU); ctx.arc(x, y, Math.max(0, c - w / 2) * Rmax, 0, TAU, true); ctx.fill(); | |
| ctx.fillStyle = S.sweet; ctx.beginPath(); ctx.arc(x, y, (c + w * 0.25) * Rmax, 0, TAU); ctx.arc(x, y, Math.max(0, c - w * 0.25) * Rmax, 0, TAU, true); ctx.fill(); | |
| const a0 = ctx.globalAlpha; | |
| ctx.strokeStyle = S.edge; ctx.lineWidth = lit ? 6 : 3; ctx.globalAlpha = a0 * (lit ? 1 : 0.7); | |
| for (const r of [c - w / 2, c + w / 2]) { ctx.beginPath(); ctx.arc(x, y, Math.max(0, r) * Rmax, 0, TAU); ctx.stroke(); } | |
| ctx.globalAlpha = a0; | |
| } | |
| /** A thorn ring (decoy): a red dashed band that looks like a brim but never counts. */ | |
| function thorns(ctx, x, y, Rmax, c, w) { | |
| ctx.save(); | |
| ctx.fillStyle = 'rgba(255,90,90,.13)'; ctx.beginPath(); ctx.arc(x, y, (c + w / 2) * Rmax, 0, TAU); ctx.arc(x, y, (c - w / 2) * Rmax, 0, TAU, true); ctx.fill(); | |
| ctx.strokeStyle = '#FF6B6B'; ctx.lineWidth = 3; ctx.setLineDash([10, 9]); ctx.globalAlpha = 0.85; | |
| for (const r of [c - w / 2, c + w / 2]) { ctx.beginPath(); ctx.arc(x, y, r * Rmax, 0, TAU); ctx.stroke(); } | |
| ctx.restore(); | |
| } | |
| function hearts(n, of, x, y) { | |
| g.font = '26px system-ui, "Apple Color Emoji", "Segoe UI Emoji", sans-serif'; g.textAlign = 'left'; | |
| for (let i = 0; i < of; i++) { g.globalAlpha = i < n ? 1 : 0.3; g.fillText(i < n ? '❤️' : '🖤', x + i * 32, y); } | |
| g.globalAlpha = 1; | |
| } | |
| function draw(now) { | |
| const S = SKINS[skin], Wc = cv.width, cx = Wc / 2, cy = Wc / 2 + 20, Rmax = Wc * 0.43; | |
| g.save(); | |
| if (shakeAmt > 0) { g.translate((Math.random() - 0.5) * shakeAmt, (Math.random() - 0.5) * shakeAmt); shakeAmt *= 0.85; if (shakeAmt < 0.5) shakeAmt = 0; } | |
| g.fillStyle = S.bg; g.fillRect(-20, -20, Wc + 40, Wc + 40); | |
| g.strokeStyle = S.band; g.lineWidth = 2; | |
| for (let i = 0; i < 3; i++) { const r = ((now / 30 + i * 120) % 360); g.globalAlpha = 1 - r / 360; g.beginPath(); g.arc(cx, cy, r + 20, 0, TAU); g.stroke(); } | |
| g.globalAlpha = 1; | |
| const b = sim.cur, rNow = state === 'ready' ? 0 : Math.min(sim.r, LIMIT); | |
| if (b.decoy) thorns(g, cx, cy, Rmax, b.decoy.c, b.decoy.w); | |
| if (b.rings && b.rings.length > 1) { | |
| b.rings.forEach((rg, k) => { | |
| if (k === b.k) return; | |
| g.globalAlpha = k < b.k ? 0.25 : 0.55; ring(g, cx, cy, Rmax, rg.c, rg.w, S); g.globalAlpha = 1; | |
| }); | |
| } | |
| const c0 = ringC(b, sim.t), inside = state === 'run' && Math.abs(rNow - c0) <= b.w / 2; | |
| g.globalAlpha = b.fade && state !== 'ready' ? Math.max(0.06, Math.min(1, (c0 - rNow - 0.06) / 0.2)) : 1; | |
| ring(g, cx, cy, Rmax, c0, b.w, S, inside); | |
| g.globalAlpha = 1; | |
| const R = (state === 'ready' ? 0.18 + Math.sin(now / 300) * 0.02 : rNow) * Rmax; | |
| flower(g, cx, cy, R * (1 + burst * 0.06), b.n, S, b.hue, now / 4000); | |
| burst *= 0.88; | |
| // marks row (this run) | |
| const marks = sim.marks.slice(-14); | |
| marks.forEach((m, i) => { g.fillStyle = m === 'perfect' ? '#3DDC84' : m === 'good' ? '#FFC93C' : '#FF4D4D'; g.beginPath(); g.arc(cx - (marks.length - 1) * 13 + i * 26, Wc - 26, 8, 0, TAU); g.fill(); }); | |
| g.fillStyle = S.text; g.textAlign = 'center'; g.textBaseline = 'middle'; | |
| if (isLv()) { | |
| // goal progress: one dot per flower, hearts top-left, level top-right | |
| const D = LD(), n = D.goal, w = Math.min(34, 420 / n), x0 = cx - (n * w) / 2; | |
| for (let i = 0; i < n; i++) { g.fillStyle = i < sim.cleared ? '#3DDC84' : S.band; g.beginPath(); g.arc(x0 + w * i + w / 2, 124, Math.min(9, w * 0.32), 0, TAU); g.fill(); } | |
| g.fillStyle = S.text; hearts(sim.lives, D.lives, 22, 34); | |
| g.textAlign = 'right'; g.font = '800 24px system-ui, sans-serif'; g.fillText(`${D.boss ? '👑 ' : ''}Lv ${mode.level}`, Wc - 22, 34); g.textAlign = 'center'; | |
| } | |
| if (state === 'ready') { | |
| g.font = '900 60px system-ui, sans-serif'; g.fillText('TAP', cx, 60); | |
| g.font = '700 24px system-ui, sans-serif'; g.globalAlpha = 0.85; g.fillText('then tap again when the petals reach the ring', cx, isLv() ? 162 : 108); g.globalAlpha = 1; | |
| } else { | |
| g.font = '900 64px system-ui, sans-serif'; g.fillText(isLv() ? `${sim.cleared}/${sim.goal}` : String(sim.score), cx, 64); | |
| const age = pop ? now - pop.t : 1e9; | |
| if (pop && age < 700 && state === 'run') { g.globalAlpha = 1 - age / 700; g.font = `900 ${34 + (1 - age / 700) * 8}px system-ui, sans-serif`; g.fillStyle = pop.c; g.fillText(pop.txt, cx, isLv() ? 166 : 118); g.globalAlpha = 1; } | |
| if (state === 'dead') { | |
| g.fillStyle = 'rgba(0,0,0,.5)'; g.fillRect(-20, cy - 70, Wc + 40, 140); | |
| g.fillStyle = '#FFFFFF'; g.font = '900 44px system-ui, sans-serif'; | |
| const won = isLv() && sim.won; | |
| g.fillText(won ? `LEVEL ${mode.level} CLEAR` : isLv() ? 'OUT OF HEARTS' : sim.r > LIMIT ? 'IT OVERFLOWED' : 'OFF THE BRIM', cx, cy - 22); | |
| g.font = '700 26px system-ui, sans-serif'; | |
| g.fillText(won ? `${lv.starText(levelStars(mode.level, sim.points))} ${sim.points} pts · tap to replay` : 'tap or press R to retry', cx, cy + 26); | |
| } | |
| } | |
| if (flash > 0) { g.fillStyle = `rgba(255,77,77,${flash * 0.3})`; g.fillRect(-20, -20, Wc + 40, Wc + 40); flash *= 0.88; if (flash < 0.02) flash = 0; } | |
| g.restore(); | |
| } | |
| // ------------------------------------------------------------------ results, share, card | |
| function result() { | |
| if (mode.kind === 'daily') { const s = dayStats(mode); return { best: s.best, perfect: s.perfect || 0, marks: s.marks || [], tries: s.tries }; } | |
| return { best: lastRun ? (isLv() ? lastRun.points : lastRun.score) : 0, perfect: lastRun ? lastRun.perfect : 0, marks: lastRun ? lastRun.marks : [] }; | |
| } | |
| function shareText() { | |
| const r = result(); | |
| if (isLv()) return `Brimbloom level ${mode.level} 🌸 ${lv.starText(prog.stars(mode.level))}\n${lastRun.points} points, ${lastRun.perfect} perfect\n${grid(lastRun.marks)}\nCan you beat my ${lastRun.points}?`; | |
| const st = mode.kind === 'daily' && mode.date === todayIso && streakNow() > 1 ? ` · 🔥${streakNow()}` : ''; | |
| const head = mode.kind === 'practice' ? 'Brimbloom practice 🌸' : `Brimbloom #${dayNumber(mode.date)} 🌸`; | |
| return `${head} ${r.best} blooms, ${r.perfect} perfect${r.tries ? ` (${r.tries} tries)` : ''}${st}\n${grid(r.marks)}\nCan you stop on the brim?`; | |
| } | |
| /** The "beat my score" link: the same level or daily garden, with my score to beat. */ | |
| function beatLink() { | |
| if (isLv()) return lv.challengeLink(PATH, GAME, { level: mode.level, score: lastRun ? lastRun.points : 0 }); | |
| if (mode.kind === 'daily') return lv.challengeLink(PATH, GAME, { date: mode.date, score: result().best }); | |
| return `https://cybermaxtools.com${PATH}`; | |
| } | |
| function showOver(newBest, res) { | |
| const lvl = isLv(), won = lvl && lastRun.won; | |
| const st = mode.kind === 'daily' ? dayStats(mode) : null; | |
| $('win-stars').hidden = !won; | |
| if (lvl) { | |
| $('win-stars').innerHTML = lv.starsHtml(res.got); | |
| $('over-title').textContent = won ? (res.got === 3 ? `Level ${mode.level}: perfect bloom!` : `Level ${mode.level} cleared!`) : `Out of hearts on level ${mode.level}`; | |
| const t = levelThresholds(mode.level); | |
| $('over-line').textContent = won | |
| ? `${lastRun.points} points, ${lastRun.perfect} perfect${newBest ? ' · new best' : ''}.${res.got < 3 ? ` ${t[res.got]} points for the next star.` : ''}` | |
| : `${lastRun.cleared}/${LD().goal} flowers. Tap the game or press R to retry.`; | |
| } else { | |
| $('over-title').textContent = newBest ? `New best: ${lastRun.score}!` : `${lastRun.score} bloom${lastRun.score === 1 ? '' : 's'}, ${lastRun.perfect} perfect`; | |
| $('over-line').textContent = st ? `Today's best ${st.best} after ${st.tries} tries${streakNow() > 1 ? ` · 🔥 ${streakNow()}-day streak` : ''}. Tap the game to go again.` : 'Practice garden. Tap the game to go again.'; | |
| } | |
| if (chal && chal.score !== null && !$('challenge').hidden) { | |
| const mine = lvl ? lastRun.points : lastRun.score; | |
| $('over-line').textContent += mine > chal.score ? ` 🎯 You beat your friend's ${chal.score}!` : mine === chal.score ? ` 🎯 Tied with your friend (${chal.score}).` : ` 🎯 ${chal.score - mine} short of your friend's ${chal.score}. Retry?`; | |
| } | |
| $('over-grid').textContent = grid(lastRun.marks); | |
| const next = lvl && mode.level < NL ? mode.level + 1 : 0; | |
| $('next-lv').hidden = !(won && next); | |
| if (next) $('next-lv').textContent = prog.lock(next) === 'pass' ? `Level ${next} 🔒 Fun Pass` : `Next: level ${next} ▶`; | |
| $('beat').hidden = lvl && !won; | |
| $('share-row').hidden = lvl && !won; | |
| const it = intents(GAME, `${shareText()}\n🎯 ${beatLink()}`, PATH); | |
| $('sx').href = it.x; $('sb').href = it.bsky; $('sw').href = it.whatsapp; | |
| $('over').hidden = false; | |
| const value = won || newBest; | |
| if (value) pass.afterWin($('over'), lvl ? 'level' : 'over'); | |
| const fa = $('over').querySelector('.fp-after'); if (fa) fa.hidden = !value; | |
| } | |
| function card() { | |
| const S = SKINS[skin], c = document.createElement('canvas'); | |
| c.width = 1080; c.height = 1350; | |
| const x = c.getContext('2d'); | |
| x.fillStyle = S.bg; x.fillRect(0, 0, 1080, 1350); | |
| ring(x, 540, 700, 330, 0.7, 0.16, S); | |
| flower(x, 540, 700, 0.7 * 330, 7, S, 330, 0.2); | |
| const r = result(), lvl = isLv(); | |
| x.fillStyle = S.text; x.textAlign = 'left'; | |
| x.font = '900 66px system-ui, sans-serif'; x.fillText('BRIMBLOOM', 80, 130); | |
| x.globalAlpha = 0.85; x.font = '700 40px system-ui, sans-serif'; | |
| x.fillText(lvl ? `Level ${mode.level} · ${LD().name} ${lv.starText(prog.stars(mode.level))}` : mode.kind === 'daily' ? `Daily garden #${dayNumber(mode.date)} · ${mode.date}` : 'Practice garden', 80, 190); | |
| x.globalAlpha = 1; x.textAlign = 'right'; x.font = '900 120px system-ui, sans-serif'; x.fillText(String(r.best), 1000, lvl ? 330 : 150); | |
| x.font = '700 34px system-ui, sans-serif'; x.fillText(lvl ? `points · ${r.perfect} perfect` : `blooms · ${r.perfect} perfect`, 1000, lvl ? 375 : 195); | |
| x.textAlign = 'left'; | |
| const ms = r.marks.slice(0, 30); | |
| ms.forEach((m, i) => { x.fillStyle = m === 'perfect' ? '#3DDC84' : m === 'good' ? '#FFC93C' : '#FF4D4D'; x.fillRect(80 + (i % 10) * 92, 990 + Math.floor(i / 10) * 52, 76, 40); }); | |
| x.fillStyle = S.text; x.font = '800 48px system-ui, sans-serif'; x.fillText(lvl ? 'Can you beat my score?' : 'Can you stop on the brim?', 80, 1200); | |
| x.globalAlpha = 0.75; x.font = '600 32px system-ui, sans-serif'; x.fillText('Play free: cybermaxtools.com/games/brimbloom', 80, 1290); | |
| return c; | |
| } | |
| const fname = () => `brimbloom-${isLv() ? `level-${mode.level}` : mode.date || 'practice'}.png`; | |
| $('share').onclick = async () => { | |
| const r = await lv.shareChallenge(shareText(), beatLink(), await canvasBlob(card()), fname()); | |
| if (r === 'copied') toast('Result and link copied. Paste it anywhere.'); | |
| if (r !== 'cancelled') count(GAME, 'share', r === 'shared' ? 'native' : 'copy'); | |
| }; | |
| $('beat').onclick = async () => { | |
| const mine = isLv() ? lastRun.points : result().best; | |
| const what = isLv() ? `${mine} points on Brimbloom level ${mode.level}` : `${mine} blooms on Brimbloom #${dayNumber(mode.date || todayIso)}`; | |
| const r = await lv.shareChallenge(`I scored ${what}. Can you beat me? 🌸`, beatLink()); | |
| if (r === 'copied') toast('Challenge link copied. Send it to a friend!'); | |
| if (r !== 'cancelled') count(GAME, 'share', 'challenge'); | |
| }; | |
| ['sx', 'sb', 'sw'].forEach((id) => $(id).addEventListener('click', () => count(GAME, 'share', { sx: 'x', sb: 'bsky', sw: 'whatsapp' }[id]))); | |
| $('card').onclick = async () => { download(await canvasBlob(card()), fname()); count(GAME, 'share', 'png'); }; | |
| $('retry').onclick = () => { restart(); cv.scrollIntoView({ behavior: 'smooth', block: 'center' }); }; | |
| $('next-lv').onclick = () => { if (load({ kind: 'level', level: mode.level + 1 }, true)) cv.scrollIntoView({ behavior: 'smooth', block: 'center' }); }; | |
| // ------------------------------------------------------------------ input, skins, start | |
| cv.addEventListener('pointerdown', (e) => { e.preventDefault(); input(); }); | |
| cv.addEventListener('keydown', (e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); input(); } }); | |
| document.addEventListener('keydown', (e) => { | |
| if (e.target && /INPUT|TEXTAREA|SELECT/.test(e.target.tagName)) return; | |
| if (e.key === ' ' && document.activeElement === document.body) { e.preventDefault(); input(); } | |
| else if ((e.key === 'r' || e.key === 'R') && !e.ctrlKey && !e.metaKey) { e.preventDefault(); restart(); } | |
| else if ((e.key === 'm' || e.key === 'M') && !e.ctrlKey && !e.metaKey) setMute(!muted); | |
| }); | |
| $('pass-btn').onclick = () => pass.open('header'); | |
| $('skins-btn').onclick = () => skins.open('header'); | |
| document.addEventListener('click', (e) => { | |
| const b = e.target.closest('[data-pass]'); if (b) pass.open(b.dataset.pass); | |
| const k = e.target.closest('[data-skins]'); if (k) skins.open(k.dataset.skins); | |
| }); | |
| document.addEventListener('visibilitychange', () => { if (document.hidden && state === 'run') last = performance.now(); }); | |
| function renderSkins() { | |
| const el = $('skins'); el.innerHTML = ''; | |
| for (const [id, s] of Object.entries(SKINS)) { | |
| const b = document.createElement('button'); | |
| const lock = !canUse(s); | |
| b.type = 'button'; b.title = `${s.name}${s.tier === 'pass' ? ' (Fun Pass)' : s.tier === 'pack' ? ' (Skin Pack)' : ''}`; b.setAttribute('aria-label', b.title); | |
| b.style.background = s.petals === 'hue' ? 'conic-gradient(red,yellow,lime,cyan,blue,magenta,red)' : `linear-gradient(135deg, ${s.bg} 50%, ${s.petals[0]} 50%)`; | |
| b.setAttribute('aria-pressed', String(id === skin)); | |
| if (lock) b.classList.add('locked'); | |
| b.onclick = () => { | |
| if (lock) { if (s.tier === 'pass') pass.open('theme'); else skins.open('dot'); return; } | |
| skin = id; store('brimbloom:skin', id); renderSkins(); | |
| }; | |
| el.appendChild(b); | |
| } | |
| $('skins-btn').textContent = skins.owned() ? '🌸 Skins' : '🌸 Skin Pack $1.99'; | |
| } | |
| const refresh = () => { if (!canUse(SKINS[skin])) { skin = 'pond'; store('brimbloom:skin', 'pond'); } renderSkins(); if (sim) renderModes(); }; | |
| pass.onChange(refresh); | |
| skins.onChange(refresh); | |
| // Screenshot helper for scripts/games/render_assets.mjs: a mid-run frame on today's garden (no effect on normal play). | |
| window.__cmxShot = async (stage = 1) => { | |
| if (mode.kind !== 'daily') load({ kind: 'daily', date: todayIso }); | |
| start(); | |
| const s = makeSim(todayIso); | |
| while (s.score < 4 && !s.dead) { const t = s.t + 1; s.step(s.wait === 0 && judge(s.cur, Math.max(s.r, radius(s.cur, t)), t) === 'perfect'); } | |
| while (s.wait > 0) s.step(false); | |
| for (let i = 0; i < 28; i++) s.step(false); | |
| sim = s; state = 'shot'; | |
| if (stage === 2) { s.marks.push('miss'); s.dead = true; die(); } | |
| }; | |
| // Demo recorder helper (scripts/studio/demo_video.mjs) and e2e hook: real taps (pointer events) on perfect moments for | |
| // n stops, then one early tap so a daily run ends on screen (a level run ends by itself when its goal is met). | |
| window.__cmxDemo = (n = 6) => { | |
| const tap = () => cv.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, cancelable: true })); | |
| if (state !== 'run') tap(); | |
| const iv = setInterval(() => { | |
| if (state === 'dead') { clearInterval(iv); return; } | |
| if (state !== 'run' || (sim.busy ? sim.busy() : sim.wait > 0) || pending) return; | |
| const t = sim.t + 1; | |
| if (sim.score >= n ? sim.t > 12 : judge(sim.cur, Math.max(sim.r, radius(sim.cur, t)), t) === 'perfect') tap(); | |
| }, 4); | |
| }; | |
| window.__cmxTest = { state: () => ({ state, kind: mode.kind, level: mode.level || 0, score: sim.score, cleared: sim.cleared || 0, lives: sim.lives, won: !!sim.won }) }; | |
| // a locked link (pass level, level not reached yet) falls back to the next open level, then today's daily | |
| load(fromUrl()) || (!prog.lock(prog.reached()) && load({ kind: 'level', level: prog.reached() })) || load({ kind: 'daily', date: todayIso }); | |
| requestAnimationFrame(frame); | |