packing-bingo 0.2.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.
@@ -2,38 +2,109 @@
2
2
 
3
3
  // src/items.ts
4
4
  var DEFAULT_ITEMS = [
5
- { emoji: "\u{1F4D6}", label: "\u65C5\u306E\u3057\u304A\u308A" },
6
- { emoji: "\u{1FAAA}", label: "\u751F\u5F92\u624B\u5E33" },
7
- { emoji: "\u{1F3E5}", label: "\u4FDD\u967A\u8A3C\u30B3\u30D4\u30FC" },
8
- { emoji: "\u{1F45B}", label: "\u304A\u3053\u3065\u304B\u3044" },
9
- { emoji: "\u{1F455}", label: "\u7740\u66FF\u3048" },
10
- { emoji: "\u{1F319}", label: "\u30D1\u30B8\u30E3\u30DE" },
11
- { emoji: "\u{1F9E5}", label: "\u4E0A\u7740" },
12
- { emoji: "\u{1FAA5}", label: "\u6D17\u9762\u7528\u5177" },
13
- { emoji: "\u{1F6C1}", label: "\u30BF\u30AA\u30EB" },
14
- { emoji: "\u{1F927}", label: "\u30CF\u30F3\u30AB\u30C1\u30FB\u30C6\u30A3\u30C3\u30B7\u30E5" },
5
+ { emoji: "\u{1F45B}", label: "\u8CA1\u5E03" },
6
+ { emoji: "\u{1F4F1}", label: "\u30B9\u30DE\u30DB" },
7
+ { emoji: "\u{1F50B}", label: "\u30E2\u30D0\u30A4\u30EB\u200B\u5145\u96FB\u5668" },
8
+ { emoji: "\u{1F4CB}", label: "\u5065\u5EB7\u8ABF\u67FB\u7968" },
9
+ { emoji: "\u{1FAAA}", label: "\u30DE\u30A4\u30CA\u30AB\u30FC\u30C9\u200B\u307E\u305F\u306F\u4FDD\u967A\u8A3C" },
10
+ { emoji: "\u{1F4D6}", label: "\u3057\u304A\u308A" },
15
11
  { emoji: "\u{1F637}", label: "\u30DE\u30B9\u30AF" },
16
- { emoji: "\u{1F48A}", label: "\u5E38\u5099\u85AC\u30FB\u9154\u3044\u6B62\u3081" },
17
- { emoji: "\u2602\uFE0F", label: "\u6298\u308A\u305F\u305F\u307F\u5098" },
18
- { emoji: "\u270F\uFE0F", label: "\u7B46\u8A18\u7528\u5177" },
12
+ { emoji: "\u{1F927}", label: "\u30CF\u30F3\u30AB\u30C1" },
13
+ { emoji: "\u{1F48A}", label: "\u5E38\u5099\u85AC" },
19
14
  { emoji: "\u{1F6CD}\uFE0F", label: "\u30D3\u30CB\u30FC\u30EB\u888B" },
20
- { emoji: "\u{1F4F1}", label: "\u30B9\u30DE\u30DB\u30FB\u5145\u96FB\u5668" }
15
+ { emoji: "\u{1F9E4}", label: "\u8ECD\u624B\uFF08\u5B66\u6821\u652F\u7D66\uFF09" },
16
+ { emoji: "\u{1F526}", label: "\u30D8\u30C3\u30C9\u30E9\u30A4\u30C8" },
17
+ { emoji: "\u{1F455}", label: "\u30B8\u30E3\u30FC\u30B8" },
18
+ { emoji: "\u2614", label: "\u96E8\u5177" },
19
+ { emoji: "\u{1F6C1}", label: "\u30BF\u30AA\u30EB" },
20
+ { emoji: "\u{1F9F4}", label: "\u65E5\u713C\u3051\u6B62\u3081" }
21
21
  ];
22
22
 
23
+ // src/layout.ts
24
+ var MIN_SIZE = 2;
25
+ var MAX_SIZE = 10;
26
+ var fail = (msg) => {
27
+ throw new Error(`[packing-bingo] ${msg}`);
28
+ };
29
+ function layoutItems(options) {
30
+ if (options.items) return options.items;
31
+ const n = options.size;
32
+ if (n == null || !Number.isInteger(n) || n < MIN_SIZE) return DEFAULT_ITEMS;
33
+ return DEFAULT_ITEMS.slice(0, n * n - (options.free ? 1 : 0));
34
+ }
35
+ function checkSize(size, free) {
36
+ if (!Number.isInteger(size) || size < MIN_SIZE || size > MAX_SIZE)
37
+ fail(`"size" must be a whole number from ${MIN_SIZE} to ${MAX_SIZE} (got ${size}).`);
38
+ if (free && size % 2 === 0) fail(`"free" needs an odd size such as 3 or 5, so that the card has a center (got ${size}).`);
39
+ }
40
+ function shape(n, free) {
41
+ const seq = (f) => Array.from({ length: n }, (_, k) => f(k));
42
+ const cellLines = [
43
+ ...seq((r) => seq((c) => r * n + c)),
44
+ // rows
45
+ ...seq((c) => seq((r) => r * n + c)),
46
+ // columns
47
+ seq((k) => k * (n + 1)),
48
+ // top left to bottom right
49
+ seq((k) => (k + 1) * (n - 1))
50
+ // top right to bottom left
51
+ ];
52
+ const center = free ? (n * n - 1) / 2 : -1;
53
+ const toItem = (c) => free && c > center ? c - 1 : c;
54
+ return { center, cellLines, lines: cellLines.map((l) => l.filter((c) => c !== center).map(toItem)) };
55
+ }
56
+ function resolveLayout(options) {
57
+ const items = layoutItems(options);
58
+ const free = !!options.free;
59
+ let size = options.size;
60
+ if (size == null) {
61
+ const len = items.length;
62
+ const sideOf = (cells) => {
63
+ const r = Math.round(Math.sqrt(cells));
64
+ return r * r === cells && r >= MIN_SIZE && r <= MAX_SIZE ? r : 0;
65
+ };
66
+ size = sideOf(len + (free ? 1 : 0));
67
+ if (!size) {
68
+ const other = sideOf(len + (free ? 0 : 1));
69
+ const hint = free ? other ? `; ${len} items fill a ${other}\xD7${other} card without "free"` : "" : other % 2 ? `, or set "free" for a ${other}\xD7${other} card with a FREE center` : "";
70
+ fail(
71
+ free ? `With "free", "items" must have one less than an odd square number of entries (8, 24, 48, 80)${hint} (got ${len}).` : `"items" must have a square number of entries (4, 9, 16, 25, \u2026, 100)${hint} (got ${len}).`
72
+ );
73
+ }
74
+ }
75
+ checkSize(size, free);
76
+ const want = size * size - (free ? 1 : 0);
77
+ if (items.length !== want) {
78
+ const card = `a ${size}\xD7${size} card${free ? " with a FREE center" : ""}`;
79
+ fail(
80
+ options.items ? `"items" must have exactly ${want} entries for ${card} (got ${items.length}).` : `A ${card.slice(2)} needs ${want} items, but DEFAULT_ITEMS has only ${DEFAULT_ITEMS.length}. Pass "items".`
81
+ );
82
+ }
83
+ const { center, cellLines, lines } = shape(size, free);
84
+ const itemCell = items.map((_, i) => free && i >= center ? i + 1 : i);
85
+ return { size, free, items, itemCell, center, cellLines, lines };
86
+ }
87
+ function bingoLines(size, free = false) {
88
+ checkSize(size, free);
89
+ return shape(size, free).lines;
90
+ }
91
+
23
92
  // src/messages.ts
