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)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8f16c4a8dfe44f9e8c455a2e24ad7281
@@ -1,15 +1,18 @@
using System.Collections;
using System.Reflection;
using BumpCombat.Constants;
using BumpCombat.Core;
using BumpCombat.Enemies;
using BumpCombat.Player;
using BumpCombat.Progression;
using BumpCombat.Spawning;
using BumpCombat.Stage;
using BumpCombat.UI;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.TestTools;
using UnityEngine.UI;
namespace BumpCombat.PlayModeTests
{
@@ -298,6 +301,142 @@ namespace BumpCombat.PlayModeTests
}
}
[UnityTest]
public IEnumerator RoomScaleComparison_UsesMatchedCameraAndMinimapForOriginalAndExpanded()
{
const int referenceSeed = 20260922;
RunConstants constants = GameplayConstants.Current.Run;
RoomScaleComparisonMode previousMode = constants.Stage1FirstCombatRoomScaleComparison;
UnityEngine.Random.State previousRandomState = UnityEngine.Random.state;
float previousTimeScale = Time.timeScale;
bool previousProductionMode = RunManager.ForceProductionModeForTests;
string captureDirectory = System.Environment.GetEnvironmentVariable(
"BUMPCOMBAT_COMPARISON_CAPTURE_DIRECTORY");
try
{
constants.Stage1FirstCombatRoomScaleComparison = RoomScaleComparisonMode.Original;
yield return LoadAndStartRun();
StageManager stageManager = Object.FindAnyObjectByType<StageManager>();
Camera camera = Camera.main;
Assert.That(stageManager, Is.Not.Null);
Assert.That(camera, Is.Not.Null);
RoomScaleComparisonMode[] modes =
{
RoomScaleComparisonMode.Original,
RoomScaleComparisonMode.Expanded,
};
for (int modeIndex = 0; modeIndex < modes.Length; modeIndex++)
{
RoomScaleComparisonMode mode = modes[modeIndex];
constants.Stage1FirstCombatRoomScaleComparison = mode;
stageManager.StartRun(referenceSeed);
yield return new WaitForSecondsRealtime(0.4f);
StageNode firstCombat = FindFirstCombat(stageManager.CurrentPlan);
Assert.That(firstCombat, Is.Not.Null);
Assert.That(firstCombat.PresetId,
Is.EqualTo(mode == RoomScaleComparisonMode.Expanded
? LevelPresetCatalog.S1AComparisonExpandedPresetId
: "GRAVE_S1_COMBAT_A"));
StageDirection entryDirection = GetEntryDirection(
stageManager.CurrentPlan.StartNode,
firstCombat);
// Reset immediately before the same room activation so the
// encounter's UnityEngine.Random group rolls match A/B.
UnityEngine.Random.InitState(referenceSeed);
stageManager.RequestZoneTransition(firstCombat.Id, entryDirection);
float deadline = Time.realtimeSinceStartup + 4f;
while (stageManager.CurrentZone == null
|| stageManager.CurrentZone.NodeId != firstCombat.Id
|| stageManager.IsTransitioning)
{
Assert.That(Time.realtimeSinceStartup, Is.LessThan(deadline),
"The comparison fixture did not reach the first combat room.");
yield return null;
}
// CoTransitionZone sets CurrentZone before its doorway
// hold. Wait for ActivateZone and encounter spawn to
// finish before inspecting or capturing the room.
yield return new WaitForSecondsRealtime(0.4f);
ZoneInstance zone = stageManager.CurrentZone;
Assert.That(stageManager.IsTransitioning, Is.False);
AssertReferenceEncounter(
zone,
mode == RoomScaleComparisonMode.Expanded ? 6 : 3,
mode == RoomScaleComparisonMode.Expanded ? 4 : 2);
float expectedOrtho = mode == RoomScaleComparisonMode.Expanded
? constants.StageComparisonCameraViewHalfExtents.y
: constants.StageComparisonOriginalCameraViewHalfExtents.y;
Assert.That(zone.Preset.GridWidth,
Is.EqualTo(mode == RoomScaleComparisonMode.Expanded ? 36 : 18));
Assert.That(zone.Preset.GridHeight,
Is.EqualTo(mode == RoomScaleComparisonMode.Expanded ? 24 : 12));
PlayerController comparisonPlayer =
Object.FindAnyObjectByType<PlayerController>();
Vector2 returnPosition = comparisonPlayer.transform.position;
Vector2 explorationPosition = returnPosition;
bool foundUnseenCell = false;
for (int y = 0; y < zone.Preset.GridHeight && !foundUnseenCell; y++)
{
for (int x = 0; x < zone.Preset.GridWidth; x++)
{
if (zone.GetRuntimeCell(x, y) == PresetCellType.Empty
|| zone.IsCellVisible(x, y))
{
continue;
}
explorationPosition = zone.Preset.CellToWorldPosition(
zone.ZoneCenter,
new Vector2Int(x, y));
foundUnseenCell = true;
break;
}
}
Assert.That(foundUnseenCell, Is.True,
"The comparison room must have an unseen terrain cell.");
Assert.That(comparisonPlayer.TryReposition(explorationPosition), Is.True);
zone.UpdateVisibility(explorationPosition);
yield return null;
Assert.That(comparisonPlayer.TryReposition(returnPosition), Is.True);
zone.UpdateVisibility(returnPosition);
yield return null;
camera.Render();
Assert.That(camera.orthographicSize, Is.EqualTo(expectedOrtho).Within(0.01f));
AssertPixelPerfectReference(
camera,
mode == RoomScaleComparisonMode.Expanded ? 480 : 320,
mode == RoomScaleComparisonMode.Expanded ? 270 : 180);
AssertMinimapTerrainMatches(zone);
if (!string.IsNullOrWhiteSpace(captureDirectory))
{
CaptureWorldOnlyCamera(
camera,
captureDirectory,
mode == RoomScaleComparisonMode.Expanded ? "expanded" : "original");
}
}
}
finally
{
constants.Stage1FirstCombatRoomScaleComparison = previousMode;
UnityEngine.Random.state = previousRandomState;
Time.timeScale = previousTimeScale;
RunManager.ForceProductionModeForTests = previousProductionMode;
}
}
private static IEnumerator DestroyActiveEnemies()
{
EnemyController[] enemies = Object.FindObjectsByType<EnemyController>(
@@ -313,6 +452,184 @@ namespace BumpCombat.PlayModeTests
yield return null;
}
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 StageDirection GetEntryDirection(StageNode entrance, StageNode target)
{
for (int direction = 0; direction < 4; direction++)
{
StageDirection sourceDirection = (StageDirection)direction;
if (entrance.GetConnection(sourceDirection) == target.Id)
{
return sourceDirection.Opposite();
}
}
return StageDirection.West;
}
private static void AssertPixelPerfectReference(
Camera camera,
int expectedWidth,
int expectedHeight)
{
Component pixelPerfect = camera.GetComponent(
"UnityEngine.Rendering.Universal.PixelPerfectCamera");
Assert.That(pixelPerfect, Is.Not.Null);
PropertyInfo referenceWidth = pixelPerfect.GetType().GetProperty("refResolutionX");
PropertyInfo referenceHeight = pixelPerfect.GetType().GetProperty("refResolutionY");
Assert.That(referenceWidth.GetValue(pixelPerfect), Is.EqualTo(expectedWidth));
Assert.That(referenceHeight.GetValue(pixelPerfect), Is.EqualTo(expectedHeight));
}
private static void AssertMinimapTerrainMatches(ZoneInstance zone)
{
RunHUD hud = Object.FindAnyObjectByType<RunHUD>();
Assert.That(hud, Is.Not.Null);
FieldInfo terrainField = typeof(RunHUD).GetField(
"minimapTerrainRoot",
BindingFlags.Instance | BindingFlags.NonPublic);
RectTransform terrainRoot = terrainField?.GetValue(hud) as RectTransform;
Assert.That(terrainRoot, Is.Not.Null);
int visibleCellCount = 0;
for (int y = 0; y < zone.Preset.GridHeight; y++)
{
for (int x = 0; x < zone.Preset.GridWidth; x++)
{
if (zone.GetRuntimeCell(x, y) != PresetCellType.Empty)
{
visibleCellCount++;
}
}
}
Assert.That(terrainRoot.childCount, Is.EqualTo(visibleCellCount));
Assert.That(zone.VisibilityRevision, Is.GreaterThan(0));
int darkCellCount = 0;
int dimCellCount = 0;
for (int y = 0; y < zone.Preset.GridHeight; y++)
{
for (int x = 0; x < zone.Preset.GridWidth; x++)
{
if (zone.GetRuntimeCell(x, y) == PresetCellType.Empty)
{
continue;
}
Image cell = terrainRoot.Find($"Terrain {x},{y}")?.GetComponent<Image>();
Assert.That(cell, Is.Not.Null);
Color32 color = cell.color;
if (zone.IsCellVisible(x, y))
{
Assert.That(color, Is.Not.EqualTo(new Color32(2, 4, 5, 255)));
}
else if (zone.IsCellExplored(x, y))
{
dimCellCount++;
Assert.That(color.a, Is.EqualTo(255));
}
else
{
darkCellCount++;
Assert.That(color, Is.EqualTo(new Color32(2, 4, 5, 255)));
}
}
}
Assert.That(dimCellCount, Is.GreaterThan(0));
Assert.That(darkCellCount, Is.GreaterThan(0));
}
private static void AssertReferenceEncounter(
ZoneInstance zone,
int expectedBatCount,
int expectedSkeletonCount)
{
int batCount = 0;
int skeletonCount = 0;
for (int i = 0; i < zone.SpawnedEnemies.Count; i++)
{
EnemyController enemy = zone.SpawnedEnemies[i];
if (enemy == null)
{
continue;
}
if (enemy.name.StartsWith("Bat", System.StringComparison.Ordinal))
{
batCount++;
}
else if (enemy.name.StartsWith("Skeleton", System.StringComparison.Ordinal))
{
skeletonCount++;
}
}
Assert.That(batCount, Is.EqualTo(expectedBatCount));
Assert.That(skeletonCount, Is.EqualTo(expectedSkeletonCount));
}
private static void CaptureWorldOnlyCamera(
Camera camera,
string directory,
string variant)
{
System.IO.Directory.CreateDirectory(directory);
RenderTexture previousTarget = camera.targetTexture;
RenderTexture previousActive = RenderTexture.active;
RenderTexture target = new(640, 360, 24, RenderTextureFormat.ARGB32);
Texture2D snapshot = null;
try
{
target.Create();
camera.targetTexture = target;
camera.Render();
RenderTexture.active = target;
snapshot = new Texture2D(640, 360, TextureFormat.RGB24, false);
snapshot.ReadPixels(new Rect(0f, 0f, 640f, 360f), 0, 0);
snapshot.Apply();
string path = System.IO.Path.Combine(
directory,
$"s1a-{variant}-world.png");
System.IO.File.WriteAllBytes(path, snapshot.EncodeToPNG());
}
finally
{
camera.targetTexture = previousTarget;
RenderTexture.active = previousActive;
if (snapshot != null)
{
Object.Destroy(snapshot);
}
target.Release();
Object.Destroy(target);
}
}
private static EnemyController FindRangedPrefab(SpawnDirector director)
{
FieldInfo field = typeof(SpawnDirector).GetField(
@@ -15,6 +15,21 @@ namespace BumpCombat.PlayModeTests
private static readonly BindingFlags NonPublicInstance =
BindingFlags.Instance | BindingFlags.NonPublic;
private float previousTimeScale;
[SetUp]
public void SetUp()
{
previousTimeScale = Time.timeScale;
Time.timeScale = 1f;
}
[TearDown]
public void TearDown()
{
Time.timeScale = previousTimeScale;
}
[UnityTest]
public IEnumerator ExperienceOrb_UsesChangedPickupRadius()
{
@@ -40,6 +40,10 @@ namespace BumpCombat.PlayModeTests
LancerThrustVisual visual = lancer.GetComponent<LancerThrustVisual>();
SpriteRenderer source = lancer.GetComponent<SpriteRenderer>();
lancer.enabled = false;
Rigidbody2D lancerBody = lancer.GetComponent<Rigidbody2D>();
lancerBody.linearVelocity = Vector2.zero;
lancerBody.angularVelocity = 0f;
lancerBody.constraints = RigidbodyConstraints2D.FreezeAll;
Vector2[] directions =
{
@@ -740,7 +740,7 @@ namespace BumpCombat.PlayModeTests
Assert.That(keyboard.escapeKey.isPressed, Is.False);
}
private static void CaptureAtResolution(
internal static void CaptureAtResolution(
Canvas canvas,
RectTransform panelRect,
RectTransform finalMissionRow,
@@ -174,6 +174,10 @@ namespace BumpCombat.PlayModeTests
submenuLabels.Add("개발용 시작");
}
submenuLabels.Add("기본 모드");
if (showDevelopment)
{
submenuLabels.Add("방어전 테스트");
}
submenuLabels.Add("챌린지 모드(준비 중)");
submenuLabels.Add("하드코어 모드(준비 중)");
submenuLabels.Add("뒤로");
@@ -196,7 +200,7 @@ namespace BumpCombat.PlayModeTests
label.text);
}
int placeholderIndex = showDevelopment ? 2 : 1;
int placeholderIndex = showDevelopment ? 3 : 1;
for (int i = placeholderIndex; i < placeholderIndex + 2; i++)
{
submenu.Find($"Button {i + 1}").GetComponent<Button>().onClick.Invoke();
@@ -212,12 +216,28 @@ namespace BumpCombat.PlayModeTests
EventSystem.current.currentSelectedGameObject
.GetComponentInChildren<Text>().text,
Is.EqualTo("기본 모드"));
if (showDevelopment)
{
yield return PressAndRelease(keyboard, Key.DownArrow);
Assert.That(
EventSystem.current.currentSelectedGameObject
.GetComponentInChildren<Text>().text,
Is.EqualTo("방어전 테스트"));
}
yield return PressAndRelease(keyboard, Key.DownArrow);
Assert.That(
EventSystem.current.currentSelectedGameObject
.GetComponentInChildren<Text>().text,
Is.EqualTo("뒤로"),
"Keyboard navigation skips disabled mode placeholders.");
if (showDevelopment)
{
yield return PressAndRelease(keyboard, Key.UpArrow);
Assert.That(
EventSystem.current.currentSelectedGameObject
.GetComponentInChildren<Text>().text,
Is.EqualTo("방어전 테스트"));
}
yield return PressAndRelease(keyboard, Key.UpArrow);
Assert.That(
EventSystem.current.currentSelectedGameObject
@@ -2,6 +2,7 @@ using System.Collections;
using System.Collections.Generic;
using System.Reflection;
using BumpCombat.Combat;
using BumpCombat.Constants;
using BumpCombat.Core;
using BumpCombat.Enemies;
using BumpCombat.Player;
@@ -155,7 +156,186 @@ namespace BumpCombat.PlayModeTests
}
[UnityTest]
public IEnumerator MovingPlayer_BumpKillsPassThroughCrowdWithoutImpactRecoil()
public IEnumerator MovingPlayer_BumpFrontCollision_DefaultAndToggleControlPlayerDamage()
{
CombatConstants combatConstants = GameplayConstants.Current.Combat;
bool originalFrontalBumpSelfDamage =
combatConstants.EnableFrontalBumpSelfDamage;
EnemyController targetEnemy = null;
try
{
Assert.That(
originalFrontalBumpSelfDamage,
Is.False,
"CombatConstants default must preserve the removed front-bump self damage");
combatConstants.EnableFrontalBumpSelfDamage = false;
AsyncOperation load = SceneManager.LoadSceneAsync("CombatPrototype");
while (!load.isDone)
{
yield return null;
}
yield return null;
SpawnDirector director = Object.FindAnyObjectByType<SpawnDirector>();
Assert.That(director, Is.Not.Null);
director.enabled = false;
yield return null;
PlayerController player = Object.FindAnyObjectByType<PlayerController>();
Assert.That(player, Is.Not.Null);
PlayerHealth playerHealth = player.GetComponent<PlayerHealth>();
Rigidbody2D playerBody = player.GetComponent<Rigidbody2D>();
BumpCombatResolver resolver = player.GetComponent<BumpCombatResolver>();
Assert.That(playerBody, Is.Not.Null);
Vector2 playerOrigin = playerBody.position;
FieldInfo enemyPrefabsField = typeof(SpawnDirector).GetField(
"enemyPrefabs",
BindingFlags.Instance | BindingFlags.NonPublic);
EnemyController[] enemyPrefabs =
enemyPrefabsField?.GetValue(director) as EnemyController[];
EnemyController skeletonPrefab = null;
if (enemyPrefabs != null)
{
foreach (EnemyController prefab in enemyPrefabs)
{
if (prefab != null
&& prefab.Definition != null
&& prefab.Definition.Kind == EnemyKind.Skeleton)
{
skeletonPrefab = prefab;
break;
}
}
}
Assert.That(skeletonPrefab, Is.Not.Null);
targetEnemy = Object.Instantiate(
skeletonPrefab.gameObject,
playerOrigin + Vector2.right * 0.5f,
Quaternion.identity).GetComponent<EnemyController>();
yield return null;
Assert.That(targetEnemy, Is.Not.Null);
Assert.That(targetEnemy.ContactDamage, Is.GreaterThan(0f));
Assert.That(targetEnemy.HasContactDamage, Is.False);
Assert.That(
EnemyController.AllowsContactDamage(
targetEnemy.State,
targetEnemy.AttackShape),
Is.False);
Collider2D enemyCollider = targetEnemy.GetComponent<Collider2D>();
Assert.That(
enemyCollider.isTrigger,
Is.False,
"Promoted enemy collider must be solid");
foreach (EnemyController enemy in
Object.FindObjectsByType<EnemyController>(FindObjectsSortMode.None))
{
if (enemy != targetEnemy)
{
enemy.enabled = false;
}
}
Assert.That(
targetEnemy.TryReposition(playerOrigin + Vector2.right * 0.5f),
Is.True);
SetAutoProperty(
targetEnemy,
"FacingDirection",
((Vector2)player.transform.position
- (Vector2)targetEnemy.transform.position).normalized);
targetEnemy.enabled = false;
Physics2D.SyncTransforms();
yield return new WaitForFixedUpdate();
Assert.That(RunManager.GameplayInputEnabled, Is.True);
float initialPlayerHealth = playerHealth.CurrentHealth;
float initialEnemyHealth = targetEnemy.CurrentHealth;
SetAutoProperty(player, "AttackIntentVelocity", Vector2.right * 3f);
SetAutoProperty(player, "MoveDirection", Vector2.right);
MethodInfo resolveNormalContact = typeof(BumpCombatResolver).GetMethod(
"TryResolveNormalContact",
BindingFlags.Instance | BindingFlags.NonPublic);
Assert.That(resolveNormalContact, Is.Not.Null);
resolveNormalContact.Invoke(
resolver,
new object[] { targetEnemy, Vector2.Lerp(playerBody.position, targetEnemy.transform.position, 0.5f) });
Assert.That(
targetEnemy.CurrentHealth,
Is.LessThan(initialEnemyHealth),
"Enemy must take damage from front bump");
Assert.That(
playerHealth.CurrentHealth,
Is.EqualTo(initialPlayerHealth),
"OFF must preserve no front-bump self damage");
Assert.That(
player.IsDamageKnockbackActive,
Is.False,
"OFF must preserve no front-bump damage knockback");
yield return new WaitForSeconds(0.3f);
Assert.That(
targetEnemy.TryReposition(playerBody.position + Vector2.right * 0.5f),
Is.True);
SetAutoProperty(
targetEnemy,
"FacingDirection",
((Vector2)player.transform.position
- (Vector2)targetEnemy.transform.position).normalized);
Physics2D.SyncTransforms();
yield return new WaitForFixedUpdate();
Assert.That(targetEnemy.ContactDamage, Is.GreaterThan(0f));
Assert.That(targetEnemy.HasContactDamage, Is.False);
Assert.That(
EnemyController.AllowsContactDamage(
targetEnemy.State,
targetEnemy.AttackShape),
Is.False);
combatConstants.EnableFrontalBumpSelfDamage = true;
SetAutoProperty(player, "AttackIntentVelocity", Vector2.right * 3f);
SetAutoProperty(player, "MoveDirection", Vector2.right);
float enabledPlayerHealth = playerHealth.CurrentHealth;
float enabledEnemyHealth = targetEnemy.CurrentHealth;
resolveNormalContact.Invoke(
resolver,
new object[] { targetEnemy, Vector2.Lerp(playerBody.position, targetEnemy.transform.position, 0.5f) });
Assert.That(
targetEnemy.CurrentHealth,
Is.LessThan(enabledEnemyHealth),
"ON must preserve enemy bump damage");
Assert.That(
playerHealth.CurrentHealth,
Is.LessThan(enabledPlayerHealth),
"ON must restore front-bump self damage");
Assert.That(
player.IsDamageKnockbackActive,
Is.True,
"ON must use normal PlayerHealth damage knockback");
LogAssert.NoUnexpectedReceived();
}
finally
{
combatConstants.EnableFrontalBumpSelfDamage = originalFrontalBumpSelfDamage;
if (targetEnemy != null)
{
Object.Destroy(targetEnemy.gameObject);
}
}
}
[UnityTest]
public IEnumerator MovingPlayer_BumpBackHit_DamagesEnemyWithoutPlayerDamage()
{
AsyncOperation load = SceneManager.LoadSceneAsync("CombatPrototype");
while (!load.isDone)
@@ -170,39 +350,45 @@ namespace BumpCombat.PlayModeTests
yield return null;
PlayerController player = Object.FindAnyObjectByType<PlayerController>();
Rigidbody2D playerBody = player.GetComponent<Rigidbody2D>();
PlayerHealth playerHealth = player.GetComponent<PlayerHealth>();
BumpCombatResolver resolver = player.GetComponent<BumpCombatResolver>();
EnemyController crowd = null;
EnemyController targetEnemy = null;
foreach (EnemyController enemy in
Object.FindObjectsByType<EnemyController>(
FindObjectsSortMode.None))
{
if (enemy.IsCrowd && !enemy.IsDead)
if (!enemy.IsDead)
{
crowd = enemy;
targetEnemy = enemy;
break;
}
}
Assert.That(crowd, Is.Not.Null);
Collider2D crowdCollider = crowd.GetComponent<Collider2D>();
Assert.That(crowdCollider.isTrigger, Is.True);
playerBody.position = Vector2.zero;
Assert.That(crowd.TryReposition(new Vector2(0.2f, 0f)), Is.True);
Assert.That(targetEnemy, Is.Not.Null);
targetEnemy.TryReposition(new Vector2(0.5f, 0f));
SetAutoProperty(targetEnemy, "FacingDirection", Vector2.right);
Physics2D.SyncTransforms();
yield return new WaitForFixedUpdate();
float initialPlayerHealth = playerHealth.CurrentHealth;
float initialEnemyHealth = targetEnemy.CurrentHealth;
SetAutoProperty(player, "AttackIntentVelocity", Vector2.right * 3f);
SetAutoProperty(player, "MoveDirection", Vector2.right);
typeof(BumpCombatResolver)
.GetMethod(
"OnTriggerEnter2D",
BindingFlags.Instance | BindingFlags.NonPublic)
?.Invoke(resolver, new object[] { crowdCollider });
Assert.That(crowd.IsDead, Is.True);
yield return new WaitForFixedUpdate();
Assert.That(playerBody.position.x, Is.EqualTo(0f).Within(0.01f));
MethodInfo resolveNormalContact = typeof(BumpCombatResolver).GetMethod(
"TryResolveNormalContact",
BindingFlags.Instance | BindingFlags.NonPublic);
Assert.That(resolveNormalContact, Is.Not.Null);
resolveNormalContact.Invoke(
resolver,
new object[] { targetEnemy, new Vector2(0.25f, 0f) });
Assert.That(targetEnemy.CurrentHealth, Is.LessThan(initialEnemyHealth), "Enemy must take damage from back bump");
Assert.That(playerHealth.CurrentHealth, Is.EqualTo(initialPlayerHealth), "Player must NOT take damage from back bump");
Assert.That(player.IsDamageKnockbackActive, Is.False, "Player must NOT receive damage knockback from back bump");
LogAssert.NoUnexpectedReceived();
}
@@ -1580,6 +1766,8 @@ namespace BumpCombat.PlayModeTests
PlayerStats stats = player.GetComponent<PlayerStats>();
ActiveArtifactController artifacts =
player.GetComponent<ActiveArtifactController>();
Animator animator = player.GetComponent<Animator>();
RunManager runManager = RunManager.Instance;
for (int i = 0; i < artifacts.OwnedArtifactCount * 2; i++)
{
@@ -1607,12 +1795,28 @@ namespace BumpCombat.PlayModeTests
0.75f),
Is.True);
Assert.That(health.CurrentHealth, Is.Zero);
Assert.That(runManager.IsGameOver, Is.False);
Assert.That(runManager.IsGameOverPresentationActive, Is.True);
Assert.That(RunManager.GameplayInputEnabled, Is.False);
Assert.That(
animator.GetCurrentAnimatorStateInfo(0).IsName(PlayerController.DeathStateName),
Is.True);
Assert.That(
stats.GetStackCount(
"pulse.damage-reduction",
CharacterStat.IncomingDamage),
Is.Zero);
Assert.That(artifacts.IsExecutingArtifact, Is.False);
float timeout = Time.realtimeSinceStartup + 3f;
while (!runManager.IsGameOver && Time.realtimeSinceStartup < timeout)
{
yield return null;
}
Assert.That(runManager.IsGameOver, Is.True);
Assert.That(runManager.IsGameOverPresentationActive, Is.False);
Assert.That(Time.timeScale, Is.Zero);
LogAssert.NoUnexpectedReceived();
}
@@ -1678,6 +1882,119 @@ namespace BumpCombat.PlayModeTests
LogAssert.NoUnexpectedReceived();
}
[UnityTest]
public IEnumerator ValidRearHit_ShakesInKnockbackDirectionRefreshesCooldownAndIgnoresDashBack()
{
yield return SceneManager.LoadSceneAsync("CombatPrototype");
yield return null;
var player = Object.FindFirstObjectByType<PlayerController>();
var cameraFollow = Object.FindFirstObjectByType<ArenaCameraFollow>();
Assert.IsNotNull(player);
Assert.IsNotNull(cameraFollow);
var target = new GameObject("RearHitTarget");
var shakeUnit = 1f / 32f;
var firstDirectionOffset = new Vector2(shakeUnit, 0f);
CombatEvents.RaiseValidHit(new CombatHitResult(
player.gameObject,
target,
false,
HitSide.Back,
10f,
Vector2.right,
1f,
new Vector2(0.25f, 0f)));
Assert.That(cameraFollow.ExternalShakeOffset, Is.EqualTo(firstDirectionOffset));
CombatEvents.RaiseValidHit(new CombatHitResult(
player.gameObject,
target,
false,
HitSide.Back,
10f,
Vector2.left,
1f,
new Vector2(0.25f, 0f)));
Assert.That(cameraFollow.ExternalShakeOffset, Is.EqualTo(firstDirectionOffset));
yield return new WaitForSecondsRealtime(0.10f);
Assert.That(cameraFollow.ExternalShakeOffset, Is.EqualTo(Vector2.zero));
yield return new WaitForSecondsRealtime(0.13f);
Assert.That(Time.timeScale, Is.EqualTo(1f));
yield return new WaitForSeconds(0.20f);
CombatEvents.RaiseValidHit(new CombatHitResult(
player.gameObject,
target,
false,
HitSide.Back,
10f,
Vector2.left,
1f,
new Vector2(0.25f, 0f)));
Assert.That(cameraFollow.ExternalShakeOffset, Is.EqualTo(Vector2.zero));
yield return new WaitForSecondsRealtime(0.2f);
yield return new WaitForSeconds(0.15f);
CombatEvents.RaiseValidHit(new CombatHitResult(
player.gameObject,
target,
false,
HitSide.Back,
10f,
Vector2.right,
1f,
new Vector2(0.25f, 0f)));
Assert.That(cameraFollow.ExternalShakeOffset, Is.EqualTo(Vector2.zero));
yield return new WaitForSecondsRealtime(0.2f);
yield return new WaitForSeconds(0.15f);
CombatEvents.RaiseValidHit(new CombatHitResult(
player.gameObject,
target,
false,
HitSide.Back,
10f,
Vector2.left,
1f,
new Vector2(0.25f, 0f)));
Assert.That(cameraFollow.ExternalShakeOffset, Is.EqualTo(Vector2.zero));
yield return new WaitForSecondsRealtime(0.2f);
yield return new WaitForSeconds(0.45f);
CombatEvents.RaiseValidHit(new CombatHitResult(
player.gameObject,
target,
false,
HitSide.Back,
10f,
Vector2.left,
1f,
new Vector2(0.25f, 0f)));
Assert.That(
cameraFollow.ExternalShakeOffset,
Is.EqualTo(new Vector2(-shakeUnit, 0f)));
yield return new WaitForSecondsRealtime(0.2f);
yield return new WaitForSeconds(0.45f);
CombatEvents.RaiseValidHit(new CombatHitResult(
player.gameObject,
target,
true,
HitSide.Back,
10f,
Vector2.right,
1f,
new Vector2(0.25f, 0f)));
Assert.That(cameraFollow.ExternalShakeOffset, Is.EqualTo(Vector2.zero));
yield return new WaitForSecondsRealtime(0.2f);
Object.Destroy(target);
LogAssert.NoUnexpectedReceived();
}
private static IEnumerator AssertAreaArtifactBatchesHitFeedback(
ActiveArtifactEffect expectedEffect,
int selectionCount,
@@ -2345,7 +2662,7 @@ namespace BumpCombat.PlayModeTests
Assert.That(panelRect.anchorMin, Is.EqualTo(new Vector2(0f, 1f)));
Assert.That(panelRect.anchorMax, Is.EqualTo(new Vector2(0f, 1f)));
Assert.That(panelRect.pivot, Is.EqualTo(new Vector2(0f, 1f)));
string[] labels = { "엘리트 출현", "중간보스 출현", "보스 출현" };
string[] labels = { "엘리트 출현", "중간보스 출현", "보스방 이동" };
for (int i = 0; i < buttons.Length; i++)
{
Assert.That(buttons[i].GetComponentInChildren<Text>().text, Is.EqualTo(labels[i]));
@@ -2353,18 +2670,10 @@ namespace BumpCombat.PlayModeTests
}
float elapsed = RunManager.Instance.ElapsedTime;
ClickButtonThroughEventSystem(buttons[2]);
Assert.That(RunManager.Instance.ElapsedTime, Is.EqualTo(elapsed));
Assert.That(director.CanDebugSpawnEventEnemy(RunTimedEvent.FinalBoss), Is.False);
Assert.That(FindEventEnemy(RunTimedEvent.FinalBoss), Is.Not.Null);
EnemyController firstFinalBoss = FindEventEnemy(RunTimedEvent.FinalBoss);
yield return KillEventEnemyThroughGroggy(firstFinalBoss);
Assert.That(director.CanDebugSpawnEventEnemy(RunTimedEvent.FinalBoss), Is.True);
yield return null;
Assert.That(buttons[2].interactable, Is.True);
ClickButtonThroughEventSystem(buttons[2]);
Assert.That(FindEventEnemy(RunTimedEvent.FinalBoss), Is.Not.Null);
Assert.That(RunManager.Instance.ElapsedTime, Is.EqualTo(elapsed));
yield return null;
ClickButtonThroughEventSystem(buttons[0]);
ClickButtonThroughEventSystem(buttons[1]);
@@ -2374,8 +2683,7 @@ namespace BumpCombat.PlayModeTests
Assert.That(director.CanDebugSpawnEventEnemy(RunTimedEvent.Elite), Is.False);
RunManager.ForceProductionModeForTests = true;
EnemyController secondFinalBoss = FindEventEnemy(RunTimedEvent.FinalBoss);
yield return KillEventEnemyThroughGroggy(secondFinalBoss);
yield return null;
Assert.That(panel.activeSelf, Is.False);
Assert.That(director.CanDebugSpawnEventEnemy(RunTimedEvent.FinalBoss), Is.False);
Assert.That(director.TryDebugSpawnEventEnemy(RunTimedEvent.FinalBoss), Is.False);
@@ -2423,12 +2731,12 @@ namespace BumpCombat.PlayModeTests
eventSystem.GetComponent<UnityEngine.InputSystem.UI.InputSystemUIInputModule>();
Assert.That(inputModule, Is.Not.Null);
Assert.That(eventSystem.currentInputModule, Is.SameAs(inputModule));
yield return ClickButtonWithVirtualMouse(buttons[2], mouse, inputModule);
yield return ClickButtonWithVirtualMouse(buttons[0], mouse, inputModule);
Assert.That(
Object.FindAnyObjectByType<SpawnDirector>()
.CanDebugSpawnEventEnemy(RunTimedEvent.FinalBoss),
.CanDebugSpawnEventEnemy(RunTimedEvent.Elite),
Is.False);
Assert.That(FindEventEnemy(RunTimedEvent.FinalBoss), Is.Not.Null);
Assert.That(FindEventEnemy(RunTimedEvent.Elite), Is.Not.Null);
}
finally
{
@@ -141,7 +141,7 @@ namespace BumpCombat.PlayModeTests
}
[UnityTest]
public IEnumerator EnemyStatusVisual_LoadsBothSheetsAndSharesGroundAnchor()
public IEnumerator EnemyStatusVisual_LoadsBothSheetsAndFollowsRenderedAnchor()
{
yield return LoadCombatPrototype();
EnemyController enemy = FindOrSpawnEnemy();
@@ -151,7 +151,7 @@ namespace BumpCombat.PlayModeTests
Assert.That(source == null, Is.False);
enemy.transform.localScale = Vector3.one * 0.8f;
source.flipX = true;
source.flipY = false;
source.flipY = true;
Assert.That(enemy.ApplyIgnite(3f, 0.5f, 0.01f), Is.True);
Assert.That(enemy.ApplyShock(3f, 0.2f), Is.True);
@@ -172,15 +172,18 @@ namespace BumpCombat.PlayModeTests
Assert.That(ignite.sprite.pixelsPerUnit, Is.EqualTo(32f).Within(0.01f));
Assert.That(shock.sprite.pixelsPerUnit, Is.EqualTo(32f).Within(0.01f));
Vector3 expectedStatusPosition = ExpectedStatusPosition(enemy, source);
Assert.That(
Vector3.Distance(
ignite.transform.position,
(Vector3)enemy.GroundAnchorPosition),
ignite.transform.parent,
Is.EqualTo(GetCurrentVisualTransform(enemy)));
Assert.That(
shock.transform.parent,
Is.EqualTo(GetCurrentVisualTransform(enemy)));
Assert.That(
Vector3.Distance(ignite.transform.position, expectedStatusPosition),
Is.LessThan(0.0001f));
Assert.That(
Vector3.Distance(
shock.transform.position,
(Vector3)enemy.GroundAnchorPosition),
Vector3.Distance(shock.transform.position, expectedStatusPosition),
Is.LessThan(0.0001f));
Assert.That(ignite.flipX, Is.EqualTo(source.flipX));
Assert.That(shock.flipX, Is.EqualTo(source.flipX));
@@ -203,6 +206,122 @@ namespace BumpCombat.PlayModeTests
Assert.That(enemy.transform.Find("Stun Runes") == null, Is.False);
}
[UnityTest]
public IEnumerator EnemyStatusVisual_UsesRenderedTransformWhenPhysicsPositionDiffers()
{
yield return LoadCombatPrototype();
EnemyController enemy = FindOrSpawnEnemy();
Assert.That(enemy, Is.Not.Null);
Rigidbody2D body = enemy.GetComponent<Rigidbody2D>();
SpriteRenderer source = enemy.GetComponent<SpriteRenderer>();
Assert.That(body, Is.Not.Null);
Assert.That(source, Is.Not.Null);
Time.timeScale = 0f;
body.simulated = false;
Vector2 physicalPosition = (Vector2)enemy.transform.position
+ Vector2.down * 3f;
Vector3 renderedPosition = enemy.transform.position
+ Vector3.up * 1.5f;
body.position = physicalPosition;
enemy.transform.position = renderedPosition;
Assert.That(
Vector2.Distance(body.position, enemy.transform.position),
Is.GreaterThan(1f));
Assert.That(enemy.ApplyIgnite(3f, 0.5f, 0.01f), Is.True);
ArtifactStatusVisual statusVisual =
enemy.GetComponent<ArtifactStatusVisual>();
Assert.That(statusVisual, Is.Not.Null);
statusVisual.SendMessage(
"LateUpdate",
SendMessageOptions.RequireReceiver);
SpriteRenderer ignite = FindChildRenderer(enemy, IgniteVisualName);
Assert.That(ignite, Is.Not.Null);
Assert.That(
Vector3.Distance(
ignite.transform.position,
ExpectedStatusPosition(enemy, source)),
Is.LessThan(0.0001f));
Assert.That(
Vector2.Distance(ignite.transform.position, body.position),
Is.GreaterThan(0.5f));
body.simulated = true;
}
[UnityTest]
public IEnumerator EnemyStatusVisual_FollowsLaunchAndFogVisibility()
{
yield return LoadCombatPrototype();
EnemyController enemy = FindOrSpawnEnemy();
Assert.That(enemy, Is.Not.Null);
SpriteRenderer source = enemy.GetComponent<SpriteRenderer>();
Assert.That(source, Is.Not.Null);
Assert.That(enemy.ApplyIgnite(3f, 0.5f, 0.01f), Is.True);
yield return null;
SpriteRenderer ignite = FindChildRenderer(enemy, IgniteVisualName);
Assert.That(ignite, Is.Not.Null);
enemy.PlayLaunchVisual(Vector2.right, 0.8f, 0.4f);
yield return null;
Transform launchVisual = GetCurrentVisualTransform(enemy);
Assert.That(enemy.IsLaunchVisualActive, Is.True);
Assert.That(launchVisual, Is.Not.EqualTo(enemy.transform));
Assert.That(ignite.transform.parent, Is.EqualTo(launchVisual));
Assert.That(
Vector3.Distance(
ignite.transform.position,
ExpectedStatusPosition(enemy, source)),
Is.LessThan(0.0001f));
enemy.SetFogVisibility(false);
yield return null;
Assert.That(ignite.forceRenderingOff, Is.True);
SpriteRenderer airborneRenderer =
launchVisual.GetComponent<SpriteRenderer>();
Assert.That(airborneRenderer, Is.Not.Null);
Assert.That(airborneRenderer.forceRenderingOff, Is.True);
Assert.That(enemy.ApplyShock(3f, 0.2f), Is.True);
yield return null;
SpriteRenderer shock = FindChildRenderer(enemy, ShockVisualName);
Assert.That(shock, Is.Not.Null);
Assert.That(shock.forceRenderingOff, Is.True);
enemy.SetFogVisibility(true);
yield return null;
Assert.That(ignite.forceRenderingOff, Is.False);
Assert.That(shock.forceRenderingOff, Is.False);
Assert.That(airborneRenderer.forceRenderingOff, Is.False);
yield return new WaitForSeconds(0.45f);
yield return null;
Assert.That(enemy.IsLaunchVisualActive, Is.False);
Assert.That(enemy.IsIgnited, Is.True);
SpriteRenderer landedIgnite =
FindChildRenderer(enemy, IgniteVisualName);
Assert.That(landedIgnite, Is.Not.Null);
Assert.That(
landedIgnite.transform.parent,
Is.EqualTo(enemy.transform));
Assert.That(
Vector3.Distance(
landedIgnite.transform.position,
ExpectedStatusPosition(enemy, source)),
Is.LessThan(0.0001f));
}
[UnityTest]
public IEnumerator EnemyStatusVisual_FreezesDuringPauseAndAdvancesAfterResume()
{
@@ -438,6 +557,26 @@ namespace BumpCombat.PlayModeTests
return null;
}
private static Transform GetCurrentVisualTransform(EnemyController enemy)
{
Transform airborneVisual =
enemy.transform.Find("Airborne Enemy Visual");
return airborneVisual != null
? airborneVisual
: enemy.transform;
}
private static Vector3 ExpectedStatusPosition(
EnemyController enemy,
SpriteRenderer source)
{
float anchorOffsetY = source.flipY
? 13f / 32f
: -13f / 32f;
return GetCurrentVisualTransform(enemy).TransformPoint(
new Vector3(0f, anchorOffsetY, 0f));
}
private static SpriteRenderer FindChildRenderer(
EnemyController enemy,
string objectName)
@@ -2,11 +2,13 @@ using System.Collections;
using System.Collections.Generic;
using System.Reflection;
using BumpCombat.Combat;
using BumpCombat.Constants;
using BumpCombat.Core;
using BumpCombat.Enemies;
using BumpCombat.Player;
using BumpCombat.Progression;
using BumpCombat.Spawning;
using BumpCombat.Stage;
using BumpCombat.UI;
using NUnit.Framework;
using UnityEngine;
@@ -676,8 +678,7 @@ namespace BumpCombat.PlayModeTests
AssertRect(eventText.rectTransform, new Vector2(0.5f, 1f),
new Vector2(0.5f, 1f), new Vector2(0f, -100f),
new Vector2(420f, 28f));
Assert.That(eventText.gameObject.activeSelf, Is.True);
Assert.That(eventText.text, Does.Contain("다음"));
Assert.That(eventText.gameObject.activeSelf, Is.False);
Assert.That(debug.gameObject.activeSelf, Is.True);
AssertRect(gauge, new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f),
new Vector2(0f, -24f), new Vector2(352f, 18f));
@@ -780,25 +781,13 @@ namespace BumpCombat.PlayModeTests
gridSnapping.GetValue(pixelPerfect).ToString(),
Is.EqualTo("PixelSnapping"));
RenderTexture target = new(1399, 799, 24, RenderTextureFormat.ARGB32);
target.Create();
RenderTexture previousTarget = camera.targetTexture;
camera.targetTexture = target;
RunConstants stageConstants = GameplayConstants.Current.Run;
try
{
camera.Render();
yield return null;
Rect pixelRect = camera.pixelRect;
Assert.That(pixelRect.width, Is.EqualTo(1398f).Within(0.01f));
Assert.That(pixelRect.height, Is.EqualTo(798f).Within(0.01f));
Assert.That(pixelRect.x, Is.EqualTo(0f).Within(0.01f));
Assert.That(pixelRect.y, Is.EqualTo(0f).Within(0.01f));
Assert.That(pixelRect.width % 2f, Is.EqualTo(0f).Within(0.01f));
Assert.That(pixelRect.height % 2f, Is.EqualTo(0f).Within(0.01f));
Assert.That(camera.aspect, Is.EqualTo(1398f / 798f).Within(0.001f));
Assert.That(camera.orthographicSize, Is.EqualTo(6.234375f).Within(0.01f));
PropertyInfo assetsPpu = pixelPerfect.GetType().GetProperty("assetsPPU");
PropertyInfo referenceWidth = pixelPerfect.GetType().GetProperty("refResolutionX");
PropertyInfo referenceHeight = pixelPerfect.GetType().GetProperty("refResolutionY");
FieldInfo internalField = pixelPerfect.GetType().GetField(
"m_Internal",
BindingFlags.Instance | BindingFlags.NonPublic);
@@ -812,31 +801,66 @@ namespace BumpCombat.PlayModeTests
FieldInfo useOffscreenField = internalCamera.GetType().GetField(
"useOffscreenRT",
BindingFlags.Instance | BindingFlags.NonPublic);
Assert.That(widthField.GetValue(internalCamera), Is.EqualTo(0));
Assert.That(heightField.GetValue(internalCamera), Is.EqualTo(0));
Assert.That(useOffscreenField.GetValue(internalCamera), Is.False);
int[,] targetSizes = { { 1920, 1080 }, { 1280, 720 }, { 1399, 799 } };
for (int i = 0; i < targetSizes.GetLength(0); i++)
{
RenderTexture target = new(
targetSizes[i, 0],
targetSizes[i, 1],
24,
RenderTextureFormat.ARGB32);
target.Create();
camera.targetTexture = target;
try
{
camera.Render();
yield return null;
float correctedOrthoSize = camera.orthographicSize;
camera.Render();
Assert.That(camera.pixelRect.width, Is.EqualTo(1398f).Within(0.01f));
Assert.That(camera.pixelRect.height, Is.EqualTo(798f).Within(0.01f));
Assert.That(camera.orthographicSize,
Is.EqualTo(correctedOrthoSize).Within(0.01f));
int targetWidth = targetSizes[i, 0];
int targetHeight = targetSizes[i, 1];
int expectedWidth = targetWidth & ~1;
int expectedHeight = targetHeight & ~1;
Rect pixelRect = camera.pixelRect;
Assert.That(pixelRect.width, Is.EqualTo(expectedWidth).Within(0.01f));
Assert.That(pixelRect.height, Is.EqualTo(expectedHeight).Within(0.01f));
Assert.That(pixelRect.x, Is.EqualTo(0f).Within(0.01f));
Assert.That(pixelRect.y, Is.EqualTo(0f).Within(0.01f));
Assert.That(camera.aspect,
Is.EqualTo(expectedWidth / (float)expectedHeight).Within(0.001f));
Assert.That(camera.orthographicSize,
Is.EqualTo(stageConstants.StageCameraViewHalfExtents.y
* expectedHeight / targetHeight).Within(0.01f));
Assert.That(assetsPpu.GetValue(pixelPerfect), Is.EqualTo(32));
Assert.That(
referenceWidth.GetValue(pixelPerfect),
Is.EqualTo(Mathf.RoundToInt(
stageConstants.StageCameraViewHalfExtents.x * 2f * 32f)));
Assert.That(
referenceHeight.GetValue(pixelPerfect),
Is.EqualTo(Mathf.RoundToInt(
stageConstants.StageCameraViewHalfExtents.y * 2f * 32f)));
Assert.That(widthField.GetValue(internalCamera), Is.EqualTo(0));
Assert.That(heightField.GetValue(internalCamera), Is.EqualTo(0));
Assert.That(useOffscreenField.GetValue(internalCamera), Is.False);
ArenaCameraFollow follow = camera.GetComponent<ArenaCameraFollow>();
bool previousFollowEnabled = follow.enabled;
follow.enabled = false;
camera.Render();
follow.enabled = previousFollowEnabled;
camera.Render();
Assert.That(camera.pixelRect.width, Is.EqualTo(1398f).Within(0.01f));
Assert.That(camera.pixelRect.height, Is.EqualTo(798f).Within(0.01f));
camera.Render();
Assert.That(camera.pixelRect.width, Is.EqualTo(expectedWidth).Within(0.01f));
Assert.That(camera.pixelRect.height, Is.EqualTo(expectedHeight).Within(0.01f));
Assert.That(camera.orthographicSize,
Is.EqualTo(stageConstants.StageCameraViewHalfExtents.y
* expectedHeight / targetHeight).Within(0.01f));
}
finally
{
camera.targetTexture = previousTarget;
target.Release();
Object.Destroy(target);
}
}
}
finally
{
camera.targetTexture = previousTarget;
target.Release();
Object.Destroy(target);
}
}
@@ -846,10 +870,14 @@ namespace BumpCombat.PlayModeTests
yield return LoadCombatPrototype();
RunHUD hud = Object.FindAnyObjectByType<RunHUD>();
StageManager stageManager = StageManager.Instance;
SpawnDirector director = Object.FindAnyObjectByType<SpawnDirector>();
PlayerController player = Object.FindAnyObjectByType<PlayerController>();
ArenaBounds arena = ArenaBounds.Resolve();
Assert.That(hud, Is.Not.Null);
Assert.That(stageManager, Is.Not.Null);
Assert.That(stageManager.CurrentZone, Is.Not.Null);
Assert.That(stageManager.CurrentZone.Preset, Is.Not.Null);
Assert.That(arena, Is.Not.Null);
director.enabled = false;
@@ -867,6 +895,28 @@ namespace BumpCombat.PlayModeTests
Time.timeScale = 0f;
yield return new WaitForSecondsRealtime(0.2f);
Camera worldCamera = Camera.main;
EnemyController eventEnemy = null;
EnemyController[] activeEnemies = Object.FindObjectsByType<EnemyController>(
FindObjectsInactive.Exclude,
FindObjectsSortMode.None);
for (int i = 0; i < activeEnemies.Length; i++)
{
if (activeEnemies[i].IsEventEnemy)
{
eventEnemy = activeEnemies[i];
break;
}
}
Assert.That(eventEnemy, Is.Not.Null);
ZoneInstance visibilityZone = stageManager.CurrentZone;
Vector2 onScreenPosition = player.transform.position;
visibilityZone.UpdateVisibility(onScreenPosition);
eventEnemy.GetComponent<Rigidbody2D>().position = onScreenPosition;
eventEnemy.transform.position = onScreenPosition;
Physics2D.SyncTransforms();
yield return new WaitForSecondsRealtime(0.12f);
GameObject minimap = GameObject.Find("Minimap");
Image playerMarker = GameObject.Find("Minimap Player")
.GetComponent<Image>();
@@ -884,7 +934,73 @@ namespace BumpCombat.PlayModeTests
}
RectTransform viewport = GameObject.Find("Minimap Camera Viewport")
.GetComponent<RectTransform>();
Transform terrain = GameObject.Find("Minimap Terrain")?.transform;
Assert.That(minimap.activeSelf, Is.True);
Assert.That(terrain, Is.Not.Null);
int expectedTerrainCells = 0;
RoomPreset currentPreset = stageManager.CurrentZone.Preset;
for (int y = 0; y < currentPreset.GridHeight; y++)
{
for (int x = 0; x < currentPreset.GridWidth; x++)
{
PresetCellType cellType = stageManager.CurrentZone.GetRuntimeCell(x, y);
if (cellType != PresetCellType.Empty)
{
expectedTerrainCells++;
}
}
}
Assert.That(terrain.childCount, Is.EqualTo(expectedTerrainCells));
for (int i = 0; i < currentPreset.Sockets.Count; i++)
{
RoomPresetSocket socket = currentPreset.Sockets[i];
for (int offset = 0; offset < socket.Width; offset++)
{
Vector2Int socketCell = socket.CellPosition;
if (socket.Direction == StageDirection.North ||
socket.Direction == StageDirection.South)
{
socketCell.x += offset;
}
else
{
socketCell.y += offset;
}
bool isActive = stageManager.CurrentZone.GetRuntimeCell(socketCell.x, socketCell.y)
== PresetCellType.Socket;
Image terrainCell = terrain.Find($"Terrain {socketCell.x},{socketCell.y}")
?.GetComponent<Image>();
if (isActive)
{
Assert.That(terrainCell, Is.Not.Null, socket.Id);
Color32 terrainColor = terrainCell.color;
if (visibilityZone.IsCellVisible(socketCell.x, socketCell.y))
{
Assert.That(
terrainColor.Equals(new Color32(54, 103, 112, 230)),
Is.True,
socket.Id);
}
else if (visibilityZone.IsCellExplored(socketCell.x, socketCell.y))
{
Assert.That(terrainColor, Is.Not.EqualTo(
new Color32(54, 103, 112, 230)), socket.Id);
Assert.That(terrainColor.a, Is.EqualTo(255), socket.Id);
}
else
{
Assert.That(terrainColor, Is.EqualTo(new Color32(2, 4, 5, 255)),
socket.Id);
}
}
else
{
Assert.That(terrainCell, Is.Null, socket.Id);
}
}
}
Assert.That(
((Color32)playerMarker.color).Equals(
new Color32(106, 236, 255, 255)),
@@ -898,6 +1014,68 @@ namespace BumpCombat.PlayModeTests
Assert.That(viewport.rect.width, Is.GreaterThan(0f));
Assert.That(viewport.rect.height, Is.GreaterThan(0f));
Vector2 nearEdgePosition = onScreenPosition;
bool foundVisiblePosition = false;
for (int y = 0; y < currentPreset.GridHeight && !foundVisiblePosition; y++)
{
for (int x = 0; x < currentPreset.GridWidth; x++)
{
if (!visibilityZone.IsCellVisible(x, y))
{
continue;
}
Vector2 candidate = currentPreset.CellToWorldPosition(
visibilityZone.ZoneCenter,
new Vector2Int(x, y));
if (Vector2.Distance(candidate, onScreenPosition) > 0.5f
&& arena.IsInsideCameraVisible(worldCamera, candidate))
{
nearEdgePosition = candidate;
foundVisiblePosition = true;
break;
}
}
}
Assert.That(foundVisiblePosition, Is.True);
eventEnemy.GetComponent<Rigidbody2D>().position = nearEdgePosition;
eventEnemy.transform.position = nearEdgePosition;
Physics2D.SyncTransforms();
yield return new WaitForSecondsRealtime(0.12f);
Assert.That(visibilityZone.IsWorldPositionVisible(nearEdgePosition), Is.True);
Assert.That(eventMarker.gameObject.activeSelf, Is.True, "An enemy inside the current camera view should have a marker");
Vector2 outsidePosition = onScreenPosition + Vector2.right * 6.5f;
bool foundHiddenPosition = false;
for (int y = 0; y < currentPreset.GridHeight && !foundHiddenPosition; y++)
{
for (int x = 0; x < currentPreset.GridWidth; x++)
{
if (visibilityZone.IsCellVisible(x, y))
{
continue;
}
Vector2 candidate = currentPreset.CellToWorldPosition(
visibilityZone.ZoneCenter,
new Vector2Int(x, y));
if (arena.IsInsideCameraVisible(worldCamera, candidate))
{
outsidePosition = candidate;
foundHiddenPosition = true;
break;
}
}
}
eventEnemy.GetComponent<Rigidbody2D>().position = outsidePosition;
eventEnemy.transform.position = outsidePosition;
Physics2D.SyncTransforms();
yield return new WaitForSecondsRealtime(0.12f);
Assert.That(visibilityZone.IsWorldPositionVisible(outsidePosition), Is.False);
Assert.That(eventMarker.gameObject.activeSelf, Is.False, "An enemy outside the current camera view should not have a marker");
Vector2 oldMarkerPosition = playerMarker.rectTransform.anchoredPosition;
Vector2 movedPosition = arena.ClampPlayerPosition(new Vector2(6f, 2f));
player.GetComponent<Rigidbody2D>().position = movedPosition;
@@ -0,0 +1,164 @@
using System.Collections;
using BumpCombat.Core;
using BumpCombat.Player;
using BumpCombat.Stage;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.TestTools;
namespace BumpCombat.PlayModeTests
{
public sealed class ZoneArrivalPlayModeTests
{
private GameObject arenaObject;
private GameObject playerObject;
private GameObject stageObject;
private RunManager existingRunManager;
private bool existingRunManagerWasEnabled;
private float previousTimeScale;
[SetUp]
public void SetUp()
{
previousTimeScale = Time.timeScale;
existingRunManager = Object.FindAnyObjectByType<RunManager>();
if (existingRunManager != null)
{
existingRunManagerWasEnabled = existingRunManager.enabled;
existingRunManager.enabled = false;
}
Time.timeScale = 1f;
}
[TearDown]
public void TearDown()
{
if (stageObject != null)
{
Object.Destroy(stageObject);
}
if (playerObject != null)
{
Object.Destroy(playerObject);
}
if (arenaObject != null)
{
Object.Destroy(arenaObject);
}
if (existingRunManager != null)
{
existingRunManager.enabled = existingRunManagerWasEnabled;
}
Time.timeScale = previousTimeScale;
}
[Timeout(60000)]
[UnityTest]
public IEnumerator ArrivalDoor_RejectsImmediateReverseAndRetriesOnStayAfterCooldown()
{
arenaObject = new GameObject("ArrivalPlayMode_ArenaBounds");
arenaObject.AddComponent<ArenaBounds>();
playerObject = new GameObject("ArrivalPlayMode_Player");
PlayerController player = playerObject.AddComponent<PlayerController>();
CircleCollider2D playerBody = playerObject.AddComponent<CircleCollider2D>();
playerBody.radius = 0.22f;
playerBody.offset = new Vector2(0.11f, -0.07f);
Rigidbody2D rigidbody = playerObject.GetComponent<Rigidbody2D>();
rigidbody.bodyType = RigidbodyType2D.Kinematic;
rigidbody.gravityScale = 0f;
rigidbody.constraints = RigidbodyConstraints2D.FreezeRotation;
playerObject.GetComponent<Animator>().enabled = false;
player.enabled = false;
stageObject = new GameObject("ArrivalPlayMode_StageManager");
StageManager stageManager = stageObject.AddComponent<StageManager>();
stageManager.StartRun(4242);
// StageManager.Start also builds its autonomous test stage. Let
// that lifecycle pass before taking connector references.
yield return null;
ZoneInstance startZone = stageManager.CurrentZone;
Assert.That(startZone, Is.Not.Null);
ZoneConnector sourceConnector = FindOutgoingConnector(stageManager);
Assert.That(sourceConnector, Is.Not.Null);
yield return new WaitForSecondsRealtime(0.4f);
MoveBodyCenter(player, playerBody, sourceConnector.GetComponent<BoxCollider2D>().bounds.center);
yield return new WaitForFixedUpdate();
int targetNodeId = sourceConnector.TargetNodeId;
float transitionDeadline = Time.realtimeSinceStartup + 2f;
while ((stageManager.IsTransitioning
|| stageManager.CurrentZone == null
|| stageManager.CurrentZone.NodeId != targetNodeId)
&& Time.realtimeSinceStartup < transitionDeadline)
{
yield return null;
}
Assert.That(stageManager.CurrentZone.NodeId, Is.EqualTo(targetNodeId));
Assert.That(stageManager.IsTransitioning, Is.False);
ZoneConnector arrivalConnector = stageManager.CurrentZone.GetConnector(sourceConnector.TargetSocketId);
Assert.That(arrivalConnector, Is.Not.Null);
arrivalConnector.SetLocked(false);
BoxCollider2D arrivalTrigger = arrivalConnector.GetComponent<BoxCollider2D>();
Assert.That(arrivalTrigger.bounds.Intersects(playerBody.bounds), Is.False);
// Enter the destination trigger immediately after transition. The
// trigger stay must be ignored until the manager cooldown expires.
MoveBodyCenter(player, playerBody, arrivalTrigger.bounds.center);
yield return new WaitForFixedUpdate();
yield return new WaitForSecondsRealtime(0.1f);
Assert.That(stageManager.CurrentZone.NodeId, Is.EqualTo(targetNodeId));
float returnDeadline = Time.realtimeSinceStartup + 2f;
while ((stageManager.IsTransitioning
|| stageManager.CurrentZone == null
|| stageManager.CurrentZone.NodeId != startZone.NodeId)
&& Time.realtimeSinceStartup < returnDeadline)
{
yield return null;
}
Assert.That(stageManager.CurrentZone.NodeId, Is.EqualTo(startZone.NodeId));
// The return arrival is also outside its doorway. Leaving the
// player there must not produce an automatic pingpong transition.
yield return new WaitForSecondsRealtime(0.45f);
Assert.That(stageManager.CurrentZone.NodeId, Is.EqualTo(startZone.NodeId));
}
private static ZoneConnector FindOutgoingConnector(StageManager stageManager)
{
for (int direction = 0; direction < 4; direction++)
{
ZoneConnector connector = stageManager.CurrentZone.GetConnector((StageDirection)direction);
if (connector != null && connector.TargetNodeId >= 0)
{
return connector;
}
}
return null;
}
private static void MoveBodyCenter(
PlayerController player,
Collider2D playerBody,
Vector2 bodyCenter)
{
Vector2 bodyOffset = (Vector2)playerBody.bounds.center
- (Vector2)player.transform.position;
player.transform.position = bodyCenter - bodyOffset;
Physics2D.SyncTransforms();
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7a4d1e6f9b824c1aa5e0d3f7b6c29841