mirror of
https://github.com/mudabbir-ahmad/MAD-Treasure-Hunt.git
synced 2026-10-07 19:00:20 +00:00
Add FOV cone visualization to map screens; refactor geoMath utility for heading and cone calculations
Have FOV config as it's own thing so it's easier to change the size of the FOV triangle without having to change multiple parts of the maths for it. Fov triangle needed because for some reason maps doesn't let us have our own fov ??
This commit is contained in:
1 parent
c4c8fcf91d
commit
1e405bb87f
5 files changed
+64
-84
No files matched your search
+1
-3
@@ -4,7 +4,7 @@ const path = require('path');
|
|||||||
|
|
||||||
// --- Configuration ---
|
// --- Configuration ---
|
||||||
|
|
||||||
const PORT = process.env.PORT || 80;
|
const PORT = process.env.PORT || 3000;
|
||||||
const DATA_DIR = path.join(__dirname, 'data');
|
const DATA_DIR = path.join(__dirname, 'data');
|
||||||
|
|
||||||
// --- Data Helpers ---
|
// --- Data Helpers ---
|
||||||
@@ -560,7 +560,6 @@ addRoute('GET', '/game-data/:gid', (req, res, params) => {
|
|||||||
// Helper: convert storage cache to API response format
|
// Helper: convert storage cache to API response format
|
||||||
const toCacheApi = (cache, gid) => ({
|
const toCacheApi = (cache, gid) => ({
|
||||||
id: cache.CacheId,
|
id: cache.CacheId,
|
||||||
name: cache.Name || '',
|
|
||||||
coordinates: { latitude: cache.Latitude, longitude: cache.Longitude },
|
coordinates: { latitude: cache.Latitude, longitude: cache.Longitude },
|
||||||
radius: cache.TriggerMeters,
|
radius: cache.TriggerMeters,
|
||||||
clue: cache.Title,
|
clue: cache.Title,
|
||||||
@@ -573,7 +572,6 @@ const toCacheApi = (cache, gid) => ({
|
|||||||
// Helper: convert API payload to storage cache format
|
// Helper: convert API payload to storage cache format
|
||||||
const toCacheStorage = (data, cacheId) => ({
|
const toCacheStorage = (data, cacheId) => ({
|
||||||
CacheId: cacheId,
|
CacheId: cacheId,
|
||||||
Name: data.name || data.Name || '',
|
|
||||||
Title: data.clue || data.Title || '',
|
Title: data.clue || data.Title || '',
|
||||||
Latitude: data.latitude != null ? data.latitude : (data.Latitude || 0),
|
Latitude: data.latitude != null ? data.latitude : (data.Latitude || 0),
|
||||||
Longitude: data.longitude != null ? data.longitude : (data.Longitude || 0),
|
Longitude: data.longitude != null ? data.longitude : (data.Longitude || 0),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {StyleSheet, View} from 'react-native';
|
import MapView, {Circle, Polygon} from 'react-native-maps';
|
||||||
import MapView, {Circle, Marker} from 'react-native-maps';
|
import {getFovCone} from '../../utils/geoMath';
|
||||||
|
|
||||||
const PlayerMapView = ({ userLocation, visibleCache, heading }) => {
|
const PlayerMapView = ({ userLocation, visibleCache, heading }) => {
|
||||||
// Initialisation -------------
|
// Initialisation -------------
|
||||||
@@ -10,6 +10,10 @@ const PlayerMapView = ({ userLocation, visibleCache, heading }) => {
|
|||||||
|
|
||||||
if (!userLocation) return null;
|
if (!userLocation) return null;
|
||||||
|
|
||||||
|
const coneCoords = (heading !== null && heading !== undefined)
|
||||||
|
? getFovCone(userLocation, heading)
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MapView
|
<MapView
|
||||||
style={{flex: 1}}
|
style={{flex: 1}}
|
||||||
@@ -32,35 +36,17 @@ const PlayerMapView = ({ userLocation, visibleCache, heading }) => {
|
|||||||
strokeColor="rgba(250, 204, 21, 0.90)"
|
strokeColor="rgba(250, 204, 21, 0.90)"
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{heading !== null && heading !== undefined && (
|
{coneCoords && (
|
||||||
<Marker
|
<Polygon
|
||||||
coordinate={userLocation}
|
coordinates={coneCoords}
|
||||||
flat={true}
|
fillColor="rgba(66,133,244,0.28)"
|
||||||
rotation={heading}
|
strokeColor="rgba(66,133,244,0.50)"
|
||||||
anchor={{x: 0.5, y: 1.0}}
|
strokeWidth={1}
|
||||||
tracksViewChanges={false}
|
/>
|
||||||
>
|
|
||||||
<View style={styles.coneWrap}>
|
|
||||||
<View style={styles.cone}/>
|
|
||||||
</View>
|
|
||||||
</Marker>
|
|
||||||
)}
|
)}
|
||||||
</MapView>
|
</MapView>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
coneWrap: {width: 22, height: 24, alignItems: 'center'},
|
|
||||||
cone: {
|
|
||||||
width: 0,
|
|
||||||
height: 0,
|
|
||||||
borderTopWidth: 24,
|
|
||||||
borderLeftWidth: 11,
|
|
||||||
borderRightWidth: 11,
|
|
||||||
borderTopColor: 'rgba(37,99,235,0.70)',
|
|
||||||
borderLeftColor: 'transparent',
|
|
||||||
borderRightColor: 'transparent',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default PlayerMapView;
|
export default PlayerMapView;
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import React, {useEffect, useState} from 'react';
|
import React, {useEffect, useState} from 'react';
|
||||||
import {StyleSheet, View} from 'react-native';
|
import {StyleSheet, View} from 'react-native';
|
||||||
import MapView, {Circle, Marker} from 'react-native-maps';
|
import MapView, {Circle, Marker, Polygon} from 'react-native-maps';
|
||||||
import * as Location from 'expo-location';
|
import * as Location from 'expo-location';
|
||||||
import {Magnetometer} from 'expo-sensors';
|
import {Magnetometer} from 'expo-sensors';
|
||||||
import Screen from '../layout/Screen';
|
import Screen from '../layout/Screen';
|
||||||
import {toHeading} from '../../utils/geoMath';
|
import {getFovCone, toHeading} from '../../utils/geoMath';
|
||||||
|
|
||||||
const DEFAULT_REGION = {latitude: 51.5074, longitude: -0.1278, latitudeDelta: 0.01, longitudeDelta: 0.01};
|
const DEFAULT_REGION = {latitude: 51.5074, longitude: -0.1278, latitudeDelta: 0.01, longitudeDelta: 0.01};
|
||||||
|
|
||||||
@@ -54,6 +54,10 @@ const ExpandedMapScreen = ({route}) => {
|
|||||||
|
|
||||||
// View -----------------------
|
// View -----------------------
|
||||||
|
|
||||||
|
const coneCoords = (userLocation && heading !== null)
|
||||||
|
? getFovCone(userLocation, heading)
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Screen showBack={true} style={styles.container}>
|
<Screen showBack={true} style={styles.container}>
|
||||||
<View style={styles.mapWrap}>
|
<View style={styles.mapWrap}>
|
||||||
@@ -78,19 +82,14 @@ const ExpandedMapScreen = ({route}) => {
|
|||||||
/>
|
/>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
))}
|
))}
|
||||||
{/* Heading cone for both admin and player */}
|
{/* Heading FOV cone */}
|
||||||
{userLocation && heading !== null && (
|
{coneCoords && (
|
||||||
<Marker
|
<Polygon
|
||||||
coordinate={userLocation}
|
coordinates={coneCoords}
|
||||||
flat={true}
|
fillColor="rgba(66,133,244,0.28)"
|
||||||
rotation={heading}
|
strokeColor="rgba(66,133,244,0.50)"
|
||||||
anchor={{x: 0.5, y: 1.0}}
|
strokeWidth={1}
|
||||||
tracksViewChanges={false}
|
/>
|
||||||
>
|
|
||||||
<View style={styles.coneWrap}>
|
|
||||||
<View style={styles.cone}/>
|
|
||||||
</View>
|
|
||||||
</Marker>
|
|
||||||
)}
|
)}
|
||||||
</MapView>
|
</MapView>
|
||||||
</View>
|
</View>
|
||||||
@@ -101,17 +100,6 @@ const ExpandedMapScreen = ({route}) => {
|
|||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {padding: 0},
|
container: {padding: 0},
|
||||||
mapWrap: {flex: 1},
|
mapWrap: {flex: 1},
|
||||||
coneWrap: {width: 22, height: 24, alignItems: 'center'},
|
|
||||||
cone: {
|
|
||||||
width: 0,
|
|
||||||
height: 0,
|
|
||||||
borderTopWidth: 24,
|
|
||||||
borderLeftWidth: 11,
|
|
||||||
borderRightWidth: 11,
|
|
||||||
borderTopColor: 'rgba(37,99,235,0.70)',
|
|
||||||
borderLeftColor: 'transparent',
|
|
||||||
borderRightColor: 'transparent',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export default ExpandedMapScreen;
|
export default ExpandedMapScreen;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, {useCallback, useEffect, useState} from 'react';
|
import React, {useCallback, useEffect, useState} from 'react';
|
||||||
import {ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, TextInput, View} from 'react-native';
|
import {ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, TextInput, View} from 'react-native';
|
||||||
import MapView, {Circle, Marker} from 'react-native-maps';
|
import MapView, {Circle, Marker, Polygon} from 'react-native-maps';
|
||||||
import * as Location from 'expo-location';
|
import * as Location from 'expo-location';
|
||||||
import {Magnetometer} from 'expo-sensors';
|
import {Magnetometer} from 'expo-sensors';
|
||||||
import Screen from '../layout/Screen';
|
import Screen from '../layout/Screen';
|
||||||
@@ -11,7 +11,7 @@ import PlayerMapView from '../gameplay/PlayerMapView';
|
|||||||
import useGameHook from '../../hooks/useGameHook';
|
import useGameHook from '../../hooks/useGameHook';
|
||||||
import usePlayerGame from '../../hooks/usePlayerGame';
|
import usePlayerGame from '../../hooks/usePlayerGame';
|
||||||
import {getSession, setSessionGroup, setSessionUser} from '../../hooks/SessionStore';
|
import {getSession, setSessionGroup, setSessionUser} from '../../hooks/SessionStore';
|
||||||
import {toHeading} from '../../utils/geoMath';
|
import {getFovCone, toHeading} from '../../utils/geoMath';
|
||||||
|
|
||||||
const DEFAULT_REGION = {latitude: 51.5074, longitude: -0.1278, latitudeDelta: 0.05, longitudeDelta: 0.05};
|
const DEFAULT_REGION = {latitude: 51.5074, longitude: -0.1278, latitudeDelta: 0.05, longitudeDelta: 0.05};
|
||||||
|
|
||||||
@@ -378,17 +378,12 @@ const MapScreen = ({navigation}) => {
|
|||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
))}
|
))}
|
||||||
{userLocation && heading !== null && (
|
{userLocation && heading !== null && (
|
||||||
<Marker
|
<Polygon
|
||||||
coordinate={userLocation}
|
coordinates={getFovCone(userLocation, heading)}
|
||||||
flat={true}
|
fillColor="rgba(66,133,244,0.28)"
|
||||||
rotation={heading}
|
strokeColor="rgba(66,133,244,0.50)"
|
||||||
anchor={{x: 0.5, y: 1.0}}
|
strokeWidth={1}
|
||||||
tracksViewChanges={false}
|
/>
|
||||||
>
|
|
||||||
<View style={styles.headingConeWrap}>
|
|
||||||
<View style={styles.headingCone}/>
|
|
||||||
</View>
|
|
||||||
</Marker>
|
|
||||||
)}
|
)}
|
||||||
</MapView>
|
</MapView>
|
||||||
<Pressable style={styles.expandButton} onPress={handleExpandMap}>
|
<Pressable style={styles.expandButton} onPress={handleExpandMap}>
|
||||||
@@ -563,18 +558,6 @@ const styles = StyleSheet.create({
|
|||||||
cacheSection: {flex: 1, backgroundColor: '#f3f4f6', paddingHorizontal: 12, paddingTop: 10},
|
cacheSection: {flex: 1, backgroundColor: '#f3f4f6', paddingHorizontal: 12, paddingTop: 10},
|
||||||
emptyText: {color: '#9ca3af', textAlign: 'center', marginTop: 20, fontSize: 14},
|
emptyText: {color: '#9ca3af', textAlign: 'center', marginTop: 20, fontSize: 14},
|
||||||
mapWrap: {flex: 1},
|
mapWrap: {flex: 1},
|
||||||
// Heading direction cone (like Google Maps)
|
|
||||||
headingConeWrap: {width: 22, height: 24, alignItems: 'center'},
|
|
||||||
headingCone: {
|
|
||||||
width: 0,
|
|
||||||
height: 0,
|
|
||||||
borderTopWidth: 24,
|
|
||||||
borderLeftWidth: 11,
|
|
||||||
borderRightWidth: 11,
|
|
||||||
borderTopColor: 'rgba(37,99,235,0.70)',
|
|
||||||
borderLeftColor: 'transparent',
|
|
||||||
borderRightColor: 'transparent',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export default MapScreen;
|
export default MapScreen;
|
||||||
+28
-3
@@ -1,9 +1,34 @@
|
|||||||
import * as geolib from 'geolib';
|
import * as geolib from 'geolib';
|
||||||
|
|
||||||
// Convert raw Magnetometer {x, y} reading to a 0-360° heading
|
// --- FOV Configuration ---
|
||||||
|
const FOV_DISTANCE = 20; // metres — radius of arc + length of straight edges
|
||||||
|
const FOV_ANGLE = 45; // degrees — total field of view (halved for each side)
|
||||||
|
|
||||||
|
// Convert raw Magnetometer {x, y} reading to a 0-360° heading (0° = north)
|
||||||
const toHeading = ({x, y}) => {
|
const toHeading = ({x, y}) => {
|
||||||
const angle = Math.atan2(y, x) * (180 / Math.PI);
|
const angle = Math.atan2(y, x) * (180 / Math.PI);
|
||||||
return angle < 0 ? angle + 360 : angle;
|
const heading = 90 - angle;
|
||||||
|
return heading < 0 ? heading + 360 : heading % 360;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build a pie-shaped FOV sector as polygon coordinates
|
||||||
|
const getFovCone = (location, headingDeg, radiusMeters = FOV_DISTANCE, halfAngle = FOV_ANGLE / 2, arcSegments = 20) => {
|
||||||
|
const origin = {latitude: location.latitude, longitude: location.longitude};
|
||||||
|
const startBearing = headingDeg - halfAngle;
|
||||||
|
const endBearing = headingDeg + halfAngle;
|
||||||
|
const step = (endBearing - startBearing) / arcSegments;
|
||||||
|
|
||||||
|
// Tip of the sector
|
||||||
|
const points = [location];
|
||||||
|
|
||||||
|
// Arc points at radiusMeters distance from origin
|
||||||
|
for (let i = 0; i <= arcSegments; i++) {
|
||||||
|
const bearing = startBearing + step * i;
|
||||||
|
const dest = geolib.computeDestinationPoint(origin, radiusMeters, bearing);
|
||||||
|
points.push({latitude: dest.latitude, longitude: dest.longitude});
|
||||||
|
}
|
||||||
|
|
||||||
|
return points;
|
||||||
};
|
};
|
||||||
|
|
||||||
const isWithinRadius = (playerCoords, cacheCoords, radius) => {
|
const isWithinRadius = (playerCoords, cacheCoords, radius) => {
|
||||||
@@ -20,5 +45,5 @@ const isLookingAtCache = (playerHeading, playerCoords, cacheCoords, tolerance =
|
|||||||
return diff <= tolerance || diff >= 360 - tolerance;
|
return diff <= tolerance || diff >= 360 - tolerance;
|
||||||
};
|
};
|
||||||
|
|
||||||
export { toHeading, isWithinRadius, isLookingAtCache };
|
export { toHeading, getFovCone, isWithinRadius, isLookingAtCache };
|
||||||
|
|
||||||
Reference in new issue
Block a user