diff --git a/traque-back/Dockerfile b/traque-back/Dockerfile index b39ece8..840b0a6 100644 --- a/traque-back/Dockerfile +++ b/traque-back/Dockerfile @@ -5,7 +5,7 @@ FROM node:22-alpine WORKDIR /app # Copy package.json and package-lock.json to the /app directory -COPY package.json package-lock.json ./ +COPY package.json package-lock.json* ./ # Install dependencies RUN npm install diff --git a/traque-back/Dockerfile.dev b/traque-back/Dockerfile.dev index 849da81..3bd9073 100644 --- a/traque-back/Dockerfile.dev +++ b/traque-back/Dockerfile.dev @@ -8,7 +8,7 @@ WORKDIR /app ENV NODE_ENV=development # Copy package.json and package-lock.json to the /app directory -COPY package.json package-lock.json ./ +COPY package.json package-lock.json* ./ # Install dependencies RUN npm install diff --git a/traque-back/admin_socket.js b/traque-back/admin_socket.js index 0d6282f..5bebd34 100644 --- a/traque-back/admin_socket.js +++ b/traque-back/admin_socket.js @@ -5,7 +5,7 @@ This module also exposes functions to send messages via socket to all admins */ import { io } from "./index.js"; import game from "./game.js" -import zone from "./zone_manager.js" +import zoneManager from "./zone_manager.js" import penaltyController from "./penalty_controller.js"; import { playersBroadcast, sendUpdatedTeamInformations } from "./team_socket.js"; import { createHash } from "crypto"; @@ -57,12 +57,11 @@ export function initAdminSocketHandler() { // Other settings that need initialization socket.emit("penalty_settings", penaltyController.settings) socket.emit("game_settings", game.settings) - socket.emit("zone_settings", zone.zoneSettings) - socket.emit("zone", zone.currentZone) - socket.emit("new_zone", { - begin: zone.currentStartZone, - end: zone.nextZone, - endDate: zone.nextZoneDate, + socket.emit("zone_settings", zoneManager.settings) + socket.emit("current_zone", { + begin: zoneManager.getCurrentZone(), + end: zoneManager.getNextZone(), + endDate: zoneManager.currentZoneEndDate, }) } else { // Attempt unsuccessful @@ -89,11 +88,11 @@ export function initAdminSocketHandler() { socket.emit("error", "Not logged in"); return; } - if (!game.setZoneSettings(settings)) { + if (!zoneManager.changeSettings(settings)) { socket.emit("error", "Error changing zone"); - socket.emit("zone_settings", zone.zoneSettings) // Still broadcast the old config to the client who submited an incorrect config to keep the client up to date + socket.emit("zone_settings", zoneManager.settings) } else { - secureAdminBroadcast("zone_settings", zone.zoneSettings) + secureAdminBroadcast("zone_settings", zoneManager.settings) } }) diff --git a/traque-back/game.js b/traque-back/game.js index 1bc788f..81d674b 100644 --- a/traque-back/game.js +++ b/traque-back/game.js @@ -3,12 +3,44 @@ This module manages the main game state, the teams, the settings and the game lo */ import { secureAdminBroadcast } from "./admin_socket.js"; import { playersBroadcast, sendUpdatedTeamInformations } from "./team_socket.js"; -import { isInCircle, getDistanceFromLatLon } from "./map_utils.js"; import timeoutHandler from "./timeoutHandler.js"; import penaltyController from "./penalty_controller.js"; import zoneManager from "./zone_manager.js"; import trajectory from "./trajectory.js"; +/** + * Compute the distance between two points givent their longitude and latitude + * @param {Object} pos1 The first position + * @param {Object} pos2 The second position + * @returns the distance between the two positions in meters + * @see https://gist.github.com/miguelmota/10076960 + */ +function getDistanceFromLatLon({ lat: lat1, lng: lon1 }, { lat: lat2, lng: lon2 }) { + const degToRad = (deg) => deg * (Math.PI / 180); + var R = 6371; // Radius of the earth in km + var dLat = degToRad(lat2 - lat1); + var dLon = degToRad(lon2 - lon1); + var a = + Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(degToRad(lat1)) * Math.cos(degToRad(lat2)) * + Math.sin(dLon / 2) * Math.sin(dLon / 2) + ; + var c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + var d = R * c; // Distance in km + return d * 1000; +} + +/** + * Check if a GPS point is in a circle + * @param {Object} position The position to check, an object with lat and lng fields + * @param {Object} center The center of the circle, an object with lat and lng fields + * @param {Number} radius The radius of the circle in meters + * @returns + */ +function isInCircle(position, center, radius) { + return getDistanceFromLatLon(position, center) < radius; +} + /** * The possible states of the game */ @@ -56,7 +88,7 @@ export default { switch (newState) { case GameState.SETUP: trajectory.stop(); - zoneManager.reset(); + zoneManager.stop(); penaltyController.stop(); timeoutHandler.endAllSendPositionTimeout(); for (let team of this.teams) { @@ -77,28 +109,38 @@ export default { this.updateTeamChasing(); break; case GameState.PLACEMENT: + if (this.teams.length < 3) { + secureAdminBroadcast("game_state", {state: this.state, startDate: this.startDate}); + return false; + } trajectory.stop(); - zoneManager.reset(); + zoneManager.stop(); penaltyController.stop(); timeoutHandler.endAllSendPositionTimeout(); this.startDate = null; break; case GameState.PLAYING: - if (!zoneManager.start()) { + if (this.teams.length < 3) { + secureAdminBroadcast("game_state", {state: this.state, startDate: this.startDate}); return false; } trajectory.start(); + zoneManager.start(); penaltyController.start(); this.initLastSentLocations(); this.startDate = Date.now(); break; case GameState.FINISHED: + if (this.state != GameState.PLAYING) { + secureAdminBroadcast("game_state", {state: this.state, startDate: this.startDate}); + return false; + } for (const team of this.teams) { if (!team.finishDate) team.finishDate = Date.now(); } trajectory.stop(); + zoneManager.stop(); penaltyController.stop(); - zoneManager.reset(); timeoutHandler.endAllSendPositionTimeout(); break; } @@ -379,28 +421,4 @@ export default { timeoutHandler.endSendPositionTimeout(teamId); this.updateTeamChasing(); }, - - /** - * Change the settings of the Zone manager - * The game should not be in PLAYING or FINISHED state - * @param {Object} newSettings The object containing the settings to be changed - * @returns false if failed - */ - setZoneSettings(newSettings) { - if ('min' in newSettings || 'max' in newSettings) { - const min = newSettings.min ?? zoneManager.zoneSettings.min; - const max = newSettings.max ?? zoneManager.zoneSettings.max; - // The end zone must be included in the start zone - if (!isInCircle(min.center, max.center, max.radius-min.radius)) { - return false; - } - } - zoneManager.udpateSettings(newSettings); - if (this.state == GameState.PLAYING || this.state == GameState.FINISHED) { - if (!zoneManager.start()) { - return false; - } - } - return true; - } } \ No newline at end of file diff --git a/traque-back/map_utils.js b/traque-back/map_utils.js deleted file mode 100644 index ca5385d..0000000 --- a/traque-back/map_utils.js +++ /dev/null @@ -1,40 +0,0 @@ -/** - * Convert a angle from degree to radian - * @param {Number} deg angle in degree - * @returns angle in radian - */ -function degToRad(deg) { - return deg * (Math.PI / 180); -} - -/** - * Compute the distance between two points givent their longitude and latitude - * @param {Object} pos1 The first position - * @param {Object} pos2 The second position - * @returns the distance between the two positions in meters - * @see https://gist.github.com/miguelmota/10076960 - */ -export function getDistanceFromLatLon({ lat: lat1, lng: lon1 }, { lat: lat2, lng: lon2 }) { - var R = 6371; // Radius of the earth in km - var dLat = degToRad(lat2 - lat1); - var dLon = degToRad(lon2 - lon1); - var a = - Math.sin(dLat / 2) * Math.sin(dLat / 2) + - Math.cos(degToRad(lat1)) * Math.cos(degToRad(lat2)) * - Math.sin(dLon / 2) * Math.sin(dLon / 2) - ; - var c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); - var d = R * c; // Distance in km - return d * 1000; -} - -/** - * Check if a GPS point is in a circle - * @param {Object} position The position to check, an object with lat and lng fields - * @param {Object} center The center of the circle, an object with lat and lng fields - * @param {Number} radius The radius of the circle in meters - * @returns - */ -export function isInCircle(position, center, radius) { - return getDistanceFromLatLon(position, center) < radius; -} diff --git a/traque-back/penalty_controller.js b/traque-back/penalty_controller.js index 35d7233..15bca3a 100644 --- a/traque-back/penalty_controller.js +++ b/traque-back/penalty_controller.js @@ -1,11 +1,10 @@ /* This module manages the verification of the game rules and the penalties. */ -import { isInCircle } from "./map_utils.js"; import { sendUpdatedTeamInformations, teamBroadcast } from "./team_socket.js"; import { secureAdminBroadcast } from "./admin_socket.js"; import game, { GameState } from "./game.js"; -import zone from "./zone_manager.js"; +import zoneManager from "./zone_manager.js"; export default { // Object mapping team id to the date they left the zone as a UNIX millisecond timestamp @@ -115,10 +114,10 @@ export default { game.teams.forEach((team) => { if (team.captured) { return } //All the informations are not ready yet - if (team.currentLocation == null || zone.currentZone == null) { + if (team.currentLocation == null || !zoneManager.isRunning) { return; } - if (!isInCircle({ lat: team.currentLocation[0], lng: team.currentLocation[1] }, zone.currentZone.center, zone.currentZone.radius)) { + if (!zoneManager.isInCircle({ lat: team.currentLocation[0], lng: team.currentLocation[1] })) { //The team was not previously out of the zone if (!this.outOfBoundsSince[team.id]) { this.outOfBoundsSince[team.id] = Date.now(); diff --git a/traque-back/team_socket.js b/traque-back/team_socket.js index f74afb2..7b0ddef 100644 --- a/traque-back/team_socket.js +++ b/traque-back/team_socket.js @@ -6,7 +6,7 @@ This module also exposes functions to send messages via socket to all teams import { secureAdminBroadcast } from "./admin_socket.js"; import { io } from "./index.js"; import game from "./game.js"; -import zone from "./zone_manager.js"; +import zoneManager from "./zone_manager.js"; /** * Send a socket message to all the players of a team @@ -97,11 +97,10 @@ export function initTeamSocket() { socket.emit("login_response", true); socket.emit("game_state", game.state); socket.emit("game_settings", game.settings); - socket.emit("zone", zone.currentZone); - socket.emit("new_zone", { - begin: zone.currentStartZone, - end: zone.nextZone, - endDate: zone.nextZoneDate, + socket.emit("zone", { + begin: zoneManager.getCurrentZone(), + end: zoneManager.getNextZone(), + endDate: zoneManager.currentZoneEndDate, }) callback({ isLoggedIn : true, message: "Logged in"}); }); diff --git a/traque-back/zone_manager.js b/traque-back/zone_manager.js index e17fe58..5630b6f 100644 --- a/traque-back/zone_manager.js +++ b/traque-back/zone_manager.js @@ -1,237 +1,230 @@ -/* -This module manages the play area during the game, shrinking it over time based of some settings. -*/ -import { randomCirclePoint } from 'random-location' -import { isInCircle } from './map_utils.js'; import { playersBroadcast } from './team_socket.js'; import { secureAdminBroadcast } from './admin_socket.js'; -/** - * Scale a value that is known to be in a range to a new range - * for instance map(50,0,100,1000,2000) will return 1500 as 50 is halfway between 0 and 100 and 1500 is halfway through 1000 and 2000 - * @param {Number} value value to map - * @param {Number} oldMin minimum value of the number - * @param {Number} oldMax maximum value of the number - * @param {Number} newMin minimum value of the output - * @param {Number} newMax maximum value of the output - * @returns - */ -function map(value, oldMin, oldMax, newMin, newMax) { - return ((value - oldMin) / (oldMax - oldMin)) * (newMax - newMin) + newMin; + +/* -------------------------------- Useful functions and constants -------------------------------- */ + +const EARTH_RADIUS = 6_371_000; // Radius of the earth in m + +function haversine_distance({ lat: lat1, lng: lon1 }, { lat: lat2, lng: lon2 }) { + const degToRad = (deg) => deg * (Math.PI / 180); + const dLat = degToRad(lat2 - lat1); + const dLon = degToRad(lon2 - lon1); + const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.cos(degToRad(lat1)) * Math.cos(degToRad(lat2)) * Math.sin(dLon / 2) * Math.sin(dLon / 2); + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + return c * EARTH_RADIUS; } +function latlngEqual(latlng1, latlng2, epsilon = 1e-9) { + return Math.abs(latlng1.lat - latlng2.lat) < epsilon && Math.abs(latlng1.lng - latlng2.lng) < epsilon; +} + + +/* -------------------------------- Polygon zones -------------------------------- */ + +const defaultPolygonSettings = { polygons: [], durations: [] }; + +function polygonZone(points, duration) { + return { + points: points, + duration: duration, + + isInZone(location) { + const {lat: x, lng: y} = location; + let inside = false; + + for (let i = 0, j = this.points.length - 1; i < this.points.length; j = i++) { + const {lat: xi, lng: yi} = this.points[i]; + const {lat: xj, lng: yj} = this.points[j]; + + const intersects = ((yi > y) !== (yj > y)) && (x < ((xj - xi) * (y - yi)) / (yj - yi) + xi); + + if (intersects) inside = !inside; + } + + return inside; + } + } +} + +function mergePolygons(poly1, poly2) { + // poly1 and poly2 are clockwise, not overlaping and touching polygons. If those two polygons were on a map, they would be + // one against each other, and the merge would make a new clockwise polygon out of the outer border of the two polygons. + // If it happens that poly1 and poly2 are not touching, poly1 would be returned untouched. + // Basically because polygons are clockwise, the alogorithm starts from a point A in poly1 not shared by poly2, and + // when a point is shared by poly1 and poly2, the algorithm continues in poly2, and so on until point A. + + const getPointIndex = (point, array) => { + for (let i = 0; i < array.length; i++) { + if (latlngEqual(array[i], point)) return i; + } + return -1; + } + + // Find the index of the first point of poly1 that doesn't belong to merge (it exists) + let i = 0; + while (getPointIndex(poly1[i], poly2) != -1) i++; + // Starting the merge from that point + const merge = [poly1[i]]; + i = (i + 1) % poly1.length; + let currentArray = poly1; + let otherArray = poly2; + while (!latlngEqual(currentArray[i], merge[0])) { + const j = getPointIndex(currentArray[i], otherArray); + if (j != -1) { + [currentArray, otherArray] = [otherArray, currentArray]; + i = j; + } + merge.push(currentArray[i]); + i = (i + 1) % currentArray.length; + } + return merge; +} + +function polygonSettingsToZones(settings) { + const { polygons, durations } = settings; + const reversedPolygons = polygons.slice().reverse(); + const reversedDurations = durations.slice().reverse(); + + const zones = []; + + for (let i = 0; i < reversedPolygons.length; i++) { + const polygon =reversedPolygons[i]; + const duration = reversedDurations[i]; + const length = zones.length; + + if (length == 0) { + zones.push(polygonZone( + polygon, + duration + )); + } else { + zones.push(polygonZone( + mergePolygons(zones[length-1].points, polygon), + duration + )); + } + } + + return zones.slice().reverse(); +} + + +/* -------------------------------- Circle zones -------------------------------- */ + +const defaultCircleSettings = { min: null, max: null, reductionCount: 4, duration: 1 }; + +function circleZone(center, radius, duration) { + return { + center: center, + radius: radius, + duration: duration, + + isInZone(location) { + return haversine_distance(center, location) < this.radius; + } + } +} + +function circleSettingsToZones(settings) { + const {min, max, reductionCount, duration} = settings; + if (haversine_distance(max.center, min.center) > max.radius - min.radius) { + return null; + } + const zones = [circleZone(max.center, max.radius, duration)]; + const radiusReductionLength = (max.radius - min.radius) / reductionCount; + let center = max.center; + let radius = max.radius; + for (let i = 1; i < reductionCount; i++) { + radius -= radiusReductionLength; + let new_center = null; + while (!new_center || haversine_distance(new_center, min.center) > radius - min.radius) { + const angle = Math.random() * 2 * Math.PI; + const angularDistance = Math.sqrt(Math.random()) * radiusReductionLength / EARTH_RADIUS; + const lat0Rad = center.lat * Math.PI / 180; + const lon0Rad = center.lng * Math.PI / 180; + const latRad = Math.asin( + Math.sin(lat0Rad) * Math.cos(angularDistance) + + Math.cos(lat0Rad) * Math.sin(angularDistance) * Math.cos(angle) + ); + + const lonRad = lon0Rad + Math.atan2( + Math.sin(angle) * Math.sin(angularDistance) * Math.cos(lat0Rad), + Math.cos(angularDistance) - Math.sin(lat0Rad) * Math.sin(latRad) + ); + new_center = {lat: latRad * 180 / Math.PI, lng: lonRad * 180 / Math.PI}; + } + center = new_center; + zones.push(circleZone(center, radius, duration)) + } + zones.push(circleZone(min.center, min.radius, 0)); + return zones; +} + + +/* -------------------------------- Zone manager -------------------------------- */ + export default { - //Setings storing where the zone will start, end and how it should evolve - //The zone will start by staying at its max value for reductionInterval minutes - //and then reduce during reductionDuration minutes, then wait again... - //The reduction factor is such that after reductionCount the zone will be the min zone - zoneSettings: { - min: { center: null, radius: null }, - max: { center: null, radius: null }, - reductionCount: 2, - reductionDuration: 1, - reductionInterval: 1, - updateIntervalSeconds: 1, - }, + isRunning: false, + zones: [], // A zone has to be connected space that doesn't contain an earth pole + currentZone: { id: 0, timeoutId: null, endDate: null }, + settings: defaultPolygonSettings, - nextZoneDecrement: null, - //Live location of the zone - currentZone: { center: null, radius: null }, - - //If the zone is shrinking, this is the target of the current shrinking - //If the zone is not shrinking, this will be the target of the next shrinking - nextZone: { center: null, radius: null }, - - //Zone at the begining of the shrinking - currentStartZone: { center: null, radius: null }, - - startDate: null, - started: false, - updateIntervalId: null, - nextZoneTimeoutId: null, - - nextZoneDate: null, - - /** - * Test if a given configuration object is valid, i.e if all needed values are well defined - * @param {Object} settings Settings object describing a config of a zone manager - * @returns if the config is correct - */ - validateSettings(settings) { - if (settings.reductionCount && (typeof settings.reductionCount != "number" || settings.reductionCount <= 0)) { return false } - if (settings.reductionDuration && (typeof settings.reductionDuration != "number" || settings.reductionDuration < 0)) { return false } - if (settings.reductionInterval && (typeof settings.reductionInterval != "number" || settings.reductionInterval < 0)) { return false } - if (settings.updateIntervalSeconds && (typeof settings.updateIntervalSeconds != "number" || settings.updateIntervalSeconds <= 0)) { return false } - if (settings.max && (typeof settings.max.radius != "number" || typeof settings.max.center.lat != "number" || typeof settings.max.center.lng != "number")) { return false } - if (settings.min && (typeof settings.min.radius != "number" || typeof settings.min.center.lat != "number" || typeof settings.min.center.lng != "number")) { return false } - return true; - }, - /** - * Test if the zone manager is ready to start - * @returns true if the zone manager is ready to be started, false otherwise - */ - ready() { - return this.validateSettings(this.zoneSettings); - }, - - /** - * Update the settings of the zone, this can be done by passing an object containing the settings to change. - * Unless specified, the durations are in minutes - * Default config : - * `this.zoneSettings = { - * min: {center: null, radius: null}, - * max: {center: null, radius: null}, - * reductionCount: 2, - * reductionDuration: 10, - * reductionInterval: 10, - * updateIntervalSeconds: 10, - * }` - * @param {Object} newSettings The fields of the settings to udpate - * @returns - */ - udpateSettings(newSettings) { - //validate settings - this.zoneSettings = { ...this.zoneSettings, ...newSettings }; - this.nextZoneDecrement = (this.zoneSettings.max.radius - this.zoneSettings.min.radius) / this.zoneSettings.reductionCount; - return true; - }, - - /** - * Reinitialize the object and stop all the tasks - */ - reset() { - this.currentZoneCount = 0; - this.started = false; - if (this.updateIntervalId != null) { - clearInterval(this.updateIntervalId); - this.updateIntervalId = null; - } - if (this.nextZoneTimeoutId != null) { - clearTimeout(this.nextZoneTimeoutId); - this.nextZoneTimeoutId = null; - } - }, - - /** - * Start the zone reduction sequence - */ start() { - if (!this.ready()) return false; - this.reset(); - this.started = true; - this.startDate = new Date(); - //initialize the zone to its max value - this.nextZone = JSON.parse(JSON.stringify(this.zoneSettings.max)); - this.currentStartZone = JSON.parse(JSON.stringify(this.zoneSettings.max)); - this.currentZone = JSON.parse(JSON.stringify(this.zoneSettings.max)); - return this.setNextZone(); - + this.isRunning = true; + this.currentZone.id = -1; + this.goNextZone(); }, - /** - * Get the center of the next zone, this center need to satisfy two properties - * - it needs to be included in the current zone, this means that this new point should lie in the circle of center currentZone.center and radius currentZone.radius - newRadius - * - it needs to include the last zone, which means that the center must lie in the center of center min.center and of radius newRadius - min.radius - * @param newRadius the radius that the new zone will have - * @returns the coordinates of the new center as an object with lat and long fields - */ - getRandomNextCenter(newRadius) { - let ok = false; - let res = null - let tries = 0; - const MAX_TRIES = 100000 - //take a random point satisfying both conditions - while (tries++ < MAX_TRIES && !ok) { - res = randomCirclePoint({ latitude: this.currentZone.center.lat, longitude: this.currentZone.center.lng }, this.currentZone.radius - newRadius); - ok = (isInCircle({ lat: res.latitude, lng: res.longitude }, this.zoneSettings.min.center, newRadius - this.zoneSettings.min.radius)) - } - if (tries >= MAX_TRIES) { - return false; - } - return { - lat: res.latitude, - lng: res.longitude + stop() { + this.isRunning = false; + clearTimeout(this.currentZone.timeoutId); + }, + + goNextZone() { + this.currentZone.id++; + if (this.currentZone.id >= this.zones.length) return; + this.currentZone.timeoutId = setTimeout(() => this.goNextZone(), this.getCurrentZone().duration * 60 * 1000); + this.currentZone.endDate = Date.now() + this.getCurrentZone().duration * 60 * 1000; + this.zoneBroadcast(); + }, + + getCurrentZone() { + return this.zones[this.currentZone.id]; + }, + + getNextZone() { + if (this.currentZone.id + 1 < this.zones.length) { + return this.zones[this.currentZone.id + 1]; + } else { + return this.zones[this.currentZone.id]; } }, - /** - * Compute the next zone satifying the given settings, update the nextZone and currentStartZone - * Wait for the appropriate duration before starting a new zone reduction if needed - */ - setNextZone() { - this.nextZoneDate = Date.now() + this.zoneSettings.reductionInterval * 60 * 1000; - //At this point, nextZone == currentZone, we need to update the next zone, the raidus decrement, and start a timer before the next shrink - //last zone - if (this.currentZoneCount == this.zoneSettings.reductionCount) { - this.nextZone = JSON.parse(JSON.stringify(this.zoneSettings.min)) - this.currentStartZone = JSON.parse(JSON.stringify(this.zoneSettings.min)) - } else if (this.currentZoneCount == this.zoneSettings.reductionCount - 1) { - this.currentStartZone = JSON.parse(JSON.stringify(this.currentZone)) - this.nextZone = JSON.parse(JSON.stringify(this.zoneSettings.min)) - this.nextZoneTimeoutId = setTimeout(() => this.startShrinking(), 1000 * 60 * this.zoneSettings.reductionInterval) - this.currentZoneCount++; - } else if (this.currentZoneCount < this.zoneSettings.reductionCount) { - this.nextZone.center = this.getRandomNextCenter(this.nextZone.radius - this.nextZoneDecrement) - //Next center cannot be found - if (this.nextZone.center === false) { - console.log("no center") - return false; - } - this.nextZone.radius -= this.nextZoneDecrement; - this.currentStartZone = JSON.parse(JSON.stringify(this.currentZone)) - this.nextZoneTimeoutId = setTimeout(() => this.startShrinking(), 1000 * 60 * this.zoneSettings.reductionInterval) - this.currentZoneCount++; + isInZone(location) { + if (this.zones.length == 0) { + return true; + } else { + return this.getCurrentZone().isInZone(location); } - this.onZoneUpdate(JSON.parse(JSON.stringify(this.currentStartZone))) - this.onNextZoneUpdate({ - begin: JSON.parse(JSON.stringify(this.currentStartZone)), - end: JSON.parse(JSON.stringify(this.nextZone)), - endDate: JSON.parse(JSON.stringify(this.nextZoneDate)), - }) + }, + + changeSettings(settings) { + const zones = polygonSettingsToZones(settings); + if (!zones) return false; + this.zones = zones; + this.settings = settings; + this.zoneBroadcast(); return true; }, - - /* - * Start a task that will run periodically updatinng the zone size, and calling the onZoneUpdate callback - * This will also periodically check if the reduction is over or not - * If the reduction is over this function will call setNextZone - */ - startShrinking() { - this.nextZoneDate = Date.now() + this.zoneSettings.reductionDuration * 60 * 1000; - this.onZoneUpdateStart(JSON.parse(JSON.stringify(this.nextZoneDate))); - const startTime = new Date(); - this.updateIntervalId = setInterval(() => { - const completed = ((new Date() - startTime) / (1000 * 60)) / this.zoneSettings.reductionDuration; - this.currentZone.radius = map(completed, 0, 1, this.currentStartZone.radius, this.nextZone.radius) - this.currentZone.center.lat = map(completed, 0, 1, this.currentStartZone.center.lat, this.nextZone.center.lat) - this.currentZone.center.lng = map(completed, 0, 1, this.currentStartZone.center.lng, this.nextZone.center.lng) - this.onZoneUpdate(JSON.parse(JSON.stringify(this.currentZone))) - //Zone shrinking is over - if (completed >= 1) { - clearInterval(this.updateIntervalId); - this.updateIntervalId = null; - this.setNextZone(); - return; - } - }, this.zoneSettings.updateIntervalSeconds * 1000); + + zoneBroadcast() { + const zone = { + begin: this.getCurrentZone(), + end: this.getNextZone(), + endDate:this.currentZone.endDate, + }; + playersBroadcast("current_zone", zone); + secureAdminBroadcast("current_zone", zone); }, - - //a call to onNextZoneUpdate will be made when the zone reduction ends and a new next zone is announced - onNextZoneUpdate(newZone) { - playersBroadcast("new_zone", newZone) - secureAdminBroadcast("new_zone", newZone) - }, - - //a call to onZoneUpdateStart will be made when the zone reduction starts - onZoneUpdateStart(date) { - playersBroadcast("zone_start", date) - secureAdminBroadcast("zone_start", date) - }, - - //a call to onZoneUpdate will be made every updateIntervalSeconds when the zone is changing - onZoneUpdate(zone) { - playersBroadcast("zone", zone) - secureAdminBroadcast("zone", zone) - }, - -} \ No newline at end of file +} diff --git a/traque-front/app/admin/layout.js b/traque-front/app/admin/layout.js index b2097d4..b39fe34 100644 --- a/traque-front/app/admin/layout.js +++ b/traque-front/app/admin/layout.js @@ -1,8 +1,8 @@ -import { AdminConnexionProvider} from "@/context/adminConnexionContext"; +import { AdminConnexionProvider } from "@/context/adminConnexionContext"; import { AdminProvider } from "@/context/adminContext"; import Link from "next/link"; -export default function AdminLayout({ children}) { +export default function AdminLayout({ children }) { return ( diff --git a/traque-front/app/admin/page.js b/traque-front/app/admin/page.js index 5cab131..9e62154 100644 --- a/traque-front/app/admin/page.js +++ b/traque-front/app/admin/page.js @@ -1,15 +1,14 @@ "use client"; -import { TeamReady } from "@/components/admin/teamReady"; -import BlueButton, { GreenButton, RedButton } from "@/components/util/button"; +import TeamReady from "@/components/admin/teamReady"; +import { BlueButton, GreenButton, RedButton } from "@/components/util/button"; import { useAdminConnexion } from "@/context/adminConnexionContext"; import useAdmin from "@/hook/useAdmin"; import { GameState } from "@/util/gameState"; import dynamic from "next/dynamic"; -import { TeamListFixed } from '@/components/admin/teamList'; -const LiveMap = dynamic(() => import('@/components/admin/mapPicker').then((mod) => mod.LiveMap), { - ssr: false -}); +// Imported at runtime and not at compile time +const LiveMap = dynamic(() => import('@/components/admin/liveMap'), { ssr: false }); + export default function AdminPage() { const { useProtect } = useAdminConnexion(); const { gameState, changeState } = useAdmin(); diff --git a/traque-front/app/admin/parameters/page.js b/traque-front/app/admin/parameters/page.js index 3d5f318..6abfdbb 100644 --- a/traque-front/app/admin/parameters/page.js +++ b/traque-front/app/admin/parameters/page.js @@ -1,12 +1,12 @@ "use client"; -import { GameSettings } from "@/components/admin/gameSettings"; -import { PenaltySettings } from "@/components/admin/penaltySettings"; +import GameSettings from "@/components/admin/gameSettings"; +import PenaltySettings from "@/components/admin/penaltySettings"; import { useAdminConnexion } from "@/context/adminConnexionContext"; import dynamic from "next/dynamic"; -const ZoneSelector = dynamic(() => import('@/components/admin/zoneSelector').then((mod) => mod.ZoneSelector), { - ssr: false -}); +// Imported at runtime and not at compile time +const ZoneSelector = dynamic(() => import('@/components/admin/polygonZoneMap'), { ssr: false }); + export default function AdminPage() { const { useProtect } = useAdminConnexion(); useProtect(); diff --git a/traque-front/app/team/track/page.js b/traque-front/app/team/track/page.js index 038389e..a54889b 100644 --- a/traque-front/app/team/track/page.js +++ b/traque-front/app/team/track/page.js @@ -1,8 +1,8 @@ "use client"; import ActionDrawer from '@/components/team/actionDrawer'; -import { Notification } from '@/components/team/notification'; +import Notification from '@/components/team/notification'; import PlacementOverlay from '@/components/team/placementOverlay'; -import { WaitingScreen } from '@/components/team/waitingScreen'; +import WaitingScreen from '@/components/team/waitingScreen'; import { LogoutButton } from '@/components/util/button'; import { useSocket } from '@/context/socketContext'; import { useTeamConnexion } from '@/context/teamConnexionContext'; diff --git a/traque-front/components/admin/circleZoneMap.jsx b/traque-front/components/admin/circleZoneMap.jsx new file mode 100644 index 0000000..c823fad --- /dev/null +++ b/traque-front/components/admin/circleZoneMap.jsx @@ -0,0 +1,110 @@ +import { useEffect, useState } from "react"; +import { BlueButton, GreenButton, RedButton } from "../util/button"; +import { TextInput } from "../util/textInput"; +import useAdmin from "@/hook/useAdmin"; +import useLocation from "@/hook/useLocation"; +import "leaflet/dist/leaflet.css"; +import { Circle, MapContainer, TileLayer } from "react-leaflet"; +import useMapCircleDraw from "@/hook/useMapCircleDraw"; +import { MapPan, MapEventListener } from "./mapUtils"; + +const DEFAULT_ZOOM = 14; +const EditMode = { + MIN: 0, + MAX: 1 +} + +function CircleDrawings({ minZone, setMinZone, maxZone, setMaxZone, editMode }) { + const { handleClick: maxClick, handleMouseMove: maxHover, center: maxCenter, radius: maxRadius } = useMapCircleDraw(minZone, setMinZone); + const { handleClick: minClick, handleMouseMove: minHover, center: minCenter, radius: minRadius } = useMapCircleDraw(maxZone, setMaxZone); + + function handleLeftClick(e) { + if (editMode == EditMode.MAX) { + maxClick(e); + } else { + minClick(e); + } + } + + function handleMouseMove(e) { + if (editMode == EditMode.MAX) { + maxHover(e); + } else { + minHover(e); + } + } + + return ( +
+ {minCenter && minRadius && } + {maxCenter && maxRadius && } + {}} onMouseMove={handleMouseMove} /> +
+ ); +} + +export function CircleZonePicker({ minZone, maxZone, editMode, setMinZone, setMaxZone, ...props }) { + const location = useLocation(Infinity); + + return ( +
+ + + + + +
+ ); +} + +export default function CircleZoneMap() { + const [editMode, setEditMode] = useState(EditMode.MIN); + const [minZone, setMinZone] = useState(null); + const [maxZone, setMaxZone] = useState(null); + const [reductionCount, setReductionCount] = useState(""); + const [duration, setDuration] = useState(""); + const {zoneSettings, changeZoneSettings} = useAdmin(); + + useEffect(() => { + if (zoneSettings) { + setMinZone(zoneSettings.min); + setMaxZone(zoneSettings.max); + setReductionCount(zoneSettings.reductionCount.toString()); + setDuration(zoneSettings.duration.toString()); + } + }, [zoneSettings]); + + function handleSettingsSubmit() { + const newSettings = {min:minZone, max:maxZone, reductionCount: Number(reductionCount), duration: Number(duration)}; + changeZoneSettings(newSettings); + } + + // When the user set one zone, switch to the other + useEffect(() => { + if(editMode == EditMode.MIN) { + setEditMode(EditMode.MAX); + }else { + setEditMode(EditMode.MIN); + } + + }, [minZone, maxZone]); + + return
+

Edit zones

+ {editMode == EditMode.MIN && setEditMode(EditMode.MAX)}>Click to edit first zone} + {editMode == EditMode.MAX && setEditMode(EditMode.MIN)}>Click to edit last zone} + +
+

