File size: 10,579 Bytes
41a71f1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7154ad5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
// 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;
}