Turn the quiz template into "Кто хочет стать миллионером?"
Full redesign of the Quiz template (same templateType: "quiz" key — it's a plain text column, not a SQL enum, so no migration needed): 15-level money ladder with two guaranteed checkpoints (level 5, level 10), three classic lifelines (50:50, помощь зала, звонок другу — all simulated client-side, weighted by difficulty band), a "Забрать" walk-away option, and questions organized into 7 categories x 5 difficulty bands. Ships with a 105-question bank of real, fact-checked trivia (history, geography, science, literature, film/music, sport, general knowledge) per the explicit "don't invent anything, use facts" requirement — deliberately sticks to foundational/uncontestable facts rather than "current record holder" style trivia that goes stale. A game session draws one random unused question per level from that level's band pool, so repeated playthroughs don't see the same 15 questions. Checkpoint math verified against the classic rule (wrong answer keeps the highest checkpoint reached, not partial credit) via a standalone script before wiring it into the scene. Progress save/resume, the builder form (added category+band fields), and the AI-generation prompt (now requires real verifiable facts, structurally constrained to the 7-category enum) all updated to match the new shape. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QcXH24ky6zjk2UyK5oZUPH
This commit is contained in:
1 parent
43347bc31b
commit
ff1b2ff6f9
5 files changed
+502
-140
No files matched your search
@@ -3,7 +3,13 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Plus, Trash2, Save } from "lucide-react";
|
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({
|
export function QuizBuilder({
|
||||||
mode,
|
mode,
|
||||||
@@ -24,7 +30,6 @@ export function QuizBuilder({
|
|||||||
const [questions, setQuestions] = useState<QuizQuestion[]>(
|
const [questions, setQuestions] = useState<QuizQuestion[]>(
|
||||||
initialDefinition?.questions ?? DEFAULT_QUIZ_DEFINITION.questions,
|
initialDefinition?.questions ?? DEFAULT_QUIZ_DEFINITION.questions,
|
||||||
);
|
);
|
||||||
const [timePerQuestionSec, setTimePerQuestionSec] = useState(initialDefinition?.timePerQuestionSec ?? 0);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
@@ -39,31 +44,19 @@ export function QuizBuilder({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function addQuestion() {
|
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) {
|
function removeQuestion(index: number) {
|
||||||
setQuestions((qs) => qs.filter((_, i) => i !== index));
|
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() {
|
async function handleSave() {
|
||||||
setError(null);
|
setError(null);
|
||||||
const definition: QuizDefinition = { questions, timePerQuestionSec };
|
const definition: QuizDefinition = { questions };
|
||||||
const parsed = quizDefinitionSchema.safeParse(definition);
|
const parsed = quizDefinitionSchema.safeParse(definition);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
setError(parsed.error.issues[0]?.message ?? "Проверьте вопросы — что-то заполнено неверно");
|
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"
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="w-48 text-sm">
|
<p className="text-xs text-text-faint">
|
||||||
<span className="mb-1 block font-medium text-text-muted">Время на вопрос, сек (0 — без таймера)</span>
|
Денежная лестница и несгораемые суммы фиксированы (15 уровней). У каждого вопроса — рубрика и уровень
|
||||||
<input
|
сложности 1–5; в каждой из 5 сложностей должен быть хотя бы один вопрос.
|
||||||
type="number"
|
</p>
|
||||||
min={0}
|
|
||||||
max={120}
|
|
||||||
value={timePerQuestionSec}
|
|
||||||
onChange={(e) => setTimePerQuestionSec(Number(e.target.value))}
|
|
||||||
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{questions.map((q, qIndex) => (
|
{questions.map((q, qIndex) => (
|
||||||
@@ -145,6 +131,38 @@ export function QuizBuilder({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<label className="text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Рубрика</span>
|
||||||
|
<select
|
||||||
|
value={q.category}
|
||||||
|
onChange={(e) => updateQuestion(qIndex, { category: e.target.value as QuizQuestion["category"] })}
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
|
||||||
|
>
|
||||||
|
{QUIZ_CATEGORIES.map((c) => (
|
||||||
|
<option key={c} value={c}>
|
||||||
|
{c}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Сложность (1–5)</span>
|
||||||
|
<select
|
||||||
|
value={q.band}
|
||||||
|
onChange={(e) => updateQuestion(qIndex, { band: Number(e.target.value) })}
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
|
||||||
|
>
|
||||||
|
{[1, 2, 3, 4, 5].map((b) => (
|
||||||
|
<option key={b} value={b}>
|
||||||
|
{b}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
value={q.question}
|
value={q.question}
|
||||||
onChange={(e) => updateQuestion(qIndex, { question: e.target.value })}
|
onChange={(e) => updateQuestion(qIndex, { question: e.target.value })}
|
||||||
@@ -166,19 +184,8 @@ export function QuizBuilder({
|
|||||||
placeholder={`Вариант ${optIndex + 1}`}
|
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"
|
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 && (
|
|
||||||
<button onClick={() => removeOption(qIndex, optIndex)} className="text-text-faint hover:text-danger">
|
|
||||||
<Trash2 size={13} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{q.options.length < 6 && (
|
|
||||||
<button onClick={() => addOption(qIndex)} className="btn btn-ghost self-start text-xs">
|
|
||||||
<Plus size={12} />
|
|
||||||
Вариант
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { getAiSettings } from "./config";
|
|||||||
import { templateSchemas, type TemplateType, type GameDefinition } from "@/lib/games/types";
|
import { templateSchemas, type TemplateType, type GameDefinition } from "@/lib/games/types";
|
||||||
|
|
||||||
const SYSTEM_PROMPTS: Record<TemplateType, string> = {
|
const SYSTEM_PROMPTS: Record<TemplateType, string> = {
|
||||||
quiz: "Ты создаёшь викторины для простого игрового движка. Придумай вопросы и варианты ответа на русском языке по описанию пользователя. Один вариант в каждом вопросе должен быть правильным.",
|
quiz: "Ты создаёшь вопросы для викторины в формате «Кто хочет стать миллионером?» — 15-уровневая денежная лестница с нарастающей сложностью. Каждый вопрос должен использовать ТОЛЬКО реальные, проверяемые факты (даты, столицы, авторов, научные факты и т.п.) — ничего не выдумывай и не искажай. У каждого вопроса ровно 4 варианта ответа и один правильный. Поле band (1-5) — сложность: 1 — очень лёгкий вопрос, 5 — очень сложный. Обязательно должен быть хотя бы один вопрос на каждый band от 1 до 5. Категория (category) должна отражать тему вопроса.",
|
||||||
clicker:
|
clicker:
|
||||||
"Ты создаёшь простую кликер-игру (idle game) для игрового движка. Придумай тему, набор улучшений и целевой счёт по описанию пользователя. Улучшения должны быть сбалансированы: чем дороже — тем сильнее эффект.",
|
"Ты создаёшь простую кликер-игру (idle game) для игрового движка. Придумай тему, набор улучшений и целевой счёт по описанию пользователя. Улучшения должны быть сбалансированы: чем дороже — тем сильнее эффект.",
|
||||||
maze: "Ты создаёшь лабиринт для простого игрового движка на сетке символов. Придумай интересный лабиринт по описанию пользователя. Ровно один символ S (старт), хотя бы один E (выход), стены #, пол ., предметы *. Выход обязательно должен быть достижим от старта по коридорам без стен.",
|
maze: "Ты создаёшь лабиринт для простого игрового движка на сетке символов. Придумай интересный лабиринт по описанию пользователя. Ровно один символ S (старт), хотя бы один E (выход), стены #, пол ., предметы *. Выход обязательно должен быть достижим от старта по коридорам без стен.",
|
||||||
|
|||||||
+166
-14
@@ -1,33 +1,185 @@
|
|||||||
import { z } from "zod";
|
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
|
export const quizQuestionSchema = z
|
||||||
.object({
|
.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),
|
question: z.string().min(1).max(300),
|
||||||
options: z.array(z.string().min(1).max(120)).min(2).max(6),
|
options: z.array(z.string().min(1).max(120)).length(4),
|
||||||
correctIndex: z.number().int().min(0),
|
correctIndex: z.number().int().min(0).max(3),
|
||||||
})
|
})
|
||||||
.refine((q) => q.correctIndex < q.options.length, {
|
.refine((q) => q.correctIndex < q.options.length, {
|
||||||
message: "correctIndex must be a valid index into options",
|
message: "correctIndex must be a valid index into options",
|
||||||
path: ["correctIndex"],
|
path: ["correctIndex"],
|
||||||
});
|
});
|
||||||
|
|
||||||
export const quizDefinitionSchema = z.object({
|
export const quizDefinitionSchema = z
|
||||||
questions: z.array(quizQuestionSchema).min(1).max(30),
|
.object({
|
||||||
// 0 = no timer.
|
questions: z.array(quizQuestionSchema).min(5).max(500),
|
||||||
timePerQuestionSec: z.number().int().min(0).max(120).default(0),
|
})
|
||||||
});
|
.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<typeof quizQuestionSchema>;
|
export type QuizQuestion = z.infer<typeof quizQuestionSchema>;
|
||||||
export type QuizDefinition = z.infer<typeof quizDefinitionSchema>;
|
export type QuizDefinition = z.infer<typeof quizDefinitionSchema>;
|
||||||
|
|
||||||
export const DEFAULT_QUIZ_DEFINITION: QuizDefinition = {
|
|
||||||
questions: [{ question: "Сколько будет 2 + 2?", options: ["3", "4", "5"], correctIndex: 1 }],
|
|
||||||
timePerQuestionSec: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const quizProgressSchema = z.object({
|
export const quizProgressSchema = z.object({
|
||||||
questionIndex: z.number().int().min(0),
|
level: z.number().int().min(1).max(15),
|
||||||
score: z.number().int().min(0),
|
bankedScore: z.number().int().min(0),
|
||||||
|
usedLifelines: z.array(z.enum(["fiftyFifty", "audience", "phone"])).default([]),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type QuizProgress = z.infer<typeof quizProgressSchema>;
|
export type QuizProgress = z.infer<typeof quizProgressSchema>;
|
||||||
|
|
||||||
|
// 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 },
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -1,29 +1,72 @@
|
|||||||
import Phaser from "phaser";
|
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 = {
|
const COLORS = {
|
||||||
bg: 0x14162a,
|
bg: 0x14162a,
|
||||||
option: 0x23264a,
|
option: 0x23264a,
|
||||||
optionHover: 0x2d3163,
|
optionHover: 0x2d3163,
|
||||||
|
optionHidden: 0x181a30,
|
||||||
correct: 0x0d9488,
|
correct: 0x0d9488,
|
||||||
wrong: 0xdc2626,
|
wrong: 0xdc2626,
|
||||||
timerTrack: 0x292d4a,
|
lifeline: 0x23264a,
|
||||||
timerFill: 0x818cf8,
|
lifelineUsed: 0x181a30,
|
||||||
|
overlayBg: 0x1b1e36,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type LifelineKind = "fiftyFifty" | "audience" | "phone";
|
||||||
|
|
||||||
|
const AUDIENCE_CONFIDENCE: Record<number, number> = { 1: 0.85, 2: 0.75, 3: 0.65, 4: 0.55, 5: 0.45 };
|
||||||
|
const PHONE_CONFIDENCE: Record<number, number> = { 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<T>(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 {
|
export class QuizScene extends Phaser.Scene {
|
||||||
private definition!: QuizDefinition;
|
private definition!: QuizDefinition;
|
||||||
private onFinish?: (result: GameResult) => void;
|
private onFinish?: (result: GameResult) => void;
|
||||||
private onProgress?: (state: QuizProgress) => void;
|
private onProgress?: (state: QuizProgress) => void;
|
||||||
private questionIndex = 0;
|
|
||||||
private score = 0;
|
private level = 1;
|
||||||
|
private bankedScore = 0;
|
||||||
|
private usedLifelines = new Set<LifelineKind>();
|
||||||
|
private usedQuestionKeys = new Set<string>();
|
||||||
|
|
||||||
|
private currentQuestion!: QuizQuestion;
|
||||||
|
private shuffledOptions: string[] = [];
|
||||||
|
private shuffledCorrectIndex = 0;
|
||||||
|
private hiddenIndices = new Set<number>();
|
||||||
private locked = false;
|
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 questionText!: Phaser.GameObjects.Text;
|
||||||
private optionRects: Phaser.GameObjects.Rectangle[] = [];
|
private optionRects: Phaser.GameObjects.Rectangle[] = [];
|
||||||
private optionLabels: Phaser.GameObjects.Text[] = [];
|
private optionLabels: Phaser.GameObjects.Text[] = [];
|
||||||
private timerBar?: Phaser.GameObjects.Rectangle;
|
private lifelineButtons = new Map<LifelineKind, Phaser.GameObjects.Rectangle>();
|
||||||
private timerBarBg?: Phaser.GameObjects.Rectangle;
|
private walkAwayButton!: Phaser.GameObjects.Rectangle;
|
||||||
private timerEvent?: Phaser.Time.TimerEvent;
|
private walkAwayLabel!: Phaser.GameObjects.Text;
|
||||||
|
private overlayObjects: Phaser.GameObjects.GameObject[] = [];
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super("QuizScene");
|
super("QuizScene");
|
||||||
@@ -35,23 +78,87 @@ export class QuizScene extends Phaser.Scene {
|
|||||||
this.onProgress = this.registry.get("onProgress");
|
this.onProgress = this.registry.get("onProgress");
|
||||||
|
|
||||||
const initialState = this.registry.get("initialState") as QuizProgress | undefined;
|
const initialState = this.registry.get("initialState") as QuizProgress | undefined;
|
||||||
const totalQuestions = this.definition.questions.length;
|
this.level = initialState ? Math.min(15, Math.max(1, initialState.level)) : 1;
|
||||||
this.questionIndex =
|
this.bankedScore = initialState?.bankedScore ?? 0;
|
||||||
initialState && initialState.questionIndex < totalQuestions ? initialState.questionIndex : 0;
|
this.usedLifelines = new Set(initialState?.usedLifelines ?? []);
|
||||||
this.score = initialState?.score ?? 0;
|
this.usedQuestionKeys = new Set();
|
||||||
|
this.finished = false;
|
||||||
|
|
||||||
this.cameras.main.setBackgroundColor(COLORS.bg);
|
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
|
this.questionText = this.add
|
||||||
.text(480, 60, "", {
|
.text(480, 55, "", {
|
||||||
fontFamily: "sans-serif",
|
fontFamily: "sans-serif",
|
||||||
fontSize: "24px",
|
fontSize: "21px",
|
||||||
color: "#eef0fb",
|
color: "#eef0fb",
|
||||||
align: "center",
|
align: "center",
|
||||||
wordWrap: { width: 820 },
|
wordWrap: { width: 840 },
|
||||||
})
|
})
|
||||||
.setOrigin(0.5, 0);
|
.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() {
|
private clearOptions() {
|
||||||
@@ -59,90 +166,186 @@ export class QuizScene extends Phaser.Scene {
|
|||||||
this.optionLabels.forEach((l) => l.destroy());
|
this.optionLabels.forEach((l) => l.destroy());
|
||||||
this.optionRects = [];
|
this.optionRects = [];
|
||||||
this.optionLabels = [];
|
this.optionLabels = [];
|
||||||
this.timerEvent?.remove();
|
|
||||||
this.timerBar?.destroy();
|
|
||||||
this.timerBarBg?.destroy();
|
|
||||||
this.timerBar = undefined;
|
|
||||||
this.timerBarBg = undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private showQuestion() {
|
private renderOptions() {
|
||||||
this.locked = false;
|
|
||||||
this.clearOptions();
|
this.clearOptions();
|
||||||
|
const startY = 175;
|
||||||
|
const gap = 58;
|
||||||
|
|
||||||
const q = this.definition.questions[this.questionIndex];
|
this.shuffledOptions.forEach((option, i) => {
|
||||||
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) => {
|
|
||||||
const y = startY + i * gap;
|
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
|
const label = this.add
|
||||||
.text(480, y, option, { fontFamily: "sans-serif", fontSize: "17px", color: "#eef0fb" })
|
.text(140, y, hidden ? "" : `${OPTION_LETTERS[i]}: ${option}`, {
|
||||||
.setOrigin(0.5);
|
fontFamily: "sans-serif",
|
||||||
bg.setInteractive({ useHandCursor: true });
|
fontSize: "16px",
|
||||||
bg.on("pointerover", () => {
|
color: "#eef0fb",
|
||||||
if (!this.locked) bg.setFillStyle(COLORS.optionHover);
|
})
|
||||||
});
|
.setOrigin(0, 0.5);
|
||||||
bg.on("pointerout", () => {
|
|
||||||
if (!this.locked) bg.setFillStyle(COLORS.option);
|
if (!hidden) {
|
||||||
});
|
bg.setInteractive({ useHandCursor: true });
|
||||||
bg.on("pointerdown", () => this.selectOption(i, bg));
|
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.optionRects.push(bg);
|
||||||
this.optionLabels.push(label);
|
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;
|
if (this.locked) return;
|
||||||
this.locked = true;
|
this.locked = true;
|
||||||
this.timerEvent?.remove();
|
|
||||||
|
|
||||||
const q = this.definition.questions[this.questionIndex];
|
const correct = index === this.shuffledCorrectIndex;
|
||||||
const correct = index === q.correctIndex;
|
bg.setFillStyle(correct ? COLORS.correct : COLORS.wrong);
|
||||||
if (correct) this.score += 1;
|
if (!correct) {
|
||||||
if (bg) bg.setFillStyle(correct ? COLORS.correct : COLORS.wrong);
|
this.optionRects[this.shuffledCorrectIndex]?.setFillStyle(COLORS.correct);
|
||||||
if (!correct && index >= 0) {
|
|
||||||
const correctBg = this.optionRects[q.correctIndex];
|
|
||||||
correctBg?.setFillStyle(COLORS.correct);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.time.delayedCall(700, () => {
|
this.time.delayedCall(900, () => {
|
||||||
this.questionIndex += 1;
|
if (correct) {
|
||||||
this.onProgress?.({ questionIndex: this.questionIndex, score: this.score });
|
this.bankedScore = MONEY_LADDER[this.level - 1];
|
||||||
if (this.questionIndex >= this.definition.questions.length) {
|
if (this.level >= 15) {
|
||||||
const total = this.definition.questions.length;
|
this.finish(true, this.bankedScore, `Максимальный выигрыш: ${formatMoney(this.bankedScore)} очков!`);
|
||||||
this.onFinish?.({
|
return;
|
||||||
won: this.score >= Math.ceil(total / 2),
|
}
|
||||||
score: this.score,
|
this.level += 1;
|
||||||
message: `${this.score} из ${total}`,
|
this.startLevel();
|
||||||
});
|
|
||||||
} else {
|
} 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<number, number>();
|
||||||
|
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<number, number>) {
|
||||||
|
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 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -6,13 +6,13 @@ export const TEMPLATE_TYPES = ["quiz", "clicker", "maze"] as const;
|
|||||||
export type TemplateType = (typeof TEMPLATE_TYPES)[number];
|
export type TemplateType = (typeof TEMPLATE_TYPES)[number];
|
||||||
|
|
||||||
export const TEMPLATE_LABELS: Record<TemplateType, string> = {
|
export const TEMPLATE_LABELS: Record<TemplateType, string> = {
|
||||||
quiz: "Викторина",
|
quiz: "Кто хочет стать миллионером?",
|
||||||
clicker: "Кликер",
|
clicker: "Кликер",
|
||||||
maze: "Лабиринт",
|
maze: "Лабиринт",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const TEMPLATE_DESCRIPTIONS: Record<TemplateType, string> = {
|
export const TEMPLATE_DESCRIPTIONS: Record<TemplateType, string> = {
|
||||||
quiz: "Вопросы с вариантами ответа, подсчёт очков.",
|
quiz: "15 вопросов по нарастающей, денежная лестница, несгораемые суммы и три подсказки.",
|
||||||
clicker: "Кликай, зарабатывай очки, покупай улучшения.",
|
clicker: "Кликай, зарабатывай очки, покупай улучшения.",
|
||||||
maze: "Собери предметы и дойди до выхода по лабиринту.",
|
maze: "Собери предметы и дойди до выхода по лабиринту.",
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in new issue
Block a user