@nano133/nanoterra 0.7.1 → 0.8.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.
Files changed (99) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/LICENSE +3 -0
  3. package/README.md +212 -17
  4. package/ai/SPEC.md +51 -2
  5. package/dist/admin/adapters/d1.d.ts +1 -0
  6. package/dist/admin/adapters/d1.js +6 -4
  7. package/dist/admin/adapters/firestore.d.ts +1 -0
  8. package/dist/admin/adapters/firestore.js +7 -5
  9. package/dist/admin/conformance.d.ts +1 -1
  10. package/dist/admin/conformance.js +12 -4
  11. package/dist/admin/handler.d.ts +1 -1
  12. package/dist/admin/handler.js +2 -2
  13. package/dist/admin/index.d.ts +2 -2
  14. package/dist/admin/index.js +1 -1
  15. package/dist/admin/memory.js +3 -1
  16. package/dist/admin/panel/dom.d.ts +2 -0
  17. package/dist/admin/panel/dom.js +6 -0
  18. package/dist/admin/panel/gameCard.js +13 -10
  19. package/dist/admin/panel/icons.d.ts +17 -0
  20. package/dist/admin/panel/icons.js +3 -0
  21. package/dist/admin/panel/overview.js +1 -1
  22. package/dist/admin/store.d.ts +2 -1
  23. package/dist/admin/switches.d.ts +13 -3
  24. package/dist/admin/switches.js +25 -6
  25. package/dist/character/create.js +6 -2
  26. package/dist/character/layer.d.ts +14 -0
  27. package/dist/character/layer.js +10 -0
  28. package/dist/emotes/clip.d.ts +79 -0
  29. package/dist/emotes/clip.js +165 -0
  30. package/dist/emotes/clips.d.ts +3 -0
  31. package/dist/emotes/clips.js +2 -0
  32. package/dist/emotes/events.d.ts +22 -0
  33. package/dist/emotes/events.js +19 -0
  34. package/dist/emotes/icons.d.ts +1 -0
  35. package/dist/emotes/icons.js +6 -0
  36. package/dist/emotes/index.d.ts +8 -0
  37. package/dist/emotes/index.js +11 -0
  38. package/dist/emotes/list.d.ts +123 -0
  39. package/dist/emotes/list.js +23 -0
  40. package/dist/emotes/options.d.ts +16 -0
  41. package/dist/emotes/options.js +34 -0
  42. package/dist/emotes/panel/index.d.ts +3 -0
  43. package/dist/emotes/panel/index.js +9 -0
  44. package/dist/emotes/panel/mount.d.ts +61 -0
  45. package/dist/emotes/panel/mount.js +364 -0
  46. package/dist/emotes/player.d.ts +37 -0
  47. package/dist/emotes/player.js +101 -0
  48. package/dist/emotes/wheel.d.ts +42 -0
  49. package/dist/emotes/wheel.js +110 -0
  50. package/dist/friends/panel/mount.d.ts +5 -0
  51. package/dist/friends/panel/mount.js +5 -2
  52. package/dist/options/common.d.ts +88 -0
  53. package/dist/options/common.js +59 -0
  54. package/dist/options/core.d.ts +130 -0
  55. package/dist/options/core.js +420 -0
  56. package/dist/options/index.d.ts +7 -0
  57. package/dist/options/index.js +24 -0
  58. package/dist/options/panel/index.d.ts +2 -0
  59. package/dist/options/panel/index.js +10 -0
  60. package/dist/options/panel/mount.d.ts +35 -0
  61. package/dist/options/panel/mount.js +380 -0
  62. package/dist/options/reflect.d.ts +5 -0
  63. package/dist/options/reflect.js +27 -0
  64. package/package.json +12 -4
  65. package/src/admin/adapters/d1.ts +5 -4
  66. package/src/admin/adapters/firestore.ts +6 -5
  67. package/src/admin/conformance.ts +14 -5
  68. package/src/admin/handler.ts +2 -2
  69. package/src/admin/index.ts +2 -2
  70. package/src/admin/memory.ts +2 -1
  71. package/src/admin/panel/dom.ts +6 -0
  72. package/src/admin/panel/gameCard.ts +13 -9
  73. package/src/admin/panel/icons.ts +3 -0
  74. package/src/admin/panel/overview.ts +1 -1
  75. package/src/admin/store.ts +2 -2
  76. package/src/admin/switches.ts +29 -12
  77. package/src/character/create.ts +6 -2
  78. package/src/character/layer.ts +21 -0
  79. package/src/emotes/clip.ts +204 -0
  80. package/src/emotes/clips.ts +7 -0
  81. package/src/emotes/events.ts +28 -0
  82. package/src/emotes/icons.ts +7 -0
  83. package/src/emotes/icons.webp +0 -0
  84. package/src/emotes/index.ts +16 -0
  85. package/src/emotes/list.ts +33 -0
  86. package/src/emotes/options.ts +43 -0
  87. package/src/emotes/panel/index.ts +11 -0
  88. package/src/emotes/panel/mount.ts +397 -0
  89. package/src/emotes/panel/style.css +415 -0
  90. package/src/emotes/player.ts +131 -0
  91. package/src/emotes/wheel.ts +131 -0
  92. package/src/friends/panel/mount.ts +8 -2
  93. package/src/options/common.ts +65 -0
  94. package/src/options/core.ts +465 -0
  95. package/src/options/index.ts +41 -0
  96. package/src/options/panel/index.ts +12 -0
  97. package/src/options/panel/mount.ts +423 -0
  98. package/src/options/panel/style.css +528 -0
  99. package/src/options/reflect.ts +28 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,56 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.1 (2026-11-01)
