added more maple apps
This commit is contained in:
parent
423b9a25fb
commit
5f2426c401
82 changed files with 22775 additions and 0 deletions
BIN
native/wordpress/maple-fonts-wp.zip
Normal file
BIN
native/wordpress/maple-fonts-wp.zip
Normal file
Binary file not shown.
12
web/mapleblocks-frontend-prototype/README.md
Normal file
12
web/mapleblocks-frontend-prototype/README.md
Normal 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.
|
||||
33
web/mapleblocks-frontend-prototype/eslint.config.js
Normal file
33
web/mapleblocks-frontend-prototype/eslint.config.js
Normal 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 },
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
13
web/mapleblocks-frontend-prototype/index.html
Normal file
13
web/mapleblocks-frontend-prototype/index.html
Normal 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>
|
||||
4135
web/mapleblocks-frontend-prototype/package-lock.json
generated
Normal file
4135
web/mapleblocks-frontend-prototype/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
31
web/mapleblocks-frontend-prototype/package.json
Normal file
31
web/mapleblocks-frontend-prototype/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
web/mapleblocks-frontend-prototype/postcss.config.js
Normal file
6
web/mapleblocks-frontend-prototype/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
1
web/mapleblocks-frontend-prototype/public/vite.svg
Normal file
1
web/mapleblocks-frontend-prototype/public/vite.svg
Normal 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 |
42
web/mapleblocks-frontend-prototype/src/App.css
Normal file
42
web/mapleblocks-frontend-prototype/src/App.css
Normal 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;
|
||||
}
|
||||
381
web/mapleblocks-frontend-prototype/src/App.jsx
Normal file
381
web/mapleblocks-frontend-prototype/src/App.jsx
Normal 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;
|
||||
1
web/mapleblocks-frontend-prototype/src/assets/react.svg
Normal file
1
web/mapleblocks-frontend-prototype/src/assets/react.svg
Normal 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 |
1096
web/mapleblocks-frontend-prototype/src/components/GameBoard.jsx
Normal file
1096
web/mapleblocks-frontend-prototype/src/components/GameBoard.jsx
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
304
web/mapleblocks-frontend-prototype/src/index.css
Normal file
304
web/mapleblocks-frontend-prototype/src/index.css
Normal 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);
|
||||
}
|
||||
}
|
||||
10
web/mapleblocks-frontend-prototype/src/main.jsx
Normal file
10
web/mapleblocks-frontend-prototype/src/main.jsx
Normal 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>,
|
||||
)
|
||||
471
web/mapleblocks-frontend-prototype/src/utils/blocksGame.js
Normal file
471
web/mapleblocks-frontend-prototype/src/utils/blocksGame.js
Normal 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,
|
||||
};
|
||||
32
web/mapleblocks-frontend-prototype/src/utils/blocksLogic.js
Normal file
32
web/mapleblocks-frontend-prototype/src/utils/blocksLogic.js
Normal 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
|
||||
};
|
||||
387
web/mapleblocks-frontend-prototype/src/utils/blocksPieces.js
Normal file
387
web/mapleblocks-frontend-prototype/src/utils/blocksPieces.js
Normal 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
|
||||
};
|
||||
242
web/mapleblocks-frontend-prototype/src/utils/blocksScoring.js
Normal file
242
web/mapleblocks-frontend-prototype/src/utils/blocksScoring.js
Normal 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
|
||||
};
|
||||
255
web/mapleblocks-frontend-prototype/src/utils/gameinfo.js
Normal file
255
web/mapleblocks-frontend-prototype/src/utils/gameinfo.js
Normal 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
|
||||
};
|
||||
334
web/mapleblocks-frontend-prototype/src/utils/mobile.jsx
Normal file
334
web/mapleblocks-frontend-prototype/src/utils/mobile.jsx
Normal 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
|
||||
};
|
||||
261
web/mapleblocks-frontend-prototype/src/utils/storage.js
Normal file
261
web/mapleblocks-frontend-prototype/src/utils/storage.js
Normal 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
|
||||
};
|
||||
83
web/mapleblocks-frontend-prototype/tailwind.config.js
Normal file
83
web/mapleblocks-frontend-prototype/tailwind.config.js
Normal 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: [],
|
||||
};
|
||||
9
web/mapleblocks-frontend-prototype/vite.config.js
Normal file
9
web/mapleblocks-frontend-prototype/vite.config.js
Normal 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",
|
||||
},
|
||||
});
|
||||
13
web/mapleqr-frontend-prototype/index.html
Normal file
13
web/mapleqr-frontend-prototype/index.html
Normal 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>
|
||||
2912
web/mapleqr-frontend-prototype/package-lock.json
generated
Normal file
2912
web/mapleqr-frontend-prototype/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
23
web/mapleqr-frontend-prototype/package.json
Normal file
23
web/mapleqr-frontend-prototype/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
web/mapleqr-frontend-prototype/postcss.config.js
Normal file
6
web/mapleqr-frontend-prototype/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
}
|
||||
20
web/mapleqr-frontend-prototype/src/App.jsx
Normal file
20
web/mapleqr-frontend-prototype/src/App.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
BIN
web/mapleqr-frontend-prototype/src/components/Archive.zip
Normal file
BIN
web/mapleqr-frontend-prototype/src/components/Archive.zip
Normal file
Binary file not shown.
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
52
web/mapleqr-frontend-prototype/src/components/StepResult.jsx
Normal file
52
web/mapleqr-frontend-prototype/src/components/StepResult.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
237
web/mapleqr-frontend-prototype/src/components/StepResult.jsx.bak
Normal file
237
web/mapleqr-frontend-prototype/src/components/StepResult.jsx.bak
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
12
web/mapleqr-frontend-prototype/src/index.css
Normal file
12
web/mapleqr-frontend-prototype/src/index.css
Normal 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;
|
||||
}
|
||||
11
web/mapleqr-frontend-prototype/src/main.jsx
Normal file
11
web/mapleqr-frontend-prototype/src/main.jsx
Normal 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>,
|
||||
);
|
||||
71
web/mapleqr-frontend-prototype/src/pages/WizardPage.jsx
Normal file
71
web/mapleqr-frontend-prototype/src/pages/WizardPage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
264
web/mapleqr-frontend-prototype/src/utils/qrCode.js
Normal file
264
web/mapleqr-frontend-prototype/src/utils/qrCode.js
Normal 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);
|
||||
};
|
||||
26
web/mapleqr-frontend-prototype/src/utils/storage.js
Normal file
26
web/mapleqr-frontend-prototype/src/utils/storage.js
Normal 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);
|
||||
}
|
||||
}
|
||||
11
web/mapleqr-frontend-prototype/tailwind.config.js
Normal file
11
web/mapleqr-frontend-prototype/tailwind.config.js
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,jsx}"
|
||||
],
|
||||
theme: {
|
||||
extend: {}
|
||||
},
|
||||
plugins: []
|
||||
}
|
||||
12
web/maplesudoku-frontend-prototype/README.md
Normal file
12
web/maplesudoku-frontend-prototype/README.md
Normal 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.
|
||||
33
web/maplesudoku-frontend-prototype/eslint.config.js
Normal file
33
web/maplesudoku-frontend-prototype/eslint.config.js
Normal 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 },
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
13
web/maplesudoku-frontend-prototype/index.html
Normal file
13
web/maplesudoku-frontend-prototype/index.html
Normal 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>
|
||||
3597
web/maplesudoku-frontend-prototype/package-lock.json
generated
Normal file
3597
web/maplesudoku-frontend-prototype/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
32
web/maplesudoku-frontend-prototype/package.json
Normal file
32
web/maplesudoku-frontend-prototype/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
web/maplesudoku-frontend-prototype/postcss.config.js
Normal file
6
web/maplesudoku-frontend-prototype/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
1
web/maplesudoku-frontend-prototype/public/vite.svg
Normal file
1
web/maplesudoku-frontend-prototype/public/vite.svg
Normal 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 |
42
web/maplesudoku-frontend-prototype/src/App.css
Normal file
42
web/maplesudoku-frontend-prototype/src/App.css
Normal 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;
|
||||
}
|
||||
21
web/maplesudoku-frontend-prototype/src/App.jsx
Normal file
21
web/maplesudoku-frontend-prototype/src/App.jsx
Normal 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;
|
||||
11
web/maplesudoku-frontend-prototype/src/Untitled
Normal file
11
web/maplesudoku-frontend-prototype/src/Untitled
Normal 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;
|
||||
}
|
||||
1
web/maplesudoku-frontend-prototype/src/assets/react.svg
Normal file
1
web/maplesudoku-frontend-prototype/src/assets/react.svg
Normal 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 |
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
65
web/maplesudoku-frontend-prototype/src/index.css
Normal file
65
web/maplesudoku-frontend-prototype/src/index.css
Normal 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;
|
||||
}
|
||||
10
web/maplesudoku-frontend-prototype/src/main.jsx
Normal file
10
web/maplesudoku-frontend-prototype/src/main.jsx
Normal 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>,
|
||||
);
|
||||
295
web/maplesudoku-frontend-prototype/src/utils/SudokuGenerator.js
Normal file
295
web/maplesudoku-frontend-prototype/src/utils/SudokuGenerator.js
Normal 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
|
||||
};
|
||||
201
web/maplesudoku-frontend-prototype/src/utils/hints.js
Normal file
201
web/maplesudoku-frontend-prototype/src/utils/hints.js
Normal 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,
|
||||
};
|
||||
4
web/maplesudoku-frontend-prototype/tailwind.config.js
Normal file
4
web/maplesudoku-frontend-prototype/tailwind.config.js
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
||||
};
|
||||
7
web/maplesudoku-frontend-prototype/vite.config.js
Normal file
7
web/maplesudoku-frontend-prototype/vite.config.js
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||
13
web/sashisnakegame-frontend-prototype/index.html
Normal file
13
web/sashisnakegame-frontend-prototype/index.html
Normal 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>
|
||||
3052
web/sashisnakegame-frontend-prototype/package-lock.json
generated
Normal file
3052
web/sashisnakegame-frontend-prototype/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
23
web/sashisnakegame-frontend-prototype/package.json
Normal file
23
web/sashisnakegame-frontend-prototype/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
web/sashisnakegame-frontend-prototype/postcss.config.js
Normal file
6
web/sashisnakegame-frontend-prototype/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
|
@ -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 |
25
web/sashisnakegame-frontend-prototype/src/App.jsx
Normal file
25
web/sashisnakegame-frontend-prototype/src/App.jsx
Normal 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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
73
web/sashisnakegame-frontend-prototype/src/index.css
Normal file
73
web/sashisnakegame-frontend-prototype/src/index.css
Normal 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;
|
||||
}
|
||||
11
web/sashisnakegame-frontend-prototype/src/index.js
Normal file
11
web/sashisnakegame-frontend-prototype/src/index.js
Normal 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>,
|
||||
);
|
||||
10
web/sashisnakegame-frontend-prototype/src/index.jsx
Normal file
10
web/sashisnakegame-frontend-prototype/src/index.jsx
Normal 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>,
|
||||
);
|
||||
158
web/sashisnakegame-frontend-prototype/src/utils/storage.js
Normal file
158
web/sashisnakegame-frontend-prototype/src/utils/storage.js
Normal 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
|
||||
};
|
||||
16
web/sashisnakegame-frontend-prototype/tailwind.config.js
Normal file
16
web/sashisnakegame-frontend-prototype/tailwind.config.js
Normal 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: [],
|
||||
};
|
||||
16
web/sashisnakegame-frontend-prototype/vite.config.js
Normal file
16
web/sashisnakegame-frontend-prototype/vite.config.js
Normal 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,
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue