feat(app): add web electron toolkit support

This commit is contained in:
2026-06-10 15:01:09 -05:00
parent e4a4aeb100
commit fa75ce6026
52 changed files with 20438 additions and 76 deletions
+56
View File
@@ -0,0 +1,56 @@
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
version = 1
name = "blb-throne-of-the-bone-king"
[setup]
script = "npm ci"
[[actions]]
name = "Web Dev"
icon = "run"
command = "npm run web"
[[actions]]
name = "Toolkit Dev"
icon = "run"
command = "npm run dev:toolkit"
[[actions]]
name = "Dev All"
icon = "run"
command = "npm run dev:all"
[[actions]]
name = "Dev Clear"
icon = "tool"
command = "npm run dev:clear"
[[actions]]
name = "Electron Dev"
icon = "run"
command = "npm run electron:dev"
[[actions]]
name = "Test"
icon = "tool"
command = "npm run test -- --runInBand"
[[actions]]
name = "Lint"
icon = "tool"
command = "npm run lint"
[[actions]]
name = "Web Build"
icon = "tool"
command = "npm run web:build"
[[actions]]
name = "Electron Build"
icon = "tool"
command = "npm run electron:build"
[[actions]]
name = "Electron Package"
icon = "tool"
command = "npm run electron:package"
+1
View File
@@ -1,4 +1,5 @@
module.exports = {
root: true,
extends: '@react-native',
ignorePatterns: ['dist/'],
};
+1
View File
@@ -51,6 +51,7 @@ yarn-error.log
# Bundle artifact
*.jsbundle
/dist/
# Ruby / CocoaPods
**/Pods/
+10 -32
View File
@@ -1,16 +1,11 @@
/**
* Sample React Native App
* https://github.com/facebook/react-native
*
* @format
*/
import { NewAppScreen } from '@react-native/new-app-screen';
import { StatusBar, StyleSheet, useColorScheme, View } from 'react-native';
import {
SafeAreaProvider,
useSafeAreaInsets,
} from 'react-native-safe-area-context';
StatusBar,
useColorScheme,
} from 'react-native';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { GameViewport } from './src/components/GameViewport';
import { AppNavigator } from './src/navigation/AppNavigator';
function App() {
const isDarkMode = useColorScheme() === 'dark';
@@ -18,28 +13,11 @@ function App() {
return (
<SafeAreaProvider>
<StatusBar barStyle={isDarkMode ? 'light-content' : 'dark-content'} />
<AppContent />
<GameViewport>
<AppNavigator />
</GameViewport>
</SafeAreaProvider>
);
}
function AppContent() {
const safeAreaInsets = useSafeAreaInsets();
return (
<View style={styles.container}>
<NewAppScreen
templateFileName="App.tsx"
safeAreaInsets={safeAreaInsets}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
});
export default App;
+44
View File
@@ -0,0 +1,44 @@
jest.mock('@react-native-async-storage/async-storage', () => {
const storage = new Map<string, string>();
return {
__esModule: true,
default: {
getItem: jest.fn((key: string) => Promise.resolve(storage.get(key) ?? null)),
removeItem: jest.fn((key: string) => {
storage.delete(key);
return Promise.resolve();
}),
setItem: jest.fn((key: string, value: string) => {
storage.set(key, value);
return Promise.resolve();
}),
},
};
});
import { createPersistOptions } from '../src/state/createPersistOptions';
type TestState = {
hydrated: boolean;
transientValue: string;
};
describe('createPersistOptions', () => {
it('creates named options with JSON storage and preserves extra options', () => {
const options = createPersistOptions<TestState, Pick<TestState, 'hydrated'>>({
name: 'test-state',
partialize: state => ({ hydrated: state.hydrated }),
version: 2,
});
expect(options.name).toBe('test-state');
expect(options.version).toBe(2);
expect(options.storage).toBeDefined();
expect(options.partialize?.({ hydrated: true, transientValue: 'skip' })).toEqual(
{
hydrated: true,
},
);
});
});
+50
View File
@@ -0,0 +1,50 @@
import {
clampVolume,
defaultAutosaveEnabled,
defaultGameResolutionId,
defaultVolumeSettings,
formatResolutionLabel,
gameResolutionOptions,
} from '../src/settings/gameSettings';
describe('game settings definitions', () => {
it('uses landscape phone dimensions for the default enforced resolution', () => {
expect(defaultGameResolutionId).toBe('iphoneDefault');
expect(formatResolutionLabel(defaultGameResolutionId)).toBe(
'Phone Default (844 x 390)',
);
const boundedOptions = gameResolutionOptions.filter(
option => !('unbounded' in option),
);
expect(boundedOptions.map(option => option.width)).toEqual([
812,
844,
915,
932,
]);
expect(boundedOptions.every(option => option.width > option.height)).toBe(true);
});
it('defines a browser resolution that fills available space', () => {
expect(formatResolutionLabel('browser')).toBe(
'Browser (Fill available space)',
);
});
it('clamps volumes between 0 and 100', () => {
expect(clampVolume(-5)).toBe(0);
expect(clampVolume(55)).toBe(55);
expect(clampVolume(105)).toBe(100);
});
it('defines autosave and every default volume setting', () => {
expect(defaultAutosaveEnabled).toBe(true);
expect(defaultVolumeSettings).toEqual({
ambience: 70,
master: 80,
music: 70,
speech: 80,
});
});
});
+60
View File
@@ -0,0 +1,60 @@
jest.mock('@react-native-async-storage/async-storage', () => {
const storage = new Map<string, string>();
return {
__esModule: true,
default: {
getItem: jest.fn((key: string) => Promise.resolve(storage.get(key) ?? null)),
removeItem: jest.fn((key: string) => {
storage.delete(key);
return Promise.resolve();
}),
setItem: jest.fn((key: string, value: string) => {
storage.set(key, value);
return Promise.resolve();
}),
},
};
});
import { act } from 'react-test-renderer';
import { defaultVolumeSettings } from '../src/settings/gameSettings';
import { useGameSettingsStore } from '../src/state';
describe('useGameSettingsStore', () => {
beforeEach(() => {
useGameSettingsStore.setState({
autosaveEnabled: true,
resolutionId: 'iphoneDefault',
volumes: defaultVolumeSettings,
});
});
it('captures all settings in persisted state', () => {
const partialize = useGameSettingsStore.persist.getOptions().partialize;
expect(partialize?.(useGameSettingsStore.getState())).toEqual({
autosaveEnabled: true,
resolutionId: 'iphoneDefault',
volumes: {
ambience: 70,
master: 80,
music: 70,
speech: 80,
},
});
});
it('updates autosave, resolution, and volume settings', () => {
act(() => {
useGameSettingsStore.getState().toggleAutosave();
useGameSettingsStore.getState().setResolution('phoneLarge');
useGameSettingsStore.getState().setVolume('music', 105);
});
expect(useGameSettingsStore.getState().autosaveEnabled).toBe(false);
expect(useGameSettingsStore.getState().resolutionId).toBe('phoneLarge');
expect(useGameSettingsStore.getState().volumes.music).toBe(100);
});
});
+15
View File
@@ -0,0 +1,15 @@
import { createMemoryStateStorage } from '../src/state/memoryStateStorage';
describe('createMemoryStateStorage', () => {
it('stores, reads, and removes string values', () => {
const storage = createMemoryStateStorage();
expect(storage.getItem('missing')).toBeNull();
storage.setItem('bone-king', 'awake');
expect(storage.getItem('bone-king')).toBe('awake');
storage.removeItem('bone-king');
expect(storage.getItem('bone-king')).toBeNull();
});
});
+60
View File
@@ -0,0 +1,60 @@
import {
policyRoutes,
primaryMenuRoutes,
requiredPageRoutes,
toolkitRoutes,
routeDefinitions,
} from '../src/navigation/routes';
describe('routeDefinitions', () => {
it('defines every required page with labels and titles', () => {
expect(requiredPageRoutes).toEqual([
'home',
'newGame',
'saves',
'settings',
'policiesLegal',
'policiesPrivacy',
'policiesTerms',
'policiesCookies',
'credits',
'toolkit',
'toolkitOpeningSequence',
'toolkitCredits',
'toolkitAudio',
'toolkitLore',
'toolkitResources',
]);
for (const routeName of requiredPageRoutes) {
expect(routeDefinitions[routeName].label.length).toBeGreaterThan(0);
expect(routeDefinitions[routeName].title.length).toBeGreaterThan(0);
}
});
it('exposes home menu and policy submenu routes', () => {
expect(primaryMenuRoutes).toEqual([
'newGame',
'saves',
'settings',
'policies',
'credits',
'toolkit',
]);
expect(policyRoutes).toEqual([
'policiesLegal',
'policiesPrivacy',
'policiesTerms',
'policiesCookies',
]);
expect(toolkitRoutes).toEqual([
'toolkitOpeningSequence',
'toolkitCredits',
'toolkitAudio',
'toolkitLore',
'toolkitResources',
]);
});
});
+54
View File
@@ -0,0 +1,54 @@
import { act } from 'react-test-renderer';
import { useNavigationStore } from '../src/state';
jest.mock('@react-native-async-storage/async-storage', () => {
const storage = new Map<string, string>();
return {
__esModule: true,
default: {
getItem: jest.fn((key: string) => Promise.resolve(storage.get(key) ?? null)),
removeItem: jest.fn((key: string) => {
storage.delete(key);
return Promise.resolve();
}),
setItem: jest.fn((key: string, value: string) => {
storage.set(key, value);
return Promise.resolve();
}),
},
};
});
describe('useNavigationStore', () => {
beforeEach(() => {
useNavigationStore.setState({
currentRouteName: 'home',
pendingRouteName: null,
});
});
it('captures the current and pending routes in persisted state', () => {
act(() => {
useNavigationStore.getState().setPendingRoute('settings');
});
const partialize = useNavigationStore.persist.getOptions().partialize;
expect(partialize?.(useNavigationStore.getState())).toEqual({
currentRouteName: 'home',
pendingRouteName: 'settings',
});
});
it('completes navigation by promoting the pending route to current route', () => {
act(() => {
useNavigationStore.getState().setPendingRoute('settings');
useNavigationStore.getState().completeNavigation('settings');
});
expect(useNavigationStore.getState().currentRouteName).toBe('settings');
expect(useNavigationStore.getState().pendingRouteName).toBeNull();
});
});
+46
View File
@@ -0,0 +1,46 @@
import type { StateStorage } from 'zustand/middleware';
import { createWebStateStorage } from '../src/state/persistStorage.web';
function createThrowingStorage(): StateStorage<void> {
return {
getItem: () => null,
removeItem: () => {
throw new Error('Storage is unavailable.');
},
setItem: () => {
throw new Error('Storage is unavailable.');
},
};
}
describe('createWebStateStorage', () => {
it('uses provided storage when it is available', () => {
const backingStore = new Map<string, string>();
const providedStorage: StateStorage<void> = {
getItem: (name: string) => backingStore.get(name) ?? null,
removeItem: (name: string) => {
backingStore.delete(name);
},
setItem: (name: string, value: string) => {
backingStore.set(name, value);
},
};
const storage = createWebStateStorage(() => providedStorage);
storage.setItem('throne', 'claimed');
expect(providedStorage.getItem('throne')).toBe('claimed');
expect(storage.getItem('throne')).toBe('claimed');
});
it('falls back to memory storage when local storage is unavailable', () => {
const storage = createWebStateStorage(createThrowingStorage);
storage.setItem('throne', 'hidden');
expect(storage.getItem('throne')).toBe('hidden');
storage.removeItem('throne');
expect(storage.getItem('throne')).toBeNull();
});
});
+426
View File
@@ -0,0 +1,426 @@
import type { ReactElement } from 'react';
import { ScrollView, Text } from 'react-native';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { AppNavigator } from '../src/navigation/AppNavigator';
import {
policyRoutes,
primaryMenuRoutes,
routeDefinitions,
toolkitRoutes,
} from '../src/navigation/routes';
import { defaultVolumeSettings } from '../src/settings/gameSettings';
import { HomeScreen } from '../src/screens/HomeScreen';
import { PageScreen } from '../src/screens/PageScreen';
import { PoliciesScreen } from '../src/screens/PoliciesScreen';
import { SettingsScreen } from '../src/screens/SettingsScreen';
import { ToolkitScreen } from '../src/screens/ToolkitScreen';
import {
useGameSettingsStore,
useNavigationStore,
useToolkitSocketStore,
} from '../src/state';
const mountedRenderers: ReactTestRenderer[] = [];
jest.mock('@react-native-async-storage/async-storage', () => {
const storage = new Map<string, string>();
return {
__esModule: true,
default: {
getItem: jest.fn((key: string) => Promise.resolve(storage.get(key) ?? null)),
removeItem: jest.fn((key: string) => {
storage.delete(key);
return Promise.resolve();
}),
setItem: jest.fn((key: string, value: string) => {
storage.set(key, value);
return Promise.resolve();
}),
},
};
});
jest.mock('react-native-safe-area-context', () => ({
SafeAreaProvider: ({ children }: { children: ReactElement }) => children,
useSafeAreaInsets: () => ({
bottom: 0,
left: 0,
right: 0,
top: 0,
}),
}));
function render(element: ReactElement) {
let renderer: ReactTestRenderer | undefined;
act(() => {
renderer = create(element);
});
if (renderer == null) {
throw new Error('Renderer was not created.');
}
mountedRenderers.push(renderer);
return renderer;
}
function getRenderedText(renderer: ReactTestRenderer) {
return renderer.root
.findAllByType(Text)
.map(instance => instance.props.children)
.join(' ');
}
function pressByTestID(renderer: ReactTestRenderer, testID: string) {
const element = renderer.root.findByProps({ testID });
const props = element.props as { onPress: () => void };
act(() => {
props.onPress();
});
}
function unmount(renderer: ReactTestRenderer) {
act(() => {
renderer.unmount();
});
const rendererIndex = mountedRenderers.indexOf(renderer);
if (rendererIndex >= 0) {
mountedRenderers.splice(rendererIndex, 1);
}
}
describe('screen scaffold', () => {
beforeEach(() => {
act(() => {
useGameSettingsStore.setState({
autosaveEnabled: true,
resolutionId: 'iphoneDefault',
volumes: defaultVolumeSettings,
});
useNavigationStore.setState({
currentRouteName: 'home',
pendingRouteName: null,
});
useToolkitSocketStore.setState({
lastConnectedAt: null,
socketId: null,
status: 'disconnected',
});
});
});
afterEach(() => {
act(() => {
for (const renderer of mountedRenderers) {
renderer.unmount();
}
mountedRenderers.length = 0;
});
});
it('renders the home screen game name and primary menu entries', () => {
const renderer = render(<HomeScreen navigate={jest.fn()} />);
const text = getRenderedText(renderer);
expect(text).toContain('Throne of the Bone King');
expect(text).toContain('New Game');
expect(text).toContain('Saves');
expect(text).toContain('Settings');
expect(text).toContain('Policies');
expect(text).toContain('Credits');
expect(text).not.toContain('Toolkit');
expect(renderer.root.findByProps({ testID: 'menu-newGame-icon' })).toBeTruthy();
expect(renderer.root.findByProps({ testID: 'menu-saves-icon' })).toBeTruthy();
expect(renderer.root.findByProps({ testID: 'menu-settings-icon' })).toBeTruthy();
expect(renderer.root.findByProps({ testID: 'menu-policies-icon' })).toBeTruthy();
expect(renderer.root.findByProps({ testID: 'menu-credits-icon' })).toBeTruthy();
expect(renderer.root.findAllByProps({ testID: 'menu-toolkit' })).toHaveLength(0);
});
it('shows the toolkit home entry as a distinct button only in browser sizing', () => {
act(() => {
useGameSettingsStore.setState({
autosaveEnabled: true,
resolutionId: 'browser',
volumes: defaultVolumeSettings,
});
});
const renderer = render(<HomeScreen navigate={jest.fn()} />);
const text = getRenderedText(renderer);
const toolkitIcon = renderer.root.findByProps({ testID: 'menu-toolkit-icon' });
const toolkitIconStyle = toolkitIcon.props.style as Array<
Record<string, string | number> | null
>;
expect(text).toContain('Toolkit');
expect(toolkitIcon).toBeTruthy();
expect(toolkitIconStyle).toEqual(
expect.arrayContaining([
expect.objectContaining({
color: '#f5d98d',
}),
]),
);
expect(renderer.root.findByProps({ testID: 'menu-toolkit' }).props).toMatchObject(
{
disabled: true,
},
);
});
it('enables the toolkit home entry when the toolkit socket is connected', () => {
act(() => {
useGameSettingsStore.setState({
autosaveEnabled: true,
resolutionId: 'browser',
volumes: defaultVolumeSettings,
});
useToolkitSocketStore.getState().setConnected('test-socket');
});
const renderer = render(<HomeScreen navigate={jest.fn()} />);
expect(renderer.root.findByProps({ testID: 'menu-toolkit' }).props).toMatchObject(
{
disabled: false,
},
);
});
it('renders a return-to-home control on non-home pages', () => {
const renderer = render(
<PageScreen goHome={jest.fn()} routeName="newGame" />,
);
expect(renderer.root.findByProps({ testID: 'back-home-button' })).toBeTruthy();
});
it('renders all policy page links from the policies index', () => {
const renderer = render(
<PoliciesScreen goHome={jest.fn()} navigate={jest.fn()} />,
);
const text = getRenderedText(renderer);
expect(text).toContain('Legal');
expect(text).toContain('Privacy');
expect(text).toContain('Terms and Conditions');
expect(text).toContain('Cookies');
});
it('renders settings controls for resolution and audio channels', () => {
const renderer = render(<SettingsScreen goHome={jest.fn()} />);
const text = getRenderedText(renderer);
expect(text).toContain('Game Resolution');
expect(text).toContain('Phone Default');
expect(text).toContain('Master Volume');
expect(text).toContain('Music Volume');
expect(text).toContain('Ambience Volume');
expect(text).toContain('Speech Volume');
expect(text).toContain('Autosave');
});
it('renders the toolkit template and dense toolkit menu entries', () => {
const renderer = render(
<ToolkitScreen
goHome={jest.fn()}
navigate={jest.fn()}
routeName="toolkit"
/>,
);
const text = getRenderedText(renderer);
expect(text).toContain('Toolkit');
expect(text).toContain('Opening Sequence');
expect(text).toContain('Credits');
expect(text).toContain('Audio');
expect(text).toContain('Lore');
expect(text).toContain('Resources');
const homeButton = renderer.root.findByProps({ testID: 'toolkit-home' });
expect(homeButton.props.accessibilityLabel).toBe('Home');
expect(renderer.root.findByProps({ testID: 'toolkit-home-icon' })).toBeTruthy();
expect(renderer.root.findAllByProps({ testID: 'toolkit-home-label' })).toHaveLength(
0,
);
expect(renderer.root.findByProps({ testID: 'toolkit-socket-indicator' })).toBeTruthy();
});
it('renders the toolkit websocket connection indicator', () => {
act(() => {
useToolkitSocketStore.getState().setConnected('test-socket');
});
const renderer = render(
<ToolkitScreen
goHome={jest.fn()}
navigate={jest.fn()}
routeName="toolkit"
/>,
);
const text = getRenderedText(renderer);
const socketDot = renderer.root.findByProps({ testID: 'toolkit-socket-dot' });
expect(text).toContain('Connected');
expect(socketDot.props.style).toEqual(
expect.arrayContaining([
expect.objectContaining({
backgroundColor: '#3fbf6f',
}),
]),
);
});
it('renders toolkit navigation as a dense top toolbar', () => {
const renderer = render(
<ToolkitScreen
goHome={jest.fn()}
navigate={jest.fn()}
routeName="toolkit"
/>,
);
const toolbar = renderer.root.findByProps({ testID: 'toolkit-toolbar' });
const optionGroup = renderer.root.findByProps({
testID: 'toolkit-option-group',
});
const homeSlot = renderer.root.findByProps({ testID: 'toolkit-home-slot' });
expect(toolbar.props.style).toMatchObject({ flexDirection: 'row' });
expect(optionGroup.props.style).toMatchObject({ flexDirection: 'row' });
expect(homeSlot.props.style).toMatchObject({ marginLeft: 'auto' });
expect(
renderer.root.findByProps({ testID: 'toolkit-toolkitOpeningSequence-icon' }),
).toBeTruthy();
expect(
renderer.root.findByProps({ testID: 'toolkit-toolkitOpeningSequence-label' }),
).toBeTruthy();
});
it('opens the resolution select and renders all options', () => {
const renderer = render(<SettingsScreen goHome={jest.fn()} />);
expect(renderer.root.findAllByProps({ testID: 'resolution-options' })).toHaveLength(
0,
);
pressByTestID(renderer, 'resolution-select');
const options = renderer.root.findByProps({ testID: 'resolution-options' });
expect(options).toBeTruthy();
expect(options.props.style).toMatchObject({ position: 'absolute' });
expect(renderer.root.findByProps({ testID: 'resolution-browser' })).toBeTruthy();
expect(renderer.root.findByProps({ testID: 'resolution-iphoneMini' })).toBeTruthy();
expect(
renderer.root.findByProps({ testID: 'resolution-iphoneDefault' }),
).toBeTruthy();
expect(
renderer.root.findByProps({ testID: 'resolution-androidDefault' }),
).toBeTruthy();
expect(renderer.root.findByProps({ testID: 'resolution-phoneLarge' })).toBeTruthy();
});
it('keeps the active settings route when changing resolution', () => {
const renderer = render(<AppNavigator />);
pressByTestID(renderer, 'menu-settings');
expect(getRenderedText(renderer)).toContain(routeDefinitions.settings.title);
pressByTestID(renderer, 'resolution-select');
pressByTestID(renderer, 'resolution-browser');
expect(getRenderedText(renderer)).toContain(routeDefinitions.settings.title);
expect(getRenderedText(renderer)).not.toContain(routeDefinitions.home.title);
});
it('restores the active route when the navigator remounts', () => {
const renderer = render(<AppNavigator />);
pressByTestID(renderer, 'menu-settings');
expect(getRenderedText(renderer)).toContain(routeDefinitions.settings.title);
unmount(renderer);
const remountedRenderer = render(<AppNavigator />);
expect(getRenderedText(remountedRenderer)).toContain(
routeDefinitions.settings.title,
);
});
it('does not use scroll containers for fixed landscape screens', () => {
const screens = [
<HomeScreen key="home" navigate={jest.fn()} />,
<PageScreen key="page" goHome={jest.fn()} routeName="newGame" />,
<PoliciesScreen key="policies" goHome={jest.fn()} navigate={jest.fn()} />,
<SettingsScreen key="settings" goHome={jest.fn()} />,
<ToolkitScreen
key="toolkit"
goHome={jest.fn()}
navigate={jest.fn()}
routeName="toolkit"
/>,
];
for (const screen of screens) {
const renderer = render(screen);
expect(renderer.root.findAllByType(ScrollView)).toHaveLength(0);
}
});
it('navigates to each scaffold page and returns home', () => {
const renderer = render(<AppNavigator />);
for (const routeName of primaryMenuRoutes) {
if (routeName === 'toolkit') {
continue;
}
pressByTestID(renderer, `menu-${routeName}`);
expect(getRenderedText(renderer)).toContain(routeDefinitions[routeName].title);
pressByTestID(renderer, 'back-home-button');
expect(getRenderedText(renderer)).toContain(routeDefinitions.home.title);
}
pressByTestID(renderer, 'menu-policies');
for (const routeName of policyRoutes) {
pressByTestID(renderer, `policy-${routeName}`);
expect(getRenderedText(renderer)).toContain(routeDefinitions[routeName].title);
pressByTestID(renderer, 'back-home-button');
expect(getRenderedText(renderer)).toContain(routeDefinitions.home.title);
if (routeName !== policyRoutes[policyRoutes.length - 1]) {
pressByTestID(renderer, 'menu-policies');
}
}
act(() => {
useGameSettingsStore.getState().setResolution('browser');
useToolkitSocketStore.getState().setConnected('test-socket');
});
pressByTestID(renderer, 'menu-toolkit');
for (const routeName of toolkitRoutes) {
pressByTestID(renderer, `toolkit-${routeName}`);
expect(getRenderedText(renderer)).toContain(routeDefinitions[routeName].title);
}
pressByTestID(renderer, 'toolkit-home');
expect(getRenderedText(renderer)).toContain(routeDefinitions.home.title);
});
});
+43
View File
@@ -0,0 +1,43 @@
import { act } from 'react-test-renderer';
import { useToolkitSocketStore } from '../src/state/toolkitSocketStore';
describe('useToolkitSocketStore', () => {
beforeEach(() => {
useToolkitSocketStore.setState({
lastConnectedAt: null,
socketId: null,
status: 'disconnected',
});
});
it('tracks connecting, connected, and disconnected socket state', () => {
act(() => {
useToolkitSocketStore.getState().setConnecting();
});
expect(useToolkitSocketStore.getState()).toMatchObject({
socketId: null,
status: 'connecting',
});
act(() => {
useToolkitSocketStore.getState().setConnected('socket-1');
});
expect(useToolkitSocketStore.getState()).toMatchObject({
socketId: 'socket-1',
status: 'connected',
});
expect(useToolkitSocketStore.getState().lastConnectedAt).not.toBeNull();
act(() => {
useToolkitSocketStore.getState().setDisconnected();
});
expect(useToolkitSocketStore.getState()).toMatchObject({
socketId: null,
status: 'disconnected',
});
});
});
View File
View File
+59
View File
@@ -0,0 +1,59 @@
import { app, BrowserWindow, shell } from 'electron';
import path from 'node:path';
const isDevelopment = process.env.NODE_ENV === 'development';
const devServerUrl =
process.env.VITE_DEV_SERVER_URL ?? 'http://127.0.0.1:5173';
function reportError(error: unknown) {
const message = error instanceof Error ? error.stack ?? error.message : error;
process.stderr.write(`${String(message)}\n`);
}
function createMainWindow() {
const mainWindow = new BrowserWindow({
height: 800,
minHeight: 600,
minWidth: 900,
title: 'Throne of the Bone King',
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
preload: path.join(__dirname, 'preload.js'),
},
width: 1200,
});
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url).catch(reportError);
return { action: 'deny' };
});
if (isDevelopment) {
mainWindow.loadURL(devServerUrl).catch(reportError);
return;
}
mainWindow
.loadFile(path.join(__dirname, '../renderer/index.html'))
.catch(reportError);
}
app
.whenReady()
.then(() => {
createMainWindow();
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createMainWindow();
}
});
})
.catch(reportError);
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
+5
View File
@@ -0,0 +1,5 @@
import { contextBridge } from 'electron';
contextBridge.exposeInMainWorld('boneKingApp', {
platform: process.platform,
});
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
/>
<title>Throne of the Bone King</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/web/main.tsx"></script>
</body>
</html>
+153 -37
View File
@@ -2,63 +2,179 @@
## Project
**Name:** Bone Lord Bob — Throne of the Bone King
**Repo:** `jacob-mathison/blb-throne-of-the-bone-king`
**Type:** Mobile application (React Native)
**Status:** Early development / scaffolding
**Repo:** `jacob-mathison/blb-throne-of-the-bone-king` (Gitea: `http://100.79.253.19:3000/jacob-mathison/blb-throne-of-the-bone-king`)
**Type:** Cross-platform game shell — React Native UI shared across mobile, web, and desktop
**Status:** Early development / scaffolding (menu shell, settings, navigation)
**Machine manifest:** `manifest.llm.json` — targets, task routes, commands, architecture index, known gaps
## Part of
Bone Lord Bob (BLB) is an original American Anime project developed by Jacob Mathison and Joseph Bro under Mathison Projects Inc.
## Targets
| Target | Runtime | Bundler | Entry |
|--------|---------|---------|-------|
| **Android / iOS** | React Native | Metro | `index.js` → `App.tsx` |
| **Web** | react-native-web in browser | Vite | `index.html` → `src/web/main.tsx` → `App.tsx` |
| **Desktop** | Electron (Chromium) | Vite (renderer) + `tsc` (main) | `electron/main.ts` loads Vite dev server or `dist/renderer/` |
Primary day-to-day development currently runs through **web** (`npm run web`) and **Electron** (`npm run electron:dev`). Mobile targets remain available but share the same `src/` UI layer.
## Stack
- **React Native** 0.85.3
- **React** 19.2.3
- **React Native** 0.85.3 — shared component model (`View`, `Text`, `StyleSheet`, etc.)
- **react-native-web** — web/Electron renderer
- **React** 19.2.3 / **react-dom** 19.2.3
- **TypeScript** ^5.8.3
- **Metro** bundler
- **Jest** for testing
- **ESLint + Prettier** for linting/formatting
- **Targets:** Android + iOS
- **Zustand** ^5.0.14 — app state + persistence
- **Vite** ^8 — web/Electron renderer bundler
- **Electron** ^42 — desktop shell
- **socket.io** / **socket.io-client** ^4.8 — declared; `socket/` not yet implemented
- **Jest** — unit/integration tests
- **ESLint + Prettier** — lint/format
- **Node** >= 22.12.0
## Architecture
### Shared UI (`src/`)
All screens and components are React Native. `App.tsx` is the single root used by every target:
```
App.tsx
└─ SafeAreaProvider
└─ GameViewport # enforces resolution / letterboxing
└─ AppNavigator # custom route switching with fade transitions
```
**Navigation** is store-driven, not React Navigation:
- Route catalog: `src/navigation/routes.ts` (`RouteName`, menu groupings)
- Navigator: `src/navigation/AppNavigator.tsx` — reads/writes `useNavigationStore`, animates opacity on route change
- Screens: `src/screens/` (`HomeScreen`, `SettingsScreen`, `PoliciesScreen`, `ToolkitScreen`, `PageScreen`)
**State** lives in Zustand stores under `src/state/`:
- `navigationStore` — current/pending route (persisted as `blb-navigation`)
- `gameSettingsStore` — resolution, volumes, autosave (persisted as `blb-game-settings`)
- `createPersistOptions` — shared persist middleware wiring
**Settings** constants/helpers: `src/settings/gameSettings.ts`
Resolution presets include phone sizes plus a `browser` (unbounded) mode. Toolkit menu items are only shown when resolution is `browser`.
**Components:** `src/components/` — `GameViewport`, `ScreenLayout`, `MenuButton`, `BackHomeButton`
### Platform-specific persistence
Vite resolves `.web.ts` before `.ts` (see `vite.config.ts` `resolve.extensions`).
| File | Mobile (Metro) | Web / Electron (Vite) |
|------|----------------|------------------------|
| `src/state/persistStorage.ts` | `@react-native-async-storage/async-storage` | — |
| `src/state/persistStorage.web.ts` | — | `localStorage` with in-memory fallback |
Shared stores import `./persistStorage`; the correct implementation is picked per bundler.
### Web entry
`src/web/main.tsx` registers `App` via `AppRegistry.runApplication` against `#root` in `index.html`. Global styles: `src/web/styles.css`.
### Electron shell
- `electron/main.ts` — `BrowserWindow`, context isolation, no node integration
- `electron/preload.ts` — exposes `window.boneKingApp.platform`
- Dev: loads `VITE_DEV_SERVER_URL` (default `http://127.0.0.1:5173`)
- Prod: loads `dist/renderer/index.html`
- Compiled to `dist/electron/` via `tsconfig.electron.json`
### Build outputs (gitignored under `/dist/`)
- `dist/renderer/` — Vite production build
- `dist/electron/` — compiled Electron main + preload
- `electron-builder` packages desktop artifacts (`npm run electron:package`)
## Project Structure
```
blb-throne-of-the-bone-king/
├── App.tsx # Root application component
├── index.js # Entry point
├── android/ # Android native project
├── ios/ # iOS native project (CocoaPods)
├── __tests__/ # Jest test suite
├── babel.config.js # Babel config (react-native preset)
├── metro.config.js # Metro bundler config
├── tsconfig.json # TypeScript config
├── jest.config.js # Jest config
├── Gemfile # Ruby deps for CocoaPods / fastlane
├── .eslintrc.js # Lint rules (@react-native/eslint-config)
├── .prettierrc.js # Prettier rules
├── llm.txt # This file — project context for AI agents
└── AGENTS.md # AI agent operational instructions
├── App.tsx # Shared root component (all targets)
├── index.js # React Native mobile entry
├── index.html # Vite HTML shell
├── android/ # Android native project
├── ios/ # iOS native project (CocoaPods)
├── electron/
├── main.ts # Electron main process
│ └── preload.ts # Preload bridge
├── src/
│ ├── components/ # Reusable UI
│ ├── navigation/ # Routes + AppNavigator
│ ├── screens/ # Screen-level views
│ ├── settings/ # Game settings constants/helpers
│ ├── state/ # Zustand stores + persist layer
│ └── web/ # Web-only entry + styles
├── __tests__/ # Jest test suite
├── socket/ # Planned real-time layer (stub)
├── docs/ # Project docs (stub)
├── wiki/ # Wiki content (stub)
├── vite.config.ts # Vite + react-native-web aliases
├── tsconfig.electron.json # Electron main-process TS config
├── metro.config.js # Metro bundler (mobile)
├── babel.config.js
├── tsconfig.json
├── jest.config.js
├── package.json # Scripts for all targets + electron-builder config
├── llm.txt # This file — project context for AI agents
├── manifest.llm.json # Machine-readable manifest (routes agents to the right files)
└── AGENTS.md # AI agent operational instructions
```
## LLM Read Order
1. **`llm.txt`** — human-readable orientation (this file)
2. **`manifest.llm.json`** — machine-readable routing, commands, task routes, known gaps
3. **`AGENTS.md`** — agent operating rules and common tasks
For focused work, use `taskRoutes` in `manifest.llm.json` instead of reading everything.
## Dev Commands
```bash
npm install # Install JS deps
bundle exec pod install # iOS CocoaPods deps (run inside ios/)
npm run android # Run on Android emulator/device
npm run ios # Run on iOS simulator/device
npm run start # Start Metro bundler
npm run lint # ESLint
npm run test # Jest
npm install # Install JS deps
# Web (browser, fastest iteration)
npm run web # Vite dev server at http://127.0.0.1:5173
npm run web:build # Production build → dist/renderer/
npm run web:preview # Preview production build
# Desktop (Electron)
npm run electron:dev # Vite + Electron concurrently
npm run electron:build # web:build + compile electron main
npm run electron:package # Build + electron-builder installer
# Mobile (React Native)
npm run start # Metro bundler
npm run android # Run on Android emulator/device
npm run ios # Run on iOS simulator/device
# iOS native deps (inside ios/):
bundle exec pod install
# Quality
npm run lint # ESLint
npm run test # Jest
```
## Coding Conventions
- TypeScript strictly typed — avoid `any`
- Functional components + hooks only (no class components)
- Component files: PascalCase (e.g. `BoneKingScreen.tsx`)
- Component files: PascalCase (e.g. `HomeScreen.tsx`)
- Utility/hook files: camelCase (e.g. `useBoneKing.ts`)
- Styles: `StyleSheet.create()` — no inline style objects on hot paths
- Keep native modules minimal; prefer JS-side implementations where possible
- New screens: add route in `routes.ts`, wire in `AppNavigator`, create screen under `src/screens/`
- Platform overrides: use `.web.ts` / `.web.tsx` suffix; keep shared logic in the base file
- Tests for new logic in `__tests__/`
## Known Gaps
Canonical list lives in `manifest.llm.json` → `knownGaps`. Current highlights:
- `socket/` is a stub (socket.io deps declared, not wired)
- `docs/` and `wiki/` are placeholder stubs
- `AGENTS.md` and `README.md` are stale relative to web/Electron targets
## Notes for LLMs
- This is a React Native project — do NOT suggest browser DOM APIs
- Metro is the bundler — not Vite, Webpack, or esbuild
- iOS native code lives in `ios/` (Swift/ObjC); Android in `android/` (Kotlin/Java)
- Pods are not committed to git — always run `pod install` after pulling
- `node_modules/` is not committed
- **Read `manifest.llm.json` for task routing** — use `taskRoutes` to pick the narrowest file set
- **Shared UI is React Native** — write `View`/`Text`/`Pressable`, not raw HTML, for app code under `src/`
- **Three runtimes, one component tree** — check which target a change affects (mobile vs web/Electron)
- **Do not use DOM APIs in shared `src/` code** — `document`, `window`, `localStorage` belong in `.web.ts` shims or `src/web/` entry only
- **Metro** bundles mobile; **Vite** bundles web/Electron renderer — not interchangeable
- **Navigation is Zustand-based** — do not add React Navigation unless explicitly requested
- **Persistence is platform-split** — extend `persistStorage.ts` / `persistStorage.web.ts`, not stores directly
- `socket/` is a stub — socket.io is installed but no server/client wiring yet
- iOS Pods and `node_modules/` are not committed; run `pod install` after pulling native dep changes
- `dist/` build artifacts are gitignored
+353
View File
@@ -0,0 +1,353 @@
{
"schemaVersion": "1.0.0",
"project": {
"name": "blb-throne-of-the-bone-king",
"displayName": "Bone Lord Bob — Throne of the Bone King",
"purpose": "Cross-platform game shell with shared React Native UI across mobile, web, and desktop.",
"root": ".",
"model": "single-repo",
"repo": {
"gitea": "http://100.79.253.19:3000/jacob-mathison/blb-throne-of-the-bone-king",
"slug": "jacob-mathison/blb-throne-of-the-bone-king",
"defaultBranch": "main"
},
"universe": "Bone Lord Bob (BLB) — Mathison Projects Inc.",
"status": "early-development",
"summary": "Menu shell with home navigation, settings (resolution/volume/autosave), policies, toolkit pages, and Zustand-persisted state. Primary iteration targets are web (Vite) and Electron; mobile targets share the same src/ UI layer."
},
"principles": [
"Start with llm.txt for human-readable orientation.",
"Use manifest.llm.json for machine-readable routing, commands, targets, and known gaps.",
"Shared UI under src/ is React Native — not raw HTML.",
"Do not use DOM APIs in shared src/ code; platform shims belong in .web.ts files or src/web/.",
"Metro bundles mobile; Vite bundles web/Electron renderer — they are not interchangeable.",
"Navigation is Zustand-based; do not add React Navigation unless explicitly requested.",
"Persistence is platform-split via persistStorage.ts (mobile) and persistStorage.web.ts (web/Electron).",
"Treat knownGaps in this manifest as the canonical repository-wide gap list.",
"Commit as Jacob Mathison (jacob@mathisonprojects.com); never commit as an AI identity."
],
"guideFiles": [
{
"path": "llm.txt",
"type": "root-orientation",
"purpose": "First-read guide for project shape, stack, architecture, commands, conventions, and LLM notes.",
"alwaysRead": true
},
{
"path": "manifest.llm.json",
"type": "machine-manifest",
"purpose": "Machine-readable routing manifest: targets, commands, task routes, known gaps, and verification baseline.",
"alwaysRead": true
},
{
"path": "AGENTS.md",
"type": "agent-instructions",
"purpose": "Operational instructions for coding agents: ground rules, structure conventions, and common tasks.",
"alwaysRead": true
}
],
"globalReadOrder": [
"llm.txt",
"manifest.llm.json",
"AGENTS.md"
],
"globalReadOrderSemantics": "Broad orientation order. Task routers should prefer taskRoutes read arrays to avoid over-reading.",
"documentationFiles": [
{
"path": "llm.txt",
"type": "root-orientation",
"purpose": "Human-readable first-read guide."
},
{
"path": "manifest.llm.json",
"type": "machine-manifest",
"purpose": "Machine-readable project manifest."
},
{
"path": "AGENTS.md",
"type": "agent-instructions",
"purpose": "Agent operating instructions."
},
{
"path": "README.md",
"type": "human-overview",
"purpose": "Human-facing overview (currently default React Native template; stale relative to web/Electron targets)."
}
],
"targets": [
{
"id": "web",
"label": "Web",
"runtime": "react-native-web in browser",
"bundler": "Vite",
"entry": "index.html → src/web/main.tsx → App.tsx",
"devCommand": "npm run web",
"devUrl": "http://127.0.0.1:5173",
"primary": true
},
{
"id": "electron",
"label": "Desktop",
"runtime": "Electron (Chromium renderer)",
"bundler": "Vite (renderer) + tsc (main)",
"entry": "electron/main.ts loads Vite dev server or dist/renderer/",
"devCommand": "npm run electron:dev",
"primary": true
},
{
"id": "android",
"label": "Android",
"runtime": "React Native",
"bundler": "Metro",
"entry": "index.js → App.tsx",
"devCommand": "npm run android"
},
{
"id": "ios",
"label": "iOS",
"runtime": "React Native",
"bundler": "Metro",
"entry": "index.js → App.tsx",
"devCommand": "npm run ios"
}
],
"stack": [
"React Native 0.85.3",
"react-native-web",
"React 19.2.3",
"TypeScript ^5.8.3",
"Zustand ^5.0.14",
"Vite ^8",
"Electron ^42",
"socket.io / socket.io-client ^4.8 (declared, not wired)",
"Jest",
"ESLint + Prettier",
"Node >= 22.12.0"
],
"architecture": {
"rootComponent": "App.tsx",
"componentTree": [
"SafeAreaProvider",
"GameViewport",
"AppNavigator"
],
"navigation": {
"type": "zustand-store",
"routes": "src/navigation/routes.ts",
"navigator": "src/navigation/AppNavigator.tsx",
"store": "src/state/navigationStore.ts"
},
"state": {
"library": "zustand",
"stores": [
{
"file": "src/state/navigationStore.ts",
"persistKey": "blb-navigation"
},
{
"file": "src/state/gameSettingsStore.ts",
"persistKey": "blb-game-settings"
}
],
"persistHelper": "src/state/createPersistOptions.ts",
"platformStorage": {
"mobile": "src/state/persistStorage.ts",
"web": "src/state/persistStorage.web.ts"
}
},
"settings": "src/settings/gameSettings.ts",
"screens": [
"src/screens/HomeScreen.tsx",
"src/screens/SettingsScreen.tsx",
"src/screens/PoliciesScreen.tsx",
"src/screens/ToolkitScreen.tsx",
"src/screens/PageScreen.tsx"
],
"components": [
"src/components/GameViewport.tsx",
"src/components/ScreenLayout.tsx",
"src/components/MenuButton.tsx",
"src/components/BackHomeButton.tsx"
],
"webEntry": "src/web/main.tsx",
"electronMain": "electron/main.ts",
"electronPreload": "electron/preload.ts",
"buildOutputs": [
"dist/renderer/",
"dist/electron/"
]
},
"taskRoutes": {
"ui-screen": {
"description": "Add or modify screens, navigation routes, or shared UI components.",
"read": [
"llm.txt",
"manifest.llm.json",
"AGENTS.md",
"src/navigation/routes.ts",
"src/navigation/AppNavigator.tsx",
"<target screen or component>"
],
"update": [
"src/navigation/routes.ts when adding a route",
"src/navigation/AppNavigator.tsx when wiring a route",
"manifest.llm.json when screens, routes, or architecture sections change"
]
},
"state": {
"description": "Zustand stores, persistence, or game settings.",
"read": [
"llm.txt",
"manifest.llm.json",
"src/state/",
"src/settings/gameSettings.ts",
"src/state/persistStorage.ts",
"src/state/persistStorage.web.ts"
],
"policies": [
"Extend persistStorage.ts / persistStorage.web.ts for platform storage changes.",
"Do not embed DOM or AsyncStorage calls directly in stores."
],
"update": [
"manifest.llm.json when stores, persist keys, or settings surface change"
]
},
"web-electron": {
"description": "Vite config, web entry, Electron shell, or platform-specific .web.ts overrides.",
"read": [
"llm.txt",
"manifest.llm.json",
"vite.config.ts",
"index.html",
"src/web/",
"electron/",
"tsconfig.electron.json"
],
"policies": [
"DOM APIs are allowed in src/web/ and .web.ts shims only.",
"Electron main/preload compile to dist/electron/."
],
"update": [
"manifest.llm.json when targets, build outputs, or dev commands change"
]
},
"mobile": {
"description": "React Native mobile target changes (Metro, native deps).",
"read": [
"llm.txt",
"manifest.llm.json",
"AGENTS.md",
"metro.config.js",
"index.js",
"App.tsx"
],
"policies": [
"Do not modify android/ or ios/ native files unless explicitly requested.",
"Run pod install inside ios/ after native dependency changes."
]
},
"testing": {
"description": "Jest tests for logic, stores, routes, or screen scaffolding.",
"read": [
"llm.txt",
"manifest.llm.json",
"<target source>",
"<related __tests__ file>"
],
"verify": [
"npm run test"
]
},
"realtime": {
"description": "Socket.io server/client wiring (planned; not yet implemented).",
"read": [
"llm.txt",
"manifest.llm.json",
"socket/",
"package.json"
],
"policies": [
"socket/ is currently a stub — confirm scope before implementing."
]
}
},
"rootCommands": [
{
"command": "npm run web",
"purpose": "Vite dev server for browser iteration.",
"target": "web"
},
{
"command": "npm run web:build",
"purpose": "Production web build to dist/renderer/.",
"target": "web"
},
{
"command": "npm run web:preview",
"purpose": "Preview production web build.",
"target": "web"
},
{
"command": "npm run electron:dev",
"purpose": "Run Vite dev server and Electron concurrently.",
"target": "electron"
},
{
"command": "npm run electron:build",
"purpose": "Build renderer and compile Electron main process.",
"target": "electron"
},
{
"command": "npm run electron:package",
"purpose": "Build and package desktop installer via electron-builder.",
"target": "electron"
},
{
"command": "npm run start",
"purpose": "Start Metro bundler.",
"target": "android"
},
{
"command": "npm run android",
"purpose": "Run on Android emulator or device.",
"target": "android"
},
{
"command": "npm run ios",
"purpose": "Run on iOS simulator or device.",
"target": "ios"
},
{
"command": "npm run lint",
"purpose": "ESLint."
},
{
"command": "npm run test",
"purpose": "Jest test suite."
}
],
"knownGaps": [
"socket/ is a stub — socket.io and socket.io-client are installed but no server/client wiring exists.",
"docs/ and wiki/ are placeholder README stubs.",
"AGENTS.md still describes a mobile-only app and forbids all DOM APIs; it is stale relative to web/Electron targets.",
"README.md is the default React Native Community CLI template and does not document web or Electron workflows.",
"No JSON Schema validator exists yet for manifest.llm.json."
],
"verificationBaseline": {
"logicOrStoreChanges": [
"npm run test"
],
"uiChanges": [
"npm run lint",
"npm run test"
],
"webElectronChanges": [
"npm run lint",
"npm run test"
],
"mobileChanges": [
"npm run lint",
"npm run test"
]
}
}
+16876
View File
File diff suppressed because it is too large Load Diff
+52 -6
View File
@@ -2,18 +2,38 @@
"name": "BlbThroneOfTheBoneKing",
"version": "0.0.1",
"private": true,
"main": "dist/electron/main.js",
"scripts": {
"android": "react-native run-android",
"dev": "npm run web",
"dev:all": "concurrently -k -n app,toolkit -c blue,magenta \"npm run dev\" \"npm run dev:toolkit\"",
"dev:clear": "node socket/clear.mjs",
"dev:toolkit": "node socket/server.mjs",
"electron:build": "npm run web:build && npm run electron:compile",
"electron:compile": "tsc -p tsconfig.electron.json",
"electron:dev": "npm run electron:compile && cross-env NODE_ENV=development VITE_DEV_SERVER_URL=http://127.0.0.1:5173 concurrently -k \"vite --host 127.0.0.1\" \"wait-on http://127.0.0.1:5173 && electron .\"",
"electron:package": "npm run electron:build && electron-builder",
"ios": "react-native run-ios",
"lint": "eslint .",
"start": "react-native start",
"test": "jest"
"test": "jest",
"web": "vite --host 127.0.0.1",
"web:build": "vite build",
"web:preview": "vite preview --host 127.0.0.1"
},
"dependencies": {
"react": "19.2.3",
"react-native": "0.85.3",
"@react-native-async-storage/async-storage": "^3.1.1",
"@react-native/new-app-screen": "0.85.3",
"react-native-safe-area-context": "^5.5.2"
"cross-env": "^10.1.0",
"dotenv": "^17.4.2",
"react": "19.2.3",
"react-dom": "^19.2.3",
"react-native": "0.85.3",
"react-native-safe-area-context": "^5.5.2",
"react-native-web": "^0.21.2",
"socket.io": "^4.8.3",
"socket.io-client": "^4.8.3",
"zustand": "^5.0.14"
},
"devDependencies": {
"@babel/core": "^7.25.2",
@@ -28,15 +48,41 @@
"@react-native/metro-config": "0.85.3",
"@react-native/typescript-config": "0.85.3",
"@types/jest": "^29.5.13",
"@types/node": "^25.9.2",
"@types/react": "^19.2.0",
"@types/react-test-renderer": "^19.1.0",
"@vitejs/plugin-react": "^5.2.0",
"concurrently": "^10.0.3",
"electron": "^42.4.0",
"electron-builder": "^26.15.2",
"eslint": "^8.19.0",
"jest": "^29.6.3",
"prettier": "2.8.8",
"react-test-renderer": "19.2.3",
"typescript": "^5.8.3"
"typescript": "^5.8.3",
"vite": "^8.0.16",
"wait-on": "^9.0.10"
},
"engines": {
"node": ">= 22.11.0"
"node": ">= 22.12.0"
},
"build": {
"appId": "com.mathisonprojects.blb.throneoftheboneking",
"asar": true,
"productName": "Throne of the Bone King",
"files": [
"dist/**/*",
"package.json"
],
"mac": {
"category": "public.app-category.games"
},
"win": {
"target": "nsis"
},
"linux": {
"category": "Game",
"target": "AppImage"
}
}
}
View File
+49
View File
@@ -0,0 +1,49 @@
import 'dotenv/config';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
const execFileAsync = promisify(execFile);
const ports = [
process.env.WEB_DEV_PORT ?? '5173',
process.env.TOOLKIT_SOCKET_PORT ?? '5174',
];
async function getPortProcessIds(port) {
try {
const { stdout } = await execFileAsync('lsof', ['-ti', `tcp:${port}`]);
return stdout
.split('\n')
.map(processId => processId.trim())
.filter(Boolean);
} catch (error) {
if (error != null && typeof error === 'object' && 'code' in error) {
return [];
}
throw error;
}
}
async function killProcess(processId, port) {
try {
await execFileAsync('kill', ['-TERM', processId]);
console.log(`[dev:clear] stopped process ${processId} on port ${port}`);
} catch {
console.log(`[dev:clear] process ${processId} on port ${port} already stopped`);
}
}
for (const port of ports) {
const processIds = await getPortProcessIds(port);
if (processIds.length === 0) {
console.log(`[dev:clear] no process listening on port ${port}`);
continue;
}
await Promise.all(
processIds.map(processId => killProcess(processId, port)),
);
}
View File
+83
View File
@@ -0,0 +1,83 @@
import 'dotenv/config';
import { createServer } from 'node:http';
import { Server } from 'socket.io';
const defaultWebOrigin = 'http://127.0.0.1:5173';
const host = process.env.TOOLKIT_SOCKET_HOST ?? '127.0.0.1';
const port = Number.parseInt(process.env.TOOLKIT_SOCKET_PORT ?? '5174', 10);
const allowedOrigins = (
process.env.TOOLKIT_SOCKET_CORS_ORIGIN ?? defaultWebOrigin
)
.split(',')
.map(origin => origin.trim())
.filter(Boolean);
if (Number.isNaN(port)) {
throw new Error('TOOLKIT_SOCKET_PORT must be a valid number.');
}
const httpServer = createServer((request, response) => {
if (request.url === '/health') {
response.writeHead(200, {
'Content-Type': 'application/json',
});
response.end(
JSON.stringify({
ok: true,
service: 'blb-toolkit-socket',
}),
);
return;
}
response.writeHead(404, {
'Content-Type': 'application/json',
});
response.end(
JSON.stringify({
ok: false,
service: 'blb-toolkit-socket',
}),
);
});
const io = new Server(httpServer, {
cors: {
methods: ['GET', 'POST'],
origin: allowedOrigins,
},
});
io.on('connection', socket => {
socket.emit('toolkit:ready', {
connectedAt: new Date().toISOString(),
socketId: socket.id,
});
socket.on('toolkit:event', payload => {
socket.broadcast.emit('toolkit:event', payload);
});
});
function closeServer(signal) {
console.log(`[toolkit-socket] received ${signal}; shutting down`);
io.close(() => {
httpServer.close(() => {
process.exit(0);
});
});
}
process.on('SIGINT', () => closeServer('SIGINT'));
process.on('SIGTERM', () => closeServer('SIGTERM'));
httpServer.listen(port, host, () => {
console.log(
`[toolkit-socket] listening at http://${host}:${port} with CORS ${allowedOrigins.join(
', ',
)}`,
);
});
+11
View File
@@ -0,0 +1,11 @@
import { MenuButton } from './MenuButton';
type BackHomeButtonProps = Readonly<{
onPress: () => void;
}>;
export function BackHomeButton({ onPress }: BackHomeButtonProps) {
return (
<MenuButton label="Home" onPress={onPress} testID="back-home-button" />
);
}
+77
View File
@@ -0,0 +1,77 @@
import type { ReactNode } from 'react';
import { StyleSheet, View, useWindowDimensions } from 'react-native';
import { getGameResolutionById } from '../settings/gameSettings';
import { useGameSettingsStore } from '../state';
type GameViewportProps = Readonly<{
children: ReactNode;
}>;
export function GameViewport({ children }: GameViewportProps) {
const resolutionId = useGameSettingsStore(state => state.resolutionId);
const resolution = getGameResolutionById(resolutionId);
const windowDimensions = useWindowDimensions();
if ('unbounded' in resolution) {
return (
<View style={styles.stage}>
<View style={styles.unboundedSurface}>{children}</View>
</View>
);
}
const scale = Math.min(
windowDimensions.width / resolution.width,
windowDimensions.height / resolution.height,
1,
);
return (
<View style={styles.stage}>
<View
style={[
styles.viewport,
{
height: resolution.height * scale,
width: resolution.width * scale,
},
]}>
<View
style={[
styles.gameSurface,
{
height: resolution.height,
transform: [{ scale }],
width: resolution.width,
},
]}>
{children}
</View>
</View>
</View>
);
}
const styles = StyleSheet.create({
gameSurface: {
backgroundColor: '#111315',
overflow: 'hidden',
},
stage: {
alignItems: 'center',
backgroundColor: '#050607',
flex: 1,
justifyContent: 'center',
},
viewport: {
backgroundColor: '#111315',
overflow: 'hidden',
},
unboundedSurface: {
alignSelf: 'stretch',
backgroundColor: '#111315',
flex: 1,
overflow: 'hidden',
},
});
+120
View File
@@ -0,0 +1,120 @@
import { Pressable, StyleSheet, Text, View } from 'react-native';
type MenuButtonTone = 'default' | 'toolkit';
type MenuButtonProps = Readonly<{
disabled?: boolean;
icon?: string;
label: string;
onPress: () => void;
testID?: string;
tone?: MenuButtonTone;
}>;
export function MenuButton({
disabled = false,
icon,
label,
onPress,
testID,
tone = 'default',
}: MenuButtonProps) {
const isToolkit = tone === 'toolkit';
const pressedStyle = isToolkit
? styles.toolkitButtonPressed
: styles.buttonPressed;
return (
<Pressable
accessibilityState={{ disabled }}
accessibilityRole="button"
disabled={disabled}
onPress={onPress}
style={({ pressed }) => [
styles.button,
isToolkit ? styles.toolkitButton : null,
disabled ? styles.buttonDisabled : null,
pressed ? pressedStyle : null,
]}
testID={testID}>
<View style={styles.content}>
{icon == null ? null : (
<Text
style={[
styles.icon,
isToolkit ? styles.toolkitText : null,
disabled ? styles.textDisabled : null,
]}
testID={testID == null ? undefined : `${testID}-icon`}>
{icon}
</Text>
)}
<Text
style={[
styles.label,
isToolkit ? styles.toolkitText : null,
disabled ? styles.textDisabled : null,
]}>
{label}
</Text>
</View>
</Pressable>
);
}
const styles = StyleSheet.create({
button: {
alignItems: 'center',
backgroundColor: '#24272b',
borderColor: '#4d4432',
borderRadius: 8,
borderWidth: 1,
minHeight: 42,
justifyContent: 'center',
paddingHorizontal: 14,
paddingVertical: 9,
width: '100%',
},
buttonPressed: {
backgroundColor: '#33302a',
},
buttonDisabled: {
backgroundColor: '#1a1c1f',
borderColor: '#2b2d30',
opacity: 0.55,
},
content: {
alignItems: 'center',
flexDirection: 'row',
gap: 8,
justifyContent: 'center',
},
icon: {
color: '#d6b25e',
fontSize: 13,
fontWeight: '800',
letterSpacing: 0,
lineHeight: 16,
textAlign: 'center',
},
label: {
color: '#f6f1e7',
fontSize: 14,
fontWeight: '700',
letterSpacing: 0,
textAlign: 'center',
},
toolkitButton: {
backgroundColor: '#3a2918',
borderColor: '#d6b25e',
},
toolkitButtonPressed: {
backgroundColor: '#4a351f',
},
toolkitText: {
color: '#f5d98d',
},
textDisabled: {
color: '#80796c',
},
});
+79
View File
@@ -0,0 +1,79 @@
import type { ReactNode } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
type ScreenLayoutProps = Readonly<{
children: ReactNode;
eyebrow?: string;
title: string;
}>;
export function ScreenLayout({
children,
eyebrow = 'Bone Lord Bob',
title,
}: ScreenLayoutProps) {
const safeAreaInsets = useSafeAreaInsets();
return (
<View
style={[
styles.container,
{
paddingBottom: safeAreaInsets.bottom + 18,
paddingLeft: safeAreaInsets.left + 22,
paddingRight: safeAreaInsets.right + 22,
paddingTop: safeAreaInsets.top + 18,
},
]}>
<View style={styles.content}>
<View style={styles.header}>
<Text style={styles.eyebrow}>{eyebrow}</Text>
<Text style={styles.title}>{title}</Text>
</View>
<View style={styles.body}>{children}</View>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#111315',
},
content: {
alignItems: 'center',
flex: 1,
flexDirection: 'row',
gap: 20,
justifyContent: 'space-between',
},
header: {
alignItems: 'flex-start',
flex: 1,
minWidth: 0,
},
eyebrow: {
color: '#d6b25e',
fontSize: 12,
fontWeight: '700',
letterSpacing: 0,
marginBottom: 12,
textAlign: 'left',
textTransform: 'uppercase',
},
title: {
color: '#f6f1e7',
fontSize: 30,
fontWeight: '800',
letterSpacing: 0,
lineHeight: 36,
textAlign: 'left',
},
body: {
alignSelf: 'center',
gap: 12,
width: 360,
},
});
+80
View File
@@ -0,0 +1,80 @@
import { useEffect } from 'react';
import { io, type Socket } from 'socket.io-client';
import { useToolkitSocketStore } from '../state';
type NodeProcess = {
env?: Partial<Record<string, string>>;
};
const defaultToolkitSocketUrl = 'http://127.0.0.1:5174';
let toolkitSocket: Socket | null = null;
function getToolkitSocketUrl() {
const maybeProcess =
typeof process === 'undefined' ? undefined : (process as NodeProcess);
return maybeProcess?.env?.TOOLKIT_SOCKET_URL ?? defaultToolkitSocketUrl;
}
function getNodeEnvironment() {
const maybeProcess =
typeof process === 'undefined' ? undefined : (process as NodeProcess);
return maybeProcess?.env?.NODE_ENV;
}
export function useToolkitSocketConnection() {
const setConnected = useToolkitSocketStore(state => state.setConnected);
const setConnecting = useToolkitSocketStore(state => state.setConnecting);
const setDisconnected = useToolkitSocketStore(state => state.setDisconnected);
useEffect(() => {
if (getNodeEnvironment() === 'test') {
return undefined;
}
if (toolkitSocket == null) {
setConnecting();
toolkitSocket = io(getToolkitSocketUrl(), {
reconnection: true,
reconnectionAttempts: Number.POSITIVE_INFINITY,
reconnectionDelay: 750,
transports: ['websocket'],
});
}
const activeSocket = toolkitSocket;
function handleConnect() {
setConnected(activeSocket.id ?? 'unknown');
}
function handleConnectionAttempt() {
setConnecting();
}
function handleDisconnect() {
setDisconnected();
}
activeSocket.on('connect', handleConnect);
activeSocket.on('disconnect', handleDisconnect);
activeSocket.io.on('reconnect_attempt', handleConnectionAttempt);
if (activeSocket.connected) {
handleConnect();
} else {
setConnecting();
activeSocket.connect();
}
return () => {
activeSocket.off('connect', handleConnect);
activeSocket.off('disconnect', handleDisconnect);
activeSocket.io.off('reconnect_attempt', handleConnectionAttempt);
};
}, [setConnected, setConnecting, setDisconnected]);
}
+133
View File
@@ -0,0 +1,133 @@
import { useCallback, useEffect, useRef } from 'react';
import { Animated, StyleSheet } from 'react-native';
import { useToolkitSocketConnection } from '../hooks/useToolkitSocketConnection';
import { HomeScreen } from '../screens/HomeScreen';
import { PageScreen } from '../screens/PageScreen';
import { PoliciesScreen } from '../screens/PoliciesScreen';
import { SettingsScreen } from '../screens/SettingsScreen';
import { ToolkitScreen } from '../screens/ToolkitScreen';
import { useNavigationStore } from '../state';
import { toolkitRoutes, type RouteName } from './routes';
const fadeOutDurationMs = process.env.NODE_ENV === 'test' ? 0 : 140;
const fadeInDurationMs = process.env.NODE_ENV === 'test' ? 0 : 180;
export function AppNavigator() {
useToolkitSocketConnection();
const cancelPendingRoute = useNavigationStore(state => state.cancelPendingRoute);
const completeNavigation = useNavigationStore(state => state.completeNavigation);
const pendingRouteName = useNavigationStore(state => state.pendingRouteName);
const routeName = useNavigationStore(state => state.currentRouteName);
const setPendingRoute = useNavigationStore(state => state.setPendingRoute);
const opacity = useRef(new Animated.Value(1)).current;
const isTransitioning = useRef(false);
useEffect(() => {
if (pendingRouteName == null || isTransitioning.current) {
return;
}
completeNavigation(pendingRouteName);
}, [completeNavigation, pendingRouteName]);
const navigate = useCallback((nextRouteName: RouteName) => {
if (
nextRouteName === routeName ||
nextRouteName === pendingRouteName ||
isTransitioning.current
) {
return;
}
isTransitioning.current = true;
setPendingRoute(nextRouteName);
if (fadeOutDurationMs === 0 && fadeInDurationMs === 0) {
opacity.setValue(0);
completeNavigation(nextRouteName);
opacity.setValue(1);
isTransitioning.current = false;
return;
}
Animated.timing(opacity, {
duration: fadeOutDurationMs,
toValue: 0,
useNativeDriver: true,
}).start(({ finished }) => {
if (!finished) {
cancelPendingRoute();
isTransitioning.current = false;
return;
}
completeNavigation(nextRouteName);
Animated.timing(opacity, {
duration: fadeInDurationMs,
toValue: 1,
useNativeDriver: true,
}).start(() => {
isTransitioning.current = false;
});
});
}, [
cancelPendingRoute,
completeNavigation,
opacity,
pendingRouteName,
routeName,
setPendingRoute,
]);
const goHome = useCallback(() => {
navigate('home');
}, [navigate]);
return (
<Animated.View style={styles.container}>
<Animated.View style={[styles.content, { opacity }]}>
{renderRoute(routeName, navigate, goHome)}
</Animated.View>
</Animated.View>
);
}
function renderRoute(
routeName: RouteName,
navigate: (routeName: RouteName) => void,
goHome: () => void,
) {
if (routeName === 'home') {
return <HomeScreen navigate={navigate} />;
}
if (routeName === 'policies') {
return <PoliciesScreen goHome={goHome} navigate={navigate} />;
}
if (routeName === 'settings') {
return <SettingsScreen goHome={goHome} />;
}
if (routeName === 'toolkit' || toolkitRoutes.includes(routeName)) {
return (
<ToolkitScreen
goHome={goHome}
navigate={navigate}
routeName={routeName}
/>
);
}
return <PageScreen goHome={goHome} routeName={routeName} />;
}
const styles = StyleSheet.create({
container: {
backgroundColor: '#111315',
flex: 1,
},
content: {
flex: 1,
},
});
+110
View File
@@ -0,0 +1,110 @@
export const routeDefinitions = {
home: {
label: 'Home',
title: 'Throne of the Bone King',
},
newGame: {
label: 'New Game',
title: 'New Game',
},
saves: {
label: 'Saves',
title: 'Saves',
},
settings: {
label: 'Settings',
title: 'Settings',
},
policies: {
label: 'Policies',
title: 'Policies',
},
policiesLegal: {
label: 'Legal',
title: 'Legal',
},
policiesPrivacy: {
label: 'Privacy',
title: 'Privacy',
},
policiesTerms: {
label: 'Terms and Conditions',
title: 'Terms and Conditions',
},
policiesCookies: {
label: 'Cookies',
title: 'Cookies',
},
credits: {
label: 'Credits',
title: 'Credits',
},
toolkit: {
label: 'Toolkit',
title: 'Toolkit',
},
toolkitOpeningSequence: {
label: 'Opening Sequence',
title: 'Opening Sequence',
},
toolkitCredits: {
label: 'Credits',
title: 'Toolkit Credits',
},
toolkitAudio: {
label: 'Audio',
title: 'Audio',
},
toolkitLore: {
label: 'Lore',
title: 'Lore',
},
toolkitResources: {
label: 'Resources',
title: 'Resources',
},
} as const;
export type RouteName = keyof typeof routeDefinitions;
export const primaryMenuRoutes = [
'newGame',
'saves',
'settings',
'policies',
'credits',
'toolkit',
] as const satisfies readonly RouteName[];
export const policyRoutes = [
'policiesLegal',
'policiesPrivacy',
'policiesTerms',
'policiesCookies',
] as const satisfies readonly RouteName[];
export const toolkitRoutes = [
'toolkitOpeningSequence',
'toolkitCredits',
'toolkitAudio',
'toolkitLore',
'toolkitResources',
] as const satisfies readonly RouteName[];
export const requiredPageRoutes = [
'home',
'newGame',
'saves',
'settings',
'policiesLegal',
'policiesPrivacy',
'policiesTerms',
'policiesCookies',
'credits',
'toolkit',
'toolkitOpeningSequence',
'toolkitCredits',
'toolkitAudio',
'toolkitLore',
'toolkitResources',
] as const satisfies readonly RouteName[];
+122
View File
@@ -0,0 +1,122 @@
import { StyleSheet, Text, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { MenuButton } from '../components/MenuButton';
import {
primaryMenuRoutes,
routeDefinitions,
type RouteName,
} from '../navigation/routes';
import { useGameSettingsStore, useToolkitSocketStore } from '../state';
type PrimaryMenuRouteName = (typeof primaryMenuRoutes)[number];
type HomeScreenProps = Readonly<{
navigate: (routeName: RouteName) => void;
}>;
const menuIcons: Record<PrimaryMenuRouteName, string> = {
credits: '★',
newGame: '▶',
policies: '§',
saves: '▣',
settings: '⚙',
toolkit: '✦',
};
export function HomeScreen({ navigate }: HomeScreenProps) {
const safeAreaInsets = useSafeAreaInsets();
const resolutionId = useGameSettingsStore(state => state.resolutionId);
const toolkitSocketStatus = useToolkitSocketStore(state => state.status);
const visibleMenuRoutes = primaryMenuRoutes.filter(
routeName => routeName !== 'toolkit' || resolutionId === 'browser',
);
return (
<View
style={[
styles.container,
{
paddingBottom: safeAreaInsets.bottom,
paddingLeft: safeAreaInsets.left,
paddingRight: safeAreaInsets.right,
paddingTop: safeAreaInsets.top,
},
]}>
<View style={styles.content}>
<View style={styles.copy}>
<Text style={styles.eyebrow}>Bone Lord Bob</Text>
<Text style={styles.title}>{routeDefinitions.home.title}</Text>
<Text style={styles.description}>
Choose a path through the kingdom.
</Text>
</View>
<View style={styles.menu}>
{visibleMenuRoutes.map(routeName => (
<MenuButton
key={routeName}
disabled={
routeName === 'toolkit' && toolkitSocketStatus !== 'connected'
}
icon={menuIcons[routeName]}
label={routeDefinitions[routeName].label}
onPress={() => navigate(routeName)}
testID={`menu-${routeName}`}
tone={routeName === 'toolkit' ? 'toolkit' : 'default'}
/>
))}
</View>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
backgroundColor: '#111315',
flex: 1,
},
content: {
alignItems: 'center',
flex: 1,
flexDirection: 'row',
gap: 20,
justifyContent: 'space-between',
paddingHorizontal: 22,
paddingVertical: 18,
},
copy: {
alignItems: 'flex-start',
flex: 1,
minWidth: 0,
},
description: {
color: '#c9c1b3',
fontSize: 14,
lineHeight: 20,
maxWidth: 220,
textAlign: 'left',
},
eyebrow: {
color: '#d6b25e',
fontSize: 12,
fontWeight: '700',
letterSpacing: 0,
marginBottom: 12,
textTransform: 'uppercase',
},
menu: {
gap: 8,
marginLeft: 'auto',
width: 156,
},
title: {
color: '#f6f1e7',
fontSize: 34,
fontWeight: '800',
letterSpacing: 0,
lineHeight: 40,
marginBottom: 16,
textAlign: 'left',
},
});
+56
View File
@@ -0,0 +1,56 @@
import { StyleSheet, Text, View } from 'react-native';
import { BackHomeButton } from '../components/BackHomeButton';
import { ScreenLayout } from '../components/ScreenLayout';
import { routeDefinitions, type RouteName } from '../navigation/routes';
type PageRouteName = Exclude<
RouteName,
| 'home'
| 'policies'
| 'settings'
| 'toolkit'
| 'toolkitOpeningSequence'
| 'toolkitCredits'
| 'toolkitAudio'
>;
type PageScreenProps = Readonly<{
goHome: () => void;
routeName: PageRouteName;
}>;
const pageDescriptions: Record<PageRouteName, string> = {
credits: 'Credits content will list the team, contributors, and acknowledgements.',
newGame: 'New Game setup will begin here.',
policiesCookies: 'Cookie policy content will live here.',
policiesLegal: 'Legal policy content will live here.',
policiesPrivacy: 'Privacy policy content will live here.',
policiesTerms: 'Terms and conditions content will live here.',
saves: 'Saved games will be listed here.',
};
export function PageScreen({ goHome, routeName }: PageScreenProps) {
const route = routeDefinitions[routeName];
return (
<ScreenLayout title={route.title}>
<Text style={styles.description}>{pageDescriptions[routeName]}</Text>
<View style={styles.actions}>
<BackHomeButton onPress={goHome} />
</View>
</ScreenLayout>
);
}
const styles = StyleSheet.create({
actions: {
marginTop: 12,
},
description: {
color: '#c9c1b3',
fontSize: 16,
lineHeight: 24,
textAlign: 'center',
},
});
+55
View File
@@ -0,0 +1,55 @@
import { StyleSheet, Text, View } from 'react-native';
import { BackHomeButton } from '../components/BackHomeButton';
import { MenuButton } from '../components/MenuButton';
import { ScreenLayout } from '../components/ScreenLayout';
import {
policyRoutes,
routeDefinitions,
type RouteName,
} from '../navigation/routes';
type PoliciesScreenProps = Readonly<{
goHome: () => void;
navigate: (routeName: RouteName) => void;
}>;
export function PoliciesScreen({ goHome, navigate }: PoliciesScreenProps) {
return (
<ScreenLayout title={routeDefinitions.policies.title}>
<Text style={styles.description}>
Review the policies for playing and using this app.
</Text>
<View style={styles.menu}>
{policyRoutes.map(routeName => (
<MenuButton
key={routeName}
label={routeDefinitions[routeName].label}
onPress={() => navigate(routeName)}
testID={`policy-${routeName}`}
/>
))}
</View>
<View style={styles.homeAction}>
<BackHomeButton onPress={goHome} />
</View>
</ScreenLayout>
);
}
const styles = StyleSheet.create({
description: {
color: '#c9c1b3',
fontSize: 16,
lineHeight: 24,
marginBottom: 12,
textAlign: 'center',
},
homeAction: {
marginTop: 12,
},
menu: {
gap: 12,
width: '100%',
},
});
+364
View File
@@ -0,0 +1,364 @@
import { useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { BackHomeButton } from '../components/BackHomeButton';
import {
formatResolutionLabel,
gameResolutionOptions,
volumeSettings,
type VolumeSettingKey,
} from '../settings/gameSettings';
import { useGameSettingsStore } from '../state';
type SettingsScreenProps = Readonly<{
goHome: () => void;
}>;
export function SettingsScreen({ goHome }: SettingsScreenProps) {
const safeAreaInsets = useSafeAreaInsets();
const [isResolutionSelectOpen, setIsResolutionSelectOpen] = useState(false);
const autosaveEnabled = useGameSettingsStore(state => state.autosaveEnabled);
const resolutionId = useGameSettingsStore(state => state.resolutionId);
const setResolution = useGameSettingsStore(state => state.setResolution);
const setVolume = useGameSettingsStore(state => state.setVolume);
const toggleAutosave = useGameSettingsStore(state => state.toggleAutosave);
const volumes = useGameSettingsStore(state => state.volumes);
return (
<View
style={[
styles.container,
{
paddingBottom: safeAreaInsets.bottom + 16,
paddingLeft: safeAreaInsets.left + 18,
paddingRight: safeAreaInsets.right + 18,
paddingTop: safeAreaInsets.top + 16,
},
]}>
<View style={styles.header}>
<Text style={styles.eyebrow}>Bone Lord Bob</Text>
<Text style={styles.title}>Settings</Text>
<Text style={styles.summary}>
Tune the enforced game surface and audio mix.
</Text>
<View style={styles.homeAction}>
<BackHomeButton onPress={goHome} />
</View>
</View>
<View style={styles.resolutionPanel}>
<Text style={styles.sectionTitle}>Game Resolution</Text>
<Text style={styles.helperText}>
{formatResolutionLabel(resolutionId)}
</Text>
<View style={styles.selectWrapper}>
<SettingButton
label={`${formatResolutionLabel(resolutionId)} ${
isResolutionSelectOpen ? '▲' : '▼'
}`}
onPress={() => setIsResolutionSelectOpen(isOpen => !isOpen)}
testID="resolution-select"
/>
{isResolutionSelectOpen ? (
<View style={styles.selectMenu} testID="resolution-options">
{gameResolutionOptions.map(option => {
const label =
'unbounded' in option
? `${option.label} (Fill available space)`
: `${option.label} (${option.width} x ${option.height})`;
return (
<SettingButton
key={option.id}
active={option.id === resolutionId}
label={label}
onPress={() => {
setResolution(option.id);
setIsResolutionSelectOpen(false);
}}
testID={`resolution-${option.id}`}
/>
);
})}
</View>
) : null}
</View>
<View style={styles.checkboxRow}>
<Pressable
accessibilityRole="checkbox"
accessibilityState={{ checked: autosaveEnabled }}
onPress={toggleAutosave}
style={styles.checkbox}
testID="autosave-checkbox">
<Text style={styles.checkboxMark}>
{autosaveEnabled ? '✓' : ''}
</Text>
</Pressable>
<View style={styles.checkboxCopy}>
<Text style={styles.checkboxLabel}>Autosave</Text>
<Text style={styles.checkboxHint}>Enabled by default</Text>
</View>
</View>
</View>
<View style={styles.audioPanel}>
<Text style={styles.sectionTitle}>Audio</Text>
{volumeSettings.map(setting => (
<VolumeControl
key={setting.key}
label={setting.label}
onChange={nextVolume => setVolume(setting.key, nextVolume)}
settingKey={setting.key}
value={volumes[setting.key]}
/>
))}
</View>
</View>
);
}
type SettingButtonProps = Readonly<{
active?: boolean;
label: string;
onPress: () => void;
testID: string;
}>;
function SettingButton({
active = false,
label,
onPress,
testID,
}: SettingButtonProps) {
return (
<Pressable
accessibilityRole="button"
onPress={onPress}
style={({ pressed }) => [
styles.settingButton,
active ? styles.settingButtonActive : null,
pressed ? styles.settingButtonPressed : null,
]}
testID={testID}>
<Text
style={[
styles.settingButtonLabel,
active ? styles.settingButtonLabelActive : null,
]}>
{label}
</Text>
</Pressable>
);
}
type VolumeControlProps = Readonly<{
label: string;
onChange: (volume: number) => void;
settingKey: VolumeSettingKey;
value: number;
}>;
function VolumeControl({
label,
onChange,
settingKey,
value,
}: VolumeControlProps) {
return (
<View style={styles.volumeRow}>
<Text style={styles.volumeLabel}>{label}</Text>
<View style={styles.volumeControls}>
<SettingButton
label="-"
onPress={() => onChange(value - 5)}
testID={`volume-${settingKey}-decrease`}
/>
<Text style={styles.volumeValue} testID={`volume-${settingKey}-value`}>
{value}
</Text>
<SettingButton
label="+"
onPress={() => onChange(value + 5)}
testID={`volume-${settingKey}-increase`}
/>
</View>
</View>
);
}
const styles = StyleSheet.create({
audioPanel: {
backgroundColor: '#191b1f',
borderColor: '#37342d',
borderRadius: 8,
borderWidth: 1,
gap: 10,
padding: 12,
width: 268,
},
container: {
alignItems: 'stretch',
backgroundColor: '#111315',
flex: 1,
flexDirection: 'row',
gap: 14,
},
eyebrow: {
color: '#d6b25e',
fontSize: 11,
fontWeight: '700',
letterSpacing: 0,
marginBottom: 10,
textTransform: 'uppercase',
},
header: {
justifyContent: 'flex-start',
width: 190,
},
helperText: {
color: '#c9c1b3',
fontSize: 12,
lineHeight: 16,
},
homeAction: {
marginTop: 18,
},
checkbox: {
alignItems: 'center',
backgroundColor: '#24272b',
borderColor: '#d6b25e',
borderRadius: 6,
borderWidth: 1,
height: 28,
justifyContent: 'center',
width: 28,
},
checkboxCopy: {
flex: 1,
},
checkboxHint: {
color: '#c9c1b3',
fontSize: 11,
lineHeight: 14,
},
checkboxLabel: {
color: '#f6f1e7',
fontSize: 13,
fontWeight: '800',
letterSpacing: 0,
},
checkboxMark: {
color: '#f5d98d',
fontSize: 18,
fontWeight: '800',
lineHeight: 22,
},
checkboxRow: {
alignItems: 'center',
flexDirection: 'row',
gap: 10,
},
resolutionPanel: {
backgroundColor: '#191b1f',
borderColor: '#37342d',
borderRadius: 8,
borderWidth: 1,
flex: 1,
gap: 10,
justifyContent: 'flex-start',
padding: 12,
zIndex: 2,
},
sectionTitle: {
color: '#f6f1e7',
fontSize: 16,
fontWeight: '800',
letterSpacing: 0,
},
settingButton: {
alignItems: 'center',
backgroundColor: '#24272b',
borderColor: '#4d4432',
borderRadius: 8,
borderWidth: 1,
justifyContent: 'center',
minHeight: 34,
minWidth: 38,
paddingHorizontal: 10,
paddingVertical: 7,
},
settingButtonActive: {
backgroundColor: '#3a3120',
borderColor: '#d6b25e',
},
settingButtonLabel: {
color: '#f6f1e7',
fontSize: 12,
fontWeight: '700',
letterSpacing: 0,
textAlign: 'center',
},
settingButtonLabelActive: {
color: '#f5d98d',
},
settingButtonPressed: {
backgroundColor: '#33302a',
},
selectMenu: {
backgroundColor: '#191b1f',
borderColor: '#4d4432',
borderRadius: 8,
borderWidth: 1,
gap: 6,
left: 0,
padding: 6,
position: 'absolute',
right: 0,
top: 40,
zIndex: 4,
},
selectWrapper: {
position: 'relative',
zIndex: 3,
},
summary: {
color: '#c9c1b3',
fontSize: 13,
lineHeight: 18,
},
title: {
color: '#f6f1e7',
fontSize: 30,
fontWeight: '800',
letterSpacing: 0,
lineHeight: 34,
marginBottom: 12,
},
volumeControls: {
alignItems: 'center',
flexDirection: 'row',
gap: 8,
},
volumeLabel: {
color: '#f6f1e7',
flex: 1,
fontSize: 13,
fontWeight: '700',
letterSpacing: 0,
},
volumeRow: {
alignItems: 'center',
flexDirection: 'row',
gap: 12,
justifyContent: 'space-between',
},
volumeValue: {
color: '#c9c1b3',
fontSize: 13,
fontWeight: '700',
minWidth: 32,
textAlign: 'center',
},
});
+301
View File
@@ -0,0 +1,301 @@
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import {
routeDefinitions,
toolkitRoutes,
type RouteName,
} from '../navigation/routes';
import { useToolkitSocketStore, type ToolkitSocketStatus } from '../state';
type ToolkitRouteName = (typeof toolkitRoutes)[number];
type ToolkitScreenProps = Readonly<{
goHome: () => void;
navigate: (routeName: RouteName) => void;
routeName: RouteName;
}>;
const toolkitDescriptions: Partial<Record<RouteName, string>> = {
toolkit: 'Select a toolkit area to preview game support assets.',
toolkitAudio: 'Audio tools and checks will live here.',
toolkitCredits: 'Toolkit-specific credit review will live here.',
toolkitLore: 'Lore reference material will live here.',
toolkitOpeningSequence: 'Opening sequence preview controls will live here.',
toolkitResources: 'Production and gameplay resources will live here.',
};
const toolkitIcons: Record<ToolkitRouteName, string> = {
toolkitAudio: '♪',
toolkitCredits: '★',
toolkitLore: '✦',
toolkitOpeningSequence: '▶',
toolkitResources: '▦',
};
const socketStatusCopy: Record<ToolkitSocketStatus, string> = {
connected: 'Connected',
connecting: 'Connecting',
disconnected: 'Disconnected',
};
export function ToolkitScreen({
goHome,
navigate,
routeName,
}: ToolkitScreenProps) {
const safeAreaInsets = useSafeAreaInsets();
const activeRoute = routeDefinitions[routeName];
const socketStatus = useToolkitSocketStore(state => state.status);
return (
<View
style={[
styles.container,
{
paddingBottom: safeAreaInsets.bottom + 12,
paddingLeft: safeAreaInsets.left + 12,
paddingRight: safeAreaInsets.right + 12,
paddingTop: safeAreaInsets.top + 12,
},
]}>
<View style={styles.toolbar} testID="toolkit-toolbar">
<View style={styles.toolbarGroup} testID="toolkit-option-group">
{toolkitRoutes.map(toolkitRouteName => (
<ToolkitToolbarButton
key={toolkitRouteName}
active={toolkitRouteName === routeName}
icon={toolkitIcons[toolkitRouteName]}
label={routeDefinitions[toolkitRouteName].label}
onPress={() => navigate(toolkitRouteName)}
testID={`toolkit-${toolkitRouteName}`}
/>
))}
</View>
<View style={styles.homeSlot} testID="toolkit-home-slot">
<ToolkitToolbarButton
accessibilityLabel="Home"
icon="⌂"
iconOnly
label="Home"
onPress={goHome}
testID="toolkit-home"
/>
</View>
</View>
<View style={styles.contentPanel}>
<Text style={styles.eyebrow}>Bone Lord Bob</Text>
<Text style={styles.title}>{activeRoute.title}</Text>
<Text style={styles.description}>
{toolkitDescriptions[routeName] ?? toolkitDescriptions.toolkit}
</Text>
<ToolkitSocketIndicator status={socketStatus} />
</View>
</View>
);
}
type ToolkitSocketIndicatorProps = Readonly<{
status: ToolkitSocketStatus;
}>;
function ToolkitSocketIndicator({ status }: ToolkitSocketIndicatorProps) {
return (
<View
accessibilityLabel={`Toolkit socket ${socketStatusCopy[status]}`}
style={styles.socketIndicator}
testID="toolkit-socket-indicator">
<View
style={[styles.socketDot, socketDotStyles[status]]}
testID="toolkit-socket-dot"
/>
<Text style={styles.socketLabel}>{socketStatusCopy[status]}</Text>
</View>
);
}
type ToolkitToolbarButtonProps = Readonly<{
active?: boolean;
accessibilityLabel?: string;
icon: string;
iconOnly?: boolean;
label: string;
onPress: () => void;
testID: string;
}>;
function ToolkitToolbarButton({
active = false,
accessibilityLabel,
icon,
iconOnly = false,
label,
onPress,
testID,
}: ToolkitToolbarButtonProps) {
return (
<Pressable
accessibilityLabel={accessibilityLabel ?? label}
accessibilityRole="button"
onPress={onPress}
style={({ pressed }) => [
styles.toolbarButton,
iconOnly ? styles.toolbarButtonIconOnly : null,
active ? styles.toolbarButtonActive : null,
pressed ? styles.toolbarButtonPressed : null,
]}
testID={testID}>
<View style={styles.toolbarButtonContent}>
<Text
style={[
styles.toolbarButtonIcon,
active ? styles.toolbarButtonLabelActive : null,
]}
testID={`${testID}-icon`}>
{icon}
</Text>
{iconOnly ? null : (
<Text
style={[
styles.toolbarButtonLabel,
active ? styles.toolbarButtonLabelActive : null,
]}
testID={`${testID}-label`}>
{label}
</Text>
)}
</View>
</Pressable>
);
}
const styles = StyleSheet.create({
container: {
alignItems: 'stretch',
backgroundColor: '#111315',
flex: 1,
gap: 8,
},
contentPanel: {
backgroundColor: '#191b1f',
borderColor: '#37342d',
borderRadius: 8,
borderWidth: 1,
flex: 1,
justifyContent: 'flex-start',
padding: 14,
position: 'relative',
},
description: {
color: '#c9c1b3',
fontSize: 14,
lineHeight: 20,
maxWidth: 420,
},
eyebrow: {
color: '#d6b25e',
fontSize: 11,
fontWeight: '700',
letterSpacing: 0,
marginBottom: 10,
textTransform: 'uppercase',
},
title: {
color: '#f6f1e7',
fontSize: 30,
fontWeight: '800',
letterSpacing: 0,
lineHeight: 34,
marginBottom: 12,
},
socketDot: {
borderRadius: 5,
height: 10,
width: 10,
},
socketIndicator: {
alignItems: 'center',
bottom: 12,
flexDirection: 'row',
gap: 7,
position: 'absolute',
right: 12,
},
socketLabel: {
color: '#c9c1b3',
fontSize: 11,
fontWeight: '700',
letterSpacing: 0,
textTransform: 'uppercase',
},
homeSlot: {
marginLeft: 'auto',
},
toolbar: {
alignItems: 'stretch',
flexDirection: 'row',
minHeight: 34,
},
toolbarButton: {
alignItems: 'center',
backgroundColor: '#24272b',
borderColor: '#4d4432',
borderRadius: 0,
borderWidth: 1,
justifyContent: 'center',
minHeight: 34,
paddingHorizontal: 12,
paddingVertical: 6,
},
toolbarButtonActive: {
backgroundColor: '#3a3120',
borderColor: '#d6b25e',
},
toolbarButtonContent: {
alignItems: 'center',
flexDirection: 'row',
gap: 5,
justifyContent: 'center',
},
toolbarButtonIcon: {
color: '#f5d98d',
fontSize: 12,
fontWeight: '800',
letterSpacing: 0,
lineHeight: 14,
textAlign: 'center',
},
toolbarButtonIconOnly: {
minWidth: 38,
paddingHorizontal: 9,
},
toolbarButtonLabel: {
color: '#f6f1e7',
fontSize: 12,
fontWeight: '800',
letterSpacing: 0,
textAlign: 'center',
},
toolbarButtonLabelActive: {
color: '#f5d98d',
},
toolbarButtonPressed: {
backgroundColor: '#33302a',
},
toolbarGroup: {
flexDirection: 'row',
},
});
const socketDotStyles = StyleSheet.create({
connected: {
backgroundColor: '#3fbf6f',
},
connecting: {
backgroundColor: '#d6b25e',
},
disconnected: {
backgroundColor: '#d85a4d',
},
});
+88
View File
@@ -0,0 +1,88 @@
export const gameResolutionOptions = [
{
id: 'browser',
label: 'Browser',
unbounded: true,
},
{
id: 'iphoneMini',
label: 'Phone Mini',
width: 812,
height: 375,
},
{
id: 'iphoneDefault',
label: 'Phone Default',
width: 844,
height: 390,
},
{
id: 'androidDefault',
label: 'Android Default',
width: 915,
height: 412,
},
{
id: 'phoneLarge',
label: 'Phone Large',
width: 932,
height: 430,
},
] as const;
export type GameResolutionId = (typeof gameResolutionOptions)[number]['id'];
export const defaultGameResolutionId: GameResolutionId = 'iphoneDefault';
export const defaultAutosaveEnabled = true;
export const volumeSettings = [
{
key: 'master',
label: 'Master Volume',
},
{
key: 'music',
label: 'Music Volume',
},
{
key: 'ambience',
label: 'Ambience Volume',
},
{
key: 'speech',
label: 'Speech Volume',
},
] as const;
export type VolumeSettingKey = (typeof volumeSettings)[number]['key'];
export type VolumeSettings = Record<VolumeSettingKey, number>;
export const defaultVolumeSettings: VolumeSettings = {
ambience: 70,
master: 80,
music: 70,
speech: 80,
};
export function getGameResolutionById(resolutionId: GameResolutionId) {
return (
gameResolutionOptions.find(option => option.id === resolutionId) ??
gameResolutionOptions[0]
);
}
export function formatResolutionLabel(resolutionId: GameResolutionId) {
const resolution = getGameResolutionById(resolutionId);
if ('unbounded' in resolution) {
return `${resolution.label} (Fill available space)`;
}
return `${resolution.label} (${resolution.width} x ${resolution.height})`;
}
export function clampVolume(volume: number) {
return Math.min(100, Math.max(0, volume));
}
+24
View File
@@ -0,0 +1,24 @@
import type { PersistOptions } from 'zustand/middleware';
import { createPersistStorage } from './persistStorage';
export type PersistedStateOptions<TState, TPersistedState = TState> = Omit<
PersistOptions<TState, TPersistedState>,
'name' | 'storage'
> & {
name: string;
};
export function createPersistOptions<TState, TPersistedState = TState>({
name,
...options
}: PersistedStateOptions<TState, TPersistedState>): PersistOptions<
TState,
TPersistedState
> {
return {
...options,
name,
storage: createPersistStorage<TPersistedState>(),
};
}
+62
View File
@@ -0,0 +1,62 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import {
clampVolume,
defaultGameResolutionId,
defaultAutosaveEnabled,
defaultVolumeSettings,
type GameResolutionId,
type VolumeSettingKey,
type VolumeSettings,
} from '../settings/gameSettings';
import { createPersistOptions } from './createPersistOptions';
type PersistedGameSettingsState = {
autosaveEnabled: boolean;
resolutionId: GameResolutionId;
volumes: VolumeSettings;
};
type GameSettingsState = PersistedGameSettingsState & {
setAutosaveEnabled: (autosaveEnabled: boolean) => void;
setResolution: (resolutionId: GameResolutionId) => void;
toggleAutosave: () => void;
setVolume: (key: VolumeSettingKey, volume: number) => void;
};
export const useGameSettingsStore = create<GameSettingsState>()(
persist(
set => ({
autosaveEnabled: defaultAutosaveEnabled,
resolutionId: defaultGameResolutionId,
setAutosaveEnabled: (autosaveEnabled: boolean) => {
set({ autosaveEnabled });
},
setResolution: (resolutionId: GameResolutionId) => {
set({ resolutionId });
},
toggleAutosave: () => {
set(state => ({ autosaveEnabled: !state.autosaveEnabled }));
},
setVolume: (key: VolumeSettingKey, volume: number) => {
set(state => ({
volumes: {
...state.volumes,
[key]: clampVolume(volume),
},
}));
},
volumes: defaultVolumeSettings,
}),
createPersistOptions<GameSettingsState, PersistedGameSettingsState>({
name: 'blb-game-settings',
partialize: state => ({
autosaveEnabled: state.autosaveEnabled,
resolutionId: state.resolutionId,
volumes: state.volumes,
}),
version: 1,
}),
),
);
+11
View File
@@ -0,0 +1,11 @@
export {
createPersistOptions,
type PersistedStateOptions,
} from './createPersistOptions';
export { useGameSettingsStore } from './gameSettingsStore';
export { createMemoryStateStorage } from './memoryStateStorage';
export { useNavigationStore } from './navigationStore';
export {
useToolkitSocketStore,
type ToolkitSocketStatus,
} from './toolkitSocketStore';
+15
View File
@@ -0,0 +1,15 @@
import type { StateStorage } from 'zustand/middleware';
export function createMemoryStateStorage(): StateStorage<void> {
const entries = new Map<string, string>();
return {
getItem: (name: string) => entries.get(name) ?? null,
removeItem: (name: string) => {
entries.delete(name);
},
setItem: (name: string, value: string) => {
entries.set(name, value);
},
};
}
+47
View File
@@ -0,0 +1,47 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { RouteName } from '../navigation/routes';
import { createPersistOptions } from './createPersistOptions';
const defaultRouteName: RouteName = 'home';
type PersistedNavigationState = {
currentRouteName: RouteName;
pendingRouteName: RouteName | null;
};
type NavigationState = PersistedNavigationState & {
cancelPendingRoute: () => void;
completeNavigation: (routeName: RouteName) => void;
setPendingRoute: (routeName: RouteName) => void;
};
export const useNavigationStore = create<NavigationState>()(
persist(
set => ({
cancelPendingRoute: () => {
set({ pendingRouteName: null });
},
completeNavigation: (routeName: RouteName) => {
set({
currentRouteName: routeName,
pendingRouteName: null,
});
},
currentRouteName: defaultRouteName,
pendingRouteName: null,
setPendingRoute: (routeName: RouteName) => {
set({ pendingRouteName: routeName });
},
}),
createPersistOptions<NavigationState, PersistedNavigationState>({
name: 'blb-navigation',
partialize: state => ({
currentRouteName: state.currentRouteName,
pendingRouteName: state.pendingRouteName,
}),
version: 1,
}),
),
);
+12
View File
@@ -0,0 +1,12 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import { createJSONStorage, type StateStorage } from 'zustand/middleware';
export const stateStorage: StateStorage<Promise<void>> = {
getItem: (name: string) => AsyncStorage.getItem(name),
removeItem: (name: string) => AsyncStorage.removeItem(name),
setItem: (name: string, value: string) => AsyncStorage.setItem(name, value),
};
export function createPersistStorage<TState>() {
return createJSONStorage<TState>(() => stateStorage);
}
+41
View File
@@ -0,0 +1,41 @@
import { createJSONStorage, type StateStorage } from 'zustand/middleware';
import { createMemoryStateStorage } from './memoryStateStorage';
type BrowserStorageHost = {
localStorage?: StateStorage<void>;
};
function getBrowserLocalStorage() {
return (globalThis as unknown as BrowserStorageHost).localStorage;
}
function canUseStateStorage(storage: StateStorage<void>) {
const testKey = '__blb_storage_test__';
try {
storage.setItem(testKey, testKey);
storage.removeItem(testKey);
return true;
} catch {
return false;
}
}
export function createWebStateStorage(
getStorage: () => StateStorage<void> | undefined = getBrowserLocalStorage,
): StateStorage<void> {
const storage = getStorage();
if (storage != null && canUseStateStorage(storage)) {
return storage;
}
return createMemoryStateStorage();
}
export const stateStorage = createWebStateStorage();
export function createPersistStorage<TState>() {
return createJSONStorage<TState>(() => stateStorage);
}
+37
View File
@@ -0,0 +1,37 @@
import { create } from 'zustand';
export type ToolkitSocketStatus = 'connected' | 'connecting' | 'disconnected';
type ToolkitSocketState = {
lastConnectedAt: string | null;
socketId: string | null;
status: ToolkitSocketStatus;
setConnected: (socketId: string) => void;
setConnecting: () => void;
setDisconnected: () => void;
};
export const useToolkitSocketStore = create<ToolkitSocketState>()(set => ({
lastConnectedAt: null,
setConnected: (socketId: string) => {
set({
lastConnectedAt: new Date().toISOString(),
socketId,
status: 'connected',
});
},
setConnecting: () => {
set({
socketId: null,
status: 'connecting',
});
},
setDisconnected: () => {
set({
socketId: null,
status: 'disconnected',
});
},
socketId: null,
status: 'disconnected',
}));
+14
View File
@@ -0,0 +1,14 @@
import { AppRegistry } from 'react-native';
import { name as appName } from '../../app.json';
import App from '../../App';
import './styles.css';
const rootTag = globalThis.document.getElementById('root');
if (rootTag == null) {
throw new Error('Root element #root was not found.');
}
AppRegistry.registerComponent(appName, () => App);
AppRegistry.runApplication(appName, { rootTag });
+16
View File
@@ -0,0 +1,16 @@
html,
body,
#root {
height: 100%;
margin: 0;
min-height: 100%;
}
body {
background: #050607;
overflow: hidden;
}
#root {
display: flex;
}
+15
View File
@@ -0,0 +1,15 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"allowImportingTsExtensions": false,
"module": "NodeNext",
"moduleResolution": "NodeNext",
"noEmit": false,
"outDir": "dist/electron",
"rootDir": "electron",
"target": "ES2022",
"types": ["node"],
"verbatimModuleSyntax": false
},
"include": ["electron/**/*.ts"]
}
+36
View File
@@ -0,0 +1,36 @@
import path from 'node:path';
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
const platformExtensions = [
'.web.tsx',
'.web.ts',
'.tsx',
'.ts',
'.web.jsx',
'.web.js',
'.jsx',
'.js',
'.json',
];
export default defineConfig({
plugins: [react()],
resolve: {
alias: [{ find: /^react-native$/, replacement: 'react-native-web' }],
dedupe: ['react', 'react-dom', 'react-native-web'],
extensions: platformExtensions,
},
optimizeDeps: {
exclude: ['react-native-safe-area-context'],
},
build: {
outDir: path.resolve(__dirname, 'dist/renderer'),
emptyOutDir: true,
},
server: {
host: '127.0.0.1',
port: 5173,
},
});
View File