Initialize calm game project
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
Reference in New Issue
Block a user