convert game playing..

This commit is contained in:
2026-09-23 12:53:52 +09:00
parent 37c7942b71
commit 5d11288a6f
136 changed files with 15856 additions and 299 deletions
@@ -2310,8 +2310,8 @@ namespace BumpCombat.Tests
[Test]
public void PrototypeEnemies_UseRequestedCrowdAndNormalCatalog()
{
string[] crowdNames = { "Bat", "Slime" };
foreach (string crowdName in crowdNames)
string[] promotedCrowdNames = { "Bat", "Slime" };
foreach (string crowdName in promotedCrowdNames)
{
EnemyDefinition definition =
AssetDatabase.LoadAssetAtPath<EnemyDefinition>(
@@ -2321,11 +2321,11 @@ namespace BumpCombat.Tests
Assert.That(definition, Is.Not.Null, crowdName);
Assert.That(prefab, Is.Not.Null, crowdName);
Assert.That(definition.Role, Is.EqualTo(EnemyRole.Crowd), crowdName);
Assert.That(definition.Role, Is.EqualTo(EnemyRole.Normal), crowdName);
Assert.That(definition.MaxHealth, Is.GreaterThan(0f), crowdName);
Assert.That(prefab.transform.localScale.x, Is.EqualTo(1f), crowdName);
Assert.That(prefab.transform.localScale.y, Is.EqualTo(1f), crowdName);
Assert.That(prefab.GetComponent<Collider2D>().isTrigger, Is.True, crowdName);
Assert.That(prefab.GetComponent<Collider2D>().isTrigger, Is.False, crowdName);
}
string[] normalNames = { "Skeleton", "Necrofire", "SkeletonArcher" };
@@ -3,6 +3,7 @@ using System.Reflection;
using BumpCombat.Combat;
using BumpCombat.Enemies;
using BumpCombat.Player;
using BumpCombat.Spawning;
using NUnit.Framework;
using UnityEngine;
@@ -15,6 +16,29 @@ namespace BumpCombat.Tests
private GameObject owner;
[TestCase(1, 0.00f, 1)]
[TestCase(1, 0.54f, 1)]
[TestCase(1, 0.55f, 2)]
[TestCase(1, 0.89f, 2)]
[TestCase(1, 0.90f, 3)]
[TestCase(2, 0.34f, 1)]
[TestCase(2, 0.35f, 2)]
[TestCase(2, 0.79f, 2)]
[TestCase(2, 0.80f, 3)]
[TestCase(3, 0.19f, 1)]
[TestCase(3, 0.20f, 2)]
[TestCase(3, 0.69f, 2)]
[TestCase(3, 0.70f, 3)]
public void EncounterGroupSize_UsesStageWeights(
int stageIndex,
float roll,
int expected)
{
Assert.That(
SpawnDirector.SelectEncounterGroupSize(stageIndex, roll),
Is.EqualTo(expected));
}
[SetUp]
public void SetUp()
{
@@ -0,0 +1,86 @@
using BumpCombat.Constants;
using BumpCombat.Defense;
using NUnit.Framework;
namespace BumpCombat.Tests
{
public sealed class DefensePrototypeRulesTests
{
[TestCase(6f, 1f)]
[TestCase(5.999f, 1f)]
[TestCase(6.001f, 0.7f)]
public void TowerEfficiency_UsesSightBoundaryWithoutAttackRatePenalty(
float distance,
float expectedEfficiency)
{
Assert.That(
DefensePrototypeRules.GetTowerEfficiency(distance, 6f, 1f, 0.7f),
Is.EqualTo(expectedEfficiency).Within(0.0001f));
}
[Test]
public void TowerImmunity_OnlyBlocksTheMatchingSourceKind()
{
Assert.That(
DefensePrototypeRules.IsTowerImmune(
DefenseTowerKind.Direct,
DefenseTowerKind.Direct),
Is.True);
Assert.That(
DefensePrototypeRules.IsTowerImmune(
DefenseTowerKind.Summon,
DefenseTowerKind.Direct),
Is.False);
}
[Test]
public void Arrival_ResolvesOnceWithoutCountingAsAKillEvent()
{
DefenseWaveLedger ledger = new(1);
Assert.That(ledger.RegisterSupply(0), Is.True);
ledger.MarkSupplyEnded();
Assert.That(
ledger.TryResolve(0, DefenseEnemyResolution.Arrival),
Is.True);
Assert.That(
ledger.TryResolve(0, DefenseEnemyResolution.Arrival),
Is.False);
Assert.That(ledger.ResolvedCount, Is.EqualTo(1));
Assert.That(ledger.KillEventCount, Is.Zero);
}
[Test]
public void Reward_IsAvailableOnceAfterSupplyAndAllOwnedEnemiesResolve()
{
DefenseWaveLedger ledger = new(2);
ledger.RegisterSupply(0);
ledger.RegisterSupply(1);
Assert.That(ledger.TryOfferReward(), Is.False);
ledger.MarkSupplyEnded();
ledger.TryResolve(0, DefenseEnemyResolution.Death);
Assert.That(ledger.TryOfferReward(), Is.False);
ledger.TryResolve(1, DefenseEnemyResolution.Arrival);
Assert.That(ledger.TryOfferReward(), Is.True);
Assert.That(ledger.TryOfferReward(), Is.False);
Assert.That(ledger.TryCollectReward(), Is.True);
Assert.That(ledger.TryCollectReward(), Is.False);
}
[Test]
public void PlayerDeathStopsOperationsAndZeroBaseRemainsTerminal()
{
Assert.That(
DefensePrototypeRules.CanOperateTowers(playerAlive: false, runActive: true),
Is.False);
Assert.That(
DefensePrototypeRules.CanOperateTowers(playerAlive: true, runActive: true),
Is.True);
Assert.That(DefensePrototypeRules.IsBaseDestroyed(0f), Is.True);
Assert.That(DefensePrototypeRules.IsBaseDestroyed(0.01f), Is.False);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 2c7d5a1ee7d04f3d9c8060b6b445e0aa
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,384 @@
using System.Reflection;
using BumpCombat.Combat;
using BumpCombat.Constants;
using BumpCombat.Core;
using BumpCombat.Enemies;
using BumpCombat.Player;
using BumpCombat.Stage;
using NUnit.Framework;
using UnityEditor;
using UnityEngine;
using System.Collections.Generic;
namespace BumpCombat.Tests
{
public sealed class EnemySightAndPathfindingTests
{
private GameObject playerObject;
private PlayerController player;
private GameObject enemyObject;
private EnemyController enemy;
private GameObject obstacleObject;
private StageObstacle obstacle;
private readonly List<GameObject> additionalEnemyObjects = new();
[TearDown]
public void TearDown()
{
if (playerObject != null) Object.DestroyImmediate(playerObject);
if (enemyObject != null) Object.DestroyImmediate(enemyObject);
for (int i = 0; i < additionalEnemyObjects.Count; i++)
{
if (additionalEnemyObjects[i] != null)
{
Object.DestroyImmediate(additionalEnemyObjects[i]);
}
}
additionalEnemyObjects.Clear();
if (obstacleObject != null) Object.DestroyImmediate(obstacleObject);
}
[Test]
public void SightRange_OutOfRange_StaysIdle()
{
playerObject = new GameObject("Player");
playerObject.transform.position = new Vector3(15f, 0f, 0f);
player = playerObject.AddComponent<PlayerController>();
enemyObject = CreateEnemyObject("Skeleton", EnemyKind.Skeleton, isFlying: false);
enemyObject.transform.position = Vector3.zero;
enemy = enemyObject.GetComponent<EnemyController>();
// Trigger detection check (simulate spawn transition)
bool detected = enemy.CheckPlayerDetection();
Assert.That(detected, Is.False, "Player at 15m should be outside default 8m sight range");
Assert.That(enemy.HasDetectedPlayer, Is.False);
}
[Test]
public void SightRange_WithinRange_DirectLOS_DetectsPlayer()
{
playerObject = new GameObject("Player");
playerObject.transform.position = new Vector3(5f, 0f, 0f);
player = playerObject.AddComponent<PlayerController>();
enemyObject = CreateEnemyObject("Skeleton", EnemyKind.Skeleton, isFlying: false);
enemyObject.transform.position = Vector3.zero;
enemy = enemyObject.GetComponent<EnemyController>();
Assert.That(enemy.SightRange, Is.EqualTo(8f).Within(0.001f));
bool detected = enemy.CheckPlayerDetection();
Assert.That(detected, Is.True, "Player at 5m with no obstacles should be detected");
Assert.That(enemy.HasDetectedPlayer, Is.True);
}
[Test]
public void LineOfSight_BlockedByObstacle_GroundEnemyDoesNotDetect()
{
playerObject = new GameObject("Player");
playerObject.transform.position = new Vector3(6f, 0f, 0f);
player = playerObject.AddComponent<PlayerController>();
// Place an obstacle in between at x = 3
obstacleObject = new GameObject("Obstacle_Pillar");
obstacleObject.transform.position = new Vector3(3f, 0f, 0f);
BoxCollider2D obsCol = obstacleObject.AddComponent<BoxCollider2D>();
obsCol.size = new Vector2(2f, 2f);
obstacle = obstacleObject.AddComponent<StageObstacle>();
enemyObject = CreateEnemyObject("Skeleton", EnemyKind.Skeleton, isFlying: false);
enemyObject.transform.position = Vector3.zero;
enemy = enemyObject.GetComponent<EnemyController>();
bool detected = enemy.CheckPlayerDetection();
Assert.That(detected, Is.False, "Ground enemy should be blocked by StageObstacle");
Assert.That(enemy.HasDetectedPlayer, Is.False);
}
[Test]
public void LineOfSight_LossKeepsExistingDetectionMemory()
{
playerObject = new GameObject("Player");
playerObject.transform.position = new Vector3(3.5f, 0f, 0f);
player = playerObject.AddComponent<PlayerController>();
enemyObject = CreateEnemyObject("Skeleton", EnemyKind.Skeleton, isFlying: false);
enemyObject.transform.position = Vector3.zero;
enemy = enemyObject.GetComponent<EnemyController>();
Assert.That(enemy.CheckPlayerDetection(), Is.True);
enemy.Alert();
obstacleObject = new GameObject("Obstacle_AfterDetection");
obstacleObject.transform.position = new Vector3(1.75f, 0f, 0f);
BoxCollider2D obstacleCollider = obstacleObject.AddComponent<BoxCollider2D>();
obstacleCollider.size = new Vector2(0.5f, 2f);
obstacle = obstacleObject.AddComponent<StageObstacle>();
Physics2D.SyncTransforms();
MethodInfo refreshAwareness = typeof(EnemyController).GetMethod(
"RefreshChaseAwareness",
BindingFlags.NonPublic | BindingFlags.Instance);
bool remainsInMemory = (bool)refreshAwareness.Invoke(enemy, null);
Assert.That(remainsInMemory, Is.True, "A lost LOS should retain the existing short memory window");
Assert.That(enemy.HasDetectedPlayer, Is.True);
}
[Test]
public void LineOfSight_BlockedByObstacle_FlyingEnemyDetects()
{
playerObject = new GameObject("Player");
playerObject.transform.position = new Vector3(6f, 0f, 0f);
player = playerObject.AddComponent<PlayerController>();
obstacleObject = new GameObject("Obstacle_Pillar");
obstacleObject.transform.position = new Vector3(3f, 0f, 0f);
BoxCollider2D obsCol = obstacleObject.AddComponent<BoxCollider2D>();
obsCol.size = new Vector2(2f, 2f);
obstacle = obstacleObject.AddComponent<StageObstacle>();
enemyObject = CreateEnemyObject("Bat", EnemyKind.Bat, isFlying: true);
enemyObject.transform.position = Vector3.zero;
enemy = enemyObject.GetComponent<EnemyController>();
bool detected = enemy.CheckPlayerDetection();
Assert.That(detected, Is.True, "Flying enemy should see over StageObstacle");
Assert.That(enemy.HasDetectedPlayer, Is.True);
}
[Test]
public void ProximityAlert_IgnoresObstacle_WithinProximityRange()
{
playerObject = new GameObject("Player");
playerObject.transform.position = new Vector3(1.5f, 0f, 0f);
player = playerObject.AddComponent<PlayerController>();
// Small obstacle between at x = 0.75
obstacleObject = new GameObject("Obstacle_Thin");
obstacleObject.transform.position = new Vector3(0.75f, 0f, 0f);
BoxCollider2D obsCol = obstacleObject.AddComponent<BoxCollider2D>();
obsCol.size = new Vector2(0.5f, 2f);
obstacle = obstacleObject.AddComponent<StageObstacle>();
enemyObject = CreateEnemyObject("Skeleton", EnemyKind.Skeleton, isFlying: false);
enemyObject.transform.position = Vector3.zero;
enemy = enemyObject.GetComponent<EnemyController>();
bool detected = enemy.CheckPlayerDetection();
Assert.That(detected, Is.True, "Player within 2.0m proximity range should be detected even behind obstacle");
Assert.That(enemy.HasDetectedPlayer, Is.True);
}
[Test]
public void DamageAlert_TriggersChase_Immediately()
{
playerObject = new GameObject("Player");
playerObject.transform.position = new Vector3(20f, 0f, 0f);
player = playerObject.AddComponent<PlayerController>();
enemyObject = CreateEnemyObject("Skeleton", EnemyKind.Skeleton, isFlying: false);
enemyObject.transform.position = Vector3.zero;
enemy = enemyObject.GetComponent<EnemyController>();
Assert.That(enemy.HasDetectedPlayer, Is.False);
// Taking damage triggers Alert
enemy.TryTakeDamage(1f, Vector2.left, 0f);
Assert.That(enemy.HasDetectedPlayer, Is.True, "Taking damage must trigger immediate detection");
}
[Test]
public void LimitedViewSight_AlertsOnlyTheNearbyEncounterGroup()
{
playerObject = new GameObject("Player");
playerObject.transform.position = Vector3.zero;
player = playerObject.AddComponent<PlayerController>();
enemyObject = CreateEnemyObject("NearLeader", EnemyKind.Skeleton, isFlying: false);
enemyObject.transform.position = new Vector3(3.5f, 0f, 0f);
enemy = enemyObject.GetComponent<EnemyController>();
float stageSightRangeMultiplier = GameplayConstants.Current.Run.StageEncounterSightRangeMultiplier;
Assert.That(stageSightRangeMultiplier, Is.EqualTo(0.5f).Within(0.001f));
enemy.ConfigureSightRangeMultiplier(stageSightRangeMultiplier);
enemy.ConfigureEncounterGroup(101, enemyObject.transform.position);
GameObject nearMemberObject = CreateEnemyObject("NearMember", EnemyKind.Skeleton, isFlying: false);
nearMemberObject.transform.position = new Vector3(3.8f, 0.4f, 0f);
additionalEnemyObjects.Add(nearMemberObject);
EnemyController nearMember = nearMemberObject.GetComponent<EnemyController>();
nearMember.ConfigureSightRangeMultiplier(stageSightRangeMultiplier);
nearMember.ConfigureEncounterGroup(101, enemyObject.transform.position);
GameObject farLeaderObject = CreateEnemyObject("FarLeader", EnemyKind.Skeleton, isFlying: false);
farLeaderObject.transform.position = new Vector3(5f, 0f, 0f);
additionalEnemyObjects.Add(farLeaderObject);
EnemyController farLeader = farLeaderObject.GetComponent<EnemyController>();
farLeader.ConfigureSightRangeMultiplier(stageSightRangeMultiplier);
farLeader.ConfigureEncounterGroup(202, farLeaderObject.transform.position);
Assert.That(enemy.SightRange, Is.EqualTo(4f).Within(0.001f), "Stage group members should use 8m * 0.5 sight range");
Assert.That(nearMember.SightRange, Is.EqualTo(4f).Within(0.001f));
Assert.That(farLeader.SightRange, Is.EqualTo(4f).Within(0.001f));
enemyObject.SetActive(false);
enemyObject.SetActive(true);
Assert.That(enemy.SightRange, Is.EqualTo(4f).Within(0.001f), "Re-enabling must not reset the model multiplier");
Assert.That(enemy.CheckPlayerDetection(), Is.True);
Assert.That(nearMember.HasDetectedPlayer, Is.True, "Detection should alert only the same encounter group");
Assert.That(farLeader.CheckPlayerDetection(), Is.False, "A group outside the stage sight range should remain unpulled");
Assert.That(farLeader.HasDetectedPlayer, Is.False);
playerObject.transform.position = new Vector3(4.5f, 0f, 0f);
Assert.That(farLeader.CheckPlayerDetection(), Is.True, "The far group should detect once the player enters its sight range");
Assert.That(farLeader.HasDetectedPlayer, Is.True);
}
[Test]
public void RoomPathfinder_FindsWaypointAroundObstacle()
{
RoomPreset preset = new RoomPreset
{
GridWidth = 5,
GridHeight = 5,
GridMap = new PresetCellType[5, 5],
};
// Fill all with Walkable
for (int x = 0; x < 5; x++)
{
for (int y = 0; y < 5; y++)
{
preset.GridMap[x, y] = PresetCellType.Walkable;
}
}
// Place an obstacle in the middle cell (2, 2)
preset.GridMap[2, 2] = PresetCellType.Obstacle;
GameObject zoneObj = new GameObject("Zone_PathfindTest");
ZoneInstance zone = zoneObj.AddComponent<ZoneInstance>();
FieldInfo presetField = typeof(ZoneInstance).GetField("preset", BindingFlags.NonPublic | BindingFlags.Instance);
presetField?.SetValue(zone, preset);
// Start at (1, 2), Target at (3, 2) with obstacle at (2, 2)
Vector2 startPos = preset.CellToWorldPosition(zone.ZoneCenter, new Vector2Int(1, 2));
Vector2 targetPos = preset.CellToWorldPosition(zone.ZoneCenter, new Vector2Int(3, 2));
// Also place a GameObject obstacle with StageObstacle so physics Linecast sees it
obstacleObject = new GameObject("TestObstacleObj");
obstacleObject.transform.position = preset.CellToWorldPosition(zone.ZoneCenter, new Vector2Int(2, 2));
BoxCollider2D obsCol = obstacleObject.AddComponent<BoxCollider2D>();
obsCol.size = new Vector2(1f, 1f);
obstacle = obstacleObject.AddComponent<StageObstacle>();
bool found = RoomPathfinder.TryGetNextWaypoint(zone, startPos, targetPos, isFlying: false, out Vector2 waypoint);
Assert.That(found, Is.True);
// Waypoint should NOT be (2, 2) which is the obstacle!
Vector2Int waypointCell = RoomPathfinder.WorldToCell(preset, zone.ZoneCenter, waypoint);
Assert.That(waypointCell, Is.Not.EqualTo(new Vector2Int(2, 2)), "Waypoint must avoid the obstacle cell");
Assert.That(RoomPathfinder.IsWalkable(preset, waypointCell.x, waypointCell.y), Is.True, "Waypoint must be on a walkable cell");
Object.DestroyImmediate(zoneObj);
}
[Test]
public void RoomPathfinder_RejectsUnreachableOrOutsideRoamTargets()
{
RoomPreset preset = new RoomPreset
{
GridWidth = 5,
GridHeight = 5,
GridMap = new PresetCellType[5, 5],
};
for (int x = 0; x < 5; x++)
{
for (int y = 0; y < 5; y++)
{
preset.GridMap[x, y] = PresetCellType.Walkable;
}
}
for (int y = 0; y < 5; y++)
{
preset.GridMap[2, y] = PresetCellType.Empty;
}
GameObject zoneObj = new GameObject("Zone_RoamPathTest");
ZoneInstance zone = zoneObj.AddComponent<ZoneInstance>();
FieldInfo presetField = typeof(ZoneInstance).GetField(
"preset",
BindingFlags.NonPublic | BindingFlags.Instance);
presetField?.SetValue(zone, preset);
Vector2 startPos = preset.CellToWorldPosition(
zone.ZoneCenter,
new Vector2Int(1, 2));
Vector2 blockedTarget = preset.CellToWorldPosition(
zone.ZoneCenter,
new Vector2Int(3, 2));
Assert.That(
RoomPathfinder.TryGetReachableRoamPoint(
zone,
startPos,
blockedTarget,
isFlying: false,
out _),
Is.False,
"Roaming must not keep a target across disconnected terrain");
Assert.That(
RoomPathfinder.TryGetReachableRoamPoint(
zone,
startPos,
new Vector2(100f, 100f),
isFlying: false,
out _),
Is.False,
"Roaming must reject targets outside the current zone grid");
Object.DestroyImmediate(zoneObj);
}
private GameObject CreateEnemyObject(string name, EnemyKind kind, bool isFlying)
{
GameObject obj = new GameObject(name);
obj.AddComponent<Rigidbody2D>();
CircleCollider2D col = obj.AddComponent<CircleCollider2D>();
EnemyDefinition def = ScriptableObject.CreateInstance<EnemyDefinition>();
def.Configure(kind, EnemyAttackShape.Box, 10f, 1f, 1f, 0.5f, 0.5f, 0.5f, 1f, 0f, 1f, 1f, 1f, 1);
def.ConfigureFlying(isFlying);
def.ConfigureSightRange(8f);
EnemyController controller = obj.AddComponent<EnemyController>();
FieldInfo defField = typeof(EnemyController).GetField("definition", BindingFlags.NonPublic | BindingFlags.Instance);
defField?.SetValue(controller, def);
MethodInfo configureModel = typeof(EnemyController).GetMethod("ConfigureEnemyModel", BindingFlags.NonPublic | BindingFlags.Instance);
configureModel?.Invoke(controller, null);
FieldInfo healthField = typeof(EnemyController).GetField("currentHealth", BindingFlags.NonPublic | BindingFlags.Instance);
if (healthField != null)
{
healthField.SetValue(controller, 10f);
}
else
{
PropertyInfo healthProp = typeof(EnemyController).GetProperty("CurrentHealth", BindingFlags.Public | BindingFlags.Instance);
healthProp?.GetSetMethod(true)?.Invoke(controller, new object[] { 10f });
}
return obj;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: b7c0656db76e6c64cb0b44a9176e7b66
@@ -263,6 +263,25 @@ namespace BumpCombat.Tests
?.SetValue(target, value);
}
[Test]
public void SetZoneBounds_UpdatesClampCenterAndExtents()
{
GameObject root = CreateBounds();
ArenaBounds bounds = root.GetComponent<ArenaBounds>();
Vector2 newCenter = new Vector2(40f, 30f);
bounds.SetZoneBounds(newCenter);
Vector2 clamped = bounds.ClampPlayerPosition(new Vector2(99f, 99f));
Assert.That(clamped.x, Is.EqualTo(newCenter.x + 14.5f).Within(0.0001f));
Assert.That(clamped.y, Is.EqualTo(newCenter.y + 7.8125f).Within(0.0001f));
bounds.ResetToDefaultBounds();
Vector2 resetClamped = bounds.ClampPlayerPosition(new Vector2(99f, 99f));
Assert.That(resetClamped.x, Is.EqualTo(14.5f).Within(0.0001f));
Assert.That(resetClamped.y, Is.EqualTo(7.8125f).Within(0.0001f));
}
private static GameObject CreateBounds()
{
GameObject root = new("Arena Bounds Test");
@@ -91,6 +91,7 @@ namespace BumpCombat.Tests
GameObject owner = new("Gameplay Constants Player Consumer Test");
try
{
Assert.That(originalSpeed, Is.EqualTo(3.75f).Within(0.0001f));
PlayerStats stats = owner.AddComponent<PlayerStats>();
player.BaseMoveSpeed = 6.25f;
combat.PositionDamageMultiplier = 1.7f;
@@ -0,0 +1,488 @@
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)");
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8d6c9040229520d4b9c14672939fb3b2
@@ -0,0 +1,700 @@
using System.Collections.Generic;
using BumpCombat.Core;
using BumpCombat.Stage;
using NUnit.Framework;
using UnityEngine;
namespace BumpCombat.Tests
{
public sealed class LevelPresetTests
{
[Test]
public void LevelPresetCatalog_ContainsAll12Presets()
{
LevelPresetCatalog.EnsureInitialized();
var all = LevelPresetCatalog.AllPresets;
string[] expectedPresets = new string[]
{
"GRAVE_S1_COMBAT_A",
"GRAVE_S1_COMBAT_B",
"GRAVE_S1_FORK",
"GRAVE_S1_ELITE_ARENA",
"GRAVE_S2_COMBAT_A",
"GRAVE_S2_COMBAT_B",
"GRAVE_S2_FORK_HAZARD",
"GRAVE_S2_MIDBOSS",
"GRAVE_S3_COMBAT_A",
"GRAVE_S3_COMBAT_B",
"GRAVE_S3_PREBOSS_FORK",
"GRAVE_S3_FINAL_NECROMANCER",
};
foreach (string presetId in expectedPresets)
{
RoomPreset preset = LevelPresetCatalog.GetPreset(presetId);
Assert.IsNotNull(preset, $"Preset {presetId} should be registered in LevelPresetCatalog.");
Assert.AreEqual(presetId, preset.PresetId);
}
}
[Test]
public void LevelPresets_MatchSpecificationDimensions()
{
// Stage 1: 18x12 (Elite 20x14)
AssertDimensions("GRAVE_S1_COMBAT_A", 18, 12);
AssertDimensions("GRAVE_S1_COMBAT_B", 18, 12);
AssertDimensions("GRAVE_S1_FORK", 18, 12);
AssertDimensions("GRAVE_S1_ELITE_ARENA", 20, 14);
// Stage 2: 20x14 (MidBoss 22x16)
AssertDimensions("GRAVE_S2_COMBAT_A", 20, 14);
AssertDimensions("GRAVE_S2_COMBAT_B", 20, 14);
AssertDimensions("GRAVE_S2_FORK_HAZARD", 20, 14);
AssertDimensions("GRAVE_S2_MIDBOSS", 22, 16);
// Stage 3: 22x16 (PreBoss 20x14, FinalBoss 24x18)
AssertDimensions("GRAVE_S3_COMBAT_A", 22, 16);
AssertDimensions("GRAVE_S3_COMBAT_B", 22, 16);
AssertDimensions("GRAVE_S3_PREBOSS_FORK", 20, 14);
AssertDimensions("GRAVE_S3_FINAL_NECROMANCER", 24, 18);
}
[Test]
public void S1AExpandedComparison_UsesActualGridAndPreservesCombatContract()
{
RoomPreset original = LevelPresetCatalog.GetPreset("GRAVE_S1_COMBAT_A");
RoomPreset expanded = LevelPresetCatalog.GetPreset(
LevelPresetCatalog.S1AComparisonExpandedPresetId);
Assert.That(original, Is.Not.Null);
Assert.That(expanded, Is.Not.Null);
Assert.That(original.GridWidth, Is.EqualTo(18));
Assert.That(original.GridHeight, Is.EqualTo(12));
Assert.That(expanded.GridWidth, Is.EqualTo(36));
Assert.That(expanded.GridHeight, Is.EqualTo(24));
List<Vector2Int> expectedObstacles = new()
{
new(10, 7), new(10, 8), new(11, 8),
new(24, 7),
new(20, 14), new(21, 14),
new(13, 16), new(14, 16),
};
List<Vector2Int> actualObstacles = new();
for (int y = 0; y < expanded.GridHeight; y++)
{
for (int x = 0; x < expanded.GridWidth; x++)
{
if (expanded.GridMap[x, y] == PresetCellType.Obstacle)
{
actualObstacles.Add(new Vector2Int(x, y));
}
}
}
Assert.That(actualObstacles, Is.EquivalentTo(expectedObstacles));
Assert.That(actualObstacles.Count, Is.EqualTo(8));
Assert.That(expanded.SpawnPoints.Count, Is.EqualTo(original.SpawnPoints.Count));
Assert.That(expanded.PlayerAnchor, Is.EqualTo(new Vector2Int(6, 11)));
foreach (RoomPresetSocket socket in expanded.Sockets)
{
Assert.That(socket.Width, Is.EqualTo(1), $"{socket.Id} must remain a narrow one-cell door.");
}
Assert.That(HasCellPath(expanded, new Vector2Int(2, 11), new Vector2Int(33, 11)), Is.True,
"Expanded S1-A must preserve a clear central approach.");
Assert.That(HasCellPath(expanded, new Vector2Int(2, 11), new Vector2Int(8, 6)), Is.True,
"Expanded S1-A must preserve a reachable left-flank approach.");
Assert.That(HasCellPath(expanded, new Vector2Int(33, 11), new Vector2Int(27, 17)), Is.True,
"Expanded S1-A must preserve a reachable right-flank approach.");
AssertExpandedEncounterPoolsMatch(original, expanded);
}
[Test]
public void StageFog_ExplorationPersistsOnRevisit_AndResetsForNewZone()
{
RoomPreset preset = LevelPresetCatalog.GetPreset(
LevelPresetCatalog.S1AComparisonExpandedPresetId);
StageNode node = new()
{
Id = 701,
NodeType = StageNodeType.Combat,
PresetId = preset.PresetId,
};
node.SetConnection(StageDirection.North, 1);
node.SetConnection(StageDirection.South, 2);
node.SetConnection(StageDirection.West, 3);
node.SetConnection(StageDirection.East, 4);
GameObject firstObject = new("FogZone_First");
GameObject secondObject = new("FogZone_Second");
GameObject partialObject = new("FogZone_PartialSockets");
try
{
ZoneInstance firstZone = firstObject.AddComponent<ZoneInstance>();
firstZone.Initialize(node, preset.PlayableHalfExtents);
Vector2 start = preset.CellToWorldPosition(Vector2.zero, new Vector2Int(6, 11));
Vector2 far = preset.CellToWorldPosition(Vector2.zero, new Vector2Int(30, 17));
Vector2 occlusionOrigin = preset.CellToWorldPosition(
Vector2.zero,
new Vector2Int(8, 11));
firstZone.UpdateVisibility(occlusionOrigin);
Assert.That(firstZone.IsCellVisible(12, 7), Is.False,
"A cover cell must block diagonal terrain LOS behind it.");
firstZone.UpdateVisibility(start);
Assert.That(firstZone.IsCellVisible(6, 11), Is.True);
Assert.That(firstZone.IsCellExplored(30, 17), Is.False);
firstZone.UpdateVisibility(far);
Assert.That(firstZone.IsCellVisible(30, 17), Is.True);
Assert.That(firstZone.IsCellExplored(6, 11), Is.True);
firstZone.UpdateVisibility(start);
Assert.That(firstZone.IsCellExplored(30, 17), Is.True,
"A revisited room must retain explored terrain.");
Assert.That(firstZone.IsCellVisible(30, 17), Is.False,
"Explored terrain must become hidden again outside current LOS.");
ZoneInstance secondZone = secondObject.AddComponent<ZoneInstance>();
secondZone.Initialize(node, preset.PlayableHalfExtents);
secondZone.UpdateVisibility(start);
Assert.That(secondZone.IsCellExplored(30, 17), Is.False,
"A newly created run zone must not inherit exploration memory.");
StageNode partialNode = new()
{
Id = 702,
NodeType = StageNodeType.Combat,
PresetId = preset.PresetId,
};
partialNode.SetConnection(StageDirection.North, 1);
ZoneInstance partialZone = partialObject.AddComponent<ZoneInstance>();
partialZone.Initialize(partialNode, preset.PlayableHalfExtents);
Assert.That(partialZone.GetRuntimeCell(17, 0), Is.EqualTo(PresetCellType.Socket),
"The active socket corridor must remain part of the runtime grid.");
Assert.That(partialZone.GetRuntimeCell(0, 11), Is.EqualTo(PresetCellType.Empty),
"An inactive socket must be opaque in the runtime grid.");
StageFogOfWar partialFog =
partialObject.AddComponent<StageFogOfWar>();
partialFog.Initialize(partialZone);
Assert.That(partialFog.GetCellFogAlpha(0, 11), Is.EqualTo(0f));
MeshFilter fogMeshFilter = partialObject.GetComponent<MeshFilter>();
Assert.That(fogMeshFilter, Is.Not.Null);
Color[] fogColors = fogMeshFilter.sharedMesh.colors;
int emptyVertex = 11 * preset.GridWidth * 4;
for (int vertexOffset = 0; vertexOffset < 4; vertexOffset++)
{
Color emptyColor = fogColors[emptyVertex + vertexOffset];
Assert.That(emptyColor.r, Is.EqualTo(0f).Within(0.0001f));
Assert.That(emptyColor.g, Is.EqualTo(0f).Within(0.0001f));
Assert.That(emptyColor.b, Is.EqualTo(0f).Within(0.0001f));
Assert.That(emptyColor.a, Is.EqualTo(0f).Within(0.0001f));
}
}
finally
{
Object.DestroyImmediate(firstObject);
Object.DestroyImmediate(secondObject);
Object.DestroyImmediate(partialObject);
}
}
private static void AssertExpandedEncounterPoolsMatch(RoomPreset expected, RoomPreset actual)
{
Assert.That(actual.EncounterPool.Count, Is.EqualTo(expected.EncounterPool.Count));
for (int encounterIndex = 0; encounterIndex < expected.EncounterPool.Count; encounterIndex++)
{
RoomEncounter expectedEncounter = expected.EncounterPool[encounterIndex];
RoomEncounter actualEncounter = actual.EncounterPool[encounterIndex];
Assert.That(actualEncounter.Name, Is.EqualTo(expectedEncounter.Name));
Assert.That(actualEncounter.Items.Count, Is.EqualTo(expectedEncounter.Items.Count));
for (int itemIndex = 0; itemIndex < expectedEncounter.Items.Count; itemIndex++)
{
Assert.That(actualEncounter.Items[itemIndex].EnemyKind,
Is.EqualTo(expectedEncounter.Items[itemIndex].EnemyKind));
Assert.That(actualEncounter.Items[itemIndex].Count,
Is.EqualTo(expectedEncounter.Items[itemIndex].Count * 2));
}
}
}
private static bool HasCellPath(RoomPreset preset, Vector2Int start, Vector2Int target)
{
if (!RoomPathfinder.IsWalkable(preset, start.x, start.y)
|| !RoomPathfinder.IsWalkable(preset, target.x, target.y))
{
return false;
}
Queue<Vector2Int> pending = new();
HashSet<Vector2Int> visited = new() { start };
pending.Enqueue(start);
Vector2Int[] directions =
{
Vector2Int.up,
Vector2Int.right,
Vector2Int.down,
Vector2Int.left,
};
while (pending.Count > 0)
{
Vector2Int current = pending.Dequeue();
if (current == target)
{
return true;
}
for (int i = 0; i < directions.Length; i++)
{
Vector2Int next = current + directions[i];
if (visited.Contains(next)
|| !RoomPathfinder.IsWalkable(preset, next.x, next.y))
{
continue;
}
visited.Add(next);
pending.Enqueue(next);
}
}
return false;
}
private void AssertDimensions(string presetId, int expectedW, int expectedH)
{
RoomPreset preset = LevelPresetCatalog.GetPreset(presetId);
Assert.IsNotNull(preset, $"Preset {presetId} must exist.");
Assert.AreEqual(expectedW, preset.GridWidth, $"{presetId} GridWidth mismatch");
Assert.AreEqual(expectedH, preset.GridHeight, $"{presetId} GridHeight mismatch");
Assert.AreEqual(new Vector2(expectedW * 0.5f, expectedH * 0.5f), preset.PlayableHalfExtents);
}
[Test]
public void LevelPresets_HaveObstaclesAndSpawnPoints()
{
string[] combatAndBossPresets = new string[]
{
"GRAVE_S1_COMBAT_A",
"GRAVE_S1_COMBAT_B",
"GRAVE_S1_FORK",
"GRAVE_S1_ELITE_ARENA",
"GRAVE_S2_COMBAT_A",
"GRAVE_S2_COMBAT_B",
"GRAVE_S2_FORK_HAZARD",
"GRAVE_S2_MIDBOSS",
"GRAVE_S3_COMBAT_A",
"GRAVE_S3_COMBAT_B",
"GRAVE_S3_PREBOSS_FORK",
"GRAVE_S3_FINAL_NECROMANCER",
};
foreach (string presetId in combatAndBossPresets)
{
RoomPreset preset = LevelPresetCatalog.GetPreset(presetId);
Assert.IsNotNull(preset);
// Check obstacles exist
int obstacleCount = 0;
for (int y = 0; y < preset.GridHeight; y++)
{
for (int x = 0; x < preset.GridWidth; x++)
{
if (preset.GridMap[x, y] == PresetCellType.Obstacle) obstacleCount++;
}
}
Assert.Greater(obstacleCount, 0, $"{presetId} must have at least one obstacle.");
// Check sockets exist
Assert.Greater(preset.Sockets.Count, 0, $"{presetId} must have connection sockets.");
// Check spawn points exist
Assert.Greater(preset.SpawnPoints.Count, 0, $"{presetId} must have spawn points.");
}
}
[Test]
public void StageGenerator_AssignsValidLevelPresetsToAllNodes()
{
for (int stage = 1; stage <= 3; stage++)
{
StagePlan plan = StageGenerator.GenerateStage(stage, 424242);
Assert.IsNotNull(plan);
Assert.Greater(plan.Nodes.Count, 0);
foreach (StageNode node in plan.Nodes)
{
Assert.IsFalse(string.IsNullOrEmpty(node.PresetId), $"Node {node.Id} ({node.NodeType}) should have a PresetId.");
RoomPreset preset = LevelPresetCatalog.GetPreset(node.PresetId);
Assert.IsNotNull(preset, $"Preset {node.PresetId} assigned to Node {node.Id} ({node.NodeType}) must exist in catalog.");
}
}
}
[Test]
public void RoomPreset_CoordinateConversionIsSymmetricAndAccurate()
{
RoomPreset preset = LevelPresetCatalog.GetPreset("GRAVE_S1_COMBAT_A"); // 18x12
Assert.IsNotNull(preset);
Vector2 leftTop = preset.CellToLocalPosition(0, 0);
Vector2 rightBottom = preset.CellToLocalPosition(17, 11);
Assert.AreEqual(-8.5f, leftTop.x, 0.001f);
Assert.AreEqual(5.5f, leftTop.y, 0.001f);
Assert.AreEqual(8.5f, rightBottom.x, 0.001f);
Assert.AreEqual(-5.5f, rightBottom.y, 0.001f);
// Origin symmetry
Assert.AreEqual(-leftTop.x, rightBottom.x, 0.001f);
Assert.AreEqual(-leftTop.y, rightBottom.y, 0.001f);
}
[Test]
public void RoomPreset_EntryPositionGuaranteesSafetyOffset()
{
RoomPreset preset = LevelPresetCatalog.GetPreset("GRAVE_S1_COMBAT_A"); // 18x12
Assert.IsNotNull(preset);
Vector2 entryWest = preset.GetEntryLocalPosition(StageDirection.West);
Vector2 socketWest = preset.GetSocketLocalPosition(StageDirection.West);
// Entry should be at least two cells inward so a transition cannot immediately retrigger.
Assert.Greater(entryWest.x, socketWest.x);
Assert.GreaterOrEqual(entryWest.x - socketWest.x, 2f);
}
[Test]
public void AllPresets_AllSockets_HaveClearPathAndNoObstacleOverlapAtEntry()
{
float playerRadius = 0.275f;
List<string> errors = new();
foreach (var kvp in LevelPresetCatalog.AllPresets)
{
RoomPreset preset = kvp.Value;
foreach (var socket in preset.Sockets)
{
Vector2 entryPos = preset.GetEntryLocalPosition(socket);
for (int y = 0; y < preset.GridHeight; y++)
{
for (int x = 0; x < preset.GridWidth; x++)
{
if (!RoomPathfinder.IsWalkable(preset, x, y))
{
Vector2 obsCenter = preset.CellToLocalPosition(x, y);
float dx = Mathf.Max(0f, Mathf.Abs(entryPos.x - obsCenter.x) - 0.5f);
float dy = Mathf.Max(0f, Mathf.Abs(entryPos.y - obsCenter.y) - 0.5f);
float dist = Mathf.Sqrt(dx * dx + dy * dy);
if (dist <= playerRadius)
{
errors.Add($"Preset '{preset.PresetId}' socket '{socket.Id}' ({socket.Direction}) entry {entryPos} overlaps blocked cell at ({x}, {y}) (dist={dist:F3}, radius={playerRadius})");
}
}
}
}
}
}
Assert.IsEmpty(errors, string.Join("\n", errors));
}
[Test]
public void AllPresets_SocketCellsMatchConfiguredWidths_AndCatalogUsesMixedWidths()
{
bool hasOneTileSocket = false;
bool hasWiderSocket = false;
foreach (var kvp in LevelPresetCatalog.AllPresets)
{
RoomPreset preset = kvp.Value;
HashSet<Vector2Int> expectedSocketCells = new();
foreach (RoomPresetSocket socket in preset.Sockets)
{
Assert.GreaterOrEqual(socket.Width, 1, $"{preset.PresetId}/{socket.Id} width must be at least 1.");
hasOneTileSocket |= socket.Width == 1;
hasWiderSocket |= socket.Width > 1;
bool horizontal = socket.Direction == StageDirection.North || socket.Direction == StageDirection.South;
for (int i = 0; i < socket.Width; i++)
{
expectedSocketCells.Add(new Vector2Int(
socket.CellPosition.x + (horizontal ? i : 0),
socket.CellPosition.y + (horizontal ? 0 : i)));
}
}
HashSet<Vector2Int> actualSocketCells = new();
for (int y = 0; y < preset.GridHeight; y++)
{
for (int x = 0; x < preset.GridWidth; x++)
{
if (preset.GridMap[x, y] == PresetCellType.Socket)
{
actualSocketCells.Add(new Vector2Int(x, y));
}
}
}
CollectionAssert.AreEquivalent(
expectedSocketCells,
actualSocketCells,
$"{preset.PresetId} socket cells must exactly match declared socket widths.");
}
Assert.IsTrue(hasOneTileSocket, "Catalog must contain at least one 1-tile doorway.");
Assert.IsTrue(hasWiderSocket, "Catalog must retain wider doorways for rooms that need them.");
}
[TestCase("GRAVE_S1_FORK")]
[TestCase("GRAVE_S2_FORK_HAZARD")]
[TestCase("GRAVE_S3_PREBOSS_FORK")]
public void ForkPresets_DoNotUseSolidTwoByTwoObstacleBlocks(string presetId)
{
RoomPreset preset = LevelPresetCatalog.GetPreset(presetId);
Assert.IsNotNull(preset);
for (int y = 0; y < preset.GridHeight - 1; y++)
{
for (int x = 0; x < preset.GridWidth - 1; x++)
{
bool solidBlock =
preset.GridMap[x, y] == PresetCellType.Obstacle &&
preset.GridMap[x + 1, y] == PresetCellType.Obstacle &&
preset.GridMap[x, y + 1] == PresetCellType.Obstacle &&
preset.GridMap[x + 1, y + 1] == PresetCellType.Obstacle;
Assert.IsFalse(solidBlock, $"{presetId} must use broken LOS cover instead of a solid 2x2 obstacle block at ({x}, {y}).");
}
}
}
[Test]
public void Player_ColliderBottom_AlignsWithGroundAnchor_NoInvisibleAirGapAboveObstacles()
{
#if UNITY_EDITOR
GameObject playerPrefab = UnityEditor.AssetDatabase.LoadAssetAtPath<GameObject>("Assets/_Project/Prefabs/Player/Player.prefab");
Assert.IsNotNull(playerPrefab);
CircleCollider2D col = playerPrefab.GetComponent<CircleCollider2D>();
Assert.IsNotNull(col);
// Feet anchor in Swordsman sprite is around -0.28 (local units)
float expectedFeetLocalY = -0.28f;
float colliderBottomLocalY = col.offset.y - col.radius;
// Collider bottom must align with the feet anchor within 0.02 units,
// NOT hang 0.22 units below the feet creating an invisible 20px barrier
Assert.AreEqual(expectedFeetLocalY, colliderBottomLocalY, 0.02f,
$"Player collider bottom ({colliderBottomLocalY:F3}) must align with feet ground anchor ({expectedFeetLocalY:F3}) so approaching obstacles from above does not leave an invisible air gap.");
#endif
}
[Test]
public void L01_InvalidPresetData_ThrowsValidationException()
{
// Case 1: Row length mismatch (17 chars instead of 18)
string[] shortRowGrid = new string[]
{
"□□□□□□□○○□□□□□□□□□",
"□□□■■■■■■■■■■□□□□", // 17 chars!
"□□■■◇■■■■■■◇■■□□□□",
"□□■■■■▲▲■■■■■■□□□□",
"○■■■■■▲▲■■■■■■■■■○",
"○■■☆■■■■■■■■◇■■■■○",
"□■■■■■■■■■■■■■■■■□",
"□■■■■▲▲■■■■■■■■■■□",
"□■■◇■▲▲■■■■◇■■■■■□",
"□□■■■■■■■■■■■■□□□□",
"□□□□□■■■■■■□□□□□□□",
"□□□□□□□○○□□□□□□□□□",
};
RoomPreset p1 = new RoomPreset { PresetId = "TEST_SHORT_ROW" };
var ex1 = Assert.Throws<System.ArgumentException>(() =>
LevelPresetCatalog.ParseGridAndAnchors(p1, shortRowGrid, 18, 12));
StringAssert.Contains("length mismatch", ex1.Message);
// Case 2: Row length mismatch (19 chars instead of 18)
string[] longRowGrid = new string[]
{
"□□□□□□□○○□□□□□□□□□",
"□□□■■■■■■■■■■□□□□□",
"□□■■◇■■■■■■◇■■□□□□",
"□□■■■■▲▲■■■■■■□□□□",
"○■■■■■▲▲■■■■■■■■■○",
"○■■☆■■■■■■■■◇■■■■○",
"□■■■■■■■■■■■■■■■■□",
"□■■■■▲▲■■■■■■■■■■□",
"□■■◇■▲▲■■■■◇■■■■■□",
"□□■■■■■■■■■■■■□□□□",
"□□□□□■■■■■■□□□□□□□□", // 19 chars!
"□□□□□□□○○□□□□□□□□□",
};
RoomPreset p2 = new RoomPreset { PresetId = "TEST_LONG_ROW" };
var ex2 = Assert.Throws<System.ArgumentException>(() =>
LevelPresetCatalog.ParseGridAndAnchors(p2, longRowGrid, 18, 12));
StringAssert.Contains("length mismatch", ex2.Message);
// Case 3: Unknown character
string[] unknownCharGrid = new string[]
{
"□□□□□□□○○□□□□□□□□□",
"□□□■■■■■■■■■■□□□□□",
"□□■■◇■■■■■■◇■■□□□□",
"□□■■■■▲▲■■■■■■□□□□",
"○■■■■■▲▲■■■■■■■■■○",
"○■■☆■■■■■■■■◇■■■■○",
"□■■■■■■■■■■■■■■■■□",
"□■■■■▲▲■■■■■■■■■■□",
"□■■◇■▲▲■■■■◇■■■■■□",
"□□■■■■■■■■■■■■□□□□",
"□□□□□■■■■■■□□□□□□□",
"□□□□□□□○○□□□□□□□□X", // 'X' is unknown!
};
RoomPreset p3 = new RoomPreset { PresetId = "TEST_UNKNOWN_CHAR" };
var ex3 = Assert.Throws<System.ArgumentException>(() =>
LevelPresetCatalog.ParseGridAndAnchors(p3, unknownCharGrid, 18, 12));
StringAssert.Contains("Unknown cell character", ex3.Message);
// Case 4: Spawn anchor inside obstacle
string[] obstacleSpawnGrid = new string[]
{
"□□□□□□□○○□□□□□□□□□",
"□□□■■■■■■■■■■□□□□□",
"□□■■◇■■■■■■◇■■□□□□",
"□□■■■■▲▲■■■■■■□□□□",
"○■■■■■▲▲■■■■■■■■■○",
"○■■☆■■■■■■■■◇■■■■○",
"□■■■■■■■■■■■■■■■■□",
"□■■■■▲▲■■■■■■■■■■□",
"□■■◇■▲▲■■■■◇■■■■■□",
"□□■■■■■■■■■■■■□□□□",
"□□□□□■■■■■■□□□□□□□",
"□□□□□□□○○□□□□□□□□□",
};
RoomPreset p4 = new RoomPreset { PresetId = "TEST_OBSTACLE_SPAWN" };
p4.Sockets.Add(new RoomPresetSocket("N1", StageDirection.North, new Vector2Int(7, 0), 2));
p4.Sockets.Add(new RoomPresetSocket("W1", StageDirection.West, new Vector2Int(0, 4), 2));
p4.Sockets.Add(new RoomPresetSocket("E1", StageDirection.East, new Vector2Int(17, 4), 2));
p4.Sockets.Add(new RoomPresetSocket("S1", StageDirection.South, new Vector2Int(7, 11), 2));
// Spawn point at (6, 3) which is Obstacle!
p4.SpawnPoints.Add(new RoomPresetSpawnPoint("M_BAD", SpawnRole.NormalMelee, new Vector2Int(6, 3)));
var ex4 = Assert.Throws<System.ArgumentException>(() =>
LevelPresetCatalog.ParseGridAndAnchors(p4, obstacleSpawnGrid, 18, 12));
StringAssert.Contains("cannot be on Obstacle", ex4.Message);
}
[Test]
public void S1A_NormalizedPreset_HasAccurateGeometrySocketsAndEncounterPool()
{
RoomPreset preset = LevelPresetCatalog.GetPreset("GRAVE_S1_COMBAT_A");
Assert.IsNotNull(preset);
Assert.AreEqual(18, preset.GridWidth);
Assert.AreEqual(12, preset.GridHeight);
// Sockets check
Assert.AreEqual(4, preset.Sockets.Count);
RoomPresetSocket? n1 = preset.GetSocket("N1");
RoomPresetSocket? w1 = preset.GetSocket("W1");
RoomPresetSocket? e1 = preset.GetSocket("E1");
RoomPresetSocket? s1 = preset.GetSocket("S1");
Assert.IsTrue(n1.HasValue && n1.Value.Direction == StageDirection.North && n1.Value.CellPosition == new Vector2Int(7, 0) && n1.Value.Width == 1);
Assert.IsTrue(w1.HasValue && w1.Value.Direction == StageDirection.West && w1.Value.CellPosition == new Vector2Int(0, 4) && w1.Value.Width == 1);
Assert.IsTrue(e1.HasValue && e1.Value.Direction == StageDirection.East && e1.Value.CellPosition == new Vector2Int(17, 4) && e1.Value.Width == 1);
Assert.IsTrue(s1.HasValue && s1.Value.Direction == StageDirection.South && s1.Value.CellPosition == new Vector2Int(7, 11) && s1.Value.Width == 1);
// Obstacle check: five approved cover cells.
int obstacleCount = 0;
for (int y = 0; y < 12; y++)
{
for (int x = 0; x < 18; x++)
{
if (preset.GridMap[x, y] == PresetCellType.Obstacle) obstacleCount++;
}
}
Assert.AreEqual(5, obstacleCount, "S1-A must keep five obstacle cells at approved room coordinates");
// Approved cover positions.
Assert.AreEqual(PresetCellType.Obstacle, preset.GridMap[6, 3]);
Assert.AreEqual(PresetCellType.Obstacle, preset.GridMap[6, 4]);
Assert.AreEqual(PresetCellType.Obstacle, preset.GridMap[12, 4]);
Assert.AreEqual(PresetCellType.Obstacle, preset.GridMap[10, 6]);
Assert.AreEqual(PresetCellType.Obstacle, preset.GridMap[10, 7]);
// Spawn points check: M01~M05
Assert.AreEqual(5, preset.SpawnPoints.Count);
Assert.IsTrue(preset.SpawnPoints.Exists(p => p.Id == "M01" && p.Role == SpawnRole.NormalMelee && p.CellPosition == new Vector2Int(4, 3)));
Assert.IsTrue(preset.SpawnPoints.Exists(p => p.Id == "M02" && p.Role == SpawnRole.NormalMelee && p.CellPosition == new Vector2Int(10, 2)));
Assert.IsTrue(preset.SpawnPoints.Exists(p => p.Id == "M03" && p.Role == SpawnRole.Crowd && p.CellPosition == new Vector2Int(11, 5)));
Assert.IsTrue(preset.SpawnPoints.Exists(p => p.Id == "M04" && p.Role == SpawnRole.NormalMelee && p.CellPosition == new Vector2Int(4, 8)));
Assert.IsTrue(preset.SpawnPoints.Exists(p => p.Id == "M05" && p.Role == SpawnRole.NormalMelee && p.CellPosition == new Vector2Int(12, 8)));
// EncounterPool check
Assert.AreEqual(3, preset.EncounterPool.Count);
RoomEncounter encA = preset.EncounterPool.Find(e => e.Name == "Encounter A");
Assert.IsNotNull(encA);
Assert.AreEqual(2, encA.Items.Count);
Assert.AreEqual("Bat", encA.Items[0].EnemyKind);
Assert.AreEqual(3, encA.Items[0].Count);
Assert.AreEqual("Skeleton", encA.Items[1].EnemyKind);
Assert.AreEqual(2, encA.Items[1].Count);
}
[Test]
public void RoomPreset_SocketPhysicalCenter_MatchesConfiguredWidth()
{
RoomPreset preset = LevelPresetCatalog.GetPreset("GRAVE_S1_COMBAT_A");
Assert.IsNotNull(preset);
// Width 1 sockets are centered on their declared cell.
// W1 cell (0,4) => local (-8.5, 1.5).
RoomPresetSocket w1 = preset.GetSocket("W1").Value;
Vector2 w1Center = preset.GetSocketCenterLocalPosition(w1);
Assert.AreEqual(-8.5f, w1Center.x, 0.001f);
Assert.AreEqual(1.5f, w1Center.y, 0.001f);
// E1 cell (17,4) => local (8.5, 1.5).
RoomPresetSocket e1 = preset.GetSocket("E1").Value;
Vector2 e1Center = preset.GetSocketCenterLocalPosition(e1);
Assert.AreEqual(8.5f, e1Center.x, 0.001f);
Assert.AreEqual(1.5f, e1Center.y, 0.001f);
// N1 cell (7,0) => local (-1.5, 5.5).
RoomPresetSocket n1 = preset.GetSocket("N1").Value;
Vector2 n1Center = preset.GetSocketCenterLocalPosition(n1);
Assert.AreEqual(-1.5f, n1Center.x, 0.001f);
Assert.AreEqual(5.5f, n1Center.y, 0.001f);
// S1 cell (7,11) => local (-1.5, -5.5).
RoomPresetSocket s1 = preset.GetSocket("S1").Value;
Vector2 s1Center = preset.GetSocketCenterLocalPosition(s1);
Assert.AreEqual(-1.5f, s1Center.x, 0.001f);
Assert.AreEqual(-5.5f, s1Center.y, 0.001f);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: a680c99187b982248ab6cef1292474bc
@@ -0,0 +1,127 @@
using System.Reflection;
using BumpCombat.Enemies;
using BumpCombat.Progression;
using BumpCombat.Player;
using NUnit.Framework;
using UnityEngine;
namespace BumpCombat.Tests
{
public sealed class PassiveRelicRulesTests
{
[TestCase(0.149f, true)]
[TestCase(0.15f, false)]
[TestCase(0.99f, false)]
public void RollChance_UsesExclusiveUpperThreshold(float roll, bool expected)
{
Assert.That(PassiveRelicRules.RollsChance(0.15f, roll), Is.EqualTo(expected));
}
[TestCase(true, true, false, 0.1f, 20f)]
[TestCase(true, false, false, 0.1f, 10f)]
[TestCase(true, true, true, 0.1f, 10f)]
[TestCase(false, true, false, 0.1f, 10f)]
public void TwinHorn_DoublesOnlyOwnedOrdinaryBumps(
bool ownsRelic,
bool ordinaryBump,
bool artifactHit,
float roll,
float expected)
{
Assert.That(PassiveRelicRules.ApplyTwinHornDamage(
10f, ownsRelic, ordinaryBump, artifactHit, 0.15f, roll),
Is.EqualTo(expected));
}
[Test]
public void ZeroDamagePulseEcho_HasNoCastOrPlayerSideEffects()
{
GameObject owner = new("Passive Relic Echo Test Player");
ActiveArtifactDefinition pulse = ScriptableObject.CreateInstance<ActiveArtifactDefinition>();
try
{
owner.AddComponent<PlayerStats>();
owner.AddComponent<Rigidbody2D>();
ActiveArtifactController artifacts = owner.AddComponent<ActiveArtifactController>();
SetField(pulse, "artifactId", "pulse-test");
SetField(pulse, "effect", ActiveArtifactEffect.Pulse);
SetField(pulse, "normalDamage", 0f);
artifacts.Configure(new[] { pulse });
Assert.That(artifacts.TryAddArtifact(pulse), Is.True);
typeof(ActiveArtifactController)
.GetField("<CurrentGauge>k__BackingField", BindingFlags.Instance | BindingFlags.NonPublic)
?.SetValue(artifacts, 12f);
float gaugeBefore = artifacts.CurrentGauge;
Vector3 positionBefore = owner.transform.position;
int successfulUses = 0;
artifacts.OnArtifactUseSucceeded += (_, _) => successfulUses++;
bool emitted = artifacts.TryEmitPassiveRelicEcho(owner.transform.position, Vector2.right, 0.5f);
Assert.That(emitted, Is.False);
Assert.That(artifacts.CurrentGauge, Is.EqualTo(gaugeBefore));
Assert.That(owner.transform.position, Is.EqualTo(positionBefore));
Assert.That(artifacts.LocksMovement, Is.False);
Assert.That(artifacts.IsExecutingArtifact, Is.False);
Assert.That(successfulUses, Is.Zero);
}
finally
{
Object.DestroyImmediate(owner);
Object.DestroyImmediate(pulse);
}
}
private static void SetField<T>(ActiveArtifactDefinition definition, string fieldName, T value)
{
typeof(ActiveArtifactDefinition)
.GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic)
?.SetValue(definition, value);
}
[Test]
public void AvailableRelics_ExcludesOwnedIdsAndPreservesCatalogOrder()
{
PassiveRelicId[] all = PassiveRelicRules.GetAvailableRelics(
System.Array.Empty<PassiveRelicId>());
PassiveRelicId[] afterOne = PassiveRelicRules.GetAvailableRelics(
new[] { PassiveRelicId.ResonanceCore });
PassiveRelicId[] afterAll = PassiveRelicRules.GetAvailableRelics(
new[]
{
PassiveRelicId.TwinHornTalisman,
PassiveRelicId.ResonanceCore,
PassiveRelicId.RuptureSeed,
});
Assert.That(all, Is.EqualTo(new[]
{
PassiveRelicId.TwinHornTalisman,
PassiveRelicId.ResonanceCore,
PassiveRelicId.RuptureSeed,
}));
Assert.That(afterOne, Is.EqualTo(new[]
{
PassiveRelicId.TwinHornTalisman,
PassiveRelicId.RuptureSeed,
}));
Assert.That(afterAll, Is.Empty);
}
[Test]
public void RuptureDamageAndRadiusScaleFromEnemyHealthAndPlayerBody()
{
Assert.That(PassiveRelicRules.GetExplosionDamage(1000f, 0.1f), Is.EqualTo(100f));
Assert.That(PassiveRelicRules.GetExplosionRadius(1.2f, 3f), Is.EqualTo(3.6f).Within(0.0001f));
}
[TestCase(EnemyDamageSource.Player, true)]
[TestCase(EnemyDamageSource.Tower, false)]
[TestCase(EnemyDamageSource.PassiveRelicExplosion, false)]
[TestCase(EnemyDamageSource.None, false)]
public void PlayerKillCredit_ExcludesTowerAndRelicExplosion(EnemyDamageSource source, bool expected)
{
Assert.That(PassiveRelicRules.IsPlayerCreditedKill(source), Is.EqualTo(expected));
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: cc9180e9f74b4e3d8dac7a22bed3ead4
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,164 @@
using System.Collections.Generic;
using BumpCombat.Stage;
using NUnit.Framework;
using UnityEngine;
namespace BumpCombat.Tests
{
public sealed class StageGeneratorTests
{
[TestCase(1, 12345)]
[TestCase(2, 12345)]
[TestCase(3, 12345)]
[TestCase(1, 99999)]
[TestCase(2, 42)]
[TestCase(3, 777)]
public void DeterministicSeed_ProducesIdenticalGraph(int stageIndex, int seed)
{
StagePlan planA = StageGenerator.GenerateStage(stageIndex, seed);
StagePlan planB = StageGenerator.GenerateStage(stageIndex, seed);
Assert.That(planA.Nodes.Count, Is.EqualTo(planB.Nodes.Count));
Assert.That(planA.StartNodeId, Is.EqualTo(planB.StartNodeId));
Assert.That(planA.MajorEncounterNodeId, Is.EqualTo(planB.MajorEncounterNodeId));
Assert.That(planA.ExitNodeId, Is.EqualTo(planB.ExitNodeId));
for (int i = 0; i < planA.Nodes.Count; i++)
{
StageNode a = planA.Nodes[i];
StageNode b = planB.Nodes[i];
Assert.That(a.Id, Is.EqualTo(b.Id));
Assert.That(a.NodeType, Is.EqualTo(b.NodeType));
Assert.That(a.GridPosition, Is.EqualTo(b.GridPosition));
Assert.That(a.RewardType, Is.EqualTo(b.RewardType));
for (int d = 0; d < 4; d++)
{
Assert.That(a.Connections[d], Is.EqualTo(b.Connections[d]));
}
}
}
[TestCase(1)]
[TestCase(2)]
[TestCase(3)]
public void AllNodesReachable_FromEntrance(int stageIndex)
{
StagePlan plan = StageGenerator.GenerateStage(stageIndex, 42);
HashSet<int> visited = new();
Queue<int> queue = new();
queue.Enqueue(plan.StartNodeId);
visited.Add(plan.StartNodeId);
while (queue.Count > 0)
{
int currentId = queue.Dequeue();
StageNode current = plan.GetNode(currentId);
Assert.That(current, Is.Not.Null);
for (int d = 0; d < 4; d++)
{
int targetId = current.Connections[d];
if (targetId >= 0 && visited.Add(targetId))
{
queue.Enqueue(targetId);
}
}
}
Assert.That(visited.Count, Is.EqualTo(plan.Nodes.Count), "모든 노드가 Entrance에서 도달 가능해야 합니다.");
Assert.That(visited.Contains(plan.MajorEncounterNodeId), Is.True, "Major Encounter 노드에 도달 가능해야 합니다.");
Assert.That(visited.Contains(plan.ExitNodeId), Is.True, "Exit 노드에 도달 가능해야 합니다.");
}
[TestCase(1)]
[TestCase(2)]
[TestCase(3)]
public void Sockets_MatchOppositeDirections_And_GridDeltas(int stageIndex)
{
StagePlan plan = StageGenerator.GenerateStage(stageIndex, 101);
for (int i = 0; i < plan.Nodes.Count; i++)
{
StageNode from = plan.Nodes[i];
for (int d = 0; d < 4; d++)
{
StageDirection dir = (StageDirection)d;
int targetId = from.GetConnection(dir);
if (targetId < 0) continue;
StageNode to = plan.GetNode(targetId);
Assert.That(to, Is.Not.Null, $"연결된 노드 {targetId}가 존재해야 합니다.");
// 1. 대칭 연결 확인 (A의 dir -> B 이면 B의 dir.Opposite() -> A)
StageDirection opposite = dir.Opposite();
Assert.That(to.GetConnection(opposite), Is.EqualTo(from.Id),
$"{from.NodeType}({from.Id})의 {dir} 연결인 {to.NodeType}({to.Id})는 {opposite}로 되돌아오는 연결을 가져야 합니다.");
// 2. 물리적 격자 방향 일치 확인
Vector2Int expectedDelta = dir.ToVector2Int();
Vector2Int actualDelta = to.GridPosition - from.GridPosition;
Assert.That(actualDelta, Is.EqualTo(expectedDelta),
$"연결 방향 {dir}에 맞는 그리드 이동이어야 합니다.");
}
}
}
[TestCase(1)]
[TestCase(2)]
[TestCase(3)]
public void GridPositions_DoNotOverlap(int stageIndex)
{
StagePlan plan = StageGenerator.GenerateStage(stageIndex, 555);
HashSet<Vector2Int> positions = new();
for (int i = 0; i < plan.Nodes.Count; i++)
{
Vector2Int pos = plan.Nodes[i].GridPosition;
Assert.That(positions.Add(pos), Is.True, $"그리드 위치 {pos}에 노드가 중복 생성되지 않아야 합니다.");
}
}
[Test]
public void StageSpecificRules_AreEnforced()
{
// Stage 1: Elite & Exit
StagePlan s1 = StageGenerator.GenerateStage(1, 100);
Assert.That(s1.MajorEncounterNode.NodeType, Is.EqualTo(StageNodeType.Elite));
Assert.That(s1.ExitNode.NodeType, Is.EqualTo(StageNodeType.StageExit));
Assert.That(s1.Nodes.Count, Is.GreaterThanOrEqualTo(5).And.LessThanOrEqualTo(7));
// Stage 2: MidBoss & Exit
StagePlan s2 = StageGenerator.GenerateStage(2, 100);
Assert.That(s2.MajorEncounterNode.NodeType, Is.EqualTo(StageNodeType.MidBoss));
Assert.That(s2.ExitNode.NodeType, Is.EqualTo(StageNodeType.StageExit));
Assert.That(s2.Nodes.Count, Is.GreaterThanOrEqualTo(6).And.LessThanOrEqualTo(8));
// Stage 3: FinalBoss
StagePlan s3 = StageGenerator.GenerateStage(3, 100);
Assert.That(s3.MajorEncounterNode.NodeType, Is.EqualTo(StageNodeType.FinalBoss));
Assert.That(s3.ExitNodeId, Is.EqualTo(s3.MajorEncounterNodeId));
Assert.That(s3.Nodes.Count, Is.GreaterThanOrEqualTo(6).And.LessThanOrEqualTo(9));
}
[TestCase(1)]
[TestCase(2)]
[TestCase(3)]
public void HasForkBranch_ForRouteChoice(int stageIndex)
{
StagePlan plan = StageGenerator.GenerateStage(stageIndex, 2026);
bool hasFork = false;
for (int i = 0; i < plan.Nodes.Count; i++)
{
if (plan.Nodes[i].NodeType == StageNodeType.Fork && plan.Nodes[i].ConnectionCount >= 3)
{
hasFork = true;
break;
}
}
Assert.That(hasFork, Is.True, "경로 선택을 위한 분기 노드가 적어도 하나 이상 존재해야 합니다.");
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: bcfad3a1fab16b54b9fcce05eed2677c
@@ -0,0 +1,664 @@
using System.Reflection;
using BumpCombat.Constants;
using BumpCombat.Core;
using BumpCombat.Player;
using BumpCombat.Stage;
using NUnit.Framework;
using UnityEngine;
namespace BumpCombat.Tests
{
public sealed class StageManagerTests
{
private GameObject stageManagerObject;
private StageManager stageManager;
private GameObject playerObject;
private PlayerController player;
private GameObject arenaBoundsObject;
private ArenaBounds arenaBounds;
[SetUp]
public void SetUp()
{
arenaBoundsObject = new GameObject("ArenaBounds_Test");
arenaBounds = arenaBoundsObject.AddComponent<ArenaBounds>();
stageManagerObject = new GameObject("StageManager_Test");
stageManager = stageManagerObject.AddComponent<StageManager>();
typeof(StageManager).GetMethod(
"Awake",
BindingFlags.NonPublic | BindingFlags.Instance)
?.Invoke(stageManager, null);
playerObject = new GameObject("Player_Test");
player = playerObject.AddComponent<PlayerController>();
CircleCollider2D playerBody = playerObject.AddComponent<CircleCollider2D>();
playerBody.radius = 0.22f;
playerBody.offset = new Vector2(0f, -0.06f);
}
[TearDown]
public void TearDown()
{
if (stageManagerObject != null)
{
Object.DestroyImmediate(stageManagerObject);
}
if (playerObject != null)
{
Object.DestroyImmediate(playerObject);
}
if (arenaBoundsObject != null)
{
Object.DestroyImmediate(arenaBoundsObject);
}
ArenaBounds.Instance?.ResetToDefaultBounds();
}
[Test]
public void BuildStage_InstantiatesAllNodes_And_ActivatesStartZone()
{
stageManager.StartRun(9999);
Assert.That(stageManager.CurrentPlan, Is.Not.Null);
Assert.That(stageManager.CurrentZone, Is.Not.Null);
Assert.That(stageManager.CurrentZone.NodeType, Is.EqualTo(StageNodeType.Entrance));
Assert.That(stageManager.CurrentStageIndex, Is.EqualTo(1));
// Verify all planned nodes have a corresponding ZoneInstance
foreach (StageNode node in stageManager.CurrentPlan.Nodes)
{
Transform zoneTransform = stageManager.transform.Find($"Zone_{node.Id}_{node.NodeType}");
Assert.That(zoneTransform, Is.Not.Null, $"Node {node.Id}에 해당하는 존 오브젝트가 존재해야 합니다.");
ZoneInstance zone = zoneTransform.GetComponent<ZoneInstance>();
Assert.That(zone, Is.Not.Null);
Assert.That(zone.NodeId, Is.EqualTo(node.Id));
// Major encounter lock vs normal break-through
if (node.NodeType == StageNodeType.Elite || node.NodeType == StageNodeType.MidBoss || node.NodeType == StageNodeType.FinalBoss)
{
Assert.That(zone.LockUntilClear, Is.True, "Major Encounter 존은 LockUntilClear가 true여야 합니다.");
}
else
{
Assert.That(zone.LockUntilClear, Is.False, "일반 탐험 존은 적 돌파를 위해 LockUntilClear가 false여야 합니다.");
}
}
}
[Test]
public void BuildStage_InactivePresetSockets_AreClosedAsEmptyCells()
{
stageManager.StartRun(9999);
foreach (StageNode node in stageManager.CurrentPlan.Nodes)
{
Transform zoneTransform = stageManager.transform.Find($"Zone_{node.Id}_{node.NodeType}");
ZoneInstance zone = zoneTransform.GetComponent<ZoneInstance>();
RoomPreset preset = zone.Preset;
if (preset == null) continue;
foreach (RoomPresetSocket socket in preset.Sockets)
{
StageSocketConnection connection = node.GetSocketConnection(socket.Id);
bool isActive = connection != null && connection.TargetNodeId >= 0;
bool hasSocketConnectionInDirection = false;
foreach (RoomPresetSocket directionSocket in preset.Sockets)
{
if (directionSocket.Direction != socket.Direction) continue;
StageSocketConnection directionConnection = node.GetSocketConnection(directionSocket.Id);
if (directionConnection != null && directionConnection.TargetNodeId >= 0)
{
hasSocketConnectionInDirection = true;
break;
}
}
if (!isActive && !hasSocketConnectionInDirection && node.GetConnection(socket.Direction) >= 0)
{
RoomPresetSocket? legacySocket = preset.GetSocket(socket.Direction);
isActive = legacySocket.HasValue && legacySocket.Value.Id == socket.Id;
}
bool horizontal = socket.Direction == StageDirection.North || socket.Direction == StageDirection.South;
for (int i = 0; i < socket.Width; i++)
{
int x = socket.CellPosition.x + (horizontal ? i : 0);
int y = socket.CellPosition.y + (horizontal ? 0 : i);
Transform closedCell = zoneTransform.Find($"EmptyCell_{x}_{y}");
if (isActive)
{
Assert.That(closedCell, Is.Null,
$"Active socket {socket.Id} on node {node.Id} must remain open at ({x},{y}).");
int corridorX = x;
int corridorY = y;
while (corridorX >= 0 && corridorX < preset.GridWidth &&
corridorY >= 0 && corridorY < preset.GridHeight)
{
Assert.That(zone.GetRuntimeCell(corridorX, corridorY), Is.EqualTo(PresetCellType.Socket),
$"Active socket {socket.Id} on node {node.Id} must stay open through the perimeter at ({corridorX},{corridorY}).");
Assert.That(zoneTransform.Find($"EmptyCell_{corridorX}_{corridorY}"), Is.Null,
$"Active socket {socket.Id} on node {node.Id} must not have a blocking cell at ({corridorX},{corridorY}).");
if (socket.Direction == StageDirection.North) corridorY--;
else if (socket.Direction == StageDirection.South) corridorY++;
else if (socket.Direction == StageDirection.East) corridorX++;
else if (socket.Direction == StageDirection.West) corridorX--;
}
}
else
{
Assert.That(closedCell, Is.Not.Null,
$"Inactive socket {socket.Id} on node {node.Id} must be closed at ({x},{y}).");
Assert.That(closedCell.GetComponent<BoxCollider2D>(), Is.Not.Null,
$"Inactive socket {socket.Id} on node {node.Id} must have blocking collision.");
SpriteRenderer backdropRenderer =
closedCell.Find("Backdrop")?.GetComponent<SpriteRenderer>();
Assert.That(backdropRenderer, Is.Not.Null,
$"Inactive socket {socket.Id} at ({x},{y}) must have a backdrop.");
Assert.That(backdropRenderer.sortingLayerName, Is.EqualTo("Ground"));
Assert.That(backdropRenderer.sortingOrder, Is.EqualTo(-90));
Assert.That(backdropRenderer.color, Is.EqualTo(Color.black));
Assert.That(backdropRenderer.color.a, Is.EqualTo(1f));
}
}
}
}
}
[Test]
public void RequestZoneTransition_MovesPlayer_And_SwitchesActiveZone()
{
stageManager.StartRun(4242);
ZoneInstance startZone = stageManager.CurrentZone;
Assert.That(startZone, Is.Not.Null);
// Find an outgoing connection from start zone
StageNode startNode = stageManager.CurrentPlan.StartNode;
StageDirection outgoingDir = StageDirection.North;
int targetNodeId = -1;
for (int d = 0; d < 4; d++)
{
int conn = startNode.GetConnection((StageDirection)d);
if (conn >= 0)
{
outgoingDir = (StageDirection)d;
targetNodeId = conn;
break;
}
}
Assert.That(targetNodeId, Is.GreaterThan(0));
// Trigger transition to adjacent zone
StageDirection entrySocketDir = outgoingDir.Opposite();
stageManager.RequestZoneTransition(targetNodeId, entrySocketDir);
Assert.That(stageManager.CurrentZone.NodeId, Is.EqualTo(targetNodeId));
Assert.That(stageManager.CurrentZone.IsCleared, Is.False);
// Verify player was repositioned into target room entry
Vector2 expectedEntryPos = stageManager.CurrentZone.GetEntryPosition(
entrySocketDir,
null,
player.GetComponent<Collider2D>());
Debug.Log($"[DIAGNOSTIC] startZone={startZone.ZoneCenter}, nextZone={stageManager.CurrentZone.ZoneCenter}, outgoingDir={outgoingDir}, entrySocketDir={entrySocketDir}, expectedEntryPos={expectedEntryPos}, playerPos={player.transform.position}");
Assert.That(player.transform.position.x, Is.EqualTo(expectedEntryPos.x).Within(0.01f));
Assert.That(player.transform.position.y, Is.EqualTo(expectedEntryPos.y).Within(0.01f));
// Verify player faces inwards into the new room and controls are unlocked
Vector2 expectedFacing = entrySocketDir switch
{
StageDirection.West => Vector2.right,
StageDirection.East => Vector2.left,
StageDirection.North => Vector2.down,
StageDirection.South => Vector2.up,
_ => Vector2.right,
};
Assert.That(player.FacingDirection, Is.EqualTo(expectedFacing));
Assert.That(player.IsTransitionLocked, Is.False);
// Verify ArenaBounds was updated to target zone bounds
Assert.That(arenaBounds.ZoneCenter, Is.EqualTo(stageManager.CurrentZone.ZoneCenter));
Assert.That(arenaBounds.PlayableHalfExtents, Is.EqualTo(stageManager.CurrentZone.PlayableHalfExtents));
}
[Test]
public void RequestZoneTransition_ArrivesImmediatelyInsideConnectedDoorWithoutTriggerOverlap()
{
stageManager.StartRun(4242);
StageNode startNode = stageManager.CurrentPlan.StartNode;
StageDirection outgoingDirection = StageDirection.North;
int targetNodeId = -1;
for (int d = 0; d < 4; d++)
{
StageDirection candidate = (StageDirection)d;
int connection = startNode.GetConnection(candidate);
if (connection >= 0)
{
outgoingDirection = candidate;
targetNodeId = connection;
break;
}
}
ZoneConnector sourceConnector = stageManager.CurrentZone.GetConnector(outgoingDirection);
Assert.That(sourceConnector, Is.Not.Null);
stageManager.RequestZoneTransition(
targetNodeId,
sourceConnector.Direction.Opposite(),
sourceConnector.TargetSocketId);
ZoneConnector arrivalConnector = stageManager.CurrentZone.GetConnector(sourceConnector.TargetSocketId);
Assert.That(arrivalConnector, Is.Not.Null);
Collider2D playerBody = player.GetComponent<Collider2D>();
BoxCollider2D arrivalTrigger = arrivalConnector.GetComponent<BoxCollider2D>();
Physics2D.SyncTransforms();
Bounds triggerBounds = arrivalTrigger.bounds;
Bounds playerBounds = playerBody.bounds;
bool horizontal = arrivalConnector.Direction == StageDirection.East
|| arrivalConnector.Direction == StageDirection.West;
float normalDistance = horizontal
? Mathf.Abs(playerBounds.center.x - triggerBounds.center.x)
: Mathf.Abs(playerBounds.center.y - triggerBounds.center.y);
float maximumNearDistance = (horizontal ? triggerBounds.extents.x : triggerBounds.extents.y)
+ (horizontal ? playerBounds.extents.x : playerBounds.extents.y)
+ 0.1f;
Assert.That(normalDistance, Is.LessThanOrEqualTo(maximumNearDistance));
Assert.That(triggerBounds.Intersects(playerBounds), Is.False,
"The destination trigger must be rearmed by separation instead of remaining overlapped.");
}
[TestCase(StageDirection.West)]
[TestCase(StageDirection.East)]
[TestCase(StageDirection.North)]
[TestCase(StageDirection.South)]
public void ZoneConnector_ArrivalPositionUsesDoorDirectionAndBodyOffset(StageDirection direction)
{
GameObject connectorObject = new($"Connector_{direction}_Test");
GameObject bodyObject = new($"PlayerBody_{direction}_Test");
try
{
BoxCollider2D trigger = connectorObject.AddComponent<BoxCollider2D>();
trigger.isTrigger = true;
ZoneConnector connector = connectorObject.AddComponent<ZoneConnector>();
connector.Configure("socket", direction, 1, "target", false, 2f, 1f);
CircleCollider2D body = bodyObject.AddComponent<CircleCollider2D>();
body.radius = 0.22f;
body.offset = new Vector2(0.11f, -0.07f);
Physics2D.SyncTransforms();
Assert.That(connector.TryGetArrivalPosition(body, out Vector2 actual), Is.True);
Bounds triggerBounds = trigger.bounds;
Bounds bodyBounds = body.bounds;
Vector2 inward = direction switch
{
StageDirection.West => Vector2.right,
StageDirection.East => Vector2.left,
StageDirection.North => Vector2.down,
StageDirection.South => Vector2.up,
_ => Vector2.right,
};
Vector2 expectedBodyCenter = triggerBounds.center;
if (inward.x > 0f)
{
expectedBodyCenter.x = triggerBounds.max.x + bodyBounds.extents.x + 0.03f;
}
else if (inward.x < 0f)
{
expectedBodyCenter.x = triggerBounds.min.x - bodyBounds.extents.x - 0.03f;
}
else if (inward.y > 0f)
{
expectedBodyCenter.y = triggerBounds.max.y + bodyBounds.extents.y + 0.03f;
}
else
{
expectedBodyCenter.y = triggerBounds.min.y - bodyBounds.extents.y - 0.03f;
}
Vector2 bodyOffset = (Vector2)body.bounds.center - (Vector2)body.transform.position;
Assert.That(actual, Is.EqualTo((Vector2)expectedBodyCenter - bodyOffset).Within(0.001f));
}
finally
{
Object.DestroyImmediate(connectorObject);
Object.DestroyImmediate(bodyObject);
}
}
[Test]
public void RequestZoneTransition_RejectsDestinationSocketWithMismatchedDirection()
{
stageManager.StartRun(4242);
ZoneInstance startZone = stageManager.CurrentZone;
StageNode startNode = stageManager.CurrentPlan.StartNode;
StageDirection outgoingDirection = StageDirection.North;
int targetNodeId = -1;
for (int d = 0; d < 4; d++)
{
StageDirection candidate = (StageDirection)d;
int connection = startNode.GetConnection(candidate);
if (connection >= 0)
{
outgoingDirection = candidate;
targetNodeId = connection;
break;
}
}
ZoneInstance targetZone = null;
foreach (StageNode node in stageManager.CurrentPlan.Nodes)
{
if (node.Id != targetNodeId) continue;
targetZone = stageManager.transform.Find($"Zone_{node.Id}_{node.NodeType}")?.GetComponent<ZoneInstance>();
break;
}
Assert.That(targetZone, Is.Not.Null);
ZoneConnector targetConnector = targetZone.GetConnector(outgoingDirection.Opposite());
Assert.That(targetConnector, Is.Not.Null);
stageManager.RequestZoneTransition(
targetNodeId,
outgoingDirection,
targetConnector.SocketId);
Assert.That(stageManager.CurrentZone, Is.SameAs(startZone));
}
[Test]
public void ArrivalConnector_DoesNotReverseTransitionWhilePlayerRemainsInsideDoor()
{
Assert.That(StageManager.Instance, Is.SameAs(stageManager));
stageManager.StartRun(4242);
ZoneInstance startZone = stageManager.CurrentZone;
StageNode startNode = stageManager.CurrentPlan.StartNode;
StageDirection outgoingDirection = StageDirection.North;
int targetNodeId = -1;
for (int d = 0; d < 4; d++)
{
StageDirection candidate = (StageDirection)d;
int connection = startNode.GetConnection(candidate);
if (connection >= 0)
{
outgoingDirection = candidate;
targetNodeId = connection;
break;
}
}
ZoneConnector sourceConnector = stageManager.CurrentZone.GetConnector(outgoingDirection);
Assert.That(sourceConnector, Is.Not.Null);
stageManager.RequestZoneTransition(
sourceConnector.TargetNodeId,
sourceConnector.Direction.Opposite(),
sourceConnector.TargetSocketId);
ZoneInstance arrivalZone = stageManager.CurrentZone;
ZoneConnector arrivalConnector = arrivalZone.GetConnector(sourceConnector.TargetSocketId);
Assert.That(arrivalConnector, Is.Not.Null);
arrivalConnector.SetLocked(false);
BoxCollider2D arrivalTrigger = arrivalConnector.GetComponent<BoxCollider2D>();
Vector2 originalTriggerSize = arrivalTrigger.size;
arrivalTrigger.size = new Vector2(10f, 10f);
Physics2D.SyncTransforms();
Collider2D playerBody = player.GetComponent<Collider2D>();
arrivalConnector.SuppressUntilSeparated(playerBody);
typeof(StageManager).GetField(
"lastTransitionEndTime",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)
?.SetValue(stageManager, -1f);
var onTriggerEnter = typeof(ZoneConnector).GetMethod(
"OnTriggerEnter2D",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
Assert.That(onTriggerEnter, Is.Not.Null);
onTriggerEnter.Invoke(arrivalConnector, new object[] { player.GetComponent<Collider2D>() });
Assert.That(stageManager.CurrentZone, Is.SameAs(arrivalZone));
player.transform.position = arrivalZone.ZoneCenter;
Physics2D.SyncTransforms();
var onTriggerExit = typeof(ZoneConnector).GetMethod(
"OnTriggerExit2D",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
Assert.That(onTriggerExit, Is.Not.Null);
onTriggerExit.Invoke(arrivalConnector, new object[] { playerBody });
var waitingForSeparation = typeof(ZoneConnector).GetField(
"playerWaitingForSeparation",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
Assert.That(waitingForSeparation, Is.Not.Null);
Assert.That(
waitingForSeparation.GetValue(arrivalConnector),
Is.Null,
"The arrival connector must rearm after the player exits its trigger.");
bool canReturn = startZone.TryGetEntryPosition(
arrivalConnector.Direction.Opposite(),
arrivalConnector.TargetSocketId,
playerBody,
out Vector2 returnPosition);
Assert.That(
canReturn,
Is.True,
$"Return arrival was rejected for node {startZone.NodeId}, "
+ $"direction {arrivalConnector.Direction.Opposite()}, "
+ $"socket '{arrivalConnector.TargetSocketId}', "
+ $"player position {player.transform.position}, candidate {returnPosition}.");
var onTriggerStay = typeof(ZoneConnector).GetMethod(
"OnTriggerStay2D",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
Assert.That(onTriggerStay, Is.Not.Null);
// Restore the real doorway footprint before exercising the rearmed
// return. The enlarged trigger above only makes the initial
// overlap deterministic; it must not become part of the return
// transition's geometry fixture.
arrivalTrigger.size = originalTriggerSize;
Bounds triggerBounds = arrivalTrigger.bounds;
Vector2 bodyOffset = (Vector2)playerBody.bounds.center - (Vector2)playerBody.transform.position;
player.transform.position = (Vector2)triggerBounds.center - bodyOffset;
Physics2D.SyncTransforms();
onTriggerStay.Invoke(arrivalConnector, new object[] { playerBody });
Assert.That(stageManager.CurrentZone.NodeId, Is.EqualTo(arrivalConnector.TargetNodeId));
}
[Test]
public void AdvanceToNextStage_IncrementsStageIndex_And_BuildsNewStage()
{
stageManager.StartRun(1001);
Assert.That(stageManager.CurrentStageIndex, Is.EqualTo(1));
stageManager.AdvanceToNextStage();
Assert.That(stageManager.CurrentStageIndex, Is.EqualTo(2));
Assert.That(stageManager.CurrentPlan.StageIndex, Is.EqualTo(2));
Assert.That(stageManager.CurrentPlan.MajorEncounterNode.NodeType, Is.EqualTo(StageNodeType.MidBoss));
stageManager.AdvanceToNextStage();
Assert.That(stageManager.CurrentStageIndex, Is.EqualTo(3));
Assert.That(stageManager.CurrentPlan.StageIndex, Is.EqualTo(3));
Assert.That(stageManager.CurrentPlan.MajorEncounterNode.NodeType, Is.EqualTo(StageNodeType.FinalBoss));
}
[Test]
public void CameraZoom_UsesFixedPlayerViewport_AndKeepsRoomCompositionBounded()
{
GameObject cameraObj = new("MainCamera_Test");
Camera camera = cameraObj.AddComponent<Camera>();
camera.orthographic = true;
camera.orthographicSize = 5.625f;
camera.aspect = 16f / 9f;
ArenaCameraFollow cameraFollow = cameraObj.AddComponent<ArenaCameraFollow>();
try
{
// Stage 1 Entrance
stageManager.StartRun(5555);
ZoneInstance s1Zone = stageManager.CurrentZone;
Assert.That(s1Zone, Is.Not.Null);
float expectedS1Ortho = ArenaCameraFollow.CalculateTargetOrthographicSize(camera, s1Zone.PlayableHalfExtents);
Assert.That(camera.orthographicSize, Is.EqualTo(expectedS1Ortho).Within(0.01f));
Assert.That(cameraFollow.CurrentCenter, Is.EqualTo(s1Zone.ZoneCenter));
Assert.That(
expectedS1Ortho,
Is.EqualTo(GameplayConstants.Current.Run.StageCameraViewHalfExtents.y)
.Within(0.01f));
Vector2 viewHalf = arenaBounds.GetCameraVisibleHalfExtents(camera);
Vector2 clampedCenter = arenaBounds.ClampCameraCenter(s1Zone.ZoneCenter + new Vector2(5f, 5f), viewHalf);
Assert.That(clampedCenter, Is.Not.EqualTo(s1Zone.ZoneCenter), "제한 시야에서는 플레이어가 이동할 때 카메라가 방 중심에 고정되지 않아야 합니다.");
Assert.That(s1Zone.PlayableHalfExtents.x, Is.GreaterThan(viewHalf.x));
Assert.That(s1Zone.PlayableHalfExtents.y, Is.GreaterThan(viewHalf.y));
// Advance to Stage 2 (larger rooms: 20x14 or 22x16)
stageManager.AdvanceToNextStage();
ZoneInstance s2Zone = stageManager.CurrentZone;
Assert.That(stageManager.CurrentStageIndex, Is.EqualTo(2));
Assert.That(s2Zone, Is.Not.Null);
float expectedS2Ortho = ArenaCameraFollow.CalculateTargetOrthographicSize(camera, s2Zone.PlayableHalfExtents);
Assert.That(camera.orthographicSize, Is.EqualTo(expectedS2Ortho).Within(0.01f));
Assert.That(expectedS2Ortho, Is.EqualTo(expectedS1Ortho).Within(0.01f), "방 크기에 따른 자동 축소/확대를 하지 않아야 합니다.");
Assert.That(cameraFollow.CurrentCenter, Is.EqualTo((Vector2)player.transform.position));
// Stage 2 must keep the same bounded viewport instead of fitting the whole room.
Vector2 s2ViewHalf = arenaBounds.GetCameraVisibleHalfExtents(camera);
Vector2 s2ClampedCenter = arenaBounds.ClampCameraCenter(s2Zone.ZoneCenter + new Vector2(5f, 5f), s2ViewHalf);
Assert.That(s2ClampedCenter, Is.Not.EqualTo(s2Zone.ZoneCenter));
}
finally
{
Object.DestroyImmediate(cameraObj);
}
}
[Test]
public void RoomScaleComparison_ChangesOnlyEntranceConnectedFirstCombat()
{
const int referenceSeed = 20260922;
const int catalogBSeed = 20260921;
RunConstants constants = GameplayConstants.Current.Run;
RoomScaleComparisonMode previousMode = constants.Stage1FirstCombatRoomScaleComparison;
try
{
constants.Stage1FirstCombatRoomScaleComparison = RoomScaleComparisonMode.Disabled;
stageManager.StartRun(catalogBSeed);
StageNode disabledCatalogSelection = FindFirstCombat(stageManager.CurrentPlan);
Assert.That(disabledCatalogSelection.PresetId, Is.EqualTo("GRAVE_S1_COMBAT_B"));
constants.Stage1FirstCombatRoomScaleComparison = RoomScaleComparisonMode.Disabled;
stageManager.StartRun(referenceSeed);
StageNode disabledFirstCombat = FindFirstCombat(stageManager.CurrentPlan);
Assert.That(disabledFirstCombat, Is.Not.Null);
Assert.That(disabledFirstCombat.PresetId, Is.EqualTo("GRAVE_S1_COMBAT_A"));
string[] disabledPresetIds = GetPresetIds(stageManager.CurrentPlan);
constants.Stage1FirstCombatRoomScaleComparison = RoomScaleComparisonMode.Original;
stageManager.StartRun(referenceSeed);
StageNode originalFirstCombat = FindFirstCombat(stageManager.CurrentPlan);
Assert.That(originalFirstCombat.PresetId, Is.EqualTo("GRAVE_S1_COMBAT_A"));
Assert.That(GetPresetIds(stageManager.CurrentPlan), Is.EqualTo(disabledPresetIds));
constants.Stage1FirstCombatRoomScaleComparison = RoomScaleComparisonMode.Expanded;
stageManager.StartRun(referenceSeed);
StageNode expandedFirstCombat = FindFirstCombat(stageManager.CurrentPlan);
Assert.That(expandedFirstCombat.PresetId,
Is.EqualTo(LevelPresetCatalog.S1AComparisonExpandedPresetId));
for (int i = 0; i < stageManager.CurrentPlan.Nodes.Count; i++)
{
StageNode node = stageManager.CurrentPlan.Nodes[i];
if (node.Id != expandedFirstCombat.Id)
{
Assert.That(node.PresetId, Is.EqualTo(disabledPresetIds[i]));
}
}
ZoneInstance expandedZone = stageManager.transform
.Find($"Zone_{expandedFirstCombat.Id}_{expandedFirstCombat.NodeType}")
?.GetComponent<ZoneInstance>();
Assert.That(expandedZone, Is.Not.Null);
Assert.That(expandedZone.Preset.GridWidth, Is.EqualTo(36));
Assert.That(expandedZone.Preset.GridHeight, Is.EqualTo(24));
Assert.That(
ArenaCameraFollow.CalculateTargetOrthographicSize(
null,
expandedZone.PlayableHalfExtents,
expandedZone.Preset),
Is.EqualTo(constants.StageComparisonCameraViewHalfExtents.y).Within(0.001f));
Assert.That(
ArenaCameraFollow.CalculateTargetOrthographicSize(
null,
LevelPresetCatalog.GetPreset("GRAVE_S1_COMBAT_A").PlayableHalfExtents,
LevelPresetCatalog.GetPreset("GRAVE_S1_COMBAT_A")),
Is.EqualTo(constants.StageCameraViewHalfExtents.y).Within(0.001f));
}
finally
{
constants.Stage1FirstCombatRoomScaleComparison = previousMode;
}
}
private static StageNode FindFirstCombat(StagePlan plan)
{
StageNode entrance = plan.StartNode;
for (int i = 0; i < plan.Nodes.Count; i++)
{
StageNode node = plan.Nodes[i];
if (node.NodeType != StageNodeType.Combat)
{
continue;
}
for (int direction = 0; direction < 4; direction++)
{
if (entrance.GetConnection((StageDirection)direction) == node.Id)
{
return node;
}
}
}
return null;
}
private static string[] GetPresetIds(StagePlan plan)
{
string[] presetIds = new string[plan.Nodes.Count];
for (int i = 0; i < plan.Nodes.Count; i++)
{
presetIds[i] = plan.Nodes[i].PresetId;
}
return presetIds;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5f9c363ddd5d28d4585fba3403225ba3
@@ -0,0 +1,196 @@
using System.Reflection;
using BumpCombat.Combat;
using BumpCombat.Constants;
using BumpCombat.Core;
using BumpCombat.Enemies;
using BumpCombat.Player;
using BumpCombat.Stage;
using NUnit.Framework;
using UnityEditor;
using UnityEngine;
namespace BumpCombat.Tests
{
public sealed class StageObstacleTests
{
private GameObject obstacleObject;
private StageObstacle stageObstacle;
private GameObject batObject;
private EnemyController batController;
private GameObject skeletonObject;
private EnemyController skeletonController;
private GameObject playerObject;
private Collider2D playerCollider;
private GameObject wallObject;
private Collider2D wallCollider;
[TearDown]
public void TearDown()
{
if (obstacleObject != null) Object.DestroyImmediate(obstacleObject);
if (batObject != null) Object.DestroyImmediate(batObject);
if (skeletonObject != null) Object.DestroyImmediate(skeletonObject);
if (playerObject != null) Object.DestroyImmediate(playerObject);
if (wallObject != null) Object.DestroyImmediate(wallObject);
}
[Test]
public void EnemyDefinition_IsFlying_ReturnsTrueForBatAndNecrofire()
{
EnemyDefinition batDef = AssetDatabase.LoadAssetAtPath<EnemyDefinition>(
"Assets/_Project/Constants/Enemies/Bat.asset");
EnemyDefinition necrofireDef = AssetDatabase.LoadAssetAtPath<EnemyDefinition>(
"Assets/_Project/Constants/Enemies/Necrofire.asset");
EnemyDefinition slimeDef = AssetDatabase.LoadAssetAtPath<EnemyDefinition>(
"Assets/_Project/Constants/Enemies/Slime.asset");
EnemyDefinition skeletonDef = AssetDatabase.LoadAssetAtPath<EnemyDefinition>(
"Assets/_Project/Constants/Enemies/Skeleton.asset");
Assert.That(batDef, Is.Not.Null);
Assert.That(batDef.IsFlying, Is.True, "Bat should be flying");
Assert.That(necrofireDef, Is.Not.Null);
Assert.That(necrofireDef.IsFlying, Is.True, "Necrofire should be flying");
Assert.That(slimeDef, Is.Not.Null);
Assert.That(slimeDef.IsFlying, Is.False, "Slime should not be flying");
Assert.That(skeletonDef, Is.Not.Null);
Assert.That(skeletonDef.IsFlying, Is.False, "Skeleton should not be flying");
}
[Test]
public void EnemyDefinition_ConfigureFlying_OverridesFlag()
{
EnemyDefinition testDef = ScriptableObject.CreateInstance<EnemyDefinition>();
testDef.Configure(EnemyKind.Skeleton, EnemyAttackShape.Box, 10f, 1f, 1f, 0.5f, 0.5f, 0.5f, 1f, 0f, 1f, 1f, 1f, 1);
Assert.That(testDef.IsFlying, Is.False);
testDef.ConfigureFlying(true);
Assert.That(testDef.IsFlying, Is.True);
testDef.ConfigureFlying(false);
Assert.That(testDef.IsFlying, Is.False);
Object.DestroyImmediate(testDef);
}
[Test]
public void StageObstacle_IgnoresCollision_WithFlyingEnemy()
{
// 1. Create Obstacle
obstacleObject = new GameObject("TestObstacle");
BoxCollider2D obsCol = obstacleObject.AddComponent<BoxCollider2D>();
stageObstacle = obstacleObject.AddComponent<StageObstacle>();
// 2. Create Flying Enemy (Bat)
batObject = CreateEnemyObject("TestBat", EnemyKind.Bat, isFlying: true);
batController = batObject.GetComponent<EnemyController>();
// Trigger registration
StageObstacle.RegisterFlyingEnemy(batController);
Assert.That(
Physics2D.GetIgnoreCollision(batController.BodyCollider, stageObstacle.Collider),
Is.True,
"Flying enemy should ignore collision with StageObstacle");
}
[Test]
public void StageObstacle_DoesNotIgnoreCollision_WithGroundEnemy()
{
// 1. Create Obstacle
obstacleObject = new GameObject("TestObstacle");
BoxCollider2D obsCol = obstacleObject.AddComponent<BoxCollider2D>();
stageObstacle = obstacleObject.AddComponent<StageObstacle>();
// 2. Create Ground Enemy (Skeleton)
skeletonObject = CreateEnemyObject("TestSkeleton", EnemyKind.Skeleton, isFlying: false);
skeletonController = skeletonObject.GetComponent<EnemyController>();
Assert.That(
Physics2D.GetIgnoreCollision(skeletonController.BodyCollider, stageObstacle.Collider),
Is.False,
"Ground enemy should collide with StageObstacle");
}
[Test]
public void FlyingEnemy_MaintainsCollision_WithPlayer()
{
batObject = CreateEnemyObject("TestBat", EnemyKind.Bat, isFlying: true);
batController = batObject.GetComponent<EnemyController>();
playerObject = new GameObject("TestPlayer");
playerCollider = playerObject.AddComponent<CircleCollider2D>();
Assert.That(
Physics2D.GetIgnoreCollision(batController.BodyCollider, playerCollider),
Is.False,
"Flying enemy must NOT ignore collision with Player (bump combat intact)");
}
[Test]
public void FlyingEnemy_MaintainsCollision_WithPerimeterWall()
{
batObject = CreateEnemyObject("TestBat", EnemyKind.Bat, isFlying: true);
batController = batObject.GetComponent<EnemyController>();
wallObject = new GameObject("Wall_North_Top");
wallCollider = wallObject.AddComponent<BoxCollider2D>();
Assert.That(
Physics2D.GetIgnoreCollision(batController.BodyCollider, wallCollider),
Is.False,
"Flying enemy must NOT ignore collision with perimeter walls (cannot escape room)");
}
[Test]
public void ZoneInstance_RegisterObstacleAndSpawnedEnemy_AppliesIgnoreCollisionForFlyingEnemy()
{
GameObject zoneObj = new GameObject("TestZone");
ZoneInstance zone = zoneObj.AddComponent<ZoneInstance>();
obstacleObject = new GameObject("ZoneObstacle");
BoxCollider2D obsCol = obstacleObject.AddComponent<BoxCollider2D>();
stageObstacle = obstacleObject.AddComponent<StageObstacle>();
batObject = CreateEnemyObject("ZoneBat", EnemyKind.Bat, isFlying: true);
batController = batObject.GetComponent<EnemyController>();
skeletonObject = CreateEnemyObject("ZoneSkeleton", EnemyKind.Skeleton, isFlying: false);
skeletonController = skeletonObject.GetComponent<EnemyController>();
// Register obstacle first, then enemies
zone.RegisterObstacle(obstacleObject);
zone.RegisterSpawnedEnemy(batController);
zone.RegisterSpawnedEnemy(skeletonController);
Assert.That(
Physics2D.GetIgnoreCollision(batController.BodyCollider, obsCol),
Is.True,
"Bat should ignore collision with obstacle registered in ZoneInstance");
Assert.That(
Physics2D.GetIgnoreCollision(skeletonController.BodyCollider, obsCol),
Is.False,
"Skeleton should NOT ignore collision with obstacle registered in ZoneInstance");
Object.DestroyImmediate(zoneObj);
}
private GameObject CreateEnemyObject(string name, EnemyKind kind, bool isFlying)
{
GameObject enemyObj = new GameObject(name);
CircleCollider2D col = enemyObj.AddComponent<CircleCollider2D>();
EnemyDefinition def = ScriptableObject.CreateInstance<EnemyDefinition>();
def.Configure(kind, EnemyAttackShape.Box, 10f, 1f, 1f, 0.5f, 0.5f, 0.5f, 1f, 0f, 1f, 1f, 1f, 1);
def.ConfigureFlying(isFlying);
EnemyController controller = enemyObj.AddComponent<EnemyController>();
FieldInfo defField = typeof(EnemyController).GetField("definition", BindingFlags.NonPublic | BindingFlags.Instance);
defField?.SetValue(controller, def);
return enemyObj;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 95bdb879b3aa07444a8ed90f43539de5
@@ -0,0 +1,211 @@
using BumpCombat.Combat;
using BumpCombat.Core;
using BumpCombat.Player;
using BumpCombat.Progression;
using BumpCombat.Stage;
using NUnit.Framework;
using UnityEngine;
namespace BumpCombat.Tests
{
public sealed class StageProgressionFlowTests
{
private GameObject systemsObject;
private RunManager runManager;
private StageManager stageManager;
private GameObject playerObject;
private PlayerController player;
[SetUp]
public void SetUp()
{
systemsObject = new GameObject("Systems_Test");
runManager = systemsObject.AddComponent<RunManager>();
if (RunManager.Instance != runManager)
{
typeof(RunManager)
.GetMethod("Awake", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)
?.Invoke(runManager, null);
}
stageManager = systemsObject.AddComponent<StageManager>();
playerObject = new GameObject("Player_Test");
player = playerObject.AddComponent<PlayerController>();
}
[TearDown]
public void TearDown()
{
if (systemsObject != null)
{
Object.DestroyImmediate(systemsObject);
}
if (playerObject != null)
{
Object.DestroyImmediate(playerObject);
}
ArenaBounds.Instance?.ResetToDefaultBounds();
}
[Test]
public void Stage1_EliteDefeated_UnlocksGuard()
{
stageManager.StartRun(7777);
Assert.That(stageManager.CurrentStageIndex, Is.EqualTo(1));
// In production/stage run, guard is initially locked
stageManager.HandleMajorBossDefeated(StageNodeType.Elite);
Assert.That(runManager.IsGuardUnlocked, Is.True, "Stage 1 Elite 처치 시 가드가 해금되어야 합니다.");
}
[Test]
public void Stage2_MidBossDefeated_UnlocksArtifactCharge()
{
stageManager.StartRun(7777);
stageManager.AdvanceToNextStage(); // To Stage 2
Assert.That(stageManager.CurrentStageIndex, Is.EqualTo(2));
stageManager.HandleMajorBossDefeated(StageNodeType.MidBoss);
Assert.That(runManager.IsArtifactChargeUnlocked, Is.True, "Stage 2 Mid Boss 처치 시 아티팩트 충전이 해금되어야 합니다.");
}
[Test]
public void Stage1_EliteDefeated_UnlocksGuard_And_TriggersRedRewardQueue()
{
stageManager.StartRun(7777);
runManager.BeginRun(RunMode.Production);
Assert.That(stageManager.CurrentStageIndex, Is.EqualTo(1));
var artifacts = playerObject.GetComponent<ActiveArtifactController>() ?? playerObject.AddComponent<ActiveArtifactController>();
var rewardCtrl = systemsObject.AddComponent<ArtifactRewardController>();
stageManager.HandleMajorBossDefeated(StageNodeType.Elite);
Assert.That(runManager.IsGuardUnlocked, Is.True, "Stage 1 Elite 처치 시 가드가 해금되어야 합니다.");
Assert.That(rewardCtrl.DebugIsSelectionVisible, Is.True, "보상 창이 열려야 합니다.");
Assert.That(rewardCtrl.DebugShownCount, Is.GreaterThan(0), "빨간색 아티팩트 후보가 표시되어야 합니다.");
for (int i = 0; i < rewardCtrl.DebugShownCount; i++)
{
var shown = rewardCtrl.DebugGetShownArtifact(i);
Assert.That(shown, Is.Not.Null);
Assert.That(shown.ArtifactColor, Is.EqualTo(ArtifactColor.Red), "엘리트 보상은 빨간색 아티팩트여야 합니다.");
}
bool success = rewardCtrl.DebugChooseOption(0);
Assert.That(success, Is.True, "빨간색 아티팩트 선택이 정상 처리되어야 합니다.");
Assert.That(artifacts.GetOwnedArtifactColorCount(ArtifactColor.Red), Is.EqualTo(1), "빨간색 아티팩트가 장착되어야 합니다.");
Assert.That(rewardCtrl.DebugIsSelectionVisible, Is.False, "선택 완료 후 창이 닫혀야 합니다.");
}
[Test]
public void Stage2_MidBossDefeated_UnlocksArtifactCharge_And_TriggersBlueRewardQueue()
{
stageManager.StartRun(7777);
runManager.BeginRun(RunMode.Production);
stageManager.AdvanceToNextStage(); // To Stage 2
Assert.That(stageManager.CurrentStageIndex, Is.EqualTo(2));
var artifacts = playerObject.GetComponent<ActiveArtifactController>() ?? playerObject.AddComponent<ActiveArtifactController>();
var rewardCtrl = systemsObject.AddComponent<ArtifactRewardController>();
stageManager.HandleMajorBossDefeated(StageNodeType.MidBoss);
Assert.That(runManager.IsArtifactChargeUnlocked, Is.True, "Stage 2 Mid Boss 처치 시 아티팩트 충전이 해금되어야 합니다.");
Assert.That(rewardCtrl.DebugIsSelectionVisible, Is.True, "보상 창이 열려야 합니다.");
Assert.That(rewardCtrl.DebugShownCount, Is.GreaterThan(0), "파란색 아티팩트 후보가 표시되어야 합니다.");
for (int i = 0; i < rewardCtrl.DebugShownCount; i++)
{
var shown = rewardCtrl.DebugGetShownArtifact(i);
Assert.That(shown, Is.Not.Null);
Assert.That(shown.ArtifactColor, Is.EqualTo(ArtifactColor.Blue), "중간보스 보상은 파란색 아티팩트여야 합니다.");
}
bool success = rewardCtrl.DebugChooseOption(0);
Assert.That(success, Is.True, "파란색 아티팩트 선택이 정상 처리되어야 합니다.");
Assert.That(artifacts.GetOwnedArtifactColorCount(ArtifactColor.Blue), Is.EqualTo(1), "파란색 아티팩트가 장착되어야 합니다.");
Assert.That(rewardCtrl.DebugIsSelectionVisible, Is.False, "선택 완료 후 창이 닫혀야 합니다.");
}
[Test]
public void EliteReward_WhenPlayerAlreadyOwnsGreenArtifact_DoesNotOfferGreenArtifact()
{
stageManager.StartRun(7777);
runManager.BeginRun(RunMode.Production);
var artifacts = playerObject.GetComponent<ActiveArtifactController>() ?? playerObject.AddComponent<ActiveArtifactController>();
var rewardCtrl = systemsObject.AddComponent<ArtifactRewardController>();
// Pre-add a green artifact from catalog
ActiveArtifactDefinition greenDef = null;
for (int i = 0; i < artifacts.CatalogCount; i++)
{
var def = artifacts.GetCatalogArtifactAt(i);
if (def != null && def.ArtifactColor == ArtifactColor.Green)
{
greenDef = def;
break;
}
}
if (greenDef != null)
{
artifacts.TryAddArtifact(greenDef);
Assert.That(artifacts.GetOwnedArtifactColorCount(ArtifactColor.Green), Is.EqualTo(1));
}
// Defeat Elite
stageManager.HandleMajorBossDefeated(StageNodeType.Elite);
Assert.That(rewardCtrl.DebugIsSelectionVisible, Is.True);
for (int i = 0; i < rewardCtrl.DebugShownCount; i++)
{
var shown = rewardCtrl.DebugGetShownArtifact(i);
Assert.That(shown.ArtifactColor, Is.Not.EqualTo(ArtifactColor.Green), "이미 보유한 초록색 아티팩트는 후보에 나오지 않아야 합니다.");
Assert.That(shown.ArtifactColor, Is.EqualTo(ArtifactColor.Red), "엘리트 보상은 빨간색 아티팩트만 나와야 합니다.");
}
bool chosen = rewardCtrl.DebugChooseOption(0);
Assert.That(chosen, Is.True, "소프트락 없이 정상 선택되어야 합니다.");
}
[Test]
public void Stage3_FinalBossDefeated_TriggersRunClear()
{
stageManager.StartRun(7777);
stageManager.AdvanceToNextStage(); // Stage 2
stageManager.AdvanceToNextStage(); // Stage 3
Assert.That(stageManager.CurrentStageIndex, Is.EqualTo(3));
bool runClearEventFired = false;
runManager.OnRunCleared += () => runClearEventFired = true;
stageManager.HandleMajorBossDefeated(StageNodeType.FinalBoss);
Assert.That(runManager.IsRunCleared, Is.True, "Final Boss 처치 시 런이 클리어 상태가 되어야 합니다.");
Assert.That(runClearEventFired, Is.True, "OnRunCleared 이벤트가 발생해야 합니다.");
Assert.That(RunManager.GameplayInputEnabled, Is.False, "런 클리어 후에는 플레이어 전투 입력이 비활성화되어야 합니다.");
}
[Test]
public void NormalZone_LockUntilClear_IsFalse_AllowingBreakthrough()
{
stageManager.StartRun(1234);
StagePlan plan = stageManager.CurrentPlan;
foreach (StageNode node in plan.Nodes)
{
if (node.NodeType == StageNodeType.Combat || node.NodeType == StageNodeType.Fork || node.NodeType == StageNodeType.Entrance)
{
Transform zoneTransform = stageManager.transform.Find($"Zone_{node.Id}_{node.NodeType}");
ZoneInstance zone = zoneTransform.GetComponent<ZoneInstance>();
Assert.That(zone.LockUntilClear, Is.False,
$"{node.NodeType} 존은 적을 전멸시키지 않아도 통과할 수 있도록 LockUntilClear=false 여야 합니다.");
}
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 52de208cd713e2d45aba0b1eeb6e4512
@@ -13,8 +13,8 @@ namespace BumpCombat.Tests
{
private static readonly RosterSpec[] AmbientRoster =
{
new("Bat", EnemyKind.Bat, EnemyRole.Crowd),
new("Slime", EnemyKind.Slime, EnemyRole.Crowd),
new("Bat", EnemyKind.Bat, EnemyRole.Normal),
new("Slime", EnemyKind.Slime, EnemyRole.Normal),
new("Skeleton", EnemyKind.Skeleton, EnemyRole.Normal),
new("Necrofire", EnemyKind.Necrofire, EnemyRole.Normal),
new("SkeletonArcher", EnemyKind.SkeletonArcher, EnemyRole.Normal),
@@ -67,6 +67,35 @@ namespace BumpCombat.Tests
"Necromancer must use ranged attack behavior.");
}
[Test]
public void ExistingEnemyDefinitions_UseTwentyFivePercentFasterMoveSpeeds()
{
Dictionary<string, float> expectedSpeeds = new()
{
["Bat"] = 1.875f,
["Slime"] = 1.625f,
["Skeleton"] = 1.5f,
["Necrofire"] = 1.125f,
["SkeletonArcher"] = 1.125f,
["ArmoredSkeleton"] = 1.375f,
["Werewolf"] = 1.8125f,
["Werebear"] = 1f,
["GreatswordSkeleton"] = 1.125f,
["NecroGolem"] = 0.8125f,
["Necromancer"] = 0.6875f,
["Lancer"] = 1.4375f,
["Warlock"] = 1.25f,
};
foreach (KeyValuePair<string, float> expected in expectedSpeeds)
{
Assert.That(
LoadDefinition(expected.Key).MoveSpeed,
Is.EqualTo(expected.Value).Within(0.0001f),
expected.Key);
}
}
[Test]
public void StagePrefabs_UseRequestedCollisionRolesAndKeepLegacyLancer()
{
@@ -76,9 +105,10 @@ namespace BumpCombat.Tests
AssertVisualMaterial(prefab, spec.Name);
Collider2D collider = prefab.GetComponent<Collider2D>();
Assert.That(collider, Is.Not.Null, spec.Name);
Assert.That(collider.isTrigger, Is.EqualTo(spec.Role == EnemyRole.Crowd), spec.Name);
Assert.That(prefab.transform.localScale.x, Is.EqualTo(spec.Role == EnemyRole.Crowd ? 1f : 1.25f).Within(0.0001f), spec.Name);
Assert.That(prefab.transform.localScale.y, Is.EqualTo(spec.Role == EnemyRole.Crowd ? 1f : 1.25f).Within(0.0001f), spec.Name);
Assert.That(collider.isTrigger, Is.False, spec.Name);
float expectedScale = (spec.Kind == EnemyKind.Bat || spec.Kind == EnemyKind.Slime) ? 1f : 1.25f;
Assert.That(prefab.transform.localScale.x, Is.EqualTo(expectedScale).Within(0.0001f), spec.Name);
Assert.That(prefab.transform.localScale.y, Is.EqualTo(expectedScale).Within(0.0001f), spec.Name);
}
foreach (RosterSpec spec in EventRoster)