24
93
  var ja = {
25
94
  title: "\u6301\u3061\u3082\u306E\u30D3\u30F3\u30B4",
26
- tag: "\u4FEE\u5B66\u65C5\u884C",
27
- subtitle: "\u30BF\u30C3\u30D7\u3067\u30B9\u30BF\u30F3\u30D7\u3002\u30BF\u30C6\u30FB\u30E8\u30B3\u30FB\u30CA\u30CA\u30E1\u3067\u30D3\u30F3\u30B4\uFF01",
95
+ tag: "",
96
+ subtitle: "",
28
97
  packed: "\u305D\u308D\u3063\u305F",
29
98
  bingo: "BINGO",
30
99
  score: "SCORE",
31
100
  combo: (n) => `${n} COMBO`,
32
101
  comboPop: (n) => `${n} COMBO!`,
33
102
  reset: "\u30EA\u30BB\u30C3\u30C8",
103
+ shuffle: "\u{1F500} \u30B7\u30E3\u30C3\u30D5\u30EB",
34
104
  soundOn: "\u{1F50A} \u97F3\u3042\u308A",
35
105
  soundOff: "\u{1F507} \u97F3\u306A\u3057",
36
106
  stamp: "OK",
107
+ free: "FREE",
37
108
  reachBadge: "\u30EA\u30FC\u30C1",
38
109
  reachPop: "\u30EA\u30FC\u30C1!",
39
110
  bannerSingle: "BINGO!",
@@ -41,14 +112,15 @@ var ja = {
41
112
  bannerTriple: "\u30C8\u30EA\u30D7\u30EB\u30D3\u30F3\u30B4!",
42
113
  bannerNth: (n) => `${n} \u30E9\u30A4\u30F3\u76EE\uFF01`,
43
114
  bannerMulti: (n) => `\u4E00\u6C17\u306B ${n} \u30E9\u30A4\u30F3\uFF01`,
44
- statusStart: (left) => `\u306E\u3053\u308A ${left} \u3053\u3002\u307E\u305A\u306F1\u30E9\u30A4\u30F3\u305D\u308D\u3048\u3088\u3046`,
115
+ statusStart: () => "",
45
116
  statusLines: (lines, left) => `\u30D3\u30F3\u30B4 ${lines} \u30E9\u30A4\u30F3\uFF01 \u306E\u3053\u308A ${left} \u3053`,
46
- 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`,
117
+ statusReach: () => "",
47
118
  statusDone: "\u{1F389} \u30D1\u30FC\u30D5\u30A7\u30AF\u30C8\uFF01\u5FD8\u308C\u7269\u30BC\u30ED\u3067\u51FA\u767A\u3067\u304D\u307E\u3059",
48
119
  perfectTag: "\u{1F3C6} PERFECT",
49
120
  perfectStamp: "\u6E96\u5099\n\u5B8C\u4E86",
50
121
  perfectTitle: "\u30D1\u30FC\u30D5\u30A7\u30AF\u30C8\uFF01",
51
- perfectBody: "16\u3053 \u305C\u3093\u3076\u305D\u308D\u3063\u305F\uFF01\n\u5FD8\u308C\u7269\u30BC\u30ED\u3067\u3001\u3044\u3063\u3066\u3089\u3063\u3057\u3083\u3044\uFF01",
122
+ perfectBody: (n) => `${n}\u3053 \u305C\u3093\u3076\u305D\u308D\u3063\u305F\uFF01
123
+ \u5FD8\u308C\u7269\u30BC\u30ED\u3067\u3001\u3044\u3063\u3066\u3089\u3063\u3057\u3083\u3044\uFF01`,
52
124
  perfectBonus: (n) => `PERFECT BONUS +${n}`,
53
125
  statScore: "SCORE",
54
126
  statCombo: "MAX COMBO",
@@ -61,17 +133,19 @@ var ja = {
61
133
  };
62
134
  var en = {
63
135
  title: "Packing Bingo",
64
- tag: "School trip",
65
- subtitle: "Tap to stamp. Rows, columns and diagonals all count!",
136
+ tag: "",
137
+ subtitle: "",
66
138
  packed: "PACKED",
67
139
  bingo: "BINGO",
68
140
  score: "SCORE",
69
141
  combo: (n) => `${n} COMBO`,
70
142
  comboPop: (n) => `${n} COMBO!`,
71
143
  reset: "Reset",
144
+ shuffle: "\u{1F500} Shuffle",
72
145
  soundOn: "\u{1F50A} Sound on",
73
146
  soundOff: "\u{1F507} Muted",
74
147
  stamp: "OK",
148
+ free: "FREE",
75
149
  reachBadge: "REACH",
76
150
  reachPop: "Reach!",
77
151
  bannerSingle: "BINGO!",
@@ -79,14 +153,15 @@ var en = {
79
153
  bannerTriple: "TRIPLE BINGO!",
80
154
  bannerNth: (n) => `Line ${n}!`,
81
155
  bannerMulti: (n) => `${n} lines at once!`,
82
- statusStart: (left) => `${left} to go. Finish a line first!`,
156
+ statusStart: () => "",
83
157
  statusLines: (lines, left) => `${lines} bingo line${lines === 1 ? "" : "s"}! ${left} to go`,
84
- statusReach: (labels) => `Reach! Pack ${labels.slice(0, 2).join(" or ")}${labels.length > 2 ? " (or more)" : ""} for a bingo`,
158
+ statusReach: () => "",
85
159
  statusDone: "\u{1F389} Perfect! Nothing left behind.",
86
160
  perfectTag: "\u{1F3C6} PERFECT",
87
161
  perfectStamp: "ALL\nSET",
88
162
  perfectTitle: "Perfect!",
89
- perfectBody: "All 16 packed!\nHave a great trip!",
163
+ perfectBody: (n) => `All ${n} packed!
164
+ Have a great trip!`,
90
165
  perfectBonus: (n) => `PERFECT BONUS +${n}`,
91
166
  statScore: "SCORE",
92
167
  statCombo: "MAX COMBO",
@@ -186,6 +261,11 @@ function createSound(isMuted) {
186
261
  },
187
262
  tick: () => tone(1760, 0, 0.03, "square", 0.03),
188
263
  note: (k, at = 0) => tone(step(k), at, 0.12, "square", 0.05),
264
+ shuffle() {
265
+ for (let i = 0; i < 7; i++) noise(i * 0.042, 0.05, 0.16, 1600 + i * 260);
266
+ tone(hz(7), 0.34, 0.12, "triangle", 0.14);
267
+ tone(hz(12), 0.42, 0.2, "triangle", 0.14);
268
+ },
189
269
  close() {
190
270
  if (ac) void ac.close().catch(() => {
191
271
  });
@@ -200,7 +280,7 @@ function resolveStorageKey(options) {
200
280
  if (options.controlled) return null;
201
281
  if (options.storageKey !== void 0) return options.storageKey || null;
202
282
  if (options.demo) return null;
203
- return itemsKey(options.items ?? DEFAULT_ITEMS);
283
+ return itemsKey(layoutItems(options));
204
284
  }
205
285
  function itemsKey(items) {
206
286
  const text = items.map((it) => `${it.emoji}\0${it.label}`).join("");
@@ -213,24 +293,24 @@ function itemsKey(items) {
213
293
  }
214
294
 
215
295
  // src/styles.ts
216
- var FONT_URL = "https://fonts.googleapis.com/css2?family=Dela+Gothic+One&family=M+PLUS+Rounded+1c:wght@500;800&display=swap";
217
296
  var CSS = `
218
- .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);container-type:inline-size;isolation:isolate;width:100%}
297
+ .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%}
219
298
  .pkb,.pkb *{box-sizing:border-box;margin:0;padding:0}
220
299
  .pkb-board{position:relative;overflow:hidden;max-width:720px;margin:0 auto;padding:22px 26px 20px;border-radius:24px;background-color:var(--pk-sun);background-image:radial-gradient(rgba(42,31,20,.12) 1.3px,transparent 1.6px);background-size:16px 16px;border:2.5px solid var(--pk-ink);color:var(--pk-ink);font-family:var(--pk-body);line-height:1.4;text-align:left}
221
300
  .pkb-board.pkb-shake{animation:pkb-shake .45s cubic-bezier(.36,.07,.19,.97)}
222
301
  @keyframes pkb-shake{10%,90%{transform:translate(-1px,1px)}20%,80%{transform:translate(3px,-2px)}30%,50%,70%{transform:translate(-6px,2px)}40%,60%{transform:translate(6px,-1px)}}
223
302
  .pkb-board.pkb-punch{animation:pkb-punch .4s cubic-bezier(.3,1.6,.5,1)}
224
303
  @keyframes pkb-punch{30%{transform:scale(1.018)}}
