This commit is contained in:
Adrien MALINGREY 2020-10-05 08:09:09 +02:00
parent d752ed6b54
commit a0af1272cf
9 changed files with 511 additions and 497 deletions

19
LICENSE
View File

@ -1,19 +0,0 @@
MIT License Copyright (c) <year> <copyright holders>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished
to do so, subject to the following conditions:
The above copyright notice and this permission notice (including the next
paragraph) shall be included in all copies or substantial portions of the
Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS
OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF
OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@ -1,3 +0,0 @@
# Sudoku
Web sudoku assistant

101
game.php Normal file
View File

@ -0,0 +1,101 @@
<?php
$gridStr = basename(strip_tags($_SERVER["REQUEST_URI"]));
// URL contains grid
if (preg_match("#^[1-9?]{81}$#", $gridStr)) {
?>
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<title>Sudoku</title>
<link rel="stylesheet" type="text/css" href="style.css" />
<script type="text/javascript" src="sudoku.js"></script>
</head>
<body>
<header>
<h1>Sudoku</h1>
</header>
<form id="sudokuForm">
<div>
<table id="grid">
<?php
for ($row = 0; $row < 9; $row++) {
?>
<tr>
<?php
for ($column = 0; $column < 9; $column++) {
switch($row%3) {
case 0: $classRegionRow = "regionTop"; break;
case 1: $classRegionRow = "regionMiddle"; break;
case 2: $classRegionRow = "regionBottom"; break;
}
switch($column%3) {
case 0: $classRegionColumn = "regionLeft"; break;
case 1: $classRegionColumn = "regionCenter"; break;
case 2: $classRegionColumn = "regionRight"; break;
}
$value = $gridStr[9*$row+$column];
if ($value == "?") {
$value = "";
$readonly = "";
} else {
$readonly = "readonly='true'";
}
echo " <td class='$classRegionRow $classRegionColumn'><input type='text' inputmode='numeric' minlength=0 maxlength=1 value='$value' $readonly /></td>";
}
?>
</tr>
<?php
}
?>
</table>
</div>
<div id="buttons">
<?php
for($value=1; $value<=9; $value++) {
echo "<button type='button' onclick='showValue(this)'>$value</button>";
}
?>
</div>
<div>
<button type="reset">Tout effacer</button>
</div>
</form>
<div id=help>
Remplissez la grille de sorte que chaque ligne, colonne et région (carré de 3×3 cases) contienne tous les chiffres de 1 à 9.
</div>
<div id=links>
<a href="">Lien vers cette grille</a><br/>
<a href=".">Nouvelle grille</a>
</div>
</body>
</html>
<?php
} else {
header("HTTP/1.0 400 Bad Request", true, 400);
?>
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<title>Grille incorrecte</title>
<link rel="stylesheet" type="text/css" href="style.css" />
</head>
<body>
<header>
<h1>Grille incorrecte</h1>
</header>
L'adresse URL doit être de la forme : <?=$_SERVER["REQUEST_SCHEME"]?>://<?=$_SERVER["HTTP_HOST"] . dirname($_SERVER["DOCUMENT_URI"])?>/<em>grille</em>,<br/>
<em>grille</em> étant une suite de 81 caractères représentant la grille de gauche à droite puis de haut en bas, soit :
<ul>
<li>un chiffre entre 1 et 9 pour les cases connues</li>
<li>? pour les case vides</li>
</ul>
<a href=".">Cliquez ici pour générer une nouvelle grille</a>
</body>
</html>
<?php
}
?>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

View File