Number of zones

+ setReductionCount(e.target.value)}> +
+
+

Duration of a zone

+ setDuration(e.target.value)}> +
+ Apply +
+} diff --git a/traque-front/components/admin/gameSettings.jsx b/traque-front/components/admin/gameSettings.jsx index 87887ff..8c02dc7 100644 --- a/traque-front/components/admin/gameSettings.jsx +++ b/traque-front/components/admin/gameSettings.jsx @@ -3,7 +3,7 @@ import { TextArea } from "../util/textInput"; import { GreenButton } from "../util/button"; import { useEffect, useState } from "react"; -export const GameSettings = () => { +export default function GameSettings() { const {gameSettings, changeGameSettings} = useAdmin(); const [capturedMessage, setCapturedMessage] = useState(""); const [winnerEndMessage, setWinnerEndMessage] = useState(""); @@ -11,7 +11,6 @@ export const GameSettings = () => { const [waitingMessage, setWaitingMessage] = useState(""); useEffect(() => { - console.log({gameSettings}) if (gameSettings) { setCapturedMessage(gameSettings.capturedMessage); setWinnerEndMessage(gameSettings.winnerEndGameMessage); @@ -46,4 +45,4 @@ export const GameSettings = () => { Apply ) -} \ No newline at end of file +} diff --git a/traque-front/components/admin/liveMap.jsx b/traque-front/components/admin/liveMap.jsx new file mode 100644 index 0000000..1a96f1e --- /dev/null +++ b/traque-front/components/admin/liveMap.jsx @@ -0,0 +1,75 @@ +import useLocation from "@/hook/useLocation"; +import { useEffect, useState } from "react"; +import "leaflet/dist/leaflet.css"; +import { MapContainer, Marker, TileLayer, Tooltip, Polyline, Polygon } from "react-leaflet"; +import useAdmin from "@/hook/useAdmin"; +import { GameState } from "@/util/gameState"; +import { MapPan } from "./mapUtils"; + +const DEFAULT_ZOOM = 14; +const positionIcon = new L.Icon({ + iconUrl: '/icons/location.png', + iconSize: [30, 30], + iconAnchor: [15, 15], + popupAnchor: [0, -15], + shadowSize: [30, 30], +}); + +export default function LiveMap() { + const location = useLocation(Infinity); + const [timeLeftNextZone, setTimeLeftNextZone] = useState(null); + const { zoneExtremities, teams, nextZoneDate, getTeam, gameState } = useAdmin(); + + // Remaining time before sending position + useEffect(() => { + if (nextZoneDate) { + const updateTime = () => { + setTimeLeftNextZone(Math.max(0, Math.floor((nextZoneDate - Date.now()) / 1000))); + }; + + updateTime(); + const interval = setInterval(updateTime, 1000); + + return () => clearInterval(interval); + } + }, [nextZoneDate]); + + function formatTime(time) { + // time is in seconds + if (time < 0) return "00:00"; + const minutes = Math.floor(time / 60); + const seconds = Math.floor(time % 60); + return String(minutes).padStart(2,"0") + ":" + String(seconds).padStart(2,"0"); + } + + function Arrow({pos1, pos2}) { + if (pos1 && pos2) { + return ( + + ) + } else { + return null; + } + } + + return ( +
+ {gameState == GameState.PLAYING && timeLeftNextZone &&

{`Next zone in : ${formatTime(timeLeftNextZone)}`}

} + + + + {gameState == GameState.PLAYING && zoneExtremities.begin && } + {gameState == GameState.PLAYING && zoneExtremities.end && } + {teams.map((team) => team.currentLocation && !team.captured && + + {team.name} + + + )} + +
+ ) +} diff --git a/traque-front/components/admin/mapPicker.jsx b/traque-front/components/admin/mapPicker.jsx deleted file mode 100644 index 31ce085..0000000 --- a/traque-front/components/admin/mapPicker.jsx +++ /dev/null @@ -1,166 +0,0 @@ -"use client"; -import { useLocation } from "@/hook/useLocation"; -import { useEffect, useState } from "react"; -import "leaflet/dist/leaflet.css"; -import { Circle, MapContainer, Marker, TileLayer, useMap, Tooltip, Polyline } from "react-leaflet"; -import { useMapCircleDraw } from "@/hook/mapDrawing"; -import useAdmin from "@/hook/useAdmin"; -import { GameState } from "@/util/gameState"; - -const positionIcon = new L.Icon({ - iconUrl: '/icons/location.png', - iconSize: [30, 30], - iconAnchor: [15, 15], - popupAnchor: [0, -15], - shadowSize: [30, 30], -}); - -function MapPan(props) { - const map = useMap(); - const [initialized, setInitialized] = useState(false); - - useEffect(() => { - if (!initialized && props.center) { - map.flyTo(props.center, props.zoom, { animate: false }); - setInitialized(true) - } - }, [props.center]); - return null; -} - -function MapEventListener({ onClick, onMouseMove }) { - const map = useMap(); - useEffect(() => { - map.on('click', onClick); - return () => { - map.off('click', onClick); - } - }, [onClick]); - useEffect(() => { - map.on('mousemove', onMouseMove); - return () => { - map.off('mousemove', onMouseMove); - } - }); - return null; -} - -const DEFAULT_ZOOM = 14; -export function CircularAreaPicker({ area, setArea, markerPosition, ...props }) { - const location = useLocation(Infinity); - const { handleClick, handleMouseMove, center, radius } = useMapCircleDraw(area, setArea); - return ( - - - {center && radius && } - {markerPosition && - } - - - ) -} -export const EditMode = { - MIN: 0, - MAX: 1 -} -export function ZonePicker({ minZone, setMinZone, maxZone, setMaxZone, editMode, ...props }) { - const location = useLocation(Infinity); - const { handleClick: maxClick, handleMouseMove: maxHover, center: maxCenter, radius: maxRadius } = useMapCircleDraw(minZone, setMinZone); - const { handleClick: minClick, handleMouseMove: minHover, center: minCenter, radius: minRadius } = useMapCircleDraw(maxZone, setMaxZone); - function handleClick(e) { - if (editMode == EditMode.MAX) { - maxClick(e); - } else { - minClick(e); - } - } - function handleMouseMove(e) { - if (editMode == EditMode.MAX) { - maxHover(e); - } else { - minHover(e); - } - } - - return ( -
-
- - - {minCenter && minRadius && } - {maxCenter && maxRadius && } - - - -
- { maxCenter && minCenter && typeof maxCenter.distanceTo === 'function' - && maxRadius + maxCenter.distanceTo(minCenter) >= minRadius - &&

La zone de fin doit être incluse dans celle de départ

} -
- - ) -} - -export function LiveMap() { - const location = useLocation(Infinity); - const [timeLeftNextZone, setTimeLeftNextZone] = useState(null); - const { zone, zoneExtremities, teams, nextZoneDate, isShrinking , getTeam, gameState } = useAdmin(); - - // Remaining time before sending position - useEffect(() => { - const updateTime = () => { - setTimeLeftNextZone(Math.max(0, Math.floor((nextZoneDate - Date.now()) / 1000))); - }; - - updateTime(); - const interval = setInterval(updateTime, 1000); - - return () => clearInterval(interval); - }, [nextZoneDate]); - - function formatTime(time) { - // time is in seconds - if (time < 0) return "00:00"; - const minutes = Math.floor(time / 60); - const seconds = Math.floor(time % 60); - return String(minutes).padStart(2,"0") + ":" + String(seconds).padStart(2,"0"); - } - - function Arrow({pos1, pos2}) { - if (pos1 && pos2) { - return ( - - ) - } else { - return null; - } - } - - return ( -
- {gameState == GameState.PLAYING &&

{`${isShrinking ? "Fin" : "Début"} du rétrécissement de la zone dans : ${formatTime(timeLeftNextZone)}`}

} - - - - {gameState == GameState.PLAYING && zone && } - {gameState == GameState.PLAYING && zoneExtremities && } - {gameState == GameState.PLAYING && zoneExtremities && } - {teams.map((team) => team.currentLocation && !team.captured && - - {team.name} - - - )} - -
- ) -} \ No newline at end of file diff --git a/traque-front/components/admin/mapUtils.jsx b/traque-front/components/admin/mapUtils.jsx new file mode 100644 index 0000000..c1f001a --- /dev/null +++ b/traque-front/components/admin/mapUtils.jsx @@ -0,0 +1,88 @@ +import { useEffect, useState } from "react"; +import "leaflet/dist/leaflet.css"; +import { useMap } from "react-leaflet"; + +export function MapPan(props) { + const map = useMap(); + const [initialized, setInitialized] = useState(false); + + useEffect(() => { + if (!initialized && props.center) { + map.flyTo(props.center, props.zoom, { animate: false }); + setInitialized(true) + } + }, [props.center]); + + return null; +} + +export function MapEventListener({ onLeftClick, onRightClick, onMouseMove }) { + const map = useMap(); + + // Handle the mouse click left + useEffect(() => { + let moved = false; + let downButton = null; + + const handleMouseDown = (e) => { + moved = false; + downButton = e.originalEvent.button; + }; + + const handleMouseMove = () => { + moved = true; + }; + + const handleMouseUp = (e) => { + if (!moved) { + if (downButton == 0) { + onLeftClick(e); + } + } + downButton = null; + }; + + map.on('mousedown', handleMouseDown); + map.on('mousemove', handleMouseMove); + map.on('mouseup', handleMouseUp); + + return () => { + map.off('mousedown', handleMouseDown); + map.off('mousemove', handleMouseMove); + map.off('mouseup', handleMouseUp); + }; + }, [onLeftClick, onRightClick]); + + // Handle the right click + useEffect(() => { + + const handleMouseDown = (e) => { + if (e.originalEvent.button == 2) { + onRightClick(e); + } + }; + + map.on('mousedown', handleMouseDown); + + return () => { + map.off('mousedown', handleMouseDown); + } + }, [onRightClick]); + + // Handle the mouse move + useEffect(() => { + map.on('mousemove', onMouseMove); + + return () => { + map.off('mousemove', onMouseMove); + } + }, [onMouseMove]); + + // Prevent right click context menu + useEffect(() => { + const container = map.getContainer(); + const preventContextMenu = (e) => e.preventDefault(); + container.addEventListener('contextmenu', preventContextMenu); + return () => container.removeEventListener('contextmenu', preventContextMenu); + }, []); +} diff --git a/traque-front/components/admin/penaltySettings.jsx b/traque-front/components/admin/penaltySettings.jsx index b7a8d59..e4bb135 100644 --- a/traque-front/components/admin/penaltySettings.jsx +++ b/traque-front/components/admin/penaltySettings.jsx @@ -1,9 +1,9 @@ import useAdmin from "@/hook/useAdmin"; -import TextInput from "../util/textInput"; +import { TextInput } from "../util/textInput"; import { GreenButton } from "../util/button"; import { useEffect, useState } from "react"; -export const PenaltySettings = () => { +export default function PenaltySettings() { const {penaltySettings, changePenaltySettings} = useAdmin(); const [maxPenalties, setMaxPenalties] = useState(""); const [allowedTimeOutOfZone, setAllowedTimeOutOfZone] = useState(""); @@ -46,4 +46,4 @@ export const PenaltySettings = () => { Apply ) -} \ No newline at end of file +} diff --git a/traque-front/components/admin/placementMap.jsx b/traque-front/components/admin/placementMap.jsx new file mode 100644 index 0000000..4d7bc8e --- /dev/null +++ b/traque-front/components/admin/placementMap.jsx @@ -0,0 +1,33 @@ +import useLocation from "@/hook/useLocation"; +import "leaflet/dist/leaflet.css"; +import { Circle, MapContainer, Marker, TileLayer } from "react-leaflet"; +import useMapCircleDraw from "@/hook/useMapCircleDraw"; +import { MapPan, MapEventListener } from "./mapUtils"; + +const DEFAULT_ZOOM = 14; +const positionIcon = new L.Icon({ + iconUrl: '/icons/location.png', + iconSize: [30, 30], + iconAnchor: [15, 15], + popupAnchor: [0, -15], + shadowSize: [30, 30], +}); + +export default function CircularAreaPicker({ area, setArea, markerPosition, ...props }) { + const location = useLocation(Infinity); + const { handleClick, handleMouseMove, center, radius } = useMapCircleDraw(area, setArea); + + return ( + + + {center && radius && } + {markerPosition && + } + + {}} onMouseMove={handleMouseMove} /> + + ); +} diff --git a/traque-front/components/admin/polygonZoneMap.jsx b/traque-front/components/admin/polygonZoneMap.jsx new file mode 100644 index 0000000..0c1242a --- /dev/null +++ b/traque-front/components/admin/polygonZoneMap.jsx @@ -0,0 +1,142 @@ +import { useEffect, useState } from "react"; +import { GreenButton } from "../util/button"; +import { TextInput } from "../util/textInput"; +import useAdmin from "@/hook/useAdmin"; +import useLocation from "@/hook/useLocation"; +import "leaflet/dist/leaflet.css"; +import { MapContainer, TileLayer, Polyline, Polygon, CircleMarker } from "react-leaflet"; +import useMapPolygonDraw from "@/hook/useMapPolygonDraw"; +import { MapPan, MapEventListener } from "./mapUtils"; + +const DEFAULT_ZOOM = 14; + +function PolygonDrawings({ polygons, addPolygon, removePolygon }) { + const { currentPolygon, highlightNodes, handleLeftClick, handleRightClick, handleMouseMove } = useMapPolygonDraw(polygons, addPolygon, removePolygon); + const nodeSize = 5; // px + const lineThickness = 3; // px + + function DrawNode({pos, color}) { + return ( + + ); + } + + function DrawLine({pos1, pos2, color}) { + return ( + + ); + } + + function DrawUnfinishedPolygon({polygon}) { + const length = polygon.length; + if (length > 0) { + return ( +
+ + {polygon.map((_, i) => { + if (i < length-1) { + return ; + } else { + return null; + } + })} +
+ ); + } + } + + function DrawPolygon({polygon}) { + const length = polygon.length; + + if (length > 2) { + return ( + + ); + } + } + + return ( +
+ + {polygons.map((polygon, i) => )} + + {highlightNodes.map((node, i) => )} +
+ ); +} + +function PolygonZonePicker({ polygons, addPolygon, removePolygon, ...props }) { + const location = useLocation(Infinity); + + return ( +
+ + + + + +
+ ); +} + +export default function PolygonZoneMap() { + const defaultDuration = 10; + const [polygons, setPolygons] = useState([]); + const [durations, setDurations] = useState([]); + const {zoneSettings, changeZoneSettings} = useAdmin(); + + useEffect(() => { + if (zoneSettings) { + setPolygons(zoneSettings.polygons); + setDurations(zoneSettings.durations); + } + }, [zoneSettings]); + + function addPolygon(polygon) { + // Polygons + setPolygons([...polygons, polygon]); + // Durations + setDurations([...durations, defaultDuration]); + } + + function removePolygon(i) { + // Polygons + const newPolygons = [...polygons]; + newPolygons.splice(i, 1); + setPolygons(newPolygons); + // Durations + const newDurations = [...durations]; + newDurations.splice(i, 1); + setDurations(newDurations); + } + + function updateDuration(i, duration) { + const newDurations = [...durations]; + newDurations[i] = duration; + setDurations(newDurations); + } + + function handleSettingsSubmit() { + const newSettings = {polygons: polygons, durations: durations}; + changeZoneSettings(newSettings); + } + + return ( +
+

Edit zones

+ +
    + {durations.map((duration, i) => ( +
  • +

    Zone {i+1}

    + updateDuration(i, e.target.value)}/> +
  • + ))} +
+ Apply +
+ ); +} diff --git a/traque-front/components/admin/teamAdd.jsx b/traque-front/components/admin/teamAdd.jsx index 69f65c7..7a98bff 100644 --- a/traque-front/components/admin/teamAdd.jsx +++ b/traque-front/components/admin/teamAdd.jsx @@ -1,6 +1,6 @@ import React from 'react' -import TextInput from '../util/textInput' -import BlueButton from '../util/button' +import { TextInput } from '../util/textInput' +import { BlueButton } from '../util/button' export default function TeamAddForm({onAddTeam}) { const [teamName, setTeamName] = React.useState(''); diff --git a/traque-front/components/admin/teamEdit.jsx b/traque-front/components/admin/teamEdit.jsx index 3d78914..88896ed 100644 --- a/traque-front/components/admin/teamEdit.jsx +++ b/traque-front/components/admin/teamEdit.jsx @@ -1,15 +1,13 @@ import React, { useEffect, useRef, useState } from 'react' -import TextInput from '../util/textInput' -import BlueButton, { RedButton } from '../util/button'; +import { TextInput } from '../util/textInput' +import { BlueButton, RedButton } from '../util/button'; import useAdmin from '@/hook/useAdmin'; import dynamic from 'next/dynamic'; - import { env } from 'next-runtime-env'; import { GameState } from '@/util/gameState'; -const CircularAreaPicker = dynamic(() => import('./mapPicker').then((mod) => mod.CircularAreaPicker), { - ssr: false -}); +// Imported at runtime and not at compile time +const PlacementMap = dynamic(() => import('./placementMap'), { ssr: false }); export default function TeamEdit({ selectedTeamId, setSelectedTeamId }) { const teamImage = useRef(null); @@ -84,7 +82,7 @@ export default function TeamEdit({ selectedTeamId, setSelectedTeamId }) { Remove

Starting zone

- updateTeam(team.id, { startingArea })} markerPosition={team?.currentLocation} /> + updateTeam(team.id, { startingArea })} markerPosition={team?.currentLocation} />

