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 scores: PlayerScore[] = finalScores ?? snapshot.scores
|
||||||
const ranked = [...scores].sort((a, b) => b.score - a.score)
|
const ranked = [...scores].sort((a, b) => b.score - a.score)
|
||||||
const nameOf = (id: string) =>
|
const nameOf = (id: string) =>
|
||||||
|
|
@ -227,6 +250,10 @@ export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
</ol>
|
</ol>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{gameRecap && gameRecap.length > 0 && (
|
||||||
|
<FunStats recap={gameRecap} snapshot={snapshot} />
|
||||||
|
)}
|
||||||
|
|
||||||
{gameTracks && gameTracks.length > 0 && (
|
{gameTracks && gameTracks.length > 0 && (
|
||||||
<TracksRecap tracks={gameTracks} />
|
<TracksRecap tracks={gameTracks} />
|
||||||
)}
|
)}
|
||||||
|
|
@ -236,6 +263,10 @@ export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<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 ? (
|
{isHost ? (
|
||||||
<Button className="w-full" onClick={returnToLobby}>
|
<Button className="w-full" onClick={returnToLobby}>
|
||||||
Rejouer
|
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({
|
function RoundsRecap({
|
||||||
recap,
|
recap,
|
||||||
nameOf,
|
nameOf,
|
||||||
|
|
@ -384,7 +515,11 @@ function RoundsRecap({
|
||||||
</li>
|
</li>
|
||||||
) : (
|
) : (
|
||||||
answers.map((a) => (
|
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
|
<span
|
||||||
className={
|
className={
|
||||||
a.playerId === playerId
|
a.playerId === playerId
|
||||||
|
|
@ -393,7 +528,7 @@ function RoundsRecap({
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{nameOf(a.playerId)}
|
{nameOf(a.playerId)}
|
||||||
{a.playerId === playerId && " (toi)"} :{" "}
|
{a.playerId === playerId && " (toi)"} :
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className={
|
className={
|
||||||
|
|
@ -404,7 +539,6 @@ function RoundsRecap({
|
||||||
</span>
|
</span>
|
||||||
{pointsBy.get(a.playerId) ? (
|
{pointsBy.get(a.playerId) ? (
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
{" "}
|
|
||||||
+{pointsBy.get(a.playerId)}
|
+{pointsBy.get(a.playerId)}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue