feature/quiz-mode #4

Merged
ayoub merged 10 commits from feature/quiz-mode into dev 2026-06-10 09:54:24 +00:00
5 changed files with 49 additions and 5 deletions
Showing only changes of commit 2fce63530b - Show all commits

View file

@ -109,8 +109,9 @@ describe("GameEngine", () => {
const ack = emits.find((e) => e.event === "round:voteAck")!.payload as { const ack = emits.find((e) => e.event === "round:voteAck")!.payload as {
count: number count: number
total: number total: number
voted: string[]
} }
expect(ack).toEqual({ count: 1, total: 2 }) expect(ack).toEqual({ count: 1, total: 2, voted: [p1] })
engine.handleVote(p2, { choiceIndex: 0 }) engine.handleVote(p2, { choiceIndex: 0 })
await run await run

View file

@ -73,6 +73,7 @@ export class GameEngine implements RoomGameController {
this.io.to(this.room.code).emit("round:voteAck", { this.io.to(this.room.code).emit("round:voteAck", {
count: this.runtime.votes.size, count: this.runtime.votes.size,
total, total,
voted: [...this.runtime.votes.keys()],
}) })
// Fin anticipée : tous les éligibles ont voté. // Fin anticipée : tous les éligibles ont voté.
if (total > 0 && this.runtime.votes.size >= total) { if (total > 0 && this.runtime.votes.size >= total) {

View file

@ -1,14 +1,23 @@
import { AnimatePresence, motion } from "framer-motion" import { AnimatePresence, motion } from "framer-motion"
import { Crown } from "lucide-react" import { Check, Crown } from "lucide-react"
import type { RoomSnapshot } from "@nerdware/shared" import type { RoomSnapshot } from "@nerdware/shared"
interface PlayerCardsProps { interface PlayerCardsProps {
snapshot: RoomSnapshot snapshot: RoomSnapshot
playerId: string | null playerId: string | null
/** Affiche l'état de vote (manche en cours uniquement). */
showVoteStatus?: boolean
/** playerIds ayant déjà validé leur réponse. */
votedIds?: string[]
} }
/** HUD persistant : une card par joueur avec son score, couronne au leader. */ /** HUD persistant : une card par joueur avec son score, couronne au leader. */
export function PlayerCards({ snapshot, playerId }: PlayerCardsProps) { export function PlayerCards({
snapshot,
playerId,
showVoteStatus = false,
votedIds = [],
}: PlayerCardsProps) {
const scoreOf = (id: string) => const scoreOf = (id: string) =>
snapshot.scores.find((s) => s.playerId === id)?.score ?? 0 snapshot.scores.find((s) => s.playerId === id)?.score ?? 0
@ -16,6 +25,7 @@ export function PlayerCards({ snapshot, playerId }: PlayerCardsProps) {
const ranked = [...snapshot.players].sort( const ranked = [...snapshot.players].sort(
(a, b) => scoreOf(b.id) - scoreOf(a.id) (a, b) => scoreOf(b.id) - scoreOf(a.id)
) )
const voted = new Set(votedIds)
return ( return (
<div className="flex flex-wrap justify-center gap-2"> <div className="flex flex-wrap justify-center gap-2">
@ -24,6 +34,7 @@ export function PlayerCards({ snapshot, playerId }: PlayerCardsProps) {
const score = scoreOf(p.id) const score = scoreOf(p.id)
const isLeader = max > 0 && score === max const isLeader = max > 0 && score === max
const isMe = p.id === playerId const isMe = p.id === playerId
const hasVoted = voted.has(p.id)
return ( return (
<motion.div <motion.div
key={p.id} key={p.id}
@ -51,6 +62,7 @@ export function PlayerCards({ snapshot, playerId }: PlayerCardsProps) {
{p.name} {p.name}
{isMe && <span className="text-muted-foreground"> (toi)</span>} {isMe && <span className="text-muted-foreground"> (toi)</span>}
</span> </span>
<motion.span <motion.span
key={score} key={score}
initial={{ scale: 1.4 }} initial={{ scale: 1.4 }}
@ -60,6 +72,27 @@ export function PlayerCards({ snapshot, playerId }: PlayerCardsProps) {
> >
{score} {score}
</motion.span> </motion.span>
{showVoteStatus &&
(hasVoted ? (
<motion.span
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: "spring", stiffness: 600, damping: 18 }}
className="flex items-center gap-0.5 text-[10px] font-medium text-green-500"
>
<Check className="size-3" /> Prêt
</motion.span>
) : (
<span className="flex items-center gap-1 text-[10px]">
<motion.span
animate={{ opacity: [0.3, 1, 0.3] }}
transition={{ duration: 1.1, repeat: Infinity }}
className="bg-muted-foreground size-1.5 rounded-full"
/>
<span className="text-muted-foreground">en attente</span>
</span>
))}
</motion.div> </motion.div>
) )
})} })}

View file

@ -13,6 +13,7 @@ export function RoomPage({ code }: { code: string }) {
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 boomKey = useRoomStore((s) => s.boomKey) const boomKey = useRoomStore((s) => s.boomKey)
const voteProgress = useRoomStore((s) => s.voteProgress)
// Pas de snapshot pour ce code (accès direct / refresh) : retour à l'accueil. // Pas de snapshot pour ce code (accès direct / refresh) : retour à l'accueil.
if (!snapshot || snapshot.code !== code) { if (!snapshot || snapshot.code !== code) {
@ -45,7 +46,14 @@ export function RoomPage({ code }: { code: string }) {
/> />
</header> </header>
{inGame && <PlayerCards snapshot={snapshot} playerId={playerId} />} {inGame && (
<PlayerCards
snapshot={snapshot}
playerId={playerId}
showVoteStatus={snapshot.status === "in_round"}
votedIds={voteProgress?.voted}
/>
)}
{snapshot.status === "lobby" && <LobbyView snapshot={snapshot} />} {snapshot.status === "lobby" && <LobbyView snapshot={snapshot} />}
{inGame && <QuizView snapshot={snapshot} />} {inGame && <QuizView snapshot={snapshot} />}

View file

@ -73,10 +73,11 @@ export interface VotePayload {
answer: Answer answer: Answer
} }
/** Progression anonyme des votes. */ /** Progression des votes. `voted` = playerIds ayant validé (jamais leur réponse). */
export interface VoteAckPayload { export interface VoteAckPayload {
count: number count: number
total: number total: number
voted: string[]
} }
export interface RevealPayload { export interface RevealPayload {