packing-bingo 0.1.1 → 0.2.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/README.md CHANGED
@@ -1,104 +1,182 @@
1
- # Packing Bingo (持ちものビンゴ)
1
+ # 持ちものビンゴ(Packing Bingo)
2
2
 
3
- **A React component that turns a school-trip packing checklist into a 4×4 bingo card: students stamp each item as it goes in the bag, complete lines, and get combos, fanfares and fireworks. One component, no runtime dependencies, safe to render on the server.**
3
+ **修学旅行の持ち物チェックリストを、4×4のビンゴにするReactコンポーネントです。持ち物をかばんに入れるたびにマスをタップしてスタンプを押し、タテ・ヨコ・ナナメがそろうと、コンボ・ファンファーレ・花火で盛り上がります。進み具合は各自のブラウザに自動で保存されます。**
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/packing-bingo?logo=npm)](https://www.npmjs.com/package/packing-bingo)
6
6
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
7
- [![React 18 or later](https://img.shields.io/badge/React-%E2%89%A518-61DAFB?logo=react&logoColor=black)](#at-a-glance)
7
+ [![React 18 or later](https://img.shields.io/badge/React-%E2%89%A518-61DAFB?logo=react&logoColor=black)](#概要)
8
8
  [![TypeScript types included](https://img.shields.io/badge/types-TypeScript-3178C6?logo=typescript&logoColor=white)](#api)
9
- [![Standalone single HTML file](https://img.shields.io/badge/standalone-single%20HTML%20file-E34F26?logo=html5&logoColor=white)](#standalone-page)
9
+ [![Standalone single HTML file](https://img.shields.io/badge/standalone-single%20HTML%20file-E34F26?logo=html5&logoColor=white)](#単体のページ)
10
10
 
11
- [Quick start](#quick-start) · [Recipes](#recipes) · [API](#api) · [Integration notes](#integration-notes) · [Without React](#without-react) · [日本語](#日本語) · [Changelog](CHANGELOG.md)
11
+ [クイックスタート](#クイックスタート) · [使い方の例](#使い方の例) · [API](#api) · [組み込むときの注意](#組み込むときの注意) · [Reactなしで使う](#reactなしで使う) · [English](#english) · [変更履歴](CHANGELOG.md)
12
12
 
13
13
  <p align="center">
14
- <a href="docs/images/bingo-desktop.png"><img src="docs/images/bingo-desktop.png" alt="A 4×4 bingo card of packing items. A diagonal line has just been completed: a large BINGO! banner, a +100 score popup and confetti are on screen, and the score panel shows 8/16 items, 2 lines and a 2-tap combo." width="560"></a>
14
+ <a href="docs/images/bingo-desktop.png"><img src="docs/images/bingo-desktop.png" alt="持ち物が並んだ4×4のビンゴカード。ナナメの列がそろった瞬間で、大きなBINGO!の文字、+100の得点、紙吹雪が出ている。上の欄には8/16、2ライン、2コンボと表示されている。" width="560"></a>
15
15
  </p>
16
16
 
17
- _The second line completes on a two-tap combo: the line is drawn, the BINGO! banner and +100 pop up, and confetti bursts. The default UI is Japanese: 持ちものビンゴ = "Packing Bingo", そろった = "packed", 2ライン目 = "2nd line". An English text set is included._
17
+ _2コンボで2列目がそろったところ。列に線が引かれ、BINGO!と+100が出て、紙吹雪が舞います。_
18
18
 
19
19
  <p>
20
- <a href="docs/images/phone-reach.png"><img src="docs/images/phone-reach.png" alt="Phone layout with one line completed and a dashed, pulsing square labeled リーチ (reach) on the last missing item." height="320"></a>
21
- <a href="docs/images/phone-combo.png"><img src="docs/images/phone-combo.png" alt="Phone layout during a three-tap combo: +20 and +30 popups, 2 COMBO! and 3 COMBO! labels, a draining combo gauge and two リーチ callouts." height="320"></a>
22
- <a href="docs/images/phone-perfect.png"><img src="docs/images/phone-perfect.png" alt="Phone layout after all 16 items are stamped: a 準備完了 (ready) stamp, the Perfect! card with score 3,410, max combo ×10 and 10 lines, with confetti and falling item emoji." height="320"></a>
20
+ <a href="docs/images/phone-reach.png"><img src="docs/images/phone-reach.png" alt="スマホの画面。1列そろっていて、次の列を完成させる最後のマスが点線で囲まれ、リーチの札が付いて点滅している。" height="320"></a>
21
+ <a href="docs/images/phone-combo.png"><img src="docs/images/phone-combo.png" alt="スマホの画面。3回続けてタップしたところで、+20と+30、2 COMBO!と3 COMBO!の表示、減っていくコンボのゲージ、2つのリーチが見える。" height="320"></a>
22
+ <a href="docs/images/phone-perfect.png"><img src="docs/images/phone-perfect.png" alt="スマホの画面。16個すべてにスタンプを押したあと。準備完了のスタンプと、得点3,410、最大コンボ×10、10ラインのパーフェクト画面に、紙吹雪と持ち物の絵文字が降っている。" height="320"></a>
23
23
  </p>
24
24
 
25
- _On a phone, left to right: one line done and a リーチ ("reach") badge on the square that would finish the next one; a three-tap combo; the Perfect! screen with the final score, fireworks and falling item emoji._
25
+ _スマホでの表示。左から、1列そろって次の列にリーチがかかったところ、3回続けたコンボ、全部そろったときのパーフェクト画面です。_
26
26
 
27
- ## What it does
27
+ ## できること
28
28
 
29
- Packing the night before a school trip is a chore. Packing Bingo puts the 16 items on a bingo card, so every packed item is a stamp, and every few items there's something to celebrate.
29
+ 修学旅行の前の晩の荷造りは面倒です。持ちものビンゴは16個の持ち物をビンゴカードに並べるので、持ち物を1つ入れるたびにスタンプが押せて、何個かごとにお祝いがあります。
30
30
 
31
- - **Every tap gets feedback.** A stamp, a ripple, sparks and a rising note. Taps less than 2.5 s apart chain into a combo.
32
- - **There's always a next goal.** A square that would complete a line pulses with a リーチ ("reach") badge, and the status line names the item.
33
- - **Lines and the full card are celebrated.** Any row, column or diagonal (10 lines in all) is a bingo, with a banner and confetti. Stamping all 16 plays a finale with fireworks and a score card.
34
- - **Points reward finishing,** not tapping on and off. See [Scoring](#scoring).
35
- - **It's considerate by default.** Squares are real checkboxes, so the keyboard works. `prefers-reduced-motion` is respected. The sound is synthesized (no audio files) and can be muted.
31
+ - **タップするたびに反応があります。** スタンプ、波紋、火花、少しずつ高くなる音。2.5秒以内に続けてタップすると、コンボになります。
32
+ - **次の目標がいつも見えます。** あと1つで列がそろうマスは、リーチの札を付けて点滅し、下の1行にその持ち物の名前が出ます。
33
+ - **列がそろうたびにお祝いします。** タテ・ヨコ・ナナメの10列のどれかがそろうと、BINGO!の文字と紙吹雪が出ます。16個すべてそろうと、花火とスコアカードのフィナーレになります。
34
+ - **得点は、そろえることへのごほうびです。** 付けたり外したりしても増えません。[得点のルール](#得点のルール)を見てください。
35
+ - **進み具合は自動で保存されます。** 各自のブラウザ(localStorage)に保存されるので、ページを閉じても続きから始められます。サーバーには何も送りません。
36
+ - **だれでも使えるように配慮しています。** マスは本物のチェックボックスなので、キーボードで操作できます。`prefers-reduced-motion`(動きを減らす設定)に従います。音は合成した音(音声ファイルなし)で、消すこともできます。
36
37
 
37
- ## At a glance
38
+ ## 概要
38
39
 
39
- | Area | Details |
40
+ | 項目 | 内容 |
40
41
  | --- | --- |
41
- | Install | `npm install packing-bingo`: ESM and CommonJS, TypeScript types included |
42
- | Requires | React 18 or later and `react-dom`. No other dependencies, no CSS import, no provider |
43
- | Size | About 13 kB minified and gzipped, CSS included (React not counted) |
44
- | Server rendering | Ships with `"use client"`. On the server it renders an empty `<div>`, and the card is built after mount |
45
- | Styling | Injects one `<style>` into `<head>`. Every class is prefixed `pkb-`. Themable with CSS variables, and it sizes itself to its container |
46
- | Network | Loads two fonts from Google Fonts, unless you pass `fonts={false}` |
47
- | Storage | localStorage, only for `storageKey` progress and the mute setting |
48
- | Browsers | Chrome and Edge 105+, Safari 16+, Firefox 121+. Only tested in Chromium so far ([details](#browser-support-and-testing)) |
42
+ | インストール | `npm install packing-bingo`。ESMとCommonJSの両方、TypeScriptの型付き |
43
+ | 必要なもの | React 18以上と `react-dom`。ほかに依存するパッケージはありません。CSSの読み込みもProviderも不要です |
44
+ | サイズ | 約13 kB(minify・gzip後、CSS込み、Reactは含まず) |
45
+ | サーバー側での表示 | `"use client"` 付きで配布しています。サーバー側では空の `<div>` を出し、ブラウザでカードを作ります |
46
+ | 見た目 | `<head>` に `<style>` を1つ追加します。クラス名はすべて `pkb-` で始まります。CSS変数で配色を変えられ、置いた場所の幅に合わせて自動で大きさが変わります |
47
+ | 通信 | Google Fontsからフォントを2つ読み込みます(`fonts={false}` で止められます) |
48
+ | 保存 | localStorageだけ。進み具合と、音のオン・オフの設定 |
49
+ | ブラウザ | Chrome・Edge 105以上、Safari 16以上、Firefox 121以上。今のところChromiumでしか試していません([詳しく](#対応ブラウザと確認済みの範囲)) |
49
50
 
50
- ## Quick start
51
+ ## クイックスタート
52
+
53
+ コピーして貼り付けるだけで、サイトの好きな場所に置けます。進み具合は各自のブラウザに自動で保存されるので、保存の設定も要りません。
54
+
55
+ ### 1. インストールする
51
56
 
52
57
  ```sh
53
- npm install packing-bingo # or: pnpm add packing-bingo / yarn add packing-bingo
58
+ npm install packing-bingo
54
59
  ```
55
60
 
61
+ pnpmなら `pnpm add packing-bingo`、yarnなら `yarn add packing-bingo` です。
62
+
63
+ ### 2. 部品のファイルを作る
64
+
65
+ `OkinawaPackingBingo.tsx` というファイルを作り、次をそのまま貼り付けます。置き場所はどこでも構いません(例:`components/OkinawaPackingBingo.tsx`)。
66
+
56
67
  ```tsx
57
- import { PackingBingo } from "packing-bingo";
68
+ import { PackingBingo, type BingoItem } from "packing-bingo";
58
69
 
59
- export default function PackingPage() {
60
- return <PackingBingo storageKey="kyoto-trip-2026" />;
70
+ // 沖縄の修学旅行の持ち物。ちょうど16個、左上から横に4つずつ並びます。
71
+ const items: BingoItem[] = [
72
+ { emoji: "📖", label: "旅のしおり" },
73
+ { emoji: "🪪", label: "生徒手帳" },
74
+ { emoji: "🏥", label: "保険証コピー" },
75
+ { emoji: "👛", label: "おこづかい" },
76
+ { emoji: "👕", label: "着替え" },
77
+ { emoji: "🩱", label: "水着" },
78
+ { emoji: "🧴", label: "日焼け止め" },
79
+ { emoji: "🧢", label: "帽子" },
80
+ { emoji: "🕶️", label: "サングラス" },
81
+ { emoji: "🩴", label: "ビーチサンダル" },
82
+ { emoji: "🪥", label: "洗面用具" },
83
+ { emoji: "🛁", label: "タオル" },
84
+ { emoji: "💊", label: "常備薬・酔い止め" },
85
+ { emoji: "☂️", label: "折りたたみ傘" },
86
+ { emoji: "🛍️", label: "ビニール袋" },
87
+ { emoji: "📱", label: "スマホ・充電器" },
88
+ ];
89
+
90
+ export function OkinawaPackingBingo() {
91
+ return <PackingBingo items={items} tag="沖縄修学旅行" />;
61
92
  }
62
93
  ```
63
94
 
64
- That's the whole setup. The card fills the width of its container, up to 720 px. With `storageKey`, each student's checks are saved in their browser, so they can close the page and come back.
95
+ ### 3. 表示したい場所に置く
65
96
 
66
- **Next.js App Router:** you can use the component straight from a Server Component page, as above, including `items` and `messages={en}`. Functions you write can't cross from a Server Component to a Client Component, though, so callbacks like `onChange` or `onBingo` stop the build with "Event handlers cannot be passed to Client Component props". For those, wrap the card in your own client component:
97
+ 表示したいページやコンポーネントで読み込み、置きたい場所に `<OkinawaPackingBingo />` と書きます。`import` のパスは、ファイルを置いた場所に合わせてください。
67
98
 
68
99
  ```tsx
69
- // app/packing/PackingCard.tsx
70
- "use client";
100
+ import { OkinawaPackingBingo } from "./components/OkinawaPackingBingo";
71
101
 
72
- import { PackingBingo } from "packing-bingo";
102
+ // JSXの中の、表示したい場所に
103
+ <OkinawaPackingBingo />
104
+ ```
105
+
106
+ Next.js(App Router)で専用のページを作るなら、`app/packing/page.tsx` を作って次を貼り付けます。`/packing` を開くと表示されます。
107
+
108
+ ```tsx
109
+ import { OkinawaPackingBingo } from "@/components/OkinawaPackingBingo";
73
110
 
74
- export function PackingCard() {
75
- return <PackingBingo storageKey="kyoto-trip-2026" onBingo={(e) => console.log(e)} />;
111
+ export default function PackingPage() {
112
+ return (
113
+ <main style={{ padding: 16 }}>
114
+ <OkinawaPackingBingo />
115
+ </main>
116
+ );
76
117
  }
77
118
  ```
78
119
 
79
- ## Recipes
120
+ `@/` は、`create-next-app` で作ったプロジェクトに最初から設定されている、プロジェクトの一番上を指す書き方です。部品のファイルは `components/OkinawaPackingBingo.tsx` に置いてください。`src/` フォルダを使うプロジェクトでは `src/components/OkinawaPackingBingo.tsx` です。`@/` が使えないプロジェクトでは、`../../components/OkinawaPackingBingo` のような相対パスに書き換えてください。
121
+
122
+ カードは置いた場所の幅いっぱい(最大720 px)に広がり、狭い場所ではスマホ向けの配置に自動で切り替わります。持ち物を変えたいときは `items` の中身を書き換えるだけです。`items` を省くと、一般的な修学旅行の持ち物リストになります。
80
123
 
81
- ### Your own items and text
124
+ Next.jsのServer Componentのページからでも、そのまま使えます。ただし、`onBingo` などの関数(コールバック)はServer Componentから渡せず、「Event handlers cannot be passed to Client Component props」というエラーでビルドが止まります。コールバックを使うときは、部品のファイルの1行目に `"use client";` を書いてください。
125
+
126
+ ### Reactを使っていないページに置く場合
127
+
128
+ HTMLの好きな場所に、次をそのまま貼り付けます。インストールは要りません。
129
+
130
+ ```html
131
+ <div id="packing-bingo"></div>
132
+ <script src="https://cdn.jsdelivr.net/npm/packing-bingo@0.2/dist/packing-bingo.global.js"></script>
133
+ <script>
134
+ PackingBingo.createPackingBingo(document.getElementById("packing-bingo"), {
135
+ tag: "沖縄修学旅行",
136
+ items: [
137
+ { emoji: "📖", label: "旅のしおり" }, { emoji: "🪪", label: "生徒手帳" },
138
+ { emoji: "🏥", label: "保険証コピー" }, { emoji: "👛", label: "おこづかい" },
139
+ { emoji: "👕", label: "着替え" }, { emoji: "🩱", label: "水着" },
140
+ { emoji: "🧴", label: "日焼け止め" }, { emoji: "🧢", label: "帽子" },
141
+ { emoji: "🕶️", label: "サングラス" }, { emoji: "🩴", label: "ビーチサンダル" },
142
+ { emoji: "🪥", label: "洗面用具" }, { emoji: "🛁", label: "タオル" },
143
+ { emoji: "💊", label: "常備薬・酔い止め" }, { emoji: "☂️", label: "折りたたみ傘" },
144
+ { emoji: "🛍️", label: "ビニール袋" }, { emoji: "📱", label: "スマホ・充電器" },
145
+ ],
146
+ });
147
+ </script>
148
+ ```
149
+
150
+ ## 使い方の例
151
+
152
+ ### 持ち物と文言を変える
82
153
 
83
154
  ```tsx
84
155
  import { PackingBingo, en, type BingoItem } from "packing-bingo";
85
156
 
86
157
  const items: BingoItem[] = [
87
- { emoji: "🛂", label: "Passport" },
88
- { emoji: "🎫", label: "Tickets" },
89
- // … exactly 16 items, row by row from the top left
158
+ { emoji: "🩱", label: "Swimsuit" },
159
+ { emoji: "🧴", label: "Sunscreen" },
160
+ // … ちょうど16個、左上から横に1行ずつ
90
161
  ];
91
162
 
92
- <PackingBingo items={items} messages={en} title="Kyoto Trip" tag="Class 2-B" storageKey="kyoto-2b" />;
163
+ <PackingBingo items={items} messages={en} title="Okinawa Trip" tag="Class 2-B" />;
93
164
  ```
94
165
 
95
- - `items` needs exactly 16 entries. Any other count throws `[packing-bingo] "items" must have exactly 16 entries`.
96
- - `messages` is merged over the Japanese defaults, so `messages={{ reset: "Start over" }}` changes just that label. `en` is a complete English set. See [Messages](#messages) for every key.
97
- - On narrow cards, labels wrap after a `・`, so `常備薬・酔い止め` breaks as 常備薬・ / 酔い止め.
166
+ - `items` はちょうど16個にしてください。それ以外の数だと `[packing-bingo] "items" must have exactly 16 entries` というエラーになります。
167
+ - `messages` は日本語の既定値の上に重なるので、`messages={{ reset: "やり直す" }}` のように一部の文言だけを変えられます。`en` は英語の文言一式です。キーの一覧は[文言のキー](#文言のキー)にあります。
168
+ - 狭いカードでは、ラベルは `・` の後ろで折り返します。`常備薬・酔い止め` は「常備薬・」と「酔い止め」の2行になります。
169
+
170
+ ### 保存のしかたを変える
98
171
 
99
- ### Save progress on your server
172
+ 何も指定しなければ、進み具合は持ち物リストから自動で決まるキーで、各自のブラウザに保存されます。持ち物リストが違うカードは別々に保存されるので、1つのサイトに複数のチェックリストを置いても混ざりません。
100
173
 
101
- `storageKey` saves progress in one browser. To make progress follow a student to another device, keep the checks in your own state and pass them as `checked`, which makes the card controlled:
174
+ - **キーを自分で決める:** `storageKey="okinawa-2026"` のように指定します。持ち物リストから決まるキーは、持ち物を1文字でも直すと変わり、カードは新しく始まります。生徒が使い始めたあとで持ち物を直す可能性があるなら、最初からキーを決めておくと、直したあとも続きから使えます。同じ持ち物のカードを2枚、別々に保存したいときも、それぞれにキーを付けます。
175
+ - **保存しない:** `storageKey={null}` にします。
176
+
177
+ ### 自分のサーバーに保存する
178
+
179
+ ブラウザへの保存は、その端末の中だけで使えます。別の端末でも続きから使いたい場合だけ、チェックを自分の state で持って `checked` で渡します(controlledといいます)。このときブラウザには保存しません。
102
180
 
103
181
  ```tsx
104
182
  import { useEffect, useRef, useState } from "react";
@@ -109,7 +187,7 @@ export function PackingCard({ studentId }: { studentId: string }) {
109
187
  const saving = useRef(Promise.resolve());
110
188
 
111
189
  useEffect(() => {
112
- let current = true; // ignore a response that arrives after the student has changed
190
+ let current = true; // 生徒が切り替わったあとに届いた古い応答は使わない
113
191
  setChecked(null);
114
192
  fetch(`/api/packing/${studentId}`)
115
193
  .then((res) => res.json())
@@ -121,11 +199,11 @@ export function PackingCard({ studentId }: { studentId: string }) {
121
199
  };
122
200
  }, [studentId]);
123
201
 
124
- if (!checked) return null; // or a placeholder
202
+ if (!checked) return null; // または同じ大きさの仮表示
125
203
 
126
204
  const save = (next: boolean[]) => {
127
205
  setChecked(next);
128
- // Send one request at a time, so a slow earlier save can't overwrite a later one.
206
+ // 保存は1つずつ順に送る。遅れた古い保存が、新しい保存を上書きしないように。
129
207
  saving.current = saving.current
130
208
  .then(() => fetch(`/api/packing/${studentId}`, { method: "PUT", body: JSON.stringify({ checked: next }) }))
131
209
  .then(() => undefined, () => undefined);
@@ -135,31 +213,31 @@ export function PackingCard({ studentId }: { studentId: string }) {
135
213
  }
136
214
  ```
137
215
 
138
- Students tap faster than requests finish, so the example sends the saves one after another, and it drops a load that arrives after `studentId` has changed. Each save sends all 16 checks, so a later save always holds the latest state.
216
+ 生徒のタップは通信より速いので、この例では保存を順番に送り、`studentId` が変わったあとに届いた読み込みは捨てています。保存のたびに16個すべてを送るので、最後の保存がいつも最新の状態になります。
139
217
 
140
- How a controlled card behaves:
218
+ controlledのカードの動き:
141
219
 
142
- - A tap doesn't change the card by itself. It calls `onChange` with the 16 booleans the user asked for, and the card follows when `checked` changes. If you don't update `checked`, the tap is undone.
143
- - When `checked` changes by exactly one item, the card plays it like a tap: stamp, sound, and any bingo or finale. A bigger change, such as data arriving from your server, is applied quietly, with no fanfare and no bonus for lines completed at once.
144
- - The reset button calls `onChange` with 16 `false` values.
145
- - `storageKey` is ignored.
220
+ - タップしただけでは、カードは変わりません。ユーザーが求めた16個の状態で `onChange` が呼ばれ、`checked` が変わるとカードがそれに合わせます。`checked` を変えなければ、タップは取り消されます。
221
+ - `checked` がちょうど1個だけ変わったときは、タップと同じように演出します(スタンプ、音、ビンゴやフィナーレ)。サーバーからデータが届いたときのように一度に何個も変わったときは、演出なしで静かに反映し、同時にそろった列のボーナスも付けません。
222
+ - リセットボタンを押すと、16個すべて `false` で `onChange` が呼ばれます。
223
+ - `storageKey` は使われません。
146
224
 
147
- ### React to bingos
225
+ ### ビンゴの瞬間を受け取る
148
226
 
149
227
  ```tsx
150
228
  <PackingBingo
151
- storageKey="kyoto-trip-2026"
229
+ items={items}
152
230
  onBingo={({ newLines, totalLines, score }) => analytics.track("bingo", { totalLines, score })}
153
231
  onPerfect={({ score, maxCombo }) => analytics.track("perfect", { score, maxCombo })}
154
232
  />
155
233
  ```
156
234
 
157
- - `onBingo` fires when a tap completes one or more lines. On the tap that completes the whole card, `onPerfect` fires instead.
158
- - `onChange` fires only for what the user does: a tap, the reset button, or `ref.reset()`. Changes you make through `checked` or `ref.setChecked()` don't trigger it.
159
- - `onBingo` and `onPerfect` fire whenever a change plays like a tap, including a one-item change of `checked`. Quiet updates and the first render fire nothing.
160
- - `onChange` runs first, then `onBingo` or `onPerfect`, after the card has updated. You can reset or unmount the card from inside any of them.
235
+ - `onBingo` は、タップで1列以上そろったときに呼ばれます。カード全体がそろったタップでは、代わりに `onPerfect` が呼ばれます。
236
+ - `onChange` は、ユーザーの操作(タップ、リセットボタン、`ref.reset()`)のときだけ呼ばれます。`checked` や `ref.setChecked()` で変えたときは呼ばれません。
237
+ - `onBingo` と `onPerfect` は、タップと同じように演出した変化のときに呼ばれます。`checked` が1個だけ変わったときも含みます。静かに反映した変化や、最初の表示では呼ばれません。
238
+ - 順番は、まず `onChange`、次に `onBingo` か `onPerfect` です。どれもカードの表示が変わったあとに呼ばれるので、中でカードをリセットしたり、画面から外したりしても大丈夫です。
161
239
 
162
- ### Control the card from outside
240
+ ### 外から操作する
163
241
 
164
242
  ```tsx
165
243
  import { useRef } from "react";
@@ -169,233 +247,218 @@ function PackingPage() {
169
247
  const card = useRef<PackingBingoHandle>(null);
170
248
  return (
171
249
  <>
172
- <button onClick={() => card.current?.reset()}>Start over</button>
173
- <PackingBingo ref={card} storageKey="kyoto-trip-2026" />
250
+ <button onClick={() => card.current?.reset()}>最初からやり直す</button>
251
+ <PackingBingo ref={card} />
174
252
  </>
175
253
  );
176
254
  }
177
255
  ```
178
256
 
179
- The handle has `reset()`, `setChecked()` and `getState()`. See [Ref handle](#ref-handle). With a controlled card, change `checked` instead of calling `setChecked()`.
257
+ `reset()`・`setChecked()`・`getState()` が使えます。くわしくは [ref](#ref) を見てください。controlledのカードでは、`setChecked()` ではなく `checked` を変えてください。
180
258
 
181
- ### Theme and layout
259
+ ### 配色とレイアウト
182
260
 
183
- Set any of these CSS variables on the component or an ancestor:
261
+ 次のCSS変数を、カードかその親要素に指定します。
184
262
 
185
- | Variable | Used for | Default |
263
+ | 変数 | 使われる場所 | 既定値 |
186
264
  | --- | --- | --- |
187
- | `--pkb-sun` | Board background | `#FFD54F` |
188
- | `--pkb-ink` | Lines, borders and text | `#2A1F14` |
189
- | `--pkb-paper` | Squares | `#FFFDF6` |
190
- | `--pkb-red` | Stamps and accents | `#E8473B` |
191
- | `--pkb-mint` | Completed lines | `#BDEBD2` |
192
- | `--pkb-font-fallback` | Font used before or instead of the display fonts | `sans-serif` |
265
+ | `--pkb-sun` | カードの背景 | `#FFD54F` |
266
+ | `--pkb-ink` | 線、枠、文字 | `#2A1F14` |
267
+ | `--pkb-paper` | マス | `#FFFDF6` |
268
+ | `--pkb-red` | スタンプとアクセント | `#E8473B` |
269
+ | `--pkb-mint` | そろった列 | `#BDEBD2` |
270
+ | `--pkb-font-fallback` | 見出しなどのフォントが使えないときのフォント | `sans-serif` |
193
271
 
194
272
  ```tsx
195
273
  <PackingBingo style={{ "--pkb-sun": "#9AD0F5" } as React.CSSProperties} />
196
274
  ```
197
275
 
198
- The card takes the full width of its container, up to 720 px, and centers itself. When its container is narrower than 560 px, it switches to a compact layout. This uses a container query, so the compact layout also kicks in inside a narrow sidebar on a wide screen. The height follows from the width. Because the server renders an empty `<div>`, the card appears after hydration; if layout shift matters, give its wrapper a `min-height`.
276
+ カードは置いた場所の幅いっぱい(最大720 px)に広がり、中央に寄ります。置いた場所の幅が560 pxより狭いと、スマホ向けの配置に切り替わります。画面の幅ではなく置いた場所の幅で決まるので、広い画面のサイドバーに置いてもスマホ向けの配置になります。高さは幅から自動で決まります。サーバー側では空の `<div>` を出すので、カードはhydrationのあとに現れます。レイアウトのずれが気になるときは、外側の要素に `min-height` を付けてください。
199
277
 
200
278
  ## API
201
279
 
202
- ### Props
280
+ ### props
203
281
 
204
- | Prop | Type | Default | What it does |
282
+ | prop | 型 | 既定値 | 内容 |
205
283
  | --- | --- | --- | --- |
206
- | `items` | `readonly BingoItem[]` (exactly 16) | `DEFAULT_ITEMS` (Japanese school-trip list) | The squares, row by row: `{ emoji, label }` |
207
- | `messages` | `Partial<Messages>` | `ja` | UI text, merged over the Japanese defaults. `en` is included |
208
- | `title`, `tag`, `subtitle` | `string` | from `messages` | The heading, the small label above it, and the line below it |
209
- | `storageKey` | `string` | none | Save the checks in localStorage under this key. Uncontrolled cards only |
210
- | `defaultChecked` | `readonly boolean[]` (16) | all `false` | Initial checks of an uncontrolled card, used when nothing is saved under `storageKey` |
211
- | `checked` | `readonly boolean[]` (16) | none | Makes the card controlled. See [Save progress on your server](#save-progress-on-your-server) |
212
- | `onChange` | `(checked: boolean[]) => void` | none | The user tapped or reset. For a controlled card, this is the requested state |
213
- | `onBingo` | `(event: BingoEvent) => void` | none | A tap completed one or more lines |
214
- | `onPerfect` | `(event: PerfectEvent) => void` | none | A tap completed the card |
215
- | `sound` | `boolean` | `true` | Initial sound setting, until the user picks one with the 🔊 button |
216
- | `fonts` | `boolean` | `true` | Load Dela Gothic One and M PLUS Rounded 1c from Google Fonts |
217
- | `demo` | `boolean` | `false` | Pre-fill six items (when nothing is saved) and show demo buttons |
218
- | `className`, `style` | | none | Applied to the wrapper `<div>` |
219
- | `ref` | `Ref<PackingBingoHandle>` | none | See [Ref handle](#ref-handle) |
220
-
221
- Props are compared by value, so inline arrays and objects are fine. When the content of `items`, `title`, `tag`, `subtitle`, `storageKey`, `sound`, `fonts`, `demo` or the text in `messages` changes, the card is rebuilt. The checks are kept, unless `storageKey` changed, in which case the card loads that key's own progress. The score is then recomputed, as described in [Scoring](#scoring). Message functions can use your props or state: when `messages` changes, the status line is redrawn without a rebuild.
222
-
223
- ### Events
284
+ | `items` | `readonly BingoItem[]`(ちょうど16個) | `DEFAULT_ITEMS`(一般的な修学旅行の持ち物) | マスの中身。左上から横に1行ずつ:`{ emoji, label }` |
285
+ | `messages` | `Partial<Messages>` | `ja` | 画面の文言。日本語の既定値の上に重なります。英語の `en` も入っています |
286
+ | `title`・`tag`・`subtitle` | `string` | `messages` の値 | 見出し、その上の小さなラベル、その下の1行 |
287
+ | `storageKey` | `string \| null` | 持ち物から自動で決まる | 進み具合を保存するlocalStorageのキー。`null` で保存しません。controlledのときは使われません |
288
+ | `defaultChecked` | `readonly boolean[]`(16個) | すべて `false` | uncontrolledのカードの最初の状態。保存された進み具合があれば、そちらが優先です |
289
+ | `checked` | `readonly boolean[]`(16個) | なし | 指定するとcontrolledになります。[自分のサーバーに保存する](#自分のサーバーに保存する)を見てください |
290
+ | `onChange` | `(checked: boolean[]) => void` | なし | ユーザーがタップかリセットをした。controlledのときは、求められた状態 |
291
+ | `onBingo` | `(event: BingoEvent) => void` | なし | タップで1列以上そろった |
292
+ | `onPerfect` | `(event: PerfectEvent) => void` | なし | タップでカード全体がそろった |
293
+ | `sound` | `boolean` | `true` | 音の最初の設定。ユーザーが🔊ボタンで選ぶまで使われます |
294
+ | `fonts` | `boolean` | `true` | Dela Gothic OneとM PLUS Rounded 1cをGoogle Fontsから読み込む |
295
+ | `demo` | `boolean` | `false` | デモ用のボタンを出します。保存された進み具合も `defaultChecked` もなければ、6マス埋まった状態から始めます。`storageKey` を指定しない限り保存しません |
296
+ | `className`・`style` | | なし | 外側の `<div>` に付きます |
297
+ | `ref` | `Ref<PackingBingoHandle>` | なし | [ref](#ref) を見てください |
298
+
299
+ propsは中身で比べるので、配列やオブジェクトをその場で書いても構いません。`items`・`title`・`tag`・`subtitle`・`storageKey`・`sound`・`fonts`・`demo`、または `messages` の文字列が変わると、カードを作り直します。保存先のキーが変わらなければ、チェックはそのまま残ります。キーが変わったとき(`items` を変えて自動のキーが変わったときも含む)は、新しいキーに保存された進み具合を読み込みます。得点は[得点のルール](#得点のルール)のとおり計算し直します。`messages` の関数はpropsやstateを使っても構いません。`messages` が変わると、作り直さずに下の1行だけを描き直します。
300
+
301
+ ### イベント
224
302
 
225
303
  ```ts
226
304
  interface BingoEvent {
227
- newLines: number[]; // lines completed by this tap, as indices into LINES
228
- totalLines: number; // lines completed so far
229
- score: number; // score after this tap
305
+ newLines: number[]; // このタップでそろった列(LINESの番号)
306
+ totalLines: number; // これまでにそろった列の数
307
+ score: number; // このタップのあとの得点
230
308
  }
231
309
 
232
310
  interface PerfectEvent {
233
- score: number; // final score, including the 1000-point bonus the first time
311
+ score: number; // 最終得点。初めて全部そろえたときは1000点のボーナスを含む
234
312
  maxCombo: number;
235
313
  }
236
314
  ```
237
315
 
238
- ### Ref handle
316
+ ### ref
239
317
 
240
318
  ```ts
241
319
  interface PackingBingoHandle {
242
- /** Same as the reset button: clears the card and score, and calls onChange. A controlled card only calls onChange. */
320
+ /** リセットボタンと同じ。カードと得点を消して onChange を呼ぶ。controlled のカードでは onChange を呼ぶだけ。 */
243
321
  reset(): void;
244
- /** A one-item change plays like a tap; bigger changes apply quietly. Doesn't call onChange. */
322
+ /** 1個だけの変化はタップと同じように演出し、それより大きな変化は静かに反映する。onChange は呼ばない。 */
245
323
  setChecked(checked: readonly boolean[]): void;
246
- /** null before mount and on the server. */
324
+ /** 表示前とサーバー側では null。 */
247
325
  getState(): PackingBingoState | null;
248
326
  }
249
327
 
250
328
  interface PackingBingoState {
251
- checked: boolean[]; // 16 booleans, row by row
252
- lines: number[]; // completed lines, as indices into LINES
329
+ checked: boolean[]; // 16個、左上から横に1行ずつ
330
+ lines: number[]; // そろった列(LINESの番号)
253
331
  score: number;
254
332
  maxCombo: number;
255
- complete: boolean; // all 16 checked
333
+ complete: boolean; // 16個すべてにチェックが付いている
256
334
  }
257
335
  ```
258
336
 
259
- `LINES` is exported. Its entries 0–3 are the rows, 4–7 the columns, 8 the diagonal from the top left, and 9 the diagonal from the top right.
337
+ `LINES` もexportしています。0〜3がヨコの列、4〜7がタテの列、8が左上からのナナメ、9が右上からのナナメです。
260
338
 
261
- ### Scoring
339
+ ### 得点のルール
262
340
 
263
- - An item checked for the first time scores 10 × the current combo. A combo is a chain of taps less than 2.5 s apart.
264
- - Completing n lines with one tap scores 100 × n². Each line scores only once.
265
- - Completing the card for the first time adds 1000.
266
- - Unchecking never takes points away, and checking an item again earns nothing.
267
- - After a quiet update or a rebuild (loading saved progress, for example), the score is recomputed as 10 per checked item plus 100 per completed line, and the max combo starts again from 0.
341
+ - 初めてチェックした持ち物は、10点 × そのときのコンボ数。コンボは、2.5秒以内に続けたタップの数です。
342
+ - 1回のタップでn列そろうと、100点 × n²。同じ列で得点するのは1回だけです。
343
+ - 初めてカード全体をそろえると、1000点を足します。
344
+ - チェックを外しても得点は減らず、付け直しても増えません。
345
+ - 静かに反映したあとや作り直したあと(保存された進み具合を読み込んだときなど)は、チェックした持ち物1個につき10点、そろった列1つにつき100点で計算し直し、最大コンボは0からになります。
268
346
 
269
- ### Messages
347
+ ### 文言のキー
270
348
 
271
- `messages` accepts any subset of these keys. The full Japanese and English sets are `ja` and `en` in [`src/messages.ts`](src/messages.ts).
349
+ `messages` には、次のキーを好きなだけ指定できます。日本語と英語の一式は、[`src/messages.ts`](src/messages.ts) の `ja` と `en` です。
272
350
 
273
- - **Text:** `title`, `tag`, `subtitle`, `packed`, `bingo`, `score`, `reset`, `soundOn`, `soundOff`, `stamp`, `reachBadge`, `reachPop`, `bannerSingle`, `bannerDouble`, `bannerTriple`, `statusDone`, `perfectTag`, `perfectStamp`, `perfectTitle`, `perfectBody`, `statScore`, `statCombo`, `statLines`, `close`, `demoLabel`, `demoBingo`, `demoPerfect`, `demoRestore`. In `perfectStamp` and `perfectBody`, `\n` starts a new line.
274
- - **Functions:** `combo(n)`, `comboPop(n)`, `bannerNth(totalLines)`, `bannerMulti(newLines)`, `statusStart(left)`, `statusLines(lines, left)`, `statusReach(labels)`, `perfectBonus(points)`. Each returns a string.
351
+ - **文字列:** `title`、`tag`、`subtitle`、`packed`、`bingo`、`score`、`reset`、`soundOn`、`soundOff`、`stamp`、`reachBadge`、`reachPop`、`bannerSingle`、`bannerDouble`、`bannerTriple`、`statusDone`、`perfectTag`、`perfectStamp`、`perfectTitle`、`perfectBody`、`statScore`、`statCombo`、`statLines`、`close`、`demoLabel`、`demoBingo`、`demoPerfect`、`demoRestore`。`perfectStamp` と `perfectBody` では、`\n` で改行します。
352
+ - **関数(どれも文字列を返します):** `combo(n)`、`comboPop(n)`、`bannerNth(totalLines)`、`bannerMulti(newLines)`、`statusStart(left)`、`statusLines(lines, left)`、`statusReach(labels)`、`perfectBonus(points)`。
275
353
 
276
- ### Exports
354
+ ### export
277
355
 
278
- - `packing-bingo`: `PackingBingo`, `createPackingBingo`, `LINES`, `DEFAULT_ITEMS`, `ja`, `en`, and the types `PackingBingoProps`, `PackingBingoHandle`, `PackingBingoState`, `BingoItem`, `BingoEvent`, `PerfectEvent`, `Messages`, `PackingBingoOptions`, `PackingBingoInstance`.
279
- - `packing-bingo/core`: the same without the React component and its types (`PackingBingoProps`, `PackingBingoHandle`), so it doesn't import React.
356
+ - `packing-bingo`:`PackingBingo`、`createPackingBingo`、`LINES`、`DEFAULT_ITEMS`、`ja`、`en`。型は `PackingBingoProps`、`PackingBingoHandle`、`PackingBingoState`、`BingoItem`、`BingoEvent`、`PerfectEvent`、`Messages`、`PackingBingoOptions`、`PackingBingoInstance`。
357
+ - `packing-bingo/core`:Reactのコンポーネントとその型(`PackingBingoProps`、`PackingBingoHandle`)を除いた残り。Reactを読み込みません。
280
358
 
281
- ## Integration notes
359
+ ## 組み込むときの注意
282
360
 
283
- ### What the card adds to your page
361
+ ### ページに追加されるもの
284
362
 
285
- On first mount, the card appends `<style id="pkb-styles">` to `<head>`, shared by every card and left in place after unmount. Unless `fonts={false}`, it also appends `<link id="pkb-fonts">` for Google Fonts. All CSS is scoped under `.pkb`, and every class and keyframe name starts with `pkb-`, so the rest of your page is left alone.
363
+ 最初に表示したとき、`<head>` に `<style id="pkb-styles">` を追加します。これはすべてのカードで共有し、カードを外したあとも残ります。`fonts={false}` でなければ、Google Fonts用の `<link id="pkb-fonts">` も追加します。CSSはすべて `.pkb` の中だけに効き、クラス名とアニメーション名はすべて `pkb-` で始まるので、ページのほかの部分には影響しません。
286
364
 
287
- Everything the card draws (the confetti canvas, the banner, the finale screen) stays inside the card's own box. Nothing is `position: fixed` or attached to `<body>`, and the card is its own stacking context, so a sticky site header stays on top of it.
365
+ カードが描くもの(紙吹雪、BINGO!の文字、フィナーレの画面)は、すべてカード自身の枠の中に収まります。`position: fixed` を使ったり `<body>` に要素を足したりはしません。カードは独立した重なり順(stacking context)を持つので、上部に固定したヘッダーの上に描かれることもありません。
288
366
 
289
- ### Content Security Policy
367
+ ### CSP(Content Security Policy)
290
368
 
291
- The injected `<style>` and a few inline `style` attributes need `style-src 'unsafe-inline'`. With fonts on, also allow `https://fonts.googleapis.com` in `style-src` and `https://fonts.gstatic.com` in `font-src`.
369
+ 追加する `<style>` と一部の `style` 属性のために、`style-src 'unsafe-inline'` が必要です。フォントを読み込む場合は、`style-src` に `https://fonts.googleapis.com`、`font-src` に `https://fonts.gstatic.com` も許可してください。
292
370
 
293
- ### Fonts
371
+ ### フォント
294
372
 
295
- The card uses Dela Gothic One and M PLUS Rounded 1c. With `fonts={false}`, nothing is requested from Google. If your site already loads fonts with those family names (self-hosted, for example), the card uses them. Otherwise it falls back to `--pkb-font-fallback`.
373
+ Dela Gothic OneとM PLUS Rounded 1cを使います。`fonts={false}` にするとGoogleには何も取りに行きません。サイトで同じ名前のフォントを読み込んでいれば(自分のサーバーに置いている場合など)それを使い、なければ `--pkb-font-fallback` のフォントを使います。
296
374
 
297
- ### Sound and vibration
375
+ ### 音と振動
298
376
 
299
- Sound is synthesized with the Web Audio API. The audio context is only created after the user has interacted with the page, so the browser shows no autoplay warnings. The 🔊 button's choice is stored in localStorage as `packing-bingo-muted`, and every card created afterwards starts with it, including after a reload. Cards already on screen keep their own setting. Taps and bingos vibrate where the Vibration API exists, which in practice means Android browsers.
377
+ 音はWeb Audio APIで合成しています。音を鳴らす仕組みは、ユーザーがページを操作したあとにだけ作るので、ブラウザの自動再生の警告は出ません。🔊ボタンの選択は、localStorageの `packing-bingo-muted` に保存されます。そのあとに作られるカードは、ページを読み込み直したあとも含めて、この設定で始まります。すでに表示中のほかのカードは、それぞれの設定のままです。タップやビンゴのときは、Vibration APIがある端末で振動します。実際にはAndroidのブラウザです。
300
378
 
301
- ### Saved data
379
+ ### 保存されるデータ
302
380
 
303
- With `storageKey`, the checks are stored under that key as a JSON array of 16 booleans. The score isn't stored. Saved progress takes priority over `defaultChecked`. If two cards on one page should keep separate progress, give each its own key. Blocked storage, as in some private browsing modes, is handled: the card just doesn't save.
381
+ チェックは、16個の真偽値のJSON配列としてlocalStorageに保存されます。キーは `storageKey`、指定しなければ `packing-bingo:` で始まる、持ち物リストから決まるキーです。得点は保存しません。保存された進み具合は `defaultChecked` より優先されます。プライベートブラウズなどで保存できない環境では、保存せずにそのまま動きます。
304
382
 
305
- ### Accessibility
383
+ ### アクセシビリティ
306
384
 
307
- Each square is a native checkbox inside a `<label>`, so Tab and Space work and the item name is the accessible name. The emoji and the stamp are hidden from assistive technology. The status line is a polite live region. With `prefers-reduced-motion`, shaking, bouncing and particles are turned off. It hasn't been tested with a screen reader yet.
385
+ マスは `<label>` の中の本物のチェックボックスなので、TabキーとSpaceキーで操作でき、持ち物の名前がそのまま読み上げられます。絵文字とスタンプは、読み上げの対象から外しています。下の1行は、変化を控えめに読み上げる領域(polite live region)です。`prefers-reduced-motion` のときは、揺れ、弾み、紙吹雪を止めます。スクリーンリーダーでの確認は、まだしていません。
308
386
 
309
- ### Browser support and testing
387
+ ### 対応ブラウザと確認済みの範囲
310
388
 
311
- The card uses CSS container queries (Chrome and Edge 105+, Safari 16+, Firefox 110+) and `:has()` for the keyboard focus ring (Firefox 121+).
389
+ CSSのコンテナクエリ(Chrome・Edge 105以上、Safari 16以上、Firefox 110以上)と、キーボード操作時の枠に `:has()`(Firefox 121以上)を使っています。
312
390
 
313
- Tested so far:
391
+ 確認済み:
314
392
 
315
- - React 19 in StrictMode, with three cards on one page, controlled and uncontrolled
316
- - Server rendering with `renderToString`, ESM and CommonJS
317
- - A Next.js 16 App Router production build, using the card from a Server Component page and from a client component
318
- - Installing from npm with npm and pnpm
319
- - Taps scripted in Chromium at desktop and phone sizes, including resets and callbacks that land mid-celebration
393
+ - React 19のStrictModeで、1ページに3枚、controlledとuncontrolledの両方
394
+ - `renderToString` でのサーバー側の表示(ESMとCommonJS)
395
+ - Next.js 16のApp Routerで本番ビルドし、Server Componentのページとclient componentの両方から使う
396
+ - npmとpnpmでのnpmからのインストール
397
+ - Chromiumで、パソコンとスマホの大きさでの自動タップ。演出の途中でのリセットやコールバックも含む
398
+ - 自動保存。持ち物リストごとに別々に保存されること、`storageKey={null}` で保存しないこと、持ち物を変えると新しく始まり、戻すと前の進み具合が戻ること
320
399
 
321
- These scripted browser tests aren't part of this repository yet. Safari, Firefox and real phones haven't been tried, and the sound hasn't been checked on a real device. Emoji come from each device's emoji font, so they look different on each system.
400
+ これらの自動テストは、まだこのリポジトリには入っていません。Safari、Firefox、実際のスマホでは試しておらず、実機での音も確かめていません。絵文字は端末の絵文字フォントで表示されるので、端末ごとに見た目が違います。
322
401
 
323
- ## Without React
402
+ ## Reactなしで使う
324
403
 
325
- The engine has no framework dependency:
404
+ エンジン部分はフレームワークに依存しません。
326
405
 
327
406
  ```ts
328
407
  import { createPackingBingo } from "packing-bingo/core";
329
408
 
330
409
  const card = createPackingBingo(document.getElementById("bingo")!, {
331
- storageKey: "trip",
410
+ tag: "沖縄修学旅行",
332
411
  onBingo: (e) => console.log(e),
333
412
  });
334
- // card.reset(), card.getState(), card.setChecked(next, { animate: true }), card.destroy()
413
+ // card.reset()、card.getState()、card.setChecked(next, { animate: true })、card.destroy()
335
414
  ```
336
415
 
337
- Its options are the React props without `checked`, `className`, `style` and `ref` (type `PackingBingoOptions`). For a controlled card, pass `controlled: true` and update it with `setChecked()`. Without `{ animate: true }`, `setChecked()` always applies quietly.
416
+ オプションは、Reactのpropsから `checked`・`className`・`style`・`ref` を除いたものです(型は `PackingBingoOptions`)。controlledにするときは `controlled: true` を渡し、`setChecked()` で状態を変えます。`setChecked()` は、`{ animate: true }` を付けないと、いつも静かに反映します。
338
417
 
339
- Without a bundler, load the browser build from a CDN. It defines a global `PackingBingo`:
418
+ バンドラーを使わないページでは、CDNからブラウザ用のファイルを読み込みます。グローバル変数 `PackingBingo` ができます。使い方は[クイックスタート](#reactを使っていないページに置く場合)にあります。
340
419
 
341
- ```html
342
- <div id="bingo"></div>
343
- <script src="https://cdn.jsdelivr.net/npm/packing-bingo@0.1/dist/packing-bingo.global.js"></script>
344
- <script>
345
- PackingBingo.createPackingBingo(document.getElementById("bingo"), { storageKey: "trip" });
346
- </script>
347
- ```
348
-
349
- ## Standalone page
420
+ ## 単体のページ
350
421
 
351
- `index.html` is the whole app in one file, so you can open it directly or put it on any static host. It saves progress in the browser.
422
+ `index.html` は、アプリ全体が1つに入ったファイルです。そのまま開くことも、静的なホスティングに置くこともできます。進み具合はブラウザに保存されます。
352
423
 
353
424
  ```sh
354
425
  git clone https://github.com/Jin-hiroo/packing-bingo.git
355
426
  cd packing-bingo
356
- open index.html # macOS; on other systems, open the file in a browser
427
+ open index.html # macOSの場合。ほかのOSでは、ファイルをブラウザで開いてください
357
428
  ```
358
429
 
359
- Add `?demo` to the URL to start part-way through and show the demo buttons: "▶ ビンゴを見る" plays a bingo, and "▶ コンボ → パーフェクト" plays a combo run to a full card. On GitHub Pages, use **Settings → Pages → Deploy from a branch → `main` / root**.
430
+ URLの末尾に `?demo` を付けると、途中まで埋まった状態から始まり、デモ用のボタンが出ます。「▶ ビンゴを見る」でビンゴを、「▶ コンボ → パーフェクト」でコンボからパーフェクトまでを再生します。GitHub Pagesで公開するときは、**Settings → Pages → Deploy from a branch → `main` / root** を選びます。
360
431
 
361
- To change the page, edit the options in its last `<script>`, which calls `PackingBingo.createPackingBingo(…)`. To change the defaults for every build, edit `src/items.ts` or `src/messages.ts` and run `npm run build`.
432
+ ページの中身を変えるときは、最後の `<script>` にある `PackingBingo.createPackingBingo(…)` のオプションを書き換えます。どのビルドでも使われる既定値を変えるときは、`src/items.ts` か `src/messages.ts` を編集して `npm run build` を実行します。
362
433
 
363
- ## Development
434
+ ## 開発
364
435
 
365
436
  ```sh
366
- npm install # also builds dist/ and index.html
367
- npm run build # dist/ (ESM, CJS, types, browser bundle) and the standalone index.html
437
+ npm install # dist/ と index.html もビルドされます
438
+ npm run build # dist/(ESM、CJS、型、ブラウザ用)と単体の index.html
368
439
  npm run typecheck
369
440
  ```
370
441
 
371
- `index.html` is generated from `src/` by `scripts/build-standalone.mjs`, so don't edit it by hand.
442
+ `index.html` は `scripts/build-standalone.mjs` が `src/` から作るので、直接編集しないでください。
372
443
 
373
- ## 日本語
444
+ ## English
374
445
 
375
- 修学旅行の持ち物チェックリストを4×4のビンゴにする、Reactのコンポーネントです。持ち物をかばんに入れるたびにマスをタップしてスタンプを押し、タテ・ヨコ・ナナメがそろうとビンゴになります。
446
+ Packing Bingo is a React component that turns a school-trip packing checklist into a 4×4 bingo card, with combos, fanfares and fireworks. Progress is saved in each student's browser automatically. It has no runtime dependencies and is safe to render on the server (it ships with `"use client"`).
376
447
 
377
448
  ```sh
378
449
  npm install packing-bingo
379
450
  ```
380
451
 
381
452
  ```tsx
382
- import { PackingBingo } from "packing-bingo";
453
+ import { PackingBingo, en } from "packing-bingo";
383
454
 
384
- <PackingBingo storageKey="kyoto-trip-2026" />;
455
+ <PackingBingo messages={en} />;
385
456
  ```
386
457
 
387
- - React 18以上で動きます。ほかに依存するパッケージはなく、CSSの読み込みも要りません。
388
- - `storageKey` を付けると、チェックがその人のブラウザ(localStorage)に保存されます。
389
- - 持ち物は `items`(ちょうど16個)で、文言は `messages` で変えられます。英語の文言は `en` です。
390
- - 自分のサーバーに保存したいときは、`checked` と `onChange` を使って親側で状態を持ちます([Save progress on your server](#save-progress-on-your-server))。
391
- - Next.jsのApp Routerでは、Server Componentのページにそのまま置けます(`items` や `messages={en}` も渡せます)。ただし、`onChange` などのコールバックは渡せないので、使うときは `"use client"` を付けた自分のコンポーネントで包んでください。
392
- - 配色はCSS変数(`--pkb-sun` など)で変えられます。Google Fontsを読み込みたくないときは `fonts={false}` にします。
393
- - CSP(Content Security Policy)を設定している場合は、`style-src 'unsafe-inline'` が必要です。
394
-
395
- 詳しい仕様は [API](#api) と [Integration notes](#integration-notes) にあります。
458
+ The default UI text is Japanese; `en` is a complete English set. Pass exactly 16 `items` (`{ emoji, label }`) to use your own list. The sections above cover the props, events and integration notes; the code snippets and type names can be read as they are.
396
459
 
397
- ## License
460
+ ## ライセンス
398
461
 
399
462
  [MIT](LICENSE) © 2026 Jin-hiroo.
400
463
 
401
- The Dela Gothic One and M PLUS Rounded 1c fonts are loaded from Google Fonts and are licensed under the SIL Open Font License. They aren't included in this repository.
464
+ Dela Gothic OneとM PLUS Rounded 1cのフォントはGoogle Fontsから読み込んでおり、SIL Open Font Licenseで提供されています。このリポジトリには含まれていません。
@@ -195,6 +195,23 @@ function createSound(isMuted) {
195
195
  };
196
196
  }
197
197
 
198
+ // src/storage-key.ts
199
+ function resolveStorageKey(options) {
200
+ if (options.controlled) return null;
201
+ if (options.storageKey !== void 0) return options.storageKey || null;
202
+ if (options.demo) return null;
203
+ return itemsKey(options.items ?? DEFAULT_ITEMS);
204
+ }
205
+ function itemsKey(items) {
206
+ const text = items.map((it) => `${it.emoji}\0${it.label}`).join("");
207
+ let h = 2166136261;
208
+ for (let i = 0; i < text.length; i++) {
209
+ h ^= text.charCodeAt(i);
210
+ h = Math.imul(h, 16777619);
211
+ }
212
+ return `packing-bingo:${(h >>> 0).toString(36)}`;
213
+ }
214
+
198
215
  // src/styles.ts
199
216
  var FONT_URL = "https://fonts.googleapis.com/css2?family=Dela+Gothic+One&family=M+PLUS+Rounded+1c:wght@500;800&display=swap";
200
217
  var CSS = `
@@ -384,7 +401,7 @@ function createPackingBingo(container, options = {}) {
384
401
  if (options.tag != null) M.tag = options.tag;
385
402
  if (options.subtitle != null) M.subtitle = options.subtitle;
386
403
  const controlled = !!options.controlled;
387
- const storageKey = controlled ? null : options.storageKey ?? null;
404
+ const storageKey = resolveStorageKey(options);
388
405
  const demo = !!options.demo;
389
406
  const calm = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
390
407
  let dead = false;
@@ -972,6 +989,7 @@ export {
972
989
  DEFAULT_ITEMS,
973
990
  ja,
974
991
  en,
992
+ resolveStorageKey,
975
993
  LINES,
976
994
  createPackingBingo
977
995
  };
package/dist/core.cjs CHANGED
@@ -224,6 +224,23 @@ function createSound(isMuted) {
224
224
  };
225
225
  }
226
226
 
227
+ // src/storage-key.ts
228
+ function resolveStorageKey(options) {
229
+ if (options.controlled) return null;
230
+ if (options.storageKey !== void 0) return options.storageKey || null;
231
+ if (options.demo) return null;
232
+ return itemsKey(options.items ?? DEFAULT_ITEMS);
233
+ }
234
+ function itemsKey(items) {
235
+ const text = items.map((it) => `${it.emoji}\0${it.label}`).join("");
236
+ let h = 2166136261;
237
+ for (let i = 0; i < text.length; i++) {
238
+ h ^= text.charCodeAt(i);
239
+ h = Math.imul(h, 16777619);
240
+ }
241
+ return `packing-bingo:${(h >>> 0).toString(36)}`;
242
+ }
243
+
227
244
  // src/styles.ts
228
245
  var FONT_URL = "https://fonts.googleapis.com/css2?family=Dela+Gothic+One&family=M+PLUS+Rounded+1c:wght@500;800&display=swap";
229
246
  var CSS = `
@@ -413,7 +430,7 @@ function createPackingBingo(container, options = {}) {
413
430
  if (options.tag != null) M.tag = options.tag;
414
431
  if (options.subtitle != null) M.subtitle = options.subtitle;
415
432
  const controlled = !!options.controlled;
416
- const storageKey = controlled ? null : options.storageKey ?? null;
433
+ const storageKey = resolveStorageKey(options);
417
434
  const demo = !!options.demo;
418
435
  const calm = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
419
436
  let dead = false;
package/dist/core.d.cts CHANGED
@@ -89,7 +89,11 @@ interface PackingBingoOptions {
89
89
  * requested state, and the card only changes through `setChecked`. (React's `checked` prop.)
90
90
  */
91
91
  controlled?: boolean;
92
- /** Save and restore the checks in localStorage under this key. Ignored when controlled. */
92
+ /**
93
+ * Where the checks are saved in localStorage. By default (omitted) a key derived from the
94
+ * items is used, so progress is saved automatically; pass a string to choose the key, or null
95
+ * to turn saving off. Ignored when controlled. Demo cards only save when given a key.
96
+ */
93
97
  storageKey?: string | null;
94
98
  /** Start with sound on (default) or off. A choice made with the sound button is remembered. */
95
99
  sound?: boolean;
package/dist/core.d.ts CHANGED
@@ -89,7 +89,11 @@ interface PackingBingoOptions {
89
89
  * requested state, and the card only changes through `setChecked`. (React's `checked` prop.)
90
90
  */
91
91
  controlled?: boolean;
92
- /** Save and restore the checks in localStorage under this key. Ignored when controlled. */
92
+ /**
93
+ * Where the checks are saved in localStorage. By default (omitted) a key derived from the
94
+ * items is used, so progress is saved automatically; pass a string to choose the key, or null
95
+ * to turn saving off. Ignored when controlled. Demo cards only save when given a key.
96
+ */
93
97
  storageKey?: string | null;
94
98
  /** Start with sound on (default) or off. A choice made with the sound button is remembered. */
95
99
  sound?: boolean;
package/dist/core.js CHANGED
@@ -5,7 +5,7 @@ import {
5
5
  createPackingBingo,
6
6
  en,
7
7
  ja
8
- } from "./chunk-ZDBGKPFF.js";
8
+ } from "./chunk-WGZKPODU.js";
9
9
  export {
10
10
  DEFAULT_ITEMS,
11
11
  LINES,
package/dist/index.cjs CHANGED
@@ -228,6 +228,23 @@ function createSound(isMuted) {
228
228
  };
229
229
  }
230
230
 
231
+ // src/storage-key.ts
232
+ function resolveStorageKey(options) {
233
+ if (options.controlled) return null;
234
+ if (options.storageKey !== void 0) return options.storageKey || null;
235
+ if (options.demo) return null;
236
+ return itemsKey(options.items ?? DEFAULT_ITEMS);
237
+ }
238
+ function itemsKey(items) {
239
+ const text = items.map((it) => `${it.emoji}\0${it.label}`).join("");
240
+ let h = 2166136261;
241
+ for (let i = 0; i < text.length; i++) {
242
+ h ^= text.charCodeAt(i);
243
+ h = Math.imul(h, 16777619);
244
+ }
245
+ return `packing-bingo:${(h >>> 0).toString(36)}`;
246
+ }
247
+
231
248
  // src/styles.ts
232
249
  var FONT_URL = "https://fonts.googleapis.com/css2?family=Dela+Gothic+One&family=M+PLUS+Rounded+1c:wght@500;800&display=swap";
233
250
  var CSS = `
@@ -417,7 +434,7 @@ function createPackingBingo(container, options = {}) {
417
434
  if (options.tag != null) M.tag = options.tag;
418
435
  if (options.subtitle != null) M.subtitle = options.subtitle;
419
436
  const controlled = !!options.controlled;
420
- const storageKey = controlled ? null : options.storageKey ?? null;
437
+ const storageKey = resolveStorageKey(options);
421
438
  const demo = !!options.demo;
422
439
  const calm = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
423
440
  let dead = false;
@@ -1013,7 +1030,8 @@ var PackingBingo = (0, import_react.forwardRef)(function PackingBingo2(props, re
1013
1030
  messagesRef.current = messages;
1014
1031
  const controlled = checked !== void 0;
1015
1032
  const latest = (0, import_react.useRef)(checked ?? defaultChecked);
1016
- const latestKey = (0, import_react.useRef)(config.storageKey);
1033
+ const storageKey = resolveStorageKey({ ...config, controlled });
1034
+ const latestKey = (0, import_react.useRef)(storageKey);
1017
1035
  if (controlled) latest.current = checked;
1018
1036
  const messageShape = messages ? Object.fromEntries(Object.entries(messages).map(([k, v]) => [k, typeof v === "function" ? "\u0192" : v])) : null;
1019
1037
  const key = JSON.stringify({ ...config, messageShape, controlled });
@@ -1029,7 +1047,7 @@ var PackingBingo = (0, import_react.forwardRef)(function PackingBingo2(props, re
1029
1047
  ...config,
1030
1048
  messages: liveMessages,
1031
1049
  controlled,
1032
- defaultChecked: controlled || latestKey.current === config.storageKey ? latest.current : defaultChecked,
1050
+ defaultChecked: controlled || latestKey.current === storageKey ? latest.current : defaultChecked,
1033
1051
  onChange: (next) => {
1034
1052
  if (!controlled) latest.current = next;
1035
1053
  callbacks.current.onChange?.(next);
@@ -1040,7 +1058,7 @@ var PackingBingo = (0, import_react.forwardRef)(function PackingBingo2(props, re
1040
1058
  inst.current = instance;
1041
1059
  return () => {
1042
1060
  if (!controlled) latest.current = instance.getState().checked;
1043
- latestKey.current = config.storageKey;
1061
+ latestKey.current = storageKey;
1044
1062
  instance.destroy();
1045
1063
  if (inst.current === instance) inst.current = null;
1046
1064
  };
package/dist/index.js CHANGED
@@ -4,8 +4,9 @@ import {
4
4
  LINES,
5
5
  createPackingBingo,
6
6
  en,
7
- ja
8
- } from "./chunk-ZDBGKPFF.js";
7
+ ja,
8
+ resolveStorageKey
9
+ } from "./chunk-WGZKPODU.js";
9
10
 
10
11
  // src/react.tsx
11
12
  import { forwardRef, useEffect, useImperativeHandle, useRef } from "react";
@@ -20,7 +21,8 @@ var PackingBingo = forwardRef(function PackingBingo2(props, ref) {
20
21
  messagesRef.current = messages;
21
22
  const controlled = checked !== void 0;
22
23
  const latest = useRef(checked ?? defaultChecked);
23
- const latestKey = useRef(config.storageKey);
24
+ const storageKey = resolveStorageKey({ ...config, controlled });
25
+ const latestKey = useRef(storageKey);
24
26
  if (controlled) latest.current = checked;
25
27
  const messageShape = messages ? Object.fromEntries(Object.entries(messages).map(([k, v]) => [k, typeof v === "function" ? "\u0192" : v])) : null;
26
28
  const key = JSON.stringify({ ...config, messageShape, controlled });
@@ -36,7 +38,7 @@ var PackingBingo = forwardRef(function PackingBingo2(props, ref) {
36
38
  ...config,
37
39
  messages: liveMessages,
38
40
  controlled,
39
- defaultChecked: controlled || latestKey.current === config.storageKey ? latest.current : defaultChecked,
41
+ defaultChecked: controlled || latestKey.current === storageKey ? latest.current : defaultChecked,
40
42
  onChange: (next) => {
41
43
  if (!controlled) latest.current = next;
42
44
  callbacks.current.onChange?.(next);
@@ -47,7 +49,7 @@ var PackingBingo = forwardRef(function PackingBingo2(props, ref) {
47
49
  inst.current = instance;
48
50
  return () => {
49
51
  if (!controlled) latest.current = instance.getState().checked;
50
- latestKey.current = config.storageKey;
52
+ latestKey.current = storageKey;
51
53
  instance.destroy();
52
54
  if (inst.current === instance) inst.current = null;
53
55
  };
@@ -1,8 +1,8 @@
1
- "use strict";var PackingBingo=(()=>{var Ee=Object.defineProperty;var ct=Object.getOwnPropertyDescriptor;var bt=Object.getOwnPropertyNames;var dt=Object.prototype.hasOwnProperty;var mt=(r,n)=>{for(var k in n)Ee(r,k,{get:n[k],enumerable:!0})},ft=(r,n,k,d)=>{if(n&&typeof n=="object"||typeof n=="function")for(let m of bt(n))!dt.call(r,m)&&m!==k&&Ee(r,m,{get:()=>n[m],enumerable:!(d=ct(n,m))||d.enumerable});return r};var kt=r=>ft(Ee({},"__esModule",{value:!0}),r);var yt={};mt(yt,{DEFAULT_ITEMS:()=>Le,LINES:()=>F,createPackingBingo:()=>vt,en:()=>Ge,ja:()=>Se});var Le=[{emoji:"\u{1F4D6}",label:"\u65C5\u306E\u3057\u304A\u308A"},{emoji:"\u{1FAAA}",label:"\u751F\u5F92\u624B\u5E33"},{emoji:"\u{1F3E5}",label:"\u4FDD\u967A\u8A3C\u30B3\u30D4\u30FC"},{emoji:"\u{1F45B}",label:"\u304A\u3053\u3065\u304B\u3044"},{emoji:"\u{1F455}",label:"\u7740\u66FF\u3048"},{emoji:"\u{1F319}",label:"\u30D1\u30B8\u30E3\u30DE"},{emoji:"\u{1F9E5}",label:"\u4E0A\u7740"},{emoji:"\u{1FAA5}",label:"\u6D17\u9762\u7528\u5177"},{emoji:"\u{1F6C1}",label:"\u30BF\u30AA\u30EB"},{emoji:"\u{1F927}",label:"\u30CF\u30F3\u30AB\u30C1\u30FB\u30C6\u30A3\u30C3\u30B7\u30E5"},{emoji:"\u{1F637}",label:"\u30DE\u30B9\u30AF"},{emoji:"\u{1F48A}",label:"\u5E38\u5099\u85AC\u30FB\u9154\u3044\u6B62\u3081"},{emoji:"\u2602\uFE0F",label:"\u6298\u308A\u305F\u305F\u307F\u5098"},{emoji:"\u270F\uFE0F",label:"\u7B46\u8A18\u7528\u5177"},{emoji:"\u{1F6CD}\uFE0F",label:"\u30D3\u30CB\u30FC\u30EB\u888B"},{emoji:"\u{1F4F1}",label:"\u30B9\u30DE\u30DB\u30FB\u5145\u96FB\u5668"}];var Se={title:"\u6301\u3061\u3082\u306E\u30D3\u30F3\u30B4",tag:"\u4FEE\u5B66\u65C5\u884C",subtitle:"\u30BF\u30C3\u30D7\u3067\u30B9\u30BF\u30F3\u30D7\u3002\u30BF\u30C6\u30FB\u30E8\u30B3\u30FB\u30CA\u30CA\u30E1\u3067\u30D3\u30F3\u30B4\uFF01",packed:"\u305D\u308D\u3063\u305F",bingo:"BINGO",score:"SCORE",combo:r=>`${r} COMBO`,comboPop:r=>`${r} COMBO!`,reset:"\u30EA\u30BB\u30C3\u30C8",soundOn:"\u{1F50A} \u97F3\u3042\u308A",soundOff:"\u{1F507} \u97F3\u306A\u3057",stamp:"OK",reachBadge:"\u30EA\u30FC\u30C1",reachPop:"\u30EA\u30FC\u30C1!",bannerSingle:"BINGO!",bannerDouble:"\u30C0\u30D6\u30EB\u30D3\u30F3\u30B4!",bannerTriple:"\u30C8\u30EA\u30D7\u30EB\u30D3\u30F3\u30B4!",bannerNth:r=>`${r} \u30E9\u30A4\u30F3\u76EE\uFF01`,bannerMulti:r=>`\u4E00\u6C17\u306B ${r} \u30E9\u30A4\u30F3\uFF01`,statusStart:r=>`\u306E\u3053\u308A ${r} \u3053\u3002\u307E\u305A\u306F1\u30E9\u30A4\u30F3\u305D\u308D\u3048\u3088\u3046`,statusLines:(r,n)=>`\u30D3\u30F3\u30B4 ${r} \u30E9\u30A4\u30F3\uFF01 \u306E\u3053\u308A ${n} \u3053`,statusReach:r=>`\u30EA\u30FC\u30C1\uFF01 \u3042\u3068${r.slice(0,2).map(n=>`\u300C${n}\u300D`).join("\u304B")}${r.length>2?"\u306A\u3069":""}\u3067\u30D3\u30F3\u30B4`,statusDone:"\u{1F389} \u30D1\u30FC\u30D5\u30A7\u30AF\u30C8\uFF01\u5FD8\u308C\u7269\u30BC\u30ED\u3067\u51FA\u767A\u3067\u304D\u307E\u3059",perfectTag:"\u{1F3C6} PERFECT",perfectStamp:`\u6E96\u5099
1
+ "use strict";var PackingBingo=(()=>{var Se=Object.defineProperty;var bt=Object.getOwnPropertyDescriptor;var dt=Object.getOwnPropertyNames;var mt=Object.prototype.hasOwnProperty;var ft=(o,n)=>{for(var m in n)Se(o,m,{get:n[m],enumerable:!0})},kt=(o,n,m,l)=>{if(n&&typeof n=="object"||typeof n=="function")for(let f of dt(n))!mt.call(o,f)&&f!==m&&Se(o,f,{get:()=>n[f],enumerable:!(l=bt(n,f))||l.enumerable});return o};var ut=o=>kt(Se({},"__esModule",{value:!0}),o);var Et={};ft(Et,{DEFAULT_ITEMS:()=>re,LINES:()=>F,createPackingBingo:()=>wt,en:()=>Ge,ja:()=>Be});var re=[{emoji:"\u{1F4D6}",label:"\u65C5\u306E\u3057\u304A\u308A"},{emoji:"\u{1FAAA}",label:"\u751F\u5F92\u624B\u5E33"},{emoji:"\u{1F3E5}",label:"\u4FDD\u967A\u8A3C\u30B3\u30D4\u30FC"},{emoji:"\u{1F45B}",label:"\u304A\u3053\u3065\u304B\u3044"},{emoji:"\u{1F455}",label:"\u7740\u66FF\u3048"},{emoji:"\u{1F319}",label:"\u30D1\u30B8\u30E3\u30DE"},{emoji:"\u{1F9E5}",label:"\u4E0A\u7740"},{emoji:"\u{1FAA5}",label:"\u6D17\u9762\u7528\u5177"},{emoji:"\u{1F6C1}",label:"\u30BF\u30AA\u30EB"},{emoji:"\u{1F927}",label:"\u30CF\u30F3\u30AB\u30C1\u30FB\u30C6\u30A3\u30C3\u30B7\u30E5"},{emoji:"\u{1F637}",label:"\u30DE\u30B9\u30AF"},{emoji:"\u{1F48A}",label:"\u5E38\u5099\u85AC\u30FB\u9154\u3044\u6B62\u3081"},{emoji:"\u2602\uFE0F",label:"\u6298\u308A\u305F\u305F\u307F\u5098"},{emoji:"\u270F\uFE0F",label:"\u7B46\u8A18\u7528\u5177"},{emoji:"\u{1F6CD}\uFE0F",label:"\u30D3\u30CB\u30FC\u30EB\u888B"},{emoji:"\u{1F4F1}",label:"\u30B9\u30DE\u30DB\u30FB\u5145\u96FB\u5668"}];var Be={title:"\u6301\u3061\u3082\u306E\u30D3\u30F3\u30B4",tag:"\u4FEE\u5B66\u65C5\u884C",subtitle:"\u30BF\u30C3\u30D7\u3067\u30B9\u30BF\u30F3\u30D7\u3002\u30BF\u30C6\u30FB\u30E8\u30B3\u30FB\u30CA\u30CA\u30E1\u3067\u30D3\u30F3\u30B4\uFF01",packed:"\u305D\u308D\u3063\u305F",bingo:"BINGO",score:"SCORE",combo:o=>`${o} COMBO`,comboPop:o=>`${o} COMBO!`,reset:"\u30EA\u30BB\u30C3\u30C8",soundOn:"\u{1F50A} \u97F3\u3042\u308A",soundOff:"\u{1F507} \u97F3\u306A\u3057",stamp:"OK",reachBadge:"\u30EA\u30FC\u30C1",reachPop:"\u30EA\u30FC\u30C1!",bannerSingle:"BINGO!",bannerDouble:"\u30C0\u30D6\u30EB\u30D3\u30F3\u30B4!",bannerTriple:"\u30C8\u30EA\u30D7\u30EB\u30D3\u30F3\u30B4!",bannerNth:o=>`${o} \u30E9\u30A4\u30F3\u76EE\uFF01`,bannerMulti:o=>`\u4E00\u6C17\u306B ${o} \u30E9\u30A4\u30F3\uFF01`,statusStart:o=>`\u306E\u3053\u308A ${o} \u3053\u3002\u307E\u305A\u306F1\u30E9\u30A4\u30F3\u305D\u308D\u3048\u3088\u3046`,statusLines:(o,n)=>`\u30D3\u30F3\u30B4 ${o} \u30E9\u30A4\u30F3\uFF01 \u306E\u3053\u308A ${n} \u3053`,statusReach:o=>`\u30EA\u30FC\u30C1\uFF01 \u3042\u3068${o.slice(0,2).map(n=>`\u300C${n}\u300D`).join("\u304B")}${o.length>2?"\u306A\u3069":""}\u3067\u30D3\u30F3\u30B4`,statusDone:"\u{1F389} \u30D1\u30FC\u30D5\u30A7\u30AF\u30C8\uFF01\u5FD8\u308C\u7269\u30BC\u30ED\u3067\u51FA\u767A\u3067\u304D\u307E\u3059",perfectTag:"\u{1F3C6} PERFECT",perfectStamp:`\u6E96\u5099
2
2
  \u5B8C\u4E86`,perfectTitle:"\u30D1\u30FC\u30D5\u30A7\u30AF\u30C8\uFF01",perfectBody:`16\u3053 \u305C\u3093\u3076\u305D\u308D\u3063\u305F\uFF01
3
- \u5FD8\u308C\u7269\u30BC\u30ED\u3067\u3001\u3044\u3063\u3066\u3089\u3063\u3057\u3083\u3044\uFF01`,perfectBonus:r=>`PERFECT BONUS +${r}`,statScore:"SCORE",statCombo:"MAX COMBO",statLines:"BINGO",close:"\u3068\u3058\u308B",demoLabel:"\u30C7\u30E2\uFF1A",demoBingo:"\u25B6 \u30D3\u30F3\u30B4\u3092\u898B\u308B",demoPerfect:"\u25B6 \u30B3\u30F3\u30DC \u2192 \u30D1\u30FC\u30D5\u30A7\u30AF\u30C8",demoRestore:"\u6700\u521D\u306B\u623B\u3059"},Ge={title:"Packing Bingo",tag:"School trip",subtitle:"Tap to stamp. Rows, columns and diagonals all count!",packed:"PACKED",bingo:"BINGO",score:"SCORE",combo:r=>`${r} COMBO`,comboPop:r=>`${r} COMBO!`,reset:"Reset",soundOn:"\u{1F50A} Sound on",soundOff:"\u{1F507} Muted",stamp:"OK",reachBadge:"REACH",reachPop:"Reach!",bannerSingle:"BINGO!",bannerDouble:"DOUBLE BINGO!",bannerTriple:"TRIPLE BINGO!",bannerNth:r=>`Line ${r}!`,bannerMulti:r=>`${r} lines at once!`,statusStart:r=>`${r} to go. Finish a line first!`,statusLines:(r,n)=>`${r} bingo line${r===1?"":"s"}! ${n} to go`,statusReach:r=>`Reach! Pack ${r.slice(0,2).join(" or ")}${r.length>2?" (or more)":""} for a bingo`,statusDone:"\u{1F389} Perfect! Nothing left behind.",perfectTag:"\u{1F3C6} PERFECT",perfectStamp:`ALL
3
+ \u5FD8\u308C\u7269\u30BC\u30ED\u3067\u3001\u3044\u3063\u3066\u3089\u3063\u3057\u3083\u3044\uFF01`,perfectBonus:o=>`PERFECT BONUS +${o}`,statScore:"SCORE",statCombo:"MAX COMBO",statLines:"BINGO",close:"\u3068\u3058\u308B",demoLabel:"\u30C7\u30E2\uFF1A",demoBingo:"\u25B6 \u30D3\u30F3\u30B4\u3092\u898B\u308B",demoPerfect:"\u25B6 \u30B3\u30F3\u30DC \u2192 \u30D1\u30FC\u30D5\u30A7\u30AF\u30C8",demoRestore:"\u6700\u521D\u306B\u623B\u3059"},Ge={title:"Packing Bingo",tag:"School trip",subtitle:"Tap to stamp. Rows, columns and diagonals all count!",packed:"PACKED",bingo:"BINGO",score:"SCORE",combo:o=>`${o} COMBO`,comboPop:o=>`${o} COMBO!`,reset:"Reset",soundOn:"\u{1F50A} Sound on",soundOff:"\u{1F507} Muted",stamp:"OK",reachBadge:"REACH",reachPop:"Reach!",bannerSingle:"BINGO!",bannerDouble:"DOUBLE BINGO!",bannerTriple:"TRIPLE BINGO!",bannerNth:o=>`Line ${o}!`,bannerMulti:o=>`${o} lines at once!`,statusStart:o=>`${o} to go. Finish a line first!`,statusLines:(o,n)=>`${o} bingo line${o===1?"":"s"}! ${n} to go`,statusReach:o=>`Reach! Pack ${o.slice(0,2).join(" or ")}${o.length>2?" (or more)":""} for a bingo`,statusDone:"\u{1F389} Perfect! Nothing left behind.",perfectTag:"\u{1F3C6} PERFECT",perfectStamp:`ALL
4
4
  SET`,perfectTitle:"Perfect!",perfectBody:`All 16 packed!
5
- Have a great trip!`,perfectBonus:r=>`PERFECT BONUS +${r}`,statScore:"SCORE",statCombo:"MAX COMBO",statLines:"BINGO",close:"Close",demoLabel:"Demo:",demoBingo:"\u25B6 Show a bingo",demoPerfect:"\u25B6 Combo \u2192 perfect",demoRestore:"Restore"};var Ue=[0,2,4,7,9,12,14,16,19,21,24,26,28,31,33,36],U=r=>523.25*Math.pow(2,r/12);function _e(r){let n=null,k=null;function d(){if(r()||typeof window>"u")return null;let c=navigator.userActivation;if(!n&&c&&!c.hasBeenActive)return null;if(!n){let b=window.AudioContext??window.webkitAudioContext;if(!b)return null;n=new b;let y=n.createDynamicsCompressor();k=n.createGain(),k.gain.value=.6,k.connect(y).connect(n.destination)}return n.state==="suspended"&&n.resume(),n}function m(c,b=0,y=.15,E="triangle",M=.2,A=0){let p=d();if(!p||!k)return;let x=p.currentTime+b,S=p.createOscillator(),C=p.createGain();S.type=E,S.frequency.setValueAtTime(c,x),A&&S.frequency.exponentialRampToValueAtTime(A,x+y),C.gain.setValueAtTime(1e-4,x),C.gain.exponentialRampToValueAtTime(M,x+.01),C.gain.exponentialRampToValueAtTime(1e-4,x+y),S.connect(C).connect(k),S.start(x),S.stop(x+y+.05)}function $(c=0,b=.3,y=.25,E=1200){let M=d();if(!M||!k)return;let A=M.currentTime+c,p=Math.ceil(M.sampleRate*b),x=M.createBuffer(1,p,M.sampleRate),S=x.getChannelData(0);for(let f=0;f<p;f++)S[f]=(Math.random()*2-1)*Math.pow(1-f/p,2.5);let C=M.createBufferSource(),W=M.createBiquadFilter(),I=M.createGain();C.buffer=x,W.type="bandpass",W.frequency.value=E,W.Q.value=.7,I.gain.value=y,C.connect(W).connect(I).connect(k),C.start(A)}let R=c=>U(Ue[Math.min(Math.max(c,0),Ue.length-1)]);return{pop(c){let b=R(c);m(b,0,.16,"triangle",.22),m(b*2,0,.08,"sine",.06),m(150,0,.09,"sine",.25,60)},off:()=>m(392,0,.1,"sine",.1,262),reach(){m(988,0,.08,"square",.05),m(1319,.09,.14,"square",.05)},bingo(c){[0,4,7,12,16,19].forEach((b,y)=>m(U(b),y*.06,.22,"square",.06)),[12,16,19].forEach(b=>m(U(b),.38,.6,"triangle",.12)),c>1&&[24,28,31].forEach(b=>m(U(b),.55,.6,"triangle",.08)),$(0,.25,.12,3e3)},perfect(){[0,4,7,12,16,19,24].forEach((c,b)=>m(U(c-12),b*.07,.25,"square",.06)),[0,4,7,12].forEach(c=>m(U(c),.55,1.2,"triangle",.12)),[12,16,19].forEach(c=>m(U(c),.55,1.2,"sawtooth",.03)),m(U(-12),.55,1.2,"sine",.2)},boom(){$(0,.5,.3,500),m(90,0,.3,"sine",.25,40)},tick:()=>m(1760,0,.03,"square",.03),note:(c,b=0)=>m(R(c),b,.12,"square",.05),close(){n&&n.close().catch(()=>{}),n=null,k=null}}}var We="https://fonts.googleapis.com/css2?family=Dela+Gothic+One&family=M+PLUS+Rounded+1c:wght@500;800&display=swap",Ve=`
5
+ Have a great trip!`,perfectBonus:o=>`PERFECT BONUS +${o}`,statScore:"SCORE",statCombo:"MAX COMBO",statLines:"BINGO",close:"Close",demoLabel:"Demo:",demoBingo:"\u25B6 Show a bingo",demoPerfect:"\u25B6 Combo \u2192 perfect",demoRestore:"Restore"};var Ue=[0,2,4,7,9,12,14,16,19,21,24,26,28,31,33,36],U=o=>523.25*Math.pow(2,o/12);function Ke(o){let n=null,m=null;function l(){if(o()||typeof window>"u")return null;let b=navigator.userActivation;if(!n&&b&&!b.hasBeenActive)return null;if(!n){let d=window.AudioContext??window.webkitAudioContext;if(!d)return null;n=new d;let y=n.createDynamicsCompressor();m=n.createGain(),m.gain.value=.6,m.connect(y).connect(n.destination)}return n.state==="suspended"&&n.resume(),n}function f(b,d=0,y=.15,E="triangle",M=.2,A=0){let p=l();if(!p||!m)return;let x=p.currentTime+d,B=p.createOscillator(),C=p.createGain();B.type=E,B.frequency.setValueAtTime(b,x),A&&B.frequency.exponentialRampToValueAtTime(A,x+y),C.gain.setValueAtTime(1e-4,x),C.gain.exponentialRampToValueAtTime(M,x+.01),C.gain.exponentialRampToValueAtTime(1e-4,x+y),B.connect(C).connect(m),B.start(x),B.stop(x+y+.05)}function P(b=0,d=.3,y=.25,E=1200){let M=l();if(!M||!m)return;let A=M.currentTime+b,p=Math.ceil(M.sampleRate*d),x=M.createBuffer(1,p,M.sampleRate),B=x.getChannelData(0);for(let k=0;k<p;k++)B[k]=(Math.random()*2-1)*Math.pow(1-k/p,2.5);let C=M.createBufferSource(),_=M.createBiquadFilter(),j=M.createGain();C.buffer=x,_.type="bandpass",_.frequency.value=E,_.Q.value=.7,j.gain.value=y,C.connect(_).connect(j).connect(m),C.start(A)}let R=b=>U(Ue[Math.min(Math.max(b,0),Ue.length-1)]);return{pop(b){let d=R(b);f(d,0,.16,"triangle",.22),f(d*2,0,.08,"sine",.06),f(150,0,.09,"sine",.25,60)},off:()=>f(392,0,.1,"sine",.1,262),reach(){f(988,0,.08,"square",.05),f(1319,.09,.14,"square",.05)},bingo(b){[0,4,7,12,16,19].forEach((d,y)=>f(U(d),y*.06,.22,"square",.06)),[12,16,19].forEach(d=>f(U(d),.38,.6,"triangle",.12)),b>1&&[24,28,31].forEach(d=>f(U(d),.55,.6,"triangle",.08)),P(0,.25,.12,3e3)},perfect(){[0,4,7,12,16,19,24].forEach((b,d)=>f(U(b-12),d*.07,.25,"square",.06)),[0,4,7,12].forEach(b=>f(U(b),.55,1.2,"triangle",.12)),[12,16,19].forEach(b=>f(U(b),.55,1.2,"sawtooth",.03)),f(U(-12),.55,1.2,"sine",.2)},boom(){P(0,.5,.3,500),f(90,0,.3,"sine",.25,40)},tick:()=>f(1760,0,.03,"square",.03),note:(b,d=0)=>f(R(b),d,.12,"square",.05),close(){n&&n.close().catch(()=>{}),n=null,m=null}}}function _e(o){return o.controlled?null:o.storageKey!==void 0?o.storageKey||null:o.demo?null:gt(o.items??re)}function gt(o){let n=o.map(l=>`${l.emoji}\0${l.label}`).join(""),m=2166136261;for(let l=0;l<n.length;l++)m^=n.charCodeAt(l),m=Math.imul(m,16777619);return`packing-bingo:${(m>>>0).toString(36)}`}var We="https://fonts.googleapis.com/css2?family=Dela+Gothic+One&family=M+PLUS+Rounded+1c:wght@500;800&display=swap",Ve=`
6
6
  .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%}
7
7
  .pkb,.pkb *{box-sizing:border-box;margin:0;padding:0}
8
8
  .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}
@@ -106,7 +106,7 @@ Have a great trip!`,perfectBonus:r=>`PERFECT BONUS +${r}`,statScore:"SCORE",stat
106
106
  @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}}
107
107
  @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}}
108
108
  @keyframes pkb-fade{0%,100%{opacity:0}15%,80%{opacity:1}}
109
- `;var F=[[0,1,2,3],[4,5,6,7],[8,9,10,11],[12,13,14,15],[0,4,8,12],[1,5,9,13],[2,6,10,14],[3,7,11,15],[0,5,10,15],[3,6,9,12]],ut=[0,1,2,3,5,10],Ke=["#E8473B","#2F6FED","#1FA971","#FFFDF6","#2A1F14","#FF9F1C"],Ye=["#FF9F1C","#1FA971","#2F6FED","#9B5DE5","#E8473B"],gt=2500,Xe="packing-bingo-muted",g=r=>r.replace(/[&<>"']/g,n=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"})[n]),_=r=>Array.from({length:16},(n,k)=>!!r?.[k]),Je=()=>Array.from({length:16},(r,n)=>ut.includes(n));function Qe(r){try{return localStorage.getItem(r)}catch{return null}}function Ze(r,n){try{localStorage.setItem(r,n)}catch{}}function ht(r){if(!document.getElementById("pkb-styles")){let n=document.createElement("style");n.id="pkb-styles",n.textContent=Ve,document.head.appendChild(n)}if(r&&!document.getElementById("pkb-fonts")){let n=document.createElement("link");n.id="pkb-fonts",n.rel="stylesheet",n.href=We,document.head.appendChild(n)}}function xt(r,n,k){let d=r.map(($,R)=>`<label class="pkb-tile" data-reach="${g(n.reachBadge)}" style="--pkb-gd:${((R>>2)+(R&3))*.12}s"><input type="checkbox"><span class="pkb-emo" aria-hidden="true">${g($.emoji)}</span><span class="pkb-lbl">${g($.label).replace(/・/g,"\u30FB<wbr>")}</span><span class="pkb-stamp" aria-hidden="true">${g(n.stamp)}</span></label>`).join(""),m=k?`<div class="pkb-demo"><span>${g(n.demoLabel)}</span><button type="button" data-r="dB">${g(n.demoBingo)}</button><button type="button" data-r="dP">${g(n.demoPerfect)}</button><button type="button" data-r="dR">${g(n.demoRestore)}</button></div>`:"";return`<div class="pkb"><div class="pkb-board">
109
+ `;var F=[[0,1,2,3],[4,5,6,7],[8,9,10,11],[12,13,14,15],[0,4,8,12],[1,5,9,13],[2,6,10,14],[3,7,11,15],[0,5,10,15],[3,6,9,12]],ht=[0,1,2,3,5,10],Ye=["#E8473B","#2F6FED","#1FA971","#FFFDF6","#2A1F14","#FF9F1C"],Xe=["#FF9F1C","#1FA971","#2F6FED","#9B5DE5","#E8473B"],xt=2500,Je="packing-bingo-muted",g=o=>o.replace(/[&<>"']/g,n=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"})[n]),K=o=>Array.from({length:16},(n,m)=>!!o?.[m]),Qe=()=>Array.from({length:16},(o,n)=>ht.includes(n));function Ze(o){try{return localStorage.getItem(o)}catch{return null}}function et(o,n){try{localStorage.setItem(o,n)}catch{}}function vt(o){if(!document.getElementById("pkb-styles")){let n=document.createElement("style");n.id="pkb-styles",n.textContent=Ve,document.head.appendChild(n)}if(o&&!document.getElementById("pkb-fonts")){let n=document.createElement("link");n.id="pkb-fonts",n.rel="stylesheet",n.href=We,document.head.appendChild(n)}}function yt(o,n,m){let l=o.map((P,R)=>`<label class="pkb-tile" data-reach="${g(n.reachBadge)}" style="--pkb-gd:${((R>>2)+(R&3))*.12}s"><input type="checkbox"><span class="pkb-emo" aria-hidden="true">${g(P.emoji)}</span><span class="pkb-lbl">${g(P.label).replace(/・/g,"\u30FB<wbr>")}</span><span class="pkb-stamp" aria-hidden="true">${g(n.stamp)}</span></label>`).join(""),f=m?`<div class="pkb-demo"><span>${g(n.demoLabel)}</span><button type="button" data-r="dB">${g(n.demoBingo)}</button><button type="button" data-r="dP">${g(n.demoPerfect)}</button><button type="button" data-r="dR">${g(n.demoRestore)}</button></div>`:"";return`<div class="pkb"><div class="pkb-board">
110
110
  <div class="pkb-head"><div>
111
111
  <div class="pkb-tags"><span class="pkb-kicker" data-r="kick">${g(n.tag)}</span><button type="button" class="pkb-snd" data-r="snd"></button></div>
112
112
  <h2 class="pkb-title">${g(n.title)}</h2><p class="pkb-sub">${g(n.subtitle)}</p></div>
@@ -115,7 +115,7 @@ Have a great trip!`,perfectBonus:r=>`PERFECT BONUS +${r}`,statScore:"SCORE",stat
115
115
  <div class="pkb-pill" data-r="bp"><small>${g(n.bingo)}</small><b data-r="bng"></b></div>
116
116
  <div class="pkb-pill pkb-sc" data-r="sp"><small>${g(n.score)}</small><b data-r="sc">0</b></div></div>
117
117
  <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>
118
- <div class="pkb-gw"><div class="pkb-grid">${d}</div><svg class="pkb-lines" aria-hidden="true"></svg>
118
+ <div class="pkb-gw"><div class="pkb-grid">${l}</div><svg class="pkb-lines" aria-hidden="true"></svg>
119
119
  <div class="pkb-banner" data-r="banner" aria-hidden="true"><b data-r="bT"></b><span data-r="bS"></span></div></div>
120
120
  <div class="pkb-foot"><span data-r="msg" aria-live="polite"></span><button type="button" class="pkb-reset" data-r="reset">${g(n.reset)}</button></div>
121
121
  <div class="pkb-flash" data-r="flash"></div>
@@ -123,4 +123,4 @@ Have a great trip!`,perfectBonus:r=>`PERFECT BONUS +${r}`,statScore:"SCORE",stat
123
123
  <div class="pkb-medal">${g(n.perfectStamp)}</div><h3 class="pkb-ptitle">${g(n.perfectTitle)}</h3><p class="pkb-pbody">${g(n.perfectBody)}</p>
124
124
  <div class="pkb-stats"><div><small>${g(n.statScore)}</small><b data-r="sScore">0</b></div><div><small>${g(n.statCombo)}</small><b data-r="sCombo">\xD70</b></div><div><small>${g(n.statLines)}</small><b data-r="sBingo">0</b></div></div>
125
125
  <button type="button" class="pkb-close" data-r="close">${g(n.close)}</button></div></div>
126
- <canvas class="pkb-fx"></canvas></div>${m}</div>`}function vt(r,n={}){let k=n.items??Le;if(k.length!==16)throw new Error(`[packing-bingo] "items" must have exactly 16 entries (got ${k.length}).`);let d={...Se,...n.messages};n.title!=null&&(d.title=n.title),n.tag!=null&&(d.tag=n.tag),n.subtitle!=null&&(d.subtitle=n.subtitle);let m=!!n.controlled,$=m?null:n.storageKey??null,R=!!n.demo,c=typeof matchMedia=="function"&&matchMedia("(prefers-reduced-motion: reduce)").matches,b=!1,y=new Set,E=(e,t)=>{let a=window.setTimeout(()=>{y.delete(a),b||e()},t);y.add(a)},M=e=>new Promise(t=>window.setTimeout(t,e)),A=0;ht(n.fonts!==!1),r.innerHTML=xt(k,d,R);let p=e=>{let t=r.querySelector(`[data-r="${e}"]`);if(!t)throw new Error(`[packing-bingo] internal: missing element "${e}"`);return t},x=r.querySelector(".pkb-board"),S=r.querySelector(".pkb-gw"),C=r.querySelector(".pkb-grid"),W=r.querySelector(".pkb-lines"),I=r.querySelector(".pkb-fx"),f=I.getContext("2d"),L=Array.from(C.children),re=L.map(e=>e.querySelector("input")),Be=p("kick"),be=p("snd"),N=p("perfect"),h=_(void 0),w=[],ie=new Set,de=new Set,se=!1,Me=0,z=0,ne=0,B=0,X=0,Ce=0,me=!1,ae=new Set,J=new Set,ze=Qe(Xe),V=ze!=null?ze==="1":n.sound===!1,T=_e(()=>V),Te=()=>{be.textContent=V?d.soundOff:d.soundOn,be.setAttribute("aria-pressed",String(!V))};be.addEventListener("click",()=>{V=!V,Ze(Xe,V?"1":"0"),Te(),V||T.pop(4)}),Te();let D=[],K=0,oe=1,H=0,G=0,Pe=()=>{let e=x.getBoundingClientRect();oe=Math.min(2,window.devicePixelRatio||1),H=e.width,G=e.height,I.width=Math.round(H*oe),I.height=Math.round(G*oe)},fe=(e,t,a,o,s,i,l,v)=>({x:e,y:t,vx:a,vy:o,g:s,life:l,shape:v,w:(5+Math.random()*6)*i,h:(3+Math.random()*5)*i,r:Math.random()*6,vr:(Math.random()-.5)*.35,c:Ke[Math.random()*Ke.length|0]}),ke=()=>{!K&&f&&(K=requestAnimationFrame($e))};function Q(e,t,a,o={}){if(!c){for(let s=0;s<a;s++){let i=(o.angle??-Math.PI/2)+(Math.random()-.5)*(o.spread??Math.PI*2),l=(o.speed??10)*(.35+Math.random()*.85),v=Math.random()<(o.star??.15)?"star":Math.random()<.3?"round":"rect";D.push(fe(e,t,Math.cos(i)*l,Math.sin(i)*l,o.g??.28,o.size??1,(o.life??110)+Math.random()*40,v))}ke()}}function et(e){if(c)return;let t=performance.now()+e,a=()=>{for(let o=0;o<4;o++)D.push(fe(Math.random()*H,-12,(Math.random()-.5)*2,2+Math.random()*3,.045,1,420,Math.random()<.2?"star":"rect"));ke(),performance.now()<t&&E(a,45)};a()}function tt(e){if(c)return;let t=performance.now()+e,a=()=>{let o=fe(Math.random()*H,-30,(Math.random()-.5)*1.5,1.5+Math.random()*2,.05,1,500,"emoji");o.e=k[Math.random()*k.length|0].emoji,o.w=22+Math.random()*12,o.vr=(Math.random()-.5)*.06,D.push(o),ke(),performance.now()<t&&E(a,110)};a()}function nt(e,t){Q(e,t,80,{speed:7.5,g:.06,life:85,star:.4}),T.boom()}function at(e,t){e.beginPath();for(let a=0;a<10;a++){let o=a*Math.PI/5-Math.PI/2,s=a%2?t*.45:t;e.lineTo(Math.cos(o)*s,Math.sin(o)*s)}e.closePath(),e.fill()}function $e(){if(K=0,!(b||!f)){f.setTransform(oe,0,0,oe,0,0),f.clearRect(0,0,H,G),D=D.filter(e=>e.life>0&&e.y<G+40);for(let e of D)e.vx*=.985,e.vy=e.vy*.985+e.g,e.x+=e.vx,e.y+=e.vy,e.r+=e.vr,e.life--,f.save(),f.globalAlpha=Math.min(1,e.life/25),f.translate(e.x,e.y),f.rotate(e.r),f.fillStyle=e.c,e.shape==="round"?(f.beginPath(),f.arc(0,0,e.h/1.6,0,7),f.fill()):e.shape==="star"?at(f,e.w*.8):e.shape==="emoji"?(f.font=`${e.w}px "Apple Color Emoji","Segoe UI Emoji","Noto Color Emoji",sans-serif`,f.textAlign="center",f.textBaseline="middle",f.fillText(e.e??"",0,0)):(f.scale(1,Math.cos(e.r*2.3)),f.fillRect(-e.w/2,-e.h/2,e.w,e.h)),f.restore();D.length&&(K=requestAnimationFrame($e))}}let pe=e=>{let t=x.getBoundingClientRect(),a=e.getBoundingClientRect();return{x:a.left-t.left+a.width/2,y:a.top-t.top+a.height/2}},P=(e,t)=>{e.classList.remove(t),e.offsetWidth,e.classList.add(t)},Ae=(e,t)=>{c||(L[e].classList.remove("pkb-squash"),L[e].style.setProperty("--pkb-d",t+"ms"),P(L[e],"pkb-hop"))};C.addEventListener("animationend",e=>{let t=e.target;t.classList.contains("pkb-tile")&&(e.animationName==="pkb-hop"||e.animationName==="pkb-squash")&&t.classList.remove(e.animationName)});let ot=e=>{let t=document.createElement("span");t.className="pkb-ring",e.appendChild(t),t.addEventListener("animationend",()=>t.remove())},ue=e=>{let t=navigator.userActivation;if(!(t&&!t.hasBeenActive))try{navigator.vibrate?.(e)}catch{}},le=(e,t,a,o,s=0,i)=>{let l=document.createElement("div");l.className="pkb-float pkb-"+o,l.textContent=e,l.style.left=t+"px",l.style.top=a+"px",s&&(l.style.animationDelay=s+"ms"),i&&(l.style.color=i),x.appendChild(l),l.addEventListener("animationend",()=>l.remove())},Oe=(e,t,a)=>Math.max(0,Math.min(1,(e-t)/a)),Fe=e=>1-Math.pow(1-e,3),ce=0;function ge(){cancelAnimationFrame(ce);let e=ne,t=z,a=performance.now(),o=s=>{if(b)return;let i=Oe(s,a,450);ne=Math.round(e+(t-e)*Fe(i)),p("sc").textContent=ne.toLocaleString(),i<1&&(ce=requestAnimationFrame(o))};o(a),c||P(p("sp"),"pkb-bump")}let Re=(e,t,a,o="pt")=>{le(`+${e}`,t,a,o),ge()};function he(e,t,a,o,s,i=!1){let l=performance.now(),v=A,q=0,j=Y=>{if(b||v!==A)return;let te=Oe(Y,l,o);e.textContent=s(Math.round(t+(a-t)*Fe(te))),i&&te<1&&Y-q>55&&(T.tick(),q=Y),te<1?requestAnimationFrame(j):c||P(e,"pkb-bump")};j(l)}let qe=e=>{let t=L[e];return{x:t.offsetLeft+t.offsetWidth/2,y:t.offsetTop+t.offsetHeight/2}};function je(e=[]){W.setAttribute("viewBox",`0 0 ${S.clientWidth} ${S.clientHeight}`),W.innerHTML=w.map(t=>{let a=qe(F[t][0]),o=qe(F[t][3]),s=o.x-a.x,i=o.y-a.y,l=Math.hypot(s,i)||1,v=s/l*26,q=i/l*26,j=`x1="${a.x-v}" y1="${a.y-q}" x2="${o.x+v}" y2="${o.y+q}" pathLength="1"`;return`<g${e.includes(t)?' class="pkb-draw"':""}><line class="pkb-u" ${j}/><line class="pkb-o" ${j}/></g>`}).join("")}function rt(e,t){let a=pe(L[e]);if(!t){B=0,p("combo").classList.remove("pkb-on"),T.off();return}let o=performance.now();B=o-Ce<gt?B+1:1,Ce=o,X=Math.max(X,B),T.pop(B-1),c||(P(L[e],"pkb-squash"),ot(L[e])),Q(a.x,a.y,18,{speed:6,g:.2,life:30,size:.6,star:.5}),ae.has(e)||(ae.add(e),z+=10*B,Re(10*B,a.x,a.y-12)),B>=2&&(le(d.comboPop(B),a.x,a.y+22,"cmb",90,Ye[(B-2)%Ye.length]),p("cmbN").textContent=d.combo(B),P(p("combo"),"pkb-on")),ue(12)}p("cbar").addEventListener("animationend",()=>{p("combo").classList.remove("pkb-on"),B=0});function it(e){e.forEach(t=>{let a=pe(L[t]);le(d.reachPop,a.x,a.y-34,"rch",150)}),E(()=>T.reach(),150)}function st(e,t){let a=e.length,o=[...new Set(e.flatMap(i=>F[i]))],s=Math.min(70,300/o.length);o.forEach((i,l)=>E(()=>{Ae(i,0);let v=pe(L[i]);Q(v.x,v.y,14,{speed:5,g:.15,life:30,size:.7,star:.8}),T.note(l+3)},l*s)),E(()=>{p("bT").textContent=a>=3?d.bannerTriple:a===2?d.bannerDouble:d.bannerSingle,p("bS").textContent=a>1?d.bannerMulti(a):d.bannerNth(w.length),P(p("banner"),"pkb-show"),c||(P(p("flash"),"pkb-go"),P(x,"pkb-punch"));let i=pe(S);Q(i.x,i.y,a>1?180:110,{speed:a>1?16:13,star:.3}),T.bingo(a),ue(a>1?[40,60,40,60,90]:[40,60,40]),t&&E(()=>Re(t,i.x,i.y+105,"big"),350)},o.length*s+60)}function pt(e){let t=me?0:1e3;me=!0,z+=t,L.forEach((o,s)=>Ae(s,((s>>2)+(s&3))*60)),T.perfect(),c||P(p("flash"),"pkb-go"),p("sScore").textContent="0",p("sCombo").textContent="\xD70",p("sBingo").textContent="0",E(()=>{P(N,"pkb-show"),p("close").focus({preventScroll:!0}),E(()=>{c||P(x,"pkb-shake"),T.boom(),Q(0,G,90,{angle:-Math.PI/3,spread:.8,speed:22,g:.32,star:.3}),Q(H,G,90,{angle:-Math.PI*2/3,spread:.8,speed:22,g:.32,star:.3}),et(3500),ue([60,80,60,80,200]),t&&le(d.perfectBonus(t),H/2,G*.16,"big"),he(p("sScore"),0,z,1300,o=>o.toLocaleString(),!0),he(p("sCombo"),0,X,700,o=>"\xD7"+o),he(p("sBingo"),0,w.length,700,String),ge()},720),[1300,1650,2e3,2350,2700].forEach(o=>E(()=>nt(H*(.15+Math.random()*.7),G*(.12+Math.random()*.3)),o)),E(()=>tt(3e3),1500)},520);let a={score:z,maxCombo:X};e.push(()=>n.onPerfect?.(a))}function Ie(){A++,y.forEach(e=>clearTimeout(e)),y.clear(),cancelAnimationFrame(ce),ne=z,p("sc").textContent=z.toLocaleString()}function lt(){D=[],cancelAnimationFrame(K),K=0,f?.clearRect(0,0,I.width,I.height),x.querySelectorAll(".pkb-float, .pkb-ring").forEach(e=>e.remove()),p("banner").classList.remove("pkb-show"),p("flash").classList.remove("pkb-go"),p("combo").classList.remove("pkb-on"),N.classList.remove("pkb-show")}function xe(e,t){let a=[],o=h,s=o.filter(Boolean).length;w=F.flatMap((u,O)=>u.every(we=>o[we])?[O]:[]);let i=w.filter(u=>!ie.has(u));[...ie].some(u=>!w.includes(u))&&(Ie(),s<16&&N.classList.remove("pkb-show"));let l=new Set(w.flatMap(u=>F[u])),v=new Set;for(let u of F){let O=u.filter(we=>!o[we]);O.length===1&&v.add(O[0])}L.forEach((u,O)=>{u.classList.toggle("pkb-on",o[O]),u.classList.toggle("pkb-line",l.has(O)),u.classList.toggle("pkb-reach",v.has(O))}),x.classList.toggle("pkb-all",s===16),Be.classList.toggle("pkb-gold",s===16),Be.textContent=s===16?d.perfectTag:d.tag,p("cnt").textContent=`${s}/16`,p("bng").textContent=String(w.length);let q=p("bp");w.length>Me&&(q.classList.remove("pkb-hot"),q.offsetWidth),q.classList.toggle("pkb-hot",w.length>0),Me=w.length,je(i),e!==null&&rt(e,o[e]);let j=i.filter(u=>!J.has(u));j.forEach(u=>J.add(u));let Y=100*j.length*j.length;if(z+=Y,t)Y&&ge();else if(s===16&&!se)pt(a);else if(i.length){st(i,Y);let u={newLines:i,totalLines:w.length,score:z};a.push(()=>n.onBingo?.(u))}else if(e!==null&&o[e]){let u=[...v].filter(O=>!de.has(O));u.length&&it(u)}ie=new Set(w),de=v,se=s===16;let te=[...v].map(u=>k[u].label);return p("msg").textContent=s===16?d.statusDone:te.length?d.statusReach(te):w.length?d.statusLines(w.length,16-s):d.statusStart(16-s),a}let Ne=e=>{for(let t of e){if(b)return;t()}},De=()=>{$&&Ze($,JSON.stringify(h))};function He(e,t,a){h=e,re.forEach((s,i)=>s.checked=h[i]);let o=xe(t,a);return De(),o}function ve(e){Ie(),lt(),h=e,re.forEach((t,a)=>t.checked=h[a]),ae.clear(),J.clear(),h.forEach((t,a)=>t&&ae.add(a)),F.forEach((t,a)=>t.every(o=>h[o])&&J.add(a)),ie=new Set(J),de=new Set,se=h.every(Boolean),me=se,z=ne=ae.size*10+J.size*100,B=0,X=0,p("sc").textContent=z.toLocaleString(),xe(null,!0),De()}function Z(e,t,a={}){if(m){re.forEach((s,i)=>s.checked=h[i]),n.onChange?.(e.slice());return}let o=[];a.reset?ve(e):o=He(e,t,!!a.quiet),n.onChange?.(h.slice()),Ne(o)}if(C.addEventListener("change",e=>{let t=re.indexOf(e.target);if(t<0)return;let a=h.slice();a[t]=!a[t],Z(a,t)}),p("reset").addEventListener("click",()=>Z(_(void 0),null,{reset:!0})),p("close").addEventListener("click",()=>N.classList.remove("pkb-show")),N.addEventListener("click",e=>{e.target===N&&N.classList.remove("pkb-show")}),R){let e=!1,t=()=>{h.every(Boolean)?Z(_(void 0),null,{reset:!0}):N.classList.remove("pkb-show")},a=async o=>{if(!e){e=!0;try{t();let s=A;for(let i of o){if(await M(i.delay),b||s!==A)return;let l=h.slice();l[i.i]=!0,Z(l,i.i,{quiet:i.quiet})}}finally{e=!1}}};p("dB").addEventListener("click",()=>{let o=()=>F.map(i=>i.filter(l=>!h[l])).filter(i=>i.length).sort((i,l)=>i.length-l.length)[0]??[],s=h.every(Boolean)?[...F[0]]:o();a(s.map(i=>({i,delay:320})))}),p("dP").addEventListener("click",()=>{let o=h.every(Boolean)?L.map((s,i)=>i):L.map((s,i)=>i).filter(s=>!h[s]);a(o.map((s,i)=>i<o.length-1?{i:s,delay:170,quiet:!0}:{i:s,delay:450}))}),p("dR").addEventListener("click",()=>{e||Z(Je(),null,{reset:!0})})}let ee=null;if($)try{let e=JSON.parse(Qe($)??"null");Array.isArray(e)&&(ee=_(e))}catch{}!ee&&n.defaultChecked&&(ee=_(n.defaultChecked)),!ee&&R&&(ee=Je()),Pe(),ve(ee??_(void 0));let ye=typeof ResizeObserver=="function"?new ResizeObserver(()=>{Pe(),je()}):null;return ye?.observe(x),ye?.observe(S),{setChecked(e,t={}){if(b)return;let a=_(e),o=a.flatMap((s,i)=>s!==h[i]?[i]:[]);if(o.length){if(!t.animate||o.length!==1){ve(a);return}Ne(He(a,o[0],!1))}},reset(){b||Z(_(void 0),null,{reset:!0})},refresh(){b||xe(null,!0)},getState(){return{checked:h.slice(),lines:w.slice(),score:z,maxCombo:X,complete:h.every(Boolean)}},destroy(){b||(b=!0,y.forEach(e=>clearTimeout(e)),y.clear(),cancelAnimationFrame(K),cancelAnimationFrame(ce),ye?.disconnect(),T.close(),r.innerHTML="")}}}return kt(yt);})();
126
+ <canvas class="pkb-fx"></canvas></div>${f}</div>`}function wt(o,n={}){let m=n.items??re;if(m.length!==16)throw new Error(`[packing-bingo] "items" must have exactly 16 entries (got ${m.length}).`);let l={...Be,...n.messages};n.title!=null&&(l.title=n.title),n.tag!=null&&(l.tag=n.tag),n.subtitle!=null&&(l.subtitle=n.subtitle);let f=!!n.controlled,P=_e(n),R=!!n.demo,b=typeof matchMedia=="function"&&matchMedia("(prefers-reduced-motion: reduce)").matches,d=!1,y=new Set,E=(e,t)=>{let a=window.setTimeout(()=>{y.delete(a),d||e()},t);y.add(a)},M=e=>new Promise(t=>window.setTimeout(t,e)),A=0;vt(n.fonts!==!1),o.innerHTML=yt(m,l,R);let p=e=>{let t=o.querySelector(`[data-r="${e}"]`);if(!t)throw new Error(`[packing-bingo] internal: missing element "${e}"`);return t},x=o.querySelector(".pkb-board"),B=o.querySelector(".pkb-gw"),C=o.querySelector(".pkb-grid"),_=o.querySelector(".pkb-lines"),j=o.querySelector(".pkb-fx"),k=j.getContext("2d"),S=Array.from(C.children),ie=S.map(e=>e.querySelector("input")),Le=p("kick"),de=p("snd"),N=p("perfect"),h=K(void 0),w=[],se=new Set,me=new Set,pe=!1,Me=0,z=0,ne=0,L=0,X=0,Ce=0,fe=!1,ae=new Set,J=new Set,ze=Ze(Je),W=ze!=null?ze==="1":n.sound===!1,T=Ke(()=>W),Te=()=>{de.textContent=W?l.soundOff:l.soundOn,de.setAttribute("aria-pressed",String(!W))};de.addEventListener("click",()=>{W=!W,et(Je,W?"1":"0"),Te(),W||T.pop(4)}),Te();let D=[],V=0,oe=1,H=0,G=0,$e=()=>{let e=x.getBoundingClientRect();oe=Math.min(2,window.devicePixelRatio||1),H=e.width,G=e.height,j.width=Math.round(H*oe),j.height=Math.round(G*oe)},ke=(e,t,a,r,s,i,c,v)=>({x:e,y:t,vx:a,vy:r,g:s,life:c,shape:v,w:(5+Math.random()*6)*i,h:(3+Math.random()*5)*i,r:Math.random()*6,vr:(Math.random()-.5)*.35,c:Ye[Math.random()*Ye.length|0]}),ue=()=>{!V&&k&&(V=requestAnimationFrame(Pe))};function Q(e,t,a,r={}){if(!b){for(let s=0;s<a;s++){let i=(r.angle??-Math.PI/2)+(Math.random()-.5)*(r.spread??Math.PI*2),c=(r.speed??10)*(.35+Math.random()*.85),v=Math.random()<(r.star??.15)?"star":Math.random()<.3?"round":"rect";D.push(ke(e,t,Math.cos(i)*c,Math.sin(i)*c,r.g??.28,r.size??1,(r.life??110)+Math.random()*40,v))}ue()}}function tt(e){if(b)return;let t=performance.now()+e,a=()=>{for(let r=0;r<4;r++)D.push(ke(Math.random()*H,-12,(Math.random()-.5)*2,2+Math.random()*3,.045,1,420,Math.random()<.2?"star":"rect"));ue(),performance.now()<t&&E(a,45)};a()}function nt(e){if(b)return;let t=performance.now()+e,a=()=>{let r=ke(Math.random()*H,-30,(Math.random()-.5)*1.5,1.5+Math.random()*2,.05,1,500,"emoji");r.e=m[Math.random()*m.length|0].emoji,r.w=22+Math.random()*12,r.vr=(Math.random()-.5)*.06,D.push(r),ue(),performance.now()<t&&E(a,110)};a()}function at(e,t){Q(e,t,80,{speed:7.5,g:.06,life:85,star:.4}),T.boom()}function ot(e,t){e.beginPath();for(let a=0;a<10;a++){let r=a*Math.PI/5-Math.PI/2,s=a%2?t*.45:t;e.lineTo(Math.cos(r)*s,Math.sin(r)*s)}e.closePath(),e.fill()}function Pe(){if(V=0,!(d||!k)){k.setTransform(oe,0,0,oe,0,0),k.clearRect(0,0,H,G),D=D.filter(e=>e.life>0&&e.y<G+40);for(let e of D)e.vx*=.985,e.vy=e.vy*.985+e.g,e.x+=e.vx,e.y+=e.vy,e.r+=e.vr,e.life--,k.save(),k.globalAlpha=Math.min(1,e.life/25),k.translate(e.x,e.y),k.rotate(e.r),k.fillStyle=e.c,e.shape==="round"?(k.beginPath(),k.arc(0,0,e.h/1.6,0,7),k.fill()):e.shape==="star"?ot(k,e.w*.8):e.shape==="emoji"?(k.font=`${e.w}px "Apple Color Emoji","Segoe UI Emoji","Noto Color Emoji",sans-serif`,k.textAlign="center",k.textBaseline="middle",k.fillText(e.e??"",0,0)):(k.scale(1,Math.cos(e.r*2.3)),k.fillRect(-e.w/2,-e.h/2,e.w,e.h)),k.restore();D.length&&(V=requestAnimationFrame(Pe))}}let le=e=>{let t=x.getBoundingClientRect(),a=e.getBoundingClientRect();return{x:a.left-t.left+a.width/2,y:a.top-t.top+a.height/2}},$=(e,t)=>{e.classList.remove(t),e.offsetWidth,e.classList.add(t)},Ae=(e,t)=>{b||(S[e].classList.remove("pkb-squash"),S[e].style.setProperty("--pkb-d",t+"ms"),$(S[e],"pkb-hop"))};C.addEventListener("animationend",e=>{let t=e.target;t.classList.contains("pkb-tile")&&(e.animationName==="pkb-hop"||e.animationName==="pkb-squash")&&t.classList.remove(e.animationName)});let rt=e=>{let t=document.createElement("span");t.className="pkb-ring",e.appendChild(t),t.addEventListener("animationend",()=>t.remove())},ge=e=>{let t=navigator.userActivation;if(!(t&&!t.hasBeenActive))try{navigator.vibrate?.(e)}catch{}},ce=(e,t,a,r,s=0,i)=>{let c=document.createElement("div");c.className="pkb-float pkb-"+r,c.textContent=e,c.style.left=t+"px",c.style.top=a+"px",s&&(c.style.animationDelay=s+"ms"),i&&(c.style.color=i),x.appendChild(c),c.addEventListener("animationend",()=>c.remove())},Oe=(e,t,a)=>Math.max(0,Math.min(1,(e-t)/a)),Fe=e=>1-Math.pow(1-e,3),be=0;function he(){cancelAnimationFrame(be);let e=ne,t=z,a=performance.now(),r=s=>{if(d)return;let i=Oe(s,a,450);ne=Math.round(e+(t-e)*Fe(i)),p("sc").textContent=ne.toLocaleString(),i<1&&(be=requestAnimationFrame(r))};r(a),b||$(p("sp"),"pkb-bump")}let Re=(e,t,a,r="pt")=>{ce(`+${e}`,t,a,r),he()};function xe(e,t,a,r,s,i=!1){let c=performance.now(),v=A,q=0,I=Y=>{if(d||v!==A)return;let te=Oe(Y,c,r);e.textContent=s(Math.round(t+(a-t)*Fe(te))),i&&te<1&&Y-q>55&&(T.tick(),q=Y),te<1?requestAnimationFrame(I):b||$(e,"pkb-bump")};I(c)}let qe=e=>{let t=S[e];return{x:t.offsetLeft+t.offsetWidth/2,y:t.offsetTop+t.offsetHeight/2}};function Ie(e=[]){_.setAttribute("viewBox",`0 0 ${B.clientWidth} ${B.clientHeight}`),_.innerHTML=w.map(t=>{let a=qe(F[t][0]),r=qe(F[t][3]),s=r.x-a.x,i=r.y-a.y,c=Math.hypot(s,i)||1,v=s/c*26,q=i/c*26,I=`x1="${a.x-v}" y1="${a.y-q}" x2="${r.x+v}" y2="${r.y+q}" pathLength="1"`;return`<g${e.includes(t)?' class="pkb-draw"':""}><line class="pkb-u" ${I}/><line class="pkb-o" ${I}/></g>`}).join("")}function it(e,t){let a=le(S[e]);if(!t){L=0,p("combo").classList.remove("pkb-on"),T.off();return}let r=performance.now();L=r-Ce<xt?L+1:1,Ce=r,X=Math.max(X,L),T.pop(L-1),b||($(S[e],"pkb-squash"),rt(S[e])),Q(a.x,a.y,18,{speed:6,g:.2,life:30,size:.6,star:.5}),ae.has(e)||(ae.add(e),z+=10*L,Re(10*L,a.x,a.y-12)),L>=2&&(ce(l.comboPop(L),a.x,a.y+22,"cmb",90,Xe[(L-2)%Xe.length]),p("cmbN").textContent=l.combo(L),$(p("combo"),"pkb-on")),ge(12)}p("cbar").addEventListener("animationend",()=>{p("combo").classList.remove("pkb-on"),L=0});function st(e){e.forEach(t=>{let a=le(S[t]);ce(l.reachPop,a.x,a.y-34,"rch",150)}),E(()=>T.reach(),150)}function pt(e,t){let a=e.length,r=[...new Set(e.flatMap(i=>F[i]))],s=Math.min(70,300/r.length);r.forEach((i,c)=>E(()=>{Ae(i,0);let v=le(S[i]);Q(v.x,v.y,14,{speed:5,g:.15,life:30,size:.7,star:.8}),T.note(c+3)},c*s)),E(()=>{p("bT").textContent=a>=3?l.bannerTriple:a===2?l.bannerDouble:l.bannerSingle,p("bS").textContent=a>1?l.bannerMulti(a):l.bannerNth(w.length),$(p("banner"),"pkb-show"),b||($(p("flash"),"pkb-go"),$(x,"pkb-punch"));let i=le(B);Q(i.x,i.y,a>1?180:110,{speed:a>1?16:13,star:.3}),T.bingo(a),ge(a>1?[40,60,40,60,90]:[40,60,40]),t&&E(()=>Re(t,i.x,i.y+105,"big"),350)},r.length*s+60)}function lt(e){let t=fe?0:1e3;fe=!0,z+=t,S.forEach((r,s)=>Ae(s,((s>>2)+(s&3))*60)),T.perfect(),b||$(p("flash"),"pkb-go"),p("sScore").textContent="0",p("sCombo").textContent="\xD70",p("sBingo").textContent="0",E(()=>{$(N,"pkb-show"),p("close").focus({preventScroll:!0}),E(()=>{b||$(x,"pkb-shake"),T.boom(),Q(0,G,90,{angle:-Math.PI/3,spread:.8,speed:22,g:.32,star:.3}),Q(H,G,90,{angle:-Math.PI*2/3,spread:.8,speed:22,g:.32,star:.3}),tt(3500),ge([60,80,60,80,200]),t&&ce(l.perfectBonus(t),H/2,G*.16,"big"),xe(p("sScore"),0,z,1300,r=>r.toLocaleString(),!0),xe(p("sCombo"),0,X,700,r=>"\xD7"+r),xe(p("sBingo"),0,w.length,700,String),he()},720),[1300,1650,2e3,2350,2700].forEach(r=>E(()=>at(H*(.15+Math.random()*.7),G*(.12+Math.random()*.3)),r)),E(()=>nt(3e3),1500)},520);let a={score:z,maxCombo:X};e.push(()=>n.onPerfect?.(a))}function je(){A++,y.forEach(e=>clearTimeout(e)),y.clear(),cancelAnimationFrame(be),ne=z,p("sc").textContent=z.toLocaleString()}function ct(){D=[],cancelAnimationFrame(V),V=0,k?.clearRect(0,0,j.width,j.height),x.querySelectorAll(".pkb-float, .pkb-ring").forEach(e=>e.remove()),p("banner").classList.remove("pkb-show"),p("flash").classList.remove("pkb-go"),p("combo").classList.remove("pkb-on"),N.classList.remove("pkb-show")}function ve(e,t){let a=[],r=h,s=r.filter(Boolean).length;w=F.flatMap((u,O)=>u.every(Ee=>r[Ee])?[O]:[]);let i=w.filter(u=>!se.has(u));[...se].some(u=>!w.includes(u))&&(je(),s<16&&N.classList.remove("pkb-show"));let c=new Set(w.flatMap(u=>F[u])),v=new Set;for(let u of F){let O=u.filter(Ee=>!r[Ee]);O.length===1&&v.add(O[0])}S.forEach((u,O)=>{u.classList.toggle("pkb-on",r[O]),u.classList.toggle("pkb-line",c.has(O)),u.classList.toggle("pkb-reach",v.has(O))}),x.classList.toggle("pkb-all",s===16),Le.classList.toggle("pkb-gold",s===16),Le.textContent=s===16?l.perfectTag:l.tag,p("cnt").textContent=`${s}/16`,p("bng").textContent=String(w.length);let q=p("bp");w.length>Me&&(q.classList.remove("pkb-hot"),q.offsetWidth),q.classList.toggle("pkb-hot",w.length>0),Me=w.length,Ie(i),e!==null&&it(e,r[e]);let I=i.filter(u=>!J.has(u));I.forEach(u=>J.add(u));let Y=100*I.length*I.length;if(z+=Y,t)Y&&he();else if(s===16&&!pe)lt(a);else if(i.length){pt(i,Y);let u={newLines:i,totalLines:w.length,score:z};a.push(()=>n.onBingo?.(u))}else if(e!==null&&r[e]){let u=[...v].filter(O=>!me.has(O));u.length&&st(u)}se=new Set(w),me=v,pe=s===16;let te=[...v].map(u=>m[u].label);return p("msg").textContent=s===16?l.statusDone:te.length?l.statusReach(te):w.length?l.statusLines(w.length,16-s):l.statusStart(16-s),a}let Ne=e=>{for(let t of e){if(d)return;t()}},De=()=>{P&&et(P,JSON.stringify(h))};function He(e,t,a){h=e,ie.forEach((s,i)=>s.checked=h[i]);let r=ve(t,a);return De(),r}function ye(e){je(),ct(),h=e,ie.forEach((t,a)=>t.checked=h[a]),ae.clear(),J.clear(),h.forEach((t,a)=>t&&ae.add(a)),F.forEach((t,a)=>t.every(r=>h[r])&&J.add(a)),se=new Set(J),me=new Set,pe=h.every(Boolean),fe=pe,z=ne=ae.size*10+J.size*100,L=0,X=0,p("sc").textContent=z.toLocaleString(),ve(null,!0),De()}function Z(e,t,a={}){if(f){ie.forEach((s,i)=>s.checked=h[i]),n.onChange?.(e.slice());return}let r=[];a.reset?ye(e):r=He(e,t,!!a.quiet),n.onChange?.(h.slice()),Ne(r)}if(C.addEventListener("change",e=>{let t=ie.indexOf(e.target);if(t<0)return;let a=h.slice();a[t]=!a[t],Z(a,t)}),p("reset").addEventListener("click",()=>Z(K(void 0),null,{reset:!0})),p("close").addEventListener("click",()=>N.classList.remove("pkb-show")),N.addEventListener("click",e=>{e.target===N&&N.classList.remove("pkb-show")}),R){let e=!1,t=()=>{h.every(Boolean)?Z(K(void 0),null,{reset:!0}):N.classList.remove("pkb-show")},a=async r=>{if(!e){e=!0;try{t();let s=A;for(let i of r){if(await M(i.delay),d||s!==A)return;let c=h.slice();c[i.i]=!0,Z(c,i.i,{quiet:i.quiet})}}finally{e=!1}}};p("dB").addEventListener("click",()=>{let r=()=>F.map(i=>i.filter(c=>!h[c])).filter(i=>i.length).sort((i,c)=>i.length-c.length)[0]??[],s=h.every(Boolean)?[...F[0]]:r();a(s.map(i=>({i,delay:320})))}),p("dP").addEventListener("click",()=>{let r=h.every(Boolean)?S.map((s,i)=>i):S.map((s,i)=>i).filter(s=>!h[s]);a(r.map((s,i)=>i<r.length-1?{i:s,delay:170,quiet:!0}:{i:s,delay:450}))}),p("dR").addEventListener("click",()=>{e||Z(Qe(),null,{reset:!0})})}let ee=null;if(P)try{let e=JSON.parse(Ze(P)??"null");Array.isArray(e)&&(ee=K(e))}catch{}!ee&&n.defaultChecked&&(ee=K(n.defaultChecked)),!ee&&R&&(ee=Qe()),$e(),ye(ee??K(void 0));let we=typeof ResizeObserver=="function"?new ResizeObserver(()=>{$e(),Ie()}):null;return we?.observe(x),we?.observe(B),{setChecked(e,t={}){if(d)return;let a=K(e),r=a.flatMap((s,i)=>s!==h[i]?[i]:[]);if(r.length){if(!t.animate||r.length!==1){ye(a);return}Ne(He(a,r[0],!1))}},reset(){d||Z(K(void 0),null,{reset:!0})},refresh(){d||ve(null,!0)},getState(){return{checked:h.slice(),lines:w.slice(),score:z,maxCombo:X,complete:h.every(Boolean)}},destroy(){d||(d=!0,y.forEach(e=>clearTimeout(e)),y.clear(),cancelAnimationFrame(V),cancelAnimationFrame(be),we?.disconnect(),T.close(),o.innerHTML="")}}}return ut(Et);})();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "packing-bingo",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "A school-trip packing checklist that plays like bingo, as a React component (or plain JS): combos, fanfares and fireworks.",
5
5
  "license": "MIT",
6
6
  "author": "Jin-hiroo",