@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,420 @@
|
|
|
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
|
+
export const DEVICE_CLASSES = ["phone", "tablet", "desktop"];
|
|
16
|
+
export const OPTIONS_PREFIX = "n133-options:";
|
|
17
|
+
/** The localStorage key of a game's choices. */
|
|
18
|
+
export const optionsKey = (game) => `${OPTIONS_PREFIX}${game}`;
|
|
19
|
+
const KEY_RE = /^[A-Za-z][A-Za-z0-9_-]*(\.[A-Za-z][A-Za-z0-9_-]*)*$/;
|
|
20
|
+
const KEY_MAX = 64;
|
|
21
|
+
/** A `KeyboardEvent.code`: "KeyE", "Digit1", "Space", "ArrowUp", "F5". */
|
|
22
|
+
const CODE_RE = /^[A-Za-z][A-Za-z0-9]{0,23}$/;
|
|
23
|
+
const REDUCE_QUERY = "(prefers-reduced-motion: reduce)";
|
|
24
|
+
/** A media query; null when there is none (Node, a test). */
|
|
25
|
+
function mediaQuery(query) {
|
|
26
|
+
try {
|
|
27
|
+
return typeof globalThis.matchMedia === "function" ? globalThis.matchMedia(query) : null;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* 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
|
|
35
|
+
* desktop. The short side is the screen's, so turning a phone does not change it. With no matchMedia (Node), a desktop.
|
|
36
|
+
*/
|
|
37
|
+
export function detectDevice() {
|
|
38
|
+
if (mediaQuery("(pointer: coarse)")?.matches !== true)
|
|
39
|
+
return "desktop";
|
|
40
|
+
const g = globalThis;
|
|
41
|
+
const short = Math.min(g.screen?.width || g.innerWidth || 0, g.screen?.height || g.innerHeight || 0);
|
|
42
|
+
return short > 0 && short < 600 ? "phone" : "tablet";
|
|
43
|
+
}
|
|
44
|
+
/** `prefers-reduced-motion: reduce`; false where it cannot be read. */
|
|
45
|
+
export const prefersReducedMotion = () => mediaQuery(REDUCE_QUERY)?.matches === true;
|
|
46
|
+
const isDeviceDefault = (d) => typeof d === "object" && d !== null;
|
|
47
|
+
/** The default a def has on a device class (and with reduced motion asked for or not). */
|
|
48
|
+
export function resolveDefault(def, device, reducedMotion = false) {
|
|
49
|
+
const d = def.default;
|
|
50
|
+
if (!isDeviceDefault(d))
|
|
51
|
+
return d;
|
|
52
|
+
if (reducedMotion && d.reducedMotion !== undefined)
|
|
53
|
+
return d.reducedMotion;
|
|
54
|
+
return d[device] !== undefined ? d[device] : d.default;
|
|
55
|
+
}
|
|
56
|
+
/** The number of decimals of a number, so a snapped value is 0.3 and not 0.30000000000000004. */
|
|
57
|
+
const decimals = (n) => {
|
|
58
|
+
const s = String(n);
|
|
59
|
+
const e = s.match(/e-(\d+)$/);
|
|
60
|
+
if (e)
|
|
61
|
+
return Number(e[1]);
|
|
62
|
+
const dot = s.indexOf(".");
|
|
63
|
+
return dot < 0 ? 0 : s.length - dot - 1;
|
|
64
|
+
};
|
|
65
|
+
/** A number on a range's nearest step from min, inside min and max. */
|
|
66
|
+
export function snapToRange(def, v) {
|
|
67
|
+
const places = Math.max(decimals(def.step), decimals(def.min));
|
|
68
|
+
const steps = Math.round((Math.min(def.max, Math.max(def.min, v)) - def.min) / def.step);
|
|
69
|
+
const out = Number((def.min + steps * def.step).toFixed(places));
|
|
70
|
+
return out > def.max ? Number((out - def.step).toFixed(places)) : out;
|
|
71
|
+
}
|
|
72
|
+
/** The words for a `KeyboardEvent.code` on a key cap: "KeyE" is "E", "Digit1" is "1", "ArrowUp" is "↑", "ShiftLeft" is "Shift". */
|
|
73
|
+
export function keyLabel(code) {
|
|
74
|
+
if (/^Key[A-Z]$/.test(code))
|
|
75
|
+
return code.slice(3);
|
|
76
|
+
if (/^Digit\d$/.test(code))
|
|
77
|
+
return code.slice(5);
|
|
78
|
+
if (/^Numpad\d$/.test(code))
|
|
79
|
+
return `Num ${code.slice(6)}`;
|
|
80
|
+
const named = {
|
|
81
|
+
ArrowUp: "↑", ArrowDown: "↓", ArrowLeft: "←", ArrowRight: "→", Escape: "Esc", Backquote: "`", Minus: "-", Equal: "=", BracketLeft: "[", BracketRight: "]",
|
|
82
|
+
Semicolon: ";", Quote: "'", Comma: ",", Period: ".", Slash: "/", Backslash: "\\", ShiftLeft: "Shift", ShiftRight: "Right Shift", ControlLeft: "Ctrl",
|
|
83
|
+
ControlRight: "Right Ctrl", AltLeft: "Alt", AltRight: "Right Alt", MetaLeft: "Cmd", MetaRight: "Right Cmd", Enter: "Enter", Space: "Space", Backspace: "Backspace", CapsLock: "Caps Lock",
|
|
84
|
+
};
|
|
85
|
+
return named[code] ?? code;
|
|
86
|
+
}
|
|
87
|
+
/** Whether a value fits a def as it is (a range's number must be in range; it is not snapped here). */
|
|
88
|
+
function fits(def, v) {
|
|
89
|
+
if (def.kind === "toggle")
|
|
90
|
+
return typeof v === "boolean";
|
|
91
|
+
if (def.kind === "choice")
|
|
92
|
+
return (typeof v === "string" || typeof v === "number") && def.choices.some((c) => c.value === v);
|
|
93
|
+
if (def.kind === "key")
|
|
94
|
+
return typeof v === "string" && CODE_RE.test(v);
|
|
95
|
+
return typeof v === "number" && Number.isFinite(v) && v >= def.min && v <= def.max;
|
|
96
|
+
}
|
|
97
|
+
/** The first thing wrong with a def, as a sentence, or null. `createOptions` and `add` throw it. */
|
|
98
|
+
export function defProblem(def) {
|
|
99
|
+
const d = def;
|
|
100
|
+
if (!d || typeof d !== "object")
|
|
101
|
+
return "a def must be an object";
|
|
102
|
+
const name = typeof d.key === "string" ? `"${d.key}"` : "a def";
|
|
103
|
+
if (typeof d.key !== "string" || !KEY_RE.test(d.key) || d.key.length > KEY_MAX)
|
|
104
|
+
return `${name}: the key must be letters, digits, "_" and "-", in parts joined by dots, at most ${KEY_MAX} characters`;
|
|
105
|
+
if (d.kind !== "toggle" && d.kind !== "choice" && d.kind !== "range" && d.kind !== "key")
|
|
106
|
+
return `${name}: kind must be "toggle", "choice", "range" or "key"`;
|
|
107
|
+
if (typeof d.label !== "string" || !d.label.trim())
|
|
108
|
+
return `${name}: the label is empty`;
|
|
109
|
+
if (d.help !== undefined && typeof d.help !== "string")
|
|
110
|
+
return `${name}: help must be text`;
|
|
111
|
+
if (d.tab !== "basic" && d.tab !== "advanced")
|
|
112
|
+
return `${name}: tab must be "basic" or "advanced"`;
|
|
113
|
+
if (d.group !== undefined && (typeof d.group !== "string" || !d.group.trim()))
|
|
114
|
+
return `${name}: the group is empty`;
|
|
115
|
+
if (d.devices !== undefined && (!Array.isArray(d.devices) || !d.devices.length || d.devices.some((c) => !DEVICE_CLASSES.includes(c))))
|
|
116
|
+
return `${name}: devices must list "phone", "tablet" or "desktop"`;
|
|
117
|
+
if (def.kind === "choice") {
|
|
118
|
+
if (!Array.isArray(def.choices) || def.choices.length < 2)
|
|
119
|
+
return `${name}: a choice needs two choices or more`;
|
|
120
|
+
const seen = new Set();
|
|
121
|
+
for (const c of def.choices) {
|
|
122
|
+
if (!c || (typeof c.value !== "string" && typeof c.value !== "number") || typeof c.label !== "string" || !c.label.trim())
|
|
123
|
+
return `${name}: each choice needs a value (text or a number) and a label`;
|
|
124
|
+
if (seen.has(c.value))
|
|
125
|
+
return `${name}: the choice ${JSON.stringify(c.value)} is there twice`;
|
|
126
|
+
seen.add(c.value);
|
|
127
|
+
}
|
|
128
|
+
if (def.display !== undefined && def.display !== "buttons" && def.display !== "list")
|
|
129
|
+
return `${name}: display must be "buttons" or "list"`;
|
|
130
|
+
}
|
|
131
|
+
if (def.kind === "range") {
|
|
132
|
+
if (![def.min, def.max, def.step].every((n) => typeof n === "number" && Number.isFinite(n)))
|
|
133
|
+
return `${name}: min, max and step must be numbers`;
|
|
134
|
+
if (!(def.min < def.max))
|
|
135
|
+
return `${name}: min must be under max`;
|
|
136
|
+
if (!(def.step > 0) || def.step > def.max - def.min)
|
|
137
|
+
return `${name}: the step must be above 0 and at most max - min`;
|
|
138
|
+
if (def.unit !== undefined && typeof def.unit !== "string")
|
|
139
|
+
return `${name}: the unit must be text`;
|
|
140
|
+
}
|
|
141
|
+
const dflt = d.default;
|
|
142
|
+
if (dflt === undefined)
|
|
143
|
+
return `${name}: there is no default`;
|
|
144
|
+
let values = [["default", dflt]];
|
|
145
|
+
if (isDeviceDefault(dflt)) {
|
|
146
|
+
values = Object.entries(dflt);
|
|
147
|
+
const odd = values.find(([k]) => k !== "default" && k !== "reducedMotion" && !DEVICE_CLASSES.includes(k));
|
|
148
|
+
if (odd)
|
|
149
|
+
return `${name}: a default for each device takes phone, tablet, desktop, reducedMotion and default, not "${odd[0]}"`;
|
|
150
|
+
if (!values.some(([k, v]) => k === "default" && v !== undefined))
|
|
151
|
+
return `${name}: a default for each device needs "default" too`;
|
|
152
|
+
}
|
|
153
|
+
for (const [k, v] of values) {
|
|
154
|
+
if (v === undefined)
|
|
155
|
+
continue;
|
|
156
|
+
if (!fits(def, v))
|
|
157
|
+
return `${name}: the default${k === "default" ? "" : ` for ${k}`} (${JSON.stringify(v)}) does not fit the def`;
|
|
158
|
+
}
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
/** The page's localStorage, or null where reading it throws. */
|
|
162
|
+
function pageStorage() {
|
|
163
|
+
try {
|
|
164
|
+
return globalThis.localStorage ?? null;
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
/** 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. */
|
|
171
|
+
export function createOptions(init) {
|
|
172
|
+
if (typeof init?.game !== "string" || !init.game.trim())
|
|
173
|
+
throw new TypeError("createOptions: game must be the game's id");
|
|
174
|
+
const game = init.game;
|
|
175
|
+
const storage = init.storage === undefined ? pageStorage() : init.storage;
|
|
176
|
+
const detect = () => {
|
|
177
|
+
let device = "desktop";
|
|
178
|
+
let reduce = false;
|
|
179
|
+
try {
|
|
180
|
+
const d = (init.device ?? detectDevice)();
|
|
181
|
+
if (DEVICE_CLASSES.includes(d))
|
|
182
|
+
device = d;
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
// The detection failed: a desktop.
|
|
186
|
+
}
|
|
187
|
+
try {
|
|
188
|
+
reduce = (init.reducedMotion ?? prefersReducedMotion)() === true;
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
// Not known: full motion.
|
|
192
|
+
}
|
|
193
|
+
return { device, reduce };
|
|
194
|
+
};
|
|
195
|
+
let env = detect();
|
|
196
|
+
const list = [];
|
|
197
|
+
const byKey = new Map();
|
|
198
|
+
const dflt = new Map();
|
|
199
|
+
/** The player's choices that differ from the default, by key. */
|
|
200
|
+
const diff = new Map();
|
|
201
|
+
/** Kept keys with a dot whose section has not been added yet: written back as they were, and read when the section comes. */
|
|
202
|
+
let waiting = {};
|
|
203
|
+
const listeners = new Set();
|
|
204
|
+
const defListeners = new Set();
|
|
205
|
+
const defaultFor = (def) => resolveDefault(def, env.device, env.reduce);
|
|
206
|
+
const read = () => {
|
|
207
|
+
let kept = {};
|
|
208
|
+
try {
|
|
209
|
+
const raw = storage?.getItem(optionsKey(game));
|
|
210
|
+
const v = raw ? JSON.parse(raw) : null;
|
|
211
|
+
if (v && typeof v === "object" && !Array.isArray(v))
|
|
212
|
+
kept = v;
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
// Unreadable or not JSON: nothing was kept.
|
|
216
|
+
}
|
|
217
|
+
if (!init.migrate)
|
|
218
|
+
return kept;
|
|
219
|
+
try {
|
|
220
|
+
const out = init.migrate({ ...kept }, storage);
|
|
221
|
+
return out && typeof out === "object" && !Array.isArray(out) ? out : {};
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
// The migration threw: what was kept, as it was.
|
|
225
|
+
return kept;
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
const write = () => {
|
|
229
|
+
if (!storage)
|
|
230
|
+
return;
|
|
231
|
+
const out = { ...waiting };
|
|
232
|
+
for (const [k, v] of diff)
|
|
233
|
+
out[k] = v;
|
|
234
|
+
try {
|
|
235
|
+
if (Object.keys(out).length)
|
|
236
|
+
storage.setItem(optionsKey(game), JSON.stringify(out));
|
|
237
|
+
else
|
|
238
|
+
storage.removeItem(optionsKey(game));
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
// A full or refused storage: the choices hold for this page only.
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
/** Checks the defs (all of them, and against the store) before any is taken, so a bad one adds nothing. */
|
|
245
|
+
const take = (defs) => {
|
|
246
|
+
if (!Array.isArray(defs))
|
|
247
|
+
throw new TypeError("options: defs must be a list");
|
|
248
|
+
const fresh = new Set();
|
|
249
|
+
for (const def of defs) {
|
|
250
|
+
const p = defProblem(def);
|
|
251
|
+
if (p)
|
|
252
|
+
throw new TypeError(`options: ${p}`);
|
|
253
|
+
if (byKey.has(def.key) || fresh.has(def.key))
|
|
254
|
+
throw new Error(`options: the key "${def.key}" is there twice`);
|
|
255
|
+
fresh.add(def.key);
|
|
256
|
+
}
|
|
257
|
+
for (const def of defs) {
|
|
258
|
+
list.push(def);
|
|
259
|
+
byKey.set(def.key, def);
|
|
260
|
+
dflt.set(def.key, defaultFor(def));
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
/** Reads kept values for these keys: a value that fits and is not the default is a choice; the rest is dropped. */
|
|
264
|
+
const adopt = (kept, keys) => {
|
|
265
|
+
for (const key of keys) {
|
|
266
|
+
if (!Object.prototype.hasOwnProperty.call(kept, key))
|
|
267
|
+
continue;
|
|
268
|
+
const def = byKey.get(key);
|
|
269
|
+
let v = kept[key];
|
|
270
|
+
if (def.kind === "range" && typeof v === "number" && Number.isFinite(v) && v >= def.min && v <= def.max)
|
|
271
|
+
v = snapToRange(def, v);
|
|
272
|
+
if (fits(def, v) && v !== dflt.get(key))
|
|
273
|
+
diff.set(key, v);
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
take(init.defs ?? []);
|
|
277
|
+
const kept = read();
|
|
278
|
+
adopt(kept, byKey.keys());
|
|
279
|
+
for (const [k, v] of Object.entries(kept))
|
|
280
|
+
if (!byKey.has(k) && k.includes(".") && KEY_RE.test(k))
|
|
281
|
+
waiting[k] = v;
|
|
282
|
+
// 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.
|
|
283
|
+
if (init.migrate || Object.keys(kept).length !== diff.size + Object.keys(waiting).length)
|
|
284
|
+
write();
|
|
285
|
+
const need = (key) => {
|
|
286
|
+
const def = byKey.get(key);
|
|
287
|
+
if (!def)
|
|
288
|
+
throw new TypeError(`options: no setting has the key "${key}"`);
|
|
289
|
+
return def;
|
|
290
|
+
};
|
|
291
|
+
const now = (key) => (diff.has(key) ? diff.get(key) : dflt.get(key));
|
|
292
|
+
const tell = (key, value) => {
|
|
293
|
+
for (const fn of [...listeners]) {
|
|
294
|
+
try {
|
|
295
|
+
fn(key, value);
|
|
296
|
+
}
|
|
297
|
+
catch (e) {
|
|
298
|
+
// One listener's fault must not stop the others or the change: it is thrown on its own.
|
|
299
|
+
queueMicrotask(() => {
|
|
300
|
+
throw e;
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
};
|
|
305
|
+
/** Sets the value in memory; the caller writes. */
|
|
306
|
+
const put = (key, value) => {
|
|
307
|
+
const was = now(key);
|
|
308
|
+
if (value === dflt.get(key))
|
|
309
|
+
diff.delete(key);
|
|
310
|
+
else
|
|
311
|
+
diff.set(key, value);
|
|
312
|
+
if (was !== value)
|
|
313
|
+
tell(key, value);
|
|
314
|
+
};
|
|
315
|
+
const store = {
|
|
316
|
+
game,
|
|
317
|
+
get device() {
|
|
318
|
+
return env.device;
|
|
319
|
+
},
|
|
320
|
+
get(key) {
|
|
321
|
+
need(key);
|
|
322
|
+
return now(key);
|
|
323
|
+
},
|
|
324
|
+
set(key, value) {
|
|
325
|
+
const def = need(key);
|
|
326
|
+
let v = value;
|
|
327
|
+
if (def.kind === "range") {
|
|
328
|
+
if (typeof v !== "number" || !Number.isFinite(v))
|
|
329
|
+
throw new TypeError(`options: "${key}" takes a number`);
|
|
330
|
+
v = snapToRange(def, v);
|
|
331
|
+
}
|
|
332
|
+
if (!fits(def, v))
|
|
333
|
+
throw new TypeError(`options: ${JSON.stringify(value)} does not fit "${key}"`);
|
|
334
|
+
if (def.kind === "key") {
|
|
335
|
+
// The binding that had this key gets this one's old key, so two actions never share a key.
|
|
336
|
+
const old = now(key);
|
|
337
|
+
if (old !== v)
|
|
338
|
+
for (const other of list)
|
|
339
|
+
if (other.kind === "key" && other.key !== key && now(other.key) === v)
|
|
340
|
+
put(other.key, old);
|
|
341
|
+
}
|
|
342
|
+
put(key, v);
|
|
343
|
+
write();
|
|
344
|
+
},
|
|
345
|
+
reset(key) {
|
|
346
|
+
const keys = key === undefined ? [...byKey.keys()] : [need(key).key];
|
|
347
|
+
for (const k of keys)
|
|
348
|
+
put(k, dflt.get(k));
|
|
349
|
+
write();
|
|
350
|
+
},
|
|
351
|
+
values() {
|
|
352
|
+
const out = {};
|
|
353
|
+
for (const k of byKey.keys())
|
|
354
|
+
out[k] = now(k);
|
|
355
|
+
return out;
|
|
356
|
+
},
|
|
357
|
+
defs: () => list.slice(),
|
|
358
|
+
def: (key) => byKey.get(key),
|
|
359
|
+
defaultOf(key) {
|
|
360
|
+
need(key);
|
|
361
|
+
return dflt.get(key);
|
|
362
|
+
},
|
|
363
|
+
isDefault(key) {
|
|
364
|
+
if (key === undefined)
|
|
365
|
+
return diff.size === 0;
|
|
366
|
+
need(key);
|
|
367
|
+
return !diff.has(key);
|
|
368
|
+
},
|
|
369
|
+
subscribe(fn) {
|
|
370
|
+
listeners.add(fn);
|
|
371
|
+
return () => void listeners.delete(fn);
|
|
372
|
+
},
|
|
373
|
+
subscribeDefs(fn) {
|
|
374
|
+
defListeners.add(fn);
|
|
375
|
+
return () => void defListeners.delete(fn);
|
|
376
|
+
},
|
|
377
|
+
add(defs) {
|
|
378
|
+
take(defs);
|
|
379
|
+
adopt(waiting, defs.map((d) => d.key));
|
|
380
|
+
const rest = {};
|
|
381
|
+
for (const [k, v] of Object.entries(waiting))
|
|
382
|
+
if (!byKey.has(k))
|
|
383
|
+
rest[k] = v;
|
|
384
|
+
waiting = rest;
|
|
385
|
+
for (const fn of [...defListeners])
|
|
386
|
+
fn();
|
|
387
|
+
},
|
|
388
|
+
redetect() {
|
|
389
|
+
env = detect();
|
|
390
|
+
let wrote = false;
|
|
391
|
+
for (const def of list) {
|
|
392
|
+
const was = now(def.key);
|
|
393
|
+
dflt.set(def.key, defaultFor(def));
|
|
394
|
+
// A choice that now equals the default follows the default again.
|
|
395
|
+
if (diff.get(def.key) === dflt.get(def.key)) {
|
|
396
|
+
diff.delete(def.key);
|
|
397
|
+
wrote = true;
|
|
398
|
+
}
|
|
399
|
+
if (now(def.key) !== was)
|
|
400
|
+
tell(def.key, now(def.key));
|
|
401
|
+
}
|
|
402
|
+
if (wrote)
|
|
403
|
+
write();
|
|
404
|
+
},
|
|
405
|
+
};
|
|
406
|
+
// The device's reduced-motion setting, followed live (only the kit's own reading: a game's function is read on redetect()).
|
|
407
|
+
if (!init.reducedMotion)
|
|
408
|
+
mediaQuery(REDUCE_QUERY)?.addEventListener?.("change", () => store.redetect());
|
|
409
|
+
return store;
|
|
410
|
+
}
|
|
411
|
+
/** 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. */
|
|
412
|
+
export function optionSection(name, defs, group) {
|
|
413
|
+
if (!KEY_RE.test(name) || name.includes("."))
|
|
414
|
+
throw new TypeError(`optionSection: "${name}" must be one part of a key (letters, digits, "_" and "-")`);
|
|
415
|
+
return defs.map((d) => ({ ...d, key: `${name}.${d.key}`, ...(group && !d.group ? { group } : {}) }));
|
|
416
|
+
}
|
|
417
|
+
/** 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). */
|
|
418
|
+
export function keyBinding(action, label, code, over = {}) {
|
|
419
|
+
return { kind: "key", key: `keys.${action}`, label, tab: "advanced", group: "Keys", default: code, devices: ["desktop"], ...over };
|
|
420
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { createOptions, defProblem, detectDevice, DEVICE_CLASSES, keyBinding, keyLabel, optionsKey, OPTIONS_PREFIX, optionSection, prefersReducedMotion, resolveDefault, snapToRange, } from "./core.js";
|
|
2
|
+
export type { ChoiceDef, ChoiceValue, DeviceClass, DeviceDefault, KeyDef, OptionDef, OptionsInit, OptionsStore, OptionValue, RangeDef, Tab, ToggleDef } from "./core.js";
|
|
3
|
+
export { common, COMMON_KEYS, COMMON_OPTIONS } from "./common.js";
|
|
4
|
+
export type { CommonKey } from "./common.js";
|
|
5
|
+
export { optionAttribute, reflectOptions } from "./reflect.js";
|
|
6
|
+
export { mountOptions } from "./panel/mount.js";
|
|
7
|
+
export type { MountedOptions, OptionsMountOptions } from "./panel/mount.js";
|
|
@@ -0,0 +1,24 @@
|
|
|
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
|
+
export { createOptions, defProblem, detectDevice, DEVICE_CLASSES, keyBinding, keyLabel, optionsKey, OPTIONS_PREFIX, optionSection, prefersReducedMotion, resolveDefault, snapToRange, } from "./core.js";
|
|
22
|
+
export { common, COMMON_KEYS, COMMON_OPTIONS } from "./common.js";
|
|
23
|
+
export { optionAttribute, reflectOptions } from "./reflect.js";
|
|
24
|
+
export { mountOptions } from "./panel/mount.js";
|
|
@@ -0,0 +1,10 @@
|
|
|
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
|
+
export { choiceDisplay, mountOptions, PRESS_A_KEY, rangeText, RESET_QUESTION, TAB_WORDS } from "./mount.js";
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { type ChoiceDef, type OptionsStore, type RangeDef, type Tab } from "../core.js";
|
|
2
|
+
export type OptionsMountOptions = {
|
|
3
|
+
/** The panel's heading and the key's word. Default "Options". */
|
|
4
|
+
title?: string;
|
|
5
|
+
/** Draw the Options key (a gear and the word). Default true; false for a game that calls `open()` from its own menu. */
|
|
6
|
+
key?: boolean;
|
|
7
|
+
/** Called when the panel opens and closes (a game may pause under it). */
|
|
8
|
+
onOpen?: () => void;
|
|
9
|
+
onClose?: () => void;
|
|
10
|
+
/** A slider was let go (a game plays a sound at the new level). */
|
|
11
|
+
onRangeDone?: (key: string, value: number) => void;
|
|
12
|
+
/** Asks the player a yes or no question (default: a question in the panel, with a key for each answer; never a browser dialog). */
|
|
13
|
+
confirm?: (question: string) => boolean | Promise<boolean>;
|
|
14
|
+
};
|
|
15
|
+
export type MountedOptions = {
|
|
16
|
+
open(): void;
|
|
17
|
+
close(): void;
|
|
18
|
+
readonly isOpen: boolean;
|
|
19
|
+
/** Removes the key and the panel and stops hearing the store. */
|
|
20
|
+
destroy(): void;
|
|
21
|
+
};
|
|
22
|
+
export declare const TAB_WORDS: Record<Tab, string>;
|
|
23
|
+
export declare const RESET_QUESTION = "Put every option back to its default?";
|
|
24
|
+
/** On a key binding while it waits for its key. */
|
|
25
|
+
export declare const PRESS_A_KEY = "Press a key";
|
|
26
|
+
/** "80%", "3 s", "50". */
|
|
27
|
+
export declare const rangeText: (def: Pick<RangeDef, "unit">, v: number) => string;
|
|
28
|
+
/** Buttons for at most 4 choices of at most 10 characters, else a list; the def's `display` wins. */
|
|
29
|
+
export declare const choiceDisplay: (def: ChoiceDef) => "buttons" | "list";
|
|
30
|
+
export declare function mountOptions(root: HTMLElement, store: OptionsStore, options?: OptionsMountOptions): MountedOptions;
|