Files
decay-grid/docs/superpowers/plans/2026-05-14-architecture-refactor.md

1328 lines
40 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Decay Grid Architecture Refactor Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Fix 6 concrete architectural problems in order of dependency — effect stacking, interface coupling, parallel collections, hardcoded floors, split power-up logic, and a god object — each phase leaving the game in a playable, compiling state.
**Architecture:** Ports & Adapters is already the structure; we're deepening it. Domain stays pure C#, Infrastructure stays Unity-aware. No new patterns introduced — just existing boundaries enforced properly.
**Tech Stack:** C# 10, Unity 6, Unity Test Runner (EditMode for domain logic), LeanTween, New Input System
---
## Phase 1 — Test Infrastructure
**Files:**
- Create: `Assets/Tests/EditMode/DecayGrid.Tests.EditMode.asmdef`
- Create: `Assets/Tests/EditMode/StatusManagerTests.cs`
- [ ] **Step 1: Create test directory**
```bash
mkdir -p "/mnt/drive/dev/Decay Grid/Assets/Tests/EditMode"
```
- [ ] **Step 2: Create assembly definition**
Create `Assets/Tests/EditMode/DecayGrid.Tests.EditMode.asmdef`:
```json
{
"name": "DecayGrid.Tests.EditMode",
"rootNamespace": "DecayGrid.Tests",
"references": [
"UnityEngine.TestRunner",
"UnityEditor.TestRunner"
],
"includePlatforms": [
"Editor"
],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": true,
"precompiledReferences": [
"nunit.framework.dll"
],
"autoReferenced": false,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": false
}
```
- [ ] **Step 3: Write a smoke test**
Create `Assets/Tests/EditMode/StatusManagerTests.cs`:
```csharp
using NUnit.Framework;
using Core.Domain.Status;
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);
}
}
}
```
- [ ] **Step 4: Run tests in Unity Editor**
Open Unity → Window → General → Test Runner → EditMode → Run All
Expected: 1 test passes.
- [ ] **Step 5: Commit**
```bash
cd "/mnt/drive/dev/Decay Grid"
git add Assets/Tests/
git commit -m "test: add EditMode test assembly"
```
---
## Phase 2 — Fix Status Effect Stacking (#6)
**Problem:** `LightFootedEffect.ModifyCapabilities` sets `caps.SpeedMultiplier = 1.2f` and `SpeedBoostEffect.ModifyCapabilities` sets `caps.SpeedMultiplier = _multiplier`. The second applied overwrites the first — last effect wins instead of stacking.
**Files:**
- Modify: `Assets/Scripts/Core/Domain/Status/Effects/LightFootedEffect.cs:21`
- Modify: `Assets/Scripts/Core/Domain/Status/Effects/SpeedBoostEffect.cs:15`
- Modify: `Assets/Tests/EditMode/StatusManagerTests.cs`
- [ ] **Step 1: Write failing stacking tests**
Add to `Assets/Tests/EditMode/StatusManagerTests.cs`:
```csharp
using Core.Domain.Status.Effects;
// (add inside StatusManagerTests class)
[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
}
```
- [ ] **Step 2: Run tests — confirm TwoSpeedEffectsStack fails**
Expected: `TwoSpeedEffectsStack_Multiplicatively` FAILS (actual: 1.5f, expected: 1.8f).
- [ ] **Step 3: Fix LightFootedEffect**
In `Assets/Scripts/Core/Domain/Status/Effects/LightFootedEffect.cs`, line 21:
```csharp
// Before:
caps.SpeedMultiplier = 1.2f;
// After:
caps.SpeedMultiplier *= 1.2f;
```
- [ ] **Step 4: Fix SpeedBoostEffect**
In `Assets/Scripts/Core/Domain/Status/Effects/SpeedBoostEffect.cs`, line 15:
```csharp
// Before:
caps.SpeedMultiplier = _multiplier;
// After:
caps.SpeedMultiplier *= _multiplier;
```
- [ ] **Step 5: Run tests — all pass**
Expected: 4 tests pass.
- [ ] **Step 6: Commit**
```bash
git add Assets/Scripts/Core/Domain/Status/Effects/LightFootedEffect.cs \
Assets/Scripts/Core/Domain/Status/Effects/SpeedBoostEffect.cs \
Assets/Tests/EditMode/StatusManagerTests.cs
git commit -m "fix: speed effects stack multiplicatively"
```
---
## Phase 3 — Fix Backwards Coupling: Controllers Must Not Know TileViewAdapter (#3)
**Problem:** `PlayerController.InteractWithGround()`, `NpcController.FixedUpdate()`, and `HunterNpcController.FixedUpdate()` all do `TryGetComponent<TileViewAdapter>()` and call `tile.OnPlayerStep()`. The UI adapter should not be in the call chain from physics controllers. The `Core.Ports.ITileView` exists exactly for this — it just needs `StepOn()` added to it.
**Files:**
- Modify: `Assets/Scripts/Core/Ports/ITileView.cs`
- Modify: `Assets/Scripts/Infrastructure/Unity/TileViewAdapter.cs:109`
- Modify: `Assets/Scripts/Infrastructure/Unity/PlayerController.cs:243-252`
- Modify: `Assets/Scripts/Infrastructure/Unity/NpcController.cs:50-56`
- Modify: `Assets/Scripts/Infrastructure/Unity/HunterNpcController.cs:38-44`
- [ ] **Step 1: Add StepOn() to ITileView**
Full replacement of `Assets/Scripts/Core/Ports/ITileView.cs`:
```csharp
using Core.Domain;
namespace Core.Ports
{
public interface ITileView
{
string TileId { get; }
void Initialize(Tile tile);
void SetVisualState(TileState state);
void DropPhysics();
void Dispose();
void StepOn();
}
}
```
- [ ] **Step 2: Confirm compile error**
Unity will show: `TileViewAdapter does not implement interface member 'ITileView.StepOn()'`
- [ ] **Step 3: Rename OnPlayerStep to StepOn in TileViewAdapter**
In `Assets/Scripts/Infrastructure/Unity/TileViewAdapter.cs`, line 109:
```csharp
// Before:
public void OnPlayerStep()
{
_linkedTile?.StepOn();
}
// After:
public void StepOn()
{
_linkedTile?.StepOn();
}
```
- [ ] **Step 4: Update PlayerController**
In `Assets/Scripts/Infrastructure/Unity/PlayerController.cs`:
Add `using Core.Ports;` with the other usings at the top.
Replace `InteractWithGround()` (lines 243252):
```csharp
private void InteractWithGround()
{
if (Physics.SphereCast(transform.position, 0.3f, Vector3.down, out var hit, groundCheckDistance, tileLayer))
{
if (hit.collider.TryGetComponent<ITileView>(out var tileView))
{
tileView.StepOn();
}
}
}
```
- [ ] **Step 5: Update NpcController**
In `Assets/Scripts/Infrastructure/Unity/NpcController.cs`:
Add `using Core.Ports;` at the top.
In `FixedUpdate()`, replace the raycast block (lines 5056):
```csharp
if (Physics.Raycast(transform.position, Vector3.down, out var hit, 2.0f, tileLayer))
{
if (hit.collider.TryGetComponent<ITileView>(out var tileView))
{
tileView.StepOn();
}
}
```
- [ ] **Step 6: Update HunterNpcController**
In `Assets/Scripts/Infrastructure/Unity/HunterNpcController.cs`:
Add `using Core.Ports;` at the top.
In `FixedUpdate()`, replace the raycast block (lines 3844):
```csharp
if (Physics.Raycast(transform.position, Vector3.down, out var hit, 2f, tileLayer))
{
if (hit.collider.TryGetComponent<ITileView>(out var tileView))
{
tileView.StepOn();
}
}
```
- [ ] **Step 7: Play test — tiles decay when walked on**
Play in Unity Editor. Walk the player over tiles and verify they go Warning → Falling → Destroyed. NPCs should also cause decay.
- [ ] **Step 8: Commit**
```bash
git add Assets/Scripts/Core/Ports/ITileView.cs \
Assets/Scripts/Infrastructure/Unity/TileViewAdapter.cs \
Assets/Scripts/Infrastructure/Unity/PlayerController.cs \
Assets/Scripts/Infrastructure/Unity/NpcController.cs \
Assets/Scripts/Infrastructure/Unity/HunterNpcController.cs
git commit -m "refactor: controllers use ITileView port, not TileViewAdapter directly"
```
---
## Phase 4 — Introduce TileRegistry: Replace Parallel Collections (#5)
**Problem:** `GameBootstrap` maintains `_allTiles: List<Tile>` and `_tileViews: Dictionary<string, TileViewAdapter>` in lockstep. Both are passed into `LevelGenerator.GenerateAsync()` as separate parameters. `FloorVisibilityManager` also receives both. Any lookup requires consulting both manually.
**Files:**
- Create: `Assets/Scripts/Infrastructure/Unity/TileRegistry.cs`
- Create: `Assets/Tests/EditMode/TileRegistryTests.cs`
- Modify: `Assets/Scripts/Core/Domain/GameSession.cs`
- Modify: `Assets/Scripts/Infrastructure/Unity/LevelGenerator.cs`
- Modify: `Assets/Scripts/Infrastructure/Unity/GameBootstrap.cs`
- Modify: `Assets/Scripts/Infrastructure/Unity/FloorVisibilityManager.cs`
- [ ] **Step 1: Write failing TileRegistry tests**
Create `Assets/Tests/EditMode/TileRegistryTests.cs`:
```csharp
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_WhenNoViewRegistered()
{
var tile = new Tile("0_0_0", 0, 0.5f, 2f);
_registry.Register(tile); // no view
Assert.IsFalse(_registry.TryGetView("0_0_0", out _));
}
}
}
```
- [ ] **Step 2: Run tests — fail with compile error (TileRegistry missing)**
- [ ] **Step 3: Create TileRegistry**
Create `Assets/Scripts/Infrastructure/Unity/TileRegistry.cs`:
```csharp
using System;
using System.Collections.Generic;
using Core.Domain;
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 && _views.TryGetValue(tile.Id, out var view))
floors[tile.Floor].Add(view);
}
return floors;
}
}
}
```
- [ ] **Step 4: Run tests — all 4 pass**
- [ ] **Step 5: Update GameSession to accept IReadOnlyList\<Tile\>**
In `Assets/Scripts/Core/Domain/GameSession.cs`:
Add `using System.Linq;` at the top.
Change the field and constructor:
```csharp
// Before:
private readonly List<Tile> _tiles;
public GameSession(List<Tile> tiles, IPersistenceService persistenceService)
{
_tiles = tiles;
// After:
private readonly IReadOnlyList<Tile> _tiles;
public GameSession(IReadOnlyList<Tile> tiles, IPersistenceService persistenceService)
{
_tiles = tiles;
```
Replace both `FindAll` calls with LINQ. In `SpawnNextOrb()`:
```csharp
// Before:
var validTiles = _tiles.FindAll(t =>
t.CurrentState == TileState.Stable &&
t.Floor == _playerFloorIndex
);
if (validTiles.Count == 0)
validTiles = _tiles.FindAll(t => t.CurrentState == TileState.Stable);
// After:
var validTiles = _tiles.Where(t =>
t.CurrentState == TileState.Stable && t.Floor == _playerFloorIndex).ToList();
if (validTiles.Count == 0)
validTiles = _tiles.Where(t => t.CurrentState == TileState.Stable).ToList();
```
In `SpawnRandomPowerUp()`:
```csharp
// Before:
var validTiles = _tiles.FindAll(t =>
t.CurrentState == TileState.Stable &&
t.Floor == _playerFloorIndex
);
if (validTiles.Count == 0)
validTiles = _tiles.FindAll(t => t.CurrentState == TileState.Stable);
// After:
var validTiles = _tiles.Where(t =>
t.CurrentState == TileState.Stable && t.Floor == _playerFloorIndex).ToList();
if (validTiles.Count == 0)
validTiles = _tiles.Where(t => t.CurrentState == TileState.Stable).ToList();
```
- [ ] **Step 6: Update LevelGenerator.GenerateAsync signature**
In `Assets/Scripts/Infrastructure/Unity/LevelGenerator.cs`:
Change `GenerateAsync` to accept `TileRegistry` instead of the two separate collections:
```csharp
// Before:
public IEnumerator GenerateAsync(SoundManager soundManager, List<Tile> allTiles,
Dictionary<string, TileViewAdapter> tileViews, CameraController camera,
RumbleManager rumble, Action onComplete)
{
_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();
onComplete?.Invoke();
}
// After:
public IEnumerator GenerateAsync(SoundManager soundManager, TileRegistry registry,
CameraController camera, RumbleManager rumble, Action onComplete)
{
_tilePool = new TilePool(tilePrefab, transform);
var stopwatch = new Stopwatch();
stopwatch.Start();
yield return GenerateFloorAsync(0, MapPatterns.GenerateSquare(gridSizeX, gridSizeY),
soundManager, registry, camera, rumble, stopwatch);
yield return GenerateFloorAsync(1, MapPatterns.GenerateDonut(gridSizeX, Mathf.FloorToInt(gridSizeX / 3f)),
soundManager, registry, camera, rumble, stopwatch);
yield return GenerateFloorAsync(2, MapPatterns.GenerateCircle(gridSizeX),
soundManager, registry, camera, rumble, stopwatch);
stopwatch.Stop();
onComplete?.Invoke();
}
```
Update `GenerateFloorAsync` signature (propagate `registry` down):
```csharp
// Before:
private IEnumerator GenerateFloorAsync(int floorIndex, List<Vector2Int> coordinates,
SoundManager soundManager, List<Tile> allTiles, Dictionary<string, TileViewAdapter> tileViews,
CameraController camera, RumbleManager rumble, Stopwatch stopwatch)
// After:
private IEnumerator GenerateFloorAsync(int floorIndex, List<Vector2Int> coordinates,
SoundManager soundManager, TileRegistry registry,
CameraController camera, RumbleManager rumble, Stopwatch stopwatch)
```
Update the `CreateTile` call inside `GenerateFloorAsync`:
```csharp
// Before:
CreateTile(pos, $"{floorIndex}_{coord.x}_{coord.y}", floorIndex,
soundManager, allTiles, tileViews, camera, rumble);
// After:
CreateTile(pos, $"{floorIndex}_{coord.x}_{coord.y}", floorIndex,
soundManager, registry, camera, rumble);
```
Update `CreateTile` signature and its final two lines:
```csharp
// Before:
private void CreateTile(Vector3 position, string id, int floorIndex, SoundManager soundManager,
List<Tile> allTiles, Dictionary<string, TileViewAdapter> tileViews,
CameraController camera, RumbleManager rumble)
{
// ...
allTiles.Add(tileLogic);
tileViews.Add(id, go);
}
// After:
private void CreateTile(Vector3 position, string id, int floorIndex, SoundManager soundManager,
TileRegistry registry, CameraController camera, RumbleManager rumble)
{
// ...
registry.Register(tileLogic, go);
}
```
- [ ] **Step 7: Update GameBootstrap to use TileRegistry**
In `Assets/Scripts/Infrastructure/Unity/GameBootstrap.cs`:
Remove:
```csharp
private readonly List<Tile> _allTiles = new();
private readonly Dictionary<string, TileViewAdapter> _tileViews = new();
```
Add:
```csharp
private readonly TileRegistry _tileRegistry = new();
```
In `Start()`, update the GameSession constructor call and GenerateAsync call:
```csharp
// Before:
_gameSession = new GameSession(_allTiles, _persistenceService);
// ...
StartCoroutine(levelGenerator.GenerateAsync(soundManager, _allTiles, _tileViews,
cameraController, rumbleManager, () =>
{
floorVisibilityManager.Initialize(_gameSession, _allTiles, _tileViews, floorsCount);
// ...
}));
// After:
_gameSession = new GameSession(_tileRegistry.AllTiles, _persistenceService);
// ...
StartCoroutine(levelGenerator.GenerateAsync(soundManager, _tileRegistry,
cameraController, rumbleManager, () =>
{
floorVisibilityManager.Initialize(_tileRegistry, floorsCount);
// ...
}));
```
Update `SpawnVisualOrb(string tileId)`:
```csharp
// Before:
if (!_tileViews.TryGetValue(tileId, out var tileView)) return;
// After:
if (!_tileRegistry.TryGetView(tileId, out var tileView)) return;
```
Update `SpawnNpc()`:
```csharp
// Before:
var validTiles = _allTiles.FindAll(t => t.Floor == 0 && t.CurrentState == TileState.Stable);
if (validTiles.Count == 0)
validTiles = _allTiles.FindAll(t => t.CurrentState == TileState.Stable);
// ...
if (!_tileViews.TryGetValue(randomTile.Id, out var tileView)) return;
// After:
var validTiles = _tileRegistry.FindTiles(t => t.Floor == 0 && t.CurrentState == TileState.Stable);
if (validTiles.Count == 0)
validTiles = _tileRegistry.FindTiles(t => t.CurrentState == TileState.Stable);
// ...
if (!_tileRegistry.TryGetView(randomTile.Id, out var tileView)) return;
```
Update `SpawnPowerUp(PowerUpType type, string tileId)`:
```csharp
// Before:
if (!_tileViews.TryGetValue(tileId, out var tileView)) return;
// After:
if (!_tileRegistry.TryGetView(tileId, out var tileView)) return;
```
Update `OnBeatMeasure()`:
```csharp
// Before:
var randIndex = Random.Range(0, _allTiles.Count);
var tile = _allTiles[randIndex];
// ...
if (_tileViews.TryGetValue(tile.Id, out var tileView))
// After:
var allTiles = _tileRegistry.AllTiles;
if (allTiles.Count == 0) return;
var randIndex = Random.Range(0, allTiles.Count);
var tile = allTiles[randIndex];
// ...
if (_tileRegistry.TryGetView(tile.Id, out var tileView))
```
Remove `using System.Collections.Generic;` from GameBootstrap if it is no longer used (check remaining usages first — `IEnumerator` is in `System.Collections`).
- [ ] **Step 8: Update FloorVisibilityManager.Initialize**
In `Assets/Scripts/Infrastructure/Unity/FloorVisibilityManager.cs`:
Replace `Initialize`:
```csharp
// Before:
public void Initialize(GameSession gameSession, List<Tile> allTiles,
Dictionary<string, TileViewAdapter> tileViews, 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))
if (tile.Floor < _floors.Count)
_floors[tile.Floor].Add(view);
}
}
// After:
public void Initialize(TileRegistry registry, int totalFloors)
{
_floors = registry.GroupViewsByFloor(totalFloors);
}
```
Remove the `_gameSession` field from `FloorVisibilityManager` (it's only referenced in the now-empty `Update()` method body). Remove the `Update()` method entirely if it has no logic.
Remove `using Core.Domain;` from FloorVisibilityManager if it's no longer used.
- [ ] **Step 9: Compile and play test**
Unity should compile clean. Play a full session — level generates, tiles decay, NPCs spawn, orbs appear, beat pulses fire.
- [ ] **Step 10: Commit**
```bash
git add Assets/Scripts/Infrastructure/Unity/TileRegistry.cs \
Assets/Scripts/Core/Domain/GameSession.cs \
Assets/Scripts/Infrastructure/Unity/LevelGenerator.cs \
Assets/Scripts/Infrastructure/Unity/GameBootstrap.cs \
Assets/Scripts/Infrastructure/Unity/FloorVisibilityManager.cs \
Assets/Tests/EditMode/TileRegistryTests.cs
git commit -m "refactor: TileRegistry replaces parallel tile/view collections"
```
---
## Phase 5 — Introduce LevelDefinition: End Hardcoded 3-Floor Structure (#2)
**Problem:** `LevelGenerator.GenerateAsync()` hardcodes three `yield return GenerateFloorAsync(...)` calls. Floor count, height distance, and grid size are spread across `LevelGenerator`, `GameBootstrap.Update()`, `GameBootstrap.SpawnDeathPlane()`, and `FloorVisibilityManager`. Adding a 4th floor requires touching all of them.
**Files:**
- Create: `Assets/Scripts/Infrastructure/Unity/LevelDefinition.cs`
- Modify: `Assets/Scripts/Infrastructure/Unity/LevelGenerator.cs`
- Modify: `Assets/Scripts/Infrastructure/Unity/GameBootstrap.cs`
- [ ] **Step 1: Create LevelDefinition ScriptableObject**
Create `Assets/Scripts/Infrastructure/Unity/LevelDefinition.cs`:
```csharp
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
}
}
```
- [ ] **Step 2: Update LevelGenerator to read from LevelDefinition**
In `Assets/Scripts/Infrastructure/Unity/LevelGenerator.cs`:
Remove these serialized fields and their public properties:
```csharp
// Remove:
[SerializeField] private int gridSizeX = 10;
[SerializeField] private int gridSizeY = 10;
[SerializeField] private int floorsCount = 3;
[SerializeField] private float floorHeightDistance = 15f;
// Remove:
public float FloorHeightDistance => floorHeightDistance;
public int FloorsCount => floorsCount;
public int GridSizeX => gridSizeX;
public int GridSizeY => gridSizeY;
```
Add:
```csharp
[Header("Level")]
[SerializeField] private LevelDefinition levelDefinition;
public LevelDefinition Definition => levelDefinition;
```
Replace the hardcoded three `yield return` calls in `GenerateAsync` with a loop:
```csharp
// Before:
yield return GenerateFloorAsync(0, MapPatterns.GenerateSquare(gridSizeX, gridSizeY), ...);
yield return GenerateFloorAsync(1, MapPatterns.GenerateDonut(gridSizeX, Mathf.FloorToInt(gridSizeX / 3f)), ...);
yield return GenerateFloorAsync(2, MapPatterns.GenerateCircle(gridSizeX), ...);
// After:
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);
}
```
Add the helper method after `GenerateAsync`:
```csharp
private List<Vector2Int> GetCoordsForFloor(FloorPatternType pattern)
{
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)
};
}
```
In `GenerateFloorAsync`, replace the three removed fields with `levelDefinition.*`:
```csharp
// Before:
var yOffset = -(floorIndex * floorHeightDistance);
var xOffset = gridSizeX / 2f;
var zOffset = gridSizeY / 2f;
// After:
var yOffset = -(floorIndex * levelDefinition.FloorHeightDistance);
var xOffset = levelDefinition.GridSizeX / 2f;
var zOffset = levelDefinition.GridSizeY / 2f;
```
Do the same for jump pad and teleporter position calculations within `GenerateFloorAsync` — replace `xOffset` and `zOffset` uses that referenced the removed fields (they now use the local variables defined above, so they're already covered if calculated at the top of the method).
- [ ] **Step 3: Update GameBootstrap to use levelGenerator.Definition**
In `Assets/Scripts/Infrastructure/Unity/GameBootstrap.cs`:
In `Start()`:
```csharp
// Before:
var floorsCount = levelGenerator ? levelGenerator.FloorsCount : 1;
// After:
var floorsCount = levelGenerator ? levelGenerator.Definition.FloorCount : 1;
```
In `Update()`:
```csharp
// Before:
var heightDist = levelGenerator.FloorHeightDistance;
var maxFloors = levelGenerator.FloorsCount;
// After:
var heightDist = levelGenerator.Definition.FloorHeightDistance;
var maxFloors = levelGenerator.Definition.FloorCount;
```
In `SpawnDeathPlane()`:
```csharp
// Before:
var lowestY = -(levelGenerator.FloorsCount * levelGenerator.FloorHeightDistance) - 5f;
var pos = new Vector3(levelGenerator.GridSizeX / 2f, lowestY, levelGenerator.GridSizeY / 2f);
plane.transform.localScale = new Vector3(levelGenerator.GridSizeX * 200f, 1f, levelGenerator.GridSizeY * 200f);
// After:
var def = levelGenerator.Definition;
var lowestY = -(def.FloorCount * def.FloorHeightDistance) - 5f;
var pos = new Vector3(def.GridSizeX / 2f, lowestY, def.GridSizeY / 2f);
plane.transform.localScale = new Vector3(def.GridSizeX * 200f, 1f, def.GridSizeY * 200f);
```
- [ ] **Step 4: Create the LevelDefinition asset in the Unity Editor**
In Unity Editor: Assets → Create → Decay Grid → Level Definition
Save as `Assets/Settings/DefaultLevelDefinition.asset`
In the Inspector, verify it has 3 floors (Square, Donut, Circle) matching the original hardcoded values.
Assign this asset to the `LevelGenerator` component's `levelDefinition` field in the scene.
- [ ] **Step 5: Play test — identical to before**
The level should generate identically. Test: in the Inspector, add a 4th floor to `DefaultLevelDefinition` with `FloorPatternType.Square` — verify a 4th floor appears without changing any code.
Remove the 4th floor after verifying, to restore the 3-floor default.
- [ ] **Step 6: Commit**
```bash
git add Assets/Scripts/Infrastructure/Unity/LevelDefinition.cs \
Assets/Scripts/Infrastructure/Unity/LevelGenerator.cs \
Assets/Scripts/Infrastructure/Unity/GameBootstrap.cs
git commit -m "refactor: LevelDefinition ScriptableObject drives floor count and geometry"
```
---
## Phase 6 — Unify Power-Up Collection (#4)
**Problem:** `PowerUpViewAdapter.ApplyEffect(PlayerController)` applies `LightFooted` and `SpeedBoost` directly to the player. `TimeSlow` is silently skipped there (`// Handled globally`) and applied via a callback in `GameBootstrap.SpawnPowerUp`. Effect logic is split between a UI adapter and the bootstrap. All effect application should live in `GameBootstrap` where both `_playerInstance` and `_gameSession` are available.
**Files:**
- Modify: `Assets/Scripts/Infrastructure/Unity/PowerUpViewAdapter.cs`
- Modify: `Assets/Scripts/Infrastructure/Unity/GameBootstrap.cs`
- [ ] **Step 1: Change OnCollected to include duration, remove ApplyEffect**
In `Assets/Scripts/Infrastructure/Unity/PowerUpViewAdapter.cs`:
Change the event signature:
```csharp
// Before:
public event Action<PowerUpType> OnCollected;
// After:
public event Action<PowerUpType, float> OnCollected;
```
Replace `OnTriggerEnter`:
```csharp
// Before:
private void OnTriggerEnter(Collider other)
{
if (other.TryGetComponent<PlayerController>(out var player))
{
ApplyEffect(player);
OnCollected?.Invoke(type);
// ... vfx ...
Destroy(gameObject);
}
}
// After:
private void OnTriggerEnter(Collider other)
{
if (other.TryGetComponent<PlayerController>(out _))
{
OnCollected?.Invoke(type, duration);
if (pickupVfx)
{
var vfx = Instantiate(pickupVfx, transform.position, Quaternion.identity);
var main = vfx.main;
meshRenderer.GetPropertyBlock(_propBlock);
main.startColor = _propBlock.GetColor(ColorProperty);
Destroy(vfx.gameObject, 2f);
}
Destroy(gameObject);
}
}
```
Delete the `ApplyEffect(PlayerController player)` method entirely.
Remove `using Core.Domain.Status.Effects;` from PowerUpViewAdapter (no longer needed).
- [ ] **Step 2: Update GameBootstrap.SpawnPowerUp to apply all effects**
In `Assets/Scripts/Infrastructure/Unity/GameBootstrap.cs`:
Add at the top: `using Core.Domain.Status.Effects;`
Replace the `OnCollected` handler in `SpawnPowerUp`:
```csharp
// Before:
instance.OnCollected += (t) =>
{
cameraController?.Shake(0.2f, 0.15f);
rumbleManager?.PulseMedium();
if (t == PowerUpType.TimeSlow)
{
_gameSession.ActivateTimeSlow(10f);
}
};
// After:
instance.OnCollected += (t, dur) =>
{
cameraController?.Shake(0.2f, 0.15f);
rumbleManager?.PulseMedium();
switch (t)
{
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;
}
};
```
- [ ] **Step 3: Play test all three power-up types**
Collect each power-up type in the editor:
- LightFooted: player color shifts, tiles should NOT decay under the player
- SpeedBoost: player moves noticeably faster
- TimeSlow: NPCs and decay visibly slow
- [ ] **Step 4: Commit**
```bash
git add Assets/Scripts/Infrastructure/Unity/PowerUpViewAdapter.cs \
Assets/Scripts/Infrastructure/Unity/GameBootstrap.cs
git commit -m "refactor: power-up effects applied centrally in GameBootstrap"
```
---
## Phase 7 — Split GameBootstrap: Extract GameUiCoordinator (#1)
**Problem:** `GameBootstrap` owns UI references (scoreText, highScoreText, gameOverUi, pauseUi, startScreenUi), LeanTween score animation, and show/hide logic for all menus. It's the last large concentration of unrelated responsibilities. Extracting UI into `GameUiCoordinator` removes ~70 lines and all TMP/UI concerns from GameBootstrap.
**Files:**
- Create: `Assets/Scripts/Infrastructure/Unity/GameUiCoordinator.cs`
- Modify: `Assets/Scripts/Infrastructure/Unity/GameBootstrap.cs`
- [ ] **Step 1: Create GameUiCoordinator**
Create `Assets/Scripts/Infrastructure/Unity/GameUiCoordinator.cs`:
```csharp
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;
}
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}";
}
}
}
```
- [ ] **Step 2: Remove UI fields from GameBootstrap and add coordinator reference**
In `Assets/Scripts/Infrastructure/Unity/GameBootstrap.cs`:
Remove the entire `[Header("Ui")]` block:
```csharp
// Remove:
[Header("Ui")]
[SerializeField] private TMP_Text scoreText;
[SerializeField] private TMP_Text highScoreText;
[SerializeField] private GameObject gameOverUi;
[SerializeField] private GameObject pauseUi;
[SerializeField] private GameObject startScreenUi;
```
Remove the `_currentDisplayedScore` field:
```csharp
// Remove:
private int _currentDisplayedScore;
```
Add the coordinator serialized field (in the `[Header("Infrastructure")]` section):
```csharp
[SerializeField] private GameUiCoordinator uiCoordinator;
```
- [ ] **Step 3: Update all GameBootstrap methods that referenced UI fields**
In `Start()` — replace UI setup:
```csharp
// Before:
if (gameOverUi) gameOverUi.SetActive(false);
if (startScreenUi) startScreenUi.SetActive(true); // before level gen callback
// ...
if (gameOverUi) gameOverUi.SetActive(false); // inside level gen callback
if (startScreenUi) startScreenUi.SetActive(true); // inside level gen callback
// ...
UpdateScoreUi(_gameSession.Score);
// After (before GenerateAsync call):
uiCoordinator?.ShowStartScreen();
// After (inside the level gen onComplete callback):
uiCoordinator?.ShowStartScreen();
uiCoordinator?.UpdateHighScore(_gameSession.HighScore);
```
In `WireEvents()`:
```csharp
// Before:
_gameSession.OnScoreChanged += UpdateScoreUi;
_gameSession.OnGameOver += HandleGameOver;
// After:
uiCoordinator?.Subscribe(_gameSession);
_gameSession.OnGameOver += HandleGameOver;
```
In `HandleGameOver()`:
```csharp
// Before:
private void HandleGameOver()
{
_isGameRunning = false;
if (beatPulseController) beatPulseController.StopTracking();
if (gameOverUi) gameOverUi.SetActive(true); // remove this line
StartCoroutine(RestartRoutine());
}
// After:
private void HandleGameOver()
{
_isGameRunning = false;
if (beatPulseController) beatPulseController.StopTracking();
StartCoroutine(RestartRoutine());
// GameUiCoordinator.ShowGameOverUi() fires via its own OnGameOver subscription
}
```
In `TogglePause()`:
```csharp
// Before:
if (_isPaused)
{
Time.timeScale = 0f;
if (soundManager) soundManager.SetPaused(true);
if (pauseUi) pauseUi.SetActive(true);
if (rumbleManager) rumbleManager.SetPaused(true);
}
else
{
Time.timeScale = 1f;
if (soundManager) soundManager.SetPaused(false);
if (pauseUi) pauseUi.SetActive(false);
if (rumbleManager) rumbleManager.SetPaused(false);
}
// After:
if (_isPaused)
{
Time.timeScale = 0f;
if (soundManager) soundManager.SetPaused(true);
if (rumbleManager) rumbleManager.SetPaused(true);
uiCoordinator?.ShowPauseUi();
}
else
{
Time.timeScale = 1f;
if (soundManager) soundManager.SetPaused(false);
if (rumbleManager) rumbleManager.SetPaused(false);
uiCoordinator?.HidePauseUi();
}
```
In `StartGameSequence()`:
```csharp
// Before:
if (startScreenUi) startScreenUi.SetActive(false);
_currentDisplayedScore = 0;
// After:
uiCoordinator?.HideStartScreen();
```
Delete `UpdateScoreUi(int newScore)` from GameBootstrap entirely.
Remove `using TMPro;` from GameBootstrap if it's no longer used.
- [ ] **Step 4: Wire the GameUiCoordinator in the Unity scene**
In Unity Editor:
1. Select the GameBootstrap GameObject in the Hierarchy
2. Add Component → `GameUiCoordinator`
3. Wire `scoreText`, `highScoreText`, `gameOverUi`, `pauseUi`, `startScreenUi` on the new component (same references that were on GameBootstrap)
4. Assign the `GameUiCoordinator` component reference to the `uiCoordinator` field on GameBootstrap
- [ ] **Step 5: Play a full game session**
Verify the entire flow:
- Start screen shows on launch
- Game begins on input press
- Score increments and animates with combo multiplier
- High score displays correctly
- Pause menu toggles with pause input
- Game over screen appears after death
- Scene reloads after `restartTime` seconds
- [ ] **Step 6: Commit**
```bash
git add Assets/Scripts/Infrastructure/Unity/GameUiCoordinator.cs \
Assets/Scripts/Infrastructure/Unity/GameBootstrap.cs
git commit -m "refactor: extract GameUiCoordinator from GameBootstrap"
```
---
## Self-Review
**Spec coverage:**
-#6 Effect stacking: Phase 2 — `*=` in `LightFootedEffect` and `SpeedBoostEffect`
-#3 Backwards coupling: Phase 3 — `ITileView.StepOn()`, all three controllers use port
-#5 Parallel collections: Phase 4 — `TileRegistry` owns both, replaces `_allTiles`/`_tileViews`
-#2 Hardcoded floors: Phase 5 — `LevelDefinition` ScriptableObject drives floor structure
-#4 Split power-up: Phase 6 — all effect application in GameBootstrap
-#1 God object: Phase 7 — `GameUiCoordinator` extracts all UI logic
**Placeholder scan:** None. All steps have exact code blocks and expected outcomes.
**Type consistency:**
- `TileRegistry` referenced identically across `LevelGenerator`, `GameBootstrap`, `FloorVisibilityManager`, `TileRegistryTests`
- `ITileView.StepOn()` matches `TileViewAdapter.StepOn()` and all three controller call sites
- `LevelDefinition.FloorCount` (not `FloorsCount`) used consistently in LevelGenerator and GameBootstrap
- `OnCollected(PowerUpType, float)` matches the `(t, dur) =>` lambda in GameBootstrap
- `IReadOnlyList<Tile>` in `GameSession` uses `.Where().ToList()` (not `FindAll`) consistently