@ -1,49 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<title>Sudoku</title>
<link rel="stylesheet" type="text/css" href="style.css" />
<script type="text/javascript" src="sudoku.js"></script>
</head>
<body>
<header>
<h1>Sudoku</h1>
</header>
<form id="sudokuForm">
<div>
<table id="gridTable" class="grid"></table>
<script>createGrid()</script>
</div>
</form>
<div>
Surligner les cases pouvant contenir
</div>
<div id="highlightRadiosDiv">
<input type="radio" id="highlightRadio1" name="highlightRadio" onclick="highlight('1')"><label id="highlightLabel1" for="highlightRadio1">1</label>
<input type="radio" id="highlightRadio2" name="highlightRadio" onclick="highlight('2')"><label id="highlightLabel2" for="highlightRadio2">2</label>
<input type="radio" id="highlightRadio3" name="highlightRadio" onclick="highlight('3')"><label id="highlightLabel3" for="highlightRadio3">3</label>
<input type="radio" id="highlightRadio4" name="highlightRadio" onclick="highlight('4')"><label id="highlightLabel4" for="highlightRadio4">4</label>
<input type="radio" id="highlightRadio5" name="highlightRadio" onclick="highlight('5')"><label id="highlightLabel5" for="highlightRadio5">5</label>
<input type="radio" id="highlightRadio6" name="highlightRadio" onclick="highlight('6')"><label id="highlightLabel6" for="highlightRadio6">6</label>
<input type="radio" id="highlightRadio7" name="highlightRadio" onclick="highlight('7')"><label id="highlightLabel7" for="highlightRadio7">7</label>
<input type="radio" id="highlightRadio8" name="highlightRadio" onclick="highlight('8')"><label id="highlightLabel8" for="highlightRadio8">8</label>
<input type="radio" id="highlightRadio9" name="highlightRadio" onclick="highlight('9')"><label id="highlightLabel9" for="highlightRadio9">9</label>
<input type="radio" id="highlightRadioNone" name="highlightRadio" onclick="highlight(null)"><label for="highlightRadioNone">Effacer</label>
</div>
<div>
<button id="clearButton" type="button" onclick="erase()">Effacer tout</button>
<button id="solveButton" type="button" onclick="solve()">Montrer la solution</button>
</div>
<div>
<button type="button" onclick="generateGrid()">Nouvelle partie</button>
<button id="customGridButton" type="button" onclick="customGrid()">Grille personnalisée</button>
</div>
Pour partager cette grille, copiez le lien suivant :<br/>
<div id="shareDiv">
<a id="shareA" href="">jeux.malingrey.fr/sudoku/<br/></a>
</div>
<script>loadGrid()</script>
</body>
</html>

163
index.php Normal file
View File

