From 9da200ebe6d4e38e5f2683dfa1cf7742933880b2 Mon Sep 17 00:00:00 2001 From: d1rshan Date: Sat, 16 May 2026 11:38:38 +0530 Subject: [PATCH 01/13] hmm --- .../src/features/games/core/analyze-word.ts | 27 +++++++++++++++++++ .../games/core/components/character.tsx | 17 ++++++++++++ .../features/games/core/components/word.tsx | 20 ++++++++++++++ .../frontend/src/features/games/core/types.ts | 6 +++++ 4 files changed, 70 insertions(+) create mode 100644 apps/frontend/src/features/games/core/analyze-word.ts create mode 100644 apps/frontend/src/features/games/core/components/character.tsx create mode 100644 apps/frontend/src/features/games/core/components/word.tsx create mode 100644 apps/frontend/src/features/games/core/types.ts diff --git a/apps/frontend/src/features/games/core/analyze-word.ts b/apps/frontend/src/features/games/core/analyze-word.ts new file mode 100644 index 0000000..667dc05 --- /dev/null +++ b/apps/frontend/src/features/games/core/analyze-word.ts @@ -0,0 +1,27 @@ +import type { CharacterState, AnalyzedCharacter } from "./types"; + +export function analyzeWord( + target: string, + input: string, +): AnalyzedCharacter[] { + const result: AnalyzedCharacter[] = []; + + for (let i = 0; i < target.length; i++) { + const targetChar = target[i]!; + let state: CharacterState = "pending"; + + if (i >= input.length) { + if (i === input.length) { + state = "active"; + } + } else if (input[i] === targetChar) { + state = "correct"; + } else { + state = "incorrect"; + } + + result.push({ value: targetChar, state }); + } + + return result; +} diff --git a/apps/frontend/src/features/games/core/components/character.tsx b/apps/frontend/src/features/games/core/components/character.tsx new file mode 100644 index 0000000..163a8e7 --- /dev/null +++ b/apps/frontend/src/features/games/core/components/character.tsx @@ -0,0 +1,17 @@ +import type { CharacterState } from "../types"; + +type CharacterProps = { + char: string; + state: CharacterState; +}; + +const classes: Record = { + correct: "text-green-500", + incorrect: "text-red-500", + active: "border-l border-blue-500", + pending: "text-zinc-500", +}; + +export function Character(props: CharacterProps) { + return {props.char}; +} diff --git a/apps/frontend/src/features/games/core/components/word.tsx b/apps/frontend/src/features/games/core/components/word.tsx new file mode 100644 index 0000000..ddea0ed --- /dev/null +++ b/apps/frontend/src/features/games/core/components/word.tsx @@ -0,0 +1,20 @@ +import { For } from "solid-js"; +import { analyzeWord } from "../analyze-word"; +import { Character } from "./character"; + +type WordProps = { + target: string; + input: string; +}; + +export function Word(props: WordProps) { + const analyzed = () => analyzeWord(props.target, props.input); + + return ( +
+ + {(char) => } + +
+ ); +} 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..b8355ec --- /dev/null +++ b/apps/frontend/src/features/games/core/types.ts @@ -0,0 +1,6 @@ +export type CharacterState = "correct" | "incorrect" | "active" | "pending"; + +export type AnalyzedCharacter = { + value: string; + state: CharacterState; +}; From 63c0b8ae3ee836f4e787ccec98b6fc746c598537 Mon Sep 17 00:00:00 2001 From: d1rshan Date: Sat, 16 May 2026 11:56:06 +0530 Subject: [PATCH 02/13] wip --- .../src/features/games/core/analyze-word.ts | 3 +- .../features/games/core/components/word.tsx | 6 +- .../src/features/games/core/typing-test.tsx | 52 ++++++++++++ .../src/features/games/survival/view.tsx | 79 +------------------ 4 files changed, 60 insertions(+), 80 deletions(-) create mode 100644 apps/frontend/src/features/games/core/typing-test.tsx diff --git a/apps/frontend/src/features/games/core/analyze-word.ts b/apps/frontend/src/features/games/core/analyze-word.ts index 667dc05..60ae1fe 100644 --- a/apps/frontend/src/features/games/core/analyze-word.ts +++ b/apps/frontend/src/features/games/core/analyze-word.ts @@ -3,6 +3,7 @@ import type { CharacterState, AnalyzedCharacter } from "./types"; export function analyzeWord( target: string, input: string, + isActive: boolean, ): AnalyzedCharacter[] { const result: AnalyzedCharacter[] = []; @@ -11,7 +12,7 @@ export function analyzeWord( let state: CharacterState = "pending"; if (i >= input.length) { - if (i === input.length) { + if (isActive && i === input.length) { state = "active"; } } else if (input[i] === targetChar) { diff --git a/apps/frontend/src/features/games/core/components/word.tsx b/apps/frontend/src/features/games/core/components/word.tsx index ddea0ed..da2a035 100644 --- a/apps/frontend/src/features/games/core/components/word.tsx +++ b/apps/frontend/src/features/games/core/components/word.tsx @@ -5,13 +5,15 @@ import { Character } from "./character"; type WordProps = { target: string; input: string; + isActive?: boolean; }; export function Word(props: WordProps) { - const analyzed = () => analyzeWord(props.target, props.input); + const analyzed = () => + analyzeWord(props.target, props.input, props.isActive ?? false); return ( -
+
{(char) => } 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..703e483 --- /dev/null +++ b/apps/frontend/src/features/games/core/typing-test.tsx @@ -0,0 +1,52 @@ +import { For } from "solid-js"; +import { createStore } from "solid-js/store"; +import { getWordBank } from "@/features/content/word-banks/manager"; +import { randomWord } from "@/features/games/utils"; +import { Word } from "./components/word"; + +const WORD_COUNT = 50; + +const wordBank = getWordBank("english/core-1k"); + +export function TypingTest() { + const [state, setState] = createStore({ + words: wordBank + ? Array.from({ length: WORD_COUNT }, () => randomWord(wordBank.words)) + : [], + input: "", + }); + + const typedWords = () => state.input.split(" "); + const currentIndex = () => typedWords().length - 1; + + return ( +
+
+ Word {currentIndex() + 1} / {state.words.length} +
+ +
+ + {(word, i) => ( + + )} + +
+ + setState("input", e.currentTarget.value)} + onKeyDown={(e) => { + if (e.key === "Escape") setState("input", ""); + }} + autofocus + /> +
+ ); +} diff --git a/apps/frontend/src/features/games/survival/view.tsx b/apps/frontend/src/features/games/survival/view.tsx index 2eb5ba9..9fc5f29 100644 --- a/apps/frontend/src/features/games/survival/view.tsx +++ b/apps/frontend/src/features/games/survival/view.tsx @@ -1,78 +1,3 @@ -import { Show } from "solid-js"; -import type { GameViewProps } from "@/features/games/types"; -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 { TypingTest } from "../core/typing-test"; -import "./animations.css"; - -function View(props: GameViewProps) { - const saveResult = useSubmitGameResult(meta.minScores); - - const { game, metrics, words, actions, wordBank } = useEngine( - props.wordBankId ?? meta.defaultWordBankId, - { onComplete: saveResult }, - ); - - return ( -
-
- - -
-
- - - - - - -
-
- - - -
- -
-
- ); -} - -export default View; +export default TypingTest; From 57fa42b01a48e3fc9c6d1e8dca227d07d7e33d11 Mon Sep 17 00:00:00 2001 From: d1rshan Date: Sat, 16 May 2026 22:25:56 +0530 Subject: [PATCH 03/13] metrics and game store --- .../src/features/games/core/metrics.ts | 46 ++++++++ .../src/features/games/core/state-machine.ts | 100 ++++++++++++++++++ .../frontend/src/features/games/core/types.ts | 36 +++++++ .../src/features/games/core/typing-test.tsx | 40 +++++-- 4 files changed, 213 insertions(+), 9 deletions(-) create mode 100644 apps/frontend/src/features/games/core/metrics.ts create mode 100644 apps/frontend/src/features/games/core/state-machine.ts diff --git a/apps/frontend/src/features/games/core/metrics.ts b/apps/frontend/src/features/games/core/metrics.ts new file mode 100644 index 0000000..84cc124 --- /dev/null +++ b/apps/frontend/src/features/games/core/metrics.ts @@ -0,0 +1,46 @@ +import type { KeystrokeEvent, WordResult, GameMetrics } from "./types"; + +export function calcRawWpm( + keystrokes: KeystrokeEvent[], + startTime: number, + endTime: number, +): number { + const minutes = (endTime - startTime) / 60_000; + if (minutes <= 0) return 0; + return Math.round(keystrokes.length / 5 / minutes); +} + +export function calcCorrectedWpm( + wordResults: WordResult[], + startTime: number, + endTime: number, +): number { + const minutes = (endTime - startTime) / 60_000; + if (minutes <= 0) return 0; + const correctWords = wordResults.filter((w) => w.correct).length; + return Math.round(correctWords / minutes); +} + +export function calcAccuracy(keystrokes: KeystrokeEvent[]): number { + if (keystrokes.length === 0) return 100; + const correct = keystrokes.filter((k) => k.correct).length; + return Math.round((correct / keystrokes.length) * 100); +} + +export function buildMetricsSnapshot( + keystrokes: KeystrokeEvent[], + wordResults: WordResult[], + startTime: number, + endTime: number, +): GameMetrics { + return { + rawWpm: calcRawWpm(keystrokes, startTime, endTime), + correctedWpm: calcCorrectedWpm(wordResults, startTime, endTime), + accuracy: calcAccuracy(keystrokes), + errorRate: 0, + keystrokes, + wordResults, + startTime, + endTime, + }; +} diff --git a/apps/frontend/src/features/games/core/state-machine.ts b/apps/frontend/src/features/games/core/state-machine.ts new file mode 100644 index 0000000..4bfb6af --- /dev/null +++ b/apps/frontend/src/features/games/core/state-machine.ts @@ -0,0 +1,100 @@ +import { createStore } from "solid-js/store"; +import type { GameState, KeystrokeEvent, WordResult } from "./types"; +import { buildMetricsSnapshot } from "./metrics"; + +function initMetrics() { + return { + rawWpm: 0, + correctedWpm: 0, + accuracy: 100, + errorRate: 0, + keystrokes: [] as KeystrokeEvent[], + wordResults: [] as WordResult[], + startTime: null as number | null, + endTime: null as number | null, + }; +} + +type GameConfig = { + words: string[]; + isComplete: (state: GameState) => boolean; +}; + +export function createGameStore(config: GameConfig) { + const [state, setState] = createStore({ + status: "idle", + words: config.words, + input: "", + metrics: initMetrics(), + }); + + 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(); + setState("status", "finished"); + setState( + "metrics", + buildMetricsSnapshot( + state.metrics.keystrokes, + buildWordResults(state.words, state.input.split(" ")), + state.metrics.startTime!, + endTime, + ), + ); + } + + function reset() { + setState("status", "idle"); + setState("input", ""); + setState("metrics", initMetrics()); + } + + function onInput(value: string) { + if (state.status === "idle") start(); + if (state.status !== "running") return; + + setState("input", value); + + const typedWords = value.split(" "); + const wordIndex = typedWords.length - 1; + const currentTyped = typedWords[wordIndex] ?? ""; + const currentTarget = state.words[wordIndex] ?? ""; + const charIndex = currentTyped.length - 1; + const lastChar = currentTyped[charIndex]; + + if (lastChar !== undefined) { + const event: KeystrokeEvent = { + key: lastChar, + timestamp: Date.now(), + wordIndex, + charIndex, + correct: lastChar === currentTarget[charIndex], + }; + setState("metrics", "keystrokes", (k) => [...k, event]); + } + + if (config.isComplete(state)) finish(); + } + + return { state, onInput, reset, start, finish }; +} + +function buildWordResults(targets: string[], inputs: string[]): WordResult[] { + return targets.map((target, i) => { + const input = inputs[i] ?? ""; + const errors = [...target].filter((c, j) => input[j] !== c).length; + return { + target, + input, + correct: target === input, + errors, + corrected: 0, + }; + }); +} diff --git a/apps/frontend/src/features/games/core/types.ts b/apps/frontend/src/features/games/core/types.ts index b8355ec..e6c8578 100644 --- a/apps/frontend/src/features/games/core/types.ts +++ b/apps/frontend/src/features/games/core/types.ts @@ -4,3 +4,39 @@ export type AnalyzedCharacter = { value: string; state: CharacterState; }; + +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; + corrected: number; +}; + +export type GameMetrics = { + rawWpm: number; + correctedWpm: number; + accuracy: number; + errorRate: number; + keystrokes: KeystrokeEvent[]; + wordResults: WordResult[]; + startTime: number | null; + endTime: number | null; +}; + +export type GameState = { + status: GameStatus; + words: string[]; + input: string; + metrics: GameMetrics; +}; diff --git a/apps/frontend/src/features/games/core/typing-test.tsx b/apps/frontend/src/features/games/core/typing-test.tsx index 703e483..a730255 100644 --- a/apps/frontend/src/features/games/core/typing-test.tsx +++ b/apps/frontend/src/features/games/core/typing-test.tsx @@ -1,19 +1,23 @@ -import { For } from "solid-js"; -import { createStore } from "solid-js/store"; +import { For, Show } from "solid-js"; import { getWordBank } from "@/features/content/word-banks/manager"; import { randomWord } from "@/features/games/utils"; +import { createGameStore } from "./state-machine"; import { Word } from "./components/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, setState] = createStore({ - words: wordBank - ? Array.from({ length: WORD_COUNT }, () => randomWord(wordBank.words)) - : [], - input: "", + const { state, onInput, reset } = createGameStore({ + words, + isComplete: (s) => { + const typed = s.input.split(" "); + return typed.length === s.words.length && typed[typed.length - 1] !== ""; + }, }); const typedWords = () => state.input.split(" "); @@ -37,13 +41,31 @@ export function TypingTest() {
+ +
+
+ {state.metrics.correctedWpm} wpm + | + {state.metrics.rawWpm} raw + | + {state.metrics.accuracy}% acc +
+ +
+
+ setState("input", e.currentTarget.value)} + onInput={(e) => onInput(e.currentTarget.value)} onKeyDown={(e) => { - if (e.key === "Escape") setState("input", ""); + if (e.key === "Escape") reset(); }} autofocus /> From 1bd1eea1e358cd464b876cc5c0c7204f1489c491 Mon Sep 17 00:00:00 2001 From: d1rshan Date: Sun, 17 May 2026 09:51:22 +0530 Subject: [PATCH 04/13] fix for extra chars --- apps/frontend/src/features/games/core/analyze-word.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/frontend/src/features/games/core/analyze-word.ts b/apps/frontend/src/features/games/core/analyze-word.ts index 60ae1fe..f7e6e21 100644 --- a/apps/frontend/src/features/games/core/analyze-word.ts +++ b/apps/frontend/src/features/games/core/analyze-word.ts @@ -6,8 +6,15 @@ export function analyzeWord( isActive: boolean, ): AnalyzedCharacter[] { const result: AnalyzedCharacter[] = []; + const len = Math.max(target.length, input.length); + + for (let i = 0; i < len; i++) { + if (i >= target.length) { + // extra characters beyond the target word + result.push({ value: input[i]!, state: "incorrect" }); + continue; + } - for (let i = 0; i < target.length; i++) { const targetChar = target[i]!; let state: CharacterState = "pending"; From 128c7a3de834d276483c1e2876906aa9fabeb1c4 Mon Sep 17 00:00:00 2001 From: d1rshan Date: Sun, 17 May 2026 09:56:05 +0530 Subject: [PATCH 05/13] fix colors --- .../games/core/components/character.tsx | 8 +++--- .../src/features/games/core/typing-test.tsx | 26 +++++++++++++------ 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/apps/frontend/src/features/games/core/components/character.tsx b/apps/frontend/src/features/games/core/components/character.tsx index 163a8e7..cd40531 100644 --- a/apps/frontend/src/features/games/core/components/character.tsx +++ b/apps/frontend/src/features/games/core/components/character.tsx @@ -6,10 +6,10 @@ type CharacterProps = { }; const classes: Record = { - correct: "text-green-500", - incorrect: "text-red-500", - active: "border-l border-blue-500", - pending: "text-zinc-500", + correct: "text-(--text)", + incorrect: "text-(--error)", + active: "text-(--sub) shadow-[inset_0_-2px_0_var(--caret)]", + pending: "text-(--sub)", }; export function Character(props: CharacterProps) { diff --git a/apps/frontend/src/features/games/core/typing-test.tsx b/apps/frontend/src/features/games/core/typing-test.tsx index a730255..c2cf67e 100644 --- a/apps/frontend/src/features/games/core/typing-test.tsx +++ b/apps/frontend/src/features/games/core/typing-test.tsx @@ -20,12 +20,14 @@ export function TypingTest() { }, }); + let inputRef: HTMLInputElement | undefined; + const typedWords = () => state.input.split(" "); const currentIndex = () => typedWords().length - 1; return ( -
-
+
+
Word {currentIndex() + 1} / {state.words.length}
@@ -44,22 +46,30 @@ export function TypingTest() {
- {state.metrics.correctedWpm} wpm - | - {state.metrics.rawWpm} raw - | - {state.metrics.accuracy}% acc + {state.metrics.correctedWpm} wpm + | + {state.metrics.rawWpm} raw + | + {state.metrics.accuracy}% acc
+ + Date: Sun, 17 May 2026 12:17:20 +0530 Subject: [PATCH 06/13] word state --- .../src/features/games/core/analyze-word.ts | 41 +++--- .../games/core/components/character.tsx | 2 +- .../features/games/core/components/word.tsx | 38 +++-- .../src/features/games/core/metrics.ts | 47 ++---- .../src/features/games/core/state-machine.ts | 139 +++++++++++++----- .../frontend/src/features/games/core/types.ts | 13 +- .../src/features/games/core/typing-test.tsx | 50 ++++--- 7 files changed, 211 insertions(+), 119 deletions(-) diff --git a/apps/frontend/src/features/games/core/analyze-word.ts b/apps/frontend/src/features/games/core/analyze-word.ts index f7e6e21..7d52283 100644 --- a/apps/frontend/src/features/games/core/analyze-word.ts +++ b/apps/frontend/src/features/games/core/analyze-word.ts @@ -1,34 +1,37 @@ -import type { CharacterState, AnalyzedCharacter } from "./types"; +import type { AnalyzedCharacter, CharacterState } from "./types"; export function analyzeWord( - target: string, - input: string, - isActive: boolean, + expected: string, + typed: string, ): AnalyzedCharacter[] { const result: AnalyzedCharacter[] = []; - const len = Math.max(target.length, input.length); - for (let i = 0; i < len; i++) { - if (i >= target.length) { - // extra characters beyond the target word - result.push({ value: input[i]!, state: "incorrect" }); + 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; } - const targetChar = target[i]!; let state: CharacterState = "pending"; - if (i >= input.length) { - if (isActive && i === input.length) { - state = "active"; - } - } else if (input[i] === targetChar) { - state = "correct"; - } else { - state = "incorrect"; + if (typedChar != null) { + state = typedChar === expectedChar ? "correct" : "incorrect"; } - result.push({ value: targetChar, state }); + result.push({ + value: expectedChar!, + state, + }); } return result; diff --git a/apps/frontend/src/features/games/core/components/character.tsx b/apps/frontend/src/features/games/core/components/character.tsx index cd40531..5369a1c 100644 --- a/apps/frontend/src/features/games/core/components/character.tsx +++ b/apps/frontend/src/features/games/core/components/character.tsx @@ -8,8 +8,8 @@ type CharacterProps = { const classes: Record = { correct: "text-(--text)", incorrect: "text-(--error)", - active: "text-(--sub) shadow-[inset_0_-2px_0_var(--caret)]", pending: "text-(--sub)", + extra: "text-(--error)", }; export function Character(props: CharacterProps) { diff --git a/apps/frontend/src/features/games/core/components/word.tsx b/apps/frontend/src/features/games/core/components/word.tsx index da2a035..567bce5 100644 --- a/apps/frontend/src/features/games/core/components/word.tsx +++ b/apps/frontend/src/features/games/core/components/word.tsx @@ -1,22 +1,42 @@ -import { For } from "solid-js"; +import { Index, Show } from "solid-js"; + import { analyzeWord } from "../analyze-word"; +import type { WordState } from "../types"; + import { Character } from "./character"; type WordProps = { - target: string; - input: string; + word: WordState; isActive?: boolean; }; export function Word(props: WordProps) { - const analyzed = () => - analyzeWord(props.target, props.input, props.isActive ?? false); + const chars = () => analyzeWord(props.word.expected, props.word.typed); return ( -
- - {(char) => } - +
+ + {(char, i) => ( + + + + + + + + )} + + + + = props.word.expected.length + } + > + + +
); } diff --git a/apps/frontend/src/features/games/core/metrics.ts b/apps/frontend/src/features/games/core/metrics.ts index 84cc124..ed51b2e 100644 --- a/apps/frontend/src/features/games/core/metrics.ts +++ b/apps/frontend/src/features/games/core/metrics.ts @@ -1,43 +1,28 @@ import type { KeystrokeEvent, WordResult, GameMetrics } from "./types"; -export function calcRawWpm( - keystrokes: KeystrokeEvent[], - startTime: number, - endTime: number, -): number { - const minutes = (endTime - startTime) / 60_000; - if (minutes <= 0) return 0; - return Math.round(keystrokes.length / 5 / minutes); -} - -export function calcCorrectedWpm( - wordResults: WordResult[], - startTime: number, - endTime: number, -): number { - const minutes = (endTime - startTime) / 60_000; - if (minutes <= 0) return 0; - const correctWords = wordResults.filter((w) => w.correct).length; - return Math.round(correctWords / minutes); -} - -export function calcAccuracy(keystrokes: KeystrokeEvent[]): number { - if (keystrokes.length === 0) return 100; - const correct = keystrokes.filter((k) => k.correct).length; - return Math.round((correct / keystrokes.length) * 100); -} - 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: calcRawWpm(keystrokes, startTime, endTime), - correctedWpm: calcCorrectedWpm(wordResults, startTime, endTime), - accuracy: calcAccuracy(keystrokes), - errorRate: 0, + rawWpm, + correctedWpm, + accuracy, keystrokes, wordResults, startTime, diff --git a/apps/frontend/src/features/games/core/state-machine.ts b/apps/frontend/src/features/games/core/state-machine.ts index 4bfb6af..8b0881a 100644 --- a/apps/frontend/src/features/games/core/state-machine.ts +++ b/apps/frontend/src/features/games/core/state-machine.ts @@ -1,13 +1,18 @@ import { createStore } from "solid-js/store"; -import type { GameState, KeystrokeEvent, WordResult } from "./types"; + import { buildMetricsSnapshot } from "./metrics"; +import type { GameState, KeystrokeEvent, WordResult } from "./types"; + +type GameConfig = { + words: string[]; +}; + function initMetrics() { return { rawWpm: 0, correctedWpm: 0, accuracy: 100, - errorRate: 0, keystrokes: [] as KeystrokeEvent[], wordResults: [] as WordResult[], startTime: null as number | null, @@ -15,34 +20,46 @@ function initMetrics() { }; } -type GameConfig = { - words: string[]; - isComplete: (state: GameState) => boolean; -}; - export function createGameStore(config: GameConfig) { const [state, setState] = createStore({ status: "idle", - words: config.words, - input: "", + + 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(); + + const wordResults = buildWordResults(state.words); + setState("status", "finished"); + setState( "metrics", buildMetricsSnapshot( state.metrics.keystrokes, - buildWordResults(state.words, state.input.split(" ")), + wordResults, state.metrics.startTime!, endTime, ), @@ -51,50 +68,104 @@ export function createGameStore(config: GameConfig) { function reset() { setState("status", "idle"); - setState("input", ""); + + 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; + if (state.status === "idle") { + start(); + } + + if (state.status !== "running") { + return; + } - setState("input", value); + 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; + } - const typedWords = value.split(" "); - const wordIndex = typedWords.length - 1; - const currentTyped = typedWords[wordIndex] ?? ""; - const currentTarget = state.words[wordIndex] ?? ""; - const charIndex = currentTyped.length - 1; - const lastChar = currentTyped[charIndex]; + setState("words", state.currentWordIndex, "typed", value); + + if (current != null && current !== previous) { + const charIndex = value.length - 1; - if (lastChar !== undefined) { const event: KeystrokeEvent = { - key: lastChar, + key: current, timestamp: Date.now(), - wordIndex, + wordIndex: state.currentWordIndex, charIndex, - correct: lastChar === currentTarget[charIndex], + correct: current === word!.expected[charIndex], }; + setState("metrics", "keystrokes", (k) => [...k, event]); } + } + + function nextWord() { + const isLastWord = state.currentWordIndex >= state.words.length - 1; + + if (isLastWord) { + finish(); + return; + } - if (config.isComplete(state)) finish(); + setState("currentWordIndex", (i) => i + 1); } - return { state, onInput, reset, start, finish }; + function previousWord() { + if (state.currentWordIndex <= 0) return; + + setState("currentWordIndex", (i) => i - 1); + } + + return { + state, + currentWord, + onInput, + nextWord, + previousWord, + reset, + }; } -function buildWordResults(targets: string[], inputs: string[]): WordResult[] { - return targets.map((target, i) => { - const input = inputs[i] ?? ""; - const errors = [...target].filter((c, j) => input[j] !== c).length; +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, - input, - correct: target === input, + target: word.expected, + input: word.typed, + correct: word.expected === word.typed, errors, - corrected: 0, }; }); } diff --git a/apps/frontend/src/features/games/core/types.ts b/apps/frontend/src/features/games/core/types.ts index e6c8578..a5ca94e 100644 --- a/apps/frontend/src/features/games/core/types.ts +++ b/apps/frontend/src/features/games/core/types.ts @@ -1,10 +1,15 @@ -export type CharacterState = "correct" | "incorrect" | "active" | "pending"; +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 = { @@ -20,14 +25,12 @@ export type WordResult = { input: string; correct: boolean; errors: number; - corrected: number; }; export type GameMetrics = { rawWpm: number; correctedWpm: number; accuracy: number; - errorRate: number; keystrokes: KeystrokeEvent[]; wordResults: WordResult[]; startTime: number | null; @@ -36,7 +39,7 @@ export type GameMetrics = { export type GameState = { status: GameStatus; - words: string[]; - input: string; + 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 index c2cf67e..d30f23b 100644 --- a/apps/frontend/src/features/games/core/typing-test.tsx +++ b/apps/frontend/src/features/games/core/typing-test.tsx @@ -1,44 +1,39 @@ import { For, Show } from "solid-js"; + import { getWordBank } from "@/features/content/word-banks/manager"; + import { randomWord } from "@/features/games/utils"; + import { createGameStore } from "./state-machine"; + import { Word } from "./components/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, onInput, reset } = createGameStore({ - words, - isComplete: (s) => { - const typed = s.input.split(" "); - return typed.length === s.words.length && typed[typed.length - 1] !== ""; - }, - }); + const { state, currentWord, onInput, nextWord, previousWord, reset } = + createGameStore({ + words, + }); let inputRef: HTMLInputElement | undefined; - const typedWords = () => state.input.split(" "); - const currentIndex = () => typedWords().length - 1; - return (
- Word {currentIndex() + 1} / {state.words.length} + Word {state.currentWordIndex + 1} / {state.words.length}
-
+
{(word, i) => ( - + )}
@@ -52,6 +47,7 @@ export function TypingTest() { | {state.metrics.accuracy}% acc
+
); From 6549467133ae1201912b9f70e86cca1ee7334a43 Mon Sep 17 00:00:00 2001 From: d1rshan Date: Sun, 17 May 2026 13:15:53 +0530 Subject: [PATCH 07/13] word --- .../core/components/{word.tsx => Word.tsx} | 20 ++++++++++++++++--- .../games/core/components/character.tsx | 17 ---------------- .../src/features/games/core/typing-test.tsx | 2 +- 3 files changed, 18 insertions(+), 21 deletions(-) rename apps/frontend/src/features/games/core/components/{word.tsx => Word.tsx} (72%) delete mode 100644 apps/frontend/src/features/games/core/components/character.tsx diff --git a/apps/frontend/src/features/games/core/components/word.tsx b/apps/frontend/src/features/games/core/components/Word.tsx similarity index 72% rename from apps/frontend/src/features/games/core/components/word.tsx rename to apps/frontend/src/features/games/core/components/Word.tsx index 567bce5..6638946 100644 --- a/apps/frontend/src/features/games/core/components/word.tsx +++ b/apps/frontend/src/features/games/core/components/Word.tsx @@ -1,9 +1,7 @@ import { Index, Show } from "solid-js"; import { analyzeWord } from "../analyze-word"; -import type { WordState } from "../types"; - -import { Character } from "./character"; +import type { CharacterState, WordState } from "../types"; type WordProps = { word: WordState; @@ -40,3 +38,19 @@ export function Word(props: WordProps) {
); } + +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/components/character.tsx b/apps/frontend/src/features/games/core/components/character.tsx deleted file mode 100644 index 5369a1c..0000000 --- a/apps/frontend/src/features/games/core/components/character.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import type { CharacterState } from "../types"; - -type CharacterProps = { - char: string; - state: CharacterState; -}; - -const classes: Record = { - correct: "text-(--text)", - incorrect: "text-(--error)", - pending: "text-(--sub)", - extra: "text-(--error)", -}; - -export function Character(props: CharacterProps) { - return {props.char}; -} diff --git a/apps/frontend/src/features/games/core/typing-test.tsx b/apps/frontend/src/features/games/core/typing-test.tsx index d30f23b..a4dc67b 100644 --- a/apps/frontend/src/features/games/core/typing-test.tsx +++ b/apps/frontend/src/features/games/core/typing-test.tsx @@ -6,7 +6,7 @@ import { randomWord } from "@/features/games/utils"; import { createGameStore } from "./state-machine"; -import { Word } from "./components/word"; +import { Word } from "./components/Word"; const WORD_COUNT = 50; From aff571faabcc8cd8f9686089e8be7cec63ac04d9 Mon Sep 17 00:00:00 2001 From: d1rshan Date: Sun, 17 May 2026 13:22:13 +0530 Subject: [PATCH 08/13] game input --- .../games/core/components/GameInput.tsx | 42 +++++++++++++++++++ .../src/features/games/core/typing-test.tsx | 38 +++-------------- 2 files changed, 48 insertions(+), 32 deletions(-) create mode 100644 apps/frontend/src/features/games/core/components/GameInput.tsx 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/core/typing-test.tsx b/apps/frontend/src/features/games/core/typing-test.tsx index a4dc67b..8514be1 100644 --- a/apps/frontend/src/features/games/core/typing-test.tsx +++ b/apps/frontend/src/features/games/core/typing-test.tsx @@ -6,6 +6,7 @@ import { randomWord } from "@/features/games/utils"; import { createGameStore } from "./state-machine"; +import { GameInput } from "./components/GameInput"; import { Word } from "./components/Word"; const WORD_COUNT = 50; @@ -22,8 +23,6 @@ export function TypingTest() { words, }); - let inputRef: HTMLInputElement | undefined; - return (
@@ -57,37 +56,12 @@ export function TypingTest() {
- - - onInput(e.currentTarget.value)} - onKeyDown={(e) => { - if (e.key === "Escape") { - reset(); - return; - } - - if (e.key === " ") { - e.preventDefault(); - nextWord(); - return; - } - - if (e.key === "Backspace" && currentWord()!.typed.length === 0) { - e.preventDefault(); - previousWord(); - } - }} + onInput={onInput} + onNext={nextWord} + onPrevious={previousWord} + onReset={reset} />
); From 5e01552f0aef20715c4b6865609555bb0a31f6ab Mon Sep 17 00:00:00 2001 From: d1rshan Date: Sun, 17 May 2026 13:46:15 +0530 Subject: [PATCH 09/13] live metrics --- .../src/features/games/core/metrics.ts | 37 +++++++++++++++++++ .../src/features/games/core/state-machine.ts | 1 + 2 files changed, 38 insertions(+) diff --git a/apps/frontend/src/features/games/core/metrics.ts b/apps/frontend/src/features/games/core/metrics.ts index ed51b2e..fe9601e 100644 --- a/apps/frontend/src/features/games/core/metrics.ts +++ b/apps/frontend/src/features/games/core/metrics.ts @@ -1,3 +1,4 @@ +import { createMemo, createSignal, onCleanup } from "solid-js"; import type { KeystrokeEvent, WordResult, GameMetrics } from "./types"; export function buildMetricsSnapshot( @@ -29,3 +30,39 @@ export function buildMetricsSnapshot( 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/state-machine.ts b/apps/frontend/src/features/games/core/state-machine.ts index 8b0881a..630bde9 100644 --- a/apps/frontend/src/features/games/core/state-machine.ts +++ b/apps/frontend/src/features/games/core/state-machine.ts @@ -50,6 +50,7 @@ export function createGameStore(config: GameConfig) { if (state.status !== "running") return; const endTime = Date.now(); + // TODO: are we even setting endTime state ? const wordResults = buildWordResults(state.words); From 718ed0ef943ff1fabe710fb278d4be43265ee2e4 Mon Sep 17 00:00:00 2001 From: d1rshan Date: Sun, 17 May 2026 14:06:17 +0530 Subject: [PATCH 10/13] phew --- .../games/components/game-selector.tsx | 29 ------------------ .../components/DifficultySelector.tsx} | 2 -- .../components/GameCards.tsx} | 27 ++++++++++++++++- .../components/GameMeta.tsx} | 0 .../components/GameOver.tsx} | 2 ++ .../components/GameStat.tsx} | 0 .../core/{components => engine}/Word.tsx | 2 +- .../games/core/{ => engine}/analyze-word.ts | 2 +- .../games/core/{ => engine}/metrics.ts | 2 +- .../games/core/{ => engine}/state-machine.ts | 2 +- .../src/features/games/{ => core}/hooks.ts | 0 .../src/features/games/core/registry.ts | 5 ++++ .../frontend/src/features/games/core/types.ts | 23 ++++++++++++++ .../src/features/games/core/typing-test.tsx | 4 +-- .../src/features/games/{ => core}/utils.ts | 0 apps/frontend/src/features/games/metrics.ts | 30 ------------------- apps/frontend/src/features/games/registry.ts | 10 ------- .../{ => temp}/components/game-input.tsx | 0 .../components/falling-words-field.tsx | 0 .../components/falling-words-hud.tsx | 0 .../{ => temp}/falling-words/difficulty.ts | 0 .../games/{ => temp}/falling-words/engine.ts | 0 .../games/{ => temp}/falling-words/index.ts | 0 .../games/{ => temp}/falling-words/meta.ts | 0 .../games/{ => temp}/falling-words/types.ts | 0 .../falling-words/use-falling-words-game.ts | 0 .../games/{ => temp}/falling-words/view.tsx | 0 .../frontend/src/features/games/temp/hooks.ts | 28 +++++++++++++++++ .../games/{ => temp}/survival/animations.css | 0 .../{ => temp}/survival/components/heart.tsx | 0 .../survival/components/survival-hud.tsx | 0 .../{ => temp}/survival/components/words.tsx | 0 .../games/{ => temp}/survival/engine.ts | 0 .../games/{ => temp}/survival/index.ts | 0 .../games/{ => temp}/survival/meta.ts | 0 .../games/{ => temp}/survival/view.tsx | 0 apps/frontend/src/features/games/types.ts | 22 -------------- 37 files changed, 90 insertions(+), 100 deletions(-) delete mode 100644 apps/frontend/src/features/games/components/game-selector.tsx rename apps/frontend/src/features/games/{components/difficulty-selector.tsx => core/components/DifficultySelector.tsx} (95%) rename apps/frontend/src/features/games/{components/game-card.tsx => core/components/GameCards.tsx} (58%) rename apps/frontend/src/features/games/{components/game-meta.tsx => core/components/GameMeta.tsx} (100%) rename apps/frontend/src/features/games/{components/game-over.tsx => core/components/GameOver.tsx} (92%) rename apps/frontend/src/features/games/{components/game-stat.tsx => core/components/GameStat.tsx} (100%) rename apps/frontend/src/features/games/core/{components => engine}/Word.tsx (96%) rename apps/frontend/src/features/games/core/{ => engine}/analyze-word.ts (91%) rename apps/frontend/src/features/games/core/{ => engine}/metrics.ts (96%) rename apps/frontend/src/features/games/core/{ => engine}/state-machine.ts (97%) rename apps/frontend/src/features/games/{ => core}/hooks.ts (100%) create mode 100644 apps/frontend/src/features/games/core/registry.ts rename apps/frontend/src/features/games/{ => core}/utils.ts (100%) delete mode 100644 apps/frontend/src/features/games/metrics.ts delete mode 100644 apps/frontend/src/features/games/registry.ts rename apps/frontend/src/features/games/{ => temp}/components/game-input.tsx (100%) rename apps/frontend/src/features/games/{ => temp}/falling-words/components/falling-words-field.tsx (100%) rename apps/frontend/src/features/games/{ => temp}/falling-words/components/falling-words-hud.tsx (100%) rename apps/frontend/src/features/games/{ => temp}/falling-words/difficulty.ts (100%) rename apps/frontend/src/features/games/{ => temp}/falling-words/engine.ts (100%) rename apps/frontend/src/features/games/{ => temp}/falling-words/index.ts (100%) rename apps/frontend/src/features/games/{ => temp}/falling-words/meta.ts (100%) rename apps/frontend/src/features/games/{ => temp}/falling-words/types.ts (100%) rename apps/frontend/src/features/games/{ => temp}/falling-words/use-falling-words-game.ts (100%) rename apps/frontend/src/features/games/{ => temp}/falling-words/view.tsx (100%) create mode 100644 apps/frontend/src/features/games/temp/hooks.ts rename apps/frontend/src/features/games/{ => temp}/survival/animations.css (100%) rename apps/frontend/src/features/games/{ => temp}/survival/components/heart.tsx (100%) rename apps/frontend/src/features/games/{ => temp}/survival/components/survival-hud.tsx (100%) rename apps/frontend/src/features/games/{ => temp}/survival/components/words.tsx (100%) rename apps/frontend/src/features/games/{ => temp}/survival/engine.ts (100%) rename apps/frontend/src/features/games/{ => temp}/survival/index.ts (100%) rename apps/frontend/src/features/games/{ => temp}/survival/meta.ts (100%) rename apps/frontend/src/features/games/{ => temp}/survival/view.tsx (100%) delete mode 100644 apps/frontend/src/features/games/types.ts 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/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/components/Word.tsx b/apps/frontend/src/features/games/core/engine/Word.tsx similarity index 96% rename from apps/frontend/src/features/games/core/components/Word.tsx rename to apps/frontend/src/features/games/core/engine/Word.tsx index 6638946..676f6d4 100644 --- a/apps/frontend/src/features/games/core/components/Word.tsx +++ b/apps/frontend/src/features/games/core/engine/Word.tsx @@ -1,6 +1,6 @@ import { Index, Show } from "solid-js"; -import { analyzeWord } from "../analyze-word"; +import { analyzeWord } from "./analyze-word"; import type { CharacterState, WordState } from "../types"; type WordProps = { diff --git a/apps/frontend/src/features/games/core/analyze-word.ts b/apps/frontend/src/features/games/core/engine/analyze-word.ts similarity index 91% rename from apps/frontend/src/features/games/core/analyze-word.ts rename to apps/frontend/src/features/games/core/engine/analyze-word.ts index 7d52283..d3d2f68 100644 --- a/apps/frontend/src/features/games/core/analyze-word.ts +++ b/apps/frontend/src/features/games/core/engine/analyze-word.ts @@ -1,4 +1,4 @@ -import type { AnalyzedCharacter, CharacterState } from "./types"; +import type { AnalyzedCharacter, CharacterState } from "../types"; export function analyzeWord( expected: string, diff --git a/apps/frontend/src/features/games/core/metrics.ts b/apps/frontend/src/features/games/core/engine/metrics.ts similarity index 96% rename from apps/frontend/src/features/games/core/metrics.ts rename to apps/frontend/src/features/games/core/engine/metrics.ts index fe9601e..5b0c7a2 100644 --- a/apps/frontend/src/features/games/core/metrics.ts +++ b/apps/frontend/src/features/games/core/engine/metrics.ts @@ -1,5 +1,5 @@ import { createMemo, createSignal, onCleanup } from "solid-js"; -import type { KeystrokeEvent, WordResult, GameMetrics } from "./types"; +import type { KeystrokeEvent, WordResult, GameMetrics } from "../types"; export function buildMetricsSnapshot( keystrokes: KeystrokeEvent[], diff --git a/apps/frontend/src/features/games/core/state-machine.ts b/apps/frontend/src/features/games/core/engine/state-machine.ts similarity index 97% rename from apps/frontend/src/features/games/core/state-machine.ts rename to apps/frontend/src/features/games/core/engine/state-machine.ts index 630bde9..c5b297b 100644 --- a/apps/frontend/src/features/games/core/state-machine.ts +++ b/apps/frontend/src/features/games/core/engine/state-machine.ts @@ -2,7 +2,7 @@ import { createStore } from "solid-js/store"; import { buildMetricsSnapshot } from "./metrics"; -import type { GameState, KeystrokeEvent, WordResult } from "./types"; +import type { GameState, KeystrokeEvent, WordResult } from "../types"; type GameConfig = { words: string[]; diff --git a/apps/frontend/src/features/games/hooks.ts b/apps/frontend/src/features/games/core/hooks.ts similarity index 100% rename from apps/frontend/src/features/games/hooks.ts rename to apps/frontend/src/features/games/core/hooks.ts 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..bb29252 --- /dev/null +++ b/apps/frontend/src/features/games/core/registry.ts @@ -0,0 +1,5 @@ +import type { GameId, GameModule } from "@/features/games/types"; + +export const games: Record = {}; + +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 index a5ca94e..6ac728a 100644 --- a/apps/frontend/src/features/games/core/types.ts +++ b/apps/frontend/src/features/games/core/types.ts @@ -1,3 +1,26 @@ +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; +}; + export type CharacterState = "correct" | "incorrect" | "pending" | "extra"; export type AnalyzedCharacter = { diff --git a/apps/frontend/src/features/games/core/typing-test.tsx b/apps/frontend/src/features/games/core/typing-test.tsx index 8514be1..108ba34 100644 --- a/apps/frontend/src/features/games/core/typing-test.tsx +++ b/apps/frontend/src/features/games/core/typing-test.tsx @@ -4,10 +4,10 @@ import { getWordBank } from "@/features/content/word-banks/manager"; import { randomWord } from "@/features/games/utils"; -import { createGameStore } from "./state-machine"; +import { createGameStore } from "./engine/state-machine"; import { GameInput } from "./components/GameInput"; -import { Word } from "./components/Word"; +import { Word } from "./engine/Word"; const WORD_COUNT = 50; 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/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/components/game-input.tsx b/apps/frontend/src/features/games/temp/components/game-input.tsx similarity index 100% rename from apps/frontend/src/features/games/components/game-input.tsx rename to apps/frontend/src/features/games/temp/components/game-input.tsx diff --git a/apps/frontend/src/features/games/falling-words/components/falling-words-field.tsx b/apps/frontend/src/features/games/temp/falling-words/components/falling-words-field.tsx similarity index 100% rename from apps/frontend/src/features/games/falling-words/components/falling-words-field.tsx rename to apps/frontend/src/features/games/temp/falling-words/components/falling-words-field.tsx diff --git a/apps/frontend/src/features/games/falling-words/components/falling-words-hud.tsx b/apps/frontend/src/features/games/temp/falling-words/components/falling-words-hud.tsx similarity index 100% rename from apps/frontend/src/features/games/falling-words/components/falling-words-hud.tsx rename to apps/frontend/src/features/games/temp/falling-words/components/falling-words-hud.tsx diff --git a/apps/frontend/src/features/games/falling-words/difficulty.ts b/apps/frontend/src/features/games/temp/falling-words/difficulty.ts similarity index 100% rename from apps/frontend/src/features/games/falling-words/difficulty.ts rename to apps/frontend/src/features/games/temp/falling-words/difficulty.ts diff --git a/apps/frontend/src/features/games/falling-words/engine.ts b/apps/frontend/src/features/games/temp/falling-words/engine.ts similarity index 100% rename from apps/frontend/src/features/games/falling-words/engine.ts rename to apps/frontend/src/features/games/temp/falling-words/engine.ts diff --git a/apps/frontend/src/features/games/falling-words/index.ts b/apps/frontend/src/features/games/temp/falling-words/index.ts similarity index 100% rename from apps/frontend/src/features/games/falling-words/index.ts rename to apps/frontend/src/features/games/temp/falling-words/index.ts diff --git a/apps/frontend/src/features/games/falling-words/meta.ts b/apps/frontend/src/features/games/temp/falling-words/meta.ts similarity index 100% rename from apps/frontend/src/features/games/falling-words/meta.ts rename to apps/frontend/src/features/games/temp/falling-words/meta.ts diff --git a/apps/frontend/src/features/games/falling-words/types.ts b/apps/frontend/src/features/games/temp/falling-words/types.ts similarity index 100% rename from apps/frontend/src/features/games/falling-words/types.ts rename to apps/frontend/src/features/games/temp/falling-words/types.ts diff --git a/apps/frontend/src/features/games/falling-words/use-falling-words-game.ts b/apps/frontend/src/features/games/temp/falling-words/use-falling-words-game.ts similarity index 100% rename from apps/frontend/src/features/games/falling-words/use-falling-words-game.ts rename to apps/frontend/src/features/games/temp/falling-words/use-falling-words-game.ts diff --git a/apps/frontend/src/features/games/falling-words/view.tsx b/apps/frontend/src/features/games/temp/falling-words/view.tsx similarity index 100% rename from apps/frontend/src/features/games/falling-words/view.tsx rename to apps/frontend/src/features/games/temp/falling-words/view.tsx diff --git a/apps/frontend/src/features/games/temp/hooks.ts b/apps/frontend/src/features/games/temp/hooks.ts new file mode 100644 index 0000000..5a562e6 --- /dev/null +++ b/apps/frontend/src/features/games/temp/hooks.ts @@ -0,0 +1,28 @@ +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"; + +export function useSubmitGameResult(minScores: Record) { + const auth = useAuthSession(); + const mutation = useCreateResultMutation(); + + const submit = (result: { + gameId: string; + score: number; + difficulty: DifficultyKey; + }) => { + if (!auth.isAuthenticated()) return; + + if (result.score < minScores[result.difficulty]) { + toast.info( + `Result not saved. Test too short. Minimum score for ${result.difficulty} is ${minScores[result.difficulty]}.`, + ); + return; + } + + mutation.mutate(result); + }; + + return submit; +} diff --git a/apps/frontend/src/features/games/survival/animations.css b/apps/frontend/src/features/games/temp/survival/animations.css similarity index 100% rename from apps/frontend/src/features/games/survival/animations.css rename to apps/frontend/src/features/games/temp/survival/animations.css diff --git a/apps/frontend/src/features/games/survival/components/heart.tsx b/apps/frontend/src/features/games/temp/survival/components/heart.tsx similarity index 100% rename from apps/frontend/src/features/games/survival/components/heart.tsx rename to apps/frontend/src/features/games/temp/survival/components/heart.tsx diff --git a/apps/frontend/src/features/games/survival/components/survival-hud.tsx b/apps/frontend/src/features/games/temp/survival/components/survival-hud.tsx similarity index 100% rename from apps/frontend/src/features/games/survival/components/survival-hud.tsx rename to apps/frontend/src/features/games/temp/survival/components/survival-hud.tsx diff --git a/apps/frontend/src/features/games/survival/components/words.tsx b/apps/frontend/src/features/games/temp/survival/components/words.tsx similarity index 100% rename from apps/frontend/src/features/games/survival/components/words.tsx rename to apps/frontend/src/features/games/temp/survival/components/words.tsx diff --git a/apps/frontend/src/features/games/survival/engine.ts b/apps/frontend/src/features/games/temp/survival/engine.ts similarity index 100% rename from apps/frontend/src/features/games/survival/engine.ts rename to apps/frontend/src/features/games/temp/survival/engine.ts diff --git a/apps/frontend/src/features/games/survival/index.ts b/apps/frontend/src/features/games/temp/survival/index.ts similarity index 100% rename from apps/frontend/src/features/games/survival/index.ts rename to apps/frontend/src/features/games/temp/survival/index.ts diff --git a/apps/frontend/src/features/games/survival/meta.ts b/apps/frontend/src/features/games/temp/survival/meta.ts similarity index 100% rename from apps/frontend/src/features/games/survival/meta.ts rename to apps/frontend/src/features/games/temp/survival/meta.ts diff --git a/apps/frontend/src/features/games/survival/view.tsx b/apps/frontend/src/features/games/temp/survival/view.tsx similarity index 100% rename from apps/frontend/src/features/games/survival/view.tsx rename to apps/frontend/src/features/games/temp/survival/view.tsx 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; -}; From fd16c021de8dec67d163bdca16cc550bb89ebd09 Mon Sep 17 00:00:00 2001 From: d1rshan Date: Mon, 18 May 2026 14:38:00 +0530 Subject: [PATCH 11/13] hmm --- apps/frontend/src/app/app.tsx | 12 +- apps/frontend/src/app/pages/home.tsx | 8 +- apps/frontend/src/app/pages/leaderboard.tsx | 8 +- .../src/features/commandline/registry.ts | 11 +- .../games/core/engine/state-machine.ts | 31 ++- .../frontend/src/features/games/core/hooks.ts | 2 +- .../src/features/games/core/registry.ts | 8 +- .../frontend/src/features/games/core/types.ts | 2 +- .../src/features/games/core/typing-test.tsx | 26 ++- .../features/games/survival/animations.css | 10 + .../games/survival/components/heart.tsx | 129 ++++++++++++ .../survival/components/survival-hud.tsx | 46 +++++ .../src/features/games/survival/constants.ts | 11 + .../src/features/games/survival/index.ts | 1 + .../src/features/games/survival/meta.ts | 12 ++ .../games/survival/use-survival-game.ts | 190 ++++++++++++++++++ .../src/features/games/survival/view.tsx | 112 +++++++++++ .../components/falling-words-hud.tsx | 2 +- .../features/games/temp/falling-words/meta.ts | 2 +- .../games/temp/falling-words/view.tsx | 2 +- .../temp/survival/components/survival-hud.tsx | 2 +- .../src/features/games/temp/survival/view.tsx | 2 +- .../users/pbs/components/personal-bests.tsx | 4 +- .../results/components/results-table.tsx | 2 +- 24 files changed, 586 insertions(+), 49 deletions(-) create mode 100644 apps/frontend/src/features/games/survival/animations.css create mode 100644 apps/frontend/src/features/games/survival/components/heart.tsx create mode 100644 apps/frontend/src/features/games/survival/components/survival-hud.tsx create mode 100644 apps/frontend/src/features/games/survival/constants.ts create mode 100644 apps/frontend/src/features/games/survival/index.ts create mode 100644 apps/frontend/src/features/games/survival/meta.ts create mode 100644 apps/frontend/src/features/games/survival/use-survival-game.ts create mode 100644 apps/frontend/src/features/games/survival/view.tsx diff --git a/apps/frontend/src/app/app.tsx b/apps/frontend/src/app/app.tsx index e3d90e7..dcad614 100644 --- a/apps/frontend/src/app/app.tsx +++ b/apps/frontend/src/app/app.tsx @@ -1,14 +1,8 @@ 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 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..672651b 100644 --- a/apps/frontend/src/features/commandline/registry.ts +++ b/apps/frontend/src/features/commandline/registry.ts @@ -2,14 +2,9 @@ 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 { themeManager } from "@/features/content/themes/manager"; import { useAuthSession } from "@/features/auth/hooks"; diff --git a/apps/frontend/src/features/games/core/engine/state-machine.ts b/apps/frontend/src/features/games/core/engine/state-machine.ts index c5b297b..5fb69cd 100644 --- a/apps/frontend/src/features/games/core/engine/state-machine.ts +++ b/apps/frontend/src/features/games/core/engine/state-machine.ts @@ -4,8 +4,10 @@ import { buildMetricsSnapshot } from "./metrics"; import type { GameState, KeystrokeEvent, WordResult } from "../types"; -type GameConfig = { +export type GameConfig = { words: string[]; + onKeystroke?: (event: KeystrokeEvent) => void; + onWordComplete?: (word: { expected: string; typed: string }) => void; }; function initMetrics() { @@ -121,15 +123,22 @@ export function createGameStore(config: GameConfig) { }; setState("metrics", "keystrokes", (k) => [...k, event]); + + config.onKeystroke?.(event); } } - function nextWord() { - const isLastWord = state.currentWordIndex >= state.words.length - 1; + function isLastWord() { + return state.currentWordIndex >= state.words.length - 1; + } - if (isLastWord) { - finish(); - return; + function nextWord() { + const currentWord = state.words[state.currentWordIndex]; + if (currentWord) { + config.onWordComplete?.({ + expected: currentWord.expected, + typed: currentWord.typed, + }); } setState("currentWordIndex", (i) => i + 1); @@ -141,6 +150,13 @@ export function createGameStore(config: GameConfig) { setState("currentWordIndex", (i) => i - 1); } + function appendWords(newWords: string[]) { + setState("words", (prev) => [ + ...prev, + ...newWords.map((word) => ({ expected: word, typed: "" })), + ]); + } + return { state, currentWord, @@ -148,6 +164,9 @@ export function createGameStore(config: GameConfig) { nextWord, previousWord, reset, + finish, + isLastWord, + appendWords, }; } diff --git a/apps/frontend/src/features/games/core/hooks.ts b/apps/frontend/src/features/games/core/hooks.ts index 5a562e6..7ef2922 100644 --- a/apps/frontend/src/features/games/core/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 index bb29252..9869fea 100644 --- a/apps/frontend/src/features/games/core/registry.ts +++ b/apps/frontend/src/features/games/core/registry.ts @@ -1,5 +1,9 @@ -import type { GameId, GameModule } from "@/features/games/types"; +import type { GameId, GameModule } from "@/features/games/core/types"; -export const games: Record = {}; +import { meta as survivalMeta } from "@/features/games/survival/meta"; + +export const games: Record = { + survival: survivalMeta, +}; 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 index 6ac728a..cf639d7 100644 --- a/apps/frontend/src/features/games/core/types.ts +++ b/apps/frontend/src/features/games/core/types.ts @@ -5,7 +5,7 @@ export type GameViewProps = { wordBankId?: WordBankId | null; }; -export type GameId = "falling-words" | "survival"; +export type GameId = "survival"; export type DifficultyKey = "easy" | "medium" | "hard"; diff --git a/apps/frontend/src/features/games/core/typing-test.tsx b/apps/frontend/src/features/games/core/typing-test.tsx index 108ba34..6b5abf7 100644 --- a/apps/frontend/src/features/games/core/typing-test.tsx +++ b/apps/frontend/src/features/games/core/typing-test.tsx @@ -2,7 +2,7 @@ import { For, Show } from "solid-js"; import { getWordBank } from "@/features/content/word-banks/manager"; -import { randomWord } from "@/features/games/utils"; +import { randomWord } from "@/features/games/core/utils"; import { createGameStore } from "./engine/state-machine"; @@ -18,10 +18,18 @@ const words = wordBank : []; export function TypingTest() { - const { state, currentWord, onInput, nextWord, previousWord, reset } = - createGameStore({ - words, - }); + const { + state, + currentWord, + onInput, + nextWord, + previousWord, + reset, + finish, + isLastWord, + } = createGameStore({ + words, + }); return (
@@ -59,7 +67,13 @@ export function TypingTest() { { + if (isLastWord()) { + finish(); + } else { + nextWord(); + } + }} onPrevious={previousWord} onReset={reset} /> diff --git a/apps/frontend/src/features/games/survival/animations.css b/apps/frontend/src/features/games/survival/animations.css new file mode 100644 index 0000000..cee2aa6 --- /dev/null +++ b/apps/frontend/src/features/games/survival/animations.css @@ -0,0 +1,10 @@ +@keyframes damage { + 10%, 90% { transform: translate3d(-2px, 0, 0); } + 20%, 80% { transform: translate3d(4px, 0, 0); } + 30%, 50%, 70% { transform: translate3d(-8px, 0, 0); } + 40%, 60% { transform: translate3d(8px, 0, 0); } +} + +.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/heart.tsx b/apps/frontend/src/features/games/survival/components/heart.tsx new file mode 100644 index 0000000..0314816 --- /dev/null +++ b/apps/frontend/src/features/games/survival/components/heart.tsx @@ -0,0 +1,129 @@ +import { For } from "solid-js"; + +export type HeartProps = { + state: "full" | "half" | "empty"; + isDamaged?: boolean; + class?: string; +}; + +const S = 6; + +const outlines = [ + { x: 18, y: 6 }, + { x: 12, y: 6 }, + { x: 24, y: 12 }, + { x: 30, y: 12 }, + { x: 36, y: 6 }, + { x: 42, y: 6 }, + { x: 48, y: 6 }, + { x: 54, y: 12 }, + { x: 54, y: 18 }, + { x: 6, y: 6 }, + { x: 0, y: 12 }, + { x: 0, y: 18 }, + { x: 0, y: 24 }, + { x: 54, y: 24 }, + { x: 6, y: 30 }, + { x: 12, y: 36 }, + { x: 18, y: 42 }, + { x: 24, y: 48 }, + { x: 30, y: 48 }, + { x: 36, y: 42 }, + { x: 42, y: 36 }, + { x: 48, y: 30 }, +]; + +const highlights = [{ x: 12, y: 18 }]; +const shadowFills = [ + { x: 6, y: 12 }, + { x: 6, y: 18 }, + { x: 6, y: 24 }, + { x: 12, y: 30 }, + { x: 18, y: 36 }, + { x: 24, y: 42 }, +]; +const normalFills = [ + { x: 12, y: 12 }, + { x: 18, y: 12 }, + { x: 18, y: 18 }, + { x: 12, y: 24 }, + { x: 18, y: 24 }, + { x: 18, y: 30 }, + { x: 24, y: 36 }, + { x: 30, y: 42 }, + { x: 30, y: 36 }, + { x: 24, y: 30 }, + { x: 30, y: 30 }, + { x: 30, y: 24 }, + { x: 24, y: 24 }, + { x: 24, y: 18 }, + { x: 30, y: 18 }, + { x: 36, y: 24 }, + { x: 42, y: 30 }, + { x: 36, y: 30 }, + { x: 36, y: 36 }, + { x: 42, y: 24 }, + { x: 36, y: 18 }, + { x: 42, y: 18 }, +]; +const lightFills = [ + { x: 48, y: 24 }, + { x: 48, y: 18 }, + { x: 48, y: 12 }, + { x: 42, y: 12 }, + { x: 36, y: 12 }, +]; + +const fillGroups = [ + { pixels: highlights, color: "#ffffff" }, + { pixels: shadowFills, color: "#9d0000" }, + { pixels: normalFills, color: "#ff0000" }, + { pixels: lightFills, color: "#ff5757" }, +]; + +export function Heart(props: HeartProps) { + const fillColor = (x: number, color: string) => { + if (props.state === "full") return color; + if (props.state === "empty") return "#333333"; + return x < 30 ? color : "#333333"; + }; + + return ( + + + {(p) => ( + + )} + + + {(group) => ( + + {(p) => ( + + )} + + )} + + + ); +} diff --git a/apps/frontend/src/features/games/survival/components/survival-hud.tsx b/apps/frontend/src/features/games/survival/components/survival-hud.tsx new file mode 100644 index 0000000..797867f --- /dev/null +++ b/apps/frontend/src/features/games/survival/components/survival-hud.tsx @@ -0,0 +1,46 @@ +import { Index } from "solid-js"; +import { Heart } from "./heart"; +import { GameStat } from "@/features/games/core/components/GameStat"; + +export type SurvivalHudProps = { + health: number; + score: number; + wpm: number; + accuracy: number; + isTakingDamage?: boolean; +}; + +export function SurvivalHud(props: SurvivalHudProps) { + return ( +
+ + + + +
+
+ + health + +
+
+ + {(_, i) => ( + = 1 + ? "full" + : props.health - i > 0 + ? "half" + : "empty" + } + isDamaged={props.isTakingDamage} + class="h-6 w-6" + /> + )} + +
+
+
+ ); +} 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/index.ts b/apps/frontend/src/features/games/survival/index.ts new file mode 100644 index 0000000..b814f91 --- /dev/null +++ b/apps/frontend/src/features/games/survival/index.ts @@ -0,0 +1 @@ +export { meta } from "./meta"; diff --git a/apps/frontend/src/features/games/survival/meta.ts b/apps/frontend/src/features/games/survival/meta.ts new file mode 100644 index 0000000..a88f9e5 --- /dev/null +++ b/apps/frontend/src/features/games/survival/meta.ts @@ -0,0 +1,12 @@ +import type { GameModule } from "@/features/games/core/types"; +import SurvivalView from "./view"; + +export const meta: GameModule = { + id: "survival", + name: "Survival", + description: "Type as fast as you can to survive without making mistakes.", + defaultWordBankId: "english/core-1k", + difficultyKeys: ["easy", "medium", "hard"] as const, + minScores: { easy: 15, medium: 10, hard: 5 }, + 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 new file mode 100644 index 0000000..3901c65 --- /dev/null +++ b/apps/frontend/src/features/games/survival/view.tsx @@ -0,0 +1,112 @@ +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 "@/features/games/survival/animations.css"; + +const DIFFICULTY_KEYS = meta.difficultyKeys; + +export default function SurvivalView(props: GameViewProps) { + const auth = useAuthSession(); + const createResultMutation = useCreateResultMutation(); + + 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} + /> +
+
+ ); +} diff --git a/apps/frontend/src/features/games/temp/falling-words/components/falling-words-hud.tsx b/apps/frontend/src/features/games/temp/falling-words/components/falling-words-hud.tsx index d6781e6..41e8513 100644 --- a/apps/frontend/src/features/games/temp/falling-words/components/falling-words-hud.tsx +++ b/apps/frontend/src/features/games/temp/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/temp/falling-words/meta.ts b/apps/frontend/src/features/games/temp/falling-words/meta.ts index 613c6e9..bfc07cf 100644 --- a/apps/frontend/src/features/games/temp/falling-words/meta.ts +++ b/apps/frontend/src/features/games/temp/falling-words/meta.ts @@ -2,7 +2,7 @@ import type { GameModule } from "@/features/games/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/temp/falling-words/view.tsx b/apps/frontend/src/features/games/temp/falling-words/view.tsx index 7b1e59a..02ef27a 100644 --- a/apps/frontend/src/features/games/temp/falling-words/view.tsx +++ b/apps/frontend/src/features/games/temp/falling-words/view.tsx @@ -4,7 +4,7 @@ import { useAuthSession } from "@/features/auth/hooks"; import type { GameViewProps, DifficultyKey } from "@/features/games/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/temp/survival/components/survival-hud.tsx b/apps/frontend/src/features/games/temp/survival/components/survival-hud.tsx index 96d7d8c..797867f 100644 --- a/apps/frontend/src/features/games/temp/survival/components/survival-hud.tsx +++ b/apps/frontend/src/features/games/temp/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/temp/survival/view.tsx b/apps/frontend/src/features/games/temp/survival/view.tsx index 9fc5f29..9d49c74 100644 --- a/apps/frontend/src/features/games/temp/survival/view.tsx +++ b/apps/frontend/src/features/games/temp/survival/view.tsx @@ -1,3 +1,3 @@ -import { TypingTest } from "../core/typing-test"; +import { TypingTest } from "@/features/games/core/typing-test"; export default TypingTest; 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) { From 94b45d6da6b3930c6857f7620e0066c33c19144a Mon Sep 17 00:00:00 2001 From: d1rshan Date: Mon, 18 May 2026 14:49:27 +0530 Subject: [PATCH 12/13] falling words --- .../src/features/games/core/registry.ts | 2 + .../frontend/src/features/games/core/types.ts | 2 +- .../components/falling-words-field.tsx | 0 .../components/falling-words-hud.tsx | 0 .../{temp => }/falling-words/difficulty.ts | 0 .../games/{temp => }/falling-words/engine.ts | 0 .../games/{temp => }/falling-words/index.ts | 0 .../games/{temp => }/falling-words/meta.ts | 2 +- .../games/{temp => }/falling-words/types.ts | 2 +- .../falling-words/use-falling-words-game.ts | 0 .../games/{temp => }/falling-words/view.tsx | 2 +- .../games/temp/components/game-input.tsx | 22 -- .../frontend/src/features/games/temp/hooks.ts | 28 -- .../games/temp/survival/animations.css | 10 - .../games/temp/survival/components/heart.tsx | 129 --------- .../temp/survival/components/survival-hud.tsx | 46 --- .../games/temp/survival/components/words.tsx | 123 -------- .../features/games/temp/survival/engine.ts | 270 ------------------ .../src/features/games/temp/survival/index.ts | 1 - .../src/features/games/temp/survival/meta.ts | 12 - .../src/features/games/temp/survival/view.tsx | 3 - 21 files changed, 6 insertions(+), 648 deletions(-) rename apps/frontend/src/features/games/{temp => }/falling-words/components/falling-words-field.tsx (100%) rename apps/frontend/src/features/games/{temp => }/falling-words/components/falling-words-hud.tsx (100%) rename apps/frontend/src/features/games/{temp => }/falling-words/difficulty.ts (100%) rename apps/frontend/src/features/games/{temp => }/falling-words/engine.ts (100%) rename apps/frontend/src/features/games/{temp => }/falling-words/index.ts (100%) rename apps/frontend/src/features/games/{temp => }/falling-words/meta.ts (85%) rename apps/frontend/src/features/games/{temp => }/falling-words/types.ts (98%) rename apps/frontend/src/features/games/{temp => }/falling-words/use-falling-words-game.ts (100%) rename apps/frontend/src/features/games/{temp => }/falling-words/view.tsx (99%) delete mode 100644 apps/frontend/src/features/games/temp/components/game-input.tsx delete mode 100644 apps/frontend/src/features/games/temp/hooks.ts delete mode 100644 apps/frontend/src/features/games/temp/survival/animations.css delete mode 100644 apps/frontend/src/features/games/temp/survival/components/heart.tsx delete mode 100644 apps/frontend/src/features/games/temp/survival/components/survival-hud.tsx delete mode 100644 apps/frontend/src/features/games/temp/survival/components/words.tsx delete mode 100644 apps/frontend/src/features/games/temp/survival/engine.ts delete mode 100644 apps/frontend/src/features/games/temp/survival/index.ts delete mode 100644 apps/frontend/src/features/games/temp/survival/meta.ts delete mode 100644 apps/frontend/src/features/games/temp/survival/view.tsx diff --git a/apps/frontend/src/features/games/core/registry.ts b/apps/frontend/src/features/games/core/registry.ts index 9869fea..b419393 100644 --- a/apps/frontend/src/features/games/core/registry.ts +++ b/apps/frontend/src/features/games/core/registry.ts @@ -1,9 +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 index cf639d7..736008d 100644 --- a/apps/frontend/src/features/games/core/types.ts +++ b/apps/frontend/src/features/games/core/types.ts @@ -5,7 +5,7 @@ export type GameViewProps = { wordBankId?: WordBankId | null; }; -export type GameId = "survival"; +export type GameId = "survival" | "falling-words"; export type DifficultyKey = "easy" | "medium" | "hard"; diff --git a/apps/frontend/src/features/games/temp/falling-words/components/falling-words-field.tsx b/apps/frontend/src/features/games/falling-words/components/falling-words-field.tsx similarity index 100% rename from apps/frontend/src/features/games/temp/falling-words/components/falling-words-field.tsx rename to apps/frontend/src/features/games/falling-words/components/falling-words-field.tsx diff --git a/apps/frontend/src/features/games/temp/falling-words/components/falling-words-hud.tsx b/apps/frontend/src/features/games/falling-words/components/falling-words-hud.tsx similarity index 100% rename from apps/frontend/src/features/games/temp/falling-words/components/falling-words-hud.tsx rename to apps/frontend/src/features/games/falling-words/components/falling-words-hud.tsx diff --git a/apps/frontend/src/features/games/temp/falling-words/difficulty.ts b/apps/frontend/src/features/games/falling-words/difficulty.ts similarity index 100% rename from apps/frontend/src/features/games/temp/falling-words/difficulty.ts rename to apps/frontend/src/features/games/falling-words/difficulty.ts diff --git a/apps/frontend/src/features/games/temp/falling-words/engine.ts b/apps/frontend/src/features/games/falling-words/engine.ts similarity index 100% rename from apps/frontend/src/features/games/temp/falling-words/engine.ts rename to apps/frontend/src/features/games/falling-words/engine.ts diff --git a/apps/frontend/src/features/games/temp/falling-words/index.ts b/apps/frontend/src/features/games/falling-words/index.ts similarity index 100% rename from apps/frontend/src/features/games/temp/falling-words/index.ts rename to apps/frontend/src/features/games/falling-words/index.ts diff --git a/apps/frontend/src/features/games/temp/falling-words/meta.ts b/apps/frontend/src/features/games/falling-words/meta.ts similarity index 85% rename from apps/frontend/src/features/games/temp/falling-words/meta.ts rename to apps/frontend/src/features/games/falling-words/meta.ts index bfc07cf..5352382 100644 --- a/apps/frontend/src/features/games/temp/falling-words/meta.ts +++ b/apps/frontend/src/features/games/falling-words/meta.ts @@ -1,4 +1,4 @@ -import type { GameModule } from "@/features/games/types"; +import type { GameModule } from "@/features/games/core/types"; import FallingWordsView from "./view"; export const meta: GameModule = { diff --git a/apps/frontend/src/features/games/temp/falling-words/types.ts b/apps/frontend/src/features/games/falling-words/types.ts similarity index 98% rename from apps/frontend/src/features/games/temp/falling-words/types.ts rename to apps/frontend/src/features/games/falling-words/types.ts index 7a3bb4b..6ede1e1 100644 --- a/apps/frontend/src/features/games/temp/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/temp/falling-words/use-falling-words-game.ts b/apps/frontend/src/features/games/falling-words/use-falling-words-game.ts similarity index 100% rename from apps/frontend/src/features/games/temp/falling-words/use-falling-words-game.ts rename to apps/frontend/src/features/games/falling-words/use-falling-words-game.ts diff --git a/apps/frontend/src/features/games/temp/falling-words/view.tsx b/apps/frontend/src/features/games/falling-words/view.tsx similarity index 99% rename from apps/frontend/src/features/games/temp/falling-words/view.tsx rename to apps/frontend/src/features/games/falling-words/view.tsx index 02ef27a..888fb54 100644 --- a/apps/frontend/src/features/games/temp/falling-words/view.tsx +++ b/apps/frontend/src/features/games/falling-words/view.tsx @@ -1,7 +1,7 @@ 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 "@/features/games/core/components/DifficultySelector"; diff --git a/apps/frontend/src/features/games/temp/components/game-input.tsx b/apps/frontend/src/features/games/temp/components/game-input.tsx deleted file mode 100644 index fb842d7..0000000 --- a/apps/frontend/src/features/games/temp/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/temp/hooks.ts b/apps/frontend/src/features/games/temp/hooks.ts deleted file mode 100644 index 5a562e6..0000000 --- a/apps/frontend/src/features/games/temp/hooks.ts +++ /dev/null @@ -1,28 +0,0 @@ -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"; - -export function useSubmitGameResult(minScores: Record) { - const auth = useAuthSession(); - const mutation = useCreateResultMutation(); - - const submit = (result: { - gameId: string; - score: number; - difficulty: DifficultyKey; - }) => { - if (!auth.isAuthenticated()) return; - - if (result.score < minScores[result.difficulty]) { - toast.info( - `Result not saved. Test too short. Minimum score for ${result.difficulty} is ${minScores[result.difficulty]}.`, - ); - return; - } - - mutation.mutate(result); - }; - - return submit; -} diff --git a/apps/frontend/src/features/games/temp/survival/animations.css b/apps/frontend/src/features/games/temp/survival/animations.css deleted file mode 100644 index b006659..0000000 --- a/apps/frontend/src/features/games/temp/survival/animations.css +++ /dev/null @@ -1,10 +0,0 @@ -@keyframes damage { - 10%, 90% { transform: translate3d(-2px, 0, 0); } - 20%, 80% { transform: translate3d(4px, 0, 0); } - 30%, 50%, 70% { transform: translate3d(-8px, 0, 0); } - 40%, 60% { transform: translate3d(8px, 0, 0); } -} - -.animate-damage { - animation: damage 0.3s cubic-bezier(.36,.07,.19,.97) both; -} diff --git a/apps/frontend/src/features/games/temp/survival/components/heart.tsx b/apps/frontend/src/features/games/temp/survival/components/heart.tsx deleted file mode 100644 index 0314816..0000000 --- a/apps/frontend/src/features/games/temp/survival/components/heart.tsx +++ /dev/null @@ -1,129 +0,0 @@ -import { For } from "solid-js"; - -export type HeartProps = { - state: "full" | "half" | "empty"; - isDamaged?: boolean; - class?: string; -}; - -const S = 6; - -const outlines = [ - { x: 18, y: 6 }, - { x: 12, y: 6 }, - { x: 24, y: 12 }, - { x: 30, y: 12 }, - { x: 36, y: 6 }, - { x: 42, y: 6 }, - { x: 48, y: 6 }, - { x: 54, y: 12 }, - { x: 54, y: 18 }, - { x: 6, y: 6 }, - { x: 0, y: 12 }, - { x: 0, y: 18 }, - { x: 0, y: 24 }, - { x: 54, y: 24 }, - { x: 6, y: 30 }, - { x: 12, y: 36 }, - { x: 18, y: 42 }, - { x: 24, y: 48 }, - { x: 30, y: 48 }, - { x: 36, y: 42 }, - { x: 42, y: 36 }, - { x: 48, y: 30 }, -]; - -const highlights = [{ x: 12, y: 18 }]; -const shadowFills = [ - { x: 6, y: 12 }, - { x: 6, y: 18 }, - { x: 6, y: 24 }, - { x: 12, y: 30 }, - { x: 18, y: 36 }, - { x: 24, y: 42 }, -]; -const normalFills = [ - { x: 12, y: 12 }, - { x: 18, y: 12 }, - { x: 18, y: 18 }, - { x: 12, y: 24 }, - { x: 18, y: 24 }, - { x: 18, y: 30 }, - { x: 24, y: 36 }, - { x: 30, y: 42 }, - { x: 30, y: 36 }, - { x: 24, y: 30 }, - { x: 30, y: 30 }, - { x: 30, y: 24 }, - { x: 24, y: 24 }, - { x: 24, y: 18 }, - { x: 30, y: 18 }, - { x: 36, y: 24 }, - { x: 42, y: 30 }, - { x: 36, y: 30 }, - { x: 36, y: 36 }, - { x: 42, y: 24 }, - { x: 36, y: 18 }, - { x: 42, y: 18 }, -]; -const lightFills = [ - { x: 48, y: 24 }, - { x: 48, y: 18 }, - { x: 48, y: 12 }, - { x: 42, y: 12 }, - { x: 36, y: 12 }, -]; - -const fillGroups = [ - { pixels: highlights, color: "#ffffff" }, - { pixels: shadowFills, color: "#9d0000" }, - { pixels: normalFills, color: "#ff0000" }, - { pixels: lightFills, color: "#ff5757" }, -]; - -export function Heart(props: HeartProps) { - const fillColor = (x: number, color: string) => { - if (props.state === "full") return color; - if (props.state === "empty") return "#333333"; - return x < 30 ? color : "#333333"; - }; - - return ( - - - {(p) => ( - - )} - - - {(group) => ( - - {(p) => ( - - )} - - )} - - - ); -} diff --git a/apps/frontend/src/features/games/temp/survival/components/survival-hud.tsx b/apps/frontend/src/features/games/temp/survival/components/survival-hud.tsx deleted file mode 100644 index 797867f..0000000 --- a/apps/frontend/src/features/games/temp/survival/components/survival-hud.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { Index } from "solid-js"; -import { Heart } from "./heart"; -import { GameStat } from "@/features/games/core/components/GameStat"; - -export type SurvivalHudProps = { - health: number; - score: number; - wpm: number; - accuracy: number; - isTakingDamage?: boolean; -}; - -export function SurvivalHud(props: SurvivalHudProps) { - return ( -
- - - - -
-
- - health - -
-
- - {(_, i) => ( - = 1 - ? "full" - : props.health - i > 0 - ? "half" - : "empty" - } - isDamaged={props.isTakingDamage} - class="h-6 w-6" - /> - )} - -
-
-
- ); -} diff --git a/apps/frontend/src/features/games/temp/survival/components/words.tsx b/apps/frontend/src/features/games/temp/survival/components/words.tsx deleted file mode 100644 index 91b7ffb..0000000 --- a/apps/frontend/src/features/games/temp/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/temp/survival/engine.ts b/apps/frontend/src/features/games/temp/survival/engine.ts deleted file mode 100644 index e49690f..0000000 --- a/apps/frontend/src/features/games/temp/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/temp/survival/index.ts b/apps/frontend/src/features/games/temp/survival/index.ts deleted file mode 100644 index b814f91..0000000 --- a/apps/frontend/src/features/games/temp/survival/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { meta } from "./meta"; diff --git a/apps/frontend/src/features/games/temp/survival/meta.ts b/apps/frontend/src/features/games/temp/survival/meta.ts deleted file mode 100644 index 4746e67..0000000 --- a/apps/frontend/src/features/games/temp/survival/meta.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { GameModule } from "@/features/games/types"; -import View from "./view"; - -export const meta: GameModule = { - id: "survival", - name: "Survival", - description: "Type as fast as you can to survive without making mistakes.", - defaultWordBankId: "english/core-1k", - difficultyKeys: ["easy", "medium", "hard"] as const, - minScores: { easy: 15, medium: 10, hard: 5 }, - View, -}; diff --git a/apps/frontend/src/features/games/temp/survival/view.tsx b/apps/frontend/src/features/games/temp/survival/view.tsx deleted file mode 100644 index 9d49c74..0000000 --- a/apps/frontend/src/features/games/temp/survival/view.tsx +++ /dev/null @@ -1,3 +0,0 @@ -import { TypingTest } from "@/features/games/core/typing-test"; - -export default TypingTest; From 15dd424c5141f9676c330a6a2593e04cf5582d78 Mon Sep 17 00:00:00 2001 From: d1rshan Date: Mon, 18 May 2026 14:57:01 +0530 Subject: [PATCH 13/13] missing imports --- apps/frontend/src/app/app.tsx | 8 ++++++++ apps/frontend/src/features/commandline/registry.ts | 9 ++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/apps/frontend/src/app/app.tsx b/apps/frontend/src/app/app.tsx index dcad614..e1023c2 100644 --- a/apps/frontend/src/app/app.tsx +++ b/apps/frontend/src/app/app.tsx @@ -1,5 +1,13 @@ import { createEffect, createMemo } from "solid-js"; import { Router, Route, useNavigate, useSearchParams } from "@solidjs/router"; + +import Layout from "@/app/layout"; +import AboutPage from "@/app/pages/about"; +import AdminPage from "@/app/pages/admin"; +import HomePage from "@/app/pages/home"; +import LeaderboardPage from "@/app/pages/leaderboard"; +import ProfilePage from "@/app/pages/profile"; + import { games } from "@/features/games/core/registry"; import { getHomeGamePath } from "@/features/games/core/utils"; import type { GameId } from "@/features/games/core/types"; diff --git a/apps/frontend/src/features/commandline/registry.ts b/apps/frontend/src/features/commandline/registry.ts index 672651b..8b67c69 100644 --- a/apps/frontend/src/features/commandline/registry.ts +++ b/apps/frontend/src/features/commandline/registry.ts @@ -1,13 +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/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 {