diff --git a/BASIC.md b/BASIC.md index 326b166..1baff55 100644 --- a/BASIC.md +++ b/BASIC.md @@ -102,13 +102,14 @@ jobs: 内容: -- `/play` ルート作成 +- `/$lang/$codeLanguage/play` ルート作成(例:`/ja/javascript/play`) - GameState(score, combo, timer, currentLevel) - 行タップ処理(当たり / 外れ) - コンボ計算 - レベル進行(1→2→3) - スキップ機能 - ゲーム終了処理(フロント内完結) +- レベル内のデータはランダムに選択 --- @@ -346,13 +347,14 @@ npm create cloudflare@latest -- my-react-router-app --framework=react-router ### 3.1 ルート一覧 -| パス | 役割 | -| ---------------- | --------------------------------- | -| `/` | タイトル画面 | -| `/play` | ゲーム画面 | -| `/result/create` | ゲーム終了時のスコア登録 `action` | -| `/result/:id` | 結果画面(SSR、シェア用) | -| `/ranking` | ランキング一覧画面 | +| パス | 役割 | +| ------------------------------ | --------------------------------- | +| `/` | ルート(言語自動判定・リダイレクト) | +| `/$lang` | タイトル画面(例:`/ja`, `/en`) | +| `/$lang/$codeLanguage/play` | ゲーム画面(例:`/ja/javascript/play`) | +| `/result/create` | ゲーム終了時のスコア登録 `action` | +| `/result/:id` | 結果画面(SSR、シェア用) | +| `/ranking` | ランキング一覧画面 | - ルート定義は `src/routes` 配下に作成する。 diff --git a/app/components/Header.tsx b/app/components/Header.tsx index 498c484..dc64ce9 100644 --- a/app/components/Header.tsx +++ b/app/components/Header.tsx @@ -1,5 +1,6 @@ import { Link } from 'react-router'; -import { SupportedLanguage, saveLanguage } from '../locales'; +import type { SupportedLanguage } from '../locales'; +import { saveLanguage } from '../locales'; interface HeaderProps { currentLang: SupportedLanguage; diff --git a/app/routes.ts b/app/routes.ts index 11e6cf5..c446427 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -3,4 +3,5 @@ import { type RouteConfig, index, route } from '@react-router/dev/routes'; export default [ index('routes/_index.tsx'), route(':lang', 'routes/$lang.tsx'), + route(':lang/:codeLanguage/play', 'routes/$lang.$codeLanguage.play.tsx'), ] satisfies RouteConfig; diff --git a/app/routes/$lang.$codeLanguage.play.tsx b/app/routes/$lang.$codeLanguage.play.tsx new file mode 100644 index 0000000..20933ff --- /dev/null +++ b/app/routes/$lang.$codeLanguage.play.tsx @@ -0,0 +1,395 @@ +import { useState, useEffect, useCallback } from 'react'; +import { redirect } from 'react-router'; +import type { Route } from './+types/$lang.$codeLanguage.play'; +import type { SupportedLanguage } from '../locales'; +import { t } from '../locales'; +import type { CodeLanguageOrAll, Problem } from '../problems'; +import { getProblems, calculateScore } from '../problems'; +import { Header } from '../components/Header'; + +/** + * Game state type + */ +type GameState = { + currentProblem: Problem | null; + currentLevel: number; // 1 → 2 → 3 + score: number; + combo: number; + remainingSeconds: number; // 60 → 0 + solvedIssueIds: string[]; // IDs of issues that have been found + tappedLines: Record; // Map of problem ID to tapped lines + problemCount: number; // Number of problems solved +}; + +/** + * Validate route parameters and redirect if invalid + */ +export function loader({ params }: Route.LoaderArgs) { + const { lang, codeLanguage } = params; + + // Validate language parameter + if (lang !== 'ja' && lang !== 'en') { + throw redirect('/'); + } + + // Validate code language parameter + const validCodeLanguages = ['all', 'javascript', 'php', 'ruby', 'java', 'dart']; + if (!validCodeLanguages.includes(codeLanguage)) { + throw redirect(`/${lang}`); + } + + return { + lang: lang as SupportedLanguage, + codeLanguage: codeLanguage as CodeLanguageOrAll, + }; +} + +/** + * Select a random problem from the available problems for the given level + */ +function selectRandomProblem( + codeLanguage: CodeLanguageOrAll, + level: number, + excludeIds: string[] = [] +): Problem | null { + const problems = getProblems(codeLanguage, level); + const availableProblems = problems.filter((p) => !excludeIds.includes(p.id)); + + if (availableProblems.length === 0) { + return null; + } + + const randomIndex = Math.floor(Math.random() * availableProblems.length); + return availableProblems[randomIndex]; +} + +/** + * Game component + */ +export default function Play({ loaderData }: Route.ComponentProps) { + const { lang, codeLanguage } = loaderData; + + // Initialize game state + const [gameState, setGameState] = useState(() => { + const firstProblem = selectRandomProblem(codeLanguage, 1); + return { + currentProblem: firstProblem, + currentLevel: 1, + score: 0, + combo: 0, + remainingSeconds: 60, + solvedIssueIds: [], + tappedLines: {}, + problemCount: 0, + }; + }); + + const [usedProblemIds, setUsedProblemIds] = useState( + gameState.currentProblem ? [gameState.currentProblem.id] : [] + ); + const [gameEnded, setGameEnded] = useState(false); + const [feedbackMessage, setFeedbackMessage] = useState<{ + type: 'correct' | 'wrong' | 'complete'; + text: string; + } | null>(null); + + // Timer countdown + useEffect(() => { + if (gameEnded) return; + + const timer = setInterval(() => { + setGameState((prev) => { + const newSeconds = prev.remainingSeconds - 1; + if (newSeconds <= 0) { + setGameEnded(true); + return { ...prev, remainingSeconds: 0 }; + } + return { ...prev, remainingSeconds: newSeconds }; + }); + }, 1000); + + return () => clearInterval(timer); + }, [gameEnded]); + + // Clear feedback message after 2 seconds + useEffect(() => { + if (feedbackMessage) { + const timeout = setTimeout(() => { + setFeedbackMessage(null); + }, 2000); + return () => clearTimeout(timeout); + } + }, [feedbackMessage]); + + /** + * Handle line tap + */ + const handleLineTap = useCallback( + (lineNumber: number) => { + if (gameEnded || !gameState.currentProblem) return; + + const problemId = gameState.currentProblem.id; + const alreadyTapped = gameState.tappedLines[problemId]?.includes(lineNumber) || false; + + // Ignore if already tapped + if (alreadyTapped) return; + + // Find if this line has an issue + const issues = gameState.currentProblem.issues; + const hitIssue = issues.find( + (issue) => + issue.lines.includes(lineNumber) && !gameState.solvedIssueIds.includes(issue.id) + ); + + setGameState((prev) => { + const newTappedLines = { + ...prev.tappedLines, + [problemId]: [...(prev.tappedLines[problemId] || []), lineNumber], + }; + + if (hitIssue) { + // Correct! Increase score and combo + const newCombo = prev.combo + 1; + const scoreGain = calculateScore(hitIssue.score, newCombo); + const newSolvedIssueIds = [...prev.solvedIssueIds, hitIssue.id]; + + setFeedbackMessage({ + type: 'correct', + text: `+${scoreGain} (${t('label.combo', lang)}: ${newCombo}x)`, + }); + + return { + ...prev, + score: prev.score + scoreGain, + combo: newCombo, + solvedIssueIds: newSolvedIssueIds, + tappedLines: newTappedLines, + }; + } else { + // Wrong! Lose 1 point and reset combo + setFeedbackMessage({ + type: 'wrong', + text: `-1`, + }); + + return { + ...prev, + score: Math.max(0, prev.score - 1), + combo: 0, + tappedLines: newTappedLines, + }; + } + }); + }, + [gameEnded, gameState.currentProblem, gameState.tappedLines, gameState.solvedIssueIds, lang] + ); + + /** + * Handle skip to next problem + */ + const handleSkip = useCallback(() => { + if (gameEnded || !gameState.currentProblem) return; + + const currentProblemIssues = gameState.currentProblem.issues; + const allIssuesSolved = currentProblemIssues.every((issue) => + gameState.solvedIssueIds.includes(issue.id) + ); + + // Bonus for finding all issues + let bonusScore = 0; + if (allIssuesSolved && currentProblemIssues.length > 0) { + bonusScore = 3; + setFeedbackMessage({ + type: 'complete', + text: `+${bonusScore} (All issues found!)`, + }); + } + + setGameState((prev) => { + // Advance level: 1 → 2 → 3 (max) + const nextLevel = Math.min(prev.currentLevel + 1, 3); + const nextProblem = selectRandomProblem(codeLanguage, nextLevel, usedProblemIds); + + if (nextProblem) { + setUsedProblemIds((prevIds) => [...prevIds, nextProblem.id]); + } + + return { + ...prev, + currentProblem: nextProblem, + currentLevel: nextLevel, + score: prev.score + bonusScore, + solvedIssueIds: [], + problemCount: prev.problemCount + 1, + }; + }); + }, [gameEnded, gameState.currentProblem, gameState.solvedIssueIds, codeLanguage, usedProblemIds]); + + // Show game over screen + if (gameEnded) { + return ( + <> +
+
+
+

