Files
tiny_tackle_heroes/Assets/_Project/Tests/PlayMode/Defense25DMonsterRosterPlayModeTests.cs
T
2026-09-28 11:07:17 +09:00

505 lines
29 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using BumpCombat.Constants;
using BumpCombat.Defense;
using BumpCombat.Defense.Comparison;
using BumpCombat.Progression;
using NUnit.Framework;
using UnityEditor;
using UnityEngine;
using UnityEngine.TestTools;
using Object = UnityEngine.Object;
namespace BumpCombat.Tests
{
public sealed class Defense25DMonsterRosterPlayModeTests
{
private const string SettingsPath = "Assets/_Project/Constants/Defense25DComparison/ComparisonSettings.asset";
private const string RunPath = "Assets/_Project/Constants/Defense25DComparison/LongDefenseRun.asset";
private Defense25DComparisonSettings settings;
private Defense25DRunDefinition runDefinition;
private Defense25DComparison world;
private float previousTimeScale;
private SimulationMode previousPhysicsSimulationMode;
private readonly List<GameObject> geometryFixtures = new();
[UnitySetUp]
public System.Collections.IEnumerator SetUp()
{
previousTimeScale = Time.timeScale;
previousPhysicsSimulationMode = Physics.simulationMode;
Physics.simulationMode = SimulationMode.Script;
Time.timeScale = 1f;
settings = Object.Instantiate(AssetDatabase.LoadAssetAtPath<Defense25DComparisonSettings>(SettingsPath));
runDefinition = Object.Instantiate(AssetDatabase.LoadAssetAtPath<Defense25DRunDefinition>(RunPath));
Assert.That(settings, Is.Not.Null);
Assert.That(runDefinition, Is.Not.Null);
world = new GameObject("Full monster roster lifecycle under test").AddComponent<Defense25DComparison>();
world.InitializeLongRun(settings, runDefinition);
world.AutoSpawn = false;
world.ReadInput = false;
world.SetPaused(false);
Time.timeScale = 0f;
world.AdvanceRunClock(runDefinition.PreparationSeconds);
Assert.That(world.RunPhase, Is.EqualTo(Defense25DRunPhase.Running));
yield return null;
}
[UnityTearDown]
public System.Collections.IEnumerator TearDown()
{
foreach (GameObject fixture in geometryFixtures)
if (fixture != null) Object.Destroy(fixture);
geometryFixtures.Clear();
if (world != null) Object.Destroy(world.gameObject);
if (settings != null) Object.Destroy(settings);
if (runDefinition != null) Object.Destroy(runDefinition);
Time.timeScale = previousTimeScale;
Physics.simulationMode = previousPhysicsSimulationMode;
yield return null;
}
[UnityTest]
public System.Collections.IEnumerator AllTwentyEightPoolSpeciesUseFactoryMovementCombatResolutionAndRestart()
{
Defense25DMonsterPoolDefinition pool = runDefinition.MonsterPoolDefinition;
Assert.That(pool, Is.Not.Null);
Assert.That(pool.TryValidate(out string poolError), Is.True, poolError);
Assert.That(runDefinition.MonsterSourceMotionCatalog.TryValidateAgainst(pool, out string motionError),
Is.True, motionError);
Defense25DMonsterPoolEntry[] entries = pool.Tiers.SelectMany(tier => tier.Entries).ToArray();
Assert.That(entries, Has.Length.EqualTo(28));
Assert.That(entries.Select(entry => entry.EnemyDefinitionId).Distinct().Count(), Is.EqualTo(28));
List<string> failures = new();
int arrivalIndex = entries.Length - 1;
int firstGeneration = -1;
for (int index = 0; index < entries.Length; index++)
{
Defense25DMonsterPoolEntry entry = entries[index];
try
{
world.ResetRun();
world.AdvanceRunClock(runDefinition.PreparationSeconds);
Assert.That(world.RunPhase, Is.EqualTo(Defense25DRunPhase.Running));
int generation = world.RunGeneration;
if (index == 0) firstGeneration = generation;
int portalId = index % 2;
int enemiesBeforeSpawn = world.Enemies.Count;
int maxSpawnIdBefore = world.Enemies
.Where(candidate => candidate.SpawnContext.RunGeneration == generation)
.Select(candidate => candidate.SpawnContext.SpawnId).DefaultIfEmpty(-1).Max();
int suppliedBeforeSpawn = world.Ledger.SuppliedCount;
int resolvedBeforeSpawn = world.Ledger.ResolvedCount;
world.SpawnScheduledEnemy(CreateNormalSpawnRequest(entry, generation, portalId, index));
Assert.That(world.Enemies.Count, Is.InRange(enemiesBeforeSpawn, enemiesBeforeSpawn + 1),
$"{entry.EnemyDefinitionId} factory either dropped a spawn or created more than one actor.");
Defense25DEnemy enemy = world.Enemies.Single(candidate =>
candidate.SpawnContext.RunGeneration == generation &&
candidate.SpawnContext.SpawnId > maxSpawnIdBefore);
Assert.That(enemy.IsAvailable, Is.True, $"{entry.EnemyDefinitionId} did not become available.");
Assert.That(enemy.SpawnContext.Source, Is.EqualTo(Defense25DSpawnSource.Normal));
Assert.That(enemy.LongRunDefinition, Is.SameAs(entry.Definition));
Assert.That(enemy.MonsterCombat, Is.Not.Null);
Assert.That(enemy.MonsterCombat.IsBound, Is.True);
Assert.That(enemy.MonsterCombat.EnemyDefinitionId, Is.EqualTo(entry.EnemyDefinitionId));
Assert.That(enemy.MonsterCombat.SourceCharacterId, Is.EqualTo(entry.SourceCharacterId));
Assert.That(enemy.MonsterCombat.AttackAction, Is.EqualTo(entry.CombatProfile.AttackAction));
Assert.That(enemy.MonsterCombat.CurrentSourceSprite, Is.Not.Null,
$"{entry.EnemyDefinitionId} did not bind its exact authored source frames.");
Vector2 start = enemy.Actor.Position;
bool movedAlongRoute = false;
for (int step = 0; step < 24 && !movedAlongRoute; step++)
{
enemy.Step(.1f);
Physics.SyncTransforms();
Physics.Simulate(.02f);
Physics.SyncTransforms();
movedAlongRoute = Vector2.Distance(start, enemy.Actor.Position) > .01f
|| enemy.WaypointIndex > 1;
}
Assert.That(movedAlongRoute, Is.True,
$"{entry.EnemyDefinitionId} did not move along portal {portalId}'s authored route.");
world.Player.ResetAt(enemy.Actor.Position + Vector2.right * .05f);
Physics.SyncTransforms();
int playerDamageEvents = world.Player.DamageEvents;
for (int step = 0; step < 600 && world.Player.DamageEvents == playerDamageEvents; step++)
{
enemy.Step(.05f);
Physics.SyncTransforms();
}
Assert.That(world.Player.DamageEvents, Is.GreaterThan(playerDamageEvents),
$"{entry.EnemyDefinitionId} did not complete its authored attack against the player.");
float healthBeforeHit = enemy.Actor.CurrentHealth;
Assert.That(enemy.Actor.TakeDamage(1f, Vector2.zero), Is.True,
$"{entry.EnemyDefinitionId} did not accept an actual actor hit.");
Assert.That(enemy.Actor.CurrentHealth, Is.LessThan(healthBeforeHit));
float baseHealthBeforeResolution = world.BaseHealth;
if (index == arrivalIndex)
{
world.ResolveEnemy(enemy, DefenseEnemyResolution.Arrival);
Assert.That(world.BaseHealth, Is.LessThan(baseHealthBeforeResolution),
$"{entry.EnemyDefinitionId} arrival did not damage the shared base.");
}
else
{
Assert.That(enemy.Actor.TakeDamage(enemy.Actor.CurrentHealth + 1f, Vector2.zero), Is.True,
$"{entry.EnemyDefinitionId} did not accept a lethal actor hit.");
}
Assert.That(enemy.IsAvailable, Is.False,
$"{entry.EnemyDefinitionId} remained available after its terminal resolution.");
Assert.That(world.Ledger.SuppliedCount, Is.EqualTo(suppliedBeforeSpawn + 1));
Assert.That(world.Ledger.ResolvedCount, Is.EqualTo(resolvedBeforeSpawn + 1));
}
catch (Exception exception)
{
failures.Add($"{entry.EnemyDefinitionId}: {exception.GetType().Name}: {exception.Message}");
}
}
try
{
world.ResetRun();
world.AdvanceRunClock(runDefinition.PreparationSeconds);
Defense25DMonsterPoolEntry firstEntry = entries[0];
int restartedGeneration = world.RunGeneration;
Assert.That(restartedGeneration, Is.GreaterThan(firstGeneration));
Defense25DEnemy firstScheduled = world.Enemies.Single(enemy =>
enemy.SpawnContext.RunGeneration == restartedGeneration && enemy.SpawnContext.SpawnId == 0);
Assert.That(firstScheduled.SpawnContext.RunGeneration, Is.EqualTo(restartedGeneration));
int enemiesBeforeRestartedManualSpawn = world.Enemies.Count;
int maxRestartedSpawnIdBefore = world.Enemies
.Where(candidate => candidate.SpawnContext.RunGeneration == restartedGeneration)
.Select(candidate => candidate.SpawnContext.SpawnId).DefaultIfEmpty(-1).Max();
int suppliedBeforeRestartedManualSpawn = world.Ledger.SuppliedCount;
world.SpawnScheduledEnemy(CreateNormalSpawnRequest(firstEntry, restartedGeneration, 0, 0));
Assert.That(world.Enemies.Count,
Is.InRange(enemiesBeforeRestartedManualSpawn, enemiesBeforeRestartedManualSpawn + 1));
Defense25DEnemy restarted = world.Enemies.Single(candidate =>
candidate.SpawnContext.RunGeneration == restartedGeneration &&
candidate.SpawnContext.SpawnId > maxRestartedSpawnIdBefore);
Assert.That(restarted.SpawnContext.RunGeneration, Is.EqualTo(restartedGeneration));
Assert.That(firstScheduled.SpawnContext.SpawnId, Is.Zero,
"Reset clears dynamic run actors and restarts the factory spawn ID for the new generation.");
Assert.That(restarted.SpawnContext.SpawnId, Is.GreaterThan(firstScheduled.SpawnContext.SpawnId));
Assert.That(restarted.MonsterCombat.IsBound, Is.True);
Assert.That(restarted.MonsterCombat.EnemyDefinitionId, Is.EqualTo(firstEntry.EnemyDefinitionId));
Assert.That(world.Ledger.SuppliedCount, Is.EqualTo(suppliedBeforeRestartedManualSpawn + 1));
}
catch (Exception exception)
{
failures.Add($"restart: {exception.GetType().Name}: {exception.Message}");
}
yield return null;
Assert.That(failures, Is.Empty, string.Join("\n", failures));
}
[UnityTest]
public System.Collections.IEnumerator FactoryReusesPoolEnemyInstanceAfterArrivalAndDeathRemoval()
{
Defense25DMonsterPoolEntry entry = runDefinition.MonsterPoolDefinition.Tiers[0].Entries[0];
Defense25DEnemy first = SpawnManualPoolEntry(entry, 1);
int firstInstanceId = first.gameObject.GetInstanceID();
world.ResolveEnemy(first, DefenseEnemyResolution.Arrival);
Assert.That(first.gameObject.activeSelf, Is.False);
Defense25DEnemy afterArrival = SpawnManualPoolEntry(entry, 2);
Assert.That(afterArrival, Is.SameAs(first), "Arrival should return this exact species instance to its pool.");
Assert.That(afterArrival.gameObject.GetInstanceID(), Is.EqualTo(firstInstanceId));
Assert.That(afterArrival.Actor.TakeDamage(afterArrival.Actor.CurrentHealth + 1f, Vector2.zero), Is.True);
Assert.That(afterArrival.IsAvailable, Is.False);
float deathRemovalDelay = GameplayConstants.Current.Enemies.DefaultDeathDuration +
GameplayConstants.Current.Enemies.DeathRemovalPadding + .1f;
world.AdvanceRunClock(deathRemovalDelay);
yield return null;
Assert.That(afterArrival.gameObject.activeSelf, Is.False,
"The death visual must finish before the instance becomes reusable.");
Defense25DEnemy afterDeath = SpawnManualPoolEntry(entry, 3);
Assert.That(afterDeath, Is.SameAs(first), "Death removal should return the same instance to its pool.");
Assert.That(afterDeath.Actor.CurrentHealth, Is.EqualTo(afterDeath.Actor.MaxHealth));
}
[UnityTest]
public System.Collections.IEnumerator ResetLeavesReturnedPoolInstancesDormantAndClearsActiveLedger()
{
world.ResetRun();
world.AdvanceRunClock(runDefinition.PreparationSeconds + 18f);
int firstGeneration = world.RunGeneration;
Defense25DEnemy firstRunEnemy = world.Enemies.Single(enemy =>
enemy.SpawnContext.RunGeneration == firstGeneration && enemy.SpawnContext.SpawnId == 0);
world.ResetRun();
Defense25DEnemy[] retainedPool = world.GetComponentsInChildren<Defense25DEnemy>(true);
int activeHostiles = retainedPool.Count(enemy => enemy != null && enemy.gameObject.activeInHierarchy &&
enemy.IsAvailable);
int activeActorColliders = retainedPool.Sum(enemy => enemy == null ? 0 :
enemy.GetComponents<Collider>().Count(collider => collider.enabled &&
collider.gameObject.activeInHierarchy));
Debug.Log($"Pool reset evidence: retained={retainedPool.Length}, activeHostiles={activeHostiles}, " +
$"activeActorColliders={activeActorColliders}, supplied={world.Ledger.SuppliedCount}, " +
$"resolved={world.Ledger.ResolvedCount}.");
Assert.That(retainedPool, Is.Not.Empty);
Assert.That(world.Enemies.All(enemy => enemy == null || !enemy.IsAvailable), Is.True,
"The active enemy registry must not expose a hostile after reset.");
Assert.That(activeHostiles, Is.Zero);
Assert.That(activeActorColliders, Is.Zero);
Assert.That(world.Ledger.SuppliedCount, Is.Zero);
Assert.That(world.Ledger.ResolvedCount, Is.Zero);
Assert.That(firstRunEnemy, Is.Not.Null, "Pooled enemy instances must survive reset.");
Assert.That(firstRunEnemy.gameObject.activeSelf, Is.False);
Assert.That(retainedPool.Any(enemy => enemy == firstRunEnemy), Is.True);
Assert.That(world.RunGeneration, Is.EqualTo(firstGeneration + 1));
yield return null;
}
[UnityTest]
public System.Collections.IEnumerator PersonalMissionRelicCandidatesUseAuthoredDisplayNameAndDescription()
{
MethodInfo method = typeof(Defense25DComparison).GetMethod("GetMissionRelicCandidates",
BindingFlags.Instance | BindingFlags.NonPublic);
Assert.That(method, Is.Not.Null);
IReadOnlyList<Defense25DRewardChoice> choices = method.Invoke(world, new object[] { "display-test" })
as IReadOnlyList<Defense25DRewardChoice>;
Assert.That(choices, Is.Not.Null.And.Not.Empty);
Defense25DRewardChoice choice = choices[0];
PassiveRelicId relic = Enum.Parse<PassiveRelicId>(choice.DefinitionId);
MethodInfo displayMethod = typeof(ArtifactRewardController).GetMethod("GetPassiveRelicName",
BindingFlags.Static | BindingFlags.NonPublic);
Assert.That(displayMethod, Is.Not.Null);
string authoredDisplay = (string)displayMethod.Invoke(null, new object[] { relic });
int descriptionStart = authoredDisplay.IndexOf('\n');
string expectedTitle = descriptionStart >= 0
? authoredDisplay.Substring(0, descriptionStart) : authoredDisplay;
string expectedDescription = descriptionStart >= 0
? authoredDisplay.Substring(descriptionStart + 1) : string.Empty;
Assert.That(choice.DisplayData.Title, Is.EqualTo(expectedTitle));
Assert.That(choice.DisplayData.Description, Is.EqualTo(expectedDescription));
Assert.That(choice.DisplayData.Title, Is.Not.EqualTo(choice.DefinitionId));
Assert.That(choice.DisplayData.Description, Does.Not.Contain("Acquire the"));
yield return null;
}
[UnityTest]
public System.Collections.IEnumerator RangedFlyingAndLargeActorsRespectPortalBoundsAndStopAtXzWalls()
{
Defense25DMonsterPoolEntry[] entries = GetGeometryCriticalEntries();
Assert.That(entries.Any(entry => entry.CombatProfile.Archetype == Defense25DMonsterAttackArchetype.Projectile), Is.True);
Assert.That(entries.Any(entry => entry.CombatProfile.IsFlying || entry.Definition.IsFlying), Is.True);
Assert.That(entries.Any(entry => entry.CombatProfile.BodyRadius >= .55f), Is.True);
List<string> failures = new();
Rect bounds = world.RunLayout.Bounds;
Defense25DMonsterPoolEntry[] allEntries = runDefinition.MonsterPoolDefinition.Tiers
.SelectMany(tier => tier.Entries).ToArray();
float maximumBodyRadius = allEntries.Max(entry => entry.CombatProfile.BodyRadius);
IReadOnlyList<Vector2> portalPositions = world.RunLayout.PortalPositions;
for (int portalIndex = 0; portalIndex < portalPositions.Count; portalIndex++)
{
Vector2 portal = portalPositions[portalIndex];
float edgeClearance = Mathf.Min(portal.x - bounds.xMin, bounds.xMax - portal.x,
portal.y - bounds.yMin, bounds.yMax - portal.y);
if (edgeClearance + .001f < maximumBodyRadius + settings.MovementSkin)
failures.Add($"Portal SpawnNode {portalIndex} has {edgeClearance:0.000}m layout-edge margin; " +
$"the maximum authored radius needs {maximumBodyRadius + settings.MovementSkin:0.000}m.");
for (int otherIndex = portalIndex + 1; otherIndex < portalPositions.Count; otherIndex++)
{
float centerDistance = Vector2.Distance(portal, portalPositions[otherIndex]);
float requiredDistance = 2f * maximumBodyRadius + settings.MovementSkin;
if (centerDistance + .001f < requiredDistance)
failures.Add($"SpawnNode pair {portalIndex}/{otherIndex} is {centerDistance:0.000}m apart; " +
$"two maximum-radius actors need {requiredDistance:0.000}m center spacing.");
}
}
for (int index = 0; index < entries.Length; index++)
{
Defense25DMonsterPoolEntry entry = entries[index];
Defense25DEnemy enemy = SpawnGeometryEntry(entry, index, out int portalId);
bool expectedFlying = entry.CombatProfile.IsFlying || entry.Definition.IsFlying;
if (enemy.MonsterCombat == null || enemy.MonsterCombat.IsFlying != expectedFlying)
failures.Add($"{entry.EnemyDefinitionId}: flight profile did not reach the bound runtime adapter.");
Vector2 portal = world.RunLayout.PortalPositions[portalId];
Vector2 spawnedAt = enemy.Actor.Position;
float edgeClearance = Mathf.Min(spawnedAt.x - bounds.xMin, bounds.xMax - spawnedAt.x,
spawnedAt.y - bounds.yMin, bounds.yMax - spawnedAt.y);
if (Vector2.Distance(portal, spawnedAt) > .002f)
failures.Add($"{entry.EnemyDefinitionId}: factory spawn was not centered on portal {portalId}.");
if (edgeClearance + .001f < entry.CombatProfile.BodyRadius + settings.MovementSkin)
failures.Add($"{entry.EnemyDefinitionId}: SpawnNode has only {edgeClearance:0.000}m to layout edge; " +
$"authored radius+skin is {entry.CombatProfile.BodyRadius + settings.MovementSkin:0.000}m.");
Vector2 route = world.RunLayout.Routes[portalId][1] - world.RunLayout.Routes[portalId][0];
if (route.sqrMagnitude < .0001f)
{
failures.Add($"{entry.EnemyDefinitionId}: SpawnNode {portalId} has no usable exit direction.");
continue;
}
Vector2 wallTestStart = new(120f + index * 8f, 120f);
failures.AddRange(CheckWallStop(enemy, entry, wallTestStart, Vector2.right,
new Vector3(.2f, 2f, 6f), $"{entry.EnemyDefinitionId} X wall", false));
failures.AddRange(CheckWallStop(enemy, entry, wallTestStart, Vector2.up,
new Vector3(6f, 2f, .2f), $"{entry.EnemyDefinitionId} Z/depth wall", false));
}
yield return null;
Assert.That(failures, Is.Empty, string.Join("\n", failures));
}
[UnityTest]
public System.Collections.IEnumerator FactoryCapsuleUsesAuthoredBodyRadiusForGeometryCriticalSpecies()
{
Defense25DMonsterPoolEntry[] entries = GetGeometryCriticalEntries();
List<string> mismatches = new();
for (int index = 0; index < entries.Length; index++)
{
Defense25DMonsterPoolEntry entry = entries[index];
Defense25DEnemy enemy = SpawnGeometryEntry(entry, index, out _);
float authoredRadius = entry.CombatProfile.BodyRadius;
float actorRadius = enemy.Actor.Radius;
float capsuleRadius = enemy.Actor.BodyCollider.radius;
if (Mathf.Abs(authoredRadius - actorRadius) > .001f || Mathf.Abs(authoredRadius - capsuleRadius) > .001f)
mismatches.Add($"{entry.EnemyDefinitionId}: profile={authoredRadius:0.000}, actor={actorRadius:0.000}, CapsuleCollider={capsuleRadius:0.000}");
}
yield return null;
Assert.That(mismatches, Is.Empty,
"The factory must apply each profile's ground-plane footprint before testing clearance:\n"
+ string.Join("\n", mismatches));
}
[UnityTest]
public System.Collections.IEnumerator WallStopsRespectAuthoredFootprintOnBothGroundPlaneAxes()
{
Defense25DMonsterPoolEntry[] entries = GetGeometryCriticalEntries();
List<string> failures = new();
for (int index = 0; index < entries.Length; index++)
{
Defense25DMonsterPoolEntry entry = entries[index];
Defense25DEnemy enemy = SpawnGeometryEntry(entry, index, out _);
Vector2 start = new(220f + index * 8f, 220f);
failures.AddRange(CheckWallStop(enemy, entry, start, Vector2.right,
new Vector3(.2f, 2f, 6f), $"{entry.EnemyDefinitionId} X wall", true));
failures.AddRange(CheckWallStop(enemy, entry, start, Vector2.up,
new Vector3(6f, 2f, .2f), $"{entry.EnemyDefinitionId} Z/depth wall", true));
}
yield return null;
Assert.That(failures, Is.Empty,
"Wall stops must leave at least the authored body radius in both XZ axes:\n"
+ string.Join("\n", failures));
}
private static Defense25DSpawnRequest CreateNormalSpawnRequest(
Defense25DMonsterPoolEntry entry, int generation, int portalId, int ordinal)
{
Type[] parameterTypes =
{
typeof(int), typeof(int), typeof(int), typeof(Defense25DSpawnSource), typeof(string),
typeof(float), typeof(float), typeof(float), typeof(float), typeof(float), typeof(int),
};
ConstructorInfo constructor = typeof(Defense25DSpawnRequest).GetConstructor(
BindingFlags.Instance | BindingFlags.NonPublic, null, parameterTypes, null);
Assert.That(constructor, Is.Not.Null, "The scheduled-spawn test seam changed.");
return (Defense25DSpawnRequest)constructor.Invoke(new object[]
{
generation, 1, portalId, Defense25DSpawnSource.Normal, entry.EnemyDefinitionId,
entry.ThreatCost, entry.ThreatCost, 0f, 0f, 0f, ordinal,
});
}
private Defense25DMonsterPoolEntry[] GetGeometryCriticalEntries()
{
Defense25DMonsterPoolDefinition pool = runDefinition.MonsterPoolDefinition;
return pool.Tiers.SelectMany(tier => tier.Entries)
.Where(entry => entry.CombatProfile.Archetype == Defense25DMonsterAttackArchetype.Projectile
|| entry.CombatProfile.IsFlying || entry.Definition.IsFlying
|| entry.CombatProfile.BodyRadius >= .55f)
.ToArray();
}
private Defense25DEnemy SpawnGeometryEntry(
Defense25DMonsterPoolEntry entry, int ordinal, out int portalId)
{
world.ResetRun();
world.AdvanceRunClock(runDefinition.PreparationSeconds);
portalId = ordinal % world.PortalCount;
int previousCount = world.Enemies.Count;
int generation = world.RunGeneration;
world.SpawnScheduledEnemy(CreateNormalSpawnRequest(entry, generation, portalId, ordinal));
Assert.That(world.Enemies.Count, Is.EqualTo(previousCount + 1),
$"{entry.EnemyDefinitionId} geometry fixture did not create one factory actor.");
return world.Enemies[previousCount];
}
private Defense25DEnemy SpawnManualPoolEntry(Defense25DMonsterPoolEntry entry, int ordinal)
{
int generation = world.RunGeneration;
int maxSpawnIdBefore = world.Enemies
.Where(candidate => candidate.SpawnContext.RunGeneration == generation)
.Select(candidate => candidate.SpawnContext.SpawnId).DefaultIfEmpty(-1).Max();
world.SpawnScheduledEnemy(CreateNormalSpawnRequest(entry, generation,
ordinal % world.PortalCount, ordinal));
return world.Enemies.Single(candidate => candidate.SpawnContext.RunGeneration == generation &&
candidate.SpawnContext.SpawnId > maxSpawnIdBefore);
}
private GameObject CreateGeometryWall(string label, Vector2 position, Vector3 size, Quaternion rotation)
{
GameObject wall = new(label);
geometryFixtures.Add(wall);
wall.transform.SetParent(world.transform, false);
wall.layer = Defense25DComparisonSettings.TerrainLayer;
wall.transform.position = Defense25DActor.OnGround(position, settings.GroundY + size.y * .5f);
wall.transform.rotation = rotation;
BoxCollider collider = wall.AddComponent<BoxCollider>();
collider.size = size;
return wall;
}
private List<string> CheckWallStop(Defense25DEnemy enemy, Defense25DMonsterPoolEntry entry,
Vector2 start, Vector2 direction, Vector3 wallSize, string label, bool enforceProfileRadius)
{
List<string> failures = new();
enemy.Actor.ResetAt(start);
Vector2 colliderCenter = enemy.Actor.Center;
Vector2 wallCenter = colliderCenter + direction;
GameObject wall = CreateGeometryWall(label, wallCenter, wallSize, Quaternion.identity);
Physics.SyncTransforms();
enemy.Actor.Move(direction * 2f);
Physics.SyncTransforms();
Physics.Simulate(.02f);
Physics.SyncTransforms();
float wallFaceDistance = 1f - (direction.x != 0f ? wallSize.x : wallSize.z) * .5f;
float maximumProfileTravel = wallFaceDistance - entry.CombatProfile.BodyRadius + .04f;
if (!enemy.Actor.MovementBlocked || enemy.Actor.LastMove.magnitude <= .05f)
failures.Add($"{label}: actor did not stop at the terrain collider.");
if (enforceProfileRadius && enemy.Actor.LastMove.magnitude > maximumProfileTravel)
failures.Add($"{label}: root advanced {enemy.Actor.LastMove.magnitude:0.000}m past the profile-radius clearance limit {maximumProfileTravel:0.000}m.");
if (Physics.ComputePenetration(enemy.Actor.BodyCollider, enemy.Actor.transform.position,
enemy.Actor.transform.rotation, wall.GetComponent<BoxCollider>(), wall.transform.position,
wall.transform.rotation, out _, out _))
failures.Add($"{label}: the runtime CapsuleCollider penetrated the wall after movement.");
wall.SetActive(false);
return failures;
}
}
}