@tastic/hud 0.12.0 → 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 +132 -11
- 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 +46 -9
- package/dist/index.d.ts +46 -9
- package/dist/index.js +318 -250
- package/dist/index.mjs +283 -286
- package/package.json +32 -5
- package/src/AchievementUnlockList.tsx +98 -0
- package/src/BaseSettingsDialog.tsx +32 -1
- package/src/ConfirmDialog.tsx +4 -43
- package/src/ContentGutter.tsx +12 -5
- 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 +2 -1
- package/src/overlayCard.ts +51 -4
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { useAutoPaperTheme } from '@rific/auto-paper'
|
|
2
|
+
import { Button } from '@rific/feedback-press'
|
|
3
|
+
import { useEffect, useRef, useState } from 'react'
|
|
4
|
+
import { BackHandler, type NativeScrollEvent, type NativeSyntheticEvent, ScrollView, StyleSheet, useWindowDimensions, View } from 'react-native'
|
|
5
|
+
import { Portal, Text } from 'react-native-paper'
|
|
6
|
+
import { useReducedMotion } from 'react-native-reanimated'
|
|
7
|
+
|
|
8
|
+
import { getOverlayCardColors, overlayActionStyles, overlayStyles } from '../overlayCard'
|
|
9
|
+
import type { GuideRotation, GuideStep } from './types'
|
|
10
|
+
|
|
11
|
+
// The overlay's own breathing room and the card's own inner padding. Together with the card's max
|
|
12
|
+
// width these fix the width of one page exactly, which the paging ScrollView below needs up front (it
|
|
13
|
+
// snaps to multiples of it) rather than measuring after layout.
|
|
14
|
+
const OVERLAY_GUTTER = 24
|
|
15
|
+
const CARD_PADDING = 24
|
|
16
|
+
const MAX_CARD_WIDTH = 360
|
|
17
|
+
// A quarter-turned card has the phone's long edge to spread across, so it's allowed wider: shorter
|
|
18
|
+
// lines mean fewer of them, which is what actually has to fit (see sizing below).
|
|
19
|
+
const MAX_CARD_WIDTH_TURNED = 520
|
|
20
|
+
const ART_HEIGHT = 132
|
|
21
|
+
// Below this much usable height the illustration box shrinks. Sized so the full card (padding, art,
|
|
22
|
+
// title, two lines of body, dots, buttons) fits a 360pt-wide phone held sideways.
|
|
23
|
+
const COMPACT_BELOW = 480
|
|
24
|
+
const ART_HEIGHT_COMPACT = 88
|
|
25
|
+
|
|
26
|
+
// Where the card has to fit. The fleet's rotation is FAKE: the OS window stays portrait and the card
|
|
27
|
+
// is only turned with a transform, so a quarter-turned card's on-screen width is its layout HEIGHT,
|
|
28
|
+
// which must fit the window's short edge, and its on-screen height is its layout width. Sizing it
|
|
29
|
+
// against the unswapped window (what the first version did) let a sideways card run off a small
|
|
30
|
+
// phone. Exported for tests.
|
|
31
|
+
export function getGuideCardSize(windowWidth: number, windowHeight: number, rotation: GuideRotation) {
|
|
32
|
+
const turned = rotation === 90 || rotation === -90
|
|
33
|
+
const availableWidth = (turned ? windowHeight : windowWidth) - OVERLAY_GUTTER * 2
|
|
34
|
+
const availableHeight = (turned ? windowWidth : windowHeight) - OVERLAY_GUTTER * 2
|
|
35
|
+
const width = Math.min(turned ? MAX_CARD_WIDTH_TURNED : MAX_CARD_WIDTH, availableWidth)
|
|
36
|
+
return {
|
|
37
|
+
width,
|
|
38
|
+
pageWidth: width - CARD_PADDING * 2,
|
|
39
|
+
maxHeight: availableHeight,
|
|
40
|
+
artHeight: availableHeight < COMPACT_BELOW ? ART_HEIGHT_COMPACT : ART_HEIGHT
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface HowToPlayDialogProps {
|
|
45
|
+
visible: boolean
|
|
46
|
+
steps: GuideStep[]
|
|
47
|
+
// Last card's primary button.
|
|
48
|
+
onFinish: () => void
|
|
49
|
+
// Skip (first card only — see the action row), and Android's hardware back button — both mean "I don't need this", so a caller persisting
|
|
50
|
+
// "seen" should treat them exactly like onFinish.
|
|
51
|
+
onSkip: () => void
|
|
52
|
+
// The app's live fake-landscape rotation (see GuideRotation). Portal content escapes any rotated
|
|
53
|
+
// ancestor, so the card rotates itself, and sizes itself for the turn (see getGuideCardSize).
|
|
54
|
+
rotation?: GuideRotation
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
type CardProps = Omit<HowToPlayDialogProps, 'visible'>
|
|
58
|
+
|
|
59
|
+
function HowToPlayCard({ steps, onFinish, onSkip, rotation = 0 }: CardProps) {
|
|
60
|
+
const { dark, colors } = useAutoPaperTheme()
|
|
61
|
+
const { width: windowWidth, height: windowHeight } = useWindowDimensions()
|
|
62
|
+
const reducedMotion = useReducedMotion()
|
|
63
|
+
const [index, setIndex] = useState(0)
|
|
64
|
+
const scrollRef = useRef<ScrollView>(null)
|
|
65
|
+
|
|
66
|
+
// Android's hardware back would otherwise fall through to the router underneath and pop a screen
|
|
67
|
+
// (or exit the app) with the guide still up. Treated as Skip, the same as the on-screen button.
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
const subscription = BackHandler.addEventListener('hardwareBackPress', () => {
|
|
70
|
+
onSkip()
|
|
71
|
+
return true
|
|
72
|
+
})
|
|
73
|
+
return () => subscription.remove()
|
|
74
|
+
}, [onSkip])
|
|
75
|
+
|
|
76
|
+
const { fg, cardBg, cardBorder, fgMuted } = getOverlayCardColors(dark)
|
|
77
|
+
const { width: cardWidth, pageWidth, maxHeight, artHeight } = getGuideCardSize(windowWidth, windowHeight, rotation)
|
|
78
|
+
const rotationStyle = rotation === 0 ? undefined : { transform: [{ rotate: `${rotation}deg` }] }
|
|
79
|
+
const isFirst = index === 0
|
|
80
|
+
const isLast = index === steps.length - 1
|
|
81
|
+
const isOnly = steps.length === 1
|
|
82
|
+
const artBackground = dark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.05)'
|
|
83
|
+
|
|
84
|
+
// The page a Next/Back tap is animating to, while it's on its way. An animated scrollTo emits every
|
|
85
|
+
// offset in between through onScroll, and without this the dots and buttons would flip back to the
|
|
86
|
+
// page being left for the first half of the animation.
|
|
87
|
+
const targetRef = useRef<number | null>(null)
|
|
88
|
+
|
|
89
|
+
function goTo(next: number) {
|
|
90
|
+
targetRef.current = next
|
|
91
|
+
setIndex(next)
|
|
92
|
+
scrollRef.current?.scrollTo({ x: next * pageWidth, animated: !reducedMotion })
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Keeps the dots and buttons in step with a swipe (or a trackpad scroll on web). onScroll, not only
|
|
96
|
+
// onMomentumScrollEnd: react-native-web never fires the momentum events, so on web a swipe moved the
|
|
97
|
+
// cards while the dots and buttons stayed on the old page (Jay spotted it in Solitaire). The index
|
|
98
|
+
// follows whichever page is more than half in view, the same moment a native pager's dots change.
|
|
99
|
+
function syncToOffset(offsetX: number) {
|
|
100
|
+
const page = Math.round(offsetX / pageWidth)
|
|
101
|
+
if (page < 0 || page >= steps.length) return
|
|
102
|
+
if (targetRef.current !== null) {
|
|
103
|
+
if (page === targetRef.current) targetRef.current = null
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
setIndex(page)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function handleScroll(e: NativeSyntheticEvent<NativeScrollEvent>) {
|
|
110
|
+
syncToOffset(e.nativeEvent.contentOffset.x)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Native's settle event: also clears a pending target, in case the animation was interrupted by a
|
|
114
|
+
// drag before it reached its page.
|
|
115
|
+
function handleMomentumEnd(e: NativeSyntheticEvent<NativeScrollEvent>) {
|
|
116
|
+
targetRef.current = null
|
|
117
|
+
syncToOffset(e.nativeEvent.contentOffset.x)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
<Portal>
|
|
122
|
+
<View style={[overlayStyles.overlay, styles.overlay]}>
|
|
123
|
+
<View accessibilityViewIsModal style={[overlayStyles.card, styles.card, { width: cardWidth, maxHeight, backgroundColor: cardBg, borderColor: cardBorder }, rotationStyle]}>
|
|
124
|
+
{/* The pager shrinks (flexShrink) when the card hits maxHeight, and each page is its own
|
|
125
|
+
vertical ScrollView, so a page that still doesn't fit (a long translation, large text)
|
|
126
|
+
scrolls instead of being clipped. In the normal case nothing scrolls. */}
|
|
127
|
+
<ScrollView ref={scrollRef} horizontal pagingEnabled showsHorizontalScrollIndicator={false} onScroll={handleScroll} scrollEventThrottle={16} onMomentumScrollEnd={handleMomentumEnd} style={[styles.pager, { width: pageWidth }]}>
|
|
128
|
+
{steps.map((step, i) => (
|
|
129
|
+
<ScrollView key={i} nestedScrollEnabled showsVerticalScrollIndicator={false} style={{ width: pageWidth }} contentContainerStyle={styles.page}>
|
|
130
|
+
{step.art !== undefined && <View style={[styles.art, { height: artHeight, backgroundColor: artBackground }]}>{step.art}</View>}
|
|
131
|
+
<Text variant='headlineSmall' style={[styles.title, { color: fg }]}>
|
|
132
|
+
{step.title}
|
|
133
|
+
</Text>
|
|
134
|
+
<Text variant='bodyMedium' style={[styles.body, { color: fg }]}>
|
|
135
|
+
{step.body}
|
|
136
|
+
</Text>
|
|
137
|
+
</ScrollView>
|
|
138
|
+
))}
|
|
139
|
+
</ScrollView>
|
|
140
|
+
|
|
141
|
+
{steps.length > 1 && (
|
|
142
|
+
<View accessible accessibilityLabel={`Step ${index + 1} of ${steps.length}`} style={styles.dots}>
|
|
143
|
+
{steps.map((_, i) => (
|
|
144
|
+
<View key={i} style={[styles.dot, { backgroundColor: i === index ? colors.primary : fgMuted }]} />
|
|
145
|
+
))}
|
|
146
|
+
</View>
|
|
147
|
+
)}
|
|
148
|
+
|
|
149
|
+
{/* ConfirmDialog's exact row (see overlayActionStyles): outlined secondary on the left,
|
|
150
|
+
contained primary on the right. Skip and Back share the one secondary slot — three
|
|
151
|
+
full-size buttons don't fit side by side at the row's 128 minimum — so Skip is only
|
|
152
|
+
offered on the first card; after that the way out is Next to the end (a short flow) or
|
|
153
|
+
Android's hardware back, which skips from anywhere. */}
|
|
154
|
+
<View style={overlayActionStyles.actions}>
|
|
155
|
+
{!isOnly && (
|
|
156
|
+
<Button mode='outlined' onPress={isFirst ? onSkip : () => goTo(index - 1)} style={overlayActionStyles.actionButton}>
|
|
157
|
+
{isFirst ? 'Skip' : 'Back'}
|
|
158
|
+
</Button>
|
|
159
|
+
)}
|
|
160
|
+
<Button mode='contained' onPress={isLast ? onFinish : () => goTo(index + 1)} style={isOnly ? overlayActionStyles.actionButtonSingle : overlayActionStyles.actionButton} buttonColor={colors.primary} textColor={colors.onPrimary}>
|
|
161
|
+
{isLast ? 'Ready' : 'Next'}
|
|
162
|
+
</Button>
|
|
163
|
+
</View>
|
|
164
|
+
</View>
|
|
165
|
+
</View>
|
|
166
|
+
</Portal>
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// A short, skippable, paged "how to play" card — the one shared shape every fleet game's intro takes,
|
|
171
|
+
// built on the same retro overlay-card look as ConfirmDialog (see overlayCard.ts) so it reads as
|
|
172
|
+
// part of the same family. Controlled and presentational: whether it's visible, and what happens on
|
|
173
|
+
// finish/skip (persisting "seen", mostly), is the caller's — see GuideProvider for the wiring nearly
|
|
174
|
+
// every app wants. Buttons are the primary way through; the paging ScrollView underneath is what
|
|
175
|
+
// makes a swipe work too, with no gesture-handler dependency.
|
|
176
|
+
//
|
|
177
|
+
// Split into an outer visibility gate and an inner card so the card's own page index is created
|
|
178
|
+
// fresh every time it opens — a replay from Settings always starts on the first card, with no
|
|
179
|
+
// effect needed to reset it.
|
|
180
|
+
export function HowToPlayDialog({ visible, steps, ...rest }: HowToPlayDialogProps) {
|
|
181
|
+
if (!visible || steps.length === 0) return null
|
|
182
|
+
return <HowToPlayCard steps={steps} {...rest} />
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const styles = StyleSheet.create({
|
|
186
|
+
art: {
|
|
187
|
+
alignItems: 'center',
|
|
188
|
+
borderRadius: 12,
|
|
189
|
+
justifyContent: 'center',
|
|
190
|
+
overflow: 'hidden',
|
|
191
|
+
width: '100%'
|
|
192
|
+
},
|
|
193
|
+
body: {
|
|
194
|
+
textAlign: 'center'
|
|
195
|
+
},
|
|
196
|
+
// Additive on top of overlayCard's own shared `card` shape — its default 32px padding is tighter
|
|
197
|
+
// here (a paged card wants more width for its art) and the shared 16px gap is right.
|
|
198
|
+
card: {
|
|
199
|
+
padding: CARD_PADDING
|
|
200
|
+
},
|
|
201
|
+
dot: {
|
|
202
|
+
borderRadius: 4,
|
|
203
|
+
height: 8,
|
|
204
|
+
width: 8
|
|
205
|
+
},
|
|
206
|
+
dots: {
|
|
207
|
+
flexDirection: 'row',
|
|
208
|
+
gap: 8
|
|
209
|
+
},
|
|
210
|
+
overlay: {
|
|
211
|
+
paddingHorizontal: OVERLAY_GUTTER
|
|
212
|
+
},
|
|
213
|
+
page: {
|
|
214
|
+
alignItems: 'center',
|
|
215
|
+
gap: 12
|
|
216
|
+
},
|
|
217
|
+
pager: {
|
|
218
|
+
flexShrink: 1
|
|
219
|
+
},
|
|
220
|
+
title: {
|
|
221
|
+
fontWeight: 'bold',
|
|
222
|
+
textAlign: 'center'
|
|
223
|
+
}
|
|
224
|
+
})
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { useAutoPaperTheme } from '@rific/auto-paper'
|
|
2
|
+
import { StyleSheet, View } from 'react-native'
|
|
3
|
+
import { Icon } from 'react-native-paper'
|
|
4
|
+
|
|
5
|
+
export type SeatDevice = 'keyboard' | 'mouse'
|
|
6
|
+
|
|
7
|
+
export interface SeatDevicesHintProps {
|
|
8
|
+
// What each seat plays with right now, in seat order (index 0 = player 1). Map the app's own live
|
|
9
|
+
// control scheme onto this: a key scheme is 'keyboard', a pointer scheme is 'mouse'.
|
|
10
|
+
devices: readonly SeatDevice[]
|
|
11
|
+
// Each seat's color, same order. Defaults to the theme's primary/secondary, which ARE the two seats'
|
|
12
|
+
// colors in the fleet games.
|
|
13
|
+
seatColors?: readonly string[]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const ICON: Record<SeatDevice, { source: string; size: number }> = {
|
|
17
|
+
keyboard: { source: 'keyboard-outline', size: 56 },
|
|
18
|
+
mouse: { source: 'mouse-outline', size: 44 }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const ORDER: readonly SeatDevice[] = ['keyboard', 'mouse']
|
|
22
|
+
|
|
23
|
+
// "Two players, one computer": the desktop counterpart to SeatDiagram on the two-player card. One icon
|
|
24
|
+
// per device the seats actually use (a keyboard if either is on keys, a mouse if either drags), each
|
|
25
|
+
// tinted in the color of the one seat using it, or the neutral onSurface when both share it. So the
|
|
26
|
+
// fleet's common default of P1 on the mouse and P2 on WASD reads as a P1-colored mouse beside a
|
|
27
|
+
// P2-colored keyboard, and a keys-only setup as one plain keyboard. Snake, AirHockey and Pong each drew
|
|
28
|
+
// their own copy of this before it existed here. Plain Icons, no animation.
|
|
29
|
+
export function SeatDevicesHint({ devices, seatColors }: SeatDevicesHintProps) {
|
|
30
|
+
const { colors } = useAutoPaperTheme()
|
|
31
|
+
const tints = seatColors ?? [colors.primary, colors.secondary]
|
|
32
|
+
const accessibilityLabel = devices.map((device, i) => `Player ${i + 1} uses the ${device}`).join(', ')
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<View accessible accessibilityLabel={accessibilityLabel} style={styles.row}>
|
|
36
|
+
{ORDER.map((device) => {
|
|
37
|
+
const seats = devices.flatMap((d, i) => (d === device ? [i] : []))
|
|
38
|
+
if (seats.length === 0) return null
|
|
39
|
+
const color = seats.length === 1 ? (tints[seats[0]] ?? colors.onSurface) : colors.onSurface
|
|
40
|
+
return <Icon key={device} source={ICON[device].source} size={ICON[device].size} color={color} />
|
|
41
|
+
})}
|
|
42
|
+
</View>
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const styles = StyleSheet.create({
|
|
47
|
+
row: {
|
|
48
|
+
alignItems: 'center',
|
|
49
|
+
flexDirection: 'row',
|
|
50
|
+
gap: 20
|
|
51
|
+
}
|
|
52
|
+
})
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { useAutoPaperTheme } from '@rific/auto-paper'
|
|
2
|
+
import { StyleSheet, View } from 'react-native'
|
|
3
|
+
import { Text } from 'react-native-paper'
|
|
4
|
+
|
|
5
|
+
export interface SeatDiagramProps {
|
|
6
|
+
// Default to the theme's own secondary/primary — in the fleet games those ARE the two players'
|
|
7
|
+
// live seat colors, so the picture always matches whatever colors they've picked.
|
|
8
|
+
topColor?: string
|
|
9
|
+
bottomColor?: string
|
|
10
|
+
topLabel?: string
|
|
11
|
+
bottomLabel?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const DEVICE_WIDTH = 88
|
|
15
|
+
const DEVICE_HEIGHT = 120
|
|
16
|
+
|
|
17
|
+
// "Two players, one phone": a portrait phone split into a top half and a bottom half, each tinted in
|
|
18
|
+
// its own seat color, with the top label upside down the way that player actually reads it. The
|
|
19
|
+
// seating card every face-to-face game (LightCycles, Snake, AirHockey, BoxHockey, Pong) needs, drawn
|
|
20
|
+
// once. Plain Views, no animation — nothing here needs reduced-motion handling.
|
|
21
|
+
export function SeatDiagram({ topColor, bottomColor, topLabel = 'P2', bottomLabel = 'P1' }: SeatDiagramProps) {
|
|
22
|
+
const { colors } = useAutoPaperTheme()
|
|
23
|
+
const top = topColor ?? colors.secondary
|
|
24
|
+
const bottom = bottomColor ?? colors.primary
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<View accessible accessibilityLabel={`${bottomLabel} plays the bottom half, ${topLabel} plays the top half`} style={[styles.device, { borderColor: colors.onSurface }]}>
|
|
28
|
+
<View style={[styles.half, { borderColor: top }]}>
|
|
29
|
+
<View style={[StyleSheet.absoluteFill, styles.tint, { backgroundColor: top }]} />
|
|
30
|
+
<Text variant='titleMedium' style={[styles.label, styles.flipped, { color: top }]}>
|
|
31
|
+
{topLabel}
|
|
32
|
+
</Text>
|
|
33
|
+
</View>
|
|
34
|
+
<View style={[styles.half, styles.bottomHalf, { borderColor: bottom }]}>
|
|
35
|
+
<View style={[StyleSheet.absoluteFill, styles.tint, { backgroundColor: bottom }]} />
|
|
36
|
+
<Text variant='titleMedium' style={[styles.label, { color: bottom }]}>
|
|
37
|
+
{bottomLabel}
|
|
38
|
+
</Text>
|
|
39
|
+
</View>
|
|
40
|
+
</View>
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const styles = StyleSheet.create({
|
|
45
|
+
bottomHalf: {
|
|
46
|
+
borderTopWidth: 0
|
|
47
|
+
},
|
|
48
|
+
device: {
|
|
49
|
+
borderRadius: 16,
|
|
50
|
+
borderWidth: 2,
|
|
51
|
+
height: DEVICE_HEIGHT,
|
|
52
|
+
overflow: 'hidden',
|
|
53
|
+
width: DEVICE_WIDTH
|
|
54
|
+
},
|
|
55
|
+
flipped: {
|
|
56
|
+
transform: [{ rotate: '180deg' }]
|
|
57
|
+
},
|
|
58
|
+
half: {
|
|
59
|
+
alignItems: 'center',
|
|
60
|
+
borderWidth: 2,
|
|
61
|
+
flex: 1,
|
|
62
|
+
justifyContent: 'center'
|
|
63
|
+
},
|
|
64
|
+
label: {
|
|
65
|
+
fontWeight: 'bold'
|
|
66
|
+
},
|
|
67
|
+
// A wash of the seat color behind its own label rather than an rgba() fill — the theme's colors
|
|
68
|
+
// aren't guaranteed to be 6-digit hex, so an opacity layer is the only alpha that always works.
|
|
69
|
+
tint: {
|
|
70
|
+
opacity: 0.2
|
|
71
|
+
}
|
|
72
|
+
})
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { useAutoPaperTheme } from '@rific/auto-paper'
|
|
2
|
+
import { useEffect } from 'react'
|
|
3
|
+
import { StyleSheet, View } from 'react-native'
|
|
4
|
+
import { Icon } from 'react-native-paper'
|
|
5
|
+
import Animated, { Easing, useAnimatedStyle, useReducedMotion, useSharedValue, withRepeat, withSequence, withTiming } from 'react-native-reanimated'
|
|
6
|
+
|
|
7
|
+
export type SwipeDirection = 'up' | 'down' | 'left' | 'right'
|
|
8
|
+
export type SwipePointer = 'finger' | 'mouse'
|
|
9
|
+
|
|
10
|
+
export interface SwipeHintProps {
|
|
11
|
+
direction?: SwipeDirection
|
|
12
|
+
// What sweeps along the path: a fingertip ring (a touch screen) or a mouse (a desktop seat that
|
|
13
|
+
// plays by dragging). The mouse variant also relabels itself "Drag" for screen readers, since a
|
|
14
|
+
// desktop player should never be told to swipe.
|
|
15
|
+
pointer?: SwipePointer
|
|
16
|
+
// Defaults to the theme's own primary — i.e. the player's own seat color in the fleet games.
|
|
17
|
+
color?: string
|
|
18
|
+
// Length of the dotted path in points, arrowhead excluded.
|
|
19
|
+
length?: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const DOT_COUNT = 7
|
|
23
|
+
const FINGER_SIZE = 28
|
|
24
|
+
const MOUSE_SIZE = 30
|
|
25
|
+
const SWEEP_MS = 900
|
|
26
|
+
const PAUSE_MS = 500
|
|
27
|
+
|
|
28
|
+
const ARROW_ICON: Record<SwipeDirection, string> = { up: 'arrow-up', down: 'arrow-down', left: 'arrow-left', right: 'arrow-right' }
|
|
29
|
+
|
|
30
|
+
// A finger-sized ring (or, with pointer='mouse', a mouse) sweeping along a dotted path toward an
|
|
31
|
+
// arrowhead, on a loop — the "swipe this way" illustration the arcade games (LightCycles, Snake, Pong,
|
|
32
|
+
// AirHockey, BoxHockey) each need for their first card, so it lives here once rather than being
|
|
33
|
+
// redrawn per app. The mouse variant is the same card on desktop web for a seat that plays by
|
|
34
|
+
// dragging; Snake and AirHockey each drew their own before it existed. Plain Views plus Reanimated:
|
|
35
|
+
// no Skia, no SVG, so it renders identically in every consuming app (Hangman, which has neither,
|
|
36
|
+
// included). Under reduced motion the pointer just rests at the midpoint, still visible.
|
|
37
|
+
export function SwipeHint({ direction = 'right', pointer = 'finger', color, length = 96 }: SwipeHintProps) {
|
|
38
|
+
const { colors } = useAutoPaperTheme()
|
|
39
|
+
const tint = color ?? colors.primary
|
|
40
|
+
const reducedMotion = useReducedMotion()
|
|
41
|
+
const progress = useSharedValue(0.5)
|
|
42
|
+
const horizontal = direction === 'left' || direction === 'right'
|
|
43
|
+
// Which way the finger travels along its own axis — left/up run against the positive x/y axes.
|
|
44
|
+
const sign = direction === 'right' || direction === 'down' ? 1 : -1
|
|
45
|
+
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
if (reducedMotion) {
|
|
48
|
+
progress.value = 0.5
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
// Sweep out, hold (the ring fades while it rests at the end), then snap back to the start.
|
|
52
|
+
progress.value = 0
|
|
53
|
+
progress.value = withRepeat(withSequence(withTiming(1, { duration: SWEEP_MS, easing: Easing.out(Easing.cubic) }), withTiming(1, { duration: PAUSE_MS }), withTiming(0, { duration: 0 })), -1)
|
|
54
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
55
|
+
}, [reducedMotion])
|
|
56
|
+
|
|
57
|
+
const fingerStyle = useAnimatedStyle(() => {
|
|
58
|
+
const travel = (progress.value - 0.5) * length * sign
|
|
59
|
+
return {
|
|
60
|
+
opacity: reducedMotion ? 1 : Math.min(1, progress.value * 6, (1 - progress.value) * 6 + 0.001),
|
|
61
|
+
transform: horizontal ? [{ translateX: travel }] : [{ translateY: travel }]
|
|
62
|
+
}
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
const axis = horizontal ? (sign > 0 ? 'row' : 'row-reverse') : sign > 0 ? 'column' : 'column-reverse'
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<View accessibilityLabel={`${pointer === 'mouse' ? 'Drag' : 'Swipe'} ${direction}`} accessible style={[styles.path, { flexDirection: axis }]}>
|
|
69
|
+
<View style={[styles.dots, { flexDirection: axis, [horizontal ? 'width' : 'height']: length }]}>
|
|
70
|
+
{Array.from({ length: DOT_COUNT }, (_, i) => (
|
|
71
|
+
<View key={i} style={[styles.dot, { backgroundColor: tint, opacity: 0.35 + (0.65 * i) / (DOT_COUNT - 1) }]} />
|
|
72
|
+
))}
|
|
73
|
+
</View>
|
|
74
|
+
<Icon source={ARROW_ICON[direction]} size={28} color={tint} />
|
|
75
|
+
{pointer === 'mouse' ? (
|
|
76
|
+
<Animated.View style={[styles.mouse, fingerStyle]}>
|
|
77
|
+
<Icon source='mouse' size={MOUSE_SIZE} color={tint} />
|
|
78
|
+
</Animated.View>
|
|
79
|
+
) : (
|
|
80
|
+
<Animated.View style={[styles.finger, { borderColor: tint }, fingerStyle]} />
|
|
81
|
+
)}
|
|
82
|
+
</View>
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const styles = StyleSheet.create({
|
|
87
|
+
dot: {
|
|
88
|
+
borderRadius: 3,
|
|
89
|
+
height: 6,
|
|
90
|
+
width: 6
|
|
91
|
+
},
|
|
92
|
+
dots: {
|
|
93
|
+
alignItems: 'center',
|
|
94
|
+
justifyContent: 'space-between'
|
|
95
|
+
},
|
|
96
|
+
// Centered on the whole path (not on the dots row alone) by the container's own alignItems/
|
|
97
|
+
// justifyContent, then pushed along the axis by its own animated translate — absolute so it never
|
|
98
|
+
// takes space from the dots and arrowhead it's drawn over.
|
|
99
|
+
finger: {
|
|
100
|
+
borderRadius: FINGER_SIZE / 2,
|
|
101
|
+
borderWidth: 3,
|
|
102
|
+
height: FINGER_SIZE,
|
|
103
|
+
position: 'absolute',
|
|
104
|
+
width: FINGER_SIZE
|
|
105
|
+
},
|
|
106
|
+
// Positioned exactly like `finger` (absolute, centered by the container, moved by the same animated
|
|
107
|
+
// translate); only what's drawn differs.
|
|
108
|
+
mouse: {
|
|
109
|
+
position: 'absolute'
|
|
110
|
+
},
|
|
111
|
+
path: {
|
|
112
|
+
alignItems: 'center',
|
|
113
|
+
justifyContent: 'center'
|
|
114
|
+
}
|
|
115
|
+
})
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// redux-persist's own REHYDRATE action-type constant, inlined as a literal rather than taking a
|
|
2
|
+
// dependency on the package itself — same reasoning as @tastic/profile's createSeatColorsSlice, whose
|
|
3
|
+
// factory shape this follows: this file has no opinion on whether the consuming app uses
|
|
4
|
+
// redux-persist at all, and the string is a long-stable part of its public contract.
|
|
5
|
+
const REHYDRATE = 'persist/REHYDRATE'
|
|
6
|
+
|
|
7
|
+
export interface GuideState {
|
|
8
|
+
// The highest guide version this player has finished or skipped — 0 means "never". Compared
|
|
9
|
+
// against the app's own `currentVersion`, so bumping that later re-prompts everyone (only worth
|
|
10
|
+
// doing for a genuine controls/rules change, not a copy tweak).
|
|
11
|
+
versionSeen: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// A `type` alias, not an `interface`, on purpose: redux's UnknownAction has a string index signature,
|
|
15
|
+
// and only an object-literal type alias (never an interface) is implicitly assignable to one — with an
|
|
16
|
+
// interface here `configureStore` rejects the reducer at the consuming app's own typecheck.
|
|
17
|
+
export type GuideAction = {
|
|
18
|
+
type: string
|
|
19
|
+
payload?: unknown
|
|
20
|
+
// Present on redux-persist's own REHYDRATE action (the persistConfig `key` it belongs to).
|
|
21
|
+
key?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface GuideSliceOptions {
|
|
25
|
+
// What a player who has already seen (or been grandfathered past) the guide is stamped with.
|
|
26
|
+
currentVersion: number
|
|
27
|
+
// The key the reducer is mounted under in the app's own combineReducers({...}) — redux-persist's
|
|
28
|
+
// REHYDRATE payload is keyed by it, and a reducer has no way to see its own mount key.
|
|
29
|
+
mountKey?: string
|
|
30
|
+
// The persistConfig `key` of the store this reducer lives in. A REHYDRATE for any OTHER persisted
|
|
31
|
+
// reducer (a nested persistReducer) carries that reducer's own substate, which would otherwise
|
|
32
|
+
// look exactly like "an existing player with no guide key".
|
|
33
|
+
persistKey?: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface GuideSlice {
|
|
37
|
+
actions: { markSeen: (version: number) => { type: string; payload: number } }
|
|
38
|
+
reducer: (state: GuideState | undefined, action: GuideAction) => GuideState
|
|
39
|
+
selectVersionSeen: (rootState: object) => number
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// The persisted "has this player seen the how-to-play flow" flag, as its own slice rather than a
|
|
43
|
+
// field on any app's settings blob — every fleet app already has a redux-persist root store behind a
|
|
44
|
+
// PersistGate, and a dedicated key survives 'Reset stats'/'Reset match settings' (neither touches
|
|
45
|
+
// Redux) without needing any of the four strict per-app settings validators to learn a new field.
|
|
46
|
+
//
|
|
47
|
+
// The one thing a plain settings field can't do, and the reason this is a factory instead of a
|
|
48
|
+
// createSettingsSlice call: tell a FRESH install from an EXISTING player who just updated. On a
|
|
49
|
+
// fresh install redux-persist's getStoredState finds nothing under the persistConfig key and
|
|
50
|
+
// REHYDRATE arrives with `payload === undefined`; an existing player has a payload object that
|
|
51
|
+
// simply predates this slice and so has no `[mountKey]` entry. The first shows the guide; the
|
|
52
|
+
// second is grandfathered — stamped as already having seen `currentVersion` — so nobody who has been
|
|
53
|
+
// playing gets a "how to play" screen after an over-the-air update. Both can still replay it from
|
|
54
|
+
// Settings.
|
|
55
|
+
//
|
|
56
|
+
// redux-persist's default stateReconciler (autoMergeLevel1) skips any substate a reducer already
|
|
57
|
+
// changed while handling REHYDRATE, so the value returned here for an existing player wins rather
|
|
58
|
+
// than being overwritten.
|
|
59
|
+
export function createGuideSlice({ currentVersion, mountKey = 'guide', persistKey = 'root' }: GuideSliceOptions): GuideSlice {
|
|
60
|
+
const MARK_SEEN = `${mountKey}/markSeen`
|
|
61
|
+
const initialState: GuideState = { versionSeen: 0 }
|
|
62
|
+
|
|
63
|
+
function markSeen(version: number) {
|
|
64
|
+
return { type: MARK_SEEN, payload: version }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function reducer(state: GuideState = initialState, action: GuideAction): GuideState {
|
|
68
|
+
if (action.type === REHYDRATE) {
|
|
69
|
+
if (action.key !== undefined && action.key !== persistKey) return state
|
|
70
|
+
const payload = action.payload as Record<string, Partial<GuideState> | undefined> | undefined
|
|
71
|
+
// Nothing persisted at all (fresh install) — or the read failed, which shows the guide once
|
|
72
|
+
// more rather than silently skipping it.
|
|
73
|
+
if (!payload) return state
|
|
74
|
+
const persisted = payload[mountKey]
|
|
75
|
+
if (typeof persisted?.versionSeen === 'number') return { versionSeen: persisted.versionSeen }
|
|
76
|
+
return { versionSeen: currentVersion }
|
|
77
|
+
}
|
|
78
|
+
if (action.type === MARK_SEEN && typeof action.payload === 'number') {
|
|
79
|
+
return { versionSeen: Math.max(state.versionSeen, action.payload) }
|
|
80
|
+
}
|
|
81
|
+
return state
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function selectVersionSeen(rootState: object): number {
|
|
85
|
+
return (rootState as Record<string, GuideState | undefined>)[mountKey]?.versionSeen ?? 0
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return { actions: { markSeen }, reducer, selectVersionSeen }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// The one AsyncStorage call createReturningPlayerMigrate needs, passed in (apps pass AsyncStorage
|
|
92
|
+
// itself) so this entry point never imports it.
|
|
93
|
+
export interface GuideStorageReader {
|
|
94
|
+
getItem: (key: string) => Promise<string | null>
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Structurally identical to redux-persist's own PersistedState, so the returned function drops straight
|
|
98
|
+
// into a persistConfig's `migrate` without this package depending on redux-persist.
|
|
99
|
+
export type GuidePersistedState = { _persist: { version: number; rehydrated: boolean } } | undefined
|
|
100
|
+
|
|
101
|
+
// For a persistConfig's `migrate`. createGuideSlice grandfathers a player by finding a persisted root
|
|
102
|
+
// store with no guide entry in it, which only works for a player whose last build already HAD that root
|
|
103
|
+
// store. Most of the fleet (LightCycles, AirHockey, BoxHockey, Pong) only got a redux-persist root store
|
|
104
|
+
// after its last shipped update, so their current players have no root store at all: on their first
|
|
105
|
+
// launch of a guide build REHYDRATE would arrive with no payload, exactly like a fresh install, and the
|
|
106
|
+
// guide would greet people who've been playing for weeks.
|
|
107
|
+
//
|
|
108
|
+
// Those earlier builds did write their own AsyncStorage keys (settings, stats, appearance, ...), which
|
|
109
|
+
// are passed in as `legacyKeys`. So: no root store, but any legacy key present, means "played an earlier
|
|
110
|
+
// build", and that player is rehydrated with an empty payload instead, which the guide slice reads as an
|
|
111
|
+
// existing player and stamps as seen. An empty payload is a no-op for every other slice (nothing to merge,
|
|
112
|
+
// and redux-persist's reconciler skips `_persist`). A root store that already exists passes through
|
|
113
|
+
// untouched, so this is a no-op for every launch after the first. A failed read falls back to the
|
|
114
|
+
// fresh-install behavior (the guide shows once), the same as the slice itself.
|
|
115
|
+
//
|
|
116
|
+
// Runs before REHYDRATE and before PersistGate lets anything render, so there's no race with Home's
|
|
117
|
+
// auto-show. List the keys the shipped builds actually wrote (read them from the last shipped commit, not
|
|
118
|
+
// today's source). The one thing to avoid is a key today's build writes BEFORE PersistGate opens: on a
|
|
119
|
+
// fresh install that would already exist by the time this runs, and the fresh install would be taken for
|
|
120
|
+
// a returning player.
|
|
121
|
+
export function createReturningPlayerMigrate(storage: GuideStorageReader, legacyKeys: readonly string[]) {
|
|
122
|
+
return async (state: GuidePersistedState, version: number): Promise<GuidePersistedState> => {
|
|
123
|
+
if (state !== undefined || legacyKeys.length === 0) return state
|
|
124
|
+
try {
|
|
125
|
+
const values = await Promise.all(legacyKeys.map((key) => storage.getItem(key)))
|
|
126
|
+
return values.some((value) => value !== null) ? { _persist: { version, rehydrated: false } } : undefined
|
|
127
|
+
} catch {
|
|
128
|
+
return undefined
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// The how-to-play entry point (`@tastic/hud/guide`) — a standalone build reachable without ever
|
|
2
|
+
// touching the main barrel, the same reason `@tastic/hud/skia-gate` is. This file and everything it
|
|
3
|
+
// imports must stay free of @tastic/core, @tastic/profile, @tastic/split-screen, @tastic/achievements,
|
|
4
|
+
// @rific/updater and Skia: those are optional peers of this package (see package.json's
|
|
5
|
+
// peerDependenciesMeta) precisely so an app like Hangman, which has none of them, can install
|
|
6
|
+
// @tastic/hud and use only this. src/__tests__/guideImports.test.ts enforces it.
|
|
7
|
+
export { type DirectionKeyLabels, type DirectionKeysAxis, DirectionKeysHint, type DirectionKeysHintProps } from './DirectionKeysHint'
|
|
8
|
+
export { GuideProvider, type GuideProviderProps, useAutoShowGuide, useGuide } from './GuideProvider'
|
|
9
|
+
export { createGuideSlice, createReturningPlayerMigrate, type GuideAction, type GuidePersistedState, type GuideSlice, type GuideSliceOptions, type GuideState, type GuideStorageReader } from './guideSlice'
|
|
10
|
+
export { getGuideCardSize, HowToPlayDialog, type HowToPlayDialogProps } from './HowToPlayDialog'
|
|
11
|
+
export { type SeatDevice, SeatDevicesHint, type SeatDevicesHintProps } from './SeatDevicesHint'
|
|
12
|
+
export { SeatDiagram, type SeatDiagramProps } from './SeatDiagram'
|
|
13
|
+
export { type SwipeDirection, SwipeHint, type SwipeHintProps, type SwipePointer } from './SwipeHint'
|
|
14
|
+
export type { GuideRotation, GuideStep } from './types'
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
// One card of a game's how-to-play flow. Content is app-local data, not something this package
|
|
4
|
+
// authors: only the app knows its own controls, its own key schemes on desktop web, its own win
|
|
5
|
+
// condition. `art` is whatever illustrates the idea — a `SwipeHint`/`SeatDiagram` from this same
|
|
6
|
+
// entry point, or anything the app draws itself (plain Views + Reanimated keeps it renderer-free).
|
|
7
|
+
export interface GuideStep {
|
|
8
|
+
title: string
|
|
9
|
+
body: string
|
|
10
|
+
art?: ReactNode
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// The fleet's fake-landscape rotation (structurally @tastic/core's ViewRotation, restated here so this
|
|
14
|
+
// entry point never imports @tastic/core). Pass the app's LIVE rotation (useRotation/getViewRotation):
|
|
15
|
+
// the guide renders in the app-root Portal host, outside any rotated screen, so it turns itself.
|
|
16
|
+
export type GuideRotation = 0 | 90 | 180 | -90
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { AchievementCatalogSection } from './AchievementCatalogSection'
|
|
2
2
|
export { AchievementRow, LOCKED_BADGE_COLOR } from './AchievementRow'
|
|
3
|
+
export { AchievementUnlockList, type AchievementUnlockOwner, type UnlockedAchievement } from './AchievementUnlockList'
|
|
3
4
|
export { ActivityStatSection } from './ActivityStatSection'
|
|
4
5
|
export { BaseSettingsDialog, type BaseSettingsDialogProps } from './BaseSettingsDialog'
|
|
5
6
|
export { BaseStatsScreen } from './BaseStatsScreen'
|
|
@@ -12,7 +13,7 @@ export { MONO_FONT } from './fonts'
|
|
|
12
13
|
export { getInlineColorPickerContentSize, InlineColorPicker } from './InlineColorPicker'
|
|
13
14
|
export { getLabeledDropdownContentHeight, LABELED_DROPDOWN_POPOVER_WIDTH, LabeledDropdown, type LabeledDropdownOption } from './LabeledDropdown'
|
|
14
15
|
export { loadSkiaWeb } from './loadSkiaWeb'
|
|
15
|
-
export { getOverlayCardColors, type OverlayCardColors, overlayStyles } from './overlayCard'
|
|
16
|
+
export { getOverlayCardColors, overlayActionStyles, type OverlayCardColors, overlayStyles } from './overlayCard'
|
|
16
17
|
export { getColorPopoverId, getCpuDifficultyPopoverId, PlayerSetupPanel, type PlayerSetupPanelProps } from './PlayerSetupPanel'
|
|
17
18
|
export { PopoverBody } from './PopoverBody'
|
|
18
19
|
export { PressAwayOverlay } from './PressAwayOverlay'
|