@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
@@ -32,13 +32,13 @@ export type FriendsMountOptions = {
32
32
  /**
33
33
  * 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
34
34
  * `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
35
- * move the player, and `friend` names them (with no `onJoin`, there is no Join key).
35
+ * move the player, and `friend` names them (with no `onJoin`, there is no Join key and no Invite key: an invite would lead nowhere).
36
36
  */
37
37
  onJoin?: (target: string, friend?: { name: string }) => void;
38
38
  /** The `fetch` to use (default: the page's). */
39
39
  fetch?: typeof fetch;
40
- /** Asks the player a yes or no question (default: `window.confirm`). */
41
- confirm?: (question: string) => boolean;
40
+ /** Asks the player a yes or no question (default: a question in the panel, with a key for each answer; never a browser dialog). */
41
+ confirm?: (question: string) => boolean | Promise<boolean>;
42
42
  };
43
43
 
44
44
  export type MountedFriends = {
@@ -60,7 +60,6 @@ export function mountFriends(root: HTMLElement, options: FriendsMountOptions): M
60
60
  if (options.mode !== "nanoterra" && options.mode !== "own") return NOTHING;
61
61
  const n = ++mounts;
62
62
  const origin = (options.origin ?? "https://nano133.com").replace(/\/+$/, "");
63
- const ask = options.confirm ?? ((q: string) => window.confirm(q));
64
63
  let where = options.where === undefined ? null : whereText(options.where);
65
64
  let isOpen = false;
66
65
  let destroyed = false;
@@ -73,7 +72,12 @@ export function mountFriends(root: HTMLElement, options: FriendsMountOptions): M
73
72
  const title = h("h2", { class: "n133f-title", id: `n133f-title-${n}` }, "Friends");
74
73
  const closeKey = h("button", { class: "n133f-close", type: "button", "aria-label": "Close friends" }, icon("close", 20));
75
74
  const body = h("div", { class: "n133f-body" });
76
- const panel = h("section", { class: `n133f-panel n133f-${options.mode}`, id: `n133f-panel-${n}`, role: "dialog", "aria-labelledby": `n133f-title-${n}`, "data-open": "false" }, h("header", { class: "n133f-head" }, title, closeKey), body);
75
+ const question = h("p", { class: "n133f-ask-q", id: `n133f-ask-${n}` });
76
+ const yesKey = h("button", { class: "n133f-btn n133f-primary", type: "button" });
77
+ const noKey = h("button", { class: "n133f-btn", type: "button" }, "Cancel");
78
+ const askBox = h("div", { class: "n133f-ask", role: "alertdialog", "aria-labelledby": `n133f-ask-${n}` }, question, h("div", { class: "n133f-ask-keys" }, yesKey, noKey));
79
+ askBox.hidden = true;
80
+ const panel = h("section", { class: `n133f-panel n133f-${options.mode}`, id: `n133f-panel-${n}`, role: "dialog", "aria-labelledby": `n133f-title-${n}`, "data-open": "false" }, h("header", { class: "n133f-head" }, title, closeKey), askBox, body);
77
81
  const wrap = h("div", { class: "n133-friends" }, key, panel);
78
82
  root.append(wrap);
79
83
 
@@ -99,6 +103,7 @@ export function mountFriends(root: HTMLElement, options: FriendsMountOptions): M
99
103
  };
100
104
  const close = () => {
101
105
  if (destroyed || !isOpen) return;
106
+ answer?.(false);
102
107
  isOpen = false;
103
108
  panel.dataset.open = "false";
104
109
  key.setAttribute("aria-expanded", "false");
@@ -107,8 +112,34 @@ export function mountFriends(root: HTMLElement, options: FriendsMountOptions): M
107
112
  };
108
113
  key.addEventListener("click", () => (isOpen ? close() : open()));
109
114
  closeKey.addEventListener("click", close);
115
+
116
+ /** The question in the panel: one at a time (a new one answers the old with no), and closing the panel answers no. */
117
+ let answer: ((yes: boolean) => void) | null = null;
118
+ const askInPanel = (q: string, yesWord: string) =>
119
+ new Promise<boolean>((resolve) => {
120
+ answer?.(false);
121
+ question.textContent = q;
122
+ yesKey.textContent = yesWord;
123
+ askBox.hidden = false;
124
+ answer = (yes) => {
125
+ answer = null;
126
+ askBox.hidden = true;
127
+ resolve(yes);
128
+ if (isOpen && !destroyed) closeKey.focus();
129
+ };
130
+ open();
131
+ yesKey.focus();
132
+ });
133
+ yesKey.addEventListener("click", () => answer?.(true));
134
+ noKey.addEventListener("click", () => answer?.(false));
135
+ const ask = (q: string, yesWord: string): Promise<boolean> => (options.confirm ? Promise.resolve(options.confirm(q)) : askInPanel(q, yesWord));
136
+ cleanup.push(() => answer?.(false));
137
+
110
138
  const onKey = (e: KeyboardEvent) => {
111
- if (e.key === "Escape" && isOpen) close();
139
+ if (e.key !== "Escape" || !isOpen) return;
140
+ // Escape answers an open question first, then closes the panel.
141
+ if (answer) answer(false);
142
+ else close();
112
143
  };
