Spaces:
Running
Running
Deploy brimbloom
Browse files- README.md +18 -5
- cmx.js +3 -1
- core.js +121 -0
- funpass.js +2 -2
- game.js +286 -85
- index.html +39 -20
- levels.js +341 -0
- style.css +5 -0
- sw.js +2 -2
README.md
CHANGED
|
@@ -1,24 +1,37 @@
|
|
| 1 |
---
|
| 2 |
title: "Brimbloom: One-tap timing game"
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: static
|
| 7 |
pinned: false
|
| 8 |
license: other
|
|
|
|
| 9 |
short_description: "One-tap timing: stop the bloom on the brim"
|
| 10 |
tags:
|
| 11 |
- game
|
|
|
|
|
|
|
|
|
|
| 12 |
- html5
|
| 13 |
- browser-game
|
| 14 |
- pwa
|
| 15 |
- cybermax
|
| 16 |
-
thumbnail: https://huggingface.co/spaces/CyberMax-tools/brimbloom/resolve/main/thumbnail.png
|
| 17 |
---
|
| 18 |
**Brimbloom**: One-tap timing: stop the bloom on the brim. A free daily game from CyberMax: the same challenge for everyone each day, a shareable result, works offline and installs on your phone (no app store, no sign-up).
|
| 19 |
|
| 20 |
Play it full screen: https://cybermaxtools.com/games/brimbloom/?utm_source=huggingface
|
| 21 |
|
| 22 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
More free games: https://cybermaxtools.com/games/ · © 2026 CyberMax. All rights reserved.
|
|
|
|
| 1 |
---
|
| 2 |
title: "Brimbloom: One-tap timing game"
|
| 3 |
+
emoji: 🌸
|
| 4 |
+
colorFrom: pink
|
| 5 |
+
colorTo: yellow
|
| 6 |
sdk: static
|
| 7 |
pinned: false
|
| 8 |
license: other
|
| 9 |
+
thumbnail: https://cybermaxtools.com/games/brimbloom/og.png
|
| 10 |
short_description: "One-tap timing: stop the bloom on the brim"
|
| 11 |
tags:
|
| 12 |
- game
|
| 13 |
+
- arcade
|
| 14 |
+
- one-tap
|
| 15 |
+
- timing
|
| 16 |
- html5
|
| 17 |
- browser-game
|
| 18 |
- pwa
|
| 19 |
- cybermax
|
|
|
|
| 20 |
---
|
| 21 |
**Brimbloom**: One-tap timing: stop the bloom on the brim. A free daily game from CyberMax: the same challenge for everyone each day, a shareable result, works offline and installs on your phone (no app store, no sign-up).
|
| 22 |
|
| 23 |
Play it full screen: https://cybermaxtools.com/games/brimbloom/?utm_source=huggingface
|
| 24 |
|
| 25 |
+
**Watch the real gameplay demo:** https://cybermaxtools.com/media/demos/brimbloom/demo-16x9.mp4
|
| 26 |
+
|
| 27 |
+

