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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/frontend/src/features/content/word-banks/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@ import { wordBanks } from "@/features/content/word-banks/registry";
import type { WordBankId } from "@/features/content/word-banks/types";

export function getWordBank(wordBankId: WordBankId) {
return wordBanks[wordBankId] ?? null;
return wordBanks[wordBankId];
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,16 @@ import { Kbd } from "@/components/ui/kbd";
import type { GamePhase } from "../../core/types";
import type { FallingWord } from "../types";

type FallingWordsFieldProps = {
type FieldProps = {
ref?: (el: HTMLDivElement) => void;
words: FallingWord[];
currentInput: string;
focusedWordId: number | null;
phase: GamePhase;
score: number;
onFieldClick: () => void;
};

function FallingWordsField(props: FallingWordsFieldProps) {
export function Field(props: FieldProps) {
return (
<div
ref={props.ref}
Expand All @@ -31,20 +30,6 @@ function FallingWordsField(props: FallingWordsFieldProps) {
</div>
)}

{props.phase === "game-over" && (
<div class="absolute inset-0 z-20 flex items-center justify-center bg-(--bg)/90 backdrop-blur-sm">
<div class="text-center">
<p class="text-6xl leading-none font-bold tracking-tighter text-(--main) sm:text-8xl">
{props.score}
</p>
<div class="mt-12 flex items-center gap-2">
<Kbd>enter</Kbd>
<p class="text-base leading-normal">to restart</p>
</div>
</div>
</div>
)}

{props.words.map((word) => {
const isFocused = word.id === props.focusedWordId;
const isPrefixMatch =
Expand Down Expand Up @@ -118,5 +103,3 @@ function FallingWordsField(props: FallingWordsFieldProps) {
</div>
);
}

export default FallingWordsField;
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { GameStat } from "../../core/components/game-stat";

export type FallingWordsHudProps = {
type HudProps = {
score: number;
typedValue: string;
};

export function FallingWordsHud(props: FallingWordsHudProps) {
export function Hud(props: HudProps) {
return (
<div class="flex items-center gap-12 font-mono">
<GameStat label="score" value={props.score.toLocaleString()} highlight />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,8 @@ import {
import { getWordBank } from "@/features/content/word-banks/manager";
import type { WordBankId } from "@/features/content/word-banks/types";

import type { DifficultyKey, GamePhase } from "../core/types";
import type {
DifficultyConfig,
FallingWord,
UseFallingWordsGameOptions,
} from "./types";
import type { DifficultyKey, GameId, GamePhase } from "../core/types";
import type { DifficultyConfig, FallingWord } from "./types";

const difficultyConfigs: Record<DifficultyKey, DifficultyConfig> = {
easy: { spawnIntervalMs: 1800, baseSpeed: 68, speedJitter: 20, gravity: 6 },
Expand All @@ -24,6 +20,14 @@ const difficultyConfigs: Record<DifficultyKey, DifficultyConfig> = {

const rand = (min: number, max: number) => Math.random() * (max - min) + min;

export type UseGameOptions = {
onComplete?: (result: {
gameId: GameId;
score: number;
difficulty: DifficultyKey;
}) => void;
};
Comment thread
greptile-apps[bot] marked this conversation as resolved.

function createFallingWord(
id: number,
width: number,
Expand Down Expand Up @@ -59,9 +63,9 @@ function findExactMatch(words: FallingWord[], value: string) {
.sort((left, right) => right.y - left.y)[0];
}

export function useFallingWordsGame(
export function useEngine(
wordBankId: WordBankId,
options: UseFallingWordsGameOptions = {},
options: UseGameOptions = {},
) {
const wordBank = getWordBank(wordBankId);

Expand All @@ -82,26 +86,22 @@ export function useFallingWordsGame(
const [currentInput, setCurrentInput] = createSignal("");
const [elapsedMs, setElapsedMs] = createSignal(0);

const selectedDifficulty = createMemo(() => difficultyConfigs[difficulty()]);
const config = createMemo(() => difficultyConfigs[difficulty()]);
const score = createMemo(() => formatScore(elapsedMs()));

const focusedWordId = createMemo((prevFocusedWordId?: number | null) => {
const input = currentInput();
const words = activeWords();

if (input.length === 0) {
return null;
}
if (input.length === 0) return null;

// Keep focus sticky if the previously focused word is still a valid exact prefix match
if (prevFocusedWordId != null) {
const prevWord = words.find((w) => w.id === prevFocusedWordId);
if (prevWord && prevWord.text.startsWith(input)) {
return prevFocusedWordId;
}
}

// Try exact prefix match first
const exactPrefixCandidates = words
.filter((word) => word.text.startsWith(input))
.sort((left, right) => right.y - left.y);
Expand All @@ -110,8 +110,6 @@ export function useFallingWordsGame(
return exactPrefixCandidates[0]?.id ?? null;
}

// If no exact prefix match, find the word that HAS the longest prefix match with currentInput
// This allows for mistakes at the end of the input while keeping focus
let bestWordId = null;
let longestPrefix = 0;
let lowestY = -1;
Expand All @@ -126,14 +124,11 @@ export function useFallingWordsGame(
prefixLen++;
}

if (prefixLen > 0) {
if (prefixLen > longestPrefix) {
if (prefixLen > 0 && prefixLen >= longestPrefix) {
if (prefixLen > longestPrefix || word.y > lowestY) {
longestPrefix = prefixLen;
bestWordId = word.id;
lowestY = word.y;
} else if (prefixLen === longestPrefix && word.y > lowestY) {
bestWordId = word.id;
lowestY = word.y;
}
}
}
Expand All @@ -145,7 +140,6 @@ export function useFallingWordsGame(
if (phase() === "running" && runStartTime > 0) {
return elapsedBeforeRun + (performance.now() - runStartTime);
}

return elapsedBeforeRun;
};

Expand All @@ -164,20 +158,16 @@ export function useFallingWordsGame(
}
};

const focusInput = () => {
inputRef?.focus();
};
const focusInput = () => inputRef?.focus();

const spawnWord = () => {
if (!wordBank || wordBank.words.length === 0) {
return;
}
if (!wordBank || wordBank.words.length === 0) return;

const nextWord = createFallingWord(
nextWordId,
fieldWidth(),
wordBank.words,
selectedDifficulty(),
config(),
);
nextWordId += 1;
setActiveWords((current) => [...current, nextWord]);
Expand All @@ -189,9 +179,7 @@ export function useFallingWordsGame(
setPhase("idle");
setActiveWords([]);
setCurrentInput("");
if (inputRef) {
inputRef.value = "";
}
if (inputRef) inputRef.value = "";
focusInput();
setElapsedMs(0);
lastFrameTime = 0;
Expand All @@ -217,7 +205,7 @@ export function useFallingWordsGame(
setPhase("game-over");
stopLoop();
setElapsedMs(finalElapsedMs);
void options.onComplete?.({
options.onComplete?.({
gameId: "falling-words",
score: finalScore,
difficulty: difficulty(),
Expand All @@ -226,21 +214,14 @@ export function useFallingWordsGame(

const submitExactMatch = (value: string, requireFocusMatch = false) => {
const targetWord = findExactMatch(activeWords(), value);
if (!targetWord) {
return false;
}

if (requireFocusMatch && targetWord.id !== focusedWordId()) {
return false;
}
if (!targetWord) return false;
if (requireFocusMatch && targetWord.id !== focusedWordId()) return false;

setActiveWords((current) =>
current.filter((word) => word.id !== targetWord.id),
);
setCurrentInput("");
if (inputRef) {
inputRef.value = "";
}
if (inputRef) inputRef.value = "";
return true;
};

Expand All @@ -249,7 +230,6 @@ export function useFallingWordsGame(
resetGame(nextDifficulty);
return;
}

setDifficulty(nextDifficulty);
};

Expand Down Expand Up @@ -285,23 +265,16 @@ export function useFallingWordsGame(

if (event.key === "Enter") {
event.preventDefault();

if (phase() === "idle" || phase() === "game-over") {
startGame();
}
if (phase() === "idle" || phase() === "game-over") startGame();
}
};

const handleVisibilityChange = () => {
if (document.hidden && phase() === "running") {
endGame();
}
if (document.hidden && phase() === "running") endGame();
};

const handleWindowBlur = () => {
if (phase() === "running") {
endGame();
}
if (phase() === "running") endGame();
};

createEffect(() => {
Expand All @@ -315,7 +288,7 @@ export function useFallingWordsGame(
lastFrameTime = timestamp;
setElapsedMs(elapsedBeforeRun + (timestamp - runStartTime));

const difficultyConfig = selectedDifficulty();
const difficultyConfig = config();

if (timestamp - lastSpawnTime >= difficultyConfig.spawnIntervalMs) {
spawnWord();
Expand All @@ -337,9 +310,8 @@ export function useFallingWordsGame(
const nextRotation =
word.rotation + word.angularVelocity * deltaSeconds;

if (fieldHeight() > 0 && nextY >= fieldHeight() - 40) {
if (fieldHeight() > 0 && nextY >= fieldHeight() - 40)
hitBottom = true;
}

return {
...word,
Expand Down Expand Up @@ -373,13 +345,8 @@ export function useFallingWordsGame(
setTimeout(updateFieldSize, 100);
focusInput();

const observer = new ResizeObserver(() => {
updateFieldSize();
});

if (fieldRef) {
observer.observe(fieldRef);
}
const observer = new ResizeObserver(() => updateFieldSize());
if (fieldRef) observer.observe(fieldRef);

window.addEventListener("blur", handleWindowBlur);
document.addEventListener("visibilitychange", handleVisibilityChange);
Expand All @@ -391,27 +358,29 @@ export function useFallingWordsGame(
});
});

onCleanup(() => {
stopLoop();
});
onCleanup(stopLoop);

return {
activeWords,
currentInput,
difficulty,
focusedWordId,
handleDifficultyChange,
handleInput,
handleKeyDown,
phase,
score,
setInputRef: (element: HTMLInputElement) => {
inputRef = element;
game: {
phase,
difficulty,
activeWords,
currentInput,
focusedWordId,
score,
},
setFieldRef: (element: HTMLDivElement) => {
fieldRef = element;
actions: {
handleDifficultyChange,
handleInput,
handleKeyDown,
setInputRef: (element: HTMLInputElement) => {
inputRef = element;
},
setFieldRef: (element: HTMLDivElement) => {
fieldRef = element;
},
focusInput,
},
focusInput,
wordBank,
};
}
4 changes: 2 additions & 2 deletions apps/frontend/src/features/games/falling-words/meta.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { GameModule } from "../core/types";
import FallingWordsView from "./view";
import View from "./view";

export const meta: GameModule = {
id: "falling-words",
Expand All @@ -8,5 +8,5 @@ export const meta: GameModule = {
defaultWordBankId: "english/core-1k",
difficultyKeys: ["easy", "medium", "hard"] as const,
minScores: { easy: 20, medium: 15, hard: 10 },
View: FallingWordsView,
View,
};
12 changes: 0 additions & 12 deletions apps/frontend/src/features/games/falling-words/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import type { DifficultyKey } from "../core/types";

export type DifficultyConfig = {
spawnIntervalMs: number;
baseSpeed: number;
Expand All @@ -17,13 +15,3 @@ export type FallingWord = {
rotation: number;
angularVelocity: number;
};

type CompletedGameResult = {
gameId: "falling-words";
score: number;
difficulty: DifficultyKey;
};

export type UseFallingWordsGameOptions = {
onComplete?: (result: CompletedGameResult) => void | Promise<void>;
};
Loading
Loading