@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/README.md
CHANGED
|
@@ -27,6 +27,7 @@ name and type is documented where it is declared, in the package's
|
|
|
27
27
|
- [Input](#input)
|
|
28
28
|
- [Audio](#audio)
|
|
29
29
|
- [Pause](#pause)
|
|
30
|
+
- [Match](#match)
|
|
30
31
|
- [Checklist](#checklist)
|
|
31
32
|
|
|
32
33
|
## What a game is
|
|
@@ -73,16 +74,16 @@ The SDK is not part of your game. It is **the runtime**, which whatever runs
|
|
|
73
74
|
the game serves beside it, and your page loads as its first script:
|
|
74
75
|
|
|
75
76
|
```html
|
|
76
|
-
<script src="_rt/
|
|
77
|
+
<script src="_rt/hopple.js"></script>
|
|
77
78
|
```
|
|
78
79
|
|
|
79
80
|
The runtime defines the global `Hopple`, holding everything this guide
|
|
80
81
|
describes. Because it is served rather than built into the game, a fix to the
|
|
81
82
|
SDK (input, on-screen controls, audio, layout, the loop, save) reaches every
|
|
82
|
-
game without a rebuild.
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
its own origin, and a host like itch.io under a path, with `_rt/` beside
|
|
83
|
+
game without a rebuild. Which release a game gets is its track's: the major
|
|
84
|
+
its manifest's `api` names, or `next`, built with the previews it declares. A change that would break games is a
|
|
85
|
+
new major, which a game takes by changing its `api`. Keep the address
|
|
86
|
+
relative, as above: Hopple serves a game at the root of its own origin, and a host like itch.io under a path, with `_rt/` beside
|
|
86
87
|
`index.html` either way.
|
|
87
88
|
|
|
88
89
|
- **With a bundler** (Vite, esbuild, webpack, Rollup, Parcel, Bun):
|
|
@@ -95,9 +96,7 @@ its own origin, and a host like itch.io under a path, with `_rt/` beside
|
|
|
95
96
|
(`const sdk = await Hopple.init()` in a module script or an async function).
|
|
96
97
|
|
|
97
98
|
`hopple check`, `hopple publish` and the server refuse a page that doesn't
|
|
98
|
-
load the runtime first, or that carries a copy of the SDK.
|
|
99
|
-
before the runtime keep the SDK they bundled, and get no runtime fixes until
|
|
100
|
-
they are rebuilt this way.
|
|
99
|
+
load the runtime first, or that carries a copy of the SDK.
|
|
101
100
|
|
|
102
101
|
A `hopple.config.json` can point its `"$schema"` at
|
|
103
102
|
`./node_modules/@hopple/sdk/config.schema.json` for an editor to check it; a
|
|
@@ -140,7 +139,9 @@ Options for `init()`, all optional and rarely needed:
|
|
|
140
139
|
|
|
141
140
|
| Option | Meaning |
|
|
142
141
|
|---|---|
|
|
143
|
-
| `onError` | Called with problems the game should know about but that don't reject a call: a refused analytics event, a
|
|
142
|
+
| `onError` | Called with problems the game should know about but that don't reject a call: a refused analytics event, a save value that JSON would silently change. Defaults to `console.error`. |
|
|
143
|
+
| `timeoutMs` | How long each request waits for the host. Default 10 s. The handshake always waits at least 10 s, whatever this says. |
|
|
144
|
+
| `save` | How to merge two saves made on two devices: `{ merge }`. See [Save](#save). |
|
|
144
145
|
|
|
145
146
|
The host is told about your errors too: from the moment `init()` is called,
|
|
146
147
|
the SDK reports every exception nothing caught, every promise rejection nothing
|
|
@@ -149,9 +150,6 @@ handled, and everything it passes to `onError`, with its stack. That is how
|
|
|
149
150
|
console. Nothing is suppressed: the console prints everything as before. An
|
|
150
151
|
error thrown before `init()` is called, or a script that doesn't parse, can't
|
|
151
152
|
be reported, so look in the browser console when a game never starts.
|
|
152
|
-
| `timeoutMs` | How long each request waits for the host. Default 10 s. The handshake always waits at least 10 s, whatever this says. |
|
|
153
|
-
| `transport` | A channel of your own. By default the SDK finds the host itself. |
|
|
154
|
-
| `scope` | The window to use. For tests. |
|
|
155
153
|
|
|
156
154
|
Everything else hangs off the returned object: `sdk.lifecycle`, `sdk.layout`,
|
|
157
155
|
`sdk.save`, and so on. It also has `sdk.ping()` (resolves with the round-trip
|
|
@@ -394,6 +392,27 @@ How to use it:
|
|
|
394
392
|
- **Every host accepts at least 64 KiB** (`SAVE_BYTES`). A larger save is
|
|
395
393
|
rejected with `too_large` before it is sent.
|
|
396
394
|
|
|
395
|
+
**Two devices.** A signed-in player's save follows them to every device. When
|
|
396
|
+
two devices each saved without seeing the other's progress (one was offline),
|
|
397
|
+
the host keeps both and asks the game to merge them, with the `merge` passed to
|
|
398
|
+
`init()`:
|
|
399
|
+
|
|
400
|
+
```ts
|
|
401
|
+
const sdk = await init({
|
|
402
|
+
save: {
|
|
403
|
+
merge: (mine, theirs) => ({ version: 2, state: combine(mine.state, theirs.state) }),
|
|
404
|
+
},
|
|
405
|
+
});
|
|
406
|
+
sdk.save.onMerge((merged) => restore(merged.state)); // merged while playing: play on from this
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
- `merge(mine, theirs)` is pure and answers with the save to keep in place of
|
|
410
|
+
both. Throw where the two can't be merged, and the host asks the player
|
|
411
|
+
which to keep, as it does for a game with no `merge`.
|
|
412
|
+
- Asked while the game loads, the merge is what `read()` resolves with. Asked
|
|
413
|
+
while it plays, it goes to `onMerge` listeners, and waits for the next load
|
|
414
|
+
where there are none.
|
|
415
|
+
|
|
397
416
|
| Error | Meaning |
|
|
398
417
|
|---|---|
|
|
399
418
|
| `unsupported` | This host has nowhere to store saves (`available` is `false`). |
|
|
@@ -518,7 +537,9 @@ sdk.panel.available; // false where there are none
|
|
|
518
537
|
sdk.panel.open('scores'); // returns nothing: what happens arrives through sdk.panel.on
|
|
519
538
|
```
|
|
520
539
|
|
|
521
|
-
The possible pages are `about`, `scores`, `achievements` and `input`.
|
|
540
|
+
The possible pages are `about`, `scores`, `achievements` and `input`. A game
|
|
541
|
+
that plays over the network also has `room`, the player's party, from
|
|
542
|
+
[`@hopple/sdk-preview-network`](https://www.npmjs.com/package/@hopple/sdk-preview-network).
|
|
522
543
|
|
|
523
544
|
- **Check `sdk.panel.pages` before drawing a button for a page.** It is known
|
|
524
545
|
from the start, so you can decide while laying out your menu. If the host has
|
|
@@ -601,21 +622,42 @@ Call `read()` **once per frame**. It reports presses that started and ended
|
|
|
601
622
|
since the previous call (so a tap shorter than a frame isn't lost), which means
|
|
602
623
|
a second call in the same frame sees none of them.
|
|
603
624
|
|
|
604
|
-
### Showing
|
|
625
|
+
### Showing which button to press
|
|
605
626
|
|
|
606
627
|
```ts
|
|
607
|
-
prompt.textContent = `Press ${sdk.input.
|
|
628
|
+
prompt.textContent = `Press ${sdk.input.glyph('shoot')} to shoot`; // written once
|
|
629
|
+
prompt.setAttribute('aria-label', `Press ${sdk.input.describe('shoot')} to shoot`);
|
|
630
|
+
|
|
631
|
+
ctx.font = `20px 'My Font', sans-serif, ${sdk.input.font}`; // on a canvas
|
|
632
|
+
ctx.fillText(`Press ${sdk.input.glyph('shoot')} to shoot`, x, y);
|
|
633
|
+
```
|
|
608
634
|
|
|
609
|
-
|
|
610
|
-
|
|
635
|
+
```css
|
|
636
|
+
body { font-family: 'My Font', sans-serif, hopple-keys; }
|
|
611
637
|
```
|
|
612
638
|
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
`Z`
|
|
616
|
-
on
|
|
617
|
-
the player rebinds
|
|
618
|
-
|
|
639
|
+
`sdk.input.glyph(action)` is one character, drawn by a font the SDK builds:
|
|
640
|
+
every key or button bound to the action for the device in the player's hands —
|
|
641
|
+
`[Z] / [Space]` as keycaps, a pad's face buttons as a diamond with the one to
|
|
642
|
+
press filled, the same on every controller, the on-screen button on a touch
|
|
643
|
+
screen. When the player rebinds the action or picks up another device the SDK
|
|
644
|
+
swaps the font, so text in the DOM is right without being touched, and a canvas
|
|
645
|
+
is right the next time it is painted (a canvas drawn once redraws on
|
|
646
|
+
`sdk.input.on`). `glyph('pad.start')` draws a button that is none of your
|
|
647
|
+
actions.
|
|
648
|
+
|
|
649
|
+
**Name `hopple-keys` (`sdk.input.font`) last in every font stack that prints
|
|
650
|
+
a glyph**, the canvas's included. A stack without it draws a box; one that
|
|
651
|
+
names it first changes the line height of its text in some browsers. Children
|
|
652
|
+
inherit a `body` rule, form controls don't unless they set `font: inherit`.
|
|
653
|
+
Under `hopple dev` a glyph printed anywhere else is reported to `onError`.
|
|
654
|
+
|
|
655
|
+
`sdk.input.describe(action)` is the same in words (`Z or Space`, `bottom face
|
|
656
|
+
button`), for an `aria-label` and for text drawn without fonts.
|
|
657
|
+
`sdk.input.mapping[action].label` still names one binding in text, and
|
|
658
|
+
`sdk.input.scheme` is what the player is using: `'keyboard' | 'mouse' | 'touch'
|
|
659
|
+
| 'gamepad'`. `sdk.input.on` fires when the player rebinds a key (`mapping`
|
|
660
|
+
changed) or switches device (`scheme` changed).
|
|
619
661
|
|
|
620
662
|
### On-screen controls
|
|
621
663
|
|
|
@@ -631,8 +673,74 @@ sdk.input.controls.showing; // whether controls are actually visible
|
|
|
631
673
|
- Hiding them only stops drawing them; keys and gamepads keep working.
|
|
632
674
|
- While shown, they appear in `sdk.layout.obstructions` as reserved
|
|
633
675
|
rectangles, so code that avoids the host's buttons avoids them too.
|
|
634
|
-
- They are drawn in a shadow root inside your frame, so
|
|
635
|
-
|
|
676
|
+
- They are drawn in a shadow root inside your frame, so they don't appear in
|
|
677
|
+
your DOM and your CSS only reaches them where it is meant to: through
|
|
678
|
+
`hopple-controls::part(…)` and the `--hopple-pad-*` properties (see
|
|
679
|
+
[Styling them](#styling-the-on-screen-controls)).
|
|
680
|
+
|
|
681
|
+
### Styling the on-screen controls
|
|
682
|
+
|
|
683
|
+
```css
|
|
684
|
+
hopple-controls {
|
|
685
|
+
--hopple-pad-tint: #2b1d0ecc; /* a button's fill */
|
|
686
|
+
--hopple-pad-edge: #f5c542; /* rims, the knob */
|
|
687
|
+
--hopple-pad-ink: #fff8e1; /* labels and icons */
|
|
688
|
+
--hopple-pad-radius: 14px;
|
|
689
|
+
--hopple-pad-font: 700 16px/1 'My Font', sans-serif, hopple-keys;
|
|
690
|
+
}
|
|
691
|
+
hopple-controls::part(face pressed) { translate: 0 2px; }
|
|
692
|
+
hopple-controls::part(face action-jump) { background: url(ui/jump.png) center / 70% no-repeat; }
|
|
693
|
+
```
|
|
694
|
+
|
|
695
|
+
```ts
|
|
696
|
+
sdk.input.icon('jump', 'M12 3 4 13h5v8h6v-8h5z'); // a filled path in a 24×24 box, or null
|
|
697
|
+
```
|
|
698
|
+
|
|
699
|
+
- Parts: `face`, `label` and `icon` on a button; `group` behind a group of
|
|
700
|
+
buttons; `well`, `knob`, `groove` and `arrow` on a stick. Each also carries
|
|
701
|
+
`action-<id>` and `control-<id>`, and the states `pressed`, `held`,
|
|
702
|
+
`floating` and `resting`.
|
|
703
|
+
- `<hopple-controls>` has `layout`, `sticks` and `look` attributes, and a
|
|
704
|
+
stick has `--hopple-stick-x` and `--hopple-stick-y` (−1 to 1) for a game to
|
|
705
|
+
read.
|
|
706
|
+
- Sizes (`--hopple-pad-size`, `-stick`, `-track`, `-gap`, `-inset`) are held
|
|
707
|
+
between a size a thumb can press and one that leaves the game room. How big
|
|
708
|
+
one control is next to the others is its `scale` in the manifest. What a
|
|
709
|
+
part draws never changes where a touch lands.
|
|
710
|
+
- A player can turn a game's look off, for every game or for one, and gets the
|
|
711
|
+
plain controls back: test that they still read well over the game.
|
|
712
|
+
- Under `hopple dev`, a rule that names a part or a property the controls don't
|
|
713
|
+
have is reported to `onError`.
|
|
714
|
+
|
|
715
|
+
### Several players on one device
|
|
716
|
+
|
|
717
|
+
A manifest's `input.player` declares what each local player does, and
|
|
718
|
+
`players.local` how many may play on one device: one set of actions every
|
|
719
|
+
player has a copy of, bound once for every pad, and the parts of the keyboard
|
|
720
|
+
players share (`keyboard`, each with a title and its keys).
|
|
721
|
+
|
|
722
|
+
```ts
|
|
723
|
+
sdk.input.joining = true; // the screen where players join
|
|
724
|
+
sdk.input.onPlayers((players) => drawRoster(players));
|
|
725
|
+
for (const player of sdk.input.players) { // the first player is always there
|
|
726
|
+
const now = player.read(); // once a frame, as sdk.input.read()
|
|
727
|
+
if (now.started('bomb')) dropBomb(player.slot);
|
|
728
|
+
label[player.slot].textContent = player.glyph('bomb'); // their own key or button
|
|
729
|
+
}
|
|
730
|
+
sdk.input.joining = false; // play starts
|
|
731
|
+
```
|
|
732
|
+
|
|
733
|
+
- **The first player hears every controller nobody claimed**, so a player
|
|
734
|
+
alone loses nothing between devices. Setting `joining` gives them the one
|
|
735
|
+
they last used; a join action pressed on a free pad or part of the keyboard
|
|
736
|
+
seats a new player with it, up to `mostPlayers`, and is nobody else's press.
|
|
737
|
+
- **`slot`** is a player's place from 0, kept until `leave()`.
|
|
738
|
+
**`controller`** is what they hold, or `null` once their pad went away:
|
|
739
|
+
they keep their place, and the next free pad pressed is theirs again.
|
|
740
|
+
- **`sdk.input.read()` and `onAction` answer for player actions too**, as
|
|
741
|
+
anybody pressing them, for menus any player may work.
|
|
742
|
+
- **In a match**, the device tells the match how many play on it, and
|
|
743
|
+
`mostPlayers` takes no more than the match has room for.
|
|
636
744
|
|
|
637
745
|
### Text fields
|
|
638
746
|
|
|
@@ -725,6 +833,14 @@ sdk.pause.on(async (paused) => { // the host asked; return a promise to make i
|
|
|
725
833
|
});
|
|
726
834
|
```
|
|
727
835
|
|
|
836
|
+
## Match
|
|
837
|
+
|
|
838
|
+
Play over the network, in matches: `sdk.match`. It is a preview, not stable
|
|
839
|
+
yet, so it is a package of its own,
|
|
840
|
+
[`@hopple/sdk-preview-network`](https://www.npmjs.com/package/@hopple/sdk-preview-network), which
|
|
841
|
+
the game installs and declares in its manifest as `previews.network`, at its
|
|
842
|
+
revision. A game that does not declare it has no `sdk.match`.
|
|
843
|
+
|
|
728
844
|
## Checklist
|
|
729
845
|
|
|
730
846
|
- [ ] Let a rejected `init()` through rather than catching it.
|
|
@@ -738,10 +854,11 @@ sdk.pause.on(async (paused) => { // the host asked; return a promise to make i
|
|
|
738
854
|
- [ ] Submit every run's score. Only show a rank when you get one.
|
|
739
855
|
- [ ] Share and open the panel only from a player's press.
|
|
740
856
|
- [ ] Validate `sdk.share.data` like any untrusted input.
|
|
741
|
-
- [ ] Print button
|
|
857
|
+
- [ ] Print button prompts with `sdk.input.glyph` (and `sdk.input.describe` for words), and read input once per frame.
|
|
742
858
|
- [ ] Route every sound through `sdk.audio.output` or `media`, or multiply it by `sdk.audio.volume(channel)`, and obey mute.
|
|
743
859
|
- [ ] Keep `sdk.pause` up to date, separately from the loop.
|
|
744
|
-
- [ ]
|
|
860
|
+
- [ ] For network play, install `@hopple/sdk-preview-network` and declare `previews.network`, leave the lobby and invites to the host, and start matches from a player's press.
|
|
861
|
+
- [ ] Load the runtime as the page's first script, `<script src="_rt/hopple.js"></script>`, and import `@hopple/sdk` rather than bundling a copy.
|
|
745
862
|
- [ ] Refer to every file relatively, and publish the output directory.
|
|
746
863
|
|
|
747
864
|
## License
|