@hopple/sdk 1.0.0-next.1 → 1.0.0-next.13
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 +116 -21
- package/index.d.ts +738 -111
- package/index.js +1 -1
- package/manifest.d.ts +148 -8
- package/manifest.js +82 -6
- package/manifest.md +61 -17
- package/package.json +5 -2
- package/config.schema.json +0 -954
package/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/*! @hopple/sdk
|
|
1
|
+
/*! @hopple/sdk | Copyright (c) 2026 The Hopple authors | MIT License */
|
|
2
2
|
// The SDK runs from the runtime the page loads as its first script, which
|
|
3
3
|
// defines the global `Hopple`; this forwards each export to it.
|
|
4
4
|
const runtime = globalThis.Hopple;
|
package/manifest.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//#region ../../../../../../../tmp/hopple-sdk-types-
|
|
1
|
+
//#region ../../../../../../../tmp/hopple-sdk-types-SHySdg/manifest/src/manifest.d.ts
|
|
2
2
|
/**
|
|
3
3
|
* The API a game is written against: `M.m`, a major and the least minor it
|
|
4
4
|
* needs, or `next`, the pre-releases of the next stable release.
|
|
@@ -217,6 +217,26 @@ type ScoreBoard = {
|
|
|
217
217
|
direction: ScoreDirection;
|
|
218
218
|
/** See `ScoreFormat`. Absent means `integer`. */
|
|
219
219
|
format?: ScoreFormat;
|
|
220
|
+
/**
|
|
221
|
+
* Which board, of the ones this id has named. Absent is `1`.
|
|
222
|
+
*
|
|
223
|
+
* **Raising it starts the board again from nobody.** The runs under the
|
|
224
|
+
* version before are kept and no longer counted, shown or ranked, so a board
|
|
225
|
+
* whose scores stopped being comparable — new levels, a new rule — begins
|
|
226
|
+
* again under the id the game already submits to.
|
|
227
|
+
*
|
|
228
|
+
* **Only a change to what a stored score means needs it.** The title changes
|
|
229
|
+
* freely, and so does the format between `integer` and `decimal`. Once the
|
|
230
|
+
* board holds scores, turning it around (`direction`) or writing it in
|
|
231
|
+
* another unit (to or from `duration-ms`) is refused at the same version.
|
|
232
|
+
* Going back to a version declared before brings its runs back, so it is
|
|
233
|
+
* held to what that version was.
|
|
234
|
+
*
|
|
235
|
+
* @asType integer
|
|
236
|
+
* @minimum 1
|
|
237
|
+
* @maximum 1000
|
|
238
|
+
*/
|
|
239
|
+
version?: number;
|
|
220
240
|
};
|
|
221
241
|
/**
|
|
222
242
|
* The boards this game keeps a score on.
|
|
@@ -380,6 +400,28 @@ type Achievement = {
|
|
|
380
400
|
secret?: boolean;
|
|
381
401
|
/** Present makes this measured rather than flagged. See `AchievementProgress`. */
|
|
382
402
|
progress?: AchievementProgress;
|
|
403
|
+
/**
|
|
404
|
+
* Which achievement, of the ones this id has named. Absent is `1`.
|
|
405
|
+
*
|
|
406
|
+
* **Raising it starts the achievement again for everybody.** What players
|
|
407
|
+
* reported and unlocked under the version before is kept and no longer
|
|
408
|
+
* counted, so it is earned afresh under the id the game already reports to.
|
|
409
|
+
*
|
|
410
|
+
* **Only a change to what a stored value means needs it.** The words change
|
|
411
|
+
* freely — title, detail, icon, secret — and so does the target, and a tier
|
|
412
|
+
* added after the last one: progress is kept as the number reported, which
|
|
413
|
+
* stays true when the goal moves, and a player who unlocked it keeps it. At
|
|
414
|
+
* the same version, a published achievement may not turn from a flag into a
|
|
415
|
+
* measure or back, turn its direction around, write its value in another
|
|
416
|
+
* unit (to or from `duration-ms`), or move or drop a tier it already has.
|
|
417
|
+
* Going back to a version declared before brings back what was earned under
|
|
418
|
+
* it, so it is held to what that version was.
|
|
419
|
+
*
|
|
420
|
+
* @asType integer
|
|
421
|
+
* @minimum 1
|
|
422
|
+
* @maximum 1000
|
|
423
|
+
*/
|
|
424
|
+
version?: number;
|
|
383
425
|
};
|
|
384
426
|
/**
|
|
385
427
|
* The achievements this game keeps for a player.
|
|
@@ -445,7 +487,7 @@ type InputDeviceSet = InputDevice[];
|
|
|
445
487
|
* doubled. A thumbstick is not one of these; see `InputStick`.
|
|
446
488
|
*
|
|
447
489
|
* @group Input
|
|
448
|
-
* @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))$
|
|
490
|
+
* @pattern ^(?:key\.(?!Escape$)(?=.{1,48}$)[A-Za-z][A-Za-z0-9]*|pointer\.(?:primary|secondary)|mouse\.(?:left|middle|right|wheelup|wheeldown)|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))$
|
|
449
491
|
* @errorMessage is not a binding this SDK knows (a thumbstick is read rather than bound, and Escape is the host's key)
|
|
450
492
|
*/
|
|
451
493
|
type InputBinding = string;
|
|
@@ -521,7 +563,7 @@ type PlayerBinding = string;
|
|
|
521
563
|
* mouse button.
|
|
522
564
|
*
|
|
523
565
|
* @group Input
|
|
524
|
-
* @pattern ^(?:key\.(?!Escape$)(?=.{1,48}$)[A-Za-z][A-Za-z0-9]*|mouse\.(?:left|middle|right))$
|
|
566
|
+
* @pattern ^(?:key\.(?!Escape$)(?=.{1,48}$)[A-Za-z][A-Za-z0-9]*|mouse\.(?:left|middle|right|wheelup|wheeldown))$
|
|
525
567
|
* @errorMessage must be a key or a mouse button (and Escape is the host's key)
|
|
526
568
|
*/
|
|
527
569
|
type KeyboardBinding = string;
|
|
@@ -671,6 +713,20 @@ type ControlSlot = {
|
|
|
671
713
|
as?: InputStick | Record<string, InputBinding>;
|
|
672
714
|
/** `thumbstick` only. See `ControlAxis`. Absent, the stick moves both ways. */
|
|
673
715
|
axis?: ControlAxis;
|
|
716
|
+
/**
|
|
717
|
+
* How big this control is next to the layout's others: `1.25` for a jump
|
|
718
|
+
* button a thumb lands on without looking, `0.8` for one pressed now and then.
|
|
719
|
+
* Absent, 1.
|
|
720
|
+
*
|
|
721
|
+
* A size in the layout rather than in the game's stylesheet, because whether
|
|
722
|
+
* the layout fits on a small screen is worked out from it before the
|
|
723
|
+
* controls are drawn. A game's stylesheet changes how they look, never how
|
|
724
|
+
* much of the screen they answer in.
|
|
725
|
+
*
|
|
726
|
+
* @minimum 0.75
|
|
727
|
+
* @maximum 1.5
|
|
728
|
+
*/
|
|
729
|
+
scale?: number;
|
|
674
730
|
};
|
|
675
731
|
/**
|
|
676
732
|
* The one direction an on-screen thumbstick moves in: `x` across, `y` up and
|
|
@@ -821,6 +877,53 @@ type PreviewsDeclaration = {
|
|
|
821
877
|
*/
|
|
822
878
|
network?: PreviewRevision;
|
|
823
879
|
};
|
|
880
|
+
/**
|
|
881
|
+
* What kind of game this is, as one word from a list every game shares.
|
|
882
|
+
*
|
|
883
|
+
* A closed list, because a client files games under these and has to name each
|
|
884
|
+
* one in front of a player: left to free text, one kind of game becomes three
|
|
885
|
+
* spellings of it with a game each. The list only grows, since a published
|
|
886
|
+
* manifest has to stay valid. Whoever reads one may be older than the game, and
|
|
887
|
+
* ignores a word it does not know.
|
|
888
|
+
*
|
|
889
|
+
* `local-multiplayer` and `online-multiplayer` say how a game is played rather
|
|
890
|
+
* than what it is, and only count where the manifest bears them out: see
|
|
891
|
+
* `declaredGenres`.
|
|
892
|
+
*
|
|
893
|
+
* @group Manifest
|
|
894
|
+
* @category Useful
|
|
895
|
+
*/
|
|
896
|
+
type GameGenre = 'racing' | 'shooter' | 'platformer' | 'puzzle' | 'arcade' | 'physics' | 'roguelike' | 'strategy' | 'building' | 'idle' | 'sports' | 'horror' | 'stealth' | 'fighting' | 'casual' | 'simulation' | 'adventure' | 'rhythm' | 'rpg' | 'local-multiplayer' | 'online-multiplayer';
|
|
897
|
+
/**
|
|
898
|
+
* A few seconds of real play, which Explore shows in place of the still cover,
|
|
899
|
+
* played muted, over and over. See `GameManifest.clip`.
|
|
900
|
+
*
|
|
901
|
+
* @group Manifest
|
|
902
|
+
* @category Useful
|
|
903
|
+
*/
|
|
904
|
+
type ClipDeclaration = {
|
|
905
|
+
/**
|
|
906
|
+
* The clip: the path of an MP4 file beside the config. H.264 with no sound
|
|
907
|
+
* track, 2 to 15 seconds long, its longest side at most 1280 and its
|
|
908
|
+
* shortest at most 720, saved with "fast start", and at most
|
|
909
|
+
* `CLIP_MAX_BYTES`. `clipProblems` says what is wrong with one.
|
|
910
|
+
*
|
|
911
|
+
* @pattern ^(?!/)(?!.*\.\.)[A-Za-z0-9._/-]+\.mp4$
|
|
912
|
+
* @maxLength 200
|
|
913
|
+
* @errorMessage must be the path of an .mp4 file beside the config, like "clip.mp4"
|
|
914
|
+
*/
|
|
915
|
+
video: string;
|
|
916
|
+
/**
|
|
917
|
+
* The clip's first frame, as a PNG, JPEG or WebP file beside the config,
|
|
918
|
+
* shown until the clip plays and wherever it cannot. At most
|
|
919
|
+
* `POSTER_MAX_BYTES`.
|
|
920
|
+
*
|
|
921
|
+
* @pattern ^(?!/)(?!.*\.\.)[A-Za-z0-9._/-]+\.(?:png|jpe?g|webp)$
|
|
922
|
+
* @maxLength 200
|
|
923
|
+
* @errorMessage must be the path of a .png, .jpg or .webp file beside the config, like "clip.png"
|
|
924
|
+
*/
|
|
925
|
+
poster: string;
|
|
926
|
+
};
|
|
824
927
|
/**
|
|
825
928
|
* Publishing refuses a key the manifest's API does not know; a host reading a
|
|
826
929
|
* published one ignores it (see `validateManifest`).
|
|
@@ -860,8 +963,19 @@ type GameManifest = {
|
|
|
860
963
|
* @errorMessage must be a handle: lowercase letters, digits and hyphens
|
|
861
964
|
*/
|
|
862
965
|
owner?: string;
|
|
966
|
+
/**
|
|
967
|
+
* `false` for a game that is never to be uploaded: an example, a test, a
|
|
968
|
+
* game kept to one machine. `hopple dev` and `hopple check` still run it;
|
|
969
|
+
* `hopple upload` and `hopple publish` refuse it, and so does a server it
|
|
970
|
+
* is sent to another way. Absent, it may be published.
|
|
971
|
+
*
|
|
972
|
+
* @category Useful
|
|
973
|
+
*/
|
|
974
|
+
publishable?: boolean;
|
|
863
975
|
/**
|
|
864
976
|
* The game's own version, `MAJOR.MINOR.PATCH`. Ours to read, never to parse for meaning.
|
|
977
|
+
* People see it beside the build the server numbers each upload with, and
|
|
978
|
+
* each published version needs one no other published version of the game has.
|
|
865
979
|
*
|
|
866
980
|
* @category Important
|
|
867
981
|
* @pattern ^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$
|
|
@@ -885,20 +999,46 @@ type GameManifest = {
|
|
|
885
999
|
*/
|
|
886
1000
|
description?: string;
|
|
887
1001
|
/**
|
|
888
|
-
*
|
|
889
|
-
*
|
|
1002
|
+
* What kind of game it is, the main one first. Read by whoever files it in a
|
|
1003
|
+
* catalogue, which never runs it. At most `GENRE_MAX`, not counting
|
|
1004
|
+
* `local-multiplayer` and `online-multiplayer`: a game that is every kind
|
|
1005
|
+
* tells a player nothing. The publisher's claim, taken at its word, except
|
|
1006
|
+
* for those two. See `GameGenre`.
|
|
1007
|
+
*
|
|
1008
|
+
* @category Useful
|
|
1009
|
+
* @minItems 1
|
|
1010
|
+
* @maxItems 5
|
|
1011
|
+
*/
|
|
1012
|
+
genres?: GameGenre[];
|
|
1013
|
+
/**
|
|
1014
|
+
* A picture of the game: its cover on Explore's shelves and in a player's
|
|
1015
|
+
* inbox, and what a link to it shows wherever it is pasted — a chat, a post,
|
|
1016
|
+
* a message.
|
|
890
1017
|
*
|
|
891
1018
|
* The path of a PNG, JPEG or WebP file, relative to the config the manifest
|
|
892
1019
|
* is in, and at most `COVER_MAX_BYTES`. `hopple publish` uploads it beside
|
|
893
|
-
* the game
|
|
894
|
-
*
|
|
1020
|
+
* the game, and a server takes no upload without one: only a game that is
|
|
1021
|
+
* never uploaded leaves it out. 1200 × 630 is the shape every platform shows
|
|
1022
|
+
* whole; `hopple smoke --cover` makes one from the game itself.
|
|
895
1023
|
*
|
|
896
|
-
* @category
|
|
1024
|
+
* @category Important
|
|
897
1025
|
* @pattern ^(?!/)(?!.*\.\.)[A-Za-z0-9._/-]+\.(?:png|jpe?g|webp)$
|
|
898
1026
|
* @maxLength 200
|
|
899
1027
|
* @errorMessage must be the path of a .png, .jpg or .webp file beside the config, like "cover.png"
|
|
900
1028
|
*/
|
|
901
1029
|
cover?: string;
|
|
1030
|
+
/**
|
|
1031
|
+
* A few seconds of real play, which Explore shows in place of the still
|
|
1032
|
+
* `cover`, played muted. `poster` is the clip's first frame, shown until it
|
|
1033
|
+
* plays. The clip is an H.264 MP4 with no sound track, 2 to 15 seconds long,
|
|
1034
|
+
* no larger than 1280 × 720 either way up, saved with "fast start", and at
|
|
1035
|
+
* most `CLIP_MAX_BYTES`; the poster at most `POSTER_MAX_BYTES`. Both are
|
|
1036
|
+
* paths beside the config, and `hopple publish` uploads them with the game.
|
|
1037
|
+
* See `ClipDeclaration`.
|
|
1038
|
+
*
|
|
1039
|
+
* @category Useful
|
|
1040
|
+
*/
|
|
1041
|
+
clip?: ClipDeclaration;
|
|
902
1042
|
/** @category Important */
|
|
903
1043
|
orientation: Orientation;
|
|
904
1044
|
/**
|
package/manifest.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/*! @hopple/sdk 1.0.0-next.
|
|
1
|
+
/*! @hopple/sdk 1.0.0-next.13 | Copyright (c) 2026 The Hopple authors | MIT License */
|
|
2
2
|
//#region ../manifest/src/manifest.schema.ts
|
|
3
3
|
const GameManifest = {
|
|
4
4
|
"type": "object",
|
|
@@ -16,6 +16,7 @@ const GameManifest = {
|
|
|
16
16
|
"maxLength": 64,
|
|
17
17
|
"errorMessage": "must be a handle: lowercase letters, digits and hyphens"
|
|
18
18
|
},
|
|
19
|
+
"publishable": { "type": "boolean" },
|
|
19
20
|
"version": {
|
|
20
21
|
"type": "string",
|
|
21
22
|
"pattern": "^\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?$",
|
|
@@ -33,12 +34,19 @@ const GameManifest = {
|
|
|
33
34
|
"maxLength": 500,
|
|
34
35
|
"errorMessage": "must not be blank"
|
|
35
36
|
},
|
|
37
|
+
"genres": {
|
|
38
|
+
"type": "array",
|
|
39
|
+
"items": { "$ref": "#/definitions/GameGenre" },
|
|
40
|
+
"minItems": 1,
|
|
41
|
+
"maxItems": 5
|
|
42
|
+
},
|
|
36
43
|
"cover": {
|
|
37
44
|
"type": "string",
|
|
38
45
|
"pattern": "^(?!/)(?!.*\\.\\.)[A-Za-z0-9._/-]+\\.(?:png|jpe?g|webp)$",
|
|
39
46
|
"maxLength": 200,
|
|
40
47
|
"errorMessage": "must be the path of a .png, .jpg or .webp file beside the config, like \"cover.png\""
|
|
41
48
|
},
|
|
49
|
+
"clip": { "$ref": "#/definitions/ClipDeclaration" },
|
|
42
50
|
"orientation": { "$ref": "#/definitions/Orientation" },
|
|
43
51
|
"layout": { "$ref": "#/definitions/LayoutDeclaration" },
|
|
44
52
|
"analytics": { "$ref": "#/definitions/AnalyticsDeclaration" },
|
|
@@ -65,6 +73,51 @@ const ApiVersion = {
|
|
|
65
73
|
"pattern": "^(?:(?:0|[1-9][0-9]*)\\.(?:0|[1-9][0-9]*)|next)$",
|
|
66
74
|
"errorMessage": "must be the API the game is written against, like \"1.0\", or \"next\""
|
|
67
75
|
};
|
|
76
|
+
const GameGenre = {
|
|
77
|
+
"type": "string",
|
|
78
|
+
"enum": [
|
|
79
|
+
"racing",
|
|
80
|
+
"shooter",
|
|
81
|
+
"platformer",
|
|
82
|
+
"puzzle",
|
|
83
|
+
"arcade",
|
|
84
|
+
"physics",
|
|
85
|
+
"roguelike",
|
|
86
|
+
"strategy",
|
|
87
|
+
"building",
|
|
88
|
+
"idle",
|
|
89
|
+
"sports",
|
|
90
|
+
"horror",
|
|
91
|
+
"stealth",
|
|
92
|
+
"fighting",
|
|
93
|
+
"casual",
|
|
94
|
+
"simulation",
|
|
95
|
+
"adventure",
|
|
96
|
+
"rhythm",
|
|
97
|
+
"rpg",
|
|
98
|
+
"local-multiplayer",
|
|
99
|
+
"online-multiplayer"
|
|
100
|
+
]
|
|
101
|
+
};
|
|
102
|
+
const ClipDeclaration = {
|
|
103
|
+
"type": "object",
|
|
104
|
+
"properties": {
|
|
105
|
+
"video": {
|
|
106
|
+
"type": "string",
|
|
107
|
+
"pattern": "^(?!/)(?!.*\\.\\.)[A-Za-z0-9._/-]+\\.mp4$",
|
|
108
|
+
"maxLength": 200,
|
|
109
|
+
"errorMessage": "must be the path of an .mp4 file beside the config, like \"clip.mp4\""
|
|
110
|
+
},
|
|
111
|
+
"poster": {
|
|
112
|
+
"type": "string",
|
|
113
|
+
"pattern": "^(?!/)(?!.*\\.\\.)[A-Za-z0-9._/-]+\\.(?:png|jpe?g|webp)$",
|
|
114
|
+
"maxLength": 200,
|
|
115
|
+
"errorMessage": "must be the path of a .png, .jpg or .webp file beside the config, like \"clip.png\""
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
"required": ["video", "poster"],
|
|
119
|
+
"additionalProperties": false
|
|
120
|
+
};
|
|
68
121
|
const Orientation = {
|
|
69
122
|
"type": "string",
|
|
70
123
|
"enum": [
|
|
@@ -153,7 +206,12 @@ const ScoreBoard = {
|
|
|
153
206
|
"errorMessage": "must not be blank"
|
|
154
207
|
},
|
|
155
208
|
"direction": { "$ref": "#/definitions/ScoreDirection" },
|
|
156
|
-
"format": { "$ref": "#/definitions/ScoreFormat" }
|
|
209
|
+
"format": { "$ref": "#/definitions/ScoreFormat" },
|
|
210
|
+
"version": {
|
|
211
|
+
"type": "integer",
|
|
212
|
+
"minimum": 1,
|
|
213
|
+
"maximum": 1e3
|
|
214
|
+
}
|
|
157
215
|
},
|
|
158
216
|
"required": ["title", "direction"],
|
|
159
217
|
"additionalProperties": false
|
|
@@ -208,7 +266,12 @@ const Achievement = {
|
|
|
208
266
|
"errorMessage": "must not be blank"
|
|
209
267
|
},
|
|
210
268
|
"secret": { "type": "boolean" },
|
|
211
|
-
"progress": { "$ref": "#/definitions/AchievementProgress" }
|
|
269
|
+
"progress": { "$ref": "#/definitions/AchievementProgress" },
|
|
270
|
+
"version": {
|
|
271
|
+
"type": "integer",
|
|
272
|
+
"minimum": 1,
|
|
273
|
+
"maximum": 1e3
|
|
274
|
+
}
|
|
212
275
|
},
|
|
213
276
|
"required": ["title"],
|
|
214
277
|
"additionalProperties": false
|
|
@@ -332,7 +395,7 @@ const InputBindings = {
|
|
|
332
395
|
};
|
|
333
396
|
const InputBinding = {
|
|
334
397
|
"type": "string",
|
|
335
|
-
"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))$",
|
|
398
|
+
"pattern": "^(?:key\\.(?!Escape$)(?=.{1,48}$)[A-Za-z][A-Za-z0-9]*|pointer\\.(?:primary|secondary)|mouse\\.(?:left|middle|right|wheelup|wheeldown)|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))$",
|
|
336
399
|
"errorMessage": "is not a binding this SDK knows (a thumbstick is read rather than bound, and Escape is the host's key)"
|
|
337
400
|
};
|
|
338
401
|
const ControlLayout = {
|
|
@@ -372,7 +435,12 @@ const ControlSlot = {
|
|
|
372
435
|
"type": "object",
|
|
373
436
|
"additionalProperties": { "$ref": "#/definitions/InputBinding" }
|
|
374
437
|
}] },
|
|
375
|
-
"axis": { "$ref": "#/definitions/ControlAxis" }
|
|
438
|
+
"axis": { "$ref": "#/definitions/ControlAxis" },
|
|
439
|
+
"scale": {
|
|
440
|
+
"type": "number",
|
|
441
|
+
"minimum": .75,
|
|
442
|
+
"maximum": 1.5
|
|
443
|
+
}
|
|
376
444
|
},
|
|
377
445
|
"required": ["type"],
|
|
378
446
|
"additionalProperties": false,
|
|
@@ -412,6 +480,8 @@ const InputStick = {
|
|
|
412
480
|
const definitions = {
|
|
413
481
|
GameManifest,
|
|
414
482
|
ApiVersion,
|
|
483
|
+
GameGenre,
|
|
484
|
+
ClipDeclaration,
|
|
415
485
|
Orientation,
|
|
416
486
|
LayoutDeclaration,
|
|
417
487
|
InsetMode,
|
|
@@ -523,7 +593,7 @@ const definitions = {
|
|
|
523
593
|
},
|
|
524
594
|
KeyboardBinding: {
|
|
525
595
|
"type": "string",
|
|
526
|
-
"pattern": "^(?:key\\.(?!Escape$)(?=.{1,48}$)[A-Za-z][A-Za-z0-9]*|mouse\\.(?:left|middle|right))$",
|
|
596
|
+
"pattern": "^(?:key\\.(?!Escape$)(?=.{1,48}$)[A-Za-z][A-Za-z0-9]*|mouse\\.(?:left|middle|right|wheelup|wheeldown))$",
|
|
527
597
|
"errorMessage": "must be a key or a mouse button (and Escape is the host's key)"
|
|
528
598
|
},
|
|
529
599
|
PlayersDeclaration: {
|
|
@@ -573,6 +643,7 @@ const definitions = {
|
|
|
573
643
|
};
|
|
574
644
|
AchievementProgress.properties.target.maximum;
|
|
575
645
|
GameManifest.required;
|
|
646
|
+
GameGenre.enum;
|
|
576
647
|
/**
|
|
577
648
|
* The zones a layout places controls in, in the order they are drawn.
|
|
578
649
|
*
|
|
@@ -750,6 +821,9 @@ function walkObject(node, value, path, fail, lenient) {
|
|
|
750
821
|
}
|
|
751
822
|
}
|
|
752
823
|
function checkReferences(manifest, fail) {
|
|
824
|
+
const genres = manifest.genres ?? [];
|
|
825
|
+
for (const genre of new Set(genres.filter((one, index) => genres.indexOf(one) !== index))) fail("genres", `names "${genre}" twice`);
|
|
826
|
+
if (genres.filter((genre) => !MULTIPLAYER_GENRES.includes(genre)).length > 3) fail("genres", `must hold at most 3 genres, beside local-multiplayer and online-multiplayer`);
|
|
753
827
|
const players = manifest.input?.player;
|
|
754
828
|
const own = new Set(Object.keys(players?.actions ?? {}));
|
|
755
829
|
const actions = /* @__PURE__ */ new Set([...Object.keys(manifest.input?.actions ?? {}), ...own]);
|
|
@@ -787,6 +861,8 @@ function checkReferences(manifest, fail) {
|
|
|
787
861
|
}
|
|
788
862
|
}
|
|
789
863
|
}
|
|
864
|
+
/** The genres that say how a game is played together rather than what it is. */
|
|
865
|
+
const MULTIPLAYER_GENRES = ["local-multiplayer", "online-multiplayer"];
|
|
790
866
|
/**
|
|
791
867
|
* Whether `a` beats `b` on a board sorted this way. Ties are not improvements.
|
|
792
868
|
*
|
package/manifest.md
CHANGED
|
@@ -35,6 +35,7 @@ leave `api` and `previews` out: the CLI fills `api` from the installed
|
|
|
35
35
|
| `version` | `MAJOR.MINOR.PATCH`, optional `-prerelease` | The game's own version. Shown to the game as `sdk.info.game.version`. |
|
|
36
36
|
| `title` | at most 60 characters | What players see. |
|
|
37
37
|
| `description` | optional; at most 500 characters | A sentence or two shown beside the title. |
|
|
38
|
+
| `genres` | optional; see [`genres`](#genres) | What kind of game it is, the main one first. |
|
|
38
39
|
| `orientation` | `portrait`, `landscape` or `any` | The shape the game is designed for. Hosts lay the game out to match. |
|
|
39
40
|
| `audio` | `null`, or an object | Whether the game makes sound, and its volume channels. See [`audio`](#audio). |
|
|
40
41
|
|
|
@@ -54,15 +55,17 @@ same function, so they agree on whether a manifest is valid.
|
|
|
54
55
|
|
|
55
56
|
### In an editor
|
|
56
57
|
|
|
57
|
-
|
|
58
|
-
as `config.schema.json`, for completion, hover docs and errors as
|
|
59
|
-
JSON config can point at it:
|
|
58
|
+
The `hopple` CLI ships the hopple config's JSON Schema, the manifest's
|
|
59
|
+
included, as `config.schema.json`, for completion, hover docs and errors as
|
|
60
|
+
you type. A JSON config in a project with `hopple` installed can point at it:
|
|
60
61
|
|
|
61
62
|
```json
|
|
62
|
-
{ "$schema": "./node_modules
|
|
63
|
+
{ "$schema": "./node_modules/hopple/config.schema.json", "page": "dist/index.html", "manifest": { ... } }
|
|
63
64
|
```
|
|
64
65
|
|
|
65
|
-
A TypeScript config gets the same checks from its type,
|
|
66
|
+
A TypeScript config gets the same checks from its type,
|
|
67
|
+
`import type { HoppleConfig } from 'hopple'`, whose manifest is the type of
|
|
68
|
+
the installed `@hopple/sdk`.
|
|
66
69
|
|
|
67
70
|
A few rules relate one part of a manifest to another, which JSON Schema can't
|
|
68
71
|
express: buttons must name declared actions, a control's `as` must name its
|
|
@@ -141,6 +144,7 @@ A game without this key can still use `sdk.save`.
|
|
|
141
144
|
| `title` | yes | What players see. At most 40 characters. |
|
|
142
145
|
| `direction` | yes | `desc`: higher is better. `asc`: lower is better (times, strokes). |
|
|
143
146
|
| `format` | no | `integer` (default), `decimal`, or `duration-ms` (so `224310` shows as `3:44.31`). |
|
|
147
|
+
| `version` | no | 1 by default. Raising it starts the board again; see [Changing the manifest in a new version](#changing-the-manifest-in-a-new-version). |
|
|
144
148
|
|
|
145
149
|
`boards` is a map even for one board, so a game can add a second one later
|
|
146
150
|
without changing the meaning of the first. At most 16 boards.
|
|
@@ -179,6 +183,9 @@ There are two kinds:
|
|
|
179
183
|
`secret: true` hides the title and detail from players until it is earned.
|
|
180
184
|
The manifest is public, so this only stops spoilers.
|
|
181
185
|
|
|
186
|
+
`version` is 1 by default. Raising it starts the achievement again for
|
|
187
|
+
everybody; see [Changing the manifest in a new version](#changing-the-manifest-in-a-new-version).
|
|
188
|
+
|
|
182
189
|
Limits: 128 achievements, titles up to 40 characters, details up to 120, icons
|
|
183
190
|
up to 8 characters, 8 tiers.
|
|
184
191
|
|
|
@@ -219,7 +226,8 @@ up to 8 characters, 8 tiers.
|
|
|
219
226
|
`buttons` (naming actions) arranged as a `row`, `column`, `diamond` or
|
|
220
227
|
`cluster`. An unknown shape is drawn as a column. A stick held to an axis is
|
|
221
228
|
drawn as a track and always reads 0 on the other axis; a real pad's stick
|
|
222
|
-
still reports both.
|
|
229
|
+
still reports both. Any control may have a `scale` from 0.75 to 1.5, its
|
|
230
|
+
size next to the layout's others (1 when left out).
|
|
223
231
|
|
|
224
232
|
Binding names:
|
|
225
233
|
|
|
@@ -228,6 +236,7 @@ Binding names:
|
|
|
228
236
|
| `key.KeyZ` | A physical key, by `KeyboardEvent.code`. `KeyA` is the same physical key on every keyboard layout. |
|
|
229
237
|
| `pointer.primary` | A left click or a tap. (`pointer.secondary`: a right click.) |
|
|
230
238
|
| `mouse.left` | A left click only, not a tap. Also `mouse.middle`, `mouse.right`. |
|
|
239
|
+
| `mouse.wheelup`, `mouse.wheeldown` | A step of the mouse wheel: a press and a release for each notch, and for every stretch of a trackpad swipe. |
|
|
231
240
|
| `touch.tap` | A tap only, not a click. |
|
|
232
241
|
| `pad.south` | The bottom face button of either cluster, on any gamepad or on-screen pad. |
|
|
233
242
|
| `pad.right.south` | The bottom right face button (A on Xbox, cross on PlayStation). |
|
|
@@ -295,6 +304,31 @@ Each channel gets its own labelled volume slider in the host's sound controls.
|
|
|
295
304
|
`title` is required: it is the slider's label. `master` is reserved, since
|
|
296
305
|
every host already has a master volume. At most 8 channels.
|
|
297
306
|
|
|
307
|
+
### genres
|
|
308
|
+
|
|
309
|
+
```json
|
|
310
|
+
"genres": ["puzzle", "shooter", "online-multiplayer"]
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
What kind of game this is, the main one first: at least one, and at most 3
|
|
314
|
+
beside the two multiplayer words, none twice. A game with none declared is
|
|
315
|
+
filed by what its title and description say, and most often less well.
|
|
316
|
+
|
|
317
|
+
The words are `racing`, `shooter`, `platformer`, `puzzle`, `arcade`, `physics`,
|
|
318
|
+
`roguelike`, `strategy`, `building`, `idle`, `sports`, `horror`, `stealth`,
|
|
319
|
+
`fighting`, `casual`, `simulation`, `adventure`, `rhythm` and `rpg`, and two
|
|
320
|
+
that say how it is played together:
|
|
321
|
+
|
|
322
|
+
- **`local-multiplayer`**: several people on one device. Needs `players.local`
|
|
323
|
+
above 1.
|
|
324
|
+
- **`online-multiplayer`**: over the network. Needs `previews.network`.
|
|
325
|
+
|
|
326
|
+
Either of those without what it needs is not refused: the game is published,
|
|
327
|
+
that word is ignored, and `hopple check`, `hopple upload` and `hopple publish`
|
|
328
|
+
warn about it. They warn the other way round too, for a game that declares
|
|
329
|
+
`genres` and leaves out a way of playing together its manifest shows, since it
|
|
330
|
+
then is not listed as one. Clients group the two under one shelf.
|
|
331
|
+
|
|
298
332
|
### previews
|
|
299
333
|
|
|
300
334
|
```json
|
|
@@ -327,17 +361,27 @@ approval and links, and an upload declaring it is refused.
|
|
|
327
361
|
|
|
328
362
|
## Changing the manifest in a new version
|
|
329
363
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
364
|
+
Every board and every achievement has a `version`, 1 when left out. The
|
|
365
|
+
server refuses a new version of the game whose manifest would change what data
|
|
366
|
+
players already have means, and the way out is always to raise that board's or
|
|
367
|
+
achievement's `version`: it starts again from nobody under the same id, and
|
|
368
|
+
what was stored under the version before is kept but no longer counted.
|
|
369
|
+
|
|
370
|
+
- **Boards and achievements can be added and dropped freely.** What players
|
|
371
|
+
stored under one that is dropped is kept, and comes back if it is declared
|
|
372
|
+
again.
|
|
373
|
+
- **A board's title can change, and so can its format between `integer` and
|
|
374
|
+
`decimal`.** Once it has scores, flipping its `direction` or moving its
|
|
375
|
+
format to or from `duration-ms` needs a new `version`.
|
|
376
|
+
- **An achievement's title, detail, icon and secret flag can change, and so can
|
|
377
|
+
its target; tiers can be added after the last one.** Progress is kept as the
|
|
378
|
+
number reported, so a player at 12 of 12 is at 12 of 25 when the target is
|
|
379
|
+
raised, and keeps the unlock; when it is lowered past them, they have it.
|
|
380
|
+
Turning a flag into a measured achievement or back, flipping `direction`,
|
|
381
|
+
moving its format to or from `duration-ms`, or moving or dropping a tier it
|
|
382
|
+
already has needs a new `version`, whether or not anybody holds it yet.
|
|
383
|
+
- **A version declared again is held to what it was**, since what was stored
|
|
384
|
+
under it comes back.
|
|
341
385
|
|
|
342
386
|
Input action ids and audio channel ids may change freely. A player's remapped
|
|
343
387
|
keys or volume settings for an id that no longer exists are simply dropped.
|
package/package.json
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hopple/sdk",
|
|
3
|
-
"version": "1.0.0-next.
|
|
3
|
+
"version": "1.0.0-next.13",
|
|
4
4
|
"description": "The Hopple SDK: what a game imports to talk to the host that runs it, forwarded to the runtime its page loads.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://hopple.app",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/pamarit/hopple.git"
|
|
10
|
+
},
|
|
7
11
|
"keywords": [
|
|
8
12
|
"hopple",
|
|
9
13
|
"game",
|
|
@@ -25,7 +29,6 @@
|
|
|
25
29
|
"types": "./manifest.d.ts",
|
|
26
30
|
"default": "./manifest.js"
|
|
27
31
|
},
|
|
28
|
-
"./config.schema.json": "./config.schema.json",
|
|
29
32
|
"./package.json": "./package.json"
|
|
30
33
|
},
|
|
31
34
|
"main": "./index.js",
|