🎮 Fix question selection, add score editing, media host-only playback

- Fix: select questions by questionIndex instead of points (fixes same-points categories)
- Fix: persist questionIndex in MongoDB schema for game state recovery
- Fix: update scores in UI on wrong answer
- Add: host can manually adjust team scores
- Add: audio/video plays only on host device
- Add: replay button for host after media ends
- Fix: answer media modal shown only to host
This commit is contained in:
Cosmo
2026-03-22 10:21:51 +00:00
parent c5e1344610
commit ddad7f2126
9 changed files with 166 additions and 50 deletions

View File

@@ -1,21 +1,11 @@
function GameBoard({ questions, usedQuestions, onSelectQuestion, currentRound, isHost }) {
const isQuestionUsed = (category, points, questionIndex) => {
const foundByIndex = usedQuestions.find(
q => q.category === category && q.points === points && q.questionIndex === questionIndex
return usedQuestions.some(
q => q.category === category && (
q.questionIndex === questionIndex ||
(q.questionIndex === undefined && q.points === points)
)
);
if (foundByIndex) return true;
const usedCount = usedQuestions.filter(
q => q.category === category && q.points === points
).length;
if (usedCount === 0) return false;
const categoryData = questions.find(cat => cat.name === category);
const questionsWithSamePoints = categoryData?.questions
.map((q, idx) => ({ ...q, originalIndex: idx }))
.filter(q => q.points === points) || [];
const positionAmongSamePoints = questionsWithSamePoints.findIndex(q => q.originalIndex === questionIndex);
return positionAmongSamePoints >= 0 && positionAmongSamePoints < usedCount;
}
const getPointsForRound = (basePoints, round) => {
@@ -71,7 +61,7 @@ function GameBoard({ questions, usedQuestions, onSelectQuestion, currentRound, i
return (
<button
key={questionIndex}
onClick={() => !isUsed && isHost && onSelectQuestion(category.name, question.points)}
onClick={() => !isUsed && isHost && onSelectQuestion(category.name, question.points, questionIndex)}
disabled={isUsed || !isHost}
className={`flex-1 p-3 md:p-5 rounded-xl font-bold text-base md:text-xl transition-all duration-300 ${
isUsed

View File

@@ -52,14 +52,28 @@ function QuestionModal({ question, timer, onSubmitAnswer, answeringTeamName, isH
<div className="flex items-center justify-center mb-4">
<div className="text-5xl md:text-7xl animate-pulse-slow">🎵</div>
</div>
<audio ref={audioRef} controls autoPlay src={getMediaUrl(question.question)}
className="w-full mb-4" onEnded={handleMediaEnded}>
Ваш браузер не поддерживает аудио элемент.
</audio>
{isHost ? (
<>
<audio ref={audioRef} controls autoPlay src={getMediaUrl(question.question)}
className="w-full mb-4" onEnded={handleMediaEnded}>
Ваш браузер не поддерживает аудио элемент.
</audio>
{mediaEnded && (
<button onClick={() => { if (audioRef.current) { audioRef.current.currentTime = 0; audioRef.current.play(); setMediaEnded(false); } }}
className="w-full bg-gradient-to-r from-indigo-600/60 to-purple-600/60 hover:from-indigo-500/80 hover:to-purple-500/80 text-white font-bold py-2.5 rounded-xl text-sm md:text-base transition-all border border-indigo-400/30 mb-4">
🔄 Проиграть заново
</button>
)}
</>
) : (
<p className="text-indigo-300 text-base md:text-xl text-center mb-4">
🔊 Ведущий проигрывает аудио...
</p>
)}
{question.questionText && (
<p className="text-white text-base md:text-2xl text-center mt-4">{question.questionText}</p>
)}
{!mediaEnded && (
{isHost && !mediaEnded && (
<p className="text-amber-300/80 text-sm md:text-base text-center mt-2">
Таймер начнется после прослушивания
</p>
@@ -69,14 +83,28 @@ function QuestionModal({ question, timer, onSubmitAnswer, answeringTeamName, isH
case 'video':
return (
<div className={wrapperClass}>
<video ref={videoRef} controls autoPlay src={getMediaUrl(question.question)}
className="max-w-full max-h-64 md:max-h-96 mx-auto rounded-xl mb-4 shadow-lg" onEnded={handleMediaEnded}>
Ваш браузер не поддерживает видео элемент.
</video>
{isHost ? (
<>
<video ref={videoRef} controls autoPlay src={getMediaUrl(question.question)}
className="max-w-full max-h-64 md:max-h-96 mx-auto rounded-xl mb-4 shadow-lg" onEnded={handleMediaEnded}>
Ваш браузер не поддерживает видео элемент.
</video>
{mediaEnded && (
<button onClick={() => { if (videoRef.current) { videoRef.current.currentTime = 0; videoRef.current.play(); setMediaEnded(false); } }}
className="w-full bg-gradient-to-r from-indigo-600/60 to-purple-600/60 hover:from-indigo-500/80 hover:to-purple-500/80 text-white font-bold py-2.5 rounded-xl text-sm md:text-base transition-all border border-indigo-400/30 mb-4">
🔄 Проиграть заново
</button>
)}
</>
) : (
<p className="text-indigo-300 text-base md:text-xl text-center mb-4">
🔊 Ведущий проигрывает видео...
</p>
)}
{question.questionText && (
<p className="text-white text-base md:text-2xl text-center mt-4">{question.questionText}</p>
)}
{!mediaEnded && (
{isHost && !mediaEnded && (
<p className="text-amber-300/80 text-sm md:text-base text-center mt-2">
Таймер начнется после просмотра
</p>

View File

@@ -1,4 +1,22 @@
function TeamScores({ teams, myTeamId }) {
import { useState } from 'react';
function TeamScores({ teams, myTeamId, isHost, onAdjustScore }) {
const [editingTeam, setEditingTeam] = useState(null);
const [adjustValue, setAdjustValue] = useState('');
const handleAdjust = (teamId, amount) => {
if (onAdjustScore) onAdjustScore(teamId, amount);
};
const handleCustomAdjust = (teamId) => {
const val = parseInt(adjustValue);
if (!isNaN(val) && val !== 0) {
handleAdjust(teamId, val);
setAdjustValue('');
setEditingTeam(null);
}
};
return (
<div className="grid grid-cols-2 sm:grid-cols-2 lg:grid-cols-4 gap-2 md:gap-4 mb-4 md:mb-6">
{teams.map((team, index) => {
@@ -27,6 +45,46 @@ function TeamScores({ teams, myTeamId }) {
</h3>
<p className="font-display text-xl md:text-3xl font-bold text-gradient-gold">{team.score}</p>
<p className="text-[10px] md:text-xs text-gray-400">баллов</p>
{isHost && (
<div className="mt-2">
{editingTeam === team.id ? (
<div className="flex flex-col gap-1.5">
<div className="flex gap-1">
{[-100, -50, 50, 100].map(val => (
<button key={val} onClick={() => handleAdjust(team.id, val)}
className={`flex-1 text-[10px] md:text-xs font-bold py-1 rounded-lg transition-all active:scale-90 ${
val < 0
? 'bg-red-500/30 hover:bg-red-500/50 text-red-300 border border-red-500/30'
: 'bg-emerald-500/30 hover:bg-emerald-500/50 text-emerald-300 border border-emerald-500/30'
}`}>
{val > 0 ? '+' : ''}{val}
</button>
))}
</div>
<div className="flex gap-1">
<input type="number" value={adjustValue} onChange={e => setAdjustValue(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleCustomAdjust(team.id)}
placeholder="±"
className="flex-1 bg-slate-800/80 border border-slate-600/50 rounded-lg px-2 py-1 text-xs text-white text-center focus:outline-none focus:border-blue-400 w-0" />
<button onClick={() => handleCustomAdjust(team.id)}
className="bg-blue-500/30 hover:bg-blue-500/50 text-blue-300 border border-blue-500/30 rounded-lg px-2 py-1 text-xs font-bold transition-all">
OK
</button>
<button onClick={() => { setEditingTeam(null); setAdjustValue(''); }}
className="bg-slate-600/30 hover:bg-slate-600/50 text-gray-300 border border-slate-500/30 rounded-lg px-2 py-1 text-xs transition-all">
</button>
</div>
</div>
) : (
<button onClick={() => setEditingTeam(team.id)}
className="w-full text-[10px] md:text-xs text-gray-400 hover:text-white bg-white/5 hover:bg-white/10 rounded-lg py-1 transition-all">
Изменить
</button>
)}
</div>
)}
</div>
)
})}
@@ -34,4 +92,4 @@ function TeamScores({ teams, myTeamId }) {
)
}
export default TeamScores
export default TeamScores

View File

@@ -79,6 +79,10 @@ function Game() {
setTimer(timer)
})
socket.on('score-updated', ({ teams }) => {
setGame(prev => prev ? { ...prev, teams } : prev);
})
socket.on('timer-update', ({ timer }) => { setTimer(timer) })
socket.on('time-up', ({ answerMedia, shouldFinish }) => {
@@ -94,8 +98,16 @@ function Game() {
console.log(`${teamName} buzzing!`)
})
socket.on('answer-result', ({ teamId, isCorrect, questionClosed, answerMedia, shouldFinish }) => {
console.log('Answer result:', { teamId, isCorrect, questionClosed, answerMedia, shouldFinish })
socket.on('answer-result', ({ teamId, isCorrect, newScore, questionClosed, answerMedia, shouldFinish }) => {
console.log('Answer result:', { teamId, isCorrect, newScore, questionClosed, answerMedia, shouldFinish })
// Update team score in local state
if (newScore !== undefined) {
setGame(prev => {
if (!prev) return prev;
const updatedTeams = prev.teams.map(t => t.id === teamId ? { ...t, score: newScore } : t);
return { ...prev, teams: updatedTeams };
});
}
if (questionClosed) {
if (answerMedia) {
setAnswerMedia(answerMedia)
@@ -124,6 +136,7 @@ function Game() {
socket.off('game-started')
socket.off('host-game-loaded')
socket.off('question-selected')
socket.off('score-updated')
socket.off('timer-update')
socket.off('time-up')
socket.off('team-buzzing')
@@ -134,8 +147,11 @@ function Game() {
}, [teamName, questions, isHost])
const handleStartGame = () => { socket.emit('start-game', { gameCode }) }
const handleSelectQuestion = (category, points) => {
if (isHost) socket.emit('select-question', { gameCode, category, points })
const handleSelectQuestion = (category, points, questionIndex) => {
if (isHost) socket.emit('select-question', { gameCode, category, points, questionIndex })
}
const handleAdjustScore = (teamId, amount) => {
socket.emit('adjust-score', { gameCode, teamId, amount });
}
const handleBuzzIn = () => {
if (myTeam) socket.emit('buzz-in', { gameCode, teamId: myTeam.id })
@@ -246,7 +262,7 @@ function Game() {
</div>
{/* Team scores */}
<TeamScores teams={game.teams} myTeamId={myTeam?.id} />
<TeamScores teams={game.teams} myTeamId={myTeam?.id} isHost={isHost} onAdjustScore={handleAdjustScore} />
{/* Waiting state */}
{game.status === 'waiting' && isHost && (
@@ -304,7 +320,7 @@ function Game() {
)}
{/* Answer media modal */}
{answerMedia && (
{answerMedia && isHost && (
<div className="fixed inset-0 bg-black/90 backdrop-blur-sm flex items-center justify-center z-50 p-4 animate-scale-in">
<div className="glass-strong p-6 md:p-10 rounded-2xl max-w-4xl w-full shadow-2xl glow-blue">
<h2 className="font-display text-2xl md:text-4xl font-bold text-gradient text-center mb-6 md:mb-8">