Compare commits

...

15 Commits

Author SHA1 Message Date
2d54920df9 chore: add new meta files for TileRegistry, LevelDefinition, GameUiCoordinator, and test assets 2026-05-14 01:56:02 +02:00
03bfb16cf3 chore: remove stale preloaded asset ref, add refactor plan 2026-05-14 01:40:56 +02:00
65af5ad2eb fix: equalize power-up spawn odds, use Math.Max in domain, null-guard Definition 2026-05-14 01:39:39 +02:00
eedbbb2b47 fix: unsubscribe UI events on destroy, add Ui header in GameBootstrap 2026-05-14 01:27:56 +02:00
3c6e309886 refactor: extract GameUiCoordinator from GameBootstrap 2026-05-14 01:26:07 +02:00
2bfc2ea9c2 fix: warn on unhandled power-up type in GameBootstrap 2026-05-14 01:23:58 +02:00
1b8c7f730d refactor: power-up effects applied centrally in GameBootstrap 2026-05-14 01:21:22 +02:00
8edb5cfbb5 fix: null guard for unassigned LevelDefinition 2026-05-14 01:20:20 +02:00
f507707251 refactor: LevelDefinition ScriptableObject drives floor count and geometry 2026-05-14 01:17:30 +02:00
67df6bf6d6 fix: TileRegistry floor-range warning, GroupViewsByFloor tests, stopwatch cleanup 2026-05-14 01:15:48 +02:00
49c9a7904d refactor: TileRegistry replaces parallel tile/view collections 2026-05-14 01:12:47 +02:00
34a329ad02 refactor: controllers use ITileView port, not TileViewAdapter directly 2026-05-14 01:07:37 +02:00
d4dc30bd7a fix: speed effects stack multiplicatively 2026-05-14 01:05:31 +02:00
feed4da28c fix: remove test asmdef so tests compile into Assembly-CSharp 2026-05-14 01:03:56 +02:00
0704f2e0a0 test: add EditMode test assembly 2026-05-14 01:01:14 +02:00
35 changed files with 1986 additions and 276 deletions

8
Assets/Data.meta Normal file
View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: a0d581912a9a00ddd90532c3da3be7fc
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

21
Assets/Data/Level.asset Normal file
View File

@@ -0,0 +1,21 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: d1e67659d937881ee99b3b9e84f91428, type: 3}
m_Name: Level
m_EditorClassIdentifier: Assembly-CSharp::Infrastructure.Unity.LevelDefinition
gridSizeX: 40
gridSizeY: 40
floorHeightDistance: 15
floors:
- pattern: 0
- pattern: 1
- pattern: 2

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 5b72a68ad35f58fc3baa8043b5dfb42c
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

File diff suppressed because one or more lines are too long

View File

@@ -610,6 +610,7 @@ GameObject:
- component: {fileID: 453022423}
- component: {fileID: 453022424}
- component: {fileID: 453022425}
- component: {fileID: 453022426}
m_Layer: 0
m_Name: Game
m_TagString: Untagged
@@ -640,11 +641,7 @@ MonoBehaviour:
npcPrefab: {fileID: 6083523108754401876, guid: 4b3d84858334857368bde30df360ae3e, type: 3}
hunterNpcPrefab: {fileID: 4496988857626767934, guid: ab4e193839fef9a2189f27360914c044, type: 3}
floorVisibilityManager: {fileID: 453022425}
scoreText: {fileID: 412275999}
highScoreText: {fileID: 1626199842}
gameOverUi: {fileID: 87831902}
pauseUi: {fileID: 1019087904}
startScreenUi: {fileID: 1763855010}
uiCoordinator: {fileID: 453022426}
restartTime: 3
powerUpPrefab: {fileID: 7381336953128067686, guid: 8b540be4548e610709c2f7eccf8bf9c6, type: 3}
--- !u!4 &453022421
@@ -678,10 +675,7 @@ MonoBehaviour:
tileBreakVfxPrefab: {fileID: 0}
jumpPadPrefab: {fileID: 3258547662887829175, guid: e1d1bd44370c9986ebd4bb7730430a12, type: 3}
teleporterPrefab: {fileID: 4601941687390792571, guid: 53f1de555c523511e9aaa1dee06fdf79, type: 3}
gridSizeX: 25
gridSizeY: 25
floorsCount: 3
floorHeightDistance: 15
levelDefinition: {fileID: 11400000, guid: 5b72a68ad35f58fc3baa8043b5dfb42c, type: 2}
decayTime: 0.75
fallingTime: 2
minimumDistanceBetweenTeleporters: 10
@@ -780,6 +774,23 @@ MonoBehaviour:
fadeSpeed: 5
hiddenAlpha: 0.1
visibleAlpha: 1
--- !u!114 &453022426
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 453022419}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: b2d628098b45e36879d59d2c2c2bf061, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::Infrastructure.Unity.GameUiCoordinator
scoreText: {fileID: 412275999}
highScoreText: {fileID: 1626199842}
gameOverUi: {fileID: 87831902}
pauseUi: {fileID: 1019087904}
startScreenUi: {fileID: 1763855010}
--- !u!1 &832575517
GameObject:
m_ObjectHideFlags: 0

View File

