@tastic/hud 0.8.5 → 0.13.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 +425 -23
- package/dist/chunk-32U2OIRW.mjs +77 -0
- package/dist/guide/index.d.mts +118 -0
- package/dist/guide/index.d.ts +118 -0
- package/dist/guide/index.js +543 -0
- package/dist/guide/index.mjs +428 -0
- package/dist/index.d.mts +145 -16
- package/dist/index.d.ts +145 -16
- package/dist/index.js +612 -322
- package/dist/index.mjs +533 -334
- package/package.json +46 -11
- package/src/AchievementCatalogSection.tsx +60 -0
- package/src/AchievementUnlockList.tsx +98 -0
- package/src/ActivityStatSection.tsx +29 -0
- package/src/BaseSettingsDialog.tsx +32 -1
- package/src/ConfirmDialog.tsx +4 -43
- package/src/ContentGutter.tsx +12 -5
- 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/guide/DirectionKeysHint.tsx +96 -0
- package/src/guide/GuideProvider.tsx +87 -0
- package/src/guide/HowToPlayDialog.tsx +224 -0
- package/src/guide/SeatDevicesHint.tsx +52 -0
- package/src/guide/SeatDiagram.tsx +72 -0
- package/src/guide/SwipeHint.tsx +115 -0
- package/src/guide/guideSlice.ts +131 -0
- package/src/guide/index.ts +14 -0
- package/src/guide/types.ts +16 -0
- package/src/index.ts +10 -3
- package/src/overlayCard.ts +51 -4
- package/src/useAutoAlign.ts +17 -1
- package/src/useQuitConfirmation.ts +61 -0
- package/src/useZoneClampedAlign.ts +135 -0
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { SeedColor } from '@rific/auto-paper'
|
|
2
|
+
import { Profile, ProfilePicker } from '@tastic/profile'
|
|
3
|
+
import { ReactNode } from 'react'
|
|
4
|
+
import { StyleSheet, View } from 'react-native'
|
|
5
|
+
import { Text } from 'react-native-paper'
|
|
6
|
+
|
|
7
|
+
import { InlineColorPicker } from './InlineColorPicker'
|
|
8
|
+
import { LabeledDropdown, LabeledDropdownOption } from './LabeledDropdown'
|
|
9
|
+
import { ReadyButton } from './ReadyButton'
|
|
10
|
+
import { AlignResult, PopoverRotation } from './useAutoAlign'
|
|
11
|
+
import { PopoverHost, usePopoverHost } from './usePopoverHost'
|
|
12
|
+
|
|
13
|
+
// This panel's own internal color/CPU-difficulty popover ids, derived from idPrefix — exported so a
|
|
14
|
+
// caller that needs to know whether one of them is currently open (e.g. to compute the `open`
|
|
15
|
+
// argument useZoneClampedAlign's own first parameter needs, ahead of handing its result to
|
|
16
|
+
// colorAlignOverride/cpuDifficultyAlignOverride below) doesn't have to hardcode or guess this
|
|
17
|
+
// panel's own internal naming convention. Matches secondPickerId's own job for a caller's own
|
|
18
|
+
// externally-constructed picker — this is the equivalent for the two pickers this panel owns
|
|
19
|
+
// itself, which have no prop of their own to name their id with.
|
|
20
|
+
export function getColorPopoverId(idPrefix: string): string {
|
|
21
|
+
return `${idPrefix}-color`
|
|
22
|
+
}
|
|
23
|
+
export function getCpuDifficultyPopoverId(idPrefix: string): string {
|
|
24
|
+
return `${idPrefix}-difficulty`
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface PlayerSetupPanelProps<C extends string = string> {
|
|
28
|
+
// Namespaces this panel's popover ids ('color', whatever secondPickerId names, 'difficulty') so
|
|
29
|
+
// two panels can safely share one host (see `host` below) without their ids colliding.
|
|
30
|
+
idPrefix: string
|
|
31
|
+
// Shared popover host, when this panel's own popovers should be mutually exclusive with another
|
|
32
|
+
// panel's or a shared-controls row's (e.g. vs-CPU: only one human is ever driving both slots, so
|
|
33
|
+
// having both YOU's and CPU's pickers open at once is just clutter, not a useful simultaneous-edit
|
|
34
|
+
// case). Omit to fall back to this panel's own independent host — the two-player case, where two
|
|
35
|
+
// real people editing at once is the point.
|
|
36
|
+
host?: PopoverHost
|
|
37
|
+
label?: string
|
|
38
|
+
// Applied only to the plain `label` fallback text below (ProfilePicker/LabeledDropdown render
|
|
39
|
+
// their own fixed chrome regardless of this prop) — omit to inherit whichever font the calling
|
|
40
|
+
// screen's own theme resolves for a labelSmall Text, rather than this package forcing its own
|
|
41
|
+
// MONO_FONT onto what's really the host app's own copy.
|
|
42
|
+
labelFontFamily?: string
|
|
43
|
+
color: string
|
|
44
|
+
onColorChange: (hex: string) => void
|
|
45
|
+
swatches: SeedColor[]
|
|
46
|
+
// The other player's current color — stays visible in the swatch grid but disabled, rather than
|
|
47
|
+
// removed from it entirely. Unless allowSwapTaken is set — see InlineColorPicker's own doc.
|
|
48
|
+
takenColor?: string
|
|
49
|
+
allowSwapTaken?: boolean
|
|
50
|
+
// Passed straight through to the internal InlineColorPicker's own `alignOverride` — this panel
|
|
51
|
+
// has no way for a caller to reach that picker directly (it's not a ReactNode slot the way
|
|
52
|
+
// secondPicker is, see that prop's own doc), so a caller inside a @tastic/split-screen zone
|
|
53
|
+
// needs this to substitute useZoneClampedAlign's result for the color picker's own popover.
|
|
54
|
+
colorAlignOverride?: AlignResult
|
|
55
|
+
// Passed straight through to the internal InlineColorPicker's own `rotation` — same reasoning as
|
|
56
|
+
// colorAlignOverride above (no other way for a caller to reach that picker directly), for a
|
|
57
|
+
// caller whose own rotation reading needs to differ from InlineColorPicker's default ambient
|
|
58
|
+
// useRotation() read (see that component's own `rotation` doc for when that's worth doing).
|
|
59
|
+
colorRotation?: PopoverRotation
|
|
60
|
+
isHuman: boolean
|
|
61
|
+
// Whatever sits beside the color picker in pickerRow, if anything — a ControlSchemePicker, a raw
|
|
62
|
+
// SectionedDropdown, or nothing at all. The five apps this component was extracted from each
|
|
63
|
+
// duplicated this entire panel and differed only in what (if anything) occupied this one slot,
|
|
64
|
+
// each wanting a genuinely different concept there (which physical input drives a seat, vs. a
|
|
65
|
+
// pull/push "feel" preference, vs. no such concept at all for a game with no per-seat setting to
|
|
66
|
+
// pick) — a ReactNode slot the caller renders its own picker into is what actually generalizes
|
|
67
|
+
// across all of them, rather than this component baking in one shape (e.g. ControlSchemePicker's
|
|
68
|
+
// own prop set) that most of its consumers don't need at all.
|
|
69
|
+
secondPicker?: ReactNode
|
|
70
|
+
// The popover id secondPicker opens under, if it opens one at all — lets pickerRow's own
|
|
71
|
+
// "elevate above later siblings while one of my own popovers is open" logic (see pickerRowOpen)
|
|
72
|
+
// react to secondPicker's popover the same way it already does for the color picker's, without
|
|
73
|
+
// this component needing any opinion on what secondPicker actually renders. Omit when secondPicker
|
|
74
|
+
// has no popover of its own, or isn't provided at all.
|
|
75
|
+
secondPickerId?: string
|
|
76
|
+
ready?: boolean
|
|
77
|
+
onToggleReady?: () => void
|
|
78
|
+
dark: boolean
|
|
79
|
+
// vs-CPU only has one human player, so some loadout screens render their Ready toggle standalone,
|
|
80
|
+
// centered below both slots, instead of embedded in this panel.
|
|
81
|
+
showReadyButton?: boolean
|
|
82
|
+
profiles?: Profile[]
|
|
83
|
+
selectedProfileId?: string | null
|
|
84
|
+
takenProfileId?: string | null
|
|
85
|
+
// What the name trigger shows before any profile's selected — this seat's own 'P1'/'P2' (see
|
|
86
|
+
// ProfilePicker's own doc for why it's never "Player"). Required whenever profiles is provided,
|
|
87
|
+
// since the trigger always needs some idle text.
|
|
88
|
+
guestLabel?: string
|
|
89
|
+
onProfileSelect?: (profile: Profile | null) => void
|
|
90
|
+
// Navigates to a profile-management screen — typically only ever wired up for one seat (a rotated
|
|
91
|
+
// second zone often can't host a working text keyboard).
|
|
92
|
+
onManageProfiles?: () => void
|
|
93
|
+
// Generic over the calling app's own CPU-difficulty union, matching LabeledDropdownOption<T>'s own
|
|
94
|
+
// genericness — this package has no opinion on what difficulties exist.
|
|
95
|
+
cpuDifficulty?: C
|
|
96
|
+
cpuDifficultyOptions?: LabeledDropdownOption<C>[]
|
|
97
|
+
onCpuDifficultyChange?: (value: C) => void
|
|
98
|
+
// Passed straight through to the internal LabeledDropdown's own `alignOverride` — same reasoning
|
|
99
|
+
// as colorAlignOverride above, for the CPU-difficulty picker instead of the color picker.
|
|
100
|
+
cpuDifficultyAlignOverride?: AlignResult
|
|
101
|
+
// InlineColorPicker's own `icon` for a human vs. a non-human seat (overridden by a selected
|
|
102
|
+
// profile's own `tag`, same precedence InlineColorPicker itself applies) — defaults match every
|
|
103
|
+
// consumer's own convention. A caller with its own extra CPU states (e.g. a "no CPU" slot) computes
|
|
104
|
+
// its own icon string and passes it through cpuIcon rather than this component needing any opinion
|
|
105
|
+
// on what those states are.
|
|
106
|
+
humanIcon?: string
|
|
107
|
+
cpuIcon?: string
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// One panel per player slot in a local-multiplayer loadout/lobby screen: a name header (a saved
|
|
111
|
+
// profile switcher for a human seat, a CPU-difficulty picker for a non-human one, or a plain label
|
|
112
|
+
// when neither applies), a color picker optionally paired with a second, per-app picker (see
|
|
113
|
+
// `secondPicker`'s own doc), and a Ready toggle for human seats. Ported from five apps' own
|
|
114
|
+
// near-identical PlayerSetupPanel.tsx/LobbyPlayerPanel.tsx (AirHockey, BoxHockey, Pong, LightCycles,
|
|
115
|
+
// Snake), which had all independently converged on this exact skeleton.
|
|
116
|
+
//
|
|
117
|
+
// Owns its own popover host shared by its own color/profile/difficulty pickers unless a `host` is
|
|
118
|
+
// passed in to share with another panel (or a shared-controls row) instead — same convention as
|
|
119
|
+
// every popover-hosting component in this package.
|
|
120
|
+
export function PlayerSetupPanel<C extends string = string>({ idPrefix, host, label, labelFontFamily, color, onColorChange, swatches, takenColor, allowSwapTaken, colorAlignOverride, colorRotation, isHuman, secondPicker, secondPickerId, ready, onToggleReady, dark, showReadyButton = true, profiles, selectedProfileId, takenProfileId, guestLabel, onProfileSelect, onManageProfiles, cpuDifficulty, cpuDifficultyOptions, onCpuDifficultyChange, cpuDifficultyAlignOverride, humanIcon = 'face-man', cpuIcon = 'robot' }: PlayerSetupPanelProps<C>) {
|
|
121
|
+
const ownHost = usePopoverHost()
|
|
122
|
+
const popover = host ?? ownHost
|
|
123
|
+
// Checking *this panel's own* ids, not just "is anything open on the host" — when `host` is
|
|
124
|
+
// shared, another panel's (or a shared-controls row's) popovers also live on it, and elevating
|
|
125
|
+
// this panel for those too would tie it with whichever of them actually has the open popover,
|
|
126
|
+
// letting DOM order (wrongly) decide which one paints on top.
|
|
127
|
+
const ownPopoverOpen = popover.openId?.startsWith(`${idPrefix}-`) ?? false
|
|
128
|
+
// Narrower than ownPopoverOpen on purpose — only true for a popover actually inside pickerRow
|
|
129
|
+
// itself (color, or secondPicker's own popover when it has one and secondPickerId names it). See
|
|
130
|
+
// pickerRowOpen's own style comment for why pickerRow needs this in addition to panelOpen above.
|
|
131
|
+
const pickerRowPopoverOpen = popover.openId === getColorPopoverId(idPrefix) || (secondPickerId !== undefined && popover.openId === secondPickerId)
|
|
132
|
+
const showProfilePicker = isHuman && profiles !== undefined && !!onProfileSelect
|
|
133
|
+
const showCpuDifficulty = !isHuman && cpuDifficulty !== undefined && cpuDifficultyOptions !== undefined && !!onCpuDifficultyChange
|
|
134
|
+
// The color button's own tag display (see InlineColorPicker) — read live from the selected
|
|
135
|
+
// profile rather than snapshotted at selection time, since a caller typically has no other
|
|
136
|
+
// editing surface for it on this same screen.
|
|
137
|
+
const selectedProfile = profiles?.find((p) => p.id === selectedProfileId) ?? null
|
|
138
|
+
|
|
139
|
+
return (
|
|
140
|
+
<View style={[styles.panel, ownPopoverOpen && styles.panelOpen]}>
|
|
141
|
+
{showProfilePicker ? (
|
|
142
|
+
<ProfilePicker idPrefix={idPrefix} host={popover} profiles={profiles!} selectedId={selectedProfileId ?? null} takenId={takenProfileId} color={color} dark={dark} guestLabel={guestLabel ?? 'GUEST'} onSelect={(profile: Profile | null) => onProfileSelect?.(profile)} onManage={onManageProfiles} />
|
|
143
|
+
) : showCpuDifficulty ? (
|
|
144
|
+
<LabeledDropdown id={getCpuDifficultyPopoverId(idPrefix)} host={popover} options={cpuDifficultyOptions!} value={cpuDifficulty!} onChange={onCpuDifficultyChange!} color={color} dark={dark} alignOverride={cpuDifficultyAlignOverride} />
|
|
145
|
+
) : (
|
|
146
|
+
label && (
|
|
147
|
+
<Text variant='labelSmall' style={{ color, fontFamily: labelFontFamily }}>
|
|
148
|
+
{label}
|
|
149
|
+
</Text>
|
|
150
|
+
)
|
|
151
|
+
)}
|
|
152
|
+
|
|
153
|
+
<View style={[styles.pickerRow, pickerRowPopoverOpen && styles.pickerRowOpen]}>
|
|
154
|
+
{/* Human slots show the selected profile's own tag when it has one, falling back to
|
|
155
|
+
humanIcon; a non-human slot always falls back to cpuIcon — this is what actually
|
|
156
|
+
distinguishes "you" from "the CPU" now, not a fixed icon. */}
|
|
157
|
+
<InlineColorPicker id={getColorPopoverId(idPrefix)} host={popover} value={color} onChange={onColorChange} swatches={swatches} takenValue={takenColor} allowSwapTaken={allowSwapTaken} alignOverride={colorAlignOverride} rotation={colorRotation} dark={dark} tag={isHuman ? selectedProfile?.tag : undefined} icon={isHuman ? humanIcon : cpuIcon} />
|
|
158
|
+
|
|
159
|
+
{secondPicker}
|
|
160
|
+
</View>
|
|
161
|
+
|
|
162
|
+
{isHuman && showReadyButton && onToggleReady && <ReadyButton color={color} ready={ready ?? false} onToggleReady={onToggleReady} />}
|
|
163
|
+
</View>
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const styles = StyleSheet.create({
|
|
168
|
+
panel: {
|
|
169
|
+
alignItems: 'center',
|
|
170
|
+
gap: 12
|
|
171
|
+
},
|
|
172
|
+
// See SectionedDropdown's anchorOpen comment — this panel is typically a sibling of another
|
|
173
|
+
// panel (and/or a shared-controls row), so a popover escaping this panel's bounds needs the panel
|
|
174
|
+
// itself elevated, not just the popover content, to paint above a later sibling.
|
|
175
|
+
panelOpen: {
|
|
176
|
+
zIndex: 100
|
|
177
|
+
},
|
|
178
|
+
// Same gap as `panel`'s own vertical rhythm, reused horizontally — the color picker and (when
|
|
179
|
+
// present) secondPicker sit side by side within this row instead of stacked in the panel's own
|
|
180
|
+
// column.
|
|
181
|
+
pickerRow: {
|
|
182
|
+
alignItems: 'center',
|
|
183
|
+
flexDirection: 'row',
|
|
184
|
+
gap: 12
|
|
185
|
+
},
|
|
186
|
+
// A popover is an absolutely-positioned sibling of its own trigger — so a tall one (the color
|
|
187
|
+
// swatch grid) can extend down far enough to overlap the ReadyButton below, which paints on top of
|
|
188
|
+
// it by default DOM order since it's a later sibling within `panel`. `panel`'s own panelOpen
|
|
189
|
+
// elevation only helps this panel paint above OTHER panels (see its own comment) — it does nothing
|
|
190
|
+
// for stacking *within* this panel, since React Native Web gives each View its own stacking
|
|
191
|
+
// context. This is what actually fixes that: elevate pickerRow itself above its own later sibling
|
|
192
|
+
// whenever one of its own popovers is open.
|
|
193
|
+
pickerRowOpen: {
|
|
194
|
+
zIndex: 100
|
|
195
|
+
}
|
|
196
|
+
})
|
|
@@ -7,7 +7,7 @@ import { Icon, Text } from 'react-native-paper'
|
|
|
7
7
|
import { MONO_FONT } from './fonts'
|
|
8
8
|
import { PopoverBody } from './PopoverBody'
|
|
9
9
|
import TriggerGaugeHost from './TriggerGaugeHost'
|
|
10
|
-
import { PopoverRotation, useAutoAlign } from './useAutoAlign'
|
|
10
|
+
import { AlignResult, PopoverRotation, useAutoAlign } from './useAutoAlign'
|
|
11
11
|
import { PopoverHost } from './usePopoverHost'
|
|
12
12
|
|
|
13
13
|
const MENU_BORDER_WIDTH = 1
|
|
@@ -96,10 +96,17 @@ interface Props {
|
|
|
96
96
|
// background — pass a real design-system "on" color explicitly instead when you have one.
|
|
97
97
|
onAccentColor?: string
|
|
98
98
|
dark: boolean
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
99
|
+
// Forces a specific horizontal alignment instead of letting whichever placement result applies
|
|
100
|
+
// (see alignOverride below) decide. Only pass this to force a specific side regardless of where
|
|
101
|
+
// the trigger actually sits.
|
|
102
102
|
align?: 'left' | 'right' | 'center'
|
|
103
|
+
// Substitutes this package's own plain useAutoAlign-based placement wholesale (align,
|
|
104
|
+
// verticalAlign, maxHeight, measured, triggerRef) with a caller-supplied one — e.g.
|
|
105
|
+
// useZoneClampedAlign's result, for a dropdown living inside a @tastic/split-screen zone, where
|
|
106
|
+
// the plain window-relative decision can pick a direction that overflows into the shared row
|
|
107
|
+
// instead of the zone's own boundary. Same shape LabeledDropdown's own alignOverride takes; omit
|
|
108
|
+
// for the ordinary case (a popover with nothing but the screen edge to avoid).
|
|
109
|
+
alignOverride?: AlignResult
|
|
103
110
|
// Explicit override for the rotation a @tastic/split-screen-style FakeLandscapeView (or any other
|
|
104
111
|
// ancestor rotated the same way — a bare CSS `transform: rotate()`, not an actual OS-level
|
|
105
112
|
// orientation change) is currently applying. Defaults to a live ambient read via @tastic/core's
|
|
@@ -128,7 +135,7 @@ interface Props {
|
|
|
128
135
|
// powerups are enabled, say) is just this with one 'multi' section (optionally with allClear); a
|
|
129
136
|
// combined menu (e.g. spawn frequency + which types, in one menu) is just two sections in the same
|
|
130
137
|
// array.
|
|
131
|
-
export function SectionedDropdown({ id, host, icon, accessibilityLabel, sections, accentColor, mutedColor, onAccentColor, dark, align: alignOverride, rotation: rotationOverride, autoDismiss = true, labelFontFamily = MONO_FONT, allClearLabels = { all: 'All', clear: 'Clear' } }: Props) {
|
|
138
|
+
export function SectionedDropdown({ id, host, icon, accessibilityLabel, sections, accentColor, mutedColor, onAccentColor, dark, align: forcedAlign, alignOverride, rotation: rotationOverride, autoDismiss = true, labelFontFamily = MONO_FONT, allClearLabels = { all: 'All', clear: 'Clear' } }: Props) {
|
|
132
139
|
const ambientRotation = useRotation()
|
|
133
140
|
const rotation = rotationOverride ?? ambientRotation
|
|
134
141
|
const menuBg = dark ? '#000000' : '#FFFFFF'
|
|
@@ -156,8 +163,12 @@ export function SectionedDropdown({ id, host, icon, accessibilityLabel, sections
|
|
|
156
163
|
const divider = sectionIndex > 0 ? MENU_DIVIDER_HEIGHT : 0
|
|
157
164
|
return sum + rows + footer + divider
|
|
158
165
|
}, 0)
|
|
159
|
-
|
|
160
|
-
|
|
166
|
+
// Always called, even when alignOverride is supplied and this result goes unused — hooks can't be
|
|
167
|
+
// called conditionally. See LabeledDropdown's identical comment for the same tradeoff.
|
|
168
|
+
const auto = useAutoAlign(open, MENU_MAX_WIDTH, estimatedHeight, rotation)
|
|
169
|
+
const placement = alignOverride ?? auto
|
|
170
|
+
const { maxHeight, measured, triggerRef, verticalAlign } = placement
|
|
171
|
+
const align = forcedAlign ?? placement.align
|
|
161
172
|
|
|
162
173
|
// Trigger reflects the selected option's own icon only when there's exactly one section and it's
|
|
163
174
|
// single-select — any other shape (multi-select present, or more than one section) has no single
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { useEffect } from 'react'
|
|
2
|
+
import { LayoutChangeEvent, StyleProp, StyleSheet, TextStyle, View, ViewStyle } from 'react-native'
|
|
3
|
+
import Animated, { Easing, useAnimatedStyle, useSharedValue, withDelay, withTiming } from 'react-native-reanimated'
|
|
4
|
+
|
|
5
|
+
const STAGGER_MS = 45
|
|
6
|
+
const LETTER_DURATION_MS = 320
|
|
7
|
+
|
|
8
|
+
// The one formula every consumer's own follow-on animation (a paddle rally, a bouncing puck, a
|
|
9
|
+
// looping trail) needs in order to time its own entrance off "once the letters have mostly
|
|
10
|
+
// landed" — five apps (AirHockey, BoxHockey, Pong, LightCycles, Snake) had each independently
|
|
11
|
+
// re-derived `(letterCount - 1) * STAGGER_MS + LETTER_DURATION_MS` locally as their own
|
|
12
|
+
// `TOTAL_STAGGER_MS` constant. Exported as a pure function, not a component return value, so a
|
|
13
|
+
// caller can compute it once, outside render, the same way every one of those apps already did.
|
|
14
|
+
// `letterCount` is the TOTAL number of letters actually animated, not necessarily a single
|
|
15
|
+
// StaggeredWord's own `word.length` — see `startIndex` below for a multi-word wordmark, where a
|
|
16
|
+
// second word's letters continue the same overall stagger sequence.
|
|
17
|
+
export function getStaggeredWordDuration(letterCount: number): number {
|
|
18
|
+
return (letterCount - 1) * STAGGER_MS + LETTER_DURATION_MS
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface AnimatedLetterProps {
|
|
22
|
+
char: string
|
|
23
|
+
index: number
|
|
24
|
+
color: string
|
|
25
|
+
reducedMotion: boolean
|
|
26
|
+
fontFamily: string
|
|
27
|
+
fontSize: number
|
|
28
|
+
lineHeight: number
|
|
29
|
+
fontWeight?: TextStyle['fontWeight']
|
|
30
|
+
letterSpacing?: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function AnimatedLetter({ char, index, color, reducedMotion, fontFamily, fontSize, lineHeight, fontWeight, letterSpacing }: AnimatedLetterProps) {
|
|
34
|
+
const progress = useSharedValue(reducedMotion ? 1 : 0)
|
|
35
|
+
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
if (reducedMotion) return
|
|
38
|
+
progress.value = withDelay(index * STAGGER_MS, withTiming(1, { duration: LETTER_DURATION_MS, easing: Easing.out(Easing.back(1.5)) }))
|
|
39
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
40
|
+
}, [reducedMotion])
|
|
41
|
+
|
|
42
|
+
const style = useAnimatedStyle(() => ({
|
|
43
|
+
opacity: progress.value,
|
|
44
|
+
transform: [{ translateY: (1 - progress.value) * 12 }, { scale: 0.85 + progress.value * 0.15 }]
|
|
45
|
+
}))
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<Animated.Text allowFontScaling={false} style={[{ color, fontFamily, fontSize, lineHeight, fontWeight, letterSpacing }, style]}>
|
|
49
|
+
{char}
|
|
50
|
+
</Animated.Text>
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface StaggeredWordProps {
|
|
55
|
+
word: string
|
|
56
|
+
color: string
|
|
57
|
+
fontFamily: string
|
|
58
|
+
fontSize: number
|
|
59
|
+
lineHeight: number
|
|
60
|
+
// Whether to skip the stagger entirely and render every letter already fully settled — wire
|
|
61
|
+
// straight from react-native-reanimated's own useReducedMotion() at the call site rather than
|
|
62
|
+
// this component calling it internally, so a caller composing several StaggeredWords (see
|
|
63
|
+
// `startIndex` below) only needs the one hook call, not one per word.
|
|
64
|
+
reducedMotion: boolean
|
|
65
|
+
// Continues the stagger sequence from a larger overall wordmark this word is only one line/part
|
|
66
|
+
// of — this word's own first letter animates at index `startIndex`, its second at
|
|
67
|
+
// `startIndex + 1`, and so on. Defaults to 0 for a standalone single-word wordmark (Snake's
|
|
68
|
+
// "SNAKE", Pong's "PONG"); a caller stacking multiple words (AirHockey's "AIR"/"HOCKEY",
|
|
69
|
+
// BoxHockey's "BOX"/"HOCKEY", LightCycles' "Light"/"Cycles") passes the running total of letters
|
|
70
|
+
// already animated by earlier words, so the whole wordmark reads as one continuous cascade
|
|
71
|
+
// instead of each word restarting its own stagger from zero.
|
|
72
|
+
startIndex?: number
|
|
73
|
+
// Every consumer this component was extracted from hardcoded bold letters (baked into a fixed
|
|
74
|
+
// StyleSheet entry, not a prop) — kept as the default here rather than a fixed style, so the one
|
|
75
|
+
// consumer that doesn't want it (LightCycles, whose own registered display font is already
|
|
76
|
+
// sufficiently bold on its own) can opt out with `fontWeight='normal'` instead of this component
|
|
77
|
+
// forcing it unconditionally.
|
|
78
|
+
fontWeight?: TextStyle['fontWeight']
|
|
79
|
+
// Unset by default — only AirHockey/BoxHockey/Pong's shared MONO_FONT-based letter style actually
|
|
80
|
+
// wants extra tracking; Snake's/LightCycles' own theme fonts don't.
|
|
81
|
+
letterSpacing?: number
|
|
82
|
+
// The wrapping row's own style — e.g. LightCycles' own vertical offset for its second word
|
|
83
|
+
// beneath its first.
|
|
84
|
+
style?: StyleProp<ViewStyle>
|
|
85
|
+
// Lets a caller measure this word's own rendered box (e.g. to size/position a follow-on canvas
|
|
86
|
+
// overlay the way Snake's HeroSnakeTrail or LightCycles' HeroTitleTrails do) the same way it
|
|
87
|
+
// would measure any other plain View.
|
|
88
|
+
onLayout?: (e: LayoutChangeEvent) => void
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// One row of letters staggering in on mount (or rendered already fully settled under reduced
|
|
92
|
+
// motion) — the "AnimatedLetter" building block five apps' own hero-title screens
|
|
93
|
+
// (AirHockey/BoxHockey/Pong/LightCycles/Snake's own HeroTitle.tsx/AnimatedHeroTitle.tsx) had each
|
|
94
|
+
// hand-rolled independently, identical down to the same 45ms stagger, 320ms per-letter duration,
|
|
95
|
+
// and back-ease overshoot. A single word/line on its own, or one of several stacked/side-by-side
|
|
96
|
+
// words sharing one overall stagger sequence via `startIndex` — this component only ever renders
|
|
97
|
+
// one row of letters; a caller composing more than one word renders more than one StaggeredWord
|
|
98
|
+
// (see `startIndex`'s own doc), the same way every one of those five apps' own `Word` helper
|
|
99
|
+
// already did internally.
|
|
100
|
+
export function StaggeredWord({ word, color, fontFamily, fontSize, lineHeight, reducedMotion, startIndex = 0, fontWeight = 'bold', letterSpacing, style, onLayout }: StaggeredWordProps) {
|
|
101
|
+
return (
|
|
102
|
+
<View style={[styles.row, style]} onLayout={onLayout}>
|
|
103
|
+
{word.split('').map((char, i) => (
|
|
104
|
+
<AnimatedLetter key={i} char={char} index={startIndex + i} color={color} reducedMotion={reducedMotion} fontFamily={fontFamily} fontSize={fontSize} lineHeight={lineHeight} fontWeight={fontWeight} letterSpacing={letterSpacing} />
|
|
105
|
+
))}
|
|
106
|
+
</View>
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const styles = StyleSheet.create({
|
|
111
|
+
row: {
|
|
112
|
+
flexDirection: 'row'
|
|
113
|
+
}
|
|
114
|
+
})
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { useAutoPaperTheme } from '@rific/auto-paper'
|
|
2
|
+
import { StyleSheet, View } from 'react-native'
|
|
3
|
+
import { Icon, Text } from 'react-native-paper'
|
|
4
|
+
|
|
5
|
+
export interface DirectionKeyLabels {
|
|
6
|
+
up: string
|
|
7
|
+
left: string
|
|
8
|
+
down: string
|
|
9
|
+
right: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type DirectionKeysAxis = 'both' | 'vertical'
|
|
13
|
+
|
|
14
|
+
export interface DirectionKeysHintProps {
|
|
15
|
+
// What's printed on each cap — e.g. { up: 'W', left: 'A', down: 'S', right: 'D' }. Omit for the
|
|
16
|
+
// arrow keys themselves, drawn as arrow icons (so does any single cap left out). Pass whatever the
|
|
17
|
+
// seat's own key scheme actually is, not a guess: the fleet games let each seat pick WASD, arrows,
|
|
18
|
+
// IJKL and so on.
|
|
19
|
+
labels?: Partial<DirectionKeyLabels>
|
|
20
|
+
// 'both' (default) draws the four-key inverted T. 'vertical' draws only up above down, for a game
|
|
21
|
+
// whose controls only ever read those two (Pong's paddles).
|
|
22
|
+
axis?: DirectionKeysAxis
|
|
23
|
+
// Defaults to the theme's own primary — i.e. player 1's seat color in the fleet games.
|
|
24
|
+
color?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const CAP_SIZE = 34
|
|
28
|
+
|
|
29
|
+
const ARROWS: DirectionKeyLabels = { up: 'arrow-up', left: 'arrow-left', down: 'arrow-down', right: 'arrow-right' }
|
|
30
|
+
|
|
31
|
+
function KeyCap({ label, icon, color }: { label?: string; icon: string; color: string }) {
|
|
32
|
+
return (
|
|
33
|
+
<View style={[styles.cap, { borderColor: color }]}>
|
|
34
|
+
{label !== undefined ? (
|
|
35
|
+
<Text variant='titleMedium' style={[styles.capLabel, { color }]}>
|
|
36
|
+
{label}
|
|
37
|
+
</Text>
|
|
38
|
+
) : (
|
|
39
|
+
<Icon source={icon} size={20} color={color} />
|
|
40
|
+
)}
|
|
41
|
+
</View>
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Key caps in the inverted-T every keyboard player already knows (or just up over down, see `axis`) —
|
|
46
|
+
// the desktop counterpart to SwipeHint for the "how do I steer" card, since a desktop browser steers by
|
|
47
|
+
// key, not by swipe. Plain Views, no animation, so there's nothing for reduced motion to switch off.
|
|
48
|
+
export function DirectionKeysHint({ labels, axis = 'both', color }: DirectionKeysHintProps) {
|
|
49
|
+
const { colors } = useAutoPaperTheme()
|
|
50
|
+
const tint = color ?? colors.primary
|
|
51
|
+
|
|
52
|
+
if (axis === 'vertical') {
|
|
53
|
+
const accessibilityLabel = labels?.up && labels.down ? `Keys ${labels.up} and ${labels.down}` : 'Up and down arrow keys'
|
|
54
|
+
return (
|
|
55
|
+
<View accessible accessibilityLabel={accessibilityLabel} style={styles.cluster}>
|
|
56
|
+
<KeyCap label={labels?.up} icon={ARROWS.up} color={tint} />
|
|
57
|
+
<KeyCap label={labels?.down} icon={ARROWS.down} color={tint} />
|
|
58
|
+
</View>
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const accessibilityLabel = labels?.up && labels.left && labels.down && labels.right ? `Keys ${labels.up}, ${labels.left}, ${labels.down}, ${labels.right}` : 'Arrow keys'
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<View accessible accessibilityLabel={accessibilityLabel} style={styles.cluster}>
|
|
66
|
+
<KeyCap label={labels?.up} icon={ARROWS.up} color={tint} />
|
|
67
|
+
<View style={styles.row}>
|
|
68
|
+
<KeyCap label={labels?.left} icon={ARROWS.left} color={tint} />
|
|
69
|
+
<KeyCap label={labels?.down} icon={ARROWS.down} color={tint} />
|
|
70
|
+
<KeyCap label={labels?.right} icon={ARROWS.right} color={tint} />
|
|
71
|
+
</View>
|
|
72
|
+
</View>
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const styles = StyleSheet.create({
|
|
77
|
+
cap: {
|
|
78
|
+
alignItems: 'center',
|
|
79
|
+
borderRadius: 8,
|
|
80
|
+
borderWidth: 2,
|
|
81
|
+
height: CAP_SIZE,
|
|
82
|
+
justifyContent: 'center',
|
|
83
|
+
width: CAP_SIZE
|
|
84
|
+
},
|
|
85
|
+
capLabel: {
|
|
86
|
+
fontWeight: 'bold'
|
|
87
|
+
},
|
|
88
|
+
cluster: {
|
|
89
|
+
alignItems: 'center',
|
|
90
|
+
gap: 6
|
|
91
|
+
},
|
|
92
|
+
row: {
|
|
93
|
+
flexDirection: 'row',
|
|
94
|
+
gap: 6
|
|
95
|
+
}
|
|
96
|
+
})
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
import { HowToPlayDialog } from './HowToPlayDialog'
|
|
4
|
+
import type { GuideRotation, GuideStep } from './types'
|
|
5
|
+
|
|
6
|
+
interface GuideContextValue {
|
|
7
|
+
isOpen: boolean
|
|
8
|
+
// Opens the guide unconditionally — the Settings "How to play" row's whole job. Never persists
|
|
9
|
+
// anything on its own: only finishing or skipping does, and only while the player hasn't already
|
|
10
|
+
// seen this version.
|
|
11
|
+
open: () => void
|
|
12
|
+
close: () => void
|
|
13
|
+
// Opens it once per session, and only if this player hasn't seen `currentVersion` yet — see
|
|
14
|
+
// useAutoShowGuide.
|
|
15
|
+
requestAutoShow: () => void
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const noop = () => {}
|
|
19
|
+
|
|
20
|
+
// A default that does nothing rather than throwing when there's no provider above — an app's own
|
|
21
|
+
// existing tests render its Settings dialog or Home screen without one, and a "How to play" row that
|
|
22
|
+
// silently does nothing in that harness is a far better failure than every such test breaking.
|
|
23
|
+
const GuideContext = createContext<GuideContextValue>({ isOpen: false, open: noop, close: noop, requestAutoShow: noop })
|
|
24
|
+
|
|
25
|
+
export interface GuideProviderProps {
|
|
26
|
+
steps: GuideStep[]
|
|
27
|
+
// Bumped by the app only for a genuine controls/rules change, to re-prompt everyone.
|
|
28
|
+
currentVersion: number
|
|
29
|
+
// Read from the app's own persisted store (see createGuideSlice's selectVersionSeen).
|
|
30
|
+
seenVersion: number
|
|
31
|
+
// Called with `currentVersion` when the player finishes or skips a guide they hadn't seen yet.
|
|
32
|
+
onSeen: (version: number) => void
|
|
33
|
+
// The app's live fake-landscape rotation (see GuideRotation). The card rotates and sizes itself for it.
|
|
34
|
+
rotation?: GuideRotation
|
|
35
|
+
children?: ReactNode
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Mounted once at the app root, next to <UpdateDialog /> — the fleet's existing "self-presenting
|
|
39
|
+
// app-level dialog" seat — so any screen can open it through useGuide() without prop-drilling, and
|
|
40
|
+
// the Portal it renders sits above every route. Inside PersistGate, so `seenVersion` is already the
|
|
41
|
+
// rehydrated value by the time this first renders.
|
|
42
|
+
export function GuideProvider({ steps, currentVersion, seenVersion, onSeen, rotation, children }: GuideProviderProps) {
|
|
43
|
+
const [isOpen, setIsOpen] = useState(false)
|
|
44
|
+
// Once per session, not once per Home mount — Home remounts every time the player backs out of a
|
|
45
|
+
// match, and skipping shouldn't be something they have to do again a minute later just because the
|
|
46
|
+
// persisted flag hadn't been written back through the store yet.
|
|
47
|
+
const autoShownRef = useRef(false)
|
|
48
|
+
const unseen = seenVersion < currentVersion
|
|
49
|
+
|
|
50
|
+
const open = useCallback(() => setIsOpen(true), [])
|
|
51
|
+
|
|
52
|
+
const close = useCallback(() => {
|
|
53
|
+
setIsOpen(false)
|
|
54
|
+
if (unseen) onSeen(currentVersion)
|
|
55
|
+
}, [unseen, onSeen, currentVersion])
|
|
56
|
+
|
|
57
|
+
const requestAutoShow = useCallback(() => {
|
|
58
|
+
if (!unseen || autoShownRef.current) return
|
|
59
|
+
autoShownRef.current = true
|
|
60
|
+
setIsOpen(true)
|
|
61
|
+
}, [unseen])
|
|
62
|
+
|
|
63
|
+
const value = useMemo(() => ({ isOpen, open, close, requestAutoShow }), [isOpen, open, close, requestAutoShow])
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<GuideContext.Provider value={value}>
|
|
67
|
+
{children}
|
|
68
|
+
<HowToPlayDialog visible={isOpen} steps={steps} onFinish={close} onSkip={close} rotation={rotation} />
|
|
69
|
+
</GuideContext.Provider>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function useGuide(): GuideContextValue {
|
|
74
|
+
return useContext(GuideContext)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Call this from the Home screen and nowhere else — that placement, not any route check in here, is
|
|
78
|
+
// what keeps it from ever firing over a live match or a two-seat loadout. Home is the one screen
|
|
79
|
+
// with a single reader, before any seating, so a face-to-face two-player phone doesn't need per-seat
|
|
80
|
+
// rotated copies of the card. `enabled` lets a caller hold it back (e.g. while a shared-link import
|
|
81
|
+
// is being handled) without breaking the rules of hooks by conditionally calling this.
|
|
82
|
+
export function useAutoShowGuide(enabled = true) {
|
|
83
|
+
const { requestAutoShow } = useGuide()
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
if (enabled) requestAutoShow()
|
|
86
|
+
}, [enabled, requestAutoShow])
|
|
87
|
+
}
|