@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
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
// The options store: a game's settings as data, their defaults for this device, and the player's choices, kept in this browser. No DOM: a game with its own
|
|
2
|
+
// menu reads, writes and hears the store; the kit's panel (./panel) is one view of it.
|
|
3
|
+
//
|
|
4
|
+
// - A setting is a def: a toggle (on or off), a choice (one of a list), a range (a number from min to max in steps) or a key (a key binding: a
|
|
5
|
+
// `KeyboardEvent.code`). Each has a key, a label the player reads, an optional help line, a tab ("basic" or "advanced"), an optional group heading and
|
|
6
|
+
// a default.
|
|
7
|
+
// - A default is one value, or one for each device class: { phone?, tablet?, desktop?, reducedMotion?, default }. The class comes from the pointer and the
|
|
8
|
+
// screen (`detectDevice`); `reducedMotion` wins while the device asks for less motion. The device's reduced-motion setting is followed LIVE: a player
|
|
9
|
+
// who never set the option follows the device when it changes (Echo Island's draft, 2026-11-01).
|
|
10
|
+
// - Only the choices that DIFFER from this device's default are kept (localStorage, `n133-options:<game>`), so a better default later reaches every
|
|
11
|
+
// player who never touched that setting. A value that does not fit its def any more (the wrong type, out of range, an unknown choice) is dropped on
|
|
12
|
+
// read, and so is a key that no def has, unless it has a dot: a key like "emotes.show" belongs to a module's section that may be added later, so it waits.
|
|
13
|
+
// - Two key bindings never share a key: setting one to a key that another has gives the other this one's old key (a swap).
|
|
14
|
+
// - A refused storage (some private windows, a full disk) works in memory for the life of the page.
|
|
15
|
+
|
|
16
|
+
export type Tab = "basic" | "advanced";
|
|
17
|
+
export type DeviceClass = "phone" | "tablet" | "desktop";
|
|
18
|
+
export const DEVICE_CLASSES: readonly DeviceClass[] = ["phone", "tablet", "desktop"];
|
|
19
|
+
|
|
20
|
+
/** A default for each device class. `default` is for a class left out; `reducedMotion` wins while the device asks for less motion. */
|
|
21
|
+
export type DeviceDefault<V> = { phone?: V; tablet?: V; desktop?: V; reducedMotion?: V; default: V };
|
|
22
|
+
|
|
23
|
+
type DefBase<V> = {
|
|
24
|
+
/** The key the game reads. Letters, digits, "_" and "-", in parts joined by dots; a module's section starts with its own name ("emotes.show"). */
|
|
25
|
+
key: string;
|
|
26
|
+
/** The words on the row: short, the player reads them ("Music", "Invert look"). */
|
|
27
|
+
label: string;
|
|
28
|
+
/** One short line under the label. */
|
|
29
|
+
help?: string;
|
|
30
|
+
tab: Tab;
|
|
31
|
+
/** The heading the row goes under, in the order the groups first appear. Rows with no group come first. */
|
|
32
|
+
group?: string;
|
|
33
|
+
default: V | DeviceDefault<V>;
|
|
34
|
+
/** Show the row only on these device classes (a stick's side means nothing with a mouse). The store keeps the value on every device. */
|
|
35
|
+
devices?: readonly DeviceClass[];
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type ToggleDef = DefBase<boolean> & { kind: "toggle" };
|
|
39
|
+
export type ChoiceValue = string | number;
|
|
40
|
+
export type ChoiceDef = DefBase<ChoiceValue> & {
|
|
41
|
+
kind: "choice";
|
|
42
|
+
/** At least two, each value once. */
|
|
43
|
+
choices: readonly { value: ChoiceValue; label: string }[];
|
|
44
|
+
/** "buttons": a row of keys; "list": a drop-down. Left out: buttons for at most 4 short labels (10 characters), else a list. */
|
|
45
|
+
display?: "buttons" | "list";
|
|
46
|
+
};
|
|
47
|
+
export type RangeDef = DefBase<number> & {
|
|
48
|
+
kind: "range";
|
|
49
|
+
min: number;
|
|
50
|
+
max: number;
|
|
51
|
+
step: number;
|
|
52
|
+
/** Put after the number as written: "%" gives "80%", " s" gives "3 s". */
|
|
53
|
+
unit?: string;
|
|
54
|
+
};
|
|
55
|
+
/** A key binding: the value is a `KeyboardEvent.code` ("KeyE", "Space", "ArrowUp"): the key's place, the same on every keyboard layout. */
|
|
56
|
+
export type KeyDef = DefBase<string> & { kind: "key" };
|
|
57
|
+
export type OptionDef = ToggleDef | ChoiceDef | RangeDef | KeyDef;
|
|
58
|
+
export type OptionValue = boolean | ChoiceValue;
|
|
59
|
+
|
|
60
|
+
type Kept = Pick<Storage, "getItem" | "setItem" | "removeItem">;
|
|
61
|
+
|
|
62
|
+
export type OptionsInit = {
|
|
63
|
+
/** The game's id: the storage key is `n133-options:<game>`. */
|
|
64
|
+
game: string;
|
|
65
|
+
defs: readonly OptionDef[];
|
|
66
|
+
/** The device class (default: `detectDevice`). */
|
|
67
|
+
device?: () => DeviceClass;
|
|
68
|
+
/** Whether the device asks for less motion (default: `prefers-reduced-motion: reduce`, followed live). A game's own function is read at start and on `redetect()`. */
|
|
69
|
+
reducedMotion?: () => boolean;
|
|
70
|
+
/** Where the choices are kept (default: the page's localStorage). null keeps them for the life of the page only. */
|
|
71
|
+
storage?: Kept | null;
|
|
72
|
+
/**
|
|
73
|
+
* Changes what was kept before it is read: a renamed key, a new scale, or a game's old save under another key (read it from `storage` when `stored` is
|
|
74
|
+
* empty, and remove it). Gets a copy of the stored object ({} when there is none); returns the object to read. What it returns is checked like any kept value.
|
|
75
|
+
*/
|
|
76
|
+
migrate?: (stored: Record<string, unknown>, storage: Kept | null) => Record<string, unknown> | null | undefined;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export type OptionsStore = {
|
|
80
|
+
readonly game: string;
|
|
81
|
+
/** The device class the defaults are resolved for. */
|
|
82
|
+
readonly device: DeviceClass;
|
|
83
|
+
/** The value now. Throws for a key no def has. */
|
|
84
|
+
get<T extends OptionValue = OptionValue>(key: string): T;
|
|
85
|
+
/**
|
|
86
|
+
* Sets a value, keeps it (only if it differs from the default) and tells the listeners. A range's number is put on its nearest step inside min and max;
|
|
87
|
+
* a key binding that another binding has swaps with it. Throws a TypeError for an unknown key, a value of the wrong type, a number that is not finite,
|
|
88
|
+
* an unknown choice and a key binding that is not a `KeyboardEvent.code`.
|
|
89
|
+
*/
|
|
90
|
+
set(key: string, value: OptionValue): void;
|
|
91
|
+
/** One setting back to its default; with no key, every setting. */
|
|
92
|
+
reset(key?: string): void;
|
|
93
|
+
/** Every value now, by key (a copy). */
|
|
94
|
+
values(): Record<string, OptionValue>;
|
|
95
|
+
/** The defs, in order (the game's first, then each section in the order it was added). */
|
|
96
|
+
defs(): readonly OptionDef[];
|
|
97
|
+
/** The def of a key, or undefined. */
|
|
98
|
+
def(key: string): OptionDef | undefined;
|
|
99
|
+
/** This device's default for a key now. Throws for a key no def has. */
|
|
100
|
+
defaultOf(key: string): OptionValue;
|
|
101
|
+
/** True when the key (or, with no key, every setting) is at its default. */
|
|
102
|
+
isDefault(key?: string): boolean;
|
|
103
|
+
/** Hears each change of a value (set, reset, a default that moved): the key and the new value. Returns the call that stops it. */
|
|
104
|
+
subscribe(fn: (key: string, value: OptionValue) => void): () => void;
|
|
105
|
+
/** Hears each add(): a panel draws the new rows. Returns the call that stops it. */
|
|
106
|
+
subscribeDefs(fn: () => void): () => void;
|
|
107
|
+
/** Adds defs later (a module's section). A key that is already there throws, and nothing is added. */
|
|
108
|
+
add(defs: readonly OptionDef[]): void;
|
|
109
|
+
/** Reads the device class and reduced motion again and moves the defaults: a setting the player never changed follows, and its listeners hear it. */
|
|
110
|
+
redetect(): void;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
export const OPTIONS_PREFIX = "n133-options:";
|
|
114
|
+
/** The localStorage key of a game's choices. */
|
|
115
|
+
export const optionsKey = (game: string): string => `${OPTIONS_PREFIX}${game}`;
|
|
116
|
+
|
|
117
|
+
const KEY_RE = /^[A-Za-z][A-Za-z0-9_-]*(\.[A-Za-z][A-Za-z0-9_-]*)*$/;
|
|
118
|
+
const KEY_MAX = 64;
|
|
119
|
+
/** A `KeyboardEvent.code`: "KeyE", "Digit1", "Space", "ArrowUp", "F5". */
|
|
120
|
+
const CODE_RE = /^[A-Za-z][A-Za-z0-9]{0,23}$/;
|
|
121
|
+
const REDUCE_QUERY = "(prefers-reduced-motion: reduce)";
|
|
122
|
+
|
|
123
|
+
type Media = Pick<MediaQueryList, "matches"> & Partial<Pick<MediaQueryList, "addEventListener">>;
|
|
124
|
+
/** A media query; null when there is none (Node, a test). */
|
|
125
|
+
function mediaQuery(query: string): Media | null {
|
|
126
|
+
try {
|
|
127
|
+
return typeof globalThis.matchMedia === "function" ? globalThis.matchMedia(query) : null;
|
|
128
|
+
} catch {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The device class: a coarse pointer (a finger) and a short side under 600 px is a phone; a coarse pointer otherwise is a tablet; anything else is a
|
|
135
|
+
* desktop. The short side is the screen's, so turning a phone does not change it. With no matchMedia (Node), a desktop.
|
|
136
|
+
*/
|
|
137
|
+
export function detectDevice(): DeviceClass {
|
|
138
|
+
if (mediaQuery("(pointer: coarse)")?.matches !== true) return "desktop";
|
|
139
|
+
const g = globalThis as { screen?: { width: number; height: number }; innerWidth?: number; innerHeight?: number };
|
|
140
|
+
const short = Math.min(g.screen?.width || g.innerWidth || 0, g.screen?.height || g.innerHeight || 0);
|
|
141
|
+
return short > 0 && short < 600 ? "phone" : "tablet";
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** `prefers-reduced-motion: reduce`; false where it cannot be read. */
|
|
145
|
+
export const prefersReducedMotion = (): boolean => mediaQuery(REDUCE_QUERY)?.matches === true;
|
|
146
|
+
|
|
147
|
+
const isDeviceDefault = <V>(d: V | DeviceDefault<V>): d is DeviceDefault<V> => typeof d === "object" && d !== null;
|
|
148
|
+
|
|
149
|
+
/** The default a def has on a device class (and with reduced motion asked for or not). */
|
|
150
|
+
export function resolveDefault<V extends OptionValue>(def: { default: V | DeviceDefault<V> }, device: DeviceClass, reducedMotion = false): V {
|
|
151
|
+
const d = def.default;
|
|
152
|
+
if (!isDeviceDefault(d)) return d;
|
|
153
|
+
if (reducedMotion && d.reducedMotion !== undefined) return d.reducedMotion;
|
|
154
|
+
return d[device] !== undefined ? (d[device] as V) : d.default;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The number of decimals of a number, so a snapped value is 0.3 and not 0.30000000000000004. */
|
|
158
|
+
const decimals = (n: number): number => {
|
|
159
|
+
const s = String(n);
|
|
160
|
+
const e = s.match(/e-(\d+)$/);
|
|
161
|
+
if (e) return Number(e[1]);
|
|
162
|
+
const dot = s.indexOf(".");
|
|
163
|
+
return dot < 0 ? 0 : s.length - dot - 1;
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
/** A number on a range's nearest step from min, inside min and max. */
|
|
167
|
+
export function snapToRange(def: Pick<RangeDef, "min" | "max" | "step">, v: number): number {
|
|
168
|
+
const places = Math.max(decimals(def.step), decimals(def.min));
|
|
169
|
+
const steps = Math.round((Math.min(def.max, Math.max(def.min, v)) - def.min) / def.step);
|
|
170
|
+
const out = Number((def.min + steps * def.step).toFixed(places));
|
|
171
|
+
return out > def.max ? Number((out - def.step).toFixed(places)) : out;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** The words for a `KeyboardEvent.code` on a key cap: "KeyE" is "E", "Digit1" is "1", "ArrowUp" is "↑", "ShiftLeft" is "Shift". */
|
|
175
|
+
export function keyLabel(code: string): string {
|
|
176
|
+
if (/^Key[A-Z]$/.test(code)) return code.slice(3);
|
|
177
|
+
if (/^Digit\d$/.test(code)) return code.slice(5);
|
|
178
|
+
if (/^Numpad\d$/.test(code)) return `Num ${code.slice(6)}`;
|
|
179
|
+
const named: Record<string, string> = {
|
|
180
|
+
ArrowUp: "↑", ArrowDown: "↓", ArrowLeft: "←", ArrowRight: "→", Escape: "Esc", Backquote: "`", Minus: "-", Equal: "=", BracketLeft: "[", BracketRight: "]",
|
|
181
|
+
Semicolon: ";", Quote: "'", Comma: ",", Period: ".", Slash: "/", Backslash: "\\", ShiftLeft: "Shift", ShiftRight: "Right Shift", ControlLeft: "Ctrl",
|
|
182
|
+
ControlRight: "Right Ctrl", AltLeft: "Alt", AltRight: "Right Alt", MetaLeft: "Cmd", MetaRight: "Right Cmd", Enter: "Enter", Space: "Space", Backspace: "Backspace", CapsLock: "Caps Lock",
|
|
183
|
+
};
|
|
184
|
+
return named[code] ?? code;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Whether a value fits a def as it is (a range's number must be in range; it is not snapped here). */
|
|
188
|
+
function fits(def: OptionDef, v: unknown): boolean {
|
|
189
|
+
if (def.kind === "toggle") return typeof v === "boolean";
|
|
190
|
+
if (def.kind === "choice") return (typeof v === "string" || typeof v === "number") && def.choices.some((c) => c.value === v);
|
|
191
|
+
if (def.kind === "key") return typeof v === "string" && CODE_RE.test(v);
|
|
192
|
+
return typeof v === "number" && Number.isFinite(v) && v >= def.min && v <= def.max;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** The first thing wrong with a def, as a sentence, or null. `createOptions` and `add` throw it. */
|
|
196
|
+
export function defProblem(def: OptionDef): string | null {
|
|
197
|
+
const d = def as Partial<OptionDef> & Record<string, unknown>;
|
|
198
|
+
if (!d || typeof d !== "object") return "a def must be an object";
|
|
199
|
+
const name = typeof d.key === "string" ? `"${d.key}"` : "a def";
|
|
200
|
+
if (typeof d.key !== "string" || !KEY_RE.test(d.key) || d.key.length > KEY_MAX) return `${name}: the key must be letters, digits, "_" and "-", in parts joined by dots, at most ${KEY_MAX} characters`;
|
|
201
|
+
if (d.kind !== "toggle" && d.kind !== "choice" && d.kind !== "range" && d.kind !== "key") return `${name}: kind must be "toggle", "choice", "range" or "key"`;
|
|
202
|
+
if (typeof d.label !== "string" || !d.label.trim()) return `${name}: the label is empty`;
|
|
203
|
+
if (d.help !== undefined && typeof d.help !== "string") return `${name}: help must be text`;
|
|
204
|
+
if (d.tab !== "basic" && d.tab !== "advanced") return `${name}: tab must be "basic" or "advanced"`;
|
|
205
|
+
if (d.group !== undefined && (typeof d.group !== "string" || !d.group.trim())) return `${name}: the group is empty`;
|
|
206
|
+
if (d.devices !== undefined && (!Array.isArray(d.devices) || !d.devices.length || d.devices.some((c) => !DEVICE_CLASSES.includes(c)))) return `${name}: devices must list "phone", "tablet" or "desktop"`;
|
|
207
|
+
if (def.kind === "choice") {
|
|
208
|
+
if (!Array.isArray(def.choices) || def.choices.length < 2) return `${name}: a choice needs two choices or more`;
|
|
209
|
+
const seen = new Set<ChoiceValue>();
|
|
210
|
+
for (const c of def.choices) {
|
|
211
|
+
if (!c || (typeof c.value !== "string" && typeof c.value !== "number") || typeof c.label !== "string" || !c.label.trim()) return `${name}: each choice needs a value (text or a number) and a label`;
|
|
212
|
+
if (seen.has(c.value)) return `${name}: the choice ${JSON.stringify(c.value)} is there twice`;
|
|
213
|
+
seen.add(c.value);
|
|
214
|
+
}
|
|
215
|
+
if (def.display !== undefined && def.display !== "buttons" && def.display !== "list") return `${name}: display must be "buttons" or "list"`;
|
|
216
|
+
}
|
|
217
|
+
if (def.kind === "range") {
|
|
218
|
+
if (![def.min, def.max, def.step].every((n) => typeof n === "number" && Number.isFinite(n))) return `${name}: min, max and step must be numbers`;
|
|
219
|
+
if (!(def.min < def.max)) return `${name}: min must be under max`;
|
|
220
|
+
if (!(def.step > 0) || def.step > def.max - def.min) return `${name}: the step must be above 0 and at most max - min`;
|
|
221
|
+
if (def.unit !== undefined && typeof def.unit !== "string") return `${name}: the unit must be text`;
|
|
222
|
+
}
|
|
223
|
+
const dflt = d.default as unknown;
|
|
224
|
+
if (dflt === undefined) return `${name}: there is no default`;
|
|
225
|
+
let values: [string, unknown][] = [["default", dflt]];
|
|
226
|
+
if (isDeviceDefault(dflt as never)) {
|
|
227
|
+
values = Object.entries(dflt as Record<string, unknown>);
|
|
228
|
+
const odd = values.find(([k]) => k !== "default" && k !== "reducedMotion" && !DEVICE_CLASSES.includes(k as DeviceClass));
|
|
229
|
+
if (odd) return `${name}: a default for each device takes phone, tablet, desktop, reducedMotion and default, not "${odd[0]}"`;
|
|
230
|
+
if (!values.some(([k, v]) => k === "default" && v !== undefined)) return `${name}: a default for each device needs "default" too`;
|
|
231
|
+
}
|
|
232
|
+
for (const [k, v] of values) {
|
|
233
|
+
if (v === undefined) continue;
|
|
234
|
+
if (!fits(def, v)) return `${name}: the default${k === "default" ? "" : ` for ${k}`} (${JSON.stringify(v)}) does not fit the def`;
|
|
235
|
+
}
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** The page's localStorage, or null where reading it throws. */
|
|
240
|
+
function pageStorage(): Kept | null {
|
|
241
|
+
try {
|
|
242
|
+
return (globalThis as { localStorage?: Kept }).localStorage ?? null;
|
|
243
|
+
} catch {
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Makes the store, reads what this browser kept for the game, and drops what does not fit. Throws for a bad def or a key that is there twice. */
|
|
249
|
+
export function createOptions(init: OptionsInit): OptionsStore {
|
|
250
|
+
if (typeof init?.game !== "string" || !init.game.trim()) throw new TypeError("createOptions: game must be the game's id");
|
|
251
|
+
const game = init.game;
|
|
252
|
+
const storage = init.storage === undefined ? pageStorage() : init.storage;
|
|
253
|
+
const detect = () => {
|
|
254
|
+
let device: DeviceClass = "desktop";
|
|
255
|
+
let reduce = false;
|
|
256
|
+
try {
|
|
257
|
+
const d = (init.device ?? detectDevice)();
|
|
258
|
+
if (DEVICE_CLASSES.includes(d)) device = d;
|
|
259
|
+
} catch {
|
|
260
|
+
// The detection failed: a desktop.
|
|
261
|
+
}
|
|
262
|
+
try {
|
|
263
|
+
reduce = (init.reducedMotion ?? prefersReducedMotion)() === true;
|
|
264
|
+
} catch {
|
|
265
|
+
// Not known: full motion.
|
|
266
|
+
}
|
|
267
|
+
return { device, reduce };
|
|
268
|
+
};
|
|
269
|
+
let env = detect();
|
|
270
|
+
|
|
271
|
+
const list: OptionDef[] = [];
|
|
272
|
+
const byKey = new Map<string, OptionDef>();
|
|
273
|
+
const dflt = new Map<string, OptionValue>();
|
|
274
|
+
/** The player's choices that differ from the default, by key. */
|
|
275
|
+
const diff = new Map<string, OptionValue>();
|
|
276
|
+
/** Kept keys with a dot whose section has not been added yet: written back as they were, and read when the section comes. */
|
|
277
|
+
let waiting: Record<string, unknown> = {};
|
|
278
|
+
const listeners = new Set<(key: string, value: OptionValue) => void>();
|
|
279
|
+
const defListeners = new Set<() => void>();
|
|
280
|
+
const defaultFor = (def: OptionDef) => resolveDefault<OptionValue>(def as { default: OptionValue | DeviceDefault<OptionValue> }, env.device, env.reduce);
|
|
281
|
+
|
|
282
|
+
const read = (): Record<string, unknown> => {
|
|
283
|
+
let kept: Record<string, unknown> = {};
|
|
284
|
+
try {
|
|
285
|
+
const raw = storage?.getItem(optionsKey(game));
|
|
286
|
+
const v: unknown = raw ? JSON.parse(raw) : null;
|
|
287
|
+
if (v && typeof v === "object" && !Array.isArray(v)) kept = v as Record<string, unknown>;
|
|
288
|
+
} catch {
|
|
289
|
+
// Unreadable or not JSON: nothing was kept.
|
|
290
|
+
}
|
|
291
|
+
if (!init.migrate) return kept;
|
|
292
|
+
try {
|
|
293
|
+
const out = init.migrate({ ...kept }, storage);
|
|
294
|
+
return out && typeof out === "object" && !Array.isArray(out) ? out : {};
|
|
295
|
+
} catch {
|
|
296
|
+
// The migration threw: what was kept, as it was.
|
|
297
|
+
return kept;
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
const write = () => {
|
|
301
|
+
if (!storage) return;
|
|
302
|
+
const out: Record<string, unknown> = { ...waiting };
|
|
303
|
+
for (const [k, v] of diff) out[k] = v;
|
|
304
|
+
try {
|
|
305
|
+
if (Object.keys(out).length) storage.setItem(optionsKey(game), JSON.stringify(out));
|
|
306
|
+
else storage.removeItem(optionsKey(game));
|
|
307
|
+
} catch {
|
|
308
|
+
// A full or refused storage: the choices hold for this page only.
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
/** Checks the defs (all of them, and against the store) before any is taken, so a bad one adds nothing. */
|
|
313
|
+
const take = (defs: readonly OptionDef[]) => {
|
|
314
|
+
if (!Array.isArray(defs)) throw new TypeError("options: defs must be a list");
|
|
315
|
+
const fresh = new Set<string>();
|
|
316
|
+
for (const def of defs) {
|
|
317
|
+
const p = defProblem(def);
|
|
318
|
+
if (p) throw new TypeError(`options: ${p}`);
|
|
319
|
+
if (byKey.has(def.key) || fresh.has(def.key)) throw new Error(`options: the key "${def.key}" is there twice`);
|
|
320
|
+
fresh.add(def.key);
|
|
321
|
+
}
|
|
322
|
+
for (const def of defs) {
|
|
323
|
+
list.push(def);
|
|
324
|
+
byKey.set(def.key, def);
|
|
325
|
+
dflt.set(def.key, defaultFor(def));
|
|
326
|
+
}
|
|
327
|
+
};
|
|
328
|
+
/** Reads kept values for these keys: a value that fits and is not the default is a choice; the rest is dropped. */
|
|
329
|
+
const adopt = (kept: Record<string, unknown>, keys: Iterable<string>) => {
|
|
330
|
+
for (const key of keys) {
|
|
331
|
+
if (!Object.prototype.hasOwnProperty.call(kept, key)) continue;
|
|
332
|
+
const def = byKey.get(key)!;
|
|
333
|
+
let v = kept[key];
|
|
334
|
+
if (def.kind === "range" && typeof v === "number" && Number.isFinite(v) && v >= def.min && v <= def.max) v = snapToRange(def, v);
|
|
335
|
+
if (fits(def, v) && v !== dflt.get(key)) diff.set(key, v as OptionValue);
|
|
336
|
+
}
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
take(init.defs ?? []);
|
|
340
|
+
const kept = read();
|
|
341
|
+
adopt(kept, byKey.keys());
|
|
342
|
+
for (const [k, v] of Object.entries(kept)) if (!byKey.has(k) && k.includes(".") && KEY_RE.test(k)) waiting[k] = v;
|
|
343
|
+
// A migration's result is written at once (the old save may be gone), and a store with values dropped on read writes the cleaner object.
|
|
344
|
+
if (init.migrate || Object.keys(kept).length !== diff.size + Object.keys(waiting).length) write();
|
|
345
|
+
|
|
346
|
+
const need = (key: string): OptionDef => {
|
|
347
|
+
const def = byKey.get(key);
|
|
348
|
+
if (!def) throw new TypeError(`options: no setting has the key "${key}"`);
|
|
349
|
+
return def;
|
|
350
|
+
};
|
|
351
|
+
const now = (key: string): OptionValue => (diff.has(key) ? diff.get(key)! : dflt.get(key)!);
|
|
352
|
+
const tell = (key: string, value: OptionValue) => {
|
|
353
|
+
for (const fn of [...listeners]) {
|
|
354
|
+
try {
|
|
355
|
+
fn(key, value);
|
|
356
|
+
} catch (e) {
|
|
357
|
+
// One listener's fault must not stop the others or the change: it is thrown on its own.
|
|
358
|
+
queueMicrotask(() => {
|
|
359
|
+
throw e;
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
};
|
|
364
|
+
/** Sets the value in memory; the caller writes. */
|
|
365
|
+
const put = (key: string, value: OptionValue) => {
|
|
366
|
+
const was = now(key);
|
|
367
|
+
if (value === dflt.get(key)) diff.delete(key);
|
|
368
|
+
else diff.set(key, value);
|
|
369
|
+
if (was !== value) tell(key, value);
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
const store: OptionsStore = {
|
|
373
|
+
game,
|
|
374
|
+
get device() {
|
|
375
|
+
return env.device;
|
|
376
|
+
},
|
|
377
|
+
get<T extends OptionValue = OptionValue>(key: string): T {
|
|
378
|
+
need(key);
|
|
379
|
+
return now(key) as T;
|
|
380
|
+
},
|
|
381
|
+
set(key, value) {
|
|
382
|
+
const def = need(key);
|
|
383
|
+
let v: unknown = value;
|
|
384
|
+
if (def.kind === "range") {
|
|
385
|
+
if (typeof v !== "number" || !Number.isFinite(v)) throw new TypeError(`options: "${key}" takes a number`);
|
|
386
|
+
v = snapToRange(def, v);
|
|
387
|
+
}
|
|
388
|
+
if (!fits(def, v)) throw new TypeError(`options: ${JSON.stringify(value)} does not fit "${key}"`);
|
|
389
|
+
if (def.kind === "key") {
|
|
390
|
+
// The binding that had this key gets this one's old key, so two actions never share a key.
|
|
391
|
+
const old = now(key);
|
|
392
|
+
if (old !== v) for (const other of list) if (other.kind === "key" && other.key !== key && now(other.key) === v) put(other.key, old);
|
|
393
|
+
}
|
|
394
|
+
put(key, v as OptionValue);
|
|
395
|
+
write();
|
|
396
|
+
},
|
|
397
|
+
reset(key) {
|
|
398
|
+
const keys = key === undefined ? [...byKey.keys()] : [need(key).key];
|
|
399
|
+
for (const k of keys) put(k, dflt.get(k)!);
|
|
400
|
+
write();
|
|
401
|
+
},
|
|
402
|
+
values() {
|
|
403
|
+
const out: Record<string, OptionValue> = {};
|
|
404
|
+
for (const k of byKey.keys()) out[k] = now(k);
|
|
405
|
+
return out;
|
|
406
|
+
},
|
|
407
|
+
defs: () => list.slice(),
|
|
408
|
+
def: (key) => byKey.get(key),
|
|
409
|
+
defaultOf(key) {
|
|
410
|
+
need(key);
|
|
411
|
+
return dflt.get(key)!;
|
|
412
|
+
},
|
|
413
|
+
isDefault(key) {
|
|
414
|
+
if (key === undefined) return diff.size === 0;
|
|
415
|
+
need(key);
|
|
416
|
+
return !diff.has(key);
|
|
417
|
+
},
|
|
418
|
+
subscribe(fn) {
|
|
419
|
+
listeners.add(fn);
|
|
420
|
+
return () => void listeners.delete(fn);
|
|
421
|
+
},
|
|
422
|
+
subscribeDefs(fn) {
|
|
423
|
+
defListeners.add(fn);
|
|
424
|
+
return () => void defListeners.delete(fn);
|
|
425
|
+
},
|
|
426
|
+
add(defs) {
|
|
427
|
+
take(defs);
|
|
428
|
+
adopt(waiting, defs.map((d) => d.key));
|
|
429
|
+
const rest: Record<string, unknown> = {};
|
|
430
|
+
for (const [k, v] of Object.entries(waiting)) if (!byKey.has(k)) rest[k] = v;
|
|
431
|
+
waiting = rest;
|
|
432
|
+
for (const fn of [...defListeners]) fn();
|
|
433
|
+
},
|
|
434
|
+
redetect() {
|
|
435
|
+
env = detect();
|
|
436
|
+
let wrote = false;
|
|
437
|
+
for (const def of list) {
|
|
438
|
+
const was = now(def.key);
|
|
439
|
+
dflt.set(def.key, defaultFor(def));
|
|
440
|
+
// A choice that now equals the default follows the default again.
|
|
441
|
+
if (diff.get(def.key) === dflt.get(def.key)) {
|
|
442
|
+
diff.delete(def.key);
|
|
443
|
+
wrote = true;
|
|
444
|
+
}
|
|
445
|
+
if (now(def.key) !== was) tell(def.key, now(def.key));
|
|
446
|
+
}
|
|
447
|
+
if (wrote) write();
|
|
448
|
+
},
|
|
449
|
+
};
|
|
450
|
+
|
|
451
|
+
// The device's reduced-motion setting, followed live (only the kit's own reading: a game's function is read on redetect()).
|
|
452
|
+
if (!init.reducedMotion) mediaQuery(REDUCE_QUERY)?.addEventListener?.("change", () => store.redetect());
|
|
453
|
+
return store;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** A module's section: its defs with the module's name before each key ("show" in "emotes" is "emotes.show"), and its group when a def has none. */
|
|
457
|
+
export function optionSection(name: string, defs: readonly OptionDef[], group?: string): OptionDef[] {
|
|
458
|
+
if (!KEY_RE.test(name) || name.includes(".")) throw new TypeError(`optionSection: "${name}" must be one part of a key (letters, digits, "_" and "-")`);
|
|
459
|
+
return defs.map((d) => ({ ...d, key: `${name}.${d.key}`, ...(group && !d.group ? { group } : {}) }));
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** A key binding def: the key `keys.<action>`, in Advanced under "Keys", shown on a desktop only (a touch screen has no keys to bind). */
|
|
463
|
+
export function keyBinding(action: string, label: string, code: string, over: Partial<Omit<KeyDef, "kind">> = {}): KeyDef {
|
|
464
|
+
return { kind: "key", key: `keys.${action}`, label, tab: "advanced", group: "Keys", default: code, devices: ["desktop"], ...over };
|
|
465
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// "@nano133/nanoterra/options": a game's options (settings) as data, with their defaults for each device, kept in this browser; the common settings a game
|
|
2
|
+
// opts into; and the ready panel.
|
|
3
|
+
//
|
|
4
|
+
// import { createOptions, common, mountOptions } from "@nano133/nanoterra/options";
|
|
5
|
+
// import "@nano133/nanoterra/options/style.css";
|
|
6
|
+
//
|
|
7
|
+
// const options = createOptions({
|
|
8
|
+
// game: "my-game",
|
|
9
|
+
// defs: [
|
|
10
|
+
// ...common("masterVolume", "musicVolume", "effectsVolume", "cameraSensitivity", "invertLook", "quality"),
|
|
11
|
+
// { kind: "toggle", key: "hints", label: "Show hints", tab: "basic", group: "Game", default: true },
|
|
12
|
+
// ],
|
|
13
|
+
// });
|
|
14
|
+
// options.get<number>("musicVolume"); // 0 to 100
|
|
15
|
+
// options.subscribe((key, value) => apply(key, value)); // every change, from the panel or the game
|
|
16
|
+
// mountOptions(document.getElementById("options")!, options); // the gear key and the panel
|
|
17
|
+
//
|
|
18
|
+
// reflectOptions(options, ["reducedMotion"]) keeps <html data-n133-opt-reduced-motion="true|false"> for the page's CSS.
|
|
19
|
+
// A kit module adds its own section later: `options.add(optionSection("emotes", emoteDefs, "Emotes"))` gives the keys "emotes.<key>".
|
|
20
|
+
// The store has no DOM and touches nothing at import. The panel (also on "@nano133/nanoterra/options/panel") builds its elements only when it is mounted.
|
|
21
|
+
|
|
22
|
+
export {
|
|
23
|
+
createOptions,
|
|
24
|
+
defProblem,
|
|
25
|
+
detectDevice,
|
|
26
|
+
DEVICE_CLASSES,
|
|
27
|
+
keyBinding,
|
|
28
|
+
keyLabel,
|
|
29
|
+
optionsKey,
|
|
30
|
+
OPTIONS_PREFIX,
|
|
31
|
+
optionSection,
|
|
32
|
+
prefersReducedMotion,
|
|
33
|
+
resolveDefault,
|
|
34
|
+
snapToRange,
|
|
35
|
+
} from "./core.js";
|
|
36
|
+
export type { ChoiceDef, ChoiceValue, DeviceClass, DeviceDefault, KeyDef, OptionDef, OptionsInit, OptionsStore, OptionValue, RangeDef, Tab, ToggleDef } from "./core.js";
|
|
37
|
+
export { common, COMMON_KEYS, COMMON_OPTIONS } from "./common.js";
|
|
38
|
+
export type { CommonKey } from "./common.js";
|
|
39
|
+
export { optionAttribute, reflectOptions } from "./reflect.js";
|
|
40
|
+
export { mountOptions } from "./panel/mount.js";
|
|
41
|
+
export type { MountedOptions, OptionsMountOptions } from "./panel/mount.js";
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// "@nano133/nanoterra/options/panel": the Options key and panel of a game, drawn in the browser. Plain DOM, no framework, no Three.js.
|
|
2
|
+
//
|
|
3
|
+
// import { createOptions, common } from "@nano133/nanoterra/options";
|
|
4
|
+
// import { mountOptions } from "@nano133/nanoterra/options/panel";
|
|
5
|
+
// import "@nano133/nanoterra/options/style.css";
|
|
6
|
+
// const options = createOptions({ game: "my-game", defs: [...common("masterVolume", "musicVolume", "quality"), myOwnDef] });
|
|
7
|
+
// const panel = mountOptions(document.getElementById("options")!, options);
|
|
8
|
+
//
|
|
9
|
+
// The store (../core.ts) has no DOM: a game with its own menu reads, writes and hears it without this panel.
|
|
10
|
+
|
|
11
|
+
export { choiceDisplay, mountOptions, PRESS_A_KEY, rangeText, RESET_QUESTION, TAB_WORDS } from "./mount.js";
|
|
12
|
+
export type { MountedOptions, OptionsMountOptions } from "./mount.js";
|