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