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
+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}
/>