diff --git a/src/App.tsx b/src/App.tsx index e94951b..9724c3b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -12,6 +12,7 @@ import Locations from "./pages/Locations"; import Profiles from "./pages/Profiles"; import Friends from "./pages/Friends"; import Games from "./pages/Games"; +import RandomPick from "./pages/RandomPick"; import ImportedGames from "./pages/ImportedGames"; import Auth from "./pages/Auth"; import Settings from "./pages/Settings"; @@ -44,6 +45,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/pages/Games.tsx b/src/pages/Games.tsx index 4fcc558..154f458 100644 --- a/src/pages/Games.tsx +++ b/src/pages/Games.tsx @@ -1,6 +1,6 @@ import { useNavigate } from "react-router-dom"; import * as Icons from "lucide-react"; -import { Puzzle } from "lucide-react"; +import { Puzzle, Dices, ChevronRight } from "lucide-react"; import { motion } from "framer-motion"; import { useAllGames } from "../games"; import { PageHeader } from "../components/PageHeader"; @@ -14,6 +14,26 @@ export default function Games() {
+ {/* Choisir un jeu par vote + tirage au sort */} + navigate("/tirage")} + className="w-full flex items-center gap-3 p-4 rounded-[1.75rem] bg-primary/10 dark:bg-primary/20 border border-primary/20 shadow-sm text-left" + > +
+ +
+
+

+ On joue à quoi ? +

+

+ Chacun vote, le sort tranche +

