added more maple apps

This commit is contained in:
Rodolfo Martinez 2026-02-05 19:12:58 -05:00
parent 423b9a25fb
commit 5f2426c401
82 changed files with 22775 additions and 0 deletions

View file

@ -0,0 +1,12 @@
# React + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.

View file

@ -0,0 +1,33 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
export default [
{ ignores: ['dist'] },
{
files: ['**/*.{js,jsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...js.configs.recommended.rules,
...reactHooks.configs.recommended.rules,
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
]

View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,31 @@
{
"name": "mapleblocks",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router-dom": "^7.6.0"
},
"devDependencies": {
"@eslint/js": "^9.25.0",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"@vitejs/plugin-react": "^4.4.1",
"autoprefixer": "^10.4.21",
"eslint": "^9.25.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^16.0.0",
"postcss": "^8.5.3",
"tailwindcss": "^3.4.17",
"vite": "^6.3.5"
}
}

View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

View file

@ -0,0 +1,381 @@
import React, { useState, useEffect } from "react";
import {
BrowserRouter,
Routes,
Route,
Navigate,
useNavigate,
} from "react-router-dom";
// Import your components (adjust paths as needed)
import WelcomeScreen from "./components/WelcomeScreen";
import ScoresScreen from "./components/ScoresScreen";
import GameBoard from "./components/GameBoard";
// Import storage utility (adjust path as needed)
// import storage from './utils/storage.js';
// Mock storage for demo (replace with actual import)
const mockStorage = {
settings: {
getStartingLevel: () =>
parseInt(localStorage.getItem("mapleBlocks_startingLevel") || "0"),
getGameSpeed: () =>
localStorage.getItem("mapleBlocks_gameSpeed") || "Normal",
saveStartingLevel: (level) =>
localStorage.setItem("mapleBlocks_startingLevel", level.toString()),
saveGameSpeed: (speed) =>
localStorage.setItem("mapleBlocks_gameSpeed", speed),
},
highScores: {
getTopScores: (limit = 10) => {
try {
const scores = JSON.parse(
localStorage.getItem("mapleBlocks_highScores") || "[]",
);
return scores.slice(0, limit);
} catch {
return [];
}
},
addHighScore: (score, level, gameSpeed, playerName = "Anonymous") => {
try {
const scores = JSON.parse(
localStorage.getItem("mapleBlocks_highScores") || "[]",
);
const newScore = {
score: parseInt(score),
level: parseInt(level),
gameSpeed,
playerName,
date: new Date().toISOString(),
timestamp: Date.now(),
};
scores.push(newScore);
scores.sort((a, b) => b.score - a.score);
const topScores = scores.slice(0, 50);
localStorage.setItem(
"mapleBlocks_highScores",
JSON.stringify(topScores),
);
return topScores;
} catch (error) {
console.error("Failed to save high score:", error);
return [];
}
},
isHighScore: (score) => {
try {
const scores = JSON.parse(
localStorage.getItem("mapleBlocks_highScores") || "[]",
);
if (scores.length < 50) return true;
return score > scores[scores.length - 1].score;
} catch {
return true;
}
},
},
};
// Game Over Screen Component
const GameOverScreen = ({
gameResult,
onPlayAgain,
onMainMenu,
onViewScores,
}) => {
const [playerName, setPlayerName] = useState("");
const [isHighScore, setIsHighScore] = useState(false);
const [scoreSaved, setScoreSaved] = useState(false);
useEffect(() => {
const highScore = mockStorage.highScores.isHighScore(gameResult.score);
setIsHighScore(highScore);
}, [gameResult.score]);
const handleSaveScore = () => {
if (playerName.trim()) {
mockStorage.highScores.addHighScore(
gameResult.score,
gameResult.level,
gameResult.gameSpeed || "Normal",
playerName.trim(),
);
setScoreSaved(true);
}
};
const formatTime = (milliseconds) => {
const totalSeconds = Math.floor(milliseconds / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
};
return (
<div className="min-h-screen bg-gradient-to-br from-red-900 via-rose-900 to-pink-900 flex items-center justify-center p-4">
<div className="bg-gray-800 rounded-2xl shadow-2xl p-8 max-w-md w-full text-center">
{/* Game Over Title */}
<div className="mb-6">
<h1 className="text-4xl font-bold text-red-400 mb-2">Game Over</h1>
<p className="text-gray-400">Thanks for playing Maple Blocks!</p>
</div>
{/* Final Stats */}
<div className="space-y-4 mb-6">
<div className="bg-gradient-to-r from-yellow-600 to-orange-600 rounded-lg p-4">
<p className="text-yellow-100 text-sm">Final Score</p>
<p className="text-white text-3xl font-bold">
{gameResult.score.toLocaleString()}
</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="bg-gradient-to-r from-blue-600 to-purple-600 rounded-lg p-3">
<p className="text-blue-100 text-xs">Lines</p>
<p className="text-white text-xl font-bold">{gameResult.lines}</p>
</div>
<div className="bg-gradient-to-r from-green-600 to-teal-600 rounded-lg p-3">
<p className="text-green-100 text-xs">Level</p>
<p className="text-white text-xl font-bold">
{Math.floor(gameResult.lines / 10)}
</p>
</div>
</div>
{gameResult.gameTime && (
<div className="bg-gradient-to-r from-gray-600 to-gray-700 rounded-lg p-3">
<p className="text-gray-100 text-sm">Game Time</p>
<p className="text-white text-lg font-bold">
{formatTime(gameResult.gameTime)}
</p>
</div>
)}
</div>
{/* High Score Input */}
{isHighScore && !scoreSaved && (
<div className="mb-6 p-4 bg-gradient-to-r from-yellow-600 to-orange-600 rounded-lg">
<p className="text-white font-bold mb-3">🎉 New High Score! 🎉</p>
<input
type="text"
placeholder="Enter your name"
value={playerName}
onChange={(e) => setPlayerName(e.target.value.slice(0, 20))}
className="w-full bg-white text-gray-800 px-3 py-2 rounded mb-3 text-center font-semibold"
maxLength={20}
/>
<button
onClick={handleSaveScore}
disabled={!playerName.trim()}
className="w-full bg-white text-orange-600 font-bold py-2 rounded hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed"
>
Save Score
</button>
</div>
)}
{scoreSaved && (
<div className="mb-6 p-3 bg-green-600 rounded-lg">
<p className="text-white font-bold"> Score Saved!</p>
</div>
)}
{/* Action Buttons */}
<div className="space-y-3">
<button
onClick={onPlayAgain}
className="w-full bg-gradient-to-r from-green-500 to-emerald-600 text-white font-bold py-3 px-6 rounded-lg hover:from-green-600 hover:to-emerald-700 transform hover:scale-105 transition-all duration-200"
>
🎮 Play Again
</button>
<button
onClick={onViewScores}
className="w-full bg-gradient-to-r from-blue-500 to-purple-600 text-white font-bold py-3 px-6 rounded-lg hover:from-blue-600 hover:to-purple-700 transform hover:scale-105 transition-all duration-200"
>
🏆 View High Scores
</button>
<button
onClick={onMainMenu}
className="w-full bg-gradient-to-r from-gray-500 to-gray-600 text-white font-bold py-3 px-6 rounded-lg hover:from-gray-600 hover:to-gray-700 transform hover:scale-105 transition-all duration-200"
>
🏠 Main Menu
</button>
</div>
</div>
</div>
);
};
// Main App Router Component
const AppRouter = () => {
const navigate = useNavigate();
const [gameResult, setGameResult] = useState(null);
// Handle starting a new game from welcome screen
const handleStartGame = (settings) => {
// Save settings to storage
if (settings) {
mockStorage.settings.saveStartingLevel(settings.level);
mockStorage.settings.saveGameSpeed(settings.speed);
}
navigate("/game");
};
// Handle viewing scores from welcome screen
const handleViewScores = () => {
navigate("/scores");
};
// Handle game over
const handleGameOver = (result) => {
setGameResult(result);
navigate("/game-over");
};
// Handle quitting game
const handleQuitGame = () => {
navigate("/");
};
// Handle going back from scores
const handleBackFromScores = () => {
navigate("/");
};
// Handle play again from game over
const handlePlayAgain = () => {
setGameResult(null);
navigate("/game");
};
// Handle main menu from game over
const handleMainMenu = () => {
setGameResult(null);
navigate("/");
};
// Handle view scores from game over
const handleViewScoresFromGameOver = () => {
navigate("/scores");
};
return (
<Routes>
{/* Welcome Screen - Main Menu */}
<Route
path="/"
element={
<WelcomeScreen
onStartGame={handleStartGame}
onViewScores={handleViewScores}
/>
}
/>
{/* Scores Screen */}
<Route
path="/scores"
element={<ScoresScreen onBack={handleBackFromScores} />}
/>
{/* Game Board */}
<Route
path="/game"
element={
<GameBoard
startingLevel={mockStorage.settings.getStartingLevel()}
gameSpeed={mockStorage.settings.getGameSpeed()}
onGameOver={handleGameOver}
onQuit={handleQuitGame}
/>
}
/>
{/* Game Over Screen */}
<Route
path="/game-over"
element={
gameResult ? (
<GameOverScreen
gameResult={gameResult}
onPlayAgain={handlePlayAgain}
onMainMenu={handleMainMenu}
onViewScores={handleViewScoresFromGameOver}
/>
) : (
<Navigate to="/" replace />
)
}
/>
{/* Redirect any unknown routes to home */}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
};
// Error Boundary Component
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error) {
return { hasError: true };
}
componentDidCatch(error, errorInfo) {
console.error("Maple Blocks Error:", error, errorInfo);
}
render() {
if (this.state.hasError) {
return (
<div className="min-h-screen bg-gradient-to-br from-red-900 via-rose-900 to-pink-900 flex items-center justify-center p-4">
<div className="bg-gray-800 rounded-2xl shadow-2xl p-8 max-w-md w-full text-center">
<h1 className="text-2xl font-bold text-red-400 mb-4">
Oops! Something went wrong
</h1>
<p className="text-gray-400 mb-6">
Maple Blocks encountered an error. Please refresh the page to try
again.
</p>
<button
onClick={() => window.location.reload()}
className="bg-red-500 hover:bg-red-600 text-white font-bold py-2 px-4 rounded-lg"
>
Refresh Page
</button>
</div>
</div>
);
}
return this.props.children;
}
}
// Main App Component
function App() {
return (
<ErrorBoundary>
<BrowserRouter>
<div className="App">
<AppRouter />
</div>
</BrowserRouter>
</ErrorBoundary>
);
}
export default App;

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4 KiB

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,215 @@
import React, { useState, useEffect } from 'react';
const ScoresScreen = ({ onBack }) => {
const [scores, setScores] = useState([]);
// Mock storage functions - replace with actual imports when integrated
const mockHighScores = [
{ score: 125000, level: 5, gameSpeed: 'Fast', playerName: 'ProPlayer', date: '2024-05-20T10:30:00Z' },
{ score: 98500, level: 3, gameSpeed: 'Normal', playerName: 'BlockMaster', date: '2024-05-19T15:45:00Z' },
{ score: 87200, level: 2, gameSpeed: 'Normal', playerName: 'TetrisKing', date: '2024-05-18T20:15:00Z' },
{ score: 76800, level: 4, gameSpeed: 'Slow', playerName: 'PuzzleQueen', date: '2024-05-17T12:00:00Z' },
{ score: 65400, level: 1, gameSpeed: 'Fast', playerName: 'SpeedRunner', date: '2024-05-16T18:30:00Z' },
{ score: 54200, level: 0, gameSpeed: 'Normal', playerName: 'Beginner', date: '2024-05-15T14:20:00Z' },
{ score: 43100, level: 2, gameSpeed: 'Slow', playerName: 'Careful', date: '2024-05-14T16:45:00Z' },
{ score: 32800, level: 1, gameSpeed: 'Normal', playerName: 'Learning', date: '2024-05-13T11:15:00Z' }
];
useEffect(() => {
// In real implementation, replace with: storage.highScores.getHighScores()
setScores(mockHighScores);
}, []);
const getTopScores = () => {
return scores.slice(0, 3); // Show only top 3
};
const formatDate = (dateString) => {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
});
};
const getRankIcon = (index) => {
switch(index) {
case 0: return '👑'; // Crown for 1st place
case 1: return '🥈'; // Silver medal for 2nd
case 2: return '🥉'; // Bronze medal for 3rd
default: return '🏆'; // This won't be used since we only show top 3
}
};
const getSpeedIcon = (speed) => {
switch(speed) {
case 'Slow': return '🐌';
case 'Normal': return '🚀';
case 'Fast': return '⚡';
default: return '🎯';
}
};
const getLevelIcon = (level) => {
if (level === 0) return '🌱';
if (level <= 2) return '🎯';
if (level <= 5) return '🔥';
if (level <= 8) return '💎';
return '🌟';
};
const clearAllScores = () => {
if (window.confirm('Are you sure you want to clear all high scores? This cannot be undone.')) {
setScores([]);
// In real implementation: storage.highScores.clearHighScores()
}
};
const topScores = getTopScores();
return (
<div className="min-h-screen bg-gradient-to-br from-red-900 via-rose-900 to-pink-900 p-4">
<div className="max-w-4xl mx-auto">
{/* Header */}
<div className="bg-gray-800 rounded-2xl shadow-2xl p-6 mb-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-3">
<span className="text-3xl">🏆</span>
<h1 className="text-3xl font-bold text-white">High Scores</h1>
<span className="text-3xl">🏆</span>
</div>
<button
onClick={onBack}
className="bg-red-500 hover:bg-red-600 text-white px-6 py-2 rounded-lg font-semibold transition-all duration-200 transform hover:scale-105"
>
Back
</button>
</div>
</div>
{/* Statistics Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<div className="bg-gradient-to-r from-yellow-500 to-orange-500 rounded-xl p-4 text-white">
<div className="flex items-center justify-between">
<div>
<p className="text-sm opacity-90">Personal Best</p>
<p className="text-2xl font-bold">
{scores.length > 0 ? scores[0].score.toLocaleString() : '0'}
</p>
</div>
<span className="text-3xl">👑</span>
</div>
</div>
<div className="bg-gradient-to-r from-blue-500 to-purple-500 rounded-xl p-4 text-white">
<div className="flex items-center justify-between">
<div>
<p className="text-sm opacity-90">Total Games</p>
<p className="text-2xl font-bold">{scores.length}</p>
</div>
<span className="text-3xl">🎮</span>
</div>
</div>
<div className="bg-gradient-to-r from-green-500 to-teal-500 rounded-xl p-4 text-white">
<div className="flex items-center justify-between">
<div>
<p className="text-sm opacity-90">Average Score</p>
<p className="text-2xl font-bold">
{scores.length > 0
? Math.round(scores.reduce((sum, s) => sum + s.score, 0) / scores.length).toLocaleString()
: '0'
}
</p>
</div>
<span className="text-3xl">📊</span>
</div>
</div>
</div>
{/* Scores List */}
<div className="bg-gray-800 rounded-2xl shadow-2xl overflow-hidden">
{topScores.length > 0 ? (
<div className="divide-y divide-gray-700">
{topScores.map((score, index) => (
<div
key={index}
className={`p-4 transition-all duration-200 hover:bg-gray-700 ${
index < 3 ? 'bg-gradient-to-r from-gray-800 to-gray-750' : ''
}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<span className="text-2xl">{getRankIcon(index)}</span>
<span className={`font-bold text-lg ${
index === 0 ? 'text-yellow-400' :
index === 1 ? 'text-gray-300' :
index === 2 ? 'text-orange-400' :
'text-white'
}`}>
#{index + 1}
</span>
</div>
<div>
<p className="text-white font-semibold text-lg">
{score.playerName}
</p>
<div className="flex items-center space-x-3 text-sm text-gray-400">
<span className="flex items-center space-x-1">
<span>{getLevelIcon(score.level)}</span>
<span>Level {score.level}</span>
</span>
<span className="flex items-center space-x-1">
<span>{getSpeedIcon(score.gameSpeed)}</span>
<span>{score.gameSpeed}</span>
</span>
<span className="flex items-center space-x-1">
<span>📅</span>
<span>{formatDate(score.date)}</span>
</span>
</div>
</div>
</div>
<div className="text-right">
<p className={`font-bold text-xl ${
index === 0 ? 'text-yellow-400' :
index === 1 ? 'text-gray-300' :
index === 2 ? 'text-orange-400' :
'text-white'
}`}>
{score.score.toLocaleString()}
</p>
<p className="text-gray-400 text-sm">points</p>
</div>
</div>
</div>
))}
</div>
) : (
<div className="p-12 text-center">
<span className="text-6xl mb-4 block">🎯</span>
<p className="text-gray-400 text-xl mb-2">No scores yet!</p>
<p className="text-gray-500">Play some games to see your high scores here.</p>
</div>
)}
</div>
{/* Footer Info */}
<div className="mt-6 text-center">
<p className="text-gray-400 text-sm">
🌟 Showing top {topScores.length} scores Play Maple Blocks to add your score! 🌟
</p>
</div>
</div>
</div>
);
};
export default ScoresScreen;

View file

@ -0,0 +1,359 @@
import React, { useState, useEffect } from "react";
import { getInstructionsForModal } from "../utils/gameinfo.js";
const WelcomeScreen = ({ onStartGame, onViewScores }) => {
const [selectedLevel, setSelectedLevel] = useState(0);
const [selectedSpeed, setSelectedSpeed] = useState("Normal");
const [highScores, setHighScores] = useState([]);
const [showInstructions, setShowInstructions] = useState(false);
// Mock storage functions for demo - replace with actual storage imports
const mockStorage = {
settings: {
getStartingLevel: () =>
parseInt(localStorage.getItem("mapleBlocks_startingLevel") || "0"),
getGameSpeed: () =>
localStorage.getItem("mapleBlocks_gameSpeed") || "Normal",
saveStartingLevel: (level) =>
localStorage.setItem("mapleBlocks_startingLevel", level.toString()),
saveGameSpeed: (speed) =>
localStorage.setItem("mapleBlocks_gameSpeed", speed),
},
highScores: {
getTopScores: (limit = 3) => {
try {
const scores = JSON.parse(
localStorage.getItem("mapleBlocks_highScores") || "[]",
);
return scores.slice(0, limit);
} catch {
return [];
}
},
},
};
// Load saved settings and high scores on component mount
useEffect(() => {
// Load saved settings
const savedLevel = mockStorage.settings.getStartingLevel();
const savedSpeed = mockStorage.settings.getGameSpeed();
setSelectedLevel(savedLevel);
setSelectedSpeed(savedSpeed);
// Load high scores
const topScores = mockStorage.highScores.getTopScores(3);
setHighScores(topScores);
}, []);
// Save settings whenever they change
useEffect(() => {
mockStorage.settings.saveStartingLevel(selectedLevel);
}, [selectedLevel]);
useEffect(() => {
mockStorage.settings.saveGameSpeed(selectedSpeed);
}, [selectedSpeed]);
const speedOptions = ["Slow", "Normal", "Fast"];
const levelOptions = Array.from({ length: 11 }, (_, i) => i);
const handleStartGame = () => {
// Save current settings before starting game
mockStorage.settings.saveStartingLevel(selectedLevel);
mockStorage.settings.saveGameSpeed(selectedSpeed);
// Call parent function with settings (for routing)
if (onStartGame) {
onStartGame({ level: selectedLevel, speed: selectedSpeed });
}
};
const handleViewScores = () => {
// Call parent function (for routing)
if (onViewScores) {
onViewScores();
}
};
const handleShowInstructions = () => {
setShowInstructions(true);
};
const handleCloseInstructions = () => {
setShowInstructions(false);
};
const getPersonalBest = () => {
return highScores.length > 0 ? highScores[0].score : 0;
};
const getTotalGames = () => {
try {
const allScores = JSON.parse(
localStorage.getItem("mapleBlocks_highScores") || "[]",
);
return allScores.length;
} catch {
return 0;
}
};
return (
<div className="min-h-screen bg-gradient-to-br from-red-900 via-rose-900 to-pink-900 flex items-center justify-center p-4">
<div className="bg-gray-800 rounded-2xl shadow-2xl p-6 md:p-8 max-w-md w-full">
{/* Game Title */}
<div className="text-center mb-6 md:mb-8">
<h1 className="text-4xl md:text-5xl font-bold text-white mb-2">
MAPLE BLOCKS
</h1>
<p className="text-gray-400">Classic Block Puzzle Game</p>
</div>
{/* Quick Stats */}
{(getPersonalBest() > 0 || getTotalGames() > 0) && (
<div className="grid grid-cols-2 gap-3 mb-4 md:mb-6">
<div className="bg-gradient-to-r from-yellow-600 to-orange-600 rounded-lg p-3 text-center">
<p className="text-xs text-yellow-100 opacity-90">
Personal Best
</p>
<p className="text-lg font-bold text-white">
{getPersonalBest().toLocaleString()}
</p>
</div>
<div className="bg-gradient-to-r from-blue-600 to-purple-600 rounded-lg p-3 text-center">
<p className="text-xs text-blue-100 opacity-90">Games Played</p>
<p className="text-lg font-bold text-white">{getTotalGames()}</p>
</div>
</div>
)}
{/* Single Top Score */}
{highScores.length > 0 && (
<div className="bg-gray-700 rounded-xl p-4 mb-4 md:mb-6">
<h3 className="text-white font-semibold mb-3 flex items-center">
<span className="mr-2">🏆</span>
Current Champion
</h3>
<div className="flex items-center justify-between bg-gradient-to-r from-yellow-600 to-orange-600 rounded-lg p-3">
<div className="flex items-center space-x-3">
<span className="text-2xl">👑</span>
<div>
<p className="text-white font-bold">
{highScores[0].playerName}
</p>
<p className="text-yellow-100 text-sm opacity-90">
Level {highScores[0].level} {highScores[0].gameSpeed}
</p>
</div>
</div>
<div className="text-right">
<p className="text-white font-bold text-xl">
{highScores[0].score.toLocaleString()}
</p>
<p className="text-yellow-100 text-xs opacity-75">points</p>
</div>
</div>
</div>
)}
{/* Game Settings */}
<div className="space-y-4 md:space-y-6">
{/* Level Selection */}
<div>
<label className="block text-white font-bold mb-3 md:mb-4 text-base md:text-lg">
Starting Level
</label>
<select
value={selectedLevel}
onChange={(e) => setSelectedLevel(parseInt(e.target.value))}
className="w-full bg-[#f6f6f6] text-[#222222] border-2 border-gray-300 rounded-xl px-4 md:px-6 py-4 md:py-5 text-lg md:text-xl font-semibold shadow-lg focus:outline-none focus:ring-3 focus:ring-red-400 focus:border-red-400 hover:border-gray-400 transition-all duration-200 cursor-pointer"
style={{
backgroundImage: `url("data:image/svg+xml;charset=US-ASCII,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='%23222222' d='M7.247 11.14 2.451 5.658C1.885 5.013 2.345 4 3.204 4h9.592a1 1 0 0 1 .753 1.659l-4.796 5.48a1 1 0 0 1-1.506 0z'/%3e%3c/svg%3e")`,
backgroundRepeat: "no-repeat",
backgroundPosition: "right 1rem center",
backgroundSize: "12px",
paddingRight: "3rem",
appearance: "none",
}}
>
{levelOptions.map((level) => (
<option
key={level}
value={level}
style={{
backgroundColor: "#f6f6f6",
color: "#222222",
fontSize: "18px",
fontWeight: "600",
padding: "12px 24px",
borderRadius: "8px",
margin: "2px",
}}
>
Level {level}
</option>
))}
</select>
</div>
{/* Speed Selection */}
<div>
<label className="block text-white font-bold mb-3 md:mb-4 text-base md:text-lg">
Game Speed
</label>
<div className="grid grid-cols-3 gap-2">
{speedOptions.map((speed) => (
<button
key={speed}
onClick={() => setSelectedSpeed(speed)}
className={`py-2 md:py-3 px-3 md:px-4 rounded-lg font-medium transition-all duration-200 text-sm md:text-base ${
selectedSpeed === speed
? "bg-red-500 text-white shadow-lg transform scale-105"
: "bg-gray-700 text-gray-300 hover:bg-gray-600"
}`}
>
{speed}
</button>
))}
</div>
</div>
</div>
{/* Action Buttons */}
<div className="mt-6 md:mt-8 space-y-3">
<button
onClick={handleStartGame}
className="w-full bg-gradient-to-r from-red-500 to-rose-600 text-white font-bold py-3 md:py-4 px-6 rounded-lg hover:from-red-600 hover:to-rose-700 transform hover:scale-105 transition-all duration-200 shadow-lg text-base md:text-lg"
>
🎮 Start Game
</button>
<button
onClick={handleViewScores}
className="w-full bg-gradient-to-r from-amber-500 to-orange-500 text-white font-bold py-2 md:py-3 px-6 rounded-lg hover:from-amber-600 hover:to-orange-600 transform hover:scale-105 transition-all duration-200 shadow-lg text-sm md:text-base"
>
🏆 View High Scores
</button>
</div>
{/* Settings Info */}
<div className="mt-4 md:mt-6 bg-gray-700 rounded-lg p-3">
<p className="text-gray-300 text-xs md:text-sm text-center">
<strong>Current Settings:</strong> Level {selectedLevel} {" "}
{selectedSpeed} Speed
</p>
</div>
{/* Instructions Link */}
<div className="mt-3 md:mt-4 text-center">
<button
onClick={handleShowInstructions}
className="text-red-400 hover:text-red-300 underline transition-colors duration-200 text-sm md:text-base"
>
📖 View Complete Instructions
</button>
</div>
{/* Instructions Modal */}
{showInstructions && (
<InstructionsModal
onClose={handleCloseInstructions}
instructions={getInstructionsForModal("basic")}
/>
)}
</div>
</div>
);
};
// Instructions Modal Component
const InstructionsModal = ({ onClose, instructions }) => {
return (
<div className="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50 p-4">
<div className="bg-gray-800 rounded-2xl shadow-2xl max-w-2xl w-full max-h-[80vh] overflow-y-auto">
{/* Modal Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-700">
<h2 className="text-2xl font-bold text-white flex items-center">
<span className="mr-3">📖</span>
{instructions.title}
</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-white text-3xl font-bold transition-colors duration-200 hover:bg-gray-700 rounded-full w-10 h-10 flex items-center justify-center"
>
×
</button>
</div>
{/* Modal Content */}
<div className="p-6 space-y-6">
{instructions.sections.map((section, sectionIndex) => (
<div key={sectionIndex} className="space-y-4">
<h3 className="text-xl font-bold text-red-400 flex items-center">
<span className="mr-2">🎮</span>
{section.title}
</h3>
<div className="space-y-3">
{section.items.map((item, itemIndex) => (
<div
key={itemIndex}
className="flex items-start space-x-3 bg-gray-700 rounded-lg p-3"
>
<span className="text-2xl flex-shrink-0">{item.icon}</span>
<div className="flex-grow">
<div className="flex items-center space-x-3">
<span className="bg-gray-600 text-white px-3 py-1 rounded-md font-mono text-sm font-semibold">
{item.key}
</span>
<span className="text-gray-300">
{item.description}
</span>
</div>
</div>
</div>
))}
</div>
</div>
))}
{/* Tips Section */}
<div className="bg-gradient-to-r from-red-600 to-rose-600 rounded-xl p-4 mt-6">
<h3 className="text-lg font-bold text-white mb-3 flex items-center">
<span className="mr-2">💡</span>
Pro Tips
</h3>
<div className="text-red-100 text-sm space-y-2">
<p> Look at the next piece preview to plan your moves</p>
<p> Try to keep your stack relatively flat</p>
<p>
Save a column for I-pieces (line pieces) to clear multiple
lines
</p>
<p> Go for "Maple Blocks!" (4 lines) for maximum points!</p>
</div>
</div>
</div>
{/* Modal Footer */}
<div className="p-6 border-t border-gray-700 bg-gray-750 rounded-b-2xl">
<div className="flex justify-between items-center">
<p className="text-gray-400 text-sm">
🍁 Master these techniques to become a Maple Blocks champion!
</p>
<button
onClick={onClose}
className="bg-red-500 hover:bg-red-600 text-white font-bold py-2 px-6 rounded-lg transition-all duration-200 transform hover:scale-105"
>
Got it!
</button>
</div>
</div>
</div>
</div>
);
};
export default WelcomeScreen;

View file

@ -0,0 +1,304 @@
/* Tailwind CSS directives */
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Custom CSS for Maple Blocks */
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Reset body styles to work with Tailwind */
body {
margin: 0;
padding: 0;
min-width: 320px;
min-height: 100vh;
/* Remove flex and centering to let components handle their own layout */
}
/* Game-specific custom styles */
.maple-blocks-app {
min-height: 100vh;
width: 100%;
}
/* Custom animations for game effects */
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes bounce {
0%,
20%,
53%,
80%,
100% {
transform: translate3d(0, 0, 0);
}
40%,
43% {
transform: translate3d(0, -10px, 0);
}
70% {
transform: translate3d(0, -5px, 0);
}
90% {
transform: translate3d(0, -2px, 0);
}
}
@keyframes pulse {
0% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
100% {
transform: scale(1);
}
}
@keyframes glow {
0% {
box-shadow: 0 0 5px rgba(239, 68, 68, 0.5);
}
50% {
box-shadow: 0 0 20px rgba(239, 68, 68, 0.8);
}
100% {
box-shadow: 0 0 5px rgba(239, 68, 68, 0.5);
}
}
/* Custom utility classes for game components */
.game-board-cell {
transition: all 0.15s ease-in-out;
}
.game-board-cell:hover {
transform: scale(1.02);
}
.score-card {
transition: all 0.2s ease-in-out;
}
.score-card:hover {
transform: translateY(-2px);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);
}
.game-button {
transition: all 0.2s ease-in-out;
position: relative;
overflow: hidden;
}
.game-button:hover {
transform: translateY(-1px);
}
.game-button:active {
transform: translateY(0);
}
.game-button::before {
content: "";
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.2),
transparent
);
transition: left 0.5s;
}
.game-button:hover::before {
left: 100%;
}
/* Maple Blocks themed scrollbar */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: #374151;
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: #ef4444;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #dc2626;
}
/* Focus styles for accessibility */
.focus-visible {
outline: 2px solid #ef4444;
outline-offset: 2px;
}
/* Game piece preview styles */
.piece-preview {
image-rendering: pixelated;
image-rendering: -moz-crisp-edges;
image-rendering: crisp-edges;
}
/* Level progress bar animation */
.progress-bar {
transition: width 0.3s ease-in-out;
}
/* Clear animation styles */
.clear-animation {
animation: fadeInUp 0.3s ease-out;
}
.line-clear-glow {
animation: glow 0.5s ease-in-out;
}
/* Game over screen styles */
.game-over-entrance {
animation: fadeInUp 0.5s ease-out;
}
/* High score celebration */
.high-score-celebration {
animation: bounce 1s infinite;
}
/* Responsive design helpers */
@media (max-width: 768px) {
.game-container {
padding: 1rem;
}
.game-stats {
font-size: 0.875rem;
}
.game-board {
max-width: 100%;
}
}
@media (max-width: 640px) {
.game-title {
font-size: 2.5rem;
}
.game-controls {
flex-direction: column;
gap: 0.5rem;
}
}
/* Print styles (hide game when printing) */
@media print {
.maple-blocks-app {
display: none;
}
}
/* Reduce motion for accessibility */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* Dark mode support (Tailwind handles most, but custom additions) */
@media (prefers-color-scheme: dark) {
::-webkit-scrollbar-track {
background: #1f2937;
}
}
/* Custom color palette for Maple Blocks theme */
.maple-red {
color: #ef4444;
}
.maple-red-bg {
background-color: #ef4444;
}
.maple-gold {
color: #f59e0b;
}
.maple-gold-bg {
background-color: #f59e0b;
}
/* Loading spinner for game initialization */
.loading-spinner {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* Game piece drop shadow effect */
.piece-shadow {
filter: drop-shadow(2px 2px 4px rgba(0, 0, 0, 0.3));
}
/* Success feedback styles */
.success-feedback {
animation: pulse 0.6s ease-in-out;
}
/* Error feedback styles */
.error-feedback {
animation: shake 0.5s ease-in-out;
}
@keyframes shake {
0%,
100% {
transform: translateX(0);
}
25% {
transform: translateX(-4px);
}
75% {
transform: translateX(4px);
}
}

View file

@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)

View file

@ -0,0 +1,471 @@
// Maple Blocks Game Management System
// Handles game initialization, state management, and game flow
// Game configuration constants
export const GAME_CONFIG = {
BOARD_WIDTH: 10,
BOARD_HEIGHT: 20,
LINES_PER_LEVEL: 10,
MIN_DROP_SPEED: 100, // Minimum milliseconds between drops
MAX_DIFFICULTY: 10, // Maximum starting difficulty
PREVIEW_PIECES: 1, // Number of next pieces to show
GHOST_PIECE_ENABLED: true,
HOLD_PIECE_ENABLED: false, // Future feature
};
// Game state constants
export const GAME_STATES = {
MENU: "menu",
PLAYING: "playing",
PAUSED: "paused",
GAME_OVER: "game_over",
LOADING: "loading",
RESULTS: "results",
};
// Difficulty patterns for starting boards
const DIFFICULTY_PATTERNS = {
0: [], // Clean board
1: [{ row: 19, pattern: [1, 1, 1, 0, 1, 1, 1, 1, 1, 1] }],
2: [
{ row: 19, pattern: [1, 1, 1, 0, 1, 1, 1, 1, 1, 1] },
{ row: 18, pattern: [1, 1, 1, 1, 1, 0, 1, 1, 1, 1] },
],
3: [
{ row: 19, pattern: [1, 1, 1, 0, 1, 1, 1, 1, 1, 1] },
{ row: 18, pattern: [1, 1, 1, 1, 1, 0, 1, 1, 1, 1] },
{ row: 17, pattern: [1, 0, 1, 1, 1, 1, 1, 1, 1, 1] },
],
// Patterns continue for difficulties 4-10
};
/**
* Create an empty game board
* @param {number} width - Board width (default from config)
* @param {number} height - Board height (default from config)
* @returns {array} 2D array representing empty board
*/
export const createEmptyBoard = (
width = GAME_CONFIG.BOARD_WIDTH,
height = GAME_CONFIG.BOARD_HEIGHT,
) => {
return Array(height)
.fill()
.map(() => Array(width).fill(0));
};
/**
* Create a starting board with difficulty-based incomplete lines
* @param {number} difficulty - Starting difficulty level (0-10)
* @param {number} width - Board width
* @param {number} height - Board height
* @returns {array} 2D array with pre-filled incomplete lines
*/
export const createStartingBoard = (
difficulty = 0,
width = GAME_CONFIG.BOARD_WIDTH,
height = GAME_CONFIG.BOARD_HEIGHT,
) => {
const board = createEmptyBoard(width, height);
if (difficulty <= 0 || difficulty > GAME_CONFIG.MAX_DIFFICULTY) {
return board;
}
// Use predefined patterns for consistent gameplay
if (difficulty <= 3 && DIFFICULTY_PATTERNS[difficulty]) {
DIFFICULTY_PATTERNS[difficulty].forEach(({ row, pattern }) => {
if (row >= 0 && row < height) {
board[row] = pattern.map((cell) => (cell ? "#666666" : 0));
}
});
} else {
// Generate random incomplete lines for higher difficulties
for (let i = 0; i < difficulty; i++) {
const row = height - 1 - i;
if (row >= 0) {
for (let col = 0; col < width; col++) {
// Create incomplete lines with 1-2 gaps
if (Math.random() > 0.15) {
board[row][col] = "#666666";
}
}
}
}
}
return board;
};
/**
* Initialize a new game state
* @param {object} settings - Game settings from welcome screen
* @returns {object} Initial game state
*/
export const initializeGame = (settings = {}) => {
const {
startingLevel = 0,
gameSpeed = "Normal",
playerName = "Anonymous",
enableSounds = true,
enableAnimations = true,
} = settings;
const startTime = Date.now();
return {
// Core game state
board: createStartingBoard(startingLevel),
currentPiece: null,
nextPiece: null,
heldPiece: null,
ghostPiece: null,
// Game progress
score: 0,
lines: 0,
level: 0,
difficulty: startingLevel,
// Game flow
gameState: GAME_STATES.LOADING,
isPaused: false,
gameOver: false,
gameStarted: false,
// Timing
startTime,
pausedTime: 0,
totalPausedDuration: 0,
lastDropTime: 0,
// Settings
gameSpeed,
playerName,
enableSounds,
enableAnimations,
// Statistics
piecesPlaced: 0,
linesCleared: { single: 0, double: 0, triple: 0, quad: 0 },
consecutiveQuads: 0,
maxConsecutiveQuads: 0,
// Combo tracking
lastClearWasQuad: false,
comboCount: 0,
maxCombo: 0,
// Performance tracking
averageDropTime: 0,
fastestDrop: Infinity,
perfectClears: 0,
};
};
/**
* Calculate current game time (excluding paused time)
* @param {object} gameState - Current game state
* @returns {number} Game time in milliseconds
*/
export const getGameTime = (gameState) => {
const now = Date.now();
const totalTime = now - gameState.startTime;
return totalTime - gameState.totalPausedDuration;
};
/**
* Pause the game and track pause time
* @param {object} gameState - Current game state
* @returns {object} Updated game state
*/
export const pauseGame = (gameState) => {
if (gameState.isPaused || gameState.gameOver) {
return gameState;
}
return {
...gameState,
isPaused: true,
gameState: GAME_STATES.PAUSED,
pausedTime: Date.now(),
};
};
/**
* Resume the game and update pause tracking
* @param {object} gameState - Current game state
* @returns {object} Updated game state
*/
export const resumeGame = (gameState) => {
if (!gameState.isPaused || gameState.gameOver) {
return gameState;
}
const pauseDuration = Date.now() - gameState.pausedTime;
return {
...gameState,
isPaused: false,
gameState: GAME_STATES.PLAYING,
pausedTime: 0,
totalPausedDuration: gameState.totalPausedDuration + pauseDuration,
};
};
/**
* End the game and calculate final statistics
* @param {object} gameState - Current game state
* @returns {object} Final game state with complete statistics
*/
export const endGame = (gameState) => {
const finalTime = getGameTime(gameState);
const finalLevel = Math.floor(gameState.lines / GAME_CONFIG.LINES_PER_LEVEL);
// Calculate performance metrics
const linesPerMinute =
finalTime > 0 ? (gameState.lines / finalTime) * 60000 : 0;
const scorePerMinute =
finalTime > 0 ? (gameState.score / finalTime) * 60000 : 0;
const averageLinesPerLevel =
finalLevel > 0 ? gameState.lines / finalLevel : gameState.lines;
return {
...gameState,
gameOver: true,
gameState: GAME_STATES.GAME_OVER,
level: finalLevel,
// Final statistics
finalTime,
linesPerMinute: Math.round(linesPerMinute),
scorePerMinute: Math.round(scorePerMinute),
averageLinesPerLevel: Math.round(averageLinesPerLevel * 10) / 10,
// Game summary
gameComplete: true,
endTime: Date.now(),
};
};
/**
* Update game statistics after line clear
* @param {object} gameState - Current game state
* @param {number} linesCleared - Number of lines cleared (1-4)
* @returns {object} Updated game state with new statistics
*/
export const updateGameStats = (gameState, linesCleared) => {
if (linesCleared < 1 || linesCleared > 4) {
return gameState;
}
const newLines = gameState.lines + linesCleared;
const newLevel = Math.floor(newLines / GAME_CONFIG.LINES_PER_LEVEL);
// Update line clear statistics
const clearTypes = { 1: "single", 2: "double", 3: "triple", 4: "quad" };
const clearType = clearTypes[linesCleared];
const updatedLineClears = {
...gameState.linesCleared,
[clearType]: gameState.linesCleared[clearType] + 1,
};
// Track consecutive quads
const isQuad = linesCleared === 4;
const consecutiveQuads =
isQuad && gameState.lastClearWasQuad
? gameState.consecutiveQuads + 1
: isQuad
? 1
: 0;
// Update combo tracking
const comboCount = gameState.comboCount + 1;
const maxCombo = Math.max(gameState.maxCombo, comboCount);
return {
...gameState,
lines: newLines,
level: newLevel,
linesCleared: updatedLineClears,
consecutiveQuads,
maxConsecutiveQuads: Math.max(
gameState.maxConsecutiveQuads,
consecutiveQuads,
),
lastClearWasQuad: isQuad,
comboCount,
maxCombo,
};
};
/**
* Check if the game board is completely empty (perfect clear)
* @param {array} board - Game board
* @returns {boolean} True if board is empty
*/
export const isPerfectClear = (board) => {
return board.every((row) => row.every((cell) => cell === 0));
};
/**
* Get game progress summary
* @param {object} gameState - Current game state
* @returns {object} Progress summary for UI display
*/
export const getGameProgress = (gameState) => {
const currentTime = getGameTime(gameState);
const linesUntilNextLevel =
GAME_CONFIG.LINES_PER_LEVEL -
(gameState.lines % GAME_CONFIG.LINES_PER_LEVEL);
const levelProgress =
(gameState.lines % GAME_CONFIG.LINES_PER_LEVEL) /
GAME_CONFIG.LINES_PER_LEVEL;
return {
currentLevel: gameState.level,
linesUntilNextLevel,
levelProgress: Math.round(levelProgress * 100),
totalGameTime: currentTime,
gameTimeFormatted: formatGameTime(currentTime),
linesPerMinute:
currentTime > 0 ? Math.round((gameState.lines / currentTime) * 60000) : 0,
scorePerSecond:
currentTime > 0 ? Math.round((gameState.score / currentTime) * 1000) : 0,
};
};
/**
* Format game time into human readable format
* @param {number} milliseconds - Time in milliseconds
* @returns {string} Formatted time string (MM:SS or HH:MM:SS)
*/
export const formatGameTime = (milliseconds) => {
const totalSeconds = Math.floor(milliseconds / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
} else {
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
}
};
/**
* Validate game settings
* @param {object} settings - Game settings to validate
* @returns {object} Validated and corrected settings
*/
export const validateGameSettings = (settings) => {
const validSpeeds = ["Slow", "Normal", "Fast"];
return {
startingLevel: Math.max(
0,
Math.min(GAME_CONFIG.MAX_DIFFICULTY, settings.startingLevel || 0),
),
gameSpeed: validSpeeds.includes(settings.gameSpeed)
? settings.gameSpeed
: "Normal",
playerName: (settings.playerName || "Anonymous").substring(0, 20), // Max 20 characters
enableSounds: Boolean(settings.enableSounds !== false), // Default true
enableAnimations: Boolean(settings.enableAnimations !== false), // Default true
};
};
/**
* Create a game save state for persistence
* @param {object} gameState - Current game state
* @returns {object} Serializable save state
*/
export const createSaveState = (gameState) => {
return {
version: "1.0",
timestamp: Date.now(),
gameData: {
score: gameState.score,
lines: gameState.lines,
level: gameState.level,
difficulty: gameState.difficulty,
gameSpeed: gameState.gameSpeed,
playerName: gameState.playerName,
gameTime: getGameTime(gameState),
linesCleared: gameState.linesCleared,
maxCombo: gameState.maxCombo,
maxConsecutiveQuads: gameState.maxConsecutiveQuads,
},
};
};
/**
* Generate performance report for end of game
* @param {object} gameState - Final game state
* @returns {object} Comprehensive performance report
*/
export const generatePerformanceReport = (gameState) => {
const gameTime = getGameTime(gameState);
const efficiency =
gameState.lines > 0 ? gameState.score / gameState.lines : 0;
const totalLineClearActions = Object.values(gameState.linesCleared).reduce(
(sum, count) => sum + count,
0,
);
return {
// Core metrics
finalScore: gameState.score,
totalLines: gameState.lines,
finalLevel: gameState.level,
gameTime: formatGameTime(gameTime),
// Performance metrics
linesPerMinute:
gameTime > 0 ? Math.round((gameState.lines / gameTime) * 60000) : 0,
scorePerSecond:
gameTime > 0 ? Math.round((gameState.score / gameTime) * 1000) : 0,
efficiency: Math.round(efficiency),
// Clear breakdown
clearBreakdown: gameState.linesCleared,
totalClears: totalLineClearActions,
quadPercentage:
totalLineClearActions > 0
? Math.round(
(gameState.linesCleared.quad / totalLineClearActions) * 100,
)
: 0,
// Achievement tracking
maxCombo: gameState.maxCombo,
maxConsecutiveQuads: gameState.maxConsecutiveQuads,
perfectClears: gameState.perfectClears,
// Difficulty context
startingDifficulty: gameState.difficulty,
gameSpeed: gameState.gameSpeed,
};
};
// Export game configuration and states
export { DIFFICULTY_PATTERNS };
// Default export with all functions
export default {
createEmptyBoard,
createStartingBoard,
initializeGame,
getGameTime,
pauseGame,
resumeGame,
endGame,
updateGameStats,
isPerfectClear,
getGameProgress,
formatGameTime,
validateGameSettings,
createSaveState,
generatePerformanceReport,
};

View file

@ -0,0 +1,32 @@
// Game constants
export const BOARD_WIDTH = 10;
export const BOARD_HEIGHT = 20;
export const PIECES = {
I: { shape: [[1,1,1,1]], color: '#00f5ff' },
O: { shape: [[1,1],[1,1]], color: '#ffff00' },
T: { shape: [[0,1,0],[1,1,1]], color: '#a000f0' },
S: { shape: [[0,1,1],[1,1,0]], color: '#00f000' },
Z: { shape: [[1,1,0],[0,1,1]], color: '#f00000' },
J: { shape: [[1,0,0],[1,1,1]], color: '#0000f0' },
L: { shape: [[0,0,1],[1,1,1]], color: '#f0a000' }
};
export const PIECE_TYPES = Object.keys(PIECES);
// Board operations
export const createEmptyBoard = () => {
return Array(BOARD_HEIGHT).fill().map(() => Array(BOARD_WIDTH).fill(0));
};
export const isValidPosition = (piece, board, x, y) => {
// ... validation logic
};
export const placePiece = (piece, board) => {
// ... piece placement logic
};
export const clearLines = (board) => {
// ... line clearing logic
};

View file

@ -0,0 +1,387 @@
// Maple Blocks Piece System
// Handles piece definitions, generation, rotation, and movement
// Piece definitions with Canadian-inspired colors
export const PIECES = {
I: {
shape: [[1,1,1,1]],
color: '#00f5ff', // Cyan - like ice
name: 'Line'
},
O: {
shape: [[1,1],[1,1]],
color: '#ffff00', // Yellow - like maple syrup
name: 'Square'
},
T: {
shape: [[0,1,0],[1,1,1]],
color: '#a000f0', // Purple - like wildflowers
name: 'T-Shape'
},
S: {
shape: [[0,1,1],[1,1,0]],
color: '#00f000', // Green - like pine trees
name: 'S-Shape'
},
Z: {
shape: [[1,1,0],[0,1,1]],
color: '#f00000', // Red - like maple leaf
name: 'Z-Shape'
},
J: {
shape: [[1,0,0],[1,1,1]],
color: '#0000f0', // Blue - like lakes
name: 'J-Shape'
},
L: {
shape: [[0,0,1],[1,1,1]],
color: '#f0a000', // Orange - like autumn leaves
name: 'L-Shape'
}
};
// Array of piece types for random generation
export const PIECE_TYPES = Object.keys(PIECES);
// Starting positions for different piece types (centered on board)
const STARTING_POSITIONS = {
I: { x: 3, y: 0 }, // Line piece needs special positioning
O: { x: 4, y: 0 }, // Square piece
T: { x: 3, y: 0 }, // T-piece
S: { x: 3, y: 0 }, // S-piece
Z: { x: 3, y: 0 }, // Z-piece
J: { x: 3, y: 0 }, // J-piece
L: { x: 3, y: 0 } // L-piece
};
// Piece statistics for tracking
let pieceStats = {
I: 0, O: 0, T: 0, S: 0, Z: 0, J: 0, L: 0
};
/**
* Generate a random piece
* @param {number} boardWidth - Width of the game board (default 10)
* @returns {object} New piece object with shape, color, position, and rotation
*/
export const generateRandomPiece = (boardWidth = 10) => {
const type = PIECE_TYPES[Math.floor(Math.random() * PIECE_TYPES.length)];
const piece = PIECES[type];
const startingPos = STARTING_POSITIONS[type];
// Update statistics
pieceStats[type]++;
return {
type,
shape: piece.shape.map(row => [...row]), // Deep copy to avoid mutations
color: piece.color,
name: piece.name,
x: startingPos.x,
y: startingPos.y,
rotation: 0,
id: Date.now() + Math.random() // Unique identifier
};
};
/**
* Generate a specific piece type (useful for testing)
* @param {string} type - Piece type (I, O, T, S, Z, J, L)
* @param {number} boardWidth - Width of the game board
* @returns {object} New piece object
*/
export const generateSpecificPiece = (type, boardWidth = 10) => {
if (!PIECES[type]) {
console.warn(`Invalid piece type: ${type}. Using random piece.`);
return generateRandomPiece(boardWidth);
}
const piece = PIECES[type];
const startingPos = STARTING_POSITIONS[type];
return {
type,
shape: piece.shape.map(row => [...row]),
color: piece.color,
name: piece.name,
x: startingPos.x,
y: startingPos.y,
rotation: 0,
id: Date.now() + Math.random()
};
};
/**
* Rotate a piece clockwise (90 degrees)
* @param {object} piece - The piece to rotate
* @returns {object} New piece object with rotated shape
*/
export const rotatePieceClockwise = (piece) => {
if (!piece || !piece.shape) {
console.warn('Invalid piece for rotation');
return piece;
}
// O-piece doesn't need rotation
if (piece.type === 'O') {
return { ...piece };
}
const shape = piece.shape;
const rotatedShape = shape[0].map((_, index) =>
shape.map(row => row[index]).reverse()
);
return {
...piece,
shape: rotatedShape,
rotation: (piece.rotation + 90) % 360
};
};
/**
* Rotate a piece counterclockwise (90 degrees)
* @param {object} piece - The piece to rotate
* @returns {object} New piece object with rotated shape
*/
export const rotatePieceCounterclockwise = (piece) => {
if (!piece || !piece.shape) {
console.warn('Invalid piece for rotation');
return piece;
}
// O-piece doesn't need rotation
if (piece.type === 'O') {
return { ...piece };
}
const shape = piece.shape;
const rotatedShape = shape[0].map((_, index) =>
shape.map(row => row[row.length - 1 - index])
);
return {
...piece,
shape: rotatedShape,
rotation: (piece.rotation - 90 + 360) % 360
};
};
/**
* Move a piece in a direction
* @param {object} piece - The piece to move
* @param {number} deltaX - Horizontal movement (-1 for left, 1 for right, 0 for no movement)
* @param {number} deltaY - Vertical movement (usually 1 for down)
* @returns {object} New piece object with updated position
*/
export const movePiece = (piece, deltaX, deltaY) => {
if (!piece) {
console.warn('Invalid piece for movement');
return piece;
}
return {
...piece,
x: piece.x + deltaX,
y: piece.y + deltaY
};
};
/**
* Get the bounding box of a piece (useful for collision detection)
* @param {object} piece - The piece to analyze
* @returns {object} Bounding box with minX, maxX, minY, maxY
*/
export const getPieceBounds = (piece) => {
if (!piece || !piece.shape) {
return { minX: 0, maxX: 0, minY: 0, maxY: 0 };
}
let minX = piece.shape[0].length;
let maxX = -1;
let minY = piece.shape.length;
let maxY = -1;
piece.shape.forEach((row, rowIndex) => {
row.forEach((cell, colIndex) => {
if (cell) {
minX = Math.min(minX, colIndex);
maxX = Math.max(maxX, colIndex);
minY = Math.min(minY, rowIndex);
maxY = Math.max(maxY, rowIndex);
}
});
});
return {
minX: piece.x + minX,
maxX: piece.x + maxX,
minY: piece.y + minY,
maxY: piece.y + maxY,
width: maxX - minX + 1,
height: maxY - minY + 1
};
};
/**
* Get all filled positions of a piece (useful for collision detection)
* @param {object} piece - The piece to analyze
* @returns {array} Array of {x, y} coordinates where the piece has blocks
*/
export const getPiecePositions = (piece) => {
if (!piece || !piece.shape) {
return [];
}
const positions = [];
piece.shape.forEach((row, rowIndex) => {
row.forEach((cell, colIndex) => {
if (cell) {
positions.push({
x: piece.x + colIndex,
y: piece.y + rowIndex
});
}
});
});
return positions;
};
/**
* Create a preview version of a piece (smaller, for next piece display)
* @param {object} piece - The piece to create a preview for
* @param {number} previewSize - Size of the preview grid (default 4x4)
* @returns {object} Preview piece centered in a grid
*/
export const createPiecePreview = (piece, previewSize = 4) => {
if (!piece || !piece.shape) {
return null;
}
// Create empty preview grid
const previewGrid = Array(previewSize).fill().map(() => Array(previewSize).fill(0));
// Calculate centering offset
const offsetX = Math.floor((previewSize - piece.shape[0].length) / 2);
const offsetY = Math.floor((previewSize - piece.shape.length) / 2);
// Place piece in preview grid
piece.shape.forEach((row, rowIndex) => {
row.forEach((cell, colIndex) => {
if (cell) {
const previewX = offsetX + colIndex;
const previewY = offsetY + rowIndex;
if (previewX >= 0 && previewX < previewSize &&
previewY >= 0 && previewY < previewSize) {
previewGrid[previewY][previewX] = piece.color;
}
}
});
});
return {
grid: previewGrid,
size: previewSize,
piece: piece
};
};
/**
* Get piece statistics
* @returns {object} Statistics for each piece type
*/
export const getPieceStats = () => {
const total = Object.values(pieceStats).reduce((sum, count) => sum + count, 0);
const stats = {};
Object.keys(pieceStats).forEach(type => {
stats[type] = {
count: pieceStats[type],
percentage: total > 0 ? Math.round((pieceStats[type] / total) * 100) : 0,
name: PIECES[type].name
};
});
return {
individual: stats,
total,
mostCommon: Object.keys(stats).reduce((a, b) =>
stats[a].count > stats[b].count ? a : b
),
leastCommon: Object.keys(stats).reduce((a, b) =>
stats[a].count < stats[b].count ? a : b
)
};
};
/**
* Reset piece statistics
*/
export const resetPieceStats = () => {
pieceStats = { I: 0, O: 0, T: 0, S: 0, Z: 0, J: 0, L: 0 };
};
/**
* Generate a bag of pieces (ensures more even distribution)
* This prevents long droughts of specific pieces
* @returns {array} Array of 7 pieces (one of each type) in random order
*/
export const generatePieceBag = () => {
const bag = [...PIECE_TYPES];
// Fisher-Yates shuffle
for (let i = bag.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[bag[i], bag[j]] = [bag[j], bag[i]];
}
return bag.map(type => generateSpecificPiece(type));
};
/**
* Check if a piece can be rotated (basic wall kick)
* @param {object} piece - The piece to check
* @param {number} boardWidth - Width of the board
* @returns {object} Rotation result with success flag and adjusted position
*/
export const canRotatePiece = (piece, boardWidth) => {
const rotated = rotatePieceClockwise(piece);
const bounds = getPieceBounds(rotated);
let adjustedPiece = { ...rotated };
// Wall kick: adjust position if rotation would go out of bounds
if (bounds.minX < 0) {
adjustedPiece.x -= bounds.minX;
} else if (bounds.maxX >= boardWidth) {
adjustedPiece.x -= (bounds.maxX - boardWidth + 1);
}
return {
canRotate: true,
adjustedPiece,
wallKickUsed: adjustedPiece.x !== rotated.x
};
};
// Export constants and piece definitions
export { pieceStats };
// Default export with all functions
export default {
generateRandomPiece,
generateSpecificPiece,
rotatePieceClockwise,
rotatePieceCounterclockwise,
movePiece,
getPieceBounds,
getPiecePositions,
createPiecePreview,
getPieceStats,
resetPieceStats,
generatePieceBag,
canRotatePiece
};

View file

@ -0,0 +1,242 @@
// Maple Blocks Scoring System
// Handles all scoring calculations, speed progression, and level management
// Standard line clearing score multipliers
const LINE_SCORES = {
0: 0, // No lines cleared
1: 40, // Single line
2: 100, // Double lines
3: 300, // Triple lines
4: 1200 // Quad lines (Maple Blocks!)
};
// Speed settings for different game modes
const SPEED_SETTINGS = {
'Slow': {
baseSpeed: 1000, // 1 second per drop
description: 'Relaxed pace for beginners'
},
'Normal': {
baseSpeed: 800, // 0.8 seconds per drop
description: 'Standard game speed'
},
'Fast': {
baseSpeed: 600, // 0.6 seconds per drop
description: 'Quick pace for experienced players'
}
};
// Bonus scoring system
const BONUS_MULTIPLIERS = {
CONSECUTIVE_QUADS: 1.5, // Extra points for back-to-back quad clears
SPEED_BONUS: 1.2, // Bonus for fast drops
LEVEL_PROGRESSION: 1.1 // Small bonus as levels increase
};
/**
* Calculate score for cleared lines
* @param {number} linesCleared - Number of lines cleared (0-4)
* @param {number} currentLevel - Current game level (affects multiplier)
* @param {boolean} isConsecutiveQuad - Whether this is a consecutive quad clear
* @returns {number} Score points earned
*/
export const calculateScore = (linesCleared, currentLevel, isConsecutiveQuad = false) => {
if (linesCleared < 0 || linesCleared > 4) {
console.warn('Invalid lines cleared:', linesCleared);
return 0;
}
let baseScore = LINE_SCORES[linesCleared] || 0;
// Level multiplier (level + 1 to avoid zero multiplication)
let score = baseScore * (currentLevel + 1);
// Consecutive quad bonus
if (linesCleared === 4 && isConsecutiveQuad) {
score *= BONUS_MULTIPLIERS.CONSECUTIVE_QUADS;
}
return Math.floor(score);
};
/**
* Calculate drop speed based on game speed setting and current level
* @param {string} gameSpeed - 'Slow', 'Normal', or 'Fast'
* @param {number} currentLevel - Current game level
* @returns {number} Drop interval in milliseconds
*/
export const getDropSpeed = (gameSpeed, currentLevel) => {
const speedSetting = SPEED_SETTINGS[gameSpeed];
if (!speedSetting) {
console.warn('Invalid game speed:', gameSpeed, '- using Normal');
return getDropSpeed('Normal', currentLevel);
}
const baseSpeed = speedSetting.baseSpeed;
// Speed increases by 50ms per level, with minimum of 100ms
const speedReduction = currentLevel * 50;
const finalSpeed = Math.max(100, baseSpeed - speedReduction);
return finalSpeed;
};
/**
* Calculate the current level based on total lines cleared
* In Maple Blocks, level increases every 10 lines cleared
* @param {number} totalLines - Total lines cleared in the game
* @returns {number} Current level (starts at 0)
*/
export const calculateLevel = (totalLines) => {
return Math.floor(totalLines / 10);
};
/**
* Calculate lines needed to reach the next level
* @param {number} totalLines - Total lines cleared so far
* @returns {number} Lines needed for next level
*/
export const getLinesUntilNextLevel = (totalLines) => {
const remainder = totalLines % 10;
return 10 - remainder;
};
/**
* Calculate progress percentage toward next level
* @param {number} totalLines - Total lines cleared so far
* @returns {number} Percentage (0-100) toward next level
*/
export const getLevelProgress = (totalLines) => {
const remainder = totalLines % 10;
return (remainder / 10) * 100;
};
/**
* Get score breakdown for display purposes
* @param {number} linesCleared - Number of lines cleared
* @param {number} currentLevel - Current game level
* @param {boolean} isConsecutiveQuad - Whether this is a consecutive quad
* @returns {object} Detailed score breakdown
*/
export const getScoreBreakdown = (linesCleared, currentLevel, isConsecutiveQuad = false) => {
const baseScore = LINE_SCORES[linesCleared] || 0;
const levelMultiplier = currentLevel + 1;
const scoreAfterLevel = baseScore * levelMultiplier;
let bonusMultiplier = 1;
const bonuses = [];
if (linesCleared === 4 && isConsecutiveQuad) {
bonusMultiplier *= BONUS_MULTIPLIERS.CONSECUTIVE_QUADS;
bonuses.push('Consecutive Quad Bonus!');
}
const finalScore = Math.floor(scoreAfterLevel * bonusMultiplier);
return {
linesCleared,
baseScore,
levelMultiplier,
scoreAfterLevel,
bonusMultiplier,
bonuses,
finalScore,
clearType: getClearTypeName(linesCleared)
};
};
/**
* Get the name for different types of line clears
* @param {number} linesCleared - Number of lines cleared
* @returns {string} Name of the clear type
*/
export const getClearTypeName = (linesCleared) => {
const clearTypes = {
0: 'No Clear',
1: 'Single',
2: 'Double',
3: 'Triple',
4: 'Maple Blocks!' // Special name for our game!
};
return clearTypes[linesCleared] || 'Unknown';
};
/**
* Calculate estimated time to complete current level
* @param {number} totalLines - Total lines cleared so far
* @param {number} averageLinesPerMinute - Player's average clearing rate
* @returns {number} Estimated seconds to next level
*/
export const getEstimatedTimeToNextLevel = (totalLines, averageLinesPerMinute) => {
const linesNeeded = getLinesUntilNextLevel(totalLines);
const minutesNeeded = linesNeeded / averageLinesPerMinute;
return Math.ceil(minutesNeeded * 60); // Convert to seconds
};
/**
* Get speed description for UI display
* @param {string} gameSpeed - Game speed setting
* @returns {string} Human-readable description
*/
export const getSpeedDescription = (gameSpeed) => {
const speedSetting = SPEED_SETTINGS[gameSpeed];
return speedSetting ? speedSetting.description : 'Unknown speed setting';
};
/**
* Calculate performance rating based on score and time
* @param {number} score - Final game score
* @param {number} timeInSeconds - Total game time
* @param {number} totalLines - Total lines cleared
* @returns {object} Performance rating and stats
*/
export const calculatePerformanceRating = (score, timeInSeconds, totalLines) => {
const scorePerSecond = timeInSeconds > 0 ? score / timeInSeconds : 0;
const linesPerMinute = timeInSeconds > 0 ? (totalLines / timeInSeconds) * 60 : 0;
let rating = 'Beginner';
let stars = 1;
if (scorePerSecond > 100) {
rating = 'Advanced';
stars = 4;
} else if (scorePerSecond > 50) {
rating = 'Intermediate';
stars = 3;
} else if (scorePerSecond > 20) {
rating = 'Improving';
stars = 2;
}
if (linesPerMinute > 60) {
rating = 'Master';
stars = 5;
}
return {
rating,
stars,
scorePerSecond: Math.round(scorePerSecond),
linesPerMinute: Math.round(linesPerMinute),
efficiency: Math.round((score / Math.max(1, totalLines)) * 10) / 10
};
};
// Export constants for use in other files
export { LINE_SCORES, SPEED_SETTINGS, BONUS_MULTIPLIERS };
// Default export with all functions
export default {
calculateScore,
getDropSpeed,
calculateLevel,
getLinesUntilNextLevel,
getLevelProgress,
getScoreBreakdown,
getClearTypeName,
getEstimatedTimeToNextLevel,
getSpeedDescription,
calculatePerformanceRating
};

View file

@ -0,0 +1,255 @@
// Maple Blocks Game Information and Instructions
// Centralized place for all game instructions, tips, and information
// Game controls and instructions
export const GAME_CONTROLS = {
movement: [
{ key: '←→', description: 'Move piece left/right', icon: '🔄' },
{ key: '↓', description: 'Drop piece faster', icon: '⚡' },
{ key: 'Space', description: 'Rotate piece clockwise', icon: '🔄' },
{ key: 'P', description: 'Pause/Resume game', icon: '⏸️' }
],
gameplay: [
{ action: 'Clear Lines', description: 'Fill complete horizontal lines to clear them', icon: '🎯' },
{ action: 'Level Up', description: 'Clear 10 lines to advance to next level', icon: '📈' },
{ action: 'Speed Increase', description: 'Game gets faster with each level', icon: '🚀' },
{ action: 'Game Over', description: 'When pieces reach the top of the board', icon: '💀' }
]
};
// Scoring system information
export const SCORING_INFO = {
lineClears: [
{ lines: 1, name: 'Single', points: '40 × Level', icon: '1⃣' },
{ lines: 2, name: 'Double', points: '100 × Level', icon: '2⃣' },
{ lines: 3, name: 'Triple', points: '300 × Level', icon: '3⃣' },
{ lines: 4, name: 'Maple Blocks!', points: '1200 × Level', icon: '🍁' }
],
bonuses: [
{ type: 'Level Multiplier', description: 'All scores multiply by (Level + 1)', icon: '✖️' },
{ type: 'Speed Bonus', description: 'Faster drops can earn bonus points', icon: '⚡' },
{ type: 'Perfect Clear', description: 'Extra points for clearing entire board', icon: '✨' }
]
};
// Game tips and strategies
export const GAME_TIPS = [
{
title: 'Plan Ahead',
description: 'Look at the next piece preview to plan your moves',
icon: '🔮',
difficulty: 'Beginner'
},
{
title: 'Build Flat',
description: 'Try to keep your stack relatively flat to avoid getting trapped',
icon: '📏',
difficulty: 'Beginner'
},
{
title: 'Save the Wells',
description: 'Keep a 1-wide column open for I-pieces (line pieces)',
icon: '🕳️',
difficulty: 'Intermediate'
},
{
title: 'Go for Quads',
description: 'Line up 4 rows for "Maple Blocks!" - the highest scoring move',
icon: '🍁',
difficulty: 'Intermediate'
},
{
title: 'Speed Control',
description: 'Use soft drops (↓) to maintain control while scoring speed bonuses',
icon: '🎯',
difficulty: 'Advanced'
},
{
title: 'Emergency Tactics',
description: 'When stack gets high, focus on survival over high scores',
icon: '🚨',
difficulty: 'Advanced'
}
];
// Level progression information
export const LEVEL_INFO = {
progression: 'Every 10 lines cleared advances you one level',
effects: [
'Pieces drop faster each level',
'Score multiplier increases',
'Game becomes more challenging',
'Higher levels = higher potential scores'
],
maxLevel: 'No maximum level - see how high you can go!',
startingDifficulty: 'Choose 0-10 incomplete lines to start with for extra challenge'
};
// Piece information
export const PIECE_INFO = {
types: [
{ name: 'I-Piece (Line)', shape: '████', color: '#00f5ff', description: 'Perfect for clearing multiple lines' },
{ name: 'O-Piece (Square)', shape: '██\n██', color: '#ffff00', description: 'Stable base piece' },
{ name: 'T-Piece', shape: ' █ \n███', color: '#a000f0', description: 'Versatile for tight spaces' },
{ name: 'S-Piece', shape: ' ██\n██ ', color: '#00f000', description: 'Great for creating steps' },
{ name: 'Z-Piece', shape: '██ \n ██', color: '#f00000', description: 'Mirror of S-piece' },
{ name: 'J-Piece', shape: '█ \n███', color: '#0000f0', description: 'Good for corners' },
{ name: 'L-Piece', shape: ' █\n███', color: '#f0a000', description: 'Mirror of J-piece' }
],
strategy: 'Each piece has unique strengths - learn to use them effectively!'
};
// Game modes and difficulty
export const DIFFICULTY_INFO = {
speeds: [
{ name: 'Slow', description: 'Relaxed pace, perfect for learning', icon: '🐌' },
{ name: 'Normal', description: 'Classic Maple Blocks experience', icon: '🚀' },
{ name: 'Fast', description: 'High-speed challenge for experts', icon: '⚡' }
],
startingLevels: {
description: 'Choose how many incomplete lines to start with',
levels: [
{ level: 0, description: 'Clean board - classic start' },
{ level: '1-3', description: 'Light challenge with some obstacles' },
{ level: '4-6', description: 'Moderate challenge' },
{ level: '7-10', description: 'Expert mode - very challenging start' }
]
}
};
// Achievement information
export const ACHIEVEMENTS = [
{ name: 'First Steps', description: 'Clear your first line', icon: '👶' },
{ name: 'Getting Started', description: 'Reach level 1', icon: '🌱' },
{ name: 'Maple Rookie', description: 'Score 1,000 points', icon: '🥉' },
{ name: 'Line Master', description: 'Clear 50 lines in one game', icon: '🎯' },
{ name: 'Speed Demon', description: 'Reach level 10', icon: '🔥' },
{ name: 'Quad Master', description: 'Clear 5 "Maple Blocks!" in one game', icon: '🍁' },
{ name: 'Perfect Storm', description: 'Achieve a perfect clear', icon: '✨' },
{ name: 'Maple Legend', description: 'Score 100,000 points', icon: '👑' }
];
// Functions to get formatted instruction data
/**
* Get basic game instructions for display
* @returns {object} Formatted basic instructions
*/
export const getBasicInstructions = () => {
return {
title: 'How to Play Maple Blocks',
sections: [
{
title: 'Controls',
items: GAME_CONTROLS.movement
},
{
title: 'Objective',
items: GAME_CONTROLS.gameplay
},
{
title: 'Scoring',
items: SCORING_INFO.lineClears.map(clear => ({
key: `${clear.lines} Line${clear.lines > 1 ? 's' : ''}`,
description: `${clear.name} - ${clear.points} points`,
icon: clear.icon
}))
}
]
};
};
/**
* Get detailed game guide
* @returns {object} Complete game guide
*/
export const getDetailedGuide = () => {
return {
title: 'Complete Maple Blocks Guide',
sections: [
{
title: 'Game Controls',
content: GAME_CONTROLS
},
{
title: 'Scoring System',
content: SCORING_INFO
},
{
title: 'Tips & Strategies',
content: GAME_TIPS
},
{
title: 'Level Progression',
content: LEVEL_INFO
},
{
title: 'Piece Guide',
content: PIECE_INFO
},
{
title: 'Difficulty Settings',
content: DIFFICULTY_INFO
}
]
};
};
/**
* Get tips by difficulty level
* @param {string} difficulty - 'Beginner', 'Intermediate', or 'Advanced'
* @returns {array} Filtered tips for the difficulty level
*/
export const getTipsByDifficulty = (difficulty) => {
return GAME_TIPS.filter(tip => tip.difficulty === difficulty);
};
/**
* Get quick reference card for in-game display
* @returns {object} Quick reference information
*/
export const getQuickReference = () => {
return {
controls: GAME_CONTROLS.movement.slice(0, 4), // Top 4 controls
scoring: SCORING_INFO.lineClears,
tips: GAME_TIPS.filter(tip => tip.difficulty === 'Beginner').slice(0, 2)
};
};
/**
* Get random tip for display
* @returns {object} Random tip object
*/
export const getRandomTip = () => {
const randomIndex = Math.floor(Math.random() * GAME_TIPS.length);
return GAME_TIPS[randomIndex];
};
/**
* Format instructions for modal display
* @param {string} type - 'basic' or 'detailed'
* @returns {object} Formatted instructions for modal
*/
export const getInstructionsForModal = (type = 'basic') => {
if (type === 'detailed') {
return getDetailedGuide();
}
return getBasicInstructions();
};
// Export everything as default object as well
export default {
GAME_CONTROLS,
SCORING_INFO,
GAME_TIPS,
LEVEL_INFO,
PIECE_INFO,
DIFFICULTY_INFO,
ACHIEVEMENTS,
getBasicInstructions,
getDetailedGuide,
getTipsByDifficulty,
getQuickReference,
getRandomTip,
getInstructionsForModal
};

View file

@ -0,0 +1,334 @@
// Mobile Interface Utilities for Maple Blocks
// Handles responsive design, hamburger menu, and mobile-specific components
import React from 'react';
/**
* Detect if the current device is mobile based on screen width
* @returns {boolean} True if mobile device
*/
export const isMobileDevice = () => {
if (typeof window !== 'undefined') {
return window.innerWidth < 768; // Tailwind's md breakpoint
}
return false;
};
/**
* Mobile Stats Modal Component
* Shows all game information in a mobile-friendly modal
*/
export const MobileStatsModal = ({
isOpen,
onClose,
gameState,
onShowInstructions,
getCurrentGameTime,
getLevelProgress
}) => {
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-90 flex items-center justify-center z-50 p-4">
<div className="bg-gray-800 rounded-2xl shadow-2xl w-full max-w-sm max-h-[90vh] overflow-y-auto">
{/* Modal Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-700">
<h2 className="text-xl font-bold text-white flex items-center">
<span className="mr-2">📊</span>
Game Info
</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-white text-2xl font-bold transition-colors duration-200 hover:bg-gray-700 rounded-full w-8 h-8 flex items-center justify-center"
>
×
</button>
</div>
{/* Game Stats */}
<div className="p-4 space-y-3">
<h3 className="text-white font-bold mb-3">Current Game</h3>
<div className="bg-gradient-to-r from-yellow-600 to-orange-600 rounded-lg p-3">
<p className="text-yellow-100 text-sm">Score</p>
<p className="text-white text-2xl font-bold">{gameState.score.toLocaleString()}</p>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="bg-gradient-to-r from-blue-600 to-purple-600 rounded-lg p-3">
<p className="text-blue-100 text-xs">Lines</p>
<p className="text-white text-lg font-bold">{gameState.lines}</p>
</div>
<div className="bg-gradient-to-r from-green-600 to-teal-600 rounded-lg p-3">
<p className="text-green-100 text-xs">Level</p>
<p className="text-white text-lg font-bold">{gameState.level}</p>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="bg-gradient-to-r from-red-600 to-pink-600 rounded-lg p-3">
<p className="text-red-100 text-xs">Difficulty</p>
<p className="text-white text-lg font-bold">{gameState.difficulty}</p>
</div>
<div className="bg-gradient-to-r from-gray-600 to-gray-700 rounded-lg p-3">
<p className="text-gray-100 text-xs">Time</p>
<p className="text-white text-lg font-bold">{getCurrentGameTime()}</p>
</div>
</div>
</div>
{/* Clear Stats */}
<div className="p-4 border-t border-gray-700">
<h3 className="text-white font-bold mb-3 flex items-center">
<span className="mr-2">🎯</span>
Clear Stats
</h3>
<div className="space-y-2">
<div className="flex justify-between bg-gray-700 rounded p-2">
<span className="text-gray-300 text-sm">Singles:</span>
<span className="text-white font-semibold">{gameState.linesCleared.single}</span>
</div>
<div className="flex justify-between bg-gray-700 rounded p-2">
<span className="text-gray-300 text-sm">Doubles:</span>
<span className="text-white font-semibold">{gameState.linesCleared.double}</span>
</div>
<div className="flex justify-between bg-gray-700 rounded p-2">
<span className="text-gray-300 text-sm">Triples:</span>
<span className="text-white font-semibold">{gameState.linesCleared.triple}</span>
</div>
<div className="flex justify-between bg-gray-700 rounded p-2">
<span className="text-gray-300 text-sm">Maple Blocks:</span>
<span className="text-yellow-400 font-bold">{gameState.linesCleared.quad}</span>
</div>
</div>
</div>
{/* Progress */}
<div className="p-4 border-t border-gray-700">
<h3 className="text-white font-bold mb-3">🎯 Progress</h3>
<div className="space-y-2">
<p className="text-gray-300 text-sm">Next level: {10 - (gameState.lines % 10)} lines</p>
<div className="bg-gray-700 rounded-full h-3">
<div
className="bg-green-500 h-3 rounded-full transition-all duration-300"
style={{ width: `${getLevelProgress()}%` }}
/>
</div>
<p className="text-xs text-gray-400">
Speed: {gameState.gameSpeed}
</p>
</div>
</div>
{/* Instructions Link */}
<div className="p-4 border-t border-gray-700">
<button
onClick={() => {
onClose();
onShowInstructions();
}}
className="w-full bg-red-600 hover:bg-red-700 text-white font-bold py-3 px-4 rounded-lg transition-all duration-200"
>
📖 View Instructions
</button>
</div>
</div>
</div>
);
};
/**
* Hamburger Menu Icon Component
*/
export const HamburgerIcon = ({ isOpen, onClick, className = "" }) => {
return (
<button
onClick={onClick}
className={`p-2 rounded-lg transition-all duration-200 ${className}`}
aria-label="Toggle menu"
>
<div className="w-6 h-6 flex flex-col justify-center space-y-1">
<div
className={`w-full h-0.5 bg-current transition-all duration-300 ${
isOpen ? 'rotate-45 translate-y-1' : ''
}`}
/>
<div
className={`w-full h-0.5 bg-current transition-all duration-300 ${
isOpen ? 'opacity-0' : ''
}`}
/>
<div
className={`w-full h-0.5 bg-current transition-all duration-300 ${
isOpen ? '-rotate-45 -translate-y-1' : ''
}`}
/>
</div>
</button>
);
};
/**
* Mobile Game Header Component
* Shows hamburger menu and basic game info
*/
export const MobileGameHeader = ({
onMenuClick,
gameState,
isMenuOpen,
getCurrentGameTime
}) => {
return (
<div className="flex items-center justify-between p-4 bg-gray-800 rounded-t-xl">
{/* Left side - Hamburger */}
<HamburgerIcon
isOpen={isMenuOpen}
onClick={onMenuClick}
className="text-white hover:bg-gray-700"
/>
{/* Center - Quick Stats */}
<div className="flex items-center space-x-4 text-white">
<div className="text-center">
<p className="text-xs text-gray-400">Score</p>
<p className="text-sm font-bold">{gameState.score.toLocaleString()}</p>
</div>
<div className="text-center">
<p className="text-xs text-gray-400">Lines</p>
<p className="text-sm font-bold">{gameState.lines}</p>
</div>
<div className="text-center">
<p className="text-xs text-gray-400">Level</p>
<p className="text-sm font-bold">{gameState.level}</p>
</div>
</div>
{/* Right side - Time */}
<div className="text-white text-right">
<p className="text-xs text-gray-400">Time</p>
<p className="text-sm font-bold">{getCurrentGameTime()}</p>
</div>
</div>
);
};
/**
* Mobile Next Piece Component
* Compact version for mobile layout
*/
export const MobileNextPiece = ({ nextPiece }) => {
if (!nextPiece) return null;
return (
<div className="bg-gray-700 rounded-lg p-2 mb-3">
<div className="flex items-center justify-between">
<p className="text-white text-xs font-semibold flex items-center">
<span className="mr-1">🔮</span>Next:
</p>
<div className="flex items-center justify-center bg-gray-800 rounded p-1">
<div
className="grid"
style={{
gridTemplateColumns: `repeat(${nextPiece.shape[0].length}, 1fr)`,
gap: '1px'
}}
>
{nextPiece.shape.map((row, rowIndex) =>
row.map((cell, colIndex) => (
<div
key={`${rowIndex}-${colIndex}`}
className="w-2 h-2"
style={{
backgroundColor: cell ? nextPiece.color : 'transparent'
}}
/>
))
)}
</div>
</div>
</div>
</div>
);
};
/**
* Mobile Layout Wrapper Component
* Handles the overall mobile layout structure
*/
export const MobileLayout = ({
gameState,
showMobileMenu,
onMenuToggle,
onShowInstructions,
getCurrentGameTime,
getLevelProgress,
nextPiece,
children // Game board and controls
}) => {
return (
<div className="min-h-screen bg-gradient-to-br from-red-900 via-rose-900 to-pink-900 p-2">
<div className="max-w-sm mx-auto">
{/* Mobile Header */}
<MobileGameHeader
onMenuClick={onMenuToggle}
gameState={gameState}
isMenuOpen={showMobileMenu}
getCurrentGameTime={getCurrentGameTime}
/>
{/* Next Piece */}
<div className="p-2">
<MobileNextPiece nextPiece={nextPiece} />
</div>
{/* Game Board and Controls */}
{children}
{/* Mobile Stats Modal */}
<MobileStatsModal
isOpen={showMobileMenu}
onClose={onMenuToggle}
gameState={gameState}
onShowInstructions={onShowInstructions}
getCurrentGameTime={getCurrentGameTime}
getLevelProgress={getLevelProgress}
/>
</div>
</div>
);
};
/**
* Hook to detect mobile screen size
*/
export const useMobileDetection = () => {
const [isMobile, setIsMobile] = React.useState(false);
React.useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth < 768);
};
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
return isMobile;
};
// Export all components and utilities
export default {
isMobileDevice,
MobileStatsModal,
HamburgerIcon,
MobileGameHeader,
MobileNextPiece,
MobileLayout,
useMobileDetection
};

View file

@ -0,0 +1,261 @@
// Storage keys
const STORAGE_KEYS = {
HIGH_SCORES: 'mapleBlocks_highScores',
SETTINGS: 'mapleBlocks_settings',
USER_PREFERENCES: 'mapleBlocks_userPreferences'
};
// Default values
const DEFAULT_SETTINGS = {
startingLevel: 0,
gameSpeed: 'Normal'
};
const DEFAULT_HIGH_SCORES = [];
// Helper function to safely parse JSON
const safeJSONParse = (jsonString, defaultValue) => {
try {
return JSON.parse(jsonString);
} catch (error) {
console.warn('Failed to parse JSON from localStorage:', error);
return defaultValue;
}
};
// Helper function to safely stringify JSON
const safeJSONStringify = (data) => {
try {
return JSON.stringify(data);
} catch (error) {
console.error('Failed to stringify data for localStorage:', error);
return null;
}
};
// High Scores Management
export const highScoresStorage = {
// Get all high scores
getHighScores: () => {
const scores = localStorage.getItem(STORAGE_KEYS.HIGH_SCORES);
return safeJSONParse(scores, DEFAULT_HIGH_SCORES);
},
// Add a new high score
addHighScore: (score, level, gameSpeed, playerName = 'Anonymous') => {
const scores = highScoresStorage.getHighScores();
const newScore = {
score: parseInt(score),
level: parseInt(level),
gameSpeed,
playerName,
date: new Date().toISOString(),
timestamp: Date.now()
};
scores.push(newScore);
// Sort by score (highest first) and keep only top 50
scores.sort((a, b) => b.score - a.score);
const topScores = scores.slice(0, 50);
const jsonString = safeJSONStringify(topScores);
if (jsonString) {
localStorage.setItem(STORAGE_KEYS.HIGH_SCORES, jsonString);
}
return topScores;
},
// Get top N scores
getTopScores: (limit = 10) => {
const scores = highScoresStorage.getHighScores();
return scores.slice(0, limit);
},
// Check if score qualifies for high score list
isHighScore: (score) => {
const scores = highScoresStorage.getHighScores();
if (scores.length < 50) return true;
return score > scores[scores.length - 1].score;
},
// Get personal best
getPersonalBest: () => {
const scores = highScoresStorage.getHighScores();
return scores.length > 0 ? scores[0].score : 0;
},
// Clear all high scores
clearHighScores: () => {
localStorage.removeItem(STORAGE_KEYS.HIGH_SCORES);
},
// Get scores by level
getScoresByLevel: (level) => {
const scores = highScoresStorage.getHighScores();
return scores.filter(score => score.level === parseInt(level));
},
// Get scores by speed
getScoresBySpeed: (gameSpeed) => {
const scores = highScoresStorage.getHighScores();
return scores.filter(score => score.gameSpeed === gameSpeed);
}
};
// Game Settings Management
export const settingsStorage = {
// Get user settings
getSettings: () => {
const settings = localStorage.getItem(STORAGE_KEYS.SETTINGS);
return { ...DEFAULT_SETTINGS, ...safeJSONParse(settings, {}) };
},
// Save user settings
saveSettings: (settings) => {
const currentSettings = settingsStorage.getSettings();
const updatedSettings = { ...currentSettings, ...settings };
const jsonString = safeJSONStringify(updatedSettings);
if (jsonString) {
localStorage.setItem(STORAGE_KEYS.SETTINGS, jsonString);
}
return updatedSettings;
},
// Get starting level
getStartingLevel: () => {
const settings = settingsStorage.getSettings();
return settings.startingLevel;
},
// Save starting level
saveStartingLevel: (level) => {
return settingsStorage.saveSettings({ startingLevel: parseInt(level) });
},
// Get game speed
getGameSpeed: () => {
const settings = settingsStorage.getSettings();
return settings.gameSpeed;
},
// Save game speed
saveGameSpeed: (speed) => {
return settingsStorage.saveSettings({ gameSpeed: speed });
},
// Reset settings to defaults
resetSettings: () => {
localStorage.removeItem(STORAGE_KEYS.SETTINGS);
return DEFAULT_SETTINGS;
}
};
// User Preferences Management (for additional features)
export const preferencesStorage = {
// Get user preferences
getPreferences: () => {
const prefs = localStorage.getItem(STORAGE_KEYS.USER_PREFERENCES);
return safeJSONParse(prefs, {});
},
// Save user preferences
savePreferences: (preferences) => {
const currentPrefs = preferencesStorage.getPreferences();
const updatedPrefs = { ...currentPrefs, ...preferences };
const jsonString = safeJSONStringify(updatedPrefs);
if (jsonString) {
localStorage.setItem(STORAGE_KEYS.USER_PREFERENCES, jsonString);
}
return updatedPrefs;
},
// Clear preferences
clearPreferences: () => {
localStorage.removeItem(STORAGE_KEYS.USER_PREFERENCES);
}
};
// Utility functions
export const storageUtils = {
// Check if localStorage is available
isLocalStorageAvailable: () => {
try {
const test = '__localStorage_test__';
localStorage.setItem(test, test);
localStorage.removeItem(test);
return true;
} catch (e) {
return false;
}
},
// Get storage usage info
getStorageInfo: () => {
if (!storageUtils.isLocalStorageAvailable()) {
return { available: false };
}
const highScores = localStorage.getItem(STORAGE_KEYS.HIGH_SCORES) || '';
const settings = localStorage.getItem(STORAGE_KEYS.SETTINGS) || '';
const preferences = localStorage.getItem(STORAGE_KEYS.USER_PREFERENCES) || '';
return {
available: true,
totalSize: highScores.length + settings.length + preferences.length,
highScoresSize: highScores.length,
settingsSize: settings.length,
preferencesSize: preferences.length
};
},
// Clear all game data
clearAllData: () => {
Object.values(STORAGE_KEYS).forEach(key => {
localStorage.removeItem(key);
});
},
// Export all data
exportData: () => {
return {
highScores: highScoresStorage.getHighScores(),
settings: settingsStorage.getSettings(),
preferences: preferencesStorage.getPreferences(),
exportDate: new Date().toISOString()
};
},
// Import data
importData: (data) => {
try {
if (data.highScores) {
localStorage.setItem(STORAGE_KEYS.HIGH_SCORES, JSON.stringify(data.highScores));
}
if (data.settings) {
localStorage.setItem(STORAGE_KEYS.SETTINGS, JSON.stringify(data.settings));
}
if (data.preferences) {
localStorage.setItem(STORAGE_KEYS.USER_PREFERENCES, JSON.stringify(data.preferences));
}
return true;
} catch (error) {
console.error('Failed to import data:', error);
return false;
}
}
};
// Default export with all storage functions
export default {
highScores: highScoresStorage,
settings: settingsStorage,
preferences: preferencesStorage,
utils: storageUtils
};

View file

@ -0,0 +1,83 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
theme: {
extend: {
// Custom colors for Maple Blocks theme
colors: {
"maple-red": {
50: "#fef2f2",
100: "#fee2e2",
200: "#fecaca",
300: "#fca5a5",
400: "#f87171",
500: "#ef4444",
600: "#dc2626",
700: "#b91c1c",
800: "#991b1b",
900: "#7f1d1d",
},
"maple-gold": {
50: "#fffbeb",
100: "#fef3c7",
200: "#fde68a",
300: "#fcd34d",
400: "#fbbf24",
500: "#f59e0b",
600: "#d97706",
700: "#b45309",
800: "#92400e",
900: "#78350f",
},
},
// Custom animations for game effects
animation: {
"bounce-slow": "bounce 2s infinite",
"pulse-fast": "pulse 1s infinite",
"spin-slow": "spin 2s linear infinite",
"fade-in": "fadeIn 0.5s ease-in-out",
"slide-up": "slideUp 0.3s ease-out",
glow: "glow 2s ease-in-out infinite",
},
keyframes: {
fadeIn: {
"0%": { opacity: "0" },
"100%": { opacity: "1" },
},
slideUp: {
"0%": { transform: "translateY(20px)", opacity: "0" },
"100%": { transform: "translateY(0)", opacity: "1" },
},
glow: {
"0%, 100%": { boxShadow: "0 0 5px rgba(239, 68, 68, 0.5)" },
"50%": { boxShadow: "0 0 20px rgba(239, 68, 68, 0.8)" },
},
},
// Custom spacing for game layout
spacing: {
18: "4.5rem",
88: "22rem",
128: "32rem",
},
// Custom font sizes for game text
fontSize: {
"2xs": "0.625rem",
"6xl": "3.75rem",
"7xl": "4.5rem",
"8xl": "6rem",
"9xl": "8rem",
},
// Custom shadows for game elements
boxShadow: {
game: "0 10px 25px rgba(0, 0, 0, 0.3)",
"game-hover": "0 15px 35px rgba(0, 0, 0, 0.4)",
maple: "0 0 20px rgba(239, 68, 68, 0.3)",
},
// Custom backdrop blur
backdropBlur: {
xs: "2px",
},
},
},
plugins: [],
};

View file

@ -0,0 +1,9 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
css: {
postcss: "./postcss.config.js",
},
});

View file

@ -0,0 +1,13 @@
<!-- index.html -->
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Maple QR</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,23 @@
{
"name": "maple-qr-wizard",
"version": "1.0.0",
"description": "A simple QR code generation wizard using React, Tailwind, and Vite",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@heroicons/react": "^2.2.0",
"qrcode": "^1.5.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.21.1"
},
"devDependencies": {
"autoprefixer": "^10.4.15",
"postcss": "^8.4.24",
"tailwindcss": "^3.4.3",
"vite": "^5.2.0"
}
}

View file

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
}

View file

@ -0,0 +1,20 @@
// src/App.jsx
import React from "react";
import {
BrowserRouter as Router,
Routes,
Route,
Navigate,
} from "react-router-dom";
import WizardPage from "./pages/WizardPage";
export default function App() {
return (
<Router>
<Routes>
<Route path="/" element={<Navigate to="/wizard" />} />
<Route path="/wizard" element={<WizardPage />} />
</Routes>
</Router>
);
}

View file

@ -0,0 +1,160 @@
// src/components/StepColorSettings.jsx
import React, { useState, useEffect } from "react";
const presetColors = [
"#8a1622", // deep red
"#ef5429", // orange
"#ffff00", // yellow
"#228B22", // forest green
"#0000ff", // blue
"#660066", // purple
"#000000", // black
];
function hexToRgb(hex) {
const bigint = parseInt(hex.slice(1), 16);
const r = (bigint >> 16) & 255;
const g = (bigint >> 8) & 255;
const b = bigint & 255;
return `rgb(${r}, ${g}, ${b})`;
}
function rgbToHex(rgb) {
const result = rgb.match(/\d+/g);
if (!result) return "#000000";
return (
"#" + result.map((n) => parseInt(n).toString(16).padStart(2, "0")).join("")
);
}
export default function StepColorSettings({
formData,
setFormData,
onNext,
onBack,
}) {
const [mode, setMode] = useState("white"); // white | presets | custom
const [hex, setHex] = useState("#000000");
const [rgb, setRgb] = useState("rgb(0, 0, 0)");
useEffect(() => {
setFormData({ ...formData, color: hex });
}, [hex]);
const handleHexChange = (e) => {
const value = e.target.value;
setHex(value);
setRgb(hexToRgb(value));
};
const handleRgbChange = (e) => {
const value = e.target.value;
setRgb(value);
try {
const hexValue = rgbToHex(value);
setHex(hexValue);
} catch {}
};
const tileClasses = (selected) =>
`border rounded-xl px-4 py-4 text-left transition hover:shadow-md ${
mode === selected ? "border-[#8a1622] bg-[#fff5f6]" : "border-gray-200"
}`;
return (
<div className="max-w-2xl mx-auto px-4 py-8">
<h2 className="text-2xl font-bold text-[#8a1622] mb-6">
Step 5: Choose Foreground Colour
</h2>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-8">
<button
onClick={() => {
setMode("white");
setHex("#000000");
}}
className={tileClasses("white")}
>
<div className="text-base font-semibold text-[#8a1622]">Black</div>
<div className="text-sm text-gray-600 mt-1">
This is the default and easiest to read & scan option
</div>
</button>
<button
onClick={() => setMode("presets")}
className={tileClasses("presets")}
>
<div className="text-base font-semibold text-[#8a1622]">Presets</div>
<div className="text-sm text-gray-600 mt-1">
Choose from preset colours
</div>
</button>
<button
onClick={() => setMode("custom")}
className={tileClasses("custom")}
>
<div className="text-base font-semibold text-[#8a1622]">Custom</div>
<div className="text-sm text-gray-600 mt-1">
Choose a colour using hex or RGB values
</div>
</button>
</div>
{mode === "presets" && (
<>
<p className="mb-4 text-sm text-gray-700">Select a preset color:</p>
<div className="grid grid-cols-7 gap-3 mb-6">
{presetColors.map((color) => (
<button
key={color}
onClick={() => setHex(color)}
className={`w-10 h-10 rounded-md transition ${hex === color ? "border-4 border-[#8a1622]" : "border-2 border-white"}`}
style={{ backgroundColor: color }}
/>
))}
</div>
</>
)}
{mode === "custom" && (
<div className="text-center">
<div
className="w-[256px] h-[256px] mx-auto border-[2px] border-[#8a1622] rounded-xl mb-[6px]"
style={{ backgroundColor: hex }}
></div>
<div className="flex flex-col gap-4 items-center">
<input
type="text"
value={hex}
onChange={handleHexChange}
className="w-48 border-2 border-[#8a1622] px-4 py-2 rounded-xl text-center"
placeholder="#000000"
/>
<input
type="text"
value={rgb}
onChange={handleRgbChange}
className="w-48 border-2 border-[#8a1622] px-4 py-2 rounded-xl text-center"
placeholder="rgb(0, 0, 0)"
/>
</div>
</div>
)}
<div className="mt-10 flex justify-between">
<button
onClick={onBack}
className="px-6 py-2 border rounded-xl text-[#8a1622] border-[#8a1622]"
>
Back
</button>
<button
onClick={onNext}
className="bg-[#8a1622] text-white px-6 py-2 rounded-xl"
>
Next
</button>
</div>
</div>
);
}

View file

@ -0,0 +1,128 @@
// src/components/StepInputDetails.jsx
import React, { useState } from "react";
export default function StepInputDetails({
selectedType,
formData,
setFormData,
onNext,
onBack,
}) {
const [errors, setErrors] = useState({});
const handleChange = (e) => {
const { name, value } = e.target;
setFormData({ ...formData, [name]: value });
};
const handleSubmit = () => {
setErrors({});
onNext();
};
const inputStyle =
"w-full border-2 border-[#8a1622] bg-[#fff5f6] text-[#222] px-4 py-3 rounded-xl placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-[#8a1622] transition";
return (
<div className="max-w-2xl mx-auto px-4 py-8">
<h2 className="text-2xl font-bold text-[#8a1622] mb-6">
Step 2: Enter Details
</h2>
<div className="space-y-4">
{selectedType === "url" && (
<input
type="url"
name="url"
value={formData.url || ""}
onChange={handleChange}
placeholder="https://example.com"
className={inputStyle}
/>
)}
{selectedType === "text" && (
<textarea
name="text"
rows="4"
value={formData.text || ""}
onChange={handleChange}
placeholder="Enter plain text..."
className={inputStyle}
/>
)}
{selectedType === "email" && (
<input
type="email"
name="email"
value={formData.email || ""}
onChange={handleChange}
placeholder="email@example.com"
className={inputStyle}
/>
)}
{selectedType === "wifi" && (
<div className="space-y-4">
<input
type="text"
name="ssid"
value={formData.ssid || ""}
onChange={handleChange}
placeholder="Wi-Fi SSID"
className={inputStyle}
/>
<input
type="password"
name="password"
value={formData.password || ""}
onChange={handleChange}
placeholder="Password"
className={inputStyle}
/>
</div>
)}
{selectedType === "vcard" && (
<div className="space-y-4">
<input
type="text"
name="name"
value={formData.name || ""}
onChange={handleChange}
placeholder="Full Name"
className={inputStyle}
/>
<input
type="tel"
name="phone"
value={formData.phone || ""}
onChange={handleChange}
placeholder="Phone Number"
className={inputStyle}
/>
<input
type="email"
name="email"
value={formData.email || ""}
onChange={handleChange}
placeholder="Email"
className={inputStyle}
/>
</div>
)}
</div>
<div className="mt-8 flex justify-between">
<button
onClick={onBack}
className="px-6 py-2 border rounded-xl text-[#8a1622] border-[#8a1622]"
>
Back
</button>
<button
onClick={handleSubmit}
className="bg-[#8a1622] text-white px-6 py-2 rounded-xl"
>
Next
</button>
</div>
</div>
);
}

View file

@ -0,0 +1,113 @@
// src/components/StepLogoSettings.jsx
import React, { useRef, useState, useEffect } from "react";
import { saveToStorage, loadFromStorage } from "../utils/storage";
import { processLogoFile } from "../utils/qrCode";
import { PlusIcon, XMarkIcon } from "@heroicons/react/24/outline";
export default function StepLogoSettings({
formData,
setFormData,
onNext,
onBack,
}) {
const [preview, setPreview] = useState(null);
const fileInputRef = useRef();
useEffect(() => {
const savedLogo = loadFromStorage("qrLogoPreview");
if (savedLogo) setPreview(savedLogo);
}, []);
const handleLogoUpload = (e) => {
const file = e.target.files[0];
processLogoFile(file, formData, setFormData, setPreview, saveToStorage);
};
const triggerUpload = () => fileInputRef.current.click();
const handleDrop = (e) => {
e.preventDefault();
e.stopPropagation();
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
handleLogoUpload({ target: { files: e.dataTransfer.files } });
e.dataTransfer.clearData();
}
};
const handleDragOver = (e) => {
e.preventDefault();
e.stopPropagation();
};
return (
<div className="max-w-2xl mx-auto px-4 py-8">
<h2 className="text-2xl font-bold text-[#8a1622] mb-4">
Step 4: Add a Logo (Optional)
</h2>
<p className="mb-6 text-sm text-gray-700">
Upload a square logo (min 300x300px, JPG/PNG/TIFF, max 4MB). This will
be placed in the center of your QR code.
</p>
<div
onClick={triggerUpload}
onDrop={handleDrop}
onDragOver={handleDragOver}
className="cursor-pointer border-2 border-dashed border-[#8a1622] p-6 rounded-xl flex flex-col items-center justify-center bg-red-50 hover:bg-red-100 transition"
>
<PlusIcon className="h-8 w-8 text-[#8a1622] mb-2" />
<p className="text-sm font-medium text-[#8a1622]">
Click or drag & drop to upload your logo
</p>
<input
type="file"
accept="image/jpeg,image/png,image/tiff"
onChange={handleLogoUpload}
ref={fileInputRef}
className="hidden"
/>
</div>
{preview && (
<div className="mt-6 flex justify-center">
<div className="text-center">
<p className="mb-2 text-sm text-gray-600">Preview:</p>
<div className="relative w-[256px] h-[256px] border-[2px] border-[#8a1622] rounded-xl overflow-hidden m-[6px]">
<img
src={preview}
alt="Logo preview"
className="w-full h-full object-contain"
/>
<button
type="button"
onClick={() => {
setPreview(null);
saveToStorage("qrLogoPreview", "");
setFormData({ ...formData, logo: "" });
}}
className="absolute top-1 right-1 bg-white p-1 rounded-full shadow hover:bg-red-100"
>
<XMarkIcon className="h-4 w-4 text-[#8a1622]" />
</button>
</div>
</div>
</div>
)}
<div className="mt-10 flex justify-between">
<button
onClick={onBack}
className="px-6 py-2 border rounded-xl text-[#8a1622] border-[#8a1622]"
>
Back
</button>
<button
onClick={onNext}
className="bg-[#8a1622] text-white px-6 py-2 rounded-xl"
>
Next
</button>
</div>
</div>
);
}

View file

@ -0,0 +1,52 @@
// src/components/StepResult.jsx
import React, { useEffect, useRef, useState } from "react";
import { generateQRCodeWithLogo, downloadQRCode } from "../utils/qrCode";
export default function StepResult({ formData, onBack }) {
const canvasRef = useRef(null);
const [error, setError] = useState(null);
useEffect(() => {
if (canvasRef.current) {
generateQRCodeWithLogo(canvasRef.current, formData, setError);
}
}, [formData]);
const handleDownload = () => {
if (canvasRef.current) {
downloadQRCode(canvasRef.current, formData, setError);
}
};
return (
<div className="max-w-2xl mx-auto px-4 py-8 text-center">
<h2 className="text-2xl font-bold text-[#8a1622] mb-6">
Step 6: Your QR Code
</h2>
{error ? (
<p className="text-red-600">{error}</p>
) : (
<canvas
ref={canvasRef}
width={256}
height={256}
className="mx-auto border-[2px] border-[#8a1622] mb-[6px]"
/>
)}
<div className="mt-8 flex justify-center gap-4">
<button
onClick={onBack}
className="px-6 py-2 border rounded-xl text-[#8a1622] border-[#8a1622]"
>
Back
</button>
<button
onClick={handleDownload}
className="bg-[#8a1622] text-white px-6 py-2 rounded-xl"
>
Download {formData.size === "svg" ? "SVG" : "PNG"}
</button>
</div>
</div>
);
}

View file

@ -0,0 +1,237 @@
// src/components/StepResult.jsx
import React, { useEffect, useRef, useState } from "react";
import QRCode from "qrcode";
export default function StepResult({ formData, onBack }) {
const canvasRef = useRef(null);
const [error, setError] = useState(null);
useEffect(() => {
const generateQR = async () => {
try {
const content = buildQRContent(formData);
const canvas = canvasRef.current;
const ctx = canvas.getContext("2d");
await QRCode.toCanvas(canvas, content, {
width: 256,
margin: 1,
errorCorrectionLevel: "H",
scale: 8,
color: {
dark: formData.color || "#000000",
light: "#ffffff",
},
});
const storedLogo = localStorage.getItem("qrLogoPreview");
const logoSrc = formData.logo || storedLogo;
if (logoSrc) {
const logoImg = new Image();
logoImg.crossOrigin = "anonymous";
logoImg.onload = () => {
const logoSize = canvas.width * 0.25;
const centerX = (canvas.width - logoSize) / 2;
const centerY = (canvas.height - logoSize) / 2;
ctx.save();
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = "high";
ctx.beginPath();
ctx.arc(
centerX + logoSize / 2,
centerY + logoSize / 2,
logoSize / 2,
0,
Math.PI * 2,
);
ctx.fillStyle = "#ffffff";
ctx.fill();
ctx.clip();
ctx.drawImage(logoImg, centerX, centerY, logoSize, logoSize);
ctx.restore();
};
logoImg.src = logoSrc;
}
} catch (err) {
setError("Failed to generate QR code");
}
};
generateQR();
}, [formData]);
const buildQRContent = (data) => {
switch (data.type) {
case "url":
return data.url || "";
case "text":
return data.text || "";
case "email":
return `mailto:${data.email}`;
case "wifi":
return `WIFI:T:WPA;S:${data.ssid};P:${data.password};;`;
case "vcard":
return `BEGIN:VCARD\nVERSION:3.0\nFN:${data.name}\nTEL:${data.phone}\nEMAIL:${data.email}\nEND:VCARD`;
default:
return "";
}
};
const handleDownload = () => {
const canvas = canvasRef.current;
const link = document.createElement("a");
const storedLogo = localStorage.getItem("qrLogoPreview");
const logoSrc = formData.logo || storedLogo;
if (formData.size === "svg") {
const content = buildQRContent(formData);
QRCode.toString(content, {
type: "svg",
margin: 1,
errorCorrectionLevel: "H",
color: {
dark: formData.color || "#000000",
light: "#ffffff",
},
}).then(async (svg) => {
try {
// Parse the SVG into a DOM structure
const parser = new DOMParser();
const doc = parser.parseFromString(svg, "image/svg+xml");
const svgEl = doc.documentElement;
// Set proper namespace
svgEl.setAttribute("xmlns", "http://www.w3.org/2000/svg");
// Calculate dimensions - get actual SVG size
const svgWidth = parseFloat(svgEl.getAttribute("width"));
const svgHeight = parseFloat(svgEl.getAttribute("height"));
// Calculate logo size and position based on SVG dimensions
const logoSize = Math.min(svgWidth, svgHeight) * 0.25;
const centerX = (svgWidth - logoSize) / 2;
const centerY = (svgHeight - logoSize) / 2;
if (logoSrc) {
try {
// Fetch and convert logo to base64
const logoBlob = await fetch(logoSrc).then((res) => res.blob());
const base64Logo = await new Promise((resolve) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result);
reader.readAsDataURL(logoBlob);
});
if (base64Logo) {
// Create defs section for the clip path
const defs = doc.createElementNS(
"http://www.w3.org/2000/svg",
"defs",
);
const clipPath = doc.createElementNS(
"http://www.w3.org/2000/svg",
"clipPath",
);
clipPath.setAttribute("id", "logoClip");
// Create a circle for clipping
const clipCircle = doc.createElementNS(
"http://www.w3.org/2000/svg",
"circle",
);
clipCircle.setAttribute("cx", `${centerX + logoSize / 2}`);
clipCircle.setAttribute("cy", `${centerY + logoSize / 2}`);
clipCircle.setAttribute("r", `${logoSize / 2}`);
clipPath.appendChild(clipCircle);
defs.appendChild(clipPath);
svgEl.insertBefore(defs, svgEl.firstChild);
// Create a white background circle for the logo
const bgCircle = doc.createElementNS(
"http://www.w3.org/2000/svg",
"circle",
);
bgCircle.setAttribute("cx", `${centerX + logoSize / 2}`);
bgCircle.setAttribute("cy", `${centerY + logoSize / 2}`);
bgCircle.setAttribute("r", `${logoSize / 2}`);
bgCircle.setAttribute("fill", "#ffffff");
svgEl.appendChild(bgCircle);
// Add the logo image
const image = doc.createElementNS(
"http://www.w3.org/2000/svg",
"image",
);
image.setAttribute("href", base64Logo);
image.setAttribute("x", `${centerX}`);
image.setAttribute("y", `${centerY}`);
image.setAttribute("width", `${logoSize}`);
image.setAttribute("height", `${logoSize}`);
image.setAttribute("clip-path", "url(#logoClip)");
image.setAttribute("preserveAspectRatio", "xMidYMid slice");
svgEl.appendChild(image);
}
} catch (logoError) {
console.error("Error adding logo to SVG:", logoError);
}
}
// Convert back to string and download
const serializer = new XMLSerializer();
const finalSvg = serializer.serializeToString(doc);
const blob = new Blob([finalSvg], { type: "image/svg+xml" });
const url = URL.createObjectURL(blob);
link.href = url;
link.download = "qr-code.svg";
link.click();
// Clean up
URL.revokeObjectURL(url);
} catch (err) {
console.error("Error generating SVG:", err);
setError("Failed to generate SVG QR code");
}
});
} else {
link.download = "qr-code.png";
link.href = canvas.toDataURL("image/png");
link.click();
}
};
return (
<div className="max-w-2xl mx-auto px-4 py-8 text-center">
<h2 className="text-2xl font-bold text-[#8a1622] mb-6">
Step 6: Your QR Code
</h2>
{error ? (
<p className="text-red-600">{error}</p>
) : (
<canvas
ref={canvasRef}
width={256}
height={256}
className="mx-auto border-[2px] border-[#8a1622] mb-[6px]"
/>
)}
<div className="mt-8 flex justify-center gap-4">
<button
onClick={onBack}
className="px-6 py-2 border rounded-xl text-[#8a1622] border-[#8a1622]"
>
Back
</button>
<button
onClick={handleDownload}
className="bg-[#8a1622] text-white px-6 py-2 rounded-xl"
>
Download {formData.size === "svg" ? "SVG" : "PNG"}
</button>
</div>
</div>
);
}

View file

@ -0,0 +1,127 @@
// src/components/StepLogoSettings.jsx
import React, { useRef, useState, useEffect } from "react";
import { saveToStorage, loadFromStorage } from "../utils/storage";
import { PlusIcon, XMarkIcon } from "@heroicons/react/24/outline";
export default function StepLogoSettings({
formData,
setFormData,
onNext,
onBack,
}) {
const [preview, setPreview] = useState(null);
const fileInputRef = useRef();
useEffect(() => {
const savedLogo = loadFromStorage("qrLogoPreview");
if (savedLogo) setPreview(savedLogo);
}, []);
const handleLogoUpload = (e) => {
const file = e.target.files[0];
if (!file) return;
const validTypes = ["image/jpeg", "image/png", "image/tiff"];
if (!validTypes.includes(file.type) || file.size > 4 * 1024 * 1024) {
alert("Only JPG, PNG, TIFF up to 4MB allowed.");
return;
}
const reader = new FileReader();
reader.onload = (event) => {
const imageData = event.target.result;
saveToStorage("qrLogoPreview", imageData);
setFormData({ ...formData, logo: imageData });
setPreview(imageData);
};
reader.readAsDataURL(file);
};
const triggerUpload = () => fileInputRef.current.click();
const handleDrop = (e) => {
e.preventDefault();
e.stopPropagation();
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
handleLogoUpload({ target: { files: e.dataTransfer.files } });
e.dataTransfer.clearData();
}
};
const handleDragOver = (e) => {
e.preventDefault();
e.stopPropagation();
};
return (
<div className="max-w-2xl mx-auto px-4 py-8">
<h2 className="text-2xl font-bold text-[#8a1622] mb-4">
Step 4: Add a Logo (Optional)
</h2>
<p className="mb-6 text-sm text-gray-700">
Upload a square logo (min 300x300px, JPG/PNG/TIFF, max 4MB). This will
be placed in the center of your QR code.
</p>
<div
onClick={triggerUpload}
onDrop={handleDrop}
onDragOver={handleDragOver}
className="cursor-pointer border-2 border-dashed border-[#8a1622] p-6 rounded-xl flex flex-col items-center justify-center bg-red-50 hover:bg-red-100 transition"
>
<PlusIcon className="h-8 w-8 text-[#8a1622] mb-2" />
<p className="text-sm font-medium text-[#8a1622]">
Click or drag & drop to upload your logo
</p>
<input
type="file"
accept="image/jpeg,image/png,image/tiff"
onChange={handleLogoUpload}
ref={fileInputRef}
className="hidden"
/>
</div>
{preview && (
<div className="mt-6 flex justify-center">
<div className="text-center">
<p className="mb-2 text-sm text-gray-600">Preview:</p>
<div className="relative w-[256px] h-[256px] border-[2px] border-[#8a1622] rounded-xl overflow-hidden m-[6px]">
<img
src={preview}
alt="Logo preview"
className="w-full h-full object-contain"
/>
<button
type="button"
onClick={() => {
setPreview(null);
saveToStorage("qrLogoPreview", "");
setFormData({ ...formData, logo: "" });
}}
className="absolute top-1 right-1 bg-white p-1 rounded-full shadow hover:bg-red-100"
>
<XMarkIcon className="h-4 w-4 text-[#8a1622]" />
</button>
</div>
</div>
</div>
)}
<div className="mt-10 flex justify-between">
<button
onClick={onBack}
className="px-6 py-2 border rounded-xl text-[#8a1622] border-[#8a1622]"
>
Back
</button>
<button
onClick={onNext}
className="bg-[#8a1622] text-white px-6 py-2 rounded-xl"
>
Next
</button>
</div>
</div>
);
}

View file

@ -0,0 +1,39 @@
// src/components/StepSizeOptions.jsx
import React from "react";
export default function StepSizeOptions({ formData, setFormData, onNext, onBack }) {
const sizeOptions = [
{ value: "300", title: "300 px", description: "Small size, suitable for website use" },
{ value: "600", title: "600 px", description: "Regular size - suitable for websites and apps" },
{ value: "1080", title: "1080 px", description: "Suitable for socials and printing" },
{ value: "svg", title: "Vector (SVG)", description: "Download to use in a vector editing app" }
];
return (
<div className="max-w-2xl mx-auto px-4 py-8">
<h2 className="text-2xl font-bold text-[#8a1622] mb-6">Step 3: Choose Output Size</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-2 lg:grid-cols-4 gap-4">
{sizeOptions.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setFormData({ ...formData, size: opt.value })}
className={`w-full text-left border-2 rounded-xl px-4 py-3 transition hover:shadow-md ${formData.size === opt.value ? 'border-[#8a1622] bg-[#fff5f6]' : 'border-gray-200'}`}
>
<div className="text-base font-semibold text-[#8a1622]">{opt.title}</div>
<div className="text-sm text-gray-600 mt-1">{opt.description}</div>
</button>
))}
</div>
<div className="mt-8 flex justify-between">
<button onClick={onBack} className="px-6 py-2 border rounded-xl text-[#8a1622] border-[#8a1622]">
Back
</button>
<button onClick={onNext} className="bg-[#8a1622] text-white px-6 py-2 rounded-xl">
Next
</button>
</div>
</div>
);
}

View file

@ -0,0 +1,127 @@
// src/components/StepStyleOptions.jsx
import React from "react";
export default function StepStyleOptions({
formData,
setFormData,
onNext,
onBack,
}) {
const handleChange = (e) => {
const { name, value } = e.target;
setFormData({ ...formData, [name]: value });
};
const presetColors = [
"#000000",
"#8a1622",
"#1e40af",
"#16a34a",
"#d97706",
"#e11d48",
];
const inputStyle =
"w-full border-2 border-[#8a1622] bg-[#fff5f6] text-[#222] px-4 py-3 rounded-xl placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-[#8a1622] transition";
const labelStyle = "block mb-2 text-sm font-semibold text-black";
const sizeOptions = [
{
value: "300",
title: "300 px",
description: "Small size, suitable for website use",
},
{
value: "600",
title: "600 px",
description: "Regular size - suitable for websites and apps",
},
{
value: "1080",
title: "1080 px",
description: "Suitable for socials and printing",
},
{
value: "svg",
title: "Vector (SVG)",
description: "Download to use in a vector editing app",
},
];
return (
<div className="max-w-2xl mx-auto px-4 py-8">
<h2 className="text-2xl font-bold text-[#8a1622] mb-6">
Step 3: Style Your QR Code
</h2>
<div className="space-y-6">
{/* Size tile selector */}
<div>
<label className={labelStyle}>Size</label>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-2 lg:grid-cols-4 gap-4">
{sizeOptions.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setFormData({ ...formData, size: opt.value })}
className={`w-full text-left border-2 rounded-xl px-4 py-3 transition hover:shadow-md ${formData.size === opt.value ? "border-[#8a1622] bg-[#fff5f6]" : "border-gray-200"}`}
>
<div className="text-base font-semibold text-[#8a1622]">
{opt.title}
</div>
<div className="text-sm text-gray-600 mt-1">
{opt.description}
</div>
</button>
))}
</div>
</div>
{/* Foreground color tiles */}
<div>
<label className={labelStyle}>Foreground Color</label>
<div className="flex flex-wrap gap-3 p-4 border-2 border-[#8a1622] bg-[#fff5f6] rounded-xl">
{presetColors.map((color) => (
<button
key={color}
type="button"
onClick={() => setFormData({ ...formData, color })}
className={`w-10 h-10 rounded-full border-2 ${formData.color === color ? "ring-2 ring-offset-2 ring-[#8a1622]" : "border-white"}`}
style={{ backgroundColor: color }}
/>
))}
</div>
</div>
{/* Margin dropdown */}
<div>
<label className={labelStyle}>Margin</label>
<select
name="margin"
value={formData.margin || "4"}
onChange={handleChange}
className={inputStyle}
>
<option value="4">Small (4px)</option>
<option value="12">Medium (12px)</option>
<option value="24">Large (24px)</option>
</select>
</div>
</div>
<div className="mt-8 flex justify-between">
<button
onClick={onBack}
className="px-6 py-2 border rounded-xl text-[#8a1622] border-[#8a1622]"
>
Back
</button>
<button
onClick={onNext}
className="bg-[#8a1622] text-white px-6 py-2 rounded-xl"
>
Next
</button>
</div>
</div>
);
}

View file

@ -0,0 +1,47 @@
import React from "react";
import {
GlobeAltIcon,
QrCodeIcon,
EnvelopeIcon,
WifiIcon,
UserIcon
} from "@heroicons/react/24/outline";
const qrTypes = [
{ id: 'url', name: 'Website URL', icon: GlobeAltIcon },
{ id: 'text', name: 'Plain Text', icon: QrCodeIcon },
{ id: 'email', name: 'Email Address', icon: EnvelopeIcon },
{ id: 'wifi', name: 'Wi-Fi Login', icon: WifiIcon },
{ id: 'vcard', name: 'Contact (vCard)', icon: UserIcon },
];
export default function StepTypeSelect({ selectedType, setSelectedType, onNext }) {
return (
<div className="max-w-2xl mx-auto px-4 py-8">
<h2 className="text-2xl font-bold text-[#8a1622] mb-6">Step 1: Choose QR Code Type</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{qrTypes.map((type) => (
<button
key={type.id}
onClick={() => setSelectedType(type.id)}
className={`flex items-center gap-4 border rounded-xl p-4 transition hover:shadow-md ${
selectedType === type.id ? 'border-[#8a1622] bg-[#8a162210]' : 'border-gray-200'
}`}
>
<type.icon className="h-6 w-6 text-[#8a1622]" />
<span className="text-lg font-medium">{type.name}</span>
</button>
))}
</div>
<div className="mt-8 text-right">
<button
disabled={!selectedType}
onClick={onNext}
className="bg-[#8a1622] text-white px-6 py-2 rounded-xl disabled:opacity-50"
>
Next
</button>
</div>
</div>
);
}

View file

@ -0,0 +1,12 @@
/* src/index.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
@apply bg-white text-gray-800 font-sans;
}
button:focus {
@apply outline-none ring-2 ring-[#8a1622] ring-offset-2;
}

View file

@ -0,0 +1,11 @@
// src/main.jsx
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

View file

@ -0,0 +1,71 @@
// src/pages/WizardPage.jsx
import React, { useState } from "react";
import StepTypeSelect from "../components/StepTypeSelect";
import StepInputDetails from "../components/StepInputDetails";
import StepSizeOptions from "../components/StepSizeOptions";
import StepLogoSettings from "../components/StepLogoSettings";
import StepColorSettings from "../components/StepColorSettings";
import StepResult from "../components/StepResult";
export default function WizardPage() {
const [step, setStep] = useState(1);
const [selectedType, setSelectedType] = useState(null);
const [formData, setFormData] = useState({});
const next = () => setStep((prev) => prev + 1);
const back = () => setStep((prev) => prev - 1);
formData.type = selectedType;
return (
<div className="min-h-screen bg-white">
{step === 1 && (
<StepTypeSelect
selectedType={selectedType}
setSelectedType={setSelectedType}
onNext={next}
/>
)}
{step === 2 && (
<StepInputDetails
selectedType={selectedType}
formData={formData}
setFormData={setFormData}
onNext={next}
onBack={back}
/>
)}
{step === 3 && (
<StepSizeOptions
formData={formData}
setFormData={setFormData}
onNext={next}
onBack={back}
/>
)}
{step === 4 && (
<StepLogoSettings
formData={formData}
setFormData={setFormData}
onNext={next}
onBack={back}
/>
)}
{step === 5 && (
<StepColorSettings
formData={formData}
setFormData={setFormData}
onNext={next}
onBack={back}
/>
)}
{step === 6 && <StepResult formData={formData} onBack={back} />}
</div>
);
}

View file

@ -0,0 +1,264 @@
// src/utils/qrCode.js
/**
* Builds QR code content based on form data type
* @param {Object} data - Form data containing QR code information
* @returns {String} Formatted content for the QR code
*/
export const buildQRContent = (data) => {
switch (data.type) {
case "url":
return data.url || "";
case "text":
return data.text || "";
case "email":
return `mailto:${data.email}`;
case "wifi":
return `WIFI:T:WPA;S:${data.ssid};P:${data.password};;`;
case "vcard":
return `BEGIN:VCARD\nVERSION:3.0\nFN:${data.name}\nTEL:${data.phone}\nEMAIL:${data.email}\nEND:VCARD`;
default:
return "";
}
};
/**
* Creates a QR code canvas with a logo overlay
* @param {HTMLCanvasElement} canvas - The canvas element to draw on
* @param {Object} formData - Form data containing QR code information
* @param {Function} setError - Function to set error state
*/
export const generateQRCodeWithLogo = async (canvas, formData, setError) => {
try {
const QRCode = await import("qrcode");
const content = buildQRContent(formData);
const ctx = canvas.getContext("2d");
await QRCode.toCanvas(canvas, content, {
width: 256,
margin: 1,
errorCorrectionLevel: "H",
scale: 8,
color: {
dark: formData.color || "#000000",
light: "#ffffff",
},
});
const storedLogo = localStorage.getItem("qrLogoPreview");
const logoSrc = formData.logo || storedLogo;
if (logoSrc) {
const logoImg = new Image();
logoImg.crossOrigin = "anonymous";
logoImg.onload = () => {
const logoSize = canvas.width * 0.25;
const centerX = (canvas.width - logoSize) / 2;
const centerY = (canvas.height - logoSize) / 2;
ctx.save();
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = "high";
ctx.beginPath();
ctx.arc(
centerX + logoSize / 2,
centerY + logoSize / 2,
logoSize / 2,
0,
Math.PI * 2,
);
ctx.fillStyle = "#ffffff";
ctx.fill();
ctx.clip();
ctx.drawImage(logoImg, centerX, centerY, logoSize, logoSize);
ctx.restore();
};
logoImg.src = logoSrc;
}
} catch (err) {
console.error("QR generation error:", err);
setError("Failed to generate QR code: " + err.message);
}
};
/**
* Handles the download of QR code as PNG or SVG
* @param {HTMLCanvasElement} canvas - Reference to the canvas element
* @param {Object} formData - Form data containing QR code information
* @param {Function} setError - Function to set error state
*/
export const downloadQRCode = async (canvas, formData, setError) => {
const QRCode = await import("qrcode");
const link = document.createElement("a");
const storedLogo = localStorage.getItem("qrLogoPreview");
const logoSrc = formData.logo || storedLogo;
if (formData.size === "svg") {
const content = buildQRContent(formData);
QRCode.toString(content, {
type: "svg",
margin: 1,
errorCorrectionLevel: "H",
color: {
dark: formData.color || "#000000",
light: "#ffffff",
},
})
.then(async (svg) => {
try {
// Parse the SVG
const parser = new DOMParser();
const doc = parser.parseFromString(svg, "image/svg+xml");
const svgEl = doc.documentElement;
// Get the actual SVG dimensions from viewBox or width/height attributes
let svgWidth, svgHeight;
if (svgEl.hasAttribute("viewBox")) {
const viewBox = svgEl.getAttribute("viewBox").split(" ");
svgWidth = parseFloat(viewBox[2]);
svgHeight = parseFloat(viewBox[3]);
} else {
svgWidth = parseFloat(svgEl.getAttribute("width") || "23");
svgHeight = parseFloat(svgEl.getAttribute("height") || "23");
// Set viewBox if not present
svgEl.setAttribute("viewBox", `0 0 ${svgWidth} ${svgHeight}`);
}
// Ensure proper namespace
svgEl.setAttribute("xmlns", "http://www.w3.org/2000/svg");
// Calculate logo size and position (25% of QR code size)
const logoSize = Math.min(svgWidth, svgHeight) * 0.25;
const centerX = (svgWidth - logoSize) / 2;
const centerY = (svgHeight - logoSize) / 2;
if (logoSrc) {
try {
// Create defs for clip path with coordinates that match the viewBox
const defs = doc.createElementNS(
"http://www.w3.org/2000/svg",
"defs",
);
const clipPath = doc.createElementNS(
"http://www.w3.org/2000/svg",
"clipPath",
);
const clipId = "logoClip" + Math.floor(Math.random() * 10000); // Add random id to avoid conflicts
clipPath.setAttribute("id", clipId);
const clipCircle = doc.createElementNS(
"http://www.w3.org/2000/svg",
"circle",
);
clipCircle.setAttribute("cx", `${centerX + logoSize / 2}`);
clipCircle.setAttribute("cy", `${centerY + logoSize / 2}`);
clipCircle.setAttribute("r", `${logoSize / 2}`);
clipPath.appendChild(clipCircle);
defs.appendChild(clipPath);
svgEl.insertBefore(defs, svgEl.firstChild);
// Add the white background circle
const bgCircle = doc.createElementNS(
"http://www.w3.org/2000/svg",
"circle",
);
bgCircle.setAttribute("cx", `${centerX + logoSize / 2}`);
bgCircle.setAttribute("cy", `${centerY + logoSize / 2}`);
bgCircle.setAttribute("r", `${logoSize / 2}`);
bgCircle.setAttribute("fill", "#ffffff");
svgEl.appendChild(bgCircle);
// Create the image element with coordinates that match the viewBox
const image = doc.createElementNS(
"http://www.w3.org/2000/svg",
"image",
);
// SVGs support using data URLs directly
image.setAttribute("href", logoSrc);
image.setAttribute("x", `${centerX}`);
image.setAttribute("y", `${centerY}`);
image.setAttribute("width", `${logoSize}`);
image.setAttribute("height", `${logoSize}`);
image.setAttribute("clip-path", `url(#${clipId})`);
image.setAttribute("preserveAspectRatio", "xMidYMid slice");
svgEl.appendChild(image);
console.log(
"Added logo to SVG successfully. ViewBox:",
svgEl.getAttribute("viewBox"),
);
} catch (logoErr) {
console.error("Error adding logo to SVG:", logoErr);
// Continue without the logo
}
}
// Convert back to string
const serializer = new XMLSerializer();
const finalSvg = serializer.serializeToString(doc);
const blob = new Blob([finalSvg], { type: "image/svg+xml" });
const url = URL.createObjectURL(blob);
link.href = url;
link.download = "qr-code.svg";
link.click();
// Clean up URL object
URL.revokeObjectURL(url);
} catch (err) {
console.error("Error generating SVG:", err);
setError("Failed to generate SVG QR code: " + err.message);
}
})
.catch((err) => {
console.error("QRCode generation error:", err);
setError("Failed to generate QR code: " + err.message);
});
} else {
link.download = "qr-code.png";
link.href = canvas.toDataURL("image/png");
link.click();
}
};
/**
* Processes and validates a logo file for use with QR codes
* @param {File} file - The image file to process
* @param {Object} formData - The current form data
* @param {Function} setFormData - Function to update form data
* @param {Function} setPreview - Function to update preview state
* @param {Function} saveToStorage - Function to save to storage
*/
export const processLogoFile = (
file,
formData,
setFormData,
setPreview,
saveToStorage,
) => {
if (!file) return;
const validTypes = ["image/jpeg", "image/png", "image/tiff"];
if (!validTypes.includes(file.type) || file.size > 4 * 1024 * 1024) {
alert("Only JPG, PNG, TIFF up to 4MB allowed.");
return;
}
const reader = new FileReader();
reader.onload = (event) => {
const imageData = event.target.result;
// Ensure we have a valid base64 data URL
if (typeof imageData === "string" && imageData.startsWith("data:image/")) {
saveToStorage("qrLogoPreview", imageData);
setFormData({ ...formData, logo: imageData });
setPreview(imageData);
} else {
alert("Invalid image format. Please try another image.");
}
};
reader.readAsDataURL(file);
};

View file

@ -0,0 +1,26 @@
// src/utils/storage.js
export function saveToStorage(key, value) {
try {
localStorage.setItem(key, value);
} catch (err) {
console.error("Storage save failed:", err);
}
}
export function loadFromStorage(key) {
try {
return localStorage.getItem(key);
} catch (err) {
console.error("Storage load failed:", err);
return null;
}
}
export function removeFromStorage(key) {
try {
localStorage.removeItem(key);
} catch (err) {
console.error("Storage remove failed:", err);
}
}

View file

@ -0,0 +1,11 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./index.html",
"./src/**/*.{js,jsx}"
],
theme: {
extend: {}
},
plugins: []
}

