// 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 ? ' ๐' : ''}`;
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 ${sim.cleared}/${D.goal} flowers`);
bits.push(`โญโญโญ at ${t[2]} pts`);
if (D.twist) bits.push(`NEW: ${D.twist}`);
} else {
bits.push(`๐ฏ Bloom as many as you can, one miss ends the run ยท ${sim.score}`);
if (mode.kind === 'daily' && mode.date === todayIso && streakNow()) bits.push(`๐ฅ ${streakNow()}-day streak`);
}
$('goal').innerHTML = `${bits.join(' ยท ')}`;
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 = `๐ฏ A friend scored ${chal.score} ${isLv() ? `points on level ${chal.level}` : `blooms on daily #${dayNumber(chal.date)}`}. Beat it!`;
}
function hud() {
if (isLv()) {
const b = prog.best(mode.level), st = prog.stars(mode.level);
$('stat').innerHTML = `${LD().name} ยท best ${b ?? 'โ'} pts ${lv.starText(st)}`;
} else if (mode.kind === 'daily') {
const s = dayStats(mode);
$('stat').innerHTML = `Today's best ${s.best} ยท tries ${s.tries || 0}`;
} else $('stat').innerHTML = `Practice best ${store('brimbloom:practice-best') || 0}`;
}
// ------------------------------------------------------------------ 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);