Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/tv/scripts/art/prepare_ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')

Expand Down
3 changes: 3 additions & 0 deletions apps/tv/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -234,6 +235,8 @@ function renderScreen(route: Route, props: ScreenProps) {
return <MysteryDetailsScreen {...props} route={route} />;
case 'settings':
return <SettingsScreen {...props} />;
case 'howToPlay':
return <HowToPlayScreen {...props} />;
case 'audioDiagnostics':
return <AudioDiagnosticsScreen {...props} />;
case 'gameIntro':
Expand Down
Binary file added apps/tv/src/assets/ui/grace.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
21 changes: 19 additions & 2 deletions apps/tv/src/components/TVButton/TVButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -48,6 +52,8 @@ export const TVButton = ({
disabled,
accessibilityLabel,
accessibilityHint,
width,
compact,
testID,
}: TVButtonProps) => {
const [focused, setFocused] = useState(false);
Expand Down Expand Up @@ -89,19 +95,21 @@ export const TVButton = ({
accessibilityHint={accessibilityHint}
accessibilityState={{selected: focused, disabled: !!disabled}}
testID={testID}>
<Animated.View style={{transform: [{scale}]}}>
<Animated.View style={{transform: [{scale}], alignSelf: 'flex-start'}}>
<View
style={[
styles.base,
isMenu ? styles.menu : styles.action,
isMenu ? (fill ? styles.menuFocused : styles.menuIdle) : fill ? (tone === 'danger' ? styles.actionDanger : styles.actionFocused) : tone === 'primary' ? styles.actionPrimary : styles.actionIdle,
disabled && styles.disabled,
isMenu && width !== undefined && {width},
!isMenu && compact && styles.compact,
]}>
{isMenu && (
<Animated.Text style={[styles.marker, {opacity: progress, transform: [{translateX: markerShift}]}]}>▶</Animated.Text>
)}
<View style={isMenu ? styles.menuText : undefined}>
<Text style={[theme.text.button, isMenu ? styles.menuLabel : styles.actionLabel, {color: labelColor}]} numberOfLines={1}>
<Text style={[theme.text.button, isMenu ? styles.menuLabel : styles.actionLabel, compact && styles.compactLabel, {color: labelColor}]} numberOfLines={1}>
{label.toUpperCase()}
</Text>
{caption !== undefined && isMenu && (
Expand Down Expand Up @@ -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,
Expand Down
34 changes: 33 additions & 1 deletion apps/tv/src/data/mysteries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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 {
Expand All @@ -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,
};
}

Expand All @@ -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<Record<AgeGroup, string>> = {all: 'All ages', family: 'Family 8+', teens: 'Teens & adults'};
export const TYPE_LABEL: Readonly<Record<StoryType, string>> = {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));
}
1 change: 1 addition & 0 deletions apps/tv/src/navigation/TVNavigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export type Route =
| {name: 'mysteries'}
| {name: 'mysteryDetails'; mysteryId: string}
| {name: 'settings'}
| {name: 'howToPlay'}
| {name: 'audioDiagnostics'}
| {name: 'gameIntro'}
| {name: 'scene'}
Expand Down
10 changes: 8 additions & 2 deletions apps/tv/src/screens/CreateRoom/CreateRoomScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -150,7 +156,7 @@ export const CreateRoomScreen = ({navigator, selectedMysteryId}: ScreenProps) =>
) : (
<>
<View style={styles.feedbackSlot} testID="room-notice">
{refusal && <Feedback kind="failure" title={refusal.title} detail={refusal.detail} playKey={refusal.key} />}
{refusal && <Feedback kind="failure" title={refusal.title} detail={refusal.detail} playKey={refusal.key} autoHideMs={0} />}
</View>
<View style={styles.actionRow}>
<TVButton
Expand Down
2 changes: 1 addition & 1 deletion apps/tv/src/screens/Ending/EndingScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ export const EndingScreen = ({navigator}: ScreenProps) => {
{/* Floats beside the buttons, so the story and numbers never move. */}
{refusal && (
<View style={styles.refusal}>
<Feedback kind="failure" title={refusal.title} detail={refusal.detail} playKey={refusal.key} />
<Feedback kind="failure" title={refusal.title} detail={refusal.detail} playKey={refusal.key} autoHideMs={0} />
</View>
)}
<TVButton variant="action" tone="primary" label="Play Again" onPress={playAgain} preferredFocus testID="ending-again" />
Expand Down
Loading
Loading