fix!: Add DisplacementEvent to fix delta coordinate transformations for drag events (#2871)

This adds `DisplacementEvent` to fix delta coordinate transformations
for drag events, to be used instead of `PositionEvent`.
Drag Events now expose the start and end position, as well as the delta,
correctly transformed by the camera and zoom.
This also ensures that drag events, once starts, do not get lost if the
drag update leaves the component bounds.



* if you are using `DragUpdateEvent` events, the `devicePosition`,
`canvasPosition`, `localPosition`, and `delta` are deprecated as they
are unclear.
* use `xStartPosition` to get the position at the start of the drag
event ("from")
* use `xEndPosition` to get the position at the end of the drag event
("to")
* if you want the delta, use `localDelta`. it now already considers the
camera zoom. no need to manually account for that
* now you keep receiving drag events for the same component even if the
mouse leaves the component (breaking)

---------

Co-authored-by: Lukas Klingsbo <lukas.klingsbo@gmail.com>
This commit is contained in:
Luan Nico
2023-11-30 10:40:26 -05:00
committed by GitHub
parent 07ef46cab0
commit 63994ebcd8
21 changed files with 512 additions and 196 deletions

View File

@ -83,7 +83,7 @@ class DragTarget extends PositionComponent with DragCallbacks {
@override @override
void onDragUpdate(DragUpdateEvent event) { void onDragUpdate(DragUpdateEvent event) {
_trails[event.pointerId]!.addPoint(event.localPosition); _trails[event.pointerId]!.addPoint(event.localEndPosition);
} }
@override @override
@ -240,6 +240,6 @@ class Star extends PositionComponent with DragCallbacks {
@override @override
void onDragUpdate(DragUpdateEvent event) { void onDragUpdate(DragUpdateEvent event) {
position += event.delta; position += event.localDelta;
} }
} }

View File

@ -241,8 +241,7 @@ class Card extends PositionComponent with DragCallbacks {
if (!_isDragging) { if (!_isDragging) {
return; return;
} }
final cameraZoom = findGame()!.camera.viewfinder.zoom; final delta = event.localDelta;
final delta = event.delta / cameraZoom;
position.add(delta); position.add(delta);
attachedCards.forEach((card) => card.position.add(delta)); attachedCards.forEach((card) => card.position.add(delta));
} }

View File

@ -265,8 +265,7 @@ class Card extends PositionComponent
if (!_isDragging) { if (!_isDragging) {
return; return;
} }
final cameraZoom = findGame()!.camera.viewfinder.zoom; final delta = event.localDelta;
final delta = event.delta / cameraZoom;
position.add(delta); position.add(delta);
attachedCards.forEach((card) => card.position.add(delta)); attachedCards.forEach((card) => card.position.add(delta));
} }

View File

@ -502,19 +502,13 @@ the card, so that it is rendered above all others. Without this, the card would
During the drag, the `onDragUpdate` event will be called continuously. Using this callback we will During the drag, the `onDragUpdate` event will be called continuously. Using this callback we will
be updating the position of the card so that it follows the movement of the finger (or the mouse). be updating the position of the card so that it follows the movement of the finger (or the mouse).
The `event` object passed to this callback contains the most recent coordinate of the point of The `event` object passed to this callback contains the most recent coordinate of the point of
touch, and also the `delta` property -- which is the displacement vector since the previous call of touch, and also the `localDelta` property -- which is the displacement vector since the previous
`onDragUpdate`. The only problem is that this delta is measured in screen pixels, whereas we want call of `onDragUpdate`, considering the camera zoom.
it to be in game world units. The conversion between the two is given by the camera zoom level, so
we will add an extra method to determine the zoom level:
```dart ```dart
@override @override
void onDragUpdate(DragUpdateEvent event) { void onDragUpdate(DragUpdateEvent event) {
final cameraZoom = (findGame()! as FlameGame) position += event.delta;
.camera
.viewfinder
.zoom;
position += event.delta / cameraZoom;
} }
``` ```
@ -606,11 +600,7 @@ to `false`:
if (!isDragged) { if (!isDragged) {
return; return;
} }
final cameraZoom = (findGame()! as FlameGame) position += event.delta;
.camera
.viewfinder
.zoom;
position += event.delta / cameraZoom;
} }
@override @override
@ -940,11 +930,7 @@ the `onDragUpdate` method:
if (!isDragged) { if (!isDragged) {
return; return;
} }
final cameraZoom = (findGame()! as FlameGame) final delta = event.delta;
.camera
.viewfinder
.zoom;
final delta = event.delta / cameraZoom;
position.add(delta); position.add(delta);
attachedCards.forEach((card) => card.position.add(delta)); attachedCards.forEach((card) => card.position.add(delta));
} }

