@tastic/shell 0.6.0 → 0.8.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.
@@ -0,0 +1,112 @@
1
+ import type { SeedColor } from '@rific/auto-paper'
2
+ import type { AlignResult, PopoverHost } from '@tastic/hud/chrome'
3
+ import { type MenuOption, useZoneClampedAlign } from '@tastic/hud/lobby'
4
+ import { getOptionPickerContentSize, OptionPicker, type OptionPickerOption, type ProfilePickerColorSection } from '@tastic/profile'
5
+ import { useMemo } from 'react'
6
+ import { type StyleProp, StyleSheet, View, type ViewStyle } from 'react-native'
7
+
8
+ import type { SeatTwo } from '../types'
9
+
10
+ // Player 1's say over seat 2 (Jay, 2026-11-07): one button on player 1's side, beside their own
11
+ // seat button, in every mode. It lists the CPU's levels (or one CPU row for a CPU with none), Solo
12
+ // (seat 2 empty, 'nobody') where the game has a solo mode, and Player, which hands seat 2 to a person,
13
+ // each kind of choice behind a divider (Jay, 2026-11-07): the loadout splits face
14
+ // to face and player 2 gets their own seat button (hud's PlayerSetupPanel, Player 2 or a profile,
15
+ // never the CPU). Player 1 keeps this button, so they're the one who takes the seat back for the CPU.
16
+ //
17
+ // The CPU's color is this button's Color row, as it was seat 2's: a CPU has no button of its own. A
18
+ // person's color is on their own button and Solo has none, so the row is only here for the CPU.
19
+ //
20
+ // Built on @tastic/profile's OptionPicker, so it opens, places itself and closes like the seat
21
+ // buttons beside it. It opens under `${idPrefix}-opponent` on player 1's host, so the loadout counts
22
+ // it as one of player 1's menus.
23
+
24
+ const CPU_PREFIX = 'cpu:'
25
+ const NOBODY = 'nobody'
26
+ const PERSON = 'person'
27
+
28
+ const PERSON_ICON = 'account-outline'
29
+ const NOBODY_ICON = 'account-off-outline'
30
+ const CPU_ICON = 'robot'
31
+
32
+ export function getOpponentPopoverId(idPrefix: string): string {
33
+ return `${idPrefix}-opponent`
34
+ }
35
+
36
+ export interface OpponentPickerProps {
37
+ // Player 1's prefix: this button is one of their menus.
38
+ idPrefix: string
39
+ host: PopoverHost
40
+ // Seat 2's choices in this game (GameDefinition.seatTwo), in menu order.
41
+ choices: readonly SeatTwo[]
42
+ seatTwo: SeatTwo
43
+ onSeatTwoChange: (seatTwo: SeatTwo) => void
44
+ // The CPU's levels (GameLoadout.cpuDifficulty), or undefined for a CPU with none: one CPU row then.
45
+ cpuDifficulty?: { value: string; options: MenuOption<string>[]; onChange: (value: string) => void }
46
+ // Seat 2's color: the button's chip, and the CPU's Color row.
47
+ color: string
48
+ onColorChange: (hex: string) => void
49
+ swatches: SeedColor[]
50
+ takenColor?: string
51
+ allowSwapTaken?: boolean
52
+ dark: boolean
53
+ // How seat 2's color is drawn (PackDefinition.previewColor), as PlayerSetupPanel's own previewValue:
54
+ // `color` and what onColorChange receives stay the saved values.
55
+ previewValue?: (hex: string) => string
56
+ // The button's trigger style (a surface behind its pill over the pack's backdrop).
57
+ triggerStyle?: StyleProp<ViewStyle>
58
+ // A placement hook of the pack's own for the menu, in place of the zone clamp (PlayerSetupPanel's
59
+ // useSeatPickerAlign): left unset inside a split-screen zone, and the same one for the button's life.
60
+ usePickerAlign?: (open: boolean, contentWidth: number, contentHeight: number) => AlignResult
61
+ }
62
+
63
+ export function OpponentPicker({ idPrefix, host, choices, seatTwo, onSeatTwoChange, cpuDifficulty, color, onColorChange, swatches, takenColor, allowSwapTaken, dark, previewValue, triggerStyle, usePickerAlign }: OpponentPickerProps) {
64
+ const drawn = previewValue ? previewValue(color) : color
65
+
66
+ const cpuOptions = cpuDifficulty?.options
67
+ const options = useMemo<OptionPickerOption<string>[]>(() => {
68
+ const list: OptionPickerOption<string>[] = []
69
+ if (choices.includes('cpu')) list.push(...(cpuOptions ? cpuOptions.map((o) => ({ value: `${CPU_PREFIX}${o.value}`, label: o.label, icon: o.icon })) : [{ value: CPU_PREFIX, label: 'CPU', icon: CPU_ICON }]))
70
+ if (choices.includes('nobody')) list.push({ value: NOBODY, label: 'Solo', icon: NOBODY_ICON, dividerBefore: true })
71
+ if (choices.includes('person')) list.push({ value: PERSON, label: 'Player', icon: PERSON_ICON, dividerBefore: true })
72
+ return list
73
+ }, [choices, cpuOptions])
74
+
75
+ const value = seatTwo === 'cpu' ? `${CPU_PREFIX}${cpuDifficulty?.value ?? ''}` : seatTwo === 'nobody' ? NOBODY : PERSON
76
+ const icon = seatTwo === 'cpu' ? CPU_ICON : seatTwo === 'nobody' ? NOBODY_ICON : PERSON_ICON
77
+
78
+ const handleChange = (next: string) => {
79
+ if (next.startsWith(CPU_PREFIX)) {
80
+ cpuDifficulty?.onChange(next.slice(CPU_PREFIX.length))
81
+ onSeatTwoChange('cpu')
82
+ } else {
83
+ onSeatTwoChange(next === NOBODY ? 'nobody' : 'person')
84
+ }
85
+ }
86
+
87
+ const colorSection: ProfilePickerColorSection | undefined = seatTwo === 'cpu' ? { value: color, onChange: onColorChange, swatches, takenValue: takenColor, allowSwapTaken, previewValue } : undefined
88
+
89
+ const pickerId = getOpponentPopoverId(idPrefix)
90
+ const size = getOptionPickerContentSize(options, colorSection)
91
+ // Kept inside player 1's half in 2 Player, like their own seat button, or placed by the pack's own
92
+ // hook where it's given one.
93
+ const useAlign = usePickerAlign ?? useZoneClampedAlign
94
+ const align = useAlign(host.openId === pickerId, size.width, size.height)
95
+
96
+ return (
97
+ <View style={[styles.button, host.openId === pickerId && styles.buttonOpen]}>
98
+ <OptionPicker id={pickerId} host={host} options={options} value={value} onChange={handleChange} color={drawn} dark={dark} icon={icon} colorSection={colorSection} alignOverride={align} triggerStyle={triggerStyle} />
99
+ </View>
100
+ )
101
+ }
102
+
103
+ const styles = StyleSheet.create({
104
+ // Its own column, so a row of seat buttons doesn't stretch the pill to the tallest of them.
105
+ button: {
106
+ alignItems: 'center'
107
+ },
108
+ // A popover escaping this button needs the button itself elevated to paint above a later sibling.
109
+ buttonOpen: {
110
+ zIndex: 100
111
+ }
112
+ })
@@ -17,20 +17,25 @@ export interface SettingsDialogProps {
17
17
  children?: ReactNode
18
18
  }
