Compare commits

..

11 Commits

Author SHA1 Message Date
a5c7096cea Add README.md 2026-01-09 03:14:45 +08:00
993cca2943 Push project 2026-01-07 22:52:00 +08:00
e2409106d4 Fixed Bomb Spawning 2025-12-19 17:18:18 +08:00
73db75d40a Update BombService
- L and T shapes will be considered as 4+
2025-12-19 15:55:13 +08:00
aae09b5696 Adjust GameVariables 2025-12-18 18:13:31 +08:00
3956a6ffab Update BombService.cs
- Matched bombs now explode one at a time
2025-12-18 14:38:57 +08:00
667a39c260 Cleanup 2025-12-18 03:49:05 +08:00
668bd03f63 Optimization and Documentation 2025-12-18 03:44:51 +08:00
1d134ffc40 Performance improvements 2025-12-18 03:01:20 +08:00
c6ebe96a12 Update gitignore 2025-12-18 01:44:50 +08:00
577f78f413 Added delays to GameVariables 2025-12-18 01:43:59 +08:00
20 changed files with 507 additions and 227 deletions

2
.gitignore vendored
View File

@@ -97,3 +97,5 @@ InitTestScene*.unity*
# Auto-generated scenes by play mode tests # Auto-generated scenes by play mode tests
/[Aa]ssets/[Ii]nit[Tt]est[Ss]cene*.unity* /[Aa]ssets/[Ii]nit[Tt]est[Ss]cene*.unity*
*.zip

View File

@@ -12,6 +12,7 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: 9f37e854902a46cb8bd927cf84ab450c, type: 3} m_Script: {fileID: 11500000, guid: 9f37e854902a46cb8bd927cf84ab450c, type: 3}
m_Name: GameVariables m_Name: GameVariables
m_EditorClassIdentifier: m_EditorClassIdentifier:
debugMode: 1
bgTilePrefabs: {fileID: 2914066502361773997, guid: 3f39182b81f944a4d93213431acb41c3, bgTilePrefabs: {fileID: 2914066502361773997, guid: 3f39182b81f944a4d93213431acb41c3,
type: 3} type: 3}
gemsPrefabs: gemsPrefabs:
@@ -64,5 +65,8 @@ MonoBehaviour:
bombDelay: 2 bombDelay: 2
bombRadius: 2 bombRadius: 2
dropHeight: 2 dropHeight: 2
gemSpeed: 4 gemSpeed: 5
scoreSpeed: 3 scoreSpeed: 3
cascadeDelayMs: 150
swapDelayMs: 600
fillBoardDelayMs: 500

View File

