@tastic/shell 0.1.1 → 0.2.1
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 +71 -12
- package/dist/index.d.mts +86 -14
- package/dist/index.d.ts +86 -14
- package/dist/index.js +337 -165
- package/dist/index.mjs +327 -156
- package/package.json +9 -9
- package/src/PackContext.tsx +2 -2
- package/src/RootLayout.tsx +110 -28
- package/src/components/GuideHost.tsx +62 -23
- package/src/components/Providers.tsx +17 -7
- package/src/components/SeatTwoPanel.tsx +2 -1
- package/src/components/SettingsDialog.tsx +6 -4
- package/src/components/UpdateDialog.tsx +1 -1
- package/src/hooks/useSeatColors.ts +54 -27
- package/src/index.ts +1 -1
- package/src/redux/createPackStore.ts +16 -6
- package/src/redux/shellState.ts +4 -2
- package/src/routes.ts +10 -0
- package/src/screens/GameRoute.tsx +15 -7
- package/src/screens/HomeScreen.tsx +145 -76
- package/src/screens/LoadoutScreen.tsx +13 -7
- package/src/screens/ProfilesRoute.tsx +26 -1
- package/src/screens/StatsScreen.tsx +138 -56
- package/src/types.ts +143 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tastic/shell",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "The app around a pack of React Native games, two-seat or for one player: Home, the loadout, the stats screen, Profiles and Settings, the pack's Redux store and its root providers, driven by one PackDefinition",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"react-native",
|
|
@@ -86,7 +86,7 @@
|
|
|
86
86
|
"@tastic/achievements": "^0.6.0",
|
|
87
87
|
"@tastic/core": "^0.14.0",
|
|
88
88
|
"@tastic/edge-guard": "^0.1.5",
|
|
89
|
-
"@tastic/hud": "^0.
|
|
89
|
+
"@tastic/hud": "^0.26.0",
|
|
90
90
|
"@tastic/profile": "^0.17.0",
|
|
91
91
|
"@tastic/split-screen": "^0.5.5",
|
|
92
92
|
"@testing-library/react-native": "^14.1.0",
|
|
@@ -138,17 +138,17 @@
|
|
|
138
138
|
"@tastic/achievements": ">=0.6.0",
|
|
139
139
|
"@tastic/core": ">=0.14.0",
|
|
140
140
|
"@tastic/edge-guard": ">=0.1.5",
|
|
141
|
-
"@tastic/hud": ">=0.
|
|
142
|
-
"@tastic/profile": ">=0.
|
|
141
|
+
"@tastic/hud": ">=0.26.0",
|
|
142
|
+
"@tastic/profile": ">=0.16.2",
|
|
143
143
|
"@tastic/split-screen": ">=0.5.5",
|
|
144
|
-
"expo-blur": ">=58.0.
|
|
145
|
-
"expo-constants": ">=58.0.
|
|
146
|
-
"expo-font": ">=58.0.
|
|
144
|
+
"expo-blur": ">=58.0.4",
|
|
145
|
+
"expo-constants": ">=58.0.12",
|
|
146
|
+
"expo-font": ">=58.0.8",
|
|
147
147
|
"expo-haptics": ">=58.0.7",
|
|
148
148
|
"expo-keep-awake": ">=58.0.3",
|
|
149
|
-
"expo-router": ">=58.0.
|
|
149
|
+
"expo-router": ">=58.0.23",
|
|
150
150
|
"expo-sensors": ">=58.0.4",
|
|
151
|
-
"expo-splash-screen": ">=58.0.
|
|
151
|
+
"expo-splash-screen": ">=58.0.10",
|
|
152
152
|
"react": ">=19.3.0",
|
|
153
153
|
"react-native": ">=0.88.0",
|
|
154
154
|
"react-native-gesture-handler": ">=3.3.0",
|
package/src/PackContext.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createContext, ReactNode, useContext } from 'react'
|
|
2
2
|
|
|
3
|
-
import type {
|
|
3
|
+
import type { PackDefinition, PackGameDefinition } from './types'
|
|
4
4
|
|
|
5
5
|
// The pack the shell is running: handed in once at the root (see RootLayout), so nothing in shell/
|
|
6
6
|
// ever imports pack.ts or a game. Games reach the shell only through this registry.
|
|
@@ -17,6 +17,6 @@ export function usePack(): PackDefinition {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
// A game by id, or undefined for an id the pack doesn't have (a stale link).
|
|
20
|
-
export function usePackGame(id: string | undefined):
|
|
20
|
+
export function usePackGame(id: string | undefined): PackGameDefinition | undefined {
|
|
21
21
|
return usePack().games.find((g) => g.id === id)
|
|
22
22
|
}
|
package/src/RootLayout.tsx
CHANGED
|
@@ -2,12 +2,13 @@ import { useAutoPaperTheme } from '@rific/auto-paper'
|
|
|
2
2
|
import { useHapticSettings } from '@rific/feedback-press'
|
|
3
3
|
import { Toaster, ToastProvider } from '@rific/toaster'
|
|
4
4
|
import { OrientationProvider, useRotationAwareStatusBar, useThemedRootBackground } from '@tastic/core'
|
|
5
|
-
import { RotatedFrame } from '@tastic/hud'
|
|
5
|
+
import { RotatedFrame } from '@tastic/hud/chrome'
|
|
6
|
+
import { ProfileColorProvider } from '@tastic/profile'
|
|
6
7
|
import * as Haptics from 'expo-haptics'
|
|
7
|
-
import { Stack } from 'expo-router'
|
|
8
|
+
import { DarkTheme, Stack, ThemeProvider as NavigationThemeProvider } from 'expo-router'
|
|
8
9
|
import { DeviceMotion } from 'expo-sensors'
|
|
9
|
-
import { ReactNode, useCallback } from 'react'
|
|
10
|
-
import { StyleSheet } from 'react-native'
|
|
10
|
+
import { ReactNode, useCallback, useMemo } from 'react'
|
|
11
|
+
import { Platform, StyleSheet, View } from 'react-native'
|
|
11
12
|
import { GestureHandlerRootView } from 'react-native-gesture-handler'
|
|
12
13
|
import * as RNPaper from 'react-native-paper'
|
|
13
14
|
import { SafeAreaProvider } from 'react-native-safe-area-context'
|
|
@@ -21,10 +22,10 @@ import { UpdateDialog } from './components/UpdateDialog'
|
|
|
21
22
|
import { useAppSettings } from './hooks/useAppSettings'
|
|
22
23
|
import { LastActiveGameProvider } from './hooks/useLastActiveGame'
|
|
23
24
|
import { ProfilesProvider, useProfiles } from './hooks/useProfiles'
|
|
24
|
-
import { PackProvider } from './PackContext'
|
|
25
|
+
import { PackProvider, usePack } from './PackContext'
|
|
25
26
|
import type { PackStore } from './redux/createPackStore'
|
|
26
27
|
import { SplashGate } from './splashGate'
|
|
27
|
-
import type {
|
|
28
|
+
import type { PackDefinition, PackGameDefinition } from './types'
|
|
28
29
|
|
|
29
30
|
// The pack's root: every provider the shell needs, the pack's games' own providers, and the stack.
|
|
30
31
|
// src/app/_layout.tsx hands it the pack (pack.ts) and its store, so nothing in shell/ imports a
|
|
@@ -45,18 +46,57 @@ import type { GameDefinition, PackDefinition } from './types'
|
|
|
45
46
|
// statusBar={false}). No `locked` argument: OrientationBridge already freezes the reading it
|
|
46
47
|
// follows. A play screen hides the bar for the whole round, whatever the angle (a screen's own
|
|
47
48
|
// options win over screenOptions); index is listed first so it stays the stack's first route.
|
|
49
|
+
// A game with keepStatusBar (Solitaire's) keeps the screen options' rotation-aware bar instead.
|
|
48
50
|
const PLAY_OPTIONS = { statusBarHidden: true }
|
|
51
|
+
const KEEP_STATUS_BAR_OPTIONS = {}
|
|
52
|
+
|
|
53
|
+
// A pack's Backdrop (PackDefinition.Backdrop) is drawn once behind the whole stack and has to show
|
|
54
|
+
// through two layers: each screen's contentStyle, and the layers React Navigation paints its theme's
|
|
55
|
+
// `colors.background` on around every screen (the ScreenStack's container on native, each screen's
|
|
56
|
+
// wrapper on the web). contentStyle alone left an opaque light grey slab over Solitaire's felt on the
|
|
57
|
+
// web. Nothing else reads the navigation theme (headers are hidden; Paper has its own). Built only for
|
|
58
|
+
// a pack with a backdrop, as it renders: never at import, where a pack's tests may stand in for
|
|
59
|
+
// expo-router without a DarkTheme.
|
|
60
|
+
const TRANSPARENT_CONTENT = { backgroundColor: 'transparent' }
|
|
61
|
+
|
|
62
|
+
// The pack's own screen options (PackDefinition.useScreenOptions), as a hook every pack calls: an
|
|
63
|
+
// empty object without them.
|
|
64
|
+
const noScreenOptions = () => ({})
|
|
49
65
|
|
|
50
66
|
function AppStack() {
|
|
67
|
+
const pack = usePack()
|
|
68
|
+
const { Backdrop } = pack
|
|
51
69
|
const { dark } = useAutoPaperTheme()
|
|
52
|
-
|
|
70
|
+
// Over a backdrop the root window stays dark whatever the mode: a backdrop is a dark table (the
|
|
71
|
+
// felt), and a light root window would flash white around it during a push or pop.
|
|
72
|
+
const rootBackground = useThemedRootBackground(Backdrop ? true : dark, false)
|
|
53
73
|
const statusBar = useRotationAwareStatusBar()
|
|
54
|
-
|
|
55
|
-
|
|
74
|
+
const packOptions = (pack.useScreenOptions ?? noScreenOptions)()
|
|
75
|
+
// Over a backdrop every screen is transparent, and Android crossfades: its default push and pop hold
|
|
76
|
+
// the outgoing screen opaque for ~450ms while the new one slides over it, which would show Home
|
|
77
|
+
// straight through a transparent play screen. iOS keeps its own push.
|
|
78
|
+
const backdropOptions = Backdrop ? { contentStyle: TRANSPARENT_CONTENT, animation: Platform.select({ android: 'fade' as const }) } : undefined
|
|
79
|
+
const playOptions = useCallback(
|
|
80
|
+
({ route }: { route: { params?: object } }) => {
|
|
81
|
+
const id = (route.params as { game?: string } | undefined)?.game
|
|
82
|
+
return pack.games.some((g) => g.id === id && g.keepStatusBar) ? KEEP_STATUS_BAR_OPTIONS : PLAY_OPTIONS
|
|
83
|
+
},
|
|
84
|
+
[pack.games]
|
|
85
|
+
)
|
|
86
|
+
const navigationTheme = useMemo(() => (Backdrop ? { ...DarkTheme, colors: { ...DarkTheme.colors, background: 'transparent' } } : null), [Backdrop])
|
|
87
|
+
const stack = (
|
|
88
|
+
<Stack screenOptions={{ ...rootBackground, ...statusBar, ...backdropOptions, ...packOptions }}>
|
|
56
89
|
<Stack.Screen name='index' />
|
|
57
|
-
<Stack.Screen name='play/[game]' options={
|
|
90
|
+
<Stack.Screen name='play/[game]' options={playOptions} />
|
|
58
91
|
</Stack>
|
|
59
92
|
)
|
|
93
|
+
if (!Backdrop || !navigationTheme) return stack
|
|
94
|
+
return (
|
|
95
|
+
<View style={styles.root}>
|
|
96
|
+
<Backdrop />
|
|
97
|
+
<NavigationThemeProvider value={navigationTheme}>{stack}</NavigationThemeProvider>
|
|
98
|
+
</View>
|
|
99
|
+
)
|
|
60
100
|
}
|
|
61
101
|
|
|
62
102
|
// Wires the persisted Lock Orientation setting into @tastic/core's OrientationProvider. Its own
|
|
@@ -77,10 +117,15 @@ function AppStack() {
|
|
|
77
117
|
// committed orientation survive navigation. deviceMotion is the app's own expo-sensors import, handed
|
|
78
118
|
// in because @tastic/core never imports expo-sensors itself (apps without tilt tracking would
|
|
79
119
|
// otherwise need it installed just to bundle).
|
|
120
|
+
//
|
|
121
|
+
// portraitWhileKeyboard (PackDefinition.portraitWhileKeyboard): the software keyboard can't turn with a
|
|
122
|
+
// faked rotation, so a pack that has the player type sideways (a profile's name) reads portrait while
|
|
123
|
+
// it's up.
|
|
80
124
|
function OrientationBridge({ children }: { children: ReactNode }) {
|
|
81
125
|
const { settings, setSettings } = useAppSettings()
|
|
126
|
+
const { portraitWhileKeyboard } = usePack()
|
|
82
127
|
return (
|
|
83
|
-
<OrientationProvider deviceMotion={DeviceMotion} freezeWhileLocked lockInitialValue={{ locked: settings.lockOrientation, rotation: settings.lockedRotation }} onLockChange={(next) => setSettings({ lockOrientation: next.locked, lockedRotation: next.rotation })}>
|
|
128
|
+
<OrientationProvider deviceMotion={DeviceMotion} freezeWhileLocked lockInitialValue={{ locked: settings.lockOrientation, rotation: settings.lockedRotation }} onLockChange={(next) => setSettings({ lockOrientation: next.locked, lockedRotation: next.rotation })} portraitWhileKeyboard={portraitWhileKeyboard}>
|
|
84
129
|
{children}
|
|
85
130
|
</OrientationProvider>
|
|
86
131
|
)
|
|
@@ -104,10 +149,14 @@ function GatedApp({ children }: { children: ReactNode }) {
|
|
|
104
149
|
// follow the same setting as every other haptic in the app. Today they can't fire anyway (the only
|
|
105
150
|
// controls that tick, the history and clear buttons, are off below), but this keeps it that way if
|
|
106
151
|
// either is ever shown. Must render inside Providers, whose FeedbackBridge provides the setting.
|
|
152
|
+
//
|
|
153
|
+
// A pack with its own Toaster gets no Paper here: with Paper the toaster draws through a Paper Portal,
|
|
154
|
+
// which would take its toasts out of the turned frame PackToaster lays them in.
|
|
107
155
|
function HapticGatedToastProvider({ children }: { children: ReactNode }) {
|
|
108
156
|
const { settings } = useHapticSettings()
|
|
157
|
+
const { Toaster: PackToasterComponent } = usePack()
|
|
109
158
|
return (
|
|
110
|
-
<ToastProvider haptics={settings.vibrate ? Haptics : undefined} paper={RNPaper}>
|
|
159
|
+
<ToastProvider haptics={settings.vibrate ? Haptics : undefined} paper={PackToasterComponent ? undefined : RNPaper}>
|
|
111
160
|
{children}
|
|
112
161
|
</ToastProvider>
|
|
113
162
|
)
|
|
@@ -125,9 +174,40 @@ function RotatingToaster() {
|
|
|
125
174
|
return <Toaster historyButton={null} clearButton={null} limit={3} renderFrame={renderFrame} />
|
|
126
175
|
}
|
|
127
176
|
|
|
128
|
-
//
|
|
129
|
-
|
|
130
|
-
|
|
177
|
+
// The pack's own toaster (PackDefinition.Toaster), laid over the whole app in a frame turned with the
|
|
178
|
+
// phone: passThrough, so only a toast claims a touch over the stack, and rotatedSafeArea, so the
|
|
179
|
+
// toaster's own safe-area reading lands on the frame's edges. The absoluteFill wrapper takes the frame
|
|
180
|
+
// out of the layout beside the stack.
|
|
181
|
+
function PackToaster({ Toaster: PackToasterComponent }: { Toaster: NonNullable<PackDefinition['Toaster']> }) {
|
|
182
|
+
return (
|
|
183
|
+
<View style={StyleSheet.absoluteFill} pointerEvents='box-none'>
|
|
184
|
+
<RotatedFrame rotatedSafeArea passThrough>
|
|
185
|
+
<PackToasterComponent />
|
|
186
|
+
</RotatedFrame>
|
|
187
|
+
</View>
|
|
188
|
+
)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Every game's own root provider (its stats and achievements context), nested in pack order. A game
|
|
192
|
+
// without one adds nothing.
|
|
193
|
+
function GameProviders({ games, children }: { games: readonly PackGameDefinition[]; children: ReactNode }) {
|
|
194
|
+
return games.reduceRight<ReactNode>((inner, game) => (game.Provider ? <game.Provider key={game.id}>{inner}</game.Provider> : inner), children)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// How every profile color is drawn (PackDefinition.previewColor), in the live mode: inside Theme for
|
|
198
|
+
// the `dark` read. A pack without it draws the colors as they are, with no provider.
|
|
199
|
+
function PreviewColors({ previewColor, children }: { previewColor: NonNullable<PackDefinition['previewColor']>; children: ReactNode }) {
|
|
200
|
+
const { dark } = useAutoPaperTheme()
|
|
201
|
+
const preview = useCallback((hex: string) => previewColor(hex, dark), [previewColor, dark])
|
|
202
|
+
return <ProfileColorProvider preview={preview}>{children}</ProfileColorProvider>
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// The pack's own root pieces that sit inside the store, Theme and every provider above:
|
|
206
|
+
// its color preview around everything, and its own Provider around How to Play and the stack.
|
|
207
|
+
function PackRoot({ pack, children }: { pack: PackDefinition; children: ReactNode }) {
|
|
208
|
+
const { Provider, previewColor } = pack
|
|
209
|
+
const inner = Provider ? <Provider>{children}</Provider> : children
|
|
210
|
+
return previewColor ? <PreviewColors previewColor={previewColor}>{inner}</PreviewColors> : <>{inner}</>
|
|
131
211
|
}
|
|
132
212
|
|
|
133
213
|
interface RootLayoutProps {
|
|
@@ -149,26 +229,28 @@ export function RootLayout({ pack, store, persistor }: RootLayoutProps) {
|
|
|
149
229
|
it mounts) is the saved one. */}
|
|
150
230
|
<LastActiveGameProvider>
|
|
151
231
|
<GameProviders games={pack.games}>
|
|
152
|
-
<
|
|
153
|
-
<
|
|
154
|
-
<
|
|
155
|
-
|
|
232
|
+
<PackRoot pack={pack}>
|
|
233
|
+
<GatedApp>
|
|
234
|
+
<OrientationBridge>
|
|
235
|
+
<HapticGatedToastProvider>
|
|
236
|
+
{/* Wraps the stack (rather than sitting beside it like UpdateDialog)
|
|
156
237
|
because Home's tiles and the loadout's How to Play row reach it through
|
|
157
238
|
context (see GuideHost). Inside the toast provider and OrientationBridge
|
|
158
239
|
because the card renders through a Portal and rotates with the shared
|
|
159
240
|
reading, and inside the gate so it mounts together with Home rather than
|
|
160
241
|
before it. */}
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
242
|
+
<GuideHost>
|
|
243
|
+
<AppStack />
|
|
244
|
+
</GuideHost>
|
|
245
|
+
{/* historyButton/clearButton off: there's no toast history to open, and a
|
|
165
246
|
control row for it above the toast stack would go nowhere. limit pins the
|
|
166
247
|
package's current default (3) so a future toaster release can't change it. */}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
248
|
+
{pack.Toaster ? <PackToaster Toaster={pack.Toaster} /> : <RotatingToaster />}
|
|
249
|
+
<UpdateDialog />
|
|
250
|
+
</HapticGatedToastProvider>
|
|
251
|
+
</OrientationBridge>
|
|
252
|
+
</GatedApp>
|
|
253
|
+
</PackRoot>
|
|
172
254
|
</GameProviders>
|
|
173
255
|
</LastActiveGameProvider>
|
|
174
256
|
</ProfilesProvider>
|
|
@@ -9,57 +9,82 @@ import { roundModeFor, seatTwoFor } from '../types'
|
|
|
9
9
|
|
|
10
10
|
interface GameGuideContextValue {
|
|
11
11
|
// Shows a game's How to Play cards if this player hasn't finished or skipped this version yet,
|
|
12
|
-
// then runs `next` once they close; runs `next` at once otherwise
|
|
13
|
-
// a game's cards show the first time it's picked, before
|
|
12
|
+
// then runs `next` once they close; runs `next` at once otherwise (and for a game with no cards of
|
|
13
|
+
// its own). Home calls it on a tile tap, so a game's cards show the first time it's picked, before
|
|
14
|
+
// its loadout opens.
|
|
14
15
|
openFirstTime: (gameId: string, next: () => void) => void
|
|
15
|
-
// Shows a game's cards now, seen or not (a tile's rules button, a loadout's How to Play row).
|
|
16
|
+
// Shows a game's cards now, seen or not (a tile's rules button, a loadout's How to Play row). Does
|
|
17
|
+
// nothing for a game with no cards of its own.
|
|
16
18
|
openFor: (gameId: string) => void
|
|
19
|
+
// Shows the pack's own cards now (PackDefinition.guide: Home's Settings).
|
|
20
|
+
openPack: () => void
|
|
21
|
+
// Shows the pack's own cards once per session, while this player hasn't seen this version (Home's
|
|
22
|
+
// first launch).
|
|
23
|
+
autoShowPack: () => void
|
|
17
24
|
}
|
|
18
25
|
|
|
26
|
+
// What the one GuideProvider is showing: the pack's own cards, or a game's (by id).
|
|
27
|
+
const PACK_GUIDE = '__pack__'
|
|
28
|
+
|
|
29
|
+
const NO_INPUT = {}
|
|
30
|
+
|
|
19
31
|
const GameGuideContext = createContext<GameGuideContextValue | null>(null)
|
|
20
32
|
|
|
21
33
|
interface Props {
|
|
22
34
|
children: ReactNode
|
|
23
35
|
}
|
|
24
36
|
|
|
25
|
-
// One @tastic/hud GuideProvider for the whole pack, showing whichever
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
37
|
+
// One @tastic/hud GuideProvider for the whole pack, showing whichever cards were asked for last:
|
|
38
|
+
// the pack's own (PackDefinition.guide, cards that teach every game at once), or a game's. Each
|
|
39
|
+
// game's cards come from its own registry entry (GameDefinition.guide), built from the live settings
|
|
40
|
+
// and seat 2 so a replay after any loadout change describes the next match. Mounted around the stack
|
|
41
|
+
// in RootLayout, inside the toast provider and OrientationBridge because the card renders through a
|
|
42
|
+
// Portal and turns with the shared reading (`rotation`, the same one Home hands its Settings dialog),
|
|
43
|
+
// and inside the profiles gate so it mounts together with Home.
|
|
31
44
|
export function GuideHost({ children }: Props) {
|
|
32
45
|
const pack = usePack()
|
|
33
|
-
const [
|
|
34
|
-
const
|
|
46
|
+
const [subject, setSubject] = useState(() => (pack.guide ? PACK_GUIDE : (pack.games.find((g) => g.guide)?.id ?? null)))
|
|
47
|
+
const packGuide = subject === PACK_GUIDE ? pack.guide : undefined
|
|
48
|
+
const game = subject === PACK_GUIDE ? undefined : pack.games.find((g) => g.id === subject)
|
|
49
|
+
const gameGuide = game?.guide
|
|
35
50
|
|
|
36
51
|
const rotation = useRotation()
|
|
37
52
|
const touch = useIsTouchPrimaryDevice()
|
|
38
53
|
const seatTwo = useSelector((state: ShellState) => state.opponent.seatTwo)
|
|
39
|
-
const input = useSelector((state: ShellState) =>
|
|
40
|
-
const seenVersion = useSelector((state: ShellState) =>
|
|
54
|
+
const input = useSelector((state: ShellState) => (gameGuide ? gameGuide.selectInput(state) : NO_INPUT), shallowEqual)
|
|
55
|
+
const seenVersion = useSelector((state: ShellState) => (packGuide ?? gameGuide)?.selectVersionSeen(state) ?? 0)
|
|
41
56
|
const dispatch = useDispatch()
|
|
42
57
|
|
|
43
58
|
// The seat-2 choice the cards describe: the one the player last picked, the CPU in a game that has
|
|
44
59
|
// no room for it (a saved 'nobody' in a game without a solo mode), or nobody in a game for one.
|
|
45
|
-
const mode = roundModeFor(seatTwoFor(seatTwo, game
|
|
46
|
-
const steps = useMemo(() =>
|
|
47
|
-
const onSeen = useCallback(
|
|
60
|
+
const mode = roundModeFor(seatTwoFor(seatTwo, game?.seatTwo))
|
|
61
|
+
const steps = useMemo(() => (packGuide ? packGuide.getSteps({ touch }) : gameGuide ? gameGuide.getSteps(input, { touch, mode }) : []), [packGuide, gameGuide, input, touch, mode])
|
|
62
|
+
const onSeen = useCallback(
|
|
63
|
+
(version: number) => {
|
|
64
|
+
const guide = packGuide ?? gameGuide
|
|
65
|
+
if (guide) dispatch(guide.markSeen(version))
|
|
66
|
+
},
|
|
67
|
+
[dispatch, packGuide, gameGuide]
|
|
68
|
+
)
|
|
48
69
|
|
|
49
70
|
return (
|
|
50
|
-
<GuideProvider steps={steps} currentVersion={
|
|
51
|
-
<GameGuideBridge onPick={
|
|
71
|
+
<GuideProvider steps={steps} currentVersion={(packGuide ?? gameGuide)?.version ?? 0} seenVersion={seenVersion} onSeen={onSeen} rotation={rotation}>
|
|
72
|
+
<GameGuideBridge onPick={setSubject}>{children}</GameGuideBridge>
|
|
52
73
|
</GuideProvider>
|
|
53
74
|
)
|
|
54
75
|
}
|
|
55
76
|
|
|
56
77
|
// Inside GuideProvider, so it can open the dialog and watch it close.
|
|
57
|
-
function GameGuideBridge({ onPick, children }: { onPick: (
|
|
78
|
+
function GameGuideBridge({ onPick, children }: { onPick: (subject: string) => void; children: ReactNode }) {
|
|
58
79
|
const pack = usePack()
|
|
59
80
|
const store = useStore<ShellState>()
|
|
60
81
|
const { isOpen, open } = useGuide()
|
|
61
82
|
const pendingNext = useRef<(() => void) | null>(null)
|
|
62
83
|
const wasOpen = useRef(false)
|
|
84
|
+
// The pack's cards show by themselves once per session at most: Home mounts again whenever the
|
|
85
|
+
// player comes back to it, and skipping shouldn't have to happen twice before the seen version is
|
|
86
|
+
// written back.
|
|
87
|
+
const packAutoShown = useRef(false)
|
|
63
88
|
|
|
64
89
|
// When cards opened by openFirstTime close (finished, skipped or dismissed), carry on to where the
|
|
65
90
|
// tap was going.
|
|
@@ -74,16 +99,17 @@ function GameGuideBridge({ onPick, children }: { onPick: (gameId: string) => voi
|
|
|
74
99
|
|
|
75
100
|
const openFor = useCallback(
|
|
76
101
|
(gameId: string) => {
|
|
102
|
+
if (!pack.games.find((g) => g.id === gameId)?.guide) return
|
|
77
103
|
onPick(gameId)
|
|
78
104
|
open()
|
|
79
105
|
},
|
|
80
|
-
[onPick, open]
|
|
106
|
+
[pack, onPick, open]
|
|
81
107
|
)
|
|
82
108
|
|
|
83
109
|
const openFirstTime = useCallback(
|
|
84
110
|
(gameId: string, next: () => void) => {
|
|
85
|
-
const
|
|
86
|
-
if (!
|
|
111
|
+
const guide = pack.games.find((g) => g.id === gameId)?.guide
|
|
112
|
+
if (!guide || guide.selectVersionSeen(store.getState()) >= guide.version) {
|
|
87
113
|
next()
|
|
88
114
|
return
|
|
89
115
|
}
|
|
@@ -93,7 +119,20 @@ function GameGuideBridge({ onPick, children }: { onPick: (gameId: string) => voi
|
|
|
93
119
|
[pack, store, openFor]
|
|
94
120
|
)
|
|
95
121
|
|
|
96
|
-
const
|
|
122
|
+
const openPack = useCallback(() => {
|
|
123
|
+
if (!pack.guide) return
|
|
124
|
+
onPick(PACK_GUIDE)
|
|
125
|
+
open()
|
|
126
|
+
}, [pack, onPick, open])
|
|
127
|
+
|
|
128
|
+
const autoShowPack = useCallback(() => {
|
|
129
|
+
const guide = pack.guide
|
|
130
|
+
if (!guide || packAutoShown.current || guide.selectVersionSeen(store.getState()) >= guide.version) return
|
|
131
|
+
packAutoShown.current = true
|
|
132
|
+
openPack()
|
|
133
|
+
}, [pack, store, openPack])
|
|
134
|
+
|
|
135
|
+
const value = useMemo(() => ({ openFirstTime, openFor, openPack, autoShowPack }), [openFirstTime, openFor, openPack, autoShowPack])
|
|
97
136
|
return <GameGuideContext.Provider value={value}>{children}</GameGuideContext.Provider>
|
|
98
137
|
}
|
|
99
138
|
|
|
@@ -9,16 +9,29 @@ import { Provider as ReduxProvider, useDispatch, useSelector } from 'react-redux
|
|
|
9
9
|
import type { Persistor } from 'redux-persist'
|
|
10
10
|
import { PersistGate } from 'redux-persist/integration/react'
|
|
11
11
|
|
|
12
|
+
import { usePack } from '../PackContext'
|
|
12
13
|
import type { PackStore } from '../redux/createPackStore'
|
|
13
14
|
import type { ShellState } from '../redux/shellState'
|
|
15
|
+
import type { FeedbackSounds } from '../types'
|
|
14
16
|
|
|
15
17
|
interface Props {
|
|
16
18
|
children: ReactNode
|
|
17
19
|
}
|
|
18
20
|
|
|
21
|
+
// Generic UI feedback sounds, wired into every Button/IconButton/etc. in the package (see
|
|
22
|
+
// useFeedbackHandlers' `fire`, which already gates these on the sound-enabled setting itself -
|
|
23
|
+
// no need to re-check it here).
|
|
24
|
+
function useShellFeedbackSounds(): FeedbackSounds {
|
|
25
|
+
const selection = useAudioPool(require('../../assets/sounds/select.wav'))
|
|
26
|
+
const notification = useAudioPool(require('../../assets/sounds/notification.wav'))
|
|
27
|
+
return { selection, notification }
|
|
28
|
+
}
|
|
29
|
+
|
|
19
30
|
// Redux-backed haptic/sound settings, dispatching through @rific/feedback-press's own
|
|
20
31
|
// hapticActions/soundActions - same shape as Expo-Starter/Snake's FeedbackBridge, converged onto
|
|
21
|
-
// fleet-wide rather than this app's old direct-AsyncStorage version.
|
|
32
|
+
// fleet-wide rather than this app's old direct-AsyncStorage version. The two sounds are the pack's own
|
|
33
|
+
// where it has them (PackDefinition.useFeedbackSounds: Solitaire's card sound and invalid-move sound);
|
|
34
|
+
// the pack never changes while it runs, so the same hook is called on every render.
|
|
22
35
|
const FeedbackBridge = ({ children }: Props) => {
|
|
23
36
|
const haptic = useSelector((state: ShellState) => state.haptic)
|
|
24
37
|
const sound = useSelector((state: ShellState) => state.sound)
|
|
@@ -26,13 +39,10 @@ const FeedbackBridge = ({ children }: Props) => {
|
|
|
26
39
|
const onChange = useCallback((next: HapticSettings) => dispatch(hapticActions.initialize(next)), [dispatch])
|
|
27
40
|
const onSoundChange = useCallback((next: SoundSettings) => dispatch(soundActions.initialize(next)), [dispatch])
|
|
28
41
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
// no need to re-check it here).
|
|
32
|
-
const playSelection = useAudioPool(require('../../assets/sounds/select.wav'))
|
|
33
|
-
const playNotification = useAudioPool(require('../../assets/sounds/notification.wav'))
|
|
42
|
+
const { useFeedbackSounds = useShellFeedbackSounds } = usePack()
|
|
43
|
+
const sounds = useFeedbackSounds()
|
|
34
44
|
|
|
35
|
-
const bridgeProps = useFeedbackBridgeProps({ initialValue: haptic, onChange, soundInitialValue: sound, onSoundChange, sound:
|
|
45
|
+
const bridgeProps = useFeedbackBridgeProps({ initialValue: haptic, onChange, soundInitialValue: sound, onSoundChange, sound: sounds })
|
|
36
46
|
|
|
37
47
|
return (
|
|
38
48
|
<FeedbackPressProvider {...bridgeProps} paper={RNPaper}>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { SeedColor } from '@rific/auto-paper'
|
|
2
|
-
import
|
|
2
|
+
import type { PopoverHost } from '@tastic/hud/chrome'
|
|
3
|
+
import { type MenuOption, ReadyButton, useZoneClampedAlign } from '@tastic/hud/lobby'
|
|
3
4
|
import { getOptionPickerContentSize, OptionPicker, type OptionPickerOption, type Profile, type ProfilePickerColorSection } from '@tastic/profile'
|
|
4
5
|
import { ReactNode, useMemo } from 'react'
|
|
5
6
|
import { StyleSheet, View } from 'react-native'
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useUpdateErrorToast } from '@rific/toaster'
|
|
2
2
|
import { ViewRotation } from '@tastic/core'
|
|
3
|
-
import { BaseSettingsDialog } from '@tastic/hud'
|
|
3
|
+
import { BaseSettingsDialog } from '@tastic/hud/chrome'
|
|
4
4
|
import type { ReactNode } from 'react'
|
|
5
5
|
|
|
6
6
|
import { useAppSettings } from '../hooks/useAppSettings'
|
|
@@ -26,15 +26,17 @@ export interface SettingsDialogProps {
|
|
|
26
26
|
// The pack-wide Settings dialog: everything the shell puts in it applies to every game (Lock
|
|
27
27
|
// Orientation, Edge Guard, theme, sound, haptics, Keep Awake), so it reads and writes the app
|
|
28
28
|
// settings itself. Under those, a play screen's own rows and then the pack's own sections
|
|
29
|
-
// (PackDefinition.SettingsSections), in every Settings dialog the pack opens.
|
|
29
|
+
// (PackDefinition.SettingsSections), in every Settings dialog the pack opens. A pack with edgeGuard
|
|
30
|
+
// false gets no Edge Guard row: hud draws it on iOS whenever its props are passed.
|
|
30
31
|
export function SettingsDialog({ visible, onDismiss, rotation = 0, onShowHowToPlay, children }: SettingsDialogProps) {
|
|
31
32
|
const onUpdateError = useUpdateErrorToast()
|
|
32
33
|
const { settings, setSettings } = useAppSettings()
|
|
33
34
|
// The pack's otaVersion (src/constants/release.ts, which the OTA tooling bumps).
|
|
34
|
-
const { otaVersion, SettingsSections } = usePack()
|
|
35
|
+
const { otaVersion, SettingsSections, edgeGuard = true } = usePack()
|
|
36
|
+
const edgeGuardProps = edgeGuard ? { deferBottomEdgeGestures: settings.deferBottomEdgeGestures, onDeferBottomEdgeGestures: (value: boolean) => setSettings({ deferBottomEdgeGestures: value }) } : {}
|
|
35
37
|
|
|
36
38
|
return (
|
|
37
|
-
<BaseSettingsDialog visible={visible} onDismiss={onDismiss} rotation={rotation} version={otaVersion} lockOrientation={settings.lockOrientation} onLockOrientationChange={(value) => setSettings({ lockOrientation: value })}
|
|
39
|
+
<BaseSettingsDialog visible={visible} onDismiss={onDismiss} rotation={rotation} version={otaVersion} lockOrientation={settings.lockOrientation} onLockOrientationChange={(value) => setSettings({ lockOrientation: value })} {...edgeGuardProps} onUpdateError={onUpdateError} onShowHowToPlay={onShowHowToPlay}>
|
|
38
40
|
{children}
|
|
39
41
|
{SettingsSections && <SettingsSections />}
|
|
40
42
|
</BaseSettingsDialog>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useUpdateErrorToast } from '@rific/toaster'
|
|
2
2
|
import { useRotation } from '@tastic/core'
|
|
3
|
-
import { UpdateDialog as SharedUpdateDialog } from '@tastic/hud'
|
|
3
|
+
import { UpdateDialog as SharedUpdateDialog } from '@tastic/hud/chrome'
|
|
4
4
|
|
|
5
5
|
// Thin bridge onto the fleet-shared @tastic/hud UpdateDialog, which now owns the useUpdater()
|
|
6
6
|
// instance, the manifest/confirm bridging, and the themed ConfirmDialog rendering this file used
|