From 42257bd23dc2ab1cd3d928e73a5cb587d74eda0e Mon Sep 17 00:00:00 2001 From: Quentin Roussel Date: Mon, 10 Jun 2024 15:24:37 +0000 Subject: [PATCH] rever to 9615d4fecd8987e45bec1102b3efd6ccf8b1c9be --- traque-back/admin_socket.js | 29 +- traque-back/game.js | 30 +- traque-back/index.js | 14 +- traque-back/penalty_controller.js | 5 +- traque-back/team_socket.js | 11 +- traque-back/zone_manager.js | 291 ++++++++++++------ traque-front/app/admin/layout.js | 1 - traque-front/app/admin/map/page.js | 9 - traque-front/app/admin/page.js | 4 +- traque-front/components/admin/mapPicker.jsx | 125 ++++++++ .../components/admin/mapZoneSelector.jsx | 15 - traque-front/components/admin/maps.jsx | 245 --------------- traque-front/components/admin/teamEdit.jsx | 2 +- .../components/admin/zoneSelector.jsx | 53 +++- traque-front/components/team/actionDrawer.jsx | 1 + traque-front/components/team/map.jsx | 38 ++- .../components/team/placementOverlay.jsx | 1 + traque-front/components/util/map.jsx | 21 -- traque-front/context/adminContext.jsx | 13 +- traque-front/context/teamContext.jsx | 4 +- traque-front/hook/mapDrawing.jsx | 96 +----- traque-front/hook/useAdmin.jsx | 10 +- traque-front/public/icons/clock.png | Bin 9597 -> 0 bytes 23 files changed, 470 insertions(+), 548 deletions(-) delete mode 100644 traque-front/app/admin/map/page.js create mode 100644 traque-front/components/admin/mapPicker.jsx delete mode 100644 traque-front/components/admin/mapZoneSelector.jsx delete mode 100644 traque-front/components/admin/maps.jsx delete mode 100644 traque-front/components/util/map.jsx delete mode 100644 traque-front/public/icons/clock.png diff --git a/traque-back/admin_socket.js b/traque-back/admin_socket.js index feb151c..1d4d58d 100644 --- a/traque-back/admin_socket.js +++ b/traque-back/admin_socket.js @@ -2,8 +2,6 @@ import { io, game, penaltyController } from "./index.js"; import { playersBroadcast, sendUpdatedTeamInformations } from "./team_socket.js"; import { config } from "dotenv"; -import { currentZone, initZone, removeZone } from "./zone_manager.js"; -import { GameState } from "./game.js"; config() const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD; @@ -49,7 +47,12 @@ export function initAdminSocketHandler() { //Other settings that need initialization socket.emit("penalty_settings", penaltyController.settings) socket.emit("game_settings", game.settings) - socket.emit("zone", currentZone) + socket.emit("zone_settings", game.zone.zoneSettings) + socket.emit("zone", game.zone.currentZone) + socket.emit("new_zone", { + begin: game.zone.currentStartZone, + end: game.zone.nextZone + }) } else { //Attempt unsuccessful @@ -69,25 +72,19 @@ export function initAdminSocketHandler() { playersBroadcast("game_settings", game.settings); }) - socket.on("set_zone", (zone) => { + socket.on("set_zone_settings", (settings) => { if (!loggedIn) { socket.emit("error", "Not logged in"); return; } - if(game.state != GameState.PLAYING) { - initZone(zone) - }else { - socket.emit("error", "Game is not in setup state") + if (!game.setZoneSettings(settings)) { + socket.emit("error", "Error changing zone"); + socket.emit("zone_settings", game.zone.zoneSettings) //Still broadcast the old config to the client who submited an incorrect config to keep the client up to date + } else { + secureAdminBroadcast("zone_settings", game.zone.zoneSettings) } - }) - socket.on("remove_zone", (zone, time) => { - if (!loggedIn) { - socket.emit("error", "Not logged in"); - return; - } - removeZone(zone, time) - }); + }) socket.on("set_penalty_settings", (settings) => { if (!loggedIn) { diff --git a/traque-back/game.js b/traque-back/game.js index f424137..9e9d6f3 100644 --- a/traque-back/game.js +++ b/traque-back/game.js @@ -2,7 +2,7 @@ import { secureAdminBroadcast } from "./admin_socket.js"; import { penaltyController } from "./index.js"; import { isInCircle } from "./map_utils.js"; import { playersBroadcast, sendUpdatedTeamInformations } from "./team_socket.js"; -import { resetZone } from "./zone_manager.js"; +import { ZoneManager } from "./zone_manager.js"; export const GameState = { SETUP: "setup", @@ -12,9 +12,10 @@ export const GameState = { } export default class Game { - constructor() { + constructor(onUpdateZone, onUpdateNewZone) { this.teams = []; this.state = GameState.SETUP; + this.zone = new ZoneManager(onUpdateZone, onUpdateNewZone) this.settings = { loserEndGameMessage: "", winnerEndGameMessage: "", @@ -35,9 +36,19 @@ export default class Game { //The game has started if (newState == GameState.PLAYING) { penaltyController.start(); + if (!this.zone.ready()) { + return false; + } this.initLastSentLocations(); + this.zone.reset() + //If the zone cannot be setup, reset everything + if (!this.zone.start()) { + this.setState(GameState.SETUP); + return; + } } if (newState != GameState.PLAYING) { + this.zone.reset(); penaltyController.stop(); } //Game reset @@ -224,8 +235,23 @@ export default class Game { 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) { + //cannot change zones while playing + if (this.state == GameState.PLAYING || this.state == GameState.FINISHED) { + return false; + } + return this.zone.udpateSettings(newSettings) + } + finishGame() { this.setState(GameState.FINISHED); + this.zone.reset(); playersBroadcast("game_state", this.state); } } \ No newline at end of file diff --git a/traque-back/index.js b/traque-back/index.js index f031bae..88b2a4d 100644 --- a/traque-back/index.js +++ b/traque-back/index.js @@ -35,7 +35,19 @@ export const io = new Server(httpsServer, { } }); -export const game = new Game(); +//Zone update broadcast function, called by the game object +function onUpdateNewZone(newZone) { + playersBroadcast("new_zone", newZone) + secureAdminBroadcast("new_zone", newZone) +} + +function onUpdateZone(zone) { + playersBroadcast("zone", zone) + secureAdminBroadcast("zone", zone) +} + + +export const game = new Game(onUpdateZone, onUpdateNewZone); export const penaltyController = new PenaltyController(); diff --git a/traque-back/penalty_controller.js b/traque-back/penalty_controller.js index 54eb31f..88c7f56 100644 --- a/traque-back/penalty_controller.js +++ b/traque-back/penalty_controller.js @@ -3,7 +3,6 @@ import { sendUpdatedTeamInformations, teamBroadcast } from "./team_socket.js"; import { GameState } from "./game.js"; import { secureAdminBroadcast } from "./admin_socket.js"; import { game } from "./index.js"; -import { isInZone } from "./zone_manager.js"; export class PenaltyController { constructor() { @@ -92,10 +91,10 @@ export class PenaltyController { this.game.teams.forEach((team) => { if (team.captured) { return } //All the informations are not ready yet - if (team.currentLocation == null) { + if (team.currentLocation == null || this.game.zone.currentZone == null) { return; } - if (!isInZone(team.currentLocation)) { + if (!isInCircle({ lat: team.currentLocation[0], lng: team.currentLocation[1] }, this.game.zone.currentZone.center, this.game.zone.currentZone.radius)) { //The team was not previously out of the zone if (!this.outOfBoundsSince[team.id]) { this.outOfBoundsSince[team.id] = new Date(); diff --git a/traque-back/team_socket.js b/traque-back/team_socket.js index 5eeb3ac..23dbddf 100644 --- a/traque-back/team_socket.js +++ b/traque-back/team_socket.js @@ -1,6 +1,5 @@ import { secureAdminBroadcast } from "./admin_socket.js"; import { io, game } from "./index.js"; -import { currentZone } from "./zone_manager.js"; /** * Send a socket message to all the players of a team @@ -9,13 +8,9 @@ import { currentZone } from "./zone_manager.js"; * @param {*} data The payload */ export function teamBroadcast(teamId, event, data) { - if(!game.getTeam(teamId)) { - return false; - } for (let socketId of game.getTeam(teamId).sockets) { io.of("player").to(socketId).emit(event, data) } - return true; } /** @@ -84,7 +79,11 @@ export function initTeamSocket() { socket.emit("login_response", true); socket.emit("game_state", game.state) socket.emit("game_settings", game.settings) - socket.emit("zone", currentZone) + socket.emit("zone", game.zone.currentZone) + socket.emit("new_zone", { + begin: game.zone.currentStartZone, + end: game.zone.nextZone + }) }); socket.on("logout", () => { diff --git a/traque-back/zone_manager.js b/traque-back/zone_manager.js index 2694608..31cdb91 100644 --- a/traque-back/zone_manager.js +++ b/traque-back/zone_manager.js @@ -1,108 +1,199 @@ -import { playersBroadcast, teamBroadcast } from './team_socket.js'; -import { secureAdminBroadcast } from './admin_socket.js'; +import { randomCirclePoint } from 'random-location' +import { isInCircle } from './map_utils.js'; +import { map } from './util.js'; -export let currentZone = [] -let tileSize = 17; - -export class TileNumber { - constructor(x, y) { - this.x = x; - this.y = y; - this.removeDate = null; - } - equals(other) { - return this.x === other.x && this.y === other.y; - } - - removeIn(minutes) { - this.removeDate = Date.now() + 1000 * 60 * minutes - } -} - -export function latLngToTileNumber(pos, tileSize) { - const numTilesX = 2 ** tileSize; - const numTilesY = 2 ** tileSize; - const lngDegrees = pos[1]; - const latRadians = pos[0] * (Math.PI / 180); - return { - x:Math.round(numTilesX * ((lngDegrees + 180) / 360)), - y:Math.round(numTilesY * (1 - Math.log(Math.tan(latRadians) + 1 / Math.cos(latRadians)) / Math.PI) / 2) - }; -} - -export function broadcastZoneState() { - playersBroadcast("zone", currentZone); - secureAdminBroadcast("zone", currentZone); -} - - -/** - * Remove all tiles from the zone - */ -export function resetZone() { - currentZone = []; - broadcastZoneState(); -} - -export function setTileSize(size) { - resetZone(); - tileSize = size; -} - -/** - * Check whether a position is in the zone - * @param {Object} position The position to check - */ -export function isInZone(position) { - let tile = latLngToTileNumber(position, tileSize); - return currentZone.some(square => square.equals(tile)) -} - -/** - * Initialize a zone with a list of tiles - * @param {Array} zone Array of tiles to add - */ -export function initZone(zone) { - currentZone = []; - try { - for (let { x, y } of zone) { - currentZone.push(new TileNumber(x, y)) +export class ZoneManager { + constructor(onZoneUpdate, onNextZoneUpdate) { + //Setings storing where the zone will start, end and how it should evolve + //The zone will start by staying at its mzx 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 + //a call to onZoneUpdate will be made every updateIntervalSeconds when the zone is changing + //a call to onNextZoneUpdate will be made when the zone reduction ends and a new next zone is announced + this.zoneSettings = { + min: { center: null, radius: null }, + max: { center: null, radius: null }, + reductionCount: 2, + reductionDuration: 1, + reductionInterval: 1, + updateIntervalSeconds: 1, } - broadcastZoneState(); - } catch (e) { - console.error(e); - secureAdminBroadcast("error", "Invalid zone format") - } -} + this.nextZoneDecrement = null; + //Live location of the zone + this.currentZone = { center: null, radius: null }; -/** - * Put a list of tiles in a warning state for a certain amount of time, before removing them - * @param {Array} zone Array of tiles to remove - * @param {Number} time Time before those tiles get removed in minutes - */ -export function removeZone(zone, time) { - for (let tile of zone) { - for (let currentTile of currentZone) { - if (currentTile.equals(tile)) { - currentTile.removeIn(time); + //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 + this.nextZone = { center: null, radius: null }; + + //Zone at the begining of the shrinking + this.currentStartZone = { center: null, radius: null }; + + this.startDate = null; + this.started = false; + this.updateIntervalId = null; + this.nextZoneTimeoutId = null; + + this.onZoneUpdate = onZoneUpdate; + this.onNextZoneUpdate = onNextZoneUpdate + } + + /** + * 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() { + 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(); + + } + + /** + * 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 + } + } + + /** + * 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() { + //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++; } - } - broadcastZoneState(); -} - -setInterval(() => { - let changed = false; - currentZone = currentZone.map(square => { - if (square.removeDate !== null && square.removeDate < Date.now()) { - changed = true; - return null; - } - return square; - }).filter(square => square !== null) - if (changed) { - broadcastZoneState(); + this.onZoneUpdate(JSON.parse(JSON.stringify(this.currentStartZone))) + this.onNextZoneUpdate({ + begin: JSON.parse(JSON.stringify(this.currentStartZone)), + end: JSON.parse(JSON.stringify(this.nextZone)) + }) + return true; } -}, 1000); + /* + * 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() { + 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); + } +} \ No newline at end of file diff --git a/traque-front/app/admin/layout.js b/traque-front/app/admin/layout.js index c75d592..9ff7fa6 100644 --- a/traque-front/app/admin/layout.js +++ b/traque-front/app/admin/layout.js @@ -11,7 +11,6 @@ export default function AdminLayout({ children}) {
diff --git a/traque-front/app/admin/map/page.js b/traque-front/app/admin/map/page.js deleted file mode 100644 index 8dfecf8..0000000 --- a/traque-front/app/admin/map/page.js +++ /dev/null @@ -1,9 +0,0 @@ -"use client"; -import dynamic from 'next/dynamic'; - -const LiveMap = dynamic(() => import('@/components/admin/maps').then((mod) => mod.LiveMap), { - ssr: false -}); -export default function LiveMapPage() { - return -} \ No newline at end of file diff --git a/traque-front/app/admin/page.js b/traque-front/app/admin/page.js index 6e0ff72..af4a7d7 100644 --- a/traque-front/app/admin/page.js +++ b/traque-front/app/admin/page.js @@ -11,7 +11,7 @@ import dynamic from "next/dynamic"; const ZoneSelector = dynamic(() => import('@/components/admin/zoneSelector').then((mod) => mod.ZoneSelector), { ssr: false }); -const ZoneEditor = dynamic(() => import('@/components/admin/maps').then((mod) => mod.ZoneEditor), { +const LiveMap = dynamic(() => import('@/components/admin/mapPicker').then((mod) => mod.LiveMap), { ssr: false }); export default function AdminPage() { @@ -36,7 +36,7 @@ export default function AdminPage() { {(gameState == GameState.SETUP || gameState == GameState.PLACEMENT) && } {(gameState == GameState.SETUP || gameState == GameState.PLACEMENT) && } {gameState == GameState.PLAYING &&
- +
}
) diff --git a/traque-front/components/admin/mapPicker.jsx b/traque-front/components/admin/mapPicker.jsx new file mode 100644 index 0000000..e511c3b --- /dev/null +++ b/traque-front/components/admin/mapPicker.jsx @@ -0,0 +1,125 @@ +"use client"; +import { useLocation } from "@/hook/useLocation"; +import { useEffect, useState } from "react"; +import "leaflet/dist/leaflet.css"; +import { Circle, MapContainer, Marker, Popup, TileLayer, useMap } from "react-leaflet"; +import { useMapCircleDraw } from "@/hook/mapDrawing"; +import useAdmin from "@/hook/useAdmin"; + +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 = 17; +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 && } + + + + ) +} + +export function LiveMap() { + const location = useLocation(Infinity); + const { zone, zoneExtremities, teams, getTeamName } = useAdmin(); + return ( + + + + {zone && } + {zoneExtremities && } + {zoneExtremities && } + {teams.map((team) => team.currentLocation && !team.captured && + + {team.name} +

Chasing : {getTeamName(team.chasing)}

+

Chased by : {getTeamName(team.chased)}

+
+
)} +
+ ) +} \ No newline at end of file diff --git a/traque-front/components/admin/mapZoneSelector.jsx b/traque-front/components/admin/mapZoneSelector.jsx deleted file mode 100644 index cf16fde..0000000 --- a/traque-front/components/admin/mapZoneSelector.jsx +++ /dev/null @@ -1,15 +0,0 @@ -import { useMapGrid } from '@/hook/mapDrawing'; -import { TileNumber, latLngToTileNumber } from '../util/map'; -import { useMapEvent } from 'react-leaflet'; - -export function MapGridZoneSelector({ tilesColor, onClickTile, tileSize }) { - useMapEvent('click', (e) => { - const fractionalTileNumber = latLngToTileNumber(e.latlng, tileSize); - const tileNumber = new TileNumber(Math.floor(fractionalTileNumber.x), Math.floor(fractionalTileNumber.y)); - onClickTile(tileNumber); - }); - - useMapGrid(tilesColor, tileSize); - - return null; -} diff --git a/traque-front/components/admin/maps.jsx b/traque-front/components/admin/maps.jsx deleted file mode 100644 index baa92b3..0000000 --- a/traque-front/components/admin/maps.jsx +++ /dev/null @@ -1,245 +0,0 @@ -"use client"; -import { useLocation } from "@/hook/useLocation"; -import { use, useEffect, useState } from "react"; -import "leaflet/dist/leaflet.css"; -import L from "leaflet"; -import { Circle, LayerGroup, LayersControl, MapContainer, Marker, Popup, TileLayer, useMap } from "react-leaflet"; -import { useMapCircleDraw, useTilesColor } from "@/hook/mapDrawing"; -import useAdmin from "@/hook/useAdmin"; -import { MapGridZoneSelector } from "./mapZoneSelector.jsx"; -import { useAdminContext } from "@/context/adminContext.jsx"; -import { GreenButton } from "../util/button.jsx"; -import TextInput from "../util/textInput.jsx"; - -export const TILE_SIZE = 17 - -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 = 13; -export function CircularAreaPicker({ area, setArea, markerPosition, ...props }) { - const location = useLocation(Infinity); - const { handleClick, handleMouseMove, center, radius } = useMapCircleDraw(area, setArea); - return ( - - - {center && radius && } - {markerPosition && } - - - ) -} - -//https://stackoverflow.com/questions/71231865/show-fixed-100-m-x-100-m-grid-on-lowest-zoom-level - -export function ZoneInitializer({ ...props }) { - const location = useLocation(Infinity); - const { zone } = useAdminContext(); - const { initZone } = useAdmin(); - - const tilesColor = useTilesColor(zone); - - const handleClickTile = (tile) => { - if (zone) { - if (zone.some(t => t.x === tile.x && t.y === tile.y)) { - initZone(zone.filter(t => t.x !== tile.x || t.y !== tile.y)); - } else { - initZone([...zone, tile]); - } - } - } - - return ( - - - - - - - - - - - - ) -} - -export function ZoneEditor() { - const location = useLocation(Infinity); - const { zone, teams, getTeamName, removeZone } = useAdmin(); - const [zonesToDelete, setZonesToDelete] = useState([]); - const [tilesColor, setTilesColor] = useState([]); - const [timeBeforeDeletion, setTimeBeforeDeletion] = useState(null); - - function handleClickTile(tile) { - if (!zone.some(t => t.x === tile.x && t.y === tile.y)) return; - console.log(tile, "click", zonesToDelete); - if (!zonesToDelete.some(t => t.x === tile.x && t.y === tile.y)) { - setZonesToDelete([...zonesToDelete, tile]); - console.log("delete", tile); - } else { - setZonesToDelete(zonesToDelete.filter(t => t.x !== tile.x || t.y !== tile.y)); - } - } - - useEffect(() => { - console.log(zone); - setTilesColor([ - ...zonesToDelete.map(t => ({ x: t.x, y: t.y, color: 'rgba(255, 0, 0, 0.5)' })), - ...zone - .filter(t => !zonesToDelete.some(t2 => t.x == t2.x && t.y == t2.y)) - .map(t => { - console.log(t) - if (t.removeDate == null) { - return { x: t.x, y: t.y, color: 'rgba(0, 0, 255, 0.5)' } - } else { - return { x: t.x, y: t.y, color: 'rgba(255, 255, 0, 0.5)' } - } - }), - ]); - }, [zone, zonesToDelete]); - - const handleSubmit = (e) => { - if (timeBeforeDeletion == null) { - return; - } - e.preventDefault(); - removeZone(zonesToDelete, timeBeforeDeletion); - setZonesToDelete([]); - setTimeBeforeDeletion(null); - } - - - return ( -
-
- - - - {teams.map((team) => team.currentLocation && !team.captured && - - {team.name} -

Chasing : {getTeamName(team.chasing)}

-

Chased by : {getTeamName(team.chased)}

-
-
)} - - - - - - - -
-
-
- - setTimeBeforeDeletion(Number(e.target.value))}> - Delete selected zones -
-
- ) -} - -export function LiveMap({ ...props }) { - const location = useLocation(Infinity); - const { zone, teams, getTeamName } = useAdmin(); - const tilesColor = useTilesColor(zone); - - return ( - - - - - - - { }} tileSize={TILE_SIZE} /> - - - - - {teams.map((team) => team.currentLocation && !team.captured && - - {team.name} -

Chasing : {getTeamName(team.chasing)}

-

Chased by : {getTeamName(team.chased)}

-
-
)} -
-
- - - {teams.map((team) => team.currentLocation && !team.captured && - - Last position of {team.name} - - )} - - -
-
- ) -} \ No newline at end of file diff --git a/traque-front/components/admin/teamEdit.jsx b/traque-front/components/admin/teamEdit.jsx index bb8ea53..e8f845a 100644 --- a/traque-front/components/admin/teamEdit.jsx +++ b/traque-front/components/admin/teamEdit.jsx @@ -4,7 +4,7 @@ import BlueButton, { RedButton } from '../util/button'; import useAdmin from '@/hook/useAdmin'; import dynamic from 'next/dynamic'; -const CircularAreaPicker = dynamic(() => import('./maps').then((mod) => mod.CircularAreaPicker), { +const CircularAreaPicker = dynamic(() => import('./mapPicker').then((mod) => mod.CircularAreaPicker), { ssr: false }); diff --git a/traque-front/components/admin/zoneSelector.jsx b/traque-front/components/admin/zoneSelector.jsx index 32c7d89..2ef1781 100644 --- a/traque-front/components/admin/zoneSelector.jsx +++ b/traque-front/components/admin/zoneSelector.jsx @@ -1,12 +1,61 @@ -import { ZoneInitializer } from "./maps"; +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() { + changeZoneSettings({min:minZone, max:maxZone, reductionCount: Number(reductionCount), reductionDuration: Number(reductionDuration), reductionInterval: Number(reductionInterval)}); + } //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)}>Edit end zone} + {editMode == EditMode.MAX && setEditMode(EditMode.MIN)}>Edit start 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 6354139..0828c00 100644 --- a/traque-front/components/team/actionDrawer.jsx +++ b/traque-front/components/team/actionDrawer.jsx @@ -16,6 +16,7 @@ export default function ActionDrawer() { useEffect(() => { const interval = setInterval(() => { + console.log(locationSendDeadline) const timeLeft = locationSendDeadline - Date.now(); setTimeLeftBeforePenalty(Math.floor(timeLeft / 1000 / 60)); }, 1000); diff --git a/traque-front/components/team/map.jsx b/traque-front/components/team/map.jsx index 70c783e..e5b505e 100644 --- a/traque-front/components/team/map.jsx +++ b/traque-front/components/team/map.jsx @@ -1,14 +1,11 @@ 'use client'; import React, { useEffect, useState } from 'react' -import { Circle, LayerGroup, LayersControl, MapContainer, Marker, Popup, TileLayer, useMap } from 'react-leaflet' +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 { useTilesColor } from '@/hook/mapDrawing'; -import { MapGridZoneSelector } from '../admin/mapZoneSelector'; -import { TILE_SIZE } from '../admin/maps'; const DEFAULT_ZOOM = 17; @@ -28,11 +25,27 @@ function MapPan(props) { return null; } -export function LiveMap({ ...props }) { - const {zone} = useTeamContext(); - const tilesColor = useTilesColor(zone); - const { currentPosition, enemyPosition } = useGame(); +function LiveZone() { + const { zone } = useTeamContext(); + console.log('Zone', zone); + return zone && +} +function ZoneExtremities() { + const { zoneExtremities } = useTeamContext(); + console.log('Zone extremities', zoneExtremities); + return zoneExtremities?.begin && zoneExtremities?.end && <> + {/* */} + + + +} + +export function LiveMap({ ...props }) { + const { currentPosition, enemyPosition } = useGame(); + useEffect(() => { + console.log('Current position', currentPosition); + }, [currentPosition]); return ( } - - - - {}} tileSize={TILE_SIZE}/> - - - + + ) } diff --git a/traque-front/components/team/placementOverlay.jsx b/traque-front/components/team/placementOverlay.jsx index c4bb5a6..63e6f68 100644 --- a/traque-front/components/team/placementOverlay.jsx +++ b/traque-front/components/team/placementOverlay.jsx @@ -1,5 +1,6 @@ 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/util/map.jsx b/traque-front/components/util/map.jsx deleted file mode 100644 index bf19533..0000000 --- a/traque-front/components/util/map.jsx +++ /dev/null @@ -1,21 +0,0 @@ -export class TileNumber { - constructor(x, y) { - this.x = x; - this.y = y; - this.removeDate = null; - } - equals(other) { - return this.x === other.x && this.y === other.y; - } -} - -export function latLngToTileNumber(latLng, tileSize) { - const numTilesX = 2 ** tileSize; - const numTilesY = 2 ** tileSize; - const lngDegrees = latLng.lng; - const latRadians = latLng.lat * (Math.PI / 180); - return new L.Point( - numTilesX * ((lngDegrees + 180) / 360), - numTilesY * (1 - Math.log(Math.tan(latRadians) + 1 / Math.cos(latRadians)) / Math.PI) / 2 - ); -} \ No newline at end of file diff --git a/traque-front/context/adminContext.jsx b/traque-front/context/adminContext.jsx index 25f1e51..0c94189 100644 --- a/traque-front/context/adminContext.jsx +++ b/traque-front/context/adminContext.jsx @@ -4,15 +4,15 @@ import { useSocket } from "./socketContext"; import { useSocketListener } from "@/hook/useSocketListener"; import { useAdminConnexion } from "./adminConnexionContext"; import { GameState } from "@/util/gameState"; -import { TileNumber } from "@/components/util/map"; const adminContext = createContext(); function AdminProvider({ children }) { const [teams, setTeams] = useState([]); - const [zone, setZone] = 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 { adminSocket } = useSocket(); const { loggedIn } = useAdminConnexion(); @@ -26,16 +26,13 @@ function AdminProvider({ children }) { //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", (zone) => setZone(zone.map(t => { - let tile = new TileNumber(t.x, t.y); - tile.removeDate = t.removeDate; - return tile; - }))); + useSocketListener(adminSocket, "zone", setZone); useSocketListener(adminSocket, "new_zone", setZoneExtremities); - const value = useMemo(() => ({ zone, zoneExtremities, teams, penaltySettings, gameSettings, gameState }), [teams, gameState, zone, zoneExtremities, penaltySettings, gameSettings]); + const value = useMemo(() => ({ zone, zoneExtremities, teams, zoneSettings, penaltySettings, gameSettings, gameState }), [zoneSettings, teams, gameState, zone, zoneExtremities, penaltySettings, gameSettings]); return ( {children} diff --git a/traque-front/context/teamContext.jsx b/traque-front/context/teamContext.jsx index e22c13f..5d66e9f 100644 --- a/traque-front/context/teamContext.jsx +++ b/traque-front/context/teamContext.jsx @@ -13,6 +13,7 @@ function TeamProvider({children}) { const [gameState, setGameState] = useState(GameState.SETUP); const [gameSettings, setGameSettings] = useState(null); const [zone, setZone] = useState(null); + const [zoneExtremities, setZoneExtremities] = useState(null); const measuredLocation = useLocation(5000); const {teamSocket} = useSocket(); const {loggedIn} = useTeamConnexion(); @@ -26,6 +27,7 @@ function TeamProvider({children}) { useSocketListener(teamSocket, "game_state", setGameState); useSocketListener(teamSocket, "zone", setZone); + useSocketListener(teamSocket, "new_zone", setZoneExtremities); useSocketListener(teamSocket, "game_settings", setGameSettings); @@ -38,7 +40,7 @@ function TeamProvider({children}) { } }, [loggedIn, measuredLocation]); - const value = useMemo(() => ({teamInfos, gameState, zone, gameSettings}), [gameSettings, teamInfos, gameState, zone]); + const value = useMemo(() => ({teamInfos, gameState, zone, zoneExtremities, gameSettings}), [gameSettings, teamInfos, gameState, zone, zoneExtremities]); return ( {children} diff --git a/traque-front/hook/mapDrawing.jsx b/traque-front/hook/mapDrawing.jsx index 17dbd97..804a99a 100644 --- a/traque-front/hook/mapDrawing.jsx +++ b/traque-front/hook/mapDrawing.jsx @@ -1,7 +1,4 @@ -import { TileNumber, latLngToTileNumber } from "@/components/util/map"; import { useEffect, useState } from "react"; -import { useLeafletContext } from '@react-leaflet/core'; -import L from 'leaflet'; export function useMapCircleDraw(area, setArea) { const [drawing, setDrawing] = useState(false); @@ -15,18 +12,18 @@ export function useMapCircleDraw(area, setArea) { }, [area]) function handleClick(e) { - if (!drawing) { + if(!drawing) { setCenter(e.latlng); setRadius(null); setDrawing(true); } else { setDrawing(false); - setArea({ center, radius }); + setArea({center, radius}); } } function handleMouseMove(e) { - if (drawing) { + if(drawing) { setRadius(e.latlng.distanceTo(center)); } } @@ -36,91 +33,4 @@ export function useMapCircleDraw(area, setArea) { center, radius, } -} - -export function useMapGrid(tilesColor, tileSize) { - const { layerContainer, map } = useLeafletContext(); - const [grid, setGrid] = useState(null); - - const Grid = L.GridLayer.extend({ - createTile: function (coords) { - const tile = L.DomUtil.create('canvas', 'leaflet-tile'); - const ctx = tile.getContext('2d'); - const size = this.getTileSize(); - tile.width = size.x - tile.height = size.y - - // calculate projection coordinates of top left tile pixel - const nwPoint = coords.scaleBy(size); - // calculate geographic coordinates of top left tile pixel - const nw = map.unproject(nwPoint, coords.z); - // calculate fraction tile number at top left point - const nwTile = latLngToTileNumber(nw, tileSize) - - // calculate projection coordinates of bottom right tile pixel - const sePoint = new L.Point(nwPoint.x + size.x - 1, nwPoint.y + size.y - 1) - // calculate geographic coordinates of bottom right tile pixel - const se = map.unproject(sePoint, coords.z); - // calculate fractional tile number at bottom right point - const seTile = latLngToTileNumber(se, tileSize) - - const minTileX = nwTile.x - const maxTileX = seTile.x - const minTileY = nwTile.y - const maxTileY = seTile.y - - for (let x = Math.ceil(minTileX) - 1; x <= Math.floor(maxTileX) + 1; x++) { - for (let y = Math.ceil(minTileY) - 1; y <= Math.floor(maxTileY) + 1; y++) { - - let tile = new TileNumber(x, y) - - const xMinPixel = Math.round(size.x * (x - minTileX) / (maxTileX - minTileX)); - const xMaxPixel = Math.round(size.x * (x + 1 - minTileX) / (maxTileX - minTileX)); - const yMinPixel = Math.round(size.y * (y - minTileY) / (maxTileY - minTileY)); - const yMaxPixel = Math.round(size.y * (y + 1 - minTileY) / (maxTileY - minTileY)); - - if (this.tilesColor?.some(t => t.x == tile.x && t.y == tile.y)) { - ctx.fillStyle = this.tilesColor.find(t => t.x == tile.x && t.y == tile.y).color; - } - else { - ctx.fillStyle = 'rgba(255, 255, 255, 0)'; - } - ctx.fillRect(xMinPixel, yMinPixel, xMaxPixel - xMinPixel, yMaxPixel - yMinPixel); - } - } - return tile; - }, - }); - - useEffect(() => { - let g = new Grid({ - minZoom: 12, - }); - setGrid(g); - layerContainer.addLayer(g); - }, []) - - useEffect(() => { - if (grid) { - grid.tilesColor = tilesColor; - grid.redraw(); - } - }, [tilesColor,grid]); -} - -export function useTilesColor(zone) { - const [tilesColor, setTilesColor] = useState([]); - useEffect(() => { - if (zone) { - setTilesColor(zone.map(t => { - if(t.removeDate == null) { - return { x: t.x, y: t.y, color: 'rgba(0, 0, 255, 0.3'} - }else { - return { x: t.x, y: t.y, color: 'rgba(255, 255, 0, 0.3'} - } - - })); - } - }, [zone]); - return tilesColor; } \ No newline at end of file diff --git a/traque-front/hook/useAdmin.jsx b/traque-front/hook/useAdmin.jsx index f031e1d..3441641 100644 --- a/traque-front/hook/useAdmin.jsx +++ b/traque-front/hook/useAdmin.jsx @@ -39,12 +39,8 @@ export default function useAdmin(){ adminSocket.emit("change_state", state); } - function initZone(zone) { - adminSocket.emit("set_zone", zone); - } - - function removeZone(zone, time) { - adminSocket.emit("remove_zone", zone, time); + function changeZoneSettings(zone) { + adminSocket.emit("set_zone_settings", zone); } function changePenaltySettings(penalties) { @@ -54,6 +50,6 @@ export default function useAdmin(){ function changeGameSettings(settings) { adminSocket.emit("set_game_settings", settings); } - return {...adminContext,changeGameSettings, removeZone, initZone, changePenaltySettings, pollTeams, getTeam, getTeamName, reorderTeams, addTeam, removeTeam, changeState, updateTeam }; + return {...adminContext,changeGameSettings, changeZoneSettings, changePenaltySettings, pollTeams, getTeam, getTeamName, reorderTeams, addTeam, removeTeam, changeState, updateTeam }; } \ No newline at end of file diff --git a/traque-front/public/icons/clock.png b/traque-front/public/icons/clock.png deleted file mode 100644 index 6097257267bbdbabf14f4a52759498ebcd7c99e9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9597 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