Add hint and partial scoring

This commit is contained in:
2026-06-27 10:58:34 +03:00
parent bacc371055
commit b14e0c8176
4 changed files with 103 additions and 15 deletions
+44 -8
View File
@@ -4,26 +4,42 @@
interface Props { interface Props {
category: Category; category: Category;
question: Question; question: Question;
/** Раскрывал ли ведущий подсказку для этого вопроса (из состояния). */
hintUsed: boolean;
/** Сообщить, что ведущий раскрыл подсказку (сохраняется в состоянии). */
onMarkHintUsed: () => void;
onAward: (points: number) => void; onAward: (points: number) => void;
onReject: () => void; onReject: () => void;
onClose: () => void; onClose: () => void;
} }
let { category, question, onAward, onReject, onClose }: Props = $props(); let {
category,
question,
hintUsed,
onMarkHintUsed,
onAward,
onReject,
onClose
}: Props = $props();
let answerShown = $state(false); let answerShown = $state(false);
let hintShown = $state(false);
// При смене вопроса — сбрасываем локальное состояние показа. // При смене вопроса — сбрасываем локальное состояние показа ответа.
// Подсказка живёт в состоянии игры (`hintUsed`), поэтому отдельно не сбрасывается.
$effect(() => { $effect(() => {
question.id; question.id;
answerShown = false; answerShown = false;
hintShown = false;
}); });
// Творческие вопросы — мягкие формулировки. // Творческие вопросы — мягкие формулировки.
const isCreative = $derived(question.type === 'creative'); const isCreative = $derived(question.type === 'creative');
// Половина стоимости — для частично правильного ответа или ответа с подсказкой.
const halfPoints = $derived(Math.round(question.points / 2));
// Сколько очков даёт основное «Засчитать»: с подсказкой — половину, иначе полное.
const awardPoints = $derived(hintUsed ? halfPoints : question.points);
// Управление с клавиатуры: Esc — вернуться (если ответ скрыт — закрыть модал, // Управление с клавиатуры: Esc — вернуться (если ответ скрыт — закрыть модал,
// иначе просто убрать фокус с кнопки). Удобно ведущему. // иначе просто убрать фокус с кнопки). Удобно ведущему.
function onKeydown(e: KeyboardEvent) { function onKeydown(e: KeyboardEvent) {
@@ -83,10 +99,10 @@
<!-- Подсказка (необязательная) --> <!-- Подсказка (необязательная) -->
{#if question.optionalHint} {#if question.optionalHint}
<div class="mt-4 flex justify-center"> <div class="mt-4 flex justify-center">
{#if !hintShown} {#if !hintUsed}
<button <button
type="button" type="button"
onclick={() => (hintShown = true)} 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" 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"
> >
💡 Показать подсказку 💡 Показать подсказку
@@ -115,13 +131,27 @@
<div class="mt-6 flex flex-wrap items-center justify-center gap-3"> <div class="mt-6 flex flex-wrap items-center justify-center gap-3">
<button <button
type="button" type="button"
onclick={() => onAward(question.points)} 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" 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"
> >
{isCreative ? 'Засчитать творческий ответ' : 'Засчитать'} {isCreative ? 'Засчитать творческий ответ' : 'Засчитать'}
<span class="tabular-nums">+{question.points.toLocaleString('ru-RU')}</span> <span class="tabular-nums">+{awardPoints.toLocaleString('ru-RU')}</span>
</button> </button>
<!-- Частично правильный ответ: половина очков по усмотрению ведущего.
Доступно только если подсказка НЕ показывалась (иначе основная
кнопка уже даёт половину). -->
{#if !hintUsed}
<button
type="button"
onclick={() => onAward(halfPoints)}
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">+{halfPoints.toLocaleString('ru-RU')}</span>
</button>
{/if}
<button <button
type="button" type="button"
onclick={onReject} onclick={onReject}
@@ -131,6 +161,12 @@
</button> </button>
</div> </div>
{#if hintUsed}
<p class="mt-3 text-center text-xs text-night-50/50">
💡 Подсказка показана — ответ засчитывается за половину стоимости.
</p>
{/if}
<div class="mt-4 text-center"> <div class="mt-4 text-center">
<button <button
type="button" type="button"
+36 -7
View File
@@ -21,7 +21,8 @@ function initialState(): GameState {
questionSetId: null, questionSetId: null,
score: 0, score: 0,
played: [], played: [],
current: null current: null,
hintUsed: []
}; };
} }
@@ -34,12 +35,15 @@ function loadState(): GameState {
const parsed = JSON.parse(raw) as Partial<GameState>; const parsed = JSON.parse(raw) as Partial<GameState>;
// Защита от повреждённых/чужих данных: проверяем базовую структуру. // Защита от повреждённых/чужих данных: проверяем базовую структуру.
// `hintUsed` может отсутствовать в старых сохранениях (до этой механики) —
// тогда считаем его пустым, а не сбрасываем всю партию.
if ( if (
!parsed || !parsed ||
(parsed.questionSetId !== null && typeof parsed.questionSetId !== 'string') || (parsed.questionSetId !== null && typeof parsed.questionSetId !== 'string') ||
typeof parsed.score !== 'number' || typeof parsed.score !== 'number' ||
!Array.isArray(parsed.played) || !Array.isArray(parsed.played) ||
(parsed.current !== null && typeof parsed.current !== 'string') (parsed.current !== null && typeof parsed.current !== 'string') ||
(parsed.hintUsed !== undefined && !Array.isArray(parsed.hintUsed))
) { ) {
return initialState(); return initialState();
} }
@@ -70,11 +74,25 @@ function loadState(): GameState {
const safeCurrent = const safeCurrent =
current !== null && findQuestion(set, current) && !seen.has(current) ? current : null; current !== null && findQuestion(set, current) && !seen.has(current) ? current : null;
// Подсказки оставляем только для реально существующих вопросов, без дублей.
// Старые сохранения без `hintUsed` дают пустой массив.
const hintUsed: QuestionRef[] = [];
if (Array.isArray(parsed.hintUsed)) {
const seenHint = new Set<QuestionRef>();
for (const ref of parsed.hintUsed) {
if (typeof ref !== 'string' || seenHint.has(ref)) continue;
if (!findQuestion(set, ref)) continue;
seenHint.add(ref);
hintUsed.push(ref);
}
}
return { return {
questionSetId: setId, questionSetId: setId,
score: parsed.score, score: parsed.score,
played, played,
current: safeCurrent current: safeCurrent,
hintUsed
}; };
} catch { } catch {
// Повреждённый JSON — просто начинаем заново. // Повреждённый JSON — просто начинаем заново.
@@ -106,7 +124,7 @@ function createGameStore() {
/** Выбрать набор и перейти к табло. */ /** Выбрать набор и перейти к табло. */
selectQuestionSet(id: string) { selectQuestionSet(id: string) {
update((s) => ({ ...s, questionSetId: id, score: 0, played: [], current: null })); update((s) => ({ ...s, questionSetId: id, score: 0, played: [], current: null, hintUsed: [] }));
}, },
/** Открыть вопрос по ключу `categoryId:questionId`. */ /** Открыть вопрос по ключу `categoryId:questionId`. */
@@ -144,13 +162,24 @@ function createGameStore() {
})); }));
}, },
/**
* Отметить, что ведущий раскрыл подсказку для вопроса.
* После этого засчитать ответ можно только за половину стоимости.
*/
markHintUsed(ref: QuestionRef) {
update((s) => ({
...s,
hintUsed: s.hintUsed.includes(ref) ? s.hintUsed : [...s.hintUsed, ref]
}));
},
/** /**
* Новая игра: сбросить прогресс по ТЕКУЩЕМУ набору (счёт, сыгранные, * Новая игра: сбросить прогресс по ТЕКУЩЕМУ набору (счёт, сыгранные,
* текущий вопрос). Набор остаётся выбранным. Сохранение очищается * текущий вопрос, использованные подсказки). Набор остаётся выбранным.
* соответствующим образом (через автосохранение). * Сохранение очищается соответствующим образом (через автосохранение).
*/ */
newGame() { newGame() {
update((s) => ({ ...s, score: 0, played: [], current: null })); update((s) => ({ ...s, score: 0, played: [], current: null, hintUsed: [] }));
}, },
/** /**
+7
View File
@@ -102,4 +102,11 @@ export interface GameState {
* если табло открыто и вопрос не выбран. * если табло открыто и вопрос не выбран.
*/ */
current: QuestionRef | null; current: QuestionRef | null;
/**
* Вопросы, где ведущий раскрыл необязательную подсказку
* (`categoryId:questionId`). Влияет на механику частичных очков:
* засчитать такой ответ можно только за половину стоимости.
* Сохраняется между перезагрузками и сбрасывается на новой игре/смене набора.
*/
hintUsed: QuestionRef[];
} }
+16
View File
@@ -25,6 +25,13 @@
return findQuestion(set, ref) ?? 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) { function selectSet(id: string) {
@@ -54,6 +61,13 @@
game.rejectQuestion(ref); game.rejectQuestion(ref);
} }
/** Ведущий раскрыл подсказку по текущему вопросу — сохраняем в состоянии. */
function markCurrentHintUsed() {
const ref = $game.current;
if (!ref) return;
game.markHintUsed(ref);
}
function randomQuestion() { function randomQuestion() {
const set = $currentSet; const set = $currentSet;
if (!set) return; if (!set) return;
@@ -176,6 +190,8 @@
<QuestionModal <QuestionModal
category={currentQuestionData.category} category={currentQuestionData.category}
question={currentQuestionData.question} question={currentQuestionData.question}
hintUsed={hintUsedForCurrent}
onMarkHintUsed={markCurrentHintUsed}
onAward={awardQuestion} onAward={awardQuestion}
onReject={rejectQuestion} onReject={rejectQuestion}
onClose={closeQuestion} onClose={closeQuestion}