diff --git a/lib/const/coordinate_system.dart b/lib/const/coordinate_system.dart index bb4ceb45..482606d5 100644 --- a/lib/const/coordinate_system.dart +++ b/lib/const/coordinate_system.dart @@ -1,10 +1,12 @@ +import 'dart:math' as math; + import 'package:flutter/material.dart'; class CoordinateSystem { // System parameters CoordinateSystem._({required Size playAreaSize}) - : _playAreaSize = playAreaSize; + : _playAreaSize = playAreaSize; final Size _playAreaSize; Size get playAreaSize => _playAreaSize; @@ -28,6 +30,67 @@ class CoordinateSystem { final double mapAspectRatio = defaultMapAspectRatio; final double worldAspectRatio = 16 / 9; + /// Placed object data is always authored in the attack-side world. + /// + /// Defense is the same world rotated 180 degrees. Keeping that projection + /// here makes the conversion reversible and keeps side changes out of saved + /// positions. + Offset positionForSide({ + required Offset canonicalPosition, + required Offset reflectionOffset, + required bool isAttack, + }) { + if (isAttack) return canonicalPosition; + return Offset( + worldNormalizedWidth - canonicalPosition.dx - reflectionOffset.dx, + normalizedHeight - canonicalPosition.dy - reflectionOffset.dy, + ); + } + + Offset positionFromSide({ + required Offset sidePosition, + required Offset reflectionOffset, + required bool isAttack, + }) { + return positionForSide( + canonicalPosition: sidePosition, + reflectionOffset: reflectionOffset, + isAttack: isAttack, + ); + } + + Offset screenPositionForSide({ + required Offset attackScreenPosition, + required Offset reflectionOffset, + required bool isAttack, + }) { + if (isAttack) return attackScreenPosition; + return Offset( + _effectiveSize.width - attackScreenPosition.dx - reflectionOffset.dx, + _effectiveSize.height - attackScreenPosition.dy - reflectionOffset.dy, + ); + } + + Offset screenPositionFromSide({ + required Offset sideScreenPosition, + required Offset reflectionOffset, + required bool isAttack, + }) { + return screenPositionForSide( + attackScreenPosition: sideScreenPosition, + reflectionOffset: reflectionOffset, + isAttack: isAttack, + ); + } + + double rotationForSide(double canonicalRotation, {required bool isAttack}) { + return isAttack ? canonicalRotation : canonicalRotation + math.pi; + } + + double rotationFromSide(double sideRotation, {required bool isAttack}) { + return isAttack ? sideRotation : sideRotation - math.pi; + } + double get mapNormalizedWidth => normalizedHeight * mapAspectRatio; double get worldNormalizedWidth => normalizedHeight * worldAspectRatio; double get mapPaddingNormalizedX => @@ -41,7 +104,8 @@ class CoordinateSystem { static CoordinateSystem get instance { if (_instance == null) { throw StateError( - "CoordinateSystem must be initialized with playAreaSize first"); + "CoordinateSystem must be initialized with playAreaSize first", + ); } return _instance!; } @@ -127,20 +191,15 @@ class CoordinateSystem { double normalize(double value) => (value / _scaleFactor); // Scale a size maintaining aspect ratio - Size scaleSize(Size size) => Size( - size.width * _scaleFactor, - size.height * _scaleFactor, - ); + Size scaleSize(Size size) => + Size(size.width * _scaleFactor, size.height * _scaleFactor); Offset convertOldCoordinateToNew(Offset oldCoordinate) { return oldCoordinate; } // Convenience method to wrap a widget with scaled dimensions - Widget scaleWidget({ - required Widget child, - required Size originalSize, - }) { + Widget scaleWidget({required Widget child, required Size originalSize}) { Size scaledSize = scaleSize(originalSize); return SizedBox( width: scaledSize.width, diff --git a/lib/const/folder_icons.dart b/lib/const/folder_icons.dart index bbb055fa..4066a5c7 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 = 95; +const int folderIconRegistryVersion = 97; enum FolderIconRenderKind { material, diff --git a/lib/const/line_provider.dart b/lib/const/line_provider.dart index f0305bf9..b5535153 100644 --- a/lib/const/line_provider.dart +++ b/lib/const/line_provider.dart @@ -4,12 +4,9 @@ import 'dart:ui'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:hive_ce_flutter/adapters.dart'; import 'package:icarus/const/agents.dart'; -import 'package:icarus/const/maps.dart'; import 'package:icarus/const/placed_classes.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/providers/action_provider.dart'; -import 'package:icarus/providers/map_provider.dart'; -import 'package:icarus/providers/strategy_settings_provider.dart'; import 'package:json_annotation/json_annotation.dart'; part "line_provider.g.dart"; @@ -68,15 +65,6 @@ class LineUp extends HiveObject { ); } - void switchSides({ - required double agentSize, - required double abilitySize, - required double mapScale, - }) { - agent.switchSides(agentSize); - ability.switchSides(mapScale: mapScale, abilitySize: abilitySize); - } - factory LineUp.fromJson(Map json) => _$LineUpFromJson(json); Map toJson() => _$LineUpToJson(this); @@ -162,17 +150,6 @@ class LineUpGroup extends HiveObject { ); } - void switchSides({ - required double agentSize, - required double abilitySize, - required double mapScale, - }) { - agent.switchSides(agentSize); - for (final item in items) { - item.ability.switchSides(mapScale: mapScale, abilitySize: abilitySize); - } - } - factory LineUpGroup.fromJson(Map json) => _$LineUpGroupFromJson(json); @@ -496,41 +473,6 @@ class LineUpProvider extends Notifier { setCurrentAbility(ability); } - void switchSides() { - final agentSize = ref.read(strategySettingsProvider).agentSize; - final abilitySize = ref.read(strategySettingsProvider).abilitySize; - final currentMap = ref.read(mapProvider).currentMap; - final mapScale = Maps.mapScale[currentMap] ?? 1.0; - final groups = [...state.groups]; - - for (final group in groups) { - group.switchSides( - agentSize: agentSize, - abilitySize: abilitySize, - mapScale: mapScale, - ); - } - - for (final group in _poppedGroups) { - group.switchSides( - agentSize: agentSize, - abilitySize: abilitySize, - mapScale: mapScale, - ); - } - - final currentAgent = state.currentAgent; - final currentAbility = state.currentAbility; - currentAgent?.switchSides(agentSize); - currentAbility?.switchSides(mapScale: mapScale, abilitySize: abilitySize); - - state = state.copyWith( - groups: groups, - currentAgent: currentAgent, - currentAbility: currentAbility, - ); - } - void setSelectingPosition(bool isSelecting, {PlacingType? type}) { state = state.copyWith( isSelectingPosition: isSelecting, diff --git a/lib/const/placed_classes.dart b/lib/const/placed_classes.dart index fe4ad710..d0aadb6e 100644 --- a/lib/const/placed_classes.dart +++ b/lib/const/placed_classes.dart @@ -1,15 +1,11 @@ import 'dart:convert'; -import 'dart:math' as math; 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'; -import 'package:icarus/const/settings.dart'; import 'package:icarus/const/utilities.dart'; import 'package:json_annotation/json_annotation.dart'; @@ -17,33 +13,6 @@ part "placed_classes.g.dart"; const int worldSizedMediaVersion = 1; -Offset getFlippedPosition({ - required Offset position, - required Offset scaledSize, - bool isRotatable = false, -}) { - final coordinateSystem = CoordinateSystem.instance; - final wNorm = (scaledSize.dx / coordinateSystem.effectiveSize.width) * - coordinateSystem.worldNormalizedWidth; - final hNorm = (scaledSize.dy / coordinateSystem.effectiveSize.height) * - coordinateSystem.normalizedHeight; - final flippedX = coordinateSystem.worldNormalizedWidth - position.dx - wNorm; - double flippedY = 0; - - if (isRotatable) { - // Rotatable widgets are rendered with a different anchor (their visual - // bounds shift when rotated/flipped). To keep their perceived position - // consistent after flipping, we need to compensate for the extra vertical - // offset introduced by rotation by subtracting the normalized height a - // second time. - flippedY = coordinateSystem.normalizedHeight - position.dy - hNorm - hNorm; - } else { - flippedY = coordinateSystem.normalizedHeight - position.dy - hNorm; - } - - return Offset(flippedX, flippedY); -} - AbilityVisualState _abilityVisualStateFromJson(Map? json) { if (json == null) { return const AbilityVisualState(); @@ -233,25 +202,6 @@ class PlacedText extends PlacedWidget { copied.isDeleted = isDeleted ?? this.isDeleted; return copied; } - - void switchSides(Offset size) { - position = getFlippedPosition(position: position, scaledSize: size); - - for (final (index, action) in _actionHistory.indexed) { - if (action is PositionAction) { - _actionHistory[index] = action.copyWith( - position: getFlippedPosition( - position: action.position, scaledSize: size)); - } - } - for (final (index, action) in _poppedAction.indexed) { - if (action is PositionAction) { - _poppedAction[index] = action.copyWith( - position: getFlippedPosition( - position: action.position, scaledSize: size)); - } - } - } } @JsonSerializable() @@ -295,25 +245,6 @@ class PlacedImage extends PlacedWidget { tagColorValue = colorValue; } - void switchSides(Offset size) { - position = getFlippedPosition(position: position, scaledSize: size); - - for (final (index, action) in _actionHistory.indexed) { - if (action is PositionAction) { - _actionHistory[index] = action.copyWith( - position: getFlippedPosition( - position: action.position, scaledSize: size)); - } - } - for (final (index, action) in _poppedAction.indexed) { - if (action is PositionAction) { - _poppedAction[index] = action.copyWith( - position: getFlippedPosition( - position: action.position, scaledSize: size)); - } - } - } - /// Returns a new independent [PlacedImage] object. None of the internal /// action history / redo stacks from the base [PlacedWidget] are carried /// over, ensuring this is a clean clone (useful when duplicating pages or @@ -391,41 +322,6 @@ sealed class PlacedAgentNode extends PlacedWidget { String get kind; - void switchSides(double agentSize) { - final coordinateSystem = CoordinateSystem.instance; - // Serialized positions use the historical default marker footprint. - // Runtime size changes are render-only, so side switching must not depend - // on the size selected when the command runs. - final agentScreenPx = coordinateSystem.scale(Settings.agentSize); - final scaledSize = Offset(agentScreenPx, agentScreenPx); - - position = getFlippedPosition(position: position, scaledSize: scaledSize); - _flipSharedPositionHistory(scaledSize); - } - - void _flipSharedPositionHistory(Offset scaledSize) { - for (final (index, action) in _actionHistory.indexed) { - if (action is PositionAction) { - _actionHistory[index] = action.copyWith( - position: getFlippedPosition( - position: action.position, - scaledSize: scaledSize, - ), - ); - } - } - for (final (index, action) in _poppedAction.indexed) { - if (action is PositionAction) { - _poppedAction[index] = action.copyWith( - position: getFlippedPosition( - position: action.position, - scaledSize: scaledSize, - ), - ); - } - } - } - factory PlacedAgentNode.fromJson(Map json) { final kind = json['kind'] as String? ?? plainKind; switch (kind) { @@ -642,27 +538,6 @@ class PlacedViewConeAgent extends PlacedAgentNode { } } - @override - void switchSides(double agentSize) { - super.switchSides(agentSize); - rotation = rotation + math.pi; - - for (final (index, action) in _actionHistory.indexed) { - if (action is ViewConeAgentGeometryAction) { - _actionHistory[index] = action.copyWith( - rotation: action.rotation + math.pi, - ); - } - } - for (final (index, action) in _poppedAction.indexed) { - if (action is ViewConeAgentGeometryAction) { - _poppedAction[index] = action.copyWith( - rotation: action.rotation + math.pi, - ); - } - } - } - static const Object _noChange = Object(); PlacedViewConeAgent copyWith({ @@ -929,63 +804,6 @@ class PlacedAbility extends PlacedWidget { _poppedAction.removeLast(); } - void switchSides({required double mapScale, required double abilitySize}) { - // Serialized positions are defined at the historical default marker size. - // Side switching must use that same geometry so the result is independent - // of whichever runtime size happens to be selected when the switch occurs. - final fullAbilityWidgetSize = data.abilityData!.getSize( - mapScale: mapScale, - abilitySize: Settings.abilitySize, - ); - final abilityData = data.abilityData!; - final hasVisionCone = AbilityVisionConeSpec.forAbility(data) != null; - final shouldRotate = isRotatable(abilityData) || hasVisionCone; - final shouldUseRotatableFlipCompensation = - isRotatable(abilityData) && abilityData is! DeadlockBarrierMeshAbility; - - final scaledAbilitySize = fullAbilityWidgetSize.scale( - CoordinateSystem.instance.scaleFactor, - CoordinateSystem.instance.scaleFactor); - - Offset flippedPosition = getFlippedPosition( - position: position, - scaledSize: scaledAbilitySize, - isRotatable: shouldUseRotatableFlipCompensation); - position = flippedPosition; - - if (shouldRotate) { - rotation = rotation + math.pi; - } - - for (final (index, action) in _actionHistory.indexed) { - if (action is PositionAction) { - _actionHistory[index] = action.copyWith( - position: getFlippedPosition( - position: action.position, - scaledSize: scaledAbilitySize, - isRotatable: shouldUseRotatableFlipCompensation)); - } else if (action is AbilityGeometryAction) { - _actionHistory[index] = action.copyWith( - rotation: shouldRotate ? action.rotation + math.pi : action.rotation, - ); - } - } - - for (final (index, action) in _poppedAction.indexed) { - if (action is PositionAction) { - _poppedAction[index] = action.copyWith( - position: getFlippedPosition( - position: action.position, - scaledSize: scaledAbilitySize, - isRotatable: shouldUseRotatableFlipCompensation)); - } else if (action is AbilityGeometryAction) { - _poppedAction[index] = action.copyWith( - rotation: shouldRotate ? action.rotation + math.pi : action.rotation, - ); - } - } - } - @override void undoAction() { if (_actionHistory.isEmpty) return; @@ -1202,108 +1020,6 @@ class PlacedUtility extends PlacedWidget { visionElevation = newElevation; } - _getIsRotationUtility(UtilityType type) { - return UtilityData.isViewCone(type); - } - - Offset _getEffectiveUtilitySize({ - required double mapScale, - required double agentSize, - required double abilitySize, - }) { - final utility = UtilityData.utilityWidgets[type]!; - if (type == UtilityType.customCircle) { - assert(customDiameter != null, - 'customDiameter is required for custom circle utility.'); - if (customDiameter == null) { - return Offset.zero; - } - return utility.getSize( - diameterMeters: customDiameter, mapScale: mapScale); - } - if (type == UtilityType.customRectangle) { - assert(customWidth != null && customLength != null, - 'customWidth and customLength are required for custom rectangle utility.'); - if (customWidth == null || customLength == null) { - return Offset.zero; - } - return utility.getSize( - widthMeters: customWidth, - rectLengthMeters: customLength, - mapScale: mapScale, - ); - } - if (UtilityData.isRoleIcon(type)) { - return utility.getSize(agentSize: agentSize); - } - return utility.getSize(abilitySize: abilitySize); - } - - void switchSides({ - required double mapScale, - required double agentSize, - required double abilitySize, - }) { - final size = _getEffectiveUtilitySize( - mapScale: mapScale, - agentSize: Settings.agentSize, - abilitySize: Settings.abilitySize, - ); - final scaledSize = size.scale(CoordinateSystem.instance.scaleFactor, - CoordinateSystem.instance.scaleFactor); - final flippedPosition = getFlippedPosition( - position: position, - scaledSize: scaledSize, - isRotatable: _getIsRotationUtility(type)); - - position = flippedPosition; - - if (_getIsRotationUtility(type)) { - rotation = rotation + math.pi; - } - - for (final (index, action) in _actionHistory.indexed) { - if (action is PositionAction) { - Offset actionFlippedPosition = getFlippedPosition( - position: action.position, - scaledSize: scaledSize, - isRotatable: _getIsRotationUtility(type)); - - _actionHistory[index] = - action.copyWith(position: actionFlippedPosition); - } else if (action is RotationAction) { - _actionHistory[index] = - action.copyWith(rotation: action.rotation + math.pi); - } else if (action is CustomShapeGeometryAction) { - final actionFlippedPosition = getFlippedPosition( - position: action.position, - scaledSize: scaledSize, - isRotatable: _getIsRotationUtility(type)); - _actionHistory[index] = - action.copyWith(position: actionFlippedPosition); - } - } - for (final (index, action) in _poppedAction.indexed) { - if (action is PositionAction) { - Offset actionFlippedPosition = getFlippedPosition( - position: action.position, - scaledSize: scaledSize, - isRotatable: _getIsRotationUtility(type)); - - _poppedAction[index] = action.copyWith(position: actionFlippedPosition); - } else if (action is RotationAction) { - _poppedAction[index] = - action.copyWith(rotation: action.rotation + math.pi); - } else if (action is CustomShapeGeometryAction) { - final actionFlippedPosition = getFlippedPosition( - position: action.position, - scaledSize: scaledSize, - isRotatable: _getIsRotationUtility(type)); - _poppedAction[index] = action.copyWith(position: actionFlippedPosition); - } - } - } - void updateRotationHistory() { final action = RotationAction( rotation: rotation, diff --git a/lib/const/placed_media_geometry.dart b/lib/const/placed_media_geometry.dart new file mode 100644 index 00000000..164a5cc3 --- /dev/null +++ b/lib/const/placed_media_geometry.dart @@ -0,0 +1,58 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:icarus/const/image_scale_policy.dart'; +import 'package:icarus/const/placed_classes.dart'; + +abstract final class PlacedMediaGeometry { + static const double _referencePixelsPerWorldUnit = 1080 / 1000; + static const double _legacyWidthToWorldFactor = (1000 * (16 / 9)) / 1920; + static const double _legacyFontToWorldFactor = 1000 / 1080; + + static double textWidthInWorld(PlacedText text) { + return text.usesWorldSize + ? text.size + : text.size * _legacyWidthToWorldFactor; + } + + static double textFontSizeInWorld(PlacedText text) { + return text.usesWorldSize + ? text.fontSize + : text.fontSize * _legacyFontToWorldFactor; + } + + static Size legacyTextFootprintInWorld(PlacedText text) { + final width = textWidthInWorld(text); + final widthInPixels = width * _referencePixelsPerWorldUnit; + final fontSizeInPixels = + textFontSizeInWorld(text) * _referencePixelsPerWorldUnit; + + // The text field sits after the 6 px tag, 2 px gap, and the card's 5 px + // horizontal padding on each side. Material's borderless field contributes + // its intrinsic vertical chrome and retains a 48 px minimum height. + final painter = TextPainter( + text: TextSpan( + text: text.text.isEmpty ? 'Write here...' : text.text, + style: TextStyle(fontSize: fontSizeInPixels), + ), + textDirection: TextDirection.ltr, + )..layout(maxWidth: math.max(0, widthInPixels - 18)); + final heightInPixels = math.max(48, painter.height + 43); + + return Size(width, heightInPixels / _referencePixelsPerWorldUnit); + } + + static Size legacyImageFootprintInWorld(PlacedImage image) { + final rawWidth = image.usesWorldSize + ? image.scale + : image.scale * _legacyWidthToWorldFactor; + final width = ImageScalePolicy.clamp(rawWidth); + final widthInPixels = width * _referencePixelsPerWorldUnit; + const leftChromeWidth = 12.0; + final cardWidth = math.max(1, widthInPixels - leftChromeWidth); + final aspectRatio = image.aspectRatio <= 0 ? 1.0 : image.aspectRatio; + final heightInPixels = (cardWidth - 10) / aspectRatio + 10; + + return Size(width, heightInPixels / _referencePixelsPerWorldUnit); + } +} diff --git a/lib/const/settings.dart b/lib/const/settings.dart index d25035ca..3860a108 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 = 96; + static const int versionNumber = 97; static const String versionName = "4.6.1"; static final Uri desktopUpdaterArchiveUrl = buildDesktopUpdaterArchiveUrl(kResolvedUpdateChannel); diff --git a/lib/const/transition_data.dart b/lib/const/transition_data.dart index e177a096..a91d00de 100644 --- a/lib/const/transition_data.dart +++ b/lib/const/transition_data.dart @@ -86,8 +86,9 @@ class PageLayering { } static int compareEntries(PageTransitionEntry a, PageTransitionEntry b) { - final zCompare = zIndexForWidget(a.visualWidget) - .compareTo(zIndexForWidget(b.visualWidget)); + final zCompare = zIndexForWidget( + a.visualWidget, + ).compareTo(zIndexForWidget(b.visualWidget)); if (zCompare != 0) { return zCompare; } @@ -106,14 +107,20 @@ Offset screenPositionForWidget({ double? mapScale, double? agentSize, double? abilitySize, + bool isAttack = true, }) { final position = coordinatePosition ?? widget.position; var screen = coordinateSystem.coordinateToScreen(position); + Offset? renderedAnchor; if (widget is PlacedAgentNode && agentSize != null) { screen += agentScreenPositionAdjustment( coordinateSystem: coordinateSystem, agentSize: agentSize, ); + renderedAnchor = Offset( + agentSize / 2, + agentSize / 2, + ).scale(coordinateSystem.scaleFactor, coordinateSystem.scaleFactor); } if (widget is PlacedAbility && mapScale != null && abilitySize != null) { screen += abilityScreenPositionAdjustment( @@ -122,6 +129,9 @@ Offset screenPositionForWidget({ mapScale: mapScale, abilitySize: abilitySize, ); + renderedAnchor = widget.data.abilityData! + .getAnchorPoint(mapScale: mapScale, abilitySize: abilitySize) + .scale(coordinateSystem.scaleFactor, coordinateSystem.scaleFactor); } if (widget is PlacedUtility && mapScale != null && @@ -134,6 +144,19 @@ Offset screenPositionForWidget({ agentSize: agentSize, abilitySize: abilitySize, ); + renderedAnchor = utilityAnchorForScale( + utility: widget, + mapScale: mapScale, + agentSize: agentSize, + abilitySize: abilitySize, + ).scale(coordinateSystem.scaleFactor, coordinateSystem.scaleFactor); + } + if (renderedAnchor != null) { + screen = coordinateSystem.screenPositionForSide( + attackScreenPosition: screen, + reflectionOffset: renderedAnchor * 2, + isAttack: isAttack, + ); } if (widget is PlacedAbility && widget.data.abilityData is CircleAbility) { // Pixel snapping keeps circles from visually drifting between static and @@ -164,26 +187,36 @@ Offset storedAgentPositionForRenderedScreenPosition({ required CoordinateSystem coordinateSystem, required Offset renderedScreenPosition, required double agentSize, + bool isAttack = true, }) { + final renderedAnchor = Offset( + agentSize / 2, + agentSize / 2, + ).scale(coordinateSystem.scaleFactor, coordinateSystem.scaleFactor); + final attackScreenPosition = coordinateSystem.screenPositionFromSide( + sideScreenPosition: renderedScreenPosition, + reflectionOffset: renderedAnchor * 2, + isAttack: isAttack, + ); final adjustment = agentScreenPositionAdjustment( coordinateSystem: coordinateSystem, agentSize: agentSize, ); - return coordinateSystem.screenToCoordinate( - renderedScreenPosition - adjustment, - ); + return coordinateSystem.screenToCoordinate(attackScreenPosition - adjustment); } Offset screenAnchorForAgent({ required PlacedAgentNode agent, required CoordinateSystem coordinateSystem, Offset? coordinatePosition, + bool isAttack = true, }) { return screenPositionForWidget( widget: agent, coordinateSystem: coordinateSystem, coordinatePosition: coordinatePosition, agentSize: Settings.agentSize, + isAttack: isAttack, ) + storedAgentAnchor.scale( coordinateSystem.scaleFactor, @@ -234,16 +267,23 @@ Offset storedAbilityPositionForRenderedScreenPosition({ required Offset renderedScreenPosition, required double mapScale, required double abilitySize, + bool isAttack = true, }) { + final renderedAnchor = ability + .getAnchorPoint(mapScale: mapScale, abilitySize: abilitySize) + .scale(coordinateSystem.scaleFactor, coordinateSystem.scaleFactor); + final attackScreenPosition = coordinateSystem.screenPositionFromSide( + sideScreenPosition: renderedScreenPosition, + reflectionOffset: renderedAnchor * 2, + isAttack: isAttack, + ); final adjustment = abilityScreenPositionAdjustment( ability: ability, coordinateSystem: coordinateSystem, mapScale: mapScale, abilitySize: abilitySize, ); - return coordinateSystem.screenToCoordinate( - renderedScreenPosition - adjustment, - ); + return coordinateSystem.screenToCoordinate(attackScreenPosition - adjustment); } /// Screen-space position of the semantic widget anchor. It intentionally does @@ -253,6 +293,7 @@ Offset screenAnchorForAbility({ required CoordinateSystem coordinateSystem, required double mapScale, Offset? coordinatePosition, + bool isAttack = true, }) { return screenPositionForWidget( widget: ability, @@ -260,14 +301,12 @@ Offset screenAnchorForAbility({ coordinatePosition: coordinatePosition, mapScale: mapScale, abilitySize: Settings.abilitySize, + isAttack: isAttack, ) + storedAbilityAnchor( ability: ability.data.abilityData!, mapScale: mapScale, - ).scale( - coordinateSystem.scaleFactor, - coordinateSystem.scaleFactor, - ); + ).scale(coordinateSystem.scaleFactor, coordinateSystem.scaleFactor); } Offset utilityAnchorForScale({ @@ -331,7 +370,19 @@ Offset storedUtilityPositionForRenderedScreenPosition({ required double mapScale, required double agentSize, required double abilitySize, + bool isAttack = true, }) { + final renderedAnchor = utilityAnchorForScale( + utility: utility, + mapScale: mapScale, + agentSize: agentSize, + abilitySize: abilitySize, + ).scale(coordinateSystem.scaleFactor, coordinateSystem.scaleFactor); + final attackScreenPosition = coordinateSystem.screenPositionFromSide( + sideScreenPosition: renderedScreenPosition, + reflectionOffset: renderedAnchor * 2, + isAttack: isAttack, + ); final adjustment = utilityScreenPositionAdjustment( utility: utility, coordinateSystem: coordinateSystem, @@ -339,31 +390,31 @@ Offset storedUtilityPositionForRenderedScreenPosition({ agentSize: agentSize, abilitySize: abilitySize, ); - return coordinateSystem.screenToCoordinate( - renderedScreenPosition - adjustment, - ); + return coordinateSystem.screenToCoordinate(attackScreenPosition - adjustment); } /// One entry describing how a single PlacedWidget should animate. class PageTransitionEntry { - PageTransitionEntry.move( - {required this.from, required this.to, this.order = 0}) - : kind = TransitionKind.move, - id = to!.id; + PageTransitionEntry.move({ + required this.from, + required this.to, + this.order = 0, + }) : kind = TransitionKind.move, + id = to!.id; PageTransitionEntry.appear({required this.to, this.order = 0}) - : kind = TransitionKind.appear, - id = to!.id, - from = null; + : kind = TransitionKind.appear, + id = to!.id, + from = null; PageTransitionEntry.disappear({required this.from, this.order = 0}) - : kind = TransitionKind.disappear, - id = from!.id, - to = null; + : kind = TransitionKind.disappear, + id = from!.id, + to = null; // For unchanged items we still want to render during the transition // so they remain visible while the base view is hidden. PageTransitionEntry.none({required this.to, this.order = 0}) - : kind = TransitionKind.none, - id = to!.id, - from = null; + : kind = TransitionKind.none, + id = to!.id, + from = null; final String id; final TransitionKind kind; @@ -378,7 +429,9 @@ class PageTransitionEntry { // Rotation is only relevant for some subtypes. Null when not applicable. static double? rotationOf(PlacedWidget w) { if (w is PlacedAbility) return w.rotation; - if (w is PlacedUtility) return w.rotation; + if (w is PlacedUtility && UtilityData.usesRotation(w.type)) { + return w.rotation; + } if (w is PlacedViewConeAgent) return w.rotation; return null; } @@ -432,16 +485,16 @@ class PageTransitionEntry { /// list. Kept next to the `*Of` helpers so adding a tweened property means /// updating both in the same place. static List _tweenedPropsOf(PlacedWidget w) => [ - w.position, - rotationOf(w), - lengthOf(w), - scaleOf(w), - textSizeOf(w), - agentStateOf(w), - customDiameterOf(w), - customWidthOf(w), - customLengthOf(w), - ]; + w.position, + rotationOf(w), + lengthOf(w), + scaleOf(w), + textSizeOf(w), + agentStateOf(w), + customDiameterOf(w), + customWidthOf(w), + customLengthOf(w), + ]; /// True when [from] and [to] differ in any tweened property, i.e. the /// widget needs a move transition rather than a plain hold. diff --git a/lib/const/utilities.dart b/lib/const/utilities.dart index 23a7f232..ff25c61a 100644 --- a/lib/const/utilities.dart +++ b/lib/const/utilities.dart @@ -81,6 +81,14 @@ class UtilityData { type == UtilityType.sentinel; } + /// Whether [type] has a visible orientation on the canvas. + /// + /// Other utility types may retain a legacy rotation value in saved data, + /// but their normal canvas widgets are deliberately rendered upright. + static bool usesRotation(UtilityType type) { + return isViewCone(type) || type == UtilityType.customRectangle; + } + static bool isAgentAttachable(UtilityType type) { return isViewCone(type) || type == UtilityType.customCircle; } diff --git a/lib/interactive_map.dart b/lib/interactive_map.dart index b391d642..d9a578b0 100644 --- a/lib/interactive_map.dart +++ b/lib/interactive_map.dart @@ -39,6 +39,7 @@ class _InteractiveMapState extends ConsumerState { final controller = TransformationController(); Size? _lastViewportSize; Size? _lastPlayAreaSize; + bool? _lastIsAttack; bool _placementCenterUpdateScheduled = false; bool _zoomSyncScheduled = false; double? _pendingZoom; @@ -77,7 +78,12 @@ class _InteractiveMapState extends ConsumerState { }) { final sceneCenter = controller.toScene(Offset(viewportWidth / 2, viewportHeight / 2)); - final normalizedCenter = coordinateSystem.screenToCoordinate(sceneCenter); + final sideCenter = coordinateSystem.screenToCoordinate(sceneCenter); + final normalizedCenter = coordinateSystem.positionFromSide( + sidePosition: sideCenter, + reflectionOffset: Offset.zero, + isAttack: ref.read(mapProvider).isAttack, + ); ref .read(placementCenterProvider.notifier) .updateCenter(_clampToWorld(normalizedCenter, coordinateSystem)); @@ -149,8 +155,10 @@ class _InteractiveMapState extends ConsumerState { (constraints.maxWidth - Settings.sideBarReservedWidth) .clamp(0.0, constraints.maxWidth); final viewportSize = Size(viewportWidth, height); - if (_lastViewportSize != viewportSize || - _lastPlayAreaSize != playAreaSize) { + final dimensionsChanged = _lastViewportSize != viewportSize || + _lastPlayAreaSize != playAreaSize; + final sideChanged = _lastIsAttack != isAttack; + if (dimensionsChanged) { final double currentScale = controller.value.getMaxScaleOnAxis(); final double safeScale = currentScale == 0 ? 1.0 : currentScale; final double centeredOffsetX = @@ -161,11 +169,6 @@ class _InteractiveMapState extends ConsumerState { matrix.translateByDouble( centeredOffsetX / safeScale, centeredOffsetY / safeScale, 0, 1); controller.value = matrix; - _schedulePlacementCenterUpdate( - viewportWidth: viewportWidth, - viewportHeight: height, - coordinateSystem: coordinateSystem, - ); _lastViewportSize = viewportSize; _lastPlayAreaSize = playAreaSize; WidgetsBinding.instance.addPostFrameCallback((_) { @@ -173,6 +176,14 @@ class _InteractiveMapState extends ConsumerState { ref.read(canvasResizeProvider.notifier).increment(); }); } + if (dimensionsChanged || sideChanged) { + _schedulePlacementCenterUpdate( + viewportWidth: viewportWidth, + viewportHeight: height, + coordinateSystem: coordinateSystem, + ); + } + _lastIsAttack = isAttack; final double mapWidth = height * coordinateSystem.mapAspectRatio; final double mapLeft = (worldWidth - mapWidth) / 2; diff --git a/lib/migrations/canonical_coordinates_migration.dart b/lib/migrations/canonical_coordinates_migration.dart new file mode 100644 index 00000000..b3bd57cd --- /dev/null +++ b/lib/migrations/canonical_coordinates_migration.dart @@ -0,0 +1,164 @@ +import 'dart:math' as math; +import 'dart:ui'; + +import 'package:icarus/const/abilities.dart'; +import 'package:icarus/const/ability_vision.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/placed_media_geometry.dart'; +import 'package:icarus/const/settings.dart'; +import 'package:icarus/const/utilities.dart'; +import 'package:icarus/providers/strategy_page.dart'; + +abstract final class CanonicalCoordinatesMigration { + static const int version = 97; + static const double _virtualToWorld = 1000 / 831; + + static List migratePages({ + required List pages, + required MapValue map, + }) { + final mapScale = Maps.mapScale[map] ?? 1; + return [ + for (final page in pages) + page.isAttack ? page : _migrateDefensePage(page, mapScale), + ]; + } + + static StrategyPage _migrateDefensePage( + StrategyPage page, + double mapScale, + ) { + return page.copyWith( + agentData: [ + for (final agent in page.agentData) _migrateAgent(agent), + ], + abilityData: [ + for (final ability in page.abilityData) + _migrateAbility(ability, mapScale), + ], + utilityData: [ + for (final utility in page.utilityData) + _migrateUtility(utility, mapScale), + ], + textData: [ + for (final text in page.textData) _migrateText(text), + ], + imageData: [ + for (final image in page.imageData) _migrateImage(image), + ], + lineUpGroups: [ + for (final group in page.lineUpGroups) + LineUpGroup( + id: group.id, + agent: _migrateAgent(group.agent) as PlacedAgent, + items: [ + for (final item in group.items) + item.copyWith( + ability: _migrateAbility(item.ability, mapScale), + images: item.images.map((image) => image.copyWith()).toList(), + ), + ], + ), + ], + ); + } + + static PlacedAgentNode _migrateAgent(PlacedAgentNode agent) { + final position = _reflect( + agent.position, + storedAnchorVirtual: const Offset( + Settings.agentSize / 2, + Settings.agentSize / 2, + ), + ); + + return switch (agent) { + PlacedAgent() => agent.copyWith(position: position), + PlacedViewConeAgent() => agent.copyWith( + position: position, + rotation: agent.rotation - math.pi, + ), + PlacedCircleAgent() => agent.copyWith(position: position), + }; + } + + static PlacedAbility _migrateAbility( + PlacedAbility ability, + double mapScale, + ) { + final data = ability.data.abilityData!; + final shouldRotate = isRotatable(data) || + AbilityVisionConeSpec.forAbility(ability.data) != null; + final migrated = ability.copyWith( + position: _reflect( + ability.position, + storedAnchorVirtual: data.getAnchorPoint( + mapScale: mapScale, + abilitySize: Settings.abilitySize, + ), + ), + rotation: shouldRotate ? ability.rotation - math.pi : ability.rotation, + ); + migrated.isDeleted = ability.isDeleted; + return migrated; + } + + static PlacedUtility _migrateUtility( + PlacedUtility utility, + double mapScale, + ) { + final anchor = UtilityData.utilityWidgets[utility.type]!.getAnchorPoint( + id: utility.id, + length: utility.length, + rotation: utility.rotation, + mapScale: mapScale, + agentSize: Settings.agentSize, + abilitySize: Settings.abilitySize, + diameterMeters: utility.customDiameter, + widthMeters: utility.customWidth, + rectLengthMeters: utility.customLength, + ); + return utility.copyWith( + position: _reflect( + utility.position, + storedAnchorVirtual: anchor, + ), + rotation: UtilityData.isViewCone(utility.type) + ? utility.rotation - math.pi + : utility.rotation, + ); + } + + static PlacedText _migrateText(PlacedText text) { + final footprint = PlacedMediaGeometry.legacyTextFootprintInWorld(text); + return text.copyWith( + position: _reflectWorld(text.position, footprint.center(Offset.zero)), + ); + } + + static PlacedImage _migrateImage(PlacedImage image) { + final footprint = PlacedMediaGeometry.legacyImageFootprintInWorld(image); + final migrated = image.copyWith( + position: _reflectWorld(image.position, footprint.center(Offset.zero)), + ); + migrated.isDeleted = image.isDeleted; + return migrated; + } + + static Offset _reflect( + Offset position, { + required Offset storedAnchorVirtual, + }) { + return _reflectWorld(position, storedAnchorVirtual * _virtualToWorld); + } + + static Offset _reflectWorld(Offset position, Offset storedAnchorWorld) { + const worldSize = Size(1000 * (16 / 9), 1000); + return Offset( + worldSize.width - position.dx - (storedAnchorWorld.dx * 2), + worldSize.height - position.dy - (storedAnchorWorld.dy * 2), + ); + } +} diff --git a/lib/page_transition/agent_path.dart b/lib/page_transition/agent_path.dart index d39bc41e..e72f3105 100644 --- a/lib/page_transition/agent_path.dart +++ b/lib/page_transition/agent_path.dart @@ -54,7 +54,6 @@ class AgentTransitionPathPlanner { static Map plan({ required List entries, required VisionGeometryMap? geometry, - required bool isAttack, required double startAgentSize, required double endAgentSize, required CoordinateSystem coordinateSystem, @@ -71,11 +70,13 @@ class AgentTransitionPathPlanner { position + coordinateSystem.virtualOffsetToWorld(Offset(size / 2, size / 2)); + // Transition positions are canonical, so pathfinding always uses the + // attack geometry. The completed frame is projected to its display side. double? elevationFor(PlacedWidget widget, Offset center) { final override = widget is PlacedViewConeAgent ? widget.visionElevation : null; return override ?? - geometry.inferredHeightAt(isAttack: isAttack, position: center); + geometry.inferredHeightAt(isAttack: true, position: center); } return { @@ -96,7 +97,7 @@ class AgentTransitionPathPlanner { start: startCenter, end: endCenter, layer: geometry.layerFor( - isAttack: isAttack, + isAttack: true, elevation: routeElevation, ), ); @@ -256,12 +257,7 @@ class AgentTransitionPathfinder { } double _segmentDistanceSquared(VisionSegment first, VisionSegment second) { - if (_segmentsIntersect( - first.start, - first.end, - second.start, - second.end, - )) { + if (_segmentsIntersect(first.start, first.end, second.start, second.end)) { return 0; } return [ diff --git a/lib/providers/ability_provider.dart b/lib/providers/ability_provider.dart index 19c1bd71..8d8f07a3 100644 --- a/lib/providers/ability_provider.dart +++ b/lib/providers/ability_provider.dart @@ -6,7 +6,6 @@ import 'package:flutter_riverpod/flutter_riverpod.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/transition_data.dart'; import 'package:icarus/providers/action_provider.dart'; import 'package:icarus/providers/map_provider.dart'; @@ -130,29 +129,6 @@ class AbilityProvider extends Notifier> { return duplicatedAbility.id; } - void switchSides() { - if (state.isEmpty && poppedAbility.isEmpty) return; - - final newState = [...state]; - final mapState = ref.read(mapProvider); - final mapScale = Maps.mapScale[mapState.currentMap] ?? 1.0; - for (final ability in state) { - ability.switchSides( - mapScale: mapScale, - abilitySize: Settings.abilitySize, - ); - } - - for (final ability in poppedAbility) { - ability.switchSides( - mapScale: mapScale, - abilitySize: Settings.abilitySize, - ); - } - - state = newState; - } - void updateRotation(int index, double rotation, double length) { updateGeometry(index, rotation: rotation, length: length); } diff --git a/lib/providers/agent_provider.dart b/lib/providers/agent_provider.dart index 41246dd5..1682627f 100644 --- a/lib/providers/agent_provider.dart +++ b/lib/providers/agent_provider.dart @@ -6,7 +6,6 @@ import 'package:icarus/const/agents.dart'; import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/const/transition_data.dart'; import 'package:icarus/providers/action_provider.dart'; -import 'package:icarus/providers/strategy_settings_provider.dart'; import 'package:icarus/const/utilities.dart'; import 'package:uuid/uuid.dart'; @@ -390,24 +389,6 @@ class AgentProvider extends Notifier> { return output; } - void switchSides() { - if (state.isEmpty) return; - - final newState = [...state]; - for (final agent in newState) { - // Flip over both axes, accounting for top-left positioning: - // x' = normalizedWidth - x - wNorm - // y' = normalizedHeight - y - hNorm - agent.switchSides(ref.read(strategySettingsProvider).agentSize); - } - - for (final agent in poppedAgents) { - agent.switchSides(ref.read(strategySettingsProvider).agentSize); - } - - state = newState; - } - void clearAll() { poppedAgents = []; state = []; diff --git a/lib/providers/image_provider.dart b/lib/providers/image_provider.dart index ce1b00ae..5f1f40f9 100644 --- a/lib/providers/image_provider.dart +++ b/lib/providers/image_provider.dart @@ -4,7 +4,6 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/const/image_scale_policy.dart'; -import 'package:icarus/providers/image_widget_size_provider.dart'; import 'package:icarus/services/app_error_reporter.dart'; import 'package:image/image.dart' as img; import 'dart:ui' as ui; @@ -215,19 +214,6 @@ class PlacedImageProvider extends Notifier { state = state.copyWith(images: newImages); } - void switchSides() { - final newImages = [...state.images]; - for (final image in newImages) { - image.switchSides( - ref.read(imageWidgetSizeProvider.notifier).getSize(image.id)); - } - for (final image in poppedImages) { - image.switchSides( - ref.read(imageWidgetSizeProvider.notifier).getSize(image.id)); - } - state = state.copyWith(images: newImages); - } - void undoAction(UserAction action) { switch (action.type) { case ActionType.addition: @@ -351,9 +337,7 @@ class PlacedImageProvider extends Notifier { json as Map, strategyID)), ); - return images - .map(_migrateLoadedImage) - .toList(); + return images.map(_migrateLoadedImage).toList(); } void updateScale(int index, double scale) { diff --git a/lib/providers/map_provider.dart b/lib/providers/map_provider.dart index 2984bfba..8b31bea1 100644 --- a/lib/providers/map_provider.dart +++ b/lib/providers/map_provider.dart @@ -1,13 +1,7 @@ import 'dart:convert'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:icarus/const/line_provider.dart'; import 'package:icarus/const/maps.dart'; -import 'package:icarus/providers/ability_provider.dart'; -import 'package:icarus/providers/agent_provider.dart'; -import 'package:icarus/providers/image_provider.dart'; -import 'package:icarus/providers/text_provider.dart'; -import 'package:icarus/providers/utility_provider.dart'; import 'package:icarus/providers/user_preferences_provider.dart'; final mapProvider = NotifierProvider(MapProvider.new); @@ -87,13 +81,6 @@ class MapProvider extends Notifier { } void switchSide() { - // Flip all placed agents to mirror positions before toggling the side - ref.read(agentProvider.notifier).switchSides(); - ref.read(abilityProvider.notifier).switchSides(); - ref.read(utilityProvider.notifier).switchSides(); - ref.read(lineUpProvider.notifier).switchSides(); - ref.read(textProvider.notifier).switchSides(); - ref.read(placedImageProvider.notifier).switchSides(); state = state.copyWith(isAttack: !state.isAttack); } @@ -108,8 +95,9 @@ class MapProvider extends Notifier { static MapValue fromJson(String json) { final mapName = jsonDecode(json); - final mapValue = - Maps.mapNames.entries.firstWhere((entry) => entry.value == mapName).key; + final mapValue = Maps.mapNames.entries + .firstWhere((entry) => entry.value == mapName) + .key; return mapValue; } diff --git a/lib/providers/strategy_provider.dart b/lib/providers/strategy_provider.dart index 830b346a..e4883aa3 100644 --- a/lib/providers/strategy_provider.dart +++ b/lib/providers/strategy_provider.dart @@ -4,8 +4,7 @@ import 'dart:developer'; import 'dart:io'; import 'package:archive/archive_io.dart'; import 'package:cross_file/cross_file.dart'; -import 'package:flutter/foundation.dart' - show kIsWeb, visibleForTesting; +import 'package:flutter/foundation.dart' show kIsWeb, visibleForTesting; import 'package:icarus/const/line_provider.dart'; import 'package:icarus/const/transition_data.dart'; import 'package:icarus/providers/transition_provider.dart'; @@ -21,6 +20,7 @@ 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/canonical_coordinates_migration.dart'; import 'package:icarus/migrations/custom_circle_wrapper_migration.dart'; import 'package:icarus/migrations/lineup_group_migration.dart'; import 'package:icarus/migrations/page_name_provenance_migration.dart'; @@ -599,6 +599,8 @@ class StrategyProvider extends Notifier { static StrategyData migrateToCurrentVersion(StrategyData strat, {bool forceAbilityScale = false}) { + final needsCanonicalCoordinatesMigration = + strat.versionNumber < CanonicalCoordinatesMigration.version; final needsAbilityVisionMigration = strat.versionNumber < AbilityVisionConeMigration.version; final needsPageNameProvenanceMigration = @@ -613,10 +615,31 @@ class StrategyProvider extends Notifier { lineUpGroupMigrated, force: needsAbilityVisionMigration, ); - return migratePageNameProvenance( + final pageNameMigrated = migratePageNameProvenance( abilityVisionMigrated, force: needsPageNameProvenanceMigration, ); + return migrateCanonicalCoordinates( + pageNameMigrated, + force: needsCanonicalCoordinatesMigration, + ); + } + + static StrategyData migrateCanonicalCoordinates(StrategyData strat, + {bool force = false}) { + if (!force && + strat.versionNumber >= CanonicalCoordinatesMigration.version) { + return strat; + } + + return strat.copyWith( + pages: CanonicalCoordinatesMigration.migratePages( + pages: strat.pages, + map: strat.mapData, + ), + versionNumber: Settings.versionNumber, + lastEdited: DateTime.now(), + ); } static StrategyData migrateAbilityVisionCones(StrategyData strat, @@ -637,8 +660,7 @@ class StrategyProvider extends Notifier { static StrategyData migratePageNameProvenance(StrategyData strat, {bool force = false}) { - if (!force && - strat.versionNumber >= PageNameProvenanceMigration.version) { + if (!force && strat.versionNumber >= PageNameProvenanceMigration.version) { return strat; } @@ -746,10 +768,18 @@ class StrategyProvider extends Notifier { customCircleMigrated, force: originalVersion < LineUpGroupMigration.version, ); - return migrateAbilityVisionCones( + final abilityVisionMigrated = migrateAbilityVisionCones( lineUpGroupMigrated, force: originalVersion < AbilityVisionConeMigration.version, ); + final pageNameMigrated = migratePageNameProvenance( + abilityVisionMigrated, + force: originalVersion < PageNameProvenanceMigration.version, + ); + return migrateCanonicalCoordinates( + pageNameMigrated, + force: originalVersion < CanonicalCoordinatesMigration.version, + ); } static StrategyData migrateToWorld16x9(StrategyData strat, @@ -1194,7 +1224,6 @@ class StrategyProvider extends Notifier { final agentPaths = AgentTransitionPathPlanner.plan( entries: entries, geometry: transitionGeometry, - isAttack: ref.read(mapProvider).isAttack, startAgentSize: startSettings.agentSize, endAgentSize: endSettings.agentSize, coordinateSystem: CoordinateSystem.instance, @@ -1234,8 +1263,8 @@ class StrategyProvider extends Notifier { List copyDirectionsForPlacedWidget( String widgetId, ) { - if (widgetId.isEmpty || - !Hive.isBoxOpen(HiveBoxNames.strategiesBox)) return const []; + if (widgetId.isEmpty || !Hive.isBoxOpen(HiveBoxNames.strategiesBox)) + return const []; final strat = Hive.box(HiveBoxNames.strategiesBox).get( state.id, @@ -1266,8 +1295,8 @@ class StrategyProvider extends Notifier { required String widgetId, required PageTransitionDirection direction, }) async { - if (widgetId.isEmpty || - !Hive.isBoxOpen(HiveBoxNames.strategiesBox)) return false; + if (widgetId.isEmpty || !Hive.isBoxOpen(HiveBoxNames.strategiesBox)) + return false; await _syncCurrentPageToHive(); diff --git a/lib/providers/text_provider.dart b/lib/providers/text_provider.dart index cdc5cebf..b220dabe 100644 --- a/lib/providers/text_provider.dart +++ b/lib/providers/text_provider.dart @@ -5,7 +5,6 @@ import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/const/placed_classes.dart'; import 'package:icarus/providers/action_provider.dart'; import 'package:icarus/providers/text_draft_provider.dart'; -import 'package:icarus/providers/text_widget_height_provider.dart'; final textProvider = NotifierProvider>(TextProvider.new); @@ -87,21 +86,6 @@ class TextProvider extends Notifier> { state = [...newState, temp]; } - void switchSides() { - final newState = [...state]; - for (final text in newState) { - text.switchSides( - ref.read(textWidgetHeightProvider.notifier).getOffset(text.id)); - } - - for (final text in poppedText) { - text.switchSides( - ref.read(textWidgetHeightProvider.notifier).getOffset(text.id)); - } - - state = newState; - } - void commitText(String id, String nextText) { final newState = [...state]; final index = PlacedWidget.getIndexByID(id, newState); diff --git a/lib/providers/utility_provider.dart b/lib/providers/utility_provider.dart index b0be1ee6..562b17da 100644 --- a/lib/providers/utility_provider.dart +++ b/lib/providers/utility_provider.dart @@ -2,12 +2,9 @@ import 'dart:convert'; import 'dart:ui'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:icarus/const/maps.dart'; import 'package:icarus/const/placed_classes.dart'; import 'package:icarus/const/utilities.dart'; import 'package:icarus/providers/action_provider.dart'; -import 'package:icarus/providers/map_provider.dart'; -import 'package:icarus/providers/strategy_settings_provider.dart'; final utilityProvider = NotifierProvider>(UtilityProvider.new); @@ -165,9 +162,7 @@ class UtilityProvider extends Notifier> { } utility.updateCustomShapeColor(colorValue); - ref - .read(actionProvider.notifier) - .addAction( + ref.read(actionProvider.notifier).addAction( UserAction(type: ActionType.edit, id: id, group: ActionGroup.utility), ); state = newState; @@ -181,30 +176,6 @@ class UtilityProvider extends Notifier> { state = newState; } - void switchSides() { - final newState = [...state]; - final mapState = ref.read(mapProvider); - final mapScale = Maps.mapScale[mapState.currentMap] ?? 1.0; - final agentSize = ref.read(strategySettingsProvider).agentSize; - final abilitySize = ref.read(strategySettingsProvider).abilitySize; - for (final utility in newState) { - utility.switchSides( - mapScale: mapScale, - agentSize: agentSize, - abilitySize: abilitySize, - ); - } - for (final utility in poppedUtilities) { - utility.switchSides( - mapScale: mapScale, - agentSize: agentSize, - abilitySize: abilitySize, - ); - } - - state = newState; - } - void undoAction(UserAction action) { switch (action.type) { case ActionType.addition: diff --git a/lib/services/video_export/video_exporter.dart b/lib/services/video_export/video_exporter.dart index b0470108..748c8313 100644 --- a/lib/services/video_export/video_exporter.dart +++ b/lib/services/video_export/video_exporter.dart @@ -204,7 +204,6 @@ class VideoExporter { final agentPaths = AgentTransitionPathPlanner.plan( entries: entries, geometry: geometry, - isAttack: nextPage.isAttack, startAgentSize: page.settings.agentSize, endAgentSize: nextPage.settings.agentSize, coordinateSystem: CoordinateSystem.instance, diff --git a/lib/widgets/current_line_up_painter.dart b/lib/widgets/current_line_up_painter.dart index 2f0f010b..48867e3f 100644 --- a/lib/widgets/current_line_up_painter.dart +++ b/lib/widgets/current_line_up_painter.dart @@ -19,13 +19,16 @@ class CurrentLineUpPainter extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final coordinateSystem = CoordinateSystem.instance; final double abilitySize = ref.watch(strategySettingsProvider).abilitySize; - final double agentSize = - coordinateSystem.scale(ref.watch(strategySettingsProvider).agentSize); - final currentMap = - ref.watch(mapProvider.select((state) => state.currentMap)); + final double agentSize = coordinateSystem.scale( + ref.watch(strategySettingsProvider).agentSize, + ); + final currentMap = ref.watch( + mapProvider.select((state) => state.currentMap), + ); final double mapScale = Maps.mapScale[currentMap] ?? 1.0; final lineUpState = ref.watch(lineUpProvider); - final PlacedAgent? currentAgent = lineUpState.currentAgent ?? + final PlacedAgent? currentAgent = + lineUpState.currentAgent ?? ref .read(lineUpProvider.notifier) .getGroupById(lineUpState.currentGroupId ?? '') @@ -40,6 +43,7 @@ class CurrentLineUpPainter extends ConsumerWidget { abilitySize: abilitySize, agentSize: agentSize, mapScale: mapScale, + isAttack: ref.watch(mapProvider).isAttack, currentAgent: currentAgent, currentAbility: currentAbility, resizeCounter: ref.watch(lineUpCanvasResizeProvider), @@ -54,6 +58,7 @@ class _CurrentLinePainter extends CustomPainter { final double abilitySize; final double agentSize; final double mapScale; + final bool isAttack; final PlacedAgent? currentAgent; final PlacedAbility? currentAbility; final int resizeCounter; @@ -63,6 +68,7 @@ class _CurrentLinePainter extends CustomPainter { required this.abilitySize, required this.agentSize, required this.mapScale, + required this.isAttack, this.currentAgent, this.currentAbility, required this.resizeCounter, @@ -81,12 +87,14 @@ class _CurrentLinePainter extends CustomPainter { final startPosition = screenAnchorForAgent( agent: currentAgent!, coordinateSystem: coordinateSystem, + isAttack: isAttack, ); final endPosition = screenAnchorForAbility( ability: currentAbility!, coordinateSystem: coordinateSystem, mapScale: mapScale, + isAttack: isAttack, ); canvas.drawLine(startPosition, endPosition, highlightPaint); @@ -100,6 +108,7 @@ class _CurrentLinePainter extends CustomPainter { oldDelegate.abilitySize != abilitySize || oldDelegate.agentSize != agentSize || oldDelegate.mapScale != mapScale || + oldDelegate.isAttack != isAttack || oldDelegate.resizeCounter != resizeCounter; } return true; diff --git a/lib/widgets/draggable_widgets/ability/placed_ability_widget.dart b/lib/widgets/draggable_widgets/ability/placed_ability_widget.dart index 8de8c394..3f9ab35c 100644 --- a/lib/widgets/draggable_widgets/ability/placed_ability_widget.dart +++ b/lib/widgets/draggable_widgets/ability/placed_ability_widget.dart @@ -107,10 +107,7 @@ class _PlacedAbilityWidgetState extends ConsumerState { final rotatedY = dx * math.sin(angle) + dy * math.cos(angle); // Translate back - return Offset( - rotatedX + origin.dx, - rotatedY + origin.dy, - ); + return Offset(rotatedX + origin.dx, rotatedY + origin.dy); } @override @@ -124,8 +121,10 @@ class _PlacedAbilityWidgetState extends ConsumerState { return const SizedBox.shrink(); } - final index = - PlacedWidget.getIndexByID(widget.id, ref.watch(abilityProvider)); + final index = PlacedWidget.getIndexByID( + widget.id, + ref.watch(abilityProvider), + ); final bool isAlly = widget.ability.isAlly; final abilityRef = widget.isLineUp @@ -134,6 +133,7 @@ class _PlacedAbilityWidgetState extends ConsumerState { ? ref.watch(abilityProvider)[index] : widget.ability; final mapScale = Maps.mapScale[ref.watch(mapProvider).currentMap] ?? 1; + final isAttack = ref.watch(mapProvider).isAttack; final contextMenuItems = widget.isLineUp ? null : buildAbilityContextMenuItems(ref, abilityRef); @@ -147,6 +147,7 @@ class _PlacedAbilityWidgetState extends ConsumerState { contextMenuItems: contextMenuItems, ); } + //Linking the local rotation with global rotation for things like undo redo if (!widget.isLineUp) { if (abilityRef.rotation != localRotation! && @@ -194,6 +195,11 @@ class _PlacedAbilityWidgetState extends ConsumerState { } } + final displayRotation = coordinateSystem.rotationForSide( + localRotation!, + isAttack: isAttack, + ); + final visionSpec = AbilityVisionConeSpec.forAbility(abilityRef.data); if (!widget.isLineUp && visionSpec != null && @@ -206,6 +212,7 @@ class _PlacedAbilityWidgetState extends ConsumerState { coordinateSystem: coordinateSystem, mapScale: mapScale, abilitySize: abilitySize, + isAttack: isAttack, index: index, isAlly: isAlly, ); @@ -217,6 +224,7 @@ class _PlacedAbilityWidgetState extends ConsumerState { coordinateSystem: coordinateSystem, mapScale: mapScale, abilitySize: abilitySize, + isAttack: isAttack, ); final isCenterSquare = abilityData is CenterSquareAbility; final isResizableSquare = abilityData is ResizableSquareAbility; @@ -246,14 +254,16 @@ class _PlacedAbilityWidgetState extends ConsumerState { top: screenPosition.dy, child: RotatableWidget( buttonTop: buttonTop, - rotation: localRotation!, + rotation: displayRotation, isDragging: isDragging, showHandle: _shouldShowRotatableHandle( abilityData, abilityRef.visualState, ), origin: abilityData.getAnchorPoint( - mapScale: mapScale, abilitySize: abilitySize), + mapScale: mapScale, + abilitySize: abilitySize, + ), onPanStart: (details) { // ref.read(abilityProvider.notifier).updateRotationHistory(index); // ref.read(abilityProvider.notifier).updateLengthHistory(index); @@ -262,14 +272,18 @@ class _PlacedAbilityWidgetState extends ConsumerState { final bottomCenter = abilityData .getAnchorPoint(mapScale: mapScale, abilitySize: abilitySize) .scale( - coordinateSystem.scaleFactor, coordinateSystem.scaleFactor); + coordinateSystem.scaleFactor, + coordinateSystem.scaleFactor, + ); rotationOrigin = box.localToGlobal(bottomCenter); if (isResizableSquare) { final resizeAbility = abilityData; lengthOrigin = box.localToGlobal( resizeAbility.getLengthAnchor(mapScale, abilitySize).scale( - coordinateSystem.scaleFactor, coordinateSystem.scaleFactor), + coordinateSystem.scaleFactor, + coordinateSystem.scaleFactor, + ), ); } }, @@ -283,17 +297,23 @@ class _PlacedAbilityWidgetState extends ConsumerState { (currentPosition - rotationOrigin); double currentAngle = math.atan2( - currentPositionNormalized.dy, currentPositionNormalized.dx); + currentPositionNormalized.dy, + currentPositionNormalized.dx, + ); // // Update rotation - final newRotation = (currentAngle) + (math.pi / 2); + final sideRotation = currentAngle + (math.pi / 2); + final newRotation = coordinateSystem.rotationFromSide( + sideRotation, + isAttack: isAttack, + ); setState(() { localRotation = newRotation; }); if (isResizableSquare) { final Offset currentPosLength = (currentPosition - - rotateOffset(lengthOrigin, rotationOrigin, localRotation!)); + rotateOffset(lengthOrigin, rotationOrigin, sideRotation)); double currentLength = coordinateSystem.normalize(currentPosLength.distance) / mapScale / @@ -327,12 +347,15 @@ class _PlacedAbilityWidgetState extends ConsumerState { context.findRenderObject()! as RenderBox; final anchorPoint = abilityData .getAnchorPoint(mapScale: mapScale, abilitySize: abilitySize) - .scale(coordinateSystem.scaleFactor, - coordinateSystem.scaleFactor); + .scale( + coordinateSystem.scaleFactor, + coordinateSystem.scaleFactor, + ); Offset rotatedPos = rotateOffset( - renderObject.globalToLocal(position), - anchorPoint, - localRotation!); + renderObject.globalToLocal(position), + anchorPoint, + displayRotation, + ); return ref .read(screenZoomProvider.notifier) @@ -341,22 +364,25 @@ class _PlacedAbilityWidgetState extends ConsumerState { feedback: Opacity( opacity: Settings.feedbackOpacity, child: Transform.rotate( - angle: localRotation!, + angle: displayRotation, alignment: Alignment.topLeft, origin: abilityData .getAnchorPoint( - mapScale: mapScale, abilitySize: abilitySize) + mapScale: mapScale, + abilitySize: abilitySize, + ) .scale( - coordinateSystem.scaleFactor * - ref.watch(screenZoomProvider), - coordinateSystem.scaleFactor * - ref.watch(screenZoomProvider)), + coordinateSystem.scaleFactor * + ref.watch(screenZoomProvider), + coordinateSystem.scaleFactor * + ref.watch(screenZoomProvider), + ), child: ZoomTransform( child: abilityData.createWidget( id: widget.id, isAlly: isAlly, mapScale: mapScale, - rotation: localRotation!, + rotation: displayRotation, length: localLength!, armLengthsMeters: abilityRef.armLengthsMeters, visualState: abilityRef.visualState, @@ -393,7 +419,7 @@ class _PlacedAbilityWidgetState extends ConsumerState { id: widget.id, isAlly: isAlly, mapScale: mapScale, - rotation: localRotation!, + rotation: displayRotation, length: localLength!, armLengthsMeters: abilityRef.armLengthsMeters, visualState: abilityRef.visualState, @@ -409,6 +435,7 @@ class _PlacedAbilityWidgetState extends ConsumerState { coordinateSystem: coordinateSystem, mapScale: mapScale, abilitySize: abilitySize, + isAttack: isAttack, ); return Positioned( left: screenPosition.dx, @@ -420,14 +447,15 @@ class _PlacedAbilityWidgetState extends ConsumerState { feedback: Opacity( opacity: Settings.feedbackOpacity, child: ZoomTransform( - child: widget.ability.data.abilityData!.createWidget( - id: null, - isAlly: isAlly, - mapScale: mapScale, - armLengthsMeters: widget.ability.armLengthsMeters, - visualState: widget.ability.visualState, - watchMouse: false, - )), + child: widget.ability.data.abilityData!.createWidget( + id: null, + isAlly: isAlly, + mapScale: mapScale, + armLengthsMeters: widget.ability.armLengthsMeters, + visualState: widget.ability.visualState, + watchMouse: false, + ), + ), ), childWhenDragging: const SizedBox.shrink(), onDragStarted: () { @@ -471,6 +499,7 @@ class _PlacedAbilityWidgetState extends ConsumerState { required CoordinateSystem coordinateSystem, required double mapScale, required double abilitySize, + required bool isAttack, required int index, required bool isAlly, }) { @@ -479,6 +508,11 @@ class _PlacedAbilityWidgetState extends ConsumerState { coordinateSystem: coordinateSystem, mapScale: mapScale, abilitySize: abilitySize, + isAttack: isAttack, + ); + final displayRotation = coordinateSystem.rotationForSide( + localRotation!, + isAttack: isAttack, ); const coneAnchor = ViewConeWidget.anchorPointVirtual; final scaledConeAnchor = coneAnchor.scale( @@ -512,7 +546,7 @@ class _PlacedAbilityWidgetState extends ConsumerState { left: screenPosition.dx - childOffset.dx, top: screenPosition.dy - childOffset.dy, child: RotatableWidget( - rotation: localRotation!, + rotation: displayRotation, isDragging: isDragging, origin: coneAnchor, buttonTop: coneAnchor.dy - resolvedLength - 7.5, @@ -525,7 +559,11 @@ class _PlacedAbilityWidgetState extends ConsumerState { if (rotationOrigin == Offset.zero) return; final delta = details.globalPosition - rotationOrigin; - final nextRotation = math.atan2(delta.dy, delta.dx) + (math.pi / 2); + final sideRotation = math.atan2(delta.dy, delta.dx) + (math.pi / 2); + final nextRotation = coordinateSystem.rotationFromSide( + sideRotation, + isAttack: isAttack, + ); final nextLength = visionSpec.storedLengthFromRendered( renderedLength: coordinateSystem.normalize(delta.distance) / ref.watch(screenZoomProvider), @@ -538,11 +576,9 @@ class _PlacedAbilityWidgetState extends ConsumerState { }); }, onPanEnd: (_) { - ref.read(abilityProvider.notifier).updateRotation( - index, - localRotation!, - localLength ?? 0, - ); + ref + .read(abilityProvider.notifier) + .updateRotation(index, localRotation!, localLength ?? 0); setState(() { rotationOrigin = Offset.zero; }); @@ -554,7 +590,7 @@ class _PlacedAbilityWidgetState extends ConsumerState { final rotatedPosition = rotateOffset( renderObject.globalToLocal(position), scaledConeAnchor, - localRotation!, + displayRotation, ); return ref @@ -567,7 +603,7 @@ class _PlacedAbilityWidgetState extends ConsumerState { child: AbilityVisionConeComposite( ability: abilityRef, spec: visionSpec, - rotation: localRotation!, + rotation: displayRotation, length: localLength ?? 0, mapScale: mapScale, abilitySize: abilitySize, @@ -601,7 +637,7 @@ class _PlacedAbilityWidgetState extends ConsumerState { child: AbilityVisionConeComposite( ability: abilityRef, spec: visionSpec, - rotation: localRotation!, + rotation: displayRotation, length: localLength ?? 0, mapScale: mapScale, abilitySize: abilitySize, diff --git a/lib/widgets/draggable_widgets/ability/placed_deadlock_barrier_mesh_widget.dart b/lib/widgets/draggable_widgets/ability/placed_deadlock_barrier_mesh_widget.dart index 6dae65f9..064b3c53 100644 --- a/lib/widgets/draggable_widgets/ability/placed_deadlock_barrier_mesh_widget.dart +++ b/lib/widgets/draggable_widgets/ability/placed_deadlock_barrier_mesh_widget.dart @@ -65,21 +65,16 @@ class _PlacedDeadlockBarrierMeshWidgetState @override void initState() { super.initState(); - _localArmLengthsMeters = - normalizeDeadlockBarrierMeshArmLengths(widget.ability.armLengthsMeters); + _localArmLengthsMeters = normalizeDeadlockBarrierMeshArmLengths( + widget.ability.armLengthsMeters, + ); _localRotation = widget.ability.rotation; _animationController = AnimationController( duration: const Duration(milliseconds: 160), vsync: this, ); - _scaleAnimation = Tween( - begin: 0.9, - end: 1.0, - ).animate( - CurvedAnimation( - parent: _animationController, - curve: Curves.easeOutCubic, - ), + _scaleAnimation = Tween(begin: 0.9, end: 1.0).animate( + CurvedAnimation(parent: _animationController, curve: Curves.easeOutCubic), ); } @@ -92,8 +87,9 @@ class _PlacedDeadlockBarrierMeshWidgetState @override Widget build(BuildContext context) { final coordinateSystem = CoordinateSystem.instance; - final currentMap = - ref.watch(mapProvider.select((state) => state.currentMap)); + final currentMap = ref.watch( + mapProvider.select((state) => state.currentMap), + ); final mapScale = Maps.mapScale[currentMap] ?? 1.0; final abilitySize = ref.watch(strategySettingsProvider).abilitySize; final isScreenshot = ref.watch(screenshotProvider); @@ -105,8 +101,9 @@ class _PlacedDeadlockBarrierMeshWidgetState final barrierAbility = abilityRef.data.abilityData! as DeadlockBarrierMeshAbility; - final providerArmLengths = - normalizeDeadlockBarrierMeshArmLengths(abilityRef.armLengthsMeters); + final providerArmLengths = normalizeDeadlockBarrierMeshArmLengths( + abilityRef.armLengthsMeters, + ); final providerRotation = abilityRef.rotation; if (!_isDragging && @@ -122,9 +119,15 @@ class _PlacedDeadlockBarrierMeshWidgetState _localRotation = providerRotation; } - final armLengths = - normalizeDeadlockBarrierMeshArmLengths(_localArmLengthsMeters); + final armLengths = normalizeDeadlockBarrierMeshArmLengths( + _localArmLengthsMeters, + ); final localRotation = _localRotation ?? providerRotation; + final isAttack = ref.watch(mapProvider).isAttack; + final displayRotation = coordinateSystem.rotationForSide( + localRotation, + isAttack: isAttack, + ); final maxExtent = coordinateSystem.scale( deadlockBarrierMeshMaxExtent( mapScale: mapScale, @@ -139,6 +142,7 @@ class _PlacedDeadlockBarrierMeshWidgetState coordinateSystem: coordinateSystem, mapScale: mapScale, abilitySize: abilitySize, + isAttack: isAttack, ); final feedbackRotationOrigin = center.scale( ref.watch(screenZoomProvider), @@ -147,7 +151,7 @@ class _PlacedDeadlockBarrierMeshWidgetState final showMesh = abilityRef.visualState.showRangeFill; final content = Transform.rotate( - angle: localRotation, + angle: displayRotation, alignment: Alignment.topLeft, origin: center, child: Stack( @@ -181,7 +185,7 @@ class _PlacedDeadlockBarrierMeshWidgetState final rotatedAnchor = _rotateOffset( anchorWithinSquare, center, - localRotation, + displayRotation, ); return ref .read(screenZoomProvider.notifier) @@ -190,7 +194,7 @@ class _PlacedDeadlockBarrierMeshWidgetState feedback: Opacity( opacity: Settings.feedbackOpacity, child: Transform.rotate( - angle: localRotation, + angle: displayRotation, alignment: Alignment.topLeft, origin: feedbackRotationOrigin, child: ZoomTransform( @@ -213,10 +217,12 @@ class _PlacedDeadlockBarrierMeshWidgetState final shouldDuplicate = !widget.isLineUp && ref.read(duplicateDragModifierProvider); final duplicateId = shouldDuplicate - ? ref.read(abilityProvider.notifier).duplicateAbilityAt( - sourceId: abilityRef.id, - position: abilityRef.position, - ) + ? ref + .read(abilityProvider.notifier) + .duplicateAbilityAt( + sourceId: abilityRef.id, + position: abilityRef.position, + ) : null; setState(() { _isDragging = true; @@ -298,8 +304,9 @@ class _PlacedDeadlockBarrierMeshWidgetState required double mapScale, required double abilitySize, }) { - final handleSize = - coordinateSystem.scale(deadlockBarrierMeshHandleDiameterVirtual); + final handleSize = coordinateSystem.scale( + deadlockBarrierMeshHandleDiameterVirtual, + ); final handleCenter = deadlockBarrierMeshRotationHandleCenter( mapScale: mapScale, abilitySize: abilitySize, @@ -332,11 +339,8 @@ class _PlacedDeadlockBarrierMeshWidgetState }, child: GestureDetector( behavior: HitTestBehavior.opaque, - onPanStart: (details) => _startRotation( - details.globalPosition, - mapScale, - abilitySize, - ), + onPanStart: (details) => + _startRotation(details.globalPosition, mapScale, abilitySize), onPanUpdate: (details) => _updateRotation(details.globalPosition), onPanEnd: (_) => _finishRotation(), onPanCancel: _cancelRotation, @@ -353,8 +357,9 @@ class _PlacedDeadlockBarrierMeshWidgetState required double mapScale, required double abilitySize, }) { - final handleSize = - coordinateSystem.scale(deadlockBarrierMeshHandleDiameterVirtual); + final handleSize = coordinateSystem.scale( + deadlockBarrierMeshHandleDiameterVirtual, + ); final handleCenter = deadlockBarrierMeshHandleCenter( arm: arm, armLengthMeters: armLengthMeters, @@ -466,8 +471,9 @@ class _PlacedDeadlockBarrierMeshWidgetState ); setState(() { - _rotationOriginGlobal = - renderBox.localToGlobal(Offset(maxExtent / 2, maxExtent / 2)); + _rotationOriginGlobal = renderBox.localToGlobal( + Offset(maxExtent / 2, maxExtent / 2), + ); _isRotating = true; _isRotationHandleHovered = true; }); @@ -484,8 +490,14 @@ class _PlacedDeadlockBarrierMeshWidgetState final delta = globalPosition - _rotationOriginGlobal; final currentAngle = math.atan2(delta.dy, delta.dx); + final sideRotation = currentAngle + (math.pi / 2); + final canonicalRotation = CoordinateSystem.instance.rotationFromSide( + sideRotation, + isAttack: ref.read(mapProvider).isAttack, + ); + setState(() { - _localRotation = currentAngle + (math.pi / 2); + _localRotation = canonicalRotation; }); } @@ -496,17 +508,16 @@ class _PlacedDeadlockBarrierMeshWidgetState } if (widget.isLineUp) { - ref.read(lineUpProvider.notifier).updateGeometry( - rotation: _localRotation, - ); + ref + .read(lineUpProvider.notifier) + .updateGeometry(rotation: _localRotation); } else { final abilities = ref.read(abilityProvider); final index = PlacedWidget.getIndexByID(widget.id, abilities); if (index >= 0) { - ref.read(abilityProvider.notifier).updateGeometry( - index, - rotation: _localRotation, - ); + ref + .read(abilityProvider.notifier) + .updateGeometry(index, rotation: _localRotation); } } @@ -537,19 +548,16 @@ class _PlacedDeadlockBarrierMeshWidgetState double mapScale, double abilitySize, ) { - final currentArmLengths = - normalizeDeadlockBarrierMeshArmLengths(_localArmLengthsMeters); + final currentArmLengths = normalizeDeadlockBarrierMeshArmLengths( + _localArmLengthsMeters, + ); setState(() { _activeArm = arm; _hoveredArm = arm; _resizeStartArmLengthsMeters = List.from(currentArmLengths); - _armDragOffsetMeters = currentArmLengths[arm.index] - - _estimateArmLengthMeters( - globalPosition, - arm, - mapScale, - abilitySize, - ); + _armDragOffsetMeters = + currentArmLengths[arm.index] - + _estimateArmLengthMeters(globalPosition, arm, mapScale, abilitySize); }); _animationController.forward(); } @@ -560,18 +568,14 @@ class _PlacedDeadlockBarrierMeshWidgetState double mapScale, double abilitySize, ) { - final nextLength = (_estimateArmLengthMeters( - globalPosition, - arm, - mapScale, - abilitySize, - ) + - _armDragOffsetMeters) - .clamp( - deadlockBarrierMeshMinArmLengthMeters, - deadlockBarrierMeshMaxArmLengthMeters, - ) - .toDouble(); + final nextLength = + (_estimateArmLengthMeters(globalPosition, arm, mapScale, abilitySize) + + _armDragOffsetMeters) + .clamp( + deadlockBarrierMeshMinArmLengthMeters, + deadlockBarrierMeshMaxArmLengthMeters, + ) + .toDouble(); final nextArmLengths = List.from( normalizeDeadlockBarrierMeshArmLengths(_localArmLengthsMeters), @@ -591,8 +595,9 @@ class _PlacedDeadlockBarrierMeshWidgetState ) { final renderBox = context.findRenderObject() as RenderBox?; if (renderBox == null) { - final armLengths = - normalizeDeadlockBarrierMeshArmLengths(_localArmLengthsMeters); + final armLengths = normalizeDeadlockBarrierMeshArmLengths( + _localArmLengthsMeters, + ); return armLengths[arm.index]; } @@ -608,7 +613,10 @@ class _PlacedDeadlockBarrierMeshWidgetState final unrotatedLocal = _rotateOffset( localPosition, center, - -(_localRotation ?? 0), + -CoordinateSystem.instance.rotationForSide( + _localRotation ?? 0, + isAttack: ref.read(mapProvider).isAttack, + ), ); final delta = unrotatedLocal - center; final deltaVirtual = Offset( @@ -624,8 +632,9 @@ class _PlacedDeadlockBarrierMeshWidgetState } void _finishArmResize() { - final armLengths = - normalizeDeadlockBarrierMeshArmLengths(_localArmLengthsMeters); + final armLengths = normalizeDeadlockBarrierMeshArmLengths( + _localArmLengthsMeters, + ); if (widget.isLineUp) { ref.read(lineUpProvider.notifier).updateArmLengths(armLengths); diff --git a/lib/widgets/draggable_widgets/agents/placed_circle_agent_widget.dart b/lib/widgets/draggable_widgets/agents/placed_circle_agent_widget.dart index 635d5ba5..336abb71 100644 --- a/lib/widgets/draggable_widgets/agents/placed_circle_agent_widget.dart +++ b/lib/widgets/draggable_widgets/agents/placed_circle_agent_widget.dart @@ -64,11 +64,13 @@ class CircleAgentComposite extends ConsumerWidget { final coordinateSystem = CoordinateSystem.instance; final agentSize = forcedAgentSize ?? ref.watch(strategySettingsProvider).agentSize; - final currentMap = - ref.watch(mapProvider.select((state) => state.currentMap)); + final currentMap = ref.watch( + mapProvider.select((state) => state.currentMap), + ); final mapScale = Maps.mapScale[currentMap] ?? 1.0; - final scaledMaxDiameter = - coordinateSystem.scale(circleAgentCompositeDiameterVirtual(mapScale)); + final scaledMaxDiameter = coordinateSystem.scale( + circleAgentCompositeDiameterVirtual(mapScale), + ); final agentOffset = circleAgentCompositeAgentOffsetScreen( coordinateSystem: coordinateSystem, agentSize: agentSize, @@ -154,8 +156,9 @@ class _PlacedCircleAgentWidgetState @override Widget build(BuildContext context) { final coordinateSystem = CoordinateSystem.instance; - final currentMap = - ref.watch(mapProvider.select((state) => state.currentMap)); + final currentMap = ref.watch( + mapProvider.select((state) => state.currentMap), + ); final mapScale = Maps.mapScale[currentMap] ?? 1.0; final isScreenshot = ref.watch(screenshotProvider); final agents = ref.watch(agentProvider); @@ -176,15 +179,14 @@ class _PlacedCircleAgentWidgetState } final agentSize = ref.watch(strategySettingsProvider).agentSize; + final isAttack = ref.watch(mapProvider).isAttack; final diameterMeters = _localDiameterMeters ?? current.diameterMeters; final meterScale = AgentData.inGameMetersDiameter * mapScale; final scaledDiameter = coordinateSystem.scale(diameterMeters * meterScale); - final scaledMaxDiameter = - coordinateSystem.scale(circleAgentCompositeDiameterVirtual(mapScale)); - final circleCenter = Offset( - scaledMaxDiameter / 2, - scaledMaxDiameter / 2, + final scaledMaxDiameter = coordinateSystem.scale( + circleAgentCompositeDiameterVirtual(mapScale), ); + final circleCenter = Offset(scaledMaxDiameter / 2, scaledMaxDiameter / 2); final compositeAgentOffset = circleAgentCompositeAgentOffsetScreen( coordinateSystem: coordinateSystem, agentSize: agentSize, @@ -194,6 +196,7 @@ class _PlacedCircleAgentWidgetState widget: current, coordinateSystem: coordinateSystem, agentSize: agentSize, + isAttack: isAttack, ); final arcRegionSize = coordinateSystem.scale(32); final handleCenter = _computeHandleCenter( @@ -349,8 +352,9 @@ class _PlacedCircleAgentWidgetState required CoordinateSystem coordinateSystem, required double scaledDiameter, }) { - final circleBorderStrokeWidth = - coordinateSystem.scale(_circleBorderStrokeVirtual); + final circleBorderStrokeWidth = coordinateSystem.scale( + _circleBorderStrokeVirtual, + ); return math.max(0.0, (scaledDiameter / 2) - (circleBorderStrokeWidth / 2)); } @@ -383,8 +387,9 @@ class _PlacedCircleAgentWidgetState if (renderBox == null) return _localDiameterMeters ?? _minDiameterMeters; final coordinateSystem = CoordinateSystem.instance; - final scaledMaxDiameter = - coordinateSystem.scale(circleAgentCompositeDiameterVirtual(mapScale)); + final scaledMaxDiameter = coordinateSystem.scale( + circleAgentCompositeDiameterVirtual(mapScale), + ); final localCenter = Offset(scaledMaxDiameter / 2, scaledMaxDiameter / 2); final localPosition = renderBox.globalToLocal(globalPosition); final deltaFromCenter = localPosition - localCenter; @@ -396,8 +401,10 @@ class _PlacedCircleAgentWidgetState final meterScale = AgentData.inGameMetersDiameter * mapScale; final radiusEstimateX = deltaVirtual.dx / math.cos(_handleAngle); final radiusEstimateY = deltaVirtual.dy / math.sin(_handleAngle); - final radiusVirtual = - math.max((radiusEstimateX + radiusEstimateY) / 2, 0.0); + final radiusVirtual = math.max( + (radiusEstimateX + radiusEstimateY) / 2, + 0.0, + ); return ((radiusVirtual * 2) / meterScale).toDouble(); } @@ -465,10 +472,12 @@ class _CircleResizeHandle extends StatelessWidget { @override Widget build(BuildContext context) { final coordinateSystem = CoordinateSystem.instance; - final strokeWidth = coordinateSystem - .scale(_PlacedCircleAgentWidgetState._handleStrokeWidthVirtual); - final circleBorderStrokeWidth = coordinateSystem - .scale(_PlacedCircleAgentWidgetState._circleBorderStrokeVirtual); + final strokeWidth = coordinateSystem.scale( + _PlacedCircleAgentWidgetState._handleStrokeWidthVirtual, + ); + final circleBorderStrokeWidth = coordinateSystem.scale( + _PlacedCircleAgentWidgetState._circleBorderStrokeVirtual, + ); return MouseRegion( cursor: SystemMouseCursors.resizeUpLeftDownRight, diff --git a/lib/widgets/draggable_widgets/agents/placed_lineup_agent_widget.dart b/lib/widgets/draggable_widgets/agents/placed_lineup_agent_widget.dart index 476a93ba..6a18c4b0 100644 --- a/lib/widgets/draggable_widgets/agents/placed_lineup_agent_widget.dart +++ b/lib/widgets/draggable_widgets/agents/placed_lineup_agent_widget.dart @@ -6,6 +6,7 @@ import 'package:icarus/const/placed_classes.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/const/transition_data.dart'; import 'package:icarus/providers/canvas_resize_provider.dart'; +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/agents/agent_widget.dart'; @@ -28,10 +29,12 @@ class PlacedLineupAgentWidget extends ConsumerWidget { ref.watch(canvasResizeProvider); final coordinateSystem = CoordinateSystem.instance; final agentSize = ref.watch(strategySettingsProvider).agentSize; + final isAttack = ref.watch(mapProvider).isAttack; final screenPosition = screenPositionForWidget( widget: agent, coordinateSystem: coordinateSystem, agentSize: agentSize, + isAttack: isAttack, ); return Positioned( @@ -40,8 +43,9 @@ class PlacedLineupAgentWidget extends ConsumerWidget { child: draggable ? Draggable( data: agent, - dragAnchorStrategy: - ref.read(screenZoomProvider.notifier).zoomDragAnchorStrategy, + dragAnchorStrategy: ref + .read(screenZoomProvider.notifier) + .zoomDragAnchorStrategy, feedback: Opacity( opacity: Settings.feedbackOpacity, child: ZoomTransform( 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 97ccde9e..55de65a2 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 @@ -10,6 +10,7 @@ import 'package:icarus/const/transition_data.dart'; import 'package:icarus/const/utilities.dart'; import 'package:icarus/providers/agent_provider.dart'; import 'package:icarus/providers/duplicate_drag_modifier_provider.dart'; +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/rotatable_widget.dart'; @@ -193,6 +194,7 @@ class _PlacedViewConeAgentWidgetState } final agentSize = ref.watch(strategySettingsProvider).agentSize; + final isAttack = ref.watch(mapProvider).isAttack; const anchorVirtual = ViewConeWidget.anchorPointVirtual; final anchorScaled = anchorVirtual.scale( coordinateSystem.scaleFactor, @@ -206,6 +208,7 @@ class _PlacedViewConeAgentWidgetState widget: current, coordinateSystem: coordinateSystem, agentSize: agentSize, + isAttack: isAttack, ); if (!_isDragging && _rotationOrigin == Offset.zero) { @@ -219,12 +222,16 @@ class _PlacedViewConeAgentWidgetState final localRotation = _localRotation ?? current.rotation; final localLength = _localLength ?? current.length; + final displayRotation = coordinateSystem.rotationForSide( + localRotation, + isAttack: isAttack, + ); return Positioned( left: agentScreenPosition.dx - compositeAgentOffset.dx, top: agentScreenPosition.dy - compositeAgentOffset.dy, child: RotatableWidget( - rotation: localRotation, + rotation: displayRotation, isDragging: _isDragging, origin: anchorVirtual, buttonTop: anchorVirtual.dy - localLength - 7.5, @@ -239,7 +246,11 @@ class _PlacedViewConeAgentWidgetState final delta = details.globalPosition - _rotationOrigin; final currentAngle = math.atan2(delta.dy, delta.dx); - final nextRotation = currentAngle + (math.pi / 2); + final sideRotation = currentAngle + (math.pi / 2); + final nextRotation = coordinateSystem.rotationFromSide( + sideRotation, + isAttack: isAttack, + ); final nextLength = (coordinateSystem.normalize(delta.distance) / ref.watch(screenZoomProvider)) .clamp(ViewConeUtility.minLength, ViewConeUtility.maxLength); @@ -266,7 +277,7 @@ class _PlacedViewConeAgentWidgetState final rotatedPosition = _rotateOffset( renderObject.globalToLocal(position), anchorScaled, - localRotation, + displayRotation, ); return ref @@ -278,7 +289,7 @@ class _PlacedViewConeAgentWidgetState child: ZoomTransform( child: ViewConeAgentComposite( agent: current, - rotation: localRotation, + rotation: displayRotation, length: localLength, forcedAgentSize: agentSize, clipToGeometry: false, @@ -310,7 +321,7 @@ class _PlacedViewConeAgentWidgetState }, child: ViewConeAgentComposite( agent: current, - rotation: localRotation, + rotation: displayRotation, length: localLength, forcedAgentSize: agentSize, applyRotation: false, diff --git a/lib/widgets/draggable_widgets/canonical_positioned.dart b/lib/widgets/draggable_widgets/canonical_positioned.dart new file mode 100644 index 00000000..fbffba7f --- /dev/null +++ b/lib/widgets/draggable_widgets/canonical_positioned.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; + +/// Positions an upright box from attack-canonical coordinates. +/// +/// The defense projection needs the child's laid-out size, so a normal +/// [Positioned] cannot do this without a separate measurement pass. This +/// delegate keeps text and images exact even while their dimensions change. +class CanonicalPositionedBox extends StatelessWidget { + const CanonicalPositionedBox({ + super.key, + required this.attackScreenPosition, + required this.isAttack, + required this.child, + }); + + final Offset attackScreenPosition; + final bool isAttack; + final Widget child; + + @override + Widget build(BuildContext context) { + return Positioned.fill( + child: CustomSingleChildLayout( + delegate: _CanonicalBoxPositionDelegate( + attackScreenPosition: attackScreenPosition, + isAttack: isAttack, + ), + child: child, + ), + ); + } +} + +class _CanonicalBoxPositionDelegate extends SingleChildLayoutDelegate { + const _CanonicalBoxPositionDelegate({ + required this.attackScreenPosition, + required this.isAttack, + }); + + final Offset attackScreenPosition; + final bool isAttack; + + @override + BoxConstraints getConstraintsForChild(BoxConstraints constraints) { + return constraints.loosen(); + } + + @override + Offset getPositionForChild(Size size, Size childSize) { + if (isAttack) return attackScreenPosition; + return Offset( + size.width - attackScreenPosition.dx - childSize.width, + size.height - attackScreenPosition.dy - childSize.height, + ); + } + + @override + bool shouldRelayout(covariant _CanonicalBoxPositionDelegate oldDelegate) { + return attackScreenPosition != oldDelegate.attackScreenPosition || + isAttack != oldDelegate.isAttack; + } +} diff --git a/lib/widgets/draggable_widgets/placed_widget_builder.dart b/lib/widgets/draggable_widgets/placed_widget_builder.dart index 97a9616f..b274a118 100644 --- a/lib/widgets/draggable_widgets/placed_widget_builder.dart +++ b/lib/widgets/draggable_widgets/placed_widget_builder.dart @@ -18,12 +18,14 @@ import 'package:icarus/providers/canvas_resize_provider.dart'; import 'package:icarus/providers/duplicate_drag_modifier_provider.dart'; import 'package:icarus/providers/hovered_delete_target_provider.dart'; import 'package:icarus/providers/image_provider.dart'; +import 'package:icarus/providers/image_widget_size_provider.dart'; import 'package:icarus/providers/interaction_state_provider.dart'; 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/providers/team_provider.dart'; import 'package:icarus/providers/text_provider.dart'; +import 'package:icarus/providers/text_widget_height_provider.dart'; 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'; @@ -31,6 +33,7 @@ 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/canonical_positioned.dart'; import 'package:icarus/widgets/draggable_widgets/text/placed_text_builder.dart'; import 'package:icarus/widgets/draggable_widgets/utilities/placed_custom_circle_widget.dart'; import 'package:icarus/widgets/draggable_widgets/utilities/placed_custom_rectangle_widget.dart'; @@ -76,8 +79,9 @@ class _PlacedWidgetBuilderState extends ConsumerState { id: targetAgent.id, presetType: toolData.type, rotation: 0, - length: - UtilityData.getViewConePreset(toolData.type).defaultLength, + length: UtilityData.getViewConePreset( + toolData.type, + ).defaultLength, ); }, ); @@ -104,6 +108,7 @@ class _PlacedWidgetBuilderState extends ConsumerState { Widget build(BuildContext context) { final coordinateSystem = CoordinateSystem.instance; final mapScale = Maps.mapScale[ref.watch(mapProvider).currentMap] ?? 1.0; + final isAttack = ref.watch(mapProvider).isAttack; final strategySettings = ref.watch(strategySettingsProvider); final agentSize = strategySettings.agentSize; @@ -167,8 +172,6 @@ class _PlacedWidgetBuilderState extends ConsumerState { onAcceptWithDetails: (details) { RenderBox renderBox = context.findRenderObject() as RenderBox; Offset localOffset = renderBox.globalToLocal(details.offset); - Offset normalizedPosition = - coordinateSystem.screenToCoordinate(localOffset); const uuid = Uuid(); if (details.data is AgentData) { @@ -177,6 +180,7 @@ class _PlacedWidgetBuilderState extends ConsumerState { coordinateSystem: coordinateSystem, renderedScreenPosition: localOffset, agentSize: agentSize, + isAttack: isAttack, ); PlacedAgent placedAgent = PlacedAgent( id: uuid.v4(), @@ -203,6 +207,7 @@ class _PlacedWidgetBuilderState extends ConsumerState { renderedScreenPosition: localOffset, mapScale: mapScale, abilitySize: abilitySize, + isAttack: isAttack, ); PlacedAbility placedAbility = PlacedAbility( id: uuid.v4(), @@ -229,6 +234,7 @@ class _PlacedWidgetBuilderState extends ConsumerState { renderedScreenPosition: localOffset, mapScale: mapScale, abilitySize: abilitySize, + isAttack: isAttack, ); PlacedAbility placedAbility = PlacedAbility( id: uuid.v4(), @@ -255,21 +261,40 @@ class _PlacedWidgetBuilderState extends ConsumerState { toolData: visionConeData, ); } else { - ref.read(utilityProvider.notifier).addUtility( - PlacedUtility( - id: uuid.v4(), - type: visionConeData.type, - position: normalizedPosition, - angle: visionConeData.angle, - ), - ); + final placedUtility = PlacedUtility( + id: uuid.v4(), + type: visionConeData.type, + position: Offset.zero, + angle: visionConeData.angle, + ); + placedUtility.position = + storedUtilityPositionForRenderedScreenPosition( + utility: placedUtility, + coordinateSystem: coordinateSystem, + renderedScreenPosition: localOffset, + mapScale: mapScale, + agentSize: agentSize, + abilitySize: abilitySize, + isAttack: isAttack, + ); + ref.read(utilityProvider.notifier).addUtility(placedUtility); } } else if (details.data is SpikeToolData) { final spikeData = details.data as SpikeToolData; final placedUtility = PlacedUtility( id: uuid.v4(), type: spikeData.type, - position: normalizedPosition, + position: Offset.zero, + ); + placedUtility.position = + storedUtilityPositionForRenderedScreenPosition( + utility: placedUtility, + coordinateSystem: coordinateSystem, + renderedScreenPosition: localOffset, + mapScale: mapScale, + agentSize: agentSize, + abilitySize: abilitySize, + isAttack: isAttack, ); ref.read(utilityProvider.notifier).addUtility(placedUtility); } else if (details.data is RoleIconToolData) { @@ -288,10 +313,9 @@ class _PlacedWidgetBuilderState extends ConsumerState { mapScale: mapScale, agentSize: agentSize, abilitySize: abilitySize, + isAttack: isAttack, ); - ref.read(utilityProvider.notifier).addUtility( - placedUtility, - ); + ref.read(utilityProvider.notifier).addUtility(placedUtility); } else if (details.data is CustomShapeToolData) { final customData = details.data as CustomShapeToolData; final targetAgent = _hoveredAgentAttachmentTarget(); @@ -302,32 +326,52 @@ class _PlacedWidgetBuilderState extends ConsumerState { toolData: customData, ); } else { - ref.read(utilityProvider.notifier).addUtility( - customData.type == UtilityType.customCircle - ? PlacedUtility( - id: uuid.v4(), - type: customData.type, - position: normalizedPosition, - customDiameter: customData.diameterMeters, - customColorValue: customData.colorValue, - customOpacityPercent: customData.opacityPercent, - ) - : PlacedUtility( - id: uuid.v4(), - type: customData.type, - position: normalizedPosition, - customWidth: customData.widthMeters, - customLength: customData.rectLengthMeters, - customColorValue: customData.colorValue, - customOpacityPercent: customData.opacityPercent, - ), - ); + final placedUtility = + customData.type == UtilityType.customCircle + ? PlacedUtility( + id: uuid.v4(), + type: customData.type, + position: Offset.zero, + customDiameter: customData.diameterMeters, + customColorValue: customData.colorValue, + customOpacityPercent: customData.opacityPercent, + ) + : PlacedUtility( + id: uuid.v4(), + type: customData.type, + position: Offset.zero, + customWidth: customData.widthMeters, + customLength: customData.rectLengthMeters, + customColorValue: customData.colorValue, + customOpacityPercent: customData.opacityPercent, + ); + placedUtility.position = + storedUtilityPositionForRenderedScreenPosition( + utility: placedUtility, + coordinateSystem: coordinateSystem, + renderedScreenPosition: localOffset, + mapScale: mapScale, + agentSize: agentSize, + abilitySize: abilitySize, + isAttack: isAttack, + ); + ref.read(utilityProvider.notifier).addUtility(placedUtility); } } else if (details.data is TextToolData) { final textData = details.data as TextToolData; + final attackScreenPosition = + coordinateSystem.screenPositionFromSide( + sideScreenPosition: localOffset, + reflectionOffset: Size( + coordinateSystem.worldWidthToScreen(textData.width), + 48, + ).bottomRight(Offset.zero), + isAttack: isAttack, + ); final placedText = PlacedText( id: uuid.v4(), - position: normalizedPosition, + position: + coordinateSystem.screenToCoordinate(attackScreenPosition), size: textData.width, fontSize: 16, sizeVersion: worldSizedMediaVersion, @@ -415,6 +459,7 @@ class _AbilityList extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final abilities = ref.watch(abilityProvider); + final isAttack = ref.watch(mapProvider).isAttack; return Stack( clipBehavior: Clip.none, @@ -430,19 +475,18 @@ class _AbilityList extends ConsumerWidget { onDragEnd: (details, draggedId) { final renderBox = context.findRenderObject() as RenderBox; 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 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), + details.offset + coneChildOffset.scale(screenZoom, screenZoom), ); final virtualOffset = storedAbilityPositionForRenderedScreenPosition( @@ -451,6 +495,7 @@ class _AbilityList extends ConsumerWidget { renderedScreenPosition: localOffset, mapScale: mapScale, abilitySize: abilitySize, + isAttack: isAttack, ); final safeArea = storedAbilityAnchor( ability: ability.data.abilityData!, @@ -458,7 +503,8 @@ class _AbilityList extends ConsumerWidget { ); if (coordinateSystem.isOutOfBounds( - virtualOffset.translate(safeArea.dx, safeArea.dy))) { + virtualOffset.translate(safeArea.dx, safeArea.dy), + )) { ref .read(abilityProvider.notifier) .removeAbilityAsAction(draggedId); @@ -476,10 +522,7 @@ class _AbilityList extends ConsumerWidget { } class _AgentList extends ConsumerStatefulWidget { - const _AgentList({ - required this.coordinateSystem, - required this.agentSize, - }); + const _AgentList({required this.coordinateSystem, required this.agentSize}); final CoordinateSystem coordinateSystem; final double agentSize; @@ -494,6 +537,7 @@ class _AgentListState extends ConsumerState<_AgentList> { @override Widget build(BuildContext context) { final agents = ref.watch(agentProvider); + final isAttack = ref.watch(mapProvider).isAttack; final zoomDragAnchorStrategy = ref.read(screenZoomProvider.notifier).zoomDragAnchorStrategy; @@ -508,18 +552,21 @@ class _AgentListState extends ConsumerState<_AgentList> { widget: agent, coordinateSystem: widget.coordinateSystem, agentSize: widget.agentSize, + isAttack: isAttack, ).dx, top: screenPositionForWidget( widget: agent, coordinateSystem: widget.coordinateSystem, agentSize: widget.agentSize, + isAttack: isAttack, ).dy, child: Draggable( data: agent, dragAnchorStrategy: zoomDragAnchorStrategy, onDragStarted: () { - final shouldDuplicate = - ref.read(duplicateDragModifierProvider); + final shouldDuplicate = ref.read( + duplicateDragModifierProvider, + ); if (!shouldDuplicate) return; final duplicatedId = @@ -551,10 +598,12 @@ class _AgentListState extends ConsumerState<_AgentList> { coordinateSystem: widget.coordinateSystem, renderedScreenPosition: localOffset, agentSize: widget.agentSize, + isAttack: isAttack, ); - final duplicateId = - _pendingDuplicateDragBySource.remove(agent.id); + final duplicateId = _pendingDuplicateDragBySource.remove( + agent.id, + ); if (duplicateId != null) { ref .read(agentProvider.notifier) @@ -596,6 +645,7 @@ class _AgentListState extends ConsumerState<_AgentList> { coordinateSystem: widget.coordinateSystem, renderedScreenPosition: localOffset, agentSize: agentSize, + isAttack: isAttack, ); ref .read(agentProvider.notifier) @@ -626,6 +676,7 @@ class _AgentListState extends ConsumerState<_AgentList> { coordinateSystem: widget.coordinateSystem, renderedScreenPosition: localOffset, agentSize: agentSize, + isAttack: isAttack, ); ref .read(agentProvider.notifier) @@ -639,10 +690,7 @@ class _AgentListState extends ConsumerState<_AgentList> { } class _TextList extends ConsumerWidget { - const _TextList({ - required this.coordinateSystem, - required this.agentSize, - }); + const _TextList({required this.coordinateSystem, required this.agentSize}); final CoordinateSystem coordinateSystem; final double agentSize; @@ -650,27 +698,41 @@ class _TextList extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final placedTexts = ref.watch(textProvider); + final isAttack = ref.watch(mapProvider).isAttack; return Stack( clipBehavior: Clip.none, children: [ for (final placedText in placedTexts) - Positioned( + CanonicalPositionedBox( key: ValueKey(placedText.id), - left: coordinateSystem.coordinateToScreen(placedText.position).dx, - top: coordinateSystem.coordinateToScreen(placedText.position).dy, + attackScreenPosition: coordinateSystem.coordinateToScreen( + placedText.position, + ), + isAttack: isAttack, child: PlacedTextBuilder( size: placedText.size, placedText: placedText, onDragEnd: (details) { final renderBox = context.findRenderObject() as RenderBox; final localOffset = renderBox.globalToLocal(details.offset); - final virtualOffset = - coordinateSystem.screenToCoordinate(localOffset); + final renderedSize = ref + .read(textWidgetHeightProvider.notifier) + .getOffset(placedText.id); + final attackScreenOffset = + coordinateSystem.screenPositionFromSide( + sideScreenPosition: localOffset, + reflectionOffset: renderedSize, + isAttack: isAttack, + ); + final virtualOffset = coordinateSystem.screenToCoordinate( + attackScreenOffset, + ); final safeArea = agentSize / 2; if (coordinateSystem.isOutOfBounds( - virtualOffset.translate(safeArea, safeArea))) { + virtualOffset.translate(safeArea, safeArea), + )) { ref .read(textProvider.notifier) .removeTextAsAction(placedText.id); @@ -700,27 +762,41 @@ class _PlacedImageList extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final images = ref.watch(placedImageProvider).images; + final isAttack = ref.watch(mapProvider).isAttack; return Stack( clipBehavior: Clip.none, children: [ for (final placedImage in images) - Positioned( + CanonicalPositionedBox( key: ValueKey(placedImage.id), - left: coordinateSystem.coordinateToScreen(placedImage.position).dx, - top: coordinateSystem.coordinateToScreen(placedImage.position).dy, + attackScreenPosition: coordinateSystem.coordinateToScreen( + placedImage.position, + ), + isAttack: isAttack, child: PlacedImageBuilder( placedImage: placedImage, scale: placedImage.scale, onDragEnd: (details) { final renderBox = context.findRenderObject() as RenderBox; final localOffset = renderBox.globalToLocal(details.offset); - final virtualOffset = - coordinateSystem.screenToCoordinate(localOffset); + final renderedSize = ref + .read(imageWidgetSizeProvider.notifier) + .getSize(placedImage.id); + final attackScreenOffset = + coordinateSystem.screenPositionFromSide( + sideScreenPosition: localOffset, + reflectionOffset: renderedSize, + isAttack: isAttack, + ); + final virtualOffset = coordinateSystem.screenToCoordinate( + attackScreenOffset, + ); final safeArea = agentSize / 2; if (coordinateSystem.isOutOfBounds( - virtualOffset.translate(safeArea, safeArea))) { + virtualOffset.translate(safeArea, safeArea), + )) { ref .read(placedImageProvider.notifier) .removeImageAsAction(placedImage.id); @@ -751,6 +827,9 @@ class _ViewConeUtilityList extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final utilities = ref.watch(utilityProvider).where(PageLayering.isViewConeUtility); + final mapScale = Maps.mapScale[ref.watch(mapProvider).currentMap] ?? 1.0; + final settings = ref.watch(strategySettingsProvider); + final isAttack = ref.watch(mapProvider).isAttack; return Stack( clipBehavior: Clip.none, @@ -762,11 +841,20 @@ class _ViewConeUtilityList extends ConsumerWidget { id: placedUtility.id, rotation: placedUtility.rotation, length: placedUtility.length, + isAttack: isAttack, onDragEnd: (details) { final renderBox = context.findRenderObject() as RenderBox; final localOffset = renderBox.globalToLocal(details.offset); final virtualOffset = - coordinateSystem.screenToCoordinate(localOffset); + storedUtilityPositionForRenderedScreenPosition( + utility: placedUtility, + coordinateSystem: coordinateSystem, + renderedScreenPosition: localOffset, + mapScale: mapScale, + agentSize: settings.agentSize, + abilitySize: settings.abilitySize, + isAttack: isAttack, + ); // if (coordinateSystem.isOutOfBounds( // virtualOffset.translate(agentSize / 2, agentSize / 2))) { @@ -784,10 +872,9 @@ class _ViewConeUtilityList extends ConsumerWidget { return; } - ref.read(utilityProvider.notifier).updatePosition( - virtualOffset, - placedUtility.id, - ); + ref + .read(utilityProvider.notifier) + .updatePosition(virtualOffset, placedUtility.id); }, ), ], @@ -812,6 +899,7 @@ class _UtilityList extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final utilities = ref.watch(utilityProvider).where(PageLayering.isTopUtility); + final isAttack = ref.watch(mapProvider).isAttack; return Stack( clipBehavior: Clip.none, @@ -825,6 +913,7 @@ class _UtilityList extends ConsumerWidget { mapScale: mapScale, agentSize: agentSize, abilitySize: abilitySize, + isAttack: isAttack, ).dx, top: screenPositionForWidget( widget: placedUtility, @@ -832,6 +921,7 @@ class _UtilityList extends ConsumerWidget { mapScale: mapScale, agentSize: agentSize, abilitySize: abilitySize, + isAttack: isAttack, ).dy, child: UtilityWidgetBuilder( rotation: placedUtility.rotation, @@ -849,6 +939,7 @@ class _UtilityList extends ConsumerWidget { mapScale: mapScale, agentSize: agentSize, abilitySize: abilitySize, + isAttack: isAttack, ); final safeArea = storedUtilityAnchor( @@ -857,7 +948,8 @@ class _UtilityList extends ConsumerWidget { ); if (coordinateSystem.isOutOfBounds( - virtualOffset.translate(safeArea.dx, safeArea.dy))) { + virtualOffset.translate(safeArea.dx, safeArea.dy), + )) { ref .read(utilityProvider.notifier) .removeUtilityAsAction(placedUtility.id); @@ -888,6 +980,8 @@ class _CustomShapeUtilityList extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final customShapes = ref.watch(utilityProvider).where(PageLayering.isCustomShapeUtility); + final settings = ref.watch(strategySettingsProvider); + final isAttack = ref.watch(mapProvider).isAttack; return Stack( clipBehavior: Clip.none, @@ -895,19 +989,41 @@ class _CustomShapeUtilityList extends ConsumerWidget { for (final placedUtility in customShapes) Positioned( key: ValueKey('custom-shape-${placedUtility.id}'), - left: - coordinateSystem.coordinateToScreen(placedUtility.position).dx, - top: coordinateSystem.coordinateToScreen(placedUtility.position).dy, + left: screenPositionForWidget( + widget: placedUtility, + coordinateSystem: coordinateSystem, + mapScale: mapScale, + agentSize: settings.agentSize, + abilitySize: settings.abilitySize, + isAttack: isAttack, + ).dx, + top: screenPositionForWidget( + widget: placedUtility, + coordinateSystem: coordinateSystem, + mapScale: mapScale, + agentSize: settings.agentSize, + abilitySize: settings.abilitySize, + isAttack: isAttack, + ).dy, child: placedUtility.type == UtilityType.customCircle ? PlacedCustomCircleWidget( utility: placedUtility, id: placedUtility.id, onDragEnd: (details) { final renderBox = context.findRenderObject() as RenderBox; - final localOffset = - renderBox.globalToLocal(details.offset); + final localOffset = renderBox.globalToLocal( + details.offset, + ); final virtualOffset = - coordinateSystem.screenToCoordinate(localOffset); + storedUtilityPositionForRenderedScreenPosition( + utility: placedUtility, + coordinateSystem: coordinateSystem, + renderedScreenPosition: localOffset, + mapScale: mapScale, + agentSize: settings.agentSize, + abilitySize: settings.abilitySize, + isAttack: isAttack, + ); final diameterMeters = placedUtility.customDiameter; if (diameterMeters == null) { @@ -925,7 +1041,8 @@ class _CustomShapeUtilityList extends ConsumerWidget { ); if (coordinateSystem.isOutOfBounds( - virtualOffset.translate(safeArea.dx, safeArea.dy))) { + virtualOffset.translate(safeArea.dx, safeArea.dy), + )) { ref .read(utilityProvider.notifier) .removeUtilityAsAction(placedUtility.id); @@ -942,21 +1059,30 @@ class _CustomShapeUtilityList extends ConsumerWidget { return; } - ref.read(utilityProvider.notifier).updatePosition( - virtualOffset, - placedUtility.id, - ); + ref + .read(utilityProvider.notifier) + .updatePosition(virtualOffset, placedUtility.id); }, ) : PlacedCustomRectangleWidget( utility: placedUtility, id: placedUtility.id, + isAttack: isAttack, onDragEnd: (details) { final renderBox = context.findRenderObject() as RenderBox; - final localOffset = - renderBox.globalToLocal(details.offset); + final localOffset = renderBox.globalToLocal( + details.offset, + ); final virtualOffset = - coordinateSystem.screenToCoordinate(localOffset); + storedUtilityPositionForRenderedScreenPosition( + utility: placedUtility, + coordinateSystem: coordinateSystem, + renderedScreenPosition: localOffset, + mapScale: mapScale, + agentSize: settings.agentSize, + abilitySize: settings.abilitySize, + isAttack: isAttack, + ); final widthMeters = placedUtility.customWidth; final lengthMeters = placedUtility.customLength; @@ -976,7 +1102,8 @@ class _CustomShapeUtilityList extends ConsumerWidget { final safeArea = Offset(length / 2, width / 2); if (coordinateSystem.isOutOfBounds( - virtualOffset.translate(safeArea.dx, safeArea.dy))) { + virtualOffset.translate(safeArea.dx, safeArea.dy), + )) { ref .read(utilityProvider.notifier) .removeUtilityAsAction(placedUtility.id); @@ -1002,9 +1129,7 @@ class LineUpOverlay extends StatelessWidget { return Stack( clipBehavior: Clip.none, children: const [ - Positioned.fill( - child: LineUpLinePainter(), - ), + Positioned.fill(child: LineUpLinePainter()), _LineUpAgents(), _LineUpAbilities(), ], diff --git a/lib/widgets/draggable_widgets/utilities/placed_custom_rectangle_widget.dart b/lib/widgets/draggable_widgets/utilities/placed_custom_rectangle_widget.dart index 3cc6a465..ef887d62 100644 --- a/lib/widgets/draggable_widgets/utilities/placed_custom_rectangle_widget.dart +++ b/lib/widgets/draggable_widgets/utilities/placed_custom_rectangle_widget.dart @@ -25,11 +25,13 @@ class PlacedCustomRectangleWidget extends ConsumerStatefulWidget { super.key, required this.utility, required this.id, + required this.isAttack, required this.onDragEnd, }); final PlacedUtility utility; final String id; + final bool isAttack; final void Function(DraggableDetails details) onDragEnd; @override @@ -65,6 +67,7 @@ class _PlacedCustomRectangleWidgetState Offset _resizeFixedEdgeCenterGlobal = Offset.zero; Offset _resizeStartStoredPosition = Offset.zero; Offset _resizePositionDeltaScreen = Offset.zero; + Offset _resizeSizeDeltaScreen = Offset.zero; Size _resizeStartSizeGlobal = Size.zero; double _resizeGlobalScale = 1; bool _isDragging = false; @@ -82,7 +85,10 @@ class _PlacedCustomRectangleWidgetState super.initState(); _localWidthMeters = widget.utility.customWidth; _localLengthMeters = widget.utility.customLength; - _localRotation = widget.utility.rotation; + _localRotation = CoordinateSystem.instance.rotationForSide( + widget.utility.rotation, + isAttack: widget.isAttack, + ); } @override @@ -114,13 +120,17 @@ class _PlacedCustomRectangleWidgetState _localLengthMeters = providerLengthMeters; } } - if (!_isRotating && _localRotation != utilityRef.rotation) { - _localRotation = utilityRef.rotation; + final providerRotation = coordinateSystem.rotationForSide( + utilityRef.rotation, + isAttack: widget.isAttack, + ); + if (!_isRotating && _localRotation != providerRotation) { + _localRotation = providerRotation; } final widthMeters = _localWidthMeters ?? providerWidthMeters; final lengthMeters = _localLengthMeters ?? providerLengthMeters; - final rotation = _localRotation ?? utilityRef.rotation; + final rotation = _localRotation ?? providerRotation; final meterScale = AgentData.inGameMetersDiameter * mapScale; final scaledWidth = coordinateSystem.scale(widthMeters * meterScale); final scaledLength = coordinateSystem.scale(lengthMeters * meterScale); @@ -157,6 +167,7 @@ class _PlacedCustomRectangleWidgetState children: [ Positioned.fill( child: Transform.rotate( + key: const ValueKey('custom-rectangle-rotation'), angle: rotation, child: Stack( clipBehavior: Clip.none, @@ -514,7 +525,11 @@ class _PlacedCustomRectangleWidgetState globalPosition.dy - centerGlobal.dy, globalPosition.dx - centerGlobal.dx, ); - _rotationStartValue = _localRotation ?? widget.utility.rotation; + _rotationStartValue = _localRotation ?? + CoordinateSystem.instance.rotationForSide( + widget.utility.rotation, + isAttack: widget.isAttack, + ); }); } @@ -539,15 +554,21 @@ class _PlacedCustomRectangleWidgetState } void _commitRotation() { - final rotation = _localRotation; + final displayedRotation = _localRotation; final utilities = ref.read(utilityProvider); final index = PlacedWidget.getIndexByID(widget.id, utilities); - if (rotation != null && - index >= 0 && - utilities[index].rotation != rotation) { - ref - .read(utilityProvider.notifier) - .updateRotation(index, rotation, utilities[index].length); + if (displayedRotation != null && index >= 0) { + final canonicalRotation = CoordinateSystem.instance.rotationFromSide( + displayedRotation, + isAttack: widget.isAttack, + ); + if (utilities[index].rotation != canonicalRotation) { + ref.read(utilityProvider.notifier).updateRotation( + index, + canonicalRotation, + utilities[index].length, + ); + } } setState(() { @@ -657,6 +678,11 @@ class _PlacedCustomRectangleWidgetState meterScale; _resizePositionDeltaScreen = (result.topLeft - _resizeStartTopLeftGlobal) / _resizeGlobalScale; + _resizeSizeDeltaScreen = Offset( + (result.size.width - _resizeStartSizeGlobal.width) / _resizeGlobalScale, + (result.size.height - _resizeStartSizeGlobal.height) / + _resizeGlobalScale, + ); }); } @@ -674,11 +700,14 @@ class _PlacedCustomRectangleWidgetState final lengthMeters = _localLengthMeters; if (widthMeters != null && lengthMeters != null) { final coordinateSystem = CoordinateSystem.instance; - final nextPosition = _resizeStartStoredPosition + - Offset( - coordinateSystem.screenWidthToWorld(_resizePositionDeltaScreen.dx), - coordinateSystem.screenHeightToWorld(_resizePositionDeltaScreen.dy), - ); + final canonicalPositionDeltaScreen = widget.isAttack + ? _resizePositionDeltaScreen + : -_resizePositionDeltaScreen - _resizeSizeDeltaScreen; + final canonicalPositionDelta = Offset( + coordinateSystem.screenWidthToWorld(canonicalPositionDeltaScreen.dx), + coordinateSystem.screenHeightToWorld(canonicalPositionDeltaScreen.dy), + ); + final nextPosition = _resizeStartStoredPosition + canonicalPositionDelta; ref.read(utilityProvider.notifier).updateCustomShapeGeometry( id: widget.id, position: nextPosition, @@ -695,6 +724,7 @@ class _PlacedCustomRectangleWidgetState _activeHandle = _RectangleResizeHandle.none; _hoveredResizeHandle = _RectangleResizeHandle.none; _resizePositionDeltaScreen = Offset.zero; + _resizeSizeDeltaScreen = Offset.zero; }); } diff --git a/lib/widgets/draggable_widgets/utilities/placed_view_cone_widget.dart b/lib/widgets/draggable_widgets/utilities/placed_view_cone_widget.dart index 3953a53e..1bdcc114 100644 --- a/lib/widgets/draggable_widgets/utilities/placed_view_cone_widget.dart +++ b/lib/widgets/draggable_widgets/utilities/placed_view_cone_widget.dart @@ -3,10 +3,14 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.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/transition_data.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/const/utilities.dart'; import 'package:icarus/providers/screen_zoom_provider.dart'; +import 'package:icarus/providers/map_provider.dart'; +import 'package:icarus/providers/strategy_settings_provider.dart'; import 'package:icarus/providers/utility_provider.dart'; import 'package:icarus/widgets/draggable_widgets/ability/rotatable_widget.dart'; import 'package:icarus/widgets/draggable_widgets/zoom_transform.dart'; @@ -17,6 +21,7 @@ class PlacedViewConeWidget extends ConsumerStatefulWidget { final String id; final double rotation; final double length; + final bool isAttack; const PlacedViewConeWidget({ super.key, @@ -25,6 +30,7 @@ class PlacedViewConeWidget extends ConsumerStatefulWidget { required this.id, required this.rotation, required this.length, + required this.isAttack, }); @override @@ -53,10 +59,7 @@ class _PlacedViewConeWidgetState extends ConsumerState { final rotatedX = dx * math.cos(angle) - dy * math.sin(angle); final rotatedY = dx * math.sin(angle) + dy * math.cos(angle); - return Offset( - rotatedX + origin.dx, - rotatedY + origin.dy, - ); + return Offset(rotatedX + origin.dx, rotatedY + origin.dy); } @override @@ -67,8 +70,10 @@ class _PlacedViewConeWidgetState extends ConsumerState { return const SizedBox.shrink(); } - final index = - PlacedWidget.getIndexByID(widget.id, ref.watch(utilityProvider)); + final index = PlacedWidget.getIndexByID( + widget.id, + ref.watch(utilityProvider), + ); if (index < 0) { return const SizedBox.shrink(); @@ -89,23 +94,43 @@ class _PlacedViewConeWidgetState extends ConsumerState { // Get the view cone utility to access its methods final viewConeUtility = UtilityData.utilityWidgets[widget.utility.type] as ViewConeUtility; + final mapScale = Maps.mapScale[ref.watch(mapProvider).currentMap] ?? 1.0; + final settings = ref.watch(strategySettingsProvider); + final displayRotation = coordinateSystem.rotationForSide( + localRotation!, + isAttack: widget.isAttack, + ); // Calculate anchor point - bottom center of the cone, based on current length // This is exactly like how abilities calculate their anchor points final anchorPoint = viewConeUtility.getAnchorPoint(); - final utilityChild = - UtilityData.utilityWidgets[widget.utility.type]!.createWidget( - id: widget.id, - rotation: localRotation, - length: localLength, - ); + final utilityChild = UtilityData.utilityWidgets[widget.utility.type]! + .createWidget( + id: widget.id, + rotation: displayRotation, + length: localLength, + ); return Positioned( - left: coordinateSystem.coordinateToScreen(widget.utility.position).dx, - top: coordinateSystem.coordinateToScreen(widget.utility.position).dy, + left: screenPositionForWidget( + widget: widget.utility, + coordinateSystem: coordinateSystem, + mapScale: mapScale, + agentSize: settings.agentSize, + abilitySize: settings.abilitySize, + isAttack: widget.isAttack, + ).dx, + top: screenPositionForWidget( + widget: widget.utility, + coordinateSystem: coordinateSystem, + mapScale: mapScale, + agentSize: settings.agentSize, + abilitySize: settings.abilitySize, + isAttack: widget.isAttack, + ).dy, child: RotatableWidget( - rotation: localRotation!, + rotation: displayRotation, isDragging: isDragging, origin: anchorPoint, // Position the rotation handle at the top (where the cone extends to) @@ -134,12 +159,15 @@ class _PlacedViewConeWidgetState extends ConsumerState { currentPositionNormalized.dy, currentPositionNormalized.dx, ); - final newRotation = currentAngle + (math.pi / 2); + final sideRotation = currentAngle + (math.pi / 2); + final newRotation = coordinateSystem.rotationFromSide( + sideRotation, + isAttack: widget.isAttack, + ); // Calculate new length from distance - double newLength = coordinateSystem.normalize( - currentPositionNormalized.distance, - ) / + double newLength = + coordinateSystem.normalize(currentPositionNormalized.distance) / ref.watch(screenZoomProvider); // Clamp length to valid range @@ -154,11 +182,9 @@ class _PlacedViewConeWidgetState extends ConsumerState { }); }, onPanEnd: (details) { - ref.read(utilityProvider.notifier).updateRotation( - index, - localRotation!, - localLength ?? 50, - ); + ref + .read(utilityProvider.notifier) + .updateRotation(index, localRotation!, localLength ?? 50); setState(() { rotationOrigin = Offset.zero; @@ -177,17 +203,15 @@ class _PlacedViewConeWidgetState extends ConsumerState { final rotatedPos = rotateOffset( renderObject.globalToLocal(position), scaledAnchor, - localRotation!, + displayRotation, ); - return ref - .read(screenZoomProvider.notifier) - .zoomOffset(rotatedPos); + return ref.read(screenZoomProvider.notifier).zoomOffset(rotatedPos); }, feedback: Opacity( opacity: Settings.feedbackOpacity, child: Transform.rotate( - angle: localRotation!, + angle: displayRotation, alignment: Alignment.topLeft, origin: anchorPoint.scale( coordinateSystem.scaleFactor * ref.watch(screenZoomProvider), @@ -196,10 +220,10 @@ class _PlacedViewConeWidgetState extends ConsumerState { child: ZoomTransform( child: UtilityData.utilityWidgets[widget.utility.type]! .createWidget( - id: null, - rotation: localRotation, - length: localLength, - ), + id: null, + rotation: displayRotation, + length: localLength, + ), ), ), ), diff --git a/lib/widgets/draggable_widgets/utilities/view_cone_widget.dart b/lib/widgets/draggable_widgets/utilities/view_cone_widget.dart index 36e1d56c..d6fda556 100644 --- a/lib/widgets/draggable_widgets/utilities/view_cone_widget.dart +++ b/lib/widgets/draggable_widgets/utilities/view_cone_widget.dart @@ -80,11 +80,12 @@ class ViewConeWidget extends ConsumerWidget { final totalHeight = totalHeightVirtual * coord.scaleFactor; final totalWidth = totalWidthVirtual * coord.scaleFactor; - final resolvedWorldOrigin = worldOrigin ?? + final resolvedWorldOrigin = + worldOrigin ?? (placedUtility == null ? null : placedUtility.position + - coord.virtualOffsetToWorld(anchorPointVirtual)); + coord.virtualOffsetToWorld(anchorPointVirtual)); final resolvedElevation = visionElevation ?? placedUtility?.visionElevation; final debugEnabled = ref.watch(viewConeDebugProvider); List? visibilityPolygon; @@ -96,6 +97,11 @@ class ViewConeWidget extends ConsumerWidget { VisionGeometryMap? geometry; if (resolvedWorldOrigin != null) { final mapState = ref.watch(mapProvider); + final sideWorldOrigin = coord.positionForSide( + canonicalPosition: resolvedWorldOrigin, + reflectionOffset: Offset.zero, + isAttack: mapState.isAttack, + ); geometry = ref .watch(viewConeGeometryProvider(mapState.currentMap)) .asData @@ -103,11 +109,11 @@ class ViewConeWidget extends ConsumerWidget { if (geometry != null) { final inferredHeight = geometry.inferredHeightAt( isAttack: mapState.isAttack, - position: resolvedWorldOrigin, + position: sideWorldOrigin, ); final layer = geometry.layerForPosition( isAttack: mapState.isAttack, - position: resolvedWorldOrigin, + position: sideWorldOrigin, elevationOverride: resolvedElevation, ); // Placed free cones normally pass their drag-preview rotation directly, @@ -115,12 +121,9 @@ class ViewConeWidget extends ConsumerWidget { // that only supplies the persisted utility id. final effectiveRotation = rotation ?? placedUtility?.rotation ?? 0; final screenZoom = ref.watch(screenZoomProvider).clamp(1.0, 8.0); - // MapProvider.switchSide mirrors every placed item before toggling the - // side. The resolved origin is therefore already in the current map's - // display frame and pairs with the correspondingly mirrored layer. final worldPolygon = VisionPolygon.compute( layer: layer, - origin: resolvedWorldOrigin, + origin: sideWorldOrigin, facingAngle: effectiveRotation - pi / 2, coneAngle: angle * pi / 180, range: coord.virtualLengthToWorld(currentLength), @@ -134,7 +137,7 @@ class ViewConeWidget extends ConsumerWidget { final sine = sin(inverseRotation); final apex = Offset(containerWidth / 2, containerHeight); Offset toLocal(Offset point) { - final delta = point - resolvedWorldOrigin; + final delta = point - sideWorldOrigin; final local = Offset( delta.dx * cosine - delta.dy * sine, delta.dx * sine + delta.dy * cosine, @@ -142,9 +145,7 @@ class ViewConeWidget extends ConsumerWidget { return apex + coord.worldOffsetToScreen(local); } - visibilityPolygon = [ - for (final point in worldPolygon) toLocal(point), - ]; + visibilityPolygon = [for (final point in worldPolygon) toLocal(point)]; if (debugEnabled) { debugMatchedSegments = [ for (final segment in layer.matchedBoundarySegments) @@ -176,13 +177,14 @@ class ViewConeWidget extends ConsumerWidget { ? layer.debugCollisionGroups : layer.boundary?.collisionGroups ?? layer.collisionGroups; for (final candidate in debugGroups) { - final group = activeGroupsById[candidate.id] ?? + final group = + activeGroupsById[candidate.id] ?? observerGroupsById[candidate.id] ?? candidate; if (group.isOuterBoundary) continue; for (final segment in group.segments) { final distanceSquared = visionDistanceSquaredToSegment( - resolvedWorldOrigin, + sideWorldOrigin, segment, ); if (distanceSquared < nearestDistanceSquared) { @@ -192,33 +194,32 @@ class ViewConeWidget extends ConsumerWidget { } } } - final nearestCoverage = nearestGroup == null || + final nearestCoverage = + nearestGroup == null || layer.layerIndex >= nearestGroup.coverageByLayer.length ? null : nearestGroup.coverageByLayer[layer.layerIndex]; final summary = inferredHeight == null ? 'fallback ${formatVisionElevation(layer.elevation)}' : 'height ${formatVisionElevation(inferredHeight)} ' - 'slice ${formatVisionElevation(layer.elevation)} ' - '${layer.collisionGroups.where((group) => group.hasEvidenceInLayer(layer.layerIndex)).length}/' - '${layer.collisionGroups.length} contours evidenced ' - '${layer.matchedSourceSegments.length}/' - '${layer.matchedSourceSegments.length + layer.rejectedSegments.length} Riot edges aligned'; + 'slice ${formatVisionElevation(layer.elevation)} ' + '${layer.collisionGroups.where((group) => group.hasEvidenceInLayer(layer.layerIndex)).length}/' + '${layer.collisionGroups.length} contours evidenced ' + '${layer.matchedSourceSegments.length}/' + '${layer.matchedSourceSegments.length + layer.rejectedSegments.length} Riot edges aligned'; debugLabel = nearestGroup == null ? summary : '$summary\nnearest ${nearestGroup.id} ' - '${nearestGroupIsActive ? 'active' : 'inactive candidate'} ' - '${nearestGroup.confidence.name} ' - '${((nearestCoverage ?? 0) * 100).round()}%'; + '${nearestGroupIsActive ? 'active' : 'inactive candidate'} ' + '${nearestGroup.confidence.name} ' + '${((nearestCoverage ?? 0) * 100).round()}%'; } } } final contextMenuItems = placedUtility == null ? null - : [ - ...buildAdjacentPageCopyMenuItems(ref, placedUtility.id), - ]; + : [...buildAdjacentPageCopyMenuItems(ref, placedUtility.id)]; return SizedBox( width: totalWidth, @@ -434,14 +435,8 @@ class ViewConePainter extends CustomPainter { !listEquals(oldDelegate.visibilityPolygon, visibilityPolygon) || !listEquals(oldDelegate.debugMatchedSegments, debugMatchedSegments) || !listEquals(oldDelegate.debugRiotSegments, debugRiotSegments) || - !listEquals( - oldDelegate.debugRejectedSegments, - debugRejectedSegments, - ) || - !listEquals( - oldDelegate.debugBoundarySegments, - debugBoundarySegments, - ) || + !listEquals(oldDelegate.debugRejectedSegments, debugRejectedSegments) || + !listEquals(oldDelegate.debugBoundarySegments, debugBoundarySegments) || oldDelegate.debugLabel != debugLabel; } } diff --git a/lib/widgets/line_up_line_painter.dart b/lib/widgets/line_up_line_painter.dart index 63b0f95f..30b9c764 100644 --- a/lib/widgets/line_up_line_painter.dart +++ b/lib/widgets/line_up_line_painter.dart @@ -11,8 +11,8 @@ import 'package:icarus/providers/strategy_settings_provider.dart'; final lineUpCanvasResizeProvider = NotifierProvider( - LineUpCanvasResizeNotifier.new, -); + LineUpCanvasResizeNotifier.new, + ); class LineUpCanvasResizeNotifier extends Notifier { @override @@ -49,8 +49,9 @@ class _LineUpLinePainterState extends ConsumerState { }); final resizeCounter = ref.watch(lineUpCanvasResizeProvider); - final currentMap = - ref.watch(mapProvider.select((state) => state.currentMap)); + final currentMap = ref.watch( + mapProvider.select((state) => state.currentMap), + ); final mapScale = Maps.mapScale[currentMap] ?? 1.0; return IgnorePointer( @@ -67,6 +68,7 @@ class _LineUpLinePainterState extends ConsumerState { ref.watch(strategySettingsProvider).agentSize, ), mapScale: mapScale, + isAttack: ref.watch(mapProvider).isAttack, currentAgent: ref.watch(lineUpProvider).currentAgent, currentAbility: ref.watch(lineUpProvider).currentAbility, ), @@ -87,6 +89,7 @@ class LinePainter extends CustomPainter { final double abilitySize; final double agentSize; final double mapScale; + final bool isAttack; final PlacedAgent? currentAgent; final PlacedAbility? currentAbility; final int resizeCounter; @@ -100,6 +103,7 @@ class LinePainter extends CustomPainter { required this.abilitySize, required this.agentSize, required this.mapScale, + required this.isAttack, this.currentAgent, this.currentAbility, }); @@ -122,6 +126,7 @@ class LinePainter extends CustomPainter { final startPosition = screenAnchorForAgent( agent: group.agent, coordinateSystem: coordinateSystem, + isAttack: isAttack, ); for (final item in group.items) { @@ -129,6 +134,7 @@ class LinePainter extends CustomPainter { ability: item.ability, coordinateSystem: coordinateSystem, mapScale: mapScale, + isAttack: isAttack, ); canvas.drawLine( @@ -152,6 +158,7 @@ class LinePainter extends CustomPainter { oldDelegate.abilitySize != abilitySize || oldDelegate.agentSize != agentSize || oldDelegate.mapScale != mapScale || + oldDelegate.isAttack != isAttack || oldDelegate.resizeCounter != resizeCounter; } return false; diff --git a/lib/widgets/line_up_placer.dart b/lib/widgets/line_up_placer.dart index 2bed8a8d..9b6edcea 100644 --- a/lib/widgets/line_up_placer.dart +++ b/lib/widgets/line_up_placer.dart @@ -31,172 +31,193 @@ class _LineupPositionWidgetState extends ConsumerState { final coordinateSystem = CoordinateSystem.instance; // log(ref.watch(mapProvider).isAttack.toString()); - return LayoutBuilder(builder: (context, constraints) { - final lineUp = ref.watch(lineUpProvider); - final previewAgent = - ref.read(lineUpProvider.notifier).getCurrentPreviewAgent(); - final isLockedAddItemMode = - ref.read(lineUpProvider.notifier).isLockedAddItemMode; - return DragTarget( - builder: (context, candidateData, rejectedData) { - return Stack( - children: [ - const Positioned.fill( - child: CurrentLineUpPainter(), - ), - if (lineUp.currentAgent == null && lineUp.currentGroupId == null) - Align( - alignment: Alignment.center, - child: Container( - margin: const EdgeInsets.all(16), - padding: - const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: Settings.tacticalVioletTheme.primary, - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: Settings.tacticalVioletTheme.border, + return LayoutBuilder( + builder: (context, constraints) { + final lineUp = ref.watch(lineUpProvider); + final previewAgent = ref + .read(lineUpProvider.notifier) + .getCurrentPreviewAgent(); + final isLockedAddItemMode = ref + .read(lineUpProvider.notifier) + .isLockedAddItemMode; + return DragTarget( + builder: (context, candidateData, rejectedData) { + return Stack( + children: [ + const Positioned.fill(child: CurrentLineUpPainter()), + if (lineUp.currentAgent == null && + lineUp.currentGroupId == null) + Align( + alignment: Alignment.center, + child: Container( + margin: const EdgeInsets.all(16), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + decoration: BoxDecoration( + color: Settings.tacticalVioletTheme.primary, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Settings.tacticalVioletTheme.border, + ), + boxShadow: const [Settings.cardForegroundBackdrop], + ), + child: Text( + "Drag an agent to the map to start placing", + style: ShadTheme.of( + context, + ).textTheme.small.copyWith(color: Colors.white), ), - boxShadow: const [Settings.cardForegroundBackdrop], - ), - child: Text( - "Drag an agent to the map to start placing", - style: ShadTheme.of(context) - .textTheme - .small - .copyWith(color: Colors.white), ), ), - ), - if (lineUp.currentAbility != null) - PlacedAbilityWidget( - rotation: lineUp.currentAbility!.rotation, - data: lineUp.currentAbility!, - ability: lineUp.currentAbility!, - id: lineUp.currentAbility!.id, - length: lineUp.currentAbility!.length, - isLineUp: true, - onDragEnd: (details, _) { - RenderBox renderBox = - context.findRenderObject() as RenderBox; - Offset localOffset = - renderBox.globalToLocal(details.offset); - // Updating info + if (lineUp.currentAbility != null) + PlacedAbilityWidget( + rotation: lineUp.currentAbility!.rotation, + data: lineUp.currentAbility!, + ability: lineUp.currentAbility!, + id: lineUp.currentAbility!.id, + length: lineUp.currentAbility!.length, + isLineUp: true, + onDragEnd: (details, _) { + RenderBox renderBox = + context.findRenderObject() as RenderBox; + Offset localOffset = renderBox.globalToLocal( + details.offset, + ); + // Updating info - final mapState = ref.read(mapProvider); - final mapScale = Maps.mapScale[mapState.currentMap] ?? 1.0; - final abilitySize = - ref.read(strategySettingsProvider).abilitySize; + final mapState = ref.read(mapProvider); + final mapScale = + Maps.mapScale[mapState.currentMap] ?? 1.0; + final abilitySize = ref + .read(strategySettingsProvider) + .abilitySize; - final abilityData = - lineUp.currentAbility!.data.abilityData!; - final virtualOffset = - storedAbilityPositionForRenderedScreenPosition( - ability: abilityData, - coordinateSystem: coordinateSystem, - renderedScreenPosition: localOffset, - mapScale: mapScale, - abilitySize: abilitySize, - ); - final safeArea = storedAbilityAnchor( - ability: abilityData, - mapScale: mapScale, - ); + final abilityData = + lineUp.currentAbility!.data.abilityData!; + final virtualOffset = + storedAbilityPositionForRenderedScreenPosition( + ability: abilityData, + coordinateSystem: coordinateSystem, + renderedScreenPosition: localOffset, + mapScale: mapScale, + abilitySize: abilitySize, + isAttack: mapState.isAttack, + ); + final safeArea = storedAbilityAnchor( + ability: abilityData, + mapScale: mapScale, + ); - if (coordinateSystem.isOutOfBounds( - virtualOffset.translate(safeArea.dx, safeArea.dy))) { - ref.read(lineUpProvider.notifier).removeCurrentAbility(); - return; - } + if (coordinateSystem.isOutOfBounds( + virtualOffset.translate(safeArea.dx, safeArea.dy), + )) { + ref + .read(lineUpProvider.notifier) + .removeCurrentAbility(); + return; + } - ref - .read(lineUpProvider.notifier) - .updateCurrentAbilityPosition(virtualOffset); - }, - ), - if (previewAgent != null && isLockedAddItemMode) - PlacedLineupAgentWidget( - agent: previewAgent, - draggable: false, - ), - if (lineUp.currentAgent != null && !isLockedAddItemMode) - PlacedLineupAgentWidget( - agent: lineUp.currentAgent!, - draggable: true, - onDragEnd: (details) { - RenderBox renderBox = - context.findRenderObject() as RenderBox; - Offset localOffset = - renderBox.globalToLocal(details.offset); + ref + .read(lineUpProvider.notifier) + .updateCurrentAbilityPosition(virtualOffset); + }, + ), + if (previewAgent != null && isLockedAddItemMode) + PlacedLineupAgentWidget( + agent: previewAgent, + draggable: false, + ), + if (lineUp.currentAgent != null && !isLockedAddItemMode) + PlacedLineupAgentWidget( + agent: lineUp.currentAgent!, + draggable: true, + onDragEnd: (details) { + RenderBox renderBox = + context.findRenderObject() as RenderBox; + Offset localOffset = renderBox.globalToLocal( + details.offset, + ); - final virtualOffset = - storedAgentPositionForRenderedScreenPosition( - coordinateSystem: coordinateSystem, - renderedScreenPosition: localOffset, - agentSize: ref.read(strategySettingsProvider).agentSize, - ); + final virtualOffset = + storedAgentPositionForRenderedScreenPosition( + coordinateSystem: coordinateSystem, + renderedScreenPosition: localOffset, + agentSize: ref + .read(strategySettingsProvider) + .agentSize, + isAttack: ref.read(mapProvider).isAttack, + ); - ref - .read(lineUpProvider.notifier) - .updateCurrentAgentPosition(virtualOffset); - }, - ), - ], - ); - }, - onAcceptWithDetails: (details) { - RenderBox renderBox = context.findRenderObject() as RenderBox; - Offset localOffset = renderBox.globalToLocal(details.offset); - const uuid = Uuid(); + ref + .read(lineUpProvider.notifier) + .updateCurrentAgentPosition(virtualOffset); + }, + ), + ], + ); + }, + onAcceptWithDetails: (details) { + RenderBox renderBox = context.findRenderObject() as RenderBox; + Offset localOffset = renderBox.globalToLocal(details.offset); + const uuid = Uuid(); - if (details.data is AgentData) { - if (isLockedAddItemMode) { - Settings.showToast( - message: - "You can only add abilities for the selected lineup agent right now.", - backgroundColor: Settings.tacticalVioletTheme.destructive, - ); - return; - } + if (details.data is AgentData) { + if (isLockedAddItemMode) { + Settings.showToast( + message: + "You can only add abilities for the selected lineup agent right now.", + backgroundColor: Settings.tacticalVioletTheme.destructive, + ); + return; + } - final agentPosition = storedAgentPositionForRenderedScreenPosition( - coordinateSystem: coordinateSystem, - renderedScreenPosition: localOffset, - agentSize: ref.read(strategySettingsProvider).agentSize, - ); - PlacedAgent placedAgent = PlacedAgent( - id: uuid.v4(), - type: (details.data as AgentData).type, - position: agentPosition, - isAlly: ref.read(teamProvider), - ); + final agentPosition = + storedAgentPositionForRenderedScreenPosition( + coordinateSystem: coordinateSystem, + renderedScreenPosition: localOffset, + agentSize: ref.read(strategySettingsProvider).agentSize, + isAttack: ref.read(mapProvider).isAttack, + ); + PlacedAgent placedAgent = PlacedAgent( + id: uuid.v4(), + type: (details.data as AgentData).type, + position: agentPosition, + isAlly: ref.read(teamProvider), + ); - ref.read(lineUpProvider.notifier).startNewGroup(placedAgent); - ref - .read(abilityBarProvider.notifier) - .updateData(AgentData.agents[placedAgent.type]!); - } else if (details.data is AbilityInfo) { - final abilityInfo = details.data as AbilityInfo; - final abilityPosition = - storedAbilityPositionForRenderedScreenPosition( - ability: abilityInfo.abilityData!, - coordinateSystem: coordinateSystem, - renderedScreenPosition: localOffset, - mapScale: Maps.mapScale[ref.read(mapProvider).currentMap] ?? 1.0, - abilitySize: ref.read(strategySettingsProvider).abilitySize, - ); - PlacedAbility placedAbility = PlacedAbility( - id: uuid.v4(), - data: abilityInfo, - position: abilityPosition, - isAlly: ref.read(teamProvider), - ); + ref.read(lineUpProvider.notifier).startNewGroup(placedAgent); + ref + .read(abilityBarProvider.notifier) + .updateData(AgentData.agents[placedAgent.type]!); + } else if (details.data is AbilityInfo) { + final abilityInfo = details.data as AbilityInfo; + final abilityPosition = + storedAbilityPositionForRenderedScreenPosition( + ability: abilityInfo.abilityData!, + coordinateSystem: coordinateSystem, + renderedScreenPosition: localOffset, + mapScale: + Maps.mapScale[ref.read(mapProvider).currentMap] ?? 1.0, + abilitySize: ref.read(strategySettingsProvider).abilitySize, + isAttack: ref.read(mapProvider).isAttack, + ); + PlacedAbility placedAbility = PlacedAbility( + id: uuid.v4(), + data: abilityInfo, + position: abilityPosition, + isAlly: ref.read(teamProvider), + ); - ref.read(lineUpProvider.notifier).setCurrentAbility(placedAbility); - } - }, - onLeave: (data) {}, - ); - }); + ref + .read(lineUpProvider.notifier) + .setCurrentAbility(placedAbility); + } + }, + onLeave: (data) {}, + ); + }, + ); } } diff --git a/lib/widgets/line_up_widget.dart b/lib/widgets/line_up_widget.dart index 6c0f0540..e4444c9e 100644 --- a/lib/widgets/line_up_widget.dart +++ b/lib/widgets/line_up_widget.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/agents.dart'; +import 'package:icarus/const/abilities.dart'; import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/const/line_provider.dart'; import 'package:icarus/const/maps.dart'; @@ -11,10 +12,8 @@ import 'package:icarus/widgets/draggable_widgets/ability/ability_visibility_cont import 'package:icarus/widgets/draggable_widgets/agents/agent_widget.dart'; class LineUpGroupAgentWidget extends ConsumerWidget { - LineUpGroupAgentWidget({ - Key? key, - required this.group, - }) : super(key: key ?? ValueKey('lineup-agent-widget-${group.id}')); + LineUpGroupAgentWidget({Key? key, required this.group}) + : super(key: key ?? ValueKey('lineup-agent-widget-${group.id}')); final LineUpGroup group; @@ -22,10 +21,12 @@ class LineUpGroupAgentWidget extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final coordinateSystem = CoordinateSystem.instance; final agentSize = ref.watch(strategySettingsProvider).agentSize; + final isAttack = ref.watch(mapProvider).isAttack; final agentScreen = screenPositionForWidget( widget: group.agent, coordinateSystem: coordinateSystem, agentSize: agentSize, + isAttack: isAttack, ); return Positioned( @@ -43,13 +44,8 @@ class LineUpGroupAgentWidget extends ConsumerWidget { } class LineUpItemAbilityWidget extends ConsumerWidget { - LineUpItemAbilityWidget({ - Key? key, - required this.groupId, - required this.item, - }) : super( - key: key ?? ValueKey('lineup-ability-widget-$groupId-${item.id}'), - ); + LineUpItemAbilityWidget({Key? key, required this.groupId, required this.item}) + : super(key: key ?? ValueKey('lineup-ability-widget-$groupId-${item.id}')); final String groupId; final LineUpItem item; @@ -57,17 +53,24 @@ class LineUpItemAbilityWidget extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final coordinateSystem = CoordinateSystem.instance; - final currentMap = - ref.watch(mapProvider.select((state) => state.currentMap)); + final currentMap = ref.watch( + mapProvider.select((state) => state.currentMap), + ); + final isAttack = ref.watch(mapProvider).isAttack; final mapScale = Maps.mapScale[currentMap] ?? 1.0; - final isRotatable = item.ability.rotation != 0; final abilitySize = ref.watch(strategySettingsProvider).abilitySize; final abilityScreen = screenPositionForWidget( widget: item.ability, coordinateSystem: coordinateSystem, mapScale: mapScale, abilitySize: abilitySize, + isAttack: isAttack, + ); + final displayRotation = coordinateSystem.rotationForSide( + item.ability.rotation, + isAttack: isAttack, ); + final shouldRotate = isRotatable(item.ability.data.abilityData!); final contextMenuItems = buildAbilityContextMenuItems( ref, item.ability, @@ -75,9 +78,9 @@ class LineUpItemAbilityWidget extends ConsumerWidget { lineUpItemId: item.id, includeDelete: true, ); - final rawAbilityChild = isRotatable + final rawAbilityChild = shouldRotate ? Transform.rotate( - angle: item.ability.rotation, + angle: displayRotation, alignment: Alignment.topLeft, origin: item.ability.data.abilityData! .getAnchorPoint(mapScale: mapScale, abilitySize: abilitySize) @@ -91,7 +94,7 @@ class LineUpItemAbilityWidget extends ConsumerWidget { mapScale: mapScale, lineUpId: groupId, lineUpItemId: item.id, - rotation: item.ability.rotation, + rotation: displayRotation, length: item.ability.length, armLengthsMeters: item.ability.armLengthsMeters, visualState: item.ability.visualState, @@ -105,7 +108,7 @@ class LineUpItemAbilityWidget extends ConsumerWidget { mapScale: mapScale, lineUpId: groupId, lineUpItemId: item.id, - rotation: item.ability.rotation, + rotation: displayRotation, length: item.ability.length, armLengthsMeters: item.ability.armLengthsMeters, visualState: item.ability.visualState, @@ -129,9 +132,7 @@ class LineUpAgentWidget extends StatelessWidget { @override Widget build(BuildContext context) { - return LineUpGroupAgentWidget( - group: LineUpGroup.fromLegacyLineUp(lineUp), - ); + return LineUpGroupAgentWidget(group: LineUpGroup.fromLegacyLineUp(lineUp)); } } diff --git a/lib/widgets/page_transition_overlay.dart b/lib/widgets/page_transition_overlay.dart index 0442a4bb..4e7bc2b3 100644 --- a/lib/widgets/page_transition_overlay.dart +++ b/lib/widgets/page_transition_overlay.dart @@ -15,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/canonical_positioned.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'; @@ -26,6 +27,7 @@ Offset _overlayScreenPosition({ required double agentSize, required double mapScale, required double abilitySize, + required bool isAttack, Offset? coordinatePosition, }) { final screen = screenPositionForWidget( @@ -35,6 +37,7 @@ Offset _overlayScreenPosition({ mapScale: mapScale, agentSize: agentSize, abilitySize: abilitySize, + isAttack: isAttack, ); if (widget is PlacedViewConeAgent) { return screen - @@ -94,6 +97,7 @@ class TransitionEntriesLayer extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final mapScale = Maps.mapScale[ref.watch(mapProvider).currentMap] ?? 1.0; + final isAttack = ref.watch(mapProvider).isAttack; final orderedEntries = [...entries]..sort(PageLayering.compareEntries); final renderer = _EntryRenderer( coordinateSystem: CoordinateSystem.instance, @@ -103,6 +107,7 @@ class TransitionEntriesLayer extends ConsumerWidget { abilitySize: abilitySize, agentPaths: agentPaths, t: t, + isAttack: isAttack, ); return IgnorePointer( @@ -126,6 +131,7 @@ class _EntryRenderer { required this.abilitySize, required this.agentPaths, required this.t, + required this.isAttack, }); final CoordinateSystem coordinateSystem; @@ -135,6 +141,7 @@ class _EntryRenderer { final double abilitySize; final Map agentPaths; final double t; + final bool isAttack; Offset _startScreenPosition(PageTransitionEntry entry) { return _overlayScreenPosition( @@ -144,6 +151,7 @@ class _EntryRenderer { agentSize: agentSize, mapScale: mapScale, abilitySize: abilitySize, + isAttack: isAttack, ); } @@ -155,9 +163,17 @@ class _EntryRenderer { agentSize: agentSize, mapScale: mapScale, abilitySize: abilitySize, + isAttack: isAttack, ); } + double _canonicalHorizontalTranslation(double screenTranslationX) { + final worldTranslation = coordinateSystem.screenWidthToWorld( + screenTranslationX, + ); + return isAttack ? worldTranslation : -worldTranslation; + } + Widget buildEntry(PageTransitionEntry entry) { final directionalOffset = coordinateSystem.scale(28); final directionSign = @@ -187,11 +203,12 @@ class _EntryRenderer { ); final coordinatePosition = entry.startPos + Offset( - coordinateSystem.screenWidthToWorld(screenTranslation.dx), + _canonicalHorizontalTranslation(screenTranslation.dx), 0, ); - final start = _startScreenPosition(entry) - .translate(screenTranslation.dx, screenTranslation.dy); + final start = _startScreenPosition( + entry, + ).translate(screenTranslation.dx, screenTranslation.dy); return _overlayItem( key: ValueKey('disappear_${entry.id}'), widget: entry.from!, @@ -230,6 +247,7 @@ class _EntryRenderer { agentSize: agentSize, mapScale: mapScale, abilitySize: abilitySize, + isAttack: isAttack, ); return _overlayItem( key: ValueKey('move_${entry.id}'), @@ -270,11 +288,12 @@ class _EntryRenderer { ); final coordinatePosition = entry.endPos + Offset( - coordinateSystem.screenWidthToWorld(screenTranslation.dx), + _canonicalHorizontalTranslation(screenTranslation.dx), 0, ); - final end = _endScreenPosition(entry) - .translate(screenTranslation.dx, screenTranslation.dy); + final end = _endScreenPosition( + entry, + ).translate(screenTranslation.dx, screenTranslation.dy); // Images fade in early (front-loaded), like the drawing layer. final appearOpacity = entry.to is PlacedImage ? earlyFadeInOpacity(t) : t; @@ -363,13 +382,16 @@ class _EntryRenderer { double? customLength, double? deadStateProgress, }) { + final displayRotation = rotation == null + ? null + : coordinateSystem.rotationForSide(rotation, isAttack: isAttack); Widget child = PlacedWidgetPreview.build( widget, mapScale, coordinatePosition: coordinatePosition, length: length, armLengthsMeters: armLengthsMeters, - rotation: rotation, + rotation: displayRotation, scale: scale, textSize: textSize, customDiameter: customDiameter, @@ -379,8 +401,8 @@ class _EntryRenderer { agentSize: agentSize, abilitySize: abilitySize, ); // central factory (below) - if (_shouldRotate(widget, rotation)) { - final angle = rotation ?? 0; + if (_shouldRotate(widget, displayRotation)) { + final angle = displayRotation ?? 0; if (widget is PlacedAbility) { child = Transform.rotate( angle: angle, @@ -404,20 +426,21 @@ class _EntryRenderer { mapScale: mapScale, agentSize: agentSize, abilitySize: abilitySize, - ).scale( - coordinateSystem.scaleFactor, - coordinateSystem.scaleFactor, - ), + ).scale(coordinateSystem.scaleFactor, coordinateSystem.scaleFactor), child: child, ); } } - return Positioned( - key: key, - left: pos.dx, - top: pos.dy, - child: Opacity(opacity: opacity, child: child), - ); + final fadedChild = Opacity(opacity: opacity, child: child); + if (widget is PlacedText || widget is PlacedImage) { + return CanonicalPositionedBox( + key: key, + attackScreenPosition: pos, + isAttack: isAttack, + child: fadedChild, + ); + } + return Positioned(key: key, left: pos.dx, top: pos.dy, child: fadedChild); } bool _shouldRotate(PlacedWidget widget, double? rotation) { @@ -435,7 +458,7 @@ class _EntryRenderer { } return isRotatable(ability); } - return widget is PlacedUtility; + return widget is PlacedUtility && UtilityData.usesRotation(widget.type); } } @@ -754,6 +777,7 @@ class TemporaryWidgetBuilder extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final state = ref.watch(transitionProvider); final mapScale = Maps.mapScale[ref.read(mapProvider).currentMap]!; + final isAttack = ref.watch(mapProvider).isAttack; final abilitySize = state.startAbilitySize; final agentSize = state.startAgentSize; final orderedWidgets = [...state.allWidgets] @@ -769,6 +793,7 @@ class TemporaryWidgetBuilder extends ConsumerWidget { mapScale: mapScale, abilitySize: abilitySize, agentSize: agentSize, + isAttack: isAttack, ), ], ), @@ -780,6 +805,7 @@ class TemporaryWidgetBuilder extends ConsumerWidget { required double mapScale, required double abilitySize, required double agentSize, + required bool isAttack, }) { final coord = CoordinateSystem.instance; final scaledPosition = _overlayScreenPosition( @@ -788,14 +814,22 @@ class TemporaryWidgetBuilder extends ConsumerWidget { agentSize: agentSize, mapScale: mapScale, abilitySize: abilitySize, + isAttack: isAttack, ); - - if (widget is PlacedUtility && widget.rotation != 0) { + final canonicalRotation = PageTransitionEntry.rotationOf(widget); + final displayRotation = canonicalRotation == null + ? null + : coord.rotationForSide(canonicalRotation, isAttack: isAttack); + + if (widget is PlacedUtility && + UtilityData.usesRotation(widget.type) && + displayRotation != null && + displayRotation != 0) { return Positioned( left: scaledPosition.dx, top: scaledPosition.dy, child: Transform.rotate( - angle: widget.rotation, + angle: displayRotation, alignment: Alignment.topLeft, origin: utilityAnchorForScale( utility: widget, @@ -810,13 +844,15 @@ class TemporaryWidgetBuilder extends ConsumerWidget { widget, mapScale, length: widget.length, + rotation: displayRotation, agentSize: agentSize, abilitySize: abilitySize, ), ), ); } else if (widget is PlacedAbility && - widget.rotation != 0 && + displayRotation != null && + displayRotation != 0 && widget.data.abilityData != null && !(widget.visualState.showVisionCone && AbilityVisionConeSpec.forAbility(widget.data) != null) && @@ -825,7 +861,7 @@ class TemporaryWidgetBuilder extends ConsumerWidget { left: scaledPosition.dx, top: scaledPosition.dy, child: Transform.rotate( - angle: widget.rotation, + angle: displayRotation, alignment: Alignment.topLeft, origin: (widget) .data @@ -836,12 +872,28 @@ class TemporaryWidgetBuilder extends ConsumerWidget { widget, mapScale, length: widget.length, + rotation: displayRotation, armLengthsMeters: widget.armLengthsMeters, agentSize: agentSize, abilitySize: abilitySize, ), ), ); + } else if (widget is PlacedText || widget is PlacedImage) { + return CanonicalPositionedBox( + attackScreenPosition: scaledPosition, + isAttack: isAttack, + child: PlacedWidgetPreview.build( + widget, + mapScale, + length: widget is PlacedAbility ? widget.length : null, + rotation: displayRotation, + armLengthsMeters: + widget is PlacedAbility ? widget.armLengthsMeters : null, + agentSize: agentSize, + abilitySize: abilitySize, + ), + ); } else { return Positioned( left: scaledPosition.dx, @@ -850,6 +902,7 @@ class TemporaryWidgetBuilder extends ConsumerWidget { widget, mapScale, length: widget is PlacedAbility ? widget.length : null, + rotation: displayRotation, armLengthsMeters: widget is PlacedAbility ? widget.armLengthsMeters : null, agentSize: agentSize, diff --git a/pubspec.yaml b/pubspec.yaml index 10c85c48..fcf2f0fa 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: "A new Flutter project." publish_to: "none" # Remove this line if you wish to publish to pub.dev -version: 4.6.1+96 #version number +version: 4.6.1+97 #version number environment: sdk: ">=3.4.3 <4.0.0" diff --git a/test/ability_anchor_scaling_test.dart b/test/ability_anchor_scaling_test.dart index 31ea4ee2..d365fef0 100644 --- a/test/ability_anchor_scaling_test.dart +++ b/test/ability_anchor_scaling_test.dart @@ -162,43 +162,6 @@ void main() { } } }); - - test('side switching is independent of the selected runtime size', () { - const mapScale = 1.2; - final sizeDependentInfos = _representativeAbilityInfos().where((info) { - final ability = info.abilityData!; - final minAnchor = ability.getAnchorPoint( - mapScale: mapScale, - abilitySize: Settings.abilitySizeMin, - ); - final maxAnchor = ability.getAnchorPoint( - mapScale: mapScale, - abilitySize: Settings.abilitySizeMax, - ); - return minAnchor != maxAnchor; - }); - - expect(sizeDependentInfos, isNotEmpty); - for (final info in sizeDependentInfos) { - final atMinimum = _placedAbility(info, id: 'minimum'); - final atMaximum = _placedAbility(info, id: 'maximum'); - - atMinimum.switchSides( - mapScale: mapScale, - abilitySize: Settings.abilitySizeMin, - ); - atMaximum.switchSides( - mapScale: mapScale, - abilitySize: Settings.abilitySizeMax, - ); - - expect( - atMaximum.position, - atMinimum.position, - reason: '${info.abilityData.runtimeType}', - ); - } - }); }); group('agent-scaled widgetAnchor scaling', () { @@ -365,32 +328,6 @@ void main() { expect(restored.dy, closeTo(expectedStoredPosition.dy, 0.0001)); } }); - - test('side switching is independent of runtime agent scale', () { - final atMinimum = representativeAgents.first.deepCopy(); - final atMaximum = representativeAgents.first.deepCopy(); - atMinimum.switchSides(Settings.agentSizeMin); - atMaximum.switchSides(Settings.agentSizeMax); - expect(atMaximum.position, atMinimum.position); - - final roleAtMinimum = PlacedUtility( - id: 'role-min', - type: UtilityType.initiator, - position: const Offset(205, 305), - ); - final roleAtMaximum = roleAtMinimum.copyWith(id: 'role-max'); - roleAtMinimum.switchSides( - mapScale: 1, - agentSize: Settings.agentSizeMin, - abilitySize: Settings.abilitySizeMin, - ); - roleAtMaximum.switchSides( - mapScale: 1, - agentSize: Settings.agentSizeMax, - abilitySize: Settings.abilitySizeMax, - ); - expect(roleAtMaximum.position, roleAtMinimum.position); - }); }); test('ability size changes participate in transaction undo and redo', () { diff --git a/test/canonical_coordinates_migration_test.dart b/test/canonical_coordinates_migration_test.dart new file mode 100644 index 00000000..58c6994a --- /dev/null +++ b/test/canonical_coordinates_migration_test.dart @@ -0,0 +1,287 @@ +import 'dart:math' as math; +import 'dart:ui'; + +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/placed_media_geometry.dart'; +import 'package:icarus/const/settings.dart'; +import 'package:icarus/const/utilities.dart'; +import 'package:icarus/migrations/canonical_coordinates_migration.dart'; +import 'package:icarus/providers/strategy_page.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/providers/strategy_settings_provider.dart'; + +const _worldSize = Size(1000 * (16 / 9), 1000); +const _virtualToWorld = 1000 / 831; + +Offset _legacyDefensePosition(Offset canonical, Offset anchorWorld) { + return Offset( + _worldSize.width - canonical.dx - (anchorWorld.dx * 2), + _worldSize.height - canonical.dy - (anchorWorld.dy * 2), + ); +} + +Offset _virtualAnchorToWorld(Offset anchor) => anchor * _virtualToWorld; + +void _expectOffset(Offset actual, Offset expected) { + expect(actual.dx, closeTo(expected.dx, 0.0001)); + expect(actual.dy, closeTo(expected.dy, 0.0001)); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('CanonicalCoordinatesMigration', () { + test('converts every defense-page object family back to attack storage', + () { + const map = MapValue.bind; + final mapScale = Maps.mapScale[map]!; + const agentCanonical = Offset(120, 210); + const abilityCanonical = Offset(330, 145); + const utilityCanonical = Offset(640, 430); + const textCanonical = Offset(80, 720); + const imageCanonical = Offset(980, 90); + const lineupAgentCanonical = Offset(250, 670); + const lineupAbilityCanonical = Offset(1180, 310); + const abilityRotation = 0.65; + const utilityRotation = 1.15; + + final abilityInfo = AgentData.agents[AgentType.deadlock]!.abilities[2]; + final abilityAnchor = _virtualAnchorToWorld( + abilityInfo.abilityData!.getAnchorPoint( + mapScale: mapScale, + abilitySize: Settings.abilitySize, + ), + ); + final utilityPrototype = PlacedUtility( + id: 'view-cone', + type: UtilityType.viewCone90, + position: Offset.zero, + ) + ..rotation = utilityRotation + math.pi + ..length = 110; + final utilityAnchor = _virtualAnchorToWorld( + UtilityData.utilityWidgets[utilityPrototype.type]!.getAnchorPoint( + id: utilityPrototype.id, + length: utilityPrototype.length, + rotation: utilityPrototype.rotation, + mapScale: mapScale, + agentSize: Settings.agentSize, + abilitySize: Settings.abilitySize, + ), + ); + final text = PlacedText( + id: 'text', + position: Offset.zero, + size: 220, + fontSize: 16, + sizeVersion: worldSizedMediaVersion, + )..text = 'Default hold\nRetake on contact'; + final textFootprint = + PlacedMediaGeometry.legacyTextFootprintInWorld(text); + final image = PlacedImage( + id: 'image', + position: Offset.zero, + aspectRatio: 16 / 9, + scale: 240, + fileExtension: '.png', + sizeVersion: worldSizedMediaVersion, + ); + final imageFootprint = + PlacedMediaGeometry.legacyImageFootprintInWorld(image); + + final page = StrategyPage( + id: 'defense-page', + name: 'Defense', + sortIndex: 0, + isAttack: false, + settings: StrategySettings(), + drawingData: const [], + agentData: [ + PlacedViewConeAgent( + id: 'agent', + type: AgentType.sova, + presetType: UtilityType.viewCone90, + position: _legacyDefensePosition( + agentCanonical, + _virtualAnchorToWorld( + const Offset(Settings.agentSize / 2, Settings.agentSize / 2), + ), + ), + rotation: 0.35 + math.pi, + ), + ], + abilityData: [ + PlacedAbility( + id: 'ability', + data: abilityInfo, + position: _legacyDefensePosition( + abilityCanonical, + abilityAnchor, + ), + rotation: abilityRotation + math.pi, + ), + ], + utilityData: [ + utilityPrototype.copyWith( + position: _legacyDefensePosition( + utilityCanonical, + utilityAnchor, + ), + ), + ], + textData: [ + text.copyWith( + position: _legacyDefensePosition( + textCanonical, + textFootprint.center(Offset.zero), + ), + ), + ], + imageData: [ + image.copyWith( + position: _legacyDefensePosition( + imageCanonical, + imageFootprint.center(Offset.zero), + ), + ), + ], + lineUpGroups: [ + LineUpGroup( + id: 'lineup', + agent: PlacedAgent( + id: 'lineup-agent', + type: AgentType.harbor, + position: _legacyDefensePosition( + lineupAgentCanonical, + _virtualAnchorToWorld( + const Offset( + Settings.agentSize / 2, + Settings.agentSize / 2, + ), + ), + ), + ), + items: [ + LineUpItem( + id: 'lineup-item', + ability: PlacedAbility( + id: 'lineup-ability', + data: abilityInfo, + position: _legacyDefensePosition( + lineupAbilityCanonical, + abilityAnchor, + ), + rotation: abilityRotation + math.pi, + ), + ), + ], + ), + ], + ); + final strategy = StrategyData( + id: 'legacy-defense', + name: 'Legacy defense', + mapData: map, + versionNumber: CanonicalCoordinatesMigration.version - 1, + lastEdited: DateTime.utc(2026, 1, 1), + folderID: null, + pages: [page], + ); + + final migrated = StrategyProvider.migrateToCurrentVersion(strategy); + final migratedPage = migrated.pages.single; + + expect(migrated.versionNumber, Settings.versionNumber); + expect(migratedPage.isAttack, isFalse); + _expectOffset(migratedPage.agentData.single.position, agentCanonical); + expect( + (migratedPage.agentData.single as PlacedViewConeAgent).rotation, + closeTo(0.35, 0.0001), + ); + _expectOffset(migratedPage.abilityData.single.position, abilityCanonical); + expect( + migratedPage.abilityData.single.rotation, + closeTo(abilityRotation, 0.0001), + ); + _expectOffset(migratedPage.utilityData.single.position, utilityCanonical); + expect( + migratedPage.utilityData.single.rotation, + closeTo(utilityRotation, 0.0001), + ); + _expectOffset(migratedPage.textData.single.position, textCanonical); + _expectOffset(migratedPage.imageData.single.position, imageCanonical); + _expectOffset( + migratedPage.lineUpGroups.single.agent.position, + lineupAgentCanonical, + ); + _expectOffset( + migratedPage.lineUpGroups.single.items.single.ability.position, + lineupAbilityCanonical, + ); + expect( + migratedPage.lineUpGroups.single.items.single.ability.rotation, + closeTo(abilityRotation, 0.0001), + ); + }); + + test('does not rewrite attack-page coordinates', () { + final page = StrategyPage( + id: 'attack-page', + name: 'Attack', + sortIndex: 0, + isAttack: true, + settings: StrategySettings(), + drawingData: const [], + agentData: [ + PlacedAgent( + id: 'agent', + type: AgentType.jett, + position: const Offset(123, 456), + ), + ], + abilityData: const [], + utilityData: const [], + textData: const [], + imageData: const [], + ); + final strategy = StrategyData( + id: 'legacy-attack', + name: 'Legacy attack', + mapData: MapValue.ascent, + versionNumber: CanonicalCoordinatesMigration.version - 1, + lastEdited: DateTime.utc(2026, 1, 1), + folderID: null, + pages: [page], + ); + + final migrated = StrategyProvider.migrateToCurrentVersion(strategy); + + expect(identical(migrated.pages.single, page), isTrue); + _expectOffset( + migrated.pages.single.agentData.single.position, + const Offset(123, 456), + ); + }); + + test('is idempotent for current-version strategies', () { + final strategy = StrategyData( + id: 'current', + name: 'Current', + mapData: MapValue.ascent, + versionNumber: Settings.versionNumber, + lastEdited: DateTime.utc(2026, 1, 1), + folderID: null, + pages: const [], + ); + + expect( + identical(StrategyProvider.migrateToCurrentVersion(strategy), strategy), + isTrue, + ); + }); + }); +} diff --git a/test/canonical_coordinates_test.dart b/test/canonical_coordinates_test.dart new file mode 100644 index 00000000..82cbe0e6 --- /dev/null +++ b/test/canonical_coordinates_test.dart @@ -0,0 +1,471 @@ +import 'dart:convert'; +import 'dart:io'; +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/agents.dart'; +import 'package:icarus/const/coordinate_system.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/placed_media_geometry.dart'; +import 'package:icarus/const/transition_data.dart'; +import 'package:icarus/const/utilities.dart'; +import 'package:icarus/providers/ability_provider.dart'; +import 'package:icarus/providers/action_provider.dart'; +import 'package:icarus/providers/agent_provider.dart'; +import 'package:icarus/providers/image_provider.dart'; +import 'package:icarus/providers/map_provider.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/providers/text_provider.dart'; +import 'package:icarus/providers/utility_provider.dart'; +import 'package:icarus/widgets/draggable_widgets/canonical_positioned.dart'; +import 'package:icarus/widgets/draggable_widgets/image/image_widget.dart'; +import 'package:icarus/widgets/draggable_widgets/text/text_widget.dart'; + +class _NoopActionProvider extends ActionProvider { + @override + List build() => []; + + @override + void addAction(UserAction action) {} +} + +class _FixedMapProvider extends MapProvider { + @override + MapState build() => MapState(currentMap: MapValue.bind, isAttack: true); +} + +class _FixedStorageStrategyProvider extends StrategyProvider { + @override + StrategyState build() => StrategyState( + isSaved: false, + stratName: null, + id: 'canonical-coordinate-test', + storageDirectory: Directory.systemTemp.path, + ); +} + +void _expectOffset(Offset actual, Offset expected, {String? reason}) { + expect(actual.dx, closeTo(expected.dx, 0.0001), reason: reason); + expect(actual.dy, closeTo(expected.dy, 0.0001), reason: reason); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + CoordinateSystem(playAreaSize: const Size(1920, 1080)); + }); + + group('canonical side projection', () { + test('position and rotation projections are reversible', () { + final coordinates = CoordinateSystem.instance; + const position = Offset(321.25, 456.75); + const anchor = Offset(40, 80); + const rotation = 0.73; + + final defense = coordinates.positionForSide( + canonicalPosition: position, + reflectionOffset: anchor * 2, + isAttack: false, + ); + _expectOffset( + coordinates.positionFromSide( + sidePosition: defense, + reflectionOffset: anchor * 2, + isAttack: false, + ), + position, + ); + expect( + coordinates.rotationFromSide( + coordinates.rotationForSide(rotation, isAttack: false), + isAttack: false, + ), + closeTo(rotation, 0.0001), + ); + }); + + test('ability anchors reflect exactly and defense drags round-trip', () { + final coordinates = CoordinateSystem.instance; + const mapScale = 0.835; + const abilitySize = 57.0; + + final representativeAbilities = [ + PlacedAbility( + id: 'base', + data: AgentData.agents[AgentType.sova]!.abilities.first, + position: const Offset(210, 320), + ), + PlacedAbility( + id: 'wall', + data: AgentData.agents[AgentType.harbor]!.abilities[1], + position: const Offset(430, 180), + rotation: 0.9, + ), + PlacedAbility( + id: 'deadlock', + data: AgentData.agents[AgentType.deadlock]!.abilities[2], + position: const Offset(610, 510), + rotation: 1.2, + ), + ]; + + for (final ability in representativeAbilities) { + final renderedAnchor = ability.data.abilityData! + .getAnchorPoint( + mapScale: mapScale, + abilitySize: abilitySize, + ) + .scale(coordinates.scaleFactor, coordinates.scaleFactor); + final attackTopLeft = screenPositionForWidget( + widget: ability, + coordinateSystem: coordinates, + mapScale: mapScale, + abilitySize: abilitySize, + ); + final defenseTopLeft = screenPositionForWidget( + widget: ability, + coordinateSystem: coordinates, + mapScale: mapScale, + abilitySize: abilitySize, + isAttack: false, + ); + final attackAnchor = attackTopLeft + renderedAnchor; + final defenseAnchor = defenseTopLeft + renderedAnchor; + + _expectOffset( + defenseAnchor, + Offset( + coordinates.effectiveSize.width - attackAnchor.dx, + coordinates.effectiveSize.height - attackAnchor.dy, + ), + reason: ability.data.abilityData.runtimeType.toString(), + ); + _expectOffset( + storedAbilityPositionForRenderedScreenPosition( + ability: ability.data.abilityData!, + coordinateSystem: coordinates, + renderedScreenPosition: defenseTopLeft, + mapScale: mapScale, + abilitySize: abilitySize, + isAttack: false, + ), + ability.position, + reason: ability.data.abilityData.runtimeType.toString(), + ); + } + }); + + test('agent and utility defense drags round-trip canonical positions', () { + final coordinates = CoordinateSystem.instance; + const mapScale = 1.12; + const agentSize = 54.0; + const abilitySize = 31.0; + final agent = PlacedViewConeAgent( + id: 'agent', + type: AgentType.fade, + presetType: UtilityType.viewCone90, + position: const Offset(240, 360), + ); + final utility = PlacedUtility( + id: 'utility', + type: UtilityType.customRectangle, + position: const Offset(700, 120), + customWidth: 8, + customLength: 17, + ); + + final agentTopLeft = screenPositionForWidget( + widget: agent, + coordinateSystem: coordinates, + agentSize: agentSize, + isAttack: false, + ); + _expectOffset( + storedAgentPositionForRenderedScreenPosition( + coordinateSystem: coordinates, + renderedScreenPosition: agentTopLeft, + agentSize: agentSize, + isAttack: false, + ), + agent.position, + ); + + final utilityTopLeft = screenPositionForWidget( + widget: utility, + coordinateSystem: coordinates, + mapScale: mapScale, + agentSize: agentSize, + abilitySize: abilitySize, + isAttack: false, + ); + _expectOffset( + storedUtilityPositionForRenderedScreenPosition( + utility: utility, + coordinateSystem: coordinates, + renderedScreenPosition: utilityTopLeft, + mapScale: mapScale, + agentSize: agentSize, + abilitySize: abilitySize, + isAttack: false, + ), + utility.position, + ); + }); + }); + + test('switching sides does not mutate any placed data', () { + final container = ProviderContainer( + overrides: [ + actionProvider.overrideWith(_NoopActionProvider.new), + mapProvider.overrideWith(_FixedMapProvider.new), + ], + ); + addTearDown(container.dispose); + + final agent = PlacedViewConeAgent( + id: 'agent', + type: AgentType.sova, + presetType: UtilityType.viewCone90, + position: const Offset(100, 200), + rotation: 0.4, + ); + final ability = PlacedAbility( + id: 'ability', + data: AgentData.agents[AgentType.deadlock]!.abilities[2], + position: const Offset(300, 400), + rotation: 0.8, + ); + final utility = PlacedUtility( + id: 'utility', + type: UtilityType.viewCone40, + position: const Offset(500, 600), + )..rotation = 1.1; + final text = PlacedText( + id: 'text', + position: const Offset(70, 80), + sizeVersion: worldSizedMediaVersion, + )..text = 'Hold this angle'; + final image = PlacedImage( + id: 'image', + position: const Offset(900, 100), + aspectRatio: 1.5, + scale: 185, + fileExtension: null, + sizeVersion: worldSizedMediaVersion, + ); + final group = LineUpGroup( + id: 'group', + agent: PlacedAgent( + id: 'lineup-agent', + type: AgentType.sova, + position: const Offset(110, 210), + ), + items: [ + LineUpItem( + id: 'item', + ability: ability.copyWith( + id: 'lineup-ability', + position: const Offset(310, 410), + ), + ), + ], + ); + + container.read(agentProvider.notifier).fromHive([agent]); + container.read(abilityProvider.notifier).fromHive([ability]); + container.read(utilityProvider.notifier).fromHive([utility]); + container.read(textProvider.notifier).fromHive([text]); + container.read(placedImageProvider.notifier).fromHive([image]); + container.read(lineUpProvider.notifier).fromHive([group]); + + String snapshot() => jsonEncode({ + 'agents': + container.read(agentProvider).map((e) => e.toJson()).toList(), + 'abilities': + container.read(abilityProvider).map((e) => e.toJson()).toList(), + 'utilities': + container.read(utilityProvider).map((e) => e.toJson()).toList(), + 'texts': container.read(textProvider).map((e) => e.toJson()).toList(), + 'images': container + .read(placedImageProvider) + .images + .map((e) => e.toJson()) + .toList(), + 'lineups': container + .read(lineUpProvider) + .groups + .map((e) => e.toJson()) + .toList(), + }); + + final before = snapshot(); + container.read(mapProvider.notifier).switchSide(); + + expect(container.read(mapProvider).isAttack, isFalse); + expect(snapshot(), before); + }); + + testWidgets('dynamic boxes reflect their measured bounds and stay upright', + (tester) async { + Future pump({required bool isAttack, required Size childSize}) { + return tester.pumpWidget( + MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 800, + height: 450, + child: Stack( + children: [ + CanonicalPositionedBox( + attackScreenPosition: const Offset(100, 80), + isAttack: isAttack, + child: SizedBox( + key: const ValueKey('dynamic-child'), + width: childSize.width, + height: childSize.height, + ), + ), + ], + ), + ), + ), + ), + ); + } + + await pump(isAttack: true, childSize: const Size(120, 70)); + expect( + tester.getTopLeft(find.byKey(const ValueKey('dynamic-child'))), + const Offset(100, 80), + ); + + await pump(isAttack: false, childSize: const Size(120, 70)); + expect( + tester.getTopLeft(find.byKey(const ValueKey('dynamic-child'))), + const Offset(580, 300), + ); + expect( + tester.getSize(find.byKey(const ValueKey('dynamic-child'))), + const Size(120, 70), + ); + + await pump(isAttack: false, childSize: const Size(180, 105)); + expect( + tester.getTopLeft(find.byKey(const ValueKey('dynamic-child'))), + const Offset(520, 265), + ); + }); + + testWidgets('legacy text footprint matches the rendered text card', + (tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(1920, 1080); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + CoordinateSystem(playAreaSize: const Size(1920, 1080)); + + final text = PlacedText( + id: 'measured-text', + position: Offset.zero, + size: 220, + fontSize: 16, + sizeVersion: worldSizedMediaVersion, + )..text = 'Hold A main\nthen swing on contact'; + + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + home: Scaffold( + body: Stack( + children: [ + TextWidget( + key: const ValueKey('text-card'), + id: text.id, + text: text.text, + size: text.size, + fontSize: text.fontSize, + ), + ], + ), + ), + ), + ), + ); + await tester.pump(); + + final expectedWorld = PlacedMediaGeometry.legacyTextFootprintInWorld(text); + final expectedScreen = CoordinateSystem.instance.worldSizeToScreen( + expectedWorld, + ); + final actual = tester.getSize(find.byKey(const ValueKey('text-card'))); + expect(actual.width, closeTo(expectedScreen.width, 0.01)); + expect(actual.height, closeTo(expectedScreen.height, 0.01)); + }); + + testWidgets('legacy image footprint matches the rendered image card', + (tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(1920, 1080); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + CoordinateSystem(playAreaSize: const Size(1920, 1080)); + + final image = PlacedImage( + id: 'measured-image', + position: Offset.zero, + aspectRatio: 16 / 9, + scale: 320, + fileExtension: null, + sizeVersion: worldSizedMediaVersion, + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + strategyProvider.overrideWith(_FixedStorageStrategyProvider.new), + ], + child: MaterialApp( + home: Scaffold( + body: Stack( + children: [ + ImageWidget( + key: const ValueKey('image-card'), + id: image.id, + link: null, + aspectRatio: image.aspectRatio, + scale: image.scale, + fileExtension: image.fileExtension, + ), + ], + ), + ), + ), + ), + ); + await tester.pump(); + + final expectedScreen = CoordinateSystem.instance.worldSizeToScreen( + PlacedMediaGeometry.legacyImageFootprintInWorld(image), + ); + final actual = tester.getSize(find.byKey(const ValueKey('image-card'))); + expect(actual.width, closeTo(expectedScreen.width, 0.01)); + expect(actual.height, closeTo(expectedScreen.height, 0.01)); + }); + + test('defense rotation is a half-turn without changing canonical rotation', + () { + const canonical = 0.42; + final displayed = CoordinateSystem.instance.rotationForSide( + canonical, + isAttack: false, + ); + expect(displayed, closeTo(canonical + math.pi, 0.0001)); + expect(canonical, 0.42); + }); +} diff --git a/test/canvas_compatibility_fixture_test.dart b/test/canvas_compatibility_fixture_test.dart new file mode 100644 index 00000000..b97132df --- /dev/null +++ b/test/canvas_compatibility_fixture_test.dart @@ -0,0 +1,291 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:archive/archive.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/const/agents.dart'; +import 'package:icarus/const/settings.dart'; +import 'package:icarus/const/traversal_speed.dart'; +import 'package:icarus/const/utilities.dart'; +import 'package:icarus/providers/strategy_page.dart'; +import 'package:path/path.dart' as path; + +const _fixtureName = 'canvas-compatibility-v97.ica'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Archive archive; + late Map payload; + late List> pages; + + setUpAll(() async { + final fixture = File( + path.join( + Directory.current.path, + 'test', + 'fixtures', + 'strategy_integrity', + _fixtureName, + ), + ); + archive = ZipDecoder().decodeBytes(await fixture.readAsBytes()); + final jsonEntry = archive.files.singleWhere( + (entry) => path.extension(entry.name).toLowerCase() == '.json', + ); + payload = jsonDecode( + utf8.decode(jsonEntry.content as List), + ) as Map; + pages = (payload['pages'] as List) + .map((page) => Map.from(page as Map)) + .toList(growable: false); + }); + + test('is a frozen, importable v97 strategy', () async { + expect(payload['versionNumber'], '97'); + expect(97, lessThanOrEqualTo(Settings.versionNumber)); + expect(payload['mapData'], 'ascent'); + expect(pages, hasLength(20)); + + for (final page in pages) { + final imported = await StrategyPage.fromJson( + json: page, + strategyID: 'canvas-compatibility-import', + isZip: true, + ); + expect(imported.name, page['name']); + expect(imported.isAutoNamed, isFalse); + } + }); + + test('contains every current agent and ability', () { + final agents = _entries(pages, 'agentData'); + final actualAgentTypes = + agents.map((agent) => agent['type'] as String).toSet(); + expect( + actualAgentTypes, + AgentType.values.map((type) => type.name).toSet(), + ); + expect( + agents.map((agent) => agent['kind'] as String? ?? 'plain').toSet(), + {'plain', 'viewCone', 'circle'}, + ); + expect(agents.any((agent) => agent['isAlly'] == false), isTrue); + expect(agents.any((agent) => agent['state'] == 'dead'), isTrue); + + final expectedAbilities = { + for (final type in AgentType.values) + for (final info in AgentData.agents[type]!.abilities) + '${type.name}:${info.index}', + }; + final actualAbilities = _entries(pages, 'abilityData') + .map((ability) => Map.from(ability['data'] as Map)) + .map((data) => '${data['type']}:${data['index']}') + .toSet(); + expect(actualAbilities, expectedAbilities); + }); + + test('covers utility, drawing, visibility, and side variants', () { + final utilities = _entries(pages, 'utilityData'); + expect( + utilities.map((utility) => utility['type'] as String).toSet(), + UtilityType.values.map((type) => type.name).toSet(), + ); + expect( + utilities.any( + (utility) => (utility['rotation'] as num).toDouble().abs() > 0.01, + ), + isTrue, + ); + expect( + utilities.any((utility) => utility['visionElevation'] != null), + isTrue, + ); + + final drawings = _entries(pages, 'drawingData'); + expect( + drawings.map((drawing) => drawing['type'] as String).toSet(), + { + 'freeDrawing', + 'lineDrawing', + 'rectangleDrawing', + 'ellipseDrawing', + }, + ); + expect( + drawings + .where((drawing) => drawing['showTraversalTime'] == true) + .map((drawing) => drawing['traversalSpeedProfile'] as String) + .toSet(), + TraversalSpeedProfile.values.map((profile) => profile.name).toSet(), + ); + + final visualStates = _entries(pages, 'abilityData') + .map((ability) => ability['visualState']) + .whereType() + .map(Map.from) + .toList(growable: false); + for (final field in const [ + 'showRangeOutline', + 'showRangeFill', + 'showInnerOutline', + 'showInnerFill', + 'showVisionCone', + ]) { + expect(visualStates.any((state) => state[field] == true), isTrue); + expect(visualStates.any((state) => state[field] == false), isTrue); + } + + expect(pages.any((page) => page['isAttack'] == 'true'), isTrue); + expect(pages.any((page) => page['isAttack'] == 'false'), isTrue); + expect( + pages.any( + (page) => (page['settings'] as Map)['useNeutralTeamColors'] == true, + ), + isTrue, + ); + }); + + test('covers dynamic text, embedded images, and lineup groups', () { + final text = _entries(pages, 'textData'); + expect(text.any((entry) => entry['text'] == ''), isTrue); + expect( + text.any((entry) => (entry['text'] as String).contains('\n')), + isTrue, + ); + expect( + text.map((entry) => (entry['size'] as num).toDouble()).toSet(), + containsAll({90, 460}), + ); + expect(text.any((entry) => entry['tagColorValue'] != null), isTrue); + + final images = _entries(pages, 'imageData'); + expect( + images.map((entry) => (entry['aspectRatio'] as num).toDouble()).toSet(), + containsAll({1, 16 / 9, 9 / 16, 2}), + ); + expect( + images.map((entry) => (entry['scale'] as num).toDouble()).toSet(), + containsAll({90, 185, 460}), + ); + final archiveFiles = archive.files.map((entry) => entry.name).toSet(); + for (final image in images) { + expect( + archiveFiles, + contains('${image['id']}${image['fileExtension']}'), + ); + } + expect(archiveFiles, contains('lineup-reference.png')); + + final groups = _entries(pages, 'lineUpGroups'); + expect(groups, isNotEmpty); + expect( + groups.any((group) => (group['items'] as List).length >= 3), + isTrue, + ); + expect( + groups + .expand((group) => group['items'] as List) + .map((item) => Map.from(item as Map)) + .any((item) => (item['notes'] as String).isNotEmpty), + isTrue, + ); + }); + + test('attack and defense mirror pages store identical canonical content', () { + final attack = _pageNamed(pages, 'Canonical mirror - attack'); + final defense = _pageNamed(pages, 'Canonical mirror - defense'); + expect(attack['isAttack'], 'true'); + expect(defense['isAttack'], 'false'); + expect(_pageContent(defense), _pageContent(attack)); + + final positions = [ + ..._entries([attack], 'agentData'), + ..._entries([attack], 'abilityData'), + ..._entries([attack], 'textData'), + ..._entries([attack], 'imageData'), + ..._entries([attack], 'utilityData'), + ].map((entry) => Map.from(entry['position'] as Map)); + expect( + positions.any((position) => (position['dx'] as num) < 100), + isTrue, + ); + expect( + positions.any((position) => (position['dx'] as num) > 1400), + isTrue, + ); + }); + + test('defense transition pair covers move, appear, and disappear', () { + final start = _pageNamed(pages, 'Defense transition A'); + final end = _pageNamed(pages, 'Defense transition B'); + final startIds = _canvasIds(start); + final endIds = _canvasIds(end); + + expect(startIds.intersection(endIds), isNotEmpty); + expect(startIds.difference(endIds), isNotEmpty); + expect(endIds.difference(startIds), isNotEmpty); + + final startAgent = _entryWithId(start, 'agentData', 'transition-agent'); + final endAgent = _entryWithId(end, 'agentData', 'transition-agent'); + expect(endAgent['position'], isNot(startAgent['position'])); + + final startAbility = + _entryWithId(start, 'abilityData', 'transition-ability'); + final endAbility = _entryWithId(end, 'abilityData', 'transition-ability'); + expect(endAbility['rotation'], isNot(startAbility['rotation'])); + }); +} + +Iterable> _entries( + Iterable> pages, + String key, +) sync* { + for (final page in pages) { + for (final entry in page[key] as List) { + yield Map.from(entry as Map); + } + } +} + +Map _pageNamed( + List> pages, + String suffix, +) { + return pages.singleWhere( + (page) => (page['name'] as String).endsWith(suffix), + ); +} + +Map _pageContent(Map page) { + return Map.from(page) + ..remove('id') + ..remove('sortIndex') + ..remove('name') + ..remove('isAttack'); +} + +Set _canvasIds(Map page) { + return { + for (final key in const [ + 'drawingData', + 'agentData', + 'abilityData', + 'textData', + 'imageData', + 'utilityData', + ]) + for (final entry in page[key] as List) + (entry as Map)['id'] as String, + }; +} + +Map _entryWithId( + Map page, + String key, + String id, +) { + return (page[key] as List) + .map((entry) => Map.from(entry as Map)) + .singleWhere((entry) => entry['id'] == id); +} diff --git a/test/custom_shape_indicator_test.dart b/test/custom_shape_indicator_test.dart index 3b48d840..26a4c360 100644 --- a/test/custom_shape_indicator_test.dart +++ b/test/custom_shape_indicator_test.dart @@ -1,3 +1,5 @@ +import 'dart:math' as math; + import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -13,6 +15,7 @@ import 'package:icarus/providers/color_library_provider.dart'; import 'package:icarus/providers/map_provider.dart'; import 'package:icarus/providers/screenshot_provider.dart'; import 'package:icarus/providers/utility_provider.dart'; +import 'package:icarus/widgets/draggable_widgets/placed_widget_builder.dart'; import 'package:icarus/widgets/draggable_widgets/utilities/custom_circle_utility_widget.dart'; import 'package:icarus/widgets/draggable_widgets/utilities/custom_rectangle_utility_widget.dart'; import 'package:icarus/widgets/draggable_widgets/utilities/placed_custom_circle_widget.dart'; @@ -29,6 +32,14 @@ class _FixedMapProvider extends MapProvider { void fromHive(MapValue map, bool isAttack) {} } +class _DefenseMapProvider extends MapProvider { + @override + MapState build() => MapState(currentMap: MapValue.ascent, isAttack: false); + + @override + void fromHive(MapValue map, bool isAttack) {} +} + void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -135,6 +146,7 @@ void main() { child: PlacedCustomRectangleWidget( utility: utility, id: utility.id, + isAttack: true, onDragEnd: (_) {}, ), ), @@ -173,6 +185,7 @@ void main() { child: PlacedCustomRectangleWidget( utility: utility, id: utility.id, + isAttack: true, onDragEnd: (_) {}, ), ), @@ -232,6 +245,121 @@ void main() { expect(container.read(utilityProvider).single.rotation, greaterThan(0)); }); + testWidgets( + 'defense rectangle projects rotation and stores rotation edits canonically', + (tester) async { + const canonicalRotation = 0.3; + final utility = _rectangle()..rotation = canonicalRotation; + final container = _containerWith(utility); + addTearDown(container.dispose); + + await tester.pumpWidget( + _harness( + container: container, + child: PlacedCustomRectangleWidget( + utility: utility, + id: utility.id, + isAttack: false, + onDragEnd: (_) {}, + ), + ), + ); + + final rotationTransform = tester.widget( + find.byKey(const ValueKey('custom-rectangle-rotation')), + ); + const displayedRotation = canonicalRotation + math.pi; + expect( + rotationTransform.transform.storage[0], + closeTo(math.cos(displayedRotation), 0.0001), + ); + expect( + rotationTransform.transform.storage[1], + closeTo(math.sin(displayedRotation), 0.0001), + ); + + final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); + await mouse.addPointer( + location: tester.getCenter(find.byType(CustomRectangleUtilityWidget)), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + + final handle = find.byKey( + const ValueKey('custom-rectangle-rotate-top-center'), + ); + final handleCenter = tester.getCenter(handle); + await mouse.moveTo(handleCenter); + await mouse.down(handleCenter); + await mouse.moveBy(const Offset(30, 8)); + await tester.pump(); + await mouse.up(); + await tester.pump(); + + final storedRotation = container.read(utilityProvider).single.rotation; + expect(storedRotation, isNot(closeTo(canonicalRotation, 0.0001))); + expect( + (storedRotation - canonicalRotation).abs(), + lessThan(math.pi / 2), + reason: 'The defense display half-turn must not be saved as canonical.', + ); + }); + + testWidgets('defense rectangle resize does not jump when committed', + (tester) async { + final utility = _rectangle(); + final container = _containerWith(utility, isAttack: false); + addTearDown(container.dispose); + + await tester.pumpWidget( + _harness( + container: container, + child: const SizedBox( + width: 800, + height: 600, + child: PlacedWidgetBuilder(), + ), + ), + ); + + final shape = find.byType(CustomRectangleUtilityWidget); + final initialRect = tester.getRect(shape); + final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); + await mouse.addPointer(location: initialRect.center); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + + final handle = find.byKey( + const ValueKey('custom-rectangle-resize-right'), + ); + final handleCenter = tester.getCenter(handle); + final outwardVector = handleCenter - initialRect.center; + final outwardDrag = outwardVector * (40 / outwardVector.distance); + await mouse.moveTo(handleCenter); + await mouse.down(handleCenter); + await mouse.moveTo(handleCenter + outwardDrag); + await tester.pump(); + + final duringResizeRect = tester.getRect(shape); + expect(duringResizeRect.size, isNot(initialRect.size)); + + await mouse.up(); + await tester.pump(); + + final committedRect = tester.getRect(shape); + expect( + (committedRect.topLeft - duringResizeRect.topLeft).distance, + lessThan(0.1), + reason: 'Initial: $initialRect, active: $duringResizeRect, ' + 'committed: $committedRect.', + ); + expect( + (committedRect.bottomRight - duringResizeRect.bottomRight).distance, + lessThan(0.1), + reason: 'The committed defense rectangle must retain its resized bounds.', + ); + }); + testWidgets('shape center menu changes the placed shape color', (tester) async { final utility = _circle(); @@ -304,10 +432,15 @@ Future _openShapeColorSubmenu(WidgetTester tester) async { await tester.pumpAndSettle(); } -ProviderContainer _containerWith(PlacedUtility utility) { +ProviderContainer _containerWith( + PlacedUtility utility, { + bool isAttack = true, +}) { final container = ProviderContainer( overrides: [ - mapProvider.overrideWith(_FixedMapProvider.new), + mapProvider.overrideWith( + isAttack ? _FixedMapProvider.new : _DefenseMapProvider.new, + ), colorLibraryProvider.overrideWith( (ref) => const [ ColorLibraryEntry(color: Colors.white, isCustom: false), diff --git a/test/deadlock_barrier_switch_sides_test.dart b/test/deadlock_barrier_switch_sides_test.dart index 02e60d3d..d04f19b2 100644 --- a/test/deadlock_barrier_switch_sides_test.dart +++ b/test/deadlock_barrier_switch_sides_test.dart @@ -1,458 +1,116 @@ -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/abilities.dart'; import 'package:icarus/const/agents.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/transition_data.dart'; import 'package:icarus/const/settings.dart'; -import 'package:icarus/providers/ability_provider.dart'; -import 'package:icarus/providers/action_provider.dart'; -import 'package:icarus/providers/map_provider.dart'; -import 'package:icarus/providers/strategy_settings_provider.dart'; import 'package:icarus/widgets/draggable_widgets/ability/deadlock_barrier_mesh_widget.dart'; -class _NoopActionProvider extends ActionProvider { - @override - List build() => []; - - @override - void addAction(UserAction action) { - state = [...state, action]; - } -} - -class _FixedMapProvider extends MapProvider { - _FixedMapProvider({ - required this.mapValue, - }); - - final MapValue mapValue; - - @override - MapState build() => MapState(currentMap: mapValue, isAttack: true); - - @override - void fromHive(MapValue map, bool isAttack) {} -} - -ProviderContainer _createContainer(MapValue mapValue) { - final container = ProviderContainer( - overrides: [ - actionProvider.overrideWith(_NoopActionProvider.new), - mapProvider.overrideWith(() => _FixedMapProvider(mapValue: mapValue)), - ], - ); - addTearDown(container.dispose); - return container; -} - void main() { TestWidgetsFlutterBinding.ensureInitialized(); - group('Deadlock barrier side-switch geometry', () { + group('Deadlock barrier canonical geometry', () { late DeadlockBarrierMeshAbility abilityData; - late AbilityInfo abilityInfo; - - const explicitMapScale = 1.15; - const explicitAbilitySize = 42.0; + late PlacedAbility placedAbility; + const mapScale = 1.15; + const abilitySize = 42.0; setUp(() { CoordinateSystem(playAreaSize: const Size(1920, 1080)); - abilityInfo = AgentData.agents[AgentType.deadlock]!.abilities[2]; + final abilityInfo = AgentData.agents[AgentType.deadlock]!.abilities[2]; abilityData = abilityInfo.abilityData! as DeadlockBarrierMeshAbility; + placedAbility = PlacedAbility( + id: 'deadlock-barrier', + data: abilityInfo, + position: const Offset(123.4, 234.5), + rotation: 0.7, + ); }); - test('getSize matches the rendered outer extent', () { + test('modeled size and anchor match the rendered outer extent', () { final extent = deadlockBarrierMeshMaxExtent( - mapScale: explicitMapScale, - abilitySize: explicitAbilitySize, + mapScale: mapScale, + abilitySize: abilitySize, ); - final size = abilityData.getSize( - mapScale: explicitMapScale, - abilitySize: explicitAbilitySize, - ); - - expect(size.dx, extent); - expect(size.dy, extent); - }); - - test('getAnchorPoint matches half the rendered outer extent', () { - final extent = deadlockBarrierMeshMaxExtent( - mapScale: explicitMapScale, - abilitySize: explicitAbilitySize, + mapScale: mapScale, + abilitySize: abilitySize, ); - final anchor = abilityData.getAnchorPoint( - mapScale: explicitMapScale, - abilitySize: explicitAbilitySize, - ); - - expect(anchor.dx, extent / 2); - expect(anchor.dy, extent / 2); - }); - - test( - 'PlacedAbility.switchSides mirrors deadlock using the full rendered size', - () { - const initialPosition = Offset(123.4, 234.5); - const initialRotation = math.pi / 3; - - final placedAbility = PlacedAbility( - id: 'deadlock-barrier', - data: abilityInfo, - position: initialPosition, - rotation: initialRotation, - ); - - final fullSize = abilityData - .getSize( - mapScale: explicitMapScale, - abilitySize: Settings.abilitySize, - ) - .scale( - CoordinateSystem.instance.scaleFactor, - CoordinateSystem.instance.scaleFactor, - ); - - final expectedPosition = getFlippedPosition( - position: initialPosition, - scaledSize: fullSize, - ); - - placedAbility.switchSides( - mapScale: explicitMapScale, - abilitySize: explicitAbilitySize, - ); - - expect(placedAbility.position.dx, closeTo(expectedPosition.dx, 0.0001)); - expect(placedAbility.position.dy, closeTo(expectedPosition.dy, 0.0001)); - expect( - placedAbility.rotation, - closeTo(initialRotation + math.pi, 0.0001), - ); - }); - - test('AbilityProvider.switchSides uses current map scale for deadlock', () { - final container = _createContainer(MapValue.bind); - final notifier = container.read(abilityProvider.notifier); - final actualMapScale = Maps.mapScale[MapValue.bind]!; - final abilitySize = container.read(strategySettingsProvider).abilitySize; - - const initialPosition = Offset(123.4, 234.5); - const initialRotation = math.pi / 3; - - final placedAbility = PlacedAbility( - id: 'deadlock-provider-switch', - data: abilityInfo, - position: initialPosition, - rotation: initialRotation, - ); - - notifier.fromHive([placedAbility]); - - final fullSize = abilityData - .getSize( - mapScale: actualMapScale, - abilitySize: abilitySize, - ) - .scale( - CoordinateSystem.instance.scaleFactor, - CoordinateSystem.instance.scaleFactor, - ); - final expectedPosition = getFlippedPosition( - position: initialPosition, - scaledSize: fullSize, + mapScale: mapScale, + abilitySize: abilitySize, ); - notifier.switchSides(); - - final flippedAbility = container.read(abilityProvider).single; - expect(flippedAbility.position.dx, closeTo(expectedPosition.dx, 0.0001)); - expect(flippedAbility.position.dy, closeTo(expectedPosition.dy, 0.0001)); - expect( - flippedAbility.rotation, - closeTo(initialRotation + math.pi, 0.0001), - ); + expect(size, Offset(extent, extent)); + expect(anchor, Offset(extent / 2, extent / 2)); }); - test('deleted deadlock ability is flipped while stored in poppedAbility', + test('defense projection preserves stored position and anchor symmetry', () { - final container = _createContainer(MapValue.bind); - final notifier = container.read(abilityProvider.notifier); - final actualMapScale = Maps.mapScale[MapValue.bind]!; - final abilitySize = container.read(strategySettingsProvider).abilitySize; - - const initialPosition = Offset(123.4, 234.5); - const initialRotation = math.pi / 3; - - final placedAbility = PlacedAbility( - id: 'deadlock-popped-switch', - data: abilityInfo, - position: initialPosition, - rotation: initialRotation, - ); - - notifier.fromHive([placedAbility]); - notifier.removeAbility(placedAbility.id); - - final fullSize = abilityData - .getSize( - mapScale: actualMapScale, - abilitySize: abilitySize, - ) - .scale( - CoordinateSystem.instance.scaleFactor, - CoordinateSystem.instance.scaleFactor, - ); - final expectedPosition = getFlippedPosition( - position: initialPosition, - scaledSize: fullSize, + final coordinateSystem = CoordinateSystem.instance; + final anchor = abilityData + .getAnchorPoint(mapScale: mapScale, abilitySize: abilitySize) + .scale(coordinateSystem.scaleFactor, coordinateSystem.scaleFactor); + final attackTopLeft = screenPositionForWidget( + widget: placedAbility, + coordinateSystem: coordinateSystem, + mapScale: mapScale, + abilitySize: abilitySize, ); - - notifier.switchSides(); - notifier.undoAction( - UserAction( - type: ActionType.deletion, - id: placedAbility.id, - group: ActionGroup.ability, - ), + final defenseTopLeft = screenPositionForWidget( + widget: placedAbility, + coordinateSystem: coordinateSystem, + mapScale: mapScale, + abilitySize: abilitySize, + isAttack: false, ); - final restoredAbility = container.read(abilityProvider).single; - expect(restoredAbility.position.dx, closeTo(expectedPosition.dx, 0.0001)); - expect(restoredAbility.position.dy, closeTo(expectedPosition.dy, 0.0001)); expect( - restoredAbility.rotation, - closeTo(initialRotation + math.pi, 0.0001), - ); - }); - - test('switchSides still flips deleted abilities when live state is empty', - () { - final container = _createContainer(MapValue.bind); - final notifier = container.read(abilityProvider.notifier); - final actualMapScale = Maps.mapScale[MapValue.bind]!; - final abilitySize = container.read(strategySettingsProvider).abilitySize; - - const initialPosition = Offset(300.0, 450.0); - - final placedAbility = PlacedAbility( - id: 'deadlock-empty-state-switch', - data: abilityInfo, - position: initialPosition, - ); - - notifier.fromHive([placedAbility]); - notifier.removeAbility(placedAbility.id); - expect(container.read(abilityProvider), isEmpty); - - final fullSize = abilityData - .getSize( - mapScale: actualMapScale, - abilitySize: abilitySize, - ) - .scale( - CoordinateSystem.instance.scaleFactor, - CoordinateSystem.instance.scaleFactor, - ); - final expectedPosition = getFlippedPosition( - position: initialPosition, - scaledSize: fullSize, - ); - - notifier.switchSides(); - notifier.undoAction( - UserAction( - type: ActionType.deletion, - id: placedAbility.id, - group: ActionGroup.ability, + defenseTopLeft + anchor, + Offset( + coordinateSystem.effectiveSize.width - attackTopLeft.dx - anchor.dx, + coordinateSystem.effectiveSize.height - attackTopLeft.dy - anchor.dy, ), ); - - final restoredAbility = container.read(abilityProvider).single; - expect(restoredAbility.position.dx, closeTo(expectedPosition.dx, 0.0001)); - expect(restoredAbility.position.dy, closeTo(expectedPosition.dy, 0.0001)); - }); - - test('switchSides flips both live and deleted deadlock abilities', () { - final container = _createContainer(MapValue.bind); - final notifier = container.read(abilityProvider.notifier); - final actualMapScale = Maps.mapScale[MapValue.bind]!; - final abilitySize = container.read(strategySettingsProvider).abilitySize; - - const livePosition = Offset(100.0, 150.0); - const deletedPosition = Offset(200.0, 250.0); - - final liveAbility = PlacedAbility( - id: 'deadlock-live-switch', - data: abilityInfo, - position: livePosition, - ); - final deletedAbility = PlacedAbility( - id: 'deadlock-deleted-switch', - data: abilityInfo, - position: deletedPosition, - ); - - notifier.fromHive([liveAbility, deletedAbility]); - notifier.removeAbility(deletedAbility.id); - - final fullSize = abilityData - .getSize( - mapScale: actualMapScale, - abilitySize: abilitySize, - ) - .scale( - CoordinateSystem.instance.scaleFactor, - CoordinateSystem.instance.scaleFactor, - ); - final expectedLivePosition = getFlippedPosition( - position: livePosition, - scaledSize: fullSize, - ); - final expectedDeletedPosition = getFlippedPosition( - position: deletedPosition, - scaledSize: fullSize, - ); - - notifier.switchSides(); - - final flippedLiveAbility = container.read(abilityProvider).single; - expect( - flippedLiveAbility.position.dx, - closeTo(expectedLivePosition.dx, 0.0001), - ); + expect(placedAbility.position, const Offset(123.4, 234.5)); + expect(placedAbility.rotation, 0.7); expect( - flippedLiveAbility.position.dy, - closeTo(expectedLivePosition.dy, 0.0001), - ); - - notifier.undoAction( - UserAction( - type: ActionType.deletion, - id: deletedAbility.id, - group: ActionGroup.ability, + coordinateSystem.rotationForSide( + placedAbility.rotation, + isAttack: false, ), - ); - - final abilities = container.read(abilityProvider); - final restoredAbility = - abilities.firstWhere((ability) => ability.id == deletedAbility.id); - expect( - restoredAbility.position.dx, - closeTo(expectedDeletedPosition.dx, 0.0001), - ); - expect( - restoredAbility.position.dy, - closeTo(expectedDeletedPosition.dy, 0.0001), + closeTo(0.7 + 3.141592653589793, 0.0001), ); }); - testWidgets('rendered deadlock wall size matches modeled size', - (tester) async { - final container = _createContainer(MapValue.bind); - addTearDown(() async { - await tester.pumpWidget(const SizedBox.shrink()); - }); - - final mapScale = Maps.mapScale[MapValue.bind]!; - final abilitySize = container.read(strategySettingsProvider).abilitySize; - final expectedExtent = - abilityData.getSize(mapScale: mapScale, abilitySize: abilitySize).dx * - CoordinateSystem.instance.scaleFactor; - - await tester.pumpWidget( - UncontrolledProviderScope( - container: container, - child: Directionality( - textDirection: TextDirection.ltr, - child: Center( - child: DeadlockBarrierMeshWidget( - iconPath: abilityInfo.iconPath, - id: 'deadlock-render-size', - isAlly: true, - color: abilityData.color, - mapScale: mapScale, - armLengthsMeters: - DeadlockBarrierMeshAbility.defaultArmLengthsMeters, - showCenterAbility: false, - ), - ), - ), + test('stored default footprint remains independent of runtime size', () { + final coordinateSystem = CoordinateSystem.instance; + final atMinimum = screenAnchorForAbility( + ability: placedAbility, + coordinateSystem: coordinateSystem, + mapScale: mapScale, + isAttack: false, + ); + final restored = storedAbilityPositionForRenderedScreenPosition( + ability: abilityData, + coordinateSystem: coordinateSystem, + renderedScreenPosition: screenPositionForWidget( + widget: placedAbility, + coordinateSystem: coordinateSystem, + mapScale: mapScale, + abilitySize: Settings.abilitySizeMax, + isAttack: false, ), + mapScale: mapScale, + abilitySize: Settings.abilitySizeMax, + isAttack: false, ); - await tester.pump(); - - final renderedSize = - tester.getSize(find.byType(DeadlockBarrierMeshWidget)); - expect(renderedSize.width, closeTo(expectedExtent, 0.0001)); - expect(renderedSize.height, closeTo(expectedExtent, 0.0001)); - }); - - test('AbilityProvider.updatePosition uses current map scale for bounds', - () { - final container = _createContainer(MapValue.pearl); - final notifier = container.read(abilityProvider.notifier); - final actualMapScale = Maps.mapScale[MapValue.pearl]!; - final abilitySize = container.read(strategySettingsProvider).abilitySize; - final worldMaxX = CoordinateSystem.instance.worldNormalizedWidth - 10; - final worldMaxY = CoordinateSystem.instance.normalizedHeight - 10; - - final placedAbility = PlacedAbility( - id: 'deadlock-edge-bounds', - data: abilityInfo, - position: const Offset(100, 100), - ); - notifier.fromHive([placedAbility]); - - final anchorWithActualScale = abilityData.getAnchorPoint( - mapScale: actualMapScale, - abilitySize: abilitySize, - ); - final anchorWithFallbackScale = abilityData.getAnchorPoint( - mapScale: 1.0, - abilitySize: abilitySize, - ); - final midpointX = - (anchorWithActualScale.dx + anchorWithFallbackScale.dx) / 2; - final midpointY = - (anchorWithActualScale.dy + anchorWithFallbackScale.dy) / 2; - final candidatePosition = Offset( - worldMaxX - midpointX, - worldMaxY - midpointY, - ); - - notifier.updatePosition(candidatePosition, placedAbility.id); - - expect( - container.read(abilityProvider), - isEmpty, - reason: 'The real current-map scale should push the deadlock center ' - 'out of bounds and remove the ability.', - ); - }); - - test('rendered extent matches projected arm span and handle diameter', () { - const meterScale = AgentData.inGameMetersDiameter * explicitMapScale; - const maxArmLengthVirtual = - DeadlockBarrierMeshAbility.maxArmLengthMeters * meterScale; - final projectedReachVirtual = maxArmLengthVirtual * math.cos(math.pi / 4); - final expectedExtent = - math.max(explicitAbilitySize, projectedReachVirtual * 2) + - deadlockBarrierMeshHandleDiameterVirtual; - final fullExtent = deadlockBarrierMeshMaxExtent( - mapScale: explicitMapScale, - abilitySize: explicitAbilitySize, - ); - - expect(fullExtent, greaterThan(explicitAbilitySize)); - expect(fullExtent, closeTo(expectedExtent, 0.0001)); + expect(atMinimum, isNot(Offset.zero)); + expect(restored.dx, closeTo(placedAbility.position.dx, 0.0001)); + expect(restored.dy, closeTo(placedAbility.position.dy, 0.0001)); }); }); } diff --git a/test/fixtures/strategy_integrity/canvas-compatibility-v97.ica b/test/fixtures/strategy_integrity/canvas-compatibility-v97.ica new file mode 100644 index 00000000..01d1fd27 Binary files /dev/null and b/test/fixtures/strategy_integrity/canvas-compatibility-v97.ica differ diff --git a/test/interactive_map_canonical_center_test.dart b/test/interactive_map_canonical_center_test.dart new file mode 100644 index 00000000..dff90fc7 --- /dev/null +++ b/test/interactive_map_canonical_center_test.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/const/coordinate_system.dart'; +import 'package:icarus/const/maps.dart'; +import 'package:icarus/const/traversal_speed.dart'; +import 'package:icarus/interactive_map.dart'; +import 'package:icarus/providers/drawing_provider.dart'; +import 'package:icarus/providers/map_provider.dart'; +import 'package:icarus/providers/pen_provider.dart'; +import 'package:icarus/providers/placement_center_provider.dart'; +import 'package:icarus/providers/user_preferences_provider.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +class _FixedMapProvider extends MapProvider { + @override + MapState build() => MapState(currentMap: MapValue.ascent, isAttack: true); +} + +class _EmptyDrawingProvider extends DrawingProvider { + @override + DrawingState build() => DrawingState(elements: const []); + + @override + void rebuildAllPaths(CoordinateSystem coordinateSystem) {} +} + +class _FixedPenProvider extends PenProvider { + @override + PenState build() => PenState( + listOfColors: const [], + color: Colors.white, + hasArrow: false, + isDotted: false, + opacity: 1, + thickness: 1, + penMode: PenMode.freeDraw, + traversalTimeEnabled: false, + activeTraversalSpeedProfile: TraversalSpeedProfile.running, + drawingCursor: null, + erasingCursor: null, + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('side changes refresh the canonical placement center', + (tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(1500, 900); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + + final container = ProviderContainer( + overrides: [ + mapProvider.overrideWith(_FixedMapProvider.new), + drawingProvider.overrideWith(_EmptyDrawingProvider.new), + penProvider.overrideWith(_FixedPenProvider.new), + effectiveMapThemePaletteProvider.overrideWith( + (ref) => MapThemeProfilesProvider.immutableDefaultPalette, + ), + ], + ); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const ShadApp( + home: Scaffold(body: InteractiveMap()), + ), + ), + ); + await tester.pump(); + + final attackCenter = container.read(placementCenterProvider); + final coordinates = CoordinateSystem.instance; + + container.read(mapProvider.notifier).switchSide(); + await tester.pump(); + await tester.pump(); + + final defenseCenter = container.read(placementCenterProvider); + expect( + defenseCenter.dx, + closeTo(coordinates.worldNormalizedWidth - attackCenter.dx, 0.0001), + ); + expect( + defenseCenter.dy, + closeTo(coordinates.normalizedHeight - attackCenter.dy, 0.0001), + ); + + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(); + }); +} diff --git a/test/line_up_provider_switch_sides_test.dart b/test/line_up_provider_switch_sides_test.dart index 36fac497..c198fbe4 100644 --- a/test/line_up_provider_switch_sides_test.dart +++ b/test/line_up_provider_switch_sides_test.dart @@ -1,486 +1,73 @@ -import 'dart:math' as math; - -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.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/coordinate_system.dart'; import 'package:icarus/const/line_provider.dart'; -import 'package:icarus/const/maps.dart'; import 'package:icarus/const/placed_classes.dart'; import 'package:icarus/providers/action_provider.dart'; -import 'package:icarus/providers/map_provider.dart'; -import 'package:icarus/providers/strategy_settings_provider.dart'; -import 'package:shadcn_ui/shadcn_ui.dart'; -import 'package:toastification/toastification.dart'; class _NoopActionProvider extends ActionProvider { @override List build() => []; @override - void addAction(UserAction action) { - state = [...state, action]; - } -} - -class _FixedMapProvider extends MapProvider { - _FixedMapProvider({ - required this.mapValue, - }); - - final MapValue mapValue; - - @override - MapState build() => MapState(currentMap: mapValue, isAttack: true); - - @override - void fromHive(MapValue map, bool isAttack) {} -} - -ProviderContainer _createContainer(MapValue mapValue) { - final container = ProviderContainer( - overrides: [ - actionProvider.overrideWith(_NoopActionProvider.new), - mapProvider.overrideWith(() => _FixedMapProvider(mapValue: mapValue)), - ], - ); - addTearDown(container.dispose); - return container; + void addAction(UserAction action) {} } void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - group('LineUpProvider locked add-item mode', () { - setUp(() { - CoordinateSystem(playAreaSize: const Size(1920, 1080)); - }); - - test('startNewItemForGroup stores target group and locked agent type', () { - final container = _createContainer(MapValue.bind); - final notifier = container.read(lineUpProvider.notifier); - final group = LineUpGroup( - id: 'group-1', - agent: PlacedAgent( - id: 'group-agent', - type: AgentType.breach, - position: const Offset(120, 220), - ), - items: [ - LineUpItem( - id: 'item-1', - ability: PlacedAbility( - id: 'ability-1', - data: AgentData.agents[AgentType.breach]!.abilities.first, - position: const Offset(220, 320), - ), - ), - ], - ); - - notifier.addGroup(group); - notifier.startNewItemForGroup(group.id); - - final state = container.read(lineUpProvider); - expect(state.currentGroupId, group.id); - expect(state.placementMode, LineUpPlacementMode.addItemToGroup); - expect(state.lockedAgentType, AgentType.breach); - expect(state.currentAgent, isNull); - expect(notifier.getCurrentPreviewAgent()?.id, group.agent.id); - expect(notifier.isLockedAddItemMode, isTrue); - }); - - test('setCurrentAbility accepts matching ability in locked add-item mode', - () { - final container = _createContainer(MapValue.bind); - final notifier = container.read(lineUpProvider.notifier); - final group = LineUpGroup( - id: 'group-accept', - agent: PlacedAgent( - id: 'group-agent', - type: AgentType.breach, - position: const Offset(120, 220), - ), - items: const [], - ); - - notifier.addGroup(group); - notifier.startNewItemForGroup(group.id); - - final ability = PlacedAbility( - id: 'new-ability', - data: AgentData.agents[AgentType.breach]!.abilities.first, - position: const Offset(300, 400), - ); - - notifier.setCurrentAbility(ability); - - final state = container.read(lineUpProvider); - expect(state.currentAbility, isNotNull); - expect(state.currentAbility!.data.type, AgentType.breach); - expect(state.currentAbility!.lineUpID, group.id); - }); - - testWidgets( - 'setCurrentAbility rejects mismatched ability in locked add-item mode', - (tester) async { - await tester.pumpWidget( - const ToastificationWrapper( - child: ShadApp( - home: SizedBox.shrink(), - ), - ), - ); - final container = _createContainer(MapValue.bind); - final notifier = container.read(lineUpProvider.notifier); - final group = LineUpGroup( - id: 'group-reject', - agent: PlacedAgent( - id: 'group-agent', - type: AgentType.breach, - position: const Offset(120, 220), - ), - items: const [], - ); - - notifier.addGroup(group); - notifier.startNewItemForGroup(group.id); - notifier.setCurrentAbility( - PlacedAbility( - id: 'good-ability', - data: AgentData.agents[AgentType.breach]!.abilities.first, - position: const Offset(300, 400), - ), - ); - - notifier.setCurrentAbility( - PlacedAbility( - id: 'bad-ability', - data: AgentData.agents[AgentType.sova]!.abilities.first, - position: const Offset(320, 420), - ), - ); - - final currentAbility = container.read(lineUpProvider).currentAbility; - expect(currentAbility, isNotNull); - expect(currentAbility!.id, 'good-ability'); - expect(currentAbility.data.type, AgentType.breach); - await tester.pump(const Duration(seconds: 4)); - await tester.pumpAndSettle(); - }); - - test('clearCurrentPlacing resets locked add-item metadata', () { - final container = _createContainer(MapValue.bind); - final notifier = container.read(lineUpProvider.notifier); - final group = LineUpGroup( - id: 'group-clear', - agent: PlacedAgent( - id: 'group-agent', - type: AgentType.breach, - position: const Offset(120, 220), - ), - items: const [], - ); - - notifier.addGroup(group); - notifier.startNewItemForGroup(group.id); - notifier.clearCurrentPlacing(); - - final state = container.read(lineUpProvider); - expect(state.currentGroupId, isNull); - expect(state.currentAbility, isNull); - expect(state.placementMode, isNull); - expect(state.lockedAgentType, isNull); - expect(notifier.isLockedAddItemMode, isFalse); - }); + test('locked add-item mode keeps the group agent canonical', () { + final container = ProviderContainer( + overrides: [actionProvider.overrideWith(_NoopActionProvider.new)], + ); + addTearDown(container.dispose); + final notifier = container.read(lineUpProvider.notifier); + final group = LineUpGroup( + id: 'group-1', + agent: PlacedAgent( + id: 'group-agent', + type: AgentType.breach, + position: const Offset(120, 220), + ), + items: const [], + ); + + notifier.addGroup(group); + notifier.startNewItemForGroup(group.id); + + final state = container.read(lineUpProvider); + expect(state.currentGroupId, group.id); + expect(state.placementMode, LineUpPlacementMode.addItemToGroup); + expect(state.lockedAgentType, AgentType.breach); + expect(state.currentAgent, isNull); + expect(notifier.getCurrentPreviewAgent()?.position, const Offset(120, 220)); }); - group('LineUpProvider.switchSides', () { - late AbilityInfo abilityInfo; - - setUp(() { - CoordinateSystem(playAreaSize: const Size(1920, 1080)); - abilityInfo = AgentData.agents[AgentType.deadlock]!.abilities[2]; - }); - - test('deleted lineup is flipped while stored in popped lineups', () { - final container = _createContainer(MapValue.bind); - final notifier = container.read(lineUpProvider.notifier); - final agentSize = container.read(strategySettingsProvider).agentSize; - final abilitySize = container.read(strategySettingsProvider).abilitySize; - final mapScale = Maps.mapScale[MapValue.bind]!; - - const initialAgentPosition = Offset(100.0, 200.0); - const initialAbilityPosition = Offset(300.0, 400.0); - const initialAbilityRotation = math.pi / 4; - - final lineUp = LineUp( - id: 'lineup-1', - agent: PlacedAgent( - id: 'lineup-agent-1', - type: AgentType.deadlock, - position: initialAgentPosition, - ), - ability: PlacedAbility( - id: 'lineup-ability-1', - data: abilityInfo, - position: initialAbilityPosition, - rotation: initialAbilityRotation, - ), - youtubeLink: '', - images: const [], - notes: '', - ); - - notifier.fromHive([lineUp]); - notifier.deleteLineUpById(lineUp.id); - notifier.switchSides(); - notifier.undoAction( - UserAction( - type: ActionType.deletion, - id: lineUp.id, - group: ActionGroup.lineUp, - ), - ); - - final restoredLineUp = container.read(lineUpProvider).lineUps.single; - final expectedAgentPosition = getFlippedPosition( - position: initialAgentPosition, - scaledSize: Offset( - CoordinateSystem.instance.scale(agentSize), - CoordinateSystem.instance.scale(agentSize), - ), - ); - final abilitySizePx = abilityInfo.abilityData! - .getSize(mapScale: mapScale, abilitySize: abilitySize) - .scale( - CoordinateSystem.instance.scaleFactor, - CoordinateSystem.instance.scaleFactor, - ); - final expectedAbilityPosition = getFlippedPosition( - position: initialAbilityPosition, - scaledSize: abilitySizePx, - ); - - expect( - restoredLineUp.agent.position.dx, - closeTo(expectedAgentPosition.dx, 0.0001), - ); - expect( - restoredLineUp.agent.position.dy, - closeTo(expectedAgentPosition.dy, 0.0001), - ); - expect( - restoredLineUp.ability.position.dx, - closeTo(expectedAbilityPosition.dx, 0.0001), - ); - expect( - restoredLineUp.ability.position.dy, - closeTo(expectedAbilityPosition.dy, 0.0001), - ); - expect( - restoredLineUp.ability.rotation, - closeTo(initialAbilityRotation + math.pi, 0.0001), - ); - }); - - test('current lineup placement flips on side switch', () { - final container = _createContainer(MapValue.bind); - final notifier = container.read(lineUpProvider.notifier); - final agentSize = container.read(strategySettingsProvider).agentSize; - final abilitySize = container.read(strategySettingsProvider).abilitySize; - final mapScale = Maps.mapScale[MapValue.bind]!; - - const initialAgentPosition = Offset(120.0, 220.0); - const initialAbilityPosition = Offset(320.0, 420.0); - const initialAbilityRotation = math.pi / 6; - - notifier.setAgent( - PlacedAgent( - id: 'current-agent', - type: AgentType.deadlock, - position: initialAgentPosition, - ), - ); - notifier.setAbility( - PlacedAbility( - id: 'current-ability', - data: abilityInfo, - position: initialAbilityPosition, - rotation: initialAbilityRotation, - ), - ); - - notifier.switchSides(); - - final currentState = container.read(lineUpProvider); - final currentAgent = currentState.currentAgent; - final currentAbility = currentState.currentAbility; - expect(currentAgent, isNotNull); - expect(currentAbility, isNotNull); - - final expectedAgentPosition = getFlippedPosition( - position: initialAgentPosition, - scaledSize: Offset( - CoordinateSystem.instance.scale(agentSize), - CoordinateSystem.instance.scale(agentSize), - ), - ); - final abilitySizePx = abilityInfo.abilityData! - .getSize(mapScale: mapScale, abilitySize: abilitySize) - .scale( - CoordinateSystem.instance.scaleFactor, - CoordinateSystem.instance.scaleFactor, - ); - final expectedAbilityPosition = getFlippedPosition( - position: initialAbilityPosition, - scaledSize: abilitySizePx, - ); - - expect( - currentAgent!.position.dx, - closeTo(expectedAgentPosition.dx, 0.0001), - ); - expect( - currentAgent.position.dy, - closeTo(expectedAgentPosition.dy, 0.0001), - ); - expect( - currentAbility!.position.dx, - closeTo(expectedAbilityPosition.dx, 0.0001), - ); - expect( - currentAbility.position.dy, - closeTo(expectedAbilityPosition.dy, 0.0001), - ); - expect( - currentAbility.rotation, - closeTo(initialAbilityRotation + math.pi, 0.0001), - ); - }); - }); - - group('LineUpProvider.switchSides wall square abilities', () { - setUp(() { - CoordinateSystem(playAreaSize: const Size(1920, 1080)); - }); - - test('stored lineup flips square walls using the full rendered width', () { - final container = _createContainer(MapValue.bind); - final notifier = container.read(lineUpProvider.notifier); - final abilityInfo = AgentData.agents[AgentType.viper]!.abilities[2]; - final abilitySize = container.read(strategySettingsProvider).abilitySize; - final mapScale = Maps.mapScale[MapValue.bind]!; - - const initialAbilityPosition = Offset(280.0, 360.0); - const initialRotation = math.pi / 5; - - final lineUp = LineUp( - id: 'viper-lineup', - agent: PlacedAgent( - id: 'viper-agent', - type: AgentType.viper, - position: const Offset(120.0, 240.0), - ), - ability: PlacedAbility( - id: 'viper-wall', - data: abilityInfo, - position: initialAbilityPosition, - rotation: initialRotation, - ), - youtubeLink: '', - images: const [], - notes: '', - ); - - notifier.fromHive([lineUp]); - notifier.switchSides(); - - final flippedLineUp = container.read(lineUpProvider).lineUps.single; - final abilitySizePx = abilityInfo.abilityData! - .getSize(mapScale: mapScale, abilitySize: abilitySize) - .scale( - CoordinateSystem.instance.scaleFactor, - CoordinateSystem.instance.scaleFactor, - ); - final expectedAbilityPosition = getFlippedPosition( - position: initialAbilityPosition, - scaledSize: abilitySizePx, - isRotatable: true, - ); - - expect( - flippedLineUp.ability.position.dx, - closeTo(expectedAbilityPosition.dx, 0.0001), - ); - expect( - flippedLineUp.ability.position.dy, - closeTo(expectedAbilityPosition.dy, 0.0001), - ); - expect( - flippedLineUp.ability.rotation, - closeTo(initialRotation + math.pi, 0.0001), - ); - }); - - test( - 'current lineup flips resizable wall abilities using the full rendered width', - () { - final container = _createContainer(MapValue.bind); - final notifier = container.read(lineUpProvider.notifier); - final abilityInfo = AgentData.agents[AgentType.neon]!.abilities.first; - final abilitySize = container.read(strategySettingsProvider).abilitySize; - final mapScale = Maps.mapScale[MapValue.bind]!; - - const initialAbilityPosition = Offset(320.0, 420.0); - const initialRotation = math.pi / 6; - - notifier.setAgent( - PlacedAgent( - id: 'neon-agent', - type: AgentType.neon, - position: const Offset(140.0, 260.0), - ), - ); - notifier.setAbility( - PlacedAbility( - id: 'neon-wall', - data: abilityInfo, - position: initialAbilityPosition, - rotation: initialRotation, - ), - ); - - notifier.switchSides(); - - final currentAbility = container.read(lineUpProvider).currentAbility; - expect(currentAbility, isNotNull); - - final abilitySizePx = abilityInfo.abilityData! - .getSize(mapScale: mapScale, abilitySize: abilitySize) - .scale( - CoordinateSystem.instance.scaleFactor, - CoordinateSystem.instance.scaleFactor, - ); - final expectedAbilityPosition = getFlippedPosition( - position: initialAbilityPosition, - scaledSize: abilitySizePx, - isRotatable: true, - ); - - expect( - currentAbility!.position.dx, - closeTo(expectedAbilityPosition.dx, 0.0001), - ); - expect( - currentAbility.position.dy, - closeTo(expectedAbilityPosition.dy, 0.0001), - ); - expect( - currentAbility.rotation, - closeTo(initialRotation + math.pi, 0.0001), - ); - }); + test('matching abilities retain canonical positions in locked mode', () { + final container = ProviderContainer( + overrides: [actionProvider.overrideWith(_NoopActionProvider.new)], + ); + addTearDown(container.dispose); + final notifier = container.read(lineUpProvider.notifier); + final group = LineUpGroup( + id: 'group-2', + agent: PlacedAgent( + id: 'group-agent', + type: AgentType.breach, + position: const Offset(120, 220), + ), + items: const [], + ); + final ability = PlacedAbility( + id: 'new-ability', + data: AgentData.agents[AgentType.breach]!.abilities.first, + position: const Offset(300, 400), + ); + + notifier.addGroup(group); + notifier.startNewItemForGroup(group.id); + notifier.setCurrentAbility(ability); + + final current = container.read(lineUpProvider).currentAbility; + expect(current?.position, const Offset(300, 400)); + expect(current?.lineUpID, group.id); }); } diff --git a/test/page_transition_overlay_test.dart b/test/page_transition_overlay_test.dart index 49c757c4..efe3604c 100644 --- a/test/page_transition_overlay_test.dart +++ b/test/page_transition_overlay_test.dart @@ -12,6 +12,7 @@ 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'; +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'; import 'package:icarus/widgets/page_transition_overlay.dart'; @@ -27,6 +28,14 @@ class _FixedMapProvider extends MapProvider { void fromHive(MapValue map, bool isAttack) {} } +class _DefenseMapProvider extends MapProvider { + @override + MapState build() => MapState(currentMap: MapValue.ascent, isAttack: false); + + @override + void fromHive(MapValue map, bool isAttack) {} +} + void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -290,6 +299,70 @@ void main() { await tester.pump(); }); + testWidgets( + 'defense appear and disappear offsets keep view-cone geometry aligned', + (tester) async { + final container = _createDefenseContainer(); + addTearDown(container.dispose); + final coordinateSystem = CoordinateSystem.instance; + final cone = PlacedUtility( + id: 'defense-transition-cone', + type: UtilityType.viewCone90, + position: const Offset(500, 400), + ) + ..rotation = 0.6 + ..length = 90; + const progress = 0.25; + final directionalOffset = coordinateSystem.scale(28); + final coneAnchor = coordinateSystem.virtualOffsetToWorld( + ViewConeWidget.anchorPointVirtual, + ); + + Future pumpEntry(PageTransitionEntry entry) async { + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: ShadApp( + home: Scaffold( + body: TransitionEntriesLayer( + entries: [entry], + agentPaths: const {}, + t: progress, + direction: PageTransitionDirection.forward, + agentSize: 40, + abilitySize: 40, + ), + ), + ), + ), + ); + await tester.pump(); + return tester + .widget(find.byType(ViewConeWidget)) + .worldOrigin!; + } + + final appearOrigin = await pumpEntry(PageTransitionEntry.appear(to: cone)); + final appearTranslation = coordinateSystem.screenWidthToWorld( + directionalOffset * (1 - progress), + ); + expect( + appearOrigin, + cone.position + Offset(-appearTranslation, 0) + coneAnchor, + ); + + final disappearOrigin = await pumpEntry( + PageTransitionEntry.disappear(from: cone), + ); + final disappearTranslation = coordinateSystem.screenWidthToWorld( + -directionalOffset * progress, + ); + expect( + disappearOrigin, + cone.position + Offset(-disappearTranslation, 0) + coneAnchor, + ); + }); + testWidgets('temporary rectangle preview applies saved rotation once', (tester) async { final container = _createContainer(); @@ -328,6 +401,93 @@ void main() { }); expect(matchingTransforms, hasLength(1)); }); + + testWidgets('role icons stay upright during page transitions', + (tester) async { + final container = _createContainer(); + addTearDown(container.dispose); + const roleTypes = [ + UtilityType.controller, + UtilityType.duelist, + UtilityType.initiator, + UtilityType.sentinel, + ]; + final roleIcons = [ + for (var index = 0; index < roleTypes.length; index++) + PlacedUtility( + id: 'rotated-${roleTypes[index].name}', + type: roleTypes[index], + position: Offset(350 + (index * 80), 350), + )..rotation = math.pi / 7 + (index * 0.1), + ]; + + for (final roleIcon in roleIcons) { + expect(PageTransitionEntry.rotationOf(roleIcon), isNull); + } + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: ShadApp( + home: Scaffold( + body: TransitionEntriesLayer( + entries: [ + for (final roleIcon in roleIcons) + PageTransitionEntry.appear(to: roleIcon), + ], + agentPaths: const {}, + t: 0.5, + direction: PageTransitionDirection.forward, + agentSize: 40, + abilitySize: 40, + ), + ), + ), + ), + ); + await tester.pump(); + + expect(find.byType(RoleIconUtilityWidget), findsNWidgets(roleTypes.length)); + for (final roleIcon in roleIcons) { + expect(_hasTransformAtAngle(tester, roleIcon.rotation), isFalse); + } + }); + + testWidgets('defense temporary role icons stay upright', (tester) async { + final container = _createDefenseContainer(); + addTearDown(container.dispose); + final roleIcon = PlacedUtility( + id: 'defense-role-icon', + type: UtilityType.sentinel, + position: const Offset(500, 350), + ); + container.read(transitionProvider.notifier).prepare( + [roleIcon], + startAgentSize: 40, + startAbilitySize: 40, + ); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const ShadApp( + home: Scaffold(body: TemporaryWidgetBuilder()), + ), + ), + ); + await tester.pump(); + + expect(find.byType(RoleIconUtilityWidget), findsOneWidget); + expect(_hasTransformAtAngle(tester, math.pi), isFalse); + }); +} + +bool _hasTransformAtAngle(WidgetTester tester, double angle) { + return tester.widgetList(find.byType(Transform)).any((transform) { + final matrix = transform.transform.storage; + final renderedRotation = math.atan2(matrix[1], matrix[0]); + return (renderedRotation - angle).abs() < 0.0001; + }); } Widget _previewHarness({ @@ -362,3 +522,11 @@ ProviderContainer _createContainer() { ], ); } + +ProviderContainer _createDefenseContainer() { + return ProviderContainer( + overrides: [ + mapProvider.overrideWith(_DefenseMapProvider.new), + ], + ); +} diff --git a/test/resizable_square_ability_test.dart b/test/resizable_square_ability_test.dart index 33f72048..efa95012 100644 --- a/test/resizable_square_ability_test.dart +++ b/test/resizable_square_ability_test.dart @@ -1,5 +1,3 @@ -import 'dart:math'; - import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -8,7 +6,6 @@ import 'package:icarus/const/agents.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/providers/ability_provider.dart'; import 'package:icarus/providers/map_provider.dart'; import 'package:icarus/providers/strategy_settings_provider.dart'; @@ -303,77 +300,6 @@ void main() { expect(renderedSize.width, closeTo(modeledSize.dx, 0.0001)); }); - - group('PlacedAbility.switchSides wall sizing', () { - const mapScale = 1.2; - const abilitySize = 42.0; - - test('square walls flip using the full rendered width', () { - CoordinateSystem(playAreaSize: const Size(1920, 1080)); - - final abilityInfo = AgentData.agents[AgentType.viper]!.abilities[2]; - const initialPosition = Offset(123.4, 234.5); - const initialRotation = 0.5; - - final placedAbility = PlacedAbility( - id: 'viper-wall-switch', - data: abilityInfo, - position: initialPosition, - rotation: initialRotation, - ); - - final fullSize = abilityInfo.abilityData! - .getSize(mapScale: mapScale, abilitySize: Settings.abilitySize) - .scale( - CoordinateSystem.instance.scaleFactor, - CoordinateSystem.instance.scaleFactor, - ); - final expectedPosition = getFlippedPosition( - position: initialPosition, - scaledSize: fullSize, - isRotatable: true, - ); - - placedAbility.switchSides(mapScale: mapScale, abilitySize: abilitySize); - - expect(placedAbility.position.dx, closeTo(expectedPosition.dx, 0.0001)); - expect(placedAbility.position.dy, closeTo(expectedPosition.dy, 0.0001)); - expect(placedAbility.rotation, closeTo(initialRotation + pi, 0.0001)); - }); - - test('resizable wall abilities flip using the full rendered width', () { - CoordinateSystem(playAreaSize: const Size(1920, 1080)); - - final abilityInfo = AgentData.agents[AgentType.neon]!.abilities.first; - const initialPosition = Offset(210.0, 310.0); - const initialRotation = 0.75; - - final placedAbility = PlacedAbility( - id: 'neon-wall-switch', - data: abilityInfo, - position: initialPosition, - rotation: initialRotation, - ); - - final fullSize = abilityInfo.abilityData! - .getSize(mapScale: mapScale, abilitySize: Settings.abilitySize) - .scale( - CoordinateSystem.instance.scaleFactor, - CoordinateSystem.instance.scaleFactor, - ); - final expectedPosition = getFlippedPosition( - position: initialPosition, - scaledSize: fullSize, - isRotatable: true, - ); - - placedAbility.switchSides(mapScale: mapScale, abilitySize: abilitySize); - - expect(placedAbility.position.dx, closeTo(expectedPosition.dx, 0.0001)); - expect(placedAbility.position.dy, closeTo(expectedPosition.dy, 0.0001)); - expect(placedAbility.rotation, closeTo(initialRotation + pi, 0.0001)); - }); - }); } class _FixedMapProvider extends MapProvider { diff --git a/test/role_icon_agent_size_test.dart b/test/role_icon_agent_size_test.dart index 8e108be4..9a5ee3d3 100644 --- a/test/role_icon_agent_size_test.dart +++ b/test/role_icon_agent_size_test.dart @@ -4,7 +4,6 @@ import 'package:flutter_test/flutter_test.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/map_provider.dart'; import 'package:icarus/providers/strategy_settings_provider.dart'; @@ -159,8 +158,7 @@ void main() { expect(framedShell.size, isNot(abilitySize)); }); - test('utility side switching uses the stable default role icon footprint', - () { + test('side switching leaves role icon coordinates canonical', () { final container = _createContainer( settings: StrategySettings( agentSize: agentSize, @@ -177,22 +175,12 @@ void main() { ), ]); - container.read(utilityProvider.notifier).switchSides(); + container.read(mapProvider.notifier).switchSide(); - final flippedUtility = container.read(utilityProvider).single; - final expectedPosition = getFlippedPosition( - position: initialPosition, - scaledSize: Offset( - CoordinateSystem.instance.scale(Settings.agentSize), - CoordinateSystem.instance.scale(Settings.agentSize), - ), - ); - - expect(flippedUtility.position.dx, closeTo(expectedPosition.dx, 0.0001)); - expect(flippedUtility.position.dy, closeTo(expectedPosition.dy, 0.0001)); + expect(container.read(utilityProvider).single.position, initialPosition); }); - test('non-role utilities keep their existing footprint', () { + test('side switching leaves non-role utility coordinates canonical', () { final container = _createContainer( settings: StrategySettings( agentSize: agentSize, @@ -209,21 +197,9 @@ void main() { ), ]); - container.read(utilityProvider.notifier).switchSides(); - - final flippedUtility = container.read(utilityProvider).single; - final expectedSize = UtilityData.utilityWidgets[UtilityType.spike]! - .getSize(agentSize: agentSize, abilitySize: abilitySize); - final expectedPosition = getFlippedPosition( - position: initialPosition, - scaledSize: expectedSize.scale( - CoordinateSystem.instance.scaleFactor, - CoordinateSystem.instance.scaleFactor, - ), - ); + container.read(mapProvider.notifier).switchSide(); - expect(flippedUtility.position.dx, closeTo(expectedPosition.dx, 0.0001)); - expect(flippedUtility.position.dy, closeTo(expectedPosition.dy, 0.0001)); + expect(container.read(utilityProvider).single.position, initialPosition); }); }); } diff --git a/test/rotated_rectangle_drag_anchor_test.dart b/test/rotated_rectangle_drag_anchor_test.dart index a46a8dd1..ca94de17 100644 --- a/test/rotated_rectangle_drag_anchor_test.dart +++ b/test/rotated_rectangle_drag_anchor_test.dart @@ -31,7 +31,8 @@ void main() { CoordinateSystem.instance.setIsScreenshot(false); }); - testWidgets('drag feedback keeps the grabbed point under the cursor ' + testWidgets( + 'drag feedback keeps the grabbed point under the cursor ' 'on a 90-degree rotated rectangle', (tester) async { final utility = PlacedUtility( id: 'rectangle', @@ -62,6 +63,7 @@ void main() { child: PlacedCustomRectangleWidget( utility: utility, id: utility.id, + isAttack: true, onDragEnd: (_) {}, ), ), diff --git a/test/sector_circle_ability_test.dart b/test/sector_circle_ability_test.dart index 03c18ef2..dcfd242d 100644 --- a/test/sector_circle_ability_test.dart +++ b/test/sector_circle_ability_test.dart @@ -72,54 +72,6 @@ void main() { expect(isRotatable(sector), isTrue); }); - - test('switchSides mirrors position, rotates, and leaves length intact', () { - const mapScale = 1.1; - const abilitySize = 42.0; - final abilityInfo = AbilityInfo( - name: 'Sector', - iconPath: 'assets/agents/Cypher/1.webp', - type: AgentType.cypher, - index: 99, - abilityData: SectorCircleAbility( - iconPath: 'assets/agents/Cypher/1.webp', - size: 6.5, - rangeOutlineColor: Colors.cyan, - sweepAngleDegrees: 75, - ), - ); - const initialPosition = Offset(120, 245); - const initialRotation = 0.8; - const initialLength = 17.0; - - final placedAbility = PlacedAbility( - id: 'sector-switch', - data: abilityInfo, - position: initialPosition, - rotation: initialRotation, - length: initialLength, - ); - - final fullSize = abilityInfo.abilityData! - .getSize(mapScale: mapScale, abilitySize: abilitySize) - .scale( - CoordinateSystem.instance.scaleFactor, - CoordinateSystem.instance.scaleFactor, - ); - final expectedPosition = getFlippedPosition( - position: initialPosition, - scaledSize: fullSize, - isRotatable: true, - ); - - placedAbility.switchSides(mapScale: mapScale, abilitySize: abilitySize); - - expect(placedAbility.position.dx, closeTo(expectedPosition.dx, 0.0001)); - expect(placedAbility.position.dy, closeTo(expectedPosition.dy, 0.0001)); - expect( - placedAbility.rotation, closeTo(initialRotation + math.pi, 0.0001)); - expect(placedAbility.length, initialLength); - }); }); group('SectorCircleWidget styling', () { diff --git a/tool/generate_canvas_compatibility_fixture.dart b/tool/generate_canvas_compatibility_fixture.dart new file mode 100644 index 00000000..2659da45 --- /dev/null +++ b/tool/generate_canvas_compatibility_fixture.dart @@ -0,0 +1,1396 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:math' as math; + +import 'package:archive/archive.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/const/abilities.dart'; +import 'package:icarus/const/agents.dart'; +import 'package:icarus/const/bounding_box.dart'; +import 'package:icarus/const/drawing_element.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/const/traversal_speed.dart'; +import 'package:icarus/const/utilities.dart'; +import 'package:icarus/providers/strategy_page.dart'; +import 'package:icarus/providers/strategy_settings_provider.dart'; +import 'package:icarus/providers/user_preferences_provider.dart'; +import 'package:image/image.dart' as img; +import 'package:path/path.dart' as path; + +const _fixtureVersion = 97; +const _fixtureTimestamp = 946684800; +const _strategyId = 'canvas-compatibility-v97'; +const _fixtureName = 'canvas-compatibility-v97'; + +const _red = 0xFFEF4444; +const _orange = 0xFFF97316; +const _yellow = 0xFFFACC15; +const _green = 0xFF22C55E; +const _blue = 0xFF3B82F6; +const _violet = 0xFF8B5CF6; +const _white = 0xFFFFFFFF; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('generate the frozen v97 canvas compatibility fixture', () async { + if (Settings.versionNumber != _fixtureVersion) { + throw StateError( + 'This generator is frozen at v$_fixtureVersion. Copy it for the ' + 'current schema instead of rewriting the historical fixture.', + ); + } + + final pages = _buildPages(); + final payload = { + 'versionNumber': '$_fixtureVersion', + 'mapData': Maps.mapNames[MapValue.ascent], + 'themePalette': MapThemeProfilesProvider.immutableDefaultPalette.toJson(), + 'pages': pages.map((page) => page.toJson(_strategyId)).toList(), + }; + + final archive = Archive() + ..addFile( + _fixtureArchiveFile( + '$_fixtureName.json', + utf8.encode(const JsonEncoder.withIndent(' ').convert(payload)), + ), + ); + + for (final spec in _fixtureImages) { + archive.addFile( + _fixtureArchiveFile( + '${spec.id}.png', + _testImageBytes(spec), + ), + ); + } + + final output = File( + path.join( + Directory.current.path, + 'test', + 'fixtures', + 'strategy_integrity', + '$_fixtureName.ica', + ), + ); + await output.parent.create(recursive: true); + await output.writeAsBytes(ZipEncoder().encode(archive)); + + expect(await output.length(), greaterThan(0)); + }); +} + +ArchiveFile _fixtureArchiveFile(String name, List bytes) { + return ArchiveFile.bytes(name, bytes) + ..creationTime = _fixtureTimestamp + ..lastModTime = _fixtureTimestamp; +} + +List _buildPages() { + final pages = []; + + void addPage({ + required String name, + bool isAttack = true, + StrategySettings? settings, + List drawings = const [], + List agents = const [], + List abilities = const [], + List text = const [], + List images = const [], + List utilities = const [], + List lineUps = const [], + }) { + final sortIndex = pages.length; + pages.add( + StrategyPage( + id: 'compat-page-${sortIndex + 1}', + sortIndex: sortIndex, + name: name, + isAutoNamed: false, + drawingData: drawings, + agentData: agents, + abilityData: abilities, + textData: text, + imageData: images, + utilityData: utilities, + lineUpGroups: lineUps, + isAttack: isAttack, + settings: settings ?? StrategySettings(), + ), + ); + } + + final teamAgents = []; + final teamLabels = []; + for (var index = 0; index < AgentType.values.length; index++) { + final type = AgentType.values[index]; + final column = index % 5; + final row = index ~/ 5; + final x = 170.0 + column * 320; + final y = 130.0 + row * 145; + teamLabels.add( + _text( + 'team-label-${type.name}', + Offset(x - 60, y - 48), + AgentData.agents[type]!.name, + width: 150, + fontSize: 12, + ), + ); + teamAgents + ..add( + PlacedAgent( + id: 'team-${type.name}-ally', + type: type, + position: Offset(x, y), + ), + ) + ..add( + PlacedAgent( + id: 'team-${type.name}-enemy', + type: type, + position: Offset(x + 62, y), + isAlly: false, + ), + ); + } + addPage( + name: '01 Agents - ally and enemy', + agents: teamAgents, + text: teamLabels, + ); + + final deadAgents = []; + final deadLabels = []; + for (var index = 0; index < AgentType.values.length; index++) { + final type = AgentType.values[index]; + final column = index % 6; + final row = index ~/ 6; + final x = 135.0 + column * 275; + final y = 145.0 + row * 175; + deadLabels.add( + _text( + 'dead-label-${type.name}', + Offset(x - 50, y - 48), + AgentData.agents[type]!.name, + width: 135, + fontSize: 11, + ), + ); + deadAgents.add( + PlacedAgent( + id: 'dead-${type.name}', + type: type, + position: Offset(x, y), + isAlly: index.isEven, + state: AgentState.dead, + ), + ); + } + addPage( + name: '02 Agents - dead state', + agents: deadAgents, + text: deadLabels, + ); + + final specialAgents = [ + PlacedAgent( + id: 'special-plain-ally', + type: AgentType.jett, + position: const Offset(180, 200), + ), + PlacedAgent( + id: 'special-plain-enemy', + type: AgentType.jett, + position: const Offset(280, 200), + isAlly: false, + ), + PlacedViewConeAgent( + id: 'special-cone-180', + type: AgentType.sova, + position: const Offset(500, 180), + presetType: UtilityType.viewCone180, + rotation: math.pi / 7, + length: 130, + visionElevation: 1.5, + ), + PlacedViewConeAgent( + id: 'special-cone-90', + type: AgentType.cypher, + position: const Offset(880, 220), + presetType: UtilityType.viewCone90, + rotation: -math.pi / 3, + length: 90, + isAlly: false, + ), + PlacedViewConeAgent( + id: 'special-cone-40-dead', + type: AgentType.killjoy, + position: const Offset(1180, 170), + presetType: UtilityType.viewCone40, + rotation: math.pi * 0.82, + length: 165, + visionElevation: -2, + state: AgentState.dead, + ), + PlacedCircleAgent( + id: 'special-circle-small', + type: AgentType.astra, + position: const Offset(260, 620), + diameterMeters: 6, + colorValue: _violet, + opacityPercent: 30, + ), + PlacedCircleAgent( + id: 'special-circle-large', + type: AgentType.viper, + position: const Offset(650, 560), + diameterMeters: 18, + colorValue: _green, + opacityPercent: 65, + isAlly: false, + ), + PlacedCircleAgent( + id: 'special-circle-dead', + type: AgentType.omen, + position: const Offset(1120, 610), + diameterMeters: 11, + colorValue: _red, + opacityPercent: 45, + state: AgentState.dead, + ), + ]; + addPage( + name: '03 Agents - cones, circles, neutral teams', + agents: specialAgents, + settings: StrategySettings(useNeutralTeamColors: true), + text: [ + _text('special-label-plain', const Offset(120, 95), 'Plain agents'), + _text( + 'special-label-cones', const Offset(650, 70), 'Attached view cones'), + _text('special-label-circles', const Offset(610, 470), 'Agent circles'), + ], + ); + + const agentTypes = AgentType.values; + for (var start = 0; start < agentTypes.length; start += 4) { + final end = math.min(start + 4, agentTypes.length); + final pageAgents = agentTypes.sublist(start, end); + final abilities = []; + final labels = []; + + for (var row = 0; row < pageAgents.length; row++) { + final type = pageAgents[row]; + final agent = AgentData.agents[type]!; + final y = 145.0 + row * 225; + labels.add( + _text( + 'ability-agent-label-${type.name}', + Offset(65, y - 45), + agent.name, + width: 170, + fontSize: 18, + tagColorValue: row.isEven ? _blue : _red, + ), + ); + + for (var abilityIndex = 0; + abilityIndex < agent.abilities.length; + abilityIndex++) { + final info = agent.abilities[abilityIndex]; + final x = 330.0 + abilityIndex * 220; + labels.add( + _text( + 'ability-label-${type.name}-$abilityIndex', + Offset(x - 70, y - 55), + info.name, + width: 180, + fontSize: 11, + ), + ); + abilities.add( + _placedAbility( + info, + id: 'all-ability-${type.name}-$abilityIndex', + position: Offset(x, y), + isAlly: (row + abilityIndex).isEven, + rotation: _abilityRotation(info, row + abilityIndex), + ), + ); + } + } + + addPage( + name: + '${(pages.length + 1).toString().padLeft(2, '0')} Abilities - ${pageAgents.first.name} to ${pageAgents.last.name}', + abilities: abilities, + text: labels, + settings: StrategySettings(abilitySize: 22), + ); + } + + final abilityKinds = _abilityKindSamples(); + final visibilityAbilities = []; + final visibilityLabels = []; + for (var index = 0; index < abilityKinds.length; index++) { + final sample = abilityKinds[index]; + final column = index % 4; + final row = index ~/ 4; + final x = 190.0 + column * 400; + final y = 190.0 + row * 315; + visibilityLabels.add( + _text( + 'visibility-label-${sample.label}', + Offset(x - 75, y - 70), + sample.label, + width: 190, + fontSize: 13, + ), + ); + visibilityAbilities + ..add( + _placedAbility( + sample.info, + id: 'visibility-${sample.label}-default', + position: Offset(x, y), + rotation: math.pi / 9, + ), + ) + ..add( + _placedAbility( + sample.info, + id: 'visibility-${sample.label}-reduced', + position: Offset(x + 145, y + 65), + isAlly: false, + rotation: -math.pi / 5, + visualState: AbilityVisualState( + showRangeOutline: index % 2 == 0, + showRangeFill: false, + showInnerOutline: index % 3 == 0, + showInnerFill: false, + showVisionCone: false, + ), + ), + ); + } + addPage( + name: + '${(pages.length + 1).toString().padLeft(2, '0')} Ability visibility states', + abilities: visibilityAbilities, + text: visibilityLabels, + ); + + final utilities = []; + final utilityLabels = []; + for (var index = 0; index < UtilityType.values.length; index++) { + final type = UtilityType.values[index]; + final column = index % 5; + final row = index ~/ 5; + final x = 150.0 + column * 330; + final y = 220.0 + row * 420; + utilityLabels.add( + _text( + 'utility-label-${type.name}', + Offset(x - 45, y - 80), + type.name, + width: 155, + fontSize: 13, + ), + ); + utilities + ..add( + _utility( + type, + id: 'utility-${type.name}-ally', + position: Offset(x, y), + isAlly: true, + rotation: math.pi / 7 + index * 0.08, + sizeVariant: index, + baselineCustomShape: true, + ), + ) + ..add( + _utility( + type, + id: 'utility-${type.name}-enemy', + position: Offset(x + 105, y + 95), + isAlly: false, + rotation: -math.pi / 4 - index * 0.06, + sizeVariant: index + 1, + ), + ); + } + addPage( + name: + '${(pages.length + 1).toString().padLeft(2, '0')} Utilities and custom shapes', + utilities: utilities, + text: utilityLabels, + ); + + addPage( + name: + '${(pages.length + 1).toString().padLeft(2, '0')} Drawings and traversal times', + drawings: _drawingMatrix(), + text: [ + _text('drawing-label-lines', const Offset(90, 65), + 'Lines and traversal times'), + _text('drawing-label-free', const Offset(90, 415), 'Freehand'), + _text('drawing-label-shapes', const Offset(920, 415), + 'Rectangle and ellipse'), + ], + ); + + addPage( + name: '${(pages.length + 1).toString().padLeft(2, '0')} Text and images', + text: _textMatrix(), + images: _imageMatrix(), + utilities: [ + _utility( + UtilityType.customCircle, + id: 'media-page-custom-circle', + position: const Offset(1260, 650), + sizeVariant: 4, + ), + _utility( + UtilityType.customRectangle, + id: 'media-page-custom-rectangle', + position: const Offset(1450, 665), + rotation: math.pi / 5, + sizeVariant: 6, + ), + ], + ); + + addPage( + name: + '${(pages.length + 1).toString().padLeft(2, '0')} Lineup groups and stacking', + lineUps: _lineUpMatrix(), + text: [ + _text( + 'lineup-label-a', const Offset(120, 110), 'Sova group: three items'), + _text('lineup-label-b', const Offset(980, 110), + 'Viper group: enemy colors'), + ], + ); + + final mirrorAttack = _mirrorContent(); + addPage( + name: + '${(pages.length + 1).toString().padLeft(2, '0')} Canonical mirror - attack', + drawings: mirrorAttack.drawings, + agents: mirrorAttack.agents, + abilities: mirrorAttack.abilities, + text: mirrorAttack.text, + images: mirrorAttack.images, + utilities: mirrorAttack.utilities, + lineUps: mirrorAttack.lineUps, + ); + + final mirrorDefense = _mirrorContent(); + addPage( + name: + '${(pages.length + 1).toString().padLeft(2, '0')} Canonical mirror - defense', + isAttack: false, + drawings: mirrorDefense.drawings, + agents: mirrorDefense.agents, + abilities: mirrorDefense.abilities, + text: mirrorDefense.text, + images: mirrorDefense.images, + utilities: mirrorDefense.utilities, + lineUps: mirrorDefense.lineUps, + ); + + final transitionA = _transitionContent(isEnd: false); + addPage( + name: + '${(pages.length + 1).toString().padLeft(2, '0')} Defense transition A', + isAttack: false, + drawings: transitionA.drawings, + agents: transitionA.agents, + abilities: transitionA.abilities, + text: transitionA.text, + images: transitionA.images, + utilities: transitionA.utilities, + ); + + final transitionB = _transitionContent(isEnd: true); + addPage( + name: + '${(pages.length + 1).toString().padLeft(2, '0')} Defense transition B', + isAttack: false, + drawings: transitionB.drawings, + agents: transitionB.agents, + abilities: transitionB.abilities, + text: transitionB.text, + images: transitionB.images, + utilities: transitionB.utilities, + ); + + return pages; +} + +PlacedText _text( + String id, + Offset position, + String value, { + double width = 185, + double fontSize = 16, + int? tagColorValue, +}) { + return PlacedText( + id: id, + position: position, + size: width, + fontSize: fontSize, + sizeVersion: worldSizedMediaVersion, + tagColorValue: tagColorValue, + )..text = value; +} + +PlacedImage _image( + String id, + Offset position, { + required double aspectRatio, + double width = 185, + int? tagColorValue, +}) { + return PlacedImage( + id: id, + position: position, + aspectRatio: aspectRatio, + scale: width, + fileExtension: '.png', + sizeVersion: worldSizedMediaVersion, + tagColorValue: tagColorValue, + ); +} + +PlacedAbility _placedAbility( + AbilityInfo info, { + required String id, + required Offset position, + bool isAlly = true, + double rotation = 0, + AbilityVisualState visualState = const AbilityVisualState(), + String? lineUpID, +}) { + final ability = info.abilityData!; + final length = ability is ResizableSquareAbility + ? (ability.minLength + ability.height) / 2 + : 0.0; + final armLengths = ability is DeadlockBarrierMeshAbility + ? const [2, 4.5, 7, 9.5] + : null; + return PlacedAbility( + id: id, + data: info, + position: position, + isAlly: isAlly, + rotation: rotation, + length: length, + visualState: visualState, + armLengthsMeters: armLengths, + lineUpID: lineUpID, + ); +} + +double _abilityRotation(AbilityInfo info, int seed) { + if (!isRotatable(info.abilityData!)) return 0; + return -math.pi / 2 + seed * math.pi / 7; +} + +PlacedUtility _utility( + UtilityType type, { + required String id, + required Offset position, + bool isAlly = true, + double rotation = 0, + int sizeVariant = 0, + bool baselineCustomShape = false, +}) { + final isCircle = type == UtilityType.customCircle; + final isRectangle = type == UtilityType.customRectangle; + final isCone = UtilityData.isViewCone(type); + return PlacedUtility( + id: id, + type: type, + position: position, + isAlly: isAlly, + angle: isCone ? UtilityData.getViewConeSpawnAngle(type) : 0, + visionElevation: isCone && sizeVariant.isEven ? sizeVariant / 2 : null, + customDiameter: isCircle + ? baselineCustomShape + ? 14 + : 5.0 + sizeVariant * 1.8 + : null, + customWidth: isRectangle + ? baselineCustomShape + ? 6 + : 3.0 + sizeVariant * 0.7 + : null, + customLength: isRectangle + ? baselineCustomShape + ? 18 + : 10.0 + sizeVariant * 2.2 + : null, + customColorValue: baselineCustomShape + ? isCircle + ? _blue + : _green + : isCircle || isRectangle + ? [_blue, _violet, _green, _orange][sizeVariant % 4] + : null, + customOpacityPercent: baselineCustomShape + ? isCircle + ? 35 + : 30 + : isCircle || isRectangle + ? 25 + (sizeVariant % 4) * 20 + : null, + ) + ..rotation = rotation + ..length = isCone ? 65.0 + sizeVariant * 14 : 0; +} + +List<_AbilityKindSample> _abilityKindSamples() { + final abilities = [ + for (final type in AgentType.values) ...AgentData.agents[type]!.abilities, + ]; + + AbilityInfo find(bool Function(Ability ability) test) => + abilities.firstWhere((info) => test(info.abilityData!)); + + return [ + _AbilityKindSample('base icon', find((ability) => ability is BaseAbility)), + _AbilityKindSample( + 'image smoke', + find( + (ability) => ability is ImageAbility && ability.supportsInactiveState, + ), + ), + _AbilityKindSample( + 'circle and inner range', + find( + (ability) => ability is CircleAbility && ability.hasInnerRange, + ), + ), + _AbilityKindSample( + 'sector circle', + find((ability) => ability is SectorCircleAbility), + ), + _AbilityKindSample( + 'square wall', + find( + (ability) => + ability is SquareAbility && + ability is! ResizableSquareAbility && + ability.supportsInactiveState, + ), + ), + _AbilityKindSample( + 'centered square', + find((ability) => ability is CenterSquareAbility), + ), + _AbilityKindSample( + 'rotatable image', + find((ability) => ability is RotatableImageAbility), + ), + _AbilityKindSample( + 'resizable square', + find((ability) => ability is ResizableSquareAbility), + ), + _AbilityKindSample( + 'barrier mesh', + find((ability) => ability is DeadlockBarrierMeshAbility), + ), + ]; +} + +List _drawingMatrix() { + final drawings = []; + for (var index = 0; index < TraversalSpeedProfile.values.length; index++) { + final profile = TraversalSpeedProfile.values[index]; + final y = 150.0 + index * 65; + drawings.add( + Line( + id: 'drawing-line-${profile.name}', + lineStart: Offset(180, y), + lineEnd: Offset(1450, y + (index.isEven ? 35 : -25)), + colorValue: [_white, _yellow, _orange, _blue][index], + thickness: Settings.strokeThicknessOptions[index], + isDotted: index.isOdd, + hasArrow: index >= 2, + showTraversalTime: true, + traversalSpeedProfile: profile, + boundingBox: BoundingBox( + min: Offset(180, y - 25), + max: Offset(1450, y + 40), + ), + ), + ); + } + + drawings + ..add( + FreeDrawing( + id: 'drawing-free-solid', + listOfPoints: const [ + Offset(180, 550), + Offset(260, 480), + Offset(350, 610), + Offset(450, 500), + Offset(570, 620), + ], + colorValue: _green, + thickness: 8, + isDotted: false, + hasArrow: false, + boundingBox: BoundingBox( + min: const Offset(180, 480), + max: const Offset(570, 620), + ), + ), + ) + ..add( + FreeDrawing( + id: 'drawing-free-dotted-arrow', + listOfPoints: const [ + Offset(170, 790), + Offset(280, 680), + Offset(390, 820), + Offset(520, 700), + Offset(650, 850), + ], + colorValue: _violet, + thickness: 5, + isDotted: true, + hasArrow: true, + showTraversalTime: true, + traversalSpeedProfile: TraversalSpeedProfile.walking, + boundingBox: BoundingBox( + min: const Offset(170, 680), + max: const Offset(650, 850), + ), + ), + ) + ..add( + RectangleDrawing( + id: 'drawing-rectangle-solid', + start: const Offset(980, 530), + end: const Offset(1300, 735), + colorValue: _red, + thickness: 8, + isDotted: false, + hasArrow: false, + boundingBox: BoundingBox( + min: const Offset(980, 530), + max: const Offset(1300, 735), + ), + ), + ) + ..add( + RectangleDrawing( + id: 'drawing-rectangle-dotted', + start: const Offset(1370, 520), + end: const Offset(1580, 700), + colorValue: _yellow, + thickness: 3, + isDotted: true, + hasArrow: false, + boundingBox: BoundingBox( + min: const Offset(1370, 520), + max: const Offset(1580, 700), + ), + ), + ) + ..add( + EllipseDrawing( + id: 'drawing-ellipse-solid', + start: const Offset(930, 765), + end: const Offset(1240, 930), + colorValue: _blue, + thickness: 5, + isDotted: false, + hasArrow: false, + boundingBox: BoundingBox( + min: const Offset(930, 765), + max: const Offset(1240, 930), + ), + ), + ) + ..add( + EllipseDrawing( + id: 'drawing-ellipse-dotted', + start: const Offset(1330, 760), + end: const Offset(1600, 925), + colorValue: _orange, + thickness: 2, + isDotted: true, + hasArrow: false, + boundingBox: BoundingBox( + min: const Offset(1330, 760), + max: const Offset(1600, 925), + ), + ), + ); + + return drawings; +} + +List _textMatrix() => [ + _text('text-empty', const Offset(90, 100), '', width: 90, fontSize: 12), + _text( + 'text-short', + const Offset(260, 100), + 'Short label', + width: 140, + fontSize: 14, + tagColorValue: _red, + ), + _text( + 'text-multiline', + const Offset(510, 90), + 'Multiline text\nkeeps its measured height\nwhen the side changes.', + width: 230, + fontSize: 18, + tagColorValue: _blue, + ), + _text( + 'text-long-word', + const Offset(870, 95), + 'SUPERCALIFRAGILISTICEXPIALIDOCIOUS', + width: 185, + fontSize: 16, + tagColorValue: _yellow, + ), + _text( + 'text-wide', + const Offset(1190, 90), + 'Maximum-width card with punctuation: A -> B, 90 degrees, 12.5m.', + width: 460, + fontSize: 20, + tagColorValue: _green, + ), + _text( + 'text-large', + const Offset(120, 520), + 'Large type', + width: 300, + fontSize: 34, + tagColorValue: _violet, + ), + _text( + 'text-unicode', + const Offset(520, 530), + 'Unicode: cafe, こんにちは, 안녕하세요, مرحبا', + width: 360, + fontSize: 17, + ), + _text( + 'text-edge', + const Offset(1600, 850), + 'Near edge', + width: 120, + fontSize: 14, + tagColorValue: _orange, + ), + ]; + +List _imageMatrix() => [ + _image( + 'image-square', + const Offset(120, 300), + aspectRatio: 1, + width: 90, + ), + _image( + 'image-wide', + const Offset(340, 285), + aspectRatio: 16 / 9, + width: 260, + tagColorValue: _blue, + ), + _image( + 'image-tall', + const Offset(760, 270), + aspectRatio: 9 / 16, + width: 185, + tagColorValue: _red, + ), + _image( + 'image-tagged', + const Offset(1030, 285), + aspectRatio: 2, + width: 460, + tagColorValue: _green, + ), + ]; + +List _lineUpMatrix() { + const sovaGroupId = 'lineup-group-sova'; + const viperGroupId = 'lineup-group-viper'; + final sova = AgentData.agents[AgentType.sova]!; + final viper = AgentData.agents[AgentType.viper]!; + return [ + LineUpGroup( + id: sovaGroupId, + agent: PlacedAgent( + id: 'lineup-sova-agent', + type: AgentType.sova, + position: const Offset(220, 430), + lineUpID: sovaGroupId, + ), + items: [ + for (var index = 0; index < 3; index++) + LineUpItem( + id: 'lineup-sova-item-$index', + ability: _placedAbility( + sova.abilities[index + 1], + id: 'lineup-sova-ability-$index', + position: Offset(520 + index * 180, 360 + index * 110), + lineUpID: sovaGroupId, + rotation: index * math.pi / 5, + ), + youtubeLink: index == 0 + ? 'https://www.youtube.com/watch?v=compatibility' + : '', + notes: 'Reference ${index + 1}: bounce and aim marker.', + images: index == 0 + ? [ + SimpleImageData( + id: 'lineup-reference', + fileExtension: '.png', + ), + ] + : const [], + ), + ], + ), + LineUpGroup( + id: viperGroupId, + agent: PlacedAgent( + id: 'lineup-viper-agent', + type: AgentType.viper, + position: const Offset(1080, 670), + isAlly: false, + lineUpID: viperGroupId, + ), + items: [ + LineUpItem( + id: 'lineup-viper-item-0', + ability: _placedAbility( + viper.abilities.first, + id: 'lineup-viper-ability-0', + position: const Offset(1370, 390), + isAlly: false, + lineUpID: viperGroupId, + ), + notes: 'Enemy-color lineup with an area ability.', + ), + LineUpItem( + id: 'lineup-viper-item-1', + ability: _placedAbility( + viper.abilities[2], + id: 'lineup-viper-ability-1', + position: const Offset(1450, 720), + isAlly: false, + lineUpID: viperGroupId, + rotation: math.pi * 0.7, + ), + notes: 'Rotated wall endpoint.', + ), + ], + ), + ]; +} + +_PageContent _mirrorContent() { + final breach = AgentData.agents[AgentType.breach]!; + final deadlock = AgentData.agents[AgentType.deadlock]!; + return _PageContent( + drawings: [ + Line( + id: 'mirror-line', + lineStart: const Offset(80, 840), + lineEnd: const Offset(620, 690), + colorValue: _yellow, + thickness: 8, + isDotted: true, + hasArrow: true, + showTraversalTime: true, + traversalSpeedProfile: TraversalSpeedProfile.brimStim, + boundingBox: BoundingBox( + min: const Offset(80, 690), + max: const Offset(620, 840), + ), + ), + FreeDrawing( + id: 'mirror-freehand', + listOfPoints: const [ + Offset(1190, 750), + Offset(1310, 650), + Offset(1410, 820), + Offset(1580, 690), + ], + colorValue: _green, + thickness: 5, + isDotted: false, + hasArrow: false, + boundingBox: BoundingBox( + min: const Offset(1190, 650), + max: const Offset(1580, 820), + ), + ), + ], + agents: [ + PlacedAgent( + id: 'mirror-agent-plain', + type: AgentType.jett, + position: const Offset(75, 90), + ), + PlacedViewConeAgent( + id: 'mirror-agent-cone', + type: AgentType.cypher, + position: const Offset(1180, 140), + presetType: UtilityType.viewCone90, + rotation: math.pi / 3, + length: 145, + visionElevation: 2.5, + isAlly: false, + ), + PlacedCircleAgent( + id: 'mirror-agent-circle', + type: AgentType.astra, + position: const Offset(210, 560), + diameterMeters: 12, + colorValue: _violet, + opacityPercent: 42, + ), + ], + abilities: [ + _placedAbility( + breach.abilities[2], + id: 'mirror-ability-resizable', + position: const Offset(510, 210), + rotation: -math.pi / 4, + ), + _placedAbility( + deadlock.abilities[2], + id: 'mirror-ability-barrier', + position: const Offset(860, 520), + isAlly: false, + rotation: math.pi / 6, + ), + ], + text: [ + _text( + 'mirror-text-dynamic', + const Offset(1450, 90), + 'Dynamic text\nuses measured bounds.', + width: 250, + fontSize: 19, + tagColorValue: _blue, + ), + _text( + 'mirror-text-edge', + const Offset(60, 430), + 'Edge anchor', + width: 115, + fontSize: 13, + tagColorValue: _orange, + ), + ], + images: [ + _image( + 'mirror-image', + const Offset(1390, 400), + aspectRatio: 16 / 9, + width: 280, + tagColorValue: _green, + ), + ], + utilities: [ + _utility( + UtilityType.viewCone40, + id: 'mirror-utility-cone', + position: const Offset(690, 650), + rotation: math.pi * 0.85, + sizeVariant: 5, + ), + _utility( + UtilityType.customRectangle, + id: 'mirror-utility-rectangle', + position: const Offset(1500, 790), + rotation: -math.pi / 5, + sizeVariant: 7, + ), + _utility( + UtilityType.customCircle, + id: 'mirror-utility-circle', + position: const Offset(760, 80), + sizeVariant: 3, + ), + ], + lineUps: _mirrorLineUp(), + ); +} + +List _mirrorLineUp() { + const groupId = 'mirror-lineup-group'; + final kayo = AgentData.agents[AgentType.kayo]!; + return [ + LineUpGroup( + id: groupId, + agent: PlacedAgent( + id: 'mirror-lineup-agent', + type: AgentType.kayo, + position: const Offset(1020, 860), + lineUpID: groupId, + ), + items: [ + LineUpItem( + id: 'mirror-lineup-item', + ability: _placedAbility( + kayo.abilities.first, + id: 'mirror-lineup-ability', + position: const Offset(1250, 900), + lineUpID: groupId, + ), + notes: 'Same canonical lineup on both sides.', + ), + ], + ), + ]; +} + +_PageContent _transitionContent({required bool isEnd}) { + final sova = AgentData.agents[AgentType.sova]!; + final breach = AgentData.agents[AgentType.breach]!; + if (!isEnd) { + return _PageContent( + drawings: [ + FreeDrawing( + id: 'transition-drawing-disappear', + listOfPoints: const [ + Offset(120, 780), + Offset(320, 650), + Offset(500, 800), + ], + colorValue: _orange, + thickness: 8, + isDotted: false, + hasArrow: false, + boundingBox: BoundingBox( + min: const Offset(120, 650), + max: const Offset(500, 800), + ), + ), + ], + agents: [ + PlacedAgent( + id: 'transition-agent', + type: AgentType.jett, + position: const Offset(210, 180), + ), + ], + abilities: [ + _placedAbility( + breach.abilities[2], + id: 'transition-ability', + position: const Offset(430, 230), + rotation: 0.2, + ), + ], + text: [ + _text( + 'transition-text-move', + const Offset(830, 170), + 'Moves across defense', + width: 230, + tagColorValue: _blue, + ), + _text( + 'transition-text-disappear', + const Offset(1280, 260), + 'Disappears', + width: 150, + tagColorValue: _red, + ), + ], + images: const [], + utilities: [ + _utility( + UtilityType.viewCone90, + id: 'transition-cone', + position: const Offset(610, 510), + rotation: -0.8, + sizeVariant: 2, + ), + ], + ); + } + + return _PageContent( + drawings: [ + Line( + id: 'transition-drawing-appear', + lineStart: const Offset(1050, 760), + lineEnd: const Offset(1540, 600), + colorValue: _green, + thickness: 5, + isDotted: true, + hasArrow: true, + boundingBox: BoundingBox( + min: const Offset(1050, 600), + max: const Offset(1540, 760), + ), + ), + ], + agents: [ + PlacedAgent( + id: 'transition-agent', + type: AgentType.jett, + position: const Offset(1390, 720), + ), + PlacedViewConeAgent( + id: 'transition-agent-appear', + type: AgentType.sova, + position: const Offset(230, 650), + presetType: UtilityType.viewCone40, + rotation: math.pi / 3, + length: 110, + ), + ], + abilities: [ + _placedAbility( + breach.abilities[2], + id: 'transition-ability', + position: const Offset(960, 580), + rotation: 1.8, + ), + _placedAbility( + sova.abilities[2], + id: 'transition-ability-appear', + position: const Offset(1450, 180), + isAlly: false, + ), + ], + text: [ + _text( + 'transition-text-move', + const Offset(360, 710), + 'Moved across defense\nwith a new height', + width: 260, + fontSize: 20, + tagColorValue: _green, + ), + ], + images: [ + _image( + 'transition-image', + const Offset(1180, 300), + aspectRatio: 16 / 9, + width: 300, + tagColorValue: _violet, + ), + ], + utilities: [ + _utility( + UtilityType.viewCone90, + id: 'transition-cone', + position: const Offset(680, 320), + rotation: 2.2, + sizeVariant: 6, + ), + _utility( + UtilityType.customRectangle, + id: 'transition-shape-appear', + position: const Offset(120, 160), + rotation: -math.pi / 3, + sizeVariant: 5, + ), + ], + ); +} + +List<_FixtureImage> get _fixtureImages => const [ + _FixtureImage('image-square', 400, 400), + _FixtureImage('image-wide', 640, 360), + _FixtureImage('image-tall', 360, 640), + _FixtureImage('image-tagged', 720, 360), + _FixtureImage('lineup-reference', 640, 360), + _FixtureImage('mirror-image', 640, 360), + _FixtureImage('transition-image', 640, 360), + ]; + +List _testImageBytes(_FixtureImage spec) { + final image = img.Image(width: spec.width, height: spec.height); + for (final pixel in image) { + final isLeft = pixel.x < spec.width / 2; + final isTop = pixel.y < spec.height / 2; + final color = switch ((isLeft, isTop)) { + (true, true) => (239, 68, 68), + (false, true) => (34, 197, 94), + (true, false) => (59, 130, 246), + (false, false) => (250, 204, 21), + }; + image.setPixelRgba( + pixel.x, + pixel.y, + color.$1, + color.$2, + color.$3, + 255, + ); + } + + final centerX = spec.width ~/ 2; + final centerY = spec.height ~/ 2; + for (var y = centerY - 8; y <= centerY + 8; y++) { + for (var x = centerX - 8; x <= centerX + 8; x++) { + image.setPixelRgba(x, y, 255, 255, 255, 255); + } + } + return img.encodePng(image); +} + +class _AbilityKindSample { + const _AbilityKindSample(this.label, this.info); + + final String label; + final AbilityInfo info; +} + +class _PageContent { + const _PageContent({ + this.drawings = const [], + this.agents = const [], + this.abilities = const [], + this.text = const [], + this.images = const [], + this.utilities = const [], + this.lineUps = const [], + }); + + final List drawings; + final List agents; + final List abilities; + final List text; + final List images; + final List utilities; + final List lineUps; +} + +class _FixtureImage { + const _FixtureImage(this.id, this.width, this.height); + + final String id; + final int width; + final int height; +}