From c17a463d3f6e2c413a6cc08d50aa0bf3ee4fff0c Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Thu, 6 Aug 2026 10:59:26 -0700 Subject: [PATCH 1/4] feat: add ability vision cones --- lib/const/ability_vision.dart | 60 ++++++ lib/const/folder_icons.dart | 2 +- lib/const/placed_classes.dart | 12 +- lib/const/placed_classes.g.dart | 2 + lib/const/settings.dart | 2 +- lib/hive/hive_adapters.g.dart | 7 +- lib/hive/hive_adapters.g.yaml | 4 +- .../ability_vision_cone_migration.dart | 38 ++++ lib/providers/strategy_provider.dart | 41 ++++- .../ability_visibility_context_menu.dart | 15 +- .../ability_vision_cone_composite.dart | 143 +++++++++++++++ .../ability/placed_ability_widget.dart | 171 ++++++++++++++++++ .../placed_widget_builder.dart | 18 +- lib/widgets/page_transition_overlay.dart | 44 +++++ test/ability_visibility_widgets_test.dart | 62 +++++++ test/ability_vision_cone_migration_test.dart | 94 ++++++++++ test/ability_vision_cone_test.dart | 56 ++++++ test/ability_visual_state_test.dart | 3 + test/page_transition_overlay_test.dart | 47 +++++ test/strategy_integrity_test.dart | 77 ++++++++ 20 files changed, 886 insertions(+), 12 deletions(-) create mode 100644 lib/const/ability_vision.dart create mode 100644 lib/migrations/ability_vision_cone_migration.dart create mode 100644 lib/widgets/draggable_widgets/ability/ability_vision_cone_composite.dart create mode 100644 test/ability_vision_cone_migration_test.dart create mode 100644 test/ability_vision_cone_test.dart diff --git a/lib/const/ability_vision.dart b/lib/const/ability_vision.dart new file mode 100644 index 00000000..527317b1 --- /dev/null +++ b/lib/const/ability_vision.dart @@ -0,0 +1,60 @@ +import 'dart:math' as math; + +import 'package:icarus/const/agents.dart'; +import 'package:icarus/const/utilities.dart'; + +/// The directional vision an ability contributes to a strategy. +class AbilityVisionConeSpec { + const AbilityVisionConeSpec({ + required this.angleDegrees, + this.maxRangeMeters, + }); + + /// Full rendered wedge angle in degrees. + final double angleDegrees; + + /// Fixed in-game detection distance. Null uses the normal adjustable + /// line-of-sight cone length. + final double? maxRangeMeters; + + static const double _defaultLength = 50; + + static const Map<(AgentType, int), AbilityVisionConeSpec> _specs = { + (AgentType.killjoy, 2): AbilityVisionConeSpec(angleDegrees: 100), + (AgentType.cypher, 2): AbilityVisionConeSpec(angleDegrees: 103), + (AgentType.raze, 0): AbilityVisionConeSpec( + angleDegrees: 40, + maxRangeMeters: 15, + ), + (AgentType.gekko, 1): AbilityVisionConeSpec( + angleDegrees: 40, + maxRangeMeters: 15, + ), + }; + + static AbilityVisionConeSpec? forAbility(AbilityInfo ability) { + return _specs[(ability.type, ability.index)]; + } + + double maximumLength(double mapScale) { + final rangeMeters = maxRangeMeters; + if (rangeMeters == null) { + return ViewConeUtility.maxLength; + } + return math.min( + rangeMeters * AgentData.inGameMeters * mapScale, + ViewConeUtility.maxLength, + ); + } + + double resolveLength({ + required double storedLength, + required double mapScale, + }) { + final maximum = maximumLength(mapScale); + final minimum = math.min(ViewConeUtility.minLength, maximum); + final fallback = maxRangeMeters == null ? _defaultLength : maximum; + final requested = storedLength > 0 ? storedLength : fallback; + return requested.clamp(minimum, maximum); + } +} diff --git a/lib/const/folder_icons.dart b/lib/const/folder_icons.dart index 37b34049..bbb055fa 100644 --- a/lib/const/folder_icons.dart +++ b/lib/const/folder_icons.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:icarus/const/custom_icons.dart'; -const int folderIconRegistryVersion = 94; +const int folderIconRegistryVersion = 95; enum FolderIconRenderKind { material, diff --git a/lib/const/placed_classes.dart b/lib/const/placed_classes.dart index 71e0bf55..2e4b93ab 100644 --- a/lib/const/placed_classes.dart +++ b/lib/const/placed_classes.dart @@ -5,6 +5,7 @@ import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:hive_ce_flutter/hive_flutter.dart'; import 'package:icarus/const/abilities.dart'; +import 'package:icarus/const/ability_vision.dart'; import 'package:icarus/const/agents.dart'; import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/const/json_converters.dart'; @@ -937,9 +938,10 @@ class PlacedAbility extends PlacedWidget { abilitySize: Settings.abilitySize, ); final abilityData = data.abilityData!; - final shouldRotate = isRotatable(abilityData); + final hasVisionCone = AbilityVisionConeSpec.forAbility(data) != null; + final shouldRotate = isRotatable(abilityData) || hasVisionCone; final shouldUseRotatableFlipCompensation = - shouldRotate && abilityData is! DeadlockBarrierMeshAbility; + isRotatable(abilityData) && abilityData is! DeadlockBarrierMeshAbility; final scaledAbilitySize = fullAbilityWidgetSize.scale( CoordinateSystem.instance.scaleFactor, @@ -1047,6 +1049,7 @@ class AbilityVisualState { this.showRangeFill = true, this.showInnerOutline = true, this.showInnerFill = true, + this.showVisionCone = true, }); @JsonKey(defaultValue: true) @@ -1061,17 +1064,22 @@ class AbilityVisualState { @JsonKey(defaultValue: true) final bool showInnerFill; + @JsonKey(defaultValue: true) + final bool showVisionCone; + AbilityVisualState copyWith({ bool? showRangeOutline, bool? showRangeFill, bool? showInnerOutline, bool? showInnerFill, + bool? showVisionCone, }) { return AbilityVisualState( showRangeOutline: showRangeOutline ?? this.showRangeOutline, showRangeFill: showRangeFill ?? this.showRangeFill, showInnerOutline: showInnerOutline ?? this.showInnerOutline, showInnerFill: showInnerFill ?? this.showInnerFill, + showVisionCone: showVisionCone ?? this.showVisionCone, ); } diff --git a/lib/const/placed_classes.g.dart b/lib/const/placed_classes.g.dart index b4fd84dd..bbf236e8 100644 --- a/lib/const/placed_classes.g.dart +++ b/lib/const/placed_classes.g.dart @@ -195,6 +195,7 @@ AbilityVisualState _$AbilityVisualStateFromJson(Map json) => showRangeFill: json['showRangeFill'] as bool? ?? true, showInnerOutline: json['showInnerOutline'] as bool? ?? true, showInnerFill: json['showInnerFill'] as bool? ?? true, + showVisionCone: json['showVisionCone'] as bool? ?? true, ); Map _$AbilityVisualStateToJson(AbilityVisualState instance) => @@ -203,6 +204,7 @@ Map _$AbilityVisualStateToJson(AbilityVisualState instance) => 'showRangeFill': instance.showRangeFill, 'showInnerOutline': instance.showInnerOutline, 'showInnerFill': instance.showInnerFill, + 'showVisionCone': instance.showVisionCone, }; PlacedUtility _$PlacedUtilityFromJson(Map json) => diff --git a/lib/const/settings.dart b/lib/const/settings.dart index 3108727b..504f34be 100644 --- a/lib/const/settings.dart +++ b/lib/const/settings.dart @@ -100,7 +100,7 @@ class Settings { static final Uri dicordLink = Uri.parse("https://discord.gg/PN2uKwCqYB"); static const Duration autoSaveOffset = Duration(seconds: 15); - static const int versionNumber = 94; + static const int versionNumber = 95; static const String versionName = "4.5.1"; static final Uri desktopUpdaterArchiveUrl = buildDesktopUpdaterArchiveUrl(kResolvedUpdateChannel); diff --git a/lib/hive/hive_adapters.g.dart b/lib/hive/hive_adapters.g.dart index 4bb85835..a6fe82ad 100644 --- a/lib/hive/hive_adapters.g.dart +++ b/lib/hive/hive_adapters.g.dart @@ -1515,13 +1515,14 @@ class AbilityVisualStateAdapter extends TypeAdapter { showRangeFill: fields[3] == null ? true : fields[3] as bool, showInnerOutline: fields[4] == null ? true : fields[4] as bool, showInnerFill: fields[5] == null ? true : fields[5] as bool, + showVisionCone: fields[6] == null ? true : fields[6] as bool, ); } @override void write(BinaryWriter writer, AbilityVisualState obj) { writer - ..writeByte(4) + ..writeByte(5) ..writeByte(2) ..write(obj.showRangeOutline) ..writeByte(3) @@ -1529,7 +1530,9 @@ class AbilityVisualStateAdapter extends TypeAdapter { ..writeByte(4) ..write(obj.showInnerOutline) ..writeByte(5) - ..write(obj.showInnerFill); + ..write(obj.showInnerFill) + ..writeByte(6) + ..write(obj.showVisionCone); } @override diff --git a/lib/hive/hive_adapters.g.yaml b/lib/hive/hive_adapters.g.yaml index 068c33e5..61fe22b4 100644 --- a/lib/hive/hive_adapters.g.yaml +++ b/lib/hive/hive_adapters.g.yaml @@ -529,7 +529,7 @@ types: index: 8 AbilityVisualState: typeId: 32 - nextIndex: 6 + nextIndex: 7 fields: showRangeOutline: index: 2 @@ -539,6 +539,8 @@ types: index: 4 showInnerFill: index: 5 + showVisionCone: + index: 6 LineUpGroup: typeId: 33 nextIndex: 3 diff --git a/lib/migrations/ability_vision_cone_migration.dart b/lib/migrations/ability_vision_cone_migration.dart new file mode 100644 index 00000000..be33a57e --- /dev/null +++ b/lib/migrations/ability_vision_cone_migration.dart @@ -0,0 +1,38 @@ +import 'package:icarus/const/line_provider.dart'; +import 'package:icarus/const/placed_classes.dart'; +import 'package:icarus/providers/strategy_page.dart'; + +class AbilityVisionConeMigration { + static const int version = 95; + + static List migratePages({required List pages}) { + return [ + for (final page in pages) + page.copyWith( + abilityData: [ + for (final ability in page.abilityData) _migrateAbility(ability), + ], + lineUpGroups: [ + for (final group in page.lineUpGroups) _migrateLineUpGroup(group), + ], + ), + ]; + } + + static LineUpGroup _migrateLineUpGroup(LineUpGroup group) { + return group.copyWith( + items: [ + for (final item in group.items) + item.copyWith(ability: _migrateAbility(item.ability)), + ], + ); + } + + static PlacedAbility _migrateAbility(PlacedAbility ability) { + final migrated = ability.copyWith( + visualState: ability.visualState.copyWith(showVisionCone: true), + ); + migrated.isDeleted = ability.isDeleted; + return migrated; + } +} diff --git a/lib/providers/strategy_provider.dart b/lib/providers/strategy_provider.dart index 7a41ce3e..d73f7756 100644 --- a/lib/providers/strategy_provider.dart +++ b/lib/providers/strategy_provider.dart @@ -19,6 +19,7 @@ import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/const/folder_icons.dart'; import 'package:icarus/const/hive_boxes.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/migrations/ability_vision_cone_migration.dart'; import 'package:icarus/migrations/ability_scale_migration.dart'; import 'package:icarus/migrations/custom_circle_wrapper_migration.dart'; import 'package:icarus/migrations/lineup_group_migration.dart'; @@ -492,7 +493,11 @@ class StrategyProvider extends Notifier { final customCircleMigrated = migrateCustomCircleWrapper(squareAoeMigrated); final lineUpGroupMigrated = migrateLineUpGroups(customCircleMigrated); - if (lineUpGroupMigrated != customCircleMigrated) { + final abilityVisionMigrated = + migrateAbilityVisionCones(lineUpGroupMigrated); + if (abilityVisionMigrated != lineUpGroupMigrated) { + await box.put(abilityVisionMigrated.id, abilityVisionMigrated); + } else if (lineUpGroupMigrated != customCircleMigrated) { await box.put(lineUpGroupMigrated.id, lineUpGroupMigrated); } else if (customCircleMigrated != squareAoeMigrated) { await box.put(customCircleMigrated.id, customCircleMigrated); @@ -593,12 +598,34 @@ class StrategyProvider extends Notifier { static StrategyData migrateToCurrentVersion(StrategyData strat, {bool forceAbilityScale = false}) { + final needsAbilityVisionMigration = + strat.versionNumber < AbilityVisionConeMigration.version; final worldMigrated = migrateToWorld16x9(strat); final abilityScaleMigrated = migrateAbilityScale(worldMigrated, force: forceAbilityScale); final squareAoeMigrated = migrateSquareAoeCenter(abilityScaleMigrated); final customCircleMigrated = migrateCustomCircleWrapper(squareAoeMigrated); - return migrateLineUpGroups(customCircleMigrated); + final lineUpGroupMigrated = migrateLineUpGroups(customCircleMigrated); + return migrateAbilityVisionCones( + lineUpGroupMigrated, + force: needsAbilityVisionMigration, + ); + } + + static StrategyData migrateAbilityVisionCones(StrategyData strat, + {bool force = false}) { + if (!force && strat.versionNumber >= AbilityVisionConeMigration.version) { + return strat; + } + + final migratedPages = AbilityVisionConeMigration.migratePages( + pages: strat.pages, + ); + return strat.copyWith( + pages: migratedPages, + versionNumber: Settings.versionNumber, + lastEdited: DateTime.now(), + ); } static StrategyData migrateLineUpGroups(StrategyData strat, @@ -686,10 +713,18 @@ class StrategyProvider extends Notifier { abilityScaleMigrated, force: originalVersion < SquareAoeCenterMigration.version, ); - return migrateCustomCircleWrapper( + final customCircleMigrated = migrateCustomCircleWrapper( squareAoeMigrated, force: originalVersion < CustomCircleWrapperMigration.version, ); + final lineUpGroupMigrated = migrateLineUpGroups( + customCircleMigrated, + force: originalVersion < LineUpGroupMigration.version, + ); + return migrateAbilityVisionCones( + lineUpGroupMigrated, + force: originalVersion < AbilityVisionConeMigration.version, + ); } static StrategyData migrateToWorld16x9(StrategyData strat, diff --git a/lib/widgets/draggable_widgets/ability/ability_visibility_context_menu.dart b/lib/widgets/draggable_widgets/ability/ability_visibility_context_menu.dart index 9fb11bc0..3e8553dc 100644 --- a/lib/widgets/draggable_widgets/ability/ability_visibility_context_menu.dart +++ b/lib/widgets/draggable_widgets/ability/ability_visibility_context_menu.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/abilities.dart'; +import 'package:icarus/const/ability_vision.dart'; import 'package:icarus/const/line_provider.dart'; import 'package:icarus/const/placed_classes.dart'; import 'package:icarus/const/settings.dart'; @@ -50,7 +51,19 @@ List _buildVisibilityItems( String? lineUpGroupId, String? lineUpItemId, }) { - final controls = _buildVisibilityControls(abilityData, ability.visualState); + final controls = [ + ..._buildVisibilityControls(abilityData, ability.visualState), + if (lineUpGroupId == null && + lineUpItemId == null && + AbilityVisionConeSpec.forAbility(ability.data) != null) + _AbilityVisibilityControl( + label: 'Vision Cone', + isEnabled: ability.visualState.showVisionCone, + toggle: (state) => state.copyWith( + showVisionCone: !state.showVisionCone, + ), + ), + ]; return controls .map( (control) => _buildToggleItem( diff --git a/lib/widgets/draggable_widgets/ability/ability_vision_cone_composite.dart b/lib/widgets/draggable_widgets/ability/ability_vision_cone_composite.dart new file mode 100644 index 00000000..29044ca7 --- /dev/null +++ b/lib/widgets/draggable_widgets/ability/ability_vision_cone_composite.dart @@ -0,0 +1,143 @@ +import 'package:flutter/material.dart'; +import 'package:icarus/const/abilities.dart'; +import 'package:icarus/const/ability_vision.dart'; +import 'package:icarus/const/coordinate_system.dart'; +import 'package:icarus/const/placed_classes.dart'; +import 'package:icarus/const/transition_data.dart'; +import 'package:icarus/widgets/draggable_widgets/utilities/view_cone_widget.dart'; + +Offset abilityVisionConeChildOffsetVirtual({ + required Ability ability, + required double mapScale, + required double abilitySize, +}) { + final abilityAnchor = ability.getAnchorPoint( + mapScale: mapScale, + abilitySize: abilitySize, + ); + return ViewConeWidget.anchorPointVirtual - abilityAnchor; +} + +Offset abilityVisionConeChildOffsetScreen({ + required CoordinateSystem coordinateSystem, + required Ability ability, + required double mapScale, + required double abilitySize, +}) { + final offset = abilityVisionConeChildOffsetVirtual( + ability: ability, + mapScale: mapScale, + abilitySize: abilitySize, + ); + return Offset( + coordinateSystem.scale(offset.dx), + coordinateSystem.scale(offset.dy), + ); +} + +/// Places an ability at the apex of its vision cone. The composite rotates +/// around that apex while counter-rotating the ability so its icon stays +/// upright. +class AbilityVisionConeComposite extends StatelessWidget { + const AbilityVisionConeComposite({ + super.key, + required this.ability, + required this.spec, + required this.rotation, + required this.length, + required this.mapScale, + required this.abilitySize, + required this.child, + this.coordinatePosition, + this.applyRotation = true, + this.clipToGeometry = true, + }); + + final PlacedAbility ability; + final AbilityVisionConeSpec spec; + final double rotation; + final double length; + final double mapScale; + final double abilitySize; + final Widget child; + + /// Stored top-left position used while rendering an animated transition. + final Offset? coordinatePosition; + final bool applyRotation; + final bool clipToGeometry; + + @override + Widget build(BuildContext context) { + final coordinateSystem = CoordinateSystem.instance; + final abilityData = ability.data.abilityData!; + final resolvedLength = spec.resolveLength( + storedLength: length, + mapScale: mapScale, + ); + final coneAnchor = ViewConeWidget.anchorPointVirtual.scale( + coordinateSystem.scaleFactor, + coordinateSystem.scaleFactor, + ); + final childOffset = abilityVisionConeChildOffsetScreen( + coordinateSystem: coordinateSystem, + ability: abilityData, + mapScale: mapScale, + abilitySize: abilitySize, + ); + final abilityAnchor = abilityData + .getAnchorPoint(mapScale: mapScale, abilitySize: abilitySize) + .scale(coordinateSystem.scaleFactor, coordinateSystem.scaleFactor); + final storedAnchor = storedAbilityAnchor( + ability: abilityData, + mapScale: mapScale, + ); + final worldOrigin = clipToGeometry + ? (coordinatePosition ?? ability.position) + + coordinateSystem.virtualOffsetToWorld(storedAnchor) + : null; + + final composite = SizedBox( + width: coordinateSystem.scale(ViewConeWidget.totalWidthVirtual), + height: coordinateSystem.scale(ViewConeWidget.totalHeightVirtual), + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned( + left: 0, + top: 0, + child: ViewConeWidget( + key: const ValueKey('ability-vision-cone'), + id: null, + angle: spec.angleDegrees, + rotation: rotation, + length: resolvedLength, + worldOrigin: worldOrigin, + showCenterMarker: false, + ), + ), + Positioned( + left: childOffset.dx, + top: childOffset.dy, + child: Transform.rotate( + angle: -rotation, + alignment: Alignment.topLeft, + origin: abilityAnchor, + child: child, + ), + ), + ], + ), + ); + + if (!applyRotation || rotation == 0) { + return composite; + } + + return Transform.rotate( + angle: rotation, + alignment: Alignment.topLeft, + origin: coneAnchor, + child: composite, + ); + } +} diff --git a/lib/widgets/draggable_widgets/ability/placed_ability_widget.dart b/lib/widgets/draggable_widgets/ability/placed_ability_widget.dart index 1749f9f1..51db0ef7 100644 --- a/lib/widgets/draggable_widgets/ability/placed_ability_widget.dart +++ b/lib/widgets/draggable_widgets/ability/placed_ability_widget.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/abilities.dart'; +import 'package:icarus/const/ability_vision.dart'; import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/const/line_provider.dart'; import 'package:icarus/const/maps.dart'; @@ -13,11 +14,14 @@ import 'package:icarus/providers/map_provider.dart'; import 'package:icarus/providers/screen_zoom_provider.dart'; import 'package:icarus/providers/strategy_settings_provider.dart'; import 'package:icarus/widgets/draggable_widgets/ability/ability_visibility_context_menu.dart'; +import 'package:icarus/widgets/draggable_widgets/ability/ability_vision_cone_composite.dart'; import 'package:icarus/widgets/draggable_widgets/ability/placed_deadlock_barrier_mesh_widget.dart'; import 'package:icarus/widgets/draggable_widgets/ability/rotatable_widget.dart'; +import 'package:icarus/widgets/draggable_widgets/utilities/view_cone_widget.dart'; import 'dart:math' as math; import 'package:icarus/widgets/draggable_widgets/zoom_transform.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; bool _shouldShowRotatableHandle( Ability ability, @@ -190,6 +194,23 @@ class _PlacedAbilityWidgetState extends ConsumerState { } } + final visionSpec = AbilityVisionConeSpec.forAbility(abilityRef.data); + if (!widget.isLineUp && + visionSpec != null && + abilityRef.visualState.showVisionCone) { + return _buildVisionConeAbility( + visionSpec: visionSpec, + abilityRef: abilityRef, + abilityData: abilityData, + contextMenuItems: contextMenuItems, + coordinateSystem: coordinateSystem, + mapScale: mapScale, + abilitySize: abilitySize, + index: index, + isAlly: isAlly, + ); + } + if (isRotatable(abilityData)) { final screenPosition = screenPositionForWidget( widget: widget.ability, @@ -441,4 +462,154 @@ class _PlacedAbilityWidgetState extends ConsumerState { ), ); } + + Widget _buildVisionConeAbility({ + required AbilityVisionConeSpec visionSpec, + required PlacedAbility abilityRef, + required Ability abilityData, + required List? contextMenuItems, + required CoordinateSystem coordinateSystem, + required double mapScale, + required double abilitySize, + required int index, + required bool isAlly, + }) { + final screenPosition = screenPositionForWidget( + widget: abilityRef, + coordinateSystem: coordinateSystem, + mapScale: mapScale, + abilitySize: abilitySize, + ); + const coneAnchor = ViewConeWidget.anchorPointVirtual; + final scaledConeAnchor = coneAnchor.scale( + coordinateSystem.scaleFactor, + coordinateSystem.scaleFactor, + ); + final childOffset = abilityVisionConeChildOffsetScreen( + coordinateSystem: coordinateSystem, + ability: abilityData, + mapScale: mapScale, + abilitySize: abilitySize, + ); + final resolvedLength = visionSpec.resolveLength( + storedLength: localLength ?? 0, + mapScale: mapScale, + ); + + Widget buildAbilityChild({required bool watchMouse}) { + return abilityData.createWidget( + id: watchMouse ? widget.id : null, + isAlly: isAlly, + mapScale: mapScale, + armLengthsMeters: abilityRef.armLengthsMeters, + visualState: abilityRef.visualState, + watchMouse: watchMouse, + contextMenuItems: watchMouse ? contextMenuItems : null, + ); + } + + return Positioned( + left: screenPosition.dx - childOffset.dx, + top: screenPosition.dy - childOffset.dy, + child: RotatableWidget( + rotation: localRotation!, + isDragging: isDragging, + origin: coneAnchor, + buttonTop: coneAnchor.dy - resolvedLength - 7.5, + buttonLeft: coneAnchor.dx - 7.5, + onPanStart: (_) { + final box = context.findRenderObject() as RenderBox; + rotationOrigin = box.localToGlobal(scaledConeAnchor); + }, + onPanUpdate: (details) { + if (rotationOrigin == Offset.zero) return; + + final delta = details.globalPosition - rotationOrigin; + final nextRotation = math.atan2(delta.dy, delta.dx) + (math.pi / 2); + final nextLength = visionSpec.resolveLength( + storedLength: coordinateSystem.normalize(delta.distance) / + ref.watch(screenZoomProvider), + mapScale: mapScale, + ); + + setState(() { + localRotation = nextRotation; + localLength = nextLength; + }); + }, + onPanEnd: (_) { + ref.read(abilityProvider.notifier).updateRotation( + index, + localRotation!, + localLength ?? 0, + ); + setState(() { + rotationOrigin = Offset.zero; + }); + }, + child: Draggable( + data: widget.data, + dragAnchorStrategy: (draggable, context, position) { + final renderObject = context.findRenderObject()! as RenderBox; + final rotatedPosition = rotateOffset( + renderObject.globalToLocal(position), + scaledConeAnchor, + localRotation!, + ); + + return ref + .read(screenZoomProvider.notifier) + .zoomOffset(rotatedPosition); + }, + feedback: Opacity( + opacity: Settings.feedbackOpacity, + child: ZoomTransform( + child: AbilityVisionConeComposite( + ability: abilityRef, + spec: visionSpec, + rotation: localRotation!, + length: localLength ?? 0, + mapScale: mapScale, + abilitySize: abilitySize, + clipToGeometry: false, + child: buildAbilityChild(watchMouse: false), + ), + ), + ), + childWhenDragging: const SizedBox.shrink(), + onDragStarted: () { + final shouldDuplicate = ref.read(duplicateDragModifierProvider); + final duplicateId = shouldDuplicate + ? ref.read(abilityProvider.notifier).duplicateAbilityAt( + sourceId: abilityRef.id, + position: abilityRef.position, + ) + : null; + setState(() { + isDragging = true; + _activeDragId = duplicateId ?? abilityRef.id; + }); + }, + onDragEnd: (details) { + final dragId = _activeDragId ?? abilityRef.id; + setState(() { + isDragging = false; + _activeDragId = null; + }); + widget.onDragEnd(details, dragId); + }, + child: AbilityVisionConeComposite( + ability: abilityRef, + spec: visionSpec, + rotation: localRotation!, + length: localLength ?? 0, + mapScale: mapScale, + abilitySize: abilitySize, + applyRotation: false, + child: buildAbilityChild(watchMouse: true), + ), + ), + ), + ); + } } diff --git a/lib/widgets/draggable_widgets/placed_widget_builder.dart b/lib/widgets/draggable_widgets/placed_widget_builder.dart index be64a5dd..97a9616f 100644 --- a/lib/widgets/draggable_widgets/placed_widget_builder.dart +++ b/lib/widgets/draggable_widgets/placed_widget_builder.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/const/ability_vision.dart'; import 'package:icarus/const/agents.dart'; import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/const/line_provider.dart'; @@ -27,6 +28,7 @@ import 'package:icarus/providers/utility_provider.dart'; import 'package:icarus/widgets/draggable_widgets/agents/placed_circle_agent_widget.dart'; import 'package:icarus/widgets/draggable_widgets/agents/placed_view_cone_agent_widget.dart'; import 'package:icarus/widgets/draggable_widgets/agents/agent_widget.dart'; +import 'package:icarus/widgets/draggable_widgets/ability/ability_vision_cone_composite.dart'; import 'package:icarus/widgets/draggable_widgets/image/placed_image_builder.dart'; import 'package:icarus/widgets/draggable_widgets/ability/placed_ability_widget.dart'; import 'package:icarus/widgets/draggable_widgets/text/placed_text_builder.dart'; @@ -427,7 +429,21 @@ class _AbilityList extends ConsumerWidget { length: ability.length, onDragEnd: (details, draggedId) { final renderBox = context.findRenderObject() as RenderBox; - final localOffset = renderBox.globalToLocal(details.offset); + final visionSpec = AbilityVisionConeSpec.forAbility(ability.data); + final coneChildOffset = visionSpec != null && + ability.visualState.showVisionCone + ? abilityVisionConeChildOffsetScreen( + coordinateSystem: coordinateSystem, + ability: ability.data.abilityData!, + mapScale: mapScale, + abilitySize: abilitySize, + ) + : Offset.zero; + final screenZoom = ref.read(screenZoomProvider); + final localOffset = renderBox.globalToLocal( + details.offset + + coneChildOffset.scale(screenZoom, screenZoom), + ); final virtualOffset = storedAbilityPositionForRenderedScreenPosition( ability: ability.data.abilityData!, diff --git a/lib/widgets/page_transition_overlay.dart b/lib/widgets/page_transition_overlay.dart index 6b00c591..0442a4bb 100644 --- a/lib/widgets/page_transition_overlay.dart +++ b/lib/widgets/page_transition_overlay.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/abilities.dart'; +import 'package:icarus/const/ability_vision.dart'; import 'package:icarus/const/agents.dart'; import 'package:icarus/const/coordinate_system.dart'; @@ -14,6 +15,7 @@ import 'package:icarus/providers/transition_provider.dart'; import 'package:icarus/widgets/draggable_widgets/agents/agent_widget.dart'; import 'package:icarus/widgets/draggable_widgets/agents/placed_circle_agent_widget.dart'; import 'package:icarus/widgets/draggable_widgets/agents/placed_view_cone_agent_widget.dart'; +import 'package:icarus/widgets/draggable_widgets/ability/ability_vision_cone_composite.dart'; import 'package:icarus/widgets/draggable_widgets/image/image_widget.dart'; import 'package:icarus/widgets/draggable_widgets/text/text_widget.dart'; import 'package:icarus/widgets/draggable_widgets/utilities/view_cone_widget.dart'; @@ -49,6 +51,18 @@ Offset _overlayScreenPosition({ mapScale: mapScale, ); } + if (widget is PlacedAbility && + widget.visualState.showVisionCone && + AbilityVisionConeSpec.forAbility(widget.data) != null && + widget.data.abilityData != null) { + return screen - + abilityVisionConeChildOffsetScreen( + coordinateSystem: coordinateSystem, + ability: widget.data.abilityData!, + mapScale: mapScale, + abilitySize: abilitySize, + ); + } return screen; } @@ -415,6 +429,10 @@ class _EntryRenderer { if (ability == null) { return false; } + if (widget.visualState.showVisionCone && + AbilityVisionConeSpec.forAbility(widget.data) != null) { + return false; + } return isRotatable(ability); } return widget is PlacedUtility; @@ -573,6 +591,29 @@ class PlacedWidgetPreview { return const SizedBox.shrink(); } final ability = w.data.abilityData!; + final visionSpec = AbilityVisionConeSpec.forAbility(w.data); + if (visionSpec != null && w.visualState.showVisionCone) { + final child = ability.createWidget( + id: w.id, + isAlly: w.isAlly, + mapScale: mapScale, + rotation: rotation ?? w.rotation, + length: length ?? w.length, + armLengthsMeters: armLengthsMeters ?? w.armLengthsMeters, + visualState: w.visualState, + watchMouse: false, + ); + return AbilityVisionConeComposite( + ability: w, + spec: visionSpec, + rotation: rotation ?? w.rotation, + length: length ?? w.length, + mapScale: mapScale, + abilitySize: abilitySize, + coordinatePosition: coordinatePosition, + child: child, + ); + } switch (ability) { case BaseAbility(): @@ -777,6 +818,8 @@ class TemporaryWidgetBuilder extends ConsumerWidget { } else if (widget is PlacedAbility && widget.rotation != 0 && widget.data.abilityData != null && + !(widget.visualState.showVisionCone && + AbilityVisionConeSpec.forAbility(widget.data) != null) && isRotatable(widget.data.abilityData!)) { return Positioned( left: scaledPosition.dx, @@ -806,6 +849,7 @@ class TemporaryWidgetBuilder extends ConsumerWidget { child: PlacedWidgetPreview.build( widget, mapScale, + length: widget is PlacedAbility ? widget.length : null, armLengthsMeters: widget is PlacedAbility ? widget.armLengthsMeters : null, agentSize: agentSize, diff --git a/test/ability_visibility_widgets_test.dart b/test/ability_visibility_widgets_test.dart index b8a52a3b..f8e3fda7 100644 --- a/test/ability_visibility_widgets_test.dart +++ b/test/ability_visibility_widgets_test.dart @@ -20,6 +20,7 @@ import 'package:icarus/widgets/draggable_widgets/ability/placed_ability_widget.d import 'package:icarus/widgets/draggable_widgets/ability/resizable_square_widget.dart'; import 'package:icarus/widgets/draggable_widgets/ability/rotatable_widget.dart'; import 'package:icarus/widgets/draggable_widgets/ability/sector_circle_widget.dart'; +import 'package:icarus/widgets/draggable_widgets/utilities/view_cone_widget.dart'; import 'package:icarus/widgets/line_up_media_carousel.dart'; import 'package:icarus/widgets/line_up_widget.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; @@ -321,6 +322,67 @@ void main() { }); group('Ability visibility context menus', () { + testWidgets('placed directional ability toggles its vision cone', + (tester) async { + final turret = PlacedAbility( + id: 'turret-vision-menu', + data: AgentData.agents[AgentType.killjoy]!.abilities[2], + position: const Offset(100, 120), + ); + final container = ProviderContainer( + overrides: [ + actionProvider.overrideWith(_TestActionProvider.new), + mapProvider.overrideWith(_FixedMapProvider.new), + ], + ); + addTearDown(() async { + await tester.pumpWidget(const SizedBox.shrink()); + container.dispose(); + }); + container.read(abilityProvider.notifier).fromHive([turret]); + + await tester.pumpWidget( + _buildHarness( + container: container, + child: Stack( + children: [ + PlacedAbilityWidget( + ability: turret, + onDragEnd: (_, __) {}, + id: turret.id, + data: turret, + rotation: turret.rotation, + length: turret.length, + ), + ], + ), + ), + ); + await tester.pumpAndSettle(); + + expect( + find.byKey(const ValueKey('ability-vision-cone')), + findsOneWidget, + ); + expect(find.byType(ViewConeWidget), findsOneWidget); + + await _openContextMenu(tester, find.byType(AbilityWidget)); + expect(find.text('Vision Cone'), findsOneWidget); + + await tester.tap(find.text('Vision Cone')); + await tester.pumpAndSettle(); + + expect( + container.read(abilityProvider).single.visualState.showVisionCone, + isFalse, + ); + expect( + find.byKey(const ValueKey('ability-vision-cone')), + findsNothing, + ); + expect(find.byType(AbilityWidget), findsOneWidget); + }); + testWidgets('placed square ability shows Range', (tester) async { final squareAbility = PlacedAbility( id: 'square-menu', diff --git a/test/ability_vision_cone_migration_test.dart b/test/ability_vision_cone_migration_test.dart new file mode 100644 index 00000000..ef462794 --- /dev/null +++ b/test/ability_vision_cone_migration_test.dart @@ -0,0 +1,94 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/const/agents.dart'; +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/migrations/ability_vision_cone_migration.dart'; +import 'package:icarus/providers/strategy_page.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/providers/strategy_settings_provider.dart'; + +void main() { + group('AbilityVisionConeMigration', () { + test('initializes map and lineup ability state and bumps the version', () { + final strategy = + _strategy(version: AbilityVisionConeMigration.version - 1); + + final migrated = StrategyProvider.migrateAbilityVisionCones(strategy); + final page = migrated.pages.single; + + expect(migrated.versionNumber, Settings.versionNumber); + expect(page.abilityData.single.visualState.showVisionCone, isTrue); + expect( + page.lineUpGroups.single.items.single.ability.visualState + .showVisionCone, + isTrue, + ); + expect(page.abilityData.single.isDeleted, isTrue); + }); + + test('does not rewrite a strategy already at the migration version', () { + final strategy = _strategy(version: AbilityVisionConeMigration.version); + + expect( + identical( + StrategyProvider.migrateAbilityVisionCones(strategy), + strategy, + ), + isTrue, + ); + }); + }); +} + +StrategyData _strategy({required int version}) { + PlacedAbility hiddenAbility(String id) { + return PlacedAbility( + id: id, + data: AgentData.agents[AgentType.killjoy]!.abilities[2], + position: Offset.zero, + visualState: const AbilityVisualState(showVisionCone: false), + ); + } + + final mapAbility = hiddenAbility('map-turret')..isDeleted = true; + final lineUpAbility = hiddenAbility('lineup-turret'); + + return StrategyData( + id: 'strategy-id', + name: 'Vision cone migration test', + mapData: MapValue.ascent, + versionNumber: version, + lastEdited: DateTime.utc(2026, 1, 1), + folderID: null, + pages: [ + StrategyPage( + id: 'page-1', + sortIndex: 0, + name: 'Page 1', + drawingData: const [], + agentData: const [], + abilityData: [mapAbility], + textData: const [], + imageData: const [], + utilityData: const [], + isAttack: true, + settings: StrategySettings(), + lineUpGroups: [ + LineUpGroup( + id: 'lineup-group', + agent: PlacedAgent( + id: 'lineup-agent', + type: AgentType.killjoy, + position: Offset.zero, + ), + items: [ + LineUpItem(id: 'lineup-item', ability: lineUpAbility), + ], + ), + ], + ), + ], + ); +} diff --git a/test/ability_vision_cone_test.dart b/test/ability_vision_cone_test.dart new file mode 100644 index 00000000..01f94d0a --- /dev/null +++ b/test/ability_vision_cone_test.dart @@ -0,0 +1,56 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/const/ability_vision.dart'; +import 'package:icarus/const/agents.dart'; + +void main() { + group('AbilityVisionConeSpec', () { + test('only the four directional abilities have vision cones', () { + final expected = <(AgentType, int), double>{ + (AgentType.killjoy, 2): 100, + (AgentType.cypher, 2): 103, + (AgentType.raze, 0): 40, + (AgentType.gekko, 1): 40, + }; + + for (final agent in AgentData.agents.values) { + for (final ability in agent.abilities) { + final spec = AbilityVisionConeSpec.forAbility(ability); + final key = (ability.type, ability.index); + expect( + spec?.angleDegrees, + expected[key], + reason: '${agent.name} ${ability.name}', + ); + } + } + }); + + test('Boom Bot and Wingman default to and clamp at 15 meters', () { + for (final (agentType, index) in [ + (AgentType.raze, 0), + (AgentType.gekko, 1), + ]) { + final ability = AgentData.agents[agentType]!.abilities[index]; + final spec = AbilityVisionConeSpec.forAbility(ability)!; + final maximum = spec.maximumLength(1); + + expect(spec.maxRangeMeters, 15); + expect(spec.resolveLength(storedLength: 0, mapScale: 1), maximum); + expect(spec.resolveLength(storedLength: 10000, mapScale: 1), maximum); + } + }); + + test('Turret and Spycam retain adjustable line-of-sight range', () { + for (final (agentType, index) in [ + (AgentType.killjoy, 2), + (AgentType.cypher, 2), + ]) { + final ability = AgentData.agents[agentType]!.abilities[index]; + final spec = AbilityVisionConeSpec.forAbility(ability)!; + + expect(spec.maxRangeMeters, isNull); + expect(spec.resolveLength(storedLength: 75, mapScale: 1), 75); + } + }); + }); +} diff --git a/test/ability_visual_state_test.dart b/test/ability_visual_state_test.dart index 084831a0..a6b86755 100644 --- a/test/ability_visual_state_test.dart +++ b/test/ability_visual_state_test.dart @@ -44,6 +44,7 @@ void main() { expect(decoded.visualState.showRangeFill, isTrue); expect(decoded.visualState.showInnerOutline, isTrue); expect(decoded.visualState.showInnerFill, isTrue); + expect(decoded.visualState.showVisionCone, isTrue); }); test('visual state round-trips through json', () { @@ -56,6 +57,7 @@ void main() { showRangeFill: false, showInnerOutline: false, showInnerFill: false, + showVisionCone: false, ), ); @@ -65,6 +67,7 @@ void main() { expect(decoded.visualState.showRangeFill, isFalse); expect(decoded.visualState.showInnerOutline, isFalse); expect(decoded.visualState.showInnerFill, isFalse); + expect(decoded.visualState.showVisionCone, isFalse); }); }); diff --git a/test/page_transition_overlay_test.dart b/test/page_transition_overlay_test.dart index a6e84f5b..49c757c4 100644 --- a/test/page_transition_overlay_test.dart +++ b/test/page_transition_overlay_test.dart @@ -8,6 +8,7 @@ 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/transition_data.dart'; import 'package:icarus/const/utilities.dart'; import 'package:icarus/providers/map_provider.dart'; import 'package:icarus/providers/transition_provider.dart'; @@ -197,6 +198,52 @@ void main() { await tester.pump(); }); + testWidgets('ability vision-cone previews use animated geometry', + (tester) async { + final container = _createContainer(); + addTearDown(container.dispose); + final turret = PlacedAbility( + id: 'moving-turret', + data: AgentData.agents[AgentType.killjoy]!.abilities[2], + position: const Offset(25, 35), + rotation: 0.3, + length: 90, + ); + const animatedPosition = Offset(500, 250); + + await tester.pumpWidget( + _previewHarness( + container: container, + widget: turret, + coordinatePosition: animatedPosition, + rotation: 1.1, + length: 120, + ), + ); + + final preview = tester.widget(find.byType(ViewConeWidget)); + final storedAnchor = storedAbilityAnchor( + ability: turret.data.abilityData!, + mapScale: 1, + ); + expect(preview.angle, 100); + expect(preview.rotation, 1.1); + expect(preview.length, 120); + expect( + preview.worldOrigin, + animatedPosition + + CoordinateSystem.instance.virtualOffsetToWorld(storedAnchor), + ); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const SizedBox.shrink(), + ), + ); + await tester.pump(); + }); + testWidgets('attached view-cone previews raycast from the animated agent', (tester) async { final container = _createContainer(); diff --git a/test/strategy_integrity_test.dart b/test/strategy_integrity_test.dart index 0644d46e..de43ed91 100644 --- a/test/strategy_integrity_test.dart +++ b/test/strategy_integrity_test.dart @@ -326,6 +326,83 @@ void main() { }); group('Strategy round-trip integrity', () { + test('legacy Hive ability visual state defaults vision cones on', () { + final writer = BinaryWriterImpl(Hive) + ..writeByte(4) + ..writeByte(2) + ..write(true) + ..writeByte(3) + ..write(false) + ..writeByte(4) + ..write(true) + ..writeByte(5) + ..write(false); + final reader = BinaryReaderImpl( + Uint8List.fromList(writer.toBytes()), + Hive, + ); + + final restored = AbilityVisualStateAdapter().read(reader); + + expect(restored.showRangeOutline, isTrue); + expect(restored.showRangeFill, isFalse); + expect(restored.showInnerOutline, isTrue); + expect(restored.showInnerFill, isFalse); + expect(restored.showVisionCone, isTrue); + }); + + test('vision cone visibility survives current .ica export and import', + () async { + final source = StrategyData( + id: 'vision-cone-source', + name: 'Vision cone source', + mapData: MapValue.ascent, + versionNumber: Settings.versionNumber, + lastEdited: DateTime.utc(2026, 1, 1), + folderID: null, + pages: [ + StrategyPage( + id: 'page-1', + sortIndex: 0, + name: 'Page 1', + drawingData: const [], + agentData: const [], + abilityData: [ + PlacedAbility( + id: 'hidden-turret', + data: AgentData.agents[AgentType.killjoy]!.abilities[2], + position: const Offset(100, 200), + rotation: 0.75, + length: 80, + visualState: const AbilityVisualState( + showVisionCone: false, + ), + ), + ], + textData: const [], + imageData: const [], + utilityData: const [], + isAttack: true, + settings: StrategySettings(), + ), + ], + ); + final exported = _buildExportPayload(source); + + final imported = await _importStrategyFromDecoded( + decoded: exported, + strategyName: 'Vision cone imported', + strategyId: 'vision-cone-imported', + isZip: true, + ); + final ability = imported.pages.single.abilityData.single; + + expect(ability.visualState.showVisionCone, isFalse); + expect(ability.rotation, 0.75); + expect(ability.length, 80); + expect(_buildExportPayload(imported)['pages'], exported['pages']); + }); + test('legacy Hive field 11 lineUps still deserialize into lineUpGroups', () { _ensureAdaptersRegistered(); From 34268f1130f1df6e7b9906c53bb89d7123f4cde2 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Thu, 6 Aug 2026 11:12:09 -0700 Subject: [PATCH 2/4] test: verify cone drag anchor rotation --- .../ability_vision_cone_composite.dart | 4 ++ test/ability_vision_cone_test.dart | 67 +++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/lib/widgets/draggable_widgets/ability/ability_vision_cone_composite.dart b/lib/widgets/draggable_widgets/ability/ability_vision_cone_composite.dart index 29044ca7..57e794e5 100644 --- a/lib/widgets/draggable_widgets/ability/ability_vision_cone_composite.dart +++ b/lib/widgets/draggable_widgets/ability/ability_vision_cone_composite.dart @@ -15,6 +15,10 @@ Offset abilityVisionConeChildOffsetVirtual({ mapScale: mapScale, abilitySize: abilitySize, ); + // This puts the ability anchor exactly on the cone pivot. The composite + // counter-rotates the icon around that anchor, so the icon's rendered + // top-left (and therefore its drag-end correction) stays fixed at every + // cone rotation. return ViewConeWidget.anchorPointVirtual - abilityAnchor; } diff --git a/test/ability_vision_cone_test.dart b/test/ability_vision_cone_test.dart index 01f94d0a..ac682541 100644 --- a/test/ability_vision_cone_test.dart +++ b/test/ability_vision_cone_test.dart @@ -1,8 +1,21 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:icarus/const/ability_vision.dart'; import 'package:icarus/const/agents.dart'; +import 'package:icarus/const/coordinate_system.dart'; +import 'package:icarus/const/placed_classes.dart'; +import 'package:icarus/widgets/draggable_widgets/ability/ability_vision_cone_composite.dart'; void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + CoordinateSystem(playAreaSize: const Size(1920, 1080)); + }); + group('AbilityVisionConeSpec', () { test('only the four directional abilities have vision cones', () { final expected = <(AgentType, int), double>{ @@ -52,5 +65,59 @@ void main() { expect(spec.resolveLength(storedLength: 75, mapScale: 1), 75); } }); + + testWidgets('counter-rotation keeps the drag correction rotation-invariant', + (tester) async { + final turret = PlacedAbility( + id: 'turret', + data: AgentData.agents[AgentType.killjoy]!.abilities[2], + position: Offset.zero, + ); + final spec = AbilityVisionConeSpec.forAbility(turret.data)!; + + Future renderedChildTopLeft(double rotation) async { + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + home: Stack( + children: [ + Positioned( + left: 20, + top: 30, + child: AbilityVisionConeComposite( + ability: turret, + spec: spec, + rotation: rotation, + length: 50, + mapScale: 1, + abilitySize: 40, + clipToGeometry: false, + child: const SizedBox( + key: ValueKey('ability-child'), + width: 40, + height: 40, + ), + ), + ), + ], + ), + ), + ), + ); + await tester.pump(); + return tester.getTopLeft( + find.byKey(const ValueKey('ability-child')), + ); + } + + final unrotated = await renderedChildTopLeft(0); + final quarterTurn = await renderedChildTopLeft(math.pi / 2); + final arbitraryRotation = await renderedChildTopLeft(1.234); + + for (final rotated in [quarterTurn, arbitraryRotation]) { + expect(rotated.dx, closeTo(unrotated.dx, 0.000001)); + expect(rotated.dy, closeTo(unrotated.dy, 0.000001)); + } + }); }); } From 6e70518580498ec2a5f5ca57d9a12f559eacd9d4 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Thu, 6 Aug 2026 11:16:36 -0700 Subject: [PATCH 3/4] fix: persist cone lengths in map units --- lib/const/ability_vision.dart | 15 +++++++++++++-- .../ability/placed_ability_widget.dart | 4 ++-- test/ability_vision_cone_test.dart | 17 +++++++++++++++++ 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/lib/const/ability_vision.dart b/lib/const/ability_vision.dart index 527317b1..80491ad9 100644 --- a/lib/const/ability_vision.dart +++ b/lib/const/ability_vision.dart @@ -54,7 +54,18 @@ class AbilityVisionConeSpec { final maximum = maximumLength(mapScale); final minimum = math.min(ViewConeUtility.minLength, maximum); final fallback = maxRangeMeters == null ? _defaultLength : maximum; - final requested = storedLength > 0 ? storedLength : fallback; - return requested.clamp(minimum, maximum); + final storedScale = maxRangeMeters == null ? 1.0 : mapScale; + final requested = storedLength > 0 ? storedLength * storedScale : fallback; + return requested.clamp(minimum, maximum).toDouble(); + } + + double storedLengthFromRendered({ + required double renderedLength, + required double mapScale, + }) { + final maximum = maximumLength(mapScale); + final minimum = math.min(ViewConeUtility.minLength, maximum); + final clamped = renderedLength.clamp(minimum, maximum).toDouble(); + return maxRangeMeters == null ? clamped : clamped / mapScale; } } diff --git a/lib/widgets/draggable_widgets/ability/placed_ability_widget.dart b/lib/widgets/draggable_widgets/ability/placed_ability_widget.dart index 51db0ef7..8de8c394 100644 --- a/lib/widgets/draggable_widgets/ability/placed_ability_widget.dart +++ b/lib/widgets/draggable_widgets/ability/placed_ability_widget.dart @@ -526,8 +526,8 @@ class _PlacedAbilityWidgetState extends ConsumerState { final delta = details.globalPosition - rotationOrigin; final nextRotation = math.atan2(delta.dy, delta.dx) + (math.pi / 2); - final nextLength = visionSpec.resolveLength( - storedLength: coordinateSystem.normalize(delta.distance) / + final nextLength = visionSpec.storedLengthFromRendered( + renderedLength: coordinateSystem.normalize(delta.distance) / ref.watch(screenZoomProvider), mapScale: mapScale, ); diff --git a/test/ability_vision_cone_test.dart b/test/ability_vision_cone_test.dart index ac682541..7ecade5b 100644 --- a/test/ability_vision_cone_test.dart +++ b/test/ability_vision_cone_test.dart @@ -53,6 +53,23 @@ void main() { } }); + test('fixed-range cones persist map-independent lengths', () { + final ability = AgentData.agents[AgentType.raze]!.abilities.first; + final spec = AbilityVisionConeSpec.forAbility(ability)!; + const mapScale = 0.5; + final renderedMaximum = spec.maximumLength(mapScale); + final stored = spec.storedLengthFromRendered( + renderedLength: renderedMaximum, + mapScale: mapScale, + ); + + expect(stored, renderedMaximum / mapScale); + expect( + spec.resolveLength(storedLength: stored, mapScale: mapScale), + renderedMaximum, + ); + }); + test('Turret and Spycam retain adjustable line-of-sight range', () { for (final (agentType, index) in [ (AgentType.killjoy, 2), From 4719f649e91aada7acdecdce3d15af189f02527b Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Thu, 6 Aug 2026 11:29:29 -0700 Subject: [PATCH 4/4] test: verify cone lengths across map scales --- test/ability_vision_cone_test.dart | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/test/ability_vision_cone_test.dart b/test/ability_vision_cone_test.dart index 7ecade5b..2be724a7 100644 --- a/test/ability_vision_cone_test.dart +++ b/test/ability_vision_cone_test.dart @@ -68,6 +68,11 @@ void main() { spec.resolveLength(storedLength: stored, mapScale: mapScale), renderedMaximum, ); + const otherMapScale = 1.0; + expect( + spec.resolveLength(storedLength: stored, mapScale: otherMapScale), + spec.maximumLength(otherMapScale), + ); }); test('Turret and Spycam retain adjustable line-of-sight range', () {