diff --git a/apps/frontend/src/app/app.tsx b/apps/frontend/src/app/app.tsx index e3d90e7..e1023c2 100644 --- a/apps/frontend/src/app/app.tsx +++ b/apps/frontend/src/app/app.tsx @@ -1,14 +1,16 @@ import { createEffect, createMemo } from "solid-js"; import { Router, Route, useNavigate, useSearchParams } from "@solidjs/router"; -import { games } from "@/features/games/registry"; -import { getHomeGamePath } from "@/features/games/utils"; + +import Layout from "@/app/layout"; import AboutPage from "@/app/pages/about"; import AdminPage from "@/app/pages/admin"; import HomePage from "@/app/pages/home"; -import Layout from "@/app/layout"; import LeaderboardPage from "@/app/pages/leaderboard"; import ProfilePage from "@/app/pages/profile"; -import type { GameId } from "@/features/games/types"; + +import { games } from "@/features/games/core/registry"; +import { getHomeGamePath } from "@/features/games/core/utils"; +import type { GameId } from "@/features/games/core/types"; import type { WordBankId } from "@/features/content/word-banks/types"; function App() { diff --git a/apps/frontend/src/app/pages/home.tsx b/apps/frontend/src/app/pages/home.tsx index cae0271..b17ea5e 100644 --- a/apps/frontend/src/app/pages/home.tsx +++ b/apps/frontend/src/app/pages/home.tsx @@ -1,10 +1,10 @@ import { createMemo } from "solid-js"; import { Dynamic } from "solid-js/web"; -import GameSelector from "@/features/games/components/game-selector"; +import { GameCards } from "@/features/games/core/components/GameCards"; import { FeedbackFeed } from "@/features/feedback/components/feedback-feed"; -import { games } from "@/features/games/registry"; -import type { GameId } from "@/features/games/types"; +import { games } from "@/features/games/core/registry"; +import type { GameId } from "@/features/games/core/types"; import type { WordBankId } from "@/features/content/word-banks/types"; type HomeProps = { @@ -23,7 +23,7 @@ function HomePage(props: HomeProps) {
{!props.selectedGameId && ( <> - diff --git a/apps/frontend/src/app/pages/leaderboard.tsx b/apps/frontend/src/app/pages/leaderboard.tsx index a9845e4..d892af7 100644 --- a/apps/frontend/src/app/pages/leaderboard.tsx +++ b/apps/frontend/src/app/pages/leaderboard.tsx @@ -1,16 +1,16 @@ import { For, createSignal } from "solid-js"; -import { gameRegistry } from "@/features/games/registry"; +import { gameRegistry } from "@/features/games/core/registry"; import type { LeaderboardDifficulty } from "@/features/leaderboard/types"; import { LeaderboardTable } from "@/features/leaderboard/components/leaderboard-table"; -import { getGameName } from "@/features/games/utils"; -import type { GameId } from "@/features/games/types"; +import { getGameName } from "@/features/games/core/utils"; +import type { GameId } from "@/features/games/core/types"; const difficulties: LeaderboardDifficulty[] = ["easy", "medium", "hard"]; function LeaderboardPage() { const [gameId, setGameId] = createSignal( - gameRegistry[0]?.id ?? "falling-words", + gameRegistry[0]?.id ?? "survival", ); const [difficulty, setDifficulty] = diff --git a/apps/frontend/src/features/commandline/registry.ts b/apps/frontend/src/features/commandline/registry.ts index 2579e77..8b67c69 100644 --- a/apps/frontend/src/features/commandline/registry.ts +++ b/apps/frontend/src/features/commandline/registry.ts @@ -1,18 +1,20 @@ import { useNavigate, useLocation, useSearchParams } from "@solidjs/router"; -import { themes } from "@/features/content/themes/registry"; + import { wordBanks } from "@/features/content/word-banks/registry"; import type { WordBankId } from "@/features/content/word-banks/types"; -import { gameRegistry } from "@/features/games/registry"; -import { getHomeGamePath } from "@/features/games/utils"; -import type { ThemeName } from "@/features/content/themes/types"; -import type { - CommandlineItem, - CommandlineScope, -} from "@/features/commandline/types"; -import type { GameId } from "@/features/games/types"; + +import { gameRegistry } from "@/features/games/core/registry"; +import { getHomeGamePath } from "@/features/games/core/utils"; +import type { GameId } from "@/features/games/core/types"; + +import { themes } from "@/features/content/themes/registry"; import { themeManager } from "@/features/content/themes/manager"; +import type { ThemeName } from "@/features/content/themes/types"; + import { useAuthSession } from "@/features/auth/hooks"; +import type { CommandlineItem, CommandlineScope } from "./types.ts"; + export function createCommandlineRegistry( setScope: (scope: CommandlineScope) => void, ): Record { diff --git a/apps/frontend/src/features/games/components/game-input.tsx b/apps/frontend/src/features/games/components/game-input.tsx deleted file mode 100644 index fb842d7..0000000 --- a/apps/frontend/src/features/games/components/game-input.tsx +++ /dev/null @@ -1,22 +0,0 @@ -export type GameInputProps = { - ref: (el: HTMLInputElement) => void; - value: string; - onInput: (e: InputEvent & { currentTarget: HTMLInputElement }) => void; - onKeyDown: (e: KeyboardEvent & { currentTarget: HTMLInputElement }) => void; -}; - -export function GameInput(props: GameInputProps) { - return ( - - ); -} diff --git a/apps/frontend/src/features/games/components/game-selector.tsx b/apps/frontend/src/features/games/components/game-selector.tsx deleted file mode 100644 index 43adc5d..0000000 --- a/apps/frontend/src/features/games/components/game-selector.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { For } from "solid-js"; -import { gameRegistry } from "@/features/games/registry"; -import type { GameId } from "@/features/games/types"; -import { GameCard } from "./game-card"; - -type GameSelectorProps = { - activeGameId?: GameId | null; - onSelectGame: (gameId: GameId) => void; -}; - -function GameSelector(props: GameSelectorProps) { - return ( -
- - {(game) => ( - props.onSelectGame(game.id)} - /> - )} - -
- ); -} - -export default GameSelector; -// TODO: merge game card and game selector to one Game Cards ig diff --git a/apps/frontend/src/features/games/components/difficulty-selector.tsx b/apps/frontend/src/features/games/core/components/DifficultySelector.tsx similarity index 95% rename from apps/frontend/src/features/games/components/difficulty-selector.tsx rename to apps/frontend/src/features/games/core/components/DifficultySelector.tsx index 2d4dc7f..57bf51d 100644 --- a/apps/frontend/src/features/games/components/difficulty-selector.tsx +++ b/apps/frontend/src/features/games/core/components/DifficultySelector.tsx @@ -30,5 +30,3 @@ export function DifficultySelector(
); } - -//TODO: rename to mode selector instead diff --git a/apps/frontend/src/features/games/components/game-card.tsx b/apps/frontend/src/features/games/core/components/GameCards.tsx similarity index 58% rename from apps/frontend/src/features/games/components/game-card.tsx rename to apps/frontend/src/features/games/core/components/GameCards.tsx index 0b610f5..ce59cb5 100644 --- a/apps/frontend/src/features/games/components/game-card.tsx +++ b/apps/frontend/src/features/games/core/components/GameCards.tsx @@ -1,5 +1,8 @@ +import { For } from "solid-js"; import { ArrowRight } from "lucide-solid"; -import type { GameId } from "@/features/games/types"; + +import { gameRegistry } from "@/features/games/core/registry"; +import type { GameId } from "@/features/games/core/types"; type GameCardProps = { name: string; @@ -30,3 +33,25 @@ export function GameCard(props: GameCardProps) { ); } + +type GameCardsProps = { + activeGameId?: GameId | null; + onSelectGame: (gameId: GameId) => void; +}; + +export function GameCards(props: GameCardsProps) { + return ( +
+ + {(game) => ( + props.onSelectGame(game.id)} + /> + )} + +
+ ); +} diff --git a/apps/frontend/src/features/games/core/components/GameInput.tsx b/apps/frontend/src/features/games/core/components/GameInput.tsx new file mode 100644 index 0000000..b7eb695 --- /dev/null +++ b/apps/frontend/src/features/games/core/components/GameInput.tsx @@ -0,0 +1,42 @@ +import { onMount } from "solid-js"; + +type GameInputProps = { + value: string; + onInput: (value: string) => void; + onNext: () => void; + onPrevious: () => void; + onReset: () => void; +}; + +export function GameInput(props: GameInputProps) { + let ref: HTMLInputElement | undefined; + + onMount(() => ref?.focus()); + + return ( + props.onInput(e.currentTarget.value)} + onKeyDown={(e) => { + if (e.key === "Escape") { + props.onReset(); + return; + } + + if (e.key === " ") { + e.preventDefault(); + props.onNext(); + return; + } + + if (e.key === "Backspace" && props.value.length === 0) { + e.preventDefault(); + props.onPrevious(); + } + }} + /> + ); +} diff --git a/apps/frontend/src/features/games/components/game-meta.tsx b/apps/frontend/src/features/games/core/components/GameMeta.tsx similarity index 100% rename from apps/frontend/src/features/games/components/game-meta.tsx rename to apps/frontend/src/features/games/core/components/GameMeta.tsx diff --git a/apps/frontend/src/features/games/components/game-over.tsx b/apps/frontend/src/features/games/core/components/GameOver.tsx similarity index 92% rename from apps/frontend/src/features/games/components/game-over.tsx rename to apps/frontend/src/features/games/core/components/GameOver.tsx index c8aec69..61ab062 100644 --- a/apps/frontend/src/features/games/components/game-over.tsx +++ b/apps/frontend/src/features/games/core/components/GameOver.tsx @@ -21,3 +21,5 @@ export function GameOver(props: GameOverProps) { ); } + +// TODO: make this game overlay ie for all phases ig diff --git a/apps/frontend/src/features/games/components/game-stat.tsx b/apps/frontend/src/features/games/core/components/GameStat.tsx similarity index 100% rename from apps/frontend/src/features/games/components/game-stat.tsx rename to apps/frontend/src/features/games/core/components/GameStat.tsx diff --git a/apps/frontend/src/features/games/core/engine/Word.tsx b/apps/frontend/src/features/games/core/engine/Word.tsx new file mode 100644 index 0000000..676f6d4 --- /dev/null +++ b/apps/frontend/src/features/games/core/engine/Word.tsx @@ -0,0 +1,56 @@ +import { Index, Show } from "solid-js"; + +import { analyzeWord } from "./analyze-word"; +import type { CharacterState, WordState } from "../types"; + +type WordProps = { + word: WordState; + isActive?: boolean; +}; + +export function Word(props: WordProps) { + const chars = () => analyzeWord(props.word.expected, props.word.typed); + + return ( +
+ + {(char, i) => ( + + + + + + + + )} + + + + = props.word.expected.length + } + > + + + +
+ ); +} + +type CharacterProps = { + char: string; + state: CharacterState; +}; + +const classes: Record = { + correct: "text-(--text)", + incorrect: "text-(--error)", + pending: "text-(--sub)", + extra: "text-(--error)", +}; + +function Character(props: CharacterProps) { + return {props.char}; +} diff --git a/apps/frontend/src/features/games/core/engine/analyze-word.ts b/apps/frontend/src/features/games/core/engine/analyze-word.ts new file mode 100644 index 0000000..d3d2f68 --- /dev/null +++ b/apps/frontend/src/features/games/core/engine/analyze-word.ts @@ -0,0 +1,38 @@ +import type { AnalyzedCharacter, CharacterState } from "../types"; + +export function analyzeWord( + expected: string, + typed: string, +): AnalyzedCharacter[] { + const result: AnalyzedCharacter[] = []; + + const max = Math.max(expected.length, typed.length); + + for (let i = 0; i < max; i++) { + const expectedChar = expected[i]; + const typedChar = typed[i]; + + // extra chars + if (i >= expected.length) { + result.push({ + value: typedChar!, + state: "extra", + }); + + continue; + } + + let state: CharacterState = "pending"; + + if (typedChar != null) { + state = typedChar === expectedChar ? "correct" : "incorrect"; + } + + result.push({ + value: expectedChar!, + state, + }); + } + + return result; +} diff --git a/apps/frontend/src/features/games/core/engine/metrics.ts b/apps/frontend/src/features/games/core/engine/metrics.ts new file mode 100644 index 0000000..5b0c7a2 --- /dev/null +++ b/apps/frontend/src/features/games/core/engine/metrics.ts @@ -0,0 +1,68 @@ +import { createMemo, createSignal, onCleanup } from "solid-js"; +import type { KeystrokeEvent, WordResult, GameMetrics } from "../types"; + +export function buildMetricsSnapshot( + keystrokes: KeystrokeEvent[], + wordResults: WordResult[], + startTime: number, + endTime: number, +): GameMetrics { + const durationMinutes = (endTime - startTime) / 1000 / 60; + + const totalChars = keystrokes.length; + + const correctChars = keystrokes.filter((k) => k.correct).length; + + const rawWpm = Math.round(totalChars / 5 / durationMinutes); + + const correctedWpm = Math.round(correctChars / 5 / durationMinutes); + + const accuracy = + totalChars === 0 ? 100 : Math.round((correctChars / totalChars) * 100); + + return { + rawWpm, + correctedWpm, + accuracy, + keystrokes, + wordResults, + startTime, + endTime, + }; +} + +export function createLiveMetrics( + keystrokes: () => KeystrokeEvent[], + startTime: () => number | null, +) { + const [now, setNow] = createSignal(Date.now()); + + const interval = setInterval(() => setNow(Date.now()), 1000); + onCleanup(() => clearInterval(interval)); + + return createMemo(() => { + const start = startTime(); + if (!start) { + return { rawWpm: 0, correctedWpm: 0, accuracy: 100 }; + } + + const allKeystrokes = keystrokes(); + now(); + + const durationMinutes = (Date.now() - start) / 1000 / 60; + + if (durationMinutes <= 0) { + return { rawWpm: 0, correctedWpm: 0, accuracy: 100 }; + } + + const totalChars = allKeystrokes.length; + const correctChars = allKeystrokes.filter((k) => k.correct).length; + + const rawWpm = Math.round(totalChars / 5 / durationMinutes); + const correctedWpm = Math.round(correctChars / 5 / durationMinutes); + const accuracy = + totalChars === 0 ? 100 : Math.round((correctChars / totalChars) * 100); + + return { rawWpm, correctedWpm, accuracy }; + }); +} diff --git a/apps/frontend/src/features/games/core/engine/state-machine.ts b/apps/frontend/src/features/games/core/engine/state-machine.ts new file mode 100644 index 0000000..5fb69cd --- /dev/null +++ b/apps/frontend/src/features/games/core/engine/state-machine.ts @@ -0,0 +1,191 @@ +import { createStore } from "solid-js/store"; + +import { buildMetricsSnapshot } from "./metrics"; + +import type { GameState, KeystrokeEvent, WordResult } from "../types"; + +export type GameConfig = { + words: string[]; + onKeystroke?: (event: KeystrokeEvent) => void; + onWordComplete?: (word: { expected: string; typed: string }) => void; +}; + +function initMetrics() { + return { + rawWpm: 0, + correctedWpm: 0, + accuracy: 100, + keystrokes: [] as KeystrokeEvent[], + wordResults: [] as WordResult[], + startTime: null as number | null, + endTime: null as number | null, + }; +} + +export function createGameStore(config: GameConfig) { + const [state, setState] = createStore({ + status: "idle", + + words: config.words.map((word) => ({ + expected: word, + typed: "", + })), + + currentWordIndex: 0, + + metrics: initMetrics(), + }); + + function currentWord() { + return state.words[state.currentWordIndex]; + } + + function start() { + if (state.status !== "idle") return; + + setState("status", "running"); + + setState("metrics", "startTime", Date.now()); + } + + function finish() { + if (state.status !== "running") return; + + const endTime = Date.now(); + // TODO: are we even setting endTime state ? + + const wordResults = buildWordResults(state.words); + + setState("status", "finished"); + + setState( + "metrics", + buildMetricsSnapshot( + state.metrics.keystrokes, + wordResults, + state.metrics.startTime!, + endTime, + ), + ); + } + + function reset() { + setState("status", "idle"); + + setState( + "words", + config.words.map((word) => ({ + expected: word, + typed: "", + })), + ); + + setState("currentWordIndex", 0); + + setState("metrics", initMetrics()); + } + + function onInput(value: string) { + if (state.status === "idle") { + start(); + } + + if (state.status !== "running") { + return; + } + + const word = currentWord(); + + if (!word) return; + + const previous = word.typed[word.typed.length - 1]; + + const current = value[value.length - 1]; + + // ignore deletions + if (value.length < word.typed.length) { + setState("words", state.currentWordIndex, "typed", value); + + return; + } + + setState("words", state.currentWordIndex, "typed", value); + + if (current != null && current !== previous) { + const charIndex = value.length - 1; + + const event: KeystrokeEvent = { + key: current, + timestamp: Date.now(), + wordIndex: state.currentWordIndex, + charIndex, + correct: current === word!.expected[charIndex], + }; + + setState("metrics", "keystrokes", (k) => [...k, event]); + + config.onKeystroke?.(event); + } + } + + function isLastWord() { + return state.currentWordIndex >= state.words.length - 1; + } + + function nextWord() { + const currentWord = state.words[state.currentWordIndex]; + if (currentWord) { + config.onWordComplete?.({ + expected: currentWord.expected, + typed: currentWord.typed, + }); + } + + setState("currentWordIndex", (i) => i + 1); + } + + function previousWord() { + if (state.currentWordIndex <= 0) return; + + setState("currentWordIndex", (i) => i - 1); + } + + function appendWords(newWords: string[]) { + setState("words", (prev) => [ + ...prev, + ...newWords.map((word) => ({ expected: word, typed: "" })), + ]); + } + + return { + state, + currentWord, + onInput, + nextWord, + previousWord, + reset, + finish, + isLastWord, + appendWords, + }; +} + +function buildWordResults( + words: { + expected: string; + typed: string; + }[], +): WordResult[] { + return words.map((word) => { + const errors = [...word.expected].filter( + (char, i) => word.typed[i] !== char, + ).length; + + return { + target: word.expected, + input: word.typed, + correct: word.expected === word.typed, + errors, + }; + }); +} diff --git a/apps/frontend/src/features/games/hooks.ts b/apps/frontend/src/features/games/core/hooks.ts similarity index 91% rename from apps/frontend/src/features/games/hooks.ts rename to apps/frontend/src/features/games/core/hooks.ts index 5a562e6..7ef2922 100644 --- a/apps/frontend/src/features/games/hooks.ts +++ b/apps/frontend/src/features/games/core/hooks.ts @@ -1,7 +1,7 @@ import { useAuthSession } from "@/features/auth/hooks"; import { useCreateResultMutation } from "@/features/users/results/api"; import { toast } from "@/lib/toast"; -import type { DifficultyKey } from "@/features/games/types"; +import type { DifficultyKey } from "@/features/games/core/types"; export function useSubmitGameResult(minScores: Record) { const auth = useAuthSession(); diff --git a/apps/frontend/src/features/games/core/registry.ts b/apps/frontend/src/features/games/core/registry.ts new file mode 100644 index 0000000..b419393 --- /dev/null +++ b/apps/frontend/src/features/games/core/registry.ts @@ -0,0 +1,11 @@ +import type { GameId, GameModule } from "@/features/games/core/types"; + +import { meta as survivalMeta } from "@/features/games/survival/meta"; +import { meta as fallingWordsMeta } from "@/features/games/falling-words/meta"; + +export const games: Record = { + survival: survivalMeta, + "falling-words": fallingWordsMeta, +}; + +export const gameRegistry = Object.values(games); diff --git a/apps/frontend/src/features/games/core/types.ts b/apps/frontend/src/features/games/core/types.ts new file mode 100644 index 0000000..736008d --- /dev/null +++ b/apps/frontend/src/features/games/core/types.ts @@ -0,0 +1,68 @@ +import type { Component } from "solid-js"; +import type { WordBankId } from "@/features/content/word-banks/types"; + +export type GameViewProps = { + wordBankId?: WordBankId | null; +}; + +export type GameId = "survival" | "falling-words"; + +export type DifficultyKey = "easy" | "medium" | "hard"; + +export type GamePhase = "idle" | "running" | "game-over"; + +export type GameModule = { + id: GameId; + name: string; + description: string; + defaultWordBankId: WordBankId; + difficultyKeys: readonly DifficultyKey[]; + minScores: Record; + View: Component; +}; + +export type CharacterState = "correct" | "incorrect" | "pending" | "extra"; + +export type AnalyzedCharacter = { + value: string; + state: CharacterState; +}; + +export type WordState = { + expected: string; + typed: string; +}; + +export type GameStatus = "idle" | "running" | "finished"; + +export type KeystrokeEvent = { + key: string; + timestamp: number; + wordIndex: number; + charIndex: number; + correct: boolean; +}; + +export type WordResult = { + target: string; + input: string; + correct: boolean; + errors: number; +}; + +export type GameMetrics = { + rawWpm: number; + correctedWpm: number; + accuracy: number; + keystrokes: KeystrokeEvent[]; + wordResults: WordResult[]; + startTime: number | null; + endTime: number | null; +}; + +export type GameState = { + status: GameStatus; + words: WordState[]; + currentWordIndex: number; + metrics: GameMetrics; +}; diff --git a/apps/frontend/src/features/games/core/typing-test.tsx b/apps/frontend/src/features/games/core/typing-test.tsx new file mode 100644 index 0000000..6b5abf7 --- /dev/null +++ b/apps/frontend/src/features/games/core/typing-test.tsx @@ -0,0 +1,82 @@ +import { For, Show } from "solid-js"; + +import { getWordBank } from "@/features/content/word-banks/manager"; + +import { randomWord } from "@/features/games/core/utils"; + +import { createGameStore } from "./engine/state-machine"; + +import { GameInput } from "./components/GameInput"; +import { Word } from "./engine/Word"; + +const WORD_COUNT = 50; + +const wordBank = getWordBank("english/core-1k"); + +const words = wordBank + ? Array.from({ length: WORD_COUNT }, () => randomWord(wordBank.words)) + : []; + +export function TypingTest() { + const { + state, + currentWord, + onInput, + nextWord, + previousWord, + reset, + finish, + isLastWord, + } = createGameStore({ + words, + }); + + return ( +
+
+ Word {state.currentWordIndex + 1} / {state.words.length} +
+ +
+ + {(word, i) => ( + + )} + +
+ + +
+
+ {state.metrics.correctedWpm} wpm + | + {state.metrics.rawWpm} raw + | + {state.metrics.accuracy}% acc +
+ + +
+
+ + { + if (isLastWord()) { + finish(); + } else { + nextWord(); + } + }} + onPrevious={previousWord} + onReset={reset} + /> +
+ ); +} diff --git a/apps/frontend/src/features/games/utils.ts b/apps/frontend/src/features/games/core/utils.ts similarity index 100% rename from apps/frontend/src/features/games/utils.ts rename to apps/frontend/src/features/games/core/utils.ts diff --git a/apps/frontend/src/features/games/falling-words/components/falling-words-hud.tsx b/apps/frontend/src/features/games/falling-words/components/falling-words-hud.tsx index d6781e6..41e8513 100644 --- a/apps/frontend/src/features/games/falling-words/components/falling-words-hud.tsx +++ b/apps/frontend/src/features/games/falling-words/components/falling-words-hud.tsx @@ -1,4 +1,4 @@ -import { GameStat } from "@/features/games/components/game-stat"; +import { GameStat } from "@/features/games/core/components/GameStat"; export type FallingWordsHudProps = { score: number; diff --git a/apps/frontend/src/features/games/falling-words/meta.ts b/apps/frontend/src/features/games/falling-words/meta.ts index 613c6e9..5352382 100644 --- a/apps/frontend/src/features/games/falling-words/meta.ts +++ b/apps/frontend/src/features/games/falling-words/meta.ts @@ -1,8 +1,8 @@ -import type { GameModule } from "@/features/games/types"; +import type { GameModule } from "@/features/games/core/types"; import FallingWordsView from "./view"; export const meta: GameModule = { - id: "falling-words", + id: "falling-words" as const, name: "Falling Words", description: "Catch words before they hit the bottom of the screen.", defaultWordBankId: "english/core-1k", diff --git a/apps/frontend/src/features/games/falling-words/types.ts b/apps/frontend/src/features/games/falling-words/types.ts index 7a3bb4b..6ede1e1 100644 --- a/apps/frontend/src/features/games/falling-words/types.ts +++ b/apps/frontend/src/features/games/falling-words/types.ts @@ -1,4 +1,4 @@ -import type { DifficultyKey, GamePhase } from "@/features/games/types"; +import type { DifficultyKey, GamePhase } from "@/features/games/core/types"; export type { DifficultyKey, GamePhase }; export type DifficultyConfig = { diff --git a/apps/frontend/src/features/games/falling-words/view.tsx b/apps/frontend/src/features/games/falling-words/view.tsx index 7b1e59a..888fb54 100644 --- a/apps/frontend/src/features/games/falling-words/view.tsx +++ b/apps/frontend/src/features/games/falling-words/view.tsx @@ -1,10 +1,10 @@ import { Show } from "solid-js"; import { Globe, Keyboard } from "lucide-solid"; import { useAuthSession } from "@/features/auth/hooks"; -import type { GameViewProps, DifficultyKey } from "@/features/games/types"; +import type { GameViewProps, DifficultyKey } from "@/features/games/core/types"; import { useCreateResultMutation } from "@/features/users/results/api"; import { toast } from "@/lib/toast"; -import { DifficultySelector } from "../components/difficulty-selector"; +import { DifficultySelector } from "@/features/games/core/components/DifficultySelector"; import FallingWordsField from "./components/falling-words-field"; import { FallingWordsHud as Hud } from "./components/falling-words-hud"; import { useFallingWordsGame } from "./use-falling-words-game"; diff --git a/apps/frontend/src/features/games/metrics.ts b/apps/frontend/src/features/games/metrics.ts deleted file mode 100644 index 4de5f61..0000000 --- a/apps/frontend/src/features/games/metrics.ts +++ /dev/null @@ -1,30 +0,0 @@ -export function calculateWpm( - totalCorrectChars: number, - elapsedMs: number, -): number { - const elapsedMinutes = elapsedMs / 60000; - if (elapsedMinutes === 0) return 0; - return Math.round(totalCorrectChars / 5 / elapsedMinutes); -} - -export function calculateAccuracy( - totalTypedChars: number, - totalErrors: number, -): number { - if (totalTypedChars === 0) return 1; - return Math.max(0, (totalTypedChars - totalErrors) / totalTypedChars); -} - -export type GameMetrics = { wpm: number; accuracy: number }; - -export function getMetrics( - totalCorrectChars: number, - totalTypedChars: number, - totalErrors: number, - elapsedMs: number, -): GameMetrics { - return { - wpm: calculateWpm(totalCorrectChars, elapsedMs), - accuracy: calculateAccuracy(totalTypedChars, totalErrors), - }; -} diff --git a/apps/frontend/src/features/games/registry.ts b/apps/frontend/src/features/games/registry.ts deleted file mode 100644 index 1d99fe4..0000000 --- a/apps/frontend/src/features/games/registry.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { meta as fallingWordsGame } from "@/features/games/falling-words"; -import { meta as survivalGame } from "@/features/games/survival"; -import type { GameId, GameModule } from "@/features/games/types"; - -export const games: Record = { - "falling-words": fallingWordsGame, - survival: survivalGame, -}; - -export const gameRegistry = Object.values(games); diff --git a/apps/frontend/src/features/games/survival/animations.css b/apps/frontend/src/features/games/survival/animations.css index b006659..cee2aa6 100644 --- a/apps/frontend/src/features/games/survival/animations.css +++ b/apps/frontend/src/features/games/survival/animations.css @@ -7,4 +7,4 @@ .animate-damage { animation: damage 0.3s cubic-bezier(.36,.07,.19,.97) both; -} +} \ No newline at end of file diff --git a/apps/frontend/src/features/games/survival/components/survival-hud.tsx b/apps/frontend/src/features/games/survival/components/survival-hud.tsx index 96d7d8c..797867f 100644 --- a/apps/frontend/src/features/games/survival/components/survival-hud.tsx +++ b/apps/frontend/src/features/games/survival/components/survival-hud.tsx @@ -1,6 +1,6 @@ import { Index } from "solid-js"; import { Heart } from "./heart"; -import { GameStat } from "@/features/games/components/game-stat"; +import { GameStat } from "@/features/games/core/components/GameStat"; export type SurvivalHudProps = { health: number; diff --git a/apps/frontend/src/features/games/survival/components/words.tsx b/apps/frontend/src/features/games/survival/components/words.tsx deleted file mode 100644 index 91b7ffb..0000000 --- a/apps/frontend/src/features/games/survival/components/words.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import { Index, createEffect, Show } from "solid-js"; - -export type WordsProps = { - words: string[]; - currentWordIndex: number; - currentInput: string; - pastInputs: string[]; - onFieldClick: () => void; -}; - -function WordChars(props: { word: string; input: string; isActive: boolean }) { - return ( - - {(char, i) => { - return ( - - - - - - {char()} - - - ); - }} - - ); -} - -function PastWord(props: { word: string; pastInput: string }) { - return ( - - - props.word.length}> - - {props.pastInput.slice(props.word.length)} - - - - ); -} - -function ActiveWord(props: { word: string; currentInput: string }) { - return ( - - - - - - - - props.word.length}> - - {props.currentInput.slice(props.word.length)} - - - - - ); -} - -export function Words(props: WordsProps) { - let containerRef: HTMLDivElement | undefined; - - createEffect(() => { - if (containerRef && props.currentWordIndex >= 0) { - const el = containerRef.querySelector(".active-word") as HTMLElement; - if (el) { - const scrollTo = - el.offsetTop - containerRef.offsetHeight / 2 + el.offsetHeight / 2; - containerRef.scrollTo({ top: scrollTo, behavior: "smooth" }); - } - } - }); - - return ( -
-
-
- - {(word, i) => ( -
- - - - - - - props.currentWordIndex}> - {word()} - -
- )} -
-
-
-
- ); -} diff --git a/apps/frontend/src/features/games/survival/constants.ts b/apps/frontend/src/features/games/survival/constants.ts new file mode 100644 index 0000000..546ffdb --- /dev/null +++ b/apps/frontend/src/features/games/survival/constants.ts @@ -0,0 +1,11 @@ +import type { DifficultyKey } from "@/features/games/core/types"; + +export const WORD_BATCH = 50; +export const WORD_REFILL_THRESHOLD = 20; +export const INITIAL_HEALTH = 5; +export const DAMAGE: Record = { + easy: 0.5, + medium: 1, + hard: 2.5, +}; +export const SHAKE_DURATION = 300; diff --git a/apps/frontend/src/features/games/survival/engine.ts b/apps/frontend/src/features/games/survival/engine.ts deleted file mode 100644 index e49690f..0000000 --- a/apps/frontend/src/features/games/survival/engine.ts +++ /dev/null @@ -1,270 +0,0 @@ -import { createMemo, onCleanup, createEffect } from "solid-js"; -import { createStore } from "solid-js/store"; -import { getWordBank } from "@/features/content/word-banks/manager"; -import type { WordBankId } from "@/features/content/word-banks/types"; -import type { DifficultyKey, GamePhase } from "@/features/games/types"; -import { getMetrics } from "@/features/games/metrics"; -import { randomWord } from "@/features/games/utils"; - -const WORD_BATCH = 50; -const WORD_REFILL_THRESHOLD = 20; -const TIMER_INTERVAL = 250; -const SHAKE_DURATION = 300; -const INITIAL_HEALTH = 5; -const DAMAGE: Record = { - easy: 0.5, - medium: 1, - hard: 2.5, -}; - -export type UseGameOptions = { - onComplete?: (result: { - gameId: string; - score: number; - difficulty: DifficultyKey; - }) => void; -}; - -type GameState = { - phase: GamePhase; - difficulty: DifficultyKey; - health: number; - isShaking: boolean; - activeWords: string[]; - pastInputs: string[]; - currentWordIndex: number; - currentInput: string; - totalCorrectChars: number; - totalTypedChars: number; - totalErrors: number; - elapsedMs: number; -}; - -const INITIAL_STATE: GameState = { - phase: "idle", - difficulty: "easy", - health: INITIAL_HEALTH, - isShaking: false, - activeWords: [], - pastInputs: [], - currentWordIndex: 0, - currentInput: "", - totalCorrectChars: 0, - totalTypedChars: 0, - totalErrors: 0, - elapsedMs: 0, -}; - -export function useEngine( - wordBankId: WordBankId, - options: UseGameOptions = {}, -) { - const wordBank = getWordBank(wordBankId); - - const [state, setState] = createStore({ ...INITIAL_STATE }); - - let runStartTime = 0; - let timerInterval: number | undefined; - let shakeTimeout: number | undefined; - let inputRef: HTMLInputElement | undefined; - - const generateWords = (count: number) => { - if (!wordBank || !wordBank.words.length) return []; - return Array.from({ length: count }, () => randomWord(wordBank.words)); - }; - - const metrics = createMemo(() => - getMetrics( - state.totalCorrectChars, - state.totalTypedChars, - state.totalErrors, - state.elapsedMs, - ), - ); - - const score = createMemo(() => - Math.floor( - (state.totalCorrectChars * metrics().wpm * metrics().accuracy) / 100, - ), - ); - - const stopTimer = () => { - if (timerInterval !== undefined) { - clearInterval(timerInterval); - timerInterval = undefined; - } - }; - - const triggerShake = () => { - setState("isShaking", true); - if (shakeTimeout !== undefined) clearTimeout(shakeTimeout); - shakeTimeout = window.setTimeout( - () => setState("isShaking", false), - SHAKE_DURATION, - ); - }; - - const takeDamage = (count: number = 1) => { - if (count <= 0) return; - const newHealth = Math.max( - 0, - state.health - DAMAGE[state.difficulty] * count, - ); - setState({ totalErrors: state.totalErrors + count, health: newHealth }); - if (state.phase !== "game-over") triggerShake(); - if (newHealth <= 0 && state.phase !== "game-over") endGame(); - }; - - const endGame = () => { - if (state.phase === "game-over") return; - stopTimer(); - const elapsed = performance.now() - runStartTime; - setState({ phase: "game-over" as const, elapsedMs: elapsed }); - options.onComplete?.({ - gameId: "survival", - score: score(), - difficulty: state.difficulty, - }); - }; - - const resetGame = (nextDiff = state.difficulty) => { - stopTimer(); - runStartTime = 0; - setState({ - ...INITIAL_STATE, - difficulty: nextDiff, - activeWords: generateWords(WORD_BATCH), - }); - if (inputRef) { - inputRef.value = ""; - inputRef.focus(); - } - }; - - const startGame = () => { - setState("phase", "running"); - runStartTime = performance.now(); - timerInterval = window.setInterval(() => { - setState("elapsedMs", performance.now() - runStartTime); - }, TIMER_INTERVAL); - }; - - const handleInput = (e: InputEvent & { currentTarget: HTMLInputElement }) => { - if (state.phase === "game-over") { - e.currentTarget.value = ""; - return; - } - - if (state.phase === "idle") startGame(); - - const value = e.currentTarget.value; - const targetWord = state.activeWords[state.currentWordIndex]; - if (!targetWord) return; - - if ( - e.inputType === "deleteContentBackward" || - e.inputType === "deleteContentForward" || - e.inputType === "deleteWordBackward" || - e.inputType === "deleteWordForward" - ) { - setState("currentInput", value); - return; - } - - setState("totalTypedChars", (t) => t + 1); - - if (value.endsWith(" ")) { - const input = value.trim(); - let correct = 0; - for (let i = 0; i < targetWord.length; i++) { - if (input[i] === targetWord[i]) correct++; - } - const missed = targetWord.length - input.length; - setState({ - totalCorrectChars: state.totalCorrectChars + correct + 1, - pastInputs: [...state.pastInputs, input], - currentWordIndex: state.currentWordIndex + 1, - currentInput: "", - }); - if (missed > 0) takeDamage(missed); - e.currentTarget.value = ""; - - if ( - state.currentWordIndex > - state.activeWords.length - WORD_REFILL_THRESHOLD - ) { - setState("activeWords", (prev) => [ - ...prev, - ...generateWords(WORD_BATCH), - ]); - } - return; - } - - const newChar = value[value.length - 1]; - if ( - newChar !== targetWord[value.length - 1] && - value.length > state.currentInput.length - ) { - takeDamage(1); - } - - setState("currentInput", value); - }; - - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === "Escape") { - e.preventDefault(); - resetGame(); - return; - } - - if ( - e.key === "Enter" && - (state.phase === "idle" || state.phase === "game-over") - ) { - e.preventDefault(); - resetGame(); - startGame(); - } - }; - - onCleanup(() => stopTimer()); - - createEffect(() => { - if (state.activeWords.length === 0 && wordBank) { - setState("activeWords", generateWords(WORD_BATCH)); - } - }); - - return { - game: { - phase: () => state.phase, - difficulty: () => state.difficulty, - health: () => state.health, - isShaking: () => state.isShaking, - }, - metrics: { - wpm: () => metrics().wpm, - accuracy: () => metrics().accuracy, - score, - }, - words: { - activeWords: () => state.activeWords, - pastInputs: () => state.pastInputs, - currentWordIndex: () => state.currentWordIndex, - currentInput: () => state.currentInput, - }, - wordBank, - actions: { - handleInput, - handleKeyDown, - handleDifficultyChange: (diff: DifficultyKey) => resetGame(diff), - setInputRef: (el: HTMLInputElement) => { - inputRef = el; - }, - focusInput: () => inputRef?.focus(), - resetGame, - }, - }; -} diff --git a/apps/frontend/src/features/games/survival/meta.ts b/apps/frontend/src/features/games/survival/meta.ts index 4746e67..a88f9e5 100644 --- a/apps/frontend/src/features/games/survival/meta.ts +++ b/apps/frontend/src/features/games/survival/meta.ts @@ -1,5 +1,5 @@ -import type { GameModule } from "@/features/games/types"; -import View from "./view"; +import type { GameModule } from "@/features/games/core/types"; +import SurvivalView from "./view"; export const meta: GameModule = { id: "survival", @@ -8,5 +8,5 @@ export const meta: GameModule = { defaultWordBankId: "english/core-1k", difficultyKeys: ["easy", "medium", "hard"] as const, minScores: { easy: 15, medium: 10, hard: 5 }, - View, + View: SurvivalView, }; diff --git a/apps/frontend/src/features/games/survival/use-survival-game.ts b/apps/frontend/src/features/games/survival/use-survival-game.ts new file mode 100644 index 0000000..f1475da --- /dev/null +++ b/apps/frontend/src/features/games/survival/use-survival-game.ts @@ -0,0 +1,190 @@ +import { createMemo, onCleanup } from "solid-js"; +import { createStore } from "solid-js/store"; +import { getWordBank } from "@/features/content/word-banks/manager"; +import type { WordBankId } from "@/features/content/word-banks/types"; +import type { DifficultyKey } from "@/features/games/core/types"; +import { createLiveMetrics } from "@/features/games/core/engine/metrics"; +import { createGameStore } from "@/features/games/core/engine/state-machine"; +import { randomWord } from "@/features/games/core/utils"; +import { + WORD_BATCH, + WORD_REFILL_THRESHOLD, + INITIAL_HEALTH, + DAMAGE, + SHAKE_DURATION, +} from "./constants"; + +export type UseGameOptions = { + onComplete?: (result: { + gameId: string; + score: number; + difficulty: DifficultyKey; + }) => void; +}; + +type GameState = { + difficulty: DifficultyKey; + health: number; + isShaking: boolean; +}; + +export function useSurvivalGame( + wordBankId: WordBankId, + options: UseGameOptions = {}, +) { + const wordBank = getWordBank(wordBankId); + + const generateWords = (count: number) => { + if (!wordBank || !wordBank.words.length) return []; + return Array.from({ length: count }, () => randomWord(wordBank.words)); + }; + + const initialWords = generateWords(WORD_BATCH); + const store = createGameStore({ + words: initialWords, + onKeystroke: (event) => { + if (!event.correct) { + takeDamage(1); + } + }, + onWordComplete: ({ expected, typed }) => { + const missed = Math.max(0, expected.length - typed.length); + if (missed > 0) takeDamage(missed); + + if ( + store.state.currentWordIndex > + store.state.words.length - WORD_REFILL_THRESHOLD + ) { + store.appendWords(generateWords(WORD_BATCH)); + } + }, + }); + + const [gameState, setGameState] = createStore({ + difficulty: "easy", + health: INITIAL_HEALTH, + isShaking: false, + }); + + let shakeTimeout: number | undefined; + let inputRef: HTMLInputElement | undefined; + + const triggerShake = () => { + setGameState("isShaking", true); + if (shakeTimeout !== undefined) clearTimeout(shakeTimeout); + shakeTimeout = window.setTimeout( + () => setGameState("isShaking", false), + SHAKE_DURATION, + ); + }; + + const takeDamage = (count: number = 1) => { + if (count <= 0) return; + const newHealth = Math.max( + 0, + gameState.health - DAMAGE[gameState.difficulty] * count, + ); + setGameState({ health: newHealth }); + if (store.state.status !== "finished") { + triggerShake(); + } + if (newHealth <= 0 && store.state.status !== "finished") { + store.finish(); + options.onComplete?.({ + gameId: "survival", + score: score(), + difficulty: gameState.difficulty, + }); + } + }; + + const metrics = createLiveMetrics( + () => store.state.metrics.keystrokes, + () => store.state.metrics.startTime, + ); + + const score = createMemo(() => + Math.floor( + (store.state.metrics.wordResults.reduce( + (acc, w) => acc + w.target.length - w.errors, + 0, + ) * + metrics().correctedWpm * + metrics().accuracy) / + 100, + ), + ); + + const resetGame = (nextDiff = gameState.difficulty) => { + setGameState({ + difficulty: nextDiff, + health: INITIAL_HEALTH, + isShaking: false, + }); + store.reset(); + store.appendWords(generateWords(WORD_BATCH)); + if (inputRef) { + inputRef.value = ""; + inputRef.focus(); + } + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + resetGame(); + return; + } + + if ( + e.key === "Enter" && + (store.state.status === "idle" || store.state.status === "finished") + ) { + e.preventDefault(); + resetGame(); + } + }; + + const handleInput = (e: InputEvent & { currentTarget: HTMLInputElement }) => { + if (store.state.status === "idle") { + const input = e.currentTarget; + if (input.form) { + const submitEvent = new Event("submit", { + bubbles: true, + cancelable: true, + }); + input.form.dispatchEvent(submitEvent); + } + } + }; + + onCleanup(() => { + if (shakeTimeout !== undefined) clearTimeout(shakeTimeout); + }); + + return { + game: { + phase: () => store.state.status, + difficulty: () => gameState.difficulty, + health: () => gameState.health, + isShaking: () => gameState.isShaking, + }, + metrics: { + wpm: () => metrics().correctedWpm, + accuracy: () => metrics().accuracy, + score, + }, + store, + wordBank, + actions: { + handleInput, + handleKeyDown, + handleDifficultyChange: (diff: DifficultyKey) => resetGame(diff), + setInputRef: (el: HTMLInputElement) => { + inputRef = el; + }, + focusInput: () => inputRef?.focus(), + resetGame, + }, + }; +} diff --git a/apps/frontend/src/features/games/survival/view.tsx b/apps/frontend/src/features/games/survival/view.tsx index 2eb5ba9..3901c65 100644 --- a/apps/frontend/src/features/games/survival/view.tsx +++ b/apps/frontend/src/features/games/survival/view.tsx @@ -1,78 +1,112 @@ -import { Show } from "solid-js"; -import type { GameViewProps } from "@/features/games/types"; +import { For, Show } from "solid-js"; +import { useAuthSession } from "@/features/auth/hooks"; +import type { GameViewProps } from "@/features/games/core/types"; +import { useCreateResultMutation } from "@/features/users/results/api"; +import { DifficultySelector } from "@/features/games/core/components/DifficultySelector"; +import { GameMeta } from "@/features/games/core/components/GameMeta"; +import { GameOver } from "@/features/games/core/components/GameOver"; +import { Word } from "@/features/games/core/engine/Word"; +import { useSurvivalGame } from "./use-survival-game"; +import { SurvivalHud } from "./components/survival-hud"; import { meta } from "./meta"; -import { useEngine } from "./engine"; -import { useSubmitGameResult } from "@/features/games/hooks"; -import { DifficultySelector } from "../components/difficulty-selector"; -import { SurvivalHud as Hud } from "./components/survival-hud"; -import { Words } from "./components/words"; -import { GameOver } from "@/features/games/components/game-over"; -import { GameInput } from "../components/game-input"; -import { GameMeta } from "../components/game-meta"; +import "@/features/games/survival/animations.css"; -import "./animations.css"; +const DIFFICULTY_KEYS = meta.difficultyKeys; -function View(props: GameViewProps) { - const saveResult = useSubmitGameResult(meta.minScores); +export default function SurvivalView(props: GameViewProps) { + const auth = useAuthSession(); + const createResultMutation = useCreateResultMutation(); - const { game, metrics, words, actions, wordBank } = useEngine( - props.wordBankId ?? meta.defaultWordBankId, - { onComplete: saveResult }, - ); + const game = useSurvivalGame(props.wordBankId ?? meta.defaultWordBankId, { + onComplete: (result) => { + if (!auth.isAuthenticated()) return; + + if (result.score < meta.minScores[result.difficulty]) { + return; + } + + createResultMutation.mutate({ + gameId: result.gameId, + score: result.score, + difficulty: result.difficulty, + }); + }, + }); + + if (!game.wordBank) { + return ( +
+ Missing word bank. +
+ ); + } return (
- + +
+
- - + + press any key to start +
+ } + > +
+ + {(word, i) => ( + + )} + +
- - -
-
- - - +
+
- + + { + game.actions.handleInput(e); + game.store.onInput(e.currentTarget.value); + }} + onKeyDown={game.actions.handleKeyDown} />
); } - -export default View; diff --git a/apps/frontend/src/features/games/types.ts b/apps/frontend/src/features/games/types.ts deleted file mode 100644 index 7a52859..0000000 --- a/apps/frontend/src/features/games/types.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { Component } from "solid-js"; -import type { WordBankId } from "@/features/content/word-banks/types"; - -export type GameViewProps = { - wordBankId?: WordBankId | null; -}; - -export type GameId = "falling-words" | "survival"; - -export type DifficultyKey = "easy" | "medium" | "hard"; - -export type GamePhase = "idle" | "running" | "game-over"; - -export type GameModule = { - id: GameId; - name: string; - description: string; - defaultWordBankId: WordBankId; - difficultyKeys: readonly DifficultyKey[]; - minScores: Record; - View: Component; -}; diff --git a/apps/frontend/src/features/users/pbs/components/personal-bests.tsx b/apps/frontend/src/features/users/pbs/components/personal-bests.tsx index 2c2417a..0ea8258 100644 --- a/apps/frontend/src/features/users/pbs/components/personal-bests.tsx +++ b/apps/frontend/src/features/users/pbs/components/personal-bests.tsx @@ -1,7 +1,7 @@ import { For } from "solid-js"; -import { gameRegistry } from "@/features/games/registry"; -import type { DifficultyKey } from "@/features/games/types"; +import { gameRegistry } from "@/features/games/core/registry"; +import type { DifficultyKey } from "@/features/games/core/types"; import { formatDateTime } from "@/lib/utils"; import type { UserPBs } from "../types"; diff --git a/apps/frontend/src/features/users/results/components/results-table.tsx b/apps/frontend/src/features/users/results/components/results-table.tsx index ebde059..3d8af4a 100644 --- a/apps/frontend/src/features/users/results/components/results-table.tsx +++ b/apps/frontend/src/features/users/results/components/results-table.tsx @@ -1,5 +1,5 @@ import { Table, type TableColumn } from "@/components/table"; -import { getGameName } from "@/features/games/utils"; +import { getGameName } from "@/features/games/core/utils"; import type { Result } from "../types"; function formatResultsDateTime(value: string | Date) {