diff --git a/lib/page_transition/agent_path.dart b/lib/page_transition/agent_path.dart index 2649d2fa..6bd9574e 100644 --- a/lib/page_transition/agent_path.dart +++ b/lib/page_transition/agent_path.dart @@ -81,9 +81,8 @@ class AgentTransitionPathPlanner { required CoordinateSystem coordinateSystem, }) { final native = navigation?.geometry ?? geometry?.navigationGeometry; - Offset centerFor(Offset position, double size) => - position + - coordinateSystem.virtualOffsetToWorld(Offset(size / 2, size / 2)); + 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 @@ -93,8 +92,8 @@ class AgentTransitionPathPlanner { if (entry.kind == TransitionKind.move && entry.visualWidget is PlacedAgentNode) entry.id: AgentTransitionPath.unreachable( - centerFor(entry.startPos, startAgentSize), - centerFor(entry.endPos, endAgentSize), + centerFor(entry.startPos), + centerFor(entry.endPos), ), }; } @@ -129,8 +128,8 @@ class AgentTransitionPathPlanner { if (entry.kind == TransitionKind.move && entry.visualWidget is PlacedAgentNode) entry.id: () { - final startCenter = centerFor(entry.startPos, startAgentSize); - final endCenter = centerFor(entry.endPos, endAgentSize); + final startCenter = centerFor(entry.startPos); + final endCenter = centerFor(entry.endPos); final startElevation = elevationFor(entry.from!, startCenter); final endElevation = elevationFor(entry.to!, endCenter); if (native != null) { diff --git a/lib/widgets/draggable_widgets/agents/agent_widget.dart b/lib/widgets/draggable_widgets/agents/agent_widget.dart index d63b8cf8..318310d6 100644 --- a/lib/widgets/draggable_widgets/agents/agent_widget.dart +++ b/lib/widgets/draggable_widgets/agents/agent_widget.dart @@ -9,6 +9,7 @@ import 'package:icarus/const/line_provider.dart'; import 'package:icarus/const/maps.dart'; import 'package:icarus/const/placed_classes.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/const/transition_data.dart'; import 'package:icarus/const/utilities.dart'; import 'package:icarus/providers/ability_bar_provider.dart'; import 'package:icarus/providers/action_provider.dart'; @@ -245,7 +246,7 @@ class AgentWidget extends ConsumerWidget { coordinateSystem.positionForSide( canonicalPosition: viewConeAgent!.position + coordinateSystem.virtualOffsetToWorld( - Offset(agentSize / 2, agentSize / 2), + storedAgentAnchor, ), reflectionOffset: Offset.zero, isAttack: mapState!.isAttack, @@ -327,7 +328,7 @@ class AgentWidget extends ConsumerWidget { model: svgHeightModel, canonicalOrigin: viewConeAgent.position + coordinateSystem.virtualOffsetToWorld( - Offset(agentSize / 2, agentSize / 2), + storedAgentAnchor, ), rotation: coordinateSystem.rotationForSide( viewConeAgent.rotation, diff --git a/lib/widgets/draggable_widgets/agents/placed_view_cone_agent_widget.dart b/lib/widgets/draggable_widgets/agents/placed_view_cone_agent_widget.dart index b9342fb1..af07c6c4 100644 --- a/lib/widgets/draggable_widgets/agents/placed_view_cone_agent_widget.dart +++ b/lib/widgets/draggable_widgets/agents/placed_view_cone_agent_widget.dart @@ -113,7 +113,7 @@ class ViewConeAgentComposite extends ConsumerWidget { (clipToGeometry ? agent.position + coordinateSystem.virtualOffsetToWorld( - Offset(agentSize / 2, agentSize / 2), + storedAgentAnchor, ) : null), visionElevation: agent.visionElevation, @@ -303,8 +303,7 @@ class _PlacedViewConeAgentWidgetState dragAnchorStrategy: (draggable, context, position) { _dragOrigin.start( origin: current.position + - coordinateSystem.virtualOffsetToWorld( - Offset(agentSize / 2, agentSize / 2)), + coordinateSystem.virtualOffsetToWorld(storedAgentAnchor), coordinates: coordinateSystem, zoom: ref.read(screenZoomProvider), isAttack: isAttack); diff --git a/lib/widgets/page_transition_overlay.dart b/lib/widgets/page_transition_overlay.dart index 214c275a..c65d52dd 100644 --- a/lib/widgets/page_transition_overlay.dart +++ b/lib/widgets/page_transition_overlay.dart @@ -235,7 +235,7 @@ class _EntryRenderer { ? null : pathPosition - coordinateSystem.virtualOffsetToWorld( - Offset(agentSize / 2, agentSize / 2), + storedAgentAnchor, ); final coordinatePosition = pathTopLeft ?? (Offset.lerp(entry.startPos, entry.endPos, t) ?? entry.endPos); diff --git a/test/navigation_geometry_provider_test.dart b/test/navigation_geometry_provider_test.dart index 95271616..41a6f06f 100644 --- a/test/navigation_geometry_provider_test.dart +++ b/test/navigation_geometry_provider_test.dart @@ -136,13 +136,16 @@ void main() { 0 ]); const start = Offset(5, 5), end = Offset(25, 5); + final coordinates = CoordinateSystem(playAreaSize: const Size(1920, 1080)); + final anchor = coordinates.virtualOffsetToWorld(storedAgentAnchor); final entries = [ PageTransitionEntry.move( - from: PlacedAgent(id: 'sova', type: AgentType.sova, position: start), - to: PlacedAgent(id: 'sova', type: AgentType.sova, position: end), + from: PlacedAgent( + id: 'sova', type: AgentType.sova, position: start - anchor), + to: PlacedAgent( + id: 'sova', type: AgentType.sova, position: end - anchor), ) ]; - final coordinates = CoordinateSystem(playAreaSize: const Size(1920, 1080)); for (final offset in [ Offset.zero, const Offset(100, 50), @@ -217,11 +220,14 @@ void main() { VisionGeometryMap.projectUv(MapValue.split, const Offset(.3, .4)); final end = VisionGeometryMap.projectUv(MapValue.split, const Offset(.7, .6)); + final anchor = coordinates.virtualOffsetToWorld(storedAgentAnchor); 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), + from: PlacedAgent( + id: 'sova', type: AgentType.sova, position: start - anchor), + to: PlacedAgent( + id: 'sova', type: AgentType.sova, position: end - anchor), ) ], geometry: null, @@ -281,9 +287,9 @@ void main() { final path = routes['sova']!; expect(path.isReachable, isFalse); expect(path.positionAt(.999), - start + coordinates.virtualOffsetToWorld(const Offset(10, 10))); + start + coordinates.virtualOffsetToWorld(storedAgentAnchor)); expect(path.positionAt(1), - end + coordinates.virtualOffsetToWorld(const Offset(20, 20))); + end + coordinates.virtualOffsetToWorld(storedAgentAnchor)); } }); } diff --git a/test/page_transition_overlay_test.dart b/test/page_transition_overlay_test.dart index efe3604c..0ffc3732 100644 --- a/test/page_transition_overlay_test.dart +++ b/test/page_transition_overlay_test.dart @@ -10,8 +10,10 @@ import 'package:icarus/const/placed_classes.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/const/transition_data.dart'; import 'package:icarus/const/utilities.dart'; +import 'package:icarus/page_transition/agent_path.dart'; import 'package:icarus/providers/map_provider.dart'; import 'package:icarus/providers/transition_provider.dart'; +import 'package:icarus/widgets/draggable_widgets/agents/agent_widget.dart'; import 'package:icarus/widgets/draggable_widgets/utilities/role_icon_utility_widget.dart'; import 'package:icarus/widgets/draggable_widgets/utilities/view_cone_widget.dart'; import 'package:icarus/widgets/mouse_watch.dart'; @@ -286,7 +288,7 @@ void main() { preview.worldOrigin, animatedPosition + CoordinateSystem.instance.virtualOffsetToWorld( - const Offset(20, 20), + storedAgentAnchor, ), ); @@ -299,6 +301,61 @@ void main() { await tester.pump(); }); + for (final attack in [true, false]) { + testWidgets('animated cone and marker follow the path center, side=$attack', + (tester) async { + final container = attack ? _createContainer() : _createDefenseContainer(); + addTearDown(container.dispose); + final coordinates = CoordinateSystem.instance; + const startCenter = Offset(500, 250), endCenter = Offset(700, 350); + final anchor = coordinates.virtualOffsetToWorld(storedAgentAnchor); + final from = PlacedViewConeAgent( + id: 'path-cone', + type: AgentType.jett, + presetType: UtilityType.viewCone90, + position: startCenter - anchor, + rotation: .3, + length: 90, + ); + final to = from.copyWith(position: endCenter - anchor); + final path = AgentTransitionPath([startCenter, endCenter]); + for (final size in [15.0, 25.0, 35.0, 45.0]) { + for (final progress in [0.0, .5, 1.0]) { + await tester.pumpWidget(UncontrolledProviderScope( + container: container, + child: ShadApp( + home: Scaffold( + body: TransitionEntriesLayer( + entries: [PageTransitionEntry.move(from: from, to: to)], + agentPaths: {from.id: path}, + t: progress, + direction: PageTransitionDirection.forward, + agentSize: size, + abilitySize: 40, + ))), + )); + final center = path.positionAt(progress); + final cone = + tester.widget(find.byType(ViewConeWidget)); + expect((cone.worldOrigin! - center).distance, lessThan(1e-7), + reason: + 'size=$size progress=$progress must raycast from the path'); + final expectedScreen = coordinates.coordinateToScreen( + coordinates.positionForSide( + canonicalPosition: center, + reflectionOffset: Offset.zero, + isAttack: attack)); + expect( + (tester.getCenter(find.byType(AgentWidget)) - expectedScreen) + .distance, + lessThan(1e-7), + reason: 'The marker must follow that same center.'); + } + } + await tester.pumpWidget(const SizedBox.shrink()); + }); + } + testWidgets( 'defense appear and disappear offsets keep view-cone geometry aligned', (tester) async { diff --git a/test/view_cone_agent_anchor_test.dart b/test/view_cone_agent_anchor_test.dart new file mode 100644 index 00000000..aa9c4919 --- /dev/null +++ b/test/view_cone_agent_anchor_test.dart @@ -0,0 +1,174 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:math' as math; +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/const/agents.dart'; +import 'package:icarus/const/app_provider_container.dart'; +import 'package:icarus/const/coordinate_system.dart'; +import 'package:icarus/const/maps.dart'; +import 'package:icarus/const/placed_classes.dart'; +import 'package:icarus/const/settings.dart'; +import 'package:icarus/const/utilities.dart'; +import 'package:icarus/providers/agent_provider.dart'; +import 'package:icarus/providers/map_provider.dart'; +import 'package:icarus/providers/strategy_settings_provider.dart'; +import 'package:icarus/providers/svg_height_runtime_provider.dart'; +import 'package:icarus/view_cone/svg_height_visibility.dart'; +import 'package:icarus/widgets/draggable_widgets/agents/agent_widget.dart'; +import 'package:icarus/widgets/draggable_widgets/agents/placed_view_cone_agent_widget.dart'; +import 'package:icarus/widgets/draggable_widgets/utilities/svg_height_view_cone.dart'; +import 'package:icarus/widgets/draggable_widgets/utilities/view_cone_widget.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +class _HavenMap extends MapProvider { + @override + MapState build() => MapState(currentMap: MapValue.haven, isAttack: false); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + late SvgHeightRuntime runtime; + setUpAll(() { + appProviderContainer = ProviderContainer(); + SvgHeightVisibility load(String side) { + final model = SvgHeightVisibility.fromJson(jsonDecode(utf8.decode( + gzip.decode(File('assets/maps/haven_svg_height_$side.json.gz') + .readAsBytesSync())))); + final library = Platform.environment['ICARUS_SVG_NATIVE_LIBRARY']; + if (library != null) { + expect(model.enableNativeAcceleration(libraryPath: library), isTrue); + } + return model; + } + + runtime = SvgHeightRuntime.forMap( + MapValue.haven, load('attack'), load('defense')); + }); + tearDownAll(() { + runtime.dispose(); + appProviderContainer.dispose(); + }); + + for (final attack in [false, true]) { + testWidgets( + 'Haven cone stays on its walls when markers resize, side=$attack', + (tester) async { + tester.view.physicalSize = const Size(1920, 1080); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final coordinates = + CoordinateSystem(playAreaSize: const Size(1920, 1080)); + final container = ProviderContainer(overrides: [ + mapProvider.overrideWith(_HavenMap.new), + svgHeightRuntimeProvider.overrideWith((ref, map) async => runtime), + ]); + addTearDown(container.dispose); + container.read(mapProvider.notifier).fromHive(MapValue.haven, attack); + + // The reported pose, expressed at the historical marker size. Resizing + // changes presentation only, so every size must paint the same sightline. + const canonicalCenter = Offset(885.124, 437.01); + final agent = PlacedViewConeAgent( + id: 'haven-resized-cone', + type: AgentType.neon, + presetType: UtilityType.viewCone90, + position: canonicalCenter - + coordinates.virtualOffsetToWorld( + const Offset(Settings.agentSize / 2, Settings.agentSize / 2)), + rotation: -1.505 - math.pi / 2, + length: 162.584 * + SvgHeightMapTransform.forMap(MapValue.haven).scale / + coordinates.virtualLengthToWorld(1), + ); + container.read(agentProvider.notifier).fromHive([agent]); + await tester.pumpWidget(UncontrolledProviderScope( + container: container, + child: ShadApp( + home: Scaffold( + body: Stack(children: [ + PlacedViewConeAgentWidget(agent: agent, onDragEnd: (_, __) {}), + ]))), + )); + await tester.pump(); + await tester.pump(); + + Offset display(Offset canonical) => + coordinates.coordinateToScreen(coordinates.positionForSide( + canonicalPosition: canonical, + reflectionOffset: Offset.zero, + isAttack: attack)); + + void expectAligned() { + final origin = tester + .widget(find.byType(ViewConeWidget)) + .worldOrigin!; + final center = tester.getCenter(find.byType(AgentWidget)); + expect((display(origin) - center).distance, lessThan(1e-7), + reason: 'Raycasting must start at the rendered marker center.'); + } + + Uint8List? baseline; + for (final size in [35.0, 15.0, 25.0, 45.0, 35.0]) { + container.read(strategySettingsProvider.notifier).updateAgentSize(size); + await tester.pump(); + expectAligned(); + final finder = find.byWidgetPredicate((widget) => + widget is CustomPaint && + widget.painter is SvgHeightViewConePainter); + final box = tester.renderObject(finder); + final painter = tester.widget(finder).painter!; + final pixels = (await tester.runAsync(() async { + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + final center = display(canonicalCenter); + canvas.translate(160 - center.dx, 160 - center.dy); + canvas.transform(box.getTransformTo(null).storage); + painter.paint(canvas, box.size); + final picture = recorder.endRecording(); + final image = await picture.toImage(320, 320); + final bytes = + (await image.toByteData(format: ui.ImageByteFormat.rawRgba))! + .buffer + .asUint8List(); + image.dispose(); + picture.dispose(); + return bytes; + }))!; + if (baseline == null) { + baseline = pixels; + expect([ + for (var i = 3; i < pixels.length; i += 4) pixels[i] + ], contains(greaterThan(0)), reason: 'The cone must actually paint.'); + } else { + expect(pixels, orderedEquals(baseline), + reason: + 'Marker size $size must not move wall contacts or shadows.'); + } + } + + for (final size in [15.0, 45.0]) { + container.read(strategySettingsProvider.notifier).updateAgentSize(size); + await tester.pump(); + final gesture = await tester + .startGesture(tester.getCenter(find.byType(AgentWidget))); + await gesture.moveBy(const Offset(12, 6)); + await tester.pump(); + expectAligned(); + await gesture.moveBy(const Offset(5, -3)); + await tester.pump(); + expectAligned(); + expect(container.read(agentProvider).single.position, agent.position, + reason: 'Previewing a drag must not save a position.'); + await gesture.up(); + await tester.pump(); + } + await tester.pumpWidget(const SizedBox.shrink()); + }); + } +}