From 0a80b60c810a9d066a9afe2f22d412d0c321ae08 Mon Sep 17 00:00:00 2001 From: NickTheDevOpsGuy Date: Wed, 4 Feb 2026 10:53:52 -0500 Subject: [PATCH 1/2] Making it more better --- .env.example | 7 + .prettierrc.yml | 2 +- README.md | 34 +- eslint.config.ts | 60 +-- index.html | 5 +- package-lock.json | 539 +++++------------------ package.json | 2 + src/app/App.tsx | 6 +- src/app/components/Board.tsx | 72 --- src/app/components/CountdownOverlay.tsx | 40 ++ src/app/components/GameOverOverlay.tsx | 58 +++ src/app/components/HUD.tsx | 12 +- src/app/components/HowToPlayModal.tsx | 62 +++ src/app/components/Leaderboard.tsx | 43 -- src/app/components/LeaderboardModal.tsx | 127 ++++++ src/app/components/MenuScreen.tsx | 122 +++++ src/app/components/MobileControls.tsx | 90 ++++ src/app/components/SettingsModal.tsx | 82 ++++ src/app/components/SnakeCanvas.tsx | 336 +++++++------- src/app/constants/game.ts | 56 +-- src/app/hooks/useBestScore.ts | 6 +- src/app/hooks/useCanvas2D.ts | 4 +- src/app/hooks/useGameScale.ts | 23 + src/app/hooks/useGameSetup.ts | 63 +++ src/app/hooks/useInput.ts | 12 +- src/app/hooks/useLeaderboard.ts | 36 +- src/app/hooks/usePauseHotkey.ts | 8 +- src/app/hooks/useSettings.ts | 79 ++++ src/app/hooks/useSnake.ts | 14 - src/app/hooks/useSnakeGame.ts | 77 ++-- src/app/hooks/useSwipe.ts | 20 +- src/app/hooks/useTicker.ts | 2 +- src/app/main.tsx | 10 +- src/app/services/leaderboardService.ts | 52 +++ src/app/styles/App.css | 12 +- src/app/supabase/client.ts | 13 + src/app/types/index.ts | 24 +- src/app/types/ui.ts | 10 +- src/app/utils/canvas.ts | 56 +-- src/app/utils/gameTick.ts | 35 ++ src/app/utils/logic.ts | 26 +- src/app/utils/speed.ts | 18 + supabase/migrations/001_snake_scores.sql | 23 + vite.config.ts | 18 +- 44 files changed, 1459 insertions(+), 937 deletions(-) create mode 100644 .env.example delete mode 100644 src/app/components/Board.tsx create mode 100644 src/app/components/CountdownOverlay.tsx create mode 100644 src/app/components/GameOverOverlay.tsx create mode 100644 src/app/components/HowToPlayModal.tsx delete mode 100644 src/app/components/Leaderboard.tsx create mode 100644 src/app/components/LeaderboardModal.tsx create mode 100644 src/app/components/MenuScreen.tsx create mode 100644 src/app/components/MobileControls.tsx create mode 100644 src/app/components/SettingsModal.tsx create mode 100644 src/app/hooks/useGameScale.ts create mode 100644 src/app/hooks/useGameSetup.ts create mode 100644 src/app/hooks/useSettings.ts delete mode 100644 src/app/hooks/useSnake.ts create mode 100644 src/app/services/leaderboardService.ts create mode 100644 src/app/supabase/client.ts create mode 100644 src/app/utils/gameTick.ts create mode 100644 src/app/utils/speed.ts create mode 100644 supabase/migrations/001_snake_scores.sql diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..284abb6 --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +# Snake Environment Variables +# Copy to .env.local for local development + +# Supabase (optional - for global leaderboard) +# Get from Supabase: Project Settings > API +# VITE_SUPABASE_URL=https://xxxxx.supabase.co +# VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... diff --git a/.prettierrc.yml b/.prettierrc.yml index e7c2875..9c00e2e 100644 --- a/.prettierrc.yml +++ b/.prettierrc.yml @@ -1,7 +1,7 @@ semi: true singleQuote: true printWidth: 80 -quoteProps: 'as-needed' +quoteProps: "as-needed" tabWidth: 2 useTabs: false trailingComma: es5 diff --git a/README.md b/README.md index b4d70c1..9dc64b2 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,18 @@ Try Snake here: https://snake-beryl-six.vercel.app/ --- +## πŸ† Supabase Leaderboard (Optional) + +To enable the global leaderboard: + +1. Create a [Supabase](https://supabase.com) project +2. Run the migration in `supabase/migrations/001_snake_scores.sql` in the SQL Editor +3. Copy `.env.example` to `.env.local` and add your `VITE_SUPABASE_URL` and `VITE_SUPABASE_ANON_KEY` + +Without Supabase, the game still works with local-only score storage. + +--- + ## Contributing We love contributions of all kinds! Whether it’s fixing a bug, suggesting a feature, or polishing docs, your help makes this game better. @@ -124,18 +136,28 @@ Meet all our amazing [Contributors](./CONTRIBUTORS.md) β”‚ β”œβ”€β”€ assets β”‚ β”‚ └── snake-gameplay.gif β”‚ β”œβ”€β”€ components -β”‚ β”‚ β”œβ”€β”€ Board.tsx +β”‚ β”‚ β”œβ”€β”€ CountdownOverlay.tsx +β”‚ β”‚ β”œβ”€β”€ GameOverOverlay.tsx β”‚ β”‚ β”œβ”€β”€ HUD.tsx +β”‚ β”‚ β”œβ”€β”€ HowToPlayModal.tsx +β”‚ β”‚ β”œβ”€β”€ LeaderboardModal.tsx +β”‚ β”‚ β”œβ”€β”€ MenuScreen.tsx +β”‚ β”‚ β”œβ”€β”€ MobileControls.tsx +β”‚ β”‚ β”œβ”€β”€ SettingsModal.tsx β”‚ β”‚ └── SnakeCanvas.tsx β”‚ β”œβ”€β”€ constants β”‚ β”‚ └── game.ts β”‚ β”œβ”€β”€ hooks β”‚ β”‚ β”œβ”€β”€ useBestScore.ts β”‚ β”‚ β”œβ”€β”€ useCanvas2D.ts +β”‚ β”‚ β”œβ”€β”€ useGameScale.ts +β”‚ β”‚ β”œβ”€β”€ useGameSetup.ts β”‚ β”‚ β”œβ”€β”€ useInput.ts +β”‚ β”‚ β”œβ”€β”€ useLeaderboard.ts β”‚ β”‚ β”œβ”€β”€ usePauseHotkey.ts -β”‚ β”‚ β”œβ”€β”€ useSnake.ts +β”‚ β”‚ β”œβ”€β”€ useSettings.ts β”‚ β”‚ β”œβ”€β”€ useSnakeGame.ts +β”‚ β”‚ β”œβ”€β”€ useSwipe.ts β”‚ β”‚ └── useTicker.ts β”‚ β”œβ”€β”€ main.tsx β”‚ β”œβ”€β”€ styles @@ -144,9 +166,15 @@ Meet all our amazing [Contributors](./CONTRIBUTORS.md) β”‚ β”‚ β”œβ”€β”€ game.ts β”‚ β”‚ β”œβ”€β”€ index.ts β”‚ β”‚ └── ui.ts +β”‚ β”œβ”€β”€ services +β”‚ β”‚ └── leaderboardService.ts +β”‚ β”œβ”€β”€ supabase +β”‚ β”‚ └── client.ts β”‚ └── utils β”‚ β”œβ”€β”€ canvas.ts -β”‚ └── logic.ts +β”‚ β”œβ”€β”€ gameTick.ts +β”‚ β”œβ”€β”€ logic.ts +β”‚ └── speed.ts β”œβ”€β”€ tsconfig.json β”œβ”€β”€ tsconfig.node.json └── vite.config.ts diff --git a/eslint.config.ts b/eslint.config.ts index 1d29924..f020828 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -1,81 +1,81 @@ // eslint.config.js -import js from '@eslint/js'; -import react from 'eslint-plugin-react'; -import reactHooks from 'eslint-plugin-react-hooks'; -import reactRefresh from 'eslint-plugin-react-refresh'; -import globals from 'globals'; -import tseslint from 'typescript-eslint'; +import js from "@eslint/js"; +import react from "eslint-plugin-react"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; +import globals from "globals"; +import tseslint from "typescript-eslint"; export default [ // 🧹 Ignore build + coverage folders - { ignores: ['dist', 'coverage'] }, + { ignores: ["dist", "coverage"] }, // βœ… Base + TypeScript + React presets js.configs.recommended, ...tseslint.configs.recommended, - reactHooks.configs['recommended-latest'], + reactHooks.configs["recommended-latest"], reactRefresh.configs.vite, // 🧩 App code (browser) { - files: ['src/**/*.{ts,tsx,js,jsx}'], + files: ["src/**/*.{ts,tsx,js,jsx}"], languageOptions: { parser: tseslint.parser, - ecmaVersion: 'latest', - sourceType: 'module', + ecmaVersion: "latest", + sourceType: "module", parserOptions: { ecmaFeatures: { jsx: true } }, globals: globals.browser, }, plugins: { react }, rules: { // 🧠 React 17+ no longer requires importing React in JSX - 'react/react-in-jsx-scope': 'off', - 'react/prop-types': 'off', + "react/react-in-jsx-scope": "off", + "react/prop-types": "off", // 🧹 Lint polish - '@typescript-eslint/no-unused-vars': [ - 'warn', - { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, + "@typescript-eslint/no-unused-vars": [ + "warn", + { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }, ], - '@typescript-eslint/no-unused-expressions': [ - 'error', + "@typescript-eslint/no-unused-expressions": [ + "error", { allowShortCircuit: true, allowTernary: true }, ], - 'react-hooks/exhaustive-deps': 'warn', + "react-hooks/exhaustive-deps": "warn", }, settings: { - react: { version: 'detect' }, + react: { version: "detect" }, }, }, // πŸ§ͺ Tests (Vitest) { - files: ['tests/**/*.{ts,tsx,js,jsx}', '**/*.test.{ts,tsx,js,jsx}'], + files: ["tests/**/*.{ts,tsx,js,jsx}", "**/*.test.{ts,tsx,js,jsx}"], languageOptions: { parser: tseslint.parser, - ecmaVersion: 'latest', - sourceType: 'module', + ecmaVersion: "latest", + sourceType: "module", parserOptions: { ecmaFeatures: { jsx: true } }, globals: { ...globals.browser, ...globals.vitest }, }, plugins: { react }, rules: { - 'react/react-in-jsx-scope': 'off', + "react/react-in-jsx-scope": "off", }, }, // βš™οΈ Config + scripts (Node env) { files: [ - '*.config.{js,cjs,mjs,ts}', - 'vite.config.*', - 'vitest.config.*', - 'scripts/**', + "*.config.{js,cjs,mjs,ts}", + "vite.config.*", + "vitest.config.*", + "scripts/**", ], languageOptions: { parser: tseslint.parser, - ecmaVersion: 'latest', - sourceType: 'module', + ecmaVersion: "latest", + sourceType: "module", globals: globals.node, }, }, diff --git a/index.html b/index.html index fb560dc..70f08ea 100644 --- a/index.html +++ b/index.html @@ -1,5 +1,8 @@ - + + + + diff --git a/package-lock.json b/package-lock.json index a25c086..0409580 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,10 @@ "version": "0.0.0", "dependencies": { "@formkit/auto-animate": "^0.8.2", + "@supabase/supabase-js": "^2.94.1", "canvas-confetti": "^1.9.3", "husky": "^9.1.7", + "lucide-react": "^0.563.0", "react": "^19.1.1", "react-dom": "^19.1.1" }, @@ -56,29 +58,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@asamuzakjp/css-color": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", - "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@csstools/css-calc": "^2.1.3", - "@csstools/css-color-parser": "^3.0.9", - "@csstools/css-parser-algorithms": "^3.0.4", - "@csstools/css-tokenizer": "^3.0.3", - "lru-cache": "^10.4.3" - } - }, - "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC", - "optional": true - }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -416,81 +395,6 @@ "@keyv/serialize": "^1.1.1" } }, - "node_modules/@csstools/color-helpers": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", - "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "optional": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@csstools/css-calc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", - "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "optional": true, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" - } - }, - "node_modules/@csstools/css-color-parser": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", - "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "optional": true, - "dependencies": { - "@csstools/color-helpers": "^5.1.0", - "@csstools/css-calc": "^2.1.4" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" - } - }, "node_modules/@csstools/css-parser-algorithms": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", @@ -1683,6 +1587,86 @@ "win32" ] }, + "node_modules/@supabase/auth-js": { + "version": "2.94.1", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.94.1.tgz", + "integrity": "sha512-Wt/SdmAtNNiqrcBbPlzWojLcE1bQ9OYb8PTaYF6QccFX5JeXZI0sZ01MLNE+E83UK6cK0lw4YznX0D2g08UQng==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.94.1", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.94.1.tgz", + "integrity": "sha512-A7Bx0gnclDNZ4m8+mnO2IEEzMxtUSg7cpPEBF6Ek1LpjIQkC7vvoidiV/RuntnKX43IiVcWV1f2FsAppMagEmQ==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/postgrest-js": { + "version": "2.94.1", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.94.1.tgz", + "integrity": "sha512-N6MTghjHnMZddT48rAj8dIFgedCU97cc1ahQM74Tc+DF4UH7y2+iEfdYV3unJsylpaiWlu92Fy8Lj14Jbrmxog==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.94.1", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.94.1.tgz", + "integrity": "sha512-Wq8olpCAGmN4y2DH2kUdlcakdzNHRCde72BFS8zK5ub46bBeSUoE9DqrfeNFWKaF2gCE/cmK8aTUTorZD9jdtQ==", + "license": "MIT", + "dependencies": { + "@types/phoenix": "^1.6.6", + "@types/ws": "^8.18.1", + "tslib": "2.8.1", + "ws": "^8.18.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.94.1", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.94.1.tgz", + "integrity": "sha512-/Mi18LGyrugPwtfqETfAqEGcBQotY/7IMsTGYgEFdqr8cQq280BVQWjN2wI9KibWtshPp0Ryvil5Uzd5YfM7kA==", + "license": "MIT", + "dependencies": { + "iceberg-js": "^0.8.1", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.94.1", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.94.1.tgz", + "integrity": "sha512-87vOY8n3WHB3m+a/KeySj07djOQVuRA5qgX5E7db1eDkaZ1of5M+3t/tv6eYYy4BfqxuHMZuCe5uVrO/oyvoow==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.94.1", + "@supabase/functions-js": "2.94.1", + "@supabase/postgrest-js": "2.94.1", + "@supabase/realtime-js": "2.94.1", + "@supabase/storage-js": "2.94.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@tailwindcss/node": { "version": "4.1.14", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.14.tgz", @@ -2054,13 +2038,17 @@ "version": "24.7.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.7.2.tgz", "integrity": "sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA==", - "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.14.0" } }, + "node_modules/@types/phoenix": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.7.tgz", + "integrity": "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==", + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.2.2", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", @@ -2082,6 +2070,15 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.46.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.1.tgz", @@ -2514,17 +2511,6 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 14" - } - }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -3169,21 +3155,6 @@ "node": ">=4" } }, - "node_modules/cssstyle": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", - "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@asamuzakjp/css-color": "^3.2.0", - "rrweb-cssom": "^0.8.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", @@ -3191,21 +3162,6 @@ "dev": true, "license": "MIT" }, - "node_modules/data-urls": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", - "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^14.0.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -3278,14 +3234,6 @@ } } }, - "node_modules/decimal.js": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", - "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -3418,20 +3366,6 @@ "node": ">=10.13.0" } }, - "node_modules/entities": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", - "dev": true, - "license": "BSD-2-Clause", - "optional": true, - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, "node_modules/env-paths": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", @@ -4642,20 +4576,6 @@ "dev": true, "license": "MIT" }, - "node_modules/html-encoding-sniffer": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", - "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "whatwg-encoding": "^3.1.1" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/html-tags": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/html-tags/-/html-tags-3.3.1.tgz", @@ -4669,36 +4589,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/husky": { "version": "9.1.7", "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", @@ -4714,18 +4604,13 @@ "url": "https://github.com/sponsors/typicode" } }, - "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, + "node_modules/iceberg-js": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", + "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", "license": "MIT", - "optional": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, "engines": { - "node": ">=0.10.0" + "node": ">=20.0.0" } }, "node_modules/ignore": { @@ -5068,14 +4953,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-potential-custom-element-name": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", - "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -5713,6 +5590,15 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "0.563.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.563.0.tgz", + "integrity": "sha512-8dXPB2GI4dI8jV4MgUDGBeLdGk8ekfqVZ0BdLcrRzocGgG75ltNEmWS+gE7uokKF/0oSUuczNDT+g9hFJ23FkA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/magic-string": { "version": "0.30.19", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz", @@ -5874,14 +5760,6 @@ "node": ">=0.10.0" } }, - "node_modules/nwsapi": { - "version": "2.2.22", - "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.22.tgz", - "integrity": "sha512-ujSMe1OWVn55euT1ihwCI1ZcAaAU3nxUiDwfDQldc51ZXaB9m2AyOn6/jh1BLe2t/G8xd6uKG1UBF2aZJeg2SQ==", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -6100,20 +5978,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/parse5": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", - "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "entities": "^6.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, "node_modules/path": { "version": "0.12.7", "resolved": "https://registry.npmjs.org/path/-/path-0.12.7.tgz", @@ -6730,14 +6594,6 @@ "fsevents": "~2.3.2" } }, - "node_modules/rrweb-cssom": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", - "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -6817,28 +6673,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/saxes": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", - "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", - "dev": true, - "license": "ISC", - "optional": true, - "dependencies": { - "xmlchars": "^2.2.0" - }, - "engines": { - "node": ">=v12.22.7" - } - }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -7488,14 +7322,6 @@ "integrity": "sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==", "dev": true }, - "node_modules/symbol-tree": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", - "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/synckit": { "version": "0.11.11", "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz", @@ -7729,28 +7555,6 @@ "node": ">=14.0.0" } }, - "node_modules/tldts": { - "version": "6.1.86", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", - "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tldts-core": "^6.1.86" - }, - "bin": { - "tldts": "bin/cli.js" - } - }, - "node_modules/tldts-core": { - "version": "6.1.86", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", - "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -7764,34 +7568,6 @@ "node": ">=8.0" } }, - "node_modules/tough-cookie": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", - "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", - "dev": true, - "license": "BSD-3-Clause", - "optional": true, - "dependencies": { - "tldts": "^6.1.32" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/tr46": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", - "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "punycode": "^2.3.1" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/trim-repeated": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/trim-repeated/-/trim-repeated-1.0.0.tgz", @@ -7828,6 +7604,12 @@ "typescript": ">=4.8.4" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -7981,7 +7763,6 @@ "version": "7.14.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.14.0.tgz", "integrity": "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA==", - "dev": true, "license": "MIT" }, "node_modules/universalify": { @@ -8269,71 +8050,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/w3c-xmlserializer": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", - "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "xml-name-validator": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/webidl-conversions": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", - "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", - "dev": true, - "license": "BSD-2-Clause", - "optional": true, - "engines": { - "node": ">=12" - } - }, - "node_modules/whatwg-encoding": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", - "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "iconv-lite": "0.6.3" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/whatwg-mimetype": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", - "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/whatwg-url": { - "version": "14.2.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", - "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tr46": "^5.1.0", - "webidl-conversions": "^7.0.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -8484,9 +8200,7 @@ "version": "8.18.3", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", - "dev": true, "license": "MIT", - "optional": true, "engines": { "node": ">=10.0.0" }, @@ -8503,25 +8217,6 @@ } } }, - "node_modules/xml-name-validator": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", - "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/xmlchars": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", - "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index dee9564..464a93c 100644 --- a/package.json +++ b/package.json @@ -30,8 +30,10 @@ }, "dependencies": { "@formkit/auto-animate": "^0.8.2", + "@supabase/supabase-js": "^2.94.1", "canvas-confetti": "^1.9.3", "husky": "^9.1.7", + "lucide-react": "^0.563.0", "react": "^19.1.1", "react-dom": "^19.1.1" }, diff --git a/src/app/App.tsx b/src/app/App.tsx index 0befb20..f7adf1e 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,7 +1,7 @@ // src/SnakeApp.tsx -import React from 'react'; -import SnakeCanvas from './components/SnakeCanvas'; -import './styles/App.css'; +import React from "react"; +import SnakeCanvas from "./components/SnakeCanvas"; +import "./styles/App.css"; export default function SnakeApp() { return ; diff --git a/src/app/components/Board.tsx b/src/app/components/Board.tsx deleted file mode 100644 index 2863926..0000000 --- a/src/app/components/Board.tsx +++ /dev/null @@ -1,72 +0,0 @@ -// src/app/components/Board.tsx -import type { XY, Food } from '@/types'; -import type { GameTuning } from '@/constants/game'; - -type Props = { - snake: XY[]; - food: Food | null; // supports emoji if present - T: GameTuning; // pass DIFFICULTY_PRESETS[difficulty] - onCellClick?: (p: XY) => void; -}; - -export default function Board({ snake, food, T, onCellClick }: Props) { - const isSnake = (x: number, y: number) => - snake.some((p) => p.x === x && p.y === y); - const isFood = (x: number, y: number) => - !!food && food.x === x && food.y === y; - - return ( -
- {Array.from({ length: T.ROWS * T.COLS }, (_, i) => { - const x = i % T.COLS; - const y = Math.floor(i / T.COLS); - const s = isSnake(x, y); - const f = isFood(x, y); - - return ( -
onCellClick?.({ x, y })} - style={{ - position: 'absolute', - left: x * T.CELL, - top: y * T.CELL, - width: T.CELL, - height: T.CELL, - boxSizing: 'border-box', - border: '1px solid #222', - background: s ? '#4caf50' : f ? '#111' : '#111', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - fontSize: Math.floor(T.CELL * 0.75), - lineHeight: 1, - userSelect: 'none', - }} - > - {f ? ( - food?.emoji ? ( - food.emoji - ) : ( - // fallback red square if no emoji -
- ) - ) : null} -
- ); - })} -
- ); -} diff --git a/src/app/components/CountdownOverlay.tsx b/src/app/components/CountdownOverlay.tsx new file mode 100644 index 0000000..48b6946 --- /dev/null +++ b/src/app/components/CountdownOverlay.tsx @@ -0,0 +1,40 @@ +import { useState, useEffect } from "react"; + +type Props = { + visible: boolean; + onComplete: () => void; +}; + +const STEPS = ["3", "2", "1", "Go!"]; +const STEP_MS = 800; + +export default function CountdownOverlay({ visible, onComplete }: Props) { + const [step, setStep] = useState(0); + + useEffect(() => { + if (!visible) { + setStep(0); + return; + } + if (step >= STEPS.length) { + onComplete(); + return; + } + const id = setTimeout(() => setStep((s) => s + 1), STEP_MS); + return () => clearTimeout(id); + }, [visible, step, onComplete]); + + if (!visible || step >= STEPS.length) return null; + + return ( +
+ + {STEPS[step]} + +
+ ); +} diff --git a/src/app/components/GameOverOverlay.tsx b/src/app/components/GameOverOverlay.tsx new file mode 100644 index 0000000..d2a11ad --- /dev/null +++ b/src/app/components/GameOverOverlay.tsx @@ -0,0 +1,58 @@ +import { useEffect, useRef } from "react"; +import confetti from "canvas-confetti"; + +type Props = { + score: number; + best: number; + isNewBest: boolean; + playTimeMs: number; + snakeLength: number; + onRestart: () => void; +}; + +function formatTime(ms: number) { + const s = Math.floor(ms / 1000); + const m = Math.floor(s / 60); + const sec = s % 60; + return m > 0 ? `${m}:${sec.toString().padStart(2, "0")}` : `${s}s`; +} + +export default function GameOverOverlay({ + score, + best, + isNewBest, + playTimeMs, + snakeLength, + onRestart, +}: Props) { + const firedRef = useRef(false); + useEffect(() => { + if (isNewBest && !firedRef.current) { + firedRef.current = true; + confetti({ particleCount: 80, spread: 70, origin: { y: 0.6 } }); + } + }, [isNewBest]); + + return ( +
+

Game Over

+ {isNewBest && ( +

+ New high score! +

+ )} +
+

Score: {score}

+

Best: {best}

+

Length: {snakeLength}

+

Time: {formatTime(playTimeMs)}

+
+ +
+ ); +} diff --git a/src/app/components/HUD.tsx b/src/app/components/HUD.tsx index 0b0c45d..1f9316a 100644 --- a/src/app/components/HUD.tsx +++ b/src/app/components/HUD.tsx @@ -9,15 +9,15 @@ type Props = { export default function HUD({ score, best, bump, alive, onRestart }: Props) { return ( -
-
- Score: {score} - Best: {best} +
+
+ Score: {score} + Best: {best}
{!alive && ( -
-
diff --git a/src/app/components/HowToPlayModal.tsx b/src/app/components/HowToPlayModal.tsx new file mode 100644 index 0000000..7af0ea0 --- /dev/null +++ b/src/app/components/HowToPlayModal.tsx @@ -0,0 +1,62 @@ +import { X } from "lucide-react"; + +type Props = { + isOpen: boolean; + onClose: () => void; +}; + +export default function HowToPlayModal({ isOpen, onClose }: Props) { + if (!isOpen) return null; + + return ( +
+
e.stopPropagation()} + > +
+

+ How to Play +

+ +
+ +
+

Guide the snake to eat food and grow. Don't hit walls, obstacles, or yourself!

+
+

Controls

+
    +
  • Arrow keys or WASD
  • +
  • Swipe on mobile
  • +
  • Tap the D-pad on mobile
  • +
  • P to pause
  • +
+
+
+

Power-ups

+
    +
  • ⭐ Golden β€” +5 points
  • +
  • ❄️ Freeze β€” Pause timer briefly
  • +
  • πŸ‘» Ghost β€” Pass through yourself once
  • +
  • πŸ”₯ Multiplier β€” +3 points
  • +
+
+
+

Difficulty

+

Choose Relaxed for wrap-around edges, or Classic/Expert for obstacles. Blitz starts fast!

+
+
+
+
+ ); +} diff --git a/src/app/components/Leaderboard.tsx b/src/app/components/Leaderboard.tsx deleted file mode 100644 index 37ad13f..0000000 --- a/src/app/components/Leaderboard.tsx +++ /dev/null @@ -1,43 +0,0 @@ -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/LeaderboardModal.tsx b/src/app/components/LeaderboardModal.tsx new file mode 100644 index 0000000..b7714c0 --- /dev/null +++ b/src/app/components/LeaderboardModal.tsx @@ -0,0 +1,127 @@ +import { useState, useEffect } from "react"; +import type { GameDifficulty } from "@/constants/game"; +import { DIFFICULTY_PRESETS } from "@/constants/game"; +import { + fetchLeaderboard, + type LeaderboardScore, +} from "@/services/leaderboardService"; +import { X } from "lucide-react"; + +const DIFFICULTIES: GameDifficulty[] = [ + "relaxed", + "classic", + "expert", + "blitz", + "endless", +]; + +type Props = { + isOpen: boolean; + onClose: () => void; +}; + +function formatDate(iso: string) { + try { + const d = new Date(iso); + return d.toLocaleDateString(undefined, { month: "short", day: "numeric" }); + } catch { + return ""; + } +} + +export default function LeaderboardModal({ isOpen, onClose }: Props) { + const [difficulty, setDifficulty] = useState("classic"); + const [scores, setScores] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!isOpen) return; + setLoading(true); + setError(null); + fetchLeaderboard(difficulty) + .then(setScores) + .catch(() => setError("Failed to load leaderboard")) + .finally(() => setLoading(false)); + }, [isOpen, difficulty]); + + if (!isOpen) return null; + + return ( +
+
e.stopPropagation()} + > +
+

+ Leaderboard +

+ +
+ +
+ {DIFFICULTIES.map((d) => ( + + ))} +
+ +
+ {loading && ( +

Loading...

+ )} + {error && ( +

{error}

+ )} + {!loading && !error && scores.length === 0 && ( +

+ No scores yet. Be the first! +

+ )} + {!loading && !error && scores.length > 0 && ( +
    + {scores.map((s) => ( +
  • + #{s.rank} + + {s.score} + + + {s.playerName} + + + {formatDate(s.createdAt)} + +
  • + ))} +
+ )} +
+
+
+ ); +} diff --git a/src/app/components/MenuScreen.tsx b/src/app/components/MenuScreen.tsx new file mode 100644 index 0000000..21c57c9 --- /dev/null +++ b/src/app/components/MenuScreen.tsx @@ -0,0 +1,122 @@ +import { useState, useEffect } from "react"; +import type { GameDifficulty } from "@/constants/game"; +import { DIFFICULTY_PRESETS } from "@/constants/game"; +import { Play, Trophy, Settings, HelpCircle } from "lucide-react"; +import LeaderboardModal from "./LeaderboardModal"; +import SettingsModal from "./SettingsModal"; +import HowToPlayModal from "./HowToPlayModal"; + +const DIFFICULTIES: GameDifficulty[] = [ + "relaxed", + "classic", + "expert", + "blitz", + "endless", +]; + +type Props = { + difficulty: GameDifficulty; + onDifficultyChange: (d: GameDifficulty) => void; + onStart: () => void; + muted: boolean; + onMutedChange: (v: boolean) => void; + playerName: string; + onPlayerNameChange: (v: string) => void; +}; + +export default function MenuScreen({ + difficulty, + onDifficultyChange, + onStart, + muted, + onMutedChange, + playerName, + onPlayerNameChange, +}: Props) { + const [showLeaderboard, setShowLeaderboard] = useState(false); + const [showSettings, setShowSettings] = useState(false); + const [showHowToPlay, setShowHowToPlay] = useState(false); + + const T = DIFFICULTY_PRESETS[difficulty]; + + const btnBase = + "flex flex-col items-center justify-center gap-2 rounded-xl border border-zinc-600 bg-zinc-800/80 px-4 py-5 text-white transition hover:bg-zinc-700 active:bg-zinc-600 min-h-[100px]"; + + return ( +
+

Snake

+ +
+ + +

{T.description}

+
+ +
+ + + + + + + +
+ + setShowLeaderboard(false)} + /> + setShowSettings(false)} + muted={muted} + onMutedChange={onMutedChange} + playerName={playerName} + onPlayerNameChange={onPlayerNameChange} + /> + setShowHowToPlay(false)} + /> +
+ ); +} diff --git a/src/app/components/MobileControls.tsx b/src/app/components/MobileControls.tsx new file mode 100644 index 0000000..e742f22 --- /dev/null +++ b/src/app/components/MobileControls.tsx @@ -0,0 +1,90 @@ +import type { Dir } from "@/types"; + +type Props = { + visible: boolean; + onDirection: (dir: Dir) => void; +}; + +const BTN_CLASS = + "flex items-center justify-center rounded-xl bg-zinc-700/95 text-white active:bg-emerald-500 transition-colors select-none touch-manipulation min-w-[56px] min-h-[56px]"; + +export default function MobileControls({ visible, onDirection }: Props) { + if (!visible) return null; + + return ( +
+
+
+ +
+ +
+ βŒ‚ +
+ +
+ +
+
+
+ ); +} diff --git a/src/app/components/SettingsModal.tsx b/src/app/components/SettingsModal.tsx new file mode 100644 index 0000000..891e4e7 --- /dev/null +++ b/src/app/components/SettingsModal.tsx @@ -0,0 +1,82 @@ +import { X } from "lucide-react"; + +type Props = { + isOpen: boolean; + onClose: () => void; + muted: boolean; + onMutedChange: (v: boolean) => void; + playerName: string; + onPlayerNameChange: (v: string) => void; +}; + +export default function SettingsModal({ + isOpen, + onClose, + muted, + onMutedChange, + playerName, + onPlayerNameChange, +}: Props) { + if (!isOpen) return null; + + return ( +
+
e.stopPropagation()} + > +
+

+ Settings +

+ +
+ +
+ + +
+ + onPlayerNameChange(e.target.value)} + placeholder="Anonymous" + className="w-full rounded-lg border border-zinc-600 bg-zinc-800 px-3 py-2 text-white placeholder-zinc-500" + maxLength={20} + /> +
+
+
+
+ ); +} diff --git a/src/app/components/SnakeCanvas.tsx b/src/app/components/SnakeCanvas.tsx index 826f916..55468df 100644 --- a/src/app/components/SnakeCanvas.tsx +++ b/src/app/components/SnakeCanvas.tsx @@ -1,54 +1,49 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { DIFFICULTY_PRESETS, type GameDifficulty } from '@/constants/game'; -import type { XY, Dir } from '@/types'; -import { inferDirFromSnake } from '@/utils/logic'; -import { drawFrame } from '@/utils/canvas'; -import { useTicker } from '@/hooks/useTicker'; -import { useSnakeGame } from '@/hooks/useSnakeGame'; -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); -} +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import confetti from "canvas-confetti"; +import { type GameDifficulty } from "@/constants/game"; +import type { Dir } from "@/types"; +import { inferDirFromSnake, isOpposite } from "@/utils/logic"; +import { drawFrame } from "@/utils/canvas"; +import { computeDelayMs } from "@/utils/speed"; +import { useTicker } from "@/hooks/useTicker"; +import { useSnakeGame } from "@/hooks/useSnakeGame"; +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 { useGameSetup } from "@/hooks/useGameSetup"; +import { useGameScale } from "@/hooks/useGameScale"; +import { useSettings } from "@/hooks/useSettings"; +import MenuScreen from "@/components/MenuScreen"; +import HUD from "@/components/HUD"; +import MobileControls from "@/components/MobileControls"; +import CountdownOverlay from "@/components/CountdownOverlay"; +import GameOverOverlay from "@/components/GameOverOverlay"; export default function SnakeCanvas() { const { canvasRef, ctxRef } = useCanvas2D(); + const { muted, setMuted, playerName, setPlayerName, persistDifficulty, initialDifficulty } = + useSettings(); + + const { + phase, + setPhase, + difficulty, + setDifficulty, + paused, + setPaused, + bump, + triggerBump, + tuning: T, + sounds: { playEat, playDie, playMove }, + } = useGameSetup({ muted, initialDifficulty }); - const [bump, setBump] = useState(false); - const [paused, setPaused] = useState(false); - const [phase, setPhase] = useState('menu'); - const [difficulty, setDifficulty] = useState('classic'); + useEffect(() => { + persistDifficulty(difficulty); + }, [difficulty, persistDifficulty]); - const T = DIFFICULTY_PRESETS[difficulty]; const gameConfig = useMemo( () => ({ cols: T.COLS, @@ -57,41 +52,33 @@ export default function SnakeCanvas() { obstacleCount: T.obstacleCount, powerChance: T.powerChance, }), - [T] + [T], ); - const eatSnd = useMemo(() => new Audio('/sounds/food.mp3'), []); - const dieSnd = useMemo(() => new Audio('/sounds/gameover.mp3'), []); - const keySnd = useMemo(() => new Audio('/sounds/move.mp3'), []); - - useEffect(() => { - eatSnd.volume = 0.7; - dieSnd.volume = 0.9; - keySnd.volume = 0.4; - }, [eatSnd, dieSnd, keySnd]); - - const play = useCallback((a: HTMLAudioElement) => { - try { - a.currentTime = 0; - void a.play(); - } catch (err) { - console.error('Audio play exception:', err); - } - }, []); - - 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: (value, kind) => { + playEat(); + confetti({ particleCount: 12, spread: 60, origin: { y: 0.5 } }); + }, + onDie: playDie, + }); useEffect(() => { - if (!alive && phase === 'playing') setPhase('gameover'); - }, [alive, phase]); + if (!alive && phase === "playing") setPhase("gameover"); + }, [alive, phase, setPhase]); const getCurrentDir = useCallback<() => Dir>( () => inferDirFromSnake(snakeRef.current), - [snakeRef] + [snakeRef], ); const draw = useCallback(() => { @@ -103,33 +90,30 @@ export default function SnakeCanvas() { foodRef.current, snakeRef.current, obstaclesRef.current, - T + T, ); }, [alive, ctxRef, foodRef, snakeRef, obstaclesRef, T]); - useEffect(() => { - setBump(true); - const id = setTimeout(() => setBump(false), 200); - return () => clearTimeout(id); - }, [score]); + useEffect(triggerBump, [score, triggerBump]); const best = useBestScore(score); - const { entries, submitScore } = useLeaderboard(); + const { submitScore } = useLeaderboard(); const submittedRef = useRef(false); + const gameStartTimeRef = useRef(0); useEffect(() => { - if (!alive && phase === 'gameover' && score > 0 && !submittedRef.current) { + if (!alive && phase === "gameover" && score > 0 && !submittedRef.current) { submittedRef.current = true; - submitScore(score, difficulty); + submitScore(score, difficulty, playerName || undefined); } - }, [alive, phase, score, difficulty, submitScore]); + }, [alive, phase, score, difficulty, playerName, submitScore]); useEffect(() => { - if (phase === 'playing') submittedRef.current = false; + if (phase === "playing") submittedRef.current = false; }, [phase]); useEffect(() => { - if (phase === 'menu') { + if (phase === "menu") { reset(); draw(); } @@ -141,7 +125,7 @@ export default function SnakeCanvas() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const delayMs = computeDelayMs(T, score); + const delayMs = computeDelayMs(difficulty, score); useTicker( delayMs, @@ -151,119 +135,145 @@ export default function SnakeCanvas() { draw(); } }, - phase === 'playing' + phase === "playing", ); const startGame = useCallback(() => { setPaused(false); reset(); draw(); - setPhase('playing'); - }, [reset, draw]); + gameStartTimeRef.current = Date.now(); + setPhase("countdown"); + }, [reset, draw, setPaused, setPhase]); + + const startPlaying = useCallback(() => { + setPhase("playing"); + }, [setPhase]); useEffect(() => { const onKey = (e: KeyboardEvent) => { - if (phase !== 'playing' && e.code === 'Space') { + if ((phase === "menu" || phase === "gameover") && e.code === "Space") { e.preventDefault(); startGame(); } }; - window.addEventListener('keydown', onKey); - return () => window.removeEventListener('keydown', onKey); + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); }, [phase, startGame]); - useInput({ - alive, - getCurrentDir, - onTurn: turn, - onRestart: startGame, - onMoveKey: () => play(keySnd), - }); - - const handleSwipe = useCallback( + const handleTurn = useCallback( (d: Dir) => { const cur = getCurrentDir(); if (!isOpposite(cur, d)) { turn(d); - play(keySnd); + playMove(); } }, - [getCurrentDir, turn, keySnd, play] + [getCurrentDir, turn, playMove], ); + useInput({ + alive, + getCurrentDir, + onTurn: turn, + onRestart: startGame, + onMoveKey: playMove, + }); + useSwipe({ - enabled: phase === 'playing' && alive && !paused, - onSwipe: handleSwipe, + enabled: phase === "playing" && alive && !paused, + onSwipe: handleTurn, }); - usePauseHotkey(alive && phase === 'playing', () => setPaused((p) => !p)); + usePauseHotkey(alive && phase === "playing", () => setPaused((p) => !p)); + + const gameWidth = T.COLS * T.CELL; + const gameHeight = T.ROWS * T.CELL; + const scale = useGameScale(gameWidth, gameHeight); + + const isNewBest = !alive && score > 0 && score >= best; return ( -
- {phase !== 'playing' && ( -
-

- Snake -

-
- - -
-

- {T.description} -

- -
+
+ {(phase === "menu" || phase === "gameover") && ( + )}
- - {paused && phase === 'playing' && ( -
- Paused (P) -
- )} +
+ + {phase === "countdown" && ( + + )} + {paused && phase === "playing" && ( +
+ Paused (P) +
+ )} + {!alive && phase === "gameover" && ( + + )} +
- + {(phase === "playing" || phase === "countdown") && ( + + )} -
- {(1000 / delayMs).toFixed(1)} moves/s Β· Arrows / WASD / Swipe Β· P pause -
+ {(phase === "playing" || phase === "countdown") && ( +
+ {(1000 / delayMs).toFixed(1)} moves/s Β· Arrows / WASD / Swipe / Tap Β· P + pause +
+ )} + +
); } diff --git a/src/app/constants/game.ts b/src/app/constants/game.ts index 1ccbfdc..6d6dc2a 100644 --- a/src/app/constants/game.ts +++ b/src/app/constants/game.ts @@ -1,11 +1,11 @@ // Game-wide configuration and presets export type GameDifficulty = - | 'relaxed' - | 'classic' - | 'expert' - | 'blitz' - | 'endless'; + | "relaxed" + | "classic" + | "expert" + | "blitz" + | "endless"; export type GameTuning = { CELL: number; @@ -19,7 +19,7 @@ export type GameTuning = { powerChance: number; label: string; description: string; - speedCurve?: 'linear' | 'ease-in' | 'ease-out'; + speedCurve?: "linear" | "ease-in" | "ease-out"; }; // Difficulty presets (adjust these to taste) @@ -34,9 +34,9 @@ export const DIFFICULTY_PRESETS: Record = { wrap: true, obstacleCount: 0, powerChance: 0.1, - label: 'Relaxed', - description: 'Chill speed, wrap-around edges, great for warming up.', - speedCurve: 'ease-out', + label: "Relaxed", + description: "Chill speed, wrap-around edges, great for warming up.", + speedCurve: "ease-out", }, classic: { CELL: 26, @@ -48,9 +48,9 @@ export const DIFFICULTY_PRESETS: Record = { wrap: false, obstacleCount: 2, powerChance: 0.12, - label: 'Classic', - description: 'Balanced pace with light obstacles and occasional power-ups.', - speedCurve: 'linear', + label: "Classic", + description: "Balanced pace with light obstacles and occasional power-ups.", + speedCurve: "linear", }, expert: { CELL: 22, @@ -62,9 +62,9 @@ export const DIFFICULTY_PRESETS: Record = { wrap: false, obstacleCount: 8, powerChance: 0.16, - label: 'Expert', - description: 'Faster ramp, tighter space, dodging lots of barriers.', - speedCurve: 'ease-in', + label: "Expert", + description: "Faster ramp, tighter space, dodging lots of barriers.", + speedCurve: "ease-in", }, blitz: { CELL: 20, @@ -76,9 +76,9 @@ export const DIFFICULTY_PRESETS: Record = { wrap: false, obstacleCount: 4, powerChance: 0.2, - label: 'Blitz', - description: 'Starts fast, gets wildβ€”perfect for short intense runs.', - speedCurve: 'ease-in', + label: "Blitz", + description: "Starts fast, gets wildβ€”perfect for short intense runs.", + speedCurve: "ease-in", }, endless: { CELL: 18, @@ -90,25 +90,25 @@ export const DIFFICULTY_PRESETS: Record = { wrap: true, obstacleCount: 3, powerChance: 0.14, - label: 'Endless', - description: 'Massive board with wrap edgesβ€”how long can you last?', - speedCurve: 'linear', + label: "Endless", + description: "Massive board with wrap edgesβ€”how long can you last?", + speedCurve: "linear", }, }; // Emoji pool for normal food -export const FOOD_EMOJIS = ['🍎', '🍌', 'πŸ‡', 'πŸ§€', 'πŸ‰', 'πŸ“', 'πŸ₯•', '🌽']; +export const FOOD_EMOJIS = ["🍎", "🍌", "πŸ‡", "πŸ§€", "πŸ‰", "πŸ“", "πŸ₯•", "🌽"]; // Power-up food config -import type { FoodKind } from '@/types'; +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' }, + 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/useBestScore.ts b/src/app/hooks/useBestScore.ts index 0ed78b9..3bdd942 100644 --- a/src/app/hooks/useBestScore.ts +++ b/src/app/hooks/useBestScore.ts @@ -1,12 +1,12 @@ // hooks/useBestScore.ts -import { useEffect, useState } from 'react'; +import { useEffect, useState } from "react"; export function useBestScore(score: number) { const [best, setBest] = useState(() => - Number(localStorage.getItem('best') || 0) + Number(localStorage.getItem("best") || 0), ); useEffect(() => { if (score > best) { - localStorage.setItem('best', String(score)); + localStorage.setItem("best", String(score)); setBest(score); } }, [score, best]); diff --git a/src/app/hooks/useCanvas2D.ts b/src/app/hooks/useCanvas2D.ts index 6b27f18..dc74662 100644 --- a/src/app/hooks/useCanvas2D.ts +++ b/src/app/hooks/useCanvas2D.ts @@ -1,10 +1,10 @@ // hooks/useCanvas2D.ts -import { useEffect, useRef } from 'react'; +import { useEffect, useRef } from "react"; export function useCanvas2D() { const canvasRef = useRef(null); const ctxRef = useRef(null); useEffect(() => { - ctxRef.current = canvasRef.current?.getContext('2d') ?? null; + ctxRef.current = canvasRef.current?.getContext("2d") ?? null; }, []); return { canvasRef, ctxRef }; } diff --git a/src/app/hooks/useGameScale.ts b/src/app/hooks/useGameScale.ts new file mode 100644 index 0000000..b0a0660 --- /dev/null +++ b/src/app/hooks/useGameScale.ts @@ -0,0 +1,23 @@ +import { useEffect, useState } from "react"; + +export function useGameScale(gameWidth: number, gameHeight: number) { + const [scale, setScale] = useState(1); + + useEffect(() => { + const update = () => { + const padding = 24; + const controlsHeight = 180; + const maxW = window.innerWidth - padding * 2; + const maxH = window.innerHeight - controlsHeight - padding * 2; + const scaleW = maxW / gameWidth; + const scaleH = maxH / gameHeight; + setScale(Math.min(1, scaleW, scaleH)); + }; + + update(); + window.addEventListener("resize", update); + return () => window.removeEventListener("resize", update); + }, [gameWidth, gameHeight]); + + return scale; +} diff --git a/src/app/hooks/useGameSetup.ts b/src/app/hooks/useGameSetup.ts new file mode 100644 index 0000000..373eb20 --- /dev/null +++ b/src/app/hooks/useGameSetup.ts @@ -0,0 +1,63 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { DIFFICULTY_PRESETS, type GameDifficulty } from "@/constants/game"; + +export type Phase = "menu" | "countdown" | "playing" | "gameover"; + +export function useGameSetup(opts?: { + muted?: boolean; + initialDifficulty?: GameDifficulty; +}) { + const muted = opts?.muted ?? false; + const [phase, setPhase] = useState("menu"); + const [difficulty, setDifficulty] = useState( + opts?.initialDifficulty ?? "classic", + ); + const [paused, setPaused] = useState(false); + const [bump, setBump] = useState(false); + + const eatSnd = useMemo(() => new Audio("/sounds/food.mp3"), []); + const dieSnd = useMemo(() => new Audio("/sounds/gameover.mp3"), []); + const keySnd = useMemo(() => new Audio("/sounds/move.mp3"), []); + + useEffect(() => { + eatSnd.volume = 0.7; + dieSnd.volume = 0.9; + keySnd.volume = 0.4; + }, [eatSnd, dieSnd, keySnd]); + + const play = useCallback( + (a: HTMLAudioElement) => { + if (muted) return; + try { + a.currentTime = 0; + void a.play(); + } catch (err) { + console.error("Audio play exception:", err); + } + }, + [muted], + ); + + const playEat = useCallback(() => play(eatSnd), [play, eatSnd]); + const playDie = useCallback(() => play(dieSnd), [play, dieSnd]); + const playMove = useCallback(() => play(keySnd), [play, keySnd]); + + const triggerBump = useCallback(() => { + setBump(true); + const id = setTimeout(() => setBump(false), 200); + return () => clearTimeout(id); + }, []); + + return { + phase, + setPhase, + difficulty, + setDifficulty, + paused, + setPaused, + bump, + triggerBump, + tuning: DIFFICULTY_PRESETS[difficulty], + sounds: { playEat, playDie, playMove }, + }; +} diff --git a/src/app/hooks/useInput.ts b/src/app/hooks/useInput.ts index 83e6320..74c1341 100644 --- a/src/app/hooks/useInput.ts +++ b/src/app/hooks/useInput.ts @@ -1,6 +1,6 @@ -import { useEffect } from 'react'; -import { keyToDir, Dir } from '@/types'; -import { isOpposite } from '@/utils/logic'; +import { useEffect } from "react"; +import { keyToDir, Dir } from "@/types"; +import { isOpposite } from "@/utils/logic"; export function useInput(opts: { alive: boolean; @@ -14,7 +14,7 @@ export function useInput(opts: { useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { // restart - if (e.code === 'Space') { + if (e.code === "Space") { if (!alive) onRestart(); return; } @@ -32,7 +32,7 @@ export function useInput(opts: { } }; - window.addEventListener('keydown', onKeyDown); - return () => window.removeEventListener('keydown', onKeyDown); + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); }, [alive, getCurrentDir, onTurn, onRestart, onMoveKey]); } diff --git a/src/app/hooks/useLeaderboard.ts b/src/app/hooks/useLeaderboard.ts index 23ffe8a..06b1d1c 100644 --- a/src/app/hooks/useLeaderboard.ts +++ b/src/app/hooks/useLeaderboard.ts @@ -1,27 +1,29 @@ -import { useCallback, useState } from 'react'; -import type { GameDifficulty } from '@/constants/game'; +import { useCallback, useEffect, useState } from "react"; +import type { GameDifficulty } from "@/constants/game"; +import { submitScore as submitToSupabase } from "@/services/leaderboardService"; +import { isSupabaseConfigured } from "@/supabase/client"; -export type LeaderboardEntry = { +export type LocalLeaderboardEntry = { score: number; difficulty: GameDifficulty; date: number; }; -const STORAGE_KEY = 'snake-leaderboard'; +const STORAGE_KEY = "snake-leaderboard"; const MAX_ENTRIES = 10; -function loadLeaderboard(): LeaderboardEntry[] { +function loadLocalLeaderboard(): LocalLeaderboardEntry[] { try { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return []; - const parsed = JSON.parse(raw) as LeaderboardEntry[]; + const parsed = JSON.parse(raw) as LocalLeaderboardEntry[]; return Array.isArray(parsed) ? parsed : []; } catch { return []; } } -function saveLeaderboard(entries: LeaderboardEntry[]) { +function saveLocalLeaderboard(entries: LocalLeaderboardEntry[]) { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(entries)); } catch { @@ -30,21 +32,25 @@ function saveLeaderboard(entries: LeaderboardEntry[]) { } export function useLeaderboard() { - const [entries, setEntries] = useState(loadLeaderboard); + const [localEntries, setLocalEntries] = + useState(loadLocalLeaderboard); const submitScore = useCallback( - (score: number, difficulty: GameDifficulty) => { - const next: LeaderboardEntry[] = [ - ...entries, + (score: number, difficulty: GameDifficulty, playerName?: string) => { + if (isSupabaseConfigured()) { + void submitToSupabase(score, difficulty, playerName); + } + const next: LocalLeaderboardEntry[] = [ + ...localEntries, { score, difficulty, date: Date.now() }, ] .sort((a, b) => b.score - a.score) .slice(0, MAX_ENTRIES); - setEntries(next); - saveLeaderboard(next); + setLocalEntries(next); + saveLocalLeaderboard(next); }, - [entries] + [localEntries], ); - return { entries, submitScore }; + return { localEntries, submitScore }; } diff --git a/src/app/hooks/usePauseHotkey.ts b/src/app/hooks/usePauseHotkey.ts index 1e1b4f0..c0da1ee 100644 --- a/src/app/hooks/usePauseHotkey.ts +++ b/src/app/hooks/usePauseHotkey.ts @@ -1,15 +1,15 @@ // hooks/usePauseHotkey.ts -import { useEffect } from 'react'; +import { useEffect } from "react"; export function usePauseHotkey(enabled: boolean, toggle: () => void) { useEffect(() => { const onKey = (e: KeyboardEvent) => { if (!enabled) return; - if (e.key.toLowerCase() === 'p') { + if (e.key.toLowerCase() === "p") { e.preventDefault(); toggle(); } }; - window.addEventListener('keydown', onKey); - return () => window.removeEventListener('keydown', onKey); + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); }, [enabled, toggle]); } diff --git a/src/app/hooks/useSettings.ts b/src/app/hooks/useSettings.ts new file mode 100644 index 0000000..84e816e --- /dev/null +++ b/src/app/hooks/useSettings.ts @@ -0,0 +1,79 @@ +import { useCallback, useEffect, useState } from "react"; +import type { GameDifficulty } from "@/constants/game"; + +const MUTE_KEY = "snake-muted"; +const DIFFICULTY_KEY = "snake-difficulty"; +const PLAYER_NAME_KEY = "snake-player-name"; + +function loadMuted(): boolean { + try { + return localStorage.getItem(MUTE_KEY) === "true"; + } catch { + return false; + } +} + +function loadDifficulty(): GameDifficulty { + try { + const v = localStorage.getItem(DIFFICULTY_KEY); + if ( + v === "relaxed" || + v === "classic" || + v === "expert" || + v === "blitz" || + v === "endless" + ) + return v; + } catch { + // ignore + } + return "classic"; +} + +function loadPlayerName(): string { + try { + return localStorage.getItem(PLAYER_NAME_KEY) ?? ""; + } catch { + return ""; + } +} + +export function useSettings() { + const [muted, setMutedState] = useState(loadMuted); + const [playerName, setPlayerNameState] = useState(loadPlayerName); + + const setMuted = useCallback((v: boolean) => { + setMutedState(v); + try { + localStorage.setItem(MUTE_KEY, String(v)); + } catch { + // ignore + } + }, []); + + const setPlayerName = useCallback((v: string) => { + setPlayerNameState(v); + try { + localStorage.setItem(PLAYER_NAME_KEY, v); + } catch { + // ignore + } + }, []); + + const persistDifficulty = useCallback((d: GameDifficulty) => { + try { + localStorage.setItem(DIFFICULTY_KEY, d); + } catch { + // ignore + } + }, []); + + return { + muted, + setMuted, + playerName, + setPlayerName, + persistDifficulty, + initialDifficulty: loadDifficulty(), + }; +} diff --git a/src/app/hooks/useSnake.ts b/src/app/hooks/useSnake.ts deleted file mode 100644 index 704e1a1..0000000 --- a/src/app/hooks/useSnake.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { useState, useCallback } from 'react'; -import type { XY, Dir } from '@/types'; - -export function useSnake(initial: XY[]) { - const [snake, setSnake] = useState(initial); - const [dir, setDir] = useState('right'); - - const turn = useCallback((d: Dir) => setDir(d), []); - const step = useCallback((nextHead: XY, grow = false) => { - setSnake((s) => (grow ? [nextHead, ...s] : [nextHead, ...s.slice(0, -1)])); - }, []); - - return { snake, dir, turn, step, setSnake, setDir }; -} diff --git a/src/app/hooks/useSnakeGame.ts b/src/app/hooks/useSnakeGame.ts index c30a488..d7931d5 100644 --- a/src/app/hooks/useSnakeGame.ts +++ b/src/app/hooks/useSnakeGame.ts @@ -1,14 +1,13 @@ -import { useCallback, useRef, useState } from 'react'; -import type { XY, Dir, Food, FoodKind } from '@/types'; +import { useCallback, useRef, useState } from "react"; +import type { XY, Dir, Food, FoodKind } from "@/types"; +import { eq, initSnake, randomFreeCell, generateObstacles } from "@/utils/logic"; import { - eq, - nextHead, - initSnake, - wrapPoint, - randomFreeCell, - generateObstacles, -} from '@/utils/logic'; -import { FOOD_EMOJIS, POWER_UP_CONFIG } from '@/constants/game'; + computeNextHead, + isOutOfBounds, + checkCollisions, + willEatFood, +} from "@/utils/gameTick"; +import { FOOD_EMOJIS, POWER_UP_CONFIG } from "@/constants/game"; export type GameConfig = { cols: number; @@ -27,15 +26,17 @@ export function useSnakeGame( opts?: { onEat?: (value: number, kind: FoodKind) => void; onDie?: () => void; - } + }, ) { const { cols, rows, wrap, obstacleCount, powerChance } = config; - const dirRef = useRef('right'); + const dirRef = useRef("right"); const nextDirRef = useRef(null); const snakeRef = useRef(initSnake(cols, rows)); const foodRef = useRef(null); const obstaclesRef = useRef([]); + const freezeUntilRef = useRef(0); + const ghostUntilRef = useRef(0); const [alive, setAlive] = useState(true); const [score, setScore] = useState(0); @@ -46,14 +47,14 @@ export function useSnakeGame( const coords = randomFreeCell(snake, cols, rows, blocked); const isPowerUp = Math.random() < powerChance; - const kinds: FoodKind[] = ['golden', 'freeze', 'ghost', 'multiplier']; + const kinds: FoodKind[] = ["golden", "freeze", "ghost", "multiplier"]; const kind: FoodKind = isPowerUp ? kinds[Math.floor(Math.random() * kinds.length)] - : 'normal'; + : "normal"; const cfg = POWER_UP_CONFIG[kind]; const emoji = - kind === 'normal' + kind === "normal" ? FOOD_EMOJIS[Math.floor(Math.random() * FOOD_EMOJIS.length)] : cfg.emoji; @@ -64,16 +65,18 @@ export function useSnakeGame( value: cfg.value, }; }, - [cols, rows, powerChance] + [cols, rows, powerChance], ); const reset = useCallback(() => { const snake = initSnake(cols, rows); snakeRef.current = snake; - dirRef.current = 'right'; + dirRef.current = "right"; nextDirRef.current = null; obstaclesRef.current = generateObstacles(obstacleCount, cols, rows, snake); foodRef.current = spawnFood(snake); + freezeUntilRef.current = 0; + ghostUntilRef.current = 0; setAlive(true); setScore(0); }, [cols, rows, obstacleCount, spawnFood]); @@ -83,6 +86,9 @@ export function useSnakeGame( }, []); const tick = useCallback(() => { + const now = Date.now(); + if (now < freezeUntilRef.current) return; + if (nextDirRef.current) { dirRef.current = nextDirRef.current; nextDirRef.current = null; @@ -92,38 +98,39 @@ export function useSnakeGame( const head = snake[0]; if (!head) return; - let nh = nextHead(head, dirRef.current); + const nh = computeNextHead(head, dirRef.current, cols, rows, wrap); - 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; - } + if (!wrap && isOutOfBounds(nh, cols, rows)) { + setAlive(false); + opts?.onDie?.(); + return; } const food = foodRef.current; - const willEat = !!food && eq(nh, food); - const bodyToCheck = willEat ? snake : snake.slice(0, -1); - - const hitBody = bodyToCheck.some((s) => eq(s, nh)); - const hitObstacle = obstaclesRef.current.some((o) => eq(o, nh)); - - if (hitBody || hitObstacle) { + const eating = willEatFood(nh, food); + const bodyToCheck = eating ? snake : snake.slice(0, -1); + const ghostActive = now < ghostUntilRef.current; + const collision = checkCollisions( + nh, + bodyToCheck, + obstaclesRef.current, + ghostActive, + ); + + if (collision !== "none") { setAlive(false); opts?.onDie?.(); return; } - if (willEat) { + if (eating && food) { snake.unshift(nh); const value = food.value ?? 1; setScore((s) => s + value); foodRef.current = spawnFood(snake); opts?.onEat?.(value, food.kind); + if (food.kind === "freeze") freezeUntilRef.current = now + 2500; + if (food.kind === "ghost") ghostUntilRef.current = now + 4000; } else { snake.unshift(nh); snake.pop(); diff --git a/src/app/hooks/useSwipe.ts b/src/app/hooks/useSwipe.ts index 545f3bb..61b41e7 100644 --- a/src/app/hooks/useSwipe.ts +++ b/src/app/hooks/useSwipe.ts @@ -1,5 +1,5 @@ -import { useCallback, useEffect, useRef } from 'react'; -import type { Dir } from '@/types'; +import { useCallback, useEffect, useRef } from "react"; +import type { Dir } from "@/types"; const MIN_SWIPE_DIST = 30; @@ -16,7 +16,7 @@ export function useSwipe(opts: { const t = e.touches[0]; if (t) startRef.current = { x: t.clientX, y: t.clientY }; }, - [enabled] + [enabled], ); const handleEnd = useCallback( @@ -34,21 +34,21 @@ export function useSwipe(opts: { if (adx < MIN_SWIPE_DIST && ady < MIN_SWIPE_DIST) return; if (adx > ady) { - onSwipe(dx > 0 ? 'right' : 'left'); + onSwipe(dx > 0 ? "right" : "left"); } else { - onSwipe(dy > 0 ? 'down' : 'up'); + onSwipe(dy > 0 ? "down" : "up"); } }, - [enabled, onSwipe] + [enabled, onSwipe], ); useEffect(() => { const target = document; - target.addEventListener('touchstart', handleStart, { passive: true }); - target.addEventListener('touchend', handleEnd, { passive: true }); + target.addEventListener("touchstart", handleStart, { passive: true }); + target.addEventListener("touchend", handleEnd, { passive: true }); return () => { - target.removeEventListener('touchstart', handleStart); - target.removeEventListener('touchend', handleEnd); + target.removeEventListener("touchstart", handleStart); + target.removeEventListener("touchend", handleEnd); }; }, [handleStart, handleEnd]); } diff --git a/src/app/hooks/useTicker.ts b/src/app/hooks/useTicker.ts index 282acf3..e34df48 100644 --- a/src/app/hooks/useTicker.ts +++ b/src/app/hooks/useTicker.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef } from 'react'; +import { useEffect, useRef } from "react"; export function useTicker(ms: number, tick: () => void, running: boolean) { const saved = useRef(tick); diff --git a/src/app/main.tsx b/src/app/main.tsx index 9707d82..3854929 100644 --- a/src/app/main.tsx +++ b/src/app/main.tsx @@ -1,9 +1,9 @@ -import React from 'react'; -import ReactDOM from 'react-dom/client'; -import App from './App'; +import React from "react"; +import ReactDOM from "react-dom/client"; +import App from "./App"; -ReactDOM.createRoot(document.getElementById('root')!).render( +ReactDOM.createRoot(document.getElementById("root")!).render( - + , ); diff --git a/src/app/services/leaderboardService.ts b/src/app/services/leaderboardService.ts new file mode 100644 index 0000000..0a26bbc --- /dev/null +++ b/src/app/services/leaderboardService.ts @@ -0,0 +1,52 @@ +import { supabase, isSupabaseConfigured } from "@/supabase/client"; +import type { GameDifficulty } from "@/constants/game"; + +export type LeaderboardScore = { + rank: number; + score: number; + difficulty: GameDifficulty; + playerName: string | null; + createdAt: string; +}; + +/** Fetch global leaderboard for a difficulty. */ +export async function fetchLeaderboard( + difficulty: GameDifficulty, + limit = 10, +): Promise { + if (!isSupabaseConfigured() || !supabase) return []; + + const { data, error } = await supabase + .from("snake_scores") + .select("score, player_name, created_at") + .eq("difficulty", difficulty) + .order("score", { ascending: false }) + .limit(limit); + + if (error) return []; + + return (data ?? []).map((row, i) => ({ + rank: i + 1, + score: row.score, + difficulty, + playerName: row.player_name ?? "Anonymous", + createdAt: row.created_at, + })); +} + +/** Submit a score to the global leaderboard. */ +export async function submitScore( + score: number, + difficulty: GameDifficulty, + playerName?: string, +): Promise { + if (!isSupabaseConfigured() || !supabase) return false; + + const { error } = await supabase.from("snake_scores").insert({ + score, + difficulty, + player_name: playerName?.trim() || null, + }); + + return !error; +} diff --git a/src/app/styles/App.css b/src/app/styles/App.css index ad17777..679f55e 100644 --- a/src/app/styles/App.css +++ b/src/app/styles/App.css @@ -1,5 +1,5 @@ /* β€”β€”β€” Tailwind base β€”β€”β€” */ -@import 'tailwindcss'; +@import "tailwindcss"; /* β€”β€”β€” Custom animation β€”β€”β€” */ @keyframes bump { @@ -26,12 +26,18 @@ } /* β€”β€”β€” Optional extras β€”β€”β€” */ -/* Slight body padding + dark background (helps with layout) */ body { @apply bg-black font-mono text-white; + -webkit-tap-highlight-color: transparent; + touch-action: manipulation; } -/* Center canvas nicely when needed */ canvas { @apply mx-auto block rounded; } + +/* Prevent pull-to-refresh and overscroll on mobile */ +html, +body { + overscroll-behavior: none; +} diff --git a/src/app/supabase/client.ts b/src/app/supabase/client.ts new file mode 100644 index 0000000..54d86f8 --- /dev/null +++ b/src/app/supabase/client.ts @@ -0,0 +1,13 @@ +import { createClient, type SupabaseClient } from "@supabase/supabase-js"; + +const supabaseUrl = String(import.meta.env.VITE_SUPABASE_URL ?? "").trim(); +const supabaseAnonKey = String( + import.meta.env.VITE_SUPABASE_ANON_KEY ?? "", +).trim(); + +export const supabase: SupabaseClient | null = + supabaseUrl && supabaseAnonKey + ? createClient(supabaseUrl, supabaseAnonKey) + : null; + +export const isSupabaseConfigured = (): boolean => !!supabase; diff --git a/src/app/types/index.ts b/src/app/types/index.ts index b02929b..82fc393 100644 --- a/src/app/types/index.ts +++ b/src/app/types/index.ts @@ -1,15 +1,15 @@ // src/app/types/index.ts // Re-export from sibling type modules -export * from './game'; -export * from './ui'; +export * from "./game"; +export * from "./ui"; // β€”β€”β€” Core shared types β€”β€”β€” // Basic coordinate point export type XY = { x: number; y: number }; -export type FoodKind = 'normal' | 'golden' | 'freeze' | 'ghost' | 'multiplier'; +export type FoodKind = "normal" | "golden" | "freeze" | "ghost" | "multiplier"; // Food extends XY so it can hold rendering + gameplay metadata export type Food = XY & { @@ -20,18 +20,18 @@ export type Food = XY & { }; // Snake movement direction -export type Dir = 'up' | 'down' | 'left' | 'right'; +export type Dir = "up" | "down" | "left" | "right"; // 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', + 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/types/ui.ts b/src/app/types/ui.ts index 2c5386b..6f4efc9 100644 --- a/src/app/types/ui.ts +++ b/src/app/types/ui.ts @@ -2,11 +2,11 @@ // palette tokens (helps if you switch dark/light later) export const COLORS = { - background: '#0a0a0a', - grid: '#666', - snake: '#22c55e', - food: '#ef4444', - text: '#f8fafc', + background: "#0a0a0a", + grid: "#666", + snake: "#22c55e", + food: "#ef4444", + text: "#f8fafc", }; // animation timing diff --git a/src/app/utils/canvas.ts b/src/app/utils/canvas.ts index 40c3504..20add48 100644 --- a/src/app/utils/canvas.ts +++ b/src/app/utils/canvas.ts @@ -1,20 +1,20 @@ -import type { GameTuning } from '@/constants/game'; -import type { XY, Food } from '@/types'; +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; const H = T.ROWS * T.CELL; - ctx.fillStyle = '#0f172a'; + ctx.fillStyle = "#0f172a"; ctx.fillRect(0, 0, W, H); - ctx.strokeStyle = '#334155'; + ctx.strokeStyle = "#334155"; ctx.lineWidth = 2; ctx.strokeRect(0, 0, W, H); ctx.save(); ctx.globalAlpha = 0.4; - ctx.strokeStyle = '#1e293b'; + ctx.strokeStyle = "#1e293b"; ctx.lineWidth = 1; for (let i = 1; i < T.COLS; i++) { @@ -37,10 +37,10 @@ export function drawGrid(ctx: CanvasRenderingContext2D, T: GameTuning) { export function drawObstacles( ctx: CanvasRenderingContext2D, obstacles: XY[], - T: GameTuning + T: GameTuning, ) { - ctx.fillStyle = '#374151'; - ctx.strokeStyle = '#4b5563'; + ctx.fillStyle = "#374151"; + ctx.strokeStyle = "#4b5563"; ctx.lineWidth = 1; for (const { x, y } of obstacles) { const px = x * T.CELL; @@ -53,21 +53,21 @@ export function drawObstacles( export function drawFood( ctx: CanvasRenderingContext2D, f: Food | null, - T: GameTuning + T: GameTuning, ) { 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'; + const isPowerUp = f.kind && f.kind !== "normal"; if (isPowerUp) { ctx.save(); - ctx.shadowColor = '#fbbf24'; + 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.fillStyle = "rgba(251, 191, 36, 0.3)"; ctx.fill(); ctx.restore(); } @@ -75,21 +75,21 @@ export function drawFood( if (f.emoji) { ctx.save(); 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.textAlign = "center"; + ctx.textBaseline = "middle"; ctx.fillText(f.emoji, cx, cy); ctx.restore(); return; } - ctx.fillStyle = '#ef4444'; + ctx.fillStyle = "#ef4444"; ctx.fillRect(f.x * T.CELL, f.y * T.CELL, T.CELL, T.CELL); } export function drawSnake( ctx: CanvasRenderingContext2D, snake: XY[], - T: GameTuning + T: GameTuning, ) { const pad = 1; snake.forEach(({ x, y }, i) => { @@ -97,10 +97,10 @@ export function drawSnake( const py = y * T.CELL + pad; const size = T.CELL - pad * 2; const isHead = i === 0; - ctx.fillStyle = isHead ? '#10b981' : '#22c55e'; + ctx.fillStyle = isHead ? "#10b981" : "#22c55e"; ctx.fillRect(px, py, size, size); if (isHead) { - ctx.strokeStyle = '#059669'; + ctx.strokeStyle = "#059669"; ctx.lineWidth = 1; ctx.strokeRect(px, py, size, size); } @@ -111,17 +111,17 @@ export function drawGameOver(ctx: CanvasRenderingContext2D, T: GameTuning) { const W = T.COLS * T.CELL; const H = T.ROWS * T.CELL; ctx.save(); - ctx.fillStyle = 'rgba(0,0,0,0.6)'; + ctx.fillStyle = "rgba(0,0,0,0.6)"; ctx.fillRect(0, 0, W, H); ctx.globalAlpha = 1; - 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.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(); } @@ -131,7 +131,7 @@ export function drawFrame( food: Food | null, snake: XY[], obstacles: XY[], - T: GameTuning + T: GameTuning, ) { drawGrid(ctx, T); drawObstacles(ctx, obstacles, T); diff --git a/src/app/utils/gameTick.ts b/src/app/utils/gameTick.ts new file mode 100644 index 0000000..c0d4467 --- /dev/null +++ b/src/app/utils/gameTick.ts @@ -0,0 +1,35 @@ +import type { XY, Dir, Food } from "@/types"; +import { eq, nextHead, wrapPoint } from "./logic"; + +export function computeNextHead( + head: XY, + dir: Dir, + cols: number, + rows: number, + wrap: boolean, +): XY { + let nh = nextHead(head, dir); + if (wrap) nh = wrapPoint(nh, cols, rows); + return nh; +} + +export function isOutOfBounds(p: XY, cols: number, rows: number): boolean { + return p.x < 0 || p.x >= cols || p.y < 0 || p.y >= rows; +} + +export function checkCollisions( + nh: XY, + bodyToCheck: XY[], + obstacles: XY[], + ghostActive: boolean, +): "body" | "obstacle" | "none" { + const hitBody = bodyToCheck.some((s) => eq(s, nh)); + const hitObstacle = obstacles.some((o) => eq(o, nh)); + if (hitBody && !ghostActive) return "body"; + if (hitObstacle) return "obstacle"; + return "none"; +} + +export function willEatFood(nh: XY, food: Food | null): boolean { + return !!food && eq(nh, food); +} diff --git a/src/app/utils/logic.ts b/src/app/utils/logic.ts index 14fd4b8..bd41967 100644 --- a/src/app/utils/logic.ts +++ b/src/app/utils/logic.ts @@ -1,20 +1,20 @@ // src/app/utils/logic.ts -import type { XY, Dir } from '@/types'; +import type { XY, Dir } from "@/types"; export const eq = (a: XY, b: XY) => a.x === b.x && a.y === b.y; export const isOpposite = (a: Dir, b: Dir) => - (a === 'up' && b === 'down') || - (a === 'down' && b === 'up') || - (a === 'left' && b === 'right') || - (a === 'right' && b === 'left'); + (a === "up" && b === "down") || + (a === "down" && b === "up") || + (a === "left" && b === "right") || + (a === "right" && b === "left"); export const nextHead = (h: XY, d: Dir): XY => - d === 'up' + d === "up" ? { x: h.x, y: h.y - 1 } - : d === 'down' + : d === "down" ? { x: h.x, y: h.y + 1 } - : d === 'left' + : d === "left" ? { x: h.x - 1, y: h.y } : { x: h.x + 1, y: h.y }; @@ -43,7 +43,7 @@ export function randomFreeCell( snake: XY[], cols: number, rows: number, - blocked: XY[] = [] + blocked: XY[] = [], ): XY { const isBlocked = (x: number, y: number) => snake.some((c) => c.x === x && c.y === y) || @@ -59,9 +59,9 @@ export function randomFreeCell( /** Infer current direction from headβ†’second segment. */ export function inferDirFromSnake(snake: XY[]): Dir { const [h, s] = snake; - if (!s) return 'right'; - if (h.x === s.x) return h.y < s.y ? 'up' : 'down'; - return h.x < s.x ? 'left' : 'right'; + if (!s) return "right"; + 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 { @@ -83,7 +83,7 @@ export function generateObstacles( cols: number, rows: number, snake: XY[], - padding = 2 + padding = 2, ): XY[] { if (count <= 0) return []; const taken = new Set(); diff --git a/src/app/utils/speed.ts b/src/app/utils/speed.ts new file mode 100644 index 0000000..3a77657 --- /dev/null +++ b/src/app/utils/speed.ts @@ -0,0 +1,18 @@ +import type { GameDifficulty } from "@/constants/game"; +import { DIFFICULTY_PRESETS } from "@/constants/game"; + +export function computeDelayMs( + difficulty: GameDifficulty, + score: number, +): number { + const T = DIFFICULTY_PRESETS[difficulty]; + 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); +} diff --git a/supabase/migrations/001_snake_scores.sql b/supabase/migrations/001_snake_scores.sql new file mode 100644 index 0000000..762f50f --- /dev/null +++ b/supabase/migrations/001_snake_scores.sql @@ -0,0 +1,23 @@ +-- Snake game leaderboard (anonymous, no auth required) +CREATE TABLE IF NOT EXISTS snake_scores ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + score INTEGER NOT NULL, + difficulty TEXT NOT NULL, + player_name TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_snake_scores_difficulty_score + ON snake_scores(difficulty, score DESC); + +CREATE INDEX IF NOT EXISTS idx_snake_scores_created + ON snake_scores(created_at DESC); + +-- Allow anonymous inserts and reads (no RLS for simplicity, or enable RLS with permissive policies) +ALTER TABLE snake_scores ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Anyone can insert scores" ON snake_scores + FOR INSERT WITH CHECK (true); + +CREATE POLICY "Anyone can read scores" ON snake_scores + FOR SELECT USING (true); diff --git a/vite.config.ts b/vite.config.ts index c841f83..5b933ab 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,17 +1,17 @@ -import { defineConfig } from 'vite'; -import react from '@vitejs/plugin-react'; -import tailwindcss from '@tailwindcss/vite'; -import path from 'path'; +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import tailwindcss from "@tailwindcss/vite"; +import path from "path"; export default defineConfig({ plugins: [react(), tailwindcss()], resolve: { alias: { - '@': path.resolve(__dirname, './src/app'), - '@components': path.resolve(__dirname, './src/app/components'), - '@hooks': path.resolve(__dirname, './src/app/hooks'), - '@lib': path.resolve(__dirname, './src/app/lib'), - '@types': path.resolve(__dirname, './src/app/types'), + "@": path.resolve(__dirname, "./src/app"), + "@components": path.resolve(__dirname, "./src/app/components"), + "@hooks": path.resolve(__dirname, "./src/app/hooks"), + "@lib": path.resolve(__dirname, "./src/app/lib"), + "@types": path.resolve(__dirname, "./src/app/types"), }, }, build: { From aca1ed26b7f10f54c57df1fdea5283023990a02c Mon Sep 17 00:00:00 2001 From: NickTheDevOpsGuy Date: Wed, 4 Feb 2026 10:54:00 -0500 Subject: [PATCH 2/2] style: auto-format with Prettier [skip-precheck] --- .prettierrc.yml | 2 +- eslint.config.ts | 60 +++++----- index.html | 5 +- src/app/App.tsx | 6 +- src/app/components/CountdownOverlay.tsx | 8 +- src/app/components/GameOverOverlay.tsx | 20 ++-- src/app/components/HUD.tsx | 12 +- src/app/components/HowToPlayModal.tsx | 57 +++++---- src/app/components/LeaderboardModal.tsx | 72 ++++++------ src/app/components/MenuScreen.tsx | 75 +++++------- src/app/components/MobileControls.tsx | 54 ++++----- src/app/components/SettingsModal.tsx | 38 +++--- src/app/components/SnakeCanvas.tsx | 148 ++++++++++++------------ src/app/constants/game.ts | 56 ++++----- src/app/hooks/useBestScore.ts | 6 +- src/app/hooks/useCanvas2D.ts | 4 +- src/app/hooks/useGameScale.ts | 6 +- src/app/hooks/useGameSetup.ts | 20 ++-- src/app/hooks/useInput.ts | 12 +- src/app/hooks/useLeaderboard.ts | 12 +- src/app/hooks/usePauseHotkey.ts | 8 +- src/app/hooks/useSettings.ts | 28 ++--- src/app/hooks/useSnakeGame.ts | 37 +++--- src/app/hooks/useSwipe.ts | 20 ++-- src/app/hooks/useTicker.ts | 2 +- src/app/main.tsx | 10 +- src/app/services/leaderboardService.ts | 20 ++-- src/app/styles/App.css | 2 +- src/app/supabase/client.ts | 6 +- src/app/types/index.ts | 24 ++-- src/app/types/ui.ts | 10 +- src/app/utils/canvas.ts | 56 ++++----- src/app/utils/gameTick.ts | 16 +-- src/app/utils/logic.ts | 26 ++--- src/app/utils/speed.ts | 10 +- vite.config.ts | 18 +-- 36 files changed, 489 insertions(+), 477 deletions(-) diff --git a/.prettierrc.yml b/.prettierrc.yml index 9c00e2e..e7c2875 100644 --- a/.prettierrc.yml +++ b/.prettierrc.yml @@ -1,7 +1,7 @@ semi: true singleQuote: true printWidth: 80 -quoteProps: "as-needed" +quoteProps: 'as-needed' tabWidth: 2 useTabs: false trailingComma: es5 diff --git a/eslint.config.ts b/eslint.config.ts index f020828..1d29924 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -1,81 +1,81 @@ // eslint.config.js -import js from "@eslint/js"; -import react from "eslint-plugin-react"; -import reactHooks from "eslint-plugin-react-hooks"; -import reactRefresh from "eslint-plugin-react-refresh"; -import globals from "globals"; -import tseslint from "typescript-eslint"; +import js from '@eslint/js'; +import react from 'eslint-plugin-react'; +import reactHooks from 'eslint-plugin-react-hooks'; +import reactRefresh from 'eslint-plugin-react-refresh'; +import globals from 'globals'; +import tseslint from 'typescript-eslint'; export default [ // 🧹 Ignore build + coverage folders - { ignores: ["dist", "coverage"] }, + { ignores: ['dist', 'coverage'] }, // βœ… Base + TypeScript + React presets js.configs.recommended, ...tseslint.configs.recommended, - reactHooks.configs["recommended-latest"], + reactHooks.configs['recommended-latest'], reactRefresh.configs.vite, // 🧩 App code (browser) { - files: ["src/**/*.{ts,tsx,js,jsx}"], + files: ['src/**/*.{ts,tsx,js,jsx}'], languageOptions: { parser: tseslint.parser, - ecmaVersion: "latest", - sourceType: "module", + ecmaVersion: 'latest', + sourceType: 'module', parserOptions: { ecmaFeatures: { jsx: true } }, globals: globals.browser, }, plugins: { react }, rules: { // 🧠 React 17+ no longer requires importing React in JSX - "react/react-in-jsx-scope": "off", - "react/prop-types": "off", + 'react/react-in-jsx-scope': 'off', + 'react/prop-types': 'off', // 🧹 Lint polish - "@typescript-eslint/no-unused-vars": [ - "warn", - { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }, + '@typescript-eslint/no-unused-vars': [ + 'warn', + { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, ], - "@typescript-eslint/no-unused-expressions": [ - "error", + '@typescript-eslint/no-unused-expressions': [ + 'error', { allowShortCircuit: true, allowTernary: true }, ], - "react-hooks/exhaustive-deps": "warn", + 'react-hooks/exhaustive-deps': 'warn', }, settings: { - react: { version: "detect" }, + react: { version: 'detect' }, }, }, // πŸ§ͺ Tests (Vitest) { - files: ["tests/**/*.{ts,tsx,js,jsx}", "**/*.test.{ts,tsx,js,jsx}"], + files: ['tests/**/*.{ts,tsx,js,jsx}', '**/*.test.{ts,tsx,js,jsx}'], languageOptions: { parser: tseslint.parser, - ecmaVersion: "latest", - sourceType: "module", + ecmaVersion: 'latest', + sourceType: 'module', parserOptions: { ecmaFeatures: { jsx: true } }, globals: { ...globals.browser, ...globals.vitest }, }, plugins: { react }, rules: { - "react/react-in-jsx-scope": "off", + 'react/react-in-jsx-scope': 'off', }, }, // βš™οΈ Config + scripts (Node env) { files: [ - "*.config.{js,cjs,mjs,ts}", - "vite.config.*", - "vitest.config.*", - "scripts/**", + '*.config.{js,cjs,mjs,ts}', + 'vite.config.*', + 'vitest.config.*', + 'scripts/**', ], languageOptions: { parser: tseslint.parser, - ecmaVersion: "latest", - sourceType: "module", + ecmaVersion: 'latest', + sourceType: 'module', globals: globals.node, }, }, diff --git a/index.html b/index.html index 70f08ea..93925ab 100644 --- a/index.html +++ b/index.html @@ -1,7 +1,10 @@ - + diff --git a/src/app/App.tsx b/src/app/App.tsx index f7adf1e..0befb20 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,7 +1,7 @@ // src/SnakeApp.tsx -import React from "react"; -import SnakeCanvas from "./components/SnakeCanvas"; -import "./styles/App.css"; +import React from 'react'; +import SnakeCanvas from './components/SnakeCanvas'; +import './styles/App.css'; export default function SnakeApp() { return ; diff --git a/src/app/components/CountdownOverlay.tsx b/src/app/components/CountdownOverlay.tsx index 48b6946..6723b19 100644 --- a/src/app/components/CountdownOverlay.tsx +++ b/src/app/components/CountdownOverlay.tsx @@ -1,11 +1,11 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect } from 'react'; type Props = { visible: boolean; onComplete: () => void; }; -const STEPS = ["3", "2", "1", "Go!"]; +const STEPS = ['3', '2', '1', 'Go!']; const STEP_MS = 800; export default function CountdownOverlay({ visible, onComplete }: Props) { @@ -27,10 +27,10 @@ export default function CountdownOverlay({ visible, onComplete }: Props) { if (!visible || step >= STEPS.length) return null; return ( -
+
{STEPS[step]} diff --git a/src/app/components/GameOverOverlay.tsx b/src/app/components/GameOverOverlay.tsx index d2a11ad..16fb2b4 100644 --- a/src/app/components/GameOverOverlay.tsx +++ b/src/app/components/GameOverOverlay.tsx @@ -1,5 +1,5 @@ -import { useEffect, useRef } from "react"; -import confetti from "canvas-confetti"; +import { useEffect, useRef } from 'react'; +import confetti from 'canvas-confetti'; type Props = { score: number; @@ -14,7 +14,7 @@ function formatTime(ms: number) { const s = Math.floor(ms / 1000); const m = Math.floor(s / 60); const sec = s % 60; - return m > 0 ? `${m}:${sec.toString().padStart(2, "0")}` : `${s}s`; + return m > 0 ? `${m}:${sec.toString().padStart(2, '0')}` : `${s}s`; } export default function GameOverOverlay({ @@ -34,21 +34,23 @@ export default function GameOverOverlay({ }, [isNewBest]); return ( -
-

Game Over

+
+

Game Over

{isNewBest && ( -

+

New high score!

)} -
-

Score: {score}

+
+

+ Score: {score} +

Best: {best}

Length: {snakeLength}

Time: {formatTime(playTimeMs)}

diff --git a/src/app/components/HowToPlayModal.tsx b/src/app/components/HowToPlayModal.tsx index 7af0ea0..ae74667 100644 --- a/src/app/components/HowToPlayModal.tsx +++ b/src/app/components/HowToPlayModal.tsx @@ -1,4 +1,4 @@ -import { X } from "lucide-react"; +import { X } from 'lucide-react'; type Props = { isOpen: boolean; @@ -10,32 +10,35 @@ export default function HowToPlayModal({ isOpen, onClose }: Props) { return (
e.stopPropagation()} > -
-

+
+

How to Play

-
-

Guide the snake to eat food and grow. Don't hit walls, obstacles, or yourself!

+
+

+ Guide the snake to eat food and grow. Don't hit walls, + obstacles, or yourself! +

-

Controls

-
    +

    Controls

    +
    • Arrow keys or WASD
    • Swipe on mobile
    • Tap the D-pad on mobile
    • @@ -43,17 +46,31 @@ export default function HowToPlayModal({ isOpen, onClose }: Props) {
-

Power-ups

-
    -
  • ⭐ Golden β€” +5 points
  • -
  • ❄️ Freeze β€” Pause timer briefly
  • -
  • πŸ‘» Ghost β€” Pass through yourself once
  • -
  • πŸ”₯ Multiplier β€” +3 points
  • +

    Power-ups

    +
      +
    • + ⭐ Golden β€” +5 points +
    • +
    • + ❄️ Freeze β€” Pause timer + briefly +
    • +
    • + πŸ‘» Ghost β€” Pass through + yourself once +
    • +
    • + πŸ”₯ Multiplier β€” +3 + points +
-

Difficulty

-

Choose Relaxed for wrap-around edges, or Classic/Expert for obstacles. Blitz starts fast!

+

Difficulty

+

+ Choose Relaxed for wrap-around edges, or Classic/Expert for + obstacles. Blitz starts fast! +

diff --git a/src/app/components/LeaderboardModal.tsx b/src/app/components/LeaderboardModal.tsx index b7714c0..0200597 100644 --- a/src/app/components/LeaderboardModal.tsx +++ b/src/app/components/LeaderboardModal.tsx @@ -1,18 +1,18 @@ -import { useState, useEffect } from "react"; -import type { GameDifficulty } from "@/constants/game"; -import { DIFFICULTY_PRESETS } from "@/constants/game"; +import { useState, useEffect } from 'react'; +import type { GameDifficulty } from '@/constants/game'; +import { DIFFICULTY_PRESETS } from '@/constants/game'; import { fetchLeaderboard, type LeaderboardScore, -} from "@/services/leaderboardService"; -import { X } from "lucide-react"; +} from '@/services/leaderboardService'; +import { X } from 'lucide-react'; const DIFFICULTIES: GameDifficulty[] = [ - "relaxed", - "classic", - "expert", - "blitz", - "endless", + 'relaxed', + 'classic', + 'expert', + 'blitz', + 'endless', ]; type Props = { @@ -23,14 +23,14 @@ type Props = { function formatDate(iso: string) { try { const d = new Date(iso); - return d.toLocaleDateString(undefined, { month: "short", day: "numeric" }); + return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); } catch { - return ""; + return ''; } } export default function LeaderboardModal({ isOpen, onClose }: Props) { - const [difficulty, setDifficulty] = useState("classic"); + const [difficulty, setDifficulty] = useState('classic'); const [scores, setScores] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -41,7 +41,7 @@ export default function LeaderboardModal({ isOpen, onClose }: Props) { setError(null); fetchLeaderboard(difficulty) .then(setScores) - .catch(() => setError("Failed to load leaderboard")) + .catch(() => setError('Failed to load leaderboard')) .finally(() => setLoading(false)); }, [isOpen, difficulty]); @@ -49,36 +49,36 @@ export default function LeaderboardModal({ isOpen, onClose }: Props) { return (
e.stopPropagation()} > -
-

+
+

Leaderboard

-
+
{DIFFICULTIES.map((d) => (
-
+
{loading && ( -

Loading...

- )} - {error && ( -

{error}

+

Loading...

)} + {error &&

{error}

} {!loading && !error && scores.length === 0 && ( -

+

No scores yet. Be the first!

)} {!loading && !error && scores.length > 0 && ( -
    +
      {scores.map((s) => (
    • - #{s.rank} - + #{s.rank} + {s.score} - + {s.playerName} - + {formatDate(s.createdAt)}
    • diff --git a/src/app/components/MenuScreen.tsx b/src/app/components/MenuScreen.tsx index 21c57c9..c5af425 100644 --- a/src/app/components/MenuScreen.tsx +++ b/src/app/components/MenuScreen.tsx @@ -1,17 +1,17 @@ -import { useState, useEffect } from "react"; -import type { GameDifficulty } from "@/constants/game"; -import { DIFFICULTY_PRESETS } from "@/constants/game"; -import { Play, Trophy, Settings, HelpCircle } from "lucide-react"; -import LeaderboardModal from "./LeaderboardModal"; -import SettingsModal from "./SettingsModal"; -import HowToPlayModal from "./HowToPlayModal"; +import { useState, useEffect } from 'react'; +import type { GameDifficulty } from '@/constants/game'; +import { DIFFICULTY_PRESETS } from '@/constants/game'; +import { Play, Trophy, Settings, HelpCircle } from 'lucide-react'; +import LeaderboardModal from './LeaderboardModal'; +import SettingsModal from './SettingsModal'; +import HowToPlayModal from './HowToPlayModal'; const DIFFICULTIES: GameDifficulty[] = [ - "relaxed", - "classic", - "expert", - "blitz", - "endless", + 'relaxed', + 'classic', + 'expert', + 'blitz', + 'endless', ]; type Props = { @@ -40,22 +40,20 @@ export default function MenuScreen({ const T = DIFFICULTY_PRESETS[difficulty]; const btnBase = - "flex flex-col items-center justify-center gap-2 rounded-xl border border-zinc-600 bg-zinc-800/80 px-4 py-5 text-white transition hover:bg-zinc-700 active:bg-zinc-600 min-h-[100px]"; + 'flex flex-col items-center justify-center gap-2 rounded-xl border border-zinc-600 bg-zinc-800/80 px-4 py-5 text-white transition hover:bg-zinc-700 active:bg-zinc-600 min-h-[100px]'; return ( -
      -

      Snake

      +
      +

      Snake

      -
      -