feat: leave-room button
- room:leave event: removes the player from the room (not just disconnected), reassigns host if the host leaves, deletes the room when empty - store leaveRoom() emits it and clears the session (reset) - room header gets a "Quitter" button (always available); end-screen "Quitter" now also leaves cleanly Verified e2e: leaver removed from snapshot, host reassigned to the next player. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
6ed99ea803
commit
f0c327617b
6 changed files with 67 additions and 7 deletions
|
|
@ -324,6 +324,22 @@ export function registerRoomHandlers(
|
||||||
broadcastState(room)
|
broadcastState(room)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
socket.on("room:leave", () => {
|
||||||
|
const code = socket.data.roomCode
|
||||||
|
const playerId = socket.data.playerId
|
||||||
|
if (!code || !playerId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
socket.leave(code)
|
||||||
|
socket.data.roomCode = undefined
|
||||||
|
socket.data.playerId = undefined
|
||||||
|
const room = rooms.leave(code, playerId)
|
||||||
|
if (room) {
|
||||||
|
io.to(room.code).emit("player:left", { playerId })
|
||||||
|
broadcastState(room)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
socket.on("disconnect", () => {
|
socket.on("disconnect", () => {
|
||||||
const affected = rooms.handleDisconnect(socket.id)
|
const affected = rooms.handleDisconnect(socket.id)
|
||||||
if (!affected) {
|
if (!affected) {
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,28 @@ export class RoomManager {
|
||||||
return this.rooms.get(code)
|
return this.rooms.get(code)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retire un joueur d'une room (départ volontaire). Réassigne l'hôte si besoin,
|
||||||
|
* supprime la room si plus personne. Renvoie la room (ou null si supprimée).
|
||||||
|
*/
|
||||||
|
leave(code: string, playerId: string): ServerRoom | null {
|
||||||
|
const room = this.rooms.get(code)
|
||||||
|
if (!room) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
room.players.delete(playerId)
|
||||||
|
room.scores.delete(playerId)
|
||||||
|
if (room.players.size === 0) {
|
||||||
|
this.rooms.delete(code)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (room.hostId === playerId) {
|
||||||
|
const named = [...room.players.values()].find((p) => p.name !== "")
|
||||||
|
room.hostId = (named ?? [...room.players.values()][0]).id
|
||||||
|
}
|
||||||
|
return room
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Marque le joueur d'un socket comme déconnecté.
|
* Marque le joueur d'un socket comme déconnecté.
|
||||||
* Retourne la room affectée et le playerId, ou null si non rattaché.
|
* Retourne la room affectée et le playerId, ou null si non rattaché.
|
||||||
|
|
|
||||||
|
|
@ -133,7 +133,7 @@ export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
const finalScores = useRoomStore((s) => s.finalScores)
|
const finalScores = useRoomStore((s) => s.finalScores)
|
||||||
const gameTracks = useRoomStore((s) => s.gameTracks)
|
const gameTracks = useRoomStore((s) => s.gameTracks)
|
||||||
const gameRecap = useRoomStore((s) => s.gameRecap)
|
const gameRecap = useRoomStore((s) => s.gameRecap)
|
||||||
const reset = useRoomStore((s) => s.reset)
|
const leaveRoom = useRoomStore((s) => s.leaveRoom)
|
||||||
const returnToLobby = useRoomStore((s) => s.returnToLobby)
|
const returnToLobby = useRoomStore((s) => s.returnToLobby)
|
||||||
const isHost = snapshot.hostId === playerId
|
const isHost = snapshot.hostId === playerId
|
||||||
|
|
||||||
|
|
@ -284,7 +284,7 @@ export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<Link href="/">
|
<Link href="/">
|
||||||
<Button variant="secondary" className="w-full" onClick={reset}>
|
<Button variant="secondary" className="w-full" onClick={leaveRoom}>
|
||||||
Quitter
|
Quitter
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { Link } from "wouter"
|
import { Link, useLocation } from "wouter"
|
||||||
import { AnimatePresence, motion } from "framer-motion"
|
import { AnimatePresence, motion } from "framer-motion"
|
||||||
|
import { LogOut } from "lucide-react"
|
||||||
import { Button } from "@workspace/ui/components/button"
|
import { Button } from "@workspace/ui/components/button"
|
||||||
import { useRoomStore } from "@/store/room"
|
import { useRoomStore } from "@/store/room"
|
||||||
import { LobbyView } from "@/components/lobby-view"
|
import { LobbyView } from "@/components/lobby-view"
|
||||||
|
|
@ -13,8 +14,10 @@ import { RoundTransition } from "@/components/round-transition"
|
||||||
import { PseudoScreen } from "@/components/pseudo-screen"
|
import { PseudoScreen } from "@/components/pseudo-screen"
|
||||||
|
|
||||||
export function RoomPage({ code }: { code: string }) {
|
export function RoomPage({ code }: { code: string }) {
|
||||||
|
const [, navigate] = useLocation()
|
||||||
const snapshot = useRoomStore((s) => s.snapshot)
|
const snapshot = useRoomStore((s) => s.snapshot)
|
||||||
const roomCode = useRoomStore((s) => s.roomCode)
|
const roomCode = useRoomStore((s) => s.roomCode)
|
||||||
|
const leaveRoom = useRoomStore((s) => s.leaveRoom)
|
||||||
const playerId = useRoomStore((s) => s.playerId)
|
const playerId = useRoomStore((s) => s.playerId)
|
||||||
const connected = useRoomStore((s) => s.connected)
|
const connected = useRoomStore((s) => s.connected)
|
||||||
const pseudoSet = useRoomStore((s) => s.pseudoSet)
|
const pseudoSet = useRoomStore((s) => s.pseudoSet)
|
||||||
|
|
@ -80,10 +83,22 @@ export function RoomPage({ code }: { code: string }) {
|
||||||
>
|
>
|
||||||
<header className="flex items-center justify-between">
|
<header className="flex items-center justify-between">
|
||||||
<RoomCode code={snapshot.code} />
|
<RoomCode code={snapshot.code} />
|
||||||
<span
|
<div className="flex items-center gap-3">
|
||||||
className={`size-2.5 rounded-full ${connected ? "bg-green-500" : "bg-red-500"}`}
|
<span
|
||||||
title={connected ? "Connecté" : "Déconnecté"}
|
className={`size-2.5 rounded-full ${connected ? "bg-green-500" : "bg-red-500"}`}
|
||||||
/>
|
title={connected ? "Connecté" : "Déconnecté"}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
leaveRoom()
|
||||||
|
navigate("/")
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LogOut /> Quitter
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,7 @@ interface RoomState {
|
||||||
submitTrack: (youtubeUrl: string) => Promise<SubmitOkPayload>
|
submitTrack: (youtubeUrl: string) => Promise<SubmitOkPayload>
|
||||||
removeTrack: (trackId: string) => Promise<boolean>
|
removeTrack: (trackId: string) => Promise<boolean>
|
||||||
returnToLobby: () => void
|
returnToLobby: () => void
|
||||||
|
leaveRoom: () => void
|
||||||
vote: (choiceIndex: number) => void
|
vote: (choiceIndex: number) => void
|
||||||
voteText: (text: string) => void
|
voteText: (text: string) => void
|
||||||
voteBlindtest: (answer: BlindtestAnswer) => void
|
voteBlindtest: (answer: BlindtestAnswer) => void
|
||||||
|
|
@ -192,6 +193,11 @@ export const useRoomStore = create<RoomState>((set, get) => ({
|
||||||
socket.emit("lobby:return")
|
socket.emit("lobby:return")
|
||||||
},
|
},
|
||||||
|
|
||||||
|
leaveRoom: () => {
|
||||||
|
socket.emit("room:leave")
|
||||||
|
get().reset()
|
||||||
|
},
|
||||||
|
|
||||||
vote: (choiceIndex) => {
|
vote: (choiceIndex) => {
|
||||||
if (get().hasVoted) {
|
if (get().hasVoted) {
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -149,6 +149,7 @@ export interface ClientToServerEvents {
|
||||||
"room:join": (payload: RoomJoinPayload, ack: Ack<RoomCreatedPayload>) => void
|
"room:join": (payload: RoomJoinPayload, ack: Ack<RoomCreatedPayload>) => void
|
||||||
"room:rejoin": (payload: RejoinPayload, ack: Ack<RoomCreatedPayload>) => void
|
"room:rejoin": (payload: RejoinPayload, ack: Ack<RoomCreatedPayload>) => void
|
||||||
"player:setName": (payload: SetNamePayload) => void
|
"player:setName": (payload: SetNamePayload) => void
|
||||||
|
"room:leave": () => void
|
||||||
"lobby:updateSettings": (payload: UpdateSettingsPayload) => void
|
"lobby:updateSettings": (payload: UpdateSettingsPayload) => void
|
||||||
"lobby:return": () => void
|
"lobby:return": () => void
|
||||||
"game:start": (ack: Ack<null>) => void
|
"game:start": (ack: Ack<null>) => void
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue