@tastic/hud 0.8.4 → 0.12.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 +301 -20
- package/dist/index.d.mts +107 -15
- package/dist/index.d.ts +107 -15
- package/dist/index.js +556 -315
- package/dist/index.mjs +483 -262
- package/package.json +16 -8
- package/src/AchievementCatalogSection.tsx +60 -0
- package/src/ActivityStatSection.tsx +29 -0
- package/src/ConfirmDialog.tsx +30 -11
- package/src/ControlSchemePicker.tsx +11 -2
- package/src/InlineColorPicker.tsx +38 -11
- package/src/LabeledDropdown.tsx +5 -17
- package/src/PlayerSetupPanel.tsx +196 -0
- package/src/SectionedDropdown.tsx +18 -7
- package/src/StaggeredWord.tsx +114 -0
- package/src/index.ts +8 -2
- package/src/useAutoAlign.ts +17 -1
- package/src/useQuitConfirmation.ts +61 -0
- package/src/useZoneClampedAlign.ts +135 -0
package/README.md
CHANGED
|
@@ -38,6 +38,31 @@ corner of the screen.
|
|
|
38
38
|
- **`TriggerGauge`** — decorative ring of tick marks around a trigger, showing which option(s) are
|
|
39
39
|
active without opening the popover.
|
|
40
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
|
+
name header (a saved-profile switcher for a human seat, a CPU-difficulty `LabeledDropdown` for a
|
|
43
|
+
non-human one, or a plain label when neither applies), a color picker (`InlineColorPicker`)
|
|
44
|
+
optionally paired with a caller-rendered `secondPicker` slot, and a `ReadyButton` for human seats.
|
|
45
|
+
Ported from five apps' own near-identical `PlayerSetupPanel.tsx`/`LobbyPlayerPanel.tsx`
|
|
46
|
+
(AirHockey, BoxHockey, Pong, LightCycles, Snake), which had all independently converged on this
|
|
47
|
+
exact skeleton. See "The secondPicker slot" below before wiring up that one prop.
|
|
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
|
+
default for *any* screen with top-corner icon buttons, home screens included: every app in the
|
|
51
|
+
fleet still hand-rolls its own trophy/cog `IconButton` pair on the home screen specifically
|
|
52
|
+
instead of reusing this component there, a real (if minor) missed-reuse case worth fixing next
|
|
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) — see `SharedActionBand` for
|
|
55
|
+
that case instead.
|
|
56
|
+
- **`StaggeredWord`** / **`getStaggeredWordDuration`** — the letter-by-letter stagger-in animation
|
|
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 — a
|
|
59
|
+
caller stacking several words (AirHockey's "AIR"/"HOCKEY", LightCycles' "Light"/"Cycles") renders
|
|
60
|
+
more than one `StaggeredWord` and threads `startIndex` through so the whole wordmark reads as one
|
|
61
|
+
continuous cascade instead of each word restarting its own stagger from zero.
|
|
62
|
+
`getStaggeredWordDuration(letterCount)` is the matching total-duration formula, exported as a
|
|
63
|
+
plain function rather than something read off a ref or hook, so a caller's own follow-on
|
|
64
|
+
animation (a paddle rally, a bouncing puck, a looping trail) can compute once, outside render,
|
|
65
|
+
when the lettering has mostly landed.
|
|
41
66
|
- **`PressAwayOverlay`** — an invisible full-bleed tap-catcher for press-away-to-close. This is the
|
|
42
67
|
one piece that needs care in a split-screen layout — see below.
|
|
43
68
|
- **`BaseSettingsDialog`** / **`BaseStatsScreen`** / **`ConfirmDialog`** — shared shells for the
|
|
@@ -118,37 +143,293 @@ your shared/global settings) gets the whole-screen overlay; every other player's
|
|
|
118
143
|
must stay mounted (shielding, if not actually closing anything) whenever *any* host with broader
|
|
119
144
|
reach is open — not just their own.
|
|
120
145
|
|
|
121
|
-
##
|
|
146
|
+
## Integrating BaseSettingsDialog
|
|
122
147
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
`
|
|
126
|
-
|
|
127
|
-
|
|
148
|
+
`BaseSettingsDialog` is a shared shell, not a drop-in screen — every one of the five consuming apps
|
|
149
|
+
(AirHockey, BoxHockey, Pong, LightCycles, Snake) wraps it in its own local
|
|
150
|
+
`src/components/SettingsDialog.tsx` adapter rather than rendering it directly from a route. The
|
|
151
|
+
adapter's whole job is to bridge whatever local settings mechanism the app uses onto this package's
|
|
152
|
+
fixed prop contract — it should carry no UI of its own beyond that plumbing.
|
|
128
153
|
|
|
129
|
-
|
|
130
|
-
|
|
154
|
+
That local mechanism genuinely varies per app. AirHockey/BoxHockey/LightCycles thread a
|
|
155
|
+
`settings`/`setSettings` pair in as props from a caller-owned `GameSettings` object (each adapter's
|
|
156
|
+
own three call sites already have one in scope for other reasons). Pong reads that same shape off
|
|
157
|
+
its own `useGameSettings()` hook internally instead of taking it as props. Snake — the one app in
|
|
158
|
+
the fleet with real Redux Toolkit state — reads `lockOrientation`/`deferBottomEdgeGestures` via
|
|
159
|
+
`useSelector` and writes them back via `dispatch(gameActions.setLockOrientation(...))` directly
|
|
160
|
+
inside the adapter, with nothing threaded through props at all. `BaseSettingsDialog` doesn't care
|
|
161
|
+
which of these an app uses: sound, haptics, appearance, and update-checking are identical across
|
|
162
|
+
every app and live entirely inside this package now, so `lockOrientation`/`onLockOrientationChange`
|
|
163
|
+
and `deferBottomEdgeGestures`/`onDeferBottomEdgeGestures` are the only two settings an adapter
|
|
164
|
+
actually needs to source itself, however it does that.
|
|
165
|
+
|
|
166
|
+
Every adapter also passes `version={release.otaVersion}` (so the dialog's own update-check button
|
|
167
|
+
knows what it's currently running) and `onUpdateError`, wired to a real `useToast()` call. Wire this
|
|
168
|
+
one up — an adapter that leaves `onUpdateError` unset silently swallows a failed update check
|
|
169
|
+
instead of surfacing it anywhere, which is a real bug this fleet actually shipped in one app before
|
|
170
|
+
being caught and fixed. If your app has no toast system, route it to whatever your own
|
|
171
|
+
error-surfacing convention is; just don't leave it as a no-op.
|
|
172
|
+
|
|
173
|
+
A minimal adapter, following the shape every current one uses:
|
|
174
|
+
|
|
175
|
+
```tsx
|
|
176
|
+
export function SettingsDialog({ visible, onDismiss, rotation = 0, settings, setSettings }: SettingsDialogProps) {
|
|
177
|
+
const { error } = useToast()
|
|
178
|
+
|
|
179
|
+
return (
|
|
180
|
+
<BaseSettingsDialog
|
|
181
|
+
visible={visible}
|
|
182
|
+
onDismiss={onDismiss}
|
|
183
|
+
rotation={rotation}
|
|
184
|
+
version={release.otaVersion}
|
|
185
|
+
lockOrientation={settings.lockOrientation}
|
|
186
|
+
onLockOrientationChange={(value) => setSettings({ lockOrientation: value })}
|
|
187
|
+
deferBottomEdgeGestures={settings.deferBottomEdgeGestures}
|
|
188
|
+
onDeferBottomEdgeGestures={(value) => setSettings({ deferBottomEdgeGestures: value })}
|
|
189
|
+
onUpdateError={(message) => error('Update check failed', message)}
|
|
190
|
+
/>
|
|
191
|
+
)
|
|
192
|
+
}
|
|
131
193
|
```
|
|
132
194
|
|
|
133
|
-
|
|
195
|
+
## Zone-aware popovers (`useZoneClampedAlign`)
|
|
134
196
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
197
|
+
`useAutoAlign` — and everything built on it, including `SectionedDropdown`, `InlineColorPicker`, and
|
|
198
|
+
`LabeledDropdown` — only knows about the full device window edge. It has no concept of a
|
|
199
|
+
`@tastic/split-screen` zone boundary: the shared neutral band between two players' halves, or the
|
|
200
|
+
line where one player's zone stops and the other's begins. A popover host that lives entirely
|
|
201
|
+
inside one player's zone (rather than centered app-wide, the way `BaseSettingsDialog` is) can end up
|
|
202
|
+
choosing an alignment that opens toward the shared band, or overflows past the zone boundary into
|
|
203
|
+
the other player's half — not because `useAutoAlign` measured wrong, but because the window edge it
|
|
204
|
+
correctly measured against isn't the boundary that actually matters there.
|
|
205
|
+
|
|
206
|
+
LightCycles hit this for real — its `PlayerSetupPanel`-hosted color/CPU-difficulty pickers live
|
|
207
|
+
inside a rotated split-screen zone — and originally built its own local fix for it rather than
|
|
208
|
+
waiting for this package to grow zone-awareness. That fix is now `useZoneClampedAlign`, a
|
|
209
|
+
first-class export of this package: it wraps `useAutoAlign`'s result and re-derives the vertical
|
|
210
|
+
decision (and `maxHeight`) against the zone's real edge, via `@tastic/split-screen`'s
|
|
211
|
+
`useZoneBounds()`, instead of the window edge — including correcting for a 180°-rotated zone
|
|
212
|
+
inverting `PopoverBody`'s local 'above'/'below' labels relative to their real-world direction.
|
|
213
|
+
`@tastic/split-screen` is a peer dependency of this package because of this hook specifically — it's
|
|
214
|
+
only ever imported if you actually call `useZoneClampedAlign`.
|
|
215
|
+
|
|
216
|
+
Same signature and return shape as `useAutoAlign` (`open`, `contentWidth`, `contentHeight` in;
|
|
217
|
+
`align`, `verticalAlign`, `maxHeight`, `measured`, `triggerRef` out — exported as `AlignResult`, see
|
|
218
|
+
below) so it's a drop-in replacement anywhere `useAutoAlign` is called directly. Outside a
|
|
219
|
+
`@tastic/split-screen` zone (`useZoneBounds()` returns `null`), it's a pure passthrough to
|
|
220
|
+
`useAutoAlign`'s own decision — safe to reach for even in a component that might or might not end up
|
|
221
|
+
inside one:
|
|
222
|
+
|
|
223
|
+
```tsx
|
|
224
|
+
const auto = useZoneClampedAlign(open, POPOVER_WIDTH, contentHeight)
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
`LabeledDropdown`, `SectionedDropdown`, and `InlineColorPicker` all accept the result of the above
|
|
228
|
+
via an `alignOverride` prop (typed `AlignResult`, also exported), substituting it wholesale for
|
|
229
|
+
their own internal, zone-blind `useAutoAlign` call:
|
|
230
|
+
|
|
231
|
+
```tsx
|
|
232
|
+
<SectionedDropdown id='p1-arena' host={host} sections={sections} accentColor={color} mutedColor={muted} dark={dark} alignOverride={useZoneClampedAlign(open, MENU_WIDTH, contentHeight)} />
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
`ControlSchemePicker` forwards both `alignOverride` and `rotation` straight through to the
|
|
236
|
+
`SectionedDropdown` it renders internally, so the same call works there too. `PlayerSetupPanel`'s
|
|
237
|
+
own internal color/CPU-difficulty pickers aren't reachable this way (they're not a `ReactNode` slot
|
|
238
|
+
your own code renders — see "The secondPicker slot" below), so it exposes `colorAlignOverride` and
|
|
239
|
+
`cpuDifficultyAlignOverride` props instead, forwarded straight to `InlineColorPicker`'s and
|
|
240
|
+
`LabeledDropdown`'s own `alignOverride` internally. Computing `open` for either one needs that
|
|
241
|
+
picker's own popover id, which `PlayerSetupPanel` derives internally from `idPrefix` — call
|
|
242
|
+
`getColorPopoverId(idPrefix)`/`getCpuDifficultyPopoverId(idPrefix)` (both exported) rather than
|
|
243
|
+
guessing or hardcoding the naming convention yourself:
|
|
244
|
+
|
|
245
|
+
```tsx
|
|
246
|
+
const cpuOpen = host.openId === getCpuDifficultyPopoverId('p2')
|
|
247
|
+
const cpuAlign = useZoneClampedAlign(cpuOpen, LABELED_DROPDOWN_POPOVER_WIDTH, getLabeledDropdownContentHeight(cpuDifficultyOptions.length))
|
|
248
|
+
|
|
249
|
+
<PlayerSetupPanel idPrefix='p2' host={host} cpuDifficulty={difficulty} cpuDifficultyOptions={cpuDifficultyOptions} onCpuDifficultyChange={setDifficulty} cpuDifficultyAlignOverride={cpuAlign} /* ...the rest of your usual props */ />
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
For any other popover-hosting component that might sit inside a split-screen zone, call
|
|
253
|
+
`useZoneClampedAlign` directly in place of `useAutoAlign`.
|
|
254
|
+
|
|
255
|
+
## The secondPicker slot (read this before wiring one up)
|
|
256
|
+
|
|
257
|
+
`PlayerSetupPanel`'s `secondPicker` is a caller-rendered `ReactNode` slot next to the built-in color
|
|
258
|
+
picker, not a fixed prop shape — the five apps this component was extracted from each wanted a
|
|
259
|
+
genuinely different second control there (a `ControlSchemePicker`, a raw `SectionedDropdown`, or
|
|
260
|
+
nothing at all), so rather than this package baking in one shape most consumers don't need, the
|
|
261
|
+
panel just renders whatever `secondPicker` is given, wherever it's given.
|
|
262
|
+
|
|
263
|
+
If whatever you slot in opens its own popover, pass its popover id as `secondPickerId` too:
|
|
264
|
+
|
|
265
|
+
```tsx
|
|
266
|
+
<PlayerSetupPanel
|
|
267
|
+
idPrefix='p1'
|
|
268
|
+
host={sharedHost}
|
|
269
|
+
color={color}
|
|
270
|
+
onColorChange={setColor}
|
|
271
|
+
swatches={swatches}
|
|
272
|
+
isHuman
|
|
273
|
+
secondPicker={<ControlSchemePicker id='p1-scheme' host={sharedHost} value={scheme} onChange={setScheme} />}
|
|
274
|
+
secondPickerId='p1-scheme'
|
|
275
|
+
dark={dark}
|
|
276
|
+
/>
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
Skipping `secondPickerId` when your slotted picker does open a popover is easy to get wrong, and it
|
|
280
|
+
fails silently rather than throwing: `PlayerSetupPanel` uses it to decide whether its own
|
|
281
|
+
`pickerRow` needs the elevated z-index that lets a popover escape the row's bounds and paint above a
|
|
282
|
+
later sibling (the `ReadyButton` beneath it, or another panel next to it — see `pickerRowOpen` in
|
|
283
|
+
the component's own source). Without `secondPickerId`, the panel has no way to know your picker's
|
|
284
|
+
popover is open, so that elevation never kicks in, and your popover can silently render underneath
|
|
285
|
+
sibling content instead of on top of it. If your `secondPicker` never opens a popover of its own (a
|
|
286
|
+
plain toggle, static content, or nothing at all), omit `secondPickerId` — there's nothing for it to
|
|
287
|
+
track. And if that slotted picker's own popover lives inside a split-screen zone, see "Zone-aware
|
|
288
|
+
popovers" above for how to wire `alignOverride`/`rotation` into whatever you construct there.
|
|
289
|
+
|
|
290
|
+
## Quit confirmation (`useQuitConfirmation`)
|
|
291
|
+
|
|
292
|
+
Captures the `onBackPress` + `<ConfirmDialog>` "Quit Match?" pattern every fleet `game.tsx` used to
|
|
293
|
+
hand-wire on its own (Snake, AirHockey, BoxHockey, Pong, LightCycles): interrupt backing out of a
|
|
294
|
+
match with a confirmation only when there's progress worth losing, otherwise back out immediately.
|
|
295
|
+
|
|
296
|
+
```ts
|
|
297
|
+
function useQuitConfirmation(hasProgress: () => boolean, onConfirmedBack: () => void): QuitConfirmation
|
|
139
298
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
299
|
+
interface QuitConfirmation {
|
|
300
|
+
confirmVisible: boolean
|
|
301
|
+
requestBack: () => void
|
|
302
|
+
cancelBack: () => void
|
|
303
|
+
}
|
|
143
304
|
```
|
|
144
305
|
|
|
145
|
-
|
|
146
|
-
|
|
306
|
+
Wire `confirmVisible`/`cancelBack` straight to `<ConfirmDialog visible={...} onCancel={...}>`, and
|
|
307
|
+
`requestBack` to the back button's `onPress`. The hook deliberately doesn't own or render the
|
|
308
|
+
`<ConfirmDialog>` itself — title/message/icon/labels/rotation stay authored at each call site (a
|
|
309
|
+
score pairing in most apps, a round-history pip row in LightCycles), matching this package's
|
|
310
|
+
established "hooks return data, JSX stays at the call site" convention (`usePopoverHost`,
|
|
311
|
+
`useAutoAlign`, `useZoneClampedAlign` all do the same).
|
|
312
|
+
|
|
313
|
+
**`hasProgress` is a lazy `() => boolean` getter, not a plain `boolean` — this is a load-bearing
|
|
314
|
+
correction, not a style choice.** A plain-boolean argument forces the caller to compute it every
|
|
315
|
+
render from state that, in several apps, doesn't exist yet at that point in the render.
|
|
316
|
+
AirHockey/BoxHockey/Pong all fold this hook's own `confirmVisible` into a
|
|
317
|
+
`paused = settingsOpen || confirmVisible` that gates `useGameState(paused)` — and
|
|
318
|
+
`useGameState`'s *return value* (scores, lives, bricks) is exactly what `hasProgress` needs to
|
|
319
|
+
read. A plain boolean here would create a real circular render dependency:
|
|
320
|
+
`confirmVisible → paused → useGameState(paused) → {scores/lives/bricks} → hasProgress →
|
|
321
|
+
confirmVisible`. The lazy getter breaks the cycle: `hasProgress` is only invoked inside
|
|
322
|
+
`requestBack`, at tap time, well after `useGameState` has already produced that render's state —
|
|
323
|
+
the getter's closure just reads whatever `scores` is bound to *by then*, the same way a hand-rolled
|
|
324
|
+
`onBackPress` closure always did before this hook existed:
|
|
325
|
+
|
|
326
|
+
```tsx
|
|
327
|
+
// AirHockey's game.tsx — hasProgress reads `scores`, a value useGameState (below) hasn't produced
|
|
328
|
+
// yet at the point useQuitConfirmation is called. That's fine: the getter is only ever called from
|
|
329
|
+
// inside requestBack, well after this render has finished and `scores` is a real value.
|
|
330
|
+
const [settingsOpen, setSettingsOpen] = useState(false)
|
|
331
|
+
const { confirmVisible, requestBack, cancelBack } = useQuitConfirmation(() => scores[0] > 0 || scores[1] > 0, safeBack)
|
|
332
|
+
const paused = settingsOpen || confirmVisible
|
|
333
|
+
|
|
334
|
+
const { state } = useGameState(board, scoreToWin, friction, paused)
|
|
335
|
+
const { scores } = state
|
|
336
|
+
|
|
337
|
+
// ...
|
|
338
|
+
|
|
339
|
+
<IconButton icon='arrow-left' onPress={requestBack} />
|
|
340
|
+
|
|
341
|
+
<ConfirmDialog
|
|
342
|
+
visible={confirmVisible}
|
|
343
|
+
title='Quit Match?'
|
|
344
|
+
message={`${scores[0]} – ${scores[1]}`}
|
|
345
|
+
confirmLabel='Quit'
|
|
346
|
+
cancelLabel='Cancel'
|
|
347
|
+
onConfirm={safeBack}
|
|
348
|
+
onCancel={cancelBack}
|
|
349
|
+
/>
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
A game with no such circular dependency (Snake reads `humanScore`/`opponentScore`, LightCycles
|
|
353
|
+
reads `roundHistory.length`) still passes a getter — `() => humanScore > 0 || (opponentScore ?? 0) > 0`
|
|
354
|
+
— it's just a plain wrapper around an expression that could have been a bare boolean there; the
|
|
355
|
+
signature stays uniform across every call site rather than special-casing the apps that don't
|
|
356
|
+
currently need the deferred read.
|
|
357
|
+
|
|
358
|
+
## Achievements catalog (`AchievementCatalogSection`, `ActivityStatSection`)
|
|
359
|
+
|
|
360
|
+
Two presentational components for an achievements/stats screen, designed to pair with
|
|
361
|
+
[`@tastic/achievements`](https://github.com/jayrdeaton/react-native-game-achievements)'s own
|
|
362
|
+
`getAchievementCatalogRows` — the row-computation logic (reading `ACHIEVEMENT_CATALOG`,
|
|
363
|
+
`unlockedAchievements`, and stats) lives in that headless package (no react-native/Paper deps of
|
|
364
|
+
its own); the two components that actually render the result live here. Mirrors this package's
|
|
365
|
+
existing `AchievementRow` (hud, presentational, fully-precomputed props) / achievement-engine-shaped
|
|
366
|
+
(pure data) boundary.
|
|
367
|
+
|
|
368
|
+
**`AchievementCatalogSection`** — an "ALL ACHIEVEMENTS" heading plus one `<AchievementRow>` per
|
|
369
|
+
row. Returns a `Fragment`, not a `View`: every call site renders the heading and rows as flat
|
|
370
|
+
siblings inside `BaseStatsScreen`'s own gap-spaced `ScrollView` content, and a wrapping container
|
|
371
|
+
here would double up that spacing. Resolves each row's locked-vs-unlocked `badgeColor` itself
|
|
372
|
+
(`row.tierColor` once `row.unlockedAt` is set, else the locked color) rather than upstream, since
|
|
373
|
+
`getAchievementCatalogRows` deliberately leaves that choice to whatever renders the rows.
|
|
374
|
+
|
|
375
|
+
```ts
|
|
376
|
+
interface AchievementCatalogSectionProps {
|
|
377
|
+
rows: AchievementCatalogRow[] // from @tastic/achievements' getAchievementCatalogRows
|
|
378
|
+
label?: string // default 'ALL ACHIEVEMENTS'
|
|
379
|
+
fg?: string
|
|
380
|
+
fgMuted?: string
|
|
381
|
+
}
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
**`ActivityStatSection`** — the "ACTIVITY" `<StatSection>` (Days Played / Day Streak / Best Day
|
|
385
|
+
Streak). Takes a plain structural `ActivityStats` shape rather than importing
|
|
386
|
+
`@tastic/achievements`' own `DayStreakState` type, even though the fields match exactly — this
|
|
387
|
+
component has zero dependency, not even type-only, on `@tastic/achievements`; any caller's own
|
|
388
|
+
stats object with these three fields satisfies it:
|
|
389
|
+
|
|
390
|
+
```ts
|
|
391
|
+
interface ActivityStatSectionProps {
|
|
392
|
+
stats: { distinctDaysPlayed: number; currentDayStreak: number; bestDayStreak: number }
|
|
393
|
+
}
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
Composing both with `@tastic/achievements`' own row computation:
|
|
397
|
+
|
|
398
|
+
```tsx
|
|
399
|
+
import { getAchievementCatalogRows } from '@tastic/achievements'
|
|
400
|
+
import { AchievementCatalogSection, ActivityStatSection, BaseStatsScreen } from '@tastic/hud'
|
|
401
|
+
|
|
402
|
+
const rows = getAchievementCatalogRows(ACHIEVEMENT_CATALOG, deviceStats, statsView, unlockedAchievements, profileId)
|
|
403
|
+
|
|
404
|
+
return (
|
|
405
|
+
<BaseStatsScreen /* ...your usual props */>
|
|
406
|
+
<ActivityStatSection stats={statsView} />
|
|
407
|
+
<AchievementCatalogSection rows={rows} />
|
|
408
|
+
</BaseStatsScreen>
|
|
409
|
+
)
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
`AchievementCatalogSection` is the only place in this package with a `@tastic/achievements`
|
|
413
|
+
dependency, and even there it's type-only (`AchievementCatalogRow`) — never a value/runtime
|
|
414
|
+
import. See Peer Dependencies below.
|
|
415
|
+
|
|
416
|
+
## Install
|
|
417
|
+
|
|
418
|
+
Published to the public npm registry as `@tastic/hud`. Everything described above, including the
|
|
419
|
+
self-reading rotation behavior on these four components (defaulting to `@tastic/core`'s own
|
|
420
|
+
`useRotation()` rather than a plain `0`), `PlayerSetupPanel`, and `StaggeredWord`, is live on npm.
|
|
421
|
+
|
|
422
|
+
```bash
|
|
423
|
+
npm install @tastic/hud
|
|
424
|
+
```
|
|
147
425
|
|
|
148
426
|
## Peer dependencies
|
|
149
427
|
|
|
150
428
|
`react`, `react-native`, `react-native-paper` (`Icon`, `IconButton`, `Text`), `@rific/auto-paper`
|
|
151
429
|
(`defaultColors`, `getContrastColor`, `getBlendedColor`, `SeedColor`), `@rific/feedback-press`
|
|
152
430
|
(`IconButton`, `TouchableRipple`), [`@tastic/core`](https://github.com/jayrdeaton/react-native-game-core)
|
|
153
|
-
(>=0.1.0 — `useRotation()`, see Rotation above)
|
|
154
|
-
|
|
431
|
+
(>=0.1.0 — `useRotation()`, see Rotation above),
|
|
432
|
+
[`@tastic/achievements`](https://github.com/jayrdeaton/react-native-game-achievements) (>=0.1.2 —
|
|
433
|
+
`AchievementCatalogRow`, in `AchievementCatalogSection.tsx` only, and type-only: no value/runtime
|
|
434
|
+
import, see Achievements catalog above) — none of these are bundled, so use whatever versions your
|
|
435
|
+
app already has.
|
package/dist/index.d.mts
CHANGED
|
@@ -1,12 +1,22 @@
|
|
|
1
1
|
import * as react from 'react';
|
|
2
2
|
import { ReactNode, RefObject } from 'react';
|
|
3
|
+
import { AchievementCatalogRow } from '@tastic/achievements';
|
|
3
4
|
import { ViewRotation } from '@tastic/core';
|
|
4
|
-
import { StyleProp, ViewStyle, View } from 'react-native';
|
|
5
|
+
import { StyleProp, ViewStyle, View, TextStyle, LayoutChangeEvent } from 'react-native';
|
|
5
6
|
import { SeedColor } from '@rific/auto-paper';
|
|
7
|
+
import { Profile } from '@tastic/profile';
|
|
6
8
|
export { SkiaGate, SkiaGateProps } from './SkiaGate.mjs';
|
|
7
9
|
|
|
10
|
+
interface Props$e {
|
|
11
|
+
rows: AchievementCatalogRow[];
|
|
12
|
+
label?: string;
|
|
13
|
+
fg?: string;
|
|
14
|
+
fgMuted?: string;
|
|
15
|
+
}
|
|
16
|
+
declare function AchievementCatalogSection({ rows, label, fg, fgMuted: fgMutedOverride }: Props$e): react.JSX.Element;
|
|
17
|
+
|
|
8
18
|
declare const LOCKED_BADGE_COLOR = "rgba(128,128,128,0.3)";
|
|
9
|
-
interface Props$
|
|
19
|
+
interface Props$d {
|
|
10
20
|
icon: string;
|
|
11
21
|
title: string;
|
|
12
22
|
description: string;
|
|
@@ -19,7 +29,17 @@ interface Props$c {
|
|
|
19
29
|
fgMuted?: string;
|
|
20
30
|
sectionBg?: string;
|
|
21
31
|
}
|
|
22
|
-
declare function AchievementRow({ icon, title, description, badgeColor, unlockedLabel, checkColor, progress, deviceMarker, fg: fgOverride, fgMuted: fgMutedOverride, sectionBg: sectionBgOverride }: Props$
|
|
32
|
+
declare function AchievementRow({ icon, title, description, badgeColor, unlockedLabel, checkColor, progress, deviceMarker, fg: fgOverride, fgMuted: fgMutedOverride, sectionBg: sectionBgOverride }: Props$d): react.JSX.Element;
|
|
33
|
+
|
|
34
|
+
interface ActivityStats {
|
|
35
|
+
distinctDaysPlayed: number;
|
|
36
|
+
currentDayStreak: number;
|
|
37
|
+
bestDayStreak: number;
|
|
38
|
+
}
|
|
39
|
+
interface Props$c {
|
|
40
|
+
stats: ActivityStats;
|
|
41
|
+
}
|
|
42
|
+
declare function ActivityStatSection({ stats }: Props$c): react.JSX.Element;
|
|
23
43
|
|
|
24
44
|
interface BaseSettingsDialogProps {
|
|
25
45
|
visible: boolean;
|
|
@@ -87,11 +107,18 @@ declare function ContentGutter({ maxContentWidth, leftGutter, rightGutter, style
|
|
|
87
107
|
type PopoverAlign = 'left' | 'right' | 'center';
|
|
88
108
|
type PopoverVerticalAlign = 'below' | 'above';
|
|
89
109
|
type PopoverRotation = ViewRotation;
|
|
110
|
+
interface AlignResult {
|
|
111
|
+
align: PopoverAlign;
|
|
112
|
+
verticalAlign: PopoverVerticalAlign;
|
|
113
|
+
maxHeight: number;
|
|
114
|
+
measured: boolean;
|
|
115
|
+
triggerRef: RefObject<View | null>;
|
|
116
|
+
}
|
|
90
117
|
declare function useAutoAlign(open: boolean, contentWidth: number, contentHeight: number, rotation?: PopoverRotation): {
|
|
91
118
|
align: PopoverAlign;
|
|
92
119
|
maxHeight: number;
|
|
93
120
|
measured: boolean;
|
|
94
|
-
triggerRef:
|
|
121
|
+
triggerRef: RefObject<View | null>;
|
|
95
122
|
verticalAlign: PopoverVerticalAlign;
|
|
96
123
|
};
|
|
97
124
|
|
|
@@ -137,6 +164,7 @@ interface Props$9 {
|
|
|
137
164
|
onAccentColor?: string;
|
|
138
165
|
dark: boolean;
|
|
139
166
|
align?: 'left' | 'right' | 'center';
|
|
167
|
+
alignOverride?: AlignResult;
|
|
140
168
|
rotation?: PopoverRotation;
|
|
141
169
|
autoDismiss?: boolean;
|
|
142
170
|
labelFontFamily?: string;
|
|
@@ -145,7 +173,7 @@ interface Props$9 {
|
|
|
145
173
|
clear: string;
|
|
146
174
|
};
|
|
147
175
|
}
|
|
148
|
-
declare function SectionedDropdown({ id, host, icon, accessibilityLabel, sections, accentColor, mutedColor, onAccentColor, dark, align: alignOverride, rotation: rotationOverride, autoDismiss, labelFontFamily, allClearLabels }: Props$9): react.JSX.Element;
|
|
176
|
+
declare function SectionedDropdown({ id, host, icon, accessibilityLabel, sections, accentColor, mutedColor, onAccentColor, dark, align: forcedAlign, alignOverride, rotation: rotationOverride, autoDismiss, labelFontFamily, allClearLabels }: Props$9): react.JSX.Element;
|
|
149
177
|
|
|
150
178
|
interface ControlSchemePickerProps<T extends string | number> {
|
|
151
179
|
id: string;
|
|
@@ -159,9 +187,11 @@ interface ControlSchemePickerProps<T extends string | number> {
|
|
|
159
187
|
onAccentColor?: string;
|
|
160
188
|
dark: boolean;
|
|
161
189
|
align?: 'left' | 'right' | 'center';
|
|
190
|
+
alignOverride?: AlignResult;
|
|
191
|
+
rotation?: PopoverRotation;
|
|
162
192
|
labelFontFamily?: string;
|
|
163
193
|
}
|
|
164
|
-
declare function ControlSchemePicker<T extends string | number>({ id, host, value, onChange, options, takenValue, accentColor, mutedColor, onAccentColor, dark, align, labelFontFamily }: ControlSchemePickerProps<T>): react.JSX.Element | null;
|
|
194
|
+
declare function ControlSchemePicker<T extends string | number>({ id, host, value, onChange, options, takenValue, accentColor, mutedColor, onAccentColor, dark, align, alignOverride, rotation, labelFontFamily }: ControlSchemePickerProps<T>): react.JSX.Element | null;
|
|
165
195
|
|
|
166
196
|
interface Props$8 {
|
|
167
197
|
onBack: () => void;
|
|
@@ -191,6 +221,10 @@ declare function CornerStatusBadges({ orientationMode, p1OnRight, p1, p2 }: Corn
|
|
|
191
221
|
|
|
192
222
|
declare const MONO_FONT: string;
|
|
193
223
|
|
|
224
|
+
declare function getInlineColorPickerContentSize(swatchCount: number, windowWidth: number, columns?: number): {
|
|
225
|
+
width: number;
|
|
226
|
+
height: number;
|
|
227
|
+
};
|
|
194
228
|
interface Props$7 {
|
|
195
229
|
id: string;
|
|
196
230
|
host: PopoverHost;
|
|
@@ -202,6 +236,7 @@ interface Props$7 {
|
|
|
202
236
|
allowSwapTaken?: boolean;
|
|
203
237
|
dark: boolean;
|
|
204
238
|
align?: 'left' | 'right' | 'center';
|
|
239
|
+
alignOverride?: AlignResult;
|
|
205
240
|
rotation?: PopoverRotation;
|
|
206
241
|
icon?: string;
|
|
207
242
|
tag?: string;
|
|
@@ -210,7 +245,7 @@ interface Props$7 {
|
|
|
210
245
|
columns?: number;
|
|
211
246
|
size?: number;
|
|
212
247
|
}
|
|
213
|
-
declare function InlineColorPicker({ id, host, value, onChange, previewValue, swatches, takenValue, allowSwapTaken, dark, align: alignOverride, rotation: rotationOverride, icon, tag, labelFontFamily, autoDismiss, columns, size }: Props$7): react.JSX.Element;
|
|
248
|
+
declare function InlineColorPicker({ id, host, value, onChange, previewValue, swatches, takenValue, allowSwapTaken, dark, align: forcedAlign, alignOverride, rotation: rotationOverride, icon, tag, labelFontFamily, autoDismiss, columns, size }: Props$7): react.JSX.Element;
|
|
214
249
|
|
|
215
250
|
declare const LABELED_DROPDOWN_POPOVER_WIDTH = 180;
|
|
216
251
|
declare function getLabeledDropdownContentHeight(optionCount: number): number;
|
|
@@ -219,13 +254,6 @@ interface LabeledDropdownOption<T extends string> {
|
|
|
219
254
|
label: string;
|
|
220
255
|
icon?: string;
|
|
221
256
|
}
|
|
222
|
-
interface AlignResult {
|
|
223
|
-
align: PopoverAlign;
|
|
224
|
-
verticalAlign: PopoverVerticalAlign;
|
|
225
|
-
maxHeight: number;
|
|
226
|
-
measured: boolean;
|
|
227
|
-
triggerRef: RefObject<View | null>;
|
|
228
|
-
}
|
|
229
257
|
interface Props$6<T extends string> {
|
|
230
258
|
id: string;
|
|
231
259
|
host: PopoverHost;
|
|
@@ -269,6 +297,42 @@ declare const overlayStyles: {
|
|
|
269
297
|
};
|
|
270
298
|
};
|
|
271
299
|
|
|
300
|
+
declare function getColorPopoverId(idPrefix: string): string;
|
|
301
|
+
declare function getCpuDifficultyPopoverId(idPrefix: string): string;
|
|
302
|
+
interface PlayerSetupPanelProps<C extends string = string> {
|
|
303
|
+
idPrefix: string;
|
|
304
|
+
host?: PopoverHost;
|
|
305
|
+
label?: string;
|
|
306
|
+
labelFontFamily?: string;
|
|
307
|
+
color: string;
|
|
308
|
+
onColorChange: (hex: string) => void;
|
|
309
|
+
swatches: SeedColor[];
|
|
310
|
+
takenColor?: string;
|
|
311
|
+
allowSwapTaken?: boolean;
|
|
312
|
+
colorAlignOverride?: AlignResult;
|
|
313
|
+
colorRotation?: PopoverRotation;
|
|
314
|
+
isHuman: boolean;
|
|
315
|
+
secondPicker?: ReactNode;
|
|
316
|
+
secondPickerId?: string;
|
|
317
|
+
ready?: boolean;
|
|
318
|
+
onToggleReady?: () => void;
|
|
319
|
+
dark: boolean;
|
|
320
|
+
showReadyButton?: boolean;
|
|
321
|
+
profiles?: Profile[];
|
|
322
|
+
selectedProfileId?: string | null;
|
|
323
|
+
takenProfileId?: string | null;
|
|
324
|
+
guestLabel?: string;
|
|
325
|
+
onProfileSelect?: (profile: Profile | null) => void;
|
|
326
|
+
onManageProfiles?: () => void;
|
|
327
|
+
cpuDifficulty?: C;
|
|
328
|
+
cpuDifficultyOptions?: LabeledDropdownOption<C>[];
|
|
329
|
+
onCpuDifficultyChange?: (value: C) => void;
|
|
330
|
+
cpuDifficultyAlignOverride?: AlignResult;
|
|
331
|
+
humanIcon?: string;
|
|
332
|
+
cpuIcon?: string;
|
|
333
|
+
}
|
|
334
|
+
declare function PlayerSetupPanel<C extends string = string>({ idPrefix, host, label, labelFontFamily, color, onColorChange, swatches, takenColor, allowSwapTaken, colorAlignOverride, colorRotation, isHuman, secondPicker, secondPickerId, ready, onToggleReady, dark, showReadyButton, profiles, selectedProfileId, takenProfileId, guestLabel, onProfileSelect, onManageProfiles, cpuDifficulty, cpuDifficultyOptions, onCpuDifficultyChange, cpuDifficultyAlignOverride, humanIcon, cpuIcon }: PlayerSetupPanelProps<C>): react.JSX.Element;
|
|
335
|
+
|
|
272
336
|
interface Props$5 {
|
|
273
337
|
visible: boolean;
|
|
274
338
|
children: ReactNode;
|
|
@@ -308,6 +372,22 @@ interface Props$2 {
|
|
|
308
372
|
}
|
|
309
373
|
declare function SharedActionBand({ onBack, onSettings, onRandomize, onReset, fg, popoverOpen, children }: Props$2): react.JSX.Element;
|
|
310
374
|
|
|
375
|
+
declare function getStaggeredWordDuration(letterCount: number): number;
|
|
376
|
+
interface StaggeredWordProps {
|
|
377
|
+
word: string;
|
|
378
|
+
color: string;
|
|
379
|
+
fontFamily: string;
|
|
380
|
+
fontSize: number;
|
|
381
|
+
lineHeight: number;
|
|
382
|
+
reducedMotion: boolean;
|
|
383
|
+
startIndex?: number;
|
|
384
|
+
fontWeight?: TextStyle['fontWeight'];
|
|
385
|
+
letterSpacing?: number;
|
|
386
|
+
style?: StyleProp<ViewStyle>;
|
|
387
|
+
onLayout?: (e: LayoutChangeEvent) => void;
|
|
388
|
+
}
|
|
389
|
+
declare function StaggeredWord({ word, color, fontFamily, fontSize, lineHeight, reducedMotion, startIndex, fontWeight, letterSpacing, style, onLayout }: StaggeredWordProps): react.JSX.Element;
|
|
390
|
+
|
|
311
391
|
interface Props$1 {
|
|
312
392
|
label: string;
|
|
313
393
|
value: string;
|
|
@@ -345,4 +425,16 @@ interface UpdateDialogProps {
|
|
|
345
425
|
}
|
|
346
426
|
declare function UpdateDialog({ rotation, autoCheck, autoPrompt, onError }: UpdateDialogProps): react.JSX.Element;
|
|
347
427
|
|
|
348
|
-
|
|
428
|
+
interface QuitConfirmation {
|
|
429
|
+
/** Wire directly to <ConfirmDialog visible={...}>. */
|
|
430
|
+
confirmVisible: boolean;
|
|
431
|
+
/** Wire directly to the back button's onPress (GameActionChips/IconButton onBack/onPress). */
|
|
432
|
+
requestBack: () => void;
|
|
433
|
+
/** Wire directly to <ConfirmDialog onCancel={...}>. */
|
|
434
|
+
cancelBack: () => void;
|
|
435
|
+
}
|
|
436
|
+
declare function useQuitConfirmation(hasProgress: () => boolean, onConfirmedBack: () => void): QuitConfirmation;
|
|
437
|
+
|
|
438
|
+
declare function useZoneClampedAlign(open: boolean, contentWidth: number, contentHeight: number, rotation?: PopoverRotation): ReturnType<typeof useAutoAlign>;
|
|
439
|
+
|
|
440
|
+
export { AchievementCatalogSection, AchievementRow, ActivityStatSection, type AlignResult, BaseSettingsDialog, type BaseSettingsDialogProps, BaseStatsScreen, ConfirmDialog, type ConfirmDialogProps, ContentGutter, ControlSchemePicker, type ControlSchemePickerProps, CornerActionButtons, type CornerStatusBadgeSeat, CornerStatusBadges, type CornerStatusBadgesProps, InlineColorPicker, LABELED_DROPDOWN_POPOVER_WIDTH, LOCKED_BADGE_COLOR, LabeledDropdown, type LabeledDropdownOption, MONO_FONT, type MenuOption, type MenuSection, type MultiSelectSection, type OrientationMode, type OverlayCardColors, PlayerSetupPanel, type PlayerSetupPanelProps, type PopoverAlign, PopoverBody, type PopoverHost, type PopoverRotation, type PopoverVerticalAlign, PressAwayOverlay, type QuitConfirmation, ReadyButton, SectionedDropdown, SharedActionBand, type SingleSelectSection, StaggeredWord, type StaggeredWordProps, StatRow, StatSection, TriggerGaugeHost, type TriggerGaugeProps, UpdateDialog, type UpdateDialogProps, getColorPopoverId, getCpuDifficultyPopoverId, getInlineColorPickerContentSize, getLabeledDropdownContentHeight, getOverlayCardColors, getStaggeredWordDuration, loadSkiaWeb, overlayStyles, useAutoAlign, usePopoverHost, useQuitConfirmation, useZoneClampedAlign };
|