@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.
Files changed (96) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +206 -10
  3. package/ai/SPEC.md +51 -2
  4. package/dist/admin/adapters/d1.d.ts +1 -0
  5. package/dist/admin/adapters/d1.js +6 -4
  6. package/dist/admin/adapters/firestore.d.ts +1 -0
  7. package/dist/admin/adapters/firestore.js +7 -5
  8. package/dist/admin/conformance.d.ts +1 -1
  9. package/dist/admin/conformance.js +12 -4
  10. package/dist/admin/handler.d.ts +1 -1
  11. package/dist/admin/handler.js +2 -2
  12. package/dist/admin/index.d.ts +2 -2
  13. package/dist/admin/index.js +1 -1
  14. package/dist/admin/memory.js +3 -1
  15. package/dist/admin/panel/gameCard.js +13 -10
  16. package/dist/admin/panel/icons.d.ts +17 -0
  17. package/dist/admin/panel/icons.js +3 -0
  18. package/dist/admin/panel/overview.js +1 -1
  19. package/dist/admin/store.d.ts +2 -1
  20. package/dist/admin/switches.d.ts +13 -3
  21. package/dist/admin/switches.js +25 -6
  22. package/dist/character/create.js +6 -2
  23. package/dist/character/layer.d.ts +14 -0
  24. package/dist/character/layer.js +10 -0
  25. package/dist/emotes/clip.d.ts +79 -0
  26. package/dist/emotes/clip.js +165 -0
  27. package/dist/emotes/clips.d.ts +3 -0
  28. package/dist/emotes/clips.js +2 -0
  29. package/dist/emotes/events.d.ts +22 -0
  30. package/dist/emotes/events.js +19 -0
  31. package/dist/emotes/icons.d.ts +1 -0
  32. package/dist/emotes/icons.js +6 -0
  33. package/dist/emotes/index.d.ts +8 -0
  34. package/dist/emotes/index.js +11 -0
  35. package/dist/emotes/list.d.ts +123 -0
  36. package/dist/emotes/list.js +23 -0
  37. package/dist/emotes/options.d.ts +16 -0
  38. package/dist/emotes/options.js +34 -0
  39. package/dist/emotes/panel/index.d.ts +3 -0
  40. package/dist/emotes/panel/index.js +9 -0
  41. package/dist/emotes/panel/mount.d.ts +56 -0
  42. package/dist/emotes/panel/mount.js +360 -0
  43. package/dist/emotes/player.d.ts +37 -0
  44. package/dist/emotes/player.js +101 -0
  45. package/dist/emotes/wheel.d.ts +42 -0
  46. package/dist/emotes/wheel.js +110 -0
  47. package/dist/friends/panel/mount.d.ts +3 -3
  48. package/dist/friends/panel/mount.js +46 -12
  49. package/dist/options/common.d.ts +88 -0
  50. package/dist/options/common.js +59 -0
  51. package/dist/options/core.d.ts +130 -0
  52. package/dist/options/core.js +420 -0
  53. package/dist/options/index.d.ts +7 -0
  54. package/dist/options/index.js +24 -0
  55. package/dist/options/panel/index.d.ts +2 -0
  56. package/dist/options/panel/index.js +10 -0
  57. package/dist/options/panel/mount.d.ts +30 -0
  58. package/dist/options/panel/mount.js +378 -0
  59. package/dist/options/reflect.d.ts +5 -0
  60. package/dist/options/reflect.js +27 -0
  61. package/package.json +12 -4
  62. package/src/admin/adapters/d1.ts +5 -4
  63. package/src/admin/adapters/firestore.ts +6 -5
  64. package/src/admin/conformance.ts +14 -5
  65. package/src/admin/handler.ts +2 -2
  66. package/src/admin/index.ts +2 -2
  67. package/src/admin/memory.ts +2 -1
  68. package/src/admin/panel/gameCard.ts +13 -9
  69. package/src/admin/panel/icons.ts +3 -0
  70. package/src/admin/panel/overview.ts +1 -1
  71. package/src/admin/store.ts +2 -2
  72. package/src/admin/switches.ts +29 -12
  73. package/src/character/create.ts +6 -2
  74. package/src/character/layer.ts +21 -0
  75. package/src/emotes/clip.ts +204 -0
  76. package/src/emotes/clips.ts +7 -0
  77. package/src/emotes/events.ts +28 -0
  78. package/src/emotes/icons.ts +7 -0
  79. package/src/emotes/icons.webp +0 -0
  80. package/src/emotes/index.ts +16 -0
  81. package/src/emotes/list.ts +33 -0
  82. package/src/emotes/options.ts +43 -0
  83. package/src/emotes/panel/index.ts +11 -0
  84. package/src/emotes/panel/mount.ts +390 -0
  85. package/src/emotes/panel/style.css +415 -0
  86. package/src/emotes/player.ts +131 -0
  87. package/src/emotes/wheel.ts +131 -0
  88. package/src/friends/panel/mount.ts +47 -13
  89. package/src/friends/panel/style.css +26 -0
  90. package/src/options/common.ts +65 -0
  91. package/src/options/core.ts +465 -0
  92. package/src/options/index.ts +41 -0
  93. package/src/options/panel/index.ts +12 -0
  94. package/src/options/panel/mount.ts +418 -0
  95. package/src/options/panel/style.css +528 -0
  96. package/src/options/reflect.ts +28 -0
