@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.
- package/LICENSE +21 -0
- package/README.md +154 -2
- package/assets/sounds/notification.wav +0 -0
- package/assets/sounds/select.wav +0 -0
- package/dist/index.d.mts +487 -0
- package/dist/index.d.ts +487 -0
- package/dist/index.js +1636 -0
- package/dist/index.mjs +1562 -0
- package/dist/notification-CGGFPYK7.wav +0 -0
- package/dist/select-MLBNNA4N.wav +0 -0
- package/package.json +163 -4
- package/src/PackContext.tsx +22 -0
- package/src/RootLayout.tsx +186 -0
- package/src/components/Fonts.tsx +41 -0
- package/src/components/GuideHost.tsx +104 -0
- package/src/components/Providers.tsx +86 -0
- package/src/components/SeatTwoPanel.tsx +132 -0
- package/src/components/SettingsDialog.tsx +42 -0
- package/src/components/Theme.tsx +75 -0
- package/src/components/UpdateDialog.tsx +13 -0
- package/src/components/imageGameIcon.tsx +13 -0
- package/src/gameStats.tsx +90 -0
- package/src/hooks/useActiveRound.ts +16 -0
- package/src/hooks/useAppSettings.ts +19 -0
- package/src/hooks/useFrozenWhileHidden.ts +33 -0
- package/src/hooks/useLastActiveGame.tsx +73 -0
- package/src/hooks/useOpponent.ts +14 -0
- package/src/hooks/useProfiles.tsx +134 -0
- package/src/hooks/useSeatColors.ts +148 -0
- package/src/index.ts +39 -0
- package/src/navigation.ts +11 -0
- package/src/playerSettings.ts +112 -0
- package/src/redux/appSettingsSlice.ts +34 -0
- package/src/redux/createPackStore.ts +78 -0
- package/src/redux/gameGuideSlice.ts +39 -0
- package/src/redux/lastActiveGameSlice.ts +12 -0
- package/src/redux/liveplaySlice.ts +34 -0
- package/src/redux/onProfileRemoved.ts +25 -0
- package/src/redux/opponentSlice.ts +21 -0
- package/src/redux/profileSelectionSlice.ts +17 -0
- package/src/redux/seatColorsSlice.ts +24 -0
- package/src/redux/shellState.ts +59 -0
- package/src/screens/GameRoute.tsx +48 -0
- package/src/screens/HomeScreen.tsx +378 -0
- package/src/screens/LoadoutScreen.tsx +271 -0
- package/src/screens/ProfilesRoute.tsx +14 -0
- package/src/screens/StatsScreen.tsx +133 -0
- package/src/screens/homeLayout.ts +129 -0
- package/src/splashGate.ts +3 -0
- 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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|