brimbloom / core.js
CyberMax-tools's picture
Deploy brimbloom
7154ad5 verified
Raw History Blame Contribute Delete
10.6 kB
// Brimbloom core: deterministic simulation (no DOM). A flower blooms outward; one tap stops it. Stop inside the ring
// (the brim) to clear the bloom; dead centre of the ring is a perfect. Miss once and the run ends. Blooms get faster,
// rings thinner, and later blooms pulse or their ring breathes. Same blooms for everyone each UTC day (seed = date).
// Fixed 60 Hz ticks. (c) 2026 CyberMax.
export const HZ = 60;
export const GAP = 24; // ticks between blooms (the result flash)
export const LIMIT = 1.06; // a bloom that grows past the frame is a miss
export function rng(seed) {
let h = 0x811c9dc5;
for (let i = 0; i < seed.length; i++) { h ^= seed.charCodeAt(i); h = Math.imul(h, 0x01000193); }
let a = h >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/** Bloom i of a seed: ring centre c and width w (radius 1 = the frame), growth speed s (radius/second),
* pulse amplitude p (the bloom surges and eases), breathe b (the ring itself drifts in and out), petals n. */
export function bloom(seed, i) {
const r = rng(`brimbloom:${seed}:${i}`);
const w = Math.max(0.075, 0.17 - i * 0.0065 - r() * 0.02);
return {
c: 0.46 + r() * 0.4, w,
s: Math.min(0.95, 0.42 + i * 0.04 + r() * 0.1),
p: i < 4 ? 0 : Math.min(0.5, 0.15 + r() * 0.35),
b: i < 7 || r() < 0.4 ? 0 : 0.02 + r() * 0.025,
n: 5 + Math.floor(r() * 4), hue: Math.floor(r() * 360),
};
}
/** Radius after t ticks. With a pulse the bloom surges and eases but never shrinks (p <= 0.5 keeps it opening). */
export function radius(b, t) {
const x = t / HZ;
return b.s * (x + b.p * Math.sin(x * 4.4) / 4.4);
}
/** The ring centre after t ticks (it breathes on later blooms). */
export const ringC = (b, t) => b.c + (b.b ? b.b * Math.sin(t / HZ * 2.2) : 0);
/** Judge a stop at radius rr against the ring: 'perfect' (middle half of the band), 'good', or 'miss'. */
export function judge(b, rr, t) {
const d = Math.abs(rr - ringC(b, t));
return d <= b.w * 0.25 ? 'perfect' : d <= b.w / 2 ? 'good' : 'miss';
}
/** A run: step(tap) once per tick; returns 'perfect' | 'good' | 'miss' | ''. */
export function makeSim(seed) {
const s = {
seed, i: 0, t: 0, wait: 0, cur: bloom(seed, 0), r: 0, marks: [], score: 0, perfect: 0, dead: false, last: '',
step(tap) {
if (this.dead) return '';
if (this.wait > 0) { if (--this.wait === 0) { this.i++; this.cur = bloom(this.seed, this.i); this.t = 0; this.r = 0; } return ''; }
this.t++;
const nr = radius(this.cur, this.t);
this.r = nr > this.r ? nr : this.r;
let res = '';
if (tap) res = judge(this.cur, this.r, this.t);
else if (this.r > LIMIT) res = 'miss';
if (!res) return '';
this.marks.push(res); this.last = res;
if (res === 'miss') { this.dead = true; return res; }
this.score++; if (res === 'perfect') this.perfect++;
this.wait = GAP;
return res;
},
};
return s;
}
/** Emoji row for the share text: 🟩 perfect, 🟨 good, 🟥 the miss that ended the run (rows of 10). */
export function grid(marks) {
const e = marks.map((m) => (m === 'perfect' ? '🟩' : m === 'good' ? '🟨' : '🟥'));
const rows = [];
for (let i = 0; i < e.length; i += 10) rows.push(e.slice(i, i + 10).join(''));
return rows.join('\n');
}
// ------------------------------------------------------------------ 30 levels (own seed space: the daily is untouched)
// Each level is a finite goal: bloom `goal` flowers before you run out of `lives` (a miss costs a life and the next
// flower comes). Speeds s and widths w are [min, max] ranges; pulse = surge amplitude range, breathe = ring drift,
// two = chance a flower has two brims (stop it twice), fade = the ring fades as the bloom nears it, decoy = a false
// ring in a warning colour. Bosses (every 10th) give one life. (c) 2026 CyberMax.
export const HOLD = 14; // ticks a two-brim flower rests after its first stop
const L = (name, goal, s, w, o = {}) => ({ name, goal, s, w, lives: o.lives ?? 3, ...o });
export const LEVELS = [
L('First bud', 5, [0.36, 0.4], [0.2, 0.22]),
L('Petal pond', 6, [0.4, 0.46], [0.18, 0.2]),
L('Quickening', 7, [0.45, 0.52], [0.16, 0.18]),
L('Narrow brim', 8, [0.48, 0.56], [0.14, 0.16]),
L('Surge', 8, [0.46, 0.52], [0.16, 0.18], { pulse: [0.2, 0.35], twist: 'Surging blooms rush, then ease' }),
L('Rush hour', 8, [0.5, 0.58], [0.15, 0.17], { pulse: [0.25, 0.4] }),
L('Tide pool', 9, [0.54, 0.62], [0.14, 0.16], { pulse: [0.25, 0.45] }),
L('Quick petals', 10, [0.58, 0.66], [0.13, 0.15], { pulse: [0.3, 0.5], lives: 2 }),
L('Thin water', 10, [0.6, 0.68], [0.12, 0.14], { pulse: [0.3, 0.5], lives: 2 }),
L('Garden gauntlet', 12, [0.6, 0.72], [0.12, 0.14], { pulse: [0.2, 0.5], lives: 1, boss: true }),
L('Breathing ring', 8, [0.5, 0.56], [0.15, 0.17], { breathe: [0.02, 0.03], twist: 'Breathing rings drift in and out' }),
L('Low tide', 9, [0.54, 0.6], [0.14, 0.16], { breathe: [0.02, 0.035] }),
L('Swell', 9, [0.54, 0.62], [0.14, 0.16], { breathe: [0.02, 0.035], pulse: [0.2, 0.35] }),
L('Ripples', 10, [0.58, 0.66], [0.13, 0.15], { breathe: [0.025, 0.04], pulse: [0.2, 0.4], lives: 2 }),
L('Moon pond', 10, [0.62, 0.7], [0.12, 0.14], { breathe: [0.025, 0.04], pulse: [0.25, 0.45], lives: 2 }),
L('Two brims', 6, [0.46, 0.52], [0.16, 0.18], { two: 1, twist: 'Two brims: stop each flower twice' }),
L('Double take', 7, [0.5, 0.56], [0.15, 0.17], { two: 0.7 }),
L('Second wind', 8, [0.52, 0.6], [0.14, 0.16], { two: 0.6, pulse: [0.2, 0.35] }),
L('Echo bloom', 8, [0.56, 0.62], [0.13, 0.15], { two: 0.6, breathe: [0.02, 0.03], lives: 2 }),
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 }),
L('Fading brim', 8, [0.5, 0.56], [0.15, 0.17], { fade: true, twist: 'Fading rings vanish as the bloom nears' }),
L('Mist', 9, [0.54, 0.6], [0.14, 0.16], { fade: true, pulse: [0.2, 0.35] }),
L('Ghost pond', 9, [0.56, 0.62], [0.14, 0.16], { fade: true, breathe: [0.02, 0.03] }),
L('Fog bank', 10, [0.58, 0.66], [0.13, 0.15], { fade: true, two: 0.4, lives: 2 }),
L('Night bloom', 10, [0.6, 0.68], [0.12, 0.14], { fade: true, pulse: [0.25, 0.45], lives: 2 }),
L('Thorn ring', 8, [0.52, 0.58], [0.14, 0.16], { decoy: true, twist: 'Thorn rings: only the bright ring counts' }),
L('Bramble', 9, [0.56, 0.62], [0.13, 0.15], { decoy: true, pulse: [0.2, 0.4] }),
L('Briar patch', 10, [0.58, 0.66], [0.13, 0.15], { decoy: true, breathe: [0.02, 0.03], two: 0.3, lives: 2 }),
L('Thornfall', 10, [0.62, 0.7], [0.12, 0.14], { decoy: true, fade: true, pulse: [0.2, 0.4], lives: 2 }),
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 }),
];
const pick = (r, [a, b]) => a + r() * (b - a);
/** Flower i of level num (1-based): same shape as bloom() plus rings (one or two brims), fade and decoy. */
export function levelBloom(num, i) {
const D = LEVELS[num - 1], r = rng(`brimbloom:lv:${num}:${i}`);
const easeIn = Math.min(1, i / Math.max(1, D.goal - 1)); // speed climbs across the level
const s = D.s[0] + (D.s[1] - D.s[0]) * (0.6 * easeIn + 0.4 * r());
const w = pick(r, D.w);
const p = D.pulse && i > 0 ? pick(r, D.pulse) : 0;
const b = D.breathe && i > 0 ? pick(r, D.breathe) : 0;
const two = D.two && r() < D.two && (D.two >= 1 || i > 0);
let rings;
if (two) {
const c1 = 0.34 + r() * 0.1;
rings = [{ c: c1, w }, { c: Math.min(0.88, c1 + 0.34 + r() * 0.08), w }];
} else rings = [{ c: 0.5 + r() * 0.36, w }];
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 };
if (D.decoy) {
// a thorn ring well clear of every real brim (inside the first brim when there is room, else outside)
const c0 = rings[0].c, last = rings[rings.length - 1].c;
const dc = c0 - 0.28 >= 0.2 ? c0 - 0.24 - r() * 0.04 : last + 0.24;
if (dc <= 0.98) out.decoy = { c: dc, w };
}
return out;
}
/** Stops a level asks for (two-brim flowers count twice). */
export function levelStops(num) {
let n = 0;
for (let i = 0; i < LEVELS[num - 1].goal; i++) n += levelBloom(num, i).rings.length;
return n;
}
/** Level score: 2 per perfect stop, 1 per good stop. Star thresholds [1 clear, 2, 3] in score points. */
export function levelThresholds(num) {
const max = 2 * levelStops(num);
return [1, Math.ceil(max * 0.7), Math.ceil(max * 0.86)];
}
/** Stars for a finished level: 0 if failed, else 1-3 by score. */
export function levelStars(num, score, won = true) {
if (!won) return 0;
const t = levelThresholds(num);
return score >= t[2] ? 3 : score >= t[1] ? 2 : 1;
}
/** A level run: same step(tap) contract as makeSim; ends with dead=true and won=true/false. */
export function makeLevelSim(num) {
const D = LEVELS[num - 1];
const s = {
level: num, goal: D.goal, lives: D.lives, i: 0, t: 0, wait: 0, hold: 0, cur: levelBloom(num, 0), r: 0, marks: [],
score: 0, points: 0, perfect: 0, cleared: 0, dead: false, won: false, last: '', missed: 0,
busy() { return this.wait > 0 || this.hold > 0; },
step(tap) {
if (this.dead) return '';
if (this.wait > 0) { if (--this.wait === 0) { this.i++; this.cur = levelBloom(num, this.i); this.t = 0; this.r = 0; } return ''; }
if (this.hold > 0) { this.hold--; return ''; }
this.t++;
const nr = radius(this.cur, this.t);
this.r = nr > this.r ? nr : this.r;
let res = '';
if (tap) res = judge(this.cur, this.r, this.t);
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';
if (!res) return '';
this.marks.push(res); this.last = res;
if (res === 'miss') {
this.missed++; this.lives--;
if (this.lives <= 0) { this.dead = true; return res; }
this.wait = GAP + 6;
return res;
}
this.score++; this.points += res === 'perfect' ? 2 : 1; if (res === 'perfect') this.perfect++;
const b = this.cur;
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; }
this.cleared++;
if (this.cleared >= this.goal) { this.dead = true; this.won = true; return res; }
this.wait = GAP;
return res;
},
};
return s;
}