@nano133/nanoterra 0.7.1 → 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 (92) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +202 -9
  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/options/common.d.ts +88 -0
  48. package/dist/options/common.js +59 -0
  49. package/dist/options/core.d.ts +130 -0
  50. package/dist/options/core.js +420 -0
  51. package/dist/options/index.d.ts +7 -0
  52. package/dist/options/index.js +24 -0
  53. package/dist/options/panel/index.d.ts +2 -0
  54. package/dist/options/panel/index.js +10 -0
  55. package/dist/options/panel/mount.d.ts +30 -0
  56. package/dist/options/panel/mount.js +378 -0
  57. package/dist/options/reflect.d.ts +5 -0
  58. package/dist/options/reflect.js +27 -0
  59. package/package.json +12 -4
  60. package/src/admin/adapters/d1.ts +5 -4
  61. package/src/admin/adapters/firestore.ts +6 -5
  62. package/src/admin/conformance.ts +14 -5
  63. package/src/admin/handler.ts +2 -2
  64. package/src/admin/index.ts +2 -2
  65. package/src/admin/memory.ts +2 -1
  66. package/src/admin/panel/gameCard.ts +13 -9
  67. package/src/admin/panel/icons.ts +3 -0
  68. package/src/admin/panel/overview.ts +1 -1
  69. package/src/admin/store.ts +2 -2
  70. package/src/admin/switches.ts +29 -12
  71. package/src/character/create.ts +6 -2
  72. package/src/character/layer.ts +21 -0
  73. package/src/emotes/clip.ts +204 -0
  74. package/src/emotes/clips.ts +7 -0
  75. package/src/emotes/events.ts +28 -0
  76. package/src/emotes/icons.ts +7 -0
  77. package/src/emotes/icons.webp +0 -0
  78. package/src/emotes/index.ts +16 -0
  79. package/src/emotes/list.ts +33 -0
  80. package/src/emotes/options.ts +43 -0
  81. package/src/emotes/panel/index.ts +11 -0
  82. package/src/emotes/panel/mount.ts +390 -0
  83. package/src/emotes/panel/style.css +415 -0
  84. package/src/emotes/player.ts +131 -0
  85. package/src/emotes/wheel.ts +131 -0
  86. package/src/options/common.ts +65 -0
  87. package/src/options/core.ts +465 -0
  88. package/src/options/index.ts +41 -0
  89. package/src/options/panel/index.ts +12 -0
  90. package/src/options/panel/mount.ts +418 -0
  91. package/src/options/panel/style.css +528 -0
  92. package/src/options/reflect.ts +28 -0
@@ -11,4 +11,4 @@ export { createMemoryStore } from "./memory.js";
11
11
  export { ADMIN_LOGINS_SHOWN } from "./store.js";
12
12
  export { ORDERS_SHOWN, moneyOf, parseRate, shapeOrders, shapePlayers, statusOf, summarize } from "./summary.js";
13
13
  export { RAW_PER_XNO, xnoText } from "./amount.js";
