Created
April 4, 2026 03:03
-
-
Save shinayser/33a7df3af5caa83a83383e435b645dfb to your computer and use it in GitHub Desktop.
A Behavior (flame_behavior) that adds a Pan and Zoom behavior to a Entity in Flame engine.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import 'dart:ui'; | |
| import 'package:flame/components.dart'; | |
| import 'package:flame/events.dart'; | |
| import 'package:flame/rendering.dart'; | |
| import 'package:flame_behaviors/flame_behaviors.dart'; | |
| /// A [Behavior] that adds pan and zoom to its parent entity, | |
| /// simulating camera movement. | |
| /// | |
| /// The parent must be a [PositionComponent] with [EntityMixin]. | |
| /// | |
| /// ```dart | |
| /// class Warrior extends SpriteComponent with EntityMixin { | |
| /// @override | |
| /// Future<void> onLoad() async { | |
| /// await add(PanZoomBehavior()); | |
| /// } | |
| /// } | |
| /// ``` | |
| /// | |
| /// For mouse scroll zoom, call [applyZoom] from an | |
| /// external handler (e.g. [ScrollDetector] at the game level). | |
| class PanZoomBehavior extends Behavior<EntityMixin> with DragCallbacks { | |
| PanZoomBehavior({ | |
| double minZoom = 0.5, | |
| double maxZoom = 3.0, | |
| double zoomStep = 0.1, | |
| super.key, | |
| }) : _minZoom = minZoom, | |
| _maxZoom = maxZoom, | |
| _zoomStep = zoomStep; | |
| final double _minZoom; | |
| final double _maxZoom; | |
| final double _zoomStep; | |
| final Vector2 _panOffset = Vector2.zero(); | |
| double _zoom = 1.0; | |
| // Pointer tracking (positions in canvas space) | |
| final Map<int, Vector2> _pointers = {}; | |
| // Pinch state | |
| double _pinchStartDist = 0; | |
| double _zoomAtPinchStart = 1.0; | |
| final Vector2 _lastPinchCenter = Vector2.zero(); | |
| late final _PanZoomDecorator _decorator; | |
| PositionComponent get _parentComp => parent as PositionComponent; | |
| // ── Configuration ───────────────────────────────────────────────── | |
| /// Minimum allowed zoom level. | |
| double get minZoom => _minZoom; | |
| /// Maximum allowed zoom level. | |
| double get maxZoom => _maxZoom; | |
| /// Zoom increment used by [zoomIn] / [zoomOut]. | |
| double get zoomStep => _zoomStep; | |
| // ── State (read-only) ────────────────────────────────────────────── | |
| /// Current pan offset (copy). | |
| Vector2 get panOffset => _panOffset.clone(); | |
| /// Current zoom level. | |
| double get currentZoom => _zoom; | |
| // ── Lifecycle ────────────────────────────────────────────────────── | |
| @override | |
| void onMount() { | |
| super.onMount(); | |
| assert( | |
| parent is PositionComponent, | |
| 'PanZoomBehavior parent must be a PositionComponent', | |
| ); | |
| _decorator = _PanZoomDecorator(_panOffset, () => _zoom); | |
| _parentComp.decorator.addLast(_decorator); | |
| } | |
| @override | |
| void onRemove() { | |
| if (parent is PositionComponent) { | |
| _parentComp.decorator.removeLast(); | |
| } | |
| super.onRemove(); | |
| } | |
| // ── Gestures (DragCallbacks) ─────────────────────────────────────── | |
| @override | |
| void onDragStart(DragStartEvent event) { | |
| super.onDragStart(event); | |
| _pointers[event.pointerId] = event.canvasPosition.clone(); | |
| if (_pointers.length == 2) { | |
| _beginPinch(); | |
| } | |
| } | |
| @override | |
| void onDragUpdate(DragUpdateEvent event) { | |
| _pointers[event.pointerId] = event.canvasEndPosition.clone(); | |
| if (_pointers.length == 1) { | |
| // 1 finger → pan | |
| _panOffset.add(event.localDelta); | |
| } else if (_pointers.length >= 2) { | |
| // 2+ fingers → pinch (zoom + pan) | |
| _handlePinchUpdate(event); | |
| } | |
| } | |
| @override | |
| void onDragEnd(DragEndEvent event) { | |
| super.onDragEnd(event); | |
| _pointers.remove(event.pointerId); | |
| // Re-initialize pinch if back to exactly 2 fingers | |
| if (_pointers.length == 2) { | |
| _beginPinch(); | |
| } | |
| } | |
| // ── Internal pinch ──────────────────────────────────────────────── | |
| void _beginPinch() { | |
| _pinchStartDist = _pinchDistance(); | |
| _zoomAtPinchStart = _zoom; | |
| _lastPinchCenter.setFrom(_pinchCenter()); | |
| } | |
| void _handlePinchUpdate(DragUpdateEvent event) { | |
| final center = _pinchCenter(); | |
| final distance = _pinchDistance(); | |
| // Zoom by the distance ratio between fingers | |
| if (_pinchStartDist > 1.0) { | |
| final targetZoom = | |
| (_zoomAtPinchStart * distance / _pinchStartDist).clamp( | |
| minZoom, | |
| maxZoom, | |
| ); | |
| if (targetZoom != _zoom) { | |
| final focal = _parentComp.size / 2; | |
| final ratio = targetZoom / _zoom; | |
| _panOffset | |
| ..x = focal.x - (focal.x - _panOffset.x) * ratio | |
| ..y = focal.y - (focal.y - _panOffset.y) * ratio; | |
| _zoom = targetZoom; | |
| } | |
| } | |
| // Pan by the pinch center displacement. | |
| // Each finger contributes half the delta to the center; since we | |
| // receive one update per finger, we use half of the current finger's localDelta. | |
| final delta = event.localDelta; | |
| if (!delta.x.isNaN && !delta.y.isNaN) { | |
| _panOffset.add(delta * 0.5); | |
| } | |
| _lastPinchCenter.setFrom(center); | |
| } | |
| Vector2 _pinchCenter() { | |
| final pts = _pointers.values.toList(); | |
| return (pts[0] + pts[1]) / 2; | |
| } | |
| double _pinchDistance() { | |
| final pts = _pointers.values.toList(); | |
| return pts[0].distanceTo(pts[1]); | |
| } | |
| // ── Programmatic zoom ───────────────────────────────────────────── | |
| /// Changes zoom by [delta] around [focalPoint] (in the component's | |
| /// local coordinates). If [focalPoint] is `null`, uses the center. | |
| /// | |
| /// Useful for integrating with mouse scroll from the game: | |
| /// ```dart | |
| /// // In FlameGame: | |
| /// void onScroll(PointerScrollInfo info) { | |
| /// final delta = info.scrollDelta.global.y > 0 ? -0.1 : 0.1; | |
| /// myEntity.findBehavior<PanZoomBehavior>().applyZoom(delta); | |
| /// } | |
| /// ``` | |
| void applyZoom(double delta, {Vector2? focalPoint}) { | |
| final focal = focalPoint ?? (_parentComp.size / 2); | |
| final oldZoom = _zoom; | |
| _zoom = (_zoom + delta).clamp(minZoom, maxZoom); | |
| if (_zoom == oldZoom) return; | |
| final ratio = _zoom / oldZoom; | |
| _panOffset | |
| ..x = focal.x - (focal.x - _panOffset.x) * ratio | |
| ..y = focal.y - (focal.y - _panOffset.y) * ratio; | |
| } | |
| /// Zooms in by [zoomStep] around an optional [focalPoint]. | |
| void zoomIn({Vector2? focalPoint}) => | |
| applyZoom(zoomStep, focalPoint: focalPoint); | |
| /// Zooms out by [zoomStep] around an optional [focalPoint]. | |
| void zoomOut({Vector2? focalPoint}) => | |
| applyZoom(-zoomStep, focalPoint: focalPoint); | |
| // ── Coordinate conversion ────────────────────────────────────────── | |
| /// Converts a point from the component's local space to | |
| /// content space (accounting for pan and zoom). | |
| Vector2 localToContent(Vector2 localPoint) { | |
| return (localPoint - _panOffset) / _zoom; | |
| } | |
| /// Converts a point from content space to the component's local space. | |
| Vector2 contentToLocal(Vector2 contentPoint) { | |
| return contentPoint * _zoom + _panOffset; | |
| } | |
| // ── Utilities ────────────────────────────────────────────────────── | |
| /// Resets pan and zoom to default values (origin, 1×). | |
| void resetView() { | |
| _panOffset.setZero(); | |
| _zoom = 1.0; | |
| _pointers.clear(); | |
| } | |
| } | |
| /// Decorator that applies pan/zoom canvas transforms. | |
| /// | |
| /// Injected into the parent's decorator chain by [PanZoomBehavior]. | |
| class _PanZoomDecorator extends Decorator { | |
| _PanZoomDecorator(this._panOffset, this._zoomGetter); | |
| final Vector2 _panOffset; | |
| final double Function() _zoomGetter; | |
| @override | |
| void apply(void Function(Canvas) draw, Canvas canvas) { | |
| canvas.save(); | |
| canvas.translate(_panOffset.x, _panOffset.y); | |
| final zoom = _zoomGetter(); | |
| canvas.scale(zoom, zoom); | |
| draw(canvas); | |
| canvas.restore(); | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment