mirror of
https://github.com/randyjc/Rahoot.git
synced 2026-03-13 20:15:35 +01:00
adding multiple choice (not fully tested yet)
This commit is contained in:
@@ -8,7 +8,7 @@ export type Player = {
|
|||||||
|
|
||||||
export type Answer = {
|
export type Answer = {
|
||||||
playerId: string
|
playerId: string
|
||||||
answerId: number
|
answerIds: number[]
|
||||||
points: number
|
points: number
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ export type Quizz = {
|
|||||||
image?: string
|
image?: string
|
||||||
media?: QuestionMedia
|
media?: QuestionMedia
|
||||||
answers: string[]
|
answers: string[]
|
||||||
solution: number
|
solution: number | number[]
|
||||||
cooldown: number
|
cooldown: number
|
||||||
time: number
|
time: number
|
||||||
}[]
|
}[]
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ export interface ClientToServerEvents {
|
|||||||
"player:login": (_message: MessageWithoutStatus<{ username: string }>) => void
|
"player:login": (_message: MessageWithoutStatus<{ username: string }>) => void
|
||||||
"player:reconnect": (_message: { gameId: string }) => void
|
"player:reconnect": (_message: { gameId: string }) => void
|
||||||
"player:selectedAnswer": (
|
"player:selectedAnswer": (
|
||||||
_message: MessageWithoutStatus<{ answerKey: number }>
|
_message: MessageWithoutStatus<{ answerKeys: number[] }>
|
||||||
) => void
|
) => void
|
||||||
|
|
||||||
// Common
|
// Common
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export type CommonStatusDataMap = {
|
|||||||
media?: QuestionMedia
|
media?: QuestionMedia
|
||||||
time: number
|
time: number
|
||||||
totalPlayer: number
|
totalPlayer: number
|
||||||
|
allowsMultiple: boolean
|
||||||
}
|
}
|
||||||
SHOW_RESULT: {
|
SHOW_RESULT: {
|
||||||
correct: boolean
|
correct: boolean
|
||||||
@@ -49,7 +50,7 @@ type ManagerExtraStatus = {
|
|||||||
SHOW_RESPONSES: {
|
SHOW_RESPONSES: {
|
||||||
question: string
|
question: string
|
||||||
responses: Record<number, number>
|
responses: Record<number, number>
|
||||||
correct: number
|
correct: number | number[]
|
||||||
answers: string[]
|
answers: string[]
|
||||||
image?: string
|
image?: string
|
||||||
media?: QuestionMedia
|
media?: QuestionMedia
|
||||||
|
|||||||
@@ -218,7 +218,7 @@ io.on("connection", (socket) => {
|
|||||||
|
|
||||||
socket.on("player:selectedAnswer", ({ gameId, data }) =>
|
socket.on("player:selectedAnswer", ({ gameId, data }) =>
|
||||||
withGame(gameId, socket, (game) =>
|
withGame(gameId, socket, (game) =>
|
||||||
game.selectAnswer(socket, data.answerKey)
|
game.selectAnswer(socket, data.answerKeys)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ class Config {
|
|||||||
type: "audio",
|
type: "audio",
|
||||||
url: "https://upload.wikimedia.org/wikipedia/commons/transcoded/4/4c/Beethoven_Moonlight_1st_movement.ogg/Beethoven_Moonlight_1st_movement.ogg.mp3",
|
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,
|
cooldown: 5,
|
||||||
time: 25,
|
time: 25,
|
||||||
},
|
},
|
||||||
@@ -120,7 +120,7 @@ class Config {
|
|||||||
type: "video",
|
type: "video",
|
||||||
url: "https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4",
|
url: "https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4",
|
||||||
},
|
},
|
||||||
solution: 2,
|
solution: [2],
|
||||||
cooldown: 5,
|
cooldown: 5,
|
||||||
time: 40,
|
time: 40,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -125,11 +125,25 @@ class Game {
|
|||||||
id: "",
|
id: "",
|
||||||
connected: false,
|
connected: false,
|
||||||
}))
|
}))
|
||||||
game.round = snapshot.round || {
|
const round = snapshot.round || {
|
||||||
playersAnswers: [],
|
playersAnswers: [],
|
||||||
currentQuestion: 0,
|
currentQuestion: 0,
|
||||||
startTime: 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 = {
|
game.cooldown = {
|
||||||
active: snapshot.cooldown?.active || false,
|
active: snapshot.cooldown?.active || false,
|
||||||
paused: snapshot.cooldown?.paused || false,
|
paused: snapshot.cooldown?.paused || false,
|
||||||
@@ -538,6 +552,8 @@ class Game {
|
|||||||
media: question.media,
|
media: question.media,
|
||||||
time: question.time,
|
time: question.time,
|
||||||
totalPlayer: this.players.length,
|
totalPlayer: this.players.length,
|
||||||
|
allowsMultiple:
|
||||||
|
Array.isArray(question.solution) && question.solution.length > 1,
|
||||||
})
|
})
|
||||||
|
|
||||||
await this.startCooldown(question.time)
|
await this.startCooldown(question.time)
|
||||||
@@ -557,9 +573,10 @@ class Game {
|
|||||||
: this.leaderboard.map((p) => ({ ...p }))
|
: this.leaderboard.map((p) => ({ ...p }))
|
||||||
|
|
||||||
const totalType = this.round.playersAnswers.reduce(
|
const totalType = this.round.playersAnswers.reduce(
|
||||||
(acc: Record<number, number>, { answerId }) => {
|
(acc: Record<number, number>, { answerIds }) => {
|
||||||
acc[answerId] = (acc[answerId] || 0) + 1
|
answerIds.forEach((id) => {
|
||||||
|
acc[id] = (acc[id] || 0) + 1
|
||||||
|
})
|
||||||
return acc
|
return acc
|
||||||
},
|
},
|
||||||
{}
|
{}
|
||||||
@@ -571,8 +588,16 @@ class Game {
|
|||||||
(a) => a.playerId === player.id
|
(a) => a.playerId === player.id
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const correctAnswers = Array.isArray(question.solution)
|
||||||
|
? Array.from(new Set(question.solution))
|
||||||
|
: [question.solution]
|
||||||
|
|
||||||
const isCorrect = playerAnswer
|
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
|
: false
|
||||||
|
|
||||||
const points =
|
const points =
|
||||||
@@ -615,7 +640,7 @@ class Game {
|
|||||||
this.round.playersAnswers = []
|
this.round.playersAnswers = []
|
||||||
this.persist()
|
this.persist()
|
||||||
}
|
}
|
||||||
selectAnswer(socket: Socket, answerId: number) {
|
selectAnswer(socket: Socket, answerIds: number[]) {
|
||||||
const player = this.players.find((player) => player.id === socket.id)
|
const player = this.players.find((player) => player.id === socket.id)
|
||||||
const question = this.quizz.questions[this.round.currentQuestion]
|
const question = this.quizz.questions[this.round.currentQuestion]
|
||||||
|
|
||||||
@@ -627,9 +652,17 @@ class Game {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const uniqueAnswers = Array.from(new Set(answerIds)).filter(
|
||||||
|
(id) => !Number.isNaN(id)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (uniqueAnswers.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
this.round.playersAnswers.push({
|
this.round.playersAnswers.push({
|
||||||
playerId: player.id,
|
playerId: player.id,
|
||||||
answerId,
|
answerIds: uniqueAnswers,
|
||||||
points: timeToPoint(this.round.startTime, question.time),
|
points: timeToPoint(this.round.startTime, question.time),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -4,17 +4,20 @@ import { ButtonHTMLAttributes, ElementType, PropsWithChildren } from "react"
|
|||||||
type Props = PropsWithChildren &
|
type Props = PropsWithChildren &
|
||||||
ButtonHTMLAttributes<HTMLButtonElement> & {
|
ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||||
icon: ElementType
|
icon: ElementType
|
||||||
|
selected?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const AnswerButton = ({
|
const AnswerButton = ({
|
||||||
className,
|
className,
|
||||||
icon: Icon,
|
icon: Icon,
|
||||||
children,
|
children,
|
||||||
|
selected = false,
|
||||||
...otherProps
|
...otherProps
|
||||||
}: Props) => (
|
}: Props) => (
|
||||||
<button
|
<button
|
||||||
className={clsx(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...otherProps}
|
{...otherProps}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ type MediaLibraryItem = {
|
|||||||
const blankQuestion = (): EditableQuestion => ({
|
const blankQuestion = (): EditableQuestion => ({
|
||||||
question: "",
|
question: "",
|
||||||
answers: ["", ""],
|
answers: ["", ""],
|
||||||
solution: 0,
|
solution: [0],
|
||||||
cooldown: 5,
|
cooldown: 5,
|
||||||
time: 20,
|
time: 20,
|
||||||
})
|
})
|
||||||
@@ -186,10 +186,14 @@ const QuizEditor = ({ quizzList, onBack, onListUpdate }: Props) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
currentAnswers.splice(aIndex, 1)
|
currentAnswers.splice(aIndex, 1)
|
||||||
let nextSolution = nextQuestions[qIndex].solution
|
const currentSolution = Array.isArray(nextQuestions[qIndex].solution)
|
||||||
if (nextSolution >= currentAnswers.length) {
|
? nextQuestions[qIndex].solution
|
||||||
nextSolution = currentAnswers.length - 1
|
: [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] = {
|
||||||
...nextQuestions[qIndex],
|
...nextQuestions[qIndex],
|
||||||
answers: currentAnswers,
|
answers: currentAnswers,
|
||||||
@@ -735,30 +739,47 @@ const QuizEditor = ({ quizzList, onBack, onListUpdate }: Props) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-2 md:grid-cols-2">
|
<div className="grid gap-2 md:grid-cols-2">
|
||||||
{question.answers.map((answer, aIndex) => (
|
{question.answers.map((answer, aIndex) => (
|
||||||
<div
|
<div
|
||||||
key={aIndex}
|
key={aIndex}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"flex items-center gap-2 rounded-md border p-2",
|
"flex items-center gap-2 rounded-md border p-2",
|
||||||
question.solution === aIndex
|
(Array.isArray(question.solution)
|
||||||
? "border-green-500"
|
? question.solution.includes(aIndex)
|
||||||
: "border-gray-200",
|
: question.solution === aIndex)
|
||||||
)}
|
? "border-green-500"
|
||||||
>
|
: "border-gray-200",
|
||||||
<input
|
)}
|
||||||
type="radio"
|
>
|
||||||
name={`solution-${qIndex}`}
|
<input
|
||||||
checked={question.solution === aIndex}
|
type="checkbox"
|
||||||
onChange={() =>
|
name={`solution-${qIndex}-${aIndex}`}
|
||||||
updateQuestion(qIndex, { solution: 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"
|
/>
|
||||||
value={answer}
|
<Input
|
||||||
onChange={(e) =>
|
className="flex-1"
|
||||||
updateAnswer(qIndex, aIndex, e.target.value)
|
value={answer}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateAnswer(qIndex, aIndex, e.target.value)
|
||||||
}
|
}
|
||||||
placeholder={`Answer ${aIndex + 1}`}
|
placeholder={`Answer ${aIndex + 1}`}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -21,7 +21,15 @@ type Props = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const Answers = ({
|
const Answers = ({
|
||||||
data: { question, answers, image, media, time, totalPlayer },
|
data: {
|
||||||
|
question,
|
||||||
|
answers,
|
||||||
|
image,
|
||||||
|
media,
|
||||||
|
time,
|
||||||
|
totalPlayer,
|
||||||
|
allowsMultiple,
|
||||||
|
},
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const { gameId }: { gameId?: string } = useParams()
|
const { gameId }: { gameId?: string } = useParams()
|
||||||
const { socket } = useSocket()
|
const { socket } = useSocket()
|
||||||
@@ -31,6 +39,8 @@ const Answers = ({
|
|||||||
const [paused, setPaused] = useState(false)
|
const [paused, setPaused] = useState(false)
|
||||||
const [totalAnswer, setTotalAnswer] = useState(0)
|
const [totalAnswer, setTotalAnswer] = useState(0)
|
||||||
const [isMediaPlaying, setIsMediaPlaying] = useState(false)
|
const [isMediaPlaying, setIsMediaPlaying] = useState(false)
|
||||||
|
const [selectedAnswers, setSelectedAnswers] = useState<number[]>([])
|
||||||
|
const [submitted, setSubmitted] = useState(false)
|
||||||
|
|
||||||
const [sfxPop] = useSound(SFX_ANSWERS_SOUND, {
|
const [sfxPop] = useSound(SFX_ANSWERS_SOUND, {
|
||||||
volume: 0.1,
|
volume: 0.1,
|
||||||
@@ -45,20 +55,39 @@ const Answers = ({
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleAnswer = (answerKey: number) => () => {
|
const submitAnswers = (keys: number[]) => {
|
||||||
if (!player) {
|
if (!player || submitted || keys.length === 0) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
socket?.emit("player:selectedAnswer", {
|
socket?.emit("player:selectedAnswer", {
|
||||||
gameId,
|
gameId,
|
||||||
data: {
|
data: {
|
||||||
answerKey,
|
answerKeys: keys,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
setSubmitted(true)
|
||||||
sfxPop()
|
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(() => {
|
useEffect(() => {
|
||||||
playMusic()
|
playMusic()
|
||||||
|
|
||||||
@@ -88,12 +117,25 @@ const Answers = ({
|
|||||||
sfxPop()
|
sfxPop()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCooldown(time)
|
||||||
|
setPaused(false)
|
||||||
|
setSelectedAnswers([])
|
||||||
|
setSubmitted(false)
|
||||||
|
setTotalAnswer(0)
|
||||||
|
}, [question, time])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-1 flex-col justify-between">
|
<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">
|
<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">
|
<h2 className="text-center text-2xl font-bold text-white drop-shadow-lg md:text-4xl lg:text-5xl">
|
||||||
{question}
|
{question}
|
||||||
</h2>
|
</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
|
<QuestionMedia
|
||||||
media={media || (image ? { type: "image", url: image } : undefined)}
|
media={media || (image ? { type: "image", url: image } : undefined)}
|
||||||
@@ -128,11 +170,29 @@ const Answers = ({
|
|||||||
className={clsx(ANSWERS_COLORS[key])}
|
className={clsx(ANSWERS_COLORS[key])}
|
||||||
icon={ANSWERS_ICONS[key]}
|
icon={ANSWERS_ICONS[key]}
|
||||||
onClick={handleAnswer(key)}
|
onClick={handleAnswer(key)}
|
||||||
|
disabled={submitted}
|
||||||
|
selected={selectedAnswers.includes(key)}
|
||||||
>
|
>
|
||||||
{answer}
|
{answer}
|
||||||
</AnswerButton>
|
</AnswerButton>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -67,6 +67,10 @@ const Responses = ({
|
|||||||
stopMusic()
|
stopMusic()
|
||||||
}, [playMusic, stopMusic])
|
}, [playMusic, stopMusic])
|
||||||
|
|
||||||
|
const correctSet = new Set(
|
||||||
|
Array.isArray(correct) ? correct : typeof correct === "number" ? [correct] : [],
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-1 flex-col justify-between">
|
<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">
|
<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
|
<AnswerButton
|
||||||
key={key}
|
key={key}
|
||||||
className={clsx(ANSWERS_COLORS[key], {
|
className={clsx(ANSWERS_COLORS[key], {
|
||||||
"opacity-65": responses && correct !== key,
|
"opacity-65": responses && !correctSet.has(key),
|
||||||
})}
|
})}
|
||||||
icon={ANSWERS_ICONS[key]}
|
icon={ANSWERS_ICONS[key]}
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user