feat(app): add browser toolkit support

This commit is contained in:
2026-06-12 08:19:40 -05:00
parent 255e5a6b24
commit 26bdc8747f
71 changed files with 2746 additions and 314 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 MiB

+49
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

+1
View File
@@ -0,0 +1 @@
export { default as throneOfTheBoneKingMusic } from './throne-of-the-bone-king.mp3';
Binary file not shown.
Binary file not shown.
+33
View File
@@ -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;
}
+1
View File
@@ -0,0 +1 @@
$color-page-background: #050607;
+1
View File
@@ -0,0 +1 @@
@use './__globals';
+3 -1
View File
@@ -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',
},
+4 -1
View File
@@ -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',
+3
View File
@@ -0,0 +1,3 @@
export const appVersion = '0.0.1';
export const buildVersionLabel = `Build v${appVersion}`;
+10
View File
@@ -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}`;
+58
View File
@@ -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."
]
}
+82
View File
@@ -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;
+8
View File
@@ -0,0 +1,8 @@
export {
fallbackCreditsContent,
isCreditsContent,
type CreditsContent,
type CreditsContributor,
type CreditsLogo,
type CreditsTechnology,
} from './credits';
View File
+3
View File
@@ -0,0 +1,3 @@
export function useBackgroundMusic() {
return undefined;
}
+79
View File
@@ -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]);
}
+36 -37
View File
@@ -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,
]);
}
+6 -1
View File
@@ -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[];
+70 -21
View File
@@ -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,
+2
View File
@@ -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,
+2
View File
@@ -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,
+144 -21
View File
@@ -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',
+347 -38
View File
@@ -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',
},
+9 -4
View File
@@ -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,
};
+144
View File
@@ -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;
}
+49
View File
@@ -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,
+4
View File
@@ -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,
+89
View File
@@ -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',
}),
);
+13
View File
@@ -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;
+11
View File
@@ -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;
}
+1 -1
View File
@@ -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');
-16
View File
@@ -1,16 +0,0 @@
html,
body,
#root {
height: 100%;
margin: 0;
min-height: 100%;
}
body {
background: #050607;
overflow: hidden;
}
#root {
display: flex;
}