19
19
 
20
+ // A pack without rules to put first (PackDefinition.useSettingsRules).
21
+ const noSettingsRules = () => null
22
+
20
23
  // The pack-wide Settings dialog: everything the shell puts in it applies to every game (Edge Guard,
21
24
  // theme, sound, haptics, Keep Awake), so it reads and writes the app
22
- // settings itself. Under those, a play screen's own rows and then the pack's own sections
25
+ // settings itself. Above those, the rules of the game it's opened in (PackDefinition.useSettingsRules);
26
+ // under them, a play screen's own rows and then the pack's own sections
23
27
  // (PackDefinition.SettingsSections), in every Settings dialog the pack opens. A pack with edgeGuard
24
28
  // false gets no Edge Guard row: hud draws it on iOS whenever its props are passed.
25
29
  export function SettingsDialog({ visible, onDismiss, onShowHowToPlay, children }: SettingsDialogProps) {
26
30
  const onUpdateError = useUpdateErrorToast()
27
31
  const { settings, setSettings } = useAppSettings()
28
32
  // The pack's otaVersion (src/constants/release.ts, which the OTA tooling bumps).
29
- const { otaVersion, SettingsSections, edgeGuard = true } = usePack()
33
+ const { otaVersion, SettingsSections, edgeGuard = true, useSettingsRules = noSettingsRules } = usePack()
34
+ const rules = useSettingsRules()
30
35
  const edgeGuardProps = edgeGuard ? { deferBottomEdgeGestures: settings.deferBottomEdgeGestures, onDeferBottomEdgeGestures: (value: boolean) => setSettings({ deferBottomEdgeGestures: value }) } : {}
31
36
 
32
37
  return (
33
- <BaseSettingsDialog visible={visible} onDismiss={onDismiss} version={otaVersion} {...edgeGuardProps} onUpdateError={onUpdateError} onShowHowToPlay={onShowHowToPlay}>
38
+ <BaseSettingsDialog visible={visible} onDismiss={onDismiss} version={otaVersion} {...edgeGuardProps} onUpdateError={onUpdateError} onShowHowToPlay={onShowHowToPlay} leading={rules}>
34
39
  {children}
35
40
  {SettingsSections && <SettingsSections />}
36
41
  </BaseSettingsDialog>
@@ -0,0 +1,13 @@
1
+ import { useIsTouchPrimaryDevice } from '@tastic/core'
2
+ import { Platform } from 'react-native'
3
+
4
+ // Whether this is a computer: the web without a touch-first screen (@tastic/core's
5
+ // useIsTouchPrimaryDevice). There two players
6
+ // share one upright screen at one keyboard, whatever the window's shape, so 2 Player sits side by side
7
+ // (Jay, 2026-11-07: face to face only on a phone or tablet, natively or in its browser) and the match
8
+ // chips stay up in the page's corners. The loadout (GameDefinition.sideBySideAtComputer), MatchChips,
9
+ // and a pack's own match seating all read this one test.
10
+ export function useAtComputer(): boolean {
11
+ const touchPrimary = useIsTouchPrimaryDevice()
12
+ return Platform.OS === 'web' && !touchPrimary
13
+ }
package/src/index.ts CHANGED
@@ -15,10 +15,11 @@ export { StatsScreen } from './screens/StatsScreen'
15
15
  // What a game's own screens use.
16
16
  export { useGameGuide } from './components/GuideHost'
17
17
  export { imageGameIcon } from './components/imageGameIcon'
18
- export { MATCH_CHIPS_DESKTOP_MIN_WIDTH, MATCH_CHIPS_FADE_MS, MatchChips, type MatchChipsProps } from './components/MatchChips'
18
+ export { MATCH_CHIPS_FADE_MS, MatchChips, type MatchChipsProps } from './components/MatchChips'
19
19
  export { SettingsDialog, type SettingsDialogProps } from './components/SettingsDialog'
20
20
  export { useActiveRound } from './hooks/useActiveRound'
21
21
  export { useAppSettings } from './hooks/useAppSettings'
22
+ export { useAtComputer } from './hooks/useAtComputer'
22
23
  export { useOpponent } from './hooks/useOpponent'
23
24
  export { ProfilesProvider, useProfiles } from './hooks/useProfiles'
24
25
  export { useSeatColors } from './hooks/useSeatColors'
@@ -2,7 +2,7 @@ import { createSettingsSlice } from '@rific/core'
2
2
 
3
3
  import type { SeatTwo } from '../types'
4
4
 
5
- // Who sits in seat 2, for the whole pack: picked from seat 2's own button in any game's loadout and
5
+ // Who sits in seat 2, for the whole pack: picked from player 1's opponent button in any game's loadout and
6
6
  // kept from one game to the next and across relaunches, since it's usually the same two people.
7
7
  // A fresh install starts on the CPU, so one person (App Review included) can tap a game and play.
8
8
  // A game without 'nobody' among its seat-2 choices treats a saved 'nobody' as the CPU (see
@@ -23,7 +23,7 @@ import { HOME_GRID_GAP, HOME_LEFT_PANE_PICKER_GAP, HOME_TILE_PADDING, HOME_TOP_B
23
23
  // pack as a row in a fixed order, Solitaire's list: each game on its own well, its small app icon
24
24
  // (or its preview, GameDefinition.Preview) beside its name and blurb. A tap opens that game's
25
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. A pack with tabs
26
+ // picked. Seat 2 is chosen in the loadout, from player 1's opponent button. A pack with tabs
27
27
  // (PackDefinition.homeTabs) shows one tab's games at a time, from a switch over the rows.
28
28
  //
29
29
  // A cold launch that finds the player left the app inside a resumable game's play screen
@@ -13,8 +13,9 @@ import { useDispatch } from 'react-redux'
13
13
  import type { Action, Dispatch } from 'redux'
14
14
 
15
15
  import { useGameGuide } from '../components/GuideHost'
16
- import { SeatTwoPanel } from '../components/SeatTwoPanel'
16
+ import { OpponentPicker } from '../components/OpponentPicker'
17
17
  import { SettingsDialog } from '../components/SettingsDialog'
18
+ import { useAtComputer } from '../hooks/useAtComputer'
18
19
  import { useOpponent } from '../hooks/useOpponent'
19
20
  import { useProfiles } from '../hooks/useProfiles'
20
21
  import { useSeatColors } from '../hooks/useSeatColors'
@@ -31,13 +32,18 @@ import { humanSeatsFor, type PackGameDefinition, roundModeFor, type Seat, type S
31
32
  // already happened at the OS's own pace, not a deliberate reveal.
32
33
  const PANEL_SWAP_FADE_MS = 180
33
34
 
35
+ // The popover ids a game's seat controls open under (GameLoadout.seatControl).
36
+ const P1_CONTROL_ID = 'p1-control'
37
+ const P2_CONTROL_ID = 'p2-control'
38
+
34
39
  // A game that has a loadout (GameDefinition.useLoadout): the loadout route sends any other straight
35
40
  // to its play screen.
36
41
  export type LoadoutGame = PackGameDefinition & Required<Pick<PackGameDefinition, 'useLoadout'>>
37
42
 
38
43
  // Every game's loadout: seat 1 (the player picked on Home), seat 2 (the CPU, a second person, or
39
- // nobody, from seat 2's own button, or no button in a game for one player), the game's own match
40
- // options, and Ready. Remounted per game (the route keys it by id), so a game's useLoadout hook only
44
+ // nobody, picked on player 1's opponent button beside their seat button, or no button in a game for
45
+ // one player), the game's own match options, and Ready. A person in seat 2 gets their own seat button
46
+ // (Player 2 or a profile) in their half; only player 1 can hand the seat back to the CPU. Remounted per game (the route keys it by id), so a game's useLoadout hook only
41
47
  // ever runs for that one game.
42
48
  export function LoadoutScreen({ game }: { game: LoadoutGame }) {
43
49
  const pack = usePack()
@@ -57,12 +63,16 @@ export function LoadoutScreen({ game }: { game: LoadoutGame }) {
57
63
  // face to face otherwise. The OS turns the interface; a portrait-locked phone is always face to face.
58
64
  const { orientationMode } = useOrientationState()
59
65
 
66
+ // A computer: the web without a touch-first screen (see GameDefinition.sideBySideAtComputer).
67
+ const atComputer = useAtComputer()
60
68
  // The panel area's own layout lags one fade behind the window above - see useDualZoneLayout's own
61
- // comment. 2 Player seats its players the way the game's own board does: face to face (P1 in the
62
- // bottom half, P2 in the top) in any window, unless the game seats them side by side in a wide one
63
- // (GameDefinition.sideBySideWhenWide), P1 on the left, so each player gets the same half here and
64
- // in the match. With one person the panels follow the window.
65
- const panelMode = p2IsHuman && !game.sideBySideWhenWide ? 'faceToFace' : orientationMode
69
+ // comment. 2 Player seats its players the way the game's own match does: face to face (P1 in the
70
+ // bottom half, P2 in the top) in any window, unless the game seats them side by side at a computer
71
+ // (GameDefinition.sideBySideAtComputer) or in a wide window (sideBySideWhenWide), P1 on the left,
72
+ // so each player gets the same half here and in the match. With one person the panels follow the
73
+ // window.
74
+ const twoPlayerMode = game.sideBySideAtComputer && atComputer ? 'sideBySide' : game.sideBySideWhenWide ? orientationMode : 'faceToFace'
75
+ const panelMode = p2IsHuman ? twoPlayerMode : orientationMode
66
76
  const { panelLayout, panelFadeStyle } = useDualZoneLayout(panelMode, false, true, false, PANEL_SWAP_FADE_MS)
67
77
  // split-screen types the fade as a style for any component (useAnimatedStyle's DefaultStyle). An app
68
78
  // whose types widen Text's style (Expo's react-native-web types, which a generated expo-env.d.ts pulls
@@ -75,10 +85,11 @@ export function LoadoutScreen({ game }: { game: LoadoutGame }) {
75
85
  const { p1Color, p2Color, handleP1ColorChange, handleP2ColorChange } = useSeatColors(p2IsHuman)
76
86
  const dispatch = useDispatch<Dispatch<Action>>()
77
87
 
78
- // A seat's color follows its newly selected profile through the profile-color effect above.
79
- // Picking the other seat's profile swaps the two (see @tastic/profile's select).
88
+ // A seat's color follows its newly selected profile (useSeatColors). Picking the other seat's
89
+ // profile swaps the two (see @tastic/profile's select): only reachable from seat 1 with one person,
90
+ // since 2 Player marks each seat's profile taken in the other's menu.
80
91
  const handleP1ProfileSelect = useCallback((profile: Profile | null) => selectProfile(1, profile?.id ?? null), [selectProfile])
81
- const handleP2ProfileSelect = useCallback((profileId: string | null) => selectProfile(2, profileId), [selectProfile])
92
+ const handleP2ProfileSelect = useCallback((profile: Profile | null) => selectProfile(2, profile?.id ?? null), [selectProfile])
82
93
 
83
94
  const [ready, setReady] = useState<Record<Seat, boolean>>({ 1: false, 2: false })
84
95
 
@@ -127,6 +138,9 @@ export function LoadoutScreen({ game }: { game: LoadoutGame }) {
127
138
  const p1Drawn = seatPreview ? seatPreview(p1Color) : p1Color
128
139
  const p2Drawn = seatPreview ? seatPreview(p2Color) : p2Color
129
140
  const triggerStyle = useMemo(() => (pack.Backdrop ? [styles.pill, { backgroundColor: themeColors.surface }] : undefined), [pack.Backdrop, themeColors.surface])
141
+ // Ready too: a pill on the surface, filled in the seat's color once ready, over a backdrop, where the
142
+ // outline in a muted seat color read dim (Solitaire's felt); the outline on a plain screen.
143
+ const readySurface = pack.Backdrop ? themeColors.surface : undefined
130
144
 
131
145
  const isPortrait = panelLayout.orientationMode === 'faceToFace'
132
146
  // See @tastic/split-screen's needsSharedNeutralZone: whether back/settings/randomize/reset fold
@@ -135,27 +149,25 @@ export function LoadoutScreen({ game }: { game: LoadoutGame }) {
135
149
 
136
150
  // Where P2's own press-away zone lives on screen - matches panelLayout (what's actually painted
137
151
  // right now, mid-fade included). Portrait puts P2's panel in the top half; landscape on the right
138
- // (P1 sits on the left). Only in 2 Player - otherwise seat 2 shares seat 1's host (see p2Host below).
152
+ // (P1 sits on the left). Only in 2 Player: with one person there's no seat 2 button.
139
153
  const p2ZoneStyle = p2IsHuman ? (isPortrait ? styles.p2ZoneTop : styles.p2ZoneRight) : null
140
154
 
141
- // With one human, the one Ready button sits under both panels instead of in each.
142
- const showReadyButton = p2IsHuman
143
155
  // The seat buttons' menus are placed by the pack where it places them itself (PackDefinition.
144
156
  // usePickerAlign), as on Home, but only in the one-human row: 2 Player's halves keep each menu in
145
157
  // its own player's half (hud's useZoneClampedAlign, the panels' own). The panels are keyed by layout,
146
158
  // so a placement hook never changes under a mounted panel.
147
159
  const seatPickerAlign = p2IsHuman ? undefined : pack.usePickerAlign
148
160
  const layoutKey = p2IsHuman ? 'zones' : 'row'
149
- // P1's panel and the match options share one host - they sit in the same near/bottom zone. With
150
- // one human, seat 2's panel joins it too (one person drives both, so two open menus at once would
151
- // be clutter). 2 Player gives P2 their own host so two people can pick at once, each press-away
152
- // zone closing only its own player's menu.
161
+ // Player 1's buttons (their seat's, the opponent's and their controls) and the match options share
162
+ // one host - they sit in the same near/bottom zone, and one person drives them all. 2 Player gives
163
+ // player 2 their own host so two people can pick at once, each press-away zone closing only its own
164
+ // player's menu.
153
165
  const controlsHost = usePopoverHost()
154
- const p2PanelHost = usePopoverHost()
155
- const p2Host = p2IsHuman ? p2PanelHost : controlsHost
156
- // The one-human Ready button is centered under the whole row, not under either seat button, so an
157
- // open seat menu can't fully cover it; it fades out while one is open instead.
158
- const anyPlayerPickerOpen = !!(controlsHost.openId?.startsWith('p1-') || controlsHost.openId?.startsWith('p2-'))
166
+ const p2Host = usePopoverHost()
167
+ // Player 1's menus: their seat button's, the opponent button's and their controls'. With one
168
+ // person, player 1's Ready button fades out while one is open (a menu can't fully cover it).
169
+ const p1PickerOpen = controlsHost.openId?.startsWith('p1-') ?? false
170
+ const p1ControlOpen = controlsHost.openId === P1_CONTROL_ID
159
171
 
160
172
  const optionsRow = loadout.options({
161
173
  host: controlsHost,
@@ -179,10 +191,29 @@ export function LoadoutScreen({ game }: { game: LoadoutGame }) {
179
191
 
180
192
  const controlLook = { accentColor: themeColors.tertiary, mutedColor: fgMuted, dark }
181
193
  const p1Control = loadout.seatControl(1, controlsHost, { ...controlLook, seatColor: p1Drawn })
182
- const p1Panel = <PlayerSetupPanel key={`p1-${layoutKey}`} 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={pack.guestLabel ?? 'P1'} nullLabel={pack.nullLabel} onProfileSelect={handleP1ProfileSelect} onManageProfiles={() => router.push(PROFILES_HREF)} previewValue={seatPreview} triggerStyle={triggerStyle} useSeatPickerAlign={seatPickerAlign} />
194
+ // Seat 1's button alone: player 1's controls and Ready sit under the row it shares with the
195
+ // opponent button, centered under both.
196
+ const p1Panel = <PlayerSetupPanel key={`p1-${layoutKey}`} idPrefix='p1' host={controlsHost} color={p1Color} onColorChange={handleP1ColorChange} swatches={defaultColors} takenColor={p2Exists ? p2Color : undefined} allowSwapTaken={mode === 'vsCpu'} colorCaption={pack.colorCaption} isHuman dark={dark} showReadyButton={false} profiles={profiles} selectedProfileId={lastSelected[1]} takenProfileId={p2IsHuman ? lastSelected[2] : null} guestLabel={pack.guestLabel ?? 'P1'} nullLabel={pack.nullLabel} onProfileSelect={handleP1ProfileSelect} onManageProfiles={() => router.push(PROFILES_HREF)} previewValue={seatPreview} triggerStyle={triggerStyle} useSeatPickerAlign={seatPickerAlign} />
197
+ // Who sits in seat 2, player 1's to pick in every mode. A game for one player (no seatTwo) has none:
198
+ // seat 1's button stands alone.
199
+ const opponentPicker = game.seatTwo && <OpponentPicker key={`opponent-${layoutKey}`} idPrefix='p1' host={controlsHost} choices={game.seatTwo} seatTwo={seatTwo} onSeatTwoChange={changeSeatTwo} cpuDifficulty={loadout.cpuDifficulty} color={p2Color} onColorChange={handleP2ColorChange} swatches={defaultColors} takenColor={p1Color} allowSwapTaken={mode === 'vsCpu'} dark={dark} previewValue={seatPreview} triggerStyle={triggerStyle} usePickerAlign={seatPickerAlign} />
200
+ const toggleP1Ready = () => setReady((r) => ({ ...r, 1: !r[1] }))
201
+ const seatOne = (
202
+ <View style={[styles.seatOne, !p2IsHuman && styles.seatOneAlone, p1PickerOpen && styles.raised]}>
203
+ <View style={[styles.seatOneButtons, p1PickerOpen && styles.raised]}>
204
+ <Animated.View style={[styles.playersRow, p1PickerOpen && styles.raised, !p2IsHuman && playersRowFade]}>
205
+ {p1Panel}
206
+ {opponentPicker}
207
+ </Animated.View>
208
+ {p1Control && <View style={[styles.controlRow, p1ControlOpen && styles.raised]}>{p1Control}</View>}
209
+ </View>
210
+ <ReadyButton color={p1Drawn} ready={ready[1]} onToggleReady={toggleP1Ready} surfaceColor={readySurface} dark={dark} style={!p2IsHuman && p1PickerOpen && styles.readyButtonHidden} />
211
+ </View>
212
+ )
213
+ // A person in seat 2: their own seat button (Player 2 or a profile, never the CPU), controls and
214
+ // Ready, in their half.
183
215
  const p2Control = p2IsHuman ? loadout.seatControl(2, p2Host, { ...controlLook, seatColor: p2Drawn }) : undefined
184
- // A game for one player (no seatTwo) has no seat 2 button: seat 1's panel stands alone.
185
- const p2Panel = game.seatTwo && <SeatTwoPanel key={`p2-${layoutKey}`} 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} previewValue={seatPreview} triggerStyle={triggerStyle} usePickerAlign={seatPickerAlign} />
216
+ const p2Panel = p2IsHuman && <PlayerSetupPanel key='p2-zones' idPrefix='p2' host={p2Host} color={p2Color} onColorChange={handleP2ColorChange} swatches={defaultColors} takenColor={p1Color} colorCaption={pack.colorCaption} isHuman ready={ready[2]} onToggleReady={() => setReady((r) => ({ ...r, 2: !r[2] }))} dark={dark} secondPicker={p2Control} secondPickerId={p2Control ? P2_CONTROL_ID : undefined} profiles={profiles} selectedProfileId={lastSelected[2]} takenProfileId={lastSelected[1]} guestLabel={pack.guestLabel ?? 'P2'} nullLabel={pack.nullLabel} onProfileSelect={handleP2ProfileSelect} previewValue={seatPreview} triggerStyle={triggerStyle} readySurfaceColor={readySurface} />
186
217
 
187
218
  return (
188
219
  <View style={[styles.container, { backgroundColor: bg }]}>
@@ -201,23 +232,19 @@ export function LoadoutScreen({ game }: { game: LoadoutGame }) {
201
232
  <DualZoneLayout
202
233
  panelLayout={panelLayout}
203
234
  panelFadeStyle={panelFadeStyle}
204
- p1={p1Panel}
235
+ p1={seatOne}
205
236
  p2={p2Panel}
206
237
  shared={sharedControls}
207
238
  // A zone can't self-elevate above a later sibling zone; these lift whichever zone has a
208
239
  // menu open (see DualZoneLayout's p1Elevated/p2Elevated/sharedElevated doc).
209
- p1Elevated={controlsHost.openId?.startsWith('p1-') ?? false}
240
+ p1Elevated={p1PickerOpen}
210
241
  p2Elevated={p2Host.openId !== null}
211
242
  sharedElevated={optionsPopoverOpen}
212
243
  />
213
244
  ) : (
214
245
  <View style={styles.stackedZone}>
215
246
  {sharedControls}
216
- <Animated.View style={[styles.playersRow, anyPlayerPickerOpen && styles.playersRowOpen, playersRowFade]}>
217
- {p1Panel}
218
- {p2Panel}
219
- </Animated.View>
220
- <ReadyButton color={p1Drawn} ready={ready[1]} onToggleReady={() => setReady((r) => ({ ...r, 1: !r[1] }))} style={anyPlayerPickerOpen && styles.readyButtonHidden} />
247
+ {seatOne}
221
248
  </View>
222
249
  )}
223
250
  </View>
@@ -238,6 +265,12 @@ const styles = StyleSheet.create({
238
265
  container: {
239
266
  flex: 1
240
267
  },
268
+ // Player 1's controls, under their row of buttons.
269
+ controlRow: {
270
+ alignItems: 'center',
271
+ flexDirection: 'row',
272
+ gap: 12
273
+ },
241
274
  // A real full-bleed box to size the absolutely-positioned children against.
242
275
  frame: {
243
276
  alignItems: 'center',
@@ -264,21 +297,37 @@ const styles = StyleSheet.create({
264
297
  pill: {
265
298
  borderRadius: 999
266
299
  },
267
- // The one-human row: seat 1's panel and seat 2's button read as one evenly spaced row of buttons,
268
- // at PlayerSetupPanel's own pickerRow gap (12).
300
+ // Seat 1's button and the opponent button read as one evenly spaced row of buttons, at
301
+ // PlayerSetupPanel's own pickerRow gap (12).
269
302
  playersRow: {
270
303
  flexDirection: 'row',
271
304
  gap: 12
272
305
  },
273
- playersRowOpen: {
306
+ // Lifts whichever piece of player 1's has a menu open above its later siblings (each View is its
307
+ // own stacking context on the web).
308
+ raised: {
274
309
  zIndex: 100
275
310
  },
276
- // See anyPlayerPickerOpen: opacity 0 (not display:'none') keeps the button's layout space, and
311
+ // See p1PickerOpen: opacity 0 (not display:'none') keeps the button's layout space, and
277
312
  // pointerEvents:'none' keeps the sliver an open menu doesn't cover from being a hidden tap target.
278
313
  readyButtonHidden: {
279
314
  opacity: 0,
280
315
  pointerEvents: 'none'
281
316
  },
317
+ // Player 1's column: their row of buttons and controls, then Ready, at PlayerSetupPanel's own
318
+ // rhythm in their half of 2 Player.
319
+ seatOne: {
320
+ alignItems: 'center',
321
+ gap: 12
322
+ },
323
+ // With one person, Ready sits as far under the buttons as the match options sit above them.
324
+ seatOneAlone: {
325
+ gap: 28
326
+ },
327
+ seatOneButtons: {
328
+ alignItems: 'center',
329
+ gap: 12
330
+ },
282
331
  stackedZone: {
283
332
  alignItems: 'center',
284
333
  gap: 28
package/src/types.ts CHANGED
@@ -73,8 +73,8 @@ export interface GameLoadout {
73
73
  // A human seat's controls picker (shot style, keys), or undefined when the game has none.
74
74
  seatControl: (seat: Seat, host: PopoverHost, look: SeatControlLook) => ReactNode | undefined
75
75
  // Seat 2's CPU difficulty, saved per game since each game's CPU plays differently. Left out by a
76
- // game with no seat 2 (GameDefinition.seatTwo), or whose CPU has no levels: seat 2's menu then
77
- // offers one plain CPU.
76
+ // game with no seat 2 (GameDefinition.seatTwo), or whose CPU has no levels: player 1's opponent
77
+ // menu then offers one plain CPU.
78
78
  cpuDifficulty?: { value: string; options: MenuOption<string>[]; onChange: (value: string) => void }
79
79
  }
80
80
 
@@ -146,13 +146,20 @@ export interface GameDefinition {
146
146
  // with the preview at their start, and a grid beside the hero on a wide screen. A game without one
147
147
  // keeps its icon there. What it draws is the game's own.
148
148
  Preview?: ComponentType<GamePreviewProps>
149
- // Seat 2's choices in this game, in menu order. 'nobody' only where the game has a solo mode.
150
- // Left out by a game for one player only: its loadout has no seat 2 button and every round is solo.
149
+ // Seat 2's choices in this game, in the order player 1's opponent menu lists them ('person' is its
150
+ // Player row). 'nobody' only where the game has a solo mode. Left out by a game for one player only:
151
+ // its loadout has no opponent button and every round is solo.
151
152
  seatTwo?: readonly SeatTwo[]
152
153
  // The 2 Player loadout seats its players side by side (P1 on the left) in a window wider than tall,
153
154
  // to match a game whose board does the same there. Unset, they sit face to face in any window (P1 at
154
155
  // the bottom). Only read in 2 Player, so a game without 'person' among its seatTwo choices leaves it out.
155
156
  sideBySideWhenWide?: boolean
157
+ // The 2 Player loadout seats its players side by side (P1 on the left) at a computer, whatever the
158
+ // window's shape: two people at one keyboard sit side by side, where the game's match seats them the
159
+ // same way. A computer is the web without a touch-first screen (@tastic/core's
160
+ // useIsTouchPrimaryDevice). On a phone or
161
+ // tablet, natively or in its browser, sideBySideWhenWide decides as before. Only read in 2 Player.
162
+ sideBySideAtComputer?: boolean
156
163
  // The store keys this game mounts (settings, achievements, guide), merged into the pack's store.
157
164
  reducers: Record<string, Reducer>
158
165
  // Mounted at the app root for the game's lifetime (its stats and achievements context). Left out by
@@ -285,6 +292,11 @@ export interface PackDefinition {
285
292
  // The pack's own Settings sections (how cards look, how play is helped along), in every Settings
286
293
  // dialog under the shell's own rows and any a play screen adds. Their values live in `reducers`.
287
294
  SettingsSections?: ComponentType
295
+ // What goes FIRST in every Settings dialog the pack opens, above the shell's own rows: the rules of
296
+ // the game the dialog is opened in (Solitaire finds it from the route), which someone opening
297
+ // Settings from a game is most likely there for. Null where there is nothing to put first (Home), and
298
+ // the dialog is then as it was. A hook, called on every render of the dialog.
299
+ useSettingsRules?: () => ReactNode
288
300
  // Store keys of the pack's own, beside the shell's and each game's (GameDefinition.reducers): what
289
301
  // SettingsSections saves, or anything else that belongs to every game. Never a key the shell or a
290
302
  // game already mounts.
@@ -1,144 +0,0 @@
1
- import type { SeedColor } from '@rific/auto-paper'
2
- import type { AlignResult, PopoverHost } from '@tastic/hud/chrome'
3
- import { type MenuOption, ReadyButton, useZoneClampedAlign } from '@tastic/hud/lobby'
4
- import { getOptionPickerContentSize, OptionPicker, type OptionPickerOption, type Profile, type ProfilePickerColorSection } from '@tastic/profile'
5
- import { ReactNode, useMemo } from 'react'
6
- import { type StyleProp, StyleSheet, View, type ViewStyle } from 'react-native'
7
-
8
- import type { SeatTwo } from '../types'
9
-
10
- // Seat 2's one button: who sits there (a CPU at a difficulty, nobody where the game has a solo
11
- // mode, a guest, or a profile) and, on top, the seat's Color row. It replaces the choice between
12
- // One Player and Two Player that Home used to ask before the loadout: picking a person here is what
13
- // makes the round 2 Player, and the loadout turns to face both players.
14
- //
15
- // Built on @tastic/profile's OptionPicker, so it opens, places itself and closes like seat 1's
16
- // ProfilePicker beside it; written so it can move into @tastic/hud's PlayerSetupPanel once the pack
17
- // layout's shell moves into the shared packages (see the PuckTastic plan). Laid out like
18
- // PlayerSetupPanel: the seat button, a person's controls picker under it, and their Ready button.
19
-
20
- const CPU_PREFIX = 'cpu:'
21
- const PROFILE_PREFIX = 'profile:'
22
- const NOBODY = 'nobody'
23
- const GUEST = 'guest'
24
-
25
- const GUEST_ICON = 'account-outline'
26
- const NOBODY_ICON = 'account-off-outline'
27
- const CPU_ICON = 'robot'
28
-
29
- export function getSeatTwoPopoverId(idPrefix: string): string {
30
- return `${idPrefix}-seat`
31
- }
32
-
33
- export interface SeatTwoPanelProps {
34
- idPrefix: string
35
- host: PopoverHost
36
- // Seat 2's choices in this game (GameDefinition.seatTwo), in menu order.
37
- choices: readonly SeatTwo[]
38
- seatTwo: SeatTwo
39
- onSeatTwoChange: (seatTwo: SeatTwo) => void
40
- // The CPU's levels (GameLoadout.cpuDifficulty), or undefined for a CPU with none: one CPU row then.
41
- cpuDifficulty?: { value: string; options: MenuOption<string>[]; onChange: (value: string) => void }
42
- profiles: Profile[]
43
- selectedProfileId: string | null
44
- // Seat 1's profile: picking it here swaps the two seats (see @tastic/profile's select).
45
- onProfileSelect: (profileId: string | null) => void
46
- color: string
47
- onColorChange: (hex: string) => void
48
- swatches: SeedColor[]
49
- takenColor?: string
50
- allowSwapTaken?: boolean
51
- // Shown under the Color row while a profile is in the seat (its pick is kept for this pack only).
52
- colorCaption?: string
53
- dark: boolean
54
- // A person's controls picker (see GameLoadout.seatControl).
55
- secondPicker?: ReactNode
56
- secondPickerId?: string
57
- ready?: boolean
58
- onToggleReady?: () => void
59
- showReadyButton?: boolean
60
- // How the seat's color, and each profile's in the menu, is drawn (PackDefinition.previewColor), as
61
- // PlayerSetupPanel's own previewValue: `color` and what onColorChange receives stay the saved values.
62
- previewValue?: (hex: string) => string
63
- // The button's trigger style (a surface behind its pill over the pack's backdrop).
64
- triggerStyle?: StyleProp<ViewStyle>
65
- // A placement hook of the pack's own for the menu, in place of the zone clamp (PlayerSetupPanel's
66
- // useSeatPickerAlign): left unset inside a split-screen zone, and the same one for the panel's life.
67
- usePickerAlign?: (open: boolean, contentWidth: number, contentHeight: number) => AlignResult
68
- }
69
-
70
- export function SeatTwoPanel({ idPrefix, host, choices, seatTwo, onSeatTwoChange, cpuDifficulty, profiles, selectedProfileId, onProfileSelect, color, onColorChange, swatches, takenColor, allowSwapTaken, colorCaption, dark, secondPicker, secondPickerId, ready, onToggleReady, showReadyButton = true, previewValue, triggerStyle, usePickerAlign }: SeatTwoPanelProps) {
71
- const isPerson = seatTwo === 'person'
72
- const drawn = previewValue ? previewValue(color) : color
73
- const selectedProfile = isPerson ? (profiles.find((p) => p.id === selectedProfileId) ?? null) : null
74
-
75
- const cpuOptions = cpuDifficulty?.options
76
- const options = useMemo<OptionPickerOption<string>[]>(() => {
77
- const list: OptionPickerOption<string>[] = []
78
- if (choices.includes('cpu')) list.push(...(cpuOptions ? cpuOptions.map((o) => ({ value: `${CPU_PREFIX}${o.value}`, label: o.label, icon: o.icon })) : [{ value: CPU_PREFIX, label: 'CPU', icon: CPU_ICON }]))
79
- if (choices.includes('nobody')) list.push({ value: NOBODY, label: 'Nobody', icon: NOBODY_ICON })
80
- if (choices.includes('person')) {
81
- list.push({ value: GUEST, label: 'Player 2', icon: GUEST_ICON })
82
- list.push(...[...profiles].sort((a, b) => a.name.localeCompare(b.name)).map((p) => ({ value: `${PROFILE_PREFIX}${p.id}`, label: p.name, icon: 'account', color: previewValue ? previewValue(p.color) : p.color })))
83
- }
84
- return list
85
- }, [choices, cpuOptions, profiles, previewValue])
86
-
87
- const value = seatTwo === 'cpu' ? `${CPU_PREFIX}${cpuDifficulty?.value ?? ''}` : seatTwo === 'nobody' ? NOBODY : selectedProfile ? `${PROFILE_PREFIX}${selectedProfile.id}` : GUEST
88
- const icon = seatTwo === 'cpu' ? CPU_ICON : seatTwo === 'nobody' ? NOBODY_ICON : selectedProfile ? 'account' : GUEST_ICON
89
-
90
- const handleChange = (next: string) => {
91
- if (next.startsWith(CPU_PREFIX)) {
92
- cpuDifficulty?.onChange(next.slice(CPU_PREFIX.length))
93
- onSeatTwoChange('cpu')
94
- } else if (next === NOBODY) {
95
- onSeatTwoChange('nobody')
96
- } else {
97
- onProfileSelect(next.startsWith(PROFILE_PREFIX) ? next.slice(PROFILE_PREFIX.length) : null)
98
- onSeatTwoChange('person')
99
- }
100
- }
101
-
102
- // Nobody has no color to pick: there's nothing of theirs on the board.
103
- const colorSection: ProfilePickerColorSection | undefined = seatTwo === 'nobody' ? undefined : { value: color, onChange: onColorChange, swatches, takenValue: takenColor, allowSwapTaken, caption: selectedProfile ? colorCaption : undefined, previewValue }
104
-
105
- const pickerId = getSeatTwoPopoverId(idPrefix)
106
- const size = getOptionPickerContentSize(options.length, colorSection)
107
- // Kept inside this player's half in 2 Player, like PlayerSetupPanel's own seat button, or placed by
108
- // the pack's own hook where it's given one.
109
- const useAlign = usePickerAlign ?? useZoneClampedAlign
110
- const align = useAlign(host.openId === pickerId, size.width, size.height)
111
-
112
- const ownPopoverOpen = host.openId?.startsWith(`${idPrefix}-`) ?? false
113
- const pickerRowOpen = secondPickerId !== undefined && host.openId === secondPickerId
114
-
115
- return (
116
- <View style={[styles.panel, ownPopoverOpen && styles.panelOpen]}>
117
- <OptionPicker id={pickerId} host={host} options={options} value={value} onChange={handleChange} color={drawn} dark={dark} icon={icon} colorSection={colorSection} alignOverride={align} triggerStyle={triggerStyle} />
118
- {isPerson && secondPicker && <View style={[styles.pickerRow, pickerRowOpen && styles.pickerRowOpen]}>{secondPicker}</View>}
119
- {isPerson && showReadyButton && onToggleReady && <ReadyButton color={drawn} ready={ready ?? false} onToggleReady={onToggleReady} />}
120
- </View>
121
- )
122
- }
123
-
124
- // The same rhythm and stacking as @tastic/hud's PlayerSetupPanel, so seat 2 lines up with seat 1.
125
- const styles = StyleSheet.create({
126
- panel: {
127
- alignItems: 'center',
128
- gap: 12
129
- },
130
- // A popover escaping this panel needs the panel itself elevated to paint above a later sibling.
131
- panelOpen: {
132
- zIndex: 100
133
- },
134
- pickerRow: {
135
- alignItems: 'center',
136
- flexDirection: 'row',
137
- gap: 12
138
- },
139
- // Lifts the controls picker's popover above the Ready button below it (each View is its own
140
- // stacking context on the web).
141
- pickerRowOpen: {
142
- zIndex: 100
143
- }
144
- })