feature/quiz-mode #4
5 changed files with 49 additions and 5 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -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} />}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue