diff --git a/apps/server/src/game/engine.ts b/apps/server/src/game/engine.ts index a34be3a..9b11409 100644 --- a/apps/server/src/game/engine.ts +++ b/apps/server/src/game/engine.ts @@ -198,7 +198,7 @@ export class GameEngine implements RoomGameController { private eligibleVoters(): string[] { const secret = this.runtime?.secretPlayerId return [...this.room.players.values()] - .filter((p) => p.connected && p.id !== secret) + .filter((p) => p.connected && p.name !== "" && p.id !== secret) .map((p) => p.id) } diff --git a/apps/server/src/handlers.ts b/apps/server/src/handlers.ts index 05657c9..2c5bf82 100644 --- a/apps/server/src/handlers.ts +++ b/apps/server/src/handlers.ts @@ -53,11 +53,8 @@ export function registerRoomHandlers( } socket.on("room:create", (payload, ack) => { - const name = cleanName(payload?.playerName) - if (!name) { - ack({ ok: false, error: { code: "INVALID_NAME", message: "Pseudo invalide." } }) - return - } + // Le pseudo est choisi dans la room (player:setName) : on crée sans nom. + const name = cleanName(payload?.playerName) ?? "" try { const { room, player } = rooms.create(name, socket.id) socket.data.playerId = player.id @@ -71,12 +68,8 @@ export function registerRoomHandlers( }) socket.on("room:join", (payload, ack) => { - const name = cleanName(payload?.playerName) + const name = cleanName(payload?.playerName) ?? "" const code = typeof payload?.roomCode === "string" ? payload.roomCode.trim().toUpperCase() : "" - if (!name) { - ack({ ok: false, error: { code: "INVALID_NAME", message: "Pseudo invalide." } }) - return - } try { const { room, player } = rooms.join(code, name, socket.id) socket.data.playerId = player.id @@ -89,6 +82,22 @@ export function registerRoomHandlers( } }) + socket.on("player:setName", (payload) => { + const code = socket.data.roomCode + const playerId = socket.data.playerId + const room = code ? rooms.get(code) : undefined + const name = cleanName(payload?.name) + if (!room || !playerId || !name) { + return + } + const player = room.players.get(playerId) + if (!player) { + return + } + player.name = name + broadcastState(room) + }) + socket.on("lobby:updateSettings", (payload) => { const code = socket.data.roomCode const room = code ? rooms.get(code) : undefined @@ -129,7 +138,9 @@ export function registerRoomHandlers( return } const hasBlindtest = room.settings.rounds.some((r) => r.type === "blindtest") - const connected = [...room.players.values()].filter((p) => p.connected).length + const connected = [...room.players.values()].filter( + (p) => p.connected && p.name !== "" + ).length if (hasBlindtest && connected < 3) { ack({ ok: false, diff --git a/apps/server/src/rooms.ts b/apps/server/src/rooms.ts index bffbd5c..96be50b 100644 --- a/apps/server/src/rooms.ts +++ b/apps/server/src/rooms.ts @@ -112,23 +112,25 @@ export class RoomManager { /** Projette la room vers la vue publique diffusée aux clients (sans secrets). */ toSnapshot(room: ServerRoom): RoomSnapshot { + // Seuls les joueurs ayant choisi un pseudo sont visibles dans la room. + const named = [...room.players.values()].filter((p) => p.name !== "") return { code: room.code, status: room.status, hostId: room.hostId, - players: [...room.players.values()].map((p) => ({ + players: named.map((p) => ({ id: p.id, name: p.name, connected: p.connected, })), settings: room.settings, - scores: [...room.scores.entries()].map(([playerId, score]) => ({ - playerId, - score, + scores: named.map((p) => ({ + playerId: p.id, + score: room.scores.get(p.id) ?? 0, })), currentRound: room.currentRound, totalRounds: room.settings.rounds.length, - submissions: [...room.players.values()].map((p) => ({ + submissions: named.map((p) => ({ playerId: p.id, count: room.blindtestTracks.filter((t) => t.submittedBy === p.id).length, })), diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 3aca4dd..6a275ee 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,11 +1,15 @@ import { Route, Switch } from "wouter" import { HomePage } from "@/pages/home" +import { JoinPage } from "@/pages/join" import { RoomPage } from "@/pages/room" export function App() { return ( + + {(params) => } + {(params) => } diff --git a/apps/web/src/components/pseudo-screen.tsx b/apps/web/src/components/pseudo-screen.tsx new file mode 100644 index 0000000..5a11d8c --- /dev/null +++ b/apps/web/src/components/pseudo-screen.tsx @@ -0,0 +1,45 @@ +import { useState } from "react" +import { Button } from "@workspace/ui/components/button" +import { useRoomStore } from "@/store/room" +import { Avatar } from "@/components/avatar" + +const inputClass = + "border-input bg-background h-10 w-full rounded-md border px-3 py-2 text-center text-sm focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none" + +/** Choix du pseudo dans la room, avec aperçu de l'avatar en temps réel. */ +export function PseudoScreen({ code }: { code: string }) { + const setName = useRoomStore((s) => s.setName) + const [name, setName_] = useState("") + const canSubmit = name.trim().length > 0 + + return ( +
+
+

+ Room {code} +

+ +

Choisis ton pseudo

+ setName_(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && canSubmit && setName(name)} + /> + +
+
+ ) +} diff --git a/apps/web/src/components/room-code.tsx b/apps/web/src/components/room-code.tsx index 58142df..ccd599d 100644 --- a/apps/web/src/components/room-code.tsx +++ b/apps/web/src/components/room-code.tsx @@ -1,39 +1,56 @@ import { useState } from "react" -import { Check, Copy } from "lucide-react" +import { Check, Copy, Link2 } from "lucide-react" +import { Button } from "@workspace/ui/components/button" -/** Code de room cliquable : copie dans le presse-papier avec retour visuel. */ +type Copied = "code" | "link" | null + +/** Code de room copiable + bouton pour copier le lien d'invitation. */ export function RoomCode({ code }: { code: string }) { - const [copied, setCopied] = useState(false) + const [copied, setCopied] = useState(null) - async function copy() { + async function copy(what: Copied, text: string) { try { - await navigator.clipboard.writeText(code) - setCopied(true) - setTimeout(() => setCopied(false), 1500) + await navigator.clipboard.writeText(text) + setCopied(what) + setTimeout(() => setCopied(null), 1500) } catch { // presse-papier indisponible (http non sécurisé) : on ignore silencieusement } } + const link = `${window.location.origin}/join/${code}` + return ( - + {copied === "code" ? ( + + ) : ( + + )} + + + + ) } diff --git a/apps/web/src/pages/home.tsx b/apps/web/src/pages/home.tsx index c9d305c..6aebc56 100644 --- a/apps/web/src/pages/home.tsx +++ b/apps/web/src/pages/home.tsx @@ -12,18 +12,15 @@ export function HomePage() { const joinRoom = useRoomStore((s) => s.joinRoom) const connected = useRoomStore((s) => s.connected) - const [name, setName] = useState("") const [code, setCode] = useState("") const [error, setError] = useState(null) const [busy, setBusy] = useState(false) - const canSubmit = name.trim().length > 0 && connected && !busy - async function handleCreate() { setError(null) setBusy(true) try { - const { roomCode } = await createRoom(name.trim()) + const { roomCode } = await createRoom() navigate(`/room/${roomCode}`) } catch (err) { setError((err as { message?: string }).message ?? "Erreur") @@ -36,7 +33,7 @@ export function HomePage() { setError(null) setBusy(true) try { - const { roomCode } = await joinRoom(code.trim().toUpperCase(), name.trim()) + const { roomCode } = await joinRoom(code.trim().toUpperCase()) navigate(`/room/${roomCode}`) } catch (err) { setError((err as { message?: string }).message ?? "Erreur") @@ -57,15 +54,7 @@ export function HomePage() {

- setName(e.target.value)} - /> - - @@ -82,10 +71,11 @@ export function HomePage() { value={code} maxLength={4} onChange={(e) => setCode(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && code.trim() && handleJoin()} /> + + + ) : ( +

+ Connexion à la room {code}… +

+ )} + + ) +} diff --git a/apps/web/src/pages/room.tsx b/apps/web/src/pages/room.tsx index ee937fc..5c00307 100644 --- a/apps/web/src/pages/room.tsx +++ b/apps/web/src/pages/room.tsx @@ -9,19 +9,22 @@ import { PlayerCards } from "@/components/player-cards" import { RoomCode } from "@/components/room-code" import { Boom } from "@/components/boom" import { RoundTransition } from "@/components/round-transition" +import { PseudoScreen } from "@/components/pseudo-screen" export function RoomPage({ code }: { code: string }) { const snapshot = useRoomStore((s) => s.snapshot) + const roomCode = useRoomStore((s) => s.roomCode) const playerId = useRoomStore((s) => s.playerId) const connected = useRoomStore((s) => s.connected) + const pseudoSet = useRoomStore((s) => s.pseudoSet) const boomKey = useRoomStore((s) => s.boomKey) const roundKey = useRoomStore((s) => s.roundKey) const voteProgress = useRoomStore((s) => s.voteProgress) const round = useRoomStore((s) => s.round) const roundModeChanged = useRoomStore((s) => s.roundModeChanged) - // Pas de snapshot pour ce code (accès direct / refresh) : retour à l'accueil. - if (!snapshot || snapshot.code !== code) { + // Pas dans cette room (accès direct sans rejoindre / refresh) : retour accueil. + if (roomCode !== code) { return (

@@ -34,6 +37,20 @@ export function RoomPage({ code }: { code: string }) { ) } + // Pseudo pas encore choisi : on l'affiche d'abord. + if (!pseudoSet) { + return + } + + // En attente du premier snapshot. + if (!snapshot || snapshot.code !== code) { + return ( +

+

Chargement…

+
+ ) + } + // Cards de scores visibles en permanence pendant le jeu (suivi continu). const inGame = snapshot.status === "in_round" || diff --git a/apps/web/src/store/room.ts b/apps/web/src/store/room.ts index 6955ae1..94f018c 100644 --- a/apps/web/src/store/room.ts +++ b/apps/web/src/store/room.ts @@ -37,6 +37,8 @@ interface RoomState { connected: boolean playerId: string | null roomCode: string | null + /** Le joueur local a-t-il choisi son pseudo dans la room ? */ + pseudoSet: boolean snapshot: RoomSnapshot | null lastError: ErrorPayload | null @@ -53,8 +55,9 @@ interface RoomState { /** Le type d'épreuve a-t-il changé par rapport à la manche précédente ? */ roundModeChanged: boolean - createRoom: (playerName: string) => Promise - joinRoom: (roomCode: string, playerName: string) => Promise + createRoom: () => Promise + joinRoom: (roomCode: string) => Promise + setName: (name: string) => void updateSettings: (partial: Partial) => void startGame: (rounds: RoundConfig[]) => Promise submitTrack: (youtubeUrl: string) => Promise @@ -73,6 +76,7 @@ export const useRoomStore = create((set, get) => ({ connected: socket.connected, playerId: null, roomCode: null, + pseudoSet: false, snapshot: null, lastError: null, round: null, @@ -86,11 +90,15 @@ export const useRoomStore = create((set, get) => ({ roundKey: 0, roundModeChanged: false, - createRoom: (playerName) => + createRoom: () => new Promise((resolve, reject) => { - socket.emit("room:create", { playerName }, (res) => { + socket.emit("room:create", {}, (res) => { if (res.ok) { - set({ playerId: res.data.playerId, roomCode: res.data.roomCode }) + set({ + playerId: res.data.playerId, + roomCode: res.data.roomCode, + pseudoSet: false, + }) resolve(res.data) } else { set({ lastError: res.error }) @@ -99,11 +107,15 @@ export const useRoomStore = create((set, get) => ({ }) }), - joinRoom: (roomCode, playerName) => + joinRoom: (roomCode) => new Promise((resolve, reject) => { - socket.emit("room:join", { roomCode, playerName }, (res) => { + socket.emit("room:join", { roomCode }, (res) => { if (res.ok) { - set({ playerId: res.data.playerId, roomCode: res.data.roomCode }) + set({ + playerId: res.data.playerId, + roomCode: res.data.roomCode, + pseudoSet: false, + }) resolve(res.data) } else { set({ lastError: res.error }) @@ -112,6 +124,14 @@ export const useRoomStore = create((set, get) => ({ }) }), + setName: (name) => { + if (name.trim().length === 0) { + return + } + socket.emit("player:setName", { name: name.trim() }) + set({ pseudoSet: true }) + }, + updateSettings: (partial) => { const current = get().snapshot?.settings ?? DEFAULT_ROOM_SETTINGS socket.emit("lobby:updateSettings", { @@ -184,6 +204,7 @@ export const useRoomStore = create((set, get) => ({ reset: () => set({ roomCode: null, + pseudoSet: false, snapshot: null, lastError: null, round: null, diff --git a/packages/shared/src/events.ts b/packages/shared/src/events.ts index b3f35fe..747f6ff 100644 --- a/packages/shared/src/events.ts +++ b/packages/shared/src/events.ts @@ -14,7 +14,7 @@ import type { // --- Payloads ------------------------------------------------------------ export interface RoomCreatePayload { - playerName: string + playerName?: string } export interface RoomCreatedPayload { @@ -24,7 +24,11 @@ export interface RoomCreatedPayload { export interface RoomJoinPayload { roomCode: string - playerName: string + playerName?: string +} + +export interface SetNamePayload { + name: string } export interface UpdateSettingsPayload { @@ -126,6 +130,7 @@ export type Ack = (result: { ok: true; data: T } | { ok: false; error: ErrorP export interface ClientToServerEvents { "room:create": (payload: RoomCreatePayload, ack: Ack) => void "room:join": (payload: RoomJoinPayload, ack: Ack) => void + "player:setName": (payload: SetNamePayload) => void "lobby:updateSettings": (payload: UpdateSettingsPayload) => void "lobby:return": () => void "game:start": (ack: Ack) => void