Initial commit: Своя Игра - multiplayer quiz game

This commit is contained in:
Cosmo
2026-03-21 05:00:06 +00:00
commit 1d46ad8b06
80 changed files with 9215 additions and 0 deletions

18
server/Dockerfile Normal file
View File

@@ -0,0 +1,18 @@
FROM node:20-alpine
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm install
# Copy source code
COPY . .
# Expose port
EXPOSE 3001
# Start server
CMD ["npm", "start"]

1731
server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

25
server/package.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "svoya-igra-server",
"version": "1.0.0",
"description": "Backend server for Svoya Igra",
"main": "src/index.js",
"type": "module",
"scripts": {
"dev": "nodemon src/index.js",
"start": "node src/index.js"
},
"keywords": [],
"author": "",
"license": "MIT",
"dependencies": {
"express": "^4.18.2",
"socket.io": "^4.7.2",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"mongoose": "^8.0.3",
"nanoid": "^5.0.4"
},
"devDependencies": {
"nodemon": "^3.0.2"
}
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 362 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 347 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 631 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 506 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 883 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 446 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 398 KiB

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,396 @@
{
"categories": [
{
"id": "reverse",
"name": "Песни наоборот",
"questions": [
{
"points": 100,
"type": "audio",
"question": "/audio/aeropotri_reverse.mp3",
"questionText": "Что это за песня?",
"answer": "Агутин - Аэропорты",
"answerMedia": "/audio/aeropotri_cut.mp3"
},
{
"points": 200,
"type": "audio",
"question": "/audio/derji_reverse.mp3",
"questionText": "Что это за песня?",
"answer": "Дима Билан - Держи",
"answerMedia": "/audio/derji_cut.mp3"
},
{
"points": 300,
"type": "audio",
"question": "/audio/kvartali_reverse.mp3",
"questionText": "Что это за песня?",
"answer": "Звери - Районы кварталы",
"answerMedia": "/audio/kvartali_cut.mp3"
},
{
"points": 400,
"type": "audio",
"question": "/audio/korni_reverse.mp3",
"questionText": "Что это за песня?",
"answer": "Корни - Ты узнаешь ее",
"answerMedia": "/audio/korni_cut.mp3"
},
{
"points": 500,
"type": "audio",
"question": "/audio/znaesh_reverse.mp3",
"questionText": "Что это за песня?",
"answer": "Максим - Знаешь ли ты",
"answerMedia": "/audio/znaesh_cut.mp3"
}
]
},
{
"id": "prodolji",
"name": "Продолжи песню",
"questions": [
{
"points": 100,
"type": "audio",
"question": "/audio/kolambia_cut.mp3",
"questionText": "Что поется дальше?",
"answer": "Мы увидим сзади",
"answerMedia": "/audio/kolambia_answer.mp3"
},
{
"points": 200,
"type": "audio",
"question": "/audio/vera_cut.mp3",
"questionText": "Слова из чего? (продолжи)",
"answer": "Из ярких лампочек",
"answerMedia": "/audio/vera_answer.mp3"
},
{
"points": 300,
"type": "audio",
"question": "/audio/prichin_cut.mp3",
"questionText": "Третья причина - это?",
"answer": "Все твои слова",
"answerMedia": "/audio/prichin_answer.mp3"
},
{
"points": 400,
"type": "audio",
"question": "/audio/grim_cut.mp3",
"questionText": "Что дальше?",
"answer": "Силы не жалей",
"answerMedia": "/audio/grim_answer.mp3"
},
{
"points": 500,
"type": "audio",
"question": "/audio/narkotik_cut.mp3",
"questionText": "Что дальше?",
"answer": "Что любят гламур",
"answerMedia": "/audio/narkotik_answer.mp3"
}
]
},
{
"id": "danya",
"name": "Вопросы от Дани",
"questions": [
{
"points": 100,
"type": "text",
"question": "Из-за чего Даня обиделся на Юлю Шубину на даче летом?",
"answer": "Она забыла его имя"
},
{
"points": 200,
"type": "text",
"question": "Баскетбол, танцы, легкая атлетика, настольный теннис. Чем из этого не занимался Даня?",
"answer": "Настольный теннис"
},
{
"points": 300,
"type": "text",
"question": "У какого героя мультфильма в голове были опилки?",
"answer": "Винни-пух"
},
{
"points": 400,
"type": "image",
"question": "/images/danya_4.jpg",
"questionText": "Какой патронус у МакГонагалл?",
"answer": "Кошка"
},
{
"points": 500,
"type": "image",
"question": "/images/danya_5.jpg",
"questionText": "Сколько это будет в десятичной системе?",
"answer": "25"
}
]
},
{
"id": "sumerki",
"name": "Сумерки",
"questions": [
{
"points": 100,
"type": "image",
"question": "/images/sumerki_1.jpg",
"questionText": "Как зовут главную героиню?",
"answer": "Белла"
},
{
"points": 200,
"type": "image",
"question": "/images/sumerki_2.jpg",
"questionText": "Как зовут вампира, в которого влюбляется главная героиня?",
"answer": "Эдвард Каллен"
},
{
"points": 300,
"type": "image",
"question": "/images/sumerki_3.jpg",
"questionText": "В какую игру играет семья Калленов во время грозы?",
"answer": "Бейсбол"
},
{
"points": 400,
"type": "image",
"question": "/images/sumerki_4.jpg",
"questionText": "Куда Джеймс заманивает Беллу для финальной схватки?",
"answer": "В балетную студию в Фениксе"
},
{
"points": 500,
"type": "image",
"question": "/images/sumerki_5.jpg",
"questionText": "Какую ложь придумывают, чтобы объяснить травмы Беллы после нападения Джеймса?",
"answer": "Что она упала с лестницы и вылетела через окно"
}
]
},
{
"id": "playlist",
"name": "Чей плейлист",
"questions": [
{
"points": 300,
"type": "audio",
"question": "/audio/ksusha_playlist.mp3",
"questionText": "Кто выбрал эту песню?",
"answer": "Ксюша"
},
{
"points": 300,
"type": "audio",
"question": "/audio/dasha_playlist.mp3",
"questionText": "Кто выбрал эту песню?",
"answer": "Даша"
},
{
"points": 300,
"type": "audio",
"question": "/audio/sveta_playlist.mp3",
"questionText": "Кто выбрал эту песню?",
"answer": "Света"
},
{
"points": 300,
"type": "audio",
"question": "/audio/shubina_playlist.mp3",
"questionText": "Кто выбрал эту песню?",
"answer": "Юля Шубина"
},
{
"points": 300,
"type": "audio",
"question": "/audio/mosina_playlist.mp3",
"questionText": "Кто выбрал эту песню?",
"answer": "Юля Мосина (Слесарчук)"
}
]
},
{
"id": "poezdki",
"name": "Наши поездки",
"questions": [
{
"points": 100,
"type": "text",
"question": "Сколько шагов мы прошли в Пскове? (Диапазон в 2 тысячи шагов)",
"answer": "25 тысяч шагов"
},
{
"points": 200,
"type": "text",
"question": "",
"answer": ""
},
{
"points": 300,
"type": "text",
"question": "",
"answer": ""
},
{
"points": 400,
"type": "text",
"question": "",
"answer": ""
},
{
"points": 500,
"type": "text",
"question": "",
"answer": ""
}
]
},
{
"id": "facti",
"name": "Факты о нас",
"questions": [
{
"points": 100,
"type": "text",
"question": "",
"answer": ""
},
{
"points": 200,
"type": "text",
"question": "",
"answer": ""
},
{
"points": 300,
"type": "text",
"question": "Сколько лет Ксюша носила брекеты? (Диапазон в 2 месяца)",
"answer": "1 год и 7 месяцев (19 месяцев)"
},
{
"points": 400,
"type": "text",
"question": "",
"answer": ""
},
{
"points": 500,
"type": "text",
"question": "",
"answer": ""
}
]
},
{
"id": "univer",
"name": "Универ",
"questions": [
{
"points": 100,
"type": "text",
"question": "",
"answer": ""
},
{
"points": 200,
"type": "text",
"question": "В каком году основан наш любимый универ?",
"answer": "1832"
},
{
"points": 300,
"type": "text",
"question": "",
"answer": ""
},
{
"points": 400,
"type": "text",
"question": "",
"answer": ""
},
{
"points": 500,
"type": "text",
"question": "",
"answer": ""
}
]
},
{
"id": "stroyka",
"name": "Вопросы о стройке",
"questions": [
{
"points": 100,
"type": "text",
"question": "",
"answer": ""
},
{
"points": 200,
"type": "text",
"question": "",
"answer": ""
},
{
"points": 300,
"type": "text",
"question": "",
"answer": ""
},
{
"points": 400,
"type": "text",
"question": "Как расшифровывается КГИОП?",
"answer": "Комитет по государственному контролю, использованию и охране памятников истории и культуры Санкт-Петербурга"
},
{
"points": 500,
"type": "text",
"question": "",
"answer": ""
}
]
},
{
"id": "vstrechi",
"name": "Наши встречи",
"questions": [
{
"points": 100,
"type": "text",
"question": "",
"answer": ""
},
{
"points": 200,
"type": "text",
"question": "Когда мы толпой ходили на БухайТанцуй? Месяц и год",
"answer": "Август 2023 года"
},
{
"points": 300,
"type": "text",
"question": "",
"answer": ""
},
{
"points": 400,
"type": "text",
"question": "",
"answer": ""
},
{
"points": 500,
"type": "text",
"question": "",
"answer": ""
}
]
}
]
}

