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. */