View file

@ -0,0 +1,12 @@
# React + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.

View file

@ -0,0 +1,33 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
export default [
{ ignores: ['dist'] },
{
files: ['**/*.{js,jsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...js.configs.recommended.rules,
...reactHooks.configs.recommended.rules,
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
]

View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,32 @@
{
"name": "maplesudoku",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router-dom": "^7.6.0"
},
"devDependencies": {
"@eslint/js": "^9.25.0",
"@tailwindcss/postcss": "^4.1.7",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"@vitejs/plugin-react": "^4.4.1",
"autoprefixer": "^10.4.21",
"eslint": "^9.25.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^16.0.0",
"postcss": "^8.5.3",
"tailwindcss": "^4.1.7",
"vite": "^6.3.5"
}
}

View file

@ -0,0 +1,6 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
autoprefixer: {},
},
};

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

View file

@ -0,0 +1,21 @@
import React from "react";
import { BrowserRouter as Router, Routes, Route } from "react-router-dom";
import SudokuWelcome from "./components/SudokuWelcome";
import SudokuBoard from "./components/SudokuBoard";
function App() {
return (
<Router>
<div className="App">
<Routes>
<Route path="/" element={<SudokuWelcome />} />
<Route path="/game/easy" element={<SudokuBoard />} />
<Route path="/game/medium" element={<SudokuBoard />} />
<Route path="/game/hard" element={<SudokuBoard />} />
</Routes>
</div>
</Router>
);
}
export default App;

View file

@ -0,0 +1,11 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Manual test */
.bg-red-500 {
background-color: #ef4444 !important;
}
.text-white {
color: white !important;
}

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4 KiB

View file

@ -0,0 +1,795 @@
import React, { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import {
provideHint,
getRemainingHints,
areHintsAvailable,
HINT_LIMITS,
} from "../utils/hints.js";
import { getRandomPuzzle } from "../utils/sudokuGenerator.js";
const SimpleSudokuGrid = () => {
const navigate = useNavigate();
// Get difficulty from URL params (default to 'easy')
const difficulty = window.location.pathname.includes("/hard")
? "hard"
: window.location.pathname.includes("/medium")
? "medium"
: "easy";
// Static puzzles as fallback - guaranteed to work
const staticPuzzles = {
easy: {
puzzle: [
[5, 3, 0, 0, 7, 0, 0, 0, 0],
[6, 0, 0, 1, 9, 5, 0, 0, 0],
[0, 9, 8, 0, 0, 0, 0, 6, 0],
[8, 0, 0, 0, 6, 0, 0, 0, 3],
[4, 0, 0, 8, 0, 3, 0, 0, 1],
[7, 0, 0, 0, 2, 0, 0, 0, 6],
[0, 6, 0, 0, 0, 0, 2, 8, 0],
[0, 0, 0, 4, 1, 9, 0, 0, 5],
[0, 0, 0, 0, 8, 0, 0, 7, 9],
],
solution: [
[5, 3, 4, 6, 7, 8, 9, 1, 2],
[6, 7, 2, 1, 9, 5, 3, 4, 8],
[1, 9, 8, 3, 4, 2, 5, 6, 7],
[8, 5, 9, 7, 6, 1, 4, 2, 3],
[4, 2, 6, 8, 5, 3, 7, 9, 1],
[7, 1, 3, 9, 2, 4, 8, 5, 6],
[9, 6, 1, 5, 3, 7, 2, 8, 4],
[2, 8, 7, 4, 1, 9, 6, 3, 5],
[3, 4, 5, 2, 8, 6, 1, 7, 9],
],
},
medium: {
puzzle: [
[0, 2, 0, 6, 0, 8, 0, 0, 0],
[5, 8, 0, 0, 0, 9, 7, 0, 0],
[0, 0, 0, 0, 4, 0, 0, 0, 0],
[3, 7, 0, 0, 0, 0, 5, 0, 0],
[6, 0, 0, 0, 0, 0, 0, 0, 4],
[0, 0, 8, 0, 0, 0, 0, 1, 3],
[0, 0, 0, 0, 2, 0, 0, 0, 0],
[0, 0, 9, 8, 0, 0, 0, 3, 6],
[0, 0, 0, 3, 0, 6, 0, 9, 0],
],
solution: [
[1, 2, 3, 6, 7, 8, 9, 4, 5],
[5, 8, 4, 2, 3, 9, 7, 6, 1],
[6, 7, 9, 5, 4, 1, 8, 2, 3],
[3, 7, 1, 4, 9, 2, 5, 8, 6],
[6, 9, 2, 7, 1, 5, 3, 5, 4],
[4, 5, 8, 9, 6, 3, 2, 1, 7],
[7, 1, 6, 1, 2, 4, 4, 3, 9],
[2, 4, 9, 8, 5, 7, 1, 3, 6],
[8, 3, 5, 3, 9, 6, 6, 9, 2],
],
},
hard: {
puzzle: [
[0, 0, 0, 6, 0, 0, 4, 0, 0],
[7, 0, 0, 0, 0, 3, 6, 0, 0],
[0, 0, 0, 0, 9, 1, 0, 8, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 5, 0, 1, 8, 0, 0, 0, 3],
[0, 0, 0, 3, 0, 6, 0, 4, 5],
[0, 4, 0, 2, 0, 0, 0, 6, 0],
[9, 0, 3, 0, 0, 0, 0, 0, 0],
[0, 2, 0, 0, 0, 0, 1, 0, 0],
],
solution: [
[2, 6, 1, 6, 3, 5, 4, 9, 7],
[7, 9, 4, 8, 2, 3, 6, 5, 1],
[3, 5, 8, 7, 9, 1, 2, 8, 4],
[1, 8, 6, 5, 7, 9, 3, 2, 8],
[4, 5, 2, 1, 8, 7, 9, 6, 3],
[8, 7, 9, 3, 4, 6, 5, 4, 5],
[5, 4, 7, 2, 1, 8, 8, 6, 9],
[9, 1, 3, 4, 6, 2, 7, 8, 5],
[6, 2, 5, 9, 5, 3, 1, 3, 8],
],
},
};
// Validate that a puzzle is properly formed (no initial conflicts)
const validatePuzzle = (puzzle) => {
console.log("Validating puzzle...");
for (let row = 0; row < 9; row++) {
for (let col = 0; col < 9; col++) {
const num = puzzle[row][col];
if (num !== 0) {
// Temporarily remove this number to test if it would be valid
const tempPuzzle = puzzle.map((r) => [...r]);
tempPuzzle[row][col] = 0;
if (!isValidMoveForPuzzle(tempPuzzle, row, col, num)) {
console.warn(
`Invalid puzzle: ${num} at row ${row}, col ${col} conflicts with existing numbers`,
);
return false;
}
}
}
}
console.log("Puzzle validation passed");
return true;
};
// Validation function for any puzzle (not just current board)
const isValidMoveForPuzzle = (puzzle, row, col, num) => {
// Check row
for (let i = 0; i < 9; i++) {
if (i !== col && puzzle[row][i] === num) return false;
}
// Check column
for (let i = 0; i < 9; i++) {
if (i !== row && puzzle[i][col] === num) return false;
}
// Check 3x3 box
const boxRow = Math.floor(row / 3) * 3;
const boxCol = Math.floor(col / 3) * 3;
for (let i = boxRow; i < boxRow + 3; i++) {
for (let j = boxCol; j < boxCol + 3; j++) {
if ((i !== row || j !== col) && puzzle[i][j] === num) return false;
}
}
return true;
};
// Smart puzzle selection with validation
const getPuzzleForDifficulty = (difficulty) => {
console.log("Getting puzzle for difficulty:", difficulty);
// Try multiple times to get a valid generated puzzle
for (let attempt = 1; attempt <= 3; attempt++) {
try {
if (getRandomPuzzle) {
console.log(`Attempt ${attempt}: Generating random puzzle...`);
const generatedPuzzle = getRandomPuzzle(difficulty);
if (
generatedPuzzle &&
generatedPuzzle.puzzle &&
generatedPuzzle.solution
) {
// Validate the puzzle before using it
if (validatePuzzle(generatedPuzzle.puzzle)) {
console.log("Using validated generated puzzle");
return {
...generatedPuzzle,
isGenerated: true,
};
} else {
console.warn(
`Attempt ${attempt}: Generated puzzle failed validation, trying again...`,
);
}
}
}
} catch (error) {
console.warn(`Attempt ${attempt}: Generator failed:`, error);
}
}
console.log(
"All generation attempts failed or unavailable, using static puzzle for",
difficulty,
);
// Fallback to static puzzle
return {
...(staticPuzzles[difficulty] || staticPuzzles.easy),
isGenerated: false,
};
};
// Current puzzle state
const [currentPuzzleData, setCurrentPuzzleData] = useState(() =>
getPuzzleForDifficulty(difficulty),
);
const [puzzleId, setPuzzleId] = useState(0); // Track puzzle changes
// State management
const [board, setBoard] = useState(() =>
currentPuzzleData.puzzle.map((row) => [...row]),
);
const [selectedCell, setSelectedCell] = useState(null);
const [errors, setErrors] = useState(new Set());
const [isComplete, setIsComplete] = useState(false);
const [startTime, setStartTime] = useState(Date.now());
const [elapsedTime, setElapsedTime] = useState(0);
const [isTimerRunning, setIsTimerRunning] = useState(true);
const [hintsUsed, setHintsUsed] = useState(new Set());
const [hintMessage, setHintMessage] = useState("");
const [generationStatus, setGenerationStatus] = useState(
getRandomPuzzle ? "Generated" : "Static",
);
// Get hint limits and remaining hints
const maxHints = HINT_LIMITS[difficulty];
const hintsRemaining = getRemainingHints(difficulty, hintsUsed);
// Reset when difficulty changes
useEffect(() => {
const newPuzzle = getPuzzleForDifficulty(difficulty);
setCurrentPuzzleData(newPuzzle);
setBoard(newPuzzle.puzzle.map((row) => [...row]));
setSelectedCell(null);
setErrors(new Set());
setIsComplete(false);
setStartTime(Date.now());
setElapsedTime(0);
setIsTimerRunning(true);
setHintsUsed(new Set());
setHintMessage("");
setGenerationStatus(getRandomPuzzle ? "Generated" : "Static");
setPuzzleId((prev) => prev + 1);
}, [difficulty]);
// New Game function - generates fresh puzzle
const newGame = () => {
console.log("New Game clicked!");
const newPuzzle = getPuzzleForDifficulty(difficulty);
console.log("New puzzle data:", newPuzzle);
setCurrentPuzzleData(newPuzzle);
setBoard(newPuzzle.puzzle.map((row) => [...row]));
setSelectedCell(null);
setErrors(new Set());
setIsComplete(false);
setStartTime(Date.now());
setElapsedTime(0);
setIsTimerRunning(true);
setHintsUsed(new Set());
setHintMessage("");
setGenerationStatus(newPuzzle.isGenerated ? "Generated" : "Static");
setPuzzleId((prev) => prev + 1);
console.log("New game state set. Puzzle ID:", puzzleId + 1);
};
// Validation function - check if a number is valid at given position
const isValidMove = (row, col, num, currentBoard = board) => {
// Check row
for (let i = 0; i < 9; i++) {
if (i !== col && currentBoard[row][i] === num) return false;
}
// Check column
for (let i = 0; i < 9; i++) {
if (i !== row && currentBoard[i][col] === num) return false;
}
// Check 3x3 box
const boxRow = Math.floor(row / 3) * 3;
const boxCol = Math.floor(col / 3) * 3;
for (let i = boxRow; i < boxRow + 3; i++) {
for (let j = boxCol; j < boxCol + 3; j++) {
if ((i !== row || j !== col) && currentBoard[i][j] === num)
return false;
}
}
return true;
};
// Timer effect - updates every second
useEffect(() => {
let interval = null;
if (isTimerRunning && !isComplete) {
interval = setInterval(() => {
setElapsedTime(Date.now() - startTime);
}, 1000);
} else {
clearInterval(interval);
}
return () => clearInterval(interval);
}, [isTimerRunning, isComplete, startTime]);
// Clear hint message after 3 seconds
useEffect(() => {
if (hintMessage) {
const timeout = setTimeout(() => {
setHintMessage("");
}, 3000);
return () => clearTimeout(timeout);
}
}, [hintMessage]);
// Check for errors whenever board changes
useEffect(() => {
const newErrors = new Set();
for (let row = 0; row < 9; row++) {
for (let col = 0; col < 9; col++) {
const num = board[row][col];
if (num !== 0 && !isValidMove(row, col, num)) {
newErrors.add(`${row}-${col}`);
}
}
}
setErrors(newErrors);
// Check if puzzle is complete (all cells filled and no errors)
const isFull = board.every((row) => row.every((cell) => cell !== 0));
const isValid = newErrors.size === 0;
const wasComplete = isComplete;
const nowComplete = isFull && isValid;
setIsComplete(nowComplete);
// Stop timer when puzzle is completed
if (nowComplete && !wasComplete) {
setIsTimerRunning(false);
}
}, [board]);
// Format time for display (MM:SS)
const formatTime = (milliseconds) => {
const totalSeconds = Math.floor(milliseconds / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
};
// Handle hint requests
const giveHint = () => {
const hintResult = provideHint({
board,
difficulty,
hintsUsed,
maxHints,
solution: currentPuzzleData.solution,
});
if (!hintResult.success) {
setHintMessage(hintResult.message);
return;
}
// Apply the hint to the board
const newBoard = board.map((row) => [...row]);
newBoard[hintResult.hintRow][hintResult.hintCol] = hintResult.correctValue;
setBoard(newBoard);
// Mark this cell as having received a hint
const newHintsUsed = new Set(hintsUsed);
newHintsUsed.add(`${hintResult.hintRow}-${hintResult.hintCol}`);
setHintsUsed(newHintsUsed);
// Show hint message
setHintMessage(hintResult.message);
};
const handleCellClick = (row, col) => {
if (currentPuzzleData.puzzle[row][col] === 0) {
setSelectedCell(`${row}-${col}`);
}
};
const handleNumberInput = (num) => {
if (!selectedCell) return;
const [row, col] = selectedCell.split("-").map(Number);
if (currentPuzzleData.puzzle[row][col] !== 0) return;
const newBoard = board.map((row) => [...row]);
newBoard[row][col] = num;
setBoard(newBoard);
};
const clearCell = () => {
if (!selectedCell) return;
const [row, col] = selectedCell.split("-").map(Number);
if (currentPuzzleData.puzzle[row][col] !== 0) return;
const newBoard = board.map((row) => [...row]);
newBoard[row][col] = 0;
setBoard(newBoard);
};
const resetGame = () => {
setBoard(currentPuzzleData.puzzle.map((row) => [...row]));
setSelectedCell(null);
setErrors(new Set());
setIsComplete(false);
setStartTime(Date.now());
setElapsedTime(0);
setIsTimerRunning(true);
setHintsUsed(new Set());
setHintMessage("");
};
const goHome = () => {
navigate("/");
};
// Inline styles for guaranteed rendering
const tableStyle = {
borderCollapse: "collapse",
border: "4px solid black",
backgroundColor: "white",
margin: "0 auto",
};
const getCellStyle = (row, col) => {
const isSelected = selectedCell === `${row}-${col}`;
const isPreFilled = currentPuzzleData.puzzle[row][col] !== 0;
const hasError = errors.has(`${row}-${col}`);
let backgroundColor = "white";
let textColor = "#000";
if (isSelected) {
backgroundColor = "#3b82f6";
textColor = "white";
} else if (hasError) {
backgroundColor = "#fee2e2";
textColor = "#dc2626";
} else if (isPreFilled) {
backgroundColor = "#f3f4f6";
textColor = "#333";
}
return {
width: "60px",
height: "60px",
border: "1px solid #666",
borderRight:
col === 2 || col === 5 ? "4px solid black" : "1px solid #666",
borderBottom:
row === 2 || row === 5 ? "4px solid black" : "1px solid #666",
textAlign: "center",
verticalAlign: "middle",
fontSize: "24px",
fontWeight: "bold",
cursor: isPreFilled ? "default" : "pointer",
backgroundColor: backgroundColor,
color: textColor,
userSelect: "none",
transition: "all 0.2s ease",
};
};
return (
<div
style={{
minHeight: "100vh",
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "20px",
}}
>
<div style={{ maxWidth: "800px", width: "100%" }}>
{/* Header */}
<div style={{ textAlign: "center", marginBottom: "30px" }}>
<button
onClick={goHome}
style={{
padding: "10px 20px",
backgroundColor: "rgba(255,255,255,0.2)",
color: "white",
border: "1px solid rgba(255,255,255,0.3)",
borderRadius: "8px",
cursor: "pointer",
marginBottom: "20px",
fontSize: "16px",
}}
>
Back to Home
</button>
<h1
style={{
color: "white",
fontSize: "48px",
margin: "0 0 10px 0",
textTransform: "capitalize",
}}
>
Sudoku - {difficulty}
</h1>
<p
style={{
color: "rgba(255,255,255,0.8)",
fontSize: "18px",
margin: "0 0 10px 0",
}}
>
Fill each row, column, and 3×3 box with digits 1-9
</p>
{/* Timer Display */}
<div
style={{
display: "inline-block",
padding: "12px 20px",
backgroundColor: "rgba(255,255,255,0.15)",
border: "1px solid rgba(255,255,255,0.3)",
borderRadius: "12px",
color: "white",
fontSize: "24px",
fontWeight: "bold",
fontFamily: "monospace",
marginBottom: "15px",
minWidth: "100px",
}}
>
{formatTime(elapsedTime)}
</div>
<br />
<div
style={{
display: "inline-block",
padding: "8px 16px",
backgroundColor:
difficulty === "easy"
? "rgba(34, 197, 94, 0.2)"
: difficulty === "medium"
? "rgba(251, 146, 60, 0.2)"
: "rgba(239, 68, 68, 0.2)",
border: `1px solid ${
difficulty === "easy"
? "rgba(34, 197, 94, 0.4)"
: difficulty === "medium"
? "rgba(251, 146, 60, 0.4)"
: "rgba(239, 68, 68, 0.4)"
}`,
borderRadius: "20px",
color: "white",
fontSize: "14px",
fontWeight: "bold",
marginRight: "10px",
}}
>
{difficulty === "easy"
? "🌱 Beginner Friendly"
: difficulty === "medium"
? "⚡ Moderate Challenge"
: "🔥 Expert Level"}
</div>
<div
style={{
display: "inline-block",
padding: "8px 16px",
backgroundColor:
generationStatus === "Generated"
? "rgba(139, 92, 246, 0.2)"
: "rgba(107, 114, 128, 0.2)",
border: `1px solid ${generationStatus === "Generated" ? "rgba(139, 92, 246, 0.4)" : "rgba(107, 114, 128, 0.4)"}`,
borderRadius: "20px",
color: "white",
fontSize: "12px",
fontWeight: "bold",
}}
>
{generationStatus === "Generated"
? "🎲 Random Puzzle"
: "📋 Classic Puzzle"}{" "}
#{puzzleId}
</div>
</div>
{/* Game Board */}
<div
style={{
backgroundColor: "white",
borderRadius: "20px",
padding: "40px",
boxShadow: "0 20px 40px rgba(0,0,0,0.3)",
marginBottom: "30px",
}}
>
<table style={tableStyle}>
<tbody>
{board.map((row, rowIndex) => (
<tr key={rowIndex}>
{row.map((cell, colIndex) => (
<td
key={`${rowIndex}-${colIndex}`}
style={getCellStyle(rowIndex, colIndex)}
onClick={() => handleCellClick(rowIndex, colIndex)}
>
{cell !== 0 ? cell : ""}
</td>
))}
</tr>
))}
</tbody>
</table>
{/* Number Input */}
<div style={{ textAlign: "center", marginTop: "30px" }}>
<div style={{ marginBottom: "20px" }}>
{[1, 2, 3, 4, 5, 6, 7, 8, 9].map((num) => (
<button
key={num}
onClick={() => handleNumberInput(num)}
disabled={!selectedCell}
style={{
width: "50px",
height: "50px",
margin: "5px",
fontSize: "20px",
fontWeight: "bold",
border: "2px solid #ddd",
borderRadius: "8px",
backgroundColor: selectedCell ? "#3b82f6" : "#f5f5f5",
color: selectedCell ? "white" : "#333",
cursor: selectedCell ? "pointer" : "not-allowed",
opacity: selectedCell ? 1 : 0.5,
}}
>
{num}
</button>
))}
</div>
<button
onClick={clearCell}
disabled={!selectedCell}
style={{
padding: "10px 20px",
fontSize: "16px",
fontWeight: "bold",
border: "2px solid #dc2626",
borderRadius: "8px",
backgroundColor: selectedCell ? "#dc2626" : "#f5f5f5",
color: selectedCell ? "white" : "#666",
cursor: selectedCell ? "pointer" : "not-allowed",
opacity: selectedCell ? 1 : 0.5,
marginRight: "10px",
}}
>
Clear
</button>
<button
onClick={giveHint}
disabled={!areHintsAvailable(difficulty, hintsUsed) || isComplete}
style={{
padding: "10px 20px",
fontSize: "16px",
fontWeight: "bold",
border: "2px solid #f59e0b",
borderRadius: "8px",
backgroundColor:
!areHintsAvailable(difficulty, hintsUsed) || isComplete
? "#f5f5f5"
: "#f59e0b",
color:
!areHintsAvailable(difficulty, hintsUsed) || isComplete
? "#666"
: "white",
cursor:
!areHintsAvailable(difficulty, hintsUsed) || isComplete
? "not-allowed"
: "pointer",
opacity:
!areHintsAvailable(difficulty, hintsUsed) || isComplete
? 0.5
: 1,
marginRight: "10px",
}}
>
💡 Hint ({hintsRemaining} left)
</button>
<button
onClick={resetGame}
style={{
padding: "10px 20px",
fontSize: "16px",
fontWeight: "bold",
border: "2px solid #6b7280",
borderRadius: "8px",
backgroundColor: "#6b7280",
color: "white",
cursor: "pointer",
marginRight: "10px",
}}
>
Reset
</button>
<button
onClick={newGame}
style={{
padding: "10px 20px",
fontSize: "16px",
fontWeight: "bold",
border: "2px solid #8b5cf6",
borderRadius: "8px",
backgroundColor: "#8b5cf6",
color: "white",
cursor: "pointer",
}}
>
🎲 New Game
</button>
</div>
{/* Game Status */}
<div style={{ textAlign: "center", marginTop: "20px" }}>
{hintMessage && (
<div
style={{
backgroundColor: "#fef3c7",
color: "#92400e",
padding: "10px 20px",
borderRadius: "8px",
marginBottom: "10px",
border: "1px solid #fbbf24",
fontWeight: "bold",
}}
>
💡 {hintMessage}
</div>
)}
{errors.size > 0 && (
<div
style={{
backgroundColor: "#fee2e2",
color: "#dc2626",
padding: "10px 20px",
borderRadius: "8px",
marginBottom: "10px",
border: "1px solid #fca5a5",
fontWeight: "bold",
}}
>
{errors.size} error{errors.size !== 1 ? "s" : ""} found
</div>
)}
{isComplete && (
<div
style={{
backgroundColor: "#d1fae5",
color: "#065f46",
padding: "15px 20px",
borderRadius: "8px",
marginBottom: "10px",
border: "1px solid #6ee7b7",
fontWeight: "bold",
fontSize: "18px",
}}
>
🎉 Congratulations! You solved the puzzle! 🎉
<br />
<span style={{ fontSize: "16px" }}>
Time: {formatTime(elapsedTime)} | Hints used: {hintsUsed.size}
/{maxHints}
</span>
</div>
)}
{selectedCell && !isComplete && (
<p style={{ color: "#666", margin: "10px 0" }}>
Selected: Row {parseInt(selectedCell.split("-")[0]) + 1}, Column{" "}
{parseInt(selectedCell.split("-")[1]) + 1}
</p>
)}
</div>
</div>
</div>
</div>
);
};
export default SimpleSudokuGrid;

View file

@ -0,0 +1,119 @@
import React, { useState } from "react";
import { useNavigate } from "react-router-dom";
const SudokuWelcome = () => {
const [selectedDifficulty, setSelectedDifficulty] = useState(null);
const navigate = useNavigate();
const difficulties = [
{
id: "easy",
name: "Easy",
description: "Perfect for beginners",
icon: "🌱",
},
{
id: "medium",
name: "Medium",
description: "A balanced challenge",
icon: "⚡",
},
{
id: "hard",
name: "Hard",
description: "For puzzle masters",
icon: "🔥",
},
];
const handleStartGame = () => {
if (selectedDifficulty) {
navigate(`/game/${selectedDifficulty}`);
}
};
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900 flex items-center justify-center p-4">
<div className="max-w-md w-full">
{/* Header */}
<div className="text-center mb-12">
<div className="inline-block p-4 bg-white/10 rounded-full mb-6 backdrop-blur-sm">
<div className="text-6xl">🧩</div>
</div>
<h1 className="text-5xl font-bold text-white mb-3 tracking-tight">
Sudoku
</h1>
<p className="text-slate-300 text-lg">
Challenge your mind with numbers
</p>
</div>
{/* Difficulty Selection */}
<div className="space-y-4 mb-8">
<h2 className="text-xl font-semibold text-white text-center mb-6">
Choose Your Difficulty
</h2>
{difficulties.map((difficulty) => (
<button
key={difficulty.id}
onClick={() => setSelectedDifficulty(difficulty.id)}
className={`
w-full p-6 rounded-2xl transition-all duration-300 transform
${
selectedDifficulty === difficulty.id
? "bg-gradient-to-r from-blue-500 to-purple-600 scale-105 shadow-2xl"
: "bg-white/10 hover:bg-white/20 hover:scale-102"
}
backdrop-blur-sm border border-white/20 active:scale-95
`}
>
<div className="flex items-center justify-between">
<div className="text-left">
<div className="flex items-center gap-3 mb-2">
<span className="text-2xl">{difficulty.icon}</span>
<h3 className="text-xl font-bold text-white">
{difficulty.name}
</h3>
</div>
<p className="text-slate-200 text-sm">
{difficulty.description}
</p>
</div>
{selectedDifficulty === difficulty.id && (
<div className="text-white text-2xl"></div>
)}
</div>
</button>
))}
</div>
{/* Start Game Button */}
<button
onClick={handleStartGame}
disabled={!selectedDifficulty}
className={`
w-full py-4 px-8 rounded-2xl font-bold text-lg transition-all duration-300 transform
${
selectedDifficulty
? "bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600 text-white shadow-2xl hover:scale-105 active:scale-95"
: "bg-gray-600 text-gray-400 cursor-not-allowed"
}
`}
>
{selectedDifficulty ? "Start Game" : "Select Difficulty"}
</button>
{/* Footer */}
<div className="text-center mt-8">
<p className="text-slate-400 text-sm">
Fill each row, column, and 3×3 box with digits 1-9
</p>
</div>
</div>
</div>
);
};
export default SudokuWelcome;

View file

@ -0,0 +1,65 @@
@import "tailwindcss";
body {
margin: 0;
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
"Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#root {
min-height: 100vh;
}
/* Explicit table styles for Sudoku grid */
.sudoku-table {
border-collapse: collapse;
border: 2px solid #1f2937;
}
.sudoku-table td {
width: 48px;
height: 48px;
border: 1px solid #9ca3af;
text-align: center;
vertical-align: middle;
font-size: 18px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.sudoku-table td:hover {
background-color: #f9fafb;
}
/* 3x3 box borders */
.sudoku-table td:nth-child(3n) {
border-right: 2px solid #1f2937;
}
.sudoku-table tr:nth-child(3n) td {
border-bottom: 2px solid #1f2937;
}
/* Cell states */
.sudoku-cell-prefilled {
background-color: #f3f4f6;
color: #374151;
}
.sudoku-cell-selected {
background-color: #dbeafe;
}
.sudoku-cell-error {
background-color: #fef2f2;
color: #dc2626;
}
.sudoku-cell-empty {
background-color: white;
}

View file

@ -0,0 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.jsx";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

View file

@ -0,0 +1,295 @@
// Sudoku Puzzle Generator
// /src/utils/sudokuGenerator.js
// Base complete Sudoku grids that we'll transform
const basePuzzles = [
[
[5,3,4,6,7,8,9,1,2],
[6,7,2,1,9,5,3,4,8],
[1,9,8,3,4,2,5,6,7],
[8,5,9,7,6,1,4,2,3],
[4,2,6,8,5,3,7,9,1],
[7,1,3,9,2,4,8,5,6],
[9,6,1,5,3,7,2,8,4],
[2,8,7,4,1,9,6,3,5],
[3,4,5,2,8,6,1,7,9]
],
[
[1,2,3,4,5,6,7,8,9],
[4,5,6,7,8,9,1,2,3],
[7,8,9,1,2,3,4,5,6],
[2,3,4,5,6,7,8,9,1],
[5,6,7,8,9,1,2,3,4],
[8,9,1,2,3,4,5,6,7],
[3,4,5,6,7,8,9,1,2],
[6,7,8,9,1,2,3,4,5],
[9,1,2,3,4,5,6,7,8]
],
[
[9,8,7,6,5,4,3,2,1],
[2,4,6,1,7,3,9,8,5],
[3,5,1,8,2,9,7,6,4],
[1,9,8,5,6,7,4,3,2],
[6,3,2,4,8,1,5,7,9],
[7,5,4,9,3,2,8,1,6],
[4,2,3,7,1,8,6,5,9],
[5,1,9,2,4,6,1,9,8],
[8,6,5,3,9,5,2,4,7]
],
[
[4,3,5,2,6,9,7,8,1],
[6,8,2,5,7,1,4,9,3],
[1,9,7,8,3,4,5,6,2],
[8,2,6,1,9,5,3,7,4],
[3,7,4,6,8,2,9,1,5],
[5,1,9,7,4,3,6,2,8],
[7,5,3,4,2,8,1,3,9],
[9,6,1,3,5,7,2,4,6],
[2,4,8,9,1,6,8,5,7]
]
];
// Utility functions for puzzle transformations
const shuffleArray = (array) => {
const newArray = [...array];
for (let i = newArray.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[newArray[i], newArray[j]] = [newArray[j], newArray[i]];
}
return newArray;
};
const swapRows = (grid, row1, row2) => {
const newGrid = grid.map(row => [...row]);
[newGrid[row1], newGrid[row2]] = [newGrid[row2], newGrid[row1]];
return newGrid;
};
const swapCols = (grid, col1, col2) => {
const newGrid = grid.map(row => [...row]);
for (let i = 0; i < 9; i++) {
[newGrid[i][col1], newGrid[i][col2]] = [newGrid[i][col2], newGrid[i][col1]];
}
return newGrid;
};
const swapNumbers = (grid, num1, num2) => {
return grid.map(row =>
row.map(cell => {
if (cell === num1) return num2;
if (cell === num2) return num1;
return cell;
})
);
};
const transpose = (grid) => {
return grid[0].map((_, colIndex) => grid.map(row => row[colIndex]));
};
const rotateGrid = (grid) => {
return transpose(grid.map(row => [...row].reverse()));
};
// Generate a complete valid Sudoku grid through transformations
const generateCompleteGrid = (baseIndex = 0) => {
let grid = basePuzzles[baseIndex % basePuzzles.length].map(row => [...row]);
// Apply random transformations
const transformations = Math.floor(Math.random() * 8) + 3; // 3-10 transformations
for (let i = 0; i < transformations; i++) {
const transformation = Math.floor(Math.random() * 6);
switch (transformation) {
case 0: // Swap two rows within same 3x3 block
const block1 = Math.floor(Math.random() * 3);
const row1 = block1 * 3 + Math.floor(Math.random() * 3);
const row2 = block1 * 3 + Math.floor(Math.random() * 3);
if (row1 !== row2) grid = swapRows(grid, row1, row2);
break;
case 1: // Swap two columns within same 3x3 block
const block2 = Math.floor(Math.random() * 3);
const col1 = block2 * 3 + Math.floor(Math.random() * 3);
const col2 = block2 * 3 + Math.floor(Math.random() * 3);
if (col1 !== col2) grid = swapCols(grid, col1, col2);
break;
case 2: // Swap two numbers
const nums = shuffleArray([1,2,3,4,5,6,7,8,9]);
grid = swapNumbers(grid, nums[0], nums[1]);
break;
case 3: // Transpose
grid = transpose(grid);
break;
case 4: // Rotate
grid = rotateGrid(grid);
break;
case 5: // Swap row blocks
const blockA = Math.floor(Math.random() * 3);
let blockB = Math.floor(Math.random() * 3);
if (blockA !== blockB) {
for (let j = 0; j < 3; j++) {
grid = swapRows(grid, blockA * 3 + j, blockB * 3 + j);
}
}
break;
}
}
return grid;
};
// Remove numbers from complete grid to create puzzle
const createPuzzle = (completeGrid, difficulty) => {
const puzzle = completeGrid.map(row => [...row]);
// Difficulty settings (number of cells to remove)
const difficultySettings = {
easy: { min: 36, max: 45 }, // Remove 36-45 numbers (64-53 remaining)
medium: { min: 46, max: 52 }, // Remove 46-52 numbers (52-46 remaining)
hard: { min: 53, max: 60 } // Remove 53-60 numbers (45-36 remaining)
};
const { min, max } = difficultySettings[difficulty];
const cellsToRemove = Math.floor(Math.random() * (max - min + 1)) + min;
// Create list of all positions
const positions = [];
for (let row = 0; row < 9; row++) {
for (let col = 0; col < 9; col++) {
positions.push([row, col]);
}
}
// Shuffle positions and remove cells
const shuffledPositions = shuffleArray(positions);
for (let i = 0; i < cellsToRemove; i++) {
const [row, col] = shuffledPositions[i];
puzzle[row][col] = 0;
}
return puzzle;
};
// Validate that puzzle has unique solution (simplified check)
const isValidPuzzle = (puzzle) => {
// Count filled cells per row, column, and box
const rowCounts = new Array(9).fill(0);
const colCounts = new Array(9).fill(0);
const boxCounts = new Array(9).fill(0);
for (let row = 0; row < 9; row++) {
for (let col = 0; col < 9; col++) {
if (puzzle[row][col] !== 0) {
rowCounts[row]++;
colCounts[col]++;
const boxIndex = Math.floor(row / 3) * 3 + Math.floor(col / 3);
boxCounts[boxIndex]++;
}
}
}
// Ensure each row, column, and box has at least 2 filled cells
return rowCounts.every(count => count >= 2) &&
colCounts.every(count => count >= 2) &&
boxCounts.every(count => count >= 2);
};
// Generate a single puzzle
export const generatePuzzle = (difficulty = 'easy') => {
let attempts = 0;
let puzzle, solution;
do {
const baseIndex = Math.floor(Math.random() * basePuzzles.length);
solution = generateCompleteGrid(baseIndex);
puzzle = createPuzzle(solution, difficulty);
attempts++;
} while (!isValidPuzzle(puzzle) && attempts < 10);
return {
puzzle,
solution,
difficulty,
id: Date.now() + Math.random()
};
};
// Pre-generate puzzle sets for better performance
const generatePuzzleSet = (difficulty, count = 100) => {
const puzzles = [];
for (let i = 0; i < count; i++) {
puzzles.push(generatePuzzle(difficulty));
}
return puzzles;
};
// Pre-generated puzzle collections
let easyPuzzles = null;
let mediumPuzzles = null;
let hardPuzzles = null;
// Initialize puzzle collections
const initializePuzzles = () => {
if (!easyPuzzles) easyPuzzles = generatePuzzleSet('easy', 120);
if (!mediumPuzzles) mediumPuzzles = generatePuzzleSet('medium', 120);
if (!hardPuzzles) hardPuzzles = generatePuzzleSet('hard', 120);
};
// Get a random puzzle from pre-generated set
export const getRandomPuzzle = (difficulty = 'easy') => {
initializePuzzles();
let puzzleSet;
switch (difficulty) {
case 'medium':
puzzleSet = mediumPuzzles;
break;
case 'hard':
puzzleSet = hardPuzzles;
break;
default:
puzzleSet = easyPuzzles;
}
if (!puzzleSet || puzzleSet.length === 0) {
// Fallback to generating new puzzle
return generatePuzzle(difficulty);
}
const randomIndex = Math.floor(Math.random() * puzzleSet.length);
return puzzleSet[randomIndex];
};
// Get multiple puzzles at once
export const getPuzzleBatch = (difficulty = 'easy', count = 10) => {
const puzzles = [];
for (let i = 0; i < count; i++) {
puzzles.push(getRandomPuzzle(difficulty));
}
return puzzles;
};
// Statistics function
export const getPuzzleStats = () => {
initializePuzzles();
return {
easy: easyPuzzles ? easyPuzzles.length : 0,
medium: mediumPuzzles ? mediumPuzzles.length : 0,
hard: hardPuzzles ? hardPuzzles.length : 0,
total: (easyPuzzles?.length || 0) + (mediumPuzzles?.length || 0) + (hardPuzzles?.length || 0)
};
};
// Export default function for convenience
export default {
generatePuzzle,
getRandomPuzzle,
getPuzzleBatch,
getPuzzleStats
};

View file

@ -0,0 +1,201 @@
// Hints utility functions
// /src/utils/hints.js
// Hint limits based on difficulty
export const HINT_LIMITS = {
easy: 3,
medium: 2,
hard: 1,
};
// Pre-defined solutions for each difficulty puzzle
const SOLUTIONS = {
easy: [
[5, 3, 4, 6, 7, 8, 9, 1, 2],
[6, 7, 2, 1, 9, 5, 3, 4, 8],
[1, 9, 8, 3, 4, 2, 5, 6, 7],
[8, 5, 9, 7, 6, 1, 4, 2, 3],
[4, 2, 6, 8, 5, 3, 7, 9, 1],
[7, 1, 3, 9, 2, 4, 8, 5, 6],
[9, 6, 1, 5, 3, 7, 2, 8, 4],
[2, 8, 7, 4, 1, 9, 6, 3, 5],
[3, 4, 5, 2, 8, 6, 1, 7, 9],
],
medium: [
[1, 2, 3, 6, 7, 8, 9, 4, 5],
[5, 8, 4, 2, 3, 9, 7, 6, 1],
[6, 7, 9, 5, 4, 1, 8, 2, 3],
[3, 7, 1, 4, 9, 2, 5, 8, 6],
[2, 9, 6, 7, 1, 5, 3, 9, 4],
[4, 5, 8, 9, 6, 3, 2, 1, 7],
[7, 1, 6, 1, 2, 4, 4, 3, 9],
[2, 4, 9, 8, 5, 7, 1, 3, 6],
[8, 3, 5, 3, 9, 6, 6, 9, 2],
],
hard: [
[2, 6, 1, 6, 3, 5, 4, 9, 7],
[7, 9, 4, 8, 2, 3, 6, 5, 1],
[3, 5, 8, 7, 9, 1, 2, 8, 4],
[1, 8, 6, 5, 7, 9, 3, 2, 8],
[4, 5, 2, 1, 8, 7, 9, 6, 3],
[8, 7, 9, 3, 4, 6, 5, 4, 5],
[5, 4, 7, 2, 1, 8, 8, 6, 9],
[9, 1, 3, 4, 6, 2, 7, 8, 5],
[6, 2, 5, 9, 5, 3, 1, 3, 8],
],
};
/**
* Get the solution for a specific difficulty
* @param {string} difficulty - 'easy', 'medium', or 'hard'
* @returns {number[][]} - 9x9 solution grid
*/
export const getSolution = (difficulty) => {
return SOLUTIONS[difficulty] || SOLUTIONS.easy;
};
/**
* Get all empty cells from the current board
* @param {number[][]} board - Current 9x9 game board
* @returns {number[][]} - Array of [row, col] coordinates for empty cells
*/
export const getEmptyCells = (board) => {
const emptyCells = [];
for (let row = 0; row < 9; row++) {
for (let col = 0; col < 9; col++) {
if (board[row][col] === 0) {
emptyCells.push([row, col]);
}
}
}
return emptyCells;
};
/**
* Filter out cells that have already received hints
* @param {number[][]} emptyCells - Array of empty cell coordinates
* @param {Set} hintsUsed - Set of hint coordinates in "row-col" format
* @returns {number[][]} - Array of available cell coordinates
*/
export const getAvailableHintCells = (emptyCells, hintsUsed) => {
return emptyCells.filter(([row, col]) => !hintsUsed.has(`${row}-${col}`));
};
/**
* Select a random cell from available options
* @param {number[][]} availableCells - Array of available cell coordinates
* @returns {number[]|null} - [row, col] coordinate or null if none available
*/
export const selectRandomHintCell = (availableCells) => {
if (availableCells.length === 0) return null;
return availableCells[Math.floor(Math.random() * availableCells.length)];
};
/**
* Main hint function - provides a hint for the current board
* @param {Object} params - Hint parameters
* @param {number[][]} params.board - Current game board
* @param {string} params.difficulty - Current difficulty level
* @param {Set} params.hintsUsed - Set of already used hint coordinates
* @param {number} params.maxHints - Maximum hints allowed for this difficulty
* @param {number[][]|null} params.solution - Optional custom solution (overrides default)
* @returns {Object} - Hint result with success status, message, and hint data
*/
export const provideHint = ({
board,
difficulty,
hintsUsed,
maxHints,
solution = null,
}) => {
// Check if hints are available
if (hintsUsed.size >= maxHints) {
return {
success: false,
message: `No more hints available for ${difficulty} mode!`,
hintRow: null,
hintCol: null,
correctValue: null,
};
}
// Get empty cells
const emptyCells = getEmptyCells(board);
if (emptyCells.length === 0) {
return {
success: false,
message: "No empty cells to hint!",
hintRow: null,
hintCol: null,
correctValue: null,
};
}
// Filter available cells (not already hinted)
const availableCells = getAvailableHintCells(emptyCells, hintsUsed);
if (availableCells.length === 0) {
return {
success: false,
message: "All possible hints have been used!",
hintRow: null,
hintCol: null,
correctValue: null,
};
}
// Select random cell and get solution
const selectedCell = selectRandomHintCell(availableCells);
if (!selectedCell) {
return {
success: false,
message: "Could not select a hint cell!",
hintRow: null,
hintCol: null,
correctValue: null,
};
}
const [hintRow, hintCol] = selectedCell;
const puzzleSolution = solution || getSolution(difficulty);
const correctValue = puzzleSolution[hintRow][hintCol];
return {
success: true,
message: `Hint used! Filled cell at Row ${hintRow + 1}, Column ${hintCol + 1}`,
hintRow,
hintCol,
correctValue,
};
};
/**
* Calculate remaining hints
* @param {string} difficulty - Current difficulty level
* @param {Set} hintsUsed - Set of used hint coordinates
* @returns {number} - Number of remaining hints
*/
export const getRemainingHints = (difficulty, hintsUsed) => {
const maxHints = HINT_LIMITS[difficulty] || HINT_LIMITS.easy;
return Math.max(0, maxHints - hintsUsed.size);
};
/**
* Check if hints are available
* @param {string} difficulty - Current difficulty level
* @param {Set} hintsUsed - Set of used hint coordinates
* @returns {boolean} - True if hints are available
*/
export const areHintsAvailable = (difficulty, hintsUsed) => {
return getRemainingHints(difficulty, hintsUsed) > 0;
};
export default {
HINT_LIMITS,
getSolution,
getEmptyCells,
getAvailableHintCells,
selectRandomHintCell,
provideHint,
getRemainingHints,
areHintsAvailable,
};

View file

@ -0,0 +1,4 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
};

View file

@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})

