@hopple/sdk 0.1.0-rc.3 → 1.0.0-next.4
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/README.md +144 -27
- package/config.schema.json +261 -41
- package/index.d.ts +578 -879
- package/index.js +2 -29
- package/manifest.d.ts +1063 -0
- package/manifest.js +896 -0
- package/manifest.md +101 -11
- package/package.json +9 -1
package/manifest.js
ADDED
|
@@ -0,0 +1,896 @@
|
|
|
1
|
+
/*! @hopple/sdk 1.0.0-next.4 | Copyright (c) 2026 The Hopple authors | MIT License */
|
|
2
|
+
//#region ../manifest/src/manifest.schema.ts
|
|
3
|
+
const GameManifest = {
|
|
4
|
+
"type": "object",
|
|
5
|
+
"properties": {
|
|
6
|
+
"api": { "$ref": "#/definitions/ApiVersion" },
|
|
7
|
+
"id": {
|
|
8
|
+
"type": "string",
|
|
9
|
+
"pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$",
|
|
10
|
+
"maxLength": 64,
|
|
11
|
+
"errorMessage": "must be lowercase letters, digits and hyphens"
|
|
12
|
+
},
|
|
13
|
+
"owner": {
|
|
14
|
+
"type": "string",
|
|
15
|
+
"pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$",
|
|
16
|
+
"maxLength": 64,
|
|
17
|
+
"errorMessage": "must be a handle: lowercase letters, digits and hyphens"
|
|
18
|
+
},
|
|
19
|
+
"publishable": { "type": "boolean" },
|
|
20
|
+
"version": {
|
|
21
|
+
"type": "string",
|
|
22
|
+
"pattern": "^\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?$",
|
|
23
|
+
"errorMessage": "must look like 1.0.0"
|
|
24
|
+
},
|
|
25
|
+
"title": {
|
|
26
|
+
"type": "string",
|
|
27
|
+
"pattern": "\\S",
|
|
28
|
+
"maxLength": 60,
|
|
29
|
+
"errorMessage": "must not be blank"
|
|
30
|
+
},
|
|
31
|
+
"description": {
|
|
32
|
+
"type": "string",
|
|
33
|
+
"pattern": "\\S",
|
|
34
|
+
"maxLength": 500,
|
|
35
|
+
"errorMessage": "must not be blank"
|
|
36
|
+
},
|
|
37
|
+
"genres": {
|
|
38
|
+
"type": "array",
|
|
39
|
+
"items": { "$ref": "#/definitions/GameGenre" },
|
|
40
|
+
"minItems": 1,
|
|
41
|
+
"maxItems": 5
|
|
42
|
+
},
|
|
43
|
+
"cover": {
|
|
44
|
+
"type": "string",
|
|
45
|
+
"pattern": "^(?!/)(?!.*\\.\\.)[A-Za-z0-9._/-]+\\.(?:png|jpe?g|webp)$",
|
|
46
|
+
"maxLength": 200,
|
|
47
|
+
"errorMessage": "must be the path of a .png, .jpg or .webp file beside the config, like \"cover.png\""
|
|
48
|
+
},
|
|
49
|
+
"orientation": { "$ref": "#/definitions/Orientation" },
|
|
50
|
+
"layout": { "$ref": "#/definitions/LayoutDeclaration" },
|
|
51
|
+
"analytics": { "$ref": "#/definitions/AnalyticsDeclaration" },
|
|
52
|
+
"save": { "$ref": "#/definitions/SaveDeclaration" },
|
|
53
|
+
"score": { "$ref": "#/definitions/ScoreDeclaration" },
|
|
54
|
+
"achievements": { "$ref": "#/definitions/AchievementDeclaration" },
|
|
55
|
+
"input": { "$ref": "#/definitions/InputDeclaration" },
|
|
56
|
+
"players": { "$ref": "#/definitions/PlayersDeclaration" },
|
|
57
|
+
"audio": { "anyOf": [{ "$ref": "#/definitions/AudioDeclaration" }, { "type": "null" }] },
|
|
58
|
+
"previews": { "$ref": "#/definitions/PreviewsDeclaration" }
|
|
59
|
+
},
|
|
60
|
+
"required": [
|
|
61
|
+
"api",
|
|
62
|
+
"id",
|
|
63
|
+
"version",
|
|
64
|
+
"title",
|
|
65
|
+
"orientation",
|
|
66
|
+
"audio"
|
|
67
|
+
],
|
|
68
|
+
"additionalProperties": false
|
|
69
|
+
};
|
|
70
|
+
const ApiVersion = {
|
|
71
|
+
"type": "string",
|
|
72
|
+
"pattern": "^(?:(?:0|[1-9][0-9]*)\\.(?:0|[1-9][0-9]*)|next)$",
|
|
73
|
+
"errorMessage": "must be the API the game is written against, like \"1.0\", or \"next\""
|
|
74
|
+
};
|
|
75
|
+
const GameGenre = {
|
|
76
|
+
"type": "string",
|
|
77
|
+
"enum": [
|
|
78
|
+
"racing",
|
|
79
|
+
"shooter",
|
|
80
|
+
"platformer",
|
|
81
|
+
"puzzle",
|
|
82
|
+
"arcade",
|
|
83
|
+
"physics",
|
|
84
|
+
"roguelike",
|
|
85
|
+
"strategy",
|
|
86
|
+
"building",
|
|
87
|
+
"idle",
|
|
88
|
+
"sports",
|
|
89
|
+
"horror",
|
|
90
|
+
"stealth",
|
|
91
|
+
"fighting",
|
|
92
|
+
"casual",
|
|
93
|
+
"simulation",
|
|
94
|
+
"adventure",
|
|
95
|
+
"rhythm",
|
|
96
|
+
"rpg",
|
|
97
|
+
"local-multiplayer",
|
|
98
|
+
"online-multiplayer"
|
|
99
|
+
]
|
|
100
|
+
};
|
|
101
|
+
const Orientation = {
|
|
102
|
+
"type": "string",
|
|
103
|
+
"enum": [
|
|
104
|
+
"portrait",
|
|
105
|
+
"landscape",
|
|
106
|
+
"any"
|
|
107
|
+
]
|
|
108
|
+
};
|
|
109
|
+
const LayoutDeclaration = {
|
|
110
|
+
"type": "object",
|
|
111
|
+
"properties": { "insets": { "$ref": "#/definitions/InsetMode" } },
|
|
112
|
+
"required": ["insets"],
|
|
113
|
+
"additionalProperties": false
|
|
114
|
+
};
|
|
115
|
+
const InsetMode = {
|
|
116
|
+
"type": "string",
|
|
117
|
+
"enum": ["draw-under", "reserve"]
|
|
118
|
+
};
|
|
119
|
+
const AnalyticsDeclaration = {
|
|
120
|
+
"type": "object",
|
|
121
|
+
"properties": { "events": {
|
|
122
|
+
"type": "object",
|
|
123
|
+
"additionalProperties": { "$ref": "#/definitions/AnalyticsShape" },
|
|
124
|
+
"maxProperties": 64,
|
|
125
|
+
"propertyNames": {
|
|
126
|
+
"pattern": "^[a-z0-9]+(?:[._-][a-z0-9]+)*$",
|
|
127
|
+
"maxLength": 48,
|
|
128
|
+
"not": { "pattern": "^hopple\\." },
|
|
129
|
+
"errorMessage": "must be lowercase letters, digits, dots, hyphens and underscores, and must not start with \"hopple.\", which the host uses"
|
|
130
|
+
}
|
|
131
|
+
} },
|
|
132
|
+
"required": ["events"],
|
|
133
|
+
"additionalProperties": false
|
|
134
|
+
};
|
|
135
|
+
const AnalyticsShape = {
|
|
136
|
+
"type": "object",
|
|
137
|
+
"additionalProperties": { "$ref": "#/definitions/AnalyticsType" },
|
|
138
|
+
"maxProperties": 8,
|
|
139
|
+
"propertyNames": {
|
|
140
|
+
"pattern": "^[a-z0-9]+(?:[._-][a-z0-9]+)*$",
|
|
141
|
+
"maxLength": 24,
|
|
142
|
+
"errorMessage": "must be a prop name of lowercase letters, digits, dots, hyphens and underscores"
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
const AnalyticsType = {
|
|
146
|
+
"type": "string",
|
|
147
|
+
"enum": [
|
|
148
|
+
"string",
|
|
149
|
+
"number",
|
|
150
|
+
"boolean"
|
|
151
|
+
]
|
|
152
|
+
};
|
|
153
|
+
const SaveDeclaration = {
|
|
154
|
+
"type": "object",
|
|
155
|
+
"properties": { "bytes": {
|
|
156
|
+
"type": "integer",
|
|
157
|
+
"exclusiveMinimum": 0,
|
|
158
|
+
"maximum": 1048576
|
|
159
|
+
} },
|
|
160
|
+
"required": ["bytes"],
|
|
161
|
+
"additionalProperties": false
|
|
162
|
+
};
|
|
163
|
+
const ScoreDeclaration = {
|
|
164
|
+
"type": "object",
|
|
165
|
+
"properties": { "boards": {
|
|
166
|
+
"type": "object",
|
|
167
|
+
"additionalProperties": { "$ref": "#/definitions/ScoreBoard" },
|
|
168
|
+
"minProperties": 1,
|
|
169
|
+
"maxProperties": 16,
|
|
170
|
+
"propertyNames": {
|
|
171
|
+
"pattern": "^[a-z0-9]+(?:[._-][a-z0-9]+)*$",
|
|
172
|
+
"maxLength": 48,
|
|
173
|
+
"errorMessage": "must be lowercase letters, digits, dots, hyphens and underscores"
|
|
174
|
+
}
|
|
175
|
+
} },
|
|
176
|
+
"required": ["boards"],
|
|
177
|
+
"additionalProperties": false
|
|
178
|
+
};
|
|
179
|
+
const ScoreBoard = {
|
|
180
|
+
"type": "object",
|
|
181
|
+
"properties": {
|
|
182
|
+
"title": {
|
|
183
|
+
"type": "string",
|
|
184
|
+
"pattern": "\\S",
|
|
185
|
+
"maxLength": 40,
|
|
186
|
+
"errorMessage": "must not be blank"
|
|
187
|
+
},
|
|
188
|
+
"direction": { "$ref": "#/definitions/ScoreDirection" },
|
|
189
|
+
"format": { "$ref": "#/definitions/ScoreFormat" }
|
|
190
|
+
},
|
|
191
|
+
"required": ["title", "direction"],
|
|
192
|
+
"additionalProperties": false
|
|
193
|
+
};
|
|
194
|
+
const ScoreDirection = {
|
|
195
|
+
"type": "string",
|
|
196
|
+
"enum": ["asc", "desc"]
|
|
197
|
+
};
|
|
198
|
+
const ScoreFormat = {
|
|
199
|
+
"type": "string",
|
|
200
|
+
"enum": [
|
|
201
|
+
"integer",
|
|
202
|
+
"decimal",
|
|
203
|
+
"duration-ms"
|
|
204
|
+
]
|
|
205
|
+
};
|
|
206
|
+
const AchievementDeclaration = {
|
|
207
|
+
"type": "object",
|
|
208
|
+
"properties": { "list": {
|
|
209
|
+
"type": "object",
|
|
210
|
+
"additionalProperties": { "$ref": "#/definitions/Achievement" },
|
|
211
|
+
"minProperties": 1,
|
|
212
|
+
"maxProperties": 128,
|
|
213
|
+
"propertyNames": {
|
|
214
|
+
"pattern": "^[a-z0-9]+(?:[._-][a-z0-9]+)*$",
|
|
215
|
+
"maxLength": 48,
|
|
216
|
+
"errorMessage": "must be lowercase letters, digits, dots, hyphens and underscores"
|
|
217
|
+
}
|
|
218
|
+
} },
|
|
219
|
+
"required": ["list"],
|
|
220
|
+
"additionalProperties": false
|
|
221
|
+
};
|
|
222
|
+
const Achievement = {
|
|
223
|
+
"type": "object",
|
|
224
|
+
"properties": {
|
|
225
|
+
"title": {
|
|
226
|
+
"type": "string",
|
|
227
|
+
"pattern": "\\S",
|
|
228
|
+
"maxLength": 40,
|
|
229
|
+
"errorMessage": "must not be blank"
|
|
230
|
+
},
|
|
231
|
+
"detail": {
|
|
232
|
+
"type": "string",
|
|
233
|
+
"pattern": "\\S",
|
|
234
|
+
"maxLength": 120,
|
|
235
|
+
"errorMessage": "must not be blank"
|
|
236
|
+
},
|
|
237
|
+
"icon": {
|
|
238
|
+
"type": "string",
|
|
239
|
+
"pattern": "\\S",
|
|
240
|
+
"maxLength": 8,
|
|
241
|
+
"errorMessage": "must not be blank"
|
|
242
|
+
},
|
|
243
|
+
"secret": { "type": "boolean" },
|
|
244
|
+
"progress": { "$ref": "#/definitions/AchievementProgress" }
|
|
245
|
+
},
|
|
246
|
+
"required": ["title"],
|
|
247
|
+
"additionalProperties": false
|
|
248
|
+
};
|
|
249
|
+
const AchievementProgress = {
|
|
250
|
+
"type": "object",
|
|
251
|
+
"properties": {
|
|
252
|
+
"target": {
|
|
253
|
+
"type": "number",
|
|
254
|
+
"minimum": -9007199254740991,
|
|
255
|
+
"maximum": 9007199254740991
|
|
256
|
+
},
|
|
257
|
+
"direction": { "$ref": "#/definitions/ScoreDirection" },
|
|
258
|
+
"format": { "$ref": "#/definitions/ScoreFormat" },
|
|
259
|
+
"tiers": {
|
|
260
|
+
"type": "array",
|
|
261
|
+
"items": { "$ref": "#/definitions/AchievementTier" },
|
|
262
|
+
"maxItems": 8
|
|
263
|
+
}
|
|
264
|
+
},
|
|
265
|
+
"required": ["target"],
|
|
266
|
+
"additionalProperties": false
|
|
267
|
+
};
|
|
268
|
+
const AchievementTier = {
|
|
269
|
+
"type": "object",
|
|
270
|
+
"properties": {
|
|
271
|
+
"at": {
|
|
272
|
+
"type": "number",
|
|
273
|
+
"minimum": -9007199254740991,
|
|
274
|
+
"maximum": 9007199254740991
|
|
275
|
+
},
|
|
276
|
+
"title": {
|
|
277
|
+
"type": "string",
|
|
278
|
+
"pattern": "\\S",
|
|
279
|
+
"maxLength": 40,
|
|
280
|
+
"errorMessage": "must not be blank"
|
|
281
|
+
},
|
|
282
|
+
"icon": {
|
|
283
|
+
"type": "string",
|
|
284
|
+
"pattern": "\\S",
|
|
285
|
+
"maxLength": 8,
|
|
286
|
+
"errorMessage": "must not be blank"
|
|
287
|
+
}
|
|
288
|
+
},
|
|
289
|
+
"required": ["at", "title"],
|
|
290
|
+
"additionalProperties": false
|
|
291
|
+
};
|
|
292
|
+
const InputDeclaration = {
|
|
293
|
+
"type": "object",
|
|
294
|
+
"properties": {
|
|
295
|
+
"supported": {
|
|
296
|
+
"type": "array",
|
|
297
|
+
"items": { "$ref": "#/definitions/InputDeviceSet" },
|
|
298
|
+
"minItems": 1,
|
|
299
|
+
"maxItems": 8
|
|
300
|
+
},
|
|
301
|
+
"actions": {
|
|
302
|
+
"type": "object",
|
|
303
|
+
"additionalProperties": { "$ref": "#/definitions/InputAction" },
|
|
304
|
+
"maxProperties": 64,
|
|
305
|
+
"propertyNames": {
|
|
306
|
+
"pattern": "^[a-z0-9]+(?:[._-][a-z0-9]+)*$",
|
|
307
|
+
"maxLength": 48,
|
|
308
|
+
"errorMessage": "must be lowercase letters, digits, dots, hyphens and underscores"
|
|
309
|
+
}
|
|
310
|
+
},
|
|
311
|
+
"controls": {
|
|
312
|
+
"type": "object",
|
|
313
|
+
"additionalProperties": { "$ref": "#/definitions/ControlLayout" },
|
|
314
|
+
"maxProperties": 8,
|
|
315
|
+
"propertyNames": {
|
|
316
|
+
"pattern": "^[a-z0-9]+(?:[._-][a-z0-9]+)*$",
|
|
317
|
+
"maxLength": 48,
|
|
318
|
+
"errorMessage": "must be a layout name of lowercase letters, digits, dots, hyphens and underscores"
|
|
319
|
+
}
|
|
320
|
+
},
|
|
321
|
+
"player": { "$ref": "#/definitions/PlayerInputDeclaration" }
|
|
322
|
+
},
|
|
323
|
+
"additionalProperties": false
|
|
324
|
+
};
|
|
325
|
+
const InputDeviceSet = {
|
|
326
|
+
"type": "array",
|
|
327
|
+
"items": { "$ref": "#/definitions/InputDevice" },
|
|
328
|
+
"minItems": 1,
|
|
329
|
+
"maxItems": 8
|
|
330
|
+
};
|
|
331
|
+
const InputDevice = {
|
|
332
|
+
"type": "string",
|
|
333
|
+
"enum": [
|
|
334
|
+
"keyboard",
|
|
335
|
+
"mouse",
|
|
336
|
+
"touch",
|
|
337
|
+
"gamepad"
|
|
338
|
+
]
|
|
339
|
+
};
|
|
340
|
+
const InputAction = {
|
|
341
|
+
"type": "object",
|
|
342
|
+
"properties": {
|
|
343
|
+
"bind": { "$ref": "#/definitions/InputBindings" },
|
|
344
|
+
"title": {
|
|
345
|
+
"type": "string",
|
|
346
|
+
"pattern": "\\S",
|
|
347
|
+
"maxLength": 40,
|
|
348
|
+
"errorMessage": "must not be blank"
|
|
349
|
+
},
|
|
350
|
+
"label": {
|
|
351
|
+
"type": "string",
|
|
352
|
+
"pattern": "\\S",
|
|
353
|
+
"maxLength": 8,
|
|
354
|
+
"errorMessage": "must not be blank"
|
|
355
|
+
}
|
|
356
|
+
},
|
|
357
|
+
"required": ["bind"],
|
|
358
|
+
"additionalProperties": false
|
|
359
|
+
};
|
|
360
|
+
const InputBindings = {
|
|
361
|
+
"type": "array",
|
|
362
|
+
"items": { "$ref": "#/definitions/InputBinding" },
|
|
363
|
+
"minItems": 1,
|
|
364
|
+
"maxItems": 8
|
|
365
|
+
};
|
|
366
|
+
const InputBinding = {
|
|
367
|
+
"type": "string",
|
|
368
|
+
"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))$",
|
|
369
|
+
"errorMessage": "is not a binding this SDK knows (a thumbstick is read rather than bound, and Escape is the host's key)"
|
|
370
|
+
};
|
|
371
|
+
const ControlLayout = {
|
|
372
|
+
"type": "object",
|
|
373
|
+
"properties": {
|
|
374
|
+
"left": { "$ref": "#/definitions/ControlZoneSlots" },
|
|
375
|
+
"right": { "$ref": "#/definitions/ControlZoneSlots" },
|
|
376
|
+
"full": { "$ref": "#/definitions/ControlZoneSlots" }
|
|
377
|
+
},
|
|
378
|
+
"additionalProperties": false
|
|
379
|
+
};
|
|
380
|
+
const ControlZoneSlots = {
|
|
381
|
+
"type": "object",
|
|
382
|
+
"additionalProperties": { "$ref": "#/definitions/ControlSlot" },
|
|
383
|
+
"maxProperties": 8,
|
|
384
|
+
"propertyNames": {
|
|
385
|
+
"pattern": "^[a-z0-9]+(?:[._-][a-z0-9]+)*$",
|
|
386
|
+
"maxLength": 48,
|
|
387
|
+
"errorMessage": "must be a control id of lowercase letters, digits, dots, hyphens and underscores"
|
|
388
|
+
}
|
|
389
|
+
};
|
|
390
|
+
const ControlSlot = {
|
|
391
|
+
"type": "object",
|
|
392
|
+
"properties": {
|
|
393
|
+
"type": {
|
|
394
|
+
"type": "string",
|
|
395
|
+
"enum": ["buttons", "thumbstick"]
|
|
396
|
+
},
|
|
397
|
+
"buttons": {
|
|
398
|
+
"type": "array",
|
|
399
|
+
"items": { "type": "string" },
|
|
400
|
+
"minItems": 1,
|
|
401
|
+
"maxItems": 8
|
|
402
|
+
},
|
|
403
|
+
"shape": { "$ref": "#/definitions/ControlShape" },
|
|
404
|
+
"as": { "anyOf": [{ "$ref": "#/definitions/InputStick" }, {
|
|
405
|
+
"type": "object",
|
|
406
|
+
"additionalProperties": { "$ref": "#/definitions/InputBinding" }
|
|
407
|
+
}] },
|
|
408
|
+
"axis": { "$ref": "#/definitions/ControlAxis" },
|
|
409
|
+
"scale": {
|
|
410
|
+
"type": "number",
|
|
411
|
+
"minimum": .75,
|
|
412
|
+
"maximum": 1.5
|
|
413
|
+
}
|
|
414
|
+
},
|
|
415
|
+
"required": ["type"],
|
|
416
|
+
"additionalProperties": false,
|
|
417
|
+
"if": {
|
|
418
|
+
"properties": { "type": { "const": "buttons" } },
|
|
419
|
+
"required": ["type"]
|
|
420
|
+
},
|
|
421
|
+
"then": {
|
|
422
|
+
"required": ["buttons"],
|
|
423
|
+
"properties": {
|
|
424
|
+
"as": {
|
|
425
|
+
"type": "object",
|
|
426
|
+
"additionalProperties": { "$ref": "#/definitions/InputBinding" }
|
|
427
|
+
},
|
|
428
|
+
"axis": {
|
|
429
|
+
"not": {},
|
|
430
|
+
"errorMessage": "is only for a thumbstick"
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
},
|
|
434
|
+
"else": { "properties": { "as": { "$ref": "#/definitions/InputStick" } } }
|
|
435
|
+
};
|
|
436
|
+
const ControlShape = {
|
|
437
|
+
"type": "string",
|
|
438
|
+
"enum": [
|
|
439
|
+
"row",
|
|
440
|
+
"column",
|
|
441
|
+
"diamond",
|
|
442
|
+
"cluster"
|
|
443
|
+
]
|
|
444
|
+
};
|
|
445
|
+
const InputStick = {
|
|
446
|
+
"type": "string",
|
|
447
|
+
"pattern": "^(?:controls\\.(?=.{1,48}$)[a-z0-9]+(?:[_-][a-z0-9]+)*|pad\\.stick|pad\\.(?:left|right)\\.stick)$",
|
|
448
|
+
"errorMessage": "must name a thumbstick, such as pad.left.stick"
|
|
449
|
+
};
|
|
450
|
+
const definitions = {
|
|
451
|
+
GameManifest,
|
|
452
|
+
ApiVersion,
|
|
453
|
+
GameGenre,
|
|
454
|
+
Orientation,
|
|
455
|
+
LayoutDeclaration,
|
|
456
|
+
InsetMode,
|
|
457
|
+
AnalyticsDeclaration,
|
|
458
|
+
AnalyticsShape,
|
|
459
|
+
AnalyticsType,
|
|
460
|
+
SaveDeclaration,
|
|
461
|
+
ScoreDeclaration,
|
|
462
|
+
ScoreBoard,
|
|
463
|
+
ScoreDirection,
|
|
464
|
+
ScoreFormat,
|
|
465
|
+
AchievementDeclaration,
|
|
466
|
+
Achievement,
|
|
467
|
+
AchievementProgress,
|
|
468
|
+
AchievementTier,
|
|
469
|
+
InputDeclaration,
|
|
470
|
+
InputDeviceSet,
|
|
471
|
+
InputDevice,
|
|
472
|
+
InputAction,
|
|
473
|
+
InputBindings,
|
|
474
|
+
InputBinding,
|
|
475
|
+
ControlLayout,
|
|
476
|
+
ControlZoneSlots,
|
|
477
|
+
ControlSlot,
|
|
478
|
+
ControlShape,
|
|
479
|
+
InputStick,
|
|
480
|
+
ControlAxis: {
|
|
481
|
+
"type": "string",
|
|
482
|
+
"enum": ["x", "y"]
|
|
483
|
+
},
|
|
484
|
+
PlayerInputDeclaration: {
|
|
485
|
+
"type": "object",
|
|
486
|
+
"properties": {
|
|
487
|
+
"actions": {
|
|
488
|
+
"type": "object",
|
|
489
|
+
"additionalProperties": { "$ref": "#/definitions/PlayerInputAction" },
|
|
490
|
+
"maxProperties": 32,
|
|
491
|
+
"propertyNames": {
|
|
492
|
+
"pattern": "^[a-z0-9]+(?:[._-][a-z0-9]+)*$",
|
|
493
|
+
"maxLength": 48,
|
|
494
|
+
"errorMessage": "must be lowercase letters, digits, dots, hyphens and underscores"
|
|
495
|
+
}
|
|
496
|
+
},
|
|
497
|
+
"keyboard": {
|
|
498
|
+
"type": "array",
|
|
499
|
+
"items": { "$ref": "#/definitions/KeyboardSet" },
|
|
500
|
+
"minItems": 1,
|
|
501
|
+
"maxItems": 4
|
|
502
|
+
}
|
|
503
|
+
},
|
|
504
|
+
"required": ["actions"],
|
|
505
|
+
"additionalProperties": false
|
|
506
|
+
},
|
|
507
|
+
PlayerInputAction: {
|
|
508
|
+
"type": "object",
|
|
509
|
+
"properties": {
|
|
510
|
+
"bind": {
|
|
511
|
+
"type": "array",
|
|
512
|
+
"items": { "$ref": "#/definitions/PlayerBinding" },
|
|
513
|
+
"minItems": 1,
|
|
514
|
+
"maxItems": 8
|
|
515
|
+
},
|
|
516
|
+
"title": {
|
|
517
|
+
"type": "string",
|
|
518
|
+
"pattern": "\\S",
|
|
519
|
+
"maxLength": 40,
|
|
520
|
+
"errorMessage": "must not be blank"
|
|
521
|
+
},
|
|
522
|
+
"label": {
|
|
523
|
+
"type": "string",
|
|
524
|
+
"pattern": "\\S",
|
|
525
|
+
"maxLength": 8,
|
|
526
|
+
"errorMessage": "must not be blank"
|
|
527
|
+
},
|
|
528
|
+
"join": { "type": "boolean" }
|
|
529
|
+
},
|
|
530
|
+
"additionalProperties": false
|
|
531
|
+
},
|
|
532
|
+
PlayerBinding: {
|
|
533
|
+
"type": "string",
|
|
534
|
+
"pattern": "^(?: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))$",
|
|
535
|
+
"errorMessage": "must be a pad button or an on-screen control: a player's keys and mouse buttons go in input.player.keyboard"
|
|
536
|
+
},
|
|
537
|
+
KeyboardSet: {
|
|
538
|
+
"type": "object",
|
|
539
|
+
"properties": {
|
|
540
|
+
"title": {
|
|
541
|
+
"type": "string",
|
|
542
|
+
"pattern": "\\S",
|
|
543
|
+
"maxLength": 40,
|
|
544
|
+
"errorMessage": "must not be blank"
|
|
545
|
+
},
|
|
546
|
+
"bind": {
|
|
547
|
+
"type": "object",
|
|
548
|
+
"additionalProperties": {
|
|
549
|
+
"type": "array",
|
|
550
|
+
"items": { "$ref": "#/definitions/KeyboardBinding" }
|
|
551
|
+
},
|
|
552
|
+
"maxProperties": 64,
|
|
553
|
+
"propertyNames": {
|
|
554
|
+
"pattern": "^[a-z0-9]+(?:[._-][a-z0-9]+)*$",
|
|
555
|
+
"maxLength": 48,
|
|
556
|
+
"errorMessage": "must be a player action id"
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
},
|
|
560
|
+
"required": ["bind"],
|
|
561
|
+
"additionalProperties": false
|
|
562
|
+
},
|
|
563
|
+
KeyboardBinding: {
|
|
564
|
+
"type": "string",
|
|
565
|
+
"pattern": "^(?:key\\.(?!Escape$)(?=.{1,48}$)[A-Za-z][A-Za-z0-9]*|mouse\\.(?:left|middle|right))$",
|
|
566
|
+
"errorMessage": "must be a key or a mouse button (and Escape is the host's key)"
|
|
567
|
+
},
|
|
568
|
+
PlayersDeclaration: {
|
|
569
|
+
"type": "object",
|
|
570
|
+
"properties": { "local": {
|
|
571
|
+
"type": "integer",
|
|
572
|
+
"minimum": 1,
|
|
573
|
+
"maximum": 8
|
|
574
|
+
} },
|
|
575
|
+
"additionalProperties": false
|
|
576
|
+
},
|
|
577
|
+
AudioDeclaration: {
|
|
578
|
+
"type": "object",
|
|
579
|
+
"properties": { "channels": {
|
|
580
|
+
"type": "object",
|
|
581
|
+
"additionalProperties": { "$ref": "#/definitions/AudioChannel" },
|
|
582
|
+
"maxProperties": 8,
|
|
583
|
+
"propertyNames": {
|
|
584
|
+
"pattern": "^[a-z0-9]+(?:[._-][a-z0-9]+)*$",
|
|
585
|
+
"maxLength": 48,
|
|
586
|
+
"not": { "const": "master" },
|
|
587
|
+
"errorMessage": "must be lowercase letters, digits, dots, hyphens and underscores, and must not be \"master\", which every host adds itself"
|
|
588
|
+
}
|
|
589
|
+
} },
|
|
590
|
+
"additionalProperties": false
|
|
591
|
+
},
|
|
592
|
+
AudioChannel: {
|
|
593
|
+
"type": "object",
|
|
594
|
+
"properties": { "title": {
|
|
595
|
+
"type": "string",
|
|
596
|
+
"pattern": "\\S",
|
|
597
|
+
"maxLength": 40,
|
|
598
|
+
"errorMessage": "must not be blank"
|
|
599
|
+
} },
|
|
600
|
+
"required": ["title"],
|
|
601
|
+
"additionalProperties": false
|
|
602
|
+
},
|
|
603
|
+
PreviewsDeclaration: {
|
|
604
|
+
"type": "object",
|
|
605
|
+
"properties": { "network": { "$ref": "#/definitions/PreviewRevision" } },
|
|
606
|
+
"additionalProperties": false
|
|
607
|
+
},
|
|
608
|
+
PreviewRevision: {
|
|
609
|
+
"type": "integer",
|
|
610
|
+
"minimum": 1
|
|
611
|
+
}
|
|
612
|
+
};
|
|
613
|
+
AchievementProgress.properties.target.maximum;
|
|
614
|
+
GameManifest.required;
|
|
615
|
+
GameGenre.enum;
|
|
616
|
+
/**
|
|
617
|
+
* The zones a layout places controls in, in the order they are drawn.
|
|
618
|
+
*
|
|
619
|
+
* @group Input
|
|
620
|
+
*/
|
|
621
|
+
const CONTROL_ZONES = Object.keys(ControlLayout.properties);
|
|
622
|
+
new RegExp(InputBinding.pattern);
|
|
623
|
+
new RegExp(InputStick.pattern);
|
|
624
|
+
/**
|
|
625
|
+
* Check a parsed manifest, without throwing and without changing it.
|
|
626
|
+
*
|
|
627
|
+
* Every problem is reported, not just the first: someone fixing a manifest
|
|
628
|
+
* wants the list, and a validator that reveals one error per run is a validator
|
|
629
|
+
* people stop running. A key the manifest does not know is refused, since a
|
|
630
|
+
* misspelt one would otherwise mean nothing in silence; `lenient` ignores it
|
|
631
|
+
* instead, for reading a manifest that was published, possibly against a newer
|
|
632
|
+
* minor than this one. A key we *do* know, spelled wrongly, is refused either
|
|
633
|
+
* way: `"draw_under"` would otherwise mean `reserve` in silence, and silence
|
|
634
|
+
* here looks exactly like a bug in the host.
|
|
635
|
+
*
|
|
636
|
+
* @group Manifest
|
|
637
|
+
* @category Important
|
|
638
|
+
*/
|
|
639
|
+
function validateManifest(value, options = {}) {
|
|
640
|
+
const problems = [];
|
|
641
|
+
const fail = (field, message) => {
|
|
642
|
+
if (problems.some((p) => p.field === field && p.message === message)) return;
|
|
643
|
+
problems.push({
|
|
644
|
+
field,
|
|
645
|
+
message
|
|
646
|
+
});
|
|
647
|
+
};
|
|
648
|
+
walk(GameManifest, value, "", fail, options.lenient === true);
|
|
649
|
+
if (problems.length === 0) checkReferences(value, fail);
|
|
650
|
+
if (problems.length > 0) return {
|
|
651
|
+
ok: false,
|
|
652
|
+
problems
|
|
653
|
+
};
|
|
654
|
+
return {
|
|
655
|
+
ok: true,
|
|
656
|
+
manifest: value
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
/**
|
|
660
|
+
* One line per problem, worded the same wherever a manifest is rejected.
|
|
661
|
+
*
|
|
662
|
+
* Field names are set in backticks, because plenty of them are ordinary words
|
|
663
|
+
* — "entry is required" reads as a sentence about some entry, and
|
|
664
|
+
* "`entry` is required" names a key.
|
|
665
|
+
*
|
|
666
|
+
* @group Manifest
|
|
667
|
+
* @category Useful
|
|
668
|
+
*/
|
|
669
|
+
function describeProblems(problems) {
|
|
670
|
+
return problems.map((p) => p.field === "" ? `the manifest ${p.message}` : `\`${p.field}\` ${p.message}`).join("; ");
|
|
671
|
+
}
|
|
672
|
+
function resolve(node) {
|
|
673
|
+
let at = node;
|
|
674
|
+
while (typeof at.$ref === "string") {
|
|
675
|
+
const name = at.$ref.slice(14);
|
|
676
|
+
const found = definitions[name];
|
|
677
|
+
if (found === void 0) throw new Error(`the manifest schema has no definition ${name}`);
|
|
678
|
+
at = found;
|
|
679
|
+
}
|
|
680
|
+
return at;
|
|
681
|
+
}
|
|
682
|
+
/** Whether a value passes, with nothing reported. */
|
|
683
|
+
function passes(node, value, lenient) {
|
|
684
|
+
let ok = true;
|
|
685
|
+
walk(node, value, "", () => {
|
|
686
|
+
ok = false;
|
|
687
|
+
}, lenient);
|
|
688
|
+
return ok;
|
|
689
|
+
}
|
|
690
|
+
function kindOf(value) {
|
|
691
|
+
if (Array.isArray(value)) return "array";
|
|
692
|
+
if (value === null) return "null";
|
|
693
|
+
if (typeof value === "number") return Number.isInteger(value) ? "integer" : "number";
|
|
694
|
+
return typeof value;
|
|
695
|
+
}
|
|
696
|
+
function isKind(value, type) {
|
|
697
|
+
const kind = kindOf(value);
|
|
698
|
+
return kind === type || type === "number" && kind === "integer";
|
|
699
|
+
}
|
|
700
|
+
const KIND_WORDS = {
|
|
701
|
+
string: "a string",
|
|
702
|
+
number: "a number",
|
|
703
|
+
integer: "a whole number",
|
|
704
|
+
boolean: "true or false",
|
|
705
|
+
object: "an object",
|
|
706
|
+
array: "a list",
|
|
707
|
+
null: "null"
|
|
708
|
+
};
|
|
709
|
+
function child(path, key, max = 48) {
|
|
710
|
+
const name = [...key].length > max ? `${[...key].slice(0, max).join("")}…` : key;
|
|
711
|
+
return path === "" ? name : `${path}.${name}`;
|
|
712
|
+
}
|
|
713
|
+
/** One value against one schema node, reporting what is wrong where it is wrong. */
|
|
714
|
+
/** `lenient` ignores keys an object's schema does not name, rather than refusing them. */
|
|
715
|
+
function walk(given, value, path, fail, lenient) {
|
|
716
|
+
const node = resolve(given);
|
|
717
|
+
if (Array.isArray(node.anyOf)) {
|
|
718
|
+
const branches = node.anyOf.map(resolve);
|
|
719
|
+
if (branches.some((branch) => passes(branch, value, lenient))) return;
|
|
720
|
+
const shaped = branches.find((branch) => isKind(value, branch.type));
|
|
721
|
+
if (shaped !== void 0) walk(shaped, value, path, fail, lenient);
|
|
722
|
+
else fail(path, `must be ${branches.map((branch) => KIND_WORDS[String(branch.type)]).join(" or ")}`);
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
if (node.type !== void 0 && !isKind(value, node.type)) {
|
|
726
|
+
fail(path, `must be ${KIND_WORDS[String(node.type)] ?? String(node.type)}`);
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
if ("const" in node && value !== node.const) {
|
|
730
|
+
fail(path, `must be ${JSON.stringify(node.const)}`);
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
if (Array.isArray(node.enum) && !node.enum.includes(value)) {
|
|
734
|
+
fail(path, `must be one of ${node.enum.join(", ")}`);
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
const message = typeof node.errorMessage === "string" ? node.errorMessage : void 0;
|
|
738
|
+
if (typeof value === "string") {
|
|
739
|
+
const length = [...value].length;
|
|
740
|
+
if (typeof node.pattern === "string" && !new RegExp(node.pattern, "u").test(value)) fail(path, message ?? `must match ${node.pattern}`);
|
|
741
|
+
else if (typeof node.maxLength === "number" && length > node.maxLength) fail(path, `must be at most ${node.maxLength} characters`);
|
|
742
|
+
else if (typeof node.minLength === "number" && length < node.minLength) fail(path, `must be at least ${node.minLength} characters`);
|
|
743
|
+
}
|
|
744
|
+
if (typeof value === "number") {
|
|
745
|
+
if (typeof node.minimum === "number" && value < node.minimum) fail(path, `must be at least ${node.minimum}`);
|
|
746
|
+
else if (typeof node.exclusiveMinimum === "number" && value <= node.exclusiveMinimum) fail(path, `must be above ${node.exclusiveMinimum}`);
|
|
747
|
+
else if (typeof node.maximum === "number" && value > node.maximum) fail(path, `must be at most ${node.maximum}`);
|
|
748
|
+
else if (typeof node.exclusiveMaximum === "number" && value >= node.exclusiveMaximum) fail(path, `must be below ${node.exclusiveMaximum}`);
|
|
749
|
+
}
|
|
750
|
+
if (node.not !== void 0 && passes(node.not, value, lenient)) fail(path, message ?? "is not allowed");
|
|
751
|
+
if (Array.isArray(value)) walkArray(node, value, path, fail, lenient);
|
|
752
|
+
else if (kindOf(value) === "object") walkObject(node, value, path, fail, lenient);
|
|
753
|
+
if (node.if !== void 0) {
|
|
754
|
+
const branch = passes(node.if, value, lenient) ? node.then : node.else;
|
|
755
|
+
if (branch !== void 0) walk(branch, value, path, fail, lenient);
|
|
756
|
+
}
|
|
757
|
+
if (Array.isArray(node.allOf)) for (const part of node.allOf) walk(part, value, path, fail, lenient);
|
|
758
|
+
}
|
|
759
|
+
function walkArray(node, value, path, fail, lenient) {
|
|
760
|
+
if (typeof node.minItems === "number" && value.length < node.minItems) fail(path, node.minItems === 1 ? "must not be empty" : `must hold at least ${node.minItems}`);
|
|
761
|
+
else if (typeof node.maxItems === "number" && value.length > node.maxItems) fail(path, `must hold at most ${node.maxItems}`);
|
|
762
|
+
if (node.items === void 0) return;
|
|
763
|
+
for (const [index, item] of value.entries()) if (typeof item === "object" && item !== null) walk(node.items, item, `${path}[${index}]`, fail, lenient);
|
|
764
|
+
else walk(node.items, item, path, (field, message) => {
|
|
765
|
+
fail(field, field === path ? `${JSON.stringify(item)} ${message}` : message);
|
|
766
|
+
}, lenient);
|
|
767
|
+
}
|
|
768
|
+
function walkObject(node, value, path, fail, lenient) {
|
|
769
|
+
const keys = Object.keys(value);
|
|
770
|
+
if (typeof node.minProperties === "number" && keys.length < node.minProperties) fail(path, node.minProperties === 1 ? "must declare at least one" : `must declare at least ${node.minProperties}`);
|
|
771
|
+
else if (typeof node.maxProperties === "number" && keys.length > node.maxProperties) fail(path, `must declare at most ${node.maxProperties}`);
|
|
772
|
+
const names = node.propertyNames;
|
|
773
|
+
const clip = typeof names?.maxLength === "number" ? names.maxLength : void 0;
|
|
774
|
+
const properties = node.properties ?? {};
|
|
775
|
+
const required = node.required ?? [];
|
|
776
|
+
for (const [key, property] of Object.entries(properties)) if (key in value) walk(property, value[key], child(path, key), fail, lenient);
|
|
777
|
+
else if (required.includes(key)) fail(child(path, key), "is required");
|
|
778
|
+
for (const key of keys) {
|
|
779
|
+
if (key in properties) continue;
|
|
780
|
+
const at = child(path, key, clip);
|
|
781
|
+
if (names !== void 0) {
|
|
782
|
+
let first;
|
|
783
|
+
walk(names, key, "", (_field, message) => {
|
|
784
|
+
first ??= message;
|
|
785
|
+
}, lenient);
|
|
786
|
+
if (first !== void 0) fail(at, first);
|
|
787
|
+
}
|
|
788
|
+
if (typeof node.additionalProperties === "object" && node.additionalProperties !== null) walk(node.additionalProperties, value[key], at, fail, lenient);
|
|
789
|
+
else if (node.additionalProperties === false && !lenient) fail(at, "is not a key the manifest knows");
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
function checkReferences(manifest, fail) {
|
|
793
|
+
const genres = manifest.genres ?? [];
|
|
794
|
+
for (const genre of new Set(genres.filter((one, index) => genres.indexOf(one) !== index))) fail("genres", `names "${genre}" twice`);
|
|
795
|
+
if (genres.filter((genre) => !MULTIPLAYER_GENRES.includes(genre)).length > 3) fail("genres", `must hold at most 3 genres, beside local-multiplayer and online-multiplayer`);
|
|
796
|
+
const players = manifest.input?.player;
|
|
797
|
+
const own = new Set(Object.keys(players?.actions ?? {}));
|
|
798
|
+
const actions = /* @__PURE__ */ new Set([...Object.keys(manifest.input?.actions ?? {}), ...own]);
|
|
799
|
+
for (const id of own) if (manifest.input?.actions?.[id] !== void 0) fail(`input.player.actions.${id}`, "is an id input.actions declares too");
|
|
800
|
+
/** Which set each key or mouse button already presses something in, so two players never share one. */
|
|
801
|
+
const taken = /* @__PURE__ */ new Map();
|
|
802
|
+
for (const [index, set] of (players?.keyboard ?? []).entries()) for (const [id, bind] of Object.entries(set.bind)) {
|
|
803
|
+
const at = `input.player.keyboard[${index}].bind.${id}`;
|
|
804
|
+
if (!own.has(id)) fail(at, `names no player action "${id}"`);
|
|
805
|
+
for (const binding of bind) {
|
|
806
|
+
const before = taken.get(binding);
|
|
807
|
+
if (before !== void 0 && before !== index) fail(at, `binds ${binding}, which keyboard set ${before} binds too: a press would be two players'`);
|
|
808
|
+
taken.set(binding, index);
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
for (const id of own) if (!((players?.actions[id]?.bind?.length ?? 0) > 0 || (players?.keyboard ?? []).some((set) => (set.bind[id]?.length ?? 0) > 0) || Object.values(controlLayouts(manifest)).some((layout) => layoutActions(layout).includes(id)))) fail(`input.player.actions.${id}`, "is pressed by nothing: bind it, give it keys in a keyboard set, or put it on an on-screen control");
|
|
812
|
+
if ((manifest.players?.local ?? 1) > 1 && players === void 0) fail("players.local", "needs input.player: the actions each of the players has");
|
|
813
|
+
for (const [name, layout] of Object.entries(controlLayouts(manifest))) for (const zone of CONTROL_ZONES) for (const [id, slot] of Object.entries(layout[zone] ?? {})) {
|
|
814
|
+
if (slot.type !== "buttons" || slot.buttons === void 0) continue;
|
|
815
|
+
const at = `input.controls.${name}.${zone}.${id}`;
|
|
816
|
+
for (const action of slot.buttons) if (!actions.has(action)) fail(`${at}.buttons`, `names no declared action "${action}"`);
|
|
817
|
+
if (typeof slot.as !== "object") continue;
|
|
818
|
+
for (const action of Object.keys(slot.as)) if (!slot.buttons.includes(action)) fail(`${at}.as`, `names no button "${action}" in this control`);
|
|
819
|
+
}
|
|
820
|
+
for (const [id, achievement] of Object.entries(declaredAchievements(manifest))) {
|
|
821
|
+
const progress = achievement.progress;
|
|
822
|
+
if (progress?.tiers === void 0) continue;
|
|
823
|
+
const way = achievementDirection(achievement);
|
|
824
|
+
let previous = null;
|
|
825
|
+
for (const [index, tier] of progress.tiers.entries()) {
|
|
826
|
+
const at = `achievements.list.${id}.progress.tiers[${index}].at`;
|
|
827
|
+
if (previous !== null && !beatsScore(way, tier.at, previous)) fail(at, `must be past ${previous}, which is the tier before it`);
|
|
828
|
+
if (beatsScore(way, tier.at, progress.target)) fail(at, `must be short of the target of ${progress.target}`);
|
|
829
|
+
previous = tier.at;
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
/** The genres that say how a game is played together rather than what it is. */
|
|
834
|
+
const MULTIPLAYER_GENRES = ["local-multiplayer", "online-multiplayer"];
|
|
835
|
+
/**
|
|
836
|
+
* Whether `a` beats `b` on a board sorted this way. Ties are not improvements.
|
|
837
|
+
*
|
|
838
|
+
* @group Score
|
|
839
|
+
*/
|
|
840
|
+
function beatsScore(direction, a, b) {
|
|
841
|
+
return direction === "asc" ? a < b : a > b;
|
|
842
|
+
}
|
|
843
|
+
/**
|
|
844
|
+
* The achievements a game declared, with the default applied.
|
|
845
|
+
*
|
|
846
|
+
* An empty map for a game that declared nothing, which reads as "this game has
|
|
847
|
+
* no achievements" — the only safe reading of silence, and what makes the tab
|
|
848
|
+
* absent rather than empty.
|
|
849
|
+
*
|
|
850
|
+
* @group Achievements
|
|
851
|
+
*/
|
|
852
|
+
function declaredAchievements(manifest) {
|
|
853
|
+
return manifest.achievements?.list ?? {};
|
|
854
|
+
}
|
|
855
|
+
/**
|
|
856
|
+
* Which way a measured achievement counts, with the default applied.
|
|
857
|
+
*
|
|
858
|
+
* One place that knows, because a host, a client and a server that disagreed
|
|
859
|
+
* would each be right about a different achievement.
|
|
860
|
+
*
|
|
861
|
+
* @group Achievements
|
|
862
|
+
*/
|
|
863
|
+
function achievementDirection(achievement) {
|
|
864
|
+
return achievement.progress?.direction ?? "desc";
|
|
865
|
+
}
|
|
866
|
+
/**
|
|
867
|
+
* The layouts of on-screen controls a game declared.
|
|
868
|
+
*
|
|
869
|
+
* An empty map for a game that declared none, which reads as "draw nothing over
|
|
870
|
+
* this game" — the only safe reading of silence, and what keeps a pad off a
|
|
871
|
+
* game written before any of this existed.
|
|
872
|
+
*
|
|
873
|
+
* @group Input
|
|
874
|
+
*/
|
|
875
|
+
function controlLayouts(manifest) {
|
|
876
|
+
return manifest.input?.controls ?? {};
|
|
877
|
+
}
|
|
878
|
+
/**
|
|
879
|
+
* Every action a layout puts on the screen, in the order the zones declare them.
|
|
880
|
+
*
|
|
881
|
+
* @group Input
|
|
882
|
+
*/
|
|
883
|
+
function layoutActions(layout) {
|
|
884
|
+
const found = [];
|
|
885
|
+
for (const zone of CONTROL_ZONES) {
|
|
886
|
+
const slots = layout[zone];
|
|
887
|
+
if (slots === void 0) continue;
|
|
888
|
+
for (const slot of Object.values(slots)) {
|
|
889
|
+
if (slot.type !== "buttons" || !Array.isArray(slot.buttons)) continue;
|
|
890
|
+
for (const action of slot.buttons) if (!found.includes(action)) found.push(action);
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
return found;
|
|
894
|
+
}
|
|
895
|
+
//#endregion
|
|
896
|
+
export { describeProblems, validateManifest };
|