Refactor cache claim logic to use admin-defined claim distance; update related components for consistency

This commit is contained in:
bobbert committed 2026-04-07 18:00:30 +01:00
1 parent ecb61c4858
commit 2646c7b9bc
11 files changed
+98 -74

No files matched your search

+1 -2
View File
@@ -19,8 +19,7 @@
}
},
"android": {
"usesCleartextTraffic": true,
"adaptiveIcon": {
"adaptiveIcon": {
"foregroundImage": "./assets/images/android-icon-monochrome.png",
"backgroundColor": "#ffffff"
},
+1
View File
@@ -209,6 +209,7 @@ addRoute('POST', '/groups', async (req, res) => {
CreatedByUid: payload.CreatedByUid,
MaxMemberSubgroups: payload.MaxMemberSubgroups || 1,
TeamsEnabled: payload.TeamsEnabled !== undefined ? payload.TeamsEnabled : true,
CacheTriggerMeters: payload.CacheTriggerMeters || 20,
ApprovedAdmins: [payload.CreatedByUid],
CreatedAt: new Date().toISOString(),
};
@@ -1,7 +1,7 @@
import React from 'react';
import MapView, {Circle, Marker} from 'react-native-maps';
const AdminCacheEditorView = ({ userLocation, caches, onAddCache, onMoveCache }) => {
const AdminCacheEditorView = ({ userLocation, caches, claimDistance, onAddCache, onMoveCache }) => {
// Initialisation -------------
// State ----------------------
// Handlers -------------------
@@ -34,7 +34,7 @@ const AdminCacheEditorView = ({ userLocation, caches, onAddCache, onMoveCache })
/>
<Circle
center={cache.coordinates}
radius={cache.radius}
radius={claimDistance}
fillColor="rgba(59, 130, 246, 0.15)"
strokeColor="rgba(59, 130, 246, 0.85)"
/>
+3 -2
View File
@@ -1,11 +1,12 @@
import React, {useEffect, useState} from 'react';
import {StyleSheet, Text, View} from 'react-native';
import {CACHE_CLAIM_TIMER} from '../../utils/geoMath';
const ClaimTimerView = ({ cache, onClaimSuccess, isClaiming }) => {
// Initialisation -------------
// State ----------------------
const [timeLeft, setTimeLeft] = useState(5);
const [timeLeft, setTimeLeft] = useState(CACHE_CLAIM_TIMER);
// Handlers -------------------
@@ -16,7 +17,7 @@ const ClaimTimerView = ({ cache, onClaimSuccess, isClaiming }) => {
} else if (isClaiming && timeLeft === 0 && cache) {
onClaimSuccess(cache.id);
} else {
setTimeLeft(5);
setTimeLeft(CACHE_CLAIM_TIMER);
}
return () => clearTimeout(timer);
}, [isClaiming, timeLeft, cache, onClaimSuccess]);
+2 -2
View File
@@ -2,7 +2,7 @@ import React from 'react';
import MapView, {Circle, Polygon} from 'react-native-maps';
import {getFovCone} from '../../utils/geoMath';
const PlayerMapView = ({ userLocation, visibleCache, heading }) => {
const PlayerMapView = ({ userLocation, visibleCache, heading, claimDistance }) => {
// Initialisation -------------
// State ----------------------
// Handlers -------------------
@@ -31,7 +31,7 @@ const PlayerMapView = ({ userLocation, visibleCache, heading }) => {
{visibleCache ? (
<Circle
center={visibleCache.coordinates}
radius={visibleCache.radius}
radius={claimDistance}
fillColor="rgba(250, 204, 21, 0.20)"
strokeColor="rgba(250, 204, 21, 0.90)"
/>
+4 -3
View File
@@ -11,8 +11,9 @@ const DEFAULT_REGION = {latitude: 51.5074, longitude: -0.1278, latitudeDelta: 0.
const ExpandedMapScreen = ({route}) => {
// Initialisation ------------
const {isAdmin, cacheRecords: cacheStr} = route.params || {};
const {isAdmin, cacheRecords: cacheStr, claimDistance: routeClaimDistance} = route.params || {};
const caches = cacheStr ? JSON.parse(cacheStr) : [];
const claimDistance = routeClaimDistance || 20;
// State ----------------------
@@ -74,9 +75,9 @@ const ExpandedMapScreen = ({route}) => {
title={cache.name || cache.clue}
pinColor="#2563eb"
/>
<Circle
<Circle
center={cache.coordinates}
radius={cache.radius}
radius={claimDistance}
fillColor="rgba(59,130,246,0.15)"
strokeColor="rgba(59,130,246,0.85)"
/>
@@ -19,6 +19,7 @@ const GameSettingsScreen = () => {
const [groupName, setGroupName] = useState('');
const [teamsEnabled, setTeamsEnabled] = useState(false);
const [maxSubgroups, setMaxSubgroups] = useState('1');
const [cacheTriggerMeters, setCacheTriggerMeters] = useState('20');
const [adminJoinCode, setAdminJoinCode] = useState('');
const [memberJoinCode, setMemberJoinCode] = useState('');
@@ -35,6 +36,7 @@ const GameSettingsScreen = () => {
setGroupName(group.GroupName || '');
setTeamsEnabled(Boolean(group.TeamsEnabled));
setMaxSubgroups(String(group.MaxMemberSubgroups || 1));
setCacheTriggerMeters(String(group.CacheTriggerMeters || 20));
setAdminJoinCode(group.AdminJoinCode || '');
}
const sgs = await getSubgroups(session.currentGid);
@@ -52,6 +54,7 @@ const GameSettingsScreen = () => {
GroupName: groupName.trim(),
TeamsEnabled: teamsEnabled,
MaxMemberSubgroups: parseInt(maxSubgroups) || 1,
CacheTriggerMeters: parseInt(cacheTriggerMeters) || 20,
});
setSaving(false);
};
@@ -112,6 +115,16 @@ const GameSettingsScreen = () => {
</>
)}
<Text style={styles.label}>Cache Claim Distance (metres)</Text>
<TextInput
style={styles.input}
value={cacheTriggerMeters}
onChangeText={setCacheTriggerMeters}
keyboardType="numeric"
placeholder="20"
placeholderTextColor="#9ca3af"
/>
<View style={styles.codeSection}>
<Text style={styles.codeLabel}>Admin Join Code</Text>
<Text style={styles.codeValue}>{adminJoinCode || '—'}</Text>
+10 -3
View File
@@ -10,7 +10,7 @@ const ManageGameScreen = () => {
// Initialisation ------------
const session = getSession();
const {getCaches, upsertCache} = useGameHook();
const {getCaches, upsertCache, getLobby} = useGameHook();
// State ----------------------
@@ -18,6 +18,7 @@ const ManageGameScreen = () => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [cacheRecords, setCacheRecords] = useState([]);
const [claimDistance, setClaimDistance] = useState(20);
// Handlers -------------------
@@ -32,6 +33,13 @@ const ManageGameScreen = () => {
useEffect(() => { loadCaches(); }, [loadCaches]);
useEffect(() => {
if (!session.currentGid) return;
getLobby(session.currentGid).then((group) => {
if (group) setClaimDistance(group.CacheTriggerMeters || 20);
});
}, [session.currentGid]);
useEffect(() => {
let locationSub;
const start = async () => {
@@ -62,7 +70,6 @@ const ManageGameScreen = () => {
gid: session.currentGid,
latitude: coordinate.latitude,
longitude: coordinate.longitude,
radius: 20,
clue: `Cache ${cacheRecords.length + 1}`,
subgroupId: 1,
});
@@ -78,7 +85,6 @@ const ManageGameScreen = () => {
cacheId,
latitude: coordinate.latitude,
longitude: coordinate.longitude,
radius: current.radius,
clue: current.clue,
subgroupId: current.subgroupId,
});
@@ -125,6 +131,7 @@ const ManageGameScreen = () => {
<AdminCacheEditorView
userLocation={userLocation}
caches={cacheRecords}
claimDistance={claimDistance}
onAddCache={handleAddCache}
onMoveCache={handleMoveCache}
/>
+10 -17
View File
@@ -40,13 +40,16 @@ const MapScreen = ({navigation}) => {
const [newCoord, setNewCoord] = useState(null);
const [newName, setNewName] = useState('');
const [newClue, setNewClue] = useState('');
const [newRadius, setNewRadius] = useState('20');
// Global claim distance comes from the group's CacheTriggerMeters setting
const claimDistance = groupInfo?.CacheTriggerMeters || 20;
const isPlayer = inGame && !isAdmin;
const {visibleCache, isClaiming, setIsClaiming} = usePlayerGame(
isPlayer ? userLocation : null,
isPlayer ? heading : null,
isPlayer ? cacheRecords : [],
claimDistance,
);
const mapRegion = userLocation
@@ -151,7 +154,6 @@ const MapScreen = ({navigation}) => {
setNewCoord(fallback);
setNewName('');
setNewClue('');
setNewRadius('20');
setIsCreating(true);
};
@@ -161,7 +163,6 @@ const MapScreen = ({navigation}) => {
setNewCoord(cache.coordinates);
setNewName(cache.name || '');
setNewClue(cache.clue || '');
setNewRadius(String(cache.radius || 20));
setIsCreating(true);
};
@@ -180,7 +181,6 @@ const MapScreen = ({navigation}) => {
name: newName.trim(),
latitude: newCoord.latitude,
longitude: newCoord.longitude,
radius: parseInt(newRadius) || 20,
clue: newClue.trim(),
subgroupId: 1,
};
@@ -202,6 +202,7 @@ const MapScreen = ({navigation}) => {
isAdmin,
cacheRecords: JSON.stringify(cacheRecords),
heading: heading || 0,
claimDistance,
});
};
@@ -264,7 +265,7 @@ const MapScreen = ({navigation}) => {
<Marker coordinate={cache.coordinates} pinColor="#9ca3af" title={cache.clue}/>
<Circle
center={cache.coordinates}
radius={cache.radius}
radius={claimDistance}
fillColor="rgba(156,163,175,0.15)"
strokeColor="rgba(156,163,175,0.6)"
/>
@@ -282,7 +283,7 @@ const MapScreen = ({navigation}) => {
/>
<Circle
center={newCoord}
radius={parseInt(newRadius) || 20}
radius={claimDistance}
fillColor="rgba(37,99,235,0.15)"
strokeColor="rgba(37,99,235,0.85)"
/>
@@ -305,8 +306,7 @@ const MapScreen = ({navigation}) => {
value={newClue}
onChangeText={setNewClue}
/>
<View style={styles.coordRow}>
<View style={styles.coordField}>
<View style={styles.coordRow}> <View style={styles.coordField}>
<Text style={styles.coordLabel}>Latitude</Text>
<TextInput
style={styles.formInput}
@@ -327,14 +327,6 @@ const MapScreen = ({navigation}) => {
/>
</View>
</View>
<TextInput
style={styles.formInput}
placeholder="Claim Radius (metres)"
placeholderTextColor="#9ca3af"
value={newRadius}
onChangeText={setNewRadius}
keyboardType="numeric"
/>
<ButtonTray>
<Button
label={editingCacheId ? 'Update Cache' : 'Save Cache'}
@@ -371,7 +363,7 @@ const MapScreen = ({navigation}) => {
<Marker coordinate={cache.coordinates} title={cache.name || cache.clue}/>
<Circle
center={cache.coordinates}
radius={cache.radius}
radius={claimDistance}
fillColor="rgba(59,130,246,0.15)"
strokeColor="rgba(59,130,246,0.85)"
/>
@@ -450,6 +442,7 @@ const MapScreen = ({navigation}) => {
userLocation={userLocation}
visibleCache={visibleCache}
heading={heading}
claimDistance={claimDistance}
/>
<Pressable style={styles.expandButton} onPress={handleExpandMap}>
<Text style={styles.expandIcon}>⛶</Text>
+10 -14
View File
@@ -1,7 +1,7 @@
import {useEffect, useState} from 'react';
import {isLookingAtCache, isWithinRadius} from '../utils/geoMath';
import {isInClaimCone} from '../utils/geoMath';
const usePlayerGame = (playerLocation, playerHeading, activeCaches) => {
const usePlayerGame = (playerLocation, playerHeading, activeCaches, claimDistance) => {
// State ----------------------
const [visibleCache, setVisibleCache] = useState(null);
@@ -16,19 +16,15 @@ const usePlayerGame = (playerLocation, playerHeading, activeCaches) => {
return;
}
const nearbyCache = (activeCaches || []).find((cache) =>
isWithinRadius(playerLocation, cache.coordinates, cache.radius));
// Find the first cache that falls inside the invisible claim cone
// (same FOV angle as the visible cone, but radius = admin-set claimDistance)
const cacheInCone = (activeCaches || []).find((cache) =>
isInClaimCone(playerHeading, playerLocation, cache.coordinates, claimDistance)
);
if (!nearbyCache) {
setVisibleCache(null);
setIsClaiming(false);
return;
}
const isTargeted = isLookingAtCache(playerHeading, playerLocation, nearbyCache.coordinates);
setVisibleCache(nearbyCache);
setIsClaiming(isTargeted);
}, [playerLocation, playerHeading, activeCaches]);
setVisibleCache(cacheInCone || null);
setIsClaiming(Boolean(cacheInCone));
}, [playerLocation, playerHeading, activeCaches, claimDistance]);
// Return ---------------------
+42 -29
View File
@@ -1,49 +1,62 @@
import * as geolib from 'geolib';
// --- FOV Configuration ---
const FOV_DISTANCE = 30; // metres — radius of arc + length of straight edges
const FOV_ANGLE = 60; // degrees — total field of view (halved for each side)
const FOV_SIZE = 50; // metres — radius of the visible FOV cone drawn on screen
const FOV_ANGLE = 60; // degrees — total field of view (halved for each side)
const CACHE_CLAIM_TIMER = 5; // seconds — how long the player must hold a cache in the claim cone to claim it
// Convert raw Magnetometer {x, y} reading to a 0-360° heading (0° = north)
const toHeading = ({x, y}) => {
const angle = Math.atan2(y, x) * (180 / Math.PI);
const heading = 90 - angle;
return heading < 0 ? heading + 360 : heading % 360;
const angle = Math.atan2(y, x) * (180 / Math.PI);
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;
// radiusMeters defaults to FOV_SIZE (the visible cone) — pass a different value to build the invisible claim cone
const getFovCone = (location, headingDeg, radiusMeters = FOV_SIZE, 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];
// 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});
}
// 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;
return points;
};
const isWithinRadius = (playerCoords, cacheCoords, radius) => {
const distance = geolib.getDistance(playerCoords, cacheCoords);
return distance <= 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;
const isLookingAtCache = (playerHeading, playerCoords, cacheCoords, tolerance = FOV_ANGLE / 2) => {
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 { toHeading, getFovCone, isWithinRadius, isLookingAtCache };
// Checks whether a cache falls within the invisible claim sector
// The claim sector uses the same FOV_ANGLE as the visible cone but extends to the
// admin-defined claimDistance rather than the visible FOV_SIZE.
// If the cache sits inside this sector the claiming countdown is triggered.
const isInClaimCone = (playerHeading, playerCoords, cacheCoords, claimDistance) => {
if (playerHeading === null || playerHeading === undefined) return false;
// Cache must be within the admin-configured claim distance
if (!isWithinRadius(playerCoords, cacheCoords, claimDistance)) return false;
// Cache must also fall within the same angular width as the visible cone
return isLookingAtCache(playerHeading, playerCoords, cacheCoords);
};
export { toHeading, getFovCone, isWithinRadius, isLookingAtCache, isInClaimCone, FOV_ANGLE, FOV_SIZE, CACHE_CLAIM_TIMER };