nerdware/apps/web/src/store/room.ts
AyoubBenziza 7dda00c65c feat: lead-in timer compensation + GIF support for round transitions
Server (option 2 — don't eat answer time):
- engine: leadMs prep delay (default 1600ms) before the clock starts; the
  round timer now fires at lead + duration, and scoring uses the post-lead
  start so the speed bonus spans the full answer window
- shared: round:start carries startsAt (answers begin) alongside endsAt
- engine test passes leadMs:0

Client:
- Countdown shows the full duration (no ticking) until startsAt, then counts
  down — so the WarioWare transition plays in full without stealing time
- store/ActiveRound carry startsAt

Custom transition media:
- RoundTransition auto-loads any gif/webp/apng/png/avif dropped in
  src/assets/transitions/ (import.meta.glob), plays one at random per round,
  falls back to the Framer animation when none are present
- README documenting how to add animations

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:25:17 +02:00

169 lines
4.7 KiB
TypeScript

import { create } from "zustand"
import type {
ErrorPayload,
PlayerScore,
RoomCreatedPayload,
RoomSnapshot,
RoundStartPayload,
VoteAckPayload,
} from "@nerdware/shared"
import { socket } from "@/lib/socket"
/** Manche en cours côté client (depuis round:start). */
export interface ActiveRound {
type: RoundStartPayload["type"]
djId?: string
startsAt: number
endsAt: number
payload: unknown
}
/** Reveal reçu (round:reveal), payloads typés par mode au moment de l'affichage. */
export interface RoundReveal {
truth: unknown
perPlayerResult: unknown
}
interface RoomState {
connected: boolean
/** Identité locale, pour reconnaître "moi" dans le snapshot. */
playerId: string | null
roomCode: string | null
snapshot: RoomSnapshot | null
lastError: ErrorPayload | null
// État de jeu (poussé par le moteur serveur).
round: ActiveRound | null
voteProgress: VoteAckPayload | null
reveal: RoundReveal | null
myChoiceIndex: number | null
finalScores: PlayerScore[] | null
/** Compteur d'explosions : incrémenté à chaque fin de manche au timer (boom à 0). */
boomKey: number
/** Compteur de manches : incrémenté à chaque round:start (transition WarioWare). */
roundKey: number
createRoom: (playerName: string) => Promise<RoomCreatedPayload>
joinRoom: (roomCode: string, playerName: string) => Promise<RoomCreatedPayload>
startGame: (questionCount: number) => Promise<void>
vote: (choiceIndex: number) => void
boom: () => void
clearError: () => void
reset: () => void
}
export const useRoomStore = create<RoomState>((set, get) => ({
connected: socket.connected,
playerId: null,
roomCode: null,
snapshot: null,
lastError: null,
round: null,
voteProgress: null,
reveal: null,
myChoiceIndex: null,
finalScores: null,
boomKey: 0,
roundKey: 0,
createRoom: (playerName) =>
new Promise((resolve, reject) => {
socket.emit("room:create", { playerName }, (res) => {
if (res.ok) {
set({ playerId: res.data.playerId, roomCode: res.data.roomCode })
resolve(res.data)
} else {
set({ lastError: res.error })
reject(res.error)
}
})
}),
joinRoom: (roomCode, playerName) =>
new Promise((resolve, reject) => {
socket.emit("room:join", { roomCode, playerName }, (res) => {
if (res.ok) {
set({ playerId: res.data.playerId, roomCode: res.data.roomCode })
resolve(res.data)
} else {
set({ lastError: res.error })
reject(res.error)
}
})
}),
startGame: (questionCount) =>
new Promise((resolve, reject) => {
const rounds = Array.from({ length: questionCount }, () => ({
type: "quiz" as const,
}))
// On pousse la séquence de manches, puis on lance.
socket.emit("lobby:updateSettings", {
blindtestMode: get().snapshot?.settings.blindtestMode ?? "title_artist",
roundDuration: 20,
rounds,
})
socket.emit("game:start", (res) => {
if (res.ok) {
resolve()
} else {
set({ lastError: res.error })
reject(res.error)
}
})
}),
vote: (choiceIndex) => {
if (get().myChoiceIndex !== null) {
return // vote déjà émis pour cette manche
}
set({ myChoiceIndex: choiceIndex })
socket.emit("round:vote", { answer: { choiceIndex } })
},
boom: () => set((s) => ({ boomKey: s.boomKey + 1 })),
clearError: () => set({ lastError: null }),
reset: () =>
set({
roomCode: null,
snapshot: null,
lastError: null,
round: null,
voteProgress: null,
reveal: null,
myChoiceIndex: null,
finalScores: null,
boomKey: 0,
roundKey: 0,
}),
}))
// Listeners socket → on pousse l'état serveur dans le store (source de vérité).
socket.on("connect", () => useRoomStore.setState({ connected: true }))
socket.on("disconnect", () => useRoomStore.setState({ connected: false }))
socket.on("room:state", (snapshot) => useRoomStore.setState({ snapshot }))
socket.on("error", (error) => useRoomStore.setState({ lastError: error }))
socket.on("round:start", (payload) =>
useRoomStore.setState((s) => ({
round: {
type: payload.type,
djId: payload.djId,
startsAt: payload.startsAt,
endsAt: payload.endsAt,
payload: payload.payload,
},
voteProgress: null,
reveal: null,
myChoiceIndex: null,
roundKey: s.roundKey + 1,
}))
)
socket.on("round:voteAck", (voteProgress) =>
useRoomStore.setState({ voteProgress })
)
socket.on("round:reveal", (reveal) => useRoomStore.setState({ reveal }))
socket.on("game:end", ({ finalScores }) =>
useRoomStore.setState({ finalScores })
)