Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,089 changes: 902 additions & 187 deletions quiz-app2/src/App.vue

Large diffs are not rendered by default.

64 changes: 51 additions & 13 deletions quiz-app2/src/components/games/ChoiceSelectGame.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref, watch, watchEffect } from "vue";
import type { SelectStage, StageSubmission } from "../../types";
import { ref, watch, watchEffect, inject, type Ref } from "vue";
import type { SelectStage, StageSubmission, Locale } from "../../types";
import PressButton from "../ui/PressButton.vue";
import StageOutputPanel from "../ui/StageOutputPanel.vue";

Expand All @@ -22,6 +22,8 @@ const emit = defineEmits<{
(event: "submit", value: StageSubmission): void;
}>();

const locale = inject("locale", ref("ja")) as Ref<Locale>;

const optionItems = ref<OptionItem[]>([]);
const selectedIds = ref<string[]>([]);

Expand Down Expand Up @@ -51,7 +53,10 @@ const clearSelection = () => {

const isSelected = (item: OptionItem) => selectedIds.value.includes(item.id);

const selectedLabels = () => selectedIds.value.map((id) => optionItems.value.find((item) => item.id === id)?.label).filter((label): label is string => Boolean(label));
const selectedLabels = () =>
selectedIds.value
.map((id) => optionItems.value.find((item) => item.id === id)?.label)
.filter((label): label is string => Boolean(label));

const toggleOption = (item: OptionItem) => {
if (props.locked) {
Expand Down Expand Up @@ -86,12 +91,17 @@ watchEffect(() => {
watch(
() => props.submitSignal,
() => {
if (props.locked || selectedCount() !== props.stage.correctAnswers.length) {
if (
props.locked ||
selectedCount() !== props.stage.correctAnswers.length
) {
return;
}

const chosen = selectedLabels();
const correct = props.stage.correctAnswers.every((answer) => chosen.includes(answer));
const correct = props.stage.correctAnswers.every((answer) =>
chosen.includes(answer),
);

emit("submit", {
correct,
Expand All @@ -114,27 +124,55 @@ watch(

<template>
<section class="surface-card space-y-4 px-3 py-4">
<StageOutputPanel v-if="stage.outputLines.length > 0" :lines="stage.outputLines" />
<StageOutputPanel
v-if="stage.outputLines.length > 0"
:lines="stage.outputLines"
/>

<div class="flex items-center justify-between text-quiz-muted text-xs">
<span> コード表示エリア</span>
<span>{{ selectedIds.length }}/{{ stage.correctAnswers.length }}</span>
<span>{{
locale === "en" ? "Code Display Area" : "コード表示エリア"
}}</span>
<span
>{{ selectedIds.length }}/{{
stage.correctAnswers.length
}}</span
>
</div>

<div class="code-surface space-y-2">
<div v-for="(line, lineIndex) in stage.snippetLines" :key="`${stage.id}:${lineIndex}`" class="whitespace-pre-wrap">
<div
v-for="(line, lineIndex) in stage.snippetLines"
:key="`${stage.id}:${lineIndex}`"
class="whitespace-pre-wrap"
>
{{ line }}
</div>
</div>

<div class="space-y-3">
<div class="flex items-center justify-between text-quiz-muted text-xs">
<span>選択肢</span>
<span>{{ stage.correctAnswers.length }} 個選ぶ</span>
<div
class="flex items-center justify-between text-quiz-muted text-xs"
>
<span>{{ locale === "en" ? "Options" : "選択肢" }}</span>
<span>{{
locale === "en"
? `Select ${stage.correctAnswers.length}`
: `${stage.correctAnswers.length} 個選ぶ`
}}</span>
</div>

<div class="flex flex-wrap gap-3">
<PressButton v-for="item in optionItems" :key="item.id" tone="secondary" class="px-4 py-3 font-mono text-[13px]" :class="{ 'ring-2 ring-sky-400': isSelected(item) }" :disabled="locked" :aria-pressed="isSelected(item) ? 'true' : 'false'" @click="toggleOption(item)">
<PressButton
v-for="item in optionItems"
:key="item.id"
tone="secondary"
class="px-4 py-3 font-mono text-[13px]"
:class="{ 'ring-2 ring-sky-400': isSelected(item) }"
:disabled="locked"
:aria-pressed="isSelected(item) ? 'true' : 'false'"
@click="toggleOption(item)"
>
{{ item.label }}
</PressButton>
</div>
Expand Down
186 changes: 111 additions & 75 deletions quiz-app2/src/components/games/FillBlankTapGame.vue
Original file line number Diff line number Diff line change
@@ -1,14 +1,9 @@
<script setup lang="ts">
import { ref, watch, watchEffect } from "vue";
import type { FillStage, StageSubmission } from "../../types";
import { ref, computed, watch, inject, type Ref } from "vue";
import type { FillStage, Locale } from "../../types";
import PressButton from "../ui/PressButton.vue";
import StageOutputPanel from "../ui/StageOutputPanel.vue";

interface PoolItem {
id: string;
label: string;
}

const props = defineProps<{
stage: FillStage;
locked: boolean;
Expand All @@ -17,123 +12,149 @@ const props = defineProps<{
}>();

const emit = defineEmits<{
(event: "ready-change", value: boolean): void;
(event: "dirty-change", value: boolean): void;
(event: "submit", value: StageSubmission): void;
(e: "dirty-change", dirty: boolean): void;
(e: "ready-change", ready: boolean): void;
(
e: "submit",
payload: { correct: boolean; selectionSummary: string },
): void;
}>();

const slots = ref<(PoolItem | null)[]>(Array.from({ length: props.stage.correctAnswers.length }, () => null));
const poolItems = ref<PoolItem[]>([]);
const locale = inject("locale", ref("ja")) as Ref<Locale>;

const splitLine = (line: string) => line.split(/(\[\d+\])/).filter(Boolean);
const isSlot = (part: string) => /^\[\d+\]$/.test(part);
const slotIndex = (part: string) => Number(part.slice(1, -1)) - 1;
interface PoolItem {
id: string;
label: string;
}

const shuffle = <T,>(values: T[]): T[] => {
const next = [...values];
const slots = ref<(PoolItem | null)[]>([]);
const poolItems = ref<PoolItem[]>([]);

for (let index = next.length - 1; index > 0; index -= 1) {
const swapIndex = Math.floor(Math.random() * (index + 1));
[next[index], next[swapIndex]] = [next[swapIndex], next[index]];
const shuffle = <T,>(array: T[]): T[] => {
const arr = [...array];
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}

return next;
return arr;
};

const splitLine = (line: string) => line.split(/(\[\d+\])/).filter(Boolean);
const isSlot = (part: string) => /^\[\d+\]$/.test(part);
const slotIndex = (part: string) =>
parseInt(part.replace(/[\[\]]/g, ""), 10) - 1;

const resetState = () => {
slots.value = Array.from({ length: props.stage.correctAnswers.length }, () => null);
slots.value = Array.from(
{ length: props.stage.correctAnswers.length },
() => null,
);
poolItems.value = shuffle(props.stage.pool).map((label, index) => ({
id: `${props.stage.id}:${index}:${label}`,
label,
}));
};

const slotIsFilled = (item: PoolItem) => slots.value.some((slot) => slot?.id === item.id);

const clearSelection = () => {
slots.value = Array.from({ length: props.stage.correctAnswers.length }, () => null);
slots.value = Array.from(
{ length: props.stage.correctAnswers.length },
() => null,
);
};

const addToken = (item: PoolItem) => {
if (props.locked || slotIsFilled(item)) {
return;
}
const slotIsFilled = (item: PoolItem) =>
slots.value.some((slot) => slot?.id === item.id);

const firstEmpty = slots.value.findIndex((value) => value === null);
if (firstEmpty === -1) {
return;
const addToken = (item: PoolItem) => {
if (props.locked) return;
const index = slots.value.findIndex((s) => s === null);
if (index !== -1) {
slots.value[index] = item;
}

slots.value[firstEmpty] = item;
};

const removeToken = (index: number) => {
if (props.locked) {
return;
}

if (props.locked) return;
slots.value[index] = null;
};

watch(
() => props.stage.id,
() => props.stage,
() => {
resetState();
},
{ immediate: true },
);

watchEffect(() => {
emit(
"ready-change",
slots.value.every((slot) => slot !== null),
);
emit(
"dirty-change",
slots.value.some((slot) => slot !== null),
);
});
const isDirty = computed(() => slots.value.some((s) => s !== null));
const isReady = computed(() => slots.value.every((s) => s !== null));

watch(
() => props.submitSignal,
() => {
if (props.locked || slots.value.some((slot) => slot === null)) {
return;
}

const answer = slots.value.map((slot) => slot!.label);
emit("submit", {
correct: answer.every((token, index) => token === props.stage.correctAnswers[index]),
selectionSummary: answer.join(" "),
});
[isDirty, isReady],
([dirty, ready]) => {
emit("dirty-change", dirty);
emit("ready-change", ready);
},
{ immediate: true },
);

watch(
() => props.resetSignal,
() => {
if (props.locked) {
return;
}

clearSelection();
},
);

watch(
() => props.submitSignal,
() => {
const answer = slots.value.map((slot) => slot?.label ?? "");
emit("submit", {
correct: answer.every(
(token, index) => token === props.stage.correctAnswers[index],
),
selectionSummary: answer.join(" "),
});
},
);
</script>

<template>
<section class="surface-card space-y-4 px-3 py-4">
<StageOutputPanel v-if="stage.outputLines.length > 0" :lines="stage.outputLines" />
<StageOutputPanel
v-if="stage.outputLines.length > 0"
:lines="stage.outputLines"
/>

<div class="flex items-center justify-between text-quiz-muted text-xs">
<span>コード表示エリア</span>
<span>{{ slots.filter(Boolean).length }}/{{ stage.correctAnswers.length }}</span>
<span>{{
locale === "en" ? "Code Display Area" : "コード表示エリア"
}}</span>
<span
>{{ slots.filter(Boolean).length }}/{{
stage.correctAnswers.length
}}</span
>
</div>

<div class="code-surface">
<div v-for="(line, lineIndex) in stage.templateLines" :key="lineIndex" class="flex flex-wrap items-center gap-2 whitespace-pre-wrap">
<template v-for="(part, partIndex) in splitLine(line)" :key="`${lineIndex}:${partIndex}`">
<button v-if="isSlot(part)" type="button" class="code-slot" :class="{ 'is-filled': slots[slotIndex(part)] }" @click="removeToken(slotIndex(part))">
<div
v-for="(line, lineIndex) in stage.templateLines"
:key="lineIndex"
class="flex flex-wrap items-center gap-2 whitespace-pre-wrap"
>
<template
v-for="(part, partIndex) in splitLine(line)"
:key="`${lineIndex}:${partIndex}`"
>
<button
v-if="isSlot(part)"
type="button"
class="code-slot"
:class="{ 'is-filled': slots[slotIndex(part)] }"
:disabled="locked || !slots[slotIndex(part)]"
@click="removeToken(slotIndex(part))"
>
{{ slots[slotIndex(part)]?.label ?? part }}
</button>
<span v-else class="whitespace-pre-wrap">{{ part }}</span>
Expand All @@ -142,13 +163,28 @@ watch(
</div>

<div class="space-y-3">
<div class="flex items-center justify-between text-quiz-muted text-xs">
<span>選択肢プール</span>
<span>タップで左から挿入</span>
<div
class="flex items-center justify-between text-quiz-muted text-xs"
>
<span>{{
locale === "en" ? "Word Pool" : "選択肢プール"
}}</span>
<span>{{
locale === "en"
? "Tap to insert from left"
: "タップで左から挿入"
}}</span>
</div>

<div class="flex flex-wrap gap-3">
<PressButton v-for="item in poolItems" :key="item.id" tone="secondary" :disabled="slotIsFilled(item) || locked" class="px-4 py-3 font-mono text-[13px]" @click="addToken(item)">
<PressButton
v-for="item in poolItems"
:key="item.id"
tone="secondary"
:disabled="slotIsFilled(item) || locked"
class="px-4 py-3 font-mono text-[13px]"
@click="addToken(item)"
>
{{ item.label }}
</PressButton>
</div>
Expand Down
Loading