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:
  • BoardModel do przechowywania modelu całej planszy
  • CellModel do przechowywania modelu pojedynczej komórki
  • CellSetModel do 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 (COLUMNROW 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. CellModel posiada 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. CellModel posiada 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...:


...dostając logi z błędami w konsoli, kiedy tak się dzieje:
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).


Pobieramy darmową ikonę przekreślenia z pixabay.com, którą następnie korzystając z GIMP'a przemalowujemy na wybrane kolory:


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):


Do możliwej wartości (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

Popularne posty z tego bloga

Sprawdzam Sudoku - Kurs Unity - 2. Konfiguracja

Sprawdzam Sudoku - Kurs Unity - 3. Szkielet aplikacji

Sprawdzam Sudoku - Kurs Unity - 1. Pomysł