@nano133/nanoterra 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +50 -0
- package/README.md +206 -10
- package/ai/SPEC.md +51 -2
- package/dist/admin/adapters/d1.d.ts +1 -0
- package/dist/admin/adapters/d1.js +6 -4
- package/dist/admin/adapters/firestore.d.ts +1 -0
- package/dist/admin/adapters/firestore.js +7 -5
- package/dist/admin/conformance.d.ts +1 -1
- package/dist/admin/conformance.js +12 -4
- package/dist/admin/handler.d.ts +1 -1
- package/dist/admin/handler.js +2 -2
- package/dist/admin/index.d.ts +2 -2
- package/dist/admin/index.js +1 -1
- package/dist/admin/memory.js +3 -1
- package/dist/admin/panel/gameCard.js +13 -10
- package/dist/admin/panel/icons.d.ts +17 -0
- package/dist/admin/panel/icons.js +3 -0
- package/dist/admin/panel/overview.js +1 -1
- package/dist/admin/store.d.ts +2 -1
- package/dist/admin/switches.d.ts +13 -3
- package/dist/admin/switches.js +25 -6
- package/dist/character/create.js +6 -2
- package/dist/character/layer.d.ts +14 -0
- package/dist/character/layer.js +10 -0
- package/dist/emotes/clip.d.ts +79 -0
- package/dist/emotes/clip.js +165 -0
- package/dist/emotes/clips.d.ts +3 -0
- package/dist/emotes/clips.js +2 -0
- package/dist/emotes/events.d.ts +22 -0
- package/dist/emotes/events.js +19 -0
- package/dist/emotes/icons.d.ts +1 -0
- package/dist/emotes/icons.js +6 -0
- package/dist/emotes/index.d.ts +8 -0
- package/dist/emotes/index.js +11 -0
- package/dist/emotes/list.d.ts +123 -0
- package/dist/emotes/list.js +23 -0
- package/dist/emotes/options.d.ts +16 -0
- package/dist/emotes/options.js +34 -0
- package/dist/emotes/panel/index.d.ts +3 -0
- package/dist/emotes/panel/index.js +9 -0
- package/dist/emotes/panel/mount.d.ts +56 -0
- package/dist/emotes/panel/mount.js +360 -0
- package/dist/emotes/player.d.ts +37 -0
- package/dist/emotes/player.js +101 -0
- package/dist/emotes/wheel.d.ts +42 -0
- package/dist/emotes/wheel.js +110 -0
- package/dist/friends/panel/mount.d.ts +3 -3
- package/dist/friends/panel/mount.js +46 -12
- package/dist/options/common.d.ts +88 -0
- package/dist/options/common.js +59 -0
- package/dist/options/core.d.ts +130 -0
- package/dist/options/core.js +420 -0
- package/dist/options/index.d.ts +7 -0
- package/dist/options/index.js +24 -0
- package/dist/options/panel/index.d.ts +2 -0
- package/dist/options/panel/index.js +10 -0
- package/dist/options/panel/mount.d.ts +30 -0
- package/dist/options/panel/mount.js +378 -0
- package/dist/options/reflect.d.ts +5 -0
- package/dist/options/reflect.js +27 -0
- package/package.json +12 -4
- package/src/admin/adapters/d1.ts +5 -4
- package/src/admin/adapters/firestore.ts +6 -5
- package/src/admin/conformance.ts +14 -5
- package/src/admin/handler.ts +2 -2
- package/src/admin/index.ts +2 -2
- package/src/admin/memory.ts +2 -1
- package/src/admin/panel/gameCard.ts +13 -9
- package/src/admin/panel/icons.ts +3 -0
- package/src/admin/panel/overview.ts +1 -1
- package/src/admin/store.ts +2 -2
- package/src/admin/switches.ts +29 -12
- package/src/character/create.ts +6 -2
- package/src/character/layer.ts +21 -0
- package/src/emotes/clip.ts +204 -0
- package/src/emotes/clips.ts +7 -0
- package/src/emotes/events.ts +28 -0
- package/src/emotes/icons.ts +7 -0
- package/src/emotes/icons.webp +0 -0
- package/src/emotes/index.ts +16 -0
- package/src/emotes/list.ts +33 -0
- package/src/emotes/options.ts +43 -0
- package/src/emotes/panel/index.ts +11 -0
- package/src/emotes/panel/mount.ts +390 -0
- package/src/emotes/panel/style.css +415 -0
- package/src/emotes/player.ts +131 -0
- package/src/emotes/wheel.ts +131 -0
- package/src/friends/panel/mount.ts +47 -13
- package/src/friends/panel/style.css +26 -0
- package/src/options/common.ts +65 -0
- package/src/options/core.ts +465 -0
- package/src/options/index.ts +41 -0
- package/src/options/panel/index.ts +12 -0
- package/src/options/panel/mount.ts +418 -0
- package/src/options/panel/style.css +528 -0
- package/src/options/reflect.ts +28 -0
|
@@ -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",
|
|
2
|
-
// choice, "Friends list: Nanoterra / The game's own / Off". A press saves at once (POST {api}/game, behind the
|
|
3
|
-
// the game reads the setting at run time, so the creator pushes and deploys nothing. The words are in
|
|
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
|
|
|
6
6
|
import { CHARACTER_NEEDS_SIGNIN, CHARACTER_SIGNIN_OFF, FRIENDS_CHOICES, FRIENDS_TITLE, REVIEW_LINK, REVIEW_NOTE, SWITCHES, pressOf, signinOn, stateOf, type Friends, type GameState, type SwitchId } from "../switches.js";
|
|
7
7
|
import { ApiError, type Api } from "./api.js";
|
|
@@ -17,7 +17,7 @@ export function gameCard(api: Api, initial: GameState, defaultsFile = "the game'
|
|
|
17
17
|
/** 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. */
|
|
18
18
|
const needs = h("p", { class: "fine switch-needs" });
|
|
19
19
|
const card = h("section", { class: "admin-card game-card", "aria-labelledby": "game-title" }, h("div", { class: "card-head" }, h("h2", { id: "game-title" }, "Game")));
|
|
20
|
-
card.append(h("p", { class: "fine card-intro" }, `
|
|
20
|
+
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.`));
|
|
21
21
|
/** The friends choice: a radio group of three keys (arrow keys move between them, as a radio group does). */
|
|
22
22
|
const friendsKeys = new Map<Friends, HTMLButtonElement>();
|
|
23
23
|
const friendsNote = h("p", { class: "switch-note", id: "switch-friends-note" });
|
|
@@ -74,7 +74,8 @@ export function gameCard(api: Api, initial: GameState, defaultsFile = "the game'
|
|
|
74
74
|
}
|
|
75
75
|
};
|
|
76
76
|
|
|
77
|
-
|
|
77
|
+
/** A switch's row: its title, the On/Off key, the sentence for its state and the save status. */
|
|
78
|
+
const switchRow = (id: SwitchId) => {
|
|
78
79
|
const label = h("span", { class: "switch-title", id: `switch-${id}-label` }, SWITCHES[id].title);
|
|
79
80
|
const button = h(
|
|
80
81
|
"button",
|
|
@@ -87,9 +88,11 @@ export function gameCard(api: Api, initial: GameState, defaultsFile = "the game'
|
|
|
87
88
|
button.addEventListener("click", () => void press(id));
|
|
88
89
|
rows.set(id, { button, sentence, status });
|
|
89
90
|
card.append(h("div", { class: "switch-row" }, h("div", { class: "switch-head" }, label, button), sentence, id === "character" ? needs : null, status));
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
91
|
+
};
|
|
92
|
+
switchRow("character");
|
|
93
|
+
switchRow("signin");
|
|
94
|
+
// The sign-in word is part of the listing file: Nanoterra's review decides what Nanoterra shows. This page cannot see that review.
|
|
95
|
+
card.append(h("p", { class: "fine switch-review" }, REVIEW_NOTE, " ", h("a", { href: REVIEW_LINK, target: "_blank", rel: "noopener noreferrer" }, "Open the launch page")));
|
|
93
96
|
|
|
94
97
|
const order = Object.keys(FRIENDS_CHOICES) as Friends[];
|
|
95
98
|
const group = h("div", { class: "seg friends-choice", role: "radiogroup", "aria-labelledby": "switch-friends-label", "aria-describedby": "switch-friends-note" });
|
|
@@ -108,6 +111,7 @@ export function gameCard(api: Api, initial: GameState, defaultsFile = "the game'
|
|
|
108
111
|
group.append(key);
|
|
109
112
|
}
|
|
110
113
|
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));
|
|
114
|
+
switchRow("emotes");
|
|
111
115
|
draw();
|
|
112
116
|
return card;
|
|
113
117
|
}
|
package/src/admin/panel/icons.ts
CHANGED
|
@@ -31,7 +31,10 @@ const SHAPES = {
|
|
|
31
31
|
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" }]],
|
|
32
32
|
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" }]],
|
|
33
33
|
device: [["circle", { cx: 12, cy: 12, r: 8.5 }], ["path", { d: "M12 3.5a8.5 8.5 0 0 1 0 17Z", fill: "currentColor" }]],
|
|
34
|
+
// A gear of 8 teeth (the options key): the settings icon above reads as a sun at 20 px.
|
|
35
|
+
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 }]],
|
|
34
36
|
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" }]],
|
|
37
|
+
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 }]],
|
|
35
38
|
} satisfies Record<string, Shape[]>;
|
|
36
39
|
|
|
37
40
|
export type IconName = keyof typeof SHAPES;
|
|
@@ -47,7 +47,7 @@ export function overviewView(looks: readonly Look[], colourOf: (lookId: string)
|
|
|
47
47
|
const linkKey = (words: string, to: () => void) => h("button", { class: "btn btn-small btn-quiet link-key", type: "button", onclick: to }, words, h("span", { "aria-hidden": "true" }, "→"));
|
|
48
48
|
|
|
49
49
|
function drawGame() {
|
|
50
|
-
const rows = (["character", "signin"] as const).map((id) => {
|
|
50
|
+
const rows = (["character", "signin", "emotes"] as const).map((id) => {
|
|
51
51
|
const on = game ? stateOf(id, game) : null;
|
|
52
52
|
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"));
|
|
53
53
|
});
|
package/src/admin/store.ts
CHANGED
|
@@ -48,13 +48,13 @@ export type AdminLogin = { at: number; country: string | null; browser: string }
|
|
|
48
48
|
export const ADMIN_LOGINS_SHOWN = 10;
|
|
49
49
|
|
|
50
50
|
/**
|
|
51
|
-
* The game's database, as the handler needs it. An adapter writes ONLY what these calls say: the
|
|
51
|
+
* 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
|
|
52
52
|
* (`signOutEverywhere`). Nothing else.
|
|
53
53
|
* (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.)
|
|
54
54
|
*/
|
|
55
55
|
export interface AdminStore {
|
|
56
56
|
/** The settings that /admin has saved. A value that is not a known word is ignored by the handler, so an adapter returns what it holds. */
|
|
57
|
-
readSettings(): Promise<{ character?: unknown; signin?: unknown; friends?: unknown }>;
|
|
57
|
+
readSettings(): Promise<{ character?: unknown; signin?: unknown; friends?: unknown; emotes?: unknown }>;
|
|
58
58
|
/** Saves a checked patch: all of it or none of it. */
|
|
59
59
|
saveSettings(patch: GamePatch, now: number): Promise<void>;
|
|
60
60
|
summary(now: number): Promise<StoreSummary>;
|
package/src/admin/switches.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
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
|
|
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
|
|
|
4
4
|
/** Which character the player is: Nanoterra's island character (the one the player has there) or the game's own. */
|
|
5
5
|
export type Character = "nanoterra" | "own";
|
|
@@ -10,46 +10,54 @@ export type Signin = "exchange" | "ticket" | "none";
|
|
|
10
10
|
* A three-way choice, not an On/Off switch.
|
|
11
11
|
*/
|
|
12
12
|
export type Friends = "nanoterra" | "own" | "off";
|
|
13
|
-
/**
|
|
14
|
-
export type
|
|
13
|
+
/** 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. */
|
|
14
|
+
export type Emotes = "on" | "off";
|
|
15
|
+
/** The game's own words for the switches. `friends` and `emotes` may be left out: "nanoterra" and "on" then (on by default). */
|
|
16
|
+
export type GameSettings = { character: Character; signin: Signin; friends?: Friends; emotes?: Emotes };
|
|
15
17
|
/** What a press can set: sign-in is on ("exchange": a game with a server) or off ("none"). */
|
|
16
|
-
export type GamePatch = { character?: Character; signin?: "exchange" | "none"; friends?: Friends };
|
|
18
|
+
export type GamePatch = { character?: Character; signin?: "exchange" | "none"; friends?: Friends; emotes?: Emotes };
|
|
17
19
|
/** The settings as they are now, and which ones /admin has set (the others still have the game's default). */
|
|
18
|
-
export type GameState = { character: Character; signin: Signin; friends: Friends; fromAdmin: { character: boolean; signin: boolean; friends: boolean } };
|
|
20
|
+
export type GameState = { character: Character; signin: Signin; friends: Friends; emotes: Emotes; fromAdmin: { character: boolean; signin: boolean; friends: boolean; emotes: boolean } };
|
|
19
21
|
|
|
20
22
|
export const isCharacter = (v: unknown): v is Character => v === "nanoterra" || v === "own";
|
|
21
23
|
export const isSigninChoice = (v: unknown): v is "exchange" | "none" => v === "exchange" || v === "none";
|
|
22
24
|
export const isSignin = (v: unknown): v is Signin => v === "exchange" || v === "ticket" || v === "none";
|
|
23
25
|
export const isFriends = (v: unknown): v is Friends => v === "nanoterra" || v === "own" || v === "off";
|
|
26
|
+
export const isEmotes = (v: unknown): v is Emotes => v === "on" || v === "off";
|
|
24
27
|
/** The friends list a game shows while neither its own words nor /admin say: Nanoterra's. */
|
|
25
28
|
export const DEFAULT_FRIENDS: Friends = "nanoterra";
|
|
29
|
+
/** Nanoterra's emotes are on while neither the game's own words nor /admin say. */
|
|
30
|
+
export const DEFAULT_EMOTES: Emotes = "on";
|
|
26
31
|
/** The sign-in switch is on for any way but "none". */
|
|
27
32
|
export const signinOn = (s: Signin): boolean => s !== "none";
|
|
28
33
|
|
|
29
34
|
/** A request body for a press: only the two known words, at least one. Anything else is refused, with the reason in words. */
|
|
30
35
|
export function parsePatch(body: unknown): { ok: true; patch: GamePatch } | { ok: false; error: string } {
|
|
31
|
-
if (!body || typeof body !== "object" || Array.isArray(body)) return { ok: false, error: 'Send an object with "character", "signin" or "
|
|
36
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) return { ok: false, error: 'Send an object with "character", "signin", "friends" or "emotes".' };
|
|
32
37
|
const patch: GamePatch = {};
|
|
33
38
|
for (const [key, value] of Object.entries(body as Record<string, unknown>)) {
|
|
34
39
|
if (key === "character" && isCharacter(value)) patch.character = value;
|
|
35
40
|
else if (key === "signin" && isSigninChoice(value)) patch.signin = value;
|
|
36
41
|
else if (key === "friends" && isFriends(value)) patch.friends = value;
|
|
42
|
+
else if (key === "emotes" && isEmotes(value)) patch.emotes = value;
|
|
37
43
|
else if (key === "friends") return { ok: false, error: '"friends" must be "nanoterra", "own" or "off".' };
|
|
44
|
+
else if (key === "emotes") return { ok: false, error: '"emotes" must be "on" or "off".' };
|
|
38
45
|
else if (key === "character") return { ok: false, error: '"character" must be "nanoterra" or "own".' };
|
|
39
46
|
else if (key === "signin") return { ok: false, error: '"signin" must be "exchange" or "none".' };
|
|
40
47
|
else return { ok: false, error: `"${key}" is not a game switch.` };
|
|
41
48
|
}
|
|
42
|
-
if (!Object.keys(patch).length) return { ok: false, error: 'Send "character", "signin" or "
|
|
49
|
+
if (!Object.keys(patch).length) return { ok: false, error: 'Send "character", "signin", "friends" or "emotes".' };
|
|
43
50
|
return { ok: true, patch };
|
|
44
51
|
}
|
|
45
52
|
|
|
46
53
|
/** 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. */
|
|
47
|
-
export function mergeSettings(stored: { character?: unknown; signin?: unknown; friends?: unknown }, defaults: GameSettings): GameState {
|
|
54
|
+
export function mergeSettings(stored: { character?: unknown; signin?: unknown; friends?: unknown; emotes?: unknown }, defaults: GameSettings): GameState {
|
|
48
55
|
return {
|
|
49
56
|
character: isCharacter(stored.character) ? stored.character : defaults.character,
|
|
50
57
|
signin: isSigninChoice(stored.signin) ? stored.signin : defaults.signin,
|
|
51
58
|
friends: isFriends(stored.friends) ? stored.friends : isFriends(defaults.friends) ? defaults.friends : DEFAULT_FRIENDS,
|
|
52
|
-
|
|
59
|
+
emotes: isEmotes(stored.emotes) ? stored.emotes : isEmotes(defaults.emotes) ? defaults.emotes : DEFAULT_EMOTES,
|
|
60
|
+
fromAdmin: { character: isCharacter(stored.character), signin: isSigninChoice(stored.signin), friends: isFriends(stored.friends), emotes: isEmotes(stored.emotes) },
|
|
53
61
|
};
|
|
54
62
|
}
|
|
55
63
|
|
|
@@ -68,7 +76,7 @@ export const FRIENDS_CHOICES: Record<Friends, { name: string; note: string }> =
|
|
|
68
76
|
};
|
|
69
77
|
|
|
70
78
|
/** One switch of the card: its title, and one plain sentence for each state. */
|
|
71
|
-
export type SwitchId = "character" | "signin";
|
|
79
|
+
export type SwitchId = "character" | "signin" | "emotes";
|
|
72
80
|
export const SWITCHES: Record<SwitchId, { title: string; on: string; off: string }> = {
|
|
73
81
|
character: {
|
|
74
82
|
title: "Players can play as their Nanoterra character",
|
|
@@ -80,6 +88,11 @@ export const SWITCHES: Record<SwitchId, { title: string; on: string; off: string
|
|
|
80
88
|
on: "On: this game's listing file says players can sign in with Nanoterra, and the game accepts that sign-in.",
|
|
81
89
|
off: "Off: the listing file says no sign-in, and the game refuses it: a link made before still signs nobody in.",
|
|
82
90
|
},
|
|
91
|
+
emotes: {
|
|
92
|
+
title: "Allow Nanoterra emotes",
|
|
93
|
+
on: "On: players can play Nanoterra's emotes (Wave, Dance, Sit and more) from the Emotes wheel, and see the other players' emotes.",
|
|
94
|
+
off: "Off: the game shows no Nanoterra emote. The game's own emotes, if it has any, stay on the wheel.",
|
|
95
|
+
},
|
|
83
96
|
};
|
|
84
97
|
|
|
85
98
|
/**
|
|
@@ -96,5 +109,9 @@ export const REVIEW_NOTE =
|
|
|
96
109
|
export const REVIEW_LINK = "https://nano133.com/build/nanoterra/launch";
|
|
97
110
|
|
|
98
111
|
/** The state of a switch from the settings, and the patch that a press sends (the other state). */
|
|
99
|
-
export const stateOf = (id: SwitchId, s: GameSettings): boolean => (id === "character" ? s.character === "nanoterra" : signinOn(s.signin));
|
|
100
|
-
export
|
|
112
|
+
export const stateOf = (id: SwitchId, s: GameSettings): boolean => (id === "character" ? s.character === "nanoterra" : id === "emotes" ? (s.emotes ?? DEFAULT_EMOTES) === "on" : signinOn(s.signin));
|
|
113
|
+
export function pressOf(id: SwitchId, s: GameSettings): GamePatch {
|
|
114
|
+
if (id === "character") return { character: s.character === "nanoterra" ? "own" : "nanoterra" };
|
|
115
|
+
if (id === "emotes") return { emotes: stateOf("emotes", s) ? "off" : "on" };
|
|
116
|
+
return { signin: signinOn(s.signin) ? "none" : "exchange" };
|
|
117
|
+
}
|
package/src/character/create.ts
CHANGED
|
@@ -2,6 +2,7 @@ import * as THREE from "three";
|
|
|
2
2
|
import { assemble, type Assembled } 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, type Look } from "./look.js";
|
|
6
7
|
import { buildWith, paintCharacter, type Made } from "./make.js";
|
|
7
8
|
import { makePose, MAX_DT, type Motion } from "./pose.js";
|
|
@@ -202,7 +203,7 @@ export function createCharacter(options: CharacterOptions = {}): Character {
|
|
|
202
203
|
r.a.skeleton.dispose();
|
|
203
204
|
};
|
|
204
205
|
|
|
205
|
-
|
|
206
|
+
const self: Character = {
|
|
206
207
|
object,
|
|
207
208
|
get record() {
|
|
208
209
|
return frozen(record);
|
|
@@ -223,7 +224,9 @@ export function createCharacter(options: CharacterOptions = {}): Character {
|
|
|
223
224
|
if (!(step > 0)) return;
|
|
224
225
|
time += step;
|
|
225
226
|
rig.pose.step(step, motion ?? {}, reducedMotion);
|
|
226
|
-
//
|
|
227
|
+
// 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.
|
|
228
|
+
const state = rig.pose.state;
|
|
229
|
+
poseLayerOf(self)?.(rig.a.bones as Record<BoneName, THREE.Bone>, { dt: step, speed: state.speed, airborne: state.air, reducedMotion });
|
|
227
230
|
rig.a.mover?.(step, time, rig.pose.state.speed, reducedMotion);
|
|
228
231
|
},
|
|
229
232
|
wave() {
|
|
@@ -282,4 +285,5 @@ export function createCharacter(options: CharacterOptions = {}): Character {
|
|
|
282
285
|
ramp.dispose();
|
|
283
286
|
},
|
|
284
287
|
};
|
|
288
|
+
return self;
|
|
285
289
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type * as THREE from "three";
|
|
2
|
+
import type { BoneName } from "./skeleton.js";
|
|
3
|
+
|
|
4
|
+
// A layer over the pose: one function a character may carry, which its `update` calls each frame after the walk is solved and before the character's
|
|
5
|
+
// own bones (a tail, wings, long hair) follow, so they follow the layer's turns too. The emotes (`@nano133/nanoterra/emotes/player`) are such a layer:
|
|
6
|
+
// a clip blends over the bones the solver just turned. It is kept beside the character, not on it, so `Character` stays as it is. One layer a character.
|
|
7
|
+
|
|
8
|
+
/** 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. */
|
|
9
|
+
export type PoseFrame = { dt: number; speed: number; airborne: boolean; reducedMotion: boolean };
|
|
10
|
+
export type PoseLayer = (bones: Readonly<Record<BoneName, THREE.Bone>>, frame: PoseFrame) => void;
|
|
11
|
+
|
|
12
|
+
const layers = new WeakMap<object, PoseLayer>();
|
|
13
|
+
|
|
14
|
+
/** Gives a character its layer (null takes it off). */
|
|
15
|
+
export function setPoseLayer(character: object, layer: PoseLayer | null): void {
|
|
16
|
+
if (layer) layers.set(character, layer);
|
|
17
|
+
else layers.delete(character);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The character's layer, if it has one. */
|
|
21
|
+
export const poseLayerOf = (character: object): PoseLayer | undefined => layers.get(character);
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import * as THREE from "three";
|
|
2
|
+
import { BONES } from "../character/skeleton.js";
|
|
3
|
+
|
|
4
|
+
// Plays an emote's clip on a character, over the pose the solver gave its bones this frame: nano133.com's app/island/character/emotes/play.ts, with
|
|
5
|
+
// the same numbers. A clip (./clips.ts) holds each bone's turn from its parent for every frame, 30 a second, in the character's own space: Blender's
|
|
6
|
+
// IK and all is already in them, so nothing here solves. A clip fades in, and out at its end; a "full" clip (the whole body) stops when the character
|
|
7
|
+
// moves, jumps or swims, an "upper" one (the spine, the arms and the head) plays over the walk and stops only in the water. A loop plays until it
|
|
8
|
+
// stops; with reduced motion an emote holds its one picture (the clip's `hold` frame) instead. The clips load on the first emote (one chunk of their
|
|
9
|
+
// own), not with the game.
|
|
10
|
+
//
|
|
11
|
+
// The island measures a clip's age on the page's clock; here it is the sum of the steps the character's `update` took, so a paused game pauses its
|
|
12
|
+
// emotes too, and a test steps them exactly.
|
|
13
|
+
|
|
14
|
+
/** 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). */
|
|
15
|
+
export type RawClip = { n: number; loop: boolean; from?: number; kind: "full" | "upper"; hold: number; label: string; q: number[][]; p?: number[] };
|
|
16
|
+
export type RawClips = { v: number; fps: number; bones: string[]; clips: Record<string, RawClip> };
|
|
17
|
+
|
|
18
|
+
export type Clip = {
|
|
19
|
+
/** Frames (a loop's last frame is its first, so it is not among them). */
|
|
20
|
+
n: number;
|
|
21
|
+
loop: boolean;
|
|
22
|
+
/** The frame a loop goes back to (0: the whole clip loops; more: an opening that plays once, then the loop). */
|
|
23
|
+
from: number;
|
|
24
|
+
kind: "full" | "upper";
|
|
25
|
+
hold: number;
|
|
26
|
+
/** A one-shot's length in seconds. */
|
|
27
|
+
seconds: number;
|
|
28
|
+
turns: { bone: string; q: Float32Array }[];
|
|
29
|
+
/** The hips' place from their rest place, three numbers a frame (or one place), or null. */
|
|
30
|
+
hips: Float32Array | null;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const FPS = 30;
|
|
34
|
+
const Q = 10000;
|
|
35
|
+
/** Fades, in seconds: in at the start, out at a one-shot's end, and out when a move stops it. */
|
|
36
|
+
export const FADE_IN = 0.12;
|
|
37
|
+
export const FADE_END = 0.22;
|
|
38
|
+
export const FADE_STOP = 0.18;
|
|
39
|
+
const HIPS_REST = (() => {
|
|
40
|
+
const b = BONES.find((x) => x[0] === "hips")!;
|
|
41
|
+
return new THREE.Vector3(b[2], b[3], b[4]);
|
|
42
|
+
})();
|
|
43
|
+
|
|
44
|
+
let clips: Map<string, Clip> | null = null;
|
|
45
|
+
let loading: Promise<void> | null = null;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 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
|
|
49
|
+
* arrive. A failed load (the network) may be tried again.
|
|
50
|
+
*/
|
|
51
|
+
export function loadEmotes(): Promise<void> {
|
|
52
|
+
loading ??= import("./clips.js").then(
|
|
53
|
+
(m) => {
|
|
54
|
+
clips = decodeClips(m.default);
|
|
55
|
+
},
|
|
56
|
+
(e: unknown) => {
|
|
57
|
+
loading = null;
|
|
58
|
+
throw e;
|
|
59
|
+
},
|
|
60
|
+
);
|
|
61
|
+
return loading;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** True once the clips are loaded. */
|
|
65
|
+
export const emotesLoaded = (): boolean => clips !== null;
|
|
66
|
+
/** A loaded clip by its id, or null. */
|
|
67
|
+
export const emoteClip = (id: string): Clip | null => clips?.get(id) ?? null;
|
|
68
|
+
|
|
69
|
+
/** Whole numbers, the first frame then each frame's change, back to the frames (`width` numbers each); one frame when it never changes. */
|
|
70
|
+
function unpack(vals: readonly number[], width: number): Float32Array {
|
|
71
|
+
const out = new Float32Array(vals.length);
|
|
72
|
+
const acc = new Array<number>(width).fill(0);
|
|
73
|
+
for (let i = 0; i < vals.length; i++) {
|
|
74
|
+
acc[i % width] += vals[i];
|
|
75
|
+
out[i] = acc[i % width] / Q;
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function decodeClips(raw: RawClips): Map<string, Clip> {
|
|
81
|
+
const out = new Map<string, Clip>();
|
|
82
|
+
for (const [id, c] of Object.entries(raw.clips)) {
|
|
83
|
+
const turns = c.q.map(([bone, ...vals]) => {
|
|
84
|
+
const q = unpack(vals, 4);
|
|
85
|
+
// The whole numbers round each turn a little: back to unit length.
|
|
86
|
+
for (let i = 0; i < q.length; i += 4) {
|
|
87
|
+
const l = Math.hypot(q[i], q[i + 1], q[i + 2], q[i + 3]) || 1;
|
|
88
|
+
for (let k = 0; k < 4; k++) q[i + k] /= l;
|
|
89
|
+
}
|
|
90
|
+
return { bone: raw.bones[bone], q };
|
|
91
|
+
});
|
|
92
|
+
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 });
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** 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. */
|
|
98
|
+
export type ClipRun = { id: string | null; age: number; stopAge: number; stopWhy: "move" | "stop"; waiting: boolean; ended: "end" | "move" | "stop" | null };
|
|
99
|
+
export const newRun = (): ClipRun => ({ id: null, age: 0, stopAge: -1, stopWhy: "move", waiting: false, ended: null });
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* 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
|
|
103
|
+
* through its own repeat: a player's loop is sent again for those who come later.
|
|
104
|
+
*/
|
|
105
|
+
export function startClip(run: ClipRun, id: string, age = 0): void {
|
|
106
|
+
const now = run.id ? clips?.get(run.id) : null;
|
|
107
|
+
if (run.id === id && now && now.loop && run.stopAge < 0) return;
|
|
108
|
+
run.id = id;
|
|
109
|
+
run.age = Math.max(0, age);
|
|
110
|
+
run.stopAge = -1;
|
|
111
|
+
run.waiting = !clips;
|
|
112
|
+
run.ended = null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Stops the clip on show with its fade (`stop` from the game). */
|
|
116
|
+
export function stopClip(run: ClipRun): void {
|
|
117
|
+
if (run.id && run.stopAge < 0) {
|
|
118
|
+
run.stopAge = run.age;
|
|
119
|
+
run.stopWhy = "stop";
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export type ClipFrame = {
|
|
124
|
+
/** The step (s). */
|
|
125
|
+
dt: number;
|
|
126
|
+
moving: boolean;
|
|
127
|
+
air: boolean;
|
|
128
|
+
swim: boolean;
|
|
129
|
+
calm: boolean;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const qa = new THREE.Quaternion();
|
|
133
|
+
const qb = new THREE.Quaternion();
|
|
134
|
+
const va = new THREE.Vector3();
|
|
135
|
+
const vb = new THREE.Vector3();
|
|
136
|
+
const smooth = (w: number) => w * w * (3 - 2 * w);
|
|
137
|
+
|
|
138
|
+
/** Where a clip is at `age` seconds: the frame before, the frame after and how far between. */
|
|
139
|
+
function frameAt(c: Clip, age: number, calm: boolean): [number, number, number] {
|
|
140
|
+
if (calm) return [c.hold, c.hold, 0];
|
|
141
|
+
let f = age * FPS;
|
|
142
|
+
if (c.loop && f >= c.n) f = c.from + ((f - c.from) % (c.n - c.from));
|
|
143
|
+
else f = Math.min(f, c.n - 1);
|
|
144
|
+
const i0 = Math.floor(f);
|
|
145
|
+
const i1 = i0 + 1 >= c.n ? (c.loop ? c.from : c.n - 1) : i0 + 1;
|
|
146
|
+
return [i0, i1, f - i0];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* 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,
|
|
151
|
+
* the water or `stopClip`), `run.id` goes back to null and `run.ended` says why.
|
|
152
|
+
*/
|
|
153
|
+
export function stepClip(run: ClipRun, bones: Readonly<Record<string, THREE.Bone>>, o: ClipFrame): boolean {
|
|
154
|
+
if (!run.id) return false;
|
|
155
|
+
const clip = clips?.get(run.id);
|
|
156
|
+
if (!clip) {
|
|
157
|
+
// Not loaded yet (or not a clip): it starts when the clips arrive, from its first frame.
|
|
158
|
+
if (!clips) {
|
|
159
|
+
run.waiting = true;
|
|
160
|
+
void loadEmotes().catch(() => undefined);
|
|
161
|
+
} else run.id = null;
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
if (run.waiting) {
|
|
165
|
+
run.waiting = false;
|
|
166
|
+
run.age = 0;
|
|
167
|
+
} else run.age += o.dt;
|
|
168
|
+
const age = run.age;
|
|
169
|
+
const stop = o.swim || (clip.kind === "full" && (o.moving || o.air));
|
|
170
|
+
if (stop && run.stopAge < 0) {
|
|
171
|
+
run.stopAge = age;
|
|
172
|
+
run.stopWhy = "move";
|
|
173
|
+
}
|
|
174
|
+
let w = Math.min(1, age / FADE_IN);
|
|
175
|
+
if (!clip.loop) w = Math.min(w, (clip.seconds - age) / FADE_END);
|
|
176
|
+
if (run.stopAge >= 0) w = Math.min(w, 1 - (age - run.stopAge) / FADE_STOP);
|
|
177
|
+
if (w <= 0) {
|
|
178
|
+
if (run.stopAge >= 0 || (!clip.loop && age >= clip.seconds)) {
|
|
179
|
+
run.ended = run.stopAge >= 0 ? run.stopWhy : "end";
|
|
180
|
+
run.id = null;
|
|
181
|
+
}
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
w = smooth(Math.min(1, w));
|
|
185
|
+
const [i0, i1, t] = frameAt(clip, age, o.calm);
|
|
186
|
+
for (const tr of clip.turns) {
|
|
187
|
+
const bone = bones[tr.bone];
|
|
188
|
+
if (!bone) continue;
|
|
189
|
+
if (tr.q.length === 4) qa.fromArray(tr.q, 0);
|
|
190
|
+
else {
|
|
191
|
+
qa.fromArray(tr.q, i0 * 4);
|
|
192
|
+
qb.fromArray(tr.q, i1 * 4);
|
|
193
|
+
qa.slerp(qb, t);
|
|
194
|
+
}
|
|
195
|
+
bone.quaternion.slerp(qa, w);
|
|
196
|
+
}
|
|
197
|
+
const hips = bones.hips;
|
|
198
|
+
if (clip.hips && hips) {
|
|
199
|
+
if (clip.hips.length === 3) va.fromArray(clip.hips, 0);
|
|
200
|
+
else va.fromArray(clip.hips, i0 * 3).lerp(vb.fromArray(clip.hips, i1 * 3), t);
|
|
201
|
+
hips.position.lerp(va.add(HIPS_REST), w);
|
|
202
|
+
}
|
|
203
|
+
return true;
|
|
204
|
+
}
|