225
- .pkb-head{display:flex;justify-content:space-between;align-items:center;gap:16px;margin-bottom:18px}
304
+ .pkb-head{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:center;gap:12px 16px;margin-bottom:18px}
226
305
  .pkb-tags{display:flex;gap:8px;align-items:center}
227
306
  .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}
307
+ .pkb-kicker:empty{display:none}
228
308
  .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}
229
309
  @keyframes pkb-rb{to{background-position:-200% 0}}
230
310
  .pkb button{font-family:var(--pk-body)}
231
311
  .pkb-snd{appearance:none;border:2px solid var(--pk-ink);background:var(--pk-paper);border-radius:999px;padding:2px 9px;font-weight:800;font-size:11px;line-height:1.4;color:var(--pk-ink);cursor:pointer}
232
312
  .pkb-title{font-family:var(--pk-display);font-weight:400;font-size:34px;line-height:1.15;margin-top:8px;letter-spacing:.02em;color:inherit}
233
- .pkb-sub{font-weight:800;font-size:13px;margin-top:4px}
313
+ .pkb-sub{font-weight:800;font-size:13px;margin-top:4px;word-break:auto-phrase}
234
314
  .pkb-side{display:flex;flex-direction:column;gap:8px;flex:none}
235
315
  .pkb-score{display:flex;gap:10px}
236
316
  .pkb-pill{min-width:78px;background:var(--pk-paper);border:2.5px solid var(--pk-ink);border-radius:14px;padding:6px 10px 5px;text-align:center;box-shadow:3px 3px 0 var(--pk-ink)}
@@ -249,16 +329,18 @@ var CSS = `
249
329
  .pkb-combo.pkb-on .pkb-bar i{animation:pkb-drain 2.5s linear forwards}
250
330
  @keyframes pkb-drain{from{transform:scaleX(1)}to{transform:scaleX(0)}}
251
331
  .pkb-gw{position:relative}
252
- .pkb-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:12px}
253
- .pkb-tile{position:relative;aspect-ratio:1/.82;background:var(--pk-paper);border:2.5px solid var(--pk-ink);border-radius:16px;box-shadow:4px 4px 0 var(--pk-ink);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;cursor:pointer;user-select:none;-webkit-tap-highlight-color:transparent;transition:transform .1s,box-shadow .1s,background-color .25s}
254
- .pkb-tile:active{transform:translate(3px,3px);box-shadow:1px 1px 0 var(--pk-ink)}
332
+ .pkb-grid{display:grid;grid-template-columns:repeat(var(--pk-n),minmax(0,1fr));gap:calc(12px*var(--pk-d))}
333
+ .pkb-tile{position:relative;min-width:0;place-self:stretch;aspect-ratio:1/.82;background:var(--pk-paper);border:2.5px solid var(--pk-ink);border-radius:calc(16px*var(--pk-d));box-shadow:4px 4px 0 var(--pk-ink);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:calc(6px*var(--pk-d));cursor:pointer;user-select:none;-webkit-tap-highlight-color:transparent;transition:transform .1s,box-shadow .1s,background-color .25s}
334
+ .pkb-tile:active:not(.pkb-free){transform:translate(3px,3px);box-shadow:1px 1px 0 var(--pk-ink)}
255
335
  .pkb-tile input{position:absolute;opacity:0;pointer-events:none;width:1px;height:1px}
256
336
  .pkb-tile:has(input:focus-visible){outline:3px solid var(--pk-ink);outline-offset:3px}
257
- .pkb-emo{font-size:32px;line-height:1;transition:opacity .2s}
258
- .pkb-lbl{font-weight:800;font-size:13px;line-height:1.25;text-align:center;padding:0 6px;transition:opacity .2s;word-break:keep-all;overflow-wrap:anywhere}
259
- .pkb-stamp{position:absolute;left:50%;top:50%;width:64%;aspect-ratio:1;border-radius:50%;border:3px solid var(--pk-red);box-shadow:inset 0 0 0 2px var(--pk-paper),inset 0 0 0 4px var(--pk-red);color:var(--pk-red);display:grid;place-items:center;font-family:var(--pk-display);font-size:20px;letter-spacing:.04em;opacity:0;transform:translate(-50%,-50%) rotate(-16deg) scale(1.7);transition:transform .25s cubic-bezier(.3,1.5,.5,1),opacity .15s;pointer-events:none}
337
+ .pkb-emo{font-size:max(14px,calc(32px*var(--pk-s)));line-height:1;transition:opacity .2s}
338
+ .pkb-lbl{font-weight:800;font-size:max(9px,calc(13px*var(--pk-s)));line-height:1.25;text-align:center;padding:0 calc(6px*var(--pk-d));transition:opacity .2s;word-break:keep-all;overflow-wrap:anywhere}
339
+ .pkb-stamp{position:absolute;left:50%;top:50%;width:64%;aspect-ratio:1;border-radius:50%;border:3px solid var(--pk-red);box-shadow:inset 0 0 0 2px var(--pk-paper),inset 0 0 0 4px var(--pk-red);color:var(--pk-red);display:grid;place-items:center;font-family:var(--pk-display);font-size:max(9px,calc(20px*var(--pk-s)));letter-spacing:.04em;opacity:0;transform:translate(-50%,-50%) rotate(-16deg) scale(1.7);transition:transform .25s cubic-bezier(.3,1.5,.5,1),opacity .15s;pointer-events:none}
260
340
  .pkb-tile.pkb-on .pkb-stamp{opacity:.9;transform:translate(-50%,-50%) rotate(-16deg) scale(1)}
261
341
  .pkb-tile.pkb-on .pkb-emo,.pkb-tile.pkb-on .pkb-lbl{opacity:.35}
342
+ .pkb-tile.pkb-free{cursor:default;container-type:inline-size}
343
+ .pkb-tile.pkb-free .pkb-stamp{width:72%;opacity:1;transition:none;font-size:17cqi;letter-spacing:.02em;line-height:1;text-align:center;background:var(--pk-red);color:var(--pk-paper);border-color:var(--pk-red);box-shadow:inset 0 0 0 2px var(--pk-red),inset 0 0 0 4px var(--pk-paper)}
262
344
  .pkb-tile.pkb-line{background-color:var(--pk-mint)}
263
345
  .pkb-tile.pkb-line::before{content:"";position:absolute;inset:0;border-radius:inherit;pointer-events:none;background:linear-gradient(110deg,transparent 30%,rgba(255,255,255,.9) 45%,transparent 60%) no-repeat;background-size:250% 100%;background-position:200% 0;animation:pkb-glint 3.2s ease-in-out var(--pkb-gd,0s) infinite}
264
346
  @keyframes pkb-glint{0%{background-position:200% 0}30%,100%{background-position:-100% 0}}
@@ -275,12 +357,17 @@ var CSS = `
275
357
  .pkb-lines{position:absolute;inset:0;width:100%;height:100%;pointer-events:none;overflow:visible;z-index:2;transition:opacity .4s}
276
358
  .pkb-board.pkb-all .pkb-lines{opacity:0}
277
359
  .pkb-lines line{fill:none;stroke-linecap:round;stroke-dasharray:1;stroke-dashoffset:0}
278
- .pkb-lines .pkb-u{stroke:var(--pk-paper);stroke-width:13}
279
- .pkb-lines .pkb-o{stroke:var(--pk-ink);stroke-width:6}
360
+ .pkb-lines .pkb-u{stroke:var(--pk-paper);stroke-width:calc(13px*var(--pk-d))}
361
+ .pkb-lines .pkb-o{stroke:var(--pk-ink);stroke-width:calc(6px*var(--pk-d))}
280
362
  .pkb-lines .pkb-draw line{animation:pkb-draw .45s ease-out .3s both}
281
363
  @keyframes pkb-draw{from{stroke-dashoffset:1}}
