@nano133/nanoterra 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +50 -0
- package/README.md +206 -10
- package/ai/SPEC.md +51 -2
- package/dist/admin/adapters/d1.d.ts +1 -0
- package/dist/admin/adapters/d1.js +6 -4
- package/dist/admin/adapters/firestore.d.ts +1 -0
- package/dist/admin/adapters/firestore.js +7 -5
- package/dist/admin/conformance.d.ts +1 -1
- package/dist/admin/conformance.js +12 -4
- package/dist/admin/handler.d.ts +1 -1
- package/dist/admin/handler.js +2 -2
- package/dist/admin/index.d.ts +2 -2
- package/dist/admin/index.js +1 -1
- package/dist/admin/memory.js +3 -1
- package/dist/admin/panel/gameCard.js +13 -10
- package/dist/admin/panel/icons.d.ts +17 -0
- package/dist/admin/panel/icons.js +3 -0
- package/dist/admin/panel/overview.js +1 -1
- package/dist/admin/store.d.ts +2 -1
- package/dist/admin/switches.d.ts +13 -3
- package/dist/admin/switches.js +25 -6
- package/dist/character/create.js +6 -2
- package/dist/character/layer.d.ts +14 -0
- package/dist/character/layer.js +10 -0
- package/dist/emotes/clip.d.ts +79 -0
- package/dist/emotes/clip.js +165 -0
- package/dist/emotes/clips.d.ts +3 -0
- package/dist/emotes/clips.js +2 -0
- package/dist/emotes/events.d.ts +22 -0
- package/dist/emotes/events.js +19 -0
- package/dist/emotes/icons.d.ts +1 -0
- package/dist/emotes/icons.js +6 -0
- package/dist/emotes/index.d.ts +8 -0
- package/dist/emotes/index.js +11 -0
- package/dist/emotes/list.d.ts +123 -0
- package/dist/emotes/list.js +23 -0
- package/dist/emotes/options.d.ts +16 -0
- package/dist/emotes/options.js +34 -0
- package/dist/emotes/panel/index.d.ts +3 -0
- package/dist/emotes/panel/index.js +9 -0
- package/dist/emotes/panel/mount.d.ts +56 -0
- package/dist/emotes/panel/mount.js +360 -0
- package/dist/emotes/player.d.ts +37 -0
- package/dist/emotes/player.js +101 -0
- package/dist/emotes/wheel.d.ts +42 -0
- package/dist/emotes/wheel.js +110 -0
- package/dist/friends/panel/mount.d.ts +3 -3
- package/dist/friends/panel/mount.js +46 -12
- package/dist/options/common.d.ts +88 -0
- package/dist/options/common.js +59 -0
- package/dist/options/core.d.ts +130 -0
- package/dist/options/core.js +420 -0
- package/dist/options/index.d.ts +7 -0
- package/dist/options/index.js +24 -0
- package/dist/options/panel/index.d.ts +2 -0
- package/dist/options/panel/index.js +10 -0
- package/dist/options/panel/mount.d.ts +30 -0
- package/dist/options/panel/mount.js +378 -0
- package/dist/options/reflect.d.ts +5 -0
- package/dist/options/reflect.js +27 -0
- package/package.json +12 -4
- package/src/admin/adapters/d1.ts +5 -4
- package/src/admin/adapters/firestore.ts +6 -5
- package/src/admin/conformance.ts +14 -5
- package/src/admin/handler.ts +2 -2
- package/src/admin/index.ts +2 -2
- package/src/admin/memory.ts +2 -1
- package/src/admin/panel/gameCard.ts +13 -9
- package/src/admin/panel/icons.ts +3 -0
- package/src/admin/panel/overview.ts +1 -1
- package/src/admin/store.ts +2 -2
- package/src/admin/switches.ts +29 -12
- package/src/character/create.ts +6 -2
- package/src/character/layer.ts +21 -0
- package/src/emotes/clip.ts +204 -0
- package/src/emotes/clips.ts +7 -0
- package/src/emotes/events.ts +28 -0
- package/src/emotes/icons.ts +7 -0
- package/src/emotes/icons.webp +0 -0
- package/src/emotes/index.ts +16 -0
- package/src/emotes/list.ts +33 -0
- package/src/emotes/options.ts +43 -0
- package/src/emotes/panel/index.ts +11 -0
- package/src/emotes/panel/mount.ts +390 -0
- package/src/emotes/panel/style.css +415 -0
- package/src/emotes/player.ts +131 -0
- package/src/emotes/wheel.ts +131 -0
- package/src/friends/panel/mount.ts +47 -13
- package/src/friends/panel/style.css +26 -0
- package/src/options/common.ts +65 -0
- package/src/options/core.ts +465 -0
- package/src/options/index.ts +41 -0
- package/src/options/panel/index.ts +12 -0
- package/src/options/panel/mount.ts +418 -0
- package/src/options/panel/style.css +528 -0
- package/src/options/reflect.ts +28 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,55 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.8.0 (2026-11-01)
|
|
4
|
+
|
|
5
|
+
- **An options menu for a game** (`@nano133/nanoterra/options`, `/options/panel` and `/options/style.css`). Wes: "build options menu and emotes in kit"
|
|
6
|
+
(2026-11-01).
|
|
7
|
+
- **The store** (`createOptions({ game, defs })`, no DOM): a game declares its settings as data, of four kinds: `"toggle"`, `"choice"`, `"range"` and `"key"`
|
|
8
|
+
(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
|
|
9
|
+
(`"basic"` or `"advanced"`), an optional group and a default, one value or one for each device class `{ phone?, tablet?, desktop?, reducedMotion?, default }`
|
|
10
|
+
(`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
|
|
11
|
+
setting is followed live until the player sets the option. Only the values that differ from the default are kept, in `localStorage` under
|
|
12
|
+
`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`,
|
|
13
|
+
`reset`, `values`, `defs`, `def`, `defaultOf`, `isDefault`, `subscribe`, `subscribeDefs`, `add` (a module's section, keys like `emotes.show`; a key used twice
|
|
14
|
+
throws) and `redetect`.
|
|
15
|
+
- **The common settings** a game opts into by key (`common(...)`, `COMMON_OPTIONS`): `masterVolume`, `musicVolume`, `effectsVolume`, `cameraSensitivity`,
|
|
16
|
+
`invertLook`, `stickSide` (a phone and a tablet only), `reducedMotion`, `quality` and `showFps`. `keyBinding(action, label, code)` and `optionSection(name, defs,
|
|
17
|
+
group?)` make defs.
|
|
18
|
+
- **The panel** (`mountOptions(el, store, { title?, key?, onOpen?, onClose?, onRangeDone?, confirm? })`): a gear key and a sheet (from the bottom on a phone held
|
|
19
|
+
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
|
|
20
|
+
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
|
|
21
|
+
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
|
|
22
|
+
`data-n133-theme` on `<html>`, else `prefers-color-scheme`; every colour is a `--n133-*` variable with a `--n133-dark-*` twin.
|
|
23
|
+
- `reflectOptions(store, ["reducedMotion"])` keeps `data-n133-opt-reduced-motion` on `<html>` for the page's CSS.
|
|
24
|
+
- 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
|
|
25
|
+
a slider is let go).
|
|
26
|
+
- The admin panel's icons have a `gear`.
|
|
27
|
+
|
|
28
|
+
- **Emotes for a game** (`@nano133/nanoterra/emotes`, `/emotes/player`, `/emotes/wheel`, `/emotes/style.css` and `/emotes/icons.webp`). Wes: "build options
|
|
29
|
+
menu and emotes in kit" (2026-11-01). Nano Island's eleven emotes, the same as nano133.com's (2026-10-30):
|
|
30
|
+
- **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
|
|
31
|
+
island's Blender clips, made on this package's skeleton. `createEmotes(character, { onEmote, onStop })` gives `play(id)`, `stop()` and `playing`. A clip is a
|
|
32
|
+
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
|
|
33
|
+
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
|
|
34
|
+
(Clap, Shrug, Point, Facepalm) plays over the walk; Dance and Sit loop until a move; reduced motion holds one frame. `playRemote(character, { id, at? })`
|
|
35
|
+
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
|
|
36
|
+
(`loadEmotes`, a dynamic `import()`, so a chunk of their own).
|
|
37
|
+
- **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
|
|
38
|
+
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
|
|
39
|
+
(`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.
|
|
40
|
+
- **Multiplayer:** `onEmote({ id, loop, at, again })` (a loop again every 10 s, `LOOP_AGAIN_MS`), `onStop({ id, at, why })`, and the message
|
|
41
|
+
`emoteMessage(e)` = `{ emote: id }`, read back with `readEmoteMessage`. The kit has no network.
|
|
42
|
+
- **Options:** `EMOTE_OPTIONS` for `/options`: "Show the Emotes key", "Show other players' emotes" and the wheel's key (`keys.emotes`, B).
|
|
43
|
+
- **Licence:** the clips and the icons picture are part of the package and under its MIT licence (Wes, 2026-11-01).
|
|
44
|
+
- **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
|
|
45
|
+
(a fourth setting; the store contract has a case for it). `GameState.emotes`, `DEFAULT_EMOTES`, `isEmotes`; the Overview shows it.
|
|
46
|
+
|
|
47
|
+
## 0.7.1 (2026-10-31)
|
|
48
|
+
|
|
49
|
+
- **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
|
|
50
|
+
each answer show at the top of the panel, which opens for it. Escape or closing the panel answers no. The `confirm` option stays, and may return a promise.
|
|
51
|
+
- **Friends, "own": no Invite key without `onJoin`.** A game with no way to join a friend gave an invite that led nowhere.
|
|
52
|
+
|
|
3
53
|
## 0.7.0 (2026-10-30)
|
|
4
54
|
|
|
5
55
|
- **The admin panel has a light and a dark theme** (`/admin/panel`, `/admin/style.css`). A theme key in the top bar (and on the signed-out screen) opens Device,
|
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
|
|
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
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.
|
|
14
|
+
- **An options menu** (`/options`, unreleased): 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`, unreleased): 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
|
|
@@ -73,8 +75,13 @@ Each path loads only what it needs. A page that reads a ticket loads no Three.js
|
|
|
73
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` (unreleased) | no | 3.0 kB each |
|
|
74
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` (unreleased) | no | 5.0 kB + 4.8 kB of CSS (minified) |
|
|
75
77
|
| `@nano133/nanoterra/friends/conformance` | the friends rules as test cases, for a store a game writes itself (unreleased) | no | n/a |
|
|
78
|
+
| `@nano133/nanoterra/options` | the options store (no DOM), the common settings, `reflectOptions` and the panel's `mountOptions` (unreleased) | 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` (unreleased) | 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 (unreleased) | no | 1.4 kB |
|
|
81
|
+
| `@nano133/nanoterra/emotes/player` | the emotes on the kit's character: `createEmotes`, `playRemote`, `loadEmotes` (unreleased) | 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` (unreleased) | no | 5.9 kB + 7.4 kB of CSS (minified); the pictures (`/emotes/icons.webp`, 34 kB) load when the wheel first opens |
|
|
76
83
|
|
|
77
|
-
Measured 2026-10-26 (the friends paths 2026-10-30): `esbuild --bundle --minify --format=esm --external:three`, then `gzip -9`, without Three.js.
|
|
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
|
|
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
|
|
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
|
|
563
|
-
(`setting:character`, `setting:signin` and `setting:
|
|
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
|
|
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`,
|
|
@@ -812,10 +819,199 @@ for (const c of friendsStoreConformance(async () => ({ store: newEmptyStore(), d
|
|
|
812
819
|
```
|
|
813
820
|
|
|
814
821
|
**The panel in `"own"` mode** reads the view at once, every 20 s while open and every 60 s while closed (for the badge), pulses every 45 s while it has a `where`, and
|
|
815
|
-
sends a leaving pulse when the page goes away. It draws Requests (Accept, Decline), Friends (online and the place, Invite to your `where
|
|
822
|
+
sends a leaving pulse when the page goes away. It draws Requests (Accept, Decline), Friends (online and the place, Invite to your `where` and Join an invite, both only when you
|
|
816
823
|
give `onJoin`, Remove, Block), Sent (Cancel) and Blocked (Unblock), an add-by-name field and the "show friends when I am online" choice. The game's own friends have
|
|
817
824
|
no live connection: only Nanoterra's do.
|
|
818
825
|
|
|
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
|
|
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.
|
|
828
|
+
|
|
829
|
+
## The options menu (`/options`; unreleased)
|
|
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
|
+
the focus goes back where it was. `onOpen` and `onClose` let a game pause under the panel; `onRangeDone(key, value)` hears a slider let go (play a sound at the new
|
|
912
|
+
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.
|
|
913
|
+
|
|
914
|
+
**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
|
|
915
|
+
`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),
|
|
916
|
+
`--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
|
|
917
|
+
`--n133-options-z` (default 1100, above the friends panel) too. The stylesheet is scoped under `.n133-options`.
|
|
918
|
+
|
|
919
|
+
**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
|
|
920
|
+
returns the call that stops it:
|
|
921
|
+
|
|
922
|
+
```css
|
|
923
|
+
html[data-n133-opt-reduced-motion="true"] .my-hud * { animation: none !important; transition: none !important; }
|
|
924
|
+
```
|
|
925
|
+
|
|
926
|
+
## Emotes (`/emotes`; unreleased)
|
|
927
|
+
|
|
928
|
+
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,
|
|
929
|
+
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
|
|
930
|
+
`BONES`), so they fit every base. The wheel also plays **a game's own animations**: give it your list and an `onPlay`.
|
|
931
|
+
|
|
932
|
+
```ts
|
|
933
|
+
import { createEmotes, loadEmotes, playRemote } from "@nano133/nanoterra/emotes/player";
|
|
934
|
+
import { emoteMessage, readEmoteMessage } from "@nano133/nanoterra/emotes";
|
|
935
|
+
import { mountEmotes } from "@nano133/nanoterra/emotes/wheel";
|
|
936
|
+
import "@nano133/nanoterra/emotes/style.css";
|
|
937
|
+
|
|
938
|
+
const emotes = createEmotes(hero, { onEmote: (e) => net.send(emoteMessage(e)) }); // hero: createCharacter(...); onEmote only for a multiplayer game
|
|
939
|
+
const wheel = mountEmotes(document.getElementById("emotes")!, {
|
|
940
|
+
game: "my-game", // the wheel's places are kept per game, in this browser
|
|
941
|
+
onPlay: (id) => emotes.play(id), // the player picked one
|
|
942
|
+
onOpen: () => void loadEmotes(), // the clips load when the wheel first opens, not with the game
|
|
943
|
+
nanoterraEmotes: settings.emotes, // the admin's "Allow Nanoterra emotes": "on" (the default) or "off"
|
|
944
|
+
});
|
|
945
|
+
// Each frame: nothing new. hero.update(dt, motion) plays the emote over the walk.
|
|
946
|
+
// Another player's emote, from your network:
|
|
947
|
+
net.on("message", (m, from) => { const id = readEmoteMessage(m); if (id) playRemote(characterOf(from), { id }); });
|
|
948
|
+
```
|
|
949
|
+
|
|
950
|
+
**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
|
|
951
|
+
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
|
|
952
|
+
long hair follow. The rules are the island's:
|
|
953
|
+
|
|
954
|
+
| Emotes | Kind | What stops it |
|
|
955
|
+
|---|---|---|
|
|
956
|
+
| 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 |
|
|
957
|
+
| Clap, Shrug, Point, Facepalm | the spine, the arms and the head: they play over the walk | their end, the water, or `stop()` |
|
|
958
|
+
| Dance, and Sit (its sitting down plays once, then its seated part) | loops | as above: a loop plays until the player moves |
|
|
959
|
+
| The Wave | the character's own wave (1.8 s) | its end |
|
|
960
|
+
|
|
961
|
+
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
|
|
962
|
+
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.
|
|
963
|
+
The kit's pose has no swim: a game with water calls `emotes.stop()` (or `stopEmote(character)`) when a character goes in.
|
|
964
|
+
|
|
965
|
+
**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
|
|
966
|
+
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
|
|
967
|
+
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
|
|
968
|
+
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
|
|
969
|
+
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
|
|
970
|
+
(`icons`), e.g. Vite's `import icons from "@nano133/nanoterra/emotes/icons.webp?url"`.
|
|
971
|
+
|
|
972
|
+
| Call (`/emotes/player`) | What it does |
|
|
973
|
+
|---|---|
|
|
974
|
+
| `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. |
|
|
975
|
+
| `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 }`. |
|
|
976
|
+
| `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. |
|
|
977
|
+
| `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). |
|
|
978
|
+
| `stopEmote(character)` | Stops any character's clip, with its fade. |
|
|
979
|
+
| `loadEmotes()`, `emotesLoaded()` | Loads the clips (once; a failed load may be tried again), and says whether they are here. |
|
|
980
|
+
|
|
981
|
+
**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
|
|
982
|
+
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
|
|
983
|
+
middle disc with the name of the place under the finger and **Edit**.
|
|
984
|
+
|
|
985
|
+
- 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
|
|
986
|
+
let-go in the middle leaves the wheel open). A press beside the wheel or Escape closes it.
|
|
987
|
+
- 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
|
|
988
|
+
8** play its places while it is open; the places show their numbers only where there is a mouse.
|
|
989
|
+
- **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
|
|
990
|
+
"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
|
|
991
|
+
that refuses storage keeps them for the page. Nanoterra's first wheel is the island's: Wave, Cheer, Clap, Dance, Laugh, Point, Sit, Spin.
|
|
992
|
+
- `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
|
|
993
|
+
Nanoterra's eleven. `defaults`: your first wheel. `nanoterraEmotes: "off"` (the admin's switch) leaves Nanoterra's out and keeps yours; with nothing left
|
|
994
|
+
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
|
|
995
|
+
panel sets too, else `prefers-color-scheme`). It returns `open()`, `close()`, `isOpen`, `slots` and `destroy()`.
|
|
996
|
+
- 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).
|
|
997
|
+
|
|
998
|
+
The clips and the icons are part of the package and under its MIT licence.
|
|
999
|
+
|
|
1000
|
+
**The admin's switch.** The Game card has **"Allow Nanoterra emotes"** (`emotes` in the settings: `"on"`, the default, or `"off"`). Read it with
|
|
1001
|
+
`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
|
|
1002
|
+
Nanoterra emote. Your own emotes may still use the wheel.
|
|
1003
|
+
|
|
1004
|
+
**The player's options.** For the options menu (`/options`), `/emotes` exports three settings: `emotes.show` "Show the Emotes key" (Basic, on),
|
|
1005
|
+
`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
|
|
1006
|
+
`keyBinding("emotes", "Emotes wheel", "KeyB")` makes). `EMOTE_OPTIONS` holds all three with their whole keys, for `createOptions({ defs: [...yours, ...EMOTE_OPTIONS] })`
|
|
1007
|
+
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
|
|
1008
|
+
`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`
|
|
1009
|
+
(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.
|
|
1010
|
+
|
|
1011
|
+
**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
|
|
1012
|
+
`{ 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
|
|
1013
|
+
does not know an id drops it, and nothing breaks.
|
|
1014
|
+
|
|
819
1015
|
## Moving from the three old packages
|
|
820
1016
|
|
|
821
1017
|
| Before | Now |
|
|
@@ -834,7 +1030,7 @@ no live connection: only Nanoterra's do.
|
|
|
834
1030
|
- The island's item art (see "Worn things and sockets"): a game brings its own things.
|
|
835
1031
|
- 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.
|
|
836
1032
|
- The katana, the saya, the headband and the dojo look of the island's Ananos game. (The skeleton keeps their bones, with no vertices.)
|
|
837
|
-
- First-person arms, swimming, climbing
|
|
1033
|
+
- 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).
|
|
838
1034
|
- Shared geometry for many characters at once, and a react-three-fiber wrapper.
|
|
839
1035
|
- `WebGPURenderer`: the ink is a GLSL `ShaderMaterial`, so use `WebGLRenderer`.
|
|
840
1036
|
- 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`)
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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: {
|
|
@@ -40,7 +40,7 @@ async function read(res) {
|
|
|
40
40
|
/** A refusal carries words and a reason and nothing else: no data of the game. */
|
|
41
41
|
const REFUSAL_KEYS = new Set(["error", "reason", "signedInAs"]);
|
|
42
42
|
/** Words of the game's data that a refusal or an answer to a visitor must never hold. */
|
|
43
|
-
const DATA_WORDS = /"orders"|"players"|"totals"|"counts"|"character"|"signin"|"friends"|"warnings"/;
|
|
43
|
+
const DATA_WORDS = /"orders"|"players"|"totals"|"counts"|"character"|"signin"|"friends"|"emotes"|"warnings"/;
|
|
44
44
|
export function adminConformance(host) {
|
|
45
45
|
const cases = [];
|
|
46
46
|
const add = (name, run) => cases.push({ name, run });
|
|
@@ -264,7 +264,7 @@ export function adminStoreConformance(make) {
|
|
|
264
264
|
const order = (id, over) => ({ id, player: A, lookId: "mint-cap", usd: "0.25", amountRaw: rawOf(0.25), status: "paid", createdAt: NOW - 1000, expiresAt: NOW + 1000, paidAt: NOW - 500, payer: A, hash: `H${id}`, ...over });
|
|
265
265
|
add("a new store has no settings of its own", async ({ store }) => {
|
|
266
266
|
const s = await store.readSettings();
|
|
267
|
-
check(s.character === undefined && s.signin === undefined && s.friends === undefined, `expected no settings, got ${JSON.stringify(s)}`);
|
|
267
|
+
check(s.character === undefined && s.signin === undefined && s.friends === undefined && s.emotes === undefined, `expected no settings, got ${JSON.stringify(s)}`);
|
|
268
268
|
});
|
|
269
269
|
add("the friends list is kept as its own setting: each of its three words, and a patch of it leaves the other two", async ({ store }) => {
|
|
270
270
|
await store.saveSettings({ signin: "none", character: "own" }, NOW);
|
|
@@ -274,6 +274,14 @@ export function adminStoreConformance(make) {
|
|
|
274
274
|
check(s.friends === friends && s.signin === "none" && s.character === "own", `after friends ${friends}: ${JSON.stringify(s)}`);
|
|
275
275
|
}
|
|
276
276
|
});
|
|
277
|
+
add("the emotes switch is kept as its own setting: off and on, and a patch of it leaves the other three", async ({ store }) => {
|
|
278
|
+
await store.saveSettings({ signin: "none", character: "own", friends: "off" }, NOW);
|
|
279
|
+
for (const [i, emotes] of ["off", "on"].entries()) {
|
|
280
|
+
await store.saveSettings({ emotes }, NOW + 1 + i);
|
|
281
|
+
const s = await store.readSettings();
|
|
282
|
+
check(s.emotes === emotes && s.signin === "none" && s.character === "own" && s.friends === "off", `after emotes ${emotes}: ${JSON.stringify(s)}`);
|
|
283
|
+
}
|
|
284
|
+
});
|
|
277
285
|
add("a patch saves only what it names; the next patch keeps the rest", async ({ store }) => {
|
|
278
286
|
await store.saveSettings({ signin: "none" }, NOW);
|
|
279
287
|
let s = await store.readSettings();
|
|
@@ -288,12 +296,12 @@ export function adminStoreConformance(make) {
|
|
|
288
296
|
add("an empty patch writes nothing and does not fail", async ({ store }) => {
|
|
289
297
|
await store.saveSettings({}, NOW);
|
|
290
298
|
const s = await store.readSettings();
|
|
291
|
-
check(s.character === undefined && s.signin === undefined && s.friends === undefined, "an empty patch must save nothing");
|
|
299
|
+
check(s.character === undefined && s.signin === undefined && s.friends === undefined && s.emotes === undefined, "an empty patch must save nothing");
|
|
292
300
|
});
|
|
293
301
|
add("saving changes nothing but the settings: no order, no owned look, no other meta row", async (h) => {
|
|
294
302
|
await h.seed({ orders: [order("1", {}), order("2", { status: "open" })], owned: [{ player: A, lookId: "mint-cap", at: NOW }], rate: { value: JSON.stringify({ usd: 1.2, sources: ["x"] }), at: NOW } });
|
|
295
303
|
const before = await h.snapshot();
|
|
296
|
-
await h.store.saveSettings({ character: "own", signin: "none", friends: "off" }, NOW + 5);
|
|
304
|
+
await h.store.saveSettings({ character: "own", signin: "none", friends: "off", emotes: "off" }, NOW + 5);
|
|
297
305
|
check((await h.snapshot()) === before, "the rows of the game changed after saving the settings");
|
|
298
306
|
});
|
|
299
307
|
add("the summary counts the orders by status, reads an open order past its expiry as expired, and totals the money", async ({ store, seed }) => {
|
package/dist/admin/handler.d.ts
CHANGED
|
@@ -59,5 +59,5 @@ export declare const ADMIN_LIMITS: {
|
|
|
59
59
|
readonly gameBodyMax: 512;
|
|
60
60
|
};
|
|
61
61
|
export declare function createAdminHandler(options: AdminHandlerOptions): AdminHandler;
|
|
62
|
-
/** The settings as the game's own routes need them (the listing file, the character, the friends list): the stored words, or the game's defaults. A fault in the store gives the defaults. */
|
|
62
|
+
/** The settings as the game's own routes need them (the listing file, the character, the friends list, the emotes): the stored words, or the game's defaults. A fault in the store gives the defaults. */
|
|
63
63
|
export declare function loadGameSettings(store: Pick<AdminStore, "readSettings">, defaults: GameSettings): Promise<GameState>;
|
package/dist/admin/handler.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
//
|
|
3
3
|
// GET {base}/summary the numbers: status, orders, the looks that players own
|
|
4
4
|
// GET {base}/game the game switches
|
|
5
|
-
// POST {base}/game a press: { character }, { signin } or {
|
|
5
|
+
// POST {base}/game a press: { character }, { signin }, { friends } or { emotes } (only the known words)
|
|
6
6
|
// POST {base}/check the payment check, when the game gives one
|
|
7
7
|
//
|
|
8
8
|
// EVERY request under the base goes through `adminAccess` first, before the route is even looked up: a visitor who is not the owner gets 401 or 403
|
|
@@ -159,7 +159,7 @@ export function createAdminHandler(options) {
|
|
|
159
159
|
}
|
|
160
160
|
return { routes, basePath: options.basePath ?? "/api/admin", handle };
|
|
161
161
|
}
|
|
162
|
-
/** The settings as the game's own routes need them (the listing file, the character, the friends list): the stored words, or the game's defaults. A fault in the store gives the defaults. */
|
|
162
|
+
/** The settings as the game's own routes need them (the listing file, the character, the friends list, the emotes): the stored words, or the game's defaults. A fault in the store gives the defaults. */
|
|
163
163
|
export async function loadGameSettings(store, defaults) {
|
|
164
164
|
try {
|
|
165
165
|
return mergeSettings(await store.readSettings(), defaults);
|