@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 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/1/hopple.js"></script>
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. `_rt/1/` is the newest release of major 1; a change
83
- that would break games is a new major, which a game takes by changing its
84
- tag. Keep the address relative, as above: Hopple serves a game at the root of
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. Games published
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 shim that threw, a save value that JSON would silently change. Defaults to `console.error`. |
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 the right button names
625
+ ### Showing which button to press
605
626
 
606
627
  ```ts
607
- prompt.textContent = `Press ${sdk.input.mapping.shoot?.label ?? 'shoot'} to shoot`;
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
- sdk.input.on((now, previous) => redrawPrompts()); // now and previous: { mapping, scheme }
610
- sdk.input.scheme; // 'keyboard' | 'mouse' | 'touch' | 'gamepad': what the player is using
635
+ ```css
636
+ body { font-family: 'My Font', sans-serif, hopple-keys; }
611
637
  ```
612
638
 
613
- Always print labels from `sdk.input.mapping[action].label`, never from your
614
- manifest. The label is what is written on whatever the player would press:
615
- `Z` on a keyboard, `A` on a gamepad, and the manifest's own `label` on an
616
- on-screen button. It follows the player's remapping. `sdk.input.on` fires when
617
- the player rebinds a key (`mapping` changed) or switches device (`scheme`
618
- changed).
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 your CSS can't affect
635
- them and they don't appear in your DOM.
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 labels from `sdk.input.mapping`, and read input once per frame.
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
- - [ ] Load the runtime as the page's first script, `<script src="_rt/1/hopple.js"></script>`, and import `@hopple/sdk` rather than bundling a copy.
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