packing-bingo 0.1.1 → 0.3.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/index.cjs CHANGED
@@ -24,6 +24,7 @@ __export(src_exports, {
24
24
  DEFAULT_ITEMS: () => DEFAULT_ITEMS,
25
25
  LINES: () => LINES,
26
26
  PackingBingo: () => PackingBingo,
27
+ bingoLines: () => bingoLines,
27
28
  createPackingBingo: () => createPackingBingo,
28
29
  en: () => en,
29
30
  ja: () => ja
@@ -35,29 +36,98 @@ var import_react = require("react");
35
36
 
36
37
  // src/items.ts
37
38
  var DEFAULT_ITEMS = [
38
- { emoji: "\u{1F4D6}", label: "\u65C5\u306E\u3057\u304A\u308A" },
39
- { emoji: "\u{1FAAA}", label: "\u751F\u5F92\u624B\u5E33" },
40
- { emoji: "\u{1F3E5}", label: "\u4FDD\u967A\u8A3C\u30B3\u30D4\u30FC" },
41
- { emoji: "\u{1F45B}", label: "\u304A\u3053\u3065\u304B\u3044" },
42
- { emoji: "\u{1F455}", label: "\u7740\u66FF\u3048" },
43
- { emoji: "\u{1F319}", label: "\u30D1\u30B8\u30E3\u30DE" },
44
- { emoji: "\u{1F9E5}", label: "\u4E0A\u7740" },
45
- { emoji: "\u{1FAA5}", label: "\u6D17\u9762\u7528\u5177" },
46
- { emoji: "\u{1F6C1}", label: "\u30BF\u30AA\u30EB" },
47
- { emoji: "\u{1F927}", label: "\u30CF\u30F3\u30AB\u30C1\u30FB\u30C6\u30A3\u30C3\u30B7\u30E5" },
39
+ { emoji: "\u{1F45B}", label: "\u8CA1\u5E03" },
40
+ { emoji: "\u{1F4F1}", label: "\u30B9\u30DE\u30DB" },
41
+ { emoji: "\u{1F50B}", label: "\u30E2\u30D0\u30A4\u30EB\u200B\u5145\u96FB\u5668" },
42
+ { emoji: "\u{1F4CB}", label: "\u5065\u5EB7\u8ABF\u67FB\u7968" },
43
+ { emoji: "\u{1FAAA}", label: "\u30DE\u30A4\u30CA\u30AB\u30FC\u30C9\u200B\u307E\u305F\u306F\u4FDD\u967A\u8A3C" },
44
+ { emoji: "\u{1F4D6}", label: "\u3057\u304A\u308A" },
48
45
  { emoji: "\u{1F637}", label: "\u30DE\u30B9\u30AF" },
49
- { emoji: "\u{1F48A}", label: "\u5E38\u5099\u85AC\u30FB\u9154\u3044\u6B62\u3081" },
50
- { emoji: "\u2602\uFE0F", label: "\u6298\u308A\u305F\u305F\u307F\u5098" },
51
- { emoji: "\u270F\uFE0F", label: "\u7B46\u8A18\u7528\u5177" },
46
+ { emoji: "\u{1F927}", label: "\u30CF\u30F3\u30AB\u30C1" },
47
+ { emoji: "\u{1F48A}", label: "\u5E38\u5099\u85AC" },
52
48
  { emoji: "\u{1F6CD}\uFE0F", label: "\u30D3\u30CB\u30FC\u30EB\u888B" },
53
- { emoji: "\u{1F4F1}", label: "\u30B9\u30DE\u30DB\u30FB\u5145\u96FB\u5668" }
49
+ { emoji: "\u{1F9E4}", label: "\u8ECD\u624B\uFF08\u5B66\u6821\u652F\u7D66\uFF09" },
50
+ { emoji: "\u{1F526}", label: "\u30D8\u30C3\u30C9\u30E9\u30A4\u30C8" },
51
+ { emoji: "\u{1F455}", label: "\u30B8\u30E3\u30FC\u30B8" },
52
+ { emoji: "\u2614", label: "\u96E8\u5177" },
53
+ { emoji: "\u{1F6C1}", label: "\u30BF\u30AA\u30EB" },
54
+ { emoji: "\u{1F9F4}", label: "\u65E5\u713C\u3051\u6B62\u3081" }
54
55
  ];
55
56
 
57
+ // src/layout.ts
58
+ var MIN_SIZE = 2;
59
+ var MAX_SIZE = 10;
60
+ var fail = (msg) => {
61
+ throw new Error(`[packing-bingo] ${msg}`);
62
+ };
63
+ function layoutItems(options) {
64
+ if (options.items) return options.items;
65
+ const n = options.size;
66
+ if (n == null || !Number.isInteger(n) || n < MIN_SIZE) return DEFAULT_ITEMS;
67
+ return DEFAULT_ITEMS.slice(0, n * n - (options.free ? 1 : 0));
68
+ }
69
+ function checkSize(size, free) {
70
+ if (!Number.isInteger(size) || size < MIN_SIZE || size > MAX_SIZE)
71
+ fail(`"size" must be a whole number from ${MIN_SIZE} to ${MAX_SIZE} (got ${size}).`);
72
+ 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}).`);
73
+ }
74
+ function shape(n, free) {
75
+ const seq = (f) => Array.from({ length: n }, (_, k) => f(k));
76
+ const cellLines = [
77
+ ...seq((r) => seq((c) => r * n + c)),
78
+ // rows
79
+ ...seq((c) => seq((r) => r * n + c)),
80
+ // columns
81
+ seq((k) => k * (n + 1)),
82
+ // top left to bottom right
83
+ seq((k) => (k + 1) * (n - 1))
84
+ // top right to bottom left
85
+ ];
86
+ const center = free ? (n * n - 1) / 2 : -1;
87
+ const toItem = (c) => free && c > center ? c - 1 : c;
88
+ return { center, cellLines, lines: cellLines.map((l) => l.filter((c) => c !== center).map(toItem)) };
89
+ }
90
+ function resolveLayout(options) {
91
+ const items = layoutItems(options);
92
+ const free = !!options.free;
93
+ let size = options.size;
94
+ if (size == null) {
95
+ const len = items.length;
96
+ const sideOf = (cells) => {
97
+ const r = Math.round(Math.sqrt(cells));
98
+ return r * r === cells && r >= MIN_SIZE && r <= MAX_SIZE ? r : 0;
99
+ };
100
+ size = sideOf(len + (free ? 1 : 0));
101
+ if (!size) {
102
+ const other = sideOf(len + (free ? 0 : 1));
103
+ 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` : "";
104
+ fail(
105
+ 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}).`
106
+ );
107
+ }
108
+ }
109
+ checkSize(size, free);
110
+ const want = size * size - (free ? 1 : 0);
111
+ if (items.length !== want) {
112
+ const card = `a ${size}\xD7${size} card${free ? " with a FREE center" : ""}`;
113
+ fail(
114
+ 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".`
115
+ );
116
+ }
117
+ const { center, cellLines, lines } = shape(size, free);
118
+ const itemCell = items.map((_, i) => free && i >= center ? i + 1 : i);
119
+ return { size, free, items, itemCell, center, cellLines, lines };
120
+ }
121
+ function bingoLines(size, free = false) {
122
+ checkSize(size, free);
123
+ return shape(size, free).lines;
124
+ }
125
+
56
126
  // src/messages.ts
