@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,360 @@
1
+ // The Emotes key and the emote wheel of a game: `mountEmotes(element, options)`. Plain DOM, no framework, no Three.js: nano133.com's EmoteKeys.tsx and
2
+ // EmoteWheel.tsx (Nano Island), for any game. The key goes into `element`; it opens the wheel in the middle of the screen over a dim page: eight round
3
+ // places on a ring, slot 1 at the top and on clockwise, each the emote's picture and name, and a middle disc with the name of the place under the
4
+ // finger and Edit. Two ways with a finger or a mouse: a tap on the key opens the wheel and a tap on a place plays it; or a hold on the key opens it at
5
+ // once, and sliding onto a place and letting go plays that one (a slide under 14 px is a tap, a let-go in the middle leaves the wheel open). A press
6
+ // beside the wheel, Escape or the key closes it. On a keyboard B opens and closes it (not while a field has the keys, or when the game's `keyAllowed`
7
+ // says no) and 1 to 8 play its places while it is open. Edit shows every emote: a tap puts one on the wheel in its first empty place or takes it off,
8
+ // and a ninth is refused with WHEEL_FULL; in Edit a tap on a place takes its emote off.
9
+ //
10
+ // The list is Nanoterra's (each id names one of EMOTES: its name and picture come from the kit), the game's own ({ id, label, icon }), or both. The
11
+ // wheel only calls `onPlay(id)`: the game plays it (Nanoterra's on the kit's character: createEmotes(...).play, from "@nano133/nanoterra/emotes/player").
12
+ import { h } from "../../admin/panel/dom.js";
13
+ import { icon } from "../../admin/panel/icons.js";
14
+ import { EMOTE_ICONS_URL } from "../icons.js";
15
+ import { EMOTES, emoteOf, isEmote } from "../list.js";
16
+ import { readEmoteOptions } from "../options.js";
17
+ import { WHEEL_FULL, WHEEL_SLOTS, createWheel } from "../wheel.js";
18
+ /** How far a finger moves on the key before it is a slide, not a tap (CSS pixels). */
19
+ export const SLIDE_PX = 14;
20
+ const NOTHING = { open() { }, close() { }, isOpen: false, slots: [], destroy() { } };
21
+ /** Each place's direction from the middle: slot 1 at the top, then clockwise. */
22
+ const AT = Array.from({ length: WHEEL_SLOTS }, (_, i) => {
23
+ const a = ((-90 + i * (360 / WHEEL_SLOTS)) * Math.PI) / 180;
24
+ return { x: Math.cos(a), y: Math.sin(a) };
25
+ });
26
+ const DIGITS = Object.fromEntries(Array.from({ length: WHEEL_SLOTS }, (_, i) => [
27
+ [`Digit${i + 1}`, i],
28
+ [`Numpad${i + 1}`, i],
29
+ ]).flat());
30
+ /** A key that a field, or a page's own editable text, is holding. */
31
+ const inField = (t) => {
32
+ const el = t;
33
+ return !!el && (["INPUT", "SELECT", "TEXTAREA"].includes(el.tagName) || el.isContentEditable === true);
34
+ };
35
+ /** The place under a point of the screen, by its angle from the wheel's middle, outside the middle disc; null in the middle or with no wheel. */
36
+ export function placeAt(rect, x, y) {
37
+ if (!rect || !rect.width)
38
+ return null;
39
+ const dx = x - (rect.left + rect.width / 2);
40
+ const dy = y - (rect.top + rect.height / 2);
41
+ if (Math.hypot(dx, dy) < rect.width * 0.2)
42
+ return null;
43
+ const turn = (Math.atan2(dy, dx) * 180) / Math.PI + 90;
44
+ return ((Math.round(turn / (360 / WHEEL_SLOTS)) % WHEEL_SLOTS) + WHEEL_SLOTS) % WHEEL_SLOTS;
45
+ }
46
+ let mounts = 0;
47
+ export function mountEmotes(root, options) {
48
+ const nanoOn = options.nanoterraEmotes !== "off";
49
+ const list = [];
50
+ for (const e of options.emotes ?? EMOTES.map((x) => x.id)) {
51
+ if (typeof e === "string") {
52
+ if (nanoOn && isEmote(e))
53
+ list.push({ id: e, label: emoteOf(e).label, sheet: EMOTES.findIndex((x) => x.id === e), icon: null });
54
+ }
55
+ else if (e && typeof e.id === "string" && typeof e.label === "string")
56
+ list.push({ id: e.id, label: e.label, sheet: -1, icon: typeof e.icon === "string" ? e.icon : null });
57
+ }
58
+ // Each id once: the first wins.
59
+ const items = list.filter((x, i) => list.findIndex((y) => y.id === x.id) === i);
60
+ if (!items.length)
61
+ return NOTHING;
62
+ const byId = new Map(items.map((x) => [x.id, x]));
63
+ const n = ++mounts;
64
+ const sheet = options.icons ?? EMOTE_ICONS_URL;
65
+ const wheel = createWheel({ game: options.game, ids: items.map((x) => x.id), defaults: options.defaults, storage: options.storage });
66
+ const keys = options.keys !== false;
67
+ let isOpen = false;
68
+ let edit = false;
69
+ let full = false;
70
+ let hover = null;
71
+ let mouse = null;
72
+ let destroyed = false;
73
+ const cleanup = [];
74
+ /** An emote's picture: its cell of the strip, or the game's own picture; nothing when it has none. */
75
+ const picture = (id, cls) => {
76
+ const it = byId.get(id);
77
+ if (!it)
78
+ return null;
79
+ if (it.sheet >= 0) {
80
+ const count = EMOTES.length;
81
+ const el = h("span", { class: cls, "aria-hidden": "true" });
82
+ el.style.backgroundImage = `url("${sheet}")`;
83
+ el.style.backgroundSize = `${count * 100}% 100%`;
84
+ el.style.backgroundPosition = `${(it.sheet / (count - 1)) * 100}% 0`;
85
+ return el;
86
+ }
87
+ if (!it.icon)
88
+ return null;
89
+ const el = h("span", { class: `${cls} is-own`, "aria-hidden": "true" });
90
+ el.style.backgroundImage = `url("${it.icon.replace(/["\\\n\r]/g, "")}")`;
91
+ return el;
92
+ };
93
+ const labelOf = (id) => byId.get(id)?.label ?? id;
94
+ const key = h("button", { class: "n133e-key", type: "button", "aria-expanded": "false", "aria-haspopup": "dialog", "aria-controls": `n133e-wheel-${n}` }, icon("smile", 20), h("span", {}, "Emotes"));
95
+ const hubName = h("span", { class: "n133e-hub-name", "aria-live": "polite" });
96
+ const editKey = h("button", { class: "n133e-edit", type: "button", "aria-pressed": "false" }, "Edit");
97
+ const slotKeys = AT.map((p, i) => {
98
+ const b = h("button", { class: "n133e-slot", type: "button", "data-slot": i });
99
+ b.style.setProperty("--x", String(p.x));
100
+ b.style.setProperty("--y", String(p.y));
101
+ b.addEventListener("pointerenter", () => {
102
+ mouse = i;
103
+ drawLit();
104
+ });
105
+ b.addEventListener("pointerleave", () => {
106
+ if (mouse === i)
107
+ mouse = null;
108
+ drawLit();
109
+ });
110
+ b.addEventListener("click", () => pick(i));
111
+ return b;
112
+ });
113
+ const ring = h("div", { class: "n133e-wheel", id: `n133e-wheel-${n}`, role: "dialog", "aria-label": "Emotes" }, h("div", { class: "n133e-ring", "aria-hidden": "true" }), ...slotKeys, h("div", { class: "n133e-hub" }, hubName, editKey));
114
+ const fullLine = h("p", { class: "n133e-list-line", "aria-live": "polite" });
115
+ const grid = h("div", { class: "n133e-grid" });
116
+ const editList = h("div", { class: "n133e-list", role: "group", "aria-label": "All emotes" }, fullLine, grid);
117
+ const layer = h("div", { class: "n133e-layer", "data-open": "false" }, ring, editList);
118
+ layer.hidden = true;
119
+ const wrap = h("div", { class: "n133-emotes" }, key, layer);
120
+ root.append(wrap);
121
+ // The theme: the game's word, or the page's (data-n133-theme, which the admin panel sets too), or the device's setting.
122
+ let media = null;
123
+ try {
124
+ media = typeof globalThis.matchMedia === "function" ? globalThis.matchMedia("(prefers-color-scheme: dark)") : null;
125
+ }
126
+ catch {
127
+ media = null;
128
+ }
129
+ const drawTheme = () => {
130
+ const want = options.theme ?? "device";
131
+ const page = globalThis.document?.documentElement?.getAttribute("data-n133-theme");
132
+ const dark = want === "device" ? (page === "dark" || page === "light" ? page === "dark" : media?.matches === true) : want === "dark";
133
+ wrap.setAttribute("data-theme", dark ? "dark" : "light");
134
+ };
135
+ drawTheme();
136
+ media?.addEventListener?.("change", drawTheme);
137
+ cleanup.push(() => media?.removeEventListener?.("change", drawTheme));
138
+ // The options: `emotes.show` off hides the key; `keys.emotes` is the key that opens the wheel (B unless the player changed it).
139
+ let openCode = "KeyB";
140
+ const drawShow = () => {
141
+ const o = readEmoteOptions(options.options);
142
+ key.hidden = !o.show;
143
+ openCode = o.key;
144
+ if (keys)
145
+ key.setAttribute("aria-keyshortcuts", openCode.replace(/^(Key|Digit)/, ""));
146
+ };
147
+ drawShow();
148
+ const unOptions = options.options?.subscribe?.((k) => {
149
+ if (k === "emotes.show" || k === "keys.emotes")
150
+ drawShow();
151
+ });
152
+ if (unOptions)
153
+ cleanup.push(unOptions);
154
+ function drawLit() {
155
+ const lit = hover ?? mouse;
156
+ const slots = wheel.slots;
157
+ slotKeys.forEach((b, i) => b.classList.toggle("is-lit", lit === i && slots[i] !== null));
158
+ const id = lit !== null ? slots[lit] : null;
159
+ hubName.textContent = id ? labelOf(id) : edit ? "Your wheel" : "Emotes";
160
+ }
161
+ function drawSlots() {
162
+ const slots = wheel.slots;
163
+ slotKeys.forEach((b, i) => {
164
+ const id = slots[i];
165
+ b.classList.toggle("is-empty", !id);
166
+ b.setAttribute("aria-label", id ? (edit ? `Take ${labelOf(id)} off the wheel` : labelOf(id)) : edit ? "Empty place" : "Empty place: edit the wheel");
167
+ if (id && !edit && keys)
168
+ b.setAttribute("aria-keyshortcuts", String(i + 1));
169
+ else
170
+ b.removeAttribute("aria-keyshortcuts");
171
+ b.replaceChildren(...(id
172
+ ? [picture(id, "n133e-pic") ?? "", h("span", { class: "n133e-name" }, labelOf(id)), !edit && keys ? h("span", { class: "n133e-num", "aria-hidden": "true" }, String(i + 1)) : "", edit ? h("span", { class: "n133e-off", "aria-hidden": "true" }, "×") : ""]
173
+ : [h("span", { class: "n133e-plus", "aria-hidden": "true" }, "+")]));
174
+ });
175
+ layer.classList.toggle("is-edit", edit);
176
+ editKey.textContent = edit ? "Done" : "Edit";
177
+ editKey.setAttribute("aria-pressed", String(edit));
178
+ editList.hidden = !edit;
179
+ fullLine.textContent = full ? WHEEL_FULL : `Tap to put on the wheel or take off. Up to ${WHEEL_SLOTS}.`;
180
+ fullLine.classList.toggle("is-full", full);
181
+ if (edit) {
182
+ grid.replaceChildren(...items.map((it) => {
183
+ const at = slots.indexOf(it.id);
184
+ return h("button", { class: `n133e-tile${at >= 0 ? " is-on" : ""}`, type: "button", "aria-pressed": String(at >= 0), "data-emote": it.id, onclick: () => tap(it.id) }, picture(it.id, "n133e-tile-pic") ?? h("span", { class: "n133e-tile-pic is-blank", "aria-hidden": "true" }), h("span", { class: "n133e-tile-name" }, it.label), at >= 0 ? h("span", { class: "n133e-badge", "aria-label": `place ${at + 1}` }, String(at + 1)) : null);
185
+ }));
186
+ }
187
+ drawLit();
188
+ }
189
+ const unWheel = wheel.subscribe(drawSlots);
190
+ cleanup.push(unWheel);
191
+ function tap(id) {
192
+ full = wheel.toggle(id) === "full";
193
+ drawSlots();
194
+ // The tile that was pressed keeps the focus after the grid is drawn again.
195
+ for (const el of Array.from(grid.children))
196
+ if (el.dataset.emote === id)
197
+ el.focus();
198
+ }
199
+ function play(id) {
200
+ close(false);
201
+ options.onPlay(id);
202
+ }
203
+ function pick(i) {
204
+ const id = wheel.slots[i];
205
+ if (edit) {
206
+ if (id)
207
+ wheel.clear(i);
208
+ full = false;
209
+ drawSlots();
210
+ return;
211
+ }
212
+ if (!id) {
213
+ edit = true;
214
+ drawSlots();
215
+ return;
216
+ }
217
+ play(id);
218
+ }
219
+ function open(focusFirst = false) {
220
+ if (destroyed || isOpen)
221
+ return;
222
+ isOpen = true;
223
+ edit = false;
224
+ full = false;
225
+ hover = null;
226
+ mouse = null;
227
+ drawTheme();
228
+ drawSlots();
229
+ layer.hidden = false;
230
+ layer.dataset.open = "true";
231
+ key.setAttribute("aria-expanded", "true");
232
+ options.onOpen?.();
233
+ if (focusFirst)
234
+ slotKeys[Math.max(0, wheel.slots.findIndex((x) => x !== null))].focus();
235
+ }
236
+ function close(focusKey) {
237
+ if (destroyed || !isOpen)
238
+ return;
239
+ isOpen = false;
240
+ hover = null;
241
+ press = null;
242
+ layer.hidden = true;
243
+ layer.dataset.open = "false";
244
+ key.setAttribute("aria-expanded", "false");
245
+ if (focusKey && !key.hidden)
246
+ key.focus();
247
+ }
248
+ editKey.addEventListener("click", () => {
249
+ edit = !edit;
250
+ full = false;
251
+ drawSlots();
252
+ });
253
+ // A press on the dim page beside the wheel closes it.
254
+ layer.addEventListener("pointerdown", (e) => {
255
+ if (e.target === layer)
256
+ close(false);
257
+ });
258
+ // The key: a press opens the wheel at once; a slide from it onto a place and a let-go plays that place.
259
+ let press = null;
260
+ key.addEventListener("pointerdown", (e) => {
261
+ if (e.button !== 0 || isOpen)
262
+ return;
263
+ open();
264
+ press = { id: e.pointerId, x: e.clientX, y: e.clientY, slid: false };
265
+ try {
266
+ key.setPointerCapture(e.pointerId);
267
+ }
268
+ catch {
269
+ // A pointer that is already gone: the wheel stays open for a tap.
270
+ }
271
+ });
272
+ key.addEventListener("pointermove", (e) => {
273
+ const p = press;
274
+ if (!p || p.id !== e.pointerId)
275
+ return;
276
+ if (!p.slid && Math.hypot(e.clientX - p.x, e.clientY - p.y) > SLIDE_PX)
277
+ p.slid = true;
278
+ if (p.slid) {
279
+ hover = placeAt(ring.getBoundingClientRect(), e.clientX, e.clientY);
280
+ drawLit();
281
+ }
282
+ });
283
+ key.addEventListener("pointerup", (e) => {
284
+ const p = press;
285
+ press = null;
286
+ if (!p || !p.slid || p.id !== e.pointerId)
287
+ return;
288
+ const i = placeAt(ring.getBoundingClientRect(), e.clientX, e.clientY);
289
+ const id = i === null ? null : wheel.slots[i];
290
+ if (id)
291
+ play(id);
292
+ else {
293
+ hover = null;
294
+ drawLit();
295
+ }
296
+ });
297
+ key.addEventListener("pointercancel", () => {
298
+ press = null;
299
+ hover = null;
300
+ drawLit();
301
+ });
302
+ // Enter or Space is a click with no pointer: it opens and closes the wheel. A pointer's click is already handled.
303
+ key.addEventListener("click", (e) => {
304
+ if (e.detail !== 0)
305
+ return;
306
+ if (isOpen)
307
+ close(true);
308
+ else
309
+ open(true);
310
+ });
311
+ const onKey = (e) => {
312
+ if (destroyed)
313
+ return;
314
+ if (e.key === "Escape" && isOpen) {
315
+ e.preventDefault();
316
+ close(true);
317
+ return;
318
+ }
319
+ if (!keys || e.repeat || e.ctrlKey || e.metaKey || e.altKey || inField(e.target))
320
+ return;
321
+ if (e.code === openCode) {
322
+ if (options.keyAllowed && !options.keyAllowed(e))
323
+ return;
324
+ e.preventDefault();
325
+ if (isOpen)
326
+ close(true);
327
+ else
328
+ open(true);
329
+ return;
330
+ }
331
+ const i = DIGITS[e.code];
332
+ if (i === undefined || !isOpen || edit)
333
+ return;
334
+ const id = wheel.slots[i];
335
+ if (!id)
336
+ return;
337
+ e.preventDefault();
338
+ play(id);
339
+ };
340
+ document.addEventListener("keydown", onKey);
341
+ cleanup.push(() => document.removeEventListener("keydown", onKey));
342
+ return {
343
+ open: () => open(false),
344
+ close: () => close(false),
345
+ get isOpen() {
346
+ return isOpen;
347
+ },
348
+ get slots() {
349
+ return wheel.slots;
350
+ },
351
+ destroy() {
352
+ if (destroyed)
353
+ return;
354
+ destroyed = true;
355
+ for (const f of cleanup)
356
+ f();
357
+ wrap.remove();
358
+ },
359
+ };
360
+ }
@@ -0,0 +1,37 @@
1
+ import type { Character } from "../character/create.js";
2
+ import { type EmoteEvent, type EmoteStop } from "./events.js";
3
+ import { type EmoteOptionsStore } from "./options.js";
4
+ export { emoteClip, emotesLoaded, loadEmotes, FADE_END, FADE_IN, FADE_STOP } from "./clip.js";
5
+ export type EmotesOptions = {
6
+ /** The player played an emote (and again every 10 s while a loop plays): send `emoteMessage(e)` to the other players. */
7
+ onEmote?: (e: EmoteEvent) => void;
8
+ /** 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`. */
9
+ onStop?: (e: EmoteStop) => void;
10
+ };
11
+ export type LocalEmotes = {
12
+ /** Plays a Nanoterra emote on the player's character (the wheel's onPlay). False, and nothing happens, for an id that is not one. */
13
+ play(id: string): boolean;
14
+ /** Stops the player's clip, with its fade. A game with water calls it when the character goes in. */
15
+ stop(): void;
16
+ /** The clip on show, or null (the Wave is not a clip: it is never here). */
17
+ readonly playing: string | null;
18
+ /** Stops listening (`onEmote`, `onStop`). The character keeps playing what it plays. */
19
+ destroy(): void;
20
+ };
21
+ /** The local player's emotes on their character, with the events a multiplayer game sends on. */
22
+ export declare function createEmotes(character: Character, options?: EmotesOptions): LocalEmotes;
23
+ /** Another player's emote: its id, and when it came (performance.now(); left out: now). */
24
+ export type RemotePlay = {
25
+ id: unknown;
26
+ at?: number;
27
+ };
28
+ /**
29
+ * 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
30
+ * 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
31
+ * options store whose `emotes.others` is off, nothing plays.
32
+ */
33
+ export declare function playRemote(character: Character, play: RemotePlay, opts?: {
34
+ options?: EmoteOptionsStore | null;
35
+ }): boolean;
36
+ /** 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). */
37
+ export declare function stopEmote(character: Character): void;
@@ -0,0 +1,101 @@
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
+ import { setPoseLayer } from "../character/layer.js";
11
+ import { newRun, startClip, stepClip, stopClip } from "./clip.js";
12
+ import { LOOP_AGAIN_MS } from "./events.js";
13
+ import { emoteOf, isEmote } from "./list.js";
14
+ import { readEmoteOptions } from "./options.js";
15
+ export { emoteClip, emotesLoaded, loadEmotes, FADE_END, FADE_IN, FADE_STOP } from "./clip.js";
16
+ /** How fast a character must go before a full clip stops (m/s, the pose's eased speed): the island's. */
17
+ const MOVING = 0.4;
18
+ const states = new WeakMap();
19
+ const now = () => (typeof performance !== "undefined" ? performance.now() : Date.now());
20
+ /** The character's state, and its layer, made on its first emote. */
21
+ function stateOf(character) {
22
+ let st = states.get(character);
23
+ if (st)
24
+ return st;
25
+ const s = { run: newRun(), seen: Number.NaN, sent: 0, local: null };
26
+ setPoseLayer(character, (bones, f) => {
27
+ const was = s.run.id;
28
+ stepClip(s.run, bones, { dt: f.dt, moving: f.speed > MOVING, air: f.airborne, swim: false, calm: f.reducedMotion });
29
+ if (!s.local)
30
+ return;
31
+ if (was && !s.run.id && s.run.ended)
32
+ s.local.onStop?.({ id: was, at: now(), why: s.run.ended });
33
+ else if (s.run.id && emoteOf(s.run.id)?.loop && s.run.stopAge < 0 && s.run.age - s.sent >= LOOP_AGAIN_MS / 1000) {
34
+ // A loop is sent again while it plays, so a player who comes later sees it.
35
+ s.sent = s.run.age;
36
+ s.local.onEmote?.({ id: s.run.id, loop: true, at: now(), again: true });
37
+ }
38
+ });
39
+ states.set(character, s);
40
+ st = s;
41
+ return st;
42
+ }
43
+ /** 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. */
44
+ function start(character, id, age = 0) {
45
+ if (!isEmote(id))
46
+ return false;
47
+ if (id === "wave") {
48
+ character.wave();
49
+ return true;
50
+ }
51
+ const st = stateOf(character);
52
+ startClip(st.run, id, age);
53
+ return true;
54
+ }
55
+ /** The local player's emotes on their character, with the events a multiplayer game sends on. */
56
+ export function createEmotes(character, options = {}) {
57
+ const st = stateOf(character);
58
+ st.local = options;
59
+ return {
60
+ play(id) {
61
+ if (!start(character, id))
62
+ return false;
63
+ st.sent = 0;
64
+ options.onEmote?.({ id, loop: emoteOf(id)?.loop === true, at: now(), again: false });
65
+ return true;
66
+ },
67
+ stop() {
68
+ stopClip(st.run);
69
+ },
70
+ get playing() {
71
+ return st.run.id;
72
+ },
73
+ destroy() {
74
+ if (st.local === options)
75
+ st.local = null;
76
+ },
77
+ };
78
+ }
79
+ /**
80
+ * 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
81
+ * 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
82
+ * options store whose `emotes.others` is off, nothing plays.
83
+ */
84
+ export function playRemote(character, play, opts = {}) {
85
+ if (!play || typeof play !== "object" || !isEmote(play.id))
86
+ return false;
87
+ if (!readEmoteOptions(opts.options).others)
88
+ return false;
89
+ const at = typeof play.at === "number" && Number.isFinite(play.at) ? play.at : now();
90
+ const st = stateOf(character);
91
+ if (st.seen === at)
92
+ return false;
93
+ st.seen = at;
94
+ return start(character, play.id, (now() - at) / 1000);
95
+ }
96
+ /** 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). */
97
+ export function stopEmote(character) {
98
+ const st = states.get(character);
99
+ if (st)
100
+ stopClip(st.run);
101
+ }
@@ -0,0 +1,42 @@
1
+ export declare const WHEEL_SLOTS = 8;
2
+ /** The localStorage key of a game's wheel. */
3
+ export declare const wheelKey: (game: string) => string;
4
+ /** A wheel: eight places, each an emote's id once, or null (empty). */
5
+ export type Slots = readonly (string | null)[];
6
+ /** Nanoterra's wheel before the player changes it (the island's): the eight most social emotes. Bow, Shrug and Facepalm wait in Edit. */
7
+ export declare const DEFAULT_WHEEL: Slots;
8
+ /** What the player is told when they put a ninth emote on the wheel. */
9
+ export declare const WHEEL_FULL = "Your wheel is full. Take one off first.";
10
+ /** 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. */
11
+ export declare function defaultWheel(ids: readonly string[], defaults?: readonly (string | null)[]): Slots;
12
+ /** A kept wheel: eight places, each an emote of `ids` once or empty. Anything else (no key, broken text, another length) is `fallback`. */
13
+ export declare function readWheel(raw: string | null | undefined, ids: readonly string[], fallback: Slots): Slots;
14
+ /** The emote put on (in the first empty place), taken off, or refused because all eight places are full. */
15
+ export declare function toggleSlot(slots: Slots, id: string): {
16
+ slots: Slots;
17
+ did: "added" | "removed" | "full";
18
+ };
19
+ type Store = Pick<Storage, "getItem" | "setItem">;
20
+ export type WheelOptions = {
21
+ /** The game's id: each game keeps its own wheel. */
22
+ game: string;
23
+ /** Every emote the player may put on the wheel, by id. Default: Nanoterra's eleven. */
24
+ ids?: readonly string[];
25
+ /** The eight places before the player changes them. Default: see `defaultWheel`. */
26
+ defaults?: readonly (string | null)[];
27
+ /** Where the wheel is kept (default: the page's localStorage). null keeps it only for the life of the page. */
28
+ storage?: Store | null;
29
+ };
30
+ export type Wheel = {
31
+ /** The eight places now. */
32
+ readonly slots: Slots;
33
+ /** Puts an emote on (its first empty place) or takes it off; "full" changes nothing. */
34
+ toggle(id: string): "added" | "removed" | "full";
35
+ /** Takes the emote off a place. */
36
+ clear(slot: number): void;
37
+ /** Hears every change. Returns the call that stops it. */
38
+ subscribe(listen: (slots: Slots) => void): () => void;
39
+ };
40
+ /** A game's wheel, read from this browser (or the default), and kept there on each change. */
41
+ export declare function createWheel(options: WheelOptions): Wheel;
42
+ export {};
@@ -0,0 +1,110 @@
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
+ import { EMOTES } from "./list.js";
6
+ export const WHEEL_SLOTS = 8;
7
+ /** The localStorage key of a game's wheel. */
8
+ export const wheelKey = (game) => `n133-emotes:${game}`;
9
+ /** Nanoterra's wheel before the player changes it (the island's): the eight most social emotes. Bow, Shrug and Facepalm wait in Edit. */
10
+ export const DEFAULT_WHEEL = ["wave", "cheer", "clap", "dance", "laugh", "point", "sit", "spin"];
11
+ /** What the player is told when they put a ninth emote on the wheel. */
12
+ export const WHEEL_FULL = "Your wheel is full. Take one off first.";
13
+ /** Eight places from a list (cut, or filled with empty places), each id of `known` once. */
14
+ function fit(list, known) {
15
+ const seen = new Set();
16
+ return Array.from({ length: WHEEL_SLOTS }, (_, i) => {
17
+ const x = list[i];
18
+ if (typeof x !== "string" || !known.has(x) || seen.has(x))
19
+ return null;
20
+ seen.add(x);
21
+ return x;
22
+ });
23
+ }
24
+ /** 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. */
25
+ export function defaultWheel(ids, defaults) {
26
+ const known = new Set(ids);
27
+ if (defaults)
28
+ return fit(defaults, known);
29
+ const island = fit(DEFAULT_WHEEL, known);
30
+ return island.some((x) => x !== null) ? island : fit(ids, known);
31
+ }
32
+ /** A kept wheel: eight places, each an emote of `ids` once or empty. Anything else (no key, broken text, another length) is `fallback`. */
33
+ export function readWheel(raw, ids, fallback) {
34
+ if (!raw)
35
+ return fallback;
36
+ try {
37
+ const v = JSON.parse(raw);
38
+ if (!Array.isArray(v) || v.length !== WHEEL_SLOTS)
39
+ return fallback;
40
+ return fit(v, new Set(ids));
41
+ }
42
+ catch {
43
+ return fallback;
44
+ }
45
+ }
46
+ /** The emote put on (in the first empty place), taken off, or refused because all eight places are full. */
47
+ export function toggleSlot(slots, id) {
48
+ const at = slots.indexOf(id);
49
+ if (at >= 0)
50
+ return { slots: slots.map((x, i) => (i === at ? null : x)), did: "removed" };
51
+ const free = slots.indexOf(null);
52
+ if (free < 0)
53
+ return { slots, did: "full" };
54
+ return { slots: slots.map((x, i) => (i === free ? id : x)), did: "added" };
55
+ }
56
+ /** The page's localStorage, or null where reading it throws. */
57
+ function pageStorage() {
58
+ try {
59
+ return globalThis.localStorage ?? null;
60
+ }
61
+ catch {
62
+ return null;
63
+ }
64
+ }
65
+ /** A game's wheel, read from this browser (or the default), and kept there on each change. */
66
+ export function createWheel(options) {
67
+ const ids = options.ids ?? EMOTES.map((e) => e.id);
68
+ const storage = options.storage === undefined ? pageStorage() : options.storage;
69
+ const key = wheelKey(options.game);
70
+ let raw = null;
71
+ try {
72
+ raw = storage?.getItem(key) ?? null;
73
+ }
74
+ catch {
75
+ raw = null;
76
+ }
77
+ let slots = readWheel(raw, ids, defaultWheel(ids, options.defaults));
78
+ const listeners = new Set();
79
+ const set = (next) => {
80
+ slots = next;
81
+ try {
82
+ storage?.setItem(key, JSON.stringify(next));
83
+ }
84
+ catch {
85
+ // A full or refused storage: the wheel still works for this page.
86
+ }
87
+ for (const l of listeners)
88
+ l(slots);
89
+ };
90
+ return {
91
+ get slots() {
92
+ return slots;
93
+ },
94
+ toggle(id) {
95
+ const r = toggleSlot(slots, id);
96
+ if (r.did !== "full")
97
+ set(r.slots);
98
+ return r.did;
99
+ },
100
+ clear(slot) {
101
+ if (slots[slot] == null)
102
+ return;
103
+ set(slots.map((x, i) => (i === slot ? null : x)));
104
+ },
105
+ subscribe(listen) {
106
+ listeners.add(listen);
107
+ return () => listeners.delete(listen);
108
+ },
109
+ };
110
+ }
@@ -12,15 +12,15 @@ export type FriendsMountOptions = {
12
12
  /**
13
13
  * The player chose to join a friend. "nanoterra": `target` is the friend's https address (the kit has asked the player already) and there is no
14
14
  * `friend` (the window sends no name); with no `onJoin` the page goes there. "own": `target` is the `where` of the friend's invite, for the game to
15
- * move the player, and `friend` names them (with no `onJoin`, there is no Join key).
15
+ * move the player, and `friend` names them (with no `onJoin`, there is no Join key and no Invite key: an invite would lead nowhere).
16
16
  */
17
17
  onJoin?: (target: string, friend?: {
18
18
  name: string;
19
19
  }) => void;
20
20
  /** The `fetch` to use (default: the page's). */
21
21
  fetch?: typeof fetch;
22
- /** Asks the player a yes or no question (default: `window.confirm`). */
23
- confirm?: (question: string) => boolean;
22
+ /** Asks the player a yes or no question (default: a question in the panel, with a key for each answer; never a browser dialog). */
23
+ confirm?: (question: string) => boolean | Promise<boolean>;
24
24
  };
25
25
  export type MountedFriends = {
26
26
  /** Sets where the player is (friends see it). Null: no place. */