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>
169 lines
4.7 KiB
TypeScript
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 })
|
|
)
|