@@ -1,7 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Core.Ports;
using UnityEngine;
using Random = System.Random;
namespace Core.Domain
@@ -25,7 +25,7 @@ namespace Core.Domain
public event Action OnSpawnNpc;
public event Action<PowerUpType, string> OnSpawnPowerUp;
private readonly List<Tile> _tiles;
private readonly IReadOnlyList<Tile> _tiles;
private readonly IPersistenceService _persistenceService;
private readonly Random _rng = new();
private int _playerFloorIndex = 0;
@@ -40,7 +40,7 @@ namespace Core.Domain
public int ComboMultiplier { get; private set; } = 1;
public event Action<int> OnComboUpdated;
public GameSession(List<Tile> tiles, IPersistenceService persistenceService)
public GameSession(IReadOnlyList<Tile> tiles, IPersistenceService persistenceService)
{
_tiles = tiles;
_persistenceService = persistenceService;
@@ -74,7 +74,7 @@ namespace Core.Domain
{
_npcTimer = 0f;
OnSpawnNpc?.Invoke();
NpcSpawnTime = Mathf.Max(5f, NpcSpawnTime * 0.95f);
NpcSpawnTime = Math.Max(5f, NpcSpawnTime * 0.95f);
}
_powerUpTimer += deltaTime;
@@ -124,15 +124,12 @@ namespace Core.Domain
private void SpawnNextOrb()
{
var validTiles = _tiles.FindAll(t =>
var validTiles = _tiles.Where(t =>
t.CurrentState == TileState.Stable &&
t.Floor == _playerFloorIndex
);
t.Floor == _playerFloorIndex).ToList();
if (validTiles.Count == 0)
{
validTiles = _tiles.FindAll(t => t.CurrentState == TileState.Stable);
}
validTiles = _tiles.Where(t => t.CurrentState == TileState.Stable).ToList();
if (validTiles.Count == 0)
{
@@ -173,27 +170,24 @@ namespace Core.Domain
private void SpawnRandomPowerUp()
{
var validTiles = _tiles.FindAll(t =>
t.CurrentState == TileState.Stable &&
t.Floor == _playerFloorIndex
);
var validTiles = _tiles.Where(t =>
t.CurrentState == TileState.Stable &&
t.Floor == _playerFloorIndex).ToList();
if (validTiles.Count == 0)
{
validTiles = _tiles.FindAll(t => t.CurrentState == TileState.Stable);
}
validTiles = _tiles.Where(t => t.CurrentState == TileState.Stable).ToList();
if (validTiles.Count == 0) return;
var tile = validTiles[_rng.Next(validTiles.Count)];
var rand = _rng.Next(0, 4);
var type = PowerUpType.LightFooted;
var rand = _rng.Next(0, 3);
PowerUpType type;
switch (rand)
{
case 0: type = PowerUpType.LightFooted; break;
case 1: type = PowerUpType.SpeedBoost; break;
case 3: type = PowerUpType.TimeSlow; break;
default: type = PowerUpType.TimeSlow; break;
}
OnSpawnPowerUp?.Invoke(type, tile.Id);

View File

@@ -19,7 +19,7 @@ namespace Core.Domain.Status.Effects
public void ModifyCapabilities(ref PlayerCapabilities caps)
{
caps.CanTriggerDecay = false;
caps.SpeedMultiplier = 1.2f;
caps.SpeedMultiplier *= 1.2f;
}
public void OnApply()

View File

@@ -20,7 +20,7 @@ namespace Core.Domain.Status.Effects
public void ModifyCapabilities(ref PlayerCapabilities caps)
{
caps.SpeedMultiplier = _multiplier;
caps.SpeedMultiplier *= _multiplier;
}
public void OnApply() { }

View File

@@ -9,5 +9,6 @@ namespace Core.Ports
void SetVisualState(TileState state);
void DropPhysics();
void Dispose();
void StepOn();
}
}

View File

@@ -1,6 +1,5 @@
using System.Collections;
using System.Collections.Generic;
using Core.Domain;
using UnityEngine;
namespace Infrastructure.Unity
@@ -10,44 +9,13 @@ namespace Infrastructure.Unity
[SerializeField] private float fadeSpeed = 5f;
[SerializeField] private float hiddenAlpha = 0.1f;
[SerializeField] private float visibleAlpha = 1.0f;
private GameSession _gameSession;
private List<List<TileViewAdapter>> _floors;
private int _currentFloorIndex = -1;
public void Initialize(GameSession gameSession, List<Tile> allTiles, Dictionary<string, TileViewAdapter> tileViews, int totalFloors)
public void Initialize(TileRegistry registry, int totalFloors)
{
_gameSession = gameSession;
_floors = new List<List<TileViewAdapter>>();
for (var i = 0; i < totalFloors; i++)
{
_floors.Add(new List<TileViewAdapter>());
}
foreach (var tile in allTiles)
{
if (tileViews.TryGetValue(tile.Id, out var view))
{
// Safety check for array bounds
if (tile.Floor < _floors.Count)
{
_floors[tile.Floor].Add(view);
}
}
}
}
private void Update()
{
if (_gameSession == null) return;
// Check if player changed floors
// We read the private field _playerFloorIndex via a public getter we need to add,
// OR we just track it locally if you updated GameSession to expose it.
// Assuming GameSession doesn't expose it publically yet, let's rely on GameBootstrap passing it or just hack it:
// Ideally, GameSession should emit an event 'OnFloorChanged'.
// For now, let's assume we can get it or we passed the player reference.
_floors = registry.GroupViewsByFloor(totalFloors);
}
// Call this from GameBootstrap.Update()
@@ -75,9 +43,9 @@ namespace Infrastructure.Unity
tile.SetAlpha(targetAlpha);
}
}
if (i % 2 == 0) yield return null;
if (i % 2 == 0) yield return null;
}
}
}
}
}

View File