View File

@@ -0,0 +1,407 @@
{
"categories": [
{
"id": "reverse",
"name": "Песни наоборот",
"questions": [
{
"points": 100,
"type": "audio",
"question": "/audio/aeropotri_reverse.mp3",
"questionText": "Что это за песня?",
"answer": "Агутин - Аэропорты",
"answerMedia": "/audio/aeropotri_cut.mp3"
},
{
"points": 200,
"type": "audio",
"question": "/audio/derji_reverse.mp3",
"questionText": "Что это за песня?",
"answer": "Дима Билан - Держи",
"answerMedia": "/audio/derji_cut.mp3"
},
{
"points": 300,
"type": "audio",
"question": "/audio/kvartali_reverse.mp3",
"questionText": "Что это за песня?",
"answer": "Звери - Районы кварталы",
"answerMedia": "/audio/kvartali_cut.mp3"
},
{
"points": 400,
"type": "audio",
"question": "/audio/korni_reverse.mp3",
"questionText": "Что это за песня?",
"answer": "Корни - Ты узнаешь ее",
"answerMedia": "/audio/korni_cut.mp3"
},
{
"points": 500,
"type": "audio",
"question": "/audio/znaesh_reverse.mp3",
"questionText": "Что это за песня?",
"answer": "Максим - Знаешь ли ты",
"answerMedia": "/audio/znaesh_cut.mp3"
}
]
},
{
"id": "prodolji",
"name": "Продолжи песню",
"questions": [
{
"points": 100,
"type": "audio",
"question": "/audio/kolambia_cut.mp3",
"questionText": "Что поется дальше?",
"answer": "Мы увидим сзади",
"answerMedia": "/audio/kolambia_answer.mp3"
},
{
"points": 200,
"type": "audio",
"question": "/audio/vera_cut.mp3",
"questionText": "Слова из чего? (продолжи)",
"answer": "Из ярких лампочек",
"answerMedia": "/audio/vera_answer.mp3"
},
{
"points": 300,
"type": "audio",
"question": "/audio/prichin_cut.mp3",
"questionText": "Третья причина - это?",
"answer": "Все твои слова",
"answerMedia": "/audio/prichin_answer.mp3"
},
{
"points": 400,
"type": "audio",
"question": "/audio/grim_cut.mp3",
"questionText": "Что дальше?",
"answer": "Силы не жалей",
"answerMedia": "/audio/grim_answer.mp3"
},
{
"points": 500,
"type": "audio",
"question": "/audio/narkotik_cut.mp3",
"questionText": "Что дальше?",
"answer": "Что любят гламур",
"answerMedia": "/audio/narkotik_answer.mp3"
}
]
},
{
"id": "danya",
"name": "Вопросы от Дани",
"questions": [
{
"points": 100,
"type": "text",
"question": "Из-за чего Даня обиделся на Юлю Шубину на даче летом?",
"answer": "Она забыла его имя"
},
{
"points": 200,
"type": "text",
"question": "Баскетбол, танцы, легкая атлетика, настольный теннис. Чем из этого не занимался Даня?",
"answer": "Настольный теннис"
},
{
"points": 300,
"type": "text",
"question": "У какого героя мультфильма в голове были опилки?",
"answer": "Винни-пух"
},
{
"points": 400,
"type": "image",
"question": "/images/danya_4.jpg",
"questionText": "Какой патронус у МакГонагалл?",
"answer": "Кошка"
},
{
"points": 500,
"type": "image",
"question": "/images/danya_5.jpg",
"questionText": "Сколько это будет в десятичной системе?",
"answer": "25"
}
]
},
{
"id": "sumerki",
"name": "Сумерки",
"questions": [
{
"points": 100,
"type": "image",
"question": "/images/sumerki_1.jpg",
"questionText": "Как зовут главную героиню?",
"answer": "Белла"
},
{
"points": 200,
"type": "image",
"question": "/images/sumerki_2.jpg",
"questionText": "Как зовут вампира, в которого влюбляется главная героиня?",
"answer": "Эдвард Каллен"
},
{
"points": 300,
"type": "image",
"question": "/images/sumerki_3.jpg",
"questionText": "В какую игру играет семья Калленов во время грозы?",
"answer": "Бейсбол"
},
{
"points": 400,
"type": "image",
"question": "/images/sumerki_4.jpg",
"questionText": "Куда Джеймс заманивает Беллу для финальной схватки?",
"answer": "В балетную студию в Фениксе"
},
{
"points": 500,
"type": "image",
"question": "/images/sumerki_5.jpg",
"questionText": "Какую ложь придумывают, чтобы объяснить травмы Беллы после нападения Джеймса?",
"answer": "Что она упала с лестницы и вылетела через окно"
}
]
},
{
"id": "playlist",
"name": "Чей плейлист",
"questions": [
{
"points": 300,
"type": "audio",
"question": "/audio/ksusha_playlist.mp3",
"questionText": "Кто выбрал эту песню?",
"answer": "Ксюша (Гарик Сукачёв - Ботик)"
},
{
"points": 300,
"type": "audio",
"question": "/audio/dasha_playlist.mp3",
"questionText": "Кто выбрал эту песню?",
"answer": "Даша (Skinny Kaleo)"
},
{
"points": 300,
"type": "audio",
"question": "/audio/sveta_playlist.mp3",
"questionText": "Кто выбрал эту песню?",
"answer": "Света (30 Seconds to Mars - The Kill)"
},
{
"points": 300,
"type": "audio",
"question": "/audio/shubina_playlist.mp3",
"questionText": "Кто выбрал эту песню?",
"answer": "Юля Шубина (Второй Ка - Тени от пальм)"
},
{
"points": 300,
"type": "audio",
"question": "/audio/mosina_playlist.mp3",
"questionText": "Кто выбрал эту песню?",
"answer": "Юля Мосина (Слесарчук)"
}
]
},
{
"id": "poezdki",
"name": "Наши поездки",
"questions": [
{
"points": 100,
"type": "text",
"question": "Сколько шагов мы прошли в Пскове? (Диапазон в 2 тысячи шагов)",
"answer": "25 тысяч шагов"
},
{
"points": 200,
"type": "text",
"question": "Как называются керамические плитки, которые украшают храмы Ярославля?",
"answer": "Изразцы"
},
{
"points": 300,
"type": "text",
"question": "Как звали проводника в поезде Санкт-Петербург -> Ярославль?",
"answer": "Альберт"
},
{
"points": 400,
"type": "text",
"question": "Эта знаменитая княгиня, предположительно родом из Пскова, первая на Руси приняла христианство",
"answer": "Княгиня Ольга"
},
{
"points": 500,
"type": "image",
"question": "/images/poezdki_5.jpg",
"questionText": "Сколько лет исполнилось бы самой первой Церкви Благовещения на Городище в Великом Новгороде?",
"answer": "922"
}
]
},
{
"id": "facti",
"name": "Факты о нас",
"questions": [
{
"points": 100,
"type": "text",
"question": "Сколько времени жила Юля Мосина у Даши Неберовой на карантине?",
"answer": "2 месяца"
},
{
"points": 200,
"type": "image",
"question": "/images/kto_2.jpeg",
"questionText": "Кто на фото?",
"answer": "Даша Неберова"
},
{
"points": 300,
"type": "text",
"question": "Сколько лет Ксюша носила брекеты? (Диапазон в 2 месяца)",
"answer": "1 год и 7 месяцев (19 месяцев)"
},
{
"points": 400,
"type": "image",
"question": "/images/kto_4.jpg",
"questionText": "С чем Света больше всего любила играть в детстве?",
"answer": "Животные игрушки"
},
{
"points": 500,
"type": "image",
"question": "/images/kto_5.jpg",
"questionText": "Что с ней случилось?",
"answer": "Аллергия на текилу"
}
]
},
{
"id": "univer",
"name": "Универ",
"questions": [
{
"points": 100,
"type": "text",
"question": "В каком году основан наш любимый универ?",
"answer": "1832"
},
{
"points": 200,
"type": "text",
"question": "По мнению Барышниковой Тамары Николаевны (препод по экологии), какой самый экологически грязный город России?",
"answer": "Норильск"
},
{
"points": 300,
"type": "text",
"question": "Назовите полное название предмета, на котором мы изучали AutoCAD",
"answer": "Информационные технологии графического проектирования"
},
{
"points": 400,
"type": "image",
"question": "/images/univer_5.jpg",
"questionText": "Кто это (какой предмет вел)?",
"answer": "Препод по философии (Гурьев Евгений Павлович)"
},
{
"points": 500,
"type": "video",
"question": "/video/univer_4.mp4",
"questionText": "Чей это голос (аудиозапись)?",
"answer": "Разумнова Елена Альбертовна"
}
]
},
{
"id": "stroyka",
"name": "Профессия",
"questions": [
{
"points": 100,
"type": "text",
"question": "",
"answer": ""
},
{
"points": 200,
"type": "image",
"question": "/images/professii_3.jpg",
"questionText": "Какой кондиционер изображен на картинке?",
"answer": "Канальный"
},
{
"points": 300,
"type": "image",
"question": "/images/professii_2.jpg",
"questionText": "Кто из глав нашей страны по своей основной профессии был строителем?",
"answer": "Б.Н. Ельцин"
},
{
"points": 400,
"type": "text",
"question": "Как расшифровывается КГИОП?",
"answer": "Комитет по государственному контролю, использованию и охране памятников истории и культуры Санкт-Петербурга"
},
{
"points": 500,
"type": "image",
"question": "/images/professii_5.jpg",
"questionText": "Выберите верный вариант армирования конструкции",
"answer": "4"
}
]
},
{
"id": "vstrechi",
"name": "Наши встречи",
"questions": [
{
"points": 100,
"type": "text",
"question": "Какой предмет личной гигиены только одна из нас взяла на девичник Светы?",
"answer": "Зубная паста"
},
{
"points": 200,
"type": "text",
"question": "Когда мы толпой ходили на Бухай-Танцуй? Месяц и год",
"answer": "Август 2023 года"
},
{
"points": 300,
"type": "image",
"question": "/images/vstrechi_3.jpg",
"questionText": "Что скрыто за черным квадратом и находится на столе перед Ксюшей?",
"answer": "Учебник по металлическим конструкциям"
},
{
"points": 400,
"type": "video",
"question": "/video/vstrechi_4.mp4",
"questionText": "Продолжите крылатую фразу, которую сказала в тот вечер Даша. Для чего велась съемка?",
"answer": "Для смысла"
},
{
"points": 500,
"type": "text",
"question": "Как остановить время при встрече?",
"answer": "Вынуть батарейки из Дашиных часов в гостиной"
}
]
}
]
}