@@ -0,0 +1,415 @@
1
+ /* The Emotes key and the emote wheel (mountEmotes in ./mount.ts). Every rule is under .n133-emotes, so nothing leaks into the game's page.
2
+ Phone first: the key is 44 px, each place of the wheel 58 px or more, each tile of Edit 70 px. The wheel's sizes follow the screen's short side
3
+ (a phone on its side too): --r is the ring's radius, --slot a place's size, as on nano133.com's island.
4
+ To theme it, set the --n133-* custom properties on the element that holds the key (or a parent): --n133-ink, --n133-ink-soft, --n133-paper,
5
+ --n133-ground, --n133-line, --n133-gem, --n133-gem-dark, --n133-radius, --n133-font and --n133-emotes-z (the wheel's z-index, default 1000).
6
+ Those set the LIGHT theme; the dark one reads the twins with "dark-" in the name (--n133-dark-ink and so on), as the admin panel does.
7
+ mountEmotes sets data-theme="light" or "dark" on .n133-emotes. */
8
+
9
+ .n133-emotes {
10
+ --font: var(--n133-font, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif);
11
+ --radius: var(--n133-radius, 18px);
12
+ --ink: var(--n133-ink, #16202e);
13
+ --ink-soft: var(--n133-ink-soft, #4a5568);
14
+ --paper: var(--n133-paper, #ffffff);
15
+ --ground: var(--n133-ground, #f4f7fb);
16
+ --line: var(--n133-line, #dbe4ee);
17
+ --gem: var(--n133-gem, #20c9a6);
18
+ --gem-dark: var(--n133-gem-dark, #0e8a72);
19
+ --on-gem: var(--n133-on-gem, #062b23);
20
+ --dim: rgb(16 24 40 / 0.36);
21
+ --shade: 16 24 40;
22
+ display: inline-block;
23
+ color: var(--ink);
24
+ color-scheme: light;
25
+ font: 15px/1.4 var(--font);
26
+ }
27
+
28
+ .n133-emotes[data-theme="dark"] {
29
+ --ink: var(--n133-dark-ink, #e7edf5);
30
+ --ink-soft: var(--n133-dark-ink-soft, #a9b5c5);
31
+ --paper: var(--n133-dark-paper, #171f2b);
32
+ --ground: var(--n133-dark-ground, #0f151e);
33
+ --line: var(--n133-dark-line, #3a475b);
34
+ --gem: var(--n133-dark-gem, #20c9a6);
35
+ --gem-dark: var(--n133-dark-gem-dark, #3fd8b6);
36
+ --on-gem: var(--n133-dark-on-gem, #062b23);
37
+ --dim: rgb(0 0 0 / 0.5);
38
+ --shade: 0 0 0;
39
+ color-scheme: dark;
40
+ }
41
+
42
+ .n133-emotes *,
43
+ .n133-emotes *::before,
44
+ .n133-emotes *::after {
45
+ box-sizing: border-box;
46
+ }
47
+
48
+ /* The key. A hold on it that slides onto the wheel must not scroll or zoom the page, nor open the phone's own menu. */
49
+ .n133-emotes .n133e-key {
50
+ display: inline-flex;
51
+ align-items: center;
52
+ gap: 8px;
53
+ min-width: 44px;
54
+ min-height: 44px;
55
+ padding: 0 14px;
56
+ border: 1px solid var(--line);
57
+ border-radius: 999px;
58
+ background: var(--paper);
59
+ color: var(--ink);
60
+ font: 650 15px/1 var(--font);
61
+ cursor: pointer;
62
+ touch-action: none;
63
+ user-select: none;
64
+ -webkit-user-select: none;
65
+ -webkit-touch-callout: none;
66
+ -webkit-tap-highlight-color: transparent;
67
+ }
68
+
69
+ .n133-emotes .n133e-key[hidden] {
70
+ display: none;
71
+ }
72
+
73
+ /* The dim page and the wheel in the middle of it. */
74
+ .n133-emotes .n133e-layer {
75
+ position: fixed;
76
+ z-index: var(--n133-emotes-z, 1000);
77
+ inset: 0;
78
+ display: flex;
79
+ flex-direction: column;
80
+ align-items: center;
81
+ justify-content: center;
82
+ gap: 12px;
83
+ padding: max(10px, env(safe-area-inset-top, 0px)) max(10px, env(safe-area-inset-right, 0px)) max(10px, env(safe-area-inset-bottom, 0px)) max(10px, env(safe-area-inset-left, 0px));
84
+ background: var(--dim);
85
+ touch-action: none;
86
+ }
87
+
88
+ .n133-emotes .n133e-layer[hidden] {
89
+ display: none;
90
+ }
91
+
92
+ .n133-emotes .n133e-wheel {
93
+ --r: clamp(96px, 29vmin, 140px);
94
+ --slot: clamp(66px, 20vmin, 90px);
95
+ position: relative;
96
+ flex: none;
97
+ width: calc(2 * var(--r) + var(--slot) + 8px);
98
+ height: calc(2 * var(--r) + var(--slot) + 8px);
99
+ }
100
+
101
+ /* A low screen (a phone on its side): a little smaller, so the top place stays inside the screen. */
102
+ @media (max-height: 500px) {
103
+ .n133-emotes .n133e-wheel {
104
+ --r: clamp(84px, 26vmin, 120px);
105
+ --slot: clamp(60px, 18vmin, 80px);
106
+ }
107
+ }
108
+
109
+ @media (prefers-reduced-motion: no-preference) {
110
+ .n133-emotes .n133e-wheel {
111
+ animation: n133e-in 0.12s ease-out;
112
+ }
113
+ }
114
+
115
+ @keyframes n133e-in {
116
+ from {
117
+ opacity: 0;
118
+ transform: scale(0.92);
119
+ }
120
+ }
121
+
122
+ .n133-emotes .n133e-ring {
123
+ position: absolute;
124
+ left: 50%;
125
+ top: 50%;
126
+ width: calc(2 * var(--r) + var(--slot) * 0.7);
127
+ height: calc(2 * var(--r) + var(--slot) * 0.7);
128
+ transform: translate(-50%, -50%);
129
+ border: 1px solid var(--line);
130
+ border-radius: 50%;
131
+ background: color-mix(in srgb, var(--paper) 94%, transparent);
132
+ box-shadow: 0 10px 40px rgb(var(--shade) / 0.28);
133
+ }
134
+
135
+ .n133-emotes .n133e-slot {
136
+ position: absolute;
137
+ left: 50%;
138
+ top: 50%;
139
+ display: flex;
140
+ flex-direction: column;
141
+ align-items: center;
142
+ justify-content: center;
143
+ width: var(--slot);
144
+ height: var(--slot);
145
+ margin: calc(var(--slot) / -2) 0 0 calc(var(--slot) / -2);
146
+ padding: 0 0 4px;
147
+ transform: translate(calc(var(--x) * var(--r)), calc(var(--y) * var(--r)));
148
+ border: 1px solid var(--line);
149
+ border-radius: 50%;
150
+ background: var(--ground);
151
+ color: var(--ink);
152
+ box-shadow: 0 2px 6px rgb(var(--shade) / 0.14);
153
+ font: 700 11px/1.05 var(--font);
154
+ cursor: pointer;
155
+ touch-action: none;
156
+ /* Over the middle disc, so a place's corner key is never under it. */
157
+ z-index: 1;
158
+ -webkit-tap-highlight-color: transparent;
159
+ }
160
+
161
+ .n133-emotes .n133e-slot.is-lit {
162
+ border-color: var(--gem-dark);
163
+ background: var(--gem);
164
+ color: var(--on-gem);
165
+ scale: 1.1;
166
+ }
167
+
168
+ .n133-emotes .n133e-slot.is-empty {
169
+ border-style: dashed;
170
+ background: transparent;
171
+ box-shadow: none;
172
+ }
173
+
174
+ .n133-emotes .n133e-pic,
175
+ .n133-emotes .n133e-tile-pic {
176
+ background-repeat: no-repeat;
177
+ }
178
+
179
+ .n133-emotes .n133e-pic {
180
+ width: 62%;
181
+ height: 62%;
182
+ }
183
+
184
+ .n133-emotes .is-own {
185
+ background-position: center;
186
+ background-size: contain;
187
+ }
188
+
189
+ .n133-emotes .n133e-name {
190
+ max-width: 92%;
191
+ overflow: hidden;
192
+ white-space: nowrap;
193
+ text-overflow: ellipsis;
194
+ }
195
+
196
+ .n133-emotes .n133e-plus {
197
+ font: 700 26px/1 var(--font);
198
+ opacity: 0.55;
199
+ }
200
+
201
+ /* A place's key (1 to 8): only where there is a keyboard and a mouse. Edit's × sits in the same corner. */
202
+ .n133-emotes .n133e-num,
203
+ .n133-emotes .n133e-off {
204
+ position: absolute;
205
+ top: -4px;
206
+ right: -4px;
207
+ min-width: 22px;
208
+ height: 22px;
209
+ border-radius: 11px;
210
+ background: var(--ink);
211
+ color: var(--paper);
212
+ font: 700 12px/22px var(--font);
213
+ text-align: center;
214
+ }
215
+
216
+ .n133-emotes .n133e-num {
217
+ display: none;
218
+ }
219
+
220
+ .n133-emotes .n133e-off {
221
+ font-size: 16px;
222
+ }
223
+
224
+ @media (hover: hover) and (pointer: fine) {
225
+ .n133-emotes .n133e-num {
226
+ display: block;
227
+ }
228
+
229
+ .n133-emotes .n133e-slot:hover:not(.is-empty) {
230
+ border-color: var(--gem-dark);
231
+ background: var(--gem);
232
+ color: var(--on-gem);
233
+ }
234
+ }
235
+
236
+ /* The middle disc: the name of the place under the finger, and Edit. */
237
+ .n133-emotes .n133e-hub {
238
+ position: absolute;
239
+ left: 50%;
240
+ top: 50%;
241
+ display: flex;
242
+ flex-direction: column;
243
+ align-items: center;
244
+ justify-content: center;
245
+ gap: 6px;
246
+ width: calc(2 * (var(--r) - var(--slot) / 2) - 14px);
247
+ height: calc(2 * (var(--r) - var(--slot) / 2) - 14px);
248
+ transform: translate(-50%, -50%);
249
+ border: 1px solid var(--line);
250
+ border-radius: 50%;
251
+ background: var(--paper);
252
+ color: var(--ink);
253
+ text-align: center;
254
+ }
255
+
256
+ .n133-emotes .n133e-hub-name {
257
+ max-width: 90%;
258
+ font: 750 15px/1.1 var(--font);
259
+ }
260
+
261
+ .n133-emotes .n133e-edit,
262
+ .n133-emotes .n133e-tile {
263
+ border: 1px solid var(--line);
264
+ background: var(--paper);
265
+ color: var(--ink);
266
+ cursor: pointer;
267
+ touch-action: manipulation;
268
+ -webkit-tap-highlight-color: transparent;
269
+ }
270
+
271
+ .n133-emotes .n133e-edit {
272
+ min-width: 44px;
273
+ min-height: 44px;
274
+ padding: 0 14px;
275
+ border-radius: 12px;
276
+ font: 650 14px/1 var(--font);
277
+ }
278
+
279
+ .n133-emotes .n133e-edit[aria-pressed="true"] {
280
+ border-color: var(--gem-dark);
281
+ background: var(--gem);
282
+ color: var(--on-gem);
283
+ }
284
+
285
+ .n133-emotes :is(.n133e-key, .n133e-slot, .n133e-edit, .n133e-tile):focus-visible {
286
+ outline: 3px solid var(--gem);
287
+ outline-offset: 2px;
288
+ }
289
+
290
+ /* Edit: every emote under the wheel (beside it on a low screen); a tap puts one on or takes it off. */
291
+ .n133-emotes .n133e-layer.is-edit .n133e-wheel {
292
+ --r: clamp(84px, 24vmin, 118px);
293
+ --slot: clamp(58px, 17vmin, 78px);
294
+ }
295
+
296
+ .n133-emotes .n133e-list {
297
+ display: flex;
298
+ flex-direction: column;
299
+ gap: 8px;
300
+ max-height: 46vh;
301
+ max-width: 100%;
302
+ padding: 10px;
303
+ overflow-y: auto;
304
+ overscroll-behavior: contain;
305
+ border: 1px solid var(--line);
306
+ border-radius: var(--radius);
307
+ background: var(--paper);
308
+ box-shadow: 0 10px 40px rgb(var(--shade) / 0.28);
309
+ touch-action: pan-y;
310
+ }
311
+
312
+ .n133-emotes .n133e-list[hidden] {
313
+ display: none;
314
+ }
315
+
316
+ .n133-emotes .n133e-list-line {
317
+ margin: 0;
318
+ color: var(--ink-soft);
319
+ font: 600 13px/1.25 var(--font);
320
+ text-align: center;
321
+ }
322
+
323
+ .n133-emotes .n133e-list-line.is-full {
324
+ color: var(--ink);
325
+ font-weight: 750;
326
+ }
327
+
328
+ .n133-emotes .n133e-grid {
329
+ display: grid;
330
+ grid-template-columns: repeat(4, 74px);
331
+ gap: 6px;
332
+ justify-content: center;
333
+ }
334
+
335
+ .n133-emotes .n133e-tile {
336
+ position: relative;
337
+ display: flex;
338
+ flex-direction: column;
339
+ align-items: center;
340
+ justify-content: flex-end;
341
+ gap: 1px;
342
+ width: 74px;
343
+ height: 78px;
344
+ padding: 2px 2px 5px;
345
+ border-radius: 12px;
346
+ font: 650 12px/1.1 var(--font);
347
+ }
348
+
349
+ .n133-emotes .n133e-tile.is-on {
350
+ border-color: var(--gem-dark);
351
+ background: color-mix(in srgb, var(--gem) 22%, var(--paper));
352
+ }
353
+
354
+ .n133-emotes .n133e-tile-pic {
355
+ width: 54px;
356
+ height: 54px;
357
+ }
358
+
359
+ .n133-emotes .n133e-tile-name {
360
+ max-width: 100%;
361
+ overflow: hidden;
362
+ white-space: nowrap;
363
+ text-overflow: ellipsis;
364
+ }
365
+
366
+ .n133-emotes .n133e-badge {
367
+ position: absolute;
368
+ top: -6px;
369
+ right: -6px;
370
+ min-width: 22px;
371
+ height: 22px;
372
+ border-radius: 11px;
373
+ background: var(--gem);
374
+ color: var(--on-gem);
375
+ font: 700 12px/22px var(--font);
376
+ text-align: center;
377
+ }
378
+
379
+ @media (hover: hover) {
380
+ .n133-emotes .n133e-tile:hover {
381
+ border-color: var(--gem-dark);
382
+ }
383
+ }
384
+
385
+ @media (orientation: landscape) and (max-height: 600px) {
386
+ .n133-emotes .n133e-layer.is-edit {
387
+ flex-direction: row;
388
+ }
389
+
390
+ .n133-emotes .n133e-layer.is-edit .n133e-list {
391
+ max-height: calc(100vh - 24px);
392
+ max-height: calc(100dvh - 24px);
393
+ }
394
+
395
+ .n133-emotes .n133e-grid {
396
+ grid-template-columns: repeat(4, 70px);
397
+ }
398
+
399
+ .n133-emotes .n133e-tile {
400
+ width: 70px;
401
+ height: 70px;
402
+ }
403
+
404
+ .n133-emotes .n133e-tile-pic {
405
+ width: 46px;
406
+ height: 46px;
407
+ }
408
+ }
409
+
410
+ /* A narrow phone held upright: three tiles a row, so Edit stays inside the screen. */
411
+ @media (max-width: 340px) {
412
+ .n133-emotes .n133e-grid {
413
+ grid-template-columns: repeat(3, 74px);
414
+ }
415
+ }
@@ -0,0 +1,131 @@
1
+ // "@nano133/nanoterra/emotes/player": Nanoterra's emotes on the kit's character (createCharacter of "@nano133/nanoterra/character"). Three.js, as the
2
+ // character is. A clip plays as a layer over the character's pose (../character/layer.ts), so the game calls nothing new each frame: its usual
3
+ // `character.update(dt, motion)` plays the emote too, after the walk and before the tail, the wings and the long hair.
4
+ //
5
+ // const emotes = createEmotes(hero, { onEmote: (e) => send(emoteMessage(e)) }); // the local player
6
+ // emotes.play("dance"); // from the wheel's onPlay
7
+ // playRemote(other, { id: msg.emote }); // another player's emote, from your network
8
+ //
9
+ // The Wave is the character's own `wave()`; the ten others are clips, which load on the first emote (`loadEmotes`, one chunk of their own).
10
+
11
+ import type { Character } from "../character/create.js";
12
+ import { setPoseLayer } from "../character/layer.js";
13
+ import { newRun, startClip, stepClip, stopClip, type ClipRun } from "./clip.js";
14
+ import { LOOP_AGAIN_MS, type EmoteEvent, type EmoteStop } from "./events.js";
15
+ import { emoteOf, isEmote } from "./list.js";
16
+ import { readEmoteOptions, type EmoteOptionsStore } from "./options.js";
17
+
18
+ export { emoteClip, emotesLoaded, loadEmotes, FADE_END, FADE_IN, FADE_STOP } from "./clip.js";
19
+
20
+ /** How fast a character must go before a full clip stops (m/s, the pose's eased speed): the island's. */
21
+ const MOVING = 0.4;
22
+
23
+ /** What a character keeps for its emotes: its clip run, the last play it was given (by `at`), and the local player's events. */
24
+ type State = {
25
+ run: ClipRun;
26
+ seen: number;
27
+ /** The age of the run when its loop was last sent. */
28
+ sent: number;
29
+ local: EmotesOptions | null;
30
+ };
31
+ const states = new WeakMap<Character, State>();
32
+
33
+ const now = () => (typeof performance !== "undefined" ? performance.now() : Date.now());
34
+
35
+ /** The character's state, and its layer, made on its first emote. */
36
+ function stateOf(character: Character): State {
37
+ let st = states.get(character);
38
+ if (st) return st;
39
+ const s: State = { run: newRun(), seen: Number.NaN, sent: 0, local: null };
40
+ setPoseLayer(character, (bones, f) => {
41
+ const was = s.run.id;
42
+ stepClip(s.run, bones, { dt: f.dt, moving: f.speed > MOVING, air: f.airborne, swim: false, calm: f.reducedMotion });
43
+ if (!s.local) return;
44
+ if (was && !s.run.id && s.run.ended) s.local.onStop?.({ id: was, at: now(), why: s.run.ended });
45
+ else if (s.run.id && emoteOf(s.run.id)?.loop && s.run.stopAge < 0 && s.run.age - s.sent >= LOOP_AGAIN_MS / 1000) {
46
+ // A loop is sent again while it plays, so a player who comes later sees it.
47
+ s.sent = s.run.age;
48
+ s.local.onEmote?.({ id: s.run.id, loop: true, at: now(), again: true });
49
+ }
50
+ });
51
+ states.set(character, s);
52
+ st = s;
53
+ return st;
54
+ }
55
+
56
+ /** Plays a Nanoterra emote on a character: the Wave is its own `wave()`, the others start a clip. False for an id that is not one. */
57
+ function start(character: Character, id: unknown, age = 0): id is string {
58
+ if (!isEmote(id)) return false;
59
+ if (id === "wave") {
60
+ character.wave();
61
+ return true;
62
+ }
63
+ const st = stateOf(character);
64
+ startClip(st.run, id, age);
65
+ return true;
66
+ }
67
+
68
+ export type EmotesOptions = {
69
+ /** The player played an emote (and again every 10 s while a loop plays): send `emoteMessage(e)` to the other players. */
70
+ onEmote?: (e: EmoteEvent) => void;
71
+ /** The player's clip ended: its end, a move, the water or `stop`. Nothing to send for a move (the others see it); see the README for `stop`. */
72
+ onStop?: (e: EmoteStop) => void;
73
+ };
74
+
75
+ export type LocalEmotes = {
76
+ /** Plays a Nanoterra emote on the player's character (the wheel's onPlay). False, and nothing happens, for an id that is not one. */
77
+ play(id: string): boolean;
78
+ /** Stops the player's clip, with its fade. A game with water calls it when the character goes in. */
79
+ stop(): void;
80
+ /** The clip on show, or null (the Wave is not a clip: it is never here). */
81
+ readonly playing: string | null;
82
+ /** Stops listening (`onEmote`, `onStop`). The character keeps playing what it plays. */
83
+ destroy(): void;
84
+ };
85
+
86
+ /** The local player's emotes on their character, with the events a multiplayer game sends on. */
87
+ export function createEmotes(character: Character, options: EmotesOptions = {}): LocalEmotes {
88
+ const st = stateOf(character);
89
+ st.local = options;
90
+ return {
91
+ play(id) {
92
+ if (!start(character, id)) return false;
93
+ st.sent = 0;
94
+ options.onEmote?.({ id, loop: emoteOf(id)?.loop === true, at: now(), again: false });
95
+ return true;
96
+ },
97
+ stop() {
98
+ stopClip(st.run);
99
+ },
100
+ get playing() {
101
+ return st.run.id;
102
+ },
103
+ destroy() {
104
+ if (st.local === options) st.local = null;
105
+ },
106
+ };
107
+ }
108
+
109
+ /** Another player's emote: its id, and when it came (performance.now(); left out: now). */
110
+ export type RemotePlay = { id: unknown; at?: number };
111
+
112
+ /**
113
+ * Plays another player's emote on their character (from the game's network). An id that is not a Nanoterra emote is ignored (false). The same play
114
+ * given again (the same `at`) is ignored, so a game may pass the last one every frame; a loop that is on show goes on through a repeat. With an
115
+ * options store whose `emotes.others` is off, nothing plays.
116
+ */
117
+ export function playRemote(character: Character, play: RemotePlay, opts: { options?: EmoteOptionsStore | null } = {}): boolean {
118
+ if (!play || typeof play !== "object" || !isEmote(play.id)) return false;
119
+ if (!readEmoteOptions(opts.options).others) return false;
120
+ const at = typeof play.at === "number" && Number.isFinite(play.at) ? play.at : now();
121
+ const st = stateOf(character);
122
+ if (st.seen === at) return false;
123
+ st.seen = at;
124
+ return start(character, play.id, (now() - at) / 1000);
125
+ }
126
+
127
+ /** Stops a character's clip with its fade (a remote player's, when the game hears a stop, or any character that goes into water). */
128
+ export function stopEmote(character: Character): void {
129
+ const st = states.get(character);
130
+ if (st) stopClip(st.run);
131
+ }
@@ -0,0 +1,131 @@
1
+ // The player's emote wheel: eight places round the wheel, slot 1 at the top and on clockwise, each an emote or empty. The player picks which emotes sit
2
+ // there from every emote the game offers (Edit in the panel), so the list can grow past eight. Kept in this browser per game, under `n133-emotes:<game>`;
3
+ // a browser with nothing kept, or a storage that is refused (some private windows), has the game's default wheel and keeps changes for the page's life.
4
+ // No DOM and no Three.js here: the panel (./panel) draws it.
5
+
6
+ import { EMOTES } from "./list.js";
7
+
8
+ export const WHEEL_SLOTS = 8;
9
+ /** The localStorage key of a game's wheel. */
10
+ export const wheelKey = (game: string) => `n133-emotes:${game}`;
11
+ /** A wheel: eight places, each an emote's id once, or null (empty). */
12
+ export type Slots = readonly (string | null)[];
13
+ /** Nanoterra's wheel before the player changes it (the island's): the eight most social emotes. Bow, Shrug and Facepalm wait in Edit. */
14
+ export const DEFAULT_WHEEL: Slots = ["wave", "cheer", "clap", "dance", "laugh", "point", "sit", "spin"];
15
+ /** What the player is told when they put a ninth emote on the wheel. */
16
+ export const WHEEL_FULL = "Your wheel is full. Take one off first.";
17
+
18
+ /** Eight places from a list (cut, or filled with empty places), each id of `known` once. */
19
+ function fit(list: readonly unknown[], known: ReadonlySet<string>): Slots {
20
+ const seen = new Set<string>();
21
+ return Array.from({ length: WHEEL_SLOTS }, (_, i) => {
22
+ const x = list[i];
23
+ if (typeof x !== "string" || !known.has(x) || seen.has(x)) return null;
24
+ seen.add(x);
25
+ return x;
26
+ });
27
+ }
28
+
29
+ /** The first wheel for a list of emotes: `defaults` when given (each id of the list once), else the island's wheel for Nanoterra's list, else the list's first eight. */
30
+ export function defaultWheel(ids: readonly string[], defaults?: readonly (string | null)[]): Slots {
31
+ const known = new Set(ids);
32
+ if (defaults) return fit(defaults, known);
33
+ const island = fit(DEFAULT_WHEEL, known);
34
+ return island.some((x) => x !== null) ? island : fit(ids, known);
35
+ }
36
+
37
+ /** A kept wheel: eight places, each an emote of `ids` once or empty. Anything else (no key, broken text, another length) is `fallback`. */
38
+ export function readWheel(raw: string | null | undefined, ids: readonly string[], fallback: Slots): Slots {
39
+ if (!raw) return fallback;
40
+ try {
41
+ const v = JSON.parse(raw) as unknown;
42
+ if (!Array.isArray(v) || v.length !== WHEEL_SLOTS) return fallback;
43
+ return fit(v, new Set(ids));
44
+ } catch {
45
+ return fallback;
46
+ }
47
+ }
48
+
49
+ /** The emote put on (in the first empty place), taken off, or refused because all eight places are full. */
50
+ export function toggleSlot(slots: Slots, id: string): { slots: Slots; did: "added" | "removed" | "full" } {
51
+ const at = slots.indexOf(id);
52
+ if (at >= 0) return { slots: slots.map((x, i) => (i === at ? null : x)), did: "removed" };
53
+ const free = slots.indexOf(null);
54
+ if (free < 0) return { slots, did: "full" };
55
+ return { slots: slots.map((x, i) => (i === free ? id : x)), did: "added" };
56
+ }
57
+
58
+ type Store = Pick<Storage, "getItem" | "setItem">;
59
+
60
+ /** The page's localStorage, or null where reading it throws. */
61
+ function pageStorage(): Store | null {
62
+ try {
63
+ return globalThis.localStorage ?? null;
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
68
+
69
+ export type WheelOptions = {
70
+ /** The game's id: each game keeps its own wheel. */
71
+ game: string;
72
+ /** Every emote the player may put on the wheel, by id. Default: Nanoterra's eleven. */
73
+ ids?: readonly string[];
74
+ /** The eight places before the player changes them. Default: see `defaultWheel`. */
75
+ defaults?: readonly (string | null)[];
76
+ /** Where the wheel is kept (default: the page's localStorage). null keeps it only for the life of the page. */
77
+ storage?: Store | null;
78
+ };
79
+
80
+ export type Wheel = {
81
+ /** The eight places now. */
82
+ readonly slots: Slots;
83
+ /** Puts an emote on (its first empty place) or takes it off; "full" changes nothing. */
84
+ toggle(id: string): "added" | "removed" | "full";
85
+ /** Takes the emote off a place. */
86
+ clear(slot: number): void;
87
+ /** Hears every change. Returns the call that stops it. */
88
+ subscribe(listen: (slots: Slots) => void): () => void;
89
+ };
90
+
91
+ /** A game's wheel, read from this browser (or the default), and kept there on each change. */
92
+ export function createWheel(options: WheelOptions): Wheel {
93
+ const ids = options.ids ?? EMOTES.map((e) => e.id);
94
+ const storage = options.storage === undefined ? pageStorage() : options.storage;
95
+ const key = wheelKey(options.game);
96
+ let raw: string | null = null;
97
+ try {
98
+ raw = storage?.getItem(key) ?? null;
99
+ } catch {
100
+ raw = null;
101
+ }
102
+ let slots = readWheel(raw, ids, defaultWheel(ids, options.defaults));
103
+ const listeners = new Set<(slots: Slots) => void>();
104
+ const set = (next: Slots) => {
105
+ slots = next;
106
+ try {
107
+ storage?.setItem(key, JSON.stringify(next));
108
+ } catch {
109
+ // A full or refused storage: the wheel still works for this page.
110
+ }
111
+ for (const l of listeners) l(slots);
112
+ };
113
+ return {
114
+ get slots() {
115
+ return slots;
116
+ },
117
+ toggle(id) {
118
+ const r = toggleSlot(slots, id);
119
+ if (r.did !== "full") set(r.slots);
120
+ return r.did;
121
+ },
122
+ clear(slot) {
123
+ if (slots[slot] == null) return;
124
+ set(slots.map((x, i) => (i === slot ? null : x)));
125
+ },
126
+ subscribe(listen) {
127
+ listeners.add(listen);
128
+ return () => listeners.delete(listen);
129
+ },
130
+ };
131
+ }