@tastic/hud 0.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tastic/hud",
3
- "version": "0.6.0",
3
+ "version": "0.7.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",
@@ -0,0 +1,104 @@
1
+ import { useAutoPaperTheme } from '@rific/auto-paper'
2
+ import { Button } from '@rific/feedback-press'
3
+ import { StyleSheet, View } from 'react-native'
4
+ import { Icon, Portal, Text } from 'react-native-paper'
5
+
6
+ export interface ConfirmDialogProps {
7
+ visible: boolean
8
+ title: string
9
+ message: string
10
+ confirmLabel: string
11
+ cancelLabel: string
12
+ onConfirm: () => void
13
+ onCancel: () => void
14
+ // Live physical-hold rotation (see @tastic/split-screen's getViewRotation) - same convention as
15
+ // BaseSettingsDialog's own rotation prop; defaults to 0 for a caller with no per-player zone to
16
+ // match (a portrait-only or single-player game has nothing to stay consistent with).
17
+ rotation?: number
18
+ // Any react-native-paper Icon `source` name, shown above the title - defaults to a plain
19
+ // question mark, since this component has no way to know why a caller is asking.
20
+ icon?: string
21
+ // Whether the confirm button reads as destructive (the fleet's shared `danger` semantic role)
22
+ // instead of the default primary-colored affirmative action.
23
+ destructive?: boolean
24
+ }
25
+
26
+ // A themed replacement for the native Alert.alert/window.confirm two-button confirm pattern - the
27
+ // same "retro card overlay" look BaseSettingsDialog's own update-check info messages already use
28
+ // (see its onInfo/infoMessage), generalized here into its own reusable, exported component for any
29
+ // caller that needs a real yes/no decision instead of a single OK dismissal. A solid backdrop +
30
+ // centered card, not react-native-paper's own Dialog (which BaseSettingsDialog uses instead) -
31
+ // this renders identically on every platform, including web, where react-native-web's Alert.alert
32
+ // is a hard no-op (confirmed: its entire implementation is `static alert() {}`) and Dialog's own
33
+ // Portal-based positioning has its own web quirks BaseSettingsDialog already works around.
34
+ export function ConfirmDialog({ visible, title, message, confirmLabel, cancelLabel, onConfirm, onCancel, rotation = 0, icon = 'help-circle-outline', destructive = false }: ConfirmDialogProps) {
35
+ const { dark, colors } = useAutoPaperTheme()
36
+ if (!visible) return null
37
+
38
+ // High-contrast retro look: literal black/white by appearance, not auto-paper's own (slightly
39
+ // tinted) background role - same convention BaseSettingsDialog's own overlay card uses.
40
+ const fg = dark ? '#FFFFFF' : '#000000'
41
+ const cardBg = dark ? '#111111' : '#F2F2F2'
42
+ const cardBorder = dark ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.2)'
43
+ const accent = destructive ? colors.danger : colors.primary
44
+ const onAccent = destructive ? colors.onDanger : colors.onPrimary
45
+
46
+ return (
47
+ <Portal>
48
+ <View style={styles.overlay}>
49
+ <View style={[styles.card, { backgroundColor: cardBg, borderColor: cardBorder }, rotation % 360 !== 0 && { transform: [{ rotate: `${rotation}deg` }] }]}>
50
+ <Icon source={icon} size={64} color={accent} />
51
+ <Text variant='headlineLarge' style={[styles.title, { color: accent }]}>
52
+ {title}
53
+ </Text>
54
+ <Text variant='bodyLarge' style={[styles.body, { color: fg }]}>
55
+ {message}
56
+ </Text>
57
+ <View style={styles.actions}>
58
+ <Button mode='outlined' onPress={onCancel} style={styles.actionButton}>
59
+ {cancelLabel}
60
+ </Button>
61
+ <Button mode='contained' onPress={onConfirm} style={styles.actionButton} buttonColor={accent} textColor={onAccent}>
62
+ {confirmLabel}
63
+ </Button>
64
+ </View>
65
+ </View>
66
+ </View>
67
+ </Portal>
68
+ )
69
+ }
70
+
71
+ const styles = StyleSheet.create({
72
+ actionButton: {
73
+ flex: 1
74
+ },
75
+ actions: {
76
+ flexDirection: 'row',
77
+ gap: 12,
78
+ width: '100%'
79
+ },
80
+ body: {
81
+ textAlign: 'center'
82
+ },
83
+ card: {
84
+ alignItems: 'center',
85
+ borderRadius: 20,
86
+ borderWidth: 1,
87
+ gap: 16,
88
+ maxWidth: 360,
89
+ padding: 32
90
+ },
91
+ overlay: {
92
+ alignItems: 'center',
93
+ backgroundColor: 'rgba(0,0,0,0.72)',
94
+ bottom: 0,
95
+ justifyContent: 'center',
96
+ left: 0,
97
+ position: 'absolute',
98
+ right: 0,
99
+ top: 0
100
+ },
101
+ title: {
102
+ fontWeight: 'bold'
103
+ }
104
+ })
@@ -0,0 +1,53 @@
1
+ import { useIsTouchPrimaryDevice } from '@tastic/core'
2
+ import { Platform } from 'react-native'
3
+
4
+ import { MenuOption, SectionedDropdown } from './SectionedDropdown'
5
+ import { PopoverHost } from './usePopoverHost'
6
+
7
+ export interface ControlSchemePickerProps<T extends string | number> {
8
+ id: string
9
+ host: PopoverHost
10
+ value: T
11
+ onChange: (value: T) => void
12
+ options: MenuOption<T>[]
13
+ // Another seat's currently-selected scheme, if any — passed straight through as the underlying
14
+ // section's own takenValue (see SectionedDropdown's own doc: stays in the list, shown disabled,
15
+ // rather than filtered out). Two local seats sharing one physical keyboard (and, depending on your
16
+ // own option set, one mouse) can't both be assigned the same scheme at once; this is what a caller
17
+ // wires up to prevent that.
18
+ takenValue?: T
19
+ accentColor: string
20
+ mutedColor: string
21
+ onAccentColor?: string
22
+ dark: boolean
23
+ align?: 'left' | 'right' | 'center'
24
+ labelFontFamily?: string
25
+ }
26
+
27
+ // A single-select SectionedDropdown, opinionated for exactly one job: letting a local seat pick
28
+ // which physical input drives it in a couch-multiplayer game. Fixes the trigger icon and
29
+ // accessibility label so every game's control-scheme picker looks and reads the same, and — the
30
+ // actual reason this exists as its own component rather than a usage snippet in each app's own
31
+ // docs — bakes in the "does this even mean anything right now" gate every consumer was independently
32
+ // re-deriving: a physical keyboard scheme has no meaning on a touch-primary device (there's nothing
33
+ // to pick a *keyboard* layout for), and this repo's own components target React Native Web, where
34
+ // `Platform.OS !== 'web'` means native/touch already, so keying off `useIsTouchPrimaryDevice` alone
35
+ // isn't enough on its own — a native touch device still needs the `Platform.OS` check to be excluded
36
+ // the same way a desktop browser needs the touch check. Renders nothing at all when either
37
+ // condition fails, rather than a disabled/empty picker, so a caller can mount this unconditionally
38
+ // inside its own per-seat layout (a `View` row alongside a color picker, say) without its own
39
+ // `Platform`/`useIsTouchPrimaryDevice` branch — an always-rendered-but-sometimes-null child is a
40
+ // no-op in a flex layout, so the surrounding row collapses to just its other children automatically.
41
+ //
42
+ // Deliberately generic over `T` (matching SectionedDropdown's own `MenuOption<T>`) rather than
43
+ // hardcoding a fixed scheme union: this repo has no opinion on which schemes exist — the raw
44
+ // physical-key layouts (wasd/arrows/ijkl/numpad) live in `@tastic/input`, and any extra values with
45
+ // no keys of their own (a "keep using pointer input" option, say) are purely a per-app label — so a
46
+ // caller always supplies its own `options` (and typically its own `T`) rather than this component
47
+ // 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>) {
49
+ const isTouchPrimary = useIsTouchPrimaryDevice()
50
+ if (Platform.OS !== 'web' || isTouchPrimary) return null
51
+
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} />
53
+ }
@@ -12,6 +12,14 @@ interface Props {
12
12
  // @tastic/split-screen's rotateInsets) — this component just adds the fixed 8px margin on top,
13
13
  // it doesn't know anything about physical device orientation itself.
14
14
  insets: { top: number; left: number; right: number }
15
+ // Overrides the top-left button's icon (a react-native-paper/MaterialCommunityIcons name) and its
16
+ // accessibility label — default 'arrow-left'/'Back'. Not every screen's top-left control is
17
+ // actually "undo the last navigation": a flat menu/detail screen with nothing deeper to pop back
18
+ // through often wants a literal home/menu affordance driving an absolute navigation instead (see
19
+ // e.g. Solitaire's GameChrome, whose onBack navigates straight to its root route). Pass both
20
+ // together — an icon without a matching label (or vice versa) misleads screen readers/tooltips.
21
+ backIcon?: string
22
+ backLabel?: string
15
23
  }
