Files
tiny_tackle_heroes/Assets/_Project/Tests/EditMode/LevelPresetRuntimeTests.cs
T
2026-09-23 12:53:52 +09:00

489 lines
22 KiB
C#

using System.Collections.Generic;
using BumpCombat.Core;
using BumpCombat.Enemies;
using BumpCombat.Player;
using BumpCombat.Spawning;
using BumpCombat.Stage;
using NUnit.Framework;
using UnityEngine;
namespace BumpCombat.Tests
{
public sealed class LevelPresetRuntimeTests
{
private GameObject stageManagerObject;
private StageManager stageManager;
private GameObject spawnDirectorObject;
private SpawnDirector spawnDirector;
private GameObject playerObject;
private PlayerController player;
[SetUp]
public void SetUp()
{
stageManagerObject = new GameObject("StageManager_RuntimeTest");
stageManager = stageManagerObject.AddComponent<StageManager>();
spawnDirectorObject = new GameObject("SpawnDirector_RuntimeTest");
spawnDirector = spawnDirectorObject.AddComponent<SpawnDirector>();
playerObject = new GameObject("Player_RuntimeTest");
player = playerObject.AddComponent<PlayerController>();
// Setup mock enemy prefabs on SpawnDirector via reflection
SetupMockEnemyPrefabs();
}
[TearDown]
public void TearDown()
{
if (stageManagerObject != null) Object.DestroyImmediate(stageManagerObject);
if (spawnDirectorObject != null) Object.DestroyImmediate(spawnDirectorObject);
if (playerObject != null) Object.DestroyImmediate(playerObject);
ArenaBounds.Instance?.ResetToDefaultBounds();
}
private void SetupMockEnemyPrefabs()
{
#if UNITY_EDITOR
EnemyController batPrefab = UnityEditor.AssetDatabase.LoadAssetAtPath<EnemyController>("Assets/_Project/Prefabs/Enemies/Bat.prefab");
EnemyController skelPrefab = UnityEditor.AssetDatabase.LoadAssetAtPath<EnemyController>("Assets/_Project/Prefabs/Enemies/Skeleton.prefab");
if (batPrefab != null && skelPrefab != null)
{
var field = typeof(SpawnDirector).GetField("enemyPrefabs", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
field?.SetValue(spawnDirector, new EnemyController[] { skelPrefab, batPrefab });
}
#endif
}
private ZoneInstance CreateZoneInstanceWithS1A(int nodeId, Vector2 worldPos)
{
return CreateZoneInstanceWithPreset("GRAVE_S1_COMBAT_A", nodeId, worldPos);
}
private ZoneInstance CreateZoneInstanceWithPreset(
string presetId,
int nodeId,
Vector2 worldPos,
bool connectEast = true)
{
StageNode node = new StageNode
{
Id = nodeId,
NodeType = StageNodeType.Combat,
PresetId = presetId,
GridPosition = Vector2Int.zero,
};
node.SetConnection(StageDirection.North, 2);
node.SetConnection(StageDirection.South, 3);
node.SetConnection(StageDirection.West, 4);
if (connectEast)
{
node.SetConnection(StageDirection.East, 5);
}
GameObject zoneObj = new GameObject($"Zone_{nodeId}_Combat");
zoneObj.transform.position = worldPos;
zoneObj.transform.SetParent(stageManager.transform);
ZoneInstance zone = zoneObj.AddComponent<ZoneInstance>();
zone.Initialize(node, new Vector2(9f, 6f));
// Call private BuildZoneGeometry via reflection
var method = typeof(StageManager).GetMethod("BuildZoneGeometry",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
method?.Invoke(stageManager, new object[] { zoneObj, zone, node });
return zone;
}
[Test]
public void ZoneConnector_ExistingBarrierObjectWithoutCollider_AddsBoxCollider2D()
{
GameObject connectorObject = new GameObject("ZoneConnector_RuntimeTest");
connectorObject.transform.SetParent(stageManager.transform);
connectorObject.AddComponent<BoxCollider2D>();
GameObject barrierObject = new GameObject("BarrierCollider");
barrierObject.transform.SetParent(connectorObject.transform, false);
connectorObject.AddComponent<ZoneConnector>();
Assert.IsNotNull(barrierObject.GetComponent<BoxCollider2D>());
}
[Test]
public void S1A_Geometry_EmptyCellsBlocked_And_ObstaclesPlacedCorrectly()
{
ZoneInstance zone = CreateZoneInstanceWithS1A(10, Vector2.zero);
Assert.IsNotNull(zone);
// S1-A (18x12 = 216 cells). Count empty and obstacle objects
int emptyColliderCount = 0;
int obstacleColliderCount = 0;
for (int i = 0; i < zone.transform.childCount; i++)
{
Transform child = zone.transform.GetChild(i);
if (child.name.StartsWith("EmptyCell_")) emptyColliderCount++;
if (child.name.StartsWith("Obstacle_")) obstacleColliderCount++;
}
// Approved S1-A layout uses five broken cover cells.
Assert.AreEqual(5, obstacleColliderCount, "S1-A must have exactly 5 obstacle objects");
// Approved S1-A obstacle cells.
Assert.IsNotNull(zone.transform.Find("Obstacle_6_3"));
Assert.IsNotNull(zone.transform.Find("Obstacle_6_4"));
Assert.IsNotNull(zone.transform.Find("Obstacle_12_4"));
Assert.IsNotNull(zone.transform.Find("Obstacle_10_6"));
Assert.IsNotNull(zone.transform.Find("Obstacle_10_7"));
// Empty cells must have colliders to block entry
Assert.Greater(emptyColliderCount, 50, "Empty cells must have blocking colliders");
Assert.IsNotNull(zone.transform.Find("EmptyCell_0_0"), "Corner (0,0) must be blocked");
Assert.IsNotNull(zone.transform.Find("EmptyCell_17_0"), "Corner (17,0) must be blocked");
Assert.IsNotNull(zone.transform.Find("EmptyCell_0_11"), "Corner (0,11) must be blocked");
Assert.IsNotNull(zone.transform.Find("EmptyCell_17_11"), "Corner (17,11) must be blocked");
}
[Test]
public void S1AExpanded_Geometry_UsesOneUnitCellsAndOneCellDoors()
{
ZoneInstance zone = CreateZoneInstanceWithPreset(
LevelPresetCatalog.S1AComparisonExpandedPresetId,
11,
Vector2.zero);
Assert.IsNotNull(zone);
Assert.AreEqual(36, zone.Preset.GridWidth);
Assert.AreEqual(24, zone.Preset.GridHeight);
int obstacleCount = 0;
for (int i = 0; i < zone.transform.childCount; i++)
{
Transform child = zone.transform.GetChild(i);
if (!child.name.StartsWith("Obstacle_"))
{
continue;
}
obstacleCount++;
BoxCollider2D collider = child.GetComponent<BoxCollider2D>();
Assert.IsNotNull(collider);
Assert.AreEqual(new Vector2(1f, 1f), collider.size);
}
Assert.AreEqual(8, obstacleCount);
foreach (StageDirection direction in System.Enum.GetValues(typeof(StageDirection)))
{
ZoneConnector connector = zone.GetConnector(direction);
Assert.IsNotNull(connector, $"Expanded S1-A must have {direction} connector");
BoxCollider2D trigger = connector.GetComponent<BoxCollider2D>();
Assert.AreEqual(new Vector2(1f, 1f), trigger.size,
$"Expanded S1-A {direction} door must remain one cell wide and one cell deep");
}
}
[Test]
public void S1A_PerimeterAndSockets_MatchPhysicalSocketCentersAndWidths()
{
ZoneInstance zone = CreateZoneInstanceWithS1A(10, Vector2.zero);
Assert.IsNotNull(zone);
// W1 Socket: West, cell (0, 4), width 1.
// Physical outer opening: x = -9.0f, y = 1.5f. Trigger size: (1.5f, 1.0f)
ZoneConnector w1Connector = zone.GetConnector("W1");
Assert.IsNotNull(w1Connector, "W1 connector must exist");
Assert.AreEqual(StageDirection.West, w1Connector.Direction);
Assert.AreEqual(-9.0f, w1Connector.transform.localPosition.x, 0.01f);
Assert.AreEqual(1.5f, w1Connector.transform.localPosition.y, 0.01f);
BoxCollider2D w1Trigger = w1Connector.GetComponent<BoxCollider2D>();
Assert.AreEqual(1.0f, w1Trigger.size.y, 0.01f);
// E1 Socket: East, cell (17, 4), width 1.
// Physical outer opening: x = 9.0f, y = 1.5f. Trigger size: (1.5f, 1.0f)
ZoneConnector e1Connector = zone.GetConnector("E1");
Assert.IsNotNull(e1Connector, "E1 connector must exist");
Assert.AreEqual(StageDirection.East, e1Connector.Direction);
Assert.AreEqual(9.0f, e1Connector.transform.localPosition.x, 0.01f);
Assert.AreEqual(1.5f, e1Connector.transform.localPosition.y, 0.01f);
BoxCollider2D e1Trigger = e1Connector.GetComponent<BoxCollider2D>();
Assert.AreEqual(1.0f, e1Trigger.size.y, 0.01f);
// N1 Socket: North, cell (7, 0), width 1.
// Physical outer opening: x = -1.5f, y = 6.0f. Trigger size: (1.0f, 1.5f)
ZoneConnector n1Connector = zone.GetConnector("N1");
Assert.IsNotNull(n1Connector, "N1 connector must exist");
Assert.AreEqual(StageDirection.North, n1Connector.Direction);
Assert.AreEqual(-1.5f, n1Connector.transform.localPosition.x, 0.01f);
Assert.AreEqual(6.0f, n1Connector.transform.localPosition.y, 0.01f);
BoxCollider2D n1Trigger = n1Connector.GetComponent<BoxCollider2D>();
Assert.AreEqual(1.0f, n1Trigger.size.x, 0.01f);
// S1 Socket: South, cell (7, 11), width 1.
// Physical outer opening: x = -1.5f, y = -6.0f. Trigger size: (1.0f, 1.5f)
ZoneConnector s1Connector = zone.GetConnector("S1");
Assert.IsNotNull(s1Connector, "S1 connector must exist");
Assert.AreEqual(StageDirection.South, s1Connector.Direction);
Assert.AreEqual(-1.5f, s1Connector.transform.localPosition.x, 0.01f);
Assert.AreEqual(-6.0f, s1Connector.transform.localPosition.y, 0.01f);
BoxCollider2D s1Trigger = s1Connector.GetComponent<BoxCollider2D>();
Assert.AreEqual(1.0f, s1Trigger.size.x, 0.01f);
}
[Test]
public void S3D_InactiveEastSocket_CorridorBeyondDeclaredCellsIsClosed()
{
ZoneInstance zone = CreateZoneInstanceWithPreset(
"GRAVE_S3_FINAL_NECROMANCER",
30,
Vector2.zero,
false);
RoomPreset preset = zone.Preset;
Assert.IsNotNull(preset);
Assert.AreEqual(PresetCellType.Empty, preset.GridMap[22, 7]);
Assert.AreEqual(PresetCellType.Empty, preset.GridMap[23, 7]);
for (int y = 7; y <= 9; y++)
{
Assert.AreEqual(PresetCellType.Empty, zone.GetRuntimeCell(21, y),
$"Inactive E1 declared cell (21,{y}) must be closed.");
Assert.AreEqual(PresetCellType.Empty, zone.GetRuntimeCell(22, y),
$"Inactive E1 outward cell (22,{y}) must be closed.");
Assert.AreEqual(PresetCellType.Empty, zone.GetRuntimeCell(23, y),
$"Inactive E1 outward cell (23,{y}) must be closed.");
}
}
[Test]
public void S1A_EncounterA_SpawnsExactBat3AndSkeleton2_WithSeparation()
{
ZoneInstance zone = CreateZoneInstanceWithS1A(10, Vector2.zero);
// Set player at W1 entry position
Vector2 entryPos = zone.GetEntryPosition(StageDirection.West);
player.transform.position = entryPos;
// Spawn encounter
spawnDirector.SpawnZoneEncounter(zone);
// Canonical S1-A Encounter A: Bat 3 + Skeleton 2 = 5 enemies total
Assert.AreEqual(5, zone.SpawnedEnemies.Count, "Canonical Encounter A must spawn exactly 5 enemies");
int batCount = 0;
int skeletonCount = 0;
foreach (var enemy in zone.SpawnedEnemies)
{
if (enemy.name.StartsWith("Bat")) batCount++;
if (enemy.name.StartsWith("Skeleton")) skeletonCount++;
}
Assert.AreEqual(3, batCount, "Canonical S1-A must have exactly 3 Bat enemies");
Assert.AreEqual(2, skeletonCount, "Must have exactly 2 Skeleton enemies");
// Verify no overlapping colliders (separation >= 0.8 units)
for (int i = 0; i < zone.SpawnedEnemies.Count; i++)
{
for (int j = i + 1; j < zone.SpawnedEnemies.Count; j++)
{
float dist = Vector2.Distance(zone.SpawnedEnemies[i].transform.position, zone.SpawnedEnemies[j].transform.position);
Assert.GreaterOrEqual(dist, 0.8f, $"Enemies {i} and {j} must not overlap (dist={dist})");
}
}
}
[Test]
public void S1AExpanded_EncounterA_DoublesCountsAndKeepsGroupsBounded()
{
ZoneInstance zone = CreateZoneInstanceWithPreset(
LevelPresetCatalog.S1AComparisonExpandedPresetId,
12,
Vector2.zero);
player.transform.position = zone.GetEntryPosition(StageDirection.West);
spawnDirector.SpawnZoneEncounter(zone);
Assert.AreEqual(10, zone.SpawnedEnemies.Count,
"Expanded Encounter A must spawn Bat 6 + Skeleton 4");
int batCount = 0;
int skeletonCount = 0;
Dictionary<int, int> groupCounts = new();
foreach (EnemyController enemy in zone.SpawnedEnemies)
{
if (enemy.name.StartsWith("Bat")) batCount++;
if (enemy.name.StartsWith("Skeleton")) skeletonCount++;
if (!groupCounts.ContainsKey(enemy.EncounterGroupId))
{
groupCounts[enemy.EncounterGroupId] = 0;
}
groupCounts[enemy.EncounterGroupId]++;
}
Assert.AreEqual(6, batCount);
Assert.AreEqual(4, skeletonCount);
foreach (int groupCount in groupCounts.Values)
{
Assert.That(groupCount, Is.InRange(1, 3),
"Expanded encounters must retain the existing 1-3 member group rule");
}
}
[Test]
public void S1A_EntrySafeDistance_GuaranteedFromWestEntrySocket()
{
ZoneInstance zone = CreateZoneInstanceWithS1A(10, Vector2.zero);
// Position player at W1 entry position (inward offset 2.5f from socket)
Vector2 playerEntryPos = zone.GetEntryPosition(StageDirection.West);
player.transform.position = playerEntryPos;
spawnDirector.SpawnZoneEncounter(zone);
// Verify safe distance for each enemy
foreach (var enemy in zone.SpawnedEnemies)
{
float dist = Vector2.Distance(playerEntryPos, enemy.transform.position);
if (enemy.name.StartsWith("Bat"))
{
// Fast / Crowd enemy requires >= 5.0 units
Assert.GreaterOrEqual(dist, 5.0f - 0.01f, $"Bat at {enemy.transform.position} must be >= 5.0 units from player {playerEntryPos} (dist={dist})");
}
else if (enemy.name.StartsWith("Skeleton"))
{
// Normal melee enemy requires >= 4.0 units
Assert.GreaterOrEqual(dist, 4.0f - 0.01f, $"Skeleton at {enemy.transform.position} must be >= 4.0 units from player {playerEntryPos} (dist={dist})");
}
}
}
[Test]
public void S1A_Breakthrough_AllowsSocketCrossingWithAliveEnemies_AndPreservesEnemies()
{
stageManager.StartRun(9999);
// Create S1-A combat zone and adjacent exit zone
ZoneInstance combatZone = CreateZoneInstanceWithS1A(100, Vector2.zero);
ZoneInstance nextZone = CreateZoneInstanceWithS1A(101, new Vector2(36f, 0f));
// Register zones in stageManager's zoneMap via reflection
var zoneMapField = typeof(StageManager).GetField("zoneMap",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
var zoneMap = (Dictionary<int, ZoneInstance>)zoneMapField.GetValue(stageManager);
zoneMap[100] = combatZone;
zoneMap[101] = nextZone;
// Set currentZone to combatZone
var currentZoneField = typeof(StageManager).GetField("currentZone",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
currentZoneField.SetValue(stageManager, combatZone);
// Activate combat zone
combatZone.ActivateZone();
spawnDirector.SpawnZoneEncounter(combatZone);
// Canonical S1-A Encounter A has 5 enemies alive
Assert.AreEqual(5, combatZone.SpawnedEnemies.Count);
Assert.IsFalse(combatZone.IsCleared, "Combat zone is not cleared yet");
// Verify normal combat zone exit socket is NOT locked (Breakthrough allowed)
ZoneConnector e1Connector = combatZone.GetConnector("E1");
Assert.IsNotNull(e1Connector);
Assert.IsFalse(e1Connector.IsLocked, "Normal combat room exit must not be locked when enemies are alive");
// Player requests transition through E1 with enemies alive
stageManager.RequestZoneTransition(101, StageDirection.West);
// Current zone switched to nextZone
Assert.AreEqual(101, stageManager.CurrentZone.NodeId);
// Previous combat zone enemies must be preserved (still 5 alive, but deactivated)
Assert.AreEqual(5, combatZone.SpawnedEnemies.Count);
foreach (var enemy in combatZone.SpawnedEnemies)
{
Assert.IsNotNull(enemy);
Assert.IsFalse(enemy.gameObject.activeSelf, "Previous zone enemies must be deactivated while outside");
}
Assert.IsFalse(combatZone.IsCleared, "Bypassed room remains uncleared");
// Return to combat zone
stageManager.RequestZoneTransition(100, StageDirection.East);
Assert.AreEqual(100, stageManager.CurrentZone.NodeId);
// Enemies reactivated upon re-entry
foreach (var enemy in combatZone.SpawnedEnemies)
{
Assert.IsTrue(enemy.gameObject.activeSelf, "Enemies must be reactivated upon re-entering room");
}
}
[Test]
public void S1A_Visuals_LinesUsedForBoundariesAndObstacles_NoStretchedVisuals()
{
ZoneInstance zone = CreateZoneInstanceWithS1A(20, Vector2.zero);
Assert.IsNotNull(zone);
// Verify perimeter walls use BoundaryLine with line thickness (not stretched GraveSlab)
bool foundBoundaryLine = false;
for (int i = 0; i < zone.transform.childCount; i++)
{
Transform child = zone.transform.GetChild(i);
if (child.name.StartsWith("Wall_"))
{
Transform boundaryLine = child.Find("BoundaryLine");
if (boundaryLine != null)
{
foundBoundaryLine = true;
// Either x or y must be thin line (0.08f)
Assert.IsTrue(Mathf.Approximately(boundaryLine.localScale.x, 0.08f) || Mathf.Approximately(boundaryLine.localScale.y, 0.08f),
"Boundary line must have 0.08f thickness");
}
}
}
Assert.IsTrue(foundBoundaryLine, "At least one wall must contain BoundaryLine");
// Verify obstacles use line segments (Fill / Line_N / Line_S / etc.) instead of stretched GraveSlab
bool foundObstacleLines = false;
for (int i = 0; i < zone.transform.childCount; i++)
{
Transform child = zone.transform.GetChild(i);
if (child.name.StartsWith("Obstacle_"))
{
Transform fill = child.Find("Fill");
Assert.IsNotNull(fill, "Obstacle must have semi-transparent fill");
foundObstacleLines = true;
}
}
Assert.IsTrue(foundObstacleLines, "Obstacles must be rendered with Fill and outline lines");
}
[Test]
public void S1A_Encounter_PrioritizesMeleeAnchors_BeforeCrowd()
{
ZoneInstance zone = CreateZoneInstanceWithS1A(21, Vector2.zero);
spawnDirector.SpawnZoneEncounter(zone);
Assert.AreEqual(5, zone.SpawnedEnemies.Count);
// Skeletons (Melee) should occupy authored NormalMelee anchors
RoomPreset preset = zone.Preset;
Vector2 m01Pos = preset.CellToWorldPosition(zone.ZoneCenter, new Vector2Int(4, 3));
Vector2 m02Pos = preset.CellToWorldPosition(zone.ZoneCenter, new Vector2Int(10, 2));
bool skeletonAtM01 = false;
bool skeletonAtM02 = false;
foreach (var enemy in zone.SpawnedEnemies)
{
if (enemy.name.StartsWith("Skeleton"))
{
if (Vector2.Distance(enemy.transform.position, m01Pos) < 0.1f) skeletonAtM01 = true;
if (Vector2.Distance(enemy.transform.position, m02Pos) < 0.1f) skeletonAtM02 = true;
}
}
Assert.IsTrue(skeletonAtM01 || skeletonAtM02, "At least one Skeleton must occupy a priority NormalMelee anchor (M01 or M02)");
}
}
}