Add hint and partial scoring
This commit is contained in:
@@ -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
@@ -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: [] }));
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -102,4 +102,11 @@ export interface GameState {
|
||||
* если табло открыто и вопрос не выбран.
|
||||
*/
|
||||
current: QuestionRef | null;
|
||||
/**
|
||||
* Вопросы, где ведущий раскрыл необязательную подсказку
|
||||
* (`categoryId:questionId`). Влияет на механику частичных очков:
|
||||
* засчитать такой ответ можно только за половину стоимости.
|
||||
* Сохраняется между перезагрузками и сбрасывается на новой игре/смене набора.
|
||||
*/
|
||||
hintUsed: QuestionRef[];
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user