Add preserved-progress main menu flow
This commit is contained in:
@@ -3,10 +3,11 @@
|
||||
score: number;
|
||||
playedCount: number;
|
||||
onPlayAgain: () => void;
|
||||
onChangeSet: () => void;
|
||||
/** Вернуться в главное меню, не сбрасывая прогресс сразу. */
|
||||
onMainMenu: () => void;
|
||||
}
|
||||
|
||||
let { score, playedCount, onPlayAgain, onChangeSet }: Props = $props();
|
||||
let { score, playedCount, onPlayAgain, onMainMenu }: Props = $props();
|
||||
|
||||
// Мягкая финальная фраза в зависимости от результата.
|
||||
const finalPhrase = $derived.by(() => {
|
||||
@@ -66,10 +67,10 @@
|
||||
|
||||
<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"
|
||||
>
|
||||
🗂️ Выбрать другой набор
|
||||
🗂️ Главное меню
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -6,11 +6,11 @@
|
||||
played: QuestionRef[];
|
||||
onRandom: () => 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(
|
||||
@@ -38,9 +38,9 @@
|
||||
|
||||
<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"
|
||||
>
|
||||
🗂️ <span>Сменить набор</span>
|
||||
🏠 <span>Главное меню</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -5,11 +5,34 @@
|
||||
interface Props {
|
||||
sets: QuestionSet[];
|
||||
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);
|
||||
|
||||
// Прогресс сохранённой партии в процентах — для полоски в блоке «Продолжить».
|
||||
const continuePct = $derived(
|
||||
currentTotalCount > 0 ? Math.round((currentPlayedCount / currentTotalCount) * 100) : 0
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="animate-fade-in mx-auto w-full max-w-6xl px-4 py-10 sm:py-16">
|
||||
@@ -29,15 +52,59 @@
|
||||
</p>
|
||||
</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">
|
||||
{#each sets as set, i (set.id)}
|
||||
{@const isCurrent = currentSet?.id === 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"
|
||||
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
|
||||
@@ -63,7 +130,16 @@
|
||||
{set.description}
|
||||
</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">❓ {getTotalQuestions(set)} вопросов</span>
|
||||
</div>
|
||||
|
||||
+66
-17
@@ -11,8 +11,17 @@
|
||||
import GameResult from '$lib/components/GameResult.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);
|
||||
@@ -35,7 +44,24 @@
|
||||
// ---- Действия ведущего ----
|
||||
|
||||
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);
|
||||
showMainMenu = false;
|
||||
}
|
||||
|
||||
function openQuestion(ref: QuestionRef) {
|
||||
@@ -91,38 +117,61 @@
|
||||
else game.newGame();
|
||||
}
|
||||
|
||||
function askChangeSet() {
|
||||
if (hasProgress) confirmState = { kind: 'changeSet' };
|
||||
else game.changeSet();
|
||||
/** Открыть главное меню, не сбрасывая сохранённую партию. */
|
||||
function openMainMenu() {
|
||||
showMainMenu = true;
|
||||
}
|
||||
|
||||
/** Продолжить текущую партию — закрыть главное меню. */
|
||||
function continueGame() {
|
||||
showMainMenu = false;
|
||||
}
|
||||
|
||||
function confirmAction() {
|
||||
if (confirmState.kind === 'newGame') game.newGame();
|
||||
else if (confirmState.kind === 'changeSet') game.changeSet();
|
||||
if (confirmState.kind === 'newGame') {
|
||||
game.newGame();
|
||||
} else if (confirmState.kind === 'switchSet') {
|
||||
if (pendingSetId !== null) game.selectQuestionSet(pendingSetId);
|
||||
showMainMenu = false;
|
||||
}
|
||||
confirmState = { kind: null };
|
||||
pendingSetId = null;
|
||||
}
|
||||
|
||||
function cancelAction() {
|
||||
confirmState = { kind: null };
|
||||
pendingSetId = null;
|
||||
}
|
||||
|
||||
// Прогресс в процентах для полоски.
|
||||
const progressPct = $derived(
|
||||
$totalQuestions > 0 ? Math.round(($playedCount / $totalQuestions) * 100) : 0
|
||||
);
|
||||
|
||||
// Эффективный экран с учётом локального «главного меню»: когда меню открыто,
|
||||
// показываем выбор набора, не трогая сохранённую партию.
|
||||
const effectiveScreen = $derived(showMainMenu ? 'select' : $screen);
|
||||
</script>
|
||||
|
||||
<!-- ===================== Экран выбора набора ===================== -->
|
||||
{#if $screen === 'select'}
|
||||
<QuestionSetSelector sets={questionSets} onSelect={selectSet} />
|
||||
{:else if $screen === 'result'}
|
||||
{#if effectiveScreen === 'select'}
|
||||
<QuestionSetSelector
|
||||
sets={questionSets}
|
||||
onSelect={selectSet}
|
||||
currentSet={$currentSet}
|
||||
currentScore={$game.score}
|
||||
currentPlayedCount={$playedCount}
|
||||
currentTotalCount={$totalQuestions}
|
||||
onContinue={continueGame}
|
||||
/>
|
||||
{:else if effectiveScreen === 'result'}
|
||||
<GameResult
|
||||
score={$game.score}
|
||||
playedCount={$playedCount}
|
||||
onPlayAgain={() => game.newGame()}
|
||||
onChangeSet={() => game.changeSet()}
|
||||
onMainMenu={openMainMenu}
|
||||
/>
|
||||
{:else if $screen === 'board' || $screen === 'question'}
|
||||
{:else if effectiveScreen === 'board' || effectiveScreen === 'question'}
|
||||
{@const set = $currentSet}
|
||||
{#if set}
|
||||
<!-- Полноэкранная раскладка: всегда влезает во вьюпорт без скролла.
|
||||
@@ -178,7 +227,7 @@
|
||||
played={$game.played}
|
||||
onRandom={randomQuestion}
|
||||
onNewGame={askNewGame}
|
||||
onChangeSet={askChangeSet}
|
||||
onMainMenu={openMainMenu}
|
||||
/>
|
||||
</footer>
|
||||
</div>
|
||||
@@ -186,7 +235,7 @@
|
||||
{/if}
|
||||
|
||||
<!-- ===================== Модальный экран вопроса ===================== -->
|
||||
{#if $screen === 'question' && currentQuestionData}
|
||||
{#if effectiveScreen === 'question' && currentQuestionData}
|
||||
<QuestionModal
|
||||
category={currentQuestionData.category}
|
||||
question={currentQuestionData.question}
|
||||
@@ -201,11 +250,11 @@
|
||||
<!-- ===================== Диалог подтверждения ===================== -->
|
||||
<ConfirmDialog
|
||||
open={confirmState.kind !== null}
|
||||
title={confirmState.kind === 'changeSet' ? 'Сменить набор?' : 'Начать новую игру?'}
|
||||
message={confirmState.kind === 'changeSet'
|
||||
? 'Текущая партия будет завершена, и вы вернётесь к выбору набора. Прогресс не сохранится.'
|
||||
title={confirmState.kind === 'switchSet' ? 'Сменить набор?' : 'Начать новую игру?'}
|
||||
message={confirmState.kind === 'switchSet'
|
||||
? 'Текущий прогресс будет сброшен, и начнётся игра с новым набором.'
|
||||
: 'Счёт и сыгранные вопросы будут сброшены для этого набора.'}
|
||||
confirmLabel={confirmState.kind === 'changeSet' ? 'Сменить набор' : 'Начать заново'}
|
||||
confirmLabel={confirmState.kind === 'switchSet' ? 'Сменить набор' : 'Начать заново'}
|
||||
onConfirm={confirmAction}
|
||||
onCancel={cancelAction}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user