From f82c7d97003da5cbd3476d38a75b9f8ba787ff31 Mon Sep 17 00:00:00 2001 From: AyoubBenziza Date: Mon, 8 Jun 2026 15:56:57 +0200 Subject: [PATCH] feat(web): bomb countdown, copyable room code, persistent score cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Countdown: under 10s it ramps into a "bomb" — white→red color and an escalating scale/wiggle pulse (faster & wider as it nears 0) via Framer Motion - RoomCode: click-to-copy the room code with visual feedback - PlayerCards: always-on score HUD during the game (cards per player, animated reorder, crown on the leader) so scores are tracked continuously instead of only appearing at reveal - quiz view: drop the reveal-only scoreboard (now covered by PlayerCards) - add framer-motion dependency Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web/package.json | 1 + apps/web/src/components/countdown.tsx | 45 +++++++++++++++- apps/web/src/components/player-cards.tsx | 69 ++++++++++++++++++++++++ apps/web/src/components/quiz-view.tsx | 26 ++++----- apps/web/src/components/room-code.tsx | 39 ++++++++++++++ apps/web/src/pages/room.tsx | 22 ++++---- bun.lock | 7 +++ 7 files changed, 182 insertions(+), 27 deletions(-) create mode 100644 apps/web/src/components/player-cards.tsx create mode 100644 apps/web/src/components/room-code.tsx diff --git a/apps/web/package.json b/apps/web/package.json index 2c7c96e..5d5458d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -14,6 +14,7 @@ "dependencies": { "@nerdware/shared": "workspace:*", "@workspace/ui": "workspace:*", + "framer-motion": "^11", "lucide-react": "^1.17.0", "react": "^19.2.6", "react-dom": "^19.2.6", diff --git a/apps/web/src/components/countdown.tsx b/apps/web/src/components/countdown.tsx index fe53b7f..9f2012a 100644 --- a/apps/web/src/components/countdown.tsx +++ b/apps/web/src/components/countdown.tsx @@ -1,10 +1,25 @@ import { useEffect, useState } from "react" +import { motion } from "framer-motion" + +const DANGER_FROM = 10 // secondes : seuil d'entrée en mode "bombe" /** Secondes restantes jusqu'à `endsAt` (timestamp serveur). */ function secondsLeft(endsAt: number): number { return Math.max(0, Math.ceil((endsAt - Date.now()) / 1000)) } +function lerp(a: number, b: number, t: number): number { + return a + (b - a) * t +} + +/** Blanc → rouge sur les DANGER_FROM dernières secondes. */ +function dangerColor(t: number): string { + const r = Math.round(lerp(255, 239, t)) + const g = Math.round(lerp(255, 68, t)) + const b = Math.round(lerp(255, 68, t)) + return `rgb(${r}, ${g}, ${b})` +} + export function Countdown({ endsAt }: { endsAt: number }) { const [left, setLeft] = useState(() => secondsLeft(endsAt)) @@ -13,7 +28,35 @@ export function Countdown({ endsAt }: { endsAt: number }) { return () => clearInterval(id) }, [endsAt]) + const danger = left <= DANGER_FROM && left > 0 + // 0 (début du danger) → 1 (explosion imminente) + const intensity = danger ? Math.min(1, (DANGER_FROM - left) / DANGER_FROM) : 0 + + // Plus on approche de 0, plus le "battement" est ample et rapide. + const peak = 1.15 + intensity * 0.5 + const wiggle = 4 + intensity * 12 + const duration = 0.7 - intensity * 0.46 + return ( - {left} + + {danger && 💣} + {left} + ) } diff --git a/apps/web/src/components/player-cards.tsx b/apps/web/src/components/player-cards.tsx new file mode 100644 index 0000000..353116f --- /dev/null +++ b/apps/web/src/components/player-cards.tsx @@ -0,0 +1,69 @@ +import { AnimatePresence, motion } from "framer-motion" +import { Crown } from "lucide-react" +import type { RoomSnapshot } from "@nerdware/shared" + +interface PlayerCardsProps { + snapshot: RoomSnapshot + playerId: string | null +} + +/** HUD persistant : une card par joueur avec son score, couronne au leader. */ +export function PlayerCards({ snapshot, playerId }: PlayerCardsProps) { + const scoreOf = (id: string) => + snapshot.scores.find((s) => s.playerId === id)?.score ?? 0 + + const max = Math.max(0, ...snapshot.players.map((p) => scoreOf(p.id))) + const ranked = [...snapshot.players].sort( + (a, b) => scoreOf(b.id) - scoreOf(a.id) + ) + + return ( +
+ + {ranked.map((p) => { + const score = scoreOf(p.id) + const isLeader = max > 0 && score === max + const isMe = p.id === playerId + return ( + + {isLeader && ( + + + + )} + + {p.name} + {isMe && (toi)} + + + {score} + + + ) + })} + +
+ ) +} diff --git a/apps/web/src/components/quiz-view.tsx b/apps/web/src/components/quiz-view.tsx index d255a3b..cbc231a 100644 --- a/apps/web/src/components/quiz-view.tsx +++ b/apps/web/src/components/quiz-view.tsx @@ -6,7 +6,6 @@ import type { } from "@nerdware/shared" import { useRoomStore } from "@/store/room" import { Countdown } from "@/components/countdown" -import { Scoreboard } from "@/components/scoreboard" export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) { const round = useRoomStore((s) => s.round) @@ -91,22 +90,15 @@ export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) { ))} {showReveal && ( -
-

- {myResult?.correct - ? "Bonne réponse ! 🎉" - : myResult?.choiceIndex == null - ? "Pas de réponse 😴" - : "Raté 💥"} -

- -
+

+ {myResult?.correct + ? "Bonne réponse ! 🎉" + : myResult?.choiceIndex == null + ? "Pas de réponse 😴" + : "Raté 💥"} +

)} ) diff --git a/apps/web/src/components/room-code.tsx b/apps/web/src/components/room-code.tsx new file mode 100644 index 0000000..58142df --- /dev/null +++ b/apps/web/src/components/room-code.tsx @@ -0,0 +1,39 @@ +import { useState } from "react" +import { Check, Copy } from "lucide-react" + +/** Code de room cliquable : copie dans le presse-papier avec retour visuel. */ +export function RoomCode({ code }: { code: string }) { + const [copied, setCopied] = useState(false) + + async function copy() { + try { + await navigator.clipboard.writeText(code) + setCopied(true) + setTimeout(() => setCopied(false), 1500) + } catch { + // presse-papier indisponible (http non sécurisé) : on ignore silencieusement + } + } + + return ( + + ) +} diff --git a/apps/web/src/pages/room.tsx b/apps/web/src/pages/room.tsx index 949bd77..bb975dd 100644 --- a/apps/web/src/pages/room.tsx +++ b/apps/web/src/pages/room.tsx @@ -4,9 +4,12 @@ import { useRoomStore } from "@/store/room" import { LobbyView } from "@/components/lobby-view" import { QuizView } from "@/components/quiz-view" import { GameEndView } from "@/components/game-end-view" +import { PlayerCards } from "@/components/player-cards" +import { RoomCode } from "@/components/room-code" export function RoomPage({ code }: { code: string }) { const snapshot = useRoomStore((s) => s.snapshot) + const playerId = useRoomStore((s) => s.playerId) const connected = useRoomStore((s) => s.connected) // Pas de snapshot pour ce code (accès direct / refresh) : retour à l'accueil. @@ -23,26 +26,27 @@ export function RoomPage({ code }: { code: string }) { ) } + // Cards de scores visibles en permanence pendant le jeu (suivi continu). + const inGame = + snapshot.status === "in_round" || + snapshot.status === "reveal" || + snapshot.status === "scores" + return (
-
-

Code room

-

- {snapshot.code} -

-
+
+ {inGame && } + {snapshot.status === "lobby" && } - {(snapshot.status === "in_round" || - snapshot.status === "reveal" || - snapshot.status === "scores") && } + {inGame && } {snapshot.status === "ended" && }
diff --git a/bun.lock b/bun.lock index 515f883..8ff9b27 100644 --- a/bun.lock +++ b/bun.lock @@ -37,6 +37,7 @@ "dependencies": { "@nerdware/shared": "workspace:*", "@workspace/ui": "workspace:*", + "framer-motion": "^11", "lucide-react": "^1.17.0", "react": "^19.2.6", "react-dom": "^19.2.6", @@ -872,6 +873,8 @@ "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], + "framer-motion": ["framer-motion@11.18.2", "", { "dependencies": { "motion-dom": "^11.18.1", "motion-utils": "^11.18.1", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-5F5Och7wrvtLVElIpclDT0CBzMVg3dL22B64aZwHtsIY8RB4mXICLrkajK4G9R+ieSAGcgrLeae2SeUTg2pr6w=="], + "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], "fs-extra": ["fs-extra@11.3.5", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg=="], @@ -1076,6 +1079,10 @@ "mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="], + "motion-dom": ["motion-dom@11.18.1", "", { "dependencies": { "motion-utils": "^11.18.1" } }, "sha512-g76KvA001z+atjfxczdRtw/RXOM3OMSdd1f4DL77qCTF/+avrRJiawSG4yDibEQ215sr9kpinSlX2pCTJ9zbhw=="], + + "motion-utils": ["motion-utils@11.18.1", "", {}, "sha512-49Kt+HKjtbJKLtgO/LKj9Ld+6vw9BjH5d9sc40R/kVyH8GLAXgT42M2NnuPcJNuA3s9ZfZBUcwIgpmZWGEE+hA=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "msw": ["msw@2.14.6", "", { "dependencies": { "@inquirer/confirm": "^6.0.11", "@mswjs/interceptors": "^0.41.3", "@open-draft/deferred-promise": "^3.0.0", "@types/statuses": "^2.0.6", "cookie": "^1.1.1", "graphql": "^16.13.2", "headers-polyfill": "^5.0.1", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "path-to-regexp": "^6.3.0", "picocolors": "^1.1.1", "rettime": "^0.11.11", "statuses": "^2.0.2", "strict-event-emitter": "^0.5.1", "tough-cookie": "^6.0.1", "type-fest": "^5.5.0", "until-async": "^3.0.2", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": ">= 4.8.x" }, "optionalPeers": ["typescript"], "bin": { "msw": "cli/index.js" } }, "sha512-ALe+N10S72cyx94cMcy3Zs4HhXCj35sgeAL4c+WTvKi0zWnbd8/h0lcFqv0mb2P+aSgAdD7p9HzvA0DiUPxsyg=="],