diff --git a/eslint.config.mjs b/eslint.config.mjs index c41e29cb..aad8e36a 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,22 +1,19 @@ -import nextCoreWebVitals from "eslint-config-next/core-web-vitals"; -import nextTypescript from "eslint-config-next/typescript"; +import { FlatCompat } from "@eslint/eslintrc"; +import { fileURLToPath } from "url"; +import { dirname } from "path"; import prettier from "eslint-config-prettier/flat"; -const config = [ - ...nextCoreWebVitals, - ...nextTypescript, - { - files: ["**/*.{js,jsx,mjs,ts,tsx,mts,cts}"], - settings: { - react: { - version: "19.2", - }, - }, - }, +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const compat = new FlatCompat({ + baseDirectory: __dirname, +}); + +export default [ + ...compat.extends("next/core-web-vitals", "next/typescript"), { - ignores: [".netlify/**"], + ignores: [".netlify/**", ".next/**"], }, prettier, -]; - -export default config; +]; \ No newline at end of file diff --git a/src/app/api/v1/users/[userId]/game-statistics/record/route.ts b/src/app/api/v1/users/[userId]/game-statistics/record/route.ts index 5ba4a83f..53449588 100644 --- a/src/app/api/v1/users/[userId]/game-statistics/record/route.ts +++ b/src/app/api/v1/users/[userId]/game-statistics/record/route.ts @@ -13,12 +13,13 @@ export const POST = withAuth<{ userId: string }>( ) => { const { userId } = params; verifyUser(tokenUser, userId, ERRORS.GAME_STATISTICS.UNAUTHORIZED); - const { gameName, result } = await req.json(); + const { gameName, result, score } = await req.json(); const data = await GameStatisticsService.recordGameResult( userId, gameName, result, + score, ); return NextResponse.json(data, { status: 201 }); }, diff --git a/src/components/games/bird/BirdGame.tsx b/src/components/games/bird/BirdGame.tsx new file mode 100644 index 00000000..2461c6a1 --- /dev/null +++ b/src/components/games/bird/BirdGame.tsx @@ -0,0 +1,277 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { GameState, type GameWrapperControls } from "../GameWrapper"; + +type Pipe = { + id: number; + x: number; + gapY: number; // where gap starts from top + scored: boolean; // true once the bird has passed this pipe +}; + +const BIRD_SIZE_RATIO = 0.07; // fraction of board height +const BIRD_X_RATIO = 0.12; // fraction of board width +const GRAVITY = 0.35; +const FLAP_STRENGTH = -6; +const TICK_MS = 16; +const PIPE_WIDTH_RATIO = 0.09; // fraction of board width +const PIPE_GAP_RATIO = 0.42; // fraction of board height +const PIPE_SPEED = 3; +const PIPE_SPAWN = 120; + +export default function BirdGame({ + setSpeechText, + gameState, + setGameState, +}: GameWrapperControls) { + const boardRef = useRef(null); + const spawnCounter = useRef(0); + + // Physics refs are the authoritative source of truth for the game loop. + // React state is derived from them solely for rendering. + const birdYRef = useRef(0); + const birdVelocityRef = useRef(0); + const pipesRef = useRef([]); + const gameOverRef = useRef(false); + + const [boardHeight, setBoardHeight] = useState(0); + const [boardWidth, setBoardWidth] = useState(0); + const [birdY, setBirdY] = useState(0); + const [pipes, setPipes] = useState([]); + const [score, setScore] = useState(0); + + const resetGame = useCallback( + (height = boardHeight) => { + const startY = height > 0 ? height * 0.25 : 0; + birdYRef.current = startY; + birdVelocityRef.current = 0; + pipesRef.current = []; + gameOverRef.current = false; + spawnCounter.current = 0; + setBirdY(startY); + setPipes([]); + setScore(0); + }, + [boardHeight], + ); + + const handleStartOrReplay = () => { + resetGame(); + setGameState(GameState.PLAYING); + }; + + const birdSize = boardHeight * BIRD_SIZE_RATIO; + const birdX = boardWidth * BIRD_X_RATIO; + const pipeWidth = boardWidth * PIPE_WIDTH_RATIO; + const pipeGap = boardHeight * PIPE_GAP_RATIO; + + useEffect(() => { + if (gameState === GameState.START && boardHeight > 0) { + resetGame(); + } + }, [gameState, boardHeight, resetGame]); + + useEffect(() => { + const measureBoard = () => { + if (!boardRef.current) return; + setBoardHeight(boardRef.current.clientHeight); + setBoardWidth(boardRef.current.clientWidth); + }; + + measureBoard(); + window.addEventListener("resize", measureBoard); + return () => window.removeEventListener("resize", measureBoard); + }, []); + + useEffect(() => { + if (gameState === GameState.START) { + setSpeechText("Press start, then space to flap!"); + return; + } + if (gameState === GameState.PLAYING) { + setSpeechText("Press space to flap!"); + return; + } + if (gameState === GameState.WON) { + setSpeechText("Amazing! You have won!"); + return; + } + setSpeechText("Nice try!"); + }, [gameState, setSpeechText]); + + useEffect(() => { + if (gameState !== GameState.PLAYING || boardHeight === 0) return; + + const activeBirdSize = boardHeight * BIRD_SIZE_RATIO; + const activeBirdX = boardWidth * BIRD_X_RATIO; + const activePipeWidth = boardWidth * PIPE_WIDTH_RATIO; + const activePipeGap = boardHeight * PIPE_GAP_RATIO; + + const intervalId = window.setInterval(() => { + // Skip ticks that fire between setGameState(LOSS) and the cleanup running. + if (gameOverRef.current) return; + + // Advance pipes + let pointScored = false; + const movedPipes = pipesRef.current + .map((pipe) => { + const newX = pipe.x - PIPE_SPEED; + const justPassed = + !pipe.scored && activeBirdX > pipe.x + activePipeWidth - PIPE_SPEED; + if (justPassed) pointScored = true; + return { ...pipe, x: newX, scored: pipe.scored || justPassed }; + }) + .filter((pipe) => pipe.x + activePipeWidth > 0); + + // Spawn pipes + spawnCounter.current += 1; + if (spawnCounter.current >= PIPE_SPAWN) { + const gap = boardHeight * PIPE_GAP_RATIO; + if (boardHeight > gap) { + movedPipes.push({ + id: Date.now(), + x: boardRef.current?.clientWidth ?? 400, + gapY: Math.random() * (boardHeight - gap), + scored: false, + }); + } + spawnCounter.current = 0; + } + + pipesRef.current = movedPipes; + + // Physics + const newVelocity = birdVelocityRef.current + GRAVITY; + const rawY = birdYRef.current + newVelocity; + + let collision = false; + let nextY = rawY; + + if (rawY <= 0) { + collision = true; + nextY = 0; + } else if (rawY + activeBirdSize >= boardHeight) { + collision = true; + nextY = boardHeight - activeBirdSize; + } else { + const birdLeft = activeBirdX; + const birdRight = activeBirdX + activeBirdSize; + const birdTop = rawY; + const birdBottom = rawY + activeBirdSize; + + for (const pipe of movedPipes) { + const horizontalOverlap = + birdRight > pipe.x && birdLeft < pipe.x + activePipeWidth; + if (horizontalOverlap) { + const hitsTop = birdTop < pipe.gapY; + const hitsBottom = birdBottom > pipe.gapY + activePipeGap; + if (hitsTop || hitsBottom) { + collision = true; + nextY = rawY; + break; + } + } + } + } + + birdVelocityRef.current = newVelocity; + birdYRef.current = nextY; + + if (pointScored) setScore((s) => s + 1); + setBirdY(nextY); + setPipes([...movedPipes]); + + if (collision) { + gameOverRef.current = true; + setGameState(GameState.LOSS); + } + }, TICK_MS); + + return () => window.clearInterval(intervalId); + }, [gameState, boardHeight, boardWidth, setGameState]); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.code !== "Space") return; + event.preventDefault(); + if (gameState === GameState.PLAYING) { + birdVelocityRef.current = FLAP_STRENGTH; + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [gameState]); + + const isGameOver = + gameState === GameState.LOSS || gameState === GameState.WON; + + return ( +
+
+ {/* Score — visible while playing and on the end screen */} + {(gameState === GameState.PLAYING || isGameOver) && ( +
+ {score} +
+ )} + + {/* Bird */} +
+ + {/* Pipes */} + {pipes.map((pipe) => ( +
+ {/* Top pipe */} +
+ + {/* Bottom pipe */} +
+
+ ))} + + {gameState !== GameState.PLAYING && ( +
+ {isGameOver && ( +

+ Score: {score} +

+ )} + +
+ )} +
+
+ ); +} diff --git a/src/components/games/flowerman/FlowermanFlower.tsx b/src/components/games/flowerman/FlowermanFlower.tsx index c4116886..f43eb0ec 100644 --- a/src/components/games/flowerman/FlowermanFlower.tsx +++ b/src/components/games/flowerman/FlowermanFlower.tsx @@ -1,7 +1,7 @@ "use client"; import Image from "next/image"; -import { LIVES } from "@/constant/flowermanConstants"; +import { LIVES } from "@/constants/flowermanConstants"; export default function FlowermanFlower({ livesRemaining, diff --git a/src/components/games/flowerman/FlowermanGame.tsx b/src/components/games/flowerman/FlowermanGame.tsx index a2e019c8..1aba4d8e 100644 --- a/src/components/games/flowerman/FlowermanGame.tsx +++ b/src/components/games/flowerman/FlowermanGame.tsx @@ -4,7 +4,7 @@ import { getRandomWordWithHint, INSTRUCTIONS, LIVES as START_LIVES, -} from "@/constant/flowermanConstants"; +} from "@/constants/flowermanConstants"; import FlowermanWordWithFlower from "@/components/games/flowerman/FlowermanWordWithFlower"; import FlowermanKeyboard from "@/components/games/flowerman/FlowermanKeyboard"; import MistakesLeft from "@/components/games/MistakesLeft"; diff --git a/src/components/games/flowerman/FlowermanKeyboard.tsx b/src/components/games/flowerman/FlowermanKeyboard.tsx index 20b697a6..6fbeff7a 100644 --- a/src/components/games/flowerman/FlowermanKeyboard.tsx +++ b/src/components/games/flowerman/FlowermanKeyboard.tsx @@ -2,7 +2,7 @@ import Image from "next/image"; import FlowermanKey from "./FlowermanKey"; -import { ALPHABET } from "@/constant/flowermanConstants"; +import { ALPHABET } from "@/constants/flowermanConstants"; const ROW1 = ALPHABET.slice(0, 13); // A-M const ROW2 = ALPHABET.slice(13, 26); // N-Z diff --git a/src/constant/flowermanConstants.ts b/src/constants/flowermanConstants.ts similarity index 100% rename from src/constant/flowermanConstants.ts rename to src/constants/flowermanConstants.ts diff --git a/src/db/actions/gameStatistics.ts b/src/db/actions/gameStatistics.ts index c9d10b76..744638e7 100644 --- a/src/db/actions/gameStatistics.ts +++ b/src/db/actions/gameStatistics.ts @@ -80,6 +80,7 @@ export default class GameStatisticsDAO { userId: string, gameName: GameName, result: GameResult, + score?: number, ): Promise { const _id = new Types.ObjectId(userId); await dbConnect(); @@ -139,6 +140,11 @@ export default class GameStatisticsDAO { -10, ], }, + ...(score !== undefined && { + [`gameStatistics.${gameName}.highScore`]: { + $max: [{ $ifNull: [`${fieldRef}.highScore`, 0] }, score], + }, + }), }, }, ], diff --git a/src/pages/games/bird.tsx b/src/pages/games/bird.tsx new file mode 100644 index 00000000..54db505d --- /dev/null +++ b/src/pages/games/bird.tsx @@ -0,0 +1,6 @@ +import GameWrapper from "@/components/games/GameWrapper"; +import BirdGame from "@/components/games/bird/BirdGame"; + +export default function BirdGamePage() { + return ; +} diff --git a/src/services/gameStatistics.ts b/src/services/gameStatistics.ts index 57227304..3797e515 100644 --- a/src/services/gameStatistics.ts +++ b/src/services/gameStatistics.ts @@ -10,10 +10,11 @@ export default class GameStatisticsService { userId: string, gameName: GameName, result: GameResult, + score?: number, ): Promise<{ coinsEarnedToday: number }> { - validateRecordGameResult({ userId, gameName, result }); + validateRecordGameResult({ userId, gameName, result, score }); - await GameStatisticsDAO.recordGameResult(userId, gameName, result); + await GameStatisticsDAO.recordGameResult(userId, gameName, result, score); const coinsEarnedToday = result === GameResult.WIN diff --git a/src/types/games.ts b/src/types/games.ts index 04f7650d..09e754a2 100644 --- a/src/types/games.ts +++ b/src/types/games.ts @@ -27,6 +27,7 @@ export interface GameStats { bestStreak: number; lastPlayedDate: string | null; lastTenResults: GameResult[]; + highScore?: number; // flappy bird is score-based } export type GameStatistics = Partial>; diff --git a/src/utils/serviceUtils/gameStatisticsUtil.ts b/src/utils/serviceUtils/gameStatisticsUtil.ts index 58c3f716..6ee235c9 100644 --- a/src/utils/serviceUtils/gameStatisticsUtil.ts +++ b/src/utils/serviceUtils/gameStatisticsUtil.ts @@ -6,6 +6,7 @@ export const recordGameResultSchema = z.object({ userId: objectIdSchema("UserId"), gameName: z.nativeEnum(GameName), result: z.nativeEnum(GameResult), + score: z.number().int().nonnegative().optional(), }); export const getGameStatisticsSchema = z.object({ diff --git a/tsconfig.json b/tsconfig.json index 3af314a1..90511079 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,7 +15,7 @@ "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, - "jsx": "react-jsx", + "jsx": "preserve", "incremental": true, "paths": { "@/*": [