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
38 changes: 28 additions & 10 deletions quiz-app2/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,6 @@ const compactTierTitle = (titleText: I18nText | string) => {
let timerId: number | null = null;
let startedAt = 0;

// --- i18n Dictionary ---
const i18n = computed(() => {
const isEn = selectedLanguage.value === "en";
return {
Expand Down Expand Up @@ -161,12 +160,12 @@ const i18n = computed(() => {
completedText: isEn ? "Completed" : "完了",
specialUnlocked: isEn ? "Special Unlocked" : "スペシャル解放",
allCorrectTitle: isEn
? "Special Page Unlocked!"
: "スペシャルページ解放!",
? "Present a souvenir!"
: "おみやげをプレゼント!",
allCorrectDesc: isEn
? "You have completed this set and unlocked the special page."
: "このセットを最後まで進めたため、スペシャルページへ進めます。",
goToSpecial: isEn ? "Go to Special Page" : "スペシャルページへ",
? "Prepared some souvenirs for everyone played."
: "プレイしてくれたみんなのために記念品を用意したよ。",
goToSpecial: isEn ? "Go to Souvenirs Page" : "おみやげページ",
nextLevel: isEn ? "Next Level" : "次のレベルへ",
retryLevel: isEn
? isSingleTier
Expand Down Expand Up @@ -199,6 +198,18 @@ const i18n = computed(() => {
};
});

watch(selectedLanguage, () => {
if (
keywordMessage.value === "合言葉が違います。" ||
keywordMessage.value === "Incorrect keyword."
) {
keywordMessage.value =
selectedLanguage.value === "en"
? "Incorrect keyword."
: "合言葉が違います。";
}
});

const correctFeedbackPhrases: I18nText[] = [
{ ja: "その調子!", en: "Keep it up!" },
{ ja: "素晴らしい!", en: "Excellent!" },
Expand Down Expand Up @@ -389,14 +400,14 @@ const shareText = computed(() => {
const tierName = t(currentTier.value.title);

const baseText = isEn
? `Cleared ${isSingleTier ? "all stages" : tierName} on Go Conference 2026 CodeLab! Correct: ${runCorrectCount.value}/${tierSize.value} Score: ${runScore.value}`
: `Go Conference 2026 CodeLabで${isSingleTier ? "全問題を" : ` ${tierName} を`}クリアしました! 正解数: ${runCorrectCount.value}/${tierSize.value} スコア: ${runScore.value}`;
? `Cleared ${isSingleTier ? "all stages" : tierName} on Go Conference 2026 CodeLab!\nCorrect: ${runCorrectCount.value}/${tierSize.value} Score: ${runScore.value}`
: `Go Conference 2026 CodeLabで${isSingleTier ? "全問題を" : ` ${tierName} を`}クリアしました!\n正解数: ${runCorrectCount.value}/${tierSize.value} スコア: ${runScore.value}`;

const url =
typeof window !== "undefined"
? window.location.href.split("#")[0].split("?")[0]
: "https://gocon.jp/";
return `${baseText}\n#gocon26cl\n${url}`;
return `${baseText}\n${url}\n#gocon26 #gocon26cl`;
});

const xShareUrl = computed(
Expand Down Expand Up @@ -571,6 +582,10 @@ const finishTierRun = () => {
const goToNextStage = () => {
if (!currentResult.value) return;

if (typeof window !== "undefined") {
window.scrollTo(0, 0);
}

if (currentQuestionIndex.value === tierSize.value - 1) {
finishTierRun();
return;
Expand Down Expand Up @@ -624,7 +639,10 @@ const closeKeywordModal = () => {

const submitKeyword = () => {
if (keywordValue.value.trim() !== PREVIEW_UNLOCK_KEYWORD) {
keywordMessage.value = i18n.value.keywordIncorrect;
keywordMessage.value =
selectedLanguage.value === "en"
? "Incorrect keyword."
: "合言葉が違います。";
return;
}

Expand Down
24 changes: 18 additions & 6 deletions quiz-app2/src/components/games/FillBlankTapGame.vue
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,15 @@ const clearSelection = () => {
const slotIsFilled = (item: PoolItem) =>
slots.value.some((slot) => slot?.id === item.id);

const addToken = (item: PoolItem) => {
const toggleToken = (item: PoolItem) => {
if (props.locked) return;

const existingIndex = slots.value.findIndex((s) => s?.id === item.id);
if (existingIndex !== -1) {
slots.value[existingIndex] = null;
return;
}

const index = slots.value.findIndex((s) => s === null);
if (index !== -1) {
slots.value[index] = item;
Expand Down Expand Up @@ -124,6 +131,10 @@ watch(
<StageOutputPanel
v-if="stage.outputLines.length > 0"
:lines="stage.outputLines"
is-fill
:slots="slots"
:locked="locked"
@remove-token="removeToken"
/>

<div class="flex items-center justify-between text-quiz-muted text-xs">
Expand Down Expand Up @@ -171,8 +182,8 @@ watch(
}}</span>
<span>{{
locale === "en"
? "Tap to insert from left"
: "タップで左から挿入"
? "Tap to insert/remove"
: "タップで追加 / 解除"
}}</span>
</div>

Expand All @@ -181,9 +192,10 @@ watch(
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)"
:disabled="locked"
class="px-4 py-3 font-mono text-[13px] transition-all"
:class="{ 'opacity-30': slotIsFilled(item) }"
@click="toggleToken(item)"
>
{{ item.label }}
</PressButton>
Expand Down
47 changes: 44 additions & 3 deletions quiz-app2/src/components/ui/StageOutputPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,24 @@
import { inject, ref, type Ref } from "vue";
import type { Locale } from "../../types";

defineProps<{
const props = defineProps<{
lines: string[];
isFill?: boolean;
slots?: any[];
locked?: boolean;
}>();

const emit = defineEmits<{
(e: "remove-token", index: number): void;
}>();

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) =>
parseInt(part.replace(/[\[\]]/g, ""), 10) - 1;
</script>

<template>
Expand All @@ -22,9 +35,37 @@ const locale = inject("locale", ref("ja")) as Ref<Locale>;
<div
v-for="(line, lineIndex) in lines"
:key="lineIndex"
class="whitespace-pre-wrap"
class="flex flex-wrap items-center gap-2 whitespace-pre-wrap"
>
{{ line }}
<template v-if="isFill">
<template
v-for="(part, partIndex) in splitLine(line)"
:key="partIndex"
>
<button
v-if="isSlot(part)"
type="button"
class="code-slot"
:class="{
'is-filled': slots && slots[slotIndex(part)],
}"
:disabled="
locked || !slots || !slots[slotIndex(part)]
"
@click="emit('remove-token', slotIndex(part))"
>
{{
slots && slots[slotIndex(part)]
? slots[slotIndex(part)].label
: part
}}
</button>
<span v-else>{{ part }}</span>
</template>
</template>
<template v-else>
{{ line }}
</template>
</div>
</div>
</section>
Expand Down
11 changes: 4 additions & 7 deletions quiz-app2/src/data/stages.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ why = { ja = "Go には `while` や `loop` といったキーワードは存在
takeaway = { ja = "`for` の条件式を省略して `for {}` と書くだけで、もっともシンプルでスッキリとした無限ループを作成できます。", en = "By omitting the condition from a `for` statement and simply writing `for {}`, you can create an infinite loop more simply and clearly." }
templateLines = """
[1] {
// 無限ループ

}
"""
pool = [ "for", "while", "loop", "(true)", "True:" ]
Expand Down Expand Up @@ -161,20 +161,17 @@ label = { ja = "defer", en = "defer" }
title = { ja = "defer の実行順序", en = "Execution order of defer" }
prompt = { ja = "コードの実行結果(出力)の順番を上から完成させてください。", en = "Complete the execution order (output) from top to bottom." }
outputLines = """
[1]
[2]
[3]
"""
playgroundUrl = "https://go.dev/play/p/p9uTA5pzU1G"
why = { ja = "`defer` に登録された関数は、関数が終了する際に「最後に追加されたものから順に(LIFO: Last-In, First-Out)」逆順で実行されます。", en = "`defer` statements are executed in reverse order (LIFO: Last-In, First-Out) when the function returns." }
takeaway = { ja = "通常の文(C)が先に実行され、その後に `defer` が下から上の順(B -> A)で実行されるため、出力は C、B、A の順になります。", en = "Since regular statements (C) execute first, followed by `defer` statements in reverse order (B -> A), the output will be C, B, then A." }
templateLines = """
// ── 実行されるコード ──
defer fmt.Println("A")
defer fmt.Println("B")
fmt.Println("C")

// ── 出力結果 ──
[1]
[2]
[3]
"""
pool = [ "A", "B", "C" ]
correctAnswers = [ "C", "B", "A" ]
Expand Down
10 changes: 6 additions & 4 deletions quiz-app2/src/data/stages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import stagesDocument from "./stages.toml";
export const STAGE_TIME_LIMIT_MS = 30_000;
export const PREVIEW_UNLOCK_KEYWORD = "gofar,gotogether";
export const FOOTER_TAP_THRESHOLD = 10;
export const SPECIAL_PAGE_URL = "https://example.com";
export const SPECIAL_PAGE_URL = "https://gocon.jp/2026/";

type TomlRecord = Record<string, unknown>;

Expand Down Expand Up @@ -160,13 +160,15 @@ const parseStageBase = <K extends StageKind>(
};

const validateFillTemplate = (stage: FillStage, path: string) => {
const placeholderIndexes = stage.templateLines.flatMap((line) =>
const allLines = [...stage.templateLines, ...stage.outputLines];

const placeholderIndexes = allLines.flatMap((line) =>
Array.from(line.matchAll(/\[(\d+)\]/g), (match) => Number(match[1])),
);

if (placeholderIndexes.length !== stage.correctAnswers.length) {
throw new Error(
`${path}.templateLines must contain ${stage.correctAnswers.length} placeholders.`,
`${path} must contain exactly ${stage.correctAnswers.length} placeholders across templateLines and outputLines.`,
);
}

Expand All @@ -178,7 +180,7 @@ const validateFillTemplate = (stage: FillStage, path: string) => {
for (const [index, value] of placeholderIndexes.entries()) {
if (value !== expectedIndexes[index]) {
throw new Error(
`${path}.templateLines placeholders must be numbered [1]...[${stage.correctAnswers.length}] in order.`,
`${path} placeholders must be numbered [1]...[${stage.correctAnswers.length}] in order across templateLines and outputLines.`,
);
}
}
Expand Down
2 changes: 1 addition & 1 deletion quiz-app2/test/app.ui.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ describe("quiz-app2 campaign flow", () => {

expect(shell().attributes("data-screen")).toBe("score");
expect(wrapper.text()).toContain(`${stages.length}/${stages.length}`);
expect(wrapper.text()).toContain("スペシャルページへ");
expect(wrapper.text()).toContain("おみやげをプレゼント");

// Tierが1つだけの場合はボタン名が「もう一度挑戦する」に変化する対応
const isSingleTier = campaignTiers.length === 1;
Expand Down