Sprawdzam Sudoku - Kurs Unity - 5. Zasady gry
W poprzednim kroku zaimplementowaliśmy ustawianie wartości na planszy. W tym kroku dodamy sprawdzanie zasad Sudoku, aby nie dało się "wyklikać" niepoprawnej planszy:
Implementacja
Na tym etapie musimy w aplikacji stworzyć model gry. W tym celu tworzymy klasy:
BoardModeldo przechowywania modelu całej planszyCellModeldo przechowywania modelu pojedynczej komórkiCellSetModeldo przechowywania zbiorów komórek (kolumn, wierszy, kwadratów 3x3)
Pytanie 1:
Który element powinien przechowywać model planszy?
Możliwości:
- Menadżer gry (
GameManager) - Plansza (
BoardImage) - Robocza komórka (
WorkCellImage)
Decyzja:
Ponieważ i tak musimy połączyć model z komórkami planszy, najprościej będzie jeśli to plansza (
BoardImage) sama stworzy model przy starcie przekazując mu wszystkie swoje komórki. Aby ułatwić testowanie, ustawiamy początkowe wartości niektórych komórek, aby uzyskać rozwiązywalne 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);
}
...
}Przed nami najtrudniejsza część. Model planszy (
BoardModel) zapamiętuje przekazane komórki, dla każdej tworząc model i ustawiając wszystkie zależności między komórkami, grupami komórek (kolumnami, wierszami, kwadratami 3x3), sąsiednimi komórkami itp. Dodajemy też metodę SetCellValue którą plansza używa, aby zainicjować 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);
}
}Zbiór komórek (
CellSetModel) musi jedynie zapamiętać swój typ (COLUMN, ROW albo GROUP) oraz wszystkie komórki w zbiorze: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);
}
}
Model komórki (
CellModel) musi tylko pamiętać jej wartość oraz to, w których zbiorach się znajduje: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;
}
}Chcielibyśmy, aby nie dało się ustawić wartości komórki w modelu, jeśli w danej kolumnie, wierszu, lub kwadracie 3x3 ta wartość już istnieje.
Pytanie 2:
Jak sprawdzać, czy w danej kolumnie, wierszu lub kwadracie znajduje się już zadana wartość?
Możliwości:
- Wyliczać na bieżąco - t.j.
CellModelposiada tylko aktualną wartość komórki. Kiedy jest potrzeba pobrania możliwych wartości, wylicza je przeglądając wszystkie inne komórki w danej kolumnie, wierszu lub kwadracie 3x3. - Zapamiętywać aby nie musieć za każdym razem tego wyliczać - t.j.
CellModelposiada aktualną wartość i/lub zbiór wartości już istniejących w danej kolumnie, wierszu lub kwadracie 3x3.
Decyzja:
Ponieważ w informatyce "przedwczesna optymalizacja jest źródłem wszelkiego zła", będziemy wyliczać możliwe wartości na bieżąco.
Do modelu zbioru komórek (
CellSetModel) dodajemy metodę AnyOtherCellHasValue która sprawdza, czy którakolwiek inna komórka w grupie ma zadaną wartość: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;
}
}Do modelu komórki (
CellModel) dodajemy metodę GetCellSetTypesHavingValue, która zwraca w których zbiorach podana wartość już istnieje. Przy ustawianiu wartości metodą SetValue upewniamy się, że dana wartość nie istnieje w żadnym innym zbiorze tej komórki: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);
...
}
}Komórka (
CellImage) zamiast sama przechowywać swoją wartość przechowuje model, informację o tym, czy jest wybrana, oraz informacje o swoich sąsiadach (następna komórka w kolumnie lub wierszu, poprzednia komórka w kolumnie lub wierszu):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);
...
}
}Komórki do przechowywania wartości korzystają teraz z modelu, ale nadal możemy wybierać niepoprawne wartości...:
Invalid value being set: 1
#0 GetStacktrace(int)
...
Invalid value being set: 2
#0 GetStacktrace(int)
...Chcemy, aby po wyborze aktualnej komórki robocza komórka przekreśliła niektóre opcje. Zgodnie z naszym pomysłem, wartości które występują już w danej kolumnie przekreślimy na niebiesko (
#1E88E5), w danym wierszu, na czerwono (#D81B60), w danym kwadracie 3x3 na żółto (#FFC107).Do możliwej wartości (
PossibilityRect) dodajemy obrazy (StrikethroughColImage, StrikethroughRowImage, StrikethroughGroupImage) umiejscowione tak, aby możliwe było wyświetlenie kilku przekreśleń jednocześnie (jeśli dana wartość występuje np. zarówno w kolumnie jak i wierszu):PossibilityRect) dodajemy również właściwość Enabled (czy jest możliwa do użycia) oraz Selected (czy jest aktualnie wybrana) i metody aktywujące przekreślenia: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;
}
}
}Pytanie 3:
Kto ma aktywować/dezaktywować możliwe wartości i włączać/wyłączać przekreślenia?
Możliwości:
- Robocza komórka (
WorkCellImage) - już teraz zapamiętuje która wartość jest wybrana. Nie ma jednak dostępu do wszystkich komórek aby określić, które wartości powinny być przekreślone. - Możliwa wartość (
PossibilityRect) - nie ma dostępu do wszystkich komórek. - Aktualnie wybrana komórka (
CellImage) - ona ma dostęp (poprzez swój model) do wszystkich komórek w kolumnie, wierszu i kwadracie 3x3
Decyzja:
Aktywacja i dezaktywacja możliwej wartości oraz włączanie/wyłączanie przekreślenia będzie robione przez aktualnie wybraną komórkę.
Dodajemy do komórki (
CellImage) metody do aktywacji i dezaktywacji możliwej wartości, które będą wołane tylko wtedy, kiedy komórka jest zaznaczona: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);
}
}
}
}W końcu w aktywnej komórce (
WorkCellImage) uaktualniamy metody SelectCell oraz SelectPossibility tak, aby:- Zaznaczenie już zaznaczonej komórki ją "odznaczyło"
- Przy zaznaczeniu komórki, wybrane wartości były aktywowane
- Przy odznaczaniu komórki, wybrane wartości były dezaktywowane
- Zaznaczenie już zaznaczonej wartości ją "odznaczyło"
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);
}
}
}Otrzymujemy funkcjonalną aplikację, która przekreśla niemożliwe wartości i uniemożliwia "wyklikanie" niepoprawnej planszy:
Na koniec, chcielibyśmy zrobić ukłon w stronę użytkowników aplikacji posiadających klawiaturę. Chcielibyśmy, aby:
- strzałkami można było przesuwać zaznaczenie komórki
- spacją można było usunąć aktualne zaznaczenie komórki
- cyframi można było wybierać zaznaczenie wartości
- cyfrą zero można było usunąć aktualne zaznaczenie wartości
W tym celu do komórki (
CellImage) dodajemy obsługę strzałek i spacji: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);
}
...
}Do wartości komórki (
PossibilityRect) dodajemy obsługę cyfr: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);
}
}
...
}Otrzymujemy aplikację pokazaną we wstępie.
GitHub
Zmiany związane z tym krokiem znajdują się tutaj:
Kolejne kroki
Istniejąca aplikacja dopiero po zaznaczeniu komórki pokazuje jakie wartości są dla niej możliwe. W następnym kroku dodamy podpowiadanie użytkownikowi które komórki mają już tylko jedną poprawną wartość (i jaka to jest wartość).
Polski | Angielski














Komentarze
Prześlij komentarz