57
127
  var ja = {
58
128
  title: "\u6301\u3061\u3082\u306E\u30D3\u30F3\u30B4",
59
129
  tag: "\u4FEE\u5B66\u65C5\u884C",
60
- subtitle: "\u30BF\u30C3\u30D7\u3067\u30B9\u30BF\u30F3\u30D7\u3002\u30BF\u30C6\u30FB\u30E8\u30B3\u30FB\u30CA\u30CA\u30E1\u3067\u30D3\u30F3\u30B4\uFF01",
130
+ subtitle: "",
61
131
  packed: "\u305D\u308D\u3063\u305F",
62
132
  bingo: "BINGO",
63
133
  score: "SCORE",
@@ -67,6 +137,7 @@ var ja = {
67
137
  soundOn: "\u{1F50A} \u97F3\u3042\u308A",
68
138
  soundOff: "\u{1F507} \u97F3\u306A\u3057",
69
139
  stamp: "OK",
140
+ free: "FREE",
70
141
  reachBadge: "\u30EA\u30FC\u30C1",
71
142
  reachPop: "\u30EA\u30FC\u30C1!",
72
143
  bannerSingle: "BINGO!",
@@ -74,14 +145,15 @@ var ja = {
74
145
  bannerTriple: "\u30C8\u30EA\u30D7\u30EB\u30D3\u30F3\u30B4!",
75
146
  bannerNth: (n) => `${n} \u30E9\u30A4\u30F3\u76EE\uFF01`,
76
147
  bannerMulti: (n) => `\u4E00\u6C17\u306B ${n} \u30E9\u30A4\u30F3\uFF01`,
77
- statusStart: (left) => `\u306E\u3053\u308A ${left} \u3053\u3002\u307E\u305A\u306F1\u30E9\u30A4\u30F3\u305D\u308D\u3048\u3088\u3046`,
148
+ statusStart: () => "",
78
149
  statusLines: (lines, left) => `\u30D3\u30F3\u30B4 ${lines} \u30E9\u30A4\u30F3\uFF01 \u306E\u3053\u308A ${left} \u3053`,
79
150
  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`,
80
151
  statusDone: "\u{1F389} \u30D1\u30FC\u30D5\u30A7\u30AF\u30C8\uFF01\u5FD8\u308C\u7269\u30BC\u30ED\u3067\u51FA\u767A\u3067\u304D\u307E\u3059",
81
152
  perfectTag: "\u{1F3C6} PERFECT",
82
153
  perfectStamp: "\u6E96\u5099\n\u5B8C\u4E86",
83
154
  perfectTitle: "\u30D1\u30FC\u30D5\u30A7\u30AF\u30C8\uFF01",
84
- 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",
155
+ perfectBody: (n) => `${n}\u3053 \u305C\u3093\u3076\u305D\u308D\u3063\u305F\uFF01
156
+ \u5FD8\u308C\u7269\u30BC\u30ED\u3067\u3001\u3044\u3063\u3066\u3089\u3063\u3057\u3083\u3044\uFF01`,
85
157
  perfectBonus: (n) => `PERFECT BONUS +${n}`,
86
158
  statScore: "SCORE",
87
159
  statCombo: "MAX COMBO",
@@ -95,7 +167,7 @@ var ja = {
95
167
  var en = {
96
168
  title: "Packing Bingo",
97
169
  tag: "School trip",
98
- subtitle: "Tap to stamp. Rows, columns and diagonals all count!",
170
+ subtitle: "",
99
171
  packed: "PACKED",
100
172
  bingo: "BINGO",
101
173
  score: "SCORE",
@@ -105,6 +177,7 @@ var en = {
105
177
  soundOn: "\u{1F50A} Sound on",
106
178
  soundOff: "\u{1F507} Muted",
107
179
  stamp: "OK",
180
+ free: "FREE",
108
181
  reachBadge: "REACH",
109
182
  reachPop: "Reach!",
110
183
  bannerSingle: "BINGO!",
@@ -112,14 +185,15 @@ var en = {
112
185
  bannerTriple: "TRIPLE BINGO!",
113
186
  bannerNth: (n) => `Line ${n}!`,
114
187
  bannerMulti: (n) => `${n} lines at once!`,
115
- statusStart: (left) => `${left} to go. Finish a line first!`,
188
+ statusStart: () => "",
116
189
  statusLines: (lines, left) => `${lines} bingo line${lines === 1 ? "" : "s"}! ${left} to go`,
117
190
  statusReach: (labels) => `Reach! Pack ${labels.slice(0, 2).join(" or ")}${labels.length > 2 ? " (or more)" : ""} for a bingo`,
118
191
  statusDone: "\u{1F389} Perfect! Nothing left behind.",
119
192
  perfectTag: "\u{1F3C6} PERFECT",
120
193
  perfectStamp: "ALL\nSET",
121
194
  perfectTitle: "Perfect!",
122
- perfectBody: "All 16 packed!\nHave a great trip!",
195
+ perfectBody: (n) => `All ${n} packed!
196
+ Have a great trip!`,
123
197
  perfectBonus: (n) => `PERFECT BONUS +${n}`,
124
198
  statScore: "SCORE",
125
199
  statCombo: "MAX COMBO",
@@ -228,17 +302,34 @@ function createSound(isMuted) {
228
302
  };
229
303
  }
230
304
 
305
+ // src/storage-key.ts
306
+ function resolveStorageKey(options) {
307
+ if (options.controlled) return null;
308
+ if (options.storageKey !== void 0) return options.storageKey || null;
309
+ if (options.demo) return null;
310
+ return itemsKey(layoutItems(options));
311
+ }
312
+ function itemsKey(items) {
313
+ const text = items.map((it) => `${it.emoji}\0${it.label}`).join("");
314
+ let h = 2166136261;
315
+ for (let i = 0; i < text.length; i++) {
316
+ h ^= text.charCodeAt(i);
317
+ h = Math.imul(h, 16777619);
318
+ }
319
+ return `packing-bingo:${(h >>> 0).toString(36)}`;
320
+ }
321
+
231
322
  // src/styles.ts
232
323
  var FONT_URL = "https://fonts.googleapis.com/css2?family=Dela+Gothic+One&family=M+PLUS+Rounded+1c:wght@500;800&display=swap";
233
324
  var CSS = `
234
- .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%}
325
+ .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%}
235
326
  .pkb,.pkb *{box-sizing:border-box;margin:0;padding:0}
236
327
  .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}
