first commit
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled

This commit is contained in:
el 2025-04-14 17:47:08 +02:00
parent 3e537bc05d
commit 154646a124
19 changed files with 2989 additions and 147 deletions

View file

@ -0,0 +1,32 @@
name: Build and Push Docker Image
on:
push:
branches:
- main # Ou la branche sur laquelle vous pushez habituellement
jobs:
build:
runs-on: alpine # Utilisation de votre runner
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Build Docker image
run: |
docker build -t git.wilmoredynamics.com/ab/ab01:${GITHUB_SHA::8} .
# Tag the image with 'latest'
docker tag git.wilmoredynamics.com/ab/ab01:${GITHUB_SHA::8} git.wilmoredynamics.com/ab/ab01:latest
- name: Log in to Forgejo Container Registry
uses: docker/login-action@v2
with:
registry: git.wilmoredynamics.com
username: AB # Votre nom d'utilisateur ou organisation Forgejo
password: ${{ secrets.FORGEJO_TOKEN }} # Votre jeton d'accès personnel Forgejo
- name: Push Docker image to Forgejo Container Registry
run: |
docker push git.wilmoredynamics.com/ab/ab01:${GITHUB_SHA::8}
docker push git.wilmoredynamics.com/ab/ab01:latest

66
dockerfile Normal file
View file

@ -0,0 +1,66 @@
# syntax=docker.io/docker/dockerfile:1
FROM node:18-alpine AS base
# Install dependencies only when needed
FROM base AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Install dependencies based on the preferred package manager
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./
RUN \
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
elif [ -f package-lock.json ]; then npm ci; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
else echo "Lockfile not found." && exit 1; \
fi
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Next.js collects completely anonymous telemetry data about general usage.
# Learn more here: https://nextjs.org/telemetry
# Uncomment the following line in case you want to disable telemetry during the build.
ENV NEXT_TELEMETRY_DISABLED=1
RUN \
if [ -f yarn.lock ]; then yarn run build; \
elif [ -f package-lock.json ]; then npm run build; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
else echo "Lockfile not found." && exit 1; \
fi
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
# Uncomment the following line in case you want to disable telemetry during runtime.
# ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
# server.js is created by next build from the standalone output
# https://nextjs.org/docs/pages/api-reference/config/next-config-js/output
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

54
fdr.md Normal file
View file

@ -0,0 +1,54 @@
Absolument ! Voici la feuille de route condensée, sans la configuration du projet, la phase de tests ni le déploiement :
**Phase 1 : Conception et Planification**
1. **Définir la portée de l'application :**
* **Jeux :** Mots croisés, Sudoku, Binero.
* **Niveaux de difficulté :** Facile, Moyen, Difficile (et potentiellement Expert). Définir les critères pour chaque niveau de chaque jeu.
* **Fonctionnalités :** Score par partie, progression locale (sauvegarde de l'état du jeu et des meilleurs scores).
* **Public cible :** Tout public, avec une interface intuitive et accessible.
* **Design :** Inspiration Dieter Rams (minimalisme, clarté, fonctionnalité) et Apple (simplicité, attention aux détails, typographie soignée).
2. **Choisir les technologies :**
* Next.js, TypeScript, Material UI.
3. **Conception de l'interface utilisateur (UI) et de l'expérience utilisateur (UX) :**
* **Principes de design :** Minimalisme, clarté, fonctionnalité, cohérence, attention aux détails.
* **Wireframes :** Schémas simples des écrans clés (accueil, sélection du jeu, interface de chaque jeu).
* **Maquettes (Mockups) :** Designs détaillés basés sur Material UI, personnalisés pour un style épuré (couleurs neutres, typographie élégante, espaces blancs).
4. **Planification de la structure du projet :**
* Organisation des dossiers (`pages`, `components`, `styles`, `utils`, `types`).
**Phase 2 : Développement des Jeux**
1. **Développement du jeu de Mots Croisés :**
* **Logique du jeu :** Gestion des niveaux de difficulté (taille de la grille, nombre de mots, complexité des définitions), génération (ou utilisation de grilles prédéfinies), vérification de la solution, calcul du score.
* **Interface utilisateur (avec Material UI) :** Affichage de la grille, des définitions, interaction pour remplir les cases.
2. **Développement du jeu de Sudoku :**
* **Logique du jeu :** Gestion des niveaux de difficulté (nombre de cases pré-remplies), génération de grilles valides, vérification de la solution, calcul du score.
* **Interface utilisateur (avec Material UI) :** Affichage de la grille, interaction pour entrer les chiffres.
3. **Développement du jeu de Binero :**
* **Logique du jeu :** Gestion des niveaux de difficulté (taille de la grille, nombre de cases initialement remplies), génération de grilles valides, vérification de la solution, calcul du score.
* **Interface utilisateur (avec Material UI) :** Affichage de la grille, interaction pour placer des 0 ou des 1.
**Phase 3 : Développement des Fonctionnalités Additionnelles**
* **Système de score :** Implémenter la logique de calcul du score pour chaque jeu.
* **Progression locale :** Utiliser `localStorage` pour sauvegarder l'état des parties en cours et les meilleurs scores.
**Phase 4 : Conception et Développement de l'Interface Utilisateur (UI)**
* **Thème Material UI :** Personnalisation pour refléter le style Dieter Rams et Apple (couleurs, typographie, espacements).
* **Composants Material UI :** Utilisation judicieuse et minimaliste des composants.
* **Mise en page :** Création d'interfaces claires et organisées avec les outils de Material UI.
* **Typographie :** Choix d'une police élégante et lisible.
* **Icônes :** Utilisation parcimonieuse et significative des icônes Material UI.
**Phase 5 : Maintenance et Améliorations**
* Surveillance de l'application, correction des bugs, ajout de nouvelles fonctionnalités.
Cette feuille de route se concentre sur les étapes essentielles de conception et de développement de votre application.

851
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -9,19 +9,23 @@
"lint": "next lint"
},
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@mui/icons-material": "^7.0.2",
"@mui/material": "^7.0.2",
"next": "15.3.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"next": "15.3.0"
"react-dom": "^19.0.0"
},
"devDependencies": {
"typescript": "^5",
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@tailwindcss/postcss": "^4",
"tailwindcss": "^4",
"eslint": "^9",
"eslint-config-next": "15.3.0",
"@eslint/eslintrc": "^3"
"tailwindcss": "^4",
"typescript": "^5"
}
}

775
src/app/binero/page.tsx Normal file
View file

