packing-bingo 0.1.0 → 0.1.1
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 +289 -69
- package/dist/{chunk-RJDKWHWG.js → chunk-ZDBGKPFF.js} +4 -3
- package/dist/core.cjs +4 -3
- package/dist/core.d.cts +1 -1
- package/dist/core.d.ts +1 -1
- package/dist/core.js +1 -1
- package/dist/index.cjs +7 -4
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +4 -2
- package/dist/packing-bingo.global.js +2 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
# Packing Bingo (持ちものビンゴ)
|
|
2
2
|
|
|
3
|
-
**A
|
|
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.**
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/packing-bingo)
|
|
6
6
|
[](LICENSE)
|
|
7
|
-
[](#
|
|
8
|
-
[](
|
|
9
|
-
[](
|
|
7
|
+
[](#at-a-glance)
|
|
8
|
+
[](#api)
|
|
9
|
+
[](#standalone-page)
|
|
10
10
|
|
|
11
|
-
[
|
|
11
|
+
[Quick start](#quick-start) · [Recipes](#recipes) · [API](#api) · [Integration notes](#integration-notes) · [Without React](#without-react) · [日本語](#日本語) · [Changelog](CHANGELOG.md)
|
|
12
12
|
|
|
13
13
|
<p align="center">
|
|
14
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>
|
|
@@ -24,29 +24,35 @@ _The second line completes on a two-tap combo: the line is drawn, the BINGO! ban
|
|
|
24
24
|
|
|
25
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._
|
|
26
26
|
|
|
27
|
-
##
|
|
27
|
+
## What it does
|
|
28
28
|
|
|
29
|
-
Packing the night before a school trip is a chore. Packing Bingo
|
|
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.
|
|
30
30
|
|
|
31
|
-
- **
|
|
32
|
-
- **There's always a next goal.** A square that would complete a line pulses
|
|
33
|
-
- **
|
|
34
|
-
- **
|
|
35
|
-
- **
|
|
36
|
-
- **It fits into your site.** It's one React component with TypeScript types. All its CSS is scoped and prefixed, it lays itself out by the width of its own container rather than the screen, it renders safely on the server (Next.js App Router included), and you can theme it with CSS variables.
|
|
37
|
-
- **Sound needs no audio files.** All effects are synthesized with the Web Audio API. The 🔊 toggle mutes them, and the choice is remembered in the browser.
|
|
38
|
-
- **It's considerate by default.** Squares are real checkboxes, so the keyboard works. `prefers-reduced-motion` turns off particles, shaking and bouncing. Android phones vibrate on taps and bingos.
|
|
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.
|
|
39
36
|
|
|
40
|
-
##
|
|
37
|
+
## At a glance
|
|
41
38
|
|
|
42
|
-
|
|
39
|
+
| Area | Details |
|
|
40
|
+
| --- | --- |
|
|
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)) |
|
|
49
|
+
|
|
50
|
+
## Quick start
|
|
43
51
|
|
|
44
52
|
```sh
|
|
45
|
-
npm install packing-bingo
|
|
53
|
+
npm install packing-bingo # or: pnpm add packing-bingo / yarn add packing-bingo
|
|
46
54
|
```
|
|
47
55
|
|
|
48
|
-
Render the component wherever the checklist should go:
|
|
49
|
-
|
|
50
56
|
```tsx
|
|
51
57
|
import { PackingBingo } from "packing-bingo";
|
|
52
58
|
|
|
@@ -55,9 +61,24 @@ export default function PackingPage() {
|
|
|
55
61
|
}
|
|
56
62
|
```
|
|
57
63
|
|
|
58
|
-
|
|
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.
|
|
65
|
+
|
|
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:
|
|
67
|
+
|
|
68
|
+
```tsx
|
|
69
|
+
// app/packing/PackingCard.tsx
|
|
70
|
+
"use client";
|
|
71
|
+
|
|
72
|
+
import { PackingBingo } from "packing-bingo";
|
|
73
|
+
|
|
74
|
+
export function PackingCard() {
|
|
75
|
+
return <PackingBingo storageKey="kyoto-trip-2026" onBingo={(e) => console.log(e)} />;
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Recipes
|
|
59
80
|
|
|
60
|
-
### Your own items and
|
|
81
|
+
### Your own items and text
|
|
61
82
|
|
|
62
83
|
```tsx
|
|
63
84
|
import { PackingBingo, en, type BingoItem } from "packing-bingo";
|
|
@@ -68,67 +89,261 @@ const items: BingoItem[] = [
|
|
|
68
89
|
// … exactly 16 items, row by row from the top left
|
|
69
90
|
];
|
|
70
91
|
|
|
71
|
-
<PackingBingo items={items} messages={en} title="Kyoto Trip" tag="Class 2-B" />;
|
|
92
|
+
<PackingBingo items={items} messages={en} title="Kyoto Trip" tag="Class 2-B" storageKey="kyoto-2b" />;
|
|
72
93
|
```
|
|
73
94
|
|
|
74
|
-
`
|
|
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 常備薬・ / 酔い止め.
|
|
75
98
|
|
|
76
|
-
###
|
|
99
|
+
### Save progress on your server
|
|
77
100
|
|
|
78
|
-
To
|
|
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:
|
|
79
102
|
|
|
80
103
|
```tsx
|
|
81
|
-
|
|
104
|
+
import { useEffect, useRef, useState } from "react";
|
|
105
|
+
import { PackingBingo } from "packing-bingo";
|
|
106
|
+
|
|
107
|
+
export function PackingCard({ studentId }: { studentId: string }) {
|
|
108
|
+
const [checked, setChecked] = useState<boolean[] | null>(null);
|
|
109
|
+
const saving = useRef(Promise.resolve());
|
|
110
|
+
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
let current = true; // ignore a response that arrives after the student has changed
|
|
113
|
+
setChecked(null);
|
|
114
|
+
fetch(`/api/packing/${studentId}`)
|
|
115
|
+
.then((res) => res.json())
|
|
116
|
+
.then((data) => {
|
|
117
|
+
if (current) setChecked(data.checked ?? Array(16).fill(false));
|
|
118
|
+
});
|
|
119
|
+
return () => {
|
|
120
|
+
current = false;
|
|
121
|
+
};
|
|
122
|
+
}, [studentId]);
|
|
123
|
+
|
|
124
|
+
if (!checked) return null; // or a placeholder
|
|
125
|
+
|
|
126
|
+
const save = (next: boolean[]) => {
|
|
127
|
+
setChecked(next);
|
|
128
|
+
// Send one request at a time, so a slow earlier save can't overwrite a later one.
|
|
129
|
+
saving.current = saving.current
|
|
130
|
+
.then(() => fetch(`/api/packing/${studentId}`, { method: "PUT", body: JSON.stringify({ checked: next }) }))
|
|
131
|
+
.then(() => undefined, () => undefined);
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
return <PackingBingo checked={checked} onChange={save} />;
|
|
135
|
+
}
|
|
136
|
+
```
|
|
137
|
+
|
|
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.
|
|
139
|
+
|
|
140
|
+
How a controlled card behaves:
|
|
82
141
|
|
|
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.
|
|
146
|
+
|
|
147
|
+
### React to bingos
|
|
148
|
+
|
|
149
|
+
```tsx
|
|
83
150
|
<PackingBingo
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
151
|
+
storageKey="kyoto-trip-2026"
|
|
152
|
+
onBingo={({ newLines, totalLines, score }) => analytics.track("bingo", { totalLines, score })}
|
|
153
|
+
onPerfect={({ score, maxCombo }) => analytics.track("perfect", { score, maxCombo })}
|
|
154
|
+
/>
|
|
155
|
+
```
|
|
156
|
+
|
|
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.
|
|
161
|
+
|
|
162
|
+
### Control the card from outside
|
|
163
|
+
|
|
164
|
+
```tsx
|
|
165
|
+
import { useRef } from "react";
|
|
166
|
+
import { PackingBingo, type PackingBingoHandle } from "packing-bingo";
|
|
167
|
+
|
|
168
|
+
function PackingPage() {
|
|
169
|
+
const card = useRef<PackingBingoHandle>(null);
|
|
170
|
+
return (
|
|
171
|
+
<>
|
|
172
|
+
<button onClick={() => card.current?.reset()}>Start over</button>
|
|
173
|
+
<PackingBingo ref={card} storageKey="kyoto-trip-2026" />
|
|
174
|
+
</>
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
The handle has `reset()`, `setChecked()` and `getState()`. See [Ref handle](#ref-handle). With a controlled card, change `checked` instead of calling `setChecked()`.
|
|
180
|
+
|
|
181
|
+
### Theme and layout
|
|
182
|
+
|
|
183
|
+
Set any of these CSS variables on the component or an ancestor:
|
|
184
|
+
|
|
185
|
+
| Variable | Used for | Default |
|
|
186
|
+
| --- | --- | --- |
|
|
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` |
|
|
193
|
+
|
|
194
|
+
```tsx
|
|
195
|
+
<PackingBingo style={{ "--pkb-sun": "#9AD0F5" } as React.CSSProperties} />
|
|
89
196
|
```
|
|
90
197
|
|
|
91
|
-
|
|
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`.
|
|
199
|
+
|
|
200
|
+
## API
|
|
92
201
|
|
|
93
202
|
### Props
|
|
94
203
|
|
|
95
204
|
| Prop | Type | Default | What it does |
|
|
96
205
|
| --- | --- | --- | --- |
|
|
97
|
-
| `items` | `BingoItem[]` (16) | Japanese school-trip list |
|
|
98
|
-
| `messages` | `Partial<Messages>` | `ja` | UI text
|
|
99
|
-
| `title`, `tag`, `subtitle` | `string` | from `messages` |
|
|
100
|
-
| `
|
|
101
|
-
| `
|
|
102
|
-
| `
|
|
103
|
-
| `
|
|
104
|
-
| `
|
|
105
|
-
| `
|
|
106
|
-
| `sound` | `boolean` | `true` |
|
|
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 |
|
|
107
216
|
| `fonts` | `boolean` | `true` | Load Dela Gothic One and M PLUS Rounded 1c from Google Fonts |
|
|
108
|
-
| `demo` | `boolean` | `false` | Pre-fill six items and show demo buttons |
|
|
109
|
-
| `className`, `style` | |
|
|
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) |
|
|
110
220
|
|
|
111
|
-
|
|
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.
|
|
112
222
|
|
|
113
|
-
###
|
|
223
|
+
### Events
|
|
114
224
|
|
|
115
|
-
|
|
225
|
+
```ts
|
|
226
|
+
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
|
|
230
|
+
}
|
|
116
231
|
|
|
117
|
-
|
|
118
|
-
|
|
232
|
+
interface PerfectEvent {
|
|
233
|
+
score: number; // final score, including the 1000-point bonus the first time
|
|
234
|
+
maxCombo: number;
|
|
235
|
+
}
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
### Ref handle
|
|
239
|
+
|
|
240
|
+
```ts
|
|
241
|
+
interface PackingBingoHandle {
|
|
242
|
+
/** Same as the reset button: clears the card and score, and calls onChange. A controlled card only calls onChange. */
|
|
243
|
+
reset(): void;
|
|
244
|
+
/** A one-item change plays like a tap; bigger changes apply quietly. Doesn't call onChange. */
|
|
245
|
+
setChecked(checked: readonly boolean[]): void;
|
|
246
|
+
/** null before mount and on the server. */
|
|
247
|
+
getState(): PackingBingoState | null;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
interface PackingBingoState {
|
|
251
|
+
checked: boolean[]; // 16 booleans, row by row
|
|
252
|
+
lines: number[]; // completed lines, as indices into LINES
|
|
253
|
+
score: number;
|
|
254
|
+
maxCombo: number;
|
|
255
|
+
complete: boolean; // all 16 checked
|
|
256
|
+
}
|
|
119
257
|
```
|
|
120
258
|
|
|
121
|
-
|
|
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.
|
|
260
|
+
|
|
261
|
+
### Scoring
|
|
262
|
+
|
|
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.
|
|
268
|
+
|
|
269
|
+
### Messages
|
|
270
|
+
|
|
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).
|
|
272
|
+
|
|
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.
|
|
275
|
+
|
|
276
|
+
### Exports
|
|
277
|
+
|
|
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.
|
|
280
|
+
|
|
281
|
+
## Integration notes
|
|
282
|
+
|
|
283
|
+
### What the card adds to your page
|
|
284
|
+
|
|
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.
|
|
286
|
+
|
|
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.
|
|
288
|
+
|
|
289
|
+
### Content Security Policy
|
|
290
|
+
|
|
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`.
|
|
292
|
+
|
|
293
|
+
### Fonts
|
|
294
|
+
|
|
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`.
|
|
296
|
+
|
|
297
|
+
### Sound and vibration
|
|
298
|
+
|
|
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.
|
|
300
|
+
|
|
301
|
+
### Saved data
|
|
302
|
+
|
|
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.
|
|
122
304
|
|
|
123
|
-
###
|
|
305
|
+
### Accessibility
|
|
306
|
+
|
|
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.
|
|
308
|
+
|
|
309
|
+
### Browser support and testing
|
|
310
|
+
|
|
311
|
+
The card uses CSS container queries (Chrome and Edge 105+, Safari 16+, Firefox 110+) and `:has()` for the keyboard focus ring (Firefox 121+).
|
|
312
|
+
|
|
313
|
+
Tested so far:
|
|
314
|
+
|
|
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
|
|
320
|
+
|
|
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.
|
|
322
|
+
|
|
323
|
+
## Without React
|
|
124
324
|
|
|
125
325
|
The engine has no framework dependency:
|
|
126
326
|
|
|
127
327
|
```ts
|
|
128
328
|
import { createPackingBingo } from "packing-bingo/core";
|
|
129
329
|
|
|
130
|
-
const card = createPackingBingo(document.getElementById("bingo")!, {
|
|
131
|
-
|
|
330
|
+
const card = createPackingBingo(document.getElementById("bingo")!, {
|
|
331
|
+
storageKey: "trip",
|
|
332
|
+
onBingo: (e) => console.log(e),
|
|
333
|
+
});
|
|
334
|
+
// card.reset(), card.getState(), card.setChecked(next, { animate: true }), card.destroy()
|
|
335
|
+
```
|
|
336
|
+
|
|
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.
|
|
338
|
+
|
|
339
|
+
Without a bundler, load the browser build from a CDN. It defines a global `PackingBingo`:
|
|
340
|
+
|
|
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>
|
|
132
347
|
```
|
|
133
348
|
|
|
134
349
|
## Standalone page
|
|
@@ -143,12 +358,7 @@ open index.html # macOS; on other systems, open the file in a browser
|
|
|
143
358
|
|
|
144
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**.
|
|
145
360
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
- **React:** use the `items`, `messages`, `title`, `tag` and `subtitle` props shown above.
|
|
149
|
-
- **Standalone page:** the last `<script>` in `index.html` calls `PackingBingo.createPackingBingo(…)`. Add options there, for example `items: [{ emoji: "🛂", label: "Passport" }, …]`. To change the defaults for every build, edit `src/items.ts` or `src/messages.ts` and run `npm run build`.
|
|
150
|
-
|
|
151
|
-
The card needs exactly 16 items, and it throws an error otherwise. On narrow screens, labels wrap after a `・`, so `常備薬・酔い止め` breaks as 常備薬・ / 酔い止め.
|
|
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`.
|
|
152
362
|
|
|
153
363
|
## Development
|
|
154
364
|
|
|
@@ -160,19 +370,29 @@ npm run typecheck
|
|
|
160
370
|
|
|
161
371
|
`index.html` is generated from `src/` by `scripts/build-standalone.mjs`, so don't edit it by hand.
|
|
162
372
|
|
|
163
|
-
##
|
|
373
|
+
## 日本語
|
|
164
374
|
|
|
165
|
-
|
|
166
|
-
- **Emoji look different on each system**, because they come from the device's emoji font.
|
|
167
|
-
- **Vibration works only where the Vibration API exists**, which in practice means Android browsers. iPhones don't vibrate.
|
|
168
|
-
- **Testing so far:** the React component was tested with React 19 in StrictMode, three cards on one page, controlled and uncontrolled, and server rendering with `renderToString` (ESM and CommonJS). Resets and callbacks that land mid-celebration were checked with scripted browser tests, which aren't part of this repository yet. Taps were automated in Chromium at desktop and phone sizes. Safari, Firefox and real phones haven't been tried yet, and the sound hasn't been checked on a real device.
|
|
375
|
+
修学旅行の持ち物チェックリストを4×4のビンゴにする、Reactのコンポーネントです。持ち物をかばんに入れるたびにマスをタップしてスタンプを押し、タテ・ヨコ・ナナメがそろうとビンゴになります。
|
|
169
376
|
|
|
170
|
-
|
|
377
|
+
```sh
|
|
378
|
+
npm install packing-bingo
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
```tsx
|
|
382
|
+
import { PackingBingo } from "packing-bingo";
|
|
383
|
+
|
|
384
|
+
<PackingBingo storageKey="kyoto-trip-2026" />;
|
|
385
|
+
```
|
|
171
386
|
|
|
172
|
-
|
|
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'` が必要です。
|
|
173
394
|
|
|
174
|
-
|
|
175
|
-
- **単体のページで使う場合:** `index.html` を開くだけで動き、進み具合はブラウザに保存されます。`?demo` を付けると、途中まで埋まった状態とデモ用のボタンが出ます。
|
|
395
|
+
詳しい仕様は [API](#api) と [Integration notes](#integration-notes) にあります。
|
|
176
396
|
|
|
177
397
|
## License
|
|
178
398
|
|
|
@@ -198,7 +198,7 @@ function createSound(isMuted) {
|
|
|
198
198
|
// src/styles.ts
|
|
199
199
|
var FONT_URL = "https://fonts.googleapis.com/css2?family=Dela+Gothic+One&family=M+PLUS+Rounded+1c:wght@500;800&display=swap";
|
|
200
200
|
var CSS = `
|
|
201
|
-
.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;width:100%}
|
|
201
|
+
.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%}
|
|
202
202
|
.pkb,.pkb *{box-sizing:border-box;margin:0;padding:0}
|
|
203
203
|
.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}
|
|
204
204
|
.pkb-board.pkb-shake{animation:pkb-shake .45s cubic-bezier(.36,.07,.19,.97)}
|
|
@@ -915,14 +915,15 @@ function createPackingBingo(container, options = {}) {
|
|
|
915
915
|
if (!busy) request(demoStart(), null, { reset: true });
|
|
916
916
|
});
|
|
917
917
|
}
|
|
918
|
-
let initial =
|
|
919
|
-
if (
|
|
918
|
+
let initial = null;
|
|
919
|
+
if (storageKey) {
|
|
920
920
|
try {
|
|
921
921
|
const saved = JSON.parse(readStore(storageKey) ?? "null");
|
|
922
922
|
if (Array.isArray(saved)) initial = normalize(saved);
|
|
923
923
|
} catch {
|
|
924
924
|
}
|
|
925
925
|
}
|
|
926
|
+
if (!initial && options.defaultChecked) initial = normalize(options.defaultChecked);
|
|
926
927
|
if (!initial && demo) initial = demoStart();
|
|
927
928
|
fit();
|
|
928
929
|
baseline(initial ?? normalize(void 0));
|
package/dist/core.cjs
CHANGED
|
@@ -227,7 +227,7 @@ function createSound(isMuted) {
|
|
|
227
227
|
// src/styles.ts
|
|
228
228
|
var FONT_URL = "https://fonts.googleapis.com/css2?family=Dela+Gothic+One&family=M+PLUS+Rounded+1c:wght@500;800&display=swap";
|
|
229
229
|
var CSS = `
|
|
230
|
-
.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;width:100%}
|
|
230
|
+
.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%}
|
|
231
231
|
.pkb,.pkb *{box-sizing:border-box;margin:0;padding:0}
|
|
232
232
|
.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}
|
|
233
233
|
.pkb-board.pkb-shake{animation:pkb-shake .45s cubic-bezier(.36,.07,.19,.97)}
|
|
@@ -944,14 +944,15 @@ function createPackingBingo(container, options = {}) {
|
|
|
944
944
|
if (!busy) request(demoStart(), null, { reset: true });
|
|
945
945
|
});
|
|
946
946
|
}
|
|
947
|
-
let initial =
|
|
948
|
-
if (
|
|
947
|
+
let initial = null;
|
|
948
|
+
if (storageKey) {
|
|
949
949
|
try {
|
|
950
950
|
const saved = JSON.parse(readStore(storageKey) ?? "null");
|
|
951
951
|
if (Array.isArray(saved)) initial = normalize(saved);
|
|
952
952
|
} catch {
|
|
953
953
|
}
|
|
954
954
|
}
|
|
955
|
+
if (!initial && options.defaultChecked) initial = normalize(options.defaultChecked);
|
|
955
956
|
if (!initial && demo) initial = demoStart();
|
|
956
957
|
fit();
|
|
957
958
|
baseline(initial ?? normalize(void 0));
|
package/dist/core.d.cts
CHANGED
|
@@ -82,7 +82,7 @@ interface PackingBingoOptions {
|
|
|
82
82
|
title?: string;
|
|
83
83
|
tag?: string;
|
|
84
84
|
subtitle?: string;
|
|
85
|
-
/** Initial state
|
|
85
|
+
/** Initial state (16 booleans), used when nothing is saved under `storageKey`. */
|
|
86
86
|
defaultChecked?: readonly boolean[];
|
|
87
87
|
/**
|
|
88
88
|
* When true, taps don't change the card by themselves: they call `onChange` with the
|
package/dist/core.d.ts
CHANGED
|
@@ -82,7 +82,7 @@ interface PackingBingoOptions {
|
|
|
82
82
|
title?: string;
|
|
83
83
|
tag?: string;
|
|
84
84
|
subtitle?: string;
|
|
85
|
-
/** Initial state
|
|
85
|
+
/** Initial state (16 booleans), used when nothing is saved under `storageKey`. */
|
|
86
86
|
defaultChecked?: readonly boolean[];
|
|
87
87
|
/**
|
|
88
88
|
* When true, taps don't change the card by themselves: they call `onChange` with the
|
package/dist/core.js
CHANGED
package/dist/index.cjs
CHANGED
|
@@ -231,7 +231,7 @@ function createSound(isMuted) {
|
|
|
231
231
|
// src/styles.ts
|
|
232
232
|
var FONT_URL = "https://fonts.googleapis.com/css2?family=Dela+Gothic+One&family=M+PLUS+Rounded+1c:wght@500;800&display=swap";
|
|
233
233
|
var CSS = `
|
|
234
|
-
.pkb{--pk-sun:var(--pkb-sun,#FFD54F);--pk-ink:var(--pkb-ink,#2A1F14);--pk-paper:var(--pkb-paper,#FFFDF6);--pk-red:var(--pkb-red,#E8473B);--pk-mint:var(--pkb-mint,#BDEBD2);--pk-display:"Dela Gothic One",var(--pkb-font-fallback,sans-serif);--pk-body:"M PLUS Rounded 1c","Hiragino Maru Gothic ProN",var(--pkb-font-fallback,sans-serif);container-type:inline-size;width:100%}
|
|
234
|
+
.pkb{--pk-sun:var(--pkb-sun,#FFD54F);--pk-ink:var(--pkb-ink,#2A1F14);--pk-paper:var(--pkb-paper,#FFFDF6);--pk-red:var(--pkb-red,#E8473B);--pk-mint:var(--pkb-mint,#BDEBD2);--pk-display:"Dela Gothic One",var(--pkb-font-fallback,sans-serif);--pk-body:"M PLUS Rounded 1c","Hiragino Maru Gothic ProN",var(--pkb-font-fallback,sans-serif);container-type:inline-size;isolation:isolate;width:100%}
|
|
235
235
|
.pkb,.pkb *{box-sizing:border-box;margin:0;padding:0}
|
|
236
236
|
.pkb-board{position:relative;overflow:hidden;max-width:720px;margin:0 auto;padding:22px 26px 20px;border-radius:24px;background-color:var(--pk-sun);background-image:radial-gradient(rgba(42,31,20,.12) 1.3px,transparent 1.6px);background-size:16px 16px;border:2.5px solid var(--pk-ink);color:var(--pk-ink);font-family:var(--pk-body);line-height:1.4;text-align:left}
|
|
237
237
|
.pkb-board.pkb-shake{animation:pkb-shake .45s cubic-bezier(.36,.07,.19,.97)}
|
|
@@ -948,14 +948,15 @@ function createPackingBingo(container, options = {}) {
|
|
|
948
948
|
if (!busy) request(demoStart(), null, { reset: true });
|
|
949
949
|
});
|
|
950
950
|
}
|
|
951
|
-
let initial =
|
|
952
|
-
if (
|
|
951
|
+
let initial = null;
|
|
952
|
+
if (storageKey) {
|
|
953
953
|
try {
|
|
954
954
|
const saved = JSON.parse(readStore(storageKey) ?? "null");
|
|
955
955
|
if (Array.isArray(saved)) initial = normalize(saved);
|
|
956
956
|
} catch {
|
|
957
957
|
}
|
|
958
958
|
}
|
|
959
|
+
if (!initial && options.defaultChecked) initial = normalize(options.defaultChecked);
|
|
959
960
|
if (!initial && demo) initial = demoStart();
|
|
960
961
|
fit();
|
|
961
962
|
baseline(initial ?? normalize(void 0));
|
|
@@ -1012,6 +1013,7 @@ var PackingBingo = (0, import_react.forwardRef)(function PackingBingo2(props, re
|
|
|
1012
1013
|
messagesRef.current = messages;
|
|
1013
1014
|
const controlled = checked !== void 0;
|
|
1014
1015
|
const latest = (0, import_react.useRef)(checked ?? defaultChecked);
|
|
1016
|
+
const latestKey = (0, import_react.useRef)(config.storageKey);
|
|
1015
1017
|
if (controlled) latest.current = checked;
|
|
1016
1018
|
const messageShape = messages ? Object.fromEntries(Object.entries(messages).map(([k, v]) => [k, typeof v === "function" ? "\u0192" : v])) : null;
|
|
1017
1019
|
const key = JSON.stringify({ ...config, messageShape, controlled });
|
|
@@ -1027,7 +1029,7 @@ var PackingBingo = (0, import_react.forwardRef)(function PackingBingo2(props, re
|
|
|
1027
1029
|
...config,
|
|
1028
1030
|
messages: liveMessages,
|
|
1029
1031
|
controlled,
|
|
1030
|
-
defaultChecked: latest.current,
|
|
1032
|
+
defaultChecked: controlled || latestKey.current === config.storageKey ? latest.current : defaultChecked,
|
|
1031
1033
|
onChange: (next) => {
|
|
1032
1034
|
if (!controlled) latest.current = next;
|
|
1033
1035
|
callbacks.current.onChange?.(next);
|
|
@@ -1038,6 +1040,7 @@ var PackingBingo = (0, import_react.forwardRef)(function PackingBingo2(props, re
|
|
|
1038
1040
|
inst.current = instance;
|
|
1039
1041
|
return () => {
|
|
1040
1042
|
if (!controlled) latest.current = instance.getState().checked;
|
|
1043
|
+
latestKey.current = config.storageKey;
|
|
1041
1044
|
instance.destroy();
|
|
1042
1045
|
if (inst.current === instance) inst.current = null;
|
|
1043
1046
|
};
|
package/dist/index.d.cts
CHANGED
|
@@ -6,7 +6,7 @@ export { BingoEvent, BingoItem, DEFAULT_ITEMS, LINES, Messages, PackingBingoInst
|
|
|
6
6
|
interface PackingBingoProps extends Omit<PackingBingoOptions, "controlled" | "defaultChecked"> {
|
|
7
7
|
/** Controlled state (16 booleans). Update it from `onChange`. */
|
|
8
8
|
checked?: readonly boolean[];
|
|
9
|
-
/** Initial state for an uncontrolled card (16 booleans)
|
|
9
|
+
/** Initial state for an uncontrolled card (16 booleans), used when nothing is saved under `storageKey`. */
|
|
10
10
|
defaultChecked?: readonly boolean[];
|
|
11
11
|
className?: string;
|
|
12
12
|
style?: CSSProperties;
|
package/dist/index.d.ts
CHANGED
|
@@ -6,7 +6,7 @@ export { BingoEvent, BingoItem, DEFAULT_ITEMS, LINES, Messages, PackingBingoInst
|
|
|
6
6
|
interface PackingBingoProps extends Omit<PackingBingoOptions, "controlled" | "defaultChecked"> {
|
|
7
7
|
/** Controlled state (16 booleans). Update it from `onChange`. */
|
|
8
8
|
checked?: readonly boolean[];
|
|
9
|
-
/** Initial state for an uncontrolled card (16 booleans)
|
|
9
|
+
/** Initial state for an uncontrolled card (16 booleans), used when nothing is saved under `storageKey`. */
|
|
10
10
|
defaultChecked?: readonly boolean[];
|
|
11
11
|
className?: string;
|
|
12
12
|
style?: CSSProperties;
|
package/dist/index.js
CHANGED
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
createPackingBingo,
|
|
6
6
|
en,
|
|
7
7
|
ja
|
|
8
|
-
} from "./chunk-
|
|
8
|
+
} from "./chunk-ZDBGKPFF.js";
|
|
9
9
|
|
|
10
10
|
// src/react.tsx
|
|
11
11
|
import { forwardRef, useEffect, useImperativeHandle, useRef } from "react";
|
|
@@ -20,6 +20,7 @@ var PackingBingo = forwardRef(function PackingBingo2(props, ref) {
|
|
|
20
20
|
messagesRef.current = messages;
|
|
21
21
|
const controlled = checked !== void 0;
|
|
22
22
|
const latest = useRef(checked ?? defaultChecked);
|
|
23
|
+
const latestKey = useRef(config.storageKey);
|
|
23
24
|
if (controlled) latest.current = checked;
|
|
24
25
|
const messageShape = messages ? Object.fromEntries(Object.entries(messages).map(([k, v]) => [k, typeof v === "function" ? "\u0192" : v])) : null;
|
|
25
26
|
const key = JSON.stringify({ ...config, messageShape, controlled });
|
|
@@ -35,7 +36,7 @@ var PackingBingo = forwardRef(function PackingBingo2(props, ref) {
|
|
|
35
36
|
...config,
|
|
36
37
|
messages: liveMessages,
|
|
37
38
|
controlled,
|
|
38
|
-
defaultChecked: latest.current,
|
|
39
|
+
defaultChecked: controlled || latestKey.current === config.storageKey ? latest.current : defaultChecked,
|
|
39
40
|
onChange: (next) => {
|
|
40
41
|
if (!controlled) latest.current = next;
|
|
41
42
|
callbacks.current.onChange?.(next);
|
|
@@ -46,6 +47,7 @@ var PackingBingo = forwardRef(function PackingBingo2(props, ref) {
|
|
|
46
47
|
inst.current = instance;
|
|
47
48
|
return () => {
|
|
48
49
|
if (!controlled) latest.current = instance.getState().checked;
|
|
50
|
+
latestKey.current = config.storageKey;
|
|
49
51
|
instance.destroy();
|
|
50
52
|
if (inst.current === instance) inst.current = null;
|
|
51
53
|
};
|
|
@@ -3,7 +3,7 @@
|
|
|
3
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
|
|
4
4
|
SET`,perfectTitle:"Perfect!",perfectBody:`All 16 packed!
|
|
5
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=`
|
|
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;width:100%}
|
|
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}
|
|
9
9
|
.pkb-board.pkb-shake{animation:pkb-shake .45s cubic-bezier(.36,.07,.19,.97)}
|
|
@@ -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,te=0,B=0,X=0,Ce=0,me=!1,ne=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,ae=1,H=0,G=0,Pe=()=>{let e=x.getBoundingClientRect();ae=Math.min(2,window.devicePixelRatio||1),H=e.width,G=e.height,I.width=Math.round(H*ae),I.height=Math.round(G*ae)},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(ae,0,0,ae,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=te,t=z,a=performance.now(),o=s=>{if(b)return;let i=Oe(s,a,450);te=Math.round(e+(t-e)*Fe(i)),p("sc").textContent=te.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 ee=Oe(Y,l,o);e.textContent=s(Math.round(t+(a-t)*Fe(ee))),i&&ee<1&&Y-q>55&&(T.tick(),q=Y),ee<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}),ne.has(e)||(ne.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),te=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 ee=[...v].map(u=>k[u].label);return p("msg").textContent=s===16?d.statusDone:ee.length?d.statusReach(ee):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]),ne.clear(),J.clear(),h.forEach((t,a)=>t&&ne.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=te=ne.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 oe=n.defaultChecked?_(n.defaultChecked):null;if(!oe&&$)try{let e=JSON.parse(Qe($)??"null");Array.isArray(e)&&(oe=_(e))}catch{}!oe&&R&&(oe=Je()),Pe(),ve(oe??_(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>${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);})();
|
package/package.json
CHANGED