237
328
  .pkb-board.pkb-shake{animation:pkb-shake .45s cubic-bezier(.36,.07,.19,.97)}
238
329
  @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)}}
239
330
  .pkb-board.pkb-punch{animation:pkb-punch .4s cubic-bezier(.3,1.6,.5,1)}
240
331
  @keyframes pkb-punch{30%{transform:scale(1.018)}}
241
- .pkb-head{display:flex;justify-content:space-between;align-items:center;gap:16px;margin-bottom:18px}
332
+ .pkb-head{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:center;gap:12px 16px;margin-bottom:18px}
242
333
  .pkb-tags{display:flex;gap:8px;align-items:center}
243
334
  .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}
244
335
  .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}
@@ -246,7 +337,7 @@ var CSS = `
246
337
  .pkb button{font-family:var(--pk-body)}
247
338
  .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}
248
339
  .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}
249
- .pkb-sub{font-weight:800;font-size:13px;margin-top:4px}
340
+ .pkb-sub{font-weight:800;font-size:13px;margin-top:4px;word-break:auto-phrase}
250
341
  .pkb-side{display:flex;flex-direction:column;gap:8px;flex:none}
251
342
  .pkb-score{display:flex;gap:10px}
252
343
  .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)}
@@ -265,16 +356,18 @@ var CSS = `
265
356
  .pkb-combo.pkb-on .pkb-bar i{animation:pkb-drain 2.5s linear forwards}
266
357
  @keyframes pkb-drain{from{transform:scaleX(1)}to{transform:scaleX(0)}}
267
358
  .pkb-gw{position:relative}
268
- .pkb-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:12px}
269
- .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}
270
- .pkb-tile:active{transform:translate(3px,3px);box-shadow:1px 1px 0 var(--pk-ink)}
359
+ .pkb-grid{display:grid;grid-template-columns:repeat(var(--pk-n),minmax(0,1fr));gap:calc(12px*var(--pk-d))}
360
+ .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}
361
+ .pkb-tile:active:not(.pkb-free){transform:translate(3px,3px);box-shadow:1px 1px 0 var(--pk-ink)}
271
362
  .pkb-tile input{position:absolute;opacity:0;pointer-events:none;width:1px;height:1px}
272
363
  .pkb-tile:has(input:focus-visible){outline:3px solid var(--pk-ink);outline-offset:3px}
273
- .pkb-emo{font-size:32px;line-height:1;transition:opacity .2s}
274
- .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}
275
- .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}
364
+ .pkb-emo{font-size:max(14px,calc(32px*var(--pk-s)));line-height:1;transition:opacity .2s}
365
+ .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}
366
+ .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}
276
367
  .pkb-tile.pkb-on .pkb-stamp{opacity:.9;transform:translate(-50%,-50%) rotate(-16deg) scale(1)}
