@tastic/hud 0.12.0 → 0.14.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.
@@ -0,0 +1,115 @@
1
+ import { useAutoPaperTheme } from '@rific/auto-paper'
2
+ import { useEffect } from 'react'
3
+ import { StyleSheet, View } from 'react-native'
4
+ import { Icon } from 'react-native-paper'
5
+ import Animated, { Easing, useAnimatedStyle, useReducedMotion, useSharedValue, withRepeat, withSequence, withTiming } from 'react-native-reanimated'
6
+
7
+ export type SwipeDirection = 'up' | 'down' | 'left' | 'right'
8
+ export type SwipePointer = 'finger' | 'mouse'
9
+
10
+ export interface SwipeHintProps {
11
+ direction?: SwipeDirection
12
+ // What sweeps along the path: a fingertip ring (a touch screen) or a mouse (a desktop seat that
13
+ // plays by dragging). The mouse variant also relabels itself "Drag" for screen readers, since a
14
+ // desktop player should never be told to swipe.
15
+ pointer?: SwipePointer
16
+ // Defaults to the theme's own primary — i.e. the player's own seat color in the fleet games.
17
+ color?: string
18
+ // Length of the dotted path in points, arrowhead excluded.
19
+ length?: number
20
+ }
21
+
22
+ const DOT_COUNT = 7
23
+ const FINGER_SIZE = 28
24
+ const MOUSE_SIZE = 30
25
+ const SWEEP_MS = 900
26
+ const PAUSE_MS = 500
27
+
28
+ const ARROW_ICON: Record<SwipeDirection, string> = { up: 'arrow-up', down: 'arrow-down', left: 'arrow-left', right: 'arrow-right' }
29
+
30
+ // A finger-sized ring (or, with pointer='mouse', a mouse) sweeping along a dotted path toward an
31
+ // arrowhead, on a loop — the "swipe this way" illustration the arcade games (LightCycles, Snake, Pong,
32
+ // AirHockey, BoxHockey) each need for their first card, so it lives here once rather than being
33
+ // redrawn per app. The mouse variant is the same card on desktop web for a seat that plays by
34
+ // dragging; Snake and AirHockey each drew their own before it existed. Plain Views plus Reanimated:
35
+ // no Skia, no SVG, so it renders identically in every consuming app (Hangman, which has neither,
36
+ // included). Under reduced motion the pointer just rests at the midpoint, still visible.
37
+ export function SwipeHint({ direction = 'right', pointer = 'finger', color, length = 96 }: SwipeHintProps) {
38
+ const { colors } = useAutoPaperTheme()
39
+ const tint = color ?? colors.primary
40
+ const reducedMotion = useReducedMotion()
41
+ const progress = useSharedValue(0.5)
42
+ const horizontal = direction === 'left' || direction === 'right'
43
+ // Which way the finger travels along its own axis — left/up run against the positive x/y axes.
44
+ const sign = direction === 'right' || direction === 'down' ? 1 : -1
45
+
46
+ useEffect(() => {
47
+ if (reducedMotion) {
48
+ progress.value = 0.5
49
+ return
50
+ }
51
+ // Sweep out, hold (the ring fades while it rests at the end), then snap back to the start.
52
+ progress.value = 0
53
+ progress.value = withRepeat(withSequence(withTiming(1, { duration: SWEEP_MS, easing: Easing.out(Easing.cubic) }), withTiming(1, { duration: PAUSE_MS }), withTiming(0, { duration: 0 })), -1)
54
+ // eslint-disable-next-line react-hooks/exhaustive-deps
55
+ }, [reducedMotion])
56
+
57
+ const fingerStyle = useAnimatedStyle(() => {
58
+ const travel = (progress.value - 0.5) * length * sign
59
+ return {
60
+ opacity: reducedMotion ? 1 : Math.min(1, progress.value * 6, (1 - progress.value) * 6 + 0.001),
61
+ transform: horizontal ? [{ translateX: travel }] : [{ translateY: travel }]
62
+ }
63
+ })
64
+
65
+ const axis = horizontal ? (sign > 0 ? 'row' : 'row-reverse') : sign > 0 ? 'column' : 'column-reverse'
66
+
67
+ return (
68
+ <View accessibilityLabel={`${pointer === 'mouse' ? 'Drag' : 'Swipe'} ${direction}`} accessible style={[styles.path, { flexDirection: axis }]}>
69
+ <View style={[styles.dots, { flexDirection: axis, [horizontal ? 'width' : 'height']: length }]}>
70
+ {Array.from({ length: DOT_COUNT }, (_, i) => (
71
+ <View key={i} style={[styles.dot, { backgroundColor: tint, opacity: 0.35 + (0.65 * i) / (DOT_COUNT - 1) }]} />
72
+ ))}
73
+ </View>
74
+ <Icon source={ARROW_ICON[direction]} size={28} color={tint} />
75
+ {pointer === 'mouse' ? (
76
+ <Animated.View style={[styles.mouse, fingerStyle]}>
77
+ <Icon source='mouse' size={MOUSE_SIZE} color={tint} />
78
+ </Animated.View>
79
+ ) : (
80
+ <Animated.View style={[styles.finger, { borderColor: tint }, fingerStyle]} />
81
+ )}
82
+ </View>
83
+ )
84
+ }
85
+
86
+ const styles = StyleSheet.create({
87
+ dot: {
88
+ borderRadius: 3,
89
+ height: 6,
90
+ width: 6
91
+ },
92
+ dots: {
93
+ alignItems: 'center',
94
+ justifyContent: 'space-between'
95
+ },
96
+ // Centered on the whole path (not on the dots row alone) by the container's own alignItems/
97
+ // justifyContent, then pushed along the axis by its own animated translate — absolute so it never
98
+ // takes space from the dots and arrowhead it's drawn over.
99
+ finger: {
100
+ borderRadius: FINGER_SIZE / 2,
101
+ borderWidth: 3,
102
+ height: FINGER_SIZE,
103
+ position: 'absolute',
104
+ width: FINGER_SIZE
105
+ },
106
+ // Positioned exactly like `finger` (absolute, centered by the container, moved by the same animated
107
+ // translate); only what's drawn differs.
108
+ mouse: {
109
+ position: 'absolute'
110
+ },
111
+ path: {
112
+ alignItems: 'center',
113
+ justifyContent: 'center'
114
+ }
115
+ })
@@ -0,0 +1,131 @@
1
+ // redux-persist's own REHYDRATE action-type constant, inlined as a literal rather than taking a
2
+ // dependency on the package itself — same reasoning as @tastic/profile's createSeatColorsSlice, whose
3
+ // factory shape this follows: this file has no opinion on whether the consuming app uses
4
+ // redux-persist at all, and the string is a long-stable part of its public contract.
5
+ const REHYDRATE = 'persist/REHYDRATE'
6
+
7
+ export interface GuideState {
8
+ // The highest guide version this player has finished or skipped — 0 means "never". Compared
9
+ // against the app's own `currentVersion`, so bumping that later re-prompts everyone (only worth
10
+ // doing for a genuine controls/rules change, not a copy tweak).
11
+ versionSeen: number
12
+ }
13
+
14
+ // A `type` alias, not an `interface`, on purpose: redux's UnknownAction has a string index signature,
15
+ // and only an object-literal type alias (never an interface) is implicitly assignable to one — with an
16
+ // interface here `configureStore` rejects the reducer at the consuming app's own typecheck.
17
+ export type GuideAction = {
18
+ type: string
19
+ payload?: unknown
20
+ // Present on redux-persist's own REHYDRATE action (the persistConfig `key` it belongs to).
21
+ key?: string
22
+ }
23
+
24
+ export interface GuideSliceOptions {
25
+ // What a player who has already seen (or been grandfathered past) the guide is stamped with.
26
+ currentVersion: number
27
+ // The key the reducer is mounted under in the app's own combineReducers({...}) — redux-persist's
28
+ // REHYDRATE payload is keyed by it, and a reducer has no way to see its own mount key.
29
+ mountKey?: string
30
+ // The persistConfig `key` of the store this reducer lives in. A REHYDRATE for any OTHER persisted
31
+ // reducer (a nested persistReducer) carries that reducer's own substate, which would otherwise
32
+ // look exactly like "an existing player with no guide key".
33
+ persistKey?: string
34
+ }
35
+
36
+ export interface GuideSlice {
37
+ actions: { markSeen: (version: number) => { type: string; payload: number } }
38
+ reducer: (state: GuideState | undefined, action: GuideAction) => GuideState
39
+ selectVersionSeen: (rootState: object) => number
40
+ }
41
+
42
+ // The persisted "has this player seen the how-to-play flow" flag, as its own slice rather than a
43
+ // field on any app's settings blob — every fleet app already has a redux-persist root store behind a
44
+ // PersistGate, and a dedicated key survives 'Reset stats'/'Reset match settings' (neither touches
45
+ // Redux) without needing any of the four strict per-app settings validators to learn a new field.
46
+ //
47
+ // The one thing a plain settings field can't do, and the reason this is a factory instead of a
48
+ // createSettingsSlice call: tell a FRESH install from an EXISTING player who just updated. On a
49
+ // fresh install redux-persist's getStoredState finds nothing under the persistConfig key and
50
+ // REHYDRATE arrives with `payload === undefined`; an existing player has a payload object that
51
+ // simply predates this slice and so has no `[mountKey]` entry. The first shows the guide; the
52
+ // second is grandfathered — stamped as already having seen `currentVersion` — so nobody who has been
53
+ // playing gets a "how to play" screen after an over-the-air update. Both can still replay it from
54
+ // Settings.
55
+ //
56
+ // redux-persist's default stateReconciler (autoMergeLevel1) skips any substate a reducer already
57
+ // changed while handling REHYDRATE, so the value returned here for an existing player wins rather
58
+ // than being overwritten.
59
+ export function createGuideSlice({ currentVersion, mountKey = 'guide', persistKey = 'root' }: GuideSliceOptions): GuideSlice {
60
+ const MARK_SEEN = `${mountKey}/markSeen`
61
+ const initialState: GuideState = { versionSeen: 0 }
62
+
63
+ function markSeen(version: number) {
64
+ return { type: MARK_SEEN, payload: version }
65
+ }
66
+
67
+ function reducer(state: GuideState = initialState, action: GuideAction): GuideState {
68
+ if (action.type === REHYDRATE) {
69
+ if (action.key !== undefined && action.key !== persistKey) return state
70
+ const payload = action.payload as Record<string, Partial<GuideState> | undefined> | undefined
71
+ // Nothing persisted at all (fresh install) — or the read failed, which shows the guide once
72
+ // more rather than silently skipping it.
73
+ if (!payload) return state
74
+ const persisted = payload[mountKey]
75
+ if (typeof persisted?.versionSeen === 'number') return { versionSeen: persisted.versionSeen }
76
+ return { versionSeen: currentVersion }
77
+ }
78
+ if (action.type === MARK_SEEN && typeof action.payload === 'number') {
79
+ return { versionSeen: Math.max(state.versionSeen, action.payload) }
80
+ }
81
+ return state
82
+ }
83
+
84
+ function selectVersionSeen(rootState: object): number {
85
+ return (rootState as Record<string, GuideState | undefined>)[mountKey]?.versionSeen ?? 0
86
+ }
87
+
88
+ return { actions: { markSeen }, reducer, selectVersionSeen }
89
+ }
90
+
91
+ // The one AsyncStorage call createReturningPlayerMigrate needs, passed in (apps pass AsyncStorage
92
+ // itself) so this entry point never imports it.
93
+ export interface GuideStorageReader {
94
+ getItem: (key: string) => Promise<string | null>
95
+ }
96
+
97
+ // Structurally identical to redux-persist's own PersistedState, so the returned function drops straight
98
+ // into a persistConfig's `migrate` without this package depending on redux-persist.
99
+ export type GuidePersistedState = { _persist: { version: number; rehydrated: boolean } } | undefined
100
+
101
+ // For a persistConfig's `migrate`. createGuideSlice grandfathers a player by finding a persisted root
102
+ // store with no guide entry in it, which only works for a player whose last build already HAD that root
103
+ // store. Most of the fleet (LightCycles, AirHockey, BoxHockey, Pong) only got a redux-persist root store
104
+ // after its last shipped update, so their current players have no root store at all: on their first
105
+ // launch of a guide build REHYDRATE would arrive with no payload, exactly like a fresh install, and the
106
+ // guide would greet people who've been playing for weeks.
107
+ //
108
+ // Those earlier builds did write their own AsyncStorage keys (settings, stats, appearance, ...), which
109
+ // are passed in as `legacyKeys`. So: no root store, but any legacy key present, means "played an earlier
110
+ // build", and that player is rehydrated with an empty payload instead, which the guide slice reads as an
111
+ // existing player and stamps as seen. An empty payload is a no-op for every other slice (nothing to merge,
112
+ // and redux-persist's reconciler skips `_persist`). A root store that already exists passes through
113
+ // untouched, so this is a no-op for every launch after the first. A failed read falls back to the
114
+ // fresh-install behavior (the guide shows once), the same as the slice itself.
115
+ //
116
+ // Runs before REHYDRATE and before PersistGate lets anything render, so there's no race with Home's
117
+ // auto-show. List the keys the shipped builds actually wrote (read them from the last shipped commit, not
118
+ // today's source). The one thing to avoid is a key today's build writes BEFORE PersistGate opens: on a
119
+ // fresh install that would already exist by the time this runs, and the fresh install would be taken for
120
+ // a returning player.
121
+ export function createReturningPlayerMigrate(storage: GuideStorageReader, legacyKeys: readonly string[]) {
122
+ return async (state: GuidePersistedState, version: number): Promise<GuidePersistedState> => {
123
+ if (state !== undefined || legacyKeys.length === 0) return state
124
+ try {
125
+ const values = await Promise.all(legacyKeys.map((key) => storage.getItem(key)))
126
+ return values.some((value) => value !== null) ? { _persist: { version, rehydrated: false } } : undefined
127
+ } catch {
128
+ return undefined
129
+ }
130
+ }
131
+ }
@@ -0,0 +1,14 @@
1
+ // The how-to-play entry point (`@tastic/hud/guide`) — a standalone build reachable without ever
2
+ // touching the main barrel, the same reason `@tastic/hud/skia-gate` is. This file and everything it
3
+ // imports must stay free of @tastic/core, @tastic/profile, @tastic/split-screen, @tastic/achievements,
4
+ // @rific/updater and Skia: those are optional peers of this package (see package.json's
5
+ // peerDependenciesMeta) precisely so an app like Hangman, which has none of them, can install
6
+ // @tastic/hud and use only this. src/__tests__/guideImports.test.ts enforces it.
7
+ export { type DirectionKeyLabels, type DirectionKeysAxis, DirectionKeysHint, type DirectionKeysHintProps } from './DirectionKeysHint'
8
+ export { GuideProvider, type GuideProviderProps, useAutoShowGuide, useGuide } from './GuideProvider'
9
+ export { createGuideSlice, createReturningPlayerMigrate, type GuideAction, type GuidePersistedState, type GuideSlice, type GuideSliceOptions, type GuideState, type GuideStorageReader } from './guideSlice'
10
+ export { getGuideCardSize, HowToPlayDialog, type HowToPlayDialogProps } from './HowToPlayDialog'
11
+ export { type SeatDevice, SeatDevicesHint, type SeatDevicesHintProps } from './SeatDevicesHint'
12
+ export { SeatDiagram, type SeatDiagramProps } from './SeatDiagram'
13
+ export { type SwipeDirection, SwipeHint, type SwipeHintProps, type SwipePointer } from './SwipeHint'
14
+ export type { GuideRotation, GuideStep } from './types'
@@ -0,0 +1,16 @@
1
+ import type { ReactNode } from 'react'
2
+
3
+ // One card of a game's how-to-play flow. Content is app-local data, not something this package
4
+ // authors: only the app knows its own controls, its own key schemes on desktop web, its own win
5
+ // condition. `art` is whatever illustrates the idea — a `SwipeHint`/`SeatDiagram` from this same
6
+ // entry point, or anything the app draws itself (plain Views + Reanimated keeps it renderer-free).
7
+ export interface GuideStep {
8
+ title: string
9
+ body: string
10
+ art?: ReactNode
11
+ }
12
+
13
+ // The fleet's fake-landscape rotation (structurally @tastic/core's ViewRotation, restated here so this
14
+ // entry point never imports @tastic/core). Pass the app's LIVE rotation (useRotation/getViewRotation):
15
+ // the guide renders in the app-root Portal host, outside any rotated screen, so it turns itself.
16
+ export type GuideRotation = 0 | 90 | 180 | -90
package/src/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export { AchievementCatalogSection } from './AchievementCatalogSection'
2
2
  export { AchievementRow, LOCKED_BADGE_COLOR } from './AchievementRow'
