[Add] Field and mine generation

This commit is contained in:
2026-06-06 21:18:10 +07:00
parent 8ed9cc655f
commit 1a6f8901a2
17 changed files with 481 additions and 7 deletions
@@ -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<StartGameCommand>
{
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<OpenCellCommand>
{
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<RestartCommand>
{
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<GoToMenuCommand>
{
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);
}
}
}
@@ -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();
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 832de90f32c3d7d4d8526f82eb203866
@@ -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<BoardCellData> GetCells()
{
EnsureInitialized();
var result = new List<BoardCellData>(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<int>(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<int> 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; }
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 11dec36e720c3e745b9dc11d588b5794
@@ -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<BoardCellData> GetCells();
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 1339a8a92f1f56d4fa786ce011294737
@@ -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<BoardConfigComponent>(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<GameStateComponent>(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<T>(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;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 0ad76f74587f648429ed4e14a39e4d13
@@ -0,0 +1,10 @@
using Minesweeper.Core;
namespace Minesweeper.ECS
{
public interface IBoardEcsSyncService
{
void SyncBoard(IBoardService boardService);
void SyncGameState(GameState state, bool hasFirstClick);
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 779db0eb469cc2449bce502b6e46231d
@@ -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<GameStateService>(Lifetime.Singleton).As<IGameStateService>();
builder.Register<BoardService>(Lifetime.Singleton).As<IBoardService>();
builder.Register<BoardEcsSyncService>(Lifetime.Singleton).As<IBoardEcsSyncService>();
builder.Register<GameReadModel>(Lifetime.Singleton).As<IGameReadModel>();
builder.Register<GameStateViewAdapter>(Lifetime.Singleton).As<IGameStateViewAdapter>();
builder.Register<CellViewFactory>(Lifetime.Singleton).As<ICellViewFactory>();
@@ -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}";
}
}
}
@@ -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);
}
}
@@ -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<BoardCellData> GetCells()
{
return boardService.GetCells();
}
}
}
@@ -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<BoardCellData> GetCells();
}
}
@@ -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: []