Initialize calm game project

This commit is contained in:
2026-06-27 08:37:40 +03:00
commit cc7aec8db5
34 changed files with 5329 additions and 0 deletions
+209
View File
@@ -0,0 +1,209 @@
@import 'tailwindcss';
/* =========================================================================
Уютная ночная тема — тёплый градиент, мягкое свечение, спокойные цвета.
Эти переменные используются во всём приложении, чтобы тему было легко
менять в одном месте.
========================================================================= */
@theme {
--color-night-950: #0b0a1f;
--color-night-900: #14112e;
--color-night-800: #1d1840;
--color-night-700: #2a2356;
--color-night-600: #3a2f70;
--color-ember-50: #fff7ed;
--color-ember-200: #fed7aa;
--color-ember-300: #fdba74;
--color-ember-400: #fb923c;
--color-ember-500: #f97316;
--color-gold-300: #fcd34d;
--color-gold-400: #fbbf24;
--color-mint-300: #6ee7b7;
--color-mint-400: #34d399;
--color-plum-300: #d8b4fe;
--color-plum-400: #c084fc;
}
:root {
--font-display: 'Quicksand', ui-rounded, 'Segoe UI', system-ui, sans-serif;
}
html,
body {
min-height: 100%;
}
body {
font-family: var(--font-display);
color: theme(--color-night-50, #f8f7ff);
background:
radial-gradient(ellipse at top, #2a2356 0%, transparent 55%),
radial-gradient(ellipse at bottom, #3a1f5d 0%, transparent 50%),
linear-gradient(160deg, #0b0a1f 0%, #14112e 50%, #1d1840 100%);
background-attachment: fixed;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
/* Мягкие «звёзды» на фоне для атмосферы */
body::before {
content: '';
position: fixed;
inset: 0;
pointer-events: none;
background-image:
radial-gradient(1px 1px at 20% 30%, rgba(255, 255, 255, 0.5), transparent),
radial-gradient(1px 1px at 70% 60%, rgba(255, 255, 255, 0.35), transparent),
radial-gradient(1px 1px at 40% 80%, rgba(255, 255, 255, 0.3), transparent),
radial-gradient(1px 1px at 85% 20%, rgba(255, 255, 255, 0.4), transparent),
radial-gradient(1px 1px at 55% 45%, rgba(255, 255, 255, 0.25), transparent);
background-size: 100% 100%;
opacity: 0.6;
z-index: 0;
}
/* Убираем раздражающую синюю подсветку на мобильных */
button {
-webkit-tap-highlight-color: transparent;
}
/* =====================================================================
Курсор pointer для всех интерактивных элементов — централизованно.
По умолчанию браузеры дают кнопкам курсор `default`, что ощущается
«неживым». Включаем pointer глобально, а отключённым элементам —
`not-allowed`, чтобы было понятно, что клик невозможен.
===================================================================== */
button:not(:disabled),
[role='button']:not([aria-disabled='true']),
[role='button']:not(:disabled),
a[href],
summary,
label[for],
select,
[tabindex]:not([tabindex='-1']) {
cursor: pointer;
}
button:disabled,
[role='button'][aria-disabled='true'],
[aria-disabled='true'] {
cursor: not-allowed;
}
/* Скроллбар в теме */
*::-webkit-scrollbar {
width: 10px;
height: 10px;
}
*::-webkit-scrollbar-thumb {
background: rgba(192, 132, 252, 0.3);
border-radius: 999px;
}
*::-webkit-scrollbar-track {
background: transparent;
}
/* ===================== Анимации ===================== */
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes pop-in {
0% {
opacity: 0;
transform: scale(0.92) translateY(12px);
}
60% {
opacity: 1;
transform: scale(1.02) translateY(-2px);
}
100% {
opacity: 1;
transform: scale(1) translateY(0);
}
}
@keyframes soft-rise {
from {
opacity: 0;
transform: translateY(18px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes reveal-answer {
0% {
opacity: 0;
transform: translateY(10px) scale(0.98);
filter: blur(6px);
}
100% {
opacity: 1;
transform: translateY(0) scale(1);
filter: blur(0);
}
}
@keyframes score-bump {
0% {
transform: scale(1);
}
40% {
transform: scale(1.18);
}
100% {
transform: scale(1);
}
}
@keyframes glow-pulse {
0%,
100% {
box-shadow: 0 0 18px rgba(251, 191, 36, 0.25);
}
50% {
box-shadow: 0 0 34px rgba(251, 191, 36, 0.5);
}
}
.animate-fade-in {
animation: fade-in 0.45s ease both;
}
.animate-pop-in {
animation: pop-in 0.4s cubic-bezier(0.2, 0.9, 0.3, 1.3) both;
}
.animate-soft-rise {
animation: soft-rise 0.5s ease both;
}
.animate-reveal-answer {
animation: reveal-answer 0.55s ease both;
}
.animate-score-bump {
animation: score-bump 0.5s ease;
}
.animate-glow-pulse {
animation: glow-pulse 2.8s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
}
}
+13
View File
@@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="Спокойная семейная игра-викторина перед сном" />
<title>Уютная игра · Семейная викторина</title>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+67
View File
@@ -0,0 +1,67 @@
<script lang="ts">
import { fade, scale } from 'svelte/transition';
interface Props {
open: boolean;
title?: string;
message?: string;
confirmLabel?: string;
cancelLabel?: string;
onConfirm: () => void;
onCancel: () => void;
}
let {
open,
title = 'Подтвердите',
message = 'Вы уверены?',
confirmLabel = 'Да, продолжить',
cancelLabel = 'Отмена',
onConfirm,
onCancel
}: Props = $props();
function onKeydown(e: KeyboardEvent) {
if (!open) return;
if (e.key === 'Escape' || e.key === 'Enter') {
e.key === 'Enter' ? onConfirm() : onCancel();
}
}
</script>
<svelte:window onkeydown={onKeydown} />
{#if open}
<!-- Затемнённый фон -->
<div
class="fixed inset-0 z-[60] flex items-center justify-center bg-night-950/80 p-4 backdrop-blur-md"
transition:fade={{ duration: 200 }}
role="alertdialog"
aria-modal="true"
>
<div
class="w-full max-w-md rounded-3xl border border-white/10 bg-gradient-to-br from-night-700/95 to-night-900/95 p-7 text-center shadow-2xl shadow-night-950/60"
transition:scale={{ duration: 220, start: 0.92, opacity: 0.5 }}
>
<h2 class="text-xl font-bold text-white sm:text-2xl">{title}</h2>
<p class="mt-3 text-sm leading-relaxed text-night-50/70">{message}</p>
<div class="mt-7 flex flex-col gap-3 sm:flex-row sm:justify-center">
<button
type="button"
onclick={onCancel}
class="rounded-2xl border border-white/15 bg-white/5 px-6 py-3 text-sm font-semibold text-night-50/80 transition-all duration-200 hover:bg-white/10 focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-white/20 active:scale-95"
>
{cancelLabel}
</button>
<button
type="button"
onclick={onConfirm}
class="rounded-2xl bg-gradient-to-br from-ember-400 to-ember-500 px-6 py-3 text-sm font-bold text-night-900 shadow-lg shadow-ember-500/30 transition-all duration-200 hover:shadow-ember-500/50 focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-ember-400/50 active:scale-95"
>
{confirmLabel}
</button>
</div>
</div>
</div>
{/if}
+50
View File
@@ -0,0 +1,50 @@
<script lang="ts">
import type { QuestionRef, QuestionSet } from '$lib/types';
import QuestionCard from './QuestionCard.svelte';
interface Props {
set: QuestionSet;
/** Множество сыгранных вопросов (`categoryId:questionId`). */
played: QuestionRef[];
onOpenQuestion: (ref: QuestionRef) => void;
}
let { set, played, onOpenQuestion }: Props = $props();
const isPlayed = (categoryId: string, questionId: string) =>
played.includes(`${categoryId}:${questionId}`);
// Эмодзи-иконка для строки категории (чисто декоративная, по индексу).
const rowIcons = ['🐾', '🌍', '✨'];
</script>
<div class="flex h-full min-h-0 w-full flex-col gap-2 sm:gap-3">
{#each set.categories as category, ci (category.id)}
<div class="grid min-h-0 flex-1 gap-2 sm:gap-3" style="grid-template-columns: minmax(7rem, 1fr) repeat(5, minmax(0, 1fr))">
<!-- Заголовок категории (левая «колонка» строки) -->
<div
class="flex min-h-0 items-center gap-2 rounded-2xl border border-plum-400/30 bg-gradient-to-br from-plum-400/15 to-night-700/60 px-3 backdrop-blur-sm sm:px-4"
>
<span class="text-xl sm:text-2xl">{rowIcons[ci % rowIcons.length]}</span>
<h3
class="font-bold leading-tight text-plum-300"
style="font-size: clamp(0.8rem, 1.6vw, 1.5rem); text-shadow: 0 0 14px rgba(192, 132, 252, 0.3)"
>
{category.title}
</h3>
</div>
<!-- Карточки очков 100 / 200 / 300 / 500 / 1000 -->
{#each category.questions as question (question.id)}
{@const ref = `${category.id}:${question.id}`}
<div class="min-h-0 animate-pop-in" style="animation-delay: {ci * 50}ms">
<QuestionCard
points={question.points}
played={isPlayed(category.id, question.id)}
onSelect={() => onOpenQuestion(ref)}
/>
</div>
{/each}
</div>
{/each}
</div>
+77
View File
@@ -0,0 +1,77 @@
<script lang="ts">
interface Props {
score: number;
playedCount: number;
onPlayAgain: () => void;
onChangeSet: () => void;
}
let { score, playedCount, onPlayAgain, onChangeSet }: Props = $props();
// Мягкая финальная фраза в зависимости от результата.
const finalPhrase = $derived.by(() => {
const per = playedCount > 0 ? score / playedCount : 0;
if (per >= 450) return 'Великолепная командная игра — вы блестяще справились!';
if (per >= 250) return 'Замечательный вечер! Вы отлично играли вместе.';
if (per > 0) return 'Хорошая игра! Главное — вы провели время вместе.';
return 'Спасибо за тёплый вечер. Главное — что вы были вместе.';
});
</script>
<div class="animate-fade-in mx-auto flex min-h-[80vh] w-full max-w-2xl flex-col items-center justify-center px-4 py-10 text-center">
<div class="mb-6 animate-pop-in text-6xl" aria-hidden="true">🌟</div>
<h1
class="bg-gradient-to-r from-gold-300 via-ember-300 to-plum-300 bg-clip-text text-3xl font-bold text-transparent sm:text-5xl"
>
Вечер завершён
</h1>
<!-- Итоговый счёт -->
<div
class="mt-10 w-full max-w-md rounded-3xl border border-gold-400/30 bg-gradient-to-br from-night-700/80 to-night-800/80 p-8 shadow-2xl shadow-night-950/50 backdrop-blur-sm animate-soft-rise"
>
<span class="text-xs font-semibold uppercase tracking-widest text-gold-300/80">
Общий счёт команды
</span>
<div
class="mt-2 text-6xl font-extrabold tabular-nums text-gold-300 sm:text-7xl animate-score-bump"
style="text-shadow: 0 0 30px rgba(251, 191, 36, 0.4)"
>
{score.toLocaleString('ru-RU')}
</div>
<div class="mt-6 flex items-center justify-center gap-6 text-sm text-night-50/60">
<div class="flex items-center gap-2">
<span class="text-lg"></span>
<span>{playedCount} вопросов сыграно</span>
</div>
</div>
</div>
<!-- Мягкая финальная фраза -->
<p class="mt-8 max-w-md text-lg leading-relaxed text-night-50/80 animate-soft-rise">
{finalPhrase}
</p>
<!-- Действия -->
<div class="mt-10 flex flex-wrap items-center justify-center gap-4">
<button
type="button"
onclick={onPlayAgain}
class="inline-flex items-center gap-2 rounded-2xl bg-gradient-to-br from-mint-400 to-mint-300 px-7 py-4 text-base font-bold text-night-900 shadow-lg shadow-mint-400/30 transition-all duration-200 hover:scale-105 hover:shadow-mint-400/50 focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-mint-400/50 active:scale-95"
>
🔄 Сыграть снова
</button>
<button
type="button"
onclick={onChangeSet}
class="inline-flex items-center gap-2 rounded-2xl border border-white/15 bg-white/5 px-7 py-4 text-base font-semibold text-night-50/80 transition-all duration-200 hover:scale-105 hover:border-white/30 hover:bg-white/10 focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-white/20 active:scale-95"
>
🗂️ Выбрать другой набор
</button>
</div>
<p class="mt-12 text-sm text-night-50/40">Спокойной ночи и до новых игр 🌙</p>
</div>
+46
View File
@@ -0,0 +1,46 @@
<script lang="ts">
import type { QuestionRef, QuestionSet } from '$lib/types';
interface Props {
set: QuestionSet;
played: QuestionRef[];
onRandom: () => void;
onNewGame: () => void;
/** Полный сброс к экрану выбора набора. */
onChangeSet: () => void;
}
let { set, played, onRandom, onNewGame, onChangeSet }: Props = $props();
/** Есть ли ещё несыгранные вопросы (для кнопки «Случайный вопрос»). */
const hasUnplayed = $derived(
set.categories.some((c) => c.questions.some((q) => !played.includes(`${c.id}:${q.id}`)))
);
</script>
<div class="flex flex-wrap items-center justify-center gap-3 sm:gap-4">
<button
type="button"
onclick={onRandom}
disabled={!hasUnplayed}
class="inline-flex items-center gap-2 rounded-2xl border border-mint-400/40 bg-gradient-to-br from-mint-400/20 to-night-700/60 px-5 py-3 text-sm font-bold text-mint-300 shadow-lg shadow-night-950/30 transition-all duration-200 hover:scale-105 hover:border-mint-400/70 hover:shadow-mint-400/20 focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-mint-400/40 active:scale-95 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:scale-100 sm:text-base"
>
🎲 <span>Случайный вопрос</span>
</button>
<button
type="button"
onclick={onNewGame}
class="inline-flex items-center gap-2 rounded-2xl border border-white/15 bg-white/5 px-5 py-3 text-sm font-semibold text-night-50/80 transition-all duration-200 hover:scale-105 hover:border-white/30 hover:bg-white/10 focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-white/20 active:scale-95 sm:text-base"
>
🔄 <span>Новая игра</span>
</button>
<button
type="button"
onclick={onChangeSet}
class="inline-flex items-center gap-2 rounded-2xl border border-white/15 bg-white/5 px-5 py-3 text-sm font-semibold text-night-50/80 transition-all duration-200 hover:scale-105 hover:border-white/30 hover:bg-white/10 focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-white/20 active:scale-95 sm:text-base"
>
🗂️ <span>Сменить набор</span>
</button>
</div>
+44
View File
@@ -0,0 +1,44 @@
<script lang="ts">
import type { PointValue } from '$lib/types';
interface Props {
/** Стоимость вопроса (берётся из данных, НЕ хардкод). */
points: PointValue;
/** Сыгран ли вопрос (затемняется и неактивен). */
played: boolean;
onSelect: () => void;
}
let { points, played, onSelect }: Props = $props();
</script>
{#if played}
<!-- Сыгранный вопрос: затемнён, неактивен -->
<button
type="button"
disabled
aria-disabled="true"
aria-label="Вопрос сыгран"
class="flex h-full w-full min-h-0 items-center justify-center rounded-2xl border border-white/5 bg-night-900/60 text-night-50/25 transition-colors"
>
<span class="text-2xl font-bold line-through decoration-night-50/20 lg:text-4xl"></span>
</button>
{:else}
<button
type="button"
onclick={onSelect}
aria-label="Открыть вопрос за {points} очков"
class="group relative flex h-full w-full min-h-0 items-center justify-center overflow-hidden rounded-2xl border border-gold-400/30 bg-gradient-to-br from-night-700/90 to-night-800/90 shadow-lg shadow-night-950/40 transition-all duration-300 hover:scale-[1.03] hover:border-gold-400/70 hover:shadow-xl hover:shadow-gold-400/30 focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-gold-400/50 active:scale-95"
>
<!-- свечение при наведении -->
<div
class="pointer-events-none absolute inset-0 bg-gradient-to-br from-ember-400/0 via-ember-400/0 to-gold-400/0 opacity-0 transition-opacity duration-300 group-hover:from-ember-400/20 group-hover:via-ember-400/5 group-hover:to-gold-400/20 group-hover:opacity-100"
></div>
<span
class="relative font-extrabold tabular-nums text-gold-300 transition-transform duration-300 group-hover:scale-110"
style="font-size: clamp(1rem, 3vw, 2.6rem); text-shadow: 0 0 16px rgba(251, 191, 36, 0.35)"
>
{points.toLocaleString('ru-RU')}
</span>
</button>
{/if}
+164
View File
@@ -0,0 +1,164 @@
<script lang="ts">
import type { Category, Question } from '$lib/types';
interface Props {
category: Category;
question: Question;
onAward: (points: number) => void;
onReject: () => void;
onClose: () => void;
}
let { category, question, onAward, onReject, onClose }: Props = $props();
let answerShown = $state(false);
let hintShown = $state(false);
// При смене вопроса — сбрасываем локальное состояние показа.
$effect(() => {
question.id;
answerShown = false;
hintShown = false;
});
// Творческие вопросы — мягкие формулировки.
const isCreative = $derived(question.type === 'creative');
// Управление с клавиатуры: Esc — вернуться (если ответ скрыт — закрыть модал,
// иначе просто убрать фокус с кнопки). Удобно ведущему.
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && !answerShown) {
onClose();
}
}
</script>
<svelte:window onkeydown={onKeydown} />
<!-- Затемнённый фон -->
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-night-950/80 p-4 backdrop-blur-md animate-fade-in"
role="dialog"
aria-modal="true"
aria-label="Вопрос викторины"
>
<div
class="relative w-full max-w-3xl overflow-hidden rounded-3xl border border-white/10 bg-gradient-to-br from-night-700/95 to-night-900/95 shadow-2xl shadow-night-950/60 animate-pop-in"
>
<!-- верхняя подсветка -->
<div
class="pointer-events-none absolute -top-24 left-1/2 h-48 w-[120%] -translate-x-1/2 rounded-full bg-gradient-to-r from-ember-400/20 via-gold-400/20 to-plum-400/20 blur-3xl"
></div>
<div class="relative p-6 sm:p-10">
<!-- Категория + стоимость -->
<div class="mb-6 flex items-center justify-between gap-4">
<span
class="inline-flex items-center rounded-full bg-plum-400/15 px-4 py-1.5 text-sm font-semibold text-plum-300"
>
{category.title}
</span>
<span
class="inline-flex items-center rounded-full bg-gold-400/15 px-4 py-1.5 text-lg font-extrabold tabular-nums text-gold-300"
style="text-shadow: 0 0 14px rgba(251, 191, 36, 0.4)"
>
{question.points.toLocaleString('ru-RU')}
{#if isCreative}
<span class="ml-2 text-xs font-medium text-plum-300">творческий</span>
{/if}
</span>
</div>
<!-- Сам вопрос -->
<p
class="mb-2 text-center text-2xl font-bold leading-snug text-white sm:text-4xl"
style="text-shadow: 0 0 20px rgba(255,255,255,0.08)"
>
{question.question}
</p>
<!-- Подсказка (необязательная) -->
{#if question.optionalHint}
<div class="mt-4 flex justify-center">
{#if !hintShown}
<button
type="button"
onclick={() => (hintShown = true)}
class="inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/5 px-4 py-2 text-sm text-night-50/70 transition-colors hover:bg-white/10"
>
💡 Показать подсказку
</button>
{:else}
<p class="animate-fade-in max-w-lg rounded-2xl bg-white/5 px-4 py-3 text-center text-sm italic text-night-50/70">
💡 {question.optionalHint}
</p>
{/if}
</div>
{/if}
<!-- Ответ -->
{#if answerShown}
<div class="mt-8 animate-reveal-answer">
<div
class="rounded-2xl border border-mint-400/30 bg-mint-400/10 p-5 text-center"
>
<span class="mb-1 block text-xs font-semibold uppercase tracking-widest text-mint-300/80">
Ответ
</span>
<p class="text-xl font-bold text-mint-300 sm:text-2xl">{question.answer}</p>
</div>
<!-- Действия ведущего -->
<div class="mt-6 flex flex-wrap items-center justify-center gap-3">
<button
type="button"
onclick={() => onAward(question.points)}
class="inline-flex items-center gap-2 rounded-2xl bg-gradient-to-br from-mint-400 to-mint-300 px-6 py-3.5 text-base font-bold text-night-900 shadow-lg shadow-mint-400/30 transition-all duration-200 hover:scale-105 hover:shadow-mint-400/50 focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-mint-400/50 active:scale-95"
>
{isCreative ? 'Засчитать творческий ответ' : 'Засчитать'}
<span class="tabular-nums">+{question.points.toLocaleString('ru-RU')}</span>
</button>
<button
type="button"
onclick={onReject}
class="inline-flex items-center gap-2 rounded-2xl border border-white/15 bg-white/5 px-6 py-3.5 text-base font-semibold text-night-50/70 transition-all duration-200 hover:scale-105 hover:border-white/30 hover:bg-white/10 focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-white/20 active:scale-95"
>
✕ Не засчитывать
</button>
</div>
<div class="mt-4 text-center">
<button
type="button"
onclick={onClose}
class="text-sm text-night-50/50 underline-offset-4 transition-colors hover:text-night-50/80 hover:underline"
>
Вернуться к табло
</button>
</div>
</div>
{:else}
<div class="mt-8 flex justify-center">
<button
type="button"
onclick={() => (answerShown = true)}
class="inline-flex items-center gap-2 rounded-2xl bg-gradient-to-br from-gold-400 to-ember-500 px-8 py-4 text-lg font-bold text-night-900 shadow-lg shadow-gold-400/30 transition-all duration-200 hover:scale-105 hover:shadow-gold-400/50 focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-gold-400/50 active:scale-95"
>
👁️ Показать ответ
</button>
</div>
<div class="mt-4 text-center">
<button
type="button"
onclick={onClose}
class="text-sm text-night-50/50 underline-offset-4 transition-colors hover:text-night-50/80 hover:underline"
>
← Вернуться к табло
</button>
</div>
{/if}
</div>
</div>
</div>
@@ -0,0 +1,77 @@
<script lang="ts">
import type { QuestionSet } from '$lib/types';
import { getTotalQuestions } from '$lib/question-sets';
interface Props {
sets: QuestionSet[];
onSelect: (id: string) => void;
}
let { sets, onSelect }: Props = $props();
let hovered = $state<string | null>(null);
</script>
<div class="animate-fade-in mx-auto w-full max-w-6xl px-4 py-10 sm:py-16">
<header class="mb-10 text-center sm:mb-14">
<div
class="mx-auto mb-6 flex h-20 w-20 items-center justify-center rounded-3xl bg-gradient-to-br from-gold-400 to-ember-500 text-4xl shadow-lg shadow-ember-500/30 animate-glow-pulse"
>
🌙
</div>
<h1
class="bg-gradient-to-r from-gold-300 via-ember-300 to-plum-300 bg-clip-text text-3xl font-bold tracking-tight text-transparent sm:text-5xl"
>
Уютная викторина
</h1>
<p class="mx-auto mt-4 max-w-xl text-base text-night-50/70 sm:text-lg">
Спокойная игра для всей семьи перед сном. Выберите набор вопросов — и устраивайтесь поудобнее на диване.
</p>
</header>
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{#each sets as set, i (set.id)}
<button
type="button"
onclick={() => onSelect(set.id)}
onmouseenter={() => (hovered = set.id)}
onmouseleave={() => (hovered = null)}
style="--i: {i}"
class="group relative flex flex-col overflow-hidden rounded-3xl border border-white/10 bg-white/5 p-6 text-left backdrop-blur-sm transition-all duration-300 hover:-translate-y-1 hover:border-gold-400/50 hover:bg-white/10 hover:shadow-2xl hover:shadow-ember-500/20 focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-gold-400/40 animate-soft-rise"
>
<!-- декоративное свечение -->
<div
class="pointer-events-none absolute -right-10 -top-10 h-32 w-32 rounded-full bg-gradient-to-br from-ember-400/30 to-plum-400/20 blur-2xl transition-opacity duration-300 group-hover:opacity-100 {hovered ===
set.id
? 'opacity-100'
: 'opacity-50'}"
></div>
<div class="relative mb-4 flex items-center justify-between">
<span
class="inline-flex items-center rounded-full bg-gold-400/15 px-3 py-1 text-xs font-semibold text-gold-300"
>
{set.ageRange}
</span>
<span class="text-2xl opacity-70 transition-transform duration-300 group-hover:translate-x-1">
</span>
</div>
<h2 class="relative text-xl font-bold text-white sm:text-2xl">{set.title}</h2>
<p class="relative mt-2 flex-1 text-sm leading-relaxed text-night-50/70">
{set.description}
</p>
<div class="relative mt-5 flex items-center gap-4 text-xs text-night-50/50">
<span class="inline-flex items-center gap-1">📂 {set.categories.length} категорий</span>
<span class="inline-flex items-center gap-1">{getTotalQuestions(set)} вопросов</span>
</div>
</button>
{/each}
</div>
<footer class="mt-12 text-center text-xs text-night-50/40">
Ведущий управляет игрой · команда отвечает вместе · никто не торопится
</footer>
</div>
+37
View File
@@ -0,0 +1,37 @@
<script lang="ts">
interface Props {
score: number;
/** Триггер для анимации «подпрыгивания» при изменении счёта. */
bumpSignal?: number;
}
let { score, bumpSignal = 0 }: Props = $props();
// Реагируем на каждое изменение сигнала — пересоздаём анимацию.
let bumpClass = $state('');
$effect(() => {
// читаем сигнал
bumpSignal;
if (score === 0) return;
bumpClass = 'animate-score-bump';
const t = setTimeout(() => (bumpClass = ''), 500);
return () => clearTimeout(t);
});
</script>
<div
class="flex items-center gap-3 rounded-2xl border border-gold-400/30 bg-gradient-to-br from-night-700/80 to-night-800/80 px-5 py-3 shadow-lg shadow-night-950/40 backdrop-blur-sm"
>
<span class="text-2xl" aria-hidden="true"></span>
<div class="flex flex-col leading-none">
<span class="text-[10px] font-semibold uppercase tracking-widest text-gold-300/80">
Счёт команды
</span>
<span
class="text-3xl font-extrabold tabular-nums text-gold-300 sm:text-4xl {bumpClass}"
style="text-shadow: 0 0 18px rgba(251, 191, 36, 0.4)"
>
{score.toLocaleString('ru-RU')}
</span>
</div>
</div>
+51
View File
@@ -0,0 +1,51 @@
import confetti from 'canvas-confetti';
/**
* Мягкое «спокойное» конфетти за правильный ответ.
*
* Использует каноничную библиотеку `canvas-confetti` (без зависимостей,
* рендер в overlay-<canvas> поверх всего). Цвета — в тёплой теме приложения.
*
* Намеренно НЕ агрессивно: меньше частиц, мягкий разлёт, без бесконечных
* залпов. Уютное поздравление, а не новогодний салют.
*
* Важно: используем `useWorker: false` (рисуем на обычном DOM-canvas в главном
* потоке). Режим Web Worker по умолчанию использует OffscreenCanvas вне DOM,
* что в некоторых окружениях (headless/автоматизация, отдельные origin-условия)
* рендерит вхолостую. Для редких залпов викторины главный поток более чем
* достаточен и гарантированно работает везде.
*/
export function celebrate(): void {
const colors = ['#fcd34d', '#fbbf24', '#fb923c', '#c084fc', '#6ee7b7'];
const base = { colors, useWorker: false, disableForReducedMotion: true };
// Основной «пуфф» из центра-сверху.
confetti({
...base,
particleCount: 70,
spread: 70,
startVelocity: 38,
gravity: 0.9,
scalar: 1.05,
ticks: 200,
origin: { y: 0.35 }
});
// Два лёгких боковых дополнения с задержкой — мягкое эхо.
setTimeout(() => {
confetti({
...base,
particleCount: 28,
angle: 60,
spread: 55,
origin: { x: 0, y: 0.5 }
});
confetti({
...base,
particleCount: 28,
angle: 120,
spread: 55,
origin: { x: 1, y: 0.5 }
});
}, 180);
}
+148
View File
@@ -0,0 +1,148 @@
import type { QuestionSet } from '$lib/types';
/**
* Тестовый набор-заглушка: «Животные».
*
* Это пример структуры данных и НЕ содержит реального контента —
* только простые вопросы-заглушки. Реальные наборы добавляются отдельно
* по той же схеме. В каждой категории ровно 5 вопросов с очками
* 100 / 200 / 300 / 500 / 1000.
*/
export const animalsSet: QuestionSet = {
id: 'animals',
title: 'Животные',
description: 'Знакомые звери и птицы — тёплый разогрев для всей семьи.',
ageRange: '6+ лет',
categories: [
{
id: 'who',
title: 'Кто это?',
questions: [
{
id: 'q1',
points: 100,
question: 'Кто говорит «мяу»?',
answer: 'Кошка',
type: 'strict'
},
{
id: 'q2',
points: 200,
question: 'У какого домашнего животного есть грива?',
answer: 'Лев (или лошадь — грива есть и у жеребца)',
optionalHint: 'Его часто называют царём зверей.',
type: 'strict'
},
{
id: 'q3',
points: 300,
question: 'Какое животное носит дом на спине?',
answer: 'Улитка (или черепаха)',
type: 'strict'
},
{
id: 'q4',
points: 500,
question: 'Какое самое крупное наземное животное?',
answer: 'Африканский слон',
optionalHint: 'У него большие уши и хобот.',
type: 'strict'
},
{
id: 'q5',
points: 1000,
question: 'Какое морское животное — млекопитающее, а не рыба?',
answer: 'Кит или дельфин',
optionalHint: 'Оно дышит воздухом и кормит детёнышей молоком.',
type: 'strict'
}
]
},
{
id: 'where',
title: 'Где живут?',
questions: [
{
id: 'q1',
points: 100,
question: 'Где живёт аквариумная рыбка?',
answer: 'В аквариуме (в воде)',
type: 'strict'
},
{
id: 'q2',
points: 200,
question: 'Как называется домик для пчёл?',
answer: 'Улей (или пасека)',
type: 'strict'
},
{
id: 'q3',
points: 300,
question: 'В каком холодном месте живут белые медведи?',
answer: 'В Арктике (на Северном полюсе / во льдах)',
type: 'strict'
},
{
id: 'q4',
points: 500,
question: 'Где можно встретить стаю диких обезьян?',
answer: 'В тропическом лесу / джунглях',
optionalHint: 'Там тепло, влажно и много деревьев.',
type: 'strict'
},
{
id: 'q5',
points: 1000,
question: 'В какой природной зоне живут верблюды?',
answer: 'В пустыне',
optionalHint: 'Их называют «кораблями» этих мест.',
type: 'strict'
}
]
},
{
id: 'invent',
title: 'Придумай сам',
questions: [
{
id: 'q1',
points: 100,
question: 'Придумай ласковое прозвище для котёнка.',
answer: 'Любое тёплое имя (например: Пушок, Мурлык)',
type: 'creative'
},
{
id: 'q2',
points: 200,
question: 'Каким голосом, по-твоему, разговаривает собака?',
answer: 'Любой творческий ответ',
optionalHint: 'Это фантазийный вопрос — поощряй воображение.',
type: 'creative'
},
{
id: 'q3',
points: 300,
question: 'Придумай, чем хобот слона может быть полезен.',
answer: 'Любая идея (пить, обниматься, душ, рисовать)',
type: 'creative'
},
{
id: 'q4',
points: 500,
question: 'Опиши воображаемого зверя, который живёт на диване.',
answer: 'Любой фантазийный ответ',
optionalHint: 'Чем нелепее и добрее — тем лучше.',
type: 'creative'
},
{
id: 'q5',
points: 1000,
question: 'Придумай короткую сказку про дружбу кота и пчелы.',
answer: 'Любой связный рассказ',
type: 'creative'
}
]
}
]
};
+146
View File
@@ -0,0 +1,146 @@
import type { QuestionSet } from '$lib/types';
/**
* Тестовый набор-заглушка: «Уютные вечера».
*
* Содержит простые вопросы-заглушки без реального контента — структура
* данных и форматирование. Каждая категория содержит ровно 5 вопросов
* с очками 100 / 200 / 300 / 500 / 1000.
*/
export const cozySet: QuestionSet = {
id: 'cozy-evenings',
title: 'Уютные вечера',
description: 'Спокойные вопросы про дом, сон и вечерние ритуалы.',
ageRange: 'вся семья',
categories: [
{
id: 'bedtime',
title: 'Перед сном',
questions: [
{
id: 'q1',
points: 100,
question: 'Что мы надеваем перед тем, как лечь спать?',
answer: 'Пижаму',
type: 'strict'
},
{
id: 'q2',
points: 200,
question: 'Как называется мягкая подушка-одеяло, в которую закутываются?',
answer: 'Плед',
type: 'strict'
},
{
id: 'q3',
points: 300,
question: 'Что светит в окно ночью на небе?',
answer: 'Луна (и звёзды)',
type: 'strict'
},
{
id: 'q4',
points: 500,
question: 'Как называется горячий вечерний напиток из молока?',
answer: 'Какао (или горячий шоколад / тёплое молоко)',
optionalHint: 'Его часто пьют перед сном.',
type: 'strict'
},
{
id: 'q5',
points: 1000,
question: 'Что нужно сделать с кроватью, чтобы в ней было уютно спать?',
answer: 'Заправить / застелить / взбить подушку',
optionalHint: 'Это вечерний ритуал порядка.',
type: 'strict'
}
]
},
{
id: 'home',
title: 'Дом и семья',
questions: [
{
id: 'q1',
points: 100,
question: 'Кто будит всех по утрам в семье самым первым? (по-твоему)',
answer: 'Любой правдоподобный ответ (мама, папа, кот, будильник)',
type: 'creative'
},
{
id: 'q2',
points: 200,
question: 'Где семья чаще всего ужинает вместе?',
answer: 'На кухне / за столом',
type: 'strict'
},
{
id: 'q3',
points: 300,
question: 'Что висит на стене и показывает время в доме?',
answer: 'Часы',
type: 'strict'
},
{
id: 'q4',
points: 500,
question: 'Как называется лампа на прикроватной тумбочке?',
answer: 'Настольная лампа / ночник',
optionalHint: 'Она даёт мягкий свет вечером.',
type: 'strict'
},
{
id: 'q5',
points: 1000,
question: 'Какой домашний вечерний ритуал помогает уснуть спокойнее?',
answer: 'Чтение сказки / тёплый напиток / тёплый душ / тишину',
optionalHint: 'Главное — чтобы было тихо и тепло.',
type: 'creative'
}
]
},
{
id: 'dreams',
title: 'Фантазии перед сном',
questions: [
{
id: 'q1',
points: 100,
question: 'Если бы ты мог летать во сне, куда бы ты полетел?',
answer: 'Любой творческий ответ',
type: 'creative'
},
{
id: 'q2',
points: 200,
question: 'Какой цвет ты бы выбрал для звёзд?',
answer: 'Любой цвет с объяснением',
optionalHint: 'Может быть даже необычный.',
type: 'creative'
},
{
id: 'q3',
points: 300,
question: 'Придумай имя для доброго ночного облачка.',
answer: 'Любое мягкое имя',
type: 'creative'
},
{
id: 'q4',
points: 500,
question: 'Опиши самый уютный сон, который ты можешь представить.',
answer: 'Любой тёплый рассказ',
optionalHint: 'Мягкие цвета, тёплое место, добрые герои.',
type: 'creative'
},
{
id: 'q5',
points: 1000,
question: 'Сочини доброе пожелание перед сном для всей семьи.',
answer: 'Любое искреннее пожелание',
type: 'creative'
}
]
}
]
};
+57
View File
@@ -0,0 +1,57 @@
import type { Category, Question, QuestionRef, QuestionSet } from '$lib/types';
import { animalsSet } from './animals';
import { cozySet } from './cozy';
/**
* Реестр всех доступных наборов вопросов.
*
* ┌───────────────────────────────────────────────────────────────────────┐
* │ КАК ДОБАВИТЬ НОВЫЙ НАБОР: │
* │ 1. Создай файл `src/lib/question-sets/<name>.ts` │
* │ 2. Экспортируй из него объект типа `QuestionSet`. │
* │ (id, title, description, ageRange, categories[][]) │
* │ 3. Импортируй его сюда и добавь в массив `questionSets` ниже. │
* │ Всё — на экране выбора набор появится автоматически. │
* └───────────────────────────────────────────────────────────────────────┘
*/
export const questionSets: QuestionSet[] = [animalsSet, cozySet];
/* ----------------------------------------------------------------------- */
/* Хелперы поиска по данным (без состояния). */
/* ----------------------------------------------------------------------- */
export function getQuestionSetById(id: string): QuestionSet | undefined {
return questionSets.find((set) => set.id === id);
}
/** Сколько всего вопросов в наборе (для прогресс-бара). */
export function getTotalQuestions(set: QuestionSet): number {
return set.categories.reduce((sum, cat) => sum + cat.questions.length, 0);
}
/** Ключ для множества сыгранных вопросов: `categoryId:questionId`. */
export function makeQuestionRef(categoryId: string, questionId: string): QuestionRef {
return `${categoryId}:${questionId}`;
}
/** Разбивает ключ обратно на id категории и id вопроса. */
export function parseQuestionRef(ref: QuestionRef): { categoryId: string; questionId: string } {
const [categoryId, questionId] = ref.split(':');
return { categoryId, questionId };
}
/**
* Находит вопрос по ключу `categoryId:questionId` внутри набора.
* Возвращает категорию, вопрос или undefined, если не найдено.
*/
export function findQuestion(
set: QuestionSet,
ref: QuestionRef
): { category: Category; question: Question } | undefined {
const { categoryId, questionId } = parseQuestionRef(ref);
const category = set.categories.find((c) => c.id === categoryId);
if (!category) return undefined;
const question = category.questions.find((q) => q.id === questionId);
if (!question) return undefined;
return { category, question };
}
+186
View File
@@ -0,0 +1,186 @@
import { writable } from 'svelte/store';
import { browser } from '$app/environment';
import type { GameState, QuestionRef } from '$lib/types';
import { getQuestionSetById, getTotalQuestions } from '$lib/question-sets';
/**
* Игровое состояние полностью отделено от компонентов.
*
* Магия Svelte store: один `writable`, за которым следят все экраны.
* Действия ведущего (выбор набора, открытие вопроса, засчёт, новая игра)
* реализованы как функции, мутирующие этот store. При каждом изменении
* состояние автоматически сохраняется в localStorage, а при перезагрузке
* страницы — восстанавливается.
*/
const STORAGE_KEY = 'my-calm-game:state:v1';
/** Начальное (пустое) состояние — экран выбора набора. */
function initialState(): GameState {
return {
questionSetId: null,
score: 0,
played: [],
current: null
};
}
/** Безопасно читаем состояние из localStorage, игнорируя мусор/ошибки. */
function loadState(): GameState {
if (!browser) return initialState();
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return initialState();
const parsed = JSON.parse(raw) as Partial<GameState>;
// Защита от повреждённых/чужих данных: проверяем базовую структуру.
if (
parsed &&
(parsed.questionSetId === null || typeof parsed.questionSetId === 'string') &&
typeof parsed.score === 'number' &&
Array.isArray(parsed.played) &&
(parsed.current === null || typeof parsed.current === 'string')
) {
return {
questionSetId: parsed.questionSetId ?? null,
score: parsed.score ?? 0,
played: parsed.played as QuestionRef[],
current: parsed.current ?? null
};
}
} catch {
// Повреждённый JSON — просто начинаем заново.
}
return initialState();
}
/** Сохраняем состояние в localStorage (только в браузере). */
function saveState(state: GameState): void {
if (!browser) return;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch {
// localStorage переполнен или недоступен (приватный режим) —
// игра продолжит работать в памяти, просто без сохранения.
}
}
function createGameStore() {
const { subscribe, update, set } = writable<GameState>(loadState());
// Автосохранение при каждом изменении.
if (browser) {
subscribe((state) => saveState(state));
}
return {
subscribe,
/** Выбрать набор и перейти к табло. */
selectQuestionSet(id: string) {
update((s) => ({ ...s, questionSetId: id, score: 0, played: [], current: null }));
},
/** Открыть вопрос по ключу `categoryId:questionId`. */
openQuestion(ref: QuestionRef) {
update((s) => ({ ...s, current: ref }));
},
/** Закрыть текущий вопрос без решения (вернуться к табло). */
closeQuestion() {
update((s) => ({ ...s, current: null }));
},
/**
* Засчитать ответ: добавить очки и пометить вопрос сыгранным.
* Очки берутся из данных вопроса, а не хардкодятся.
*/
awardQuestion(ref: QuestionRef, points: number) {
update((s) => ({
...s,
score: s.score + points,
played: s.played.includes(ref) ? s.played : [...s.played, ref],
current: null
}));
},
/**
* Не засчитывать: вопрос всё равно считается сыгранным, но очки не идут.
* Штрафов и минусов нет.
*/
rejectQuestion(ref: QuestionRef) {
update((s) => ({
...s,
played: s.played.includes(ref) ? s.played : [...s.played, ref],
current: null
}));
},
/**
* Новая игра: сбросить прогресс по ТЕКУЩЕМУ набору (счёт, сыгранные,
* текущий вопрос). Набор остаётся выбранным. Сохранение очищается
* соответствующим образом (через автосохранение).
*/
newGame() {
update((s) => ({ ...s, score: 0, played: [], current: null }));
},
/**
* Сменить набор: вернуться к экрану выбора набора.
* Полный сброс партии.
*/
changeSet() {
set(initialState());
},
/** Принудительно заменить состояние (для тестов/отладки). */
reset() {
set(initialState());
}
};
}
export const game = createGameStore();
/* ----------------------------------------------------------------------- */
/* Производные значения как отдельные store (computed-like). */
/* ----------------------------------------------------------------------- */
import { derived } from 'svelte/store';
/** Текущий выбранный набор (объект) или null. */
export const currentSet = derived(game, ($g) =>
$g.questionSetId ? getQuestionSetById($g.questionSetId) ?? null : null
);
/** Сколько всего вопросов в текущем наборе. */
export const totalQuestions = derived(currentSet, ($set) =>
$set ? getTotalQuestions($set) : 0
);
/** Сколько вопросов уже сыграно. */
export const playedCount = derived(game, ($g) => $g.played.length);
/**
* Сыграли ли все вопросы набора → пора показывать финал.
* Финал показывается только если набор выбран и сыграны ВСЕ вопросы.
*/
export const isFinished = derived(
[currentSet, totalQuestions, playedCount],
([$set, $total, $played]) => $set !== null && $total > 0 && $played >= $total
);
/**
* Глобальное «app screen» — высчитывается из состояния.
* - 'select' — экран выбора набора
* - 'board' — основное табло
* - 'question' — открыт вопрос
* - 'result' — финальный экран
*
* Восстанавливается корректно после перезагрузки.
*/
export const screen = derived([game, currentSet, isFinished], ([$g, $set, $finished]) => {
if (!$set) return 'select' as const;
if ($finished) return 'result' as const;
if ($g.current) return 'question' as const;
return 'board' as const;
});
+105
View File
@@ -0,0 +1,105 @@
/**
* Доменные типы игры.
*
* Данные вопросов (`QuestionSet`, `Category`, `Question`) полностью отделены
* от состояния игры и от UI. Реальные наборы добавляются как файлы в
* `src/lib/question-sets/` и регистрируются в `src/lib/question-sets/index.ts`.
*/
/**
* Тип вопроса.
* - `strict` — есть однозначный правильный ответ (ведущий решает строго).
* - `creative` — творческий вопрос, допускающий разные interpretations.
* UI для таких вопросов использует более мягкие формулировки действий.
*/
export type QuestionType = 'strict' | 'creative';
/**
* Стоимость вопроса. Ограничена разрешёнными номиналами «Своей игры».
* Чем выше стоимость — тем сложнее вопрос (сложность заложена в тексте).
*/
export const POINT_VALUES = [100, 200, 300, 500, 1000] as const;
export type PointValue = (typeof POINT_VALUES)[number];
/**
* Структура вопроса (исходные данные, без состояния).
*
* `played` намеренно НЕ хранится здесь — это игровое состояние, оно живёт
* отдельно в `GameState` и сохраняется в localStorage.
*/
export interface Question {
/** Стабильный идентификатор, уникальный в пределах набора. */
id: string;
/** Стоимость вопроса, берётся из данных — НЕ хардкодится в UI. */
points: PointValue;
/** Текст вопроса. */
question: string;
/** Правильный ответ. */
answer: string;
/** Необязательная подсказка, ведущий может её показать по желанию. */
optionalHint?: string;
/** Тип вопроса влияет на формулировки действий ведущего. */
type: QuestionType;
}
/**
* Категория набора — содержит ровно 5 вопросов с очками 100/200/300/500/1000.
*
* `id` нужен для стабильной адресации вопросов (`categoryId:questionId`),
* так как id вопросов уникальны лишь в пределах категории.
*/
export interface Category {
id: string;
title: string;
questions: Question[];
}
/**
* Возрастная рекомендация набора.
*/
export type AgeRange = string;
/**
* Структура набора вопросов (исходные данные).
*
* Чтобы добавить новый набор:
* 1. Создайте файл в `src/lib/question-sets/<name>.ts`.
* 2. Экспортируйте из него объект, удовлетворяющий этому интерфейсу.
* 3. Добавьте его в массив `questionSets` в `src/lib/question-sets/index.ts`.
*/
export interface QuestionSet {
id: string;
title: string;
description: string;
/** Возрастная рекомендация, напр. "69 лет" или "вся семья". */
ageRange: AgeRange;
categories: Category[];
}
/* ----------------------------------------------------------------------- */
/* Игровое состояние (живёт отдельно от данных вопросов) */
/* ----------------------------------------------------------------------- */
/**
* Идентификатор сыгранного/открытого вопроса в виде `<categoryId>:<questionId>`.
* Используется как ключ в множестве сыгранных вопросов.
*/
export type QuestionRef = string;
/**
* Полное состояние одной партии.
* Сериализуется в localStorage и восстанавливается после перезагрузки.
*/
export interface GameState {
/** id выбранного набора (или null, если на экране выбора). */
questionSetId: string | null;
/** Текущий счёт команды (только засчитанные ответы). */
score: number;
/** Множество сыгранных вопросов в виде `categoryId:questionId`. */
played: QuestionRef[];
/**
* Текущий открытый вопрос (`categoryId:questionId`) или null,
* если табло открыто и вопрос не выбран.
*/
current: QuestionRef | null;
}
+9
View File
@@ -0,0 +1,9 @@
<script lang="ts">
import '../app.css';
let { children } = $props();
</script>
<div class="relative z-10 min-h-screen">
{@render children()}
</div>
+5
View File
@@ -0,0 +1,5 @@
// Отключаем SSR: игра полностью клиентская (работа с localStorage).
// Это упрощает работу с браузерным состоянием и avoids hydration mismatches
// при восстановлении сохранённой партии.
export const ssr = false;
export const prerender = false;
+195
View File
@@ -0,0 +1,195 @@
<script lang="ts">
import { game, currentSet, totalQuestions, playedCount, isFinished, screen } from '$lib/stores/game';
import { questionSets, findQuestion } from '$lib/question-sets';
import { celebrate } from '$lib/confetti';
import type { QuestionRef } from '$lib/types';
import QuestionSetSelector from '$lib/components/QuestionSetSelector.svelte';
import GameBoard from '$lib/components/GameBoard.svelte';
import ScorePanel from '$lib/components/ScorePanel.svelte';
import HostControls from '$lib/components/HostControls.svelte';
import QuestionModal from '$lib/components/QuestionModal.svelte';
import GameResult from '$lib/components/GameResult.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
// ---- Состояние подтверждений ----
let confirmState = $state<{ kind: 'newGame' | 'changeSet' | null }>({ kind: null });
// Сигнал для анимации счёта.
let bumpSignal = $state(0);
/** Текущий открытый вопрос (объект), если экран вопроса активен. */
const currentQuestionData = $derived.by(() => {
const set = $currentSet;
const ref = $game.current;
if (!set || !ref) return null;
return findQuestion(set, ref) ?? null;
});
// ---- Действия ведущего ----
function selectSet(id: string) {
game.selectQuestionSet(id);
}
function openQuestion(ref: QuestionRef) {
game.openQuestion(ref);
}
function closeQuestion() {
game.closeQuestion();
}
function awardQuestion(points: number) {
const ref = $game.current;
if (!ref) return;
game.awardQuestion(ref, points);
bumpSignal++;
// Праздничное конфетти — засчитанный ответ!
celebrate();
}
function rejectQuestion() {
const ref = $game.current;
if (!ref) return;
game.rejectQuestion(ref);
}
function randomQuestion() {
const set = $currentSet;
if (!set) return;
const unplayed: QuestionRef[] = [];
for (const cat of set.categories) {
for (const q of cat.questions) {
const ref = `${cat.id}:${q.id}`;
if (!$game.played.includes(ref)) unplayed.push(ref);
}
}
if (unplayed.length === 0) return;
const pick = unplayed[Math.floor(Math.random() * unplayed.length)];
openQuestion(pick);
}
// Подтверждаемые действия — только если игра уже началась (есть прогресс).
const hasProgress = $derived($game.score > 0 || $game.played.length > 0);
function askNewGame() {
if (hasProgress) confirmState = { kind: 'newGame' };
else game.newGame();
}
function askChangeSet() {
if (hasProgress) confirmState = { kind: 'changeSet' };
else game.changeSet();
}
function confirmAction() {
if (confirmState.kind === 'newGame') game.newGame();
else if (confirmState.kind === 'changeSet') game.changeSet();
confirmState = { kind: null };
}
function cancelAction() {
confirmState = { kind: null };
}
// Прогресс в процентах для полоски.
const progressPct = $derived(
$totalQuestions > 0 ? Math.round(($playedCount / $totalQuestions) * 100) : 0
);
</script>
<!-- ===================== Экран выбора набора ===================== -->
{#if $screen === 'select'}
<QuestionSetSelector sets={questionSets} onSelect={selectSet} />
{:else if $screen === 'result'}
<GameResult
score={$game.score}
playedCount={$playedCount}
onPlayAgain={() => game.newGame()}
onChangeSet={() => game.changeSet()}
/>
{:else if $screen === 'board' || $screen === 'question'}
{@const set = $currentSet}
{#if set}
<!-- Полноэкранная раскладка: всегда влезает во вьюпорт без скролла.
h-dvh = «настоящая» высота вьюпорта (учитывает моб. адресную строку).
flex-col + board во flex-1/min-h-0 забирает всё оставшееся место. -->
<div
class="mx-auto flex h-[100dvh] w-full max-w-[1400px] flex-col gap-3 px-4 py-4 sm:gap-4 sm:px-6 sm:py-5"
>
<!-- Шапка табло -->
<header class="flex flex-wrap items-center justify-between gap-3 sm:gap-4">
<div class="flex min-w-0 items-center gap-3">
<span class="text-2xl sm:text-3xl">🌙</span>
<div class="min-w-0">
<div
class="flex items-center gap-2 text-[10px] font-semibold uppercase tracking-widest text-plum-300/70 sm:text-xs"
>
📂 Набор вопросов
</div>
<h1
class="truncate font-bold text-white"
style="font-size: clamp(1.1rem, 2.4vw, 2rem)"
>
{set.title}
</h1>
</div>
</div>
<ScorePanel score={$game.score} bumpSignal={bumpSignal} />
</header>
<!-- Прогресс -->
<div class="shrink-0">
<div class="mb-1 flex items-center justify-between text-xs text-night-50/50">
<span>🎯 Прогресс · {$playedCount} из {$totalQuestions}</span>
<span>{progressPct}%</span>
</div>
<div class="h-2 w-full overflow-hidden rounded-full bg-white/5">
<div
class="h-full rounded-full bg-gradient-to-r from-mint-400 via-gold-400 to-ember-400 transition-all duration-500 ease-out"
style="width: {progressPct}%"
></div>
</div>
</div>
<!-- Само табло — забирает всё свободное место по высоте -->
<main class="min-h-0 flex-1">
<GameBoard set={set} played={$game.played} onOpenQuestion={openQuestion} />
</main>
<!-- Управление ведущего -->
<footer class="shrink-0">
<HostControls
set={set}
played={$game.played}
onRandom={randomQuestion}
onNewGame={askNewGame}
onChangeSet={askChangeSet}
/>
</footer>
</div>
{/if}
{/if}
<!-- ===================== Модальный экран вопроса ===================== -->
{#if $screen === 'question' && currentQuestionData}
<QuestionModal
category={currentQuestionData.category}
question={currentQuestionData.question}
onAward={awardQuestion}
onReject={rejectQuestion}
onClose={closeQuestion}
/>
{/if}
<!-- ===================== Диалог подтверждения ===================== -->
<ConfirmDialog
open={confirmState.kind !== null}
title={confirmState.kind === 'changeSet' ? 'Сменить набор?' : 'Начать новую игру?'}
message={confirmState.kind === 'changeSet'
? 'Текущая партия будет завершена, и вы вернётесь к выбору набора. Прогресс не сохранится.'
: 'Счёт и сыгранные вопросы будут сброшены для этого набора.'}
confirmLabel={confirmState.kind === 'changeSet' ? 'Сменить набор' : 'Начать заново'}
onConfirm={confirmAction}
onCancel={cancelAction}
/>