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