Files
match3-unity/Assets/Scripts/Services/GameBoardService.cs
2025-12-15 03:44:53 +08:00

396 lines
15 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using Cysharp.Threading.Tasks;
using Enums;
using Models.Interfaces;
using Presenter;
using ScriptableObjects;
using Services.Interfaces;
using Structs;
using UnityEngine;
using Utils;
using VContainer.Unity;
using Views;
using Object = UnityEngine.Object;
using Random = UnityEngine.Random;
namespace Services {
public class GameBoardService : IGameBoardService, ITickable, IDisposable {
#region Inject
private readonly IGameBoard gameBoard;
private readonly GameVariables gameVariables;
private readonly IMatchService matchService;
private readonly IScoreService scoreService;
private readonly IObjectPool<GemView> objectPool;
private readonly Transform gemsHolder;
#endregion
#region Variables
private readonly List<GemPresenter> gemPresenters = new List<GemPresenter>();
private readonly ScorePresenter scorePresenter;
private GameState currentState = GameState.Move;
#endregion
public GameBoardService(IGameBoard gameBoard, GameVariables gameVariables, IMatchService matchService, IScoreService scoreSerivce, IObjectPool<GemView> objectPool, Transform gemsHolder, ScorePresenter scorePresenter) {
this.gameBoard = gameBoard;
this.gameVariables = gameVariables;
this.matchService = matchService;
this.scoreService = scoreSerivce;
this.objectPool = objectPool;
this.gemsHolder = gemsHolder;
this.scorePresenter = scorePresenter;
}
public void Tick() {
foreach (GemPresenter gemPresenter in gemPresenters) {
gemPresenter.Tick(this.gameVariables.gemSpeed);
}
this.scorePresenter.Tick(this.gameVariables.scoreSpeed);
}
//Instantiates background tiles and calls SpawnGems
//Uses MatchService.MatchesAt to avoid matching Gems
public void Setup() {
for (int x = 0; x < this.gameBoard.Width; x++)
for (int y = 0; y < this.gameBoard.Height; y++)
{
Vector2 position = new Vector2(x, y);
GameObject backgroundTile = Object.Instantiate(this.gameVariables.bgTilePrefabs, position, Quaternion.identity);
backgroundTile.transform.SetParent(this.gemsHolder);
backgroundTile.name = "BG Tile - " + x + ", " + y;
int gemToUse = RandomUtils.RandomGemTypeAsInt();
int iterations = 0;
while (this.matchService.MatchesAt(new Vector2Int(x, y), (GemType)gemToUse) && iterations < 100)
{
gemToUse = RandomUtils.RandomGemTypeAsInt();
iterations++;
}
SpawnGem(new Vector2Int(x, y), (GemType)gemToUse);
}
this.currentState = GameState.Move;
}
//Uses the ObjectPool to spawn a gem at the given position
private void SpawnGem(Vector2Int position, GemType gemType) {
GemView gemView = this.objectPool.Get(gemType, position, this.gameVariables.dropHeight);
gemView.name = "Gem - " + position.x + ", " + position.y + ' ' + gemType;
// If we randomly spawned a bomb, give it a random color group (so it can match by color).
int scoreValue = GemUtils.GetGemValues(gemType, this.gameVariables.gemsPrefabs).scoreValue;
Gem gem = new Gem(gemType, position, scoreValue);
gemView.Bind(gem);
this.gemPresenters.Add(new GemPresenter(gem, gemView));
SetGem(new Vector2Int(position.x, position.y), gem);
}
private void SpawnBomb(Vector2Int position, GemType color) {
// remove existing gem/view at that position
DestroyMatchedGems(position);
GemView gemView = this.objectPool.Get(GemType.Bomb, position, this.gameVariables.dropHeight);
gemView.name = "Gem - " + position.x + ", " + position.y + ' ' + GemType.Bomb;
int scoreValue = GemUtils.GetGemValues(color, this.gameVariables.gemsPrefabs).scoreValue;
Gem bombGem = new Gem(GemType.Bomb, position, scoreValue, color);
gemView.Bind(bombGem);
this.gemPresenters.Add(new GemPresenter(bombGem, gemView));
SetGem(position, bombGem);
}
//Sets the gem on the GameBoard
private void SetGem(Vector2Int position, Gem gem) {
this.gameBoard.SetGemAt(new Vector2Int(position.x, position.y), gem);
}
//Gets the gem from the GameBoard
private Gem GetGem(Vector2Int position) {
return this.gameBoard.GetGemAt(position);
}
//Listens to InputService OnSwapRequest
public async UniTask<bool> TrySwap(Vector2Int from, Vector2Int to) {
if (this.currentState != GameState.Move)
return false;
if (!InBounds(from) || !InBounds(to))
return false;
if (!AreAdjacentCardinal(from, to))
return false;
Gem fromGem = GetGem(from);
Gem toGem = GetGem(to);
if(fromGem == null || toGem == null)
return false;
this.currentState = GameState.Wait;
ApplySwap(from, to, fromGem, toGem);
await UniTask.Delay(600);
this.matchService.SetLastSwap(from, to);
this.matchService.FindAllMatches();
bool hasMatch = this.matchService.CurrentMatches.Count > 0;
if (!hasMatch) {
ApplySwap(to, from, fromGem, toGem);
await UniTask.Delay(600);
this.currentState = GameState.Move;
return false;
}
List<Vector2Int> protectedPositions = ApplyPendingBombSpawns();
await DestroyMatchesAsync(protectedPositions);
this.currentState = GameState.Move;
return true;
}
private void ApplySwap(Vector2Int posA, Vector2Int posB, Gem gemA, Gem gemB) {
// swap their stored positions
gemA.SetPosition(posB);
gemB.SetPosition(posA);
// update grid
SetGem(posA, gemB);
SetGem(posB, gemA);
}
private List<Vector2Int> ApplyPendingBombSpawns() {
List<Vector2Int> positions = new List<Vector2Int>();
foreach (BombSpawnRequest bomSpawnRequest in this.matchService.PendingBombSpawns) {
positions.Add(bomSpawnRequest.Position);
SpawnBomb(bomSpawnRequest.Position, bomSpawnRequest.Color);
}
this.matchService.ClearPendingBombs();
return positions;
}
private async UniTask DestroyMatchesAsync(List<Vector2Int> protectedPositions) {
// Build initial queues from current matches
Queue<Vector2Int> bombsToProcess = new Queue<Vector2Int>();
List<Vector2Int> processedBombs = new List<Vector2Int>();
List<Vector2Int> regularToDestroy = new List<Vector2Int>();
for (int i = 0; i < this.matchService.CurrentMatches.Count; i++) {
Gem matchedGem = this.matchService.CurrentMatches[i];
if (matchedGem == null)
continue;
Vector2Int pos = matchedGem.Position;
// If a bomb was spawned at this cell due to 4+ creation, it must survive this destruction pass.
if (protectedPositions != null && protectedPositions.Contains(pos))
continue;
Gem current = GetGem(pos);
if (current == null)
continue;
if (current.Type == GemType.Bomb) {
bombsToProcess.Enqueue(pos);
} else {
regularToDestroy.Add(pos);
}
}
// Process bombs: neighbors first (after delay), then the bomb itself (after delay).
while (bombsToProcess.Count > 0) {
Vector2Int bombPos = bombsToProcess.Dequeue();
if (processedBombs.Contains(bombPos))
continue;
Gem bomb = GetGem(bombPos);
if (bomb is not { Type: GemType.Bomb })
continue;
processedBombs.Add(bombPos);
// Delay before destroying neighbor group
if (this.gameVariables.bombDelay > 0f) {
int msDelay = Mathf.RoundToInt(this.gameVariables.bombDelay * 1000f);
await UniTask.Delay(msDelay);
}
// Collect cross neighbors
foreach (Vector2Int neighborPosition in CrossNeighbors(bombPos, this.gameVariables.bombRadius)) {
if (!InBounds(neighborPosition))
continue;
Gem g = GetGem(neighborPosition);
if (g == null)
continue;
// If we encounter another bomb, queue it (so it explodes too).
if (g.Type == GemType.Bomb) {
if (!processedBombs.Contains(neighborPosition))
bombsToProcess.Enqueue(neighborPosition);
continue;
}
regularToDestroy.Add(neighborPosition);
}
// Destroy the neighbor group now
foreach (Vector2Int position in regularToDestroy.ToList()) {
Gem gem = GetGem(position);
if (gem == null)
continue;
this.scoreService.ScoreCheck(gem.ScoreValue);
DestroyMatchedGems(position);
}
regularToDestroy.Clear();
// Delay before destroying the bomb itself
if (this.gameVariables.bombSelfDelay > 0f) {
int ms = Mathf.RoundToInt(this.gameVariables.bombSelfDelay * 1000f);
await UniTask.Delay(ms);
}
// Destroy the bomb
Gem b = GetGem(bombPos);
if (b != null && b.Type == GemType.Bomb) {
this.scoreService.ScoreCheck(b.ScoreValue);
DestroyMatchedGems(bombPos);
}
}
// Destroy any remaining regular matches (non-bomb) after bomb processing
foreach (Vector2Int pos in regularToDestroy) {
Gem g = GetGem(pos);
if (g == null)
continue;
this.scoreService.ScoreCheck(g.ScoreValue);
DestroyMatchedGems(pos);
}
// Now we can cascade
await MoveGemsDown();
}
private static IEnumerable<Vector2Int> CrossNeighbors(Vector2Int center, int radius) {
// center excluded for "neighbors first"
for (int i = 1; i <= radius; i++) {
yield return center + Vector2Int.left * i;
yield return center + Vector2Int.right * i;
yield return center + Vector2Int.up * i;
yield return center + Vector2Int.down * i;
}
}
private async UniTask MoveGemsDown() {
await UniTask.Delay(50);
int nullCounter = 0;
for (int x = 0; x < this.gameBoard.Width; x++)
{
for (int y = 0; y < this.gameBoard.Height; y++)
{
Gem currentGem = this.gameBoard.GetGemAt(new Vector2Int(x, y));
if (currentGem == null)
{
nullCounter++;
}
else if (nullCounter > 0)
{
currentGem.SetPosition(new Vector2Int(currentGem.Position.x, currentGem.Position.y - nullCounter));
SetGem(currentGem.Position, currentGem);
SetGem(new Vector2Int(x,y), null);
}
}
nullCounter = 0;
}
await FillBoard();
}
private async UniTask FillBoard() {
await UniTask.Delay(5);
RefillBoard();
await UniTask.Delay(5);
this.matchService.FindAllMatches();
if (this.matchService.CurrentMatches.Count > 0) {
await UniTask.Delay(5);
// In cascades, there is no "creating slot" bomb protection.
await DestroyMatchesAsync(new List<Vector2Int>());
} else {
await UniTask.Delay(5);
this.currentState = GameState.Move;
}
}
private void RefillBoard() {
for (int x = 0; x < this.gameBoard.Width; x++)
{
for (int y = 0; y < this.gameBoard.Height; y++)
{
Gem currentGem = this.gameBoard.GetGemAt(new Vector2Int(x,y));
if (currentGem == null) {
int gemToUse = RandomUtils.RandomGemTypeAsInt();
SpawnGem(new Vector2Int(x, y), (GemType)gemToUse);
}
}
}
}
private void DestroyMatchedGems(Vector2Int position) {
List<GemView> gemsViews = GemsViews();
Gem currentGem = this.gameBoard.GetGemAt(position);
if (currentGem != null)
{
GemView gemView = gemsViews.FirstOrDefault(gv => gv.Gem == currentGem);
if (gemView is null) {
return;
}
this.objectPool.Release(gemView);
RemovePresenterFor(gemView);
SetGem(position, null);
}
}
#region Utils
private void RemovePresenterFor(GemView gemView) {
if (gemView is null) {
return;
}
GemPresenter presenter = this.gemPresenters.FirstOrDefault(p => p.GemView == gemView);
this.gemPresenters.Remove(presenter);
}
private List<GemView> GemsViews() {
return this.gemsHolder.GetComponentsInChildren<GemView>().ToList();
}
private bool InBounds(Vector2Int p) {
return p.x >= 0 && p.x < this.gameBoard.Width && p.y >= 0 && p.y < this.gameBoard.Height;
}
private static bool AreAdjacentCardinal(Vector2Int a, Vector2Int b) {
Vector2Int d = b - a;
return (Mathf.Abs(d.x) == 1 && d.y == 0) || (Mathf.Abs(d.y) == 1 && d.x == 0);
}
#endregion
public void Dispose() {
this.objectPool.Clear();
}
}
}