View File

@@ -0,0 +1,30 @@
Песни наоборот +
Продолжи песню +
Вопросы от Дани +
Сумерки +
Угадай из чьего плейлиста
1 +
2 +
3 +
4 +
5
Поездки +
Факты о нас +
Универ +
Профессии
1
2 +
3 +
4 +
5 +
Наши встречи +
Юля М
- Плейлист
Юля Ш
- Профессии

68
server/src/index.js Normal file
View File

@@ -0,0 +1,68 @@
import express from 'express';
import { createServer } from 'http';
import { Server } from 'socket.io';
import cors from 'cors';
import dotenv from 'dotenv';
import mongoose from 'mongoose';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
dotenv.config();
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const app = express();
const httpServer = createServer(app);
const io = new Server(httpServer, {
cors: {
origin: process.env.CLIENT_URL || 'http://localhost:5173',
methods: ['GET', 'POST']
}
});
app.use(cors());
app.use(express.json());
// Статичные файлы для медиа
app.use('/images', express.static(join(__dirname, '../public/images')));
app.use('/audio', express.static(join(__dirname, '../public/audio')));
app.use('/video', express.static(join(__dirname, '../public/video')));
// MongoDB connection (optional - можно использовать JSON файлы)
const connectDB = async () => {
if (process.env.MONGODB_URI) {
try {
await mongoose.connect(process.env.MONGODB_URI);
console.log('✅ MongoDB connected');
} catch (error) {
console.log('⚠️ MongoDB not connected, using JSON storage');
}
} else {
console.log('⚠️ No MongoDB URI, using JSON storage');
}
};
// Хранилище активных игр в памяти
const activeGames = new Map();
// Импорт роутов и сокет обработчиков
import questionsRouter from './routes/questions.js';
import gamesRouter from './routes/games.js';
import { setupSocketHandlers } from './socket/handlers.js';
app.use('/api/questions', questionsRouter);
app.use('/api/games', gamesRouter);
// WebSocket обработчики
setupSocketHandlers(io, activeGames);
const PORT = process.env.PORT || 3001;
connectDB().then(() => {
httpServer.listen(PORT, () => {
console.log(`🚀 Server running on port ${PORT}`);
});
});
export { io, activeGames };