113
144
  document.addEventListener("keydown", onKey);
114
145
  cleanup.push(() => document.removeEventListener("keydown", onKey));
@@ -165,9 +196,12 @@ export function mountFriends(root: HTMLElement, options: FriendsMountOptions): M
165
196
  frame.style.height = `${room > 0 ? Math.min(m.height, room) : m.height}px`;
166
197
  } else if (m.type === "badge") setBadge(m);
167
198
  else if (m.type === "join") {
168
- if (!ask("Leave this game to join your friend?")) return;
169
- if (options.onJoin) options.onJoin(m.url);
170
- else window.location.assign(m.url);
199
+ const url = m.url;
200
+ void ask("Leave this game to join your friend?", "Join").then((yes) => {
201
+ if (!yes || destroyed) return;
202
+ if (options.onJoin) options.onJoin(url);
203
+ else window.location.assign(url);
204
+ });
171
205
  }
172
206
  // "signin": the window needs a tap (Storage Access) inside itself. The panel stays as it is; the kit does nothing else.
173
207
  };
@@ -198,7 +232,7 @@ export function mountFriends(root: HTMLElement, options: FriendsMountOptions): M
198
232
  }
199
233
 
200
234
  /** The game's own list in `body`. */
201
- function ownList(body: HTMLElement, options: FriendsMountOptions, whereNow: () => string | null, setBadge: (n: { requests: number; online: number }) => void, ask: (q: string) => boolean) {
235
+ function ownList(body: HTMLElement, options: FriendsMountOptions, whereNow: () => string | null, setBadge: (n: { requests: number; online: number }) => void, ask: (q: string, yesWord: string) => Promise<boolean>) {
202
236
  const api: Api = createApi(options.base ?? "/friends", options.fetch);
203
237
  const lease = Array.from(globalThis.crypto.getRandomValues(new Uint8Array(16)), (b) => b.toString(16).padStart(2, "0")).join("");
204
238
  const status = h("p", { class: "n133f-status", role: "status", "aria-live": "polite" });
@@ -253,9 +287,9 @@ function ownList(body: HTMLElement, options: FriendsMountOptions, whereNow: () =
253
287
  if (p.state === "friend") {
254
288
  if (p.invite && options.onJoin) keys.push(btn("Join", () => options.onJoin!(p.invite!.where, { name: p.name }), "n133f-primary"));
255
289
  const here = whereNow();
256
- if (here) keys.push(btn("Invite", () => void act({ op: "invite", edge: p.edge, where: here }, `Invited ${p.name}.`)));
257
- keys.push(btn("Remove", () => ask(`Remove ${p.name} from your friends?`) && void act({ op: "remove", edge: p.edge })));
258
- keys.push(btn("Block", () => ask(`Block ${p.name}? They will not see you or be able to ask you again.`) && void act({ op: "block", edge: p.edge }), "n133f-quiet"));
290
+ if (here && options.onJoin) keys.push(btn("Invite", () => void act({ op: "invite", edge: p.edge, where: here }, `Invited ${p.name}.`)));
291
+ keys.push(btn("Remove", () => void ask(`Remove ${p.name} from your friends?`, "Remove").then((yes) => (yes ? act({ op: "remove", edge: p.edge }) : undefined))));
292
+ keys.push(btn("Block", () => void ask(`Block ${p.name}? They will not see you or be able to ask you again.`, "Block").then((yes) => (yes ? act({ op: "block", edge: p.edge }) : undefined)), "n133f-quiet"));
259
293
  }
260
294
  if (p.state === "outgoing") keys.push(btn("Cancel", () => void act({ op: "decline", edge: p.edge })));
261
295
  if (p.state === "blocked") keys.push(btn("Unblock", () => void act({ op: "unblock", edge: p.edge })));
@@ -202,6 +202,32 @@
202
202
  outline-offset: 2px;
203
203
  }
204
204
 
205
+ /* The panel's own question (Remove, Block, Join): never a browser dialog. */
206
+ .n133-friends .n133f-ask {
207
+ flex: 0 0 auto;
208
+ margin: 12px 16px 0;
209
+ padding: 12px;
210
+ border: 1px solid var(--gem-dark);
211
+ border-radius: 14px;
212
+ background: var(--paper);
213
+ }
214
+
215
+ .n133-friends .n133f-ask[hidden] {
216
+ display: none;
217
+ }
218
+
219
+ .n133-friends .n133f-ask-q {
220
+ margin: 0 0 10px;
221
+ font-weight: 650;
222
+ overflow-wrap: anywhere;
223
+ }
224
+
225
+ .n133-friends .n133f-ask-keys {
226
+ display: flex;
227
+ flex-wrap: wrap;
228
+ gap: 8px;
229
+ }
230
+
205
231
  .n133-friends .n133f-status:empty {
206
232
  display: none;
207
233
  }
@@ -0,0 +1,65 @@
1
+ // The settings most games have, ready to opt into by key: `common("masterVolume", "musicVolume", "quality")`. They are plain defs: the game reads them and
2
+ // does what they say (the kit plays no sound and draws no frame). A game changes one by spreading it: `{ ...COMMON_OPTIONS.quality, default: "low" }`.
3
+ // The words and ranges follow nano133's island and Echo Island (Wes, 2026-10-30: Sound effects and Music sliders; the island's Settings rows).
4
+
5
+ import type { OptionDef, RangeDef } from "./core.js";
6
+
7
+ const volume = (key: string, label: string, help?: string): RangeDef => ({ kind: "range", key, label, ...(help ? { help } : {}), tab: "basic", group: "Sound", min: 0, max: 100, step: 5, unit: "%", default: 100 });
8
+
9
+ export const COMMON_OPTIONS = {
10
+ /** 0 to 100 (%), every sound. A game multiplies it into the others; the square of it is a fair loudness curve (`level ** 2`). */
11
+ masterVolume: volume("masterVolume", "Volume"),
12
+ /** 0 to 100 (%), the music only. */
13
+ musicVolume: volume("musicVolume", "Music"),
14
+ /** 0 to 100 (%), every sound but the music. */
15
+ effectsVolume: volume("effectsVolume", "Sound effects"),
16
+ /** 10 to 100, how fast a drag or the mouse turns the camera; 50 is the game's own speed. */
17
+ cameraSensitivity: { kind: "range", key: "cameraSensitivity", label: "Camera speed", tab: "basic", group: "Camera", min: 10, max: 100, step: 5, default: 50 },
18
+ /** Up and down of the camera swapped. */
19
+ invertLook: { kind: "toggle", key: "invertLook", label: "Invert look", help: "Up and down are swapped", tab: "advanced", group: "Camera", default: false },
20
+ /** "left" or "right": the side of a touch screen the move stick is on (the camera drag is on the other). Shown on a phone and a tablet only. */
21
+ stickSide: {
22
+ kind: "choice",
23
+ key: "stickSide",
24
+ label: "Move stick",
25
+ help: "The side of the screen that moves you",
26
+ tab: "basic",
27
+ group: "Controls",
28
+ choices: [
29
+ { value: "left", label: "Left" },
30
+ { value: "right", label: "Right" },
31
+ ],
32
+ default: "left",
33
+ devices: ["phone", "tablet"],
34
+ },
35
+ /** Fewer shakes, swings and flashes. On by default when the device asks for less motion. */
36
+ reducedMotion: { kind: "toggle", key: "reducedMotion", label: "Less motion", help: "Fewer shakes, swings and flashes", tab: "advanced", group: "Comfort", default: { default: false, reducedMotion: true } },
37
+ /** "low", "medium" or "high": the game decides what each draws. Medium on a phone. */
38
+ quality: {
39
+ kind: "choice",
40
+ key: "quality",
41
+ label: "Quality",
42
+ tab: "advanced",
43
+ group: "Graphics",
44
+ choices: [
45
+ { value: "low", label: "Low" },
46
+ { value: "medium", label: "Medium" },
47
+ { value: "high", label: "High" },
48
+ ],
49
+ default: { phone: "medium", default: "high" },
50
+ },
51
+ /** A small frames-a-second counter on the screen. */
52
+ showFps: { kind: "toggle", key: "showFps", label: "Show frame rate", tab: "advanced", group: "Graphics", default: false },
53
+ } satisfies Record<string, OptionDef>;
54
+
55
+ export type CommonKey = keyof typeof COMMON_OPTIONS;
56
+ export const COMMON_KEYS = Object.keys(COMMON_OPTIONS) as CommonKey[];
57
+
58
+ /** The common defs for these keys, in the order given (a copy: change it freely). */
59
+ export function common(...keys: CommonKey[]): OptionDef[] {
60
+ return keys.map((k) => {
61
+ const def = COMMON_OPTIONS[k] as OptionDef | undefined;
62
+ if (!def) throw new TypeError(`common: "${String(k)}" is not a common setting (${COMMON_KEYS.join(", ")})`);
63
+ return { ...def } as OptionDef;
64
+ });
65
+ }