16
24
 
17
25
  // Back (top-left) + Settings (top-right) icon buttons, fixed at the screen's rotated corners — the
@@ -20,10 +28,10 @@ interface Props {
20
28
  // right answer for a screen where the top corners themselves fall *inside* one player's own rotated
21
29
  // zone (two-player face-to-face) — see SharedActionBand for that case instead, which callers select
22
30
  // between based on their own game-mode/orientation state (this component has no opinion on which).
23
- export function CornerActionButtons({ onBack, onSettings, fg, insets }: Props) {
31
+ export function CornerActionButtons({ onBack, onSettings, fg, insets, backIcon = 'arrow-left', backLabel = 'Back' }: Props) {
24
32
  return (
25
33
  <>
26
- <IconButton icon='arrow-left' iconColor={fg} size={24} style={[styles.back, { top: 8 + insets.top, left: 8 + insets.left }]} onPress={onBack} accessibilityLabel='Back' />
34
+ <IconButton icon={backIcon} iconColor={fg} size={24} style={[styles.back, { top: 8 + insets.top, left: 8 + insets.left }]} onPress={onBack} accessibilityLabel={backLabel} />
27
35
  <IconButton icon='cog' iconColor={fg} size={24} style={[styles.topRight, { top: 8 + insets.top, right: 8 + insets.right }]} onPress={onSettings} accessibilityLabel='Settings' />
28
36
  </>
29
37
  )
@@ -0,0 +1,87 @@
1
+ import { StyleSheet, View } from 'react-native'
2
+ import { Icon } from 'react-native-paper'
3
+
4
+ // Mirrors @tastic/split-screen's own type of the same name, duplicated here rather than imported —
5
+ // this package has never taken a dependency on @tastic/split-screen; every orientation/rotation-
6
+ // derived value it touches (ConfirmDialog's and BaseSettingsDialog's own `rotation` props, this
7
+ // component's orientationMode/p1OnRight below) is always a plain value the caller already resolved
8
+ // via that package's own hook, never something this package reads itself.
9
+ export type OrientationMode = 'faceToFace' | 'sideBySide'
10
+
11
+ export interface CornerStatusBadgeSeat {
12
+ // Any react-native-paper Icon `source` name. Omit to still render the badge itself, empty — a
13
+ // seat with nothing to show right now is different from no seat at all (see `p1`/`p2` on
14
+ // CornerStatusBadgesProps below), and the empty badge is itself the answer ("nothing"), not a
15
+ // reason to hide the corner.
16
+ icon?: string
17
+ }
18
+
19
+ export interface CornerStatusBadgesProps {
20
+ orientationMode: OrientationMode
21
+ // Same "is this seat on the right" convention as @tastic/split-screen's own p1OnRight — only
22
+ // meaningful when orientationMode is 'sideBySide'; ignored in 'faceToFace', where seat 1 always
23
+ // owns the bottom half of a shared screen and seat 2 the top half, regardless of physical rotation.
24
+ p1OnRight: boolean
25
+ // Independently optional — omit a seat entirely to skip rendering its badge at all (e.g. a solo
26
+ // mode with no second seat to show anything for). Pass an object (even `{}`) to render that
27
+ // seat's badge with nothing in it.
28
+ p1?: CornerStatusBadgeSeat
29
+ p2?: CornerStatusBadgeSeat
30
+ }
31
+
32
+ const BADGE_SIZE = 30
33
+
34
+ // Deliberately understated (small, low-opacity, no per-seat color) — a quiet corner landmark, not
35
+ // something that should compete for attention with whatever's on screen underneath it.
36
+ function StatusBadge({ icon }: { icon?: string }) {
37
+ return <View style={styles.badge}>{icon && <Icon source={icon} size={16} color='rgba(255,255,255,0.85)' />}</View>
38
+ }
39
+
40
+ // Generic per-seat corner status badge: at most one caller-supplied icon per seat (or none), each
41
+ // pinned to that seat's own screen corner. Has zero opinion on what the icon means — originally
42
+ // extracted from LightCycles' PowerupHud.tsx and Snake's SnakePowerupHud.tsx, which both used this
43
+ // exact positioning and styling to reveal a held power-up, but this component only ever sees a
44
+ // resolved icon name (or undefined) per seat, never the domain value behind it, so it's equally
45
+ // suited to any other per-seat status a caller wants glanceable in a screen corner (connection
46
+ // state, a turn-order marker, anything that's a single icon or nothing). Corner picked per seat
47
+ // from orientationMode/p1OnRight — the same "is this seat on the right" convention
48
+ // @tastic/split-screen's own consumers already use for zone/rotation decisions — so each badge
49
+ // always sits in that seat's own zone rather than a side fixed regardless of which way the device
50
+ // is being held. In 'faceToFace', column doesn't matter (each seat's zone spans the full width):
51
+ // seat 1 stays bottom, seat 2 stays top, regardless of p1OnRight.
52
+ export function CornerStatusBadges({ orientationMode, p1OnRight, p1, p2 }: CornerStatusBadgesProps) {
53
+ const p1OnRightSide = orientationMode === 'sideBySide' && p1OnRight
54
+ const p2OnLeftSide = orientationMode === 'sideBySide' && !p1OnRight
55
+ return (
56
+ <>
57
+ {p1 && (
58
+ <View pointerEvents='none' style={p1OnRightSide ? styles.bottomRight : styles.bottomLeft}>
59
+ <StatusBadge icon={p1.icon} />
60
+ </View>
61
+ )}
62
+ {p2 && (
63
+ <View pointerEvents='none' style={p2OnLeftSide ? styles.topLeft : styles.topRight}>
64
+ <StatusBadge icon={p2.icon} />
65
+ </View>
66
+ )}
67
+ </>
68
+ )
69
+ }
70
+
71
+ const styles = StyleSheet.create({
72
+ badge: {
73
+ alignItems: 'center',
74
+ backgroundColor: 'rgba(0,0,0,0.28)',
75
+ borderColor: 'rgba(255,255,255,0.18)',
76
+ borderRadius: BADGE_SIZE / 2,
77
+ borderWidth: 1,
78
+ height: BADGE_SIZE,
79
+ justifyContent: 'center',
80
+ opacity: 0.7,
81
+ width: BADGE_SIZE
82
+ },
83
+ bottomLeft: { bottom: 12, left: 12, position: 'absolute' },
84
+ bottomRight: { bottom: 12, position: 'absolute', right: 12 },
85
+ topLeft: { left: 12, position: 'absolute', top: 12 },
86
+ topRight: { position: 'absolute', right: 12, top: 12 }
87
+ })
package/src/index.ts CHANGED
@@ -1,8 +1,11 @@
1
1
  export { AchievementRow, LOCKED_BADGE_COLOR } from './AchievementRow'
2
2
  export { BaseSettingsDialog, type BaseSettingsDialogProps } from './BaseSettingsDialog'
3
3
  export { BaseStatsScreen } from './BaseStatsScreen'
4
+ export { ConfirmDialog, type ConfirmDialogProps } from './ConfirmDialog'
4
5
  export { ContentGutter } from './ContentGutter'
6
+ export { ControlSchemePicker, type ControlSchemePickerProps } from './ControlSchemePicker'
5
7
  export { CornerActionButtons } from './CornerActionButtons'
8
+ export { CornerStatusBadges, type CornerStatusBadgeSeat, type CornerStatusBadgesProps, type OrientationMode } from './CornerStatusBadges'
6
9
  export { MONO_FONT } from './fonts'
7
10
  export { InlineColorPicker } from './InlineColorPicker'
8
11
  export { getLabeledDropdownContentHeight, LABELED_DROPDOWN_POPOVER_WIDTH, LabeledDropdown, type LabeledDropdownOption } from './LabeledDropdown'