packing-bingo 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/core.cjs CHANGED
@@ -122,7 +122,7 @@ function bingoLines(size, free = false) {
122
122
  // src/messages.ts
123
123
  var ja = {
124
124
  title: "\u6301\u3061\u3082\u306E\u30D3\u30F3\u30B4",
125
- tag: "\u4FEE\u5B66\u65C5\u884C",
125
+ tag: "",
126
126
  subtitle: "",
127
127
  packed: "\u305D\u308D\u3063\u305F",
128
128
  bingo: "BINGO",
@@ -130,6 +130,7 @@ var ja = {
130
130
  combo: (n) => `${n} COMBO`,
131
131
  comboPop: (n) => `${n} COMBO!`,
132
132
  reset: "\u30EA\u30BB\u30C3\u30C8",
133
+ shuffle: "\u{1F500} \u30B7\u30E3\u30C3\u30D5\u30EB",
133
134
  soundOn: "\u{1F50A} \u97F3\u3042\u308A",
134
135
  soundOff: "\u{1F507} \u97F3\u306A\u3057",
135
136
  stamp: "OK",
@@ -143,7 +144,7 @@ var ja = {
143
144
  bannerMulti: (n) => `\u4E00\u6C17\u306B ${n} \u30E9\u30A4\u30F3\uFF01`,
144
145
  statusStart: () => "",
145
146
  statusLines: (lines, left) => `\u30D3\u30F3\u30B4 ${lines} \u30E9\u30A4\u30F3\uFF01 \u306E\u3053\u308A ${left} \u3053`,
146
- statusReach: (labels) => `\u30EA\u30FC\u30C1\uFF01 \u3042\u3068${labels.slice(0, 2).map((l) => `\u300C${l}\u300D`).join("\u304B")}${labels.length > 2 ? "\u306A\u3069" : ""}\u3067\u30D3\u30F3\u30B4`,
147
+ statusReach: () => "",
147
148
  statusDone: "\u{1F389} \u30D1\u30FC\u30D5\u30A7\u30AF\u30C8\uFF01\u5FD8\u308C\u7269\u30BC\u30ED\u3067\u51FA\u767A\u3067\u304D\u307E\u3059",
148
149
  perfectTag: "\u{1F3C6} PERFECT",
149
150
  perfectStamp: "\u6E96\u5099\n\u5B8C\u4E86",
@@ -162,7 +163,7 @@ var ja = {
162
163
  };
163
164
  var en = {
164
165
  title: "Packing Bingo",
165
- tag: "School trip",
166
+ tag: "",
166
167
  subtitle: "",
167
168
  packed: "PACKED",
168
169
  bingo: "BINGO",
@@ -170,6 +171,7 @@ var en = {
170
171
  combo: (n) => `${n} COMBO`,
171
172
  comboPop: (n) => `${n} COMBO!`,
172
173
  reset: "Reset",
174
+ shuffle: "\u{1F500} Shuffle",
173
175
  soundOn: "\u{1F50A} Sound on",
174
176
  soundOff: "\u{1F507} Muted",
175
177
  stamp: "OK",
@@ -183,7 +185,7 @@ var en = {
183
185
  bannerMulti: (n) => `${n} lines at once!`,
184
186
  statusStart: () => "",
185
187
  statusLines: (lines, left) => `${lines} bingo line${lines === 1 ? "" : "s"}! ${left} to go`,
186
- statusReach: (labels) => `Reach! Pack ${labels.slice(0, 2).join(" or ")}${labels.length > 2 ? " (or more)" : ""} for a bingo`,
188
+ statusReach: () => "",
187
189
  statusDone: "\u{1F389} Perfect! Nothing left behind.",
188
190
  perfectTag: "\u{1F3C6} PERFECT",
189
191
  perfectStamp: "ALL\nSET",
@@ -289,6 +291,11 @@ function createSound(isMuted) {
289
291
  },
290
292
  tick: () => tone(1760, 0, 0.03, "square", 0.03),
291
293
  note: (k, at = 0) => tone(step(k), at, 0.12, "square", 0.05),
294
+ shuffle() {
295
+ for (let i = 0; i < 7; i++) noise(i * 0.042, 0.05, 0.16, 1600 + i * 260);
296
+ tone(hz(7), 0.34, 0.12, "triangle", 0.14);
297
+ tone(hz(12), 0.42, 0.2, "triangle", 0.14);
298
+ },
292
299
  close() {
293
300
  if (ac) void ac.close().catch(() => {
294
301
  });
@@ -316,7 +323,6 @@ function itemsKey(items) {
316
323
  }
317
324
 
318
325
  // src/styles.ts
319
- var FONT_URL = "https://fonts.googleapis.com/css2?family=Dela+Gothic+One&family=M+PLUS+Rounded+1c:wght@500;800&display=swap";
320
326
  var CSS = `
321
327
  .pkb{--pk-sun:var(--pkb-sun,#FFD54F);--pk-ink:var(--pkb-ink,#2A1F14);--pk-paper:var(--pkb-paper,#FFFDF6);--pk-red:var(--pkb-red,#E8473B);--pk-mint:var(--pkb-mint,#BDEBD2);--pk-display:"Dela Gothic One",var(--pkb-font-fallback,sans-serif);--pk-body:"M PLUS Rounded 1c","Hiragino Maru Gothic ProN",var(--pkb-font-fallback,sans-serif);--pk-n:4;--pk-s:1;--pk-d:1;container-type:inline-size;isolation:isolate;width:100%}
322
328
  .pkb,.pkb *{box-sizing:border-box;margin:0;padding:0}
@@ -328,6 +334,7 @@ var CSS = `
328
334
  .pkb-head{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:center;gap:12px 16px;margin-bottom:18px}
329
335
  .pkb-tags{display:flex;gap:8px;align-items:center}
330
336
  .pkb-kicker{display:inline-block;background:var(--pk-ink);color:var(--pk-sun);font-weight:800;font-size:11px;letter-spacing:.14em;padding:4px 10px;border-radius:999px}
337
+ .pkb-kicker:empty{display:none}
331
338
  .pkb-kicker.pkb-gold{color:#fff;background:linear-gradient(90deg,#E8473B,#FF9F1C,#1FA971,#2F6FED,#E8473B);background-size:200% 100%;animation:pkb-rb 2s linear infinite}
332
339
  @keyframes pkb-rb{to{background-position:-200% 0}}
333
340
  .pkb button{font-family:var(--pk-body)}
@@ -384,8 +391,13 @@ var CSS = `
384
391
  .pkb-lines .pkb-o{stroke:var(--pk-ink);stroke-width:calc(6px*var(--pk-d))}
385
392
  .pkb-lines .pkb-draw line{animation:pkb-draw .45s ease-out .3s both}
386
393
  @keyframes pkb-draw{from{stroke-dashoffset:1}}
387
- .pkb-foot{display:flex;justify-content:space-between;align-items:center;gap:10px;margin-top:18px;font-weight:800;font-size:13px;word-break:auto-phrase}
388
- .pkb-reset{appearance:none;flex:none;border:2px solid var(--pk-ink);background:transparent;border-radius:999px;padding:5px 12px;font-weight:800;font-size:12px;line-height:1.4;color:var(--pk-ink);cursor:pointer}
394
+ .pkb-foot{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:center;gap:8px 10px;margin-top:18px;font-weight:800;font-size:13px;word-break:auto-phrase}
395
+ .pkb-foot>span{flex:1 1 14em}
396
+ .pkb-foot>span:empty{flex-basis:0}
397
+ .pkb-acts{display:flex;gap:8px;flex:none;margin-left:auto}
398
+ .pkb-reset,.pkb-shuf{appearance:none;flex:none;border:2px solid var(--pk-ink);background:transparent;border-radius:999px;padding:5px 12px;font-weight:800;font-size:12px;line-height:1.4;color:var(--pk-ink);cursor:pointer}
399
+ .pkb-shuf{background:var(--pk-paper);box-shadow:2px 2px 0 var(--pk-ink)}
400
+ .pkb-shuf:active{transform:translate(2px,2px);box-shadow:none}
389
401
  .pkb-flash{position:absolute;inset:0;z-index:7;background:#fff;opacity:0;pointer-events:none}
390
402
  .pkb-flash.pkb-go{animation:pkb-flash .4s ease-out}
391
403
  @keyframes pkb-flash{0%{opacity:.8}100%{opacity:0}}
@@ -433,6 +445,7 @@ var CONFETTI = ["#E8473B", "#2F6FED", "#1FA971", "#FFFDF6", "#2A1F14", "#FF9F1C"
433
445
  var COMBO_COLORS = ["#FF9F1C", "#1FA971", "#2F6FED", "#9B5DE5", "#E8473B"];
434
446
  var COMBO_MS = 2500;
435
447
  var MUTE_KEY = "packing-bingo-muted";
448
+ var ORDER_PREFIX = "packing-bingo-order:";
436
449
  var esc = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
437
450
  function readStore(key) {
438
451
  try {
@@ -447,29 +460,25 @@ function writeStore(key, value) {
447
460
  } catch {
448
461
  }
449
462
  }
450
- function ensureAssets(fonts) {
451
- if (!document.getElementById("pkb-styles")) {
452
- const s = document.createElement("style");
453
- s.id = "pkb-styles";
454
- s.textContent = CSS;
455
- document.head.appendChild(s);
456
- }
457
- if (fonts && !document.getElementById("pkb-fonts")) {
458
- const l = document.createElement("link");
459
- l.id = "pkb-fonts";
460
- l.rel = "stylesheet";
461
- l.href = FONT_URL;
462
- document.head.appendChild(l);
463
- }
463
+ var glintDelay = (c, n) => `${+((Math.floor(c / n) + c % n) * 0.36 / Math.max(1, n - 1)).toFixed(3)}s`;
464
+ var lineKey = (l) => l.slice().sort((a, b) => a - b).join(",");
465
+ var FLY_MS = 560;
466
+ function ensureStyles() {
467
+ if (document.getElementById("pkb-styles")) return;
468
+ const s = document.createElement("style");
469
+ s.id = "pkb-styles";
470
+ s.textContent = CSS;
471
+ document.head.appendChild(s);
464
472
  }
465
- function template(L, M, demo) {
473
+ function template(L, M, { demo, shuffle, vibrate }) {
466
474
  const n = L.size;
475
+ const input = vibrate ? `<input type="checkbox" switch role="checkbox">` : `<input type="checkbox">`;
467
476
  const tiles = Array.from({ length: n * n }, (_, c) => {
468
- const gd = `--pkb-gd:${+((Math.floor(c / n) + c % n) * 0.36 / Math.max(1, n - 1)).toFixed(3)}s`;
477
+ const gd = `--pkb-gd:${glintDelay(c, n)}`;
469
478
  if (c === L.center)
470
479
  return `<div class="pkb-tile pkb-free pkb-on" style="${gd}"><span class="pkb-stamp">${esc(M.free)}</span></div>`;
471
480
  const it = L.items[L.itemCell.indexOf(c)];
472
- return `<label class="pkb-tile" data-reach="${esc(M.reachBadge)}" style="${gd}"><input type="checkbox"><span class="pkb-emo" aria-hidden="true">${esc(it.emoji)}</span><span class="pkb-lbl">${esc(it.label).replace(/・/g, "\u30FB<wbr>")}</span><span class="pkb-stamp" aria-hidden="true">${esc(M.stamp)}</span></label>`;
481
+ return `<label class="pkb-tile" data-reach="${esc(M.reachBadge)}" style="${gd}">${input}<span class="pkb-emo" aria-hidden="true">${esc(it.emoji)}</span><span class="pkb-lbl">${esc(it.label).replace(/・/g, "\u30FB<wbr>")}</span><span class="pkb-stamp" aria-hidden="true">${esc(M.stamp)}</span></label>`;
473
482
  }).join("");
474
483
  const scale = `--pk-n:${n};--pk-s:${+Math.min(1.35, 4 / n).toFixed(3)};--pk-d:${+Math.min(1, 4 / n).toFixed(3)}`;
475
484
  const demoBar = demo ? `<div class="pkb-demo"><span>${esc(M.demoLabel)}</span><button type="button" data-r="dB">${esc(M.demoBingo)}</button><button type="button" data-r="dP">${esc(M.demoPerfect)}</button><button type="button" data-r="dR">${esc(M.demoRestore)}</button></div>` : "";
@@ -484,7 +493,7 @@ function template(L, M, demo) {
484
493
  <div class="pkb-combo" data-r="combo"><b data-r="cmbN"></b><span class="pkb-bar"><i data-r="cbar"></i></span></div></div></div>
485
494
  <div class="pkb-gw"><div class="pkb-grid">${tiles}</div><svg class="pkb-lines" aria-hidden="true"></svg>
486
495
  <div class="pkb-banner" data-r="banner" aria-hidden="true"><b data-r="bT"></b><span data-r="bS"></span></div></div>
487
- <div class="pkb-foot"><span data-r="msg" aria-live="polite"></span><button type="button" class="pkb-reset" data-r="reset">${esc(M.reset)}</button></div>
496
+ <div class="pkb-foot"><span data-r="msg" aria-live="polite"></span><div class="pkb-acts">${shuffle ? `<button type="button" class="pkb-shuf" data-r="shuf">${esc(M.shuffle)}</button>` : ""}<button type="button" class="pkb-reset" data-r="reset">${esc(M.reset)}</button></div></div>
488
497
  <div class="pkb-flash" data-r="flash"></div>
489
498
  <div class="pkb-perfect" data-r="perfect"><div class="pkb-card" role="dialog" aria-label="${esc(M.perfectTitle)}">
490
499
  <div class="pkb-medal">${esc(M.perfectStamp)}</div><h3 class="pkb-ptitle">${esc(M.perfectTitle)}</h3><p class="pkb-pbody" data-r="pbody">${esc(perfectText(M, L.items.length))}</p>
@@ -495,12 +504,22 @@ function template(L, M, demo) {
495
504
  var perfectText = (M, total) => typeof M.perfectBody === "function" ? M.perfectBody(total) : M.perfectBody;
496
505
  function createPackingBingo(container, options = {}) {
497
506
  const L = resolveLayout(options);
498
- const { items, lines, cellLines } = L;
507
+ const { items, cellLines } = L;
499
508
  const n = L.size, N = items.length;
500
509
  const normalize = (v) => Array.from({ length: N }, (_, i) => !!v?.[i]);
510
+ const identity = () => Array.from({ length: N }, (_, s) => s);
511
+ const isIdentity = (o) => o.every((v, s) => v === s);
512
+ const validOrder = (o) => Array.isArray(o) && o.length === N && o.every((v) => Number.isInteger(v) && v >= 0 && v < N) && new Set(o).size === N;
513
+ let order = identity();
514
+ const slotOf = identity();
515
+ let lines = L.lines;
501
516
  const demoCells = [...cellLines[0], ...cellLines[2 * n].slice(1, -1)];
502
- const demoItems = new Set(L.itemCell.flatMap((c, i) => demoCells.includes(c) ? [i] : []));
503
- const demoStart = () => Array.from({ length: N }, (_, i) => demoItems.has(i));
517
+ const demoSlots = L.itemCell.flatMap((c, s) => demoCells.includes(c) ? [s] : []);
518
+ const demoStart = () => {
519
+ const on = normalize(void 0);
520
+ demoSlots.forEach((s) => on[order[s]] = true);
521
+ return on;
522
+ };
504
523
  const far = (c) => Math.floor(c / n) + c % n;
505
524
  const dScale = Math.min(1, 4 / n);
506
525
  const M = { ...ja, ...options.messages };
@@ -509,7 +528,9 @@ function createPackingBingo(container, options = {}) {
509
528
  if (options.subtitle != null) M.subtitle = options.subtitle;
510
529
  const controlled = !!options.controlled;
511
530
  const storageKey = resolveStorageKey(options);
531
+ const orderKey = storageKey && ORDER_PREFIX + storageKey;
512
532
  const demo = !!options.demo;
533
+ const vibrate = options.vibrate !== false;
513
534
  const calm = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
514
535
  let dead = false;
515
536
  const timers = /* @__PURE__ */ new Set();
@@ -522,8 +543,8 @@ function createPackingBingo(container, options = {}) {
522
543
  };
523
544
  const wait = (ms) => new Promise((r) => window.setTimeout(r, ms));
524
545
  let gen = 0;
525
- ensureAssets(options.fonts !== false);
526
- container.innerHTML = template(L, M, demo);
546
+ ensureStyles();
547
+ container.innerHTML = template(L, M, { demo, shuffle: options.shuffle !== false, vibrate });
527
548
  const $ = (r) => {
528
549
  const el = container.querySelector(`[data-r="${r}"]`);
529
550
  if (!el) throw new Error(`[packing-bingo] internal: missing element "${r}"`);
@@ -700,6 +721,7 @@ function createPackingBingo(container, options = {}) {
700
721
  r.addEventListener("animationend", () => r.remove());
701
722
  };
702
723
  const buzz = (pattern) => {
724
+ if (!vibrate) return;
703
725
  const ua = navigator.userActivation;
704
726
  if (ua && !ua.hasBeenActive) return;
705
727
  try {
@@ -758,7 +780,12 @@ function createPackingBingo(container, options = {}) {
758
780
  const ctr = (t) => {
759
781
  return { x: t.offsetLeft + t.offsetWidth / 2, y: t.offsetTop + t.offsetHeight / 2 };
760
782
  };
783
+ let landing = 0;
761
784
  function drawLines(fresh = []) {
785
+ if (landing) {
786
+ svg.innerHTML = "";
787
+ return;
788
+ }
762
789
  svg.setAttribute("viewBox", `0 0 ${gw.clientWidth} ${gw.clientHeight}`);
763
790
  svg.innerHTML = cur.map((k) => {
764
791
  const a = ctr(cellTiles[cellLines[k][0]]), b = ctr(cellTiles[cellLines[k][n - 1]]);
@@ -773,6 +800,7 @@ function createPackingBingo(container, options = {}) {
773
800
  combo = 0;
774
801
  $("combo").classList.remove("pkb-on");
775
802
  sfx.off();
803
+ buzz(8);
776
804
  return;
777
805
  }
778
806
  const now = performance.now();
@@ -795,7 +823,7 @@ function createPackingBingo(container, options = {}) {
795
823
  $("cmbN").textContent = M.combo(combo);
796
824
  replay($("combo"), "pkb-on");
797
825
  }
798
- buzz(12);
826
+ buzz(18);
799
827
  }
800
828
  $("cbar").addEventListener("animationend", () => {
801
829
  $("combo").classList.remove("pkb-on");
@@ -889,10 +917,11 @@ function createPackingBingo(container, options = {}) {
889
917
  $("combo").classList.remove("pkb-on");
890
918
  perfectEl.classList.remove("pkb-show");
891
919
  }
920
+ const completeLines = () => lines.flatMap((l, k) => l.every((i) => checked[i]) ? [k] : []);
892
921
  function render(src, quiet) {
893
922
  const events = [];
894
923
  const on = checked, c = on.filter(Boolean).length;
895
- cur = lines.flatMap((l, k) => l.every((i) => on[i]) ? [k] : []);
924
+ cur = completeLines();
896
925
  const fresh = cur.filter((k) => !prevLines.has(k));
897
926
  if ([...prevLines].some((k) => !cur.includes(k))) {
898
927
  cancelFx();
@@ -906,7 +935,7 @@ function createPackingBingo(container, options = {}) {
906
935
  }
907
936
  tiles.forEach((t, i) => {
908
937
  t.classList.toggle("pkb-on", on[i]);
909
- t.classList.toggle("pkb-line", hit.has(L.itemCell[i]));
938
+ t.classList.toggle("pkb-line", hit.has(L.itemCell[slotOf[i]]));
910
939
  t.classList.toggle("pkb-reach", reach.has(i));
911
940
  });
912
941
  freeTile?.classList.toggle("pkb-line", hit.has(L.center));
@@ -924,8 +953,8 @@ function createPackingBingo(container, options = {}) {
924
953
  lastN = cur.length;
925
954
  drawLines(fresh);
926
955
  if (src !== null) tapFx(src, on[src]);
927
- const newL = fresh.filter((k) => !gotL.has(k));
928
- newL.forEach((k) => gotL.add(k));
956
+ const newL = fresh.map((k) => lineKey(lines[k])).filter((key) => !gotL.has(key));
957
+ newL.forEach((key) => gotL.add(key));
929
958
  const linePts = 100 * newL.length * newL.length;
930
959
  score += linePts;
931
960
  if (quiet) {
@@ -970,8 +999,9 @@ function createPackingBingo(container, options = {}) {
970
999
  got.clear();
971
1000
  gotL.clear();
972
1001
  checked.forEach((v, i) => v && got.add(i));
973
- lines.forEach((l, k) => l.every((i) => checked[i]) && gotL.add(k));
974
- prevLines = new Set(gotL);
1002
+ const done = completeLines();
1003
+ done.forEach((k) => gotL.add(lineKey(lines[k])));
1004
+ prevLines = new Set(done);
975
1005
  prevReach = /* @__PURE__ */ new Set();
976
1006
  prevAll = checked.every(Boolean);
977
1007
  gotP = prevAll;
@@ -994,6 +1024,77 @@ function createPackingBingo(container, options = {}) {
994
1024
  options.onChange?.(checked.slice());
995
1025
  flush(events);
996
1026
  }
1027
+ function place(next) {
1028
+ order = next;
1029
+ order.forEach((i, s) => {
1030
+ slotOf[i] = s;
1031
+ cellTiles[L.itemCell[s]] = tiles[i];
1032
+ });
1033
+ lines = L.lines.map((l) => l.map((s) => order[s]));
1034
+ const focused = document.activeElement;
1035
+ cellTiles.forEach((t, c) => {
1036
+ t.style.setProperty("--pkb-gd", glintDelay(c, n));
1037
+ grid.appendChild(t);
1038
+ });
1039
+ if (focused instanceof HTMLElement && grid.contains(focused) && document.activeElement !== focused)
1040
+ focused.focus({ preventScroll: true });
1041
+ }
1042
+ const saveOrder = () => {
1043
+ if (!orderKey) return;
1044
+ writeStore(orderKey, JSON.stringify(order));
1045
+ };
1046
+ let flights = [];
1047
+ function arrange(next) {
1048
+ if (!checked.every(Boolean)) cancelFx();
1049
+ const from = calm ? null : tiles.map((t) => t.getBoundingClientRect());
1050
+ flights.forEach((a) => a.cancel());
1051
+ flights = [];
1052
+ place(next);
1053
+ lines.forEach((l) => {
1054
+ if (l.every((i) => got.has(i))) gotL.add(lineKey(l));
1055
+ });
1056
+ const done = completeLines();
1057
+ done.forEach((k) => gotL.add(lineKey(lines[k])));
1058
+ prevLines = new Set(done);
1059
+ render(null, true);
1060
+ saveOrder();
1061
+ if (!from || typeof Element.prototype.animate !== "function") return;
1062
+ tiles.forEach((t, i) => {
1063
+ const a = from[i], b = t.getBoundingClientRect();
1064
+ const dx = a.left - b.left, dy = a.top - b.top;
1065
+ if (Math.abs(dx) + Math.abs(dy) < 1) return;
1066
+ flights.push(
1067
+ t.animate([{ transform: `translate(${dx}px,${dy}px)` }, { transform: "none" }], {
1068
+ duration: FLY_MS - 140,
1069
+ delay: Math.random() * 140,
1070
+ easing: "cubic-bezier(.3,1.25,.5,1)",
1071
+ fill: "backwards"
1072
+ })
1073
+ );
1074
+ });
1075
+ clearTimeout(landing);
1076
+ landing = window.setTimeout(() => {
1077
+ landing = 0;
1078
+ flights = [];
1079
+ if (!dead) drawLines(cur);
1080
+ }, FLY_MS);
1081
+ svg.innerHTML = "";
1082
+ }
1083
+ function shuffleCard() {
1084
+ let next = order.slice();
1085
+ do {
1086
+ for (let s = N - 1; s > 0; s--) {
1087
+ const r = Math.floor(Math.random() * (s + 1));
1088
+ [next[s], next[r]] = [next[r], next[s]];
1089
+ }
1090
+ } while (next.every((v, s) => v === order[s]));
1091
+ arrange(next);
1092
+ sfx.shuffle();
1093
+ buzz([10, 40, 10, 40, 10, 40, 24]);
1094
+ }
1095
+ const unshuffle = () => {
1096
+ if (!isIdentity(order)) arrange(identity());
1097
+ };
997
1098
  grid.addEventListener("change", (e) => {
998
1099
  const i = ins.indexOf(e.target);
999
1100
  if (i < 0) return;
@@ -1001,7 +1102,11 @@ function createPackingBingo(container, options = {}) {
1001
1102
  next[i] = !next[i];
1002
1103
  request(next, i);
1003
1104
  });
1004
- $("reset").addEventListener("click", () => request(normalize(void 0), null, { reset: true }));
1105
+ container.querySelector('[data-r="shuf"]')?.addEventListener("click", shuffleCard);
1106
+ $("reset").addEventListener("click", () => {
1107
+ unshuffle();
1108
+ request(normalize(void 0), null, { reset: true });
1109
+ });
1005
1110
  $("close").addEventListener("click", () => perfectEl.classList.remove("pkb-show"));
1006
1111
  perfectEl.addEventListener("click", (e) => {
1007
1112
  if (e.target === perfectEl) perfectEl.classList.remove("pkb-show");
@@ -1040,9 +1145,21 @@ function createPackingBingo(container, options = {}) {
1040
1145
  void run(miss.map((i, k) => k < miss.length - 1 ? { i, delay: 170, quiet: true } : { i, delay: 450 }));
1041
1146
  });
1042
1147
  $("dR").addEventListener("click", () => {
1043
- if (!busy) request(demoStart(), null, { reset: true });
1148
+ if (busy) return;
1149
+ unshuffle();
1150
+ request(demoStart(), null, { reset: true });
1044
1151
  });
1045
1152
  }
1153
+ let startOrder = null;
1154
+ if (orderKey) {
1155
+ try {
1156
+ const saved = JSON.parse(readStore(orderKey) ?? "null");
1157
+ if (validOrder(saved)) startOrder = saved;
1158
+ } catch {
1159
+ }
1160
+ }
1161
+ if (!startOrder && validOrder(options.defaultOrder)) startOrder = options.defaultOrder.slice();
1162
+ if (startOrder && !isIdentity(startOrder)) place(startOrder);
1046
1163
  let initial = null;
1047
1164
  if (storageKey) {
1048
1165
  try {
@@ -1074,19 +1191,35 @@ function createPackingBingo(container, options = {}) {
1074
1191
  flush(apply(n2, changed[0], false));
1075
1192
  },
1076
1193
  reset() {
1077
- if (!dead) request(normalize(void 0), null, { reset: true });
1194
+ if (dead) return;
1195
+ unshuffle();
1196
+ request(normalize(void 0), null, { reset: true });
1197
+ },
1198
+ shuffle() {
1199
+ if (!dead) shuffleCard();
1078
1200
  },
1079
1201
  refresh() {
1080
1202
  if (!dead) render(null, true);
1081
1203
  },
1082
1204
  getState() {
1083
- return { checked: checked.slice(), lines: cur.slice(), score, maxCombo, complete: checked.every(Boolean), size: n, free: L.free };
1205
+ return {
1206
+ checked: checked.slice(),
1207
+ lines: cur.slice(),
1208
+ score,
1209
+ maxCombo,
1210
+ complete: checked.every(Boolean),
1211
+ size: n,
1212
+ free: L.free,
1213
+ order: order.slice()
1214
+ };
1084
1215
  },
1085
1216
  destroy() {
1086
1217
  if (dead) return;
1087
1218
  dead = true;
1088
1219
  timers.forEach((id) => clearTimeout(id));
1089
1220
  timers.clear();
1221
+ clearTimeout(landing);
1222
+ flights.forEach((a) => a.cancel());
1090
1223
  cancelAnimationFrame(raf);
1091
1224
  cancelAnimationFrame(tw);
1092
1225
  ro?.disconnect();
package/dist/core.d.cts CHANGED
@@ -21,6 +21,7 @@ declare function bingoLines(size: number, free?: boolean): number[][];
21
21
  /** Every piece of UI text. Pass a partial object as `messages` to override some of them. */
22
22
  interface Messages {
23
23
  title: string;
24
+ /** A label above the title; empty (not shown) by default. */
24
25
  tag: string;
25
26
  /** A line under the title; empty (not shown) by default. */
26
27
  subtitle: string;
@@ -30,6 +31,8 @@ interface Messages {
30
31
  combo: (n: number) => string;
31
32
  comboPop: (n: number) => string;
32
33
  reset: string;
34
+ /** The button that moves the items to random squares. */
35
+ shuffle: string;
33
36
  soundOn: string;
34
37
  soundOff: string;
35
38
  stamp: string;
@@ -45,7 +48,7 @@ interface Messages {
45
48
  /** The status line before the first line is complete; empty by default. */
46
49
  statusStart: (left: number) => string;
47
50
  statusLines: (lines: number, left: number) => string;
48
- /** `labels` are the items that would each complete a line right now. */
51
+ /** `labels` are the items that would each complete a line right now; empty by default. */
49
52
  statusReach: (labels: string[]) => string;
50
53
  statusDone: string;
51
54
  perfectTag: string;
@@ -86,6 +89,11 @@ interface PackingBingoState {
86
89
  size: number;
87
90
  /** Whether the center is a FREE square. */
88
91
  free: boolean;
92
+ /**
93
+ * The item in each slot (row by row, skipping the FREE square): `[0, 1, 2, …]` until the card
94
+ * is shuffled. Line `k` holds the items `bingoLines(size, free)[k].map((s) => order[s])`.
95
+ */
96
+ order: number[];
89
97
  }
90
98
  interface BingoEvent {
91
99
  /** Lines (indices into `bingoLines(size, free)`, which is LINES for 4×4) completed by this change. */
@@ -133,7 +141,27 @@ interface PackingBingoOptions {
133
141
  storageKey?: string | null;
134
142
  /** Start with sound on (default) or off. A choice made with the sound button is remembered. */
135
143
  sound?: boolean;
136
- /** Load the display fonts from Google Fonts (default true). */
144
+ /**
145
+ * Show the 🔀 button that moves the items to random squares (default true). Checks stay with
146
+ * their items. The arrangement is saved next to the checks, under "packing-bingo-order:" +
147
+ * the storage key.
148
+ */
149
+ shuffle?: boolean;
150
+ /**
151
+ * The starting arrangement, as in `getState().order`, used when no shuffled arrangement is
152
+ * saved. Ignored unless it lists every item index exactly once.
153
+ */
154
+ defaultOrder?: readonly number[];
155
+ /**
156
+ * Buzz on phones (default true): a short buzz on each tap and longer patterns for a bingo, a
157
+ * perfect card and a shuffle where the Vibration API exists (Android), and the system tap
158
+ * haptic on iPhone (iOS 18 and later), which only follows a real tap.
159
+ */
160
+ vibrate?: boolean;
161
+ /**
162
+ * @deprecated Ignored since 0.4.0: the card no longer loads fonts. To get Dela Gothic One and
163
+ * M PLUS Rounded 1c, load them on the page (see the README's フォント section).
164
+ */
137
165
  fonts?: boolean;
138
166
  /** Pre-fill a few items and show demo buttons. */
139
167
  demo?: boolean;
@@ -149,8 +177,13 @@ interface PackingBingoInstance {
149
177
  setChecked(next: readonly boolean[], options?: {
150
178
  animate?: boolean;
151
179
  }): void;
152
- /** Clear the card and score (calls onChange). */
180
+ /** Clear the card and score (calls onChange), and put the items back in their own squares. */
153
181
  reset(): void;
182
+ /**
183
+ * Move the items to random squares, as the 🔀 button does. Checks stay with their items; lines
184
+ * the new arrangement happens to complete count as done but earn no points.
185
+ */
186
+ shuffle(): void;
154
187
  getState(): PackingBingoState;
155
188
  /** Redraw the status text, e.g. after functions in `messages` start returning something else. */
156
189
  refresh(): void;
package/dist/core.d.ts CHANGED
@@ -21,6 +21,7 @@ declare function bingoLines(size: number, free?: boolean): number[][];
21
21
  /** Every piece of UI text. Pass a partial object as `messages` to override some of them. */
22
22
  interface Messages {
23
23
  title: string;
24
+ /** A label above the title; empty (not shown) by default. */
24
25
  tag: string;
25
26
  /** A line under the title; empty (not shown) by default. */
26
27
  subtitle: string;
@@ -30,6 +31,8 @@ interface Messages {
30
31
  combo: (n: number) => string;
31
32
  comboPop: (n: number) => string;
32
33
  reset: string;
34
+ /** The button that moves the items to random squares. */
35
+ shuffle: string;
33
36
  soundOn: string;
34
37
  soundOff: string;
35
38
  stamp: string;
@@ -45,7 +48,7 @@ interface Messages {
45
48
  /** The status line before the first line is complete; empty by default. */
46
49
  statusStart: (left: number) => string;
47
50
  statusLines: (lines: number, left: number) => string;
48
- /** `labels` are the items that would each complete a line right now. */
51
+ /** `labels` are the items that would each complete a line right now; empty by default. */
49
52
  statusReach: (labels: string[]) => string;
50
53
  statusDone: string;
51
54
  perfectTag: string;
@@ -86,6 +89,11 @@ interface PackingBingoState {
86
89
  size: number;
87
90
  /** Whether the center is a FREE square. */
88
91
  free: boolean;
92
+ /**
93
+ * The item in each slot (row by row, skipping the FREE square): `[0, 1, 2, …]` until the card
94
+ * is shuffled. Line `k` holds the items `bingoLines(size, free)[k].map((s) => order[s])`.
95
+ */
96
+ order: number[];
89
97
  }
90
98
  interface BingoEvent {
91
99
  /** Lines (indices into `bingoLines(size, free)`, which is LINES for 4×4) completed by this change. */
@@ -133,7 +141,27 @@ interface PackingBingoOptions {
133
141
  storageKey?: string | null;
134
142
  /** Start with sound on (default) or off. A choice made with the sound button is remembered. */
135
143
  sound?: boolean;
136
- /** Load the display fonts from Google Fonts (default true). */
144
+ /**
145
+ * Show the 🔀 button that moves the items to random squares (default true). Checks stay with
146
+ * their items. The arrangement is saved next to the checks, under "packing-bingo-order:" +
147
+ * the storage key.
148
+ */
149
+ shuffle?: boolean;
150
+ /**
151
+ * The starting arrangement, as in `getState().order`, used when no shuffled arrangement is
152
+ * saved. Ignored unless it lists every item index exactly once.
153
+ */
154
+ defaultOrder?: readonly number[];
155
+ /**
156
+ * Buzz on phones (default true): a short buzz on each tap and longer patterns for a bingo, a
157
+ * perfect card and a shuffle where the Vibration API exists (Android), and the system tap
158
+ * haptic on iPhone (iOS 18 and later), which only follows a real tap.
159
+ */
160
+ vibrate?: boolean;
161
+ /**
162
+ * @deprecated Ignored since 0.4.0: the card no longer loads fonts. To get Dela Gothic One and
163
+ * M PLUS Rounded 1c, load them on the page (see the README's フォント section).
164
+ */
137
165
  fonts?: boolean;
138
166
  /** Pre-fill a few items and show demo buttons. */
139
167
  demo?: boolean;
@@ -149,8 +177,13 @@ interface PackingBingoInstance {
149
177
  setChecked(next: readonly boolean[], options?: {
150
178
  animate?: boolean;
151
179
  }): void;
152
- /** Clear the card and score (calls onChange). */
180
+ /** Clear the card and score (calls onChange), and put the items back in their own squares. */
153
181
  reset(): void;
182
+ /**
183
+ * Move the items to random squares, as the 🔀 button does. Checks stay with their items; lines
184
+ * the new arrangement happens to complete count as done but earn no points.
185
+ */
186
+ shuffle(): void;
154
187
  getState(): PackingBingoState;
155
188
  /** Redraw the status text, e.g. after functions in `messages` start returning something else. */
156
189
  refresh(): void;
package/dist/core.js CHANGED
@@ -6,7 +6,7 @@ import {
6
6
  createPackingBingo,
7
7
  en,
8
8
  ja
9
- } from "./chunk-Z34GMJW2.js";
9
+ } from "./chunk-BAA2CFUA.js";
10
10
  export {
11
11
  DEFAULT_ITEMS,
12
12
  LINES,