feat: Added componentsAtPoint() iterable (#1518)

This commit is contained in:
Pasha Stetsenko
2022-04-25 10:52:21 -07:00
committed by GitHub
parent 5591c10943
commit b99e35120d
19 changed files with 355 additions and 30 deletions

View File

@ -190,6 +190,32 @@ void update(double dt) {
``` ```
### Querying components at a specific point on the screen
The method `componentsAtPoint()` allows you to check which components have been rendered at a
specific point on the screen. The returned value is an iterable which contains both the components
and the coordinates of the query point in those components' local coordinates. The iterable
retrieves the components in the front-to-back order, i.e. first the components in the front,
followed by the components in the back.
This method can only return components that implement the method `containsLocalPoint()`. The
`PositionComponent` (which is the base class for many components in Flame) provides such an
implementation. However, if you're defining a custom class that derives from `Component`, you'd have
to implement the `containsLocalPoint()` method yourself.
Here is an example of how `componentsAtPoint()` can be used:
```dart
void onDragUpdate(DragUpdateInfo info) {
game.componentsAtPoint(info.widget).forEach((p) {
if (p.component is DropTarget) {
p.component.highlight();
}
});
}
```
### PositionType ### PositionType
If you want to create a HUD (Head-up display) or another component that isn't positioned in relation If you want to create a HUD (Head-up display) or another component that isn't positioned in relation
to the game coordinates, you can change the `PositionType` of the component. to the game coordinates, you can change the `PositionType` of the component.
@ -370,7 +396,7 @@ use `animation.completed`.
Example: Example:
```dart ```dart
await animation.completed; await animation.completed;
doSomething(); doSomething();
// or alternatively // or alternatively

View File

@ -3,8 +3,9 @@ import 'dart:ui';
import 'package:flame/components.dart'; import 'package:flame/components.dart';
import 'package:flame/experimental.dart'; import 'package:flame/experimental.dart';
import 'package:flame/game.dart' hide Viewport; import 'package:flame/game.dart' hide Viewport;
import 'package:flame/input.dart';
class CameraComponentPropertiesExample extends FlameGame { class CameraComponentPropertiesExample extends FlameGame with HasTappables {
static const description = ''' static const description = '''
This example uses FixedSizeViewport which is dynamically sized and This example uses FixedSizeViewport which is dynamically sized and
positioned based on the size of the game widget. positioned based on the size of the game widget.
@ -13,6 +14,8 @@ class CameraComponentPropertiesExample extends FlameGame {
green dot being the origin. The viewfinder uses custom anchor in order to green dot being the origin. The viewfinder uses custom anchor in order to
declare its "center" half-way between the bottom left corner and the true declare its "center" half-way between the bottom left corner and the true
center. center.
Click at any point within the viewport to create a circle there.
'''; ''';
CameraComponent? _camera; CameraComponent? _camera;
@ -41,6 +44,19 @@ class CameraComponentPropertiesExample extends FlameGame {
_camera?.viewport.size = size * 0.7; _camera?.viewport.size = size * 0.7;
_camera?.viewport.position = size * 0.6; _camera?.viewport.position = size * 0.6;
} }
@override
// ignore: must_call_super
void onTapDown(int pointerId, TapDownInfo info) {
final canvasPoint = info.eventPosition.widget;
for (final cp in componentsAtPoint(canvasPoint)) {
if (cp.component is Background) {
cp.component.add(
ExpandingCircle(cp.point.toOffset()),
);
}
}
}
} }
class ViewportFrame extends Component { class ViewportFrame extends Component {
@ -64,7 +80,7 @@ class ViewportFrame extends Component {
class Background extends Component { class Background extends Component {
final bgPaint = Paint()..color = const Color(0xffff0000); final bgPaint = Paint()..color = const Color(0xffff0000);
final originPaint = Paint()..color = const Color(0xff2f8750); final originPaint = Paint()..color = const Color(0xff19bf57);
final axisPaint = Paint() final axisPaint = Paint()
..strokeWidth = 1 ..strokeWidth = 1
..style = PaintingStyle.stroke ..style = PaintingStyle.stroke
@ -85,4 +101,33 @@ class Background extends Component {
canvas.drawLine(Offset.zero, const Offset(10, 0), axisPaint); canvas.drawLine(Offset.zero, const Offset(10, 0), axisPaint);
canvas.drawCircle(Offset.zero, 1.0, originPaint); canvas.drawCircle(Offset.zero, 1.0, originPaint);
} }
@override
bool containsLocalPoint(Vector2 point) => true;
}
class ExpandingCircle extends CircleComponent {
ExpandingCircle(Offset center)
: super(
position: Vector2(center.dx, center.dy),
anchor: Anchor.center,
radius: 0,
paint: Paint()
..color = const Color(0xffffffff)
..style = PaintingStyle.stroke
..strokeWidth = 1,
);
static const maxRadius = 50;
@override
void update(double dt) {
radius += dt * 10;
if (radius >= maxRadius) {
removeFromParent();
} else {
final opacity = 1 - radius / maxRadius;
paint.color = const Color(0xffffffff).withOpacity(opacity);
}
}
} }

View File

@ -3,6 +3,7 @@ export 'src/anchor.dart';
export 'src/collisions/has_collision_detection.dart'; export 'src/collisions/has_collision_detection.dart';
export 'src/collisions/hitboxes/screen_hitbox.dart'; export 'src/collisions/hitboxes/screen_hitbox.dart';
export 'src/components/component.dart'; export 'src/components/component.dart';
export 'src/components/component_point_pair.dart';
export 'src/components/component_set.dart'; export 'src/components/component_set.dart';
export 'src/components/custom_painter_component.dart'; export 'src/components/custom_painter_component.dart';
export 'src/components/input/joystick_component.dart'; export 'src/components/input/joystick_component.dart';

View File

@ -3,11 +3,16 @@ import 'dart:collection';
import 'package:flutter/painting.dart'; import 'package:flutter/painting.dart';
import 'package:meta/meta.dart'; import 'package:meta/meta.dart';
import 'package:vector_math/vector_math_64.dart';
import '../../components.dart';
import '../../game.dart';
import '../../input.dart';
import '../cache/value_cache.dart'; import '../cache/value_cache.dart';
import '../game/mixins/game.dart';
import '../gestures/events.dart';
import '../text.dart';
import 'component_point_pair.dart';
import 'component_set.dart';
import 'mixins/coordinate_transform.dart';
import 'position_type.dart';
/// [Component]s are the basic building blocks for your game. /// [Component]s are the basic building blocks for your game.
/// ///
@ -613,10 +618,56 @@ class Component {
return (parent is T ? parent : parent?.findParent<T>()) as T?; return (parent is T ? parent : parent?.findParent<T>()) as T?;
} }
/// Called to check whether the point is to be counted as within the component /// Checks whether the [point] is within this component's bounds.
/// It needs to be overridden to have any effect, like it is in ///
/// PositionComponent. /// This method should be implemented for any component that has a visual
bool containsPoint(Vector2 point) => false; /// representation and non-zero size. The [point] is in the local coordinate
/// space.
bool containsLocalPoint(Vector2 point) => false;
/// Same as [containsLocalPoint], but for a "global" [point].
///
/// This will be deprecated in the future, due to the notion of "global" point
/// not being well-defined.
bool containsPoint(Vector2 point) => containsLocalPoint(point);
/// An iterable of descendant components intersecting the given point. The
/// [point] is in the local coordinate space.
///
/// More precisely, imagine a ray originating at a certain point (x, y) on
/// the screen, and extending perpendicularly to the screen's surface into
/// your game's world. The purpose of this method is to find all components
/// that intersect with this ray, in the order from those that are closest to
/// the user to those that are farthest.
///
/// The return value is an [Iterable] of `(component, point)` pairs, which
/// gives not only the components themselves, but also the points of
/// intersection, in their respective local coordinates.
///
/// The default implementation relies on the [CoordinateTransform] interface
/// to translate from the parent's coordinate system into the local one. Make
/// sure that your component implements this interface if it alters the
/// coordinate system when rendering.
///
/// If your component overrides [renderTree], then it almost certainly needs
/// to override this method as well, so that this method can find all rendered
/// components wherever they are.
Iterable<ComponentPointPair> componentsAtPoint(Vector2 point) sync* {
if (_children != null) {
for (final child in _children!.reversed()) {
Vector2? childPoint = point;
if (child is CoordinateTransform) {
childPoint = (child as CoordinateTransform).parentToLocal(point);
}
if (childPoint != null) {
yield* child.componentsAtPoint(childPoint);
}
}
}
if (containsLocalPoint(point)) {
yield ComponentPointPair(this, point);
}
}
/// Usually this is not something that the user would want to call since the /// Usually this is not something that the user would want to call since the
/// component list isn't re-ordered when it is called. /// component list isn't re-ordered when it is called.

View File

@ -0,0 +1,27 @@
import 'dart:ui';
import 'package:meta/meta.dart';
import 'package:vector_math/vector_math_64.dart';
import 'component.dart';
/// A simple tuple of a component and a point. This is a helper class for the
/// [Component.componentsAtPoint] method.
@immutable
class ComponentPointPair {
const ComponentPointPair(this.component, this.point);
final Component component;
final Vector2 point;
@override
bool operator ==(Object other) =>
other is ComponentPointPair &&
other.component == component &&
other.point == point;
@override
int get hashCode => hashValues(component, point);
@override
String toString() => '<$component, $point>';
}

View File

@ -0,0 +1,24 @@
import 'package:vector_math/vector_math_64.dart';
import '../component.dart';
/// Interface to be implemented by components that perform a coordinate change.
///
/// Any [Component] that does any coordinate transformation of the canvas during
/// rendering should consider implementing this interface in order to describe
/// how the points from the parent's coordinate system relate to the component's
/// local coordinate system.
///
/// This interface assumes that the component performs a "uniform" coordinate
/// transformation, that is, the transform applies to all children of the
/// component equally. If that is not the case (for example, the component does
/// different transformations for some of its children), then that component
/// must implement [Component.componentsAtPoint] method instead.
///
/// The two methods of this interface convert between the parent's coordinate
/// space and the local coordinates. The methods may also return `null`,
/// indicating that the given cannot be mapped to any local/parent point.
abstract class CoordinateTransform {
Vector2? parentToLocal(Vector2 point);
Vector2? localToParent(Vector2 point);
}

View File

@ -8,6 +8,7 @@ import '../extensions/vector2.dart';
import '../game/notifying_vector2.dart'; import '../game/notifying_vector2.dart';
import '../game/transform2d.dart'; import '../game/transform2d.dart';
import 'component.dart'; import 'component.dart';
import 'mixins/coordinate_transform.dart';
/// A [Component] implementation that represents an object that can be /// A [Component] implementation that represents an object that can be
/// freely moved around the screen, rotated, and scaled. /// freely moved around the screen, rotated, and scaled.
@ -59,7 +60,12 @@ import 'component.dart';
/// do not specify the size of a PositionComponent, then it will be /// do not specify the size of a PositionComponent, then it will be
/// equal to zero and the component won't be able to respond to taps. /// equal to zero and the component won't be able to respond to taps.
class PositionComponent extends Component class PositionComponent extends Component
implements AnchorProvider, AngleProvider, PositionProvider, ScaleProvider { implements
AnchorProvider,
AngleProvider,
PositionProvider,
ScaleProvider,
CoordinateTransform {
PositionComponent({ PositionComponent({
Vector2? position, Vector2? position,
Vector2? size, Vector2? size,
@ -214,14 +220,24 @@ class PositionComponent extends Component
/// component. The top and the left borders of the component are inclusive, /// component. The top and the left borders of the component are inclusive,
/// while the bottom and the right borders are exclusive. /// while the bottom and the right borders are exclusive.
@override @override
bool containsPoint(Vector2 point) { bool containsLocalPoint(Vector2 point) {
final local = absoluteToLocal(point); return (point.x >= 0) &&
return (local.x >= 0) && (point.y >= 0) &&
(local.y >= 0) && (point.x < _size.x) &&
(local.x < _size.x) && (point.y < _size.y);
(local.y < _size.y);
} }
@override
bool containsPoint(Vector2 point) {
return containsLocalPoint(absoluteToLocal(point));
}
@override
Vector2 parentToLocal(Vector2 point) => transform.globalToLocal(point);
@override
Vector2 localToParent(Vector2 point) => transform.localToGlobal(point);
/// Convert local coordinates of a point [point] inside the component /// Convert local coordinates of a point [point] inside the component
/// into the parent's coordinate space. /// into the parent's coordinate space.
Vector2 positionOf(Vector2 point) { Vector2 positionOf(Vector2 point) {

View File

@ -4,6 +4,7 @@ import 'package:meta/meta.dart';
import 'package:vector_math/vector_math_64.dart'; import 'package:vector_math/vector_math_64.dart';
import '../components/component.dart'; import '../components/component.dart';
import '../components/component_point_pair.dart';
import '../components/position_component.dart'; import '../components/position_component.dart';
import '../effects/controllers/effect_controller.dart'; import '../effects/controllers/effect_controller.dart';
import '../effects/move_effect.dart'; import '../effects/move_effect.dart';
@ -92,7 +93,7 @@ class CameraComponent extends Component {
viewport.clip(canvas); viewport.clip(canvas);
try { try {
currentCameras.add(this); currentCameras.add(this);
canvas.transform(viewfinder.transformMatrix.storage); canvas.transform(viewfinder.transform.transformMatrix.storage);
world.renderFromCamera(canvas); world.renderFromCamera(canvas);
viewfinder.renderTree(canvas); viewfinder.renderTree(canvas);
} finally { } finally {
@ -105,6 +106,24 @@ class CameraComponent extends Component {
canvas.restore(); canvas.restore();
} }
@override
Iterable<ComponentPointPair> componentsAtPoint(Vector2 point) sync* {
final viewportPoint = point - viewport.position;
if (world.isMounted && currentCameras.length < maxCamerasDepth) {
if (viewport.containsPoint(viewportPoint)) {
try {
currentCameras.add(this);
final worldPoint = viewfinder.transform.globalToLocal(viewportPoint);
yield* world.componentsAtPointFromCamera(worldPoint);
yield* viewfinder.componentsAtPoint(worldPoint);
} finally {
currentCameras.removeLast();
}
}
}
yield* viewport.componentsAtPoint(viewportPoint);
}
/// A camera that currently performs rendering. /// A camera that currently performs rendering.
/// ///
/// This variable is set to `this` when we begin rendering the world through /// This variable is set to `this` when we begin rendering the world through

View File

@ -12,14 +12,19 @@ class CircularViewport extends Viewport {
} }
Path _clipPath = Path(); Path _clipPath = Path();
double _radiusSquared = 0;
@override @override
void clip(Canvas canvas) => canvas.clipPath(_clipPath, doAntiAlias: false); void clip(Canvas canvas) => canvas.clipPath(_clipPath, doAntiAlias: false);
@override
bool containsLocalPoint(Vector2 point) => point.length2 <= _radiusSquared;
@override @override
void onViewportResize() { void onViewportResize() {
assert(size.x == size.y, 'Viewport shape is not circular: $size');
final x = size.x / 2; final x = size.x / 2;
final y = size.y / 2; _clipPath = Path()..addOval(Rect.fromLTRB(-x, -x, x, x));
_clipPath = Path()..addOval(Rect.fromLTRB(-x, -y, x, y)); _radiusSquared = x * x;
} }
} }

View File

@ -23,7 +23,12 @@ class FixedAspectRatioViewport extends Viewport {
} }
@override @override
void clip(Canvas canvas) => canvas.clipRect(_clipRect); void clip(Canvas canvas) => canvas.clipRect(_clipRect, doAntiAlias: false);
@override
bool containsLocalPoint(Vector2 point) {
return point.x.abs() <= size.x / 2 && point.y.abs() <= size.y / 2;
}
@override @override
void onViewportResize() { void onViewportResize() {

View File

@ -24,6 +24,11 @@ class FixedSizeViewport extends Viewport {
@override @override
void clip(Canvas canvas) => canvas.clipRect(_clipRect, doAntiAlias: false); void clip(Canvas canvas) => canvas.clipRect(_clipRect, doAntiAlias: false);
@override
bool containsLocalPoint(Vector2 point) {
return point.x.abs() <= size.x / 2 && point.y.abs() <= size.y / 2;
}
@override @override
void onViewportResize() { void onViewportResize() {
final x = size.x / 2; final x = size.x / 2;

View File

@ -21,6 +21,9 @@ class MaxViewport extends Viewport {
@override @override
void clip(Canvas canvas) {} void clip(Canvas canvas) {}
@override
bool containsLocalPoint(Vector2 point) => true;
@override @override
void onViewportResize() {} void onViewportResize() {}
} }

View File

@ -21,7 +21,7 @@ class Viewfinder extends Component
final Transform2D _transform = Transform2D(); final Transform2D _transform = Transform2D();
@internal @internal
Matrix4 get transformMatrix => _transform.transformMatrix; Transform2D get transform => _transform;
/// The game coordinates of a point that is to be positioned at the center /// The game coordinates of a point that is to be positioned at the center
/// of the viewport. /// of the viewport.

View File

@ -66,6 +66,14 @@ abstract class Viewport extends Component implements PositionProvider {
/// This API must be implemented by all viewports. /// This API must be implemented by all viewports.
void clip(Canvas canvas); void clip(Canvas canvas);
/// Tests whether the given point lies within the viewport.
///
/// This method must be consistent with the action of [clip], in the sense
/// that [containsLocalPoint] must return true if and only if that point on
/// the canvas is not clipped by [clip].
@override
bool containsLocalPoint(Vector2 point);
/// Override in order to perform a custom action upon resize. /// Override in order to perform a custom action upon resize.
/// ///
/// A typical use-case would be to adjust the viewport's clip mask to match /// A typical use-case would be to adjust the viewport's clip mask to match

View File

@ -1,8 +1,10 @@
import 'dart:ui'; import 'dart:ui';
import 'package:meta/meta.dart'; import 'package:meta/meta.dart';
import 'package:vector_math/vector_math_64.dart';
import '../components/component.dart'; import '../components/component.dart';
import '../components/component_point_pair.dart';
import 'camera_component.dart'; import 'camera_component.dart';
/// The root component for all game world elements. /// The root component for all game world elements.
@ -20,4 +22,14 @@ class World extends Component {
assert(CameraComponent.currentCamera != null); assert(CameraComponent.currentCamera != null);
super.renderTree(canvas); super.renderTree(canvas);
} }
@override
Iterable<ComponentPointPair> componentsAtPoint(Vector2 point) {
return const Iterable.empty();
}
@internal
Iterable<ComponentPointPair> componentsAtPointFromCamera(Vector2 point) {
return super.componentsAtPoint(point);
}
} }

View File

@ -131,8 +131,8 @@ class FlameGame extends Component with Game {
/// Whether a point is within the boundaries of the visible part of the game. /// Whether a point is within the boundaries of the visible part of the game.
@override @override
bool containsPoint(Vector2 p) { bool containsLocalPoint(Vector2 p) {
return p.x > 0 && p.y > 0 && p.x < size.x && p.y < size.y; return p.x >= 0 && p.y >= 0 && p.x < size.x && p.y < size.y;
} }
/// Returns the current time in seconds with microseconds precision. /// Returns the current time in seconds with microseconds precision.

View File

@ -85,6 +85,14 @@ class CircleComponent extends ShapeComponent {
scaledRadius * scaledRadius; scaledRadius * scaledRadius;
} }
@override
bool containsLocalPoint(Vector2 point) {
final radius = size.x / 2;
final dx = point.x - radius;
final dy = point.y - radius;
return dx * dx + dy * dy <= radius * radius;
}
/// Returns the locus of points in which the provided line segment intersect /// Returns the locus of points in which the provided line segment intersect
/// the circle. /// the circle.
/// ///
@ -94,7 +102,7 @@ class CircleComponent extends ShapeComponent {
LineSegment line, { LineSegment line, {
double epsilon = double.minPositive, double epsilon = double.minPositive,
}) { }) {
double sq(double x) => pow(x, 2).toDouble(); double sq(double x) => x * x;
final cx = absoluteCenter.x; final cx = absoluteCenter.x;
final cy = absoluteCenter.y; final cy = absoluteCenter.y;

View File

@ -1,13 +1,16 @@
import 'dart:math'; import 'dart:math';
import 'dart:ui' hide Canvas; import 'dart:ui';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:meta/meta.dart'; import 'package:meta/meta.dart';
import '../../cache.dart'; import '../anchor.dart';
import '../../components.dart'; import '../cache/value_cache.dart';
import '../../extensions.dart'; import '../components/component.dart';
import '../../geometry.dart'; import '../extensions/rect.dart';
import '../extensions/vector2.dart';
import 'line_segment.dart';
import 'shape_component.dart';
class PolygonComponent extends ShapeComponent { class PolygonComponent extends ShapeComponent {
final List<Vector2> _vertices; final List<Vector2> _vertices;
@ -221,6 +224,23 @@ class PolygonComponent extends ShapeComponent {
return true; return true;
} }
@override
bool containsLocalPoint(Vector2 point) {
if (size.x == 0 || size.y == 0) {
return false;
}
for (var i = 0; i < _vertices.length; i++) {
final edge = getEdge(i, vertices: vertices);
final isOutside = (edge.to.x - edge.from.x) * (point.y - edge.from.y) -
(point.x - edge.from.x) * (edge.to.y - edge.from.y) >
0;
if (isOutside) {
return false;
}
}
return true;
}
/// Return all vertices as [LineSegment]s that intersect [rect], if [rect] /// Return all vertices as [LineSegment]s that intersect [rect], if [rect]
/// is null return all vertices as [LineSegment]s. /// is null return all vertices as [LineSegment]s.
List<LineSegment> possibleIntersectionVertices(Rect? rect) { List<LineSegment> possibleIntersectionVertices(Rect? rect) {

View File

@ -501,6 +501,56 @@ void main() {
}, },
); );
}); });
group('componentsAtPoint', () {
testWithFlameGame('nested components', (game) async {
final componentA = PositionComponent()
..size = Vector2(200, 150)
..scale = Vector2.all(2)
..position = Vector2(350, 50)
..addToParent(game);
final componentB = CircleComponent(radius: 10)
..position = Vector2(150, 75)
..anchor = Anchor.center
..addToParent(componentA);
await game.ready();
expect(
game.componentsAtPoint(Vector2.zero()).toList(),
[ComponentPointPair(game, Vector2.zero())],
);
expect(
game.componentsAtPoint(Vector2(400, 100)).toList(),
[
ComponentPointPair(componentA, Vector2(25, 25)),
ComponentPointPair(game, Vector2(400, 100)),
],
);
expect(
game.componentsAtPoint(Vector2(650, 200)).toList(),
[
ComponentPointPair(componentB, Vector2(10, 10)),
ComponentPointPair(componentA, Vector2(150, 75)),
ComponentPointPair(game, Vector2(650, 200)),
],
);
expect(
game.componentsAtPoint(Vector2(664, 214)).toList(),
[
ComponentPointPair(componentB, Vector2(17, 17)),
ComponentPointPair(componentA, Vector2(157, 82)),
ComponentPointPair(game, Vector2(664, 214)),
],
);
expect(
game.componentsAtPoint(Vector2(664, 216)).toList(),
[
ComponentPointPair(componentA, Vector2(157, 83)),
ComponentPointPair(game, Vector2(664, 216)),
],
);
});
});
}); });
} }