diff --git a/Assets/Minesweeper/Runtime/Commands/GameCommandHandlers.cs b/Assets/Minesweeper/Runtime/Commands/GameCommandHandlers.cs index 8d72e33..0a35489 100644 --- a/Assets/Minesweeper/Runtime/Commands/GameCommandHandlers.cs +++ b/Assets/Minesweeper/Runtime/Commands/GameCommandHandlers.cs @@ -1,4 +1,5 @@ using Minesweeper.Core; +using Minesweeper.ECS; namespace Minesweeper.Commands { @@ -19,23 +20,54 @@ namespace Minesweeper.Commands public sealed class StartGameCommandHandler : IGameCommandHandler { + private readonly IBoardEcsSyncService boardEcsSyncService; + private readonly IBoardService boardService; private readonly IGameStateService gameStateService; - public StartGameCommandHandler(IGameStateService gameStateService) + public StartGameCommandHandler(IBoardService boardService, IBoardEcsSyncService boardEcsSyncService, IGameStateService gameStateService) { + this.boardService = boardService; + this.boardEcsSyncService = boardEcsSyncService; this.gameStateService = gameStateService; } public void Handle(StartGameCommand command) { + boardService.InitializeEmptyBoard(); gameStateService.SetState(GameState.Preparing); + boardEcsSyncService.SyncBoard(boardService); + boardEcsSyncService.SyncGameState(gameStateService.Current, false); } } public sealed class OpenCellCommandHandler : IGameCommandHandler { + private readonly IBoardEcsSyncService boardEcsSyncService; + private readonly IBoardService boardService; + private readonly IGameStateService gameStateService; + + public OpenCellCommandHandler(IBoardService boardService, IBoardEcsSyncService boardEcsSyncService, IGameStateService gameStateService) + { + this.boardService = boardService; + this.boardEcsSyncService = boardEcsSyncService; + this.gameStateService = gameStateService; + } + public void Handle(OpenCellCommand command) { + if (gameStateService.Current != GameState.Preparing) + { + return; + } + + if (!boardService.GenerateAfterFirstClick(command.X, command.Y)) + { + return; + } + + gameStateService.SetState(GameState.Playing); + boardEcsSyncService.SyncBoard(boardService); + boardEcsSyncService.SyncGameState(gameStateService.Current, true); } } @@ -48,16 +80,23 @@ namespace Minesweeper.Commands public sealed class RestartCommandHandler : IGameCommandHandler { + private readonly IBoardEcsSyncService boardEcsSyncService; + private readonly IBoardService boardService; private readonly IGameStateService gameStateService; - public RestartCommandHandler(IGameStateService gameStateService) + public RestartCommandHandler(IBoardService boardService, IBoardEcsSyncService boardEcsSyncService, IGameStateService gameStateService) { + this.boardService = boardService; + this.boardEcsSyncService = boardEcsSyncService; this.gameStateService = gameStateService; } public void Handle(RestartCommand command) { + boardService.InitializeEmptyBoard(); gameStateService.SetState(GameState.Preparing); + boardEcsSyncService.SyncBoard(boardService); + boardEcsSyncService.SyncGameState(gameStateService.Current, false); } } @@ -77,16 +116,19 @@ namespace Minesweeper.Commands public sealed class GoToMenuCommandHandler : IGameCommandHandler { + private readonly IBoardEcsSyncService boardEcsSyncService; private readonly IGameStateService gameStateService; - public GoToMenuCommandHandler(IGameStateService gameStateService) + public GoToMenuCommandHandler(IBoardEcsSyncService boardEcsSyncService, IGameStateService gameStateService) { + this.boardEcsSyncService = boardEcsSyncService; this.gameStateService = gameStateService; } public void Handle(GoToMenuCommand command) { gameStateService.SetState(GameState.FieldSelection); + boardEcsSyncService.SyncGameState(gameStateService.Current, false); } } } diff --git a/Assets/Minesweeper/Runtime/Core/BoardCellData.cs b/Assets/Minesweeper/Runtime/Core/BoardCellData.cs new file mode 100644 index 0000000..149c020 --- /dev/null +++ b/Assets/Minesweeper/Runtime/Core/BoardCellData.cs @@ -0,0 +1,23 @@ +namespace Minesweeper.Core +{ + public readonly struct BoardCellData + { + public BoardCellData(int x, int y, bool isMine, bool isOpened, bool isFlagged, int neighborMines) + { + X = x; + Y = y; + IsMine = isMine; + IsOpened = isOpened; + IsFlagged = isFlagged; + NeighborMines = neighborMines; + } + + public int X { get; } + public int Y { get; } + public bool IsMine { get; } + public bool IsOpened { get; } + public bool IsFlagged { get; } + public int NeighborMines { get; } + public string DisplayValue => IsMine ? "M" : NeighborMines.ToString(); + } +} diff --git a/Assets/Minesweeper/Runtime/Core/BoardCellData.cs.meta b/Assets/Minesweeper/Runtime/Core/BoardCellData.cs.meta new file mode 100644 index 0000000..adbfc23 --- /dev/null +++ b/Assets/Minesweeper/Runtime/Core/BoardCellData.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 832de90f32c3d7d4d8526f82eb203866 \ No newline at end of file diff --git a/Assets/Minesweeper/Runtime/Core/BoardService.cs b/Assets/Minesweeper/Runtime/Core/BoardService.cs new file mode 100644 index 0000000..13b451b --- /dev/null +++ b/Assets/Minesweeper/Runtime/Core/BoardService.cs @@ -0,0 +1,219 @@ +using System; +using System.Collections.Generic; +using Minesweeper.Config; + +namespace Minesweeper.Core +{ + public sealed class BoardService : IBoardService + { + private const int DefaultWidth = 9; + private const int DefaultHeight = 9; + private const int DefaultMinesCount = 10; + + private readonly MinesweeperGameConfig config; + private readonly Random random = new Random(); + private CellData[,] cells; + + public BoardService(MinesweeperGameConfig config) + { + this.config = config; + } + + public int Width { get; private set; } + public int Height { get; private set; } + public int MinesCount { get; private set; } + public bool IsGenerated { get; private set; } + + public void InitializeEmptyBoard() + { + ResolveConfig(out var width, out var height, out var minesCount); + + Width = width; + Height = height; + MinesCount = minesCount; + IsGenerated = false; + cells = new CellData[Width, Height]; + + for (var x = 0; x < Width; x++) + { + for (var y = 0; y < Height; y++) + { + cells[x, y] = new CellData(x, y); + } + } + } + + public bool GenerateAfterFirstClick(int safeX, int safeY) + { + EnsureInitialized(); + + if (IsGenerated || !IsInside(safeX, safeY)) + { + return false; + } + + PlaceMines(safeX, safeY); + CalculateNeighborMines(); + + cells[safeX, safeY].IsOpened = true; + IsGenerated = true; + return true; + } + + public bool IsInside(int x, int y) + { + return cells != null && x >= 0 && y >= 0 && x < Width && y < Height; + } + + public bool TryGetCell(int x, int y, out BoardCellData cell) + { + if (!IsInside(x, y)) + { + cell = default; + return false; + } + + cell = ToData(cells[x, y]); + return true; + } + + public IReadOnlyList GetCells() + { + EnsureInitialized(); + + var result = new List(Width * Height); + for (var y = 0; y < Height; y++) + { + for (var x = 0; x < Width; x++) + { + result.Add(ToData(cells[x, y])); + } + } + + return result; + } + + private void ResolveConfig(out int width, out int height, out int minesCount) + { + width = config.Width; + height = config.Height; + minesCount = config.MinesCount; + + if (width <= 0 || height <= 0 || minesCount <= 0 || minesCount >= width * height) + { + width = DefaultWidth; + height = DefaultHeight; + minesCount = DefaultMinesCount; + } + } + + private void EnsureInitialized() + { + if (cells == null) + { + InitializeEmptyBoard(); + } + } + + private void PlaceMines(int safeX, int safeY) + { + var positions = new List(Width * Height - 1); + for (var x = 0; x < Width; x++) + { + for (var y = 0; y < Height; y++) + { + if (x == safeX && y == safeY) + { + continue; + } + + positions.Add(ToIndex(x, y)); + } + } + + Shuffle(positions); + + for (var i = 0; i < MinesCount; i++) + { + var index = positions[i]; + var x = index % Width; + var y = index / Width; + cells[x, y].IsMine = true; + } + } + + private void Shuffle(List values) + { + for (var i = values.Count - 1; i > 0; i--) + { + var j = random.Next(i + 1); + (values[i], values[j]) = (values[j], values[i]); + } + } + + private void CalculateNeighborMines() + { + for (var x = 0; x < Width; x++) + { + for (var y = 0; y < Height; y++) + { + if (cells[x, y].IsMine) + { + cells[x, y].NeighborMines = 0; + continue; + } + + cells[x, y].NeighborMines = CountNeighborMines(x, y); + } + } + } + + private int CountNeighborMines(int centerX, int centerY) + { + var count = 0; + for (var x = centerX - 1; x <= centerX + 1; x++) + { + for (var y = centerY - 1; y <= centerY + 1; y++) + { + if (x == centerX && y == centerY) + { + continue; + } + + if (IsInside(x, y) && cells[x, y].IsMine) + { + count++; + } + } + } + + return count; + } + + private int ToIndex(int x, int y) + { + return y * Width + x; + } + + private static BoardCellData ToData(CellData cell) + { + return new BoardCellData(cell.X, cell.Y, cell.IsMine, cell.IsOpened, cell.IsFlagged, cell.NeighborMines); + } + + private sealed class CellData + { + public CellData(int x, int y) + { + X = x; + Y = y; + } + + public int X { get; } + public int Y { get; } + public bool IsMine { get; set; } + public bool IsOpened { get; set; } + public bool IsFlagged { get; set; } + public int NeighborMines { get; set; } + } + } +} diff --git a/Assets/Minesweeper/Runtime/Core/BoardService.cs.meta b/Assets/Minesweeper/Runtime/Core/BoardService.cs.meta new file mode 100644 index 0000000..14335c2 --- /dev/null +++ b/Assets/Minesweeper/Runtime/Core/BoardService.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 11dec36e720c3e745b9dc11d588b5794 \ No newline at end of file diff --git a/Assets/Minesweeper/Runtime/Core/IBoardService.cs b/Assets/Minesweeper/Runtime/Core/IBoardService.cs new file mode 100644 index 0000000..ac65992 --- /dev/null +++ b/Assets/Minesweeper/Runtime/Core/IBoardService.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; + +namespace Minesweeper.Core +{ + public interface IBoardService + { + int Width { get; } + int Height { get; } + int MinesCount { get; } + bool IsGenerated { get; } + + void InitializeEmptyBoard(); + bool GenerateAfterFirstClick(int safeX, int safeY); + bool IsInside(int x, int y); + bool TryGetCell(int x, int y, out BoardCellData cell); + IReadOnlyList GetCells(); + } +} diff --git a/Assets/Minesweeper/Runtime/Core/IBoardService.cs.meta b/Assets/Minesweeper/Runtime/Core/IBoardService.cs.meta new file mode 100644 index 0000000..c254618 --- /dev/null +++ b/Assets/Minesweeper/Runtime/Core/IBoardService.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1339a8a92f1f56d4fa786ce011294737 \ No newline at end of file diff --git a/Assets/Minesweeper/Runtime/ECS/BoardEcsSyncService.cs b/Assets/Minesweeper/Runtime/ECS/BoardEcsSyncService.cs new file mode 100644 index 0000000..be1179a --- /dev/null +++ b/Assets/Minesweeper/Runtime/ECS/BoardEcsSyncService.cs @@ -0,0 +1,110 @@ +using Minesweeper.Core; +using Minesweeper.ECS.Components; +using Unity.Collections; +using Unity.Entities; + +namespace Minesweeper.ECS +{ + public sealed class BoardEcsSyncService : IBoardEcsSyncService + { + public void SyncBoard(IBoardService boardService) + { + if (!TryGetEntityManager(out var entityManager)) + { + return; + } + + ClearCells(entityManager); + + var boardEntity = GetOrCreateSingleton(entityManager); + entityManager.SetComponentData(boardEntity, new BoardConfigComponent + { + Width = boardService.Width, + Height = boardService.Height, + MinesCount = boardService.MinesCount + }); + + var archetype = entityManager.CreateArchetype(typeof(CellComponent)); + var cells = boardService.GetCells(); + for (var i = 0; i < cells.Count; i++) + { + var cell = cells[i]; + var entity = entityManager.CreateEntity(archetype); + entityManager.SetComponentData(entity, new CellComponent + { + X = cell.X, + Y = cell.Y, + IsMine = ToByte(cell.IsMine), + IsOpened = ToByte(cell.IsOpened), + IsFlagged = ToByte(cell.IsFlagged), + NeighborMines = cell.NeighborMines + }); + } + } + + public void SyncGameState(GameState state, bool hasFirstClick) + { + if (!TryGetEntityManager(out var entityManager)) + { + return; + } + + var stateEntity = GetOrCreateSingleton(entityManager); + entityManager.SetComponentData(stateEntity, new GameStateComponent + { + State = state, + HasFirstClick = ToByte(hasFirstClick) + }); + } + + private static bool TryGetEntityManager(out EntityManager entityManager) + { + var world = World.DefaultGameObjectInjectionWorld; + if (world == null || !world.IsCreated) + { + entityManager = default; + return false; + } + + entityManager = world.EntityManager; + return true; + } + + private static void ClearCells(EntityManager entityManager) + { + var query = entityManager.CreateEntityQuery(typeof(CellComponent)); + entityManager.DestroyEntity(query); + query.Dispose(); + } + + private static Entity GetOrCreateSingleton(EntityManager entityManager) where T : unmanaged, IComponentData + { + var query = entityManager.CreateEntityQuery(typeof(T)); + Entity entity; + + if (query.IsEmptyIgnoreFilter) + { + entity = entityManager.CreateEntity(typeof(T)); + } + else + { + var entities = query.ToEntityArray(Allocator.Temp); + entity = entities[0]; + for (var i = 1; i < entities.Length; i++) + { + entityManager.DestroyEntity(entities[i]); + } + + entities.Dispose(); + } + + query.Dispose(); + return entity; + } + + private static byte ToByte(bool value) + { + return value ? (byte)1 : (byte)0; + } + } +} diff --git a/Assets/Minesweeper/Runtime/ECS/BoardEcsSyncService.cs.meta b/Assets/Minesweeper/Runtime/ECS/BoardEcsSyncService.cs.meta new file mode 100644 index 0000000..0a5ad05 --- /dev/null +++ b/Assets/Minesweeper/Runtime/ECS/BoardEcsSyncService.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 0ad76f74587f648429ed4e14a39e4d13 \ No newline at end of file diff --git a/Assets/Minesweeper/Runtime/ECS/IBoardEcsSyncService.cs b/Assets/Minesweeper/Runtime/ECS/IBoardEcsSyncService.cs new file mode 100644 index 0000000..bbeb96e --- /dev/null +++ b/Assets/Minesweeper/Runtime/ECS/IBoardEcsSyncService.cs @@ -0,0 +1,10 @@ +using Minesweeper.Core; + +namespace Minesweeper.ECS +{ + public interface IBoardEcsSyncService + { + void SyncBoard(IBoardService boardService); + void SyncGameState(GameState state, bool hasFirstClick); + } +} diff --git a/Assets/Minesweeper/Runtime/ECS/IBoardEcsSyncService.cs.meta b/Assets/Minesweeper/Runtime/ECS/IBoardEcsSyncService.cs.meta new file mode 100644 index 0000000..9c880aa --- /dev/null +++ b/Assets/Minesweeper/Runtime/ECS/IBoardEcsSyncService.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 779db0eb469cc2449bce502b6e46231d \ No newline at end of file diff --git a/Assets/Minesweeper/Runtime/Infrastructure/MinesweeperLifetimeScope.cs b/Assets/Minesweeper/Runtime/Infrastructure/MinesweeperLifetimeScope.cs index bf0c15e..ae0cc72 100644 --- a/Assets/Minesweeper/Runtime/Infrastructure/MinesweeperLifetimeScope.cs +++ b/Assets/Minesweeper/Runtime/Infrastructure/MinesweeperLifetimeScope.cs @@ -1,6 +1,7 @@ using Minesweeper.Commands; using Minesweeper.Config; using Minesweeper.Core; +using Minesweeper.ECS; using Minesweeper.Presentation.Adapters; using Minesweeper.Presentation.Factories; using Minesweeper.Presentation.Presenters; @@ -20,6 +21,8 @@ namespace Minesweeper.Infrastructure { builder.RegisterInstance(GetConfig()); builder.Register(Lifetime.Singleton).As(); + builder.Register(Lifetime.Singleton).As(); + builder.Register(Lifetime.Singleton).As(); builder.Register(Lifetime.Singleton).As(); builder.Register(Lifetime.Singleton).As(); builder.Register(Lifetime.Singleton).As(); diff --git a/Assets/Minesweeper/Runtime/Presentation/Factories/CellViewFactory.cs b/Assets/Minesweeper/Runtime/Presentation/Factories/CellViewFactory.cs index ef0407d..72a5271 100644 --- a/Assets/Minesweeper/Runtime/Presentation/Factories/CellViewFactory.cs +++ b/Assets/Minesweeper/Runtime/Presentation/Factories/CellViewFactory.cs @@ -6,5 +6,10 @@ namespace Minesweeper.Presentation.Factories { return $"bt_{x}_{y}_{(isMine ? "M" : value.ToString())}"; } + + public string BuildCellName(int x, int y, string displayValue) + { + return $"bt_{x}_{y}_{displayValue}"; + } } } diff --git a/Assets/Minesweeper/Runtime/Presentation/Factories/ICellViewFactory.cs b/Assets/Minesweeper/Runtime/Presentation/Factories/ICellViewFactory.cs index 09d51e7..eba650f 100644 --- a/Assets/Minesweeper/Runtime/Presentation/Factories/ICellViewFactory.cs +++ b/Assets/Minesweeper/Runtime/Presentation/Factories/ICellViewFactory.cs @@ -3,5 +3,6 @@ namespace Minesweeper.Presentation.Factories public interface ICellViewFactory { string BuildCellName(int x, int y, int value, bool isMine); + string BuildCellName(int x, int y, string displayValue); } } diff --git a/Assets/Minesweeper/Runtime/Presentation/ReadModels/GameReadModel.cs b/Assets/Minesweeper/Runtime/Presentation/ReadModels/GameReadModel.cs index 452fc2f..a3d563c 100644 --- a/Assets/Minesweeper/Runtime/Presentation/ReadModels/GameReadModel.cs +++ b/Assets/Minesweeper/Runtime/Presentation/ReadModels/GameReadModel.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using Minesweeper.Config; using Minesweeper.Core; @@ -6,17 +7,29 @@ namespace Minesweeper.Presentation.ReadModels public sealed class GameReadModel : IGameReadModel { private readonly MinesweeperGameConfig config; + private readonly IBoardService boardService; private readonly IGameStateService gameStateService; - public GameReadModel(MinesweeperGameConfig config, IGameStateService gameStateService) + public GameReadModel(MinesweeperGameConfig config, IBoardService boardService, IGameStateService gameStateService) { this.config = config; + this.boardService = boardService; this.gameStateService = gameStateService; } public GameState State => gameStateService.Current; - public int Width => config.Width; - public int Height => config.Height; - public int MinesCount => config.MinesCount; + public int Width => boardService.Width > 0 ? boardService.Width : config.Width; + public int Height => boardService.Height > 0 ? boardService.Height : config.Height; + public int MinesCount => boardService.MinesCount > 0 ? boardService.MinesCount : config.MinesCount; + + public bool TryGetCell(int x, int y, out BoardCellData cell) + { + return boardService.TryGetCell(x, y, out cell); + } + + public IReadOnlyList GetCells() + { + return boardService.GetCells(); + } } } diff --git a/Assets/Minesweeper/Runtime/Presentation/ReadModels/IGameReadModel.cs b/Assets/Minesweeper/Runtime/Presentation/ReadModels/IGameReadModel.cs index 28351d4..5920493 100644 --- a/Assets/Minesweeper/Runtime/Presentation/ReadModels/IGameReadModel.cs +++ b/Assets/Minesweeper/Runtime/Presentation/ReadModels/IGameReadModel.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using Minesweeper.Core; namespace Minesweeper.Presentation.ReadModels @@ -8,5 +9,8 @@ namespace Minesweeper.Presentation.ReadModels int Width { get; } int Height { get; } int MinesCount { get; } + + bool TryGetCell(int x, int y, out BoardCellData cell); + IReadOnlyList GetCells(); } } diff --git a/ProjectSettings/EntitiesClientSettings.asset b/ProjectSettings/EntitiesClientSettings.asset new file mode 100644 index 0000000..baf6668 --- /dev/null +++ b/ProjectSettings/EntitiesClientSettings.asset @@ -0,0 +1,16 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &1 +MonoBehaviour: + m_ObjectHideFlags: 53 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: e2ea235c1fcfe29488ed97c467a0da53, type: 3} + m_Name: + m_EditorClassIdentifier: Unity.Entities.Build::Unity.Entities.Build.EntitiesClientSettings + FilterSettings: + ExcludedBakingSystemAssemblies: []