Team details

diff --git a/traque-front/components/admin/teamList.jsx b/traque-front/components/admin/teamList.jsx index 80c7024..5b58f6c 100644 --- a/traque-front/components/admin/teamList.jsx +++ b/traque-front/components/admin/teamList.jsx @@ -1,14 +1,12 @@ -"use client"; import useAdmin from '@/hook/useAdmin'; import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd'; import React from 'react' -const reorder = (list, startIndex, endIndex) => { - const result = Array.from(list); - const [removed] = result.splice(startIndex, 1); - result.splice(endIndex, 0, removed); - - return result; +function reorder(list, startIndex, endIndex) { + const result = Array.from(list); + const [removed] = result.splice(startIndex, 1); + result.splice(endIndex, 0, removed); + return result; }; function TeamListItem({ team, index, onSelected, itemSelected }) { @@ -24,11 +22,12 @@ function TeamListItem({ team, index, onSelected, itemSelected }) { )} - ) + ); } export default function TeamList({selectedTeamId, onSelected}) { const {teams, reorderTeams} = useAdmin(); + function onDragEnd(result) { if (!result.destination) { return; @@ -46,6 +45,7 @@ export default function TeamList({selectedTeamId, onSelected}) { reorderTeams(newTeams); } + return ( @@ -61,5 +61,5 @@ export default function TeamList({selectedTeamId, onSelected}) { )} - ) + ); } diff --git a/traque-front/components/admin/teamReady.jsx b/traque-front/components/admin/teamReady.jsx index ea5cd60..e735893 100644 --- a/traque-front/components/admin/teamReady.jsx +++ b/traque-front/components/admin/teamReady.jsx @@ -1,16 +1,18 @@ import useAdmin from "@/hook/useAdmin" -export function TeamReady() { +export default function TeamReady() { const {teams} = useAdmin(); - return
-