View File

@ -36,7 +36,7 @@ class DraggableBall extends Ball with DragCallbacks {
@override @override
void onDragUpdate(DragUpdateEvent event) { void onDragUpdate(DragUpdateEvent event) {
body.applyLinearImpulse(event.delta * 1000); body.applyLinearImpulse(event.localDelta * 1000);
} }
@override @override

View File

@ -55,7 +55,7 @@ class MouseJointWorld extends Forge2DWorld
@override @override
void onDragUpdate(DragUpdateEvent info) { void onDragUpdate(DragUpdateEvent info) {
mouseJoint?.setTarget(info.localPosition); mouseJoint?.setTarget(info.localEndPosition);
} }
@override @override

View File

@ -64,10 +64,7 @@ class DraggableBox extends Box with DragCallbacks {
@override @override
bool onDragUpdate(DragUpdateEvent info) { bool onDragUpdate(DragUpdateEvent info) {
final target = info.localPosition; final target = info.localEndPosition;
if (target.isNaN) {
return false;
}
final mouseJointDef = MouseJointDef() final mouseJointDef = MouseJointDef()
..maxForce = body.mass * 300 ..maxForce = body.mass * 300
..dampingRatio = 0 ..dampingRatio = 0

View File

@ -39,7 +39,6 @@ class DraggableEmber extends Ember with DragCallbacks {
@override @override
void onDragUpdate(DragUpdateEvent event) { void onDragUpdate(DragUpdateEvent event) {
event.continuePropagation = true; position += event.localDelta;
return;
} }
} }

View File

@ -1,3 +1,4 @@
import 'package:flame/components.dart';
import 'package:flame/extensions.dart'; import 'package:flame/extensions.dart';
import 'package:flame/src/camera/behaviors/bounded_position_behavior.dart'; import 'package:flame/src/camera/behaviors/bounded_position_behavior.dart';
import 'package:flame/src/camera/behaviors/follow_behavior.dart'; import 'package:flame/src/camera/behaviors/follow_behavior.dart';
@ -6,9 +7,6 @@ import 'package:flame/src/camera/viewfinder.dart';
import 'package:flame/src/camera/viewport.dart'; import 'package:flame/src/camera/viewport.dart';
import 'package:flame/src/camera/viewports/fixed_resolution_viewport.dart'; import 'package:flame/src/camera/viewports/fixed_resolution_viewport.dart';
import 'package:flame/src/camera/viewports/max_viewport.dart'; import 'package:flame/src/camera/viewports/max_viewport.dart';
import 'package:flame/src/camera/world.dart';
import 'package:flame/src/components/core/component.dart';
import 'package:flame/src/components/position_component.dart';
import 'package:flame/src/effects/controllers/effect_controller.dart'; import 'package:flame/src/effects/controllers/effect_controller.dart';
import 'package:flame/src/effects/move_by_effect.dart'; import 'package:flame/src/effects/move_by_effect.dart';
import 'package:flame/src/effects/move_effect.dart'; import 'package:flame/src/effects/move_effect.dart';
@ -223,22 +221,44 @@ class CameraComponent extends Component {
return viewport.localToGlobal(viewfinderPosition, output: output); return viewport.localToGlobal(viewfinderPosition, output: output);
} }
final _viewportPoint = Vector2.zero();
@override @override
Iterable<Component> componentsAtPoint( Iterable<Component> componentsAtLocation<T>(
Vector2 point, [ T locationContext,
List<Vector2>? nestedPoints, List<T>? nestedContexts,
]) sync* { T? Function(CoordinateTransform, T) transformContext,
final viewportPoint = viewport.globalToLocal(point, output: _viewportPoint); bool Function(Component, T) checkContains,
yield* viewport.componentsAtPoint(viewportPoint, nestedPoints); ) sync* {
final viewportPoint = transformContext(viewport, locationContext);
if (viewportPoint == null) {
return;
}
yield* viewport.componentsAtLocation(
viewportPoint,
nestedContexts,
transformContext,
checkContains,
);
if ((world?.isMounted ?? false) && if ((world?.isMounted ?? false) &&
currentCameras.length < maxCamerasDepth) { currentCameras.length < maxCamerasDepth) {
if (viewport.containsLocalPoint(_viewportPoint)) { if (checkContains(viewport, viewportPoint)) {
currentCameras.add(this); currentCameras.add(this);
final worldPoint = viewfinder.transform.globalToLocal(_viewportPoint); final worldPoint = transformContext(viewfinder, viewportPoint);
yield* viewfinder.componentsAtPoint(worldPoint, nestedPoints); if (worldPoint == null) {
yield* world!.componentsAtPoint(worldPoint, nestedPoints); return;
}
yield* viewfinder.componentsAtLocation(
worldPoint,
nestedContexts,
transformContext,
checkContains,
);
yield* world!.componentsAtLocation(
worldPoint,
nestedContexts,
transformContext,
checkContains,
);
currentCameras.removeLast(); currentCameras.removeLast();
} }
} }

View File

@ -63,8 +63,8 @@ import 'package:meta/meta.dart';
/// [update]d, and then all the components will be [render]ed. /// [update]d, and then all the components will be [render]ed.
/// ///
/// You may also need to override [containsLocalPoint] if the component needs to /// You may also need to override [containsLocalPoint] if the component needs to
/// respond to tap events or similar; the [componentsAtPoint] may also need to /// respond to tap events or similar; the [componentsAtLocation] may also need
/// be overridden if you have reimplemented [renderTree]. /// to be overridden if you have reimplemented [renderTree].
class Component { class Component {
Component({ Component({
Iterable<Component>? children, Iterable<Component>? children,
@ -704,28 +704,65 @@ class Component {
Iterable<Component> componentsAtPoint( Iterable<Component> componentsAtPoint(
Vector2 point, [ Vector2 point, [
List<Vector2>? nestedPoints, List<Vector2>? nestedPoints,
]) sync* { ]) {
nestedPoints?.add(point); return componentsAtLocation<Vector2>(
point,
nestedPoints,
(transform, point) => transform.parentToLocal(point),
(component, point) => component.containsLocalPoint(point),
);
}
/// This is a generic implementation of [componentsAtPoint]; refer to those
/// docs for context.
///
/// This will find components intersecting a given location context [T]. The
/// context can be a single point or a more complicated structure. How to
/// interpret the structure T is determined by the provided lambdas,
/// [transformContext] and [checkContains].
///
/// A simple choice of T would be a simple point (i.e. Vector2). In that case
/// transformContext needs to be able to transform a Vector2 on the parent
/// coordinate space into the coordinate space of a provided
/// [CoordinateTransform]; and [checkContains] must be able to determine if
/// a given [Component] "contains" the Vector2 (the definition of "contains"
/// will vary and shall be determined by the nature of the chosen location
/// context [T]).
Iterable<Component> componentsAtLocation<T>(
T locationContext,
List<T>? nestedContexts,
T? Function(CoordinateTransform, T) transformContext,
bool Function(Component, T) checkContains,
) sync* {
nestedContexts?.add(locationContext);
if (_children != null) { if (_children != null) {
for (final child in _children!.reversed()) { for (final child in _children!.reversed()) {
if (child is IgnoreEvents && child.ignoreEvents) { if (child is IgnoreEvents && child.ignoreEvents) {
continue; continue;
} }
Vector2? childPoint = point; T? childPoint = locationContext;
if (child is CoordinateTransform) { if (child is CoordinateTransform) {
childPoint = (child as CoordinateTransform).parentToLocal(point); childPoint = transformContext(
child as CoordinateTransform,
locationContext,
);
} }
if (childPoint != null) { if (childPoint != null) {
yield* child.componentsAtPoint(childPoint, nestedPoints); yield* child.componentsAtLocation(
childPoint,
nestedContexts,
transformContext,
checkContains,
);
} }
} }
} }
final shouldIgnoreEvents = final shouldIgnoreEvents =
this is IgnoreEvents && (this as IgnoreEvents).ignoreEvents; this is IgnoreEvents && (this as IgnoreEvents).ignoreEvents;
if (containsLocalPoint(point) && !shouldIgnoreEvents) { if (checkContains(this, locationContext) && !shouldIgnoreEvents) {
yield this; yield this;
} }
nestedPoints?.removeLast(); nestedContexts?.removeLast();
} }
//#endregion //#endregion

View File

@ -113,7 +113,7 @@ class JoystickComponent extends HudMarginComponent with DragCallbacks {
@override @override
bool onDragUpdate(DragUpdateEvent event) { bool onDragUpdate(DragUpdateEvent event) {
_unscaledDelta.add(event.delta); _unscaledDelta.add(event.localDelta);
return false; return false;
} }

View File

@ -12,7 +12,7 @@ import 'package:vector_math/vector_math_64.dart';
/// transformation, that is, the transform applies to all children of the /// transformation, that is, the transform applies to all children of the
/// component equally. If that is not the case (for example, the component does /// component equally. If that is not the case (for example, the component does
/// different transformations for some of its children), then that component /// different transformations for some of its children), then that component
/// must implement [Component.componentsAtPoint] method instead. /// must implement [Component.componentsAtLocation] method instead.
/// ///
/// The two methods of this interface convert between the parent's coordinate /// The two methods of this interface convert between the parent's coordinate
/// space and the local coordinates. The methods may also return `null`, /// space and the local coordinates. The methods may also return `null`,

View File

@ -3,7 +3,7 @@ import 'package:flame/src/components/core/component.dart';
/// This mixin allows a component and all it's descendants to ignore events. /// This mixin allows a component and all it's descendants to ignore events.
/// ///
/// Do note that this will also ignore the component and its descendants in /// Do note that this will also ignore the component and its descendants in
/// calls to [Component.componentsAtPoint]. /// calls to [Component.componentsAtLocation].
/// ///
/// If you want to dynamically use this mixin, you can add it and set /// If you want to dynamically use this mixin, you can add it and set
/// [ignoreEvents] true or false at runtime. /// [ignoreEvents] true or false at runtime.

View File

@ -1,13 +1,10 @@
import 'dart:ui'; import 'dart:ui';
import 'package:flame/src/components/core/component.dart'; import 'package:flame/components.dart';
import 'package:flame/src/components/mixins/parent_is_a.dart';
import 'package:flame/src/components/position_component.dart';
import 'package:flame/src/components/router_component.dart'; import 'package:flame/src/components/router_component.dart';
import 'package:flame/src/effects/effect.dart'; import 'package:flame/src/effects/effect.dart';
import 'package:flame/src/rendering/decorator.dart'; import 'package:flame/src/rendering/decorator.dart';
import 'package:meta/meta.dart'; import 'package:meta/meta.dart';
import 'package:vector_math/vector_math_64.dart';
/// [Route] is a light-weight component that builds and manages a page. /// [Route] is a light-weight component that builds and manages a page.
/// ///
@ -166,12 +163,19 @@ class Route extends PositionComponent with ParentIsA<RouterComponent> {
} }
@override @override
Iterable<Component> componentsAtPoint( Iterable<Component> componentsAtLocation<T>(
Vector2 point, [ T locationContext,
List<Vector2>? nestedPoints, List<T>? nestedContexts,
]) { T? Function(CoordinateTransform, T) transformContext,
bool Function(Component, T) checkContains,
) {
if (isRendered) { if (isRendered) {
return super.componentsAtPoint(point, nestedPoints); return super.componentsAtLocation(
locationContext,
nestedContexts,
transformContext,
checkContains,
);
} else { } else {
return const Iterable<Component>.empty(); return const Iterable<Component>.empty();
} }

View File

@ -0,0 +1,151 @@
import 'package:flame/components.dart';
import 'package:flame/src/events/messages/location_context_event.dart';
import 'package:flame/src/game/game.dart';
/// Location context for the Displacement Event.
///
/// This represents a user event that has a start and end locations, as the
/// ones that are represented by a [DisplacementEvent].
typedef DisplacementContext = ({
Vector2 start,
Vector2 end,
});
extension DisplacementContextDelta on DisplacementContext {
/// Displacement delta
Vector2 get delta => end - start;
}
/// Base class for events that contain two points on the screen, representing
/// some sort of movement (from a "start" to and "end") and having a "delta".
/// These include: drag events and (in the future) pointer move events.
///
/// This class includes properties that describe both positions where the event
/// has occurred (start and end) and the delta (i.e. displacement) represented
/// by the event.
abstract class DisplacementEvent
extends LocationContextEvent<DisplacementContext> {
DisplacementEvent(
this._game, {
required this.deviceStartPosition,
required this.deviceEndPosition,
});
final Game _game;
@Deprecated(
'use deviceStartPosition instead; will be removed in version 1.12.0',
)
late final Vector2 devicePosition = deviceStartPosition;
@Deprecated(
'use canvasStartPosition instead; will be removed in version 1.12.0',
)
late final Vector2 canvasPosition = canvasStartPosition;
@Deprecated(
'use localStartPosition instead; will be removed in version 1.12.0',
)
Vector2 get localPosition => localStartPosition;
@Deprecated('use localDelta instead; will be removed in version 1.12.0')
Vector2 get delta => localDelta;
/// Event start position in the coordinate space of the device -- either the
/// phone, or the browser window, or the app.
///
/// If the game runs in a full-screen mode, then this would be equal to the
/// [canvasStartPosition]. Otherwise, the [deviceStartPosition] is the
/// Flutter-level global position.
final Vector2 deviceStartPosition;
/// Event start position in the coordinate space of the game widget, i.e.
/// relative to the game canvas.
///
/// This could be considered the Flame-level global position.
late final Vector2 canvasStartPosition =
_game.convertGlobalToLocalCoordinate(deviceStartPosition);
/// Event start position in the local coordinate space of the current
/// component.
///
/// This property is only accessible when the event is being propagated to
/// the components via [deliverAtPoint]. It is an error to try to read this
/// property at other times.
Vector2 get localStartPosition => renderingTrace.last.start;
/// Event end position in the coordinate space of the device -- either the
/// phone, or the browser window, or the app.
///
/// If the game runs in a full-screen mode, then this would be equal to the
/// [canvasEndPosition]. Otherwise, the [deviceEndPosition] is the
/// Flutter-level global position.
final Vector2 deviceEndPosition;
/// Event end position in the coordinate space of the game widget, i.e.
/// relative to the game canvas.
///
/// This could be considered the Flame-level global position.
late final Vector2 canvasEndPosition =
_game.convertGlobalToLocalCoordinate(deviceEndPosition);
/// Event end position in the local coordinate space of the current
/// component.
///
/// This property is only accessible when the event is being propagated to
/// the components via [deliverAtPoint]. It is an error to try to read this
/// property at other times.
Vector2 get localEndPosition => renderingTrace.last.end;
/// Event delta in the coordinate space of the device -- either the
/// phone, or the browser window, or the app.
///
/// If the game runs in a full-screen mode, then this would be equal to the
/// [canvasDelta]. Otherwise, the [deviceDelta] is the
/// Flutter-level global position.
late final Vector2 deviceDelta = deviceEndPosition - deviceStartPosition;
/// Event delta in the coordinate space of the game widget, i.e.
/// relative to the game canvas.
///
/// This could be considered the Flame-level global position.
late final Vector2 canvasDelta = canvasEndPosition - canvasStartPosition;
/// Event delta in the local coordinate space of the current component.
///
/// This property is only accessible when the event is being propagated to
/// the components via [deliverAtPoint]. It is an error to try to read this
/// property at other times.
Vector2 get localDelta => localEndPosition - localStartPosition;
@override
Iterable<Component> collectApplicableChildren({
required Component rootComponent,
}) {
return rootComponent.componentsAtLocation(
(
start: canvasStartPosition,
end: canvasEndPosition,
),
renderingTrace,
(transform, context) {
final start = context.start;
final transformedStart = transform.parentToLocal(start);
final end = context.end;
final transformedEnd = transform.parentToLocal(end);
if (transformedStart == null || transformedEnd == null) {
return null;
}
return (
start: transformedStart,
end: transformedEnd,
);
},
// we only trigger the drag start if the component check passes, but
// as the user drags the cursor it could end up outside the component
// bounds; we don't want the event to be lost, so we bypass the check.
(component, context) => true,
);
}
}

View File

@ -1,29 +1,25 @@
import 'package:flame/extensions.dart'; import 'package:flame/extensions.dart';
import 'package:flame/src/events/messages/position_event.dart'; import 'package:flame/src/events/messages/displacement_event.dart';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
class DragUpdateEvent extends PositionEvent { class DragUpdateEvent extends DisplacementEvent {
DragUpdateEvent(this.pointerId, super.game, DragUpdateDetails details) DragUpdateEvent(this.pointerId, super.game, DragUpdateDetails details)
: timestamp = details.sourceTimeStamp ?? Duration.zero, : timestamp = details.sourceTimeStamp ?? Duration.zero,
delta = details.delta.toVector2(),
super( super(
devicePosition: details.globalPosition.toVector2(), deviceStartPosition: details.globalPosition.toVector2(),
deviceEndPosition:
details.globalPosition.toVector2() + details.delta.toVector2(),
); );
final int pointerId; final int pointerId;
final Duration timestamp; final Duration timestamp;
final Vector2 delta;
static final _nanPoint = Vector2.all(double.nan);
@override @override
Vector2 get localPosition { String toString() => 'DragUpdateEvent('
return renderingTrace.isEmpty ? _nanPoint : renderingTrace.last; 'devicePosition: $deviceStartPosition, '
} 'canvasPosition: $canvasStartPosition, '
'delta: $localDelta, '
@override 'pointerId: $pointerId, '
String toString() => 'DragUpdateEvent(devicePosition: $devicePosition, ' 'timestamp: $timestamp'
'canvasPosition: $canvasPosition, ' ')';
'delta: $delta, '
'pointerId: $pointerId, timestamp: $timestamp)';
} }

View File

@ -0,0 +1,50 @@
import 'package:flame/components.dart';
import 'package:flame/src/events/messages/event.dart';
import 'package:meta/meta.dart';
/// A base event that includes a location context, i.e. a position or set of
/// positions in which the event happens.
abstract class LocationContextEvent<C> extends Event {
/// The stacktrace of coordinates of the event within the components in their
/// rendering order.
///
/// This represents the stack of transformed versions of the same location
/// context -- which represents the event point -- but in the coordinate space
/// of each parent component until the root.
final List<C> renderingTrace = [];
/// The context in the parent's coordinate space, containing start and end
/// points.
C? get parentContext {
if (renderingTrace.length >= 2) {
final lastIndex = renderingTrace.length - 1;
return renderingTrace[lastIndex];
} else {
return null;
}
}
@internal
Iterable<Component> collectApplicableChildren({
required Component rootComponent,
});
@internal
void deliverAtPoint<T extends Component>({
required Component rootComponent,
required void Function(T component) eventHandler,
bool deliverToAll = false,
}) {
final children = collectApplicableChildren(
rootComponent: rootComponent,
);
for (final child in children.whereType<T>()) {
continuePropagation = deliverToAll;
eventHandler(child);
if (!continuePropagation) {
CameraComponent.currentCameras.clear();
break;
}
}
}
}

View File

@ -1,24 +1,16 @@
import 'package:flame/components.dart'; import 'package:flame/components.dart';
import 'package:flame/src/events/messages/event.dart'; import 'package:flame/src/events/messages/location_context_event.dart';
import 'package:flame/src/game/game.dart'; import 'package:flame/src/game/game.dart';
import 'package:meta/meta.dart';
/// Base class for events that originate at some point on the screen. These /// Base class for events that originate at some point on the screen. These
/// include: tap events, drag events, scale events, etc. /// include: tap events, scale events, etc.
/// ///
/// This class includes properties that describe the position where the event /// This class includes properties that describe the position where the event
/// has occurred. /// has occurred.
abstract class PositionEvent extends Event { abstract class PositionEvent extends LocationContextEvent<Vector2> {
PositionEvent(this._game, {required this.devicePosition}); PositionEvent(this._game, {required this.devicePosition});
final Game _game; final Game _game;
/// Event position in the coordinate space of the game widget, i.e. relative
/// to the game canvas.
///
/// This could be considered the Flame-level global position.
late final Vector2 canvasPosition =
_game.convertGlobalToLocalCoordinate(devicePosition);
/// Event position in the coordinate space of the device -- either the phone, /// Event position in the coordinate space of the device -- either the phone,
/// or the browser window, or the app. /// or the browser window, or the app.
/// ///
@ -27,6 +19,13 @@ abstract class PositionEvent extends Event {
/// global position. /// global position.
final Vector2 devicePosition; final Vector2 devicePosition;
/// Event position in the coordinate space of the game widget, i.e. relative
/// to the game canvas.
///
/// This could be considered the Flame-level global position.
late final Vector2 canvasPosition =
_game.convertGlobalToLocalCoordinate(devicePosition);
/// Event position in the local coordinate space of the current component. /// Event position in the local coordinate space of the current component.
/// ///
/// This property is only accessible when the event is being propagated to /// This property is only accessible when the event is being propagated to
@ -34,35 +33,10 @@ abstract class PositionEvent extends Event {
/// property at other times. /// property at other times.
Vector2 get localPosition => renderingTrace.last; Vector2 get localPosition => renderingTrace.last;
/// The stacktrace of coordinates of the event within the components in their @override
/// rendering order. Iterable<Component> collectApplicableChildren({
final List<Vector2> renderingTrace = [];
Vector2? get parentPosition {
if (renderingTrace.length >= 2) {
return renderingTrace[renderingTrace.length - 2];
} else {
return null;
}
}
/// Sends the event to components of type <T> that are currently rendered at
/// the [canvasPosition].
@internal
void deliverAtPoint<T extends Component>({
required Component rootComponent, required Component rootComponent,
required void Function(T component) eventHandler,
bool deliverToAll = false,
}) { }) {
for (final child in rootComponent return rootComponent.componentsAtPoint(canvasPosition, renderingTrace);
.componentsAtPoint(canvasPosition, renderingTrace)
.whereType<T>()) {
continuePropagation = deliverToAll;
eventHandler(child);
if (!continuePropagation) {
CameraComponent.currentCameras.clear();
break;
}
}
} }
} }

View File

@ -2,6 +2,7 @@ import 'dart:ui';
import 'package:flame/camera.dart'; import 'package:flame/camera.dart';
import 'package:flame/components.dart'; import 'package:flame/components.dart';
import 'package:flame/game.dart';
import 'package:flame_test/flame_test.dart'; import 'package:flame_test/flame_test.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
@ -112,42 +113,48 @@ void main() {
size: Vector2(50, 50), size: Vector2(50, 50),
); );
testWithFlameGame('hit testing', (game) async { testWithGame(
final world = _MyWorld(); 'hit testing',
final viewport = CircularViewport.ellipse(80, 20) () => FlameGame(
..position = Vector2(20, 30); camera: CameraComponent(
final camera = CameraComponent( viewport: CircularViewport.ellipse(80, 20)
world: world, ..position = Vector2(20, 30),
viewport: viewport, world: _MyWorld(),
); ),
game.addAll([world, camera]); ),
await game.ready(); (game) async {
await game.ready();
final viewport = game.camera.viewport;
bool hit(double x, double y) { bool hit(double x, double y) {
final components = game.componentsAtPoint(Vector2(x, y)).toList(); final components = game.componentsAtPoint(Vector2(x, y)).toList();
return components.first == viewport && components[1] == world; return components.first == viewport && components[1] == game.world;
}
expect(hit(10, 20), false);
expect(hit(20, 30), false);
expect(hit(25, 35), false);
expect(hit(100, 35), true);
expect(hit(100, 50), true);
expect(hit(180, 50), true);
expect(hit(180, 49), false);
expect(hit(180, 51), false);
final nestedPoints = <Vector2>[];
final center = Vector2(100, 50);
for (final component in game.componentsAtPoint(center, nestedPoints)) {
if (component == viewport) {
continue;
} }
expect(component, world);
expect(nestedPoints.last, Vector2.zero()); expect(hit(10, 20), false);
break; expect(hit(20, 30), false);
} expect(hit(25, 35), false);
}); expect(hit(100, 35), true);
expect(hit(100, 50), true);
expect(hit(180, 50), true);
expect(hit(180, 49), false);
expect(hit(180, 51), false);
final nestedPoints = <Vector2>[];
final center = Vector2(100, 50);
for (final component in game.componentsAtPoint(
center,
nestedPoints,
)) {
if (component == viewport) {
continue;
}
expect(component, game.world);
expect(nestedPoints.last, Vector2.zero());
break;
}
},
);
test('set wrong size', () { test('set wrong size', () {
expect( expect(

View File

@ -443,34 +443,37 @@ void main() {
); );
}); });
testWithFlameGame('componentsAtPoint for transparent route', (game) async { testWithFlameGame(
final initialComponent = PositionComponent(size: Vector2.all(100)); 'componentsAtPoint for transparent route',
final newComponent = PositionComponent(size: Vector2.all(100)); (game) async {
final router = RouterComponent( final initialComponent = PositionComponent(size: Vector2.all(100));
initialRoute: 'initial', final newComponent = PositionComponent(size: Vector2.all(100));
routes: { final router = RouterComponent(
'initial': Route( initialRoute: 'initial',
() => initialComponent, routes: {
), 'initial': Route(
'new': Route( () => initialComponent,
() => newComponent, ),
transparent: true, 'new': Route(
), () => newComponent,
}, transparent: true,
)..addToParent(game); ),
await game.ready(); },
)..addToParent(game);
await game.ready();
router.pushNamed('new'); router.pushNamed('new');
await game.ready(); await game.ready();
expect( expect(
game.componentsAtPoint(Vector2(50, 50)).contains(newComponent), game.componentsAtPoint(Vector2(50, 50)).contains(newComponent),
isTrue, isTrue,
); );
expect( expect(
game.componentsAtPoint(Vector2(50, 50)).contains(initialComponent), game.componentsAtPoint(Vector2(50, 50)).contains(initialComponent),
isTrue, isTrue,
); );
}); },
);
}); });
} }

View File

@ -3,7 +3,7 @@ import 'package:flame/events.dart';
import 'package:flame/game.dart'; import 'package:flame/game.dart';
import 'package:flame/src/events/flame_game_mixins/multi_drag_dispatcher.dart'; import 'package:flame/src/events/flame_game_mixins/multi_drag_dispatcher.dart';
import 'package:flame_test/flame_test.dart'; import 'package:flame_test/flame_test.dart';
import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
void main() { void main() {
@ -211,7 +211,7 @@ void main() {
expect(game.children.elementAt(1), isA<_DragWithCallbacksComponent>()); expect(game.children.elementAt(1), isA<_DragWithCallbacksComponent>());
expect(game.children.elementAt(2), isA<MultiDragDispatcher>()); expect(game.children.elementAt(2), isA<MultiDragDispatcher>());
// regular drag // regular drag
await tester.timedDragFrom( await tester.timedDragFrom(
const Offset(50, 50), const Offset(50, 50),
const Offset(20, 0), const Offset(20, 0),
@ -221,7 +221,7 @@ void main() {
expect(nDragUpdateCalled, 8); expect(nDragUpdateCalled, 8);
expect(nDragEndCalled, 1); expect(nDragEndCalled, 1);
// cancelled drag // cancelled drag
final gesture = await tester.startGesture(const Offset(50, 50)); final gesture = await tester.startGesture(const Offset(50, 50));
await gesture.moveBy(const Offset(10, 10)); await gesture.moveBy(const Offset(10, 10));
await gesture.cancel(); await gesture.cancel();
@ -262,7 +262,7 @@ void main() {
); );
testWidgets( testWidgets(
'drag event can move outside the component bounds', 'drag event can move outside the component bounds and still fire',
(tester) async { (tester) async {
final points = <Vector2>[]; final points = <Vector2>[];
final game = FlameGame( final game = FlameGame(
@ -270,7 +270,7 @@ void main() {
_DragWithCallbacksComponent( _DragWithCallbacksComponent(
size: Vector2.all(95), size: Vector2.all(95),
position: Vector2.all(5), position: Vector2.all(5),
onDragUpdate: (e) => points.add(e.localPosition), onDragUpdate: (e) => points.add(e.localStartPosition),
), ),
], ],
); );
@ -289,16 +289,110 @@ void main() {
expect(points.length, 42); expect(points.length, 42);
expect(points.first, Vector2(75, 75)); expect(points.first, Vector2(75, 75));
expect( expect(
points.skip(1).take(20), points.skip(1),
List.generate(20, (i) => Vector2(75.0, 75.0 + i)), List.generate(41, (i) => Vector2(75.0, 75.0 + i)),
);
expect(
points.skip(21),
everyElement(predicate((Vector2 v) => v.isNaN)),
); );
}, },
); );
}); });
testWidgets(
'drag event delta respects camera & zoom',
(tester) async {
// canvas size is 800x600 so this means a 10x logical scale across
// both dimensions
final resolution = Vector2(80, 60);
final game = FlameGame(
camera: CameraComponent.withFixedResolution(
width: resolution.x,
height: resolution.y,
),
);
game.camera.viewfinder.zoom = 2;
final deltas = <Vector2>[];
await game.world.add(
_DragWithCallbacksComponent(
position: Vector2.all(-5),
size: Vector2.all(10),
onDragUpdate: (event) => deltas.add(event.localDelta),
),
);
await tester.pumpWidget(GameWidget(game: game));
await tester.pump();
await tester.pump();
final canvasSize = game.canvasSize;
await tester.dragFrom(
(canvasSize / 2).toOffset(),
Offset(canvasSize.x / 10, 0),
);
final totalDelta = deltas.reduce((a, b) => a + b);
expect(totalDelta, Vector2(4, 0));
},
);
testWidgets(
'drag event delta respects widget positioning',
(tester) async {
// canvas size is 800x600 so this means a 10x logical scale across
// both dimensions
final resolution = Vector2(80, 60);
final game = FlameGame(
camera: CameraComponent.withFixedResolution(
width: resolution.x,
height: resolution.y,
),
);
game.camera.viewfinder.zoom = 1 / 2;
final deltas = <Vector2>[];
await game.world.add(
_DragWithCallbacksComponent(
position: Vector2.all(-5),
size: Vector2.all(10),
onDragUpdate: (event) => deltas.add(event.localDelta),
),
);
await tester.pumpWidget(
MaterialApp(
home: Stack(
children: [
Positioned(
left: 100.0,
top: 200.0,
width: 800,
height: 600,
child: GameWidget(game: game),
),
],
),
),
);
await tester.pump();
await tester.pump();
final canvasSize = game.canvasSize;
// no offset
await tester.dragFrom(
(canvasSize / 2).toOffset(),
Offset(canvasSize.x / 10, 0),
);
expect(deltas, isEmpty);
// accounting for offset
await tester.dragFrom(
(canvasSize / 2 + Vector2(100, 200)).toOffset(),
Offset(canvasSize.x / 10, 0),
);
expect(deltas, isNotEmpty);
final totalDelta = deltas.reduce((a, b) => a + b);
expect(totalDelta, Vector2(16, 0));
},
);
} }
mixin _DragCounter on DragCallbacks { mixin _DragCounter on DragCallbacks {