Files
2026-06-07 01:12:10 +07:00

377 lines
12 KiB
C#

using System;
using System.Collections.Generic;
using Minesweeper.Config;
using Minesweeper.Core;
using Minesweeper.Presentation.Factories;
using UnityEngine;
using UnityEngine.UI;
namespace Minesweeper.Presentation.Views
{
public sealed class BoardView : MonoBehaviour, IBoardView
{
private const float ResizeRefreshDelaySeconds = 0.5f;
private const float ResizeSizeEpsilon = 0.5f;
private const float MaximumCellPixelsPerUnitMultiplier = 1f;
private const float ContentPaddingReferenceCellSize = 202f;
private const float ContentPaddingReferencePadding = 15f;
private const float MinimumContentPadding = 1f;
[SerializeField] private GameObject root;
[SerializeField] private RectTransform boardPanel;
[SerializeField] private GridLayoutGroup gridLayoutGroup;
[SerializeField] private MinesweeperUiConfig uiConfig;
private readonly Dictionary<int, CellView> cellsByCoordinate = new Dictionary<int, CellView>();
private readonly Stack<CellView> pooledCells = new Stack<CellView>();
private IReadOnlyList<BoardCellData> currentCells;
private readonly List<BoardCellData> currentCellsCache = new List<BoardCellData>();
private bool inputEnabled = true;
private bool currentRevealUnflaggedMines;
private bool resizeRefreshPending;
private int currentBoardWidth;
private int currentBoardHeight;
private float currentContentPadding = MinimumContentPadding;
private float currentPixelsPerUnitMultiplier = 1f;
private float resizeStableAt;
private Vector2 lastObservedLayoutSize;
private GameObject Root => root != null ? root : gameObject;
public event Action CellPressStarted;
public event Action CellPressEnded;
public event Action PauseRequested;
public event Action<int, int> CellOpenRequested;
public event Action<int, int> CellFlagRequested;
private void Awake()
{
if (root == null)
{
root = gameObject;
}
if (boardPanel == null)
{
boardPanel = transform as RectTransform;
}
}
private void Update()
{
TrackResize();
}
public void BindConfig(MinesweeperUiConfig config)
{
uiConfig = config;
}
public void Show()
{
Root.SetActive(true);
ResetResizeTracking();
}
public void Hide()
{
Root.SetActive(false);
}
public void Rebuild(IReadOnlyList<BoardCellData> cells, int width, int height, ICellViewFactory cellViewFactory, bool revealUnflaggedMines)
{
Clear();
currentBoardWidth = width;
currentBoardHeight = height;
CacheCurrentCells(cells);
currentRevealUnflaggedMines = revealUnflaggedMines;
var layoutWasEnabled = gridLayoutGroup != null && gridLayoutGroup.enabled;
SetGridLayoutEnabled(false);
try
{
ConfigureGrid(width, height);
for (var i = 0; i < cells.Count; i++)
{
var cell = cells[i];
var view = CreateCell(cell, cellViewFactory);
if (view != null)
{
view.Render(cell, uiConfig, currentPixelsPerUnitMultiplier, currentContentPadding, revealUnflaggedMines);
}
}
}
finally
{
SetGridLayoutEnabled(layoutWasEnabled);
}
}
public void Refresh(IReadOnlyList<BoardCellData> cells, bool revealUnflaggedMines)
{
CacheCurrentCells(cells);
currentRevealUnflaggedMines = revealUnflaggedMines;
RefreshCells(cells, revealUnflaggedMines);
}
public void RefreshCells(IReadOnlyList<BoardCellData> 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);
}
}
}
public void SetInputEnabled(bool enabled)
{
inputEnabled = enabled;
foreach (var cell in cellsByCoordinate.Values)
{
cell.SetInputEnabled(enabled);
}
}
private void TrackResize()
{
if (boardPanel == null || !boardPanel.gameObject.activeInHierarchy || currentCells == null || currentBoardWidth <= 0 || currentBoardHeight <= 0)
{
return;
}
var layoutSize = GetLayoutSourceSize();
if (layoutSize.x <= 0f || layoutSize.y <= 0f)
{
return;
}
if (HasSizeChanged(layoutSize, lastObservedLayoutSize))
{
lastObservedLayoutSize = layoutSize;
resizeStableAt = Time.unscaledTime + ResizeRefreshDelaySeconds;
resizeRefreshPending = true;
return;
}
if (resizeRefreshPending && Time.unscaledTime >= resizeStableAt)
{
resizeRefreshPending = false;
RefreshLayout();
}
}
private void RefreshLayout()
{
ConfigureGrid(currentBoardWidth, currentBoardHeight);
Refresh(currentCells, currentRevealUnflaggedMines);
}
private void ConfigureGrid(int width, int height)
{
if (gridLayoutGroup == null || boardPanel == null || uiConfig == null)
{
return;
}
Canvas.ForceUpdateCanvases();
var layoutSize = GetLayoutSourceSize();
var panelWidth = layoutSize.x > 0f ? layoutSize.x : uiConfig.ReferenceCellSize * width;
var panelHeight = layoutSize.y > 0f ? layoutSize.y : uiConfig.ReferenceCellSize * height;
var cellSize = CalculateCellSize(panelWidth, panelHeight, width, height);
var padding = cellSize * uiConfig.BoardPaddingRatio;
var spacing = cellSize * uiConfig.GridSpacingRatio;
boardPanel.anchorMin = Vector2.zero;
boardPanel.anchorMax = Vector2.one;
boardPanel.offsetMin = new Vector2(padding, padding);
boardPanel.offsetMax = new Vector2(-padding, -padding);
gridLayoutGroup.constraint = GridLayoutGroup.Constraint.FixedColumnCount;
gridLayoutGroup.constraintCount = width;
gridLayoutGroup.padding = new RectOffset();
gridLayoutGroup.spacing = new Vector2(spacing, spacing);
gridLayoutGroup.cellSize = new Vector2(cellSize, cellSize);
currentPixelsPerUnitMultiplier = Mathf.Min(MaximumCellPixelsPerUnitMultiplier, uiConfig.ReferenceCellSize / cellSize);
currentContentPadding = CalculateContentPadding(cellSize);
lastObservedLayoutSize = layoutSize;
}
private Vector2 GetLayoutSourceSize()
{
var parentRect = boardPanel.parent as RectTransform;
var rect = parentRect != null ? parentRect.rect : boardPanel.rect;
return rect.size;
}
private void ResetResizeTracking()
{
resizeRefreshPending = false;
if (boardPanel != null)
{
lastObservedLayoutSize = GetLayoutSourceSize();
}
}
private float CalculateCellSize(float panelWidth, float panelHeight, int width, int height)
{
var widthUnits = width + 2f * uiConfig.BoardPaddingRatio + Mathf.Max(0, width - 1) * uiConfig.GridSpacingRatio;
var heightUnits = height + 2f * uiConfig.BoardPaddingRatio + Mathf.Max(0, height - 1) * uiConfig.GridSpacingRatio;
var cellByWidth = panelWidth / widthUnits;
var cellByHeight = panelHeight / heightUnits;
return Mathf.Max(uiConfig.MinimumCellSize, Mathf.Floor(Mathf.Min(cellByWidth, cellByHeight)));
}
private CellView CreateCell(BoardCellData cell, ICellViewFactory cellViewFactory)
{
if (gridLayoutGroup == null)
{
return null;
}
var view = GetOrCreateCell(cell, cellViewFactory);
if (view == null)
{
return null;
}
view.SetInputEnabled(inputEnabled);
view.OpenRequested += OnCellOpenRequested;
view.FlagRequested += OnCellFlagRequested;
view.PressStarted += OnCellPressStarted;
view.PressEnded += OnCellPressEnded;
cellsByCoordinate[ToKey(cell.X, cell.Y)] = view;
return view;
}
private CellView GetOrCreateCell(BoardCellData cell, ICellViewFactory cellViewFactory)
{
CellView view;
if (pooledCells.Count > 0)
{
view = pooledCells.Pop();
view.transform.SetParent(gridLayoutGroup.transform, false);
view.transform.SetAsLastSibling();
view.Initialize(cell.X, cell.Y);
view.gameObject.SetActive(true);
return view;
}
view = cellViewFactory.CreateCell(cell, gridLayoutGroup.transform);
if (view != null)
{
view.transform.SetAsLastSibling();
}
return view;
}
private void Clear()
{
foreach (var cell in cellsByCoordinate.Values)
{
if (cell != null)
{
cell.OpenRequested -= OnCellOpenRequested;
cell.FlagRequested -= OnCellFlagRequested;
cell.PressStarted -= OnCellPressStarted;
cell.PressEnded -= OnCellPressEnded;
PoolCell(cell);
}
}
cellsByCoordinate.Clear();
currentCellsCache.Clear();
currentCells = null;
}
private void CacheCurrentCells(IReadOnlyList<BoardCellData> 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)
{
cell.gameObject.SetActive(false);
pooledCells.Push(cell);
}
private void SetGridLayoutEnabled(bool enabled)
{
if (gridLayoutGroup != null)
{
gridLayoutGroup.enabled = enabled;
}
}
private void OnCellOpenRequested(int x, int y)
{
CellOpenRequested?.Invoke(x, y);
}
private void OnCellFlagRequested(int x, int y)
{
CellFlagRequested?.Invoke(x, y);
}
private void OnCellPressStarted()
{
CellPressStarted?.Invoke();
}
private void OnCellPressEnded()
{
CellPressEnded?.Invoke();
}
private void OnPauseClicked()
{
PauseRequested?.Invoke();
}
private static float CalculateContentPadding(float cellSize)
{
return Mathf.Max(MinimumContentPadding, cellSize * ContentPaddingReferencePadding / ContentPaddingReferenceCellSize);
}
private static bool HasSizeChanged(Vector2 current, Vector2 previous)
{
return Mathf.Abs(current.x - previous.x) > ResizeSizeEpsilon || Mathf.Abs(current.y - previous.y) > ResizeSizeEpsilon;
}
private static int ToKey(int x, int y)
{
return (y << 16) ^ x;
}
}
}