@ -0,0 +1,163 @@
<?php
function isUnknown($box) {
return $box->value == "?";
}
function easyFirst($box1, $box2) {
return count($box1->allowedValues) - count($box2->allowedValues);
}
function array_unset_value($value, &$array) {
$key = array_search($value, $array);
if ($key !== false) {
unset($array[$key]);
return true;
} else {
return false;
}
}
class Box {
public $values = array("1", "2", "3", "4", "5", "6", "7", "8", "9");
function __construct($rowId, $columnId, $regionId) {
$this->value = "?";
$this->rowId = $rowId;
$this->columnId = $columnId;
$this->regionId = $regionId;
$this->allowedValues = $this->values;
$this->testValueWasAllowed = array();
$this->neighbourhood = array();
}
function searchAllowedValues() {
$this->allowedValues = $this->values;
forEach($this->neighbourhood as $neighbour) {
if ($neighbour->value != "?")
array_unset_value($neighbour->value, $this->allowedValues);
}
}
}
class Grid {
function __construct() {
$this->boxes = array();
$this->rows = array_fill(0, 9, array());
$this->columns = array_fill(0, 9, array());
$this->regions = array_fill(0, 9, array());
for ($regionRowId = 0; $regionRowId < 3; $regionRowId++) {
for($regionColumnId = 0; $regionColumnId < 3; $regionColumnId++) {
for ($rowId = 3*$regionRowId; $rowId < 3*($regionRowId+1); $rowId++) {
for ($columnId = 3*$regionColumnId; $columnId < 3*($regionColumnId+1); $columnId++) {
$regionId = 3*$regionRowId + $regionColumnId;
$box = new Box($rowId, $columnId, $regionId);
$this->boxes[] = $box;
$this->rows[$rowId][] = $box;
$this->columns[$columnId][] = $box;
$this->regions[$regionId][] = $box;
}
}
}
}
// box.neighbourhood: boxes in the same row, column and region as box
foreach($this->boxes as $box) {
foreach(array_merge($this->rows[$box->rowId], $this->columns[$box->columnId], $this->regions[$box->regionId]) as $neighbour)
if ($box != $neighbour && !in_array($neighbour, $box->neighbourhood))
$box->neighbourhood[] = $neighbour;
}
// Init with a shuffle row
$values = array("1", "2", "3", "4", "5", "6", "7", "8", "9");
shuffle($values);
forEach($values as $columnId => $value) {
$box = $this->rows[0][$columnId];
$box->value = $value;
forEach($box->neighbourhood as $neighbour)
array_unset_value($box->value, $neighbour->allowedValues);
}
// Fill grid
$this->findSolutions(true, 1, 4)->current();
// Remove clues while there is still a unique solution
$untestedBoxes = $this->boxes;
shuffle($untestedBoxes);
$nbClues = count($untestedBoxes);
while(count($untestedBoxes)) {
$testBoxes = array(array_pop($untestedBoxes));
if ($nbClues >=30)
$testBoxes[] = $this->rows[8-$testBoxes[0]->rowId][8-$testBoxes[0]->columnId];
if ($nbClues >=61) {
$testBoxes[] = $this->rows[8-$testBoxes[0]->rowId][$testBoxes[0]->columnId];
$testBoxes[] = $this->rows[$testBoxes[0]->rowId][8-$testBoxes[0]->columnId];
}
$erasedValues = array();
forEach($testBoxes as $testBox) {
$erasedValues[] = $testBox->value;
$testBox->value = "?";
forEach($testBox->neighbourhood as $neighbour)
$neighbour->searchAllowedValues();
}
$solutions = array();
forEach($this->findSolutions(false, 2, 4) as $solution) $solutions[$solution] = true;
if (count($solutions) == 1) {
$nbClues -= count($testBoxes);
forEach($testBoxes as $testBox) array_unset_value($testBox, $untestedBoxes);
} else {
forEach($testBoxes as $i => $box) {
$box->value = $erasedValues[$i];
forEach($box->neighbourhood as $neighbour) array_unset_value($box->value, $neighbour->allowedValues);
}
}
}
}
function findSolutions($randomized=false, $maxSolutions=1, $maxTries=4) {
$emptyBoxes = array_filter($this->boxes, "isUnknown");
if (count($emptyBoxes)) {
if ($randomized) shuffle($emptyBoxes);
usort($emptyBoxes, "easyFirst");
$testBox = $emptyBoxes[0];
$nbSolutionsFound = 0;
$nbTries = 0;
if ($randomized) shuffle($testBox->allowedValues);
foreach($testBox->allowedValues as $testBox->value) {
foreach($testBox->neighbourhood as $neighbour)
$neighbour->testValueWasAllowed[] = array_unset_value($testBox->value, $neighbour->allowedValues);
$correctGrid = true;
foreach(array_filter($testBox->neighbourhood, "isUnknown") as $neighbour) {
if (count($neighbour->allowedValues) == 0) $correctGrid = false;
}
if ($correctGrid) {
foreach($this->findSolutions($randomized, $maxSolutions-$nbSolutionsFound, $maxTries) as $solution) {
yield $solution;
$nbSolutionsFound++;
}
}
forEach($testBox->neighbourhood as $neighbour)
if (array_pop($neighbour->testValueWasAllowed))
$neighbour->allowedValues[] = $testBox->value;
if (($maxSolutions && $nbSolutionsFound >= $maxSolutions) || ++$nbTries >= $maxTries) break;
}
$testBox->value = "?";
} else {
yield $this->toString();
}
}
function toString() {
$str = "";
foreach($this->rows as $row) {
forEach($row as $box) {
$str .= ($box->value? $box->value : "?");
}
}
return $str;
}
}
$grid = new Grid();
header("Location: " . $_SERVER["REQUEST_SCHEME"] . "://" . $_SERVER["HTTP_HOST"] . dirname($_SERVER["DOCUMENT_URI"]) . "/" . $grid->toString());
exit();
?>

23
nginx-example.conf Normal file
View File

@ -0,0 +1,23 @@
location /sudoku/ {
alias /var/www/sudoku/;
index index.php;
try_files $uri $uri/ @sudoku;
location ~ [^/]\.php(/|$) {
fastcgi_split_path_info ^(.+?\.php)(/.*)$;
fastcgi_pass unix:/var/run/php/php7.3-fpm-sudoku.sock;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param REMOTE_USER $remote_user;
fastcgi_param PATH_INFO $fastcgi_path_info;
fastcgi_param SCRIPT_FILENAME $request_filename;
}
}
location @sudoku {
fastcgi_pass unix:/var/run/php/php7.3-fpm-sudoku.sock;
include fastcgi_params;
fastcgi_param REMOTE_USER $remote_user;
fastcgi_param PATH_INFO $fastcgi_path_info;
fastcgi_param SCRIPT_FILENAME sudoku/game.php;
}