View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/snake-icon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sashi Snake Game</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/index.jsx"></script>
</body>
</html>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,23 @@
{
"name": "sashisnake",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.22.3"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.21",
"postcss": "^8.5.3",
"tailwindcss": "^3.4.17",
"vite": "^5.2.2"
}
}

View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View file

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<!-- Background circle -->
<circle cx="50" cy="50" r="48" fill="#4ade80" stroke="#166534" stroke-width="2" />
<!-- Snake body -->
<path d="M25,40 Q35,30 45,40 Q55,50 65,40 Q75,30 85,40"
fill="none" stroke="#166534" stroke-width="10" stroke-linecap="round" />
<path d="M25,60 Q35,70 45,60 Q55,50 65,60 Q75,70 80,65"
fill="none" stroke="#166534" stroke-width="10" stroke-linecap="round" />
<!-- Snake head -->
<circle cx="25" cy="40" r="8" fill="#166534" />
<!-- Snake eye -->
<circle cx="23" cy="37" r="2" fill="white" />
<!-- Snake tongue -->
<path d="M17,40 L12,37 M17,40 L12,43"
fill="none" stroke="#ef4444" stroke-width="2" stroke-linecap="round" />
<!-- Food (apple) -->
<circle cx="80" cy="65" r="6" fill="#ef4444" />
<path d="M80,59 Q83,56 85,58"
fill="none" stroke="#166534" stroke-width="2" stroke-linecap="round" />
</svg>

