@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,8 +1,7 @@
|
|
|
1
1
|
import { useAutoPaperTheme } from '@rific/auto-paper'
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import { useMemo, useState } from 'react'
|
|
2
|
+
import { BaseStatsScreen, type PopoverHost, PressAwayOverlay, RotatedFrame, usePopoverHost } from '@tastic/hud/chrome'
|
|
3
|
+
import { getOptionPickerContentSize, OptionPicker, type OptionPickerOption } from '@tastic/profile'
|
|
4
|
+
import { type ReactNode, useMemo, useState } from 'react'
|
|
6
5
|
import { StyleSheet, View } from 'react-native'
|
|
7
6
|
import { SegmentedButtons, Text } from 'react-native-paper'
|
|
8
7
|
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
|
@@ -11,15 +10,22 @@ import { useDispatch } from 'react-redux'
|
|
|
11
10
|
import { useProfiles } from '../hooks/useProfiles'
|
|
12
11
|
import { safeBack } from '../navigation'
|
|
13
12
|
import { usePack } from '../PackContext'
|
|
14
|
-
import type
|
|
13
|
+
import { GUEST_PLAYER_ID, type PackDefinition, type PackGameDefinition, type PackStatsTab } from '../types'
|
|
15
14
|
|
|
16
15
|
// 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
|
-
|
|
16
|
+
// would leave the achievements out of sight at the bottom. A pack's own tabs (PackDefinition.statsTabs)
|
|
17
|
+
// go between them.
|
|
18
|
+
const STATS = 'stats'
|
|
19
|
+
const ACHIEVEMENTS = 'achievements'
|
|
19
20
|
const TABS = [
|
|
20
|
-
{ value:
|
|
21
|
-
{ value:
|
|
21
|
+
{ value: STATS, label: 'Stats', icon: 'chart-box-outline' },
|
|
22
|
+
{ value: ACHIEVEMENTS, label: 'Achievements', icon: 'trophy-outline' }
|
|
22
23
|
]
|
|
24
|
+
// With a pack's own tabs, three or more don't fit a phone as equal segments with icons (Paper pads each
|
|
25
|
+
// label 16pt a side, and "Achievements" alone is ~109pt in Solitaire's font), so each segment is as wide
|
|
26
|
+
// as its label and the icons go: Solitaire's three take 309pt of a 375pt phone's 335pt row.
|
|
27
|
+
const LABEL_WIDE_TAB = { flexBasis: 'auto', flexGrow: 1, flexShrink: 1 } as const
|
|
28
|
+
const NO_PACK_TABS: readonly PackStatsTab[] = []
|
|
23
29
|
|
|
24
30
|
// The pickers' values for Everyone and All Games (neither is ever a profile or game id).
|
|
25
31
|
const EVERYONE = '__everyone__'
|
|
@@ -28,76 +34,149 @@ const ALL_GAMES = '__all__'
|
|
|
28
34
|
const PLAYER_PICKER_ID = 'stats-player'
|
|
29
35
|
const GAME_PICKER_ID = 'stats-game'
|
|
30
36
|
|
|
37
|
+
// Widest the column gets in the wide layout (a phone held sideways, a desktop window): a list of stat
|
|
38
|
+
// rows stretched across an 874pt-wide screen reads badly. Upright it's never capped.
|
|
39
|
+
const STATS_MAX_WIDTH = 640
|
|
40
|
+
|
|
31
41
|
// Every game's stats and achievements on one screen, filtered by player (Everyone, or one profile)
|
|
32
42
|
// and by game (All Games, or one), like Solitaire's. Each game keeps its own stats and its own
|
|
33
43
|
// 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
|
|
35
|
-
// be told apart.
|
|
44
|
+
// open on, so it opens on Everyone, and guests count only toward Everyone: two guests in one match
|
|
45
|
+
// can't be told apart. A pack for one player at a time (PackDefinition.guestStats) keeps a guest's
|
|
46
|
+
// stats under GUEST_PLAYER_ID, offers Guest in the picker and opens on whoever is in seat 1.
|
|
47
|
+
//
|
|
48
|
+
// Turned with the phone in hud's RotatedFrame: its insets remapped onto the frame's edges, the reset
|
|
49
|
+
// confirm portaled into a host inside the frame (so it's turned with it, at rotation 0), and the column
|
|
50
|
+
// capped in the wide layout.
|
|
36
51
|
export function StatsScreen() {
|
|
52
|
+
const pickerHost = usePopoverHost()
|
|
53
|
+
return (
|
|
54
|
+
<StatsPressAway host={pickerHost}>
|
|
55
|
+
<RotatedFrame rotatedSafeArea portalHost maxContentWidth={STATS_MAX_WIDTH}>
|
|
56
|
+
<StatsContent pickerHost={pickerHost} />
|
|
57
|
+
</RotatedFrame>
|
|
58
|
+
</StatsPressAway>
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Closes the pickers' open menu on a press anywhere on the screen that nothing else takes: the header
|
|
63
|
+
// (its title and blank space) and, in the wide layout, the frame either side of the capped column. The
|
|
64
|
+
// page's own press-away overlay only covers the scrolling content, which neither is part of. A press
|
|
65
|
+
// only reaches this when no control under the finger claims it, so the back button, the menu's rows and
|
|
66
|
+
// everything else still work.
|
|
67
|
+
function StatsPressAway({ host, children }: { host: PopoverHost; children: ReactNode }) {
|
|
68
|
+
return (
|
|
69
|
+
<View testID='stats-press-away' style={styles.screen} onStartShouldSetResponder={() => host.openId !== null} onResponderRelease={host.close}>
|
|
70
|
+
{children}
|
|
71
|
+
</View>
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// A pack whose Reset follows the game picker resets each game's own (GameDefinition.resetStats).
|
|
76
|
+
const useNoPackReset = () => null
|
|
77
|
+
|
|
78
|
+
// No placement of the pack's own: hud places the pickers' menus itself.
|
|
79
|
+
const noPickerAlign = () => undefined
|
|
80
|
+
|
|
81
|
+
function StatsContent({ pickerHost }: { pickerHost: PopoverHost }) {
|
|
37
82
|
const pack = usePack()
|
|
38
83
|
const { dark, colors } = useAutoPaperTheme()
|
|
39
|
-
//
|
|
40
|
-
|
|
41
|
-
const
|
|
42
|
-
const insets = rotateInsets(useSafeAreaInsets(), rotation)
|
|
43
|
-
const { profiles } = useProfiles()
|
|
84
|
+
// Already remapped onto the turned frame's edges by RotatedFrame's rotatedSafeArea.
|
|
85
|
+
const insets = useSafeAreaInsets()
|
|
86
|
+
const { profiles, lastSelected } = useProfiles()
|
|
44
87
|
const dispatch = useDispatch()
|
|
45
|
-
const
|
|
88
|
+
const fg = pack.foreground ?? colors.onBackground
|
|
46
89
|
|
|
47
|
-
const [tab, setTab] = useState<
|
|
90
|
+
const [tab, setTab] = useState<string>(STATS)
|
|
48
91
|
const [gameId, setGameId] = useState<string>(ALL_GAMES)
|
|
49
|
-
//
|
|
50
|
-
|
|
51
|
-
|
|
92
|
+
// Opens on whoever is in seat 1 (Guest for a guest) where the pack keeps a guest's stats, else on
|
|
93
|
+
// Everyone. Self-heals to Everyone if the selected profile is deleted while this screen is mounted, and
|
|
94
|
+
// from Guest once there is no profile (Everyone is then just the guest).
|
|
95
|
+
const [selectedProfileId, setSelectedProfileId] = useState<string | null>(() => (pack.guestStats ? (lastSelected[1] ?? GUEST_PLAYER_ID) : null))
|
|
96
|
+
const profileId = selectedProfileId && profiles.length > 0 && ((pack.guestStats && selectedProfileId === GUEST_PLAYER_ID) || profiles.some((p) => p.id === selectedProfileId)) ? selectedProfileId : null
|
|
52
97
|
|
|
53
98
|
const games = gameId === ALL_GAMES ? pack.games : pack.games.filter((g) => g.id === gameId)
|
|
99
|
+
const packTabs = pack.statsTabs ?? NO_PACK_TABS
|
|
100
|
+
const packTab = packTabs.find((t) => t.value === tab)
|
|
101
|
+
// One achievement list across the pack's games doesn't narrow to a game, so the game picker goes.
|
|
102
|
+
const showGamePicker = !(tab === ACHIEVEMENTS && pack.AchievementList)
|
|
54
103
|
|
|
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])
|
|
104
|
+
const playerOptions = useMemo<OptionPickerOption<string>[]>(() => [{ value: EVERYONE, label: 'Everyone', icon: 'account-group' }, ...(pack.guestStats ? [{ value: GUEST_PLAYER_ID, label: 'Guest', icon: 'account-outline' }] : []), ...profiles.map((p) => ({ value: p.id, label: p.name, icon: 'account', color: pack.previewColor ? pack.previewColor(p.color, dark) : p.color }))], [profiles, pack, dark])
|
|
56
105
|
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])
|
|
106
|
+
const tabButtons = useMemo(() => (packTabs.length > 0 ? [{ value: STATS, label: 'Stats' }, ...packTabs.map(({ value, label }) => ({ value, label })), { value: ACHIEVEMENTS, label: 'Achievements' }].map((button) => ({ ...button, style: LABEL_WIDE_TAB })) : TABS), [packTabs])
|
|
107
|
+
const title = tab === STATS ? 'Stats' : tab === ACHIEVEMENTS ? 'Achievements' : (packTab?.label ?? '')
|
|
108
|
+
|
|
109
|
+
// Reset: the pack's own (PackDefinition.resetStats) follows the player picker, one player's or
|
|
110
|
+
// everyone's; otherwise it clears every player's stats and achievements in the games shown (one game,
|
|
111
|
+
// or all of them). The pack never changes while it runs, so the same hook is called on every render.
|
|
112
|
+
const usePackReset = pack.resetStats?.useReset ?? useNoPackReset
|
|
113
|
+
const packReset = usePackReset()
|
|
114
|
+
const reset = resetFor(pack, packReset, profileId, profiles, games, () => games.forEach((g) => g.resetStats && dispatch(g.resetStats())))
|
|
57
115
|
|
|
58
|
-
//
|
|
59
|
-
const
|
|
60
|
-
const
|
|
116
|
+
// The pack's own placement for the pickers' menus (PackDefinition.usePickerAlign), sized from their rows.
|
|
117
|
+
const usePickerAlign = pack.usePickerAlign ?? noPickerAlign
|
|
118
|
+
const playerSize = getOptionPickerContentSize(playerOptions.length)
|
|
119
|
+
const gameSize = getOptionPickerContentSize(gameOptions.length)
|
|
120
|
+
const playerAlign = usePickerAlign(pickerHost.openId === PLAYER_PICKER_ID, playerSize.width, playerSize.height)
|
|
121
|
+
const gameAlign = usePickerAlign(pickerHost.openId === GAME_PICKER_ID, gameSize.width, gameSize.height)
|
|
122
|
+
|
|
123
|
+
// Over the pack's Backdrop the page is transparent, with its tabs and pickers on the theme's surface.
|
|
124
|
+
const surface = pack.Backdrop ? { backgroundColor: colors.surface } : undefined
|
|
125
|
+
const triggerStyle = pack.Backdrop ? [styles.pill, surface] : undefined
|
|
126
|
+
const PackTabContent = packTab?.Content
|
|
61
127
|
|
|
62
128
|
return (
|
|
63
|
-
<
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
</
|
|
129
|
+
<BaseStatsScreen title={title} onBack={safeBack} insets={insets} onReset={reset.onReset} resetLabel={reset.label} resetConfirmTitle={reset.title} resetConfirmBody={reset.body} fg={pack.foreground} bg={pack.Backdrop ? 'transparent' : undefined} rotation={0}>
|
|
130
|
+
{/* Inside the page, not before it: BaseStatsScreen paints an opaque full-screen container that
|
|
131
|
+
would hide a tap catcher placed underneath it. Over the stat sections and under the open
|
|
132
|
+
filters (pressAway's zIndex sits below filtersOpen's), so a tap anywhere on the page but a
|
|
133
|
+
menu closes it. Off the page (the header, the margins) StatsPressAway closes it. */}
|
|
134
|
+
<PressAwayOverlay active={pickerHost.openId !== null} onPress={pickerHost.close} style={styles.pressAway} />
|
|
135
|
+
<SegmentedButtons value={tab} onValueChange={setTab} buttons={tabButtons} style={[styles.tabs, surface]} />
|
|
136
|
+
|
|
137
|
+
{/* Takes a press on its own blank parts (the gap, an open menu's padding) while a menu is open, so
|
|
138
|
+
it never reaches StatsPressAway. */}
|
|
139
|
+
<View style={[styles.filters, pickerHost.openId !== null && styles.filtersOpen]} onStartShouldSetResponder={() => pickerHost.openId !== null}>
|
|
140
|
+
{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'} alignOverride={playerAlign} triggerStyle={triggerStyle} />}
|
|
141
|
+
{showGamePicker && <OptionPicker id={GAME_PICKER_ID} host={pickerHost} options={gameOptions} value={gameId} onChange={setGameId} color={colors.primary} dark={dark} icon='view-grid-outline' alignOverride={gameAlign} triggerStyle={triggerStyle} />}
|
|
142
|
+
</View>
|
|
143
|
+
|
|
144
|
+
{/* The pack's own section across its games (a calendar of play), first on the Stats tab: it
|
|
145
|
+
follows both filters, so narrowed to one game it shows that game's part. */}
|
|
146
|
+
{tab === STATS && pack.StatsSections && <pack.StatsSections profileId={profileId} gameId={gameId === ALL_GAMES ? null : gameId} />}
|
|
147
|
+
|
|
148
|
+
{PackTabContent && <PackTabContent profileId={profileId} gameId={gameId === ALL_GAMES ? null : gameId} />}
|
|
149
|
+
|
|
150
|
+
{tab === ACHIEVEMENTS && pack.AchievementList ? <pack.AchievementList profileId={profileId} /> : (tab === STATS || tab === ACHIEVEMENTS) && games.map((game) => <GameStats key={game.id} game={game} tab={tab} profileId={profileId} showTitle={games.length > 1 && pack.statsGameTitles !== false} titleColor={fg} />)}
|
|
151
|
+
</BaseStatsScreen>
|
|
86
152
|
)
|
|
87
153
|
}
|
|
88
154
|
|
|
89
|
-
//
|
|
90
|
-
|
|
91
|
-
|
|
155
|
+
// What the screen's Reset does and says: the pack's own, for the player picked (by name; Guest for a
|
|
156
|
+
// guest) or everyone, or each shown game's own.
|
|
157
|
+
function resetFor(pack: PackDefinition, packReset: ((profileId: string | null) => void) | null, profileId: string | null, profiles: readonly { id: string; name: string }[], games: readonly PackGameDefinition[], resetGames: () => void) {
|
|
158
|
+
if (pack.resetStats && packReset) {
|
|
159
|
+
const name = profileId === GUEST_PLAYER_ID ? 'Guest' : profileId ? (profiles.find((p) => p.id === profileId)?.name ?? null) : null
|
|
160
|
+
return { onReset: () => packReset(profileId), ...pack.resetStats.labels(name) }
|
|
161
|
+
}
|
|
162
|
+
const scope = games.length === 1 ? games[0].title : 'every game'
|
|
163
|
+
return { onReset: resetGames, label: games.length === 1 ? `Reset ${scope} Stats` : 'Reset All Stats', title: 'Reset Stats?', body: `Erases every player's stats and achievements in ${scope}. This cannot be undone.` }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// One game's part of the page: its own sections, under its name when more than one game is shown. A
|
|
167
|
+
// game with no achievement list of its own shows nothing on Achievements.
|
|
168
|
+
function GameStats({ game, tab, profileId, showTitle, titleColor }: { game: PackGameDefinition; tab: string; profileId: string | null; showTitle: boolean; titleColor: string }) {
|
|
92
169
|
const { StatsSections, AchievementList } = game
|
|
170
|
+
const Sections = tab === STATS ? StatsSections : AchievementList
|
|
171
|
+
if (!Sections) return null
|
|
93
172
|
return (
|
|
94
173
|
<View style={styles.game}>
|
|
95
174
|
{showTitle && (
|
|
96
|
-
<Text variant='titleLarge' style={[styles.gameTitle, { color:
|
|
175
|
+
<Text variant='titleLarge' style={[styles.gameTitle, { color: titleColor }]}>
|
|
97
176
|
{game.title}
|
|
98
177
|
</Text>
|
|
99
178
|
)}
|
|
100
|
-
|
|
179
|
+
<Sections profileId={profileId} />
|
|
101
180
|
</View>
|
|
102
181
|
)
|
|
103
182
|
}
|
|
@@ -119,14 +198,17 @@ const styles = StyleSheet.create({
|
|
|
119
198
|
fontWeight: 'bold',
|
|
120
199
|
marginTop: 8
|
|
121
200
|
},
|
|
201
|
+
// A picker's button on the theme's surface (over a backdrop), the same pill as Home's.
|
|
202
|
+
pill: {
|
|
203
|
+
borderRadius: 999
|
|
204
|
+
},
|
|
122
205
|
pressAway: {
|
|
123
206
|
zIndex: 50
|
|
124
207
|
},
|
|
125
|
-
|
|
126
|
-
rotatable: {
|
|
208
|
+
screen: {
|
|
127
209
|
flex: 1
|
|
128
210
|
},
|
|
129
|
-
// SegmentedButtons' own rounding.
|
|
211
|
+
// SegmentedButtons' own rounding, so a surface behind it is the same pill.
|
|
130
212
|
tabs: {
|
|
131
213
|
borderRadius: 20
|
|
132
214
|
}
|
package/src/types.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import type { ThemeAppearance } from '@rific/auto-paper'
|
|
1
|
+
import type { ThemeAppearance, ThemeSettings } from '@rific/auto-paper'
|
|
2
2
|
import type { ViewRotation } from '@tastic/core'
|
|
3
|
-
import type {
|
|
3
|
+
import type { AlignResult, PopoverHost } from '@tastic/hud/chrome'
|
|
4
4
|
import type { GuideStep } from '@tastic/hud/guide'
|
|
5
|
+
import type { MenuOption } from '@tastic/hud/lobby'
|
|
5
6
|
import type { FontSource } from 'expo-font'
|
|
6
|
-
import type {
|
|
7
|
+
import type { Stack } from 'expo-router'
|
|
8
|
+
import type { ComponentProps, ComponentType, ReactNode } from 'react'
|
|
7
9
|
import type { Action, Reducer } from 'redux'
|
|
8
10
|
|
|
9
11
|
import type { ShellState } from './redux/shellState'
|
|
@@ -79,6 +81,11 @@ export interface GameLoadout {
|
|
|
79
81
|
cpuDifficulty?: { value: string; options: MenuOption<string>[]; onChange: (value: string) => void }
|
|
80
82
|
}
|
|
81
83
|
|
|
84
|
+
// The id a guest's stats are kept under where a pack keeps them (PackDefinition.guestStats): the one
|
|
85
|
+
// player who isn't a profile, in a pack for one player at a time. Not seatPlayerKey's `guest:1`, which
|
|
86
|
+
// tells two guests at one table apart.
|
|
87
|
+
export const GUEST_PLAYER_ID = 'guest'
|
|
88
|
+
|
|
82
89
|
// What a game's How to Play cards are built from: the live settings (through the game's own
|
|
83
90
|
// selector, so the guide host never has to know their shape), how seat 2 is set up, and whether
|
|
84
91
|
// the device is touch-first.
|
|
@@ -113,12 +120,30 @@ export interface GamePreviewProps {
|
|
|
113
120
|
dark: boolean
|
|
114
121
|
}
|
|
115
122
|
|
|
123
|
+
// The pack's own How to Play (PackDefinition.guide): cards that teach every game in the pack at once,
|
|
124
|
+
// shown on Home's first launch and replayed from Home's Settings. Its seen version is a key of the
|
|
125
|
+
// pack's own (hud's createGuideSlice), so a pack keeps whatever it already saved.
|
|
126
|
+
export interface PackGuide {
|
|
127
|
+
version: number
|
|
128
|
+
selectVersionSeen: (state: ShellState) => number
|
|
129
|
+
markSeen: (version: number) => Action
|
|
130
|
+
getSteps: (context: { touch: boolean }) => GuideStep[]
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// A game's rules sheet (GameDefinition.Rules), opened from its tile's rules button on Home. It renders
|
|
134
|
+
// through a Paper Portal into a host Home mounts inside its turned frame, so it's already turned with it.
|
|
135
|
+
export interface GameRulesProps {
|
|
136
|
+
visible: boolean
|
|
137
|
+
onDismiss: () => void
|
|
138
|
+
}
|
|
139
|
+
|
|
116
140
|
export interface GameDefinition {
|
|
117
141
|
id: string
|
|
118
142
|
title: string
|
|
119
143
|
// One line under the tile's name on Home.
|
|
120
144
|
blurb: string
|
|
121
|
-
|
|
145
|
+
// Left out only by a game with a Preview, which Home draws in its place.
|
|
146
|
+
icon?: ComponentType<GameIconProps>
|
|
122
147
|
// Drawn on Home in place of the icon, a frame after Home first paints (a preview is far more to
|
|
123
148
|
// draw than an icon). While any game in the pack has one, Home lays its games out as tiles: rows
|
|
124
149
|
// with the preview at their start, and a grid beside the hero on a wide screen. A game without one
|
|
@@ -134,8 +159,9 @@ export interface GameDefinition {
|
|
|
134
159
|
twoPlayerSeats?: { faceToFaceOnWeb: boolean; followUpsideDown: boolean }
|
|
135
160
|
// The store keys this game mounts (settings, achievements, guide), merged into the pack's store.
|
|
136
161
|
reducers: Record<string, Reducer>
|
|
137
|
-
// Mounted at the app root for the game's lifetime (its stats and achievements context).
|
|
138
|
-
|
|
162
|
+
// Mounted at the app root for the game's lifetime (its stats and achievements context). Left out by
|
|
163
|
+
// a game whose stats live in the pack's own (PackDefinition.Provider).
|
|
164
|
+
Provider?: ComponentType<{ children: ReactNode }>
|
|
139
165
|
// Left out by a game with nothing to set up before it starts: Home opens its play screen
|
|
140
166
|
// straight away (after its How to Play the first time), and its rule variants are rows in its
|
|
141
167
|
// play screen's own Settings (SettingsDialog's children).
|
|
@@ -146,15 +172,34 @@ export interface GameDefinition {
|
|
|
146
172
|
// board: the shell only remembers which game it was (redux/lastActiveGameSlice.ts). A match that
|
|
147
173
|
// isn't saved (liveplay) shouldn't opt in, or a relaunch would open a fresh one.
|
|
148
174
|
resumable?: boolean
|
|
175
|
+
// The play screen keeps the status bar while the phone is held upright. Unset, the bar is hidden on
|
|
176
|
+
// every play screen for the whole round, whatever the angle; set, it's hidden only while the
|
|
177
|
+
// content is turned, as on every other screen.
|
|
178
|
+
keepStatusBar?: boolean
|
|
149
179
|
// The game's stats sections and its achievement list for the stats screen, for one profile
|
|
150
180
|
// (null is Everyone).
|
|
151
181
|
StatsSections: ComponentType<{ profileId: string | null }>
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
182
|
+
// Left out by a game whose achievements are in the pack's one list (PackDefinition.AchievementList).
|
|
183
|
+
AchievementList?: ComponentType<{ profileId: string | null }>
|
|
184
|
+
// Clears the game's stats and achievements (the stats screen's Reset). Left out by a game whose stats
|
|
185
|
+
// the pack resets itself (PackDefinition.resetStats).
|
|
186
|
+
resetStats?: () => Action
|
|
187
|
+
// The game's own How to Play cards: shown the first time its tile is picked, and from its rules
|
|
188
|
+
// button and its loadout's Settings.
|
|
155
189
|
guide: GameGuide
|
|
190
|
+
// The rules sheet its tile's rules button opens, in place of its How to Play cards.
|
|
191
|
+
Rules?: ComponentType<GameRulesProps>
|
|
156
192
|
}
|
|
157
193
|
|
|
194
|
+
// A game with no How to Play cards of its own, taught by the pack's own (PackDefinition.guide): its
|
|
195
|
+
// tile opens without cards the first time, and its rules button shows its Rules or nothing. Its own type
|
|
196
|
+
// rather than an optional `guide` on GameDefinition, so a pack's code that reads its own games' cards
|
|
197
|
+
// (DuelTastic's packGuides test) keeps compiling.
|
|
198
|
+
export type GuidelessGameDefinition = Omit<GameDefinition, 'guide'> & { guide?: undefined }
|
|
199
|
+
|
|
200
|
+
// Any game a pack holds: one with How to Play cards of its own, or one without.
|
|
201
|
+
export type PackGameDefinition = GameDefinition | GuidelessGameDefinition
|
|
202
|
+
|
|
158
203
|
// What Home hands the pack's hero (PackDefinition.HeroTitle).
|
|
159
204
|
export interface HeroTitleProps {
|
|
160
205
|
// The pack's wordmark (see PackDefinition.wordmark).
|
|
@@ -180,13 +225,41 @@ export interface PackStatsSectionsProps {
|
|
|
180
225
|
gameId: string | null
|
|
181
226
|
}
|
|
182
227
|
|
|
228
|
+
// The stack's screen options (expo-router's Stack), as an object: what PackDefinition.useScreenOptions
|
|
229
|
+
// returns.
|
|
230
|
+
export type StackScreenOptions = Exclude<NonNullable<ComponentProps<typeof Stack>['screenOptions']>, (...args: never[]) => unknown>
|
|
231
|
+
|
|
232
|
+
// The two sounds feedback-press plays on every button press (PackDefinition.useFeedbackSounds): a tap,
|
|
233
|
+
// and a refused or notifying press.
|
|
234
|
+
export interface FeedbackSounds {
|
|
235
|
+
selection: () => void
|
|
236
|
+
notification: () => void
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// A tab of the pack's own on the stats screen (PackDefinition.statsTabs), between Stats and
|
|
240
|
+
// Achievements, handed the screen's two filters.
|
|
241
|
+
export interface PackStatsTab {
|
|
242
|
+
value: string
|
|
243
|
+
label: string
|
|
244
|
+
Content: ComponentType<PackStatsSectionsProps>
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// The stats screen's Reset, where the pack resets stats itself (PackDefinition.resetStats). It follows
|
|
248
|
+
// the player picker: one player's (a profile id, or GUEST_PLAYER_ID), or everyone's (null).
|
|
249
|
+
export interface PackStatsReset {
|
|
250
|
+
useReset: () => (profileId: string | null) => void
|
|
251
|
+
// The button, the confirm's title and its body, for a player's name (Guest for a guest) or null for
|
|
252
|
+
// Everyone.
|
|
253
|
+
labels: (name: string | null) => { label: string; title: string; body: string }
|
|
254
|
+
}
|
|
255
|
+
|
|
183
256
|
export interface PackDefinition {
|
|
184
257
|
name: string
|
|
185
258
|
// The Home wordmark's two lines.
|
|
186
259
|
wordmark: readonly [string, string]
|
|
187
260
|
// A seat's color pick for a profile is kept for this pack only; the seat button says so.
|
|
188
261
|
colorCaption: string
|
|
189
|
-
games: readonly
|
|
262
|
+
games: readonly PackGameDefinition[]
|
|
190
263
|
// Player 1's and player 2's own default colors. They seed the theme's primary and secondary (the
|
|
191
264
|
// tertiary is derived from them, see shellState.ts) and the guest and CPU colors a fresh install
|
|
192
265
|
// starts with (seatColorsSlice.ts), so a fresh install's live theme and its remembered colors agree.
|
|
@@ -213,4 +286,64 @@ export interface PackDefinition {
|
|
|
213
286
|
// A section of the pack's own at the top of the stats screen's Stats tab, across its games (a
|
|
214
287
|
// calendar of play), above each game's own StatsSections. It follows the screen's two filters.
|
|
215
288
|
StatsSections?: ComponentType<PackStatsSectionsProps>
|
|
289
|
+
|
|
290
|
+
// A table drawn once behind the whole stack (a felt), seen through every screen: the stack's own
|
|
291
|
+
// layers go transparent, and Home, the stats screen and Profiles draw on it in `foreground`.
|
|
292
|
+
Backdrop?: ComponentType
|
|
293
|
+
// The text and icon color of what Home, the stats screen and Profiles draw straight on the screen's
|
|
294
|
+
// background (the hero, the corner buttons, headers). Unset is black or white with the theme.
|
|
295
|
+
foreground?: string
|
|
296
|
+
// Mounted once around How to Play and the stack, inside the store, Theme, ProfilesProvider and every
|
|
297
|
+
// game's Provider: a context of the pack's own (one stats context across its games).
|
|
298
|
+
Provider?: ComponentType<{ children: ReactNode }>
|
|
299
|
+
// Merged into the stack's screen options after the shell's own (a status bar style, a crossfade).
|
|
300
|
+
useScreenOptions?: () => StackScreenOptions
|
|
301
|
+
// The two sounds every button plays, in place of the shell's own.
|
|
302
|
+
useFeedbackSounds?: () => FeedbackSounds
|
|
303
|
+
// The pack's own toaster, drawn in a frame turned with the phone (with the safe-area insets turned
|
|
304
|
+
// onto the frame's edges) in place of the shell's. It renders inline: the toast provider is given no
|
|
305
|
+
// Paper, so nothing portals it out of the frame.
|
|
306
|
+
Toaster?: ComponentType
|
|
307
|
+
// Every screen reads upright while the software keyboard is up (@tastic/core's OrientationProvider),
|
|
308
|
+
// for typing a profile's name with the phone sideways.
|
|
309
|
+
portraitWhileKeyboard?: boolean
|
|
310
|
+
// false leaves Edge Guard out of Settings (a pack with no edge controls and no Edge Guard plugin).
|
|
311
|
+
edgeGuard?: boolean
|
|
312
|
+
// The theme seat 1's color makes (a muted seed with a harmony), applied whenever the seats' colors or
|
|
313
|
+
// dark mode change and as a fresh install's theme, in place of a triad of the raw seat colors. With it,
|
|
314
|
+
// each seat's color is read from the seat colors rather than from the theme.
|
|
315
|
+
seatTheme?: (colors: Record<Seat, string>, dark: boolean) => Pick<ThemeSettings, 'color' | 'harmony'>
|
|
316
|
+
// How every profile color is drawn (the button's chip, the swatches, each ProfileChip): mounted as
|
|
317
|
+
// @tastic/profile's ProfileColorProvider. The saved colors stay as picked.
|
|
318
|
+
previewColor?: (hex: string, dark: boolean) => string
|
|
319
|
+
// The pack's own How to Play: shown on Home's first launch (held back while a cold launch resumes a
|
|
320
|
+
// game) and from Home's Settings, never in a play screen.
|
|
321
|
+
guide?: PackGuide
|
|
322
|
+
// Seat 1's button: the chip's label for a guest (default 'P1') and the guest row's in its menu
|
|
323
|
+
// (default @tastic/profile's).
|
|
324
|
+
guestLabel?: string
|
|
325
|
+
nullLabel?: string
|
|
326
|
+
// The stats screen keeps a guest's stats under GUEST_PLAYER_ID: a Guest entry in the player picker
|
|
327
|
+
// once a profile exists, and the screen opens on whoever is in seat 1.
|
|
328
|
+
guestStats?: boolean
|
|
329
|
+
// One achievement list across the pack's games, on the Achievements tab (the game picker hidden there).
|
|
330
|
+
AchievementList?: ComponentType<{ profileId: string | null }>
|
|
331
|
+
// Tabs of the pack's own between Stats and Achievements.
|
|
332
|
+
statsTabs?: readonly PackStatsTab[]
|
|
333
|
+
// The stats screen's Reset, following the player picker rather than the game picker.
|
|
334
|
+
resetStats?: PackStatsReset
|
|
335
|
+
// false leaves out each game's name over its sections on All Games, where its sections name it.
|
|
336
|
+
statsGameTitles?: boolean
|
|
337
|
+
// A new profile's starting color on Profiles. Unset is auto-paper's first swatch.
|
|
338
|
+
newProfileColor?: string
|
|
339
|
+
// Where a picker's menu opens (its alignOverride) on Home (seat 1's button), the stats screen (its two
|
|
340
|
+
// pickers) and Profiles (a profile's color swatch). hud's own placement measures a turned button
|
|
341
|
+
// against the upright window, so with the phone held sideways it can pick the side with no room and
|
|
342
|
+
// run the menu off the screen (Solitaire's useFrameAutoAlign places it in the turned frame instead).
|
|
343
|
+
// `open` is whether this picker's menu is open, the size is its content's, and `maxContentWidth` is a
|
|
344
|
+
// capped column's width the menu stays inside (Profiles' roster). Unset is hud's own placement.
|
|
345
|
+
usePickerAlign?: PickerAlign
|
|
216
346
|
}
|
|
347
|
+
|
|
348
|
+
// What PackDefinition.usePickerAlign is: a hook, called on every render of the screen it serves.
|
|
349
|
+
export type PickerAlign = (open: boolean, contentWidth: number, contentHeight: number, options?: { maxContentWidth?: number }) => AlignResult
|