@tastic/hud 0.14.2 → 0.16.0
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 +117 -80
- package/dist/chrome/index.d.mts +240 -0
- package/dist/chrome/index.d.ts +240 -0
- package/dist/chrome/index.js +772 -0
- package/dist/chrome/index.mjs +44 -0
- package/dist/chunk-JXRBXQLQ.mjs +645 -0
- package/dist/guide/index.js +3 -3
- package/dist/guide/index.mjs +3 -3
- package/dist/index.d.mts +21 -252
- package/dist/index.d.ts +21 -252
- package/dist/index.js +23 -23
- package/dist/index.mjs +186 -791
- package/package.json +8 -1
- package/src/AchievementCatalogSection.tsx +1 -1
- package/src/AchievementRow.tsx +9 -9
- package/src/BaseSettingsDialog.tsx +31 -31
- package/src/BaseStatsScreen.tsx +13 -13
- package/src/ConfirmDialog.tsx +2 -2
- package/src/ControlSchemePicker.tsx +9 -9
- package/src/CornerActionButtons.tsx +18 -10
- package/src/CornerStatusBadges.tsx +8 -8
- package/src/InlineColorPicker.tsx +30 -30
- package/src/LabeledDropdown.tsx +8 -8
- package/src/PlayerSetupPanel.tsx +25 -25
- package/src/PopoverBody.tsx +16 -16
- package/src/PressAwayOverlay.tsx +2 -2
- package/src/ReadyButton.tsx +3 -3
- package/src/SectionedDropdown.tsx +31 -31
- package/src/SharedActionBand.tsx +6 -6
- package/src/SkiaGate.tsx +5 -5
- package/src/SkiaGate.web.tsx +2 -2
- package/src/StaggeredWord.tsx +9 -9
- package/src/StatRow.tsx +3 -3
- package/src/StatSection.tsx +3 -3
- package/src/TriggerGauge.tsx +54 -54
- package/src/TriggerGaugeHost.tsx +2 -2
- package/src/chrome/index.ts +22 -0
- package/src/fonts.ts +1 -1
- package/src/guide/DirectionKeysHint.tsx +3 -3
- package/src/guide/GuideProvider.tsx +7 -7
- package/src/guide/HowToPlayDialog.tsx +7 -7
- package/src/guide/SeatDiagram.tsx +3 -3
- package/src/guide/SwipeHint.tsx +4 -4
- package/src/guide/guideSlice.ts +7 -7
- package/src/guide/index.ts +4 -3
- package/src/guide/types.ts +1 -1
- package/src/index.ts +1 -1
- package/src/loadSkiaWeb.ts +2 -2
- package/src/loadSkiaWeb.web.ts +2 -2
- package/src/overlayCard.ts +3 -3
- package/src/useAutoAlign.ts +23 -23
- package/src/usePopoverHost.ts +1 -1
- package/src/useZoneClampedAlign.ts +23 -23
package/README.md
CHANGED
|
@@ -1,61 +1,67 @@
|
|
|
1
1
|
# @tastic/hud
|
|
2
2
|
|
|
3
|
-
Visual component kit for local-multiplayer React Native games
|
|
3
|
+
Visual component kit for local-multiplayer React Native games - inline (non-modal) popovers,
|
|
4
4
|
dropdowns, color pickers, gauges, ready buttons, and dialogs. Every interactive piece stays scoped
|
|
5
5
|
to one player's own zone on a shared screen, so it never blocks the rest of the screen the way a
|
|
6
6
|
full-screen modal would.
|
|
7
7
|
|
|
8
8
|
Depends on [`@tastic/core`](https://github.com/jayrdeaton/react-native-game-core) for live device-
|
|
9
|
-
rotation tracking (see Rotation below)
|
|
9
|
+
rotation tracking (see Rotation below) - every popover/dialog here reads it directly, so it stays
|
|
10
10
|
legible even rotated 180° inside a [`@tastic/split-screen`](https://github.com/jayrdeaton/react-native-split-screen)
|
|
11
11
|
zone (that package's own two-player layout engine is what actually owns the rotated zone; this kit
|
|
12
12
|
and split-screen don't depend on each other, they just both sit on `@tastic/core`).
|
|
13
13
|
|
|
14
14
|
## Why not a modal?
|
|
15
15
|
|
|
16
|
-
A full-screen `Dialog` blocks the whole screen. In a two-player lobby that's a non-starter
|
|
16
|
+
A full-screen `Dialog` blocks the whole screen. In a two-player lobby that's a non-starter - player
|
|
17
17
|
2 can't touch anything while player 1's color picker is open. Every popover here renders **inline**,
|
|
18
18
|
as an absolutely-positioned sibling of its own trigger, so it never blocks anything outside its own
|
|
19
19
|
corner of the screen.
|
|
20
20
|
|
|
21
21
|
## What's in here
|
|
22
22
|
|
|
23
|
-
- **`usePopoverHost()
|
|
23
|
+
- **`usePopoverHost()`**: tracks which *one* popover (by id) is open within a group, so opening one
|
|
24
24
|
closes any sibling already open in the same group.
|
|
25
|
-
- **`PopoverBody
|
|
25
|
+
- **`PopoverBody`**: the low-level popover shell (position, caret, alignment). `SectionedDropdown`
|
|
26
26
|
and `InlineColorPicker` are built on it.
|
|
27
|
-
- **`useAutoAlign
|
|
28
|
-
(left/right/center, above/below) keeps a popover from overflowing a screen edge
|
|
27
|
+
- **`useAutoAlign`**: measures a trigger's actual on-screen position and picks whichever alignment
|
|
28
|
+
(left/right/center, above/below) keeps a popover from overflowing a screen edge - including a
|
|
29
29
|
`maxHeight` for the caller to cap content and scroll when neither direction has enough room.
|
|
30
|
-
- **`SectionedDropdown
|
|
30
|
+
- **`SectionedDropdown`**: a popover holding any mix of single-select ("pick one", radio-style) and
|
|
31
31
|
multi-select ("pick some", checkbox-style, optional all/clear footer) sections, divided by rules.
|
|
32
|
-
- **`InlineColorPicker
|
|
32
|
+
- **`InlineColorPicker`**: swatch-grid color popover, columns auto-clamped to 4 or 5 (the default
|
|
33
33
|
`swatches` list is exactly 20 colors, so that's the only range that fills every row completely),
|
|
34
34
|
with an optional "taken" color (shown disabled, or swappable). The trigger itself is a `size`-
|
|
35
35
|
configurable circle showing either an `icon` or a short `tag` (e.g. a saved player identity's own
|
|
36
|
-
initial or emoji)
|
|
36
|
+
initial or emoji) - `tag` renders at a larger ratio automatically when it's an emoji, since an
|
|
37
37
|
emoji glyph reads visually smaller than a bold letter at the same font size.
|
|
38
|
-
- **`TriggerGauge
|
|
38
|
+
- **`TriggerGauge`**: decorative ring of tick marks around a trigger, showing which option(s) are
|
|
39
39
|
active without opening the popover.
|
|
40
|
-
- **`ReadyButton
|
|
41
|
-
- **`PlayerSetupPanel
|
|
40
|
+
- **`ReadyButton`**: a per-player or standalone ready toggle.
|
|
41
|
+
- **`PlayerSetupPanel`**: one panel per player slot in a local-multiplayer loadout/lobby screen: a
|
|
42
42
|
name header (a saved-profile switcher for a human seat, a CPU-difficulty `LabeledDropdown` for a
|
|
43
43
|
non-human one, or a plain label when neither applies), a color picker (`InlineColorPicker`)
|
|
44
44
|
optionally paired with a caller-rendered `secondPicker` slot, and a `ReadyButton` for human seats.
|
|
45
45
|
Ported from five apps' own near-identical `PlayerSetupPanel.tsx`/`LobbyPlayerPanel.tsx`
|
|
46
46
|
(AirHockey, BoxHockey, Pong, LightCycles, Snake), which had all independently converged on this
|
|
47
47
|
exact skeleton. See "The secondPicker slot" below before wiring up that one prop.
|
|
48
|
-
- **`CornerActionButtons
|
|
49
|
-
screen's rotated corners. This isn't just a loadout/lobby-screen convention
|
|
48
|
+
- **`CornerActionButtons`**: Back (top-left) + Settings (top-right) icon buttons fixed at a
|
|
49
|
+
screen's rotated corners. This isn't just a loadout/lobby-screen convention - it's the right
|
|
50
50
|
default for *any* screen with top-corner icon buttons, home screens included: every app in the
|
|
51
51
|
fleet still hand-rolls its own trophy/cog `IconButton` pair on the home screen specifically
|
|
52
52
|
instead of reusing this component there, a real (if minor) missed-reuse case worth fixing next
|
|
53
53
|
time one of those screens gets touched. Not the right fit for a screen where the top corners fall
|
|
54
|
-
*inside* one player's own rotated zone (two-player face-to-face)
|
|
55
|
-
that case instead.
|
|
56
|
-
|
|
54
|
+
*inside* one player's own rotated zone (two-player face-to-face) - see `SharedActionBand` for
|
|
55
|
+
that case instead. Each button's touch target is its own 40pt box. Pass the optional `hitSlop`
|
|
56
|
+
(a number or `Insets`, forwarded to both buttons) to grow it without changing anything visible:
|
|
57
|
+
`hitSlop={4}` makes a 48pt target, and the glyphs and press ripple stay as they are. It needs
|
|
58
|
+
`@rific/feedback-press` 0.13.0 or later, whose `IconButton` makes `hitSlop` reach past Paper's
|
|
59
|
+
clipped box (older versions silently ignore it). Omit it to keep the 40pt target. Leave room for
|
|
60
|
+
it: the slop reaches past each button's box, and where it overlaps another touch target,
|
|
61
|
+
whichever is drawn on top gets the press.
|
|
62
|
+
- **`StaggeredWord`** / **`getStaggeredWordDuration`** - the letter-by-letter stagger-in animation
|
|
57
63
|
every app's own hero-title screen had hand-rolled independently, identical down to the same 45ms
|
|
58
|
-
stagger, 320ms per-letter duration, and back-ease overshoot. Renders one word/line at a time
|
|
64
|
+
stagger, 320ms per-letter duration, and back-ease overshoot. Renders one word/line at a time - a
|
|
59
65
|
caller stacking several words (AirHockey's "AIR"/"HOCKEY", LightCycles' "Light"/"Cycles") renders
|
|
60
66
|
more than one `StaggeredWord` and threads `startIndex` through so the whole wordmark reads as one
|
|
61
67
|
continuous cascade instead of each word restarting its own stagger from zero.
|
|
@@ -63,9 +69,9 @@ corner of the screen.
|
|
|
63
69
|
plain function rather than something read off a ref or hook, so a caller's own follow-on
|
|
64
70
|
animation (a paddle rally, a bouncing puck, a looping trail) can compute once, outside render,
|
|
65
71
|
when the lettering has mostly landed.
|
|
66
|
-
- **`PressAwayOverlay
|
|
67
|
-
one piece that needs care in a split-screen layout
|
|
68
|
-
- **`BaseSettingsDialog`** / **`BaseStatsScreen`** / **`ConfirmDialog`**
|
|
72
|
+
- **`PressAwayOverlay`**: an invisible full-bleed tap-catcher for press-away-to-close. This is the
|
|
73
|
+
one piece that needs care in a split-screen layout - see below.
|
|
74
|
+
- **`BaseSettingsDialog`** / **`BaseStatsScreen`** / **`ConfirmDialog`** - shared shells for the
|
|
69
75
|
settings sheet, an achievements/stats screen, and a plain confirm-or-cancel dialog, each factored
|
|
70
76
|
out after being independently copied into several games. See Rotation below for how each of these
|
|
71
77
|
(plus `SectionedDropdown`/`InlineColorPicker`) handles rotating inside a `@tastic/split-screen`
|
|
@@ -75,21 +81,21 @@ corner of the screen.
|
|
|
75
81
|
|
|
76
82
|
`SectionedDropdown`, `InlineColorPicker`, `BaseSettingsDialog`, and `BaseStatsScreen` all read the
|
|
77
83
|
device's live rotation themselves, via [`@tastic/core`](https://github.com/jayrdeaton/react-native-game-core)'s
|
|
78
|
-
`useRotation()`
|
|
84
|
+
`useRotation()` - you don't need to pass anything for a popover/dialog to read correctly no matter
|
|
79
85
|
which way the phone is held, as long as your app mounts `@tastic/core`'s `OrientationProvider`
|
|
80
86
|
(or `@tastic/split-screen`'s `AccelerometerOrientationProvider`, a compat alias for the same thing)
|
|
81
87
|
somewhere above it.
|
|
82
88
|
|
|
83
89
|
Each of the four also still accepts an explicit `rotation` prop, which overrides the ambient read
|
|
84
|
-
when present
|
|
90
|
+
when present - reach for it only when a specific instance genuinely needs to differ from the live
|
|
85
91
|
app-wide rotation (e.g. a fading, lagged `panelLayout` value mid-transition, rather than the raw
|
|
86
|
-
live reading
|
|
92
|
+
live reading - see `@tastic/split-screen`'s own `DualZoneLayout` docs for why that lag exists).
|
|
87
93
|
|
|
88
94
|
This matters more than it might look: `useAutoAlign` (what `SectionedDropdown`/`InlineColorPicker`
|
|
89
95
|
use to pick left/right/above/below) measures a trigger via `measureInWindow`, which reports the
|
|
90
|
-
trigger's **pre-transform** layout position
|
|
96
|
+
trigger's **pre-transform** layout position - confirmed against a real rotated screen, not just
|
|
91
97
|
reasoned about. Inside a `FakeLandscapeView`-rotated zone (or any other ancestor rotated the same
|
|
92
|
-
way
|
|
98
|
+
way - a bare CSS `transform: rotate()`, not a real OS-level orientation change), that pre-transform
|
|
93
99
|
position can be nowhere near where the trigger actually is on screen, which is exactly the bug this
|
|
94
100
|
rotation-awareness fixes: without it, a popover can pick an alignment for where its trigger *used to
|
|
95
101
|
be* before rotating, clipping off the edge it's actually now near instead of the one it was near.
|
|
@@ -135,7 +141,7 @@ board inside it.
|
|
|
135
141
|
|
|
136
142
|
A single full-screen `PressAwayOverlay` works fine for a one-player screen. It does **not** work
|
|
137
143
|
for a two-player split screen: if player 1's press-away covers the whole screen, then any tap on
|
|
138
|
-
player 2's side
|
|
144
|
+
player 2's side - even one that has nothing to do with player 1 - falls through and closes player
|
|
139
145
|
1's popover. That defeats the point of letting both players drive their own settings at once.
|
|
140
146
|
|
|
141
147
|
The fix is two overlays with an asymmetric relationship, not two independent halves:
|
|
@@ -147,18 +153,18 @@ const p2Host = usePopoverHost()
|
|
|
147
153
|
return (
|
|
148
154
|
<View style={styles.container}>
|
|
149
155
|
{/* Player 1's overlay covers the ENTIRE screen. This is correct even though player 1 visually
|
|
150
|
-
only owns "their side"
|
|
156
|
+
only owns "their side" - any shared-settings row that reads as "player 1's" (see your own
|
|
151
157
|
layout) may not stay confined to a literal half of the screen, and this overlay needs to cover
|
|
152
158
|
everywhere player 1 might have something open. */}
|
|
153
159
|
<PressAwayOverlay active={p1Host.openId !== null} onPress={p1Host.close} />
|
|
154
160
|
|
|
155
161
|
{/* Player 2's overlay is scoped to just their own zone (half the screen, whichever side
|
|
156
|
-
they're on
|
|
162
|
+
they're on - see @tastic/split-screen's panelLayout for how to size this) - and, being a later
|
|
157
163
|
sibling, it paints on top of player 1's overlay within that rect, so a tap there is always
|
|
158
164
|
player 2's to own.
|
|
159
165
|
|
|
160
166
|
Critically: this has to mount whenever EITHER host is open, not just p2Host. If it only mounted
|
|
161
|
-
for p2Host, then closing player 2's popover would unmount it
|
|
167
|
+
for p2Host, then closing player 2's popover would unmount it - exposing player 1's full-screen
|
|
162
168
|
overlay underneath for the rest of that render, and the next tap anywhere on player 2's side
|
|
163
169
|
(even if player 2 has nothing open) would fall through and close player 1's popover instead.
|
|
164
170
|
Mounting it any time p1Host is open too "shields" player 2's zone from player 1's overlay
|
|
@@ -166,7 +172,7 @@ return (
|
|
|
166
172
|
closed. */}
|
|
167
173
|
<PressAwayOverlay active={p1Host.openId !== null || p2Host.openId !== null} onPress={p2Host.close} style={styles.p2Zone} />
|
|
168
174
|
|
|
169
|
-
{/* Real content goes after both overlays
|
|
175
|
+
{/* Real content goes after both overlays - plain paint order (later siblings on top) is what
|
|
170
176
|
keeps every real trigger/button directly tappable; only genuinely empty space falls through to
|
|
171
177
|
the overlays above. */}
|
|
172
178
|
<Player1Panel host={p1Host} />
|
|
@@ -178,21 +184,21 @@ return (
|
|
|
178
184
|
The general rule for N players: the "owner" of the broadest zone (usually whoever's panel absorbs
|
|
179
185
|
your shared/global settings) gets the whole-screen overlay; every other player's zone-scoped overlay
|
|
180
186
|
must stay mounted (shielding, if not actually closing anything) whenever *any* host with broader
|
|
181
|
-
reach is open
|
|
187
|
+
reach is open - not just their own.
|
|
182
188
|
|
|
183
189
|
## Integrating BaseSettingsDialog
|
|
184
190
|
|
|
185
|
-
`BaseSettingsDialog` is a shared shell, not a drop-in screen
|
|
191
|
+
`BaseSettingsDialog` is a shared shell, not a drop-in screen - every one of the five consuming apps
|
|
186
192
|
(AirHockey, BoxHockey, Pong, LightCycles, Snake) wraps it in its own local
|
|
187
193
|
`src/components/SettingsDialog.tsx` adapter rather than rendering it directly from a route. The
|
|
188
194
|
adapter's whole job is to bridge whatever local settings mechanism the app uses onto this package's
|
|
189
|
-
fixed prop contract
|
|
195
|
+
fixed prop contract - it should carry no UI of its own beyond that plumbing.
|
|
190
196
|
|
|
191
197
|
That local mechanism genuinely varies per app. AirHockey/BoxHockey/LightCycles thread a
|
|
192
198
|
`settings`/`setSettings` pair in as props from a caller-owned `GameSettings` object (each adapter's
|
|
193
199
|
own three call sites already have one in scope for other reasons). Pong reads that same shape off
|
|
194
|
-
its own `useGameSettings()` hook internally instead of taking it as props. Snake
|
|
195
|
-
the fleet with real Redux Toolkit state
|
|
200
|
+
its own `useGameSettings()` hook internally instead of taking it as props. Snake - the one app in
|
|
201
|
+
the fleet with real Redux Toolkit state - reads `lockOrientation`/`deferBottomEdgeGestures` via
|
|
196
202
|
`useSelector` and writes them back via `dispatch(gameActions.setLockOrientation(...))` directly
|
|
197
203
|
inside the adapter, with nothing threaded through props at all. `BaseSettingsDialog` doesn't care
|
|
198
204
|
which of these an app uses: sound, haptics, appearance, and update-checking are identical across
|
|
@@ -202,7 +208,7 @@ actually needs to source itself, however it does that.
|
|
|
202
208
|
|
|
203
209
|
Every adapter also passes `version={release.otaVersion}` (so the dialog's own update-check button
|
|
204
210
|
knows what it's currently running) and `onUpdateError`, wired to a real `useToast()` call. Wire this
|
|
205
|
-
one up
|
|
211
|
+
one up - an adapter that leaves `onUpdateError` unset silently swallows a failed update check
|
|
206
212
|
instead of surfacing it anywhere, which is a real bug this fleet actually shipped in one app before
|
|
207
213
|
being caught and fixed. If your app has no toast system, route it to whatever your own
|
|
208
214
|
error-surfacing convention is; just don't leave it as a no-op.
|
|
@@ -231,30 +237,30 @@ export function SettingsDialog({ visible, onDismiss, rotation = 0, settings, set
|
|
|
231
237
|
|
|
232
238
|
## Zone-aware popovers (`useZoneClampedAlign`)
|
|
233
239
|
|
|
234
|
-
`useAutoAlign`
|
|
235
|
-
`LabeledDropdown`
|
|
240
|
+
`useAutoAlign` - and everything built on it, including `SectionedDropdown`, `InlineColorPicker`, and
|
|
241
|
+
`LabeledDropdown` - only knows about the full device window edge. It has no concept of a
|
|
236
242
|
`@tastic/split-screen` zone boundary: the shared neutral band between two players' halves, or the
|
|
237
243
|
line where one player's zone stops and the other's begins. A popover host that lives entirely
|
|
238
244
|
inside one player's zone (rather than centered app-wide, the way `BaseSettingsDialog` is) can end up
|
|
239
245
|
choosing an alignment that opens toward the shared band, or overflows past the zone boundary into
|
|
240
|
-
the other player's half
|
|
246
|
+
the other player's half - not because `useAutoAlign` measured wrong, but because the window edge it
|
|
241
247
|
correctly measured against isn't the boundary that actually matters there.
|
|
242
248
|
|
|
243
|
-
LightCycles hit this for real
|
|
244
|
-
inside a rotated split-screen zone
|
|
249
|
+
LightCycles hit this for real - its `PlayerSetupPanel`-hosted color/CPU-difficulty pickers live
|
|
250
|
+
inside a rotated split-screen zone - and originally built its own local fix for it rather than
|
|
245
251
|
waiting for this package to grow zone-awareness. That fix is now `useZoneClampedAlign`, a
|
|
246
252
|
first-class export of this package: it wraps `useAutoAlign`'s result and re-derives the vertical
|
|
247
253
|
decision (and `maxHeight`) against the zone's real edge, via `@tastic/split-screen`'s
|
|
248
|
-
`useZoneBounds()`, instead of the window edge
|
|
254
|
+
`useZoneBounds()`, instead of the window edge - including correcting for a 180°-rotated zone
|
|
249
255
|
inverting `PopoverBody`'s local 'above'/'below' labels relative to their real-world direction.
|
|
250
|
-
`@tastic/split-screen` is a peer dependency of this package because of this hook specifically
|
|
256
|
+
`@tastic/split-screen` is a peer dependency of this package because of this hook specifically - it's
|
|
251
257
|
only ever imported if you actually call `useZoneClampedAlign`.
|
|
252
258
|
|
|
253
259
|
Same signature and return shape as `useAutoAlign` (`open`, `contentWidth`, `contentHeight` in;
|
|
254
|
-
`align`, `verticalAlign`, `maxHeight`, `measured`, `triggerRef` out
|
|
260
|
+
`align`, `verticalAlign`, `maxHeight`, `measured`, `triggerRef` out - exported as `AlignResult`, see
|
|
255
261
|
below) so it's a drop-in replacement anywhere `useAutoAlign` is called directly. Outside a
|
|
256
262
|
`@tastic/split-screen` zone (`useZoneBounds()` returns `null`), it's a pure passthrough to
|
|
257
|
-
`useAutoAlign`'s own decision
|
|
263
|
+
`useAutoAlign`'s own decision - safe to reach for even in a component that might or might not end up
|
|
258
264
|
inside one:
|
|
259
265
|
|
|
260
266
|
```tsx
|
|
@@ -272,10 +278,10 @@ their own internal, zone-blind `useAutoAlign` call:
|
|
|
272
278
|
`ControlSchemePicker` forwards both `alignOverride` and `rotation` straight through to the
|
|
273
279
|
`SectionedDropdown` it renders internally, so the same call works there too. `PlayerSetupPanel`'s
|
|
274
280
|
own internal color/CPU-difficulty pickers aren't reachable this way (they're not a `ReactNode` slot
|
|
275
|
-
your own code renders
|
|
281
|
+
your own code renders - see "The secondPicker slot" below), so it exposes `colorAlignOverride` and
|
|
276
282
|
`cpuDifficultyAlignOverride` props instead, forwarded straight to `InlineColorPicker`'s and
|
|
277
283
|
`LabeledDropdown`'s own `alignOverride` internally. Computing `open` for either one needs that
|
|
278
|
-
picker's own popover id, which `PlayerSetupPanel` derives internally from `idPrefix`
|
|
284
|
+
picker's own popover id, which `PlayerSetupPanel` derives internally from `idPrefix` - call
|
|
279
285
|
`getColorPopoverId(idPrefix)`/`getCpuDifficultyPopoverId(idPrefix)` (both exported) rather than
|
|
280
286
|
guessing or hardcoding the naming convention yourself:
|
|
281
287
|
|
|
@@ -292,7 +298,7 @@ For any other popover-hosting component that might sit inside a split-screen zon
|
|
|
292
298
|
## The secondPicker slot (read this before wiring one up)
|
|
293
299
|
|
|
294
300
|
`PlayerSetupPanel`'s `secondPicker` is a caller-rendered `ReactNode` slot next to the built-in color
|
|
295
|
-
picker, not a fixed prop shape
|
|
301
|
+
picker, not a fixed prop shape - the five apps this component was extracted from each wanted a
|
|
296
302
|
genuinely different second control there (a `ControlSchemePicker`, a raw `SectionedDropdown`, or
|
|
297
303
|
nothing at all), so rather than this package baking in one shape most consumers don't need, the
|
|
298
304
|
panel just renders whatever `secondPicker` is given, wherever it's given.
|
|
@@ -316,11 +322,11 @@ If whatever you slot in opens its own popover, pass its popover id as `secondPic
|
|
|
316
322
|
Skipping `secondPickerId` when your slotted picker does open a popover is easy to get wrong, and it
|
|
317
323
|
fails silently rather than throwing: `PlayerSetupPanel` uses it to decide whether its own
|
|
318
324
|
`pickerRow` needs the elevated z-index that lets a popover escape the row's bounds and paint above a
|
|
319
|
-
later sibling (the `ReadyButton` beneath it, or another panel next to it
|
|
325
|
+
later sibling (the `ReadyButton` beneath it, or another panel next to it - see `pickerRowOpen` in
|
|
320
326
|
the component's own source). Without `secondPickerId`, the panel has no way to know your picker's
|
|
321
327
|
popover is open, so that elevation never kicks in, and your popover can silently render underneath
|
|
322
328
|
sibling content instead of on top of it. If your `secondPicker` never opens a popover of its own (a
|
|
323
|
-
plain toggle, static content, or nothing at all), omit `secondPickerId`
|
|
329
|
+
plain toggle, static content, or nothing at all), omit `secondPickerId` - there's nothing for it to
|
|
324
330
|
track. And if that slotted picker's own popover lives inside a split-screen zone, see "Zone-aware
|
|
325
331
|
popovers" above for how to wire `alignOverride`/`rotation` into whatever you construct there.
|
|
326
332
|
|
|
@@ -342,26 +348,26 @@ interface QuitConfirmation {
|
|
|
342
348
|
|
|
343
349
|
Wire `confirmVisible`/`cancelBack` straight to `<ConfirmDialog visible={...} onCancel={...}>`, and
|
|
344
350
|
`requestBack` to the back button's `onPress`. The hook deliberately doesn't own or render the
|
|
345
|
-
`<ConfirmDialog>` itself
|
|
351
|
+
`<ConfirmDialog>` itself - title/message/icon/labels/rotation stay authored at each call site (a
|
|
346
352
|
score pairing in most apps, a round-history pip row in LightCycles), matching this package's
|
|
347
353
|
established "hooks return data, JSX stays at the call site" convention (`usePopoverHost`,
|
|
348
354
|
`useAutoAlign`, `useZoneClampedAlign` all do the same).
|
|
349
355
|
|
|
350
|
-
**`hasProgress` is a lazy `() => boolean` getter, not a plain `boolean`
|
|
356
|
+
**`hasProgress` is a lazy `() => boolean` getter, not a plain `boolean` - this is a load-bearing
|
|
351
357
|
correction, not a style choice.** A plain-boolean argument forces the caller to compute it every
|
|
352
358
|
render from state that, in several apps, doesn't exist yet at that point in the render.
|
|
353
359
|
AirHockey/BoxHockey/Pong all fold this hook's own `confirmVisible` into a
|
|
354
|
-
`paused = settingsOpen || confirmVisible` that gates `useGameState(paused)`
|
|
360
|
+
`paused = settingsOpen || confirmVisible` that gates `useGameState(paused)` - and
|
|
355
361
|
`useGameState`'s *return value* (scores, lives, bricks) is exactly what `hasProgress` needs to
|
|
356
362
|
read. A plain boolean here would create a real circular render dependency:
|
|
357
363
|
`confirmVisible → paused → useGameState(paused) → {scores/lives/bricks} → hasProgress →
|
|
358
364
|
confirmVisible`. The lazy getter breaks the cycle: `hasProgress` is only invoked inside
|
|
359
|
-
`requestBack`, at tap time, well after `useGameState` has already produced that render's state
|
|
365
|
+
`requestBack`, at tap time, well after `useGameState` has already produced that render's state -
|
|
360
366
|
the getter's closure just reads whatever `scores` is bound to *by then*, the same way a hand-rolled
|
|
361
367
|
`onBackPress` closure always did before this hook existed:
|
|
362
368
|
|
|
363
369
|
```tsx
|
|
364
|
-
// AirHockey's game.tsx
|
|
370
|
+
// AirHockey's game.tsx - hasProgress reads `scores`, a value useGameState (below) hasn't produced
|
|
365
371
|
// yet at the point useQuitConfirmation is called. That's fine: the getter is only ever called from
|
|
366
372
|
// inside requestBack, well after this render has finished and `scores` is a real value.
|
|
367
373
|
const [settingsOpen, setSettingsOpen] = useState(false)
|
|
@@ -378,7 +384,7 @@ const { scores } = state
|
|
|
378
384
|
<ConfirmDialog
|
|
379
385
|
visible={confirmVisible}
|
|
380
386
|
title='Quit Match?'
|
|
381
|
-
message={`${scores[0]}
|
|
387
|
+
message={`${scores[0]} - ${scores[1]}`}
|
|
382
388
|
confirmLabel='Quit'
|
|
383
389
|
cancelLabel='Cancel'
|
|
384
390
|
onConfirm={safeBack}
|
|
@@ -387,8 +393,8 @@ const { scores } = state
|
|
|
387
393
|
```
|
|
388
394
|
|
|
389
395
|
A game with no such circular dependency (Snake reads `humanScore`/`opponentScore`, LightCycles
|
|
390
|
-
reads `roundHistory.length`) still passes a getter
|
|
391
|
-
|
|
396
|
+
reads `roundHistory.length`) still passes a getter - `() => humanScore > 0 || (opponentScore ?? 0) > 0`;
|
|
397
|
+
it's just a plain wrapper around an expression that could have been a bare boolean there; the
|
|
392
398
|
signature stays uniform across every call site rather than special-casing the apps that don't
|
|
393
399
|
currently need the deferred read.
|
|
394
400
|
|
|
@@ -396,13 +402,13 @@ currently need the deferred read.
|
|
|
396
402
|
|
|
397
403
|
Two presentational components for an achievements/stats screen, designed to pair with
|
|
398
404
|
[`@tastic/achievements`](https://github.com/jayrdeaton/react-native-game-achievements)'s own
|
|
399
|
-
`getAchievementCatalogRows`
|
|
405
|
+
`getAchievementCatalogRows` - the row-computation logic (reading `ACHIEVEMENT_CATALOG`,
|
|
400
406
|
`unlockedAchievements`, and stats) lives in that headless package (no react-native/Paper deps of
|
|
401
407
|
its own); the two components that actually render the result live here. Mirrors this package's
|
|
402
408
|
existing `AchievementRow` (hud, presentational, fully-precomputed props) / achievement-engine-shaped
|
|
403
409
|
(pure data) boundary.
|
|
404
410
|
|
|
405
|
-
**`AchievementCatalogSection`**
|
|
411
|
+
**`AchievementCatalogSection`** - an "ALL ACHIEVEMENTS" heading plus one `<AchievementRow>` per
|
|
406
412
|
row. Returns a `Fragment`, not a `View`: every call site renders the heading and rows as flat
|
|
407
413
|
siblings inside `BaseStatsScreen`'s own gap-spaced `ScrollView` content, and a wrapping container
|
|
408
414
|
here would double up that spacing. Resolves each row's locked-vs-unlocked `badgeColor` itself
|
|
@@ -418,9 +424,9 @@ interface AchievementCatalogSectionProps {
|
|
|
418
424
|
}
|
|
419
425
|
```
|
|
420
426
|
|
|
421
|
-
**`ActivityStatSection`**
|
|
427
|
+
**`ActivityStatSection`** - the "ACTIVITY" `<StatSection>` (Days Played / Day Streak / Best Day
|
|
422
428
|
Streak). Takes a plain structural `ActivityStats` shape rather than importing
|
|
423
|
-
`@tastic/achievements`' own `DayStreakState` type, even though the fields match exactly
|
|
429
|
+
`@tastic/achievements`' own `DayStreakState` type, even though the fields match exactly - this
|
|
424
430
|
component has zero dependency, not even type-only, on `@tastic/achievements`; any caller's own
|
|
425
431
|
stats object with these three fields satisfies it:
|
|
426
432
|
|
|
@@ -448,7 +454,7 @@ return (
|
|
|
448
454
|
|
|
449
455
|
`AchievementCatalogSection` and `AchievementUnlockList` (below) are the only places in this package
|
|
450
456
|
with a `@tastic/achievements` dependency, and both are type-only (`AchievementCatalogRow`,
|
|
451
|
-
`AchievementTier`)
|
|
457
|
+
`AchievementTier`) - never a value/runtime import. See Peer Dependencies below.
|
|
452
458
|
|
|
453
459
|
## Achievement unlocks on a result card (`AchievementUnlockList`)
|
|
454
460
|
|
|
@@ -479,7 +485,7 @@ pick it up. A guest seat's avatar draws the `color` you pass as-is, so pass it a
|
|
|
479
485
|
A short, skippable, paged "how to play" card that shows once on a fresh install and can be replayed on
|
|
480
486
|
demand from Settings. Import it from the **`@tastic/hud/guide` subpath**, not the main barrel: it's a
|
|
481
487
|
standalone build that pulls in only `react`, `react-native`, `react-native-paper`,
|
|
482
|
-
`react-native-reanimated`, `@rific/auto-paper` and `@rific/feedback-press`
|
|
488
|
+
`react-native-reanimated`, `@rific/auto-paper` and `@rific/feedback-press` - none of Skia,
|
|
483
489
|
`@tastic/core`, `@tastic/profile`, `@tastic/split-screen`, `@tastic/achievements` or `@rific/updater`
|
|
484
490
|
(all marked optional in `peerDependenciesMeta`), so an app that has none of them can use it. A test
|
|
485
491
|
(`src/__tests__/guideImports.test.ts`) fails if any file under `src/guide/` ever imports one.
|
|
@@ -487,7 +493,7 @@ standalone build that pulls in only `react`, `react-native`, `react-native-paper
|
|
|
487
493
|
```tsx
|
|
488
494
|
import { createGuideSlice, GuideProvider, SeatDiagram, SwipeHint, useAutoShowGuide, useGuide } from '@tastic/hud/guide'
|
|
489
495
|
|
|
490
|
-
// 1. A persisted "seen" version
|
|
496
|
+
// 1. A persisted "seen" version - its own slice, mounted under `guide` in your root reducer.
|
|
491
497
|
const guide = createGuideSlice({ currentVersion: 1 }) // { actions.markSeen, reducer, selectVersionSeen }
|
|
492
498
|
|
|
493
499
|
// 2. Once, near the root, wrapping your screens (inside PersistGate + your theme/Paper providers).
|
|
@@ -505,7 +511,7 @@ const guide = createGuideSlice({ currentVersion: 1 }) // { actions.markSeen, red
|
|
|
505
511
|
// 3. On the Home screen, and nowhere else: opens it once per session for a player who hasn't seen it.
|
|
506
512
|
useAutoShowGuide()
|
|
507
513
|
|
|
508
|
-
// 4. Replay from Settings
|
|
514
|
+
// 4. Replay from Settings - BaseSettingsDialog renders a "How to Play" row when this is passed.
|
|
509
515
|
const { open } = useGuide()
|
|
510
516
|
<BaseSettingsDialog {...props} onShowHowToPlay={open} />
|
|
511
517
|
```
|
|
@@ -514,7 +520,7 @@ const { open } = useGuide()
|
|
|
514
520
|
an existing player has a payload that simply predates the `guide` key. `createGuideSlice` stamps the
|
|
515
521
|
second with `currentVersion` instead of `0`, so an OTA update never greets them with a tutorial (they
|
|
516
522
|
can still replay it). Only finishing or skipping writes; a replay never does. Bump `currentVersion`
|
|
517
|
-
only for a real controls/rules change
|
|
523
|
+
only for a real controls/rules change - everyone sees it again.
|
|
518
524
|
- **If your last shipped build predates your redux-persist store, you also need the migrate.** Those
|
|
519
525
|
players have no root store yet, which looks exactly like a fresh install. Pass the AsyncStorage keys the
|
|
520
526
|
shipped build wrote (read them from the last shipped commit, not today's source):
|
|
@@ -529,7 +535,7 @@ const { open } = useGuide()
|
|
|
529
535
|
|
|
530
536
|
No root store but any of those keys present means "played an earlier build": that launch rehydrates
|
|
531
537
|
with an empty payload, which the slice grandfathers. An existing root store passes through untouched.
|
|
532
|
-
- **Home only.** Call `useAutoShowGuide()` from the Home screen
|
|
538
|
+
- **Home only.** Call `useAutoShowGuide()` from the Home screen - that placement is what keeps it from
|
|
533
539
|
firing over a live match or a two-seat loadout, where a single centered card can't be read right-way-up
|
|
534
540
|
by both players. Pass `false` to hold it back (e.g. while a deep link is being handled).
|
|
535
541
|
- **Leave the row off during a live match.** Omit `onShowHowToPlay` on any screen where Settings pauses or
|
|
@@ -549,6 +555,36 @@ const { open } = useGuide()
|
|
|
549
555
|
and only say what's true in every mode ("your half" is only true in two-player).
|
|
550
556
|
- **Not `onboarding`.** LightCycles and Snake already use that word for their 3-2-1-GO countdown.
|
|
551
557
|
|
|
558
|
+
## Fleet chrome without the heavy peers (`@tastic/hud/chrome`)
|
|
559
|
+
|
|
560
|
+
The shared dialogs, stats screen pieces, corner buttons and style tokens, from a standalone entry that never
|
|
561
|
+
touches the main barrel. It's for an app that has `@tastic/core` and `@rific/updater` but none of Skia,
|
|
562
|
+
`@tastic/profile` or `@tastic/split-screen` (Hangman). It exports, with their props types:
|
|
563
|
+
|
|
564
|
+
- Dialogs: `BaseSettingsDialog`, `ConfirmDialog`, `UpdateDialog`, `RotatedFrame`
|
|
565
|
+
- Stats: `BaseStatsScreen`, `StatSection`, `StatRow`, `AchievementRow`, `LOCKED_BADGE_COLOR`,
|
|
566
|
+
`AchievementCatalogSection`, `ActivityStatSection`
|
|
567
|
+
- Chrome: `CornerActionButtons`, `ContentGutter`, `StaggeredWord`, `getStaggeredWordDuration`
|
|
568
|
+
- Style tokens: `getOverlayCardColors`, `overlayStyles`, `overlayActionStyles`, `MONO_FONT`
|
|
569
|
+
|
|
570
|
+
Each is the same component the main entry exports and is documented above. Only the import path changes:
|
|
571
|
+
|
|
572
|
+
```tsx
|
|
573
|
+
import { BaseSettingsDialog, ConfirmDialog, CornerActionButtons } from '@tastic/hud/chrome'
|
|
574
|
+
```
|
|
575
|
+
|
|
576
|
+
It pulls in only `react`, `react-native`, `react-native-paper`, `react-native-reanimated`,
|
|
577
|
+
`react-native-safe-area-context`, `@rific/auto-paper`, `@rific/feedback-press`, `@rific/updater` and
|
|
578
|
+
`@tastic/core`. `AchievementCatalogSection` uses `@tastic/achievements` for types only, so nothing loads it at
|
|
579
|
+
runtime, but an app whose TypeScript resolves the `react-native` export condition (the fleet's tsconfig does)
|
|
580
|
+
type-checks hud's source and needs it installed. `AchievementUnlockList` isn't here because it renders
|
|
581
|
+
`@tastic/profile`'s `ProfileChip`. A test (`src/__tests__/chromeImports.test.ts`) walks the import graph from
|
|
582
|
+
`src/chrome/index.ts` and fails if anything it reaches imports another package, value-imports
|
|
583
|
+
`@tastic/achievements`, reaches the main barrel, or has a `.web` twin.
|
|
584
|
+
|
|
585
|
+
Metro resolves both entries to the same source files, so an app that imports some components from here and
|
|
586
|
+
others from `@tastic/hud` still bundles each module once.
|
|
587
|
+
|
|
552
588
|
## Install
|
|
553
589
|
|
|
554
590
|
Published to the public npm registry as `@tastic/hud`. Everything described above, including the
|
|
@@ -571,8 +607,9 @@ when no rotation applies.
|
|
|
571
607
|
## Peer dependencies
|
|
572
608
|
|
|
573
609
|
None of these are bundled, so use whatever versions your app already has. Six are marked `optional` in
|
|
574
|
-
`peerDependenciesMeta`
|
|
575
|
-
|
|
610
|
+
`peerDependenciesMeta` so a light app can install hud without the ones it doesn't use: `./guide` needs
|
|
611
|
+
none of the six, and `./chrome` needs only `@tastic/core` and `@rific/updater` (plus
|
|
612
|
+
`@tastic/achievements` to type-check, see above). **npm does not install optional peers for you**, so an app that imports the
|
|
576
613
|
main `@tastic/hud` entry must list every peer that entry loads in its own `dependencies`, including
|
|
577
614
|
the four optional ones marked "main entry" below. Without them, Metro fails to bundle with "Unable to
|
|
578
615
|
resolve module".
|
|
@@ -580,14 +617,14 @@ resolve module".
|
|
|
580
617
|
| Peer | Floor | Needed by |
|
|
581
618
|
| --- | --- | --- |
|
|
582
619
|
| `react`, `react-native` | >=19.0.0, >=0.76.0 | every entry |
|
|
583
|
-
| `react-native-paper` | >=5.0.0 | main entry, `./guide` |
|
|
584
|
-
| `react-native-reanimated` | >=3.0.0 | main entry (`StaggeredWord`), `./guide` |
|
|
585
|
-
| `@rific/auto-paper` | >=0.12.2 | main entry (`BaseSettingsDialog` needs the nested `Portal.Host` first shipped in 0.12.2), `./guide` |
|
|
586
|
-
| `@rific/feedback-press` | >=0.10.2 | main entry (`useSoundSettings` first shipped in 0.10.2), `./guide` |
|
|
587
|
-
| `react-native-safe-area-context` | >=5.0.0 | main entry (`useZoneClampedAlign`) |
|
|
588
|
-
| `@tastic/core` | >=0.12.0, optional | main entry: `useRotation()` and most dialogs; `RotatedFrame` and `BaseSettingsDialog` need `FakeLandscapeView`'s `rotation`/`passThrough`, first shipped in 0.12.0 |
|
|
589
|
-
| `@rific/updater` | >=0.4.0, optional | main entry
|
|
620
|
+
| `react-native-paper` | >=5.0.0 | main entry, `./guide`, `./chrome` |
|
|
621
|
+
| `react-native-reanimated` | >=3.0.0 | main entry (`StaggeredWord`), `./guide`, `./chrome` (`StaggeredWord`) |
|
|
622
|
+
| `@rific/auto-paper` | >=0.12.2 | main entry (`BaseSettingsDialog` needs the nested `Portal.Host` first shipped in 0.12.2), `./guide`, `./chrome` |
|
|
623
|
+
| `@rific/feedback-press` | >=0.10.2 | main entry (`useSoundSettings` first shipped in 0.10.2), `./guide`, `./chrome` |
|
|
624
|
+
| `react-native-safe-area-context` | >=5.0.0 | main entry (`useZoneClampedAlign`), `./chrome` (`RotatedFrame`, `BaseSettingsDialog`) |
|
|
625
|
+
| `@tastic/core` | >=0.12.0, optional | main entry: `useRotation()` and most dialogs; `RotatedFrame` and `BaseSettingsDialog` need `FakeLandscapeView`'s `rotation`/`passThrough`, first shipped in 0.12.0; `./chrome` (the same dialogs) |
|
|
626
|
+
| `@rific/updater` | >=0.4.0, optional | main entry and `./chrome`: `BaseSettingsDialog`, `UpdateDialog` |
|
|
590
627
|
| `@tastic/profile` | >=0.4.0, optional | main entry: `PlayerSetupPanel`, `AchievementUnlockList` (`ProfileChip`) |
|
|
591
628
|
| `@tastic/split-screen` | >=0.5.0, optional | main entry: `useZoneClampedAlign` |
|
|
592
629
|
| `@shopify/react-native-skia` | >=1.5.0, optional | `TriggerGaugeHost`'s gauge (lazy-loaded behind `SkiaGate`) and `./skia-gate` |
|
|
593
|
-
| `@tastic/achievements` | >=0.2.0, optional | `AchievementCatalogSection`, `AchievementUnlockList`, type-only (`AchievementCatalogRow`, first exported in 0.2.0; `AchievementTier`) |
|
|
630
|
+
| `@tastic/achievements` | >=0.2.0, optional | `AchievementCatalogSection`, `AchievementUnlockList`, type-only (`AchievementCatalogRow`, first exported in 0.2.0; `AchievementTier`); `./chrome` needs it installed only to type-check |
|