@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.
- package/README.md +169 -11
- package/dist/chunk-32U2OIRW.mjs +77 -0
- package/dist/guide/index.d.mts +118 -0
- package/dist/guide/index.d.ts +118 -0
- package/dist/guide/index.js +543 -0
- package/dist/guide/index.mjs +428 -0
- package/dist/index.d.mts +80 -9
- package/dist/index.d.ts +80 -9
- package/dist/index.js +417 -317
- package/dist/index.mjs +324 -296
- package/package.json +34 -7
- package/src/AchievementUnlockList.tsx +98 -0
- package/src/BaseSettingsDialog.tsx +179 -135
- package/src/ConfirmDialog.tsx +4 -43
- package/src/ContentGutter.tsx +12 -5
- package/src/RotatedFrame.tsx +71 -0
- package/src/guide/DirectionKeysHint.tsx +96 -0
- package/src/guide/GuideProvider.tsx +87 -0
- package/src/guide/HowToPlayDialog.tsx +224 -0
- package/src/guide/SeatDevicesHint.tsx +52 -0
- package/src/guide/SeatDiagram.tsx +72 -0
- package/src/guide/SwipeHint.tsx +115 -0
- package/src/guide/guideSlice.ts +131 -0
- package/src/guide/index.ts +14 -0
- package/src/guide/types.ts +16 -0
- package/src/index.ts +3 -1
- package/src/overlayCard.ts +51 -4
package/src/ContentGutter.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { computeContentBounds } from '@tastic/core'
|
|
1
|
+
import { computeContentBounds, useRotatedWindowDimensions } from '@tastic/core'
|
|
2
2
|
import { ReactNode } from 'react'
|
|
3
|
-
import { StyleProp, StyleSheet,
|
|
3
|
+
import { StyleProp, StyleSheet, View, ViewStyle } from 'react-native'
|
|
4
4
|
|
|
5
5
|
interface Props {
|
|
6
6
|
// Upper bound on the centered content region's width, in px. Omitted (the default) means no
|
|
@@ -25,13 +25,20 @@ interface Props {
|
|
|
25
25
|
// any further stops being useful. Below maxContentWidth - or with it omitted entirely - this is a
|
|
26
26
|
// no-op passthrough: children render at the full window width, no gutters.
|
|
27
27
|
//
|
|
28
|
-
//
|
|
29
|
-
// useWindowDimensions()
|
|
28
|
+
// The width it clamps against is @tastic/core's useRotatedWindowDimensions(), not a raw
|
|
29
|
+
// useWindowDimensions(): under a fake-landscape ancestor (FakeLandscapeView - the OS window stays
|
|
30
|
+
// portrait-locked, the screen is only visually rotated) the raw window width never changes, so a
|
|
31
|
+
// raw read would cap a rotated play area at the portrait width while everything inside laid out for
|
|
32
|
+
// the swapped landscape footprint, clipping it. That hook is an exact identity with
|
|
33
|
+
// useWindowDimensions() whenever no rotation applies, so nothing changes at rotation 0.
|
|
34
|
+
//
|
|
35
|
+
// See @tastic/core's computeContentBounds for the actual clamp math this just wires up to that live
|
|
36
|
+
// read - deliberately the only geometry source this component owns itself; a
|
|
30
37
|
// caller that also needs to fold in device safe-area insets or a split-screen zone boundary
|
|
31
38
|
// composes those together on its own side, the same way LightCycles' useZoneClampedAlign composes
|
|
32
39
|
// useAutoAlign with its own extra geometry (see that hook for the pattern this follows).
|
|
33
40
|
export function ContentGutter({ maxContentWidth, leftGutter, rightGutter, style, children }: Props) {
|
|
34
|
-
const { width: windowWidth } =
|
|
41
|
+
const { width: windowWidth } = useRotatedWindowDimensions()
|
|
35
42
|
const { contentWidth, gutterWidth } = computeContentBounds(windowWidth, maxContentWidth ?? Infinity)
|
|
36
43
|
|
|
37
44
|
return (
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { FakeLandscapeView, rotateDimensions, rotateInsets, useRotation, type ViewRotation } from '@tastic/core'
|
|
2
|
+
import { type ReactNode, useMemo } from 'react'
|
|
3
|
+
import { StyleSheet, useWindowDimensions, View } from 'react-native'
|
|
4
|
+
import { Portal } from 'react-native-paper'
|
|
5
|
+
import { SafeAreaInsetsContext, useSafeAreaInsets } from 'react-native-safe-area-context'
|
|
6
|
+
|
|
7
|
+
export interface RotatedFrameProps {
|
|
8
|
+
children: ReactNode
|
|
9
|
+
/** The angle to turn the frame by. Defaults to the live physical hold (@tastic/core's useRotation); pass it
|
|
10
|
+
* when the caller already has the exact angle it wants, e.g. a dialog handed its caller's rotation, or 0 for
|
|
11
|
+
* one that already sits inside a turned frame. */
|
|
12
|
+
rotation?: ViewRotation
|
|
13
|
+
/** Give everything inside the frame the safe-area insets already remapped onto the frame's own edges (see
|
|
14
|
+
* @tastic/core's rotateInsets), so a component that reads `useSafeAreaInsets()` itself (a library screen,
|
|
15
|
+
* Paper's Modal, a toaster) lands on the right edge when the frame is turned. Leave it off for content that
|
|
16
|
+
* rotates the insets itself, which would otherwise rotate them twice. */
|
|
17
|
+
rotatedSafeArea?: boolean
|
|
18
|
+
/** Mount a Paper Portal.Host INSIDE the frame, so a dialog (Paper's Portal otherwise mounts at the app root,
|
|
19
|
+
* outside the frame) is laid out against the frame's footprint and turned with it. The dialog must then be
|
|
20
|
+
* told `rotation={0}`. */
|
|
21
|
+
portalHost?: boolean
|
|
22
|
+
/** Let touches fall through the frame to whatever is beneath it, so only its children claim them. For a frame
|
|
23
|
+
* laid OVER other content (a dialog layer, a root-level overlay); a screen that owns the window leaves it off. */
|
|
24
|
+
passThrough?: boolean
|
|
25
|
+
/** Cap on the content column's width in the WIDE layout only (the rotated footprint is landscape), centred: a
|
|
26
|
+
* stats list or a roster stretched across an 874pt-wide landscape screen reads badly. Portrait is never capped,
|
|
27
|
+
* so a portrait iPad or desktop window keeps its full width. Omit for no cap. */
|
|
28
|
+
maxContentWidth?: number
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A frame turned to follow the phone while the OS stays portrait-locked (see @tastic/core's FakeLandscapeView),
|
|
32
|
+
* sized to the turned footprint so its content lays out against the short edge at +/-90. The tree is IDENTICAL
|
|
33
|
+
* at every angle, so a rotation never remounts what is inside: `FakeLandscapeView > SafeArea provider >
|
|
34
|
+
* [Portal.Host] > column`. `rotation`, `rotatedSafeArea`, `passThrough` and `maxContentWidth` only change styles
|
|
35
|
+
* and values, but `portalHost` adds or removes the Portal.Host element, so it must be STATIC per call site
|
|
36
|
+
* (flipping it at runtime would remount everything inside).
|
|
37
|
+
*
|
|
38
|
+
* Safe for tap-driven content only: react-native-gesture-handler's translation deltas are untransformed, so
|
|
39
|
+
* never put a gesture-tracked game board inside it. */
|
|
40
|
+
export function RotatedFrame({ children, rotation: rotationOverride, rotatedSafeArea = false, portalHost = false, passThrough = false, maxContentWidth }: RotatedFrameProps) {
|
|
41
|
+
const ambientRotation = useRotation()
|
|
42
|
+
const rotation = rotationOverride ?? ambientRotation
|
|
43
|
+
const window = useWindowDimensions()
|
|
44
|
+
const { width, height } = rotateDimensions(window.width, window.height, rotation)
|
|
45
|
+
const rawInsets = useSafeAreaInsets()
|
|
46
|
+
const insets = useMemo(() => (rotatedSafeArea ? rotateInsets(rawInsets, rotation) : rawInsets), [rotatedSafeArea, rawInsets, rotation])
|
|
47
|
+
const cappedWidth = maxContentWidth !== undefined && width > height ? maxContentWidth : undefined
|
|
48
|
+
const column = (
|
|
49
|
+
<View style={[styles.column, cappedWidth !== undefined && { maxWidth: cappedWidth }]} pointerEvents={passThrough ? 'box-none' : undefined}>
|
|
50
|
+
{children}
|
|
51
|
+
</View>
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<FakeLandscapeView rotation={rotation} passThrough={passThrough} style={styles.root}>
|
|
56
|
+
<SafeAreaInsetsContext.Provider value={insets}>{portalHost ? <Portal.Host>{column}</Portal.Host> : column}</SafeAreaInsetsContext.Provider>
|
|
57
|
+
</FakeLandscapeView>
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const styles = StyleSheet.create({
|
|
62
|
+
// A centred column: full width up to `maxWidth`, so a capped frame sits in the middle of a wider window.
|
|
63
|
+
column: {
|
|
64
|
+
alignSelf: 'center',
|
|
65
|
+
flex: 1,
|
|
66
|
+
width: '100%'
|
|
67
|
+
},
|
|
68
|
+
root: {
|
|
69
|
+
flex: 1
|
|
70
|
+
}
|
|
71
|
+
})
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { useAutoPaperTheme } from '@rific/auto-paper'
|
|
2
|
+
import { StyleSheet, View } from 'react-native'
|
|
3
|
+
import { Icon, Text } from 'react-native-paper'
|
|
4
|
+
|
|
5
|
+
export interface DirectionKeyLabels {
|
|
6
|
+
up: string
|
|
7
|
+
left: string
|
|
8
|
+
down: string
|
|
9
|
+
right: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type DirectionKeysAxis = 'both' | 'vertical'
|
|
13
|
+
|
|
14
|
+
export interface DirectionKeysHintProps {
|
|
15
|
+
// What's printed on each cap — e.g. { up: 'W', left: 'A', down: 'S', right: 'D' }. Omit for the
|
|
16
|
+
// arrow keys themselves, drawn as arrow icons (so does any single cap left out). Pass whatever the
|
|
17
|
+
// seat's own key scheme actually is, not a guess: the fleet games let each seat pick WASD, arrows,
|
|
18
|
+
// IJKL and so on.
|
|
19
|
+
labels?: Partial<DirectionKeyLabels>
|
|
20
|
+
// 'both' (default) draws the four-key inverted T. 'vertical' draws only up above down, for a game
|
|
21
|
+
// whose controls only ever read those two (Pong's paddles).
|
|
22
|
+
axis?: DirectionKeysAxis
|
|
23
|
+
// Defaults to the theme's own primary — i.e. player 1's seat color in the fleet games.
|
|
24
|
+
color?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const CAP_SIZE = 34
|
|
28
|
+
|
|
29
|
+
const ARROWS: DirectionKeyLabels = { up: 'arrow-up', left: 'arrow-left', down: 'arrow-down', right: 'arrow-right' }
|
|
30
|
+
|
|
31
|
+
function KeyCap({ label, icon, color }: { label?: string; icon: string; color: string }) {
|
|
32
|
+
return (
|
|
33
|
+
<View style={[styles.cap, { borderColor: color }]}>
|
|
34
|
+
{label !== undefined ? (
|
|
35
|
+
<Text variant='titleMedium' style={[styles.capLabel, { color }]}>
|
|
36
|
+
{label}
|
|
37
|
+
</Text>
|
|
38
|
+
) : (
|
|
39
|
+
<Icon source={icon} size={20} color={color} />
|
|
40
|
+
)}
|
|
41
|
+
</View>
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Key caps in the inverted-T every keyboard player already knows (or just up over down, see `axis`) —
|
|
46
|
+
// the desktop counterpart to SwipeHint for the "how do I steer" card, since a desktop browser steers by
|
|
47
|
+
// key, not by swipe. Plain Views, no animation, so there's nothing for reduced motion to switch off.
|
|
48
|
+
export function DirectionKeysHint({ labels, axis = 'both', color }: DirectionKeysHintProps) {
|
|
49
|
+
const { colors } = useAutoPaperTheme()
|
|
50
|
+
const tint = color ?? colors.primary
|
|
51
|
+
|
|
52
|
+
if (axis === 'vertical') {
|
|
53
|
+
const accessibilityLabel = labels?.up && labels.down ? `Keys ${labels.up} and ${labels.down}` : 'Up and down arrow keys'
|
|
54
|
+
return (
|
|
55
|
+
<View accessible accessibilityLabel={accessibilityLabel} style={styles.cluster}>
|
|
56
|
+
<KeyCap label={labels?.up} icon={ARROWS.up} color={tint} />
|
|
57
|
+
<KeyCap label={labels?.down} icon={ARROWS.down} color={tint} />
|
|
58
|
+
</View>
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const accessibilityLabel = labels?.up && labels.left && labels.down && labels.right ? `Keys ${labels.up}, ${labels.left}, ${labels.down}, ${labels.right}` : 'Arrow keys'
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<View accessible accessibilityLabel={accessibilityLabel} style={styles.cluster}>
|
|
66
|
+
<KeyCap label={labels?.up} icon={ARROWS.up} color={tint} />
|
|
67
|
+
<View style={styles.row}>
|
|
68
|
+
<KeyCap label={labels?.left} icon={ARROWS.left} color={tint} />
|
|
69
|
+
<KeyCap label={labels?.down} icon={ARROWS.down} color={tint} />
|
|
70
|
+
<KeyCap label={labels?.right} icon={ARROWS.right} color={tint} />
|
|
71
|
+
</View>
|
|
72
|
+
</View>
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const styles = StyleSheet.create({
|
|
77
|
+
cap: {
|
|
78
|
+
alignItems: 'center',
|
|
79
|
+
borderRadius: 8,
|
|
80
|
+
borderWidth: 2,
|
|
81
|
+
height: CAP_SIZE,
|
|
82
|
+
justifyContent: 'center',
|
|
83
|
+
width: CAP_SIZE
|
|
84
|
+
},
|
|
85
|
+
capLabel: {
|
|
86
|
+
fontWeight: 'bold'
|
|
87
|
+
},
|
|
88
|
+
cluster: {
|
|
89
|
+
alignItems: 'center',
|
|
90
|
+
gap: 6
|
|
91
|
+
},
|
|
92
|
+
row: {
|
|
93
|
+
flexDirection: 'row',
|
|
94
|
+
gap: 6
|
|
95
|
+
}
|
|
96
|
+
})
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
import { HowToPlayDialog } from './HowToPlayDialog'
|
|
4
|
+
import type { GuideRotation, GuideStep } from './types'
|
|
5
|
+
|
|
6
|
+
interface GuideContextValue {
|
|
7
|
+
isOpen: boolean
|
|
8
|
+
// Opens the guide unconditionally — the Settings "How to play" row's whole job. Never persists
|
|
9
|
+
// anything on its own: only finishing or skipping does, and only while the player hasn't already
|
|
10
|
+
// seen this version.
|
|
11
|
+
open: () => void
|
|
12
|
+
close: () => void
|
|
13
|
+
// Opens it once per session, and only if this player hasn't seen `currentVersion` yet — see
|
|
14
|
+
// useAutoShowGuide.
|
|
15
|
+
requestAutoShow: () => void
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const noop = () => {}
|
|
19
|
+
|
|
20
|
+
// A default that does nothing rather than throwing when there's no provider above — an app's own
|
|
21
|
+
// existing tests render its Settings dialog or Home screen without one, and a "How to play" row that
|
|
22
|
+
// silently does nothing in that harness is a far better failure than every such test breaking.
|
|
23
|
+
const GuideContext = createContext<GuideContextValue>({ isOpen: false, open: noop, close: noop, requestAutoShow: noop })
|
|
24
|
+
|
|
25
|
+
export interface GuideProviderProps {
|
|
26
|
+
steps: GuideStep[]
|
|
27
|
+
// Bumped by the app only for a genuine controls/rules change, to re-prompt everyone.
|
|
28
|
+
currentVersion: number
|
|
29
|
+
// Read from the app's own persisted store (see createGuideSlice's selectVersionSeen).
|
|
30
|
+
seenVersion: number
|
|
31
|
+
// Called with `currentVersion` when the player finishes or skips a guide they hadn't seen yet.
|
|
32
|
+
onSeen: (version: number) => void
|
|
33
|
+
// The app's live fake-landscape rotation (see GuideRotation). The card rotates and sizes itself for it.
|
|
34
|
+
rotation?: GuideRotation
|
|
35
|
+
children?: ReactNode
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Mounted once at the app root, next to <UpdateDialog /> — the fleet's existing "self-presenting
|
|
39
|
+
// app-level dialog" seat — so any screen can open it through useGuide() without prop-drilling, and
|
|
40
|
+
// the Portal it renders sits above every route. Inside PersistGate, so `seenVersion` is already the
|
|
41
|
+
// rehydrated value by the time this first renders.
|
|
42
|
+
export function GuideProvider({ steps, currentVersion, seenVersion, onSeen, rotation, children }: GuideProviderProps) {
|
|
43
|
+
const [isOpen, setIsOpen] = useState(false)
|
|
44
|
+
// Once per session, not once per Home mount — Home remounts every time the player backs out of a
|
|
45
|
+
// match, and skipping shouldn't be something they have to do again a minute later just because the
|
|
46
|
+
// persisted flag hadn't been written back through the store yet.
|
|
47
|
+
const autoShownRef = useRef(false)
|
|
48
|
+
const unseen = seenVersion < currentVersion
|
|
49
|
+
|
|
50
|
+
const open = useCallback(() => setIsOpen(true), [])
|
|
51
|
+
|
|
52
|
+
const close = useCallback(() => {
|
|
53
|
+
setIsOpen(false)
|
|
54
|
+
if (unseen) onSeen(currentVersion)
|
|
55
|
+
}, [unseen, onSeen, currentVersion])
|
|
56
|
+
|
|
57
|
+
const requestAutoShow = useCallback(() => {
|
|
58
|
+
if (!unseen || autoShownRef.current) return
|
|
59
|
+
autoShownRef.current = true
|
|
60
|
+
setIsOpen(true)
|
|
61
|
+
}, [unseen])
|
|
62
|
+
|
|
63
|
+
const value = useMemo(() => ({ isOpen, open, close, requestAutoShow }), [isOpen, open, close, requestAutoShow])
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<GuideContext.Provider value={value}>
|
|
67
|
+
{children}
|
|
68
|
+
<HowToPlayDialog visible={isOpen} steps={steps} onFinish={close} onSkip={close} rotation={rotation} />
|
|
69
|
+
</GuideContext.Provider>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function useGuide(): GuideContextValue {
|
|
74
|
+
return useContext(GuideContext)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Call this from the Home screen and nowhere else — that placement, not any route check in here, is
|
|
78
|
+
// what keeps it from ever firing over a live match or a two-seat loadout. Home is the one screen
|
|
79
|
+
// with a single reader, before any seating, so a face-to-face two-player phone doesn't need per-seat
|
|
80
|
+
// rotated copies of the card. `enabled` lets a caller hold it back (e.g. while a shared-link import
|
|
81
|
+
// is being handled) without breaking the rules of hooks by conditionally calling this.
|
|
82
|
+
export function useAutoShowGuide(enabled = true) {
|
|
83
|
+
const { requestAutoShow } = useGuide()
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
if (enabled) requestAutoShow()
|
|
86
|
+
}, [enabled, requestAutoShow])
|
|
87
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { useAutoPaperTheme } from '@rific/auto-paper'
|
|
2
|
+
import { Button } from '@rific/feedback-press'
|
|
3
|
+
import { useEffect, useRef, useState } from 'react'
|
|
4
|
+
import { BackHandler, type NativeScrollEvent, type NativeSyntheticEvent, ScrollView, StyleSheet, useWindowDimensions, View } from 'react-native'
|
|
5
|
+
import { Portal, Text } from 'react-native-paper'
|
|
6
|
+
import { useReducedMotion } from 'react-native-reanimated'
|
|
7
|
+
|
|
8
|
+
import { getOverlayCardColors, overlayActionStyles, overlayStyles } from '../overlayCard'
|
|
9
|
+
import type { GuideRotation, GuideStep } from './types'
|
|
10
|
+
|
|
11
|
+
// The overlay's own breathing room and the card's own inner padding. Together with the card's max
|
|
12
|
+
// width these fix the width of one page exactly, which the paging ScrollView below needs up front (it
|
|
13
|
+
// snaps to multiples of it) rather than measuring after layout.
|
|
14
|
+
const OVERLAY_GUTTER = 24
|
|
15
|
+
const CARD_PADDING = 24
|
|
16
|
+
const MAX_CARD_WIDTH = 360
|
|
17
|
+
// A quarter-turned card has the phone's long edge to spread across, so it's allowed wider: shorter
|
|
18
|
+
// lines mean fewer of them, which is what actually has to fit (see sizing below).
|
|
19
|
+
const MAX_CARD_WIDTH_TURNED = 520
|
|
20
|
+
const ART_HEIGHT = 132
|
|
21
|
+
// Below this much usable height the illustration box shrinks. Sized so the full card (padding, art,
|
|
22
|
+
// title, two lines of body, dots, buttons) fits a 360pt-wide phone held sideways.
|
|
23
|
+
const COMPACT_BELOW = 480
|
|
24
|
+
const ART_HEIGHT_COMPACT = 88
|
|
25
|
+
|
|
26
|
+
// Where the card has to fit. The fleet's rotation is FAKE: the OS window stays portrait and the card
|
|
27
|
+
// is only turned with a transform, so a quarter-turned card's on-screen width is its layout HEIGHT,
|
|
28
|
+
// which must fit the window's short edge, and its on-screen height is its layout width. Sizing it
|
|
29
|
+
// against the unswapped window (what the first version did) let a sideways card run off a small
|
|
30
|
+
// phone. Exported for tests.
|
|
31
|
+
export function getGuideCardSize(windowWidth: number, windowHeight: number, rotation: GuideRotation) {
|
|
32
|
+
const turned = rotation === 90 || rotation === -90
|
|
33
|
+
const availableWidth = (turned ? windowHeight : windowWidth) - OVERLAY_GUTTER * 2
|
|
34
|
+
const availableHeight = (turned ? windowWidth : windowHeight) - OVERLAY_GUTTER * 2
|
|
35
|
+
const width = Math.min(turned ? MAX_CARD_WIDTH_TURNED : MAX_CARD_WIDTH, availableWidth)
|
|
36
|
+
return {
|
|
37
|
+
width,
|
|
38
|
+
pageWidth: width - CARD_PADDING * 2,
|
|
39
|
+
maxHeight: availableHeight,
|
|
40
|
+
artHeight: availableHeight < COMPACT_BELOW ? ART_HEIGHT_COMPACT : ART_HEIGHT
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface HowToPlayDialogProps {
|
|
45
|
+
visible: boolean
|
|
46
|
+
steps: GuideStep[]
|
|
47
|
+
// Last card's primary button.
|
|
48
|
+
onFinish: () => void
|
|
49
|
+
// Skip (first card only — see the action row), and Android's hardware back button — both mean "I don't need this", so a caller persisting
|
|
50
|
+
// "seen" should treat them exactly like onFinish.
|
|
51
|
+
onSkip: () => void
|
|
52
|
+
// The app's live fake-landscape rotation (see GuideRotation). Portal content escapes any rotated
|
|
53
|
+
// ancestor, so the card rotates itself, and sizes itself for the turn (see getGuideCardSize).
|
|
54
|
+
rotation?: GuideRotation
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
type CardProps = Omit<HowToPlayDialogProps, 'visible'>
|
|
58
|
+
|
|
59
|
+
function HowToPlayCard({ steps, onFinish, onSkip, rotation = 0 }: CardProps) {
|
|
60
|
+
const { dark, colors } = useAutoPaperTheme()
|
|
61
|
+
const { width: windowWidth, height: windowHeight } = useWindowDimensions()
|
|
62
|
+
const reducedMotion = useReducedMotion()
|
|
63
|
+
const [index, setIndex] = useState(0)
|
|
64
|
+
const scrollRef = useRef<ScrollView>(null)
|
|
65
|
+
|
|
66
|
+
// Android's hardware back would otherwise fall through to the router underneath and pop a screen
|
|
67
|
+
// (or exit the app) with the guide still up. Treated as Skip, the same as the on-screen button.
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
const subscription = BackHandler.addEventListener('hardwareBackPress', () => {
|
|
70
|
+
onSkip()
|
|
71
|
+
return true
|
|
72
|
+
})
|
|
73
|
+
return () => subscription.remove()
|
|
74
|
+
}, [onSkip])
|
|
75
|
+
|
|
76
|
+
const { fg, cardBg, cardBorder, fgMuted } = getOverlayCardColors(dark)
|
|
77
|
+
const { width: cardWidth, pageWidth, maxHeight, artHeight } = getGuideCardSize(windowWidth, windowHeight, rotation)
|
|
78
|
+
const rotationStyle = rotation === 0 ? undefined : { transform: [{ rotate: `${rotation}deg` }] }
|
|
79
|
+
const isFirst = index === 0
|
|
80
|
+
const isLast = index === steps.length - 1
|
|
81
|
+
const isOnly = steps.length === 1
|
|
82
|
+
const artBackground = dark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.05)'
|
|
83
|
+
|
|
84
|
+
// The page a Next/Back tap is animating to, while it's on its way. An animated scrollTo emits every
|
|
85
|
+
// offset in between through onScroll, and without this the dots and buttons would flip back to the
|
|
86
|
+
// page being left for the first half of the animation.
|
|
87
|
+
const targetRef = useRef<number | null>(null)
|
|
88
|
+
|
|
89
|
+
function goTo(next: number) {
|
|
90
|
+
targetRef.current = next
|
|
91
|
+
setIndex(next)
|
|
92
|
+
scrollRef.current?.scrollTo({ x: next * pageWidth, animated: !reducedMotion })
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Keeps the dots and buttons in step with a swipe (or a trackpad scroll on web). onScroll, not only
|
|
96
|
+
// onMomentumScrollEnd: react-native-web never fires the momentum events, so on web a swipe moved the
|
|
97
|
+
// cards while the dots and buttons stayed on the old page (Jay spotted it in Solitaire). The index
|
|
98
|
+
// follows whichever page is more than half in view, the same moment a native pager's dots change.
|
|
99
|
+
function syncToOffset(offsetX: number) {
|
|
100
|
+
const page = Math.round(offsetX / pageWidth)
|
|
101
|
+
if (page < 0 || page >= steps.length) return
|
|
102
|
+
if (targetRef.current !== null) {
|
|
103
|
+
if (page === targetRef.current) targetRef.current = null
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
setIndex(page)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function handleScroll(e: NativeSyntheticEvent<NativeScrollEvent>) {
|
|
110
|
+
syncToOffset(e.nativeEvent.contentOffset.x)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Native's settle event: also clears a pending target, in case the animation was interrupted by a
|
|
114
|
+
// drag before it reached its page.
|
|
115
|
+
function handleMomentumEnd(e: NativeSyntheticEvent<NativeScrollEvent>) {
|
|
116
|
+
targetRef.current = null
|
|
117
|
+
syncToOffset(e.nativeEvent.contentOffset.x)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
<Portal>
|
|
122
|
+
<View style={[overlayStyles.overlay, styles.overlay]}>
|
|
123
|
+
<View accessibilityViewIsModal style={[overlayStyles.card, styles.card, { width: cardWidth, maxHeight, backgroundColor: cardBg, borderColor: cardBorder }, rotationStyle]}>
|
|
124
|
+
{/* The pager shrinks (flexShrink) when the card hits maxHeight, and each page is its own
|
|
125
|
+
vertical ScrollView, so a page that still doesn't fit (a long translation, large text)
|
|
126
|
+
scrolls instead of being clipped. In the normal case nothing scrolls. */}
|
|
127
|
+
<ScrollView ref={scrollRef} horizontal pagingEnabled showsHorizontalScrollIndicator={false} onScroll={handleScroll} scrollEventThrottle={16} onMomentumScrollEnd={handleMomentumEnd} style={[styles.pager, { width: pageWidth }]}>
|
|
128
|
+
{steps.map((step, i) => (
|
|
129
|
+
<ScrollView key={i} nestedScrollEnabled showsVerticalScrollIndicator={false} style={{ width: pageWidth }} contentContainerStyle={styles.page}>
|
|
130
|
+
{step.art !== undefined && <View style={[styles.art, { height: artHeight, backgroundColor: artBackground }]}>{step.art}</View>}
|
|
131
|
+
<Text variant='headlineSmall' style={[styles.title, { color: fg }]}>
|
|
132
|
+
{step.title}
|
|
133
|
+
</Text>
|
|
134
|
+
<Text variant='bodyMedium' style={[styles.body, { color: fg }]}>
|
|
135
|
+
{step.body}
|
|
136
|
+
</Text>
|
|
137
|
+
</ScrollView>
|
|
138
|
+
))}
|
|
139
|
+
</ScrollView>
|
|
140
|
+
|
|
141
|
+
{steps.length > 1 && (
|
|
142
|
+
<View accessible accessibilityLabel={`Step ${index + 1} of ${steps.length}`} style={styles.dots}>
|
|
143
|
+
{steps.map((_, i) => (
|
|
144
|
+
<View key={i} style={[styles.dot, { backgroundColor: i === index ? colors.primary : fgMuted }]} />
|
|
145
|
+
))}
|
|
146
|
+
</View>
|
|
147
|
+
)}
|
|
148
|
+
|
|
149
|
+
{/* ConfirmDialog's exact row (see overlayActionStyles): outlined secondary on the left,
|
|
150
|
+
contained primary on the right. Skip and Back share the one secondary slot — three
|
|
151
|
+
full-size buttons don't fit side by side at the row's 128 minimum — so Skip is only
|
|
152
|
+
offered on the first card; after that the way out is Next to the end (a short flow) or
|
|
153
|
+
Android's hardware back, which skips from anywhere. */}
|
|
154
|
+
<View style={overlayActionStyles.actions}>
|
|
155
|
+
{!isOnly && (
|
|
156
|
+
<Button mode='outlined' onPress={isFirst ? onSkip : () => goTo(index - 1)} style={overlayActionStyles.actionButton}>
|
|
157
|
+
{isFirst ? 'Skip' : 'Back'}
|
|
158
|
+
</Button>
|
|
159
|
+
)}
|
|
160
|
+
<Button mode='contained' onPress={isLast ? onFinish : () => goTo(index + 1)} style={isOnly ? overlayActionStyles.actionButtonSingle : overlayActionStyles.actionButton} buttonColor={colors.primary} textColor={colors.onPrimary}>
|
|
161
|
+
{isLast ? 'Ready' : 'Next'}
|
|
162
|
+
</Button>
|
|
163
|
+
</View>
|
|
164
|
+
</View>
|
|
165
|
+
</View>
|
|
166
|
+
</Portal>
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// A short, skippable, paged "how to play" card — the one shared shape every fleet game's intro takes,
|
|
171
|
+
// built on the same retro overlay-card look as ConfirmDialog (see overlayCard.ts) so it reads as
|
|
172
|
+
// part of the same family. Controlled and presentational: whether it's visible, and what happens on
|
|
173
|
+
// finish/skip (persisting "seen", mostly), is the caller's — see GuideProvider for the wiring nearly
|
|
174
|
+
// every app wants. Buttons are the primary way through; the paging ScrollView underneath is what
|
|
175
|
+
// makes a swipe work too, with no gesture-handler dependency.
|
|
176
|
+
//
|
|
177
|
+
// Split into an outer visibility gate and an inner card so the card's own page index is created
|
|
178
|
+
// fresh every time it opens — a replay from Settings always starts on the first card, with no
|
|
179
|
+
// effect needed to reset it.
|
|
180
|
+
export function HowToPlayDialog({ visible, steps, ...rest }: HowToPlayDialogProps) {
|
|
181
|
+
if (!visible || steps.length === 0) return null
|
|
182
|
+
return <HowToPlayCard steps={steps} {...rest} />
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const styles = StyleSheet.create({
|
|
186
|
+
art: {
|
|
187
|
+
alignItems: 'center',
|
|
188
|
+
borderRadius: 12,
|
|
189
|
+
justifyContent: 'center',
|
|
190
|
+
overflow: 'hidden',
|
|
191
|
+
width: '100%'
|
|
192
|
+
},
|
|
193
|
+
body: {
|
|
194
|
+
textAlign: 'center'
|
|
195
|
+
},
|
|
196
|
+
// Additive on top of overlayCard's own shared `card` shape — its default 32px padding is tighter
|
|
197
|
+
// here (a paged card wants more width for its art) and the shared 16px gap is right.
|
|
198
|
+
card: {
|
|
199
|
+
padding: CARD_PADDING
|
|
200
|
+
},
|
|
201
|
+
dot: {
|
|
202
|
+
borderRadius: 4,
|
|
203
|
+
height: 8,
|
|
204
|
+
width: 8
|
|
205
|
+
},
|
|
206
|
+
dots: {
|
|
207
|
+
flexDirection: 'row',
|
|
208
|
+
gap: 8
|
|
209
|
+
},
|
|
210
|
+
overlay: {
|
|
211
|
+
paddingHorizontal: OVERLAY_GUTTER
|
|
212
|
+
},
|
|
213
|
+
page: {
|
|
214
|
+
alignItems: 'center',
|
|
215
|
+
gap: 12
|
|
216
|
+
},
|
|
217
|
+
pager: {
|
|
218
|
+
flexShrink: 1
|
|
219
|
+
},
|
|
220
|
+
title: {
|
|
221
|
+
fontWeight: 'bold',
|
|
222
|
+
textAlign: 'center'
|
|
223
|
+
}
|
|
224
|
+
})
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { useAutoPaperTheme } from '@rific/auto-paper'
|
|
2
|
+
import { StyleSheet, View } from 'react-native'
|
|
3
|
+
import { Icon } from 'react-native-paper'
|
|
4
|
+
|
|
5
|
+
export type SeatDevice = 'keyboard' | 'mouse'
|
|
6
|
+
|
|
7
|
+
export interface SeatDevicesHintProps {
|
|
8
|
+
// What each seat plays with right now, in seat order (index 0 = player 1). Map the app's own live
|
|
9
|
+
// control scheme onto this: a key scheme is 'keyboard', a pointer scheme is 'mouse'.
|
|
10
|
+
devices: readonly SeatDevice[]
|
|
11
|
+
// Each seat's color, same order. Defaults to the theme's primary/secondary, which ARE the two seats'
|
|
12
|
+
// colors in the fleet games.
|
|
13
|
+
seatColors?: readonly string[]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const ICON: Record<SeatDevice, { source: string; size: number }> = {
|
|
17
|
+
keyboard: { source: 'keyboard-outline', size: 56 },
|
|
18
|
+
mouse: { source: 'mouse-outline', size: 44 }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const ORDER: readonly SeatDevice[] = ['keyboard', 'mouse']
|
|
22
|
+
|
|
23
|
+
// "Two players, one computer": the desktop counterpart to SeatDiagram on the two-player card. One icon
|
|
24
|
+
// per device the seats actually use (a keyboard if either is on keys, a mouse if either drags), each
|
|
25
|
+
// tinted in the color of the one seat using it, or the neutral onSurface when both share it. So the
|
|
26
|
+
// fleet's common default of P1 on the mouse and P2 on WASD reads as a P1-colored mouse beside a
|
|
27
|
+
// P2-colored keyboard, and a keys-only setup as one plain keyboard. Snake, AirHockey and Pong each drew
|
|
28
|
+
// their own copy of this before it existed here. Plain Icons, no animation.
|
|
29
|
+
export function SeatDevicesHint({ devices, seatColors }: SeatDevicesHintProps) {
|
|
30
|
+
const { colors } = useAutoPaperTheme()
|
|
31
|
+
const tints = seatColors ?? [colors.primary, colors.secondary]
|
|
32
|
+
const accessibilityLabel = devices.map((device, i) => `Player ${i + 1} uses the ${device}`).join(', ')
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<View accessible accessibilityLabel={accessibilityLabel} style={styles.row}>
|
|
36
|
+
{ORDER.map((device) => {
|
|
37
|
+
const seats = devices.flatMap((d, i) => (d === device ? [i] : []))
|
|
38
|
+
if (seats.length === 0) return null
|
|
39
|
+
const color = seats.length === 1 ? (tints[seats[0]] ?? colors.onSurface) : colors.onSurface
|
|
40
|
+
return <Icon key={device} source={ICON[device].source} size={ICON[device].size} color={color} />
|
|
41
|
+
})}
|
|
42
|
+
</View>
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const styles = StyleSheet.create({
|
|
47
|
+
row: {
|
|
48
|
+
alignItems: 'center',
|
|
49
|
+
flexDirection: 'row',
|
|
50
|
+
gap: 20
|
|
51
|
+
}
|
|
52
|
+
})
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { useAutoPaperTheme } from '@rific/auto-paper'
|
|
2
|
+
import { StyleSheet, View } from 'react-native'
|
|
3
|
+
import { Text } from 'react-native-paper'
|
|
4
|
+
|
|
5
|
+
export interface SeatDiagramProps {
|
|
6
|
+
// Default to the theme's own secondary/primary — in the fleet games those ARE the two players'
|
|
7
|
+
// live seat colors, so the picture always matches whatever colors they've picked.
|
|
8
|
+
topColor?: string
|
|
9
|
+
bottomColor?: string
|
|
10
|
+
topLabel?: string
|
|
11
|
+
bottomLabel?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const DEVICE_WIDTH = 88
|
|
15
|
+
const DEVICE_HEIGHT = 120
|
|
16
|
+
|
|
17
|
+
// "Two players, one phone": a portrait phone split into a top half and a bottom half, each tinted in
|
|
18
|
+
// its own seat color, with the top label upside down the way that player actually reads it. The
|
|
19
|
+
// seating card every face-to-face game (LightCycles, Snake, AirHockey, BoxHockey, Pong) needs, drawn
|
|
20
|
+
// once. Plain Views, no animation — nothing here needs reduced-motion handling.
|
|
21
|
+
export function SeatDiagram({ topColor, bottomColor, topLabel = 'P2', bottomLabel = 'P1' }: SeatDiagramProps) {
|
|
22
|
+
const { colors } = useAutoPaperTheme()
|
|
23
|
+
const top = topColor ?? colors.secondary
|
|
24
|
+
const bottom = bottomColor ?? colors.primary
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<View accessible accessibilityLabel={`${bottomLabel} plays the bottom half, ${topLabel} plays the top half`} style={[styles.device, { borderColor: colors.onSurface }]}>
|
|
28
|
+
<View style={[styles.half, { borderColor: top }]}>
|
|
29
|
+
<View style={[StyleSheet.absoluteFill, styles.tint, { backgroundColor: top }]} />
|
|
30
|
+
<Text variant='titleMedium' style={[styles.label, styles.flipped, { color: top }]}>
|
|
31
|
+
{topLabel}
|
|
32
|
+
</Text>
|
|
33
|
+
</View>
|
|
34
|
+
<View style={[styles.half, styles.bottomHalf, { borderColor: bottom }]}>
|
|
35
|
+
<View style={[StyleSheet.absoluteFill, styles.tint, { backgroundColor: bottom }]} />
|
|
36
|
+
<Text variant='titleMedium' style={[styles.label, { color: bottom }]}>
|
|
37
|
+
{bottomLabel}
|
|
38
|
+
</Text>
|
|
39
|
+
</View>
|
|
40
|
+
</View>
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const styles = StyleSheet.create({
|
|
45
|
+
bottomHalf: {
|
|
46
|
+
borderTopWidth: 0
|
|
47
|
+
},
|
|
48
|
+
device: {
|
|
49
|
+
borderRadius: 16,
|
|
50
|
+
borderWidth: 2,
|
|
51
|
+
height: DEVICE_HEIGHT,
|
|
52
|
+
overflow: 'hidden',
|
|
53
|
+
width: DEVICE_WIDTH
|
|
54
|
+
},
|
|
55
|
+
flipped: {
|
|
56
|
+
transform: [{ rotate: '180deg' }]
|
|
57
|
+
},
|
|
58
|
+
half: {
|
|
59
|
+
alignItems: 'center',
|
|
60
|
+
borderWidth: 2,
|
|
61
|
+
flex: 1,
|
|
62
|
+
justifyContent: 'center'
|
|
63
|
+
},
|
|
64
|
+
label: {
|
|
65
|
+
fontWeight: 'bold'
|
|
66
|
+
},
|
|
67
|
+
// A wash of the seat color behind its own label rather than an rgba() fill — the theme's colors
|
|
68
|
+
// aren't guaranteed to be 6-digit hex, so an opacity layer is the only alpha that always works.
|
|
69
|
+
tint: {
|
|
70
|
+
opacity: 0.2
|
|
71
|
+
}
|
|
72
|
+
})
|