Skip to content
Merged
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
40 changes: 15 additions & 25 deletions lib/page_transition/agent_path.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,28 +14,20 @@ import 'package:icarus/view_cone/vision_geometry.dart';
class AgentTransitionPath {
AgentTransitionPath(List<Offset> points)
: points = List<Offset>.unmodifiable(points),
_cumulativeDistances = _buildCumulativeDistances(points),
_unreachableDestination = null;
_cumulativeDistances = _buildCumulativeDistances(points);

/// Keep an unreachable move at its source until the next page takes over.
/// Inventing a straight route would visibly move the agent through a wall.
AgentTransitionPath.unreachable(Offset start, Offset end)
: points = List.unmodifiable([start]),
_cumulativeDistances = const [0],
_unreachableDestination = end;
/// A move the map has no route for (off the map, onto a box, no map data)
/// goes straight, as every other item does. It may cross a wall; an agent
/// that holds still and then jumps reads as broken.
AgentTransitionPath.straight(Offset start, Offset end) : this([start, end]);

final List<Offset> points;
final List<double> _cumulativeDistances;
final Offset? _unreachableDestination;
bool get isReachable => _unreachableDestination == null;

double get length =>
_cumulativeDistances.isEmpty ? 0 : _cumulativeDistances.last;

Offset positionAt(double progress) {
if (_unreachableDestination != null) {
return progress >= 1 ? _unreachableDestination : points.first;
}
if (points.isEmpty) return Offset.zero;
if (points.length == 1 || length <= _epsilon) return points.last;

Expand Down Expand Up @@ -75,29 +67,27 @@ class AgentTransitionPathPlanner {
required List<PageTransitionEntry> entries,
required VisionGeometryMap? geometry,
NavigationGeometryMap? navigation,
bool requireNavigation = false,
required double startAgentSize,
required double endAgentSize,
required CoordinateSystem coordinateSystem,
}) {
final native = navigation?.geometry ?? geometry?.navigationGeometry;
Offset centerFor(Offset position) =>
position + coordinateSystem.virtualOffsetToWorld(storedAgentAnchor);
if (requireNavigation && native == null) {
// Missing map data cannot establish a safe route. Page navigation still
// finishes, but agents wait for the destination page instead of crossing
// walls along the overlay's default straight-line interpolation.
// Without map data agents go straight. A path, not an empty plan: the
// overlay draws a path from the agent's center, so an agent that changes
// form between pages (a cone or circle composite) stays where it stands.
if (geometry == null && native == null) {
return {
for (final entry in entries)
if (entry.kind == TransitionKind.move &&
entry.visualWidget is PlacedAgentNode)
entry.id: AgentTransitionPath.unreachable(
entry.id: AgentTransitionPath.straight(
centerFor(entry.startPos),
centerFor(entry.endPos),
),
};
}
if (geometry == null && native == null) return const {};
final observerHeight =
navigation?.observerHeightCm ?? geometry!.observerHeight;
final defaultFloor = navigation?.defaultFloorElevationCm ??
Expand Down Expand Up @@ -144,7 +134,7 @@ class AgentTransitionPathPlanner {
);
return route.isReachable
? AgentTransitionPath(route.points)
: AgentTransitionPath.unreachable(startCenter, endCenter);
: AgentTransitionPath.straight(startCenter, endCenter);
}
final routeElevation = startElevation == null
? endElevation
Expand All @@ -165,7 +155,7 @@ class AgentTransitionPathPlanner {
}

/// Legacy SVG movement fallback for maps without a baked player navmesh.
/// Failed searches never manufacture a route through known collision.
/// A failed search goes straight (see [AgentTransitionPath.straight]).
class AgentTransitionPathfinder {
const AgentTransitionPathfinder({
this.gridSpacing = 18,
Expand All @@ -191,14 +181,14 @@ class AgentTransitionPathfinder {

final bounds = layer.boundary?.outerGroup.bounds;
if (bounds == null || !layer.contains(start) || !layer.contains(end)) {
return AgentTransitionPath.unreachable(start, end);
return AgentTransitionPath.straight(start, end);
}

final origin = bounds.topLeft;
final startNode = _nearestConnectableNode(start, origin, layer);
final endNode = _nearestConnectableNode(end, origin, layer);
if (startNode == null || endNode == null) {
return AgentTransitionPath.unreachable(start, end);
return AgentTransitionPath.straight(start, end);
}

final open = <_GridNode>{startNode};
Expand Down Expand Up @@ -249,7 +239,7 @@ class AgentTransitionPathfinder {
}
}

return AgentTransitionPath.unreachable(start, end);
return AgentTransitionPath.straight(start, end);
}

_GridNode? _nearestConnectableNode(
Expand Down
8 changes: 3 additions & 5 deletions lib/providers/strategy_page_session_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -504,19 +504,18 @@ class StrategyPageSessionNotifier extends Notifier<StrategyPageSessionState> {
VisionGeometryMap? transitionGeometry;
NavigationGeometryMap? transitionNavigation;
final map = ref.read(mapProvider).currentMap;
final requireNavigation = ref.read(worldGeometryEnabledProvider(map));
final usesNavigationMesh = ref.read(worldGeometryEnabledProvider(map));
if (needsAgentRouting) {
try {
if (requireNavigation) {
if (usesNavigationMesh) {
transitionNavigation =
await ref.read(navigationGeometryProvider(map).future);
} else {
transitionGeometry =
await ref.read(viewConeGeometryProvider(map).future);
}
} on Object {
// The provider reports the failure. Required native routes hold at
// their sources, so changing pages cannot invent movement through walls.
// The provider reports the failure. Agents then move straight.
}
}

Expand All @@ -541,7 +540,6 @@ class StrategyPageSessionNotifier extends Notifier<StrategyPageSessionState> {
entries: entries,
geometry: transitionGeometry,
navigation: transitionNavigation,
requireNavigation: requireNavigation,
startAgentSize: startSettings.agentSize,
endAgentSize: endSettings.agentSize,
coordinateSystem: CoordinateSystem.instance,
Expand Down
1 change: 0 additions & 1 deletion lib/services/video_export/video_exporter.dart
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,6 @@ class VideoExporter {
entries: entries,
geometry: geometry,
navigation: navigation,
requireNavigation: requireNavigation,
startAgentSize: page.settings.agentSize,
endAgentSize: nextPage.settings.agentSize,
coordinateSystem: CoordinateSystem.instance,
Expand Down
19 changes: 8 additions & 11 deletions test/agent_transition_path_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,11 @@ void main() {
expect(path.positionAt(2), const Offset(8, 9));
});

test('unreachable moves stay at their source until the destination page',
() {
final path = AgentTransitionPath.unreachable(
const Offset(2, 3), const Offset(8, 9));
expect(path.isReachable, isFalse);
expect(path.length, 0);
expect(path.positionAt(.5), const Offset(2, 3));
expect(path.positionAt(.999), const Offset(2, 3));
test('a move with no route goes straight, never holding then jumping', () {
final path =
AgentTransitionPath.straight(const Offset(2, 3), const Offset(8, 9));
expect(path.positionAt(0), const Offset(2, 3));
expect(path.positionAt(.5), const Offset(5, 6));
expect(path.positionAt(1), const Offset(8, 9));
});
});
Expand Down Expand Up @@ -84,10 +81,10 @@ void main() {

const boundedSearch = AgentTransitionPathfinder(
gridSpacing: 10, clearance: 2, maxExpandedNodes: 0);
final unreachable = boundedSearch.findPath(
final unrouted = boundedSearch.findPath(
start: const Offset(20, 30), end: const Offset(100, 30), layer: layer);
expect(unreachable.isReachable, isFalse);
expect(unreachable.positionAt(.5), const Offset(20, 30));
expect(unrouted.points, const [Offset(20, 30), Offset(100, 30)]);
expect(unrouted.positionAt(.5), const Offset(60, 30));
});

test('smoothed paths preserve clearance from wall corners', () {
Expand Down
110 changes: 73 additions & 37 deletions test/navigation_geometry_provider_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -171,12 +171,10 @@ void main() {
entries: entries,
geometry: null,
navigation: navigation,
requireNavigation: true,
startAgentSize: 0,
endAgentSize: 0,
coordinateSystem: coordinates)['sova']!;
final defense = route();
expect(defense.isReachable, isTrue, reason: 'defense offset $offset');
expect(defense.points.first, start);
expect(defense.points.last, end);
expect(defense.points.any((p) => p.dy >= 20), isTrue);
Expand Down Expand Up @@ -242,12 +240,12 @@ void main() {
],
geometry: null,
navigation: navigation,
requireNavigation: true,
startAgentSize: 0,
endAgentSize: 0,
coordinateSystem: coordinates,
);
expect(routes['sova']!.isReachable, isTrue);
expect(navigation.geometry.findRoute(start: start, end: end).isReachable,
isTrue);
expect(routes['sova']!.points.first, start);
expect(routes['sova']!.points.last, end);
});
Expand Down Expand Up @@ -284,37 +282,75 @@ void main() {
throwsA(isA<FormatException>().having((error) => error.message,
'message', 'Invalid world gzip checksum or length.')));
});
for (final requireNavigation in [false, true]) {
test(
'missing navigation ${requireNavigation ? 'holds native movement' : 'preserves the legacy fallback'}',
() {
final coordinates =
CoordinateSystem(playAreaSize: const Size(1920, 1080));
const start = Offset(200, 300), end = Offset(500, 600);
final routes = AgentTransitionPathPlanner.plan(
entries: [
PageTransitionEntry.move(
from:
PlacedAgent(id: 'sova', type: AgentType.sova, position: start),
to: PlacedAgent(id: 'sova', type: AgentType.sova, position: end),
)
],
geometry: null,
requireNavigation: requireNavigation,
startAgentSize: 20,
endAgentSize: 40,
coordinateSystem: coordinates,
);
if (!requireNavigation) {
expect(routes, isEmpty);
} else {
final path = routes['sova']!;
expect(path.isReachable, isFalse);
expect(path.positionAt(.999),
start + coordinates.virtualOffsetToWorld(storedAgentAnchor));
expect(path.positionAt(1),
end + coordinates.virtualOffsetToWorld(storedAgentAnchor));
}
});
}
test('an agent the map has no route for moves straight (an Ascent box top)',
() async {
final navigation = await loadNavigationGeometry(MapValue.ascent);
final mesh = navigation.geometry;
double? floorAt(Offset point) => mesh.floorHeightAt(point,
preferredElevation: navigation.defaultFloorElevationCm);
Offset centerOf(NavigationTriangle triangle) =>
triangle.vertices
.map((vertex) => mesh.vertices[vertex].position)
.reduce((a, b) => a + b) /
3;
final sizes = <int, int>{};
for (final component in mesh.components) {
sizes[component] = (sizes[component] ?? 0) + 1;
}
final mainFloor =
sizes.entries.reduce((a, b) => a.value >= b.value ? a : b).key;
final onFloor = centerOf(mesh.triangles.firstWhere(
(triangle) => mesh.components[triangle.polygon] == mainFloor));
// A patch the walking mesh doesn't join to the floor around it.
final apart = mesh.triangles.map(centerOf).firstWhere((point) =>
mesh
.findRoute(
start: point,
end: onFloor,
startFloorHeight: floorAt(point),
endFloorHeight: floorAt(onFloor))
.failure ==
'disconnected-floors');
final coordinates = CoordinateSystem(playAreaSize: const Size(1920, 1080));
final anchor = coordinates.virtualOffsetToWorld(storedAgentAnchor);
final path = AgentTransitionPathPlanner.plan(
entries: [
PageTransitionEntry.move(
from: PlacedAgent(
id: 'sova', type: AgentType.sova, position: apart - anchor),
to: PlacedAgent(
id: 'sova', type: AgentType.sova, position: onFloor - anchor),
)
],
geometry: null,
navigation: navigation,
startAgentSize: 0,
endAgentSize: 0,
coordinateSystem: coordinates,
)['sova']!;
expect(path.positionAt(0), apart);
expect(path.positionAt(.5), Offset.lerp(apart, onFloor, .5));
expect(path.positionAt(1), onFloor);
});
test('missing navigation moves agents straight between their centers', () {
final coordinates = CoordinateSystem(playAreaSize: const Size(1920, 1080));
const start = Offset(200, 300), end = Offset(500, 600);
final routes = AgentTransitionPathPlanner.plan(
entries: [
PageTransitionEntry.move(
from: PlacedAgent(id: 'sova', type: AgentType.sova, position: start),
to: PlacedAgent(id: 'sova', type: AgentType.sova, position: end),
)
],
geometry: null,
startAgentSize: 20,
endAgentSize: 40,
coordinateSystem: coordinates,
);
final anchor = coordinates.virtualOffsetToWorld(storedAgentAnchor);
final path = routes['sova']!;
expect(path.positionAt(0), start + anchor);
expect(path.positionAt(.5), (start + end) / 2 + anchor);
expect(path.positionAt(1), end + anchor);
});
}
Loading