@ -0,0 +1,775 @@
'use client';
import { Box, Container, Typography, Button, useTheme, ButtonGroup, Alert, Snackbar, IconButton, Tooltip, Paper, Divider, Dialog, DialogTitle, DialogContent, LinearProgress, Badge } from '@mui/material';
import { BineroGrid } from '@/components/games/binero/BineroGrid';
import { Timer } from '@/components/games/sudoku/Timer';
import { generateBinero, validateGrid, isGridComplete, calculateScore } from '@/components/games/binero/bineroLogic';
import { BineroRules } from '@/components/games/binero/BineroRules';
import { useState, useCallback, useEffect, useMemo } from 'react';
import { Help, Refresh, CheckCircle, Timer as TimerIcon, EmojiEvents, Undo, Save, Settings } from '@mui/icons-material';
import { useLocalStorage } from '@/hooks/useLocalStorage';
type CellValue = 0 | 1 | null;
type Grid = CellValue[][];
interface GameState {
grid: Grid;
difficulty: 'easy' | 'medium' | 'hard';
isPlaying: boolean;
score: number;
size: number;
mistakes: number;
gridSize: { rows: number; cols: number };
}
const DIFFICULTY_MULTIPLIER = {
easy: 1,
medium: 1.5,
hard: 2,
};
const GRID_SIZES = [
{ rows: 6, cols: 6, label: '6×6' },
{ rows: 8, cols: 8, label: '8×8' },
{ rows: 10, cols: 10, label: '10×10' },
{ rows: 12, cols: 12, label: '12×12' },
{ rows: 14, cols: 14, label: '14×14' },
];
export default function BineroPage() {
const theme = useTheme();
const [showRules, setShowRules] = useState(false);
const [showHints, setShowHints] = useState(false);
const [showSettings, setShowSettings] = useState(false);
const [saveCounter, setSaveCounter] = useState(0);
const [bestScores, setBestScores] = useLocalStorage<Record<string, number>>('bineroBestScores', {
easy: 0,
medium: 0,
hard: 0,
});
const [savedGames, setSavedGames] = useLocalStorage<Record<string, GameState>>('bineroSavedGames', {});
const [history, setHistory] = useState<Grid[]>([]);
const [historyIndex, setHistoryIndex] = useState(-1);
const [gameState, setGameState] = useState<GameState>(() => ({
grid: generateBinero(8, 'easy'),
difficulty: 'easy',
isPlaying: false,
score: 0,
size: 8,
mistakes: 0,
gridSize: { rows: 8, cols: 8 },
}));
const [message, setMessage] = useState<{ text: string; severity: 'success' | 'error' | 'info' }>({
text: '',
severity: 'info',
});
const memoizedGrid = useMemo(() => gameState.grid, [gameState.grid]);
const gridCompletion = useMemo(() => {
const totalCells = gameState.grid.length * gameState.grid.length;
const filledCells = gameState.grid.flat().filter(cell => cell !== null).length;
return Math.round((filledCells / totalCells) * 100);
}, [gameState.grid]);
const updateBestScore = useCallback((score: number) => {
setBestScores(prev => ({
...prev,
[gameState.difficulty]: Math.max(score, prev[gameState.difficulty] || 0),
}));
}, [gameState.difficulty, setBestScores]);
useEffect(() => {
if (!gameState.isPlaying && gameState.score > 0) {
updateBestScore(gameState.score);
}
}, [gameState.isPlaying, gameState.score, updateBestScore]);
const handleCellChange = useCallback((row: number, col: number, value: 0 | 1 | null) => {
setGameState(prev => {
const newGrid = prev.grid.map((r, i) =>
r.map((c, j) => (i === row && j === col ? value : c))
);
setHistory(prevHistory => {
const newHistory = prevHistory.slice(0, historyIndex + 1);
newHistory.push(JSON.parse(JSON.stringify(newGrid)));
return newHistory;
});
setHistoryIndex(prev => prev + 1);
return { ...prev, grid: newGrid };
});
}, [historyIndex]);
const handleGameComplete = useCallback(() => {
setGameState(prev => ({ ...prev, isPlaying: false }));
setMessage({ text: 'Félicitations ! Vous avez terminé le Binero !', severity: 'success' });
}, []);
const handleNewGame = useCallback((newDifficulty: GameState['difficulty'], newSize?: { rows: number; cols: number }) => {
const size = newSize || gameState.gridSize;
setTimeout(() => {
const newGrid = generateBinero(size.rows, newDifficulty);
setGameState(prev => ({
...prev,
grid: newGrid,
difficulty: newDifficulty,
isPlaying: true,
score: 0,
mistakes: 0,
gridSize: size,
}));
setMessage({ text: `Nouvelle partie en difficulté ${newDifficulty} (${size.rows}×${size.cols})`, severity: 'info' });
}, 0);
}, [gameState.gridSize]);
const handleCheck = useCallback(() => {
const isComplete = isGridComplete(gameState.grid);
if (!isComplete) {
setMessage({
text: 'La grille n\'est pas complète. Continuez à remplir les cases vides.',
severity: 'info'
});
return;
}
// Vérifier les règles une par une pour donner des messages plus précis
let errorMessage = '';
// Vérifier le nombre égal de 0 et 1 dans chaque ligne et colonne
for (let i = 0; i < gameState.grid.length; i++) {
const row = gameState.grid[i];
const col = gameState.grid.map(r => r[i]);
const rowZeros = row.filter(cell => cell === 0).length;
const rowOnes = row.filter(cell => cell === 1).length;
const colZeros = col.filter(cell => cell === 0).length;
const colOnes = col.filter(cell => cell === 1).length;
if (rowZeros !== rowOnes) {
errorMessage = `La ligne ${i+1} n'a pas un nombre égal de 0 et de 1.`;
break;
}
if (colZeros !== colOnes) {
errorMessage = `La colonne ${i+1} n'a pas un nombre égal de 0 et de 1.`;
break;
}
}
// Vérifier les chiffres consécutifs
if (!errorMessage) {
for (let i = 0; i < gameState.grid.length; i++) {
const row = gameState.grid[i];
const col = gameState.grid.map(r => r[i]);
for (let j = 0; j < gameState.grid.length - 2; j++) {
if (row[j] !== null && row[j] === row[j+1] && row[j] === row[j+2]) {
errorMessage = `La ligne ${i+1} contient trois ${row[j]} consécutifs.`;
break;
}
if (col[j] !== null && col[j] === col[j+1] && col[j] === col[j+2]) {
errorMessage = `La colonne ${i+1} contient trois ${col[j]} consécutifs.`;
break;
}
}
if (errorMessage) break;
}
}
// Vérifier les lignes/colonnes identiques uniquement en mode difficile
if (!errorMessage && gameState.difficulty === 'hard') {
const rows = gameState.grid.map(row => row.join(','));
const cols = gameState.grid[0].map((_, colIndex) =>
gameState.grid.map(row => row[colIndex]).join(',')
);
// Vérifier les lignes identiques
for (let i = 0; i < rows.length; i++) {
for (let j = i + 1; j < rows.length; j++) {
if (rows[i] === rows[j]) {
errorMessage = `Les lignes ${i+1} et ${j+1} sont identiques.`;
break;
}
}
if (errorMessage) break;
}
// Vérifier les colonnes identiques
if (!errorMessage) {
for (let i = 0; i < cols.length; i++) {
for (let j = i + 1; j < cols.length; j++) {
if (cols[i] === cols[j]) {
errorMessage = `Les colonnes ${i+1} et ${j+1} sont identiques.`;
break;
}
}
if (errorMessage) break;
}
}
}
if (errorMessage) {
setGameState(prev => ({ ...prev, mistakes: prev.mistakes + 1 }));
setMessage({
text: `Erreur : ${errorMessage}`,
severity: 'error'
});
} else {
setMessage({
text: 'Félicitations ! Votre grille est valide et complète.',
severity: 'success'
});
handleGameComplete();
}
}, [gameState.grid, gameState.difficulty, handleGameComplete]);
const handleTimeUpdate = useCallback((seconds: number) => {
if (!gameState.isPlaying) return;
const currentScore = calculateScore(
gameState.difficulty,
seconds,
gameState.mistakes,
gameState.gridSize
);
setGameState(prev => ({ ...prev, score: currentScore }));
}, [gameState.difficulty, gameState.mistakes, gameState.gridSize, gameState.isPlaying]);
const handleCellClick = useCallback((row: number, col: number) => {
const currentValue = gameState.grid[row][col];
let newValue: 0 | 1 | null = null;
if (currentValue === null) {
newValue = 0;
} else if (currentValue === 0) {
newValue = 1;
}
handleCellChange(row, col, newValue);
}, [gameState.grid, handleCellChange]);
useEffect(() => {
if (isGridComplete(gameState.grid) && validateGrid(gameState.grid, gameState.difficulty)) {
handleGameComplete();
}
}, [gameState.grid, gameState.difficulty, handleGameComplete]);
const handleUndo = useCallback(() => {
if (historyIndex > 0) {
setHistoryIndex(prev => prev - 1);
setGameState(prev => ({
...prev,
grid: JSON.parse(JSON.stringify(history[historyIndex - 1]))
}));
}
}, [history, historyIndex]);
const handleSaveGame = useCallback(() => {
const saveKey = `binero_${gameState.difficulty}_${gameState.gridSize.rows}x${gameState.gridSize.cols}_${saveCounter}`;
setSaveCounter(prev => prev + 1);
setSavedGames(prev => ({
...prev,
[saveKey]: gameState
}));
setMessage({ text: 'Partie sauvegardée avec succès !', severity: 'success' });
}, [gameState, setSavedGames, saveCounter]);
const handleLoadGame = useCallback((savedGame: GameState) => {
setGameState(savedGame);
setHistory([JSON.parse(JSON.stringify(savedGame.grid))]);
setHistoryIndex(0);
setMessage({ text: 'Partie chargée avec succès !', severity: 'success' });
setShowSettings(false);
}, []);
return (
<Container maxWidth="lg">
<Box sx={{ py: { xs: 2, sm: 4 } }}>
<Box sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
maxWidth: { xs: '100%', sm: 800 },
mx: 'auto',
px: { xs: 1, sm: 0 },
}}>
<Box sx={{
width: '100%',
display: 'flex',
flexDirection: { xs: 'column', sm: 'row' },
justifyContent: 'space-between',
alignItems: { xs: 'center', sm: 'center' },
mb: 3,
gap: 2,
}}>
<Typography
component="h1"
variant="h3"
sx={{
fontWeight: 600,
background: `linear-gradient(45deg, ${theme.palette.primary.main}, ${theme.palette.secondary.main})`,
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
textAlign: 'center',
fontSize: { xs: '2rem', sm: '2.5rem' },
}}
>
Binero
</Typography>
<Box sx={{ display: 'flex', gap: 1 }}>
<Tooltip title="Règles du jeu">
<IconButton onClick={() => setShowRules(!showRules)} color="primary">
<Help />
</IconButton>
</Tooltip>
<Tooltip title="Paramètres">
<IconButton onClick={() => setShowSettings(!showSettings)} color="primary">
<Settings />
</IconButton>
</Tooltip>
<Tooltip title="Nouvelle partie">
<IconButton onClick={() => handleNewGame(gameState.difficulty)} color="primary">
<Refresh />
</IconButton>
</Tooltip>
</Box>
</Box>
<Paper elevation={0} sx={{
p: { xs: 2, sm: 3 },
mb: 3,
border: `1px solid ${theme.palette.divider}`,
width: '100%',
borderRadius: '12px',
}}>
<Box sx={{
display: 'flex',
flexDirection: { xs: 'column', sm: 'row' },
justifyContent: 'space-between',
alignItems: { xs: 'center', sm: 'center' },
mb: 2,
gap: 2,
}}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<TimerIcon color="primary" />
<Timer isRunning={gameState.isPlaying} onTimeUpdate={handleTimeUpdate} />
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<EmojiEvents color="primary" />
<Typography variant="h6">
Score: {gameState.score}
</Typography>
</Box>
</Box>
<Divider sx={{ my: 2 }} />
<Box sx={{ mb: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="body2" color="text.secondary">
Progression: {gridCompletion}%
</Typography>
<Typography variant="body2" color="text.secondary">
Meilleur score ({gameState.difficulty}): {bestScores[gameState.difficulty] || 0}
</Typography>
</Box>
<LinearProgress
variant="determinate"
value={gridCompletion}
sx={{
height: 8,
borderRadius: 4,
backgroundColor: theme.palette.grey[200],
'& .MuiLinearProgress-bar': {
borderRadius: 4,
}
}}
/>
</Box>
</Paper>
<Box sx={{
mb: 3,
display: 'flex',
flexDirection: 'column',
gap: 2,
alignItems: 'center',
width: '100%',
}}>
<Box sx={{ width: '100%', overflowX: 'auto' }}>
<ButtonGroup
variant="outlined"
size="large"
sx={{
width: '100%',
'& .MuiButton-root': {
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
'&:hover': {
transform: 'translateY(-2px)',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
},
flex: 1,
borderWidth: '2px',
'&.MuiButton-contained': {
background: `linear-gradient(45deg, ${theme.palette.primary.main}, ${theme.palette.primary.dark})`,
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
},
'&.MuiButton-outlined': {
borderColor: theme.palette.primary.main,
color: theme.palette.primary.main,
'&:hover': {
borderColor: theme.palette.primary.dark,
backgroundColor: theme.palette.primary.light + '15',
},
},
},
}}
>
<Button
onClick={() => handleNewGame('easy')}
variant={gameState.difficulty === 'easy' ? 'contained' : 'outlined'}
sx={{
borderRadius: '8px',
fontWeight: 600,
letterSpacing: '0.5px',
}}
>
Facile
</Button>
<Button
onClick={() => handleNewGame('medium')}
variant={gameState.difficulty === 'medium' ? 'contained' : 'outlined'}
sx={{
borderRadius: '8px',
fontWeight: 600,
letterSpacing: '0.5px',
}}
>
Moyen
</Button>
<Button
onClick={() => handleNewGame('hard')}
variant={gameState.difficulty === 'hard' ? 'contained' : 'outlined'}
sx={{
borderRadius: '8px',
fontWeight: 600,
letterSpacing: '0.5px',
}}
>
Difficile
</Button>
</ButtonGroup>
</Box>
<Box sx={{ width: '100%', overflowX: 'auto' }}>
<ButtonGroup
variant="outlined"
size="large"
sx={{
width: '100%',
'& .MuiButton-root': {
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
'&:hover': {
transform: 'translateY(-2px)',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
},
flex: 1,
borderWidth: '2px',
'&.MuiButton-contained': {
background: `linear-gradient(45deg, ${theme.palette.primary.main}, ${theme.palette.primary.dark})`,
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
},
'&.MuiButton-outlined': {
borderColor: theme.palette.primary.main,
color: theme.palette.primary.main,
'&:hover': {
borderColor: theme.palette.primary.dark,
backgroundColor: theme.palette.primary.light + '15',
},
},
},
}}
>
{GRID_SIZES.map((size) => (
<Button
key={`${size.rows}×${size.cols}`}
onClick={() => handleNewGame(gameState.difficulty, { rows: size.rows, cols: size.cols })}
variant={
gameState.gridSize.rows === size.rows && gameState.gridSize.cols === size.cols
? 'contained'
: 'outlined'
}
sx={{
borderRadius: '8px',
fontWeight: 600,
letterSpacing: '0.5px',
}}
>
{size.label}
</Button>
))}
</ButtonGroup>
</Box>
</Box>
<Box sx={{
width: '100%',
overflowX: 'auto',
display: 'flex',
justifyContent: 'center',
mb: 3,
}}>
<Box sx={{
minWidth: { xs: '280px', sm: '400px' },
maxWidth: '100%',
}}>
<BineroGrid
grid={memoizedGrid}
onCellClick={handleCellClick}
showHints={showHints}
difficulty={gameState.difficulty}
/>
</Box>
</Box>
<Box sx={{
display: 'grid',
gridTemplateColumns: { xs: '1fr 1fr', sm: 'repeat(3, 1fr)', md: 'repeat(5, 1fr)' },
gap: 2,
width: '100%',
}}>
<Button
variant="contained"
color="primary"
onClick={() => handleNewGame(gameState.difficulty)}
startIcon={<Refresh />}
size="large"
sx={{
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
'&:hover': {
transform: 'translateY(-2px)',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
},
borderRadius: '8px',
fontWeight: 600,
letterSpacing: '0.5px',
background: `linear-gradient(45deg, ${theme.palette.primary.main}, ${theme.palette.primary.dark})`,
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
}}
>
Nouvelle Partie
</Button>
<Button
variant="outlined"
color="secondary"
onClick={handleCheck}
startIcon={<CheckCircle />}
size="large"
sx={{
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
'&:hover': {
transform: 'translateY(-2px)',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
backgroundColor: theme.palette.secondary.light + '15',
},
borderRadius: '8px',
fontWeight: 600,
letterSpacing: '0.5px',
borderWidth: '2px',
}}
>
Vérifier
</Button>
<Button
variant={showHints ? "contained" : "outlined"}
color="info"
onClick={() => setShowHints(!showHints)}
startIcon={<Help />}
size="large"
sx={{
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
'&:hover': {
transform: 'translateY(-2px)',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
},
borderRadius: '8px',
fontWeight: 600,
letterSpacing: '0.5px',
...(showHints ? {
background: `linear-gradient(45deg, ${theme.palette.info.main}, ${theme.palette.info.dark})`,
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
} : {
borderWidth: '2px',
'&:hover': {
backgroundColor: theme.palette.info.light + '15',
},
}),
}}
>
{showHints ? "Masquer" : "Indices"}
</Button>
<Tooltip title="Annuler le dernier coup">
<span>
<Button
variant="outlined"
color="inherit"
onClick={handleUndo}
startIcon={<Undo />}
size="large"
disabled={historyIndex <= 0}
sx={{
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
'&:hover:not(:disabled)': {
transform: 'translateY(-2px)',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
backgroundColor: theme.palette.mode === 'dark'
? theme.palette.grey[800]
: theme.palette.grey[200],
},
borderRadius: '8px',
fontWeight: 600,
letterSpacing: '0.5px',
borderWidth: '2px',
width: '100%',
height: '100%',
minHeight: '48px',
opacity: historyIndex <= 0 ? 0.5 : 1,
'&:disabled': {
backgroundColor: theme.palette.mode === 'dark'
? theme.palette.grey[900]
: theme.palette.grey[100],
borderColor: theme.palette.mode === 'dark'
? theme.palette.grey[700]
: theme.palette.grey[300],
},
}}
>
Annuler
</Button>
</span>
</Tooltip>
<Tooltip title="Sauvegarder la partie">
<Button
variant="outlined"
color="primary"
onClick={handleSaveGame}
size="large"
sx={{
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
'&:hover': {
transform: 'translateY(-2px)',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
backgroundColor: theme.palette.mode === 'dark'
? `${theme.palette.primary.main}33`
: theme.palette.primary.light + '15',
borderColor: theme.palette.mode === 'dark'
? theme.palette.primary.light
: theme.palette.primary.main,
},
borderRadius: '8px',
fontWeight: 600,
letterSpacing: '0.5px',
borderWidth: '1px',
borderColor: theme.palette.mode === 'dark'
? theme.palette.primary.main + '80'
: theme.palette.primary.main,
color: theme.palette.mode === 'dark'
? theme.palette.primary.light
: theme.palette.primary.main,
width: '100%',
height: '100%',
minHeight: '48px',
backgroundColor: 'transparent',
backdropFilter: 'blur(8px)',
}}
>
Sauvegarder
</Button>
</Tooltip>
</Box>
</Box>
<Dialog
open={showRules}
onClose={() => setShowRules(false)}
maxWidth="sm"
fullWidth
>
<DialogTitle>Règles du Binero</DialogTitle>
<DialogContent>
<BineroRules />
</DialogContent>
</Dialog>
<Dialog
open={showSettings}
onClose={() => setShowSettings(false)}
maxWidth="sm"
fullWidth
>
<DialogTitle>Paramètres et parties sauvegardées</DialogTitle>
<DialogContent>
<Typography variant="h6" sx={{ mt: 2, mb: 1 }}>Parties sauvegardées</Typography>
{Object.keys(savedGames).length === 0 ? (
<Typography variant="body2" color="text.secondary">
Aucune partie sauvegardée.
</Typography>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{Object.entries(savedGames).map(([key, game]) => (
<Paper
key={key}
elevation={0}
sx={{
p: 2,
border: `1px solid ${theme.palette.divider}`,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}
>
<Box>
<Typography variant="subtitle1">
{game.difficulty.charAt(0).toUpperCase() + game.difficulty.slice(1)} ({game.gridSize.rows}×{game.gridSize.cols})
</Typography>
<Typography variant="body2" color="text.secondary">
Score: {game.score} | Progression: {Math.round((game.grid.flat().filter(cell => cell !== null).length / (game.gridSize.rows * game.gridSize.cols)) * 100)}%
</Typography>
</Box>
<Button
variant="outlined"
size="small"
onClick={() => handleLoadGame(game)}
>
Charger
</Button>
</Paper>
))}
</Box>
)}
</DialogContent>
</Dialog>
<Snackbar
open={!!message.text}
autoHideDuration={3000}
onClose={() => setMessage({ text: '', severity: 'info' })}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert severity={message.severity} sx={{ width: '100%' }}>
{message.text}
</Alert>
</Snackbar>
</Box>
</Container>
);
}

View file

@ -1,6 +1,9 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { ColorModeProvider } from '@/context/ColorModeContext';
import { Navbar } from '@/components/Navbar';
import { CssBaseline } from '@mui/material';
const geistSans = Geist({
variable: "--font-geist-sans",
@ -13,21 +16,23 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "Logica - Jeux de Logique",
description: "Collection de jeux de logique et de réflexion",
};
export default function RootLayout({
children,
}: Readonly<{
}: {
children: React.ReactNode;
}>) {
}) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
<html lang="fr">
<body>
<ColorModeProvider>
<CssBaseline />
<Navbar />
{children}
</ColorModeProvider>
</body>
</html>
);

