feat(web): end-game awards, copy results, avatars in recap
- FunStats: award badges from the recap data — 🎯 Sans-faute, 🧠 Le cerveau (most correct), 🦹 Roi de la tromperie (blindtest decoy points), 🪨 Le boulet (0 correct). Shown with player avatars. - "Copier les résultats" button: copies ranking + per-round answers to clipboard - avatars next to each player in the round-by-round recap Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
331d375349
commit
976ebf0799
1 changed files with 137 additions and 3 deletions
|
|
@ -147,6 +147,29 @@ export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) {
|
|||
}
|
||||
}, [])
|
||||
|
||||
const [copied, setCopied] = useState(false)
|
||||
async function copyResults() {
|
||||
const lines = ["NerdWare — Résultats", "", "🏆 Classement"]
|
||||
ranked.forEach((s, i) =>
|
||||
lines.push(`${i + 1}. ${nameOf(s.playerId)} — ${s.score}`)
|
||||
)
|
||||
if (gameRecap && gameRecap.length > 0) {
|
||||
lines.push("", "Manches")
|
||||
gameRecap.forEach((r) =>
|
||||
lines.push(
|
||||
`${r.index + 1}. ${r.type === "blindtest" ? "🎧" : "🧠"} ${r.answer}`
|
||||
)
|
||||
)
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(lines.join("\n"))
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 1500)
|
||||
} catch {
|
||||
// presse-papier indisponible : on ignore
|
||||
}
|
||||
}
|
||||
|
||||
const scores: PlayerScore[] = finalScores ?? snapshot.scores
|
||||
const ranked = [...scores].sort((a, b) => b.score - a.score)
|
||||
const nameOf = (id: string) =>
|
||||
|
|
@ -227,6 +250,10 @@ export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) {
|
|||
</ol>
|
||||
)}
|
||||
|
||||
{gameRecap && gameRecap.length > 0 && (
|
||||
<FunStats recap={gameRecap} snapshot={snapshot} />
|
||||
)}
|
||||
|
||||
{gameTracks && gameTracks.length > 0 && (
|
||||
<TracksRecap tracks={gameTracks} />
|
||||
)}
|
||||
|
|
@ -236,6 +263,10 @@ export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) {
|
|||
)}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button variant="secondary" className="w-full" onClick={copyResults}>
|
||||
{copied ? <Check className="text-green-500" /> : <ClipboardList />}
|
||||
{copied ? "Copié !" : "Copier les résultats"}
|
||||
</Button>
|
||||
{isHost ? (
|
||||
<Button className="w-full" onClick={returnToLobby}>
|
||||
Rejouer
|
||||
|
|
@ -319,6 +350,106 @@ function TracksRecap({ tracks }: { tracks: BlindtestTrackInfo[] }) {
|
|||
)
|
||||
}
|
||||
|
||||
interface PlayerStat {
|
||||
correct: number
|
||||
answered: number
|
||||
decoy: number
|
||||
}
|
||||
|
||||
function FunStats({
|
||||
recap,
|
||||
snapshot,
|
||||
}: {
|
||||
recap: RoundRecap[]
|
||||
snapshot: RoomSnapshot
|
||||
}) {
|
||||
const stats = new Map<string, PlayerStat>(
|
||||
snapshot.players.map((p) => [p.id, { correct: 0, answered: 0, decoy: 0 }])
|
||||
)
|
||||
for (const r of recap) {
|
||||
const answered = new Set(r.answers.map((a) => a.playerId))
|
||||
for (const a of r.answers) {
|
||||
const s = stats.get(a.playerId)
|
||||
if (s) {
|
||||
s.answered++
|
||||
if (a.correct) s.correct++
|
||||
}
|
||||
}
|
||||
if (r.type === "blindtest") {
|
||||
for (const sc of r.scorers) {
|
||||
if (!answered.has(sc.playerId)) {
|
||||
const s = stats.get(sc.playerId)
|
||||
if (s) s.decoy += sc.points
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const players = snapshot.players
|
||||
const total = recap.length
|
||||
const nameOf = (id: string) => players.find((p) => p.id === id)?.name ?? "?"
|
||||
const stat = (id: string) => stats.get(id) ?? { correct: 0, answered: 0, decoy: 0 }
|
||||
const winners = (pick: (s: PlayerStat) => number, min = 1) => {
|
||||
const max = Math.max(0, ...players.map((p) => pick(stat(p.id))))
|
||||
return max >= min ? players.filter((p) => pick(stat(p.id)) === max) : []
|
||||
}
|
||||
|
||||
const awards: { emoji: string; label: string; ids: string[] }[] = []
|
||||
const perfect = players.filter(
|
||||
(p) => total > 0 && stat(p.id).correct === total
|
||||
)
|
||||
if (perfect.length) {
|
||||
awards.push({ emoji: "🎯", label: "Sans-faute", ids: perfect.map((p) => p.id) })
|
||||
}
|
||||
const brains = winners((s) => s.correct)
|
||||
if (brains.length && brains.length < players.length) {
|
||||
awards.push({ emoji: "🧠", label: "Le cerveau", ids: brains.map((p) => p.id) })
|
||||
}
|
||||
const decoy = winners((s) => s.decoy)
|
||||
if (decoy.length) {
|
||||
awards.push({
|
||||
emoji: "🦹",
|
||||
label: "Roi de la tromperie",
|
||||
ids: decoy.map((p) => p.id),
|
||||
})
|
||||
}
|
||||
const boulets = players.filter(
|
||||
(p) => stat(p.id).answered > 0 && stat(p.id).correct === 0
|
||||
)
|
||||
if (boulets.length && total >= 3) {
|
||||
awards.push({ emoji: "🪨", label: "Le boulet", ids: boulets.map((p) => p.id) })
|
||||
}
|
||||
|
||||
if (awards.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-2">
|
||||
<h3 className="text-muted-foreground text-sm font-medium">Récompenses</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{awards.map((a) => (
|
||||
<div
|
||||
key={a.label}
|
||||
className="bg-muted/40 flex flex-1 flex-col items-center gap-1 rounded-xl border px-3 py-2 text-center"
|
||||
>
|
||||
<span className="text-2xl">{a.emoji}</span>
|
||||
<span className="text-[10px] font-medium uppercase">{a.label}</span>
|
||||
<div className="flex flex-wrap items-center justify-center gap-1">
|
||||
{a.ids.map((id) => (
|
||||
<span key={id} className="flex items-center gap-1 text-xs">
|
||||
<Avatar seed={nameOf(id)} className="size-5" />
|
||||
{nameOf(id)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function RoundsRecap({
|
||||
recap,
|
||||
nameOf,
|
||||
|
|
@ -384,7 +515,11 @@ function RoundsRecap({
|
|||
</li>
|
||||
) : (
|
||||
answers.map((a) => (
|
||||
<li key={a.playerId} className="text-[10px]">
|
||||
<li
|
||||
key={a.playerId}
|
||||
className="flex items-center gap-1 text-[10px]"
|
||||
>
|
||||
<Avatar seed={nameOf(a.playerId)} className="size-4" />
|
||||
<span
|
||||
className={
|
||||
a.playerId === playerId
|
||||
|
|
@ -393,7 +528,7 @@ function RoundsRecap({
|
|||
}
|
||||
>
|
||||
{nameOf(a.playerId)}
|
||||
{a.playerId === playerId && " (toi)"} :{" "}
|
||||
{a.playerId === playerId && " (toi)"} :
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
|
|
@ -404,7 +539,6 @@ function RoundsRecap({
|
|||
</span>
|
||||
{pointsBy.get(a.playerId) ? (
|
||||
<span className="text-muted-foreground">
|
||||
{" "}
|
||||
+{pointsBy.get(a.playerId)}
|
||||
</span>
|
||||
) : null}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue