@nano133/nanoterra 0.7.1 → 0.8.1
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 +51 -0
- package/LICENSE +3 -0
- package/README.md +212 -17
- 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/dom.d.ts +2 -0
- package/dist/admin/panel/dom.js +6 -0
- 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 +61 -0
- package/dist/emotes/panel/mount.js +364 -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 +5 -0
- package/dist/friends/panel/mount.js +5 -2
- 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 +35 -0
- package/dist/options/panel/mount.js +380 -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/dom.ts +6 -0
- 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 +397 -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 +8 -2
- 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 +423 -0
- package/src/options/panel/style.css +528 -0
- package/src/options/reflect.ts +28 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Nanoterra's emotes, as nano133.com's Nano Island has them (app/island/character/emotes/list.ts there): the Wave, which is the character's own
|
|
2
|
+
// code (createCharacter's `wave`), and ten clips made in Blender (./clips.ts, played by ./clip.ts). This list is all a wheel and a network need: it
|
|
3
|
+
// loads no clip. The order is the panel's, and the order of the pictures in icons.webp.
|
|
4
|
+
export const EMOTES = [
|
|
5
|
+
{ id: "wave", label: "Wave", kind: "wave", loop: false },
|
|
6
|
+
{ id: "cheer", label: "Cheer", kind: "full", loop: false },
|
|
7
|
+
{ id: "clap", label: "Clap", kind: "upper", loop: false },
|
|
8
|
+
{ id: "dance", label: "Dance", kind: "full", loop: true },
|
|
9
|
+
{ id: "bow", label: "Bow", kind: "full", loop: false },
|
|
10
|
+
{ id: "shrug", label: "Shrug", kind: "upper", loop: false },
|
|
11
|
+
{ id: "laugh", label: "Laugh", kind: "full", loop: false },
|
|
12
|
+
{ id: "point", label: "Point", kind: "upper", loop: false },
|
|
13
|
+
{ id: "sit", label: "Sit", kind: "full", loop: true },
|
|
14
|
+
{ id: "facepalm", label: "Facepalm", kind: "upper", loop: false },
|
|
15
|
+
{ id: "spin", label: "Spin", kind: "full", loop: false },
|
|
16
|
+
];
|
|
17
|
+
const IDS = new Set(EMOTES.map((e) => e.id));
|
|
18
|
+
export const isEmote = (v) => typeof v === "string" && IDS.has(v);
|
|
19
|
+
export const isClip = (v) => isEmote(v) && v !== "wave";
|
|
20
|
+
/** A Nanoterra emote by its id, or undefined. */
|
|
21
|
+
export const emoteOf = (id) => EMOTES.find((e) => e.id === id);
|
|
22
|
+
/** True for the emotes that play until a move stops them (Dance; Sit, whose sitting down plays once, then its seated part loops). */
|
|
23
|
+
export const isLoop = (id) => emoteOf(id)?.loop === true;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { KeyDef, OptionsStore, ToggleDef } from "../options/core.js";
|
|
2
|
+
/** The two toggles with their short keys, for `optionSection("emotes", EMOTE_OPTION_DEFS, "Emotes")` (the keys become "emotes.show" and "emotes.others"). */
|
|
3
|
+
export declare const EMOTE_OPTION_DEFS: readonly ToggleDef[];
|
|
4
|
+
/** The wheel's key on a keyboard ("keys.emotes", B; shown on a desktop only): what `keyBinding("emotes", "Emotes wheel", "KeyB")` makes. */
|
|
5
|
+
export declare const EMOTE_KEY_OPTION: KeyDef;
|
|
6
|
+
/** Every emote setting with its whole key, ready for `createOptions({ defs })` or `store.add(...)`: "emotes.show", "emotes.others" and "keys.emotes". */
|
|
7
|
+
export declare const EMOTE_OPTIONS: readonly (ToggleDef | KeyDef)[];
|
|
8
|
+
/** What the emotes read of an options store: `get` and, to follow a change, `subscribe`. The options module's `OptionsStore` is one. */
|
|
9
|
+
export type EmoteOptionsStore = Pick<OptionsStore, "get"> & Partial<Pick<OptionsStore, "subscribe">>;
|
|
10
|
+
export type EmoteSettings = {
|
|
11
|
+
show: boolean;
|
|
12
|
+
others: boolean;
|
|
13
|
+
key: string;
|
|
14
|
+
};
|
|
15
|
+
/** The settings from a store: a store that is missing or a key it does not have (it throws) reads on, and B. */
|
|
16
|
+
export declare function readEmoteOptions(store: EmoteOptionsStore | null | undefined): EmoteSettings;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// The player's emote settings, for the options menu ("@nano133/nanoterra/options"): two toggles under "Emotes" and the wheel's key under "Keys". Add them
|
|
2
|
+
// to the game's store and the menu shows them (an open panel draws them at once). The wheel and the player read them from a store that is passed; with no
|
|
3
|
+
// store (or a store without them) both toggles are on and the key is B.
|
|
4
|
+
//
|
|
5
|
+
// const options = createOptions({ game: "my-game", defs: [...myDefs, ...EMOTE_OPTIONS] });
|
|
6
|
+
// // or later: options.add(optionSection("emotes", EMOTE_OPTION_DEFS, "Emotes")), and options.add([EMOTE_KEY_OPTION])
|
|
7
|
+
// mountEmotes(el, { game: "my-game", onPlay, options });
|
|
8
|
+
// playRemote(other, { id }, { options });
|
|
9
|
+
//
|
|
10
|
+
// Only the options module's TYPES are imported (from its store's own file): this path loads none of its code.
|
|
11
|
+
/** The two toggles with their short keys, for `optionSection("emotes", EMOTE_OPTION_DEFS, "Emotes")` (the keys become "emotes.show" and "emotes.others"). */
|
|
12
|
+
export const EMOTE_OPTION_DEFS = [
|
|
13
|
+
{ kind: "toggle", key: "show", label: "Show the Emotes key", help: "B still opens the wheel on a keyboard", tab: "basic", default: true },
|
|
14
|
+
{ kind: "toggle", key: "others", label: "Show other players' emotes", tab: "advanced", default: true },
|
|
15
|
+
];
|
|
16
|
+
/** The wheel's key on a keyboard ("keys.emotes", B; shown on a desktop only): what `keyBinding("emotes", "Emotes wheel", "KeyB")` makes. */
|
|
17
|
+
export const EMOTE_KEY_OPTION = { kind: "key", key: "keys.emotes", label: "Emotes wheel", tab: "advanced", group: "Keys", default: "KeyB", devices: ["desktop"] };
|
|
18
|
+
/** Every emote setting with its whole key, ready for `createOptions({ defs })` or `store.add(...)`: "emotes.show", "emotes.others" and "keys.emotes". */
|
|
19
|
+
export const EMOTE_OPTIONS = [...EMOTE_OPTION_DEFS.map((d) => ({ ...d, key: `emotes.${d.key}`, group: "Emotes" })), EMOTE_KEY_OPTION];
|
|
20
|
+
/** The settings from a store: a store that is missing or a key it does not have (it throws) reads on, and B. */
|
|
21
|
+
export function readEmoteOptions(store) {
|
|
22
|
+
const read = (key) => {
|
|
23
|
+
if (!store)
|
|
24
|
+
return undefined;
|
|
25
|
+
try {
|
|
26
|
+
return store.get(key);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
const key = read(EMOTE_KEY_OPTION.key);
|
|
33
|
+
return { show: read("emotes.show") !== false, others: read("emotes.others") !== false, key: typeof key === "string" && key ? key : "KeyB" };
|
|
34
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// "@nano133/nanoterra/emotes/wheel": the Emotes key and the emote wheel of a game, drawn in the browser. Plain DOM, no framework, no Three.js.
|
|
2
|
+
//
|
|
3
|
+
// import { mountEmotes } from "@nano133/nanoterra/emotes/wheel";
|
|
4
|
+
// import "@nano133/nanoterra/emotes/style.css";
|
|
5
|
+
// const wheel = mountEmotes(document.getElementById("emotes")!, { game: "my-game", onPlay: (id) => emotes.play(id), onOpen: () => void loadEmotes() });
|
|
6
|
+
//
|
|
7
|
+
// With the game's own animations: `emotes: [{ id: "flip", label: "Flip", icon: "/emotes/flip.png" }, ...]` and an `onPlay` that plays them.
|
|
8
|
+
export { SLIDE_PX, mountEmotes, placeAt } from "./mount.js";
|
|
9
|
+
export { EMOTE_ICONS_URL } from "../icons.js";
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { type EmoteId } from "../list.js";
|
|
2
|
+
import { type EmoteOptionsStore } from "../options.js";
|
|
3
|
+
import { type Slots } from "../wheel.js";
|
|
4
|
+
/** One of the game's own emotes: its id (what `onPlay` gets), the name on the wheel, and a picture's address (square; left out: the name alone). */
|
|
5
|
+
export type OwnEmote = {
|
|
6
|
+
id: string;
|
|
7
|
+
label: string;
|
|
8
|
+
icon?: string;
|
|
9
|
+
};
|
|
10
|
+
/** A Nanoterra emote by its id, or one of the game's own. */
|
|
11
|
+
export type WheelEmote = EmoteId | OwnEmote;
|
|
12
|
+
export type EmotesMountOptions = {
|
|
13
|
+
/** The game's id: the wheel's eight places are kept per game, in this browser (`n133-emotes:<game>`). */
|
|
14
|
+
game: string;
|
|
15
|
+
/** The player picked an emote. Play it: Nanoterra's with `createEmotes(...).play(id)`, the game's own with its own animation. */
|
|
16
|
+
onPlay: (id: string) => void;
|
|
17
|
+
/** The emotes the player may put on the wheel, in the order Edit shows them. Default: Nanoterra's eleven. */
|
|
18
|
+
emotes?: readonly WheelEmote[];
|
|
19
|
+
/** The admin's `emotes` setting: "off" leaves Nanoterra's emotes out (the game's own stay). With nothing left, nothing is drawn. Default "on". */
|
|
20
|
+
nanoterraEmotes?: "on" | "off";
|
|
21
|
+
/** The eight places before the player changes them (ids, or null for an empty place). Default: the island's wheel, or the list's first eight. */
|
|
22
|
+
defaults?: readonly (string | null)[];
|
|
23
|
+
/** The options menu's store: `emotes.show` off hides the key (B still opens the wheel); `keys.emotes` is the wheel's key. Left out: the key shows, and B. */
|
|
24
|
+
options?: EmoteOptionsStore | null;
|
|
25
|
+
/** The address of Nanoterra's picture strip. Default: the package's icons.webp (see the README for bundlers). */
|
|
26
|
+
icons?: string;
|
|
27
|
+
/** B (or the player's own key: the options' "keys.emotes") opens and closes the wheel, and 1 to 8 play its places. Default true. */
|
|
28
|
+
keys?: boolean;
|
|
29
|
+
/** Asked before B opens or closes the wheel: false when the game uses the key now (a dialog of its own, B bound to an action). */
|
|
30
|
+
keyAllowed?: (e: KeyboardEvent) => boolean;
|
|
31
|
+
/** The wheel opened. Load the clips here the first time: `loadEmotes` of "@nano133/nanoterra/emotes/player". */
|
|
32
|
+
onOpen?: () => void;
|
|
33
|
+
/** "light", "dark", or "device" (the default): the page's `data-n133-theme` when it has one, else the device's setting. */
|
|
34
|
+
theme?: "light" | "dark" | "device";
|
|
35
|
+
/** Where the wheel is kept (default: the page's localStorage). null keeps it only for the life of the page. */
|
|
36
|
+
storage?: Pick<Storage, "getItem" | "setItem"> | null;
|
|
37
|
+
/**
|
|
38
|
+
* Where the keyboard focus goes when the wheel closes. "game" (the default): out of the key and the wheel, to the page, so Space and the game's
|
|
39
|
+
* other keys reach the game again (a focused key would take Space and open the wheel again). "key": back to the key, for a page with no keys of its own.
|
|
40
|
+
*/
|
|
41
|
+
focusAfterClose?: "game" | "key";
|
|
42
|
+
};
|
|
43
|
+
export type MountedEmotes = {
|
|
44
|
+
open(): void;
|
|
45
|
+
close(): void;
|
|
46
|
+
readonly isOpen: boolean;
|
|
47
|
+
/** The eight places now. */
|
|
48
|
+
readonly slots: Slots;
|
|
49
|
+
/** Removes the key and the wheel, and stops listening to the keyboard and the options. */
|
|
50
|
+
destroy(): void;
|
|
51
|
+
};
|
|
52
|
+
/** How far a finger moves on the key before it is a slide, not a tap (CSS pixels). */
|
|
53
|
+
export declare const SLIDE_PX = 14;
|
|
54
|
+
/** 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. */
|
|
55
|
+
export declare function placeAt(rect: {
|
|
56
|
+
left: number;
|
|
57
|
+
top: number;
|
|
58
|
+
width: number;
|
|
59
|
+
height: number;
|
|
60
|
+
} | null, x: number, y: number): number | null;
|
|
61
|
+
export declare function mountEmotes(root: HTMLElement, options: EmotesMountOptions): MountedEmotes;
|
|
@@ -0,0 +1,364 @@
|
|
|
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, releaseFocus } 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 (options.focusAfterClose === "key") {
|
|
246
|
+
if (focusKey && !key.hidden)
|
|
247
|
+
key.focus();
|
|
248
|
+
}
|
|
249
|
+
else
|
|
250
|
+
releaseFocus(wrap);
|
|
251
|
+
}
|
|
252
|
+
editKey.addEventListener("click", () => {
|
|
253
|
+
edit = !edit;
|
|
254
|
+
full = false;
|
|
255
|
+
drawSlots();
|
|
256
|
+
});
|
|
257
|
+
// A press on the dim page beside the wheel closes it.
|
|
258
|
+
layer.addEventListener("pointerdown", (e) => {
|
|
259
|
+
if (e.target === layer)
|
|
260
|
+
close(false);
|
|
261
|
+
});
|
|
262
|
+
// The key: a press opens the wheel at once; a slide from it onto a place and a let-go plays that place.
|
|
263
|
+
let press = null;
|
|
264
|
+
key.addEventListener("pointerdown", (e) => {
|
|
265
|
+
if (e.button !== 0 || isOpen)
|
|
266
|
+
return;
|
|
267
|
+
open();
|
|
268
|
+
press = { id: e.pointerId, x: e.clientX, y: e.clientY, slid: false };
|
|
269
|
+
try {
|
|
270
|
+
key.setPointerCapture(e.pointerId);
|
|
271
|
+
}
|
|
272
|
+
catch {
|
|
273
|
+
// A pointer that is already gone: the wheel stays open for a tap.
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
key.addEventListener("pointermove", (e) => {
|
|
277
|
+
const p = press;
|
|
278
|
+
if (!p || p.id !== e.pointerId)
|
|
279
|
+
return;
|
|
280
|
+
if (!p.slid && Math.hypot(e.clientX - p.x, e.clientY - p.y) > SLIDE_PX)
|
|
281
|
+
p.slid = true;
|
|
282
|
+
if (p.slid) {
|
|
283
|
+
hover = placeAt(ring.getBoundingClientRect(), e.clientX, e.clientY);
|
|
284
|
+
drawLit();
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
key.addEventListener("pointerup", (e) => {
|
|
288
|
+
const p = press;
|
|
289
|
+
press = null;
|
|
290
|
+
if (!p || !p.slid || p.id !== e.pointerId)
|
|
291
|
+
return;
|
|
292
|
+
const i = placeAt(ring.getBoundingClientRect(), e.clientX, e.clientY);
|
|
293
|
+
const id = i === null ? null : wheel.slots[i];
|
|
294
|
+
if (id)
|
|
295
|
+
play(id);
|
|
296
|
+
else {
|
|
297
|
+
hover = null;
|
|
298
|
+
drawLit();
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
key.addEventListener("pointercancel", () => {
|
|
302
|
+
press = null;
|
|
303
|
+
hover = null;
|
|
304
|
+
drawLit();
|
|
305
|
+
});
|
|
306
|
+
// Enter or Space is a click with no pointer: it opens and closes the wheel. A pointer's click is already handled.
|
|
307
|
+
key.addEventListener("click", (e) => {
|
|
308
|
+
if (e.detail !== 0)
|
|
309
|
+
return;
|
|
310
|
+
if (isOpen)
|
|
311
|
+
close(true);
|
|
312
|
+
else
|
|
313
|
+
open(true);
|
|
314
|
+
});
|
|
315
|
+
const onKey = (e) => {
|
|
316
|
+
if (destroyed)
|
|
317
|
+
return;
|
|
318
|
+
if (e.key === "Escape" && isOpen) {
|
|
319
|
+
e.preventDefault();
|
|
320
|
+
close(true);
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
if (!keys || e.repeat || e.ctrlKey || e.metaKey || e.altKey || inField(e.target))
|
|
324
|
+
return;
|
|
325
|
+
if (e.code === openCode) {
|
|
326
|
+
if (options.keyAllowed && !options.keyAllowed(e))
|
|
327
|
+
return;
|
|
328
|
+
e.preventDefault();
|
|
329
|
+
if (isOpen)
|
|
330
|
+
close(true);
|
|
331
|
+
else
|
|
332
|
+
open(true);
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
const i = DIGITS[e.code];
|
|
336
|
+
if (i === undefined || !isOpen || edit)
|
|
337
|
+
return;
|
|
338
|
+
const id = wheel.slots[i];
|
|
339
|
+
if (!id)
|
|
340
|
+
return;
|
|
341
|
+
e.preventDefault();
|
|
342
|
+
play(id);
|
|
343
|
+
};
|
|
344
|
+
document.addEventListener("keydown", onKey);
|
|
345
|
+
cleanup.push(() => document.removeEventListener("keydown", onKey));
|
|
346
|
+
return {
|
|
347
|
+
open: () => open(false),
|
|
348
|
+
close: () => close(false),
|
|
349
|
+
get isOpen() {
|
|
350
|
+
return isOpen;
|
|
351
|
+
},
|
|
352
|
+
get slots() {
|
|
353
|
+
return wheel.slots;
|
|
354
|
+
},
|
|
355
|
+
destroy() {
|
|
356
|
+
if (destroyed)
|
|
357
|
+
return;
|
|
358
|
+
destroyed = true;
|
|
359
|
+
for (const f of cleanup)
|
|
360
|
+
f();
|
|
361
|
+
wrap.remove();
|
|
362
|
+
},
|
|
363
|
+
};
|
|
364
|
+
}
|
|
@@ -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
|
+
}
|