@hopple/sdk 0.0.0-stage → 0.1.0-rc.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/LICENSE +21 -0
- package/README.md +729 -2
- package/config.schema.json +776 -0
- package/hopple-sdk.min.js +139 -0
- package/index.d.ts +3650 -0
- package/index.js +3548 -0
- package/manifest.md +281 -0
- package/package.json +30 -4
package/index.js
ADDED
|
@@ -0,0 +1,3548 @@
|
|
|
1
|
+
// ../protocol/src/achievement.ts
|
|
2
|
+
var ACHIEVEMENT_ID = /^[a-z0-9]+(?:[._-][a-z0-9]+)*$/;
|
|
3
|
+
var ACHIEVEMENT_ID_MAX = 48;
|
|
4
|
+
var ACHIEVEMENT_VALUE_MAX = 9007199254740991;
|
|
5
|
+
function isAchievementId(value) {
|
|
6
|
+
return typeof value === "string" && value.length <= ACHIEVEMENT_ID_MAX && ACHIEVEMENT_ID.test(value);
|
|
7
|
+
}
|
|
8
|
+
function isAchievementValue(value) {
|
|
9
|
+
return typeof value === "number" && Number.isFinite(value) && Math.abs(value) <= ACHIEVEMENT_VALUE_MAX;
|
|
10
|
+
}
|
|
11
|
+
function readAchievementState(value) {
|
|
12
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
|
13
|
+
const raw = value;
|
|
14
|
+
if (typeof raw.unlocked !== "boolean") return null;
|
|
15
|
+
const state = { unlocked: raw.unlocked };
|
|
16
|
+
if (typeof raw.at === "number" && Number.isFinite(raw.at)) state.at = raw.at;
|
|
17
|
+
if (isAchievementValue(raw.progress)) state.progress = raw.progress;
|
|
18
|
+
return state;
|
|
19
|
+
}
|
|
20
|
+
function readAchievementResult(value) {
|
|
21
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
|
22
|
+
const raw = value;
|
|
23
|
+
if (typeof raw.unlocked !== "boolean" || typeof raw.earned !== "boolean") return null;
|
|
24
|
+
const result = { unlocked: raw.unlocked, earned: raw.earned };
|
|
25
|
+
if (isAchievementValue(raw.progress)) result.progress = raw.progress;
|
|
26
|
+
return result;
|
|
27
|
+
}
|
|
28
|
+
function readAchievementWelcome(value) {
|
|
29
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
|
30
|
+
const raw = value;
|
|
31
|
+
const ids = Array.isArray(raw.ids) ? raw.ids.filter(isAchievementId) : [];
|
|
32
|
+
const welcome = { ids };
|
|
33
|
+
const state = readStates(raw.state);
|
|
34
|
+
if (state !== void 0) welcome.state = state;
|
|
35
|
+
return welcome;
|
|
36
|
+
}
|
|
37
|
+
function readAchievementChange(value) {
|
|
38
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
|
39
|
+
const state = readStates(value.state);
|
|
40
|
+
return state === void 0 ? null : { state };
|
|
41
|
+
}
|
|
42
|
+
function readStates(value) {
|
|
43
|
+
if (value === null) return null;
|
|
44
|
+
if (typeof value !== "object" || value === void 0 || Array.isArray(value)) return void 0;
|
|
45
|
+
const states = {};
|
|
46
|
+
for (const [id, entry] of Object.entries(value)) {
|
|
47
|
+
if (!isAchievementId(id)) continue;
|
|
48
|
+
const read = readAchievementState(entry);
|
|
49
|
+
if (read !== null) states[id] = read;
|
|
50
|
+
}
|
|
51
|
+
return states;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ../protocol/src/analytics.ts
|
|
55
|
+
var ANALYTICS_RESERVED_PREFIX = "hopple.";
|
|
56
|
+
var ANALYTICS_NAME = /^[a-z0-9]+(?:[._-][a-z0-9]+)*$/;
|
|
57
|
+
function isAnalyticsName(value) {
|
|
58
|
+
return typeof value === "string" && !value.startsWith(ANALYTICS_RESERVED_PREFIX) && ANALYTICS_NAME.test(value);
|
|
59
|
+
}
|
|
60
|
+
function readAnalyticsEvent(name, props, declared) {
|
|
61
|
+
const label = typeof name === "string" ? name : "";
|
|
62
|
+
if (!isAnalyticsName(name)) return { ok: false, reason: "name", name: label };
|
|
63
|
+
const shape = declared?.[name];
|
|
64
|
+
if (declared !== void 0 && shape === void 0) {
|
|
65
|
+
return { ok: false, reason: "undeclared", name };
|
|
66
|
+
}
|
|
67
|
+
if (props !== void 0 && !isProps(props)) return { ok: false, reason: "props", name };
|
|
68
|
+
const kept = {};
|
|
69
|
+
const dropped = [];
|
|
70
|
+
for (const [key, value] of Object.entries(isProps(props) ? props : {})) {
|
|
71
|
+
const type = shape?.[key];
|
|
72
|
+
const undeclared = shape !== void 0 && type === void 0;
|
|
73
|
+
if (undeclared || !ANALYTICS_NAME.test(key) || !fits(value, type)) {
|
|
74
|
+
dropped.push(key);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
kept[key] = value;
|
|
78
|
+
}
|
|
79
|
+
return { ok: true, name, props: kept, dropped };
|
|
80
|
+
}
|
|
81
|
+
function isProps(value) {
|
|
82
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
83
|
+
}
|
|
84
|
+
function fits(value, type) {
|
|
85
|
+
if (typeof value === "string") return type === void 0 || type === "string";
|
|
86
|
+
if (typeof value === "number") {
|
|
87
|
+
return Number.isFinite(value) && (type === void 0 || type === "number");
|
|
88
|
+
}
|
|
89
|
+
if (typeof value === "boolean") return type === void 0 || type === "boolean";
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ../protocol/src/audio.ts
|
|
94
|
+
var AUDIO_VOLUME_MAX = 1;
|
|
95
|
+
var AUDIO_MASTER = "master";
|
|
96
|
+
var AUDIO_ID_MAX = 48;
|
|
97
|
+
var AUDIO_ID = /^[a-z0-9]+(?:[._-][a-z0-9]+)*$/;
|
|
98
|
+
function isAudioChannelId(value) {
|
|
99
|
+
return typeof value === "string" && value.length <= AUDIO_ID_MAX && AUDIO_ID.test(value);
|
|
100
|
+
}
|
|
101
|
+
var AUDIO_FULL = Object.freeze({
|
|
102
|
+
muted: false,
|
|
103
|
+
master: AUDIO_VOLUME_MAX,
|
|
104
|
+
// Frozen, because this is the fallback every reader reaches for and a shared
|
|
105
|
+
// object somebody wrote one channel into would quietly become the default
|
|
106
|
+
// mix for every game that ever meets a host with nothing to say.
|
|
107
|
+
channels: Object.freeze({})
|
|
108
|
+
});
|
|
109
|
+
function readAudioVolume(value, fallback) {
|
|
110
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
|
|
111
|
+
return Math.min(AUDIO_VOLUME_MAX, Math.max(0, value));
|
|
112
|
+
}
|
|
113
|
+
function readAudioState(value) {
|
|
114
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
|
115
|
+
const raw = value;
|
|
116
|
+
const channels = {};
|
|
117
|
+
if (typeof raw.channels === "object" && raw.channels !== null && !Array.isArray(raw.channels)) {
|
|
118
|
+
for (const [id, level] of Object.entries(raw.channels)) {
|
|
119
|
+
if (!isAudioChannelId(id) || id === AUDIO_MASTER) continue;
|
|
120
|
+
if (typeof level !== "number" || !Number.isFinite(level)) continue;
|
|
121
|
+
channels[id] = Math.min(AUDIO_VOLUME_MAX, Math.max(0, level));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
muted: raw.muted === true,
|
|
126
|
+
master: readAudioVolume(raw.master, AUDIO_FULL.master),
|
|
127
|
+
channels
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
function sameAudioState(a, b) {
|
|
131
|
+
if (a.muted !== b.muted || a.master !== b.master) return false;
|
|
132
|
+
const keys = Object.keys(a.channels);
|
|
133
|
+
if (keys.length !== Object.keys(b.channels).length) return false;
|
|
134
|
+
return keys.every((key) => a.channels[key] === b.channels[key]);
|
|
135
|
+
}
|
|
136
|
+
function audioVolumeOf(state, channel) {
|
|
137
|
+
if (state.muted) return 0;
|
|
138
|
+
if (channel === void 0 || channel === AUDIO_MASTER) return state.master;
|
|
139
|
+
return state.master * (state.channels[channel] ?? AUDIO_VOLUME_MAX);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ../protocol/src/allow.ts
|
|
143
|
+
var ALLOW_ALL = Object.freeze({ audio: true, loop: true, resources: true });
|
|
144
|
+
function readAllowed(value) {
|
|
145
|
+
const raw = typeof value === "object" && value !== null ? value : {};
|
|
146
|
+
return {
|
|
147
|
+
audio: raw.audio !== false,
|
|
148
|
+
loop: raw.loop !== false,
|
|
149
|
+
resources: raw.resources !== false
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ../protocol/src/envelope.ts
|
|
154
|
+
var PROTOCOL_VERSION = 1;
|
|
155
|
+
function isEnvelope(value) {
|
|
156
|
+
if (typeof value !== "object" || value === null) return false;
|
|
157
|
+
const message = value;
|
|
158
|
+
if (message.p !== PROTOCOL_VERSION) return false;
|
|
159
|
+
switch (message.kind) {
|
|
160
|
+
case "req":
|
|
161
|
+
return typeof message.id === "string" && typeof message.method === "string";
|
|
162
|
+
case "res":
|
|
163
|
+
if (typeof message.id !== "string") return false;
|
|
164
|
+
if (message.ok === true) return true;
|
|
165
|
+
if (message.ok === false) return isProtocolError(message.error);
|
|
166
|
+
return false;
|
|
167
|
+
case "evt":
|
|
168
|
+
return typeof message.topic === "string";
|
|
169
|
+
default:
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
function isProtocolError(value) {
|
|
174
|
+
if (typeof value !== "object" || value === null) return false;
|
|
175
|
+
const error = value;
|
|
176
|
+
return typeof error.code === "string" && typeof error.message === "string";
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ../protocol/src/errors.ts
|
|
180
|
+
var BridgeError = class extends Error {
|
|
181
|
+
code;
|
|
182
|
+
constructor(error) {
|
|
183
|
+
super(error.message);
|
|
184
|
+
this.name = "BridgeError";
|
|
185
|
+
this.code = error.code;
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
function toProtocolError(thrown) {
|
|
189
|
+
if (thrown instanceof BridgeError) {
|
|
190
|
+
return { code: thrown.code, message: thrown.message };
|
|
191
|
+
}
|
|
192
|
+
return { code: "internal", message: "The handler failed." };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ../protocol/src/error.ts
|
|
196
|
+
var GAME_ERROR_MESSAGE_MAX = 1e3;
|
|
197
|
+
var GAME_ERROR_STACK_MAX = 8e3;
|
|
198
|
+
function describeThrown(kind, thrown) {
|
|
199
|
+
let message;
|
|
200
|
+
let stack;
|
|
201
|
+
try {
|
|
202
|
+
if (isError(thrown)) {
|
|
203
|
+
message = thrown.message === "" ? String(thrown.name) : `${String(thrown.name)}: ${thrown.message}`;
|
|
204
|
+
stack = typeof thrown.stack === "string" ? thrown.stack : void 0;
|
|
205
|
+
} else if (typeof thrown === "string") {
|
|
206
|
+
message = thrown;
|
|
207
|
+
} else if (typeof thrown === "object" && thrown !== null) {
|
|
208
|
+
message = JSON.stringify(thrown) ?? String(thrown);
|
|
209
|
+
} else {
|
|
210
|
+
message = String(thrown);
|
|
211
|
+
}
|
|
212
|
+
} catch {
|
|
213
|
+
message = "An error that could not be described.";
|
|
214
|
+
}
|
|
215
|
+
const error = { kind, message: clip(message, GAME_ERROR_MESSAGE_MAX) };
|
|
216
|
+
if (stack !== void 0 && stack !== "") error.stack = clip(stack, GAME_ERROR_STACK_MAX);
|
|
217
|
+
return error;
|
|
218
|
+
}
|
|
219
|
+
function isError(value) {
|
|
220
|
+
if (value instanceof Error) return true;
|
|
221
|
+
const tag = Object.prototype.toString.call(value);
|
|
222
|
+
return (tag === "[object Error]" || tag === "[object DOMException]") && typeof value.message === "string";
|
|
223
|
+
}
|
|
224
|
+
function clip(text2, max) {
|
|
225
|
+
return text2.length > max ? `${text2.slice(0, max - 1)}\u2026` : text2;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ../protocol/src/input.ts
|
|
229
|
+
var INPUT_SCHEMES = ["keyboard", "mouse", "touch", "gamepad"];
|
|
230
|
+
var INPUT_ID_MAX = 48;
|
|
231
|
+
var INPUT_ID = /^[a-z0-9]+(?:[._-][a-z0-9]+)*$/;
|
|
232
|
+
var INPUT_BINDING_MAX = 96;
|
|
233
|
+
function isInputActionId(value) {
|
|
234
|
+
return typeof value === "string" && value.length <= INPUT_ID_MAX && INPUT_ID.test(value);
|
|
235
|
+
}
|
|
236
|
+
var INPUT_LAYOUT_SHAPES = ["row", "column", "diamond", "cluster"];
|
|
237
|
+
var INPUT_LAYOUT_ZONES = ["left", "right", "full"];
|
|
238
|
+
var INPUT_CHANGE_CAUSES = ["remap", "scheme", "host", "setting"];
|
|
239
|
+
function readInputBinding(value) {
|
|
240
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
|
241
|
+
const raw = value;
|
|
242
|
+
if (!Array.isArray(raw.bind)) return null;
|
|
243
|
+
const bind = raw.bind.filter(
|
|
244
|
+
(one) => typeof one === "string" && one.length <= INPUT_BINDING_MAX
|
|
245
|
+
);
|
|
246
|
+
const binding = { bind };
|
|
247
|
+
if (typeof raw.title === "string") binding.title = raw.title;
|
|
248
|
+
if (typeof raw.label === "string") binding.label = raw.label;
|
|
249
|
+
return binding;
|
|
250
|
+
}
|
|
251
|
+
function readInputWelcome(value) {
|
|
252
|
+
const state = readInputState(value);
|
|
253
|
+
if (state === null) return null;
|
|
254
|
+
const raw = value;
|
|
255
|
+
const actions = {};
|
|
256
|
+
for (const [id, entry] of entries(raw.actions)) {
|
|
257
|
+
if (!isInputActionId(id)) continue;
|
|
258
|
+
const read = readInputBinding(entry);
|
|
259
|
+
if (read !== null) actions[id] = read;
|
|
260
|
+
}
|
|
261
|
+
const layouts = {};
|
|
262
|
+
for (const [name, entry] of entries(raw.layouts)) {
|
|
263
|
+
if (!isInputActionId(name)) continue;
|
|
264
|
+
const read = readInputLayout(entry);
|
|
265
|
+
if (read !== null) layouts[name] = read;
|
|
266
|
+
}
|
|
267
|
+
return { ...state, actions, layouts };
|
|
268
|
+
}
|
|
269
|
+
function readInputLayout(value) {
|
|
270
|
+
if (!isRecord(value)) return null;
|
|
271
|
+
const layout = {};
|
|
272
|
+
for (const zone of INPUT_LAYOUT_ZONES) {
|
|
273
|
+
const slots = value[zone];
|
|
274
|
+
if (!isRecord(slots)) continue;
|
|
275
|
+
const controls = {};
|
|
276
|
+
for (const [id, slot] of Object.entries(slots)) {
|
|
277
|
+
if (!isInputActionId(id)) continue;
|
|
278
|
+
const read = readInputLayoutControl(slot);
|
|
279
|
+
if (read !== null) controls[id] = read;
|
|
280
|
+
}
|
|
281
|
+
layout[zone] = controls;
|
|
282
|
+
}
|
|
283
|
+
return layout;
|
|
284
|
+
}
|
|
285
|
+
function readInputLayoutControl(value) {
|
|
286
|
+
if (!isRecord(value)) return null;
|
|
287
|
+
const type = value["type"];
|
|
288
|
+
if (type !== "buttons" && type !== "thumbstick") return null;
|
|
289
|
+
const control = { type };
|
|
290
|
+
const buttons2 = value["buttons"];
|
|
291
|
+
if (Array.isArray(buttons2)) control.buttons = buttons2.filter(isInputActionId);
|
|
292
|
+
const shape = value["shape"];
|
|
293
|
+
if (INPUT_LAYOUT_SHAPES.includes(shape)) {
|
|
294
|
+
control.shape = shape;
|
|
295
|
+
}
|
|
296
|
+
const as = value["as"];
|
|
297
|
+
if (isBindingString(as)) control.as = as;
|
|
298
|
+
else if (isRecord(as)) {
|
|
299
|
+
const stands = {};
|
|
300
|
+
for (const [action, binding] of Object.entries(as)) {
|
|
301
|
+
if (isInputActionId(action) && isBindingString(binding)) stands[action] = binding;
|
|
302
|
+
}
|
|
303
|
+
control.as = stands;
|
|
304
|
+
}
|
|
305
|
+
const axis = value["axis"];
|
|
306
|
+
if (type === "thumbstick" && (axis === "x" || axis === "y")) control.axis = axis;
|
|
307
|
+
return control;
|
|
308
|
+
}
|
|
309
|
+
function readInputChange(value) {
|
|
310
|
+
const state = readInputState(value);
|
|
311
|
+
if (state === null) return null;
|
|
312
|
+
const cause = value.cause;
|
|
313
|
+
return {
|
|
314
|
+
...state,
|
|
315
|
+
cause: INPUT_CHANGE_CAUSES.includes(cause) ? cause : "host"
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
function readInputState(value) {
|
|
319
|
+
if (!isRecord(value)) return null;
|
|
320
|
+
const raw = value;
|
|
321
|
+
const mapping = {};
|
|
322
|
+
for (const [id, entry] of entries(raw.mapping)) {
|
|
323
|
+
if (!isInputActionId(id)) continue;
|
|
324
|
+
const read = readInputBinding(entry);
|
|
325
|
+
if (read !== null) mapping[id] = read;
|
|
326
|
+
}
|
|
327
|
+
return {
|
|
328
|
+
mapping,
|
|
329
|
+
// An unknown scheme is a host newer than this game. `keyboard` is the
|
|
330
|
+
// cautious reading: it draws written prompts, which are wrong-looking at
|
|
331
|
+
// worst, where guessing `gamepad` would draw glyphs for a pad nobody holds.
|
|
332
|
+
scheme: INPUT_SCHEMES.includes(raw.scheme) ? raw.scheme : "keyboard",
|
|
333
|
+
controls: raw.controls === true,
|
|
334
|
+
// Absent is a host older than the setting, and its players get the stick
|
|
335
|
+
// that needs no finding. One nobody here knows is a newer host's, and the
|
|
336
|
+
// same answer is the safe one.
|
|
337
|
+
sticks: raw.sticks === "fixed" ? "fixed" : "floating"
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
function isRecord(value) {
|
|
341
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
342
|
+
}
|
|
343
|
+
function entries(value) {
|
|
344
|
+
return isRecord(value) ? Object.entries(value) : [];
|
|
345
|
+
}
|
|
346
|
+
function isBindingString(value) {
|
|
347
|
+
return typeof value === "string" && value.length <= INPUT_BINDING_MAX;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ../protocol/src/methods.ts
|
|
351
|
+
var APP_SURFACES = ["app", "web", "dev"];
|
|
352
|
+
var DEVICE_TIERS = ["low", "mid", "high"];
|
|
353
|
+
var NO_INFO = {
|
|
354
|
+
app: { id: "", version: "", surface: "web" },
|
|
355
|
+
game: { id: "", version: "" },
|
|
356
|
+
player: { id: null, returning: false, locale: "en" },
|
|
357
|
+
device: { tier: "mid" }
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
// ../protocol/src/listeners.ts
|
|
361
|
+
function createListeners(report) {
|
|
362
|
+
const added = /* @__PURE__ */ new Set();
|
|
363
|
+
function call(entry, args) {
|
|
364
|
+
try {
|
|
365
|
+
return entry.listener(...args);
|
|
366
|
+
} catch (thrown) {
|
|
367
|
+
report(thrown);
|
|
368
|
+
return void 0;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return {
|
|
372
|
+
on(listener) {
|
|
373
|
+
const entry = { listener };
|
|
374
|
+
added.add(entry);
|
|
375
|
+
return () => {
|
|
376
|
+
added.delete(entry);
|
|
377
|
+
};
|
|
378
|
+
},
|
|
379
|
+
emit(...args) {
|
|
380
|
+
for (const entry of [...added]) {
|
|
381
|
+
if (!added.has(entry)) continue;
|
|
382
|
+
const result = call(entry, args);
|
|
383
|
+
if (result instanceof Promise) result.catch(report);
|
|
384
|
+
}
|
|
385
|
+
},
|
|
386
|
+
async settle(...args) {
|
|
387
|
+
await Promise.all(
|
|
388
|
+
[...added].map(async (entry) => {
|
|
389
|
+
if (!added.has(entry)) return;
|
|
390
|
+
try {
|
|
391
|
+
await call(entry, args);
|
|
392
|
+
} catch (thrown) {
|
|
393
|
+
report(thrown);
|
|
394
|
+
}
|
|
395
|
+
})
|
|
396
|
+
);
|
|
397
|
+
},
|
|
398
|
+
get size() {
|
|
399
|
+
return added.size;
|
|
400
|
+
},
|
|
401
|
+
clear() {
|
|
402
|
+
added.clear();
|
|
403
|
+
}
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// ../protocol/src/panel.ts
|
|
408
|
+
var PAGE_ID = /^[a-z0-9]+(?:[._-][a-z0-9]+)*$/;
|
|
409
|
+
var PAGE_ID_MAX = 32;
|
|
410
|
+
var PAGES = ["about", "scores", "achievements", "input"];
|
|
411
|
+
function isPageId(value) {
|
|
412
|
+
return typeof value === "string" && value.length <= PAGE_ID_MAX && PAGE_ID.test(value);
|
|
413
|
+
}
|
|
414
|
+
function readPanelWelcome(value) {
|
|
415
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
|
416
|
+
const raw = value;
|
|
417
|
+
const welcome = { ids: Array.isArray(raw.ids) ? raw.ids.filter(isPageId) : [] };
|
|
418
|
+
if (raw.isOpen === true || isPageId(raw.page)) welcome.isOpen = true;
|
|
419
|
+
if (isPageId(raw.page)) welcome.page = raw.page;
|
|
420
|
+
return welcome;
|
|
421
|
+
}
|
|
422
|
+
function readPanelChange(value) {
|
|
423
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
|
424
|
+
const raw = value;
|
|
425
|
+
const page = isPageId(raw.page) ? raw.page : null;
|
|
426
|
+
const isOpen = raw.isOpen === true || page !== null;
|
|
427
|
+
return { isOpen, page: isOpen ? page : null };
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// ../protocol/src/pause.ts
|
|
431
|
+
var PAUSE_STATES = ["running", "paused", "unpausable"];
|
|
432
|
+
var PAUSE_NONE = "unpausable";
|
|
433
|
+
function readPauseRequest(value) {
|
|
434
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
|
435
|
+
const raw = value;
|
|
436
|
+
return typeof raw.paused === "boolean" ? { paused: raw.paused } : null;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// ../protocol/src/save.ts
|
|
440
|
+
var SAVE_BYTES = 65536;
|
|
441
|
+
function readSaveRecord(value) {
|
|
442
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
|
443
|
+
const raw = value;
|
|
444
|
+
if (typeof raw.state !== "string") return null;
|
|
445
|
+
if (typeof raw.version !== "number" || !Number.isFinite(raw.version)) return null;
|
|
446
|
+
if (raw.encoding !== "json" && raw.encoding !== "text") return null;
|
|
447
|
+
const record = { version: raw.version, encoding: raw.encoding, state: raw.state };
|
|
448
|
+
if (typeof raw.rev === "string") record.rev = raw.rev;
|
|
449
|
+
return record;
|
|
450
|
+
}
|
|
451
|
+
function saveBytes(state) {
|
|
452
|
+
return new TextEncoder().encode(state).length;
|
|
453
|
+
}
|
|
454
|
+
function readSaveStored(value) {
|
|
455
|
+
if (typeof value !== "object" || value === null) return null;
|
|
456
|
+
const raw = value;
|
|
457
|
+
if (!number(raw.at) || !number(raw.version) || !number(raw.bytes)) return null;
|
|
458
|
+
return { at: raw.at, version: raw.version, bytes: raw.bytes };
|
|
459
|
+
}
|
|
460
|
+
function number(value) {
|
|
461
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// ../protocol/src/score.ts
|
|
465
|
+
var SCORE_VALUE_MAX = 9007199254740991;
|
|
466
|
+
var SCORE_BOARD_ID = /^[a-z0-9]+(?:[._-][a-z0-9]+)*$/;
|
|
467
|
+
var SCORE_BOARD_ID_MAX = 48;
|
|
468
|
+
function isScoreBoardId(value) {
|
|
469
|
+
return typeof value === "string" && value.length <= SCORE_BOARD_ID_MAX && SCORE_BOARD_ID.test(value);
|
|
470
|
+
}
|
|
471
|
+
function isScoreValue(value) {
|
|
472
|
+
return typeof value === "number" && Number.isFinite(value) && Math.abs(value) <= SCORE_VALUE_MAX;
|
|
473
|
+
}
|
|
474
|
+
function readScoreStanding(value) {
|
|
475
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
|
476
|
+
const raw = value;
|
|
477
|
+
if (!isScoreValue(raw.best) || !number2(raw.at) || !number2(raw.runs)) return null;
|
|
478
|
+
const standing = { best: raw.best, at: raw.at, runs: raw.runs };
|
|
479
|
+
if (number2(raw.rank)) standing.rank = raw.rank;
|
|
480
|
+
if (number2(raw.of)) standing.of = raw.of;
|
|
481
|
+
return standing;
|
|
482
|
+
}
|
|
483
|
+
function readScoreWelcome(value) {
|
|
484
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
|
485
|
+
const raw = value;
|
|
486
|
+
const boards = Array.isArray(raw.boards) ? raw.boards.filter(isScoreBoardId) : [];
|
|
487
|
+
const welcome = { boards };
|
|
488
|
+
if (raw.standing === null) {
|
|
489
|
+
welcome.standing = null;
|
|
490
|
+
} else if (typeof raw.standing === "object" && raw.standing !== void 0) {
|
|
491
|
+
const standing = {};
|
|
492
|
+
for (const [board, entry] of Object.entries(raw.standing)) {
|
|
493
|
+
if (!isScoreBoardId(board)) continue;
|
|
494
|
+
const read = readScoreStanding(entry);
|
|
495
|
+
if (read !== null) standing[board] = read;
|
|
496
|
+
}
|
|
497
|
+
welcome.standing = standing;
|
|
498
|
+
}
|
|
499
|
+
return welcome;
|
|
500
|
+
}
|
|
501
|
+
function readScoreChange(value) {
|
|
502
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
|
503
|
+
const raw = value;
|
|
504
|
+
if (raw.standing === null) return { standing: null };
|
|
505
|
+
const read = readScoreWelcome({ boards: [], standing: raw.standing });
|
|
506
|
+
return read?.standing === void 0 ? null : { standing: read.standing };
|
|
507
|
+
}
|
|
508
|
+
function readScoreResult(value) {
|
|
509
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
|
510
|
+
const raw = value;
|
|
511
|
+
if (typeof raw.improved !== "boolean" || !isScoreValue(raw.best)) return null;
|
|
512
|
+
const result = { improved: raw.improved, best: raw.best };
|
|
513
|
+
if (number2(raw.rank)) result.rank = raw.rank;
|
|
514
|
+
if (number2(raw.of)) result.of = raw.of;
|
|
515
|
+
return result;
|
|
516
|
+
}
|
|
517
|
+
function number2(value) {
|
|
518
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// ../protocol/src/share.ts
|
|
522
|
+
var SHARE_BYTES = 512;
|
|
523
|
+
var SHARE_META_BYTES = 1024;
|
|
524
|
+
function readShareWelcome(value) {
|
|
525
|
+
if (typeof value !== "object" || value === null) return null;
|
|
526
|
+
const raw = value;
|
|
527
|
+
const welcome = {};
|
|
528
|
+
if (typeof raw.data === "string" && raw.data !== "") welcome.data = raw.data;
|
|
529
|
+
if (typeof raw.offers === "boolean") welcome.offers = raw.offers;
|
|
530
|
+
return welcome;
|
|
531
|
+
}
|
|
532
|
+
function readShareMeta(value) {
|
|
533
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
|
534
|
+
try {
|
|
535
|
+
const carried = JSON.parse(JSON.stringify(value));
|
|
536
|
+
return typeof carried === "object" && carried !== null && !Array.isArray(carried) ? carried : null;
|
|
537
|
+
} catch {
|
|
538
|
+
return null;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// ../protocol/src/peer.ts
|
|
543
|
+
var DEFAULT_TIMEOUT_MS = 1e4;
|
|
544
|
+
function createPeer(options) {
|
|
545
|
+
const { transport } = options;
|
|
546
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
547
|
+
const report = options.onError ?? ((error) => console.error(error));
|
|
548
|
+
const handlers = options.handlers;
|
|
549
|
+
const pending = /* @__PURE__ */ new Map();
|
|
550
|
+
const listeners = /* @__PURE__ */ new Map();
|
|
551
|
+
let nextId = 0;
|
|
552
|
+
let mine = null;
|
|
553
|
+
let theirs = null;
|
|
554
|
+
let disposed = false;
|
|
555
|
+
const unsubscribe = transport.receive(receive);
|
|
556
|
+
function receive(message) {
|
|
557
|
+
if (disposed) return;
|
|
558
|
+
if (message.kind === "req") {
|
|
559
|
+
void answer(message);
|
|
560
|
+
} else if (message.kind === "res") {
|
|
561
|
+
settle(message);
|
|
562
|
+
} else {
|
|
563
|
+
deliver(message);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
async function answer(request) {
|
|
567
|
+
if (fromEndedSession(request.id)) {
|
|
568
|
+
post({
|
|
569
|
+
p: PROTOCOL_VERSION,
|
|
570
|
+
kind: "res",
|
|
571
|
+
id: request.id,
|
|
572
|
+
ok: false,
|
|
573
|
+
error: { code: "stale", message: "That conversation has ended; introduce yourself again." }
|
|
574
|
+
});
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
const handler = handlers[request.method];
|
|
578
|
+
if (!handler) {
|
|
579
|
+
post({
|
|
580
|
+
p: PROTOCOL_VERSION,
|
|
581
|
+
kind: "res",
|
|
582
|
+
id: request.id,
|
|
583
|
+
ok: false,
|
|
584
|
+
error: { code: "unknown_method", message: `No handler for "${request.method}".` }
|
|
585
|
+
});
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
try {
|
|
589
|
+
const value = await handler(request.params);
|
|
590
|
+
if (disposed) return;
|
|
591
|
+
post({ p: PROTOCOL_VERSION, kind: "res", id: request.id, ok: true, value });
|
|
592
|
+
} catch (thrown) {
|
|
593
|
+
if (disposed) return;
|
|
594
|
+
post({
|
|
595
|
+
p: PROTOCOL_VERSION,
|
|
596
|
+
kind: "res",
|
|
597
|
+
id: request.id,
|
|
598
|
+
ok: false,
|
|
599
|
+
error: toProtocolError(thrown)
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
function settle(response) {
|
|
604
|
+
const call2 = pending.get(response.id);
|
|
605
|
+
if (!call2) return;
|
|
606
|
+
pending.delete(response.id);
|
|
607
|
+
clearTimeout(call2.timer);
|
|
608
|
+
if (response.ok) call2.resolve(response.value);
|
|
609
|
+
else call2.reject(new BridgeError(response.error));
|
|
610
|
+
}
|
|
611
|
+
function deliver(event) {
|
|
612
|
+
listeners.get(event.topic)?.emit(event.data);
|
|
613
|
+
}
|
|
614
|
+
function post(message) {
|
|
615
|
+
try {
|
|
616
|
+
transport.send(message);
|
|
617
|
+
} catch (thrown) {
|
|
618
|
+
report(thrown);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
function call(method, params, options2) {
|
|
622
|
+
if (disposed) {
|
|
623
|
+
return Promise.reject(
|
|
624
|
+
new BridgeError({ code: "disposed", message: `"${method}" was not sent: peer disposed.` })
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
nextId += 1;
|
|
628
|
+
const id = mine === null ? String(nextId) : `${mine}-${nextId}`;
|
|
629
|
+
const wait = options2?.timeoutMs ?? timeoutMs;
|
|
630
|
+
return new Promise((resolve, reject) => {
|
|
631
|
+
const timer = setTimeout(() => {
|
|
632
|
+
pending.delete(id);
|
|
633
|
+
reject(
|
|
634
|
+
new BridgeError({
|
|
635
|
+
code: "timeout",
|
|
636
|
+
message: `"${method}" got no reply within ${wait}ms.`
|
|
637
|
+
})
|
|
638
|
+
);
|
|
639
|
+
}, wait);
|
|
640
|
+
pending.set(id, { resolve, reject, timer });
|
|
641
|
+
post({ p: PROTOCOL_VERSION, kind: "req", id, method, params });
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
function emit(topic, data) {
|
|
645
|
+
if (disposed) return;
|
|
646
|
+
post({ p: PROTOCOL_VERSION, kind: "evt", topic, data });
|
|
647
|
+
}
|
|
648
|
+
function on(topic, listener) {
|
|
649
|
+
let subscribers = listeners.get(topic);
|
|
650
|
+
if (subscribers === void 0) {
|
|
651
|
+
subscribers = createListeners(report);
|
|
652
|
+
listeners.set(topic, subscribers);
|
|
653
|
+
}
|
|
654
|
+
const stop = subscribers.on(listener);
|
|
655
|
+
const held = subscribers;
|
|
656
|
+
return () => {
|
|
657
|
+
stop();
|
|
658
|
+
if (held.size === 0 && listeners.get(topic) === held) listeners.delete(topic);
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
function abort(error) {
|
|
662
|
+
for (const call2 of pending.values()) {
|
|
663
|
+
clearTimeout(call2.timer);
|
|
664
|
+
call2.reject(error);
|
|
665
|
+
}
|
|
666
|
+
pending.clear();
|
|
667
|
+
}
|
|
668
|
+
function useSession(id) {
|
|
669
|
+
mine = id;
|
|
670
|
+
}
|
|
671
|
+
function expectSession(id) {
|
|
672
|
+
if (disposed) return;
|
|
673
|
+
theirs = id;
|
|
674
|
+
abort(
|
|
675
|
+
new BridgeError({
|
|
676
|
+
code: "stale",
|
|
677
|
+
message: "The other side started over; this call belonged to the conversation before it."
|
|
678
|
+
})
|
|
679
|
+
);
|
|
680
|
+
}
|
|
681
|
+
function fromEndedSession(id) {
|
|
682
|
+
if (theirs === null) return false;
|
|
683
|
+
const stamp = id.indexOf("-");
|
|
684
|
+
if (stamp === -1) return false;
|
|
685
|
+
return id.slice(0, stamp) !== theirs;
|
|
686
|
+
}
|
|
687
|
+
function dispose() {
|
|
688
|
+
if (disposed) return;
|
|
689
|
+
disposed = true;
|
|
690
|
+
unsubscribe();
|
|
691
|
+
abort(new BridgeError({ code: "disposed", message: "The peer was disposed mid-call." }));
|
|
692
|
+
listeners.clear();
|
|
693
|
+
transport.close();
|
|
694
|
+
}
|
|
695
|
+
return { call, emit, on, useSession, expectSession, dispose };
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// ../protocol/src/transport.ts
|
|
699
|
+
var SHIM = "__HOPPLE_SHIM__";
|
|
700
|
+
var SET_SHIM = "__HOPPLE_SET_SHIM__";
|
|
701
|
+
|
|
702
|
+
// package.json
|
|
703
|
+
var package_default = { version: "0.1.0-rc.1" };
|
|
704
|
+
|
|
705
|
+
// src/calls.ts
|
|
706
|
+
function answering(peer) {
|
|
707
|
+
const call = (method, params, options) => peer.call(method, params, options).catch((thrown) => {
|
|
708
|
+
throw notHere(thrown);
|
|
709
|
+
});
|
|
710
|
+
return { ...peer, call };
|
|
711
|
+
}
|
|
712
|
+
function forget(work, onError, then) {
|
|
713
|
+
let sent;
|
|
714
|
+
try {
|
|
715
|
+
sent = work();
|
|
716
|
+
} catch (thrown) {
|
|
717
|
+
sent = Promise.reject(thrown);
|
|
718
|
+
}
|
|
719
|
+
sent.then(then, (thrown) => {
|
|
720
|
+
if (thrown instanceof BridgeError && (thrown.code === "unsupported" || thrown.code === "disposed")) {
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
onError(thrown);
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
function notHere(thrown) {
|
|
727
|
+
if (!(thrown instanceof BridgeError) || thrown.code !== "unknown_method") return thrown;
|
|
728
|
+
return new BridgeError({ code: "unsupported", message: thrown.message });
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// src/achievements.ts
|
|
732
|
+
function createAchievements(options) {
|
|
733
|
+
let available = false;
|
|
734
|
+
let ids = [];
|
|
735
|
+
let state;
|
|
736
|
+
let standing = standingOf(ids, state);
|
|
737
|
+
const listeners = createListeners(options.onError);
|
|
738
|
+
function settle(next) {
|
|
739
|
+
state = next;
|
|
740
|
+
const previous = standing;
|
|
741
|
+
standing = standingOf(ids, state);
|
|
742
|
+
if (!sameStanding(previous, standing)) listeners.emit(standing, previous);
|
|
743
|
+
}
|
|
744
|
+
function keep(id, result) {
|
|
745
|
+
if (state === void 0) return;
|
|
746
|
+
const before = state ?? {};
|
|
747
|
+
const previous = before[id];
|
|
748
|
+
const now = {
|
|
749
|
+
unlocked: result.unlocked,
|
|
750
|
+
// Set once and never moved: the moment something was earned does not
|
|
751
|
+
// change because it was reported again.
|
|
752
|
+
...result.unlocked ? { at: previous?.at ?? Date.now() } : {},
|
|
753
|
+
...result.progress === void 0 ? {} : { progress: result.progress }
|
|
754
|
+
};
|
|
755
|
+
settle({ ...before, [id]: now });
|
|
756
|
+
}
|
|
757
|
+
const achievements = {
|
|
758
|
+
get available() {
|
|
759
|
+
return available;
|
|
760
|
+
},
|
|
761
|
+
get standing() {
|
|
762
|
+
return standing;
|
|
763
|
+
},
|
|
764
|
+
on: listeners.on,
|
|
765
|
+
unlock(id) {
|
|
766
|
+
if (!available) return;
|
|
767
|
+
forget(
|
|
768
|
+
() => options.peer().call("achievement.unlock", { id }),
|
|
769
|
+
options.onError,
|
|
770
|
+
(answer) => {
|
|
771
|
+
keep(id, readAchievementResult(answer) ?? { unlocked: true, earned: true });
|
|
772
|
+
}
|
|
773
|
+
);
|
|
774
|
+
},
|
|
775
|
+
progress(id, value) {
|
|
776
|
+
if (!isAchievementValue(value)) {
|
|
777
|
+
options.onError(
|
|
778
|
+
new Error(`achievements: progress on "${id}" must be a finite number an achievement can hold.`)
|
|
779
|
+
);
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
if (!available) return;
|
|
783
|
+
forget(
|
|
784
|
+
() => options.peer().call("achievement.progress", { id, progress: value }),
|
|
785
|
+
options.onError,
|
|
786
|
+
(answer) => {
|
|
787
|
+
keep(id, readAchievementResult(answer) ?? { unlocked: false, earned: false, progress: value });
|
|
788
|
+
}
|
|
789
|
+
);
|
|
790
|
+
}
|
|
791
|
+
};
|
|
792
|
+
return {
|
|
793
|
+
achievements,
|
|
794
|
+
adopt(value) {
|
|
795
|
+
const welcome = readAchievementWelcome(value);
|
|
796
|
+
available = welcome !== null;
|
|
797
|
+
ids = welcome?.ids ?? [];
|
|
798
|
+
state = welcome?.state;
|
|
799
|
+
standing = standingOf(ids, state);
|
|
800
|
+
},
|
|
801
|
+
onChange(value) {
|
|
802
|
+
const change = readAchievementChange(value);
|
|
803
|
+
if (change !== null) settle(change.state);
|
|
804
|
+
}
|
|
805
|
+
};
|
|
806
|
+
}
|
|
807
|
+
function standingOf(ids, state) {
|
|
808
|
+
if (state === void 0) {
|
|
809
|
+
return Object.freeze({ known: false, items: Object.freeze(ids.map((id) => Object.freeze({ id }))) });
|
|
810
|
+
}
|
|
811
|
+
const held = state ?? {};
|
|
812
|
+
const items = ids.map((id) => Object.freeze({ id, ...held[id] ?? { unlocked: false } }));
|
|
813
|
+
return Object.freeze({ known: true, items: Object.freeze(items) });
|
|
814
|
+
}
|
|
815
|
+
function sameStanding(a, b) {
|
|
816
|
+
if (a.known !== b.known || a.items.length !== b.items.length) return false;
|
|
817
|
+
if (!a.known || !b.known) return true;
|
|
818
|
+
return a.items.every((one, i) => {
|
|
819
|
+
const other = b.items[i];
|
|
820
|
+
return other !== void 0 && one.id === other.id && one.unlocked === other.unlocked && one.at === other.at && one.progress === other.progress;
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// src/analytics.ts
|
|
825
|
+
function createAnalytics(options) {
|
|
826
|
+
return {
|
|
827
|
+
refused(data) {
|
|
828
|
+
const raw = data;
|
|
829
|
+
const said = typeof raw === "object" && raw !== null ? raw : null;
|
|
830
|
+
(options.onRefused ?? options.onError)(
|
|
831
|
+
new Error(`analytics: the host refused "${String(said?.name)}" (${String(said?.reason)}).`)
|
|
832
|
+
);
|
|
833
|
+
},
|
|
834
|
+
analytics: {
|
|
835
|
+
track(name, props) {
|
|
836
|
+
const intake = readAnalyticsEvent(name, props);
|
|
837
|
+
if (!intake.ok) {
|
|
838
|
+
options.onError(new Error(`analytics: "${intake.name}" was not sent (${intake.reason}).`));
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
if (intake.dropped.length > 0) {
|
|
842
|
+
options.onError(
|
|
843
|
+
new Error(`analytics: "${intake.name}" dropped ${intake.dropped.join(", ")}.`)
|
|
844
|
+
);
|
|
845
|
+
}
|
|
846
|
+
const event = { name: intake.name };
|
|
847
|
+
if (Object.keys(intake.props).length > 0) event.props = intake.props;
|
|
848
|
+
options.peer().emit("analytics.event", event);
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
};
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// src/audio.ts
|
|
855
|
+
var UNLOCKING = ["pointerdown", "pointerup", "touchend", "keydown"];
|
|
856
|
+
function createAudio(options) {
|
|
857
|
+
let state = frozen(AUDIO_FULL);
|
|
858
|
+
let allowed = true;
|
|
859
|
+
let ordered = false;
|
|
860
|
+
const listeners = createListeners(options.onError);
|
|
861
|
+
const outputs = /* @__PURE__ */ new Map();
|
|
862
|
+
const held = /* @__PURE__ */ new Map();
|
|
863
|
+
let listening = false;
|
|
864
|
+
function level(channel) {
|
|
865
|
+
return allowed ? audioVolumeOf(state, channel) : 0;
|
|
866
|
+
}
|
|
867
|
+
function apply() {
|
|
868
|
+
for (const gains of outputs.values()) {
|
|
869
|
+
for (const [channel, gain] of gains) gain.gain.value = level(channel);
|
|
870
|
+
}
|
|
871
|
+
for (const [element, one] of held) element.volume = level(one.channel);
|
|
872
|
+
}
|
|
873
|
+
function wake() {
|
|
874
|
+
if (!allowed) return;
|
|
875
|
+
for (const context of outputs.keys()) {
|
|
876
|
+
if (context.state === "suspended") context.resume().catch(() => {
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
for (const [element, one] of held) {
|
|
880
|
+
if (!one.stopped) continue;
|
|
881
|
+
element.play().then(
|
|
882
|
+
() => {
|
|
883
|
+
one.stopped = false;
|
|
884
|
+
},
|
|
885
|
+
() => {
|
|
886
|
+
}
|
|
887
|
+
);
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
async function hush() {
|
|
891
|
+
for (const [element, one] of held) {
|
|
892
|
+
if (element.paused) continue;
|
|
893
|
+
element.pause();
|
|
894
|
+
one.stopped = true;
|
|
895
|
+
}
|
|
896
|
+
await Promise.all(
|
|
897
|
+
[...outputs.keys()].map(
|
|
898
|
+
(context) => context.state === "running" ? context.suspend().catch(() => {
|
|
899
|
+
}) : void 0
|
|
900
|
+
)
|
|
901
|
+
);
|
|
902
|
+
}
|
|
903
|
+
function listen() {
|
|
904
|
+
if (listening || options.scope === null) return;
|
|
905
|
+
listening = true;
|
|
906
|
+
for (const type of UNLOCKING) options.scope.addEventListener(type, wake, { capture: true });
|
|
907
|
+
}
|
|
908
|
+
const audio = {
|
|
909
|
+
get allowed() {
|
|
910
|
+
return allowed;
|
|
911
|
+
},
|
|
912
|
+
get muted() {
|
|
913
|
+
return state.muted;
|
|
914
|
+
},
|
|
915
|
+
get master() {
|
|
916
|
+
return state.master;
|
|
917
|
+
},
|
|
918
|
+
get state() {
|
|
919
|
+
return state;
|
|
920
|
+
},
|
|
921
|
+
volume(channel) {
|
|
922
|
+
return level(channel);
|
|
923
|
+
},
|
|
924
|
+
channel(id) {
|
|
925
|
+
if (id === AUDIO_MASTER) return state.master;
|
|
926
|
+
return state.channels[id] ?? AUDIO_VOLUME_MAX;
|
|
927
|
+
},
|
|
928
|
+
on: listeners.on,
|
|
929
|
+
output(context, channel) {
|
|
930
|
+
let gains = outputs.get(context);
|
|
931
|
+
if (gains === void 0) {
|
|
932
|
+
gains = /* @__PURE__ */ new Map();
|
|
933
|
+
outputs.set(context, gains);
|
|
934
|
+
if (!allowed && context.state === "running") {
|
|
935
|
+
context.suspend().catch(() => {
|
|
936
|
+
});
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
let gain = gains.get(channel);
|
|
940
|
+
if (gain === void 0) {
|
|
941
|
+
gain = context.createGain();
|
|
942
|
+
gain.gain.value = level(channel);
|
|
943
|
+
gain.connect(context.destination);
|
|
944
|
+
gains.set(channel, gain);
|
|
945
|
+
}
|
|
946
|
+
listen();
|
|
947
|
+
wake();
|
|
948
|
+
return gain;
|
|
949
|
+
},
|
|
950
|
+
media(element, channel) {
|
|
951
|
+
held.set(element, { channel, stopped: false });
|
|
952
|
+
element.volume = level(channel);
|
|
953
|
+
if (!allowed && !element.paused) {
|
|
954
|
+
element.pause();
|
|
955
|
+
const one = held.get(element);
|
|
956
|
+
if (one !== void 0) one.stopped = true;
|
|
957
|
+
}
|
|
958
|
+
listen();
|
|
959
|
+
return () => {
|
|
960
|
+
held.delete(element);
|
|
961
|
+
};
|
|
962
|
+
}
|
|
963
|
+
};
|
|
964
|
+
return {
|
|
965
|
+
audio,
|
|
966
|
+
async onAllow(params) {
|
|
967
|
+
ordered = true;
|
|
968
|
+
if (params.allowed !== allowed) {
|
|
969
|
+
allowed = params.allowed;
|
|
970
|
+
apply();
|
|
971
|
+
listeners.emit();
|
|
972
|
+
if (allowed) wake();
|
|
973
|
+
else await hush();
|
|
974
|
+
}
|
|
975
|
+
return { allowed };
|
|
976
|
+
},
|
|
977
|
+
dispose() {
|
|
978
|
+
if (listening && options.scope !== null) {
|
|
979
|
+
for (const type of UNLOCKING) options.scope.removeEventListener(type, wake, { capture: true });
|
|
980
|
+
}
|
|
981
|
+
listening = false;
|
|
982
|
+
outputs.clear();
|
|
983
|
+
held.clear();
|
|
984
|
+
},
|
|
985
|
+
adopt(value, initial) {
|
|
986
|
+
if (!ordered) allowed = initial;
|
|
987
|
+
state = frozen(readAudioState(value) ?? AUDIO_FULL);
|
|
988
|
+
apply();
|
|
989
|
+
},
|
|
990
|
+
onSet(params) {
|
|
991
|
+
const next = frozen(readAudioState(params) ?? state);
|
|
992
|
+
if (!sameAudioState(state, next)) {
|
|
993
|
+
state = next;
|
|
994
|
+
apply();
|
|
995
|
+
listeners.emit();
|
|
996
|
+
}
|
|
997
|
+
return state;
|
|
998
|
+
}
|
|
999
|
+
};
|
|
1000
|
+
}
|
|
1001
|
+
function frozen(state) {
|
|
1002
|
+
return Object.freeze({
|
|
1003
|
+
muted: state.muted,
|
|
1004
|
+
master: state.master,
|
|
1005
|
+
channels: Object.freeze({ ...state.channels })
|
|
1006
|
+
});
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
// src/errors.ts
|
|
1010
|
+
var HELD_MAX = 20;
|
|
1011
|
+
var FOLD_MS = 1e3;
|
|
1012
|
+
var FOLDING_MAX = 32;
|
|
1013
|
+
function createErrorReporter(scope) {
|
|
1014
|
+
let peer = null;
|
|
1015
|
+
let held = [];
|
|
1016
|
+
const folding = /* @__PURE__ */ new Map();
|
|
1017
|
+
function deliver(error) {
|
|
1018
|
+
if (peer !== null) {
|
|
1019
|
+
peer.emit("error.report", error);
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1022
|
+
if (held.length < HELD_MAX) held.push(error);
|
|
1023
|
+
}
|
|
1024
|
+
function fold(key, error) {
|
|
1025
|
+
const entry = {
|
|
1026
|
+
error,
|
|
1027
|
+
again: 0,
|
|
1028
|
+
timer: setTimeout(() => {
|
|
1029
|
+
folding.delete(key);
|
|
1030
|
+
if (entry.again === 0) return;
|
|
1031
|
+
deliver({ ...error, count: entry.again });
|
|
1032
|
+
fold(key, error);
|
|
1033
|
+
}, FOLD_MS)
|
|
1034
|
+
};
|
|
1035
|
+
folding.set(key, entry);
|
|
1036
|
+
}
|
|
1037
|
+
function send(error) {
|
|
1038
|
+
const key = `${error.kind}
|
|
1039
|
+
${error.message}
|
|
1040
|
+
${error.stack ?? ""}`;
|
|
1041
|
+
const seen = folding.get(key);
|
|
1042
|
+
if (seen !== void 0) {
|
|
1043
|
+
seen.again += 1;
|
|
1044
|
+
return;
|
|
1045
|
+
}
|
|
1046
|
+
deliver(error);
|
|
1047
|
+
if (folding.size < FOLDING_MAX) fold(key, error);
|
|
1048
|
+
}
|
|
1049
|
+
function report(kind, thrown) {
|
|
1050
|
+
send(describeThrown(kind, thrown));
|
|
1051
|
+
}
|
|
1052
|
+
const onError = (event) => {
|
|
1053
|
+
const caught = event;
|
|
1054
|
+
if (caught.error !== void 0 && caught.error !== null) {
|
|
1055
|
+
report("uncaught", caught.error);
|
|
1056
|
+
return;
|
|
1057
|
+
}
|
|
1058
|
+
if (typeof caught.message !== "string" || caught.message === "") return;
|
|
1059
|
+
const error = describeThrown("uncaught", caught.message);
|
|
1060
|
+
if (typeof caught.filename === "string" && caught.filename !== "") {
|
|
1061
|
+
error.stack = `at ${caught.filename}:${caught.lineno}:${caught.colno}`;
|
|
1062
|
+
}
|
|
1063
|
+
send(error);
|
|
1064
|
+
};
|
|
1065
|
+
const onRejection = (event) => {
|
|
1066
|
+
report("rejection", event.reason);
|
|
1067
|
+
};
|
|
1068
|
+
scope?.addEventListener("error", onError);
|
|
1069
|
+
scope?.addEventListener("unhandledrejection", onRejection);
|
|
1070
|
+
return {
|
|
1071
|
+
report,
|
|
1072
|
+
connect(next) {
|
|
1073
|
+
peer = next;
|
|
1074
|
+
const waiting = held;
|
|
1075
|
+
held = [];
|
|
1076
|
+
for (const error of waiting) next.emit("error.report", error);
|
|
1077
|
+
},
|
|
1078
|
+
dispose() {
|
|
1079
|
+
scope?.removeEventListener("error", onError);
|
|
1080
|
+
scope?.removeEventListener("unhandledrejection", onRejection);
|
|
1081
|
+
for (const { error, again, timer } of folding.values()) {
|
|
1082
|
+
clearTimeout(timer);
|
|
1083
|
+
if (again > 0) deliver({ ...error, count: again });
|
|
1084
|
+
}
|
|
1085
|
+
folding.clear();
|
|
1086
|
+
held = [];
|
|
1087
|
+
}
|
|
1088
|
+
};
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
// src/info.ts
|
|
1092
|
+
function readInfo(value) {
|
|
1093
|
+
const message = group(value);
|
|
1094
|
+
const app = group(message.app);
|
|
1095
|
+
const game = group(message.game);
|
|
1096
|
+
const player = group(message.player);
|
|
1097
|
+
const device = group(message.device);
|
|
1098
|
+
return Object.freeze({
|
|
1099
|
+
app: Object.freeze({
|
|
1100
|
+
id: text(app.id, NO_INFO.app.id),
|
|
1101
|
+
version: text(app.version, NO_INFO.app.version),
|
|
1102
|
+
surface: oneOf(app.surface, APP_SURFACES, NO_INFO.app.surface)
|
|
1103
|
+
}),
|
|
1104
|
+
game: Object.freeze({
|
|
1105
|
+
id: text(game.id, NO_INFO.game.id),
|
|
1106
|
+
version: text(game.version, NO_INFO.game.version)
|
|
1107
|
+
}),
|
|
1108
|
+
player: Object.freeze({
|
|
1109
|
+
// Anything that is not a name is nobody. A host that means "there is a
|
|
1110
|
+
// player here" says so with their name for this game, and an empty string
|
|
1111
|
+
// is not one — believing it would hand every anonymous player the same id.
|
|
1112
|
+
id: typeof player.id === "string" && player.id !== "" ? player.id : null,
|
|
1113
|
+
returning: player.returning === true,
|
|
1114
|
+
locale: text(player.locale, NO_INFO.player.locale)
|
|
1115
|
+
}),
|
|
1116
|
+
device: Object.freeze({
|
|
1117
|
+
tier: oneOf(device.tier, DEVICE_TIERS, NO_INFO.device.tier)
|
|
1118
|
+
})
|
|
1119
|
+
});
|
|
1120
|
+
}
|
|
1121
|
+
function group(value) {
|
|
1122
|
+
return typeof value === "object" && value !== null ? value : {};
|
|
1123
|
+
}
|
|
1124
|
+
function text(value, fallback) {
|
|
1125
|
+
return typeof value === "string" ? value : fallback;
|
|
1126
|
+
}
|
|
1127
|
+
function oneOf(value, allowed, fallback) {
|
|
1128
|
+
return allowed.includes(value) ? value : fallback;
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
// src/input/bindings.ts
|
|
1132
|
+
var PAD_BUTTONS = [
|
|
1133
|
+
"pad.right.south",
|
|
1134
|
+
"pad.right.east",
|
|
1135
|
+
"pad.right.west",
|
|
1136
|
+
"pad.right.north",
|
|
1137
|
+
"pad.left.bumper",
|
|
1138
|
+
"pad.right.bumper",
|
|
1139
|
+
"pad.left.trigger",
|
|
1140
|
+
"pad.right.trigger",
|
|
1141
|
+
"pad.select",
|
|
1142
|
+
"pad.start",
|
|
1143
|
+
// The stick presses. No name yet: nothing has asked for one, and a name added
|
|
1144
|
+
// when something does is additive, where a name invented now is forever.
|
|
1145
|
+
null,
|
|
1146
|
+
null,
|
|
1147
|
+
"pad.left.north",
|
|
1148
|
+
"pad.left.south",
|
|
1149
|
+
"pad.left.west",
|
|
1150
|
+
"pad.left.east"
|
|
1151
|
+
];
|
|
1152
|
+
var PAD_STICKS = ["pad.left.stick", "pad.right.stick"];
|
|
1153
|
+
var EITHER_STICK = ["pad.left.stick", "pad.right.stick"];
|
|
1154
|
+
function matchesBinding(declared, concrete) {
|
|
1155
|
+
if (declared === concrete) return true;
|
|
1156
|
+
if (declared.startsWith("pad.") && concrete.startsWith("pad.")) {
|
|
1157
|
+
const want = declared.slice(4);
|
|
1158
|
+
const parts = concrete.slice(4).split(".");
|
|
1159
|
+
return parts.length === 2 && parts[1] === want;
|
|
1160
|
+
}
|
|
1161
|
+
if (declared === "pointer.primary") return concrete === "mouse.left" || concrete === "touch.tap";
|
|
1162
|
+
if (declared === "pointer.secondary") return concrete === "mouse.right";
|
|
1163
|
+
return false;
|
|
1164
|
+
}
|
|
1165
|
+
function matchesStick(listening, concrete) {
|
|
1166
|
+
if (listening === concrete) return true;
|
|
1167
|
+
return listening === "pad.stick" && EITHER_STICK.includes(concrete);
|
|
1168
|
+
}
|
|
1169
|
+
function preferredBinding(bind, scheme) {
|
|
1170
|
+
return bind.find((one) => servesScheme(one, scheme)) ?? bind[0];
|
|
1171
|
+
}
|
|
1172
|
+
function servesScheme(binding, scheme) {
|
|
1173
|
+
if (binding.startsWith("pointer.")) return scheme === "mouse" || scheme === "touch";
|
|
1174
|
+
if (binding.startsWith("controls.")) return scheme === "touch";
|
|
1175
|
+
switch (scheme) {
|
|
1176
|
+
case "keyboard":
|
|
1177
|
+
return binding.startsWith("key.");
|
|
1178
|
+
case "gamepad":
|
|
1179
|
+
return binding.startsWith("pad.");
|
|
1180
|
+
case "mouse":
|
|
1181
|
+
return binding.startsWith("mouse.");
|
|
1182
|
+
case "touch":
|
|
1183
|
+
return binding.startsWith("touch.");
|
|
1184
|
+
default:
|
|
1185
|
+
return false;
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
function labelFor(binding, scheme) {
|
|
1189
|
+
if (binding.startsWith("key.")) return keyLabel(binding.slice(4));
|
|
1190
|
+
if (binding === "mouse.left") return "Click";
|
|
1191
|
+
if (binding === "mouse.right") return "Right click";
|
|
1192
|
+
if (binding === "mouse.middle") return "Middle click";
|
|
1193
|
+
if (binding === "touch.tap") return "Tap";
|
|
1194
|
+
if (binding === "pointer.primary") return scheme === "touch" ? "Tap" : "Click";
|
|
1195
|
+
if (binding === "pointer.secondary") return "Right click";
|
|
1196
|
+
if (!binding.startsWith("pad.")) return "";
|
|
1197
|
+
const parts = binding.slice(4).split(".");
|
|
1198
|
+
const [head, tail] = parts;
|
|
1199
|
+
if (parts.length === 1) {
|
|
1200
|
+
if (head === "start") return "Start";
|
|
1201
|
+
if (head === "select") return "Select";
|
|
1202
|
+
return FACES[head] ?? "";
|
|
1203
|
+
}
|
|
1204
|
+
if (head === "left" && tail !== void 0) {
|
|
1205
|
+
const arrow = ARROWS[tail];
|
|
1206
|
+
if (arrow !== void 0) return arrow;
|
|
1207
|
+
return tail === "bumper" ? "LB" : tail === "trigger" ? "LT" : "";
|
|
1208
|
+
}
|
|
1209
|
+
if (head === "right" && tail !== void 0) {
|
|
1210
|
+
const face = FACES[tail];
|
|
1211
|
+
if (face !== void 0) return face;
|
|
1212
|
+
return tail === "bumper" ? "RB" : tail === "trigger" ? "RT" : "";
|
|
1213
|
+
}
|
|
1214
|
+
return "";
|
|
1215
|
+
}
|
|
1216
|
+
var FACES = { south: "A", east: "B", west: "X", north: "Y" };
|
|
1217
|
+
var ARROWS = { north: "\u2191", south: "\u2193", west: "\u2190", east: "\u2192" };
|
|
1218
|
+
function keyLabel(code) {
|
|
1219
|
+
if (code.startsWith("Key") && code.length === 4) return code.slice(3);
|
|
1220
|
+
if (code.startsWith("Digit") && code.length === 6) return code.slice(5);
|
|
1221
|
+
if (code.startsWith("Numpad")) return `Num ${code.slice(6)}`;
|
|
1222
|
+
const named = {
|
|
1223
|
+
ArrowUp: "\u2191",
|
|
1224
|
+
ArrowDown: "\u2193",
|
|
1225
|
+
ArrowLeft: "\u2190",
|
|
1226
|
+
ArrowRight: "\u2192",
|
|
1227
|
+
Space: "Space",
|
|
1228
|
+
Enter: "Enter",
|
|
1229
|
+
Tab: "Tab",
|
|
1230
|
+
Backspace: "Backspace",
|
|
1231
|
+
ShiftLeft: "Shift",
|
|
1232
|
+
ShiftRight: "Shift",
|
|
1233
|
+
ControlLeft: "Ctrl",
|
|
1234
|
+
ControlRight: "Ctrl",
|
|
1235
|
+
AltLeft: "Alt",
|
|
1236
|
+
AltRight: "Alt"
|
|
1237
|
+
};
|
|
1238
|
+
return named[code] ?? code;
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
// src/input/controls.ts
|
|
1242
|
+
var INPUT_SHIM = "__HOPPLE_INPUT__";
|
|
1243
|
+
function isInputShim(value) {
|
|
1244
|
+
if (typeof value !== "object" || value === null) return false;
|
|
1245
|
+
return value.v === 1;
|
|
1246
|
+
}
|
|
1247
|
+
var INPUT_CONTRACT = 4;
|
|
1248
|
+
var SIDES = ["top", "right", "bottom", "left"];
|
|
1249
|
+
var KNOWN_SHAPES = ["row", "column", "diamond", "cluster"];
|
|
1250
|
+
var ZONES = ["left", "right", "full"];
|
|
1251
|
+
function resolveLayout(name, layout, actions, frame, insets, sticks) {
|
|
1252
|
+
const zones = [];
|
|
1253
|
+
for (const zone of ZONES) {
|
|
1254
|
+
const slots = layout[zone];
|
|
1255
|
+
if (slots === void 0) continue;
|
|
1256
|
+
const controls = [];
|
|
1257
|
+
for (const [id, slot] of Object.entries(slots)) {
|
|
1258
|
+
controls.push(resolveSlot(id, slot, actions));
|
|
1259
|
+
}
|
|
1260
|
+
if (controls.length > 0) zones.push({ zone, controls });
|
|
1261
|
+
}
|
|
1262
|
+
return { layout: name, zones, frame, insets, sticks };
|
|
1263
|
+
}
|
|
1264
|
+
function resolveSlot(id, slot, actions) {
|
|
1265
|
+
const shape = KNOWN_SHAPES.includes(slot.shape) ? slot.shape : "column";
|
|
1266
|
+
if (slot.type === "thumbstick") {
|
|
1267
|
+
const stick2 = typeof slot.as === "string" ? slot.as : `controls.${id}`;
|
|
1268
|
+
const resolved = { id, type: "thumbstick", shape, buttons: [], stick: stick2 };
|
|
1269
|
+
if (slot.axis !== void 0) resolved.axis = slot.axis;
|
|
1270
|
+
return resolved;
|
|
1271
|
+
}
|
|
1272
|
+
const stands = typeof slot.as === "object" && slot.as !== null ? slot.as : {};
|
|
1273
|
+
const buttons2 = (slot.buttons ?? []).map((action) => {
|
|
1274
|
+
const button = {
|
|
1275
|
+
action,
|
|
1276
|
+
label: labelOf(actions[action]) ?? action
|
|
1277
|
+
};
|
|
1278
|
+
const stood = stands[action];
|
|
1279
|
+
if (typeof stood === "string") button.as = stood;
|
|
1280
|
+
return button;
|
|
1281
|
+
});
|
|
1282
|
+
return { id, type: "buttons", shape, buttons: buttons2, stick: "" };
|
|
1283
|
+
}
|
|
1284
|
+
function labelOf(action) {
|
|
1285
|
+
const label = action?.label;
|
|
1286
|
+
return typeof label === "string" && label !== "" ? label : void 0;
|
|
1287
|
+
}
|
|
1288
|
+
function createControlsHost(options) {
|
|
1289
|
+
const { scope } = options;
|
|
1290
|
+
const doc = scope.document;
|
|
1291
|
+
let host = null;
|
|
1292
|
+
let root = null;
|
|
1293
|
+
let teardown = null;
|
|
1294
|
+
let styled = false;
|
|
1295
|
+
function mount() {
|
|
1296
|
+
if (root !== null) return root;
|
|
1297
|
+
host = doc.createElement("div");
|
|
1298
|
+
host.setAttribute("data-hopple-controls", "");
|
|
1299
|
+
host.style.cssText = "position:fixed;inset:0;pointer-events:none;z-index:2147483000;touch-action:none;";
|
|
1300
|
+
root = host.attachShadow({ mode: "open" });
|
|
1301
|
+
(doc.body ?? doc.documentElement).append(host);
|
|
1302
|
+
return root;
|
|
1303
|
+
}
|
|
1304
|
+
return {
|
|
1305
|
+
render(model) {
|
|
1306
|
+
this.clear();
|
|
1307
|
+
const root2 = mount();
|
|
1308
|
+
for (const side of SIDES) {
|
|
1309
|
+
host?.style.setProperty(`--hopple-pad-${side}`, `${model.insets[side]}px`);
|
|
1310
|
+
}
|
|
1311
|
+
const shim = options.shim();
|
|
1312
|
+
if (shim !== null && !styled && typeof shim.style === "function") {
|
|
1313
|
+
try {
|
|
1314
|
+
shim.style(root2, { layout: model.layout, frame: model.frame });
|
|
1315
|
+
styled = true;
|
|
1316
|
+
} catch (thrown) {
|
|
1317
|
+
options.onError(thrown);
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
if (shim !== null && typeof shim.render === "function") {
|
|
1321
|
+
try {
|
|
1322
|
+
const undo = shim.render(root2, model, options.report);
|
|
1323
|
+
teardown = typeof undo === "function" ? undo : null;
|
|
1324
|
+
return;
|
|
1325
|
+
} catch (thrown) {
|
|
1326
|
+
options.onError(thrown);
|
|
1327
|
+
root2.replaceChildren();
|
|
1328
|
+
teardown = null;
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
teardown = draw(root2, model, options.report, doc, options.onError);
|
|
1332
|
+
},
|
|
1333
|
+
clear() {
|
|
1334
|
+
if (teardown !== null) {
|
|
1335
|
+
try {
|
|
1336
|
+
teardown();
|
|
1337
|
+
} catch (thrown) {
|
|
1338
|
+
options.onError(thrown);
|
|
1339
|
+
}
|
|
1340
|
+
teardown = null;
|
|
1341
|
+
}
|
|
1342
|
+
root?.replaceChildren();
|
|
1343
|
+
},
|
|
1344
|
+
rects() {
|
|
1345
|
+
const found = [];
|
|
1346
|
+
if (root === null) return found;
|
|
1347
|
+
for (const element of root.querySelectorAll("[data-control]")) {
|
|
1348
|
+
const box = element.getBoundingClientRect();
|
|
1349
|
+
if (box.width <= 0 || box.height <= 0) continue;
|
|
1350
|
+
found.push({ x: box.x, y: box.y, width: box.width, height: box.height });
|
|
1351
|
+
}
|
|
1352
|
+
return found;
|
|
1353
|
+
},
|
|
1354
|
+
fits(frame, insets) {
|
|
1355
|
+
if (frame.width <= 0 || frame.height <= 0) return true;
|
|
1356
|
+
const right = frame.width - insets.right;
|
|
1357
|
+
const bottom = frame.height - insets.bottom;
|
|
1358
|
+
for (const rect of this.rects()) {
|
|
1359
|
+
if (rect.x < insets.left || rect.y < insets.top) return false;
|
|
1360
|
+
if (rect.x + rect.width > right || rect.y + rect.height > bottom) return false;
|
|
1361
|
+
}
|
|
1362
|
+
return true;
|
|
1363
|
+
},
|
|
1364
|
+
owns(event) {
|
|
1365
|
+
if (host === null) return false;
|
|
1366
|
+
const path = typeof event.composedPath === "function" ? event.composedPath() : [];
|
|
1367
|
+
if (path.includes(host)) return true;
|
|
1368
|
+
return event.target === host;
|
|
1369
|
+
},
|
|
1370
|
+
dispose() {
|
|
1371
|
+
this.clear();
|
|
1372
|
+
host?.remove();
|
|
1373
|
+
host = null;
|
|
1374
|
+
root = null;
|
|
1375
|
+
}
|
|
1376
|
+
};
|
|
1377
|
+
}
|
|
1378
|
+
function draw(root, model, report, doc, onError) {
|
|
1379
|
+
const style = doc.createElement("style");
|
|
1380
|
+
style.textContent = SHEET;
|
|
1381
|
+
root.append(style);
|
|
1382
|
+
const undo = [];
|
|
1383
|
+
const floating = model.sticks === "floating";
|
|
1384
|
+
for (const { zone, controls } of model.zones) {
|
|
1385
|
+
const element = doc.createElement("div");
|
|
1386
|
+
element.className = `zone zone-${zone}`;
|
|
1387
|
+
for (const control of controls) {
|
|
1388
|
+
if (control.type === "buttons") {
|
|
1389
|
+
element.append(buttons(control, report, doc, undo, onError));
|
|
1390
|
+
continue;
|
|
1391
|
+
}
|
|
1392
|
+
const drawn = stick(control, floating ? zone : null, report, doc, undo, onError);
|
|
1393
|
+
element.append(drawn.well);
|
|
1394
|
+
if (drawn.reach !== null) style.after(drawn.reach);
|
|
1395
|
+
}
|
|
1396
|
+
root.append(element);
|
|
1397
|
+
}
|
|
1398
|
+
return () => {
|
|
1399
|
+
for (const off of undo) off();
|
|
1400
|
+
};
|
|
1401
|
+
}
|
|
1402
|
+
function buttons(control, report, doc, undo, onError) {
|
|
1403
|
+
const group2 = doc.createElement("div");
|
|
1404
|
+
group2.className = `control buttons shape-${control.shape}`;
|
|
1405
|
+
group2.setAttribute("data-control", control.id);
|
|
1406
|
+
for (const [index, button] of control.buttons.entries()) {
|
|
1407
|
+
const element = doc.createElement("button");
|
|
1408
|
+
element.className = "btn";
|
|
1409
|
+
element.type = "button";
|
|
1410
|
+
element.textContent = button.label;
|
|
1411
|
+
element.setAttribute("aria-label", button.action);
|
|
1412
|
+
element.setAttribute("data-action", button.action);
|
|
1413
|
+
const set = (down2) => (event) => {
|
|
1414
|
+
event.preventDefault();
|
|
1415
|
+
event.stopPropagation();
|
|
1416
|
+
element.classList.toggle("down", down2);
|
|
1417
|
+
try {
|
|
1418
|
+
report.press(control.id, index, down2);
|
|
1419
|
+
} catch (thrown) {
|
|
1420
|
+
onError(thrown);
|
|
1421
|
+
}
|
|
1422
|
+
};
|
|
1423
|
+
const down = set(true);
|
|
1424
|
+
const up = set(false);
|
|
1425
|
+
element.addEventListener("pointerdown", down);
|
|
1426
|
+
element.addEventListener("pointerup", up);
|
|
1427
|
+
element.addEventListener("pointercancel", up);
|
|
1428
|
+
element.addEventListener("pointerleave", up);
|
|
1429
|
+
undo.push(() => {
|
|
1430
|
+
element.removeEventListener("pointerdown", down);
|
|
1431
|
+
element.removeEventListener("pointerup", up);
|
|
1432
|
+
element.removeEventListener("pointercancel", up);
|
|
1433
|
+
element.removeEventListener("pointerleave", up);
|
|
1434
|
+
});
|
|
1435
|
+
group2.append(element);
|
|
1436
|
+
}
|
|
1437
|
+
return group2;
|
|
1438
|
+
}
|
|
1439
|
+
function stick(control, zone, report, doc, undo, onError) {
|
|
1440
|
+
const { axis } = control;
|
|
1441
|
+
const well = doc.createElement("div");
|
|
1442
|
+
well.className = axis === void 0 ? "control stick" : `control stick track track-${axis}`;
|
|
1443
|
+
if (zone !== null) well.classList.add("floating");
|
|
1444
|
+
well.setAttribute("data-control", control.id);
|
|
1445
|
+
well.setAttribute("aria-label", control.stick);
|
|
1446
|
+
if (axis !== void 0) {
|
|
1447
|
+
for (const end2 of ["start", "end"]) {
|
|
1448
|
+
const chevron = doc.createElement("span");
|
|
1449
|
+
chevron.className = `chevron chevron-${end2}`;
|
|
1450
|
+
well.append(chevron);
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
const knob = doc.createElement("div");
|
|
1454
|
+
knob.className = "knob";
|
|
1455
|
+
well.append(knob);
|
|
1456
|
+
let reach = null;
|
|
1457
|
+
if (zone !== null) {
|
|
1458
|
+
reach = doc.createElement("div");
|
|
1459
|
+
reach.className = `reach reach-${zone}`;
|
|
1460
|
+
reach.setAttribute("data-reach", control.id);
|
|
1461
|
+
}
|
|
1462
|
+
let holding = null;
|
|
1463
|
+
let rest = null;
|
|
1464
|
+
let origin = null;
|
|
1465
|
+
function send(x, y) {
|
|
1466
|
+
knob.style.setProperty("--x", String(x));
|
|
1467
|
+
knob.style.setProperty("--y", String(y));
|
|
1468
|
+
try {
|
|
1469
|
+
report.move(control.id, x, y);
|
|
1470
|
+
} catch (thrown) {
|
|
1471
|
+
onError(thrown);
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
function bring(to) {
|
|
1475
|
+
origin = to;
|
|
1476
|
+
const from = rest ?? to;
|
|
1477
|
+
well.classList.toggle("held", to !== null);
|
|
1478
|
+
well.style.setProperty("--dx", `${to === null || from === null ? 0 : to.x - from.x}px`);
|
|
1479
|
+
well.style.setProperty("--dy", `${to === null || from === null ? 0 : to.y - from.y}px`);
|
|
1480
|
+
}
|
|
1481
|
+
const begin = (event) => {
|
|
1482
|
+
const pointer = event;
|
|
1483
|
+
event.preventDefault();
|
|
1484
|
+
event.stopPropagation();
|
|
1485
|
+
if (zone !== null && rest === null) {
|
|
1486
|
+
const box = well.getBoundingClientRect();
|
|
1487
|
+
rest = { x: box.x + box.width / 2, y: box.y + box.height / 2 };
|
|
1488
|
+
}
|
|
1489
|
+
holding = pointer.pointerId;
|
|
1490
|
+
try {
|
|
1491
|
+
event.currentTarget?.setPointerCapture?.(pointer.pointerId);
|
|
1492
|
+
} catch {
|
|
1493
|
+
}
|
|
1494
|
+
if (zone !== null) bring({ x: pointer.clientX, y: pointer.clientY });
|
|
1495
|
+
lean(pointer);
|
|
1496
|
+
};
|
|
1497
|
+
const lean = (pointer) => {
|
|
1498
|
+
const box = well.getBoundingClientRect();
|
|
1499
|
+
const centre = origin ?? { x: box.x + box.width / 2, y: box.y + box.height / 2 };
|
|
1500
|
+
if (axis !== void 0) {
|
|
1501
|
+
const long = axis === "x" ? box.width : box.height;
|
|
1502
|
+
const short = axis === "x" ? box.height : box.width;
|
|
1503
|
+
const travel = long > short ? (long - short) / 2 : long / 2;
|
|
1504
|
+
if (travel <= 0) return;
|
|
1505
|
+
const offset = axis === "x" ? pointer.clientX - centre.x : pointer.clientY - centre.y;
|
|
1506
|
+
const along = Math.max(-1, Math.min(1, offset / travel));
|
|
1507
|
+
send(axis === "x" ? along : 0, axis === "y" ? along : 0);
|
|
1508
|
+
return;
|
|
1509
|
+
}
|
|
1510
|
+
const radius = Math.min(box.width, box.height) / 2;
|
|
1511
|
+
if (radius <= 0) return;
|
|
1512
|
+
let x = (pointer.clientX - centre.x) / radius;
|
|
1513
|
+
let y = (pointer.clientY - centre.y) / radius;
|
|
1514
|
+
const magnitude = Math.hypot(x, y);
|
|
1515
|
+
if (magnitude > 1) {
|
|
1516
|
+
x /= magnitude;
|
|
1517
|
+
y /= magnitude;
|
|
1518
|
+
}
|
|
1519
|
+
send(x, y);
|
|
1520
|
+
};
|
|
1521
|
+
const move = (event) => {
|
|
1522
|
+
const pointer = event;
|
|
1523
|
+
if (holding !== pointer.pointerId) return;
|
|
1524
|
+
event.preventDefault();
|
|
1525
|
+
event.stopPropagation();
|
|
1526
|
+
lean(pointer);
|
|
1527
|
+
};
|
|
1528
|
+
const end = (event) => {
|
|
1529
|
+
const pointer = event;
|
|
1530
|
+
if (holding !== pointer.pointerId) return;
|
|
1531
|
+
event.stopPropagation();
|
|
1532
|
+
holding = null;
|
|
1533
|
+
if (zone !== null) bring(null);
|
|
1534
|
+
send(0, 0);
|
|
1535
|
+
};
|
|
1536
|
+
for (const target of reach === null ? [well] : [well, reach]) {
|
|
1537
|
+
target.addEventListener("pointerdown", begin);
|
|
1538
|
+
target.addEventListener("pointermove", move);
|
|
1539
|
+
target.addEventListener("pointerup", end);
|
|
1540
|
+
target.addEventListener("pointercancel", end);
|
|
1541
|
+
undo.push(() => {
|
|
1542
|
+
target.removeEventListener("pointerdown", begin);
|
|
1543
|
+
target.removeEventListener("pointermove", move);
|
|
1544
|
+
target.removeEventListener("pointerup", end);
|
|
1545
|
+
target.removeEventListener("pointercancel", end);
|
|
1546
|
+
});
|
|
1547
|
+
}
|
|
1548
|
+
return { well, reach };
|
|
1549
|
+
}
|
|
1550
|
+
var SHEET = `
|
|
1551
|
+
:host, * { box-sizing: border-box; }
|
|
1552
|
+
:host {
|
|
1553
|
+
--hopple-pad-size: 56px;
|
|
1554
|
+
--hopple-pad-stick: 116px;
|
|
1555
|
+
/* The length of a stick held to one axis; its thickness is a button's. */
|
|
1556
|
+
--hopple-pad-track: 148px;
|
|
1557
|
+
--hopple-pad-gap: 12px;
|
|
1558
|
+
--hopple-pad-inset: 20px;
|
|
1559
|
+
/* What the host has taken, written onto the element by render(). Kept apart
|
|
1560
|
+
from --hopple-pad-inset deliberately: that one is the gap the pad likes to
|
|
1561
|
+
keep off an edge and is a shim's to set, these are a fact about the frame
|
|
1562
|
+
and are not. The zones spend the sum, so a shim restyling the gap does not
|
|
1563
|
+
have to know the rail exists. */
|
|
1564
|
+
--hopple-pad-top: 0px;
|
|
1565
|
+
--hopple-pad-right: 0px;
|
|
1566
|
+
--hopple-pad-bottom: 0px;
|
|
1567
|
+
--hopple-pad-left: 0px;
|
|
1568
|
+
--hopple-pad-tint: rgba(255, 255, 255, 0.14);
|
|
1569
|
+
--hopple-pad-edge: rgba(255, 255, 255, 0.55);
|
|
1570
|
+
--hopple-pad-ink: #fff;
|
|
1571
|
+
--hopple-pad-opacity: 0.85;
|
|
1572
|
+
--hopple-pad-radius: 999px;
|
|
1573
|
+
/* How high a floating stick's side of the screen reaches, from the bottom. */
|
|
1574
|
+
--hopple-pad-reach: 60%;
|
|
1575
|
+
/* A floating stick at rest is a sign that there is one, drawn quieter than
|
|
1576
|
+
the stick under a thumb. */
|
|
1577
|
+
--hopple-pad-idle: 0.6;
|
|
1578
|
+
}
|
|
1579
|
+
.zone {
|
|
1580
|
+
position: fixed;
|
|
1581
|
+
bottom: calc(var(--hopple-pad-inset) + var(--hopple-pad-bottom));
|
|
1582
|
+
display: flex; align-items: flex-end; gap: var(--hopple-pad-gap);
|
|
1583
|
+
opacity: var(--hopple-pad-opacity); pointer-events: none;
|
|
1584
|
+
}
|
|
1585
|
+
.zone-left { left: calc(var(--hopple-pad-inset) + var(--hopple-pad-left)); }
|
|
1586
|
+
.zone-right {
|
|
1587
|
+
right: calc(var(--hopple-pad-inset) + var(--hopple-pad-right));
|
|
1588
|
+
flex-direction: row-reverse;
|
|
1589
|
+
}
|
|
1590
|
+
.zone-full {
|
|
1591
|
+
left: calc(var(--hopple-pad-inset) + var(--hopple-pad-left));
|
|
1592
|
+
right: calc(var(--hopple-pad-inset) + var(--hopple-pad-right));
|
|
1593
|
+
justify-content: center;
|
|
1594
|
+
}
|
|
1595
|
+
.control { pointer-events: auto; touch-action: none; }
|
|
1596
|
+
.buttons { display: grid; gap: var(--hopple-pad-gap); }
|
|
1597
|
+
.shape-column { grid-auto-flow: row; }
|
|
1598
|
+
.shape-row { grid-auto-flow: column; }
|
|
1599
|
+
.shape-cluster { grid-template-columns: repeat(2, auto); }
|
|
1600
|
+
.shape-diamond {
|
|
1601
|
+
grid-template-columns: repeat(3, auto); grid-template-rows: repeat(3, auto);
|
|
1602
|
+
}
|
|
1603
|
+
/* North, west, east, south \u2014 the order a diamond is declared in. */
|
|
1604
|
+
.shape-diamond .btn:nth-child(1) { grid-area: 1 / 2; }
|
|
1605
|
+
.shape-diamond .btn:nth-child(2) { grid-area: 2 / 1; }
|
|
1606
|
+
.shape-diamond .btn:nth-child(3) { grid-area: 2 / 3; }
|
|
1607
|
+
.shape-diamond .btn:nth-child(4) { grid-area: 3 / 2; }
|
|
1608
|
+
.btn {
|
|
1609
|
+
width: var(--hopple-pad-size); height: var(--hopple-pad-size);
|
|
1610
|
+
border-radius: var(--hopple-pad-radius);
|
|
1611
|
+
border: 1.5px solid var(--hopple-pad-edge);
|
|
1612
|
+
background: var(--hopple-pad-tint); color: var(--hopple-pad-ink);
|
|
1613
|
+
font: 600 15px/1 system-ui, sans-serif;
|
|
1614
|
+
display: grid; place-items: center; padding: 0;
|
|
1615
|
+
touch-action: none; user-select: none; -webkit-user-select: none;
|
|
1616
|
+
-webkit-tap-highlight-color: transparent; cursor: pointer;
|
|
1617
|
+
}
|
|
1618
|
+
.btn.down { background: var(--hopple-pad-edge); }
|
|
1619
|
+
.stick {
|
|
1620
|
+
width: var(--hopple-pad-stick); height: var(--hopple-pad-stick);
|
|
1621
|
+
border-radius: 50%; border: 1.5px solid var(--hopple-pad-edge);
|
|
1622
|
+
background: var(--hopple-pad-tint);
|
|
1623
|
+
display: grid; place-items: center;
|
|
1624
|
+
touch-action: none; user-select: none; -webkit-user-select: none;
|
|
1625
|
+
-webkit-tap-highlight-color: transparent;
|
|
1626
|
+
}
|
|
1627
|
+
.knob {
|
|
1628
|
+
width: 44%; height: 44%; border-radius: 50%;
|
|
1629
|
+
background: var(--hopple-pad-edge);
|
|
1630
|
+
transform: translate(calc(var(--x, 0) * 40%), calc(var(--y, 0) * 40%));
|
|
1631
|
+
will-change: transform;
|
|
1632
|
+
}
|
|
1633
|
+
.track {
|
|
1634
|
+
position: relative;
|
|
1635
|
+
--hopple-pad-travel: calc((var(--hopple-pad-track) - var(--hopple-pad-size)) / 2);
|
|
1636
|
+
}
|
|
1637
|
+
.track-x { width: var(--hopple-pad-track); height: var(--hopple-pad-size); border-radius: var(--hopple-pad-radius); }
|
|
1638
|
+
.track-y { width: var(--hopple-pad-size); height: var(--hopple-pad-track); border-radius: var(--hopple-pad-radius); }
|
|
1639
|
+
.track .knob {
|
|
1640
|
+
position: relative; z-index: 1;
|
|
1641
|
+
width: calc(var(--hopple-pad-size) - 14px); height: calc(var(--hopple-pad-size) - 14px);
|
|
1642
|
+
transform: translate(
|
|
1643
|
+
calc(var(--x, 0) * var(--hopple-pad-travel)),
|
|
1644
|
+
calc(var(--y, 0) * var(--hopple-pad-travel))
|
|
1645
|
+
);
|
|
1646
|
+
}
|
|
1647
|
+
/* A groove along the track for the knob to ride in. */
|
|
1648
|
+
.track::before {
|
|
1649
|
+
content: ''; position: absolute; border-radius: 2px;
|
|
1650
|
+
background: var(--hopple-pad-edge); opacity: 0.35;
|
|
1651
|
+
}
|
|
1652
|
+
.track-x::before { left: 30%; right: 30%; top: calc(50% - 1px); height: 2px; }
|
|
1653
|
+
.track-y::before { top: 30%; bottom: 30%; left: calc(50% - 1px); width: 2px; }
|
|
1654
|
+
/* An open arrow at each end: which way the track goes, before a thumb is on it. */
|
|
1655
|
+
.chevron {
|
|
1656
|
+
position: absolute; width: 9px; height: 9px;
|
|
1657
|
+
border: solid var(--hopple-pad-edge); border-width: 0 0 2px 2px;
|
|
1658
|
+
}
|
|
1659
|
+
.track-x .chevron { top: calc(50% - 4.5px); }
|
|
1660
|
+
.track-x .chevron-start { left: 14px; transform: rotate(45deg); }
|
|
1661
|
+
.track-x .chevron-end { right: 14px; transform: rotate(-135deg); }
|
|
1662
|
+
.track-y .chevron { left: calc(50% - 4.5px); }
|
|
1663
|
+
.track-y .chevron-start { top: 14px; transform: rotate(135deg); }
|
|
1664
|
+
.track-y .chevron-end { bottom: 14px; transform: rotate(-45deg); }
|
|
1665
|
+
/* Where a floating stick listens. The host's furniture is left alone; the
|
|
1666
|
+
pad's own gap off the edge is not kept, since a thumb is not a drawing. */
|
|
1667
|
+
.reach {
|
|
1668
|
+
position: fixed; pointer-events: auto; touch-action: none;
|
|
1669
|
+
bottom: var(--hopple-pad-bottom);
|
|
1670
|
+
height: calc(var(--hopple-pad-reach) - var(--hopple-pad-bottom));
|
|
1671
|
+
-webkit-tap-highlight-color: transparent;
|
|
1672
|
+
}
|
|
1673
|
+
.reach-left { left: var(--hopple-pad-left); right: 50%; }
|
|
1674
|
+
.reach-right { left: 50%; right: var(--hopple-pad-right); }
|
|
1675
|
+
.reach-full { left: var(--hopple-pad-left); right: var(--hopple-pad-right); }
|
|
1676
|
+
.floating { translate: var(--dx, 0px) var(--dy, 0px); }
|
|
1677
|
+
.floating:not(.held) { opacity: var(--hopple-pad-idle); }
|
|
1678
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
1679
|
+
.knob { transition: transform 60ms linear; }
|
|
1680
|
+
/* Back to rest gently; to a thumb at once, since it is already there. */
|
|
1681
|
+
.floating { transition: translate 140ms ease-out, opacity 140ms ease-out; }
|
|
1682
|
+
.floating.held { transition: none; }
|
|
1683
|
+
}
|
|
1684
|
+
`;
|
|
1685
|
+
|
|
1686
|
+
// src/input/sources.ts
|
|
1687
|
+
function typing(scope) {
|
|
1688
|
+
const active = scope.document.activeElement;
|
|
1689
|
+
if (active === null) return false;
|
|
1690
|
+
if (active.isContentEditable) return true;
|
|
1691
|
+
const tag = active.tagName;
|
|
1692
|
+
return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT";
|
|
1693
|
+
}
|
|
1694
|
+
function createSources(options) {
|
|
1695
|
+
const { scope, store } = options;
|
|
1696
|
+
const doc = scope.document;
|
|
1697
|
+
const always = [];
|
|
1698
|
+
const moving = [];
|
|
1699
|
+
let watching = false;
|
|
1700
|
+
let frame = null;
|
|
1701
|
+
const padButtons = /* @__PURE__ */ new Map();
|
|
1702
|
+
const pads = /* @__PURE__ */ new Set();
|
|
1703
|
+
let focused = true;
|
|
1704
|
+
function listen(target, type, handler, into, capture = false) {
|
|
1705
|
+
const wrapped = (event) => {
|
|
1706
|
+
try {
|
|
1707
|
+
handler(event);
|
|
1708
|
+
} catch (thrown) {
|
|
1709
|
+
options.onError(thrown);
|
|
1710
|
+
}
|
|
1711
|
+
};
|
|
1712
|
+
target.addEventListener(type, wrapped, capture);
|
|
1713
|
+
into.push(() => target.removeEventListener(type, wrapped, capture));
|
|
1714
|
+
}
|
|
1715
|
+
function key(event, down) {
|
|
1716
|
+
const press = event;
|
|
1717
|
+
if (down && press.repeat) return;
|
|
1718
|
+
if (options.suspended() || typing(scope)) return;
|
|
1719
|
+
const concrete = `key.${press.code}`;
|
|
1720
|
+
if (!options.isBound(concrete)) return;
|
|
1721
|
+
press.preventDefault();
|
|
1722
|
+
options.onScheme("keyboard");
|
|
1723
|
+
store.press(concrete, down);
|
|
1724
|
+
}
|
|
1725
|
+
function buttonOf(event) {
|
|
1726
|
+
if (event.pointerType !== "mouse") return "touch.tap";
|
|
1727
|
+
if (event.button === 0) return "mouse.left";
|
|
1728
|
+
if (event.button === 1) return "mouse.middle";
|
|
1729
|
+
if (event.button === 2) return "mouse.right";
|
|
1730
|
+
return null;
|
|
1731
|
+
}
|
|
1732
|
+
function pointer(event, phase) {
|
|
1733
|
+
const move = event;
|
|
1734
|
+
if (options.fromControls(event)) return;
|
|
1735
|
+
options.onScheme(move.pointerType === "mouse" ? "mouse" : "touch");
|
|
1736
|
+
if (phase === "move") {
|
|
1737
|
+
store.movePointer({ x: move.clientX, y: move.clientY, down: store.pointer?.down ?? false });
|
|
1738
|
+
return;
|
|
1739
|
+
}
|
|
1740
|
+
const down = phase === "down";
|
|
1741
|
+
store.movePointer({ x: move.clientX, y: move.clientY, down });
|
|
1742
|
+
if (options.suspended()) return;
|
|
1743
|
+
const concrete = buttonOf(move);
|
|
1744
|
+
if (concrete !== null) store.press(concrete, down);
|
|
1745
|
+
if (concrete === "touch.tap" || concrete === "mouse.left") store.press("pointer.primary", down);
|
|
1746
|
+
}
|
|
1747
|
+
function applyDeadzone(x, y) {
|
|
1748
|
+
const dead = options.deadzone();
|
|
1749
|
+
const magnitude = Math.hypot(x, y);
|
|
1750
|
+
if (magnitude <= dead) return { x: 0, y: 0 };
|
|
1751
|
+
if (dead <= 0 || magnitude === 0) return { x, y };
|
|
1752
|
+
const scaled = (magnitude - dead) / (1 - dead) / magnitude;
|
|
1753
|
+
return { x: x * scaled, y: y * scaled };
|
|
1754
|
+
}
|
|
1755
|
+
function samplePads() {
|
|
1756
|
+
for (const pad of scope.navigator.getGamepads?.() ?? []) {
|
|
1757
|
+
if (pad === null) continue;
|
|
1758
|
+
if (pad.mapping === "standard") {
|
|
1759
|
+
const previous = padButtons.get(pad.index) ?? [];
|
|
1760
|
+
const now = [];
|
|
1761
|
+
for (const [index, button] of pad.buttons.entries()) {
|
|
1762
|
+
const down = button.pressed;
|
|
1763
|
+
now[index] = down;
|
|
1764
|
+
if (down === (previous[index] ?? false)) continue;
|
|
1765
|
+
if (!focused) continue;
|
|
1766
|
+
const concrete = PAD_BUTTONS[index];
|
|
1767
|
+
if (concrete === null || concrete === void 0) continue;
|
|
1768
|
+
options.onScheme("gamepad");
|
|
1769
|
+
if (!options.suspended()) store.press(concrete, down);
|
|
1770
|
+
}
|
|
1771
|
+
padButtons.set(pad.index, now);
|
|
1772
|
+
}
|
|
1773
|
+
for (const [pair, stick2] of PAD_STICKS.entries()) {
|
|
1774
|
+
const raw = focused ? applyDeadzone(pad.axes[pair * 2] ?? 0, pad.axes[pair * 2 + 1] ?? 0) : { x: 0, y: 0 };
|
|
1775
|
+
if (raw.x !== 0 || raw.y !== 0) options.onScheme("gamepad");
|
|
1776
|
+
store.moveStick(stick2, raw.x, raw.y);
|
|
1777
|
+
}
|
|
1778
|
+
}
|
|
1779
|
+
}
|
|
1780
|
+
function loop() {
|
|
1781
|
+
const tick = () => {
|
|
1782
|
+
frame = null;
|
|
1783
|
+
try {
|
|
1784
|
+
samplePads();
|
|
1785
|
+
} catch (thrown) {
|
|
1786
|
+
options.onError(thrown);
|
|
1787
|
+
}
|
|
1788
|
+
if (pads.size > 0 && frame === null) loop();
|
|
1789
|
+
};
|
|
1790
|
+
frame = typeof scope.requestAnimationFrame === "function" ? scope.requestAnimationFrame(tick) : setTimeout(tick, 16);
|
|
1791
|
+
}
|
|
1792
|
+
function stopLoop() {
|
|
1793
|
+
if (frame === null) return;
|
|
1794
|
+
if (typeof scope.cancelAnimationFrame === "function") scope.cancelAnimationFrame(frame);
|
|
1795
|
+
else clearTimeout(frame);
|
|
1796
|
+
frame = null;
|
|
1797
|
+
}
|
|
1798
|
+
function connect(index) {
|
|
1799
|
+
pads.add(index);
|
|
1800
|
+
if (frame === null) loop();
|
|
1801
|
+
}
|
|
1802
|
+
function disconnect(index) {
|
|
1803
|
+
pads.delete(index);
|
|
1804
|
+
const held = padButtons.get(index) ?? [];
|
|
1805
|
+
padButtons.delete(index);
|
|
1806
|
+
for (const [button, down] of held.entries()) {
|
|
1807
|
+
const concrete = PAD_BUTTONS[button];
|
|
1808
|
+
if (down && concrete !== null && concrete !== void 0) store.press(concrete, false);
|
|
1809
|
+
}
|
|
1810
|
+
for (const stick2 of PAD_STICKS) store.moveStick(stick2, 0, 0);
|
|
1811
|
+
if (pads.size === 0) stopLoop();
|
|
1812
|
+
}
|
|
1813
|
+
return {
|
|
1814
|
+
attach() {
|
|
1815
|
+
listen(scope, "keydown", (event) => key(event, true), always);
|
|
1816
|
+
listen(scope, "keyup", (event) => key(event, false), always);
|
|
1817
|
+
listen(scope, "pointerdown", (event) => pointer(event, "down"), always);
|
|
1818
|
+
listen(scope, "pointerup", (event) => pointer(event, "up"), always);
|
|
1819
|
+
listen(scope, "pointercancel", (event) => pointer(event, "up"), always);
|
|
1820
|
+
listen(scope, "blur", () => {
|
|
1821
|
+
focused = false;
|
|
1822
|
+
store.releaseAll();
|
|
1823
|
+
}, always);
|
|
1824
|
+
listen(scope, "focus", () => {
|
|
1825
|
+
focused = true;
|
|
1826
|
+
}, always);
|
|
1827
|
+
listen(doc, "visibilitychange", () => {
|
|
1828
|
+
if (doc.visibilityState === "hidden") store.releaseAll();
|
|
1829
|
+
}, always);
|
|
1830
|
+
listen(scope, "contextmenu", (event) => {
|
|
1831
|
+
if (options.isBound("mouse.right")) event.preventDefault();
|
|
1832
|
+
}, always);
|
|
1833
|
+
listen(scope, "gamepadconnected", (event) => {
|
|
1834
|
+
connect(event.gamepad.index);
|
|
1835
|
+
}, always);
|
|
1836
|
+
listen(scope, "gamepaddisconnected", (event) => {
|
|
1837
|
+
disconnect(event.gamepad.index);
|
|
1838
|
+
}, always);
|
|
1839
|
+
for (const pad of scope.navigator.getGamepads?.() ?? []) {
|
|
1840
|
+
if (pad !== null) connect(pad.index);
|
|
1841
|
+
}
|
|
1842
|
+
},
|
|
1843
|
+
watchPointer(on) {
|
|
1844
|
+
if (on === watching) return;
|
|
1845
|
+
watching = on;
|
|
1846
|
+
if (on) {
|
|
1847
|
+
listen(scope, "pointermove", (event) => pointer(event, "move"), moving);
|
|
1848
|
+
listen(doc, "pointerleave", () => store.movePointer(null), moving);
|
|
1849
|
+
return;
|
|
1850
|
+
}
|
|
1851
|
+
for (const off of moving.splice(0)) off();
|
|
1852
|
+
},
|
|
1853
|
+
detach() {
|
|
1854
|
+
this.watchPointer(false);
|
|
1855
|
+
for (const off of always.splice(0)) off();
|
|
1856
|
+
stopLoop();
|
|
1857
|
+
pads.clear();
|
|
1858
|
+
padButtons.clear();
|
|
1859
|
+
}
|
|
1860
|
+
};
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
// src/input/state.ts
|
|
1864
|
+
var CENTRED = { x: 0, y: 0 };
|
|
1865
|
+
function createInputStore(options) {
|
|
1866
|
+
let mapping = {};
|
|
1867
|
+
const held = /* @__PURE__ */ new Map();
|
|
1868
|
+
let started = /* @__PURE__ */ new Set();
|
|
1869
|
+
let ended = /* @__PURE__ */ new Set();
|
|
1870
|
+
const sticks = /* @__PURE__ */ new Map();
|
|
1871
|
+
let pointer = null;
|
|
1872
|
+
const actionListeners = /* @__PURE__ */ new Map();
|
|
1873
|
+
const stickListeners = /* @__PURE__ */ new Map();
|
|
1874
|
+
const pointerListeners = createListeners(options.onError);
|
|
1875
|
+
function under(keyed, key) {
|
|
1876
|
+
let listeners = keyed.get(key);
|
|
1877
|
+
if (listeners === void 0) {
|
|
1878
|
+
listeners = createListeners(options.onError);
|
|
1879
|
+
keyed.set(key, listeners);
|
|
1880
|
+
}
|
|
1881
|
+
return listeners;
|
|
1882
|
+
}
|
|
1883
|
+
function settle(action, down) {
|
|
1884
|
+
(down ? started : ended).add(action);
|
|
1885
|
+
const listeners = actionListeners.get(action);
|
|
1886
|
+
listeners?.emit(down, action);
|
|
1887
|
+
}
|
|
1888
|
+
function hold(action, source, down) {
|
|
1889
|
+
let sources = held.get(action);
|
|
1890
|
+
if (sources === void 0) {
|
|
1891
|
+
sources = /* @__PURE__ */ new Set();
|
|
1892
|
+
held.set(action, sources);
|
|
1893
|
+
}
|
|
1894
|
+
const was = sources.size > 0;
|
|
1895
|
+
if (down) sources.add(source);
|
|
1896
|
+
else sources.delete(source);
|
|
1897
|
+
const now = sources.size > 0;
|
|
1898
|
+
if (was !== now) settle(action, now);
|
|
1899
|
+
}
|
|
1900
|
+
return {
|
|
1901
|
+
/**
|
|
1902
|
+
* The mapping in force. Replacing it releases everything held.
|
|
1903
|
+
*
|
|
1904
|
+
* A binding that disappeared under a finger is a press with nothing to end
|
|
1905
|
+
* it, and a game left holding `shoot` forever because a player opened the
|
|
1906
|
+
* remapping screen mid-shot is the exact bug this API exists to prevent.
|
|
1907
|
+
*
|
|
1908
|
+
* `release: false` is for the caller that has already established the
|
|
1909
|
+
* bindings did not actually move — a label rebuilt because somebody picked
|
|
1910
|
+
* up a controller. Releasing there would stop a player mid-jump for a
|
|
1911
|
+
* change that was only ever cosmetic.
|
|
1912
|
+
*/
|
|
1913
|
+
setMapping(next, options2) {
|
|
1914
|
+
mapping = next;
|
|
1915
|
+
if (options2?.release !== false) this.releaseAll();
|
|
1916
|
+
},
|
|
1917
|
+
get mapping() {
|
|
1918
|
+
return mapping;
|
|
1919
|
+
},
|
|
1920
|
+
/**
|
|
1921
|
+
* Something concrete happened: resolve it against the mapping.
|
|
1922
|
+
*
|
|
1923
|
+
* `source` names *who* is holding the action, where that is not the same as
|
|
1924
|
+
* what was pressed. An on-screen button standing in for `pad.south` matches
|
|
1925
|
+
* the same bindings a real pad does and must still be releasable on its own
|
|
1926
|
+
* — otherwise hiding the controls under a held finger would either leave the
|
|
1927
|
+
* action held forever, or let go of the real pad button beside it.
|
|
1928
|
+
*/
|
|
1929
|
+
press(concrete, down, source) {
|
|
1930
|
+
for (const [action, binding] of Object.entries(mapping)) {
|
|
1931
|
+
if (!binding.bind.some((declared) => matchesBinding(declared, concrete))) continue;
|
|
1932
|
+
hold(action, source ?? concrete, down);
|
|
1933
|
+
}
|
|
1934
|
+
},
|
|
1935
|
+
/**
|
|
1936
|
+
* An action pressed by name, without going through the mapping.
|
|
1937
|
+
*
|
|
1938
|
+
* How an on-screen control works: its slot names the action, so there is no
|
|
1939
|
+
* binding in between and nothing for a remap to change. That is deliberate —
|
|
1940
|
+
* a player rearranges the buttons a game drew for them by not using them,
|
|
1941
|
+
* not by rebinding something that is already the action itself.
|
|
1942
|
+
*/
|
|
1943
|
+
pressAction(action, source, down) {
|
|
1944
|
+
hold(action, source, down);
|
|
1945
|
+
},
|
|
1946
|
+
moveStick(stick2, x, y) {
|
|
1947
|
+
const previous = sticks.get(stick2);
|
|
1948
|
+
if (previous !== void 0 && previous.x === x && previous.y === y) return;
|
|
1949
|
+
const value = { x, y };
|
|
1950
|
+
sticks.set(stick2, value);
|
|
1951
|
+
for (const [listening, listeners] of stickListeners) {
|
|
1952
|
+
if (!matchesStick(listening, stick2)) continue;
|
|
1953
|
+
listeners.emit(value, stick2);
|
|
1954
|
+
}
|
|
1955
|
+
},
|
|
1956
|
+
movePointer(next) {
|
|
1957
|
+
const same = next === null && pointer === null || next !== null && pointer !== null && next.x === pointer.x && next.y === pointer.y && next.down === pointer.down;
|
|
1958
|
+
if (same) return;
|
|
1959
|
+
pointer = next;
|
|
1960
|
+
pointerListeners.emit(next);
|
|
1961
|
+
},
|
|
1962
|
+
/**
|
|
1963
|
+
* Everything up, at once.
|
|
1964
|
+
*
|
|
1965
|
+
* The single answer to every way the ground can move under a held button:
|
|
1966
|
+
* the frame lost focus, the tab was hidden, the host paused the game, the
|
|
1967
|
+
* player switched layouts, a text field opened. In each of those the release
|
|
1968
|
+
* that would have ended the press is going to land somewhere else, and a
|
|
1969
|
+
* game that never hears it holds the button forever.
|
|
1970
|
+
*/
|
|
1971
|
+
releaseAll() {
|
|
1972
|
+
for (const [action, sources] of held) {
|
|
1973
|
+
if (sources.size === 0) continue;
|
|
1974
|
+
sources.clear();
|
|
1975
|
+
settle(action, false);
|
|
1976
|
+
}
|
|
1977
|
+
for (const stick2 of [...sticks.keys()]) this.moveStick(stick2, 0, 0);
|
|
1978
|
+
this.movePointer(null);
|
|
1979
|
+
},
|
|
1980
|
+
/**
|
|
1981
|
+
* Let go of whatever these sources were holding, and nothing else.
|
|
1982
|
+
*
|
|
1983
|
+
* What hiding the on-screen controls does. Releasing everything would be
|
|
1984
|
+
* safe and would also drop the keys a player has under their other hand,
|
|
1985
|
+
* which on a device with both is a game that stops moving because a pad
|
|
1986
|
+
* went away.
|
|
1987
|
+
*/
|
|
1988
|
+
releaseFrom(owns) {
|
|
1989
|
+
for (const [action, sources] of held) {
|
|
1990
|
+
if (sources.size === 0) continue;
|
|
1991
|
+
for (const source of [...sources]) if (owns(source)) sources.delete(source);
|
|
1992
|
+
if (sources.size === 0) settle(action, false);
|
|
1993
|
+
}
|
|
1994
|
+
},
|
|
1995
|
+
pressed(action) {
|
|
1996
|
+
return (held.get(action)?.size ?? 0) > 0;
|
|
1997
|
+
},
|
|
1998
|
+
stick(stick2) {
|
|
1999
|
+
const exact = sticks.get(stick2);
|
|
2000
|
+
if (exact !== void 0) return exact;
|
|
2001
|
+
for (const [id, value] of sticks) {
|
|
2002
|
+
if (matchesStick(stick2, id) && (value.x !== 0 || value.y !== 0)) return value;
|
|
2003
|
+
}
|
|
2004
|
+
return CENTRED;
|
|
2005
|
+
},
|
|
2006
|
+
get pointer() {
|
|
2007
|
+
return pointer;
|
|
2008
|
+
},
|
|
2009
|
+
/**
|
|
2010
|
+
* What is true now, and what has happened since this was last asked.
|
|
2011
|
+
*
|
|
2012
|
+
* Taking one consumes the edges, which is why it is called once a frame:
|
|
2013
|
+
* two readers in one frame means the second sees none of the presses the
|
|
2014
|
+
* first did.
|
|
2015
|
+
*/
|
|
2016
|
+
read() {
|
|
2017
|
+
const startedNow = started;
|
|
2018
|
+
const endedNow = ended;
|
|
2019
|
+
started = /* @__PURE__ */ new Set();
|
|
2020
|
+
ended = /* @__PURE__ */ new Set();
|
|
2021
|
+
const down = /* @__PURE__ */ new Set();
|
|
2022
|
+
for (const [action, sources] of held) if (sources.size > 0) down.add(action);
|
|
2023
|
+
const stickValues = new Map(sticks);
|
|
2024
|
+
const where = pointer === null ? null : { ...pointer };
|
|
2025
|
+
return Object.freeze({
|
|
2026
|
+
pressed: (action) => down.has(action),
|
|
2027
|
+
started: (action) => startedNow.has(action),
|
|
2028
|
+
ended: (action) => endedNow.has(action),
|
|
2029
|
+
stick(stick2) {
|
|
2030
|
+
const exact = stickValues.get(stick2);
|
|
2031
|
+
if (exact !== void 0) return exact;
|
|
2032
|
+
for (const [id, value] of stickValues) {
|
|
2033
|
+
if (matchesStick(stick2, id) && (value.x !== 0 || value.y !== 0)) return value;
|
|
2034
|
+
}
|
|
2035
|
+
return CENTRED;
|
|
2036
|
+
},
|
|
2037
|
+
pointer: where
|
|
2038
|
+
});
|
|
2039
|
+
},
|
|
2040
|
+
onAction(action, listener) {
|
|
2041
|
+
return under(actionListeners, action).on(listener);
|
|
2042
|
+
},
|
|
2043
|
+
onStick(stick2, listener) {
|
|
2044
|
+
return under(stickListeners, stick2).on(listener);
|
|
2045
|
+
},
|
|
2046
|
+
onPointer(listener) {
|
|
2047
|
+
return pointerListeners.on(listener);
|
|
2048
|
+
},
|
|
2049
|
+
/** Whether anything is listening for where the pointer is. */
|
|
2050
|
+
get watchingPointer() {
|
|
2051
|
+
return pointerListeners.size > 0;
|
|
2052
|
+
},
|
|
2053
|
+
dispose() {
|
|
2054
|
+
actionListeners.clear();
|
|
2055
|
+
stickListeners.clear();
|
|
2056
|
+
pointerListeners.clear();
|
|
2057
|
+
held.clear();
|
|
2058
|
+
sticks.clear();
|
|
2059
|
+
pointer = null;
|
|
2060
|
+
}
|
|
2061
|
+
};
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
// src/input/index.ts
|
|
2065
|
+
var DEADZONE = 0.15;
|
|
2066
|
+
function createInput(options) {
|
|
2067
|
+
const scope = options.scope;
|
|
2068
|
+
let declared = {};
|
|
2069
|
+
let layouts = {};
|
|
2070
|
+
const store = createInputStore({ onError: options.onError });
|
|
2071
|
+
let mapping = Object.freeze({});
|
|
2072
|
+
let fromHost = {};
|
|
2073
|
+
let hostScheme = "keyboard";
|
|
2074
|
+
let scheme = "keyboard";
|
|
2075
|
+
let available = false;
|
|
2076
|
+
let sticks = "floating";
|
|
2077
|
+
let suspended = false;
|
|
2078
|
+
let polling = false;
|
|
2079
|
+
let announced = { scheme };
|
|
2080
|
+
let asked = null;
|
|
2081
|
+
let showing = false;
|
|
2082
|
+
let model = null;
|
|
2083
|
+
const listeners = createListeners(options.onError);
|
|
2084
|
+
let declaring = false;
|
|
2085
|
+
const reported = /* @__PURE__ */ new Set();
|
|
2086
|
+
function undeclared(what, name) {
|
|
2087
|
+
const known2 = what === "action" ? name in declared || name in fromHost : name in layouts;
|
|
2088
|
+
if (!declaring || known2 || reported.has(`${what}:${name}`)) return;
|
|
2089
|
+
reported.add(`${what}:${name}`);
|
|
2090
|
+
options.onError(new Error(`input: the manifest declares no ${what} "${name}".`));
|
|
2091
|
+
}
|
|
2092
|
+
function shim() {
|
|
2093
|
+
if (scope === null) return null;
|
|
2094
|
+
const found = scope[INPUT_SHIM];
|
|
2095
|
+
return isInputShim(found) ? found : null;
|
|
2096
|
+
}
|
|
2097
|
+
function ask(run, fallback) {
|
|
2098
|
+
const installed = shim();
|
|
2099
|
+
if (installed === null) return fallback;
|
|
2100
|
+
try {
|
|
2101
|
+
const answer = run(installed);
|
|
2102
|
+
return answer === void 0 ? fallback : answer;
|
|
2103
|
+
} catch (thrown) {
|
|
2104
|
+
options.onError(thrown);
|
|
2105
|
+
return fallback;
|
|
2106
|
+
}
|
|
2107
|
+
}
|
|
2108
|
+
function deadzone() {
|
|
2109
|
+
const asked2 = ask((one) => one.deadzone, DEADZONE);
|
|
2110
|
+
return typeof asked2 === "number" && asked2 >= 0 && asked2 < 1 ? asked2 : DEADZONE;
|
|
2111
|
+
}
|
|
2112
|
+
function rebuild() {
|
|
2113
|
+
const next = {};
|
|
2114
|
+
let bindingsMoved = false;
|
|
2115
|
+
for (const [id, action] of Object.entries(declared)) {
|
|
2116
|
+
const host2 = fromHost[id];
|
|
2117
|
+
const bind = host2?.bind ?? action.bind;
|
|
2118
|
+
const entry = { bind };
|
|
2119
|
+
const title = host2?.title ?? action.title;
|
|
2120
|
+
if (typeof title === "string") entry.title = title;
|
|
2121
|
+
const label = labelOf2(id, bind, host2);
|
|
2122
|
+
if (label !== "") entry.label = label;
|
|
2123
|
+
next[id] = entry;
|
|
2124
|
+
if (!sameBind(mapping[id]?.bind, bind)) bindingsMoved = true;
|
|
2125
|
+
}
|
|
2126
|
+
for (const [id, host2] of Object.entries(fromHost)) {
|
|
2127
|
+
if (next[id] !== void 0) continue;
|
|
2128
|
+
next[id] = host2;
|
|
2129
|
+
bindingsMoved = true;
|
|
2130
|
+
}
|
|
2131
|
+
for (const id of Object.keys(mapping)) {
|
|
2132
|
+
if (next[id] === void 0) bindingsMoved = true;
|
|
2133
|
+
}
|
|
2134
|
+
const previous = mapping;
|
|
2135
|
+
mapping = Object.freeze(next);
|
|
2136
|
+
store.setMapping(next, { release: bindingsMoved });
|
|
2137
|
+
const was = announced;
|
|
2138
|
+
const moved = was.scheme !== scheme || !sameMapping(previous, mapping);
|
|
2139
|
+
announced = { scheme };
|
|
2140
|
+
if (moved) {
|
|
2141
|
+
listeners.emit(Object.freeze({ mapping, scheme }), Object.freeze({ mapping: previous, scheme: was.scheme }));
|
|
2142
|
+
}
|
|
2143
|
+
}
|
|
2144
|
+
function labelOf2(id, bind, host2) {
|
|
2145
|
+
if (host2?.label !== void 0 && hostScheme === scheme) return host2.label;
|
|
2146
|
+
const preferred = preferredBinding(bind, scheme);
|
|
2147
|
+
const worked = preferred === void 0 ? "" : labelFor(preferred, scheme);
|
|
2148
|
+
if (worked !== "") return worked;
|
|
2149
|
+
const own = declared[id]?.label;
|
|
2150
|
+
return typeof own === "string" ? own : "";
|
|
2151
|
+
}
|
|
2152
|
+
function setScheme(next) {
|
|
2153
|
+
if (next === scheme) return;
|
|
2154
|
+
scheme = next;
|
|
2155
|
+
rebuild();
|
|
2156
|
+
}
|
|
2157
|
+
const report = {
|
|
2158
|
+
press(control, index, down) {
|
|
2159
|
+
const slot = model?.zones.flatMap((zone) => zone.controls).find((one) => one.id === control);
|
|
2160
|
+
const button = slot?.buttons[index];
|
|
2161
|
+
if (button === void 0) return;
|
|
2162
|
+
setScheme("touch");
|
|
2163
|
+
if (suspended) return;
|
|
2164
|
+
const owner = `${INPUT_SOURCE}${control}.${index}`;
|
|
2165
|
+
store.pressAction(button.action, owner, down);
|
|
2166
|
+
if (button.as !== void 0) store.press(button.as, down, `${owner}.as`);
|
|
2167
|
+
ask((one) => one.haptic?.(down ? "press" : "release"), void 0);
|
|
2168
|
+
},
|
|
2169
|
+
move(control, x, y) {
|
|
2170
|
+
const slot = model?.zones.flatMap((zone) => zone.controls).find((one) => one.id === control);
|
|
2171
|
+
if (slot === void 0) return;
|
|
2172
|
+
setScheme("touch");
|
|
2173
|
+
if (suspended) return;
|
|
2174
|
+
if (slot.axis === "x") store.moveStick(slot.stick, x, 0);
|
|
2175
|
+
else if (slot.axis === "y") store.moveStick(slot.stick, 0, y);
|
|
2176
|
+
else store.moveStick(slot.stick, x, y);
|
|
2177
|
+
}
|
|
2178
|
+
};
|
|
2179
|
+
const host = scope === null ? null : createControlsHost({ scope, report, shim, onError: options.onError });
|
|
2180
|
+
const sources = scope === null ? null : createSources({
|
|
2181
|
+
scope,
|
|
2182
|
+
store,
|
|
2183
|
+
onScheme: setScheme,
|
|
2184
|
+
isBound,
|
|
2185
|
+
suspended: () => suspended,
|
|
2186
|
+
deadzone,
|
|
2187
|
+
fromControls: (event) => host?.owns(event) ?? false,
|
|
2188
|
+
onError: options.onError
|
|
2189
|
+
});
|
|
2190
|
+
function isBound(concrete) {
|
|
2191
|
+
for (const binding of Object.values(mapping)) {
|
|
2192
|
+
if (binding.bind.some((declaredOne) => matchesBinding(declaredOne, concrete))) return true;
|
|
2193
|
+
}
|
|
2194
|
+
return false;
|
|
2195
|
+
}
|
|
2196
|
+
function retune() {
|
|
2197
|
+
sources?.watchPointer(polling || store.watchingPointer);
|
|
2198
|
+
}
|
|
2199
|
+
function only() {
|
|
2200
|
+
const names = Object.keys(layouts);
|
|
2201
|
+
return names.length === 1 ? names[0] : null;
|
|
2202
|
+
}
|
|
2203
|
+
function refresh() {
|
|
2204
|
+
const frame = options.frame();
|
|
2205
|
+
const insets = options.insets();
|
|
2206
|
+
const name = asked;
|
|
2207
|
+
const wanted = name === null ? void 0 : layouts[name];
|
|
2208
|
+
const permitted = host !== null && name !== null && wanted !== void 0 && available && ask((one) => one.decide?.({ layout: name, frame, insets, contract: INPUT_CONTRACT }), true) !== false;
|
|
2209
|
+
if (!permitted || host === null || name === null || wanted === void 0) {
|
|
2210
|
+
withdraw();
|
|
2211
|
+
return;
|
|
2212
|
+
}
|
|
2213
|
+
model = resolveLayout(name, wanted, declared, frame, insets, sticks);
|
|
2214
|
+
host.render(model);
|
|
2215
|
+
if (!host.fits(frame, insets)) {
|
|
2216
|
+
withdraw();
|
|
2217
|
+
return;
|
|
2218
|
+
}
|
|
2219
|
+
showing = true;
|
|
2220
|
+
options.setDrawn(host.rects());
|
|
2221
|
+
}
|
|
2222
|
+
function withdraw() {
|
|
2223
|
+
if (!showing && model === null) return;
|
|
2224
|
+
host?.clear();
|
|
2225
|
+
model = null;
|
|
2226
|
+
showing = false;
|
|
2227
|
+
options.setDrawn([]);
|
|
2228
|
+
}
|
|
2229
|
+
function releaseControls() {
|
|
2230
|
+
store.releaseFrom((source) => source.startsWith(INPUT_SOURCE));
|
|
2231
|
+
for (const zone of model?.zones ?? []) {
|
|
2232
|
+
for (const slot of zone.controls) {
|
|
2233
|
+
if (slot.type === "thumbstick") store.moveStick(slot.stick, 0, 0);
|
|
2234
|
+
}
|
|
2235
|
+
}
|
|
2236
|
+
}
|
|
2237
|
+
const controls = {
|
|
2238
|
+
show(layout) {
|
|
2239
|
+
const name = layout ?? only();
|
|
2240
|
+
if (name === null) return;
|
|
2241
|
+
undeclared("layout", name);
|
|
2242
|
+
if (name !== asked) releaseControls();
|
|
2243
|
+
asked = name;
|
|
2244
|
+
refresh();
|
|
2245
|
+
},
|
|
2246
|
+
hide() {
|
|
2247
|
+
if (asked === null) return;
|
|
2248
|
+
releaseControls();
|
|
2249
|
+
asked = null;
|
|
2250
|
+
refresh();
|
|
2251
|
+
},
|
|
2252
|
+
get showing() {
|
|
2253
|
+
return showing;
|
|
2254
|
+
},
|
|
2255
|
+
get asked() {
|
|
2256
|
+
return asked;
|
|
2257
|
+
},
|
|
2258
|
+
get available() {
|
|
2259
|
+
return available;
|
|
2260
|
+
}
|
|
2261
|
+
};
|
|
2262
|
+
const input = {
|
|
2263
|
+
onAction(action, listener) {
|
|
2264
|
+
undeclared("action", action);
|
|
2265
|
+
return store.onAction(action, listener);
|
|
2266
|
+
},
|
|
2267
|
+
onStick: (stick2, listener) => store.onStick(stick2, listener),
|
|
2268
|
+
onPointer(listener) {
|
|
2269
|
+
const off = store.onPointer(listener);
|
|
2270
|
+
retune();
|
|
2271
|
+
return () => {
|
|
2272
|
+
off();
|
|
2273
|
+
retune();
|
|
2274
|
+
};
|
|
2275
|
+
},
|
|
2276
|
+
get mapping() {
|
|
2277
|
+
return mapping;
|
|
2278
|
+
},
|
|
2279
|
+
on: listeners.on,
|
|
2280
|
+
get scheme() {
|
|
2281
|
+
return scheme;
|
|
2282
|
+
},
|
|
2283
|
+
controls,
|
|
2284
|
+
get poll() {
|
|
2285
|
+
return polling;
|
|
2286
|
+
},
|
|
2287
|
+
set poll(on) {
|
|
2288
|
+
polling = on;
|
|
2289
|
+
retune();
|
|
2290
|
+
},
|
|
2291
|
+
read() {
|
|
2292
|
+
const now = store.read();
|
|
2293
|
+
return Object.freeze({
|
|
2294
|
+
pressed: (action) => (undeclared("action", action), now.pressed(action)),
|
|
2295
|
+
started: (action) => (undeclared("action", action), now.started(action)),
|
|
2296
|
+
ended: (action) => (undeclared("action", action), now.ended(action)),
|
|
2297
|
+
stick: (stick2) => now.stick(stick2),
|
|
2298
|
+
pointer: now.pointer
|
|
2299
|
+
});
|
|
2300
|
+
},
|
|
2301
|
+
suspend() {
|
|
2302
|
+
if (suspended) return;
|
|
2303
|
+
suspended = true;
|
|
2304
|
+
store.releaseAll();
|
|
2305
|
+
},
|
|
2306
|
+
resume() {
|
|
2307
|
+
suspended = false;
|
|
2308
|
+
},
|
|
2309
|
+
get suspended() {
|
|
2310
|
+
return suspended;
|
|
2311
|
+
}
|
|
2312
|
+
};
|
|
2313
|
+
rebuild();
|
|
2314
|
+
sources?.attach();
|
|
2315
|
+
return {
|
|
2316
|
+
input,
|
|
2317
|
+
adopt(value) {
|
|
2318
|
+
const welcome = readInputWelcome(value);
|
|
2319
|
+
if (welcome === null) return;
|
|
2320
|
+
declaring = true;
|
|
2321
|
+
declared = welcome.actions;
|
|
2322
|
+
layouts = welcome.layouts;
|
|
2323
|
+
fromHost = welcome.mapping;
|
|
2324
|
+
hostScheme = welcome.scheme;
|
|
2325
|
+
scheme = welcome.scheme;
|
|
2326
|
+
available = welcome.controls;
|
|
2327
|
+
sticks = welcome.sticks;
|
|
2328
|
+
rebuild();
|
|
2329
|
+
refresh();
|
|
2330
|
+
},
|
|
2331
|
+
onChange(value) {
|
|
2332
|
+
const change = readInputChange(value);
|
|
2333
|
+
if (change === null) return;
|
|
2334
|
+
fromHost = change.mapping;
|
|
2335
|
+
hostScheme = change.scheme;
|
|
2336
|
+
scheme = change.scheme;
|
|
2337
|
+
const was = { available, sticks };
|
|
2338
|
+
available = change.controls;
|
|
2339
|
+
sticks = change.sticks;
|
|
2340
|
+
rebuild();
|
|
2341
|
+
if (was.sticks !== sticks) releaseControls();
|
|
2342
|
+
if (was.available !== available || was.sticks !== sticks) refresh();
|
|
2343
|
+
},
|
|
2344
|
+
relayout() {
|
|
2345
|
+
refresh();
|
|
2346
|
+
},
|
|
2347
|
+
release: () => store.releaseAll(),
|
|
2348
|
+
dispose() {
|
|
2349
|
+
sources?.detach();
|
|
2350
|
+
host?.dispose();
|
|
2351
|
+
store.dispose();
|
|
2352
|
+
listeners.clear();
|
|
2353
|
+
}
|
|
2354
|
+
};
|
|
2355
|
+
}
|
|
2356
|
+
var INPUT_SOURCE = "controls.";
|
|
2357
|
+
function sameBind(a, b) {
|
|
2358
|
+
if (a === void 0) return false;
|
|
2359
|
+
return a.length === b.length && a.every((one, index) => one === b[index]);
|
|
2360
|
+
}
|
|
2361
|
+
function sameMapping(a, b) {
|
|
2362
|
+
const keys = Object.keys(a);
|
|
2363
|
+
if (keys.length !== Object.keys(b).length) return false;
|
|
2364
|
+
return keys.every((key) => {
|
|
2365
|
+
const one = a[key];
|
|
2366
|
+
const other = b[key];
|
|
2367
|
+
if (one === void 0 || other === void 0) return false;
|
|
2368
|
+
return one.title === other.title && one.label === other.label && sameBind(one.bind, other.bind);
|
|
2369
|
+
});
|
|
2370
|
+
}
|
|
2371
|
+
|
|
2372
|
+
// src/layout.ts
|
|
2373
|
+
var NONE = { top: 0, right: 0, bottom: 0, left: 0 };
|
|
2374
|
+
var SIDES2 = ["top", "right", "bottom", "left"];
|
|
2375
|
+
var NOWHERE = { width: 0, height: 0 };
|
|
2376
|
+
function createLayout(options) {
|
|
2377
|
+
let frame = NOWHERE;
|
|
2378
|
+
let hosted = [];
|
|
2379
|
+
let drawn = [];
|
|
2380
|
+
let obstructions = [];
|
|
2381
|
+
let projected = null;
|
|
2382
|
+
const listeners = createListeners(options.onError);
|
|
2383
|
+
function insets() {
|
|
2384
|
+
projected ??= insetsFrom(obstructions, frame);
|
|
2385
|
+
return projected;
|
|
2386
|
+
}
|
|
2387
|
+
function snapshot() {
|
|
2388
|
+
return { frame, obstructions, insets: insets() };
|
|
2389
|
+
}
|
|
2390
|
+
function publish() {
|
|
2391
|
+
if (options.root === null) return;
|
|
2392
|
+
const now = insets();
|
|
2393
|
+
for (const side of SIDES2) {
|
|
2394
|
+
options.root.style.setProperty(`--hopple-inset-${side}`, `${now[side]}px`);
|
|
2395
|
+
}
|
|
2396
|
+
}
|
|
2397
|
+
function settle(value) {
|
|
2398
|
+
const previous = snapshot();
|
|
2399
|
+
const message = readLayout(value);
|
|
2400
|
+
frame = message.frame;
|
|
2401
|
+
hosted = message.obstructions;
|
|
2402
|
+
recombine(previous);
|
|
2403
|
+
}
|
|
2404
|
+
function recombine(previous) {
|
|
2405
|
+
obstructions = drawn.length === 0 ? hosted : [...hosted, ...drawn];
|
|
2406
|
+
projected = null;
|
|
2407
|
+
publish();
|
|
2408
|
+
if (previous.frame.width === frame.width && previous.frame.height === frame.height && sameBoxes(previous.obstructions, obstructions)) {
|
|
2409
|
+
return;
|
|
2410
|
+
}
|
|
2411
|
+
const now = snapshot();
|
|
2412
|
+
listeners.emit(now, previous);
|
|
2413
|
+
}
|
|
2414
|
+
return {
|
|
2415
|
+
adopt: settle,
|
|
2416
|
+
onChange: settle,
|
|
2417
|
+
/**
|
|
2418
|
+
* What the frame has drawn over the game, as rectangles.
|
|
2419
|
+
*
|
|
2420
|
+
* Called by the SDK's own on-screen controls when they are shown, hidden or
|
|
2421
|
+
* re-laid-out. Listeners fire exactly as they do for a host's message,
|
|
2422
|
+
* because from the game's side there is no difference worth knowing: a
|
|
2423
|
+
* rectangle it must keep its buttons out of has appeared.
|
|
2424
|
+
*/
|
|
2425
|
+
setDrawn(rects) {
|
|
2426
|
+
const previous = snapshot();
|
|
2427
|
+
drawn = rects.map((rect) => ({ rect, kind: "controls", reserved: true }));
|
|
2428
|
+
recombine(previous);
|
|
2429
|
+
},
|
|
2430
|
+
dispose() {
|
|
2431
|
+
listeners.clear();
|
|
2432
|
+
},
|
|
2433
|
+
layout: {
|
|
2434
|
+
get frame() {
|
|
2435
|
+
return frame;
|
|
2436
|
+
},
|
|
2437
|
+
get insets() {
|
|
2438
|
+
return insets();
|
|
2439
|
+
},
|
|
2440
|
+
get obstructions() {
|
|
2441
|
+
return obstructions;
|
|
2442
|
+
},
|
|
2443
|
+
on: listeners.on
|
|
2444
|
+
}
|
|
2445
|
+
};
|
|
2446
|
+
}
|
|
2447
|
+
function insetsFrom(obstructions, frame, policy = {}) {
|
|
2448
|
+
const result = { ...NONE };
|
|
2449
|
+
if (frame.width <= 0 || frame.height <= 0) return result;
|
|
2450
|
+
const strips = policy.only === "strips";
|
|
2451
|
+
for (const obstruction of obstructions) {
|
|
2452
|
+
if (!obstruction.reserved) continue;
|
|
2453
|
+
const box = clip2(obstruction.rect, frame);
|
|
2454
|
+
if (box === null) continue;
|
|
2455
|
+
if (strips && !spans(box, frame)) continue;
|
|
2456
|
+
const candidates = [
|
|
2457
|
+
["top", box.y + box.height, frame.width],
|
|
2458
|
+
["bottom", frame.height - box.y, frame.width],
|
|
2459
|
+
["left", box.x + box.width, frame.height],
|
|
2460
|
+
["right", frame.width - box.x, frame.height]
|
|
2461
|
+
];
|
|
2462
|
+
let best = candidates[0];
|
|
2463
|
+
for (const candidate of candidates) {
|
|
2464
|
+
if (candidate[1] * candidate[2] < best[1] * best[2]) best = candidate;
|
|
2465
|
+
}
|
|
2466
|
+
result[best[0]] = Math.max(result[best[0]], best[1]);
|
|
2467
|
+
}
|
|
2468
|
+
return result;
|
|
2469
|
+
}
|
|
2470
|
+
var SLACK = 1;
|
|
2471
|
+
function spans(box, frame) {
|
|
2472
|
+
return box.x <= SLACK && box.x + box.width >= frame.width - SLACK || box.y <= SLACK && box.y + box.height >= frame.height - SLACK;
|
|
2473
|
+
}
|
|
2474
|
+
function clip2(rect, frame) {
|
|
2475
|
+
const x = Math.max(0, rect.x);
|
|
2476
|
+
const y = Math.max(0, rect.y);
|
|
2477
|
+
const right = Math.min(frame.width, rect.x + rect.width);
|
|
2478
|
+
const bottom = Math.min(frame.height, rect.y + rect.height);
|
|
2479
|
+
if (right <= x || bottom <= y) return null;
|
|
2480
|
+
return { x, y, width: right - x, height: bottom - y };
|
|
2481
|
+
}
|
|
2482
|
+
function sameBoxes(a, b) {
|
|
2483
|
+
if (a === b) return true;
|
|
2484
|
+
if (a.length !== b.length) return false;
|
|
2485
|
+
return a.every((one, index) => {
|
|
2486
|
+
const other = b[index];
|
|
2487
|
+
return other !== void 0 && one.kind === other.kind && one.reserved === other.reserved && one.rect.x === other.rect.x && one.rect.y === other.rect.y && one.rect.width === other.rect.width && one.rect.height === other.rect.height;
|
|
2488
|
+
});
|
|
2489
|
+
}
|
|
2490
|
+
function readLayout(value) {
|
|
2491
|
+
const message = value;
|
|
2492
|
+
if (typeof message !== "object" || message === null) return { frame: NOWHERE, obstructions: [] };
|
|
2493
|
+
const frame = isSize(message.frame) ? message.frame : NOWHERE;
|
|
2494
|
+
if (!Array.isArray(message.obstructions)) return { frame, obstructions: [] };
|
|
2495
|
+
const obstructions = [];
|
|
2496
|
+
for (const entry of message.obstructions) {
|
|
2497
|
+
if (typeof entry !== "object" || entry === null) continue;
|
|
2498
|
+
const { rect, kind, reserved } = entry;
|
|
2499
|
+
if (!isRect(rect) || typeof reserved !== "boolean") continue;
|
|
2500
|
+
obstructions.push({ rect, reserved, kind: kind ?? "chrome" });
|
|
2501
|
+
}
|
|
2502
|
+
return { frame, obstructions };
|
|
2503
|
+
}
|
|
2504
|
+
function isRect(value) {
|
|
2505
|
+
if (typeof value !== "object" || value === null) return false;
|
|
2506
|
+
const rect = value;
|
|
2507
|
+
return number3(rect.x) && number3(rect.y) && number3(rect.width) && number3(rect.height);
|
|
2508
|
+
}
|
|
2509
|
+
function isSize(value) {
|
|
2510
|
+
if (typeof value !== "object" || value === null) return false;
|
|
2511
|
+
const size = value;
|
|
2512
|
+
return number3(size.width) && number3(size.height);
|
|
2513
|
+
}
|
|
2514
|
+
function number3(value) {
|
|
2515
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
2516
|
+
}
|
|
2517
|
+
|
|
2518
|
+
// src/lifecycle.ts
|
|
2519
|
+
function createLifecycle(options) {
|
|
2520
|
+
let phase = "loading";
|
|
2521
|
+
let asked = false;
|
|
2522
|
+
const listeners = createListeners(options.onError);
|
|
2523
|
+
async function announce(next) {
|
|
2524
|
+
const previous = phase;
|
|
2525
|
+
phase = next;
|
|
2526
|
+
await listeners.settle(next, previous);
|
|
2527
|
+
}
|
|
2528
|
+
return {
|
|
2529
|
+
async onDispose() {
|
|
2530
|
+
if (phase !== "disposed") await announce("disposed");
|
|
2531
|
+
return {};
|
|
2532
|
+
},
|
|
2533
|
+
lifecycle: {
|
|
2534
|
+
get phase() {
|
|
2535
|
+
return phase;
|
|
2536
|
+
},
|
|
2537
|
+
ready() {
|
|
2538
|
+
if (phase !== "loading" || asked) return;
|
|
2539
|
+
asked = true;
|
|
2540
|
+
forget(
|
|
2541
|
+
() => options.peer().call("lifecycle.ready", {}),
|
|
2542
|
+
(thrown) => {
|
|
2543
|
+
asked = false;
|
|
2544
|
+
options.onError(thrown);
|
|
2545
|
+
},
|
|
2546
|
+
() => {
|
|
2547
|
+
if (phase !== "loading") return;
|
|
2548
|
+
void announce("ready");
|
|
2549
|
+
}
|
|
2550
|
+
);
|
|
2551
|
+
},
|
|
2552
|
+
on: listeners.on
|
|
2553
|
+
}
|
|
2554
|
+
};
|
|
2555
|
+
}
|
|
2556
|
+
|
|
2557
|
+
// src/loop.ts
|
|
2558
|
+
var MAX_STEP = 0.1;
|
|
2559
|
+
function createLoop(options) {
|
|
2560
|
+
let allowed = true;
|
|
2561
|
+
let ordered = false;
|
|
2562
|
+
let ended = false;
|
|
2563
|
+
const listeners = createListeners(options.onError);
|
|
2564
|
+
const ticks = /* @__PURE__ */ new Set();
|
|
2565
|
+
function running() {
|
|
2566
|
+
return allowed && !ended;
|
|
2567
|
+
}
|
|
2568
|
+
function start(ticker) {
|
|
2569
|
+
if (ticker.stopped || ticker.handle !== 0 || !running()) return;
|
|
2570
|
+
ticker.last = void 0;
|
|
2571
|
+
ticker.handle = options.frames.request((now) => step(ticker, now));
|
|
2572
|
+
}
|
|
2573
|
+
function stop(ticker) {
|
|
2574
|
+
if (ticker.handle === 0) return;
|
|
2575
|
+
options.frames.cancel(ticker.handle);
|
|
2576
|
+
ticker.handle = 0;
|
|
2577
|
+
}
|
|
2578
|
+
function step(ticker, now) {
|
|
2579
|
+
ticker.handle = 0;
|
|
2580
|
+
const dt = ticker.last === void 0 ? 0 : Math.min(Math.max(now - ticker.last, 0) / 1e3, MAX_STEP);
|
|
2581
|
+
ticker.last = now;
|
|
2582
|
+
try {
|
|
2583
|
+
ticker.frame(dt, now);
|
|
2584
|
+
} catch (thrown) {
|
|
2585
|
+
options.onError(thrown);
|
|
2586
|
+
}
|
|
2587
|
+
if (ticker.handle === 0 && !ticker.stopped && running()) {
|
|
2588
|
+
ticker.handle = options.frames.request((at) => step(ticker, at));
|
|
2589
|
+
}
|
|
2590
|
+
}
|
|
2591
|
+
function reschedule() {
|
|
2592
|
+
for (const ticker of ticks) {
|
|
2593
|
+
if (running()) start(ticker);
|
|
2594
|
+
else stop(ticker);
|
|
2595
|
+
}
|
|
2596
|
+
}
|
|
2597
|
+
return {
|
|
2598
|
+
adopt(initial) {
|
|
2599
|
+
if (!ordered) allowed = initial;
|
|
2600
|
+
reschedule();
|
|
2601
|
+
},
|
|
2602
|
+
async onAllow(params) {
|
|
2603
|
+
ordered = true;
|
|
2604
|
+
if (params.allowed !== allowed) {
|
|
2605
|
+
allowed = params.allowed;
|
|
2606
|
+
reschedule();
|
|
2607
|
+
await listeners.settle(allowed);
|
|
2608
|
+
}
|
|
2609
|
+
return { allowed };
|
|
2610
|
+
},
|
|
2611
|
+
dispose() {
|
|
2612
|
+
ended = true;
|
|
2613
|
+
for (const ticker of ticks) {
|
|
2614
|
+
ticker.stopped = true;
|
|
2615
|
+
stop(ticker);
|
|
2616
|
+
}
|
|
2617
|
+
ticks.clear();
|
|
2618
|
+
},
|
|
2619
|
+
loop: {
|
|
2620
|
+
get allowed() {
|
|
2621
|
+
return allowed;
|
|
2622
|
+
},
|
|
2623
|
+
on: listeners.on,
|
|
2624
|
+
tick(frame) {
|
|
2625
|
+
const ticker = { frame, handle: 0, last: void 0, stopped: false };
|
|
2626
|
+
ticks.add(ticker);
|
|
2627
|
+
start(ticker);
|
|
2628
|
+
return () => {
|
|
2629
|
+
ticker.stopped = true;
|
|
2630
|
+
stop(ticker);
|
|
2631
|
+
ticks.delete(ticker);
|
|
2632
|
+
};
|
|
2633
|
+
}
|
|
2634
|
+
}
|
|
2635
|
+
};
|
|
2636
|
+
}
|
|
2637
|
+
|
|
2638
|
+
// src/panel.ts
|
|
2639
|
+
var CLOSED = Object.freeze({ isOpen: false, page: null });
|
|
2640
|
+
function createPanel(options) {
|
|
2641
|
+
let pages = [];
|
|
2642
|
+
let state = CLOSED;
|
|
2643
|
+
const listeners = createListeners(options.onError);
|
|
2644
|
+
const panel = {
|
|
2645
|
+
get available() {
|
|
2646
|
+
return pages.length > 0;
|
|
2647
|
+
},
|
|
2648
|
+
get pages() {
|
|
2649
|
+
return pages;
|
|
2650
|
+
},
|
|
2651
|
+
get isOpen() {
|
|
2652
|
+
return state.isOpen;
|
|
2653
|
+
},
|
|
2654
|
+
get page() {
|
|
2655
|
+
return state.page;
|
|
2656
|
+
},
|
|
2657
|
+
on: listeners.on,
|
|
2658
|
+
open(page) {
|
|
2659
|
+
forget(() => options.peer().call("panel.open", { id: page }), options.onError);
|
|
2660
|
+
}
|
|
2661
|
+
};
|
|
2662
|
+
function settle(next) {
|
|
2663
|
+
if (next.isOpen === state.isOpen && next.page === state.page) return;
|
|
2664
|
+
const previous = state;
|
|
2665
|
+
state = Object.freeze({ isOpen: next.isOpen, page: next.page });
|
|
2666
|
+
listeners.emit(state, previous);
|
|
2667
|
+
}
|
|
2668
|
+
return {
|
|
2669
|
+
panel,
|
|
2670
|
+
adopt(value) {
|
|
2671
|
+
const welcome = readPanelWelcome(value);
|
|
2672
|
+
pages = (welcome?.ids ?? []).filter(known);
|
|
2673
|
+
state = Object.freeze({ isOpen: welcome?.isOpen === true, page: pageOf(welcome?.page) });
|
|
2674
|
+
},
|
|
2675
|
+
onChange(value) {
|
|
2676
|
+
const change = readPanelChange(value);
|
|
2677
|
+
if (change !== null) settle({ isOpen: change.isOpen, page: pageOf(change.page) });
|
|
2678
|
+
},
|
|
2679
|
+
dispose() {
|
|
2680
|
+
listeners.clear();
|
|
2681
|
+
}
|
|
2682
|
+
};
|
|
2683
|
+
}
|
|
2684
|
+
function known(page) {
|
|
2685
|
+
return PAGES.includes(page);
|
|
2686
|
+
}
|
|
2687
|
+
function pageOf(page) {
|
|
2688
|
+
return typeof page === "string" && known(page) ? page : null;
|
|
2689
|
+
}
|
|
2690
|
+
|
|
2691
|
+
// src/pause.ts
|
|
2692
|
+
function createPause(options) {
|
|
2693
|
+
let state = PAUSE_NONE;
|
|
2694
|
+
const listeners = createListeners(options.onError);
|
|
2695
|
+
async function announce(paused) {
|
|
2696
|
+
await listeners.settle(paused);
|
|
2697
|
+
}
|
|
2698
|
+
const pause = {
|
|
2699
|
+
get state() {
|
|
2700
|
+
return state;
|
|
2701
|
+
},
|
|
2702
|
+
set(next) {
|
|
2703
|
+
if (next === state) return;
|
|
2704
|
+
state = next;
|
|
2705
|
+
options.peer().emit("pause.change", { state });
|
|
2706
|
+
},
|
|
2707
|
+
on: listeners.on
|
|
2708
|
+
};
|
|
2709
|
+
return {
|
|
2710
|
+
pause,
|
|
2711
|
+
async onSet(params) {
|
|
2712
|
+
const request = readPauseRequest(params);
|
|
2713
|
+
if (request !== null) await announce(request.paused);
|
|
2714
|
+
return { state };
|
|
2715
|
+
}
|
|
2716
|
+
};
|
|
2717
|
+
}
|
|
2718
|
+
|
|
2719
|
+
// src/resources.ts
|
|
2720
|
+
function createResources(options) {
|
|
2721
|
+
let allowed = true;
|
|
2722
|
+
let ordered = false;
|
|
2723
|
+
const listeners = createListeners(options.onError);
|
|
2724
|
+
return {
|
|
2725
|
+
adopt(initial) {
|
|
2726
|
+
if (!ordered) allowed = initial;
|
|
2727
|
+
},
|
|
2728
|
+
async onAllow(params) {
|
|
2729
|
+
ordered = true;
|
|
2730
|
+
if (params.allowed !== allowed) {
|
|
2731
|
+
allowed = params.allowed;
|
|
2732
|
+
await listeners.settle(allowed);
|
|
2733
|
+
}
|
|
2734
|
+
return { allowed };
|
|
2735
|
+
},
|
|
2736
|
+
resources: {
|
|
2737
|
+
get allowed() {
|
|
2738
|
+
return allowed;
|
|
2739
|
+
},
|
|
2740
|
+
on: listeners.on
|
|
2741
|
+
}
|
|
2742
|
+
};
|
|
2743
|
+
}
|
|
2744
|
+
|
|
2745
|
+
// src/save.ts
|
|
2746
|
+
var FIRST_VERSION = 1;
|
|
2747
|
+
var LOSSY = [
|
|
2748
|
+
{ what: "a Map", is: (v) => v instanceof Map, becomes: "{}" },
|
|
2749
|
+
{ what: "a Set", is: (v) => v instanceof Set, becomes: "{}" },
|
|
2750
|
+
{ what: "a Date", is: (v) => v instanceof Date, becomes: "a string" },
|
|
2751
|
+
{
|
|
2752
|
+
what: "a typed array",
|
|
2753
|
+
is: (v) => ArrayBuffer.isView(v),
|
|
2754
|
+
becomes: "an object keyed by index"
|
|
2755
|
+
},
|
|
2756
|
+
{ what: "a function", is: (v) => typeof v === "function", becomes: "nothing at all" },
|
|
2757
|
+
{ what: "undefined", is: (v) => v === void 0, becomes: "nothing at all" }
|
|
2758
|
+
];
|
|
2759
|
+
var WALK_DEPTH = 6;
|
|
2760
|
+
function createSave(options) {
|
|
2761
|
+
let cap = SAVE_BYTES;
|
|
2762
|
+
let available = false;
|
|
2763
|
+
let status = UNKNOWN;
|
|
2764
|
+
let dev = false;
|
|
2765
|
+
const listeners = createListeners(options.onError);
|
|
2766
|
+
function settle(stored) {
|
|
2767
|
+
const previous = status;
|
|
2768
|
+
if (previous.known && sameStored(previous.stored, stored)) return;
|
|
2769
|
+
status = Object.freeze({ known: true, stored: stored === null ? null : Object.freeze({ ...stored }) });
|
|
2770
|
+
listeners.emit(status, previous);
|
|
2771
|
+
}
|
|
2772
|
+
let rev;
|
|
2773
|
+
function adopt(value, surface) {
|
|
2774
|
+
dev = surface === "dev";
|
|
2775
|
+
if (typeof value !== "object" || value === null) return;
|
|
2776
|
+
available = true;
|
|
2777
|
+
const said = value;
|
|
2778
|
+
if (typeof said.bytes === "number" && Number.isFinite(said.bytes)) {
|
|
2779
|
+
cap = Math.max(SAVE_BYTES, said.bytes);
|
|
2780
|
+
}
|
|
2781
|
+
const stored = said.stored === null ? null : readSaveStored(said.stored);
|
|
2782
|
+
if (said.stored === null || stored !== null) {
|
|
2783
|
+
status = Object.freeze({ known: true, stored: stored === null ? null : Object.freeze(stored) });
|
|
2784
|
+
}
|
|
2785
|
+
}
|
|
2786
|
+
function encode(state) {
|
|
2787
|
+
if (typeof state === "string") return [state, "text"];
|
|
2788
|
+
let text2;
|
|
2789
|
+
try {
|
|
2790
|
+
text2 = JSON.stringify(state);
|
|
2791
|
+
} catch (thrown) {
|
|
2792
|
+
throw new BridgeError({
|
|
2793
|
+
code: "bad_params",
|
|
2794
|
+
message: `That state cannot be saved: ${thrown instanceof Error ? thrown.message : String(thrown)}`
|
|
2795
|
+
});
|
|
2796
|
+
}
|
|
2797
|
+
if (text2 === void 0) {
|
|
2798
|
+
throw new BridgeError({
|
|
2799
|
+
code: "bad_params",
|
|
2800
|
+
message: "That state cannot be saved: it is not a JSON value."
|
|
2801
|
+
});
|
|
2802
|
+
}
|
|
2803
|
+
return [text2, "json"];
|
|
2804
|
+
}
|
|
2805
|
+
function warn(state) {
|
|
2806
|
+
for (const found of lossy(state, WALK_DEPTH, /* @__PURE__ */ new Set())) {
|
|
2807
|
+
options.onError(
|
|
2808
|
+
new Error(`save: ${found.at} is ${found.what}, which JSON turns into ${found.becomes}.`)
|
|
2809
|
+
);
|
|
2810
|
+
}
|
|
2811
|
+
}
|
|
2812
|
+
return {
|
|
2813
|
+
adopt,
|
|
2814
|
+
save: {
|
|
2815
|
+
get available() {
|
|
2816
|
+
return available;
|
|
2817
|
+
},
|
|
2818
|
+
get status() {
|
|
2819
|
+
return status;
|
|
2820
|
+
},
|
|
2821
|
+
on: listeners.on,
|
|
2822
|
+
get maxBytes() {
|
|
2823
|
+
return cap;
|
|
2824
|
+
},
|
|
2825
|
+
async read() {
|
|
2826
|
+
const answer = await options.peer().call("save.read", {});
|
|
2827
|
+
const record = readSaveRecord(answer?.record);
|
|
2828
|
+
if (record === null) {
|
|
2829
|
+
settle(null);
|
|
2830
|
+
return null;
|
|
2831
|
+
}
|
|
2832
|
+
rev = record.rev;
|
|
2833
|
+
settle(
|
|
2834
|
+
readSaveStored(answer?.stored) ?? {
|
|
2835
|
+
at: status.known && status.stored !== null ? status.stored.at : Date.now(),
|
|
2836
|
+
version: record.version,
|
|
2837
|
+
bytes: saveBytes(record.state)
|
|
2838
|
+
}
|
|
2839
|
+
);
|
|
2840
|
+
if (record.encoding === "text") return { version: record.version, state: record.state };
|
|
2841
|
+
try {
|
|
2842
|
+
return { version: record.version, state: JSON.parse(record.state) };
|
|
2843
|
+
} catch (thrown) {
|
|
2844
|
+
options.onError(
|
|
2845
|
+
new Error(
|
|
2846
|
+
`save: the stored state could not be parsed (${thrown instanceof Error ? thrown.message : String(thrown)}).`
|
|
2847
|
+
)
|
|
2848
|
+
);
|
|
2849
|
+
return null;
|
|
2850
|
+
}
|
|
2851
|
+
},
|
|
2852
|
+
async write(state, given) {
|
|
2853
|
+
const [text2, encoding] = encode(state);
|
|
2854
|
+
if (dev) warn(state);
|
|
2855
|
+
const size = saveBytes(text2);
|
|
2856
|
+
if (size > cap) {
|
|
2857
|
+
throw new BridgeError({
|
|
2858
|
+
code: "too_large",
|
|
2859
|
+
message: `That save is ${size} bytes and this host takes ${cap}.`
|
|
2860
|
+
});
|
|
2861
|
+
}
|
|
2862
|
+
const record = {
|
|
2863
|
+
version: given?.version ?? FIRST_VERSION,
|
|
2864
|
+
encoding,
|
|
2865
|
+
state: text2
|
|
2866
|
+
};
|
|
2867
|
+
if (rev !== void 0) record.rev = rev;
|
|
2868
|
+
const answer = await options.peer().call("save.write", record);
|
|
2869
|
+
const minted = answer?.rev;
|
|
2870
|
+
rev = typeof minted === "string" ? minted : void 0;
|
|
2871
|
+
settle({ at: Date.now(), version: record.version, bytes: size });
|
|
2872
|
+
},
|
|
2873
|
+
async clear() {
|
|
2874
|
+
await options.peer().call("save.clear", {});
|
|
2875
|
+
rev = void 0;
|
|
2876
|
+
settle(null);
|
|
2877
|
+
}
|
|
2878
|
+
}
|
|
2879
|
+
};
|
|
2880
|
+
}
|
|
2881
|
+
var UNKNOWN = Object.freeze({ known: false });
|
|
2882
|
+
function sameStored(a, b) {
|
|
2883
|
+
if (a === null || b === null) return a === b;
|
|
2884
|
+
return a.at === b.at && a.version === b.version && a.bytes === b.bytes;
|
|
2885
|
+
}
|
|
2886
|
+
function lossy(value, depth, seen, at = "the state") {
|
|
2887
|
+
for (const kind of LOSSY) {
|
|
2888
|
+
if (kind.is(value)) return [{ at, what: kind.what, becomes: kind.becomes }];
|
|
2889
|
+
}
|
|
2890
|
+
if (depth <= 0 || typeof value !== "object" || value === null) return [];
|
|
2891
|
+
if (seen.has(value)) return [];
|
|
2892
|
+
seen.add(value);
|
|
2893
|
+
const found = [];
|
|
2894
|
+
if (Array.isArray(value)) {
|
|
2895
|
+
value.forEach((entry, index) => {
|
|
2896
|
+
found.push(...lossy(entry, depth - 1, seen, `${at}[${index}]`));
|
|
2897
|
+
});
|
|
2898
|
+
return found;
|
|
2899
|
+
}
|
|
2900
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
2901
|
+
found.push(...lossy(entry, depth - 1, seen, `${at}.${key}`));
|
|
2902
|
+
}
|
|
2903
|
+
return found;
|
|
2904
|
+
}
|
|
2905
|
+
|
|
2906
|
+
// src/score.ts
|
|
2907
|
+
function createScore(options) {
|
|
2908
|
+
let available = false;
|
|
2909
|
+
let boards = [];
|
|
2910
|
+
let held;
|
|
2911
|
+
let standing = UNKNOWN2;
|
|
2912
|
+
const listeners = createListeners(options.onError);
|
|
2913
|
+
function settle(next) {
|
|
2914
|
+
held = next;
|
|
2915
|
+
const previous = standing;
|
|
2916
|
+
standing = standingsOf(boards, held);
|
|
2917
|
+
if (!sameStandings(previous, standing)) listeners.emit(standing, previous);
|
|
2918
|
+
}
|
|
2919
|
+
function only(given) {
|
|
2920
|
+
if (given !== void 0) return given;
|
|
2921
|
+
return boards.length === 1 ? boards[0] : "";
|
|
2922
|
+
}
|
|
2923
|
+
const score = {
|
|
2924
|
+
get available() {
|
|
2925
|
+
return available;
|
|
2926
|
+
},
|
|
2927
|
+
get boards() {
|
|
2928
|
+
return boards;
|
|
2929
|
+
},
|
|
2930
|
+
get standing() {
|
|
2931
|
+
return standing;
|
|
2932
|
+
},
|
|
2933
|
+
on: listeners.on,
|
|
2934
|
+
async submit(value, given) {
|
|
2935
|
+
if (!isScoreValue(value)) {
|
|
2936
|
+
throw new BridgeError({
|
|
2937
|
+
code: "bad_params",
|
|
2938
|
+
message: "A score must be a finite number that a board can hold."
|
|
2939
|
+
});
|
|
2940
|
+
}
|
|
2941
|
+
const board = given?.board;
|
|
2942
|
+
const answer = await options.peer().call("score.submit", board === void 0 ? { value } : { board, value });
|
|
2943
|
+
const result = readScoreResult(answer) ?? { improved: false, best: value };
|
|
2944
|
+
const at = only(board);
|
|
2945
|
+
if (at !== "" && held !== void 0) {
|
|
2946
|
+
const before = held ?? {};
|
|
2947
|
+
const previous = before[at];
|
|
2948
|
+
settle({
|
|
2949
|
+
...before,
|
|
2950
|
+
[at]: {
|
|
2951
|
+
best: result.best,
|
|
2952
|
+
at: result.improved || previous === void 0 ? Date.now() : previous.at,
|
|
2953
|
+
runs: (previous?.runs ?? 0) + 1,
|
|
2954
|
+
...result.rank === void 0 ? {} : { rank: result.rank },
|
|
2955
|
+
...result.of === void 0 ? {} : { of: result.of }
|
|
2956
|
+
}
|
|
2957
|
+
});
|
|
2958
|
+
}
|
|
2959
|
+
return result;
|
|
2960
|
+
}
|
|
2961
|
+
};
|
|
2962
|
+
return {
|
|
2963
|
+
score,
|
|
2964
|
+
adopt(value) {
|
|
2965
|
+
const welcome = readScoreWelcome(value);
|
|
2966
|
+
available = welcome !== null;
|
|
2967
|
+
boards = welcome?.boards ?? [];
|
|
2968
|
+
held = welcome?.standing;
|
|
2969
|
+
standing = standingsOf(boards, held);
|
|
2970
|
+
},
|
|
2971
|
+
onChange(value) {
|
|
2972
|
+
const change = readScoreChange(value);
|
|
2973
|
+
if (change !== null) settle(change.standing);
|
|
2974
|
+
}
|
|
2975
|
+
};
|
|
2976
|
+
}
|
|
2977
|
+
var UNKNOWN2 = Object.freeze({ known: false });
|
|
2978
|
+
function standingsOf(boards, held) {
|
|
2979
|
+
if (held === void 0) return UNKNOWN2;
|
|
2980
|
+
const all = {};
|
|
2981
|
+
for (const board of boards) all[board] = held?.[board] ?? null;
|
|
2982
|
+
for (const [board, entry] of Object.entries(held ?? {})) all[board] = entry;
|
|
2983
|
+
return Object.freeze({ known: true, boards: Object.freeze(all) });
|
|
2984
|
+
}
|
|
2985
|
+
function sameStandings(a, b) {
|
|
2986
|
+
if (!a.known || !b.known) return a.known === b.known;
|
|
2987
|
+
const keys = Object.keys(a.boards);
|
|
2988
|
+
if (keys.length !== Object.keys(b.boards).length) return false;
|
|
2989
|
+
return keys.every((key) => {
|
|
2990
|
+
const one = a.boards[key];
|
|
2991
|
+
const other = b.boards[key];
|
|
2992
|
+
if (one === null || one === void 0 || other === null || other === void 0) return one === other;
|
|
2993
|
+
return one.best === other.best && one.at === other.at && one.runs === other.runs && one.rank === other.rank && one.of === other.of;
|
|
2994
|
+
});
|
|
2995
|
+
}
|
|
2996
|
+
|
|
2997
|
+
// src/share.ts
|
|
2998
|
+
function createShare(options) {
|
|
2999
|
+
let arrived = null;
|
|
3000
|
+
let available = false;
|
|
3001
|
+
const share = {
|
|
3002
|
+
get available() {
|
|
3003
|
+
return available;
|
|
3004
|
+
},
|
|
3005
|
+
get data() {
|
|
3006
|
+
return arrived;
|
|
3007
|
+
},
|
|
3008
|
+
offer(given) {
|
|
3009
|
+
const data = given?.data;
|
|
3010
|
+
const meta = given?.meta;
|
|
3011
|
+
if (data !== void 0 && typeof data !== "string") {
|
|
3012
|
+
options.onError(
|
|
3013
|
+
new BridgeError({
|
|
3014
|
+
code: "bad_params",
|
|
3015
|
+
message: "Share data must be a string: encode it however this game reads it back."
|
|
3016
|
+
})
|
|
3017
|
+
);
|
|
3018
|
+
return;
|
|
3019
|
+
}
|
|
3020
|
+
if (meta !== void 0 && readShareMeta(meta) === null) {
|
|
3021
|
+
options.onError(new BridgeError({ code: "bad_params", message: "Share meta must be an object JSON can carry." }));
|
|
3022
|
+
return;
|
|
3023
|
+
}
|
|
3024
|
+
forget(
|
|
3025
|
+
() => options.peer().call("share.offer", { ...data === void 0 ? {} : { data }, ...meta === void 0 ? {} : { meta } }),
|
|
3026
|
+
options.onError
|
|
3027
|
+
);
|
|
3028
|
+
}
|
|
3029
|
+
};
|
|
3030
|
+
return {
|
|
3031
|
+
share,
|
|
3032
|
+
adopt(value) {
|
|
3033
|
+
const welcome = readShareWelcome(value);
|
|
3034
|
+
arrived = welcome?.data ?? null;
|
|
3035
|
+
available = welcome?.offers === true;
|
|
3036
|
+
}
|
|
3037
|
+
};
|
|
3038
|
+
}
|
|
3039
|
+
|
|
3040
|
+
// src/shim.ts
|
|
3041
|
+
function isShim(value) {
|
|
3042
|
+
if (typeof value !== "object" || value === null) return false;
|
|
3043
|
+
const shim = value;
|
|
3044
|
+
if (shim.v !== 1) return false;
|
|
3045
|
+
return typeof shim.send === "function" && typeof shim.receive === "function";
|
|
3046
|
+
}
|
|
3047
|
+
function findShim(scope) {
|
|
3048
|
+
const found = scope[SHIM];
|
|
3049
|
+
return isShim(found) ? found : null;
|
|
3050
|
+
}
|
|
3051
|
+
function shimTransport(shim) {
|
|
3052
|
+
let unsubscribe = null;
|
|
3053
|
+
return {
|
|
3054
|
+
send(message) {
|
|
3055
|
+
shim.send(JSON.stringify(message));
|
|
3056
|
+
},
|
|
3057
|
+
receive(handler) {
|
|
3058
|
+
unsubscribe = shim.receive((text2) => {
|
|
3059
|
+
let parsed;
|
|
3060
|
+
try {
|
|
3061
|
+
parsed = JSON.parse(text2);
|
|
3062
|
+
} catch {
|
|
3063
|
+
return;
|
|
3064
|
+
}
|
|
3065
|
+
if (isEnvelope(parsed)) handler(parsed);
|
|
3066
|
+
});
|
|
3067
|
+
return () => {
|
|
3068
|
+
unsubscribe?.();
|
|
3069
|
+
unsubscribe = null;
|
|
3070
|
+
};
|
|
3071
|
+
},
|
|
3072
|
+
close() {
|
|
3073
|
+
}
|
|
3074
|
+
};
|
|
3075
|
+
}
|
|
3076
|
+
|
|
3077
|
+
// src/transport.ts
|
|
3078
|
+
var SHIM_WAIT_MS = 2e3;
|
|
3079
|
+
function hostTransport(options = {}) {
|
|
3080
|
+
const scope = options.scope ?? window;
|
|
3081
|
+
const globals = scope;
|
|
3082
|
+
const onError = options.onError ?? ((error) => console.error(error));
|
|
3083
|
+
const installed = findShim(scope);
|
|
3084
|
+
if (installed) return Promise.resolve(shimTransport(installed));
|
|
3085
|
+
if (scope.parent !== scope) return Promise.resolve(frameTransport(scope, scope.parent));
|
|
3086
|
+
const waitMs = options.shimWaitMs ?? SHIM_WAIT_MS;
|
|
3087
|
+
return new Promise((resolve, reject) => {
|
|
3088
|
+
let claimed = false;
|
|
3089
|
+
const door = (late) => {
|
|
3090
|
+
if (!isShim(late)) {
|
|
3091
|
+
onError(new Error(`${SET_SHIM} was called with something that is not a v1 shim.`));
|
|
3092
|
+
return;
|
|
3093
|
+
}
|
|
3094
|
+
if (claimed) {
|
|
3095
|
+
onError(new Error(`${SET_SHIM} was called twice; this game already has a channel.`));
|
|
3096
|
+
return;
|
|
3097
|
+
}
|
|
3098
|
+
claimed = true;
|
|
3099
|
+
clearTimeout(deadline);
|
|
3100
|
+
release();
|
|
3101
|
+
resolve(shimTransport(late));
|
|
3102
|
+
};
|
|
3103
|
+
const deadline = setTimeout(() => {
|
|
3104
|
+
release();
|
|
3105
|
+
reject(
|
|
3106
|
+
new Error(
|
|
3107
|
+
`No host: there is no ${SHIM} on the global and no parent frame to talk to. A Hopple game runs inside a Hopple host rather than on its own: while developing, run it with \`npx hopple dev\`. A native shell installs a shim before the game's bundle runs, or calls ${SET_SHIM} afterwards; neither happened within ${waitMs}ms.`
|
|
3108
|
+
)
|
|
3109
|
+
);
|
|
3110
|
+
}, waitMs);
|
|
3111
|
+
globals[SET_SHIM] = door;
|
|
3112
|
+
function release() {
|
|
3113
|
+
if (globals[SET_SHIM] === door) delete globals[SET_SHIM];
|
|
3114
|
+
}
|
|
3115
|
+
});
|
|
3116
|
+
}
|
|
3117
|
+
function frameTransport(scope, host) {
|
|
3118
|
+
return {
|
|
3119
|
+
send(message) {
|
|
3120
|
+
host.postMessage(message, "*");
|
|
3121
|
+
},
|
|
3122
|
+
receive(handler) {
|
|
3123
|
+
const listener = (event) => {
|
|
3124
|
+
if (event.source !== host) return;
|
|
3125
|
+
if (!isEnvelope(event.data)) return;
|
|
3126
|
+
handler(event.data);
|
|
3127
|
+
};
|
|
3128
|
+
scope.addEventListener("message", listener);
|
|
3129
|
+
return () => scope.removeEventListener("message", listener);
|
|
3130
|
+
},
|
|
3131
|
+
close() {
|
|
3132
|
+
}
|
|
3133
|
+
};
|
|
3134
|
+
}
|
|
3135
|
+
|
|
3136
|
+
// src/init.ts
|
|
3137
|
+
async function init(options = {}) {
|
|
3138
|
+
const onError = options.onError ?? ((error) => console.error(error));
|
|
3139
|
+
const errors = createErrorReporter(listenable(options.scope));
|
|
3140
|
+
const fail = (error) => {
|
|
3141
|
+
errors.report("sdk", error);
|
|
3142
|
+
onError(error);
|
|
3143
|
+
};
|
|
3144
|
+
let transport;
|
|
3145
|
+
try {
|
|
3146
|
+
transport = options.transport ?? await hostTransport({
|
|
3147
|
+
scope: options.scope,
|
|
3148
|
+
shimWaitMs: options.shimWaitMs,
|
|
3149
|
+
onError: fail
|
|
3150
|
+
});
|
|
3151
|
+
} catch (thrown) {
|
|
3152
|
+
errors.dispose();
|
|
3153
|
+
throw thrown;
|
|
3154
|
+
}
|
|
3155
|
+
const page = readable(options.scope);
|
|
3156
|
+
const calls = () => asked;
|
|
3157
|
+
const { lifecycle, onDispose } = createLifecycle({ peer: calls, onError: fail });
|
|
3158
|
+
const loop = createLoop({ frames: framesOf(page), onError: fail });
|
|
3159
|
+
const resources = createResources({ onError: fail });
|
|
3160
|
+
const layout = createLayout({ root: page?.document.documentElement ?? null, onError: fail });
|
|
3161
|
+
const analytics = createAnalytics({ peer: calls, onError: fail, onRefused: onError });
|
|
3162
|
+
const save = createSave({ peer: calls, onError: fail });
|
|
3163
|
+
const score = createScore({ peer: calls, onError: fail });
|
|
3164
|
+
const achievements = createAchievements({ peer: calls, onError: fail });
|
|
3165
|
+
const share = createShare({ peer: calls, onError: fail });
|
|
3166
|
+
const audio = createAudio({ scope: page, onError: fail });
|
|
3167
|
+
const panel = createPanel({ peer: calls, onError: fail });
|
|
3168
|
+
const pause = createPause({ peer: calls, onError: fail });
|
|
3169
|
+
const hostInsets = () => insetsFrom(
|
|
3170
|
+
layout.layout.obstructions.filter((one) => one.kind !== "controls"),
|
|
3171
|
+
layout.layout.frame
|
|
3172
|
+
);
|
|
3173
|
+
const input = createInput({
|
|
3174
|
+
scope: page,
|
|
3175
|
+
frame: () => layout.layout.frame,
|
|
3176
|
+
insets: hostInsets,
|
|
3177
|
+
setDrawn: layout.setDrawn,
|
|
3178
|
+
onError: fail
|
|
3179
|
+
});
|
|
3180
|
+
let ground = {
|
|
3181
|
+
frame: layout.layout.frame,
|
|
3182
|
+
insets: hostInsets()
|
|
3183
|
+
};
|
|
3184
|
+
layout.layout.on(() => {
|
|
3185
|
+
const now = { frame: layout.layout.frame, insets: hostInsets() };
|
|
3186
|
+
if (now.frame.width === ground.frame.width && now.frame.height === ground.frame.height && SIDES3.every((side) => now.insets[side] === ground.insets[side])) {
|
|
3187
|
+
return;
|
|
3188
|
+
}
|
|
3189
|
+
ground = now;
|
|
3190
|
+
input.relayout();
|
|
3191
|
+
});
|
|
3192
|
+
const peer = createPeer({
|
|
3193
|
+
transport,
|
|
3194
|
+
handlers: {
|
|
3195
|
+
// The host pings us for the same reason we ping it, and for one more: a
|
|
3196
|
+
// game whose main loop has wedged still has a live frame, and this is the
|
|
3197
|
+
// only way to tell those apart from outside.
|
|
3198
|
+
"system.ping": (params) => ({ nonce: params.nonce }),
|
|
3199
|
+
// Each order is answered once it has been obeyed: the sound has gone
|
|
3200
|
+
// quiet, the frames have stopped, the memory has been let go.
|
|
3201
|
+
"audio.allow": audio.onAllow,
|
|
3202
|
+
"loop.allow": loop.onAllow,
|
|
3203
|
+
"resources.allow": resources.onAllow,
|
|
3204
|
+
// No more frames once the game is going away, whatever the host has
|
|
3205
|
+
// allowed: the last thing drawn stays while the game saves.
|
|
3206
|
+
"lifecycle.dispose": () => {
|
|
3207
|
+
loop.dispose();
|
|
3208
|
+
return onDispose();
|
|
3209
|
+
},
|
|
3210
|
+
// Answered as soon as every listener has been told, with the mix the
|
|
3211
|
+
// game now holds: a volume is applied, never negotiated or waited on.
|
|
3212
|
+
"audio.set": audio.onSet,
|
|
3213
|
+
// Answered once every listener has settled: the host asked the game to
|
|
3214
|
+
// stop and waits — briefly — to be told what actually happened, which
|
|
3215
|
+
// may be that there was nothing to stop.
|
|
3216
|
+
"pause.set": pause.onSet
|
|
3217
|
+
},
|
|
3218
|
+
timeoutMs: options.timeoutMs,
|
|
3219
|
+
onError: fail
|
|
3220
|
+
});
|
|
3221
|
+
const asked = answering(peer);
|
|
3222
|
+
const helloMs = Math.max(options.timeoutMs ?? HELLO_MS, HELLO_MS);
|
|
3223
|
+
let welcome;
|
|
3224
|
+
try {
|
|
3225
|
+
welcome = await peer.call(
|
|
3226
|
+
"system.hello",
|
|
3227
|
+
{ protocol: PROTOCOL_VERSION, sdk: package_default.version },
|
|
3228
|
+
{ timeoutMs: helloMs }
|
|
3229
|
+
);
|
|
3230
|
+
} catch (thrown) {
|
|
3231
|
+
peer.dispose();
|
|
3232
|
+
errors.dispose();
|
|
3233
|
+
throw unanswered(thrown, helloMs);
|
|
3234
|
+
}
|
|
3235
|
+
peer.useSession(welcome.peer);
|
|
3236
|
+
errors.connect(peer);
|
|
3237
|
+
const allowed = readAllowed(welcome.allowed);
|
|
3238
|
+
loop.adopt(allowed.loop);
|
|
3239
|
+
resources.adopt(allowed.resources);
|
|
3240
|
+
layout.adopt(welcome.layout);
|
|
3241
|
+
peer.on("layout.change", layout.onChange);
|
|
3242
|
+
peer.on("analytics.refused", analytics.refused);
|
|
3243
|
+
peer.on("input.change", input.onChange);
|
|
3244
|
+
peer.on("panel.change", panel.onChange);
|
|
3245
|
+
peer.on("achievement.change", achievements.onChange);
|
|
3246
|
+
peer.on("score.change", score.onChange);
|
|
3247
|
+
loop.loop.on((running) => {
|
|
3248
|
+
if (!running) input.release();
|
|
3249
|
+
});
|
|
3250
|
+
const info = readInfo(welcome.info);
|
|
3251
|
+
save.adopt(welcome.save, info.app.surface);
|
|
3252
|
+
score.adopt(welcome.score);
|
|
3253
|
+
achievements.adopt(welcome.achievements);
|
|
3254
|
+
share.adopt(welcome.share);
|
|
3255
|
+
audio.adopt(welcome.audio, allowed.audio);
|
|
3256
|
+
panel.adopt(welcome.panel);
|
|
3257
|
+
input.adopt(welcome.input);
|
|
3258
|
+
await sized(page, () => layout.layout.frame, fail);
|
|
3259
|
+
const sdk = {
|
|
3260
|
+
lifecycle,
|
|
3261
|
+
loop: loop.loop,
|
|
3262
|
+
resources: resources.resources,
|
|
3263
|
+
layout: layout.layout,
|
|
3264
|
+
info,
|
|
3265
|
+
analytics: analytics.analytics,
|
|
3266
|
+
save: save.save,
|
|
3267
|
+
score: score.score,
|
|
3268
|
+
achievements: achievements.achievements,
|
|
3269
|
+
share: share.share,
|
|
3270
|
+
panel: panel.panel,
|
|
3271
|
+
pause: pause.pause,
|
|
3272
|
+
input: input.input,
|
|
3273
|
+
audio: audio.audio,
|
|
3274
|
+
ping: () => measurePing(peer),
|
|
3275
|
+
dispose: () => {
|
|
3276
|
+
loop.dispose();
|
|
3277
|
+
panel.dispose();
|
|
3278
|
+
input.dispose();
|
|
3279
|
+
audio.dispose();
|
|
3280
|
+
layout.dispose();
|
|
3281
|
+
errors.dispose();
|
|
3282
|
+
peer.dispose();
|
|
3283
|
+
}
|
|
3284
|
+
};
|
|
3285
|
+
return sdk;
|
|
3286
|
+
}
|
|
3287
|
+
var SIZED_MS = 1e3;
|
|
3288
|
+
async function sized(page, frame, onError) {
|
|
3289
|
+
if (page === null) return;
|
|
3290
|
+
const agrees = () => {
|
|
3291
|
+
const { width, height } = frame();
|
|
3292
|
+
if (width <= 0 || height <= 0) return true;
|
|
3293
|
+
return Math.abs(page.innerWidth - width) <= 1 && Math.abs(page.innerHeight - height) <= 1;
|
|
3294
|
+
};
|
|
3295
|
+
if (agrees()) return;
|
|
3296
|
+
await new Promise((resolve) => {
|
|
3297
|
+
const check = () => {
|
|
3298
|
+
if (agrees()) done();
|
|
3299
|
+
};
|
|
3300
|
+
const timer = setTimeout(() => {
|
|
3301
|
+
const { width, height } = frame();
|
|
3302
|
+
onError(
|
|
3303
|
+
new Error(
|
|
3304
|
+
`layout: the host says the frame is ${width}\xD7${height} but the window is ${page.innerWidth}\xD7${page.innerHeight}.`
|
|
3305
|
+
)
|
|
3306
|
+
);
|
|
3307
|
+
done();
|
|
3308
|
+
}, SIZED_MS);
|
|
3309
|
+
function done() {
|
|
3310
|
+
clearTimeout(timer);
|
|
3311
|
+
page?.removeEventListener("resize", check);
|
|
3312
|
+
resolve();
|
|
3313
|
+
}
|
|
3314
|
+
page.addEventListener("resize", check);
|
|
3315
|
+
});
|
|
3316
|
+
}
|
|
3317
|
+
function listenable(scope) {
|
|
3318
|
+
const found = scope ?? (typeof window === "undefined" ? void 0 : window);
|
|
3319
|
+
if (found === void 0) return null;
|
|
3320
|
+
return typeof found.addEventListener === "function" ? found : null;
|
|
3321
|
+
}
|
|
3322
|
+
function framesOf(page) {
|
|
3323
|
+
const scope = page ?? (typeof requestAnimationFrame === "function" ? globalThis : null);
|
|
3324
|
+
if (scope !== null && typeof scope.requestAnimationFrame === "function") {
|
|
3325
|
+
return {
|
|
3326
|
+
request: (callback) => scope.requestAnimationFrame(callback),
|
|
3327
|
+
cancel: (handle) => scope.cancelAnimationFrame(handle)
|
|
3328
|
+
};
|
|
3329
|
+
}
|
|
3330
|
+
return {
|
|
3331
|
+
request: (callback) => setTimeout(() => callback(performance.now()), 16),
|
|
3332
|
+
cancel: (handle) => clearTimeout(handle)
|
|
3333
|
+
};
|
|
3334
|
+
}
|
|
3335
|
+
var HELLO_MS = 1e4;
|
|
3336
|
+
function unanswered(thrown, ms) {
|
|
3337
|
+
if (!(thrown instanceof BridgeError) || thrown.code !== "timeout") return thrown;
|
|
3338
|
+
return new BridgeError({
|
|
3339
|
+
code: "timeout",
|
|
3340
|
+
message: `No host answered: nothing replied to this game's hello within ${ms}ms. A Hopple game runs inside a Hopple host, and whatever is holding this page is not one, or has stopped answering. While developing, run the game with \`npx hopple dev\`.`
|
|
3341
|
+
});
|
|
3342
|
+
}
|
|
3343
|
+
var SIDES3 = ["top", "right", "bottom", "left"];
|
|
3344
|
+
function readable(scope) {
|
|
3345
|
+
const found = scope ?? (typeof window === "undefined" ? void 0 : window);
|
|
3346
|
+
if (found === void 0) return null;
|
|
3347
|
+
const maybe = found;
|
|
3348
|
+
return typeof maybe.addEventListener === "function" && maybe.document != null ? found : null;
|
|
3349
|
+
}
|
|
3350
|
+
async function measurePing(peer) {
|
|
3351
|
+
const nonce = newNonce();
|
|
3352
|
+
const start = performance.now();
|
|
3353
|
+
const pong = await peer.call("system.ping", { nonce });
|
|
3354
|
+
const elapsed = performance.now() - start;
|
|
3355
|
+
if (pong.nonce !== nonce) {
|
|
3356
|
+
throw new Error(`The host answered a different ping: sent ${nonce}, got ${pong.nonce}.`);
|
|
3357
|
+
}
|
|
3358
|
+
return elapsed;
|
|
3359
|
+
}
|
|
3360
|
+
function newNonce() {
|
|
3361
|
+
return Math.random().toString(36).slice(2, 10);
|
|
3362
|
+
}
|
|
3363
|
+
|
|
3364
|
+
// ../manifest/src/manifest.schema.ts
|
|
3365
|
+
var GameManifest = {
|
|
3366
|
+
"type": "object",
|
|
3367
|
+
"properties": {
|
|
3368
|
+
"schema": {
|
|
3369
|
+
"type": "number",
|
|
3370
|
+
"const": 1
|
|
3371
|
+
},
|
|
3372
|
+
"id": {
|
|
3373
|
+
"type": "string",
|
|
3374
|
+
"pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$",
|
|
3375
|
+
"maxLength": 64,
|
|
3376
|
+
"errorMessage": "must be lowercase letters, digits and hyphens"
|
|
3377
|
+
},
|
|
3378
|
+
"version": {
|
|
3379
|
+
"type": "string",
|
|
3380
|
+
"pattern": "^\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?$",
|
|
3381
|
+
"errorMessage": "must look like 1.0.0"
|
|
3382
|
+
},
|
|
3383
|
+
"title": {
|
|
3384
|
+
"type": "string",
|
|
3385
|
+
"pattern": "\\S",
|
|
3386
|
+
"maxLength": 60,
|
|
3387
|
+
"errorMessage": "must not be blank"
|
|
3388
|
+
},
|
|
3389
|
+
"description": {
|
|
3390
|
+
"type": "string",
|
|
3391
|
+
"pattern": "\\S",
|
|
3392
|
+
"maxLength": 500,
|
|
3393
|
+
"errorMessage": "must not be blank"
|
|
3394
|
+
},
|
|
3395
|
+
"cover": {
|
|
3396
|
+
"type": "string",
|
|
3397
|
+
"pattern": "^(?!/)(?!.*\\.\\.)[A-Za-z0-9._/-]+\\.(?:png|jpe?g|webp)$",
|
|
3398
|
+
"maxLength": 200,
|
|
3399
|
+
"errorMessage": 'must be the path of a .png, .jpg or .webp file beside the config, like "cover.png"'
|
|
3400
|
+
},
|
|
3401
|
+
"orientation": {
|
|
3402
|
+
"$ref": "#/definitions/Orientation"
|
|
3403
|
+
},
|
|
3404
|
+
"sdk": {
|
|
3405
|
+
"type": "string",
|
|
3406
|
+
"pattern": "^[\\^~]?\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?$",
|
|
3407
|
+
"errorMessage": "must be a version like 1.0.0, ^1.0.0 or ~1.0.0"
|
|
3408
|
+
},
|
|
3409
|
+
"layout": {
|
|
3410
|
+
"$ref": "#/definitions/LayoutDeclaration"
|
|
3411
|
+
},
|
|
3412
|
+
"analytics": {
|
|
3413
|
+
"$ref": "#/definitions/AnalyticsDeclaration"
|
|
3414
|
+
},
|
|
3415
|
+
"save": {
|
|
3416
|
+
"$ref": "#/definitions/SaveDeclaration"
|
|
3417
|
+
},
|
|
3418
|
+
"score": {
|
|
3419
|
+
"$ref": "#/definitions/ScoreDeclaration"
|
|
3420
|
+
},
|
|
3421
|
+
"achievements": {
|
|
3422
|
+
"$ref": "#/definitions/AchievementDeclaration"
|
|
3423
|
+
},
|
|
3424
|
+
"input": {
|
|
3425
|
+
"$ref": "#/definitions/InputDeclaration"
|
|
3426
|
+
},
|
|
3427
|
+
"audio": {
|
|
3428
|
+
"anyOf": [
|
|
3429
|
+
{
|
|
3430
|
+
"$ref": "#/definitions/AudioDeclaration"
|
|
3431
|
+
},
|
|
3432
|
+
{
|
|
3433
|
+
"type": "null"
|
|
3434
|
+
}
|
|
3435
|
+
]
|
|
3436
|
+
}
|
|
3437
|
+
},
|
|
3438
|
+
"required": [
|
|
3439
|
+
"schema",
|
|
3440
|
+
"id",
|
|
3441
|
+
"version",
|
|
3442
|
+
"title",
|
|
3443
|
+
"orientation",
|
|
3444
|
+
"sdk",
|
|
3445
|
+
"audio"
|
|
3446
|
+
],
|
|
3447
|
+
"additionalProperties": {}
|
|
3448
|
+
};
|
|
3449
|
+
var AchievementProgress = {
|
|
3450
|
+
"type": "object",
|
|
3451
|
+
"properties": {
|
|
3452
|
+
"target": {
|
|
3453
|
+
"type": "number",
|
|
3454
|
+
"minimum": -9007199254740991,
|
|
3455
|
+
"maximum": 9007199254740991
|
|
3456
|
+
},
|
|
3457
|
+
"direction": {
|
|
3458
|
+
"$ref": "#/definitions/ScoreDirection"
|
|
3459
|
+
},
|
|
3460
|
+
"format": {
|
|
3461
|
+
"$ref": "#/definitions/ScoreFormat"
|
|
3462
|
+
},
|
|
3463
|
+
"tiers": {
|
|
3464
|
+
"type": "array",
|
|
3465
|
+
"items": {
|
|
3466
|
+
"$ref": "#/definitions/AchievementTier"
|
|
3467
|
+
},
|
|
3468
|
+
"maxItems": 8
|
|
3469
|
+
}
|
|
3470
|
+
},
|
|
3471
|
+
"required": [
|
|
3472
|
+
"target"
|
|
3473
|
+
],
|
|
3474
|
+
"additionalProperties": {}
|
|
3475
|
+
};
|
|
3476
|
+
var InputBinding = {
|
|
3477
|
+
"type": "string",
|
|
3478
|
+
"pattern": "^(?:key\\.(?!Escape$)(?=.{1,48}$)[A-Za-z][A-Za-z0-9]*|pointer\\.(?:primary|secondary)|mouse\\.(?:left|middle|right)|touch\\.tap|controls\\.(?=.{1,48}$)[a-z0-9]+(?:[_-][a-z0-9]+)*|pad\\.(?:north|south|east|west|start|select)|pad\\.(?:left|right)\\.(?:north|south|east|west|bumper|trigger))$",
|
|
3479
|
+
"errorMessage": "is not a binding this SDK knows (a thumbstick is read rather than bound, and Escape is the host's key)"
|
|
3480
|
+
};
|
|
3481
|
+
var ControlLayout = {
|
|
3482
|
+
"type": "object",
|
|
3483
|
+
"properties": {
|
|
3484
|
+
"left": {
|
|
3485
|
+
"$ref": "#/definitions/ControlZoneSlots"
|
|
3486
|
+
},
|
|
3487
|
+
"right": {
|
|
3488
|
+
"$ref": "#/definitions/ControlZoneSlots"
|
|
3489
|
+
},
|
|
3490
|
+
"full": {
|
|
3491
|
+
"$ref": "#/definitions/ControlZoneSlots"
|
|
3492
|
+
}
|
|
3493
|
+
},
|
|
3494
|
+
"additionalProperties": {}
|
|
3495
|
+
};
|
|
3496
|
+
var InputStick = {
|
|
3497
|
+
"type": "string",
|
|
3498
|
+
"pattern": "^(?:controls\\.(?=.{1,48}$)[a-z0-9]+(?:[_-][a-z0-9]+)*|pad\\.stick|pad\\.(?:left|right)\\.stick)$",
|
|
3499
|
+
"errorMessage": "must name a thumbstick, such as pad.left.stick"
|
|
3500
|
+
};
|
|
3501
|
+
|
|
3502
|
+
// ../manifest/src/manifest.ts
|
|
3503
|
+
var MANIFEST_FILE = "game.manifest.json";
|
|
3504
|
+
var COVER_MAX_BYTES = 1024 * 1024;
|
|
3505
|
+
var MANIFEST_SCHEMA = 1;
|
|
3506
|
+
var SCORE_VALUE_MAX2 = Number.MAX_SAFE_INTEGER;
|
|
3507
|
+
var ACHIEVEMENT_VALUE_MAX2 = AchievementProgress.properties.target.maximum;
|
|
3508
|
+
var MANIFEST_CORE_KEYS = GameManifest.required;
|
|
3509
|
+
var CONTROL_ZONES = Object.keys(ControlLayout.properties);
|
|
3510
|
+
var INPUT_BINDING = new RegExp(InputBinding.pattern);
|
|
3511
|
+
var INPUT_STICK = new RegExp(InputStick.pattern);
|
|
3512
|
+
export {
|
|
3513
|
+
ACHIEVEMENT_VALUE_MAX,
|
|
3514
|
+
AUDIO_FULL,
|
|
3515
|
+
AUDIO_MASTER,
|
|
3516
|
+
AUDIO_VOLUME_MAX,
|
|
3517
|
+
BridgeError,
|
|
3518
|
+
DEADZONE,
|
|
3519
|
+
INPUT_CONTRACT,
|
|
3520
|
+
INPUT_SCHEMES,
|
|
3521
|
+
INPUT_SHIM,
|
|
3522
|
+
MANIFEST_FILE,
|
|
3523
|
+
MANIFEST_SCHEMA,
|
|
3524
|
+
MAX_STEP,
|
|
3525
|
+
PAD_BUTTONS,
|
|
3526
|
+
PAGES,
|
|
3527
|
+
PAUSE_NONE,
|
|
3528
|
+
PAUSE_STATES,
|
|
3529
|
+
SAVE_BYTES,
|
|
3530
|
+
SCORE_VALUE_MAX,
|
|
3531
|
+
SET_SHIM,
|
|
3532
|
+
SHARE_BYTES,
|
|
3533
|
+
SHARE_META_BYTES,
|
|
3534
|
+
SHIM,
|
|
3535
|
+
SHIM_WAIT_MS,
|
|
3536
|
+
audioVolumeOf,
|
|
3537
|
+
findShim,
|
|
3538
|
+
hostTransport,
|
|
3539
|
+
init,
|
|
3540
|
+
insetsFrom,
|
|
3541
|
+
isInputShim,
|
|
3542
|
+
isShim,
|
|
3543
|
+
labelFor,
|
|
3544
|
+
preferredBinding,
|
|
3545
|
+
servesScheme,
|
|
3546
|
+
shimTransport
|
|
3547
|
+
};
|
|
3548
|
+
/*! @hopple/sdk | Copyright (c) 2026 The Hopple authors | MIT License */
|