From 1483964eafd2d292abd78a04a6a80bac6d51d709 Mon Sep 17 00:00:00 2001 From: Konstantin Dyachenko Date: Sat, 6 Jun 2026 21:34:35 +0700 Subject: [PATCH] [Add] Playful interaction with cells --- .../Runtime/Commands/GameCommandHandlers.cs | 60 +++++++++- .../Runtime/Core/BoardActionResult.cs | 21 ++++ .../Runtime/Core/BoardActionResult.cs.meta | 2 + .../Minesweeper/Runtime/Core/BoardService.cs | 113 +++++++++++++++++- .../Minesweeper/Runtime/Core/IBoardService.cs | 4 + 5 files changed, 195 insertions(+), 5 deletions(-) create mode 100644 Assets/Minesweeper/Runtime/Core/BoardActionResult.cs create mode 100644 Assets/Minesweeper/Runtime/Core/BoardActionResult.cs.meta diff --git a/Assets/Minesweeper/Runtime/Commands/GameCommandHandlers.cs b/Assets/Minesweeper/Runtime/Commands/GameCommandHandlers.cs index 0a35489..d150c86 100644 --- a/Assets/Minesweeper/Runtime/Commands/GameCommandHandlers.cs +++ b/Assets/Minesweeper/Runtime/Commands/GameCommandHandlers.cs @@ -55,26 +55,78 @@ namespace Minesweeper.Commands public void Handle(OpenCellCommand command) { - if (gameStateService.Current != GameState.Preparing) + var state = gameStateService.Current; + if (state != GameState.Preparing && state != GameState.Playing) { return; } - if (!boardService.GenerateAfterFirstClick(command.X, command.Y)) + if (state == GameState.Preparing) + { + if (boardService.TryGetCell(command.X, command.Y, out var cell) && cell.IsFlagged) + { + return; + } + + if (!boardService.GenerateAfterFirstClick(command.X, command.Y)) + { + return; + } + } + + var result = boardService.OpenCell(command.X, command.Y); + if (result.Invalid || !result.Changed) { return; } - gameStateService.SetState(GameState.Playing); + if (result.HitMine) + { + gameStateService.SetState(GameState.Lost); + } + else if (result.Won) + { + gameStateService.SetState(GameState.Won); + } + else if (state == GameState.Preparing) + { + gameStateService.SetState(GameState.Playing); + } + boardEcsSyncService.SyncBoard(boardService); - boardEcsSyncService.SyncGameState(gameStateService.Current, true); + boardEcsSyncService.SyncGameState(gameStateService.Current, boardService.IsGenerated); } } public sealed class ToggleFlagCommandHandler : IGameCommandHandler { + private readonly IBoardEcsSyncService boardEcsSyncService; + private readonly IBoardService boardService; + private readonly IGameStateService gameStateService; + + public ToggleFlagCommandHandler(IBoardService boardService, IBoardEcsSyncService boardEcsSyncService, IGameStateService gameStateService) + { + this.boardService = boardService; + this.boardEcsSyncService = boardEcsSyncService; + this.gameStateService = gameStateService; + } + public void Handle(ToggleFlagCommand command) { + var state = gameStateService.Current; + if (state != GameState.Preparing && state != GameState.Playing) + { + return; + } + + var result = boardService.ToggleFlag(command.X, command.Y); + if (result.Invalid || !result.Changed) + { + return; + } + + boardEcsSyncService.SyncBoard(boardService); + boardEcsSyncService.SyncGameState(gameStateService.Current, boardService.IsGenerated); } } diff --git a/Assets/Minesweeper/Runtime/Core/BoardActionResult.cs b/Assets/Minesweeper/Runtime/Core/BoardActionResult.cs new file mode 100644 index 0000000..48f9962 --- /dev/null +++ b/Assets/Minesweeper/Runtime/Core/BoardActionResult.cs @@ -0,0 +1,21 @@ +namespace Minesweeper.Core +{ + public readonly struct BoardActionResult + { + public BoardActionResult(bool changed, bool hitMine, bool won, bool invalid) + { + Changed = changed; + HitMine = hitMine; + Won = won; + Invalid = invalid; + } + + public bool Changed { get; } + public bool HitMine { get; } + public bool Won { get; } + public bool Invalid { get; } + + public static BoardActionResult NoChange => new BoardActionResult(false, false, false, false); + public static BoardActionResult InvalidAction => new BoardActionResult(false, false, false, true); + } +} diff --git a/Assets/Minesweeper/Runtime/Core/BoardActionResult.cs.meta b/Assets/Minesweeper/Runtime/Core/BoardActionResult.cs.meta new file mode 100644 index 0000000..319299f --- /dev/null +++ b/Assets/Minesweeper/Runtime/Core/BoardActionResult.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7457e882fbca4e644959ccff78510436 \ No newline at end of file diff --git a/Assets/Minesweeper/Runtime/Core/BoardService.cs b/Assets/Minesweeper/Runtime/Core/BoardService.cs index 13b451b..a2e8ba2 100644 --- a/Assets/Minesweeper/Runtime/Core/BoardService.cs +++ b/Assets/Minesweeper/Runtime/Core/BoardService.cs @@ -23,6 +23,8 @@ namespace Minesweeper.Core public int Height { get; private set; } public int MinesCount { get; private set; } public bool IsGenerated { get; private set; } + public int OpenedSafeCellsCount { get; private set; } + public int SafeCellsCount => Width * Height - MinesCount; public void InitializeEmptyBoard() { @@ -31,6 +33,7 @@ namespace Minesweeper.Core Width = width; Height = height; MinesCount = minesCount; + OpenedSafeCellsCount = 0; IsGenerated = false; cells = new CellData[Width, Height]; @@ -55,11 +58,62 @@ namespace Minesweeper.Core PlaceMines(safeX, safeY); CalculateNeighborMines(); - cells[safeX, safeY].IsOpened = true; IsGenerated = true; return true; } + public BoardActionResult OpenCell(int x, int y) + { + EnsureInitialized(); + + if (!IsGenerated || !IsInside(x, y)) + { + return BoardActionResult.InvalidAction; + } + + var cell = cells[x, y]; + if (cell.IsOpened || cell.IsFlagged) + { + return BoardActionResult.NoChange; + } + + if (cell.IsMine) + { + cell.IsOpened = true; + return new BoardActionResult(true, true, false, false); + } + + if (cell.NeighborMines == 0) + { + RevealEmptyArea(x, y); + } + else + { + OpenSafeCell(cell); + } + + return new BoardActionResult(true, false, IsWin(), false); + } + + public BoardActionResult ToggleFlag(int x, int y) + { + EnsureInitialized(); + + if (!IsInside(x, y)) + { + return BoardActionResult.InvalidAction; + } + + var cell = cells[x, y]; + if (cell.IsOpened) + { + return BoardActionResult.NoChange; + } + + cell.IsFlagged = !cell.IsFlagged; + return new BoardActionResult(true, false, false, false); + } + public bool IsInside(int x, int y) { return cells != null && x >= 0 && y >= 0 && x < Width && y < Height; @@ -190,6 +244,63 @@ namespace Minesweeper.Core return count; } + private void RevealEmptyArea(int startX, int startY) + { + var visited = new bool[Width, Height]; + var queue = new Queue(); + queue.Enqueue(cells[startX, startY]); + + while (queue.Count > 0) + { + var cell = queue.Dequeue(); + if (visited[cell.X, cell.Y] || cell.IsMine || cell.IsFlagged) + { + continue; + } + + visited[cell.X, cell.Y] = true; + OpenSafeCell(cell); + + if (cell.NeighborMines != 0) + { + continue; + } + + for (var x = cell.X - 1; x <= cell.X + 1; x++) + { + for (var y = cell.Y - 1; y <= cell.Y + 1; y++) + { + if ((x == cell.X && y == cell.Y) || !IsInside(x, y)) + { + continue; + } + + var neighbor = cells[x, y]; + if (!visited[x, y] && !neighbor.IsMine && !neighbor.IsFlagged && !neighbor.IsOpened) + { + queue.Enqueue(neighbor); + } + } + } + } + } + + private void OpenSafeCell(CellData cell) + { + if (cell.IsOpened || cell.IsMine) + { + return; + } + + cell.IsOpened = true; + OpenedSafeCellsCount++; + } + + private bool IsWin() + { + return OpenedSafeCellsCount >= SafeCellsCount; + } + private int ToIndex(int x, int y) { return y * Width + x; diff --git a/Assets/Minesweeper/Runtime/Core/IBoardService.cs b/Assets/Minesweeper/Runtime/Core/IBoardService.cs index ac65992..65bd8f1 100644 --- a/Assets/Minesweeper/Runtime/Core/IBoardService.cs +++ b/Assets/Minesweeper/Runtime/Core/IBoardService.cs @@ -8,9 +8,13 @@ namespace Minesweeper.Core int Height { get; } int MinesCount { get; } bool IsGenerated { get; } + int OpenedSafeCellsCount { get; } + int SafeCellsCount { get; } void InitializeEmptyBoard(); bool GenerateAfterFirstClick(int safeX, int safeY); + BoardActionResult OpenCell(int x, int y); + BoardActionResult ToggleFlag(int x, int y); bool IsInside(int x, int y); bool TryGetCell(int x, int y, out BoardCellData cell); IReadOnlyList GetCells();