Check Sudoku - Unity Tutorial - 5. Game rules
In the previous step we have implemented setting board cell values. In this one, we will add checking Sudoku rules, to prevent user from "clicking" illegal Sudoku board:
Implementation
At this stage, we need to create the game model. In order to do that, we create three classes:
BoardModelfor storing the whole board modelCellModelfor storing single cell modelCellSetModelfor storing a set of cells (e.g. column, row, or 3x3 square)
Question 1:
Which element should store the board model?
Options:
- Game manager (
GameManager) - The board (
BoardImage) - The work cell (
WorkCellImage)
Decision:
Since we need to join the model with board images anyway, it's easiest if the board (
BoardImage) creates the model upon creation and feeds all its cells into it. In order to simplify testing, we set the initial values for some of the board cells, getting a solvable Sudoku:public class BoardImage : MonoBehaviour
{
...
private BoardModel model;
private void Awake()
{
...
model = new BoardModel(GetComponentsInChildren<CellImage>());
// Set the initial board.
// 9 2 | | 7 5
// 7 | 2 5 | 8
// 4 | 8 9 | 1
// ---------------------
// 2 8 | | 1 6
// | |
// 1 6 | | 5 3
// ---------------------
// 1 | 5 6 | 2
// 2 | 9 4 | 3
// 4 3 | | 8 9
model.SetCellValue(0, 1, 9);
...
model.SetCellValue(8, 7, 9);
}
...
}Now, the hardest part. The board model (
BoardModel) stores all given cells in a grid, creating a model for each one, and setting all the dependencies between cells, cell sets (columns, rows, 3x3 squares), neighboring cells etc. We also add a SetCellValue method used by the board to initialize the Sudoku:public class BoardModel
{
private const int NUM_COLS = 9;
private const int NUM_ROWS = 9;
private const int NUM_CELLS = NUM_COLS * NUM_COLS;
private const int NUM_COLS_IN_GROUP = 3;
private const int NUM_ROWS_IN_GROUP = 3;
private const int NUM_CELLS_IN_GROUP = NUM_COLS_IN_GROUP * NUM_ROWS_IN_GROUP;
private const int NUM_GROUP_COLS = NUM_COLS / NUM_COLS_IN_GROUP;
private const int NUM_GROUP_ROWS = NUM_ROWS / NUM_ROWS_IN_GROUP;
private const int NUM_GROUPS = NUM_GROUP_COLS * NUM_GROUP_ROWS;
private CellModel[] cells = new CellModel[NUM_CELLS];
private CellSetModel[] cols = new CellSetModel[NUM_COLS];
private CellSetModel[] rows = new CellSetModel[NUM_ROWS];
private CellSetModel[] groups = new CellSetModel[NUM_GROUPS];
private CellImage[,] cellImageGrid = new CellImage[NUM_ROWS, NUM_COLS];
public BoardModel(CellImage[] cellImages)
{
Debug.AssertFormat(
cellImages.Length == NUM_CELLS,
"Invalid number of cell images. Expected {0}, but got {1}.",
NUM_CELLS, cellImages.Length);
// Create columns, rows and groups.
for(int i = 0; i < NUM_COLS; ++i)
{
cols[i] = new CellSetModel(CellSetType.COLUMN);
}
for (int i = 0; i < NUM_ROWS; ++i)
{
rows[i] = new CellSetModel(CellSetType.ROW);
}
for (int i = 0; i < NUM_GROUPS; ++i)
{
groups[i] = new CellSetModel(CellSetType.GROUP);
}
// Iterate over the cells in their display order:
// 0 1 2 9 10 11 ...
// 3 4 5 12 ...
// 6 7 8 ...
// ...
for (int i = 0; i < cellImages.Length; ++i)
{
cells[i] = new CellModel();
cellImages[i].SetModel(cells[i]);
// Assign cell to the correct column, row, and group.
// Group index for each cell : 0 0 0 0 0 0 0 0 0 1...
// Cell within group : 0 1 2 3 4 5 6 7 8 0...
int cellGroup = i / NUM_CELLS_IN_GROUP;
int cellWithinCellGroup = i % NUM_CELLS_IN_GROUP;
// Row index for each cell : 0 0 0 1 1 1 2 2 2 0...
int cellGroupFirstCellRow =
(cellGroup / NUM_GROUP_COLS) * NUM_ROWS_IN_GROUP;
int cellRow =
cellGroupFirstCellRow + cellWithinCellGroup / NUM_ROWS_IN_GROUP;
// Column index for each cell: 0 1 2 0 1 2 0 1 2 3...
int cellGroupFirstCellCol =
(cellGroup % NUM_GROUP_COLS) * NUM_COLS_IN_GROUP;
int cellCol =
cellGroupFirstCellCol + cellWithinCellGroup % NUM_COLS_IN_GROUP;
cellImageGrid[cellRow, cellCol] = cellImages[i];
cols[cellCol].AddCell(cells[i]);
cells[i].AddCellSet(cols[cellCol]);
rows[cellRow].AddCell(cells[i]);
cells[i].AddCellSet(rows[cellRow]);
groups[cellGroup].AddCell(cells[i]);
cells[i].AddCellSet(groups[cellGroup]);
}
// Set each cell image neighbors.
for (int row = 0; row < NUM_ROWS; ++row)
{
for (int col = 0; col < NUM_COLS; ++col)
{
cellImageGrid[row, col].SetNeighbours(
cellImageGrid[(row + 1) % NUM_ROWS, col],
cellImageGrid[row, (col + 1) % NUM_COLS],
cellImageGrid[(row + NUM_ROWS - 1) % NUM_ROWS, col],
cellImageGrid[row, (col + NUM_COLS - 1) % NUM_COLS]);
}
}
}
public void SetCellValue(int row, int col, int value)
{
cellImageGrid[row, col].SetValue(value);
}
}Cell set (
CellSetModel) must only store its type (COLUMN, ROW or GROUP) and all the cells in the set:public enum CellSetType {
CELL_SET_TYPE_UNSPECIFIED,
COLUMN,
ROW,
GROUP
}
public class CellSetModel
{
public CellSetType Type { get; private set; }
private List<CellModel> cells = new List<CellModel>();
public CellSetModel(CellSetType type)
{
Type = type;
}
public void AddCell(CellModel cell)
{
cells.Add(cell);
}
}
Cell model (
CellModel) must only store its value, and which sets it belongs to:public class CellModel
{
// Value in the cell. Value of 0 represents an empty cell.
public int Value { get; private set; } = 0;
private List<CellSetModel> cellSets = new List<CellSetModel>();
public CellModel(){}
public void AddCellSet(CellSetModel cellSet)
{
cellSets.Add(cellSet);
}
public void SetValue(int value)
{
Value = value;
}
}We'd like to block the possibility of setting cell value in the model, if the value already exists in some column, row, or 3x3 square.
Question 2:
How to check whether a value already exists in the given column, row, or 3x3 square?
Options:
- Calculate it on-the-fly - i.e.
CellModelstores only the value. Whenever there's a need to know the possible values, it calculates them by looking through all other cells in given column, row, or 3x3 square. - Memoize them, not to recalculate it every time - e.g.
CellModelstores both the current value, and a set of all values existing in a given column, row, or 3x3 square.
Decision:
Since, in the computer science, "premature optimization is the root of all evil", we will calculate possible values on-the-fly.
In the cell set model (
CellSetModel) we add AnyOtherCellHasValue method, which checks if any other cell contains the given value:public class CellSetModel
{
...
public bool AnyOtherCellHasValue(CellModel ignoredCell, int value)
{
foreach(CellModel cell in cells)
{
if (!cell.Equals(ignoredCell) && cell.Value.Equals(value))
{
return true;
}
}
return false;
}
}In the cell model (
CellModel) we add GetCellSetTypesHavingValue method, which returns the "cell set types" in which a given value already exists. When setting the value using SetValue we assert that given value does not exist in any other cell set:public class CellModel
{
...
public List<CellSetType> GetCellSetTypesHavingValue(int value)
{
List<CellSetType> result = new List<CellSetType>();
foreach(CellSetModel cellSet in cellSets)
{
if (cellSet.AnyOtherCellHasValue(this, value))
{
result.Add(cellSet.Type);
}
}
return result;
}
public void SetValue(int value)
{
Debug.AssertFormat(
value == 0 || GetCellSetTypesHavingValue(value).Count == 0,
"Invalid value being set: {0}", value);
...
}
}Instead of storing the value, the cell (
CellImage) stores its model, whether it is selected, and the information about its neighbours (next cell in column or row, previous cell in column or row):public class CellImage : MonoBehaviour
{
public bool Selected { get; private set; } = false;
...
private CellModel model;
private CellImage nextInCol, nextInRow;
private CellImage prevInCol, prevInRow;
// Start is called before the first frame update
void Start()
{
}
...
public void Select()
{
Selected = true;
...
}
public void Unselect()
{
Selected = false;
...
}
public void OnClick()
{
...
}
public void SetModel(CellModel model)
{
this.model = model;
}
public void SetNeighbours(CellImage nextInCol, CellImage nextInRow, CellImage prevInCol, CellImage prevInRow)
{
this.nextInCol = nextInCol;
this.nextInRow = nextInRow;
this.prevInCol = prevInCol;
this.prevInRow = prevInRow;
}
public int GetValue()
{
return model.Value;
}
public void SetValue(int value)
{
model.SetValue(value);
...
}
}The cells now use the model to store values, but we can still select invalid values...:
Invalid value being set: 1
#0 GetStacktrace(int)
...
Invalid value being set: 2
#0 GetStacktrace(int)
...We want the work cell to cross out some of the possibilities for a selected cell. According to our idea, the values which exist in the column will be crossed out with blue colour (
#1E88E5), existing in the row with red colour (#D81B60), existing in the 3x3 square with the yellow colour (#FFC107).In the possibility (
PossibilityRect) we add three images (StrikethroughColImage, StrikethroughRowImage, StrikethroughGroupImage) placed so that a few of them can be displayed at the same time (if e.g. given value exists both in the column, and in the row):PossibilityRect) we also add Enabled and Selected properties as well as methods for activating the strikethrough images:public class PossibilityRect : MonoBehaviour
{
public bool Enabled { get; private set; } = true;
public bool Selected { get; private set; } = false;
public GameObject strikethroughColImage;
public GameObject strikethroughRowImage;
public GameObject strikethroughGroupImage;
...
// Start is called before the first frame update
void Start()
{
...
Disable();
}
...
public void Select()
{
Selected = true;
...
}
public void Unselect()
{
Selected = false;
...
}
public void OnClick()
{
if (Enabled)
{
...
}
}
public void Enable()
{
valueText.alpha = 1;
Enabled = true;
}
public void Disable()
{
valueText.alpha = 0.5f;
Enabled = false;
}
public void ClearStrikethrough()
{
strikethroughColImage.SetActive(false);
strikethroughRowImage.SetActive(false);
strikethroughGroupImage.SetActive(false);
}
public void DisplayStrikethroughForType(CellSetType cellSetType)
{
Disable();
switch (cellSetType)
{
case CellSetType.COLUMN:
strikethroughColImage.SetActive(true);
break;
case CellSetType.ROW:
strikethroughRowImage.SetActive(true);
break;
case CellSetType.GROUP:
strikethroughGroupImage.SetActive(true);
break;
}
}
}Question 3:
Who should activate/deactivate possibilities and strikethrough images?
Options:
- The work cell (
WorkCellImage) - it already remembers the selected cell. It doesn't have access to the columns in all the cells to determine which possibilities should be crossed out. - Possibility (
PossibilityRect) - also does not have access to all the cells. - Selected cell (
CellImage) - it has access (through its model) to all other cells in the same column, row, and 3x3 square
Decision:
The activation/deactivation of possibilities and their strikethrough images will be done by the currently selected cell.
In the cell (
CellImage) we add methods for activating and deactivating a single possibility, which are meant to only be invoked when the cell is selected:public class CellImage : MonoBehaviour
{
...
public void DisablePossibility(PossibilityRect possibility)
{
Debug.Assert(Selected, "Only selected cell can disable possibility.");
if (possibility.Value == model.Value)
{
possibility.Unselect();
}
else
{
possibility.ClearStrikethrough();
}
possibility.Disable();
}
public void EnablePossibility(PossibilityRect possibility)
{
Debug.Assert(Selected, "Only selected cell can enable possibility.");
possibility.Enable();
if (possibility.Value == model.Value)
{
possibility.Select();
}
else
{
foreach (CellSetType cellSetType in model.GetCellSetTypesHavingValue(possibility.Value))
{
possibility.DisplayStrikethroughForType(cellSetType);
}
}
}
}Finally in the work cell (
WorkCellImage) we update the SelectCell and SelectPossibility methods so that:- Selecting a selected cell "unselects" it
- When a cell is selected, possibilities are activated
- When a cell is unselected, possibilities are deactivated
- Selecting a selected possibility "unselects" it
public class WorkCellImage : MonoBehaviour
{
...
public void SelectCell(CellImage cell)
{
Debug.Assert(cell != null, "Cell cannot be null.");
if (selectedCell != null)
{
foreach (PossibilityRect possibility in GetComponentsInChildren<PossibilityRect>())
{
selectedCell.DisablePossibility(possibility);
}
selectedPossibility = null;
selectedCell.Unselect();
}
// Selecting "already selected" cell should unselect it.
if (selectedCell == cell)
{
selectedCell = null;
}
else
{
selectedCell = cell;
selectedCell.Select();
foreach (PossibilityRect possibility in GetComponentsInChildren<PossibilityRect>())
{
if (possibility.Value == selectedCell.GetValue())
{
selectedPossibility = possibility;
}
selectedCell.EnablePossibility(possibility);
}
}
}
public void SelectPossibility(PossibilityRect possibility)
{
Debug.Assert(selectedCell != null, "Selected cell cannot be null.");
Debug.Assert(possibility != null, "Possibility cannot be null.");
if (selectedPossibility != null)
{
selectedPossibility.Unselect();
}
// Selecting "already selected" possibility should unselect it.
if (selectedPossibility == possibility)
{
selectedPossibility = null;
selectedCell.SetValue(0);
}
else
{
selectedPossibility = possibility;
selectedPossibility.Select();
selectedCell.SetValue(selectedPossibility.Value);
}
}
}We get a functional app, which strikes out impossible values, and prevents user from "clicking" illegal Sudoku board:
At the end, we would like to improve the experience for keyboard users. We would like:
- arrows to move the selected cell
- the "space" key to unselect a cell
- digits to select the possible value
- the "zero" digit to unselect a possible value
In order to do that, we add arrow and "space" keys handling to the cell (
CellImage):public class CellImage : MonoBehaviour
{
...
// Update is called once per frame
void Update()
{
if (Selected) {
if (Input.GetKeyDown(KeyCode.DownArrow))
{
SelectCellInTheNextFrame(nextInCol);
}
if (Input.GetKeyDown(KeyCode.RightArrow))
{
SelectCellInTheNextFrame(nextInRow);
}
if (Input.GetKeyDown(KeyCode.UpArrow))
{
SelectCellInTheNextFrame(prevInCol);
}
if (Input.GetKeyDown(KeyCode.LeftArrow))
{
SelectCellInTheNextFrame(prevInRow);
}
if (Input.GetKeyDown(KeyCode.Space))
{
SelectCellInTheNextFrame(this);
}
}
}
private async void SelectCellInTheNextFrame(CellImage cell)
{
await Task.Yield();
WorkCellImage.Instance.SelectCell(cell);
}
...
}We add digit keys handling to the possible value (
PossibilityRect):public class PossibilityRect : MonoBehaviour
{
...
private KeyCode GetKeyCode()
{
switch (Value)
{
case 1: return KeyCode.Alpha1;
case 2: return KeyCode.Alpha2;
case 3: return KeyCode.Alpha3;
case 4: return KeyCode.Alpha4;
case 5: return KeyCode.Alpha5;
case 6: return KeyCode.Alpha6;
case 7: return KeyCode.Alpha7;
case 8: return KeyCode.Alpha8;
case 9: return KeyCode.Alpha9;
default: return KeyCode.None;
}
}
// Update is called once per frame
void Update()
{
// Select this possibility if its value is pressed
if ((Enabled && Input.GetKeyDown(GetKeyCode())) ||
// ...or unselect it if it's currently selected and '0' is pressed.
(Selected && Input.GetKeyDown(KeyCode.Alpha0)))
{
WorkCellImage.Instance.SelectPossibility(this);
}
}
...
}That's how we get the app from the introduction.
GitHub
Commits related to this step are here:
Next steps
The app shows possible values for a cell only after it is selected. In the next step we will add hints about the cells which only have one valid possibility (and what it is).
Polish | English














Comments
Post a Comment