@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,25 @@
|
|
|
1
|
+
import { removeProfileUnlocks, type UnlockedAchievementsState } from '@tastic/achievements'
|
|
2
|
+
import { profilesActions } from '@tastic/profile'
|
|
3
|
+
import type { Reducer, UnknownAction } from 'redux'
|
|
4
|
+
|
|
5
|
+
// Wraps one of a game's reducers so it forgets a profile the moment the Profiles screen deletes it
|
|
6
|
+
// (the shell's deleteProfile dispatches @tastic/profile's profilesActions.remove), with no need for
|
|
7
|
+
// the shell to know which games keep what per profile. A profile a sibling app deleted (a roster
|
|
8
|
+
// sync) is deliberately not cleaned up this way: a failed shared-store read reports an empty roster,
|
|
9
|
+
// which would erase real players' data for good.
|
|
10
|
+
export function onProfileRemoved<S>(reducer: Reducer<S>, forget: (state: S, profileId: string) => S): Reducer<S> {
|
|
11
|
+
return (state: S | undefined, action: UnknownAction) => {
|
|
12
|
+
const next = reducer(state, action)
|
|
13
|
+
return profilesActions.remove.match(action) ? forget(next, action.payload) : next
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// For a createAchievementsSlice state: drops the profile's unlocks, and its stats through the game's
|
|
18
|
+
// own `dropStats` (where a game keeps a per-profile bucket).
|
|
19
|
+
export function forgetProfileAchievements<T, S extends { stats: T; unlocked: UnlockedAchievementsState }>(dropStats: (stats: T, profileId: string) => T) {
|
|
20
|
+
return (state: S, profileId: string): S => {
|
|
21
|
+
const stats = dropStats(state.stats, profileId)
|
|
22
|
+
const unlocked = removeProfileUnlocks(state.unlocked, profileId)
|
|
23
|
+
return stats === state.stats && unlocked === state.unlocked ? state : { ...state, stats, unlocked }
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { createSettingsSlice } from '@rific/core'
|
|
2
|
+
|
|
3
|
+
import type { SeatTwo } from '../types'
|
|
4
|
+
|
|
5
|
+
// Who sits in seat 2, for the whole pack: picked from seat 2's own button in any game's loadout and
|
|
6
|
+
// kept from one game to the next and across relaunches, since it's usually the same two people.
|
|
7
|
+
// A fresh install starts on the CPU, so one person (App Review included) can tap a game and play.
|
|
8
|
+
// A game without 'nobody' among its seat-2 choices treats a saved 'nobody' as the CPU (see
|
|
9
|
+
// types.ts' seatTwoFor).
|
|
10
|
+
export interface OpponentState {
|
|
11
|
+
seatTwo: SeatTwo
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const DEFAULT_OPPONENT: OpponentState = { seatTwo: 'cpu' }
|
|
15
|
+
|
|
16
|
+
const { actions, reducer } = createSettingsSlice<OpponentState>('opponent', {
|
|
17
|
+
initialState: DEFAULT_OPPONENT
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
export const opponentActions = actions
|
|
21
|
+
export default reducer
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { createProfileSelectionSlice } from '@tastic/profile'
|
|
2
|
+
|
|
3
|
+
import type { Seat } from '../types'
|
|
4
|
+
|
|
5
|
+
// Which profile each seat last picked, for the whole pack: seat 1 is Home's who's-playing button
|
|
6
|
+
// and seat 1 in every loadout, seat 2 is seat 2 in every loadout. Always local, never part of the
|
|
7
|
+
// shared App Group roster (@tastic/profile's own profiles slice, see createPackStore.ts): who's
|
|
8
|
+
// selected on THIS device is a per-app, per-device thing a sibling @tastic app shouldn't see.
|
|
9
|
+
export type ProfileSelectionState = Record<Seat, string | null>
|
|
10
|
+
|
|
11
|
+
export const defaultProfileSelectionState: ProfileSelectionState = { 1: null, 2: null }
|
|
12
|
+
|
|
13
|
+
// 'pack' only prefixes the action types; the state is stored under profileSelection.
|
|
14
|
+
const { actions, reducer } = createProfileSelectionSlice<Seat>('pack', defaultProfileSelectionState)
|
|
15
|
+
|
|
16
|
+
export const profileSelectionActions = actions
|
|
17
|
+
export default reducer
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { createSeatColorsSlice, type SeatColorsState as GenericSeatColorsState } from '@tastic/profile'
|
|
2
|
+
|
|
3
|
+
import type { Seat } from '../types'
|
|
4
|
+
|
|
5
|
+
// The loadout's per-seat color memory: a thin binding over @tastic/profile's createSeatColorsSlice
|
|
6
|
+
// (see its doc for each field: a guest seat's last color, the CPU seat's last color, and each
|
|
7
|
+
// profile's own color pick in this pack, kept by profile id). One memory for every game in the
|
|
8
|
+
// pack, so a player's color follows them from one game to the next.
|
|
9
|
+
export type SeatColorsState = GenericSeatColorsState<Seat>
|
|
10
|
+
|
|
11
|
+
// What a fresh install remembers: each guest seat on its own default color (the pack's seat colors,
|
|
12
|
+
// PackDefinition.seatColors) and the CPU on seat 2's.
|
|
13
|
+
export function defaultSeatColorsState(seatColors: Record<Seat, string>): SeatColorsState {
|
|
14
|
+
return { lastGuestColor: { 1: seatColors[1], 2: seatColors[2] }, lastCpuColor: seatColors[2], profileColors: {} }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// 'pack' only prefixes the action types; the state is stored under seatColors.
|
|
18
|
+
export function createSeatColorsReducer(seatColors: Record<Seat, string>) {
|
|
19
|
+
return createSeatColorsSlice<Seat>('pack', defaultSeatColorsState(seatColors)).reducer
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// The slice's actions match by type alone, so these answer any pack's reducer above. The defaults
|
|
23
|
+
// they're built over are never read.
|
|
24
|
+
export const seatColorsActions = createSeatColorsSlice<Seat>('pack', defaultSeatColorsState({ 1: '', 2: '' })).actions
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { createThemeReducer, getThirdColor } from '@rific/auto-paper'
|
|
2
|
+
import { keepAwakeReducer } from '@rific/core'
|
|
3
|
+
import { defaultSoundSettings, hapticReducer, soundReducer, type SoundSettings } from '@rific/feedback-press'
|
|
4
|
+
import { profilesReducer } from '@tastic/profile'
|
|
5
|
+
|
|
6
|
+
import type { PackDefinition } from '../types'
|
|
7
|
+
import appSettings from './appSettingsSlice'
|
|
8
|
+
import type { LastActiveGameState } from './lastActiveGameSlice'
|
|
9
|
+
import liveplay from './liveplaySlice'
|
|
10
|
+
import opponent from './opponentSlice'
|
|
11
|
+
import profileSelection from './profileSelectionSlice'
|
|
12
|
+
import { createSeatColorsReducer } from './seatColorsSlice'
|
|
13
|
+
|
|
14
|
+
// @rific/feedback-press's own soundReducer defaults `enabled` to true unconditionally (that
|
|
15
|
+
// default isn't published with the dev-only override yet). Wrap it so a fresh install with no
|
|
16
|
+
// persisted preference (redux-persist finds nothing in AsyncStorage for the `sound` key) defaults
|
|
17
|
+
// muted in dev/simulator builds so Claude/local testing doesn't blast audio; production builds
|
|
18
|
+
// still default to sound on. All actual action handling still delegates to the package's reducer.
|
|
19
|
+
const initialSoundSettings: SoundSettings = { ...defaultSoundSettings, enabled: !__DEV__ }
|
|
20
|
+
const appSoundReducer = (state: SoundSettings = initialSoundSettings, action: { type: string }): SoundSettings => soundReducer(state, action)
|
|
21
|
+
|
|
22
|
+
// The store keys every pack has, whatever its games, built from the pack's own seat colors and
|
|
23
|
+
// appearance. Each game adds its own keys beside these (see GameDefinition.reducers).
|
|
24
|
+
export function createShellReducers({ seatColors, appearance = 'system' }: Pick<PackDefinition, 'seatColors' | 'appearance'>) {
|
|
25
|
+
return {
|
|
26
|
+
// Explicit primary/secondary triad (rather than a single seed expanded via harmony) so the pack's
|
|
27
|
+
// identity is pinned exactly instead of drifting with whatever hue math a harmony offset would
|
|
28
|
+
// produce. Primary/secondary ARE player 1/player 2's own default colors (PackDefinition.seatColors),
|
|
29
|
+
// so the theme and the default guest colors (seatColorsSlice.ts) can never drift apart. Tertiary is
|
|
30
|
+
// derived, not picked, via getThirdColor: the hue maximally distant from both (see
|
|
31
|
+
// @rific/auto-paper's README "Explicit triad" section). The appearance is the pack's ('system',
|
|
32
|
+
// createThemeReducer's own default, when it sets none).
|
|
33
|
+
theme: createThemeReducer({ appearance, color: { primary: seatColors[1], secondary: seatColors[2], tertiary: getThirdColor(seatColors[1], seatColors[2]) } }),
|
|
34
|
+
haptic: hapticReducer,
|
|
35
|
+
sound: appSoundReducer,
|
|
36
|
+
// @rific/core's Keep Awake setting (see Providers.tsx's KeepAwakeBridge), under the namespace its
|
|
37
|
+
// REHYDRATE handling expects.
|
|
38
|
+
keepAwake: keepAwakeReducer,
|
|
39
|
+
// The shared App Group roster (see @tastic/profile's resolveInitialProfiles/
|
|
40
|
+
// useSharedProfilesSync, wired in hooks/useProfiles.tsx) is layered on top of this as a separate
|
|
41
|
+
// sync target - this slice persisting normally via redux-persist is what gives it local-fallback
|
|
42
|
+
// behavior where the shared store isn't available, with no separate AsyncStorage path.
|
|
43
|
+
profiles: profilesReducer,
|
|
44
|
+
profileSelection,
|
|
45
|
+
// The loadout's "remember this color for next time" memory (guest, CPU, and each profile's pick
|
|
46
|
+
// in this pack) - separate from `theme` above, which always reflects whatever's live right now.
|
|
47
|
+
seatColors: createSeatColorsReducer(seatColors),
|
|
48
|
+
appSettings,
|
|
49
|
+
opponent,
|
|
50
|
+
// Transient per-round state, the one key blacklisted from persistence (see liveplaySlice.ts).
|
|
51
|
+
liveplay
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
type ShellReducers = ReturnType<typeof createShellReducers>
|
|
56
|
+
|
|
57
|
+
// Plus the one key only a pack with a resumable game mounts (see createPackStore.ts), so a pack
|
|
58
|
+
// without one saves exactly what it did before resume existed.
|
|
59
|
+
export type ShellState = { [K in keyof ShellReducers]: ReturnType<ShellReducers[K]> } & { lastActiveGame?: LastActiveGameState }
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { Redirect, useLocalSearchParams } from 'expo-router'
|
|
2
|
+
import { useEffect } from 'react'
|
|
3
|
+
|
|
4
|
+
import { useLastActiveGame } from '../hooks/useLastActiveGame'
|
|
5
|
+
import { usePackGame } from '../PackContext'
|
|
6
|
+
import type { GameDefinition } from '../types'
|
|
7
|
+
import { type LoadoutGame, LoadoutScreen } from './LoadoutScreen'
|
|
8
|
+
|
|
9
|
+
// The game routes, loadout/[game] and play/[game]: a game's loadout and its play screen, by id. An id
|
|
10
|
+
// the pack doesn't have (a stale link, a web URL typed by hand) goes Home. The game id comes second,
|
|
11
|
+
// never first: expo-router strips the web build's base path off the front of an in-app link as well
|
|
12
|
+
// as the address bar, so a route starting with a game id the same as the base path (boxhockey/loadout
|
|
13
|
+
// under /boxhockey, lightcycles/loadout under /lightcycles) would lose that segment and land on
|
|
14
|
+
// /loadout, "Unmatched Route".
|
|
15
|
+
export function LoadoutRoute() {
|
|
16
|
+
const { game: id } = useLocalSearchParams<{ game: string }>()
|
|
17
|
+
const game = usePackGame(id)
|
|
18
|
+
if (!game) return <Redirect href='/' />
|
|
19
|
+
// A game with nothing to set up has no loadout (GameDefinition.useLoadout): a link to one plays it.
|
|
20
|
+
if (!hasLoadout(game)) return <Redirect href={{ pathname: '/play/[game]', params: { game: game.id } }} />
|
|
21
|
+
return <LoadoutScreen key={game.id} game={game} />
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function hasLoadout(game: GameDefinition): game is LoadoutGame {
|
|
25
|
+
return !!game.useLoadout
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function PlayRoute() {
|
|
29
|
+
const { game: id } = useLocalSearchParams<{ game: string }>()
|
|
30
|
+
const game = usePackGame(id)
|
|
31
|
+
useResumePoint(game?.resumable ? game.id : null)
|
|
32
|
+
if (!game) return <Redirect href='/' />
|
|
33
|
+
const { PlayScreen } = game
|
|
34
|
+
return <PlayScreen key={game.id} />
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// A resumable game's play screen (GameDefinition.resumable) is where a cold launch comes back to:
|
|
38
|
+
// remembered as it mounts, and forgotten when it unmounts, which is the player backing out of it
|
|
39
|
+
// (its own back, hardware back or a browser back). Backgrounding the app leaves the screen mounted,
|
|
40
|
+
// so it stays remembered. Runs once per play screen: the context's functions never change.
|
|
41
|
+
function useResumePoint(gameId: string | null) {
|
|
42
|
+
const { setLastActiveGame, clearLastActiveGame } = useLastActiveGame()
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (!gameId) return
|
|
45
|
+
setLastActiveGame(gameId)
|
|
46
|
+
return clearLastActiveGame
|
|
47
|
+
}, [gameId, setLastActiveGame, clearLastActiveGame])
|
|
48
|
+
}
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
import { defaultColors, useAutoPaperTheme } from '@rific/auto-paper'
|
|
2
|
+
import { IconButton, Pressable } from '@rific/feedback-press'
|
|
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'
|
|
6
|
+
import { Redirect, router, useIsFocused } from 'expo-router'
|
|
7
|
+
import { useCallback, useEffect, useState } from 'react'
|
|
8
|
+
import { AppState, type AppStateStatus, ScrollView, StyleSheet, View } from 'react-native'
|
|
9
|
+
import { Icon, Text } from 'react-native-paper'
|
|
10
|
+
|
|
11
|
+
import { useGameGuide } from '../components/GuideHost'
|
|
12
|
+
import { SettingsDialog } from '../components/SettingsDialog'
|
|
13
|
+
import { useFrozenWhileHidden } from '../hooks/useFrozenWhileHidden'
|
|
14
|
+
import { useLastActiveGame } from '../hooks/useLastActiveGame'
|
|
15
|
+
import { useOpponent } from '../hooks/useOpponent'
|
|
16
|
+
import { useProfiles } from '../hooks/useProfiles'
|
|
17
|
+
import { useSeatColors } from '../hooks/useSeatColors'
|
|
18
|
+
import { usePack } from '../PackContext'
|
|
19
|
+
import type { GameDefinition } from '../types'
|
|
20
|
+
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
|
+
// Home: who's playing (seat 1, the same seat 1 every loadout starts with), then every game in the
|
|
23
|
+
// pack as a row in a fixed order, Solitaire's list: each game on its own well, its small app icon
|
|
24
|
+
// (or its preview, GameDefinition.Preview) beside its name and blurb. A tap opens that game's
|
|
25
|
+
// loadout, or its play screen in a game without one, after its How to Play cards the first time it's
|
|
26
|
+
// picked. Seat 2 is chosen in the loadout, from seat 2's own button.
|
|
27
|
+
//
|
|
28
|
+
// 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
|
+
export function HomeScreen() {
|
|
31
|
+
const pack = usePack()
|
|
32
|
+
const { consumeResumeGame } = useLastActiveGame()
|
|
33
|
+
// A one-shot cold-launch decision, read exactly once at this instance's mount (see
|
|
34
|
+
// hooks/useLastActiveGame.tsx), never a live value this screen re-renders from. Reading it live made
|
|
35
|
+
// every Home in the stack swap its whole menu for a Redirect while a game was on top and rebuild it
|
|
36
|
+
// the moment Home was shown again (in Solitaire, ~370 preview cards: going Home took seconds). An id
|
|
37
|
+
// the pack no longer has, or of a game that no longer resumes, is dropped.
|
|
38
|
+
const [resumeGame] = useState(() => {
|
|
39
|
+
const id = consumeResumeGame()
|
|
40
|
+
return pack.games.find((g) => g.id === id && g.resumable) ?? null
|
|
41
|
+
})
|
|
42
|
+
if (resumeGame) return <Redirect href={{ pathname: '/play/[game]', params: { game: resumeGame.id } }} />
|
|
43
|
+
return <HomeMenu />
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function HomeMenu() {
|
|
47
|
+
const pack = usePack()
|
|
48
|
+
const rotation = useRotation()
|
|
49
|
+
const layout = useHomeLayout()
|
|
50
|
+
const { insets } = layout
|
|
51
|
+
const { dark } = useAutoPaperTheme()
|
|
52
|
+
const { openFirstTime, openFor } = useGameGuide()
|
|
53
|
+
const [settingsOpen, setSettingsOpen] = useState(false)
|
|
54
|
+
const isFocused = useIsFocused()
|
|
55
|
+
const [appState, setAppState] = useState<AppStateStatus>(AppState.currentState)
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
const subscription = AppState.addEventListener('change', setAppState)
|
|
58
|
+
return () => subscription.remove()
|
|
59
|
+
}, [])
|
|
60
|
+
// The hero pauses (HeroTitleProps.active) while a pushed screen covers Home or the app is in the
|
|
61
|
+
// background, so it never animates where nobody can see it.
|
|
62
|
+
const heroActive = isFocused && appState === 'active'
|
|
63
|
+
|
|
64
|
+
const { profiles, lastSelected, selectProfile } = useProfiles()
|
|
65
|
+
const { seatTwo } = useOpponent()
|
|
66
|
+
const { p1Color, p2Color, handleP1ColorChange } = useSeatColors(seatTwo === 'person')
|
|
67
|
+
const pickerHost = usePopoverHost()
|
|
68
|
+
const selectedProfile = profiles.find((p) => p.id === lastSelected[1]) ?? null
|
|
69
|
+
|
|
70
|
+
// Tiles: while any game in the pack has a preview, a wide screen lays the games out as a grid beside
|
|
71
|
+
// the hero (homeLayout.ts). Home stays mounted beneath every screen the stack pushes and keeps
|
|
72
|
+
// following the rotation, so while it's covered the layout is held where it was (see
|
|
73
|
+
// useFrozenWhileHidden); it catches up when Home is shown again. Only the choice of layout is held:
|
|
74
|
+
// the panes' numbers follow the window, unseen under the screen covering Home.
|
|
75
|
+
const tiled = pack.games.some((game) => game.Preview)
|
|
76
|
+
const isWide = useFrozenWhileHidden(tiled && layout.isWide, !isFocused)
|
|
77
|
+
// Every preview is held back a frame rather than drawn in Home's first commit: a game's preview is
|
|
78
|
+
// far more to draw than an icon, and drawing them all in the commit a navigation lands on stalled
|
|
79
|
+
// the transition itself on a device in Solitaire, reading as "pressing Home hangs". For one frame
|
|
80
|
+
// each preview's box is empty. Home stays mounted under the screens it opens, so this happens once.
|
|
81
|
+
const [previewsRevealed, setPreviewsRevealed] = useState(false)
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
if (!tiled) return
|
|
84
|
+
const id = requestAnimationFrame(() => setPreviewsRevealed(true))
|
|
85
|
+
return () => cancelAnimationFrame(id)
|
|
86
|
+
}, [tiled])
|
|
87
|
+
// A popover is placed once, as it opens. Switching between the column and the two panes unmounts
|
|
88
|
+
// the very button it hangs off, which would leave the press-away overlay up with nothing to dismiss,
|
|
89
|
+
// so the switch closes it.
|
|
90
|
+
const closePicker = pickerHost.close
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
closePicker()
|
|
93
|
+
}, [isWide, closePicker])
|
|
94
|
+
|
|
95
|
+
const bg = dark ? '#000000' : '#FFFFFF'
|
|
96
|
+
const fg = dark ? '#FFFFFF' : '#000000'
|
|
97
|
+
|
|
98
|
+
// A game with nothing to set up (no useLoadout) opens straight to its play screen.
|
|
99
|
+
const openGame = useCallback((game: GameDefinition) => openFirstTime(game.id, () => router.push(game.useLoadout ? { pathname: '/loadout/[game]', params: { game: game.id } } : { pathname: '/play/[game]', params: { game: game.id } })), [openFirstTime])
|
|
100
|
+
|
|
101
|
+
const picker = <ProfilePicker idPrefix='home-p1' host={pickerHost} profiles={profiles} selectedId={lastSelected[1]} color={p1Color} dark={dark} guestLabel='P1' onSelect={(profile: Profile | null) => selectProfile(1, profile?.id ?? null)} onManage={() => router.push('/profiles')} colorSection={{ value: p1Color, onChange: handleP1ColorChange, swatches: defaultColors, caption: selectedProfile ? pack.colorCaption : undefined }} />
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<View style={[styles.container, { backgroundColor: bg }]}>
|
|
105
|
+
{/* 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`. */}
|
|
107
|
+
<FakeLandscapeView style={styles.rotatable}>
|
|
108
|
+
<PressAwayOverlay active={pickerHost.openId !== null} onPress={pickerHost.close} />
|
|
109
|
+
{isWide ? (
|
|
110
|
+
<>
|
|
111
|
+
{/* Right pane: the game grid, scrolling on its own beside the left pane. The scroller runs
|
|
112
|
+
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
|
+
<ScrollView style={[styles.wideRightPane, { left: layout.paddingLeft + layout.leftPaneWidth + HOME_WIDE_PANE_GAP }]} contentContainerStyle={{ paddingTop: 16 + insets.top, paddingBottom: 16 + insets.bottom, paddingRight: layout.paddingRight }}>
|
|
115
|
+
<View style={[styles.grid, { width: layout.rightPaneWidth }]}>
|
|
116
|
+
{pack.games.map((game) => (
|
|
117
|
+
<GameTile key={game.id} game={game} width={layout.tileWidth} previewWidth={layout.previewWidth} previewRevealed={previewsRevealed} dark={dark} p1Color={p1Color} p2Color={p2Color} onPress={() => openGame(game)} onRules={() => openFor(game.id)} />
|
|
118
|
+
))}
|
|
119
|
+
</View>
|
|
120
|
+
</ScrollView>
|
|
121
|
+
{/* Left pane: the hero, then seat 1's button, centred as one group under the corner
|
|
122
|
+
buttons' band. A later sibling of the grid, lifted while seat 1's menu is open so the menu
|
|
123
|
+
paints over the tiles. box-none: only its controls claim taps. */}
|
|
124
|
+
<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]}>
|
|
125
|
+
<pack.HeroTitle words={pack.wordmark} color={fg} p1Color={p1Color} p2Color={p2Color} active={heroActive} fit={layout.heroFit} />
|
|
126
|
+
<View style={styles.widePicker}>{picker}</View>
|
|
127
|
+
</View>
|
|
128
|
+
</>
|
|
129
|
+
) : (
|
|
130
|
+
<ScrollView style={styles.scroll} contentContainerStyle={[styles.content, { paddingTop: 56 + insets.top, paddingBottom: 24 + insets.bottom, paddingLeft: 16 + insets.left, paddingRight: 16 + insets.right }]}>
|
|
131
|
+
<pack.HeroTitle words={pack.wordmark} color={fg} p1Color={p1Color} p2Color={p2Color} active={heroActive} />
|
|
132
|
+
|
|
133
|
+
{/* Who's playing: seat 1's button, the same one each loadout starts with. Lifted above the
|
|
134
|
+
tiles while its menu is open, so the menu paints over them. */}
|
|
135
|
+
<View style={[styles.picker, pickerHost.openId !== null && styles.pickerOpen]}>{picker}</View>
|
|
136
|
+
|
|
137
|
+
<View style={styles.list}>
|
|
138
|
+
{pack.games.map((game) => (
|
|
139
|
+
<GameRow key={game.id} game={game} dark={dark} p1Color={p1Color} p2Color={p2Color} previewRevealed={previewsRevealed} onPress={() => openGame(game)} onRules={() => openFor(game.id)} />
|
|
140
|
+
))}
|
|
141
|
+
</View>
|
|
142
|
+
</ScrollView>
|
|
143
|
+
)}
|
|
144
|
+
|
|
145
|
+
<IconButton icon='trophy' iconColor={fg} size={24} style={[styles.corner, { top: 8 + insets.top, left: 8 + insets.left }]} onPress={() => router.push('/stats')} accessibilityLabel='Stats & Achievements' />
|
|
146
|
+
<IconButton icon='cog' iconColor={fg} size={24} style={[styles.corner, { top: 8 + insets.top, right: 8 + insets.right }]} onPress={() => setSettingsOpen(true)} accessibilityLabel='Settings' />
|
|
147
|
+
</FakeLandscapeView>
|
|
148
|
+
|
|
149
|
+
<SettingsDialog visible={settingsOpen} onDismiss={() => setSettingsOpen(false)} rotation={rotation} />
|
|
150
|
+
</View>
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
interface GameRowProps {
|
|
155
|
+
game: GameDefinition
|
|
156
|
+
dark: boolean
|
|
157
|
+
p1Color: string
|
|
158
|
+
p2Color: string
|
|
159
|
+
// Whether Home has painted once, so a preview may draw (see previewsRevealed).
|
|
160
|
+
previewRevealed: boolean
|
|
161
|
+
onPress: () => void
|
|
162
|
+
onRules: () => void
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// One game on its own well: its icon at app-icon size (the art was drawn as an app icon), or its
|
|
166
|
+
// preview where it has one, its name and one-line blurb, and a rules button at the end that shows its
|
|
167
|
+
// How to Play cards without opening the game.
|
|
168
|
+
function GameRow({ game, dark, p1Color, p2Color, previewRevealed, onPress, onRules }: GameRowProps) {
|
|
169
|
+
const well = useWellColors()
|
|
170
|
+
const GameIcon = game.icon
|
|
171
|
+
return (
|
|
172
|
+
// The rules button sits beside the row's own button, laid over its end, not inside it: a button
|
|
173
|
+
// inside a button is invalid on the web.
|
|
174
|
+
<View>
|
|
175
|
+
<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
|
+
)}
|
|
183
|
+
<View style={styles.rowText}>
|
|
184
|
+
<Text variant='titleLarge' style={[styles.rowTitle, { color: well.fg }]} numberOfLines={1}>
|
|
185
|
+
{game.title}
|
|
186
|
+
</Text>
|
|
187
|
+
<Text variant='bodyMedium' style={{ color: well.fgMuted }} numberOfLines={2}>
|
|
188
|
+
{game.blurb}
|
|
189
|
+
</Text>
|
|
190
|
+
</View>
|
|
191
|
+
</Pressable>
|
|
192
|
+
<Pressable onPress={onRules} style={styles.rulesButton} hitSlop={12} accessibilityRole='button' accessibilityLabel={`${game.title} rules`}>
|
|
193
|
+
<Icon source='information-outline' size={22} color={well.fgMuted} />
|
|
194
|
+
</Pressable>
|
|
195
|
+
</View>
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
interface GameTileProps extends GameRowProps {
|
|
200
|
+
width: number
|
|
201
|
+
previewWidth: number
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// One game in the wide layout's grid: the row turned on its side, its preview (or icon) on top and its
|
|
205
|
+
// name and blurb under it, since a row needs ~290pt, more than a grid column beside the hero has. The
|
|
206
|
+
// rules button floats over the preview's corner, beside the tile's button rather than inside it.
|
|
207
|
+
function GameTile({ game, width, previewWidth, dark, p1Color, p2Color, previewRevealed, onPress, onRules }: GameTileProps) {
|
|
208
|
+
const well = useWellColors()
|
|
209
|
+
return (
|
|
210
|
+
<View style={{ width }}>
|
|
211
|
+
<Pressable onPress={onPress} style={[styles.tile, { backgroundColor: well.bg }]} accessibilityRole='button' accessibilityLabel={game.title}>
|
|
212
|
+
<PreviewBox game={game} width={previewWidth} revealed={previewRevealed} dark={dark} p1Color={p1Color} p2Color={p2Color} />
|
|
213
|
+
<Text variant='titleMedium' style={[styles.rowTitle, { color: well.fg }]} numberOfLines={1}>
|
|
214
|
+
{game.title}
|
|
215
|
+
</Text>
|
|
216
|
+
<Text variant='bodySmall' style={{ color: well.fgMuted }} numberOfLines={3}>
|
|
217
|
+
{game.blurb}
|
|
218
|
+
</Text>
|
|
219
|
+
</Pressable>
|
|
220
|
+
<Pressable onPress={onRules} style={styles.tileRulesButton} hitSlop={12} accessibilityRole='button' accessibilityLabel={`${game.title} rules`}>
|
|
221
|
+
<Icon source='information-outline' size={20} color='#FFFFFF' />
|
|
222
|
+
</Pressable>
|
|
223
|
+
</View>
|
|
224
|
+
)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
interface PreviewBoxProps {
|
|
228
|
+
game: GameDefinition
|
|
229
|
+
width: number
|
|
230
|
+
revealed: boolean
|
|
231
|
+
dark: boolean
|
|
232
|
+
p1Color: string
|
|
233
|
+
p2Color: string
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// A box of the preview's shape (PREVIEW_ASPECT) holding the game's preview, once Home has painted, or
|
|
237
|
+
// its icon centred where it has none (a tile in a pack where only some games have one).
|
|
238
|
+
function PreviewBox({ game, width, revealed, dark, p1Color, p2Color }: PreviewBoxProps) {
|
|
239
|
+
const height = Math.round(width / PREVIEW_ASPECT)
|
|
240
|
+
const { Preview, icon: GameIcon } = game
|
|
241
|
+
const iconSize = Math.min(height, ROW_ICON_SIZE)
|
|
242
|
+
return (
|
|
243
|
+
<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
|
+
<View style={[styles.previewIcon, { width: iconSize, height: iconSize, borderRadius: iconSize * 0.22 }]}>
|
|
248
|
+
<GameIcon size={iconSize} p1Color={p1Color} p2Color={p2Color} dark={dark} />
|
|
249
|
+
</View>
|
|
250
|
+
)}
|
|
251
|
+
</View>
|
|
252
|
+
)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// The icon's size on a row: an app icon's, with its rounding.
|
|
256
|
+
const ROW_ICON_SIZE = 56
|
|
257
|
+
// The app icon's backgrounds (assets/images/icon-light.png and icon-dark.png), behind every game's art.
|
|
258
|
+
const ICON_LIGHT_BG = '#FAFAFA'
|
|
259
|
+
const ICON_DARK_BG = '#050505'
|
|
260
|
+
// Room at a row's end for the rules button laid over it.
|
|
261
|
+
const RULES_BUTTON_SPACE = 44
|
|
262
|
+
|
|
263
|
+
const styles = StyleSheet.create({
|
|
264
|
+
container: {
|
|
265
|
+
flex: 1
|
|
266
|
+
},
|
|
267
|
+
content: {
|
|
268
|
+
alignItems: 'center',
|
|
269
|
+
gap: 24
|
|
270
|
+
},
|
|
271
|
+
corner: {
|
|
272
|
+
position: 'absolute'
|
|
273
|
+
},
|
|
274
|
+
// The wide layout's tiles: wrapped rows of tiles the layout sizes to fill the pane's width.
|
|
275
|
+
grid: {
|
|
276
|
+
flexDirection: 'row',
|
|
277
|
+
flexWrap: 'wrap',
|
|
278
|
+
gap: HOME_GRID_GAP
|
|
279
|
+
},
|
|
280
|
+
// Solitaire's list: one column, never wider than a phone's, centred on a wide screen.
|
|
281
|
+
list: {
|
|
282
|
+
gap: 14,
|
|
283
|
+
maxWidth: 480,
|
|
284
|
+
width: '100%'
|
|
285
|
+
},
|
|
286
|
+
picker: {
|
|
287
|
+
alignItems: 'center'
|
|
288
|
+
},
|
|
289
|
+
pickerOpen: {
|
|
290
|
+
zIndex: 100
|
|
291
|
+
},
|
|
292
|
+
// The preview's own rounding, clipping whatever it draws to the box.
|
|
293
|
+
preview: {
|
|
294
|
+
alignItems: 'center',
|
|
295
|
+
borderRadius: 8,
|
|
296
|
+
justifyContent: 'center',
|
|
297
|
+
overflow: 'hidden'
|
|
298
|
+
},
|
|
299
|
+
previewIcon: {
|
|
300
|
+
overflow: 'hidden'
|
|
301
|
+
},
|
|
302
|
+
rotatable: {
|
|
303
|
+
flex: 1
|
|
304
|
+
},
|
|
305
|
+
// A well (@tastic/hud's wellSurface): the theme's surface on MD3's level-1 shadow, the same surface
|
|
306
|
+
// as Solitaire's game rows, the stats screen's sections and every dialog.
|
|
307
|
+
row: {
|
|
308
|
+
alignItems: 'center',
|
|
309
|
+
borderRadius: 12,
|
|
310
|
+
boxShadow: WELL_SHADOW,
|
|
311
|
+
flexDirection: 'row',
|
|
312
|
+
gap: 14,
|
|
313
|
+
padding: 14,
|
|
314
|
+
paddingRight: RULES_BUTTON_SPACE
|
|
315
|
+
},
|
|
316
|
+
rowIcon: {
|
|
317
|
+
borderRadius: ROW_ICON_SIZE * 0.22,
|
|
318
|
+
height: ROW_ICON_SIZE,
|
|
319
|
+
overflow: 'hidden',
|
|
320
|
+
width: ROW_ICON_SIZE
|
|
321
|
+
},
|
|
322
|
+
rowText: {
|
|
323
|
+
flex: 1,
|
|
324
|
+
gap: 2
|
|
325
|
+
},
|
|
326
|
+
rowTitle: {
|
|
327
|
+
fontWeight: 'bold'
|
|
328
|
+
},
|
|
329
|
+
rulesButton: {
|
|
330
|
+
bottom: 0,
|
|
331
|
+
justifyContent: 'center',
|
|
332
|
+
position: 'absolute',
|
|
333
|
+
right: 14,
|
|
334
|
+
top: 0
|
|
335
|
+
},
|
|
336
|
+
scroll: {
|
|
337
|
+
flex: 1
|
|
338
|
+
},
|
|
339
|
+
// The same well as a row, turned on its side.
|
|
340
|
+
tile: {
|
|
341
|
+
borderRadius: 12,
|
|
342
|
+
boxShadow: WELL_SHADOW,
|
|
343
|
+
gap: 6,
|
|
344
|
+
padding: HOME_TILE_PADDING
|
|
345
|
+
},
|
|
346
|
+
// A small translucent disc over the preview's top-right corner, so the white icon reads on whatever
|
|
347
|
+
// the preview draws. hitSlop (on the Pressable) grows its touch area.
|
|
348
|
+
tileRulesButton: {
|
|
349
|
+
alignItems: 'center',
|
|
350
|
+
backgroundColor: 'rgba(0, 0, 0, 0.35)',
|
|
351
|
+
borderRadius: 14,
|
|
352
|
+
height: 28,
|
|
353
|
+
justifyContent: 'center',
|
|
354
|
+
position: 'absolute',
|
|
355
|
+
right: HOME_TILE_PADDING + 4,
|
|
356
|
+
top: HOME_TILE_PADDING + 4,
|
|
357
|
+
width: 28
|
|
358
|
+
},
|
|
359
|
+
// The two panes are absolutely positioned siblings of the press-away overlay, each placed by the
|
|
360
|
+
// offsets homeLayout.ts computes.
|
|
361
|
+
wideLeftPane: {
|
|
362
|
+
alignItems: 'center',
|
|
363
|
+
bottom: 0,
|
|
364
|
+
justifyContent: 'center',
|
|
365
|
+
pointerEvents: 'box-none',
|
|
366
|
+
position: 'absolute',
|
|
367
|
+
top: 0
|
|
368
|
+
},
|
|
369
|
+
widePicker: {
|
|
370
|
+
marginTop: HOME_LEFT_PANE_PICKER_GAP
|
|
371
|
+
},
|
|
372
|
+
wideRightPane: {
|
|
373
|
+
bottom: 0,
|
|
374
|
+
position: 'absolute',
|
|
375
|
+
right: 0,
|
|
376
|
+
top: 0
|
|
377
|
+
}
|
|
378
|
+
})
|