Add preserved-progress main menu flow
This commit is contained in:
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user