From 901e3e5a8ee5db49c9d0819c13d3915ae241982e Mon Sep 17 00:00:00 2001 From: Maieul BOYER Date: Sat, 10 Jan 2026 17:43:04 +0100 Subject: [PATCH] feat(tour): better frontend for tournament start phase, and auto start after X seconds --- frontend/src/pages/pong/pong.html | 39 ++++++++++------ frontend/src/pages/pong/pong.ts | 27 ++++++++--- frontend/src/pages/pong/socket.ts | 8 +++- src/pong/src/socket.ts | 9 ++-- src/pong/src/state.ts | 78 ++++++++++++++++++++++++++----- src/pong/src/tour.ts | 21 +++++++-- 6 files changed, 140 insertions(+), 42 deletions(-) diff --git a/frontend/src/pages/pong/pong.html b/frontend/src/pages/pong/pong.html index 8b88e41..3e38bff 100644 --- a/frontend/src/pages/pong/pong.html +++ b/frontend/src/pages/pong/pong.html @@ -2,22 +2,29 @@
- - + + + +

Pong Box

-
+
- ?👤 ?⏳ ?▮•▮ - -
+ ?👤 ?⏳ ?▮•▮ + + ⚪️ ?👤 ?▮•▮ + + + + +
-

@@ -34,17 +41,19 @@ W down: S -
- You are red.
Your goal is to bounce the ball back to the - adversary. -
- local games keys for the left paddle:
+
+ You are red. +
+ Your goal is to bounce the ball back to the adversary. +
+ local games keys for the left paddle: +
up: O down: L
-
+
@@ -56,4 +65,4 @@
- \ No newline at end of file + diff --git a/frontend/src/pages/pong/pong.ts b/frontend/src/pages/pong/pong.ts index 99e31f4..60a9aab 100644 --- a/frontend/src/pages/pong/pong.ts +++ b/frontend/src/pages/pong/pong.ts @@ -42,6 +42,14 @@ enum TourBtnState { AbeToProcreate = "He would be proud", } +enum TourInfoState { + Running = "🟢", + Owner = "👑", + Registered = "✅", + NotRegisted = "❌", + NoTournament = "⚪️", +} + document.addEventListener("ft:pageChange", (newUrl) => { if ( newUrl.detail.startsWith("/app/pong") || @@ -105,6 +113,8 @@ function pongClient(_url: string, _args: RouteHandlerParams): RouteHandlerReturn document.querySelector("#pong-end-screen"); const tournamentBtn = document.querySelector("#TourBtn"); + const tour_infos = + document.querySelector("#tour-info"); let socket = getSocket(); @@ -126,7 +136,8 @@ function pongClient(_url: string, _args: RouteHandlerParams): RouteHandlerReturn !LocalGameBtn || !rdy_btn || !end_scr || - !tournamentBtn + !tournamentBtn || + !tour_infos ) // sanity check return showError("fatal error"); @@ -137,7 +148,7 @@ function pongClient(_url: string, _args: RouteHandlerParams): RouteHandlerReturn switch (tournamentBtn.innerText) { case TourBtnState.AbleToStart: - //socket.emit('') + socket.emit('tourStart') break; case TourBtnState.AbleToJoin: socket.emit("tourRegister"); @@ -152,8 +163,6 @@ function pongClient(_url: string, _args: RouteHandlerParams): RouteHandlerReturn socket.emit("tourUnregister"); break; case TourBtnState.Started: - showInfo("tournament Started"); - //socket.emit("tourStart"); break; } }); @@ -264,6 +273,10 @@ function pongClient(_url: string, _args: RouteHandlerParams): RouteHandlerReturn socket.on("gameUpdate", (state: GameUpdate) => { render(state); }); + + socket.on("tourEnding", (ending) => { + showInfo(ending); + }) // --- // position logic (client) end // --- @@ -405,19 +418,23 @@ function pongClient(_url: string, _args: RouteHandlerParams): RouteHandlerReturn if (s === null) { tournamentBtn.innerText = TourBtnState.AbleToCreate; // create tournament + tour_infos.innerText = `${TourInfoState.NoTournament} 0👤 0▮•▮`; return; } let weIn = s.players.some((p) => p.id === user.id); let imOwner = s.ownerId === user.id; + // TODO: fix this so the number of remaining games are correct switch (s.state) { case "ended": tournamentBtn.innerText = TourBtnState.AbleToCreate; break; case "playing": tournamentBtn.innerText = TourBtnState.Started; + tour_infos.innerText = `${TourInfoState.Running} ${s.players.length}👤 ?▮•▮`; break; case "prestart": + tour_infos.innerText = `${imOwner ? TourInfoState.Owner : (weIn ? TourInfoState.Registered : TourInfoState.NotRegisted)} ${s.players.length}👤 ?▮•▮`; if (imOwner) { tournamentBtn.innerText = TourBtnState.AbleToStart; } else { @@ -427,8 +444,6 @@ function pongClient(_url: string, _args: RouteHandlerParams): RouteHandlerReturn } break; } - - console.log(s.players); }); socket.on("tournamentRegister", ({ kind, msg }) => { diff --git a/frontend/src/pages/pong/socket.ts b/frontend/src/pages/pong/socket.ts index f0e1638..13dc8b6 100644 --- a/frontend/src/pages/pong/socket.ts +++ b/frontend/src/pages/pong/socket.ts @@ -32,7 +32,7 @@ export type GameMove = { export type TourInfo = { ownerId: string; - state: 'prestart' | 'playing' | 'ended'; + state: 'prestart' | 'playing' | 'ended' | 'canceled'; players: { id: string; name: string; score: number }[]; currentGameInfo: GameUpdate | null; }; @@ -42,17 +42,19 @@ export interface ClientToServer { dequeue: () => void; readyUp: () => void; readyDown: () => void; - debugInfo: () => void; gameMove: (up: GameMove) => void; connectedToGame: (gameId: string) => void; localGame: () => void; + hello: () => void; + // TOURNAMENT tourRegister: () => void; tourUnregister: () => void; tourCreate: () => void; + tourStart: () => void; } export interface ServerToClient { @@ -74,6 +76,8 @@ export interface ServerToClient { msg?: string; }) => void; tournamentInfo: (info: TourInfo | null) => void; + + tourEnding: (msg: string) => void; } export type SSocket = Socket; diff --git a/src/pong/src/socket.ts b/src/pong/src/socket.ts index be065b1..78cc1e9 100644 --- a/src/pong/src/socket.ts +++ b/src/pong/src/socket.ts @@ -32,7 +32,7 @@ export type GameMove = { export type TourInfo = { ownerId: string; - state: 'prestart' | 'playing' | 'ended'; + state: 'prestart' | 'playing' | 'ended' | 'canceled'; players: { id: string; name: string; score: number }[]; currentGameInfo: GameUpdate | null; }; @@ -42,18 +42,19 @@ export interface ClientToServer { dequeue: () => void; readyUp: () => void; readyDown: () => void; - debugInfo: () => void; gameMove: (up: GameMove) => void; connectedToGame: (gameId: string) => void; localGame: () => void; + hello: () => void; + // TOURNAMENT tourRegister: () => void; tourUnregister: () => void; tourCreate: () => void; - // tourStart: () => void; + tourStart: () => void; } export interface ServerToClient { @@ -75,6 +76,8 @@ export interface ServerToClient { msg?: string; }) => void; tournamentInfo: (info: TourInfo | null) => void; + + tourEnding: (msg: string) => void; } export type SSocket = Socket; diff --git a/src/pong/src/state.ts b/src/pong/src/state.ts index feffdd7..64db4b5 100644 --- a/src/pong/src/state.ts +++ b/src/pong/src/state.ts @@ -13,22 +13,29 @@ type PUser = { socket: SSocket, windowId: string, updateInterval: NodeJS.Timeout, + killSelfInterval: NodeJS.Timeout, + lastSeen: number, }; type GameId = PongGameId; class StateI { public static readonly UPDATE_INTERVAL_FRAMES: number = 60; + public static readonly KEEP_ALIVE_MS: number = 30 * 1000; + public static readonly START_TIMER_TOURNAMENT: number = 60 * 2 * 1000; private users: Map = new Map(); private queue: Set = new Set(); private queueInterval: NodeJS.Timeout; + private tournamentInterval: NodeJS.Timeout; private games: Map = new Map(); private tournament: Tournament | null = null; public constructor(private fastify: FastifyInstance) { this.queueInterval = setInterval(() => this.queuerFunction()); + this.tournamentInterval = setInterval(() => this.tournamentIntervalFunc()); void this.queueInterval; + void this.tournamentInterval; } private static getGameUpdateData(id: GameId, g: Pong): GameUpdate { @@ -49,7 +56,7 @@ class StateI { sock.emit('tournamentRegister', { kind: 'failure', msg: 'No tournament exists' }); return; } - if (this.tournament.started) { + if (this.tournament.state !== 'prestart') { sock.emit('tournamentRegister', { kind: 'failure', msg: 'No tournament already started' }); return; } @@ -72,7 +79,7 @@ class StateI { sock.emit('tournamentRegister', { kind: 'failure', msg: 'No tournament exists' }); return; } - if (this.tournament.started) { + if (this.tournament.state !== 'prestart') { sock.emit('tournamentRegister', { kind: 'failure', msg: 'No tournament already started' }); return; } @@ -88,25 +95,36 @@ class StateI { if (this.tournament !== null) { sock.emit('tournamentCreateMsg', { kind: 'failure', msg: 'A tournament already exists' }); - return ; + return; } this.tournament = new Tournament(user.id); this.registerForTournament(sock, null); + this.tournament.startTimeout = setTimeout(() => this.tournament?.start(), StateI.START_TIMER_TOURNAMENT); } - private tournamentStart(sock: SSocket) { + private cleanupTournament() { + if (this.tournament === null) return; + // we remove all the games we might have done for the tournament, and voila :D + this.tournament.games.keys().forEach((g) => this.games.delete(g)); + + this.tournament = null; + } + + private startTournament(sock: SSocket) { if (isNullish(this.tournament)) return; const user = this.users.get(sock.authUser.id); if (isNullish(user)) return; + if (user.id !== this.tournament.owner) return; + clearInterval(this.tournament.startTimeout); this.tournament.start(); } - public newPausedGame(suid1 : string, suid2 : string) : GameId | undefined { + public newPausedGame(suid1: string, suid2: string): GameId | undefined { if (!this.users.has(suid1 as UserId) || !this.users.has(suid2 as UserId)) { return (undefined); } - const uid1 : UserId = suid1 as UserId; - const uid2 : UserId = suid2 as UserId; + const uid1: UserId = suid1 as UserId; + const uid2: UserId = suid2 as UserId; const g = new Pong(uid1, uid2); g.rdy_timer = -1; const gameId = newUUID() as unknown as GameId; @@ -114,8 +132,8 @@ class StateI { this.games.set(gameId, g); return (gameId); } - public startPausedGame(g_id: PongGameId) : boolean { - let game : Pong | undefined; + public startPausedGame(g_id: PongGameId): boolean { + let game: Pong | undefined; if (!this.games.has(g_id) || (game = this.games.get(g_id)) === undefined) { return (false); } game.rdy_timer = Date.now(); @@ -143,11 +161,30 @@ class StateI { this.gameUpdate(g_id, usr1.socket); this.gameUpdate(g_id, usr2.socket); } - if (game.checkWinner() !== null) {this.cleanupGame(g_id, game); } + if (game.checkWinner() !== null) { this.cleanupGame(g_id, game); } }, 1000 / StateI.UPDATE_INTERVAL_FRAMES); return (true); } + private tournamentIntervalFunc() { + const broadcastTourEnding = (msg: string) => { + this.users.forEach((u) => { u.socket.emit('tourEnding', msg); }); + }; + if (this.tournament) { + if (this.tournament.state === 'canceled') { + broadcastTourEnding('Tournament was canceled'); + this.cleanupTournament(); + } + else if (this.tournament.state === 'ended') { + broadcastTourEnding('Tournament is finished !'); + this.cleanupTournament(); + } + else if (this.tournament.state === 'playing') { + this.tournament.checkCurrentGame(); + } + } + } + private queuerFunction(): void { const values = Array.from(this.queue.values()); shuffle(values); @@ -246,6 +283,15 @@ class StateI { game.updateLastSeen(user.id); } + public checkKillSelf(sock: SSocket) { + const user = this.users.get(sock.authUser.id); + if (isNullish(user)) return; + + if (Date.now() - user.lastSeen < StateI.KEEP_ALIVE_MS) return; + + this.cleanupUser(sock); + } + public registerUser(socket: SSocket): void { this.fastify.log.info('Registering new user'); @@ -259,7 +305,9 @@ class StateI { id: socket.authUser.id, windowId: socket.id, updateInterval: setInterval(() => this.updateClient(socket), 100), + killSelfInterval: setInterval(() => this.checkKillSelf(socket), 100), currentGame: null, + lastSeen: Date.now(), }); this.fastify.log.info('Registered new user'); @@ -278,6 +326,7 @@ class StateI { socket.on('tourUnregister', () => this.unregisterForTournament(socket)); socket.on('tourCreate', () => this.createTournament(socket)); + socket.on('tourStart', () => this.startTournament(socket)); } private updateClient(socket: SSocket): void { @@ -290,7 +339,7 @@ class StateI { if (this.tournament !== null) { tourInfo = { ownerId: this.tournament.owner, - state: this.tournament.started ? 'playing' : 'prestart', + state: this.tournament.state, players: this.tournament.users.values().toArray(), currentGameInfo: (() => { if (this.tournament.currentGame === null) return null; @@ -307,8 +356,15 @@ class StateI { if (!this.users.has(socket.authUser.id)) return; clearInterval(this.users.get(socket.authUser.id)?.updateInterval); + clearInterval(this.users.get(socket.authUser.id)?.killSelfInterval); this.users.delete(socket.authUser.id); this.queue.delete(socket.authUser.id); + + + // if the user is in the tournament, and the tournament owner isn't the owner => we remove the user from the tournament ! + if (this.tournament?.users.has(socket.authUser.id) && this.tournament?.owner !== socket.authUser.id) { + this.tournament.removeUser(socket.authUser.id); + } } private async cleanupGame(gameId: GameId, game: Pong): Promise { diff --git a/src/pong/src/tour.ts b/src/pong/src/tour.ts index d2656f7..e63aa8a 100644 --- a/src/pong/src/tour.ts +++ b/src/pong/src/tour.ts @@ -10,27 +10,33 @@ type TourUser = { name: string; }; +type TournamentState = 'prestart' | 'playing' | 'ended' | 'canceled'; export class Tournament { public users: Map = new Map(); public currentGame: PongGameId | null = null; public games: Map = new Map(); - public started: boolean = false; - public gameUpdate: NodeJS.Timeout | undefined; + public state: TournamentState = 'prestart'; + public startTimeout: NodeJS.Timeout | undefined; constructor(public owner: UserId) { } public addUser(id: UserId, name: string) { - if (this.started) return; + if (this.state !== 'prestart') return; this.users.set(id, { id, name, score: 0 }); } public removeUser(id: UserId) { - if (this.started) return; + if (this.state !== 'prestart') return; this.users.delete(id); } public start() { - this.started = true; + if (this.state !== 'prestart') return; + if (this.users.size < 2) { + this.state = 'canceled'; + return; + } + this.state = 'playing'; const users = Array.from(this.users.keys()); const comb: [UserId, UserId][] = []; @@ -48,7 +54,12 @@ export class Tournament { const g = new Pong(u1, u2); this.games.set(gameId, g); + }); this.currentGame = this.games.keys().next().value ?? null; } + + public checkCurrentGame() { + void 0; + } }