View file

@ -1,103 +1,112 @@
import Image from "next/image";
'use client';
import { Box, Container, Typography, Grid, Card, CardContent, CardActions, Button, useTheme } from '@mui/material';
import { Extension, Grid3x3, GridOn } from '@mui/icons-material';
import Link from 'next/link';
export default function Home() {
return (
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={180}
height={38}
priority
/>
<ol className="list-inside list-decimal text-sm/6 text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
<li className="mb-2 tracking-[-.01em]">
Get started by editing{" "}
<code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-[family-name:var(--font-geist-mono)] font-semibold">
src/app/page.tsx
</code>
.
</li>
<li className="tracking-[-.01em]">
Save and see your changes instantly.
</li>
</ol>
const theme = useTheme();
<div className="flex gap-4 items-center flex-col sm:flex-row">
<a
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
const games = [
{
title: 'Mots Croisés',
description: 'Testez votre vocabulaire et votre esprit de déduction avec nos grilles de mots croisés personnalisées.',
icon: <Extension sx={{ fontSize: 40 }} />,
href: '/mots-croises',
},
{
title: 'Sudoku',
description: 'Relevez le défi des chiffres avec nos grilles de Sudoku de différents niveaux de difficulté.',
icon: <Grid3x3 sx={{ fontSize: 40 }} />,
href: '/sudoku',
},
{
title: 'Binero',
description: 'Explorez la logique binaire avec ce puzzle captivant de 0 et de 1.',
icon: <GridOn sx={{ fontSize: 40 }} />,
href: '/binero',
},
];
return (
<Box
sx={{
minHeight: 'calc(100vh - 64px)',
pt: 8,
pb: 6,
background: theme.palette.background.default,
}}
>
<Container maxWidth="lg">
<Box textAlign="center" mb={8}>
<Typography
component="h1"
variant="h2"
sx={{
fontWeight: 700,
mb: 2,
background: `linear-gradient(45deg, ${theme.palette.primary.main}, ${theme.palette.secondary.main})`,
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
}}
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={20}
height={20}
/>
Deploy now
</a>
<a
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Read our docs
</a>
</div>
</main>
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
Bienvenue sur Logica
</Typography>
<Typography variant="h5" color="text.secondary" paragraph>
Exercez votre esprit avec notre collection de jeux de logique
</Typography>
</Box>
<Box
sx={{
display: 'grid',
gridTemplateColumns: {
xs: '1fr',
sm: 'repeat(2, 1fr)',
md: 'repeat(3, 1fr)',
},
gap: 4,
}}
>
<Image
aria-hidden
src="/file.svg"
alt="File icon"
width={16}
height={16}
/>
Learn
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/window.svg"
alt="Window icon"
width={16}
height={16}
/>
Examples
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/globe.svg"
alt="Globe icon"
width={16}
height={16}
/>
Go to nextjs.org
</a>
</footer>
</div>
{games.map((game) => (
<Box key={game.title}>
<Card
sx={{
height: '100%',
display: 'flex',
flexDirection: 'column',
transition: 'transform 0.2s ease-in-out',
'&:hover': {
transform: 'translateY(-4px)',
},
}}
>
<CardContent sx={{ flexGrow: 1, textAlign: 'center' }}>
<Box sx={{ mb: 2, color: 'primary.main' }}>{game.icon}</Box>
<Typography gutterBottom variant="h5" component="h2">
{game.title}
</Typography>
<Typography color="text.secondary">{game.description}</Typography>
</CardContent>
<CardActions sx={{ justifyContent: 'center', pb: 2 }}>
<Button
component={Link}
href={game.href}
variant="contained"
color="primary"
sx={{
borderRadius: '20px',
px: 4,
}}
>
Jouer
</Button>
</CardActions>
</Card>
</Box>
))}
</Box>
</Container>
</Box>
);
}

197
src/app/sudoku/page.tsx Normal file
View file

@ -0,0 +1,197 @@
'use client';
import { Box, Container, Typography, Button, useTheme, ButtonGroup, Alert, Snackbar } from '@mui/material';
import { SudokuGrid } from '@/components/games/sudoku/SudokuGrid';
import { Timer } from '@/components/games/sudoku/Timer';
import { generateSudoku, validateGrid, isGridComplete } from '@/components/games/sudoku/sudokuLogic';
import { useState, useCallback, useEffect } from 'react';
interface GameState {
grid: (number | null)[][];
difficulty: 'easy' | 'medium' | 'hard';
isPlaying: boolean;
score: number;
bestScores: Record<string, number>;
}
const DIFFICULTY_MULTIPLIER = {
easy: 1,
medium: 2,
hard: 3,
};
const calculateScore = (difficulty: 'easy' | 'medium' | 'hard', timeInSeconds: number): number => {
const baseScore = 1000;
const timeMultiplier = Math.max(0, 1 - timeInSeconds / 3600); // 1 heure max
return Math.round(baseScore * DIFFICULTY_MULTIPLIER[difficulty] * timeMultiplier);
};
export default function SudokuPage() {
const theme = useTheme();
const [gameState, setGameState] = useState<GameState>(() => {
const savedState = localStorage.getItem('sudokuGameState');
const savedBestScores = localStorage.getItem('sudokuBestScores');
return {
grid: generateSudoku('easy'),
difficulty: 'easy',
isPlaying: false,
score: 0,
bestScores: savedBestScores ? JSON.parse(savedBestScores) : { easy: 0, medium: 0, hard: 0 },
};
});
const [message, setMessage] = useState<{ text: string; severity: 'success' | 'error' | 'info' }>({ text: '', severity: 'info' });
useEffect(() => {
localStorage.setItem('sudokuBestScores', JSON.stringify(gameState.bestScores));
}, [gameState.bestScores]);
const handleCellChange = (row: number, col: number, value: number | null) => {
const newGrid = gameState.grid.map((r, i) =>
r.map((c, j) => (i === row && j === col ? value : c))
);
setGameState(prev => ({ ...prev, grid: newGrid }));
if (isGridComplete(newGrid)) {
handleGameComplete();
}
};
const handleGameComplete = () => {
setGameState(prev => ({ ...prev, isPlaying: false }));
setMessage({ text: 'Félicitations ! Vous avez terminé le Sudoku !', severity: 'success' });
};
const handleNewGame = useCallback((newDifficulty: GameState['difficulty']) => {
setGameState(prev => ({
...prev,
grid: generateSudoku(newDifficulty),
difficulty: newDifficulty,
isPlaying: true,
score: 0,
}));
setMessage({ text: `Nouvelle partie en difficulté ${newDifficulty}`, severity: 'info' });
}, []);
const handleCheck = () => {
if (validateGrid(gameState.grid)) {
setMessage({ text: 'La grille est valide, continuez !', severity: 'success' });
} else {
setMessage({ text: 'Il y a des erreurs dans la grille', severity: 'error' });
}
};
const handleTimeUpdate = (seconds: number) => {
const currentScore = calculateScore(gameState.difficulty, seconds);
setGameState(prev => ({ ...prev, score: currentScore }));
};
const updateBestScore = useCallback((score: number) => {
setGameState(prev => ({
...prev,
bestScores: {
...prev.bestScores,
[prev.difficulty]: Math.max(score, prev.bestScores[prev.difficulty] || 0),
},
}));
}, []);
useEffect(() => {
if (!gameState.isPlaying && gameState.score > 0) {
updateBestScore(gameState.score);
}
}, [gameState.isPlaying, gameState.score, updateBestScore]);
return (
<Container maxWidth="lg">
<Box sx={{ py: 4 }}>
<Typography
component="h1"
variant="h3"
sx={{
textAlign: 'center',
mb: 4,
fontWeight: 600,
background: `linear-gradient(45deg, ${theme.palette.primary.main}, ${theme.palette.secondary.main})`,
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
}}
>
Sudoku
</Typography>
<Box sx={{ maxWidth: 600, mx: 'auto' }}>
<Box sx={{ mb: 4 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Timer isRunning={gameState.isPlaying} onTimeUpdate={handleTimeUpdate} />
<Typography variant="h6">
Score: {gameState.score}
</Typography>
</Box>
<Typography variant="subtitle1" textAlign="center" color="text.secondary">
Meilleur score ({gameState.difficulty}): {gameState.bestScores[gameState.difficulty] || 0}
</Typography>
</Box>
<Box sx={{ mb: 4, display: 'flex', justifyContent: 'center' }}>
<ButtonGroup variant="outlined">
<Button
onClick={() => handleNewGame('easy')}
variant={gameState.difficulty === 'easy' ? 'contained' : 'outlined'}
>
Facile
</Button>
<Button
onClick={() => handleNewGame('medium')}
variant={gameState.difficulty === 'medium' ? 'contained' : 'outlined'}
>
Moyen
</Button>
<Button
onClick={() => handleNewGame('hard')}
variant={gameState.difficulty === 'hard' ? 'contained' : 'outlined'}
>
Difficile
</Button>
</ButtonGroup>
</Box>
<SudokuGrid
initialGrid={gameState.grid}
onCellChange={handleCellChange}
/>
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'center', mt: 4 }}>
<Button
variant="contained"
color="primary"
onClick={() => handleNewGame(gameState.difficulty)}
>
Nouvelle Partie
</Button>
<Button
variant="outlined"
color="secondary"
onClick={handleCheck}
>
Vérifier
</Button>
</Box>
</Box>
</Box>
<Snackbar
open={!!message.text}
autoHideDuration={3000}
onClose={() => setMessage({ text: '', severity: 'info' })}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert severity={message.severity} sx={{ width: '100%' }}>
{message.text}
</Alert>
</Snackbar>
</Container>
);
}

93
src/components/Navbar.tsx Normal file
View file

@ -0,0 +1,93 @@
'use client';
import { AppBar, Toolbar, Typography, IconButton, useTheme, Box, Drawer, List, ListItem, ListItemIcon, ListItemText } from '@mui/material';
import { Brightness4, Brightness7, Menu, Extension, Grid3x3, GridOn } from '@mui/icons-material';
import { useContext, useState } from 'react';
import Link from 'next/link';
import { ColorModeContext } from '@/context/ColorModeContext';
export const Navbar = () => {
const theme = useTheme();
const colorMode = useContext(ColorModeContext);
const [drawerOpen, setDrawerOpen] = useState(false);
const menuItems = [
{ title: 'Mots Croisés', icon: <Extension />, href: '/mots-croises' },
{ title: 'Sudoku', icon: <Grid3x3 />, href: '/sudoku' },
{ title: 'Binero', icon: <GridOn />, href: '/binero' },
];
return (
<>
<AppBar position="sticky">
<Toolbar>
<IconButton
size="large"
edge="start"
color="inherit"
aria-label="menu"
sx={{ mr: 2, display: { sm: 'none' } }}
onClick={() => setDrawerOpen(true)}
>
<Menu />
</IconButton>
<Typography
variant="h6"
component={Link}
href="/"
sx={{
flexGrow: 1,
textDecoration: 'none',
color: 'inherit',
fontWeight: 600,
letterSpacing: 1,
}}
>
LOGICA
</Typography>
<Box sx={{ display: { xs: 'none', sm: 'flex' }, gap: 2, alignItems: 'center' }}>
{menuItems.map((item) => (
<Typography
key={item.title}
component={Link}
href={item.href}
sx={{ textDecoration: 'none', color: 'inherit' }}
>
{item.title}
</Typography>
))}
</Box>
<IconButton onClick={colorMode.toggleColorMode} color="inherit">
{theme.palette.mode === 'dark' ? <Brightness7 /> : <Brightness4 />}
</IconButton>
</Toolbar>
</AppBar>
<Drawer
anchor="left"
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
>
<List sx={{ width: 250 }}>
{menuItems.map((item) => (
<ListItem
key={item.title}
component={Link}
href={item.href}
onClick={() => setDrawerOpen(false)}
sx={{ textDecoration: 'none', color: 'inherit' }}
>
<ListItemIcon sx={{ color: 'primary.main' }}>
{item.icon}
</ListItemIcon>
<ListItemText primary={item.title} />
</ListItem>
))}
</List>
</Drawer>
</>
);
};

View file

@ -0,0 +1,161 @@
'use client';
import { Box, Paper, Typography, useTheme } from '@mui/material';
import { memo, useCallback, useMemo } from 'react';
type CellValue = 0 | 1 | null;
type Grid = CellValue[][];
interface BineroGridProps {
grid: Grid;
onCellClick: (row: number, col: number) => void;
showHints?: boolean;
difficulty: 'easy' | 'medium' | 'hard';
}
// Composant de cellule mémorisé pour éviter les re-rendus inutiles
const BineroCell = memo(({
value,
isConflict,
showHints,
onClick,
theme
}: {
value: CellValue;
isConflict: boolean;
showHints: boolean;
onClick: () => void;
theme: any;
}) => (
<Box
onClick={onClick}
sx={{
width: 40,
height: 40,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
border: `1px solid ${theme.palette.divider}`,
cursor: 'pointer',
backgroundColor: value === null
? theme.palette.background.default
: isConflict && showHints
? theme.palette.error.light
: theme.palette.background.paper,
'&:hover': {
backgroundColor: theme.palette.action.hover,
},
}}
>
<Typography
variant="h6"
sx={{
color: value === null
? 'transparent'
: isConflict && showHints
? theme.palette.error.main
: theme.palette.text.primary,
}}
>
{value === null ? '·' : value}
</Typography>
</Box>
));
BineroCell.displayName = 'BineroCell';
export const BineroGrid = memo(({ grid, onCellClick, showHints = false, difficulty }: BineroGridProps) => {
const theme = useTheme();
const size = grid.length;
// Vérifie si une cellule est en conflit avec les règles
const isCellInConflict = useCallback((row: number, col: number): boolean => {
if (grid[row][col] === null) return false;
const value = grid[row][col];
const rowValues = grid[row];
const colValues = grid.map((r: CellValue[]) => r[col]);
// Vérifier les chiffres consécutifs horizontalement
if (col >= 2 && rowValues[col - 1] === value && rowValues[col - 2] === value) return true;
if (col <= size - 3 && rowValues[col + 1] === value && rowValues[col + 2] === value) return true;
if (col > 0 && col < size - 1 && rowValues[col - 1] === value && rowValues[col + 1] === value) return true;
// Vérifier les chiffres consécutifs verticalement
if (row >= 2 && colValues[row - 1] === value && colValues[row - 2] === value) return true;
if (row <= size - 3 && colValues[row + 1] === value && colValues[row + 2] === value) return true;
if (row > 0 && row < size - 1 && colValues[row - 1] === value && colValues[row + 1] === value) return true;
// Vérifier le nombre de 0 et 1 dans la ligne
const rowZeros = rowValues.filter((cell: CellValue) => cell === 0).length;
const rowOnes = rowValues.filter((cell: CellValue) => cell === 1).length;
const maxAllowed = Math.ceil(size / 2);
if (rowZeros > maxAllowed || rowOnes > maxAllowed) return true;
// Vérifier le nombre de 0 et 1 dans la colonne
const colZeros = colValues.filter((cell: CellValue) => cell === 0).length;
const colOnes = colValues.filter((cell: CellValue) => cell === 1).length;
if (colZeros > maxAllowed || colOnes > maxAllowed) return true;
// Vérifier les lignes/colonnes identiques uniquement en mode difficile
if (difficulty === 'hard') {
const rowStr = rowValues.join(',');
const colStr = colValues.join(',');
// Vérifier si cette ligne est identique à une autre ligne
for (let i = 0; i < size; i++) {
if (i !== row) {
const otherRowStr = grid[i].join(',');
if (rowStr === otherRowStr) return true;
}
}
// Vérifier si cette colonne est identique à une autre colonne
for (let i = 0; i < size; i++) {
if (i !== col) {
const otherColStr = grid.map((r: CellValue[]) => r[i]).join(',');
if (colStr === otherColStr) return true;
}
}
}
return false;
}, [grid, size, difficulty]);
// Mémoriser la grille pour éviter les recalculs inutiles
const gridStyle = useMemo(() => ({
display: 'grid',
gridTemplateColumns: `repeat(${size}, 1fr)`,
gap: 1,
justifyContent: 'center',
}), [size]);
return (
<Paper
elevation={3}
sx={{
p: 2,
display: 'inline-block',
backgroundColor: theme.palette.background.paper,
margin: '0 auto',
}}
>
<Box sx={gridStyle}>
{grid.map((row: CellValue[], rowIndex: number) =>
row.map((cell: CellValue, colIndex: number) => (
<BineroCell
key={`${rowIndex}-${colIndex}`}
value={cell}
isConflict={isCellInConflict(rowIndex, colIndex)}
showHints={showHints}
onClick={() => onCellClick(rowIndex, colIndex)}
theme={theme}
/>
))
)}
</Box>
</Paper>
);
});
BineroGrid.displayName = 'BineroGrid';

View file

@ -0,0 +1,30 @@
'use client';
import { Box, Typography, List, ListItem, ListItemIcon, ListItemText, Paper, useTheme } from '@mui/material';
import { Check, Close, Info } from '@mui/icons-material';
export const BineroRules = () => {
const theme = useTheme();
return (
<Box sx={{ py: 2 }}>
<Typography variant="body1" paragraph>
Le Binero est un jeu de logique vous devez remplir une grille avec des 0 et des 1 en respectant trois règles :
</Typography>
<Box component="ul" sx={{ pl: 2, mb: 2 }}>
<Typography component="li" variant="body1" paragraph>
Chaque ligne et chaque colonne doit contenir un nombre égal de 0 et de 1.
</Typography>
<Typography component="li" variant="body1" paragraph>
Il ne peut pas y avoir plus de deux chiffres identiques côte à côte (horizontalement ou verticalement).
</Typography>
<Typography component="li" variant="body1" paragraph>
Deux lignes ou deux colonnes ne peuvent pas être identiques.
</Typography>
</Box>
<Typography variant="body2" color="text.secondary">
Cliquez sur une case pour alterner entre 0, 1 et vide. Double-cliquez pour effacer la valeur.
</Typography>
</Box>
);
};

View file

@ -0,0 +1,194 @@
type CellValue = 0 | 1 | null;
type Grid = CellValue[][];
interface GridSize {
rows: number;
cols: number;
}
// Vérifie si une ligne ou colonne a un nombre égal de 0 et 1
const hasEqualZerosAndOnes = (line: CellValue[]): boolean => {
// Ne vérifier que si la ligne est complète
if (line.some(cell => cell === null)) {
return true; // Si la ligne n'est pas complète, on considère qu'elle est valide
}
const zeros = line.filter(cell => cell === 0).length;
const ones = line.filter(cell => cell === 1).length;
return zeros === ones;
};
// Vérifie s'il y a plus de deux chiffres identiques consécutifs
const hasMoreThanTwoConsecutive = (line: CellValue[]): boolean => {
// Ne vérifier que si la ligne est complète
if (line.some(cell => cell === null)) {
return false; // Si la ligne n'est pas complète, on ne vérifie pas cette règle
}
for (let i = 0; i < line.length - 2; i++) {
// Vérifier trois cellules consécutives
if (line[i] !== null && line[i] === line[i + 1] && line[i] === line[i + 2]) {
return true;
}
}
return false;
};
// Vérifie si deux lignes ou colonnes sont identiques
const hasDuplicateLines = (grid: Grid): boolean => {
// Ne vérifier que si la grille est complète
if (grid.some(row => row.some(cell => cell === null))) {
return false; // Si la grille n'est pas complète, on ne vérifie pas cette règle
}
// Convertir les lignes et colonnes en chaînes de caractères pour la comparaison
const rows = grid.map(row => row.join(','));
const cols = grid[0].map((_, colIndex) =>
grid.map(row => row[colIndex]).join(',')
);
// Vérifier si le nombre de lignes/colonnes uniques est égal au nombre total
// Si ce n'est pas le cas, il y a des doublons
return new Set(rows).size !== rows.length || new Set(cols).size !== cols.length;
};
// Génère une grille valide selon le niveau de difficulté
export const generateBinero = (size: number, difficulty: 'easy' | 'medium' | 'hard'): Grid => {
const grid: Grid = Array(size).fill(null).map(() => Array(size).fill(null));
// Nombre de cases pré-remplies selon la difficulté
const filledCells = {
easy: Math.floor(size * size * 0.3),
medium: Math.floor(size * size * 0.2),
hard: Math.floor(size * size * 0.1),
}[difficulty];
// Fonction pour vérifier si une valeur peut être placée
const canPlaceValue = (row: number, col: number, value: 0 | 1): boolean => {
// Vérifier les chiffres consécutifs horizontalement
if (col >= 2 && grid[row][col - 1] === value && grid[row][col - 2] === value) return false;
if (col <= size - 3 && grid[row][col + 1] === value && grid[row][col + 2] === value) return false;
if (col > 0 && col < size - 1 && grid[row][col - 1] === value && grid[row][col + 1] === value) return false;
// Vérifier les chiffres consécutifs verticalement
if (row >= 2 && grid[row - 1][col] === value && grid[row - 2][col] === value) return false;
if (row <= size - 3 && grid[row + 1][col] === value && grid[row + 2][col] === value) return false;
if (row > 0 && row < size - 1 && grid[row - 1][col] === value && grid[row + 1][col] === value) return false;
// Vérifier le nombre de 0 et 1 dans la ligne
const rowZeros = grid[row].filter(cell => cell === 0).length + (value === 0 ? 1 : 0);
const rowOnes = grid[row].filter(cell => cell === 1).length + (value === 1 ? 1 : 0);
if (rowZeros > size / 2 || rowOnes > size / 2) return false;
// Vérifier le nombre de 0 et 1 dans la colonne
const colZeros = grid.map(r => r[col]).filter(cell => cell === 0).length + (value === 0 ? 1 : 0);
const colOnes = grid.map(r => r[col]).filter(cell => cell === 1).length + (value === 1 ? 1 : 0);
if (colZeros > size / 2 || colOnes > size / 2) return false;
return true;
};
// Fonction pour résoudre la grille
const solve = (row: number = 0, col: number = 0): boolean => {
if (col === size) {
col = 0;
row++;
}
if (row === size) return true;
if (grid[row][col] !== null) {
return solve(row, col + 1);
}
const values: (0 | 1)[] = Math.random() < 0.5 ? [0, 1] : [1, 0];
for (const value of values) {
if (canPlaceValue(row, col, value)) {
grid[row][col] = value;
if (solve(row, col + 1)) return true;
grid[row][col] = null;
}
}
return false;
};
// Générer une solution valide
solve();
// Retirer des cellules aléatoirement selon la difficulté
const cellsToKeep = size * size - filledCells;
let removed = 0;
while (removed < filledCells) {
const row = Math.floor(Math.random() * size);
const col = Math.floor(Math.random() * size);
if (grid[row][col] !== null) {
grid[row][col] = null;
removed++;
}
}
return grid;
};
// Vérifie si la grille est valide selon toutes les règles
export const validateGrid = (grid: Grid, difficulty: 'easy' | 'medium' | 'hard'): boolean => {
// Vérifier si la grille est complète
if (!isGridComplete(grid)) {
return false;
}
// Vérifier chaque ligne et colonne
for (let i = 0; i < grid.length; i++) {
const row = grid[i];
const col = grid.map(r => r[i]);
// Vérifier le nombre égal de 0 et 1
if (!hasEqualZerosAndOnes(row) || !hasEqualZerosAndOnes(col)) {
return false;
}
// Vérifier les chiffres consécutifs
if (hasMoreThanTwoConsecutive(row) || hasMoreThanTwoConsecutive(col)) {
return false;
}
}
// Vérifier les lignes/colonnes identiques uniquement en mode difficile
if (difficulty === 'hard' && hasDuplicateLines(grid)) {
return false;
}
return true;
};
// Vérifie si la grille est complète
export const isGridComplete = (grid: Grid): boolean => {
return grid.every(row => row.every(cell => cell !== null));
};
// Calcule le score en fonction du temps et des erreurs
export const calculateScore = (
difficulty: 'easy' | 'medium' | 'hard',
timeInSeconds: number,
mistakes: number,
gridSize: GridSize
): number => {
const baseScore = 1000;
const sizeMultiplier = (gridSize.rows * gridSize.cols) / 64; // 8x8 comme référence
const timeMultiplier = Math.max(0, 1 - timeInSeconds / 1800); // 30 minutes max
const mistakePenalty = Math.max(0, 1 - (mistakes * 0.1)); // -10% par erreur
const difficultyMultiplier = {
easy: 1,
medium: 1.5,
hard: 2,
}[difficulty];
return Math.round(
baseScore *
sizeMultiplier *
difficultyMultiplier *
timeMultiplier *
mistakePenalty
);
};

View file

@ -0,0 +1,76 @@
'use client';
import { Box, TextField, useTheme } from '@mui/material';
import { useState } from 'react';
interface SudokuGridProps {
initialGrid: (number | null)[][];
onCellChange: (row: number, col: number, value: number | null) => void;
}
export const SudokuGrid = ({ initialGrid, onCellChange }: SudokuGridProps) => {
const theme = useTheme();
const [grid, setGrid] = useState(initialGrid);
const handleChange = (row: number, col: number, value: string) => {
const numValue = value === '' ? null : parseInt(value);
if (numValue === null || (numValue >= 1 && numValue <= 9)) {
const newGrid = grid.map((r, i) =>
r.map((c, j) => (i === row && j === col ? numValue : c))
);
setGrid(newGrid);
onCellChange(row, col, numValue);
}
};
return (
<Box
sx={{
display: 'grid',
gridTemplateColumns: 'repeat(9, 1fr)',
gap: 0.5,
width: '100%',
maxWidth: '500px',
margin: '0 auto',
'& > div': {
aspectRatio: '1/1',
},
}}
>
{grid.map((row, rowIndex) =>
row.map((cell, colIndex) => (
<TextField
key={`${rowIndex}-${colIndex}`}
value={cell || ''}
onChange={(e) => handleChange(rowIndex, colIndex, e.target.value)}
inputProps={{
maxLength: 1,
style: {
padding: 0,
textAlign: 'center',
fontSize: '1.2rem',
height: '100%',
},
}}
sx={{
'& .MuiOutlinedInput-root': {
height: '100%',
backgroundColor: theme.palette.background.paper,
borderRadius: 0,
borderRight: (colIndex + 1) % 3 === 0 ? 2 : 1,
borderBottom: (rowIndex + 1) % 3 === 0 ? 2 : 1,
borderColor: theme.palette.divider,
'&:hover': {
borderColor: theme.palette.primary.main,
},
'&.Mui-focused': {
borderColor: theme.palette.primary.main,
},
},
}}
/>
))
)}
</Box>
);
};

View file

@ -0,0 +1,72 @@
'use client';
import { useEffect, useRef, useState, useCallback } from 'react';
import { Typography } from '@mui/material';
interface TimerProps {
isRunning: boolean;
onTimeUpdate?: (seconds: number) => void;
}
export const Timer = ({ isRunning, onTimeUpdate }: TimerProps) => {
const [seconds, setSeconds] = useState(0);
const intervalRef = useRef<NodeJS.Timeout>();
const lastUpdateRef = useRef<number>(0);
const isFirstRender = useRef(true);
const secondsRef = useRef(0);
const updateTimer = useCallback(() => {
const now = Date.now();
const delta = Math.floor((now - lastUpdateRef.current) / 1000);
if (delta > 0) {
lastUpdateRef.current = now;
secondsRef.current += delta;
setSeconds(secondsRef.current);
}
}, []);
useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false;
return;
}
if (isRunning) {
lastUpdateRef.current = Date.now();
intervalRef.current = setInterval(updateTimer, 1000);
} else {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
}
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, [isRunning, updateTimer]);
useEffect(() => {
if (onTimeUpdate) {
onTimeUpdate(secondsRef.current);
}
}, [seconds, onTimeUpdate]);
const formatTime = (totalSeconds: number) => {
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')}`;
}
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
};
return (
<Typography variant="h6" component="div">
{formatTime(seconds)}
</Typography>
);
};

