Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/navigation.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ Everything is on `host.Automation.Navigation`. A host without navigation answers
| `GoTo(objectId, arrivalMeters)` | Walks to an object and ends beside it, facing it, within `arrivalMeters` (above 0, at most 50) at a spot with a line of sight to it. |
| `GoTo(position, arrivalMeters)` | Walks to a place and ends on the floor that place stands on. Faces nothing on arrival. A position with no cell, such as one read from a route file, is placed by its map coordinates alone, and an elevation of NaN stands it on the ground there. |
| `PreviewPathAsync(objectId, arrivalMeters)` | Plans a path to an object and returns ordered cell-aware positions with elevation. Does not start or replace a walk. |
| `PreviewPathAsync(position, arrivalMeters)` | Plans to a place on its specified floor, including inside a dungeon. Does not start or replace a walk. |
| `PreviewPathAsync(position, arrivalMeters)` | Plans to a place on its specified floor, including inside a dungeon. A place in a dungeon or building cell stands on the floor under it, so a dungeon map cell's `Center` can be given as it is. Does not start or replace a walk. |
| `StandOn(objectId, arrivalMeters)` | Walks onto an object and ends on its top, jumping up where the character can. |
| `Follow(playerId, bufferMeters)` | Follows a player until stopped: see [Following](#following). Only players can be followed. |
| `StopGoTo()` | Ends the walk under way; `Rejected` when there is none. |
Expand Down Expand Up @@ -320,7 +320,7 @@ Reports are snapshots, safe to read on any plugin tick. Check that `Sequence` is
| Goal | Where the walk ends |
|---|---|
| An object (`go to`) | Beside it, within the arrival distance of its side, at a spot that can see that side, facing it. An object whose collision is in the grid, such as a life stone or a sign, hides its own middle, so its side is what counts. Failing that, the nearest spot that can see it, up to 10 m away, as `Arrived` with a reason. Failing that, the nearest spot a walk reaches, as `ArrivedWithoutSight`. |
| A place (`go to` a cell or map point) | On the floor the place stands on, within the arrival distance. A place on a rock top is arrived at on the rock, or not at all: a route that only reaches the ground beside it ends `NoRoute`. A place joined to the ground by a ramp or stairs is reached up them. |
| A place (`go to` a cell or map point) | On the floor the place stands on, within the arrival distance. A place on a rock top is arrived at on the rock, or not at all: a route that only reaches the ground beside it ends `NoRoute`. A place joined to the ground by a ramp or stairs is reached up them. A place in a dungeon or building cell is first stood on the floor under it: the cell's own floor nearest at or below it (or up to 0.5 m above it), or where the cell has none there, as in the upper half of a tall room authored as a cell on top of a cell, the nearest floor of any cell below it. Preview and walk place it the same way. |
| A player (`follow`) | Never ends by itself: see [Following](#following). |
| An object's top (`stand on`) | On the highest floor over the object's own collision that a body stands on, within the arrival distance of that top's middle. A ramp or stair up to a deck is not the deck. An object with nothing on top to stand on, such as a creature or a thin wall, or no way up, ends `NoRoute`. |

Expand Down
24 changes: 24 additions & 0 deletions src/AcDream.Core/Physics/CellSurface.cs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,30 @@ public CellSurface(
return null;
}

/// <summary>
/// The height of the highest level face under a point that is no higher than
/// <paramref name="atMostZ"/>, or null where the cell has none: the floor a point
/// in the cell stands on, not the ceiling over it.
/// </summary>
public float? SampleLevelZAtOrBelow(float worldX, float worldY, float atMostZ)
{
float? highest = null;
foreach (var (a, b, c) in _triangles)
{
Vector3 normal = Vector3.Cross(b - a, c - a);
float length = normal.Length();
if (length <= 1e-6f || MathF.Abs(normal.Z) / length < PhysicsGlobals.FloorZ)
continue;
if (PointInTriangleXY(worldX, worldY, a, b, c, out float z)
&& z <= atMostZ
&& (highest is null || z > highest))
{
highest = z;
}
}
return highest;
}


private static bool PointInTriangleXY(
float px, float py,
Expand Down
5 changes: 4 additions & 1 deletion src/AcDream.Plugin.Abstractions/DungeonMapAutomation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@ namespace AcDream.Plugin.Abstractions;
/// One cell of a floorplan: where it is and which layer it was drawn in.
/// </summary>
/// <param name="CellId">The full cell id, landblock in the high half.</param>
/// <param name="Center">The middle of the cell's geometry, in landblock-local metres.</param>
/// <param name="Center">
/// The middle of the cell's geometry, in landblock-local metres. In a tall room it is well
/// above the floor; a walk or preview to it stands it on the floor under it.
/// </param>
/// <param name="LayerZ">The <see cref="PluginDungeonLayer.Z"/> of the layer the cell is drawn in.</param>
public readonly record struct PluginDungeonCell(uint CellId, Vector3 Center, float LayerZ);

Expand Down
8 changes: 6 additions & 2 deletions src/AcDream.Plugin.Abstractions/NavigationAutomation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -475,7 +475,9 @@ Task<PluginNavigationPlan> PreviewPathAsync(uint objectId, float arrivalMeters =
/// <summary>
/// Plans a path to a cell-aware position without moving or taking ownership of
/// the character. Call from the thread that raises <see cref="IEvents.Tick"/>.
/// An elevation of NaN selects ground at the destination.
/// An elevation of NaN selects ground at the destination. A position in a dungeon or
/// building cell stands on the floor under it, so a <see cref="PluginDungeonCell.Center"/>
/// may be given as it is.
/// </summary>
Task<PluginNavigationPlan> PreviewPathAsync(PluginNavigationPosition position, float arrivalMeters = 2.5f) =>
Task.FromResult(new PluginNavigationPlan(PluginNavigationPlanStatus.Unavailable, [], "navigation is unavailable"));
Expand Down Expand Up @@ -656,7 +658,9 @@ PluginNavigationCommandStatus GoTo(uint objectId, float arrivalMeters) =>
/// <paramref name="arrivalMeters"/> of <paramref name="position"/> on the floor that
/// position stands on, and waiting while something else needs the character. A position
/// on a rock top is arrived at on the rock, or not at all. An elevation of NaN stands the
/// position on the ground there, for map coordinates given without a height. It does not turn the character to face
/// position on the ground there, for map coordinates given without a height. A position in
/// a dungeon or building cell stands on the floor under it, the floor of the cell below
/// where its own cell has none there, as in the upper half of a tall room. It does not turn the character to face
/// anything on arrival. A position with no cell, such as one read from a VTank route
/// file, is placed by its map coordinates alone. <see cref="GoToReport"/> reports the
/// walk with no object id.
Expand Down
47 changes: 44 additions & 3 deletions src/AcDream.Runtime/Navigation/RuntimeNavigationWalkSources.cs
Original file line number Diff line number Diff line change
Expand Up @@ -405,13 +405,54 @@ public bool TryLocatePlace(uint cellId, Vector3 local, out Vector3 position)
return false;
}
AcDream.Core.Physics.Position here = controller.CellPosition;
position = OnTheGround(
position = OnTheCellFloor(
_physics,
controller.Position + PlaceOffset(cellId, local, here.ObjCellId, here.Frame.Origin),
controller.Position.Z);
cellId,
OnTheGround(
_physics,
controller.Position + PlaceOffset(cellId, local, here.ObjCellId, here.Frame.Origin),
controller.Position.Z));
return true;
}

/// <summary>
/// How far above a floor a place may be given and still stand on it, so a place given at
/// its floor's height, a little out, is not dropped to a floor below.
/// </summary>
internal const float PlaceAboveFloorMeters = 0.5f;

/// <summary>
/// A place in an interior cell stood on the floor under it: the cell's own floor nearest at
/// or below the place, or where the cell has none there, as in the upper half of a tall room
/// authored as a cell on top of a cell, the nearest floor of any cell in its landblock. A
/// place in a cell is often given by the middle of the cell's bounds, which in a tall room
/// is metres above the floor, higher than a walk can arrive at. An outdoor place, or one with
/// no floor under it, is left as it is.
/// </summary>
internal static Vector3 OnTheCellFloor(PhysicsEngine physics, uint cellId, Vector3 point)
{
ArgumentNullException.ThrowIfNull(physics);
if ((cellId & 0xFFFFu) < 0x0100u || (cellId & 0xFFFFu) == 0xFFFFu || float.IsNaN(point.Z)
|| !physics.TryGetLandblockCollision((cellId & 0xFFFF0000u) | 0xFFFFu, out _, out IReadOnlyList<CellSurface> cells, out _))
{
return point;
}
float atMost = point.Z + PlaceAboveFloorMeters;
float? own = null;
float? any = null;
foreach (CellSurface cell in cells)
{
if (cell.SampleLevelZAtOrBelow(point.X, point.Y, atMost) is not { } z)
continue;
if (cell.CellId == cellId)
own = own is null ? z : MathF.Max(own.Value, z);
any = any is null ? z : MathF.Max(any.Value, z);
}
return own is { } floor ? point with { Z = floor }
: any is { } below ? point with { Z = below }
: point;
}

/// <summary>
/// A point whose height is not known stood on the terrain under it, or at
/// <paramref name="fallbackZ"/> where no terrain under it is loaded yet, as for a far goal
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
using System.Numerics;
using AcDream.Core.Navigation;
using AcDream.Core.Physics;
using AcDream.Runtime.Navigation;
using Xunit.Abstractions;

namespace AcDream.App.Tests.Navigation;

/// <summary>
/// Every cell of an installed dungeon routed to by the middle of its bounds, the place a
/// dungeon map gives for it, as it is given and as it stands on the floor under it.
/// </summary>
[Trait("Lane", "InstalledDat")]
[Trait("Purpose", "Diagnostic")]
public sealed class DungeonPlaceFloorInstalledDatTests
{
private readonly ITestOutputHelper _output;

public DungeonPlaceFloorInstalledDatTests(ITestOutputHelper output) => _output = output;

/// <summary>
/// 0x5B48's wings are joined by more than its cells, so from its largest wing about 265 of
/// its cells route on the floor and few as given; 0x5948 routes nearly every cell.
/// </summary>
[Theory]
[InlineData(0x5B48FFFFu, 0x5B48013Cu)]
[InlineData(0x5948FFFFu, 0x59480100u)]
public void TheMiddleOfEveryCellRoutesOnceItStandsOnTheFloor(uint dungeon, uint startCell)
{
string? directory = System.Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR");
Assert.False(string.IsNullOrWhiteSpace(directory), "Set ACDREAM_DAT_DIR to the installed game files.");
uint[] cellIds = [.. Enumerable.Range(0x100, 0x400).Select(offset => (dungeon & 0xFFFF0000u) | (uint)offset)];
PublishedLandblock world = PublishedLandblock.Load(directory!, dungeon, cellIds);
Assert.True(world.Engine.TryGetLandblockCollision(dungeon, out _, out IReadOnlyList<CellSurface> cells, out _));
Assert.True(NavGeometry.TryMeasureCells(world.Engine, dungeon, out Vector2 minimum, out Vector2 maximum));
float size = MathF.Ceiling((MathF.Max(maximum.X - minimum.X, maximum.Y - minimum.Y) + 48f) / 16f) * 16f;
float originX = MathF.Floor((((minimum.X + maximum.X) * 0.5f) - (size * 0.5f)) / NavGrid.DefaultCellSize) * NavGrid.DefaultCellSize;
float originY = MathF.Floor((((minimum.Y + maximum.Y) * 0.5f) - (size * 0.5f)) / NavGrid.DefaultCellSize) * NavGrid.DefaultCellSize;
NavGrid grid = NavGrid.Build(NavGeometry.CaptureDungeon(world.Engine, dungeon, originX, originY, size)!, world.Body);

int startNode = grid.FindWalkableNode(world.CellOrigins[startCell], 1f, 1.5f);
Assert.True(startNode >= 0);
Vector3 start = grid.Position(startNode);
float arrival = NavigationWalkController.PlanningRadius(2.5f);
int asGiven = 0;
int onTheFloor = 0;
int tall = 0;
int floorless = 0;
var reasons = new Dictionary<string, int>();
var flooredReasons = new Dictionary<string, int>();
foreach (CellSurface cell in cells)
{
var low = new Vector3(float.PositiveInfinity);
var high = new Vector3(float.NegativeInfinity);
foreach ((Vector3 a, Vector3 b, Vector3 c) in cell.Triangles)
{
low = Vector3.Min(low, Vector3.Min(a, Vector3.Min(b, c)));
high = Vector3.Max(high, Vector3.Max(a, Vector3.Max(b, c)));
}
if (float.IsInfinity(low.X))
continue;
Vector3 middle = (low + high) * 0.5f;
Vector3 placed = RuntimeNavigationGoalSource.OnTheCellFloor(world.Engine, cell.CellId, middle);
if (middle.Z - placed.Z > NavRouter.GoalHeightTolerance)
tall++;
if (cell.SampleLevelZAtOrBelow(middle.X, middle.Y, middle.Z) is null)
floorless++;
NavRoute raw = NavRouter.Find(grid, start, middle, arrival, arriveOnGoalFloor: true);
if (raw.Outcome == NavRouteOutcome.Routed)
asGiven++;
else
reasons[raw.Reason ?? "(none)"] = reasons.GetValueOrDefault(raw.Reason ?? "(none)") + 1;
NavRoute floored = NavRouter.Find(grid, start, placed, arrival, arriveOnGoalFloor: true);
if (floored.Outcome == NavRouteOutcome.Routed)
onTheFloor++;
else
flooredReasons[floored.Reason ?? "(none)"] = flooredReasons.GetValueOrDefault(floored.Reason ?? "(none)") + 1;
}

_output.WriteLine($"0x{dungeon >> 16:X4}: {cells.Count} cells, {tall} with their middle higher over the floor than a walk arrives, {floorless} with no floor of their own under it");
_output.WriteLine($" routed as given: {asGiven}; routed on the floor: {onTheFloor}");
foreach ((string reason, int count) in reasons.OrderByDescending(pair => pair.Value).Take(5))
_output.WriteLine($" {count} as given: {reason}");
foreach ((string reason, int count) in flooredReasons.OrderByDescending(pair => pair.Value).Take(5))
_output.WriteLine($" {count} on the floor: {reason}");
Assert.True(onTheFloor > asGiven);
}

}
Loading