sumbloom / game.js
CyberMax
Deploy sumbloom
96a4f6b verified
Raw History Blame
14.6 kB
/* Sumbloom — daily number puzzle. © 2026 CyberMax. All rights reserved. */
(function () {
"use strict";
var SITE = "https://shaw276-sumbloom.static.hf.space/";
var KEY = "sumbloom:v1:";
var C = window.SumbloomCore;
var makePuzzle = C.makePuzzle, apply = C.apply, puzzleNo = C.puzzleNo, todayStr = C.todayStr, OPS = C.OPS, FLOWER = C.FLOWER;
// ---------- storage (never throws) ----------
function load(k, dflt) {
try { var v = localStorage.getItem(KEY + k); return v ? JSON.parse(v) : dflt; } catch (e) { return dflt; }
}
function save(k, v) { try { localStorage.setItem(KEY + k, JSON.stringify(v)); } catch (e) { /* ignore */ } }
// ---------- state ----------
var el = function (id) { return document.getElementById(id); };
var mode = "daily";
var dateStr = todayStr();
var puzzle, state, active = 0, selTile = null, selOp = null, practiceSeed = null;
function freshState() {
return {
rounds: puzzle.map(function (r) {
return { tiles: r.nums.map(function (n, i) { return { id: i, v: n, gone: false }; }), hist: [], log: [], done: false, stars: 0, best: null };
})
};
}
function startDaily() {
mode = "daily";
dateStr = todayStr();
puzzle = makePuzzle("daily:" + dateStr);
state = load("day:" + dateStr, null);
if (!state || !state.rounds || state.rounds.length !== 5) state = freshState();
active = firstOpen();
el("puzzle-label").textContent = "Daily #" + puzzleNo(dateStr) + " · " + dateStr;
el("btn-mode").textContent = "Practice mode";
render();
}
function startPractice() {
mode = "practice";
practiceSeed = "practice:" + Date.now() + ":" + Math.random();
puzzle = makePuzzle(practiceSeed);
state = freshState();
active = 0;
el("puzzle-label").textContent = "Practice puzzle";
el("btn-mode").textContent = "Back to daily";
render();
}
function firstOpen() {
for (var i = 0; i < 5; i++) if (!state.rounds[i].done) return i;
return 4;
}
function persist() { if (mode === "daily") save("day:" + dateStr, state); }
// ---------- rendering ----------
function render() {
var R = state.rounds[active];
var rounds = el("rounds");
rounds.innerHTML = "";
puzzle.forEach(function (p, i) {
var b = document.createElement("button");
var rs = state.rounds[i];
b.className = "round" + (i === active ? " active" : "") + (rs.done ? " done" : "");
b.innerHTML = p.target + "<small>" + (rs.done ? "★".repeat(rs.stars) + "☆".repeat(3 - rs.stars) : "") + "</small>";
b.setAttribute("aria-label", "Target " + p.target + (rs.done ? ", " + rs.stars + " stars" : ""));
b.onclick = function () { active = i; selTile = null; selOp = null; render(); };
rounds.appendChild(b);
});
el("target").textContent = puzzle[active].target;
var best = closestOnBoard(R);
if (R.done) {
el("closest").textContent = R.stars === 3 ? "Bloomed! Exact hit." : "Submitted " + R.best + " (" + Math.abs(R.best - puzzle[active].target) + " away)";
} else {
el("closest").textContent = best !== null ? "Closest so far: " + best + (best === puzzle[active].target ? "" : " (" + Math.abs(best - puzzle[active].target) + " away)") : " ";
}
var board = el("board");
board.innerHTML = "";
R.tiles.forEach(function (t) {
var b = document.createElement("button");
b.className = "tile" + (t.gone ? " gone" : "") + (selTile === t.id ? " sel" : "") + (t.fresh ? " new" : "") +
(R.done && t.v === puzzle[active].target && !t.gone ? " hit" : "");
b.textContent = t.v;
b.disabled = R.done || t.gone;
b.setAttribute("aria-label", "Number " + t.v);
b.onclick = function () { tapTile(t.id, b); };
board.appendChild(b);
t.fresh = false;
});
Array.prototype.forEach.call(document.querySelectorAll(".op"), function (b) {
b.classList.toggle("sel", selOp === b.dataset.op);
b.disabled = R.done;
});
el("btn-undo").disabled = R.done || R.hist.length === 0;
el("btn-submit").disabled = R.done;
el("btn-submit").textContent = R.done ? (allDone() ? "See results" : "Next target →") : "Submit closest";
if (R.done) el("btn-submit").disabled = false;
var log = el("log");
log.innerHTML = "";
R.log.forEach(function (s) { var li = document.createElement("li"); li.textContent = s; log.appendChild(li); });
}
function closestOnBoard(R) {
var t = puzzle[active].target, best = null;
R.tiles.forEach(function (x) { if (!x.gone && (best === null || Math.abs(x.v - t) < Math.abs(best - t))) best = x.v; });
return best;
}
function allDone() { return state.rounds.every(function (r) { return r.done; }); }
function tapTile(id, node) {
var R = state.rounds[active];
if (R.done) return;
if (selTile === null || (selOp === null)) {
selTile = selTile === id ? null : id;
render();
return;
}
if (selTile === id) { selTile = null; selOp = null; render(); return; }
var a = R.tiles.filter(function (t) { return t.id === selTile; })[0];
var b = R.tiles.filter(function (t) { return t.id === id; })[0];
var v = apply(a.v, selOp, b.v);
if (!v) {
node.classList.add("shake");
toast(selOp === "-" ? "Results must be positive: try the bigger number first" : "Only whole-number division");
setTimeout(function () { node.classList.remove("shake"); }, 320);
return;
}
R.hist.push(JSON.stringify({ tiles: R.tiles, log: R.log }));
R.log = R.log.concat(a.v + " " + OPS[selOp] + " " + b.v + " = " + v);
a.gone = true;
b.v = v; b.fresh = true;
selTile = id; selOp = null;
if (v === puzzle[active].target) finishRound(3, v);
persist();
render();
}
function tapOp(op) {
var R = state.rounds[active];
if (R.done) return;
if (selTile === null) { toast("Pick a number first"); return; }
selOp = selOp === op ? null : op;
render();
}
function undo() {
var R = state.rounds[active];
if (R.done || !R.hist.length) return;
var prev = JSON.parse(R.hist.pop());
R.tiles = prev.tiles; R.log = prev.log;
selTile = null; selOp = null;
persist(); render();
}
function starsFor(diff) { return diff === 0 ? 3 : diff <= 10 ? 2 : diff <= 25 ? 1 : 0; }
function finishRound(stars, best) {
var R = state.rounds[active];
R.done = true; R.stars = stars; R.best = best; R.hist = [];
selTile = null; selOp = null;
toast(stars === 3 ? "🌸 Bloomed!" : FLOWER[stars] + " " + stars + " star" + (stars === 1 ? "" : "s"));
if (allDone()) {
if (mode === "daily") recordStats();
persist();
setTimeout(showResults, 700);
}
}
function submit() {
var R = state.rounds[active];
if (R.done) {
if (allDone()) { showResults(); return; }
active = firstOpen(); render(); return;
}
var best = closestOnBoard(R);
finishRound(starsFor(Math.abs(best - puzzle[active].target)), best);
persist(); render();
}
// ---------- stats ----------
function recordStats() {
var st = load("stats", { played: 0, streak: 0, maxStreak: 0, last: null, total: 0, perfect: 0, dist: {} });
if (st.last === dateStr) return;
var score = totalStars();
var y = new Date(); y.setDate(y.getDate() - 1);
st.streak = st.last === todayStr(y) ? st.streak + 1 : 1;
st.maxStreak = Math.max(st.maxStreak, st.streak);
st.played += 1; st.total += score; if (score === 15) st.perfect += 1;
st.dist[score] = (st.dist[score] || 0) + 1;
st.last = dateStr;
save("stats", st);
}
function totalStars() { return state.rounds.reduce(function (s, r) { return s + (r.stars || 0); }, 0); }
function shareText() {
var head = mode === "daily" ? "Sumbloom #" + puzzleNo(dateStr) : "Sumbloom practice";
return head + " " + totalStars() + "/15\n" + state.rounds.map(function (r) { return FLOWER[r.stars || 0]; }).join("") + "\n" + SITE;
}
function share() {
var text = shareText();
if (navigator.share && /Mobi|Android/i.test(navigator.userAgent)) {
navigator.share({ text: text }).catch(function () { copy(text); });
} else copy(text);
}
function copy(text) {
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(function () { toast("Copied to clipboard"); }, function () { fallbackCopy(text); });
} else fallbackCopy(text);
}
function fallbackCopy(text) {
var ta = document.createElement("textarea");
ta.value = text; document.body.appendChild(ta); ta.select();
try { document.execCommand("copy"); toast("Copied to clipboard"); } catch (e) { toast("Copy failed"); }
document.body.removeChild(ta);
}
// ---------- modals ----------
function openModal(html) {
el("modal-body").innerHTML = html;
el("modal").hidden = false;
el("modal-close").focus();
}
function closeModal() { el("modal").hidden = true; clearInterval(cdTimer); }
var cdTimer = null;
function countdown() {
var now = new Date(), next = new Date(now); next.setHours(24, 0, 0, 0);
var s = Math.max(0, Math.floor((next - now) / 1000));
return String(Math.floor(s / 3600)).padStart(2, "0") + ":" + String(Math.floor(s % 3600 / 60)).padStart(2, "0") + ":" + String(s % 60).padStart(2, "0");
}
function solutionHtml() {
return puzzle.map(function (p, i) {
return "<div class='sol'><b>" + p.target + "</b>: " + p.solution.map(function (s) { return s[0] + " " + OPS[s[1]] + " " + s[2] + " = " + s[3]; }).join(" · ") + "</div>";
}).join("<div style='height:6px'></div>");
}
function showResults() {
var html = "<h2>" + (mode === "daily" ? "Daily #" + puzzleNo(dateStr) : "Practice") + " complete</h2>" +
"<div class='big-score'>" + totalStars() + "/15</div>" +
"<div class='share-grid'>" + state.rounds.map(function (r) { return FLOWER[r.stars || 0]; }).join("") + "</div>" +
"<button class='btn' id='btn-share' style='width:100%'>Share result</button>" +
"<p style='font-size:14px'>One way to reach each target:</p>" + solutionHtml() +
(mode === "daily" ? "<p class='countdown'>Next puzzle in <b id='cd'>" + countdown() + "</b></p>" : "") +
"<button class='btn ghost' id='btn-practice' style='width:100%;margin-top:8px'>" + (mode === "daily" ? "Play a practice puzzle" : "New practice puzzle") + "</button>";
openModal(html);
el("btn-share").onclick = share;
el("btn-practice").onclick = function () { closeModal(); startPractice(); };
if (mode === "daily") cdTimer = setInterval(function () { var c = el("cd"); if (c) c.textContent = countdown(); }, 1000);
}
function showStats() {
var st = load("stats", { played: 0, streak: 0, maxStreak: 0, total: 0, perfect: 0, dist: {} });
var avg = st.played ? (st.total / st.played).toFixed(1) : "–";
var max = 1; for (var k in st.dist) max = Math.max(max, st.dist[k]);
var rows = "";
for (var sc = 15; sc >= 0; sc--) {
var n = st.dist[sc] || 0;
if (!n && sc < 10) continue;
rows += "<div class='dist-row'><span style='width:22px;text-align:right'>" + sc + "</span><span class='bar-fill' style='width:" + (8 + 80 * n / max) + "%'>" + n + "</span></div>";
}
openModal("<h2>Statistics</h2><div class='stats'><div><b>" + st.played + "</b><span>Played</span></div><div><b>" + avg +
"</b><span>Avg stars</span></div><div><b>" + st.streak + "</b><span>Streak</span></div><div><b>" + st.maxStreak +
"</b><span>Best streak</span></div></div><p style='font-size:14px;margin-bottom:4px'>Daily scores (" + st.perfect + " perfect)</p><div class='dist'>" +
(rows || "<p style='font-size:14px;color:var(--muted)'>Finish today's puzzle to start your stats.</p>") + "</div>" +
(allDone() && mode === "daily" ? "<button class='btn' id='btn-share2' style='width:100%'>Share today's result</button>" : ""));
var b = el("btn-share2"); if (b) b.onclick = share;
}
function showHelp() {
openModal("<h2>How to play</h2><p>Reach the target by combining numbers with + − × ÷.</p>" +
"<div class='how-demo'><span>5</span><span class='o'>×</span><span>6</span><span class='o'>=</span><span style='background:var(--petal);color:#fff;border-color:var(--petal)'>30</span></div>" +
"<p>Tap a number, tap an operation, then tap a second number. They merge into the result. You don't need every number, and only whole positive results count.</p>" +
"<p><b>Scoring:</b> exact = 3 blossoms 🌸, within 10 = 2 🌼, within 25 = 1 🌱. Can't get closer? Press <b>Submit closest</b>.</p>" +
"<p>Five targets a day, the same for everyone. A new puzzle every midnight, plus unlimited practice. Keys: + − * / and Backspace to undo.</p>" +
"<button class='btn' id='btn-go' style='width:100%'>Play</button>");
el("btn-go").onclick = closeModal;
}
var toastTimer = null;
function toast(msg) {
var t = el("toast"); t.textContent = msg; t.classList.add("show");
clearTimeout(toastTimer); toastTimer = setTimeout(function () { t.classList.remove("show"); }, 1600);
}
// ---------- wire up ----------
Array.prototype.forEach.call(document.querySelectorAll(".op"), function (b) { b.onclick = function () { tapOp(b.dataset.op); }; });
el("btn-undo").onclick = undo;
el("btn-submit").onclick = submit;
el("btn-help").onclick = showHelp;
el("btn-stats").onclick = showStats;
el("btn-mode").onclick = function () { mode === "daily" ? startPractice() : startDaily(); };
el("modal-close").onclick = closeModal;
el("modal").onclick = function (e) { if (e.target === el("modal")) closeModal(); };
document.addEventListener("keydown", function (e) {
if (!el("modal").hidden) { if (e.key === "Escape") closeModal(); return; }
if (OPS[e.key]) tapOp(e.key);
else if (e.key === "x" || e.key === "X") tapOp("*");
else if (e.key === "Backspace") undo();
else if (e.key === "Enter") submit();
});
// expose for tests
window.Sumbloom = { makePuzzle: makePuzzle, apply: apply, puzzleNo: puzzleNo, state: function () { return state; }, puzzle: function () { return puzzle; } };
document.addEventListener("visibilitychange", function () {
if (!document.hidden && mode === "daily" && todayStr() !== dateStr) { closeModal(); startDaily(); }
});
startDaily();
if (!load("seenHelp", false)) { save("seenHelp", true); showHelp(); }
if ("serviceWorker" in navigator && location.protocol === "https:") {
navigator.serviceWorker.register("sw.js").catch(function () {});
}
})();