14
- export { CHARACTER_NEEDS_SIGNIN, CHARACTER_SIGNIN_OFF, DEFAULT_FRIENDS, FRIENDS_CHOICES, FRIENDS_TITLE, REVIEW_LINK, REVIEW_NOTE, SWITCHES, isCharacter, isFriends, isSignin, isSigninChoice, mergeSettings, parsePatch, pressOf, signinOn, stateOf } from "./switches.js";
14
+ export { CHARACTER_NEEDS_SIGNIN, CHARACTER_SIGNIN_OFF, DEFAULT_EMOTES, DEFAULT_FRIENDS, FRIENDS_CHOICES, FRIENDS_TITLE, REVIEW_LINK, REVIEW_NOTE, SWITCHES, isCharacter, isEmotes, isFriends, isSignin, isSigninChoice, mergeSettings, parsePatch, pressOf, signinOn, stateOf } from "./switches.js";
@@ -14,7 +14,7 @@ export function createMemoryStore(seed = {}) {
14
14
  pendingAdminRequests: 0,
15
15
  pendingGameRequests: 0,
16
16
  async readSettings() {
17
- return { character: store.rows.get("setting:character"), signin: store.rows.get("setting:signin"), friends: store.rows.get("setting:friends") };
17
+ return { character: store.rows.get("setting:character"), signin: store.rows.get("setting:signin"), friends: store.rows.get("setting:friends"), emotes: store.rows.get("setting:emotes") };
18
18
  },
19
19
  async saveSettings(patch) {
20
20
  if (patch.character)
@@ -23,6 +23,8 @@ export function createMemoryStore(seed = {}) {
23
23
  store.rows.set("setting:signin", patch.signin);
24
24
  if (patch.friends)
25
25
  store.rows.set("setting:friends", patch.friends);
26
+ if (patch.emotes)
27
+ store.rows.set("setting:emotes", patch.emotes);
26
28
  store.writes++;
27
29
  },
28
30
  async summary(now) {
@@ -1,7 +1,7 @@
1
- // The "Game" card of /admin: two switches, "Players can play as their Nanoterra character" and "Players can sign in with Nanoterra", and one three-way
2
- // choice, "Friends list: Nanoterra / The game's own / Off". A press saves at once (POST {api}/game, behind the same admin guard as every admin call) and
3
- // the game reads the setting at run time, so the creator pushes and deploys nothing. The words are in ../switches.ts. The card changes ONLY these three
4
- // settings: never a look, a price or an order.
1
+ // The "Game" card of /admin: two switches, "Players can play as their Nanoterra character" and "Players can sign in with Nanoterra", one three-way
2
+ // choice, "Friends list: Nanoterra / The game's own / Off", and the switch "Allow Nanoterra emotes". A press saves at once (POST {api}/game, behind the
3
+ // same admin guard as every admin call) and the game reads the setting at run time, so the creator pushes and deploys nothing. The words are in
4
+ // ../switches.ts. The card changes ONLY these four settings: never a look, a price or an order.
5
5
  import { CHARACTER_NEEDS_SIGNIN, CHARACTER_SIGNIN_OFF, FRIENDS_CHOICES, FRIENDS_TITLE, REVIEW_LINK, REVIEW_NOTE, SWITCHES, pressOf, signinOn, stateOf } from "../switches.js";
6
6
  import { ApiError } from "./api.js";
7
7
  import { h } from "./dom.js";
@@ -15,7 +15,7 @@ export function gameCard(api, initial, defaultsFile = "the game's own settings",
15
15
  /** Under the character switch: it works only through sign-in, so it says so, and says what a player gets while sign-in is Off. */
16
16
  const needs = h("p", { class: "fine switch-needs" });
17
17
  const card = h("section", { class: "admin-card game-card", "aria-labelledby": "game-title" }, h("div", { class: "card-head" }, h("h2", { id: "game-title" }, "Game")));
18
- card.append(h("p", { class: "fine card-intro" }, `Three choices about your game. A press saves at once: you push nothing and deploy nothing. They start from ${defaultsFile}, and a press here wins.`));
18
+ card.append(h("p", { class: "fine card-intro" }, `Four choices about your game. A press saves at once: you push nothing and deploy nothing. They start from ${defaultsFile}, and a press here wins.`));
19
19
  /** The friends choice: a radio group of three keys (arrow keys move between them, as a radio group does). */
20
20
  const friendsKeys = new Map();
21
21
  const friendsNote = h("p", { class: "switch-note", id: "switch-friends-note" });
@@ -75,7 +75,8 @@ export function gameCard(api, initial, defaultsFile = "the game's own settings",
75
75
  row.button.disabled = false;
76
76
  }
77
77
  };
78
- for (const id of ["character", "signin"]) {
78
+ /** A switch's row: its title, the On/Off key, the sentence for its state and the save status. */
79
+ const switchRow = (id) => {
79
80
  const label = h("span", { class: "switch-title", id: `switch-${id}-label` }, SWITCHES[id].title);
80
81
  const button = h("button", { class: "switch", type: "button", role: "switch", "aria-checked": "false", "aria-labelledby": `switch-${id}-label`, "aria-describedby": `switch-${id}-note` }, h("span", { class: "switch-word" }, "Off"), h("span", { class: "switch-knob", "aria-hidden": "true" }));
81
82
  const sentence = h("p", { class: "switch-note", id: `switch-${id}-note` });
@@ -83,10 +84,11 @@ export function gameCard(api, initial, defaultsFile = "the game's own settings",
83
84
  button.addEventListener("click", () => void press(id));
84
85
  rows.set(id, { button, sentence, status });
85
86
  card.append(h("div", { class: "switch-row" }, h("div", { class: "switch-head" }, label, button), sentence, id === "character" ? needs : null, status));
86
- // The sign-in word is part of the listing file: Nanoterra's review decides what Nanoterra shows. This page cannot see that review.
87
- if (id === "signin")
88
- card.append(h("p", { class: "fine switch-review" }, REVIEW_NOTE, " ", h("a", { href: REVIEW_LINK, target: "_blank", rel: "noopener noreferrer" }, "Open the launch page")));
89
- }
87
+ };
88
+ switchRow("character");
89
+ switchRow("signin");
90
+ // The sign-in word is part of the listing file: Nanoterra's review decides what Nanoterra shows. This page cannot see that review.
91
+ card.append(h("p", { class: "fine switch-review" }, REVIEW_NOTE, " ", h("a", { href: REVIEW_LINK, target: "_blank", rel: "noopener noreferrer" }, "Open the launch page")));
90
92
  const order = Object.keys(FRIENDS_CHOICES);
91
93
  const group = h("div", { class: "seg friends-choice", role: "radiogroup", "aria-labelledby": "switch-friends-label", "aria-describedby": "switch-friends-note" });
92
94
  for (const id of order) {
@@ -105,6 +107,7 @@ export function gameCard(api, initial, defaultsFile = "the game's own settings",
105
107
  group.append(key);
106
108
  }
107
109
  card.append(h("div", { class: "switch-row friends-row" }, h("div", { class: "switch-head" }, h("span", { class: "switch-title", id: "switch-friends-label" }, FRIENDS_TITLE), group), friendsNote, friendsStatus));
110
+ switchRow("emotes");
108
111
  draw();
109
112
  return card;
110
113
  }
@@ -129,9 +129,26 @@ declare const SHAPES: {
129
129
  d: string;
130
130
  fill: string;
131
131
  }])[];
132
+ gear: ([string, {
133
+ d: string;
134
+ }] | [string, {
135
+ cx: number;
136
+ cy: number;
137
+ r: number;
138
+ }])[];
132
139
  sparkle: [string, {
133
140
  d: string;
134
141
  }][];
142
+ smile: ([string, {
143
+ cx: number;
144
+ cy: number;
145
+ r: number;
146
+ }] | [string, {
147
+ d: string;
148
+ }] | [string, {
149
+ d: string;
150
+ "stroke-width": number;
151
+ }])[];
135
152
  };
136
153
  export type IconName = keyof typeof SHAPES;
137
154
  /** An icon of `size` CSS pixels (default 20). */
@@ -27,7 +27,10 @@ const SHAPES = {
27
27
  sun: [["circle", { cx: 12, cy: 12, r: 4 }], ["path", { d: "M12 2.5v2.2M12 19.3v2.2M2.5 12h2.2M19.3 12h2.2M5.3 5.3l1.6 1.6M17.1 17.1l1.6 1.6M5.3 18.7l1.6-1.6M17.1 6.9l1.6-1.6" }]],
28
28
  moon: [["path", { d: "M20 14.5A8.5 8.5 0 1 1 9.5 4a6.8 6.8 0 0 0 10.5 10.5Z" }]],
29
29
  device: [["circle", { cx: 12, cy: 12, r: 8.5 }], ["path", { d: "M12 3.5a8.5 8.5 0 0 1 0 17Z", fill: "currentColor" }]],
30
+ // A gear of 8 teeth (the options key): the settings icon above reads as a sun at 20 px.
31
+ gear: [["path", { d: "M19.23 9.65 21.77 9.86V14.14L19.23 14.35 18.77 15.45 20.42 17.39 17.39 20.42 15.45 18.77 14.35 19.23 14.14 21.77H9.86L9.65 19.23 8.55 18.77 6.61 20.42 3.58 17.39 5.23 15.45 4.77 14.35 2.23 14.14V9.86L4.77 9.65 5.23 8.55 3.58 6.61 6.61 3.58 8.55 5.23 9.65 4.77 9.86 2.23H14.14L14.35 4.77 15.45 5.23 17.39 3.58 20.42 6.61 18.77 8.55Z" }], ["circle", { cx: 12, cy: 12, r: 3.2 }]],
30
32
  sparkle: [["path", { d: "M12 3v4M12 17v4M3 12h4M17 12h4M6 6l2.5 2.5M15.5 15.5 18 18M6 18l2.5-2.5M15.5 8.5 18 6" }]],
33
+ smile: [["circle", { cx: 12, cy: 12, r: 9 }], ["path", { d: "M8.2 14.2a4.6 4.6 0 0 0 7.6 0" }], ["path", { d: "M9 9.6h.01M15 9.6h.01", "stroke-width": 2.6 }]],
31
34
  };
32
35
  /** An icon of `size` CSS pixels (default 20). */
33
36
  export function icon(name, size = 20) {
@@ -31,7 +31,7 @@ export function overviewView(looks, colourOf, go) {
31
31
  const el = h("div", { class: "overview" }, main, side);
32
32
  const linkKey = (words, to) => h("button", { class: "btn btn-small btn-quiet link-key", type: "button", onclick: to }, words, h("span", { "aria-hidden": "true" }, "→"));
33
33
  function drawGame() {
34
- const rows = ["character", "signin"].map((id) => {
34
+ const rows = ["character", "signin", "emotes"].map((id) => {
35
35
  const on = game ? stateOf(id, game) : null;
36
36
  return h("li", {}, h("span", {}, SWITCHES[id].title), on === null ? h("span", { class: "pill" }, "Unknown") : h("span", { class: `pill ${on ? "pill-on" : "pill-off"}` }, on ? "On" : "Off"));
37
37
  });
@@ -71,7 +71,7 @@ export type AdminLogin = {
71
71
  /** How many sign-ins the panel lists. */
72
72
  export declare const ADMIN_LOGINS_SHOWN = 10;
73
73
  /**
74
- * The game's database, as the handler needs it. An adapter writes ONLY what these calls say: the three settings and, when the game turns it on, the deletion of the admin sessions
74
+ * The game's database, as the handler needs it. An adapter writes ONLY what these calls say: the four settings and, when the game turns it on, the deletion of the admin sessions
75
75
  * (`signOutEverywhere`). Nothing else.
76
76
  * (There is no call that writes a look, a price or an order: the Shelf gives text to copy, and a change of looks is a change of the repository.)
77
77
  */
@@ -81,6 +81,7 @@ export interface AdminStore {
81
81
  character?: unknown;
82
82
  signin?: unknown;
83
83
  friends?: unknown;
84
+ emotes?: unknown;
84
85
  }>;
85
86
  /** Saves a checked patch: all of it or none of it. */
86
87
  saveSettings(patch: GamePatch, now: number): Promise<void>;
@@ -7,35 +7,44 @@ export type Signin = "exchange" | "ticket" | "none";
7
7
  * A three-way choice, not an On/Off switch.
8
8
  */
9
9
  export type Friends = "nanoterra" | "own" | "off";
10
- /** The game's own words for the switches. `friends` may be left out: it is "nanoterra" then (on by default). */
10
+ /** Whether the game shows Nanoterra's emotes ("@nano133/nanoterra/emotes"): the wheel's Nanoterra emotes and the other players' on their characters. Off leaves the game's own emotes. */
11
+ export type Emotes = "on" | "off";
12
+ /** The game's own words for the switches. `friends` and `emotes` may be left out: "nanoterra" and "on" then (on by default). */
11
13
  export type GameSettings = {
12
14
  character: Character;
13
15
  signin: Signin;
14
16
  friends?: Friends;
17
+ emotes?: Emotes;
15
18
  };
16
19
  /** What a press can set: sign-in is on ("exchange": a game with a server) or off ("none"). */
17
20
  export type GamePatch = {
18
21
  character?: Character;
19
22
  signin?: "exchange" | "none";
20
23
  friends?: Friends;
24
+ emotes?: Emotes;
21
25
  };
22
26
  /** The settings as they are now, and which ones /admin has set (the others still have the game's default). */
23
27
  export type GameState = {
24
28
  character: Character;
25
29
  signin: Signin;
26
30
  friends: Friends;
31
+ emotes: Emotes;
27
32
  fromAdmin: {
28
33
  character: boolean;
29
34
  signin: boolean;
30
35
  friends: boolean;
36
+ emotes: boolean;
31
37
  };
32
38
  };
33
39
  export declare const isCharacter: (v: unknown) => v is Character;
34
40
  export declare const isSigninChoice: (v: unknown) => v is "exchange" | "none";
35
41
  export declare const isSignin: (v: unknown) => v is Signin;
36
42
  export declare const isFriends: (v: unknown) => v is Friends;
43
+ export declare const isEmotes: (v: unknown) => v is Emotes;
37
44
  /** The friends list a game shows while neither its own words nor /admin say: Nanoterra's. */
38
45
  export declare const DEFAULT_FRIENDS: Friends;
46
+ /** Nanoterra's emotes are on while neither the game's own words nor /admin say. */
47
+ export declare const DEFAULT_EMOTES: Emotes;
39
48
  /** The sign-in switch is on for any way but "none". */
40
49
  export declare const signinOn: (s: Signin) => boolean;
41
50
  /** A request body for a press: only the two known words, at least one. Anything else is refused, with the reason in words. */
@@ -51,6 +60,7 @@ export declare function mergeSettings(stored: {
51
60
  character?: unknown;
52
61
  signin?: unknown;
53
62
  friends?: unknown;
63
+ emotes?: unknown;
54
64
  }, defaults: GameSettings): GameState;
55
65
  /** The friends choice of the card: its title, and for each choice its short name (the card's three keys) and one plain sentence. Nanoterra's is the default. */
56
66
  export declare const FRIENDS_TITLE = "Friends list";
@@ -59,7 +69,7 @@ export declare const FRIENDS_CHOICES: Record<Friends, {
59
69
  note: string;
60
70
  }>;
61
71
  /** One switch of the card: its title, and one plain sentence for each state. */
62
- export type SwitchId = "character" | "signin";
72
+ export type SwitchId = "character" | "signin" | "emotes";
63
73
  export declare const SWITCHES: Record<SwitchId, {
64
74
  title: string;
65
75
  on: string;
@@ -77,4 +87,4 @@ export declare const REVIEW_NOTE = "Your choice is saved here at once. The sign-
77
87
  export declare const REVIEW_LINK = "https://nano133.com/build/nanoterra/launch";
78
88
  /** The state of a switch from the settings, and the patch that a press sends (the other state). */
79
89
  export declare const stateOf: (id: SwitchId, s: GameSettings) => boolean;
80
- export declare const pressOf: (id: SwitchId, s: GameSettings) => GamePatch;
90
+ export declare function pressOf(id: SwitchId, s: GameSettings): GamePatch;
@@ -1,17 +1,20 @@
1
1
  // The switches of the "Game" card in /admin, and the words that say what each does. The handler checks a press with `parsePatch`; the panel
2
- // draws the card from `SWITCHES` and `FRIENDS_CHOICES`. A switch changes ONLY these three settings; never a look, a price or an order.
2
+ // draws the card from `SWITCHES` and `FRIENDS_CHOICES`. A switch changes ONLY these four settings; never a look, a price or an order.
3
3
  export const isCharacter = (v) => v === "nanoterra" || v === "own";
4
4
  export const isSigninChoice = (v) => v === "exchange" || v === "none";
5
5
  export const isSignin = (v) => v === "exchange" || v === "ticket" || v === "none";
6
6
  export const isFriends = (v) => v === "nanoterra" || v === "own" || v === "off";
7
+ export const isEmotes = (v) => v === "on" || v === "off";
7
8
  /** The friends list a game shows while neither its own words nor /admin say: Nanoterra's. */
8
9
  export const DEFAULT_FRIENDS = "nanoterra";
10
+ /** Nanoterra's emotes are on while neither the game's own words nor /admin say. */
11
+ export const DEFAULT_EMOTES = "on";
9
12
  /** The sign-in switch is on for any way but "none". */
10
13
  export const signinOn = (s) => s !== "none";
11
14
  /** A request body for a press: only the two known words, at least one. Anything else is refused, with the reason in words. */
12
15
  export function parsePatch(body) {
13
16
  if (!body || typeof body !== "object" || Array.isArray(body))
14
- return { ok: false, error: 'Send an object with "character", "signin" or "friends".' };
17
+ return { ok: false, error: 'Send an object with "character", "signin", "friends" or "emotes".' };
15
18
  const patch = {};
16
19
  for (const [key, value] of Object.entries(body)) {
17
20
  if (key === "character" && isCharacter(value))
@@ -20,8 +23,12 @@ export function parsePatch(body) {
20
23
  patch.signin = value;
21
24
  else if (key === "friends" && isFriends(value))
22
25
  patch.friends = value;
26
+ else if (key === "emotes" && isEmotes(value))
27
+ patch.emotes = value;
23
28
  else if (key === "friends")
24
29
  return { ok: false, error: '"friends" must be "nanoterra", "own" or "off".' };
30
+ else if (key === "emotes")
31
+ return { ok: false, error: '"emotes" must be "on" or "off".' };
25
32
  else if (key === "character")
26
33
  return { ok: false, error: '"character" must be "nanoterra" or "own".' };
27
34
  else if (key === "signin")
@@ -30,7 +37,7 @@ export function parsePatch(body) {
30
37
  return { ok: false, error: `"${key}" is not a game switch.` };
31
38
  }
32
39
  if (!Object.keys(patch).length)
33
- return { ok: false, error: 'Send "character", "signin" or "friends".' };
40
+ return { ok: false, error: 'Send "character", "signin", "friends" or "emotes".' };
34
41
  return { ok: true, patch };
35
42
  }
36
43
  /** The settings from what the store kept and the game's defaults. A stored value that is not one of the known words is ignored. */
@@ -39,7 +46,8 @@ export function mergeSettings(stored, defaults) {
39
46
  character: isCharacter(stored.character) ? stored.character : defaults.character,
40
47
  signin: isSigninChoice(stored.signin) ? stored.signin : defaults.signin,
41
48
  friends: isFriends(stored.friends) ? stored.friends : isFriends(defaults.friends) ? defaults.friends : DEFAULT_FRIENDS,
42
- fromAdmin: { character: isCharacter(stored.character), signin: isSigninChoice(stored.signin), friends: isFriends(stored.friends) },
49
+ emotes: isEmotes(stored.emotes) ? stored.emotes : isEmotes(defaults.emotes) ? defaults.emotes : DEFAULT_EMOTES,
50
+ fromAdmin: { character: isCharacter(stored.character), signin: isSigninChoice(stored.signin), friends: isFriends(stored.friends), emotes: isEmotes(stored.emotes) },
43
51
  };
44
52
  }
45
53
  /** The friends choice of the card: its title, and for each choice its short name (the card's three keys) and one plain sentence. Nanoterra's is the default. */
@@ -66,6 +74,11 @@ export const SWITCHES = {
66
74
  on: "On: this game's listing file says players can sign in with Nanoterra, and the game accepts that sign-in.",
67
75
  off: "Off: the listing file says no sign-in, and the game refuses it: a link made before still signs nobody in.",
68
76
  },
77
+ emotes: {
78
+ title: "Allow Nanoterra emotes",
79
+ on: "On: players can play Nanoterra's emotes (Wave, Dance, Sit and more) from the Emotes wheel, and see the other players' emotes.",
80
+ off: "Off: the game shows no Nanoterra emote. The game's own emotes, if it has any, stay on the wheel.",
81
+ },
69
82
  };
70
83
  /**
71
84
  * The character comes through the sign-in: only a player who signs in from Nanoterra brings one. So the character switch is shown with its dependency. With sign-in Off the
@@ -78,5 +91,11 @@ export const CHARACTER_SIGNIN_OFF = "Sign-in from Nanoterra is Off, so every pla
78
91
  export const REVIEW_NOTE = "Your choice is saved here at once. The sign-in word is part of your listing file, and Nanoterra's review decides what Nanoterra shows for a game that is listed. This page cannot see that review. Check it on Nanoterra's launch page (step 6).";
79
92
  export const REVIEW_LINK = "https://nano133.com/build/nanoterra/launch";
80
93
  /** The state of a switch from the settings, and the patch that a press sends (the other state). */
81
- export const stateOf = (id, s) => (id === "character" ? s.character === "nanoterra" : signinOn(s.signin));
82
- export const pressOf = (id, s) => (id === "character" ? { character: s.character === "nanoterra" ? "own" : "nanoterra" } : { signin: signinOn(s.signin) ? "none" : "exchange" });
94
+ export const stateOf = (id, s) => (id === "character" ? s.character === "nanoterra" : id === "emotes" ? (s.emotes ?? DEFAULT_EMOTES) === "on" : signinOn(s.signin));
95
+ export function pressOf(id, s) {
96
+ if (id === "character")
97
+ return { character: s.character === "nanoterra" ? "own" : "nanoterra" };
98
+ if (id === "emotes")
99
+ return { emotes: stateOf("emotes", s) ? "off" : "on" };
100
+ return { signin: signinOn(s.signin) ? "none" : "exchange" };
101
+ }
@@ -2,6 +2,7 @@ import * as THREE from "three";
2
2
  import { assemble } from "./assemble.js";
3
3
  import { BASE_SPECS } from "./bases/index.js";
4
4
  import { HEIGHTS } from "./lists.js";
5
+ import { poseLayerOf } from "./layer.js";
5
6
  import { assertLook, recordProblem } from "./look.js";
6
7
  import { buildWith, paintCharacter } from "./make.js";
7
8
  import { makePose, MAX_DT } from "./pose.js";
@@ -136,7 +137,7 @@ export function createCharacter(options = {}) {
136
137
  r.made.hull.dispose();
137
138
  r.a.skeleton.dispose();
138
139
  };
139
- return {
140
+ const self = {
140
141
  object,
141
142
  get record() {
142
143
  return frozen(record);
@@ -159,7 +160,9 @@ export function createCharacter(options = {}) {
159
160
  return;
160
161
  time += step;
161
162
  rig.pose.step(step, motion ?? {}, reducedMotion);
162
- // The body's own moving parts (a tail, wings, long hair) follow what the pose did.
163
+ // A layer over the pose (an emote: layer.ts), then the body's own moving parts (a tail, wings, long hair) follow what the pose did.
164
+ const state = rig.pose.state;
165
+ poseLayerOf(self)?.(rig.a.bones, { dt: step, speed: state.speed, airborne: state.air, reducedMotion });
163
166
  rig.a.mover?.(step, time, rig.pose.state.speed, reducedMotion);
164
167
  },
165
168
  wave() {
@@ -228,4 +231,5 @@ export function createCharacter(options = {}) {
228
231
  ramp.dispose();
229
232
  },
230
233
  };
234
+ return self;
231
235
  }
@@ -0,0 +1,14 @@
1
+ import type * as THREE from "three";
2
+ import type { BoneName } from "./skeleton.js";
3
+ /** What the pose did this frame: the step it took (s), its eased speed (m/s), whether it is in the air, and the reduced-motion setting. */
4
+ export type PoseFrame = {
5
+ dt: number;
6
+ speed: number;
7
+ airborne: boolean;
8
+ reducedMotion: boolean;
9
+ };
10
+ export type PoseLayer = (bones: Readonly<Record<BoneName, THREE.Bone>>, frame: PoseFrame) => void;
11
+ /** Gives a character its layer (null takes it off). */
12
+ export declare function setPoseLayer(character: object, layer: PoseLayer | null): void;
13
+ /** The character's layer, if it has one. */
14
+ export declare const poseLayerOf: (character: object) => PoseLayer | undefined;
@@ -0,0 +1,10 @@
1
+ const layers = new WeakMap();
2
+ /** Gives a character its layer (null takes it off). */
3
+ export function setPoseLayer(character, layer) {
4
+ if (layer)
5
+ layers.set(character, layer);
6
+ else
7
+ layers.delete(character);
8
+ }
9
+ /** The character's layer, if it has one. */
10
+ export const poseLayerOf = (character) => layers.get(character);
@@ -0,0 +1,79 @@
1
+ import * as THREE from "three";
2
+ /** One clip as the file holds it: whole numbers, each bone's first frame then each frame's change (`q`: the bone's index, then 4 numbers a frame). */
3
+ export type RawClip = {
4
+ n: number;
5
+ loop: boolean;
6
+ from?: number;
7
+ kind: "full" | "upper";
8
+ hold: number;
9
+ label: string;
10
+ q: number[][];
11
+ p?: number[];
12
+ };
13
+ export type RawClips = {
14
+ v: number;
15
+ fps: number;
16
+ bones: string[];
17
+ clips: Record<string, RawClip>;
18
+ };
19
+ export type Clip = {
20
+ /** Frames (a loop's last frame is its first, so it is not among them). */
21
+ n: number;
22
+ loop: boolean;
23
+ /** The frame a loop goes back to (0: the whole clip loops; more: an opening that plays once, then the loop). */
24
+ from: number;
25
+ kind: "full" | "upper";
26
+ hold: number;
27
+ /** A one-shot's length in seconds. */
28
+ seconds: number;
29
+ turns: {
30
+ bone: string;
31
+ q: Float32Array;
32
+ }[];
33
+ /** The hips' place from their rest place, three numbers a frame (or one place), or null. */
34
+ hips: Float32Array | null;
35
+ };
36
+ /** Fades, in seconds: in at the start, out at a one-shot's end, and out when a move stops it. */
37
+ export declare const FADE_IN = 0.12;
38
+ export declare const FADE_END = 0.22;
39
+ export declare const FADE_STOP = 0.18;
40
+ /**
41
+ * Loads the clips, once (about 84 kB, 19 kB gzipped). Call it when the wheel first opens; a clip that plays before calls it too, and starts when they
42
+ * arrive. A failed load (the network) may be tried again.
43
+ */
44
+ export declare function loadEmotes(): Promise<void>;
45
+ /** True once the clips are loaded. */
46
+ export declare const emotesLoaded: () => boolean;
47
+ /** A loaded clip by its id, or null. */
48
+ export declare const emoteClip: (id: string) => Clip | null;
49
+ export declare function decodeClips(raw: RawClips): Map<string, Clip>;
50
+ /** What one character keeps between frames: the clip on show, its age (s), when a move or `stop` stopped it (its age then, or -1), and why it ended. */
51
+ export type ClipRun = {
52
+ id: string | null;
53
+ age: number;
54
+ stopAge: number;
55
+ stopWhy: "move" | "stop";
56
+ waiting: boolean;
57
+ ended: "end" | "move" | "stop" | null;
58
+ };
59
+ export declare const newRun: () => ClipRun;
60
+ /**
61
+ * Starts a clip on a run, `age` seconds in (a remote player's emote that began a little before). A loop already on show, and not stopping, goes on
62
+ * through its own repeat: a player's loop is sent again for those who come later.
63
+ */
64
+ export declare function startClip(run: ClipRun, id: string, age?: number): void;
65
+ /** Stops the clip on show with its fade (`stop` from the game). */
66
+ export declare function stopClip(run: ClipRun): void;
67
+ export type ClipFrame = {
68
+ /** The step (s). */
69
+ dt: number;
70
+ moving: boolean;
71
+ air: boolean;
72
+ swim: boolean;
73
+ calm: boolean;
74
+ };
75
+ /**
76
+ * One frame of the clip on a character: `bones` by name, already posed by the solver. Returns whether the clip showed. When it ends (its end, a move,
77
+ * the water or `stopClip`), `run.id` goes back to null and `run.ended` says why.
78
+ */
79
+ export declare function stepClip(run: ClipRun, bones: Readonly<Record<string, THREE.Bone>>, o: ClipFrame): boolean;
@@ -0,0 +1,165 @@
1
+ import * as THREE from "three";
2
+ import { BONES } from "../character/skeleton.js";
3
+ const FPS = 30;
4
+ const Q = 10000;
5
+ /** Fades, in seconds: in at the start, out at a one-shot's end, and out when a move stops it. */
6
+ export const FADE_IN = 0.12;
7
+ export const FADE_END = 0.22;
8
+ export const FADE_STOP = 0.18;
9
+ const HIPS_REST = (() => {
10
+ const b = BONES.find((x) => x[0] === "hips");
11
+ return new THREE.Vector3(b[2], b[3], b[4]);
12
+ })();
13
+ let clips = null;
14
+ let loading = null;
15
+ /**
16
+ * Loads the clips, once (about 84 kB, 19 kB gzipped). Call it when the wheel first opens; a clip that plays before calls it too, and starts when they
17
+ * arrive. A failed load (the network) may be tried again.
18
+ */
19
+ export function loadEmotes() {
20
+ loading ??= import("./clips.js").then((m) => {
21
+ clips = decodeClips(m.default);
22
+ }, (e) => {
23
+ loading = null;
24
+ throw e;
25
+ });
26
+ return loading;
27
+ }
28
+ /** True once the clips are loaded. */
29
+ export const emotesLoaded = () => clips !== null;
30
+ /** A loaded clip by its id, or null. */
31
+ export const emoteClip = (id) => clips?.get(id) ?? null;
32
+ /** Whole numbers, the first frame then each frame's change, back to the frames (`width` numbers each); one frame when it never changes. */
33
+ function unpack(vals, width) {
34
+ const out = new Float32Array(vals.length);
35
+ const acc = new Array(width).fill(0);
36
+ for (let i = 0; i < vals.length; i++) {
37
+ acc[i % width] += vals[i];
38
+ out[i] = acc[i % width] / Q;
39
+ }
40
+ return out;
41
+ }
42
+ export function decodeClips(raw) {
43
+ const out = new Map();
44
+ for (const [id, c] of Object.entries(raw.clips)) {
45
+ const turns = c.q.map(([bone, ...vals]) => {
46
+ const q = unpack(vals, 4);
47
+ // The whole numbers round each turn a little: back to unit length.
48
+ for (let i = 0; i < q.length; i += 4) {
49
+ const l = Math.hypot(q[i], q[i + 1], q[i + 2], q[i + 3]) || 1;
50
+ for (let k = 0; k < 4; k++)
51
+ q[i + k] /= l;
52
+ }
53
+ return { bone: raw.bones[bone], q };
54
+ });
55
+ out.set(id, { n: c.n, loop: c.loop, from: c.from ?? 0, kind: c.kind, hold: c.hold, seconds: (c.n - 1) / (raw.fps || FPS), turns, hips: c.p ? unpack(c.p, 3) : null });
56
+ }
57
+ return out;
58
+ }
59
+ export const newRun = () => ({ id: null, age: 0, stopAge: -1, stopWhy: "move", waiting: false, ended: null });
60
+ /**
61
+ * Starts a clip on a run, `age` seconds in (a remote player's emote that began a little before). A loop already on show, and not stopping, goes on
62
+ * through its own repeat: a player's loop is sent again for those who come later.
63
+ */
64
+ export function startClip(run, id, age = 0) {
65
+ const now = run.id ? clips?.get(run.id) : null;
66
+ if (run.id === id && now && now.loop && run.stopAge < 0)
67
+ return;
68
+ run.id = id;
69
+ run.age = Math.max(0, age);
70
+ run.stopAge = -1;
71
+ run.waiting = !clips;
72
+ run.ended = null;
73
+ }
74
+ /** Stops the clip on show with its fade (`stop` from the game). */
75
+ export function stopClip(run) {
76
+ if (run.id && run.stopAge < 0) {
77
+ run.stopAge = run.age;
78
+ run.stopWhy = "stop";
79
+ }
80
+ }
81
+ const qa = new THREE.Quaternion();
82
+ const qb = new THREE.Quaternion();
83
+ const va = new THREE.Vector3();
84
+ const vb = new THREE.Vector3();
85
+ const smooth = (w) => w * w * (3 - 2 * w);
86
+ /** Where a clip is at `age` seconds: the frame before, the frame after and how far between. */
87
+ function frameAt(c, age, calm) {
88
+ if (calm)
89
+ return [c.hold, c.hold, 0];
90
+ let f = age * FPS;
91
+ if (c.loop && f >= c.n)
92
+ f = c.from + ((f - c.from) % (c.n - c.from));
93
+ else
94
+ f = Math.min(f, c.n - 1);
95
+ const i0 = Math.floor(f);
96
+ const i1 = i0 + 1 >= c.n ? (c.loop ? c.from : c.n - 1) : i0 + 1;
97
+ return [i0, i1, f - i0];
98
+ }
99
+ /**
100
+ * One frame of the clip on a character: `bones` by name, already posed by the solver. Returns whether the clip showed. When it ends (its end, a move,
101
+ * the water or `stopClip`), `run.id` goes back to null and `run.ended` says why.
102
+ */
103
+ export function stepClip(run, bones, o) {
104
+ if (!run.id)
105
+ return false;
106
+ const clip = clips?.get(run.id);
107
+ if (!clip) {
108
+ // Not loaded yet (or not a clip): it starts when the clips arrive, from its first frame.
109
+ if (!clips) {
110
+ run.waiting = true;
111
+ void loadEmotes().catch(() => undefined);
112
+ }
113
+ else
114
+ run.id = null;
115
+ return false;
116
+ }
117
+ if (run.waiting) {
118
+ run.waiting = false;
119
+ run.age = 0;
120
+ }
121
+ else
122
+ run.age += o.dt;
123
+ const age = run.age;
124
+ const stop = o.swim || (clip.kind === "full" && (o.moving || o.air));
125
+ if (stop && run.stopAge < 0) {
126
+ run.stopAge = age;
127
+ run.stopWhy = "move";
128
+ }
129
+ let w = Math.min(1, age / FADE_IN);
130
+ if (!clip.loop)
131
+ w = Math.min(w, (clip.seconds - age) / FADE_END);
132
+ if (run.stopAge >= 0)
133
+ w = Math.min(w, 1 - (age - run.stopAge) / FADE_STOP);
134
+ if (w <= 0) {
135
+ if (run.stopAge >= 0 || (!clip.loop && age >= clip.seconds)) {
136
+ run.ended = run.stopAge >= 0 ? run.stopWhy : "end";
137
+ run.id = null;
138
+ }
139
+ return false;
140
+ }
141
+ w = smooth(Math.min(1, w));
142
+ const [i0, i1, t] = frameAt(clip, age, o.calm);
143
+ for (const tr of clip.turns) {
144
+ const bone = bones[tr.bone];
145
+ if (!bone)
146
+ continue;
147
+ if (tr.q.length === 4)
148
+ qa.fromArray(tr.q, 0);
149
+ else {
150
+ qa.fromArray(tr.q, i0 * 4);
151
+ qb.fromArray(tr.q, i1 * 4);
152
+ qa.slerp(qb, t);
153
+ }
154
+ bone.quaternion.slerp(qa, w);
155
+ }
156
+ const hips = bones.hips;
157
+ if (clip.hips && hips) {
158
+ if (clip.hips.length === 3)
159
+ va.fromArray(clip.hips, 0);
160
+ else
161
+ va.fromArray(clip.hips, i0 * 3).lerp(vb.fromArray(clip.hips, i1 * 3), t);
162
+ hips.position.lerp(va.add(HIPS_REST), w);
163
+ }
164
+ return true;
165
+ }
@@ -0,0 +1,3 @@
1
+ import type { RawClips } from "./clip.js";
2
+ declare const CLIPS: RawClips;
3
+ export default CLIPS;