feat(app): add web electron toolkit support
This commit is contained in:
@@ -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" />
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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[];
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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%',
|
||||
},
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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>(),
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
),
|
||||
);
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
),
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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',
|
||||
}));
|
||||
@@ -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 });
|
||||
@@ -0,0 +1,16 @@
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background: #050607;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#root {
|
||||
display: flex;
|
||||
}
|
||||
Reference in New Issue
Block a user