feat(app): add browser toolkit support
|
After Width: | Height: | Size: 5.7 MiB |
|
After Width: | Height: | Size: 6.5 MiB |
|
After Width: | Height: | Size: 7.6 MiB |
|
After Width: | Height: | Size: 6.5 MiB |
|
After Width: | Height: | Size: 6.4 MiB |
@@ -0,0 +1,49 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Throne of the Bone King favicon">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="8" y1="4" x2="56" y2="60" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#241133" />
|
||||
<stop offset="0.55" stop-color="#111418" />
|
||||
<stop offset="1" stop-color="#35220f" />
|
||||
</linearGradient>
|
||||
<linearGradient id="gold" x1="14" y1="10" x2="50" y2="42" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#ffe599" />
|
||||
<stop offset="0.55" stop-color="#d6a437" />
|
||||
<stop offset="1" stop-color="#6e4318" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<rect width="64" height="64" rx="13" fill="url(#bg)" />
|
||||
<rect x="4" y="4" width="56" height="56" rx="10" fill="none" stroke="#d6a437" stroke-width="2" />
|
||||
|
||||
<path
|
||||
d="M16 25 21 12l8 11 5-14 6 14 8-11 5 13-5 3H21l-5-3Z"
|
||||
fill="url(#gold)"
|
||||
stroke="#fff0b8"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.6"
|
||||
/>
|
||||
<circle cx="21" cy="13" r="2.2" fill="#f6d66f" />
|
||||
<circle cx="34" cy="9" r="2.2" fill="#f6d66f" />
|
||||
<circle cx="48" cy="13" r="2.2" fill="#f6d66f" />
|
||||
|
||||
<path
|
||||
d="M20 48 44 31"
|
||||
fill="none"
|
||||
stroke="#f4ead7"
|
||||
stroke-linecap="round"
|
||||
stroke-width="5"
|
||||
/>
|
||||
<circle cx="18" cy="49" r="4" fill="#f4ead7" />
|
||||
<circle cx="46" cy="30" r="4" fill="#f4ead7" />
|
||||
|
||||
<path
|
||||
d="M22 35c0-8 5-13 12-13s12 5 12 13c0 6-3 10-8 12v5H30v-5c-5-2-8-6-8-12Z"
|
||||
fill="#e6ddcb"
|
||||
stroke="#201914"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<circle cx="30" cy="35" r="3.2" fill="#151515" />
|
||||
<circle cx="38" cy="35" r="3.2" fill="#151515" />
|
||||
<path d="M34 38 31 44h6l-3-6Z" fill="#151515" />
|
||||
<path d="M28 48h12" fill="none" stroke="#151515" stroke-linecap="round" stroke-width="2" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 7.8 MiB |
|
After Width: | Height: | Size: 6.9 MiB |
|
After Width: | Height: | Size: 7.4 MiB |
|
After Width: | Height: | Size: 8.5 MiB |
|
After Width: | Height: | Size: 8.6 MiB |
|
After Width: | Height: | Size: 8.1 MiB |
|
After Width: | Height: | Size: 8.5 MiB |
|
After Width: | Height: | Size: 4.1 MiB |
|
After Width: | Height: | Size: 3.3 MiB |
|
After Width: | Height: | Size: 2.6 MiB |
@@ -0,0 +1 @@
|
||||
export { default as throneOfTheBoneKingMusic } from './throne-of-the-bone-king.mp3';
|
||||
@@ -0,0 +1,33 @@
|
||||
@use './__variables' as variables;
|
||||
|
||||
@font-face {
|
||||
font-family: 'MiTicaRegular';
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
src: url('../fonts/mitica/MiTicaRegular.otf') format('opentype');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'SingleDayRegular';
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
src: url('../fonts/single-day/SingleDayRegular.ttf') format('truetype');
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background: variables.$color-page-background;
|
||||
font-family: 'SingleDayRegular', sans-serif;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#root {
|
||||
display: flex;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
$color-page-background: #050607;
|
||||
@@ -0,0 +1 @@
|
||||
@use './__globals';
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
import { displayTextStyle } from '../styles/typography';
|
||||
|
||||
type MenuButtonTone = 'default' | 'toolkit';
|
||||
|
||||
type MenuButtonProps = Readonly<{
|
||||
@@ -98,9 +100,9 @@ const styles = StyleSheet.create({
|
||||
textAlign: 'center',
|
||||
},
|
||||
label: {
|
||||
...displayTextStyle,
|
||||
color: '#f6f1e7',
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 0,
|
||||
textAlign: 'center',
|
||||
},
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { ReactNode } from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
import { defaultTextStyle, displayTextStyle } from '../styles/typography';
|
||||
|
||||
type ScreenLayoutProps = Readonly<{
|
||||
children: ReactNode;
|
||||
eyebrow?: string;
|
||||
@@ -55,6 +57,7 @@ const styles = StyleSheet.create({
|
||||
minWidth: 0,
|
||||
},
|
||||
eyebrow: {
|
||||
...defaultTextStyle,
|
||||
color: '#d6b25e',
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
@@ -64,9 +67,9 @@ const styles = StyleSheet.create({
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
title: {
|
||||
...displayTextStyle,
|
||||
color: '#f6f1e7',
|
||||
fontSize: 30,
|
||||
fontWeight: '800',
|
||||
letterSpacing: 0,
|
||||
lineHeight: 36,
|
||||
textAlign: 'left',
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export const appVersion = '0.0.1';
|
||||
|
||||
export const buildVersionLabel = `Build v${appVersion}`;
|
||||
@@ -0,0 +1,10 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
const viteAppVersion = import.meta.env.VITE_APP_VERSION;
|
||||
|
||||
export const appVersion =
|
||||
typeof viteAppVersion === 'string' && viteAppVersion.length > 0
|
||||
? viteAppVersion
|
||||
: '0.0.1';
|
||||
|
||||
export const buildVersionLabel = `Build v${appVersion}`;
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"mainLogo": {
|
||||
"title": "Throne of the Bone King",
|
||||
"subtitle": "A Bone Lord Bob kingdom-management story",
|
||||
"image": null
|
||||
},
|
||||
"contributors": [
|
||||
{
|
||||
"name": "Jacob Mathison",
|
||||
"role": "Creator",
|
||||
"credit": "World, game direction, and implementation",
|
||||
"url": null
|
||||
},
|
||||
{
|
||||
"name": "Bone Lord Bob Team",
|
||||
"role": "Production",
|
||||
"credit": "Design iteration, toolkit review, and content planning",
|
||||
"url": null
|
||||
}
|
||||
],
|
||||
"technologies": [
|
||||
{
|
||||
"name": "React Native",
|
||||
"role": "Shared app interface",
|
||||
"url": "https://reactnative.dev"
|
||||
},
|
||||
{
|
||||
"name": "React Native Web",
|
||||
"role": "Browser renderer",
|
||||
"url": "https://necolas.github.io/react-native-web/"
|
||||
},
|
||||
{
|
||||
"name": "Vite",
|
||||
"role": "Web build tooling",
|
||||
"url": "https://vite.dev"
|
||||
},
|
||||
{
|
||||
"name": "Electron",
|
||||
"role": "Desktop shell",
|
||||
"url": "https://www.electronjs.org"
|
||||
},
|
||||
{
|
||||
"name": "Socket.IO",
|
||||
"role": "Toolkit sync channel",
|
||||
"url": "https://socket.io"
|
||||
},
|
||||
{
|
||||
"name": "Zustand",
|
||||
"role": "State management",
|
||||
"url": "https://zustand-demo.pmnd.rs"
|
||||
}
|
||||
],
|
||||
"specialThanks": [
|
||||
"The open-source maintainers behind the browser, desktop, and React Native tooling.",
|
||||
"Font Library contributors for MiTica and Single Day Regular.",
|
||||
"Everyone helping shape the Bone Lord Bob universe."
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import creditsJson from './credits.json';
|
||||
|
||||
export type CreditsLogo = Readonly<{
|
||||
image?: string | null;
|
||||
subtitle: string;
|
||||
title: string;
|
||||
}>;
|
||||
|
||||
export type CreditsContributor = Readonly<{
|
||||
credit?: string;
|
||||
name: string;
|
||||
role: string;
|
||||
url?: string | null;
|
||||
}>;
|
||||
|
||||
export type CreditsTechnology = Readonly<{
|
||||
name: string;
|
||||
role?: string;
|
||||
url?: string | null;
|
||||
}>;
|
||||
|
||||
export type CreditsContent = Readonly<{
|
||||
contributors: CreditsContributor[];
|
||||
mainLogo: CreditsLogo;
|
||||
specialThanks: string[];
|
||||
technologies: CreditsTechnology[];
|
||||
}>;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value != null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isOptionalString(value: unknown) {
|
||||
return value == null || typeof value === 'string';
|
||||
}
|
||||
|
||||
function isCreditsLogo(value: unknown): value is CreditsLogo {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
typeof value.title === 'string' &&
|
||||
typeof value.subtitle === 'string' &&
|
||||
isOptionalString(value.image)
|
||||
);
|
||||
}
|
||||
|
||||
function isCreditsContributor(value: unknown): value is CreditsContributor {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
typeof value.name === 'string' &&
|
||||
typeof value.role === 'string' &&
|
||||
isOptionalString(value.credit) &&
|
||||
isOptionalString(value.url)
|
||||
);
|
||||
}
|
||||
|
||||
function isCreditsTechnology(value: unknown): value is CreditsTechnology {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
typeof value.name === 'string' &&
|
||||
isOptionalString(value.role) &&
|
||||
isOptionalString(value.url)
|
||||
);
|
||||
}
|
||||
|
||||
export function isCreditsContent(value: unknown): value is CreditsContent {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
isCreditsLogo(value.mainLogo) &&
|
||||
Array.isArray(value.contributors) &&
|
||||
value.contributors.every(isCreditsContributor) &&
|
||||
Array.isArray(value.technologies) &&
|
||||
value.technologies.every(isCreditsTechnology) &&
|
||||
Array.isArray(value.specialThanks) &&
|
||||
value.specialThanks.every(item => typeof item === 'string')
|
||||
);
|
||||
}
|
||||
|
||||
if (!isCreditsContent(creditsJson)) {
|
||||
throw new Error('src/data/credits.json does not match CreditsContent.');
|
||||
}
|
||||
|
||||
export const fallbackCreditsContent = creditsJson;
|
||||
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
fallbackCreditsContent,
|
||||
isCreditsContent,
|
||||
type CreditsContent,
|
||||
type CreditsContributor,
|
||||
type CreditsLogo,
|
||||
type CreditsTechnology,
|
||||
} from './credits';
|
||||
@@ -0,0 +1,3 @@
|
||||
export function useBackgroundMusic() {
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import { throneOfTheBoneKingMusic } from '../assets/music';
|
||||
import { useGameSettingsStore } from '../state';
|
||||
|
||||
type AudioConstructor = new (src?: string) => HTMLAudioElement;
|
||||
|
||||
type AudioHost = typeof globalThis & {
|
||||
Audio?: AudioConstructor;
|
||||
};
|
||||
|
||||
const audioHost = globalThis as AudioHost;
|
||||
|
||||
function getEffectiveMusicVolume(
|
||||
masterVolume: number,
|
||||
musicVolume: number,
|
||||
audioMuted: boolean,
|
||||
) {
|
||||
if (audioMuted) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (masterVolume / 100) * (musicVolume / 100);
|
||||
}
|
||||
|
||||
export function useBackgroundMusic() {
|
||||
const audioMuted = useGameSettingsStore(state => state.audioMuted);
|
||||
const masterVolume = useGameSettingsStore(state => state.volumes.master);
|
||||
const musicVolume = useGameSettingsStore(state => state.volumes.music);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (audioHost.Audio == null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const audio = new audioHost.Audio(throneOfTheBoneKingMusic);
|
||||
|
||||
audio.loop = true;
|
||||
audio.preload = 'auto';
|
||||
audio.volume = 0;
|
||||
audioRef.current = audio;
|
||||
|
||||
function playAudio() {
|
||||
audio.play().catch(() => {
|
||||
// Browsers may block autoplay until the first user gesture.
|
||||
});
|
||||
}
|
||||
|
||||
function unlockAudio() {
|
||||
playAudio();
|
||||
globalThis.removeEventListener('pointerdown', unlockAudio);
|
||||
globalThis.removeEventListener('keydown', unlockAudio);
|
||||
}
|
||||
|
||||
playAudio();
|
||||
globalThis.addEventListener('pointerdown', unlockAudio, { once: true });
|
||||
globalThis.addEventListener('keydown', unlockAudio, { once: true });
|
||||
|
||||
return () => {
|
||||
globalThis.removeEventListener('pointerdown', unlockAudio);
|
||||
globalThis.removeEventListener('keydown', unlockAudio);
|
||||
audio.pause();
|
||||
audioRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (audioRef.current == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
audioRef.current.volume = getEffectiveMusicVolume(
|
||||
masterVolume,
|
||||
musicVolume,
|
||||
audioMuted,
|
||||
);
|
||||
}, [audioMuted, masterVolume, musicVolume]);
|
||||
}
|
||||
@@ -1,55 +1,33 @@
|
||||
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;
|
||||
}
|
||||
import {
|
||||
getNodeEnvironment,
|
||||
getToolkitSocket,
|
||||
type ToolkitContentErrorPayload,
|
||||
type ToolkitContentPayload,
|
||||
} from '../socket/toolkitSocketClient';
|
||||
import { useToolkitContentStore, useToolkitSocketStore } from '../state';
|
||||
|
||||
export function useToolkitSocketConnection() {
|
||||
const setConnected = useToolkitSocketStore(state => state.setConnected);
|
||||
const setConnecting = useToolkitSocketStore(state => state.setConnecting);
|
||||
const setDisconnected = useToolkitSocketStore(state => state.setDisconnected);
|
||||
const refreshCredits = useToolkitContentStore(state => state.refreshCredits);
|
||||
const setContentError = useToolkitContentStore(state => state.setContentError);
|
||||
const setCreditsFromSocket = useToolkitContentStore(
|
||||
state => state.setCreditsFromSocket,
|
||||
);
|
||||
|
||||
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;
|
||||
const activeSocket = getToolkitSocket();
|
||||
|
||||
function handleConnect() {
|
||||
setConnected(activeSocket.id ?? 'unknown');
|
||||
refreshCredits();
|
||||
}
|
||||
|
||||
function handleConnectionAttempt() {
|
||||
@@ -60,8 +38,20 @@ export function useToolkitSocketConnection() {
|
||||
setDisconnected();
|
||||
}
|
||||
|
||||
function handleContentChanged(payload: ToolkitContentPayload) {
|
||||
if (payload.resource === 'credits') {
|
||||
setCreditsFromSocket(payload.content, payload.revision);
|
||||
}
|
||||
}
|
||||
|
||||
function handleContentError(payload: ToolkitContentErrorPayload) {
|
||||
setContentError(payload.error);
|
||||
}
|
||||
|
||||
activeSocket.on('connect', handleConnect);
|
||||
activeSocket.on('disconnect', handleDisconnect);
|
||||
activeSocket.on('toolkit:content:changed', handleContentChanged);
|
||||
activeSocket.on('toolkit:content:error', handleContentError);
|
||||
activeSocket.io.on('reconnect_attempt', handleConnectionAttempt);
|
||||
|
||||
if (activeSocket.connected) {
|
||||
@@ -74,7 +64,16 @@ export function useToolkitSocketConnection() {
|
||||
return () => {
|
||||
activeSocket.off('connect', handleConnect);
|
||||
activeSocket.off('disconnect', handleDisconnect);
|
||||
activeSocket.off('toolkit:content:changed', handleContentChanged);
|
||||
activeSocket.off('toolkit:content:error', handleContentError);
|
||||
activeSocket.io.off('reconnect_attempt', handleConnectionAttempt);
|
||||
};
|
||||
}, [setConnected, setConnecting, setDisconnected]);
|
||||
}, [
|
||||
refreshCredits,
|
||||
setConnected,
|
||||
setConnecting,
|
||||
setContentError,
|
||||
setCreditsFromSocket,
|
||||
setDisconnected,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -87,6 +87,10 @@ export const routeDefinitions = {
|
||||
label: 'Audio',
|
||||
title: 'Audio',
|
||||
},
|
||||
toolkitCredits: {
|
||||
label: 'Credits',
|
||||
title: 'Toolkit Credits',
|
||||
},
|
||||
toolkitEndings: {
|
||||
label: 'Endings',
|
||||
title: 'Failure & Endings',
|
||||
@@ -127,6 +131,7 @@ export const toolkitRoutes = [
|
||||
'toolkitStory',
|
||||
'toolkitLore',
|
||||
'toolkitAudio',
|
||||
'toolkitCredits',
|
||||
'toolkitEndings',
|
||||
'toolkitReferences',
|
||||
] as const satisfies readonly RouteName[];
|
||||
@@ -135,7 +140,6 @@ export type ToolkitRouteName = (typeof toolkitRoutes)[number];
|
||||
|
||||
const legacyToolkitRoutes = [
|
||||
'toolkitOpeningSequence',
|
||||
'toolkitCredits',
|
||||
] as const;
|
||||
|
||||
export function normalizeRouteName(routeName: unknown): RouteName {
|
||||
@@ -176,6 +180,7 @@ export const requiredPageRoutes = [
|
||||
'toolkitStory',
|
||||
'toolkitLore',
|
||||
'toolkitAudio',
|
||||
'toolkitCredits',
|
||||
'toolkitEndings',
|
||||
'toolkitReferences',
|
||||
] as const satisfies readonly RouteName[];
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import { ImageBackground, StyleSheet, Text, View } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
import homeBackgroundImage from '../assets/images/landing-screen-background-anime.png';
|
||||
import menuBackgroundImage from '../assets/images/menu-background-anime.png';
|
||||
import { MenuButton } from '../components/MenuButton';
|
||||
import { buildVersionLabel } from '../config/buildInfo';
|
||||
import {
|
||||
primaryMenuRoutes,
|
||||
routeDefinitions,
|
||||
type RouteName,
|
||||
} from '../navigation/routes';
|
||||
import { useGameSettingsStore, useToolkitSocketStore } from '../state';
|
||||
import { defaultTextStyle, displayTextStyle } from '../styles/typography';
|
||||
|
||||
type PrimaryMenuRouteName = (typeof primaryMenuRoutes)[number];
|
||||
|
||||
@@ -33,7 +37,10 @@ export function HomeScreen({ navigate }: HomeScreenProps) {
|
||||
);
|
||||
|
||||
return (
|
||||
<View
|
||||
<ImageBackground
|
||||
imageStyle={styles.backgroundImage}
|
||||
resizeMode="cover"
|
||||
source={homeBackgroundImage}
|
||||
style={[
|
||||
styles.container,
|
||||
{
|
||||
@@ -42,7 +49,9 @@ export function HomeScreen({ navigate }: HomeScreenProps) {
|
||||
paddingRight: safeAreaInsets.right,
|
||||
paddingTop: safeAreaInsets.top,
|
||||
},
|
||||
]}>
|
||||
]}
|
||||
testID="home-background">
|
||||
<View style={styles.scrim} />
|
||||
<View style={styles.content}>
|
||||
<View style={styles.copy}>
|
||||
<Text style={styles.eyebrow}>Bone Lord Bob</Text>
|
||||
@@ -51,27 +60,49 @@ export function HomeScreen({ navigate }: HomeScreenProps) {
|
||||
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>
|
||||
<ImageBackground
|
||||
imageStyle={styles.menuBackgroundImage}
|
||||
resizeMode="stretch"
|
||||
source={menuBackgroundImage}
|
||||
style={styles.menuFrame}
|
||||
testID="home-menu-background">
|
||||
<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>
|
||||
<Text style={styles.buildVersion} testID="home-build-version">
|
||||
{buildVersionLabel}
|
||||
</Text>
|
||||
</ImageBackground>
|
||||
</View>
|
||||
</View>
|
||||
</ImageBackground>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
backgroundImage: {
|
||||
opacity: 0.9,
|
||||
},
|
||||
buildVersion: {
|
||||
...defaultTextStyle,
|
||||
color: '#d6b25e',
|
||||
fontSize: 12,
|
||||
letterSpacing: 0,
|
||||
marginTop: 8,
|
||||
textAlign: 'center',
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
container: {
|
||||
backgroundColor: '#111315',
|
||||
flex: 1,
|
||||
@@ -84,6 +115,7 @@ const styles = StyleSheet.create({
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 22,
|
||||
paddingVertical: 18,
|
||||
zIndex: 1,
|
||||
},
|
||||
copy: {
|
||||
alignItems: 'flex-start',
|
||||
@@ -91,6 +123,7 @@ const styles = StyleSheet.create({
|
||||
minWidth: 0,
|
||||
},
|
||||
description: {
|
||||
...defaultTextStyle,
|
||||
color: '#c9c1b3',
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
@@ -98,6 +131,7 @@ const styles = StyleSheet.create({
|
||||
textAlign: 'left',
|
||||
},
|
||||
eyebrow: {
|
||||
...defaultTextStyle,
|
||||
color: '#d6b25e',
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
@@ -107,13 +141,28 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
menu: {
|
||||
gap: 8,
|
||||
marginLeft: 'auto',
|
||||
width: 156,
|
||||
},
|
||||
menuBackgroundImage: {
|
||||
opacity: 0.96,
|
||||
},
|
||||
menuFrame: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginLeft: 'auto',
|
||||
minHeight: 600,
|
||||
paddingHorizontal: 58,
|
||||
paddingVertical: 52,
|
||||
width: 332,
|
||||
},
|
||||
scrim: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: 'rgba(5, 6, 7, 0.48)',
|
||||
},
|
||||
title: {
|
||||
...displayTextStyle,
|
||||
color: '#f6f1e7',
|
||||
fontSize: 34,
|
||||
fontWeight: '800',
|
||||
letterSpacing: 0,
|
||||
lineHeight: 40,
|
||||
marginBottom: 16,
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type RouteName,
|
||||
type ToolkitRouteName,
|
||||
} from '../navigation/routes';
|
||||
import { defaultTextStyle } from '../styles/typography';
|
||||
|
||||
type PageRouteName = Exclude<
|
||||
RouteName,
|
||||
@@ -46,6 +47,7 @@ const styles = StyleSheet.create({
|
||||
marginTop: 12,
|
||||
},
|
||||
description: {
|
||||
...defaultTextStyle,
|
||||
color: '#c9c1b3',
|
||||
fontSize: 16,
|
||||
lineHeight: 24,
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
routeDefinitions,
|
||||
type RouteName,
|
||||
} from '../navigation/routes';
|
||||
import { defaultTextStyle } from '../styles/typography';
|
||||
|
||||
type PoliciesScreenProps = Readonly<{
|
||||
goHome: () => void;
|
||||
@@ -39,6 +40,7 @@ export function PoliciesScreen({ goHome, navigate }: PoliciesScreenProps) {
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
description: {
|
||||
...defaultTextStyle,
|
||||
color: '#c9c1b3',
|
||||
fontSize: 16,
|
||||
lineHeight: 24,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { PanResponder, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
import { BackHomeButton } from '../components/BackHomeButton';
|
||||
@@ -7,21 +7,28 @@ import {
|
||||
formatResolutionLabel,
|
||||
gameResolutionOptions,
|
||||
volumeSettings,
|
||||
clampVolume,
|
||||
type VolumeSettingKey,
|
||||
} from '../settings/gameSettings';
|
||||
import { useGameSettingsStore } from '../state';
|
||||
import { defaultTextStyle, displayTextStyle } from '../styles/typography';
|
||||
|
||||
type SettingsScreenProps = Readonly<{
|
||||
goHome: () => void;
|
||||
}>;
|
||||
|
||||
const sliderTrackWidth = 128;
|
||||
const sliderThumbSize = 16;
|
||||
|
||||
export function SettingsScreen({ goHome }: SettingsScreenProps) {
|
||||
const safeAreaInsets = useSafeAreaInsets();
|
||||
const [isResolutionSelectOpen, setIsResolutionSelectOpen] = useState(false);
|
||||
const audioMuted = useGameSettingsStore(state => state.audioMuted);
|
||||
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 toggleAudioMuted = useGameSettingsStore(state => state.toggleAudioMuted);
|
||||
const toggleAutosave = useGameSettingsStore(state => state.toggleAutosave);
|
||||
const volumes = useGameSettingsStore(state => state.volumes);
|
||||
|
||||
@@ -104,7 +111,20 @@ export function SettingsScreen({ goHome }: SettingsScreenProps) {
|
||||
</View>
|
||||
|
||||
<View style={styles.audioPanel}>
|
||||
<Text style={styles.sectionTitle}>Audio</Text>
|
||||
<View style={styles.audioHeader}>
|
||||
<Text style={styles.sectionTitle}>Audio</Text>
|
||||
<View style={styles.muteRow}>
|
||||
<Pressable
|
||||
accessibilityRole="checkbox"
|
||||
accessibilityState={{ checked: audioMuted }}
|
||||
onPress={toggleAudioMuted}
|
||||
style={styles.muteCheckbox}
|
||||
testID="audio-mute-checkbox">
|
||||
<Text style={styles.muteCheckboxMark}>{audioMuted ? '✓' : ''}</Text>
|
||||
</Pressable>
|
||||
<Text style={styles.muteLabel}>Mute</Text>
|
||||
</View>
|
||||
</View>
|
||||
{volumeSettings.map(setting => (
|
||||
<VolumeControl
|
||||
key={setting.key}
|
||||
@@ -166,23 +186,57 @@ function VolumeControl({
|
||||
settingKey,
|
||||
value,
|
||||
}: VolumeControlProps) {
|
||||
const updateVolumeFromLocation = useCallback(
|
||||
(locationX: number) => {
|
||||
const sliderLocation = Math.min(sliderTrackWidth, Math.max(0, locationX));
|
||||
const nextVolume = clampVolume(
|
||||
Math.round((sliderLocation / sliderTrackWidth) * 100),
|
||||
);
|
||||
onChange(nextVolume);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
const panResponder = useMemo(
|
||||
() =>
|
||||
PanResponder.create({
|
||||
onMoveShouldSetPanResponder: () => true,
|
||||
onPanResponderGrant: event => {
|
||||
updateVolumeFromLocation(event.nativeEvent.locationX);
|
||||
},
|
||||
onPanResponderMove: event => {
|
||||
updateVolumeFromLocation(event.nativeEvent.locationX);
|
||||
},
|
||||
onStartShouldSetPanResponder: () => true,
|
||||
}),
|
||||
[updateVolumeFromLocation],
|
||||
);
|
||||
const fillWidth = `${value}%`;
|
||||
const thumbOffset = (value / 100) * sliderTrackWidth - sliderThumbSize / 2;
|
||||
|
||||
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`}
|
||||
/>
|
||||
<View
|
||||
accessibilityLabel={`${label} ${value}`}
|
||||
accessibilityRole="adjustable"
|
||||
style={styles.volumeSlider}
|
||||
testID={`volume-${settingKey}-slider`}
|
||||
{...panResponder.panHandlers}>
|
||||
<View style={styles.volumeTrack} testID={`volume-${settingKey}-track`}>
|
||||
<View
|
||||
style={[styles.volumeTrackFill, { width: fillWidth }]}
|
||||
testID={`volume-${settingKey}-fill`}
|
||||
/>
|
||||
</View>
|
||||
<View
|
||||
style={[styles.volumeThumb, { left: thumbOffset }]}
|
||||
testID={`volume-${settingKey}-thumb`}
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.volumeValue} testID={`volume-${settingKey}-value`}>
|
||||
{value}
|
||||
</Text>
|
||||
<SettingButton
|
||||
label="+"
|
||||
onPress={() => onChange(value + 5)}
|
||||
testID={`volume-${settingKey}-increase`}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
@@ -194,9 +248,14 @@ const styles = StyleSheet.create({
|
||||
borderColor: '#37342d',
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
gap: 10,
|
||||
gap: 8,
|
||||
padding: 12,
|
||||
width: 268,
|
||||
width: 286,
|
||||
},
|
||||
audioHeader: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
container: {
|
||||
alignItems: 'stretch',
|
||||
@@ -206,6 +265,7 @@ const styles = StyleSheet.create({
|
||||
gap: 14,
|
||||
},
|
||||
eyebrow: {
|
||||
...defaultTextStyle,
|
||||
color: '#d6b25e',
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
@@ -218,6 +278,7 @@ const styles = StyleSheet.create({
|
||||
width: 190,
|
||||
},
|
||||
helperText: {
|
||||
...defaultTextStyle,
|
||||
color: '#c9c1b3',
|
||||
fontSize: 12,
|
||||
lineHeight: 16,
|
||||
@@ -239,17 +300,19 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
},
|
||||
checkboxHint: {
|
||||
...defaultTextStyle,
|
||||
color: '#c9c1b3',
|
||||
fontSize: 11,
|
||||
lineHeight: 14,
|
||||
},
|
||||
checkboxLabel: {
|
||||
...displayTextStyle,
|
||||
color: '#f6f1e7',
|
||||
fontSize: 13,
|
||||
fontWeight: '800',
|
||||
letterSpacing: 0,
|
||||
},
|
||||
checkboxMark: {
|
||||
...defaultTextStyle,
|
||||
color: '#f5d98d',
|
||||
fontSize: 18,
|
||||
fontWeight: '800',
|
||||
@@ -260,6 +323,36 @@ const styles = StyleSheet.create({
|
||||
flexDirection: 'row',
|
||||
gap: 10,
|
||||
},
|
||||
muteCheckbox: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#24272b',
|
||||
borderColor: '#d6b25e',
|
||||
borderRadius: 4,
|
||||
borderWidth: 1,
|
||||
height: 20,
|
||||
justifyContent: 'center',
|
||||
width: 20,
|
||||
},
|
||||
muteCheckboxMark: {
|
||||
...defaultTextStyle,
|
||||
color: '#f5d98d',
|
||||
fontSize: 14,
|
||||
fontWeight: '800',
|
||||
lineHeight: 18,
|
||||
},
|
||||
muteLabel: {
|
||||
...defaultTextStyle,
|
||||
color: '#f6f1e7',
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 0,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
muteRow: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
gap: 6,
|
||||
},
|
||||
resolutionPanel: {
|
||||
backgroundColor: '#191b1f',
|
||||
borderColor: '#37342d',
|
||||
@@ -272,9 +365,9 @@ const styles = StyleSheet.create({
|
||||
zIndex: 2,
|
||||
},
|
||||
sectionTitle: {
|
||||
...displayTextStyle,
|
||||
color: '#f6f1e7',
|
||||
fontSize: 16,
|
||||
fontWeight: '800',
|
||||
letterSpacing: 0,
|
||||
},
|
||||
settingButton: {
|
||||
@@ -294,9 +387,9 @@ const styles = StyleSheet.create({
|
||||
borderColor: '#d6b25e',
|
||||
},
|
||||
settingButtonLabel: {
|
||||
...displayTextStyle,
|
||||
color: '#f6f1e7',
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 0,
|
||||
textAlign: 'center',
|
||||
},
|
||||
@@ -324,14 +417,15 @@ const styles = StyleSheet.create({
|
||||
zIndex: 3,
|
||||
},
|
||||
summary: {
|
||||
...defaultTextStyle,
|
||||
color: '#c9c1b3',
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
},
|
||||
title: {
|
||||
...displayTextStyle,
|
||||
color: '#f6f1e7',
|
||||
fontSize: 30,
|
||||
fontWeight: '800',
|
||||
letterSpacing: 0,
|
||||
lineHeight: 34,
|
||||
marginBottom: 12,
|
||||
@@ -339,9 +433,10 @@ const styles = StyleSheet.create({
|
||||
volumeControls: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
gap: 10,
|
||||
},
|
||||
volumeLabel: {
|
||||
...defaultTextStyle,
|
||||
color: '#f6f1e7',
|
||||
flex: 1,
|
||||
fontSize: 13,
|
||||
@@ -351,10 +446,38 @@ const styles = StyleSheet.create({
|
||||
volumeRow: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
gap: 10,
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
volumeSlider: {
|
||||
height: 24,
|
||||
justifyContent: 'center',
|
||||
width: sliderTrackWidth,
|
||||
},
|
||||
volumeThumb: {
|
||||
backgroundColor: '#f5d98d',
|
||||
borderColor: '#6f5422',
|
||||
borderRadius: sliderThumbSize / 2,
|
||||
borderWidth: 1,
|
||||
height: sliderThumbSize,
|
||||
position: 'absolute',
|
||||
width: sliderThumbSize,
|
||||
},
|
||||
volumeTrack: {
|
||||
backgroundColor: '#24272b',
|
||||
borderColor: '#4d4432',
|
||||
borderRadius: 4,
|
||||
borderWidth: 1,
|
||||
height: 8,
|
||||
overflow: 'hidden',
|
||||
width: sliderTrackWidth,
|
||||
},
|
||||
volumeTrackFill: {
|
||||
backgroundColor: '#d6b25e',
|
||||
height: '100%',
|
||||
},
|
||||
volumeValue: {
|
||||
...defaultTextStyle,
|
||||
color: '#c9c1b3',
|
||||
fontSize: 13,
|
||||
fontWeight: '700',
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
import { buildVersionLabel } from '../config/buildInfo';
|
||||
import type { CreditsContent } from '../data';
|
||||
import {
|
||||
routeDefinitions,
|
||||
toolkitRoutes,
|
||||
type RouteName,
|
||||
type ToolkitRouteName,
|
||||
} from '../navigation/routes';
|
||||
import { useToolkitSocketStore, type ToolkitSocketStatus } from '../state';
|
||||
import {
|
||||
useToolkitContentStore,
|
||||
useToolkitSocketStore,
|
||||
type ToolkitContentStatus,
|
||||
type ToolkitSocketStatus,
|
||||
} from '../state';
|
||||
import { defaultTextStyle, displayTextStyle } from '../styles/typography';
|
||||
|
||||
type ToolkitScreenProps = Readonly<{
|
||||
goHome: () => void;
|
||||
@@ -52,6 +60,21 @@ const toolkitSections: Record<ToolkitRouteName, ToolkitSectionContent> = {
|
||||
'Preview and catalog sound identity tied to throne room, map, petitioner, BLB voice, and comedy contexts.',
|
||||
sources: ['Music', 'Gameplay', 'References'],
|
||||
},
|
||||
toolkitCredits: {
|
||||
futureFields: [
|
||||
'contributor names',
|
||||
'roles',
|
||||
'asset attribution',
|
||||
'font licenses',
|
||||
'music credits',
|
||||
'special thanks',
|
||||
],
|
||||
liableToChange:
|
||||
'Credits will change whenever art, audio, fonts, tooling, collaborators, or external inspirations are added.',
|
||||
purpose:
|
||||
'Track contributor credits, asset attribution, licenses, and acknowledgement copy for the game and toolkit.',
|
||||
sources: ['Credits', 'References', 'Home'],
|
||||
},
|
||||
toolkitEndings: {
|
||||
futureFields: [
|
||||
'resource collapse',
|
||||
@@ -242,6 +265,7 @@ const toolkitSections: Record<ToolkitRouteName, ToolkitSectionContent> = {
|
||||
|
||||
const toolkitIcons: Record<ToolkitRouteName, string> = {
|
||||
toolkitAudio: '♪',
|
||||
toolkitCredits: '☆',
|
||||
toolkitEndings: '!',
|
||||
toolkitFactions: '◇',
|
||||
toolkitFlags: '#',
|
||||
@@ -262,6 +286,14 @@ const socketStatusCopy: Record<ToolkitSocketStatus, string> = {
|
||||
disconnected: 'Disconnected',
|
||||
};
|
||||
|
||||
const creditsStatusCopy: Record<ToolkitContentStatus, string> = {
|
||||
error: 'Using fallback credits',
|
||||
idle: 'Bundled fallback credits',
|
||||
loading: 'Loading credits',
|
||||
ready: 'Live credits synced',
|
||||
saving: 'Saving credits',
|
||||
};
|
||||
|
||||
export function ToolkitScreen({
|
||||
goHome,
|
||||
navigate,
|
||||
@@ -269,8 +301,13 @@ export function ToolkitScreen({
|
||||
}: ToolkitScreenProps) {
|
||||
const safeAreaInsets = useSafeAreaInsets();
|
||||
const activeRoute = routeDefinitions[routeName];
|
||||
const credits = useToolkitContentStore(state => state.credits);
|
||||
const creditsError = useToolkitContentStore(state => state.error);
|
||||
const creditsRevision = useToolkitContentStore(state => state.revision);
|
||||
const creditsStatus = useToolkitContentStore(state => state.status);
|
||||
const socketStatus = useToolkitSocketStore(state => state.status);
|
||||
const toolkitContent = getToolkitContent(routeName);
|
||||
const isCreditsRoute = routeName === 'toolkitCredits';
|
||||
|
||||
return (
|
||||
<View
|
||||
@@ -312,42 +349,54 @@ export function ToolkitScreen({
|
||||
<Text style={styles.eyebrow}>Bone Lord Bob</Text>
|
||||
<Text style={styles.title}>{activeRoute.title}</Text>
|
||||
<Text style={styles.readOnlyLabel}>Read-only planning panel</Text>
|
||||
<View style={styles.detailGrid}>
|
||||
<ToolkitDetail
|
||||
label="Purpose"
|
||||
testID="toolkit-purpose"
|
||||
value={toolkitContent.purpose}
|
||||
{isCreditsRoute ? (
|
||||
<ToolkitCreditsCard
|
||||
credits={credits}
|
||||
error={creditsError}
|
||||
revision={creditsRevision}
|
||||
socketStatus={socketStatus}
|
||||
status={creditsStatus}
|
||||
/>
|
||||
<ToolkitDetail
|
||||
label="Liable to change because"
|
||||
testID="toolkit-change-note"
|
||||
value={toolkitContent.liableToChange}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.sourcePanel} testID="toolkit-sources">
|
||||
<Text style={styles.detailLabel}>Source wiki pages</Text>
|
||||
<View style={styles.sourceRow}>
|
||||
{toolkitContent.sources.map(source => (
|
||||
<Text key={source} style={styles.sourcePill}>
|
||||
{source}
|
||||
) : (
|
||||
<>
|
||||
<View style={styles.detailGrid}>
|
||||
<ToolkitDetail
|
||||
label="Purpose"
|
||||
testID="toolkit-purpose"
|
||||
value={toolkitContent.purpose}
|
||||
/>
|
||||
<ToolkitDetail
|
||||
label="Liable to change because"
|
||||
testID="toolkit-change-note"
|
||||
value={toolkitContent.liableToChange}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.sourcePanel} testID="toolkit-sources">
|
||||
<Text style={styles.detailLabel}>Source wiki pages</Text>
|
||||
<View style={styles.sourceRow}>
|
||||
{toolkitContent.sources.map(source => (
|
||||
<Text key={source} style={styles.sourcePill}>
|
||||
{source}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
<ToolkitDetail
|
||||
label="Anticipated future fields / entities"
|
||||
testID="toolkit-future-fields"
|
||||
value={toolkitContent.futureFields.join(', ')}
|
||||
/>
|
||||
<View
|
||||
style={styles.placeholderPanel}
|
||||
testID="toolkit-future-placeholder">
|
||||
<Text style={styles.placeholderTitle}>Future authoring area</Text>
|
||||
<Text style={styles.placeholderCopy}>
|
||||
Forms, tables, previews, and schema-backed validation will mount
|
||||
here after the JSON and Markdown content formats stabilize.
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
<ToolkitDetail
|
||||
label="Anticipated future fields / entities"
|
||||
testID="toolkit-future-fields"
|
||||
value={toolkitContent.futureFields.join(', ')}
|
||||
/>
|
||||
<View
|
||||
style={styles.placeholderPanel}
|
||||
testID="toolkit-future-placeholder">
|
||||
<Text style={styles.placeholderTitle}>Future authoring area</Text>
|
||||
<Text style={styles.placeholderCopy}>
|
||||
Forms, tables, previews, and schema-backed validation will mount
|
||||
here after the JSON and Markdown content formats stabilize.
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
<ToolkitSocketIndicator status={socketStatus} />
|
||||
</View>
|
||||
</View>
|
||||
@@ -362,6 +411,93 @@ function getToolkitContent(routeName: RouteName) {
|
||||
return toolkitOverview;
|
||||
}
|
||||
|
||||
type ToolkitCreditsCardProps = Readonly<{
|
||||
credits: CreditsContent;
|
||||
error: string | null;
|
||||
revision: string | null;
|
||||
socketStatus: ToolkitSocketStatus;
|
||||
status: ToolkitContentStatus;
|
||||
}>;
|
||||
|
||||
function ToolkitCreditsCard({
|
||||
credits,
|
||||
error,
|
||||
revision,
|
||||
socketStatus,
|
||||
status,
|
||||
}: ToolkitCreditsCardProps) {
|
||||
const syncCopy =
|
||||
socketStatus === 'connected'
|
||||
? creditsStatusCopy[status]
|
||||
: 'Bundled fallback credits';
|
||||
const revisionCopy = revision == null ? 'No live revision' : `Revision ${revision}`;
|
||||
|
||||
return (
|
||||
<View style={styles.creditsCard} testID="toolkit-credits-card">
|
||||
<View style={styles.creditsHero}>
|
||||
<View style={styles.creditsLogoMark} testID="toolkit-credits-logo-mark">
|
||||
<Text style={styles.creditsLogoInitials}>BLB</Text>
|
||||
</View>
|
||||
<View style={styles.creditsHeroCopy}>
|
||||
<Text style={styles.creditsTitle}>{credits.mainLogo.title}</Text>
|
||||
<Text style={styles.creditsSubtitle}>{credits.mainLogo.subtitle}</Text>
|
||||
</View>
|
||||
<View style={styles.creditsBuildBadge} testID="toolkit-credits-build">
|
||||
<Text style={styles.creditsBuildLabel}>{buildVersionLabel}</Text>
|
||||
<Text style={styles.creditsSyncLabel}>{syncCopy}</Text>
|
||||
<Text style={styles.creditsRevisionLabel}>{revisionCopy}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{error == null ? null : (
|
||||
<Text style={styles.creditsError} testID="toolkit-credits-error">
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<View style={styles.creditsGrid}>
|
||||
<View style={styles.creditsSection} testID="toolkit-credits-contributors">
|
||||
<Text style={styles.creditsSectionTitle}>Contributors</Text>
|
||||
{credits.contributors.map(contributor => (
|
||||
<View key={`${contributor.name}-${contributor.role}`}>
|
||||
<Text style={styles.creditsItemTitle}>{contributor.name}</Text>
|
||||
<Text style={styles.creditsItemMeta}>{contributor.role}</Text>
|
||||
{contributor.credit == null ? null : (
|
||||
<Text style={styles.creditsItemCopy}>{contributor.credit}</Text>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View style={styles.creditsSection} testID="toolkit-credits-technologies">
|
||||
<Text style={styles.creditsSectionTitle}>Technologies</Text>
|
||||
<View style={styles.creditsPillGrid}>
|
||||
{credits.technologies.map(technology => (
|
||||
<View key={technology.name} style={styles.creditsTechPill}>
|
||||
<Text style={styles.creditsTechName}>{technology.name}</Text>
|
||||
{technology.role == null ? null : (
|
||||
<Text style={styles.creditsTechRole}>{technology.role}</Text>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.creditsThanks} testID="toolkit-credits-thanks">
|
||||
<Text style={styles.creditsSectionTitle}>Special Thanks</Text>
|
||||
<View style={styles.creditsThanksRow}>
|
||||
{credits.specialThanks.map(thanks => (
|
||||
<Text key={thanks} style={styles.creditsThanksItem}>
|
||||
{thanks}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
type ToolkitDetailProps = Readonly<{
|
||||
label: string;
|
||||
testID: string;
|
||||
@@ -468,12 +604,179 @@ const styles = StyleSheet.create({
|
||||
padding: 14,
|
||||
position: 'relative',
|
||||
},
|
||||
creditsBuildBadge: {
|
||||
alignItems: 'flex-end',
|
||||
backgroundColor: '#24272b',
|
||||
borderColor: '#4d4432',
|
||||
borderRadius: 6,
|
||||
borderWidth: 1,
|
||||
marginLeft: 'auto',
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
creditsBuildLabel: {
|
||||
...displayTextStyle,
|
||||
color: '#f5d98d',
|
||||
fontSize: 12,
|
||||
letterSpacing: 0,
|
||||
},
|
||||
creditsCard: {
|
||||
backgroundColor: '#131518',
|
||||
borderColor: '#4d4432',
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
flex: 1,
|
||||
gap: 10,
|
||||
padding: 12,
|
||||
},
|
||||
creditsError: {
|
||||
...defaultTextStyle,
|
||||
color: '#d85a4d',
|
||||
fontSize: 11,
|
||||
lineHeight: 14,
|
||||
},
|
||||
creditsGrid: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
gap: 10,
|
||||
},
|
||||
creditsHero: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
},
|
||||
creditsHeroCopy: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
creditsItemCopy: {
|
||||
...defaultTextStyle,
|
||||
color: '#80796c',
|
||||
fontSize: 11,
|
||||
lineHeight: 14,
|
||||
},
|
||||
creditsItemMeta: {
|
||||
...defaultTextStyle,
|
||||
color: '#d6b25e',
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 0,
|
||||
lineHeight: 14,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
creditsItemTitle: {
|
||||
...displayTextStyle,
|
||||
color: '#f6f1e7',
|
||||
fontSize: 14,
|
||||
letterSpacing: 0,
|
||||
},
|
||||
creditsLogoInitials: {
|
||||
...displayTextStyle,
|
||||
color: '#f5d98d',
|
||||
fontSize: 18,
|
||||
letterSpacing: 0,
|
||||
},
|
||||
creditsLogoMark: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#3a2918',
|
||||
borderColor: '#d6b25e',
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
height: 48,
|
||||
justifyContent: 'center',
|
||||
width: 58,
|
||||
},
|
||||
creditsRevisionLabel: {
|
||||
...defaultTextStyle,
|
||||
color: '#80796c',
|
||||
fontSize: 10,
|
||||
lineHeight: 13,
|
||||
},
|
||||
creditsSection: {
|
||||
backgroundColor: '#191b1f',
|
||||
borderColor: '#2d2f32',
|
||||
borderRadius: 6,
|
||||
borderWidth: 1,
|
||||
flex: 1,
|
||||
gap: 7,
|
||||
padding: 10,
|
||||
},
|
||||
creditsSectionTitle: {
|
||||
...defaultTextStyle,
|
||||
color: '#d6b25e',
|
||||
fontSize: 10,
|
||||
fontWeight: '800',
|
||||
letterSpacing: 0,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
creditsSubtitle: {
|
||||
...defaultTextStyle,
|
||||
color: '#c9c1b3',
|
||||
fontSize: 12,
|
||||
lineHeight: 16,
|
||||
},
|
||||
creditsSyncLabel: {
|
||||
...defaultTextStyle,
|
||||
color: '#c9c1b3',
|
||||
fontSize: 10,
|
||||
lineHeight: 13,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
creditsTechName: {
|
||||
...displayTextStyle,
|
||||
color: '#f6f1e7',
|
||||
fontSize: 11,
|
||||
letterSpacing: 0,
|
||||
},
|
||||
creditsTechPill: {
|
||||
backgroundColor: '#24272b',
|
||||
borderColor: '#4d4432',
|
||||
borderRadius: 4,
|
||||
borderWidth: 1,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 5,
|
||||
},
|
||||
creditsTechRole: {
|
||||
...defaultTextStyle,
|
||||
color: '#c9c1b3',
|
||||
fontSize: 10,
|
||||
lineHeight: 13,
|
||||
},
|
||||
creditsThanks: {
|
||||
backgroundColor: '#191b1f',
|
||||
borderColor: '#2d2f32',
|
||||
borderRadius: 6,
|
||||
borderWidth: 1,
|
||||
gap: 7,
|
||||
padding: 10,
|
||||
},
|
||||
creditsThanksItem: {
|
||||
...defaultTextStyle,
|
||||
color: '#c9c1b3',
|
||||
flex: 1,
|
||||
fontSize: 11,
|
||||
lineHeight: 14,
|
||||
minWidth: 150,
|
||||
},
|
||||
creditsThanksRow: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 8,
|
||||
},
|
||||
creditsTitle: {
|
||||
...displayTextStyle,
|
||||
color: '#f6f1e7',
|
||||
fontSize: 24,
|
||||
letterSpacing: 0,
|
||||
lineHeight: 28,
|
||||
},
|
||||
detailGrid: {
|
||||
flexDirection: 'row',
|
||||
gap: 10,
|
||||
marginBottom: 10,
|
||||
},
|
||||
detailLabel: {
|
||||
...defaultTextStyle,
|
||||
color: '#d6b25e',
|
||||
fontSize: 10,
|
||||
fontWeight: '800',
|
||||
@@ -490,11 +793,13 @@ const styles = StyleSheet.create({
|
||||
padding: 10,
|
||||
},
|
||||
detailValue: {
|
||||
...defaultTextStyle,
|
||||
color: '#c9c1b3',
|
||||
fontSize: 12,
|
||||
lineHeight: 17,
|
||||
},
|
||||
eyebrow: {
|
||||
...defaultTextStyle,
|
||||
color: '#d6b25e',
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
@@ -503,14 +808,15 @@ const styles = StyleSheet.create({
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
title: {
|
||||
...displayTextStyle,
|
||||
color: '#f6f1e7',
|
||||
fontSize: 30,
|
||||
fontWeight: '800',
|
||||
letterSpacing: 0,
|
||||
lineHeight: 34,
|
||||
marginBottom: 12,
|
||||
},
|
||||
placeholderCopy: {
|
||||
...defaultTextStyle,
|
||||
color: '#c9c1b3',
|
||||
fontSize: 12,
|
||||
lineHeight: 17,
|
||||
@@ -525,13 +831,14 @@ const styles = StyleSheet.create({
|
||||
padding: 10,
|
||||
},
|
||||
placeholderTitle: {
|
||||
...displayTextStyle,
|
||||
color: '#f6f1e7',
|
||||
fontSize: 12,
|
||||
fontWeight: '800',
|
||||
letterSpacing: 0,
|
||||
marginBottom: 4,
|
||||
},
|
||||
readOnlyLabel: {
|
||||
...defaultTextStyle,
|
||||
color: '#80796c',
|
||||
fontSize: 11,
|
||||
fontWeight: '800',
|
||||
@@ -548,6 +855,7 @@ const styles = StyleSheet.create({
|
||||
padding: 10,
|
||||
},
|
||||
sourcePill: {
|
||||
...defaultTextStyle,
|
||||
backgroundColor: '#2a261d',
|
||||
borderColor: '#4d4432',
|
||||
borderRadius: 4,
|
||||
@@ -578,6 +886,7 @@ const styles = StyleSheet.create({
|
||||
right: 12,
|
||||
},
|
||||
socketLabel: {
|
||||
...defaultTextStyle,
|
||||
color: '#c9c1b3',
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
@@ -626,9 +935,9 @@ const styles = StyleSheet.create({
|
||||
paddingHorizontal: 9,
|
||||
},
|
||||
toolbarButtonLabel: {
|
||||
...displayTextStyle,
|
||||
color: '#f6f1e7',
|
||||
fontSize: 10,
|
||||
fontWeight: '800',
|
||||
letterSpacing: 0,
|
||||
textAlign: 'center',
|
||||
},
|
||||
|
||||
@@ -39,19 +39,23 @@ export const defaultAutosaveEnabled = true;
|
||||
export const volumeSettings = [
|
||||
{
|
||||
key: 'master',
|
||||
label: 'Master Volume',
|
||||
label: 'Master',
|
||||
},
|
||||
{
|
||||
key: 'music',
|
||||
label: 'Music Volume',
|
||||
label: 'Music',
|
||||
},
|
||||
{
|
||||
key: 'ambience',
|
||||
label: 'Ambience Volume',
|
||||
label: 'Ambience',
|
||||
},
|
||||
{
|
||||
key: 'speech',
|
||||
label: 'Speech Volume',
|
||||
label: 'Speech',
|
||||
},
|
||||
{
|
||||
key: 'sfx',
|
||||
label: 'SFX',
|
||||
},
|
||||
] as const;
|
||||
|
||||
@@ -63,6 +67,7 @@ export const defaultVolumeSettings: VolumeSettings = {
|
||||
ambience: 70,
|
||||
master: 80,
|
||||
music: 70,
|
||||
sfx: 80,
|
||||
speech: 80,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { io, type Socket } from 'socket.io-client';
|
||||
|
||||
import { isCreditsContent, type CreditsContent } from '../data';
|
||||
|
||||
type NodeProcess = {
|
||||
env?: Partial<Record<string, string>>;
|
||||
};
|
||||
|
||||
export type ToolkitContentResource = 'credits';
|
||||
|
||||
export type ToolkitContentPayload = Readonly<{
|
||||
content: CreditsContent;
|
||||
resource: ToolkitContentResource;
|
||||
revision: string;
|
||||
}>;
|
||||
|
||||
export type ToolkitContentErrorPayload = Readonly<{
|
||||
error: string;
|
||||
resource: ToolkitContentResource;
|
||||
}>;
|
||||
|
||||
type ToolkitContentSuccessResponse = ToolkitContentPayload &
|
||||
Readonly<{
|
||||
ok: true;
|
||||
}>;
|
||||
|
||||
type ToolkitContentErrorResponse = ToolkitContentErrorPayload &
|
||||
Readonly<{
|
||||
ok: false;
|
||||
}>;
|
||||
|
||||
type ToolkitContentResponse =
|
||||
| ToolkitContentSuccessResponse
|
||||
| ToolkitContentErrorResponse;
|
||||
|
||||
const defaultToolkitSocketUrl = 'http://127.0.0.1:5174';
|
||||
const toolkitSocketTimeoutMs = 5000;
|
||||
|
||||
let toolkitSocket: Socket | null = null;
|
||||
|
||||
function getMaybeProcess() {
|
||||
return typeof process === 'undefined' ? undefined : (process as NodeProcess);
|
||||
}
|
||||
|
||||
export function getToolkitSocketUrl() {
|
||||
return (
|
||||
getMaybeProcess()?.env?.TOOLKIT_SOCKET_URL ?? defaultToolkitSocketUrl
|
||||
);
|
||||
}
|
||||
|
||||
export function getNodeEnvironment() {
|
||||
return getMaybeProcess()?.env?.NODE_ENV;
|
||||
}
|
||||
|
||||
export function getToolkitSocket() {
|
||||
if (toolkitSocket == null) {
|
||||
toolkitSocket = io(getToolkitSocketUrl(), {
|
||||
autoConnect: false,
|
||||
reconnection: true,
|
||||
reconnectionAttempts: Number.POSITIVE_INFINITY,
|
||||
reconnectionDelay: 750,
|
||||
transports: ['websocket'],
|
||||
});
|
||||
}
|
||||
|
||||
return toolkitSocket;
|
||||
}
|
||||
|
||||
function isToolkitContentResource(value: unknown): value is ToolkitContentResource {
|
||||
return value === 'credits';
|
||||
}
|
||||
|
||||
function isToolkitContentPayload(value: unknown): value is ToolkitContentPayload {
|
||||
if (value == null || typeof value !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const payload = value as Partial<ToolkitContentPayload>;
|
||||
|
||||
return (
|
||||
isToolkitContentResource(payload.resource) &&
|
||||
typeof payload.revision === 'string' &&
|
||||
isCreditsContent(payload.content)
|
||||
);
|
||||
}
|
||||
|
||||
function parseToolkitContentResponse(
|
||||
response: unknown,
|
||||
): ToolkitContentPayload {
|
||||
if (response == null || typeof response !== 'object') {
|
||||
throw new Error('Toolkit content response was empty.');
|
||||
}
|
||||
|
||||
const contentResponse = response as Partial<ToolkitContentResponse>;
|
||||
|
||||
if (contentResponse.ok === false) {
|
||||
throw new Error(contentResponse.error ?? 'Toolkit content request failed.');
|
||||
}
|
||||
|
||||
if (contentResponse.ok === true && isToolkitContentPayload(contentResponse)) {
|
||||
return contentResponse;
|
||||
}
|
||||
|
||||
throw new Error('Toolkit content response was invalid.');
|
||||
}
|
||||
|
||||
async function emitContentRequest(
|
||||
eventName: 'toolkit:content:read' | 'toolkit:content:save',
|
||||
payload: Readonly<{
|
||||
content?: CreditsContent;
|
||||
resource: ToolkitContentResource;
|
||||
}>,
|
||||
) {
|
||||
const socket = getToolkitSocket();
|
||||
|
||||
if (!socket.connected) {
|
||||
throw new Error('Toolkit socket is not connected.');
|
||||
}
|
||||
|
||||
const response = await socket
|
||||
.timeout(toolkitSocketTimeoutMs)
|
||||
.emitWithAck(eventName, payload);
|
||||
|
||||
return parseToolkitContentResponse(response);
|
||||
}
|
||||
|
||||
export function readToolkitContent(resource: ToolkitContentResource) {
|
||||
return emitContentRequest('toolkit:content:read', { resource });
|
||||
}
|
||||
|
||||
export function saveToolkitContent(
|
||||
resource: ToolkitContentResource,
|
||||
content: CreditsContent,
|
||||
) {
|
||||
return emitContentRequest('toolkit:content:save', {
|
||||
content,
|
||||
resource,
|
||||
});
|
||||
}
|
||||
|
||||
export function resetToolkitSocketForTests() {
|
||||
toolkitSocket?.disconnect();
|
||||
toolkitSocket = null;
|
||||
}
|
||||
@@ -13,29 +13,63 @@ import {
|
||||
import { createPersistOptions } from './createPersistOptions';
|
||||
|
||||
type PersistedGameSettingsState = {
|
||||
audioMuted: boolean;
|
||||
autosaveEnabled: boolean;
|
||||
resolutionId: GameResolutionId;
|
||||
volumes: VolumeSettings;
|
||||
};
|
||||
|
||||
type GameSettingsState = PersistedGameSettingsState & {
|
||||
setAudioMuted: (audioMuted: boolean) => void;
|
||||
setAutosaveEnabled: (autosaveEnabled: boolean) => void;
|
||||
setResolution: (resolutionId: GameResolutionId) => void;
|
||||
toggleAudioMuted: () => void;
|
||||
toggleAutosave: () => void;
|
||||
setVolume: (key: VolumeSettingKey, volume: number) => void;
|
||||
};
|
||||
|
||||
function isPersistedGameSettingsState(
|
||||
state: unknown,
|
||||
): state is Partial<PersistedGameSettingsState> {
|
||||
return state != null && typeof state === 'object';
|
||||
}
|
||||
|
||||
function mergeVolumeSettings(volumes: unknown): VolumeSettings {
|
||||
if (volumes == null || typeof volumes !== 'object') {
|
||||
return defaultVolumeSettings;
|
||||
}
|
||||
|
||||
const persistedVolumes = volumes as Partial<Record<VolumeSettingKey, number>>;
|
||||
|
||||
return {
|
||||
ambience: clampVolume(
|
||||
persistedVolumes.ambience ?? defaultVolumeSettings.ambience,
|
||||
),
|
||||
master: clampVolume(persistedVolumes.master ?? defaultVolumeSettings.master),
|
||||
music: clampVolume(persistedVolumes.music ?? defaultVolumeSettings.music),
|
||||
sfx: clampVolume(persistedVolumes.sfx ?? defaultVolumeSettings.sfx),
|
||||
speech: clampVolume(persistedVolumes.speech ?? defaultVolumeSettings.speech),
|
||||
};
|
||||
}
|
||||
|
||||
export const useGameSettingsStore = create<GameSettingsState>()(
|
||||
persist(
|
||||
set => ({
|
||||
audioMuted: false,
|
||||
autosaveEnabled: defaultAutosaveEnabled,
|
||||
resolutionId: defaultGameResolutionId,
|
||||
setAudioMuted: (audioMuted: boolean) => {
|
||||
set({ audioMuted });
|
||||
},
|
||||
setAutosaveEnabled: (autosaveEnabled: boolean) => {
|
||||
set({ autosaveEnabled });
|
||||
},
|
||||
setResolution: (resolutionId: GameResolutionId) => {
|
||||
set({ resolutionId });
|
||||
},
|
||||
toggleAudioMuted: () => {
|
||||
set(state => ({ audioMuted: !state.audioMuted }));
|
||||
},
|
||||
toggleAutosave: () => {
|
||||
set(state => ({ autosaveEnabled: !state.autosaveEnabled }));
|
||||
},
|
||||
@@ -50,8 +84,23 @@ export const useGameSettingsStore = create<GameSettingsState>()(
|
||||
volumes: defaultVolumeSettings,
|
||||
}),
|
||||
createPersistOptions<GameSettingsState, PersistedGameSettingsState>({
|
||||
merge: (persistedState, currentState) => {
|
||||
if (!isPersistedGameSettingsState(persistedState)) {
|
||||
return currentState;
|
||||
}
|
||||
|
||||
return {
|
||||
...currentState,
|
||||
audioMuted: persistedState.audioMuted ?? currentState.audioMuted,
|
||||
autosaveEnabled:
|
||||
persistedState.autosaveEnabled ?? currentState.autosaveEnabled,
|
||||
resolutionId: persistedState.resolutionId ?? currentState.resolutionId,
|
||||
volumes: mergeVolumeSettings(persistedState.volumes),
|
||||
};
|
||||
},
|
||||
name: 'blb-game-settings',
|
||||
partialize: state => ({
|
||||
audioMuted: state.audioMuted,
|
||||
autosaveEnabled: state.autosaveEnabled,
|
||||
resolutionId: state.resolutionId,
|
||||
volumes: state.volumes,
|
||||
|
||||
@@ -5,6 +5,10 @@ export {
|
||||
export { useGameSettingsStore } from './gameSettingsStore';
|
||||
export { createMemoryStateStorage } from './memoryStateStorage';
|
||||
export { useNavigationStore } from './navigationStore';
|
||||
export {
|
||||
useToolkitContentStore,
|
||||
type ToolkitContentStatus,
|
||||
} from './toolkitContentStore';
|
||||
export {
|
||||
useToolkitSocketStore,
|
||||
type ToolkitSocketStatus,
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
import { fallbackCreditsContent, type CreditsContent } from '../data';
|
||||
import {
|
||||
readToolkitContent,
|
||||
saveToolkitContent,
|
||||
} from '../socket/toolkitSocketClient';
|
||||
|
||||
export type ToolkitContentStatus =
|
||||
| 'error'
|
||||
| 'idle'
|
||||
| 'loading'
|
||||
| 'ready'
|
||||
| 'saving';
|
||||
|
||||
type ToolkitContentState = {
|
||||
credits: CreditsContent;
|
||||
error: string | null;
|
||||
lastLoadedAt: string | null;
|
||||
refreshCredits: () => Promise<void>;
|
||||
revision: string | null;
|
||||
saveCredits: (credits: CreditsContent) => Promise<void>;
|
||||
setContentError: (error: string) => void;
|
||||
setCreditsFromSocket: (credits: CreditsContent, revision: string) => void;
|
||||
status: ToolkitContentStatus;
|
||||
};
|
||||
|
||||
function getErrorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : 'Unknown toolkit content error.';
|
||||
}
|
||||
|
||||
export const useToolkitContentStore = create<ToolkitContentState>()(
|
||||
(set, get) => ({
|
||||
credits: fallbackCreditsContent,
|
||||
error: null,
|
||||
lastLoadedAt: null,
|
||||
refreshCredits: async () => {
|
||||
set({
|
||||
error: null,
|
||||
status: 'loading',
|
||||
});
|
||||
|
||||
try {
|
||||
const payload = await readToolkitContent('credits');
|
||||
|
||||
get().setCreditsFromSocket(payload.content, payload.revision);
|
||||
} catch (error) {
|
||||
set({
|
||||
error: getErrorMessage(error),
|
||||
status: 'error',
|
||||
});
|
||||
}
|
||||
},
|
||||
revision: null,
|
||||
saveCredits: async (credits: CreditsContent) => {
|
||||
set({
|
||||
error: null,
|
||||
status: 'saving',
|
||||
});
|
||||
|
||||
try {
|
||||
const payload = await saveToolkitContent('credits', credits);
|
||||
|
||||
get().setCreditsFromSocket(payload.content, payload.revision);
|
||||
} catch (error) {
|
||||
set({
|
||||
error: getErrorMessage(error),
|
||||
status: 'error',
|
||||
});
|
||||
}
|
||||
},
|
||||
setContentError: (error: string) => {
|
||||
set({
|
||||
error,
|
||||
status: 'error',
|
||||
});
|
||||
},
|
||||
setCreditsFromSocket: (credits: CreditsContent, revision: string) => {
|
||||
set({
|
||||
credits,
|
||||
error: null,
|
||||
lastLoadedAt: new Date().toISOString(),
|
||||
revision,
|
||||
status: 'ready',
|
||||
});
|
||||
},
|
||||
status: 'idle',
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,13 @@
|
||||
export const fontFamilies = {
|
||||
default: 'SingleDayRegular',
|
||||
display: 'MiTicaRegular',
|
||||
} as const;
|
||||
|
||||
export const defaultTextStyle = {
|
||||
fontFamily: fontFamilies.default,
|
||||
} as const;
|
||||
|
||||
export const displayTextStyle = {
|
||||
fontFamily: fontFamilies.display,
|
||||
fontWeight: '400',
|
||||
} as const;
|
||||
@@ -0,0 +1,11 @@
|
||||
declare module '*.png' {
|
||||
import type { ImageSourcePropType } from 'react-native';
|
||||
|
||||
const source: ImageSourcePropType;
|
||||
export default source;
|
||||
}
|
||||
|
||||
declare module '*.mp3' {
|
||||
const source: string;
|
||||
export default source;
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { AppRegistry } from 'react-native';
|
||||
|
||||
import { name as appName } from '../../app.json';
|
||||
import App from '../../App';
|
||||
import './styles.css';
|
||||
import '../assets/scss/index.scss';
|
||||
|
||||
const rootTag = globalThis.document.getElementById('root');
|
||||
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background: #050607;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#root {
|
||||
display: flex;
|
||||
}
|
||||