View file

@ -0,0 +1,148 @@
type Grid = (number | null)[][];
// Vérifie si un nombre peut être placé dans une cellule
const isValid = (grid: Grid, row: number, col: number, num: number): boolean => {
// Vérifie la ligne
for (let x = 0; x < 9; x++) {
if (grid[row][x] === num) return false;
}
// Vérifie la colonne
for (let x = 0; x < 9; x++) {
if (grid[x][col] === num) return false;
}
// Vérifie le bloc 3x3
const startRow = Math.floor(row / 3) * 3;
const startCol = Math.floor(col / 3) * 3;
for (let i = 0; i < 3; i++) {
for (let j = 0; j < 3; j++) {
if (grid[i + startRow][j + startCol] === num) return false;
}
}
return true;
};
// Résout une grille de Sudoku
const solveSudoku = (grid: Grid): boolean => {
let row = 0;
let col = 0;
let isEmpty = false;
// Trouve une cellule vide
for (let i = 0; i < 9; i++) {
for (let j = 0; j < 9; j++) {
if (grid[i][j] === null) {
row = i;
col = j;
isEmpty = true;
break;
}
}
if (isEmpty) break;
}
// Si toutes les cellules sont remplies, la grille est résolue
if (!isEmpty) return true;
// Essaie les chiffres 1-9
for (let num = 1; num <= 9; num++) {
if (isValid(grid, row, col, num)) {
grid[row][col] = num;
if (solveSudoku(grid)) return true;
grid[row][col] = null;
}
}
return false;
};
// Génère une nouvelle grille de Sudoku
export const generateSudoku = (difficulty: 'easy' | 'medium' | 'hard'): Grid => {
// Crée une grille vide
const grid: Grid = Array(9).fill(null).map(() => Array(9).fill(null));
// Remplit quelques cellules aléatoirement
for (let i = 0; i < 3; i++) {
const num = Math.floor(Math.random() * 9) + 1;
const row = Math.floor(Math.random() * 9);
const col = Math.floor(Math.random() * 9);
if (isValid(grid, row, col, num)) {
grid[row][col] = num;
}
}
// Résout la grille
solveSudoku(grid);
// Détermine le nombre de cellules à retirer selon la difficulté
const cellsToRemove = {
easy: 40,
medium: 50,
hard: 60,
}[difficulty];
// Retire des cellules aléatoirement
let removed = 0;
while (removed < cellsToRemove) {
const row = Math.floor(Math.random() * 9);
const col = Math.floor(Math.random() * 9);
if (grid[row][col] !== null) {
grid[row][col] = null;
removed++;
}
}
return grid;
};
// Vérifie si la grille est valide
export const validateGrid = (grid: Grid): boolean => {
// Vérifie chaque ligne
for (let row = 0; row < 9; row++) {
const nums = new Set();
for (let col = 0; col < 9; col++) {
const num = grid[row][col];
if (num !== null) {
if (nums.has(num)) return false;
nums.add(num);
}
}
}
// Vérifie chaque colonne
for (let col = 0; col < 9; col++) {
const nums = new Set();
for (let row = 0; row < 9; row++) {
const num = grid[row][col];
if (num !== null) {
if (nums.has(num)) return false;
nums.add(num);
}
}
}
// Vérifie chaque bloc 3x3
for (let block = 0; block < 9; block++) {
const nums = new Set();
const startRow = Math.floor(block / 3) * 3;
const startCol = (block % 3) * 3;
for (let i = 0; i < 3; i++) {
for (let j = 0; j < 3; j++) {
const num = grid[startRow + i][startCol + j];
if (num !== null) {
if (nums.has(num)) return false;
nums.add(num);
}
}
}
}
return true;
};
// Vérifie si la grille est complète
export const isGridComplete = (grid: Grid): boolean => {
return grid.every(row => row.every(cell => cell !== null)) && validateGrid(grid);
};

