diff --git a/apps/tv/scripts/art/prepare_ui.py b/apps/tv/scripts/art/prepare_ui.py index e6c97e7..9cc133a 100644 --- a/apps/tv/scripts/art/prepare_ui.py +++ b/apps/tv/scripts/art/prepare_ui.py @@ -65,6 +65,12 @@ def main(): card = cover(im, 624, 300) card = shade(card, lambda x, y: 0.95 - 0.3 * np.clip((y - 0.7) / 0.3, 0, 1)) card.save(OUT / 'cards' / f'{mystery_id}.jpg', quality=84, optimize=True) + # Detective Grace for Home: the full figure, trimmed, 900 px tall, transparent. + grace = Image.open(src / 'shared.grace.full.png').convert('RGBA') + grace = grace.crop(grace.getchannel('A').point(lambda v: 255 if v > 12 else 0).getbbox()) + grace = grace.resize((round(grace.width * 900 / grace.height), 900), Image.LANCZOS) + grace.save(OUT / 'grace.png', optimize=True) + print('grace.png', grace.size) for p in sorted(OUT.rglob('*.jpg')): print(p.relative_to(OUT), round(p.stat().st_size / 1024), 'KB') diff --git a/apps/tv/src/App.tsx b/apps/tv/src/App.tsx index d2a1e48..e7326df 100644 --- a/apps/tv/src/App.tsx +++ b/apps/tv/src/App.tsx @@ -5,6 +5,7 @@ import {ScreenTransition} from './components/Background/ScreenTransition'; import {DEFAULT_MYSTERY_ID} from './data/mysteries'; import {GameProvider, GameProviderProps, useGame} from './game/GameContext'; import {CharacterSpeech} from './game/CharacterSpeech'; +import {HowToPlayScreen} from './screens/HowToPlay/HowToPlayScreen'; import {RoomWatcher} from './game/RoomWatcher'; import {ToastProvider} from './ui/Toast'; import type {TransitionPreset} from './ui/Transition'; @@ -234,6 +235,8 @@ function renderScreen(route: Route, props: ScreenProps) { return ; case 'settings': return ; + case 'howToPlay': + return ; case 'audioDiagnostics': return ; case 'gameIntro': diff --git a/apps/tv/src/assets/ui/grace.png b/apps/tv/src/assets/ui/grace.png new file mode 100644 index 0000000..b230a2e Binary files /dev/null and b/apps/tv/src/assets/ui/grace.png differ diff --git a/apps/tv/src/components/TVButton/TVButton.tsx b/apps/tv/src/components/TVButton/TVButton.tsx index 62c1c69..94e2f12 100644 --- a/apps/tv/src/components/TVButton/TVButton.tsx +++ b/apps/tv/src/components/TVButton/TVButton.tsx @@ -25,6 +25,10 @@ export interface TVButtonProps { disabled?: boolean; accessibilityLabel?: string; accessibilityHint?: string; + /** Row width for a menu row (default 640). The focus frame follows it. */ + width?: number; + /** A small action (filter chips): shorter, no minimum width. */ + compact?: boolean; testID?: string; } @@ -48,6 +52,8 @@ export const TVButton = ({ disabled, accessibilityLabel, accessibilityHint, + width, + compact, testID, }: TVButtonProps) => { const [focused, setFocused] = useState(false); @@ -89,19 +95,21 @@ export const TVButton = ({ accessibilityHint={accessibilityHint} accessibilityState={{selected: focused, disabled: !!disabled}} testID={testID}> - + {isMenu && ( ▶ )} - + {label.toUpperCase()} {caption !== undefined && isMenu && ( @@ -148,6 +156,15 @@ const styles = StyleSheet.create({ paddingHorizontal: theme.spacing.lg, justifyContent: 'center', }, + compact: { + minWidth: 0, + height: px(56), + paddingHorizontal: theme.spacing.md, + }, + compactLabel: { + fontSize: px(22), + letterSpacing: px(2), + }, actionIdle: { borderColor: theme.colors.border, backgroundColor: theme.colors.surface, diff --git a/apps/tv/src/data/mysteries.ts b/apps/tv/src/data/mysteries.ts index e763efa..c12b3df 100644 --- a/apps/tv/src/data/mysteries.ts +++ b/apps/tv/src/data/mysteries.ts @@ -4,7 +4,7 @@ * stay in the mystery data and reach screens only through the game engine. */ import {getMysteryDefinition, mysteryCatalog} from '@roomriddle/mystery-data'; -import type {MysterySummary} from '@roomriddle/shared-types'; +import type {AgeGroup, MysterySummary, StoryType} from '@roomriddle/shared-types'; export type MysteryAvailability = 'available' | 'coming-soon'; @@ -25,6 +25,9 @@ export interface TVMystery { /** Optional large background motif on the detail screen. */ motif?: string; availability: MysteryAvailability; + ageGroup: AgeGroup; + storyType: StoryType; + synopsis: string; } function toTVMystery(summary: MysterySummary): TVMystery { @@ -43,6 +46,9 @@ function toTVMystery(summary: MysterySummary): TVMystery { estimatedMinutes: summary.estimatedMinutes, motif: summary.motif, availability: playable ? 'available' : 'coming-soon', + ageGroup: summary.ageGroup, + storyType: summary.storyType, + synopsis: summary.synopsis, }; } @@ -57,3 +63,29 @@ export function findMystery(id: string): TVMystery | undefined { export function formatPlayers(mystery: TVMystery): string { return `${mystery.minPlayers}–${mystery.maxPlayers}`; } + +// ── Library filters ──────────────────────────────────────────────────── + +export type AgeFilter = 'any' | AgeGroup; +export type TypeFilter = 'any' | StoryType; + +export const AGE_LABEL: Readonly> = {all: 'All ages', family: 'Family 8+', teens: 'Teens & adults'}; +export const TYPE_LABEL: Readonly> = {thriller: 'Thriller', gothic: 'Gothic', tech: 'Tech', adventure: 'Adventure', riddle: 'Riddles'}; + +/** Age chips in a fixed order, only for groups the library actually has. */ +export function ageFilters(list: readonly TVMystery[] = mysteries): AgeFilter[] { + return ['any', ...(['all', 'family', 'teens'] as const).filter(g => list.some(m => m.ageGroup === g))]; +} + +export function typeFilters(list: readonly TVMystery[] = mysteries): TypeFilter[] { + return ['any', ...(['thriller', 'gothic', 'tech', 'adventure', 'riddle'] as const).filter(t => list.some(m => m.storyType === t))]; +} + +/** + * The cases a group can play. An age filter means "suitable for": choosing + * Family 8+ also shows all-ages cases, and Teens & adults shows everything. + */ +export function filterMysteries(list: readonly TVMystery[], age: AgeFilter, type: TypeFilter): TVMystery[] { + const suits = (g: AgeGroup): boolean => age === 'any' || age === 'teens' || g === age || (age === 'family' && g === 'all'); + return list.filter(m => suits(m.ageGroup) && (type === 'any' || m.storyType === type)); +} diff --git a/apps/tv/src/navigation/TVNavigation.ts b/apps/tv/src/navigation/TVNavigation.ts index 021610a..a4f049a 100644 --- a/apps/tv/src/navigation/TVNavigation.ts +++ b/apps/tv/src/navigation/TVNavigation.ts @@ -27,6 +27,7 @@ export type Route = | {name: 'mysteries'} | {name: 'mysteryDetails'; mysteryId: string} | {name: 'settings'} + | {name: 'howToPlay'} | {name: 'audioDiagnostics'} | {name: 'gameIntro'} | {name: 'scene'} diff --git a/apps/tv/src/screens/CreateRoom/CreateRoomScreen.tsx b/apps/tv/src/screens/CreateRoom/CreateRoomScreen.tsx index d78240f..0c3a88b 100644 --- a/apps/tv/src/screens/CreateRoom/CreateRoomScreen.tsx +++ b/apps/tv/src/screens/CreateRoom/CreateRoomScreen.tsx @@ -8,6 +8,7 @@ import {TVButton} from '../../components/TVButton/TVButton'; import {findMystery} from '../../data/mysteries'; import {useGame} from '../../game/GameContext'; import {useBackInterceptor} from '../../navigation/useBackInterceptor'; +import {useSettings} from '../../settings/SettingsContext'; import {px, theme} from '../../theme/theme'; import {Feedback} from '../../ui/Feedback'; import {LoadingState} from '../../ui/LoadingState'; @@ -32,14 +33,19 @@ export const CreateRoomScreen = ({navigator, selectedMysteryId}: ScreenProps) => const [refusal, setRefusal] = useState<{title: string; detail: string; key: number} | null>(null); const mystery = findMystery(selectedMysteryId); const requested = useRef(false); + const {settings, update} = useSettings(); // One room per visit. Opening the screen creates it; leaving closes it. useEffect(() => { if (!requested.current) { requested.current = true; createRoom(selectedMysteryId); + // Having opened a room, this TV knows how it works: Home shows tonight's case from now on. + if (!settings.onboarded) { + update({onboarded: true}); + } } - }, [createRoom, selectedMysteryId]); + }, [createRoom, selectedMysteryId, settings.onboarded, update]); // The server started the game (our START_GAME, or a replay): go play. const started = view.room?.status === 'playing' && state !== null; @@ -150,7 +156,7 @@ export const CreateRoomScreen = ({navigator, selectedMysteryId}: ScreenProps) => ) : ( <> - {refusal && } + {refusal && } { {/* Floats beside the buttons, so the story and numbers never move. */} {refusal && ( - + )} diff --git a/apps/tv/src/screens/Home/HomeScreen.tsx b/apps/tv/src/screens/Home/HomeScreen.tsx index 0322de1..19ead41 100644 --- a/apps/tv/src/screens/Home/HomeScreen.tsx +++ b/apps/tv/src/screens/Home/HomeScreen.tsx @@ -1,12 +1,13 @@ import React, {useState} from 'react'; import {BackHandler, Image, StyleSheet, Text, View} from 'react-native'; import {TVButton} from '../../components/TVButton/TVButton'; -import {findMystery} from '../../data/mysteries'; +import {AGE_LABEL, findMystery} from '../../data/mysteries'; import {Route} from '../../navigation/TVNavigation'; import {useBackInterceptor} from '../../navigation/useBackInterceptor'; +import {useSettings} from '../../settings/SettingsContext'; import {px, theme} from '../../theme/theme'; import {Transition} from '../../ui/Transition'; -import {homeArt} from '../../ui/keyArt'; +import {GRACE_ASPECT, graceArt, homeArt} from '../../ui/keyArt'; import {ScreenProps} from '../types'; interface MenuItem { @@ -16,27 +17,39 @@ interface MenuItem { hint: string; } -const PRIMARY: readonly MenuItem[] = [ - {key: 'createRoom', label: 'Create Room', route: {name: 'createRoom'}, hint: 'Host an investigation on this TV'}, - {key: 'joinRoom', label: 'Join Room', route: {name: 'joinRoom'}, hint: 'Enter a room code'}, -]; -const SECONDARY: readonly MenuItem[] = [ - {key: 'mysteries', label: 'Mysteries', route: {name: 'mysteries'}, hint: 'Browse the case library'}, - {key: 'settings', label: 'Settings', route: {name: 'settings'}, hint: 'Motion, sound, display and controls'}, -]; const STAGGER = theme.motion.stagger.duration; +const GRACE_HEIGHT = px(900); + +/** The three steps a first-time group needs, shown on Home until they've played. */ +export const HOW_IT_WORKS: readonly {n: string; title: string; line: string}[] = [ + {n: '1', title: 'Open a room', line: 'A QR code appears here.'}, + {n: '2', title: 'Scan to join', line: 'No app. Phone = notebook.'}, + {n: '3', title: 'Investigate', line: 'Explore, question, accuse.'}, +]; /** - * Home: the title card. Wordmark in the cinematic voice between brass rules, - * the two primary actions as a focus list (▶ marker + what will happen), - * the secondary actions as a quieter row, and tonight's case in the - * technical voice. Elements arrive in a short stagger; focus is available - * immediately. + * Home, after the "noir character" reference: Detective Grace stands on the + * left in the rain; the right column is the wordmark and a short menu led by + * Quick Start (one press: a room for tonight's case, straight to the QR code). + * First-time groups see how it works under the menu; afterwards that space + * shows tonight's case. Focus starts on Quick Start. */ export const HomeScreen = ({navigator, initialFocusKey, selectedMysteryId}: ScreenProps) => { const [confirmExit, setConfirmExit] = useState(false); - const focusKey = initialFocusKey ?? PRIMARY[0].key; + const {settings} = useSettings(); const mystery = findMystery(selectedMysteryId); + const caseTitle = mystery?.title ?? 'The Last Train'; + + const menu: readonly MenuItem[] = [ + {key: 'createRoom', label: 'Quick Start', route: {name: 'createRoom'}, hint: `${caseTitle} · phones scan to join`}, + {key: 'mysteries', label: 'Choose a Case', route: {name: 'mysteries'}, hint: 'Filter by age group and story type'}, + {key: 'joinRoom', label: 'Join a Room', route: {name: 'joinRoom'}, hint: 'Enter a room code'}, + ]; + const minor: readonly MenuItem[] = [ + {key: 'howToPlay', label: 'How to Play', route: {name: 'howToPlay'}, hint: 'Three steps, one minute'}, + {key: 'settings', label: 'Settings', route: {name: 'settings'}, hint: 'Motion, sound, display and controls'}, + ]; + const focusKey = initialFocusKey ?? menu[0]!.key; // Back on Home asks before leaving the app instead of exiting straight away. useBackInterceptor(() => { @@ -44,12 +57,14 @@ export const HomeScreen = ({navigator, initialFocusKey, selectedMysteryId}: Scre return true; }); - const item = (m: MenuItem, variant: 'menu' | 'action') => ( + const button = (m: MenuItem, variant: 'menu' | 'action', primary = false) => ( navigator.push(m.route, m.key)} preferredFocus={m.key === focusKey} @@ -60,24 +75,20 @@ export const HomeScreen = ({navigator, initialFocusKey, selectedMysteryId}: Scre return ( - - INVESTIGATION SYSTEM - - {mystery ? `CASE · ${mystery.title.toUpperCase()}` : 'NO CASE SELECTED'} + + - - - + + + A CO-OP MYSTERY FOR THE BIG SCREEN ROOM RIDDLE - - - Gather. Investigate. Unravel. - + + Gather. Investigate. Unravel. + - {confirmExit ? ( CONFIRM @@ -88,21 +99,46 @@ export const HomeScreen = ({navigator, initialFocusKey, selectedMysteryId}: Scre ) : ( - <> - {PRIMARY.map((m, i) => ( - - {item(m, 'menu')} + + {menu.map((m, i) => ( + + {button(m, 'menu', i === 0)} ))} - - {SECONDARY.map(m => item(m, 'action'))} + + {minor.map(m => button(m, 'action'))} - + )} - - - ▲ ▼ ◀ ▶ MOVE · OK SELECT · BACK RETURN + + {settings.onboarded ? ( + + TONIGHT'S CASE + {caseTitle} + {mystery && ( + + {AGE_LABEL[mystery.ageGroup]} · {mystery.minPlayers}–{mystery.maxPlayers} players · ~{mystery.estimatedMinutes} min + + )} + + ) : ( + + FIRST TIME? IT TAKES A MINUTE + + {HOW_IT_WORKS.map(s => ( + + {s.n} + + {s.title} + {s.line} + + + ))} + + + )} + ); @@ -111,67 +147,62 @@ export const HomeScreen = ({navigator, initialFocusKey, selectedMysteryId}: Scre const styles = StyleSheet.create({ screen: { flex: 1, - alignItems: 'center', - paddingTop: theme.spacing.screenY, - paddingBottom: theme.spacing.screenY - px(20), + flexDirection: 'row', paddingHorizontal: theme.spacing.screenX, }, - caseLine: { - flexDirection: 'row', - alignItems: 'center', - gap: theme.spacing.sm, + graceWrap: { + position: 'absolute', + left: px(170), + bottom: 0, }, - caseText: { - ...theme.text.label, - fontFamily: theme.fonts.technical, - color: theme.colors.textMuted, + grace: { + height: GRACE_HEIGHT, + width: GRACE_HEIGHT * GRACE_ASPECT, }, - caseDot: { - width: px(8), - height: px(8), - borderRadius: px(4), - backgroundColor: theme.colors.accent, + column: { + marginLeft: 'auto', + width: px(860), + paddingTop: theme.spacing.screenY - px(20), + paddingBottom: theme.spacing.screenY - px(20), }, brand: { - alignItems: 'center', - marginTop: theme.spacing.xxl, - }, - wordRow: { - flexDirection: 'row', - alignItems: 'center', + alignItems: 'flex-start', }, - wordRule: { - width: px(120), - height: 1, - backgroundColor: theme.colors.accentSoft, + kicker: { + ...theme.text.label, + fontFamily: theme.fonts.technical, + color: theme.colors.textMuted, }, wordmark: { ...theme.text.wordmark, color: theme.colors.textPrimary, - marginHorizontal: theme.spacing.lg, - // Letter spacing adds trailing space after the last glyph; nudge back to centre. - marginRight: theme.spacing.lg - px(20), + marginTop: theme.spacing.xs, + }, + rule: { + width: px(140), + height: px(3), + marginTop: theme.spacing.sm, + backgroundColor: theme.colors.accent, }, tagline: { ...theme.text.label, - fontSize: px(30), - letterSpacing: px(10), - marginTop: theme.spacing.md, + fontSize: px(26), + letterSpacing: px(8), + marginTop: theme.spacing.sm, color: theme.colors.accent, textTransform: 'uppercase', }, menu: { - marginTop: theme.spacing.xxl, + marginTop: theme.spacing.lg, gap: theme.spacing.xs, - alignItems: 'center', }, - secondary: { + minor: { flexDirection: 'row', gap: theme.spacing.md, - marginTop: theme.spacing.lg, + marginTop: theme.spacing.md, }, confirm: { - alignItems: 'center', + marginTop: theme.spacing.xl, }, confirmLabel: { ...theme.text.label, @@ -188,14 +219,55 @@ const styles = StyleSheet.create({ flexDirection: 'row', gap: theme.spacing.md, }, - footer: { - flex: 1, - justifyContent: 'flex-end', - alignItems: 'center', + footerCard: { + marginTop: 'auto', + paddingHorizontal: theme.spacing.md, + paddingVertical: theme.spacing.sm, + backgroundColor: theme.colors.overlay, + borderLeftWidth: px(4), + borderLeftColor: theme.colors.accent, }, - footerText: { + cardLabel: { ...theme.text.label, fontFamily: theme.fonts.technical, - color: theme.colors.textMuted, + color: theme.colors.accent, + }, + cardTitle: { + ...theme.text.h2, + color: theme.colors.textPrimary, + marginTop: px(4), + }, + cardLine: { + ...theme.text.body, + color: theme.colors.textSecondary, + }, + steps: { + flexDirection: 'row', + gap: theme.spacing.md, + marginTop: theme.spacing.sm, + }, + step: { + flex: 1, + flexDirection: 'row', + gap: px(10), + }, + stepN: { + ...theme.text.h2, + color: theme.colors.accent, + width: px(30), + }, + stepText: { + flex: 1, + }, + stepTitle: { + ...theme.text.label, + color: theme.colors.textPrimary, + letterSpacing: px(1), + }, + stepLine: { + ...theme.text.body, + fontSize: px(22), + lineHeight: px(28), + color: theme.colors.textSecondary, }, }); diff --git a/apps/tv/src/screens/HowToPlay/HowToPlayScreen.tsx b/apps/tv/src/screens/HowToPlay/HowToPlayScreen.tsx new file mode 100644 index 0000000..af85ca8 --- /dev/null +++ b/apps/tv/src/screens/HowToPlay/HowToPlayScreen.tsx @@ -0,0 +1,102 @@ +import React from 'react'; +import {Image, StyleSheet, Text, View} from 'react-native'; +import {TVButton} from '../../components/TVButton/TVButton'; +import {useSettings} from '../../settings/SettingsContext'; +import {px, theme} from '../../theme/theme'; +import {Transition} from '../../ui/Transition'; +import {homeArt} from '../../ui/keyArt'; +import {HOW_IT_WORKS} from '../Home/HomeScreen'; +import {ScreenProps} from '../types'; + +const DETAIL: readonly string[] = [ + 'The TV shows the room code and a QR code. Up to six investigators can join.', + 'Scan the QR code, type a name, and you are in. Clues, puzzles and hints live on your phone; the TV shows everyone what happened.', + 'Move with the remote to inspect the scene. Ask suspects anything from your phone: they answer on the TV, but only with what you have found.', +]; + +/** + * How to Play: the three steps in full, then straight into a game. Reaching + * the end marks this TV as onboarded, so Home stops showing the steps. + */ +export const HowToPlayScreen = ({navigator}: ScreenProps) => { + const {update} = useSettings(); + const done = (then: () => void) => { + update({onboarded: true}); + then(); + }; + return ( + + + + HOW TO PLAY + Three steps. About a minute. + + {HOW_IT_WORKS.map((s, i) => ( + + {s.n} + {s.title} + {DETAIL[i]} + + ))} + + + done(() => navigator.replace({name: 'createRoom'}))} preferredFocus testID="howto-start" /> + done(() => navigator.back())} testID="howto-back" /> + + + ); +}; + +const styles = StyleSheet.create({ + screen: { + flex: 1, + paddingHorizontal: theme.spacing.screenX, + paddingTop: theme.spacing.screenY, + }, + scrim: { + ...StyleSheet.absoluteFillObject, + backgroundColor: theme.colors.overlay, + }, + label: { + ...theme.text.label, + fontFamily: theme.fonts.technical, + color: theme.colors.accent, + }, + title: { + ...theme.text.h1, + color: theme.colors.textPrimary, + marginTop: theme.spacing.xs, + }, + row: { + flexDirection: 'row', + gap: theme.spacing.lg, + marginTop: theme.spacing.xl, + }, + card: { + flex: 1, + padding: theme.spacing.lg, + backgroundColor: theme.colors.surface, + borderTopWidth: px(4), + borderTopColor: theme.colors.accent, + minHeight: px(400), + }, + n: { + ...theme.text.display, + color: theme.colors.accent, + }, + cardTitle: { + ...theme.text.h2, + color: theme.colors.textPrimary, + marginTop: theme.spacing.sm, + }, + cardLine: { + ...theme.text.body, + color: theme.colors.textSecondary, + marginTop: theme.spacing.sm, + }, + actions: { + flexDirection: 'row', + gap: theme.spacing.md, + marginTop: theme.spacing.xl, + }, +}); diff --git a/apps/tv/src/screens/Mysteries/MysteriesScreen.tsx b/apps/tv/src/screens/Mysteries/MysteriesScreen.tsx index 9209fae..3804650 100644 --- a/apps/tv/src/screens/Mysteries/MysteriesScreen.tsx +++ b/apps/tv/src/screens/Mysteries/MysteriesScreen.tsx @@ -1,55 +1,104 @@ -import React from 'react'; +import React, {useMemo, useState} from 'react'; import {Image, StyleSheet, Text, View} from 'react-native'; -import {MysteryCard} from '../../components/MysteryCard/MysteryCard'; import {ScreenHeader} from '../../components/ScreenHeader/ScreenHeader'; import {TVButton} from '../../components/TVButton/TVButton'; -import {mysteries} from '../../data/mysteries'; +import { + AGE_LABEL, + AgeFilter, + TYPE_LABEL, + TypeFilter, + TVMystery, + ageFilters, + filterMysteries, + findMystery, + mysteries, + typeFilters, +} from '../../data/mysteries'; import {px, theme} from '../../theme/theme'; import {Transition} from '../../ui/Transition'; -import {libraryArt} from '../../ui/keyArt'; +import {cardArtFor, libraryArt} from '../../ui/keyArt'; import {ScreenProps} from '../types'; -export const MysteriesScreen = ({ - navigator, - initialFocusKey, - selectedMysteryId, -}: ScreenProps) => { - const focusKey = initialFocusKey ?? `mystery-${mysteries[0].id}`; +const ageName = (a: AgeFilter): string => (a === 'any' ? 'Any age' : AGE_LABEL[a]); +const typeName = (t: TypeFilter): string => (t === 'any' ? 'Any story' : TYPE_LABEL[t]); + +/** + * The case library, after the "cold cases" reference: two filter rows (age + * group, story type) above a list of case files; the focused case shows its + * painting, synopsis and facts on the right. Filters apply as soon as a chip + * is pressed; Select on a case opens it. + */ +export const MysteriesScreen = ({navigator, initialFocusKey, selectedMysteryId}: ScreenProps) => { + const [age, setAge] = useState('any'); + const [type, setType] = useState('any'); + const shown = useMemo(() => filterMysteries(mysteries, age, type), [age, type]); + const [focusedId, setFocusedId] = useState(selectedMysteryId ?? mysteries[0]!.id); + const preview: TVMystery | undefined = shown.find(m => m.id === focusedId) ?? shown[0] ?? findMystery(focusedId); + const focusKey = initialFocusKey ?? `mystery-${selectedMysteryId ?? shown[0]?.id}`; + + const chip = (key: string, label: string, on: boolean, press: () => void) => ( + + ); return ( - + - - {mysteries.map((mystery, index) => { - const key = `mystery-${mystery.id}`; - // Cards deal in one after another, like case files laid on a desk. - return ( - - - navigator.push({name: 'mysteryDetails', mysteryId: mystery.id}, key) - } - testID={key} - /> - - ); - })} + + AGE + {ageFilters().map(a => chip(`filter-age-${a}`, ageName(a), age === a, () => setAge(a)))} + + + STORY + {typeFilters().map(t => chip(`filter-type-${t}`, typeName(t), type === t, () => setType(t)))} - - navigator.back()} - preferredFocus={focusKey === 'back'} - testID="mysteries-back" - /> - ◀ ▶ browse · OK details + + + {shown.length === 0 && ( + + No case matches both filters. Try another story type. + + )} + {shown.map((m, i) => { + const key = `mystery-${m.id}`; + return ( + + setFocusedId(m.id)} + onPress={() => navigator.push({name: 'mysteryDetails', mysteryId: m.id}, key)} + testID={key} + /> + + ); + })} + + navigator.back()} testID="mysteries-back" /> + + + + {preview && ( + + {cardArtFor(preview.id) && } + + {preview.genre.toUpperCase()} · {preview.shortDescription.toUpperCase()} + {preview.title} + + {preview.synopsis} + + + {AGE_LABEL[preview.ageGroup]} · {preview.minPlayers}–{preview.maxPlayers} players · ~{preview.estimatedMinutes} min + + + + )} ); @@ -59,29 +108,71 @@ const styles = StyleSheet.create({ screen: { flex: 1, paddingHorizontal: theme.spacing.screenX, - paddingTop: theme.spacing.screenY, - paddingBottom: theme.spacing.screenY, + paddingTop: theme.spacing.screenY - px(24), }, - row: { - flex: 1, + filters: { flexDirection: 'row', alignItems: 'center', - justifyContent: 'space-between', + gap: theme.spacing.sm, + marginTop: theme.spacing.xs, }, - footer: { + filterLabel: { + ...theme.text.label, + fontFamily: theme.fonts.technical, + color: theme.colors.textMuted, + width: px(110), + }, + body: { + flex: 1, flexDirection: 'row', - alignItems: 'center', gap: theme.spacing.lg, + marginTop: theme.spacing.md, + }, + list: { + width: px(900), + gap: theme.spacing.xs, }, - hint: { + empty: { + ...theme.text.body, + color: theme.colors.textSecondary, + }, + back: { + flexDirection: 'row', + marginTop: theme.spacing.sm, + }, + preview: { + flex: 1, + alignSelf: 'flex-start', + backgroundColor: theme.colors.overlay, + borderTopWidth: px(3), + borderTopColor: theme.colors.accent, + }, + previewArt: { + width: '100%', + height: px(260), + }, + previewText: { + padding: theme.spacing.md, + }, + previewKicker: { ...theme.text.label, fontFamily: theme.fonts.technical, - letterSpacing: px(2), - color: theme.colors.textMuted, + color: theme.colors.accent, + }, + previewTitle: { + ...theme.text.h1, + color: theme.colors.textPrimary, + marginTop: px(4), + }, + previewBody: { + ...theme.text.body, + color: theme.colors.textSecondary, + marginTop: theme.spacing.sm, + }, + previewFacts: { + ...theme.text.label, + color: theme.colors.textPrimary, + marginTop: theme.spacing.sm, + letterSpacing: px(1), }, }); - -const WORDS = ['No', 'One', 'Two', 'Three', 'Four', 'Five', 'Six', 'Seven', 'Eight', 'Nine', 'Ten']; -function countWord(n: number): string { - return WORDS[n] ?? String(n); -} diff --git a/apps/tv/src/settings/settings.ts b/apps/tv/src/settings/settings.ts index 0b06e35..3833d9d 100644 --- a/apps/tv/src/settings/settings.ts +++ b/apps/tv/src/settings/settings.ts @@ -24,6 +24,8 @@ export interface Settings { voice: Volume; muted: boolean; subtitles: boolean; + /** Has seen how the game works (Home shows the three steps until then). */ + onboarded: boolean; } export const DEFAULT_SETTINGS: Settings = { @@ -35,6 +37,7 @@ export const DEFAULT_SETTINGS: Settings = { voice: 100, muted: false, subtitles: true, + onboarded: false, }; const isVolume = (v: unknown): v is Volume => typeof v === 'number' && (VOLUME_STEPS as readonly number[]).includes(v); @@ -73,6 +76,9 @@ export function parseSettings(raw: string | null | undefined): Partial if (typeof d.muted === 'boolean') { out.muted = d.muted; } + if (typeof d.onboarded === 'boolean') { + out.onboarded = d.onboarded; + } if (typeof d.subtitles === 'boolean') { out.subtitles = d.subtitles; } diff --git a/apps/tv/src/ui/Feedback.tsx b/apps/tv/src/ui/Feedback.tsx index 0d092ed..3695bdb 100644 --- a/apps/tv/src/ui/Feedback.tsx +++ b/apps/tv/src/ui/Feedback.tsx @@ -24,16 +24,25 @@ export interface FeedbackProps { /** Changing this key replays the animation (e.g. per attempt). */ playKey?: string | number; layout?: 'banner' | 'stack'; + /** + * Fades out after this long (ms) so notices never linger on screen. 0 keeps + * it (for refusals the player must act on). Default: 5 s, longer for long text. + */ + autoHideMs?: number; testID?: string; } +export function feedbackHoldMs(title: string, detail?: string): number { + return Math.min(9000, 4000 + (title.length + (detail?.length ?? 0)) * 35); +} + /** * Answer and hint feedback. Every kind has a glyph, a word and a colour, so * the result never depends on colour alone. Success rises in; failure gives a * short horizontal shake ("that doesn't fit"); hints unfold. Each replays when * `playKey` changes, so a second wrong answer still registers. */ -export const Feedback = ({kind, title, detail, amount, playKey, layout = 'banner', testID}: FeedbackProps) => { +export const Feedback = ({kind, title, detail, amount, playKey, layout = 'banner', autoHideMs, testID}: FeedbackProps) => { const reduced = useReducedMotion(); const [progress] = useState(() => new Animated.Value(0)); const [shake] = useState(() => new Animated.Value(0)); @@ -46,9 +55,12 @@ export const Feedback = ({kind, title, detail, amount, playKey, layout = 'banner kind === 'failure' && !reduced ? Animated.sequence([1, -1, 0.6, -0.4, 0].map(v => timing(shake, v, 'press', false))) : null; - const all = shakes ? Animated.parallel([enter, shakes]) : enter; + const shown = shakes ? Animated.parallel([enter, shakes]) : enter; + const hold = autoHideMs ?? feedbackHoldMs(title, detail); + const all = hold > 0 ? Animated.sequence([shown, Animated.delay(hold), timing(progress, 0, 'modalExit', reduced)]) : shown; all.start(); return () => all.stop(); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [kind, playKey, progress, shake, reduced]); const look = LOOK[kind]; diff --git a/apps/tv/src/ui/keyArt.ts b/apps/tv/src/ui/keyArt.ts index 0819794..1de08e0 100644 --- a/apps/tv/src/ui/keyArt.ts +++ b/apps/tv/src/ui/keyArt.ts @@ -6,6 +6,9 @@ import type {ImageSourcePropType} from 'react-native'; */ export const homeArt: ImageSourcePropType = require('../assets/ui/home.jpg'); export const libraryArt: ImageSourcePropType = require('../assets/ui/library.jpg'); +/** Detective Grace, full figure (900 px tall, transparent). */ +export const graceArt: ImageSourcePropType = require('../assets/ui/grace.png'); +export const GRACE_ASPECT = 0.5; const cards: Readonly> = { 'the-last-train': require('../assets/ui/cards/the-last-train.jpg'), diff --git a/apps/tv/test/App.spec.tsx b/apps/tv/test/App.spec.tsx index 129697f..8a6e222 100644 --- a/apps/tv/test/App.spec.tsx +++ b/apps/tv/test/App.spec.tsx @@ -18,13 +18,37 @@ afterEach(() => { }); describe('Room Riddle TV shell', () => { - it('starts on Home with the four menu items', () => { + it('starts on Home: Quick Start first, and a first-time TV sees how it works', () => { const screen = render(); expect(screen.getByText('ROOM RIDDLE')).toBeTruthy(); expect(screen.getByText('Gather. Investigate. Unravel.')).toBeTruthy(); - for (const id of ['createRoom', 'joinRoom', 'mysteries', 'settings']) { + expect(screen.getByLabelText(/Quick Start/i)).toBeTruthy(); + for (const id of ['createRoom', 'mysteries', 'joinRoom', 'howToPlay', 'settings']) { expect(screen.getByTestId(`home-${id}`)).toBeTruthy(); } + expect(screen.getByTestId('home-how-it-works')).toBeTruthy(); + }); + + it('How to Play leads straight into a room, and afterwards Home shows tonight\'s case', () => { + const screen = render(); + fireEvent.press(screen.getByTestId('home-howToPlay')); + expect(screen.getByText('Three steps. About a minute.')).toBeTruthy(); + fireEvent.press(screen.getByTestId('howto-back')); + expect(screen.getByTestId('home-tonight')).toBeTruthy(); + expect(screen.queryByTestId('home-how-it-works')).toBeNull(); + }); + + it('the library filters by age group and story type', () => { + const screen = render(); + fireEvent.press(screen.getByTestId('home-mysteries')); + fireEvent.press(screen.getByTestId('filter-age-family')); + expect(screen.getByTestId('mystery-the-forgotten-temple')).toBeTruthy(); + expect(screen.getByTestId('mystery-the-yakshas-questions')).toBeTruthy(); + expect(screen.queryByTestId('mystery-the-last-train')).toBeNull(); + fireEvent.press(screen.getByTestId('filter-type-tech')); + expect(screen.getByTestId('mysteries-empty')).toBeTruthy(); + fireEvent.press(screen.getByTestId('filter-age-any')); + expect(screen.getByTestId('mystery-signal-lost')).toBeTruthy(); }); it('opens Create Room: the server room code, live players, and asks before closing', () => { @@ -76,7 +100,7 @@ describe('Room Riddle TV shell', () => { it('lists all mysteries and opens The Last Train details', () => { const screen = render(); fireEvent.press(screen.getByTestId('home-mysteries')); - expect(screen.getByText('CHOOSE YOUR MYSTERY')).toBeTruthy(); + expect(screen.getByText('CHOOSE A CASE')).toBeTruthy(); for (const mystery of mysteries) { expect(screen.getByTestId(`mystery-${mystery.id}`)).toBeTruthy(); } @@ -87,7 +111,7 @@ describe('Room Riddle TV shell', () => { expect(screen.getByText('2–6')).toBeTruthy(); fireEvent.press(screen.getByTestId('details-back')); - expect(screen.getByText('CHOOSE YOUR MYSTERY')).toBeTruthy(); + expect(screen.getByText('CHOOSE A CASE')).toBeTruthy(); fireEvent.press(screen.getByTestId('mysteries-back')); expect(screen.getByTestId('home-mysteries')).toBeTruthy(); }); @@ -144,7 +168,7 @@ describe('remote Back button', () => { fireEvent.press(screen.getByTestId('home-mysteries')); fireEvent.press(screen.getByTestId('mystery-the-last-train')); expect(back()).toBe(true); - expect(screen.getByText('CHOOSE YOUR MYSTERY')).toBeTruthy(); + expect(screen.getByText('CHOOSE A CASE')).toBeTruthy(); expect(back()).toBe(true); expect(screen.getByTestId('home-mysteries')).toBeTruthy(); }); diff --git a/apps/tv/test/AudioJourney.spec.tsx b/apps/tv/test/AudioJourney.spec.tsx index 6467025..1541784 100644 --- a/apps/tv/test/AudioJourney.spec.tsx +++ b/apps/tv/test/AudioJourney.spec.tsx @@ -505,7 +505,7 @@ describe('lifecycle (Step 4)', () => { await run(100); press(screen, 'scene-leave-risky'); await run(3000); - expect(screen.getByText('CHOOSE YOUR MYSTERY')).toBeTruthy(); + expect(screen.getByText('CHOOSE A CASE')).toBeTruthy(); // Every one-shot ends as the platform would (the fake player never ends by itself). for (const p of players.filter(x => x.live && x.src && !x.loop)) { p.emit('ended'); diff --git a/apps/tv/test/Game.spec.tsx b/apps/tv/test/Game.spec.tsx index 521c95f..606653d 100644 --- a/apps/tv/test/Game.spec.tsx +++ b/apps/tv/test/Game.spec.tsx @@ -203,7 +203,7 @@ describe('The Last Train on the TV', () => { pressBack(); }); press(screen, 'scene-leave-risky'); - expect(screen.getByText('CHOOSE YOUR MYSTERY')).toBeTruthy(); + expect(screen.getByText('CHOOSE A CASE')).toBeTruthy(); }); it('Play Again restarts the mystery from its intro', () => { diff --git a/apps/tv/test/LibraryFilter.spec.ts b/apps/tv/test/LibraryFilter.spec.ts new file mode 100644 index 0000000..e20492d --- /dev/null +++ b/apps/tv/test/LibraryFilter.spec.ts @@ -0,0 +1,21 @@ +import {ageFilters, filterMysteries, mysteries, typeFilters} from '../src/data/mysteries'; + +const ids = (list: {id: string}[]) => list.map(m => m.id).sort(); + +describe('library filters', () => { + it('offers only the groups and types the library has', () => { + expect(ageFilters()).toEqual(['any', 'all', 'family', 'teens']); + expect(typeFilters()).toEqual(['any', 'thriller', 'gothic', 'tech', 'adventure', 'riddle']); + }); + + it('an age filter means "suitable for": younger groups get the cases written for them and for everyone', () => { + expect(ids(filterMysteries(mysteries, 'all', 'any'))).toEqual(['the-yakshas-questions']); + expect(ids(filterMysteries(mysteries, 'family', 'any'))).toEqual(['the-forgotten-temple', 'the-yakshas-questions']); + expect(filterMysteries(mysteries, 'teens', 'any')).toHaveLength(mysteries.length); + }); + + it('age and type combine; an impossible combination is empty, not an error', () => { + expect(ids(filterMysteries(mysteries, 'any', 'thriller'))).toEqual(['the-last-train']); + expect(filterMysteries(mysteries, 'all', 'tech')).toEqual([]); + }); +}); diff --git a/packages/game-engine/src/test-fixtures.ts b/packages/game-engine/src/test-fixtures.ts index 8c0cf42..f252622 100644 --- a/packages/game-engine/src/test-fixtures.ts +++ b/packages/game-engine/src/test-fixtures.ts @@ -18,6 +18,8 @@ export const fixtureMystery: MysteryDefinition = { minPlayers: 1, maxPlayers: 4, availability: 'playable', + ageGroup: 'teens', + storyType: 'thriller', }, intro: {title: 'Intro', narration: ['Welcome.']}, startSceneId: 'hall', diff --git a/packages/mystery-data/src/signal-lost/index.ts b/packages/mystery-data/src/signal-lost/index.ts index 9cde8c1..6c6eefe 100644 --- a/packages/mystery-data/src/signal-lost/index.ts +++ b/packages/mystery-data/src/signal-lost/index.ts @@ -10,6 +10,8 @@ export const signalLost: MysterySummary = { title: 'Signal Lost', tagline: 'The station sent one warning. Then silence.', genre: 'Tech', + ageGroup: 'teens', + storyType: 'tech', synopsis: 'At 10:16 PM the remote Northpoint station sends one short transmission and goes silent. ' + 'The equipment is still running, the operator is gone, and the log makes no sense.', diff --git a/packages/mystery-data/src/the-forgotten-temple/index.ts b/packages/mystery-data/src/the-forgotten-temple/index.ts index d638e5f..5dea20a 100644 --- a/packages/mystery-data/src/the-forgotten-temple/index.ts +++ b/packages/mystery-data/src/the-forgotten-temple/index.ts @@ -10,6 +10,8 @@ export const theForgottenTemple: MysterySummary = { title: 'The Forgotten Temple', tagline: 'The builders left a path. Can you read it?', genre: 'Family', + ageGroup: 'family', + storyType: 'adventure', synopsis: 'An old temple is being prepared for an exhibition. A carved panel of four symbols ' + 'hints at a hidden archive. Follow the builders\' clues to find it, without damaging a thing.', diff --git a/packages/mystery-data/src/the-hollow-manor/index.ts b/packages/mystery-data/src/the-hollow-manor/index.ts index 1c69dcd..513e41a 100644 --- a/packages/mystery-data/src/the-hollow-manor/index.ts +++ b/packages/mystery-data/src/the-hollow-manor/index.ts @@ -11,6 +11,8 @@ export const theHollowManor: MysterySummary = { title: 'The Hollow Manor', tagline: 'No one left the room. So where did he go?', genre: 'Detective', + ageGroup: 'teens', + storyType: 'gothic', synopsis: 'During a storm, historian Edmund Vale vanishes from his study, locked from the inside. ' + 'A family document is missing too. Find out how he left, and why.', diff --git a/packages/mystery-data/src/the-last-train/mystery.ts b/packages/mystery-data/src/the-last-train/mystery.ts index b761401..b590e92 100644 --- a/packages/mystery-data/src/the-last-train/mystery.ts +++ b/packages/mystery-data/src/the-last-train/mystery.ts @@ -9,6 +9,8 @@ export const theLastTrain: MysterySummary = { title: 'The Last Train', tagline: 'Can you uncover the truth before midnight?', genre: 'Thriller', + ageGroup: 'teens', + storyType: 'thriller', synopsis: 'A stormy night. The conductor has vanished, the last train north is held at the ' + 'platform, and something happened at 11:47 PM. Find out what before it leaves at midnight.', diff --git a/packages/mystery-data/src/the-yakshas-questions/index.ts b/packages/mystery-data/src/the-yakshas-questions/index.ts index e535e39..709381b 100644 --- a/packages/mystery-data/src/the-yakshas-questions/index.ts +++ b/packages/mystery-data/src/the-yakshas-questions/index.ts @@ -11,6 +11,8 @@ export const theYakshasQuestions: MysterySummary = { title: "The Yaksha's Three Questions", tagline: 'A guardian, a lake, and three questions.', genre: 'All ages', + ageGroup: 'all', + storyType: 'riddle', synopsis: 'At sunrise, travellers reach a quiet forest lake. A calm guardian, the Yaksha, ' + 'asks three questions. Observe, think, and answer together.', diff --git a/packages/shared-types/src/mystery.ts b/packages/shared-types/src/mystery.ts index 76f3003..f7ac948 100644 --- a/packages/shared-types/src/mystery.ts +++ b/packages/shared-types/src/mystery.ts @@ -27,8 +27,16 @@ export interface MysterySummary { /** Optional large background motif, e.g. "11:47". */ motif?: string; availability: MysteryAvailability; + /** Library filter: who it is written for. */ + ageGroup: AgeGroup; + /** Library filter: the kind of story. */ + storyType: StoryType; } +/** `all` = all ages, `family` = ages 8+ with grown-ups, `teens` = teens & adults. */ +export type AgeGroup = 'all' | 'family' | 'teens'; +export type StoryType = 'thriller' | 'gothic' | 'tech' | 'adventure' | 'riddle'; + /** Something that must be true before a clue or puzzle becomes available. */ export interface Requirement { /** Every one of these clues must have been discovered. */