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 (
-
- setName(e.target.value)}
- />
-
-
+
Créer une room
@@ -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()}
/>
Rejoindre
diff --git a/apps/web/src/pages/join.tsx b/apps/web/src/pages/join.tsx
new file mode 100644
index 0000000..bf8d624
--- /dev/null
+++ b/apps/web/src/pages/join.tsx
@@ -0,0 +1,42 @@
+import { useEffect, useRef, useState } from "react"
+import { Link, useLocation } from "wouter"
+import { Button } from "@workspace/ui/components/button"
+import { useRoomStore } from "@/store/room"
+
+/** Lien d'invitation /join/:code → rejoint la room puis redirige vers le lobby. */
+export function JoinPage({ code }: { code: string }) {
+ const [, navigate] = useLocation()
+ const joinRoom = useRoomStore((s) => s.joinRoom)
+ const connected = useRoomStore((s) => s.connected)
+ const [error, setError] = useState(null)
+ const tried = useRef(false)
+
+ useEffect(() => {
+ if (!connected || tried.current) {
+ return
+ }
+ tried.current = true
+ joinRoom(code)
+ .then(({ roomCode }) => navigate(`/room/${roomCode}`, { replace: true }))
+ .catch((err) =>
+ setError((err as { message?: string }).message ?? "Room introuvable.")
+ )
+ }, [connected, code, joinRoom, navigate])
+
+ return (
+
+ {error ? (
+ <>
+
{error}
+
+
Retour à l'accueil
+
+ >
+ ) : (
+
+ 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 (
+
+ )
+ }
+
// 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