View file

@ -0,0 +1,57 @@
'use client';
import { createContext, useState, useMemo, ReactNode, useEffect } from 'react';
import { ThemeProvider, PaletteMode } from '@mui/material';
import { getTheme } from '@/theme/theme';
interface ColorModeContextType {
toggleColorMode: () => void;
mode: PaletteMode;
}
export const ColorModeContext = createContext<ColorModeContextType>({
toggleColorMode: () => {},
mode: 'light',
});
interface ColorModeProviderProps {
children: ReactNode;
}
export const ColorModeProvider = ({ children }: ColorModeProviderProps) => {
const [mode, setMode] = useState<PaletteMode>('light');
// Charger le thème depuis localStorage au montage
useEffect(() => {
const savedMode = localStorage.getItem('themeMode') as PaletteMode;
if (savedMode) {
setMode(savedMode);
} else {
// Détecter le thème système par défaut
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
setMode(prefersDark ? 'dark' : 'light');
}
}, []);
const colorMode = useMemo(
() => ({
toggleColorMode: () => {
setMode((prevMode) => {
const newMode = prevMode === 'light' ? 'dark' : 'light';
localStorage.setItem('themeMode', newMode);
return newMode;
});
},
mode,
}),
[mode]
);
const theme = useMemo(() => getTheme(mode), [mode]);
return (
<ColorModeContext.Provider value={colorMode}>
<ThemeProvider theme={theme}>{children}</ThemeProvider>
</ColorModeContext.Provider>
);
};

