diff --git a/lib/const/abilities.dart b/lib/const/abilities.dart index 9867b3ab..75398ea5 100644 --- a/lib/const/abilities.dart +++ b/lib/const/abilities.dart @@ -118,8 +118,15 @@ class BaseAbility extends Ability { class ImageAbility extends Ability { final String imagePath; final double size; + final Color? inactiveTraceColor; - ImageAbility({required this.imagePath, required this.size}); + ImageAbility({ + required this.imagePath, + required this.size, + this.inactiveTraceColor, + }); + + bool get supportsInactiveState => inactiveTraceColor != null; @override Widget createWidget({ @@ -141,6 +148,10 @@ class ImageAbility extends Ability { imagePath: imagePath, size: size * mapScale, id: id, + visualState: visualState, + inactiveTraceColor: inactiveTraceColor, + watchMouse: watchMouse, + contextMenuItems: contextMenuItems, ); } @@ -343,6 +354,7 @@ class SquareAbility extends Ability { final bool hasTopborder; final bool hasSideBorders; final bool isTransparent; + final bool supportsInactiveState; SquareAbility({ required this.width, @@ -354,8 +366,12 @@ class SquareAbility extends Ability { this.hasTopborder = false, this.hasSideBorders = false, this.isTransparent = false, + this.supportsInactiveState = false, double? minHeight, - }); + }) : assert( + !supportsInactiveState || isWall, + 'Inactive square traces are only supported for walls', + ); @override Offset getAnchorPoint({ @@ -430,6 +446,7 @@ class SquareAbility extends Ability { hasSideBorders: hasSideBorders, isWall: isWall, isTransparent: isTransparent, + supportsInactiveState: supportsInactiveState, visualState: visualState, watchMouse: watchMouse, contextMenuItems: contextMenuItems, diff --git a/lib/const/agents.dart b/lib/const/agents.dart index 49089148..9ec899d5 100644 --- a/lib/const/agents.dart +++ b/lib/const/agents.dart @@ -366,6 +366,7 @@ class AgentData implements DraggableData { abilityData: ImageAbility( imagePath: 'assets/agents/Viper/Smoke.webp', size: 4.5 * inGameMetersDiameter, + inactiveTraceColor: Colors.greenAccent, ), ); @@ -380,6 +381,7 @@ class AgentData implements DraggableData { width: 5, height: 60 * inGameMeters, isWall: true, + supportsInactiveState: true, iconPath: agent.abilities[2].iconPath, color: Colors.greenAccent, ); 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 62fbe731..b1537213 100644 --- a/lib/widgets/draggable_widgets/ability/ability_visibility_context_menu.dart +++ b/lib/widgets/draggable_widgets/ability/ability_visibility_context_menu.dart @@ -10,13 +10,22 @@ import 'package:icarus/widgets/draggable_widgets/adjacent_page_copy_menu.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; bool supportsAbilityVisibilityMenu(Ability? ability) { - return ability is SquareAbility || + return supportsAbilityInactiveState(ability) || + ability is SquareAbility || ability is CenterSquareAbility || ability is CircleAbility || ability is SectorCircleAbility || ability is DeadlockBarrierMeshAbility; } +bool supportsAbilityInactiveState(Ability? ability) { + return switch (ability) { + ImageAbility() => ability.supportsInactiveState, + SquareAbility() => ability.supportsInactiveState, + _ => false, + }; +} + List? buildAbilityContextMenuItems( WidgetRef ref, PlacedAbility ability, { @@ -77,6 +86,18 @@ List<_AbilityVisibilityControl> _buildVisibilityControls( Ability? abilityData, AbilityVisualState visualState, ) { + if (supportsAbilityInactiveState(abilityData)) { + return [ + _AbilityVisibilityControl( + label: 'Active', + isEnabled: visualState.showRangeFill, + toggle: (state) => state.copyWith( + showRangeFill: !state.showRangeFill, + ), + ), + ]; + } + if (abilityData is CircleAbility || abilityData is SectorCircleAbility) { if (_hasInnerRange(abilityData)) { return [ diff --git a/lib/widgets/draggable_widgets/ability/custom_square_widget.dart b/lib/widgets/draggable_widgets/ability/custom_square_widget.dart index a618c23d..2a1f7d71 100644 --- a/lib/widgets/draggable_widgets/ability/custom_square_widget.dart +++ b/lib/widgets/draggable_widgets/ability/custom_square_widget.dart @@ -5,6 +5,7 @@ import 'package:icarus/const/placed_classes.dart'; import 'package:icarus/providers/strategy_settings_provider.dart'; import 'package:icarus/widgets/custom_border_container.dart'; import 'package:icarus/widgets/draggable_widgets/ability/ability_widget.dart'; +import 'package:icarus/widgets/draggable_widgets/ability/inactive_ability_trace.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; class CustomSquareWidget extends ConsumerWidget { @@ -24,6 +25,7 @@ class CustomSquareWidget extends ConsumerWidget { required this.hasSideBorders, required this.isWall, required this.isTransparent, + this.supportsInactiveState = false, this.visualState, this.watchMouse = true, this.contextMenuItems, @@ -43,6 +45,7 @@ class CustomSquareWidget extends ConsumerWidget { final bool hasSideBorders; final bool isWall; final bool isTransparent; + final bool supportsInactiveState; final AbilityVisualState? visualState; final bool watchMouse; final List? contextMenuItems; @@ -67,6 +70,14 @@ class CustomSquareWidget extends ConsumerWidget { (scaledAbilitySize / 2) + resizeButtonOffset; final showRangeFill = visualState?.showRangeFill ?? true; + final inactiveTraceWidth = coordinateSystem.scale(12); + final wallBody = IgnorePointer( + child: Container( + width: width, + height: scaledHeight, + color: abilityWallDisplayColor(color), + ), + ); return SizedBox( width: scaledWidth, @@ -111,14 +122,32 @@ class CustomSquareWidget extends ConsumerWidget { Positioned( top: resizeButtonOffset, left: (scaledWidth / 2) - width / 2, - child: Opacity( - key: const ValueKey('square-range-body'), - opacity: showRangeFill ? 1 : 0, + child: supportsInactiveState + ? AnimatedOpacity( + key: const ValueKey('square-range-body-transition'), + opacity: showRangeFill ? 1 : 0, + duration: abilityStateTransitionDuration, + child: wallBody, + ) + : Opacity( + key: const ValueKey('square-range-body'), + opacity: showRangeFill ? 1 : 0, + child: wallBody, + ), + ), + if (isWall && supportsInactiveState) + Positioned( + top: resizeButtonOffset, + left: (scaledWidth - inactiveTraceWidth) / 2, + child: AnimatedOpacity( + key: const ValueKey('inactive-wall-trace-transition'), + opacity: showRangeFill ? 0 : 1, + duration: abilityStateTransitionDuration, child: IgnorePointer( - child: Container( - width: width, + child: SizedBox( + width: inactiveTraceWidth, height: scaledHeight, - color: color.withAlpha(100), + child: InactiveWallAbilityTrace(color: color), ), ), ), diff --git a/lib/widgets/draggable_widgets/ability/inactive_ability_trace.dart b/lib/widgets/draggable_widgets/ability/inactive_ability_trace.dart new file mode 100644 index 00000000..82a5b425 --- /dev/null +++ b/lib/widgets/draggable_widgets/ability/inactive_ability_trace.dart @@ -0,0 +1,126 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +const abilityStateTransitionDuration = Duration(milliseconds: 180); + +Color abilityWallDisplayColor(Color color) => color.withAlpha(100); + +class InactiveCircleAbilityTrace extends StatelessWidget { + const InactiveCircleAbilityTrace({super.key, required this.color}); + + final Color color; + + @override + Widget build(BuildContext context) { + return CustomPaint( + key: const ValueKey('inactive-circle-trace'), + painter: InactiveCircleAbilityTracePainter( + color: abilityWallDisplayColor(color), + ), + ); + } +} + +class InactiveWallAbilityTrace extends StatelessWidget { + const InactiveWallAbilityTrace({super.key, required this.color}); + + final Color color; + + @override + Widget build(BuildContext context) { + return CustomPaint( + key: const ValueKey('inactive-wall-trace'), + painter: InactiveWallAbilityTracePainter( + color: abilityWallDisplayColor(color), + ), + ); + } +} + +class InactiveCircleAbilityTracePainter extends CustomPainter { + const InactiveCircleAbilityTracePainter({required this.color}); + + final Color color; + + @override + void paint(Canvas canvas, Size size) { + const strokeWidth = 2.0; + final paint = Paint() + ..color = color + ..style = PaintingStyle.stroke + ..strokeWidth = strokeWidth + ..strokeCap = StrokeCap.round; + const inset = strokeWidth / 2; + final boundary = Path() + ..addOval( + Rect.fromLTWH( + inset, + inset, + math.max(0, size.width - strokeWidth), + math.max(0, size.height - strokeWidth), + ), + ); + + _drawDashedPath(canvas, boundary, paint); + + canvas.drawCircle( + size.center(Offset.zero), + 3, + Paint() + ..color = color + ..style = PaintingStyle.fill, + ); + } + + @override + bool shouldRepaint(InactiveCircleAbilityTracePainter oldDelegate) { + return oldDelegate.color != color; + } +} + +class InactiveWallAbilityTracePainter extends CustomPainter { + const InactiveWallAbilityTracePainter({required this.color}); + + final Color color; + + @override + void paint(Canvas canvas, Size size) { + const strokeWidth = 2.0; + const inset = strokeWidth / 2; + final start = Offset(size.width / 2, inset); + final end = Offset(size.width / 2, size.height - inset); + final path = Path() + ..moveTo(start.dx, start.dy) + ..lineTo(end.dx, end.dy); + final tracePaint = Paint() + ..color = color + ..style = PaintingStyle.stroke + ..strokeWidth = strokeWidth + ..strokeCap = StrokeCap.round; + + _drawDashedPath(canvas, path, tracePaint); + } + + @override + bool shouldRepaint(InactiveWallAbilityTracePainter oldDelegate) { + return oldDelegate.color != color; + } +} + +void _drawDashedPath( + Canvas canvas, + Path path, + Paint paint, { + double dashLength = 8, + double gapLength = 5, +}) { + for (final metric in path.computeMetrics()) { + double distance = 0; + while (distance < metric.length) { + final end = math.min(distance + dashLength, metric.length); + canvas.drawPath(metric.extractPath(distance, end), paint); + distance = end + gapLength; + } + } +} diff --git a/lib/widgets/draggable_widgets/ability/simple_image_ability_widget.dart b/lib/widgets/draggable_widgets/ability/simple_image_ability_widget.dart index 922c07f0..794952cf 100644 --- a/lib/widgets/draggable_widgets/ability/simple_image_ability_widget.dart +++ b/lib/widgets/draggable_widgets/ability/simple_image_ability_widget.dart @@ -1,8 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/coordinate_system.dart'; +import 'package:icarus/const/placed_classes.dart'; import 'package:icarus/providers/hovered_delete_target_provider.dart'; +import 'package:icarus/widgets/draggable_widgets/ability/inactive_ability_trace.dart'; import 'package:icarus/widgets/mouse_watch.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; class SimpleImageAbilityWidget extends ConsumerWidget { const SimpleImageAbilityWidget({ @@ -13,6 +16,10 @@ class SimpleImageAbilityWidget extends ConsumerWidget { required this.id, this.lineUpId, this.lineUpItemId, + this.visualState, + this.inactiveTraceColor, + this.watchMouse = true, + this.contextMenuItems, }); final double size; @@ -21,6 +28,11 @@ class SimpleImageAbilityWidget extends ConsumerWidget { final String? id; final String? lineUpId; final String? lineUpItemId; + final AbilityVisualState? visualState; + final Color? inactiveTraceColor; + final bool watchMouse; + final List? contextMenuItems; + @override Widget build(BuildContext context, WidgetRef ref) { final coordinateSystem = CoordinateSystem.instance; @@ -29,17 +41,45 @@ class SimpleImageAbilityWidget extends ConsumerWidget { : (id?.isNotEmpty ?? false) ? HoveredDeleteTarget.ability(id: id!, ownerToken: Object()) : null; + final supportsInactiveState = inactiveTraceColor != null; + final isActive = + !supportsInactiveState || (visualState?.showRangeFill ?? true); + final content = SizedBox( + width: coordinateSystem.scale(size), + height: coordinateSystem.scale(size), + child: Stack( + fit: StackFit.expand, + children: [ + AnimatedOpacity( + key: const ValueKey('image-ability-active-layer'), + opacity: isActive ? 1 : 0, + duration: abilityStateTransitionDuration, + child: Image.asset(imagePath), + ), + if (supportsInactiveState) + AnimatedOpacity( + key: const ValueKey('image-ability-inactive-layer'), + opacity: isActive ? 0 : 1, + duration: abilityStateTransitionDuration, + child: InactiveCircleAbilityTrace( + color: inactiveTraceColor!, + ), + ), + ], + ), + ); + + if (!watchMouse) { + return content; + } return MouseWatch( lineUpId: lineUpId, lineUpItemId: lineUpItemId, cursor: SystemMouseCursors.click, deleteTarget: deleteTarget, - child: SizedBox( - width: coordinateSystem.scale(size), - height: coordinateSystem.scale(size), - child: Image.asset(imagePath), - ), + contextMenuItems: contextMenuItems, + child: content, ); } } diff --git a/test/ability_visibility_widgets_test.dart b/test/ability_visibility_widgets_test.dart index b8a52a3b..4ad8d394 100644 --- a/test/ability_visibility_widgets_test.dart +++ b/test/ability_visibility_widgets_test.dart @@ -16,6 +16,7 @@ import 'package:icarus/providers/action_provider.dart'; import 'package:icarus/providers/map_provider.dart'; import 'package:icarus/widgets/draggable_widgets/ability/ability_widget.dart'; import 'package:icarus/widgets/draggable_widgets/ability/custom_square_widget.dart'; +import 'package:icarus/widgets/draggable_widgets/ability/inactive_ability_trace.dart'; import 'package:icarus/widgets/draggable_widgets/ability/placed_ability_widget.dart'; import 'package:icarus/widgets/draggable_widgets/ability/resizable_square_widget.dart'; import 'package:icarus/widgets/draggable_widgets/ability/rotatable_widget.dart'; @@ -106,6 +107,144 @@ void main() { expect(find.byType(AbilityWidget), findsOneWidget); }); + testWidgets('inactive Viper cloud keeps its footprint as a dashed trace', + (tester) async { + final container = ProviderContainer( + overrides: [ + actionProvider.overrideWith(_TestActionProvider.new), + mapProvider.overrideWith(_FixedMapProvider.new), + ], + ); + addTearDown(() async { + await tester.pumpWidget(const SizedBox.shrink()); + container.dispose(); + }); + + final ability = PlacedAbility( + id: 'viper-cloud-inactive', + data: AgentData.agents[AgentType.viper]!.abilities[1], + position: const Offset(100, 120), + visualState: const AbilityVisualState(showRangeFill: false), + ); + container.read(abilityProvider.notifier).fromHive([ability]); + + await tester.pumpWidget( + _buildHarness( + container: container, + child: Stack( + children: [ + PlacedAbilityWidget( + ability: ability, + onDragEnd: (_, __) {}, + id: ability.id, + data: ability, + rotation: ability.rotation, + length: ability.length, + ), + ], + ), + ), + ); + await tester.pumpAndSettle(); + + expect( + tester + .widget( + find.byKey(const ValueKey('image-ability-active-layer')), + ) + .opacity, + 0, + ); + expect( + tester + .widget( + find.byKey(const ValueKey('image-ability-inactive-layer')), + ) + .opacity, + 1, + ); + expect(find.byType(InactiveCircleAbilityTrace), findsOneWidget); + expect(find.byKey(const ValueKey('inactive-circle-trace')), findsOneWidget); + final trace = tester.widget( + find.byKey(const ValueKey('inactive-circle-trace')), + ); + expect( + (trace.painter! as InactiveCircleAbilityTracePainter).color, + Colors.greenAccent.withAlpha(100), + ); + }); + + testWidgets('inactive Viper wall keeps its path and hides its handle', + (tester) async { + final container = ProviderContainer( + overrides: [ + actionProvider.overrideWith(_TestActionProvider.new), + mapProvider.overrideWith(_FixedMapProvider.new), + ], + ); + addTearDown(() async { + await tester.pumpWidget(const SizedBox.shrink()); + container.dispose(); + }); + + final ability = PlacedAbility( + id: 'viper-wall-inactive', + data: AgentData.agents[AgentType.viper]!.abilities[2], + position: const Offset(100, 120), + visualState: const AbilityVisualState(showRangeFill: false), + ); + container.read(abilityProvider.notifier).fromHive([ability]); + + await tester.pumpWidget( + _buildHarness( + container: container, + child: Stack( + children: [ + PlacedAbilityWidget( + ability: ability, + onDragEnd: (_, __) {}, + id: ability.id, + data: ability, + rotation: ability.rotation, + length: ability.length, + ), + ], + ), + ), + ); + await tester.pumpAndSettle(); + + expect( + tester + .widget( + find.byKey(const ValueKey('square-range-body-transition')), + ) + .opacity, + 0, + ); + expect( + tester + .widget( + find.byKey(const ValueKey('inactive-wall-trace-transition')), + ) + .opacity, + 1, + ); + expect(find.byType(InactiveWallAbilityTrace), findsOneWidget); + expect(find.byKey(const ValueKey('inactive-wall-trace')), findsOneWidget); + final trace = tester.widget( + find.byKey(const ValueKey('inactive-wall-trace')), + ); + expect( + (trace.painter! as InactiveWallAbilityTracePainter).color, + Colors.greenAccent.withAlpha(100), + ); + expect( + tester.widget(find.byType(RotatableWidget)).showHandle, + isFalse, + ); + }); + testWidgets('circle visibility toggles preserve icon and target layers', (tester) async { final container = ProviderContainer(); @@ -340,6 +479,76 @@ void main() { expect(find.text('Mesh'), findsNothing); }); + testWidgets('Viper cloud exposes an Active toggle and applies it', + (tester) async { + final ability = PlacedAbility( + id: 'viper-cloud-menu', + data: AgentData.agents[AgentType.viper]!.abilities[1], + position: Offset.zero, + ); + 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([ability]); + + await tester.pumpWidget( + _buildHarness( + container: container, + child: Stack( + children: [ + PlacedAbilityWidget( + ability: ability, + onDragEnd: (_, __) {}, + id: ability.id, + data: ability, + rotation: ability.rotation, + length: ability.length, + ), + ], + ), + ), + ); + await tester.pumpAndSettle(); + + await _openContextMenu( + tester, + find.byKey(const ValueKey('image-ability-active-layer')), + ); + + expect(find.text('Active'), findsOneWidget); + expect(find.text('Range'), findsNothing); + + await tester.tap(find.text('Active')); + await tester.pumpAndSettle(); + + expect( + container.read(abilityProvider).single.visualState.showRangeFill, + isFalse, + ); + }); + + testWidgets('Viper wall uses Active instead of a generic Range toggle', + (tester) async { + final ability = PlacedAbility( + id: 'viper-wall-menu', + data: AgentData.agents[AgentType.viper]!.abilities[2], + position: Offset.zero, + ); + + await _pumpPlacedAbility(tester, ability: ability); + await _openContextMenu(tester, find.byType(AbilityWidget)); + + expect(find.text('Active'), findsOneWidget); + expect(find.text('Range'), findsNothing); + }); + testWidgets('placed circle ability shows range and inner layer toggles', (tester) async { final circleAbility = PlacedAbility(