Add preserved-progress main menu flow

This commit is contained in:
2026-06-27 11:15:18 +03:00
parent 8fc3e39adb
commit 9e19931d8b
4 changed files with 155 additions and 29 deletions
+5 -4
View File
@@ -3,10 +3,11 @@
score: number; score: number;
playedCount: number; playedCount: number;
onPlayAgain: () => void; onPlayAgain: () => void;
onChangeSet: () => void; /** Вернуться в главное меню, не сбрасывая прогресс сразу. */
onMainMenu: () => void;
} }
let { score, playedCount, onPlayAgain, onChangeSet }: Props = $props(); let { score, playedCount, onPlayAgain, onMainMenu }: Props = $props();
// Мягкая финальная фраза в зависимости от результата. // Мягкая финальная фраза в зависимости от результата.
const finalPhrase = $derived.by(() => { const finalPhrase = $derived.by(() => {
@@ -66,10 +67,10 @@
<button <button
type="button" type="button"
onclick={onChangeSet} onclick={onMainMenu}
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" 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> </button>
</div> </div>
+5 -5
View File
@@ -6,11 +6,11 @@
played: QuestionRef[]; played: QuestionRef[];
onRandom: () => void; onRandom: () => void;
onNewGame: () => void; onNewGame: () => void;
/** Полный сброс к экрану выбора набора. */ /** Открыть главное меню (выбор набора), не сбрасывая прогресс. */
onChangeSet: () => void; onMainMenu: () => void;
} }
let { set, played, onRandom, onNewGame, onChangeSet }: Props = $props(); let { set, played, onRandom, onNewGame, onMainMenu }: Props = $props();
/** Есть ли ещё несыгранные вопросы (для кнопки «Случайный вопрос»). */ /** Есть ли ещё несыгранные вопросы (для кнопки «Случайный вопрос»). */
const hasUnplayed = $derived( const hasUnplayed = $derived(
@@ -38,9 +38,9 @@
<button <button
type="button" type="button"
onclick={onChangeSet} onclick={onMainMenu}
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" 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> 🏠 <span>Главное меню</span>
</button> </button>
</div> </div>
+79 -3
View File
@@ -5,11 +5,34 @@
interface Props { interface Props {
sets: QuestionSet[]; sets: QuestionSet[];
onSelect: (id: string) => void; onSelect: (id: string) => void;
/** Текущий сохранённый набор (если есть) — для блока «Продолжить партию». */
currentSet?: QuestionSet | null;
/** Счёт сохранённой партии. */
currentScore?: number;
/** Сыграно вопросов в сохранённой партии. */
currentPlayedCount?: number;
/** Всего вопросов в сохранённой партии. */
currentTotalCount?: number;
/** Продолжить текущую партию (закрыть главное меню без сброса). */
onContinue?: () => void;
} }
let { sets, onSelect }: Props = $props(); let {
sets,
onSelect,
currentSet = null,
currentScore = 0,
currentPlayedCount = 0,
currentTotalCount = 0,
onContinue
}: Props = $props();
let hovered = $state<string | null>(null); let hovered = $state<string | null>(null);
// Прогресс сохранённой партии в процентах — для полоски в блоке «Продолжить».
const continuePct = $derived(
currentTotalCount > 0 ? Math.round((currentPlayedCount / currentTotalCount) * 100) : 0
);
</script> </script>
<div class="animate-fade-in mx-auto w-full max-w-6xl px-4 py-10 sm:py-16"> <div class="animate-fade-in mx-auto w-full max-w-6xl px-4 py-10 sm:py-16">
@@ -29,15 +52,59 @@
</p> </p>
</header> </header>
{#if currentSet}
<!-- Сохранённая партия: вернуться к игре, не сбрасывая прогресс -->
<section class="mb-8 animate-soft-rise sm:mb-10">
<div
class="flex flex-col gap-5 rounded-3xl border border-mint-400/30 bg-gradient-to-br from-mint-400/10 to-night-700/40 p-6 backdrop-blur-sm sm:flex-row sm:items-center sm:justify-between"
>
<div class="min-w-0">
<div
class="text-[10px] font-semibold uppercase tracking-widest text-mint-300/80 sm:text-xs"
>
▶️ Сохранённая партия
</div>
<h2 class="mt-1 truncate text-lg font-bold text-white sm:text-xl">
{currentSet.title}
</h2>
<div
class="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-night-50/60"
>
<span class="inline-flex items-center gap-1"
>{(currentScore ?? 0).toLocaleString('ru-RU')}</span
>
<span class="inline-flex items-center gap-1"
>🎯 {currentPlayedCount ?? 0} из {currentTotalCount ?? 0}</span
>
</div>
<div class="mt-3 h-1.5 w-full max-w-xs 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: {continuePct}%"
></div>
</div>
</div>
<button
type="button"
onclick={() => onContinue?.()}
class="inline-flex shrink-0 items-center justify-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>
</div>
</section>
{/if}
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3"> <div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{#each sets as set, i (set.id)} {#each sets as set, i (set.id)}
{@const isCurrent = currentSet?.id === set.id}
<button <button
type="button" type="button"
onclick={() => onSelect(set.id)} onclick={() => onSelect(set.id)}
onmouseenter={() => (hovered = set.id)} onmouseenter={() => (hovered = set.id)}
onmouseleave={() => (hovered = null)} onmouseleave={() => (hovered = null)}
style="--i: {i}" 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" 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 {isCurrent ? 'border-mint-400/50 ring-2 ring-mint-400/30' : ''}"
> >
<!-- декоративное свечение --> <!-- декоративное свечение -->
<div <div
@@ -63,7 +130,16 @@
{set.description} {set.description}
</p> </p>
<div class="relative mt-5 flex items-center gap-4 text-xs text-night-50/50"> <div
class="relative mt-5 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-night-50/50"
>
{#if isCurrent}
<span
class="inline-flex items-center rounded-full bg-mint-400/20 px-2 py-0.5 text-xs font-bold text-mint-300"
>
▶️ Текущая
</span>
{/if}
<span class="inline-flex items-center gap-1">📂 {set.categories.length} категорий</span> <span class="inline-flex items-center gap-1">📂 {set.categories.length} категорий</span>
<span class="inline-flex items-center gap-1">{getTotalQuestions(set)} вопросов</span> <span class="inline-flex items-center gap-1">{getTotalQuestions(set)} вопросов</span>
</div> </div>
+66 -17
View File
@@ -11,8 +11,17 @@
import GameResult from '$lib/components/GameResult.svelte'; import GameResult from '$lib/components/GameResult.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'; import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
// ---- Локальное состояние UI ----
// Главное меню — это экран выбора набора, открытый поверх сохранённой
// партии. Локальный флаг: состояние игры (и localStorage) при этом НЕ
// меняется, прогресс сохраняется.
let showMainMenu = $state(false);
// ---- Состояние подтверждений ---- // ---- Состояние подтверждений ----
let confirmState = $state<{ kind: 'newGame' | 'changeSet' | null }>({ kind: null }); let confirmState = $state<{ kind: 'newGame' | 'switchSet' | null }>({ kind: null });
// Какой набор ждёт подтверждения перед сбросом текущей партии.
let pendingSetId = $state<string | null>(null);
// Сигнал для анимации счёта. // Сигнал для анимации счёта.
let bumpSignal = $state(0); let bumpSignal = $state(0);
@@ -35,7 +44,24 @@
// ---- Действия ведущего ---- // ---- Действия ведущего ----
function selectSet(id: string) { function selectSet(id: string) {
const currentId = $game.questionSetId;
// Тот же набор, что уже выбран, — просто продолжаем партию (без сброса).
if (currentId !== null && id === currentId) {
showMainMenu = false;
return;
}
// Другой набор при наличии прогресса — спросим перед сбросом.
if (hasProgress) {
pendingSetId = id;
confirmState = { kind: 'switchSet' };
return;
}
// Прогресса нет — спокойно переходим к новому набору.
game.selectQuestionSet(id); game.selectQuestionSet(id);
showMainMenu = false;
} }
function openQuestion(ref: QuestionRef) { function openQuestion(ref: QuestionRef) {
@@ -91,38 +117,61 @@
else game.newGame(); else game.newGame();
} }
function askChangeSet() { /** Открыть главное меню, не сбрасывая сохранённую партию. */
if (hasProgress) confirmState = { kind: 'changeSet' }; function openMainMenu() {
else game.changeSet(); showMainMenu = true;
}
/** Продолжить текущую партию — закрыть главное меню. */
function continueGame() {
showMainMenu = false;
} }
function confirmAction() { function confirmAction() {
if (confirmState.kind === 'newGame') game.newGame(); if (confirmState.kind === 'newGame') {
else if (confirmState.kind === 'changeSet') game.changeSet(); game.newGame();
} else if (confirmState.kind === 'switchSet') {
if (pendingSetId !== null) game.selectQuestionSet(pendingSetId);
showMainMenu = false;
}
confirmState = { kind: null }; confirmState = { kind: null };
pendingSetId = null;
} }
function cancelAction() { function cancelAction() {
confirmState = { kind: null }; confirmState = { kind: null };
pendingSetId = null;
} }
// Прогресс в процентах для полоски. // Прогресс в процентах для полоски.
const progressPct = $derived( const progressPct = $derived(
$totalQuestions > 0 ? Math.round(($playedCount / $totalQuestions) * 100) : 0 $totalQuestions > 0 ? Math.round(($playedCount / $totalQuestions) * 100) : 0
); );
// Эффективный экран с учётом локального «главного меню»: когда меню открыто,
// показываем выбор набора, не трогая сохранённую партию.
const effectiveScreen = $derived(showMainMenu ? 'select' : $screen);
</script> </script>
<!-- ===================== Экран выбора набора ===================== --> <!-- ===================== Экран выбора набора ===================== -->
{#if $screen === 'select'} {#if effectiveScreen === 'select'}
<QuestionSetSelector sets={questionSets} onSelect={selectSet} /> <QuestionSetSelector
{:else if $screen === 'result'} sets={questionSets}
onSelect={selectSet}
currentSet={$currentSet}
currentScore={$game.score}
currentPlayedCount={$playedCount}
currentTotalCount={$totalQuestions}
onContinue={continueGame}
/>
{:else if effectiveScreen === 'result'}
<GameResult <GameResult
score={$game.score} score={$game.score}
playedCount={$playedCount} playedCount={$playedCount}
onPlayAgain={() => game.newGame()} onPlayAgain={() => game.newGame()}
onChangeSet={() => game.changeSet()} onMainMenu={openMainMenu}
/> />
{:else if $screen === 'board' || $screen === 'question'} {:else if effectiveScreen === 'board' || effectiveScreen === 'question'}
{@const set = $currentSet} {@const set = $currentSet}
{#if set} {#if set}
<!-- Полноэкранная раскладка: всегда влезает во вьюпорт без скролла. <!-- Полноэкранная раскладка: всегда влезает во вьюпорт без скролла.
@@ -178,7 +227,7 @@
played={$game.played} played={$game.played}
onRandom={randomQuestion} onRandom={randomQuestion}
onNewGame={askNewGame} onNewGame={askNewGame}
onChangeSet={askChangeSet} onMainMenu={openMainMenu}
/> />
</footer> </footer>
</div> </div>
@@ -186,7 +235,7 @@
{/if} {/if}
<!-- ===================== Модальный экран вопроса ===================== --> <!-- ===================== Модальный экран вопроса ===================== -->
{#if $screen === 'question' && currentQuestionData} {#if effectiveScreen === 'question' && currentQuestionData}
<QuestionModal <QuestionModal
category={currentQuestionData.category} category={currentQuestionData.category}
question={currentQuestionData.question} question={currentQuestionData.question}
@@ -201,11 +250,11 @@
<!-- ===================== Диалог подтверждения ===================== --> <!-- ===================== Диалог подтверждения ===================== -->
<ConfirmDialog <ConfirmDialog
open={confirmState.kind !== null} open={confirmState.kind !== null}
title={confirmState.kind === 'changeSet' ? 'Сменить набор?' : 'Начать новую игру?'} title={confirmState.kind === 'switchSet' ? 'Сменить набор?' : 'Начать новую игру?'}
message={confirmState.kind === 'changeSet' message={confirmState.kind === 'switchSet'
? 'Текущая партия будет завершена, и вы вернётесь к выбору набора. Прогресс не сохранится.' ? 'Текущий прогресс будет сброшен, и начнётся игра с новым набором.'
: 'Счёт и сыгранные вопросы будут сброшены для этого набора.'} : 'Счёт и сыгранные вопросы будут сброшены для этого набора.'}
confirmLabel={confirmState.kind === 'changeSet' ? 'Сменить набор' : 'Начать заново'} confirmLabel={confirmState.kind === 'switchSet' ? 'Сменить набор' : 'Начать заново'}
onConfirm={confirmAction} onConfirm={confirmAction}
onCancel={cancelAction} onCancel={cancelAction}
/> />