@tastic/hud 0.8.5 → 0.12.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 +301 -20
- package/dist/index.d.mts +107 -15
- package/dist/index.d.ts +107 -15
- package/dist/index.js +528 -306
- package/dist/index.mjs +455 -253
- package/package.json +16 -8
- package/src/AchievementCatalogSection.tsx +60 -0
- package/src/ActivityStatSection.tsx +29 -0
- package/src/ControlSchemePicker.tsx +11 -2
- package/src/InlineColorPicker.tsx +38 -11
- package/src/LabeledDropdown.tsx +5 -17
- package/src/PlayerSetupPanel.tsx +196 -0
- package/src/SectionedDropdown.tsx +18 -7
- package/src/StaggeredWord.tsx +114 -0
- package/src/index.ts +8 -2
- package/src/useAutoAlign.ts +17 -1
- package/src/useQuitConfirmation.ts +61 -0
- package/src/useZoneClampedAlign.ts +135 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tastic/hud",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "Visual component kit for local-multiplayer React Native games: inline (non-modal) popovers, dropdowns, color pickers, gauges, ready buttons, and dialogs, built to stay scoped to one player's own zone on a shared screen",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"react-native",
|
|
@@ -71,14 +71,17 @@
|
|
|
71
71
|
"prettier": "@infinitetoken/eslint-config/prettier",
|
|
72
72
|
"devDependencies": {
|
|
73
73
|
"@infinitetoken/eslint-config": "^0.2.0",
|
|
74
|
-
"@infinitetoken/jest-config": "^0.
|
|
75
|
-
"@infinitetoken/tsconfig": "^0.
|
|
76
|
-
"@rific/auto-paper": "^0.
|
|
77
|
-
"@rific/core": "^0.
|
|
78
|
-
"@rific/feedback-press": "^0.
|
|
74
|
+
"@infinitetoken/jest-config": "^0.4.0",
|
|
75
|
+
"@infinitetoken/tsconfig": "^0.5.0",
|
|
76
|
+
"@rific/auto-paper": "^0.12.0",
|
|
77
|
+
"@rific/core": "^0.2.0",
|
|
78
|
+
"@rific/feedback-press": "^0.12.0",
|
|
79
79
|
"@rific/updater": "^0.6.0",
|
|
80
80
|
"@shopify/react-native-skia": "^2.6.2",
|
|
81
|
-
"@tastic/
|
|
81
|
+
"@tastic/achievements": "^0.2.0",
|
|
82
|
+
"@tastic/core": "^0.8.0",
|
|
83
|
+
"@tastic/profile": "^0.7.0",
|
|
84
|
+
"@tastic/split-screen": "^0.5.0",
|
|
82
85
|
"@testing-library/dom": "^10.4.1",
|
|
83
86
|
"@testing-library/react": "^16.3.2",
|
|
84
87
|
"@types/jest": "^30.0.0",
|
|
@@ -94,6 +97,7 @@
|
|
|
94
97
|
"react-native": "^0.85.3",
|
|
95
98
|
"react-native-paper": "^5.15.2",
|
|
96
99
|
"react-native-reanimated": "^4.5.1",
|
|
100
|
+
"react-native-safe-area-context": "^5.10.0",
|
|
97
101
|
"tsup": "^8.0.0",
|
|
98
102
|
"typescript": "^6.0.3"
|
|
99
103
|
},
|
|
@@ -102,11 +106,15 @@
|
|
|
102
106
|
"@rific/feedback-press": ">=0.10.0",
|
|
103
107
|
"@rific/updater": ">=0.4.0",
|
|
104
108
|
"@shopify/react-native-skia": ">=1.5.0",
|
|
109
|
+
"@tastic/achievements": ">=0.1.2",
|
|
105
110
|
"@tastic/core": ">=0.3.0",
|
|
111
|
+
"@tastic/profile": ">=0.4.0",
|
|
112
|
+
"@tastic/split-screen": ">=0.5.0",
|
|
106
113
|
"react": ">=19.0.0",
|
|
107
114
|
"react-native": ">=0.76.0",
|
|
108
115
|
"react-native-paper": ">=5.0.0",
|
|
109
|
-
"react-native-reanimated": ">=3.0.0"
|
|
116
|
+
"react-native-reanimated": ">=3.0.0",
|
|
117
|
+
"react-native-safe-area-context": ">=5.0.0"
|
|
110
118
|
},
|
|
111
119
|
"publishConfig": {
|
|
112
120
|
"access": "public"
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { useAutoPaperTheme } from '@rific/auto-paper'
|
|
2
|
+
import type { AchievementCatalogRow } from '@tastic/achievements'
|
|
3
|
+
import { Fragment } from 'react'
|
|
4
|
+
import { StyleSheet } from 'react-native'
|
|
5
|
+
import { Text } from 'react-native-paper'
|
|
6
|
+
|
|
7
|
+
import { AchievementRow, LOCKED_BADGE_COLOR } from './AchievementRow'
|
|
8
|
+
import { MONO_FONT } from './fonts'
|
|
9
|
+
|
|
10
|
+
// AchievementCatalogRow now comes straight from @tastic/achievements' own getAchievementCatalogRows
|
|
11
|
+
// (added in the same 2026-09-18 achievements-scaffolding pass this component belongs to) — no
|
|
12
|
+
// longer duplicated locally now that package has actually published the export (confirmed the two
|
|
13
|
+
// shapes match exactly before wiring this real, type-only peer dependency).
|
|
14
|
+
|
|
15
|
+
interface Props {
|
|
16
|
+
rows: AchievementCatalogRow[]
|
|
17
|
+
label?: string
|
|
18
|
+
// Independently optional overrides for this section's own heading/row colors - default to the
|
|
19
|
+
// same useAutoPaperTheme()-derived formula as AchievementRow/StatSection/StatRow/BaseStatsScreen
|
|
20
|
+
// (see BaseStatsScreen's own doc for why) when omitted, so every existing caller renders
|
|
21
|
+
// identically to before. Passed straight through to each <AchievementRow>.
|
|
22
|
+
fg?: string
|
|
23
|
+
fgMuted?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// The "ALL ACHIEVEMENTS" heading + one <AchievementRow> per row - extracted verbatim from every
|
|
27
|
+
// fleet app's own achievements.tsx (Snake, AirHockey, BoxHockey, Pong, LightCycles), where this
|
|
28
|
+
// exact heading/list pair backed the bottom of the stats screen. Returns a Fragment, not a View:
|
|
29
|
+
// every existing call site rendered the heading and each row as flat siblings inside
|
|
30
|
+
// BaseStatsScreen's own gap-spaced ScrollView content, and wrapping them in a container here would
|
|
31
|
+
// change that spacing (BaseStatsScreen's own `content` style already supplies the gap between
|
|
32
|
+
// every child, this component included).
|
|
33
|
+
//
|
|
34
|
+
// Resolves each row's own locked-vs-unlocked badgeColor (tierColor when unlocked,
|
|
35
|
+
// LOCKED_BADGE_COLOR when not) here rather than upstream in row computation, matching
|
|
36
|
+
// AchievementRow's own "badge color is a rendering decision" doc comment - @tastic/achievements'
|
|
37
|
+
// (pending) getAchievementCatalogRows deliberately leaves tierColor unresolved-to-badgeColor for
|
|
38
|
+
// the same reason.
|
|
39
|
+
export function AchievementCatalogSection({ rows, label = 'ALL ACHIEVEMENTS', fg, fgMuted: fgMutedOverride }: Props) {
|
|
40
|
+
const { dark } = useAutoPaperTheme()
|
|
41
|
+
const fgMuted = fgMutedOverride ?? (dark ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.5)')
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<Fragment>
|
|
45
|
+
<Text variant='labelMedium' style={[styles.label, { color: fgMuted, fontFamily: MONO_FONT }]}>
|
|
46
|
+
{label}
|
|
47
|
+
</Text>
|
|
48
|
+
{rows.map((row) => (
|
|
49
|
+
<AchievementRow key={row.id} icon={row.icon} title={row.title} description={row.description} badgeColor={row.unlockedAt !== undefined ? row.tierColor : LOCKED_BADGE_COLOR} checkColor={row.tierColor} unlockedLabel={row.unlockedLabel} progress={row.progress} deviceMarker={row.deviceMarker} fg={fg} fgMuted={fgMutedOverride} />
|
|
50
|
+
))}
|
|
51
|
+
</Fragment>
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const styles = StyleSheet.create({
|
|
56
|
+
label: {
|
|
57
|
+
letterSpacing: 2,
|
|
58
|
+
marginTop: 8
|
|
59
|
+
}
|
|
60
|
+
})
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { StatRow } from './StatRow'
|
|
2
|
+
import { StatSection } from './StatSection'
|
|
3
|
+
|
|
4
|
+
interface ActivityStats {
|
|
5
|
+
distinctDaysPlayed: number
|
|
6
|
+
currentDayStreak: number
|
|
7
|
+
bestDayStreak: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface Props {
|
|
11
|
+
stats: ActivityStats
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// The "ACTIVITY" StatSection (Days Played / Day Streak / Best Day Streak) - extracted verbatim
|
|
15
|
+
// from every fleet app's own achievements.tsx (Snake, AirHockey, BoxHockey, Pong, LightCycles),
|
|
16
|
+
// where this exact three-row block was byte-for-byte identical. ActivityStats is a plain
|
|
17
|
+
// structural type (matching @tastic/achievements' own DayStreakState shape - distinctDaysPlayed/
|
|
18
|
+
// currentDayStreak/bestDayStreak) rather than importing DayStreakState itself, so this component
|
|
19
|
+
// has no dependency on @tastic/achievements at all: any caller's own stats/statsView object that
|
|
20
|
+
// happens to carry these three fields already satisfies it structurally.
|
|
21
|
+
export function ActivityStatSection({ stats }: Props) {
|
|
22
|
+
return (
|
|
23
|
+
<StatSection label='ACTIVITY'>
|
|
24
|
+
<StatRow label='Days Played' value={String(stats.distinctDaysPlayed)} />
|
|
25
|
+
<StatRow label='Day Streak' value={String(stats.currentDayStreak)} />
|
|
26
|
+
<StatRow label='Best Day Streak' value={String(stats.bestDayStreak)} />
|
|
27
|
+
</StatSection>
|
|
28
|
+
)
|
|
29
|
+
}
|
|
@@ -2,6 +2,7 @@ import { useIsTouchPrimaryDevice } from '@tastic/core'
|
|
|
2
2
|
import { Platform } from 'react-native'
|
|
3
3
|
|
|
4
4
|
import { MenuOption, SectionedDropdown } from './SectionedDropdown'
|
|
5
|
+
import { AlignResult, PopoverRotation } from './useAutoAlign'
|
|
5
6
|
import { PopoverHost } from './usePopoverHost'
|
|
6
7
|
|
|
7
8
|
export interface ControlSchemePickerProps<T extends string | number> {
|
|
@@ -21,6 +22,14 @@ export interface ControlSchemePickerProps<T extends string | number> {
|
|
|
21
22
|
onAccentColor?: string
|
|
22
23
|
dark: boolean
|
|
23
24
|
align?: 'left' | 'right' | 'center'
|
|
25
|
+
// Passed straight through to the underlying SectionedDropdown — see that component's own
|
|
26
|
+
// `alignOverride` doc. Lets a caller inside a @tastic/split-screen zone substitute
|
|
27
|
+
// useZoneClampedAlign's result for this picker's own popover placement.
|
|
28
|
+
alignOverride?: AlignResult
|
|
29
|
+
// Passed straight through to the underlying SectionedDropdown — see that component's own
|
|
30
|
+
// `rotation` doc. Defaults to a live ambient useRotation() read when omitted, same as
|
|
31
|
+
// SectionedDropdown itself.
|
|
32
|
+
rotation?: PopoverRotation
|
|
24
33
|
labelFontFamily?: string
|
|
25
34
|
}
|
|
26
35
|
|
|
@@ -45,9 +54,9 @@ export interface ControlSchemePickerProps<T extends string | number> {
|
|
|
45
54
|
// no keys of their own (a "keep using pointer input" option, say) are purely a per-app label — so a
|
|
46
55
|
// caller always supplies its own `options` (and typically its own `T`) rather than this component
|
|
47
56
|
// importing `@tastic/input` and choosing a set for every consumer.
|
|
48
|
-
export function ControlSchemePicker<T extends string | number>({ id, host, value, onChange, options, takenValue, accentColor, mutedColor, onAccentColor, dark, align, labelFontFamily }: ControlSchemePickerProps<T>) {
|
|
57
|
+
export function ControlSchemePicker<T extends string | number>({ id, host, value, onChange, options, takenValue, accentColor, mutedColor, onAccentColor, dark, align, alignOverride, rotation, labelFontFamily }: ControlSchemePickerProps<T>) {
|
|
49
58
|
const isTouchPrimary = useIsTouchPrimaryDevice()
|
|
50
59
|
if (Platform.OS !== 'web' || isTouchPrimary) return null
|
|
51
60
|
|
|
52
|
-
return <SectionedDropdown id={id} host={host} icon='keyboard-outline' accessibilityLabel='Control scheme' sections={[{ kind: 'single', id: 'controls', options, value, onChange, takenValue }]} accentColor={accentColor} mutedColor={mutedColor} onAccentColor={onAccentColor} dark={dark} align={align} labelFontFamily={labelFontFamily} />
|
|
61
|
+
return <SectionedDropdown id={id} host={host} icon='keyboard-outline' accessibilityLabel='Control scheme' sections={[{ kind: 'single', id: 'controls', options, value, onChange, takenValue }]} accentColor={accentColor} mutedColor={mutedColor} onAccentColor={onAccentColor} dark={dark} align={align} alignOverride={alignOverride} rotation={rotation} labelFontFamily={labelFontFamily} />
|
|
53
62
|
}
|
|
@@ -6,7 +6,7 @@ import { Icon, Text } from 'react-native-paper'
|
|
|
6
6
|
|
|
7
7
|
import { MONO_FONT } from './fonts'
|
|
8
8
|
import { PopoverBody } from './PopoverBody'
|
|
9
|
-
import { PopoverRotation, useAutoAlign } from './useAutoAlign'
|
|
9
|
+
import { AlignResult, PopoverRotation, useAutoAlign } from './useAutoAlign'
|
|
10
10
|
import { PopoverHost } from './usePopoverHost'
|
|
11
11
|
|
|
12
12
|
// Presence check, not a full-string "is this exactly one emoji" validation — this only decides a
|
|
@@ -35,6 +35,26 @@ function widthForColumns(columns: number) {
|
|
|
35
35
|
return SWATCHES_BORDER_WIDTH * 2 + SWATCHES_PADDING * 2 + SWATCH_SIZE * columns + SWATCHES_GAP * (columns - 1)
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
// Exported so a caller that needs to independently compute this popover's own content size — to
|
|
39
|
+
// feed useZoneClampedAlign's contentWidth/contentHeight params with, ahead of producing a
|
|
40
|
+
// colorAlignOverride for a picker living inside a @tastic/split-screen zone — can match this
|
|
41
|
+
// component's real rendered size exactly, rather than re-deriving or hardcoding its internal
|
|
42
|
+
// column/row math. Same job as LabeledDropdown's own getLabeledDropdownContentHeight, but this
|
|
43
|
+
// picker's size also depends on window width (via the auto-column clamp below), so windowWidth is
|
|
44
|
+
// a required parameter here rather than an ambient useWindowDimensions() read — a caller computing
|
|
45
|
+
// this ahead of render (to decide alignment before the picker itself has even mounted) has no
|
|
46
|
+
// component instance to read that hook from, so it has to pass the value in instead. The component
|
|
47
|
+
// below calls this same function with its own live useWindowDimensions() read, so there is exactly
|
|
48
|
+
// one place this formula lives.
|
|
49
|
+
export function getInlineColorPickerContentSize(swatchCount: number, windowWidth: number, columns?: number): { width: number; height: number } {
|
|
50
|
+
const autoColumns = clamp(Math.floor((windowWidth - 2 * SCREEN_MARGIN + SWATCHES_GAP) / (SWATCH_SIZE + SWATCHES_GAP)), MIN_COLUMNS, MAX_COLUMNS)
|
|
51
|
+
const resolvedColumns = columns ?? autoColumns
|
|
52
|
+
const width = widthForColumns(resolvedColumns)
|
|
53
|
+
const rows = Math.ceil(swatchCount / resolvedColumns)
|
|
54
|
+
const height = SWATCHES_BORDER_WIDTH * 2 + SWATCHES_PADDING * 2 + SWATCH_SIZE * rows + SWATCHES_GAP * (rows - 1)
|
|
55
|
+
return { width, height }
|
|
56
|
+
}
|
|
57
|
+
|
|
38
58
|
interface Props {
|
|
39
59
|
id: string
|
|
40
60
|
host: PopoverHost
|
|
@@ -59,9 +79,16 @@ interface Props {
|
|
|
59
79
|
// tradeoff.
|
|
60
80
|
allowSwapTaken?: boolean
|
|
61
81
|
dark: boolean
|
|
62
|
-
//
|
|
63
|
-
//
|
|
82
|
+
// Forces a specific horizontal alignment instead of letting whichever placement result applies
|
|
83
|
+
// (see alignOverride below) decide.
|
|
64
84
|
align?: 'left' | 'right' | 'center'
|
|
85
|
+
// Substitutes this package's own plain useAutoAlign-based placement wholesale (align,
|
|
86
|
+
// verticalAlign, maxHeight, measured, triggerRef) with a caller-supplied one — e.g.
|
|
87
|
+
// useZoneClampedAlign's result, for a picker living inside a @tastic/split-screen zone, where the
|
|
88
|
+
// plain window-relative decision can pick a direction that overflows into the shared row instead
|
|
89
|
+
// of the zone's own boundary. Same shape LabeledDropdown's/SectionedDropdown's own alignOverride
|
|
90
|
+
// takes; omit for the ordinary case (a popover with nothing but the screen edge to avoid).
|
|
91
|
+
alignOverride?: AlignResult
|
|
65
92
|
// Explicit override for the rotation a @tastic/split-screen-style FakeLandscapeView (or
|
|
66
93
|
// equivalent) ancestor is currently applying — see SectionedDropdown's identical prop for the
|
|
67
94
|
// full reasoning. Defaults to a live ambient read via @tastic/core's useRotation() when omitted.
|
|
@@ -93,20 +120,20 @@ interface Props {
|
|
|
93
120
|
// Renders inline rather than as a full-screen modal, scoped to its own panel — a modal color
|
|
94
121
|
// picker would block the whole screen for one player while another can't touch their own panel at
|
|
95
122
|
// the same time, which defeats the point of a split-screen lobby.
|
|
96
|
-
export function InlineColorPicker({ id, host, value, onChange, previewValue, swatches = defaultColors, takenValue, allowSwapTaken, dark, align: alignOverride, rotation: rotationOverride, icon = 'palette', tag, labelFontFamily = MONO_FONT, autoDismiss = true, columns, size = DEFAULT_SIZE }: Props) {
|
|
123
|
+
export function InlineColorPicker({ id, host, value, onChange, previewValue, swatches = defaultColors, takenValue, allowSwapTaken, dark, align: forcedAlign, alignOverride, rotation: rotationOverride, icon = 'palette', tag, labelFontFamily = MONO_FONT, autoDismiss = true, columns, size = DEFAULT_SIZE }: Props) {
|
|
97
124
|
const ambientRotation = useRotation()
|
|
98
125
|
const rotation = rotationOverride ?? ambientRotation
|
|
99
126
|
const menuBg = dark ? '#000000' : '#FFFFFF'
|
|
100
127
|
const { width: windowWidth } = useWindowDimensions()
|
|
101
|
-
const
|
|
102
|
-
const resolvedColumns = columns ?? autoColumns
|
|
103
|
-
const swatchesWidth = widthForColumns(resolvedColumns)
|
|
104
|
-
const swatchRows = Math.ceil(swatches.length / resolvedColumns)
|
|
105
|
-
const swatchesHeight = SWATCHES_BORDER_WIDTH * 2 + SWATCHES_PADDING * 2 + SWATCH_SIZE * swatchRows + SWATCHES_GAP * (swatchRows - 1)
|
|
128
|
+
const { width: swatchesWidth, height: swatchesHeight } = getInlineColorPickerContentSize(swatches.length, windowWidth, columns)
|
|
106
129
|
|
|
107
130
|
const open = host.openId === id
|
|
108
|
-
|
|
109
|
-
|
|
131
|
+
// Always called, even when alignOverride is supplied and this result goes unused — hooks can't be
|
|
132
|
+
// called conditionally. See LabeledDropdown's identical comment for the same tradeoff.
|
|
133
|
+
const auto = useAutoAlign(open, swatchesWidth, swatchesHeight, rotation)
|
|
134
|
+
const placement = alignOverride ?? auto
|
|
135
|
+
const { maxHeight, measured, triggerRef, verticalAlign } = placement
|
|
136
|
+
const align = forcedAlign ?? placement.align
|
|
110
137
|
const displayFor = previewValue ?? ((hex: string) => hex)
|
|
111
138
|
const displayValue = displayFor(value)
|
|
112
139
|
const contrastColor = getContrastColor(displayValue)
|
package/src/LabeledDropdown.tsx
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import { getContrastColor } from '@rific/auto-paper'
|
|
2
2
|
import { TouchableRipple } from '@rific/feedback-press'
|
|
3
|
-
import { RefObject } from 'react'
|
|
4
3
|
import { ScrollView, StyleSheet, View } from 'react-native'
|
|
5
4
|
import { Icon, Text } from 'react-native-paper'
|
|
6
5
|
|
|
7
6
|
import { PopoverBody } from './PopoverBody'
|
|
8
|
-
import {
|
|
7
|
+
import { AlignResult, useAutoAlign } from './useAutoAlign'
|
|
9
8
|
import { PopoverHost } from './usePopoverHost'
|
|
10
9
|
|
|
11
10
|
const TRIGGER_HEIGHT = 28
|
|
@@ -29,17 +28,6 @@ export interface LabeledDropdownOption<T extends string> {
|
|
|
29
28
|
icon?: string
|
|
30
29
|
}
|
|
31
30
|
|
|
32
|
-
// Same shape useAutoAlign itself returns — lets a caller inside a @tastic/split-screen zone (or
|
|
33
|
-
// any other layout this package doesn't know about) substitute its own alignment decision via
|
|
34
|
-
// `alignOverride` below, without this package taking a dependency on that caller's own hook.
|
|
35
|
-
interface AlignResult {
|
|
36
|
-
align: PopoverAlign
|
|
37
|
-
verticalAlign: PopoverVerticalAlign
|
|
38
|
-
maxHeight: number
|
|
39
|
-
measured: boolean
|
|
40
|
-
triggerRef: RefObject<View | null>
|
|
41
|
-
}
|
|
42
|
-
|
|
43
31
|
interface Props<T extends string> {
|
|
44
32
|
id: string
|
|
45
33
|
host: PopoverHost
|
|
@@ -74,10 +62,10 @@ export function LabeledDropdown<T extends string>({ id, host, options, value, on
|
|
|
74
62
|
const selected = options.find((o) => o.value === value) ?? null
|
|
75
63
|
const contentHeight = LIST_PADDING * 2 + options.length * ROW_HEIGHT
|
|
76
64
|
// Always called, even when alignOverride is supplied and this result goes unused — hooks can't be
|
|
77
|
-
// called conditionally. A caller passing alignOverride (e.g.
|
|
78
|
-
// which already wraps this same hook) does end up measuring the trigger
|
|
79
|
-
// tradeoff for keeping this component's own hook usage unconditional and
|
|
80
|
-
// than needing a second, hook-free code path.
|
|
65
|
+
// called conditionally. A caller passing alignOverride (e.g. this package's own
|
|
66
|
+
// useZoneClampedAlign, which already wraps this same hook) does end up measuring the trigger
|
|
67
|
+
// twice; an acceptable tradeoff for keeping this component's own hook usage unconditional and
|
|
68
|
+
// override-shaped rather than needing a second, hook-free code path.
|
|
81
69
|
const auto = useAutoAlign(open, POPOVER_WIDTH, contentHeight)
|
|
82
70
|
const placement = alignOverride ?? auto
|
|
83
71
|
const { maxHeight, measured, triggerRef, verticalAlign } = placement
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { SeedColor } from '@rific/auto-paper'
|
|
2
|
+
import { Profile, ProfilePicker } from '@tastic/profile'
|
|
3
|
+
import { ReactNode } from 'react'
|
|
4
|
+
import { StyleSheet, View } from 'react-native'
|
|
5
|
+
import { Text } from 'react-native-paper'
|
|
6
|
+
|
|
7
|
+
import { InlineColorPicker } from './InlineColorPicker'
|
|
8
|
+
import { LabeledDropdown, LabeledDropdownOption } from './LabeledDropdown'
|
|
9
|
+
import { ReadyButton } from './ReadyButton'
|
|
10
|
+
import { AlignResult, PopoverRotation } from './useAutoAlign'
|
|
11
|
+
import { PopoverHost, usePopoverHost } from './usePopoverHost'
|
|
12
|
+
|
|
13
|
+
// This panel's own internal color/CPU-difficulty popover ids, derived from idPrefix — exported so a
|
|
14
|
+
// caller that needs to know whether one of them is currently open (e.g. to compute the `open`
|
|
15
|
+
// argument useZoneClampedAlign's own first parameter needs, ahead of handing its result to
|
|
16
|
+
// colorAlignOverride/cpuDifficultyAlignOverride below) doesn't have to hardcode or guess this
|
|
17
|
+
// panel's own internal naming convention. Matches secondPickerId's own job for a caller's own
|
|
18
|
+
// externally-constructed picker — this is the equivalent for the two pickers this panel owns
|
|
19
|
+
// itself, which have no prop of their own to name their id with.
|
|
20
|
+
export function getColorPopoverId(idPrefix: string): string {
|
|
21
|
+
return `${idPrefix}-color`
|
|
22
|
+
}
|
|
23
|
+
export function getCpuDifficultyPopoverId(idPrefix: string): string {
|
|
24
|
+
return `${idPrefix}-difficulty`
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface PlayerSetupPanelProps<C extends string = string> {
|
|
28
|
+
// Namespaces this panel's popover ids ('color', whatever secondPickerId names, 'difficulty') so
|
|
29
|
+
// two panels can safely share one host (see `host` below) without their ids colliding.
|
|
30
|
+
idPrefix: string
|
|
31
|
+
// Shared popover host, when this panel's own popovers should be mutually exclusive with another
|
|
32
|
+
// panel's or a shared-controls row's (e.g. vs-CPU: only one human is ever driving both slots, so
|
|
33
|
+
// having both YOU's and CPU's pickers open at once is just clutter, not a useful simultaneous-edit
|
|
34
|
+
// case). Omit to fall back to this panel's own independent host — the two-player case, where two
|
|
35
|
+
// real people editing at once is the point.
|
|
36
|
+
host?: PopoverHost
|
|
37
|
+
label?: string
|
|
38
|
+
// Applied only to the plain `label` fallback text below (ProfilePicker/LabeledDropdown render
|
|
39
|
+
// their own fixed chrome regardless of this prop) — omit to inherit whichever font the calling
|
|
40
|
+
// screen's own theme resolves for a labelSmall Text, rather than this package forcing its own
|
|
41
|
+
// MONO_FONT onto what's really the host app's own copy.
|
|
42
|
+
labelFontFamily?: string
|
|
43
|
+
color: string
|
|
44
|
+
onColorChange: (hex: string) => void
|
|
45
|
+
swatches: SeedColor[]
|
|
46
|
+
// The other player's current color — stays visible in the swatch grid but disabled, rather than
|
|
47
|
+
// removed from it entirely. Unless allowSwapTaken is set — see InlineColorPicker's own doc.
|
|
48
|
+
takenColor?: string
|
|
49
|
+
allowSwapTaken?: boolean
|
|
50
|
+
// Passed straight through to the internal InlineColorPicker's own `alignOverride` — this panel
|
|
51
|
+
// has no way for a caller to reach that picker directly (it's not a ReactNode slot the way
|
|
52
|
+
// secondPicker is, see that prop's own doc), so a caller inside a @tastic/split-screen zone
|
|
53
|
+
// needs this to substitute useZoneClampedAlign's result for the color picker's own popover.
|
|
54
|
+
colorAlignOverride?: AlignResult
|
|
55
|
+
// Passed straight through to the internal InlineColorPicker's own `rotation` — same reasoning as
|
|
56
|
+
// colorAlignOverride above (no other way for a caller to reach that picker directly), for a
|
|
57
|
+
// caller whose own rotation reading needs to differ from InlineColorPicker's default ambient
|
|
58
|
+
// useRotation() read (see that component's own `rotation` doc for when that's worth doing).
|
|
59
|
+
colorRotation?: PopoverRotation
|
|
60
|
+
isHuman: boolean
|
|
61
|
+
// Whatever sits beside the color picker in pickerRow, if anything — a ControlSchemePicker, a raw
|
|
62
|
+
// SectionedDropdown, or nothing at all. The five apps this component was extracted from each
|
|
63
|
+
// duplicated this entire panel and differed only in what (if anything) occupied this one slot,
|
|
64
|
+
// each wanting a genuinely different concept there (which physical input drives a seat, vs. a
|
|
65
|
+
// pull/push "feel" preference, vs. no such concept at all for a game with no per-seat setting to
|
|
66
|
+
// pick) — a ReactNode slot the caller renders its own picker into is what actually generalizes
|
|
67
|
+
// across all of them, rather than this component baking in one shape (e.g. ControlSchemePicker's
|
|
68
|
+
// own prop set) that most of its consumers don't need at all.
|
|
69
|
+
secondPicker?: ReactNode
|
|
70
|
+
// The popover id secondPicker opens under, if it opens one at all — lets pickerRow's own
|
|
71
|
+
// "elevate above later siblings while one of my own popovers is open" logic (see pickerRowOpen)
|
|
72
|
+
// react to secondPicker's popover the same way it already does for the color picker's, without
|
|
73
|
+
// this component needing any opinion on what secondPicker actually renders. Omit when secondPicker
|
|
74
|
+
// has no popover of its own, or isn't provided at all.
|
|
75
|
+
secondPickerId?: string
|
|
76
|
+
ready?: boolean
|
|
77
|
+
onToggleReady?: () => void
|
|
78
|
+
dark: boolean
|
|
79
|
+
// vs-CPU only has one human player, so some loadout screens render their Ready toggle standalone,
|
|
80
|
+
// centered below both slots, instead of embedded in this panel.
|
|
81
|
+
showReadyButton?: boolean
|
|
82
|
+
profiles?: Profile[]
|
|
83
|
+
selectedProfileId?: string | null
|
|
84
|
+
takenProfileId?: string | null
|
|
85
|
+
// What the name trigger shows before any profile's selected — this seat's own 'P1'/'P2' (see
|
|
86
|
+
// ProfilePicker's own doc for why it's never "Player"). Required whenever profiles is provided,
|
|
87
|
+
// since the trigger always needs some idle text.
|
|
88
|
+
guestLabel?: string
|
|
89
|
+
onProfileSelect?: (profile: Profile | null) => void
|
|
90
|
+
// Navigates to a profile-management screen — typically only ever wired up for one seat (a rotated
|
|
91
|
+
// second zone often can't host a working text keyboard).
|
|
92
|
+
onManageProfiles?: () => void
|
|
93
|
+
// Generic over the calling app's own CPU-difficulty union, matching LabeledDropdownOption<T>'s own
|
|
94
|
+
// genericness — this package has no opinion on what difficulties exist.
|
|
95
|
+
cpuDifficulty?: C
|
|
96
|
+
cpuDifficultyOptions?: LabeledDropdownOption<C>[]
|
|
97
|
+
onCpuDifficultyChange?: (value: C) => void
|
|
98
|
+
// Passed straight through to the internal LabeledDropdown's own `alignOverride` — same reasoning
|
|
99
|
+
// as colorAlignOverride above, for the CPU-difficulty picker instead of the color picker.
|
|
100
|
+
cpuDifficultyAlignOverride?: AlignResult
|
|
101
|
+
// InlineColorPicker's own `icon` for a human vs. a non-human seat (overridden by a selected
|
|
102
|
+
// profile's own `tag`, same precedence InlineColorPicker itself applies) — defaults match every
|
|
103
|
+
// consumer's own convention. A caller with its own extra CPU states (e.g. a "no CPU" slot) computes
|
|
104
|
+
// its own icon string and passes it through cpuIcon rather than this component needing any opinion
|
|
105
|
+
// on what those states are.
|
|
106
|
+
humanIcon?: string
|
|
107
|
+
cpuIcon?: string
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// One panel per player slot in a local-multiplayer loadout/lobby screen: a name header (a saved
|
|
111
|
+
// profile switcher for a human seat, a CPU-difficulty picker for a non-human one, or a plain label
|
|
112
|
+
// when neither applies), a color picker optionally paired with a second, per-app picker (see
|
|
113
|
+
// `secondPicker`'s own doc), and a Ready toggle for human seats. Ported from five apps' own
|
|
114
|
+
// near-identical PlayerSetupPanel.tsx/LobbyPlayerPanel.tsx (AirHockey, BoxHockey, Pong, LightCycles,
|
|
115
|
+
// Snake), which had all independently converged on this exact skeleton.
|
|
116
|
+
//
|
|
117
|
+
// Owns its own popover host shared by its own color/profile/difficulty pickers unless a `host` is
|
|
118
|
+
// passed in to share with another panel (or a shared-controls row) instead — same convention as
|
|
119
|
+
// every popover-hosting component in this package.
|
|
120
|
+
export function PlayerSetupPanel<C extends string = string>({ idPrefix, host, label, labelFontFamily, color, onColorChange, swatches, takenColor, allowSwapTaken, colorAlignOverride, colorRotation, isHuman, secondPicker, secondPickerId, ready, onToggleReady, dark, showReadyButton = true, profiles, selectedProfileId, takenProfileId, guestLabel, onProfileSelect, onManageProfiles, cpuDifficulty, cpuDifficultyOptions, onCpuDifficultyChange, cpuDifficultyAlignOverride, humanIcon = 'face-man', cpuIcon = 'robot' }: PlayerSetupPanelProps<C>) {
|
|
121
|
+
const ownHost = usePopoverHost()
|
|
122
|
+
const popover = host ?? ownHost
|
|
123
|
+
// Checking *this panel's own* ids, not just "is anything open on the host" — when `host` is
|
|
124
|
+
// shared, another panel's (or a shared-controls row's) popovers also live on it, and elevating
|
|
125
|
+
// this panel for those too would tie it with whichever of them actually has the open popover,
|
|
126
|
+
// letting DOM order (wrongly) decide which one paints on top.
|
|
127
|
+
const ownPopoverOpen = popover.openId?.startsWith(`${idPrefix}-`) ?? false
|
|
128
|
+
// Narrower than ownPopoverOpen on purpose — only true for a popover actually inside pickerRow
|
|
129
|
+
// itself (color, or secondPicker's own popover when it has one and secondPickerId names it). See
|
|
130
|
+
// pickerRowOpen's own style comment for why pickerRow needs this in addition to panelOpen above.
|
|
131
|
+
const pickerRowPopoverOpen = popover.openId === getColorPopoverId(idPrefix) || (secondPickerId !== undefined && popover.openId === secondPickerId)
|
|
132
|
+
const showProfilePicker = isHuman && profiles !== undefined && !!onProfileSelect
|
|
133
|
+
const showCpuDifficulty = !isHuman && cpuDifficulty !== undefined && cpuDifficultyOptions !== undefined && !!onCpuDifficultyChange
|
|
134
|
+
// The color button's own tag display (see InlineColorPicker) — read live from the selected
|
|
135
|
+
// profile rather than snapshotted at selection time, since a caller typically has no other
|
|
136
|
+
// editing surface for it on this same screen.
|
|
137
|
+
const selectedProfile = profiles?.find((p) => p.id === selectedProfileId) ?? null
|
|
138
|
+
|
|
139
|
+
return (
|
|
140
|
+
<View style={[styles.panel, ownPopoverOpen && styles.panelOpen]}>
|
|
141
|
+
{showProfilePicker ? (
|
|
142
|
+
<ProfilePicker idPrefix={idPrefix} host={popover} profiles={profiles!} selectedId={selectedProfileId ?? null} takenId={takenProfileId} color={color} dark={dark} guestLabel={guestLabel ?? 'GUEST'} onSelect={(profile: Profile | null) => onProfileSelect?.(profile)} onManage={onManageProfiles} />
|
|
143
|
+
) : showCpuDifficulty ? (
|
|
144
|
+
<LabeledDropdown id={getCpuDifficultyPopoverId(idPrefix)} host={popover} options={cpuDifficultyOptions!} value={cpuDifficulty!} onChange={onCpuDifficultyChange!} color={color} dark={dark} alignOverride={cpuDifficultyAlignOverride} />
|
|
145
|
+
) : (
|
|
146
|
+
label && (
|
|
147
|
+
<Text variant='labelSmall' style={{ color, fontFamily: labelFontFamily }}>
|
|
148
|
+
{label}
|
|
149
|
+
</Text>
|
|
150
|
+
)
|
|
151
|
+
)}
|
|
152
|
+
|
|
153
|
+
<View style={[styles.pickerRow, pickerRowPopoverOpen && styles.pickerRowOpen]}>
|
|
154
|
+
{/* Human slots show the selected profile's own tag when it has one, falling back to
|
|
155
|
+
humanIcon; a non-human slot always falls back to cpuIcon — this is what actually
|
|
156
|
+
distinguishes "you" from "the CPU" now, not a fixed icon. */}
|
|
157
|
+
<InlineColorPicker id={getColorPopoverId(idPrefix)} host={popover} value={color} onChange={onColorChange} swatches={swatches} takenValue={takenColor} allowSwapTaken={allowSwapTaken} alignOverride={colorAlignOverride} rotation={colorRotation} dark={dark} tag={isHuman ? selectedProfile?.tag : undefined} icon={isHuman ? humanIcon : cpuIcon} />
|
|
158
|
+
|
|
159
|
+
{secondPicker}
|
|
160
|
+
</View>
|
|
161
|
+
|
|
162
|
+
{isHuman && showReadyButton && onToggleReady && <ReadyButton color={color} ready={ready ?? false} onToggleReady={onToggleReady} />}
|
|
163
|
+
</View>
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const styles = StyleSheet.create({
|
|
168
|
+
panel: {
|
|
169
|
+
alignItems: 'center',
|
|
170
|
+
gap: 12
|
|
171
|
+
},
|
|
172
|
+
// See SectionedDropdown's anchorOpen comment — this panel is typically a sibling of another
|
|
173
|
+
// panel (and/or a shared-controls row), so a popover escaping this panel's bounds needs the panel
|
|
174
|
+
// itself elevated, not just the popover content, to paint above a later sibling.
|
|
175
|
+
panelOpen: {
|
|
176
|
+
zIndex: 100
|
|
177
|
+
},
|
|
178
|
+
// Same gap as `panel`'s own vertical rhythm, reused horizontally — the color picker and (when
|
|
179
|
+
// present) secondPicker sit side by side within this row instead of stacked in the panel's own
|
|
180
|
+
// column.
|
|
181
|
+
pickerRow: {
|
|
182
|
+
alignItems: 'center',
|
|
183
|
+
flexDirection: 'row',
|
|
184
|
+
gap: 12
|
|
185
|
+
},
|
|
186
|
+
// A popover is an absolutely-positioned sibling of its own trigger — so a tall one (the color
|
|
187
|
+
// swatch grid) can extend down far enough to overlap the ReadyButton below, which paints on top of
|
|
188
|
+
// it by default DOM order since it's a later sibling within `panel`. `panel`'s own panelOpen
|
|
189
|
+
// elevation only helps this panel paint above OTHER panels (see its own comment) — it does nothing
|
|
190
|
+
// for stacking *within* this panel, since React Native Web gives each View its own stacking
|
|
191
|
+
// context. This is what actually fixes that: elevate pickerRow itself above its own later sibling
|
|
192
|
+
// whenever one of its own popovers is open.
|
|
193
|
+
pickerRowOpen: {
|
|
194
|
+
zIndex: 100
|
|
195
|
+
}
|
|
196
|
+
})
|
|
@@ -7,7 +7,7 @@ import { Icon, Text } from 'react-native-paper'
|
|
|
7
7
|
import { MONO_FONT } from './fonts'
|
|
8
8
|
import { PopoverBody } from './PopoverBody'
|
|
9
9
|
import TriggerGaugeHost from './TriggerGaugeHost'
|
|
10
|
-
import { PopoverRotation, useAutoAlign } from './useAutoAlign'
|
|
10
|
+
import { AlignResult, PopoverRotation, useAutoAlign } from './useAutoAlign'
|
|
11
11
|
import { PopoverHost } from './usePopoverHost'
|
|
12
12
|
|
|
13
13
|
const MENU_BORDER_WIDTH = 1
|
|
@@ -96,10 +96,17 @@ interface Props {
|
|
|
96
96
|
// background — pass a real design-system "on" color explicitly instead when you have one.
|
|
97
97
|
onAccentColor?: string
|
|
98
98
|
dark: boolean
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
99
|
+
// Forces a specific horizontal alignment instead of letting whichever placement result applies
|
|
100
|
+
// (see alignOverride below) decide. Only pass this to force a specific side regardless of where
|
|
101
|
+
// the trigger actually sits.
|
|
102
102
|
align?: 'left' | 'right' | 'center'
|
|
103
|
+
// Substitutes this package's own plain useAutoAlign-based placement wholesale (align,
|
|
104
|
+
// verticalAlign, maxHeight, measured, triggerRef) with a caller-supplied one — e.g.
|
|
105
|
+
// useZoneClampedAlign's result, for a dropdown living inside a @tastic/split-screen zone, where
|
|
106
|
+
// the plain window-relative decision can pick a direction that overflows into the shared row
|
|
107
|
+
// instead of the zone's own boundary. Same shape LabeledDropdown's own alignOverride takes; omit
|
|
108
|
+
// for the ordinary case (a popover with nothing but the screen edge to avoid).
|
|
109
|
+
alignOverride?: AlignResult
|
|
103
110
|
// Explicit override for the rotation a @tastic/split-screen-style FakeLandscapeView (or any other
|
|
104
111
|
// ancestor rotated the same way — a bare CSS `transform: rotate()`, not an actual OS-level
|
|
105
112
|
// orientation change) is currently applying. Defaults to a live ambient read via @tastic/core's
|
|
@@ -128,7 +135,7 @@ interface Props {
|
|
|
128
135
|
// powerups are enabled, say) is just this with one 'multi' section (optionally with allClear); a
|
|
129
136
|
// combined menu (e.g. spawn frequency + which types, in one menu) is just two sections in the same
|
|
130
137
|
// array.
|
|
131
|
-
export function SectionedDropdown({ id, host, icon, accessibilityLabel, sections, accentColor, mutedColor, onAccentColor, dark, align: alignOverride, rotation: rotationOverride, autoDismiss = true, labelFontFamily = MONO_FONT, allClearLabels = { all: 'All', clear: 'Clear' } }: Props) {
|
|
138
|
+
export function SectionedDropdown({ id, host, icon, accessibilityLabel, sections, accentColor, mutedColor, onAccentColor, dark, align: forcedAlign, alignOverride, rotation: rotationOverride, autoDismiss = true, labelFontFamily = MONO_FONT, allClearLabels = { all: 'All', clear: 'Clear' } }: Props) {
|
|
132
139
|
const ambientRotation = useRotation()
|
|
133
140
|
const rotation = rotationOverride ?? ambientRotation
|
|
134
141
|
const menuBg = dark ? '#000000' : '#FFFFFF'
|
|
@@ -156,8 +163,12 @@ export function SectionedDropdown({ id, host, icon, accessibilityLabel, sections
|
|
|
156
163
|
const divider = sectionIndex > 0 ? MENU_DIVIDER_HEIGHT : 0
|
|
157
164
|
return sum + rows + footer + divider
|
|
158
165
|
}, 0)
|
|
159
|
-
|
|
160
|
-
|
|
166
|
+
// Always called, even when alignOverride is supplied and this result goes unused — hooks can't be
|
|
167
|
+
// called conditionally. See LabeledDropdown's identical comment for the same tradeoff.
|
|
168
|
+
const auto = useAutoAlign(open, MENU_MAX_WIDTH, estimatedHeight, rotation)
|
|
169
|
+
const placement = alignOverride ?? auto
|
|
170
|
+
const { maxHeight, measured, triggerRef, verticalAlign } = placement
|
|
171
|
+
const align = forcedAlign ?? placement.align
|
|
161
172
|
|
|
162
173
|
// Trigger reflects the selected option's own icon only when there's exactly one section and it's
|
|
163
174
|
// single-select — any other shape (multi-select present, or more than one section) has no single
|