212 lines
7.3 KiB
Svelte
212 lines
7.3 KiB
Svelte
<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;
|
|
});
|
|
|
|
/** Раскрывал ли ведущий подсказку для текущего открытого вопроса. */
|
|
const hintUsedForCurrent = $derived.by(() => {
|
|
const ref = $game.current;
|
|
if (!ref) return false;
|
|
return $game.hintUsed.includes(ref);
|
|
});
|
|
|
|
// ---- Действия ведущего ----
|
|
|
|
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 markCurrentHintUsed() {
|
|
const ref = $game.current;
|
|
if (!ref) return;
|
|
game.markHintUsed(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}
|
|
hintUsed={hintUsedForCurrent}
|
|
onMarkHintUsed={markCurrentHintUsed}
|
|
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}
|
|
/>
|