convert game playing..
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user