142
style.css
View File

@ -1,18 +1,9 @@
body {
/* Background pattern from Toptal Subtle Patterns */
background: url("handmadepaper.png");
text-align: center;
* {
font-family: sans;
}
h1 {
text-align: center;
text-shadow: 1px 1px grey;
text-decoration: underline;
}
table {
border-spacing: 0;
border-collapse: collapse;
}
div {
@ -21,69 +12,128 @@ div {
row-gap: 0.5em;
margin: 1em auto;
justify-content: center;
text-align: center;
}
input[type="radio"] {
margin-top: -1px;
vertical-align: middle;
#grid {
border-spacing: 0;
border-radius: 6px;
}
label.disabled {
color: #aaa;
}
.grid td {
border: 2px solid black;
td {
padding: 0;
}
.region td {
border: 1px solid grey;
#grid tr:first-child td:first-child
{
border-top-left-radius: 6px;
}
.grid input {
#grid tr:first-child td
{
border-top: 2px solid black;
}
#grid tr:first-child td:last-child
{
border-top-right-radius: 6px;
}
#grid tr td:first-child
{
border-left: 2px solid black;
}
#grid tr td:last-child
{
border-right: 2px solid black;
}
#grid tr:last-child td:first-child
{
border-bottom-left-radius: 6px;
}
#grid tr:last-child td
{
border-bottom: 2px solid black;
}
#grid tr:last-child td:last-child
{
border-bottom-right-radius: 6px;
}
.regionTop {
border-top: 1px solid black;
}
.regionMiddle {
border-top: 1px solid grey;
border-bottom: 1px solid grey;
}
.regionBottom {
border-bottom: 1px solid black;
}
.regionLeft {
border-left: 1px solid black;
}
.regionCenter {
border-left: 1px solid grey;
border-right: 1px solid grey;
}
.regionRight {
border-right: 1px solid black;
}
input {
width: 1.6em;
height: 1.6em;
font-size: 1.5em;
border: 0;
padding: 0;
text-align: center;
transition: 0.3s;
}
.grid input:enabled {
font-family: cursive;
input:read-write {
color: darkblue;
background: white;
}
.grid input:disabled {
input:read-only {
color: black;
background: rgba(0, 0, 0, 0.1);
background: #6666ff;
font-weight: bold;
}
.grid input::placeholder {
input.same-value:read-write {
color: #009973;
background: #66ffd9;
}
input.forbidden-value:read-write {
background: #ccffe6;
}
input.same-value:read-only {
color: #00664d;
background: #00e6ac;
}
input::placeholder {
color: #888;
}
.unhighlighted:enabled {
background: transparent;
#buttons {
column-gap: 0.2em;
margin: 0;
}
.highlighted {
background: lightYellow;
}
#shareDiv {
display: block;
line-height: 80%;
}
#shareA {
a {
text-decoration: none;
font-size: 0.8em;
letter-spacing: 0.5em;
}
#highlightRadiosDiv {
column-gap: 0;
}

508
sudoku.js
View File

@ -1,326 +1,55 @@
const VALUES = "123456789"
suggestionTimer = null
highlightedValue = null
function createGrid() {
rows = Array.from(Array(9), x => [])
columns = Array.from(Array(9), x => [])
regions = Array.from(Array(9), x => [])
for (let regionRow = 0; regionRow < 3; regionRow++) {
const gridRow = document.createElement("tr")
for(let regionColumn = 0; regionColumn < 3; regionColumn++) {
const gridCell = document.createElement("td")
const regionTable = document.createElement("table")
regionTable.className = "region"
for (let row = 3*regionRow; row < 3*(regionRow+1); row++) {
const regionTr = document.createElement("tr")
for (let column = 3*regionColumn; column < 3*(regionColumn+1); column++) {
const regionCell = document.createElement("td")
const box = document.createElement("input")
box.type = "text"
box.oninput = oninput
box.oninvalid = oninvalid
box.onfocus = box.select
box.onkeydown = keyboardBrowse
box.setAttribute("inputmode", "numeric")
box.required = false
box.region = 3*regionRow + regionColumn
box.column = column
box.row = row
box.minLength = 0
box.maxLength = 1
box.tabIndex = 9
rows[row].push(box)
columns[column].push(box)
regions[box.region].push(box)
regionCell.appendChild(box)
regionTr.appendChild(regionCell)
}
regionTable.appendChild(regionTr)
}
gridCell.appendChild(regionTable)
gridRow.appendChild(gridCell)
let boxes = []
let rows = Array.from(Array(9), x => [])
let columns = Array.from(Array(9), x => [])
let regions = Array.from(Array(9), x => [])
let suggestionTimer= null
let highlightedValue = ""
window.onload = function() {
let rowId = 0
for (row of grid.getElementsByTagName('tr')) {
let columnId = 0
for (box of row.getElementsByTagName('input')) {
let regionId = rowId - rowId%3 + Math.floor(columnId/3)
if (!box.readOnly) box.select
box.oninput = oninput
box.oninvalid = oninvalid
box.onkeydown = keyboardBrowse
box.rowId = rowId
box.columnId = columnId
box.regionId = regionId
boxes.push(box)
rows[rowId].push(box)
columns[columnId].push(box)
regions[regionId].push(box)
columnId++
}
gridTable.appendChild(gridRow)
rowId++
}
grid = rows.reduce((grid, row) => grid.concat(row), [])
// box.neighbourhood: boxes in the same row, column and region as box
grid.forEach(box => {
box.neighbourhood = new Set(rows[box.row].concat(columns[box.column]).concat(regions[box.region]))
boxes.forEach(box => {
box.neighbourhood = new Set(rows[box.rowId].concat(columns[box.columnId]).concat(regions[box.regionId]))
box.neighbourhood.delete(box)
box.neighbourhood = Array.from(box.neighbourhood)
})
}
function clearGrid() {
clearTimeout(suggestionTimer)
suggestionTimer = null
grid.forEach(box => {
box.value = ""
box.allowedValues = new Set(VALUES)
box.testValueWasAllowed = []
box.pattern = "[1-9]?"
box.placeholder = ""
box.disabled = false
box.required = false
})
enableHighlightOptions()
}
function loadGrid() {
window.onhashchange = function() {
if (location.hash) {
if (location.hash.match(/^#[1-9?]{81}$/)) {
gridAsString = location.hash.substring(1)
if (gridAsString != grid.map(box => box.disabled? box.value : "?").join("")) {
// Load grid from hash if it differs
clearGrid()
if (savedGame = localStorage.getItem(gridAsString + "SavedGame")) {
// We trust grid is valid if it was previously saved
grid.forEach((box, i) => {
box.value = savedGame[i] == "?"? "" : savedGame[i]
box.disabled = Boolean(gridAsString[i] != "?")
})
startTime = Date.now() - localStorage.getItem(gridAsString + "Time")
finishGridLoad()
} else {
// Else we need to check if grid is valid
grid.forEach((box, i) => box.value = gridAsString[i] == "?"? "" : gridAsString[i])
grid.forEach(box => searchAllowedValuesOf(box))
checkGrid()
}
}
} else {
alert(location.hash.substring(1) + " n'est pas une grille valide.")
}
boxes.forEach(searchAllowedValuesOf)
enableButtons()
boxes.forEach(showAllowedValuesOn)
for(box of boxes) {
if (!box.readOnly) {
box.focus()
break
}
}
if (location.hash)
window.onhashchange()
else if ((lastPlayedGrid = localStorage.getItem("lastPlayedGrid")) && localStorage.getItem(lastPlayedGrid + "SavedGame").match("[?]"))
location.hash = lastPlayedGrid
else
generateGrid()
}
function checkGrid() {
if (sudokuForm.checkValidity()) {
switch(new Set(findSolutions(false, 2, 4)).size) {
case 0:
this.reportValidity()
alert("Cette grille n'a pas de solution. Veuillez la corriger.")
break
case 1:
freezeGrid()
break
default:
alert("Cette grille a plusieurs solutions. Rajoutez des indices.")
}
} else {
sudokuForm.reportValidity()
}
}
function freezeGrid() {
grid.forEach(box => box.disabled = Boolean(box.value.length))
startTime = Date.now()
gridAsString = grid.map(box => box.disabled? box.value : "?").join("")
location.hash = gridAsString
finishGridLoad()
}
function finishGridLoad() {
grid.forEach(box => {
searchAllowedValuesOf(box)
showAllowedValuesOn(box)
box.pattern = `[${Array.from(box.allowedValues).join("")}]?`
})
enableHighlightOptions()
highlightAndTabOrder()
shareA.href = location
shareA.innerHTML = location.hostname + location.pathname + "#<br/>" + Array(9).fill().map((_,i) => gridAsString.slice(9*i, 9*i+9)).join("<br/>")
solutions = findSolutions(false, 1, 4)
localStorage.setItem("lastPlayedGrid", gridAsString)
saveGame()
suggestionTimer = setTimeout(showSuggestion, 30000)
}
function saveGame() {
localStorage.setItem(gridAsString + "SavedGame", grid.map(box => box.value || "?").join(""))
localStorage.setItem(gridAsString + "Time", Date.now() - startTime)
}
fromEasyToDifficult = (box1, box2) => box1.allowedValues.size - box2.allowedValues.size
function shuffle(iterable) {
array = Array.from(iterable)
if (array.length > 1) {
let i, j, tmp
for (i = array.length - 1; i > 0; i--) {
j = Math.floor(Math.random() * (i+1))
tmp = array[i]
array[i] = array[j]
array[j] = tmp
}
}
return array
}
function* findSolutions(randomized=false, maxSolutions=1, maxTries=4) {
let emptyBoxes = grid.filter(box => box.value == "")
if (emptyBoxes.length) {
if (randomized) // Generate random grids
emptyBoxes = shuffle(emptyBoxes)
const testBox = emptyBoxes.sort(fromEasyToDifficult)[0] // Pick an easy box to solve
// Try every allowed value
let nbSolutionsFound = 0
let nbTries = 0
for (testBox.value of randomized? shuffle(testBox.allowedValues) : testBox.allowedValues) {
testBox.neighbourhood.forEach(neighbour => {
// Remember if testBox.value was in neighbour.allowedValues in case we rewrite it
neighbour.testValueWasAllowed.push(neighbour.allowedValues.has(testBox.value))
neighbour.allowedValues.delete(testBox.value)
})
// If grid still correct, yield all solutions with this hypothesis
if (testBox.neighbourhood.filter(box => box.value == "").every(emptyBox => emptyBox.allowedValues.size)) {
for (const solution of findSolutions(randomized, maxSolutions-nbSolutionsFound, maxTries)) {
yield solution
nbSolutionsFound++
}
}
testBox.neighbourhood.filter(
neighbour => neighbour.testValueWasAllowed.pop()
).forEach(neighbour => neighbour.allowedValues.add(testBox.value))
if (maxSolutions && nbSolutionsFound >= maxSolutions) break
if (++nbTries >= maxTries) break
}
testBox.value = ""
} else {
yield grid.map(box => box.value).join("")
}
return "No more solutions"
}
function generateGrid() {
clearGrid()
shuffle(Array.from(VALUES)).forEach((value, c) => {
rows[0][c].value = value
rows[0][c].neighbourhood.forEach(neighbour => neighbour.allowedValues.delete(rows[0][c].value))
})
// Generate a random valid grid where all boxes are clues
while (findSolutions(true, 1, 4).next().done) {}
// Remove clues while there is still a unique solution
let untestedBoxes = shuffle(grid)
let nbClues = 81
while(untestedBoxes.length) {
const testBoxes = [untestedBoxes.pop()]
if (nbClues >= 30)
testBoxes.push(rows[8-testBoxes[0].row][8-testBoxes[0].column])
if (nbClues >= 61) {
testBoxes.push(rows[8-testBoxes[0].row][testBoxes[0].column])
testBoxes.push(rows[testBoxes[0].row][8-testBoxes[0].column])
}
const erasedValues = testBoxes.map(box => box.value)
testBoxes.forEach(box => {
box.value = ""
box.neighbourhood.forEach(neighbour => searchAllowedValuesOf(neighbour))
})
if (Array.from(findSolutions(false, 2, 4)).length == 1) {
nbClues -= testBoxes.length
testBoxes.slice(1).forEach(box => untestedBoxes.splice(untestedBoxes.indexOf(box), 1))
} else {
testBoxes.forEach((box, i) => {
box.value = erasedValues[i]
box.neighbourhood.forEach(neighbour => neighbour.allowedValues.delete(box.value))
})
}
}
freezeGrid()
}
function customGrid() {
clearGrid()
grid.forEach(box => showAllowedValuesOn(box))
enableHighlightOptions()
highlightAndTabOrder()
solutions = findSolutions(false, 1, 4)
customGridButton.innerText = "Figer la grille"
customGridButton.onclick = checkGrid
location.hash = ""
}
function searchAllowedValuesOf(box) {
box.allowedValues = new Set(VALUES)
box.neighbourhood.forEach(neighbour => box.allowedValues.delete(neighbour.value))
}
function keyboardBrowse(event) {
switch(event.key) {
case "ArrowLeft":
event.preventDefault()
moveOn(rows[this.row], this.column, 8)
break
case "ArrowRight":
event.preventDefault()
moveOn(rows[this.row], this.column, 1)
break
case "ArrowUp":
event.preventDefault()
moveOn(columns[this.column], this.row, 8)
break
case "ArrowDown":
event.preventDefault()
moveOn(columns[this.column], this.row, 1)
break
}
}
function moveOn(area, position, direction) {
if (area.filter(box => box.disabled).length < 9) {
do {
position = (position + direction) % 9
} while (area[position].disabled)
area[position].focus()
}
}
timeFormat = new Intl.DateTimeFormat("fr-FR", {
hour: "numeric",
minute: "2-digit",
second: "2-digit",
timeZone: "UTC"
}).format
function oninput() {
saveGame()
this.neighbourhood.concat([this]).forEach(box => {
box.setCustomValidity("")
searchAllowedValuesOf(box)
box.pattern = `[${Array.from(box.allowedValues).join("")}]?`
})
highlightAndTabOrder()
enableHighlightOptions()
this.neighbourhood.concat([this]).forEach(neighbour => showAllowedValuesOn(neighbour))
if (this.form.checkValidity()) { // Correct grid
if (grid.filter(box => box.value == "").length == 0) {
alert(`Bravo ! Vous avez résolu la grille en ${timeFormat(Date.now() - startTime)}.`)
} else {
solutions = findSolutions(false, 1, 4) // Reset solutions generator
if (suggestionTimer) clearTimeout(suggestionTimer)
suggestionTimer = setTimeout(showSuggestion, 30000)
}
} else { // Errors on grid
this.select()
this.reportValidity()
}
}
// Help functions
function showAllowedValuesOn(box) {
box.required = box.allowedValues.size == 0
if (box.value.length) {
@ -333,14 +62,49 @@ function showAllowedValuesOn(box) {
}
}
function oninput() {
this.neighbourhood.concat([this]).forEach(box => {
box.setCustomValidity("")
searchAllowedValuesOf(box)
box.pattern = `[${Array.from(box.allowedValues).join("")}]?`
})
enableButtons()
refreshShowValue()
this.neighbourhood.concat([this]).forEach(neighbour => showAllowedValuesOn(neighbour))
if (this.form.checkValidity()) { // Correct grid
if (boxes.filter(box => box.value == "").length == 0) {
alert(`Bravo ! Vous avez résolu la grille.`)
} else {
if (suggestionTimer) clearTimeout(suggestionTimer)
suggestionTimer = setTimeout(showSuggestion, 30000)
}
} else { // Errors on grid
this.select()
this.reportValidity()
}
}
function enableButtons() {
for (button of buttons.getElementsByTagName("button")) {
if (boxes.filter(box => box.value == "").some(box => box.allowedValues.has(button.textContent))) {
button.disabled = false
} else {
button.disabled = true
if (highlightedValue == button.textContent) highlightedValue = ""
}
}
}
function oninvalid() {
if (this.value.length && !this.value.match(/[1-9]/))
this.setCustomValidity("Entrez un chiffre entre 1 et 9.")
else if (sameValueIn(regions[this.region]))
else if (sameValueIn(regions[this.regionId]))
this.setCustomValidity(`Il y a un autre ${this.value} dans cette région.`)
else if (sameValueIn(rows[this.row]))
else if (sameValueIn(rows[this.rowId]))
this.setCustomValidity(`Il y a un autre ${this.value} dans cette ligne.`)
else if (sameValueIn(columns[this.column]))
else if (sameValueIn(columns[this.columnId]))
this.setCustomValidity(`Il y a un autre ${this.value} dans cette colonne.`)
else if (this.allowedValues.size == 0)
this.setCustomValidity("La grille est incorrecte.")
@ -357,90 +121,74 @@ function sameValueIn(area) {
return false
}
function highlight(value) {
highlightedValue = value
highlightAndTabOrder()
function keyboardBrowse(event) {
switch(event.key) {
case "ArrowLeft":
event.preventDefault()
moveOn(rows[this.rowId], this.columnId, 8)
break
case "ArrowRight":
event.preventDefault()
moveOn(rows[this.rowId], this.columnId, 1)
break
case "ArrowUp":
event.preventDefault()
moveOn(columns[this.columnId], this.rowId, 8)
break
case "ArrowDown":
event.preventDefault()
moveOn(columns[this.columnId], this.rowId, 1)
break
}
}
function highlightAndTabOrder() {
if (highlightedValue) {
for (const box of grid) {
box.tabIndex = -1
box.className = "unhighlighted"
if (box.value == "") {
if (box.allowedValues.has(highlightedValue)) {
box.tabIndex = 0
box.className = "highlighted"
}
}
}
function moveOn(area, position, direction) {
position = (position + direction) % 9
area[position].focus()
}
function showValue(button) {
if (button.textContent == highlightedValue) {
highlightedValue = ""
} else {
for (const box of grid) {
box.className = "unhighlighted"
if (box.value == "" && box.allowedValues.size == 1) {
box.tabIndex = 0
} else {
box.tabIndex = -1
}
}
highlightedValue = button.textContent
}
refreshShowValue()
}
function refreshShowValue() {
boxes.forEach(box => box.className = "")
if (highlightedValue) {
boxes.forEach(box => {
if (box.value == highlightedValue) box.className = "same-value"
if (!box.allowedValues.has(highlightedValue)) box.className = "forbidden-value"
})
}
}
function enableHighlightOptions() {
for (value of VALUES) {
let highlightRadio = document.getElementById("highlightRadio" + value)
let highlightLabel = document.getElementById("highlightLabel" + value)
if (grid.filter(box => box.value == "").some(box => box.allowedValues.has(value))) {
highlightRadio.disabled = false
highlightLabel.className = ""
} else {
highlightRadio.disabled = true
highlightRadio.checked = false
highlightLabel.className = "disabled"
function shuffle(iterable) {
array = Array.from(iterable)
if (array.length > 1) {
let i, j, tmp
for (i = array.length - 1; i > 0; i--) {
j = Math.floor(Math.random() * (i+1))
tmp = array[i]
array[i] = array[j]
array[j] = tmp
}
}
}
return array
}
easyFirst = (box1, box2) => box1.allowedValues.size - box2.allowedValues.size
function showSuggestion() {
const emptyBoxes = grid.filter(box => box.value == "")
const emptyBoxes = boxes.filter(box => box.value == "")
if (emptyBoxes.length) {
shuffle(emptyBoxes).sort(fromEasyToDifficult)[0].placeholder = "!"
shuffle(emptyBoxes).sort(easyFirst)[0].placeholder = "!"
} else {
clearTimeout(suggestionTimer)
suggestionTimer = null
}
}
function erase() {
grid.filter(box => !box.disabled).forEach(box => {
box.value = ""
box.placeholder = ""
})
grid.filter(box => !box.disabled).forEach(box => {
searchAllowedValuesOf(box)
showAllowedValuesOn(box)
})
sudokuForm.checkValidity()
sudokuForm.reportValidity()
enableHighlightOptions()
solutions = findSolutions(false, 1, 4)
solveButton.innerText = "Résoudre"
solveButton.type = "button"
solveButton.onclick = solve
solveButton.disabled = false
startTime = Date.now()
}
function solve() {
if (sudokuForm.checkValidity()) {
if (solutions.next().done) { // End of solutions generator
solveButton.innerText = "Montrer la solution"
solutions = findSolutions(false, 1, 4)
} else {
solveButton.innerText = "Effacer la solution"
}
grid.forEach(box => showAllowedValuesOn(box))
} else {
sudokuForm.reportValidity()
}
}