@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.
@@ -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
- // Manual override — omit to let the popover measure its own trigger and pick whichever alignment
100
- // keeps it from overflowing the screen edge (see useAutoAlign). Only pass this to force a specific
101
- // side regardless of where the trigger actually sits.
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
- const { align: autoAlign, maxHeight, measured, triggerRef, verticalAlign } = useAutoAlign(open, MENU_MAX_WIDTH, estimatedHeight, rotation)
160
- const align = alignOverride ?? autoAlign
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
+ }