feat(web): bomb countdown, copyable room code, persistent score cards
- 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) <noreply@anthropic.com>
This commit is contained in:
parent
5cc9113b8c
commit
f82c7d9700
7 changed files with 182 additions and 27 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<span className="font-heading text-2xl font-bold tabular-nums">{left}</span>
|
||||
<motion.span
|
||||
className="font-heading inline-flex items-center gap-1 text-2xl font-bold tabular-nums"
|
||||
style={{ color: danger ? dangerColor(intensity) : undefined }}
|
||||
animate={
|
||||
danger
|
||||
? {
|
||||
scale: [1, peak, 0.92, peak * 0.97, 1],
|
||||
rotate: [0, -wiggle, wiggle, -wiggle * 0.6, 0],
|
||||
}
|
||||
: { scale: 1, rotate: 0 }
|
||||
}
|
||||
transition={
|
||||
danger
|
||||
? { duration, repeat: Infinity, ease: "easeInOut" }
|
||||
: { duration: 0.2 }
|
||||
}
|
||||
>
|
||||
{danger && <span aria-hidden>💣</span>}
|
||||
{left}
|
||||
</motion.span>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
69
apps/web/src/components/player-cards.tsx
Normal file
69
apps/web/src/components/player-cards.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
<AnimatePresence initial={false}>
|
||||
{ranked.map((p) => {
|
||||
const score = scoreOf(p.id)
|
||||
const isLeader = max > 0 && score === max
|
||||
const isMe = p.id === playerId
|
||||
return (
|
||||
<motion.div
|
||||
key={p.id}
|
||||
layout
|
||||
transition={{ type: "spring", stiffness: 500, damping: 40 }}
|
||||
className={`relative flex min-w-20 flex-col items-center gap-0.5 rounded-xl border px-3 py-2 ${
|
||||
isLeader
|
||||
? "border-amber-400/60 bg-amber-400/10"
|
||||
: "border-input bg-muted/40"
|
||||
} ${isMe ? "ring-primary ring-2" : ""} ${
|
||||
p.connected ? "" : "opacity-40"
|
||||
}`}
|
||||
>
|
||||
{isLeader && (
|
||||
<motion.span
|
||||
initial={{ scale: 0, rotate: -30 }}
|
||||
animate={{ scale: 1, rotate: 0 }}
|
||||
transition={{ type: "spring", stiffness: 600, damping: 20 }}
|
||||
className="absolute -top-2.5 text-amber-400"
|
||||
>
|
||||
<Crown className="size-4 fill-amber-400" />
|
||||
</motion.span>
|
||||
)}
|
||||
<span className="max-w-24 truncate text-xs font-medium">
|
||||
{p.name}
|
||||
{isMe && <span className="text-muted-foreground"> (toi)</span>}
|
||||
</span>
|
||||
<motion.span
|
||||
key={score}
|
||||
initial={{ scale: 1.4 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ type: "spring", stiffness: 500, damping: 25 }}
|
||||
className="font-heading text-lg font-bold tabular-nums"
|
||||
>
|
||||
{score}
|
||||
</motion.span>
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<p
|
||||
className={`text-center text-sm font-medium ${myResult?.correct ? "text-green-500" : "text-red-500"}`}
|
||||
>
|
||||
{myResult?.correct
|
||||
? "Bonne réponse ! 🎉"
|
||||
: myResult?.choiceIndex == null
|
||||
? "Pas de réponse 😴"
|
||||
: "Raté 💥"}
|
||||
</p>
|
||||
<Scoreboard
|
||||
scores={snapshot.scores}
|
||||
snapshot={snapshot}
|
||||
playerId={playerId}
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
className={`text-center text-sm font-medium ${myResult?.correct ? "text-green-500" : "text-red-500"}`}
|
||||
>
|
||||
{myResult?.correct
|
||||
? "Bonne réponse ! 🎉"
|
||||
: myResult?.choiceIndex == null
|
||||
? "Pas de réponse 😴"
|
||||
: "Raté 💥"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
39
apps/web/src/components/room-code.tsx
Normal file
39
apps/web/src/components/room-code.tsx
Normal file
|
|
@ -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 (
|
||||
<button
|
||||
onClick={copy}
|
||||
title="Copier le code"
|
||||
className="group flex items-center gap-2 text-left"
|
||||
>
|
||||
<span>
|
||||
<span className="text-muted-foreground text-xs uppercase">
|
||||
Code room
|
||||
</span>
|
||||
<span className="font-heading block text-2xl font-bold tracking-widest">
|
||||
{code}
|
||||
</span>
|
||||
</span>
|
||||
{copied ? (
|
||||
<Check className="size-4 text-green-500" />
|
||||
) : (
|
||||
<Copy className="text-muted-foreground group-hover:text-foreground size-4 transition-colors" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div className="flex min-h-svh items-start justify-center p-6 pt-12">
|
||||
<div className="flex w-full max-w-md flex-col gap-6">
|
||||
<header className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-muted-foreground text-xs uppercase">Code room</p>
|
||||
<p className="font-heading text-2xl font-bold tracking-widest">
|
||||
{snapshot.code}
|
||||
</p>
|
||||
</div>
|
||||
<RoomCode code={snapshot.code} />
|
||||
<span
|
||||
className={`size-2.5 rounded-full ${connected ? "bg-green-500" : "bg-red-500"}`}
|
||||
title={connected ? "Connecté" : "Déconnecté"}
|
||||
/>
|
||||
</header>
|
||||
|
||||
{inGame && <PlayerCards snapshot={snapshot} playerId={playerId} />}
|
||||
|
||||
{snapshot.status === "lobby" && <LobbyView snapshot={snapshot} />}
|
||||
{(snapshot.status === "in_round" ||
|
||||
snapshot.status === "reveal" ||
|
||||
snapshot.status === "scores") && <QuizView snapshot={snapshot} />}
|
||||
{inGame && <QuizView snapshot={snapshot} />}
|
||||
{snapshot.status === "ended" && <GameEndView snapshot={snapshot} />}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
7
bun.lock
7
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=="],
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue