@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
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { SeedColor } from '@rific/auto-paper'
|
|
2
|
+
import { type MenuOption, type PopoverHost, ReadyButton, useZoneClampedAlign } from '@tastic/hud'
|
|
3
|
+
import { getOptionPickerContentSize, OptionPicker, type OptionPickerOption, type Profile, type ProfilePickerColorSection } from '@tastic/profile'
|
|
4
|
+
import { ReactNode, useMemo } from 'react'
|
|
5
|
+
import { StyleSheet, View } from 'react-native'
|
|
6
|
+
|
|
7
|
+
import type { SeatTwo } from '../types'
|
|
8
|
+
|
|
9
|
+
// Seat 2's one button: who sits there (a CPU at a difficulty, nobody where the game has a solo
|
|
10
|
+
// mode, a guest, or a profile) and, on top, the seat's Color row. It replaces the choice between
|
|
11
|
+
// One Player and Two Player that Home used to ask before the loadout: picking a person here is what
|
|
12
|
+
// makes the round 2 Player, and the loadout turns to face both players.
|
|
13
|
+
//
|
|
14
|
+
// Built on @tastic/profile's OptionPicker, so it opens, places itself and closes like seat 1's
|
|
15
|
+
// ProfilePicker beside it; written so it can move into @tastic/hud's PlayerSetupPanel once the pack
|
|
16
|
+
// layout's shell moves into the shared packages (see the PuckTastic plan). Laid out like
|
|
17
|
+
// PlayerSetupPanel: the seat button, a person's controls picker under it, and their Ready button.
|
|
18
|
+
|
|
19
|
+
const CPU_PREFIX = 'cpu:'
|
|
20
|
+
const PROFILE_PREFIX = 'profile:'
|
|
21
|
+
const NOBODY = 'nobody'
|
|
22
|
+
const GUEST = 'guest'
|
|
23
|
+
|
|
24
|
+
const GUEST_ICON = 'account-outline'
|
|
25
|
+
const NOBODY_ICON = 'account-off-outline'
|
|
26
|
+
const CPU_ICON = 'robot'
|
|
27
|
+
|
|
28
|
+
export function getSeatTwoPopoverId(idPrefix: string): string {
|
|
29
|
+
return `${idPrefix}-seat`
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface SeatTwoPanelProps {
|
|
33
|
+
idPrefix: string
|
|
34
|
+
host: PopoverHost
|
|
35
|
+
// Seat 2's choices in this game (GameDefinition.seatTwo), in menu order.
|
|
36
|
+
choices: readonly SeatTwo[]
|
|
37
|
+
seatTwo: SeatTwo
|
|
38
|
+
onSeatTwoChange: (seatTwo: SeatTwo) => void
|
|
39
|
+
// The CPU's levels (GameLoadout.cpuDifficulty), or undefined for a CPU with none: one CPU row then.
|
|
40
|
+
cpuDifficulty?: { value: string; options: MenuOption<string>[]; onChange: (value: string) => void }
|
|
41
|
+
profiles: Profile[]
|
|
42
|
+
selectedProfileId: string | null
|
|
43
|
+
// Seat 1's profile: picking it here swaps the two seats (see @tastic/profile's select).
|
|
44
|
+
onProfileSelect: (profileId: string | null) => void
|
|
45
|
+
color: string
|
|
46
|
+
onColorChange: (hex: string) => void
|
|
47
|
+
swatches: SeedColor[]
|
|
48
|
+
takenColor?: string
|
|
49
|
+
allowSwapTaken?: boolean
|
|
50
|
+
// Shown under the Color row while a profile is in the seat (its pick is kept for this pack only).
|
|
51
|
+
colorCaption?: string
|
|
52
|
+
dark: boolean
|
|
53
|
+
// A person's controls picker (see GameLoadout.seatControl).
|
|
54
|
+
secondPicker?: ReactNode
|
|
55
|
+
secondPickerId?: string
|
|
56
|
+
ready?: boolean
|
|
57
|
+
onToggleReady?: () => void
|
|
58
|
+
showReadyButton?: boolean
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function SeatTwoPanel({ idPrefix, host, choices, seatTwo, onSeatTwoChange, cpuDifficulty, profiles, selectedProfileId, onProfileSelect, color, onColorChange, swatches, takenColor, allowSwapTaken, colorCaption, dark, secondPicker, secondPickerId, ready, onToggleReady, showReadyButton = true }: SeatTwoPanelProps) {
|
|
62
|
+
const isPerson = seatTwo === 'person'
|
|
63
|
+
const selectedProfile = isPerson ? (profiles.find((p) => p.id === selectedProfileId) ?? null) : null
|
|
64
|
+
|
|
65
|
+
const cpuOptions = cpuDifficulty?.options
|
|
66
|
+
const options = useMemo<OptionPickerOption<string>[]>(() => {
|
|
67
|
+
const list: OptionPickerOption<string>[] = []
|
|
68
|
+
if (choices.includes('cpu')) list.push(...(cpuOptions ? cpuOptions.map((o) => ({ value: `${CPU_PREFIX}${o.value}`, label: o.label, icon: o.icon })) : [{ value: CPU_PREFIX, label: 'CPU', icon: CPU_ICON }]))
|
|
69
|
+
if (choices.includes('nobody')) list.push({ value: NOBODY, label: 'Nobody', icon: NOBODY_ICON })
|
|
70
|
+
if (choices.includes('person')) {
|
|
71
|
+
list.push({ value: GUEST, label: 'Player 2', icon: GUEST_ICON })
|
|
72
|
+
list.push(...[...profiles].sort((a, b) => a.name.localeCompare(b.name)).map((p) => ({ value: `${PROFILE_PREFIX}${p.id}`, label: p.name, icon: 'account', color: p.color })))
|
|
73
|
+
}
|
|
74
|
+
return list
|
|
75
|
+
}, [choices, cpuOptions, profiles])
|
|
76
|
+
|
|
77
|
+
const value = seatTwo === 'cpu' ? `${CPU_PREFIX}${cpuDifficulty?.value ?? ''}` : seatTwo === 'nobody' ? NOBODY : selectedProfile ? `${PROFILE_PREFIX}${selectedProfile.id}` : GUEST
|
|
78
|
+
const icon = seatTwo === 'cpu' ? CPU_ICON : seatTwo === 'nobody' ? NOBODY_ICON : selectedProfile ? 'account' : GUEST_ICON
|
|
79
|
+
|
|
80
|
+
const handleChange = (next: string) => {
|
|
81
|
+
if (next.startsWith(CPU_PREFIX)) {
|
|
82
|
+
cpuDifficulty?.onChange(next.slice(CPU_PREFIX.length))
|
|
83
|
+
onSeatTwoChange('cpu')
|
|
84
|
+
} else if (next === NOBODY) {
|
|
85
|
+
onSeatTwoChange('nobody')
|
|
86
|
+
} else {
|
|
87
|
+
onProfileSelect(next.startsWith(PROFILE_PREFIX) ? next.slice(PROFILE_PREFIX.length) : null)
|
|
88
|
+
onSeatTwoChange('person')
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Nobody has no color to pick: there's nothing of theirs on the board.
|
|
93
|
+
const colorSection: ProfilePickerColorSection | undefined = seatTwo === 'nobody' ? undefined : { value: color, onChange: onColorChange, swatches, takenValue: takenColor, allowSwapTaken, caption: selectedProfile ? colorCaption : undefined }
|
|
94
|
+
|
|
95
|
+
const pickerId = getSeatTwoPopoverId(idPrefix)
|
|
96
|
+
const size = getOptionPickerContentSize(options.length, colorSection)
|
|
97
|
+
// Kept inside this player's half in 2 Player, like PlayerSetupPanel's own seat button.
|
|
98
|
+
const align = useZoneClampedAlign(host.openId === pickerId, size.width, size.height)
|
|
99
|
+
|
|
100
|
+
const ownPopoverOpen = host.openId?.startsWith(`${idPrefix}-`) ?? false
|
|
101
|
+
const pickerRowOpen = secondPickerId !== undefined && host.openId === secondPickerId
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<View style={[styles.panel, ownPopoverOpen && styles.panelOpen]}>
|
|
105
|
+
<OptionPicker id={pickerId} host={host} options={options} value={value} onChange={handleChange} color={color} dark={dark} icon={icon} colorSection={colorSection} alignOverride={align} />
|
|
106
|
+
{isPerson && secondPicker && <View style={[styles.pickerRow, pickerRowOpen && styles.pickerRowOpen]}>{secondPicker}</View>}
|
|
107
|
+
{isPerson && showReadyButton && onToggleReady && <ReadyButton color={color} ready={ready ?? false} onToggleReady={onToggleReady} />}
|
|
108
|
+
</View>
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// The same rhythm and stacking as @tastic/hud's PlayerSetupPanel, so seat 2 lines up with seat 1.
|
|
113
|
+
const styles = StyleSheet.create({
|
|
114
|
+
panel: {
|
|
115
|
+
alignItems: 'center',
|
|
116
|
+
gap: 12
|
|
117
|
+
},
|
|
118
|
+
// A popover escaping this panel needs the panel itself elevated to paint above a later sibling.
|
|
119
|
+
panelOpen: {
|
|
120
|
+
zIndex: 100
|
|
121
|
+
},
|
|
122
|
+
pickerRow: {
|
|
123
|
+
alignItems: 'center',
|
|
124
|
+
flexDirection: 'row',
|
|
125
|
+
gap: 12
|
|
126
|
+
},
|
|
127
|
+
// Lifts the controls picker's popover above the Ready button below it (each View is its own
|
|
128
|
+
// stacking context on the web).
|
|
129
|
+
pickerRowOpen: {
|
|
130
|
+
zIndex: 100
|
|
131
|
+
}
|
|
132
|
+
})
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { useUpdateErrorToast } from '@rific/toaster'
|
|
2
|
+
import { ViewRotation } from '@tastic/core'
|
|
3
|
+
import { BaseSettingsDialog } from '@tastic/hud'
|
|
4
|
+
import type { ReactNode } from 'react'
|
|
5
|
+
|
|
6
|
+
import { useAppSettings } from '../hooks/useAppSettings'
|
|
7
|
+
import { usePack } from '../PackContext'
|
|
8
|
+
|
|
9
|
+
export interface SettingsDialogProps {
|
|
10
|
+
visible: boolean
|
|
11
|
+
onDismiss: () => void
|
|
12
|
+
// Live physical-hold rotation (see @tastic/core's useRotation). BaseSettingsDialog lays the sheet
|
|
13
|
+
// out in its own frame turned by this (see @tastic/hud's RotatedFrame), so it reads right-side-up
|
|
14
|
+
// and fits the short edge however the phone is held. Defaults to 0 for a call site that doesn't
|
|
15
|
+
// track one.
|
|
16
|
+
rotation?: ViewRotation
|
|
17
|
+
// Shows the How to Play row, which opens this game's cards (a loadout passes its game's opener).
|
|
18
|
+
// Left out on Home, where each tile's rules button does it, and in a match, where opening
|
|
19
|
+
// Settings pauses play.
|
|
20
|
+
onShowHowToPlay?: () => void
|
|
21
|
+
// A play screen's own rows (a game's rule variants, where it has no loadout to set them in, see
|
|
22
|
+
// GameDefinition.useLoadout), above the pack's own sections.
|
|
23
|
+
children?: ReactNode
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// The pack-wide Settings dialog: everything the shell puts in it applies to every game (Lock
|
|
27
|
+
// Orientation, Edge Guard, theme, sound, haptics, Keep Awake), so it reads and writes the app
|
|
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.
|
|
30
|
+
export function SettingsDialog({ visible, onDismiss, rotation = 0, onShowHowToPlay, children }: SettingsDialogProps) {
|
|
31
|
+
const onUpdateError = useUpdateErrorToast()
|
|
32
|
+
const { settings, setSettings } = useAppSettings()
|
|
33
|
+
// The pack's otaVersion (src/constants/release.ts, which the OTA tooling bumps).
|
|
34
|
+
const { otaVersion, SettingsSections } = usePack()
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<BaseSettingsDialog visible={visible} onDismiss={onDismiss} rotation={rotation} version={otaVersion} lockOrientation={settings.lockOrientation} onLockOrientationChange={(value) => setSettings({ lockOrientation: value })} deferBottomEdgeGestures={settings.deferBottomEdgeGestures} onDeferBottomEdgeGestures={(value) => setSettings({ deferBottomEdgeGestures: value })} onUpdateError={onUpdateError} onShowHowToPlay={onShowHowToPlay}>
|
|
38
|
+
{children}
|
|
39
|
+
{SettingsSections && <SettingsSections />}
|
|
40
|
+
</BaseSettingsDialog>
|
|
41
|
+
)
|
|
42
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import MaskedView from '@react-native-masked-view/masked-view'
|
|
2
|
+
import { Provider as AutoPaperProvider, themeActions, ThemeSettings, useThemeBridgeProps } from '@rific/auto-paper'
|
|
3
|
+
import * as ExpoBlur from 'expo-blur'
|
|
4
|
+
import * as SplashScreen from 'expo-splash-screen'
|
|
5
|
+
import { ReactNode, useCallback } from 'react'
|
|
6
|
+
import Reanimated from 'react-native-reanimated'
|
|
7
|
+
import { shallowEqual, useDispatch, useSelector } from 'react-redux'
|
|
8
|
+
|
|
9
|
+
import { usePack } from '../PackContext'
|
|
10
|
+
import type { ShellState } from '../redux/shellState'
|
|
11
|
+
import { markSplashReady } from '../splashGate'
|
|
12
|
+
|
|
13
|
+
SplashScreen.preventAutoHideAsync()
|
|
14
|
+
// iOS defaults `fade` to false (Android always fades regardless of this flag), so without this the
|
|
15
|
+
// splash view is just yanked off screen the instant every gate reports ready.
|
|
16
|
+
SplashScreen.setOptions({ fade: true, duration: 400 })
|
|
17
|
+
|
|
18
|
+
interface Props {
|
|
19
|
+
children: ReactNode
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function Theme({ children }: Props) {
|
|
23
|
+
const { fontFamily } = usePack()
|
|
24
|
+
// redux/store.ts's own `theme` slice (createThemeReducer, seeded with this app's real default
|
|
25
|
+
// P1/P2 colors - see constants/game.ts) replaces this component's old per-field AsyncStorage
|
|
26
|
+
// load/save entirely. PersistGate (mounted in Providers.tsx, an ancestor of this component -
|
|
27
|
+
// Theme is rendered as its child in _layout.tsx) already blocks rendering until redux-persist has
|
|
28
|
+
// rehydrated, so by the time this mounts `settings` is already the real persisted (or, on a fresh
|
|
29
|
+
// install, default) triad - no async gate/loading state needed here the way the old AsyncStorage
|
|
30
|
+
// version required.
|
|
31
|
+
const settings = useSelector((state: ShellState) => state.theme, shallowEqual)
|
|
32
|
+
const dispatch = useDispatch()
|
|
33
|
+
|
|
34
|
+
// A one-shot callback, not a boolean, so there's nothing for useSplashReady to watch - PersistGate
|
|
35
|
+
// (an ancestor, see Providers.tsx) already blocks this component from mounting until redux-persist
|
|
36
|
+
// has rehydrated, so `settings` above is already the real persisted value the instant Theme mounts.
|
|
37
|
+
// That's necessary but not sufficient, though: AutoPaperProvider itself renders null internally
|
|
38
|
+
// until it's actually computed a theme object from that settings value (see its own source), which
|
|
39
|
+
// is a separate, later step from Theme just mounting with valid settings in hand. Marking this gate
|
|
40
|
+
// ready unconditionally at mount - as this file used to - could let the splash screen lift while
|
|
41
|
+
// AutoPaperProvider was still rendering null underneath it, same class of bug this app's own
|
|
42
|
+
// splash-gate audit found elsewhere. This instead marks the 'theme' gate (see
|
|
43
|
+
// src/utils/splashGate.ts) only once AutoPaperProvider itself reports a real theme, matching every
|
|
44
|
+
// sibling app's own Theme.tsx.
|
|
45
|
+
const onReady = useCallback(() => markSplashReady('theme'), [])
|
|
46
|
+
|
|
47
|
+
// Persists the FULL settings object now (appearance included), not just appearance - color used
|
|
48
|
+
// to be excluded here and persisted separately by loadout.tsx's own saveGuestColors/saveCpuColor
|
|
49
|
+
// AsyncStorage calls; now that both live in the same `theme` Redux slice, one unconditional
|
|
50
|
+
// dispatch covers all of it. loadout.tsx's own useThemeSettings().set(...) calls (unchanged by
|
|
51
|
+
// this) are what drive the LIVE color moment to moment; this onChange is only what makes whatever
|
|
52
|
+
// that resolves to durable across a relaunch.
|
|
53
|
+
const onChange = useCallback((next: ThemeSettings) => dispatch(themeActions.initialize(next)), [dispatch])
|
|
54
|
+
|
|
55
|
+
const bridgeProps = useThemeBridgeProps({ initialValue: settings, onChange, onReady })
|
|
56
|
+
|
|
57
|
+
// expoBlur is what lets auto-paper's BlurView actually blur (the settings sheet, Paper menus and
|
|
58
|
+
// @tastic/hud's overlay cards) while the player's Blur setting is on. Without it they paint the
|
|
59
|
+
// theme's solid surface instead, and hud's Settings hides the Blur row.
|
|
60
|
+
// maskedView is what lets auto-paper's FadingScrollView fade scrolling rows out at their edges on
|
|
61
|
+
// iOS/Android (the Settings sheet's and Rules' rows above Done, @tastic/profile's dropdown lists
|
|
62
|
+
// above Manage); without it they just scroll with a hard edge. Web needs nothing.
|
|
63
|
+
// statusBar={false}: auto-paper renders no React Native StatusBar (here or in Appbar.Header). The
|
|
64
|
+
// status bar is set per screen (_layout.tsx's useRotationAwareStatusBar), and with @tastic/core's
|
|
65
|
+
// config plugin on, a mounted StatusBar logs an error (a red box in a dev build).
|
|
66
|
+
// reanimated is what makes Dialog's own animatedStyle prop do anything (Metro won't pick up a
|
|
67
|
+
// require()-in-try/catch from this package's ESM build, so the module is handed in); nothing else in
|
|
68
|
+
// auto-paper reads it. fontFamily is the pack's (PackDefinition.fontFamily): set, it goes on every
|
|
69
|
+
// Paper typography variant; unset, Paper keeps its own.
|
|
70
|
+
return (
|
|
71
|
+
<AutoPaperProvider {...bridgeProps} expoBlur={ExpoBlur} maskedView={MaskedView} statusBar={false} reanimated={Reanimated} fontFamily={fontFamily}>
|
|
72
|
+
{children}
|
|
73
|
+
</AutoPaperProvider>
|
|
74
|
+
)
|
|
75
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { useUpdateErrorToast } from '@rific/toaster'
|
|
2
|
+
import { useRotation } from '@tastic/core'
|
|
3
|
+
import { UpdateDialog as SharedUpdateDialog } from '@tastic/hud'
|
|
4
|
+
|
|
5
|
+
// Thin bridge onto the fleet-shared @tastic/hud UpdateDialog, which now owns the useUpdater()
|
|
6
|
+
// instance, the manifest/confirm bridging, and the themed ConfirmDialog rendering this file used
|
|
7
|
+
// to hand-roll itself. Kept as its own named export (rather than importing SharedUpdateDialog
|
|
8
|
+
// directly in _layout.tsx) purely so this app's own onError -> toast wiring has one place to live.
|
|
9
|
+
export function UpdateDialog() {
|
|
10
|
+
const onError = useUpdateErrorToast()
|
|
11
|
+
const rotation = useRotation()
|
|
12
|
+
return <SharedUpdateDialog onError={onError} rotation={rotation} />
|
|
13
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ComponentType } from 'react'
|
|
2
|
+
import { Image, type ImageSourcePropType } from 'react-native'
|
|
3
|
+
|
|
4
|
+
import type { GameIconProps } from '../types'
|
|
5
|
+
|
|
6
|
+
// A game icon drawn as a light and dark pair of pictures (opaque squares, the art drawn as an app
|
|
7
|
+
// icon) rather than in the seats' colors: GameDefinition.icon for a game whose art is a picture.
|
|
8
|
+
// Home clips it to the row's rounded icon square.
|
|
9
|
+
export function imageGameIcon(images: { light: ImageSourcePropType; dark: ImageSourcePropType }): ComponentType<GameIconProps> {
|
|
10
|
+
return function ImageGameIcon({ size, dark }: GameIconProps) {
|
|
11
|
+
return <Image source={dark ? images.dark : images.light} style={{ height: size, width: size }} accessibilityIgnoresInvertColors />
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { type AchievementDefinition, type AchievementsState, createAchievementsSlice, mapUnlocksBySeat, type UnlockedAchievementsState, useAchievements } from '@tastic/achievements'
|
|
2
|
+
import { createContext, ReactNode, useCallback, useContext, useMemo } from 'react'
|
|
3
|
+
|
|
4
|
+
import { forgetProfileAchievements, onProfileRemoved } from './redux/onProfileRemoved'
|
|
5
|
+
import type { Seat } from './types'
|
|
6
|
+
|
|
7
|
+
const SEATS: readonly Seat[] = [1, 2]
|
|
8
|
+
|
|
9
|
+
// What a recorded result unlocked: device-wide, and per seat for each seat with a profile in it (the
|
|
10
|
+
// package keys per-profile unlocks by profile id; the games think in seats).
|
|
11
|
+
export interface RecordResult<TStats> {
|
|
12
|
+
device: AchievementDefinition<TStats>[]
|
|
13
|
+
profiles: Partial<Record<Seat, AchievementDefinition<TStats>[]>>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface GameStatsContextValue<TStats> {
|
|
17
|
+
stats: TStats
|
|
18
|
+
unlockedAchievements: UnlockedAchievementsState
|
|
19
|
+
// Applies a result to the stats and returns what it unlocked. `profileIds` maps each seat to the
|
|
20
|
+
// profile in it, for the per-seat breakdown.
|
|
21
|
+
record: (update: (prev: TStats) => TStats, profileIds?: Partial<Record<Seat, string>>) => RecordResult<TStats>
|
|
22
|
+
// Wipes the game's stats and achievements back to defaults - irreversible, so callers confirm with
|
|
23
|
+
// the player first (the shell's stats screen reset).
|
|
24
|
+
resetAll: () => void
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface GameStatsOptions<TStats, TBucket> {
|
|
28
|
+
// The store key, which is also where the stats are saved.
|
|
29
|
+
key: string
|
|
30
|
+
defaultStats: TStats
|
|
31
|
+
isValidStats?: (value: unknown) => boolean
|
|
32
|
+
catalog: AchievementDefinition<TStats>[]
|
|
33
|
+
// A profile's own bucket seen through the same shape as the device-wide stats, so every
|
|
34
|
+
// achievement predicate reads either.
|
|
35
|
+
getProfileStatsView: (bucket: TBucket) => TStats
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// One game's stats and achievements: its store slice (@tastic/achievements' createAchievementsSlice
|
|
39
|
+
// under the game's own key, with a profile deleted on the Profiles screen taking its stats bucket and
|
|
40
|
+
// unlocks with it), and the context every screen reads them through, mounted once at the app root
|
|
41
|
+
// (GameDefinition.Provider) so useAchievements' mount-time unlock re-sweep runs once. Every game keeps
|
|
42
|
+
// its stats with a `profiles` bucket per profile beside the device-wide totals.
|
|
43
|
+
export function createGameStats<TStats extends { profiles: Record<string, TBucket> }, TBucket>({ key, defaultStats, isValidStats, catalog, getProfileStatsView }: GameStatsOptions<TStats, TBucket>) {
|
|
44
|
+
const { actions, reducer: sliceReducer } = createAchievementsSlice<TStats>({
|
|
45
|
+
key,
|
|
46
|
+
defaultStats,
|
|
47
|
+
isValidStats,
|
|
48
|
+
// Backfills `profiles` onto stats saved without it.
|
|
49
|
+
migrateStats: (stored) => ({ ...stored, profiles: stored.profiles ?? {} })
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
const dropProfileStats = (stats: TStats, profileId: string): TStats => {
|
|
53
|
+
if (!(profileId in stats.profiles)) return stats
|
|
54
|
+
const { [profileId]: _removed, ...rest } = stats.profiles
|
|
55
|
+
return { ...stats, profiles: rest }
|
|
56
|
+
}
|
|
57
|
+
const reducer = onProfileRemoved(sliceReducer, forgetProfileAchievements<TStats, AchievementsState<TStats>>(dropProfileStats))
|
|
58
|
+
|
|
59
|
+
const profileViews = (stats: TStats): Record<string, TStats> => Object.fromEntries(Object.entries(stats.profiles).map(([profileId, bucket]) => [profileId, getProfileStatsView(bucket)]))
|
|
60
|
+
|
|
61
|
+
const Context = createContext<GameStatsContextValue<TStats> | null>(null)
|
|
62
|
+
|
|
63
|
+
function Provider({ children }: { children: ReactNode }) {
|
|
64
|
+
const { stats, unlockedAchievements, recordOutcome, resetAll } = useAchievements<TStats, Record<string, AchievementsState<TStats>>>({
|
|
65
|
+
catalog,
|
|
66
|
+
select: (state) => state[key],
|
|
67
|
+
actions,
|
|
68
|
+
profileViews
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
const record = useCallback(
|
|
72
|
+
(update: (prev: TStats) => TStats, profileIds?: Partial<Record<Seat, string>>): RecordResult<TStats> => {
|
|
73
|
+
const result = recordOutcome(update)
|
|
74
|
+
return { device: result.device, profiles: mapUnlocksBySeat(SEATS, profileIds, result.profiles) }
|
|
75
|
+
},
|
|
76
|
+
[recordOutcome]
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
const value = useMemo(() => ({ stats, unlockedAchievements, record, resetAll }), [stats, unlockedAchievements, record, resetAll])
|
|
80
|
+
return <Context.Provider value={value}>{children}</Context.Provider>
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function useStats(): GameStatsContextValue<TStats> {
|
|
84
|
+
const ctx = useContext(Context)
|
|
85
|
+
if (!ctx) throw new Error(`The ${key} stats are read within their game's Provider`)
|
|
86
|
+
return ctx
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return { reducer, actions, Provider, useStats }
|
|
90
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { useSelector } from 'react-redux'
|
|
2
|
+
|
|
3
|
+
import { usePackGame } from '../PackContext'
|
|
4
|
+
import type { ShellState } from '../redux/shellState'
|
|
5
|
+
import type { RoundMode } from '../types'
|
|
6
|
+
|
|
7
|
+
// The round a play screen is playing: committed by the loadout when every human player readied up
|
|
8
|
+
// (see liveplaySlice.ts). A play screen reached without one (a direct link, a web reload, or a game
|
|
9
|
+
// with no loadout, which Home opens straight away) plays its game's defaults: against the CPU, or
|
|
10
|
+
// solo in a game with no seat 2 (GameDefinition.seatTwo).
|
|
11
|
+
export function useActiveRound<S extends object>(game: string, defaults: S): { mode: RoundMode; settings: S } {
|
|
12
|
+
const round = useSelector((state: ShellState) => state.liveplay.activeRound)
|
|
13
|
+
const seated = !!usePackGame(game)?.seatTwo
|
|
14
|
+
if (!round || round.game !== game) return { mode: seated ? 'vsCpu' : 'solo', settings: defaults }
|
|
15
|
+
return { mode: round.mode, settings: { ...defaults, ...(round.settings as Partial<S>) } }
|
|
16
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { useCallback } from 'react'
|
|
2
|
+
import { useDispatch, useSelector } from 'react-redux'
|
|
3
|
+
|
|
4
|
+
import { type AppSettings, appSettingsActions } from '../redux/appSettingsSlice'
|
|
5
|
+
import type { ShellState } from '../redux/shellState'
|
|
6
|
+
|
|
7
|
+
// The one way the pack-wide settings are read and written (Lock Orientation, Edge Guard). Writes
|
|
8
|
+
// merge, so a patch only changes the fields it names.
|
|
9
|
+
export function useAppSettings(): { settings: AppSettings; setSettings: (patch: Partial<AppSettings>) => void } {
|
|
10
|
+
const settings = useSelector((state: ShellState) => state.appSettings)
|
|
11
|
+
const dispatch = useDispatch()
|
|
12
|
+
const setSettings = useCallback(
|
|
13
|
+
(patch: Partial<AppSettings>) => {
|
|
14
|
+
dispatch(appSettingsActions.initialize(patch))
|
|
15
|
+
},
|
|
16
|
+
[dispatch]
|
|
17
|
+
)
|
|
18
|
+
return { settings, setSettings }
|
|
19
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
|
|
3
|
+
// Returns `value`, except while `hidden` is true: then it keeps returning whatever `value` was the
|
|
4
|
+
// instant before `hidden` became true, rather than whatever `value` has already become. Ported from
|
|
5
|
+
// Solitaire's components/useFrozenWhileHidden.ts as it was.
|
|
6
|
+
//
|
|
7
|
+
// Home holds its layout with it while another screen covers it (screens/HomeScreen.tsx): Home stays
|
|
8
|
+
// mounted beneath every screen the stack pushes and keeps following the rotation, and crossing the
|
|
9
|
+
// upright/wide threshold swaps its whole tree (every tile, the hero, seat 1's button), invisible work
|
|
10
|
+
// landing in the same commit as the covering screen's own rotation update. While covered the layout is
|
|
11
|
+
// held where it was; it catches up (one swap, visible) when Home is shown again.
|
|
12
|
+
export function useFrozenWhileHidden<T>(value: T, hidden: boolean): T {
|
|
13
|
+
// React's own sanctioned "adjust state during render" shape (see react.dev's guide of the same
|
|
14
|
+
// name) rather than mutating a ref during render: each setter below is only ever called when the
|
|
15
|
+
// value it tracks has actually changed, so this settles within the same render pass instead of
|
|
16
|
+
// triggering a visible extra frame.
|
|
17
|
+
const [prevHidden, setPrevHidden] = useState(hidden)
|
|
18
|
+
const [prevValue, setPrevValue] = useState(value)
|
|
19
|
+
const [frozen, setFrozen] = useState(value)
|
|
20
|
+
|
|
21
|
+
if (hidden !== prevHidden) {
|
|
22
|
+
setPrevHidden(hidden)
|
|
23
|
+
// Freezes `prevValue` (last render's value, still whatever was visible a moment ago), not this
|
|
24
|
+
// render's `value`: hidden can flip true the same commit `value` already changed, and it's the
|
|
25
|
+
// outgoing one that should stay on screen.
|
|
26
|
+
if (hidden) setFrozen(prevValue)
|
|
27
|
+
}
|
|
28
|
+
if (value !== prevValue) {
|
|
29
|
+
setPrevValue(value)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return hidden ? frozen : value
|
|
33
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { createContext, ReactNode, useContext, useMemo, useRef, useState } from 'react'
|
|
2
|
+
import { useDispatch, useStore } from 'react-redux'
|
|
3
|
+
|
|
4
|
+
import { lastActiveGameActions } from '../redux/lastActiveGameSlice'
|
|
5
|
+
import type { ShellState } from '../redux/shellState'
|
|
6
|
+
|
|
7
|
+
interface LastActiveGameContextValue {
|
|
8
|
+
// Returns the game a cold launch should drop the player straight back onto (once), or null.
|
|
9
|
+
consumeResumeGame: () => string | null
|
|
10
|
+
setLastActiveGame: (gameId: string) => void
|
|
11
|
+
clearLastActiveGame: () => void
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const LastActiveGameContext = createContext<LastActiveGameContextValue | null>(null)
|
|
15
|
+
|
|
16
|
+
// Which play screen the player is currently on, persisted (the store's `lastActiveGame` key) so a
|
|
17
|
+
// cold launch/relaunch can drop them straight back onto it (see screens/HomeScreen.tsx) instead of
|
|
18
|
+
// Home - independent of whether that game still has a resumable board, which is the game's own.
|
|
19
|
+
// Mounted inside PersistGate, so the value read once at mount is already the rehydrated one. Ported
|
|
20
|
+
// from Solitaire's stores/lastActiveGame.tsx as it was.
|
|
21
|
+
//
|
|
22
|
+
// Deliberately NOT React state, and never read with useSelector. The only thing that ever needed the
|
|
23
|
+
// live value was Home's resume Redirect, and that is a one-shot cold-launch decision - so exposing it
|
|
24
|
+
// as state made every set/clear (each game mount, each Home tap) re-render every consumer, and made
|
|
25
|
+
// every Home instance in the stack swap its whole menu tree for a Redirect and back (in Solitaire,
|
|
26
|
+
// tearing down and rebuilding ~370 SVG preview cards on each transition). The value handed out here
|
|
27
|
+
// is stable for the provider's whole lifetime; `pendingResume` is consumed by the first Home to mount
|
|
28
|
+
// and cleared by any set/clear, so a launch straight into a game (deep link) can never later resume a
|
|
29
|
+
// stale one. A pack with no resumable game has no `lastActiveGame` key, and this reads null.
|
|
30
|
+
export function LastActiveGameProvider({ children }: { children: ReactNode }) {
|
|
31
|
+
const store = useStore<ShellState>()
|
|
32
|
+
const dispatch = useDispatch()
|
|
33
|
+
// Read once, at mount (a lazy initializer, never a subscription).
|
|
34
|
+
const [initial] = useState(() => store.getState().lastActiveGame?.gameId ?? null)
|
|
35
|
+
const pendingResume = useRef<string | null>(initial)
|
|
36
|
+
// What the store currently holds, so an unchanged value is never rewritten: every game mount sets it
|
|
37
|
+
// and every exit clears it.
|
|
38
|
+
const persisted = useRef<string | null>(initial)
|
|
39
|
+
|
|
40
|
+
const value = useMemo<LastActiveGameContextValue>(
|
|
41
|
+
() => ({
|
|
42
|
+
consumeResumeGame: () => {
|
|
43
|
+
const game = pendingResume.current
|
|
44
|
+
pendingResume.current = null
|
|
45
|
+
return game
|
|
46
|
+
},
|
|
47
|
+
setLastActiveGame: (gameId) => {
|
|
48
|
+
pendingResume.current = null
|
|
49
|
+
if (persisted.current === gameId) return
|
|
50
|
+
persisted.current = gameId
|
|
51
|
+
dispatch(lastActiveGameActions.setGameId(gameId))
|
|
52
|
+
},
|
|
53
|
+
// Called from the play route's unmount cleanup (screens/GameRoute.tsx) - it fires on the play
|
|
54
|
+
// screen's own back, hardware back, or a browser back, but not on mere app backgrounding, where
|
|
55
|
+
// the screen stays mounted. That's exactly "explicitly back out."
|
|
56
|
+
clearLastActiveGame: () => {
|
|
57
|
+
pendingResume.current = null
|
|
58
|
+
if (persisted.current === null) return
|
|
59
|
+
persisted.current = null
|
|
60
|
+
dispatch(lastActiveGameActions.setGameId(null))
|
|
61
|
+
}
|
|
62
|
+
}),
|
|
63
|
+
[dispatch]
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
return <LastActiveGameContext.Provider value={value}>{children}</LastActiveGameContext.Provider>
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function useLastActiveGame(): LastActiveGameContextValue {
|
|
70
|
+
const ctx = useContext(LastActiveGameContext)
|
|
71
|
+
if (!ctx) throw new Error('useLastActiveGame must be used within a LastActiveGameProvider')
|
|
72
|
+
return ctx
|
|
73
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { useCallback } from 'react'
|
|
2
|
+
import { useDispatch, useSelector } from 'react-redux'
|
|
3
|
+
|
|
4
|
+
import { opponentActions } from '../redux/opponentSlice'
|
|
5
|
+
import type { ShellState } from '../redux/shellState'
|
|
6
|
+
import type { SeatTwo } from '../types'
|
|
7
|
+
|
|
8
|
+
// Who sits in seat 2 across the pack (see opponentSlice.ts).
|
|
9
|
+
export function useOpponent(): { seatTwo: SeatTwo; setSeatTwo: (seatTwo: SeatTwo) => void } {
|
|
10
|
+
const seatTwo = useSelector((state: ShellState) => state.opponent.seatTwo)
|
|
11
|
+
const dispatch = useDispatch()
|
|
12
|
+
const setSeatTwo = useCallback((next: SeatTwo) => dispatch(opponentActions.setSeatTwo(next)), [dispatch])
|
|
13
|
+
return { seatTwo, setSeatTwo }
|
|
14
|
+
}
|