Teams ready status

- {teams.map((team) => team.ready ? ( -
-
{team.name} : Ready
-
) : ( -
-
{team.name} : Not ready
-
+ return ( +
+

Teams ready status

+ {teams.map((team) => team.ready ? ( +
+
{team.name} : Ready
+
) : ( +
+
{team.name} : Not ready
+
))} -
-} \ No newline at end of file +
+ ); +} diff --git a/traque-front/components/admin/zoneSelector.jsx b/traque-front/components/admin/zoneSelector.jsx deleted file mode 100644 index 9c770e7..0000000 --- a/traque-front/components/admin/zoneSelector.jsx +++ /dev/null @@ -1,66 +0,0 @@ -import { useEffect, useState } from "react"; -import BlueButton, { GreenButton, RedButton } from "../util/button"; -import { EditMode, ZonePicker } from "./mapPicker"; -import TextInput from "../util/textInput"; -import useAdmin from "@/hook/useAdmin"; - -export function ZoneSelector() { - const [editMode, setEditMode] = useState(EditMode.MIN); - const [minZone, setMinZone] = useState(null); - const [maxZone, setMaxZone] = useState(null); - const [reductionCount, setReductionCount] = useState(""); - const [reductionDuration, setReductionDuration] = useState(""); - const [reductionInterval, setReductionInterval] = useState(""); - const {zoneSettings, changeZoneSettings} = useAdmin(); - - useEffect(() => { - if (zoneSettings) { - setMinZone(zoneSettings.min); - setMaxZone(zoneSettings.max); - setReductionCount(zoneSettings.reductionCount.toString()); - setReductionDuration(zoneSettings.reductionDuration.toString()); - setReductionInterval(zoneSettings.reductionInterval.toString()); - } - }, [zoneSettings]); - - function handleSettingsSubmit() { - const newSettings = {min:minZone, max:maxZone, reductionCount: Number(reductionCount), reductionDuration: Number(reductionDuration), reductionInterval: Number(reductionInterval)}; - const changingSettings = {}; - for (const key in newSettings) { - if (newSettings[key] != zoneSettings[key]) { - changingSettings[key] = newSettings[key]; - } - } - changeZoneSettings(changingSettings); - } - - //When the user set one zone, switch to the other - useEffect(() => { - if(editMode == EditMode.MIN) { - setEditMode(EditMode.MAX); - }else { - setEditMode(EditMode.MIN); - } - - }, [minZone, maxZone]); - - return
-

Edit zones

- {editMode == EditMode.MIN && setEditMode(EditMode.MAX)}>Click to edit first zone} - {editMode == EditMode.MAX && setEditMode(EditMode.MIN)}>Click to edit last zone} - -
-