277
368
  .pkb-tile.pkb-on .pkb-emo,.pkb-tile.pkb-on .pkb-lbl{opacity:.35}
369
+ .pkb-tile.pkb-free{cursor:default;container-type:inline-size}
370
+ .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)}
278
371
  .pkb-tile.pkb-line{background-color:var(--pk-mint)}
279
372
  .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}
280
373
  @keyframes pkb-glint{0%{background-position:200% 0}30%,100%{background-position:-100% 0}}
@@ -291,11 +384,11 @@ var CSS = `
291
384
  .pkb-lines{position:absolute;inset:0;width:100%;height:100%;pointer-events:none;overflow:visible;z-index:2;transition:opacity .4s}
292
385
  .pkb-board.pkb-all .pkb-lines{opacity:0}
293
386
  .pkb-lines line{fill:none;stroke-linecap:round;stroke-dasharray:1;stroke-dashoffset:0}
294
- .pkb-lines .pkb-u{stroke:var(--pk-paper);stroke-width:13}
295
- .pkb-lines .pkb-o{stroke:var(--pk-ink);stroke-width:6}
387
+ .pkb-lines .pkb-u{stroke:var(--pk-paper);stroke-width:calc(13px*var(--pk-d))}
388
+ .pkb-lines .pkb-o{stroke:var(--pk-ink);stroke-width:calc(6px*var(--pk-d))}
296
389
  .pkb-lines .pkb-draw line{animation:pkb-draw .45s ease-out .3s both}
297
390
  @keyframes pkb-draw{from{stroke-dashoffset:1}}
298
- .pkb-foot{display:flex;justify-content:space-between;align-items:center;gap:10px;margin-top:18px;font-weight:800;font-size:13px}
391
+ .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}
299
392
  .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}
300
393
  .pkb-flash{position:absolute;inset:0;z-index:7;background:#fff;opacity:0;pointer-events:none}
301
394
  .pkb-flash.pkb-go{animation:pkb-flash .4s ease-out}
@@ -310,7 +403,7 @@ var CSS = `
310
403
  .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}
311
404
  .pkb-float.pkb-pt{font-size:22px;color:var(--pk-paper)}
312
405
  .pkb-float.pkb-cmb{font-size:17px}
313
- .pkb-float.pkb-big{font-size:clamp(20px,6cqi,34px);color:#FFE066}
406
+ .pkb-float.pkb-big{font-size:clamp(14px,5.6cqi,34px);color:#FFE066}
314
407
  .pkb-float.pkb-rch{font-size:20px;color:var(--pk-red);-webkit-text-stroke:4px var(--pk-paper)}
315
408
  @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)}}
316
409
  .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}
@@ -321,7 +414,7 @@ var CSS = `
321
414
  .pkb-perfect.pkb-show .pkb-medal{animation:pkb-slam .38s cubic-bezier(.5,0,.75,0) .35s forwards}
322
415
  @keyframes pkb-slam{to{opacity:1;transform:rotate(-14deg) scale(1)}}
323
416
  .pkb-ptitle{font-family:var(--pk-display);font-weight:400;font-size:30px;line-height:1.3;color:inherit}
324
- .pkb-pbody{font-weight:800;font-size:13.5px;line-height:1.6;margin:6px 0 14px;white-space:pre-line}
417
+ .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}
325
418
  .pkb-stats{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-bottom:16px}
326
419
  .pkb-stats>div{border:2px solid var(--pk-ink);border-radius:12px;padding:6px 4px 5px;background:#fff}
327
420
  .pkb-stats small{display:block;font-size:9.5px;font-weight:800;letter-spacing:.1em}
@@ -331,32 +424,20 @@ var CSS = `
331
424
  .pkb-fx{position:absolute;inset:0;width:100%;height:100%;z-index:10;pointer-events:none}
332
425
  .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}
333
426
  .pkb-demo button{appearance:none;border:1px solid #ccc;background:#f3f3f3;color:#222;border-radius:999px;padding:5px 12px;font-size:12px;cursor:pointer}
334
- @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}}
427
+ @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}}
428
+ @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}}
429
+ @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}}
335
430
  @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}}
336
431
  @keyframes pkb-fade{0%,100%{opacity:0}15%,80%{opacity:1}}
