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 crystals122 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 // seeds140 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