4
+
5
+ - **A closed panel gives the keys back to the game.** The options panel, the friends panel and the emote wheel put the focus back on their key when they
6
+ closed; Space (a game's jump) then pressed that key and opened the panel again (in the starter, after the player moved Jump off Space). Now the focus
7
+ leaves the key and the panel. `focusAfterClose: "key"` (on `mountOptions`, `mountFriends` and `mountEmotes`) keeps the old way, for a page with no keys of
8
+ its own.
9
+
10
+ ## 0.8.0 (2026-11-01)
11
+
12
+ - **An options menu for a game** (`@nano133/nanoterra/options`, `/options/panel` and `/options/style.css`). Wes: "build options menu and emotes in kit"
13
+ (2026-11-01).
14
+ - **The store** (`createOptions({ game, defs })`, no DOM): a game declares its settings as data, of four kinds: `"toggle"`, `"choice"`, `"range"` and `"key"`
15
+ (a key binding, a `KeyboardEvent.code`; two bindings never share a key: a taken key swaps). Each has a key, a short label, an optional help line, a tab
16
+ (`"basic"` or `"advanced"`), an optional group and a default, one value or one for each device class `{ phone?, tablet?, desktop?, reducedMotion?, default }`
17
+ (`detectDevice`: a coarse pointer with a short side under 600 px is a phone, another coarse pointer a tablet, else a desktop). The device's reduced-motion
18
+ setting is followed live until the player sets the option. Only the values that differ from the default are kept, in `localStorage` under
19
+ `n133-options:<game>`; a kept value that does not fit is dropped on read; a refused storage works in memory; `migrate` reads an old save. `get`, `set`,
20
+ `reset`, `values`, `defs`, `def`, `defaultOf`, `isDefault`, `subscribe`, `subscribeDefs`, `add` (a module's section, keys like `emotes.show`; a key used twice
21
+ throws) and `redetect`.
22
+ - **The common settings** a game opts into by key (`common(...)`, `COMMON_OPTIONS`): `masterVolume`, `musicVolume`, `effectsVolume`, `cameraSensitivity`,
23
+ `invertLook`, `stickSide` (a phone and a tablet only), `reducedMotion`, `quality` and `showFps`. `keyBinding(action, label, code)` and `optionSection(name, defs,
24
+ group?)` make defs.
25
+ - **The panel** (`mountOptions(el, store, { title?, key?, onOpen?, onClose?, onRangeDone?, confirm? })`): a gear key and a sheet (from the bottom on a phone held
26
+ upright, from the right on a wide or sideways screen) with Basic and Advanced tabs (ARIA tabs, the arrow keys), groups, a switch, a row of keys or a
27
+ drop-down, a slider with its value, a key cap that waits for a key. A change applies at once and shows in place (no Save key; the panel never scrolls back to
28
+ the top). "Reset to defaults" asks in the panel, never in a browser dialog. `key: false` for a game's own pause menu. Light and dark: it follows
29
+ `data-n133-theme` on `<html>`, else `prefers-color-scheme`; every colour is a `--n133-*` variable with a `--n133-dark-*` twin.
30
+ - `reflectOptions(store, ["reducedMotion"])` keeps `data-n133-opt-reduced-motion` on `<html>` for the page's CSS.
31
+ - Ideas from Echo Island's own draft (its key bindings with a swap, the live reduced motion, "Less motion" in the page's CSS, rows changed in place, a sound when
32
+ a slider is let go).
33
+ - The admin panel's icons have a `gear`.
34
+
35
+ - **Emotes for a game** (`@nano133/nanoterra/emotes`, `/emotes/player`, `/emotes/wheel`, `/emotes/style.css` and `/emotes/icons.webp`). Wes: "build options
36
+ menu and emotes in kit" (2026-11-01). Nano Island's eleven emotes, the same as nano133.com's (2026-10-30):
37
+ - **The clips** (`/emotes/player`): the Wave is the character's own `wave()`; Cheer, Clap, Dance, Bow, Shrug, Laugh, Point, Sit, Facepalm and Spin are the
38
+ island's Blender clips, made on this package's skeleton. `createEmotes(character, { onEmote, onStop })` gives `play(id)`, `stop()` and `playing`. A clip is a
39
+ layer over the character's pose: `update` solves the walk, the clip blends over it, then the tail, the wings and the hair follow, so the frame loop does not
40
+ change. The island's rules: fades of 0.12 s in, 0.22 s out at the end and 0.18 s on a stop; a whole-body clip stops on a move or a jump, an upper-body one
41
+ (Clap, Shrug, Point, Facepalm) plays over the walk; Dance and Sit loop until a move; reduced motion holds one frame. `playRemote(character, { id, at? })`
42
+ plays another player's emote and ignores an id that is not one; `stopEmote(character)`. The clips (84 kB, 18.8 kB gzip) load on the first emote
43
+ (`loadEmotes`, a dynamic `import()`, so a chunk of their own).
44
+ - **The wheel** (`/emotes/wheel`): `mountEmotes(el, { game, onPlay })`, the island's wheel in plain DOM: an Emotes key, the wheel in the middle over a dim
45
+ page, eight places, a tap or a hold-and-slide, Edit ("Your wheel is full. Take one off first."), B and 1 to 8, Escape; kept per game in this browser
46
+ (`n133-emotes:<game>`). Nanoterra's emotes, the game's own (`{ id, label, icon? }`), or both. Light and dark. The pictures load when it first opens.
47
+ - **Multiplayer:** `onEmote({ id, loop, at, again })` (a loop again every 10 s, `LOOP_AGAIN_MS`), `onStop({ id, at, why })`, and the message
48
+ `emoteMessage(e)` = `{ emote: id }`, read back with `readEmoteMessage`. The kit has no network.
49
+ - **Options:** `EMOTE_OPTIONS` for `/options`: "Show the Emotes key", "Show other players' emotes" and the wheel's key (`keys.emotes`, B).
50
+ - **Licence:** the clips and the icons picture are part of the package and under its MIT licence (Wes, 2026-11-01).
51
+ - **The admin's Game card: "Allow Nanoterra emotes"** (`emotes`: `"on"` by default, or `"off"`), kept as `setting:emotes` by the memory, D1 and Firestore stores
52
+ (a fourth setting; the store contract has a case for it). `GameState.emotes`, `DEFAULT_EMOTES`, `isEmotes`; the Overview shows it.
53
+
3
54
  ## 0.7.1 (2026-10-31)
4
55
 
5
56
  - **Friends: the panel asks in the panel, never in a browser dialog.** Remove, Block and Join (both modes) showed `window.confirm`; now the question and a key for
package/LICENSE CHANGED
@@ -19,3 +19,6 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
19
  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
20
  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
21
  SOFTWARE.
22
+
23
+ The emote clips (src/emotes/clips.ts, dist/emotes/clips.js) and the emote icons (src/emotes/icons.webp) are
24
+ nano133's own work, made in Blender, and are covered by this licence like the code.
package/README.md CHANGED
@@ -4,13 +4,15 @@ Stage: Pre-alpha. What this means: https://nano133.com/stages
4
4
 
