From 2cd4394e0d4b57d3ad7bc9333576e4744b44af7d Mon Sep 17 00:00:00 2001 From: NickTheDevOpsGuy Date: Wed, 4 Feb 2026 08:08:29 -0500 Subject: [PATCH 1/2] Fixing up snake making it awesome --- package-lock.json | 111 ++++------------ src/app/components/HUD.tsx | 9 +- src/app/components/Leaderboard.tsx | 45 +++++++ src/app/components/SnakeCanvas.tsx | 207 ++++++++++++++++++++--------- src/app/constants/game.ts | 97 ++++++++++++-- src/app/hooks/useLeaderboard.ts | 50 +++++++ src/app/hooks/useSnakeGame.ts | 103 ++++++++++---- src/app/hooks/useSwipe.ts | 54 ++++++++ src/app/types/index.ts | 17 ++- src/app/utils/canvas.ts | 87 +++++++++--- src/app/utils/logic.ts | 66 ++++++++- 11 files changed, 631 insertions(+), 215 deletions(-) create mode 100644 src/app/components/Leaderboard.tsx create mode 100644 src/app/hooks/useLeaderboard.ts create mode 100644 src/app/hooks/useSwipe.ts diff --git a/package-lock.json b/package-lock.json index 04bebc6..a25c086 100644 --- a/package-lock.json +++ b/package-lock.json @@ -63,7 +63,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@csstools/css-calc": "^2.1.3", "@csstools/css-color-parser": "^3.0.9", @@ -78,8 +77,7 @@ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "dev": true, "license": "ISC", - "optional": true, - "peer": true + "optional": true }, "node_modules/@babel/code-frame": { "version": "7.27.1", @@ -112,6 +110,7 @@ "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -434,7 +433,6 @@ ], "license": "MIT-0", "optional": true, - "peer": true, "engines": { "node": ">=18" } @@ -456,7 +454,6 @@ ], "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">=18" }, @@ -482,7 +479,6 @@ ], "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@csstools/color-helpers": "^5.1.0", "@csstools/css-calc": "^2.1.4" @@ -511,6 +507,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -534,6 +531,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -2058,6 +2056,7 @@ "integrity": "sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.14.0" } @@ -2068,6 +2067,7 @@ "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -2128,6 +2128,7 @@ "integrity": "sha512-6JSSaBZmsKvEkbRUkf7Zj7dru/8ZCrJxAqArcLaVMee5907JdtEbKGsZ7zNiIm/UAkpGUkaSMZEXShnN2D1HZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.1", "@typescript-eslint/types": "8.46.1", @@ -2495,6 +2496,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2519,7 +2521,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">= 14" } @@ -2836,6 +2837,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001741", @@ -3174,7 +3176,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@asamuzakjp/css-color": "^3.2.0", "rrweb-cssom": "^0.8.0" @@ -3197,7 +3198,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.0.0" @@ -3284,8 +3284,7 @@ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", "dev": true, "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/deep-eql": { "version": "5.0.2", @@ -3426,7 +3425,6 @@ "dev": true, "license": "BSD-2-Clause", "optional": true, - "peer": true, "engines": { "node": ">=0.12" }, @@ -3709,6 +3707,7 @@ "integrity": "sha512-QePbBFMJFjgmlE+cXAlbHZbHpdFVS2E/6vzCy7aKlebddvl1vadiC4JFV5u/wqTkNUwEV8WrQi257jf5f06hrg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3770,6 +3769,7 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -4649,7 +4649,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "whatwg-encoding": "^3.1.1" }, @@ -4677,7 +4676,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" @@ -4693,7 +4691,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "agent-base": "^7.1.2", "debug": "4" @@ -4724,7 +4721,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" }, @@ -5078,8 +5074,7 @@ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", "dev": true, "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/is-regex": { "version": "1.2.1", @@ -5288,48 +5283,6 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/jsdom": { - "version": "26.1.0", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", - "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "cssstyle": "^4.2.1", - "data-urls": "^5.0.0", - "decimal.js": "^10.5.0", - "html-encoding-sniffer": "^4.0.0", - "http-proxy-agent": "^7.0.2", - "https-proxy-agent": "^7.0.6", - "is-potential-custom-element-name": "^1.0.1", - "nwsapi": "^2.2.16", - "parse5": "^7.2.1", - "rrweb-cssom": "^0.8.0", - "saxes": "^6.0.0", - "symbol-tree": "^3.2.4", - "tough-cookie": "^5.1.1", - "w3c-xmlserializer": "^5.0.0", - "webidl-conversions": "^7.0.0", - "whatwg-encoding": "^3.1.1", - "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^14.1.1", - "ws": "^8.18.0", - "xml-name-validator": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "canvas": "^3.0.0" - }, - "peerDependenciesMeta": { - "canvas": { - "optional": true - } - } - }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -5927,8 +5880,7 @@ "integrity": "sha512-ujSMe1OWVn55euT1ihwCI1ZcAaAU3nxUiDwfDQldc51ZXaB9m2AyOn6/jh1BLe2t/G8xd6uKG1UBF2aZJeg2SQ==", "dev": true, "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/object-assign": { "version": "4.1.1", @@ -6155,7 +6107,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "entities": "^6.0.0" }, @@ -6347,6 +6298,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -6396,6 +6348,7 @@ "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -6427,6 +6380,7 @@ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -6608,6 +6562,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -6781,8 +6736,7 @@ "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", "dev": true, "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/run-parallel": { "version": "1.2.0", @@ -6869,8 +6823,7 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "dev": true, "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/saxes": { "version": "6.0.0", @@ -6879,7 +6832,6 @@ "dev": true, "license": "ISC", "optional": true, - "peer": true, "dependencies": { "xmlchars": "^2.2.0" }, @@ -7542,8 +7494,7 @@ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", "dev": true, "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/synckit": { "version": "0.11.11", @@ -7740,6 +7691,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -7784,7 +7736,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "tldts-core": "^6.1.86" }, @@ -7798,8 +7749,7 @@ "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", "dev": true, "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/to-regex-range": { "version": "5.0.1", @@ -7821,7 +7771,6 @@ "dev": true, "license": "BSD-3-Clause", "optional": true, - "peer": true, "dependencies": { "tldts": "^6.1.32" }, @@ -7836,7 +7785,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "punycode": "^2.3.1" }, @@ -7977,6 +7925,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8109,6 +8058,7 @@ "integrity": "sha512-4nVGliEpxmhCL8DslSAUdxlB6+SMrhB0a1v5ijlh1xB1nEPuy1mxaHxysVucLHuWryAxLWg6a5ei+U4TLn/rFg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -8225,6 +8175,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -8325,7 +8276,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "xml-name-validator": "^5.0.0" }, @@ -8340,7 +8290,6 @@ "dev": true, "license": "BSD-2-Clause", "optional": true, - "peer": true, "engines": { "node": ">=12" } @@ -8352,7 +8301,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "iconv-lite": "0.6.3" }, @@ -8367,7 +8315,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">=18" } @@ -8379,7 +8326,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" @@ -8541,7 +8487,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">=10.0.0" }, @@ -8565,7 +8510,6 @@ "dev": true, "license": "Apache-2.0", "optional": true, - "peer": true, "engines": { "node": ">=18" } @@ -8576,8 +8520,7 @@ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "dev": true, "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/yallist": { "version": "3.1.1", diff --git a/src/app/components/HUD.tsx b/src/app/components/HUD.tsx index daca225..e933e08 100644 --- a/src/app/components/HUD.tsx +++ b/src/app/components/HUD.tsx @@ -3,10 +3,17 @@ type Props = { best: number; bump: boolean; alive: boolean; + paused?: boolean; onRestart: () => void; }; -export default function HUD({ score, best, bump, alive, onRestart }: Props) { +export default function HUD({ + score, + best, + bump, + alive, + onRestart, +}: Props) { return (
diff --git a/src/app/components/Leaderboard.tsx b/src/app/components/Leaderboard.tsx new file mode 100644 index 0000000..9290ba6 --- /dev/null +++ b/src/app/components/Leaderboard.tsx @@ -0,0 +1,45 @@ +import type { LeaderboardEntry } from '@/hooks/useLeaderboard'; +import { DIFFICULTY_PRESETS } from '@/constants/game'; +import type { GameDifficulty } from '@/constants/game'; + +type Props = { + entries: LeaderboardEntry[]; +}; + +function formatDate(ts: number) { + const d = new Date(ts); + return d.toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + }); +} + +export default function Leaderboard({ entries }: Props) { + if (entries.length === 0) return null; + + return ( +
+

+ Top scores +

+
    + {entries.slice(0, 5).map((e, i) => ( +
  • + #{i + 1} + {e.score} + + {DIFFICULTY_PRESETS[e.difficulty as GameDifficulty]?.label ?? + e.difficulty} + + + {formatDate(e.date)} + +
  • + ))} +
+
+ ); +} diff --git a/src/app/components/SnakeCanvas.tsx b/src/app/components/SnakeCanvas.tsx index 656f819..c03fc41 100644 --- a/src/app/components/SnakeCanvas.tsx +++ b/src/app/components/SnakeCanvas.tsx @@ -1,8 +1,7 @@ -// src/app/components/SnakeCanvas.tsx -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { DIFFICULTY_PRESETS, type GameDifficulty } from '@/constants/game'; import type { XY, Dir } from '@/types'; -import { randomFreeCell, inferDirFromSnake } from '@/utils/logic'; +import { inferDirFromSnake } from '@/utils/logic'; import { drawFrame } from '@/utils/canvas'; import { useTicker } from '@/hooks/useTicker'; import { useSnakeGame } from '@/hooks/useSnakeGame'; @@ -10,21 +9,57 @@ import { useInput } from '@/hooks/useInput'; import { useCanvas2D } from '@/hooks/useCanvas2D'; import { useBestScore } from '@/hooks/useBestScore'; import { usePauseHotkey } from '@/hooks/usePauseHotkey'; +import { useSwipe } from '@/hooks/useSwipe'; +import { useLeaderboard } from '@/hooks/useLeaderboard'; import HUD from '@/components/HUD'; +import Leaderboard from '@/components/Leaderboard'; +import { isOpposite } from '@/utils/logic'; type Phase = 'menu' | 'playing' | 'gameover'; +const DIFFICULTIES: GameDifficulty[] = [ + 'relaxed', + 'classic', + 'expert', + 'blitz', + 'endless', +]; + +function computeDelayMs( + T: (typeof DIFFICULTY_PRESETS)[GameDifficulty], + score: number +): number { + const linear = T.TICK_START_MS - score * T.TICK_STEP_MS; + const curve = T.speedCurve; + let delay = linear; + if (curve === 'ease-in') { + delay = T.TICK_START_MS - score * score * 0.08; + } else if (curve === 'ease-out') { + delay = T.TICK_START_MS - score * T.TICK_STEP_MS * 0.85; + } + return Math.max(T.TICK_MIN_MS, delay); +} + export default function SnakeCanvas() { const { canvasRef, ctxRef } = useCanvas2D(); const [bump, setBump] = useState(false); const [paused, setPaused] = useState(false); const [phase, setPhase] = useState('menu'); - const [difficulty, setDifficulty] = useState('medium'); + const [difficulty, setDifficulty] = useState('classic'); const T = DIFFICULTY_PRESETS[difficulty]; + const gameConfig = useMemo( + () => ({ + cols: T.COLS, + rows: T.ROWS, + wrap: T.wrap, + obstacleCount: T.obstacleCount, + powerChance: T.powerChance, + }), + [T] + ); - // πŸ”Š preload sounds const eatSnd = useMemo(() => new Audio('/sounds/food.mp3'), []); const dieSnd = useMemo(() => new Audio('/sounds/gameover.mp3'), []); const keySnd = useMemo(() => new Audio('/sounds/move.mp3'), []); @@ -39,79 +74,94 @@ export default function SnakeCanvas() { try { a.currentTime = 0; void a.play(); - } catch { + } catch (err) { console.error('Audio play exception:', err); } }, []); - // random free cell based on current tuning - const pickCell = useCallback( - (snake: XY[]) => randomFreeCell(snake, T.COLS, T.ROWS), - [T] - ); - - const { alive, score, snakeRef, foodRef, reset, turn, tick } = useSnakeGame( - pickCell, - { - onEat: () => play(eatSnd), - onDie: () => play(dieSnd), - isOutOfBounds: (p) => - p.x < 0 || p.x >= T.COLS || p.y < 0 || p.y >= T.ROWS, - } - ); + const { + alive, + score, + snakeRef, + foodRef, + obstaclesRef, + reset, + turn, + tick, + } = useSnakeGame(gameConfig, { + onEat: () => play(eatSnd), + onDie: () => play(dieSnd), + }); - // phase: playing β†’ gameover when you die useEffect(() => { if (!alive && phase === 'playing') setPhase('gameover'); }, [alive, phase]); - // derive current dir for opposite-turn guard const getCurrentDir = useCallback<() => Dir>( () => inferDirFromSnake(snakeRef.current), [snakeRef] ); - // drawing (thread tuning into canvas utils) const draw = useCallback(() => { const ctx = ctxRef.current; if (!ctx) return; - drawFrame(ctx, alive, foodRef.current, snakeRef.current, T); - }, [alive, ctxRef, foodRef, snakeRef, T]); + drawFrame( + ctx, + alive, + foodRef.current, + snakeRef.current, + obstaclesRef.current, + T + ); + }, [alive, ctxRef, foodRef, snakeRef, obstaclesRef, T]); - // score bump anim useEffect(() => { setBump(true); const id = setTimeout(() => setBump(false), 200); return () => clearTimeout(id); }, [score]); - // best score persistence const best = useBestScore(score); + const { entries, submitScore } = useLeaderboard(); + const submittedRef = useRef(false); + + useEffect(() => { + if (!alive && phase === 'gameover' && score > 0 && !submittedRef.current) { + submittedRef.current = true; + submitScore(score, difficulty); + } + }, [alive, phase, score, difficulty, submitScore]); + + useEffect(() => { + if (phase === 'playing') submittedRef.current = false; + }, [phase]); + + useEffect(() => { + if (phase === 'menu') { + reset(); + draw(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [difficulty]); - // first paint (menu): draw an empty frame for current tuning useEffect(() => { draw(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // dynamic speed: faster as you eat - const delayMs = Math.max( - T.TICK_MIN_MS, - T.TICK_START_MS - score * T.TICK_STEP_MS - ); + const delayMs = computeDelayMs(T, score); useTicker( delayMs, () => { - if (phase === 'playing' && alive && !paused) { + if (alive && !paused) { tick(); draw(); } }, - true + phase === 'playing' ); - // start/restart helper const startGame = useCallback(() => { setPaused(false); reset(); @@ -119,7 +169,6 @@ export default function SnakeCanvas() { setPhase('playing'); }, [reset, draw]); - // Space to start when not playing useEffect(() => { const onKey = (e: KeyboardEvent) => { if (phase !== 'playing' && e.code === 'Space') { @@ -131,7 +180,6 @@ export default function SnakeCanvas() { return () => window.removeEventListener('keydown', onKey); }, [phase, startGame]); - // keyboard for turns + HUD restart useInput({ alive, getCurrentDir, @@ -140,37 +188,62 @@ export default function SnakeCanvas() { onMoveKey: () => play(keySnd), }); + const handleSwipe = useCallback( + (d: Dir) => { + const cur = getCurrentDir(); + if (!isOpposite(cur, d)) { + turn(d); + play(keySnd); + } + }, + [getCurrentDir, turn, keySnd, play] + ); + + useSwipe({ + enabled: phase === 'playing' && alive && !paused, + onSwipe: handleSwipe, + }); + usePauseHotkey(alive && phase === 'playing', () => setPaused((p) => !p)); return ( -
- {/* Toolbar - only visible in menu or game over */} +
{phase !== 'playing' && ( -
- - - +
+

+ Snake +

+
+ + +
+

+ {T.description} +

+
)} - {/* Game canvas */}
+ {paused && phase === 'playing' && ( +
+ Paused (P) +
+ )}
- {/* HUD + stats */} -
- {(1000 / delayMs).toFixed(1)} moves/s +
+ {(1000 / delayMs).toFixed(1)} moves/s Β· Arrows / WASD / Swipe Β· P pause
); diff --git a/src/app/constants/game.ts b/src/app/constants/game.ts index 46723ed..1ccbfdc 100644 --- a/src/app/constants/game.ts +++ b/src/app/constants/game.ts @@ -1,6 +1,11 @@ // Game-wide configuration and presets -export type GameDifficulty = 'easy' | 'medium' | 'hard'; +export type GameDifficulty = + | 'relaxed' + | 'classic' + | 'expert' + | 'blitz' + | 'endless'; export type GameTuning = { CELL: number; @@ -9,35 +14,101 @@ export type GameTuning = { TICK_START_MS: number; TICK_MIN_MS: number; TICK_STEP_MS: number; + wrap: boolean; + obstacleCount: number; + powerChance: number; + label: string; + description: string; + speedCurve?: 'linear' | 'ease-in' | 'ease-out'; }; // Difficulty presets (adjust these to taste) export const DIFFICULTY_PRESETS: Record = { - easy: { - CELL: 28, + relaxed: { + CELL: 30, COLS: 16, ROWS: 16, - TICK_START_MS: 200, - TICK_MIN_MS: 90, - TICK_STEP_MS: 6, + TICK_START_MS: 240, + TICK_MIN_MS: 120, + TICK_STEP_MS: 5, + wrap: true, + obstacleCount: 0, + powerChance: 0.1, + label: 'Relaxed', + description: 'Chill speed, wrap-around edges, great for warming up.', + speedCurve: 'ease-out', }, - medium: { - CELL: 24, + classic: { + CELL: 26, COLS: 20, ROWS: 20, - TICK_START_MS: 160, + TICK_START_MS: 170, TICK_MIN_MS: 70, TICK_STEP_MS: 6, + wrap: false, + obstacleCount: 2, + powerChance: 0.12, + label: 'Classic', + description: 'Balanced pace with light obstacles and occasional power-ups.', + speedCurve: 'linear', }, - hard: { - CELL: 20, + expert: { + CELL: 22, COLS: 24, ROWS: 24, + TICK_START_MS: 150, + TICK_MIN_MS: 55, + TICK_STEP_MS: 6, + wrap: false, + obstacleCount: 8, + powerChance: 0.16, + label: 'Expert', + description: 'Faster ramp, tighter space, dodging lots of barriers.', + speedCurve: 'ease-in', + }, + blitz: { + CELL: 20, + COLS: 22, + ROWS: 22, TICK_START_MS: 120, - TICK_MIN_MS: 60, + TICK_MIN_MS: 45, + TICK_STEP_MS: 8, + wrap: false, + obstacleCount: 4, + powerChance: 0.2, + label: 'Blitz', + description: 'Starts fast, gets wildβ€”perfect for short intense runs.', + speedCurve: 'ease-in', + }, + endless: { + CELL: 18, + COLS: 28, + ROWS: 28, + TICK_START_MS: 190, + TICK_MIN_MS: 80, TICK_STEP_MS: 5, + wrap: true, + obstacleCount: 3, + powerChance: 0.14, + label: 'Endless', + description: 'Massive board with wrap edgesβ€”how long can you last?', + speedCurve: 'linear', }, }; -// Emoji pool for food +// Emoji pool for normal food export const FOOD_EMOJIS = ['🍎', '🍌', 'πŸ‡', 'πŸ§€', 'πŸ‰', 'πŸ“', 'πŸ₯•', '🌽']; + +// Power-up food config +import type { FoodKind } from '@/types'; + +export const POWER_UP_CONFIG: Record< + FoodKind, + { emoji: string; value: number; label: string } +> = { + normal: { emoji: '🍎', value: 1, label: '+1' }, + golden: { emoji: '⭐', value: 5, label: '+5' }, + freeze: { emoji: '❄️', value: 1, label: 'Freeze' }, + ghost: { emoji: 'πŸ‘»', value: 2, label: 'Ghost' }, + multiplier: { emoji: 'πŸ”₯', value: 3, label: '+3' }, +}; diff --git a/src/app/hooks/useLeaderboard.ts b/src/app/hooks/useLeaderboard.ts new file mode 100644 index 0000000..23ffe8a --- /dev/null +++ b/src/app/hooks/useLeaderboard.ts @@ -0,0 +1,50 @@ +import { useCallback, useState } from 'react'; +import type { GameDifficulty } from '@/constants/game'; + +export type LeaderboardEntry = { + score: number; + difficulty: GameDifficulty; + date: number; +}; + +const STORAGE_KEY = 'snake-leaderboard'; +const MAX_ENTRIES = 10; + +function loadLeaderboard(): LeaderboardEntry[] { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw) as LeaderboardEntry[]; + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +function saveLeaderboard(entries: LeaderboardEntry[]) { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(entries)); + } catch { + // ignore + } +} + +export function useLeaderboard() { + const [entries, setEntries] = useState(loadLeaderboard); + + const submitScore = useCallback( + (score: number, difficulty: GameDifficulty) => { + const next: LeaderboardEntry[] = [ + ...entries, + { score, difficulty, date: Date.now() }, + ] + .sort((a, b) => b.score - a.score) + .slice(0, MAX_ENTRIES); + setEntries(next); + saveLeaderboard(next); + }, + [entries] + ); + + return { entries, submitScore }; +} diff --git a/src/app/hooks/useSnakeGame.ts b/src/app/hooks/useSnakeGame.ts index bc120a9..84726fb 100644 --- a/src/app/hooks/useSnakeGame.ts +++ b/src/app/hooks/useSnakeGame.ts @@ -1,58 +1,88 @@ import { useCallback, useRef, useState } from 'react'; -import type { XY, Dir, Food } from '@/types'; +import type { XY, Dir, Food, FoodKind } from '@/types'; import { eq, nextHead, - outOfBounds as oobDefault, initSnake, + wrapPoint, + randomFreeCell, + generateObstacles, } from '@/utils/logic'; -import { FOOD_EMOJIS } from '@/constants/game'; +import { FOOD_EMOJIS, POWER_UP_CONFIG } from '@/constants/game'; + +export type GameConfig = { + cols: number; + rows: number; + wrap: boolean; + obstacleCount: number; + powerChance: number; +}; /** * Core game state + rules (no rendering). - * Accepts callbacks for events like eating or dying, and an optional bounds override. + * Supports obstacles, wrap mode, and power-up food. */ export function useSnakeGame( - pickFreeCell: (snake: XY[]) => XY, + config: GameConfig, opts?: { - onEat?: () => void; + onEat?: (value: number, kind: FoodKind) => void; onDie?: () => void; - isOutOfBounds?: (p: XY) => boolean; // overrides default bounds if provided } ) { + const { cols, rows, wrap, obstacleCount, powerChance } = config; + const dirRef = useRef('right'); const nextDirRef = useRef(null); - const snakeRef = useRef(initSnake()); + const snakeRef = useRef(initSnake(cols, rows)); const foodRef = useRef(null); + const obstaclesRef = useRef([]); const [alive, setAlive] = useState(true); const [score, setScore] = useState(0); const spawnFood = useCallback( (snake: XY[]): Food => { - const coords = pickFreeCell(snake); - const emoji = FOOD_EMOJIS[Math.floor(Math.random() * FOOD_EMOJIS.length)]; - return { ...coords, emoji }; + const blocked = [...obstaclesRef.current]; + const coords = randomFreeCell(snake, cols, rows, blocked); + + const isPowerUp = Math.random() < powerChance; + const kinds: FoodKind[] = ['golden', 'freeze', 'ghost', 'multiplier']; + const kind: FoodKind = isPowerUp + ? kinds[Math.floor(Math.random() * kinds.length)] + : 'normal'; + + const cfg = POWER_UP_CONFIG[kind]; + const emoji = + kind === 'normal' + ? FOOD_EMOJIS[Math.floor(Math.random() * FOOD_EMOJIS.length)] + : cfg.emoji; + + return { + ...coords, + kind, + emoji, + value: cfg.value, + }; }, - [pickFreeCell] + [cols, rows, powerChance] ); const reset = useCallback(() => { - snakeRef.current = initSnake(); + const snake = initSnake(cols, rows); + snakeRef.current = snake; dirRef.current = 'right'; nextDirRef.current = null; - foodRef.current = spawnFood(snakeRef.current); + obstaclesRef.current = generateObstacles(obstacleCount, cols, rows, snake); + foodRef.current = spawnFood(snake); setAlive(true); setScore(0); - }, [spawnFood]); + }, [cols, rows, obstacleCount, spawnFood]); - /** Queue a direction; consumed once per tick */ const turn = useCallback((d: Dir) => { nextDirRef.current = d; }, []); const tick = useCallback(() => { - // apply queued turn if (nextDirRef.current) { dirRef.current = nextDirRef.current; nextDirRef.current = null; @@ -62,13 +92,28 @@ export function useSnakeGame( const head = snake[0]; if (!head) return; - const nh = nextHead(head, dirRef.current); + let nh = nextHead(head, dirRef.current); + + if (wrap) { + nh = wrapPoint(nh, cols, rows); + } else { + const oob = + nh.x < 0 || nh.x >= cols || nh.y < 0 || nh.y >= rows; + if (oob) { + setAlive(false); + opts?.onDie?.(); + return; + } + } + const food = foodRef.current; const willEat = !!food && eq(nh, food); const bodyToCheck = willEat ? snake : snake.slice(0, -1); - const isOOB = opts?.isOutOfBounds ?? oobDefault; - if (isOOB(nh) || bodyToCheck.some((s) => eq(s, nh))) { + const hitBody = bodyToCheck.some((s) => eq(s, nh)); + const hitObstacle = obstaclesRef.current.some((o) => eq(o, nh)); + + if (hitBody || hitObstacle) { setAlive(false); opts?.onDie?.(); return; @@ -76,14 +121,24 @@ export function useSnakeGame( if (willEat) { snake.unshift(nh); - setScore((s) => s + 1); + const value = food.value ?? 1; + setScore((s) => s + value); foodRef.current = spawnFood(snake); - opts?.onEat?.(); + opts?.onEat?.(value, food.kind); } else { snake.unshift(nh); snake.pop(); } - }, [opts, spawnFood]); + }, [cols, rows, wrap, opts, spawnFood]); - return { alive, score, snakeRef, foodRef, reset, turn, tick }; + return { + alive, + score, + snakeRef, + foodRef, + obstaclesRef, + reset, + turn, + tick, + }; } diff --git a/src/app/hooks/useSwipe.ts b/src/app/hooks/useSwipe.ts new file mode 100644 index 0000000..545f3bb --- /dev/null +++ b/src/app/hooks/useSwipe.ts @@ -0,0 +1,54 @@ +import { useCallback, useEffect, useRef } from 'react'; +import type { Dir } from '@/types'; + +const MIN_SWIPE_DIST = 30; + +export function useSwipe(opts: { + enabled: boolean; + onSwipe: (dir: Dir) => void; +}) { + const { enabled, onSwipe } = opts; + const startRef = useRef<{ x: number; y: number } | null>(null); + + const handleStart = useCallback( + (e: TouchEvent) => { + if (!enabled) return; + const t = e.touches[0]; + if (t) startRef.current = { x: t.clientX, y: t.clientY }; + }, + [enabled] + ); + + const handleEnd = useCallback( + (e: TouchEvent) => { + if (!enabled || !startRef.current) return; + const t = e.changedTouches[0]; + if (!t) return; + + const dx = t.clientX - startRef.current.x; + const dy = t.clientY - startRef.current.y; + startRef.current = null; + + const adx = Math.abs(dx); + const ady = Math.abs(dy); + if (adx < MIN_SWIPE_DIST && ady < MIN_SWIPE_DIST) return; + + if (adx > ady) { + onSwipe(dx > 0 ? 'right' : 'left'); + } else { + onSwipe(dy > 0 ? 'down' : 'up'); + } + }, + [enabled, onSwipe] + ); + + useEffect(() => { + const target = document; + target.addEventListener('touchstart', handleStart, { passive: true }); + target.addEventListener('touchend', handleEnd, { passive: true }); + return () => { + target.removeEventListener('touchstart', handleStart); + target.removeEventListener('touchend', handleEnd); + }; + }, [handleStart, handleEnd]); +} diff --git a/src/app/types/index.ts b/src/app/types/index.ts index a9a3be0..b02929b 100644 --- a/src/app/types/index.ts +++ b/src/app/types/index.ts @@ -9,18 +9,29 @@ export * from './ui'; // Basic coordinate point export type XY = { x: number; y: number }; -// Food extends XY so it can hold an emoji -export type Food = XY & { emoji?: string }; +export type FoodKind = 'normal' | 'golden' | 'freeze' | 'ghost' | 'multiplier'; + +// Food extends XY so it can hold rendering + gameplay metadata +export type Food = XY & { + kind: FoodKind; + emoji?: string; + value?: number; + expiresAt?: number; +}; // Snake movement direction export type Dir = 'up' | 'down' | 'left' | 'right'; -// Keyboard direction mapping +// Keyboard direction mapping (arrows + WASD) export const keyToDir: Record = { ArrowUp: 'up', ArrowDown: 'down', ArrowLeft: 'left', ArrowRight: 'right', + KeyW: 'up', + KeyS: 'down', + KeyA: 'left', + KeyD: 'right', }; // Equality helper for comparing coordinates diff --git a/src/app/utils/canvas.ts b/src/app/utils/canvas.ts index 03ed4ed..40c3504 100644 --- a/src/app/utils/canvas.ts +++ b/src/app/utils/canvas.ts @@ -2,19 +2,19 @@ import type { GameTuning } from '@/constants/game'; import type { XY, Food } from '@/types'; export function drawGrid(ctx: CanvasRenderingContext2D, T: GameTuning) { - const W = T.COLS * T.CELL, - H = T.ROWS * T.CELL; + const W = T.COLS * T.CELL; + const H = T.ROWS * T.CELL; - ctx.fillStyle = '#111'; + ctx.fillStyle = '#0f172a'; ctx.fillRect(0, 0, W, H); - ctx.strokeStyle = '#666'; - ctx.lineWidth = 1.5; + ctx.strokeStyle = '#334155'; + ctx.lineWidth = 2; ctx.strokeRect(0, 0, W, H); ctx.save(); - ctx.globalAlpha = 0.8; - ctx.strokeStyle = '#000'; + ctx.globalAlpha = 0.4; + ctx.strokeStyle = '#1e293b'; ctx.lineWidth = 1; for (let i = 1; i < T.COLS; i++) { @@ -34,6 +34,22 @@ export function drawGrid(ctx: CanvasRenderingContext2D, T: GameTuning) { ctx.restore(); } +export function drawObstacles( + ctx: CanvasRenderingContext2D, + obstacles: XY[], + T: GameTuning +) { + ctx.fillStyle = '#374151'; + ctx.strokeStyle = '#4b5563'; + ctx.lineWidth = 1; + for (const { x, y } of obstacles) { + const px = x * T.CELL; + const py = y * T.CELL; + ctx.fillRect(px + 1, py + 1, T.CELL - 2, T.CELL - 2); + ctx.strokeRect(px + 1, py + 1, T.CELL - 2, T.CELL - 2); + } +} + export function drawFood( ctx: CanvasRenderingContext2D, f: Food | null, @@ -41,17 +57,31 @@ export function drawFood( ) { if (!f) return; + const cx = f.x * T.CELL + T.CELL / 2; + const cy = f.y * T.CELL + T.CELL / 2; + const isPowerUp = f.kind && f.kind !== 'normal'; + + if (isPowerUp) { + ctx.save(); + ctx.shadowColor = '#fbbf24'; + ctx.shadowBlur = 8; + ctx.beginPath(); + ctx.arc(cx, cy, T.CELL * 0.35, 0, Math.PI * 2); + ctx.fillStyle = 'rgba(251, 191, 36, 0.3)'; + ctx.fill(); + ctx.restore(); + } + if (f.emoji) { ctx.save(); - ctx.font = `${Math.floor(T.CELL * 0.8)}px system-ui, -apple-system, Segoe UI, Roboto, Emoji, sans-serif`; + ctx.font = `${Math.floor(T.CELL * 0.85)}px system-ui, -apple-system, Segoe UI, Roboto, Emoji, sans-serif`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; - ctx.fillText(f.emoji, f.x * T.CELL + T.CELL / 2, f.y * T.CELL + T.CELL / 2); + ctx.fillText(f.emoji, cx, cy); ctx.restore(); return; } - // fallback: red square ctx.fillStyle = '#ef4444'; ctx.fillRect(f.x * T.CELL, f.y * T.CELL, T.CELL, T.CELL); } @@ -61,20 +91,37 @@ export function drawSnake( snake: XY[], T: GameTuning ) { - ctx.fillStyle = '#22c55e'; - for (const { x, y } of snake) { - ctx.fillRect(x * T.CELL, y * T.CELL, T.CELL, T.CELL); - } + const pad = 1; + snake.forEach(({ x, y }, i) => { + const px = x * T.CELL + pad; + const py = y * T.CELL + pad; + const size = T.CELL - pad * 2; + const isHead = i === 0; + ctx.fillStyle = isHead ? '#10b981' : '#22c55e'; + ctx.fillRect(px, py, size, size); + if (isHead) { + ctx.strokeStyle = '#059669'; + ctx.lineWidth = 1; + ctx.strokeRect(px, py, size, size); + } + }); } export function drawGameOver(ctx: CanvasRenderingContext2D, T: GameTuning) { + const W = T.COLS * T.CELL; + const H = T.ROWS * T.CELL; ctx.save(); - ctx.globalAlpha = 0.3; - ctx.fillRect(0, 0, T.COLS * T.CELL, T.ROWS * T.CELL); + ctx.fillStyle = 'rgba(0,0,0,0.6)'; + ctx.fillRect(0, 0, W, H); ctx.globalAlpha = 1; - ctx.fillStyle = '#fff'; - ctx.font = '16px monospace'; - ctx.fillText('Game Over β€” press Space', 12, 28); + ctx.fillStyle = '#f8fafc'; + ctx.font = 'bold 20px monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText('Game Over', W / 2, H / 2 - 12); + ctx.font = '14px monospace'; + ctx.fillStyle = '#94a3b8'; + ctx.fillText('Press Space to restart', W / 2, H / 2 + 12); ctx.restore(); } @@ -83,9 +130,11 @@ export function drawFrame( alive: boolean, food: Food | null, snake: XY[], + obstacles: XY[], T: GameTuning ) { drawGrid(ctx, T); + drawObstacles(ctx, obstacles, T); drawFood(ctx, food, T); drawSnake(ctx, snake, T); if (!alive) drawGameOver(ctx, T); diff --git a/src/app/utils/logic.ts b/src/app/utils/logic.ts index c688158..14fd4b8 100644 --- a/src/app/utils/logic.ts +++ b/src/app/utils/logic.ts @@ -29,20 +29,30 @@ export const outOfBounds = (p: XY, cols = 20, rows = 20) => * Initial snake centered on the given number of rows. * Defaults to 20 so initSnake() keeps working if caller doesn't pass rows. */ -export const initSnake = (rows = 20): XY[] => { +export const initSnake = (cols = 20, rows = 20): XY[] => { const row = Math.floor(rows / 2); + const headX = Math.floor(cols / 2); return [ - { x: 2, y: row }, - { x: 1, y: row }, - { x: 0, y: row }, + { x: headX, y: row }, + { x: headX - 1, y: row }, + { x: headX - 2, y: row }, ]; }; -export function randomFreeCell(snake: XY[], cols: number, rows: number): XY { +export function randomFreeCell( + snake: XY[], + cols: number, + rows: number, + blocked: XY[] = [] +): XY { + const isBlocked = (x: number, y: number) => + snake.some((c) => c.x === x && c.y === y) || + blocked.some((c) => c.x === x && c.y === y); + while (true) { const x = Math.floor(Math.random() * cols); const y = Math.floor(Math.random() * rows); - if (!snake.some((c) => c.x === x && c.y === y)) return { x, y }; + if (!isBlocked(x, y)) return { x, y }; } } @@ -53,3 +63,47 @@ export function inferDirFromSnake(snake: XY[]): Dir { if (h.x === s.x) return h.y < s.y ? 'up' : 'down'; return h.x < s.x ? 'left' : 'right'; } + +export function wrapPoint(p: XY, cols: number, rows: number): XY { + let x = p.x; + let y = p.y; + if (x < 0) x = cols - 1; + if (x >= cols) x = 0; + if (y < 0) y = rows - 1; + if (y >= rows) y = 0; + return { x, y }; +} + +export function manhattan(a: XY, b: XY) { + return Math.abs(a.x - b.x) + Math.abs(a.y - b.y); +} + +export function generateObstacles( + count: number, + cols: number, + rows: number, + snake: XY[], + padding = 2 +): XY[] { + if (count <= 0) return []; + const taken = new Set(); + const key = (x: number, y: number) => `${x}:${y}`; + snake.forEach((p) => taken.add(key(p.x, p.y))); + + const head = snake[0] ?? { x: 0, y: 0 }; + const result: XY[] = []; + let attempts = 0; + + while (result.length < count && attempts < count * 50) { + attempts += 1; + const x = Math.floor(Math.random() * cols); + const y = Math.floor(Math.random() * rows); + const candidate = key(x, y); + if (taken.has(candidate)) continue; + if (manhattan({ x, y }, head) <= padding) continue; + taken.add(candidate); + result.push({ x, y }); + } + + return result; +} From 22e0d7386887918c24fd53b3b85dd141af6ebf1f Mon Sep 17 00:00:00 2001 From: NickTheDevOpsGuy Date: Wed, 4 Feb 2026 08:08:37 -0500 Subject: [PATCH 2/2] style: auto-format with Prettier [skip-precheck] --- src/app/components/HUD.tsx | 8 +------- src/app/components/Leaderboard.tsx | 4 +--- src/app/components/SnakeCanvas.tsx | 22 ++++++---------------- src/app/hooks/useSnakeGame.ts | 3 +-- 4 files changed, 9 insertions(+), 28 deletions(-) diff --git a/src/app/components/HUD.tsx b/src/app/components/HUD.tsx index e933e08..0b0c45d 100644 --- a/src/app/components/HUD.tsx +++ b/src/app/components/HUD.tsx @@ -7,13 +7,7 @@ type Props = { onRestart: () => void; }; -export default function HUD({ - score, - best, - bump, - alive, - onRestart, -}: Props) { +export default function HUD({ score, best, bump, alive, onRestart }: Props) { return (
diff --git a/src/app/components/Leaderboard.tsx b/src/app/components/Leaderboard.tsx index 9290ba6..37ad13f 100644 --- a/src/app/components/Leaderboard.tsx +++ b/src/app/components/Leaderboard.tsx @@ -34,9 +34,7 @@ export default function Leaderboard({ entries }: Props) { {DIFFICULTY_PRESETS[e.difficulty as GameDifficulty]?.label ?? e.difficulty} - - {formatDate(e.date)} - + {formatDate(e.date)} ))} diff --git a/src/app/components/SnakeCanvas.tsx b/src/app/components/SnakeCanvas.tsx index c03fc41..826f916 100644 --- a/src/app/components/SnakeCanvas.tsx +++ b/src/app/components/SnakeCanvas.tsx @@ -79,19 +79,11 @@ export default function SnakeCanvas() { } }, []); - const { - alive, - score, - snakeRef, - foodRef, - obstaclesRef, - reset, - turn, - tick, - } = useSnakeGame(gameConfig, { - onEat: () => play(eatSnd), - onDie: () => play(dieSnd), - }); + const { alive, score, snakeRef, foodRef, obstaclesRef, reset, turn, tick } = + useSnakeGame(gameConfig, { + onEat: () => play(eatSnd), + onDie: () => play(dieSnd), + }); useEffect(() => { if (!alive && phase === 'playing') setPhase('gameover'); @@ -217,9 +209,7 @@ export default function SnakeCanvas() {