Add new screens for game management, team management, and cache handling; implement caching logic and session management

This commit is contained in:
bobbert committed 2026-04-02 22:08:07 +01:00
1 parent 8686f0e1ff
commit 17ed120ce8
70 files changed
+2172 -3892

No files matched your search

+38
View File
@@ -0,0 +1,38 @@
import {Pressable, StyleSheet, Text, View} from "react-native";
export const Button = ({ label, icon, onClick, styleLabel, styleButton }) => {
return (
<Pressable onPress={onClick} style={[styles.button, styleButton]}>
{icon ? icon : null}
<Text style={[styles.label, styleLabel]}>{label}</Text>
</Pressable>
);
};
export const ButtonTray = ({ children }) => {
return <View style={styles.buttonTray}>{children}</View>;
};
const styles = StyleSheet.create({
button: {
minHeight: 50,
borderWidth: 1,
borderRadius: 7,
borderColor: "grey",
backgroundColor: "white",
alignItems: "center",
justifyContent: "center",
padding: 3,
flex: 1,
flexDirection: "row",
gap: 5,
},
label: {
fontSize: 16,
},
buttonTray: {
flexDirection: "row",
gap: 15,
},
});
@@ -0,0 +1,46 @@
import React from 'react';
import MapView, {Circle, Marker} from 'react-native-maps';
const AdminCacheEditorView = ({ userLocation, caches, onAddCache, onMoveCache }) => {
if (!userLocation) {
return null;
}
return (
<MapView
style={{ flex: 1 }}
initialRegion={{
...userLocation,
latitudeDelta: 0.01,
longitudeDelta: 0.01,
}}
region={{
...userLocation,
latitudeDelta: 0.01,
longitudeDelta: 0.01,
}}
onLongPress={(event) => onAddCache(event.nativeEvent.coordinate)}
showsUserLocation
>
{(caches || []).map((cache) => (
<React.Fragment key={cache.id}>
<Marker
coordinate={cache.coordinates}
draggable
onDragEnd={(event) => onMoveCache(cache.id, event.nativeEvent.coordinate)}
title={`Cache ${cache.id}`}
/>
<Circle
center={cache.coordinates}
radius={cache.radius}
fillColor="rgba(59, 130, 246, 0.15)"
strokeColor="rgba(59, 130, 246, 0.85)"
/>
</React.Fragment>
))}
</MapView>
);
};
export default AdminCacheEditorView;
+48
View File
@@ -0,0 +1,48 @@
import React, {useEffect, useState} from 'react';
import {StyleSheet, Text, View} from 'react-native';
const ClaimTimerView = ({ cache, onClaimSuccess, isClaiming }) => {
const [timeLeft, setTimeLeft] = useState(5);
useEffect(() => {
let timer;
if (isClaiming && timeLeft > 0) {
timer = setTimeout(() => setTimeLeft((prev) => prev - 1), 1000);
} else if (isClaiming && timeLeft === 0 && cache) {
onClaimSuccess(cache.id);
} else {
setTimeLeft(5);
}
return () => clearTimeout(timer);
}, [isClaiming, timeLeft, cache, onClaimSuccess]);
if (!isClaiming) {
return null;
}
return (
<View style={styles.overlay}>
<Text style={styles.text}>Hold steady. Claiming in {timeLeft}...</Text>
</View>
);
};
const styles = StyleSheet.create({
overlay: {
position: 'absolute',
bottom: 24,
alignSelf: 'center',
backgroundColor: 'rgba(0,0,0,0.75)',
paddingHorizontal: 16,
paddingVertical: 12,
borderRadius: 10,
},
text: {
color: '#ffffff',
fontSize: 16,
fontWeight: '700',
},
});
export default ClaimTimerView;
+46
View File
@@ -0,0 +1,46 @@
import React from 'react';
import MapView, {Circle} from 'react-native-maps';
import ClaimTimerView from './ClaimTimerView';
const PlayerMapView = ({ userLocation, visibleCache, isClaiming, onClaimSuccess }) => {
if (!userLocation) {
return null;
}
return (
<>
<MapView
style={{ flex: 1 }}
initialRegion={{
...userLocation,
latitudeDelta: 0.01,
longitudeDelta: 0.01,
}}
region={{
...userLocation,
latitudeDelta: 0.01,
longitudeDelta: 0.01,
}}
showsUserLocation
>
{visibleCache ? (
<Circle
center={visibleCache.coordinates}
radius={visibleCache.radius}
fillColor="rgba(250, 204, 21, 0.20)"
strokeColor="rgba(250, 204, 21, 0.90)"
/>
) : null}
</MapView>
<ClaimTimerView
cache={visibleCache}
isClaiming={isClaiming}
onClaimSuccess={onClaimSuccess}
/>
</>
);
};
export default PlayerMapView;
+74
View File
@@ -0,0 +1,74 @@
import {Pressable, StyleSheet, Text, View} from 'react-native';
const BottomNavbar = ({ navigation, routeName }) => {
const tabs = [
{
label: 'Map',
route: 'MapScreen',
onPress: () => navigation.navigate('Game', { screen: 'MapScreen' }),
},
{
label: 'Leaderboard',
route: 'LeaderboardScreen',
onPress: () => navigation.navigate('Game', { screen: 'LeaderboardScreen' }),
},
{
label: 'Current Team',
route: 'TeamScreen',
onPress: () => navigation.navigate('Game', { screen: 'TeamScreen' }),
},
];
return (
<View style={styles.container}>
{tabs.map((tab) => {
const isActive = routeName === tab.route;
return (
<Pressable
key={tab.route}
onPress={tab.onPress}
style={[styles.tab, isActive && styles.tabActive]}
>
<Text style={[styles.tabText, isActive && styles.tabTextActive]}>{tab.label}</Text>
</Pressable>
);
})}
</View>
);
};
const styles = StyleSheet.create({
container: {
minHeight: 64,
borderTopWidth: 1,
borderTopColor: '#e5e7eb',
backgroundColor: '#ffffff',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-around',
paddingHorizontal: 8,
},
tab: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 12,
borderRadius: 8,
marginHorizontal: 4,
},
tabActive: {
backgroundColor: '#eff6ff',
},
tabText: {
color: '#374151',
fontSize: 12,
fontWeight: '600',
textAlign: 'center',
},
tabTextActive: {
color: '#2563eb',
},
});
export default BottomNavbar;
+53
View File
@@ -0,0 +1,53 @@
import {StatusBar} from "expo-status-bar";
import {StyleSheet, View} from "react-native";
import {useNavigation, useRoute} from "@react-navigation/native";
import TopNavbar from "./TopNavbar";
import BottomNavbar from "./BottomNavbar";
const Screen = ({ children, style }) => {
const navigation = useNavigation();
const route = useRoute();
const titleMap = {
LoginScreen: "Login",
RegisterScreen: "Register",
GameTypeScreen: "Game Type",
JoinPrivateGameScreen: "Join Private Game",
ManagePrivateGameScreen: "Manage Private Game",
CreateGameScreen: "Create Game",
ManageGameScreen: "Manage Game",
GameLobbyScreen: "Leaderboard",
LeaderboardScreen: "Leaderboard",
TeamScreen: "Current Team",
MapScreen: "Map",
AdminHubScreen: "Admin Hub",
ManageSubgroupsScreen: "Manage Subgroups",
};
const routeName = route.name;
const showBack = routeName !== "LoginScreen" && navigation.canGoBack();
const pageTitle = titleMap[routeName] || "Treasure Hunt";
return (
<View style={[styles.screen, style]}>
<TopNavbar title={pageTitle} showBack={showBack} navigation={navigation} />
<View style={styles.content}>{children}</View>
<BottomNavbar navigation={navigation} routeName={routeName} />
<StatusBar style="light" />
</View>
);
};
const styles = StyleSheet.create({
screen: {
flex: 1,
backgroundColor: "#fff",
},
content: {
flex: 1,
padding: 15,
},
});
export default Screen;
+90
View File
@@ -0,0 +1,90 @@
import {Pressable, StyleSheet, Text, View} from 'react-native';
import {clearSession} from '../../hooks/SessionStore';
const TopNavbar = ({ title, showBack, navigation }) => {
const routeName = navigation.getState()?.routes?.[navigation.getState().index]?.name;
const showLogout = routeName !== 'LoginScreen' && routeName !== 'RegisterScreen';
const handleBack = () => {
if (navigation.canGoBack()) {
navigation.goBack();
}
};
const handleLogout = () => {
clearSession();
navigation.reset({
index: 0,
routes: [{ name: 'Auth', params: { screen: 'LoginScreen' } }],
});
};
return (
<View style={styles.container}>
<View style={styles.leftSlot}>
{showBack ? (
<Pressable onPress={handleBack} style={styles.iconButton}>
<Text style={styles.iconText}>{'<'}</Text>
</Pressable>
) : null}
</View>
<Text style={styles.title}>{title}</Text>
<View style={styles.rightSlot}>
{showLogout ? (
<Pressable onPress={handleLogout} style={styles.logoutButton}>
<Text style={styles.logoutText}>Logout</Text>
</Pressable>
) : null}
</View>
</View>
);
};
const styles = StyleSheet.create({
container: {
minHeight: 56,
borderBottomWidth: 1,
borderBottomColor: '#e5e7eb',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 12,
backgroundColor: '#ffffff',
},
leftSlot: {
width: 72,
alignItems: 'flex-start',
},
rightSlot: {
width: 72,
alignItems: 'flex-end',
},
iconButton: {
paddingVertical: 8,
paddingHorizontal: 10,
},
iconText: {
fontSize: 20,
color: '#111827',
fontWeight: '700',
},
title: {
flex: 1,
textAlign: 'center',
fontSize: 18,
fontWeight: '700',
color: '#111827',
},
logoutButton: {
paddingVertical: 6,
paddingHorizontal: 8,
},
logoutText: {
color: '#2563eb',
fontSize: 13,
fontWeight: '600',
},
});
export default TopNavbar;
+18
View File
@@ -0,0 +1,18 @@
import React from 'react';
import {StyleSheet, Text} from 'react-native';
import Screen from '../layout/Screen';
const AdminHubScreen = () => (
<Screen style={styles.container}>
<Text style={styles.title}>Admin Hub</Text>
<Text style={styles.body}>Admin hub setup goes here.</Text>
</Screen>
);
const styles = StyleSheet.create({
container: { justifyContent: 'center', alignItems: 'center' },
title: { fontSize: 24, fontWeight: 'bold' },
body: { color: '#4b5563', fontSize: 15, marginTop: 10 },
});
export default AdminHubScreen;
@@ -0,0 +1,18 @@
import React from 'react';
import {StyleSheet, Text} from 'react-native';
import Screen from '../layout/Screen';
const CreateGameScreen = () => (
<Screen style={styles.container}>
<Text style={styles.title}>Create Game</Text>
<Text style={styles.body}>Create game setup goes here.</Text>
</Screen>
);
const styles = StyleSheet.create({
container: { justifyContent: 'center', alignItems: 'center' },
title: { fontSize: 24, fontWeight: 'bold' },
body: { color: '#4b5563', fontSize: 15, marginTop: 10 },
});
export default CreateGameScreen;
+18
View File
@@ -0,0 +1,18 @@
import React from 'react';
import {StyleSheet, Text} from 'react-native';
import Screen from '../layout/Screen';
const GameLobbyScreen = () => (
<Screen style={styles.container}>
<Text style={styles.title}>Game Lobby</Text>
<Text style={styles.body}>Game lobby setup goes here.</Text>
</Screen>
);
const styles = StyleSheet.create({
container: { justifyContent: 'center', alignItems: 'center' },
title: { fontSize: 24, fontWeight: 'bold' },
body: { color: '#4b5563', fontSize: 15, marginTop: 10 },
});
export default GameLobbyScreen;
+50
View File
@@ -0,0 +1,50 @@
import React from 'react';
import {Pressable, StyleSheet, Text, View} from 'react-native';
import useGameHook from '../../hooks/useGameHook';
import Screen from '../layout/Screen';
const GameTypeScreen = ({ navigation }) => {
const { getGameTypes } = useGameHook();
const gameTypes = getGameTypes();
return (
<Screen style={styles.container}>
<Text style={styles.title}>Select Game Type</Text>
<View style={styles.content}>
{gameTypes && gameTypes.map((type) => (
<Pressable key={type} style={styles.button} onPress={() => navigation.navigate('GameLobbyScreen')}>
<Text style={styles.buttonText}>{type}</Text>
</Pressable>
))}
</View>
</Screen>
);
};
const styles = StyleSheet.create({
container: {
justifyContent: 'center',
},
content: {
gap: 12,
flex: 1,
},
title: {
fontSize: 24,
fontWeight: 'bold',
textAlign: 'center',
marginBottom: 20,
},
button: {
backgroundColor: '#2563eb',
borderRadius: 8,
paddingVertical: 12,
alignItems: 'center',
},
buttonText: {
color: '#ffffff',
fontWeight: '600',
},
});
export default GameTypeScreen;
@@ -0,0 +1,30 @@
import React from 'react';
import {StyleSheet, Text} from 'react-native';
import Screen from '../layout/Screen';
const JoinPrivateGameScreen = () => {
return (
<Screen style={styles.container}>
<Text style={styles.title}>Join Private Game</Text>
<Text style={styles.body}>Join private game setup goes here.</Text>
</Screen>
);
};
const styles = StyleSheet.create({
container: {
justifyContent: 'center',
alignItems: 'center',
},
title: {
fontSize: 24,
fontWeight: 'bold',
},
body: {
color: '#4b5563',
fontSize: 15,
marginTop: 10,
},
});
export default JoinPrivateGameScreen;
@@ -0,0 +1,19 @@
import React from 'react';
import {StyleSheet, Text} from 'react-native';
import Screen from '../layout/Screen';
const LeaderboardScreen = () => (
<Screen style={styles.container}>
<Text style={styles.title}>Leaderboard</Text>
<Text style={styles.body}>Leaderboard view goes here.</Text>
</Screen>
);
const styles = StyleSheet.create({
container: { justifyContent: 'center', alignItems: 'center' },
title: { fontSize: 24, fontWeight: 'bold' },
body: { color: '#4b5563', fontSize: 15, marginTop: 10 },
});
export default LeaderboardScreen;
+110
View File
@@ -0,0 +1,110 @@
import React, {useState} from 'react';
import {Pressable, StyleSheet, Text, TextInput, View} from 'react-native';
import useAuthHook from '../../hooks/useAuthHook';
import Screen from '../layout/Screen';
import {Button, ButtonTray} from '../UI/Button';
const LoginScreen = ({ navigation }) => {
const { email, password, error, isLoading, setEmail, setPassword, login } = useAuthHook();
const [submitting, setSubmitting] = useState(false);
const handleLogin = async () => {
setSubmitting(true);
const user = await login();
setSubmitting(false);
if (!user) {
return;
}
if (user.IsAcceptedAdmin) {
navigation.reset({ index: 0, routes: [{ name: 'Admin' }] });
return;
}
navigation.reset({ index: 0, routes: [{ name: 'Game' }] });
};
return (
<Screen style={styles.container}>
<View style={styles.content}>
<Text style={styles.title}>Login</Text>
<TextInput
style={styles.input}
placeholder="Email"
autoCapitalize="none"
keyboardType="email-address"
value={email}
onChangeText={setEmail}
editable={!isLoading}
/>
<TextInput
style={styles.input}
placeholder="Password"
secureTextEntry
value={password}
onChangeText={setPassword}
editable={!isLoading}
/>
{error ? <Text style={styles.errorText}>{error}</Text> : null}
<ButtonTray>
<Button
label={submitting ? 'Logging in...' : 'Login'}
onClick={handleLogin}
styleButton={styles.submitButton}
styleLabel={styles.submitText}
/>
</ButtonTray>
<Pressable onPress={() => navigation.navigate('Auth', { screen: 'RegisterScreen' })}>
<Text style={styles.linkText}>Create new account</Text>
</Pressable>
</View>
</Screen>
);
};
const styles = StyleSheet.create({
container: {
justifyContent: 'center',
},
content: {
gap: 15,
},
title: {
fontSize: 24,
fontWeight: 'bold',
textAlign: 'center',
marginBottom: 10,
},
input: {
borderWidth: 1,
borderColor: '#d1d5db',
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 16,
},
errorText: {
color: '#dc2626',
textAlign: 'center',
marginTop: 5,
},
submitButton: {
backgroundColor: '#2563eb',
borderColor: '#2563eb',
},
submitText: {
color: '#ffffff',
fontWeight: '600',
},
linkText: {
textAlign: 'center',
color: '#2563eb',
fontWeight: '600',
paddingVertical: 10,
},
});
export default LoginScreen;
+168
View File
@@ -0,0 +1,168 @@
import React, {useCallback, useEffect, useState} from 'react';
import {ActivityIndicator, StyleSheet, Text, View} from 'react-native';
import * as Location from 'expo-location';
import Screen from '../layout/Screen';
import AdminCacheEditorView from '../gameplay/AdminCacheEditorView';
import useGameHook from '../../hooks/useGameHook';
import {getSession} from '../../hooks/SessionStore';
const ManageGameScreen = () => {
const session = getSession();
const { getCaches, upsertCache } = useGameHook();
const [userLocation, setUserLocation] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [cacheRecords, setCacheRecords] = useState([]);
const loadCaches = useCallback(() => {
if (!session.currentGid) {
setCacheRecords([]);
return;
}
setCacheRecords(getCaches(session.currentGid, null));
}, [getCaches, session.currentGid]);
useEffect(() => {
loadCaches();
}, [loadCaches]);
useEffect(() => {
let locationSub;
const start = async () => {
const { status } = await Location.requestForegroundPermissionsAsync();
if (status !== 'granted') {
setError('Location permission denied');
setLoading(false);
return;
}
const current = await Location.getCurrentPositionAsync({ accuracy: Location.Accuracy.Balanced });
setUserLocation({ latitude: current.coords.latitude, longitude: current.coords.longitude });
locationSub = await Location.watchPositionAsync(
{
accuracy: Location.Accuracy.Balanced,
distanceInterval: 1,
timeInterval: 1000,
},
(next) => {
setUserLocation({ latitude: next.coords.latitude, longitude: next.coords.longitude });
},
);
setLoading(false);
};
start();
return () => {
if (locationSub) {
locationSub.remove();
}
};
}, []);
const handleAddCache = (coordinate) => {
if (!session.currentGid) {
return;
}
upsertCache({
gid: session.currentGid,
latitude: coordinate.latitude,
longitude: coordinate.longitude,
radius: 20,
clue: `Cache ${cacheRecords.length + 1}`,
subgroupId: 1,
});
loadCaches();
};
const handleMoveCache = (cacheId, coordinate) => {
if (!session.currentGid) {
return;
}
const current = cacheRecords.find((cache) => cache.id === cacheId);
if (!current) {
return;
}
upsertCache({
gid: session.currentGid,
cacheId,
latitude: coordinate.latitude,
longitude: coordinate.longitude,
radius: current.radius,
clue: current.clue,
subgroupId: current.subgroupId,
});
loadCaches();
};
if (!session.currentGid) {
return (
<Screen style={styles.center}>
<Text style={styles.body}>Create or join a game first.</Text>
</Screen>
);
}
if (!session.isAcceptedAdmin) {
return (
<Screen style={styles.center}>
<Text style={styles.body}>Only admins can edit cache locations.</Text>
</Screen>
);
}
if (loading) {
return (
<Screen style={styles.center}>
<ActivityIndicator size="large" />
</Screen>
);
}
if (error) {
return (
<Screen style={styles.center}>
<Text style={styles.error}>{error}</Text>
</Screen>
);
}
return (
<Screen style={styles.container}>
<View style={styles.mapWrap}>
<AdminCacheEditorView
userLocation={userLocation}
caches={cacheRecords}
onAddCache={handleAddCache}
onMoveCache={handleMoveCache}
/>
</View>
</Screen>
);
};
const styles = StyleSheet.create({
container: {
padding: 0,
},
mapWrap: {
flex: 1,
},
center: {
justifyContent: 'center',
alignItems: 'center',
},
body: {
color: '#4b5563',
fontSize: 15,
},
error: {
color: '#dc2626',
fontSize: 15,
},
});
export default ManageGameScreen;
@@ -0,0 +1,20 @@
import React from 'react';
import {StyleSheet, Text} from 'react-native';
import Screen from '../layout/Screen';
const ManagePrivateGameScreen = () => {
return (
<Screen style={styles.container}>
<Text style={styles.title}>Manage Private Game</Text>
<Text style={styles.body}>Manage private game setup goes here.</Text>
</Screen>
);
};
const styles = StyleSheet.create({
container: { justifyContent: 'center', alignItems: 'center' },
title: { fontSize: 24, fontWeight: 'bold' },
body: { color: '#4b5563', fontSize: 15, marginTop: 10 },
});
export default ManagePrivateGameScreen;
@@ -0,0 +1,18 @@
import React from 'react';
import {StyleSheet, Text} from 'react-native';
import Screen from '../layout/Screen';
const ManageSubgroupsScreen = () => (
<Screen style={styles.container}>
<Text style={styles.title}>Manage Subgroups</Text>
<Text style={styles.body}>Manage subgroups setup goes here.</Text>
</Screen>
);
const styles = StyleSheet.create({
container: { justifyContent: 'center', alignItems: 'center' },
title: { fontSize: 24, fontWeight: 'bold' },
body: { color: '#4b5563', fontSize: 15, marginTop: 10 },
});
export default ManageSubgroupsScreen;
+183
View File
@@ -0,0 +1,183 @@
import React, {useCallback, useEffect, useMemo, useState} from 'react';
import {ActivityIndicator, StyleSheet, Text, View} from 'react-native';
import * as Location from 'expo-location';
import {Magnetometer} from 'expo-sensors';
import Screen from '../layout/Screen';
import PlayerMapView from '../gameplay/PlayerMapView';
import useGameHook from '../../hooks/useGameHook';
import usePlayerGame from '../../hooks/usePlayerGame';
import {getSession} from '../../hooks/SessionStore';
import Cache from '../../models/Cache';
const toHeading = ({ x, y }) => {
const angle = Math.atan2(y, x) * (180 / Math.PI);
return angle < 0 ? angle + 360 : angle;
};
const MapScreen = () => {
const session = getSession();
const { getCaches, claimCache } = useGameHook();
const [userLocation, setUserLocation] = useState(null);
const [heading, setHeading] = useState(null);
const [loading, setLoading] = useState(true);
const [cacheRecords, setCacheRecords] = useState([]);
const [error, setError] = useState('');
const activeCaches = useMemo(() => (
cacheRecords.map(
(cache) => new Cache(
cache.id,
cache.coordinates.latitude,
cache.coordinates.longitude,
cache.radius,
cache.clue,
cache.groupId,
cache.subgroupId,
),
)
), [cacheRecords]);
const subgroupFilter = session.currentSGid;
const { visibleCache, isClaiming, setIsClaiming } = usePlayerGame(userLocation, heading, activeCaches);
const loadCaches = useCallback(() => {
if (!session.currentGid) {
setCacheRecords([]);
return;
}
const rows = getCaches(session.currentGid, subgroupFilter);
setCacheRecords(rows);
}, [getCaches, session.currentGid, subgroupFilter]);
useEffect(() => {
loadCaches();
}, [loadCaches]);
useEffect(() => {
let locationSub;
let headingSub;
const start = async () => {
const { status } = await Location.requestForegroundPermissionsAsync();
if (status !== 'granted') {
setError('Location permission denied');
setLoading(false);
return;
}
const current = await Location.getCurrentPositionAsync({ accuracy: Location.Accuracy.Balanced });
setUserLocation({ latitude: current.coords.latitude, longitude: current.coords.longitude });
locationSub = await Location.watchPositionAsync(
{
accuracy: Location.Accuracy.Balanced,
distanceInterval: 1,
timeInterval: 1000,
},
(next) => {
setUserLocation({ latitude: next.coords.latitude, longitude: next.coords.longitude });
},
);
Magnetometer.setUpdateInterval(500);
headingSub = Magnetometer.addListener((data) => {
setHeading(toHeading(data));
});
setLoading(false);
};
start();
return () => {
if (locationSub) {
locationSub.remove();
}
if (headingSub) {
headingSub.remove();
}
};
}, []);
const handleClaim = (cacheId) => {
if (!session.currentGid) {
return;
}
claimCache({
gid: session.currentGid,
cacheId,
uid: session.currentUid,
tid: session.currentTid,
});
setIsClaiming(false);
loadCaches();
};
if (!session.currentGid) {
return (
<Screen style={styles.center}>
<Text style={styles.body}>Join or create a game to use map features.</Text>
</Screen>
);
}
if (session.isAcceptedAdmin) {
return (
<Screen style={styles.center}>
<Text style={styles.body}>Admins manage caches from Manage Game.</Text>
</Screen>
);
}
if (loading) {
return (
<Screen style={styles.center}>
<ActivityIndicator size="large" />
</Screen>
);
}
if (error) {
return (
<Screen style={styles.center}>
<Text style={styles.error}>{error}</Text>
</Screen>
);
}
return (
<Screen style={styles.container}>
<View style={styles.mapWrap}>
<PlayerMapView
userLocation={userLocation}
visibleCache={visibleCache}
isClaiming={isClaiming}
onClaimSuccess={handleClaim}
/>
</View>
</Screen>
);
};
const styles = StyleSheet.create({
container: {
padding: 0,
},
mapWrap: {
flex: 1,
},
center: {
justifyContent: 'center',
alignItems: 'center',
},
body: {
color: '#4b5563',
fontSize: 15,
},
error: {
color: '#dc2626',
fontSize: 15,
},
});
export default MapScreen;
+178
View File
@@ -0,0 +1,178 @@
import React, {useState} from 'react';
import {Pressable, StyleSheet, Text, TextInput, View} from 'react-native';
import useAuthHook from '../../hooks/useAuthHook';
import Screen from '../layout/Screen';
import {Button, ButtonTray} from '../UI/Button';
const RegisterScreen = ({ navigation }) => {
const {
username,
email,
password,
confirmPassword,
accountType,
error,
isLoading,
setUsername,
setEmail,
setPassword,
setConfirmPassword,
setAccountType,
register,
} = useAuthHook();
const [submitting, setSubmitting] = useState(false);
const handleRegister = async () => {
setSubmitting(true);
const user = await register();
setSubmitting(false);
if (!user) {
return;
}
if (user.IsAcceptedAdmin) {
navigation.reset({ index: 0, routes: [{ name: 'Admin' }] });
return;
}
navigation.reset({ index: 0, routes: [{ name: 'Game' }] });
};
return (
<Screen style={styles.container}>
<View style={styles.content}>
<Text style={styles.title}>Create Account</Text>
<TextInput
style={styles.input}
placeholder="Username"
value={username}
onChangeText={setUsername}
editable={!isLoading}
/>
<TextInput
style={styles.input}
placeholder="Email"
autoCapitalize="none"
keyboardType="email-address"
value={email}
onChangeText={setEmail}
editable={!isLoading}
/>
<TextInput
style={styles.input}
placeholder="Password"
secureTextEntry
value={password}
onChangeText={setPassword}
editable={!isLoading}
/>
<TextInput
style={styles.input}
placeholder="Confirm Password"
secureTextEntry
value={confirmPassword}
onChangeText={setConfirmPassword}
editable={!isLoading}
/>
<View style={styles.typeRow}>
<Pressable
style={[styles.typeButton, accountType === 'Individual' && styles.typeButtonActive]}
onPress={() => setAccountType('Individual')}
disabled={isLoading}
>
<Text style={[styles.typeText, accountType === 'Individual' && styles.typeTextActive]}>Individual</Text>
</Pressable>
<Pressable
style={[styles.typeButton, accountType === 'Business/School' && styles.typeButtonActive]}
onPress={() => setAccountType('Business/School')}
disabled={isLoading}
>
<Text style={[styles.typeText, accountType === 'Business/School' && styles.typeTextActive]}>Business</Text>
</Pressable>
</View>
{error ? <Text style={styles.errorText}>{error}</Text> : null}
<ButtonTray>
<Button
label={submitting ? 'Creating...' : 'Create Account'}
onClick={handleRegister}
styleButton={styles.submitButton}
styleLabel={styles.submitText}
/>
</ButtonTray>
<Pressable onPress={() => navigation.navigate('Auth', { screen: 'LoginScreen' })}>
<Text style={styles.linkText}>Back to Login</Text>
</Pressable>
</View>
</Screen>
);
};
const styles = StyleSheet.create({
container: {
justifyContent: 'center',
},
content: {
gap: 12,
},
title: {
fontSize: 24,
fontWeight: 'bold',
textAlign: 'center',
marginBottom: 10,
},
input: {
borderWidth: 1,
borderColor: '#d1d5db',
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 16,
},
typeRow: {
flexDirection: 'row',
gap: 10,
},
typeButton: {
flex: 1,
borderWidth: 1,
borderColor: '#cbd5e1',
borderRadius: 8,
alignItems: 'center',
paddingVertical: 10,
},
typeButtonActive: {
backgroundColor: '#2563eb',
borderColor: '#2563eb',
},
typeText: {
color: '#1f2937',
fontWeight: '600',
},
typeTextActive: {
color: '#ffffff',
},
errorText: {
color: '#dc2626',
textAlign: 'center',
marginTop: 5,
},
submitButton: {
backgroundColor: '#2563eb',
borderColor: '#2563eb',
},
submitText: {
color: '#ffffff',
fontWeight: '600',
},
linkText: {
textAlign: 'center',
color: '#2563eb',
fontWeight: '600',
paddingVertical: 10,
},
});
export default RegisterScreen;
+18
View File
@@ -0,0 +1,18 @@
import React from 'react';
import {StyleSheet, Text} from 'react-native';
import Screen from '../layout/Screen';
const TeamScreen = () => (
<Screen style={styles.container}>
<Text style={styles.title}>Team Management</Text>
<Text style={styles.body}>Team management setup goes here.</Text>
</Screen>
);
const styles = StyleSheet.create({
container: { justifyContent: 'center', alignItems: 'center' },
title: { fontSize: 24, fontWeight: 'bold' },
body: { color: '#4b5563', fontSize: 15, marginTop: 10 },
});
export default TeamScreen;
+495
View File
@@ -0,0 +1,495 @@
import buildDbLink from './dbLink';
class DbController {
constructor(db = buildDbLink()) {
this.db = db;
}
hashPassword(value) {
let hash = 0;
for (let i = 0; i < value.length; i += 1) {
hash = (hash << 5) - hash + value.charCodeAt(i);
hash |= 0;
}
return `H_${Math.abs(hash)}`;
}
toMinuteIso(date = new Date()) {
return `${date.toISOString().slice(0, 16)}Z`;
}
generateJoinCode(length = 5) {
const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
let code = '';
for (let i = 0; i < length; i += 1) {
code += charset[Math.floor(Math.random() * charset.length)];
}
return code;
}
generateUniqueJoinCode(collection, key) {
let code = this.generateJoinCode(Math.random() > 0.5 ? 5 : 6);
while (collection.some((item) => item[key] === code)) {
code = this.generateJoinCode(Math.random() > 0.5 ? 5 : 6);
}
return code;
}
nextId(counterKey) {
const keyToCollection = {
user: { collection: 'users', field: 'Uid' },
group: { collection: 'groups', field: 'Gid' },
team: { collection: 'teams', field: 'Tid' },
};
const config = keyToCollection[counterKey];
if (!config) {
throw new Error(`Unsupported counter key: ${counterKey}`);
}
const rows = this.db[config.collection] || [];
const maxId = rows.reduce((max, row) => {
const value = Number(row[config.field] || 0);
return value > max ? value : max;
}, 0);
return maxId + 1;
}
getUserByEmail(email) {
return this.db.users.find((u) => u.email.toLowerCase() === email.toLowerCase()) || null;
}
getUserById(uid) {
return this.db.users.find((u) => u.Uid === Number(uid)) || null;
}
getGroupById(gid) {
return this.db.groups.find((group) => group.Gid === Number(gid)) || null;
}
register(payload) {
const { username, email, password, confirmPassword, accountType } = payload;
if (!username || !email || !password || !confirmPassword) {
throw new Error('Missing required registration fields');
}
if (password !== confirmPassword) {
throw new Error('Passwords do not match');
}
if (this.getUserByEmail(email)) {
throw new Error('Email already in use');
}
const uid = this.nextId('user');
const user = {
Uid: uid,
username,
email,
passwordHash: this.hashPassword(password),
isBusiness: accountType === 'Business/School',
IsAcceptedAdmin: false,
Gid: null,
SGid: null,
TGid: null,
};
this.db.users.push(user);
return user;
}
login(payload) {
const { email, password } = payload;
const user = this.getUserByEmail(email);
if (!user) {
throw new Error('Email not found');
}
const incomingHash = this.hashPassword(password);
if (incomingHash !== user.passwordHash) {
throw new Error('Invalid password');
}
return user;
}
getGameTypes() {
return this.db.game_types;
}
getSubgroupByJoinCode(joinCode) {
return this.db.subgroups.find(
(subgroup) => subgroup.JoinCode && subgroup.JoinCode === joinCode.toUpperCase(),
) || null;
}
ensureMembership(uid, gid, sgid, isAcceptedAdmin) {
const existing = this.db.subgroup_memberships.find(
(membership) => membership.Uid === uid && membership.Gid === gid,
);
if (existing) {
existing.SGid = sgid;
existing.IsAcceptedAdmin = isAcceptedAdmin;
return existing;
}
const membership = {
Uid: uid,
Gid: gid,
SGid: sgid,
IsAcceptedAdmin: isAcceptedAdmin,
};
this.db.subgroup_memberships.push(membership);
return membership;
}
ensureGroupGameData(gid) {
const existing = this.db.game_data.find((entry) => entry.Gid === gid);
if (existing) {
return existing;
}
const payload = { Gid: gid, GeoCaches: [] };
this.db.game_data.push(payload);
return payload;
}
hasActiveAdmin(gid) {
return this.db.subgroup_memberships.some(
(membership) => membership.Gid === gid && membership.SGid === 0 && membership.IsAcceptedAdmin,
);
}
canJoinTeamForSubgroup(group, sgid) {
return group.TeamsEnabled && sgid > 0;
}
createPrivateGame(payload) {
const { userId, groupName, businessOrSchoolName, gameType = 'private', isBusiness = false } = payload;
const user = this.getUserById(Number(userId));
if (!user) {
throw new Error('User not found');
}
const existing = this.db.groups.find(
(group) => group.CreatedByUid === Number(userId) && group.GameType === 'private',
);
if (existing) {
return existing;
}
const gid = this.nextId('group');
const isBusinessGroup = Boolean(isBusiness);
const group = {
Gid: gid,
GroupName: groupName,
BusinessOrSchoolName: businessOrSchoolName || groupName,
GameType: gameType,
AdminJoinCode: this.generateUniqueJoinCode(this.db.groups, 'AdminJoinCode'),
CreatedByUid: user.Uid,
MaxMemberSubgroups: isBusinessGroup ? 999 : 1,
TeamsEnabled: true,
IsBusinessGroup: isBusinessGroup,
ApprovedAdmins: isBusinessGroup ? [user.Uid] : [],
CreatedAt: this.toMinuteIso(),
};
this.db.groups.push(group);
this.ensureGroupGameData(gid);
this.db.subgroups.push({
SGid: 0,
SubGroupName: 'Admins',
Gid: group.Gid,
JoinCode: null,
IsAdminGroup: true,
IsGameStarted: false,
CacheTriggerMeters: 20,
});
if (!isBusinessGroup) {
const memberSGid = this.db.subgroups.filter((sg) => sg.Gid === gid).length;
this.db.subgroups.push({
SGid: memberSGid,
SubGroupName: 'Members',
Gid: gid,
JoinCode: this.generateUniqueJoinCode(this.db.subgroups, 'JoinCode'),
IsAdminGroup: false,
IsGameStarted: false,
CacheTriggerMeters: 20,
});
}
const ownerSgid = isBusinessGroup ? 0 : 1;
this.ensureMembership(user.Uid, gid, ownerSgid, isBusinessGroup);
user.Gid = gid;
user.SGid = ownerSgid;
user.IsAcceptedAdmin = isBusinessGroup;
return group;
}
joinPrivateGame(payload) {
const { userId, joinCode } = payload;
const user = this.getUserById(Number(userId));
const subgroup = this.getSubgroupByJoinCode(joinCode);
if (!user || !subgroup || subgroup.IsAdminGroup) {
throw new Error('Invalid subgroup join code');
}
const group = this.getGroupById(subgroup.Gid);
if (!group) {
throw new Error('Group not found');
}
this.ensureMembership(user.Uid, subgroup.Gid, subgroup.SGid, false);
user.Gid = group.Gid;
user.SGid = subgroup.SGid;
user.IsAcceptedAdmin = false;
return group;
}
joinAsAdmin(payload) {
const { userId, adminJoinCode } = payload;
const user = this.getUserById(Number(userId));
const group = this.db.groups.find((g) => g.AdminJoinCode === adminJoinCode);
if (!user || !group) {
throw new Error('Invalid admin join code or user not found');
}
if (this.hasActiveAdmin(group.Gid)) {
this.db.admin_waitlist.push({
Uid: user.Uid,
Gid: group.Gid,
RequestedAt: this.toMinuteIso(),
});
return { status: 'pending', message: 'Admin approval pending' };
}
group.ApprovedAdmins.push(user.Uid);
this.ensureMembership(user.Uid, group.Gid, 0, true);
user.Gid = group.Gid;
user.SGid = 0;
user.IsAcceptedAdmin = true;
return { status: 'approved', group };
}
getCreatedPrivateGameByUser(uid) {
return this.db.groups.find(
(group) => group.CreatedByUid === Number(uid) && group.GameType === 'private',
) || null;
}
createSubgroup(payload) {
const { gid, subgroupName, cacheTriggerMeters = 20 } = payload;
const group = this.getGroupById(gid);
if (!group) {
throw new Error('Group not found');
}
const existingCount = this.db.subgroups.filter((sg) => sg.Gid === gid).length;
if (existingCount >= group.MaxMemberSubgroups + 1) {
throw new Error('Max subgroups reached');
}
const sgid = existingCount;
const newSubgroup = {
SGid: sgid,
SubGroupName: subgroupName,
Gid: gid,
JoinCode: this.generateUniqueJoinCode(this.db.subgroups, 'JoinCode'),
IsAdminGroup: false,
IsGameStarted: false,
CacheTriggerMeters: cacheTriggerMeters,
};
this.db.subgroups.push(newSubgroup);
return newSubgroup;
}
getSubgroups(gid) {
return this.db.subgroups.filter((sg) => sg.Gid === gid);
}
getVisibleSubgroups(gid) {
return this.db.subgroups.filter((sg) => sg.Gid === gid && sg.SGid > 0);
}
updateSubgroupCacheSettings(payload) {
const { gid, sgid, cacheTriggerMeters } = payload;
const subgroup = this.db.subgroups.find((sg) => sg.Gid === gid && sg.SGid === sgid);
if (!subgroup) {
throw new Error('Subgroup not found');
}
subgroup.CacheTriggerMeters = cacheTriggerMeters;
return subgroup;
}
createTeam(payload) {
const { gid, sgid, teamName, leaderId } = payload;
const tid = this.nextId('team');
const team = {
Tid: tid,
Gid: gid,
SGid: sgid,
TeamName: teamName,
LeaderId: leaderId,
JoinCode: this.generateUniqueJoinCode(this.db.teams, 'JoinCode'),
CreatedAt: this.toMinuteIso(),
};
this.db.teams.push(team);
this.db.team_members.push({ Uid: leaderId, Tid: tid });
return team;
}
joinTeamByCode(payload) {
const { userId, joinCode } = payload;
const team = this.db.teams.find((t) => t.JoinCode === joinCode.toUpperCase());
if (!team) {
throw new Error('Team join code not found');
}
const existing = this.db.team_members.find((tm) => tm.Uid === userId && tm.Tid === team.Tid);
if (existing) {
throw new Error('User already in team');
}
this.db.team_members.push({ Uid: userId, Tid: team.Tid });
return team;
}
getLobby(gid) {
const group = this.getGroupById(gid);
const subgroups = this.getSubgroups(gid);
const teams = this.db.teams.filter((t) => t.Gid === gid);
const teamMembers = this.db.team_members;
return { group, subgroups, teams, teamMembers };
}
getTeam(tid) {
return this.db.teams.find((t) => t.Tid === tid);
}
getMapPoints(gid, sgid = null) {
const gameData = this.db.game_data.find((entry) => entry.Gid === Number(gid));
if (!gameData) {
return [];
}
if (sgid === null || sgid === undefined) {
return gameData.GeoCaches;
}
return gameData.GeoCaches.filter((cache) => cache.SGid === Number(sgid));
}
normalizeCache(cache, gid) {
return {
id: cache.CacheId ?? cache.id,
coordinates: {
latitude: Number(cache.Latitude ?? cache.coordinates?.latitude ?? 0),
longitude: Number(cache.Longitude ?? cache.coordinates?.longitude ?? 0),
},
radius: Number(cache.TriggerMeters ?? cache.radius ?? 20),
clue: cache.Title ?? cache.clue ?? 'Cache',
groupId: Number(gid),
subgroupId: Number(cache.SGid ?? cache.subgroupId ?? 1),
claimedBy: cache.ClaimedBy ?? null,
claimedAt: cache.ClaimedAt ?? null,
};
}
getCaches(gid, sgid = null) {
const gameData = this.db.game_data.find((entry) => entry.Gid === Number(gid));
if (!gameData) {
return [];
}
const caches = gameData.GeoCaches || [];
const filtered = sgid === null || sgid === undefined
? caches
: caches.filter((cache) => Number(cache.SGid) === Number(sgid));
return filtered.map((cache) => this.normalizeCache(cache, gid));
}
upsertCache(payload) {
const {
gid,
cacheId,
latitude,
longitude,
radius = 20,
clue = 'Cache',
subgroupId = 1,
} = payload;
const groupId = Number(gid);
const gameData = this.ensureGroupGameData(groupId);
const id = cacheId || `C${Date.now()}`;
const existingIndex = (gameData.GeoCaches || []).findIndex(
(cache) => (cache.CacheId ?? cache.id) === id,
);
const nextCache = {
CacheId: id,
Title: clue,
Latitude: Number(latitude),
Longitude: Number(longitude),
TriggerMeters: Number(radius),
SGid: Number(subgroupId),
ClaimedBy: existingIndex >= 0 ? gameData.GeoCaches[existingIndex].ClaimedBy ?? null : null,
ClaimedAt: existingIndex >= 0 ? gameData.GeoCaches[existingIndex].ClaimedAt ?? null : null,
};
if (!gameData.GeoCaches) {
gameData.GeoCaches = [];
}
if (existingIndex >= 0) {
gameData.GeoCaches[existingIndex] = nextCache;
} else {
gameData.GeoCaches.push(nextCache);
}
return this.normalizeCache(nextCache, groupId);
}
claimCache(payload) {
const { gid, cacheId, uid = null, tid = null } = payload;
const gameData = this.db.game_data.find((entry) => entry.Gid === Number(gid));
if (!gameData || !gameData.GeoCaches) {
throw new Error('Game data not found');
}
const target = gameData.GeoCaches.find((cache) => (cache.CacheId ?? cache.id) === cacheId);
if (!target) {
throw new Error('Cache not found');
}
target.ClaimedBy = tid || uid;
target.ClaimedAt = this.toMinuteIso();
return this.normalizeCache(target, gid);
}
updateGroupSettings(payload) {
const { gid, ...settings } = payload;
const group = this.getGroupById(gid);
if (!group) {
throw new Error('Group not found');
}
Object.assign(group, settings);
return group;
}
getState() {
return this.db;
}
}
const dbController = new DbController();
export { DbController };
export default dbController;
+41
View File
@@ -0,0 +1,41 @@
const session = {
currentUid: null,
currentGid: null,
currentSGid: null,
currentTid: null,
isBusiness: null,
isAcceptedAdmin: false,
};
const setSessionUser = (user) => {
session.currentUid = user?.Uid ?? null;
session.currentGid = user?.Gid ?? null;
session.currentSGid = user?.SGid ?? null;
session.currentTid = user?.TGid ?? null;
session.isBusiness = user?.isBusiness ?? null;
session.isAcceptedAdmin = Boolean(user?.IsAcceptedAdmin);
};
const setSessionGroup = (gid, sgid = null) => {
session.currentGid = gid ?? null;
if (sgid !== null && sgid !== undefined) {
session.currentSGid = sgid;
}
};
const setSessionTeam = (tid) => {
session.currentTid = tid ?? null;
};
const getSession = () => ({ ...session });
const clearSession = () => {
session.currentUid = null;
session.currentGid = null;
session.currentSGid = null;
session.currentTid = null;
session.isBusiness = null;
session.isAcceptedAdmin = false;
};
export { setSessionUser, setSessionGroup, setSessionTeam, getSession, clearSession };
+24
View File
@@ -0,0 +1,24 @@
import usersSeed from '../../DB/users.json';
import groupsSeed from '../../DB/groups.json';
import subgroupsSeed from '../../DB/subgroups.json';
import subgroupMembershipsSeed from '../../DB/subgroup-memberships.json';
import adminWaitlistSeed from '../../DB/admin-waitlist.json';
import teamsSeed from '../../DB/teams.json';
import teamMembersSeed from '../../DB/team-members.json';
import gameDataSeed from '../../DB/game-data.json';
import gameTypesSeed from '../../DB/game-types.json';
const buildDbLink = () => ({
users: usersSeed,
groups: groupsSeed,
subgroups: subgroupsSeed,
subgroup_memberships: subgroupMembershipsSeed,
admin_waitlist: adminWaitlistSeed,
teams: teamsSeed,
team_members: teamMembersSeed,
game_data: gameDataSeed,
game_types: gameTypesSeed,
});
export default buildDbLink;
+117
View File
@@ -0,0 +1,117 @@
import {useState} from 'react';
import Constants from 'expo-constants';
import dbController from './DbController';
import {clearSession, setSessionUser} from './SessionStore';
const hostFromExpo = Constants.expoConfig?.hostUri?.split(':')[0] || null;
const apiBaseUrl = process.env.EXPO_PUBLIC_API_BASE_URL || (hostFromExpo ? `http://${hostFromExpo}:3000` : 'http://localhost:3000');
const postJson = async (path, payload) => {
const response = await fetch(`${apiBaseUrl}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.message || 'Request failed');
}
return result;
};
const useAuthHook = () => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [username, setUsername] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [accountType, setAccountType] = useState('Individual');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const login = async () => {
setError('');
setIsLoading(true);
try {
const user = await postJson('/auth/login', { email: email.trim(), password });
setSessionUser(user);
setIsLoading(false);
return user;
} catch (e) {
try {
const fallbackUser = dbController.login({ email: email.trim(), password });
setSessionUser(fallbackUser);
setIsLoading(false);
return fallbackUser;
} catch {
setError(e.message);
setIsLoading(false);
return null;
}
}
};
const register = async () => {
setError('');
setIsLoading(true);
try {
const user = await postJson('/auth/register', {
username: username.trim(),
email: email.trim().toLowerCase(),
password,
confirmPassword,
accountType,
});
setSessionUser(user);
setIsLoading(false);
return user;
} catch (e) {
try {
const fallbackUser = dbController.register({
username: username.trim(),
email: email.trim().toLowerCase(),
password,
confirmPassword,
accountType,
});
setSessionUser(fallbackUser);
setIsLoading(false);
return fallbackUser;
} catch {
setError(e.message);
setIsLoading(false);
return null;
}
}
};
const logout = () => {
clearSession();
setEmail('');
setPassword('');
setUsername('');
setConfirmPassword('');
setAccountType('Individual');
setError('');
};
return {
email,
password,
username,
confirmPassword,
accountType,
error,
isLoading,
setEmail,
setPassword,
setUsername,
setConfirmPassword,
setAccountType,
login,
register,
logout,
};
};
export default useAuthHook;
+72
View File
@@ -0,0 +1,72 @@
import dbController from './DbController';
const useGameHook = () => {
const getGameTypes = () => dbController.getGameTypes();
const getCreatedPrivateGame = (userId) => {
return dbController.getCreatedPrivateGameByUser(userId);
};
const createPrivateGame = (payload) => {
return dbController.createPrivateGame(payload);
};
const joinPrivateGame = (payload) => {
return dbController.joinPrivateGame(payload);
};
const joinAsAdmin = (payload) => {
return dbController.joinAsAdmin(payload);
};
const getLobby = (gid) => {
return dbController.getLobby(gid);
};
const getTeam = (tid) => {
return dbController.getTeam(tid);
};
const createTeam = (payload) => {
return dbController.createTeam(payload);
};
const joinTeamByCode = (payload) => {
return dbController.joinTeamByCode(payload);
};
const getMapPoints = (gid, sgid = null) => {
return dbController.getMapPoints(gid, sgid);
};
const getCaches = (gid, sgid = null) => {
return dbController.getCaches(gid, sgid);
};
const upsertCache = (payload) => {
return dbController.upsertCache(payload);
};
const claimCache = (payload) => {
return dbController.claimCache(payload);
};
return {
getGameTypes,
getCreatedPrivateGame,
createPrivateGame,
joinPrivateGame,
joinAsAdmin,
getLobby,
getTeam,
createTeam,
joinTeamByCode,
getMapPoints,
getCaches,
upsertCache,
claimCache,
};
};
export default useGameHook;
+38
View File
@@ -0,0 +1,38 @@
import {useEffect, useState} from 'react';
import {isLookingAtCache, isWithinRadius} from '../utils/geoMath';
const usePlayerGame = (playerLocation, playerHeading, activeCaches) => {
const [visibleCache, setVisibleCache] = useState(null);
const [isClaiming, setIsClaiming] = useState(false);
useEffect(() => {
if (!playerLocation || playerHeading === null || playerHeading === undefined) {
setVisibleCache(null);
setIsClaiming(false);
return;
}
const nearbyCache = (activeCaches || []).find((cache) =>
isWithinRadius(playerLocation, cache.coordinates, cache.radius));
if (!nearbyCache) {
setVisibleCache(null);
setIsClaiming(false);
return;
}
const isTargeted = isLookingAtCache(
playerHeading,
playerLocation,
nearbyCache.coordinates,
);
setVisibleCache(nearbyCache);
setIsClaiming(isTargeted);
}, [playerLocation, playerHeading, activeCaches]);
return { visibleCache, isClaiming, setIsClaiming };
};
export default usePlayerGame;
+17
View File
@@ -0,0 +1,17 @@
class Cache {
constructor(id, latitude, longitude, radius, clue, groupId, subgroupId) {
this.id = id;
this.coordinates = { latitude, longitude };
this.radius = radius;
this.clue = clue;
this.groupId = groupId;
this.subgroupId = subgroupId;
}
toJSON() {
return { ...this };
}
}
export default Cache;
+18
View File
@@ -0,0 +1,18 @@
import * as geolib from 'geolib';
const isWithinRadius = (playerCoords, cacheCoords, radius) => {
const distance = geolib.getDistance(playerCoords, cacheCoords);
return distance <= radius;
};
const isLookingAtCache = (playerHeading, playerCoords, cacheCoords, tolerance = 20) => {
if (playerHeading === null || playerHeading === undefined) {
return false;
}
const bearingToCache = geolib.getRhumbLineBearing(playerCoords, cacheCoords);
const diff = Math.abs(bearingToCache - playerHeading);
return diff <= tolerance || diff >= 360 - tolerance;
};
export { isWithinRadius, isLookingAtCache };