Number of reductions

- setReductionCount(e.target.value)}> -
-
-

Duration of each reduction

- setReductionDuration(e.target.value)}> -
-
-

Interval between reductions

- setReductionInterval(e.target.value)}> -
- Apply -
-} \ No newline at end of file diff --git a/traque-front/components/team/actionDrawer.jsx b/traque-front/components/team/actionDrawer.jsx index 0828c00..3a17036 100644 --- a/traque-front/components/team/actionDrawer.jsx +++ b/traque-front/components/team/actionDrawer.jsx @@ -1,10 +1,9 @@ import useGame from "@/hook/useGame"; import { useEffect, useState } from "react" -import BlueButton, { GreenButton, RedButton } from "../util/button"; -import TextInput from "../util/textInput"; -import { useTeamConnexion } from "@/context/teamConnexionContext"; -import { EnemyTeamModal } from "./enemyTeamModal"; -import Image from "next/image"; +import { BlueButton, GreenButton } from "../util/button"; +import { TextInput } from "../util/textInput"; +import useTeamConnexion from "@/context/teamConnexionContext"; +import EnemyTeamModal from "./enemyTeamModal"; export default function ActionDrawer() { const [visible, setVisible] = useState(false); @@ -73,4 +72,4 @@ export default function ActionDrawer() { setEnemyModalVisible(false)} />
) -} \ No newline at end of file +} diff --git a/traque-front/components/team/enemyTeamModal.jsx b/traque-front/components/team/enemyTeamModal.jsx index fa8e964..7c4c576 100644 --- a/traque-front/components/team/enemyTeamModal.jsx +++ b/traque-front/components/team/enemyTeamModal.jsx @@ -1,11 +1,9 @@ import useGame from "@/hook/useGame"; import { RedButton } from "../util/button"; import { useEffect, useRef } from "react"; -import Image from "next/image"; - import { env } from 'next-runtime-env'; -export function EnemyTeamModal({ visible, onClose }) { +export default function EnemyTeamModal({ visible, onClose }) { const { teamId, enemyName } = useGame(); const imageRef = useRef(null); @@ -38,4 +36,4 @@ export function EnemyTeamModal({ visible, onClose }) { ) -} \ No newline at end of file +} diff --git a/traque-front/components/team/loginForm.jsx b/traque-front/components/team/loginForm.jsx index b5e76c9..4556bdc 100644 --- a/traque-front/components/team/loginForm.jsx +++ b/traque-front/components/team/loginForm.jsx @@ -1,7 +1,6 @@ -"use client"; import { useState } from "react"; -import BlueButton from "../util/button"; -import TextInput from "../util/textInput"; +import { BlueButton } from "../util/button"; +import { TextInput } from "../util/textInput"; export default function LoginForm({ onSubmit, title, placeholder, buttonText}) { const [value, setValue] = useState(""); @@ -17,4 +16,4 @@ export default function LoginForm({ onSubmit, title, placeholder, buttonText}) { {buttonText} ) -} \ No newline at end of file +} diff --git a/traque-front/components/team/map.jsx b/traque-front/components/team/map.jsx index a3db9f3..cd613b5 100644 --- a/traque-front/components/team/map.jsx +++ b/traque-front/components/team/map.jsx @@ -1,15 +1,13 @@ -'use client'; import React, { useEffect, useState } from 'react' import { Circle, MapContainer, Marker, Popup, TileLayer, useMap } from 'react-leaflet' import 'leaflet-defaulticon-compatibility/dist/leaflet-defaulticon-compatibility.css' import "leaflet-defaulticon-compatibility"; import "leaflet/dist/leaflet.css"; import useGame from '@/hook/useGame'; -import { useTeamContext } from '@/context/teamContext'; +import useTeamContext from '@/context/teamContext'; const DEFAULT_ZOOM = 14; - // Pan to the center of the map when the position of the user is updated for the first time function MapPan(props) { const map = useMap(); diff --git a/traque-front/components/team/notification.jsx b/traque-front/components/team/notification.jsx index 07d5fd1..c002406 100644 --- a/traque-front/components/team/notification.jsx +++ b/traque-front/components/team/notification.jsx @@ -1,26 +1,29 @@ import { useSocketListener } from "@/hook/useSocketListener"; import { useEffect, useState } from "react"; -export function Notification({ socket }) { +export default function Notification({ socket }) { const [visible, setVisible] = useState(false); const [timeoutId, setTimeoutId] = useState(null); - const [notification, setNotification] = useState(null); + useSocketListener(socket, "error", (notification) => { console.log("error", notification); setNotification({ type: "error", text: notification }); setVisible(true); }); + useSocketListener(socket, "success", (notification) => { console.log("success", notification); setNotification({ type: "success", text: notification }); setVisible(true); }); + useSocketListener(socket, "warning", (notification) => { console.log("warning", notification); setNotification({ type: "warning", text: notification }); setVisible(true); }); + // Hide the notification after 5 seconds useEffect(() => { console.log({ visible }); @@ -34,12 +37,14 @@ export function Notification({ socket }) { } }, [visible]); - let bgColorMap = { + const bgColorMap = { error: "bg-red-500 text-white", success: "bg-green-500", warning: "bg-yellow-500" } + const classNames = 'fixed relative w-11/12 p-5 z-30 mx-auto inset-x-0 flex justify-center rounded-xl transition-all shadow-xl ' + (visible ? "top-5 " : "-translate-y-full "); + return ( Object.keys(bgColorMap).map((key) => notification?.type == key && @@ -47,5 +52,5 @@ export function Notification({ socket }) {

