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 cellsByCoordinate = new Dictionary(); private readonly Stack pooledCells = new Stack(); private IReadOnlyList currentCells; 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 CellOpenRequested; public event Action 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 cells, int width, int height, ICellViewFactory cellViewFactory, bool revealUnflaggedMines) { Clear(); currentBoardWidth = width; currentBoardHeight = height; currentCells = 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 cells, bool revealUnflaggedMines) { currentCells = cells; currentRevealUnflaggedMines = revealUnflaggedMines; for (var i = 0; i < cells.Count; i++) { var cell = cells[i]; 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.Initialize(cell.X, cell.Y); view.gameObject.SetActive(true); return view; } return cellViewFactory.CreateCell(cell, gridLayoutGroup.transform); } 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(); } 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; } } }