@tastic/hud 0.14.1 → 0.15.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 +110 -79
- package/dist/chrome/index.d.mts +239 -0
- package/dist/chrome/index.d.ts +239 -0
- package/dist/chrome/index.js +772 -0
- package/dist/chrome/index.mjs +44 -0
- package/dist/chunk-OTMMST5M.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 +22 -22
- 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 +36 -34
- package/src/BaseStatsScreen.tsx +13 -13
- package/src/ConfirmDialog.tsx +2 -2
- package/src/ControlSchemePicker.tsx +9 -9
- package/src/CornerActionButtons.tsx +6 -6
- 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,61 @@
|
|
|
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)
|
|
54
|
+
*inside* one player's own rotated zone (two-player face-to-face) - see `SharedActionBand` for
|
|
55
55
|
that case instead.
|
|
56
|
-
- **`StaggeredWord`** / **`getStaggeredWordDuration`**
|
|
56
|
+
- **`StaggeredWord`** / **`getStaggeredWordDuration`** - the letter-by-letter stagger-in animation
|
|
57
57
|
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
|
|
58
|
+
stagger, 320ms per-letter duration, and back-ease overshoot. Renders one word/line at a time - a
|
|
59
59
|
caller stacking several words (AirHockey's "AIR"/"HOCKEY", LightCycles' "Light"/"Cycles") renders
|
|
60
60
|
more than one `StaggeredWord` and threads `startIndex` through so the whole wordmark reads as one
|
|
61
61
|
continuous cascade instead of each word restarting its own stagger from zero.
|
|
@@ -63,9 +63,9 @@ corner of the screen.
|
|
|
63
63
|
plain function rather than something read off a ref or hook, so a caller's own follow-on
|
|
64
64
|
animation (a paddle rally, a bouncing puck, a looping trail) can compute once, outside render,
|
|
65
65
|
when the lettering has mostly landed.
|
|
66
|
-
- **`PressAwayOverlay
|
|
67
|
-
one piece that needs care in a split-screen layout
|
|
68
|
-
- **`BaseSettingsDialog`** / **`BaseStatsScreen`** / **`ConfirmDialog`**
|
|
66
|
+
- **`PressAwayOverlay`**: an invisible full-bleed tap-catcher for press-away-to-close. This is the
|
|
67
|
+
one piece that needs care in a split-screen layout - see below.
|
|
68
|
+
- **`BaseSettingsDialog`** / **`BaseStatsScreen`** / **`ConfirmDialog`** - shared shells for the
|
|
69
69
|
settings sheet, an achievements/stats screen, and a plain confirm-or-cancel dialog, each factored
|
|
70
70
|
out after being independently copied into several games. See Rotation below for how each of these
|
|
71
71
|
(plus `SectionedDropdown`/`InlineColorPicker`) handles rotating inside a `@tastic/split-screen`
|
|
@@ -75,21 +75,21 @@ corner of the screen.
|
|
|
75
75
|
|
|
76
76
|
`SectionedDropdown`, `InlineColorPicker`, `BaseSettingsDialog`, and `BaseStatsScreen` all read the
|
|
77
77
|
device's live rotation themselves, via [`@tastic/core`](https://github.com/jayrdeaton/react-native-game-core)'s
|
|
78
|
-
`useRotation()`
|
|
78
|
+
`useRotation()` - you don't need to pass anything for a popover/dialog to read correctly no matter
|
|
79
79
|
which way the phone is held, as long as your app mounts `@tastic/core`'s `OrientationProvider`
|
|
80
80
|
(or `@tastic/split-screen`'s `AccelerometerOrientationProvider`, a compat alias for the same thing)
|
|
81
81
|
somewhere above it.
|
|
82
82
|
|
|
83
83
|
Each of the four also still accepts an explicit `rotation` prop, which overrides the ambient read
|
|
84
|
-
when present
|
|
84
|
+
when present - reach for it only when a specific instance genuinely needs to differ from the live
|
|
85
85
|
app-wide rotation (e.g. a fading, lagged `panelLayout` value mid-transition, rather than the raw
|
|
86
|
-
live reading
|
|
86
|
+
live reading - see `@tastic/split-screen`'s own `DualZoneLayout` docs for why that lag exists).
|
|
87
87
|
|
|
88
88
|
This matters more than it might look: `useAutoAlign` (what `SectionedDropdown`/`InlineColorPicker`
|
|
89
89
|
use to pick left/right/above/below) measures a trigger via `measureInWindow`, which reports the
|
|
90
|
-
trigger's **pre-transform** layout position
|
|
90
|
+
trigger's **pre-transform** layout position - confirmed against a real rotated screen, not just
|
|
91
91
|
reasoned about. Inside a `FakeLandscapeView`-rotated zone (or any other ancestor rotated the same
|
|
92
|
-
way
|
|
92
|
+
way - a bare CSS `transform: rotate()`, not a real OS-level orientation change), that pre-transform
|
|
93
93
|
position can be nowhere near where the trigger actually is on screen, which is exactly the bug this
|
|
94
94
|
rotation-awareness fixes: without it, a popover can pick an alignment for where its trigger *used to
|
|
95
95
|
be* before rotating, clipping off the edge it's actually now near instead of the one it was near.
|
|
@@ -135,7 +135,7 @@ board inside it.
|
|
|
135
135
|
|
|
136
136
|
A single full-screen `PressAwayOverlay` works fine for a one-player screen. It does **not** work
|
|
137
137
|
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
|
|
138
|
+
player 2's side - even one that has nothing to do with player 1 - falls through and closes player
|
|
139
139
|
1's popover. That defeats the point of letting both players drive their own settings at once.
|
|
140
140
|
|
|
141
141
|
The fix is two overlays with an asymmetric relationship, not two independent halves:
|
|
@@ -147,18 +147,18 @@ const p2Host = usePopoverHost()
|
|
|
147
147
|
return (
|
|
148
148
|
<View style={styles.container}>
|
|
149
149
|
{/* Player 1's overlay covers the ENTIRE screen. This is correct even though player 1 visually
|
|
150
|
-
only owns "their side"
|
|
150
|
+
only owns "their side" - any shared-settings row that reads as "player 1's" (see your own
|
|
151
151
|
layout) may not stay confined to a literal half of the screen, and this overlay needs to cover
|
|
152
152
|
everywhere player 1 might have something open. */}
|
|
153
153
|
<PressAwayOverlay active={p1Host.openId !== null} onPress={p1Host.close} />
|
|
154
154
|
|
|
155
155
|
{/* Player 2's overlay is scoped to just their own zone (half the screen, whichever side
|
|
156
|
-
they're on
|
|
156
|
+
they're on - see @tastic/split-screen's panelLayout for how to size this) - and, being a later
|
|
157
157
|
sibling, it paints on top of player 1's overlay within that rect, so a tap there is always
|
|
158
158
|
player 2's to own.
|
|
159
159
|
|
|
160
160
|
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
|
|
161
|
+
for p2Host, then closing player 2's popover would unmount it - exposing player 1's full-screen
|
|
162
162
|
overlay underneath for the rest of that render, and the next tap anywhere on player 2's side
|
|
163
163
|
(even if player 2 has nothing open) would fall through and close player 1's popover instead.
|
|
164
164
|
Mounting it any time p1Host is open too "shields" player 2's zone from player 1's overlay
|
|
@@ -166,7 +166,7 @@ return (
|
|
|
166
166
|
closed. */}
|
|
167
167
|
<PressAwayOverlay active={p1Host.openId !== null || p2Host.openId !== null} onPress={p2Host.close} style={styles.p2Zone} />
|
|
168
168
|
|
|
169
|
-
{/* Real content goes after both overlays
|
|
169
|
+
{/* Real content goes after both overlays - plain paint order (later siblings on top) is what
|
|
170
170
|
keeps every real trigger/button directly tappable; only genuinely empty space falls through to
|
|
171
171
|
the overlays above. */}
|
|
172
172
|
<Player1Panel host={p1Host} />
|
|
@@ -178,21 +178,21 @@ return (
|
|
|
178
178
|
The general rule for N players: the "owner" of the broadest zone (usually whoever's panel absorbs
|
|
179
179
|
your shared/global settings) gets the whole-screen overlay; every other player's zone-scoped overlay
|
|
180
180
|
must stay mounted (shielding, if not actually closing anything) whenever *any* host with broader
|
|
181
|
-
reach is open
|
|
181
|
+
reach is open - not just their own.
|
|
182
182
|
|
|
183
183
|
## Integrating BaseSettingsDialog
|
|
184
184
|
|
|
185
|
-
`BaseSettingsDialog` is a shared shell, not a drop-in screen
|
|
185
|
+
`BaseSettingsDialog` is a shared shell, not a drop-in screen - every one of the five consuming apps
|
|
186
186
|
(AirHockey, BoxHockey, Pong, LightCycles, Snake) wraps it in its own local
|
|
187
187
|
`src/components/SettingsDialog.tsx` adapter rather than rendering it directly from a route. The
|
|
188
188
|
adapter's whole job is to bridge whatever local settings mechanism the app uses onto this package's
|
|
189
|
-
fixed prop contract
|
|
189
|
+
fixed prop contract - it should carry no UI of its own beyond that plumbing.
|
|
190
190
|
|
|
191
191
|
That local mechanism genuinely varies per app. AirHockey/BoxHockey/LightCycles thread a
|
|
192
192
|
`settings`/`setSettings` pair in as props from a caller-owned `GameSettings` object (each adapter's
|
|
193
193
|
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
|
|
194
|
+
its own `useGameSettings()` hook internally instead of taking it as props. Snake - the one app in
|
|
195
|
+
the fleet with real Redux Toolkit state - reads `lockOrientation`/`deferBottomEdgeGestures` via
|
|
196
196
|
`useSelector` and writes them back via `dispatch(gameActions.setLockOrientation(...))` directly
|
|
197
197
|
inside the adapter, with nothing threaded through props at all. `BaseSettingsDialog` doesn't care
|
|
198
198
|
which of these an app uses: sound, haptics, appearance, and update-checking are identical across
|
|
@@ -202,7 +202,7 @@ actually needs to source itself, however it does that.
|
|
|
202
202
|
|
|
203
203
|
Every adapter also passes `version={release.otaVersion}` (so the dialog's own update-check button
|
|
204
204
|
knows what it's currently running) and `onUpdateError`, wired to a real `useToast()` call. Wire this
|
|
205
|
-
one up
|
|
205
|
+
one up - an adapter that leaves `onUpdateError` unset silently swallows a failed update check
|
|
206
206
|
instead of surfacing it anywhere, which is a real bug this fleet actually shipped in one app before
|
|
207
207
|
being caught and fixed. If your app has no toast system, route it to whatever your own
|
|
208
208
|
error-surfacing convention is; just don't leave it as a no-op.
|
|
@@ -231,30 +231,30 @@ export function SettingsDialog({ visible, onDismiss, rotation = 0, settings, set
|
|
|
231
231
|
|
|
232
232
|
## Zone-aware popovers (`useZoneClampedAlign`)
|
|
233
233
|
|
|
234
|
-
`useAutoAlign`
|
|
235
|
-
`LabeledDropdown`
|
|
234
|
+
`useAutoAlign` - and everything built on it, including `SectionedDropdown`, `InlineColorPicker`, and
|
|
235
|
+
`LabeledDropdown` - only knows about the full device window edge. It has no concept of a
|
|
236
236
|
`@tastic/split-screen` zone boundary: the shared neutral band between two players' halves, or the
|
|
237
237
|
line where one player's zone stops and the other's begins. A popover host that lives entirely
|
|
238
238
|
inside one player's zone (rather than centered app-wide, the way `BaseSettingsDialog` is) can end up
|
|
239
239
|
choosing an alignment that opens toward the shared band, or overflows past the zone boundary into
|
|
240
|
-
the other player's half
|
|
240
|
+
the other player's half - not because `useAutoAlign` measured wrong, but because the window edge it
|
|
241
241
|
correctly measured against isn't the boundary that actually matters there.
|
|
242
242
|
|
|
243
|
-
LightCycles hit this for real
|
|
244
|
-
inside a rotated split-screen zone
|
|
243
|
+
LightCycles hit this for real - its `PlayerSetupPanel`-hosted color/CPU-difficulty pickers live
|
|
244
|
+
inside a rotated split-screen zone - and originally built its own local fix for it rather than
|
|
245
245
|
waiting for this package to grow zone-awareness. That fix is now `useZoneClampedAlign`, a
|
|
246
246
|
first-class export of this package: it wraps `useAutoAlign`'s result and re-derives the vertical
|
|
247
247
|
decision (and `maxHeight`) against the zone's real edge, via `@tastic/split-screen`'s
|
|
248
|
-
`useZoneBounds()`, instead of the window edge
|
|
248
|
+
`useZoneBounds()`, instead of the window edge - including correcting for a 180°-rotated zone
|
|
249
249
|
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
|
|
250
|
+
`@tastic/split-screen` is a peer dependency of this package because of this hook specifically - it's
|
|
251
251
|
only ever imported if you actually call `useZoneClampedAlign`.
|
|
252
252
|
|
|
253
253
|
Same signature and return shape as `useAutoAlign` (`open`, `contentWidth`, `contentHeight` in;
|
|
254
|
-
`align`, `verticalAlign`, `maxHeight`, `measured`, `triggerRef` out
|
|
254
|
+
`align`, `verticalAlign`, `maxHeight`, `measured`, `triggerRef` out - exported as `AlignResult`, see
|
|
255
255
|
below) so it's a drop-in replacement anywhere `useAutoAlign` is called directly. Outside a
|
|
256
256
|
`@tastic/split-screen` zone (`useZoneBounds()` returns `null`), it's a pure passthrough to
|
|
257
|
-
`useAutoAlign`'s own decision
|
|
257
|
+
`useAutoAlign`'s own decision - safe to reach for even in a component that might or might not end up
|
|
258
258
|
inside one:
|
|
259
259
|
|
|
260
260
|
```tsx
|
|
@@ -272,10 +272,10 @@ their own internal, zone-blind `useAutoAlign` call:
|
|
|
272
272
|
`ControlSchemePicker` forwards both `alignOverride` and `rotation` straight through to the
|
|
273
273
|
`SectionedDropdown` it renders internally, so the same call works there too. `PlayerSetupPanel`'s
|
|
274
274
|
own internal color/CPU-difficulty pickers aren't reachable this way (they're not a `ReactNode` slot
|
|
275
|
-
your own code renders
|
|
275
|
+
your own code renders - see "The secondPicker slot" below), so it exposes `colorAlignOverride` and
|
|
276
276
|
`cpuDifficultyAlignOverride` props instead, forwarded straight to `InlineColorPicker`'s and
|
|
277
277
|
`LabeledDropdown`'s own `alignOverride` internally. Computing `open` for either one needs that
|
|
278
|
-
picker's own popover id, which `PlayerSetupPanel` derives internally from `idPrefix`
|
|
278
|
+
picker's own popover id, which `PlayerSetupPanel` derives internally from `idPrefix` - call
|
|
279
279
|
`getColorPopoverId(idPrefix)`/`getCpuDifficultyPopoverId(idPrefix)` (both exported) rather than
|
|
280
280
|
guessing or hardcoding the naming convention yourself:
|
|
281
281
|
|
|
@@ -292,7 +292,7 @@ For any other popover-hosting component that might sit inside a split-screen zon
|
|
|
292
292
|
## The secondPicker slot (read this before wiring one up)
|
|
293
293
|
|
|
294
294
|
`PlayerSetupPanel`'s `secondPicker` is a caller-rendered `ReactNode` slot next to the built-in color
|
|
295
|
-
picker, not a fixed prop shape
|
|
295
|
+
picker, not a fixed prop shape - the five apps this component was extracted from each wanted a
|
|
296
296
|
genuinely different second control there (a `ControlSchemePicker`, a raw `SectionedDropdown`, or
|
|
297
297
|
nothing at all), so rather than this package baking in one shape most consumers don't need, the
|
|
298
298
|
panel just renders whatever `secondPicker` is given, wherever it's given.
|
|
@@ -316,11 +316,11 @@ If whatever you slot in opens its own popover, pass its popover id as `secondPic
|
|
|
316
316
|
Skipping `secondPickerId` when your slotted picker does open a popover is easy to get wrong, and it
|
|
317
317
|
fails silently rather than throwing: `PlayerSetupPanel` uses it to decide whether its own
|
|
318
318
|
`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
|
|
319
|
+
later sibling (the `ReadyButton` beneath it, or another panel next to it - see `pickerRowOpen` in
|
|
320
320
|
the component's own source). Without `secondPickerId`, the panel has no way to know your picker's
|
|
321
321
|
popover is open, so that elevation never kicks in, and your popover can silently render underneath
|
|
322
322
|
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`
|
|
323
|
+
plain toggle, static content, or nothing at all), omit `secondPickerId` - there's nothing for it to
|
|
324
324
|
track. And if that slotted picker's own popover lives inside a split-screen zone, see "Zone-aware
|
|
325
325
|
popovers" above for how to wire `alignOverride`/`rotation` into whatever you construct there.
|
|
326
326
|
|
|
@@ -342,26 +342,26 @@ interface QuitConfirmation {
|
|
|
342
342
|
|
|
343
343
|
Wire `confirmVisible`/`cancelBack` straight to `<ConfirmDialog visible={...} onCancel={...}>`, and
|
|
344
344
|
`requestBack` to the back button's `onPress`. The hook deliberately doesn't own or render the
|
|
345
|
-
`<ConfirmDialog>` itself
|
|
345
|
+
`<ConfirmDialog>` itself - title/message/icon/labels/rotation stay authored at each call site (a
|
|
346
346
|
score pairing in most apps, a round-history pip row in LightCycles), matching this package's
|
|
347
347
|
established "hooks return data, JSX stays at the call site" convention (`usePopoverHost`,
|
|
348
348
|
`useAutoAlign`, `useZoneClampedAlign` all do the same).
|
|
349
349
|
|
|
350
|
-
**`hasProgress` is a lazy `() => boolean` getter, not a plain `boolean`
|
|
350
|
+
**`hasProgress` is a lazy `() => boolean` getter, not a plain `boolean` - this is a load-bearing
|
|
351
351
|
correction, not a style choice.** A plain-boolean argument forces the caller to compute it every
|
|
352
352
|
render from state that, in several apps, doesn't exist yet at that point in the render.
|
|
353
353
|
AirHockey/BoxHockey/Pong all fold this hook's own `confirmVisible` into a
|
|
354
|
-
`paused = settingsOpen || confirmVisible` that gates `useGameState(paused)`
|
|
354
|
+
`paused = settingsOpen || confirmVisible` that gates `useGameState(paused)` - and
|
|
355
355
|
`useGameState`'s *return value* (scores, lives, bricks) is exactly what `hasProgress` needs to
|
|
356
356
|
read. A plain boolean here would create a real circular render dependency:
|
|
357
357
|
`confirmVisible → paused → useGameState(paused) → {scores/lives/bricks} → hasProgress →
|
|
358
358
|
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
|
|
359
|
+
`requestBack`, at tap time, well after `useGameState` has already produced that render's state -
|
|
360
360
|
the getter's closure just reads whatever `scores` is bound to *by then*, the same way a hand-rolled
|
|
361
361
|
`onBackPress` closure always did before this hook existed:
|
|
362
362
|
|
|
363
363
|
```tsx
|
|
364
|
-
// AirHockey's game.tsx
|
|
364
|
+
// AirHockey's game.tsx - hasProgress reads `scores`, a value useGameState (below) hasn't produced
|
|
365
365
|
// yet at the point useQuitConfirmation is called. That's fine: the getter is only ever called from
|
|
366
366
|
// inside requestBack, well after this render has finished and `scores` is a real value.
|
|
367
367
|
const [settingsOpen, setSettingsOpen] = useState(false)
|
|
@@ -378,7 +378,7 @@ const { scores } = state
|
|
|
378
378
|
<ConfirmDialog
|
|
379
379
|
visible={confirmVisible}
|
|
380
380
|
title='Quit Match?'
|
|
381
|
-
message={`${scores[0]}
|
|
381
|
+
message={`${scores[0]} - ${scores[1]}`}
|
|
382
382
|
confirmLabel='Quit'
|
|
383
383
|
cancelLabel='Cancel'
|
|
384
384
|
onConfirm={safeBack}
|
|
@@ -387,8 +387,8 @@ const { scores } = state
|
|
|
387
387
|
```
|
|
388
388
|
|
|
389
389
|
A game with no such circular dependency (Snake reads `humanScore`/`opponentScore`, LightCycles
|
|
390
|
-
reads `roundHistory.length`) still passes a getter
|
|
391
|
-
|
|
390
|
+
reads `roundHistory.length`) still passes a getter - `() => humanScore > 0 || (opponentScore ?? 0) > 0`;
|
|
391
|
+
it's just a plain wrapper around an expression that could have been a bare boolean there; the
|
|
392
392
|
signature stays uniform across every call site rather than special-casing the apps that don't
|
|
393
393
|
currently need the deferred read.
|
|
394
394
|
|
|
@@ -396,13 +396,13 @@ currently need the deferred read.
|
|
|
396
396
|
|
|
397
397
|
Two presentational components for an achievements/stats screen, designed to pair with
|
|
398
398
|
[`@tastic/achievements`](https://github.com/jayrdeaton/react-native-game-achievements)'s own
|
|
399
|
-
`getAchievementCatalogRows`
|
|
399
|
+
`getAchievementCatalogRows` - the row-computation logic (reading `ACHIEVEMENT_CATALOG`,
|
|
400
400
|
`unlockedAchievements`, and stats) lives in that headless package (no react-native/Paper deps of
|
|
401
401
|
its own); the two components that actually render the result live here. Mirrors this package's
|
|
402
402
|
existing `AchievementRow` (hud, presentational, fully-precomputed props) / achievement-engine-shaped
|
|
403
403
|
(pure data) boundary.
|
|
404
404
|
|
|
405
|
-
**`AchievementCatalogSection`**
|
|
405
|
+
**`AchievementCatalogSection`** - an "ALL ACHIEVEMENTS" heading plus one `<AchievementRow>` per
|
|
406
406
|
row. Returns a `Fragment`, not a `View`: every call site renders the heading and rows as flat
|
|
407
407
|
siblings inside `BaseStatsScreen`'s own gap-spaced `ScrollView` content, and a wrapping container
|
|
408
408
|
here would double up that spacing. Resolves each row's locked-vs-unlocked `badgeColor` itself
|
|
@@ -418,9 +418,9 @@ interface AchievementCatalogSectionProps {
|
|
|
418
418
|
}
|
|
419
419
|
```
|
|
420
420
|
|
|
421
|
-
**`ActivityStatSection`**
|
|
421
|
+
**`ActivityStatSection`** - the "ACTIVITY" `<StatSection>` (Days Played / Day Streak / Best Day
|
|
422
422
|
Streak). Takes a plain structural `ActivityStats` shape rather than importing
|
|
423
|
-
`@tastic/achievements`' own `DayStreakState` type, even though the fields match exactly
|
|
423
|
+
`@tastic/achievements`' own `DayStreakState` type, even though the fields match exactly - this
|
|
424
424
|
component has zero dependency, not even type-only, on `@tastic/achievements`; any caller's own
|
|
425
425
|
stats object with these three fields satisfies it:
|
|
426
426
|
|
|
@@ -448,7 +448,7 @@ return (
|
|
|
448
448
|
|
|
449
449
|
`AchievementCatalogSection` and `AchievementUnlockList` (below) are the only places in this package
|
|
450
450
|
with a `@tastic/achievements` dependency, and both are type-only (`AchievementCatalogRow`,
|
|
451
|
-
`AchievementTier`)
|
|
451
|
+
`AchievementTier`) - never a value/runtime import. See Peer Dependencies below.
|
|
452
452
|
|
|
453
453
|
## Achievement unlocks on a result card (`AchievementUnlockList`)
|
|
454
454
|
|
|
@@ -479,7 +479,7 @@ pick it up. A guest seat's avatar draws the `color` you pass as-is, so pass it a
|
|
|
479
479
|
A short, skippable, paged "how to play" card that shows once on a fresh install and can be replayed on
|
|
480
480
|
demand from Settings. Import it from the **`@tastic/hud/guide` subpath**, not the main barrel: it's a
|
|
481
481
|
standalone build that pulls in only `react`, `react-native`, `react-native-paper`,
|
|
482
|
-
`react-native-reanimated`, `@rific/auto-paper` and `@rific/feedback-press`
|
|
482
|
+
`react-native-reanimated`, `@rific/auto-paper` and `@rific/feedback-press` - none of Skia,
|
|
483
483
|
`@tastic/core`, `@tastic/profile`, `@tastic/split-screen`, `@tastic/achievements` or `@rific/updater`
|
|
484
484
|
(all marked optional in `peerDependenciesMeta`), so an app that has none of them can use it. A test
|
|
485
485
|
(`src/__tests__/guideImports.test.ts`) fails if any file under `src/guide/` ever imports one.
|
|
@@ -487,7 +487,7 @@ standalone build that pulls in only `react`, `react-native`, `react-native-paper
|
|
|
487
487
|
```tsx
|
|
488
488
|
import { createGuideSlice, GuideProvider, SeatDiagram, SwipeHint, useAutoShowGuide, useGuide } from '@tastic/hud/guide'
|
|
489
489
|
|
|
490
|
-
// 1. A persisted "seen" version
|
|
490
|
+
// 1. A persisted "seen" version - its own slice, mounted under `guide` in your root reducer.
|
|
491
491
|
const guide = createGuideSlice({ currentVersion: 1 }) // { actions.markSeen, reducer, selectVersionSeen }
|
|
492
492
|
|
|
493
493
|
// 2. Once, near the root, wrapping your screens (inside PersistGate + your theme/Paper providers).
|
|
@@ -505,7 +505,7 @@ const guide = createGuideSlice({ currentVersion: 1 }) // { actions.markSeen, red
|
|
|
505
505
|
// 3. On the Home screen, and nowhere else: opens it once per session for a player who hasn't seen it.
|
|
506
506
|
useAutoShowGuide()
|
|
507
507
|
|
|
508
|
-
// 4. Replay from Settings
|
|
508
|
+
// 4. Replay from Settings - BaseSettingsDialog renders a "How to Play" row when this is passed.
|
|
509
509
|
const { open } = useGuide()
|
|
510
510
|
<BaseSettingsDialog {...props} onShowHowToPlay={open} />
|
|
511
511
|
```
|
|
@@ -514,7 +514,7 @@ const { open } = useGuide()
|
|
|
514
514
|
an existing player has a payload that simply predates the `guide` key. `createGuideSlice` stamps the
|
|
515
515
|
second with `currentVersion` instead of `0`, so an OTA update never greets them with a tutorial (they
|
|
516
516
|
can still replay it). Only finishing or skipping writes; a replay never does. Bump `currentVersion`
|
|
517
|
-
only for a real controls/rules change
|
|
517
|
+
only for a real controls/rules change - everyone sees it again.
|
|
518
518
|
- **If your last shipped build predates your redux-persist store, you also need the migrate.** Those
|
|
519
519
|
players have no root store yet, which looks exactly like a fresh install. Pass the AsyncStorage keys the
|
|
520
520
|
shipped build wrote (read them from the last shipped commit, not today's source):
|
|
@@ -529,7 +529,7 @@ const { open } = useGuide()
|
|
|
529
529
|
|
|
530
530
|
No root store but any of those keys present means "played an earlier build": that launch rehydrates
|
|
531
531
|
with an empty payload, which the slice grandfathers. An existing root store passes through untouched.
|
|
532
|
-
- **Home only.** Call `useAutoShowGuide()` from the Home screen
|
|
532
|
+
- **Home only.** Call `useAutoShowGuide()` from the Home screen - that placement is what keeps it from
|
|
533
533
|
firing over a live match or a two-seat loadout, where a single centered card can't be read right-way-up
|
|
534
534
|
by both players. Pass `false` to hold it back (e.g. while a deep link is being handled).
|
|
535
535
|
- **Leave the row off during a live match.** Omit `onShowHowToPlay` on any screen where Settings pauses or
|
|
@@ -549,6 +549,36 @@ const { open } = useGuide()
|
|
|
549
549
|
and only say what's true in every mode ("your half" is only true in two-player).
|
|
550
550
|
- **Not `onboarding`.** LightCycles and Snake already use that word for their 3-2-1-GO countdown.
|
|
551
551
|
|
|
552
|
+
## Fleet chrome without the heavy peers (`@tastic/hud/chrome`)
|
|
553
|
+
|
|
554
|
+
The shared dialogs, stats screen pieces, corner buttons and style tokens, from a standalone entry that never
|
|
555
|
+
touches the main barrel. It's for an app that has `@tastic/core` and `@rific/updater` but none of Skia,
|
|
556
|
+
`@tastic/profile` or `@tastic/split-screen` (Hangman). It exports, with their props types:
|
|
557
|
+
|
|
558
|
+
- Dialogs: `BaseSettingsDialog`, `ConfirmDialog`, `UpdateDialog`, `RotatedFrame`
|
|
559
|
+
- Stats: `BaseStatsScreen`, `StatSection`, `StatRow`, `AchievementRow`, `LOCKED_BADGE_COLOR`,
|
|
560
|
+
`AchievementCatalogSection`, `ActivityStatSection`
|
|
561
|
+
- Chrome: `CornerActionButtons`, `ContentGutter`, `StaggeredWord`, `getStaggeredWordDuration`
|
|
562
|
+
- Style tokens: `getOverlayCardColors`, `overlayStyles`, `overlayActionStyles`, `MONO_FONT`
|
|
563
|
+
|
|
564
|
+
Each is the same component the main entry exports and is documented above. Only the import path changes:
|
|
565
|
+
|
|
566
|
+
```tsx
|
|
567
|
+
import { BaseSettingsDialog, ConfirmDialog, CornerActionButtons } from '@tastic/hud/chrome'
|
|
568
|
+
```
|
|
569
|
+
|
|
570
|
+
It pulls in only `react`, `react-native`, `react-native-paper`, `react-native-reanimated`,
|
|
571
|
+
`react-native-safe-area-context`, `@rific/auto-paper`, `@rific/feedback-press`, `@rific/updater` and
|
|
572
|
+
`@tastic/core`. `AchievementCatalogSection` uses `@tastic/achievements` for types only, so nothing loads it at
|
|
573
|
+
runtime, but an app whose TypeScript resolves the `react-native` export condition (the fleet's tsconfig does)
|
|
574
|
+
type-checks hud's source and needs it installed. `AchievementUnlockList` isn't here because it renders
|
|
575
|
+
`@tastic/profile`'s `ProfileChip`. A test (`src/__tests__/chromeImports.test.ts`) walks the import graph from
|
|
576
|
+
`src/chrome/index.ts` and fails if anything it reaches imports another package, value-imports
|
|
577
|
+
`@tastic/achievements`, reaches the main barrel, or has a `.web` twin.
|
|
578
|
+
|
|
579
|
+
Metro resolves both entries to the same source files, so an app that imports some components from here and
|
|
580
|
+
others from `@tastic/hud` still bundles each module once.
|
|
581
|
+
|
|
552
582
|
## Install
|
|
553
583
|
|
|
554
584
|
Published to the public npm registry as `@tastic/hud`. Everything described above, including the
|
|
@@ -571,8 +601,9 @@ when no rotation applies.
|
|
|
571
601
|
## Peer dependencies
|
|
572
602
|
|
|
573
603
|
None of these are bundled, so use whatever versions your app already has. Six are marked `optional` in
|
|
574
|
-
`peerDependenciesMeta`
|
|
575
|
-
|
|
604
|
+
`peerDependenciesMeta` so a light app can install hud without the ones it doesn't use: `./guide` needs
|
|
605
|
+
none of the six, and `./chrome` needs only `@tastic/core` and `@rific/updater` (plus
|
|
606
|
+
`@tastic/achievements` to type-check, see above). **npm does not install optional peers for you**, so an app that imports the
|
|
576
607
|
main `@tastic/hud` entry must list every peer that entry loads in its own `dependencies`, including
|
|
577
608
|
the four optional ones marked "main entry" below. Without them, Metro fails to bundle with "Unable to
|
|
578
609
|
resolve module".
|
|
@@ -580,14 +611,14 @@ resolve module".
|
|
|
580
611
|
| Peer | Floor | Needed by |
|
|
581
612
|
| --- | --- | --- |
|
|
582
613
|
| `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
|
|
614
|
+
| `react-native-paper` | >=5.0.0 | main entry, `./guide`, `./chrome` |
|
|
615
|
+
| `react-native-reanimated` | >=3.0.0 | main entry (`StaggeredWord`), `./guide`, `./chrome` (`StaggeredWord`) |
|
|
616
|
+
| `@rific/auto-paper` | >=0.12.2 | main entry (`BaseSettingsDialog` needs the nested `Portal.Host` first shipped in 0.12.2), `./guide`, `./chrome` |
|
|
617
|
+
| `@rific/feedback-press` | >=0.10.2 | main entry (`useSoundSettings` first shipped in 0.10.2), `./guide`, `./chrome` |
|
|
618
|
+
| `react-native-safe-area-context` | >=5.0.0 | main entry (`useZoneClampedAlign`), `./chrome` (`RotatedFrame`, `BaseSettingsDialog`) |
|
|
619
|
+
| `@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) |
|
|
620
|
+
| `@rific/updater` | >=0.4.0, optional | main entry and `./chrome`: `BaseSettingsDialog`, `UpdateDialog` |
|
|
590
621
|
| `@tastic/profile` | >=0.4.0, optional | main entry: `PlayerSetupPanel`, `AchievementUnlockList` (`ProfileChip`) |
|
|
591
622
|
| `@tastic/split-screen` | >=0.5.0, optional | main entry: `useZoneClampedAlign` |
|
|
592
623
|
| `@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`) |
|
|
624
|
+
| `@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 |
|