adding multiple choice (not fully tested yet)

This commit is contained in:
RandyJC
2025-12-09 08:52:07 +01:00
parent 82be8dee93
commit 55349e01f6
10 changed files with 170 additions and 48 deletions

View File

@@ -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
}[]

View File

@@ -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

View File

@@ -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<number, number>
correct: number
correct: number | number[]
answers: string[]
image?: string
media?: QuestionMedia

View File

@@ -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)
)
)

View File

@@ -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,
},

View File

@@ -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<number, number>, { answerId }) => {
acc[answerId] = (acc[answerId] || 0) + 1
(acc: Record<number, number>, { 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),
})

View File

@@ -4,17 +4,20 @@ import { ButtonHTMLAttributes, ElementType, PropsWithChildren } from "react"
type Props = PropsWithChildren &
ButtonHTMLAttributes<HTMLButtonElement> & {
icon: ElementType
selected?: boolean
}
const AnswerButton = ({
className,
icon: Icon,
children,
selected = false,
...otherProps
}: Props) => (
<button
className={clsx(
"shadow-inset flex items-center gap-3 rounded px-4 py-6 text-left",
"shadow-inset flex items-center gap-3 rounded px-4 py-6 text-left transition-all",
selected && "ring-4 ring-white/80 shadow-lg",
className,
)}
{...otherProps}

View File

@@ -33,7 +33,7 @@ type MediaLibraryItem = {
const blankQuestion = (): EditableQuestion => ({
question: "",
answers: ["", ""],
solution: 0,
solution: [0],
cooldown: 5,
time: 20,
})
@@ -186,10 +186,14 @@ const QuizEditor = ({ quizzList, onBack, onListUpdate }: Props) => {
return
}
currentAnswers.splice(aIndex, 1)
let nextSolution = nextQuestions[qIndex].solution
if (nextSolution >= currentAnswers.length) {
nextSolution = currentAnswers.length - 1
}
const currentSolution = Array.isArray(nextQuestions[qIndex].solution)
? nextQuestions[qIndex].solution
: [nextQuestions[qIndex].solution]
const adjusted = currentSolution
.filter((idx) => idx !== aIndex)
.map((idx) => (idx > aIndex ? idx - 1 : idx))
const nextSolution =
adjusted.length > 0 ? adjusted : [Math.max(0, currentAnswers.length - 1)]
nextQuestions[qIndex] = {
...nextQuestions[qIndex],
answers: currentAnswers,
@@ -741,18 +745,35 @@ const QuizEditor = ({ quizzList, onBack, onListUpdate }: Props) => {
key={aIndex}
className={clsx(
"flex items-center gap-2 rounded-md border p-2",
question.solution === aIndex
(Array.isArray(question.solution)
? question.solution.includes(aIndex)
: question.solution === aIndex)
? "border-green-500"
: "border-gray-200",
)}
>
<input
type="radio"
name={`solution-${qIndex}`}
checked={question.solution === aIndex}
onChange={() =>
updateQuestion(qIndex, { solution: aIndex })
type="checkbox"
name={`solution-${qIndex}-${aIndex}`}
checked={
Array.isArray(question.solution)
? question.solution.includes(aIndex)
: question.solution === aIndex
}
onChange={(e) => {
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)
}
updateQuestion(qIndex, { solution: next })
}}
/>
<Input
className="flex-1"

View File

@@ -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<number[]>([])
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 (
<div className="flex h-full flex-1 flex-col justify-between">
<div className="mx-auto inline-flex h-full w-full max-w-7xl flex-1 flex-col items-center justify-center gap-5">
<h2 className="text-center text-2xl font-bold text-white drop-shadow-lg md:text-4xl lg:text-5xl">
{question}
</h2>
{allowsMultiple && (
<p className="rounded-full bg-black/40 px-4 py-2 text-sm font-semibold text-white">
Select all correct answers, then submit.
</p>
)}
<QuestionMedia
media={media || (image ? { type: "image", url: image } : undefined)}
@@ -128,11 +170,29 @@ const Answers = ({
className={clsx(ANSWERS_COLORS[key])}
icon={ANSWERS_ICONS[key]}
onClick={handleAnswer(key)}
disabled={submitted}
selected={selectedAnswers.includes(key)}
>
{answer}
</AnswerButton>
))}
</div>
{allowsMultiple && (
<div className="mx-auto flex w-full max-w-7xl justify-end px-2">
<button
type="button"
onClick={() => submitAnswers(selectedAnswers)}
disabled={submitted || selectedAnswers.length === 0}
className={clsx(
"rounded bg-white/80 px-4 py-2 text-sm font-semibold text-slate-900 shadow-md transition hover:bg-white",
(submitted || selectedAnswers.length === 0) &&
"cursor-not-allowed opacity-60",
)}
>
{submitted ? "Submitted" : "Submit answers"}
</button>
</div>
)}
</div>
</div>
)

View File

@@ -67,6 +67,10 @@ const Responses = ({
stopMusic()
}, [playMusic, stopMusic])
const correctSet = new Set(
Array.isArray(correct) ? correct : typeof correct === "number" ? [correct] : [],
)
return (
<div className="flex h-full flex-1 flex-col justify-between">
<div className="mx-auto inline-flex h-full w-full max-w-7xl flex-1 flex-col items-center justify-center gap-5">
@@ -107,7 +111,7 @@ const Responses = ({
<AnswerButton
key={key}
className={clsx(ANSWERS_COLORS[key], {
"opacity-65": responses && correct !== key,
"opacity-65": responses && !correctSet.has(key),
})}
icon={ANSWERS_ICONS[key]}
>