5
5
  Nanoterra for your game, in **one package**. [Nanoterra](https://nano133.com/build/nanoterra) is
6
6
  nano133.com's island. A player who is signed in there presses Play, and arrives at your game as
7
- themselves. This package gives your game four things:
7
+ themselves. This package gives your game these things:
8
8
 
9
9
  - **The sign-in ticket** (`/ticket`): the player's name, Nano address and character, read in the browser. No server, no secret.
10
10
  - **The player's character** (`/character`): the same toon body that the player has on the island, drawn with Three.js. Six bases, faces, hair, clothes, sizes and colours.
11
11
  - **The looks ladder** (`/looks`): the rules of `looks.json`, the price steps, and what a change does to the players who own a look.
12
12
  - **Held input that does not stick** (`/input`, since 0.3.1): lets go of the keys and the stick when the player leaves the page, so the character never walks on by itself, and pauses a free round.
13
- - **A friends list** (`/friends`, unreleased): a Friends key that holds the player's Nanoterra friends (a nano133 window), or a full friends list of the game's own players on the game's own database, as the admin's Game card chooses.
13
+ - **A friends list** (`/friends`, since 0.6.0): a Friends key that holds the player's Nanoterra friends (a nano133 window), or a full friends list of the game's own players on the game's own database, as the admin's Game card chooses.
14
+ - **An options menu** (`/options`, since 0.8.0): the game's settings as data, with defaults for each device, kept in the player's browser, and a ready Options key and panel (Basic and Advanced); common settings to opt into, and a section another module adds.
15
+ - **Emotes** (`/emotes`, since 0.8.0): Nano Island's eleven emotes on the player's character (the Wave, Cheer, Clap, Dance and seven more, made in Blender), and the emote wheel of eight that the player picks, which also plays a game's own animations.
14
16
 
15
17
  It replaces `@nano133/character` 0.1.0, `@nano133/ticket` 0.1.0 and `@nano133/looks` 0.1.0
16
18
  (see "Moving from the three old packages"). Sign-in, checkout and late returns stay in their own packages
@@ -69,12 +71,17 @@ Each path loads only what it needs. A page that reads a ticket loads no Three.js
69
71
  | `@nano133/nanoterra/admin/d1`, `/admin/firestore` | the admin store for D1 (the starter's tables) or Firestore (a small interface, no Firebase dependency): `createD1Store`, `createFirestoreStore` (since 0.2.0) | no | 1.5 kB each |
70
72
  | `@nano133/nanoterra/admin/panel` | the admin page, drawn in the browser into one element, and its stylesheet `@nano133/nanoterra/admin/style.css` (since 0.2.0) | no | 18.9 kB with the tabbed layout (10.6 kB on 0.3.0, 8.5 kB on 0.2.0) + 5.9 kB of CSS (minified) |
71
73
  | `@nano133/nanoterra/admin/conformance` | test cases that a game runs against its own admin (a dev dependency in practice; since 0.2.0) | no | n/a |
72
- | `@nano133/nanoterra/friends` | the game's own friends list: the handler, the rules, a memory store (server code; unreleased) | no | 3.7 kB |
73
- | `@nano133/nanoterra/friends/d1`, `/friends/firestore` | the friends store for D1 (with the migration `/friends/d1.sql`) or Firestore (a small interface, no Firebase dependency): `createD1Friends`, `createFirestoreFriends` (unreleased) | no | 3.0 kB each |
74
- | `@nano133/nanoterra/friends/panel` | the Friends key and panel: the nano133 window, or the game's own list, and its stylesheet `@nano133/nanoterra/friends/style.css` (unreleased) | no | 5.0 kB + 4.8 kB of CSS (minified) |
75
- | `@nano133/nanoterra/friends/conformance` | the friends rules as test cases, for a store a game writes itself (unreleased) | no | n/a |
76
-
77
- Measured 2026-10-26 (the friends paths 2026-10-30): `esbuild --bundle --minify --format=esm --external:three`, then `gzip -9`, without Three.js.
74
+ | `@nano133/nanoterra/friends` | the game's own friends list: the handler, the rules, a memory store (server code; since 0.6.0) | no | 3.7 kB |
75
+ | `@nano133/nanoterra/friends/d1`, `/friends/firestore` | the friends store for D1 (with the migration `/friends/d1.sql`) or Firestore (a small interface, no Firebase dependency): `createD1Friends`, `createFirestoreFriends` (since 0.6.0) | no | 3.0 kB each |
76
+ | `@nano133/nanoterra/friends/panel` | the Friends key and panel: the nano133 window, or the game's own list, and its stylesheet `@nano133/nanoterra/friends/style.css` (since 0.6.0) | no | 5.0 kB + 4.8 kB of CSS (minified) |
77
+ | `@nano133/nanoterra/friends/conformance` | the friends rules as test cases, for a store a game writes itself (since 0.6.0) | no | n/a |
78
+ | `@nano133/nanoterra/options` | the options store (no DOM), the common settings, `reflectOptions` and the panel's `mountOptions` (since 0.8.0) | no | 2.9 kB (`createOptions` alone), 8.7 kB (all) |
79
+ | `@nano133/nanoterra/options/panel` | the Options key and panel alone, and its stylesheet `@nano133/nanoterra/options/style.css` (since 0.8.0) | no | 2.3 kB of CSS (9.4 kB minified) |
80
+ | `@nano133/nanoterra/emotes` | the emotes' list, the wheel's eight places, the multiplayer message and the player's options: no DOM, no Three.js (since 0.8.0) | no | 1.4 kB |
81
+ | `@nano133/nanoterra/emotes/player` | the emotes on the kit's character: `createEmotes`, `playRemote`, `loadEmotes` (since 0.8.0) | yes | 2.9 kB; the clips load on the first emote, a chunk of their own: 84 kB, 18.8 kB gzip |
82
+ | `@nano133/nanoterra/emotes/wheel` | the Emotes key and the emote wheel: `mountEmotes`, and its stylesheet `@nano133/nanoterra/emotes/style.css` (since 0.8.0) | no | 5.9 kB + 7.4 kB of CSS (minified); the pictures (`/emotes/icons.webp`, 34 kB) load when the wheel first opens |
83
+
84
+ Measured 2026-10-26 (the friends paths 2026-10-30, the options and the emotes paths 2026-11-01): `esbuild --bundle --minify --format=esm --external:three`, then `gzip -9`, without Three.js.
78
85
  All four paths together: 23.6 kB. A test of the package proves that the record, the ticket, the looks and the small part import no package at all.
79
86
 
80
87
  ## The character
@@ -485,7 +492,7 @@ import { createAdminHandler } from "@nano133/nanoterra/admin";
485
492
 
486
493
  const admin = createAdminHandler({
487
494
  store, // your database: readSettings(), saveSettings(patch, now), summary(now) (a memory store is in the package, for tests)
488
- defaults: { character: "nanoterra", signin: "exchange", friends: "nanoterra" }, // what the switches are until /admin sets them (your game.config.json; friends may be left out)
495
+ defaults: { character: "nanoterra", signin: "exchange", friends: "nanoterra", emotes: "on" }, // what the switches are until /admin sets them (your game.config.json; friends and emotes may be left out)
489
496
  owner: ownerAddress, // "nano_..." lowercase, its checksum checked by you; null when none is set
490
497
  session: (req, now) => readAdminSession(req, now), // YOUR admin login: its own cookie; return { address, createdAt, via: "wallet" } or null
491
498
  status: (now, summary) => ({ address: ownerAddress, testMode, warnings }), // what only your game knows (the store's numbers come too, for a warning like "2 payments came late")
@@ -511,9 +518,9 @@ neither header (curl) and the browser's own address bar (`Sec-Fetch-Site: none`)
511
518
  **The handler** sends every request under the base path (`/api/admin` by default) through the guard first, before the route is looked up: a visitor who is not the owner
512
519
  gets 401 or 403 for any path, so nothing about the routes leaks. A POST must come from the game's own page (`Origin`, else `Sec-Fetch-Site: same-origin`). It sets no CORS
513
520
  header, always sends `Cache-Control: no-store`, reads at most 512 bytes of a press, and limits presses to 30 a minute. A fault in your store gives a plain 500 that shows
514
- nothing of the fault (`onError` hears it). The only writes are the three settings (and, when the game turns on the sign-in list, the deletion of the admin sessions), **never a look, a price or an order**: a change of looks stays a change of your repository.
521
+ nothing of the fault (`onError` hears it). The only writes are the four settings (and, when the game turns on the sign-in list, the deletion of the admin sessions), **never a look, a price or an order**: a change of looks stays a change of your repository.
515
522
 
516
- **Your own routes read the switches** with `loadGameSettings(store, defaults)` (for the listing file's `signin` word and for the character): the stored word, else your default,
523
+ **Your own routes read the switches** with `loadGameSettings(store, defaults)` (for the listing file's `signin` word, the character, the friends list and the emotes): the stored word, else your default,
517
524
  and your defaults when the store fails.
518
525
 
519
526
  **The conformance suite** proves your whole chain, not only the guard: the cookie you read, the session lookup, the owner you pass.
@@ -559,8 +566,8 @@ admin may make an admin login, and never from another sign-in. A security read f
559
566
 
560
567
  ### The store (an adapter for your database)
561
568
 
562
- `createAdminHandler` needs a store: three calls, `readSettings()`, `saveSettings(patch, now)` and `summary(now)`. Two adapters come with the package, and **both write only the three settings** (the D1 store also deletes the admin sessions, but only when the game turns on the sign-in list: see below)
563
- (`setting:character`, `setting:signin` and `setting:friends`); every other call they make is a read. Both must pass the same **store contract**, `adminStoreConformance(make)` in
569
+ `createAdminHandler` needs a store: three calls, `readSettings()`, `saveSettings(patch, now)` and `summary(now)`. Two adapters come with the package, and **both write only the four settings** (the D1 store also deletes the admin sessions, but only when the game turns on the sign-in list: see below)
570
+ (`setting:character`, `setting:signin`, `setting:friends` and `setting:emotes`); every other call they make is a read. Both must pass the same **store contract**, `adminStoreConformance(make)` in
564
571
  `@nano133/nanoterra/admin/conformance`: the settings are kept and accumulate, saving changes no order, no owned look and no other row, an open order past its expiry counts as expired,
565
572
  the money is totalled right, the newest 500 orders come first, and a bad rate row is ignored. A game with another database writes its own store and runs the same contract.
566
573
 
@@ -622,7 +629,7 @@ status warnings, one banner); four numbers (Earned, Open now, To send back, Play
622
629
  looks with their share of the money, the last five orders, the switches as On or Off (and the friends list's choice) and the shelf in numbers (a game with no order yet gets three first steps).
623
630
  **Cosmetics**: the **Shelf** (below). **Orders**: a filter by status with its count, a search (a look, an address or a block), "Check payments now" when the game gave a
624
631
  check, "Paid late" with the address to send the money back to, 20 orders at a time; a card each on a phone, a table on a wide panel. **Players**: each player who owns a
625
- look, with their looks. **Settings**: the **Game** card (the two switches and the friends list's three-way choice), the Sign-ins card and the facts (the address, the mode, the nodes, the last price). The tabs are
632
+ look, with their looks. **Settings**: the **Game** card (the character and sign-in switches, the friends list's three-way choice and "Allow Nanoterra emotes"), the Sign-ins card and the facts (the address, the mode, the nodes, the last price). The tabs are
626
633
  real tabs (the arrow keys, Home and End work); every tab is made once, so a draft, a search and a switch keep their state, and the panel never writes the page's address.
627
634
  A visitor who is signed out gets the game's sign-in; a wrong wallet, a Nanoterra sign-in and a missing owner address each get their own screen and see no data (a sign-out
628
635
  takes the drawn data off the page). Options: `api` (the handler's base path), `noAddressHelp`, `lookPicture(look)` (your own picture of a look for the Shelf), `repoHelp`,
@@ -665,7 +672,7 @@ width (a container query), not the window's: on a phone two cards a row and the
665
672
 
666
673
  The wallet sign-in stays yours (`signIn`), because only your game can start a request in the ADMIN scope with its own cookie. The panel never signs anyone in.
667
674
 
668
- **Light and dark** (unreleased). The top bar has a theme key (44 px, a half circle, a sun or a moon; its name says the choice: "Theme: Dark", "Theme: Device (dark
675
+ **Light and dark** (since 0.7.0). The top bar has a theme key (44 px, a half circle, a sun or a moon; its name says the choice: "Theme: Dark", "Theme: Device (dark
669
676
  now)"). A press opens a menu of three: **Device** (the default: it follows the device's setting, `prefers-color-scheme`, and a live change of it), **Light** and **Dark**.
670
677
  The signed-out screen has the same key. The choice is kept in this browser only, in `localStorage` under `n133-admin-theme`: no server call and no admin setting. A
671
678
  browser that refuses storage (some private windows) reads Device, and a choice there holds for the page.
@@ -696,7 +703,7 @@ html[data-n133-theme="dark"] body.admin-page {
696
703
  The pictures: a look's flat picture and the character previews (`createLookPreview` draws with a clear background) stand on the `stage` colour, which is a mid grey
697
704
  blue in dark, so a dark outfit and the outlines still show. The panel draws no QR code.
698
705
 
699
- ## The friends list (`/friends`; unreleased)
706
+ ## The friends list (`/friends`; since 0.6.0)
700
707
 
701
708
  A Friends key for the game, and a list behind it. The admin's **Game** card chooses which list (`friends` in the settings; Nanoterra by default):
702
709
 
@@ -819,6 +826,194 @@ no live connection: only Nanoterra's do.
819
826
  **Questions.** Remove, Block and (in both modes) Join ask the player first, in the panel itself: the question and a key for each answer; Escape or closing the
820
827
  panel answers no. The kit never opens a browser dialog. To ask in your game's own dialog, pass `confirm(question)`, which returns a boolean or a promise of one.
821
828
 
829
+ ## The options menu (`/options`; since 0.8.0)
830
+
831
+ A game's settings as data, kept in the player's browser, and a ready Options key and panel. A game declares its settings; the kit resolves their defaults for
832
+ the device, keeps only what the player changed, and draws them in two tabs, **Basic** and **Advanced**. A game with its own menu uses the store alone.
833
+
834
+ ```ts
835
+ import { common, createOptions, keyBinding, mountOptions, reflectOptions } from "@nano133/nanoterra/options";
836
+ import "@nano133/nanoterra/options/style.css";
837
+
838
+ const options = createOptions({
839
+ game: "my-game", // the storage key is n133-options:my-game
840
+ defs: [
841
+ ...common("masterVolume", "musicVolume", "effectsVolume", "cameraSensitivity", "invertLook", "stickSide", "reducedMotion", "quality"),
842
+ { kind: "toggle", key: "hints", label: "Show hints", help: "Arrows to the next gem", tab: "basic", group: "Game", default: true },
843
+ keyBinding("jump", "Jump", "Space"), // keys.jump: a KeyboardEvent.code
844
+ ],
845
+ });
846
+ music.volume = (options.get<number>("musicVolume") / 100) ** 2;
847
+ options.subscribe((key, value) => apply(key, value)); // every change: the panel, the game, a reset, a default that moved
848
+ reflectOptions(options, ["reducedMotion"]); // <html data-n133-opt-reduced-motion="true|false"> for your CSS
849
+ const panel = mountOptions(document.getElementById("options")!, options); // the gear key and the panel
850
+ // panel.open(), panel.close(), panel.isOpen, panel.destroy()
851
+ ```
852
+
853
+ **The defs.** Each has a `key`, a `label` (short: the player reads it), an optional `help` line, a `tab` (`"basic"` or `"advanced"`), an optional `group` heading, a
854
+ `default`, and optionally `devices` (show the row only on `"phone"`, `"tablet"` or `"desktop"`; the value is kept everywhere).
855
+
856
+ | `kind` | Value | Drawn as | Its own fields |
857
+ |---|---|---|---|
858
+ | `"toggle"` | `true` or `false` | a switch: the whole row is the key | |
859
+ | `"choice"` | one of `choices[].value` (text or a number) | a row of keys (at most 4 labels of 10 characters), else a drop-down | `choices: [{ value, label }]` (two or more), `display?: "buttons" \| "list"` |
860
+ | `"range"` | a number on a step from `min` to `max` | a slider, its value and unit beside the label | `min`, `max`, `step`, `unit?` (put after the number as written: `"%"`, `" s"`) |
861
+ | `"key"` | a `KeyboardEvent.code` (`"KeyE"`, `"Space"`): the key's place, the same on every layout | a key cap; pressed, it waits for the next key (Escape keeps the old one) | |
862
+
863
+ A bad def (an empty label, a default that does not fit, a choice with one value) and a key that is there twice throw a `TypeError` or an `Error` when the store is
864
+ made or `add` is called, with what is wrong; nothing of that list is added.
865
+
866
+ **Defaults for each device.** A default is one value, or one for each device class: `{ phone?, tablet?, desktop?, reducedMotion?, default }`. The class: a coarse
867
+ pointer (a finger) with a screen whose short side is under 600 px is a **phone**, any other coarse pointer a **tablet**, anything else a **desktop**
868
+ (`detectDevice`; pass `device: () => "phone"` to choose it yourself). `reducedMotion` wins while the device asks for less motion
869
+ (`prefers-reduced-motion: reduce`), and the kit follows that setting **live**: a player who never set the option follows the device when it changes.
870
+ `store.redetect()` reads both again (a game that passes its own `reducedMotion` function calls it).
871
+
872
+ **What the browser keeps.** One `localStorage` key, `n133-options:<game>`, holds **only the values that differ from this device's default**, so a better default you
873
+ ship later reaches every player who never changed that setting. A kept value that no longer fits (the wrong type, out of range, a choice you removed) is dropped
874
+ when it is read, and so is a key that no def has, unless it has a dot: a module's section (`emotes.show`) may be added later, so its kept values wait for it. A
875
+ browser that refuses storage (some private windows) keeps the values for the page. `migrate(stored, storage)` (optional) changes what was kept before it is read:
876
+ a renamed key, or a game's old save under another key (read it when `stored` is empty, then remove it).
877
+
878
+ **The store** (`createOptions` returns it; no DOM): `get(key)` (throws for an unknown key), `set(key, value)` (a range's number goes to its nearest step inside min
879
+ and max; a wrong type or an unknown choice throws a `TypeError`), `reset(key?)`, `values()`, `defs()`, `def(key)`, `defaultOf(key)`, `isDefault(key?)`,
880
+ `subscribe(fn(key, value))` (returns the call that stops it), `subscribeDefs(fn)`, `add(defs)`, `redetect()`, `game` and `device`. **Two key bindings never share
881
+ a key:** setting one to a key that another has gives the other this one's old key, and both changes are heard.
882
+
883
+ **The common settings** (`common(...keys)` gives copies; `COMMON_OPTIONS` holds them, to spread and change: `{ ...COMMON_OPTIONS.quality, default: "low" }`). They are
884
+ plain defs: your game does what they say.
885
+
886
+ | Key | Label | Tab, group | Kind | Default |
887
+ |---|---|---|---|---|
888
+ | `masterVolume` | Volume | Basic, Sound | range 0 to 100, step 5, `%` | 100 |
889
+ | `musicVolume` | Music | Basic, Sound | range 0 to 100, step 5, `%` | 100 |
890
+ | `effectsVolume` | Sound effects | Basic, Sound | range 0 to 100, step 5, `%` | 100 |
891
+ | `cameraSensitivity` | Camera speed | Basic, Camera | range 10 to 100, step 5 | 50 (the game's own speed) |
892
+ | `stickSide` | Move stick | Basic, Controls (a phone and a tablet only) | choice `"left"`, `"right"` | `"left"` |
893
+ | `invertLook` | Invert look | Advanced, Camera | toggle | off |
894
+ | `reducedMotion` | Less motion | Advanced, Comfort | toggle | the device's setting |
895
+ | `quality` | Quality | Advanced, Graphics | choice `"low"`, `"medium"`, `"high"` | `"medium"` on a phone, else `"high"` |
896
+ | `showFps` | Show frame rate | Advanced, Graphics | toggle | off |
897
+
898
+ A volume's square is a fair loudness curve (`(v / 100) ** 2`: Echo Island's). `keyBinding(action, label, code)` makes a `"key"` def `keys.<action>` in Advanced,
899
+ under "Keys", on a desktop only.
900
+
901
+ **A section from another module.** A kit module (the emotes) or a part of your game adds its own settings later, beside the others:
902
+ `options.add(optionSection("emotes", defs, "Emotes"))` puts `emotes.` before each key and the group "Emotes" on each def that has none. Its keys are namespaced
903
+ (`emotes.show`), a key that is already there throws at once, a value kept before the section came is read when it comes, and a mounted panel draws the new rows.
904
+
905
+ **The panel.** `mountOptions(el, store, { title?, key?, onOpen?, onClose?, onRangeDone?, confirm? })` puts a 44 px key (a gear and "Options") in `el`, and a panel: a
906
+ sheet from the bottom on a phone held upright, from the right on a wide or sideways screen, over a scrim (a press on it closes the panel). The two tabs are real tabs
907
+ (the arrow keys, Home and End; one tab alone has no tab bar); each tab draws its rows without a group first, then each group under its heading. **A change applies
908
+ at once** (there is no Save key), and a change from elsewhere shows in place: the rows are drawn once, a slider under a finger is never drawn again, and the panel
909
+ never scrolls back to the top. **Reset to defaults** asks in the panel ("Put every option back to its default?", Reset and Cancel), never in a browser dialog;
910
+ Escape answers the question first, then closes the panel. `key: false` draws no key, for a game that puts "Options" in its own pause menu and calls `open()`.
911
+ **When it closes, the focus leaves the key and the panel** (to the page), so Space and the game's other keys reach the game again: a focused key would take
912
+ Space and open the panel again. The friends panel and the emote wheel do the same. `focusAfterClose: "key"` gives the focus back to the key (or, with no
913
+ key, to what had it before), for a page with no keys of its own. `onOpen` and `onClose` let a game pause under the panel; `onRangeDone(key, value)` hears a slider let go (play a sound at the new
914
+ level). The panel holds the focus while it is open. Every key, switch, choice and slider is 44 px or more, and a row's label is 16 px.
915
+
916
+ **Light and dark.** The panel follows `data-n133-theme="light" | "dark"` on the page's `<html>` when the page sets it (the admin panel does), else the device's
917
+ `prefers-color-scheme`. Every colour is a variable: `--n133-ink`, `--n133-ink-soft`, `--n133-paper`, `--n133-ground` (the tab bar and a choice's well),
918
+ `--n133-line`, `--n133-gem`, `--n133-gem-dark`, `--n133-on-gem` set the light theme, and each has a `--n133-dark-*` twin; `--n133-radius`, `--n133-font` and
919
+ `--n133-options-z` (default 1100, above the friends panel) too. The stylesheet is scoped under `.n133-options`.
920
+
921
+ **Less motion in your CSS.** `reflectOptions(store, keys, target?)` keeps each key's value on `<html>` (or `target`) as `data-n133-opt-<key in kebab case>`, and
922
+ returns the call that stops it:
923
+
924
+ ```css
925
+ html[data-n133-opt-reduced-motion="true"] .my-hud * { animation: none !important; transition: none !important; }
926
+ ```
927
+
928
+ ## Emotes (`/emotes`; since 0.8.0)
929
+
930
+ Nano Island's emotes for a game: the same eleven, on the same character, with the same wheel. **The Wave** is the character's own `wave()`; **Cheer, Clap,
931
+ Dance, Bow, Shrug, Laugh, Point, Sit, Facepalm and Spin** are clips made in Blender on the character's own skeleton (the island makes them from this package's
932
+ `BONES`), so they fit every base. The wheel also plays **a game's own animations**: give it your list and an `onPlay`.
933
+
934
+ ```ts
935
+ import { createEmotes, loadEmotes, playRemote } from "@nano133/nanoterra/emotes/player";
936
+ import { emoteMessage, readEmoteMessage } from "@nano133/nanoterra/emotes";
937
+ import { mountEmotes } from "@nano133/nanoterra/emotes/wheel";
938
+ import "@nano133/nanoterra/emotes/style.css";
939
+
940
+ const emotes = createEmotes(hero, { onEmote: (e) => net.send(emoteMessage(e)) }); // hero: createCharacter(...); onEmote only for a multiplayer game
941
+ const wheel = mountEmotes(document.getElementById("emotes")!, {
942
+ game: "my-game", // the wheel's places are kept per game, in this browser
943
+ onPlay: (id) => emotes.play(id), // the player picked one
944
+ onOpen: () => void loadEmotes(), // the clips load when the wheel first opens, not with the game
945
+ nanoterraEmotes: settings.emotes, // the admin's "Allow Nanoterra emotes": "on" (the default) or "off"
946
+ });
947
+ // Each frame: nothing new. hero.update(dt, motion) plays the emote over the walk.
948
+ // Another player's emote, from your network:
949
+ net.on("message", (m, from) => { const id = readEmoteMessage(m); if (id) playRemote(characterOf(from), { id }); });
950
+ ```
951
+
952
+ **The clips.** A clip turns each bone from its parent, 30 frames a second, and the hips' place; the game runs no IK for it. It plays as a layer over the
953
+ character's pose: `update` solves the walk, then the clip blends over it (a fade in of 0.12 s, a fade out of 0.22 s at its end), then the tail, the wings and the
954
+ long hair follow. The rules are the island's:
955
+
956
+ | Emotes | Kind | What stops it |
957
+ |---|---|---|
958
+ | Cheer, Dance, Bow, Laugh, Sit, Spin | the whole body | a move (the pose's speed over 0.4 m/s), a jump, the water, or `stop()`, with a fade of 0.18 s |
959
+ | Clap, Shrug, Point, Facepalm | the spine, the arms and the head: they play over the walk | their end, the water, or `stop()` |
960
+ | Dance, and Sit (its sitting down plays once, then its seated part) | loops | as above: a loop plays until the player moves |
961
+ | The Wave | the character's own wave (1.8 s) | its end |
962
+
963
+ With `reducedMotion` on the character, an emote shows its one picture (the clip's hold frame) for its time. Point never raises a straight arm: the hand stays
964
+ below the shoulder with the elbow bent (Wes's rule on the island). A clip's time is the sum of the steps `update` took, so a paused game pauses its emotes.
965
+ The kit's pose has no swim: a game with water calls `emotes.stop()` (or `stopEmote(character)`) when a character goes in.
966
+
967
+ **Loaded only when needed.** The clips (84 kB, 18.8 kB gzip) are in a module that only `import()` reaches (`loadEmotes`), so a bundler puts them in a chunk of
968
+ their own; `onOpen` loads them when the wheel first opens, and a clip that plays before they arrive (another player's) loads them and starts when they come. The
969
+ pictures (`icons.webp`, one strip of eleven 128 px squares, 34 kB) are found with `new URL("…", import.meta.url)` and load when the wheel first shows them. Checked
970
+ on Vite 8 (`vite build`: a `clips-<hash>.js` chunk and an `icons-<hash>.webp` file, neither fetched before the wheel opens; `vite` dev with the package
971
+ pre-bundled: the same) and in plain ESM with an import map. A bundler that does not follow `new URL(…, import.meta.url)` can pass the picture's address itself
972
+ (`icons`), e.g. Vite's `import icons from "@nano133/nanoterra/emotes/icons.webp?url"`.
973
+
974
+ | Call (`/emotes/player`) | What it does |
975
+ |---|---|
976
+ | `createEmotes(character, { onEmote?, onStop? })` | The local player's emotes. `play(id)` plays a Nanoterra emote (false, and nothing happens, for another id); `stop()` stops the clip with its fade; `playing` is the clip on show or null; `destroy()` stops the events. |
977
+ | `onEmote({ id, loop, at, again })` | The player played an emote; while a loop plays, it fires again every 10 s (`LOOP_AGAIN_MS`) with `again: true`, so a player who comes later sees it. Send `emoteMessage(e)`, which is `{ emote: id }`. |
978
+ | `onStop({ id, at, why })` | The player's clip ended: `"end"`, `"move"` (a move, a jump or the water) or `"stop"`. Nothing to send for a move: the others see the character move, and that ends it on their screens too. Send a stop only for `"stop"`, and call `stopEmote` on their side. |
979
+ | `playRemote(character, { id, at? }, { options? })` | Another player's emote on their character. An id that is not one of the eleven is ignored (false). The same play twice (the same `at`) is one, so you may pass the last one each frame; a loop that is on show goes on through a repeat. `at` is when it came (`performance.now()`; left out: now). |
980
+ | `stopEmote(character)` | Stops any character's clip, with its fade. |
981
+ | `loadEmotes()`, `emotesLoaded()` | Loads the clips (once; a failed load may be tried again), and says whether they are here. |
982
+
983
+ **The wheel** (`/emotes/wheel`) is nano133's island wheel in plain DOM. `mountEmotes(element, options)` puts an **Emotes** key (44 px) in `element`; a press opens the
984
+ wheel in the middle of the screen over a dim page: eight round places (58 px or more), slot 1 at the top and on clockwise, each the emote's picture and name, and a
985
+ middle disc with the name of the place under the finger and **Edit**.
986
+
987
+ - A tap on the key opens it and a tap on a place plays it; or a **hold on the key and a slide** onto a place and a let-go plays that one (a slide under 14 px is a tap; a
988
+ let-go in the middle leaves the wheel open). A press beside the wheel or Escape closes it.
989
+ - On a keyboard **B** opens and closes it (not while a field has the keys, or when your `keyAllowed(e)` says no: B bound to an action, a dialog of your own) and **1 to
990
+ 8** play its places while it is open; the places show their numbers only where there is a mouse.
991
+ - **Edit** shows every emote of the list (it may hold more than eight; it scrolls): a tap puts one on the wheel in its first empty place or takes it off; a ninth says
992
+ "Your wheel is full. Take one off first."; a tap on a place takes its emote off. The places are kept in this browser per game (`n133-emotes:<game>`); a browser
993
+ that refuses storage keeps them for the page. Nanoterra's first wheel is the island's: Wave, Cheer, Clap, Dance, Laugh, Point, Sit, Spin.
994
+ - `emotes`: the list, Nanoterra ids (`"dance"`) and your own (`{ id, label, icon? }`, `icon` a square picture's address) in the order Edit shows them; default
995
+ Nanoterra's eleven. `defaults`: your first wheel. `nanoterraEmotes: "off"` (the admin's switch) leaves Nanoterra's out and keeps yours; with nothing left
996
+ there is no key. `keys: false` turns B and 1 to 8 off. `theme`: `"light"`, `"dark"` or `"device"` (the default: the page's `data-n133-theme`, which the admin
997
+ panel sets too, else `prefers-color-scheme`). It returns `open()`, `close()`, `isOpen`, `slots` and `destroy()`.
998
+ - Theme it with the same `--n133-*` properties as the admin panel (and their `--n133-dark-*` twins), and `--n133-emotes-z` for its z-index (default 1000).
999
+
1000
+ The clips and the icons are nano133's own work, made in Blender for Nano Island, and are part of the package under its MIT licence.
1001
+
1002
+ **The admin's switch.** The Game card has **"Allow Nanoterra emotes"** (`emotes` in the settings: `"on"`, the default, or `"off"`). Read it with
1003
+ `loadGameSettings` and pass it to the wheel as `nanoterraEmotes`. When it is off, also do not call `playRemote` and do not send the player's emotes: the game shows no
1004
+ Nanoterra emote. Your own emotes may still use the wheel.
1005
+
1006
+ **The player's options.** For the options menu (`/options`), `/emotes` exports three settings: `emotes.show` "Show the Emotes key" (Basic, on),
1007
+ `emotes.others` "Show other players' emotes" (Advanced, on) and the wheel's key, `keys.emotes` "Emotes wheel" (Advanced, under Keys, B, a desktop only: what
1008
+ `keyBinding("emotes", "Emotes wheel", "KeyB")` makes). `EMOTE_OPTIONS` holds all three with their whole keys, for `createOptions({ defs: [...yours, ...EMOTE_OPTIONS] })`
1009
+ or `store.add(EMOTE_OPTIONS)`; `EMOTE_OPTION_DEFS` holds the two toggles with short keys, for `store.add(optionSection("emotes", EMOTE_OPTION_DEFS, "Emotes"))`, and
1010
+ `EMOTE_KEY_OPTION` the key. Pass the store as `options` to `mountEmotes` (the key hides, live; B, or the player's own key, still opens the wheel) and to `playRemote`
1011
+ (the others' emotes do not play). With no store, both toggles are on and the key is B. `/emotes` imports only the options module's types, none of its code.
1012
+
1013
+ **The message on the wire** is yours to carry: one small object, `{ emote: id }`. nano133.com's island is one example, not a rule: its room server takes
1014
+ `{ t: "react", k: "dance" }` from a player, checks the id against its own list and sends the others `{ t: "react", id: <player>, k: "dance" }`; an old server that
1015
+ does not know an id drops it, and nothing breaks.
1016
+
822
1017
  ## Moving from the three old packages
823
1018
 
824
1019
  | Before | Now |
@@ -837,7 +1032,7 @@ panel answers no. The kit never opens a browser dialog. To ask in your game's ow
837
1032
  - The island's item art (see "Worn things and sockets"): a game brings its own things.
838
1033
  - A pose for a held thing: the arms swing free, so a two-handed thing (a `grip_L` as well as a `grip_R`) is held in the right hand only.
839
1034
  - The katana, the saya, the headband and the dojo look of the island's Ananos game. (The skeleton keeps their bones, with no vertices.)
840
- - First-person arms, swimming, climbing, sitting and the room's other moves: only idle, walk, run, jump and wave.
1035
+ - First-person arms, swimming, climbing and the room's other moves: only idle, walk, run, jump and wave, and the emotes (`/emotes`, which sit too).
841
1036
  - Shared geometry for many characters at once, and a react-three-fiber wrapper.
842
1037
  - `WebGPURenderer`: the ink is a GLSL `ShaderMaterial`, so use `WebGLRenderer`.
843
1038
  - Any server or Nano code. This package draws a character and reads a ticket; it moves no money.
package/ai/SPEC.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Package: `@nano133/nanoterra` (0.1.0 or later). Follow these steps in order. Each step ends with a check. Ask the owner when a step says
4
4
  "ask". The package has five parts: the sign-in ticket (`/ticket`), the player's character (`/character`, Three.js), the
5
- looks ladder (`/looks`), held input that does not stick (`/input`) and the friends list (`/friends`, with its panel). Use only the parts the game needs. It has no network, wallet or Nano code. Read the package README first.
5
+ looks ladder (`/looks`), held input that does not stick (`/input`), the friends list (`/friends`, with its panel), the options menu (`/options`) and the emotes (`/emotes`, with the wheel). Use only the parts the game needs. It has no network, wallet or Nano code. Read the package README first.
6
6
 
7
7
  ## 0. What the owner decides (ask)
8
8
 
@@ -14,6 +14,8 @@ looks ladder (`/looks`), held input that does not stick (`/input`) and the frien
14
14
  4. How the game moves the character: speed in metres a second, gravity and jump. The package animates; it does not move the object.
15
15
  5. Does the game sell looks? If yes, the game is the seller (its own checkout); see step 7.
16
16
  6. The friends list (step 9): Nanoterra's (the default), the game's own, or none. It is the admin's `friends` setting; ask which one the game starts with.
17
+ 7. The options menu (step 10): which settings the game can really honour (a game with no music offers no Music slider), and whether the game opens it from its own
18
+ pause menu or shows the kit's gear key.
17
19
 
18
20
  **Check:** the owner has named the server (yes or no), the source of the character and the quality.
19
21
 
@@ -139,7 +141,54 @@ import "@nano133/nanoterra/friends/style.css";
139
141
  in two browsers: one adds the other by name, the other accepts, both show online with their place, an invite shows, a block hides the blocker from the other. With
140
142
  `"off"`, no key.
141
143
 
142
- ## 10. Do not
144
+ ## 10. The options menu (any game with settings)
145
+
146
+ ```ts
147
+ import { common, createOptions, keyBinding, mountOptions, reflectOptions } from "@nano133/nanoterra/options";
148
+ import "@nano133/nanoterra/options/style.css";
149
+ ```
150
+
151
+ - Make ONE store when the game starts: `createOptions({ game, defs })`, with `game` the game's id. Take the common settings the game honours by key
152
+ (`common("masterVolume", "musicVolume", ...)`) and add the game's own defs (`kind` `"toggle"`, `"choice"`, `"range"` or `"key"`; a short `label`; `tab`
153
+ `"basic"` for what most players want, `"advanced"` for the rest; a `group`; a `default`, one value or `{ phone?, tablet?, desktop?, reducedMotion?, default }`).
154
+ - Read values with `get(key)` where the game uses them, and apply a change at once in `subscribe((key, value) => ...)`: a volume, the camera speed, the quality.
155
+ Never keep a second copy of a setting in the game's own storage.
156
+ - `mountOptions(el, store)` for the gear key, or `mountOptions(el, store, { key: false })` and `open()` from the game's pause menu. Pause a free round in
157
+ `onOpen` and go on in `onClose`.
158
+ - With `reducedMotion`: honour it in the game's code (no shakes, no camera swings, no flashes) and call `reflectOptions(store, ["reducedMotion"])` so the page's
159
+ CSS can calm its own animations (`html[data-n133-opt-reduced-motion="true"]`).
160
+ - A kit module or a part of the game that comes later adds a section: `store.add(optionSection("name", defs, "Heading"))`. Its keys start with `name.`; never
161
+ use a key twice (the store throws).
162
+ - Do not use a setting for a game's rules, a score or a payment: a setting only changes how the game looks, sounds and is controlled.
163
+
164
+ **Check:** change a setting, reload: it holds; set it back: the browser keeps nothing for it (`localStorage["n133-options:<game>"]` has no such key). On a
165
+ phone held upright the panel is a sheet from the bottom, on a desktop a sheet on the right; the tabs work by touch; Reset asks in the panel.
166
+
167
+ ## 11. Emotes (any game with the kit's character, or its own animations; the admin's `emotes` setting)
168
+
169
+ ```ts
170
+ import { createEmotes, loadEmotes, playRemote } from "@nano133/nanoterra/emotes/player";
171
+ import { emoteMessage, readEmoteMessage, EMOTE_OPTIONS } from "@nano133/nanoterra/emotes";
172
+ import { mountEmotes } from "@nano133/nanoterra/emotes/wheel";
173
+ import "@nano133/nanoterra/emotes/style.css";
174
+ ```
175
+
176
+ - Read `emotes` with `loadGameSettings` (`"on"` by default, or `"off"`) and pass it to the wheel as `nanoterraEmotes`. When it is `"off"`, do not call
177
+ `playRemote` and do not send the player's emotes. Do not read it in a second place.
178
+ - With the kit's character: `const emotes = createEmotes(hero)` once, and `mountEmotes(el, { game, onPlay: (id) => emotes.play(id), onOpen: () => void
179
+ loadEmotes() })`. Change nothing in the frame loop: `hero.update(dt, motion)` plays the emote. Never import the clips yourself: `loadEmotes` loads them.
180
+ - With the game's own animations: `mountEmotes(el, { game, emotes: [{ id, label, icon? }, ...], onPlay })`; Nanoterra's ids may be in the same list.
181
+ - Multiplayer: send `emoteMessage(e)` from `onEmote` (a loop fires again every 10 s: send it again); on the other side, `readEmoteMessage(m)` and
182
+ `playRemote(characterOf(sender), { id })`, which ignores an id that is not an emote. Nothing to send when the player moves: the move ends it everywhere.
183
+ A server that relays emotes checks the id against the list (`EMOTES`) and drops any other.
184
+ - With an options menu (step 10): add `EMOTE_OPTIONS` to the store and pass the store as `options` to `mountEmotes` and `playRemote`.
185
+ - A game with water calls `emotes.stop()` (or `stopEmote(character)`) when a character goes in: the kit's pose does not swim.
186
+
187
+ **Check:** the network tab shows no `clips` chunk and no `icons` picture before the wheel first opens. On a phone held upright: a tap on Emotes opens the wheel in
188
+ the middle; a hold on the key and a slide onto a place plays it; Edit refuses a ninth with "Your wheel is full. Take one off first."; the wheel holds after a
189
+ reload. A walk ends Dance; Clap goes on over the walk. On a keyboard: B opens it, 2 plays the second place, Escape closes it.
190
+
191
+ ## 12. Do not
143
192
 
144
193
  - Do not edit the geometry, the skeleton or the toon and ink materials.
145
194
  - Do not add the katana, the headband or other brands' art. A game's own worn model goes on a socket (`c.socket("head" | "hand" | "back")` or
@@ -16,6 +16,7 @@ export declare const D1_KEYS: {
16
16
  readonly character: "setting:character";
17
17
  readonly signin: "setting:signin";
18
18
  readonly friends: "setting:friends";
19
+ readonly emotes: "setting:emotes";
19
20
  };
20
21
  /**
21
22
  * The admin store for D1. With `logins: true` it also lists the last admin sign-ins (the table `admin_logins`: at, country, browser) and can sign the owner out everywhere
@@ -1,11 +1,11 @@
1
1
  // The admin store for a game on Cloudflare D1, with the tables of the game starter (migrations/0001_init.sql): `meta`, `orders` and `owned`.
2
- // It writes ONLY the three settings (`setting:character`, `setting:signin`, `setting:friends` in `meta`); everything else it does is a read, except when the game turns the sign-in list on
2
+ // It writes ONLY the four settings (`setting:character`, `setting:signin`, `setting:friends`, `setting:emotes` in `meta`); everything else it does is a read, except when the game turns the sign-in list on
3
3
  // (`logins: true`): then the one other write is `DELETE FROM sessions WHERE scope = 'admin'` ("sign out everywhere").
4
4
  //
5
5
  // import { createD1Store } from "@nano133/nanoterra/admin/d1";
6
6
  // const store = createD1Store(env.DB);
7
7
  import { ORDERS_SHOWN, moneyOf, parseRate, shapeOrders, shapePlayers } from "../summary.js";
8
- export const D1_KEYS = { character: "setting:character", signin: "setting:signin", friends: "setting:friends" };
8
+ export const D1_KEYS = { character: "setting:character", signin: "setting:signin", friends: "setting:friends", emotes: "setting:emotes" };
9
9
  /**
10
10
  * The admin store for D1. With `logins: true` it also lists the last admin sign-ins (the table `admin_logins`: at, country, browser) and can sign the owner out everywhere
11
11
  * (`DELETE FROM sessions WHERE scope = 'admin'`, and `UPDATE signin_requests SET delivered = 1, expires_at = 0 WHERE scope = 'admin' AND delivered = 0` so that a request signed a moment ago
@@ -15,9 +15,9 @@ export const D1_KEYS = { character: "setting:character", signin: "setting:signin
15
15
  export function createD1Store(db, options = {}) {
16
16
  const store = {
17
17
  async readSettings() {
18
- const rows = (await db.prepare("SELECT key, value FROM meta WHERE key IN (?, ?, ?)").bind(D1_KEYS.character, D1_KEYS.signin, D1_KEYS.friends).all()).results;
18
+ const rows = (await db.prepare("SELECT key, value FROM meta WHERE key IN (?, ?, ?, ?)").bind(D1_KEYS.character, D1_KEYS.signin, D1_KEYS.friends, D1_KEYS.emotes).all()).results;
19
19
  const row = (key) => rows.find((r) => r.key === key)?.value;
20
- return { character: row(D1_KEYS.character), signin: row(D1_KEYS.signin), friends: row(D1_KEYS.friends) };
20
+ return { character: row(D1_KEYS.character), signin: row(D1_KEYS.signin), friends: row(D1_KEYS.friends), emotes: row(D1_KEYS.emotes) };
21
21
  },
22
22
  async saveSettings(patch, now) {
23
23
  const write = (key, value) => db.prepare("INSERT INTO meta (key, value, at) VALUES (?, ?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value, at = excluded.at").bind(key, value, now);
@@ -28,6 +28,8 @@ export function createD1Store(db, options = {}) {
28
28
  batch.push(write(D1_KEYS.signin, patch.signin));
29
29
  if (patch.friends)
30
30
  batch.push(write(D1_KEYS.friends, patch.friends));
31
+ if (patch.emotes)
32
+ batch.push(write(D1_KEYS.emotes, patch.emotes));
31
33
  if (batch.length)
32
34
  await db.batch(batch);
33
35
  },
@@ -33,6 +33,7 @@ export declare const FIRESTORE_KEYS: {
33
33
  readonly character: "setting:character";
34
34
  readonly signin: "setting:signin";
35
35
  readonly friends: "setting:friends";
36
+ readonly emotes: "setting:emotes";
36
37
  };
37
38
  export declare function createFirestoreStore(db: FirestoreLike, collections?: {
38
39
  meta?: string;
@@ -1,15 +1,15 @@
1
1
  // The admin store for a game on Firestore (the Admin SDK). The package has no Firebase dependency: the adapter uses the small interface below, which the
2
- // Admin SDK's `Firestore` fits. It writes ONLY the three settings; everything else it does is a read.
2
+ // Admin SDK's `Firestore` fits. It writes ONLY the four settings; everything else it does is a read.
3
3
  //
4
4
  // The data model (the names a game must have; only the three collection names can be changed):
5
- // meta/{key} { value: string, at: number } keys "setting:character", "setting:signin", "setting:friends", "rate" (value is JSON { usd, sources })
5
+ // meta/{key} { value: string, at: number } keys "setting:character", "setting:signin", "setting:friends", "setting:emotes", "rate" (value is JSON { usd, sources })
6
6
  // orders/{id} { player, lookId, usd, amount, status, createdAt, expiresAt, paidAt, payer, hash } `amount` is the raw amount as text
7
7
  // owned/{player__lookId} { player, lookId, orderId, grantedAt }
8
8
  //
9
9
  // The summary reads the newest 5000 orders (the counts and the totals are over those) and every owned look: for a small game. A big game should keep running totals.
10
10
  import { parseRate, summarize } from "../summary.js";
11
11
  export const FIRESTORE_SCAN_LIMIT = 5000;
12
- export const FIRESTORE_KEYS = { character: "setting:character", signin: "setting:signin", friends: "setting:friends" };
12
+ export const FIRESTORE_KEYS = { character: "setting:character", signin: "setting:signin", friends: "setting:friends", emotes: "setting:emotes" };
13
13
  const text = (v, fallback = "") => (typeof v === "string" ? v : fallback);
14
14
  const num = (v, fallback = 0) => (typeof v === "number" && Number.isFinite(v) ? v : fallback);
15
15
  const orNull = (v) => (typeof v === "string" ? v : null);
@@ -23,8 +23,8 @@ export function createFirestoreStore(db, collections = {}) {
23
23
  };
24
24
  return {
25
25
  async readSettings() {
26
- const [character, signin, friends] = await Promise.all([valueOf(FIRESTORE_KEYS.character), valueOf(FIRESTORE_KEYS.signin), valueOf(FIRESTORE_KEYS.friends)]);
27
- return { character: character?.value, signin: signin?.value, friends: friends?.value };
26
+ const [character, signin, friends, emotes] = await Promise.all([valueOf(FIRESTORE_KEYS.character), valueOf(FIRESTORE_KEYS.signin), valueOf(FIRESTORE_KEYS.friends), valueOf(FIRESTORE_KEYS.emotes)]);
27
+ return { character: character?.value, signin: signin?.value, friends: friends?.value, emotes: emotes?.value };
28
28
  },
29
29
  async saveSettings(patch, now) {
30
30
  const batch = db.batch();
@@ -35,6 +35,8 @@ export function createFirestoreStore(db, collections = {}) {
35
35
  (batch.set(db.collection(meta).doc(FIRESTORE_KEYS.signin), { value: patch.signin, at: now }), n++);
36
36
  if (patch.friends)
37
37
  (batch.set(db.collection(meta).doc(FIRESTORE_KEYS.friends), { value: patch.friends, at: now }), n++);
38
+ if (patch.emotes)
39
+ (batch.set(db.collection(meta).doc(FIRESTORE_KEYS.emotes), { value: patch.emotes, at: now }), n++);
38
40
  if (n)
39
41
  await batch.commit();
40
42
  },
@@ -71,7 +71,7 @@ export type StoreHarness = {
71
71
  at: number;
72
72
  };
73
73
  }): Promise<void>;
74
- /** Every row that the store could change by mistake, as one text: the orders, the owned looks and the meta rows that are NOT the three settings. Same rows, same text. */
74
+ /** Every row that the store could change by mistake, as one text: the orders, the owned looks and the meta rows that are NOT the four settings. Same rows, same text. */
75
75
  snapshot(): Promise<string>;
76
76
  /** Only for a store with `recentLogins` and `signOutEverywhere`: put admin sign-ins in the game's database (the store only reads them). */
77
77
  seedLogins?(rows: {