@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,271 @@
|
|
|
1
|
+
import { defaultColors, useAutoPaperTheme } from '@rific/auto-paper'
|
|
2
|
+
import { FakeLandscapeView, getViewRotation, rotateInsets, useOrientationState } from '@tastic/core'
|
|
3
|
+
import { CornerActionButtons, PlayerSetupPanel, PressAwayOverlay, ReadyButton, SharedActionBand, usePopoverHost } from '@tastic/hud'
|
|
4
|
+
import type { Profile } from '@tastic/profile'
|
|
5
|
+
import { DualZoneLayout, needsSharedNeutralZone, useDualZoneLayout } from '@tastic/split-screen'
|
|
6
|
+
import { router, useFocusEffect } from 'expo-router'
|
|
7
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
8
|
+
import { Platform, StyleSheet, View } from 'react-native'
|
|
9
|
+
import Animated from 'react-native-reanimated'
|
|
10
|
+
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
|
11
|
+
import { useDispatch } from 'react-redux'
|
|
12
|
+
import type { Action, Dispatch } from 'redux'
|
|
13
|
+
|
|
14
|
+
import { useGameGuide } from '../components/GuideHost'
|
|
15
|
+
import { SeatTwoPanel } from '../components/SeatTwoPanel'
|
|
16
|
+
import { SettingsDialog } from '../components/SettingsDialog'
|
|
17
|
+
import { useOpponent } from '../hooks/useOpponent'
|
|
18
|
+
import { useProfiles } from '../hooks/useProfiles'
|
|
19
|
+
import { useSeatColors } from '../hooks/useSeatColors'
|
|
20
|
+
import { safeBack } from '../navigation'
|
|
21
|
+
import { usePack } from '../PackContext'
|
|
22
|
+
import { liveplayActions } from '../redux/liveplaySlice'
|
|
23
|
+
import { type GameDefinition, humanSeatsFor, roundModeFor, type Seat, type SeatTwo, seatTwoFor } from '../types'
|
|
24
|
+
|
|
25
|
+
// The player-panel area: whenever a real device rotation changes orientationMode or which physical
|
|
26
|
+
// side P1 lands on, the panels fade out, the layout underneath swaps while invisible, then they fade
|
|
27
|
+
// back in - masking what would otherwise be an instant jump-cut as panels reflow or swap sides.
|
|
28
|
+
// Short and symmetric on purpose: this is masking a reflow that already happened at the OS's own
|
|
29
|
+
// pace, not a deliberate reveal.
|
|
30
|
+
const PANEL_SWAP_FADE_MS = 180
|
|
31
|
+
|
|
32
|
+
// A game that has a loadout (GameDefinition.useLoadout): the loadout route sends any other straight
|
|
33
|
+
// to its play screen.
|
|
34
|
+
export type LoadoutGame = GameDefinition & Required<Pick<GameDefinition, 'useLoadout'>>
|
|
35
|
+
|
|
36
|
+
// Every game's loadout: seat 1 (the player picked on Home), seat 2 (the CPU, a second person, or
|
|
37
|
+
// nobody, from seat 2's own button, or no button in a game for one player), the game's own match
|
|
38
|
+
// options, and Ready. Remounted per game (the route keys it by id), so a game's useLoadout hook only
|
|
39
|
+
// ever runs for that one game.
|
|
40
|
+
export function LoadoutScreen({ game }: { game: LoadoutGame }) {
|
|
41
|
+
const pack = usePack()
|
|
42
|
+
const { seatTwo: savedSeatTwo, setSeatTwo } = useOpponent()
|
|
43
|
+
const seatTwo = seatTwoFor(savedSeatTwo, game.seatTwo)
|
|
44
|
+
const mode = roundModeFor(seatTwo)
|
|
45
|
+
const humanSeats = useMemo(() => humanSeatsFor(mode), [mode])
|
|
46
|
+
const p2IsHuman = mode === 'twoPlayer'
|
|
47
|
+
// A CPU takes a seat on the board; nobody doesn't (solo).
|
|
48
|
+
const p2Exists = mode !== 'solo'
|
|
49
|
+
|
|
50
|
+
const loadout = game.useLoadout(mode)
|
|
51
|
+
const { openFor } = useGameGuide()
|
|
52
|
+
|
|
53
|
+
const { profiles, lastSelected, selectProfile } = useProfiles()
|
|
54
|
+
// orientationMode just follows the device's current physical tilt (see @tastic/core's
|
|
55
|
+
// useOrientationState), CPU games included - how you're holding the phone right now decides the
|
|
56
|
+
// panel arrangement, not a stored per-round choice. Lock Orientation pins it app-wide by freezing
|
|
57
|
+
// the shared reading itself (RootLayout's OrientationBridge), so this read takes no `locked`
|
|
58
|
+
// argument. The OS is portrait-locked; FakeLandscapeView below fakes the rest in JS.
|
|
59
|
+
const { orientationMode, p1OnRight, upsideDown, resolved: p1OnRightResolved } = useOrientationState()
|
|
60
|
+
|
|
61
|
+
// The panel area's own layout lags one fade behind the live orientation above - see
|
|
62
|
+
// useDualZoneLayout's own comment. 2 Player seats its players the way the game's own board does
|
|
63
|
+
// (GameDefinition.twoPlayerSeats): face to face however the phone is held, P1 in the bottom half
|
|
64
|
+
// and P2 in the top, flipped for an upside-down hold where the game flips too, so each player gets
|
|
65
|
+
// the same half here and in the match. On the web a game may seat them side by side instead.
|
|
66
|
+
const fixedSeats = p2IsHuman && (!!game.twoPlayerSeats?.faceToFaceOnWeb || Platform.OS !== 'web')
|
|
67
|
+
const fixedUpsideDown = !!game.twoPlayerSeats?.followUpsideDown && orientationMode === 'faceToFace' && upsideDown
|
|
68
|
+
const { panelLayout, panelFadeStyle } = useDualZoneLayout(fixedSeats ? 'faceToFace' : orientationMode, fixedSeats ? true : p1OnRight, fixedSeats || p1OnRightResolved, fixedSeats ? fixedUpsideDown : upsideDown, PANEL_SWAP_FADE_MS)
|
|
69
|
+
|
|
70
|
+
const [settingsOpen, setSettingsOpen] = useState(false)
|
|
71
|
+
// Safe-area insets are relative to the device's fixed portrait frame; rotateInsets remaps them
|
|
72
|
+
// onto whichever edge they land on once FakeLandscapeView turns the content, so `top`/`left`/
|
|
73
|
+
// `right` below always mean the screen's real, visual edges.
|
|
74
|
+
const rotation = getViewRotation(panelLayout.orientationMode, panelLayout.p1OnRight, panelLayout.upsideDown)
|
|
75
|
+
const insets = rotateInsets(useSafeAreaInsets(), rotation)
|
|
76
|
+
const { colors: themeColors, dark } = useAutoPaperTheme()
|
|
77
|
+
const { p1Color, p2Color, handleP1ColorChange, handleP2ColorChange } = useSeatColors(p2IsHuman)
|
|
78
|
+
const dispatch = useDispatch<Dispatch<Action>>()
|
|
79
|
+
|
|
80
|
+
// A seat's color follows its newly selected profile through the profile-color effect above.
|
|
81
|
+
// Picking the other seat's profile swaps the two (see @tastic/profile's select).
|
|
82
|
+
const handleP1ProfileSelect = useCallback((profile: Profile | null) => selectProfile(1, profile?.id ?? null), [selectProfile])
|
|
83
|
+
const handleP2ProfileSelect = useCallback((profileId: string | null) => selectProfile(2, profileId), [selectProfile])
|
|
84
|
+
|
|
85
|
+
const [ready, setReady] = useState<Record<Seat, boolean>>({ 1: false, 2: false })
|
|
86
|
+
|
|
87
|
+
// Every time this screen (re)gains focus - arrival from Home, or coming back from a match - Ready
|
|
88
|
+
// starts false again. Without this, popping back to a loadout whose players both left it Ready
|
|
89
|
+
// would immediately re-trigger the all-ready effect below and bounce straight back into the match.
|
|
90
|
+
useFocusEffect(
|
|
91
|
+
useCallback(() => {
|
|
92
|
+
setReady({ 1: false, 2: false })
|
|
93
|
+
}, [setReady])
|
|
94
|
+
)
|
|
95
|
+
// A change of who's in seat 2 starts Ready over too: otherwise player 1, ready and waiting on
|
|
96
|
+
// player 2, would be launched into a match the moment seat 2 switched to the CPU.
|
|
97
|
+
const changeSeatTwo = useCallback(
|
|
98
|
+
(next: SeatTwo) => {
|
|
99
|
+
setReady({ 1: false, 2: false })
|
|
100
|
+
setSeatTwo(next)
|
|
101
|
+
},
|
|
102
|
+
[setSeatTwo, setReady]
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
// Read through a ref rather than listed as a dependency of the effect below: this screen stays
|
|
106
|
+
// mounted (and still all Ready) under the play screen, so a settings change made there would
|
|
107
|
+
// otherwise re-run that effect and push a second match.
|
|
108
|
+
const roundSettingsRef = useRef(loadout.roundSettings)
|
|
109
|
+
useEffect(() => {
|
|
110
|
+
roundSettingsRef.current = loadout.roundSettings
|
|
111
|
+
}, [loadout.roundSettings])
|
|
112
|
+
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
if (!humanSeats.every((s) => ready[s])) return
|
|
115
|
+
dispatch(liveplayActions.setActiveRound({ game: game.id, mode, settings: roundSettingsRef.current }))
|
|
116
|
+
router.push({ pathname: '/play/[game]', params: { game: game.id } })
|
|
117
|
+
}, [ready, humanSeats, game.id, mode, dispatch])
|
|
118
|
+
|
|
119
|
+
const bg = dark ? '#000000' : '#FFFFFF'
|
|
120
|
+
const fg = dark ? '#FFFFFF' : '#000000'
|
|
121
|
+
const fgMuted = dark ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.5)'
|
|
122
|
+
|
|
123
|
+
const isPortrait = panelLayout.orientationMode === 'faceToFace'
|
|
124
|
+
// See @tastic/split-screen's needsSharedNeutralZone: whether back/settings/randomize/reset fold
|
|
125
|
+
// into one band between the two players instead of sitting in the corners.
|
|
126
|
+
const showMetaInSharedBand = needsSharedNeutralZone(panelLayout.orientationMode, humanSeats.length)
|
|
127
|
+
|
|
128
|
+
// Where P2's own press-away zone lives on screen - matches panelLayout (what's actually painted
|
|
129
|
+
// right now, mid-fade included). Portrait puts P2's panel in the top half; landscape on whichever
|
|
130
|
+
// side isn't P1's. Only in 2 Player - otherwise seat 2 shares seat 1's host (see p2Host below).
|
|
131
|
+
const p2ZoneStyle = p2IsHuman ? (isPortrait ? styles.p2ZoneTop : panelLayout.p1OnRight ? styles.p2ZoneLeft : styles.p2ZoneRight) : null
|
|
132
|
+
|
|
133
|
+
// With one human, the one Ready button sits under both panels instead of in each.
|
|
134
|
+
const showReadyButton = p2IsHuman
|
|
135
|
+
// P1's panel and the match options share one host - they sit in the same near/bottom zone. With
|
|
136
|
+
// one human, seat 2's panel joins it too (one person drives both, so two open menus at once would
|
|
137
|
+
// be clutter). 2 Player gives P2 their own host so two people can pick at once, each press-away
|
|
138
|
+
// zone closing only its own player's menu.
|
|
139
|
+
const controlsHost = usePopoverHost()
|
|
140
|
+
const p2PanelHost = usePopoverHost()
|
|
141
|
+
const p2Host = p2IsHuman ? p2PanelHost : controlsHost
|
|
142
|
+
// The one-human Ready button is centered under the whole row, not under either seat button, so an
|
|
143
|
+
// open seat menu can't fully cover it; it fades out while one is open instead.
|
|
144
|
+
const anyPlayerPickerOpen = !!(controlsHost.openId?.startsWith('p1-') || controlsHost.openId?.startsWith('p2-'))
|
|
145
|
+
|
|
146
|
+
const optionsRow = loadout.options({
|
|
147
|
+
host: controlsHost,
|
|
148
|
+
rotation,
|
|
149
|
+
accentColor: themeColors.tertiary,
|
|
150
|
+
mutedColor: fgMuted,
|
|
151
|
+
onAccentColor: themeColors.onTertiary,
|
|
152
|
+
dark,
|
|
153
|
+
// Left out (the row's own actions unrendered) whenever the shared band below carries them.
|
|
154
|
+
onRandomize: showMetaInSharedBand ? undefined : loadout.randomize,
|
|
155
|
+
onReset: showMetaInSharedBand ? undefined : loadout.reset
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
const optionsPopoverOpen = controlsHost.openId !== null && loadout.optionIds.includes(controlsHost.openId)
|
|
159
|
+
const sharedControls = showMetaInSharedBand ? (
|
|
160
|
+
<SharedActionBand onBack={safeBack} onSettings={() => setSettingsOpen(true)} onRandomize={loadout.randomize} onReset={loadout.reset} fg={fg} popoverOpen={optionsPopoverOpen}>
|
|
161
|
+
{optionsRow}
|
|
162
|
+
</SharedActionBand>
|
|
163
|
+
) : (
|
|
164
|
+
optionsRow
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
const controlLook = { accentColor: themeColors.tertiary, mutedColor: fgMuted, dark, rotation }
|
|
168
|
+
const p1Control = loadout.seatControl(1, controlsHost, { ...controlLook, seatColor: p1Color })
|
|
169
|
+
const p1Panel = <PlayerSetupPanel idPrefix='p1' host={controlsHost} color={p1Color} onColorChange={handleP1ColorChange} swatches={defaultColors} takenColor={p2Exists ? p2Color : undefined} allowSwapTaken={mode === 'vsCpu'} colorCaption={pack.colorCaption} isHuman ready={ready[1]} onToggleReady={() => setReady((r) => ({ ...r, 1: !r[1] }))} dark={dark} showReadyButton={showReadyButton} secondPicker={p1Control} secondPickerId={p1Control ? 'p1-control' : undefined} profiles={profiles} selectedProfileId={lastSelected[1]} takenProfileId={p2IsHuman ? lastSelected[2] : null} guestLabel='P1' onProfileSelect={handleP1ProfileSelect} onManageProfiles={() => router.push('/profiles')} />
|
|
170
|
+
const p2Control = p2IsHuman ? loadout.seatControl(2, p2Host, { ...controlLook, seatColor: p2Color }) : undefined
|
|
171
|
+
// A game for one player (no seatTwo) has no seat 2 button: seat 1's panel stands alone.
|
|
172
|
+
const p2Panel = game.seatTwo && <SeatTwoPanel idPrefix='p2' host={p2Host} choices={game.seatTwo} seatTwo={seatTwo} onSeatTwoChange={changeSeatTwo} cpuDifficulty={loadout.cpuDifficulty} profiles={profiles} selectedProfileId={lastSelected[2]} onProfileSelect={handleP2ProfileSelect} color={p2Color} onColorChange={handleP2ColorChange} swatches={defaultColors} takenColor={p1Color} allowSwapTaken={mode === 'vsCpu'} colorCaption={pack.colorCaption} dark={dark} secondPicker={p2Control} secondPickerId={p2Control ? 'p2-control' : undefined} ready={ready[2]} onToggleReady={() => setReady((r) => ({ ...r, 2: !r[2] }))} showReadyButton={showReadyButton} />
|
|
173
|
+
|
|
174
|
+
return (
|
|
175
|
+
<View style={[styles.container, { backgroundColor: bg }]}>
|
|
176
|
+
{/* Driven by panelLayout (the same committed, fade-masked state DualZoneLayout renders from),
|
|
177
|
+
so the rotation change and the panel swap land in the same fade-covered moment. The Settings
|
|
178
|
+
dialog stays outside: it renders through a Portal, turned by its own `rotation`. */}
|
|
179
|
+
<FakeLandscapeView orientationMode={panelLayout.orientationMode} p1OnRight={panelLayout.p1OnRight} upsideDown={panelLayout.upsideDown} style={styles.rotatable}>
|
|
180
|
+
{/* Press-away overlays for the loadout's own menus. P1's covers the whole screen (their
|
|
181
|
+
panel plus the match options read as theirs), while P2's covers only their half and, as a
|
|
182
|
+
later sibling, takes priority there. P2's mounts whenever either host has a menu open, so it
|
|
183
|
+
always shields P2's half from P1's press-away. */}
|
|
184
|
+
<PressAwayOverlay active={controlsHost.openId !== null} onPress={controlsHost.close} />
|
|
185
|
+
{p2ZoneStyle && <PressAwayOverlay active={controlsHost.openId !== null || p2Host.openId !== null} onPress={p2Host.close} style={p2ZoneStyle} />}
|
|
186
|
+
|
|
187
|
+
{!showMetaInSharedBand && <CornerActionButtons onBack={safeBack} onSettings={() => setSettingsOpen(true)} fg={fg} insets={insets} />}
|
|
188
|
+
|
|
189
|
+
{p2IsHuman ? (
|
|
190
|
+
<DualZoneLayout
|
|
191
|
+
panelLayout={panelLayout}
|
|
192
|
+
panelFadeStyle={panelFadeStyle}
|
|
193
|
+
p1={p1Panel}
|
|
194
|
+
p2={p2Panel}
|
|
195
|
+
shared={sharedControls}
|
|
196
|
+
// A zone can't self-elevate above a later sibling zone; these lift whichever zone has a
|
|
197
|
+
// menu open (see DualZoneLayout's p1Elevated/p2Elevated/sharedElevated doc).
|
|
198
|
+
p1Elevated={controlsHost.openId?.startsWith('p1-') ?? false}
|
|
199
|
+
p2Elevated={p2Host.openId !== null}
|
|
200
|
+
sharedElevated={optionsPopoverOpen}
|
|
201
|
+
/>
|
|
202
|
+
) : (
|
|
203
|
+
<View style={styles.stackedZone}>
|
|
204
|
+
{sharedControls}
|
|
205
|
+
<Animated.View style={[styles.playersRow, anyPlayerPickerOpen && styles.playersRowOpen, panelFadeStyle]}>
|
|
206
|
+
{!isPortrait && panelLayout.p1OnRight ? p2Panel : p1Panel}
|
|
207
|
+
{!isPortrait && panelLayout.p1OnRight ? p1Panel : p2Panel}
|
|
208
|
+
</Animated.View>
|
|
209
|
+
<ReadyButton color={p1Color} ready={ready[1]} onToggleReady={() => setReady((r) => ({ ...r, 1: !r[1] }))} style={anyPlayerPickerOpen && styles.readyButtonHidden} />
|
|
210
|
+
</View>
|
|
211
|
+
)}
|
|
212
|
+
</FakeLandscapeView>
|
|
213
|
+
|
|
214
|
+
<SettingsDialog visible={settingsOpen} onDismiss={() => setSettingsOpen(false)} rotation={rotation} onShowHowToPlay={() => openFor(game.id)} />
|
|
215
|
+
</View>
|
|
216
|
+
)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const styles = StyleSheet.create({
|
|
220
|
+
container: {
|
|
221
|
+
flex: 1
|
|
222
|
+
},
|
|
223
|
+
p2ZoneLeft: {
|
|
224
|
+
bottom: 0,
|
|
225
|
+
left: 0,
|
|
226
|
+
position: 'absolute',
|
|
227
|
+
top: 0,
|
|
228
|
+
width: '50%'
|
|
229
|
+
},
|
|
230
|
+
p2ZoneRight: {
|
|
231
|
+
bottom: 0,
|
|
232
|
+
position: 'absolute',
|
|
233
|
+
right: 0,
|
|
234
|
+
top: 0,
|
|
235
|
+
width: '50%'
|
|
236
|
+
},
|
|
237
|
+
p2ZoneTop: {
|
|
238
|
+
height: '50%',
|
|
239
|
+
left: 0,
|
|
240
|
+
position: 'absolute',
|
|
241
|
+
right: 0,
|
|
242
|
+
top: 0
|
|
243
|
+
},
|
|
244
|
+
// The one-human row: seat 1's panel and seat 2's button read as one evenly spaced row of buttons,
|
|
245
|
+
// at PlayerSetupPanel's own pickerRow gap (12).
|
|
246
|
+
playersRow: {
|
|
247
|
+
flexDirection: 'row',
|
|
248
|
+
gap: 12
|
|
249
|
+
},
|
|
250
|
+
playersRowOpen: {
|
|
251
|
+
zIndex: 100
|
|
252
|
+
},
|
|
253
|
+
// See anyPlayerPickerOpen: opacity 0 (not display:'none') keeps the button's layout space, and
|
|
254
|
+
// pointerEvents:'none' keeps the sliver an open menu doesn't cover from being a hidden tap target.
|
|
255
|
+
readyButtonHidden: {
|
|
256
|
+
opacity: 0,
|
|
257
|
+
pointerEvents: 'none'
|
|
258
|
+
},
|
|
259
|
+
// A real full-bleed box for FakeLandscapeView to size its absolutely-positioned children against.
|
|
260
|
+
rotatable: {
|
|
261
|
+
alignItems: 'center',
|
|
262
|
+
flex: 1,
|
|
263
|
+
gap: 32,
|
|
264
|
+
justifyContent: 'center',
|
|
265
|
+
paddingHorizontal: 16
|
|
266
|
+
},
|
|
267
|
+
stackedZone: {
|
|
268
|
+
alignItems: 'center',
|
|
269
|
+
gap: 28
|
|
270
|
+
}
|
|
271
|
+
})
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { defaultColors } from '@rific/auto-paper'
|
|
2
|
+
import { ProfilesScreen } from '@tastic/profile'
|
|
3
|
+
|
|
4
|
+
import { useProfiles } from '../hooks/useProfiles'
|
|
5
|
+
import { safeBack } from '../navigation'
|
|
6
|
+
|
|
7
|
+
// The Profiles screen (Manage, from either seat 1 button). Deleting a profile also removes its
|
|
8
|
+
// stats and achievements in every game: each game's reducers listen for the removal (see
|
|
9
|
+
// onProfileRemoved.ts).
|
|
10
|
+
export function ProfilesRoute() {
|
|
11
|
+
const { profiles, createProfile, updateProfile, deleteProfile } = useProfiles()
|
|
12
|
+
|
|
13
|
+
return <ProfilesScreen profiles={profiles} defaultColor={defaultColors[0].value} onCreate={(patch) => createProfile(patch)} onSave={(id, patch) => updateProfile(id, patch)} onDelete={deleteProfile} onBack={safeBack} />
|
|
14
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { useAutoPaperTheme } from '@rific/auto-paper'
|
|
2
|
+
import { FakeLandscapeView, rotateInsets, useRotation } from '@tastic/core'
|
|
3
|
+
import { BaseStatsScreen, PressAwayOverlay, usePopoverHost } from '@tastic/hud'
|
|
4
|
+
import { OptionPicker, type OptionPickerOption } from '@tastic/profile'
|
|
5
|
+
import { useMemo, useState } from 'react'
|
|
6
|
+
import { StyleSheet, View } from 'react-native'
|
|
7
|
+
import { SegmentedButtons, Text } from 'react-native-paper'
|
|
8
|
+
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
|
9
|
+
import { useDispatch } from 'react-redux'
|
|
10
|
+
|
|
11
|
+
import { useProfiles } from '../hooks/useProfiles'
|
|
12
|
+
import { safeBack } from '../navigation'
|
|
13
|
+
import { usePack } from '../PackContext'
|
|
14
|
+
import type { GameDefinition } from '../types'
|
|
15
|
+
|
|
16
|
+
// Two tabs, as on Solitaire's stats screen: a page with every game's stats above the achievements
|
|
17
|
+
// would leave the achievements out of sight at the bottom.
|
|
18
|
+
type StatsTab = 'stats' | 'achievements'
|
|
19
|
+
const TABS = [
|
|
20
|
+
{ value: 'stats', label: 'Stats', icon: 'chart-box-outline' },
|
|
21
|
+
{ value: 'achievements', label: 'Achievements', icon: 'trophy-outline' }
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
// The pickers' values for Everyone and All Games (neither is ever a profile or game id).
|
|
25
|
+
const EVERYONE = '__everyone__'
|
|
26
|
+
const ALL_GAMES = '__all__'
|
|
27
|
+
|
|
28
|
+
const PLAYER_PICKER_ID = 'stats-player'
|
|
29
|
+
const GAME_PICKER_ID = 'stats-game'
|
|
30
|
+
|
|
31
|
+
// Every game's stats and achievements on one screen, filtered by player (Everyone, or one profile)
|
|
32
|
+
// and by game (All Games, or one), like Solitaire's. Each game keeps its own stats and its own
|
|
33
|
+
// achievements; this screen only shows them side by side. With two seats there's no one player to
|
|
34
|
+
// open on, so it opens on Everyone. Guests count only toward Everyone: two guests in one match can't
|
|
35
|
+
// be told apart.
|
|
36
|
+
export function StatsScreen() {
|
|
37
|
+
const pack = usePack()
|
|
38
|
+
const { dark, colors } = useAutoPaperTheme()
|
|
39
|
+
// Turned with the phone like every other screen: rotateInsets remaps the device's raw safe-area
|
|
40
|
+
// reading onto the edges they land on once FakeLandscapeView turns the content.
|
|
41
|
+
const rotation = useRotation()
|
|
42
|
+
const insets = rotateInsets(useSafeAreaInsets(), rotation)
|
|
43
|
+
const { profiles } = useProfiles()
|
|
44
|
+
const dispatch = useDispatch()
|
|
45
|
+
const pickerHost = usePopoverHost()
|
|
46
|
+
|
|
47
|
+
const [tab, setTab] = useState<StatsTab>('stats')
|
|
48
|
+
const [gameId, setGameId] = useState<string>(ALL_GAMES)
|
|
49
|
+
// Self-heals to Everyone if the selected profile is deleted while this screen is mounted.
|
|
50
|
+
const [selectedProfileId, setSelectedProfileId] = useState<string | null>(null)
|
|
51
|
+
const profileId = selectedProfileId && profiles.some((p) => p.id === selectedProfileId) ? selectedProfileId : null
|
|
52
|
+
|
|
53
|
+
const games = gameId === ALL_GAMES ? pack.games : pack.games.filter((g) => g.id === gameId)
|
|
54
|
+
|
|
55
|
+
const playerOptions = useMemo<OptionPickerOption<string>[]>(() => [{ value: EVERYONE, label: 'Everyone', icon: 'account-group' }, ...profiles.map((p) => ({ value: p.id, label: p.name, icon: 'account', color: p.color }))], [profiles])
|
|
56
|
+
const gameOptions = useMemo<OptionPickerOption<string>[]>(() => [{ value: ALL_GAMES, label: 'All Games', icon: 'view-grid-outline' }, ...pack.games.map((g) => ({ value: g.id, label: g.title }))], [pack.games])
|
|
57
|
+
|
|
58
|
+
// Reset clears every player's stats and achievements in the games shown (one game, or all of them).
|
|
59
|
+
const resetScope = games.length === 1 ? games[0].title : 'every game'
|
|
60
|
+
const onReset = () => games.forEach((g) => dispatch(g.resetStats()))
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<FakeLandscapeView style={styles.rotatable}>
|
|
64
|
+
<BaseStatsScreen title={tab === 'stats' ? 'Stats' : 'Achievements'} onBack={safeBack} insets={insets} onReset={onReset} resetLabel={games.length === 1 ? `Reset ${resetScope} Stats` : 'Reset All Stats'} resetConfirmTitle='Reset Stats?' resetConfirmBody={`Erases every player's stats and achievements in ${resetScope}. This cannot be undone.`} rotation={rotation}>
|
|
65
|
+
{/* Inside the page, not before it: BaseStatsScreen paints an opaque full-screen container that
|
|
66
|
+
would hide a tap catcher placed underneath it. Over the stat sections and under the open
|
|
67
|
+
filters (pressAway's zIndex sits below filtersOpen's), so a tap anywhere on the page but a
|
|
68
|
+
menu closes it. */}
|
|
69
|
+
<PressAwayOverlay active={pickerHost.openId !== null} onPress={pickerHost.close} style={styles.pressAway} />
|
|
70
|
+
<SegmentedButtons value={tab} onValueChange={(value) => setTab(value as StatsTab)} buttons={TABS} style={styles.tabs} />
|
|
71
|
+
|
|
72
|
+
<View style={[styles.filters, pickerHost.openId !== null && styles.filtersOpen]}>
|
|
73
|
+
{profiles.length > 0 && <OptionPicker id={PLAYER_PICKER_ID} host={pickerHost} options={playerOptions} value={profileId ?? EVERYONE} onChange={(value) => setSelectedProfileId(value === EVERYONE ? null : value)} color={colors.primary} dark={dark} icon={profileId ? 'account' : 'account-group'} />}
|
|
74
|
+
<OptionPicker id={GAME_PICKER_ID} host={pickerHost} options={gameOptions} value={gameId} onChange={setGameId} color={colors.primary} dark={dark} icon='view-grid-outline' />
|
|
75
|
+
</View>
|
|
76
|
+
|
|
77
|
+
{/* The pack's own section across its games (a calendar of play), first on the Stats tab: it
|
|
78
|
+
follows both filters, so narrowed to one game it shows that game's part. */}
|
|
79
|
+
{tab === 'stats' && pack.StatsSections && <pack.StatsSections profileId={profileId} gameId={gameId === ALL_GAMES ? null : gameId} />}
|
|
80
|
+
|
|
81
|
+
{games.map((game) => (
|
|
82
|
+
<GameStats key={game.id} game={game} tab={tab} profileId={profileId} showTitle={games.length > 1} />
|
|
83
|
+
))}
|
|
84
|
+
</BaseStatsScreen>
|
|
85
|
+
</FakeLandscapeView>
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// One game's part of the page: its own sections, under its name when more than one game is shown.
|
|
90
|
+
function GameStats({ game, tab, profileId, showTitle }: { game: GameDefinition; tab: StatsTab; profileId: string | null; showTitle: boolean }) {
|
|
91
|
+
const { colors } = useAutoPaperTheme()
|
|
92
|
+
const { StatsSections, AchievementList } = game
|
|
93
|
+
return (
|
|
94
|
+
<View style={styles.game}>
|
|
95
|
+
{showTitle && (
|
|
96
|
+
<Text variant='titleLarge' style={[styles.gameTitle, { color: colors.onBackground }]}>
|
|
97
|
+
{game.title}
|
|
98
|
+
</Text>
|
|
99
|
+
)}
|
|
100
|
+
{tab === 'stats' ? <StatsSections profileId={profileId} /> : <AchievementList profileId={profileId} />}
|
|
101
|
+
</View>
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const styles = StyleSheet.create({
|
|
106
|
+
filters: {
|
|
107
|
+
flexDirection: 'row',
|
|
108
|
+
flexWrap: 'wrap',
|
|
109
|
+
gap: 12,
|
|
110
|
+
justifyContent: 'center'
|
|
111
|
+
},
|
|
112
|
+
filtersOpen: {
|
|
113
|
+
zIndex: 100
|
|
114
|
+
},
|
|
115
|
+
game: {
|
|
116
|
+
gap: 16
|
|
117
|
+
},
|
|
118
|
+
gameTitle: {
|
|
119
|
+
fontWeight: 'bold',
|
|
120
|
+
marginTop: 8
|
|
121
|
+
},
|
|
122
|
+
pressAway: {
|
|
123
|
+
zIndex: 50
|
|
124
|
+
},
|
|
125
|
+
// A real full-bleed box for FakeLandscapeView to size its turned content against.
|
|
126
|
+
rotatable: {
|
|
127
|
+
flex: 1
|
|
128
|
+
},
|
|
129
|
+
// SegmentedButtons' own rounding.
|
|
130
|
+
tabs: {
|
|
131
|
+
borderRadius: 20
|
|
132
|
+
}
|
|
133
|
+
})
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { clamp, rotateInsets, useRotatedWindowDimensions, useRotation } from '@tastic/core'
|
|
2
|
+
import { type EdgeInsets, useSafeAreaInsets } from 'react-native-safe-area-context'
|
|
3
|
+
|
|
4
|
+
// Home's geometry for a pack whose games have previews (GameDefinition.Preview), ported from
|
|
5
|
+
// Solitaire's components/homeLayout.ts. Upright, Home is the one scrolling column every pack has: the
|
|
6
|
+
// hero, seat 1's button, then a row per game, each row's preview at its start. Held sideways a phone
|
|
7
|
+
// is only ~400pt tall, and that column would spend the whole height on the hero before showing a
|
|
8
|
+
// single game, so a "wide" layout (any window whose ROTATED footprint is landscape: a phone held
|
|
9
|
+
// sideways, an iPad, a desktop browser) splits it into two panes: the hero and seat 1's button on the
|
|
10
|
+
// left, and a scrolling grid of game tiles on the right. The trophy (top-left) and cog (top-right)
|
|
11
|
+
// corner buttons stay where they are; the right pane stops short of the cog so tiles never scroll
|
|
12
|
+
// underneath it. A pack without previews keeps the column at every size.
|
|
13
|
+
//
|
|
14
|
+
// Everything here works in the ROTATED frame's own coordinates (Home's FakeLandscapeView): `width`/
|
|
15
|
+
// `height` are the rotated footprint and `insets` are the safe-area insets already remapped onto the
|
|
16
|
+
// frame's edges (rotateInsets). The rotated top/bottom insets are ~0 under a portrait-locked OS (a
|
|
17
|
+
// phone held sideways only has left/right insets), so nothing here leans on them for breathing room.
|
|
18
|
+
|
|
19
|
+
// Every preview's width / height: Solitaire's preview canvas (360 x 200), so its deals fit as drawn.
|
|
20
|
+
export const PREVIEW_ASPECT = 360 / 200
|
|
21
|
+
// A preview's width at the start of an upright row, where every other pack's icon sits.
|
|
22
|
+
export const ROW_PREVIEW_WIDTH = 92
|
|
23
|
+
|
|
24
|
+
// Wide layout: gap between the safe edge and the left pane.
|
|
25
|
+
export const HOME_WIDE_EDGE_PADDING = 16
|
|
26
|
+
// Wide layout: gap between the two panes.
|
|
27
|
+
export const HOME_WIDE_PANE_GAP = 16
|
|
28
|
+
// Wide layout: width reserved on the trailing side for the cog corner button (8pt margin, a 40pt
|
|
29
|
+
// IconButton, 8pt of air) ON TOP of the trailing safe-area inset.
|
|
30
|
+
export const HOME_WIDE_RIGHT_GUTTER = 56
|
|
31
|
+
// Cap on the two panes' combined width on a very wide window; past it the leftover is split into
|
|
32
|
+
// equal empty margins on both sides.
|
|
33
|
+
export const HOME_WIDE_MAX_CONTENT_WIDTH = 1100
|
|
34
|
+
export const HOME_LEFT_PANE_FRACTION = 0.38
|
|
35
|
+
export const HOME_LEFT_PANE_MIN = 260
|
|
36
|
+
export const HOME_LEFT_PANE_MAX = 340
|
|
37
|
+
// Gap between grid tiles (both axes).
|
|
38
|
+
export const HOME_GRID_GAP = 12
|
|
39
|
+
// Narrowest a wide-layout tile may get before the grid drops a column, and the most columns it uses.
|
|
40
|
+
export const HOME_MIN_TILE_WIDTH = 140
|
|
41
|
+
export const HOME_MAX_COLUMNS = 3
|
|
42
|
+
// Padding inside a wide-layout tile around its preview and text.
|
|
43
|
+
export const HOME_TILE_PADDING = 10
|
|
44
|
+
// Vertical band the corner buttons occupy at the top of a pane (8pt margin + 40pt button + 8pt air).
|
|
45
|
+
export const HOME_TOP_BAND = 56
|
|
46
|
+
// What the left pane stacks under the hero: seat 1's button (44, a 32pt chip in a pill padded 6pt)
|
|
47
|
+
// and the gap above it, and the pane's own bottom padding.
|
|
48
|
+
export const HOME_LEFT_PANE_PICKER_HEIGHT = 44
|
|
49
|
+
export const HOME_LEFT_PANE_PICKER_GAP = 12
|
|
50
|
+
const HOME_LEFT_PANE_BOTTOM_PADDING = 16
|
|
51
|
+
// The hero keeps this much clear of the left pane's sides.
|
|
52
|
+
const HOME_HERO_SIDE_ROOM = 16
|
|
53
|
+
|
|
54
|
+
export interface HomeLayout {
|
|
55
|
+
// The rotated footprint is landscape: the two-pane layout is in effect (for a pack with previews).
|
|
56
|
+
isWide: boolean
|
|
57
|
+
// The window's footprint AFTER rotation.
|
|
58
|
+
windowWidth: number
|
|
59
|
+
windowHeight: number
|
|
60
|
+
// Safe-area insets remapped onto the rotated frame's own edges (see @tastic/core's rotateInsets).
|
|
61
|
+
insets: EdgeInsets
|
|
62
|
+
// Wide layout: padding on the two panes' row. Each side hugs ITS OWN inset (a phone held sideways
|
|
63
|
+
// has a large inset only on the island side), the trailing one also reserves the cog's gutter, and
|
|
64
|
+
// both carry the equal margin left over once the content hits its cap. 0 upright.
|
|
65
|
+
paddingLeft: number
|
|
66
|
+
paddingRight: number
|
|
67
|
+
leftPaneWidth: number
|
|
68
|
+
rightPaneWidth: number
|
|
69
|
+
columns: number
|
|
70
|
+
// Outer width of one wide-layout tile, and of the preview inside it.
|
|
71
|
+
tileWidth: number
|
|
72
|
+
previewWidth: number
|
|
73
|
+
// The box the hero has to fit in beside the grid (HeroTitleProps.fit); undefined upright.
|
|
74
|
+
heroFit: { width: number; height: number } | undefined
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Pure form of useHomeLayout, for tests.
|
|
78
|
+
export function computeHomeLayout(windowWidth: number, windowHeight: number, insets: EdgeInsets): HomeLayout {
|
|
79
|
+
const isWide = windowWidth > windowHeight
|
|
80
|
+
if (!isWide) {
|
|
81
|
+
return { isWide, windowWidth, windowHeight, insets, paddingLeft: 0, paddingRight: 0, leftPaneWidth: 0, rightPaneWidth: 0, columns: 1, tileWidth: 0, previewWidth: 0, heroFit: undefined }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const usable = windowWidth - insets.left - insets.right
|
|
85
|
+
const inner = Math.max(0, usable - HOME_WIDE_EDGE_PADDING - HOME_WIDE_RIGHT_GUTTER)
|
|
86
|
+
const content = Math.min(inner, HOME_WIDE_MAX_CONTENT_WIDTH)
|
|
87
|
+
const slack = (inner - content) / 2
|
|
88
|
+
|
|
89
|
+
// The left pane is a fraction of the content (within its own floor/ceiling), but never so wide that
|
|
90
|
+
// the grid is left without room for at least one tile: a tiny window degrades to one narrow column,
|
|
91
|
+
// never a negative width.
|
|
92
|
+
const leftPaneWidth = Math.min(clamp(Math.round(HOME_LEFT_PANE_FRACTION * content), HOME_LEFT_PANE_MIN, HOME_LEFT_PANE_MAX), Math.max(0, content - HOME_WIDE_PANE_GAP - HOME_MIN_TILE_WIDTH))
|
|
93
|
+
const rightPaneWidth = Math.max(0, content - HOME_WIDE_PANE_GAP - leftPaneWidth)
|
|
94
|
+
const columns = clamp(Math.floor((rightPaneWidth + HOME_GRID_GAP) / (HOME_MIN_TILE_WIDTH + HOME_GRID_GAP)), 1, HOME_MAX_COLUMNS)
|
|
95
|
+
const tileWidth = Math.max(0, Math.floor((rightPaneWidth - (columns - 1) * HOME_GRID_GAP) / columns))
|
|
96
|
+
const previewWidth = Math.max(0, tileWidth - 2 * HOME_TILE_PADDING)
|
|
97
|
+
|
|
98
|
+
// The hero gets the pane's width and whatever height the pane has left once the corner buttons'
|
|
99
|
+
// band, seat 1's button and the bottom padding are taken: how it shrinks to fit is the pack's own.
|
|
100
|
+
const heroFit = {
|
|
101
|
+
width: Math.max(0, leftPaneWidth - HOME_HERO_SIDE_ROOM),
|
|
102
|
+
height: Math.max(0, windowHeight - insets.top - insets.bottom - HOME_TOP_BAND - HOME_LEFT_PANE_PICKER_GAP - HOME_LEFT_PANE_PICKER_HEIGHT - HOME_LEFT_PANE_BOTTOM_PADDING)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
isWide,
|
|
107
|
+
windowWidth,
|
|
108
|
+
windowHeight,
|
|
109
|
+
insets,
|
|
110
|
+
paddingLeft: insets.left + HOME_WIDE_EDGE_PADDING + slack,
|
|
111
|
+
paddingRight: insets.right + HOME_WIDE_RIGHT_GUTTER + slack,
|
|
112
|
+
leftPaneWidth,
|
|
113
|
+
rightPaneWidth,
|
|
114
|
+
columns,
|
|
115
|
+
tileWidth,
|
|
116
|
+
previewWidth,
|
|
117
|
+
heroFit
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// The live layout for the current window and rotation. Reads useRotatedWindowDimensions()/
|
|
122
|
+
// rotateInsets() rather than raw useWindowDimensions()/useSafeAreaInsets(), so it describes the
|
|
123
|
+
// footprint a rotated Home actually renders into (both are exact identities at 0deg).
|
|
124
|
+
export function useHomeLayout(): HomeLayout {
|
|
125
|
+
const { width, height } = useRotatedWindowDimensions()
|
|
126
|
+
const rotation = useRotation()
|
|
127
|
+
const insets = rotateInsets(useSafeAreaInsets(), rotation)
|
|
128
|
+
return computeHomeLayout(width, height, insets)
|
|
129
|
+
}
|