After

Width:  |  Height:  |  Size: 1,015 B

View file

@ -0,0 +1,25 @@
import React from "react";
import { BrowserRouter as Router, Routes, Route } from "react-router-dom";
import WelcomeScreen from "./components/WelcomeScreen.jsx";
import GameBoard from "./components/GameBoard.jsx";
import GameOverScreen from "./components/GameOverScreen.jsx";
import HighScores from "./components/HighScores.jsx";
import Settings from "./components/Settings.jsx";
function App() {
return (
<Router>
<div className="app-container">
<Routes>
<Route path="/" element={<WelcomeScreen />} />
<Route path="/play" element={<GameBoard />} />
<Route path="/game-over" element={<GameOverScreen />} />
<Route path="/high-scores" element={<HighScores />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</div>
</Router>
);
}
export default App;

View file

@ -0,0 +1,480 @@
import React, { useState, useEffect, useRef } from "react";
import { useNavigate } from "react-router-dom";
import { getSettings } from "../utils/storage";
// Default values (will be overridden by settings)
const DEFAULT_GRID_SIZE = 20;
const DEFAULT_CELL_SIZE = 20;
const DEFAULT_INITIAL_SPEED = 150;
// Speed settings for different difficulty levels
const SPEED_SETTINGS = {
easy: 180,
medium: 150,
hard: 100,
};
// How much to increase speed for each food eaten
const SPEED_INCREMENT = 5;
const DIRECTIONS = {
UP: { x: 0, y: -1 },
DOWN: { x: 0, y: 1 },
LEFT: { x: -1, y: 0 },
RIGHT: { x: 1, y: 0 },
};
function GameBoard() {
const canvasRef = useRef(null);
const navigate = useNavigate();
// Game configuration (from settings)
const [gridSize, setGridSize] = useState(DEFAULT_GRID_SIZE);
const [cellSize, setCellSize] = useState(DEFAULT_CELL_SIZE);
const [initialSpeed, setInitialSpeed] = useState(DEFAULT_INITIAL_SPEED);
const [snakeColor, setSnakeColor] = useState("#32CD32");
const [foodColor, setFoodColor] = useState("#FF0000");
const [soundEnabled, setSoundEnabled] = useState(true);
// Game state
const [snake, setSnake] = useState([]);
const [food, setFood] = useState(null);
const [direction, setDirection] = useState(DIRECTIONS.RIGHT);
const [gameOver, setGameOver] = useState(false);
const [score, setScore] = useState(0);
const [speed, setSpeed] = useState(DEFAULT_INITIAL_SPEED);
const [isPaused, setIsPaused] = useState(false);
// Game loop references
const gameLoopRef = useRef(null);
const directionRef = useRef(direction);
const isPausedRef = useRef(isPaused);
const snakeRef = useRef(snake);
const foodRef = useRef(food);
// Load settings and initialize game
useEffect(() => {
// Load settings
const settings = getSettings();
// Apply settings
const newGridSize = parseInt(settings.gridSize) || DEFAULT_GRID_SIZE;
setGridSize(newGridSize);
setCellSize(DEFAULT_CELL_SIZE); // Keep cell size fixed for now
// Set difficulty-based speed
const difficultySpeed =
SPEED_SETTINGS[settings.difficulty] || DEFAULT_INITIAL_SPEED;
setInitialSpeed(difficultySpeed);
setSpeed(difficultySpeed);
// Set colors
setSnakeColor(settings.snakeColor || "#32CD32");
setFoodColor(settings.foodColor || "#FF0000");
// Set sound
setSoundEnabled(settings.soundEnabled !== false);
// Initialize snake in the center of the grid
const initialSnake = [
{ x: Math.floor(newGridSize / 2), y: Math.floor(newGridSize / 2) },
];
setSnake(initialSnake);
snakeRef.current = initialSnake;
// Event listeners and game loop
document.addEventListener("keydown", handleKeyDown);
// Spawn first food
spawnFood(initialSnake, newGridSize);
return () => {
document.removeEventListener("keydown", handleKeyDown);
clearInterval(gameLoopRef.current);
};
}, []);
// Start game loop after settings and initial state are set
useEffect(() => {
if (initialSpeed > 0 && snake.length > 0) {
startGameLoop();
}
}, [initialSpeed, snake]);
// Watch for game over
useEffect(() => {
if (gameOver) {
clearInterval(gameLoopRef.current);
// Play game over sound if enabled
if (soundEnabled) {
playGameOverSound();
}
setTimeout(() => {
navigate("/game-over", { state: { score } });
}, 1000);
}
}, [gameOver, navigate, score, soundEnabled]);
// Update canvas when game state changes
useEffect(() => {
renderGame();
}, [snake, food, gameOver, isPaused, snakeColor, foodColor]);
// Update direction ref when direction changes
useEffect(() => {
directionRef.current = direction;
}, [direction]);
// Update isPaused ref when isPaused changes
useEffect(() => {
isPausedRef.current = isPaused;
renderGame();
}, [isPaused]);
// Update snake ref when snake changes
useEffect(() => {
snakeRef.current = snake;
}, [snake]);
// Update food ref when food changes
useEffect(() => {
foodRef.current = food;
}, [food]);
function spawnFood(
currentSnake = snakeRef.current,
currentGridSize = gridSize,
) {
let newFood;
const isPositionOccupied = (x, y) =>
currentSnake.some((segment) => segment.x === x && segment.y === y);
do {
newFood = {
x: Math.floor(Math.random() * currentGridSize),
y: Math.floor(Math.random() * currentGridSize),
};
} while (isPositionOccupied(newFood.x, newFood.y));
setFood(newFood);
foodRef.current = newFood;
// Play food spawn sound if enabled
if (soundEnabled) {
playFoodSpawnSound();
}
}
function startGameLoop() {
if (gameLoopRef.current) clearInterval(gameLoopRef.current);
gameLoopRef.current = setInterval(() => {
if (!isPausedRef.current) {
moveSnake();
}
}, speed);
}
function moveSnake() {
if (isPausedRef.current) return;
setSnake((prevSnake) => {
// Calculate new head position based on current direction
const head = prevSnake[0];
const newHead = {
x: (head.x + directionRef.current.x + gridSize) % gridSize,
y: (head.y + directionRef.current.y + gridSize) % gridSize,
};
// Check for collisions with self
if (
prevSnake.some(
(segment) => segment.x === newHead.x && segment.y === newHead.y,
)
) {
setGameOver(true);
return prevSnake;
}
// Create new snake array with new head
const newSnake = [newHead, ...prevSnake];
// Check if food is eaten
const currentFood = foodRef.current;
if (
currentFood &&
newHead.x === currentFood.x &&
newHead.y === currentFood.y
) {
// Play eat sound if enabled
if (soundEnabled) {
playEatSound();
}
// Snake grows (don't remove tail)
setTimeout(() => {
setScore((prevScore) => prevScore + 10);
setSpeed((prevSpeed) => {
const newSpeed = Math.max(prevSpeed - SPEED_INCREMENT, 50);
// Restart game loop with new speed
setTimeout(() => startGameLoop(), 0);
return newSpeed;
});
spawnFood(newSnake);
}, 0);
// Return new snake with tail intact (snake grows)
return newSnake;
} else {
// Remove tail if no food eaten
newSnake.pop();
return newSnake;
}
});
}
function handleKeyDown(e) {
const key = e.key;
// Handle pause toggle with spacebar
if (key === " ") {
togglePause();
return;
}
// Don't process movement keys if the game is paused
if (isPausedRef.current) return;
// Prevent reversing direction directly
switch (key) {
case "ArrowUp":
if (directionRef.current !== DIRECTIONS.DOWN) {
setDirection(DIRECTIONS.UP);
}
break;
case "ArrowDown":
if (directionRef.current !== DIRECTIONS.UP) {
setDirection(DIRECTIONS.DOWN);
}
break;
case "ArrowLeft":
if (directionRef.current !== DIRECTIONS.RIGHT) {
setDirection(DIRECTIONS.LEFT);
}
break;
case "ArrowRight":
if (directionRef.current !== DIRECTIONS.LEFT) {
setDirection(DIRECTIONS.RIGHT);
}
break;
default:
break;
}
}
function togglePause() {
setIsPaused((prev) => !prev);
// Play pause sound if enabled
if (soundEnabled) {
playPauseSound();
}
}
// Simple sound functions - could be expanded with actual audio
function playEatSound() {
console.log("Sound: Food eaten!");
// Implement actual sound playing here when available
}
function playGameOverSound() {
console.log("Sound: Game over!");
// Implement actual sound playing here when available
}
function playFoodSpawnSound() {
console.log("Sound: New food spawned!");
// Implement actual sound playing here when available
}
function playPauseSound() {
console.log("Sound: Game paused/resumed!");
// Implement actual sound playing here when available
}
function renderGame() {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
const width = gridSize * cellSize;
const height = gridSize * cellSize;
// Clear canvas
ctx.clearRect(0, 0, width, height);
// Draw background
ctx.fillStyle = "#f0f0f0";
ctx.fillRect(0, 0, width, height);
// Draw grid lines
ctx.strokeStyle = "#e0e0e0";
ctx.lineWidth = 1;
// Vertical lines
for (let i = 1; i < gridSize; i++) {
ctx.beginPath();
ctx.moveTo(i * cellSize, 0);
ctx.lineTo(i * cellSize, height);
ctx.stroke();
}
// Horizontal lines
for (let i = 1; i < gridSize; i++) {
ctx.beginPath();
ctx.moveTo(0, i * cellSize);
ctx.lineTo(width, i * cellSize);
ctx.stroke();
}
// Draw food
if (food) {
ctx.fillStyle = foodColor;
ctx.beginPath();
const centerX = food.x * cellSize + cellSize / 2;
const centerY = food.y * cellSize + cellSize / 2;
const radius = cellSize / 2 - 2;
ctx.arc(centerX, centerY, radius, 0, Math.PI * 2);
ctx.fill();
}
// Draw snake
snake.forEach((segment, index) => {
// Head has different color
if (index === 0) {
// Darker version of snake color for head
ctx.fillStyle = darkenColor(snakeColor, 20);
} else {
ctx.fillStyle = snakeColor;
}
ctx.fillRect(
segment.x * cellSize + 1,
segment.y * cellSize + 1,
cellSize - 2,
cellSize - 2,
);
// Draw eyes on the head
if (index === 0) {
ctx.fillStyle = "white";
const eyeSize = cellSize / 5;
const eyeOffset = cellSize / 4;
// Position eyes based on direction
let leftEyeX, leftEyeY, rightEyeX, rightEyeY;
switch (directionRef.current) {
case DIRECTIONS.UP:
leftEyeX = segment.x * cellSize + eyeOffset;
leftEyeY = segment.y * cellSize + eyeOffset;
rightEyeX = segment.x * cellSize + cellSize - eyeOffset - eyeSize;
rightEyeY = segment.y * cellSize + eyeOffset;
break;
case DIRECTIONS.DOWN:
leftEyeX = segment.x * cellSize + eyeOffset;
leftEyeY = segment.y * cellSize + cellSize - eyeOffset - eyeSize;
rightEyeX = segment.x * cellSize + cellSize - eyeOffset - eyeSize;
rightEyeY = segment.y * cellSize + cellSize - eyeOffset - eyeSize;
break;
case DIRECTIONS.LEFT:
leftEyeX = segment.x * cellSize + eyeOffset;
leftEyeY = segment.y * cellSize + eyeOffset;
rightEyeX = segment.x * cellSize + eyeOffset;
rightEyeY = segment.y * cellSize + cellSize - eyeOffset - eyeSize;
break;
case DIRECTIONS.RIGHT:
leftEyeX = segment.x * cellSize + cellSize - eyeOffset - eyeSize;
leftEyeY = segment.y * cellSize + eyeOffset;
rightEyeX = segment.x * cellSize + cellSize - eyeOffset - eyeSize;
rightEyeY = segment.y * cellSize + cellSize - eyeOffset - eyeSize;
break;
default:
break;
}
ctx.fillRect(leftEyeX, leftEyeY, eyeSize, eyeSize);
ctx.fillRect(rightEyeX, rightEyeY, eyeSize, eyeSize);
}
});
// Draw pause overlay
if (isPausedRef.current) {
ctx.fillStyle = "rgba(0, 0, 0, 0.5)";
ctx.fillRect(0, 0, width, height);
ctx.fillStyle = "white";
ctx.font = "24px Arial";
ctx.textAlign = "center";
ctx.fillText("PAUSED", width / 2, height / 2);
ctx.font = "16px Arial";
ctx.fillText("Press SPACE to resume", width / 2, height / 2 + 30);
}
}
// Helper function to darken a color by a percentage
function darkenColor(hex, percent) {
// Remove # if present
hex = hex.replace("#", "");
// Convert to RGB
let r = parseInt(hex.substring(0, 2), 16);
let g = parseInt(hex.substring(2, 4), 16);
let b = parseInt(hex.substring(4, 6), 16);
// Darken
r = Math.floor((r * (100 - percent)) / 100);
g = Math.floor((g * (100 - percent)) / 100);
b = Math.floor((b * (100 - percent)) / 100);
// Convert back to hex
return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`;
}
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-green-800 p-4">
<div className="max-w-md mx-auto bg-white rounded-xl shadow-2xl p-6 w-full">
<div className="flex justify-between items-center mb-4">
<div className="text-xl font-bold text-green-800">Score: {score}</div>
<button
onClick={togglePause}
className="px-3 py-1 bg-yellow-500 text-white rounded hover:bg-yellow-600"
>
{isPaused ? "Resume" : "Pause"}
</button>
</div>
<div className="border-4 border-green-600 rounded flex justify-center">
<canvas
ref={canvasRef}
width={gridSize * cellSize}
height={gridSize * cellSize}
/>
</div>
<div className="mt-4 text-gray-600 text-sm text-center">
<p>
Use arrow keys to control the snake. Press space to pause/resume.
</p>
</div>
</div>
</div>
);
}
export default GameBoard;

View file

@ -0,0 +1,124 @@
import React, { useState, useEffect } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { getHighScores, addHighScore, isHighScore } from "../utils/storage";
function GameOverScreen() {
const location = useLocation();
const navigate = useNavigate();
const [score, setScore] = useState(0);
const [playerName, setPlayerName] = useState("");
const [highScores, setHighScores] = useState([]);
const [isHighScore, setIsHighScore] = useState(false);
useEffect(() => {
// Get score from location state
const gameScore = location.state?.score || 0;
setScore(gameScore);
// Get existing high scores from storage utility
const savedHighScores = getHighScores();
setHighScores(savedHighScores);
// Check if current score qualifies as a high score
const qualifiesAsHighScore =
savedHighScores.length < 5 ||
gameScore > Math.min(...savedHighScores.map((item) => item.score || 0));
setIsHighScore(qualifiesAsHighScore);
}, [location.state]);
function handleSubmitScore() {
if (!playerName.trim()) return;
// Use the storage utility to add the high score
addHighScore(playerName, score);
// Navigate to high scores screen
navigate("/high-scores");
}
function handlePlayAgain() {
navigate("/play");
}
function handleGoHome() {
navigate("/");
}
return (
<div className="flex items-center justify-center min-h-screen bg-gradient-to-b from-red-900 to-red-600 p-4">
<div className="max-w-md w-full bg-white rounded-xl shadow-2xl overflow-hidden">
{/* Header */}
<div className="bg-red-500 p-6 text-center">
<h1 className="text-3xl font-bold text-white">Game Over!</h1>
</div>
{/* Score display */}
<div className="p-6 text-center">
<div className="mb-6">
<p className="text-gray-700 mb-2">Your Score</p>
<p className="text-5xl font-bold text-red-600">{score}</p>
</div>
{/* High score form (conditional) */}
{isHighScore && (
<div className="mb-6 p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<p className="text-lg font-semibold text-yellow-700 mb-3">
🏆 New High Score! 🏆
</p>
<div className="flex">
<input
type="text"
placeholder="Enter your name"
value={playerName}
onChange={(e) => setPlayerName(e.target.value)}
className="flex-1 px-4 py-2 border border-gray-300 rounded-l focus:outline-none focus:ring-2 focus:ring-green-500"
maxLength={15}
/>
<button
onClick={handleSubmitScore}
className="px-4 py-2 bg-green-500 text-white font-medium rounded-r hover:bg-green-600 transition-colors"
>
Save
</button>
</div>
</div>
)}
{/* Action buttons */}
<div className="flex flex-col space-y-3">
<button
onClick={handlePlayAgain}
className="w-full py-3 px-6 bg-green-500 text-white font-semibold rounded-lg hover:bg-green-600 transition-colors"
>
Play Again
</button>
<button
onClick={() => navigate("/high-scores")}
className="w-full py-3 px-6 bg-blue-500 text-white font-semibold rounded-lg hover:bg-blue-600 transition-colors"
>
View High Scores
</button>
<button
onClick={handleGoHome}
className="w-full py-3 px-6 bg-gray-200 text-gray-800 font-semibold rounded-lg hover:bg-gray-300 transition-colors"
>
Back to Home
</button>
</div>
</div>
{/* Game tip */}
<div className="bg-gray-50 p-4 text-center border-t border-gray-200">
<p className="text-sm text-gray-600 italic">
Tip: Try to plan your path ahead to avoid trapping yourself!
</p>
</div>
</div>
</div>
);
}
export default GameOverScreen;

View file

@ -0,0 +1,121 @@
import React, { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { getHighScores, clearHighScores } from "../utils/storage";
function HighScores() {
const navigate = useNavigate();
const [highScores, setHighScores] = useState([]);
useEffect(() => {
// Load high scores from storage utility
const savedHighScores = getHighScores();
setHighScores(savedHighScores);
}, []);
function formatDate(dateString) {
const date = new Date(dateString);
return date.toLocaleDateString();
}
function handleClearScores() {
if (window.confirm("Are you sure you want to clear all high scores?")) {
// Use storage utility to clear high scores
clearHighScores();
setHighScores([]);
}
}
return (
<div className="flex items-center justify-center min-h-screen bg-gradient-to-b from-blue-900 to-blue-600 p-4">
<div className="max-w-md w-full bg-white rounded-xl shadow-2xl overflow-hidden">
{/* Header */}
<div className="bg-blue-500 p-6 text-center">
<h1 className="text-3xl font-bold text-white">High Scores</h1>
</div>
{/* Scores list */}
<div className="p-6">
{highScores.length > 0 ? (
<div className="mb-6">
<table className="w-full">
<thead>
<tr className="text-left border-b-2 border-gray-200">
<th className="py-2 px-4 text-gray-700">Rank</th>
<th className="py-2 px-4 text-gray-700">Name</th>
<th className="py-2 px-4 text-gray-700 text-right">
Score
</th>
<th className="py-2 px-4 text-gray-700 text-right">Date</th>
</tr>
</thead>
<tbody>
{highScores.map((score, index) => (
<tr
key={index}
className={`border-b border-gray-100 ${index === 0 ? "bg-yellow-50" : ""}`}
>
<td className="py-3 px-4">
{index === 0 ? (
<span className="text-yellow-500 font-bold">👑</span>
) : (
`#${index + 1}`
)}
</td>
<td className="py-3 px-4 font-medium">{score.name}</td>
<td className="py-3 px-4 text-right font-bold">
{score.score}
</td>
<td className="py-3 px-4 text-right text-sm text-gray-500">
{formatDate(score.date)}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="text-center py-8 text-gray-500">
<p className="mb-4">No high scores yet!</p>
<p>Be the first to set a record.</p>
</div>
)}
{/* Action buttons */}
<div className="flex flex-col space-y-3">
<button
onClick={() => navigate("/play")}
className="w-full py-3 px-6 bg-green-500 text-white font-semibold rounded-lg hover:bg-green-600 transition-colors"
>
Play Game
</button>
<button
onClick={() => navigate("/")}
className="w-full py-3 px-6 bg-gray-200 text-gray-800 font-semibold rounded-lg hover:bg-gray-300 transition-colors"
>
Back to Home
</button>
{highScores.length > 0 && (
<button
onClick={handleClearScores}
className="w-full py-2 px-6 text-sm text-red-500 font-medium hover:text-red-700 transition-colors"
>
Clear All High Scores
</button>
)}
</div>
</div>
{/* Motivational footer */}
<div className="bg-gray-50 p-4 text-center border-t border-gray-200">
<p className="text-sm text-gray-600">
Think you can do better? Challenge yourself again!
</p>
</div>
</div>
</div>
);
}
export default HighScores;

