From f4a2cac16d555f266c3b13cb10e0a0c8cdb7496d Mon Sep 17 00:00:00 2001 From: Jesus Castro Date: Mon, 15 Dec 2025 04:45:17 +0800 Subject: [PATCH] Separate bomb logic --- Assets/Scripts/Scopes/LevelLifetimeScope.cs | 2 + Assets/Scripts/Services/BombService.cs | 117 +++++++++++++++ Assets/Scripts/Services/BombService.cs.meta | 3 + Assets/Scripts/Services/GameBoardService.cs | 134 ++++++------------ .../Services/Interfaces/IBombService.cs | 21 +++ .../Services/Interfaces/IBombService.cs.meta | 3 + 6 files changed, 191 insertions(+), 89 deletions(-) create mode 100644 Assets/Scripts/Services/BombService.cs create mode 100644 Assets/Scripts/Services/BombService.cs.meta create mode 100644 Assets/Scripts/Services/Interfaces/IBombService.cs create mode 100644 Assets/Scripts/Services/Interfaces/IBombService.cs.meta diff --git a/Assets/Scripts/Scopes/LevelLifetimeScope.cs b/Assets/Scripts/Scopes/LevelLifetimeScope.cs index 55a10f0..24e5815 100644 --- a/Assets/Scripts/Scopes/LevelLifetimeScope.cs +++ b/Assets/Scripts/Scopes/LevelLifetimeScope.cs @@ -34,6 +34,8 @@ namespace Scopes new ObjectPoolService(this.gameVariables.gemsPrefabs, this.gemsHolder), Lifetime.Scoped); + builder.Register(Lifetime.Scoped); + builder.Register(Lifetime.Scoped); builder.Register(Lifetime.Scoped).AsImplementedInterfaces(); diff --git a/Assets/Scripts/Services/BombService.cs b/Assets/Scripts/Services/BombService.cs new file mode 100644 index 0000000..53b6b11 --- /dev/null +++ b/Assets/Scripts/Services/BombService.cs @@ -0,0 +1,117 @@ +// Assets/Scripts/Services/BombService.cs +using System; +using System.Collections.Generic; +using System.Linq; +using Cysharp.Threading.Tasks; +using Enums; +using Models; +using Services.Interfaces; +using UnityEngine; + +namespace Services +{ + public class BombService : IBombService + { + public IReadOnlyList CollectTriggeredBombs(IReadOnlyList matchPositions) + { + if (matchPositions == null || matchPositions.Count == 0) + return Array.Empty(); + + // Activation: any match cell that is a bomb OR cardinal-adjacent to a bomb. + // NOTE: The actual "is bomb?" check depends on the board, so we only return + // the positions to be checked/queued by caller if desired. + // To keep BombService isolated, we’ll let DetonateChainAsync validate bombs via getGemAt. + // Here we return: all matched positions + their cardinal neighbors. + HashSet candidates = new HashSet(matchPositions); + + foreach (Vector2Int p in matchPositions) + { + candidates.Add(p + Vector2Int.left); + candidates.Add(p + Vector2Int.right); + candidates.Add(p + Vector2Int.up); + candidates.Add(p + Vector2Int.down); + } + + return candidates.ToList(); + } + + public async UniTask DetonateChainAsync( + IReadOnlyList initialBombs, + Func inBounds, + Func getGemAt, + Func destroyAtAsync, + int radius, + float bombDelaySeconds, + float bombSelfDelaySeconds) + { + if (initialBombs == null || initialBombs.Count == 0) + return; + + Queue queue = new Queue(initialBombs); + HashSet processed = new HashSet(); + + while (queue.Count > 0) + { + Vector2Int bombPos = queue.Dequeue(); + if (processed.Contains(bombPos)) + continue; + + if (!inBounds(bombPos)) + continue; + + Gem bomb = getGemAt(bombPos); + if (bomb is not { Type: GemType.Bomb }) + continue; + + processed.Add(bombPos); + + // Delay before neighbor blast + int neighborDelayMs = Mathf.Max(0, Mathf.RoundToInt(bombDelaySeconds * 1000f)); + if (neighborDelayMs > 0) + await UniTask.Delay(neighborDelayMs); + + // Blast neighbors first (cross) + foreach (Vector2Int n in CrossNeighbors(bombPos, radius)) + { + if (!inBounds(n)) + continue; + + Gem g = getGemAt(n); + if (g == null) + continue; + + // Chain: if another bomb is in blast area, queue it + if (g.Type == GemType.Bomb) + { + if (!processed.Contains(n)) + queue.Enqueue(n); + continue; + } + + await destroyAtAsync(n); + } + + // Delay before destroying the bomb itself + int selfDelayMs = Mathf.Max(0, Mathf.RoundToInt(bombSelfDelaySeconds * 1000f)); + if (selfDelayMs > 0) + await UniTask.Delay(selfDelayMs); + + // Destroy bomb last + Gem stillBomb = getGemAt(bombPos); + if (stillBomb is { Type: GemType.Bomb }) + await destroyAtAsync(bombPos); + } + } + + private static IEnumerable CrossNeighbors(Vector2Int center, int radius) + { + 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; + } + } + } +} \ No newline at end of file diff --git a/Assets/Scripts/Services/BombService.cs.meta b/Assets/Scripts/Services/BombService.cs.meta new file mode 100644 index 0000000..c9d8fe0 --- /dev/null +++ b/Assets/Scripts/Services/BombService.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 25e4f7a32f2f4b5e9b9f5984d10d17ab +timeCreated: 1765744719 \ No newline at end of file diff --git a/Assets/Scripts/Services/GameBoardService.cs b/Assets/Scripts/Services/GameBoardService.cs index 4043038..9bd503c 100644 --- a/Assets/Scripts/Services/GameBoardService.cs +++ b/Assets/Scripts/Services/GameBoardService.cs @@ -22,6 +22,7 @@ namespace Services { private readonly GameVariables gameVariables; private readonly IMatchService matchService; private readonly IScoreService scoreService; + private readonly IBombService bombService; private readonly IObjectPool objectPool; private readonly Transform gemsHolder; #endregion @@ -32,11 +33,12 @@ namespace Services { private GameState currentState = GameState.Move; #endregion - public GameBoardService(IGameBoard gameBoard, GameVariables gameVariables, IMatchService matchService, IScoreService scoreSerivce, IObjectPool objectPool, Transform gemsHolder, ScorePresenter scorePresenter) { + public GameBoardService(IGameBoard gameBoard, GameVariables gameVariables, IMatchService matchService, IScoreService scoreSerivce, IBombService bombService, IObjectPool objectPool, Transform gemsHolder, ScorePresenter scorePresenter) { this.gameBoard = gameBoard; this.gameVariables = gameVariables; this.matchService = matchService; this.scoreService = scoreSerivce; + this.bombService = bombService; this.objectPool = objectPool; this.gemsHolder = gemsHolder; this.scorePresenter = scorePresenter; @@ -179,109 +181,63 @@ namespace Services { } private async UniTask DestroyMatchesAsync(List protectedPositions) { - // Build initial queues from current matches - Queue bombsToProcess = new Queue(); - List processedBombs = new List(); - List regularToDestroy = new List(); - + // Collect match positions, excluding protected (bomb creation slots). + List matchPositions = new List(this.matchService.CurrentMatches.Count); for (int i = 0; i < this.matchService.CurrentMatches.Count; i++) { - Gem matchedGem = this.matchService.CurrentMatches[i]; - if (matchedGem == null) - continue; + var m = this.matchService.CurrentMatches[i]; + if (m == null) continue; - Vector2Int pos = matchedGem.Position; - - // If a bomb was spawned at this cell due to 4+ creation, it must survive this destruction pass. + Vector2Int pos = m.Position; 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); - } + matchPositions.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; + // Bombs are handled by BombService so they can respect delays + chaining. + foreach (Vector2Int pos in matchPositions.Distinct().ToList()) { + var g = GetGem(pos); + if (g == null) continue; + if (g.Type == GemType.Bomb) continue; this.scoreService.ScoreCheck(g.ScoreValue); DestroyMatchedGems(pos); } - // Now we can cascade + IReadOnlyList bombCandidates = this.bombService.CollectTriggeredBombs(matchPositions); + + List initialBombs = new List(); + foreach (Vector2Int p in bombCandidates) { + if (!InBounds(p)) continue; + var g = GetGem(p); + if (g is { Type: GemType.Bomb }) + initialBombs.Add(p); + } + initialBombs = initialBombs.Distinct().ToList(); + + await this.bombService.DetonateChainAsync( + initialBombs, + InBounds, + GetGem, + DestroyAtAsync, + this.gameVariables.bombRadius, + this.gameVariables.bombDelay, + this.gameVariables.bombSelfDelay + ); + await MoveGemsDown(); } + private UniTask DestroyAtAsync(Vector2Int pos) { + Gem gem = GetGem(pos); + if (gem == null) + return UniTask.CompletedTask; + + this.scoreService.ScoreCheck(gem.ScoreValue); + DestroyMatchedGems(pos); + return UniTask.CompletedTask; + } + private static IEnumerable CrossNeighbors(Vector2Int center, int radius) { // center excluded for "neighbors first" for (int i = 1; i <= radius; i++) { diff --git a/Assets/Scripts/Services/Interfaces/IBombService.cs b/Assets/Scripts/Services/Interfaces/IBombService.cs new file mode 100644 index 0000000..1dd8d0a --- /dev/null +++ b/Assets/Scripts/Services/Interfaces/IBombService.cs @@ -0,0 +1,21 @@ +// Assets/Scripts/Services/Interfaces/IBombService.cs +using System; +using System.Collections.Generic; +using Cysharp.Threading.Tasks; +using UnityEngine; + +namespace Services.Interfaces +{ + public interface IBombService + { + IReadOnlyList CollectTriggeredBombs(IReadOnlyList matchPositions); + UniTask DetonateChainAsync( + IReadOnlyList initialBombs, + Func inBounds, + Func getGemAt, + Func destroyAtAsync, + int radius, + float bombDelaySeconds, + float bombSelfDelaySeconds); + } +} \ No newline at end of file diff --git a/Assets/Scripts/Services/Interfaces/IBombService.cs.meta b/Assets/Scripts/Services/Interfaces/IBombService.cs.meta new file mode 100644 index 0000000..f723b25 --- /dev/null +++ b/Assets/Scripts/Services/Interfaces/IBombService.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 596ff7c31da640178508db656fc88e9b +timeCreated: 1765744682 \ No newline at end of file