@@ -1,8 +1,7 @@
using System.Collections;
using System.Collections.Generic;
using Core.Domain;
using Core.Domain.Status.Effects;
using Core.Ports;
using TMPro;
using UnityEngine;
using UnityEngine.SceneManagement;
@@ -24,11 +23,7 @@ namespace Infrastructure.Unity
[SerializeField] private FloorVisibilityManager floorVisibilityManager;
[Header("Ui")]
[SerializeField] private TMP_Text scoreText;
[SerializeField] private TMP_Text highScoreText;
[SerializeField] private GameObject gameOverUi;
[SerializeField] private GameObject pauseUi;
[SerializeField] private GameObject startScreenUi;
[SerializeField] private GameUiCoordinator uiCoordinator;
[Header("Settings")]
[SerializeField] private float restartTime = 3f;
@@ -36,8 +31,7 @@ namespace Infrastructure.Unity
[Header("Power Ups")]
[SerializeField] private PowerUpViewAdapter powerUpPrefab;
private readonly List<Tile> _allTiles = new();
private readonly Dictionary<string, TileViewAdapter> _tileViews = new();
private readonly TileRegistry _tileRegistry = new();
private GameSession _gameSession;
private IPersistenceService _persistenceService;
private InputSystem_Actions _actions;
@@ -45,11 +39,11 @@ namespace Infrastructure.Unity
private GameObject _currentOrbInstance;
private bool _isGameRunning;
private int _currentPlayerFloorIndex;
private int _currentDisplayedScore;
private float _inputBlockTimer;
private bool _isPaused;
private bool _levelGenerated;
private void OnEnable()
{
_actions = new InputSystem_Actions();
@@ -67,16 +61,16 @@ namespace Infrastructure.Unity
{
_inputBlockTimer = 0.5f;
_persistenceService = new PlayerPrefsPersistenceAdapter();
_gameSession = new GameSession(_allTiles, _persistenceService);
_gameSession = new GameSession(_tileRegistry.AllTiles, _persistenceService);
// Set Theme based on High Score
ThemeManager.CurrentTheme = ThemeManager.GetTheme(_gameSession.HighScore);
var floorsCount = levelGenerator ? levelGenerator.FloorsCount : 1;
var floorsCount = levelGenerator?.Definition != null ? levelGenerator.Definition.FloorCount : 1;
if (levelGenerator)
{
StartCoroutine(levelGenerator.GenerateAsync(soundManager, _allTiles, _tileViews, cameraController,
StartCoroutine(levelGenerator.GenerateAsync(soundManager, _tileRegistry, cameraController,
rumbleManager,
() =>
{
@@ -84,23 +78,21 @@ namespace Infrastructure.Unity
{
floorVisibilityManager = gameObject.AddComponent<FloorVisibilityManager>();
}
floorVisibilityManager.Initialize(_gameSession, _allTiles, _tileViews, floorsCount);
floorVisibilityManager.Initialize(_tileRegistry, floorsCount);
SpawnDeathPlane();
SpawnPlayer();
if (gameOverUi) gameOverUi.SetActive(false);
if (startScreenUi) startScreenUi.SetActive(true); // Show start screen NOW
uiCoordinator?.ShowStartScreen();
uiCoordinator?.UpdateHighScore(_gameSession.HighScore);
WireEvents();
UpdateScoreUi(_gameSession.Score);
_levelGenerated = true;
}));
}
if (gameOverUi) gameOverUi.SetActive(false);
if (startScreenUi) startScreenUi.SetActive(true);
uiCoordinator?.ShowStartScreen();
}
private void Update()
@@ -134,9 +126,12 @@ namespace Infrastructure.Unity
// Calculate current floor index based on Y height (inverse logic from Generator)
// Note: Generator uses negative offsets: 0, -15, -30.
// So Floor 0 is at Y=0. Floor 1 is at Y=-15.
var heightDist = levelGenerator.FloorHeightDistance;
var maxFloors = levelGenerator.FloorsCount;
var def = levelGenerator.Definition;
if (def == null) return;
var heightDist = def.FloorHeightDistance;
var maxFloors = def.FloorCount;
var rawFloor = Mathf.RoundToInt(-playerY / heightDist);
_currentPlayerFloorIndex = Mathf.Clamp(rawFloor, 0, maxFloors - 1);
@@ -155,9 +150,10 @@ namespace Infrastructure.Unity
// Hard Mode: Decay faster as score increases
var decayMultiplier = 1.0f + (_gameSession.Score / 500f);
for (var i = _allTiles.Count - 1; i >= 0; i--)
var allTiles = _tileRegistry.AllTiles;
for (var i = allTiles.Count - 1; i >= 0; i--)
{
_allTiles[i].Tick(dt * dilation * decayMultiplier);
allTiles[i].Tick(dt * dilation * decayMultiplier);
}
}
@@ -169,14 +165,14 @@ namespace Infrastructure.Unity
{
Time.timeScale = 0f;
if (soundManager) soundManager.SetPaused(true);
if (pauseUi) pauseUi.SetActive(true);
uiCoordinator?.ShowPauseUi();
if (rumbleManager) rumbleManager.SetPaused(true);
}
else
{
Time.timeScale = 1f;
if (soundManager) soundManager.SetPaused(false);
if (pauseUi) pauseUi.SetActive(false);
uiCoordinator?.HidePauseUi();
if (rumbleManager) rumbleManager.SetPaused(false);
}
}
@@ -185,7 +181,7 @@ namespace Infrastructure.Unity
{
if (_currentOrbInstance) Destroy(_currentOrbInstance);
if (!_tileViews.TryGetValue(tileId, out var tileView)) return;
if (!_tileRegistry.TryGetView(tileId, out var tileView)) return;
if (!tileView) return;
var spawnPos = tileView.transform.position + Vector3.up;
@@ -204,34 +200,6 @@ namespace Infrastructure.Unity
}
}
private void UpdateScoreUi(int newScore)
{
if (!scoreText) return;
LeanTween.cancel(scoreText.gameObject);
scoreText.rectTransform.localScale = Vector3.one;
LeanTween.scale(scoreText.rectTransform, Vector3.one * 1.5f, 0.5f)
.setEasePunch();
LeanTween.value(scoreText.gameObject, (float val) =>
{
var currentVal = Mathf.RoundToInt(val);
var combo = _gameSession?.ComboMultiplier ?? 1;
var comboText = combo > 1 ? $" <color=yellow>x{combo}</color>" : "";
scoreText.text = $"{currentVal}{comboText}";
}, _currentDisplayedScore, newScore, 0.5f)
.setEaseOutExpo();
_currentDisplayedScore = newScore;
if (highScoreText && _gameSession != null)
highScoreText.text = $"BEST: {_gameSession.HighScore}";
}
private void SpawnPlayer()
{
var spawnPos = new Vector3(0f, 5f, 0f);
@@ -251,10 +219,11 @@ namespace Infrastructure.Unity
{
if (!levelGenerator) return;
var lowestY = -(levelGenerator.FloorsCount * levelGenerator.FloorHeightDistance) - 5f;
var pos = new Vector3(levelGenerator.GridSizeX / 2f, lowestY, levelGenerator.GridSizeY / 2f);
var def = levelGenerator.Definition;
var lowestY = -(def.FloorCount * def.FloorHeightDistance) - 5f;
var pos = new Vector3(def.GridSizeX / 2f, lowestY, def.GridSizeY / 2f);
var plane = Instantiate(deathPlanePrefab, pos, Quaternion.identity);
plane.transform.localScale = new Vector3(levelGenerator.GridSizeX * 200f, 1f, levelGenerator.GridSizeY * 200f);
plane.transform.localScale = new Vector3(def.GridSizeX * 200f, 1f, def.GridSizeY * 200f);
plane.OnPlayerFell += () => _gameSession.EndGame();
}
@@ -264,8 +233,6 @@ namespace Infrastructure.Unity
_isGameRunning = false;
if (beatPulseController) beatPulseController.StopTracking();
if (gameOverUi) gameOverUi.SetActive(true);
StartCoroutine(RestartRoutine());
}
@@ -279,7 +246,7 @@ namespace Infrastructure.Unity
private void WireEvents()
{
_gameSession.OnScoreChanged += UpdateScoreUi;
uiCoordinator?.Subscribe(_gameSession);
_gameSession.OnOrbSpawned += SpawnVisualOrb;
_gameSession.OnOrbReset += HandleOrbReset;
_gameSession.OnGameOver += HandleGameOver;
@@ -303,18 +270,18 @@ namespace Infrastructure.Unity
private void SpawnNpc()
{
var validTiles = _allTiles.FindAll(t => t.Floor == 0 && t.CurrentState == TileState.Stable);
var validTiles = _tileRegistry.FindTiles(t => t.Floor == 0 && t.CurrentState == TileState.Stable);
if (validTiles.Count == 0)
{
validTiles = _allTiles.FindAll(t => t.CurrentState == TileState.Stable);
validTiles = _tileRegistry.FindTiles(t => t.CurrentState == TileState.Stable);
}
if (validTiles.Count == 0) return;
var randomTile = validTiles[Random.Range(0, validTiles.Count)];
if (!_tileViews.TryGetValue(randomTile.Id, out var tileView)) return;
if (!_tileRegistry.TryGetView(randomTile.Id, out var tileView)) return;
if (!tileView) return;
var spawnPos = tileView.transform.position + Vector3.up * 5f;
@@ -336,9 +303,8 @@ namespace Infrastructure.Unity
private void StartGameSequence()
{
_isGameRunning = true;
_currentDisplayedScore = 0;
if (startScreenUi) startScreenUi.SetActive(false);
uiCoordinator?.HideStartScreen();
if (soundManager)
{
@@ -359,7 +325,7 @@ namespace Infrastructure.Unity
private void SpawnPowerUp(PowerUpType type, string tileId)
{
if (!_tileViews.TryGetValue(tileId, out var tileView)) return;
if (!_tileRegistry.TryGetView(tileId, out var tileView)) return;
if (!tileView) return;
var spawnPos = tileView.transform.position + Vector3.up * 0.5f;
@@ -367,35 +333,47 @@ namespace Infrastructure.Unity
instance.Configure(type);
instance.OnCollected += (t) =>
instance.OnCollected += (t, dur) =>
{
cameraController?.Shake(0.2f, 0.15f);
rumbleManager?.PulseMedium();
if (t == PowerUpType.TimeSlow)
switch (t)
{
_gameSession.ActivateTimeSlow(10f);
case PowerUpType.TimeSlow:
_gameSession.ActivateTimeSlow(dur);
break;
case PowerUpType.LightFooted:
_playerInstance?.Status.AddEffect(new LightFootedEffect(dur));
break;
case PowerUpType.SpeedBoost:
_playerInstance?.Status.AddEffect(new SpeedBoostEffect(dur));
break;
default:
Debug.LogWarning($"GameBootstrap: no effect handler for PowerUpType {t}");
break;
}
};
}
private void OnBeatMeasure()
{
if (_allTiles.Count == 0) return;
var allTiles = _tileRegistry.AllTiles;
if (allTiles.Count == 0) return;
var pulseCount = 25;
for (var i = 0; i < pulseCount; i++)
{
var randIndex = Random.Range(0, _allTiles.Count);
var tile = _allTiles[randIndex];
var randIndex = Random.Range(0, allTiles.Count);
var tile = allTiles[randIndex];
if (tile.Floor < _currentPlayerFloorIndex) continue;
if (tile.Floor > _currentPlayerFloorIndex + 1) continue;
if (tile.CurrentState != TileState.Stable) continue;
if (_tileViews.TryGetValue(tile.Id, out var tileView))
if (_tileRegistry.TryGetView(tile.Id, out var tileView))
{
tileView.PulseEmission(Random.Range(1.2f, 2.0f));
}

View File

@@ -0,0 +1,86 @@
using Core.Domain;
using TMPro;
using UnityEngine;
namespace Infrastructure.Unity
{
public class GameUiCoordinator : MonoBehaviour
{
[Header("References")]
[SerializeField] private TMP_Text scoreText;
[SerializeField] private TMP_Text highScoreText;
[SerializeField] private GameObject gameOverUi;
[SerializeField] private GameObject pauseUi;
[SerializeField] private GameObject startScreenUi;
private GameSession _session;
private int _currentDisplayedScore;
public void Subscribe(GameSession session)
{
_session = session;
session.OnScoreChanged += UpdateScore;
session.OnGameOver += ShowGameOverUi;
}
private void OnDestroy()
{
if (_session == null) return;
_session.OnScoreChanged -= UpdateScore;
_session.OnGameOver -= ShowGameOverUi;
}
public void ShowStartScreen()
{
if (gameOverUi) gameOverUi.SetActive(false);
if (startScreenUi) startScreenUi.SetActive(true);
}
public void HideStartScreen()
{
if (startScreenUi) startScreenUi.SetActive(false);
}
public void ShowGameOverUi()
{
if (gameOverUi) gameOverUi.SetActive(true);
}
public void ShowPauseUi()
{
if (pauseUi) pauseUi.SetActive(true);
}
public void HidePauseUi()
{
if (pauseUi) pauseUi.SetActive(false);
}
public void UpdateHighScore(int highScore)
{
if (highScoreText) highScoreText.text = $"BEST: {highScore}";
}
private void UpdateScore(int newScore)
{
if (!scoreText) return;
LeanTween.cancel(scoreText.gameObject);
scoreText.rectTransform.localScale = Vector3.one;
LeanTween.scale(scoreText.rectTransform, Vector3.one * 1.5f, 0.5f).setEasePunch();
LeanTween.value(scoreText.gameObject, (float val) =>
{
var combo = _session?.ComboMultiplier ?? 1;
var comboText = combo > 1 ? $" <color=yellow>x{combo}</color>" : "";
scoreText.text = $"{Mathf.RoundToInt(val)}{comboText}";
}, _currentDisplayedScore, newScore, 0.5f)
.setEaseOutExpo();
_currentDisplayedScore = newScore;
if (highScoreText && _session != null)
highScoreText.text = $"BEST: {_session.HighScore}";
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: b2d628098b45e36879d59d2c2c2bf061

View File

@@ -1,4 +1,5 @@
using System;
using Core.Ports;
using UnityEngine;
using KBCore.Refs;
@@ -37,9 +38,9 @@ namespace Infrastructure.Unity
if (Physics.Raycast(transform.position, Vector3.down, out var hit, 2f, tileLayer))
{
if (hit.collider.TryGetComponent<TileViewAdapter>(out var tile))
if (hit.collider.TryGetComponent<ITileView>(out var tileView))
{
tile.OnPlayerStep();
tileView.StepOn();
}
}
}

View File

@@ -0,0 +1,39 @@
using System;
using System.Collections.Generic;
using UnityEngine;
namespace Infrastructure.Unity
{
[CreateAssetMenu(fileName = "LevelDefinition", menuName = "Decay Grid/Level Definition")]
public class LevelDefinition : ScriptableObject
{
[SerializeField] private int gridSizeX = 10;
[SerializeField] private int gridSizeY = 10;
[SerializeField] private float floorHeightDistance = 15f;
[SerializeField] private FloorConfig[] floors =
{
new() { pattern = FloorPatternType.Square },
new() { pattern = FloorPatternType.Donut },
new() { pattern = FloorPatternType.Circle }
};
public int GridSizeX => gridSizeX;
public int GridSizeY => gridSizeY;
public float FloorHeightDistance => floorHeightDistance;
public int FloorCount => floors.Length;
public IReadOnlyList<FloorConfig> Floors => floors;
}
[Serializable]
public class FloorConfig
{
public FloorPatternType pattern;
}
public enum FloorPatternType
{
Square,
Donut,
Circle
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d1e67659d937881ee99b3b9e84f91428

View File

@@ -4,6 +4,7 @@ using System.Collections.Generic;
using System.Diagnostics;
using Core.Domain;
using UnityEngine;
using Debug = UnityEngine.Debug;
using Random = UnityEngine.Random;
namespace Infrastructure.Unity
@@ -16,50 +17,68 @@ namespace Infrastructure.Unity
[SerializeField] private JumpPadAdapter jumpPadPrefab;
[SerializeField] private TeleporterAdapter teleporterPrefab;
[Header("Level")]
[SerializeField] private LevelDefinition levelDefinition;
[Header("Settings")]
[SerializeField] private int gridSizeX = 10;
[SerializeField] private int gridSizeY = 10;
[SerializeField] private int floorsCount = 3;
[SerializeField] private float floorHeightDistance = 15f;
[SerializeField] private float decayTime = 0.5f;
[SerializeField] private float fallingTime = 2.0f;
[SerializeField] private float minimumDistanceBetweenTeleporters = 3f;
public float FloorHeightDistance => floorHeightDistance;
public int FloorsCount => floorsCount;
public int GridSizeX => gridSizeX;
public int GridSizeY => gridSizeY;
public LevelDefinition Definition => levelDefinition;
private TilePool _tilePool;
public IEnumerator GenerateAsync(SoundManager soundManager, List<Tile> allTiles, Dictionary<string, TileViewAdapter> tileViews, CameraController camera, RumbleManager rumble, Action onComplete)
public IEnumerator GenerateAsync(SoundManager soundManager, TileRegistry registry, CameraController camera, RumbleManager rumble, Action onComplete)
{
if (!levelDefinition)
{
Debug.LogError("LevelGenerator: levelDefinition is not assigned. Assign a LevelDefinition asset in the Inspector.");
onComplete?.Invoke();
yield break;
}
_tilePool = new TilePool(tilePrefab, transform);
var stopwatch = new Stopwatch();
stopwatch.Start();
yield return GenerateFloorAsync(0, MapPatterns.GenerateSquare(gridSizeX, gridSizeY), soundManager, allTiles, tileViews, camera, rumble, stopwatch);
yield return GenerateFloorAsync(1, MapPatterns.GenerateDonut(gridSizeX, Mathf.FloorToInt(gridSizeX / 3f)), soundManager, allTiles, tileViews, camera, rumble, stopwatch);
yield return GenerateFloorAsync(2, MapPatterns.GenerateCircle(gridSizeX), soundManager, allTiles, tileViews, camera, rumble, stopwatch);
stopwatch?.Stop();
for (var i = 0; i < levelDefinition.FloorCount; i++)
{
var coords = GetCoordsForFloor(levelDefinition.Floors[i].pattern);
yield return GenerateFloorAsync(i, coords, soundManager, registry, camera, rumble, stopwatch);
}
stopwatch.Stop();
onComplete?.Invoke();
}
private IEnumerator GenerateFloorAsync(int floorIndex, List<Vector2Int> coordinates, SoundManager soundManager,
List<Tile> allTiles, Dictionary<string, TileViewAdapter> tileViews, CameraController camera, RumbleManager rumble, Stopwatch stopwatch)
private List<Vector2Int> GetCoordsForFloor(FloorPatternType pattern)
{
var yOffset = -(floorIndex * floorHeightDistance);
var xOffset = gridSizeX / 2f;
var zOffset = gridSizeY / 2f;
var sizeX = levelDefinition.GridSizeX;
var sizeY = levelDefinition.GridSizeY;
return pattern switch
{
FloorPatternType.Square => MapPatterns.GenerateSquare(sizeX, sizeY),
FloorPatternType.Donut => MapPatterns.GenerateDonut(sizeX, Mathf.FloorToInt(sizeX / 3f)),
FloorPatternType.Circle => MapPatterns.GenerateCircle(sizeX),
_ => MapPatterns.GenerateSquare(sizeX, sizeY)
};
}
private IEnumerator GenerateFloorAsync(int floorIndex, List<Vector2Int> coordinates, SoundManager soundManager,
TileRegistry registry, CameraController camera, RumbleManager rumble, Stopwatch stopwatch)
{
var yOffset = -(floorIndex * levelDefinition.FloorHeightDistance);
var xOffset = levelDefinition.GridSizeX / 2f;
var zOffset = levelDefinition.GridSizeY / 2f;
const long frameBudgetMs = 5;
foreach (var coord in coordinates)
{
var pos = new Vector3(coord.x - xOffset, yOffset, coord.y - zOffset);
CreateTile(pos, $"{floorIndex}_{coord.x}_{coord.y}", floorIndex, soundManager, allTiles, tileViews, camera, rumble);
CreateTile(pos, $"{floorIndex}_{coord.x}_{coord.y}", floorIndex, soundManager, registry, camera, rumble);
if (stopwatch.ElapsedMilliseconds > frameBudgetMs)
{
@@ -129,7 +148,7 @@ namespace Infrastructure.Unity
}
private void CreateTile(Vector3 position, string id, int floorIndex, SoundManager soundManager,
List<Tile> allTiles, Dictionary<string, TileViewAdapter> tileViews, CameraController camera, RumbleManager rumble)
TileRegistry registry, CameraController camera, RumbleManager rumble)
{
var go = _tilePool.Get();
go.transform.position = position;
@@ -172,8 +191,7 @@ namespace Infrastructure.Unity
}
};
allTiles.Add(tileLogic);
tileViews.Add(id, go);
registry.Register(tileLogic, go);
}
}
}

View File

@@ -1,4 +1,5 @@
using System;
using Core.Ports;
using KBCore.Refs;
using UnityEngine;
using UnityEngine.AI;
@@ -49,9 +50,9 @@ namespace Infrastructure.Unity
if (Physics.Raycast(transform.position, Vector3.down, out var hit, 2.0f, tileLayer))
{
if (hit.collider.TryGetComponent<TileViewAdapter>(out var tile))
if (hit.collider.TryGetComponent<ITileView>(out var tileView))
{
tile.OnPlayerStep();
tileView.StepOn();
}
}
}

View File

@@ -1,6 +1,7 @@
using System;
using Core.Domain.Status;
using Core.Domain.Status.Effects;
using Core.Ports;
using KBCore.Refs;
using UnityEngine;
using UnityEngine.InputSystem;
@@ -244,9 +245,9 @@ namespace Infrastructure.Unity
{
if (Physics.SphereCast(transform.position, 0.3f, Vector3.down, out var hit, groundCheckDistance, tileLayer))
{
if (hit.collider.TryGetComponent<TileViewAdapter>(out var tileAdapter))
if (hit.collider.TryGetComponent<ITileView>(out var tileView))
{
tileAdapter.OnPlayerStep();
tileView.StepOn();
}
}
}

View File

@@ -17,7 +17,7 @@ namespace Infrastructure.Unity
private MaterialPropertyBlock _propBlock;
private static readonly int ColorProperty = Shader.PropertyToID("_BaseColor");
public event Action<PowerUpType> OnCollected;
public event Action<PowerUpType, float> OnCollected;
private void Awake()
{
@@ -45,20 +45,16 @@ namespace Infrastructure.Unity
private void OnTriggerEnter(Collider other)
{
if (other.TryGetComponent<PlayerController>(out var player))
if (other.TryGetComponent<PlayerController>(out _))
{
ApplyEffect(player);
OnCollected?.Invoke(type);
OnCollected?.Invoke(type, duration);
if (pickupVfx)
{
var vfx = Instantiate(pickupVfx, transform.position, Quaternion.identity);
var main = vfx.main;
meshRenderer.GetPropertyBlock(_propBlock);
var currentColor = _propBlock.GetColor(ColorProperty);
main.startColor = currentColor;
main.startColor = _propBlock.GetColor(ColorProperty);
Destroy(vfx.gameObject, 2f);
}
@@ -66,24 +62,6 @@ namespace Infrastructure.Unity
}
}
private void ApplyEffect(PlayerController player)
{
switch (type)
{
case PowerUpType.LightFooted:
player.Status.AddEffect(new LightFootedEffect(duration));
break;
case PowerUpType.SpeedBoost:
player.Status.AddEffect(new SpeedBoostEffect(duration, 1.5f));
break;
case PowerUpType.TimeSlow:
// Handled globally
break;
default:
throw new ArgumentOutOfRangeException();
}
}
public void Configure(PowerUpType newType)
{
type = newType;

View File

@@ -0,0 +1,46 @@
using System;
using System.Collections.Generic;
using Core.Domain;
using UnityEngine;
namespace Infrastructure.Unity
{
public class TileRegistry
{
private readonly List<Tile> _tiles = new();
private readonly Dictionary<string, TileViewAdapter> _views = new();
public IReadOnlyList<Tile> AllTiles => _tiles;
public void Register(Tile tile, TileViewAdapter view = null)
{
_tiles.Add(tile);
if (view != null) _views[tile.Id] = view;
}
public bool TryGetView(string tileId, out TileViewAdapter view)
=> _views.TryGetValue(tileId, out view);
public List<Tile> FindTiles(Predicate<Tile> predicate)
=> _tiles.FindAll(predicate);
public List<List<TileViewAdapter>> GroupViewsByFloor(int floorCount)
{
var floors = new List<List<TileViewAdapter>>(floorCount);
for (var i = 0; i < floorCount; i++) floors.Add(new List<TileViewAdapter>());
foreach (var tile in _tiles)
{
if (tile.Floor >= floorCount)
{
Debug.LogWarning($"TileRegistry: tile '{tile.Id}' floor {tile.Floor} >= floorCount {floorCount}, skipping");
continue;
}
if (_views.TryGetValue(tile.Id, out var view))
floors[tile.Floor].Add(view);
}
return floors;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: b9e18fc22049e4e8c97b14af34cc4747

View File

@@ -106,7 +106,7 @@ namespace Infrastructure.Unity
StartCoroutine(ShrinkAndDestroy());
}
public void OnPlayerStep()
public void StepOn()
{
_linkedTile?.StepOn();
}

View File

@@ -29,6 +29,7 @@ MonoBehaviour:
m_StripUnusedVariants: 1
m_StripScreenCoordOverrideVariants: 1
supportRuntimeDebugDisplay: 0
m_EnableRenderGraph: 0
m_Settings:
m_SettingsList:
m_List:
@@ -68,7 +69,23 @@ MonoBehaviour:
- rid: 2396665312000868355
- rid: 2396665312000868356
m_RuntimeSettings:
m_List: []
m_List:
- rid: 6852985685364965378
- rid: 6852985685364965379
- rid: 6852985685364965380
- rid: 6852985685364965381
- rid: 6852985685364965384
- rid: 6852985685364965385
- rid: 6852985685364965392
- rid: 6852985685364965394
- rid: 8712630790384254976
- rid: 3311242227245645828
- rid: 3311242227245645829
- rid: 3311242227245645830
- rid: 3311242227245645832
- rid: 3311242227245645834
- rid: 3311242227245645835
- rid: 2396665312000868354
m_AssetVersion: 10
m_ObsoleteDefaultVolumeProfile: {fileID: 0}
m_RenderingLayerNames:
@@ -99,6 +116,8 @@ MonoBehaviour:
references:
version: 2
RefIds:
- rid: -2
type: {class: , ns: , asm: }
- rid: 2396665312000868354
type: {class: UniversalRenderPipelineRuntimeTerrainShaders, ns: UnityEngine.Rendering.Universal, asm: Unity.RenderPipelines.Universal.Runtime}
data:
@@ -362,6 +381,7 @@ MonoBehaviour:
type: {class: RenderGraphSettings, ns: UnityEngine.Rendering.Universal, asm: Unity.RenderPipelines.Universal.Runtime}
data:
m_Version: 0
m_EnableRenderCompatibilityMode: 0
- rid: 6852985685364965386
type: {class: GPUResidentDrawerResources, ns: UnityEngine.Rendering, asm: Unity.RenderPipelines.GPUDriven.Runtime}
data:

8
Assets/Tests.meta Normal file
View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 0980498940170826f97da8599b70e474
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 84756af79e10fce108b1a3dd15cbe7ae
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,44 @@
using NUnit.Framework;
using Core.Domain.Status;
using Core.Domain.Status.Effects;
namespace DecayGrid.Tests
{
public class StatusManagerTests
{
[Test]
public void DefaultCapabilities_HaveExpectedValues()
{
var sm = new StatusManager();
var caps = sm.CurrentCapabilities;
Assert.IsTrue(caps.CanTriggerDecay);
Assert.AreEqual(1f, caps.SpeedMultiplier, 0.001f);
Assert.IsFalse(caps.CanHover);
}
[Test]
public void SpeedBoostAlone_AppliesMultiplier()
{
var sm = new StatusManager();
sm.AddEffect(new SpeedBoostEffect(10f, 1.5f));
Assert.AreEqual(1.5f, sm.CurrentCapabilities.SpeedMultiplier, 0.001f);
}
[Test]
public void LightFootedAlone_AppliesMultiplier()
{
var sm = new StatusManager();
sm.AddEffect(new LightFootedEffect(10f));
Assert.AreEqual(1.2f, sm.CurrentCapabilities.SpeedMultiplier, 0.001f);
}
[Test]
public void TwoSpeedEffectsStack_Multiplicatively()
{
var sm = new StatusManager();
sm.AddEffect(new LightFootedEffect(10f)); // *1.2
sm.AddEffect(new SpeedBoostEffect(10f, 1.5f)); // *1.5
Assert.AreEqual(1.8f, sm.CurrentCapabilities.SpeedMultiplier, 0.001f); // 1.0 * 1.2 * 1.5
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 14aaf27a4073981cea2d26cc74361e3d

View File

@@ -0,0 +1,69 @@
using NUnit.Framework;
using Core.Domain;
using Infrastructure.Unity;
namespace DecayGrid.Tests
{
public class TileRegistryTests
{
private TileRegistry _registry;
[SetUp]
public void SetUp() => _registry = new TileRegistry();
[Test]
public void Register_AddsToAllTiles()
{
var tile = new Tile("0_0_0", 0, 0.5f, 2f);
_registry.Register(tile);
Assert.AreEqual(1, _registry.AllTiles.Count);
}
[Test]
public void FindTiles_ReturnsMatchingTiles()
{
var tile = new Tile("0_0_0", 0, 0.5f, 2f);
_registry.Register(tile);
var result = _registry.FindTiles(t => t.CurrentState == TileState.Stable);
Assert.AreEqual(1, result.Count);
}
[Test]
public void FindTiles_ExcludesNonMatchingTiles()
{
var tile = new Tile("0_0_0", 0, 0.5f, 2f);
tile.StepOn(); // Now Warning
_registry.Register(tile);
var result = _registry.FindTiles(t => t.CurrentState == TileState.Stable);
Assert.AreEqual(0, result.Count);
}
[Test]
public void TryGetView_ReturnsFalse_WhenTileHasNoView()
{
var tile = new Tile("0_0_0", 0, 0.5f, 2f);
_registry.Register(tile); // no view
Assert.IsFalse(_registry.TryGetView("0_0_0", out _));
}
[Test]
public void GroupViewsByFloor_PlacesTileOnCorrectFloor()
{
// Can't create TileViewAdapter in EditMode (MonoBehaviour), so just verify tile with no view doesn't crash
var tile = new Tile("1_5_5", 1, 0.5f, 2f);
_registry.Register(tile); // no view
var floors = _registry.GroupViewsByFloor(3);
Assert.AreEqual(3, floors.Count);
// Tile has no view registered, so no view should appear in any floor
Assert.AreEqual(0, floors[1].Count);
}
[Test]
public void GroupViewsByFloor_OutOfRangeTile_DoesNotThrow()
{
var tile = new Tile("5_0_0", 5, 0.5f, 2f); // floor 5, but only 3 floors
_registry.Register(tile);
Assert.DoesNotThrow(() => _registry.GroupViewsByFloor(3));
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8d6a64903e18ff18094c2db56bf61f38

View File

@@ -8,8 +8,10 @@
"com.unity.inputsystem": "1.19.0",
"com.unity.multiplayer.center": "1.0.1",
"com.unity.render-pipelines.universal": "17.4.0",
"com.unity.sdk.linux-x86_64": "1.1.0",
"com.unity.test-framework": "1.6.0",
"com.unity.timeline": "1.8.12",
"com.unity.toolchain.linux-x86_64-linux": "1.1.0",
"com.unity.ugui": "2.0.0",
"com.unity.visualscripting": "1.9.11",
"com.unity.modules.accessibility": "1.0.0",

View File

@@ -34,16 +34,17 @@
"url": "https://packages.unity.com"
},
"com.unity.collections": {
"version": "6.4.0",
"version": "2.6.5",
"depth": 2,
"source": "builtin",
"source": "registry",
"dependencies": {
"com.unity.burst": "1.8.23",
"com.unity.burst": "1.8.27",
"com.unity.mathematics": "1.3.2",
"com.unity.nuget.mono-cecil": "1.11.5",
"com.unity.test-framework": "1.4.6",
"com.unity.nuget.mono-cecil": "1.11.6",
"com.unity.test-framework.performance": "3.0.3"
}
},
"url": "https://packages.unity.com"
},
"com.unity.ext.nunit": {
"version": "2.0.5",
@@ -101,7 +102,7 @@
"url": "https://packages.unity.com"
},
"com.unity.render-pipelines.core": {
"version": "17.4.0",
"version": "17.3.0",
"depth": 1,
"source": "builtin",
"dependencies": {
@@ -109,28 +110,38 @@
"com.unity.mathematics": "1.3.2",
"com.unity.ugui": "2.0.0",
"com.unity.collections": "2.4.3",
"com.unity.modules.physics": "1.0.0",
"com.unity.modules.terrain": "1.0.0",
"com.unity.modules.jsonserialize": "1.0.0"
}
},
"com.unity.render-pipelines.universal": {
"version": "17.4.0",
"version": "17.3.0",
"depth": 0,
"source": "builtin",
"dependencies": {
"com.unity.render-pipelines.core": "17.4.0",
"com.unity.shadergraph": "17.4.0",
"com.unity.render-pipelines.universal-config": "17.4.0"
"com.unity.render-pipelines.core": "17.3.0",
"com.unity.shadergraph": "17.3.0",
"com.unity.render-pipelines.universal-config": "17.0.3"
}
},
"com.unity.render-pipelines.universal-config": {
"version": "17.4.0",
"version": "17.0.3",
"depth": 1,
"source": "builtin",
"dependencies": {
"com.unity.render-pipelines.core": "17.4.0"
"com.unity.render-pipelines.core": "17.0.3"
}
},
"com.unity.sdk.linux-x86_64": {
"version": "1.1.0",
"depth": 0,
"source": "registry",
"dependencies": {
"com.unity.sysroot.base": "1.1.0"
},
"url": "https://packages.unity.com"
},
"com.unity.searcher": {
"version": "4.9.4",
"depth": 2,
@@ -139,14 +150,21 @@
"url": "https://packages.unity.com"
},
"com.unity.shadergraph": {
"version": "17.4.0",
"version": "17.3.0",
"depth": 1,
"source": "builtin",
"dependencies": {
"com.unity.render-pipelines.core": "17.4.0",
"com.unity.render-pipelines.core": "17.3.0",
"com.unity.searcher": "4.9.3"
}
},
"com.unity.sysroot.base": {
"version": "1.1.0",
"depth": 1,
"source": "registry",
"dependencies": {},
"url": "https://packages.unity.com"
},
"com.unity.test-framework": {
"version": "1.6.0",
"depth": 0,
@@ -179,6 +197,15 @@
},
"url": "https://packages.unity.com"
},
"com.unity.toolchain.linux-x86_64-linux": {
"version": "1.1.0",
"depth": 0,
"source": "registry",
"dependencies": {
"com.unity.sysroot.base": "1.1.0"
},
"url": "https://packages.unity.com"
},
"com.unity.ugui": {
"version": "2.0.0",
"depth": 0,

View File

@@ -114,6 +114,7 @@ PlayerSettings:
xboxEnableGuest: 0
xboxEnablePIXSampling: 0
metalFramebufferOnly: 0
metalUseMetalDisplayLink: 0
xboxOneResolution: 0
xboxOneSResolution: 0
xboxOneXResolution: 3

View File

@@ -1,2 +1,2 @@
m_EditorVersion: 6000.4.3f1
m_EditorVersionWithRevision: 6000.4.3f1 (39d1a88d4dd1)
m_EditorVersion: 6000.3.15f1
m_EditorVersionWithRevision: 6000.3.15f1 (c1aa84e375f6)

File diff suppressed because it is too large Load Diff