@@ -4,6 +4,10 @@ using UnityEngine;
namespace ScriptableObjects { namespace ScriptableObjects {
[CreateAssetMenu(fileName = "GameVariables", menuName = "Game Variables")] [CreateAssetMenu(fileName = "GameVariables", menuName = "Game Variables")]
public class GameVariables : ScriptableObject { public class GameVariables : ScriptableObject {
[Header("Debug")]
[Tooltip("Turns Gem switching on/off")]
public bool debugMode;
[Header("Prefabs")] [Header("Prefabs")]
public GameObject bgTilePrefabs; public GameObject bgTilePrefabs;
public GemTypeValues[] gemsPrefabs; public GemTypeValues[] gemsPrefabs;
@@ -28,5 +32,10 @@ namespace ScriptableObjects {
[Header("Score")] [Header("Score")]
public float scoreSpeed = 5; public float scoreSpeed = 5;
[Header("Delays")]
public int cascadeDelayMs = 150;
public int swapDelayMs = 600;
public int fillBoardDelayMs = 250;
} }
} }

View File

@@ -25,7 +25,7 @@ namespace Services
public void PlaySound(AudioClip clip) public void PlaySound(AudioClip clip)
{ {
if (clip == null) return; if (clip == null) return;
if (this.source == null) return; // In case called before Initialize in some edge setup if (this.source == null) return; // In case it's called before Initialize in some edge setup
this.source.PlayOneShot(clip); this.source.PlayOneShot(clip);
} }

View File

@@ -19,8 +19,7 @@ namespace Services
private Vector2Int lastSwapFrom; private Vector2Int lastSwapFrom;
private Vector2Int lastSwapTo; private Vector2Int lastSwapTo;
private BombSpawnRequest? pendingBombSpawn; private List<BombSpawnRequest> pendingBombSpawns = new List<BombSpawnRequest>();
public BombSpawnRequest? PendingBombSpawn => this.pendingBombSpawn;
public BombService(GameVariables gameVariables, IGameBoard gameBoard) { public BombService(GameVariables gameVariables, IGameBoard gameBoard) {
this.gameVariables = gameVariables; this.gameVariables = gameVariables;
@@ -34,8 +33,8 @@ namespace Services
ClearPendingBombs(); ClearPendingBombs();
} }
public UniTask<List<Vector2Int>> GetInitialBombs(List<Vector2Int> protectedPositions, List<Vector2Int> bombCandidates) { public UniTask<List<Vector2Int>> GetInitialBombs(List<Vector2Int> protectedPositions, HashSet<Vector2Int> bombCandidates) {
List<Vector2Int> initialBombs = new List<Vector2Int>(); HashSet<Vector2Int> initialBombs = new HashSet<Vector2Int>();
foreach (Vector2Int p in bombCandidates) { foreach (Vector2Int p in bombCandidates) {
if (!GemUtils.IsInBounds(p, this.gameBoard)) continue; if (!GemUtils.IsInBounds(p, this.gameBoard)) continue;
@@ -47,15 +46,13 @@ namespace Services
initialBombs.Add(p); initialBombs.Add(p);
} }
return UniTask.FromResult(initialBombs.Distinct().ToList()); return UniTask.FromResult(initialBombs.ToList());
} }
public List<Vector2Int> ApplyPendingBombSpawns(Action<Vector2Int, GemType, bool> spawnGem) { public List<Vector2Int> ApplyPendingBombSpawns(Action<Vector2Int, GemType, bool> spawnGem) {
List<Vector2Int> positions = new List<Vector2Int>(); List<Vector2Int> positions = new List<Vector2Int>();
BombSpawnRequest? bombSpawnRequest = PendingBombSpawn;
if (bombSpawnRequest != null) { foreach (BombSpawnRequest bombRequest in this.pendingBombSpawns) {
BombSpawnRequest bombRequest = PendingBombSpawn.GetValueOrDefault();
positions.Add(bombRequest.Position); positions.Add(bombRequest.Position);
spawnGem(bombRequest.Position, bombRequest.Color, true); spawnGem(bombRequest.Position, bombRequest.Color, true);
} }
@@ -64,7 +61,7 @@ namespace Services
return positions; return positions;
} }
public void DetectBombSpawnFromLastSwap(List<Gem> currentMatches) { public void DetectBombSpawnFromLastSwap(HashSet<Gem> currentMatches) {
Vector2Int from = this.lastSwapFrom; Vector2Int from = this.lastSwapFrom;
Vector2Int to = this.lastSwapTo; Vector2Int to = this.lastSwapTo;
@@ -72,7 +69,7 @@ namespace Services
TryCreateBombSpawnAt(to, currentMatches); TryCreateBombSpawnAt(to, currentMatches);
} }
private void TryCreateBombSpawnAt(Vector2Int pivot, List<Gem> currentMatches) { private void TryCreateBombSpawnAt(Vector2Int pivot, HashSet<Gem> currentMatches) {
Gem pivotGem = this.gameBoard.GetGemAt(pivot); Gem pivotGem = this.gameBoard.GetGemAt(pivot);
if (pivotGem == null) if (pivotGem == null)
return; return;
@@ -81,25 +78,42 @@ namespace Services
if (pivotGem.Type == GemType.Bomb) if (pivotGem.Type == GemType.Bomb)
return; return;
if (currentMatches.All(g => g.Position != pivot)) if (currentMatches == null || !currentMatches.Contains(pivotGem))
return; return;
// Only create a bomb if pivot is part of a straight 4+ line of the SAME color. // Create a bomb if it's a 4+ line OR an L/T shape (both horizontal and vertical >= 3)
int longestLine = GetLongestMatchedLineThroughPivot(pivot, pivotGem.MatchColor); bool isEligibleForBomb = IsEligibleForBomb(pivot, pivotGem.MatchColor);
if (longestLine < 4) if (!isEligibleForBomb)
return; return;
// Prevent duplicates for the same cell. // Prevent duplicates for the same cell.
if (this.pendingBombSpawn.GetValueOrDefault().Position == pivot) if (this.pendingBombSpawns.Any(b => b.Position == pivot))
return; return;
this.pendingBombSpawn = new BombSpawnRequest(pivot, pivotGem.MatchColor); this.pendingBombSpawns.Add(new BombSpawnRequest(pivot, pivotGem.MatchColor));
}
private bool IsEligibleForBomb(Vector2Int pivot, GemType color) {
int horizontal = 1 + CountSameColorInDirection(pivot, Vector2Int.left, color)
+ CountSameColorInDirection(pivot, Vector2Int.right, color);
int vertical = 1 + CountSameColorInDirection(pivot, Vector2Int.up, color)
+ CountSameColorInDirection(pivot, Vector2Int.down, color);
// Straight line of 4 or more
if (horizontal >= 4 || vertical >= 4)
return true;
// L or T shape: both directions have at least a 3-match intersecting at this pivot
if (horizontal >= 3 && vertical >= 3)
return true;
return false;
} }
public async UniTask DetonateChainAsync( public async UniTask DetonateChainAsync(
IReadOnlyList<Vector2Int> initialBombs, IReadOnlyList<Vector2Int> initialBombs,
Func<Vector2Int, UniTask> destroyAtAsync, Func<Vector2Int, UniTask> destroyAtAsync)
IGameBoard gameBoard)
{ {
if (initialBombs == null || initialBombs.Count == 0) if (initialBombs == null || initialBombs.Count == 0)
return; return;
@@ -108,86 +122,66 @@ namespace Services
HashSet<Vector2Int> processedBombs = new HashSet<Vector2Int>(); HashSet<Vector2Int> processedBombs = new HashSet<Vector2Int>();
Queue<Vector2Int> waveQueue = new Queue<Vector2Int>( Queue<Vector2Int> waveQueue = new Queue<Vector2Int>();
initialBombs.Where(p => foreach (Vector2Int position in initialBombs) {
{ if (GemUtils.IsInBounds(position, this.gameBoard)) {
if (!GemUtils.IsInBounds(p, gameBoard)) return false; Gem gem = this.gameBoard.GetGemAt(position);
Gem g = gameBoard.GetGemAt(p); if(gem is { Type: GemType.Bomb })
return g is { Type: GemType.Bomb }; waveQueue.Enqueue(position);
}) }
); }
while (waveQueue.Count > 0) while (waveQueue.Count > 0)
{ {
// current wave (per bomb) Vector2Int bombPos = waveQueue.Dequeue();
List<Vector2Int> waveBombs = new List<Vector2Int>();
while (waveQueue.Count > 0) if (processedBombs.Contains(bombPos))
{
Vector2Int b = waveQueue.Dequeue();
if (processedBombs.Contains(b))
continue; continue;
if (!GemUtils.IsInBounds(b, gameBoard)) if (!GemUtils.IsInBounds(bombPos, this.gameBoard))
continue; continue;
Gem g = gameBoard.GetGemAt(b); Gem g = this.gameBoard.GetGemAt(bombPos);
if (g is not { Type: GemType.Bomb }) if (g is not { Type: GemType.Bomb })
continue; continue;
processedBombs.Add(b); processedBombs.Add(bombPos);
waveBombs.Add(b);
}
if (waveBombs.Count == 0) // delay once per bomb
continue;
// delay once per wave
if (waveDelayMs > 0)
await UniTask.Delay(waveDelayMs); await UniTask.Delay(waveDelayMs);
HashSet<Vector2Int> nextWaveBombs = new HashSet<Vector2Int>();
HashSet<Vector2Int> toDestroyNow = new HashSet<Vector2Int>(); HashSet<Vector2Int> toDestroyNow = new HashSet<Vector2Int>();
for (int i = 0; i < waveBombs.Count; i++)
{
Vector2Int bombPos = waveBombs[i];
// destroy self when it detonates // destroy self when it detonates
toDestroyNow.Add(bombPos); toDestroyNow.Add(bombPos);
foreach (Vector2Int p in DiamondAreaInclusive(bombPos, this.gameVariables.bombRadius)) foreach (Vector2Int position in DiamondAreaInclusive(bombPos, this.gameVariables.bombRadius))
{ {
if (!GemUtils.IsInBounds(p, gameBoard)) if (!GemUtils.IsInBounds(position, this.gameBoard))
continue; continue;
if (p == bombPos) if (position == bombPos)
continue; continue;
Gem cellGem = gameBoard.GetGemAt(p); Gem cellGem = this.gameBoard.GetGemAt(position);
if (cellGem == null) if (cellGem == null)
continue; continue;
if (cellGem.Type == GemType.Bomb) if (cellGem.Type == GemType.Bomb)
{ {
// bombs in range are NOT destroyed now. triggered to explode in a later wave. // bombs in range are NOT destroyed now. triggered to explode in a later "step".
if (!processedBombs.Contains(p)) if (!processedBombs.Contains(position))
nextWaveBombs.Add(p); waveQueue.Enqueue(position);
continue; continue;
} }
// Non-bomb gem gets destroyed by this bomb // Non-bomb gem gets destroyed by this bomb
toDestroyNow.Add(p); toDestroyNow.Add(position);
}
} }
// Destroy everything for this wave (non-bombs in range + the detonating bombs themselves) // Destroy everything for this specific bomb detonation
foreach (Vector2Int p in toDestroyNow) foreach (Vector2Int p in toDestroyNow)
await destroyAtAsync(p); await destroyAtAsync(p);
// Schedule the next wave (triggered bombs)
foreach (Vector2Int b in nextWaveBombs)
waveQueue.Enqueue(b);
} }
} }
@@ -204,34 +198,24 @@ namespace Services
} }
} }
private int GetLongestMatchedLineThroughPivot(Vector2Int pivot, GemType color) {
int horizontal = 1 + CountSameColorInDirection(pivot, Vector2Int.left, color)
+ CountSameColorInDirection(pivot, Vector2Int.right, color);
int vertical = 1 + CountSameColorInDirection(pivot, Vector2Int.up, color)
+ CountSameColorInDirection(pivot, Vector2Int.down, color);
return Mathf.Max(horizontal, vertical);
}
private int CountSameColorInDirection(Vector2Int start, Vector2Int direction, GemType color) { private int CountSameColorInDirection(Vector2Int start, Vector2Int direction, GemType color) {
int count = 0; int count = 0;
Vector2Int oivot = start + direction; Vector2Int pivot = start + direction;
while (oivot.x >= 0 && oivot.x < this.gameBoard.Width && oivot.y >= 0 && oivot.y < this.gameBoard.Height) { while (pivot.x >= 0 && pivot.x < this.gameBoard.Width && pivot.y >= 0 && pivot.y < this.gameBoard.Height) {
Gem g = this.gameBoard.GetGemAt(oivot); Gem g = this.gameBoard.GetGemAt(pivot);
if (g == null || g.Type == GemType.Bomb || g.MatchColor != color) if (g == null || g.Type == GemType.Bomb || g.MatchColor != color)
break; break;
count++; count++;
oivot += direction; pivot += direction;
} }
return count; return count;
} }
public void ClearPendingBombs() { private void ClearPendingBombs() {
this.pendingBombSpawn = null; this.pendingBombSpawns.Clear();
} }
} }
} }