3
+ export { AchievementUnlockList, type AchievementUnlockOwner, type UnlockedAchievement } from './AchievementUnlockList'
3
4
  export { ActivityStatSection } from './ActivityStatSection'
4
5
  export { BaseSettingsDialog, type BaseSettingsDialogProps } from './BaseSettingsDialog'
5
6
  export { BaseStatsScreen } from './BaseStatsScreen'
@@ -12,11 +13,12 @@ export { MONO_FONT } from './fonts'
12
13
  export { getInlineColorPickerContentSize, InlineColorPicker } from './InlineColorPicker'
13
14
  export { getLabeledDropdownContentHeight, LABELED_DROPDOWN_POPOVER_WIDTH, LabeledDropdown, type LabeledDropdownOption } from './LabeledDropdown'
14
15
  export { loadSkiaWeb } from './loadSkiaWeb'
15
- export { getOverlayCardColors, type OverlayCardColors, overlayStyles } from './overlayCard'
16
+ export { getOverlayCardColors, overlayActionStyles, type OverlayCardColors, overlayStyles } from './overlayCard'
16
17
  export { getColorPopoverId, getCpuDifficultyPopoverId, PlayerSetupPanel, type PlayerSetupPanelProps } from './PlayerSetupPanel'
17
18
  export { PopoverBody } from './PopoverBody'
