diff --git a/lib/page_transition/agent_path.dart b/lib/page_transition/agent_path.dart index 6bd9574e..07f1a3e2 100644 --- a/lib/page_transition/agent_path.dart +++ b/lib/page_transition/agent_path.dart @@ -14,28 +14,20 @@ import 'package:icarus/view_cone/vision_geometry.dart'; class AgentTransitionPath { AgentTransitionPath(List points) : points = List.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 points; final List _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; @@ -75,7 +67,6 @@ class AgentTransitionPathPlanner { required List entries, required VisionGeometryMap? geometry, NavigationGeometryMap? navigation, - bool requireNavigation = false, required double startAgentSize, required double endAgentSize, required CoordinateSystem coordinateSystem, @@ -83,21 +74,20 @@ class AgentTransitionPathPlanner { 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 ?? @@ -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 @@ -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, @@ -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}; @@ -249,7 +239,7 @@ class AgentTransitionPathfinder { } } - return AgentTransitionPath.unreachable(start, end); + return AgentTransitionPath.straight(start, end); } _GridNode? _nearestConnectableNode( diff --git a/lib/providers/strategy_page_session_provider.dart b/lib/providers/strategy_page_session_provider.dart index eae5c72a..066523a3 100644 --- a/lib/providers/strategy_page_session_provider.dart +++ b/lib/providers/strategy_page_session_provider.dart @@ -504,10 +504,10 @@ class StrategyPageSessionNotifier extends Notifier { 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 { @@ -515,8 +515,7 @@ class StrategyPageSessionNotifier extends Notifier { 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. } } @@ -541,7 +540,6 @@ class StrategyPageSessionNotifier extends Notifier { entries: entries, geometry: transitionGeometry, navigation: transitionNavigation, - requireNavigation: requireNavigation, startAgentSize: startSettings.agentSize, endAgentSize: endSettings.agentSize, coordinateSystem: CoordinateSystem.instance, diff --git a/lib/services/video_export/video_exporter.dart b/lib/services/video_export/video_exporter.dart index ef05c839..a89d4c23 100644 --- a/lib/services/video_export/video_exporter.dart +++ b/lib/services/video_export/video_exporter.dart @@ -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, diff --git a/test/agent_transition_path_test.dart b/test/agent_transition_path_test.dart index d03162c0..5a95729d 100644 --- a/test/agent_transition_path_test.dart +++ b/test/agent_transition_path_test.dart @@ -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)); }); }); @@ -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', () { diff --git a/test/navigation_geometry_provider_test.dart b/test/navigation_geometry_provider_test.dart index e3b79ae3..964f51c4 100644 --- a/test/navigation_geometry_provider_test.dart +++ b/test/navigation_geometry_provider_test.dart @@ -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); @@ -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); }); @@ -284,37 +282,75 @@ void main() { throwsA(isA().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 = {}; + 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); + }); }