1328 lines
40 KiB
Markdown
1328 lines
40 KiB
Markdown
# 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 243–252):
|
||
|
||
```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 50–56):
|
||
|
||
```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 38–44):
|
||
|
||
```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
|