From 5a58c9031a24b0caa16fdbc78221d061fb2d0b7e Mon Sep 17 00:00:00 2001 From: Konstantin Dyachenko Date: Sun, 7 Jun 2026 01:12:10 +0700 Subject: [PATCH] [Fix] ECS --- .../Scripts/Commands/GameCommandHandlers.cs | 15 ++- Assets/Scripts/Core/BoardActionResult.cs | 6 +- Assets/Scripts/Core/BoardService.cs | 33 +++++- Assets/Scripts/Core/IBoardService.cs | 2 + Assets/Scripts/ECS/BoardEcsSyncService.cs | 103 ++++++++++++++++-- .../ECS/Components/BoardConfigComponent.cs | 3 + .../Scripts/ECS/Components/CellChangedTag.cs | 8 ++ .../ECS/Components/CellChangedTag.cs.meta | 2 + .../Scripts/ECS/Components/CellComponent.cs | 2 + Assets/Scripts/ECS/IBoardEcsSyncService.cs | 2 + .../Presentation/Presenters/GamePresenter.cs | 15 ++- .../Presentation/ReadModels/GameReadModel.cs | 17 +-- .../Presentation/ReadModels/IGameReadModel.cs | 1 + .../Scripts/Presentation/Views/BoardView.cs | 50 ++++++++- .../Scripts/Presentation/Views/IBoardView.cs | 1 + .../Presentation/Views/NullBoardView.cs | 1 + 16 files changed, 225 insertions(+), 36 deletions(-) create mode 100644 Assets/Scripts/ECS/Components/CellChangedTag.cs create mode 100644 Assets/Scripts/ECS/Components/CellChangedTag.cs.meta diff --git a/Assets/Scripts/Commands/GameCommandHandlers.cs b/Assets/Scripts/Commands/GameCommandHandlers.cs index 8d0f84d..5544792 100644 --- a/Assets/Scripts/Commands/GameCommandHandlers.cs +++ b/Assets/Scripts/Commands/GameCommandHandlers.cs @@ -69,6 +69,7 @@ namespace Minesweeper.Commands } var state = gameStateService.Current; + var generatedOnThisCommand = false; if (state != GameState.Preparing && state != GameState.Playing) { return; @@ -85,6 +86,8 @@ namespace Minesweeper.Commands { return; } + + generatedOnThisCommand = true; } var result = boardService.OpenCell(command.X, command.Y); @@ -106,7 +109,15 @@ namespace Minesweeper.Commands gameStateService.SetState(GameState.Playing); } - boardEcsSyncService.SyncBoard(boardService); + if (generatedOnThisCommand) + { + boardEcsSyncService.SyncBoard(boardService); + } + else + { + boardEcsSyncService.SyncCells(result.ChangedCells, boardService); + } + boardEcsSyncService.SyncGameState(gameStateService.Current, boardService.IsGenerated); } } @@ -145,7 +156,7 @@ namespace Minesweeper.Commands return; } - boardEcsSyncService.SyncBoard(boardService); + boardEcsSyncService.SyncCells(result.ChangedCells, boardService); boardEcsSyncService.SyncGameState(gameStateService.Current, boardService.IsGenerated); } } diff --git a/Assets/Scripts/Core/BoardActionResult.cs b/Assets/Scripts/Core/BoardActionResult.cs index 48f9962..7ed4dad 100644 --- a/Assets/Scripts/Core/BoardActionResult.cs +++ b/Assets/Scripts/Core/BoardActionResult.cs @@ -1,19 +1,23 @@ +using System.Collections.Generic; + namespace Minesweeper.Core { public readonly struct BoardActionResult { - public BoardActionResult(bool changed, bool hitMine, bool won, bool invalid) + public BoardActionResult(bool changed, bool hitMine, bool won, bool invalid, IReadOnlyList changedCells = null) { Changed = changed; HitMine = hitMine; Won = won; Invalid = invalid; + ChangedCells = changedCells; } public bool Changed { get; } public bool HitMine { get; } public bool Won { get; } public bool Invalid { get; } + public IReadOnlyList ChangedCells { 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/Scripts/Core/BoardService.cs b/Assets/Scripts/Core/BoardService.cs index 24e7fc6..d4a94a5 100644 --- a/Assets/Scripts/Core/BoardService.cs +++ b/Assets/Scripts/Core/BoardService.cs @@ -7,6 +7,7 @@ namespace Minesweeper.Core { private readonly IGameSettingsService settingsService; private readonly Random random = new Random(); + private readonly List changedCells = new List(); private CellData[,] cells; public BoardService(IGameSettingsService settingsService) @@ -19,7 +20,9 @@ namespace Minesweeper.Core public int MinesCount { get; private set; } public bool IsGenerated { get; private set; } public int OpenedSafeCellsCount { get; private set; } + public int FlaggedCellsCount { get; private set; } public int SafeCellsCount => Width * Height - MinesCount; + public IReadOnlyList LastChangedCells => changedCells; public void InitializeEmptyBoard() { @@ -27,7 +30,9 @@ namespace Minesweeper.Core Height = settingsService.SizeY; MinesCount = Math.Min(settingsService.MinesCount, Width * Height - 1); OpenedSafeCellsCount = 0; + FlaggedCellsCount = 0; IsGenerated = false; + changedCells.Clear(); cells = new CellData[Width, Height]; for (var x = 0; x < Width; x++) @@ -58,6 +63,7 @@ namespace Minesweeper.Core public BoardActionResult OpenCell(int x, int y) { EnsureInitialized(); + changedCells.Clear(); if (!IsGenerated || !IsInside(x, y)) { @@ -73,7 +79,8 @@ namespace Minesweeper.Core if (cell.IsMine) { cell.IsOpened = true; - return new BoardActionResult(true, true, false, false); + AddChangedCell(cell); + return new BoardActionResult(true, true, false, false, changedCells); } if (cell.NeighborMines == 0) @@ -85,12 +92,13 @@ namespace Minesweeper.Core OpenSafeCell(cell); } - return new BoardActionResult(true, false, IsWin(), false); + return new BoardActionResult(true, false, IsWin(), false, changedCells); } public BoardActionResult ToggleFlag(int x, int y) { EnsureInitialized(); + changedCells.Clear(); if (!IsInside(x, y)) { @@ -103,8 +111,19 @@ namespace Minesweeper.Core return BoardActionResult.NoChange; } - cell.IsFlagged = !cell.IsFlagged; - return new BoardActionResult(true, false, false, false); + if (cell.IsFlagged) + { + cell.IsFlagged = false; + FlaggedCellsCount--; + } + else + { + cell.IsFlagged = true; + FlaggedCellsCount++; + } + + AddChangedCell(cell); + return new BoardActionResult(true, false, false, false, changedCells); } public bool IsInside(int x, int y) @@ -273,6 +292,12 @@ namespace Minesweeper.Core cell.IsOpened = true; OpenedSafeCellsCount++; + AddChangedCell(cell); + } + + private void AddChangedCell(CellData cell) + { + changedCells.Add(ToData(cell)); } private bool IsWin() diff --git a/Assets/Scripts/Core/IBoardService.cs b/Assets/Scripts/Core/IBoardService.cs index 65bd8f1..07f77e0 100644 --- a/Assets/Scripts/Core/IBoardService.cs +++ b/Assets/Scripts/Core/IBoardService.cs @@ -9,7 +9,9 @@ namespace Minesweeper.Core int MinesCount { get; } bool IsGenerated { get; } int OpenedSafeCellsCount { get; } + int FlaggedCellsCount { get; } int SafeCellsCount { get; } + IReadOnlyList LastChangedCells { get; } void InitializeEmptyBoard(); bool GenerateAfterFirstClick(int safeX, int safeY); diff --git a/Assets/Scripts/ECS/BoardEcsSyncService.cs b/Assets/Scripts/ECS/BoardEcsSyncService.cs index 7c899ff..029bee6 100644 --- a/Assets/Scripts/ECS/BoardEcsSyncService.cs +++ b/Assets/Scripts/ECS/BoardEcsSyncService.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using Minesweeper.Core; using Minesweeper.ECS.Components; using Unity.Collections; @@ -7,6 +8,10 @@ namespace Minesweeper.ECS { public sealed class BoardEcsSyncService : IBoardEcsSyncService { + private readonly Dictionary cellsByIndex = new Dictionary(); + private int syncedWidth; + private int syncedHeight; + public void ClearBoard() { if (!TryGetEntityManager(out var entityManager)) @@ -15,6 +20,9 @@ namespace Minesweeper.ECS } ClearCells(entityManager); + cellsByIndex.Clear(); + syncedWidth = 0; + syncedHeight = 0; } public void SyncBoard(IBoardService boardService) @@ -24,14 +32,27 @@ namespace Minesweeper.ECS return; } - ClearCells(entityManager); + if (syncedWidth != boardService.Width || syncedHeight != boardService.Height || cellsByIndex.Count != boardService.Width * boardService.Height) + { + ClearCells(entityManager); + cellsByIndex.Clear(); + syncedWidth = boardService.Width; + syncedHeight = boardService.Height; + } + else + { + ClearChangedTags(entityManager); + } var boardEntity = GetOrCreateSingleton(entityManager); entityManager.SetComponentData(boardEntity, new BoardConfigComponent { Width = boardService.Width, Height = boardService.Height, - MinesCount = boardService.MinesCount + MinesCount = boardService.MinesCount, + OpenedSafeCellsCount = boardService.OpenedSafeCellsCount, + FlaggedCellsCount = boardService.FlaggedCellsCount, + IsGenerated = ToByte(boardService.IsGenerated) }); var archetype = entityManager.CreateArchetype(typeof(CellComponent)); @@ -39,16 +60,45 @@ namespace Minesweeper.ECS for (var i = 0; i < cells.Count; i++) { var cell = cells[i]; - var entity = entityManager.CreateEntity(archetype); - entityManager.SetComponentData(entity, new CellComponent + var index = ToIndex(cell.X, cell.Y, boardService.Width); + if (!cellsByIndex.TryGetValue(index, out var entity) || !entityManager.Exists(entity)) { - X = cell.X, - Y = cell.Y, - IsMine = ToByte(cell.IsMine), - IsOpened = ToByte(cell.IsOpened), - IsFlagged = ToByte(cell.IsFlagged), - NeighborMines = cell.NeighborMines - }); + entity = entityManager.CreateEntity(archetype); + cellsByIndex[index] = entity; + } + + SetCell(entityManager, entity, cell, boardService.Width, false); + } + } + + public void SyncCells(IReadOnlyList cells, IBoardService boardService) + { + if (cells == null || cells.Count == 0 || !TryGetEntityManager(out var entityManager)) + { + return; + } + + var boardEntity = GetOrCreateSingleton(entityManager); + entityManager.SetComponentData(boardEntity, new BoardConfigComponent + { + Width = boardService.Width, + Height = boardService.Height, + MinesCount = boardService.MinesCount, + OpenedSafeCellsCount = boardService.OpenedSafeCellsCount, + FlaggedCellsCount = boardService.FlaggedCellsCount, + IsGenerated = ToByte(boardService.IsGenerated) + }); + + ClearChangedTags(entityManager); + + for (var i = 0; i < cells.Count; i++) + { + var cell = cells[i]; + var index = ToIndex(cell.X, cell.Y, boardService.Width); + if (cellsByIndex.TryGetValue(index, out var entity) && entityManager.Exists(entity)) + { + SetCell(entityManager, entity, cell, boardService.Width, true); + } } } @@ -87,6 +137,37 @@ namespace Minesweeper.ECS query.Dispose(); } + private static void ClearChangedTags(EntityManager entityManager) + { + var query = entityManager.CreateEntityQuery(typeof(CellChangedTag)); + entityManager.RemoveComponent(query); + query.Dispose(); + } + + private static void SetCell(EntityManager entityManager, Entity entity, BoardCellData cell, int width, bool markChanged) + { + entityManager.SetComponentData(entity, new CellComponent + { + X = cell.X, + Y = cell.Y, + Index = ToIndex(cell.X, cell.Y, width), + IsMine = ToByte(cell.IsMine), + IsOpened = ToByte(cell.IsOpened), + IsFlagged = ToByte(cell.IsFlagged), + NeighborMines = cell.NeighborMines + }); + + if (markChanged && !entityManager.HasComponent(entity)) + { + entityManager.AddComponent(entity); + } + } + + private static int ToIndex(int x, int y, int width) + { + return y * width + x; + } + private static Entity GetOrCreateSingleton(EntityManager entityManager) where T : unmanaged, IComponentData { var query = entityManager.CreateEntityQuery(typeof(T)); diff --git a/Assets/Scripts/ECS/Components/BoardConfigComponent.cs b/Assets/Scripts/ECS/Components/BoardConfigComponent.cs index 4cf36be..cffda65 100644 --- a/Assets/Scripts/ECS/Components/BoardConfigComponent.cs +++ b/Assets/Scripts/ECS/Components/BoardConfigComponent.cs @@ -7,5 +7,8 @@ namespace Minesweeper.ECS.Components public int Width; public int Height; public int MinesCount; + public int OpenedSafeCellsCount; + public int FlaggedCellsCount; + public byte IsGenerated; } } diff --git a/Assets/Scripts/ECS/Components/CellChangedTag.cs b/Assets/Scripts/ECS/Components/CellChangedTag.cs new file mode 100644 index 0000000..4ecbf9d --- /dev/null +++ b/Assets/Scripts/ECS/Components/CellChangedTag.cs @@ -0,0 +1,8 @@ +using Unity.Entities; + +namespace Minesweeper.ECS.Components +{ + public struct CellChangedTag : IComponentData + { + } +} diff --git a/Assets/Scripts/ECS/Components/CellChangedTag.cs.meta b/Assets/Scripts/ECS/Components/CellChangedTag.cs.meta new file mode 100644 index 0000000..a6c5f79 --- /dev/null +++ b/Assets/Scripts/ECS/Components/CellChangedTag.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5702c8f40c71e7444a70e7aa73d8a9db \ No newline at end of file diff --git a/Assets/Scripts/ECS/Components/CellComponent.cs b/Assets/Scripts/ECS/Components/CellComponent.cs index f474513..185db10 100644 --- a/Assets/Scripts/ECS/Components/CellComponent.cs +++ b/Assets/Scripts/ECS/Components/CellComponent.cs @@ -6,9 +6,11 @@ namespace Minesweeper.ECS.Components { public int X; public int Y; + public int Index; public byte IsMine; public byte IsOpened; public byte IsFlagged; public int NeighborMines; } + } diff --git a/Assets/Scripts/ECS/IBoardEcsSyncService.cs b/Assets/Scripts/ECS/IBoardEcsSyncService.cs index 8d90860..926c08f 100644 --- a/Assets/Scripts/ECS/IBoardEcsSyncService.cs +++ b/Assets/Scripts/ECS/IBoardEcsSyncService.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using Minesweeper.Core; namespace Minesweeper.ECS @@ -6,6 +7,7 @@ namespace Minesweeper.ECS { void ClearBoard(); void SyncBoard(IBoardService boardService); + void SyncCells(IReadOnlyList cells, IBoardService boardService); void SyncGameState(GameState state, bool hasFirstClick); } } diff --git a/Assets/Scripts/Presentation/Presenters/GamePresenter.cs b/Assets/Scripts/Presentation/Presenters/GamePresenter.cs index eb470a1..627d934 100644 --- a/Assets/Scripts/Presentation/Presenters/GamePresenter.cs +++ b/Assets/Scripts/Presentation/Presenters/GamePresenter.cs @@ -70,7 +70,7 @@ namespace Minesweeper.Presentation.Presenters { topPanelPresenter.SetCellPressActive(false); commandDispatcher.Dispatch(new OpenCellCommand(x, y)); - RefreshBoard(); + RefreshChangedCellsOrBoard(); topPanelPresenter.RefreshCounters(); UpdateBoardInput(); } @@ -78,7 +78,7 @@ namespace Minesweeper.Presentation.Presenters private void OnCellFlagRequested(int x, int y) { commandDispatcher.Dispatch(new ToggleFlagCommand(x, y)); - RefreshBoard(); + RefreshChangedCellsOrBoard(); topPanelPresenter.RefreshCounters(); UpdateBoardInput(); } @@ -156,6 +156,17 @@ namespace Minesweeper.Presentation.Presenters boardView.Refresh(readModel.GetCells(), IsFinalState()); } + private void RefreshChangedCellsOrBoard() + { + if (IsFinalState()) + { + RefreshBoard(); + return; + } + + boardView.RefreshCells(readModel.GetChangedCells(), false); + } + private bool IsFinalState() { var state = gameStateService.Current; diff --git a/Assets/Scripts/Presentation/ReadModels/GameReadModel.cs b/Assets/Scripts/Presentation/ReadModels/GameReadModel.cs index 3082eae..aa23456 100644 --- a/Assets/Scripts/Presentation/ReadModels/GameReadModel.cs +++ b/Assets/Scripts/Presentation/ReadModels/GameReadModel.cs @@ -20,7 +20,7 @@ namespace Minesweeper.Presentation.ReadModels public int Width => boardService.Width > 0 ? boardService.Width : settingsService.SizeX; public int Height => boardService.Height > 0 ? boardService.Height : settingsService.SizeY; public int MinesCount => boardService.MinesCount > 0 ? boardService.MinesCount : settingsService.MinesCount; - public int FlaggedCellsCount => CountFlaggedCells(); + public int FlaggedCellsCount => boardService.FlaggedCellsCount; public int RemainingMinesCount => MinesCount - FlaggedCellsCount; public bool TryGetCell(int x, int y, out BoardCellData cell) @@ -33,19 +33,10 @@ namespace Minesweeper.Presentation.ReadModels return boardService.GetCells(); } - private int CountFlaggedCells() + public IReadOnlyList GetChangedCells() { - var cells = boardService.GetCells(); - var count = 0; - for (var i = 0; i < cells.Count; i++) - { - if (cells[i].IsFlagged) - { - count++; - } - } - - return count; + return boardService.LastChangedCells; } + } } diff --git a/Assets/Scripts/Presentation/ReadModels/IGameReadModel.cs b/Assets/Scripts/Presentation/ReadModels/IGameReadModel.cs index be20876..ffed54b 100644 --- a/Assets/Scripts/Presentation/ReadModels/IGameReadModel.cs +++ b/Assets/Scripts/Presentation/ReadModels/IGameReadModel.cs @@ -14,5 +14,6 @@ namespace Minesweeper.Presentation.ReadModels bool TryGetCell(int x, int y, out BoardCellData cell); IReadOnlyList GetCells(); + IReadOnlyList GetChangedCells(); } } diff --git a/Assets/Scripts/Presentation/Views/BoardView.cs b/Assets/Scripts/Presentation/Views/BoardView.cs index 0c17a91..50583e2 100644 --- a/Assets/Scripts/Presentation/Views/BoardView.cs +++ b/Assets/Scripts/Presentation/Views/BoardView.cs @@ -25,6 +25,7 @@ namespace Minesweeper.Presentation.Views private readonly Dictionary cellsByCoordinate = new Dictionary(); private readonly Stack pooledCells = new Stack(); private IReadOnlyList currentCells; + private readonly List currentCellsCache = new List(); private bool inputEnabled = true; private bool currentRevealUnflaggedMines; private bool resizeRefreshPending; @@ -82,7 +83,7 @@ namespace Minesweeper.Presentation.Views Clear(); currentBoardWidth = width; currentBoardHeight = height; - currentCells = cells; + CacheCurrentCells(cells); currentRevealUnflaggedMines = revealUnflaggedMines; var layoutWasEnabled = gridLayoutGroup != null && gridLayoutGroup.enabled; SetGridLayoutEnabled(false); @@ -109,12 +110,23 @@ namespace Minesweeper.Presentation.Views public void Refresh(IReadOnlyList cells, bool revealUnflaggedMines) { - currentCells = cells; + CacheCurrentCells(cells); currentRevealUnflaggedMines = revealUnflaggedMines; + RefreshCells(cells, revealUnflaggedMines); + } + + public void RefreshCells(IReadOnlyList cells, bool revealUnflaggedMines) + { + if (cells == null) + { + return; + } + for (var i = 0; i < cells.Count; i++) { var cell = cells[i]; + UpdateCachedCell(cell); if (cellsByCoordinate.TryGetValue(ToKey(cell.X, cell.Y), out var view)) { view.Render(cell, uiConfig, currentPixelsPerUnitMultiplier, currentContentPadding, revealUnflaggedMines); @@ -250,12 +262,19 @@ namespace Minesweeper.Presentation.Views { view = pooledCells.Pop(); view.transform.SetParent(gridLayoutGroup.transform, false); + view.transform.SetAsLastSibling(); view.Initialize(cell.X, cell.Y); view.gameObject.SetActive(true); return view; } - return cellViewFactory.CreateCell(cell, gridLayoutGroup.transform); + view = cellViewFactory.CreateCell(cell, gridLayoutGroup.transform); + if (view != null) + { + view.transform.SetAsLastSibling(); + } + + return view; } private void Clear() @@ -273,6 +292,31 @@ namespace Minesweeper.Presentation.Views } cellsByCoordinate.Clear(); + currentCellsCache.Clear(); + currentCells = null; + } + + private void CacheCurrentCells(IReadOnlyList cells) + { + currentCellsCache.Clear(); + if (cells != null) + { + for (var i = 0; i < cells.Count; i++) + { + currentCellsCache.Add(cells[i]); + } + } + + currentCells = currentCellsCache; + } + + private void UpdateCachedCell(BoardCellData cell) + { + var index = cell.Y * currentBoardWidth + cell.X; + if (index >= 0 && index < currentCellsCache.Count) + { + currentCellsCache[index] = cell; + } } private void PoolCell(CellView cell) diff --git a/Assets/Scripts/Presentation/Views/IBoardView.cs b/Assets/Scripts/Presentation/Views/IBoardView.cs index 59b16c6..b472d06 100644 --- a/Assets/Scripts/Presentation/Views/IBoardView.cs +++ b/Assets/Scripts/Presentation/Views/IBoardView.cs @@ -17,6 +17,7 @@ namespace Minesweeper.Presentation.Views void Hide(); void Rebuild(IReadOnlyList cells, int width, int height, ICellViewFactory cellViewFactory, bool revealUnflaggedMines); void Refresh(IReadOnlyList cells, bool revealUnflaggedMines); + void RefreshCells(IReadOnlyList cells, bool revealUnflaggedMines); void SetInputEnabled(bool enabled); } } diff --git a/Assets/Scripts/Presentation/Views/NullBoardView.cs b/Assets/Scripts/Presentation/Views/NullBoardView.cs index 72b1af4..981549b 100644 --- a/Assets/Scripts/Presentation/Views/NullBoardView.cs +++ b/Assets/Scripts/Presentation/Views/NullBoardView.cs @@ -16,6 +16,7 @@ namespace Minesweeper.Presentation.Views public void Hide() { } public void Rebuild(IReadOnlyList cells, int width, int height, ICellViewFactory cellViewFactory, bool revealUnflaggedMines) { } public void Refresh(IReadOnlyList cells, bool revealUnflaggedMines) { } + public void RefreshCells(IReadOnlyList cells, bool revealUnflaggedMines) { } public void SetInputEnabled(bool enabled) { } } }