54
server/src/models/Game.js Normal file
View File

@@ -0,0 +1,54 @@
import mongoose from 'mongoose';
const gameSchema = new mongoose.Schema({
gameCode: {
type: String,
required: true,
unique: true
},
hostId: String,
hostName: String,
teams: [{
id: String,
name: String,
score: { type: Number, default: 0 },
players: [{
id: String
}]
}],
currentRound: {
type: Number,
default: 1
},
selectedCategories: {
type: mongoose.Schema.Types.Mixed,
default: []
},
usedCategoryIds: {
type: [String],
default: []
},
usedQuestions: [{
category: String,
points: Number
}],
status: {
type: String,
enum: ['waiting', 'playing', 'finished'],
default: 'waiting'
},
currentQuestion: {
type: mongoose.Schema.Types.Mixed,
default: null
},
answeringTeam: {
type: String,
default: null
}
}, {
timestamps: true
});
const Game = mongoose.model('Game', gameSchema);
export default Game;

View File

@@ -0,0 +1,33 @@
import mongoose from 'mongoose';
const questionSchema = new mongoose.Schema({
category: {
type: String,
required: true
},
points: {
type: Number,
required: true,
enum: [100, 200, 300, 400, 500, 200, 400, 600, 800, 1000]
},
question: {
type: String,
required: true
},
answer: {
type: String,
required: true
},
round: {
type: Number,
required: true,
enum: [1, 2],
default: 1
}
}, {
timestamps: true
});
const Question = mongoose.model('Question', questionSchema);
export default Question;

