arcadia / chain-bloom

Chain Bloom

sim.js17.1 KB
1// Chain Bloom - deterministic simulation, level generation and solver (no rendering here).
2(function (root) {
3 "use strict";
4 const W = 540, H = 960, TICK = 1 / 60, LATE = 8, HOLD = 0.42, TAP_TICK = 60;
5 const F = { x0: 46, x1: 494, y0: 190, y1: 818 };
6 const TY = {
7 n: { R: 72, dur: 0.85, r: 13 },
8 p: { R: 150, dur: 1.15, r: 16 },
9 m: { R: 88, dur: 0.9, r: 15, charge: 0.6, pullR: 210 },
10 s: { R: 54, dur: 0.7, r: 14 },
11 i: { R: 132, dur: 1.05, r: 14, delay: 0.95 },
12 b: { R: 178, dur: 1.25, r: 16 },
13 t: { r: 13 },
14 };
15 const SPARK = { R: 80, dur: 0.85 };
16 const ease = (t) => 1 - Math.pow(1 - t, 3);
17 const mult = (ch) => Math.pow(1.1, Math.max(0, ch - 1));
18
19 function rng(seed) {
20 let a = seed >>> 0;
21 return function () {
22 a = (a + 0x6d2b79f5) >>> 0;
23 let t = a;
24 t = Math.imul(t ^ (t >>> 15), t | 1);
25 t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
26 return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
27 };
28 }
29 function hash(a, b) {
30 let h = (Math.imul(a | 0, 374761393) + Math.imul(b | 0, 668265263)) >>> 0;
31 h = Math.imul(h ^ (h >>> 13), 1274126177);
32 return (h ^ (h >>> 16)) >>> 0;
33 }
34
35 // ---------- world ----------
36 function makeWorld(level) {
37 const cs = level.crystals.map((s, i) => ({
38 id: i, type: s.type, ax: s.ax, ay: s.ay, Ax: s.Ax, Ay: s.Ay, wx: s.wx, wy: s.wy, px: s.px, py: s.py,
39 hue: s.hue, rot: s.rot, ox: 0, oy: 0, x: 0, y: 0, state: 0, timer: 0, timer0: 0, depth: 0, t0: 0,
40 }));
41 const w = {
42 level, time: 0, tick: 0, crystals: cs, blooms: [], seeds: [], events: [], count: 0, chain: 0, maxChain: 0,
43 score: 0, tapsLeft: level.taps, noteIdx: 0, breaks: 0, goalHit: false, lastTapTick: -1, quiet: false,
44 };
45 place(w);
46 return w;
47 }
48 function place(w) {
49 const t = w.time, cs = w.crystals, q = w.quiet;
50 for (let i = 0; i < cs.length; i++) {
51 const c = cs[i];
52 if (q && c.state >= 2) continue;
53 c.x = c.ax + c.ox + c.Ax * Math.sin(c.wx * t + c.px);
54 c.y = c.ay + c.oy + c.Ay * Math.sin(c.wy * t + c.py);
55 }
56 }
57 function warp(w, tick) { w.tick = tick; w.time = tick * TICK; place(w); }
58 function ev(w, e) { if (!w.quiet) w.events.push(e); }
59 function addBloom(w, x, y, R, dur, depth, type) {
60 w.blooms.push({ x, y, R, dur, t: 0, depth, type, rad: 0, noTouch: false, end: dur + HOLD, wither: false });
61 }
62 function burst(w, c, d) {
63 c.state = 2; c.depth = d; c.t0 = w.time;
64 w.count++; w.chain++;
65 if (w.chain > w.maxChain) w.maxChain = w.chain;
66 const m = mult(w.chain), pts = Math.round(25 * m);
67 w.score += pts;
68 const T = TY[c.type];
69 addBloom(w, c.x, c.y, T.R, T.dur, d, c.type);
70 if (c.type === "s") {
71 for (let k = 0; k < 3; k++) {
72 const a = c.id * 2.399 + (k * Math.PI * 2) / 3, sp = 70 + 14 * k;
73 w.seeds.push({ x: c.x, y: c.y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp, t: 0, delay: 1.0 + k * 0.14, depth: d + 1 });
74 }
75 }
76 ev(w, { k: "bloom", id: c.id, x: c.x, y: c.y, type: c.type, depth: d, note: w.noteIdx, pts, chain: w.chain, mult: m });
77 w.noteIdx++;
78 if (!w.goalHit && w.level.goal && w.count >= w.level.goal) { w.goalHit = true; ev(w, { k: "goal" }); }
79 }
80 function touch(w, c, b) {
81 const d = b.depth + 1;
82 switch (c.type) {
83 case "t":
84 c.state = 3; c.t0 = w.time; w.chain = 0; w.breaks++; b.noTouch = true; b.wither = true; b.end = b.t + 0.25;
85 ev(w, { k: "thorn", id: c.id, x: c.x, y: c.y });
86 return;
87 case "b":
88 if (w.chain >= LATE) { burst(w, c, d); ev(w, { k: "boom", x: c.x, y: c.y }); }
89 else {
90 c.state = 3; c.t0 = w.time; w.chain = 0; w.breaks++; b.noTouch = true; b.wither = true; b.end = b.t + 0.25;
91 ev(w, { k: "dud", id: c.id, x: c.x, y: c.y });
92 }
93 return;
94 case "i":
95 c.state = 1; c.timer = c.timer0 = TY.i.delay; c.depth = d; c.t0 = w.time; ev(w, { k: "freeze", id: c.id, x: c.x, y: c.y });
96 return;
97 case "m":
98 c.state = 1; c.timer = c.timer0 = TY.m.charge; c.depth = d; c.t0 = w.time; ev(w, { k: "charge", id: c.id, x: c.x, y: c.y });
99 return;
100 default:
101 burst(w, c, d);
102 }
103 }
104 function step(w) {
105 const dt = TICK, cs = w.crystals;
106 w.time += dt; w.tick++;
107 place(w);
108 const bl = w.blooms;
109 for (let bi = 0; bi < bl.length; bi++) {
110 const b = bl[bi];
111 b.t += dt;
112 if (b.noTouch || b.t > b.dur + HOLD) continue;
113 b.rad = b.R * ease(Math.min(1, b.t / b.dur));
114 for (let i = 0; i < cs.length; i++) {
115 const c = cs[i];
116 if (c.state !== 0) continue;
117 const dx = c.x - b.x, dy = c.y - b.y, rr = b.rad + TY[c.type].r;
118 if (dx * dx + dy * dy <= rr * rr) { touch(w, c, b); if (b.noTouch) break; }
119 }
120 }
121 // charging / frozen crystals
122 for (let i = 0; i < cs.length; i++) {
123 const c = cs[i];
124 if (c.state !== 1) continue;
125 c.timer -= dt;
126 if (c.type === "m") {
127 for (let j = 0; j < cs.length; j++) {
128 const o = cs[j];
129 if (o.state !== 0) continue;
130 const dx = c.x - o.x, dy = c.y - o.y, d = Math.sqrt(dx * dx + dy * dy);
131 if (d < TY.m.pullR && d > 26) {
132 const mv = Math.min(d - 24, 170 * dt * (1 - (d / TY.m.pullR) * 0.5));
133 o.ox += (dx / d) * mv; o.oy += (dy / d) * mv;
134 }
135 }
136 }
137 if (c.timer <= 0) burst(w, c, c.depth);
138 }
139 // seeds
140 const sd = w.seeds;
141 for (let i = 0; i < sd.length; i++) {
142 const s = sd[i];
143 s.t += dt; s.x += s.vx * dt; s.y += s.vy * dt; s.vx *= 0.985; s.vy *= 0.985;
144 if (s.x < F.x0 - 10 || s.x > F.x1 + 10) s.vx = -s.vx;
145 if (s.y < F.y0 - 10 || s.y > F.y1 + 10) s.vy = -s.vy;
146 if (s.t >= s.delay) {
147 s.done = true; w.chain++;
148 if (w.chain > w.maxChain) w.maxChain = w.chain;
149 const m = mult(w.chain), pts = Math.round(25 * m);
150 w.score += pts;
151 addBloom(w, s.x, s.y, 60, 0.75, s.depth, "s");
152 ev(w, { k: "seed", x: s.x, y: s.y, depth: s.depth, note: w.noteIdx, pts, chain: w.chain, mult: m });
153 w.noteIdx++;
154 }
155 }
156 if (sd.length) w.seeds = sd.filter((s) => !s.done);
157 if (bl.length) w.blooms = bl.filter((b) => b.t < b.end);
158 }
159 function isSettled(w) {
160 if (w.lastTapTick < 0 || w.blooms.length || w.seeds.length) return false;
161 for (let i = 0; i < w.crystals.length; i++) if (w.crystals[i].state === 1) return false;
162 return true;
163 }
164 function tap(w, x, y) {
165 if (w.tapsLeft <= 0) return false;
166 w.tapsLeft--; w.lastTapTick = w.tick; w.chain = 0; w.noteIdx = 0;
167 addBloom(w, x, y, SPARK.R, SPARK.dur, 0, "k");
168 ev(w, { k: "spark", x, y });
169 return true;
170 }
171 function runToSettle(w, max) {
172 let n = 0;
173 max = max || 2600;
174 while (!isSettled(w) && n < max) { step(w); n++; }
175 }
176 function cloneWorld(w) {
177 const n = Object.assign({}, w);
178 n.crystals = w.crystals.map((c) => Object.assign({}, c));
179 n.blooms = w.blooms.map((b) => Object.assign({}, b));
180 n.seeds = w.seeds.map((s) => Object.assign({}, s));
181 n.events = [];
182 return n;
183 }
184
185 // ---------- solver ----------
186 function makeGrid(stepPx, ox, oy) {
187 const g = [];
188 for (let y = F.y0 - 10 + (oy || 0); y <= F.y1 + 10; y += stepPx)
189 for (let x = F.x0 - 10 + (ox || 0); x <= F.x1 + 10; x += stepPx) g.push([x, y]);
190 return g;
191 }
192 function nearCrystal(w, x, y) {
193 for (const c of w.crystals) {
194 if (c.state !== 0) continue;
195 const rr = SPARK.R + TY[c.type].r, dx = c.x - x, dy = c.y - y;
196 if (dx * dx + dy * dy <= rr * rr) return true;
197 }
198 return false;
199 }
200 function solveLevel(level) {
201 const grid = makeGrid(level.taps > 1 ? 36 : 28);
202 const base = makeWorld(level);
203 base.quiet = true;
204 warp(base, TAP_TICK);
205 let stage = [{ w: base, seq: [] }];
206 let final = [];
207 for (let k = 0; k < level.taps; k++) {
208 const res = [];
209 for (const st of stage) {
210 for (const p of grid) {
211 if (!nearCrystal(st.w, p[0], p[1])) continue;
212 const w2 = cloneWorld(st.w);
213 tap(w2, p[0], p[1]);
214 const tk = st.w.tick;
215 runToSettle(w2);
216 res.push({ count: w2.count, score: w2.score, seq: st.seq.concat([{ x: p[0], y: p[1], tick: tk }]), w: w2 });
217 }
218 }
219 for (const st of stage) res.push({ count: st.w.count, score: st.w.score, seq: st.seq, w: st.w, skip: true });
220 res.sort((a, b) => b.count - a.count || b.score - a.score);
221 if (k < level.taps - 1) {
222 const beam = [];
223 for (const r of res) {
224 const last = r.seq[r.seq.length - 1];
225 if (r.skip || beam.every((b) => !b.seq[k] || Math.hypot(b.seq[k].x - last.x, b.seq[k].y - last.y) > 44)) beam.push(r);
226 if (beam.length >= 5) break;
227 }
228 stage = beam.map((r) => { warp(r.w, r.w.tick + 30); return { w: r.w, seq: r.seq }; });
229 if (!stage.length) { final = []; break; }
230 } else final = res;
231 }
232 const counts = final.map((r) => r.count);
233 return { counts, best: counts.length ? counts[0] : 0, seq: final.length ? final[0].seq : [], all: final };
234 }
235
236 // ---------- layouts ----------
237 function tryAdd(p, x, y, minD) {
238 if (x < F.x0 || x > F.x1 || y < F.y0 || y > F.y1) return false;
239 for (let i = 0; i < p.length; i++) { const dx = p[i][0] - x, dy = p[i][1] - y; if (dx * dx + dy * dy < minD * minD) return false; }
240 p.push([x, y]);
241 return true;
242 }
243 const gauss = (r) => Math.sqrt(-2 * Math.log(r() + 1e-9)) * Math.cos(6.2832 * r());
244 function fill(r, p, n, minD) {
245 let tries = 0;
246 while (p.length < n && tries < 5000) { tries++; tryAdd(p, F.x0 + (F.x1 - F.x0) * r(), F.y0 + (F.y1 - F.y0) * r(), minD); }
247 return p.slice(0, n);
248 }
249 const LAYOUTS = {
250 scatter: (r, n, m) => fill(r, [], n, m),
251 clusters(r, n, m) {
252 const k = 3 + Math.floor(r() * 3), cs = [];
253 for (let i = 0; i < k; i++) cs.push([F.x0 + 70 + (F.x1 - F.x0 - 140) * r(), F.y0 + 70 + (F.y1 - F.y0 - 140) * r()]);
254 const p = []; let tries = 0;
255 while (p.length < n * 0.85 && tries < 3000) { tries++; const c = cs[Math.floor(r() * k)]; tryAdd(p, c[0] + gauss(r) * 46, c[1] + gauss(r) * 46, m); }
256 return fill(r, p, n, m);
257 },
258 rings(r, n, m) {
259 const cx = 270 + (r() - 0.5) * 80, cy = 505 + (r() - 0.5) * 120, r1 = 62 + r() * 24, r2 = r1 + 68 + r() * 22, r3 = r2 + 72;
260 const p = []; tryAdd(p, cx, cy, m);
261 const ring = (rad, cnt) => { const a0 = r() * 6.283; for (let i = 0; i < cnt; i++) { const a = a0 + (i * 6.283) / cnt; tryAdd(p, cx + Math.cos(a) * rad * (1 + (r() - 0.5) * 0.08), cy + Math.sin(a) * rad * (1 + (r() - 0.5) * 0.08), m); } };
262 ring(r1, Math.max(4, Math.round(n * 0.22))); ring(r2, Math.round(n * 0.33)); ring(r3, Math.round(n * 0.4));
263 return fill(r, p, n, m);
264 },
265 spiral(r, n, m) {
266 const cx = 270, cy = 505, dir = r() < 0.5 ? 1 : -1, a = r() * 6.28, p = [];
267 let th = 0.5 + r() * 2;
268 for (let k = 0; k < 500 && p.length < n; k++) {
269 const rad = 26 + th * 15;
270 tryAdd(p, cx + Math.cos(dir * th + a) * rad, cy + Math.sin(dir * th + a) * rad * 1.3, m);
271 th += 60 / rad;
272 }
273 return fill(r, p, n, m);
274 },
275 lanes(r, n, m) {
276 const p = [], lanes = 2 + Math.floor(r() * 2);
277 for (let l = 0; l < lanes; l++) {
278 const x0 = F.x0 + (F.x1 - F.x0) * r(), y0 = F.y0 + (F.y1 - F.y0) * r(), an = r() * Math.PI, cs = Math.cos(an), sn = Math.sin(an), am = 20 + r() * 30, fq = 0.01 + r() * 0.01;
279 for (let s = -560; s <= 560; s += 60) {
280 const off = am * Math.sin(s * fq * 3);
281 tryAdd(p, x0 + cs * s - sn * off + (r() - 0.5) * 12, y0 + sn * s + cs * off + (r() - 0.5) * 12, m);
282 }
283 }
284 return fill(r, p.slice(0, n), n, m);
285 },
286 islands(r, n, m) {
287 const k = 3, cs = []; let tries = 0;
288 while (cs.length < k && tries < 400) {
289 tries++; const c = [F.x0 + 60 + (F.x1 - F.x0 - 120) * r(), F.y0 + 60 + (F.y1 - F.y0 - 120) * r()];
290 if (cs.every((o) => Math.hypot(o[0] - c[0], o[1] - c[1]) > 230)) cs.push(c);
291 }
292 const p = []; tries = 0;
293 while (p.length < n * 0.8 && tries < 3000 && cs.length) { tries++; const c = cs[Math.floor(r() * cs.length)]; tryAdd(p, c[0] + gauss(r) * 36, c[1] + gauss(r) * 36, m); }
294 return fill(r, p, n, m);
295 },
296 };
297 const LAYOUT_KEYS = ["scatter", "clusters", "rings", "spiral", "lanes", "islands"];
298
299 const PACKS = ["Dewdrop Shallows", "Moonlit Reeds", "Starlotus Deep", "Aurora Heart"];
300 const ADJ = ["Quiet", "Silver", "Drifting", "Whispering", "Glass", "Velvet", "Amber", "Hidden", "Gentle", "Lantern", "Cobalt", "Misty", "Opal", "Twilight", "Sleeping", "Jade"];
301 const NOUN = ["Ripple", "Reeds", "Lotus", "Basin", "Bend", "Hollow", "Terrace", "Glade", "Eddy", "Lagoon", "Shore", "Garden", "Crossing", "Spring", "Cove", "Isle"];
302
303 function params(e) {
304 return {
305 n: Math.max(12, Math.min(46, Math.round(12 + e * 0.78))),
306 thorns: e < 3 ? 0 : Math.min(1 + Math.floor((e - 3) / 5), 6),
307 bombs: e < 7 ? 0 : Math.min(1 + Math.floor((e - 7) / 6), 4),
308 sf: Math.min(0.4, 0.1 + 0.012 * e),
309 };
310 }
311 function buildLayout(index, e, seedBase, attempt, taps) {
312 const r = rng(hash(seedBase, attempt * 7919 + 13));
313 const P = params(e);
314 let n = P.n; if (taps > 1) n = Math.min(50, Math.round(n * 1.2));
315 const keys = e < 2 ? ["scatter", "clusters"] : LAYOUT_KEYS;
316 const key = keys[Math.floor(r() * keys.length)];
317 const minD = e < 4 ? 52 : 46;
318 const pts = LAYOUTS[key](r, n, minD);
319 const N = pts.length;
320 const deg = pts.map((a, i) => { let d = 0; for (let j = 0; j < N; j++) if (j !== i && Math.hypot(a[0] - pts[j][0], a[1] - pts[j][1]) < 105) d++; return d; });
321 const order = pts.map((_, i) => i).sort((a, b) => deg[b] + r() * 2.2 - (deg[a] + r() * 2.2));
322 const types = new Array(N).fill("n");
323 const free = order.slice();
324 const takeHub = () => { const k = Math.floor(r() * Math.max(1, free.length * 0.55)); return free.splice(k, 1)[0]; };
325 const takeAny = () => free.splice(Math.floor(r() * free.length), 1)[0];
326 const takeEdge = () => free.splice(free.length - 1 - Math.floor(r() * Math.max(1, free.length * 0.5)), 1)[0];
327 for (let i = 0; i < P.thorns; i++) if (free.length > 6) types[takeHub()] = "t";
328 for (let i = 0; i < P.bombs; i++) if (free.length > 6) types[takeAny()] = "b";
329 const unlock = { p: 2, i: 4, m: 5, s: 6 };
330 const avail = Object.keys(unlock).filter((k) => e >= unlock[k]);
331 if (avail.length) {
332 const total = Math.round(N * P.sf);
333 const list = [];
334 for (const k of avail) if (unlock[k] === e) list.push(k, k);
335 while (list.length < total) list.push(avail[Math.floor(r() * avail.length)]);
336 for (const k of list) { if (free.length < 3) break; types[k === "p" ? takeEdge() : takeAny()] = k; }
337 }
338 const A = e < 3 ? 5 : 7;
339 const crystals = pts.map((p, i) => {
340 const amp = A + r() * (e < 3 ? 6 : 13);
341 return {
342 type: types[i], ax: p[0], ay: p[1], Ax: amp * (0.6 + r() * 0.8), Ay: amp * (0.6 + r() * 0.8),
343 wx: 0.28 + r() * 0.4, wy: 0.28 + r() * 0.4, px: r() * 6.283, py: r() * 6.283,
344 hue: 178 + Math.floor(r() * 120), rot: r() * 6.283,
345 };
346 });
347 return { crystals, key };
348 }
349
350 function genLevel(index, opts) {
351 opts = opts || {};
352 const e = opts.e != null ? opts.e : index;
353 const seedBase = opts.seed != null ? opts.seed : hash(0xc0ffee, index);
354 let taps = 1;
355 if (!opts.oneTap) {
356 if (e >= 9 && e % 5 === 4) taps = 2;
357 if (e >= 19 && e % 10 === 9) taps = 3;
358 }
359 const frac = e === 0 ? 0.42 : e === 1 ? 0.34 : e === 2 ? 0.27 : Math.max(0.03, 0.22 - e * 0.0055);
360 let bestLv = null;
361 for (let att = 0; att < 7; att++) {
362 const lay = buildLayout(index, e, seedBase, att, taps);
363 const lv = { index, e, taps, crystals: lay.crystals, layout: lay.key, seed: seedBase, goal: 0 };
364 const M = lv.crystals.filter((c) => c.type !== "t").length;
365 lv.M = M;
366 const sol = solveLevel(lv);
367 const B = sol.best;
368 const counts = sol.counts;
369 let goal = 1;
370 if (counts.length) {
371 if (taps > 1) goal = Math.max(counts[Math.min(5, counts.length - 1)], Math.round(B * 0.9));
372 else goal = counts[Math.min(counts.length, Math.max(3, Math.round(counts.length * frac))) - 1];
373 goal = Math.max(goal, Math.round(B * 0.4));
374 }
375 goal = Math.max(1, Math.min(goal, B || 1));
376 lv.best = B; lv.goal = goal;
377 lv.hits = counts.filter((c) => c >= goal).length;
378 lv.total = counts.length;
379 const s2 = Math.min(B, Math.max(goal + 1, Math.round(goal + (B - goal) * 0.4)));
380 const s3 = Math.min(B, Math.max(s2, Math.round(goal + (B - goal) * 0.8)));
381 lv.s2 = Math.max(goal, s2); lv.s3 = Math.max(lv.s2, s3);
382 lv.hint = sol.seq.slice();
383 lv.attempt = att;
384 lv.quality = goal / M;
385 lv.name = ADJ[hash(seedBase, 3) % ADJ.length] + " " + NOUN[hash(seedBase, 5) % NOUN.length];
386 if (!bestLv || lv.quality > bestLv.quality) bestLv = lv;
387 const need = e < 6 ? 0.4 : 0.36;
388 if (lv.quality >= need && goal >= Math.min(4, M - 1) && B >= 0.55 * M * (e > 12 ? 0.85 : 1)) { bestLv = lv; break; }
389 }
390 return bestLv;
391 }
392
393 root.CB = {
394 W, H, TICK, F, TY, SPARK, LATE, HOLD, TAP_TICK, PACKS, mult, rng, hash, ease,
395 makeWorld, step, tap, isSettled, runToSettle, cloneWorld, warp, place, solveLevel, genLevel, nearCrystal,
396 };
397})(typeof window !== "undefined" ? window : globalThis);
398