|
|
import { useNavigate } from '@solidjs/router'
|
|
|
import { createEffect, createMemo, createSignal, onMount } from 'solid-js'
|
|
|
|
|
|
import {
|
|
|
AnswerInput,
|
|
|
AttemptIndicator,
|
|
|
GameCard,
|
|
|
HintButton,
|
|
|
ScoreDisplay,
|
|
|
Timer,
|
|
|
} from '@knowfoolery/ui-components'
|
|
|
import Button from '@suid/material/Button'
|
|
|
import Stack from '@suid/material/Stack'
|
|
|
import Typography from '@suid/material/Typography'
|
|
|
|
|
|
import { useTimer } from '../hooks/useTimer'
|
|
|
import { leaderboardClient } from '../services/api'
|
|
|
import { appendGameHistory, saveLastResult } from '../services/session'
|
|
|
|
|
|
type QuizQuestion = {
|
|
|
theme: string
|
|
|
text: string
|
|
|
answer: string
|
|
|
hint: string
|
|
|
}
|
|
|
|
|
|
const QUESTION: QuizQuestion = {
|
|
|
theme: 'Général',
|
|
|
text: 'Quel est le plus petit nombre premier ?',
|
|
|
answer: '2',
|
|
|
hint: 'C’est le seul nombre premier pair.',
|
|
|
}
|
|
|
|
|
|
function normalizeAnswer(answer: string): string {
|
|
|
return answer.trim().toLowerCase()
|
|
|
}
|
|
|
|
|
|
async function estimateLeaderboardPosition(score: number, durationSec: number): Promise<number | null> {
|
|
|
try {
|
|
|
const rows = await leaderboardClient.top10()
|
|
|
const sorted = [...rows].sort((a, b) => {
|
|
|
if (b.score !== a.score) return b.score - a.score
|
|
|
const durationA = typeof a.durationSec === 'number' ? a.durationSec : Number.MAX_SAFE_INTEGER
|
|
|
const durationB = typeof b.durationSec === 'number' ? b.durationSec : Number.MAX_SAFE_INTEGER
|
|
|
return durationA - durationB
|
|
|
})
|
|
|
|
|
|
let rank = 1
|
|
|
for (const row of sorted) {
|
|
|
const rowDuration = typeof row.durationSec === 'number' ? row.durationSec : Number.MAX_SAFE_INTEGER
|
|
|
if (score > row.score) break
|
|
|
if (score === row.score && durationSec < rowDuration) break
|
|
|
rank += 1
|
|
|
}
|
|
|
|
|
|
return rank <= 10 ? rank : null
|
|
|
} catch {
|
|
|
return null
|
|
|
}
|
|
|
}
|
|
|
|
|
|
export default function GameRoute() {
|
|
|
const navigate = useNavigate()
|
|
|
|
|
|
const [question] = createSignal(QUESTION)
|
|
|
const [answer, setAnswer] = createSignal('')
|
|
|
const [attempts, setAttempts] = createSignal(0)
|
|
|
const [hintUsed, setHintUsed] = createSignal(false)
|
|
|
const [score, setScore] = createSignal(0)
|
|
|
const [message, setMessage] = createSignal<string | null>(null)
|
|
|
const [finished, setFinished] = createSignal(false)
|
|
|
|
|
|
const durationMs = 30 * 60 * 1000
|
|
|
const startedAt = Date.now()
|
|
|
const { remainingMs, isExpired, start, stop } = useTimer(durationMs)
|
|
|
|
|
|
onMount(() => start())
|
|
|
|
|
|
const attemptsLeft = createMemo(() => Math.max(0, 3 - attempts()))
|
|
|
const answerLocked = createMemo(() => finished() || attemptsLeft() === 0 || isExpired())
|
|
|
|
|
|
const finalize = async (finalScore: number, answered: number, correct: number) => {
|
|
|
if (finished()) return
|
|
|
setFinished(true)
|
|
|
stop()
|
|
|
|
|
|
const elapsedSec = Math.max(1, Math.floor((Date.now() - startedAt) / 1000))
|
|
|
const durationSec = Math.min(Math.floor(durationMs / 1000), elapsedSec)
|
|
|
const successRate = answered > 0 ? Math.round((correct / answered) * 100) : 0
|
|
|
const playerName = window.localStorage.getItem('kf.playerName') ?? 'Anonymous'
|
|
|
const leaderboardPosition = await estimateLeaderboardPosition(finalScore, durationSec)
|
|
|
|
|
|
const result = {
|
|
|
playerName,
|
|
|
finalScore,
|
|
|
answered,
|
|
|
correct,
|
|
|
successRate,
|
|
|
durationSec,
|
|
|
leaderboardPosition,
|
|
|
finishedAt: new Date().toISOString(),
|
|
|
}
|
|
|
|
|
|
saveLastResult(result)
|
|
|
appendGameHistory(result)
|
|
|
navigate('/results')
|
|
|
}
|
|
|
|
|
|
createEffect(() => {
|
|
|
if (isExpired() && !finished()) {
|
|
|
setMessage('Temps écoulé.')
|
|
|
const answered = attempts() > 0 ? 1 : 0
|
|
|
void finalize(score(), answered, 0)
|
|
|
}
|
|
|
})
|
|
|
|
|
|
const submit = () => {
|
|
|
if (answerLocked()) return
|
|
|
|
|
|
const normalized = normalizeAnswer(answer())
|
|
|
if (!normalized) {
|
|
|
setMessage('Saisis une réponse avant d’envoyer.')
|
|
|
return
|
|
|
}
|
|
|
|
|
|
const isCorrect = normalized === normalizeAnswer(question().answer) || normalized === 'deux'
|
|
|
const nextAttempts = attempts() + 1
|
|
|
setAttempts(nextAttempts)
|
|
|
|
|
|
if (isCorrect) {
|
|
|
const delta = hintUsed() ? 1 : 2
|
|
|
const nextScore = score() + delta
|
|
|
setScore(nextScore)
|
|
|
setMessage(`Bonne réponse (+${delta}).`)
|
|
|
void finalize(nextScore, 1, 1)
|
|
|
return
|
|
|
}
|
|
|
|
|
|
if (nextAttempts >= 3) {
|
|
|
setMessage("Mauvaise réponse. Plus d'essais.")
|
|
|
void finalize(score(), 1, 0)
|
|
|
} else {
|
|
|
setMessage('Mauvaise réponse.')
|
|
|
}
|
|
|
|
|
|
setAnswer('')
|
|
|
}
|
|
|
|
|
|
const confirmHint = () => {
|
|
|
if (hintUsed() || attempts() > 0 || answerLocked()) return
|
|
|
setHintUsed(true)
|
|
|
setMessage(`Indice: ${question().hint}`)
|
|
|
}
|
|
|
|
|
|
return (
|
|
|
<GameCard
|
|
|
title="Partie"
|
|
|
theme={question().theme}
|
|
|
themeLabelPrefix="Thème : "
|
|
|
questionText={question().text}
|
|
|
timerSlot={<Timer remainingMs={remainingMs()} />}
|
|
|
scoreSlot={<ScoreDisplay score={score()} />}
|
|
|
answerSlot={
|
|
|
<AnswerInput
|
|
|
label="Ta réponse"
|
|
|
value={answer()}
|
|
|
disabled={answerLocked()}
|
|
|
onInputValue={setAnswer}
|
|
|
onSubmit={submit}
|
|
|
/>
|
|
|
}
|
|
|
attemptSlot={<AttemptIndicator attemptsUsed={attempts()} attemptsMax={3} />}
|
|
|
actionsSlot={
|
|
|
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
|
|
|
<Button variant="contained" disabled={answerLocked()} onClick={submit}>
|
|
|
Envoyer
|
|
|
</Button>
|
|
|
<HintButton
|
|
|
disabled={hintUsed() || attempts() > 0 || answerLocked()}
|
|
|
buttonLabel="Indice (score réduit)"
|
|
|
confirmTitle="Confirmer l’indice"
|
|
|
confirmMessage="Demander un indice réduit le score maximal de la question de 2 à 1 point. Continuer ?"
|
|
|
confirmLabel="Oui, utiliser un indice"
|
|
|
cancelLabel="Annuler"
|
|
|
onConfirm={confirmHint}
|
|
|
/>
|
|
|
</Stack>
|
|
|
}
|
|
|
feedbackSlot={
|
|
|
<>
|
|
|
{message() && <Typography sx={{ opacity: 0.9 }}>{message()}</Typography>}
|
|
|
{attemptsLeft() === 0 && <Typography color="warning.main">Plus d'essais.</Typography>}
|
|
|
{isExpired() && <Typography color="error.main">Temps écoulé.</Typography>}
|
|
|
</>
|
|
|
}
|
|
|
/>
|
|
|
)
|
|
|
}
|