@tastic/shell 0.0.0-stage → 0.1.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.
Files changed (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +154 -2
  3. package/assets/sounds/notification.wav +0 -0
  4. package/assets/sounds/select.wav +0 -0
  5. package/dist/index.d.mts +487 -0
  6. package/dist/index.d.ts +487 -0
  7. package/dist/index.js +1636 -0
  8. package/dist/index.mjs +1562 -0
  9. package/dist/notification-CGGFPYK7.wav +0 -0
  10. package/dist/select-MLBNNA4N.wav +0 -0
  11. package/package.json +163 -4
  12. package/src/PackContext.tsx +22 -0
  13. package/src/RootLayout.tsx +186 -0
  14. package/src/components/Fonts.tsx +41 -0
  15. package/src/components/GuideHost.tsx +104 -0
  16. package/src/components/Providers.tsx +86 -0
  17. package/src/components/SeatTwoPanel.tsx +132 -0
  18. package/src/components/SettingsDialog.tsx +42 -0
  19. package/src/components/Theme.tsx +75 -0
  20. package/src/components/UpdateDialog.tsx +13 -0
  21. package/src/components/imageGameIcon.tsx +13 -0
  22. package/src/gameStats.tsx +90 -0
  23. package/src/hooks/useActiveRound.ts +16 -0
  24. package/src/hooks/useAppSettings.ts +19 -0
  25. package/src/hooks/useFrozenWhileHidden.ts +33 -0
  26. package/src/hooks/useLastActiveGame.tsx +73 -0
  27. package/src/hooks/useOpponent.ts +14 -0
  28. package/src/hooks/useProfiles.tsx +134 -0
  29. package/src/hooks/useSeatColors.ts +148 -0
  30. package/src/index.ts +39 -0
  31. package/src/navigation.ts +11 -0
  32. package/src/playerSettings.ts +112 -0
  33. package/src/redux/appSettingsSlice.ts +34 -0
  34. package/src/redux/createPackStore.ts +78 -0
  35. package/src/redux/gameGuideSlice.ts +39 -0
  36. package/src/redux/lastActiveGameSlice.ts +12 -0
  37. package/src/redux/liveplaySlice.ts +34 -0
  38. package/src/redux/onProfileRemoved.ts +25 -0
  39. package/src/redux/opponentSlice.ts +21 -0
  40. package/src/redux/profileSelectionSlice.ts +17 -0
  41. package/src/redux/seatColorsSlice.ts +24 -0
  42. package/src/redux/shellState.ts +59 -0
  43. package/src/screens/GameRoute.tsx +48 -0
  44. package/src/screens/HomeScreen.tsx +378 -0
  45. package/src/screens/LoadoutScreen.tsx +271 -0
  46. package/src/screens/ProfilesRoute.tsx +14 -0
  47. package/src/screens/StatsScreen.tsx +133 -0
  48. package/src/screens/homeLayout.ts +129 -0
  49. package/src/splashGate.ts +3 -0
  50. package/src/types.ts +216 -0
Binary file
Binary file
package/package.json CHANGED
@@ -1,6 +1,165 @@
1
1
  {
2
2
  "name": "@tastic/shell",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
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
+ "keywords": [
6
+ "react-native",
7
+ "expo",
8
+ "expo-router",
9
+ "local-multiplayer",
10
+ "couch-multiplayer",
11
+ "game-pack",
12
+ "redux"
13
+ ],
14
+ "homepage": "https://github.com/jayrdeaton/react-native-game-shell#readme",
15
+ "bugs": {
16
+ "url": "https://github.com/jayrdeaton/react-native-game-shell/issues"
17
+ },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/jayrdeaton/react-native-game-shell.git"
21
+ },
22
+ "license": "MIT",
23
+ "author": "Jay Deaton",
24
+ "sideEffects": [
25
+ "./src/navigation.ts",
26
+ "./src/components/Theme.tsx",
27
+ "./dist/*"
28
+ ],
29
+ "type": "commonjs",
30
+ "exports": {
31
+ ".": {
32
+ "react-native": "./src/index.ts",
33
+ "browser": "./src/index.ts",
34
+ "types": "./dist/index.d.ts",
35
+ "import": "./dist/index.mjs",
36
+ "require": "./dist/index.js"
37
+ }
38
+ },
39
+ "main": "dist/index.js",
40
+ "module": "dist/index.mjs",
41
+ "react-native": "src/index.ts",
42
+ "types": "dist/index.d.ts",
43
+ "files": [
44
+ "assets",
45
+ "dist",
46
+ "src",
47
+ "!src/__tests__",
48
+ "!src/__mocks__",
49
+ "!src/testUtils"
50
+ ],
51
+ "scripts": {
52
+ "build": "tsup",
53
+ "build:watch": "tsup --watch",
54
+ "fix": "eslint --fix",
55
+ "lint": "eslint",
56
+ "prepublishOnly": "npm run build",
57
+ "release": "git push --follow-tags",
58
+ "release:major": "npm version major && npm run release",
59
+ "release:minor": "npm version minor && npm run release",
60
+ "release:patch": "npm version patch && npm run release",
61
+ "test": "jest",
62
+ "test:watch": "jest --watchAll",
63
+ "typecheck": "tsc --noEmit",
64
+ "verify": "npm run lint && npm test && npm run typecheck && npm run build",
65
+ "preversion": "npm run verify"
66
+ },
67
+ "prettier": "@infinitetoken/eslint-config/prettier",
68
+ "devDependencies": {
69
+ "@babel/core": "^7.29.7",
70
+ "@expo/vector-icons": "^15.1.1",
71
+ "@infinitetoken/eslint-config": "^0.2.1",
72
+ "@infinitetoken/jest-config": "^0.5.2",
73
+ "@infinitetoken/tsconfig": "^0.6.0",
74
+ "@react-native-async-storage/async-storage": "2.2.0",
75
+ "@react-native-masked-view/masked-view": "0.3.2",
76
+ "@react-native/jest-preset": "^0.88.0",
77
+ "@reduxjs/toolkit": "^2.13.0",
78
+ "@rific/auto-paper": "^0.13.4",
79
+ "@rific/core": "^0.5.0",
80
+ "@rific/feedback-press": "^0.13.1",
81
+ "@rific/focus-chain": "^0.4.8",
82
+ "@rific/gestures": "^0.1.3",
83
+ "@rific/splash-gate": "^0.2.3",
84
+ "@rific/toaster": "^0.12.0",
85
+ "@rific/updater": "^0.8.0",
86
+ "@tastic/achievements": "^0.6.0",
87
+ "@tastic/core": "^0.14.0",
88
+ "@tastic/edge-guard": "^0.1.5",
89
+ "@tastic/hud": "^0.25.1",
90
+ "@tastic/profile": "^0.17.0",
91
+ "@tastic/split-screen": "^0.5.5",
92
+ "@testing-library/react-native": "^14.1.0",
93
+ "@types/jest": "30.0.0",
94
+ "@types/react": "~19.3.0",
95
+ "eslint": "^9.39.5",
96
+ "eslint-plugin-react-hooks": "^7.1.1",
97
+ "eslint-plugin-react-native": "^5.0.0",
98
+ "expo": "~58.0.19",
99
+ "expo-audio": "~58.0.10",
100
+ "expo-blur": "~58.0.5",
101
+ "expo-constants": "~58.0.13",
102
+ "expo-font": "~58.0.10",
103
+ "expo-haptics": "~58.0.7",
104
+ "expo-keep-awake": "~58.0.3",
105
+ "expo-router": "~58.0.26",
106
+ "expo-sensors": "~58.0.4",
107
+ "expo-splash-screen": "~58.0.11",
108
+ "expo-system-ui": "~58.0.6",
109
+ "expo-updates": "~58.0.18",
110
+ "jest": "~30.5.2",
111
+ "jest-expo": "~58.0.12",
112
+ "prettier": "^3.8.3",
113
+ "react": "19.3.0",
114
+ "react-native": "0.88.0",
115
+ "react-native-gesture-handler": "~3.3.0",
116
+ "react-native-paper": "^5.15.3",
117
+ "react-native-reanimated": "4.7.1",
118
+ "react-native-safe-area-context": "~5.10.1",
119
+ "react-native-screens": "~4.30.0",
120
+ "react-native-worklets": "0.13.0",
121
+ "react-redux": "^9.3.0",
122
+ "redux": "^5.0.1",
123
+ "redux-persist": "^6.0.0",
124
+ "test-renderer": "^1.3.1",
125
+ "tsup": "^8.0.0",
126
+ "typescript": "~6.0.3"
127
+ },
128
+ "peerDependencies": {
129
+ "@expo/vector-icons": ">=15.1.1",
130
+ "@react-native-async-storage/async-storage": ">=2.2.0",
131
+ "@react-native-masked-view/masked-view": ">=0.3.2",
132
+ "@reduxjs/toolkit": ">=2.13.0",
133
+ "@rific/auto-paper": ">=0.13.4",
134
+ "@rific/core": ">=0.5.0",
135
+ "@rific/feedback-press": ">=0.13.1",
136
+ "@rific/splash-gate": ">=0.2.3",
137
+ "@rific/toaster": ">=0.10.1",
138
+ "@tastic/achievements": ">=0.6.0",
139
+ "@tastic/core": ">=0.14.0",
140
+ "@tastic/edge-guard": ">=0.1.5",
141
+ "@tastic/hud": ">=0.25.1",
142
+ "@tastic/profile": ">=0.17.0",
143
+ "@tastic/split-screen": ">=0.5.5",
144
+ "expo-blur": ">=58.0.5",
145
+ "expo-constants": ">=58.0.13",
146
+ "expo-font": ">=58.0.10",
147
+ "expo-haptics": ">=58.0.7",
148
+ "expo-keep-awake": ">=58.0.3",
149
+ "expo-router": ">=58.0.26",
150
+ "expo-sensors": ">=58.0.4",
151
+ "expo-splash-screen": ">=58.0.11",
152
+ "react": ">=19.3.0",
153
+ "react-native": ">=0.88.0",
154
+ "react-native-gesture-handler": ">=3.3.0",
155
+ "react-native-paper": ">=5.15.3",
156
+ "react-native-reanimated": ">=4.7.1",
157
+ "react-native-safe-area-context": ">=5.10.1",
158
+ "react-redux": ">=9.3.0",
159
+ "redux": ">=5.0.1",
160
+ "redux-persist": ">=6.0.0"
161
+ },
162
+ "publishConfig": {
163
+ "access": "public"
164
+ }
165
+ }
@@ -0,0 +1,22 @@
1
+ import { createContext, ReactNode, useContext } from 'react'
2
+
3
+ import type { GameDefinition, PackDefinition } from './types'
4
+
5
+ // The pack the shell is running: handed in once at the root (see RootLayout), so nothing in shell/
6
+ // ever imports pack.ts or a game. Games reach the shell only through this registry.
7
+ const PackContext = createContext<PackDefinition | null>(null)
8
+
9
+ export function PackProvider({ pack, children }: { pack: PackDefinition; children: ReactNode }) {
10
+ return <PackContext.Provider value={pack}>{children}</PackContext.Provider>
11
+ }
12
+
13
+ export function usePack(): PackDefinition {
14
+ const pack = useContext(PackContext)
15
+ if (!pack) throw new Error('usePack must be used within a PackProvider')
16
+ return pack
17
+ }
18
+
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): GameDefinition | undefined {
21
+ return usePack().games.find((g) => g.id === id)
22
+ }
@@ -0,0 +1,186 @@
1
+ import { useAutoPaperTheme } from '@rific/auto-paper'
2
+ import { useHapticSettings } from '@rific/feedback-press'
3
+ import { Toaster, ToastProvider } from '@rific/toaster'
4
+ import { OrientationProvider, useRotationAwareStatusBar, useThemedRootBackground } from '@tastic/core'
5
+ import { RotatedFrame } from '@tastic/hud'
6
+ import * as Haptics from 'expo-haptics'
7
+ import { Stack } from 'expo-router'
8
+ import { DeviceMotion } from 'expo-sensors'
9
+ import { ReactNode, useCallback } from 'react'
10
+ import { StyleSheet } from 'react-native'
11
+ import { GestureHandlerRootView } from 'react-native-gesture-handler'
12
+ import * as RNPaper from 'react-native-paper'
13
+ import { SafeAreaProvider } from 'react-native-safe-area-context'
14
+ import type { Persistor } from 'redux-persist'
15
+
16
+ import { Fonts } from './components/Fonts'
17
+ import { GuideHost } from './components/GuideHost'
18
+ import { Providers } from './components/Providers'
19
+ import { Theme } from './components/Theme'
20
+ import { UpdateDialog } from './components/UpdateDialog'
21
+ import { useAppSettings } from './hooks/useAppSettings'
22
+ import { LastActiveGameProvider } from './hooks/useLastActiveGame'
23
+ import { ProfilesProvider, useProfiles } from './hooks/useProfiles'
24
+ import { PackProvider } from './PackContext'
25
+ import type { PackStore } from './redux/createPackStore'
26
+ import { SplashGate } from './splashGate'
27
+ import type { GameDefinition, PackDefinition } from './types'
28
+
29
+ // The pack's root: every provider the shell needs, the pack's games' own providers, and the stack.
30
+ // src/app/_layout.tsx hands it the pack (pack.ts) and its store, so nothing in shell/ imports a
31
+ // game or the pack itself.
32
+
33
+ // Sits below Theme so it can read the live theme. useThemedRootBackground (@tastic/core) keeps both
34
+ // the screens' contentStyle and the OS root window's own background on the live theme, which is what
35
+ // stops white flashing through the display's rounded corners during a push/pop transition. Passing
36
+ // false disables iOS's swipe-back gesture: otherwise it competes with the game's own pan gestures on
37
+ // a play screen and wins (confirmed on-device in LightCycles, where it kicked the player back to the title
38
+ // screen mid-round instead of turning their cycle). Web has no such OS-level gesture.
39
+ //
40
+ // useRotationAwareStatusBar (@tastic/core) hides the OS status bar whenever the content is rotated
41
+ // off portrait. It does it through screen options because that is the only way left: an app linked
42
+ // against the iOS 27 SDK can't set the status bar app-wide, which is all React Native's StatusBar
43
+ // (and expo-status-bar) does on iOS, and with @tastic/core's config plugin on, every mounted
44
+ // StatusBar logs an error. So nothing in the app mounts one (Theme passes auto-paper's Provider
45
+ // statusBar={false}). No `locked` argument: OrientationBridge already freezes the reading it
46
+ // follows. A play screen hides the bar for the whole round, whatever the angle (a screen's own
47
+ // options win over screenOptions); index is listed first so it stays the stack's first route.
48
+ const PLAY_OPTIONS = { statusBarHidden: true }
49
+
50
+ function AppStack() {
51
+ const { dark } = useAutoPaperTheme()
52
+ const rootBackground = useThemedRootBackground(dark, false)
53
+ const statusBar = useRotationAwareStatusBar()
54
+ return (
55
+ <Stack screenOptions={{ ...rootBackground, ...statusBar }}>
56
+ <Stack.Screen name='index' />
57
+ <Stack.Screen name='play/[game]' options={PLAY_OPTIONS} />
58
+ </Stack>
59
+ )
60
+ }
61
+
62
+ // Wires the persisted Lock Orientation setting into @tastic/core's OrientationProvider. Its own
63
+ // component because RootLayout renders the Redux store (inside Providers) and so can't call
64
+ // useAppSettings() itself. Providers' PersistGate holds rendering until the store has rehydrated, so
65
+ // lockInitialValue carries the saved lock from the very first render.
66
+ //
67
+ // freezeWhileLocked makes the lock app-wide: while appSettings.lockOrientation is on, the ONE shared
68
+ // orientation reading freezes for every consumer (every screen, every board, hud dialogs, the
69
+ // status bar), including anything that mounts after the lock, so no call site ever takes a `locked`
70
+ // argument of its own (one would freeze on the pre-sensor default at mount). The Settings dialog
71
+ // toggle only writes appSettings.lockOrientation, which reaches the provider as a changed
72
+ // lockInitialValue; the provider reports the angle it froze on through onLockChange (`rotation`,
73
+ // cleared on unlock), persisted as appSettings.lockedRotation and handed back here so a relaunch reopens
74
+ // in the locked orientation instead of portrait.
75
+ //
76
+ // Mounted once, here, rather than per screen: one app-lifetime sensor subscription is what lets the
77
+ // committed orientation survive navigation. deviceMotion is the app's own expo-sensors import, handed
78
+ // in because @tastic/core never imports expo-sensors itself (apps without tilt tracking would
79
+ // otherwise need it installed just to bundle).
80
+ function OrientationBridge({ children }: { children: ReactNode }) {
81
+ const { settings, setSettings } = useAppSettings()
82
+ return (
83
+ <OrientationProvider deviceMotion={DeviceMotion} freezeWhileLocked lockInitialValue={{ locked: settings.lockOrientation, rotation: settings.lockedRotation }} onLockChange={(next) => setSettings({ lockOrientation: next.locked, lockedRotation: next.rotation })}>
84
+ {children}
85
+ </OrientationProvider>
86
+ )
87
+ }
88
+
89
+ // ProfilesProvider reconciles the local roster with the shared App Group one before `loaded` flips,
90
+ // so a screen mounting earlier would read (and could persist) the wrong profiles. This holds
91
+ // everything nested inside it, and the splash screen, until that's done, while ProfilesProvider sits
92
+ // above it and starts reconciling immediately. Settings and stats need no gate: they live in the
93
+ // Redux store, which Providers' PersistGate already holds until rehydrated.
94
+ function GatedApp({ children }: { children: ReactNode }) {
95
+ const { loaded } = useProfiles()
96
+ return (
97
+ <SplashGate gate='profiles' ready={loaded}>
98
+ {children}
99
+ </SplashGate>
100
+ )
101
+ }
102
+
103
+ // The toaster only gets expo-haptics while the player's Vibrate setting is on, so its haptic ticks
104
+ // follow the same setting as every other haptic in the app. Today they can't fire anyway (the only
105
+ // controls that tick, the history and clear buttons, are off below), but this keeps it that way if
106
+ // either is ever shown. Must render inside Providers, whose FeedbackBridge provides the setting.
107
+ function HapticGatedToastProvider({ children }: { children: ReactNode }) {
108
+ const { settings } = useHapticSettings()
109
+ return (
110
+ <ToastProvider haptics={settings.vibrate ? Haptics : undefined} paper={RNPaper}>
111
+ {children}
112
+ </ToastProvider>
113
+ )
114
+ }
115
+
116
+ function RotatingToaster() {
117
+ const renderFrame = useCallback(
118
+ (toasts: ReactNode) => (
119
+ <RotatedFrame rotatedSafeArea passThrough>
120
+ {toasts}
121
+ </RotatedFrame>
122
+ ),
123
+ []
124
+ )
125
+ return <Toaster historyButton={null} clearButton={null} limit={3} renderFrame={renderFrame} />
126
+ }
127
+
128
+ // Every game's own root provider (its stats and achievements context), nested in pack order.
129
+ function GameProviders({ games, children }: { games: readonly GameDefinition[]; children: ReactNode }) {
130
+ return games.reduceRight<ReactNode>((inner, game) => <game.Provider key={game.id}>{inner}</game.Provider>, children)
131
+ }
132
+
133
+ interface RootLayoutProps {
134
+ pack: PackDefinition
135
+ store: PackStore
136
+ persistor: Persistor
137
+ }
138
+
139
+ export function RootLayout({ pack, store, persistor }: RootLayoutProps) {
140
+ return (
141
+ <GestureHandlerRootView style={styles.root}>
142
+ <SafeAreaProvider>
143
+ <PackProvider pack={pack}>
144
+ <Fonts>
145
+ <Providers store={store} persistor={persistor}>
146
+ <Theme>
147
+ <ProfilesProvider>
148
+ {/* Inside Providers' PersistGate, so the game a cold launch resumes (read once, as
149
+ it mounts) is the saved one. */}
150
+ <LastActiveGameProvider>
151
+ <GameProviders games={pack.games}>
152
+ <GatedApp>
153
+ <OrientationBridge>
154
+ <HapticGatedToastProvider>
155
+ {/* Wraps the stack (rather than sitting beside it like UpdateDialog)
156
+ because Home's tiles and the loadout's How to Play row reach it through
157
+ context (see GuideHost). Inside the toast provider and OrientationBridge
158
+ because the card renders through a Portal and rotates with the shared
159
+ reading, and inside the gate so it mounts together with Home rather than
160
+ before it. */}
161
+ <GuideHost>
162
+ <AppStack />
163
+ </GuideHost>
164
+ {/* historyButton/clearButton off: there's no toast history to open, and a
165
+ control row for it above the toast stack would go nowhere. limit pins the
166
+ package's current default (3) so a future toaster release can't change it. */}
167
+ <RotatingToaster />
168
+ <UpdateDialog />
169
+ </HapticGatedToastProvider>
170
+ </OrientationBridge>
171
+ </GatedApp>
172
+ </GameProviders>
173
+ </LastActiveGameProvider>
174
+ </ProfilesProvider>
175
+ </Theme>
176
+ </Providers>
177
+ </Fonts>
178
+ </PackProvider>
179
+ </SafeAreaProvider>
180
+ </GestureHandlerRootView>
181
+ )
182
+ }
183
+
184
+ const styles = StyleSheet.create({
185
+ root: { flex: 1 }
186
+ })
@@ -0,0 +1,41 @@
1
+ import { MaterialCommunityIcons } from '@expo/vector-icons'
2
+ import Constants from 'expo-constants'
3
+ import { useFonts } from 'expo-font'
4
+ import Head from 'expo-router/head'
5
+ import { ReactNode } from 'react'
6
+ import { Platform } from 'react-native'
7
+
8
+ import { usePack } from '../PackContext'
9
+ import { SplashGate } from '../splashGate'
10
+
11
+ interface Props {
12
+ children: ReactNode
13
+ }
14
+
15
+ // Holds the splash screen up (see splashGate.ts's 'fonts' gate) until the MaterialCommunityIcons
16
+ // glyph font and the pack's own fonts (PackDefinition.fonts, its hero's display face) have actually
17
+ // loaded - otherwise every react-native-paper Icon/IconButton (and @rific/feedback-press's
18
+ // IconButton, which wraps paper's under the hood) would render blank, and a hero would render in
19
+ // its fallback font, for one visible frame before snapping in once the async load resolves.
20
+ // Gated via the Gate component (SplashGate, per splashGate.ts's own alias) rather than a
21
+ // hand-rolled `if (!loaded) return null` - marking the splash gate ready and holding `children`
22
+ // back until then happen in one place this way, same as RootLayout's 'profiles' gate.
23
+ export function Fonts({ children }: Props) {
24
+ const pack = usePack()
25
+ // A failed load counts as ready too: `loaded` stays false forever after an error, which would hold
26
+ // the splash up for good, so the app opens on the system fallback font instead.
27
+ const [loaded, error] = useFonts({ ...pack.fonts, ...MaterialCommunityIcons.font })
28
+ return (
29
+ <>
30
+ {/* The web tab title, outside the fonts gate. Since SDK 58 Head only sets it after hydration; the static export's HTML no longer carries it (see TODO.md). Web only: on iOS, expo-router's Head is Handoff/Spotlight metadata that needs the plugin's `origin` set, and without it every route change throws (a dev build) or shows a system alert (a production build) */}
31
+ {Platform.OS === 'web' && (
32
+ <Head>
33
+ <title>{Constants.expoConfig?.name}</title>
34
+ </Head>
35
+ )}
36
+ <SplashGate gate='fonts' ready={loaded || !!error}>
37
+ {children}
38
+ </SplashGate>
39
+ </>
40
+ )
41
+ }
@@ -0,0 +1,104 @@
1
+ import { useIsTouchPrimaryDevice, useRotation } from '@tastic/core'
2
+ import { GuideProvider, useGuide } from '@tastic/hud/guide'
3
+ import { createContext, ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
4
+ import { shallowEqual, useDispatch, useSelector, useStore } from 'react-redux'
5
+
6
+ import { usePack } from '../PackContext'
7
+ import type { ShellState } from '../redux/shellState'
8
+ import { roundModeFor, seatTwoFor } from '../types'
9
+
10
+ interface GameGuideContextValue {
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. Home calls it on a tile tap, so
13
+ // a game's cards show the first time it's picked, before its loadout opens.
14
+ 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
+ openFor: (gameId: string) => void
17
+ }
18
+
19
+ const GameGuideContext = createContext<GameGuideContextValue | null>(null)
20
+
21
+ interface Props {
22
+ children: ReactNode
23
+ }
24
+
25
+ // One @tastic/hud GuideProvider for the whole pack, showing whichever game's cards were asked for
26
+ // last. Each game's cards come from its own registry entry (GameDefinition.guide), built from the
27
+ // live settings and seat 2 so a replay after any loadout change describes the next match. Mounted
28
+ // around the stack in RootLayout, inside the toast provider and OrientationBridge because the card
29
+ // renders through a Portal and turns with the shared reading (`rotation`, the same one Home hands
30
+ // its Settings dialog), and inside the profiles gate so it mounts together with Home.
31
+ export function GuideHost({ children }: Props) {
32
+ const pack = usePack()
33
+ const [gameId, setGameId] = useState(pack.games[0].id)
34
+ const game = pack.games.find((g) => g.id === gameId) ?? pack.games[0]
35
+
36
+ const rotation = useRotation()
37
+ const touch = useIsTouchPrimaryDevice()
38
+ const seatTwo = useSelector((state: ShellState) => state.opponent.seatTwo)
39
+ const input = useSelector((state: ShellState) => game.guide.selectInput(state), shallowEqual)
40
+ const seenVersion = useSelector((state: ShellState) => game.guide.selectVersionSeen(state))
41
+ const dispatch = useDispatch()
42
+
43
+ // The seat-2 choice the cards describe: the one the player last picked, the CPU in a game that has
44
+ // 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.seatTwo))
46
+ const steps = useMemo(() => game.guide.getSteps(input, { touch, mode }), [game, input, touch, mode])
47
+ const onSeen = useCallback((version: number) => dispatch(game.guide.markSeen(version)), [dispatch, game])
48
+
49
+ return (
50
+ <GuideProvider steps={steps} currentVersion={game.guide.version} seenVersion={seenVersion} onSeen={onSeen} rotation={rotation}>
51
+ <GameGuideBridge onPick={setGameId}>{children}</GameGuideBridge>
52
+ </GuideProvider>
53
+ )
54
+ }
55
+
56
+ // Inside GuideProvider, so it can open the dialog and watch it close.
57
+ function GameGuideBridge({ onPick, children }: { onPick: (gameId: string) => void; children: ReactNode }) {
58
+ const pack = usePack()
59
+ const store = useStore<ShellState>()
60
+ const { isOpen, open } = useGuide()
61
+ const pendingNext = useRef<(() => void) | null>(null)
62
+ const wasOpen = useRef(false)
63
+
64
+ // When cards opened by openFirstTime close (finished, skipped or dismissed), carry on to where the
65
+ // tap was going.
66
+ useEffect(() => {
67
+ if (wasOpen.current && !isOpen && pendingNext.current) {
68
+ const next = pendingNext.current
69
+ pendingNext.current = null
70
+ next()
71
+ }
72
+ wasOpen.current = isOpen
73
+ }, [isOpen])
74
+
75
+ const openFor = useCallback(
76
+ (gameId: string) => {
77
+ onPick(gameId)
78
+ open()
79
+ },
80
+ [onPick, open]
81
+ )
82
+
83
+ const openFirstTime = useCallback(
84
+ (gameId: string, next: () => void) => {
85
+ const game = pack.games.find((g) => g.id === gameId)
86
+ if (!game || game.guide.selectVersionSeen(store.getState()) >= game.guide.version) {
87
+ next()
88
+ return
89
+ }
90
+ pendingNext.current = next
91
+ openFor(gameId)
92
+ },
93
+ [pack, store, openFor]
94
+ )
95
+
96
+ const value = useMemo(() => ({ openFirstTime, openFor }), [openFirstTime, openFor])
97
+ return <GameGuideContext.Provider value={value}>{children}</GameGuideContext.Provider>
98
+ }
99
+
100
+ export function useGameGuide(): GameGuideContextValue {
101
+ const ctx = useContext(GameGuideContext)
102
+ if (!ctx) throw new Error('useGameGuide must be used within a GuideHost')
103
+ return ctx
104
+ }
@@ -0,0 +1,86 @@
1
+ import { keepAwakeActions, KeepAwakeProvider, type KeepAwakeSettings } from '@rific/core'
2
+ import { FeedbackPressProvider, hapticActions, type HapticSettings, soundActions, type SoundSettings, useFeedbackBridgeProps } from '@rific/feedback-press'
3
+ import { useAudioPool } from '@rific/feedback-press/audio'
4
+ import { useEdgeGestureGuard } from '@tastic/edge-guard'
5
+ import * as ExpoKeepAwake from 'expo-keep-awake'
6
+ import { ReactNode, useCallback } from 'react'
7
+ import * as RNPaper from 'react-native-paper'
8
+ import { Provider as ReduxProvider, useDispatch, useSelector } from 'react-redux'
9
+ import type { Persistor } from 'redux-persist'
10
+ import { PersistGate } from 'redux-persist/integration/react'
11
+
12
+ import type { PackStore } from '../redux/createPackStore'
13
+ import type { ShellState } from '../redux/shellState'
14
+
15
+ interface Props {
16
+ children: ReactNode
17
+ }
18
+
19
+ // Redux-backed haptic/sound settings, dispatching through @rific/feedback-press's own
20
+ // hapticActions/soundActions - same shape as Expo-Starter/Snake's FeedbackBridge, converged onto
21
+ // fleet-wide rather than this app's old direct-AsyncStorage version.
22
+ const FeedbackBridge = ({ children }: Props) => {
23
+ const haptic = useSelector((state: ShellState) => state.haptic)
24
+ const sound = useSelector((state: ShellState) => state.sound)
25
+ const dispatch = useDispatch()
26
+ const onChange = useCallback((next: HapticSettings) => dispatch(hapticActions.initialize(next)), [dispatch])
27
+ const onSoundChange = useCallback((next: SoundSettings) => dispatch(soundActions.initialize(next)), [dispatch])
28
+
29
+ // Generic UI feedback sounds, wired into every Button/IconButton/etc. in the package (see
30
+ // useFeedbackHandlers' `fire`, which already gates these on the sound-enabled setting itself -
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'))
34
+
35
+ const bridgeProps = useFeedbackBridgeProps({ initialValue: haptic, onChange, soundInitialValue: sound, onSoundChange, sound: { selection: playSelection, notification: playNotification } })
36
+
37
+ return (
38
+ <FeedbackPressProvider {...bridgeProps} paper={RNPaper}>
39
+ {children}
40
+ </FeedbackPressProvider>
41
+ )
42
+ }
43
+
44
+ // The one place @tastic/edge-guard's hook is called, mounted once for the app's lifetime rather than
45
+ // per screen. appSettings.deferBottomEdgeGestures is the player's opt-in; liveplay.activelyPlaying is
46
+ // a play screen's report that a live round is in progress right now (see liveplaySlice.ts). Combining them
47
+ // here keeps Edge Guard both opt-in and scoped to actual play.
48
+ const EdgeGuardBridge = ({ children }: Props) => {
49
+ const deferBottomEdgeGestures = useSelector((state: ShellState) => state.appSettings.deferBottomEdgeGestures)
50
+ const activelyPlaying = useSelector((state: ShellState) => state.liveplay.activelyPlaying)
51
+ useEdgeGestureGuard(deferBottomEdgeGestures && activelyPlaying)
52
+ return <>{children}</>
53
+ }
54
+
55
+ // @rific/core's Keep Awake setting (Settings dialog), persisted as the store's `keepAwake` slice.
56
+ // Handing it expo-keep-awake is what turns the feature on: each play screen's useKeepAwake('game') and hud's
57
+ // Keep Awake row both read the module from here.
58
+ const KeepAwakeBridge = ({ children }: Props) => {
59
+ const keepAwake = useSelector((state: ShellState) => state.keepAwake)
60
+ const dispatch = useDispatch()
61
+ const onChange = useCallback((settings: KeepAwakeSettings) => dispatch(keepAwakeActions.initialize(settings)), [dispatch])
62
+ return (
63
+ <KeepAwakeProvider expoKeepAwake={ExpoKeepAwake} initialValue={keepAwake} onChange={onChange}>
64
+ {children}
65
+ </KeepAwakeProvider>
66
+ )
67
+ }
68
+
69
+ interface ProvidersProps extends Props {
70
+ store: PackStore
71
+ persistor: Persistor
72
+ }
73
+
74
+ export function Providers({ store, persistor, children }: ProvidersProps) {
75
+ return (
76
+ <ReduxProvider store={store}>
77
+ <PersistGate persistor={persistor}>
78
+ <EdgeGuardBridge>
79
+ <FeedbackBridge>
80
+ <KeepAwakeBridge>{children}</KeepAwakeBridge>
81
+ </FeedbackBridge>
82
+ </EdgeGuardBridge>
83
+ </PersistGate>
84
+ </ReduxProvider>
85
+ )
86
+ }