release/0.1.0 #18
8 changed files with 187 additions and 49 deletions
|
|
@ -72,7 +72,18 @@ export class GameEngine implements RoomGameController {
|
|||
}
|
||||
this.room.status = "ended"
|
||||
this.broadcastState()
|
||||
this.io.to(this.room.code).emit("game:end", { finalScores: this.scoreboard() })
|
||||
// Récap des titres blindtest joués (pour récupérer les liens YouTube).
|
||||
const tracks = this.room.blindtestTracks.map((t) => ({
|
||||
title: t.title,
|
||||
artist: t.artist,
|
||||
youtubeId: t.youtubeId,
|
||||
url: t.url,
|
||||
submittedByName: this.room.players.get(t.submittedBy)?.name ?? "?",
|
||||
}))
|
||||
this.io.to(this.room.code).emit("game:end", {
|
||||
finalScores: this.scoreboard(),
|
||||
tracks: tracks.length > 0 ? tracks : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
/** Vote d'un joueur pendant une manche. Délègue à l'épreuve, puis check fin anticipée. */
|
||||
|
|
|
|||
|
|
@ -295,23 +295,22 @@ function VoteForm({
|
|||
)}
|
||||
{needsWho && (
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
{snapshot.players.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => setGuessed(p.id)}
|
||||
className={`flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-sm ${
|
||||
guessed === p.id
|
||||
? "border-primary bg-primary/10"
|
||||
: "border-input hover:bg-muted/60"
|
||||
}`}
|
||||
>
|
||||
<Avatar seed={p.name} className="size-5" />
|
||||
{p.name}
|
||||
{p.id === playerId && (
|
||||
<span className="text-muted-foreground">(toi)</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{snapshot.players
|
||||
.filter((p) => p.id !== playerId)
|
||||
.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => setGuessed(p.id)}
|
||||
className={`flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-sm ${
|
||||
guessed === p.id
|
||||
? "border-primary bg-primary/10"
|
||||
: "border-input hover:bg-muted/60"
|
||||
}`}
|
||||
>
|
||||
<Avatar seed={p.name} className="size-5" />
|
||||
{p.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Button disabled={!canSubmit} onClick={submit}>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Link } from "wouter"
|
||||
import { motion } from "framer-motion"
|
||||
import { Crown } from "lucide-react"
|
||||
import { Crown, ExternalLink, Music } from "lucide-react"
|
||||
import type { PlayerScore, RoomSnapshot } from "@nerdware/shared"
|
||||
import { Button } from "@workspace/ui/components/button"
|
||||
import { useRoomStore } from "@/store/room"
|
||||
|
|
@ -112,6 +112,7 @@ function PodiumColumn({
|
|||
export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||
const playerId = useRoomStore((s) => s.playerId)
|
||||
const finalScores = useRoomStore((s) => s.finalScores)
|
||||
const gameTracks = useRoomStore((s) => s.gameTracks)
|
||||
const reset = useRoomStore((s) => s.reset)
|
||||
const returnToLobby = useRoomStore((s) => s.returnToLobby)
|
||||
const isHost = snapshot.hostId === playerId
|
||||
|
|
@ -196,6 +197,46 @@ export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) {
|
|||
</ol>
|
||||
)}
|
||||
|
||||
{gameTracks && gameTracks.length > 0 && (
|
||||
<section className="flex flex-col gap-2 text-left">
|
||||
<h3 className="text-muted-foreground flex items-center gap-1.5 text-sm font-medium">
|
||||
<Music className="size-4" /> Les musiques de la partie
|
||||
</h3>
|
||||
<ul className="flex flex-col gap-2">
|
||||
{gameTracks.map((t) => (
|
||||
<li
|
||||
key={t.youtubeId}
|
||||
className="bg-muted/40 flex items-center gap-3 rounded-lg p-2"
|
||||
>
|
||||
<img
|
||||
src={`https://img.youtube.com/vi/${t.youtubeId}/mqdefault.jpg`}
|
||||
alt=""
|
||||
className="h-10 w-16 shrink-0 rounded object-cover"
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="line-clamp-1 text-xs font-medium">
|
||||
{t.title}
|
||||
</span>
|
||||
<span className="text-muted-foreground line-clamp-1 text-[10px]">
|
||||
ajouté par {t.submittedByName}
|
||||
</span>
|
||||
</span>
|
||||
<a
|
||||
href={t.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title="Ouvrir sur YouTube"
|
||||
>
|
||||
<Button size="icon-sm" variant="secondary">
|
||||
<ExternalLink />
|
||||
</Button>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{isHost ? (
|
||||
<Button className="w-full" onClick={returnToLobby}>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,14 @@
|
|||
import { useState } from "react"
|
||||
import { Trash2 } from "lucide-react"
|
||||
import {
|
||||
Brain,
|
||||
Headphones,
|
||||
ListMusic,
|
||||
Music,
|
||||
Play,
|
||||
Shuffle,
|
||||
Trash2,
|
||||
type LucideIcon,
|
||||
} from "lucide-react"
|
||||
import type {
|
||||
BlindtestMode,
|
||||
GameType,
|
||||
|
|
@ -12,10 +21,10 @@ import { Avatar } from "@/components/avatar"
|
|||
|
||||
const QUESTION_OPTIONS = [3, 5, 10]
|
||||
const TRACKS_OPTIONS = [1, 2, 3]
|
||||
const GAME_TYPES: { value: GameType; label: string }[] = [
|
||||
{ value: "mixed", label: "Mixte" },
|
||||
{ value: "quiz", label: "Quiz" },
|
||||
{ value: "blindtest", label: "Blindtest" },
|
||||
const GAME_TYPES: { value: GameType; label: string; Icon: LucideIcon }[] = [
|
||||
{ value: "mixed", label: "Mixte", Icon: Shuffle },
|
||||
{ value: "quiz", label: "Quiz", Icon: Brain },
|
||||
{ value: "blindtest", label: "Blindtest", Icon: Headphones },
|
||||
]
|
||||
const MODE_LABELS: Record<BlindtestMode, string> = {
|
||||
title_artist: "Titre & artiste",
|
||||
|
|
@ -128,18 +137,18 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
|
|||
{isHost && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex gap-2">
|
||||
{GAME_TYPES.map((t) => {
|
||||
const needsThree = t.value !== "quiz" && !blindtestAvailable
|
||||
{GAME_TYPES.map(({ value, label, Icon }) => {
|
||||
const needsThree = value !== "quiz" && !blindtestAvailable
|
||||
return (
|
||||
<Button
|
||||
key={t.value}
|
||||
key={value}
|
||||
className="flex-1"
|
||||
variant={effectiveType === t.value ? "default" : "secondary"}
|
||||
variant={effectiveType === value ? "default" : "secondary"}
|
||||
disabled={needsThree}
|
||||
title={needsThree ? "Blindtest : 3 joueurs minimum" : undefined}
|
||||
onClick={() => updateSettings({ gameType: t.value })}
|
||||
onClick={() => updateSettings({ gameType: value })}
|
||||
>
|
||||
{t.label}
|
||||
<Icon /> {label}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
|
|
@ -154,7 +163,9 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
|
|||
|
||||
{isHost && showQuiz && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Questions de quiz</span>
|
||||
<span className="flex items-center gap-1.5 text-sm font-medium">
|
||||
<Brain className="size-4" /> Questions de quiz
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
{QUESTION_OPTIONS.map((n) => (
|
||||
<Button
|
||||
|
|
@ -173,7 +184,9 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
|
|||
{isHost && showBlindtest && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-sm font-medium">Mode blindtest</span>
|
||||
<span className="flex items-center gap-1.5 text-sm font-medium">
|
||||
<Music className="size-4" /> Mode blindtest
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
{(["title_artist", "who_added", "mixed"] as const).map((m) => (
|
||||
<Button
|
||||
|
|
@ -189,7 +202,9 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
|
|||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Titres par joueur</span>
|
||||
<span className="flex items-center gap-1.5 text-sm font-medium">
|
||||
<ListMusic className="size-4" /> Titres par joueur
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
{TRACKS_OPTIONS.map((n) => (
|
||||
<Button
|
||||
|
|
@ -212,7 +227,7 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
|
|||
|
||||
{isHost ? (
|
||||
<Button disabled={busy || !canStart} onClick={start}>
|
||||
{busy ? "Lancement…" : `Lancer (${rounds.length} manches)`}
|
||||
<Play /> {busy ? "Lancement…" : `Lancer (${rounds.length} manches)`}
|
||||
</Button>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-center text-xs">
|
||||
|
|
|
|||
|
|
@ -1,11 +1,23 @@
|
|||
import { useState } from "react"
|
||||
import { useLocation } from "wouter"
|
||||
import { motion } from "framer-motion"
|
||||
import { LogIn, Sparkles } from "lucide-react"
|
||||
import { Button } from "@workspace/ui/components/button"
|
||||
import { useRoomStore } from "@/store/room"
|
||||
|
||||
const inputClass =
|
||||
"border-input bg-background ring-offset-background focus-visible:ring-ring h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50"
|
||||
|
||||
// Emojis geek qui flottent en fond.
|
||||
const FLOATERS = [
|
||||
{ emoji: "🎮", x: "8%", y: "18%", delay: 0 },
|
||||
{ emoji: "🎧", x: "82%", y: "22%", delay: 0.6 },
|
||||
{ emoji: "🧠", x: "15%", y: "72%", delay: 1.2 },
|
||||
{ emoji: "👾", x: "78%", y: "70%", delay: 0.3 },
|
||||
{ emoji: "🕹️", x: "45%", y: "12%", delay: 0.9 },
|
||||
{ emoji: "🎬", x: "60%", y: "82%", delay: 1.5 },
|
||||
]
|
||||
|
||||
export function HomePage() {
|
||||
const [, navigate] = useLocation()
|
||||
const createRoom = useRoomStore((s) => s.createRoom)
|
||||
|
|
@ -43,20 +55,63 @@ export function HomePage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-svh items-center justify-center p-6">
|
||||
<div className="flex w-full max-w-sm flex-col gap-6">
|
||||
<div className="relative flex min-h-svh items-center justify-center overflow-hidden p-6">
|
||||
{/* Halo animé */}
|
||||
<motion.div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute -z-10 size-[120vmin] rounded-full bg-gradient-to-br from-fuchsia-500/20 via-purple-500/10 to-cyan-400/20 blur-3xl"
|
||||
animate={{ scale: [1, 1.15, 1], rotate: [0, 30, 0] }}
|
||||
transition={{ duration: 18, repeat: Infinity, ease: "easeInOut" }}
|
||||
/>
|
||||
{/* Emojis flottants */}
|
||||
{FLOATERS.map((f) => (
|
||||
<motion.span
|
||||
key={f.emoji}
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute -z-10 text-4xl opacity-40 select-none sm:text-5xl"
|
||||
style={{ left: f.x, top: f.y }}
|
||||
animate={{ y: [0, -18, 0], rotate: [-8, 8, -8] }}
|
||||
transition={{
|
||||
duration: 5,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut",
|
||||
delay: f.delay,
|
||||
}}
|
||||
>
|
||||
{f.emoji}
|
||||
</motion.span>
|
||||
))}
|
||||
|
||||
<motion.div
|
||||
className="flex w-full max-w-sm flex-col gap-6"
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, ease: "easeOut" }}
|
||||
>
|
||||
<header className="text-center">
|
||||
<h1 className="font-heading text-3xl font-bold tracking-tight">
|
||||
<motion.h1
|
||||
className="font-heading bg-gradient-to-r from-fuchsia-500 via-purple-500 to-cyan-400 bg-clip-text text-5xl font-black tracking-tight text-transparent"
|
||||
initial={{ scale: 0.6, rotate: -6, opacity: 0 }}
|
||||
animate={{ scale: 1, rotate: 0, opacity: 1 }}
|
||||
transition={{ type: "spring", stiffness: 260, damping: 14 }}
|
||||
>
|
||||
NerdWare
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Party game culture geek
|
||||
</motion.h1>
|
||||
<p className="text-muted-foreground mt-1 flex items-center justify-center gap-1 text-sm">
|
||||
<Sparkles className="size-3.5" /> Party game culture geek
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<Button disabled={!connected || busy} onClick={handleCreate}>
|
||||
Créer une room
|
||||
</Button>
|
||||
<motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
|
||||
<Button
|
||||
size="lg"
|
||||
className="w-full"
|
||||
disabled={!connected || busy}
|
||||
onClick={handleCreate}
|
||||
>
|
||||
<Sparkles /> Créer une room
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-border h-px flex-1" />
|
||||
|
|
@ -66,8 +121,8 @@ export function HomePage() {
|
|||
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
className={`${inputClass} uppercase`}
|
||||
placeholder="Code"
|
||||
className={`${inputClass} text-center text-lg font-bold tracking-[0.3em] uppercase`}
|
||||
placeholder="CODE"
|
||||
value={code}
|
||||
maxLength={4}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
|
|
@ -78,7 +133,7 @@ export function HomePage() {
|
|||
disabled={!connected || busy || code.trim().length === 0}
|
||||
onClick={handleJoin}
|
||||
>
|
||||
Rejoindre
|
||||
<LogIn /> Rejoindre
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
|
@ -90,7 +145,7 @@ export function HomePage() {
|
|||
{error && (
|
||||
<p className="text-destructive text-center text-sm">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { create } from "zustand"
|
|||
import {
|
||||
DEFAULT_ROOM_SETTINGS,
|
||||
type BlindtestAnswer,
|
||||
type BlindtestTrackInfo,
|
||||
type ErrorPayload,
|
||||
type MediaControlPayload,
|
||||
type MediaSyncPayload,
|
||||
|
|
@ -49,6 +50,7 @@ interface RoomState {
|
|||
myChoiceIndex: number | null
|
||||
hasVoted: boolean
|
||||
finalScores: PlayerScore[] | null
|
||||
gameTracks: BlindtestTrackInfo[] | null
|
||||
mediaSync: MediaSyncPayload | null
|
||||
boomKey: number
|
||||
roundKey: number
|
||||
|
|
@ -85,6 +87,7 @@ export const useRoomStore = create<RoomState>((set, get) => ({
|
|||
myChoiceIndex: null,
|
||||
hasVoted: false,
|
||||
finalScores: null,
|
||||
gameTracks: null,
|
||||
mediaSync: null,
|
||||
boomKey: 0,
|
||||
roundKey: 0,
|
||||
|
|
@ -213,6 +216,7 @@ export const useRoomStore = create<RoomState>((set, get) => ({
|
|||
myChoiceIndex: null,
|
||||
hasVoted: false,
|
||||
finalScores: null,
|
||||
gameTracks: null,
|
||||
mediaSync: null,
|
||||
boomKey: 0,
|
||||
roundKey: 0,
|
||||
|
|
@ -234,6 +238,7 @@ socket.on("room:state", (snapshot) =>
|
|||
myChoiceIndex: null,
|
||||
hasVoted: false,
|
||||
finalScores: null,
|
||||
gameTracks: null,
|
||||
mediaSync: null,
|
||||
}
|
||||
: { snapshot }
|
||||
|
|
@ -265,6 +270,6 @@ socket.on("round:voteAck", (voteProgress) =>
|
|||
)
|
||||
socket.on("round:reveal", (reveal) => useRoomStore.setState({ reveal }))
|
||||
socket.on("media:sync", (mediaSync) => useRoomStore.setState({ mediaSync }))
|
||||
socket.on("game:end", ({ finalScores }) =>
|
||||
useRoomStore.setState({ finalScores })
|
||||
socket.on("game:end", ({ finalScores, tracks }) =>
|
||||
useRoomStore.setState({ finalScores, gameTracks: tracks ?? null })
|
||||
)
|
||||
|
|
|
|||
|
|
@ -26,3 +26,12 @@ export interface BlindtestPlayerResult {
|
|||
|
||||
/** round:reveal → perPlayerResult : playerId → résultat. */
|
||||
export type BlindtestPerPlayerResult = Record<string, BlindtestPlayerResult>
|
||||
|
||||
/** Titre récapitulé en fin de partie (export / récupération des liens). */
|
||||
export interface BlindtestTrackInfo {
|
||||
title: string
|
||||
artist: string
|
||||
youtubeId: string
|
||||
url: string
|
||||
submittedByName: string
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import type {
|
|||
RoundConfig,
|
||||
RoundType,
|
||||
} from "./domain"
|
||||
import type { BlindtestTrackInfo } from "./blindtest"
|
||||
|
||||
// --- Payloads ------------------------------------------------------------
|
||||
|
||||
|
|
@ -113,6 +114,8 @@ export interface ScoreUpdatePayload {
|
|||
|
||||
export interface GameEndPayload {
|
||||
finalScores: PlayerScore[]
|
||||
/** Titres joués (si la partie comportait du blindtest) — pour récupérer les liens. */
|
||||
tracks?: BlindtestTrackInfo[]
|
||||
spotifyExport?: unknown
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue