From 55349e01f60f39876f9481e1b1864b15af6e23a8 Mon Sep 17 00:00:00 2001 From: RandyJC Date: Tue, 9 Dec 2025 08:52:07 +0100 Subject: [PATCH] adding multiple choice (not fully tested yet) --- packages/common/src/types/game/index.ts | 4 +- packages/common/src/types/game/socket.ts | 2 +- packages/common/src/types/game/status.ts | 3 +- packages/socket/src/index.ts | 2 +- packages/socket/src/services/config.ts | 4 +- packages/socket/src/services/game.ts | 47 +++++++++-- packages/web/src/components/AnswerButton.tsx | 5 +- .../src/components/game/create/QuizEditor.tsx | 77 ++++++++++++------- .../src/components/game/states/Answers.tsx | 68 +++++++++++++++- .../src/components/game/states/Responses.tsx | 6 +- 10 files changed, 170 insertions(+), 48 deletions(-) diff --git a/packages/common/src/types/game/index.ts b/packages/common/src/types/game/index.ts index 7966ab1..5d98693 100644 --- a/packages/common/src/types/game/index.ts +++ b/packages/common/src/types/game/index.ts @@ -8,7 +8,7 @@ export type Player = { export type Answer = { playerId: string - answerId: number + answerIds: number[] points: number } @@ -19,7 +19,7 @@ export type Quizz = { image?: string media?: QuestionMedia answers: string[] - solution: number + solution: number | number[] cooldown: number time: number }[] diff --git a/packages/common/src/types/game/socket.ts b/packages/common/src/types/game/socket.ts index e194d21..91ba82e 100644 --- a/packages/common/src/types/game/socket.ts +++ b/packages/common/src/types/game/socket.ts @@ -91,7 +91,7 @@ export interface ClientToServerEvents { "player:login": (_message: MessageWithoutStatus<{ username: string }>) => void "player:reconnect": (_message: { gameId: string }) => void "player:selectedAnswer": ( - _message: MessageWithoutStatus<{ answerKey: number }> + _message: MessageWithoutStatus<{ answerKeys: number[] }> ) => void // Common diff --git a/packages/common/src/types/game/status.ts b/packages/common/src/types/game/status.ts index 39ecfad..75f4d19 100644 --- a/packages/common/src/types/game/status.ts +++ b/packages/common/src/types/game/status.ts @@ -31,6 +31,7 @@ export type CommonStatusDataMap = { media?: QuestionMedia time: number totalPlayer: number + allowsMultiple: boolean } SHOW_RESULT: { correct: boolean @@ -49,7 +50,7 @@ type ManagerExtraStatus = { SHOW_RESPONSES: { question: string responses: Record - correct: number + correct: number | number[] answers: string[] image?: string media?: QuestionMedia diff --git a/packages/socket/src/index.ts b/packages/socket/src/index.ts index 8499a1d..173fb90 100644 --- a/packages/socket/src/index.ts +++ b/packages/socket/src/index.ts @@ -218,7 +218,7 @@ io.on("connection", (socket) => { socket.on("player:selectedAnswer", ({ gameId, data }) => withGame(gameId, socket, (game) => - game.selectAnswer(socket, data.answerKey) + game.selectAnswer(socket, data.answerKeys) ) ) diff --git a/packages/socket/src/services/config.ts b/packages/socket/src/services/config.ts index af66a88..69fd3be 100644 --- a/packages/socket/src/services/config.ts +++ b/packages/socket/src/services/config.ts @@ -104,7 +104,7 @@ class Config { type: "audio", url: "https://upload.wikimedia.org/wikipedia/commons/transcoded/4/4c/Beethoven_Moonlight_1st_movement.ogg/Beethoven_Moonlight_1st_movement.ogg.mp3", }, - solution: 1, + solution: [1], cooldown: 5, time: 25, }, @@ -120,7 +120,7 @@ class Config { type: "video", url: "https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4", }, - solution: 2, + solution: [2], cooldown: 5, time: 40, }, diff --git a/packages/socket/src/services/game.ts b/packages/socket/src/services/game.ts index 12174a0..5cc7459 100644 --- a/packages/socket/src/services/game.ts +++ b/packages/socket/src/services/game.ts @@ -125,11 +125,25 @@ class Game { id: "", connected: false, })) - game.round = snapshot.round || { + const round = snapshot.round || { playersAnswers: [], currentQuestion: 0, startTime: 0, } + const migratedAnswers = Array.isArray(round.playersAnswers) + ? round.playersAnswers.map((a: any) => ({ + ...a, + answerIds: Array.isArray(a.answerIds) + ? a.answerIds + : typeof a.answerId === "number" + ? [a.answerId] + : [], + })) + : [] + game.round = { + ...round, + playersAnswers: migratedAnswers, + } game.cooldown = { active: snapshot.cooldown?.active || false, paused: snapshot.cooldown?.paused || false, @@ -538,6 +552,8 @@ class Game { media: question.media, time: question.time, totalPlayer: this.players.length, + allowsMultiple: + Array.isArray(question.solution) && question.solution.length > 1, }) await this.startCooldown(question.time) @@ -557,9 +573,10 @@ class Game { : this.leaderboard.map((p) => ({ ...p })) const totalType = this.round.playersAnswers.reduce( - (acc: Record, { answerId }) => { - acc[answerId] = (acc[answerId] || 0) + 1 - + (acc: Record, { answerIds }) => { + answerIds.forEach((id) => { + acc[id] = (acc[id] || 0) + 1 + }) return acc }, {} @@ -571,8 +588,16 @@ class Game { (a) => a.playerId === player.id ) + const correctAnswers = Array.isArray(question.solution) + ? Array.from(new Set(question.solution)) + : [question.solution] + const isCorrect = playerAnswer - ? playerAnswer.answerId === question.solution + ? (() => { + const chosen = Array.from(new Set(playerAnswer.answerIds)) + if (chosen.length !== correctAnswers.length) return false + return correctAnswers.every((id: number) => chosen.includes(id)) + })() : false const points = @@ -615,7 +640,7 @@ class Game { this.round.playersAnswers = [] this.persist() } - selectAnswer(socket: Socket, answerId: number) { + selectAnswer(socket: Socket, answerIds: number[]) { const player = this.players.find((player) => player.id === socket.id) const question = this.quizz.questions[this.round.currentQuestion] @@ -627,9 +652,17 @@ class Game { return } + const uniqueAnswers = Array.from(new Set(answerIds)).filter( + (id) => !Number.isNaN(id) + ) + + if (uniqueAnswers.length === 0) { + return + } + this.round.playersAnswers.push({ playerId: player.id, - answerId, + answerIds: uniqueAnswers, points: timeToPoint(this.round.startTime, question.time), }) diff --git a/packages/web/src/components/AnswerButton.tsx b/packages/web/src/components/AnswerButton.tsx index 7b757ab..1afd064 100644 --- a/packages/web/src/components/AnswerButton.tsx +++ b/packages/web/src/components/AnswerButton.tsx @@ -4,17 +4,20 @@ import { ButtonHTMLAttributes, ElementType, PropsWithChildren } from "react" type Props = PropsWithChildren & ButtonHTMLAttributes & { icon: ElementType + selected?: boolean } const AnswerButton = ({ className, icon: Icon, children, + selected = false, ...otherProps }: Props) => ( -
- {question.answers.map((answer, aIndex) => ( -
- - updateQuestion(qIndex, { solution: aIndex }) +
+ {question.answers.map((answer, aIndex) => ( +
+ { + const current = Array.isArray(question.solution) + ? question.solution + : [question.solution] + let next = current + if (e.target.checked) { + next = Array.from(new Set([...current, aIndex])).sort( + (a, b) => a - b, + ) + } else { + next = current.filter((idx) => idx !== aIndex) } - /> - - updateAnswer(qIndex, aIndex, e.target.value) + updateQuestion(qIndex, { solution: next }) + }} + /> + + updateAnswer(qIndex, aIndex, e.target.value) } placeholder={`Answer ${aIndex + 1}`} /> diff --git a/packages/web/src/components/game/states/Answers.tsx b/packages/web/src/components/game/states/Answers.tsx index c84c8ef..226079a 100644 --- a/packages/web/src/components/game/states/Answers.tsx +++ b/packages/web/src/components/game/states/Answers.tsx @@ -21,7 +21,15 @@ type Props = { } const Answers = ({ - data: { question, answers, image, media, time, totalPlayer }, + data: { + question, + answers, + image, + media, + time, + totalPlayer, + allowsMultiple, + }, }: Props) => { const { gameId }: { gameId?: string } = useParams() const { socket } = useSocket() @@ -31,6 +39,8 @@ const Answers = ({ const [paused, setPaused] = useState(false) const [totalAnswer, setTotalAnswer] = useState(0) const [isMediaPlaying, setIsMediaPlaying] = useState(false) + const [selectedAnswers, setSelectedAnswers] = useState([]) + const [submitted, setSubmitted] = useState(false) const [sfxPop] = useSound(SFX_ANSWERS_SOUND, { volume: 0.1, @@ -45,20 +55,39 @@ const Answers = ({ }, ) - const handleAnswer = (answerKey: number) => () => { - if (!player) { + const submitAnswers = (keys: number[]) => { + if (!player || submitted || keys.length === 0) { return } socket?.emit("player:selectedAnswer", { gameId, data: { - answerKey, + answerKeys: keys, }, }) + setSubmitted(true) sfxPop() } + const handleAnswer = (answerKey: number) => () => { + if (!player) { + return + } + + if (!allowsMultiple) { + setSelectedAnswers([answerKey]) + submitAnswers([answerKey]) + return + } + + setSelectedAnswers((prev) => + prev.includes(answerKey) + ? prev.filter((key) => key !== answerKey) + : [...prev, answerKey], + ) + } + useEffect(() => { playMusic() @@ -88,12 +117,25 @@ const Answers = ({ sfxPop() }) + useEffect(() => { + setCooldown(time) + setPaused(false) + setSelectedAnswers([]) + setSubmitted(false) + setTotalAnswer(0) + }, [question, time]) + return (

{question}

+ {allowsMultiple && ( +

+ Select all correct answers, then submit. +

+ )} {answer} ))}
+ {allowsMultiple && ( +
+ +
+ )}
) diff --git a/packages/web/src/components/game/states/Responses.tsx b/packages/web/src/components/game/states/Responses.tsx index 9b843e3..cc93c16 100644 --- a/packages/web/src/components/game/states/Responses.tsx +++ b/packages/web/src/components/game/states/Responses.tsx @@ -67,6 +67,10 @@ const Responses = ({ stopMusic() }, [playMusic, stopMusic]) + const correctSet = new Set( + Array.isArray(correct) ? correct : typeof correct === "number" ? [correct] : [], + ) + return (
@@ -107,7 +111,7 @@ const Responses = ({