View File

@@ -9,7 +9,6 @@ using ScriptableObjects;
using Services.Interfaces; using Services.Interfaces;
using Structs; using Structs;
using UnityEngine; using UnityEngine;
using UnityEngine.UIElements;
using Utils; using Utils;
using VContainer.Unity; using VContainer.Unity;
using Views; using Views;
@@ -30,12 +29,12 @@ namespace Services {
private readonly ScorePresenter scorePresenter; private readonly ScorePresenter scorePresenter;
private readonly AudioPresenter audioPresenter; private readonly AudioPresenter audioPresenter;
private readonly Transform gemsHolder;
private readonly Transform backgroundHolder; private readonly Transform backgroundHolder;
#endregion #endregion
#region Variables #region Variables
private readonly List<GemPresenter> gemPresenters = new List<GemPresenter>(); private readonly List<GemPresenter> gemPresenters = new List<GemPresenter>();
private readonly Dictionary<Gem, GemView> gemToView = new Dictionary<Gem, GemView>();
private GameState currentState = GameState.Setup; private GameState currentState = GameState.Setup;
#endregion #endregion
@@ -48,7 +47,6 @@ namespace Services {
IScoreService scoreService, IScoreService scoreService,
ScorePresenter scorePresenter, ScorePresenter scorePresenter,
AudioPresenter audioPresenter, AudioPresenter audioPresenter,
Transform gemsHolder,
Transform backgroundHolder) { Transform backgroundHolder) {
this.gameVariables = gameVariables; this.gameVariables = gameVariables;
this.gameBoard = gameBoard; this.gameBoard = gameBoard;
@@ -58,10 +56,12 @@ namespace Services {
this.scoreService = scoreService; this.scoreService = scoreService;
this.scorePresenter = scorePresenter; this.scorePresenter = scorePresenter;
this.audioPresenter = audioPresenter; this.audioPresenter = audioPresenter;
this.gemsHolder = gemsHolder;
this.backgroundHolder = backgroundHolder; this.backgroundHolder = backgroundHolder;
} }
/// <summary>
/// Global tick. Update replacement of VContainer-managed objects.
/// </summary>
public void Tick() { public void Tick() {
foreach (GemPresenter gemPresenter in gemPresenters) { foreach (GemPresenter gemPresenter in gemPresenters) {
gemPresenter.Tick(this.gameVariables.gemSpeed); gemPresenter.Tick(this.gameVariables.gemSpeed);
@@ -70,8 +70,9 @@ namespace Services {
this.scorePresenter.Tick(this.gameVariables.scoreSpeed); this.scorePresenter.Tick(this.gameVariables.scoreSpeed);
} }
//Instantiates background tiles and calls SpawnGems /// <summary>
//Uses MatchService.MatchesAt to avoid matching Gems /// Initiates the game board by placing gems randomly.
/// </summary>
public void Setup() { public void Setup() {
List<Gem> gemsToSpawn = new List<Gem>(); List<Gem> gemsToSpawn = new List<Gem>();
for (int x = 0; x < this.gameBoard.Width; x++) for (int x = 0; x < this.gameBoard.Width; x++)
@@ -81,18 +82,31 @@ namespace Services {
SpawnBackgroundTile(position); SpawnBackgroundTile(position);
int iterations = 0; int iterations = 0;
int gemToUse = -1; GemType gemToUse;
do { do {
gemToUse = RandomUtils.RandomGemTypeAsInt(); gemToUse = RandomUtils.RandomGemType();
iterations++; iterations++;
} while (this.matchService.MatchesAt(position.ToVector2Int(), (GemType)gemToUse) && iterations < 100); } while (this.matchService.MatchesAt(position.ToVector2Int(), gemToUse) && iterations < 100);
gemsToSpawn.Add(SetGemAt(position.ToVector2Int(), (GemType)gemToUse)); gemsToSpawn.Add(SetGemAt(position.ToVector2Int(), gemToUse));
} }
SpawnCascade(gemsToSpawn); SpawnCascade(gemsToSpawn).Forget();
} }
/// <summary>
/// Create a Gem object and place it on the GameBoard.
/// </summary>
/// <param name="position">
/// Position on the GameBoard to place the Gem.
/// </param>
/// <param name="gemType">
/// Type of Gem to create.
/// </param>
/// <param name="isBomb">
/// Whether the Gem is a Bomb or not.
/// </param>
/// <returns></returns>
private Gem SetGemAt(Vector2Int position, GemType gemType, bool isBomb = false) { private Gem SetGemAt(Vector2Int position, GemType gemType, bool isBomb = false) {
GemTypeValues gemValue = GemUtils.GetGemValues(gemType, this.gameVariables.gemsPrefabs); GemTypeValues gemValue = GemUtils.GetGemValues(gemType, this.gameVariables.gemsPrefabs);
Gem gem = new Gem(isBomb ? GemType.Bomb : gemType, position, gemValue, gemType); Gem gem = new Gem(isBomb ? GemType.Bomb : gemType, position, gemValue, gemType);
@@ -101,6 +115,15 @@ namespace Services {
return gem; return gem;
} }
/// <summary>
/// Spawns a GemView object and binds it to the Gem object.
/// </summary>
/// <param name="gem">
/// Gem to bind to the GemView.
/// </param>
/// <param name="isBomb">
/// Whether the Gem is a Bomb or not.
/// </param>
private void SpawnGemGameObject(Gem gem, bool isBomb = false) { private void SpawnGemGameObject(Gem gem, bool isBomb = false) {
GemView gemView = this.objectPool.Get(isBomb ? GemType.Bomb : gem.Type, gem.Position, isBomb ? 0 : this.gameVariables.dropHeight); GemView gemView = this.objectPool.Get(isBomb ? GemType.Bomb : gem.Type, gem.Position, isBomb ? 0 : this.gameVariables.dropHeight);
gemView.name = "Gem - " + gem.Position.x + ", " + gem.Position.y + ' ' + gem.Type; gemView.name = "Gem - " + gem.Position.x + ", " + gem.Position.y + ' ' + gem.Type;
@@ -108,8 +131,21 @@ namespace Services {
GemTypeValues gemValue = GemUtils.GetGemValues(gem.MatchColor, this.gameVariables.gemsPrefabs); GemTypeValues gemValue = GemUtils.GetGemValues(gem.MatchColor, this.gameVariables.gemsPrefabs);
gemView.Bind(gem, gemValue, isBomb: isBomb); gemView.Bind(gem, gemValue, isBomb: isBomb);
this.gemPresenters.Add(new GemPresenter(gem, gemView)); this.gemPresenters.Add(new GemPresenter(gem, gemView));
this.gemToView.Add(gem, gemView);
} }
/// <summary>
/// Calls SetGemAt and SpawnGemGameObject.
/// </summary>
/// <param name="position">
/// Position on the GameBoard to place the Gem.
/// </param>
/// <param name="gemType">
/// Type of Gem to create.
/// </param>
/// <param name="isBomb">
/// Whether the Gem is a Bomb or not.
/// </param>
private void SetAndSpawnGem(Vector2Int position, GemType gemType, bool isBomb) { private void SetAndSpawnGem(Vector2Int position, GemType gemType, bool isBomb) {
if(isBomb) if(isBomb)
ReleaseMatchedGems(position); ReleaseMatchedGems(position);
@@ -117,13 +153,28 @@ namespace Services {
SpawnGemGameObject(SetGemAt(position, gemType, isBomb), isBomb); SpawnGemGameObject(SetGemAt(position, gemType, isBomb), isBomb);
} }
/// <summary>
/// Spawns a background tile at the given position. Only for setup.
/// </summary>
/// <param name="position">
/// Position on the GameBoard to place the tile.
/// </param>
private void SpawnBackgroundTile(Vector2 position) { private void SpawnBackgroundTile(Vector2 position) {
GameObject backgroundTile = Object.Instantiate(this.gameVariables.bgTilePrefabs, position, Quaternion.identity); GameObject backgroundTile = Object.Instantiate(this.gameVariables.bgTilePrefabs, position, Quaternion.identity);
backgroundTile.transform.SetParent(this.backgroundHolder); backgroundTile.transform.SetParent(this.backgroundHolder);
backgroundTile.name = "BG Tile - " + position.x + ", " + position.y; backgroundTile.name = "BG Tile - " + position.x + ", " + position.y;
} }
//Listens to InputService OnSwapRequest /// <summary>
/// Attempts to swap two gems on the GameBoard.
/// </summary>
/// <param name="from">
/// Original position of the gem to swap.
/// </param>
/// <param name="to">
/// Destination position of the gem to swap.
/// </param>
/// <returns></returns>
public async UniTask<bool> TrySwap(Vector2Int from, Vector2Int to) { public async UniTask<bool> TrySwap(Vector2Int from, Vector2Int to) {
if (this.currentState != GameState.Move) if (this.currentState != GameState.Move)
return false; return false;
@@ -138,14 +189,14 @@ namespace Services {
ApplySwap(from, to); ApplySwap(from, to);
await UniTask.Delay(600); await UniTask.Delay(this.gameVariables.swapDelayMs);
this.bombService.SetLastSwap(from, to); this.bombService.SetLastSwap(from, to);
this.matchService.FindAllMatches(); this.matchService.FindAllMatches();
this.bombService.DetectBombSpawnFromLastSwap(this.matchService.CurrentMatches); this.bombService.DetectBombSpawnFromLastSwap(this.matchService.CurrentMatches);
if (this.matchService.CurrentMatches.Count == 0) { if (this.matchService.CurrentMatches.Count == 0) {
ApplySwap(to, from); ApplySwap(to, from);
await UniTask.Delay(600); await UniTask.Delay(this.gameVariables.swapDelayMs);
this.currentState = GameState.Move; this.currentState = GameState.Move;
return false; return false;
} }
@@ -156,10 +207,20 @@ namespace Services {
return true; return true;
} }
public async UniTask<bool> TrySwitch(Vector2Int position) { /// <summary>
/// For debug purposes. Switches the Gem at the given position to a different GemType.
/// </summary>
/// <param name="position">
/// Position of the Gem to switch.
/// </param>
/// <returns></returns>
public void TrySwitch(Vector2Int position) {
if (!this.gameVariables.debugMode)
return;
Gem gem = this.gameBoard.GetGemAt(position); Gem gem = this.gameBoard.GetGemAt(position);
if(gem == null) if(gem == null)
return false; return;
GemType[] normalTypes = Enum.GetValues(typeof(GemType)) GemType[] normalTypes = Enum.GetValues(typeof(GemType))
.Cast<GemType>() .Cast<GemType>()
@@ -167,7 +228,7 @@ namespace Services {
.ToArray(); .ToArray();
if (normalTypes.Length == 0) if (normalTypes.Length == 0)
return false; return;
bool nextIsBomb; bool nextIsBomb;
GemType nextTypeOrMatchColor; GemType nextTypeOrMatchColor;
@@ -196,12 +257,20 @@ namespace Services {
} }
} }
// Replace both model+view by releasing current and respawning at same position. // Replace both model+view by releasing current and respawning at the same position.
ReleaseMatchedGems(position); ReleaseMatchedGems(position);
SpawnGemGameObject(SetGemAt(position, nextTypeOrMatchColor, nextIsBomb), nextIsBomb); SpawnGemGameObject(SetGemAt(position, nextTypeOrMatchColor, nextIsBomb), nextIsBomb);
return true;
} }
/// <summary>
/// Helper function for TrySwap. Swaps the Gems in the GameBoard.
/// </summary>
/// <param name="from">
/// Original position of the gem to swap.
/// </param>
/// <param name="to">
/// Destination position of the gem to swap.
/// </param>
private void ApplySwap(Vector2Int from, Vector2Int to) { private void ApplySwap(Vector2Int from, Vector2Int to) {
Gem fromGem = this.gameBoard.GetGemAt(from); Gem fromGem = this.gameBoard.GetGemAt(from);
Gem toGem = this.gameBoard.GetGemAt(to); Gem toGem = this.gameBoard.GetGemAt(to);
@@ -214,45 +283,62 @@ namespace Services {
this.gameBoard.SetGemAt(to, fromGem); this.gameBoard.SetGemAt(to, fromGem);
} }
/// <summary>
/// Destroys all matches on the GameBoard.
/// </summary>
/// <param name="protectedPositions">
/// Gems we don't want to instantly destroy.
/// </param>
private async UniTask DestroyMatchesAsync(List<Vector2Int> protectedPositions) { private async UniTask DestroyMatchesAsync(List<Vector2Int> protectedPositions) {
List<Vector2Int> matchPositions = await this.matchService.GetMatchPositionsAsync(protectedPositions); HashSet<Vector2Int> matchPositions = await this.matchService.GetMatchPositionsAsync(protectedPositions);
List<Vector2Int> initialBombs = await this.bombService.GetInitialBombs(protectedPositions, matchPositions.Distinct().ToList()); List<Vector2Int> initialBombs = await this.bombService.GetInitialBombs(protectedPositions, matchPositions);
// If a bomb is part of the match, do NOT destroy matching pieces immediately.
// Let the bomb's manhattan-distance explosion destroy them in sequence.
if (initialBombs.Count > 0) { if (initialBombs.Count > 0) {
await this.bombService.DetonateChainAsync( await this.bombService.DetonateChainAsync(
initialBombs, initialBombs,
DestroyAtAsync, DestroyAtAsync);
this.gameBoard);
foreach (Vector2Int p in matchPositions.Distinct()) foreach (Vector2Int p in matchPositions)
await DestroyAtAsync(p); await DestroyAtAsync(p);
await UniTask.Delay(600); await UniTask.Delay(this.gameVariables.fillBoardDelayMs);
await MoveGemsDown(); await MoveGemsDown();
return; return;
} }
// For audio SFX // For audio SFX
bool willBreakAnyNonBombGem = matchPositions.Select(pos => this.gameBoard.GetGemAt(pos)).Where(gem => gem != null).Any(gem => gem.Type != GemType.Bomb); bool willBreakAnyNonBombGem = false;
foreach (Vector2Int pos in matchPositions) {
Gem g = this.gameBoard.GetGemAt(pos);
if (g != null && g.Type != GemType.Bomb) {
willBreakAnyNonBombGem = true;
break;
}
}
if (willBreakAnyNonBombGem) if (willBreakAnyNonBombGem)
this.audioPresenter.OnMatch(this.gameVariables.matchSfx); this.audioPresenter.OnMatch(this.gameVariables.matchSfx);
// For score counting // For score counting
foreach (Vector2Int pos in matchPositions.Distinct().ToList()) { foreach (Vector2Int pos in matchPositions) {
Gem gem = this.gameBoard.GetGemAt(pos); Gem gem = this.gameBoard.GetGemAt(pos);
if (gem == null) continue; if (gem == null) continue;
if (gem.Type == GemType.Bomb) continue; if (gem.Type == GemType.Bomb) continue;
this.scoreService.ScoreCheck(gem.ScoreValue); this.scoreService.AddScore(gem.ScoreValue);
ReleaseMatchedGems(pos); ReleaseMatchedGems(pos);
} }
await UniTask.Delay(250); await UniTask.Delay(this.gameVariables.fillBoardDelayMs);
await MoveGemsDown(); await MoveGemsDown();
} }
/// <summary>
/// Destroys the Gem at the given position.
/// </summary>
/// <param name="pos">
/// Position of the Gem to destroy.
/// </param>
/// <returns></returns>
private UniTask DestroyAtAsync(Vector2Int pos) { private UniTask DestroyAtAsync(Vector2Int pos) {
Gem gem = this.gameBoard.GetGemAt(pos); Gem gem = this.gameBoard.GetGemAt(pos);
if (gem == null) if (gem == null)
@@ -261,20 +347,23 @@ namespace Services {
if (gem.Type == GemType.Bomb) if (gem.Type == GemType.Bomb)
this.audioPresenter.OnBombExplosion(this.gameVariables.bombExplodeSfx); this.audioPresenter.OnBombExplosion(this.gameVariables.bombExplodeSfx);
this.scoreService.ScoreCheck(gem.ScoreValue); this.scoreService.AddScore(gem.ScoreValue);
ReleaseMatchedGems(pos); ReleaseMatchedGems(pos);
return UniTask.CompletedTask; return UniTask.CompletedTask;
} }
/// <summary>
/// Returns the GemView to the Object Pool and sets the Position in the GameBoard to null.
/// </summary>
/// <param name="position">
/// Position of the Gem to release.
/// </param>
private void ReleaseMatchedGems(Vector2Int position) { private void ReleaseMatchedGems(Vector2Int position) {
List<GemView> gemsViews = this.gemsHolder.GetComponentsInChildren<GemView>().ToList();
Gem currentGem = this.gameBoard.GetGemAt(position); Gem currentGem = this.gameBoard.GetGemAt(position);
if (currentGem != null) if (currentGem != null)
{ {
GemView gemView = gemsViews.FirstOrDefault(gv => gv.Gem == currentGem); if (!this.gemToView.TryGetValue(currentGem, out GemView gemView) || gemView == null)
if (gemView is null) {
return; return;
}
this.objectPool.Release(gemView); this.objectPool.Release(gemView);
RemovePresenterFor(gemView); RemovePresenterFor(gemView);
@@ -282,45 +371,69 @@ namespace Services {
} }
} }
/// <summary>
/// Moves Gems Down to free spaces.
/// </summary>
private async UniTask MoveGemsDown() { private async UniTask MoveGemsDown() {
int nullCounter = 0; List<FallMove> moves = new List<FallMove>();
while (true) {
moves.Clear();
for (int x = 0; x < this.gameBoard.Width; x++) for (int x = 0; x < this.gameBoard.Width; x++)
{ {
for (int y = 0; y < this.gameBoard.Height; y++) for (int y = 1; y < this.gameBoard.Height; y++)
{ {
Gem currentGem = this.gameBoard.GetGemAt(new Vector2Int(x, y)); Vector2Int from = new Vector2Int(x, y);
if (currentGem == null) Vector2Int to = new Vector2Int(x, y - 1);
{
nullCounter++; Gem gem = this.gameBoard.GetGemAt(from);
if (gem == null)
continue;
if (this.gameBoard.GetGemAt(to) != null)
continue;
moves.Add(new FallMove {
from = from,
to = to,
gem = gem
});
} }
else if (nullCounter > 0)
{
currentGem.SetPosition(new Vector2Int(currentGem.Position.x, currentGem.Position.y - nullCounter));
this.gameBoard.SetGemAt(currentGem.Position, currentGem);
this.gameBoard.SetGemAt(new Vector2Int(x,y), null);
}
}
nullCounter = 0;
} }
await UniTask.Delay(600); if (moves.Count == 0)
break;
foreach (FallMove move in moves) {
this.gameBoard.SetGemAt(move.to, move.gem);
this.gameBoard.SetGemAt(move.from, null);
move.gem.SetPosition(move.to);
}
await UniTask.Delay(this.gameVariables.cascadeDelayMs);
}
await FillBoard(); await FillBoard();
} }
/// <summary>
/// Fills the GameBoard with gems. If there are resulting matches, process them.
/// </summary>
private async UniTask FillBoard() { private async UniTask FillBoard() {
await RefillBoard(); await RefillBoard();
this.matchService.FindAllMatches(); this.matchService.FindAllMatches();
if (this.matchService.CurrentMatches.Count > 0) { if (this.matchService.CurrentMatches.Count > 0) {
await UniTask.Delay(600); await UniTask.Delay(this.gameVariables.fillBoardDelayMs);
// In cascades, there is no "creating slot" bomb protection.
await DestroyMatchesAsync(new List<Vector2Int>()); await DestroyMatchesAsync(new List<Vector2Int>());
} else { } else {
this.currentState = GameState.Move; this.currentState = GameState.Move;
} }
} }
/// <summary>
/// Refills the GameBoard with gems.
/// </summary>
private async UniTask RefillBoard() { private async UniTask RefillBoard() {
List<Gem> gemsToSpawn = new List<Gem>(); List<Gem> gemsToSpawn = new List<Gem>();
for (int x = 0; x < this.gameBoard.Width; x++) for (int x = 0; x < this.gameBoard.Width; x++)
@@ -329,16 +442,16 @@ namespace Services {
{ {
Gem currentGem = this.gameBoard.GetGemAt(new Vector2Int(x,y)); Gem currentGem = this.gameBoard.GetGemAt(new Vector2Int(x,y));
if (currentGem == null) { if (currentGem == null) {
int gemToUse = RandomUtils.RandomGemTypeAsInt(); GemType gemToUse = RandomUtils.RandomGemType();
int iterations = 0; int iterations = 0;
while (this.matchService.MatchesAt(new Vector2Int(x, y), (GemType)gemToUse) && iterations < 100) while (this.matchService.MatchesAt(new Vector2Int(x, y), gemToUse) && iterations < 100)
{ {
gemToUse = RandomUtils.RandomGemTypeAsInt(); gemToUse = RandomUtils.RandomGemType();
iterations++; iterations++;
} }
gemsToSpawn.Add(SetGemAt(new Vector2Int(x,y), (GemType)gemToUse)); gemsToSpawn.Add(SetGemAt(new Vector2Int(x,y), gemToUse));
} }
} }
} }
@@ -346,6 +459,12 @@ namespace Services {
await SpawnCascade(gemsToSpawn); await SpawnCascade(gemsToSpawn);
} }
/// <summary>
/// Spawns gems in cascade.
/// </summary>
/// <param name="gemsToSpawn">
/// List of gems to spawn.
/// </param>
private async UniTask SpawnCascade(List<Gem> gemsToSpawn) private async UniTask SpawnCascade(List<Gem> gemsToSpawn)
{ {
List<IGrouping<int, Gem>> groups = gemsToSpawn List<IGrouping<int, Gem>> groups = gemsToSpawn
@@ -359,13 +478,19 @@ namespace Services {
SpawnGemGameObject(gem); SpawnGemGameObject(gem);
if (i < groups.Count - 1) if (i < groups.Count - 1)
await UniTask.Delay(150); await UniTask.Delay(this.gameVariables.cascadeDelayMs);
} }
if(this.currentState == GameState.Setup) if(this.currentState == GameState.Setup)
this.currentState = GameState.Move; this.currentState = GameState.Move;
} }
/// <summary>
/// Removes the Presenter for the given GemView from the list to avoid calling its Tick method.
/// </summary>
/// <param name="gemView">
/// GemView to remove from the List of presenters.
/// </param>
private void RemovePresenterFor(GemView gemView) { private void RemovePresenterFor(GemView gemView) {
if (gemView is null) { if (gemView is null) {
return; return;
@@ -377,6 +502,8 @@ namespace Services {
public void Dispose() { public void Dispose() {
this.objectPool.Clear(); this.objectPool.Clear();
this.gemPresenters.Clear();
this.gemToView.Clear();
} }
} }
} }

View File

@@ -100,7 +100,7 @@ namespace Services {
|| touch.phase == TouchPhase.Moved || touch.phase == TouchPhase.Moved
|| touch.phase == TouchPhase.Stationary; || touch.phase == TouchPhase.Stationary;
// Ended/Canceled => not down (we still report position) // Ended/Canceled => not down (we still report the position)
if (touch.phase == TouchPhase.Ended || touch.phase == TouchPhase.Canceled) if (touch.phase == TouchPhase.Ended || touch.phase == TouchPhase.Canceled)
isDown = false; isDown = false;

View File

@@ -4,23 +4,56 @@ using System.Collections.Generic;
using Cysharp.Threading.Tasks; using Cysharp.Threading.Tasks;
using Enums; using Enums;
using Models.Interfaces; using Models.Interfaces;
using Structs;
using UnityEngine; using UnityEngine;
namespace Services.Interfaces namespace Services.Interfaces
{ {
public interface IBombService { public interface IBombService {
public BombSpawnRequest? PendingBombSpawn { get; } /// <summary>
/// Caches last swap action.
/// </summary>
/// <param name="from">
/// Original location of the gem.
/// </param>
/// <param name="to">
/// Destination location of the gem.
/// </param>
void SetLastSwap(Vector2Int from, Vector2Int to); void SetLastSwap(Vector2Int from, Vector2Int to);
void DetectBombSpawnFromLastSwap(List<Gem> currentMatches); /// <summary>
/// Try to spawn a bomb at the last swap location.
/// </summary>
/// <param name="currentMatches">
/// List of current matches.
/// </param>
void DetectBombSpawnFromLastSwap(HashSet<Gem> currentMatches);
List<Vector2Int> ApplyPendingBombSpawns(Action<Vector2Int, GemType, bool> spawnGem); List<Vector2Int> ApplyPendingBombSpawns(Action<Vector2Int, GemType, bool> spawnGem);
UniTask<List<Vector2Int>> GetInitialBombs(List<Vector2Int> protectedPositions, List<Vector2Int> bombCandidates);
/// <summary>
/// Get a List of bombs that we will detonate.
/// </summary>
/// <param name="protectedPositions">
/// Protected positions, bombs that we don't want to destroy.
/// </param>
/// <param name="bombCandidates">
/// Possible bombs.
/// </param>
/// <returns>
/// List of Bombs.
/// </returns>
UniTask<List<Vector2Int>> GetInitialBombs(List<Vector2Int> protectedPositions, HashSet<Vector2Int> bombCandidates);
/// <summary>
/// Detonate the bomb(s) part of the match. If there are other bombs within the radius, they will be detonated too sequentially.
/// </summary>
/// <param name="initialBombs">
/// List of bombs to detonate.
/// </param>
/// <param name="destroyAtAsync">
/// Destroy function reference.
/// </param>
UniTask DetonateChainAsync( UniTask DetonateChainAsync(
IReadOnlyList<Vector2Int> initialBombs, IReadOnlyList<Vector2Int> initialBombs,
Func<Vector2Int, UniTask> destroyAtAsync, Func<Vector2Int, UniTask> destroyAtAsync);
IGameBoard gameBoard);
} }
} }

View File

@@ -6,6 +6,6 @@ namespace Services.Interfaces {
void Setup(); void Setup();
UniTask<bool> TrySwap(Vector2Int from, Vector2Int to); UniTask<bool> TrySwap(Vector2Int from, Vector2Int to);
UniTask<bool> TrySwitch(Vector2Int position); void TrySwitch(Vector2Int position);
} }
} }

View File

@@ -2,12 +2,33 @@ using System.Collections.Generic;
using Cysharp.Threading.Tasks; using Cysharp.Threading.Tasks;
using UnityEngine; using UnityEngine;
using Enums; using Enums;
using Structs;
namespace Services.Interfaces { namespace Services.Interfaces {
public interface IMatchService { public interface IMatchService {
List<Gem> CurrentMatches { get; } HashSet<Gem> CurrentMatches { get; }
UniTask<List<Vector2Int>> GetMatchPositionsAsync(List<Vector2Int> protectedPositions); /// <summary>
/// Get positions of all matches that are not protected.
/// </summary>
/// <param name="protectedPositions">
/// Protected positions, bombs that we don't want to destroy.
/// </param>
/// <returns>
/// HashSet of unprotected matches.
/// </returns>
UniTask<HashSet<Vector2Int>> GetMatchPositionsAsync(List<Vector2Int> protectedPositions);
/// <summary>
/// Checks if there are any matches at the given position and type.
/// </summary>
/// <param name="positionToCheck">
/// Position on the gameBoard to check.
/// </param>
/// <param name="gemTypeToCheck">
/// Type of gem to check.
/// </param>
/// <returns>
/// True if there are matches, false otherwise.
/// </returns>
bool MatchesAt(Vector2Int positionToCheck, GemType gemTypeToCheck); bool MatchesAt(Vector2Int positionToCheck, GemType gemTypeToCheck);
void FindAllMatches(); void FindAllMatches();
} }

View File

@@ -4,6 +4,6 @@ namespace Services.Interfaces {
public interface IScoreService { public interface IScoreService {
event Action<int> OnScoreChanged; event Action<int> OnScoreChanged;
int Score { get; } int Score { get; }
void ScoreCheck(int value); void AddScore(int value);
} }
} }

View File

@@ -1,24 +1,18 @@
using Presenter;
using Services.Interfaces; using Services.Interfaces;
using UnityEngine; using UnityEngine;
using VContainer.Unity; using VContainer.Unity;
using Views;
namespace Services namespace Services
{ {
public class LevelEntryPoint : IStartable public class LevelEntryPoint : IStartable
{ {
private readonly IObjectPool<GemView> gemViewPool;
private readonly IGameBoardService gameBoardService; private readonly IGameBoardService gameBoardService;
private readonly IInputService inputService; private readonly IInputService inputService;
private readonly AudioPresenter audioPresenter;
public LevelEntryPoint(IObjectPool<GemView> gemViewPool, IGameBoardService gameBoardService, IInputService inputService, AudioPresenter audioPresenter) public LevelEntryPoint(IGameBoardService gameBoardService, IInputService inputService)
{ {
this.gemViewPool = gemViewPool;
this.gameBoardService = gameBoardService; this.gameBoardService = gameBoardService;
this.inputService = inputService; this.inputService = inputService;
this.audioPresenter = audioPresenter;
} }
public void Start() public void Start()

View File

@@ -4,16 +4,14 @@ using Cysharp.Threading.Tasks;
using Enums; using Enums;
using Models.Interfaces; using Models.Interfaces;
using Services.Interfaces; using Services.Interfaces;
using Structs;
using UnityEngine; using UnityEngine;
namespace Services { namespace Services {
public class MatchService : IMatchService { public class MatchService : IMatchService {
private readonly IGameBoard gameBoard; private readonly IGameBoard gameBoard;
private readonly HashSet<Gem> currentMatches = new HashSet<Gem>();
private List<Gem> currentMatches = new List<Gem>(); public HashSet<Gem> CurrentMatches => this.currentMatches;
public List<Gem> CurrentMatches => this.currentMatches;
public MatchService(IGameBoard gameBoard) { public MatchService(IGameBoard gameBoard) {
this.gameBoard = gameBoard; this.gameBoard = gameBoard;
@@ -51,10 +49,11 @@ namespace Services {
return false; return false;
} }
public UniTask<List<Vector2Int>> GetMatchPositionsAsync(List<Vector2Int> protectedPositions) { public UniTask<HashSet<Vector2Int>> GetMatchPositionsAsync(List<Vector2Int> protectedPositions) {
List<Vector2Int> matchPositions = new List<Vector2Int>(CurrentMatches.Count); HashSet<Vector2Int> matchPositions = new HashSet<Vector2Int>();
for (int i = 0; i < CurrentMatches.Count; i++) { List<Gem> matches = this.currentMatches.ToList();
Gem match = CurrentMatches[i]; for (int i = 0; i < matches.Count; i++) {
Gem match = matches[i];
if (match == null) continue; if (match == null) continue;
Vector2Int pos = match.Position; Vector2Int pos = match.Position;
@@ -70,39 +69,59 @@ namespace Services {
public void FindAllMatches() { public void FindAllMatches() {
this.currentMatches.Clear(); this.currentMatches.Clear();
for (int x = 0; x < this.gameBoard.Width; x++) Gem[,] grid = this.gameBoard.GemsGrid;
for (int y = 0; y < this.gameBoard.Height; y++) { int boardWidth = this.gameBoard.Width;
Gem currentGem = this.gameBoard.GemsGrid[x, y]; int boardHeight = this.gameBoard.Height;
if (currentGem == null)
continue;
if (x > 0 && x < this.gameBoard.Width - 1) { // Horizontal runs
Gem leftGem = this.gameBoard.GemsGrid[x - 1, y]; for (int y = 0; y < boardHeight; y++) {
Gem rightGem = this.gameBoard.GemsGrid[x + 1, y]; int x = 0;
if (leftGem != null && rightGem != null) { while (x < boardWidth) {
if (leftGem.MatchColor == currentGem.MatchColor && rightGem.MatchColor == currentGem.MatchColor) { Gem start = grid[x, y];
this.currentMatches.Add(currentGem); if (start == null) { x++; continue; }
this.currentMatches.Add(leftGem);
this.currentMatches.Add(rightGem); GemType color = start.MatchColor;
int runLen = 1;
while (x + runLen < boardWidth) {
Gem next = grid[x + runLen, y];
if (next == null || next.MatchColor != color) break;
runLen++;
} }
if (runLen >= 3) {
for (int i = 0; i < runLen; i++)
this.currentMatches.Add(grid[x + i, y]);
}
x += runLen;
} }
} }
if (y > 0 && y < this.gameBoard.Height - 1) { // Vertical runs
Gem aboveGem = this.gameBoard.GemsGrid[x, y - 1]; for (int x = 0; x < boardWidth; x++) {
Gem bellowGem = this.gameBoard.GemsGrid[x, y + 1]; int y = 0;
if (aboveGem != null && bellowGem != null) { while (y < boardHeight) {
if (aboveGem.MatchColor == currentGem.MatchColor && bellowGem.MatchColor == currentGem.MatchColor) { Gem start = grid[x, y];
this.currentMatches.Add(currentGem); if (start == null) { y++; continue; }
this.currentMatches.Add(aboveGem);
this.currentMatches.Add(bellowGem); GemType color = start.MatchColor;
}
} int runLen = 1;
} while (y + runLen < boardHeight) {
Gem next = grid[x, y + runLen];
if (next == null || next.MatchColor != color) break;
runLen++;
} }
if (this.currentMatches.Count > 0) if (runLen >= 3) {
this.currentMatches = this.currentMatches.Distinct().ToList(); for (int i = 0; i < runLen; i++)
this.currentMatches.Add(grid[x, y + i]);
}
y += runLen;
}
}
} }
} }
} }

View File

@@ -1,13 +1,12 @@
using System; using System;
using Services.Interfaces; using Services.Interfaces;
using UnityEngine;
namespace Services { namespace Services {
public class ScoreService : IScoreService { public class ScoreService : IScoreService {
private int score = 0; private int score;
public int Score => this.score; public int Score => this.score;
public event Action<int> OnScoreChanged; public event Action<int> OnScoreChanged;
public void ScoreCheck(int value) { public void AddScore(int value) {
this.score += value; this.score += value;
OnScoreChanged?.Invoke(this.score); OnScoreChanged?.Invoke(this.score);

View File

@@ -0,0 +1,10 @@
using Services;
using UnityEngine;
namespace Structs {
public struct FallMove {
public Vector2Int from;
public Vector2Int to;
public Gem gem;
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 420c7135e99442e280e7ac7439d5c702
timeCreated: 1765995012

View File

@@ -1,6 +1,5 @@
using Enums; using Enums;
using Models.Interfaces; using Models.Interfaces;
using Services;
using Structs; using Structs;
using UnityEngine; using UnityEngine;

View File

@@ -1,17 +1,36 @@
using System; using System;
using System.Linq;
using Enums; using Enums;
using Random = UnityEngine.Random; using Random = UnityEngine.Random;
namespace Utils { namespace Utils {
public static class RandomUtils { public static class RandomUtils {
public static int RandomGemTypeAsInt() { private static readonly GemType[] SPAWNABLE_GEMS = BuildSpawnableGems();
GemType[] spawnableGems = Enum.GetValues(typeof(GemType))
.Cast<GemType>()
.Where(gType => gType != GemType.Bomb)
.ToArray();
return Random.Range(0, spawnableGems.Length); private static GemType[] BuildSpawnableGems() {
Array values = Enum.GetValues(typeof(GemType));
int count = 0;
for (int i = 0; i < values.Length; i++) {
if ((GemType)values.GetValue(i) != GemType.Bomb)
count++;
}
GemType[] result = new GemType[count];
int write = 0;
for (int i = 0; i < values.Length; i++) {
GemType t = (GemType)values.GetValue(i);
if (t == GemType.Bomb)
continue;
result[write++] = t;
}
return result;
}
public static GemType RandomGemType() {
return SPAWNABLE_GEMS[Random.Range(0, SPAWNABLE_GEMS.Length)];
} }
} }
} }

View File

@@ -19,11 +19,11 @@ namespace Views {
private CancellationTokenSource spawnScaleCts; private CancellationTokenSource spawnScaleCts;
public void Bind(Gem gem, GemTypeValues gemvalue, bool isBomb = false) { public void Bind(Gem gem, GemTypeValues gemValue, bool isBomb = false) {
this.gem = gem; this.gem = gem;
this.gameObject.SetActive(true); this.gameObject.SetActive(true);
SetupGem(isBomb, gemvalue.gemSprite); SetupGem(isBomb, gemValue.gemSprite);
PlaySpawnScale(); PlaySpawnScale();
} }

57
README.md Normal file
View File

@@ -0,0 +1,57 @@
# Unity Match-3: VContainer Edition
A modern Match-3 puzzle game built in Unity, focusing on clean architecture, dependency injection, and data-driven design. This project serves as a template for scalable mobile game development.
## 🏗 Architecture & Patterns
### Dependency Injection with VContainer
This project utilizes **VContainer** for high-performance dependency injection. By using a `LifetimeScope`, we decouple our game logic (Grid Management, Scoring, Input) from the Unity Lifecycle, making the codebase more modular and easier to test.
### Data-Driven Gameplay
All balancing and configuration parameters are centralized in a **ScriptableObject** called `GameVariables`. This allows designers to tweak the game feel in real-time without touching code.
**Adjustable Parameters include:**
* Debug Mode
* Tile prefabs
* Spawnable object
* Spawnable explosion prefab animation
* Sprite
* Score value
* Grid Dimensions
* Audio SFX
* Special Tiles
* Animation speed and delays
---
## 🎮 Features
* **Grid Logic:** Efficient recursive algorithms for detecting matches and handling "gravity" (falling tiles).
* **Decoupled Systems:** UI, Sound, and Gameplay systems communicate via injected interfaces.
* **Scriptable Configuration:** Easily swap between "Easy" and "Hard" mode by swapping the `GameVariables` asset.
---
## 🛠 Tech Stack
* **Engine:** Unity 2022.3+
* **DI Framework:** [VContainer](https://vcontainer.hadashikakeru.jp/)
* **UniTask:** C# (Task-based async for animations)
* **Data:** ScriptableObjects
---
## 🚀 Getting Started
### Prerequisites
* Unity Hub & Unity 2022.3 LTS or newer.
* VContainer package.
* UniTask package.
### Installation
1. Open the project in Unity.
2. Navigate to Assets/Scenes/ and open `Scene_Submission`
3. Locate `GameVariables` asset in `Assets` to modify gameplay values.