x

{notification?.text}

- )); -} \ No newline at end of file + )); +} diff --git a/traque-front/components/team/placementOverlay.jsx b/traque-front/components/team/placementOverlay.jsx index 63e6f68..d0e53ad 100644 --- a/traque-front/components/team/placementOverlay.jsx +++ b/traque-front/components/team/placementOverlay.jsx @@ -1,6 +1,5 @@ -import { useTeamConnexion } from "@/context/teamConnexionContext"; +import useTeamConnexion from "@/context/teamConnexionContext"; import useGame from "@/hook/useGame" -import Image from "next/image"; export default function PlacementOverlay() { const { name, ready } = useGame(); diff --git a/traque-front/components/team/waitingScreen.jsx b/traque-front/components/team/waitingScreen.jsx index 837fa6b..3062f49 100644 --- a/traque-front/components/team/waitingScreen.jsx +++ b/traque-front/components/team/waitingScreen.jsx @@ -1,12 +1,10 @@ import useGame from "@/hook/useGame" import { GreenButton, LogoutButton } from "../util/button"; import { useRef } from "react"; -import Image from "next/image"; -import { useTeamContext } from "@/context/teamContext"; - +import useTeamContext from "@/context/teamContext"; import { env } from 'next-runtime-env'; -export function WaitingScreen() { +export default function WaitingScreen() { const { name, teamId } = useGame(); const { gameSettings } = useTeamContext(); const imageRef = useRef(null); diff --git a/traque-front/components/util/button.jsx b/traque-front/components/util/button.jsx index 25671ca..c4b8307 100644 --- a/traque-front/components/util/button.jsx +++ b/traque-front/components/util/button.jsx @@ -1,7 +1,6 @@ import { useTeamConnexion } from "@/context/teamConnexionContext"; -import Image from "next/image"; -export default function BlueButton({ children, ...props }) { +export function BlueButton({ children, ...props }) { return () @@ -20,6 +19,6 @@ export function GreenButton({ children, ...props }) { } export function LogoutButton() { - const { logout } = useTeamConnexion(); - return -} \ No newline at end of file + const { logout } = useTeamConnexion(); + return +} diff --git a/traque-front/components/util/textInput.jsx b/traque-front/components/util/textInput.jsx index 01778be..aae9661 100644 --- a/traque-front/components/util/textInput.jsx +++ b/traque-front/components/util/textInput.jsx @@ -1,6 +1,6 @@ import React from 'react' -export default function TextInput({...props}) { +export function TextInput({...props}) { return ( ) diff --git a/traque-front/context/adminConnexionContext.jsx b/traque-front/context/adminConnexionContext.jsx index 52dad56..af629b9 100644 --- a/traque-front/context/adminConnexionContext.jsx +++ b/traque-front/context/adminConnexionContext.jsx @@ -1,11 +1,12 @@ "use client"; -import { createContext, useContext, useMemo, } from "react"; +import { createContext, useContext, useMemo } from "react"; import { useSocket } from "./socketContext"; -import { useSocketAuth } from "@/hook/useSocketAuth"; -import { usePasswordProtect } from "@/hook/usePasswordProtect"; +import useSocketAuth from "@/hook/useSocketAuth"; +import usePasswordProtect from "@/hook/usePasswordProtect"; const adminConnexionContext = createContext(); -const AdminConnexionProvider = ({ children }) => { + +export function AdminConnexionProvider({ children }) { const { adminSocket } = useSocket(); const { login, loggedIn, loading } = useSocketAuth(adminSocket, "admin_password"); const useProtect = () => usePasswordProtect("/admin/login", "/admin", loading, loggedIn); @@ -19,9 +20,6 @@ const AdminConnexionProvider = ({ children }) => { ); } -function useAdminConnexion() { +export function useAdminConnexion() { return useContext(adminConnexionContext); } - -export { AdminConnexionProvider, useAdminConnexion }; - diff --git a/traque-front/context/adminContext.jsx b/traque-front/context/adminContext.jsx index b6b5037..c093b2c 100644 --- a/traque-front/context/adminContext.jsx +++ b/traque-front/context/adminContext.jsx @@ -1,53 +1,43 @@ "use client"; import { createContext, useContext, useEffect, useMemo, useState } from "react"; import { useSocket } from "./socketContext"; -import { useSocketListener } from "@/hook/useSocketListener"; +import useSocketListener from "@/hook/useSocketListener"; import { useAdminConnexion } from "./adminConnexionContext"; import { GameState } from "@/util/gameState"; const adminContext = createContext(); -function AdminProvider({ children }) { +export function AdminProvider({ children }) { const [teams, setTeams] = useState([]); const [zoneSettings, setZoneSettings] = useState(null) const [penaltySettings, setPenaltySettings] = useState(null); const [gameSettings, setGameSettings] = useState(null); - const [zone, setZone] = useState(null); const [zoneExtremities, setZoneExtremities] = useState(null); const [nextZoneDate, setNextZoneDate] = useState(null); - const [isShrinking, setIsShrinking] = useState(false); const { adminSocket } = useSocket(); const { loggedIn } = useAdminConnexion(); const [gameState, setGameState] = useState(GameState.SETUP); const [startDate, setStartDate] = useState(null); useSocketListener(adminSocket, "game_state", (data) => {setGameState(data.state); setStartDate(data.startDate)}); - //Send a request to get the teams when the user logs in + // Send a request to get the teams when the user logs in useEffect(() => { adminSocket.emit("get_teams"); }, [loggedIn]); - function waiting(data) { - setIsShrinking(false); + function setCurrent_zone(data) { setZoneExtremities({begin: data.begin, end: data.end}); setNextZoneDate(data.endDate); } - function shrinking(data) { - setIsShrinking(true); - setNextZoneDate(data); - } - - //Bind listeners to update the team list and the game status on socket message + // Bind listeners to update the team list and the game status on socket message useSocketListener(adminSocket, "teams", setTeams); useSocketListener(adminSocket, "zone_settings", setZoneSettings); useSocketListener(adminSocket, "game_settings", setGameSettings); useSocketListener(adminSocket, "penalty_settings", setPenaltySettings); - useSocketListener(adminSocket, "zone", setZone); - useSocketListener(adminSocket, "zone_start", shrinking); - useSocketListener(adminSocket, "new_zone", waiting); + useSocketListener(adminSocket, "current_zone", setCurrent_zone); - const value = useMemo(() => ({ zone, zoneExtremities, teams, zoneSettings, penaltySettings, gameSettings, gameState, nextZoneDate, isShrinking, startDate }), [zoneSettings, teams, gameState, zone, zoneExtremities, penaltySettings, gameSettings, nextZoneDate, isShrinking, startDate]); + const value = useMemo(() => ({ zoneExtremities, teams, zoneSettings, penaltySettings, gameSettings, gameState, nextZoneDate, startDate }), [zoneSettings, teams, gameState, zoneExtremities, penaltySettings, gameSettings, nextZoneDate, startDate]); return ( {children} @@ -55,8 +45,6 @@ function AdminProvider({ children }) { ); } -function useAdminContext() { +export function useAdminContext() { return useContext(adminContext); } - -export { AdminProvider, useAdminContext }; \ No newline at end of file diff --git a/traque-front/context/socketContext.jsx b/traque-front/context/socketContext.jsx index e972299..7413ed5 100644 --- a/traque-front/context/socketContext.jsx +++ b/traque-front/context/socketContext.jsx @@ -1,22 +1,17 @@ "use client"; import { createContext, useContext, useMemo } from "react"; - import { env } from 'next-runtime-env'; +import { io } from 'socket.io-client'; -const { io } = require("socket.io-client"); - -var proto = "wss://"; const NEXT_PUBLIC_SOCKET_HOST = env("NEXT_PUBLIC_SOCKET_HOST"); -if (NEXT_PUBLIC_SOCKET_HOST == "localhost") { - proto = "ws://"; -} -const SOCKET_URL = proto + NEXT_PUBLIC_SOCKET_HOST; +const SOCKET_URL = (NEXT_PUBLIC_SOCKET_HOST == "localhost" ? "ws://" : "wss://") + NEXT_PUBLIC_SOCKET_HOST; const USER_SOCKET_URL = SOCKET_URL + "/player"; const ADMIN_SOCKET_URL = SOCKET_URL + "/admin"; export const teamSocket = io(USER_SOCKET_URL, { path: "/back/socket.io", }); + export const adminSocket = io(ADMIN_SOCKET_URL, { path: "/back/socket.io", }); diff --git a/traque-front/context/teamConnexionContext.jsx b/traque-front/context/teamConnexionContext.jsx index 4f0ae3e..09be1c6 100644 --- a/traque-front/context/teamConnexionContext.jsx +++ b/traque-front/context/teamConnexionContext.jsx @@ -1,13 +1,14 @@ "use client"; import { createContext, useContext, useMemo } from "react"; import { useSocket } from "./socketContext"; -import { useSocketAuth } from "@/hook/useSocketAuth"; -import { usePasswordProtect } from "@/hook/usePasswordProtect"; +import useSocketAuth from "@/hook/useSocketAuth"; +import usePasswordProtect from "@/hook/usePasswordProtect"; const teamConnexionContext = createContext(); -const TeamConnexionProvider = ({ children }) => { + +export function TeamConnexionProvider({ children }) { const { teamSocket } = useSocket(); - const { login, password: teamId, loggedIn, loading, logout } = useSocketAuth(teamSocket, "team_password"); + const { login, password: teamId, loggedIn, loading, logout } = useSocketAuth(teamSocket, "team_password"); const useProtect = () => usePasswordProtect("/team", "/team/track", loading, loggedIn); const value = useMemo(() => ({ teamId, login, logout, loggedIn, loading, useProtect}), [teamId, login, loggedIn, loading]); @@ -19,9 +20,6 @@ const TeamConnexionProvider = ({ children }) => { ); } -function useTeamConnexion() { +export function useTeamConnexion() { return useContext(teamConnexionContext); } - -export { TeamConnexionProvider, useTeamConnexion }; - diff --git a/traque-front/context/teamContext.jsx b/traque-front/context/teamContext.jsx index 5d66e9f..30af732 100644 --- a/traque-front/context/teamContext.jsx +++ b/traque-front/context/teamContext.jsx @@ -1,14 +1,14 @@ "use client"; -import { useLocation } from "@/hook/useLocation"; -import { useSocketListener } from "@/hook/useSocketListener"; -import { createContext, use, useContext, useEffect, useMemo, useRef, useState } from "react"; +import useLocation from "@/hook/useLocation"; +import useSocketListener from "@/hook/useSocketListener"; +import { createContext, useContext, useEffect, useMemo, useRef, useState } from "react"; import { useSocket } from "./socketContext"; import { useTeamConnexion } from "./teamConnexionContext"; import { GameState } from "@/util/gameState"; +const teamContext = createContext(); -const teamContext = createContext() -function TeamProvider({children}) { +export function TeamProvider({children}) { const [teamInfos, setTeamInfos] = useState({}); const [gameState, setGameState] = useState(GameState.SETUP); const [gameSettings, setGameSettings] = useState(null); @@ -21,17 +21,12 @@ function TeamProvider({children}) { teamInfosRef.current = teamInfos; - useSocketListener(teamSocket, "update_team", (newTeamInfos) => { - setTeamInfos({...teamInfosRef.current, ...newTeamInfos}); - }); - + useSocketListener(teamSocket, "update_team", (newTeamInfos) => setTeamInfos({...teamInfosRef.current, ...newTeamInfos}) ); useSocketListener(teamSocket, "game_state", setGameState); useSocketListener(teamSocket, "zone", setZone); useSocketListener(teamSocket, "new_zone", setZoneExtremities); useSocketListener(teamSocket, "game_settings", setGameSettings); - - //Send the current position to the server when the user is logged in useEffect(() => { console.log("sending position", measuredLocation); @@ -48,8 +43,6 @@ function TeamProvider({children}) { ); } -function useTeamContext() { +export function useTeamContext() { return useContext(teamContext); } - -export { TeamProvider, useTeamContext }; \ No newline at end of file diff --git a/traque-front/hook/useAdmin.jsx b/traque-front/hook/useAdmin.jsx index 2a692d9..b141efe 100644 --- a/traque-front/hook/useAdmin.jsx +++ b/traque-front/hook/useAdmin.jsx @@ -1,3 +1,4 @@ +"use client"; import { useAdminContext } from "@/context/adminContext"; import { useSocket } from "@/context/socketContext"; @@ -52,4 +53,4 @@ export default function useAdmin() { } return { ...adminContext, changeGameSettings, changeZoneSettings, changePenaltySettings, pollTeams, getTeam, getTeamName, reorderTeams, addTeam, removeTeam, changeState, updateTeam }; -} \ No newline at end of file +} diff --git a/traque-front/hook/useGame.jsx b/traque-front/hook/useGame.jsx index a77b8f9..cdf7aa8 100644 --- a/traque-front/hook/useGame.jsx +++ b/traque-front/hook/useGame.jsx @@ -1,5 +1,4 @@ "use client"; - import { useSocket } from "@/context/socketContext"; import { useTeamConnexion } from "@/context/teamConnexionContext"; import { useTeamContext } from "@/context/teamContext"; @@ -33,4 +32,4 @@ export default function useGame() { teamId, gameState, }; -} \ No newline at end of file +} diff --git a/traque-front/hook/useLocalStorage.jsx b/traque-front/hook/useLocalStorage.jsx index 14a63ce..5376f2b 100644 --- a/traque-front/hook/useLocalStorage.jsx +++ b/traque-front/hook/useLocalStorage.jsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useState } from "react"; -export function useLocalStorage(key, initialValue) { +export default function useLocalStorage(key, initialValue) { const [storedValue, setStoredValue] = useState(initialValue); const [loading, setLoading] = useState(true); diff --git a/traque-front/hook/useLocation.jsx b/traque-front/hook/useLocation.jsx index 37d1272..bb25c3c 100644 --- a/traque-front/hook/useLocation.jsx +++ b/traque-front/hook/useLocation.jsx @@ -5,11 +5,10 @@ import { useEffect, useState } from "react"; * A hook that returns the location of the user and updates it periodically * @returns {Object} The location of the user */ -export function useLocation(interval) { +export default function useLocation(interval) { const [location, setLocation] = useState(); useEffect(() => { function update() { - console.log('Updating location'); navigator.geolocation.getCurrentPosition((position) => { setLocation([position.coords.latitude, position.coords.longitude]); if(interval != Infinity) { @@ -21,4 +20,4 @@ export function useLocation(interval) { }, []); return location; -} \ No newline at end of file +} diff --git a/traque-front/hook/mapDrawing.jsx b/traque-front/hook/useMapCircleDraw.jsx similarity index 78% rename from traque-front/hook/mapDrawing.jsx rename to traque-front/hook/useMapCircleDraw.jsx index 804a99a..fbcd9cd 100644 --- a/traque-front/hook/mapDrawing.jsx +++ b/traque-front/hook/useMapCircleDraw.jsx @@ -1,6 +1,7 @@ +"use client"; import { useEffect, useState } from "react"; -export function useMapCircleDraw(area, setArea) { +export default function useMapCircleDraw(area, setArea) { const [drawing, setDrawing] = useState(false); const [center, setCenter] = useState(area?.center || null); const [radius, setRadius] = useState(area?.radius || null); @@ -12,7 +13,7 @@ export function useMapCircleDraw(area, setArea) { }, [area]) function handleClick(e) { - if(!drawing) { + if (!drawing) { setCenter(e.latlng); setRadius(null); setDrawing(true); @@ -23,14 +24,10 @@ export function useMapCircleDraw(area, setArea) { } function handleMouseMove(e) { - if(drawing) { + if (drawing) { setRadius(e.latlng.distanceTo(center)); } } - return { - handleClick, - handleMouseMove, - center, - radius, - } -} \ No newline at end of file + + return { handleClick, handleMouseMove, center, radius }; +} diff --git a/traque-front/hook/useMapPolygonDraw.jsx b/traque-front/hook/useMapPolygonDraw.jsx new file mode 100644 index 0000000..1b78077 --- /dev/null +++ b/traque-front/hook/useMapPolygonDraw.jsx @@ -0,0 +1,214 @@ +"use client"; +import { useState } from "react"; +import { useMap } from "react-leaflet"; + +export default function useMapPolygonDraw(polygons, addPolygon, removePolygon) { + const map = useMap(); + const nodeCatchDistance = 30; // px + const nodeHighlightDistance = 30; // px + const [currentPolygon, setCurrentPolygon] = useState([]); + const [highlightNodes, setHighlightNodes] = useState([]); + + function latlngEqual(latlng1, latlng2, epsilon = 1e-9) { + return Math.abs(latlng1.lat - latlng2.lat) < epsilon && Math.abs(latlng1.lng - latlng2.lng) < epsilon; + } + + function layerDistance(latlng1, latlng2) { + // Return the pixel distance between latlng1 and latlng2 as they appear on the map + const {x: x1, y: y1} = map.latLngToLayerPoint(latlng1); + const {x: x2, y: y2} = map.latLngToLayerPoint(latlng2); + return Math.sqrt((x1 - x2)**2 + (y1 - y2)**2); + } + + function isDrawing() { + return currentPolygon.length > 0; + } + + function areSegmentsIntersecting(p1, p2, p3, p4) { + // Return true if the segments (p1, p2) and (p3, p4) are strictly intersecting, else false + const direction = (a, b, c) => { + return (c.lng - a.lng) * (b.lat - a.lat) - (b.lng - a.lng) * (c.lat - a.lat); + }; + + const d1 = direction(p3, p4, p1); + const d2 = direction(p3, p4, p2); + const d3 = direction(p1, p2, p3); + const d4 = direction(p1, p2, p4); + + return ((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0)); + } + + function isIntersecting(segment, pointArray, isPolygon) { + // Return true if segment intersects one of the pointArray segments according to areSegmentsIntersecting + // Moreover if isPolygon, then it verifies if segment intersects the segment closing pointArray + const length = pointArray.length; + + for (let i = 0; i < length-1; i++) { + if (areSegmentsIntersecting(segment[0], segment[1], pointArray[i], pointArray[i+1])) { + return true; + } + } + + if (isPolygon && length > 2) { + return areSegmentsIntersecting(segment[0], segment[1], pointArray[length-1], pointArray[0]); + } else { + return false; + } + } + + function isInPolygon(latlng, polygon) { + // Return true if latlng is strictly inside polygon + // Return false if latlng is outside polygon or on a vertex of the polygon + // Return true or false if latlng is on the border + if (latlngEqual(latlng, polygon[0])) return false; + + const length = polygon.length; + const {lat: x, lng: y} = latlng; + let inside = false; + + for (let i = 0, j = length - 1; i < length; j = i++) { + if (latlngEqual(latlng, polygon[j])) return false; + + const {lat: xi, lng: yi} = polygon[i]; + const {lat: xj, lng: yj} = polygon[j]; + const intersects = ((yi > y) !== (yj > y)) && (x < ((xj - xi) * (y - yi)) / (yj - yi) + xi); + + if (intersects) inside = !inside; + } + + return inside; + } + + function isClockwise(points) { + // Return true if the tab describes a clockwise polygon (Shoelace formula) + let sum = 0; + for (let i = 0; i < points.length; i++) { + const curr = points[i]; + const next = points[(i + 1) % points.length]; + sum += (next.lng - curr.lng) * (next.lat + curr.lat); + } + return sum > 0; + }; + + function getZoneIndex(latlng) { + // Return the index of the polygon where latlng is according to isInPolygon + for (let iPolygon = 0; iPolygon < polygons.length; iPolygon++) { + if (isInPolygon(latlng, polygons[iPolygon])) { + return iPolygon; + } + } + return -1; + } + + function getEventLatLng(e) { + // Return the closest latlng to e.latlng among the existing nodes including the first node of currentPolygon + // If the closest distance is superior to nodeCatchDistance, then e.latlng is returned + const closeNodes = []; + // Existing nodes + for (const polygon of polygons) { + for (const node of polygon) { + const d = layerDistance(e.latlng, node); + if (d < nodeCatchDistance) { + closeNodes.push([d, node]); + } + } + } + // First node of currentPolygon + if (isDrawing()) { + const d = layerDistance(e.latlng, currentPolygon[0]); + if (d < nodeCatchDistance) { + closeNodes.push([d, currentPolygon[0]]); + } + } + // If there is no close node + if (closeNodes.length == 0) { + return e.latlng; + // Else return the closest close node + } else { + return closeNodes.reduce( (min, current) => { return current[0] < min[0] ? current : min } )[1]; + } + } + + function handleLeftClick(e) { + setHighlightNodes([]); + const latlng = getEventLatLng(e); + const length = currentPolygon.length; + + // If it is the first node + if (!isDrawing()) { + // If the point is not in an existing polygon + if (getZoneIndex(latlng) == -1) { + setCurrentPolygon([latlng]); + } + + // If it is the last node + } else if (latlngEqual(latlng, currentPolygon[0])) { + // If the current polygon is a polygon (at least 3 points) + if (length >= 3) { + // If the current polygon is not circling an existing polygon + for (const polygon of polygons) { + // meanPoint exists and is strictly inside polygon + const meanPoint = { + lat: (polygon[0].lat + polygon[1].lat + polygon[2].lat) / 3, + lng: (polygon[0].lng + polygon[1].lng + polygon[2].lng) / 3 + }; + if (isInPolygon(meanPoint, currentPolygon)) return; + } + // Making the new polygon clockwise to simplify some algorithms + if (!isClockwise(currentPolygon)) currentPolygon.reverse(); + addPolygon(currentPolygon); + setCurrentPolygon([]); + } + + // If it is an intermediate node + } else { + // Is the polygon closing to early ? + for (const point of currentPolygon) if (latlngEqual(point, latlng)) return; + // Is the new point making the current polygon intersect with itself ? + if (isIntersecting([latlng, currentPolygon[length-1]], currentPolygon, false)) return; + // Is the new point inside a polygon ? + if (getZoneIndex(latlng) != -1) return; + // Is the new point making the current polygon intersect with another polygon ? + for (const polygon of polygons) { + // Strict intersection + if (isIntersecting([latlng, currentPolygon[length-1]], polygon, true)) return; + // Intersection by joining two non adjacent nodes of polygon + let tab = [-1, -1]; + for (let i = 0; i < polygon.length; i++) { + if (latlngEqual(latlng, polygon[i])) tab[0] = i; + if (latlngEqual(currentPolygon[length-1], polygon[i])) tab[1] = i; + } + if ( + tab[0] != -1 && tab[1] != -1 && + (tab[0] != (tab[1] + 1) % polygon.length) && + (tab[1] != (tab[0] + 1) % polygon.length) + ) return; + } + setCurrentPolygon([...currentPolygon, latlng]); + } + } + + function handleRightClick(e) { + setHighlightNodes([]); + // If isDrawing, cancel the currentPolygon + if (isDrawing()) { + setCurrentPolygon([]); + // If not isDrawing, remove the clicked polygon + } else { + const i = getZoneIndex(e.latlng); + if (i != -1) removePolygon(i); + } + } + + function handleMouseMove(e) { + const nodes = []; + for (const polygon of polygons) { + for (const node of polygon) { + if (layerDistance(node, e.latlng) < nodeHighlightDistance && node != currentPolygon[0]) nodes.push(node); + } + } + setHighlightNodes(nodes); + } + + return { currentPolygon, highlightNodes, handleLeftClick, handleRightClick, handleMouseMove }; +} diff --git a/traque-front/hook/usePasswordProtect.jsx b/traque-front/hook/usePasswordProtect.jsx index 581bce4..7094812 100644 --- a/traque-front/hook/usePasswordProtect.jsx +++ b/traque-front/hook/usePasswordProtect.jsx @@ -2,7 +2,7 @@ import { redirect, usePathname } from "next/navigation"; import { useEffect } from "react"; -export function usePasswordProtect(loginPath, redirectPath, loading, loggedIn) { +export default function usePasswordProtect(loginPath, redirectPath, loading, loggedIn) { const path = usePathname(); useEffect(() => { if (!loggedIn && !loading && path !== loginPath) { @@ -12,4 +12,4 @@ export function usePasswordProtect(loginPath, redirectPath, loading, loggedIn) { redirect(redirectPath) } }, [loggedIn, loading, path]); -} \ No newline at end of file +} diff --git a/traque-front/hook/useSocketAuth.jsx b/traque-front/hook/useSocketAuth.jsx index b17a2f5..d42919a 100644 --- a/traque-front/hook/useSocketAuth.jsx +++ b/traque-front/hook/useSocketAuth.jsx @@ -1,13 +1,13 @@ -import {useEffect, useState} from 'react'; -import { useSocketListener } from './useSocketListener'; -import { useLocalStorage } from './useLocalStorage'; -import { usePathname } from 'next/navigation'; +"use client"; +import { useEffect, useState } from 'react'; +import useSocketListener from './useSocketListener'; +import useLocalStorage from './useLocalStorage'; const LOGIN_MESSAGE = "login"; const LOGOUT_MESSAGE = "logout"; const LOGIN_RESPONSE_MESSAGE = "login_response"; -export function useSocketAuth(socket, passwordName) { +export default function useSocketAuth(socket, passwordName) { const [loggedIn, setLoggedIn] = useState(false); const [loading, setLoading] = useState(true); const [waitingForResponse, setWaitingForResponse] = useState(true); @@ -50,4 +50,4 @@ export function useSocketAuth(socket, passwordName) { return {login,logout,password: savedPassword, loggedIn, loading}; -} \ No newline at end of file +} diff --git a/traque-front/hook/useSocketListener.jsx b/traque-front/hook/useSocketListener.jsx index d425018..00b52e5 100644 --- a/traque-front/hook/useSocketListener.jsx +++ b/traque-front/hook/useSocketListener.jsx @@ -1,10 +1,11 @@ -import { useEffect} from "react"; +"use client"; +import { useEffect } from "react"; -export function useSocketListener(socket, event, callback) { +export default function useSocketListener(socket, event, callback) { useEffect(() => { socket.on(event,callback); return () => { socket.off(event, callback); } }, []); -} \ No newline at end of file +}