701 lines
32 KiB
C#
701 lines
32 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|