mirror of
https://github.com/arthur-pbty/sudoku.git
synced 2026-06-03 23:36:39 +02:00
feat: Implement Sudoku game with grid generation, validation, and solving features
- Added Sudoku grid generation with varying difficulty levels (easy, medium, hard). - Implemented Sudoku solving functionality. - Created a user interface for inputting and checking Sudoku solutions. - Added validation for user inputs and error handling for invalid grids. - Introduced a Docker configuration for easy deployment.
This commit is contained in:
@@ -30,6 +30,9 @@ yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# local editor settings (can contain infrastructure credentials)
|
||||
.vscode/
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
|
||||
@@ -1,36 +1,63 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
# Sudoku
|
||||
|
||||
## Getting Started
|
||||
Application Sudoku developpee avec Next.js (App Router), React et TypeScript.
|
||||
|
||||
First, run the development server:
|
||||
Site en production: [sudoku.arthurp.fr](https://sudoku.arthurp.fr)
|
||||
|
||||
## Fonctionnalites
|
||||
|
||||
- Generation de grilles Sudoku.
|
||||
- Interface web simple et rapide a charger.
|
||||
- Build de production via Next.js.
|
||||
|
||||
## Stack technique
|
||||
|
||||
- Next.js 16
|
||||
- React 19
|
||||
- TypeScript
|
||||
- ESLint
|
||||
|
||||
## Lancer en local
|
||||
|
||||
Prerequis:
|
||||
|
||||
- Node.js 20+
|
||||
- npm
|
||||
|
||||
Installation et demarrage:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Application disponible sur [http://localhost:3000](http://localhost:3000).
|
||||
|
||||
## Scripts utiles
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
npm run build
|
||||
npm run start
|
||||
npm run lint
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
## Lancer avec Docker Compose
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
Le service expose l'application sur le port `3005` de la machine locale.
|
||||
|
||||
## Learn More
|
||||
## Deploiement
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
Workflow recommande avant push GitHub:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
1. Verifier la qualite du code: `npm run lint`
|
||||
2. Verifier le build de prod: `npm run build`
|
||||
3. Verifier les fichiers a publier: `git status` puis `git add` cible
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
## Backlink
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
Ce depot supporte le site Sudoku publie ici: [https://sudoku.arthurp.fr](https://sudoku.arthurp.fr)
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
services:
|
||||
sudoku-app:
|
||||
image: node:20-alpine
|
||||
working_dir: /app
|
||||
volumes:
|
||||
- ./:/app
|
||||
command: sh -c "npm install && npm run build && npm start"
|
||||
ports:
|
||||
- "3005:3000"
|
||||
restart: unless-stopped
|
||||
Generated
+390
-335
File diff suppressed because it is too large
Load Diff
+204
-56
@@ -1,65 +1,213 @@
|
||||
import Image from "next/image";
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import { generateSudoku, solveSudoku, Difficulty, SudokuGrid } from "./sudokuGenerator";
|
||||
|
||||
function cloneGrid(grid: SudokuGrid): SudokuGrid {
|
||||
return grid.map(row => [...row]);
|
||||
}
|
||||
|
||||
function getInvalidCells(grid: SudokuGrid): boolean[][] {
|
||||
const invalid: boolean[][] = Array.from({ length: 9 }, () => Array(9).fill(false));
|
||||
for (let row = 0; row < 9; row++) {
|
||||
for (let col = 0; col < 9; col++) {
|
||||
const val = grid[row][col];
|
||||
if (val === 0) continue;
|
||||
for (let k = 0; k < 9; k++) {
|
||||
if (k !== col && grid[row][k] === val) invalid[row][col] = true;
|
||||
if (k !== row && grid[k][col] === val) invalid[row][col] = true;
|
||||
}
|
||||
const startRow = row - row % 3;
|
||||
const startCol = col - col % 3;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
for (let j = 0; j < 3; j++) {
|
||||
const r = startRow + i;
|
||||
const c = startCol + j;
|
||||
if ((r !== row || c !== col) && grid[r][c] === val) invalid[row][col] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return invalid;
|
||||
}
|
||||
|
||||
function SudokuBoard({ grid, onChange, editable, invalidCells, fixedCells }: { grid: SudokuGrid; onChange?: (row: number, col: number, value: number) => void; editable?: boolean; invalidCells?: boolean[][]; fixedCells?: boolean[][] }) {
|
||||
return (
|
||||
<table className="border border-gray-400 mx-auto" style={{background:'#fff',borderRadius:16,boxShadow:'0 2px 16px #e0e0e0'}}>
|
||||
<tbody>
|
||||
{grid.map((row, i) => (
|
||||
<tr key={i}>
|
||||
{row.map((cell, j) => (
|
||||
<td key={j} style={{width:40,height:40,border:'1px solid #bdbdbd',textAlign:'center',background:'#fafafa',fontSize:20,fontWeight:'bold',color:'#222',borderRight:(j%3===2&&j!==8)?'2px solid #222':'',borderBottom:(i%3===2&&i!==8)?'2px solid #222':''}}>
|
||||
{editable && onChange ? (
|
||||
fixedCells && fixedCells[i][j] ? (
|
||||
<span style={{color:'#222',fontWeight:'bold',userSelect:'none'}}>{cell}</span>
|
||||
) : (
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={9}
|
||||
value={cell === 0 ? "" : cell}
|
||||
onChange={e => onChange(i, j, Number(e.target.value))}
|
||||
style={{width:'100%',height:'100%',textAlign:'center',background:'#fff',border:'none',outline:'none',fontSize:20,fontWeight:'bold',color:invalidCells && invalidCells[i][j] ? '#d32f2f' : '#222'}}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
cell !== 0 ? (
|
||||
<span style={{color:invalidCells && invalidCells[i][j] ? '#d32f2f' : '#222',fontWeight:'bold'}}>{cell}</span>
|
||||
) : ""
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const [difficulty, setDifficulty] = useState<Difficulty>("easy");
|
||||
const [grid, setGrid] = useState<SudokuGrid | null>(null);
|
||||
const [solvedGrid, setSolvedGrid] = useState<SudokuGrid | null>(null);
|
||||
const [error, setError] = useState<string>("");
|
||||
const [customGrid, setCustomGrid] = useState<SudokuGrid>(Array.from({ length: 9 }, () => Array(9).fill(0)));
|
||||
const [showCustom, setShowCustom] = useState(false);
|
||||
const [userGrid, setUserGrid] = useState<SudokuGrid | null>(null);
|
||||
const [checkResult, setCheckResult] = useState<string>("");
|
||||
|
||||
const handleGenerate = () => {
|
||||
const generated = generateSudoku(difficulty);
|
||||
setGrid(generated);
|
||||
setUserGrid(cloneGrid(generated));
|
||||
setSolvedGrid(null);
|
||||
setError("");
|
||||
setShowCustom(false);
|
||||
setCheckResult("");
|
||||
};
|
||||
|
||||
const handleUserChange = (row: number, col: number, value: number) => {
|
||||
if (!userGrid) return;
|
||||
if (value < 0 || value > 9) return;
|
||||
setUserGrid(prev => {
|
||||
if (!prev) return null;
|
||||
const copy = cloneGrid(prev);
|
||||
copy[row][col] = value;
|
||||
return copy;
|
||||
});
|
||||
};
|
||||
|
||||
const handleCheck = () => {
|
||||
if (!userGrid || !grid) return;
|
||||
const invalid = getInvalidCells(userGrid);
|
||||
const hasInvalid = invalid.flat().some(Boolean);
|
||||
if (hasInvalid) {
|
||||
setCheckResult("Erreur : La grille contient des chiffres en conflit (ligne, colonne ou bloc). Les chiffres invalides sont en rouge.");
|
||||
return;
|
||||
}
|
||||
for (let i = 0; i < 9; i++) {
|
||||
for (let j = 0; j < 9; j++) {
|
||||
if (userGrid[i][j] === 0) {
|
||||
setCheckResult("La grille n'est pas entièrement remplie.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < 9; i++) {
|
||||
for (let j = 0; j < 9; j++) {
|
||||
if (grid[i][j] !== 0 && userGrid[i][j] !== grid[i][j]) {
|
||||
setCheckResult("Erreur : Vous avez modifié une case pré-remplie.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
setCheckResult("Bravo ! La solution est correcte.");
|
||||
};
|
||||
|
||||
const handleSolve = () => {
|
||||
if (!grid) return;
|
||||
const gridCopy = cloneGrid(grid);
|
||||
const solved = solveSudoku(gridCopy);
|
||||
if (solved) {
|
||||
setSolvedGrid(gridCopy);
|
||||
setError("");
|
||||
} else {
|
||||
setError("Grille non résoluble.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleCustomSolve = () => {
|
||||
const invalid = getInvalidCells(customGrid);
|
||||
const hasInvalid = invalid.flat().some(Boolean);
|
||||
if (hasInvalid) {
|
||||
setSolvedGrid(null);
|
||||
setError("Erreur : La grille contient des chiffres en conflit (ligne, colonne ou bloc). Les chiffres invalides sont en rouge.");
|
||||
return;
|
||||
}
|
||||
const gridCopy = cloneGrid(customGrid);
|
||||
const solved = solveSudoku(gridCopy);
|
||||
if (solved) {
|
||||
setSolvedGrid(gridCopy);
|
||||
setError("");
|
||||
} else {
|
||||
setSolvedGrid(null);
|
||||
setError("Grille non résoluble.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleCustomChange = (row: number, col: number, value: number) => {
|
||||
if (value < 0 || value > 9) return;
|
||||
setCustomGrid(prev => {
|
||||
const copy = cloneGrid(prev);
|
||||
copy[row][col] = value;
|
||||
return copy;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={100}
|
||||
height={20}
|
||||
priority
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
||||
To get started, edit the page.tsx file.
|
||||
</h1>
|
||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||
Looking for a starting point or more instructions? Head over to{" "}
|
||||
<a
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Templates
|
||||
</a>{" "}
|
||||
or the{" "}
|
||||
<a
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Learning
|
||||
</a>{" "}
|
||||
center.
|
||||
</p>
|
||||
<div style={{minHeight:'100vh',background:'#f5f5f5',display:'flex',flexDirection:'column',alignItems:'center',justifyContent:'flex-start',fontFamily:'Inter, Arial, sans-serif',padding:'32px 0'}}>
|
||||
<div style={{width:'100%',maxWidth:480,background:'#fff',borderRadius:24,boxShadow:'0 2px 24px #e0e0e0',padding:'32px 24px',margin:'24px 0'}}>
|
||||
<h1 style={{fontSize:32,fontWeight:800,marginBottom:24,color:'#222',textAlign:'center',letterSpacing:'-1px'}}>Sudoku</h1>
|
||||
<div style={{display:'flex',gap:12,flexWrap:'wrap',justifyContent:'center',alignItems:'center',marginBottom:24}}>
|
||||
<label style={{fontWeight:600,color:'#222'}}>Niveau :</label>
|
||||
<select value={difficulty} onChange={e => setDifficulty(e.target.value as Difficulty)} style={{padding:'6px 12px',borderRadius:8,border:'1px solid #bdbdbd',fontWeight:500,color:'#222',background:'#fafafa'}}>
|
||||
<option value="easy">Facile</option>
|
||||
<option value="medium">Moyen</option>
|
||||
<option value="hard">Difficile</option>
|
||||
</select>
|
||||
<button onClick={handleGenerate} style={{padding:'8px 18px',background:'#1976d2',color:'#fff',border:'none',borderRadius:8,fontWeight:600,cursor:'pointer',boxShadow:'0 1px 4px #e0e0e0'}}>Générer</button>
|
||||
<button onClick={handleSolve} style={{padding:'8px 18px',background:'#388e3c',color:'#fff',border:'none',borderRadius:8,fontWeight:600,cursor:'pointer',boxShadow:'0 1px 4px #e0e0e0'}} disabled={!grid}>Résoudre</button>
|
||||
<button onClick={() => {setShowCustom(true); setGrid(null); setSolvedGrid(null); setError("");}} style={{padding:'8px 18px',background:'#fbc02d',color:'#222',border:'none',borderRadius:8,fontWeight:600,cursor:'pointer',boxShadow:'0 1px 4px #e0e0e0'}}>Entrer une grille</button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
||||
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"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={16}
|
||||
height={16}
|
||||
{grid && userGrid && (
|
||||
<div style={{marginBottom:32,display:'flex',flexDirection:'column',alignItems:'center'}}>
|
||||
<h2 style={{fontSize:20,fontWeight:700,marginBottom:12,color:'#222'}}>Remplissez la grille</h2>
|
||||
<SudokuBoard
|
||||
grid={userGrid}
|
||||
onChange={handleUserChange}
|
||||
editable={true}
|
||||
invalidCells={getInvalidCells(userGrid)}
|
||||
fixedCells={grid.map(row => row.map(cell => cell !== 0))}
|
||||
/>
|
||||
Deploy Now
|
||||
</a>
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] 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"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
<button onClick={handleCheck} style={{marginTop:16,padding:'8px 18px',background:'#7b1fa2',color:'#fff',border:'none',borderRadius:8,fontWeight:600,cursor:'pointer',boxShadow:'0 1px 4px #e0e0e0'}}>Vérifier</button>
|
||||
{checkResult && <div style={{marginTop:10,color:checkResult.startsWith('Bravo') ? '#388e3c' : '#d32f2f',fontWeight:600}}>{checkResult}</div>}
|
||||
</div>
|
||||
</main>
|
||||
)}
|
||||
{showCustom && (
|
||||
<div style={{marginBottom:32,display:'flex',flexDirection:'column',alignItems:'center'}}>
|
||||
<h2 style={{fontSize:20,fontWeight:700,marginBottom:12,color:'#222'}}>Saisissez votre grille</h2>
|
||||
<SudokuBoard grid={customGrid} onChange={handleCustomChange} editable={true} invalidCells={getInvalidCells(customGrid)} />
|
||||
<button onClick={handleCustomSolve} style={{marginTop:16,padding:'8px 18px',background:'#388e3c',color:'#fff',border:'none',borderRadius:8,fontWeight:600,cursor:'pointer',boxShadow:'0 1px 4px #e0e0e0'}}>Résoudre ma grille</button>
|
||||
</div>
|
||||
)}
|
||||
{solvedGrid && (
|
||||
<div style={{marginBottom:32}}>
|
||||
<h2 style={{fontSize:20,fontWeight:700,marginBottom:12,color:'#222'}}>Solution</h2>
|
||||
<SudokuBoard grid={solvedGrid} />
|
||||
</div>
|
||||
)}
|
||||
{error && <div style={{color:'#d32f2f',marginTop:16,fontWeight:600}}>{error}</div>}
|
||||
</div>
|
||||
<footer style={{marginTop:24,color:'#222',fontWeight:500,fontSize:14,opacity:0.7}}>© 2026 Sudoku App.</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
// Générateur de grilles de sudoku avec différents niveaux de difficulté
|
||||
// Facile, Moyen, Difficile
|
||||
|
||||
export type Difficulty = 'easy' | 'medium' | 'hard';
|
||||
|
||||
export type SudokuGrid = number[][];
|
||||
|
||||
function shuffle<T>(array: T[]): T[] {
|
||||
for (let i = array.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[array[i], array[j]] = [array[j], array[i]];
|
||||
}
|
||||
return array;
|
||||
}
|
||||
|
||||
function isSafe(grid: SudokuGrid, row: number, col: number, num: number): boolean {
|
||||
for (let x = 0; x < 9; x++) {
|
||||
if (grid[row][x] === num || grid[x][col] === num) return false;
|
||||
}
|
||||
const startRow = row - row % 3;
|
||||
const startCol = col - col % 3;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
for (let j = 0; j < 3; j++) {
|
||||
if (grid[startRow + i][startCol + j] === num) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function fillGrid(grid: SudokuGrid): boolean {
|
||||
for (let row = 0; row < 9; row++) {
|
||||
for (let col = 0; col < 9; col++) {
|
||||
if (grid[row][col] === 0) {
|
||||
const numbers = shuffle([1,2,3,4,5,6,7,8,9]);
|
||||
for (const num of numbers) {
|
||||
if (isSafe(grid, row, col, num)) {
|
||||
grid[row][col] = num;
|
||||
if (fillGrid(grid)) return true;
|
||||
grid[row][col] = 0;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function removeCells(grid: SudokuGrid, difficulty: Difficulty): SudokuGrid {
|
||||
const gridCopy = grid.map(row => [...row]);
|
||||
let cellsToRemove;
|
||||
switch (difficulty) {
|
||||
case 'easy': cellsToRemove = 36; break;
|
||||
case 'medium': cellsToRemove = 46; break;
|
||||
case 'hard': cellsToRemove = 54; break;
|
||||
default: cellsToRemove = 36;
|
||||
}
|
||||
while (cellsToRemove > 0) {
|
||||
const row = Math.floor(Math.random() * 9);
|
||||
const col = Math.floor(Math.random() * 9);
|
||||
if (gridCopy[row][col] !== 0) {
|
||||
gridCopy[row][col] = 0;
|
||||
cellsToRemove--;
|
||||
}
|
||||
}
|
||||
return gridCopy;
|
||||
}
|
||||
|
||||
|
||||
export function generateSudoku(difficulty: Difficulty = 'easy'): SudokuGrid {
|
||||
const grid: SudokuGrid = Array.from({ length: 9 }, () => Array(9).fill(0));
|
||||
fillGrid(grid);
|
||||
return removeCells(grid, difficulty);
|
||||
}
|
||||
|
||||
// Résolution d'une grille de sudoku
|
||||
export function solveSudoku(grid: SudokuGrid): boolean {
|
||||
for (let row = 0; row < 9; row++) {
|
||||
for (let col = 0; col < 9; col++) {
|
||||
if (grid[row][col] === 0) {
|
||||
for (let num = 1; num <= 9; num++) {
|
||||
if (isSafe(grid, row, col, num)) {
|
||||
grid[row][col] = num;
|
||||
if (solveSudoku(grid)) return true;
|
||||
grid[row][col] = 0;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
Reference in New Issue
Block a user