+ {lang === 'ja' ? 'ゲーム終了!' : 'Game Over!'} +

+
+
+
+ {t('label.score', lang)} +
+
+ {gameState.score} +
+
+
+ {lang === 'ja' + ? `${gameState.problemCount}問を解きました` + : `Solved ${gameState.problemCount} problems`} +
+
+ +
+
+ + ); + } + + // Show message if no problem available + if (!gameState.currentProblem) { + return ( + <> +
+
+
+

+ {lang === 'ja' + ? '問題が見つかりませんでした。' + : 'No problems available.'} +

+ +
+
+ + ); + } + + const currentTappedLines = gameState.tappedLines[gameState.currentProblem.id] || []; + const currentProblemIssues = gameState.currentProblem.issues; + const foundIssuesCount = gameState.solvedIssueIds.filter((id) => + currentProblemIssues.some((issue) => issue.id === id) + ).length; + + return ( + <> +
+
+ {/* Game stats header */} +
+
+
+ {t('label.time', lang)}: + + {gameState.remainingSeconds}s + +
+
+ {t('label.score', lang)}: + + {gameState.score} + +
+
+ {t('label.combo', lang)}: + + {gameState.combo}x + +
+
+
+ + {/* Feedback message */} + {feedbackMessage && ( +
+ {feedbackMessage.text} +
+ )} + + {/* Problem info */} +
+
+
+ Level {gameState.currentLevel} | {gameState.currentProblem.codeLanguage} +
+
+ {lang === 'ja' ? '発見' : 'Found'}: {foundIssuesCount} / {currentProblemIssues.length} +
+
+
+ + {/* Code display */} +
+
+            {gameState.currentProblem.code.map((line, idx) => {
+              const lineNumber = idx + 1;
+              const isTapped = currentTappedLines.includes(lineNumber);
+              const hasIssue = gameState.currentProblem!.issues.some((issue) =>
+                issue.lines.includes(lineNumber)
+              );
+              const isFound = isTapped && hasIssue;
+
+              return (
+                
handleLineTap(lineNumber)} + className={`py-1 px-2 -mx-2 rounded cursor-pointer transition-colors ${ + isFound + ? 'bg-emerald-100 dark:bg-emerald-900' + : isTapped + ? 'bg-red-100 dark:bg-red-900' + : 'hover:bg-sky-50 dark:hover:bg-slate-700' + }`} + > + + {lineNumber} + + {line} +
+ ); + })} +
+
+ + {/* Skip button */} +
+ +
+
+ + ); +} diff --git a/app/routes/$lang.tsx b/app/routes/$lang.tsx index 4e68af7..6aad15f 100644 --- a/app/routes/$lang.tsx +++ b/app/routes/$lang.tsx @@ -1,6 +1,6 @@ import { redirect } from 'react-router'; import type { Route } from './+types/$lang'; -import { SupportedLanguage } from '../locales'; +import type { SupportedLanguage } from '../locales'; import { Welcome } from '../welcome/welcome'; import { Header } from '../components/Header'; diff --git a/app/welcome/welcome.tsx b/app/welcome/welcome.tsx index 5f66ffc..b5f6d4e 100644 --- a/app/welcome/welcome.tsx +++ b/app/welcome/welcome.tsx @@ -1,6 +1,17 @@ -import { SupportedLanguage, t } from '../locales'; +import { useState } from 'react'; +import { useNavigate } from 'react-router'; +import type { SupportedLanguage } from '../locales'; +import { t } from '../locales'; +import type { CodeLanguageOrAll } from '../problems'; export function Welcome({ lang }: { lang: SupportedLanguage }) { + const navigate = useNavigate(); + const [codeLanguage, setCodeLanguage] = useState('all'); + + const handleStart = () => { + navigate(`/${lang}/${codeLanguage}/play`); + }; + return (
@@ -36,7 +47,11 @@ export function Welcome({ lang }: { lang: SupportedLanguage }) {
- setCodeLanguage(e.target.value as CodeLanguageOrAll)} + > @@ -47,7 +62,10 @@ export function Welcome({ lang }: { lang: SupportedLanguage }) {
-