Add auction question mechanic
This commit is contained in:
@@ -0,0 +1,301 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import type { Category, Question } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
/** Номинал оригинальной карточки табло, по которой запустили аукцион. */
|
||||
originalNominal: number;
|
||||
/** Название набора, откуда пришёл случайный вопрос аукциона. */
|
||||
sourceSetTitle: string;
|
||||
/** Категория случайного вопроса аукциона. */
|
||||
category: Category;
|
||||
/** Случайный вопрос аукциона (не с табло). */
|
||||
question: Question;
|
||||
/** Текущий счёт команды. */
|
||||
score: number;
|
||||
/** Текущая ставка — эффективная стоимость аукциона. */
|
||||
wager: number;
|
||||
/** Показывал ли ведущий подсказку аукциона. */
|
||||
hintUsed: boolean;
|
||||
onSetWager: (wager: number) => void;
|
||||
onMarkHintUsed: () => void;
|
||||
onAward: (points: number) => void;
|
||||
onReject: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
originalNominal,
|
||||
sourceSetTitle,
|
||||
category,
|
||||
question,
|
||||
score,
|
||||
wager,
|
||||
hintUsed,
|
||||
onSetWager,
|
||||
onMarkHintUsed,
|
||||
onAward,
|
||||
onReject,
|
||||
onCancel
|
||||
}: Props = $props();
|
||||
|
||||
let answerShown = $state(false);
|
||||
|
||||
// При смене вопроса аукциона — сбрасываем локальное состояние показа ответа.
|
||||
$effect(() => {
|
||||
question.id;
|
||||
answerShown = false;
|
||||
});
|
||||
|
||||
// Ставку можно редактировать только при ненулевом счёте.
|
||||
const wagerEditable = $derived(score > 0);
|
||||
|
||||
// Локальное зеркало поля ставки: даём свободно печатать, не сражаясь с
|
||||
// пользователем, и синхронизируемся со значением из состояния, когда оно
|
||||
// поменялось извне (кнопка «Ва-банк» или восстановление после перезагрузки).
|
||||
let wagerInput = $state(untrack(() => String(wager)));
|
||||
let lastWager = untrack(() => wager);
|
||||
$effect(() => {
|
||||
if (wager !== lastWager) {
|
||||
lastWager = wager;
|
||||
wagerInput = String(wager);
|
||||
}
|
||||
});
|
||||
|
||||
function onWagerInput(e: Event) {
|
||||
const target = e.currentTarget as HTMLInputElement;
|
||||
wagerInput = target.value;
|
||||
const n = parseInt(target.value, 10);
|
||||
// Пустое/нечисловое поле коммитим в состояние только когда оно валидно —
|
||||
// даём ведущему спокойно стереть и перепечатать ставку.
|
||||
if (Number.isFinite(n)) {
|
||||
onSetWager(n);
|
||||
}
|
||||
}
|
||||
|
||||
function vaBank() {
|
||||
onSetWager(score);
|
||||
}
|
||||
|
||||
// Половина ставки — для ответа по подсказке или частично правильного ответа.
|
||||
const halfValue = $derived(Math.round(wager / 2));
|
||||
// Сколько очков даёт основное «Засчитать»: с подсказкой — половину, иначе ставку.
|
||||
const awardPoints = $derived(hintUsed ? halfValue : wager);
|
||||
|
||||
// Управление с клавиатуры: Esc — отменить аукцион (если ответ скрыт).
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && !answerShown) {
|
||||
onCancel();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKeydown} />
|
||||
|
||||
<!-- Затемнённый фон -->
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-night-950/80 p-4 backdrop-blur-md animate-fade-in"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="auction-modal-title"
|
||||
aria-describedby="auction-modal-value"
|
||||
>
|
||||
<div
|
||||
class="relative w-full max-w-3xl overflow-hidden rounded-3xl border border-white/10 bg-gradient-to-br from-night-700/95 to-night-900/95 shadow-2xl shadow-night-950/60 animate-pop-in"
|
||||
>
|
||||
<!-- верхняя подсветка — тёплая, «аукционная» -->
|
||||
<div
|
||||
class="pointer-events-none absolute -top-24 left-1/2 h-48 w-[120%] -translate-x-1/2 rounded-full bg-gradient-to-r from-ember-400/25 via-gold-400/25 to-ember-400/25 blur-3xl"
|
||||
></div>
|
||||
|
||||
<div class="relative p-6 sm:p-10">
|
||||
<!-- Аукцион + источник + категория -->
|
||||
<div class="mb-5 flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
class="inline-flex items-center rounded-full bg-ember-400/15 px-4 py-1.5 text-sm font-semibold text-ember-300"
|
||||
>
|
||||
🔨 Аукцион
|
||||
</span>
|
||||
<span class="truncate text-xs text-night-50/50">из «{sourceSetTitle}»</span>
|
||||
</div>
|
||||
<span
|
||||
id="auction-modal-value"
|
||||
class="inline-flex items-center rounded-full bg-plum-400/15 px-4 py-1.5 text-sm font-semibold text-plum-300"
|
||||
>
|
||||
{category.title}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Сам вопрос -->
|
||||
<p
|
||||
id="auction-modal-title"
|
||||
class="mb-2 text-center text-2xl font-bold leading-snug text-white sm:text-4xl"
|
||||
style="text-shadow: 0 0 20px rgba(255,255,255,0.08)"
|
||||
>
|
||||
{question.question}
|
||||
</p>
|
||||
|
||||
<!-- Подсказка (необязательная) -->
|
||||
{#if question.optionalHint}
|
||||
<div class="mt-4 flex justify-center">
|
||||
{#if !hintUsed}
|
||||
<button
|
||||
type="button"
|
||||
onclick={onMarkHintUsed}
|
||||
class="inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/5 px-4 py-2 text-sm text-night-50/70 transition-colors hover:bg-white/10"
|
||||
>
|
||||
💡 Показать подсказку
|
||||
</button>
|
||||
{:else}
|
||||
<p class="animate-fade-in max-w-lg rounded-2xl bg-white/5 px-4 py-3 text-center text-sm italic text-night-50/70">
|
||||
💡 {question.optionalHint}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Управление ставкой -->
|
||||
<div class="mt-6 rounded-2xl border border-white/10 bg-white/5 p-4">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="text-sm text-night-50/70">
|
||||
<div class="text-[10px] uppercase tracking-widest text-night-50/50">
|
||||
Карточка табло
|
||||
</div>
|
||||
<div class="font-bold tabular-nums text-gold-300">
|
||||
{originalNominal.toLocaleString('ru-RU')} очков
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
{#if wagerEditable}
|
||||
<label for="auction-wager" class="text-[10px] uppercase tracking-widest text-night-50/50">
|
||||
Ставка
|
||||
</label>
|
||||
<input
|
||||
id="auction-wager"
|
||||
type="number"
|
||||
inputmode="numeric"
|
||||
min="1"
|
||||
max={score}
|
||||
value={wagerInput}
|
||||
oninput={onWagerInput}
|
||||
aria-label="Ставка аукциона"
|
||||
class="w-28 rounded-xl border border-white/15 bg-night-900/60 px-3 py-2 text-center text-lg font-bold tabular-nums text-white focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-gold-400/40"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onclick={vaBank}
|
||||
class="inline-flex items-center gap-1 rounded-xl border border-ember-400/50 bg-ember-400/15 px-4 py-2 text-sm font-bold text-ember-300 transition-all duration-200 hover:scale-105 hover:bg-ember-400/25 focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-ember-400/40 active:scale-95"
|
||||
>
|
||||
🔥 Ва-банк<span class="tabular-nums"> {score.toLocaleString('ru-RU')}</span>
|
||||
</button>
|
||||
{:else}
|
||||
<div class="text-sm text-night-50/70">
|
||||
<div class="text-[10px] uppercase tracking-widest text-night-50/50">
|
||||
Ставка
|
||||
</div>
|
||||
<div class="font-bold tabular-nums text-gold-300">
|
||||
{wager.toLocaleString('ru-RU')} очков
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if !wagerEditable}
|
||||
<p class="mt-2 text-center text-xs text-night-50/50">
|
||||
Счёт 0 — ставка равна номиналу карточки и не редактируется.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Ответ -->
|
||||
{#if answerShown}
|
||||
<div class="mt-6 animate-reveal-answer">
|
||||
<div class="rounded-2xl border border-mint-400/30 bg-mint-400/10 p-5 text-center">
|
||||
<span class="mb-1 block text-xs font-semibold uppercase tracking-widest text-mint-300/80">
|
||||
Ответ
|
||||
</span>
|
||||
<p class="text-xl font-bold text-mint-300 sm:text-2xl">{question.answer}</p>
|
||||
</div>
|
||||
|
||||
<!-- Действия ведущего -->
|
||||
<div class="mt-6 flex flex-wrap items-center justify-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onAward(awardPoints)}
|
||||
class="inline-flex items-center gap-2 rounded-2xl bg-gradient-to-br from-mint-400 to-mint-300 px-6 py-3.5 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"
|
||||
>
|
||||
✓ Засчитать
|
||||
<span class="tabular-nums">+{awardPoints.toLocaleString('ru-RU')}</span>
|
||||
</button>
|
||||
|
||||
<!-- Частично правильный ответ: половина ставки. Доступно только если
|
||||
подсказка НЕ показывалась (иначе основная кнопка уже даёт половину). -->
|
||||
{#if !hintUsed}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onAward(halfValue)}
|
||||
class="inline-flex items-center gap-2 rounded-2xl border border-mint-400/40 bg-mint-400/10 px-5 py-3.5 text-base font-semibold text-mint-300 transition-all duration-200 hover:scale-105 hover:border-mint-400/60 hover:bg-mint-400/20 focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-mint-400/30 active:scale-95"
|
||||
>
|
||||
≈ Засчитать частично
|
||||
<span class="tabular-nums">+{halfValue.toLocaleString('ru-RU')}</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={onReject}
|
||||
class="inline-flex items-center gap-2 rounded-2xl border border-white/15 bg-white/5 px-6 py-3.5 text-base font-semibold text-night-50/70 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"
|
||||
>
|
||||
✕ Не засчитывать
|
||||
{#if wagerEditable}
|
||||
<span class="tabular-nums text-night-50/50">−{wager.toLocaleString('ru-RU')}</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if hintUsed}
|
||||
<p class="mt-3 text-center text-xs text-night-50/50">
|
||||
💡 Подсказка показана — ответ засчитывается за половину ставки.
|
||||
</p>
|
||||
{:else if !wagerEditable}
|
||||
<p class="mt-3 text-center text-xs text-night-50/50">
|
||||
Счёт 0 — неправильный ответ не меняет очки.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="mt-4 text-center">
|
||||
<button
|
||||
type="button"
|
||||
onclick={onCancel}
|
||||
class="text-sm text-night-50/50 underline-offset-4 transition-colors hover:text-night-50/80 hover:underline"
|
||||
>
|
||||
← Вернуться к табло (отменить аукцион)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mt-6 flex justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (answerShown = true)}
|
||||
class="inline-flex items-center gap-2 rounded-2xl bg-gradient-to-br from-gold-400 to-ember-500 px-8 py-4 text-lg font-bold text-night-900 shadow-lg shadow-gold-400/30 transition-all duration-200 hover:scale-105 hover:shadow-gold-400/50 focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-gold-400/50 active:scale-95"
|
||||
>
|
||||
👁️ Показать ответ
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 text-center">
|
||||
<button
|
||||
type="button"
|
||||
onclick={onCancel}
|
||||
class="text-sm text-night-50/50 underline-offset-4 transition-colors hover:text-night-50/80 hover:underline"
|
||||
>
|
||||
← Вернуться к табло (отменить аукцион)
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -55,3 +55,54 @@ export function findQuestion(
|
||||
if (!question) return undefined;
|
||||
return { category, question };
|
||||
}
|
||||
|
||||
/**
|
||||
* Находит вопрос по `(setId, ref)` в любом зарегистрированном наборе.
|
||||
* Используется для отображения случайного вопроса аукциона.
|
||||
*/
|
||||
export function findQuestionAcrossSets(
|
||||
setId: string,
|
||||
ref: QuestionRef
|
||||
): { set: QuestionSet; category: Category; question: Question } | undefined {
|
||||
const set = getQuestionSetById(setId);
|
||||
if (!set) return undefined;
|
||||
const found = findQuestion(set, ref);
|
||||
if (!found) return undefined;
|
||||
return { set, category: found.category, question: found.question };
|
||||
}
|
||||
|
||||
/**
|
||||
* Локатор вопроса в реестре: набор + ключ + сам вопрос (с категорией).
|
||||
*/
|
||||
export interface QuestionLocator {
|
||||
setId: string;
|
||||
ref: QuestionRef;
|
||||
category: Category;
|
||||
question: Question;
|
||||
}
|
||||
|
||||
/**
|
||||
* Случайный вопрос из всех зарегистрированных наборов — источник для аукциона.
|
||||
* Опционально исключает конкретную карточку (оригинальную с табло), чтобы
|
||||
* аукцион не выдал ту же самую карточку.
|
||||
*/
|
||||
export function pickRandomAuctionQuestion(
|
||||
exclude?: { setId: string; ref: QuestionRef }
|
||||
): QuestionLocator | undefined {
|
||||
const all: QuestionLocator[] = [];
|
||||
for (const set of questionSets) {
|
||||
for (const category of set.categories) {
|
||||
for (const question of category.questions) {
|
||||
all.push({ setId: set.id, ref: `${category.id}:${question.id}`, category, question });
|
||||
}
|
||||
}
|
||||
}
|
||||
if (all.length === 0) return undefined;
|
||||
|
||||
let pool = all;
|
||||
if (exclude) {
|
||||
const filtered = all.filter((q) => !(q.setId === exclude.setId && q.ref === exclude.ref));
|
||||
if (filtered.length > 0) pool = filtered;
|
||||
}
|
||||
return pool[Math.floor(Math.random() * pool.length)];
|
||||
}
|
||||
|
||||
+200
-11
@@ -1,7 +1,12 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import { browser } from '$app/environment';
|
||||
import type { GameState, QuestionRef } from '$lib/types';
|
||||
import { findQuestion, getQuestionSetById, getTotalQuestions } from '$lib/question-sets';
|
||||
import type { AuctionState, GameState, QuestionRef } from '$lib/types';
|
||||
import {
|
||||
findQuestion,
|
||||
getQuestionSetById,
|
||||
getTotalQuestions,
|
||||
pickRandomAuctionQuestion
|
||||
} from '$lib/question-sets';
|
||||
|
||||
/**
|
||||
* Игровое состояние полностью отделено от компонентов.
|
||||
@@ -15,6 +20,12 @@ import { findQuestion, getQuestionSetById, getTotalQuestions } from '$lib/questi
|
||||
|
||||
const STORAGE_KEY = 'my-calm-game:state:v1';
|
||||
|
||||
/**
|
||||
* Шанс, что открытие вопроса запустит аукцион вместо обычного вопроса.
|
||||
* Именованная константа — баланс аукционов удобно менять в одном месте.
|
||||
*/
|
||||
export const AUCTION_CHANCE = 0.2;
|
||||
|
||||
/** Начальное (пустое) состояние — экран выбора набора. */
|
||||
function initialState(): GameState {
|
||||
return {
|
||||
@@ -22,7 +33,8 @@ function initialState(): GameState {
|
||||
score: 0,
|
||||
played: [],
|
||||
current: null,
|
||||
hintUsed: []
|
||||
hintUsed: [],
|
||||
auction: null
|
||||
};
|
||||
}
|
||||
|
||||
@@ -87,12 +99,53 @@ function loadState(): GameState {
|
||||
}
|
||||
}
|
||||
|
||||
// Аукцион: восстанавливаем только при корректной структуре и валидных
|
||||
// ссылках. Старые сохранения без `auction` (или с мусором) → null.
|
||||
let auction: AuctionState | null = null;
|
||||
const parsedAuction = parsed.auction;
|
||||
if (parsedAuction !== null && parsedAuction !== undefined) {
|
||||
const a = parsedAuction;
|
||||
if (
|
||||
a &&
|
||||
typeof a === 'object' &&
|
||||
typeof a.originalRef === 'string' &&
|
||||
typeof a.sourceQuestionSetId === 'string' &&
|
||||
typeof a.sourceRef === 'string' &&
|
||||
typeof a.originalNominal === 'number' &&
|
||||
typeof a.wager === 'number' &&
|
||||
typeof a.hintUsed === 'boolean'
|
||||
) {
|
||||
const originalFound = findQuestion(set, a.originalRef);
|
||||
const originalPlayed = seen.has(a.originalRef);
|
||||
const sourceSet = getQuestionSetById(a.sourceQuestionSetId);
|
||||
const sourceFound = sourceSet ? findQuestion(sourceSet, a.sourceRef) : undefined;
|
||||
if (originalFound && !originalPlayed && sourceSet && sourceFound) {
|
||||
// Номинал берём авторитетно из данных, а не из сохранения.
|
||||
const originalNominal = originalFound.question.points;
|
||||
// При ненулевом счёте ставку ограничиваем [1, счёт];
|
||||
// при счёте 0 ставка равна номиналу (не редактируется).
|
||||
const wager =
|
||||
parsed.score > 0 ? clampInt(a.wager, 1, parsed.score) : originalNominal;
|
||||
auction = {
|
||||
originalRef: a.originalRef,
|
||||
sourceQuestionSetId: a.sourceQuestionSetId,
|
||||
sourceRef: a.sourceRef,
|
||||
originalNominal,
|
||||
wager,
|
||||
hintUsed: a.hintUsed
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
questionSetId: setId,
|
||||
score: parsed.score,
|
||||
played,
|
||||
current: safeCurrent,
|
||||
hintUsed
|
||||
// Во время аукциона обычный открытый вопрос отсутствует.
|
||||
current: auction ? null : safeCurrent,
|
||||
hintUsed,
|
||||
auction
|
||||
};
|
||||
} catch {
|
||||
// Повреждённый JSON — просто начинаем заново.
|
||||
@@ -111,6 +164,50 @@ function saveState(state: GameState): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Ограничить целое число диапазоном [min, max]; NaN/Infinity → min. */
|
||||
function clampInt(value: number, min: number, max: number): number {
|
||||
if (!Number.isFinite(value)) return min;
|
||||
return Math.max(min, Math.min(max, Math.trunc(value)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Создаёт состояние запуска аукциона по оригинальной карточке табло.
|
||||
*
|
||||
* Чистая функция: выбирает случайный вопрос из всех зарегистрированных наборов
|
||||
* и считает стартовую ставку. Если набор/вопрос не найдены или нет вопросов для
|
||||
* аукциона — возвращаем состояние обычного открытия вопроса (мягкий фолбэк,
|
||||
* чтобы игра никогда не падала из-за аукциона).
|
||||
*/
|
||||
function startAuctionState(state: GameState, originalRef: QuestionRef): GameState {
|
||||
const setId = state.questionSetId;
|
||||
if (!setId) return { ...state, current: originalRef };
|
||||
const set = getQuestionSetById(setId);
|
||||
if (!set) return { ...state, current: originalRef };
|
||||
const found = findQuestion(set, originalRef);
|
||||
if (!found) return { ...state, current: originalRef };
|
||||
|
||||
const originalNominal = found.question.points;
|
||||
const pick = pickRandomAuctionQuestion({ setId, ref: originalRef });
|
||||
if (!pick) return { ...state, current: originalRef };
|
||||
|
||||
// При ненулевом счёте ставка по умолчанию = min(номинал, счёт).
|
||||
// При счёте 0 — равна номиналу карточки и не редактируется.
|
||||
const wager = state.score > 0 ? Math.min(originalNominal, state.score) : originalNominal;
|
||||
|
||||
return {
|
||||
...state,
|
||||
current: null,
|
||||
auction: {
|
||||
originalRef,
|
||||
sourceQuestionSetId: pick.setId,
|
||||
sourceRef: pick.ref,
|
||||
originalNominal,
|
||||
wager,
|
||||
hintUsed: false
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function createGameStore() {
|
||||
const { subscribe, update, set } = writable<GameState>(loadState());
|
||||
|
||||
@@ -124,12 +221,33 @@ function createGameStore() {
|
||||
|
||||
/** Выбрать набор и перейти к табло. */
|
||||
selectQuestionSet(id: string) {
|
||||
update((s) => ({ ...s, questionSetId: id, score: 0, played: [], current: null, hintUsed: [] }));
|
||||
update((s) => ({
|
||||
...s,
|
||||
questionSetId: id,
|
||||
score: 0,
|
||||
played: [],
|
||||
current: null,
|
||||
hintUsed: [],
|
||||
auction: null
|
||||
}));
|
||||
},
|
||||
|
||||
/** Открыть вопрос по ключу `categoryId:questionId`. */
|
||||
/**
|
||||
* Открыть вопрос по ключу `categoryId:questionId`.
|
||||
*
|
||||
* С шансом {@link AUCTION_CHANCE} вместо обычного вопроса запускается
|
||||
* аукцион: случайный вопрос из любого зарегистрированного набора +
|
||||
* ставка ведущего. Иначе — обычное поведение.
|
||||
*/
|
||||
openQuestion(ref: QuestionRef) {
|
||||
update((s) => ({ ...s, current: ref }));
|
||||
update((s) => {
|
||||
// Аукцион уже идёт — не открываем новый вопрос поверх.
|
||||
if (s.auction) return s;
|
||||
if (Math.random() < AUCTION_CHANCE) {
|
||||
return startAuctionState(s, ref);
|
||||
}
|
||||
return { ...s, current: ref };
|
||||
});
|
||||
},
|
||||
|
||||
/** Закрыть текущий вопрос без решения (вернуться к табло). */
|
||||
@@ -173,13 +291,83 @@ function createGameStore() {
|
||||
}));
|
||||
},
|
||||
|
||||
/* ------------------------------ Аукцион ------------------------------ */
|
||||
|
||||
/**
|
||||
* Изменить ставку аукциона (ведущий ввёл число или нажал «Ва-банк»).
|
||||
* Менять ставку можно только при ненулевом счёте; значение ограничено
|
||||
* диапазоном [1, текущий счёт].
|
||||
*/
|
||||
setAuctionWager(wager: number) {
|
||||
update((s) => {
|
||||
if (!s.auction || s.score <= 0) return s;
|
||||
return { ...s, auction: { ...s.auction, wager: clampInt(wager, 1, s.score) } };
|
||||
});
|
||||
},
|
||||
|
||||
/** Отметить, что ведущий раскрыл подсказку аукциона. */
|
||||
markAuctionHintUsed() {
|
||||
update((s) => {
|
||||
if (!s.auction || s.auction.hintUsed) return s;
|
||||
return { ...s, auction: { ...s.auction, hintUsed: true } };
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Засчитать ответ в аукционе: добавить очки и пометить оригинальную
|
||||
* карточку табло сыгранной. Случайный вопрос-источник НЕ помечается.
|
||||
*/
|
||||
resolveAuctionAward(points: number) {
|
||||
update((s) => {
|
||||
if (!s.auction) return s;
|
||||
const ref = s.auction.originalRef;
|
||||
return {
|
||||
...s,
|
||||
score: s.score + points,
|
||||
played: s.played.includes(ref) ? s.played : [...s.played, ref],
|
||||
current: null,
|
||||
auction: null
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Не засчитать ответ в аукционе: вычесть ставку (не ниже нуля) и пометить
|
||||
* оригинальную карточку сыгранной. При счёте 0 вычитать нечего — очки
|
||||
* не меняются (в минус не уходим).
|
||||
*/
|
||||
resolveAuctionReject() {
|
||||
update((s) => {
|
||||
if (!s.auction) return s;
|
||||
const ref = s.auction.originalRef;
|
||||
const wager = s.auction.wager;
|
||||
return {
|
||||
...s,
|
||||
score: Math.max(0, s.score - wager),
|
||||
played: s.played.includes(ref) ? s.played : [...s.played, ref],
|
||||
current: null,
|
||||
auction: null
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Отменить аукцион и вернуться к табло, не разрешая его:
|
||||
* очки не меняются, оригинальная карточка НЕ помечается сыгранной.
|
||||
* Мягкий выход для ведущего (как «Вернуться к табло» у обычного вопроса).
|
||||
*/
|
||||
cancelAuction() {
|
||||
update((s) => (s.auction ? { ...s, auction: null } : s));
|
||||
},
|
||||
|
||||
/**
|
||||
* Новая игра: сбросить прогресс по ТЕКУЩЕМУ набору (счёт, сыгранные,
|
||||
* текущий вопрос, использованные подсказки). Набор остаётся выбранным.
|
||||
* Сохранение очищается соответствующим образом (через автосохранение).
|
||||
* текущий вопрос, использованные подсказки, активный аукцион). Набор
|
||||
* остаётся выбранным. Сохранение очищается соответствующим образом
|
||||
* (через автосохранение).
|
||||
*/
|
||||
newGame() {
|
||||
update((s) => ({ ...s, score: 0, played: [], current: null, hintUsed: [] }));
|
||||
update((s) => ({ ...s, score: 0, played: [], current: null, hintUsed: [], auction: null }));
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -239,6 +427,7 @@ export const isFinished = derived(
|
||||
export const screen = derived([game, currentSet, isFinished], ([$g, $set, $finished]) => {
|
||||
if (!$set) return 'select' as const;
|
||||
if ($finished) return 'result' as const;
|
||||
if ($g.auction) return 'question' as const;
|
||||
if ($g.current) return 'question' as const;
|
||||
return 'board' as const;
|
||||
});
|
||||
|
||||
@@ -86,6 +86,39 @@ export interface QuestionSet {
|
||||
*/
|
||||
export type QuestionRef = string;
|
||||
|
||||
/**
|
||||
* Активный аукцион — спецвопрос в стиле «Своей игры»: вместо вопроса с табло
|
||||
* открывается случайный вопрос из любого зарегистрированного набора, а ведущий
|
||||
* назначает ставку. Когда аукцион разрешается, сыгранной помечается именно
|
||||
* оригинальная карточка табло (не источник случайного вопроса).
|
||||
*
|
||||
* Сохраняется между перезагрузками и сбрасывается на новой игре/смене набора.
|
||||
*/
|
||||
export interface AuctionState {
|
||||
/**
|
||||
* Оригинальная карточка табло, по которой запустили аукцион
|
||||
* (`categoryId:questionId` в пределах текущего набора). Помечается сыгранной
|
||||
* при разрешении аукциона.
|
||||
*/
|
||||
originalRef: QuestionRef;
|
||||
/** id набора, откуда взят случайный вопрос аукциона. */
|
||||
sourceQuestionSetId: string;
|
||||
/**
|
||||
* Случайный вопрос аукциона (`categoryId:questionId` в пределах
|
||||
* `sourceQuestionSetId`). НЕ помечается сыгранным.
|
||||
*/
|
||||
sourceRef: QuestionRef;
|
||||
/** Номинал оригинальной карточки табло. */
|
||||
originalNominal: PointValue;
|
||||
/**
|
||||
* Текущая ставка ведущего — эффективная стоимость аукциона.
|
||||
* При счёте 0 равна `originalNominal` и не редактируется.
|
||||
*/
|
||||
wager: number;
|
||||
/** Показывал ли ведущий подсказку аукциона. */
|
||||
hintUsed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Полное состояние одной партии.
|
||||
* Сериализуется в localStorage и восстанавливается после перезагрузки.
|
||||
@@ -109,4 +142,10 @@ export interface GameState {
|
||||
* Сохраняется между перезагрузками и сбрасывается на новой игре/смене набора.
|
||||
*/
|
||||
hintUsed: QuestionRef[];
|
||||
/**
|
||||
* Активный аукцион или null. Пока аукцион идёт, обычный «текущий открытый
|
||||
* вопрос» (`current`) равен null: на экране показан вопрос аукциона.
|
||||
* Сохраняется между перезагрузками и сбрасывается на новой игре/смене набора.
|
||||
*/
|
||||
auction: AuctionState | null;
|
||||
}
|
||||
|
||||
+83
-11
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { game, currentSet, totalQuestions, playedCount, isFinished, screen } from '$lib/stores/game';
|
||||
import { questionSets, findQuestion } from '$lib/question-sets';
|
||||
import { questionSets, findQuestion, findQuestionAcrossSets } from '$lib/question-sets';
|
||||
import { celebrate } from '$lib/confetti';
|
||||
import type { QuestionRef } from '$lib/types';
|
||||
import QuestionSetSelector from '$lib/components/QuestionSetSelector.svelte';
|
||||
@@ -8,6 +8,7 @@
|
||||
import ScorePanel from '$lib/components/ScorePanel.svelte';
|
||||
import HostControls from '$lib/components/HostControls.svelte';
|
||||
import QuestionModal from '$lib/components/QuestionModal.svelte';
|
||||
import AuctionModal from '$lib/components/AuctionModal.svelte';
|
||||
import GameResult from '$lib/components/GameResult.svelte';
|
||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||
|
||||
@@ -41,6 +42,27 @@
|
||||
return $game.hintUsed.includes(ref);
|
||||
});
|
||||
|
||||
/**
|
||||
* Данные активного аукциона: случайный вопрос-источник (из любого набора)
|
||||
* + стейт ставки. Пока аукцион идёт, показываем AuctionModal вместо обычного
|
||||
* QuestionModal.
|
||||
*/
|
||||
const currentAuctionData = $derived.by(() => {
|
||||
const a = $game.auction;
|
||||
if (!a) return null;
|
||||
const found = findQuestionAcrossSets(a.sourceQuestionSetId, a.sourceRef);
|
||||
if (!found) return null;
|
||||
return {
|
||||
originalRef: a.originalRef,
|
||||
originalNominal: a.originalNominal,
|
||||
wager: a.wager,
|
||||
hintUsed: a.hintUsed,
|
||||
sourceSetTitle: found.set.title,
|
||||
category: found.category,
|
||||
question: found.question
|
||||
};
|
||||
});
|
||||
|
||||
// ---- Действия ведущего ----
|
||||
|
||||
function selectSet(id: string) {
|
||||
@@ -94,6 +116,38 @@
|
||||
game.markHintUsed(ref);
|
||||
}
|
||||
|
||||
// ---- Действия ведущего: аукцион ----
|
||||
|
||||
/** Ведущий изменил ставку аукциона (ввод или «Ва-банк»). */
|
||||
function setAuctionWager(wager: number) {
|
||||
game.setAuctionWager(wager);
|
||||
}
|
||||
|
||||
/** Ведущий раскрыл подсказку аукциона. */
|
||||
function markAuctionHintUsed() {
|
||||
game.markAuctionHintUsed();
|
||||
}
|
||||
|
||||
/** Засчитать ответ в аукционе: добавить очки, карточку табло пометить сыгранной. */
|
||||
function resolveAuctionAward(points: number) {
|
||||
if (!$game.auction) return;
|
||||
game.resolveAuctionAward(points);
|
||||
bumpSignal++;
|
||||
// Праздничное конфетти — засчитанный ответ в аукционе!
|
||||
celebrate();
|
||||
}
|
||||
|
||||
/** Не засчитать ответ в аукционе: вычесть ставку (не ниже 0), карточку пометить сыгранной. */
|
||||
function resolveAuctionReject() {
|
||||
if (!$game.auction) return;
|
||||
game.resolveAuctionReject();
|
||||
}
|
||||
|
||||
/** Отменить аукцион: вернуться к табло, не разрешая его (карточка не помечается). */
|
||||
function cancelAuction() {
|
||||
game.cancelAuction();
|
||||
}
|
||||
|
||||
function randomQuestion() {
|
||||
const set = $currentSet;
|
||||
if (!set) return;
|
||||
@@ -235,16 +289,34 @@
|
||||
{/if}
|
||||
|
||||
<!-- ===================== Модальный экран вопроса ===================== -->
|
||||
{#if effectiveScreen === 'question' && currentQuestionData}
|
||||
<QuestionModal
|
||||
category={currentQuestionData.category}
|
||||
question={currentQuestionData.question}
|
||||
hintUsed={hintUsedForCurrent}
|
||||
onMarkHintUsed={markCurrentHintUsed}
|
||||
onAward={awardQuestion}
|
||||
onReject={rejectQuestion}
|
||||
onClose={closeQuestion}
|
||||
/>
|
||||
{#if effectiveScreen === 'question'}
|
||||
{#if $game.auction && currentAuctionData}
|
||||
<!-- Аукцион: случайный вопрос из любого набора + ставка ведущего. -->
|
||||
<AuctionModal
|
||||
originalNominal={currentAuctionData.originalNominal}
|
||||
sourceSetTitle={currentAuctionData.sourceSetTitle}
|
||||
category={currentAuctionData.category}
|
||||
question={currentAuctionData.question}
|
||||
score={$game.score}
|
||||
wager={currentAuctionData.wager}
|
||||
hintUsed={currentAuctionData.hintUsed}
|
||||
onSetWager={setAuctionWager}
|
||||
onMarkHintUsed={markAuctionHintUsed}
|
||||
onAward={resolveAuctionAward}
|
||||
onReject={resolveAuctionReject}
|
||||
onCancel={cancelAuction}
|
||||
/>
|
||||
{:else if currentQuestionData}
|
||||
<QuestionModal
|
||||
category={currentQuestionData.category}
|
||||
question={currentQuestionData.question}
|
||||
hintUsed={hintUsedForCurrent}
|
||||
onMarkHintUsed={markCurrentHintUsed}
|
||||
onAward={awardQuestion}
|
||||
onReject={rejectQuestion}
|
||||
onClose={closeQuestion}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- ===================== Диалог подтверждения ===================== -->
|
||||
|
||||
Reference in New Issue
Block a user