View file

@ -0,0 +1,121 @@
import React, { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { getHighScores, clearHighScores } from "../utils/storage";
function HighScores() {
const navigate = useNavigate();
const [highScores, setHighScores] = useState([]);
useEffect(() => {
// Load high scores from storage utility
const savedHighScores = getHighScores();
setHighScores(savedHighScores);
}, []);
function formatDate(dateString) {
const date = new Date(dateString);
return date.toLocaleDateString();
}
function handleClearScores() {
if (window.confirm("Are you sure you want to clear all high scores?")) {
// Use storage utility to clear high scores
clearHighScores();
setHighScores([]);
}
}
return (
<div className="flex items-center justify-center min-h-screen bg-gradient-to-b from-blue-900 to-blue-600 p-4">
<div className="max-w-md w-full bg-white rounded-xl shadow-2xl overflow-hidden">
{/* Header */}
<div className="bg-blue-500 p-6 text-center">
<h1 className="text-3xl font-bold text-white">High Scores</h1>
</div>
{/* Scores list */}
<div className="p-6">
{highScores.length > 0 ? (
<div className="mb-6">
<table className="w-full">
<thead>
<tr className="text-left border-b-2 border-gray-200">
<th className="py-2 px-4 text-gray-700">Rank</th>
<th className="py-2 px-4 text-gray-700">Name</th>
<th className="py-2 px-4 text-gray-700 text-right">
Score
</th>
<th className="py-2 px-4 text-gray-700 text-right">Date</th>
</tr>
</thead>
<tbody>
{highScores.map((score, index) => (
<tr
key={index}
className={`border-b border-gray-100 ${index === 0 ? "bg-yellow-50" : ""}`}
>
<td className="py-3 px-4">
{index === 0 ? (
<span className="text-yellow-500 font-bold">👑</span>
) : (
`#${index + 1}`
)}
</td>
<td className="py-3 px-4 font-medium">{score.name}</td>
<td className="py-3 px-4 text-right font-bold">
{score.score}
</td>
<td className="py-3 px-4 text-right text-sm text-gray-500">
{formatDate(score.date)}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="text-center py-8 text-gray-500">
<p className="mb-4">No high scores yet!</p>
<p>Be the first to set a record.</p>
</div>
)}
{/* Action buttons */}
<div className="flex flex-col space-y-3">
<button
onClick={() => navigate("/play")}
className="w-full py-3 px-6 bg-green-500 text-white font-semibold rounded-lg hover:bg-green-600 transition-colors"
>
Play Game
</button>
<button
onClick={() => navigate("/")}
className="w-full py-3 px-6 bg-gray-200 text-gray-800 font-semibold rounded-lg hover:bg-gray-300 transition-colors"
>
Back to Home
</button>
{highScores.length > 0 && (
<button
onClick={handleClearScores}
className="w-full py-2 px-6 text-sm text-red-500 font-medium hover:text-red-700 transition-colors"
>
Clear All High Scores
</button>
)}
</div>
</div>
{/* Motivational footer */}
<div className="bg-gray-50 p-4 text-center border-t border-gray-200">
<p className="text-sm text-gray-600">
Think you can do better? Challenge yourself again!
</p>
</div>
</div>
</div>
);
}
export default HighScores;

View file

@ -0,0 +1,122 @@
import React from "react";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
function WelcomeScreen() {
const navigate = useNavigate();
const [isHovered, setIsHovered] = useState(false);
function handlePlayClick() {
navigate("/play");
}
function handleMouseEnter() {
setIsHovered(true);
}
function handleMouseLeave() {
setIsHovered(false);
}
function goToHighScores() {
navigate("/high-scores");
}
function goToSettings() {
navigate("/settings");
}
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-gradient-to-b from-green-900 to-green-600 p-4">
<div className="max-w-md w-full bg-white rounded-xl shadow-2xl overflow-hidden">
{/* Header with snake icon */}
<div className="bg-green-500 p-6 flex items-center justify-center">
<div className="relative">
<div className="text-6xl">🐍</div>
<div className="absolute -top-2 -right-2 bg-yellow-400 text-xs font-bold px-2 py-1 rounded-full animate-pulse">
NEW!
</div>
</div>
</div>
{/* Game title */}
<div className="p-6 text-center">
<h1 className="text-4xl font-bold text-green-800 mb-2 tracking-tight">
Sashi Snake Game
</h1>
<p className="text-gray-600 mb-6">
The classic snake game with a modern twist!
</p>
{/* Play button */}
<button
className={`w-full py-3 px-6 rounded-lg text-lg font-semibold transition-all duration-300 ease-in-out ${
isHovered
? "bg-green-700 text-white transform scale-105"
: "bg-green-500 text-white"
}`}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onClick={handlePlayClick}
>
Play Now!
</button>
{/* Additional buttons */}
<div className="mt-4 grid grid-cols-2 gap-3">
<button
onClick={goToHighScores}
className="py-2 px-4 bg-blue-500 text-white font-medium rounded hover:bg-blue-600 transition-colors"
>
High Scores
</button>
<button
onClick={goToSettings}
className="py-2 px-4 bg-purple-500 text-white font-medium rounded hover:bg-purple-600 transition-colors"
>
Settings
</button>
</div>
{/* Instructions */}
<div className="mt-8 text-left border-t border-gray-200 pt-6">
<h2 className="text-xl font-semibold text-gray-800 mb-3">
How to Play:
</h2>
<ul className="space-y-2 text-gray-600">
<li className="flex items-center">
<span className="mr-2 text-green-500"></span>
<span>Use arrow keys to control the snake</span>
</li>
<li className="flex items-center">
<span className="mr-2 text-green-500">🍎</span>
<span>Eat food to grow longer</span>
</li>
<li className="flex items-center">
<span className="mr-2 text-green-500"></span>
<span>Don't hit the walls or yourself!</span>
</li>
</ul>
</div>
{/* High scores teaser */}
<div className="mt-6 text-center">
<p className="text-sm text-gray-500">
Can you make it to the top of the leaderboard?
</p>
</div>
</div>
{/* Footer */}
<div className="bg-gray-50 px-6 py-4 text-center">
<p className="text-sm text-gray-500">
© 2025 Sashi Snake Game | Made with 💚
</p>
</div>
</div>
</div>
);
}
export default WelcomeScreen;

View file

@ -0,0 +1,73 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
html,
body {
margin: 0;
padding: 0;
height: 100%;
}
/* Canvas styles */
canvas {
display: block;
}
/* Animation for game elements */
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.animate-pulse {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
/* Transition effects */
.transition-all {
transition-property: all;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 300ms;
}
.transition-colors {
transition-property: background-color, border-color, color, fill, stroke;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 300ms;
}
/* Transform utilities */
.transform {
--tw-translate-x: 0;
--tw-translate-y: 0;
--tw-rotate: 0;
--tw-skew-x: 0;
--tw-skew-y: 0;
--tw-scale-x: 1;
--tw-scale-y: 1;
transform: translateX(var(--tw-translate-x))
translateY(var(--tw-translate-y)) rotate(var(--tw-rotate))
skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y))
scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
}
.scale-105 {
--tw-scale-x: 1.05;
--tw-scale-y: 1.05;
}

View file

@ -0,0 +1,11 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./index.css";
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

View file

@ -0,0 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

View file

@ -0,0 +1,158 @@
/**
* storage.js - Utility functions for local storage operations
*
* This module handles saving and retrieving data from localStorage
* for the Sashi Snake Game, particularly focused on high scores.
*/
// Keys used for localStorage
const STORAGE_KEYS = {
HIGH_SCORES: 'sashiSnake.highScores',
SETTINGS: 'sashiSnake.settings'
};
/**
* Save high scores to localStorage
* @param {Array} scores - Array of score objects with name, score, and date properties
* @returns {boolean} - Whether the operation was successful
*/
function saveHighScores(scores) {
try {
const sortedScores = [...scores].sort((a, b) => b.score - a.score);
localStorage.setItem(STORAGE_KEYS.HIGH_SCORES, JSON.stringify(sortedScores));
return true;
} catch (error) {
console.error('Error saving high scores:', error);
return false;
}
}
/**
* Get high scores from localStorage
* @returns {Array} - Array of score objects, sorted by score (highest first)
*/
function getHighScores() {
try {
const scores = localStorage.getItem(STORAGE_KEYS.HIGH_SCORES);
return scores ? JSON.parse(scores) : [];
} catch (error) {
console.error('Error retrieving high scores:', error);
return [];
}
}
/**
* Add a new high score to the existing list
* @param {string} name - Player name
* @param {number} score - Player score
* @param {number} maxEntries - Maximum number of high scores to keep (default: 5)
* @returns {boolean} - Whether the operation was successful
*/
function addHighScore(name, score, maxEntries = 5) {
try {
const scores = getHighScores();
const newScore = {
name: name.trim(),
score: score,
date: new Date().toISOString()
};
// Add new score and sort
const updatedScores = [...scores, newScore]
.sort((a, b) => b.score - a.score)
.slice(0, maxEntries); // Keep only top scores up to maxEntries
return saveHighScores(updatedScores);
} catch (error) {
console.error('Error adding high score:', error);
return false;
}
}
/**
* Check if a score qualifies as a high score
* @param {number} score - Score to check
* @param {number} maxEntries - Maximum number of high scores to keep (default: 5)
* @returns {boolean} - Whether the score is high enough to be in the top scores
*/
function isHighScore(score, maxEntries = 5) {
const scores = getHighScores();
// If we don't have enough scores yet, any score qualifies
if (scores.length < maxEntries) {
return true;
}
// Otherwise, check if this score is higher than the lowest current high score
const lowestHighScore = Math.min(...scores.map(entry => entry.score));
return score > lowestHighScore;
}
/**
* Clear all high scores from localStorage
* @returns {boolean} - Whether the operation was successful
*/
function clearHighScores() {
try {
localStorage.removeItem(STORAGE_KEYS.HIGH_SCORES);
return true;
} catch (error) {
console.error('Error clearing high scores:', error);
return false;
}
}
/**
* Save game settings to localStorage
* @param {Object} settings - Game settings object
* @returns {boolean} - Whether the operation was successful
*/
function saveSettings(settings) {
try {
localStorage.setItem(STORAGE_KEYS.SETTINGS, JSON.stringify(settings));
return true;
} catch (error) {
console.error('Error saving settings:', error);
return false;
}
}
/**
* Get game settings from localStorage
* @returns {Object} - Game settings object or default settings
*/
function getSettings() {
try {
const settings = localStorage.getItem(STORAGE_KEYS.SETTINGS);
return settings ? JSON.parse(settings) : getDefaultSettings();
} catch (error) {
console.error('Error retrieving settings:', error);
return getDefaultSettings();
}
}
/**
* Get default game settings
* @returns {Object} - Default game settings
*/
function getDefaultSettings() {
return {
difficulty: 'medium',
gridSize: '20',
soundEnabled: true,
snakeColor: '#32CD32',
foodColor: '#FF0000'
};
}
// Export all functions
export {
saveHighScores,
getHighScores,
addHighScore,
isHighScore,
clearHighScores,
saveSettings,
getSettings,
getDefaultSettings
};

View file

@ -0,0 +1,16 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{js,jsx}"],
theme: {
extend: {
colors: {
"snake-green": "#32CD32",
"food-red": "#FF0000",
},
animation: {
pulse: "pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite",
},
},
},
plugins: [],
};

View file

@ -0,0 +1,16 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
react({
// This enables JSX syntax in .js files
include: "**/*.{jsx,js}",
}),
],
server: {
port: 3000,
open: true,
},
});