diff --git a/src/components/builders/quiz-builder.tsx b/src/components/builders/quiz-builder.tsx index 49b020e..0cb2fab 100644 --- a/src/components/builders/quiz-builder.tsx +++ b/src/components/builders/quiz-builder.tsx @@ -3,7 +3,13 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; import { Plus, Trash2, Save } from "lucide-react"; -import { quizDefinitionSchema, DEFAULT_QUIZ_DEFINITION, type QuizDefinition, type QuizQuestion } from "@/lib/games/quiz"; +import { + quizDefinitionSchema, + DEFAULT_QUIZ_DEFINITION, + QUIZ_CATEGORIES, + type QuizDefinition, + type QuizQuestion, +} from "@/lib/games/quiz"; export function QuizBuilder({ mode, @@ -24,7 +30,6 @@ export function QuizBuilder({ const [questions, setQuestions] = useState( initialDefinition?.questions ?? DEFAULT_QUIZ_DEFINITION.questions, ); - const [timePerQuestionSec, setTimePerQuestionSec] = useState(initialDefinition?.timePerQuestionSec ?? 0); const [error, setError] = useState(null); const [saving, setSaving] = useState(false); @@ -39,31 +44,19 @@ export function QuizBuilder({ } function addQuestion() { - setQuestions((qs) => [...qs, { question: "", options: ["", ""], correctIndex: 0 }]); + setQuestions((qs) => [ + ...qs, + { category: QUIZ_CATEGORIES[0], band: 1, question: "", options: ["", "", "", ""], correctIndex: 0 }, + ]); } function removeQuestion(index: number) { setQuestions((qs) => qs.filter((_, i) => i !== index)); } - function addOption(qIndex: number) { - setQuestions((qs) => qs.map((q, i) => (i === qIndex && q.options.length < 6 ? { ...q, options: [...q.options, ""] } : q))); - } - - function removeOption(qIndex: number, optIndex: number) { - setQuestions((qs) => - qs.map((q, i) => { - if (i !== qIndex || q.options.length <= 2) return q; - const options = q.options.filter((_, j) => j !== optIndex); - const correctIndex = q.correctIndex >= options.length ? 0 : q.correctIndex; - return { ...q, options, correctIndex }; - }), - ); - } - async function handleSave() { setError(null); - const definition: QuizDefinition = { questions, timePerQuestionSec }; + const definition: QuizDefinition = { questions }; const parsed = quizDefinitionSchema.safeParse(definition); if (!parsed.success) { setError(parsed.error.issues[0]?.message ?? "Проверьте вопросы — что-то заполнено неверно"); @@ -122,17 +115,10 @@ export function QuizBuilder({ className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent" /> - +

+ Денежная лестница и несгораемые суммы фиксированы (15 уровней). У каждого вопроса — рубрика и уровень + сложности 1–5; в каждой из 5 сложностей должен быть хотя бы один вопрос. +

{questions.map((q, qIndex) => ( @@ -145,6 +131,38 @@ export function QuizBuilder({ )} + +
+ + +
+ updateQuestion(qIndex, { question: e.target.value })} @@ -166,19 +184,8 @@ export function QuizBuilder({ placeholder={`Вариант ${optIndex + 1}`} className="flex-1 rounded-md border border-border bg-surface px-3 py-1.5 text-sm outline-none focus:border-accent" /> - {q.options.length > 2 && ( - - )} ))} - {q.options.length < 6 && ( - - )} ))} diff --git a/src/lib/ai/generate.ts b/src/lib/ai/generate.ts index d90bcad..1abddb5 100644 --- a/src/lib/ai/generate.ts +++ b/src/lib/ai/generate.ts @@ -4,7 +4,7 @@ import { getAiSettings } from "./config"; import { templateSchemas, type TemplateType, type GameDefinition } from "@/lib/games/types"; const SYSTEM_PROMPTS: Record = { - quiz: "Ты создаёшь викторины для простого игрового движка. Придумай вопросы и варианты ответа на русском языке по описанию пользователя. Один вариант в каждом вопросе должен быть правильным.", + quiz: "Ты создаёшь вопросы для викторины в формате «Кто хочет стать миллионером?» — 15-уровневая денежная лестница с нарастающей сложностью. Каждый вопрос должен использовать ТОЛЬКО реальные, проверяемые факты (даты, столицы, авторов, научные факты и т.п.) — ничего не выдумывай и не искажай. У каждого вопроса ровно 4 варианта ответа и один правильный. Поле band (1-5) — сложность: 1 — очень лёгкий вопрос, 5 — очень сложный. Обязательно должен быть хотя бы один вопрос на каждый band от 1 до 5. Категория (category) должна отражать тему вопроса.", clicker: "Ты создаёшь простую кликер-игру (idle game) для игрового движка. Придумай тему, набор улучшений и целевой счёт по описанию пользователя. Улучшения должны быть сбалансированы: чем дороже — тем сильнее эффект.", maze: "Ты создаёшь лабиринт для простого игрового движка на сетке символов. Придумай интересный лабиринт по описанию пользователя. Ровно один символ S (старт), хотя бы один E (выход), стены #, пол ., предметы *. Выход обязательно должен быть достижим от старта по коридорам без стен.", diff --git a/src/lib/games/quiz.ts b/src/lib/games/quiz.ts index f8da776..9405da5 100644 --- a/src/lib/games/quiz.ts +++ b/src/lib/games/quiz.ts @@ -1,33 +1,185 @@ import { z } from "zod"; +export const QUIZ_CATEGORIES = [ + "История", + "География", + "Наука и техника", + "Литература", + "Кино и музыка", + "Спорт", + "Общие знания", +] as const; +export type QuizCategory = (typeof QUIZ_CATEGORIES)[number]; + +/** 15-level money ladder, fixed — not part of the editable schema. */ +export const MONEY_LADDER = [ + 100, 200, 300, 500, 1000, 2000, 4000, 8000, 16000, 32000, 64000, 125000, 250000, 500000, 1000000, +]; +/** "Несгораемые суммы" — a wrong answer never drops you below the last checkpoint passed. */ +export const CHECKPOINT_LEVELS = [5, 10]; + +/** Maps a ladder level (1-15) to its difficulty band (1-5), 3 levels per band. */ +export function bandForLevel(level: number): number { + return Math.min(5, Math.max(1, Math.ceil(level / 3))); +} + export const quizQuestionSchema = z .object({ + category: z.enum(QUIZ_CATEGORIES), + // Difficulty band 1 (easiest) – 5 (hardest); band N covers ladder levels (N-1)*3+1..N*3. + band: z.number().int().min(1).max(5), question: z.string().min(1).max(300), - options: z.array(z.string().min(1).max(120)).min(2).max(6), - correctIndex: z.number().int().min(0), + options: z.array(z.string().min(1).max(120)).length(4), + correctIndex: z.number().int().min(0).max(3), }) .refine((q) => q.correctIndex < q.options.length, { message: "correctIndex must be a valid index into options", path: ["correctIndex"], }); -export const quizDefinitionSchema = z.object({ - questions: z.array(quizQuestionSchema).min(1).max(30), - // 0 = no timer. - timePerQuestionSec: z.number().int().min(0).max(120).default(0), -}); +export const quizDefinitionSchema = z + .object({ + questions: z.array(quizQuestionSchema).min(5).max(500), + }) + .refine((def) => [1, 2, 3, 4, 5].every((band) => def.questions.some((q) => q.band === band)), { + message: "Every difficulty band 1-5 needs at least one question", + path: ["questions"], + }); export type QuizQuestion = z.infer; export type QuizDefinition = z.infer; -export const DEFAULT_QUIZ_DEFINITION: QuizDefinition = { - questions: [{ question: "Сколько будет 2 + 2?", options: ["3", "4", "5"], correctIndex: 1 }], - timePerQuestionSec: 0, -}; - export const quizProgressSchema = z.object({ - questionIndex: z.number().int().min(0), - score: z.number().int().min(0), + level: z.number().int().min(1).max(15), + bankedScore: z.number().int().min(0), + usedLifelines: z.array(z.enum(["fiftyFifty", "audience", "phone"])).default([]), }); export type QuizProgress = z.infer; + +// Real, well-established facts only — 7 categories x 5 difficulty bands x 3 +// questions = 105. Deliberately avoids "current record holder"-style facts +// that go stale; sticks to capitals, foundational history, classic +// literature/science, and long-settled records. +export const DEFAULT_QUIZ_DEFINITION: QuizDefinition = { + questions: [ + // --- История --- + { category: "История", band: 1, question: "В каком году началась Вторая мировая война?", options: ["1937", "1939", "1941", "1945"], correctIndex: 1 }, + { category: "История", band: 1, question: "Кто был первым президентом США?", options: ["Джордж Вашингтон", "Авраам Линкольн", "Томас Джефферсон", "Бенджамин Франклин"], correctIndex: 0 }, + { category: "История", band: 1, question: "В каком городе произошло взятие Бастилии, ставшее символом начала Великой французской революции?", options: ["Париж", "Лион", "Марсель", "Версаль"], correctIndex: 0 }, + { category: "История", band: 2, question: "Кто стал первым человеком в мире, побывавшим в космосе?", options: ["Юрий Гагарин", "Герман Титов", "Алексей Леонов", "Валентина Терешкова"], correctIndex: 0 }, + { category: "История", band: 2, question: "В каком году закончилась Вторая мировая война?", options: ["1943", "1944", "1945", "1946"], correctIndex: 2 }, + { category: "История", band: 2, question: "Какая империя построила Колизей в Риме?", options: ["Римская", "Османская", "Персидская", "Британская"], correctIndex: 0 }, + { category: "История", band: 3, question: "В каком году произошла Октябрьская революция в России?", options: ["1905", "1917", "1918", "1922"], correctIndex: 1 }, + { category: "История", band: 3, question: "Кто написал «Манифест Коммунистической партии» вместе с Фридрихом Энгельсом?", options: ["Карл Маркс", "Владимир Ленин", "Лев Троцкий", "Адам Смит"], correctIndex: 0 }, + { category: "История", band: 3, question: "Какая стена разделяла Берлин на Восточный и Западный до 1989 года?", options: ["Берлинская стена", "Великая Китайская стена", "Адрианов вал", "Стена Плача"], correctIndex: 0 }, + { category: "История", band: 4, question: "В каком году пала Западная Римская империя?", options: ["376", "410", "476", "527"], correctIndex: 2 }, + { category: "История", band: 4, question: "Какая царица Египта была связана политическим союзом с Юлием Цезарем и Марком Антонием?", options: ["Клеопатра", "Нефертити", "Хатшепсут", "Изида"], correctIndex: 0 }, + { category: "История", band: 4, question: "Какая война продолжалась с 1618 по 1648 год и затронула большую часть Европы?", options: ["Тридцатилетняя война", "Столетняя война", "Семилетняя война", "Война за испанское наследство"], correctIndex: 0 }, + { category: "История", band: 5, question: "В каком году был подписан Вестфальский мир, завершивший Тридцатилетнюю войну?", options: ["1618", "1648", "1658", "1701"], correctIndex: 1 }, + { category: "История", band: 5, question: "Кто был последним императором России из династии Романовых?", options: ["Николай II", "Александр III", "Николай I", "Александр II"], correctIndex: 0 }, + { category: "История", band: 5, question: "В каком году завершился Венский конгресс, установивший новый порядок в Европе после Наполеоновских войн?", options: ["1812", "1814", "1815", "1820"], correctIndex: 2 }, + + // --- География --- + { category: "География", band: 1, question: "Какая река самая длинная в Африке?", options: ["Нил", "Конго", "Нигер", "Замбези"], correctIndex: 0 }, + { category: "География", band: 1, question: "Столица Франции?", options: ["Париж", "Лион", "Марсель", "Ницца"], correctIndex: 0 }, + { category: "География", band: 1, question: "Самый большой океан на Земле?", options: ["Тихий", "Атлантический", "Индийский", "Северный Ледовитый"], correctIndex: 0 }, + { category: "География", band: 2, question: "Столица Японии?", options: ["Токио", "Осака", "Киото", "Йокогама"], correctIndex: 0 }, + { category: "География", band: 2, question: "Самая высокая гора в мире?", options: ["Эверест", "К2", "Килиманджаро", "Эльбрус"], correctIndex: 0 }, + { category: "География", band: 2, question: "Какой континент самый большой по площади?", options: ["Азия", "Африка", "Северная Америка", "Европа"], correctIndex: 0 }, + { category: "География", band: 3, question: "Какая страна занимает наибольшую территорию в мире?", options: ["Россия", "Канада", "Китай", "США"], correctIndex: 0 }, + { category: "География", band: 3, question: "Столица Австралии?", options: ["Канберра", "Сидней", "Мельбурн", "Брисбен"], correctIndex: 0 }, + { category: "География", band: 3, question: "Самое глубокое озеро в мире?", options: ["Байкал", "Танганьика", "Каспийское море", "Верхнее"], correctIndex: 0 }, + { category: "География", band: 4, question: "Какая пустыня самая большая жаркая пустыня в мире?", options: ["Сахара", "Гоби", "Калахари", "Аравийская"], correctIndex: 0 }, + { category: "География", band: 4, question: "Какой пролив разделяет Европу и Африку в самом узком месте?", options: ["Гибралтарский пролив", "Босфор", "Ла-Манш", "Керченский пролив"], correctIndex: 0 }, + { category: "География", band: 4, question: "Какая страна занимает первое место по количеству часовых поясов на своей территории (с учётом заморских территорий)?", options: ["Франция", "Россия", "США", "Китай"], correctIndex: 0 }, + { category: "География", band: 5, question: "Какая река самая полноводная в мире по объёму стока?", options: ["Амазонка", "Нил", "Янцзы", "Конго"], correctIndex: 0 }, + { category: "География", band: 5, question: "Столица Канады?", options: ["Оттава", "Торонто", "Ванкувер", "Монреаль"], correctIndex: 0 }, + { category: "География", band: 5, question: "Какое море считается самым солёным в мире?", options: ["Мёртвое море", "Красное море", "Средиземное море", "Чёрное море"], correctIndex: 0 }, + + // --- Наука и техника --- + { category: "Наука и техника", band: 1, question: "Химический символ золота?", options: ["Au", "Ag", "Fe", "Gd"], correctIndex: 0 }, + { category: "Наука и техника", band: 1, question: "Сколько планет в Солнечной системе по современной классификации?", options: ["7", "8", "9", "10"], correctIndex: 1 }, + { category: "Наука и техника", band: 1, question: "Кто разработал теорию относительности?", options: ["Альберт Эйнштейн", "Исаак Ньютон", "Никола Тесла", "Галилео Галилей"], correctIndex: 0 }, + { category: "Наука и техника", band: 2, question: "Какой газ составляет большую часть земной атмосферы?", options: ["Азот", "Кислород", "Углекислый газ", "Аргон"], correctIndex: 0 }, + { category: "Наука и техника", band: 2, question: "Сколько костей в скелете взрослого человека?", options: ["206", "186", "226", "246"], correctIndex: 0 }, + { category: "Наука и техника", band: 2, question: "Кто первым запатентовал телефон?", options: ["Александр Белл", "Томас Эдисон", "Никола Тесла", "Гульельмо Маркони"], correctIndex: 0 }, + { category: "Наука и техника", band: 3, question: "Какая частица имеет отрицательный электрический заряд?", options: ["Электрон", "Протон", "Нейтрон", "Позитрон"], correctIndex: 0 }, + { category: "Наука и техника", band: 3, question: "Сколько хромосом в соматической клетке человека?", options: ["46", "44", "48", "23"], correctIndex: 0 }, + { category: "Наука и техника", band: 3, question: "Кто открыл закон всемирного тяготения?", options: ["Исаак Ньютон", "Галилео Галилей", "Иоганн Кеплер", "Альберт Эйнштейн"], correctIndex: 0 }, + { category: "Наука и техника", band: 4, question: "Химический элемент с атомным номером 1?", options: ["Водород", "Гелий", "Литий", "Кислород"], correctIndex: 0 }, + { category: "Наука и техника", band: 4, question: "Кто считается создателем периодической таблицы химических элементов?", options: ["Дмитрий Менделеев", "Антуан Лавуазье", "Джон Дальтон", "Роберт Бойль"], correctIndex: 0 }, + { category: "Наука и техника", band: 4, question: "Как называется переход вещества из твёрдого состояния сразу в газообразное, минуя жидкое?", options: ["Сублимация", "Конденсация", "Кристаллизация", "Испарение"], correctIndex: 0 }, + { category: "Наука и техника", band: 5, question: "Какая частица, предсказанная теоретически, была открыта на Большом адронном коллайдере в 2012 году?", options: ["Бозон Хиггса", "Кварк", "Позитрон", "Нейтрино"], correctIndex: 0 }, + { category: "Наука и техника", band: 5, question: "Кто сформулировал принцип неопределённости в квантовой механике?", options: ["Вернер Гейзенберг", "Нильс Бор", "Макс Планк", "Эрвин Шрёдингер"], correctIndex: 0 }, + { category: "Наука и техника", band: 5, question: "Как называется единица измерения силы электрического тока в СИ?", options: ["Ампер", "Вольт", "Ом", "Ватт"], correctIndex: 0 }, + + // --- Литература --- + { category: "Литература", band: 1, question: "Кто автор романа «Война и мир»?", options: ["Лев Толстой", "Фёдор Достоевский", "Иван Тургенев", "Антон Чехов"], correctIndex: 0 }, + { category: "Литература", band: 1, question: "Кто написал «Ромео и Джульетту»?", options: ["Уильям Шекспир", "Чарльз Диккенс", "Оскар Уайльд", "Джордж Байрон"], correctIndex: 0 }, + { category: "Литература", band: 1, question: "Кто автор сказки «Золотой ключик, или Приключения Буратино»?", options: ["Алексей Толстой", "Лев Толстой", "Корней Чуковский", "Александр Пушкин"], correctIndex: 0 }, + { category: "Литература", band: 2, question: "Кто написал роман «Преступление и наказание»?", options: ["Фёдор Достоевский", "Лев Толстой", "Николай Гоголь", "Иван Гончаров"], correctIndex: 0 }, + { category: "Литература", band: 2, question: "Кто автор серии книг о Гарри Поттере?", options: ["Джоан Роулинг", "Дж. Р. Р. Толкин", "Клайв Льюис", "Урсула Ле Гуин"], correctIndex: 0 }, + { category: "Литература", band: 2, question: "Кто написал поэму «Мёртвые души»?", options: ["Николай Гоголь", "Александр Пушкин", "Иван Тургенев", "Михаил Лермонтов"], correctIndex: 0 }, + { category: "Литература", band: 3, question: "Кто автор романа в стихах «Евгений Онегин»?", options: ["Александр Пушкин", "Михаил Лермонтов", "Николай Некрасов", "Фёдор Тютчев"], correctIndex: 0 }, + { category: "Литература", band: 3, question: "Кто написал роман «Мастер и Маргарита»?", options: ["Михаил Булгаков", "Борис Пастернак", "Александр Солженицын", "Максим Горький"], correctIndex: 0 }, + { category: "Литература", band: 3, question: "Кто автор трагедии «Гамлет»?", options: ["Уильям Шекспир", "Кристофер Марло", "Бен Джонсон", "Джон Мильтон"], correctIndex: 0 }, + { category: "Литература", band: 4, question: "Кто написал роман-антиутопию «1984»?", options: ["Джордж Оруэлл", "Олдос Хаксли", "Рэй Брэдбери", "Герберт Уэллс"], correctIndex: 0 }, + { category: "Литература", band: 4, question: "Кто автор «Божественной комедии»?", options: ["Данте Алигьери", "Франческо Петрарка", "Джованни Боккаччо", "Торквато Тассо"], correctIndex: 0 }, + { category: "Литература", band: 4, question: "Кто написал роман «Анна Каренина»?", options: ["Лев Толстой", "Фёдор Достоевский", "Иван Бунин", "Александр Куприн"], correctIndex: 0 }, + { category: "Литература", band: 5, question: "Кто автор цикла романов «В поисках утраченного времени»?", options: ["Марсель Пруст", "Альбер Камю", "Жан-Поль Сартр", "Виктор Гюго"], correctIndex: 0 }, + { category: "Литература", band: 5, question: "Кому принадлежит цикл романов «Человеческая комедия»?", options: ["Оноре де Бальзак", "Виктор Гюго", "Эмиль Золя", "Гюстав Флобер"], correctIndex: 0 }, + { category: "Литература", band: 5, question: "Кто написал эпическую поэму «Потерянный рай»?", options: ["Джон Мильтон", "Уильям Блейк", "Джон Донн", "Александр Поуп"], correctIndex: 0 }, + + // --- Кино и музыка --- + { category: "Кино и музыка", band: 1, question: "Кто сыграл главную мужскую роль в фильме «Титаник»?", options: ["Леонардо Ди Каприо", "Брэд Питт", "Том Круз", "Джонни Депп"], correctIndex: 0 }, + { category: "Кино и музыка", band: 1, question: "Какая студия создала мультфильм «Король Лев» (1994)?", options: ["Disney", "Pixar", "DreamWorks", "Warner Bros"], correctIndex: 0 }, + { category: "Кино и музыка", band: 1, question: "Кто исполнитель песни «Thriller»?", options: ["Майкл Джексон", "Принс", "Элтон Джон", "Стиви Уандер"], correctIndex: 0 }, + { category: "Кино и музыка", band: 2, question: "Кто режиссёр фильма «Список Шиндлера»?", options: ["Стивен Спилберг", "Мартин Скорсезе", "Фрэнсис Форд Коппола", "Квентин Тарантино"], correctIndex: 0 }, + { category: "Кино и музыка", band: 2, question: "Какая группа исполнила песню «Bohemian Rhapsody»?", options: ["Queen", "The Beatles", "Led Zeppelin", "Pink Floyd"], correctIndex: 0 }, + { category: "Кино и музыка", band: 2, question: "В каком фильме прозвучала фраза «Я вернусь» («I'll be back»)?", options: ["Терминатор", "Рэмбо", "Крепкий орешек", "Робокоп"], correctIndex: 0 }, + { category: "Кино и музыка", band: 3, question: "Кто написал музыку к фильму «Крёстный отец»?", options: ["Нино Рота", "Эннио Морриконе", "Джон Уильямс", "Ханс Циммер"], correctIndex: 0 }, + { category: "Кино и музыка", band: 3, question: "Кто режиссёр кинотрилогии «Властелин колец»?", options: ["Питер Джексон", "Джеймс Кэмерон", "Ридли Скотт", "Гильермо дель Торо"], correctIndex: 0 }, + { category: "Кино и музыка", band: 3, question: "Кто написал оперу «Аида»?", options: ["Джузеппе Верди", "Джакомо Пуччини", "Джоаккино Россини", "Винченцо Беллини"], correctIndex: 0 }, + { category: "Кино и музыка", band: 4, question: "Какой фильм получил первый в истории «Оскар» за лучший фильм (церемония 1929 года)?", options: ["«Крылья»", "«Кинг-Конг»", "«Унесённые ветром»", "«Касабланка»"], correctIndex: 0 }, + { category: "Кино и музыка", band: 4, question: "Кто композитор знаменитой Девятой симфонии с «Одой к радости»?", options: ["Людвиг ван Бетховен", "Вольфганг Амадей Моцарт", "Иоганн Себастьян Бах", "Йозеф Гайдн"], correctIndex: 0 }, + { category: "Кино и музыка", band: 4, question: "Кто сыграл Джокера в фильме «Тёмный рыцарь» (2008)?", options: ["Хит Леджер", "Хоакин Феникс", "Джаред Лето", "Джек Николсон"], correctIndex: 0 }, + { category: "Кино и музыка", band: 5, question: "Кто автор оперного цикла «Кольцо нибелунга»?", options: ["Рихард Вагнер", "Рихард Штраус", "Густав Малер", "Антон Брукнер"], correctIndex: 0 }, + { category: "Кино и музыка", band: 5, question: "Какой фильм Альфреда Хичкока известен сценой убийства в душе?", options: ["«Психо»", "«Головокружение»", "«Птицы»", "«Окно во двор»"], correctIndex: 0 }, + { category: "Кино и музыка", band: 5, question: "Кто написал музыку балета «Лебединое озеро»?", options: ["Пётр Чайковский", "Сергей Прокофьев", "Игорь Стравинский", "Александр Глазунов"], correctIndex: 0 }, + + // --- Спорт --- + { category: "Спорт", band: 1, question: "Сколько игроков от одной команды находится на поле в футболе одновременно?", options: ["11", "10", "9", "12"], correctIndex: 0 }, + { category: "Спорт", band: 1, question: "В какой стране проходили летние Олимпийские игры 1980 года?", options: ["СССР", "США", "Германия", "Франция"], correctIndex: 0 }, + { category: "Спорт", band: 1, question: "Сколько очков даёт трёхочковый бросок в баскетболе?", options: ["3", "2", "1", "4"], correctIndex: 0 }, + { category: "Спорт", band: 2, question: "Как часто проводится чемпионат мира по футболу?", options: ["Раз в 4 года", "Раз в 2 года", "Ежегодно", "Раз в 3 года"], correctIndex: 0 }, + { category: "Спорт", band: 2, question: "В каком виде спорта победу над соперником называют «нокаутом»?", options: ["Бокс", "Теннис", "Фехтование", "Гольф"], correctIndex: 0 }, + { category: "Спорт", band: 2, question: "Сколько игроков от одной команды на площадке одновременно в баскетболе?", options: ["5", "6", "4", "7"], correctIndex: 0 }, + { category: "Спорт", band: 3, question: "В каком городе прошли первые Олимпийские игры современности (1896)?", options: ["Афины", "Париж", "Лондон", "Рим"], correctIndex: 0 }, + { category: "Спорт", band: 3, question: "Кто считается основателем современного олимпийского движения?", options: ["Пьер де Кубертен", "Деметриус Викелас", "Спиридон Луис", "Теодор Рузвельт"], correctIndex: 0 }, + { category: "Спорт", band: 3, question: "Сколько колец изображено на олимпийском флаге?", options: ["5", "6", "4", "7"], correctIndex: 0 }, + { category: "Спорт", band: 4, question: "Какая сборная выиграла первый в истории чемпионат мира по футболу (1930)?", options: ["Уругвай", "Аргентина", "Бразилия", "Италия"], correctIndex: 0 }, + { category: "Спорт", band: 4, question: "Сколько раз сборная Бразилии выигрывала чемпионат мира по футболу (1958, 1962, 1970, 1994, 2002)?", options: ["5", "4", "6", "3"], correctIndex: 0 }, + { category: "Спорт", band: 4, question: "В каком виде спорта соревнуются в дисциплинах «вольный стиль», «брасс», «баттерфляй» и «на спине»?", options: ["Плавание", "Лёгкая атлетика", "Гребля", "Триатлон"], correctIndex: 0 }, + { category: "Спорт", band: 5, question: "Какой пловец является рекордсменом по числу золотых олимпийских медалей за карьеру (23)?", options: ["Майкл Фелпс", "Марк Спитц", "Ян Торп", "Кейти Ледеки"], correctIndex: 0 }, + { category: "Спорт", band: 5, question: "В каком году женщины впервые приняли участие в Олимпийских играх?", options: ["1900", "1896", "1912", "1920"], correctIndex: 0 }, + { category: "Спорт", band: 5, question: "Какова официальная длина марафонской дистанции?", options: ["42,195 км", "40 км", "45 км", "50 км"], correctIndex: 0 }, + + // --- Общие знания --- + { category: "Общие знания", band: 1, question: "Сколько дней в високосном году?", options: ["366", "365", "364", "367"], correctIndex: 0 }, + { category: "Общие знания", band: 1, question: "Сколько цветов принято выделять в радуге?", options: ["7", "6", "5", "8"], correctIndex: 0 }, + { category: "Общие знания", band: 1, question: "Сколько сторон у шестиугольника?", options: ["6", "5", "7", "8"], correctIndex: 0 }, + { category: "Общие знания", band: 2, question: "Сколько минут в сутках?", options: ["1440", "1000", "1200", "1500"], correctIndex: 0 }, + { category: "Общие знания", band: 2, question: "На каком языке говорит большинство населения Бразилии?", options: ["Португальский", "Испанский", "Английский", "Французский"], correctIndex: 0 }, + { category: "Общие знания", band: 2, question: "Сколько букв в современном русском алфавите?", options: ["33", "32", "34", "30"], correctIndex: 0 }, + { category: "Общие знания", band: 3, question: "Как называется денежная единица Японии?", options: ["Иена", "Юань", "Вон", "Донг"], correctIndex: 0 }, + { category: "Общие знания", band: 3, question: "Сколько шейных позвонков у жирафа?", options: ["7", "5", "9", "12"], correctIndex: 0 }, + { category: "Общие знания", band: 3, question: "Какой газ выделяется как побочный продукт фотосинтеза?", options: ["Кислород", "Углекислый газ", "Азот", "Водород"], correctIndex: 0 }, + { category: "Общие знания", band: 4, question: "Как называется самое маленькое по площади государство в мире?", options: ["Ватикан", "Монако", "Сан-Марино", "Лихтенштейн"], correctIndex: 0 }, + { category: "Общие знания", band: 4, question: "Сколько составных частей (стран) входит в Соединённое Королевство Великобритании?", options: ["4", "3", "5", "2"], correctIndex: 0 }, + { category: "Общие знания", band: 4, question: "Какой металл остаётся жидким при комнатной температуре?", options: ["Ртуть", "Свинец", "Олово", "Цинк"], correctIndex: 0 }, + { category: "Общие знания", band: 5, question: "Как называется боязнь замкнутого пространства?", options: ["Клаустрофобия", "Агорафобия", "Акрофобия", "Арахнофобия"], correctIndex: 0 }, + { category: "Общие знания", band: 5, question: "Сколько костей в кисти человека, включая запястье?", options: ["27", "25", "29", "23"], correctIndex: 0 }, + { category: "Общие знания", band: 5, question: "Какое море не имеет сухопутных берегов, а окружено океаническими течениями?", options: ["Саргассово море", "Мёртвое море", "Красное море", "Аравийское море"], correctIndex: 0 }, + ], +}; diff --git a/src/lib/games/scenes/quiz-scene.ts b/src/lib/games/scenes/quiz-scene.ts index d3d37bc..93305ec 100644 --- a/src/lib/games/scenes/quiz-scene.ts +++ b/src/lib/games/scenes/quiz-scene.ts @@ -1,29 +1,72 @@ import Phaser from "phaser"; -import type { QuizDefinition, QuizProgress, GameResult } from "../types"; +import { MONEY_LADDER, CHECKPOINT_LEVELS, bandForLevel } from "../quiz"; +import type { QuizDefinition, QuizQuestion, QuizProgress, GameResult } from "../types"; const COLORS = { bg: 0x14162a, option: 0x23264a, optionHover: 0x2d3163, + optionHidden: 0x181a30, correct: 0x0d9488, wrong: 0xdc2626, - timerTrack: 0x292d4a, - timerFill: 0x818cf8, + lifeline: 0x23264a, + lifelineUsed: 0x181a30, + overlayBg: 0x1b1e36, }; +type LifelineKind = "fiftyFifty" | "audience" | "phone"; + +const AUDIENCE_CONFIDENCE: Record = { 1: 0.85, 2: 0.75, 3: 0.65, 4: 0.55, 5: 0.45 }; +const PHONE_CONFIDENCE: Record = { 1: 0.9, 2: 0.82, 3: 0.74, 4: 0.64, 5: 0.55 }; +const OPTION_LETTERS = ["A", "B", "C", "D"]; + +function formatMoney(n: number): string { + return n.toLocaleString("ru-RU"); +} + +/** Money kept after failing a question, once `completedLevels` were already answered correctly. */ +function checkpointScoreAfter(completedLevels: number): number { + const reached = CHECKPOINT_LEVELS.filter((c) => c <= completedLevels); + if (reached.length === 0) return 0; + return MONEY_LADDER[Math.max(...reached) - 1]; +} + +function shuffle(items: T[]): T[] { + const arr = [...items]; + for (let i = arr.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [arr[i], arr[j]] = [arr[j], arr[i]]; + } + return arr; +} + export class QuizScene extends Phaser.Scene { private definition!: QuizDefinition; private onFinish?: (result: GameResult) => void; private onProgress?: (state: QuizProgress) => void; - private questionIndex = 0; - private score = 0; + + private level = 1; + private bankedScore = 0; + private usedLifelines = new Set(); + private usedQuestionKeys = new Set(); + + private currentQuestion!: QuizQuestion; + private shuffledOptions: string[] = []; + private shuffledCorrectIndex = 0; + private hiddenIndices = new Set(); private locked = false; + private finished = false; + + private levelText!: Phaser.GameObjects.Text; + private prizeText!: Phaser.GameObjects.Text; + private categoryText!: Phaser.GameObjects.Text; private questionText!: Phaser.GameObjects.Text; private optionRects: Phaser.GameObjects.Rectangle[] = []; private optionLabels: Phaser.GameObjects.Text[] = []; - private timerBar?: Phaser.GameObjects.Rectangle; - private timerBarBg?: Phaser.GameObjects.Rectangle; - private timerEvent?: Phaser.Time.TimerEvent; + private lifelineButtons = new Map(); + private walkAwayButton!: Phaser.GameObjects.Rectangle; + private walkAwayLabel!: Phaser.GameObjects.Text; + private overlayObjects: Phaser.GameObjects.GameObject[] = []; constructor() { super("QuizScene"); @@ -35,23 +78,87 @@ export class QuizScene extends Phaser.Scene { this.onProgress = this.registry.get("onProgress"); const initialState = this.registry.get("initialState") as QuizProgress | undefined; - const totalQuestions = this.definition.questions.length; - this.questionIndex = - initialState && initialState.questionIndex < totalQuestions ? initialState.questionIndex : 0; - this.score = initialState?.score ?? 0; + this.level = initialState ? Math.min(15, Math.max(1, initialState.level)) : 1; + this.bankedScore = initialState?.bankedScore ?? 0; + this.usedLifelines = new Set(initialState?.usedLifelines ?? []); + this.usedQuestionKeys = new Set(); + this.finished = false; this.cameras.main.setBackgroundColor(COLORS.bg); + + this.levelText = this.add.text(30, 18, "", { fontFamily: "sans-serif", fontSize: "16px", color: "#a3a8c6" }); + this.prizeText = this.add + .text(930, 18, "", { fontFamily: "sans-serif", fontSize: "18px", color: "#eef0fb", fontStyle: "bold" }) + .setOrigin(1, 0); + this.categoryText = this.add + .text(480, 18, "", { fontFamily: "sans-serif", fontSize: "13px", color: "#818cf8" }) + .setOrigin(0.5, 0); this.questionText = this.add - .text(480, 60, "", { + .text(480, 55, "", { fontFamily: "sans-serif", - fontSize: "24px", + fontSize: "21px", color: "#eef0fb", align: "center", - wordWrap: { width: 820 }, + wordWrap: { width: 840 }, }) .setOrigin(0.5, 0); - this.showQuestion(); + this.walkAwayButton = this.add + .rectangle(880, 60, 140, 30, COLORS.lifeline) + .setStrokeStyle(1, 0x4f46e5) + .setInteractive({ useHandCursor: true }); + this.walkAwayLabel = this.add + .text(880, 60, "Забрать", { fontFamily: "sans-serif", fontSize: "13px", color: "#eef0fb" }) + .setOrigin(0.5); + this.walkAwayButton.on("pointerdown", () => this.walkAway()); + + const lifelineDefs: { kind: LifelineKind; x: number; label: string }[] = [ + { kind: "fiftyFifty", x: 330, label: "50:50" }, + { kind: "audience", x: 480, label: "Зал" }, + { kind: "phone", x: 630, label: "Звонок" }, + ]; + lifelineDefs.forEach(({ kind, x, label }) => { + const bg = this.add + .rectangle(x, 425, 130, 36, COLORS.lifeline) + .setStrokeStyle(1, 0x4f46e5) + .setInteractive({ useHandCursor: true }); + this.add.text(x, 425, label, { fontFamily: "sans-serif", fontSize: "14px", color: "#eef0fb" }).setOrigin(0.5); + bg.on("pointerdown", () => this.useLifeline(kind)); + this.lifelineButtons.set(kind, bg); + }); + + this.refreshLifelineButtons(); + this.startLevel(); + } + + private startLevel() { + this.locked = false; + this.hiddenIndices = new Set(); + this.clearOverlay(); + + const band = bandForLevel(this.level); + const pool = this.definition.questions.filter( + (q) => q.band === band && !this.usedQuestionKeys.has(`${q.category}|${q.question}`), + ); + const fallbackPool = pool.length > 0 ? pool : this.definition.questions.filter((q) => q.band === band); + this.currentQuestion = fallbackPool[Math.floor(Math.random() * fallbackPool.length)]; + this.usedQuestionKeys.add(`${this.currentQuestion.category}|${this.currentQuestion.question}`); + + const order = shuffle(this.currentQuestion.options.map((_, i) => i)); + this.shuffledOptions = order.map((i) => this.currentQuestion.options[i]); + this.shuffledCorrectIndex = order.indexOf(this.currentQuestion.correctIndex); + + this.levelText.setText(`Уровень ${this.level}/15`); + this.prizeText.setText(`${formatMoney(MONEY_LADDER[this.level - 1])} очков`); + this.categoryText.setText(this.currentQuestion.category.toUpperCase()); + this.questionText.setText(this.currentQuestion.question); + this.walkAwayButton.setVisible(this.level > 1); + this.walkAwayLabel.setVisible(this.level > 1); + this.walkAwayLabel.setText(`Забрать\n${formatMoney(this.bankedScore)}`); + + this.renderOptions(); + this.refreshLifelineButtons(); + this.reportProgress(); } private clearOptions() { @@ -59,90 +166,186 @@ export class QuizScene extends Phaser.Scene { this.optionLabels.forEach((l) => l.destroy()); this.optionRects = []; this.optionLabels = []; - this.timerEvent?.remove(); - this.timerBar?.destroy(); - this.timerBarBg?.destroy(); - this.timerBar = undefined; - this.timerBarBg = undefined; } - private showQuestion() { - this.locked = false; + private renderOptions() { this.clearOptions(); + const startY = 175; + const gap = 58; - const q = this.definition.questions[this.questionIndex]; - this.questionText.setText(`Вопрос ${this.questionIndex + 1}/${this.definition.questions.length}\n\n${q.question}`); - - const startY = 230; - const gap = 70; - q.options.forEach((option, i) => { + this.shuffledOptions.forEach((option, i) => { const y = startY + i * gap; - const bg = this.add.rectangle(480, y, 700, 56, COLORS.option).setStrokeStyle(1, 0x4f46e5); + const hidden = this.hiddenIndices.has(i); + const bg = this.add.rectangle(480, y, 780, 48, hidden ? COLORS.optionHidden : COLORS.option).setStrokeStyle( + 1, + hidden ? 0x292d4a : 0x4f46e5, + ); const label = this.add - .text(480, y, option, { fontFamily: "sans-serif", fontSize: "17px", color: "#eef0fb" }) - .setOrigin(0.5); - bg.setInteractive({ useHandCursor: true }); - bg.on("pointerover", () => { - if (!this.locked) bg.setFillStyle(COLORS.optionHover); - }); - bg.on("pointerout", () => { - if (!this.locked) bg.setFillStyle(COLORS.option); - }); - bg.on("pointerdown", () => this.selectOption(i, bg)); + .text(140, y, hidden ? "" : `${OPTION_LETTERS[i]}: ${option}`, { + fontFamily: "sans-serif", + fontSize: "16px", + color: "#eef0fb", + }) + .setOrigin(0, 0.5); + + if (!hidden) { + bg.setInteractive({ useHandCursor: true }); + bg.on("pointerover", () => { + if (!this.locked) bg.setFillStyle(COLORS.optionHover); + }); + bg.on("pointerout", () => { + if (!this.locked) bg.setFillStyle(COLORS.option); + }); + bg.on("pointerdown", () => this.selectOption(i, bg)); + } + this.optionRects.push(bg); this.optionLabels.push(label); }); - - if (this.definition.timePerQuestionSec > 0) { - const barY = startY + q.options.length * gap + 20; - this.timerBarBg = this.add.rectangle(480, barY, 700, 8, COLORS.timerTrack); - this.timerBar = this.add.rectangle(480 - 350, barY, 700, 8, COLORS.timerFill).setOrigin(0, 0.5); - - const totalMs = this.definition.timePerQuestionSec * 1000; - let elapsed = 0; - this.timerEvent = this.time.addEvent({ - delay: 50, - loop: true, - callback: () => { - elapsed += 50; - const pct = Math.max(0, 1 - elapsed / totalMs); - this.timerBar?.setScale(pct, 1); - if (elapsed >= totalMs) { - this.timerEvent?.remove(); - this.selectOption(-1); - } - }, - }); - } } - private selectOption(index: number, bg?: Phaser.GameObjects.Rectangle) { + private selectOption(index: number, bg: Phaser.GameObjects.Rectangle) { if (this.locked) return; this.locked = true; - this.timerEvent?.remove(); - const q = this.definition.questions[this.questionIndex]; - const correct = index === q.correctIndex; - if (correct) this.score += 1; - if (bg) bg.setFillStyle(correct ? COLORS.correct : COLORS.wrong); - if (!correct && index >= 0) { - const correctBg = this.optionRects[q.correctIndex]; - correctBg?.setFillStyle(COLORS.correct); + const correct = index === this.shuffledCorrectIndex; + bg.setFillStyle(correct ? COLORS.correct : COLORS.wrong); + if (!correct) { + this.optionRects[this.shuffledCorrectIndex]?.setFillStyle(COLORS.correct); } - this.time.delayedCall(700, () => { - this.questionIndex += 1; - this.onProgress?.({ questionIndex: this.questionIndex, score: this.score }); - if (this.questionIndex >= this.definition.questions.length) { - const total = this.definition.questions.length; - this.onFinish?.({ - won: this.score >= Math.ceil(total / 2), - score: this.score, - message: `${this.score} из ${total}`, - }); + this.time.delayedCall(900, () => { + if (correct) { + this.bankedScore = MONEY_LADDER[this.level - 1]; + if (this.level >= 15) { + this.finish(true, this.bankedScore, `Максимальный выигрыш: ${formatMoney(this.bankedScore)} очков!`); + return; + } + this.level += 1; + this.startLevel(); } else { - this.showQuestion(); + const finalScore = checkpointScoreAfter(this.level - 1); + this.finish(false, finalScore, `Неверный ответ. Забрано: ${formatMoney(finalScore)} очков.`); } }); } + + private walkAway() { + if (this.locked || this.finished || this.level <= 1) return; + this.finish(true, this.bankedScore, `Забрано: ${formatMoney(this.bankedScore)} очков.`); + } + + private useLifeline(kind: LifelineKind) { + if (this.locked || this.finished || this.usedLifelines.has(kind)) return; + this.usedLifelines.add(kind); + this.refreshLifelineButtons(); + this.reportProgress(); + + if (kind === "fiftyFifty") { + const wrongVisible = this.shuffledOptions + .map((_, i) => i) + .filter((i) => i !== this.shuffledCorrectIndex && !this.hiddenIndices.has(i)); + shuffle(wrongVisible) + .slice(0, 2) + .forEach((i) => this.hiddenIndices.add(i)); + this.renderOptions(); + return; + } + + const band = bandForLevel(this.level); + const visible = this.shuffledOptions.map((_, i) => i).filter((i) => !this.hiddenIndices.has(i)); + + if (kind === "audience") { + const confidence = AUDIENCE_CONFIDENCE[band]; + const wrongVisible = visible.filter((i) => i !== this.shuffledCorrectIndex); + const shares = new Map(); + shares.set(this.shuffledCorrectIndex, confidence); + const remaining = 1 - confidence; + wrongVisible.forEach((i) => shares.set(i, remaining / Math.max(1, wrongVisible.length))); + this.showAudienceOverlay(shares); + return; + } + + // phone + const confidence = PHONE_CONFIDENCE[band]; + const wrongVisible = visible.filter((i) => i !== this.shuffledCorrectIndex); + const suggestion = + Math.random() < confidence || wrongVisible.length === 0 + ? this.shuffledCorrectIndex + : wrongVisible[Math.floor(Math.random() * wrongVisible.length)]; + this.showPhoneOverlay(suggestion); + } + + private clearOverlay() { + this.overlayObjects.forEach((o) => o.destroy()); + this.overlayObjects = []; + } + + private showAudienceOverlay(shares: Map) { + this.clearOverlay(); + const bg = this.add.rectangle(480, 505, 780, 70, COLORS.overlayBg).setStrokeStyle(1, 0x292d4a); + this.overlayObjects.push(bg); + const title = this.add + .text(480, 478, "Помощь зала", { fontFamily: "sans-serif", fontSize: "12px", color: "#a3a8c6" }) + .setOrigin(0.5); + this.overlayObjects.push(title); + + const entries = [...shares.entries()].sort((a, b) => a[0] - b[0]); + const barAreaWidth = 720; + const barWidth = barAreaWidth / entries.length - 16; + entries.forEach(([optIndex, pct], i) => { + const x = 480 - barAreaWidth / 2 + i * (barAreaWidth / entries.length) + barWidth / 2 + 8; + const label = this.add + .text(x, 495, `${OPTION_LETTERS[optIndex]}: ${Math.round(pct * 100)}%`, { + fontFamily: "sans-serif", + fontSize: "13px", + color: "#eef0fb", + }) + .setOrigin(0.5); + this.overlayObjects.push(label); + const bar = this.add + .rectangle(x, 522, Math.max(6, barWidth * pct), 10, 0x818cf8) + .setOrigin(0.5); + this.overlayObjects.push(bar); + }); + } + + private showPhoneOverlay(suggestedIndex: number) { + this.clearOverlay(); + const bg = this.add.rectangle(480, 500, 780, 60, COLORS.overlayBg).setStrokeStyle(1, 0x292d4a); + this.overlayObjects.push(bg); + const text = this.add + .text(480, 500, `Друг на телефоне: «Я почти уверен, это ${OPTION_LETTERS[suggestedIndex]}»`, { + fontFamily: "sans-serif", + fontSize: "14px", + color: "#eef0fb", + align: "center", + wordWrap: { width: 720 }, + }) + .setOrigin(0.5); + this.overlayObjects.push(text); + } + + private refreshLifelineButtons() { + this.lifelineButtons.forEach((bg, kind) => { + const used = this.usedLifelines.has(kind); + bg.setFillStyle(used ? COLORS.lifelineUsed : COLORS.lifeline); + bg.disableInteractive(); + if (!used) bg.setInteractive({ useHandCursor: true }); + }); + } + + private reportProgress() { + this.onProgress?.({ + level: this.level, + bankedScore: this.bankedScore, + usedLifelines: Array.from(this.usedLifelines), + }); + } + + private finish(won: boolean, score: number, message: string) { + this.finished = true; + this.locked = true; + this.onFinish?.({ won, score, message }); + } } diff --git a/src/lib/games/types.ts b/src/lib/games/types.ts index 59808d5..85f0386 100644 --- a/src/lib/games/types.ts +++ b/src/lib/games/types.ts @@ -6,13 +6,13 @@ export const TEMPLATE_TYPES = ["quiz", "clicker", "maze"] as const; export type TemplateType = (typeof TEMPLATE_TYPES)[number]; export const TEMPLATE_LABELS: Record = { - quiz: "Викторина", + quiz: "Кто хочет стать миллионером?", clicker: "Кликер", maze: "Лабиринт", }; export const TEMPLATE_DESCRIPTIONS: Record = { - quiz: "Вопросы с вариантами ответа, подсчёт очков.", + quiz: "15 вопросов по нарастающей, денежная лестница, несгораемые суммы и три подсказки.", clicker: "Кликай, зарабатывай очки, покупай улучшения.", maze: "Собери предметы и дойди до выхода по лабиринту.", };