+
+ +
+
{games.map((game, index) => { const Icon = (Icons as any)[game.icon || "Box"] || Icons.Box; diff --git a/src/pages/RandomPick.tsx b/src/pages/RandomPick.tsx new file mode 100644 index 0000000..a13101f --- /dev/null +++ b/src/pages/RandomPick.tsx @@ -0,0 +1,393 @@ +import { useState, useRef, useEffect } from "react"; +import { useNavigate } from "react-router-dom"; +import * as Icons from "lucide-react"; +import { Dices, Minus, Plus, Play, RotateCcw, Users, Crown } from "lucide-react"; +import { motion, AnimatePresence } from "framer-motion"; +import confetti from "canvas-confetti"; +import { useAllGames } from "../games"; +import { GameConfig } from "../types"; +import { PageHeader } from "../components/PageHeader"; +import { Button } from "../components/ui/button"; + +type Step = "setup" | "vote" | "reveal"; + +// Small square game tile shared by the vote grid and the reveal animation. +function GameTile({ + game, + size = "md", +}: { + game: GameConfig; + size?: "md" | "lg"; +}) { + const Icon = (Icons as any)[game.icon || "Box"] || Icons.Box; + const box = + size === "lg" ? "w-40 h-40 rounded-[2rem]" : "w-full aspect-square rounded-[1.5rem]"; + return ( +
+ {game.imagePath ? ( + {game.name} + ) : ( + + )} +
+ ); +} + +export default function RandomPick() { + const navigate = useNavigate(); + const games = useAllGames(); + + const [step, setStep] = useState("setup"); + const [playerCount, setPlayerCount] = useState(3); + const [votes, setVotes] = useState([]); + const [winnerId, setWinnerId] = useState(null); + const [byMajority, setByMajority] = useState(false); + const [spinningId, setSpinningId] = useState(null); + const timers = useRef([]); + + useEffect( + () => () => timers.current.forEach((t) => clearTimeout(t)), + [], + ); + + // Games that can actually be played with this many players (fallback: all). + const eligible = games.filter( + (g) => playerCount >= g.minPlayers && playerCount <= g.maxPlayers, + ); + const votableGames = eligible.length > 0 ? eligible : games; + + const gameById = (id: string) => games.find((g) => g.id === id); + const winner = winnerId ? gameById(winnerId) : undefined; + + const reset = () => { + timers.current.forEach((t) => clearTimeout(t)); + timers.current = []; + setVotes([]); + setWinnerId(null); + setSpinningId(null); + setStep("setup"); + }; + + const castVote = (gameId: string) => { + const next = [...votes, gameId]; + setVotes(next); + if (next.length >= playerCount) startReveal(next); + }; + + const startReveal = (finalVotes: string[]) => { + // Tally the votes + const tally = new Map(); + finalVotes.forEach((id) => tally.set(id, (tally.get(id) ?? 0) + 1)); + const candidates = [...tally.keys()]; + + // Strict majority (> 50%), otherwise a draw among the voted games + const majority = [...tally.entries()].find( + ([, n]) => n > finalVotes.length / 2, + ); + const picked = majority + ? majority[0] + : candidates[Math.floor(Math.random() * candidates.length)]; + + setStep("reveal"); + + // Slot-machine animation: cycle through the candidates, slowing down. + const reel = candidates.length > 1 ? candidates : votableGames.map((g) => g.id); + let delay = 70; + let elapsed = 0; + let i = 0; + const tick = () => { + setSpinningId(reel[i % reel.length]); + i++; + elapsed += delay; + delay *= 1.13; // ease-out + if (elapsed < 2200) { + timers.current.push(window.setTimeout(tick, delay)); + } else { + timers.current.push( + window.setTimeout(() => { + setSpinningId(picked); + setWinnerId(picked); + setByMajority(Boolean(majority)); + confetti({ + particleCount: 90, + spread: 75, + origin: { y: 0.35 }, + colors: ["#14b8a6", "#22d3ee", "#fde68a", "#a5f3fc"], + }); + }, delay), + ); + } + }; + tick(); + }; + + const tallyEntries = () => { + const tally = new Map(); + votes.forEach((id) => tally.set(id, (tally.get(id) ?? 0) + 1)); + return [...tally.entries()].sort((a, b) => b[1] - a[1]); + }; + + return ( +
+ + + + {/* ---------- 1. Nombre de joueurs ---------- */} + {step === "setup" && ( + +
+
+ +
+
+

+ Combien de joueurs ? +

+

+ Chacun votera pour un jeu, puis le sort décidera. +

+
+ +
+ + + {playerCount} + + +
+ +

+ {votableGames.length} jeu{votableGames.length > 1 ? "x" : ""}{" "} + {eligible.length > 0 ? "compatible" : "disponible"} + {votableGames.length > 1 ? "s" : ""} +

+
+ + +
+ )} + + {/* ---------- 2. Vote de chaque joueur ---------- */} + {step === "vote" && ( + +
+
+ {votes.length + 1} +
+
+

+ Joueur {votes.length + 1}, à toi ! +

+

+ Choisis ton jeu · {votes.length}/{playerCount} vote + {votes.length > 1 ? "s" : ""} +

+
+ +
+ + {/* Barre de progression des votes */} +
+ +
+ +
+ {votableGames.map((game) => ( + castVote(game.id)} + className="flex flex-col items-center gap-2" + > + + + {game.name} + + + ))} +
+ + +
+ )} + + {/* ---------- 3. Révélation ---------- */} + {step === "reveal" && ( + +
+ {!winner ? ( + <> +

+ Tirage en cours… +

+ + {spinningId && gameById(spinningId) && ( + + )} + +

+ {spinningId ? gameById(spinningId)?.name : ""} +

+ + ) : ( + <> + +
+ + {byMajority ? "Élu à la majorité" : "Tiré au sort"} +
+ +

+ {winner.name} +

+

+ {byMajority + ? `${tallyEntries()[0]?.[1]} voix sur ${playerCount}` + : `Tirage parmi ${tallyEntries().length} jeux à égalité`} +

+
+ + )} +
+ + {/* Détail des votes */} + {winner && ( + +

+ Votes +

+ {tallyEntries().map(([id, n]) => { + const g = gameById(id); + if (!g) return null; + return ( +
+ + + {g.name} + + + {n} + +
+ ); + })} +
+ )} + + {winner && ( + + + + + )} +
+ )} +
+
+ ); +}