View File

@@ -0,0 +1,46 @@
import express from 'express';
import { nanoid } from 'nanoid';
const router = express.Router();
// Создать новую игру
router.post('/create', (req, res) => {
try {
const gameCode = nanoid(6).toUpperCase();
const { teamName } = req.body;
const game = {
gameCode,
teams: [{
name: teamName,
score: 0,
players: []
}],
currentRound: 1,
usedQuestions: [],
status: 'waiting',
createdAt: new Date()
};
res.json({
success: true,
gameCode,
game
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Проверить существование игры
router.get('/:gameCode', (req, res) => {
try {
const { gameCode } = req.params;
// Проверка будет в Socket.io, т.к. игры хранятся в памяти
res.json({ exists: true });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
export default router;

View File

@@ -0,0 +1,31 @@
import express from 'express';
import { readFileSync, existsSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const router = express.Router();
const questionsPath = join(__dirname, '../data/questions.json');
// Helper функция для чтения вопросов
const getQuestions = () => {
if (!existsSync(questionsPath)) {
return { categories: [] };
}
const data = readFileSync(questionsPath, 'utf-8');
return JSON.parse(data);
};
// GET все категории и вопросы
router.get('/', (req, res) => {
try {
const questions = getQuestions();
res.json(questions);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
export default router;

View File

@@ -0,0 +1,762 @@
import { nanoid } from 'nanoid';
import { readFileSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import Game from '../models/Game.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Функция для выбора случайных 5 категорий (из всех доступных)
const selectRandomCategories = (allCategories, usedCategoryIds) => {
const availableCategories = allCategories.filter(
cat => !usedCategoryIds.includes(cat.id)
);
// Перемешиваем и берем первые 5
const shuffled = availableCategories.sort(() => Math.random() - 0.5);
return shuffled.slice(0, 5);
};
// Функция проверки, закончилась ли игра
const checkGameFinished = (game, allCategories) => {
// Проверяем, остались ли доступные категории
const availableCategories = allCategories.filter(
cat => !game.usedCategoryIds.includes(cat.id)
);
// Проверяем, все ли вопросы текущего раунда использованы
const totalQuestionsInRound = game.selectedCategories.reduce(
(sum, cat) => sum + cat.questions.length,
0
);
const allQuestionsUsed = game.usedQuestions.length >= totalQuestionsInRound;
// Игра заканчивается если:
// 1. Все вопросы текущего раунда использованы
// 2. И нет больше доступных категорий для следующего раунда
return allQuestionsUsed && availableCategories.length === 0;
};
// Функция для отправки обновления игры (без timerInterval)
const sendGameUpdate = (io, gameCode, game) => {
const { timerInterval, ...gameToSend } = game;
io.to(gameCode).emit('game-updated', gameToSend);
};
export const setupSocketHandlers = (io, activeGames) => {
io.on('connection', (socket) => {
console.log('👤 Client connected:', socket.id);
// Создание игры
socket.on('create-game', async ({ hostName }) => {
const gameCode = nanoid(6).toUpperCase();
// Загружаем все категории
const questionsPath = join(__dirname, '../data/questions.json');
const questionsData = JSON.parse(readFileSync(questionsPath, 'utf-8'));
console.log(`📚 Total categories available: ${questionsData.categories.length}`);
// Выбираем 5 случайных категорий
const selectedCategories = selectRandomCategories(questionsData.categories, []);
console.log(`🎲 Selected ${selectedCategories.length} categories for game ${gameCode}`);
const gameData = {
gameCode,
hostId: socket.id,
hostName: hostName,
teams: [],
currentRound: 1,
selectedCategories: selectedCategories,
usedCategoryIds: selectedCategories.map(cat => cat.id),
usedQuestions: [],
status: 'waiting',
currentQuestion: null,
answeringTeam: null,
timer: null
};
// Сохраняем в MongoDB
try {
await Game.create(gameData);
console.log(`💾 Game ${gameCode} saved to DB`);
} catch (error) {
console.log('⚠️ Error saving to DB:', error.message);
}
activeGames.set(gameCode, gameData);
socket.join(gameCode);
socket.emit('game-created', { gameCode, game: gameData });
console.log(`🎮 Game created: ${gameCode} by host ${hostName}`);
});
// Ведущий присоединяется к игре
socket.on('host-join-game', async ({ gameCode }) => {
let game = activeGames.get(gameCode);
// Если игры нет в памяти, пытаемся загрузить из БД
if (!game) {
try {
const dbGame = await Game.findOne({ gameCode }).lean();
if (dbGame) {
game = { ...dbGame, timer: null, timerInterval: null };
activeGames.set(gameCode, game);
console.log(`📥 Game ${gameCode} loaded from DB`);
}
} catch (error) {
console.log('⚠️ Error loading from DB:', error.message);
}
}
if (!game) {
socket.emit('error', { message: 'Игра не найдена' });
return;
}
// Обновляем hostId на текущий socket
game.hostId = socket.id;
// Обновляем в БД
try {
await Game.findOneAndUpdate({ gameCode }, { hostId: socket.id });
} catch (error) {
console.log('⚠️ Error updating DB:', error.message);
}
socket.join(gameCode);
socket.emit('host-game-loaded', game);
console.log(`🎤 Host joined game ${gameCode}`);
});
// Присоединение к игре
socket.on('join-game', async ({ gameCode, teamName }) => {
let game = activeGames.get(gameCode);
// Если игры нет в памяти, пытаемся загрузить из БД
if (!game) {
try {
const dbGame = await Game.findOne({ gameCode }).lean();
if (dbGame) {
game = { ...dbGame, timer: null, timerInterval: null };
activeGames.set(gameCode, game);
console.log(`📥 Game ${gameCode} loaded from DB for join`);
}
} catch (error) {
console.log('⚠️ Error loading from DB:', error.message);
}
}
if (!game) {
socket.emit('error', { message: 'Игра не найдена' });
return;
}
let team = game.teams.find(t => t.name === teamName);
if (!team) {
// Создаем новую команду
team = {
id: nanoid(),
name: teamName,
score: 0,
players: []
};
game.teams.push(team);
}
// Добавляем socket id в команду (без имени игрока)
team.players.push({ id: socket.id });
// Обновляем в БД
try {
await Game.findOneAndUpdate({ gameCode }, { teams: game.teams });
} catch (error) {
console.log('⚠️ Error updating teams in DB:', error.message);
}
socket.join(gameCode);
sendGameUpdate(io, gameCode, game);
console.log(`👥 Team ${teamName} joined game ${gameCode}`);
});
// Начать игру
socket.on('start-game', async ({ gameCode }) => {
const game = activeGames.get(gameCode);
if (game) {
game.status = 'playing';
// Обновляем в БД
try {
await Game.findOneAndUpdate({ gameCode }, { status: 'playing' });
} catch (error) {
console.log('⚠️ Error updating status in DB:', error.message);
}
io.to(gameCode).emit('game-started', game);
}
});
// Выбор вопроса
socket.on('select-question', ({ gameCode, category, points }) => {
const game = activeGames.get(gameCode);
if (!game) return;
// Находим категорию
const categoryData = game.selectedCategories.find(cat => cat.name === category);
// Подсчитываем, сколько вопросов с такими баллами уже использовано в этой категории
const usedCount = game.usedQuestions.filter(
q => q.category === category && q.points === points
).length;
// Находим следующий неиспользованный вопрос с такими баллами (по порядку)
const questionsWithPoints = categoryData?.questions
.map((q, idx) => ({ ...q, originalIndex: idx }))
.filter(q => q.points === points);
const questionIndex = questionsWithPoints[usedCount]?.originalIndex;
console.log(`📊 Select question: category="${category}", points=${points}, usedCount=${usedCount}, questionIndex=${questionIndex}`);
console.log(`📊 Questions with these points:`, questionsWithPoints.map(q => q.originalIndex));
if (questionIndex === undefined) {
console.log('❌ No available question found');
socket.emit('error', { message: 'Этот вопрос уже был использован' });
return;
}
game.currentQuestion = { category, points, questionIndex };
console.log(`✅ Selected question:`, game.currentQuestion);
game.answeringTeam = null;
game.answeredTeams = []; // Список команд, которые уже отвечали на этот вопрос
// Получаем тип вопроса
const questionData = categoryData?.questions[questionIndex];
const questionType = questionData?.type || 'text';
game.timer = 60; // 60 секунд на ответ
game.timerStarted = false; // Флаг, запущен ли таймер
io.to(gameCode).emit('question-selected', {
category,
points,
questionIndex,
questionType,
timer: game.timer
});
// Для текстовых и картинок запускаем таймер сразу
// Для аудио и видео - ждем события 'media-ended' от клиента
if (questionType === 'text' || questionType === 'image') {
console.log(`⏱️ Starting timer immediately for ${questionType} question`);
game.timerStarted = true;
const timerInterval = setInterval(() => {
game.timer--;
io.to(gameCode).emit('timer-update', { timer: game.timer });
if (game.timer <= 0) {
clearInterval(timerInterval);
io.to(gameCode).emit('time-up');
game.currentQuestion = null;
game.answeringTeam = null;
game.answeredTeams = [];
}
}, 1000);
game.timerInterval = timerInterval;
} else {
console.log(`⏸️ Waiting for media to end before starting timer (${questionType} question)`);
}
});
// Событие окончания проигрывания медиа (аудио/видео)
socket.on('media-ended', ({ gameCode }) => {
const game = activeGames.get(gameCode);
if (!game || !game.currentQuestion || game.timerStarted) return;
console.log(`🎬 Media ended, starting timer now`);
game.timerStarted = true;
// Запускаем таймер
const timerInterval = setInterval(() => {
game.timer--;
io.to(gameCode).emit('timer-update', { timer: game.timer });
if (game.timer <= 0) {
clearInterval(timerInterval);
io.to(gameCode).emit('time-up');
game.currentQuestion = null;
game.answeringTeam = null;
game.answeredTeams = [];
}
}, 1000);
game.timerInterval = timerInterval;
});
// Нажатие кнопки "Ответить"
socket.on('buzz-in', ({ gameCode, teamId }) => {
const game = activeGames.get(gameCode);
if (!game || !game.currentQuestion || game.answeringTeam) return;
// Проверяем, не отвечала ли уже эта команда на этот вопрос
if (game.answeredTeams && game.answeredTeams.includes(teamId)) {
return; // Команда уже отвечала, игнорируем
}
game.answeringTeam = teamId;
// Останавливаем таймер
if (game.timerInterval) {
clearInterval(game.timerInterval);
}
const team = game.teams.find(t => t.id === teamId);
io.to(gameCode).emit('team-buzzing', {
teamId,
teamName: team.name
});
// Отправляем обновление игры чтобы у ведущего появились кнопки
sendGameUpdate(io, gameCode, game);
});
// Проверка ответа
socket.on('submit-answer', async ({ gameCode, teamId, isCorrect }) => {
const game = activeGames.get(gameCode);
console.log(`📝 Submit answer: gameCode=${gameCode}, teamId=${teamId}, isCorrect=${isCorrect}`);
if (!game || !game.currentQuestion) {
console.log('⚠️ No game or no current question');
return;
}
const team = game.teams.find(t => t.id === teamId);
const basePoints = game.currentQuestion.points;
// Удваиваем баллы во втором раунде
const points = game.currentRound === 2 ? basePoints * 2 : basePoints;
console.log(`Team: ${team?.name}, Base Points: ${basePoints}, Actual Points (Round ${game.currentRound}): ${points}`);
// Найдем answerMedia из вопроса
const category = game.selectedCategories.find(cat => cat.name === game.currentQuestion.category);
const questionData = category?.questions.find(q => q.points === game.currentQuestion.points);
const answerMedia = questionData?.answerMedia;
if (isCorrect) {
console.log('✅ Correct answer!');
team.score += points;
game.usedQuestions.push(game.currentQuestion);
game.currentQuestion = null;
game.answeringTeam = null;
// Проверяем, закончилась ли игра
const questionsPath = join(__dirname, '../data/questions.json');
const questionsData = JSON.parse(readFileSync(questionsPath, 'utf-8'));
const isGameFinished = checkGameFinished(game, questionsData.categories);
if (isGameFinished) {
console.log(`🏁 All questions answered and no more categories.`);
// Если есть answerMedia, отправляем флаг shouldFinish вместо немедленного завершения
if (answerMedia) {
console.log('Answer media exists, delaying game finish');
// Обновляем в БД (но не меняем статус на finished)
try {
await Game.findOneAndUpdate(
{ gameCode },
{
teams: game.teams,
usedQuestions: game.usedQuestions,
currentQuestion: null,
answeringTeam: null
}
);
} catch (error) {
console.log('⚠️ Error updating in DB:', error.message);
}
io.to(gameCode).emit('answer-result', {
teamId,
isCorrect: true,
newScore: team.score,
questionClosed: true,
answerMedia: answerMedia,
shouldFinish: true
});
sendGameUpdate(io, gameCode, game);
return;
}
// Если нет answerMedia, завершаем игру сразу
game.status = 'finished';
// Обновляем в БД
try {
await Game.findOneAndUpdate(
{ gameCode },
{
teams: game.teams,
usedQuestions: game.usedQuestions,
currentQuestion: null,
answeringTeam: null,
status: 'finished'
}
);
} catch (error) {
console.log('⚠️ Error finishing game in DB:', error.message);
}
io.to(gameCode).emit('answer-result', {
teamId,
isCorrect: true,
newScore: team.score,
questionClosed: true,
answerMedia: answerMedia
});
io.to(gameCode).emit('game-finished', game);
sendGameUpdate(io, gameCode, game);
return;
}
// Обновляем в БД
try {
await Game.findOneAndUpdate(
{ gameCode },
{
teams: game.teams,
usedQuestions: game.usedQuestions,
currentQuestion: null,
answeringTeam: null
}
);
} catch (error) {
console.log('⚠️ Error updating answer in DB:', error.message);
}
console.log('Sending answer-result (correct) and game-updated');
io.to(gameCode).emit('answer-result', {
teamId,
isCorrect: true,
newScore: team.score,
questionClosed: true,
answerMedia: answerMedia
});
} else {
console.log('❌ Incorrect answer!');
team.score -= points;
// Добавляем команду в список ответивших
if (!game.answeredTeams) {
game.answeredTeams = [];
}
game.answeredTeams.push(teamId);
game.answeringTeam = null;
// Обновляем в БД
try {
await Game.findOneAndUpdate(
{ gameCode },
{ teams: game.teams, answeringTeam: null }
);
} catch (error) {
console.log('⚠️ Error updating answer in DB:', error.message);
}
console.log('Sending answer-result (incorrect)');
io.to(gameCode).emit('answer-result', {
teamId,
isCorrect: false,
newScore: team.score,
questionClosed: false
});
// Проверяем, остались ли команды которые могут ответить
const remainingTeams = game.teams.filter(t => !game.answeredTeams.includes(t.id));
console.log(`Remaining teams: ${remainingTeams.length}, Timer: ${game.timer}`);
if (remainingTeams.length === 0 || game.timer <= 0) {
console.log('No more teams or time up - closing question');
// Все команды ответили неправильно или время вышло
game.usedQuestions.push(game.currentQuestion);
game.currentQuestion = null;
game.answeredTeams = [];
// Проверяем, закончилась ли игра
const questionsPath = join(__dirname, '../data/questions.json');
const questionsData = JSON.parse(readFileSync(questionsPath, 'utf-8'));
const isGameFinished = checkGameFinished(game, questionsData.categories);
if (isGameFinished) {
console.log(`🏁 All questions answered and no more categories. Finishing game ${gameCode}`);
game.status = 'finished';
try {
await Game.findOneAndUpdate(
{ gameCode },
{
usedQuestions: game.usedQuestions,
currentQuestion: null,
answeredTeams: [],
status: 'finished'
}
);
} catch (error) {
console.log('⚠️ Error finishing game in DB:', error.message);
}
io.to(gameCode).emit('time-up');
io.to(gameCode).emit('game-finished', game);
sendGameUpdate(io, gameCode, game);
return;
}
io.to(gameCode).emit('time-up');
sendGameUpdate(io, gameCode, game);
} else {
console.log('Resuming timer for other teams');
// Возобновляем таймер для других команд
const timerInterval = setInterval(() => {
game.timer--;
io.to(gameCode).emit('timer-update', { timer: game.timer });
if (game.timer <= 0) {
clearInterval(timerInterval);
io.to(gameCode).emit('time-up');
game.currentQuestion = null;
game.answeredTeams = [];
}
}, 1000);
game.timerInterval = timerInterval;
}
}
sendGameUpdate(io, gameCode, game);
});
// Досрочное закрытие вопроса ведущим
socket.on('close-question', async ({ gameCode }) => {
const game = activeGames.get(gameCode);
if (!game || !game.currentQuestion) return;
// Найдем answerMedia из вопроса перед закрытием
const category = game.selectedCategories.find(cat => cat.name === game.currentQuestion.category);
const questionData = category?.questions.find(q => q.points === game.currentQuestion.points);
const answerMedia = questionData?.answerMedia;
// Останавливаем таймер
if (game.timerInterval) {
clearInterval(game.timerInterval);
}
// Добавляем вопрос в использованные (чтобы он пропал с доски)
game.usedQuestions.push(game.currentQuestion);
// Закрываем вопрос
game.currentQuestion = null;
game.answeringTeam = null;
game.timer = null;
// Проверяем, закончилась ли игра
const questionsPath = join(__dirname, '../data/questions.json');
const questionsData = JSON.parse(readFileSync(questionsPath, 'utf-8'));
const isGameFinished = checkGameFinished(game, questionsData.categories);
if (isGameFinished) {
console.log(`🏁 All questions answered and no more categories.`);
// Если есть answerMedia, отправляем флаг shouldFinish вместо немедленного завершения
if (answerMedia) {
console.log('Answer media exists, delaying game finish');
try {
await Game.findOneAndUpdate(
{ gameCode },
{
usedQuestions: game.usedQuestions,
currentQuestion: null,
answeringTeam: null
}
);
} catch (error) {
console.log('⚠️ Error updating in DB:', error.message);
}
io.to(gameCode).emit('time-up', { answerMedia: answerMedia, shouldFinish: true });
sendGameUpdate(io, gameCode, game);
return;
}
// Если нет answerMedia, завершаем игру сразу
game.status = 'finished';
try {
await Game.findOneAndUpdate(
{ gameCode },
{
usedQuestions: game.usedQuestions,
currentQuestion: null,
answeringTeam: null,
status: 'finished'
}
);
} catch (error) {
console.log('⚠️ Error finishing game in DB:', error.message);
}
io.to(gameCode).emit('time-up', { answerMedia: answerMedia });
io.to(gameCode).emit('game-finished', game);
sendGameUpdate(io, gameCode, game);
return;
}
// Обновляем в БД
try {
await Game.findOneAndUpdate(
{ gameCode },
{
usedQuestions: game.usedQuestions,
currentQuestion: null,
answeringTeam: null
}
);
} catch (error) {
console.log('⚠️ Error updating close-question in DB:', error.message);
}
io.to(gameCode).emit('time-up', { answerMedia: answerMedia });
sendGameUpdate(io, gameCode, game);
});
// Завершение игры
socket.on('finish-game', async ({ gameCode }) => {
const game = activeGames.get(gameCode);
if (!game) return;
game.status = 'finished';
// Обновляем в БД
try {
await Game.findOneAndUpdate({ gameCode }, { status: 'finished' });
} catch (error) {
console.log('⚠️ Error updating game status in DB:', error.message);
}
io.to(gameCode).emit('game-finished', game);
sendGameUpdate(io, gameCode, game);
console.log(`🏁 Game ${gameCode} finished`);
});
// Переход к следующему раунду
socket.on('next-round', async ({ gameCode }) => {
const game = activeGames.get(gameCode);
if (!game) return;
// Загружаем все категории
const questionsPath = join(__dirname, '../data/questions.json');
const questionsData = JSON.parse(readFileSync(questionsPath, 'utf-8'));
// Выбираем 5 новых случайных категорий (исключая использованные)
const selectedCategories = selectRandomCategories(
questionsData.categories,
game.usedCategoryIds
);
// Проверяем, есть ли еще доступные категории
if (selectedCategories.length === 0) {
// Категории закончились - завершаем игру автоматически
console.log(`🏁 No more categories available. Finishing game ${gameCode}`);
game.status = 'finished';
try {
await Game.findOneAndUpdate(
{ gameCode },
{ status: 'finished' }
);
} catch (error) {
console.log('⚠️ Error finishing game in DB:', error.message);
}
io.to(gameCode).emit('game-finished', game);
sendGameUpdate(io, gameCode, game);
return;
}
// Увеличиваем номер раунда
const nextRound = game.currentRound + 1;
game.currentRound = nextRound;
game.selectedCategories = selectedCategories;
game.usedCategoryIds = [...game.usedCategoryIds, ...selectedCategories.map(cat => cat.id)];
game.usedQuestions = [];
game.currentQuestion = null;
// Обновляем в БД
try {
await Game.findOneAndUpdate(
{ gameCode },
{
currentRound: nextRound,
selectedCategories: selectedCategories,
usedCategoryIds: game.usedCategoryIds,
usedQuestions: [],
currentQuestion: null
}
);
} catch (error) {
console.log('⚠️ Error updating round in DB:', error.message);
}
console.log(`🎮 Game ${gameCode}: Starting round ${nextRound} with ${selectedCategories.length} categories`);
io.to(gameCode).emit('round-changed', { round: nextRound });
sendGameUpdate(io, gameCode, game);
});
// Отключение
socket.on('disconnect', () => {
console.log('👤 Client disconnected:', socket.id);
// Удаляем игрока из всех игр
activeGames.forEach((game, gameCode) => {
// Проверяем, является ли отключившийся хостом
const isHost = game.hostId === socket.id;
if (isHost) {
// Если отключился хост, игра завершается
activeGames.delete(gameCode);
io.to(gameCode).emit('host-disconnected');
console.log(`🗑️ Game ${gameCode} deleted (host disconnected)`);
return;
}
// Удаляем игрока из команд
game.teams.forEach(team => {
team.players = team.players.filter(p => p.id !== socket.id);
});
// Удаляем пустые команды
game.teams = game.teams.filter(team => team.players.length > 0);
// Обновляем игру для оставшихся игроков
sendGameUpdate(io, gameCode, game);
});
});
});
};