@tastic/shell 0.1.1 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +71 -12
- package/dist/index.d.mts +86 -14
- package/dist/index.d.ts +86 -14
- package/dist/index.js +337 -165
- package/dist/index.mjs +327 -156
- package/package.json +9 -9
- package/src/PackContext.tsx +2 -2
- package/src/RootLayout.tsx +110 -28
- package/src/components/GuideHost.tsx +62 -23
- package/src/components/Providers.tsx +17 -7
- package/src/components/SeatTwoPanel.tsx +2 -1
- package/src/components/SettingsDialog.tsx +6 -4
- package/src/components/UpdateDialog.tsx +1 -1
- package/src/hooks/useSeatColors.ts +54 -27
- package/src/index.ts +1 -1
- package/src/redux/createPackStore.ts +16 -6
- package/src/redux/shellState.ts +4 -2
- package/src/routes.ts +10 -0
- package/src/screens/GameRoute.tsx +15 -7
- package/src/screens/HomeScreen.tsx +145 -76
- package/src/screens/LoadoutScreen.tsx +13 -7
- package/src/screens/ProfilesRoute.tsx +26 -1
- package/src/screens/StatsScreen.tsx +138 -56
- package/src/types.ts +143 -10
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import { defaultColors, useAutoPaperTheme } from '@rific/auto-paper'
|
|
1
|
+
import { defaultColors, FadingScrollView, useAutoPaperTheme } from '@rific/auto-paper'
|
|
2
2
|
import { IconButton, Pressable } from '@rific/feedback-press'
|
|
3
3
|
import { FakeLandscapeView, useRotation } from '@tastic/core'
|
|
4
|
-
import { PressAwayOverlay, usePopoverHost, useWellColors, WELL_SHADOW } from '@tastic/hud'
|
|
5
|
-
import { type Profile, ProfilePicker } from '@tastic/profile'
|
|
4
|
+
import { PressAwayOverlay, usePopoverHost, useWellColors, WELL_SHADOW } from '@tastic/hud/chrome'
|
|
5
|
+
import { getProfilePickerContentSize, type Profile, ProfilePicker, type ProfilePickerColorSection } from '@tastic/profile'
|
|
6
6
|
import { Redirect, router, useIsFocused } from 'expo-router'
|
|
7
|
-
import { useCallback, useEffect, useState } from 'react'
|
|
8
|
-
import { AppState, type AppStateStatus,
|
|
9
|
-
import { Icon, Text } from 'react-native-paper'
|
|
7
|
+
import { type ReactNode, useCallback, useEffect, useMemo, useState } from 'react'
|
|
8
|
+
import { AppState, type AppStateStatus, StyleSheet, View } from 'react-native'
|
|
9
|
+
import { Icon, Portal, Text } from 'react-native-paper'
|
|
10
10
|
|
|
11
11
|
import { useGameGuide } from '../components/GuideHost'
|
|
12
12
|
import { SettingsDialog } from '../components/SettingsDialog'
|
|
@@ -16,7 +16,8 @@ import { useOpponent } from '../hooks/useOpponent'
|
|
|
16
16
|
import { useProfiles } from '../hooks/useProfiles'
|
|
17
17
|
import { useSeatColors } from '../hooks/useSeatColors'
|
|
18
18
|
import { usePack } from '../PackContext'
|
|
19
|
-
import
|
|
19
|
+
import { loadoutHref, playHref, PROFILES_HREF, STATS_HREF } from '../routes'
|
|
20
|
+
import type { PackGameDefinition } from '../types'
|
|
20
21
|
import { HOME_GRID_GAP, HOME_LEFT_PANE_PICKER_GAP, HOME_TILE_PADDING, HOME_TOP_BAND, HOME_WIDE_PANE_GAP, PREVIEW_ASPECT, ROW_PREVIEW_WIDTH, useHomeLayout } from './homeLayout'
|
|
21
22
|
|
|
22
23
|
// Home: who's playing (seat 1, the same seat 1 every loadout starts with), then every game in the
|
|
@@ -26,10 +27,13 @@ import { HOME_GRID_GAP, HOME_LEFT_PANE_PICKER_GAP, HOME_TILE_PADDING, HOME_TOP_B
|
|
|
26
27
|
// picked. Seat 2 is chosen in the loadout, from seat 2's own button.
|
|
27
28
|
//
|
|
28
29
|
// A cold launch that finds the player left the app inside a resumable game's play screen
|
|
29
|
-
// (GameDefinition.resumable) goes straight back there instead, before the menu ever paints.
|
|
30
|
+
// (GameDefinition.resumable) goes straight back there instead, before the menu ever paints. The
|
|
31
|
+
// pack's own How to Play (PackDefinition.guide) shows by itself on a first launch, held back while
|
|
32
|
+
// that launch is about to drop into a game: it must never open over a game in progress.
|
|
30
33
|
export function HomeScreen() {
|
|
31
34
|
const pack = usePack()
|
|
32
35
|
const { consumeResumeGame } = useLastActiveGame()
|
|
36
|
+
const { autoShowPack } = useGameGuide()
|
|
33
37
|
// A one-shot cold-launch decision, read exactly once at this instance's mount (see
|
|
34
38
|
// hooks/useLastActiveGame.tsx), never a live value this screen re-renders from. Reading it live made
|
|
35
39
|
// every Home in the stack swap its whole menu for a Redirect while a game was on top and rebuild it
|
|
@@ -39,7 +43,10 @@ export function HomeScreen() {
|
|
|
39
43
|
const id = consumeResumeGame()
|
|
40
44
|
return pack.games.find((g) => g.id === id && g.resumable) ?? null
|
|
41
45
|
})
|
|
42
|
-
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
if (!resumeGame) autoShowPack()
|
|
48
|
+
}, [resumeGame, autoShowPack])
|
|
49
|
+
if (resumeGame) return <Redirect href={playHref(resumeGame.id)} />
|
|
43
50
|
return <HomeMenu />
|
|
44
51
|
}
|
|
45
52
|
|
|
@@ -48,9 +55,11 @@ function HomeMenu() {
|
|
|
48
55
|
const rotation = useRotation()
|
|
49
56
|
const layout = useHomeLayout()
|
|
50
57
|
const { insets } = layout
|
|
51
|
-
const { dark } = useAutoPaperTheme()
|
|
52
|
-
const { openFirstTime, openFor } = useGameGuide()
|
|
58
|
+
const { dark, colors } = useAutoPaperTheme()
|
|
59
|
+
const { openFirstTime, openFor, openPack } = useGameGuide()
|
|
53
60
|
const [settingsOpen, setSettingsOpen] = useState(false)
|
|
61
|
+
// The game whose rules sheet (GameDefinition.Rules) is open.
|
|
62
|
+
const [rulesGame, setRulesGame] = useState<PackGameDefinition | null>(null)
|
|
54
63
|
const isFocused = useIsFocused()
|
|
55
64
|
const [appState, setAppState] = useState<AppStateStatus>(AppState.currentState)
|
|
56
65
|
useEffect(() => {
|
|
@@ -84,82 +93,135 @@ function HomeMenu() {
|
|
|
84
93
|
const id = requestAnimationFrame(() => setPreviewsRevealed(true))
|
|
85
94
|
return () => cancelAnimationFrame(id)
|
|
86
95
|
}, [tiled])
|
|
87
|
-
// A popover is placed once, as it opens
|
|
88
|
-
// the
|
|
89
|
-
//
|
|
96
|
+
// A popover is placed once, as it opens, from measurements of the frame it opens in. Turning the
|
|
97
|
+
// phone (or any resize) moves the frame under it, and switching between the column and the two panes
|
|
98
|
+
// unmounts the very button it hangs off, which would leave the press-away overlay up with nothing to
|
|
99
|
+
// dismiss. So every layout change closes it.
|
|
90
100
|
const closePicker = pickerHost.close
|
|
91
101
|
useEffect(() => {
|
|
92
102
|
closePicker()
|
|
93
|
-
}, [isWide, closePicker])
|
|
103
|
+
}, [isWide, rotation, layout.windowWidth, layout.windowHeight, closePicker])
|
|
94
104
|
|
|
95
|
-
|
|
96
|
-
const
|
|
105
|
+
// Over the pack's Backdrop Home draws nothing of its own behind the menu.
|
|
106
|
+
const bg = pack.Backdrop ? 'transparent' : dark ? '#000000' : '#FFFFFF'
|
|
107
|
+
const fg = pack.foreground ?? (dark ? '#FFFFFF' : '#000000')
|
|
108
|
+
// Seat 1's color as the pack draws profile colors (PackDefinition.previewColor), and the button on
|
|
109
|
+
// the theme's surface over a backdrop, so neither washes out on it.
|
|
110
|
+
const { previewColor } = pack
|
|
111
|
+
const p1Drawn = previewColor ? previewColor(p1Color, dark) : p1Color
|
|
112
|
+
const triggerStyle = useMemo(() => (pack.Backdrop ? [styles.pill, { backgroundColor: colors.surface }] : undefined), [pack.Backdrop, colors.surface])
|
|
97
113
|
|
|
98
114
|
// A game with nothing to set up (no useLoadout) opens straight to its play screen.
|
|
99
|
-
const openGame = useCallback((game:
|
|
115
|
+
const openGame = useCallback((game: PackGameDefinition) => openFirstTime(game.id, () => router.push(game.useLoadout ? loadoutHref(game.id) : playHref(game.id))), [openFirstTime])
|
|
116
|
+
|
|
117
|
+
// A tile's rules button: the game's rules sheet where it has one, else its How to Play cards.
|
|
118
|
+
const openRules = useCallback((game: PackGameDefinition) => (game.Rules ? setRulesGame(game) : openFor(game.id)), [openFor])
|
|
119
|
+
|
|
120
|
+
const colorSection: ProfilePickerColorSection = { value: p1Color, onChange: handleP1ColorChange, swatches: defaultColors, caption: selectedProfile ? pack.colorCaption : undefined, previewValue: previewColor ? (hex: string) => previewColor(hex, dark) : undefined }
|
|
121
|
+
// The pack's own placement for the menu (PackDefinition.usePickerAlign), sized from the same rows
|
|
122
|
+
// and color page the picker draws; hud's own without one. The pack never changes while it runs.
|
|
123
|
+
const usePickerAlign = pack.usePickerAlign ?? noPickerAlign
|
|
124
|
+
const pickerSize = getProfilePickerContentSize(profiles.length, true, colorSection)
|
|
125
|
+
const pickerAlign = usePickerAlign(pickerHost.openId === `${SEAT_ONE_PICKER}-profile`, pickerSize.width, pickerSize.height)
|
|
126
|
+
|
|
127
|
+
const picker = <ProfilePicker idPrefix={SEAT_ONE_PICKER} host={pickerHost} profiles={profiles} selectedId={lastSelected[1]} color={p1Drawn} dark={dark} guestLabel={pack.guestLabel ?? 'P1'} nullLabel={pack.nullLabel} onSelect={(profile: Profile | null) => selectProfile(1, profile?.id ?? null)} onManage={() => router.push(PROFILES_HREF)} colorSection={colorSection} alignOverride={pickerAlign} triggerStyle={triggerStyle} />
|
|
100
128
|
|
|
101
|
-
|
|
129
|
+
// Closes seat 1's menu on a press anywhere but the menu and its button. Where it's mounted matters on
|
|
130
|
+
// a phone, where zIndex only orders the children of one parent: the menu lives inside the scroller in
|
|
131
|
+
// the upright layout, so the overlay goes there too, under the button's wrapper (raised while the menu
|
|
132
|
+
// is open) and over the rows. As the frame's first child it sat under the whole scroller, and a press
|
|
133
|
+
// on a row played the game. In the wide layout the left pane is itself the frame's child, so the
|
|
134
|
+
// overlay stays there, under the pane and over the grid.
|
|
135
|
+
const pressAway = <PressAwayOverlay active={pickerHost.openId !== null} onPress={pickerHost.close} style={styles.pressAway} />
|
|
136
|
+
const RulesSheet = rulesGame?.Rules
|
|
102
137
|
|
|
103
138
|
return (
|
|
104
139
|
<View style={[styles.container, { backgroundColor: bg }]}>
|
|
105
140
|
{/* Reads correctly however the phone is held: see @tastic/core's FakeLandscapeView. The
|
|
106
|
-
Settings dialog stays outside it: it renders through a Portal, turned by its own `rotation`.
|
|
141
|
+
Settings dialog stays outside it: it renders through a Portal, turned by its own `rotation`. A
|
|
142
|
+
game's rules sheet renders inside it instead (see RulesHost). */}
|
|
107
143
|
<FakeLandscapeView style={styles.rotatable}>
|
|
108
|
-
<
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
144
|
+
<RulesHost enabled={pack.games.some((game) => game.Rules)}>
|
|
145
|
+
{isWide ? (
|
|
146
|
+
<>
|
|
147
|
+
{pressAway}
|
|
148
|
+
{/* Right pane: the game grid, scrolling on its own beside the left pane. The scroller runs
|
|
112
149
|
to the frame's right edge and the cog's gutter is its content's padding, so a web scrollbar
|
|
113
|
-
sits at the window's edge.
|
|
114
|
-
|
|
115
|
-
<
|
|
116
|
-
{
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
150
|
+
sits at the window's edge. Both scrollers fade out at an edge while there is more past it
|
|
151
|
+
(auto-paper's FadingScrollView, a mask, so whatever is behind shows through). */}
|
|
152
|
+
<FadingScrollView style={[styles.wideRightPane, { left: layout.paddingLeft + layout.leftPaneWidth + HOME_WIDE_PANE_GAP }]} contentContainerStyle={{ paddingTop: 16 + insets.top, paddingBottom: 16 + insets.bottom, paddingRight: layout.paddingRight }}>
|
|
153
|
+
<View style={[styles.grid, { width: layout.rightPaneWidth }]}>
|
|
154
|
+
{pack.games.map((game) => (
|
|
155
|
+
<GameTile key={game.id} game={game} width={layout.tileWidth} previewWidth={layout.previewWidth} previewRevealed={previewsRevealed} dark={dark} p1Color={p1Color} p2Color={p2Color} onPress={() => openGame(game)} onRules={hasRules(game) ? () => openRules(game) : undefined} />
|
|
156
|
+
))}
|
|
157
|
+
</View>
|
|
158
|
+
</FadingScrollView>
|
|
159
|
+
{/* Left pane: the hero, then seat 1's button, centred as one group under the corner
|
|
122
160
|
buttons' band. A later sibling of the grid, lifted while seat 1's menu is open so the menu
|
|
123
161
|
paints over the tiles. box-none: only its controls claim taps. */}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
162
|
+
<View style={[styles.wideLeftPane, { left: layout.paddingLeft, width: layout.leftPaneWidth, paddingTop: HOME_TOP_BAND + insets.top, paddingBottom: 16 + insets.bottom }, pickerHost.openId !== null && styles.pickerOpen]}>
|
|
163
|
+
<pack.HeroTitle words={pack.wordmark} color={fg} p1Color={p1Color} p2Color={p2Color} active={heroActive} fit={layout.heroFit} />
|
|
164
|
+
<View style={styles.widePicker}>{picker}</View>
|
|
165
|
+
</View>
|
|
166
|
+
</>
|
|
167
|
+
) : (
|
|
168
|
+
<FadingScrollView style={styles.scroll} contentContainerStyle={[styles.content, { paddingTop: 56 + insets.top, paddingBottom: 24 + insets.bottom, paddingLeft: 16 + insets.left, paddingRight: 16 + insets.right }]}>
|
|
169
|
+
{pressAway}
|
|
170
|
+
<pack.HeroTitle words={pack.wordmark} color={fg} p1Color={p1Color} p2Color={p2Color} active={heroActive} />
|
|
132
171
|
|
|
133
|
-
|
|
172
|
+
{/* Who's playing: seat 1's button, the same one each loadout starts with. Lifted above the
|
|
134
173
|
tiles while its menu is open, so the menu paints over them. */}
|
|
135
|
-
|
|
174
|
+
<View style={[styles.picker, pickerHost.openId !== null && styles.pickerOpen]}>{picker}</View>
|
|
136
175
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
176
|
+
<View style={styles.list}>
|
|
177
|
+
{pack.games.map((game) => (
|
|
178
|
+
<GameRow key={game.id} game={game} dark={dark} p1Color={p1Color} p2Color={p2Color} previewRevealed={previewsRevealed} onPress={() => openGame(game)} onRules={hasRules(game) ? () => openRules(game) : undefined} />
|
|
179
|
+
))}
|
|
180
|
+
</View>
|
|
181
|
+
</FadingScrollView>
|
|
182
|
+
)}
|
|
144
183
|
|
|
145
|
-
|
|
146
|
-
|
|
184
|
+
<IconButton icon='trophy' iconColor={fg} size={24} style={[styles.corner, { top: 8 + insets.top, left: 8 + insets.left }]} onPress={() => router.push(STATS_HREF)} accessibilityLabel='Stats & Achievements' />
|
|
185
|
+
<IconButton icon='cog' iconColor={fg} size={24} style={[styles.corner, { top: 8 + insets.top, right: 8 + insets.right }]} onPress={() => setSettingsOpen(true)} accessibilityLabel='Settings' />
|
|
186
|
+
{RulesSheet && <RulesSheet visible onDismiss={() => setRulesGame(null)} />}
|
|
187
|
+
</RulesHost>
|
|
147
188
|
</FakeLandscapeView>
|
|
148
189
|
|
|
149
|
-
|
|
190
|
+
{/* Home's How to Play row replays the pack's own cards (PackDefinition.guide); without them each
|
|
191
|
+
tile's rules button shows its game's. */}
|
|
192
|
+
<SettingsDialog visible={settingsOpen} onDismiss={() => setSettingsOpen(false)} rotation={rotation} onShowHowToPlay={pack.guide ? openPack : undefined} />
|
|
150
193
|
</View>
|
|
151
194
|
)
|
|
152
195
|
}
|
|
153
196
|
|
|
197
|
+
// Seat 1's button's popover id prefix (its menu is `${SEAT_ONE_PICKER}-profile`).
|
|
198
|
+
const SEAT_ONE_PICKER = 'home-p1'
|
|
199
|
+
|
|
200
|
+
// No placement of the pack's own: hud places the menu itself.
|
|
201
|
+
const noPickerAlign = () => undefined
|
|
202
|
+
|
|
203
|
+
// Whether a tile has a rules button: a rules sheet or How to Play cards to show.
|
|
204
|
+
function hasRules(game: PackGameDefinition): boolean {
|
|
205
|
+
return !!(game.Rules || game.guide)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// A game's rules sheet (GameDefinition.Rules) portals into a Paper host inside Home's turned frame, so
|
|
209
|
+
// it's turned with the frame and laid out against it (a sheet laid out against the upright window ran
|
|
210
|
+
// off a phone held sideways). Only in a pack with one: a pack without keeps the frame as it was.
|
|
211
|
+
function RulesHost({ enabled, children }: { enabled: boolean; children: ReactNode }) {
|
|
212
|
+
return enabled ? <Portal.Host>{children}</Portal.Host> : <>{children}</>
|
|
213
|
+
}
|
|
214
|
+
|
|
154
215
|
interface GameRowProps {
|
|
155
|
-
game:
|
|
216
|
+
game: PackGameDefinition
|
|
156
217
|
dark: boolean
|
|
157
218
|
p1Color: string
|
|
158
219
|
p2Color: string
|
|
159
220
|
// Whether Home has painted once, so a preview may draw (see previewsRevealed).
|
|
160
221
|
previewRevealed: boolean
|
|
161
222
|
onPress: () => void
|
|
162
|
-
|
|
223
|
+
// Undefined for a game with no rules sheet and no How to Play cards: no rules button.
|
|
224
|
+
onRules?: () => void
|
|
163
225
|
}
|
|
164
226
|
|
|
165
227
|
// One game on its own well: its icon at app-icon size (the art was drawn as an app icon), or its
|
|
@@ -173,13 +235,7 @@ function GameRow({ game, dark, p1Color, p2Color, previewRevealed, onPress, onRul
|
|
|
173
235
|
// inside a button is invalid on the web.
|
|
174
236
|
<View>
|
|
175
237
|
<Pressable onPress={onPress} style={[styles.row, { backgroundColor: well.bg }]} accessibilityRole='button' accessibilityLabel={game.title}>
|
|
176
|
-
{game.Preview ?
|
|
177
|
-
<PreviewBox game={game} width={ROW_PREVIEW_WIDTH} revealed={previewRevealed} dark={dark} p1Color={p1Color} p2Color={p2Color} />
|
|
178
|
-
) : (
|
|
179
|
-
<View style={[styles.rowIcon, { backgroundColor: dark ? ICON_DARK_BG : ICON_LIGHT_BG }]}>
|
|
180
|
-
<GameIcon size={ROW_ICON_SIZE} p1Color={p1Color} p2Color={p2Color} dark={dark} />
|
|
181
|
-
</View>
|
|
182
|
-
)}
|
|
238
|
+
{game.Preview ? <PreviewBox game={game} width={ROW_PREVIEW_WIDTH} revealed={previewRevealed} dark={dark} p1Color={p1Color} p2Color={p2Color} /> : <View style={[styles.rowIcon, { backgroundColor: dark ? ICON_DARK_BG : ICON_LIGHT_BG }]}>{GameIcon && <GameIcon size={ROW_ICON_SIZE} p1Color={p1Color} p2Color={p2Color} dark={dark} />}</View>}
|
|
183
239
|
<View style={styles.rowText}>
|
|
184
240
|
<Text variant='titleLarge' style={[styles.rowTitle, { color: well.fg }]} numberOfLines={1}>
|
|
185
241
|
{game.title}
|
|
@@ -189,9 +245,11 @@ function GameRow({ game, dark, p1Color, p2Color, previewRevealed, onPress, onRul
|
|
|
189
245
|
</Text>
|
|
190
246
|
</View>
|
|
191
247
|
</Pressable>
|
|
192
|
-
|
|
193
|
-
<
|
|
194
|
-
|
|
248
|
+
{onRules && (
|
|
249
|
+
<Pressable onPress={onRules} style={styles.rulesButton} hitSlop={12} accessibilityRole='button' accessibilityLabel={`${game.title} rules`}>
|
|
250
|
+
<Icon source='information-outline' size={22} color={well.fgMuted} />
|
|
251
|
+
</Pressable>
|
|
252
|
+
)}
|
|
195
253
|
</View>
|
|
196
254
|
)
|
|
197
255
|
}
|
|
@@ -217,15 +275,17 @@ function GameTile({ game, width, previewWidth, dark, p1Color, p2Color, previewRe
|
|
|
217
275
|
{game.blurb}
|
|
218
276
|
</Text>
|
|
219
277
|
</Pressable>
|
|
220
|
-
|
|
221
|
-
<
|
|
222
|
-
|
|
278
|
+
{onRules && (
|
|
279
|
+
<Pressable onPress={onRules} style={styles.tileRulesButton} hitSlop={12} accessibilityRole='button' accessibilityLabel={`${game.title} rules`}>
|
|
280
|
+
<Icon source='information-outline' size={20} color='#FFFFFF' />
|
|
281
|
+
</Pressable>
|
|
282
|
+
)}
|
|
223
283
|
</View>
|
|
224
284
|
)
|
|
225
285
|
}
|
|
226
286
|
|
|
227
287
|
interface PreviewBoxProps {
|
|
228
|
-
game:
|
|
288
|
+
game: PackGameDefinition
|
|
229
289
|
width: number
|
|
230
290
|
revealed: boolean
|
|
231
291
|
dark: boolean
|
|
@@ -241,13 +301,13 @@ function PreviewBox({ game, width, revealed, dark, p1Color, p2Color }: PreviewBo
|
|
|
241
301
|
const iconSize = Math.min(height, ROW_ICON_SIZE)
|
|
242
302
|
return (
|
|
243
303
|
<View style={[styles.preview, { width, height, backgroundColor: dark ? ICON_DARK_BG : ICON_LIGHT_BG }]}>
|
|
244
|
-
{Preview
|
|
245
|
-
revealed && <Preview width={width} height={height} p1Color={p1Color} p2Color={p2Color} dark={dark} />
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
304
|
+
{Preview
|
|
305
|
+
? revealed && <Preview width={width} height={height} p1Color={p1Color} p2Color={p2Color} dark={dark} />
|
|
306
|
+
: GameIcon && (
|
|
307
|
+
<View style={[styles.previewIcon, { width: iconSize, height: iconSize, borderRadius: iconSize * 0.22 }]}>
|
|
308
|
+
<GameIcon size={iconSize} p1Color={p1Color} p2Color={p2Color} dark={dark} />
|
|
309
|
+
</View>
|
|
310
|
+
)}
|
|
251
311
|
</View>
|
|
252
312
|
)
|
|
253
313
|
}
|
|
@@ -289,6 +349,15 @@ const styles = StyleSheet.create({
|
|
|
289
349
|
pickerOpen: {
|
|
290
350
|
zIndex: 100
|
|
291
351
|
},
|
|
352
|
+
// Seat 1's button on the theme's surface (over a backdrop), the same pill as the tiles' wells.
|
|
353
|
+
pill: {
|
|
354
|
+
borderRadius: 999
|
|
355
|
+
},
|
|
356
|
+
// Over the rows (and, wide, the grid), whose own stacking is unset, so a press on them reaches it on
|
|
357
|
+
// the web too, and under the raised button and left pane, so the menu's rows stay pressable.
|
|
358
|
+
pressAway: {
|
|
359
|
+
zIndex: 5
|
|
360
|
+
},
|
|
292
361
|
// The preview's own rounding, clipping whatever it draws to the box.
|
|
293
362
|
preview: {
|
|
294
363
|
alignItems: 'center',
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { defaultColors, useAutoPaperTheme } from '@rific/auto-paper'
|
|
2
2
|
import { FakeLandscapeView, getViewRotation, rotateInsets, useOrientationState } from '@tastic/core'
|
|
3
|
-
import { CornerActionButtons,
|
|
3
|
+
import { CornerActionButtons, PressAwayOverlay, usePopoverHost } from '@tastic/hud/chrome'
|
|
4
|
+
import { PlayerSetupPanel, ReadyButton, SharedActionBand } from '@tastic/hud/lobby'
|
|
4
5
|
import type { Profile } from '@tastic/profile'
|
|
5
6
|
import { DualZoneLayout, needsSharedNeutralZone, useDualZoneLayout } from '@tastic/split-screen'
|
|
6
7
|
import { router, useFocusEffect } from 'expo-router'
|
|
7
8
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
8
|
-
import { Platform, StyleSheet, View } from 'react-native'
|
|
9
|
+
import { Platform, type StyleProp, StyleSheet, View, type ViewStyle } from 'react-native'
|
|
9
10
|
import Animated from 'react-native-reanimated'
|
|
10
11
|
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
|
11
12
|
import { useDispatch } from 'react-redux'
|
|
@@ -20,7 +21,8 @@ import { useSeatColors } from '../hooks/useSeatColors'
|
|
|
20
21
|
import { safeBack } from '../navigation'
|
|
21
22
|
import { usePack } from '../PackContext'
|
|
22
23
|
import { liveplayActions } from '../redux/liveplaySlice'
|
|
23
|
-
import {
|
|
24
|
+
import { playHref, PROFILES_HREF } from '../routes'
|
|
25
|
+
import { humanSeatsFor, type PackGameDefinition, roundModeFor, type Seat, type SeatTwo, seatTwoFor } from '../types'
|
|
24
26
|
|
|
25
27
|
// The player-panel area: whenever a real device rotation changes orientationMode or which physical
|
|
26
28
|
// side P1 lands on, the panels fade out, the layout underneath swaps while invisible, then they fade
|
|
@@ -31,7 +33,7 @@ const PANEL_SWAP_FADE_MS = 180
|
|
|
31
33
|
|
|
32
34
|
// A game that has a loadout (GameDefinition.useLoadout): the loadout route sends any other straight
|
|
33
35
|
// to its play screen.
|
|
34
|
-
export type LoadoutGame =
|
|
36
|
+
export type LoadoutGame = PackGameDefinition & Required<Pick<PackGameDefinition, 'useLoadout'>>
|
|
35
37
|
|
|
36
38
|
// Every game's loadout: seat 1 (the player picked on Home), seat 2 (the CPU, a second person, or
|
|
37
39
|
// nobody, from seat 2's own button, or no button in a game for one player), the game's own match
|
|
@@ -66,6 +68,10 @@ export function LoadoutScreen({ game }: { game: LoadoutGame }) {
|
|
|
66
68
|
const fixedSeats = p2IsHuman && (!!game.twoPlayerSeats?.faceToFaceOnWeb || Platform.OS !== 'web')
|
|
67
69
|
const fixedUpsideDown = !!game.twoPlayerSeats?.followUpsideDown && orientationMode === 'faceToFace' && upsideDown
|
|
68
70
|
const { panelLayout, panelFadeStyle } = useDualZoneLayout(fixedSeats ? 'faceToFace' : orientationMode, fixedSeats ? true : p1OnRight, fixedSeats || p1OnRightResolved, fixedSeats ? fixedUpsideDown : upsideDown, PANEL_SWAP_FADE_MS)
|
|
71
|
+
// split-screen types the fade as a style for any component (useAnimatedStyle's DefaultStyle). An app
|
|
72
|
+
// whose types widen Text's style (Expo's react-native-web types, which a generated expo-env.d.ts pulls
|
|
73
|
+
// in, make its cursor any string) can't hand that to Animated.View. It only ever sets opacity.
|
|
74
|
+
const playersRowFade = panelFadeStyle as unknown as StyleProp<ViewStyle>
|
|
69
75
|
|
|
70
76
|
const [settingsOpen, setSettingsOpen] = useState(false)
|
|
71
77
|
// Safe-area insets are relative to the device's fixed portrait frame; rotateInsets remaps them
|
|
@@ -113,7 +119,7 @@ export function LoadoutScreen({ game }: { game: LoadoutGame }) {
|
|
|
113
119
|
useEffect(() => {
|
|
114
120
|
if (!humanSeats.every((s) => ready[s])) return
|
|
115
121
|
dispatch(liveplayActions.setActiveRound({ game: game.id, mode, settings: roundSettingsRef.current }))
|
|
116
|
-
router.push(
|
|
122
|
+
router.push(playHref(game.id))
|
|
117
123
|
}, [ready, humanSeats, game.id, mode, dispatch])
|
|
118
124
|
|
|
119
125
|
const bg = dark ? '#000000' : '#FFFFFF'
|
|
@@ -166,7 +172,7 @@ export function LoadoutScreen({ game }: { game: LoadoutGame }) {
|
|
|
166
172
|
|
|
167
173
|
const controlLook = { accentColor: themeColors.tertiary, mutedColor: fgMuted, dark, rotation }
|
|
168
174
|
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(
|
|
175
|
+
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_HREF)} />
|
|
170
176
|
const p2Control = p2IsHuman ? loadout.seatControl(2, p2Host, { ...controlLook, seatColor: p2Color }) : undefined
|
|
171
177
|
// A game for one player (no seatTwo) has no seat 2 button: seat 1's panel stands alone.
|
|
172
178
|
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} />
|
|
@@ -202,7 +208,7 @@ export function LoadoutScreen({ game }: { game: LoadoutGame }) {
|
|
|
202
208
|
) : (
|
|
203
209
|
<View style={styles.stackedZone}>
|
|
204
210
|
{sharedControls}
|
|
205
|
-
<Animated.View style={[styles.playersRow, anyPlayerPickerOpen && styles.playersRowOpen,
|
|
211
|
+
<Animated.View style={[styles.playersRow, anyPlayerPickerOpen && styles.playersRowOpen, playersRowFade]}>
|
|
206
212
|
{!isPortrait && panelLayout.p1OnRight ? p2Panel : p1Panel}
|
|
207
213
|
{!isPortrait && panelLayout.p1OnRight ? p1Panel : p2Panel}
|
|
208
214
|
</Animated.View>
|
|
@@ -1,14 +1,39 @@
|
|
|
1
1
|
import { defaultColors } from '@rific/auto-paper'
|
|
2
|
+
import { type AlignResult, RotatedFrame } from '@tastic/hud/chrome'
|
|
2
3
|
import { ProfilesScreen } from '@tastic/profile'
|
|
3
4
|
|
|
4
5
|
import { useProfiles } from '../hooks/useProfiles'
|
|
5
6
|
import { safeBack } from '../navigation'
|
|
7
|
+
import { usePack } from '../PackContext'
|
|
8
|
+
|
|
9
|
+
// Widest the roster gets in the wide layout (a phone held sideways, a desktop window). Upright it's
|
|
10
|
+
// never capped.
|
|
11
|
+
const ROSTER_MAX_WIDTH = 640
|
|
6
12
|
|
|
7
13
|
// The Profiles screen (Manage, from either seat 1 button). Deleting a profile also removes its
|
|
8
14
|
// stats and achievements in every game: each game's reducers listen for the removal (see
|
|
9
15
|
// onProfileRemoved.ts).
|
|
16
|
+
//
|
|
17
|
+
// Turned with the phone like every other screen, in hud's RotatedFrame: ProfilesManager reads the
|
|
18
|
+
// safe-area insets itself (the header's padding), so they're handed over remapped onto the frame's
|
|
19
|
+
// edges (rotatedSafeArea); its delete confirm is a Paper Portal, which lands in a host inside the frame
|
|
20
|
+
// and is turned with it (portalHost); and the roster's column is capped in the wide layout. Over a
|
|
21
|
+
// pack's Backdrop the screen is transparent, drawn in the pack's foreground.
|
|
22
|
+
// The pack's own placement for a profile's color swatch (PackDefinition.usePickerAlign), kept inside the
|
|
23
|
+
// roster's capped column: the roster scrolls in it, which clips the menu at its edges. Handed to
|
|
24
|
+
// ProfilesScreen only where the pack has one.
|
|
25
|
+
function useRosterAlign(open: boolean, contentWidth: number, contentHeight: number): AlignResult {
|
|
26
|
+
const { usePickerAlign } = usePack()
|
|
27
|
+
return usePickerAlign!(open, contentWidth, contentHeight, { maxContentWidth: ROSTER_MAX_WIDTH })
|
|
28
|
+
}
|
|
29
|
+
|
|
10
30
|
export function ProfilesRoute() {
|
|
11
31
|
const { profiles, createProfile, updateProfile, deleteProfile } = useProfiles()
|
|
32
|
+
const { Backdrop, foreground, newProfileColor, usePickerAlign } = usePack()
|
|
12
33
|
|
|
13
|
-
return
|
|
34
|
+
return (
|
|
35
|
+
<RotatedFrame rotatedSafeArea portalHost maxContentWidth={ROSTER_MAX_WIDTH}>
|
|
36
|
+
<ProfilesScreen profiles={profiles} defaultColor={newProfileColor ?? defaultColors[0].value} onCreate={(patch) => createProfile(patch)} onSave={(id, patch) => updateProfile(id, patch)} onDelete={deleteProfile} onBack={safeBack} fg={foreground} bg={Backdrop ? 'transparent' : undefined} useColorPickerAlign={usePickerAlign ? useRosterAlign : undefined} />
|
|
37
|
+
</RotatedFrame>
|
|
38
|
+
)
|
|
14
39
|
}
|