🎮 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 }) { function GameBoard({ questions, usedQuestions, onSelectQuestion, currentRound, isHost }) {
const isQuestionUsed = (category, points, questionIndex) => { const isQuestionUsed = (category, points, questionIndex) => {
const foundByIndex = usedQuestions.find( return usedQuestions.some(
q => q.category === category && q.points === points && q.questionIndex === questionIndex 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) => { const getPointsForRound = (basePoints, round) => {
@@ -71,7 +61,7 @@ function GameBoard({ questions, usedQuestions, onSelectQuestion, currentRound, i
return ( return (
<button <button
key={questionIndex} key={questionIndex}
onClick={() => !isUsed && isHost && onSelectQuestion(category.name, question.points)} onClick={() => !isUsed && isHost && onSelectQuestion(category.name, question.points, questionIndex)}
disabled={isUsed || !isHost} disabled={isUsed || !isHost}
className={`flex-1 p-3 md:p-5 rounded-xl font-bold text-base md:text-xl transition-all duration-300 ${ className={`flex-1 p-3 md:p-5 rounded-xl font-bold text-base md:text-xl transition-all duration-300 ${
isUsed 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="flex items-center justify-center mb-4">
<div className="text-5xl md:text-7xl animate-pulse-slow">🎵</div> <div className="text-5xl md:text-7xl animate-pulse-slow">🎵</div>
</div> </div>
<audio ref={audioRef} controls autoPlay src={getMediaUrl(question.question)} {isHost ? (
className="w-full mb-4" onEnded={handleMediaEnded}> <>
Ваш браузер не поддерживает аудио элемент. <audio ref={audioRef} controls autoPlay src={getMediaUrl(question.question)}
</audio> 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 && ( {question.questionText && (
<p className="text-white text-base md:text-2xl text-center mt-4">{question.questionText}</p> <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 className="text-amber-300/80 text-sm md:text-base text-center mt-2">
Таймер начнется после прослушивания Таймер начнется после прослушивания
</p> </p>
@@ -69,14 +83,28 @@ function QuestionModal({ question, timer, onSubmitAnswer, answeringTeamName, isH
case 'video': case 'video':
return ( return (
<div className={wrapperClass}> <div className={wrapperClass}>
<video ref={videoRef} controls autoPlay src={getMediaUrl(question.question)} {isHost ? (
className="max-w-full max-h-64 md:max-h-96 mx-auto rounded-xl mb-4 shadow-lg" onEnded={handleMediaEnded}> <>
Ваш браузер не поддерживает видео элемент. <video ref={videoRef} controls autoPlay src={getMediaUrl(question.question)}
</video> 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 && ( {question.questionText && (
<p className="text-white text-base md:text-2xl text-center mt-4">{question.questionText}</p> <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 className="text-amber-300/80 text-sm md:text-base text-center mt-2">
Таймер начнется после просмотра Таймер начнется после просмотра
</p> </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 ( 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"> <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) => { {teams.map((team, index) => {
@@ -27,6 +45,46 @@ function TeamScores({ teams, myTeamId }) {
</h3> </h3>
<p className="font-display text-xl md:text-3xl font-bold text-gradient-gold">{team.score}</p> <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> <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> </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) setTimer(timer)
}) })
socket.on('score-updated', ({ teams }) => {
setGame(prev => prev ? { ...prev, teams } : prev);
})
socket.on('timer-update', ({ timer }) => { setTimer(timer) }) socket.on('timer-update', ({ timer }) => { setTimer(timer) })
socket.on('time-up', ({ answerMedia, shouldFinish }) => { socket.on('time-up', ({ answerMedia, shouldFinish }) => {
@@ -94,8 +98,16 @@ function Game() {
console.log(`${teamName} buzzing!`) console.log(`${teamName} buzzing!`)
}) })
socket.on('answer-result', ({ teamId, isCorrect, questionClosed, answerMedia, shouldFinish }) => { socket.on('answer-result', ({ teamId, isCorrect, newScore, questionClosed, answerMedia, shouldFinish }) => {
console.log('Answer result:', { teamId, isCorrect, 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 (questionClosed) {
if (answerMedia) { if (answerMedia) {
setAnswerMedia(answerMedia) setAnswerMedia(answerMedia)
@@ -124,6 +136,7 @@ function Game() {
socket.off('game-started') socket.off('game-started')
socket.off('host-game-loaded') socket.off('host-game-loaded')
socket.off('question-selected') socket.off('question-selected')
socket.off('score-updated')
socket.off('timer-update') socket.off('timer-update')
socket.off('time-up') socket.off('time-up')
socket.off('team-buzzing') socket.off('team-buzzing')
@@ -134,8 +147,11 @@ function Game() {
}, [teamName, questions, isHost]) }, [teamName, questions, isHost])
const handleStartGame = () => { socket.emit('start-game', { gameCode }) } const handleStartGame = () => { socket.emit('start-game', { gameCode }) }
const handleSelectQuestion = (category, points) => { const handleSelectQuestion = (category, points, questionIndex) => {
if (isHost) socket.emit('select-question', { gameCode, category, points }) if (isHost) socket.emit('select-question', { gameCode, category, points, questionIndex })
}
const handleAdjustScore = (teamId, amount) => {
socket.emit('adjust-score', { gameCode, teamId, amount });
} }
const handleBuzzIn = () => { const handleBuzzIn = () => {
if (myTeam) socket.emit('buzz-in', { gameCode, teamId: myTeam.id }) if (myTeam) socket.emit('buzz-in', { gameCode, teamId: myTeam.id })
@@ -246,7 +262,7 @@ function Game() {
</div> </div>
{/* Team scores */} {/* Team scores */}
<TeamScores teams={game.teams} myTeamId={myTeam?.id} /> <TeamScores teams={game.teams} myTeamId={myTeam?.id} isHost={isHost} onAdjustScore={handleAdjustScore} />
{/* Waiting state */} {/* Waiting state */}
{game.status === 'waiting' && isHost && ( {game.status === 'waiting' && isHost && (
@@ -304,7 +320,7 @@ function Game() {
)} )}
{/* Answer media modal */} {/* 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="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"> <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"> <h2 className="font-display text-2xl md:text-4xl font-bold text-gradient text-center mb-6 md:mb-8">

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

View File

@@ -332,9 +332,10 @@
"questions": [ "questions": [
{ {
"points": 100, "points": 100,
"type": "text", "type": "image",
"question": "", "question": "/images/professii_1.jpg",
"answer": "" "questionText": "Что зашифровано в этой картинке?",
"answer": "Третий закон Ньютона"
}, },
{ {
"points": 200, "points": 200,

View File

@@ -30,7 +30,8 @@ const gameSchema = new mongoose.Schema({
}, },
usedQuestions: [{ usedQuestions: [{
category: String, category: String,
points: Number points: Number,
questionIndex: Number
}], }],
status: { status: {
type: String, type: String,

View File

@@ -202,7 +202,7 @@ export const setupSocketHandlers = (io, activeGames) => {
}); });
// Выбор вопроса // Выбор вопроса
socket.on('select-question', ({ gameCode, category, points }) => { socket.on('select-question', ({ gameCode, category, points, questionIndex: requestedIndex }) => {
const game = activeGames.get(gameCode); const game = activeGames.get(gameCode);
if (!game) return; if (!game) return;
@@ -210,20 +210,21 @@ export const setupSocketHandlers = (io, activeGames) => {
// Находим категорию // Находим категорию
const categoryData = game.selectedCategories.find(cat => cat.name === category); const categoryData = game.selectedCategories.find(cat => cat.name === category);
// Подсчитываем, сколько вопросов с такими баллами уже использовано в этой категории // Используем переданный индекс вопроса напрямую
const usedCount = game.usedQuestions.filter( const questionIndex = requestedIndex;
q => q.category === category && q.points === points
).length;
// Находим следующий неиспользованный вопрос с такими баллами (по порядку) // Проверяем, не использован ли уже этот вопрос
const questionsWithPoints = categoryData?.questions const alreadyUsed = game.usedQuestions.some(
.map((q, idx) => ({ ...q, originalIndex: idx })) q => q.category === category && q.questionIndex === questionIndex
.filter(q => q.points === points); );
const questionIndex = questionsWithPoints[usedCount]?.originalIndex; if (alreadyUsed) {
console.log('❌ Question already used:', { category, questionIndex });
socket.emit('error', { message: 'Этот вопрос уже был использован' });
return;
}
console.log(`📊 Select question: category="${category}", points=${points}, usedCount=${usedCount}, questionIndex=${questionIndex}`); console.log(`📊 Select question: category="${category}", points=${points}, questionIndex=${questionIndex}`);
console.log(`📊 Questions with these points:`, questionsWithPoints.map(q => q.originalIndex));
if (questionIndex === undefined) { if (questionIndex === undefined) {
console.log('❌ No available question found'); console.log('❌ No available question found');
@@ -730,6 +731,27 @@ export const setupSocketHandlers = (io, activeGames) => {
}); });
// Отключение // Отключение
// Manual score adjustment by host
socket.on('adjust-score', async ({ gameCode, teamId, amount }) => {
const game = activeGames.get(gameCode);
if (!game || socket.id !== game.hostId) return;
const team = game.teams.find(t => t.id === teamId);
if (!team) return;
team.score += amount;
console.log(`Score adjusted: ${team.name} ${amount > 0 ? '+' : ''}${amount} = ${team.score}`);
try {
await Game.findOneAndUpdate({ gameCode }, { teams: game.teams });
} catch (error) {
console.log('Error updating DB:', error.message);
}
io.to(gameCode).emit('score-updated', { teams: game.teams });
});
socket.on('disconnect', () => { socket.on('disconnect', () => {
console.log('👤 Client disconnected:', socket.id); console.log('👤 Client disconnected:', socket.id);