18
19
  export { PressAwayOverlay } from './PressAwayOverlay'
19
20
  export { ReadyButton } from './ReadyButton'
21
+ export { RotatedFrame, type RotatedFrameProps } from './RotatedFrame'
20
22
  export { type MenuOption, type MenuSection, type MultiSelectSection, SectionedDropdown, type SingleSelectSection } from './SectionedDropdown'
21
23
  export { SharedActionBand } from './SharedActionBand'
22
24
  export { SkiaGate, type SkiaGateProps } from './SkiaGate'
@@ -22,10 +22,10 @@ export function getOverlayCardColors(dark: boolean): OverlayCardColors {
22
22
  }
23
23
 
24
24
  // Only the shell shape every overlay card shares (backdrop + card geometry) — deliberately not the
25
- // button/title/body styling, which already legitimately varies by consumer (BaseSettingsDialog's
26
- // single centered OK button vs. ConfirmDialog's two-button flexBasis-wrap row vs. each app's own
27
- // score/badge/zone content). A consumer applies `{backgroundColor: cardBg, borderColor: cardBorder}`
28
- // from getOverlayCardColors on top of `card` itself, same as every existing call site already does.
25
+ // title/body styling, which already legitimately varies by consumer (each app's own score/badge/zone
26
+ // content). A consumer applies `{backgroundColor: cardBg, borderColor: cardBorder}` from
27
+ // getOverlayCardColors on top of `card` itself, same as every existing call site already does. The
28
+ // one button row every overlay card with actions shares lives in overlayActionStyles below.
29
29
  export const overlayStyles = StyleSheet.create({
30
30
  card: {
31
31
  alignItems: 'center',
@@ -46,3 +46,50 @@ export const overlayStyles = StyleSheet.create({
46
46
  top: 0
47
47
  }
48
48
  })
49
+
50
+ // The action row ConfirmDialog and guide/HowToPlayDialog share: an outlined secondary (Cancel, Skip,
51
+ // Back) on the left, a contained primary on the right, each growing to split the row but never
52
+ // narrower than 128. Moved here from ConfirmDialog when the how-to-play card needed the identical
53
+ // row, so the two can't drift into different button shapes (the guide briefly shipped text-mode
54
+ // Skip/Back — the only text buttons in the fleet).
55
+ export const overlayActionStyles = StyleSheet.create({
56
+ // minWidth (not flexBasis) is each button's real floor - whenever both fit side by side, flexGrow
57
+ // still splits any leftover width evenly beyond that floor, same visual result as a plain flex: 1
58
+ // on a roomy screen, but a button can never be squeezed narrower than 128, only wrap to its own
59
+ // row instead (see actions' flexWrap below). This used to be flexBasis: 128, which measured
60
+ // correctly on web but rendered every button roughly SQUARE on iOS - confirmed live (see the
61
+ // "Quit Match?" Cancel/Quit buttons and the single-button "OK" dialogs both coming out ~128pt
62
+ // tall instead of wide). Root cause: react-native-paper's Button renders its Surface as two
63
+ // nested views on iOS only (Surface.tsx's SurfaceIOS, for shadow rendering) - only a curated
64
+ // allowlist of style keys (flex/flexGrow/flexShrink/width/height/position/... - notably NOT
65
+ // flexBasis) reaches the outer view, which is the real flex item inside this row; every other
66
+ // key, flexBasis included, falls through to an inner view whose own unspecified flexDirection
67
+ // defaults to column. flexGrow: 1 landed on the (row-context) outer view and grew width
68
+ // correctly; flexBasis: 128 landed on the (column-context) inner view instead, where a flexBasis
69
+ // sets HEIGHT, not width. minWidth isn't axis-relative like flexBasis, so it constrains width
70
+ // correctly regardless of which of the two views it ends up on. react-native-paper's Button also
71
+ // hardcodes numberOfLines={1} on its label with no override, so a button squeezed under its
72
+ // label's natural width silently ellipsizes ("Cancel" -> "Can...") instead of wrapping - assumed
73
+ // fixable by just giving the row more width, until confirmed live that these two-word labels
74
+ // (Cancel/Reset/New Game) can still lose the truncation fight even with the extra room ConfirmDialog's own
75
+ // card width fix provides, on a narrow enough screen.
76
+ actionButton: {
77
+ flexGrow: 1,
78
+ minWidth: 128
79
+ },
80
+ // A lone confirm button (no cancel - BaseSettingsDialog's info-only "OK" dialogs, e.g. the
81
+ // update-check "no update found" message) has nothing left to share actions' width with, so
82
+ // actionButton's own flexGrow: 1 would stretch it across the entire row instead of reading as a
83
+ // normal button. Same 128 floor, no flexGrow, so it stays at that natural size instead of filling
84
+ // the card - centered by actions' own justifyContent below.
85
+ actionButtonSingle: {
86
+ minWidth: 128
87
+ },
88
+ actions: {
89
+ flexDirection: 'row',
90
+ flexWrap: 'wrap',
91
+ gap: 12,
92
+ justifyContent: 'center',
93
+ width: '100%'
94
+ }
95
+ })