337
432
  `;
338
433
 
339
434
  // src/core.ts
340
- var LINES = [
341
- [0, 1, 2, 3],
342
- [4, 5, 6, 7],
343
- [8, 9, 10, 11],
344
- [12, 13, 14, 15],
345
- [0, 4, 8, 12],
346
- [1, 5, 9, 13],
347
- [2, 6, 10, 14],
348
- [3, 7, 11, 15],
349
- [0, 5, 10, 15],
350
- [3, 6, 9, 12]
351
- ];
352
- var DEMO_START = [0, 1, 2, 3, 5, 10];
435
+ var LINES = bingoLines(4);
353
436
  var CONFETTI = ["#E8473B", "#2F6FED", "#1FA971", "#FFFDF6", "#2A1F14", "#FF9F1C"];
354
437
  var COMBO_COLORS = ["#FF9F1C", "#1FA971", "#2F6FED", "#9B5DE5", "#E8473B"];
355
438
  var COMBO_MS = 2500;
356
439
  var MUTE_KEY = "packing-bingo-muted";
357
440
  var esc = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
358
- var normalize = (v) => Array.from({ length: 16 }, (_, i) => !!v?.[i]);
359
- var demoStart = () => Array.from({ length: 16 }, (_, i) => DEMO_START.includes(i));
360
441
  function readStore(key) {
361
442
  try {
362
443
  return localStorage.getItem(key);
@@ -385,15 +466,21 @@ function ensureAssets(fonts) {
385
466
  document.head.appendChild(l);
386
467
  }
387
468
  }
388
- function template(items, M, demo) {
389
- const tiles = items.map(
390
- (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>`
391
- ).join("");
469
+ function template(L, M, demo) {
470
+ const n = L.size;
471
+ const tiles = Array.from({ length: n * n }, (_, c) => {
472
+ const gd = `--pkb-gd:${+((Math.floor(c / n) + c % n) * 0.36 / Math.max(1, n - 1)).toFixed(3)}s`;
473
+ if (c === L.center)
474
+ return `<div class="pkb-tile pkb-free pkb-on" style="${gd}"><span class="pkb-stamp">${esc(M.free)}</span></div>`;
475
+ const it = L.items[L.itemCell.indexOf(c)];
476
+ 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>`;
477
+ }).join("");
478
+ const scale = `--pk-n:${n};--pk-s:${+Math.min(1.35, 4 / n).toFixed(3)};--pk-d:${+Math.min(1, 4 / n).toFixed(3)}`;
392
479
  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>` : "";
393
- return `<div class="pkb"><div class="pkb-board">
480
+ return `<div class="pkb" style="${scale}"><div class="pkb-board">
394
481
  <div class="pkb-head"><div>
395
482
  <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>
396
- <h2 class="pkb-title">${esc(M.title)}</h2><p class="pkb-sub">${esc(M.subtitle)}</p></div>
483
+ <h2 class="pkb-title">${esc(M.title)}</h2>${M.subtitle ? `<p class="pkb-sub">${esc(M.subtitle)}</p>` : ""}</div>
397
484
  <div class="pkb-side"><div class="pkb-score">
398
485
  <div class="pkb-pill"><small>${esc(M.packed)}</small><b data-r="cnt"></b></div>
399
486
  <div class="pkb-pill" data-r="bp"><small>${esc(M.bingo)}</small><b data-r="bng"></b></div>
@@ -404,20 +491,28 @@ function template(items, M, demo) {
404
491
  <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>
405
492
  <div class="pkb-flash" data-r="flash"></div>
406
493
  <div class="pkb-perfect" data-r="perfect"><div class="pkb-card" role="dialog" aria-label="${esc(M.perfectTitle)}">
407
- <div class="pkb-medal">${esc(M.perfectStamp)}</div><h3 class="pkb-ptitle">${esc(M.perfectTitle)}</h3><p class="pkb-pbody">${esc(M.perfectBody)}</p>
494
+ <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>
408
495
  <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>
409
496
  <button type="button" class="pkb-close" data-r="close">${esc(M.close)}</button></div></div>
410
497
  <canvas class="pkb-fx"></canvas></div>${demoBar}</div>`;
411
498
  }
499
+ var perfectText = (M, total) => typeof M.perfectBody === "function" ? M.perfectBody(total) : M.perfectBody;
412
500
  function createPackingBingo(container, options = {}) {
413
- const items = options.items ?? DEFAULT_ITEMS;
414
- if (items.length !== 16) throw new Error(`[packing-bingo] "items" must have exactly 16 entries (got ${items.length}).`);
501
+ const L = resolveLayout(options);
502
+ const { items, lines, cellLines } = L;
503
+ const n = L.size, N = items.length;
504
+ const normalize = (v) => Array.from({ length: N }, (_, i) => !!v?.[i]);
505
+ const demoCells = [...cellLines[0], ...cellLines[2 * n].slice(1, -1)];
506
+ const demoItems = new Set(L.itemCell.flatMap((c, i) => demoCells.includes(c) ? [i] : []));
507
+ const demoStart = () => Array.from({ length: N }, (_, i) => demoItems.has(i));
508
+ const far = (c) => Math.floor(c / n) + c % n;
509
+ const dScale = Math.min(1, 4 / n);
415
510
  const M = { ...ja, ...options.messages };
416
511
  if (options.title != null) M.title = options.title;
417
512
  if (options.tag != null) M.tag = options.tag;
418
513
  if (options.subtitle != null) M.subtitle = options.subtitle;
419
514
  const controlled = !!options.controlled;
420
- const storageKey = controlled ? null : options.storageKey ?? null;
515
+ const storageKey = resolveStorageKey(options);
421
516
  const demo = !!options.demo;
422
517
  const calm = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
423
518
  let dead = false;
@@ -432,7 +527,7 @@ function createPackingBingo(container, options = {}) {
432
527
  const wait = (ms) => new Promise((r) => window.setTimeout(r, ms));
433
528
  let gen = 0;
434
529
  ensureAssets(options.fonts !== false);
435
- container.innerHTML = template(items, M, demo);
530
+ container.innerHTML = template(L, M, demo);
436
531
  const $ = (r) => {
437
532
  const el = container.querySelector(`[data-r="${r}"]`);
438
533
  if (!el) throw new Error(`[packing-bingo] internal: missing element "${r}"`);
@@ -444,7 +539,9 @@ function createPackingBingo(container, options = {}) {
444
539
  const svg = container.querySelector(".pkb-lines");
445
540
  const cv = container.querySelector(".pkb-fx");
446
541
  const ctx = cv.getContext("2d");
447
- const tiles = Array.from(grid.children);
542
+ const cellTiles = Array.from(grid.children);
543
+ const tiles = L.itemCell.map((c) => cellTiles[c]);
544
+ const freeTile = L.center >= 0 ? cellTiles[L.center] : null;
448
545
  const ins = tiles.map((t) => t.querySelector("input"));
449
546
  const kick = $("kick"), snd = $("snd"), perfectEl = $("perfect");
450
547
  let checked = normalize(void 0);
@@ -479,14 +576,14 @@ function createPackingBingo(container, options = {}) {
479
576
  cv.width = Math.round(BW * dpr);
480
577
  cv.height = Math.round(BH * dpr);
481
578
  };
482
- const piece = (x, y, vx, vy, g, s, life, shape) => ({
579
+ const piece = (x, y, vx, vy, g, s, life, shape2) => ({
483
580
  x,
484
581
  y,
485
582
  vx,
486
583
  vy,
487
584
  g,
488
585
  life,
489
- shape,
586
+ shape: shape2,
490
587
  w: (5 + Math.random() * 6) * s,
491
588
  h: (3 + Math.random() * 5) * s,
492
589
  r: Math.random() * 6,
@@ -496,9 +593,9 @@ function createPackingBingo(container, options = {}) {
496
593
  const go = () => {
497
594
  if (!raf && ctx) raf = requestAnimationFrame(tick);
498
595
  };
499
- function burst(x, y, n, o = {}) {
596
+ function burst(x, y, n2, o = {}) {
500
597
  if (calm) return;
501
- for (let i = 0; i < n; i++) {
598
+ for (let i = 0; i < n2; i++) {
502
599
  const a = (o.angle ?? -Math.PI / 2) + (Math.random() - 0.5) * (o.spread ?? Math.PI * 2);
503
600
  const v = (o.speed ?? 10) * (0.35 + Math.random() * 0.85);
504
601
  const sh = Math.random() < (o.star ?? 0.15) ? "star" : Math.random() < 0.3 ? "round" : "rect";
@@ -589,11 +686,11 @@ function createPackingBingo(container, options = {}) {
589
686
  void el.offsetWidth;
590
687
  el.classList.add(cls);
591
688
  };
592
- const hop = (i, d) => {
689
+ const hop = (t, d) => {
593
690
  if (calm) return;
594
- tiles[i].classList.remove("pkb-squash");
595
- tiles[i].style.setProperty("--pkb-d", d + "ms");
596
- replay(tiles[i], "pkb-hop");
691
+ t.classList.remove("pkb-squash");
692
+ t.style.setProperty("--pkb-d", d + "ms");
693
+ replay(t, "pkb-hop");
597
694
  };
598
695
  grid.addEventListener("animationend", (e) => {
599
696
  const t = e.target;
@@ -641,8 +738,8 @@ function createPackingBingo(container, options = {}) {
641
738
  st(t0);
642
739
  if (!calm) replay($("sp"), "pkb-bump");
643
740
  }
644
- const showGain = (n, x, y, cls = "pt") => {
645
- floatText(`+${n}`, x, y, cls);
741
+ const showGain = (pts, x, y, cls = "pt") => {
742
+ floatText(`+${pts}`, x, y, cls);
646
743
  countScore();
647
744
  };
648
745
  function countUp(el, a, b, d, fmt, ticks = false) {
@@ -662,15 +759,14 @@ function createPackingBingo(container, options = {}) {
662
759
  };
663
760
  st(t0);
664
761
  }
665
- const ctr = (i) => {
666
- const t = tiles[i];
762
+ const ctr = (t) => {
667
763
  return { x: t.offsetLeft + t.offsetWidth / 2, y: t.offsetTop + t.offsetHeight / 2 };
668
764
  };
669
765
  function drawLines(fresh = []) {
670
766
  svg.setAttribute("viewBox", `0 0 ${gw.clientWidth} ${gw.clientHeight}`);
671
767
  svg.innerHTML = cur.map((k) => {
672
- const a = ctr(LINES[k][0]), b = ctr(LINES[k][3]);
673
- 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;
768
+ const a = ctr(cellTiles[cellLines[k][0]]), b = ctr(cellTiles[cellLines[k][n - 1]]);
769
+ 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;
674
770
  const at = `x1="${a.x - ex}" y1="${a.y - ey}" x2="${b.x + ex}" y2="${b.y + ey}" pathLength="1"`;
675
771
  return `<g${fresh.includes(k) ? ' class="pkb-draw"' : ""}><line class="pkb-u" ${at}/><line class="pkb-o" ${at}/></g>`;
676
772
  }).join("");
@@ -717,29 +813,29 @@ function createPackingBingo(container, options = {}) {
717
813
  later(() => sfx.reach(), 150);
718
814
  }
719
815
  function bingo(fresh, pts) {
720
- const n = fresh.length;
721
- const seq = [...new Set(fresh.flatMap((k) => LINES[k]))];
816
+ const nl = fresh.length;
817
+ const seq = [...new Set(fresh.flatMap((k) => cellLines[k]))].map((c) => cellTiles[c]);
722
818
  const step = Math.min(70, 300 / seq.length);
723
819
  seq.forEach(
724
- (i, j) => later(() => {
725
- hop(i, 0);
726
- const p = rel(tiles[i]);
820
+ (t, j) => later(() => {
821
+ hop(t, 0);
822
+ const p = rel(t);
727
823
  burst(p.x, p.y, 14, { speed: 5, g: 0.15, life: 30, size: 0.7, star: 0.8 });
728
824
  sfx.note(j + 3);
729
825
  }, j * step)
730
826
  );
731
827
  later(() => {
732
- $("bT").textContent = n >= 3 ? M.bannerTriple : n === 2 ? M.bannerDouble : M.bannerSingle;
733
- $("bS").textContent = n > 1 ? M.bannerMulti(n) : M.bannerNth(cur.length);
828
+ $("bT").textContent = nl >= 3 ? M.bannerTriple : nl === 2 ? M.bannerDouble : M.bannerSingle;
829
+ $("bS").textContent = nl > 1 ? M.bannerMulti(nl) : M.bannerNth(cur.length);
734
830
  replay($("banner"), "pkb-show");
735
831
  if (!calm) {
736
832
  replay($("flash"), "pkb-go");
737
833
  replay(board, "pkb-punch");
738
834
  }
739
835
  const c = rel(gw);
740
- burst(c.x, c.y, n > 1 ? 180 : 110, { speed: n > 1 ? 16 : 13, star: 0.3 });
741
- sfx.bingo(n);
742
- buzz(n > 1 ? [40, 60, 40, 60, 90] : [40, 60, 40]);
836
+ burst(c.x, c.y, nl > 1 ? 180 : 110, { speed: nl > 1 ? 16 : 13, star: 0.3 });
837
+ sfx.bingo(nl);
838
+ buzz(nl > 1 ? [40, 60, 40, 60, 90] : [40, 60, 40]);
743
839
  if (pts) later(() => showGain(pts, c.x, c.y + 105, "big"), 350);
744
840
  }, seq.length * step + 60);
745
841
  }
@@ -747,9 +843,10 @@ function createPackingBingo(container, options = {}) {
747
843
  const bonus = gotP ? 0 : 1e3;
748
844
  gotP = true;
749
845
  score += bonus;
750
- tiles.forEach((_, i) => hop(i, ((i >> 2) + (i & 3)) * 60));
846
+ cellTiles.forEach((t, c) => hop(t, far(c) * 180 / Math.max(1, n - 1)));
751
847
  sfx.perfect();
752
848
  if (!calm) replay($("flash"), "pkb-go");
849
+ $("pbody").textContent = perfectText(M, N);
753
850
  $("sScore").textContent = "0";
754
851
  $("sCombo").textContent = "\xD70";
755
852
  $("sBingo").textContent = "0";
@@ -799,27 +896,28 @@ function createPackingBingo(container, options = {}) {
799
896
  function render(src, quiet) {
800
897
  const events = [];
801
898
  const on = checked, c = on.filter(Boolean).length;
802
- cur = LINES.flatMap((l, k) => l.every((i) => on[i]) ? [k] : []);
899
+ cur = lines.flatMap((l, k) => l.every((i) => on[i]) ? [k] : []);
803
900
  const fresh = cur.filter((k) => !prevLines.has(k));
804
901
  if ([...prevLines].some((k) => !cur.includes(k))) {
805
902
  cancelFx();
806
- if (c < 16) perfectEl.classList.remove("pkb-show");
903
+ if (c < N) perfectEl.classList.remove("pkb-show");
807
904
  }
808
- const hit = new Set(cur.flatMap((k) => LINES[k]));
905
+ const hit = new Set(cur.flatMap((k) => cellLines[k]));
809
906
  const reach = /* @__PURE__ */ new Set();
810
- for (const l of LINES) {
907
+ for (const l of lines) {
811
908
  const m = l.filter((i) => !on[i]);
812
909
  if (m.length === 1) reach.add(m[0]);
813
910
  }
814
911
  tiles.forEach((t, i) => {
815
912
  t.classList.toggle("pkb-on", on[i]);
816
- t.classList.toggle("pkb-line", hit.has(i));
913
+ t.classList.toggle("pkb-line", hit.has(L.itemCell[i]));
817
914
  t.classList.toggle("pkb-reach", reach.has(i));
818
915
  });
819
- board.classList.toggle("pkb-all", c === 16);
820
- kick.classList.toggle("pkb-gold", c === 16);
821
- kick.textContent = c === 16 ? M.perfectTag : M.tag;
822
- $("cnt").textContent = `${c}/16`;
916
+ freeTile?.classList.toggle("pkb-line", hit.has(L.center));
917
+ board.classList.toggle("pkb-all", c === N);
918
+ kick.classList.toggle("pkb-gold", c === N);
919
+ kick.textContent = c === N ? M.perfectTag : M.tag;
920
+ $("cnt").textContent = `${c}/${N}`;
823
921
  $("bng").textContent = String(cur.length);
824
922
  const bp = $("bp");
825
923
  if (cur.length > lastN) {
@@ -836,7 +934,7 @@ function createPackingBingo(container, options = {}) {
836
934
  score += linePts;
837
935
  if (quiet) {
838
936
  if (linePts) countScore();
839
- } else if (c === 16 && !prevAll) perfect(events);
937
+ } else if (c === N && !prevAll) perfect(events);
840
938
  else if (fresh.length) {
841
939
  bingo(fresh, linePts);
842
940
  const e = { newLines: fresh, totalLines: cur.length, score };
@@ -847,9 +945,9 @@ function createPackingBingo(container, options = {}) {
847
945
  }
848
946
  prevLines = new Set(cur);
849
947
  prevReach = reach;
850
- prevAll = c === 16;
948
+ prevAll = c === N;
851
949
  const labels = [...reach].map((i) => items[i].label);
852
- $("msg").textContent = c === 16 ? M.statusDone : labels.length ? M.statusReach(labels) : cur.length ? M.statusLines(cur.length, 16 - c) : M.statusStart(16 - c);
950
+ $("msg").textContent = c === N ? M.statusDone : labels.length ? M.statusReach(labels) : cur.length ? M.statusLines(cur.length, N - c) : M.statusStart(N - c);
853
951
  return events;
854
952
  }
855
953
  const flush = (events) => {
@@ -876,7 +974,7 @@ function createPackingBingo(container, options = {}) {
876
974
  got.clear();
877
975
  gotL.clear();
878
976
  checked.forEach((v, i) => v && got.add(i));
879
- LINES.forEach((l, k) => l.every((i) => checked[i]) && gotL.add(k));
977
+ lines.forEach((l, k) => l.every((i) => checked[i]) && gotL.add(k));
880
978
  prevLines = new Set(gotL);
881
979
  prevReach = /* @__PURE__ */ new Set();
882
980
  prevAll = checked.every(Boolean);
@@ -936,12 +1034,13 @@ function createPackingBingo(container, options = {}) {
936
1034
  }
937
1035
  };
938
1036
  $("dB").addEventListener("click", () => {
939
- const open = () => LINES.map((l) => l.filter((i) => !checked[i])).filter((m) => m.length).sort((a, b) => a.length - b.length)[0] ?? [];
940
- const pick = checked.every(Boolean) ? [...LINES[0]] : open();
1037
+ const open = () => lines.map((l) => l.filter((i) => !checked[i])).filter((m) => m.length).sort((a, b) => a.length - b.length)[0] ?? [];
1038
+ const pick = checked.every(Boolean) ? [...lines[0]] : open();
941
1039
  void run(pick.map((i) => ({ i, delay: 320 })));
942
1040
  });
943
1041
  $("dP").addEventListener("click", () => {
944
- const miss = checked.every(Boolean) ? tiles.map((_, i) => i) : tiles.map((_, i) => i).filter((i) => !checked[i]);
1042
+ const all = items.map((_, i) => i);
1043
+ const miss = checked.every(Boolean) ? all : all.filter((i) => !checked[i]);
945
1044
  void run(miss.map((i, k) => k < miss.length - 1 ? { i, delay: 170, quiet: true } : { i, delay: 450 }));
946
1045
  });
947
1046
  $("dR").addEventListener("click", () => {
@@ -969,14 +1068,14 @@ function createPackingBingo(container, options = {}) {
969
1068
  return {
970
1069
  setChecked(next, opts = {}) {
971
1070
  if (dead) return;
972
- const n = normalize(next);
973
- const changed = n.flatMap((v, i) => v !== checked[i] ? [i] : []);
1071
+ const n2 = normalize(next);
1072
+ const changed = n2.flatMap((v, i) => v !== checked[i] ? [i] : []);
974
1073
  if (!changed.length) return;
975
1074
  if (!opts.animate || changed.length !== 1) {
976
- baseline(n);
1075
+ baseline(n2);
977
1076
  return;
978
1077
  }
979
- flush(apply(n, changed[0], false));
1078
+ flush(apply(n2, changed[0], false));
980
1079
  },
981
1080
  reset() {
982
1081
  if (!dead) request(normalize(void 0), null, { reset: true });
@@ -985,7 +1084,7 @@ function createPackingBingo(container, options = {}) {
985
1084
  if (!dead) render(null, true);
986
1085
  },
987
1086
  getState() {
988
- return { checked: checked.slice(), lines: cur.slice(), score, maxCombo, complete: checked.every(Boolean) };
1087
+ return { checked: checked.slice(), lines: cur.slice(), score, maxCombo, complete: checked.every(Boolean), size: n, free: L.free };
989
1088
  },
990
1089
  destroy() {
991
1090
  if (dead) return;
@@ -1013,7 +1112,8 @@ var PackingBingo = (0, import_react.forwardRef)(function PackingBingo2(props, re
1013
1112
  messagesRef.current = messages;
1014
1113
  const controlled = checked !== void 0;
1015
1114
  const latest = (0, import_react.useRef)(checked ?? defaultChecked);
1016
- const latestKey = (0, import_react.useRef)(config.storageKey);
1115
+ const storageKey = resolveStorageKey({ ...config, controlled });
1116
+ const latestKey = (0, import_react.useRef)(storageKey);
1017
1117
  if (controlled) latest.current = checked;
1018
1118
  const messageShape = messages ? Object.fromEntries(Object.entries(messages).map(([k, v]) => [k, typeof v === "function" ? "\u0192" : v])) : null;
1019
1119
  const key = JSON.stringify({ ...config, messageShape, controlled });
@@ -1029,7 +1129,7 @@ var PackingBingo = (0, import_react.forwardRef)(function PackingBingo2(props, re
1029
1129
  ...config,
1030
1130
  messages: liveMessages,
1031
1131
  controlled,
1032
- defaultChecked: controlled || latestKey.current === config.storageKey ? latest.current : defaultChecked,
1132
+ defaultChecked: controlled || latestKey.current === storageKey ? latest.current : defaultChecked,
1033
1133
  onChange: (next) => {
1034
1134
  if (!controlled) latest.current = next;
1035
1135
  callbacks.current.onChange?.(next);
@@ -1040,7 +1140,7 @@ var PackingBingo = (0, import_react.forwardRef)(function PackingBingo2(props, re
1040
1140
  inst.current = instance;
1041
1141
  return () => {
1042
1142
  if (!controlled) latest.current = instance.getState().checked;
1043
- latestKey.current = config.storageKey;
1143
+ latestKey.current = storageKey;
1044
1144
  instance.destroy();
1045
1145
  if (inst.current === instance) inst.current = null;
1046
1146
  };
@@ -1067,6 +1167,7 @@ var PackingBingo = (0, import_react.forwardRef)(function PackingBingo2(props, re
1067
1167
  DEFAULT_ITEMS,
1068
1168
  LINES,
1069
1169
  PackingBingo,
1170
+ bingoLines,
1070
1171
  createPackingBingo,
1071
1172
  en,
1072
1173
  ja