|
| 28 |
+
|
| 29 |
+
**Embed it free on your site** (works on phones, keeps a small link to CyberMax):
|
| 30 |
+
|
| 31 |
+
```html
|
| 32 |
+
<iframe src="https://cybermaxtools.com/embed/brimbloom/" width="480" height="720" style="border:0;max-width:100%;aspect-ratio:2/3;height:auto" title="Brimbloom: free game by CyberMax" loading="lazy" allowfullscreen></iframe>
|
| 33 |
+
```
|
| 34 |
+
|
| 35 |
+
The optional **CyberMax Fun Pass** ($2.99/month, $19/year or $29.99 for life) adds extra packs, the full archive of past dailies and colour themes in every CyberMax game: https://cybermaxtools.com/games/?utm_source=huggingface
|
| 36 |
|
| 37 |
More free games: https://cybermaxtools.com/games/ · © 2026 CyberMax. All rights reserved.
|
cmx.js
CHANGED
|
@@ -129,7 +129,7 @@ export function pwa(game, btn) {
|
|
| 129 |
let deferred = null;
|
| 130 |
let wanted = false;
|
| 131 |
const ios = /iphone|ipad|ipod/i.test(navigator.userAgent) && !/crios|fxios/i.test(navigator.userAgent);
|
| 132 |
-
window.addEventListener('beforeinstallprompt', (e) => { e.preventDefault(); deferred = e; if (wanted && btn) btn.hidden = false; });
|
| 133 |
window.addEventListener('appinstalled', () => { count(game, 'install', 'prompt'); if (btn) btn.hidden = true; });
|
| 134 |
if (btn) {
|
| 135 |
btn.addEventListener('click', async () => {
|
|
@@ -137,10 +137,12 @@ export function pwa(game, btn) {
|
|
| 137 |
deferred.prompt();
|
| 138 |
const r = await deferred.userChoice.catch(() => null);
|
| 139 |
if (r && r.outcome === 'accepted') btn.hidden = true;
|
|
|
|
| 140 |
deferred = null;
|
| 141 |
} else if (ios) {
|
| 142 |
toast('Tap the Share button, then "Add to Home Screen".', 5000);
|
| 143 |
count(game, 'install', 'ios-hint');
|
|
|
|
| 144 |
}
|
| 145 |
});
|
| 146 |
}
|
|
|
|
| 129 |
let deferred = null;
|
| 130 |
let wanted = false;
|
| 131 |
const ios = /iphone|ipad|ipod/i.test(navigator.userAgent) && !/crios|fxios/i.test(navigator.userAgent);
|
| 132 |
+
window.addEventListener('beforeinstallprompt', (e) => { e.preventDefault(); deferred = e; if (wanted && btn && !store(`cmx-install-no:${game}`)) btn.hidden = false; });
|
| 133 |
window.addEventListener('appinstalled', () => { count(game, 'install', 'prompt'); if (btn) btn.hidden = true; });
|
| 134 |
if (btn) {
|
| 135 |
btn.addEventListener('click', async () => {
|
|
|
|
| 137 |
deferred.prompt();
|
| 138 |
const r = await deferred.userChoice.catch(() => null);
|
| 139 |
if (r && r.outcome === 'accepted') btn.hidden = true;
|
| 140 |
+
else if (r && r.outcome === 'dismissed') { store(`cmx-install-no:${game}`, 1); btn.hidden = true; } // "Not now" is remembered
|
| 141 |
deferred = null;
|
| 142 |
} else if (ios) {
|
| 143 |
toast('Tap the Share button, then "Add to Home Screen".', 5000);
|
| 144 |
count(game, 'install', 'ios-hint');
|
| 145 |
+
store(`cmx-install-no:${game}`, 1);
|
| 146 |
}
|
| 147 |
});
|
| 148 |
}
|
core.js
CHANGED
|
@@ -79,3 +79,124 @@ export function grid(marks) {
|
|
| 79 |
for (let i = 0; i < e.length; i += 10) rows.push(e.slice(i, i + 10).join(''));
|
| 80 |
return rows.join('\n');
|
| 81 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
for (let i = 0; i < e.length; i += 10) rows.push(e.slice(i, i + 10).join(''));
|
| 80 |
return rows.join('\n');
|
| 81 |
}
|
| 82 |
+
|
| 83 |
+
// ------------------------------------------------------------------ 30 levels (own seed space: the daily is untouched)
|
| 84 |
+
// Each level is a finite goal: bloom `goal` flowers before you run out of `lives` (a miss costs a life and the next
|
| 85 |
+
// flower comes). Speeds s and widths w are [min, max] ranges; pulse = surge amplitude range, breathe = ring drift,
|
| 86 |
+
// two = chance a flower has two brims (stop it twice), fade = the ring fades as the bloom nears it, decoy = a false
|
| 87 |
+
// ring in a warning colour. Bosses (every 10th) give one life. (c) 2026 CyberMax.
|
| 88 |
+
export const HOLD = 14; // ticks a two-brim flower rests after its first stop
|
| 89 |
+
const L = (name, goal, s, w, o = {}) => ({ name, goal, s, w, lives: o.lives ?? 3, ...o });
|
| 90 |
+
export const LEVELS = [
|
| 91 |
+
L('First bud', 5, [0.36, 0.4], [0.2, 0.22]),
|
| 92 |
+
L('Petal pond', 6, [0.4, 0.46], [0.18, 0.2]),
|
| 93 |
+
L('Quickening', 7, [0.45, 0.52], [0.16, 0.18]),
|
| 94 |
+
L('Narrow brim', 8, [0.48, 0.56], [0.14, 0.16]),
|
| 95 |
+
L('Surge', 8, [0.46, 0.52], [0.16, 0.18], { pulse: [0.2, 0.35], twist: 'Surging blooms rush, then ease' }),
|
| 96 |
+
L('Rush hour', 8, [0.5, 0.58], [0.15, 0.17], { pulse: [0.25, 0.4] }),
|
| 97 |
+
L('Tide pool', 9, [0.54, 0.62], [0.14, 0.16], { pulse: [0.25, 0.45] }),
|
| 98 |
+
L('Quick petals', 10, [0.58, 0.66], [0.13, 0.15], { pulse: [0.3, 0.5], lives: 2 }),
|
| 99 |
+
L('Thin water', 10, [0.6, 0.68], [0.12, 0.14], { pulse: [0.3, 0.5], lives: 2 }),
|
| 100 |
+
L('Garden gauntlet', 12, [0.6, 0.72], [0.12, 0.14], { pulse: [0.2, 0.5], lives: 1, boss: true }),
|
| 101 |
+
L('Breathing ring', 8, [0.5, 0.56], [0.15, 0.17], { breathe: [0.02, 0.03], twist: 'Breathing rings drift in and out' }),
|
| 102 |
+
L('Low tide', 9, [0.54, 0.6], [0.14, 0.16], { breathe: [0.02, 0.035] }),
|
| 103 |
+
L('Swell', 9, [0.54, 0.62], [0.14, 0.16], { breathe: [0.02, 0.035], pulse: [0.2, 0.35] }),
|
| 104 |
+
L('Ripples', 10, [0.58, 0.66], [0.13, 0.15], { breathe: [0.025, 0.04], pulse: [0.2, 0.4], lives: 2 }),
|
| 105 |
+
L('Moon pond', 10, [0.62, 0.7], [0.12, 0.14], { breathe: [0.025, 0.04], pulse: [0.25, 0.45], lives: 2 }),
|
| 106 |
+
L('Two brims', 6, [0.46, 0.52], [0.16, 0.18], { two: 1, twist: 'Two brims: stop each flower twice' }),
|
| 107 |
+
L('Double take', 7, [0.5, 0.56], [0.15, 0.17], { two: 0.7 }),
|
| 108 |
+
L('Second wind', 8, [0.52, 0.6], [0.14, 0.16], { two: 0.6, pulse: [0.2, 0.35] }),
|
| 109 |
+
L('Echo bloom', 8, [0.56, 0.62], [0.13, 0.15], { two: 0.6, breathe: [0.02, 0.03], lives: 2 }),
|
| 110 |
+
L('Twin gauntlet', 10, [0.58, 0.66], [0.12, 0.14], { two: 0.6, pulse: [0.2, 0.4], breathe: [0.02, 0.03], lives: 1, boss: true }),
|
| 111 |
+
L('Fading brim', 8, [0.5, 0.56], [0.15, 0.17], { fade: true, twist: 'Fading rings vanish as the bloom nears' }),
|
| 112 |
+
L('Mist', 9, [0.54, 0.6], [0.14, 0.16], { fade: true, pulse: [0.2, 0.35] }),
|
| 113 |
+
L('Ghost pond', 9, [0.56, 0.62], [0.14, 0.16], { fade: true, breathe: [0.02, 0.03] }),
|
| 114 |
+
L('Fog bank', 10, [0.58, 0.66], [0.13, 0.15], { fade: true, two: 0.4, lives: 2 }),
|
| 115 |
+
L('Night bloom', 10, [0.6, 0.68], [0.12, 0.14], { fade: true, pulse: [0.25, 0.45], lives: 2 }),
|
| 116 |
+
L('Thorn ring', 8, [0.52, 0.58], [0.14, 0.16], { decoy: true, twist: 'Thorn rings: only the bright ring counts' }),
|
| 117 |
+
L('Bramble', 9, [0.56, 0.62], [0.13, 0.15], { decoy: true, pulse: [0.2, 0.4] }),
|
| 118 |
+
L('Briar patch', 10, [0.58, 0.66], [0.13, 0.15], { decoy: true, breathe: [0.02, 0.03], two: 0.3, lives: 2 }),
|
| 119 |
+
L('Thornfall', 10, [0.62, 0.7], [0.12, 0.14], { decoy: true, fade: true, pulse: [0.2, 0.4], lives: 2 }),
|
| 120 |
+
L('Full bloom', 14, [0.6, 0.72], [0.11, 0.13], { pulse: [0.2, 0.45], breathe: [0.02, 0.03], two: 0.4, fade: true, decoy: true, lives: 1, boss: true }),
|
| 121 |
+
];
|
| 122 |
+
|
| 123 |
+
const pick = (r, [a, b]) => a + r() * (b - a);
|
| 124 |
+
|
| 125 |
+
/** Flower i of level num (1-based): same shape as bloom() plus rings (one or two brims), fade and decoy. */
|
| 126 |
+
export function levelBloom(num, i) {
|
| 127 |
+
const D = LEVELS[num - 1], r = rng(`brimbloom:lv:${num}:${i}`);
|
| 128 |
+
const easeIn = Math.min(1, i / Math.max(1, D.goal - 1)); // speed climbs across the level
|
| 129 |
+
const s = D.s[0] + (D.s[1] - D.s[0]) * (0.6 * easeIn + 0.4 * r());
|
| 130 |
+
const w = pick(r, D.w);
|
| 131 |
+
const p = D.pulse && i > 0 ? pick(r, D.pulse) : 0;
|
| 132 |
+
const b = D.breathe && i > 0 ? pick(r, D.breathe) : 0;
|
| 133 |
+
const two = D.two && r() < D.two && (D.two >= 1 || i > 0);
|
| 134 |
+
let rings;
|
| 135 |
+
if (two) {
|
| 136 |
+
const c1 = 0.34 + r() * 0.1;
|
| 137 |
+
rings = [{ c: c1, w }, { c: Math.min(0.88, c1 + 0.34 + r() * 0.08), w }];
|
| 138 |
+
} else rings = [{ c: 0.5 + r() * 0.36, w }];
|
| 139 |
+
const out = { c: rings[0].c, w, s, p, b, n: 5 + Math.floor(r() * 4), hue: Math.floor(r() * 360), rings, k: 0, fade: !!D.fade };
|
| 140 |
+
if (D.decoy) {
|
| 141 |
+
// a thorn ring well clear of every real brim (inside the first brim when there is room, else outside)
|
| 142 |
+
const c0 = rings[0].c, last = rings[rings.length - 1].c;
|
| 143 |
+
const dc = c0 - 0.28 >= 0.2 ? c0 - 0.24 - r() * 0.04 : last + 0.24;
|
| 144 |
+
if (dc <= 0.98) out.decoy = { c: dc, w };
|
| 145 |
+
}
|
| 146 |
+
return out;
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
/** Stops a level asks for (two-brim flowers count twice). */
|
| 150 |
+
export function levelStops(num) {
|
| 151 |
+
let n = 0;
|
| 152 |
+
for (let i = 0; i < LEVELS[num - 1].goal; i++) n += levelBloom(num, i).rings.length;
|
| 153 |
+
return n;
|
| 154 |
+
}
|
| 155 |
+
/** Level score: 2 per perfect stop, 1 per good stop. Star thresholds [1 clear, 2, 3] in score points. */
|
| 156 |
+
export function levelThresholds(num) {
|
| 157 |
+
const max = 2 * levelStops(num);
|
| 158 |
+
return [1, Math.ceil(max * 0.7), Math.ceil(max * 0.86)];
|
| 159 |
+
}
|
| 160 |
+
/** Stars for a finished level: 0 if failed, else 1-3 by score. */
|
| 161 |
+
export function levelStars(num, score, won = true) {
|
| 162 |
+
if (!won) return 0;
|
| 163 |
+
const t = levelThresholds(num);
|
| 164 |
+
return score >= t[2] ? 3 : score >= t[1] ? 2 : 1;
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
/** A level run: same step(tap) contract as makeSim; ends with dead=true and won=true/false. */
|
| 168 |
+
export function makeLevelSim(num) {
|
| 169 |
+
const D = LEVELS[num - 1];
|
| 170 |
+
const s = {
|
| 171 |
+
level: num, goal: D.goal, lives: D.lives, i: 0, t: 0, wait: 0, hold: 0, cur: levelBloom(num, 0), r: 0, marks: [],
|
| 172 |
+
score: 0, points: 0, perfect: 0, cleared: 0, dead: false, won: false, last: '', missed: 0,
|
| 173 |
+
busy() { return this.wait > 0 || this.hold > 0; },
|
| 174 |
+
step(tap) {
|
| 175 |
+
if (this.dead) return '';
|
| 176 |
+
if (this.wait > 0) { if (--this.wait === 0) { this.i++; this.cur = levelBloom(num, this.i); this.t = 0; this.r = 0; } return ''; }
|
| 177 |
+
if (this.hold > 0) { this.hold--; return ''; }
|
| 178 |
+
this.t++;
|
| 179 |
+
const nr = radius(this.cur, this.t);
|
| 180 |
+
this.r = nr > this.r ? nr : this.r;
|
| 181 |
+
let res = '';
|
| 182 |
+
if (tap) res = judge(this.cur, this.r, this.t);
|
| 183 |
+
else if (this.r > LIMIT || this.r > this.cur.c + this.cur.w / 2 + 0.12 && this.cur.k < this.cur.rings.length - 1) res = 'miss';
|
| 184 |
+
if (!res) return '';
|
| 185 |
+
this.marks.push(res); this.last = res;
|
| 186 |
+
if (res === 'miss') {
|
| 187 |
+
this.missed++; this.lives--;
|
| 188 |
+
if (this.lives <= 0) { this.dead = true; return res; }
|
| 189 |
+
this.wait = GAP + 6;
|
| 190 |
+
return res;
|
| 191 |
+
}
|
| 192 |
+
this.score++; this.points += res === 'perfect' ? 2 : 1; if (res === 'perfect') this.perfect++;
|
| 193 |
+
const b = this.cur;
|
| 194 |
+
if (b.k < b.rings.length - 1) { b.k++; b.c = b.rings[b.k].c; b.w = b.rings[b.k].w; this.hold = HOLD; return res; }
|
| 195 |
+
this.cleared++;
|
| 196 |
+
if (this.cleared >= this.goal) { this.dead = true; this.won = true; return res; }
|
| 197 |
+
this.wait = GAP;
|
| 198 |
+
return res;
|
| 199 |
+
},
|
| 200 |
+
};
|
| 201 |
+
return s;
|
| 202 |
+
}
|
funpass.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
// CyberMax Fun Pass: ONE optional supporter pass for every CyberMax game (copied into each game's web/ folder).
|
| 2 |
// Free games stay free. The pass removes the supporter nudges and unlocks extra packs, the full archive and themes.
|
| 3 |
-
// Monthly $2.99 · Yearly $19 ·
|
| 4 |
// Buy: counted redirect https://data.cybermaxtools.com/buy/st-fun-pass-<plan>?s=game-<id> -> Stripe checkout.
|
| 5 |
// After checkout Stripe sends the buyer to data.cybermaxtools.com/unlock/fun-pass?session_id=..., which checks
|
| 6 |
// the Checkout Session (and, for a subscription, that it is still active) and returns to /games/ with #cmx-pro=<code>.
|
|
@@ -15,7 +15,7 @@ const RECHECK = 3 * 864e5; // subscriptions are re-checked every 3 days; a cance
|
|
| 15 |
export const PLANS = [
|
| 16 |
{ id: 'monthly', label: 'Monthly', price: '$2.99', per: '/month', note: 'Cancel any time' },
|
| 17 |
{ id: 'yearly', label: 'Yearly', price: '$19', per: '/year', note: 'Save 47%', best: true },
|
| 18 |
-
{ id: 'unlock', label: '
|
| 19 |
];
|
| 20 |
export const COMMON_PERKS = ['No supporter nudges, ever', 'Extra puzzle packs in every game', 'The full archive of past dailies', 'Custom colour themes'];
|
| 21 |
|
|
|
|
| 1 |
// CyberMax Fun Pass: ONE optional supporter pass for every CyberMax game (copied into each game's web/ folder).
|
| 2 |
// Free games stay free. The pass removes the supporter nudges and unlocks extra packs, the full archive and themes.
|
| 3 |
+
// Monthly $2.99 · Yearly $19 · Unlock all $4.99 once (Stripe Payment Links, pack revenue/packs/stripe/fun-pass).
|
| 4 |
// Buy: counted redirect https://data.cybermaxtools.com/buy/st-fun-pass-<plan>?s=game-<id> -> Stripe checkout.
|
| 5 |
// After checkout Stripe sends the buyer to data.cybermaxtools.com/unlock/fun-pass?session_id=..., which checks
|
| 6 |
// the Checkout Session (and, for a subscription, that it is still active) and returns to /games/ with #cmx-pro=<code>.
|
|
|
|
| 15 |
export const PLANS = [
|
| 16 |
{ id: 'monthly', label: 'Monthly', price: '$2.99', per: '/month', note: 'Cancel any time' },
|
| 17 |
{ id: 'yearly', label: 'Yearly', price: '$19', per: '/year', note: 'Save 47%', best: true },
|
| 18 |
+
{ id: 'unlock', label: 'Unlock all', price: '$4.99', per: ' once', note: 'Pay once, keep forever' },
|
| 19 |
];
|
| 20 |
export const COMMON_PERKS = ['No supporter nudges, ever', 'Extra puzzle packs in every game', 'The full archive of past dailies', 'Custom colour themes'];
|
| 21 |
|
game.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
| 1 |
-
// Brimbloom: game screen (fixed-tick loop, canvas, sound, daily/practice/archive, share grid,
|
| 2 |
-
|
| 3 |
-
import {
|
|
|
|
|
|
|
| 4 |
import * as skins from './skins.js';
|
| 5 |
import * as pass from './funpass.js';
|
| 6 |
|
| 7 |
const GAME = 'brimbloom';
|
| 8 |
const PATH = '/games/brimbloom/';
|
| 9 |
-
const GUMROAD = 'https://
|
| 10 |
const $ = (id) => document.getElementById(id);
|
| 11 |
const cv = $('c');
|
| 12 |
const g = cv.getContext('2d');
|
|
@@ -25,44 +27,56 @@ const SKINS = {
|
|
| 25 |
const PACK = Object.values(SKINS).filter((s) => s.tier === 'pack');
|
| 26 |
const todayIso = new Date().toISOString().slice(0, 10); // UTC day: the same blooms for everyone on Earth
|
| 27 |
const todayNum = dayNumber(todayIso);
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
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])),
|
| 30 |
perks: [`${PACK.length} flowers with their own pond and petals: ${PACK.map((s) => s.name).join(', ')}`, 'Every skin also on your share card'] });
|
| 31 |
-
pass.init({ game: GAME, name: 'Brimbloom', perks: ['Every past daily garden in the archive', 'Night and Linen themes'] });
|
| 32 |
const canUse = (s) => s.tier === 'free' || (s.tier === 'pass' && pass.isPro()) || (s.tier === 'pack' && skins.owned());
|
| 33 |
let skin = SKINS[store('brimbloom:skin')] && canUse(SKINS[store('brimbloom:skin')]) ? store('brimbloom:skin') : 'pond';
|
| 34 |
let muted = !!store('brimbloom:mute');
|
|
|
|
|
|
|
|
|
|
| 35 |
let mode = { kind: 'daily', date: todayIso };
|
| 36 |
-
let sim, state = 'ready', pending = false, acc = 0, last = 0, deadAt = 0, flash = 0,
|
|
|
|
| 37 |
const install = pwa(GAME, $('install'));
|
| 38 |
themeToggle($('theme'));
|
| 39 |
|
| 40 |
-
|
| 41 |
-
let ac;
|
| 42 |
-
function blip(f, d = 0.06, type = 'sine', vol = 0.08) {
|
| 43 |
-
if (muted) return;
|
| 44 |
-
try {
|
| 45 |
-
ac = ac || new (window.AudioContext || window.webkitAudioContext)();
|
| 46 |
-
const o = ac.createOscillator(), v = ac.createGain();
|
| 47 |
-
o.type = type; o.frequency.setValueAtTime(f, ac.currentTime);
|
| 48 |
-
o.frequency.exponentialRampToValueAtTime(f * (type === 'sawtooth' ? 0.3 : 1.5), ac.currentTime + d);
|
| 49 |
-
v.gain.setValueAtTime(vol, ac.currentTime); v.gain.exponentialRampToValueAtTime(0.0001, ac.currentTime + d);
|
| 50 |
-
o.connect(v); v.connect(ac.destination); o.start(); o.stop(ac.currentTime + d + 0.02);
|
| 51 |
-
} catch { /* no audio */ }
|
| 52 |
-
}
|
| 53 |
$('mute').textContent = muted ? '🔇' : '🔊';
|
| 54 |
-
$('mute').onclick = () =>
|
| 55 |
|
| 56 |
-
// ------------------------------------------------------------------ modes
|
| 57 |
const seedOf = (m) => (m.kind === 'practice' ? m.seed : m.date);
|
| 58 |
const dayKey = (m) => `brimbloom:day:${m.date}`;
|
| 59 |
const dayStats = (m) => store(dayKey(m)) || { best: 0, tries: 0, marks: [] };
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
function renderModes() {
|
| 61 |
const el = $('modes'); el.innerHTML = '';
|
|
|
|
| 62 |
const items = [
|
| 63 |
-
{ t: `Daily #${todayNum}`, on: mode.kind === 'daily' && mode.date === todayIso, go: () =>
|
| 64 |
-
{ t: 'Practice', on: mode.kind === 'practice', go: () =>
|
| 65 |
{ t: 'Archive', on: mode.kind === 'daily' && mode.date !== todayIso, lock: !pass.isPro(), go: archive },
|
|
|
|
|
|
|
| 66 |
];
|
| 67 |
for (const it of items) {
|
| 68 |
const b = document.createElement('button');
|
|
@@ -76,44 +90,132 @@ function archive() {
|
|
| 76 |
const n = todayNum - 1;
|
| 77 |
if (n < 1) { toast('The archive fills up day by day; the first archived garden arrives tomorrow.', 4000); return; }
|
| 78 |
const k = Number(prompt(`Play a past daily garden: enter a number from 1 to ${n}`, String(n)));
|
| 79 |
-
if (k >= 1 && k <= n)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
}
|
| 81 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
mode = m;
|
| 83 |
-
$('num').textContent = m.kind === 'practice' ? 'practice' : `#${dayNumber(m.date)}`;
|
| 84 |
-
$('over').hidden = true;
|
| 85 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
}
|
| 87 |
function hud() {
|
| 88 |
-
|
| 89 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
}
|
| 91 |
|
| 92 |
// ------------------------------------------------------------------ loop
|
| 93 |
-
function reset() {
|
|
|
|
|
|
|
|
|
|
| 94 |
function start() {
|
| 95 |
if (mode.kind === 'practice') mode.seed = `p${Date.now()}`;
|
| 96 |
reset(); state = 'run'; last = performance.now();
|
| 97 |
-
count(GAME, 'play', mode.kind === 'daily' ? (mode.date === todayIso ? 'daily' : 'archive') : 'practice');
|
| 98 |
$('over').hidden = true;
|
|
|
|
| 99 |
}
|
|
|
|
|
|
|
| 100 |
function input() {
|
| 101 |
-
if (state === 'ready') { start(); return; }
|
| 102 |
if (state === 'run') { pending = true; return; }
|
| 103 |
-
if (state === 'dead' && performance.now() - deadAt >
|
| 104 |
}
|
|
|
|
|
|
|
| 105 |
function die() {
|
| 106 |
-
state = 'dead'; deadAt = performance.now();
|
| 107 |
-
lastRun = { score: sim.score, perfect: sim.perfect, marks: sim.marks.slice() };
|
| 108 |
-
|
| 109 |
-
if (
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
if (sim.
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
if (sim.score >= 5) install.offer();
|
| 118 |
}
|
| 119 |
function frame(now) {
|
|
@@ -123,15 +225,23 @@ function frame(now) {
|
|
| 123 |
let n = 0;
|
| 124 |
while (acc >= 1 / HZ && n++ < 8) {
|
| 125 |
acc -= 1 / HZ;
|
| 126 |
-
const
|
| 127 |
-
const
|
| 128 |
-
|
| 129 |
-
|
| 130 |
if (sim.dead) { die(); break; }
|
| 131 |
}
|
| 132 |
} else last = now;
|
| 133 |
draw(now);
|
| 134 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
|
| 136 |
// ------------------------------------------------------------------ drawing
|
| 137 |
const TAU = Math.PI * 2;
|
|
@@ -155,62 +265,130 @@ function flower(ctx, x, y, R, n, S, hue, spinA) {
|
|
| 155 |
ctx.fillStyle = S.core; ctx.beginPath(); ctx.arc(0, 0, Math.max(5, R * 0.16), 0, TAU); ctx.fill();
|
| 156 |
ctx.restore();
|
| 157 |
}
|
| 158 |
-
function ring(ctx, x, y, Rmax, c, w, S) {
|
| 159 |
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();
|
| 160 |
-
ctx.fillStyle = S.sweet; ctx.beginPath(); ctx.arc(x, y, (c + w * 0.25) * Rmax, 0, TAU); ctx.arc(x, y, (c - w * 0.25) * Rmax, 0, TAU, true); ctx.fill();
|
| 161 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
for (const r of [c - w / 2, c + w / 2]) { ctx.beginPath(); ctx.arc(x, y, r * Rmax, 0, TAU); ctx.stroke(); }
|
| 163 |
-
ctx.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
}
|
| 165 |
function draw(now) {
|
| 166 |
const S = SKINS[skin], Wc = cv.width, cx = Wc / 2, cy = Wc / 2 + 20, Rmax = Wc * 0.43;
|
| 167 |
g.save();
|
| 168 |
-
if (
|
| 169 |
g.fillStyle = S.bg; g.fillRect(-20, -20, Wc + 40, Wc + 40);
|
| 170 |
-
// ripples
|
| 171 |
g.strokeStyle = S.band; g.lineWidth = 2;
|
| 172 |
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(); }
|
| 173 |
g.globalAlpha = 1;
|
| 174 |
-
const b = sim.cur;
|
| 175 |
-
|
| 176 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
flower(g, cx, cy, R * (1 + burst * 0.06), b.n, S, b.hue, now / 4000);
|
| 178 |
burst *= 0.88;
|
| 179 |
// marks row (this run)
|
| 180 |
const marks = sim.marks.slice(-14);
|
| 181 |
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(); });
|
| 182 |
g.fillStyle = S.text; g.textAlign = 'center'; g.textBaseline = 'middle';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
if (state === 'ready') {
|
| 184 |
-
g.font = '900 60px system-ui, sans-serif'; g.fillText('TAP', cx,
|
| 185 |
-
g.font = '700 24px system-ui, sans-serif'; g.globalAlpha = 0.85; g.fillText('then tap again when the
|
| 186 |
} else {
|
| 187 |
-
g.font = '900
|
| 188 |
-
|
| 189 |
-
if (state === '
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 190 |
}
|
| 191 |
-
if (flash > 0) { g.fillStyle = `rgba(255,77,77,${flash * 0.3})`; g.fillRect(
|
| 192 |
g.restore();
|
| 193 |
}
|
| 194 |
|
| 195 |
// ------------------------------------------------------------------ results, share, card
|
| 196 |
function result() {
|
| 197 |
if (mode.kind === 'daily') { const s = dayStats(mode); return { best: s.best, perfect: s.perfect || 0, marks: s.marks || [], tries: s.tries }; }
|
| 198 |
-
return { best: lastRun ? lastRun.score : 0, perfect: lastRun ? lastRun.perfect : 0, marks: lastRun ? lastRun.marks : [] };
|
| 199 |
}
|
| 200 |
function shareText() {
|
| 201 |
const r = result();
|
|
|
|
|
|
|
| 202 |
const head = mode.kind === 'practice' ? 'Brimbloom practice 🌸' : `Brimbloom #${dayNumber(mode.date)} 🌸`;
|
| 203 |
-
return `${head} ${r.best} blooms, ${r.perfect} perfect${r.tries ? ` (${r.tries} tries)` : ''}\n${grid(r.marks)}\nCan you stop on the brim?`;
|
| 204 |
}
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 209 |
$('over-grid').textContent = grid(lastRun.marks);
|
| 210 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 211 |
$('sx').href = it.x; $('sb').href = it.bsky; $('sw').href = it.whatsapp;
|
| 212 |
$('over').hidden = false;
|
| 213 |
-
|
|
|
|
|
|
|
| 214 |
}
|
| 215 |
function card() {
|
| 216 |
const S = SKINS[skin], c = document.createElement('canvas');
|
|
@@ -219,28 +397,47 @@ function card() {
|
|
| 219 |
x.fillStyle = S.bg; x.fillRect(0, 0, 1080, 1350);
|
| 220 |
ring(x, 540, 700, 330, 0.7, 0.16, S);
|
| 221 |
flower(x, 540, 700, 0.7 * 330, 7, S, 330, 0.2);
|
| 222 |
-
const r = result();
|
| 223 |
x.fillStyle = S.text; x.textAlign = 'left';
|
| 224 |
x.font = '900 66px system-ui, sans-serif'; x.fillText('BRIMBLOOM', 80, 130);
|
| 225 |
x.globalAlpha = 0.85; x.font = '700 40px system-ui, sans-serif';
|
| 226 |
-
x.fillText(mode.kind === 'daily' ? `Daily garden #${dayNumber(mode.date)} · ${mode.date}` : 'Practice garden', 80, 190);
|
| 227 |
-
x.globalAlpha = 1; x.textAlign = 'right'; x.font = '900 120px system-ui, sans-serif'; x.fillText(String(r.best), 1000, 150);
|
| 228 |
-
x.font = '700 34px system-ui, sans-serif'; x.fillText(`blooms · ${r.perfect} perfect`, 1000, 195);
|
| 229 |
x.textAlign = 'left';
|
| 230 |
const ms = r.marks.slice(0, 30);
|
| 231 |
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); });
|
| 232 |
-
x.fillStyle = S.text; x.font = '800 48px system-ui, sans-serif'; x.fillText('Can you stop on the brim?', 80, 1200);
|
| 233 |
x.globalAlpha = 0.75; x.font = '600 32px system-ui, sans-serif'; x.fillText('Play free: cybermaxtools.com/games/brimbloom', 80, 1290);
|
| 234 |
return c;
|
| 235 |
}
|
| 236 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 237 |
['sx', 'sb', 'sw'].forEach((id) => $(id).addEventListener('click', () => count(GAME, 'share', { sx: 'x', sb: 'bsky', sw: 'whatsapp' }[id])));
|
| 238 |
-
$('card').onclick = async () => { download(await canvasBlob(card()),
|
|
|
|
|
|
|
| 239 |
|
| 240 |
// ------------------------------------------------------------------ input, skins, start
|
| 241 |
cv.addEventListener('pointerdown', (e) => { e.preventDefault(); input(); });
|
| 242 |
cv.addEventListener('keydown', (e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); input(); } });
|
| 243 |
-
document.addEventListener('keydown', (e) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 244 |
$('pass-btn').onclick = () => pass.open('header');
|
| 245 |
$('skins-btn').onclick = () => skins.open('header');
|
| 246 |
document.addEventListener('click', (e) => {
|
|
@@ -265,12 +462,13 @@ function renderSkins() {
|
|
| 265 |
}
|
| 266 |
$('skins-btn').textContent = skins.owned() ? '🌸 Skins' : '🌸 Skin Pack $1.99';
|
| 267 |
}
|
| 268 |
-
const refresh = () => { if (!canUse(SKINS[skin])) { skin = 'pond'; store('brimbloom:skin', 'pond'); } renderSkins(); renderModes(); };
|
| 269 |
pass.onChange(refresh);
|
| 270 |
skins.onChange(refresh);
|
| 271 |
|
| 272 |
// Screenshot helper for scripts/games/render_assets.mjs: a mid-run frame on today's garden (no effect on normal play).
|
| 273 |
window.__cmxShot = async (stage = 1) => {
|
|
|
|
| 274 |
start();
|
| 275 |
const s = makeSim(todayIso);
|
| 276 |
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'); }
|
|
@@ -280,17 +478,20 @@ window.__cmxShot = async (stage = 1) => {
|
|
| 280 |
if (stage === 2) { s.marks.push('miss'); s.dead = true; die(); }
|
| 281 |
};
|
| 282 |
|
| 283 |
-
// Demo recorder helper (scripts/studio/demo_video.mjs): real taps (pointer events) on perfect moments for
|
| 284 |
-
// then one early tap so
|
| 285 |
window.__cmxDemo = (n = 6) => {
|
| 286 |
const tap = () => cv.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, cancelable: true }));
|
| 287 |
if (state !== 'run') tap();
|
| 288 |
const iv = setInterval(() => {
|
| 289 |
if (state === 'dead') { clearInterval(iv); return; }
|
| 290 |
-
if (state !== 'run' || sim.wait > 0 || pending) return;
|
| 291 |
const t = sim.t + 1;
|
| 292 |
if (sim.score >= n ? sim.t > 12 : judge(sim.cur, Math.max(sim.r, radius(sim.cur, t)), t) === 'perfect') tap();
|
| 293 |
}, 4);
|
| 294 |
};
|
| 295 |
-
|
|
|
|
|
|
|
|
|
|
| 296 |
requestAnimationFrame(frame);
|
|
|
|
| 1 |
+
// Brimbloom: game screen (fixed-tick loop, canvas, sound, 30 levels, daily/practice/archive, streak, share grid,
|
| 2 |
+
// beat-my-score links, Fun Pass, Skin Pack). (c) 2026 CyberMax.
|
| 3 |
+
import { makeSim, grid, ringC, judge, radius, HZ, LIMIT, LEVELS, makeLevelSim, levelStars, levelThresholds } from './core.js';
|
| 4 |
+
import * as lv from './levels.js';
|
| 5 |
+
import { dayNumber, dateOf, count, store, toast, intents, download, canvasBlob, pwa, themeToggle } from './cmx.js';
|
| 6 |
import * as skins from './skins.js';
|
| 7 |
import * as pass from './funpass.js';
|
| 8 |
|
| 9 |
const GAME = 'brimbloom';
|
| 10 |
const PATH = '/games/brimbloom/';
|
| 11 |
+
const GUMROAD = 'https://cybermaxtools.gumroad.com/l/cybermax-brimbloom-skins';
|
| 12 |
const $ = (id) => document.getElementById(id);
|
| 13 |
const cv = $('c');
|
| 14 |
const g = cv.getContext('2d');
|
|
|
|
| 27 |
const PACK = Object.values(SKINS).filter((s) => s.tier === 'pack');
|
| 28 |
const todayIso = new Date().toISOString().slice(0, 10); // UTC day: the same blooms for everyone on Earth
|
| 29 |
const todayNum = dayNumber(todayIso);
|
| 30 |
+
const NL = LEVELS.length;
|
| 31 |
+
const MAP = LEVELS.map((l, i) => ({ n: i + 1, name: l.name, twist: l.twist, boss: l.boss }));
|
| 32 |
+
const WORLDS = ['Spring pond', 'Summer pond', 'Night garden (Fun Pass)'];
|
| 33 |
|
| 34 |
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])),
|
| 35 |
perks: [`${PACK.length} flowers with their own pond and petals: ${PACK.map((s) => s.name).join(', ')}`, 'Every skin also on your share card'] });
|
| 36 |
+
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'] });
|
| 37 |
const canUse = (s) => s.tier === 'free' || (s.tier === 'pass' && pass.isPro()) || (s.tier === 'pack' && skins.owned());
|
| 38 |
let skin = SKINS[store('brimbloom:skin')] && canUse(SKINS[store('brimbloom:skin')]) ? store('brimbloom:skin') : 'pond';
|
| 39 |
let muted = !!store('brimbloom:mute');
|
| 40 |
+
const sfx = lv.sfx(() => muted);
|
| 41 |
+
const prog = lv.progress(GAME, { total: NL, free: 20, isPro: () => pass.isPro() });
|
| 42 |
+
const chal = lv.readChallenge(); // ?seed=L7&score=12 or ?seed=2026-10-08&score=9 ("beat my score")
|
| 43 |
let mode = { kind: 'daily', date: todayIso };
|
| 44 |
+
let sim, state = 'ready', pending = false, acc = 0, last = 0, deadAt = 0, flash = 0, shakeAmt = 0, burst = 0, lastRun = null, pop = null;
|
| 45 |
+
lv.css();
|
| 46 |
const install = pwa(GAME, $('install'));
|
| 47 |
themeToggle($('theme'));
|
| 48 |
|
| 49 |
+
function setMute(v) { muted = v; store('brimbloom:mute', muted); $('mute').textContent = muted ? '🔇' : '🔊'; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
$('mute').textContent = muted ? '🔇' : '🔊';
|
| 51 |
+
$('mute').onclick = () => setMute(!muted);
|
| 52 |
|
| 53 |
+
// ------------------------------------------------------------------ modes, stats, streak
|
| 54 |
const seedOf = (m) => (m.kind === 'practice' ? m.seed : m.date);
|
| 55 |
const dayKey = (m) => `brimbloom:day:${m.date}`;
|
| 56 |
const dayStats = (m) => store(dayKey(m)) || { best: 0, tries: 0, marks: [] };
|
| 57 |
+
const played = (n) => { const s = n >= 1 && store(`brimbloom:day:${dateOf(n)}`); return !!(s && s.tries > 0); };
|
| 58 |
+
/** Days in a row with at least one daily run (counting from today, or from yesterday while today is unplayed). */
|
| 59 |
+
function streakNow() {
|
| 60 |
+
let d = played(todayNum) ? todayNum : todayNum - 1, n = 0;
|
| 61 |
+
while (played(d)) { n++; d--; }
|
| 62 |
+
return n;
|
| 63 |
+
}
|
| 64 |
+
function anyDaily() {
|
| 65 |
+
try { for (let i = 0; i < localStorage.length; i++) if (localStorage.key(i).startsWith('brimbloom:day:')) return true; } catch { /* */ }
|
| 66 |
+
return false;
|
| 67 |
+
}
|
| 68 |
+
const isLv = () => mode.kind === 'level';
|
| 69 |
+
const LD = () => LEVELS[mode.level - 1];
|
| 70 |
+
|
| 71 |
function renderModes() {
|
| 72 |
const el = $('modes'); el.innerHTML = '';
|
| 73 |
+
const st = streakNow();
|
| 74 |
const items = [
|
| 75 |
+
{ t: `Daily #${todayNum}${st ? ` 🔥${st}` : ''}`, on: mode.kind === 'daily' && mode.date === todayIso, go: () => load({ kind: 'daily', date: todayIso }, true) },
|
| 76 |
+
{ t: 'Practice', on: mode.kind === 'practice', go: () => load({ kind: 'practice', seed: `p${Date.now()}` }, true) },
|
| 77 |
{ t: 'Archive', on: mode.kind === 'daily' && mode.date !== todayIso, lock: !pass.isPro(), go: archive },
|
| 78 |
+
// after Practice/Archive so their positions stay where the shared latchkite/brimbloom e2e expects them
|
| 79 |
+
{ t: `Levels ${prog.cleared()}/${NL} ⭐${prog.totalStars()}`, on: isLv(), go: showMap },
|
| 80 |
];
|
| 81 |
for (const it of items) {
|
| 82 |
const b = document.createElement('button');
|
|
|
|
| 90 |
const n = todayNum - 1;
|
| 91 |
if (n < 1) { toast('The archive fills up day by day; the first archived garden arrives tomorrow.', 4000); return; }
|
| 92 |
const k = Number(prompt(`Play a past daily garden: enter a number from 1 to ${n}`, String(n)));
|
| 93 |
+
if (k >= 1 && k <= n) load({ kind: 'daily', date: dateOf(k) }, true);
|
| 94 |
+
}
|
| 95 |
+
function showMap() {
|
| 96 |
+
const el = $('levels');
|
| 97 |
+
if (!el.hidden) { el.hidden = true; return; }
|
| 98 |
+
lv.renderMap($('lvmap'), prog, MAP, {
|
| 99 |
+
current: isLv() ? mode.level : 0, worlds: WORLDS,
|
| 100 |
+
onPick: (k) => { load({ kind: 'level', level: k }, true); cv.scrollIntoView({ behavior: 'smooth', block: 'center' }); },
|
| 101 |
+
onLocked: (k, why) => (why === 'pass' ? pass.open('level') : toast(`Clear level ${prog.reached()} to open level ${k}.`)),
|
| 102 |
+
});
|
| 103 |
+
el.hidden = false;
|
| 104 |
+
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
| 105 |
}
|
| 106 |
+
|
| 107 |
+
function fromUrl() {
|
| 108 |
+
const q = new URLSearchParams(location.search);
|
| 109 |
+
if (chal && chal.level >= 1 && chal.level <= NL) return { kind: 'level', level: chal.level, challenge: true };
|
| 110 |
+
if (chal && chal.date) return { kind: 'daily', date: chal.date <= todayIso && (pass.isPro() || chal.date === todayIso) ? chal.date : todayIso };
|
| 111 |
+
const l = Number(q.get('lv'));
|
| 112 |
+
if (l >= 1 && l <= NL) return { kind: 'level', level: l };
|
| 113 |
+
// first visit: level 1 with the 2-second tutorial; returning players land on today's daily garden
|
| 114 |
+
if (!anyDaily() && !prog.cleared()) return { kind: 'level', level: 1 };
|
| 115 |
+
return { kind: 'daily', date: todayIso };
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
function load(m, push = false) {
|
| 119 |
+
if (m.kind === 'level') {
|
| 120 |
+
const lk = prog.lock(m.level, { challenge: !!m.challenge });
|
| 121 |
+
if (lk === 'progress') { toast(`Clear level ${prog.reached()} first.`); return false; }
|
| 122 |
+
if (lk) { pass.open('level'); return false; }
|
| 123 |
+
prog.open(m.level);
|
| 124 |
+
}
|
| 125 |
mode = m;
|
| 126 |
+
$('num').textContent = isLv() ? `Level ${m.level}` : m.kind === 'practice' ? 'practice' : `#${dayNumber(m.date)}`;
|
| 127 |
+
$('over').hidden = true; $('levels').hidden = true;
|
| 128 |
+
if (push) {
|
| 129 |
+
const u = new URL(location.href);
|
| 130 |
+
u.search = isLv() ? `?lv=${m.level}` : '';
|
| 131 |
+
history.replaceState(null, '', u.pathname + u.search);
|
| 132 |
+
}
|
| 133 |
+
renderModes(); reset(); hud(); goal(); banner();
|
| 134 |
+
if (isLv() && m.level === 1 && !prog.stars(1)) setTimeout(() => lv.tutorial(GAME, cv, { fx: 0.5, fy: 0.62 }), 250);
|
| 135 |
+
if (isLv() && LD().boss) sfx.boss();
|
| 136 |
+
return true;
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
/** The goal line: what this run asks for, with live progress. */
|
| 140 |
+
function goal() {
|
| 141 |
+
const bits = [];
|
| 142 |
+
if (isLv()) {
|
| 143 |
+
const D = LD(), t = levelThresholds(mode.level);
|
| 144 |
+
if (D.boss) bits.push('👑 Challenge level');
|
| 145 |
+
bits.push(`🎯 Bloom <b id="gp">${sim.cleared}/${D.goal}</b> flowers`);
|
| 146 |
+
bits.push(`⭐⭐⭐ at <b>${t[2]}</b> pts`);
|
| 147 |
+
if (D.twist) bits.push(`<span class="new-twist">NEW: ${D.twist}</span>`);
|
| 148 |
+
} else {
|
| 149 |
+
bits.push(`🎯 Bloom as many as you can, one miss ends the run · <b id="gp">${sim.score}</b>`);
|
| 150 |
+
if (mode.kind === 'daily' && mode.date === todayIso && streakNow()) bits.push(`🔥 ${streakNow()}-day streak`);
|
| 151 |
+
}
|
| 152 |
+
$('goal').innerHTML = `<span>${bits.join(' · ')}</span>`;
|
| 153 |
+
liveGoal();
|
| 154 |
+
}
|
| 155 |
+
function liveGoal() {
|
| 156 |
+
const gp = $('gp'); if (gp) gp.textContent = isLv() ? `${sim.cleared}/${sim.goal}` : sim.score;
|
| 157 |
+
}
|
| 158 |
+
function banner() {
|
| 159 |
+
const el = $('challenge');
|
| 160 |
+
const same = chal && chal.score !== null && ((chal.level && isLv() && mode.level === chal.level) || (chal.date && mode.kind === 'daily' && mode.date === chal.date));
|
| 161 |
+
el.hidden = !same;
|
| 162 |
+
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>`;
|
| 163 |
}
|
| 164 |
function hud() {
|
| 165 |
+
if (isLv()) {
|
| 166 |
+
const b = prog.best(mode.level), st = prog.stars(mode.level);
|
| 167 |
+
$('stat').innerHTML = `${LD().name} · best <b>${b ?? '–'}</b> pts ${lv.starText(st)}`;
|
| 168 |
+
} else if (mode.kind === 'daily') {
|
| 169 |
+
const s = dayStats(mode);
|
| 170 |
+
$('stat').innerHTML = `Today's best <b id="best">${s.best}</b> · tries <b id="tries">${s.tries || 0}</b>`;
|
| 171 |
+
} else $('stat').innerHTML = `Practice best <b>${store('brimbloom:practice-best') || 0}</b>`;
|
| 172 |
}
|
| 173 |
|
| 174 |
// ------------------------------------------------------------------ loop
|
| 175 |
+
function reset() {
|
| 176 |
+
sim = isLv() ? makeLevelSim(mode.level) : makeSim(seedOf(mode));
|
| 177 |
+
state = 'ready'; pending = false; acc = 0; flash = 0; shakeAmt = 0; burst = 0; pop = null;
|
| 178 |
+
}
|
| 179 |
function start() {
|
| 180 |
if (mode.kind === 'practice') mode.seed = `p${Date.now()}`;
|
| 181 |
reset(); state = 'run'; last = performance.now();
|
| 182 |
+
count(GAME, 'play', isLv() ? `level-${mode.level}` : mode.kind === 'daily' ? (mode.date === todayIso ? 'daily' : 'archive') : 'practice');
|
| 183 |
$('over').hidden = true;
|
| 184 |
+
goal();
|
| 185 |
}
|
| 186 |
+
/** Instant restart (R key, Retry button, or a tap after the run): straight into a fresh run. */
|
| 187 |
+
function restart() { sfx.tap(); start(); }
|
| 188 |
function input() {
|
| 189 |
+
if (state === 'ready') { sfx.tap(); start(); return; }
|
| 190 |
if (state === 'run') { pending = true; return; }
|
| 191 |
+
if (state === 'dead' && performance.now() - deadAt > (sim.won ? 600 : 250)) restart();
|
| 192 |
}
|
| 193 |
+
const flowerY = () => (cv.width / 2 + 20) / cv.width;
|
| 194 |
+
function petalCols() { const S = SKINS[skin]; return S.petals === 'hue' ? ['#FF5A5A', '#FFD84D', '#5CF2FF', '#C37CFF'] : [...S.petals, S.core, '#FFFFFF']; }
|
| 195 |
function die() {
|
| 196 |
+
state = 'dead'; deadAt = performance.now();
|
| 197 |
+
lastRun = { score: sim.score, perfect: sim.perfect, marks: sim.marks.slice(), points: sim.points, won: !!sim.won, cleared: sim.cleared };
|
| 198 |
+
let newBest = false, res = null;
|
| 199 |
+
if (isLv()) {
|
| 200 |
+
const st = levelStars(mode.level, sim.points, sim.won);
|
| 201 |
+
res = prog.record(mode.level, st, sim.points);
|
| 202 |
+
res.got = st;
|
| 203 |
+
if (sim.won) {
|
| 204 |
+
sfx.win(); for (let k = 0; k < st; k++) sfx.star(k + 1);
|
| 205 |
+
lv.burstAt(cv, 0.5, flowerY(), { n: 70, speed: 10, colors: petalCols() });
|
| 206 |
+
count(GAME, 'win', `level-${mode.level}`);
|
| 207 |
+
} else { flash = 1; shakeAmt = 12; sfx.lose(); lv.shake(cv, 10, 320); }
|
| 208 |
+
newBest = res.improved;
|
| 209 |
+
} else {
|
| 210 |
+
flash = 1; shakeAmt = 12; sfx.hit(); lv.shake(cv, 10, 320);
|
| 211 |
+
if (navigator.vibrate) try { navigator.vibrate([30, 40, 30]); } catch { /* */ }
|
| 212 |
+
if (mode.kind === 'daily') {
|
| 213 |
+
const s = dayStats(mode); s.tries = (s.tries || 0) + 1;
|
| 214 |
+
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; }
|
| 215 |
+
store(dayKey(mode), s);
|
| 216 |
+
} else if (sim.score > (store('brimbloom:practice-best') || 0)) { store('brimbloom:practice-best', sim.score); newBest = true; }
|
| 217 |
+
}
|
| 218 |
+
renderModes(); hud(); goal(); showOver(newBest, res);
|
| 219 |
if (sim.score >= 5) install.offer();
|
| 220 |
}
|
| 221 |
function frame(now) {
|
|
|
|
| 225 |
let n = 0;
|
| 226 |
while (acc >= 1 / HZ && n++ < 8) {
|
| 227 |
acc -= 1 / HZ;
|
| 228 |
+
const busy = sim.busy ? sim.busy() : sim.wait > 0;
|
| 229 |
+
const tap = pending && !busy; pending = false;
|
| 230 |
+
const rAt = sim.r, ev = sim.step(tap);
|
| 231 |
+
if (ev) onEvent(ev, rAt);
|
| 232 |
if (sim.dead) { die(); break; }
|
| 233 |
}
|
| 234 |
} else last = now;
|
| 235 |
draw(now);
|
| 236 |
}
|
| 237 |
+
function onEvent(ev, rAt) {
|
| 238 |
+
const k = sim.score;
|
| 239 |
+
const fy = flowerY() - Math.min(rAt, 1) * 0.43 * 0.7;
|
| 240 |
+
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' }; }
|
| 241 |
+
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' }; }
|
| 242 |
+
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' }; }
|
| 243 |
+
liveGoal();
|
| 244 |
+
}
|
| 245 |
|
| 246 |
// ------------------------------------------------------------------ drawing
|
| 247 |
const TAU = Math.PI * 2;
|
|
|
|
| 265 |
ctx.fillStyle = S.core; ctx.beginPath(); ctx.arc(0, 0, Math.max(5, R * 0.16), 0, TAU); ctx.fill();
|
| 266 |
ctx.restore();
|
| 267 |
}
|
| 268 |
+
function ring(ctx, x, y, Rmax, c, w, S, lit = false) {
|
| 269 |
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();
|
| 270 |
+
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();
|
| 271 |
+
const a0 = ctx.globalAlpha;
|
| 272 |
+
ctx.strokeStyle = S.edge; ctx.lineWidth = lit ? 6 : 3; ctx.globalAlpha = a0 * (lit ? 1 : 0.7);
|
| 273 |
+
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(); }
|
| 274 |
+
ctx.globalAlpha = a0;
|
| 275 |
+
}
|
| 276 |
+
/** A thorn ring (decoy): a red dashed band that looks like a brim but never counts. */
|
| 277 |
+
function thorns(ctx, x, y, Rmax, c, w) {
|
| 278 |
+
ctx.save();
|
| 279 |
+
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();
|
| 280 |
+
ctx.strokeStyle = '#FF6B6B'; ctx.lineWidth = 3; ctx.setLineDash([10, 9]); ctx.globalAlpha = 0.85;
|
| 281 |
for (const r of [c - w / 2, c + w / 2]) { ctx.beginPath(); ctx.arc(x, y, r * Rmax, 0, TAU); ctx.stroke(); }
|
| 282 |
+
ctx.restore();
|
| 283 |
+
}
|
| 284 |
+
function hearts(n, of, x, y) {
|
| 285 |
+
g.font = '26px system-ui, "Apple Color Emoji", "Segoe UI Emoji", sans-serif'; g.textAlign = 'left';
|
| 286 |
+
for (let i = 0; i < of; i++) { g.globalAlpha = i < n ? 1 : 0.3; g.fillText(i < n ? '❤️' : '🖤', x + i * 32, y); }
|
| 287 |
+
g.globalAlpha = 1;
|
| 288 |
}
|
| 289 |
function draw(now) {
|
| 290 |
const S = SKINS[skin], Wc = cv.width, cx = Wc / 2, cy = Wc / 2 + 20, Rmax = Wc * 0.43;
|
| 291 |
g.save();
|
| 292 |
+
if (shakeAmt > 0) { g.translate((Math.random() - 0.5) * shakeAmt, (Math.random() - 0.5) * shakeAmt); shakeAmt *= 0.85; if (shakeAmt < 0.5) shakeAmt = 0; }
|
| 293 |
g.fillStyle = S.bg; g.fillRect(-20, -20, Wc + 40, Wc + 40);
|
|
|
|
| 294 |
g.strokeStyle = S.band; g.lineWidth = 2;
|
| 295 |
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(); }
|
| 296 |
g.globalAlpha = 1;
|
| 297 |
+
const b = sim.cur, rNow = state === 'ready' ? 0 : Math.min(sim.r, LIMIT);
|
| 298 |
+
if (b.decoy) thorns(g, cx, cy, Rmax, b.decoy.c, b.decoy.w);
|
| 299 |
+
if (b.rings && b.rings.length > 1) {
|
| 300 |
+
b.rings.forEach((rg, k) => {
|
| 301 |
+
if (k === b.k) return;
|
| 302 |
+
g.globalAlpha = k < b.k ? 0.25 : 0.55; ring(g, cx, cy, Rmax, rg.c, rg.w, S); g.globalAlpha = 1;
|
| 303 |
+
});
|
| 304 |
+
}
|
| 305 |
+
const c0 = ringC(b, sim.t), inside = state === 'run' && Math.abs(rNow - c0) <= b.w / 2;
|
| 306 |
+
g.globalAlpha = b.fade && state !== 'ready' ? Math.max(0.06, Math.min(1, (c0 - rNow - 0.06) / 0.2)) : 1;
|
| 307 |
+
ring(g, cx, cy, Rmax, c0, b.w, S, inside);
|
| 308 |
+
g.globalAlpha = 1;
|
| 309 |
+
const R = (state === 'ready' ? 0.18 + Math.sin(now / 300) * 0.02 : rNow) * Rmax;
|
| 310 |
flower(g, cx, cy, R * (1 + burst * 0.06), b.n, S, b.hue, now / 4000);
|
| 311 |
burst *= 0.88;
|
| 312 |
// marks row (this run)
|
| 313 |
const marks = sim.marks.slice(-14);
|
| 314 |
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(); });
|
| 315 |
g.fillStyle = S.text; g.textAlign = 'center'; g.textBaseline = 'middle';
|
| 316 |
+
if (isLv()) {
|
| 317 |
+
// goal progress: one dot per flower, hearts top-left, level top-right
|
| 318 |
+
const D = LD(), n = D.goal, w = Math.min(34, 420 / n), x0 = cx - (n * w) / 2;
|
| 319 |
+
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(); }
|
| 320 |
+
g.fillStyle = S.text; hearts(sim.lives, D.lives, 22, 34);
|
| 321 |
+
g.textAlign = 'right'; g.font = '800 24px system-ui, sans-serif'; g.fillText(`${D.boss ? '👑 ' : ''}Lv ${mode.level}`, Wc - 22, 34); g.textAlign = 'center';
|
| 322 |
+
}
|
| 323 |
if (state === 'ready') {
|
| 324 |
+
g.font = '900 60px system-ui, sans-serif'; g.fillText('TAP', cx, 60);
|
| 325 |
+
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;
|
| 326 |
} else {
|
| 327 |
+
g.font = '900 64px system-ui, sans-serif'; g.fillText(isLv() ? `${sim.cleared}/${sim.goal}` : String(sim.score), cx, 64);
|
| 328 |
+
const age = pop ? now - pop.t : 1e9;
|
| 329 |
+
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; }
|
| 330 |
+
if (state === 'dead') {
|
| 331 |
+
g.fillStyle = 'rgba(0,0,0,.5)'; g.fillRect(-20, cy - 70, Wc + 40, 140);
|
| 332 |
+
g.fillStyle = '#FFFFFF'; g.font = '900 44px system-ui, sans-serif';
|
| 333 |
+
const won = isLv() && sim.won;
|
| 334 |
+
g.fillText(won ? `LEVEL ${mode.level} CLEAR` : isLv() ? 'OUT OF HEARTS' : sim.r > LIMIT ? 'IT OVERFLOWED' : 'OFF THE BRIM', cx, cy - 22);
|
| 335 |
+
g.font = '700 26px system-ui, sans-serif';
|
| 336 |
+
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);
|
| 337 |
+
}
|
| 338 |
}
|
| 339 |
+
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; }
|
| 340 |
g.restore();
|
| 341 |
}
|
| 342 |
|
| 343 |
// ------------------------------------------------------------------ results, share, card
|
| 344 |
function result() {
|
| 345 |
if (mode.kind === 'daily') { const s = dayStats(mode); return { best: s.best, perfect: s.perfect || 0, marks: s.marks || [], tries: s.tries }; }
|
| 346 |
+
return { best: lastRun ? (isLv() ? lastRun.points : lastRun.score) : 0, perfect: lastRun ? lastRun.perfect : 0, marks: lastRun ? lastRun.marks : [] };
|
| 347 |
}
|
| 348 |
function shareText() {
|
| 349 |
const r = result();
|
| 350 |
+
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}?`;
|
| 351 |
+
const st = mode.kind === 'daily' && mode.date === todayIso && streakNow() > 1 ? ` · 🔥${streakNow()}` : '';
|
| 352 |
const head = mode.kind === 'practice' ? 'Brimbloom practice 🌸' : `Brimbloom #${dayNumber(mode.date)} 🌸`;
|
| 353 |
+
return `${head} ${r.best} blooms, ${r.perfect} perfect${r.tries ? ` (${r.tries} tries)` : ''}${st}\n${grid(r.marks)}\nCan you stop on the brim?`;
|
| 354 |
}
|
| 355 |
+
/** The "beat my score" link: the same level or daily garden, with my score to beat. */
|
| 356 |
+
function beatLink() {
|
| 357 |
+
if (isLv()) return lv.challengeLink(PATH, GAME, { level: mode.level, score: lastRun ? lastRun.points : 0 });
|
| 358 |
+
if (mode.kind === 'daily') return lv.challengeLink(PATH, GAME, { date: mode.date, score: result().best });
|
| 359 |
+
return `https://cybermaxtools.com${PATH}`;
|
| 360 |
+
}
|
| 361 |
+
function showOver(newBest, res) {
|
| 362 |
+
const lvl = isLv(), won = lvl && lastRun.won;
|
| 363 |
+
const st = mode.kind === 'daily' ? dayStats(mode) : null;
|
| 364 |
+
$('win-stars').hidden = !won;
|
| 365 |
+
if (lvl) {
|
| 366 |
+
$('win-stars').innerHTML = lv.starsHtml(res.got);
|
| 367 |
+
$('over-title').textContent = won ? (res.got === 3 ? `Level ${mode.level}: perfect bloom!` : `Level ${mode.level} cleared!`) : `Out of hearts on level ${mode.level}`;
|
| 368 |
+
const t = levelThresholds(mode.level);
|
| 369 |
+
$('over-line').textContent = won
|
| 370 |
+
? `${lastRun.points} points, ${lastRun.perfect} perfect${newBest ? ' · new best' : ''}.${res.got < 3 ? ` ${t[res.got]} points for the next star.` : ''}`
|
| 371 |
+
: `${lastRun.cleared}/${LD().goal} flowers. Tap the game or press R to retry.`;
|
| 372 |
+
} else {
|
| 373 |
+
$('over-title').textContent = newBest ? `New best: ${lastRun.score}!` : `${lastRun.score} bloom${lastRun.score === 1 ? '' : 's'}, ${lastRun.perfect} perfect`;
|
| 374 |
+
$('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.';
|
| 375 |
+
}
|
| 376 |
+
if (chal && chal.score !== null && !$('challenge').hidden) {
|
| 377 |
+
const mine = lvl ? lastRun.points : lastRun.score;
|
| 378 |
+
$('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?`;
|
| 379 |
+
}
|
| 380 |
$('over-grid').textContent = grid(lastRun.marks);
|
| 381 |
+
const next = lvl && mode.level < NL ? mode.level + 1 : 0;
|
| 382 |
+
$('next-lv').hidden = !(won && next);
|
| 383 |
+
if (next) $('next-lv').textContent = prog.lock(next) === 'pass' ? `Level ${next} 🔒 Fun Pass` : `Next: level ${next} ▶`;
|
| 384 |
+
$('beat').hidden = lvl && !won;
|
| 385 |
+
$('share-row').hidden = lvl && !won;
|
| 386 |
+
const it = intents(GAME, `${shareText()}\n🎯 ${beatLink()}`, PATH);
|
| 387 |
$('sx').href = it.x; $('sb').href = it.bsky; $('sw').href = it.whatsapp;
|
| 388 |
$('over').hidden = false;
|
| 389 |
+
const value = won || newBest;
|
| 390 |
+
if (value) pass.afterWin($('over'), lvl ? 'level' : 'over');
|
| 391 |
+
const fa = $('over').querySelector('.fp-after'); if (fa) fa.hidden = !value;
|
| 392 |
}
|
| 393 |
function card() {
|
| 394 |
const S = SKINS[skin], c = document.createElement('canvas');
|
|
|
|
| 397 |
x.fillStyle = S.bg; x.fillRect(0, 0, 1080, 1350);
|
| 398 |
ring(x, 540, 700, 330, 0.7, 0.16, S);
|
| 399 |
flower(x, 540, 700, 0.7 * 330, 7, S, 330, 0.2);
|
| 400 |
+
const r = result(), lvl = isLv();
|
| 401 |
x.fillStyle = S.text; x.textAlign = 'left';
|
| 402 |
x.font = '900 66px system-ui, sans-serif'; x.fillText('BRIMBLOOM', 80, 130);
|
| 403 |
x.globalAlpha = 0.85; x.font = '700 40px system-ui, sans-serif';
|
| 404 |
+
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);
|
| 405 |
+
x.globalAlpha = 1; x.textAlign = 'right'; x.font = '900 120px system-ui, sans-serif'; x.fillText(String(r.best), 1000, lvl ? 330 : 150);
|
| 406 |
+
x.font = '700 34px system-ui, sans-serif'; x.fillText(lvl ? `points · ${r.perfect} perfect` : `blooms · ${r.perfect} perfect`, 1000, lvl ? 375 : 195);
|
| 407 |
x.textAlign = 'left';
|
| 408 |
const ms = r.marks.slice(0, 30);
|
| 409 |
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); });
|
| 410 |
+
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);
|
| 411 |
x.globalAlpha = 0.75; x.font = '600 32px system-ui, sans-serif'; x.fillText('Play free: cybermaxtools.com/games/brimbloom', 80, 1290);
|
| 412 |
return c;
|
| 413 |
}
|
| 414 |
+
const fname = () => `brimbloom-${isLv() ? `level-${mode.level}` : mode.date || 'practice'}.png`;
|
| 415 |
+
$('share').onclick = async () => {
|
| 416 |
+
const r = await lv.shareChallenge(shareText(), beatLink(), await canvasBlob(card()), fname());
|
| 417 |
+
if (r === 'copied') toast('Result and link copied. Paste it anywhere.');
|
| 418 |
+
if (r !== 'cancelled') count(GAME, 'share', r === 'shared' ? 'native' : 'copy');
|
| 419 |
+
};
|
| 420 |
+
$('beat').onclick = async () => {
|
| 421 |
+
const mine = isLv() ? lastRun.points : result().best;
|
| 422 |
+
const what = isLv() ? `${mine} points on Brimbloom level ${mode.level}` : `${mine} blooms on Brimbloom #${dayNumber(mode.date || todayIso)}`;
|
| 423 |
+
const r = await lv.shareChallenge(`I scored ${what}. Can you beat me? 🌸`, beatLink());
|
| 424 |
+
if (r === 'copied') toast('Challenge link copied. Send it to a friend!');
|
| 425 |
+
if (r !== 'cancelled') count(GAME, 'share', 'challenge');
|
| 426 |
+
};
|
| 427 |
['sx', 'sb', 'sw'].forEach((id) => $(id).addEventListener('click', () => count(GAME, 'share', { sx: 'x', sb: 'bsky', sw: 'whatsapp' }[id])));
|
| 428 |
+
$('card').onclick = async () => { download(await canvasBlob(card()), fname()); count(GAME, 'share', 'png'); };
|
| 429 |
+
$('retry').onclick = () => { restart(); cv.scrollIntoView({ behavior: 'smooth', block: 'center' }); };
|
| 430 |
+
$('next-lv').onclick = () => { if (load({ kind: 'level', level: mode.level + 1 }, true)) cv.scrollIntoView({ behavior: 'smooth', block: 'center' }); };
|
| 431 |
|
| 432 |
// ------------------------------------------------------------------ input, skins, start
|
| 433 |
cv.addEventListener('pointerdown', (e) => { e.preventDefault(); input(); });
|
| 434 |
cv.addEventListener('keydown', (e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); input(); } });
|
| 435 |
+
document.addEventListener('keydown', (e) => {
|
| 436 |
+
if (e.target && /INPUT|TEXTAREA|SELECT/.test(e.target.tagName)) return;
|
| 437 |
+
if (e.key === ' ' && document.activeElement === document.body) { e.preventDefault(); input(); }
|
| 438 |
+
else if ((e.key === 'r' || e.key === 'R') && !e.ctrlKey && !e.metaKey) { e.preventDefault(); restart(); }
|
| 439 |
+
else if ((e.key === 'm' || e.key === 'M') && !e.ctrlKey && !e.metaKey) setMute(!muted);
|
| 440 |
+
});
|
| 441 |
$('pass-btn').onclick = () => pass.open('header');
|
| 442 |
$('skins-btn').onclick = () => skins.open('header');
|
| 443 |
document.addEventListener('click', (e) => {
|
|
|
|
| 462 |
}
|
| 463 |
$('skins-btn').textContent = skins.owned() ? '🌸 Skins' : '🌸 Skin Pack $1.99';
|
| 464 |
}
|
| 465 |
+
const refresh = () => { if (!canUse(SKINS[skin])) { skin = 'pond'; store('brimbloom:skin', 'pond'); } renderSkins(); if (sim) renderModes(); };
|
| 466 |
pass.onChange(refresh);
|
| 467 |
skins.onChange(refresh);
|
| 468 |
|
| 469 |
// Screenshot helper for scripts/games/render_assets.mjs: a mid-run frame on today's garden (no effect on normal play).
|
| 470 |
window.__cmxShot = async (stage = 1) => {
|
| 471 |
+
if (mode.kind !== 'daily') load({ kind: 'daily', date: todayIso });
|
| 472 |
start();
|
| 473 |
const s = makeSim(todayIso);
|
| 474 |
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'); }
|
|
|
|
| 478 |
if (stage === 2) { s.marks.push('miss'); s.dead = true; die(); }
|
| 479 |
};
|
| 480 |
|
| 481 |
+
// Demo recorder helper (scripts/studio/demo_video.mjs) and e2e hook: real taps (pointer events) on perfect moments for
|
| 482 |
+
// n stops, then one early tap so a daily run ends on screen (a level run ends by itself when its goal is met).
|
| 483 |
window.__cmxDemo = (n = 6) => {
|
| 484 |
const tap = () => cv.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, cancelable: true }));
|
| 485 |
if (state !== 'run') tap();
|
| 486 |
const iv = setInterval(() => {
|
| 487 |
if (state === 'dead') { clearInterval(iv); return; }
|
| 488 |
+
if (state !== 'run' || (sim.busy ? sim.busy() : sim.wait > 0) || pending) return;
|
| 489 |
const t = sim.t + 1;
|
| 490 |
if (sim.score >= n ? sim.t > 12 : judge(sim.cur, Math.max(sim.r, radius(sim.cur, t)), t) === 'perfect') tap();
|
| 491 |
}, 4);
|
| 492 |
};
|
| 493 |
+
window.__cmxTest = { state: () => ({ state, kind: mode.kind, level: mode.level || 0, score: sim.score, cleared: sim.cleared || 0, lives: sim.lives, won: !!sim.won }) };
|
| 494 |
+
|
| 495 |
+
// a locked link (pass level, level not reached yet) falls back to the next open level, then today's daily
|
| 496 |
+
load(fromUrl()) || (!prog.lock(prog.reached()) && load({ kind: 'level', level: prog.reached() })) || load({ kind: 'daily', date: todayIso });
|
| 497 |
requestAnimationFrame(frame);
|
index.html
CHANGED
|
@@ -1,11 +1,11 @@
|
|
| 1 |
<!doctype html>
|
| 2 |
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
| 3 |
-
<title>Brimbloom:
|
| 4 |
-
<meta name="description" content="Brimbloom
|
| 5 |
<link rel="canonical" href="https://cybermaxtools.com/games/brimbloom/">
|
| 6 |
<meta property="og:type" content="website"><meta property="og:site_name" content="CyberMax">
|
| 7 |
-
<meta property="og:title" content="Brimbloom:
|
| 8 |
-
<meta property="og:description" content="
|
| 9 |
<meta property="og:url" content="https://cybermaxtools.com/games/brimbloom/">
|
| 10 |
<meta property="og:image" content="https://cybermaxtools.com/games/brimbloom/og.png"><meta property="og:image:width" content="1200"><meta property="og:image:height" content="630">
|
| 11 |
<meta name="twitter:card" content="summary_large_image"><meta name="twitter:image" content="https://cybermaxtools.com/games/brimbloom/og.png">
|
|
@@ -15,42 +15,61 @@
|
|
| 15 |
<meta name="apple-mobile-web-app-capable" content="yes"><meta name="apple-mobile-web-app-title" content="Brimbloom">
|
| 16 |
<link rel="stylesheet" href="fun.css"><link rel="stylesheet" href="style.css">
|
| 17 |
<script>try{var t=localStorage.getItem('cm-theme');if(t)document.documentElement.dataset.theme=t}catch(e){}</script>
|
| 18 |
-
<
|
|
|
|
|
|
|
|
|
|
| 19 |
</head><body>
|
| 20 |
<div class="wrap">
|
| 21 |
<header class="top"><a class="brand" href="https://cybermaxtools.com/games/"><svg viewBox="0 0 512 512" aria-hidden="true"><clipPath id="lg"><rect width="512" height="512" rx="116"/></clipPath><g clip-path="url(#lg)"><rect width="512" height="512" fill="#FF6A3D"/><path d="M512 250V512H250Z" fill="#D9481F"/></g><g transform="translate(86 86) scale(3.4)"><path d="M66 27 A27 27 0 1 0 66 73" fill="none" stroke="#11141B" stroke-width="17" stroke-linecap="round"/><rect x="66" y="41" width="20" height="18" rx="4" fill="#11141B"/></g></svg>CyberMax</a>
|
| 22 |
<nav><a href="https://cybermaxtools.com/games/">Games</a></nav><button class="icon" id="mute" type="button" aria-label="Sound on or off">🔊</button><button class="icon" id="theme" type="button" aria-label="Switch light or dark">◐</button></header>
|
| 23 |
<main>
|
| 24 |
<section class="game">
|
| 25 |
-
<div class="headline"><h1>Brimbloom <span id="num" class="num"></span> <span class="pro-badge">PASS</span></h1>
|
| 26 |
-
<p class="muted tag">Tap to stop the bloom inside the ring.
|
| 27 |
<div class="chips" id="modes" role="group" aria-label="Mode"></div>
|
|
|
|
|
|
|
|
|
|
| 28 |
<div class="stage"><canvas id="c" width="720" height="720" aria-label="Brimbloom game. Tap, click or press Space to stop the bloom." tabindex="0"></canvas></div>
|
| 29 |
-
<div class="row under"><span class="muted small"
|
| 30 |
<section class="panel over" id="over" hidden aria-live="polite">
|
| 31 |
<h2 id="over-title"></h2>
|
|
|
|
| 32 |
<p id="over-line" class="muted"></p>
|
| 33 |
<p id="over-grid" class="grid" aria-label="Your run"></p>
|
| 34 |
-
<div class="row"><button class="btn" id="
|
|
|
|
| 35 |
<div class="nudge"><span>New look? The <b>Skin Pack</b> adds 6 flowers with their own ponds and petals, $1.99 once.</span><button class="btn ghost sm" data-skins="over" type="button">See the skins</button></div>
|
| 36 |
</section>
|
| 37 |
<div class="row skins" id="skins" aria-label="Flower skin"></div>
|
| 38 |
</section>
|
| 39 |
|
| 40 |
-
<section class="seo">
|
|
|
|
|
|
|
| 41 |
<h2>How to play Brimbloom</h2>
|
| 42 |
-
<
|
| 43 |
-
<h3>
|
| 44 |
-
<p>
|
| 45 |
-
<
|
| 46 |
-
<
|
| 47 |
-
<
|
| 48 |
-
<
|
| 49 |
-
<
|
| 50 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
</section>
|
| 52 |
</main>
|
| 53 |
<footer>© 2026 CyberMax · <a href="https://cybermaxtools.com/games/">Games</a> · <a href="https://cybermaxtools.com/toys/">Toys</a> · <a href="https://cybermaxtools.com/store/">Store</a> · <a href="https://cybermaxtools.com/privacy">Privacy</a> · <a href="https://cybermaxtools.com/terms">Terms</a></footer>
|
| 54 |
</div>
|
| 55 |
<script type="module" src="game.js"></script>
|
| 56 |
-
<
|
|
|
|
| 1 |
<!doctype html>
|
| 2 |
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
| 3 |
+
<title>Brimbloom: Free One-Tap Timing Game Online, Daily Challenge</title>
|
| 4 |
+
<meta name="description" content="Play Brimbloom free: a one-tap timing game with 30 levels and a daily garden. Stop the bloom on the brim of the ring, earn stars, beat a friend's score.">
|
| 5 |
<link rel="canonical" href="https://cybermaxtools.com/games/brimbloom/">
|
| 6 |
<meta property="og:type" content="website"><meta property="og:site_name" content="CyberMax">
|
| 7 |
+
<meta property="og:title" content="Brimbloom: Free One-Tap Timing Game Online">
|
| 8 |
+
<meta property="og:description" content="Play Brimbloom free: a one-tap timing game with 30 levels and a daily garden. Stop the bloom on the brim of the ring, earn stars, beat a friend's score.">
|
| 9 |
<meta property="og:url" content="https://cybermaxtools.com/games/brimbloom/">
|
| 10 |
<meta property="og:image" content="https://cybermaxtools.com/games/brimbloom/og.png"><meta property="og:image:width" content="1200"><meta property="og:image:height" content="630">
|
| 11 |
<meta name="twitter:card" content="summary_large_image"><meta name="twitter:image" content="https://cybermaxtools.com/games/brimbloom/og.png">
|
|
|
|
| 15 |
<meta name="apple-mobile-web-app-capable" content="yes"><meta name="apple-mobile-web-app-title" content="Brimbloom">
|
| 16 |
<link rel="stylesheet" href="fun.css"><link rel="stylesheet" href="style.css">
|
| 17 |
<script>try{var t=localStorage.getItem('cm-theme');if(t)document.documentElement.dataset.theme=t}catch(e){}</script>
|
| 18 |
+
<meta name="twitter:title" content="Brimbloom: Free One-Tap Timing Game Online">
|
| 19 |
+
<meta name="twitter:description" content="Play Brimbloom free: a one-tap timing game with 30 levels and a daily garden. Stop the bloom on the brim of the ring, earn stars, beat a friend's score.">
|
| 20 |
+
<style>.h1kw{display:block;font-size:.42em;font-weight:600;opacity:.75;letter-spacing:.01em;margin-top:.15em}.seo ol,.seo ul{padding-left:1.2em}.seo li{margin:.25em 0}</style>
|
| 21 |
+
<script type="application/ld+json">{"@context": "https://schema.org", "@graph": [{"@type": "VideoGame", "@id": "https://cybermaxtools.com/games/brimbloom/#game", "name": "Brimbloom", "alternateName": "Timing game online free", "url": "https://cybermaxtools.com/games/brimbloom/", "image": "https://cybermaxtools.com/games/brimbloom/og.png", "description": "Brimbloom is a free one-tap timing game for your browser. A flower blooms outward from the middle of the pond; tap to stop it with the petal tips inside the ring, in the brighter middle for a perfect. 30 levels ask you to bloom a set number of flowers before your hearts run out, adding surging blooms, breathing rings, two brims, fading rings and thorn rings, with a one-heart challenge level every 10. Earn up to 3 stars, play the same daily garden as everyone else, keep a streak and send a friend a link to beat your score.", "genre": ["Arcade", "Casual", "Timing", "One-tap"], "gamePlatform": ["Web browser", "Android", "iOS", "Windows", "macOS", "ChromeOS"], "applicationCategory": "Game", "operatingSystem": "Any (web browser)", "playMode": "SinglePlayer", "inLanguage": "en", "isAccessibleForFree": true, "offers": {"@type": "Offer", "price": "0", "priceCurrency": "USD"}, "author": {"@type": "Organization", "name": "CyberMax", "url": "https://cybermaxtools.com/"}, "publisher": {"@type": "Organization", "name": "CyberMax"}, "datePublished": "2026-10-03", "keywords": "Timing game online free, free one-tap timing game, unblocked, no download, browser game", "trailer": {"@id": "https://cybermaxtools.com/games/brimbloom/#video"}}, {"@type": "FAQPage", "@id": "https://cybermaxtools.com/games/brimbloom/#faq", "mainEntity": [{"@type": "Question", "name": "Is Brimbloom free?", "acceptedAnswer": {"@type": "Answer", "text": "Yes. Levels 1-20, the daily garden and practice are free; levels 21-30 come with the CyberMax Fun Pass and skins are cosmetic extras."}}, {"@type": "Question", "name": "How many levels are there?", "acceptedAnswer": {"@type": "Answer", "text": "30, with a new twist about every 5 levels and a one-heart challenge level every 10."}}, {"@type": "Question", "name": "How do I get 3 stars?", "acceptedAnswer": {"@type": "Answer", "text": "Stop most flowers in the brighter middle of the ring: a perfect is worth 2 points, a good stop 1."}}, {"@type": "Question", "name": "Can I challenge a friend?", "acceptedAnswer": {"@type": "Answer", "text": "Yes. After a run, tap Challenge a friend to send a link to the same level or daily garden with your score."}}, {"@type": "Question", "name": "Can I play offline?", "acceptedAnswer": {"@type": "Answer", "text": "Yes, after the first visit, and you can install it like an app."}}, {"@type": "Question", "name": "Why is everyone's game the same today?", "acceptedAnswer": {"@type": "Answer", "text": "Each UTC day has one seed, so daily scores are directly comparable with friends."}}]}, {"@type": "VideoObject", "name": "Brimbloom demo: Stop the bloom on the brim", "description": "A 16-second screen recording of the live Brimbloom page: One tap stops the bloom; Inside the ring scores, centre is perfect; Share your grid, retry in one tap. Real result, no voice.", "thumbnailUrl": ["https://cybermax.cybermax-tools.workers.dev/media/demos/brimbloom/poster.jpg"], "uploadDate": "2026-10-03T15:52:14+00:00", "duration": "PT16S", "contentUrl": "https://cybermax.cybermax-tools.workers.dev/media/demos/brimbloom/demo-16x9.mp4", "embedUrl": "https://cybermaxtools.com/games/brimbloom/", "inLanguage": "en", "publisher": {"@type": "Organization", "name": "CyberMax", "logo": {"@type": "ImageObject", "url": "https://cybermax.cybermax-tools.workers.dev/store/img/favicon.webp"}}, "@id": "https://cybermaxtools.com/games/brimbloom/#video"}]}</script>
|
| 22 |
</head><body>
|
| 23 |
<div class="wrap">
|
| 24 |
<header class="top"><a class="brand" href="https://cybermaxtools.com/games/"><svg viewBox="0 0 512 512" aria-hidden="true"><clipPath id="lg"><rect width="512" height="512" rx="116"/></clipPath><g clip-path="url(#lg)"><rect width="512" height="512" fill="#FF6A3D"/><path d="M512 250V512H250Z" fill="#D9481F"/></g><g transform="translate(86 86) scale(3.4)"><path d="M66 27 A27 27 0 1 0 66 73" fill="none" stroke="#11141B" stroke-width="17" stroke-linecap="round"/><rect x="66" y="41" width="20" height="18" rx="4" fill="#11141B"/></g></svg>CyberMax</a>
|
| 25 |
<nav><a href="https://cybermaxtools.com/games/">Games</a></nav><button class="icon" id="mute" type="button" aria-label="Sound on or off">🔊</button><button class="icon" id="theme" type="button" aria-label="Switch light or dark">◐</button></header>
|
| 26 |
<main>
|
| 27 |
<section class="game">
|
| 28 |
+
<div class="headline"><h1>Brimbloom <span id="num" class="num"></span> <span class="pro-badge">PASS</span> <small class="h1kw">Free one-tap timing game</small></h1>
|
| 29 |
+
<p class="muted tag">Tap to stop the bloom inside the ring. 30 levels, plus one daily garden for everyone.</p></div>
|
| 30 |
<div class="chips" id="modes" role="group" aria-label="Mode"></div>
|
| 31 |
+
<div class="lv-banner" id="challenge" hidden></div>
|
| 32 |
+
<section class="panel lvmap" id="levels" hidden aria-label="Levels"><div id="lvmap"></div></section>
|
| 33 |
+
<p class="lv-goal" id="goal"></p>
|
| 34 |
<div class="stage"><canvas id="c" width="720" height="720" aria-label="Brimbloom game. Tap, click or press Space to stop the bloom." tabindex="0"></canvas></div>
|
| 35 |
+
<div class="row under"><span class="muted small" id="stat"></span><button class="btn ghost sm" id="install" type="button" hidden>📲 Add to Home Screen</button><button class="btn ghost sm" id="skins-btn" type="button">🌸 Skin Pack $1.99</button><button class="btn pass sm" id="pass-btn" type="button">✨ Fun Pass</button></div>
|
| 36 |
<section class="panel over" id="over" hidden aria-live="polite">
|
| 37 |
<h2 id="over-title"></h2>
|
| 38 |
+
<div class="lv-stars" id="win-stars" hidden></div>
|
| 39 |
<p id="over-line" class="muted"></p>
|
| 40 |
<p id="over-grid" class="grid" aria-label="Your run"></p>
|
| 41 |
+
<div class="row"><button class="btn" id="next-lv" type="button" hidden>Next level ▶</button><button class="btn ghost" id="retry" type="button">↻ Retry (R)</button><button class="btn ghost" id="beat" type="button">🎯 Challenge a friend</button></div>
|
| 42 |
+
<div class="row" id="share-row"><button class="btn" id="share" type="button">Share score</button><a class="btn ghost sm" id="sx" target="_blank" rel="noopener">X</a><a class="btn ghost sm" id="sb" target="_blank" rel="noopener">Bluesky</a><a class="btn ghost sm" id="sw" target="_blank" rel="noopener">WhatsApp</a><button class="btn ghost sm" id="card" type="button">Save card</button></div>
|
| 43 |
<div class="nudge"><span>New look? The <b>Skin Pack</b> adds 6 flowers with their own ponds and petals, $1.99 once.</span><button class="btn ghost sm" data-skins="over" type="button">See the skins</button></div>
|
| 44 |
</section>
|
| 45 |
<div class="row skins" id="skins" aria-label="Flower skin"></div>
|
| 46 |
</section>
|
| 47 |
|
| 48 |
+
<section class="seo" id="guide">
|
| 49 |
+
<h2>Brimbloom: timing game online free</h2>
|
| 50 |
+
<p>Brimbloom is a free one-tap timing game for your browser. A flower blooms outward from the middle of the pond; tap to stop it with the petal tips inside the ring, in the brighter middle for a perfect. 30 levels ask you to bloom a set number of flowers before your hearts run out, adding surging blooms, breathing rings, two brims, fading rings and thorn rings, with a one-heart challenge level every 10. Earn up to 3 stars, play the same daily garden as everyone else, keep a streak and send a friend a link to beat your score.</p>
|
| 51 |
<h2>How to play Brimbloom</h2>
|
| 52 |
+
<ol><li>A flower grows outward from the centre.</li><li>Tap anywhere (or press Space) to stop it.</li><li>Petal tips inside the ring score; the bright middle of the ring is a perfect.</li><li>In a level, bloom the target number of flowers before your hearts run out; a miss costs a heart.</li><li>In the daily garden, one miss ends the run. Press R to retry at once.</li></ol>
|
| 53 |
+
<h3>Controls</h3>
|
| 54 |
+
<p><b>Phone and tablet:</b> Tap anywhere to stop the bloom.</p>
|
| 55 |
+
<p><b>Desktop, laptop and Chromebook:</b> Space or click to stop the bloom. M mutes.</p>
|
| 56 |
+
<h2>Brimbloom tips and strategy</h2>
|
| 57 |
+
<ul><li>Watch the rhythm of each bloom for a moment before committing; surging blooms slow down near the end.</li><li>Aim for the inner edge of the bright band, since reaction delay carries you outward.</li><li>On breathing rings, tap when the ring is at its widest.</li><li>Play with sound on: the rising tone helps timing.</li></ul>
|
| 58 |
+
<h2>Game modes</h2>
|
| 59 |
+
<ul><li>30 levels: surging blooms, breathing rings, two brims, fading rings and thorn rings, plus a one-heart challenge level every 10. Levels 1-20 are free.</li><li>Daily garden: the same blooms for everyone today, with a streak and a shareable emoji grid.</li><li>Beat-my-score links: send a friend the same level or daily garden with your score.</li><li>Practice: a random garden every try. Fun Pass: levels 21-30, past dailies and extra themes; Skin Pack adds six flowers.</li></ul>
|
| 60 |
+
<p>Free to play with no ads and no sign-up. The optional <a href="https://cybermaxtools.com/games/#pass">CyberMax Fun Pass</a> ($2.99/month, $19/year or $29.99 for life) adds cosmetic extras and bonus modes in every CyberMax game.</p>
|
| 61 |
+
<h2>Brimbloom FAQ</h2>
|
| 62 |
+
<h3>Is Brimbloom free?</h3><p>Yes. Levels 1-20, the daily garden and practice are free; levels 21-30 come with the CyberMax Fun Pass and skins are cosmetic extras.</p>
|
| 63 |
+
<h3>How many levels are there?</h3><p>30, with a new twist about every 5 levels and a one-heart challenge level every 10.</p>
|
| 64 |
+
<h3>How do I get 3 stars?</h3><p>Stop most flowers in the brighter middle of the ring: a perfect is worth 2 points, a good stop 1.</p>
|
| 65 |
+
<h3>Can I challenge a friend?</h3><p>Yes. After a run, tap Challenge a friend to send a link to the same level or daily garden with your score.</p>
|
| 66 |
+
<h3>Can I play offline?</h3><p>Yes, after the first visit, and you can install it like an app.</p>
|
| 67 |
+
<h3>Why is everyone's game the same today?</h3><p>Each UTC day has one seed, so daily scores are directly comparable with friends.</p>
|
| 68 |
+
<h2>Similar free games</h2>
|
| 69 |
+
<div class="more"><a href="https://cybermaxtools.com/games/latchkite/"><b>Latchkite</b><span>Let go at the right moment, catch the next peg</span></a><a href="https://cybermaxtools.com/games/pathmoor/"><b>Pathmoor</b><span>Turn stones until every path glows</span></a><a href="https://cybermaxtools.com/games/whirlane/"><b>Whirlane</b><span>Hop orbits, dodge blades, top today's board</span></a><a href="https://cybermaxtools.com/games/"><b>All CyberMax games</b><span>Free browser games, no download</span></a></div>
|
| 70 |
</section>
|
| 71 |
</main>
|
| 72 |
<footer>© 2026 CyberMax · <a href="https://cybermaxtools.com/games/">Games</a> · <a href="https://cybermaxtools.com/toys/">Toys</a> · <a href="https://cybermaxtools.com/store/">Store</a> · <a href="https://cybermaxtools.com/privacy">Privacy</a> · <a href="https://cybermaxtools.com/terms">Terms</a></footer>
|
| 73 |
</div>
|
| 74 |
<script type="module" src="game.js"></script>
|
| 75 |
+
</body></html>
|
levels.js
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// CyberMax Fun kit: levels, daily challenge, streaks, "beat my score" links and game juice (copied into each game's
|
| 2 |
+
// web/ folder by scripts/games/sync_kit.py when the game has "levels": true in products/fun-pass/games.json).
|
| 3 |
+
// Everything is saved in this browser only (localStorage). No new assets: sounds are WebAudio blips, particles are
|
| 4 |
+
// drawn on one overlay canvas. Levels 1..free are free; later levels need the CyberMax Fun Pass, but a level the player
|
| 5 |
+
// has already opened is never locked again. (c) 2026 CyberMax. All rights reserved.
|
| 6 |
+
import { SITE, today, dayNumber, dateOf, store, rr } from './cmx.js';
|
| 7 |
+
|
| 8 |
+
export const TOTAL = 30;
|
| 9 |
+
export const FREE = 20;
|
| 10 |
+
|
| 11 |
+
// ------------------------------------------------------------------ progress (pure logic, testable in node)
|
| 12 |
+
|
| 13 |
+
/**
|
| 14 |
+
* Level progress for one game. Higher score is better unless `lower` is set (e.g. taps or time).
|
| 15 |
+
* opts: { total, free, isPro: () => bool, lower }
|
| 16 |
+
*/
|
| 17 |
+
export function progress(game, opts = {}) {
|
| 18 |
+
const total = opts.total || TOTAL, free = opts.free ?? FREE, isPro = opts.isPro || (() => false), lower = !!opts.lower;
|
| 19 |
+
const KEY = `${game}:levels`;
|
| 20 |
+
const read = () => {
|
| 21 |
+
const d = store(KEY) || {};
|
| 22 |
+
return { stars: d.stars || {}, best: d.best || {}, opened: d.opened || [] };
|
| 23 |
+
};
|
| 24 |
+
const api = {
|
| 25 |
+
total, free, lower,
|
| 26 |
+
data: read,
|
| 27 |
+
stars: (n) => read().stars[n] || 0,
|
| 28 |
+
best: (n) => read().best[n],
|
| 29 |
+
totalStars: () => Object.values(read().stars).reduce((a, b) => a + b, 0),
|
| 30 |
+
cleared: () => Object.keys(read().stars).filter((k) => read().stars[k] > 0).length,
|
| 31 |
+
/** The furthest level the player may open: one past the highest cleared level. */
|
| 32 |
+
reached() {
|
| 33 |
+
const s = read().stars;
|
| 34 |
+
let hi = 0;
|
| 35 |
+
for (const k of Object.keys(s)) if (s[k] > 0) hi = Math.max(hi, Number(k));
|
| 36 |
+
return Math.min(total, hi + 1);
|
| 37 |
+
},
|
| 38 |
+
/** null when playable, else 'progress' (clear the level before) or 'pass' (Fun Pass level). */
|
| 39 |
+
lock(n, { challenge = false } = {}) {
|
| 40 |
+
if (!(n >= 1 && n <= total)) return 'progress';
|
| 41 |
+
const d = read();
|
| 42 |
+
if (d.opened.includes(n) || n <= free && challenge) return null;
|
| 43 |
+
if (n > api.reached() && !challenge) return 'progress';
|
| 44 |
+
if (n > free && !isPro()) return 'pass';
|
| 45 |
+
return null;
|
| 46 |
+
},
|
| 47 |
+
/** Remember that a level was opened, so it stays open even if the pass ends later. */
|
| 48 |
+
open(n) {
|
| 49 |
+
const d = read();
|
| 50 |
+
if (!d.opened.includes(n)) { d.opened.push(n); store(KEY, d); }
|
| 51 |
+
},
|
| 52 |
+
/** Save a finished level. Returns { stars, best, improved, first, next } (next = the newly reachable level or 0). */
|
| 53 |
+
record(n, stars, score) {
|
| 54 |
+
const d = read();
|
| 55 |
+
const before = api.reached();
|
| 56 |
+
const first = !(d.stars[n] > 0);
|
| 57 |
+
const prev = d.best[n];
|
| 58 |
+
const improved = prev === undefined || (lower ? score < prev : score > prev);
|
| 59 |
+
if (improved && stars > 0) d.best[n] = score;
|
| 60 |
+
if (stars > (d.stars[n] || 0)) d.stars[n] = stars;
|
| 61 |
+
store(KEY, d);
|
| 62 |
+
const after = api.reached();
|
| 63 |
+
return { stars: d.stars[n] || 0, best: d.best[n], improved: improved && stars > 0, first: first && stars > 0, next: after > before ? after : 0 };
|
| 64 |
+
},
|
| 65 |
+
};
|
| 66 |
+
return api;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
/** Stars from a score and three thresholds [one, two, three] (for `lower`, smaller scores are better). */
|
| 70 |
+
export function starsFor(score, t, lower = false) {
|
| 71 |
+
let s = 0;
|
| 72 |
+
for (const v of t) if (lower ? score <= v : score >= v) s++;
|
| 73 |
+
return s;
|
| 74 |
+
}
|
| 75 |
+
export const starText = (n, of = 3) => '⭐'.repeat(n) + '☆'.repeat(Math.max(0, of - n));
|
| 76 |
+
|
| 77 |
+
// ------------------------------------------------------------------ daily challenge + streak
|
| 78 |
+
|
| 79 |
+
/** The daily challenge: one seed for everyone today. record() keeps the best score and the day streak. */
|
| 80 |
+
export function daily(game, { lower = false } = {}) {
|
| 81 |
+
const KEY = `${game}:daily`;
|
| 82 |
+
const iso = today();
|
| 83 |
+
const read = () => store(KEY) || { days: {}, streak: 0, bestStreak: 0 };
|
| 84 |
+
return {
|
| 85 |
+
seed: iso, num: dayNumber(iso), date: iso,
|
| 86 |
+
best: () => read().days[iso],
|
| 87 |
+
streak() {
|
| 88 |
+
const d = read();
|
| 89 |
+
return d.days[iso] !== undefined || d.days[dateOf(dayNumber(iso) - 1)] !== undefined ? d.streak || 0 : 0;
|
| 90 |
+
},
|
| 91 |
+
bestStreak: () => read().bestStreak || 0,
|
| 92 |
+
played: () => Object.keys(read().days).length,
|
| 93 |
+
record(score) {
|
| 94 |
+
const d = read();
|
| 95 |
+
const prev = d.days[iso];
|
| 96 |
+
if (prev === undefined) {
|
| 97 |
+
d.streak = d.days[dateOf(dayNumber(iso) - 1)] !== undefined ? (d.streak || 0) + 1 : 1;
|
| 98 |
+
d.bestStreak = Math.max(d.bestStreak || 0, d.streak);
|
| 99 |
+
}
|
| 100 |
+
const improved = prev === undefined || (lower ? score < prev : score > prev);
|
| 101 |
+
if (improved) d.days[iso] = score;
|
| 102 |
+
store(KEY, d);
|
| 103 |
+
return { best: d.days[iso], improved, streak: d.streak };
|
| 104 |
+
},
|
| 105 |
+
};
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
// ------------------------------------------------------------------ "beat my score" links
|
| 109 |
+
|
| 110 |
+
/** Read ?seed=&score= from the page URL. seed is a YYYY-MM-DD daily or L<n> for level n. */
|
| 111 |
+
export function readChallenge(search = typeof location !== 'undefined' ? location.search : '') {
|
| 112 |
+
const q = new URLSearchParams(search);
|
| 113 |
+
const seed = (q.get('seed') || '').trim();
|
| 114 |
+
const score = Number(q.get('score'));
|
| 115 |
+
if (!seed) return null;
|
| 116 |
+
const out = { seed, score: Number.isFinite(score) && q.get('score') !== null && q.get('score') !== '' ? score : null };
|
| 117 |
+
const lv = /^L(\d{1,3})$/i.exec(seed);
|
| 118 |
+
if (lv) out.level = Number(lv[1]);
|
| 119 |
+
else if (/^\d{4}-\d{2}-\d{2}$/.test(seed) && dayNumber(seed) >= 1 && seed <= today()) out.date = seed;
|
| 120 |
+
else return null;
|
| 121 |
+
return out;
|
| 122 |
+
}
|
| 123 |
+
/** The link a player shares: opens the same level (or daily) with their score to beat. */
|
| 124 |
+
export function challengeLink(path, game, { level, date, score }) {
|
| 125 |
+
const u = new URL(SITE + path);
|
| 126 |
+
u.searchParams.set('seed', level ? `L${level}` : date);
|
| 127 |
+
if (score !== undefined && score !== null) u.searchParams.set('score', String(score));
|
| 128 |
+
u.searchParams.set('utm_source', 'challenge');
|
| 129 |
+
u.searchParams.set('utm_medium', 'share');
|
| 130 |
+
u.searchParams.set('utm_campaign', `game-${game}`);
|
| 131 |
+
return u.toString();
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
/** Share text + link via the native sheet (with an optional PNG), else copy. Returns 'shared'|'copied'|'cancelled'. */
|
| 135 |
+
export async function shareChallenge(text, url, blob, filename) {
|
| 136 |
+
const full = `${text}\n${url}`;
|
| 137 |
+
if (typeof navigator !== 'undefined' && navigator.share) {
|
| 138 |
+
try {
|
| 139 |
+
const file = blob ? new File([blob], filename || 'score.png', { type: 'image/png' }) : null;
|
| 140 |
+
await navigator.share(file && navigator.canShare && navigator.canShare({ files: [file] }) ? { files: [file], text: full } : { text: full });
|
| 141 |
+
return 'shared';
|
| 142 |
+
} catch (e) { if (e && e.name === 'AbortError') return 'cancelled'; }
|
| 143 |
+
}
|
| 144 |
+
try { await navigator.clipboard.writeText(full); } catch { prompt('Copy your challenge link:', full); }
|
| 145 |
+
return 'copied';
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
// ------------------------------------------------------------------ juice: sound, shake, particles, tutorial
|
| 149 |
+
|
| 150 |
+
let ac = null;
|
| 151 |
+
/** WebAudio blips. muted() is checked on every sound. */
|
| 152 |
+
export function sfx(muted = () => false) {
|
| 153 |
+
const tone = (f, d = 0.07, type = 'sine', vol = 0.07, slide = 1.5, at = 0) => {
|
| 154 |
+
if (muted()) return;
|
| 155 |
+
try {
|
| 156 |
+
ac = ac || new (window.AudioContext || window.webkitAudioContext)();
|
| 157 |
+
if (ac.state === 'suspended') ac.resume();
|
| 158 |
+
const t = ac.currentTime + at, o = ac.createOscillator(), v = ac.createGain();
|
| 159 |
+
o.type = type; o.frequency.setValueAtTime(f, t); o.frequency.exponentialRampToValueAtTime(Math.max(30, f * slide), t + d);
|
| 160 |
+
v.gain.setValueAtTime(vol, t); v.gain.exponentialRampToValueAtTime(0.0001, t + d);
|
| 161 |
+
o.connect(v); v.connect(ac.destination); o.start(t); o.stop(t + d + 0.02);
|
| 162 |
+
} catch { /* no audio */ }
|
| 163 |
+
};
|
| 164 |
+
return {
|
| 165 |
+
tone,
|
| 166 |
+
tap: () => tone(660, 0.04, 'triangle', 0.05, 1.3),
|
| 167 |
+
good: (k = 0) => tone(880 + Math.min(k, 30) * 22, 0.06, 'sine', 0.06, 1.4),
|
| 168 |
+
hit: () => { tone(160, 0.3, 'sawtooth', 0.11, 0.3); tone(90, 0.25, 'square', 0.05, 0.5, 0.02); },
|
| 169 |
+
lose: () => { tone(330, 0.16, 'triangle', 0.08, 0.7); tone(220, 0.3, 'triangle', 0.08, 0.6, 0.15); },
|
| 170 |
+
star: (i = 0) => tone(990 * (1 + i * 0.26), 0.12, 'sine', 0.07, 1.25, i * 0.14),
|
| 171 |
+
win: () => [523, 659, 784, 1046].forEach((f, i) => tone(f, 0.16, 'triangle', 0.07, 1.02, i * 0.09)),
|
| 172 |
+
boss: () => [196, 233, 262].forEach((f, i) => tone(f, 0.22, 'square', 0.04, 1, i * 0.12)),
|
| 173 |
+
};
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
const reduced = () => typeof matchMedia !== 'undefined' && matchMedia('(prefers-reduced-motion: reduce)').matches;
|
| 177 |
+
|
| 178 |
+
/** Shake an element for ms (skipped when the player prefers reduced motion). */
|
| 179 |
+
export function shake(el, mag = 8, ms = 260) {
|
| 180 |
+
if (!el || !el.animate || reduced()) return;
|
| 181 |
+
const k = [];
|
| 182 |
+
for (let i = 0; i <= 6; i++) { const m = mag * (1 - i / 6); k.push({ transform: `translate(${(i % 2 ? 1 : -1) * m}px, ${(i % 3 - 1) * m * 0.5}px)` }); }
|
| 183 |
+
el.animate(k, { duration: ms, easing: 'ease-out' });
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
let fx = null, parts = [], fxRaf = 0;
|
| 187 |
+
/** Particle burst at a page point (clientX/clientY). colors = array of CSS colours. */
|
| 188 |
+
export function burst(x, y, { colors = ['#FFC857', '#FF6A3D', '#5CF2FF', '#FFFFFF'], n = 26, speed = 6, life = 700 } = {}) {
|
| 189 |
+
if (typeof document === 'undefined' || reduced()) return;
|
| 190 |
+
if (!fx) {
|
| 191 |
+
fx = document.createElement('canvas');
|
| 192 |
+
fx.className = 'cmx-fx';
|
| 193 |
+
fx.setAttribute('aria-hidden', 'true');
|
| 194 |
+
document.body.appendChild(fx);
|
| 195 |
+
}
|
| 196 |
+
const dpr = Math.min(2, window.devicePixelRatio || 1);
|
| 197 |
+
if (fx.width !== innerWidth * dpr || fx.height !== innerHeight * dpr) { fx.width = innerWidth * dpr; fx.height = innerHeight * dpr; }
|
| 198 |
+
const t0 = performance.now();
|
| 199 |
+
for (let i = 0; i < n; i++) {
|
| 200 |
+
const a = Math.random() * Math.PI * 2, v = speed * (0.4 + Math.random());
|
| 201 |
+
parts.push({ x: x * dpr, y: y * dpr, vx: Math.cos(a) * v * dpr, vy: Math.sin(a) * v * dpr - 2 * dpr, c: colors[i % colors.length], t0, life: life * (0.6 + Math.random() * 0.6), r: (2 + Math.random() * 3.5) * dpr });
|
| 202 |
+
}
|
| 203 |
+
if (!fxRaf) fxRaf = requestAnimationFrame(fxStep);
|
| 204 |
+
}
|
| 205 |
+
function fxStep(now) {
|
| 206 |
+
const c = fx.getContext('2d');
|
| 207 |
+
c.clearRect(0, 0, fx.width, fx.height);
|
| 208 |
+
parts = parts.filter((p) => now - p.t0 < p.life);
|
| 209 |
+
for (const p of parts) {
|
| 210 |
+
p.x += p.vx; p.y += p.vy; p.vy += 0.25; p.vx *= 0.98;
|
| 211 |
+
c.globalAlpha = 1 - (now - p.t0) / p.life;
|
| 212 |
+
c.fillStyle = p.c; c.fillRect(p.x - p.r / 2, p.y - p.r / 2, p.r, p.r);
|
| 213 |
+
}
|
| 214 |
+
c.globalAlpha = 1;
|
| 215 |
+
fxRaf = parts.length ? requestAnimationFrame(fxStep) : 0;
|
| 216 |
+
}
|
| 217 |
+
/** Burst from the centre of an element (or a point inside it given as 0..1 fractions). */
|
| 218 |
+
export function burstAt(el, fx = 0.5, fy = 0.5, opts) {
|
| 219 |
+
if (!el) return;
|
| 220 |
+
const r = el.getBoundingClientRect();
|
| 221 |
+
burst(r.left + r.width * fx, r.top + r.height * fy, opts);
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
/**
|
| 225 |
+
* The 2-second no-text tutorial: a pulsing hand over a spot of `el` (fractions fx, fy). gesture: 'tap' (default),
|
| 226 |
+
* 'hold' or 'swipe'. Shown once per game (key `<game>:tut`) unless force is set; any tap hides it.
|
| 227 |
+
*/
|
| 228 |
+
export function tutorial(game, el, { fx = 0.5, fy = 0.6, gesture = 'tap', force = false, ms = 2200 } = {}) {
|
| 229 |
+
if (!el || (!force && store(`${game}:tut`))) return;
|
| 230 |
+
store(`${game}:tut`, 1);
|
| 231 |
+
const host = el.parentElement;
|
| 232 |
+
if (getComputedStyle(host).position === 'static') host.style.position = 'relative';
|
| 233 |
+
const h = document.createElement('div');
|
| 234 |
+
h.className = `cmx-tut cmx-tut-${gesture}`;
|
| 235 |
+
h.setAttribute('aria-hidden', 'true');
|
| 236 |
+
h.innerHTML = '<i></i><b>👆</b>';
|
| 237 |
+
const r = el.getBoundingClientRect(), hr = host.getBoundingClientRect();
|
| 238 |
+
h.style.left = `${r.left - hr.left + r.width * fx}px`;
|
| 239 |
+
h.style.top = `${r.top - hr.top + r.height * fy}px`;
|
| 240 |
+
host.appendChild(h);
|
| 241 |
+
const off = () => { h.remove(); window.removeEventListener('pointerdown', off, true); };
|
| 242 |
+
setTimeout(off, ms);
|
| 243 |
+
window.addEventListener('pointerdown', off, true);
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
// ------------------------------------------------------------------ level-select map
|
| 247 |
+
|
| 248 |
+
/**
|
| 249 |
+
* Render the level map into `host`. levels = [{ n, name, twist?, boss? }] (twist = short text for the first level
|
| 250 |
+
* of a new mechanic). onPick(n) runs for a playable level; onLocked(n, reason) for a locked one.
|
| 251 |
+
*/
|
| 252 |
+
export function renderMap(host, prog, levels, { current = 0, onPick, onLocked, worlds = [] } = {}) {
|
| 253 |
+
css();
|
| 254 |
+
const d = prog.data();
|
| 255 |
+
const reached = prog.reached();
|
| 256 |
+
let html = `<div class="lv-head"><b>${prog.cleared()}/${prog.total}</b> levels · <b>${prog.totalStars()}/${prog.total * 3}</b> ⭐</div>`;
|
| 257 |
+
for (let w = 0; w < Math.ceil(levels.length / 10); w++) {
|
| 258 |
+
const set = levels.slice(w * 10, w * 10 + 10);
|
| 259 |
+
const twists = set.filter((l) => l.twist).map((l) => `<span>Lv ${l.n}: ${esc(l.twist)}</span>`).join('');
|
| 260 |
+
html += `<section class="lv-world"><h3>${esc(worlds[w] || `World ${w + 1}`)}</h3>${twists ? `<p class="lv-twists">${twists}</p>` : ''}<ol class="lv-grid">`;
|
| 261 |
+
set.forEach((l, i) => {
|
| 262 |
+
const lock = prog.lock(l.n);
|
| 263 |
+
const passLv = !!lock && prog.lock(l.n, { challenge: true }) === 'pass'; // Fun Pass level: show the lock even before it is reached
|
| 264 |
+
const st = d.stars[l.n] || 0;
|
| 265 |
+
const cls = ['lv', l.boss ? 'boss' : '', lock ? `locked ${lock}` : '', st ? 'done' : '', l.n === reached && !st ? 'next' : '', l.n === current ? 'cur' : '', Math.floor(i / 5) % 2 ? 'rev' : ''].filter(Boolean).join(' ');
|
| 266 |
+
const label = `Level ${l.n}${l.name ? `: ${l.name}` : ''}${l.boss ? ' (challenge level)' : ''}${passLv ? ', Fun Pass' : lock ? ', locked' : st ? `, ${st} of 3 stars` : ''}`;
|
| 267 |
+
html += `<li><button type="button" class="${cls}" data-lv="${l.n}" aria-label="${esc(label)}" title="${esc(label)}">${l.boss ? '<i class="crown">👑</i>' : ''}<span class="n">${passLv ? '🔒' : l.n}</span><span class="st">${lock === 'progress' ? '' : starText(st).replace(/☆/g, '<s>☆</s>')}</span>${l.twist ? '<i class="new">NEW</i>' : ''}</button></li>`;
|
| 268 |
+
});
|
| 269 |
+
html += '</ol></section>';
|
| 270 |
+
}
|
| 271 |
+
if (prog.free < prog.total) html += `<p class="lv-note">Levels 1-${prog.free} are free. Levels ${prog.free + 1}-${prog.total} come with the CyberMax Fun Pass; a level you have opened always stays open.</p>`;
|
| 272 |
+
host.innerHTML = html;
|
| 273 |
+
host.querySelectorAll('[data-lv]').forEach((b) => b.addEventListener('click', () => {
|
| 274 |
+
const n = Number(b.dataset.lv), lock = prog.lock(n);
|
| 275 |
+
if (lock) onLocked && onLocked(n, lock); else onPick && onPick(n);
|
| 276 |
+
}));
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
| 280 |
+
|
| 281 |
+
// ------------------------------------------------------------------ score card (1080×1350 PNG for stories)
|
| 282 |
+
|
| 283 |
+
/** A share card: { title, sub, big, unit, line, stars, bg, fg, accent, foot } -> canvas. draw(ctx) adds game art. */
|
| 284 |
+
export function scoreCard(o, draw) {
|
| 285 |
+
const c = document.createElement('canvas');
|
| 286 |
+
c.width = 1080; c.height = 1350;
|
| 287 |
+
const x = c.getContext('2d');
|
| 288 |
+
x.fillStyle = o.bg || '#11141B'; x.fillRect(0, 0, 1080, 1350);
|
| 289 |
+
if (draw) { x.save(); draw(x); x.restore(); }
|
| 290 |
+
x.textAlign = 'left'; x.fillStyle = o.accent || '#FF6A3D'; x.font = '900 64px system-ui, sans-serif'; x.fillText(String(o.title || '').toUpperCase(), 80, 130);
|
| 291 |
+
x.fillStyle = o.fg || '#fff'; x.globalAlpha = 0.85; x.font = '700 40px system-ui, sans-serif'; x.fillText(o.sub || '', 80, 190); x.globalAlpha = 1;
|
| 292 |
+
x.fillStyle = 'rgba(0,0,0,.35)'; rr(x, 60, 930, 960, 300, 36); x.fill();
|
| 293 |
+
x.textAlign = 'center'; x.fillStyle = o.fg || '#fff';
|
| 294 |
+
x.font = '900 150px system-ui, sans-serif'; x.fillText(String(o.big ?? ''), 540, 1080);
|
| 295 |
+
x.font = '700 44px system-ui, sans-serif'; x.fillText(o.unit || '', 540, 1140);
|
| 296 |
+
if (o.stars !== undefined) { x.font = '64px system-ui, "Apple Color Emoji", "Segoe UI Emoji", sans-serif'; x.fillText(starText(o.stars), 540, 1205); }
|
| 297 |
+
x.fillStyle = o.accent || '#FF6A3D'; x.font = '800 46px system-ui, sans-serif'; x.fillText(o.line || 'Can you beat it?', 540, 880);
|
| 298 |
+
x.fillStyle = o.fg || '#fff'; x.globalAlpha = 0.75; x.font = '600 32px system-ui, sans-serif'; x.fillText(o.foot || 'cybermaxtools.com/games', 540, 1300);
|
| 299 |
+
return c;
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
// ------------------------------------------------------------------ styles (injected once; no extra file)
|
| 303 |
+
|
| 304 |
+
let cssDone = false;
|
| 305 |
+
export function css() {
|
| 306 |
+
if (cssDone || typeof document === 'undefined') return;
|
| 307 |
+
cssDone = true;
|
| 308 |
+
const s = document.createElement('style');
|
| 309 |
+
s.textContent = `.cmx-fx{position:fixed;inset:0;width:100vw;height:100vh;pointer-events:none;z-index:60}
|
| 310 |
+
.lv-head{font-size:15px;color:var(--muted);margin:4px 0 8px}.lv-head b{color:var(--ink)}
|
| 311 |
+
.lv-world h3{margin:14px 0 4px;font-size:16px}.lv-twists{margin:0 0 8px;font-size:13px;color:var(--muted);display:flex;flex-wrap:wrap;gap:4px 12px}
|
| 312 |
+
.lv-grid{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(5,1fr);gap:8px}
|
| 313 |
+
.lv{position:relative;width:100%;aspect-ratio:1;max-width:72px;margin:0 auto;display:flex;flex-direction:column;align-items:center;justify-content:center;border-radius:50%;border:2.5px solid var(--line);background:var(--card);color:var(--ink);font:inherit;font-weight:900;font-size:18px;cursor:pointer;touch-action:manipulation;padding:0}
|
| 314 |
+
.lv .n{line-height:1}.lv .st{font-size:9px;letter-spacing:-1px;line-height:1.2;min-height:11px}.lv .st s{text-decoration:none;opacity:.35}
|
| 315 |
+
.lv.done{border-color:var(--ok)}.lv.boss{border-radius:18px;border-width:3px}.lv.boss.done{border-color:#E0A400}
|
| 316 |
+
.lv.next{border-color:var(--acc);box-shadow:0 0 0 4px color-mix(in srgb,var(--acc) 25%,transparent);animation:lvp 1.4s ease-in-out infinite}
|
| 317 |
+
.lv.cur{outline:3px solid var(--ink);outline-offset:2px}
|
| 318 |
+
.lv.locked.progress{opacity:.38;cursor:default}.lv.locked.pass{opacity:.75;border-style:dashed}
|
| 319 |
+
.lv .crown{position:absolute;top:-10px;font-style:normal;font-size:16px}.lv .new{position:absolute;bottom:-8px;font-style:normal;font-size:9px;background:var(--acc);color:var(--acc-ink);padding:1px 5px;border-radius:6px;font-weight:900}
|
| 320 |
+
.lv-note{font-size:13px;color:var(--muted);margin:14px 0 0}
|
| 321 |
+
@keyframes lvp{50%{transform:scale(1.07)}}
|
| 322 |
+
.cmx-tut{position:absolute;width:0;height:0;pointer-events:none;z-index:20}
|
| 323 |
+
.cmx-tut i{position:absolute;left:-28px;top:-28px;width:56px;height:56px;border-radius:50%;border:4px solid #fff;opacity:0;animation:tutring 1s ease-out infinite}
|
| 324 |
+
.cmx-tut b{position:absolute;left:-18px;top:-2px;font-size:44px;filter:drop-shadow(0 3px 6px rgba(0,0,0,.5));animation:tuttap 1s ease-in-out infinite}
|
| 325 |
+
.cmx-tut-hold b{animation:tuthold 1.1s ease-in-out infinite}.cmx-tut-swipe b{animation:tutswipe 1.1s ease-in-out infinite}
|
| 326 |
+
@keyframes tutring{0%{transform:scale(.4);opacity:.9}100%{transform:scale(1.5);opacity:0}}
|
| 327 |
+
@keyframes tuttap{0%,100%{transform:translateY(8px)}50%{transform:translateY(0) scale(.9)}}
|
| 328 |
+
@keyframes tuthold{0%{transform:translateY(8px)}30%,80%{transform:translateY(0) scale(.85)}100%{transform:translateY(8px)}}
|
| 329 |
+
@keyframes tutswipe{0%{transform:translateX(-40px)}100%{transform:translateX(40px)}}
|
| 330 |
+
.lv-banner{display:flex;gap:8px;align-items:center;justify-content:space-between;flex-wrap:wrap;background:var(--card);border:1.5px solid var(--acc);border-radius:14px;padding:8px 12px;margin:8px 0;font-weight:700}
|
| 331 |
+
.lv-goal{display:flex;gap:10px;align-items:center;flex-wrap:wrap;font-size:15px;margin:8px 0}.lv-goal b{font-variant-numeric:tabular-nums}
|
| 332 |
+
.lv-stars{font-size:30px;letter-spacing:4px;margin:4px 0}.lv-stars span{display:inline-block;animation:lvstar .45s cubic-bezier(.2,1.6,.4,1) both}
|
| 333 |
+
@keyframes lvstar{0%{transform:scale(0) rotate(-40deg)}100%{transform:scale(1)}}
|
| 334 |
+
@media (prefers-reduced-motion:reduce){.lv.next,.cmx-tut i,.cmx-tut b,.lv-stars span{animation:none}}`;
|
| 335 |
+
document.head.appendChild(s);
|
| 336 |
+
}
|
| 337 |
+
|
| 338 |
+
/** Animated star row HTML for result panels. */
|
| 339 |
+
export function starsHtml(n) {
|
| 340 |
+
return [0, 1, 2].map((i) => `<span style="animation-delay:${i * 0.14}s${i < n ? '' : ';opacity:.3'}">${i < n ? '⭐' : '☆'}</span>`).join('');
|
| 341 |
+
}
|
style.css
CHANGED
|
@@ -12,3 +12,8 @@
|
|
| 12 |
.sp-row{display:flex;gap:6px;margin:0 0 10px}.sp-dot{width:26px;height:26px;border-radius:50%;display:inline-block;border:2px solid var(--line)}
|
| 13 |
.fp-row{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
|
| 14 |
.grid{font-size:22px;letter-spacing:2px;margin:4px 0 10px;white-space:pre-line;line-height:1.3}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
.sp-row{display:flex;gap:6px;margin:0 0 10px}.sp-dot{width:26px;height:26px;border-radius:50%;display:inline-block;border:2px solid var(--line)}
|
| 13 |
.fp-row{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
|
| 14 |
.grid{font-size:22px;letter-spacing:2px;margin:4px 0 10px;white-space:pre-line;line-height:1.3}
|
| 15 |
+
.over .row{margin-top:8px}.lvmap{margin:10px 0}
|
| 16 |
+
.lv-goal{min-height:24px;justify-content:center;text-align:center}.new-twist{color:var(--acc);font-weight:800}
|
| 17 |
+
.skins{gap:6px}.skins button{width:32px;height:32px}
|
| 18 |
+
.lv-goal{display:block}
|
| 19 |
+
@media (max-width:420px){.chips .chip{padding:5px 9px;font-size:13px}}
|
sw.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
| 1 |
// CyberMax game service worker: the game works offline after the first visit. Cache-first for the game's own files,
|
| 2 |
// network-first for the page itself (so updates arrive), never caches API calls or other sites. (c) 2026 CyberMax.
|
| 3 |
const GAME = 'brimbloom';
|
| 4 |
-
const CACHE = `cmx-${GAME}-
|
| 5 |
-
const FILES = ["index.html", "cmx.js", "core.js", "fun.css", "funpass.js", "game.js", "icon-192.png", "icon-512.png", "icon-maskable.png", "icon.svg", "index.html", "manifest.webmanifest", "skins.js", "style.css"];
|
| 6 |
|
| 7 |
self.addEventListener('install', (e) => {
|
| 8 |
e.waitUntil(caches.open(CACHE).then((c) => c.addAll(FILES)).then(() => self.skipWaiting()));
|
|
|
|
| 1 |
// CyberMax game service worker: the game works offline after the first visit. Cache-first for the game's own files,
|
| 2 |
// network-first for the page itself (so updates arrive), never caches API calls or other sites. (c) 2026 CyberMax.
|
| 3 |
const GAME = 'brimbloom';
|
| 4 |
+
const CACHE = `cmx-${GAME}-510a600419`;
|
| 5 |
+
const FILES = ["index.html", "cmx.js", "core.js", "fun.css", "funpass.js", "game.js", "icon-192.png", "icon-512.png", "icon-maskable.png", "icon.svg", "index.html", "levels.js", "manifest.webmanifest", "skins.js", "style.css"];
|
| 6 |
|
| 7 |
self.addEventListener('install', (e) => {
|
| 8 |
e.waitUntil(caches.open(CACHE).then((c) => c.addAll(FILES)).then(() => self.skipWaiting()));
|