Draw broken objects as fragments, plane tiles for fences and walls, tileset publishing; mod documentation
This commit is contained in:
1 parent
e153dc9b15
commit
7b28c1bb0a
74 files changed
+2782
-886
No files matched your search
@@ -0,0 +1,52 @@
|
||||
namespace Cthangover.Mods.Rpg.Tests
|
||||
{
|
||||
using Cthangover.Core.Mods.Testing;
|
||||
using Cthangover.Mods.TileRender;
|
||||
using Godot;
|
||||
|
||||
/// <summary>Replacing real renderer instances cannot change the logical actor clock.</summary>
|
||||
public class RpgActorPresentationTests
|
||||
{
|
||||
private static Node2D Actor(ITileWorldPresentation view) => (Node2D)view.Root.GetNode("Actors").GetChild(0);
|
||||
|
||||
[ModTest("actor_presentation/recreation_keeps_inflight_state")]
|
||||
static void RecreationKeepsInflightState(ModTestContext ctx)
|
||||
{
|
||||
var state = new RpgActorState { Speed = 2 };
|
||||
state.Place(new Vector2I(1, 1), RpgDirection.Right);
|
||||
state.StepTo(RpgDirection.Right);
|
||||
state.Advance(0.25);
|
||||
using var image = Image.CreateEmpty(1, 1, false, Image.Format.Rgba8);
|
||||
image.Fill(Colors.White);
|
||||
using var texture = ImageTexture.CreateFromImage(image);
|
||||
var frame = new TileFrame();
|
||||
var pose = new TileActorPose { Id = "player", Visible = true, Position = state.Position,
|
||||
Look = new TileVisual { Texture = texture, Bounds = new Rect2(0, 0, 1, 1), Source = new Rect2(0, 0, 1, 1) } };
|
||||
frame.Actors.Add(pose);
|
||||
var first = TileRendererRegistry.Instance.Resolve("topdown").Create();
|
||||
try
|
||||
{
|
||||
first.Mount(new TileRenderScene { Size = new Vector2I(8, 8), TileSize = 32, Zoom = 1 });
|
||||
first.Present(frame, 0);
|
||||
first.Present(frame, 0);
|
||||
ModAssert.CloseTo(48, Actor(first).Position.X, what: "first projection");
|
||||
ModAssert.CloseTo(1.5, state.Position.X, what: "presenting twice does not tick");
|
||||
}
|
||||
finally { first.Dispose(); }
|
||||
|
||||
var second = TileRendererRegistry.Instance.Resolve("topdown").Create();
|
||||
try
|
||||
{
|
||||
second.Mount(new TileRenderScene { Size = new Vector2I(8, 8), TileSize = 64, Zoom = 1 });
|
||||
second.Present(frame, 0);
|
||||
ModAssert.CloseTo(96, Actor(second).Position.X, what: "another projection uses the same pose");
|
||||
ModAssert.True(state.IsMoving, "freeing the old presentation does not cancel the step");
|
||||
ModAssert.True(state.Advance(0.25), "remaining duration is preserved");
|
||||
pose.Position = state.Position;
|
||||
second.Present(frame, 0);
|
||||
ModAssert.CloseTo(128, Actor(second).Position.X, what: "arrival updates the replacement");
|
||||
}
|
||||
finally { second.Dispose(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
namespace Cthangover.Mods.Rpg.Tests
|
||||
{
|
||||
using Cthangover.Core.Mods.Testing;
|
||||
using Godot;
|
||||
|
||||
/// <summary>Cell reservations, timing and poses work without a scene tree or a sprite.</summary>
|
||||
public class RpgActorStateTests
|
||||
{
|
||||
[ModTest("actor_state/reserves_destination_before_arrival")]
|
||||
static void ReservesDestinationBeforeArrival(ModTestContext ctx)
|
||||
{
|
||||
var actor = new RpgActorState { Speed = 2 };
|
||||
actor.Place(new Vector2I(3, 4), RpgDirection.Down);
|
||||
actor.StepTo(RpgDirection.Right);
|
||||
ModAssert.True(actor.Cell == new Vector2I(4, 4), "destination is reserved immediately");
|
||||
ModAssert.CloseTo(3, actor.Position.X, what: "drawing starts at the source");
|
||||
ModAssert.True(actor.IsMoving, "an unfinished step");
|
||||
ModAssert.False(actor.Advance(0.25), "half a second per cell at speed 2");
|
||||
ModAssert.CloseTo(3.5, actor.Position.X, what: "position is measured in cells");
|
||||
ModAssert.CloseTo(4, actor.Position.Y, what: "unchanged axis");
|
||||
ModAssert.True(actor.Advance(0.25), "arrival is reported once");
|
||||
ModAssert.False(actor.Advance(1), "idle time never repeats arrival");
|
||||
ModAssert.CloseTo(4, actor.Position.X, what: "destination reached exactly");
|
||||
}
|
||||
|
||||
[ModTest("actor_state/teleport_cancels_pending_arrival")]
|
||||
static void TeleportCancelsPendingArrival(ModTestContext ctx)
|
||||
{
|
||||
var actor = new RpgActorState();
|
||||
actor.StepTo(RpgDirection.Down);
|
||||
actor.Advance(0.05);
|
||||
actor.Place(new Vector2I(8, 2), RpgDirection.Left);
|
||||
ModAssert.False(actor.IsMoving, "teleport stops interpolation");
|
||||
ModAssert.False(actor.Advance(1), "old step cannot complete after teleport");
|
||||
ModAssert.CloseTo(8, actor.Position.X, what: "new x");
|
||||
ModAssert.CloseTo(2, actor.Position.Y, what: "new y");
|
||||
ModAssert.True(actor.Facing == RpgDirection.Left, "new facing");
|
||||
}
|
||||
|
||||
[ModTest("actor_state/moving_command_does_not_reserve_second_cell")]
|
||||
static void MovingCommandDoesNotReserveSecondCell(ModTestContext ctx)
|
||||
{
|
||||
var actor = new RpgActorState { Speed = 2 };
|
||||
actor.StepTo(RpgDirection.Right);
|
||||
actor.Advance(0.25);
|
||||
actor.StepTo(RpgDirection.Up);
|
||||
ModAssert.True(actor.Cell == new Vector2I(1, 0), "commands do not stack during a step");
|
||||
ModAssert.CloseTo(0.5, actor.Position.X, what: "interpolation is not restarted");
|
||||
actor.Face(RpgDirection.Left);
|
||||
ModAssert.True(actor.Facing == RpgDirection.Left, "turning is independent of displacement");
|
||||
ModAssert.True(actor.Advance(0.25), "original step finishes on time");
|
||||
}
|
||||
|
||||
[ModTest("actor_state/speed_clamp_and_large_frame")]
|
||||
static void SpeedClampAndLargeFrame(ModTestContext ctx)
|
||||
{
|
||||
var actor = new RpgActorState { Speed = 0 };
|
||||
actor.StepTo(RpgDirection.Up);
|
||||
ModAssert.False(actor.Advance(0.5), "speed clamps to one cell per second");
|
||||
ModAssert.CloseTo(-0.5, actor.Position.Y, what: "halfway up");
|
||||
ModAssert.True(actor.Advance(20), "a large frame completes one step");
|
||||
ModAssert.CloseTo(-1, actor.Position.Y, what: "no overshoot");
|
||||
actor.Speed = 100;
|
||||
actor.StepTo(RpgDirection.Right);
|
||||
ModAssert.False(actor.Advance(1.0 / 24), "speed clamps to twelve");
|
||||
ModAssert.CloseTo(0.5, actor.Position.X, what: "upper speed limit");
|
||||
}
|
||||
|
||||
[ModTest("actor_state/frame_partition_keeps_pose_and_animation")]
|
||||
static void FramePartitionKeepsPoseAndAnimation(ModTestContext ctx)
|
||||
{
|
||||
var a = new RpgActorState { Speed = 2 };
|
||||
var b = new RpgActorState { Speed = 2 };
|
||||
a.StepTo(RpgDirection.Down);
|
||||
b.StepTo(RpgDirection.Down);
|
||||
a.Advance(0.2);
|
||||
b.Advance(0.1);
|
||||
b.Advance(0.1);
|
||||
ModAssert.CloseTo(a.Position.Y, b.Position.Y, what: "same movement without renderer timing");
|
||||
ModAssert.CloseTo(a.AnimationTime, b.AnimationTime, what: "same animation phase");
|
||||
ModAssert.False(a.Advance(0), "zero time cannot complete an unfinished step");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,5 +60,20 @@ namespace Cthangover.Mods.Rpg.Tests
|
||||
ModAssert.True(RpgAutotiles.SourceRect(Vector2I.Zero, 48) == new Rect2(0, 0, 24, 24), "of any tile size");
|
||||
ModAssert.True(RpgAutotiles.PreviewRect(32) == new Rect2(0, 0, 32, 32), "the preview is the top-left tile");
|
||||
}
|
||||
|
||||
[ModTest("autotiles/material_variants_repeat_by_world_cell")]
|
||||
static void MaterialVariants(ModTestContext ctx)
|
||||
{
|
||||
ModAssert.True(RpgAutotiles.VariantRect(new Vector2I(64, 96), 32, new Vector2I(9, 7)) == new Rect2(0, 0, 64, 96),
|
||||
"legacy A2 textures still use the whole single block");
|
||||
ModAssert.True(RpgAutotiles.VariantRect(new Vector2I(256, 384), 32, new Vector2I(2, 3)) == new Rect2(128, 288, 64, 96),
|
||||
"a four-by-four material sheet selects its world phase");
|
||||
ModAssert.True(RpgAutotiles.VariantRect(new Vector2I(256, 384), 32, new Vector2I(6, 7)) == new Rect2(128, 288, 64, 96),
|
||||
"the material repeats across its period");
|
||||
ModAssert.True(RpgAutotiles.VariantRect(new Vector2I(256, 384), 32, new Vector2I(-1, -2)) == new Rect2(192, 192, 64, 96),
|
||||
"negative editor coordinates wrap to a valid block");
|
||||
ModAssert.True(RpgAutotiles.VariantRect(new Vector2I(192, 288), 48, new Vector2I(1, 1)) == new Rect2(96, 144, 96, 144),
|
||||
"other logical tile sizes keep the A2 proportions");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace Cthangover.Mods.Rpg.Tests
|
||||
{
|
||||
using Cthangover.Core.Mods.Testing;
|
||||
using Godot;
|
||||
|
||||
/// <summary>A cell of a broken object knows its place inside that object.</summary>
|
||||
public class RpgGroupFragmentsTests
|
||||
{
|
||||
[ModTest("group_fragments/place_in_object")]
|
||||
static void PlaceInObject(ModTestContext ctx)
|
||||
{
|
||||
var tileset = new RpgTileset { ID = "fragments" };
|
||||
tileset.VisualGroups.Add(new RpgVisualGroup { Id = "house", Width = 3, Height = 2, Pattern = new[] { 1, 2, 3, 4, 5, 6 }, AnchorX = 1.5f, AnchorY = 2 });
|
||||
tileset.VisualGroups.Add(new RpgVisualGroup { Id = "shed", Width = 1, Height = 2, Pattern = new[] { 6, 7 }, AnchorX = 0.5f, AnchorY = 2 });
|
||||
tileset.VisualGroups.Add(new RpgVisualGroup { Id = "bad", Width = 2, Height = 1, Pattern = new[] { 8 } });
|
||||
var fragments = RpgGroupFragments.Of(tileset);
|
||||
ModAssert.Equal("house", fragments[5].Group.Id, "owner of a cell");
|
||||
ModAssert.True(fragments[5].Cell == new Vector2I(1, 1), "column and row inside the object");
|
||||
ModAssert.Equal("house", fragments[6].Group.Id, "a shared tile keeps its first object");
|
||||
ModAssert.True(fragments[7].Cell == new Vector2I(0, 1), "second object's cells");
|
||||
ModAssert.False(fragments.ContainsKey(8), "invalid groups are ignored");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using Cthangover.Core.Mods.Testing;
|
||||
|
||||
namespace Cthangover.Mods.Rpg.Tests
|
||||
{
|
||||
/// <summary>Material transitions are calculated from neighbours, with stable world sampling and unchanged collision.</summary>
|
||||
public class RpgSurfaceRendererTests
|
||||
{
|
||||
private static RpgTileset Tileset() => new()
|
||||
{
|
||||
Image = "test/objects", SurfaceImage = "test/surfaces", SurfaceSize = 16,
|
||||
Columns = 1,
|
||||
Tiles = new List<RpgTileInfo>
|
||||
{
|
||||
new() { Index = 0, Surface = 0 },
|
||||
new() { Index = 3, Surface = 1, Solid = true },
|
||||
new() { Index = 11, AtlasIndex = 0, Solid = true }
|
||||
}
|
||||
};
|
||||
|
||||
private static byte[] Atlas()
|
||||
{
|
||||
var pixels = new byte[32 * 16 * 4];
|
||||
for (var y = 0; y < 16; y++)
|
||||
for (var x = 0; x < 32; x++)
|
||||
{
|
||||
pixels[(y * 32 + x) * 4 + (x < 16 ? 0 : 2)] = 255;
|
||||
pixels[(y * 32 + x) * 4 + 3] = 255;
|
||||
}
|
||||
return pixels;
|
||||
}
|
||||
|
||||
[ModTest("surfaces/packing_and_materials_json_round_trip")]
|
||||
static void PackingRoundTrip(ModTestContext ctx)
|
||||
{
|
||||
var restored = JsonSerializer.Deserialize<RpgTileset>(JsonSerializer.Serialize(Tileset()));
|
||||
ModAssert.Equal("test/surfaces", restored.SurfaceImage, "surface atlas survives serialization");
|
||||
ModAssert.Equal(16, restored.SurfaceSize, "material size survives serialization");
|
||||
ModAssert.Equal(1, restored.TileOf(3).Surface.Value, "logical surface maps to its physical patch");
|
||||
ModAssert.True(restored.TileOf(3).Diffuse, "natural surfaces diffuse by default");
|
||||
ModAssert.True(restored.IsSolid(3), "blending never changes collision");
|
||||
ModAssert.Equal(0, restored.TileOf(11).AtlasIndex.Value, "objects retain logical ids after packing");
|
||||
ModAssert.Equal(0, RpgTileSetBuilder.AtlasCoords(restored, 11).X, "world and editor find the packed object");
|
||||
restored.TileOf(3).Diffuse = false;
|
||||
restored = JsonSerializer.Deserialize<RpgTileset>(JsonSerializer.Serialize(restored));
|
||||
ModAssert.False(restored.TileOf(3).Diffuse, "hard surface edges survive serialization");
|
||||
}
|
||||
|
||||
[ModTest("surfaces/blend_is_continuous_deterministic_and_leaves_objects_empty")]
|
||||
static void ContinuousBlend(ModTestContext ctx)
|
||||
{
|
||||
var grid = new RpgMapGrid(3, 1);
|
||||
grid.SetTile(RpgLayer.Back, 0, 0, 0);
|
||||
grid.SetTile(RpgLayer.Back, 1, 0, 3);
|
||||
grid.SetTile(RpgLayer.Back, 2, 0, 11);
|
||||
var first = RpgSurfaceRenderer.Render(grid, RpgLayer.Back, Tileset(), Atlas(), 32);
|
||||
var second = RpgSurfaceRenderer.Render(grid, RpgLayer.Back, Tileset(), Atlas(), 32);
|
||||
ModAssert.True(first.AsSpan().SequenceEqual(second), "same map produces the same diffusion");
|
||||
ModAssert.Equal((byte)255, first[(16 * 96 + 16) * 4], "material interior stays intact");
|
||||
var left = (16 * 96 + 31) * 4;
|
||||
var right = (16 * 96 + 32) * 4;
|
||||
ModAssert.True(first[left] > 0 && first[left + 2] > 0, "boundary mixes both source materials");
|
||||
ModAssert.True(Math.Abs(first[left] - first[right]) < 40, "no colour jump at cell boundary");
|
||||
ModAssert.Equal((byte)0, first[(16 * 96 + 64) * 4 + 3], "object cell is transparent in surface layer");
|
||||
var tileset = Tileset();
|
||||
tileset.TileOf(3).Diffuse = false;
|
||||
var hard = RpgSurfaceRenderer.Render(grid, RpgLayer.Back, tileset, Atlas(), 32);
|
||||
ModAssert.Equal((byte)255, hard[left], "hard floor boundary keeps its own material");
|
||||
ModAssert.Equal((byte)0, hard[left + 2], "hard floor does not diffuse into its neighbour");
|
||||
}
|
||||
|
||||
[ModTest("surfaces/diagonal_material_reaches_shared_corner")]
|
||||
static void DiagonalBlend(ModTestContext ctx)
|
||||
{
|
||||
var grid = new RpgMapGrid(2, 2);
|
||||
grid.SetTile(RpgLayer.Back, 0, 0, 0);
|
||||
grid.SetTile(RpgLayer.Back, 1, 0, 0);
|
||||
grid.SetTile(RpgLayer.Back, 0, 1, 0);
|
||||
grid.SetTile(RpgLayer.Back, 1, 1, 3);
|
||||
var pixels = RpgSurfaceRenderer.Render(grid, RpgLayer.Back, Tileset(), Atlas(), 32);
|
||||
ModAssert.True(pixels[(31 * 64 + 31) * 4 + 2] > 0, "diagonal corner participates in the diffusion kernel");
|
||||
ModAssert.True(RpgSurfaceRenderer.Render(grid, RpgLayer.Front, Tileset(), Atlas(), 32).AsSpan().IndexOfAnyExcept((byte)0) < 0,
|
||||
"empty layers stay transparent");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
namespace Cthangover.Mods.Rpg.Tests
|
||||
{
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using Cthangover.Core.Mods.Testing;
|
||||
using Cthangover.Core.Utils;
|
||||
using Godot;
|
||||
|
||||
/// <summary>Plane tiles stand on a map axis through the cell centre; "auto" follows plane neighbours.</summary>
|
||||
public class RpgTilePlanesTests
|
||||
{
|
||||
private const int Fence = 12;
|
||||
|
||||
private static RpgTileset Tileset()
|
||||
{
|
||||
var tileset = new RpgTileset { ID = "planes" };
|
||||
tileset.Tiles.Add(new RpgTileInfo { Index = Fence, Solid = true, Plane = "auto" });
|
||||
return tileset;
|
||||
}
|
||||
|
||||
[ModTest("tile_planes/json_roundtrip")]
|
||||
static void JsonRoundtrip(ModTestContext ctx)
|
||||
{
|
||||
var restored = JsonSerializer.Deserialize<RpgTileset>(JsonSerializer.Serialize(Tileset()), JsonOptionsProvider.ReadOptions);
|
||||
ModAssert.Equal("auto", restored.TileOf(Fence).Plane, "plane mode");
|
||||
ModAssert.Equal(null, JsonSerializer.Deserialize<RpgTileInfo>("{}").Plane, "legacy tiles stay sprites");
|
||||
}
|
||||
|
||||
[ModTest("tile_planes/auto_by_neighbours")]
|
||||
static void AutoByNeighbours(ModTestContext ctx)
|
||||
{
|
||||
var grid = new RpgMapGrid(5, 5);
|
||||
grid.SetTile(RpgLayer.Main, 1, 1, Fence);
|
||||
grid.SetTile(RpgLayer.Main, 2, 1, Fence);
|
||||
grid.SetTile(RpgLayer.Main, 2, 2, Fence);
|
||||
grid.SetTile(RpgLayer.Main, 4, 4, Fence);
|
||||
grid.SetTile(RpgLayer.Front, 2, 3, Fence);
|
||||
var tileset = Tileset();
|
||||
|
||||
var row = RpgTilePlanes.Quads(grid, tileset, RpgLayer.Main, 1, 1, "auto");
|
||||
ModAssert.Equal(1, row.Count, "a row end is one plane");
|
||||
ModAssert.True(row[0][0] == new Vector3(1, 1.5f, 1) && row[0][2] == new Vector3(2, 1.5f, 0), "along x through the centre, one cell high");
|
||||
|
||||
ModAssert.Equal(2, RpgTilePlanes.Quads(grid, tileset, RpgLayer.Main, 2, 1, "auto").Count, "a corner has both planes");
|
||||
|
||||
var column = RpgTilePlanes.Quads(grid, tileset, RpgLayer.Main, 2, 2, "auto");
|
||||
ModAssert.Equal(1, column.Count, "a column end ignores the other layer");
|
||||
ModAssert.True(column[0][0] == new Vector3(2.5f, 3, 1) && column[0][1] == new Vector3(2.5f, 2, 1),
|
||||
"along y, the image's left edge at the larger y (screen left)");
|
||||
|
||||
List<Vector3[]> lone = RpgTilePlanes.Quads(grid, tileset, RpgLayer.Main, 4, 4, "auto");
|
||||
ModAssert.True(lone.Count == 1 && lone[0][0].Y == 4.5f, "a lone tile defaults to x");
|
||||
ModAssert.True(RpgTilePlanes.Quads(grid, tileset, RpgLayer.Main, 4, 4, "y")[0][0].X == 4.5f, "an explicit axis wins");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace Cthangover.Mods.Rpg.Tests
|
||||
{
|
||||
using System.Text.Json;
|
||||
using Cthangover.Core.Mods.Testing;
|
||||
using Cthangover.Core.Utils;
|
||||
/// <summary>Visual metadata survives the same serializer used by content and editor snapshots.</summary>
|
||||
public class RpgVisualGroupTests
|
||||
{
|
||||
[ModTest("visual_groups/json_roundtrip")]
|
||||
static void JsonRoundtrip(ModTestContext ctx)
|
||||
{
|
||||
var tileset = new RpgTileset { ID = "room" };
|
||||
tileset.VisualGroups.Add(new RpgVisualGroup { Id = "wall", Width = 2, Height = 1, Pattern = new[] { 1000, 1001 },
|
||||
AnchorX = 1, AnchorY = 0.75f, IsoPlanes = new() { new() { Crop = new[] { 0, 0, 2, 1 },
|
||||
Points = new[] { new[] { 0f, 0, 1 }, new[] { 2f, 0, 1 }, new[] { 2f, 0, 0 }, new[] { 0f, 0, 0 } } } } });
|
||||
var restored = JsonSerializer.Deserialize<RpgTileset>(JsonSerializer.Serialize(tileset), JsonOptionsProvider.ReadOptions);
|
||||
var group = restored.VisualGroups[0];
|
||||
ModAssert.Equal("wall", group.Id, "group ID");
|
||||
ModAssert.Equal(2, group.Width, "width");
|
||||
ModAssert.Equal(1, group.Height, "height");
|
||||
ModAssert.Equal(1001, group.Pattern[1], "logical source IDs");
|
||||
ModAssert.CloseTo(0.75, group.AnchorY, what: "pivot");
|
||||
ModAssert.Equal(2, group.IsoPlanes[0].Crop[2], "plane crop");
|
||||
ModAssert.CloseTo(1, group.IsoPlanes[0].Points[0][2], what: "elevation");
|
||||
ModAssert.Equal(0, JsonSerializer.Deserialize<RpgTileset>("{}").VisualGroups.Count, "legacy default");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace Cthangover.Mods.Rpg.Tests
|
||||
{
|
||||
using Cthangover.Core.Mods.Testing;
|
||||
/// <summary>Malformed profiles fail before renderer creation rather than stretching unrelated atlas pixels.</summary>
|
||||
public class RpgVisualGroupValidatorTests
|
||||
{
|
||||
[ModTest("visual_groups/invalid_geometry_is_reported")]
|
||||
static void InvalidGeometryIsReported(ModTestContext ctx)
|
||||
{
|
||||
var group = new RpgVisualGroup { Id = "wall", Width = 2, Height = 1, Pattern = new[] { 4, 5 } };
|
||||
ModAssert.Null(RpgVisualGroupValidator.Error(group), "valid sprite group");
|
||||
group.Pattern = new[] { 4 };
|
||||
ModAssert.NotNull(RpgVisualGroupValidator.Error(group), "incomplete pattern");
|
||||
group.Pattern = new[] { 4, 5 };
|
||||
group.IsoPlanes = new() { new() { Crop = new[] { 0, 0, 3, 1 }, Points = new[] {
|
||||
new[] { 0f, 0, 1 }, new[] { 2f, 0, 1 }, new[] { 2f, 0, 0 }, new[] { 0f, 0, 0 } } } };
|
||||
ModAssert.NotNull(RpgVisualGroupValidator.Error(group), "crop outside packed group");
|
||||
group.IsoPlanes[0].Crop[2] = 2;
|
||||
ModAssert.Null(RpgVisualGroupValidator.Error(group), "valid architectural quad");
|
||||
group.IsoPlanes[0].Points[2][2] = float.NaN;
|
||||
ModAssert.NotNull(RpgVisualGroupValidator.Error(group), "non-finite geometry");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
namespace Cthangover.Mods.Rpg.Tests
|
||||
{
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Cthangover.Core.Mods.Testing;
|
||||
using Godot;
|
||||
|
||||
/// <summary>World decisions run without presentation nodes, including pages, routes and arrival effects.</summary>
|
||||
public class RpgWorldSessionTests
|
||||
{
|
||||
private readonly List<string> effects = new();
|
||||
private bool flag;
|
||||
|
||||
private bool Evaluate(string condition) => string.IsNullOrEmpty(condition) || flag;
|
||||
|
||||
private void OnArrival(RpgActorState actor) => effects.Add($"arrived:{actor.Cell.X},{actor.Cell.Y}");
|
||||
|
||||
private void OnTouch(RpgMapEvent mapEvent) => effects.Add("touch:" + mapEvent.Id);
|
||||
|
||||
private void OnEncounter(string enemies) => effects.Add("encounter:" + enemies);
|
||||
|
||||
private RpgWorldSession Create(IEnumerable<RpgMapEvent> events = null, RpgEncounterTable encounters = null)
|
||||
{
|
||||
var map = new RpgMap { ID = "session_test", Width = 6, Height = 6, Encounters = encounters };
|
||||
var session = new RpgWorldSession(map, null, events, Evaluate, new Random(7));
|
||||
session.PlayerStepFinished += OnArrival;
|
||||
session.TouchTriggered += OnTouch;
|
||||
session.EncounterTriggered += OnEncounter;
|
||||
return session;
|
||||
}
|
||||
|
||||
[ModTest("session/blockers_stop_commands_but_finish_existing_step")]
|
||||
static void BlockersStopCommandsButFinishExistingStep(ModTestContext ctx)
|
||||
{
|
||||
var test = new RpgWorldSessionTests();
|
||||
var session = test.Create();
|
||||
session.Player.Place(new Vector2I(2, 2), RpgDirection.Down);
|
||||
session.Tick(0.1, true, RpgDirection.Right);
|
||||
ModAssert.True(session.Player.Cell == new Vector2I(2, 2), "blocked input cannot reserve a step");
|
||||
session.Tick(0.1, false, RpgDirection.Right);
|
||||
ModAssert.True(session.Player.Cell == new Vector2I(3, 2), "accepted command reserves destination");
|
||||
ModAssert.Equal(0, test.effects.Count, "no arrival while interpolating");
|
||||
session.Tick(1, true, RpgDirection.Right);
|
||||
ModAssert.Equal(1, test.effects.Count, "an already started step completes even after UI opens");
|
||||
ModAssert.Equal("arrived:3,2", test.effects[0], "saved arrival uses logical coordinates");
|
||||
session.Tick(1, true, RpgDirection.Right);
|
||||
ModAssert.Equal(1, test.effects.Count, "no repeated completion");
|
||||
}
|
||||
|
||||
[ModTest("session/touch_wins_over_encounter_and_follows_arrival")]
|
||||
static void TouchWinsOverEncounterAndFollowsArrival(ModTestContext ctx)
|
||||
{
|
||||
var test = new RpgWorldSessionTests();
|
||||
var session = test.Create(new[]
|
||||
{
|
||||
new RpgMapEvent { Id = "pickup", X = 1, Trigger = RpgEventTrigger.Touch, Solid = false }
|
||||
}, new RpgEncounterTable { Steps = 1, Groups = new() { "wolf" } });
|
||||
session.Tick(1, false, RpgDirection.Right);
|
||||
ModAssert.Equal(2, test.effects.Count, "arrival and touch only");
|
||||
ModAssert.Equal("arrived:1,0", test.effects[0], "position is available before running the event");
|
||||
ModAssert.Equal("touch:pickup", test.effects[1], "touch suppresses the encounter roll");
|
||||
session.Tick(1, false, RpgDirection.Right);
|
||||
ModAssert.Equal("encounter:wolf", test.effects[3], "a free step can start a battle");
|
||||
}
|
||||
|
||||
[ModTest("session/terrain_and_hidden_events_use_logical_passability")]
|
||||
static void TerrainAndHiddenEventsUseLogicalPassability(ModTestContext ctx)
|
||||
{
|
||||
var test = new RpgWorldSessionTests();
|
||||
var session = test.Create(new[]
|
||||
{
|
||||
new RpgMapEvent { Id = "gate", X = 1, Y = 0, Tile = 5, Condition = "enabled" }
|
||||
});
|
||||
ModAssert.True(session.CanEnter(new Vector2I(1, 0)), "hidden event does not block");
|
||||
test.flag = true;
|
||||
session.RefreshEvents();
|
||||
ModAssert.False(session.CanEnter(new Vector2I(1, 0)), "active drawn event blocks even without a sprite node");
|
||||
session.Tick(0.1, false, RpgDirection.Right);
|
||||
ModAssert.False(session.Player.IsMoving, "blocked step does not start");
|
||||
ModAssert.True(session.Player.Facing == RpgDirection.Right, "blocked command still turns");
|
||||
session.Grid.SetCollision(0, 1, RpgCollision.Block);
|
||||
ModAssert.False(session.CanEnter(new Vector2I(0, 1)), "painted collision is respected");
|
||||
ModAssert.False(session.CanEnter(new Vector2I(-1, 0)), "outside map is blocked");
|
||||
}
|
||||
|
||||
[ModTest("session/action_target_and_facing_are_shared_without_view")]
|
||||
static void ActionTargetAndFacingAreSharedWithoutView(ModTestContext ctx)
|
||||
{
|
||||
var test = new RpgWorldSessionTests();
|
||||
var session = test.Create(new[]
|
||||
{
|
||||
new RpgMapEvent { Id = "sign", X = 1, Y = 0, Tile = 1 },
|
||||
new RpgMapEvent { Id = "floor", X = 0, Y = 0, Solid = false }
|
||||
});
|
||||
session.Player.Face(RpgDirection.Right);
|
||||
ModAssert.Equal("sign", session.InteractionTarget().Event.Id, "front event takes precedence over floor");
|
||||
ModAssert.Equal("sign", session.PrepareInteraction().Id, "the executed target is the highlighted one");
|
||||
ModAssert.True(session.FindEvent("sign").Facing == RpgDirection.Left, "NPC turns toward player");
|
||||
session.Player.Face(RpgDirection.Down);
|
||||
ModAssert.Equal("floor", session.PrepareInteraction().Id, "fallback is event under the player");
|
||||
session.Player.StepTo(RpgDirection.Down);
|
||||
ModAssert.Null(session.InteractionTarget(), "no cue during a step");
|
||||
ModAssert.Null(session.PrepareInteraction(), "no action during a step");
|
||||
ModAssert.Null(session.FindEvent(null), "null selection is safe");
|
||||
}
|
||||
|
||||
[ModTest("session/routes_reserve_destinations_and_respect_player")]
|
||||
static void RoutesReserveDestinationsAndRespectPlayer(ModTestContext ctx)
|
||||
{
|
||||
var test = new RpgWorldSessionTests();
|
||||
var session = test.Create(new[]
|
||||
{
|
||||
new RpgMapEvent { Id = "a", X = 1, Y = 1, Tile = 1, Route = new() { Every = 0.1, Steps = new() { "right" } } },
|
||||
new RpgMapEvent { Id = "b", X = 3, Y = 1, Tile = 1, Route = new() { Every = 0.1, Steps = new() { "left" } } }
|
||||
});
|
||||
session.Tick(0.1, false);
|
||||
ModAssert.True(session.FindEvent("a").Cell == new Vector2I(2, 1), "first route reserves target");
|
||||
ModAssert.True(session.FindEvent("b").Cell == new Vector2I(3, 1), "second route cannot enter the reservation");
|
||||
ModAssert.True(session.FindEvent("b").Facing == RpgDirection.Left, "blocked route still faces target");
|
||||
session.Player.Place(new Vector2I(3, 1), RpgDirection.Down);
|
||||
session.Tick(1, true);
|
||||
session.Tick(0.2, false);
|
||||
ModAssert.True(session.FindEvent("a").Cell == new Vector2I(2, 1), "route does not enter player cell");
|
||||
}
|
||||
|
||||
[ModTest("session/page_change_retains_position_and_restarts_route")]
|
||||
static void PageChangeRetainsPositionAndRestartsRoute(ModTestContext ctx)
|
||||
{
|
||||
var test = new RpgWorldSessionTests();
|
||||
var session = test.Create(new[]
|
||||
{
|
||||
new RpgMapEvent
|
||||
{
|
||||
Id = "chest", X = 1, Y = 1, Tile = 14,
|
||||
Route = new() { Every = 0.1, Steps = new() { "right" }, Repeat = false },
|
||||
Pages = new() { new() { Condition = "opened", Tile = 15, Dir = RpgDirection.Up } }
|
||||
}
|
||||
});
|
||||
session.Tick(1, false);
|
||||
var actor = session.FindEvent("chest");
|
||||
ModAssert.True(actor.Cell == new Vector2I(2, 1), "route moved event");
|
||||
test.flag = true;
|
||||
session.RefreshEvents();
|
||||
ModAssert.Equal(1, actor.PageIndex, "conditional page becomes active");
|
||||
ModAssert.Equal(15, actor.Current.Tile, "look describes the effective page");
|
||||
ModAssert.True(actor.Cell == new Vector2I(2, 1), "page does not reset position");
|
||||
ModAssert.True(actor.Facing == RpgDirection.Up, "explicit page direction applies");
|
||||
ModAssert.Equal(0, actor.RouteIndex, "new page restarts the route");
|
||||
}
|
||||
|
||||
[ModTest("session/route_clock_pauses_while_ui_blocks_commands")]
|
||||
static void RouteClockPausesWhileUiBlocksCommands(ModTestContext ctx)
|
||||
{
|
||||
var test = new RpgWorldSessionTests();
|
||||
var session = test.Create(new[]
|
||||
{
|
||||
new RpgMapEvent { Id = "cat", X = 1, Y = 1, Route = new() { Every = 1, Steps = new() { "right" } } }
|
||||
});
|
||||
session.Tick(10, true);
|
||||
session.Tick(0.5, false);
|
||||
ModAssert.True(session.FindEvent("cat").Cell == new Vector2I(1, 1), "blocked time does not accumulate route delay");
|
||||
session.Tick(0.5, false);
|
||||
ModAssert.True(session.FindEvent("cat").Cell == new Vector2I(2, 1), "route starts after one unblocked second");
|
||||
}
|
||||
|
||||
[ModTest("session/dash_changes_duration_not_destination")]
|
||||
static void DashChangesDurationNotDestination(ModTestContext ctx)
|
||||
{
|
||||
var normal = new RpgWorldSessionTests().Create();
|
||||
var dash = new RpgWorldSessionTests().Create();
|
||||
normal.Tick(0.15, false, RpgDirection.Right);
|
||||
dash.Tick(0.15, false, RpgDirection.Right, true);
|
||||
ModAssert.True(normal.Player.Cell == dash.Player.Cell, "same logical step");
|
||||
ModAssert.True(normal.Player.IsMoving, "normal step takes more time");
|
||||
ModAssert.False(dash.Player.IsMoving, "dash arrives sooner");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user