@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/dist/index.d.mts +107 -63
- package/dist/index.d.ts +107 -63
- package/dist/index.js +448 -337
- package/dist/index.mjs +415 -307
- package/package.json +1 -1
- package/src/ConfirmDialog.tsx +104 -0
- package/src/ControlSchemePicker.tsx +53 -0
- package/src/CornerActionButtons.tsx +10 -2
- package/src/CornerStatusBadges.tsx +87 -0
- package/src/index.ts +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tastic/hud",
|
|
3
|
-
"version": "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=
|
|
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'
|