282
- .pkb-foot{display:flex;justify-content:space-between;align-items:center;gap:10px;margin-top:18px;font-weight:800;font-size:13px}
283
- .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}
364
+ .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}
365
+ .pkb-foot>span{flex:1 1 14em}
366
+ .pkb-foot>span:empty{flex-basis:0}
367
+ .pkb-acts{display:flex;gap:8px;flex:none;margin-left:auto}
368
+ .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}
369
+ .pkb-shuf{background:var(--pk-paper);box-shadow:2px 2px 0 var(--pk-ink)}
370
+ .pkb-shuf:active{transform:translate(2px,2px);box-shadow:none}
284
371
  .pkb-flash{position:absolute;inset:0;z-index:7;background:#fff;opacity:0;pointer-events:none}
285
372
  .pkb-flash.pkb-go{animation:pkb-flash .4s ease-out}
286
373
  @keyframes pkb-flash{0%{opacity:.8}100%{opacity:0}}
@@ -294,7 +381,7 @@ var CSS = `
294
381
  .pkb-float{position:absolute;z-index:11;pointer-events:none;white-space:nowrap;font-family:var(--pk-display);font-weight:400;line-height:1;-webkit-text-stroke:4px var(--pk-ink);paint-order:stroke fill;transform:translate(-50%,-50%);animation:pkb-rise .95s cubic-bezier(.2,.9,.3,1) both}
295
382
  .pkb-float.pkb-pt{font-size:22px;color:var(--pk-paper)}
296
383
  .pkb-float.pkb-cmb{font-size:17px}
297
- .pkb-float.pkb-big{font-size:clamp(20px,6cqi,34px);color:#FFE066}
384
+ .pkb-float.pkb-big{font-size:clamp(14px,5.6cqi,34px);color:#FFE066}
298
385
  .pkb-float.pkb-rch{font-size:20px;color:var(--pk-red);-webkit-text-stroke:4px var(--pk-paper)}
299
386
  @keyframes pkb-rise{0%{opacity:0;transform:translate(-50%,-20%) scale(.4)}18%{opacity:1;transform:translate(-50%,-60%) scale(1.2)}30%{transform:translate(-50%,-70%) scale(1)}75%{opacity:1}100%{opacity:0;transform:translate(-50%,-190%) scale(1)}}
300
387
  .pkb-perfect{position:absolute;inset:0;z-index:8;display:grid;place-items:center;padding:20px;background:rgba(42,31,20,.5);opacity:0;visibility:hidden;transition:opacity .3s,visibility .3s}
@@ -305,7 +392,7 @@ var CSS = `
305
392
  .pkb-perfect.pkb-show .pkb-medal{animation:pkb-slam .38s cubic-bezier(.5,0,.75,0) .35s forwards}
306
393
  @keyframes pkb-slam{to{opacity:1;transform:rotate(-14deg) scale(1)}}
307
394
  .pkb-ptitle{font-family:var(--pk-display);font-weight:400;font-size:30px;line-height:1.3;color:inherit}
308
- .pkb-pbody{font-weight:800;font-size:13.5px;line-height:1.6;margin:6px 0 14px;white-space:pre-line}
395
+ .pkb-pbody{font-weight:800;font-size:13.5px;line-height:1.6;margin:6px 0 14px;white-space:pre-line;word-break:auto-phrase}
309
396
  .pkb-stats{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-bottom:16px}
310
397
  .pkb-stats>div{border:2px solid var(--pk-ink);border-radius:12px;padding:6px 4px 5px;background:#fff}
311
398
  .pkb-stats small{display:block;font-size:9.5px;font-weight:800;letter-spacing:.1em}
@@ -315,32 +402,21 @@ var CSS = `
315
402
  .pkb-fx{position:absolute;inset:0;width:100%;height:100%;z-index:10;pointer-events:none}
316
403
  .pkb-demo{max-width:720px;margin:12px auto 4px;display:flex;flex-wrap:wrap;align-items:center;gap:8px;font-family:var(--pk-body);font-size:12px;color:#666}
317
404
  .pkb-demo button{appearance:none;border:1px solid #ccc;background:#f3f3f3;color:#222;border-radius:999px;padding:5px 12px;font-size:12px;cursor:pointer}
318
- @container (max-width:560px){.pkb-board{padding:18px 14px 16px}.pkb-head{flex-direction:column;align-items:stretch;gap:12px}.pkb-score{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}.pkb-pill,.pkb-pill.pkb-sc{min-width:0;padding:5px 4px 4px}.pkb-pill b{font-size:19px}.pkb-title{font-size:26px}.pkb-grid{gap:8px}.pkb-tile{border-radius:12px;box-shadow:3px 3px 0 var(--pk-ink);gap:3px;aspect-ratio:1/1}.pkb-emo{font-size:24px}.pkb-lbl{font-size:10.5px;padding:0 2px}.pkb-stamp{font-size:14px}.pkb-tile.pkb-reach::after{font-size:9px;padding:2px 5px;top:-9px;right:-5px}.pkb-lines .pkb-u{stroke-width:10}.pkb-lines .pkb-o{stroke-width:5}.pkb-banner::before{width:380px;height:380px;margin:-190px 0 0 -190px}.pkb-banner span{font-size:12px}.pkb-medal{width:96px;height:96px;font-size:20px}.pkb-card{padding:20px 14px 16px}.pkb-ptitle{font-size:24px}.pkb-stats b{font-size:17px}.pkb-float.pkb-pt{font-size:17px}.pkb-float.pkb-cmb{font-size:13px}}
405
+ @container (max-width:560px){.pkb-board{padding:18px 14px 16px}.pkb-head{flex-direction:column;align-items:stretch;gap:12px}.pkb-score{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}.pkb-pill,.pkb-pill.pkb-sc{min-width:0;padding:5px 4px 4px}.pkb-pill b{font-size:19px}.pkb-title{font-size:26px}.pkb-grid{gap:max(3px,calc(8px*var(--pk-d)))}.pkb-tile{border-radius:calc(12px*var(--pk-d));box-shadow:3px 3px 0 var(--pk-ink);gap:calc(3px*var(--pk-d));aspect-ratio:1/1}.pkb-emo{font-size:max(13px,calc(24px*var(--pk-s)))}.pkb-lbl{font-size:max(8.5px,calc(10.5px*var(--pk-s)));padding:0 calc(2px*var(--pk-d))}.pkb-stamp{font-size:max(9px,calc(14px*var(--pk-s)))}.pkb-tile.pkb-reach::after{font-size:9px;padding:2px 5px;top:-9px;right:-5px}.pkb-lines .pkb-u{stroke-width:calc(10px*var(--pk-d))}.pkb-lines .pkb-o{stroke-width:calc(5px*var(--pk-d))}.pkb-banner::before{width:380px;height:380px;margin:-190px 0 0 -190px}.pkb-banner span{font-size:12px}.pkb-medal{width:96px;height:96px;font-size:20px}.pkb-card{padding:20px 14px 16px}.pkb-ptitle{font-size:24px}.pkb-stats b{font-size:17px}.pkb-float.pkb-pt{font-size:17px}.pkb-float.pkb-cmb{font-size:13px}}
406
+ @container (max-width:400px){.pkb-board{padding:16px 10px 14px}.pkb-grid{gap:max(3px,calc(6px*var(--pk-d)))}.pkb-lbl{font-size:max(8.5px,calc(10px*var(--pk-s)));padding:0 calc(1px*var(--pk-d))}.pkb-emo{font-size:max(12px,calc(22px*var(--pk-s)))}.pkb-stamp{font-size:max(9px,calc(13px*var(--pk-s)))}.pkb-foot{margin-top:14px;font-size:12px}.pkb-perfect{padding:12px}.pkb-stats{gap:6px}.pkb-stats>div{padding:6px 2px 5px}.pkb-stats small{letter-spacing:.02em}}
407
+ @container (max-width:320px){.pkb-board{padding-inline:8px}.pkb-grid{gap:max(3px,calc(5px*var(--pk-d)))}.pkb-lbl{font-size:max(8.5px,calc(9.5px*var(--pk-s)));padding:0}}
319
408
  @media (prefers-reduced-motion:reduce){.pkb-board.pkb-shake,.pkb-board.pkb-punch,.pkb-flash.pkb-go,.pkb-tile.pkb-hop,.pkb-tile.pkb-squash,.pkb-tile.pkb-reach,.pkb-tile.pkb-line::before,.pkb-pill.pkb-hot,.pkb-bump,.pkb-tile.pkb-reach::after,.pkb-banner::before,.pkb-lines .pkb-draw line,.pkb-kicker.pkb-gold{animation:none}.pkb-banner.pkb-show{animation:pkb-fade 1.8s forwards}.pkb-float{animation:pkb-fade 1s both}.pkb-perfect.pkb-show .pkb-medal{animation:none;opacity:1;transform:rotate(-14deg)}.pkb-card{transition:none}}
320
409
  @keyframes pkb-fade{0%,100%{opacity:0}15%,80%{opacity:1}}
321
410
  `;
322
411
 
323
412
  // src/core.ts
324
- var LINES = [
325
- [0, 1, 2, 3],
326
- [4, 5, 6, 7],
327
- [8, 9, 10, 11],
328
- [12, 13, 14, 15],
329
- [0, 4, 8, 12],
330
- [1, 5, 9, 13],
331
- [2, 6, 10, 14],
332
- [3, 7, 11, 15],
333
- [0, 5, 10, 15],
334
- [3, 6, 9, 12]
335
- ];
336
- var DEMO_START = [0, 1, 2, 3, 5, 10];
413
+ var LINES = bingoLines(4);
337
414
  var CONFETTI = ["#E8473B", "#2F6FED", "#1FA971", "#FFFDF6", "#2A1F14", "#FF9F1C"];
338
415
  var COMBO_COLORS = ["#FF9F1C", "#1FA971", "#2F6FED", "#9B5DE5", "#E8473B"];
339
416
  var COMBO_MS = 2500;
340
417
  var MUTE_KEY = "packing-bingo-muted";
418
+ var ORDER_PREFIX = "packing-bingo-order:";
341
419
  var esc = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
342
- var normalize = (v) => Array.from({ length: 16 }, (_, i) => !!v?.[i]);
343
- var demoStart = () => Array.from({ length: 16 }, (_, i) => DEMO_START.includes(i));
344
420
  function readStore(key) {
345
421
  try {
346
422
  return localStorage.getItem(key);
@@ -354,30 +430,32 @@ function writeStore(key, value) {
354
430
  } catch {
355
431
  }
356
432
  }
357
- function ensureAssets(fonts) {
358
- if (!document.getElementById("pkb-styles")) {
359
- const s = document.createElement("style");
360
- s.id = "pkb-styles";
361
- s.textContent = CSS;
362
- document.head.appendChild(s);
363
- }
364
- if (fonts && !document.getElementById("pkb-fonts")) {
365
- const l = document.createElement("link");
366
- l.id = "pkb-fonts";
367
- l.rel = "stylesheet";
368
- l.href = FONT_URL;
369
- document.head.appendChild(l);
370
- }
433
+ var glintDelay = (c, n) => `${+((Math.floor(c / n) + c % n) * 0.36 / Math.max(1, n - 1)).toFixed(3)}s`;
434
+ var lineKey = (l) => l.slice().sort((a, b) => a - b).join(",");
435
+ var FLY_MS = 560;
436
+ function ensureStyles() {
437
+ if (document.getElementById("pkb-styles")) return;
438
+ const s = document.createElement("style");
439
+ s.id = "pkb-styles";
440
+ s.textContent = CSS;
441
+ document.head.appendChild(s);
371
442
  }
372
- function template(items, M, demo) {
373
- const tiles = items.map(
374
- (it, i) => `<label class="pkb-tile" data-reach="${esc(M.reachBadge)}" style="--pkb-gd:${((i >> 2) + (i & 3)) * 0.12}s"><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>`
375
- ).join("");
443
+ function template(L, M, { demo, shuffle, vibrate }) {
444
+ const n = L.size;
445
+ const input = vibrate ? `<input type="checkbox" switch role="checkbox">` : `<input type="checkbox">`;
446
+ const tiles = Array.from({ length: n * n }, (_, c) => {
447
+ const gd = `--pkb-gd:${glintDelay(c, n)}`;
448
+ if (c === L.center)
449
+ return `<div class="pkb-tile pkb-free pkb-on" style="${gd}"><span class="pkb-stamp">${esc(M.free)}</span></div>`;
450
+ const it = L.items[L.itemCell.indexOf(c)];
451
+ 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>`;
452
+ }).join("");
453
+ const scale = `--pk-n:${n};--pk-s:${+Math.min(1.35, 4 / n).toFixed(3)};--pk-d:${+Math.min(1, 4 / n).toFixed(3)}`;
376
454
  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>` : "";
377
- return `<div class="pkb"><div class="pkb-board">
455
+ return `<div class="pkb" style="${scale}"><div class="pkb-board">
378
456
  <div class="pkb-head"><div>
379
457
  <div class="pkb-tags"><span class="pkb-kicker" data-r="kick">${esc(M.tag)}</span><button type="button" class="pkb-snd" data-r="snd"></button></div>
380
- <h2 class="pkb-title">${esc(M.title)}</h2><p class="pkb-sub">${esc(M.subtitle)}</p></div>
458
+ <h2 class="pkb-title">${esc(M.title)}</h2>${M.subtitle ? `<p class="pkb-sub">${esc(M.subtitle)}</p>` : ""}</div>
381
459
  <div class="pkb-side"><div class="pkb-score">
382
460
  <div class="pkb-pill"><small>${esc(M.packed)}</small><b data-r="cnt"></b></div>
383
461
  <div class="pkb-pill" data-r="bp"><small>${esc(M.bingo)}</small><b data-r="bng"></b></div>
@@ -385,24 +463,44 @@ function template(items, M, demo) {
385
463
  <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>
386
464
  <div class="pkb-gw"><div class="pkb-grid">${tiles}</div><svg class="pkb-lines" aria-hidden="true"></svg>
387
465
  <div class="pkb-banner" data-r="banner" aria-hidden="true"><b data-r="bT"></b><span data-r="bS"></span></div></div>
388
- <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>
466
+ <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>
389
467
  <div class="pkb-flash" data-r="flash"></div>
390
468
  <div class="pkb-perfect" data-r="perfect"><div class="pkb-card" role="dialog" aria-label="${esc(M.perfectTitle)}">
391
- <div class="pkb-medal">${esc(M.perfectStamp)}</div><h3 class="pkb-ptitle">${esc(M.perfectTitle)}</h3><p class="pkb-pbody">${esc(M.perfectBody)}</p>
469
+ <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>
392
470
  <div class="pkb-stats"><div><small>${esc(M.statScore)}</small><b data-r="sScore">0</b></div><div><small>${esc(M.statCombo)}</small><b data-r="sCombo">\xD70</b></div><div><small>${esc(M.statLines)}</small><b data-r="sBingo">0</b></div></div>
393
471
  <button type="button" class="pkb-close" data-r="close">${esc(M.close)}</button></div></div>
394
472
  <canvas class="pkb-fx"></canvas></div>${demoBar}</div>`;
395
473
  }
474
+ var perfectText = (M, total) => typeof M.perfectBody === "function" ? M.perfectBody(total) : M.perfectBody;
396
475
  function createPackingBingo(container, options = {}) {
397
- const items = options.items ?? DEFAULT_ITEMS;
398
- if (items.length !== 16) throw new Error(`[packing-bingo] "items" must have exactly 16 entries (got ${items.length}).`);
476
+ const L = resolveLayout(options);
477
+ const { items, cellLines } = L;
478
+ const n = L.size, N = items.length;
479
+ const normalize = (v) => Array.from({ length: N }, (_, i) => !!v?.[i]);
480
+ const identity = () => Array.from({ length: N }, (_, s) => s);
481
+ const isIdentity = (o) => o.every((v, s) => v === s);
482
+ const validOrder = (o) => Array.isArray(o) && o.length === N && o.every((v) => Number.isInteger(v) && v >= 0 && v < N) && new Set(o).size === N;
483
+ let order = identity();
484
+ const slotOf = identity();
485
+ let lines = L.lines;
486
+ const demoCells = [...cellLines[0], ...cellLines[2 * n].slice(1, -1)];
487
+ const demoSlots = L.itemCell.flatMap((c, s) => demoCells.includes(c) ? [s] : []);
488
+ const demoStart = () => {
489
+ const on = normalize(void 0);
490
+ demoSlots.forEach((s) => on[order[s]] = true);
491
+ return on;
492
+ };
493
+ const far = (c) => Math.floor(c / n) + c % n;
494
+ const dScale = Math.min(1, 4 / n);
399
495
  const M = { ...ja, ...options.messages };
400
496
  if (options.title != null) M.title = options.title;
401
497
  if (options.tag != null) M.tag = options.tag;
402
498
  if (options.subtitle != null) M.subtitle = options.subtitle;
403
499
  const controlled = !!options.controlled;
404
500
  const storageKey = resolveStorageKey(options);
501
+ const orderKey = storageKey && ORDER_PREFIX + storageKey;
405
502
  const demo = !!options.demo;
503
+ const vibrate = options.vibrate !== false;
406
504
  const calm = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
407
505
  let dead = false;
408
506
  const timers = /* @__PURE__ */ new Set();
@@ -415,8 +513,8 @@ function createPackingBingo(container, options = {}) {
415
513
  };
416
514
  const wait = (ms) => new Promise((r) => window.setTimeout(r, ms));
417
515
  let gen = 0;
418
- ensureAssets(options.fonts !== false);
419
- container.innerHTML = template(items, M, demo);
516
+ ensureStyles();
517
+ container.innerHTML = template(L, M, { demo, shuffle: options.shuffle !== false, vibrate });
420
518
  const $ = (r) => {
421
519
  const el = container.querySelector(`[data-r="${r}"]`);
422
520
  if (!el) throw new Error(`[packing-bingo] internal: missing element "${r}"`);
@@ -428,7 +526,9 @@ function createPackingBingo(container, options = {}) {
428
526
  const svg = container.querySelector(".pkb-lines");
429
527
  const cv = container.querySelector(".pkb-fx");
430
528
  const ctx = cv.getContext("2d");
431
- const tiles = Array.from(grid.children);
529
+ const cellTiles = Array.from(grid.children);
530
+ const tiles = L.itemCell.map((c) => cellTiles[c]);
531
+ const freeTile = L.center >= 0 ? cellTiles[L.center] : null;
432
532
  const ins = tiles.map((t) => t.querySelector("input"));
433
533
  const kick = $("kick"), snd = $("snd"), perfectEl = $("perfect");
434
534
  let checked = normalize(void 0);
@@ -463,14 +563,14 @@ function createPackingBingo(container, options = {}) {
463
563
  cv.width = Math.round(BW * dpr);
464
564
  cv.height = Math.round(BH * dpr);
465
565
  };
466
- const piece = (x, y, vx, vy, g, s, life, shape) => ({
566
+ const piece = (x, y, vx, vy, g, s, life, shape2) => ({
467
567
  x,
468
568
  y,
469
569
  vx,
470
570
  vy,
471
571
  g,
472
572
  life,
473
- shape,
573
+ shape: shape2,
474
574
  w: (5 + Math.random() * 6) * s,
475
575
  h: (3 + Math.random() * 5) * s,
476
576
  r: Math.random() * 6,
@@ -480,9 +580,9 @@ function createPackingBingo(container, options = {}) {
480
580
  const go = () => {
481
581
  if (!raf && ctx) raf = requestAnimationFrame(tick);
482
582
  };
483
- function burst(x, y, n, o = {}) {
583
+ function burst(x, y, n2, o = {}) {
484
584
  if (calm) return;
485
- for (let i = 0; i < n; i++) {
585
+ for (let i = 0; i < n2; i++) {
486
586
  const a = (o.angle ?? -Math.PI / 2) + (Math.random() - 0.5) * (o.spread ?? Math.PI * 2);
487
587
  const v = (o.speed ?? 10) * (0.35 + Math.random() * 0.85);
488
588
  const sh = Math.random() < (o.star ?? 0.15) ? "star" : Math.random() < 0.3 ? "round" : "rect";
@@ -573,11 +673,11 @@ function createPackingBingo(container, options = {}) {
573
673
  void el.offsetWidth;
574
674
  el.classList.add(cls);
575
675
  };
576
- const hop = (i, d) => {
676
+ const hop = (t, d) => {
577
677
  if (calm) return;
578
- tiles[i].classList.remove("pkb-squash");
579
- tiles[i].style.setProperty("--pkb-d", d + "ms");
580
- replay(tiles[i], "pkb-hop");
678
+ t.classList.remove("pkb-squash");
679
+ t.style.setProperty("--pkb-d", d + "ms");
680
+ replay(t, "pkb-hop");
581
681
  };
582
682
  grid.addEventListener("animationend", (e) => {
583
683
  const t = e.target;
@@ -591,6 +691,7 @@ function createPackingBingo(container, options = {}) {
591
691
  r.addEventListener("animationend", () => r.remove());
592
692
  };
593
693
  const buzz = (pattern) => {
694
+ if (!vibrate) return;
594
695
  const ua = navigator.userActivation;
595
696
  if (ua && !ua.hasBeenActive) return;
596
697
  try {
@@ -625,8 +726,8 @@ function createPackingBingo(container, options = {}) {
625
726
  st(t0);
626
727
  if (!calm) replay($("sp"), "pkb-bump");
627
728
  }
628
- const showGain = (n, x, y, cls = "pt") => {
629
- floatText(`+${n}`, x, y, cls);
729
+ const showGain = (pts, x, y, cls = "pt") => {
730
+ floatText(`+${pts}`, x, y, cls);
630
731
  countScore();
631
732
  };
632
733
  function countUp(el, a, b, d, fmt, ticks = false) {
@@ -646,15 +747,19 @@ function createPackingBingo(container, options = {}) {
646
747
  };
647
748
  st(t0);
648
749
  }
649
- const ctr = (i) => {
650
- const t = tiles[i];
750
+ const ctr = (t) => {
651
751
  return { x: t.offsetLeft + t.offsetWidth / 2, y: t.offsetTop + t.offsetHeight / 2 };
652
752
  };
753
+ let landing = 0;
653
754
  function drawLines(fresh = []) {
755
+ if (landing) {
756
+ svg.innerHTML = "";
757
+ return;
758
+ }
654
759
  svg.setAttribute("viewBox", `0 0 ${gw.clientWidth} ${gw.clientHeight}`);
655
760
  svg.innerHTML = cur.map((k) => {
656
- const a = ctr(LINES[k][0]), b = ctr(LINES[k][3]);
657
- const dx = b.x - a.x, dy = b.y - a.y, d = Math.hypot(dx, dy) || 1, ex = dx / d * 26, ey = dy / d * 26;
761
+ const a = ctr(cellTiles[cellLines[k][0]]), b = ctr(cellTiles[cellLines[k][n - 1]]);
762
+ const dx = b.x - a.x, dy = b.y - a.y, d = Math.hypot(dx, dy) || 1, r = 26 * dScale, ex = dx / d * r, ey = dy / d * r;
658
763
  const at = `x1="${a.x - ex}" y1="${a.y - ey}" x2="${b.x + ex}" y2="${b.y + ey}" pathLength="1"`;
659
764
  return `<g${fresh.includes(k) ? ' class="pkb-draw"' : ""}><line class="pkb-u" ${at}/><line class="pkb-o" ${at}/></g>`;
660
765
  }).join("");
@@ -665,6 +770,7 @@ function createPackingBingo(container, options = {}) {
665
770
  combo = 0;
666
771
  $("combo").classList.remove("pkb-on");
667
772
  sfx.off();
773
+ buzz(8);
668
774
  return;
669
775
  }
670
776
  const now = performance.now();
@@ -687,7 +793,7 @@ function createPackingBingo(container, options = {}) {
687
793
  $("cmbN").textContent = M.combo(combo);
688
794
  replay($("combo"), "pkb-on");
689
795
  }
690
- buzz(12);
796
+ buzz(18);
691
797
  }
692
798
  $("cbar").addEventListener("animationend", () => {
693
799
  $("combo").classList.remove("pkb-on");
@@ -701,29 +807,29 @@ function createPackingBingo(container, options = {}) {
701
807
  later(() => sfx.reach(), 150);
702
808
  }
703
809
  function bingo(fresh, pts) {
704
- const n = fresh.length;
705
- const seq = [...new Set(fresh.flatMap((k) => LINES[k]))];
810
+ const nl = fresh.length;
811
+ const seq = [...new Set(fresh.flatMap((k) => cellLines[k]))].map((c) => cellTiles[c]);
706
812
  const step = Math.min(70, 300 / seq.length);
707
813
  seq.forEach(
708
- (i, j) => later(() => {
709
- hop(i, 0);
710
- const p = rel(tiles[i]);
814
+ (t, j) => later(() => {
815
+ hop(t, 0);
816
+ const p = rel(t);
711
817
  burst(p.x, p.y, 14, { speed: 5, g: 0.15, life: 30, size: 0.7, star: 0.8 });
712
818
  sfx.note(j + 3);
713
819
  }, j * step)
714
820
  );
715
821
  later(() => {
716
- $("bT").textContent = n >= 3 ? M.bannerTriple : n === 2 ? M.bannerDouble : M.bannerSingle;
717
- $("bS").textContent = n > 1 ? M.bannerMulti(n) : M.bannerNth(cur.length);
822
+ $("bT").textContent = nl >= 3 ? M.bannerTriple : nl === 2 ? M.bannerDouble : M.bannerSingle;
823
+ $("bS").textContent = nl > 1 ? M.bannerMulti(nl) : M.bannerNth(cur.length);
718
824
  replay($("banner"), "pkb-show");
719
825
  if (!calm) {
720
826
  replay($("flash"), "pkb-go");
721
827
  replay(board, "pkb-punch");
722
828
  }
723
829
  const c = rel(gw);
724
- burst(c.x, c.y, n > 1 ? 180 : 110, { speed: n > 1 ? 16 : 13, star: 0.3 });
725
- sfx.bingo(n);
726
- buzz(n > 1 ? [40, 60, 40, 60, 90] : [40, 60, 40]);
830
+ burst(c.x, c.y, nl > 1 ? 180 : 110, { speed: nl > 1 ? 16 : 13, star: 0.3 });
831
+ sfx.bingo(nl);
832
+ buzz(nl > 1 ? [40, 60, 40, 60, 90] : [40, 60, 40]);
727
833
  if (pts) later(() => showGain(pts, c.x, c.y + 105, "big"), 350);
728
834
  }, seq.length * step + 60);
729
835
  }
@@ -731,9 +837,10 @@ function createPackingBingo(container, options = {}) {
731
837
  const bonus = gotP ? 0 : 1e3;
732
838
  gotP = true;
733
839
  score += bonus;
734
- tiles.forEach((_, i) => hop(i, ((i >> 2) + (i & 3)) * 60));
840
+ cellTiles.forEach((t, c) => hop(t, far(c) * 180 / Math.max(1, n - 1)));
735
841
  sfx.perfect();
736
842
  if (!calm) replay($("flash"), "pkb-go");
843
+ $("pbody").textContent = perfectText(M, N);
737
844
  $("sScore").textContent = "0";
738
845
  $("sCombo").textContent = "\xD70";
739
846
  $("sBingo").textContent = "0";
@@ -780,30 +887,32 @@ function createPackingBingo(container, options = {}) {
780
887
  $("combo").classList.remove("pkb-on");
781
888
  perfectEl.classList.remove("pkb-show");
782
889
  }
890
+ const completeLines = () => lines.flatMap((l, k) => l.every((i) => checked[i]) ? [k] : []);
783
891
  function render(src, quiet) {
784
892
  const events = [];
785
893
  const on = checked, c = on.filter(Boolean).length;
786
- cur = LINES.flatMap((l, k) => l.every((i) => on[i]) ? [k] : []);
894
+ cur = completeLines();
787
895
  const fresh = cur.filter((k) => !prevLines.has(k));
788
896
  if ([...prevLines].some((k) => !cur.includes(k))) {
789
897
  cancelFx();
790
- if (c < 16) perfectEl.classList.remove("pkb-show");
898
+ if (c < N) perfectEl.classList.remove("pkb-show");
791
899
  }
792
- const hit = new Set(cur.flatMap((k) => LINES[k]));
900
+ const hit = new Set(cur.flatMap((k) => cellLines[k]));
793
901
  const reach = /* @__PURE__ */ new Set();
794
- for (const l of LINES) {
902
+ for (const l of lines) {
795
903
  const m = l.filter((i) => !on[i]);
796
904
  if (m.length === 1) reach.add(m[0]);
797
905
  }
798
906
  tiles.forEach((t, i) => {
799
907
  t.classList.toggle("pkb-on", on[i]);
800
- t.classList.toggle("pkb-line", hit.has(i));
908
+ t.classList.toggle("pkb-line", hit.has(L.itemCell[slotOf[i]]));
801
909
  t.classList.toggle("pkb-reach", reach.has(i));
802
910
  });
803
- board.classList.toggle("pkb-all", c === 16);
804
- kick.classList.toggle("pkb-gold", c === 16);
805
- kick.textContent = c === 16 ? M.perfectTag : M.tag;
806
- $("cnt").textContent = `${c}/16`;
911
+ freeTile?.classList.toggle("pkb-line", hit.has(L.center));
912
+ board.classList.toggle("pkb-all", c === N);
913
+ kick.classList.toggle("pkb-gold", c === N);
914
+ kick.textContent = c === N ? M.perfectTag : M.tag;
915
+ $("cnt").textContent = `${c}/${N}`;
807
916
  $("bng").textContent = String(cur.length);
808
917
  const bp = $("bp");
809
918
  if (cur.length > lastN) {
@@ -814,13 +923,13 @@ function createPackingBingo(container, options = {}) {
814
923
  lastN = cur.length;
815
924
  drawLines(fresh);
816
925
  if (src !== null) tapFx(src, on[src]);
817
- const newL = fresh.filter((k) => !gotL.has(k));
818
- newL.forEach((k) => gotL.add(k));
926
+ const newL = fresh.map((k) => lineKey(lines[k])).filter((key) => !gotL.has(key));
927
+ newL.forEach((key) => gotL.add(key));
819
928
  const linePts = 100 * newL.length * newL.length;
820
929
  score += linePts;
821
930
  if (quiet) {
822
931
  if (linePts) countScore();
823
- } else if (c === 16 && !prevAll) perfect(events);
932
+ } else if (c === N && !prevAll) perfect(events);
824
933
  else if (fresh.length) {
825
934
  bingo(fresh, linePts);
826
935
  const e = { newLines: fresh, totalLines: cur.length, score };
@@ -831,9 +940,9 @@ function createPackingBingo(container, options = {}) {
831
940
  }
832
941
  prevLines = new Set(cur);
833
942
  prevReach = reach;
834
- prevAll = c === 16;
943
+ prevAll = c === N;
835
944
  const labels = [...reach].map((i) => items[i].label);
836
- $("msg").textContent = c === 16 ? M.statusDone : labels.length ? M.statusReach(labels) : cur.length ? M.statusLines(cur.length, 16 - c) : M.statusStart(16 - c);
945
+ $("msg").textContent = c === N ? M.statusDone : labels.length ? M.statusReach(labels) : cur.length ? M.statusLines(cur.length, N - c) : M.statusStart(N - c);
837
946
  return events;
838
947
  }
839
948
  const flush = (events) => {
@@ -860,8 +969,9 @@ function createPackingBingo(container, options = {}) {
860
969
  got.clear();
861
970
  gotL.clear();
862
971
  checked.forEach((v, i) => v && got.add(i));
863
- LINES.forEach((l, k) => l.every((i) => checked[i]) && gotL.add(k));
864
- prevLines = new Set(gotL);
972
+ const done = completeLines();
973
+ done.forEach((k) => gotL.add(lineKey(lines[k])));
974
+ prevLines = new Set(done);
865
975
  prevReach = /* @__PURE__ */ new Set();
866
976
  prevAll = checked.every(Boolean);
867
977
  gotP = prevAll;
@@ -884,6 +994,77 @@ function createPackingBingo(container, options = {}) {
884
994
  options.onChange?.(checked.slice());
885
995
  flush(events);
886
996
  }
997
+ function place(next) {
998
+ order = next;
999
+ order.forEach((i, s) => {
1000
+ slotOf[i] = s;
1001
+ cellTiles[L.itemCell[s]] = tiles[i];
1002
+ });
1003
+ lines = L.lines.map((l) => l.map((s) => order[s]));
1004
+ const focused = document.activeElement;
1005
+ cellTiles.forEach((t, c) => {
1006
+ t.style.setProperty("--pkb-gd", glintDelay(c, n));
1007
+ grid.appendChild(t);
1008
+ });
1009
+ if (focused instanceof HTMLElement && grid.contains(focused) && document.activeElement !== focused)
1010
+ focused.focus({ preventScroll: true });
1011
+ }
1012
+ const saveOrder = () => {
1013
+ if (!orderKey) return;
1014
+ writeStore(orderKey, JSON.stringify(order));
1015
+ };
1016
+ let flights = [];
1017
+ function arrange(next) {
1018
+ if (!checked.every(Boolean)) cancelFx();
1019
+ const from = calm ? null : tiles.map((t) => t.getBoundingClientRect());
1020
+ flights.forEach((a) => a.cancel());
1021
+ flights = [];
1022
+ place(next);
1023
+ lines.forEach((l) => {
1024
+ if (l.every((i) => got.has(i))) gotL.add(lineKey(l));
1025
+ });
1026
+ const done = completeLines();
1027
+ done.forEach((k) => gotL.add(lineKey(lines[k])));
1028
+ prevLines = new Set(done);
1029
+ render(null, true);
1030
+ saveOrder();
1031
+ if (!from || typeof Element.prototype.animate !== "function") return;
1032
+ tiles.forEach((t, i) => {
1033
+ const a = from[i], b = t.getBoundingClientRect();
1034
+ const dx = a.left - b.left, dy = a.top - b.top;
1035
+ if (Math.abs(dx) + Math.abs(dy) < 1) return;
1036
+ flights.push(
1037
+ t.animate([{ transform: `translate(${dx}px,${dy}px)` }, { transform: "none" }], {
1038
+ duration: FLY_MS - 140,
1039
+ delay: Math.random() * 140,
1040
+ easing: "cubic-bezier(.3,1.25,.5,1)",
1041
+ fill: "backwards"
1042
+ })
1043
+ );
1044
+ });
1045
+ clearTimeout(landing);
1046
+ landing = window.setTimeout(() => {
1047
+ landing = 0;
1048
+ flights = [];
1049
+ if (!dead) drawLines(cur);
1050
+ }, FLY_MS);
1051
+ svg.innerHTML = "";
1052
+ }
1053
+ function shuffleCard() {
1054
+ let next = order.slice();
1055
+ do {
1056
+ for (let s = N - 1; s > 0; s--) {
1057
+ const r = Math.floor(Math.random() * (s + 1));
1058
+ [next[s], next[r]] = [next[r], next[s]];
1059
+ }
1060
+ } while (next.every((v, s) => v === order[s]));
1061
+ arrange(next);
1062
+ sfx.shuffle();
1063
+ buzz([10, 40, 10, 40, 10, 40, 24]);
1064
+ }
1065
+ const unshuffle = () => {
1066
+ if (!isIdentity(order)) arrange(identity());
1067
+ };
887
1068
  grid.addEventListener("change", (e) => {
888
1069
  const i = ins.indexOf(e.target);
889
1070
  if (i < 0) return;
@@ -891,7 +1072,11 @@ function createPackingBingo(container, options = {}) {
891
1072
  next[i] = !next[i];
892
1073
  request(next, i);
893
1074
  });
894
- $("reset").addEventListener("click", () => request(normalize(void 0), null, { reset: true }));
1075
+ container.querySelector('[data-r="shuf"]')?.addEventListener("click", shuffleCard);
1076
+ $("reset").addEventListener("click", () => {
1077
+ unshuffle();
1078
+ request(normalize(void 0), null, { reset: true });
1079
+ });
895
1080
  $("close").addEventListener("click", () => perfectEl.classList.remove("pkb-show"));
896
1081
  perfectEl.addEventListener("click", (e) => {
897
1082
  if (e.target === perfectEl) perfectEl.classList.remove("pkb-show");
@@ -920,18 +1105,31 @@ function createPackingBingo(container, options = {}) {
920
1105
  }
921
1106
  };
922
1107
  $("dB").addEventListener("click", () => {
923
- const open = () => LINES.map((l) => l.filter((i) => !checked[i])).filter((m) => m.length).sort((a, b) => a.length - b.length)[0] ?? [];
924
- const pick = checked.every(Boolean) ? [...LINES[0]] : open();
1108
+ const open = () => lines.map((l) => l.filter((i) => !checked[i])).filter((m) => m.length).sort((a, b) => a.length - b.length)[0] ?? [];
1109
+ const pick = checked.every(Boolean) ? [...lines[0]] : open();
925
1110
  void run(pick.map((i) => ({ i, delay: 320 })));
926
1111
  });
927
1112
  $("dP").addEventListener("click", () => {
928
- const miss = checked.every(Boolean) ? tiles.map((_, i) => i) : tiles.map((_, i) => i).filter((i) => !checked[i]);
1113
+ const all = items.map((_, i) => i);
1114
+ const miss = checked.every(Boolean) ? all : all.filter((i) => !checked[i]);
929
1115
  void run(miss.map((i, k) => k < miss.length - 1 ? { i, delay: 170, quiet: true } : { i, delay: 450 }));
930
1116
  });
931
1117
  $("dR").addEventListener("click", () => {
932
- if (!busy) request(demoStart(), null, { reset: true });
1118
+ if (busy) return;
1119
+ unshuffle();
1120
+ request(demoStart(), null, { reset: true });
933
1121
  });
934
1122
  }
1123
+ let startOrder = null;
1124
+ if (orderKey) {
1125
+ try {
1126
+ const saved = JSON.parse(readStore(orderKey) ?? "null");
1127
+ if (validOrder(saved)) startOrder = saved;
1128
+ } catch {
1129
+ }
1130
+ }
1131
+ if (!startOrder && validOrder(options.defaultOrder)) startOrder = options.defaultOrder.slice();
1132
+ if (startOrder && !isIdentity(startOrder)) place(startOrder);
935
1133
  let initial = null;
936
1134
  if (storageKey) {
937
1135
  try {
@@ -953,29 +1151,45 @@ function createPackingBingo(container, options = {}) {
953
1151
  return {
954
1152
  setChecked(next, opts = {}) {
955
1153
  if (dead) return;
956
- const n = normalize(next);
957
- const changed = n.flatMap((v, i) => v !== checked[i] ? [i] : []);
1154
+ const n2 = normalize(next);
1155
+ const changed = n2.flatMap((v, i) => v !== checked[i] ? [i] : []);
958
1156
  if (!changed.length) return;
959
1157
  if (!opts.animate || changed.length !== 1) {
960
- baseline(n);
1158
+ baseline(n2);
961
1159
  return;
962
1160
  }
963
- flush(apply(n, changed[0], false));
1161
+ flush(apply(n2, changed[0], false));
964
1162
  },
965
1163
  reset() {
966
- if (!dead) request(normalize(void 0), null, { reset: true });
1164
+ if (dead) return;
1165
+ unshuffle();
1166
+ request(normalize(void 0), null, { reset: true });
1167
+ },
1168
+ shuffle() {
1169
+ if (!dead) shuffleCard();
967
1170
  },
968
1171
  refresh() {
969
1172
  if (!dead) render(null, true);
970
1173
  },
971
1174
  getState() {
972
- return { checked: checked.slice(), lines: cur.slice(), score, maxCombo, complete: checked.every(Boolean) };
1175
+ return {
1176
+ checked: checked.slice(),
1177
+ lines: cur.slice(),
1178
+ score,
1179
+ maxCombo,
1180
+ complete: checked.every(Boolean),
1181
+ size: n,
1182
+ free: L.free,
1183
+ order: order.slice()
1184
+ };
973
1185
  },
974
1186
  destroy() {
975
1187
  if (dead) return;
976
1188
  dead = true;
977
1189
  timers.forEach((id) => clearTimeout(id));
978
1190
  timers.clear();
1191
+ clearTimeout(landing);
1192
+ flights.forEach((a) => a.cancel());
979
1193
  cancelAnimationFrame(raf);
980
1194
  cancelAnimationFrame(tw);
981
1195
  ro?.disconnect();
@@ -987,6 +1201,7 @@ function createPackingBingo(container, options = {}) {
987
1201
 
988
1202
  export {
989
1203
  DEFAULT_ITEMS,
1204
+ bingoLines,
990
1205
  ja,
991
1206
  en,
992
1207
  resolveStorageKey,