@tastic/hud 0.3.0 → 0.4.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.3.0",
3
+ "version": "0.4.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,124 @@
1
+ import { useAutoPaperTheme } from '@rific/auto-paper'
2
+ import { StyleSheet, View } from 'react-native'
3
+ import { Icon, ProgressBar, Text } from 'react-native-paper'
4
+
5
+ // A neutral gray for a locked badge's own background — exported so a caller computing its own
6
+ // badge color (unlocked ? tierColor : LOCKED_BADGE_COLOR, matching LightCycles' own achievements.tsx)
7
+ // doesn't need to invent or duplicate this exact tone.
8
+ export const LOCKED_BADGE_COLOR = 'rgba(128,128,128,0.3)'
9
+
10
+ interface Props {
11
+ icon: string
12
+ title: string
13
+ description: string
14
+ // The badge's own background — pass the achievement's tier color when unlocked, or
15
+ // LOCKED_BADGE_COLOR (this package's own export, above) when it isn't; this component doesn't
16
+ // infer that from unlockedLabel itself, since a locked-vs-unlocked badge color scheme is a
17
+ // caller-level design choice (see checkColor below for the identical reasoning on the status
18
+ // icon's own color).
19
+ badgeColor: string
20
+ // undefined = locked. A precomputed display string (e.g. "Unlocked 2 days ago"), not a raw
21
+ // timestamp — this component has no opinion on date formatting or streak conventions, which vary
22
+ // per app (see LightCycles' own calendar-day-difference convention in its achievements.tsx).
23
+ unlockedLabel?: string
24
+ // Color for the unlocked check-circle icon/badge glyph — typically the same tier color passed to
25
+ // badgeColor, kept as its own prop rather than reused internally so a caller can special-case it
26
+ // independently if its own tier scheme ever needs to (matching LightCycles' own call site, which
27
+ // does pass the identical value to both today).
28
+ checkColor?: string
29
+ // Only rendered while locked (unlockedLabel is undefined) — 0..1.
30
+ progress?: number
31
+ // The "Same for every profile" note LightCycles shows for a device-scoped achievement viewed
32
+ // from a specific profile's own tab — optional since only an app with per-profile stat views has
33
+ // this concept at all.
34
+ deviceMarker?: boolean
35
+ }
36
+
37
+ // One row per achievement — a tier-colored (or locked-gray) badge, title/description, an optional
38
+ // progress bar while locked, and either an unlocked check-mark+label or a lock icon. Extracted
39
+ // verbatim from LightCycles' own achievements.tsx, where this exact row shape backed its entire
40
+ // "ALL ACHIEVEMENTS" list. Derives fg/fgMuted/its own section background from useAutoPaperTheme(),
41
+ // same as StatRow/StatSection/BaseStatsScreen — see BaseStatsScreen's own doc for why. Has no
42
+ // opinion on what an achievement actually IS (id, unlock predicate, catalog) — that stays entirely
43
+ // app-local; this is purely the presentational row.
44
+ export function AchievementRow({ icon, title, description, badgeColor, unlockedLabel, checkColor, progress, deviceMarker }: Props) {
45
+ const { dark } = useAutoPaperTheme()
46
+ const fg = dark ? '#FFFFFF' : '#000000'
47
+ const fgMuted = dark ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.5)'
48
+ const sectionBg = dark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)'
49
+ const unlocked = unlockedLabel !== undefined
50
+
51
+ return (
52
+ <View style={[styles.row, { backgroundColor: sectionBg }]}>
53
+ <View style={[styles.badge, { backgroundColor: badgeColor }]}>
54
+ <Icon source={icon} size={20} color={unlocked ? '#000000' : fgMuted} />
55
+ </View>
56
+ <View style={styles.text}>
57
+ <Text variant='bodyLarge' style={[styles.boldText, { color: fg }]}>
58
+ {title}
59
+ </Text>
60
+ <Text variant='bodySmall' style={{ color: fgMuted }}>
61
+ {description}
62
+ </Text>
63
+ {deviceMarker && (
64
+ <View style={styles.deviceMarker}>
65
+ <Icon source='earth' size={11} color={fgMuted} />
66
+ <Text variant='labelSmall' style={{ color: fgMuted }}>
67
+ Same for every profile
68
+ </Text>
69
+ </View>
70
+ )}
71
+ {!unlocked && progress !== undefined && <ProgressBar progress={progress} color={checkColor ?? badgeColor} style={styles.progressBar} />}
72
+ </View>
73
+ {unlocked ? (
74
+ <View style={styles.status}>
75
+ <Icon source='check-circle' size={20} color={checkColor ?? badgeColor} />
76
+ <Text variant='labelSmall' style={{ color: fgMuted }}>
77
+ {unlockedLabel}
78
+ </Text>
79
+ </View>
80
+ ) : (
81
+ <Icon source='lock-outline' size={20} color={fgMuted} />
82
+ )}
83
+ </View>
84
+ )
85
+ }
86
+
87
+ const styles = StyleSheet.create({
88
+ badge: {
89
+ alignItems: 'center',
90
+ borderRadius: 10,
91
+ height: 36,
92
+ justifyContent: 'center',
93
+ width: 36
94
+ },
95
+ boldText: {
96
+ fontWeight: 'bold'
97
+ },
98
+ deviceMarker: {
99
+ alignItems: 'center',
100
+ flexDirection: 'row',
101
+ gap: 4
102
+ },
103
+ progressBar: {
104
+ borderRadius: 4,
105
+ height: 6,
106
+ marginTop: 6
107
+ },
108
+ row: {
109
+ alignItems: 'center',
110
+ borderRadius: 12,
111
+ flexDirection: 'row',
112
+ gap: 12,
113
+ padding: 12
114
+ },
115
+ status: {
116
+ alignItems: 'center',
117
+ gap: 2,
118
+ width: 64
119
+ },
120
+ text: {
121
+ flex: 1,
122
+ gap: 2
123
+ }
124
+ })
@@ -0,0 +1,158 @@
1
+ import { useAutoPaperTheme } from '@rific/auto-paper'
2
+ import { Button, IconButton } from '@rific/feedback-press'
3
+ import { ReactNode, useState } from 'react'
4
+ import { ScrollView, StyleSheet, View } from 'react-native'
5
+ import { Icon, Portal, Text } from 'react-native-paper'
6
+
7
+ interface Props {
8
+ // Live physical-hold rotation (see @tastic/split-screen's getViewRotation) — this dialog renders
9
+ // as a centered Portal modal, unaffected by whichever screen's own FakeLandscapeView wraps its
10
+ // trigger, so it has to rotate its own card content to read right-side-up for whichever way the
11
+ // phone is actually being held. Defaults to 0 for a caller that doesn't track one — matches
12
+ // BaseSettingsDialog's own identical prop, though the first apps consuming this screen shell
13
+ // don't wrap it in a FakeLandscapeView at all and so never have a nonzero one to give.
14
+ rotation?: number
15
+ title?: string
16
+ onBack: () => void
17
+ insets: { top: number; left: number; bottom: number; right: number }
18
+ // Optional, and always passed as a pair — a caller with nothing resettable (or that deliberately
19
+ // doesn't want a reset action on this screen) omits both rather than one alone. Confirmation copy
20
+ // is caller-owned since "erases all stats and achievements" is only accurate for a caller that
21
+ // actually tracks both; a caller with only high scores, say, would want its own wording.
22
+ onReset?: () => void
23
+ resetLabel?: string
24
+ resetConfirmTitle?: string
25
+ resetConfirmBody?: string
26
+ children: ReactNode
27
+ }
28
+
29
+ // The stats/achievements screen shell every @tastic game shares — back button + title header,
30
+ // scrollable content area, and an optional reset-everything action with its own confirm-before-
31
+ // destroying overlay — extracted after the same header/scroll/reset-confirm shape was independently
32
+ // built into LightCycles' own achievements.tsx. Derives its own colors from useAutoPaperTheme(),
33
+ // same as this package's own BaseSettingsDialog, rather than taking fg/bg/cardBg/etc as props — a
34
+ // stats screen is app-wide chrome, not a per-player surface, so there's no reason to make every
35
+ // caller re-derive and thread through the identical dark-mode formula BaseSettingsDialog already
36
+ // hardcodes internally. `children` is the seam for whatever isn't shared: this component has no
37
+ // opinion on what a "stat" is or how achievements are catalogued — see StatRow/StatSection/
38
+ // AchievementRow for the smaller presentational pieces built to go inside it.
39
+ export function BaseStatsScreen({ rotation = 0, title = 'Achievements', onBack, insets, onReset, resetLabel = 'Reset All Stats', resetConfirmTitle = 'Reset Everything?', resetConfirmBody = 'This permanently erases all stats and achievements. This cannot be undone.', children }: Props) {
40
+ const { dark, colors } = useAutoPaperTheme()
41
+ const [confirmResetVisible, setConfirmResetVisible] = useState(false)
42
+ const showReset = !!onReset
43
+
44
+ // High-contrast retro look: literal black/white by appearance, matching every other screen in
45
+ // this ecosystem (title/loadout/settings all use this identical formula) rather than auto-
46
+ // paper's own (slightly tinted) background role — same convention BaseSettingsDialog's own
47
+ // fg/cardBg/cardBorder use internally.
48
+ const fg = dark ? '#FFFFFF' : '#000000'
49
+ const bg = dark ? '#000000' : '#FFFFFF'
50
+ const cardBg = dark ? '#111111' : '#F2F2F2'
51
+ const cardBorder = dark ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.2)'
52
+
53
+ return (
54
+ <View style={[styles.container, { backgroundColor: bg }]}>
55
+ <View style={[styles.header, { paddingTop: 8 + insets.top, paddingLeft: 8 + insets.left }]}>
56
+ <IconButton icon='arrow-left' iconColor={fg} size={24} onPress={onBack} />
57
+ <Text variant='displaySmall' style={[styles.title, { color: fg }]}>
58
+ {title}
59
+ </Text>
60
+ </View>
61
+
62
+ <ScrollView style={styles.scrollView} contentContainerStyle={[styles.content, { paddingBottom: 32 + insets.bottom }]} showsVerticalScrollIndicator={false}>
63
+ {children}
64
+
65
+ {showReset && (
66
+ <Button mode='outlined' onPress={() => setConfirmResetVisible(true)} textColor={colors.secondary} style={styles.resetButton}>
67
+ {resetLabel}
68
+ </Button>
69
+ )}
70
+ </ScrollView>
71
+
72
+ {confirmResetVisible && (
73
+ <Portal>
74
+ <View style={[styles.overlay, rotation % 360 !== 0 && { transform: [{ rotate: `${rotation}deg` }] }]}>
75
+ <View style={[styles.overlayCard, { backgroundColor: cardBg, borderColor: cardBorder }]}>
76
+ <Icon source='alert-outline' size={64} color={colors.secondary} />
77
+ <Text variant='headlineLarge' style={[styles.overlayTitle, { color: colors.secondary }]}>
78
+ {resetConfirmTitle}
79
+ </Text>
80
+ <Text variant='bodyLarge' style={[styles.overlayBody, { color: fg }]}>
81
+ {resetConfirmBody}
82
+ </Text>
83
+ <Button mode='contained' onPress={() => setConfirmResetVisible(false)} style={styles.overlayButton}>
84
+ Cancel
85
+ </Button>
86
+ <Button
87
+ mode='contained'
88
+ onPress={() => {
89
+ onReset?.()
90
+ setConfirmResetVisible(false)
91
+ }}
92
+ style={styles.overlayButton}
93
+ buttonColor={colors.secondary}
94
+ textColor={colors.onSecondary}
95
+ >
96
+ Reset
97
+ </Button>
98
+ </View>
99
+ </View>
100
+ </Portal>
101
+ )}
102
+ </View>
103
+ )
104
+ }
105
+
106
+ const styles = StyleSheet.create({
107
+ container: {
108
+ flex: 1
109
+ },
110
+ content: {
111
+ gap: 12,
112
+ paddingHorizontal: 20
113
+ },
114
+ header: {
115
+ alignItems: 'center',
116
+ flexDirection: 'row',
117
+ gap: 4,
118
+ paddingBottom: 8
119
+ },
120
+ // Same overlay shape BaseSettingsDialog's own info overlay uses (backdrop + centered card),
121
+ // rotated in place the same way rather than via Portal-root transform for the same reason — see
122
+ // that component's own identical styles for the full rationale.
123
+ overlay: {
124
+ alignItems: 'center',
125
+ backgroundColor: 'rgba(0,0,0,0.72)',
126
+ bottom: 0,
127
+ justifyContent: 'center',
128
+ left: 0,
129
+ position: 'absolute',
130
+ right: 0,
131
+ top: 0
132
+ },
133
+ overlayBody: { textAlign: 'center' },
134
+ overlayButton: { width: 160 },
135
+ overlayCard: {
136
+ alignItems: 'center',
137
+ borderRadius: 20,
138
+ borderWidth: 1,
139
+ gap: 16,
140
+ maxWidth: 360,
141
+ padding: 32
142
+ },
143
+ overlayTitle: { fontWeight: 'bold', marginBottom: -8 },
144
+ resetButton: {
145
+ marginTop: 16
146
+ },
147
+ // Bounds the ScrollView to the space `container`'s flex:1 actually gives it — without this, a
148
+ // ScrollView with only a contentContainerStyle isn't reliably height-constrained on native (it
149
+ // can render at its full unclipped content height instead of the screen's), which leaves the
150
+ // header's back button touch target unreliable underneath it.
151
+ scrollView: {
152
+ flex: 1
153
+ },
154
+ title: {
155
+ flexShrink: 1,
156
+ fontWeight: 'bold'
157
+ }
158
+ })
@@ -0,0 +1,44 @@
1
+ import { useAutoPaperTheme } from '@rific/auto-paper'
2
+ import { StyleSheet, View } from 'react-native'
3
+ import { Text } from 'react-native-paper'
4
+
5
+ interface Props {
6
+ label: string
7
+ value: string
8
+ }
9
+
10
+ // A single labeled stat — label on the left (muted), value on the right (bold, full-contrast).
11
+ // Extracted from LightCycles' own achievements.tsx, where this exact row shape backed every
12
+ // section (Overall/Vs CPU/Two Player/Activity). Derives its own colors from useAutoPaperTheme(),
13
+ // same as BaseStatsScreen — see that component's own doc for why a stats-screen row doesn't take
14
+ // fg/fgMuted as props. Deliberately just label+value text, not a color swatch or profile chip —
15
+ // an app needing a row with a leading visual builds its own on top of this same dark-mode formula
16
+ // (see LightCycles' own ColorStatRow/ProfileRankingRow for an example), since what that visual
17
+ // even is varies per game.
18
+ export function StatRow({ label, value }: Props) {
19
+ const { dark } = useAutoPaperTheme()
20
+ const fg = dark ? '#FFFFFF' : '#000000'
21
+ const fgMuted = dark ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.5)'
22
+
23
+ return (
24
+ <View style={styles.row}>
25
+ <Text variant='bodyMedium' style={{ color: fgMuted }}>
26
+ {label}
27
+ </Text>
28
+ <Text variant='bodyMedium' style={[styles.value, { color: fg }]}>
29
+ {value}
30
+ </Text>
31
+ </View>
32
+ )
33
+ }
34
+
35
+ const styles = StyleSheet.create({
36
+ row: {
37
+ alignItems: 'center',
38
+ flexDirection: 'row',
39
+ justifyContent: 'space-between'
40
+ },
41
+ value: {
42
+ fontWeight: 'bold'
43
+ }
44
+ })
@@ -0,0 +1,40 @@
1
+ import { useAutoPaperTheme } from '@rific/auto-paper'
2
+ import { ReactNode } from 'react'
3
+ import { StyleSheet, View } from 'react-native'
4
+ import { Text } from 'react-native-paper'
5
+
6
+ interface Props {
7
+ label: string
8
+ children: ReactNode
9
+ }
10
+
11
+ // A labeled, tinted-background grouping box — the "OVERALL"/"VS CPU"/"ACTIVITY"-style section
12
+ // shape from LightCycles' own achievements.tsx, generalized to any content (typically a stack of
13
+ // StatRow). Derives its own colors from useAutoPaperTheme(), same as StatRow/BaseStatsScreen — see
14
+ // BaseStatsScreen's own doc for why. The label itself carries the section's own meaning (what it's
15
+ // a section OF), so this component has no opinion on that beyond rendering the string it's given.
16
+ export function StatSection({ label, children }: Props) {
17
+ const { dark } = useAutoPaperTheme()
18
+ const fgMuted = dark ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.5)'
19
+ const sectionBg = dark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)'
20
+
21
+ return (
22
+ <View style={[styles.section, { backgroundColor: sectionBg }]}>
23
+ <Text variant='labelMedium' style={[styles.label, { color: fgMuted }]}>
24
+ {label}
25
+ </Text>
26
+ {children}
27
+ </View>
28
+ )
29
+ }
30
+
31
+ const styles = StyleSheet.create({
32
+ label: {
33
+ letterSpacing: 2
34
+ },
35
+ section: {
36
+ borderRadius: 12,
37
+ gap: 8,
38
+ padding: 16
39
+ }
40
+ })
package/src/index.ts CHANGED
@@ -1,4 +1,6 @@
1
+ export { AchievementRow, LOCKED_BADGE_COLOR } from './AchievementRow'
1
2
  export { BaseSettingsDialog, type BaseSettingsDialogProps } from './BaseSettingsDialog'
3
+ export { BaseStatsScreen } from './BaseStatsScreen'
2
4
  export { CornerActionButtons } from './CornerActionButtons'
3
5
  export { MONO_FONT } from './fonts'
4
6
  export { InlineColorPicker } from './InlineColorPicker'
@@ -9,6 +11,8 @@ export { PressAwayOverlay } from './PressAwayOverlay'
9
11
  export { ReadyButton } from './ReadyButton'
10
12
  export { type MenuOption, type MenuSection, type MultiSelectSection, SectionedDropdown, type SingleSelectSection } from './SectionedDropdown'
11
13
  export { SharedActionBand } from './SharedActionBand'
14
+ export { StatRow } from './StatRow'
15
+ export { StatSection } from './StatSection'
12
16
  // TriggerGauge itself (the raw Skia-based component) is deliberately NOT exported here, even
13
17
  // though a native-only consumer could safely skip TriggerGaugeHost's Suspense/lazy-load
14
18
  // indirection. A value re-export forces the bundler to fold TriggerGauge.tsx's own `Skia` import