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