View file

@ -0,0 +1,40 @@
import { useState, useEffect } from 'react';
export function useLocalStorage<T>(key: string, initialValue: T) {
// État pour stocker notre valeur
// Passe la fonction d'initialisation à useState pour que la logique ne s'exécute qu'une seule fois
const [storedValue, setStoredValue] = useState<T>(() => {
if (typeof window === 'undefined') {
return initialValue;
}
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.warn(`Error reading localStorage key "${key}":`, error);
return initialValue;
}
});
// Retourne une version enveloppée de la fonction setState de useState qui ...
// ... persiste la nouvelle valeur dans localStorage.
const setValue = (value: T | ((val: T) => T)) => {
try {
// Permet à la valeur d'être une fonction pour que nous ayons la même API que useState
const valueToStore = value instanceof Function ? value(storedValue) : value;
// Sauvegarde l'état
setStoredValue(valueToStore);
// Sauvegarde dans localStorage
if (typeof window !== 'undefined') {
window.localStorage.setItem(key, JSON.stringify(valueToStore));
}
} catch (error) {
console.warn(`Error setting localStorage key "${key}":`, error);
}
};
return [storedValue, setValue] as const;
}

50
src/theme/theme.ts Normal file
View file

@ -0,0 +1,50 @@
import { createTheme, PaletteMode } from '@mui/material';
export const getTheme = (mode: PaletteMode) => {
return createTheme({
palette: {
mode,
primary: {
main: mode === 'light' ? '#2C3E50' : '#3498DB',
contrastText: '#fff',
},
secondary: {
main: mode === 'light' ? '#E74C3C' : '#E74C3C',
},
background: {
default: mode === 'light' ? '#F5F6FA' : '#1A1A2E',
paper: mode === 'light' ? '#FFFFFF' : '#16213E',
},
},
typography: {
fontFamily: '"Inter", "Roboto", "Helvetica", "Arial", sans-serif',
h1: {
fontSize: '2.5rem',
fontWeight: 600,
},
h2: {
fontSize: '2rem',
fontWeight: 500,
},
body1: {
fontSize: '1rem',
lineHeight: 1.5,
},
},
components: {
MuiAppBar: {
styleOverrides: {
root: {
backgroundColor: mode === 'light' ? '#FFFFFF' : '#16213E',
color: mode === 'light' ? '#2C3E50' : '#FFFFFF',
boxShadow: 'none',
borderBottom: `1px solid ${mode === 'light' ? '#E0E0E0' : '#2C3E50'}`,
},
},
},
},
shape: {
borderRadius: 8,
},
});
};