@tastic/shell 0.5.0 → 0.7.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/README.md +20 -5
- package/dist/index.d.mts +29 -2
- package/dist/index.d.ts +29 -2
- package/dist/index.js +195 -133
- package/dist/index.mjs +151 -92
- package/package.json +8 -8
- package/src/components/MatchChips.tsx +72 -0
- package/src/components/OpponentPicker.tsx +112 -0
- package/src/hooks/useAtComputer.ts +13 -0
- package/src/index.ts +2 -0
- package/src/redux/opponentSlice.ts +1 -1
- package/src/screens/HomeScreen.tsx +1 -1
- package/src/screens/LoadoutScreen.tsx +86 -37
- package/src/types.ts +11 -4
- package/src/components/SeatTwoPanel.tsx +0 -144
|
@@ -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
|
+
})
|
|
@@ -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,9 +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_FADE_MS, MatchChips, type MatchChipsProps } from './components/MatchChips'
|
|
18
19
|
export { SettingsDialog, type SettingsDialogProps } from './components/SettingsDialog'
|
|
19
20
|
export { useActiveRound } from './hooks/useActiveRound'
|
|
20
21
|
export { useAppSettings } from './hooks/useAppSettings'
|
|
22
|
+
export { useAtComputer } from './hooks/useAtComputer'
|
|
21
23
|
export { useOpponent } from './hooks/useOpponent'
|
|
22
24
|
export { ProfilesProvider, useProfiles } from './hooks/useProfiles'
|
|
23
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
|
|
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
|
|
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 {
|
|
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,
|
|
40
|
-
//
|
|
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
|
|
62
|
-
// bottom half, P2 in the top) in any window, unless the game seats them side by side
|
|
63
|
-
// (GameDefinition.sideBySideWhenWide), P1 on the left,
|
|
64
|
-
// in the match. With one person the panels follow the
|
|
65
|
-
|
|
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
|
|
79
|
-
//
|
|
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((
|
|
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
|
|
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
|
-
//
|
|
150
|
-
// one
|
|
151
|
-
//
|
|
152
|
-
//
|
|
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
|
|
155
|
-
|
|
156
|
-
//
|
|
157
|
-
|
|
158
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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={
|
|
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={
|
|
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
|
-
|
|
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
|
-
//
|
|
268
|
-
//
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
|
150
|
-
//
|
|
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
|
|
@@ -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
|
-
})
|