feat(app): add web electron toolkit support

This commit is contained in:
2026-06-10 15:01:09 -05:00
parent e4a4aeb100
commit fa75ce6026
52 changed files with 20438 additions and 76 deletions
+11
View File
@@ -0,0 +1,11 @@
import { MenuButton } from './MenuButton';
type BackHomeButtonProps = Readonly<{
onPress: () => void;
}>;
export function BackHomeButton({ onPress }: BackHomeButtonProps) {
return (
<MenuButton label="Home" onPress={onPress} testID="back-home-button" />
);
}
+77
View File
@@ -0,0 +1,77 @@
import type { ReactNode } from 'react';
import { StyleSheet, View, useWindowDimensions } from 'react-native';
import { getGameResolutionById } from '../settings/gameSettings';
import { useGameSettingsStore } from '../state';
type GameViewportProps = Readonly<{
children: ReactNode;
}>;
export function GameViewport({ children }: GameViewportProps) {
const resolutionId = useGameSettingsStore(state => state.resolutionId);
const resolution = getGameResolutionById(resolutionId);
const windowDimensions = useWindowDimensions();
if ('unbounded' in resolution) {
return (
<View style={styles.stage}>
<View style={styles.unboundedSurface}>{children}</View>
</View>
);
}
const scale = Math.min(
windowDimensions.width / resolution.width,
windowDimensions.height / resolution.height,
1,
);
return (
<View style={styles.stage}>
<View
style={[
styles.viewport,
{
height: resolution.height * scale,
width: resolution.width * scale,
},
]}>
<View
style={[
styles.gameSurface,
{
height: resolution.height,
transform: [{ scale }],
width: resolution.width,
},
]}>
{children}
</View>
</View>
</View>
);
}
const styles = StyleSheet.create({
gameSurface: {
backgroundColor: '#111315',
overflow: 'hidden',
},
stage: {
alignItems: 'center',
backgroundColor: '#050607',
flex: 1,
justifyContent: 'center',
},
viewport: {
backgroundColor: '#111315',
overflow: 'hidden',
},
unboundedSurface: {
alignSelf: 'stretch',
backgroundColor: '#111315',
flex: 1,
overflow: 'hidden',
},
});
+120
View File
@@ -0,0 +1,120 @@
import { Pressable, StyleSheet, Text, View } from 'react-native';
type MenuButtonTone = 'default' | 'toolkit';
type MenuButtonProps = Readonly<{
disabled?: boolean;
icon?: string;
label: string;
onPress: () => void;
testID?: string;
tone?: MenuButtonTone;
}>;
export function MenuButton({
disabled = false,
icon,
label,
onPress,
testID,
tone = 'default',
}: MenuButtonProps) {
const isToolkit = tone === 'toolkit';
const pressedStyle = isToolkit
? styles.toolkitButtonPressed
: styles.buttonPressed;
return (
<Pressable
accessibilityState={{ disabled }}
accessibilityRole="button"
disabled={disabled}
onPress={onPress}
style={({ pressed }) => [
styles.button,
isToolkit ? styles.toolkitButton : null,
disabled ? styles.buttonDisabled : null,
pressed ? pressedStyle : null,
]}
testID={testID}>
<View style={styles.content}>
{icon == null ? null : (
<Text
style={[
styles.icon,
isToolkit ? styles.toolkitText : null,
disabled ? styles.textDisabled : null,
]}
testID={testID == null ? undefined : `${testID}-icon`}>
{icon}
</Text>
)}
<Text
style={[
styles.label,
isToolkit ? styles.toolkitText : null,
disabled ? styles.textDisabled : null,
]}>
{label}
</Text>
</View>
</Pressable>
);
}
const styles = StyleSheet.create({
button: {
alignItems: 'center',
backgroundColor: '#24272b',
borderColor: '#4d4432',
borderRadius: 8,
borderWidth: 1,
minHeight: 42,
justifyContent: 'center',
paddingHorizontal: 14,
paddingVertical: 9,
width: '100%',
},
buttonPressed: {
backgroundColor: '#33302a',
},
buttonDisabled: {
backgroundColor: '#1a1c1f',
borderColor: '#2b2d30',
opacity: 0.55,
},
content: {
alignItems: 'center',
flexDirection: 'row',
gap: 8,
justifyContent: 'center',
},
icon: {
color: '#d6b25e',
fontSize: 13,
fontWeight: '800',
letterSpacing: 0,
lineHeight: 16,
textAlign: 'center',
},
label: {
color: '#f6f1e7',
fontSize: 14,
fontWeight: '700',
letterSpacing: 0,
textAlign: 'center',
},
toolkitButton: {
backgroundColor: '#3a2918',
borderColor: '#d6b25e',
},
toolkitButtonPressed: {
backgroundColor: '#4a351f',
},
toolkitText: {
color: '#f5d98d',
},
textDisabled: {
color: '#80796c',
},
});
+79
View File
@@ -0,0 +1,79 @@
import type { ReactNode } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
type ScreenLayoutProps = Readonly<{
children: ReactNode;
eyebrow?: string;
title: string;
}>;
export function ScreenLayout({
children,
eyebrow = 'Bone Lord Bob',
title,
}: ScreenLayoutProps) {
const safeAreaInsets = useSafeAreaInsets();
return (
<View
style={[
styles.container,
{
paddingBottom: safeAreaInsets.bottom + 18,
paddingLeft: safeAreaInsets.left + 22,
paddingRight: safeAreaInsets.right + 22,
paddingTop: safeAreaInsets.top + 18,
},
]}>
<View style={styles.content}>
<View style={styles.header}>
<Text style={styles.eyebrow}>{eyebrow}</Text>
<Text style={styles.title}>{title}</Text>
</View>
<View style={styles.body}>{children}</View>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#111315',
},
content: {
alignItems: 'center',
flex: 1,
flexDirection: 'row',
gap: 20,
justifyContent: 'space-between',
},
header: {
alignItems: 'flex-start',
flex: 1,
minWidth: 0,
},
eyebrow: {
color: '#d6b25e',
fontSize: 12,
fontWeight: '700',
letterSpacing: 0,
marginBottom: 12,
textAlign: 'left',
textTransform: 'uppercase',
},
title: {
color: '#f6f1e7',
fontSize: 30,
fontWeight: '800',
letterSpacing: 0,
lineHeight: 36,
textAlign: 'left',
},
body: {
alignSelf: 'center',
gap: 12,
width: 360,
},
});
+80
View File
@@ -0,0 +1,80 @@
import { useEffect } from 'react';
import { io, type Socket } from 'socket.io-client';
import { useToolkitSocketStore } from '../state';
type NodeProcess = {
env?: Partial<Record<string, string>>;
};
const defaultToolkitSocketUrl = 'http://127.0.0.1:5174';
let toolkitSocket: Socket | null = null;
function getToolkitSocketUrl() {
const maybeProcess =
typeof process === 'undefined' ? undefined : (process as NodeProcess);
return maybeProcess?.env?.TOOLKIT_SOCKET_URL ?? defaultToolkitSocketUrl;
}
function getNodeEnvironment() {
const maybeProcess =
typeof process === 'undefined' ? undefined : (process as NodeProcess);
return maybeProcess?.env?.NODE_ENV;
}
export function useToolkitSocketConnection() {
const setConnected = useToolkitSocketStore(state => state.setConnected);
const setConnecting = useToolkitSocketStore(state => state.setConnecting);
const setDisconnected = useToolkitSocketStore(state => state.setDisconnected);
useEffect(() => {
if (getNodeEnvironment() === 'test') {
return undefined;
}
if (toolkitSocket == null) {
setConnecting();
toolkitSocket = io(getToolkitSocketUrl(), {
reconnection: true,
reconnectionAttempts: Number.POSITIVE_INFINITY,
reconnectionDelay: 750,
transports: ['websocket'],
});
}
const activeSocket = toolkitSocket;
function handleConnect() {
setConnected(activeSocket.id ?? 'unknown');
}
function handleConnectionAttempt() {
setConnecting();
}
function handleDisconnect() {
setDisconnected();
}
activeSocket.on('connect', handleConnect);
activeSocket.on('disconnect', handleDisconnect);
activeSocket.io.on('reconnect_attempt', handleConnectionAttempt);
if (activeSocket.connected) {
handleConnect();
} else {
setConnecting();
activeSocket.connect();
}
return () => {
activeSocket.off('connect', handleConnect);
activeSocket.off('disconnect', handleDisconnect);
activeSocket.io.off('reconnect_attempt', handleConnectionAttempt);
};
}, [setConnected, setConnecting, setDisconnected]);
}
+133
View File
@@ -0,0 +1,133 @@
import { useCallback, useEffect, useRef } from 'react';
import { Animated, StyleSheet } from 'react-native';
import { useToolkitSocketConnection } from '../hooks/useToolkitSocketConnection';
import { HomeScreen } from '../screens/HomeScreen';
import { PageScreen } from '../screens/PageScreen';
import { PoliciesScreen } from '../screens/PoliciesScreen';
import { SettingsScreen } from '../screens/SettingsScreen';
import { ToolkitScreen } from '../screens/ToolkitScreen';
import { useNavigationStore } from '../state';
import { toolkitRoutes, type RouteName } from './routes';
const fadeOutDurationMs = process.env.NODE_ENV === 'test' ? 0 : 140;
const fadeInDurationMs = process.env.NODE_ENV === 'test' ? 0 : 180;
export function AppNavigator() {
useToolkitSocketConnection();
const cancelPendingRoute = useNavigationStore(state => state.cancelPendingRoute);
const completeNavigation = useNavigationStore(state => state.completeNavigation);
const pendingRouteName = useNavigationStore(state => state.pendingRouteName);
const routeName = useNavigationStore(state => state.currentRouteName);
const setPendingRoute = useNavigationStore(state => state.setPendingRoute);
const opacity = useRef(new Animated.Value(1)).current;
const isTransitioning = useRef(false);
useEffect(() => {
if (pendingRouteName == null || isTransitioning.current) {
return;
}
completeNavigation(pendingRouteName);
}, [completeNavigation, pendingRouteName]);
const navigate = useCallback((nextRouteName: RouteName) => {
if (
nextRouteName === routeName ||
nextRouteName === pendingRouteName ||
isTransitioning.current
) {
return;
}
isTransitioning.current = true;
setPendingRoute(nextRouteName);
if (fadeOutDurationMs === 0 && fadeInDurationMs === 0) {
opacity.setValue(0);
completeNavigation(nextRouteName);
opacity.setValue(1);
isTransitioning.current = false;
return;
}
Animated.timing(opacity, {
duration: fadeOutDurationMs,
toValue: 0,
useNativeDriver: true,
}).start(({ finished }) => {
if (!finished) {
cancelPendingRoute();
isTransitioning.current = false;
return;
}
completeNavigation(nextRouteName);
Animated.timing(opacity, {
duration: fadeInDurationMs,
toValue: 1,
useNativeDriver: true,
}).start(() => {
isTransitioning.current = false;
});
});
}, [
cancelPendingRoute,
completeNavigation,
opacity,
pendingRouteName,
routeName,
setPendingRoute,
]);
const goHome = useCallback(() => {
navigate('home');
}, [navigate]);
return (
<Animated.View style={styles.container}>
<Animated.View style={[styles.content, { opacity }]}>
{renderRoute(routeName, navigate, goHome)}
</Animated.View>
</Animated.View>
);
}
function renderRoute(
routeName: RouteName,
navigate: (routeName: RouteName) => void,
goHome: () => void,
) {
if (routeName === 'home') {
return <HomeScreen navigate={navigate} />;
}
if (routeName === 'policies') {
return <PoliciesScreen goHome={goHome} navigate={navigate} />;
}
if (routeName === 'settings') {
return <SettingsScreen goHome={goHome} />;
}
if (routeName === 'toolkit' || toolkitRoutes.includes(routeName)) {
return (
<ToolkitScreen
goHome={goHome}
navigate={navigate}
routeName={routeName}
/>
);
}
return <PageScreen goHome={goHome} routeName={routeName} />;
}
const styles = StyleSheet.create({
container: {
backgroundColor: '#111315',
flex: 1,
},
content: {
flex: 1,
},
});
+110
View File
@@ -0,0 +1,110 @@
export const routeDefinitions = {
home: {
label: 'Home',
title: 'Throne of the Bone King',
},
newGame: {
label: 'New Game',
title: 'New Game',
},
saves: {
label: 'Saves',
title: 'Saves',
},
settings: {
label: 'Settings',
title: 'Settings',
},
policies: {
label: 'Policies',
title: 'Policies',
},
policiesLegal: {
label: 'Legal',
title: 'Legal',
},
policiesPrivacy: {
label: 'Privacy',
title: 'Privacy',
},
policiesTerms: {
label: 'Terms and Conditions',
title: 'Terms and Conditions',
},
policiesCookies: {
label: 'Cookies',
title: 'Cookies',
},
credits: {
label: 'Credits',
title: 'Credits',
},
toolkit: {
label: 'Toolkit',
title: 'Toolkit',
},
toolkitOpeningSequence: {
label: 'Opening Sequence',
title: 'Opening Sequence',
},
toolkitCredits: {
label: 'Credits',
title: 'Toolkit Credits',
},
toolkitAudio: {
label: 'Audio',
title: 'Audio',
},
toolkitLore: {
label: 'Lore',
title: 'Lore',
},
toolkitResources: {
label: 'Resources',
title: 'Resources',
},
} as const;
export type RouteName = keyof typeof routeDefinitions;
export const primaryMenuRoutes = [
'newGame',
'saves',
'settings',
'policies',
'credits',
'toolkit',
] as const satisfies readonly RouteName[];
export const policyRoutes = [
'policiesLegal',
'policiesPrivacy',
'policiesTerms',
'policiesCookies',
] as const satisfies readonly RouteName[];
export const toolkitRoutes = [
'toolkitOpeningSequence',
'toolkitCredits',
'toolkitAudio',
'toolkitLore',
'toolkitResources',
] as const satisfies readonly RouteName[];
export const requiredPageRoutes = [
'home',
'newGame',
'saves',
'settings',
'policiesLegal',
'policiesPrivacy',
'policiesTerms',
'policiesCookies',
'credits',
'toolkit',
'toolkitOpeningSequence',
'toolkitCredits',
'toolkitAudio',
'toolkitLore',
'toolkitResources',
] as const satisfies readonly RouteName[];
+122
View File
@@ -0,0 +1,122 @@
import { StyleSheet, Text, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { MenuButton } from '../components/MenuButton';
import {
primaryMenuRoutes,
routeDefinitions,
type RouteName,
} from '../navigation/routes';
import { useGameSettingsStore, useToolkitSocketStore } from '../state';
type PrimaryMenuRouteName = (typeof primaryMenuRoutes)[number];
type HomeScreenProps = Readonly<{
navigate: (routeName: RouteName) => void;
}>;
const menuIcons: Record<PrimaryMenuRouteName, string> = {
credits: '★',
newGame: '▶',
policies: '§',
saves: '▣',
settings: '⚙',
toolkit: '✦',
};
export function HomeScreen({ navigate }: HomeScreenProps) {
const safeAreaInsets = useSafeAreaInsets();
const resolutionId = useGameSettingsStore(state => state.resolutionId);
const toolkitSocketStatus = useToolkitSocketStore(state => state.status);
const visibleMenuRoutes = primaryMenuRoutes.filter(
routeName => routeName !== 'toolkit' || resolutionId === 'browser',
);
return (
<View
style={[
styles.container,
{
paddingBottom: safeAreaInsets.bottom,
paddingLeft: safeAreaInsets.left,
paddingRight: safeAreaInsets.right,
paddingTop: safeAreaInsets.top,
},
]}>
<View style={styles.content}>
<View style={styles.copy}>
<Text style={styles.eyebrow}>Bone Lord Bob</Text>
<Text style={styles.title}>{routeDefinitions.home.title}</Text>
<Text style={styles.description}>
Choose a path through the kingdom.
</Text>
</View>
<View style={styles.menu}>
{visibleMenuRoutes.map(routeName => (
<MenuButton
key={routeName}
disabled={
routeName === 'toolkit' && toolkitSocketStatus !== 'connected'
}
icon={menuIcons[routeName]}
label={routeDefinitions[routeName].label}
onPress={() => navigate(routeName)}
testID={`menu-${routeName}`}
tone={routeName === 'toolkit' ? 'toolkit' : 'default'}
/>
))}
</View>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
backgroundColor: '#111315',
flex: 1,
},
content: {
alignItems: 'center',
flex: 1,
flexDirection: 'row',
gap: 20,
justifyContent: 'space-between',
paddingHorizontal: 22,
paddingVertical: 18,
},
copy: {
alignItems: 'flex-start',
flex: 1,
minWidth: 0,
},
description: {
color: '#c9c1b3',
fontSize: 14,
lineHeight: 20,
maxWidth: 220,
textAlign: 'left',
},
eyebrow: {
color: '#d6b25e',
fontSize: 12,
fontWeight: '700',
letterSpacing: 0,
marginBottom: 12,
textTransform: 'uppercase',
},
menu: {
gap: 8,
marginLeft: 'auto',
width: 156,
},
title: {
color: '#f6f1e7',
fontSize: 34,
fontWeight: '800',
letterSpacing: 0,
lineHeight: 40,
marginBottom: 16,
textAlign: 'left',
},
});
+56
View File
@@ -0,0 +1,56 @@
import { StyleSheet, Text, View } from 'react-native';
import { BackHomeButton } from '../components/BackHomeButton';
import { ScreenLayout } from '../components/ScreenLayout';
import { routeDefinitions, type RouteName } from '../navigation/routes';
type PageRouteName = Exclude<
RouteName,
| 'home'
| 'policies'
| 'settings'
| 'toolkit'
| 'toolkitOpeningSequence'
| 'toolkitCredits'
| 'toolkitAudio'
>;
type PageScreenProps = Readonly<{
goHome: () => void;
routeName: PageRouteName;
}>;
const pageDescriptions: Record<PageRouteName, string> = {
credits: 'Credits content will list the team, contributors, and acknowledgements.',
newGame: 'New Game setup will begin here.',
policiesCookies: 'Cookie policy content will live here.',
policiesLegal: 'Legal policy content will live here.',
policiesPrivacy: 'Privacy policy content will live here.',
policiesTerms: 'Terms and conditions content will live here.',
saves: 'Saved games will be listed here.',
};
export function PageScreen({ goHome, routeName }: PageScreenProps) {
const route = routeDefinitions[routeName];
return (
<ScreenLayout title={route.title}>
<Text style={styles.description}>{pageDescriptions[routeName]}</Text>
<View style={styles.actions}>
<BackHomeButton onPress={goHome} />
</View>
</ScreenLayout>
);
}
const styles = StyleSheet.create({
actions: {
marginTop: 12,
},
description: {
color: '#c9c1b3',
fontSize: 16,
lineHeight: 24,
textAlign: 'center',
},
});
+55
View File
@@ -0,0 +1,55 @@
import { StyleSheet, Text, View } from 'react-native';
import { BackHomeButton } from '../components/BackHomeButton';
import { MenuButton } from '../components/MenuButton';
import { ScreenLayout } from '../components/ScreenLayout';
import {
policyRoutes,
routeDefinitions,
type RouteName,
} from '../navigation/routes';
type PoliciesScreenProps = Readonly<{
goHome: () => void;
navigate: (routeName: RouteName) => void;
}>;
export function PoliciesScreen({ goHome, navigate }: PoliciesScreenProps) {
return (
<ScreenLayout title={routeDefinitions.policies.title}>
<Text style={styles.description}>
Review the policies for playing and using this app.
</Text>
<View style={styles.menu}>
{policyRoutes.map(routeName => (
<MenuButton
key={routeName}
label={routeDefinitions[routeName].label}
onPress={() => navigate(routeName)}
testID={`policy-${routeName}`}
/>
))}
</View>
<View style={styles.homeAction}>
<BackHomeButton onPress={goHome} />
</View>
</ScreenLayout>
);
}
const styles = StyleSheet.create({
description: {
color: '#c9c1b3',
fontSize: 16,
lineHeight: 24,
marginBottom: 12,
textAlign: 'center',
},
homeAction: {
marginTop: 12,
},
menu: {
gap: 12,
width: '100%',
},
});
+364
View File
@@ -0,0 +1,364 @@
import { useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { BackHomeButton } from '../components/BackHomeButton';
import {
formatResolutionLabel,
gameResolutionOptions,
volumeSettings,
type VolumeSettingKey,
} from '../settings/gameSettings';
import { useGameSettingsStore } from '../state';
type SettingsScreenProps = Readonly<{
goHome: () => void;
}>;
export function SettingsScreen({ goHome }: SettingsScreenProps) {
const safeAreaInsets = useSafeAreaInsets();
const [isResolutionSelectOpen, setIsResolutionSelectOpen] = useState(false);
const autosaveEnabled = useGameSettingsStore(state => state.autosaveEnabled);
const resolutionId = useGameSettingsStore(state => state.resolutionId);
const setResolution = useGameSettingsStore(state => state.setResolution);
const setVolume = useGameSettingsStore(state => state.setVolume);
const toggleAutosave = useGameSettingsStore(state => state.toggleAutosave);
const volumes = useGameSettingsStore(state => state.volumes);
return (
<View
style={[
styles.container,
{
paddingBottom: safeAreaInsets.bottom + 16,
paddingLeft: safeAreaInsets.left + 18,
paddingRight: safeAreaInsets.right + 18,
paddingTop: safeAreaInsets.top + 16,
},
]}>
<View style={styles.header}>
<Text style={styles.eyebrow}>Bone Lord Bob</Text>
<Text style={styles.title}>Settings</Text>
<Text style={styles.summary}>
Tune the enforced game surface and audio mix.
</Text>
<View style={styles.homeAction}>
<BackHomeButton onPress={goHome} />
</View>
</View>
<View style={styles.resolutionPanel}>
<Text style={styles.sectionTitle}>Game Resolution</Text>
<Text style={styles.helperText}>
{formatResolutionLabel(resolutionId)}
</Text>
<View style={styles.selectWrapper}>
<SettingButton
label={`${formatResolutionLabel(resolutionId)} ${
isResolutionSelectOpen ? '▲' : '▼'
}`}
onPress={() => setIsResolutionSelectOpen(isOpen => !isOpen)}
testID="resolution-select"
/>
{isResolutionSelectOpen ? (
<View style={styles.selectMenu} testID="resolution-options">
{gameResolutionOptions.map(option => {
const label =
'unbounded' in option
? `${option.label} (Fill available space)`
: `${option.label} (${option.width} x ${option.height})`;
return (
<SettingButton
key={option.id}
active={option.id === resolutionId}
label={label}
onPress={() => {
setResolution(option.id);
setIsResolutionSelectOpen(false);
}}
testID={`resolution-${option.id}`}
/>
);
})}
</View>
) : null}
</View>
<View style={styles.checkboxRow}>
<Pressable
accessibilityRole="checkbox"
accessibilityState={{ checked: autosaveEnabled }}
onPress={toggleAutosave}
style={styles.checkbox}
testID="autosave-checkbox">
<Text style={styles.checkboxMark}>
{autosaveEnabled ? '✓' : ''}
</Text>
</Pressable>
<View style={styles.checkboxCopy}>
<Text style={styles.checkboxLabel}>Autosave</Text>
<Text style={styles.checkboxHint}>Enabled by default</Text>
</View>
</View>
</View>
<View style={styles.audioPanel}>
<Text style={styles.sectionTitle}>Audio</Text>
{volumeSettings.map(setting => (
<VolumeControl
key={setting.key}
label={setting.label}
onChange={nextVolume => setVolume(setting.key, nextVolume)}
settingKey={setting.key}
value={volumes[setting.key]}
/>
))}
</View>
</View>
);
}
type SettingButtonProps = Readonly<{
active?: boolean;
label: string;
onPress: () => void;
testID: string;
}>;
function SettingButton({
active = false,
label,
onPress,
testID,
}: SettingButtonProps) {
return (
<Pressable
accessibilityRole="button"
onPress={onPress}
style={({ pressed }) => [
styles.settingButton,
active ? styles.settingButtonActive : null,
pressed ? styles.settingButtonPressed : null,
]}
testID={testID}>
<Text
style={[
styles.settingButtonLabel,
active ? styles.settingButtonLabelActive : null,
]}>
{label}
</Text>
</Pressable>
);
}
type VolumeControlProps = Readonly<{
label: string;
onChange: (volume: number) => void;
settingKey: VolumeSettingKey;
value: number;
}>;
function VolumeControl({
label,
onChange,
settingKey,
value,
}: VolumeControlProps) {
return (
<View style={styles.volumeRow}>
<Text style={styles.volumeLabel}>{label}</Text>
<View style={styles.volumeControls}>
<SettingButton
label="-"
onPress={() => onChange(value - 5)}
testID={`volume-${settingKey}-decrease`}
/>
<Text style={styles.volumeValue} testID={`volume-${settingKey}-value`}>
{value}
</Text>
<SettingButton
label="+"
onPress={() => onChange(value + 5)}
testID={`volume-${settingKey}-increase`}
/>
</View>
</View>
);
}
const styles = StyleSheet.create({
audioPanel: {
backgroundColor: '#191b1f',
borderColor: '#37342d',
borderRadius: 8,
borderWidth: 1,
gap: 10,
padding: 12,
width: 268,
},
container: {
alignItems: 'stretch',
backgroundColor: '#111315',
flex: 1,
flexDirection: 'row',
gap: 14,
},
eyebrow: {
color: '#d6b25e',
fontSize: 11,
fontWeight: '700',
letterSpacing: 0,
marginBottom: 10,
textTransform: 'uppercase',
},
header: {
justifyContent: 'flex-start',
width: 190,
},
helperText: {
color: '#c9c1b3',
fontSize: 12,
lineHeight: 16,
},
homeAction: {
marginTop: 18,
},
checkbox: {
alignItems: 'center',
backgroundColor: '#24272b',
borderColor: '#d6b25e',
borderRadius: 6,
borderWidth: 1,
height: 28,
justifyContent: 'center',
width: 28,
},
checkboxCopy: {
flex: 1,
},
checkboxHint: {
color: '#c9c1b3',
fontSize: 11,
lineHeight: 14,
},
checkboxLabel: {
color: '#f6f1e7',
fontSize: 13,
fontWeight: '800',
letterSpacing: 0,
},
checkboxMark: {
color: '#f5d98d',
fontSize: 18,
fontWeight: '800',
lineHeight: 22,
},
checkboxRow: {
alignItems: 'center',
flexDirection: 'row',
gap: 10,
},
resolutionPanel: {
backgroundColor: '#191b1f',
borderColor: '#37342d',
borderRadius: 8,
borderWidth: 1,
flex: 1,
gap: 10,
justifyContent: 'flex-start',
padding: 12,
zIndex: 2,
},
sectionTitle: {
color: '#f6f1e7',
fontSize: 16,
fontWeight: '800',
letterSpacing: 0,
},
settingButton: {
alignItems: 'center',
backgroundColor: '#24272b',
borderColor: '#4d4432',
borderRadius: 8,
borderWidth: 1,
justifyContent: 'center',
minHeight: 34,
minWidth: 38,
paddingHorizontal: 10,
paddingVertical: 7,
},
settingButtonActive: {
backgroundColor: '#3a3120',
borderColor: '#d6b25e',
},
settingButtonLabel: {
color: '#f6f1e7',
fontSize: 12,
fontWeight: '700',
letterSpacing: 0,
textAlign: 'center',
},
settingButtonLabelActive: {
color: '#f5d98d',
},
settingButtonPressed: {
backgroundColor: '#33302a',
},
selectMenu: {
backgroundColor: '#191b1f',
borderColor: '#4d4432',
borderRadius: 8,
borderWidth: 1,
gap: 6,
left: 0,
padding: 6,
position: 'absolute',
right: 0,
top: 40,
zIndex: 4,
},
selectWrapper: {
position: 'relative',
zIndex: 3,
},
summary: {
color: '#c9c1b3',
fontSize: 13,
lineHeight: 18,
},
title: {
color: '#f6f1e7',
fontSize: 30,
fontWeight: '800',
letterSpacing: 0,
lineHeight: 34,
marginBottom: 12,
},
volumeControls: {
alignItems: 'center',
flexDirection: 'row',
gap: 8,
},
volumeLabel: {
color: '#f6f1e7',
flex: 1,
fontSize: 13,
fontWeight: '700',
letterSpacing: 0,
},
volumeRow: {
alignItems: 'center',
flexDirection: 'row',
gap: 12,
justifyContent: 'space-between',
},
volumeValue: {
color: '#c9c1b3',
fontSize: 13,
fontWeight: '700',
minWidth: 32,
textAlign: 'center',
},
});
+301
View File
@@ -0,0 +1,301 @@
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import {
routeDefinitions,
toolkitRoutes,
type RouteName,
} from '../navigation/routes';
import { useToolkitSocketStore, type ToolkitSocketStatus } from '../state';
type ToolkitRouteName = (typeof toolkitRoutes)[number];
type ToolkitScreenProps = Readonly<{
goHome: () => void;
navigate: (routeName: RouteName) => void;
routeName: RouteName;
}>;
const toolkitDescriptions: Partial<Record<RouteName, string>> = {
toolkit: 'Select a toolkit area to preview game support assets.',
toolkitAudio: 'Audio tools and checks will live here.',
toolkitCredits: 'Toolkit-specific credit review will live here.',
toolkitLore: 'Lore reference material will live here.',
toolkitOpeningSequence: 'Opening sequence preview controls will live here.',
toolkitResources: 'Production and gameplay resources will live here.',
};
const toolkitIcons: Record<ToolkitRouteName, string> = {
toolkitAudio: '♪',
toolkitCredits: '★',
toolkitLore: '✦',
toolkitOpeningSequence: '▶',
toolkitResources: '▦',
};
const socketStatusCopy: Record<ToolkitSocketStatus, string> = {
connected: 'Connected',
connecting: 'Connecting',
disconnected: 'Disconnected',
};
export function ToolkitScreen({
goHome,
navigate,
routeName,
}: ToolkitScreenProps) {
const safeAreaInsets = useSafeAreaInsets();
const activeRoute = routeDefinitions[routeName];
const socketStatus = useToolkitSocketStore(state => state.status);
return (
<View
style={[
styles.container,
{
paddingBottom: safeAreaInsets.bottom + 12,
paddingLeft: safeAreaInsets.left + 12,
paddingRight: safeAreaInsets.right + 12,
paddingTop: safeAreaInsets.top + 12,
},
]}>
<View style={styles.toolbar} testID="toolkit-toolbar">
<View style={styles.toolbarGroup} testID="toolkit-option-group">
{toolkitRoutes.map(toolkitRouteName => (
<ToolkitToolbarButton
key={toolkitRouteName}
active={toolkitRouteName === routeName}
icon={toolkitIcons[toolkitRouteName]}
label={routeDefinitions[toolkitRouteName].label}
onPress={() => navigate(toolkitRouteName)}
testID={`toolkit-${toolkitRouteName}`}
/>
))}
</View>
<View style={styles.homeSlot} testID="toolkit-home-slot">
<ToolkitToolbarButton
accessibilityLabel="Home"
icon="⌂"
iconOnly
label="Home"
onPress={goHome}
testID="toolkit-home"
/>
</View>
</View>
<View style={styles.contentPanel}>
<Text style={styles.eyebrow}>Bone Lord Bob</Text>
<Text style={styles.title}>{activeRoute.title}</Text>
<Text style={styles.description}>
{toolkitDescriptions[routeName] ?? toolkitDescriptions.toolkit}
</Text>
<ToolkitSocketIndicator status={socketStatus} />
</View>
</View>
);
}
type ToolkitSocketIndicatorProps = Readonly<{
status: ToolkitSocketStatus;
}>;
function ToolkitSocketIndicator({ status }: ToolkitSocketIndicatorProps) {
return (
<View
accessibilityLabel={`Toolkit socket ${socketStatusCopy[status]}`}
style={styles.socketIndicator}
testID="toolkit-socket-indicator">
<View
style={[styles.socketDot, socketDotStyles[status]]}
testID="toolkit-socket-dot"
/>
<Text style={styles.socketLabel}>{socketStatusCopy[status]}</Text>
</View>
);
}
type ToolkitToolbarButtonProps = Readonly<{
active?: boolean;
accessibilityLabel?: string;
icon: string;
iconOnly?: boolean;
label: string;
onPress: () => void;
testID: string;
}>;
function ToolkitToolbarButton({
active = false,
accessibilityLabel,
icon,
iconOnly = false,
label,
onPress,
testID,
}: ToolkitToolbarButtonProps) {
return (
<Pressable
accessibilityLabel={accessibilityLabel ?? label}
accessibilityRole="button"
onPress={onPress}
style={({ pressed }) => [
styles.toolbarButton,
iconOnly ? styles.toolbarButtonIconOnly : null,
active ? styles.toolbarButtonActive : null,
pressed ? styles.toolbarButtonPressed : null,
]}
testID={testID}>
<View style={styles.toolbarButtonContent}>
<Text
style={[
styles.toolbarButtonIcon,
active ? styles.toolbarButtonLabelActive : null,
]}
testID={`${testID}-icon`}>
{icon}
</Text>
{iconOnly ? null : (
<Text
style={[
styles.toolbarButtonLabel,
active ? styles.toolbarButtonLabelActive : null,
]}
testID={`${testID}-label`}>
{label}
</Text>
)}
</View>
</Pressable>
);
}
const styles = StyleSheet.create({
container: {
alignItems: 'stretch',
backgroundColor: '#111315',
flex: 1,
gap: 8,
},
contentPanel: {
backgroundColor: '#191b1f',
borderColor: '#37342d',
borderRadius: 8,
borderWidth: 1,
flex: 1,
justifyContent: 'flex-start',
padding: 14,
position: 'relative',
},
description: {
color: '#c9c1b3',
fontSize: 14,
lineHeight: 20,
maxWidth: 420,
},
eyebrow: {
color: '#d6b25e',
fontSize: 11,
fontWeight: '700',
letterSpacing: 0,
marginBottom: 10,
textTransform: 'uppercase',
},
title: {
color: '#f6f1e7',
fontSize: 30,
fontWeight: '800',
letterSpacing: 0,
lineHeight: 34,
marginBottom: 12,
},
socketDot: {
borderRadius: 5,
height: 10,
width: 10,
},
socketIndicator: {
alignItems: 'center',
bottom: 12,
flexDirection: 'row',
gap: 7,
position: 'absolute',
right: 12,
},
socketLabel: {
color: '#c9c1b3',
fontSize: 11,
fontWeight: '700',
letterSpacing: 0,
textTransform: 'uppercase',
},
homeSlot: {
marginLeft: 'auto',
},
toolbar: {
alignItems: 'stretch',
flexDirection: 'row',
minHeight: 34,
},
toolbarButton: {
alignItems: 'center',
backgroundColor: '#24272b',
borderColor: '#4d4432',
borderRadius: 0,
borderWidth: 1,
justifyContent: 'center',
minHeight: 34,
paddingHorizontal: 12,
paddingVertical: 6,
},
toolbarButtonActive: {
backgroundColor: '#3a3120',
borderColor: '#d6b25e',
},
toolbarButtonContent: {
alignItems: 'center',
flexDirection: 'row',
gap: 5,
justifyContent: 'center',
},
toolbarButtonIcon: {
color: '#f5d98d',
fontSize: 12,
fontWeight: '800',
letterSpacing: 0,
lineHeight: 14,
textAlign: 'center',
},
toolbarButtonIconOnly: {
minWidth: 38,
paddingHorizontal: 9,
},
toolbarButtonLabel: {
color: '#f6f1e7',
fontSize: 12,
fontWeight: '800',
letterSpacing: 0,
textAlign: 'center',
},
toolbarButtonLabelActive: {
color: '#f5d98d',
},
toolbarButtonPressed: {
backgroundColor: '#33302a',
},
toolbarGroup: {
flexDirection: 'row',
},
});
const socketDotStyles = StyleSheet.create({
connected: {
backgroundColor: '#3fbf6f',
},
connecting: {
backgroundColor: '#d6b25e',
},
disconnected: {
backgroundColor: '#d85a4d',
},
});
+88
View File
@@ -0,0 +1,88 @@
export const gameResolutionOptions = [
{
id: 'browser',
label: 'Browser',
unbounded: true,
},
{
id: 'iphoneMini',
label: 'Phone Mini',
width: 812,
height: 375,
},
{
id: 'iphoneDefault',
label: 'Phone Default',
width: 844,
height: 390,
},
{
id: 'androidDefault',
label: 'Android Default',
width: 915,
height: 412,
},
{
id: 'phoneLarge',
label: 'Phone Large',
width: 932,
height: 430,
},
] as const;
export type GameResolutionId = (typeof gameResolutionOptions)[number]['id'];
export const defaultGameResolutionId: GameResolutionId = 'iphoneDefault';
export const defaultAutosaveEnabled = true;
export const volumeSettings = [
{
key: 'master',
label: 'Master Volume',
},
{
key: 'music',
label: 'Music Volume',
},
{
key: 'ambience',
label: 'Ambience Volume',
},
{
key: 'speech',
label: 'Speech Volume',
},
] as const;
export type VolumeSettingKey = (typeof volumeSettings)[number]['key'];
export type VolumeSettings = Record<VolumeSettingKey, number>;
export const defaultVolumeSettings: VolumeSettings = {
ambience: 70,
master: 80,
music: 70,
speech: 80,
};
export function getGameResolutionById(resolutionId: GameResolutionId) {
return (
gameResolutionOptions.find(option => option.id === resolutionId) ??
gameResolutionOptions[0]
);
}
export function formatResolutionLabel(resolutionId: GameResolutionId) {
const resolution = getGameResolutionById(resolutionId);
if ('unbounded' in resolution) {
return `${resolution.label} (Fill available space)`;
}
return `${resolution.label} (${resolution.width} x ${resolution.height})`;
}
export function clampVolume(volume: number) {
return Math.min(100, Math.max(0, volume));
}
+24
View File
@@ -0,0 +1,24 @@
import type { PersistOptions } from 'zustand/middleware';
import { createPersistStorage } from './persistStorage';
export type PersistedStateOptions<TState, TPersistedState = TState> = Omit<
PersistOptions<TState, TPersistedState>,
'name' | 'storage'
> & {
name: string;
};
export function createPersistOptions<TState, TPersistedState = TState>({
name,
...options
}: PersistedStateOptions<TState, TPersistedState>): PersistOptions<
TState,
TPersistedState
> {
return {
...options,
name,
storage: createPersistStorage<TPersistedState>(),
};
}
+62
View File
@@ -0,0 +1,62 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import {
clampVolume,
defaultGameResolutionId,
defaultAutosaveEnabled,
defaultVolumeSettings,
type GameResolutionId,
type VolumeSettingKey,
type VolumeSettings,
} from '../settings/gameSettings';
import { createPersistOptions } from './createPersistOptions';
type PersistedGameSettingsState = {
autosaveEnabled: boolean;
resolutionId: GameResolutionId;
volumes: VolumeSettings;
};
type GameSettingsState = PersistedGameSettingsState & {
setAutosaveEnabled: (autosaveEnabled: boolean) => void;
setResolution: (resolutionId: GameResolutionId) => void;
toggleAutosave: () => void;
setVolume: (key: VolumeSettingKey, volume: number) => void;
};
export const useGameSettingsStore = create<GameSettingsState>()(
persist(
set => ({
autosaveEnabled: defaultAutosaveEnabled,
resolutionId: defaultGameResolutionId,
setAutosaveEnabled: (autosaveEnabled: boolean) => {
set({ autosaveEnabled });
},
setResolution: (resolutionId: GameResolutionId) => {
set({ resolutionId });
},
toggleAutosave: () => {
set(state => ({ autosaveEnabled: !state.autosaveEnabled }));
},
setVolume: (key: VolumeSettingKey, volume: number) => {
set(state => ({
volumes: {
...state.volumes,
[key]: clampVolume(volume),
},
}));
},
volumes: defaultVolumeSettings,
}),
createPersistOptions<GameSettingsState, PersistedGameSettingsState>({
name: 'blb-game-settings',
partialize: state => ({
autosaveEnabled: state.autosaveEnabled,
resolutionId: state.resolutionId,
volumes: state.volumes,
}),
version: 1,
}),
),
);
+11
View File
@@ -0,0 +1,11 @@
export {
createPersistOptions,
type PersistedStateOptions,
} from './createPersistOptions';
export { useGameSettingsStore } from './gameSettingsStore';
export { createMemoryStateStorage } from './memoryStateStorage';
export { useNavigationStore } from './navigationStore';
export {
useToolkitSocketStore,
type ToolkitSocketStatus,
} from './toolkitSocketStore';
+15
View File
@@ -0,0 +1,15 @@
import type { StateStorage } from 'zustand/middleware';
export function createMemoryStateStorage(): StateStorage<void> {
const entries = new Map<string, string>();
return {
getItem: (name: string) => entries.get(name) ?? null,
removeItem: (name: string) => {
entries.delete(name);
},
setItem: (name: string, value: string) => {
entries.set(name, value);
},
};
}
+47
View File
@@ -0,0 +1,47 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { RouteName } from '../navigation/routes';
import { createPersistOptions } from './createPersistOptions';
const defaultRouteName: RouteName = 'home';
type PersistedNavigationState = {
currentRouteName: RouteName;
pendingRouteName: RouteName | null;
};
type NavigationState = PersistedNavigationState & {
cancelPendingRoute: () => void;
completeNavigation: (routeName: RouteName) => void;
setPendingRoute: (routeName: RouteName) => void;
};
export const useNavigationStore = create<NavigationState>()(
persist(
set => ({
cancelPendingRoute: () => {
set({ pendingRouteName: null });
},
completeNavigation: (routeName: RouteName) => {
set({
currentRouteName: routeName,
pendingRouteName: null,
});
},
currentRouteName: defaultRouteName,
pendingRouteName: null,
setPendingRoute: (routeName: RouteName) => {
set({ pendingRouteName: routeName });
},
}),
createPersistOptions<NavigationState, PersistedNavigationState>({
name: 'blb-navigation',
partialize: state => ({
currentRouteName: state.currentRouteName,
pendingRouteName: state.pendingRouteName,
}),
version: 1,
}),
),
);
+12
View File
@@ -0,0 +1,12 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import { createJSONStorage, type StateStorage } from 'zustand/middleware';
export const stateStorage: StateStorage<Promise<void>> = {
getItem: (name: string) => AsyncStorage.getItem(name),
removeItem: (name: string) => AsyncStorage.removeItem(name),
setItem: (name: string, value: string) => AsyncStorage.setItem(name, value),
};
export function createPersistStorage<TState>() {
return createJSONStorage<TState>(() => stateStorage);
}
+41
View File
@@ -0,0 +1,41 @@
import { createJSONStorage, type StateStorage } from 'zustand/middleware';
import { createMemoryStateStorage } from './memoryStateStorage';
type BrowserStorageHost = {
localStorage?: StateStorage<void>;
};
function getBrowserLocalStorage() {
return (globalThis as unknown as BrowserStorageHost).localStorage;
}
function canUseStateStorage(storage: StateStorage<void>) {
const testKey = '__blb_storage_test__';
try {
storage.setItem(testKey, testKey);
storage.removeItem(testKey);
return true;
} catch {
return false;
}
}
export function createWebStateStorage(
getStorage: () => StateStorage<void> | undefined = getBrowserLocalStorage,
): StateStorage<void> {
const storage = getStorage();
if (storage != null && canUseStateStorage(storage)) {
return storage;
}
return createMemoryStateStorage();
}
export const stateStorage = createWebStateStorage();
export function createPersistStorage<TState>() {
return createJSONStorage<TState>(() => stateStorage);
}
+37
View File
@@ -0,0 +1,37 @@
import { create } from 'zustand';
export type ToolkitSocketStatus = 'connected' | 'connecting' | 'disconnected';
type ToolkitSocketState = {
lastConnectedAt: string | null;
socketId: string | null;
status: ToolkitSocketStatus;
setConnected: (socketId: string) => void;
setConnecting: () => void;
setDisconnected: () => void;
};
export const useToolkitSocketStore = create<ToolkitSocketState>()(set => ({
lastConnectedAt: null,
setConnected: (socketId: string) => {
set({
lastConnectedAt: new Date().toISOString(),
socketId,
status: 'connected',
});
},
setConnecting: () => {
set({
socketId: null,
status: 'connecting',
});
},
setDisconnected: () => {
set({
socketId: null,
status: 'disconnected',
});
},
socketId: null,
status: 'disconnected',
}));
+14
View File
@@ -0,0 +1,14 @@
import { AppRegistry } from 'react-native';
import { name as appName } from '../../app.json';
import App from '../../App';
import './styles.css';
const rootTag = globalThis.document.getElementById('root');
if (rootTag == null) {
throw new Error('Root element #root was not found.');
}
AppRegistry.registerComponent(appName, () => App);
AppRegistry.runApplication(appName, { rootTag });
+16
View File
@@ -0,0 +1,16 @@
html,
body,
#root {
height: 100%;
margin: 0;
min-height: 100%;
}
body {
background: #050607;
overflow: hidden;
}
#root {
display: flex;
}