diff --git a/example/lib/src/common/widget/routes.dart b/example/lib/src/common/widget/routes.dart index e146ab8..28da0ee 100644 --- a/example/lib/src/common/widget/routes.dart +++ b/example/lib/src/common/widget/routes.dart @@ -3,6 +3,7 @@ import 'package:repaintexample/src/feature/clock/clock_screen.dart'; import 'package:repaintexample/src/feature/fps/fps_screen.dart'; import 'package:repaintexample/src/feature/home/home_screen.dart'; import 'package:repaintexample/src/feature/performance_overlay/performance_overlay_screen.dart'; +import 'package:repaintexample/src/feature/quadtree/quadtree_collision_screen.dart'; import 'package:repaintexample/src/feature/quadtree/quadtree_screen.dart'; import 'package:repaintexample/src/feature/shaders/fragment_shaders_screen.dart'; import 'package:repaintexample/src/feature/sunflower/sunflower_screen.dart'; @@ -45,4 +46,9 @@ final Map Function(Map?)> $routes = child: const QuadTreeScreen(), arguments: arguments, ), + 'quadtree-collisions': (arguments) => MaterialPage( + name: 'quadtree-collisions', + child: const QuadTreeCollisionScreen(), + arguments: arguments, + ), }; diff --git a/example/lib/src/feature/home/home_screen.dart b/example/lib/src/feature/home/home_screen.dart index 4ac4b24..a3e074b 100644 --- a/example/lib/src/feature/home/home_screen.dart +++ b/example/lib/src/feature/home/home_screen.dart @@ -48,6 +48,10 @@ class HomeScreen extends StatelessWidget { title: 'QuadTree', page: 'quadtree', ), + HomeTile( + title: 'QuadTree collisions', + page: 'quadtree-collisions', + ), ], ), ), diff --git a/example/lib/src/feature/quadtree/quadtree_camera.dart b/example/lib/src/feature/quadtree/quadtree_camera.dart new file mode 100644 index 0000000..2c23b7d --- /dev/null +++ b/example/lib/src/feature/quadtree/quadtree_camera.dart @@ -0,0 +1,112 @@ +import 'dart:ui'; + +import 'package:repaint/repaint.dart'; + +mixin QuadTreeCameraMixin { + final QuadTree quadTree = QuadTree( + boundary: const Rect.fromLTWH(0, 0, 100000, 100000), + capacity: 18, + ); + + final _camera = QuadTreeCamera( + boundary: Rect.zero, + ); + Rect get cameraBoundary => _camera.boundary; + Size size = Size.zero; + + bool needsPaintQt = false; + + void mountQtCamera() { + _camera.set(Rect.fromCenter( + center: quadTree.boundary.center, + width: size.width, + height: size.height, + )); + } + + void updateCameraBoundary(Size newSize) { + size = newSize; + _camera.set(Rect.fromCenter( + center: _camera.boundary.center, + width: size.width, + height: size.height, + )); + needsPaintQt = true; + } + + void moveQtCamera(Offset offset) { + if (offset == Offset.zero) return; + needsPaintQt = true; + _camera.move(offset); + // Ensure the camera stays within the quadtree boundary. + if (_camera.boundary.width > quadTree.boundary.width || + _camera.boundary.height > quadTree.boundary.height) { + final canvasAspectRatio = size.width / size.height; + final quadTreeAspectRatio = + quadTree.boundary.width / quadTree.boundary.height; + if (canvasAspectRatio > quadTreeAspectRatio) { + _camera.set(Rect.fromCenter( + center: _camera.boundary.center, + width: quadTree.boundary.width, + height: quadTree.boundary.width / canvasAspectRatio, + )); + } else { + _camera.set(Rect.fromCenter( + center: _camera.boundary.center, + width: quadTree.boundary.height * canvasAspectRatio, + height: quadTree.boundary.height, + )); + } + } + if (_camera.boundary.left < quadTree.boundary.left) { + _camera.set(Rect.fromLTWH( + 0, + _camera.boundary.top, + _camera.boundary.width, + _camera.boundary.height, + )); + } else if (_camera.boundary.right > quadTree.boundary.right) { + _camera.set(Rect.fromLTWH( + quadTree.boundary.right - _camera.boundary.width, + _camera.boundary.top, + _camera.boundary.width, + _camera.boundary.height, + )); + } + if (_camera.boundary.top < quadTree.boundary.top) { + _camera.set(Rect.fromLTWH( + _camera.boundary.left, + 0, + _camera.boundary.width, + _camera.boundary.height, + )); + } else if (_camera.boundary.bottom > quadTree.boundary.bottom) { + _camera.set(Rect.fromLTWH( + _camera.boundary.left, + quadTree.boundary.bottom - _camera.boundary.height, + _camera.boundary.width, + _camera.boundary.height, + )); + } + } + + void unmountQtCamera() { + quadTree.clear(); + } +} + +class QuadTreeCamera { + QuadTreeCamera({ + required Rect boundary, + }) : _boundary = boundary; + + /// The boundary of the camera. + Rect get boundary => _boundary; + Rect _boundary; + + /// Move the camera by the given offset. + void move(Offset offset) => _boundary = _boundary.shift(offset); + + /// Set the camera to the given boundary. + void set(Rect boundary) => _boundary = boundary; +} diff --git a/example/lib/src/feature/quadtree/quadtree_collision_screen.dart b/example/lib/src/feature/quadtree/quadtree_collision_screen.dart new file mode 100644 index 0000000..a2440e6 --- /dev/null +++ b/example/lib/src/feature/quadtree/quadtree_collision_screen.dart @@ -0,0 +1,689 @@ +import 'dart:collection'; +import 'dart:developer'; +import 'dart:math'; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:repaint/repaint.dart'; +import 'package:repaintexample/src/common/widget/app.dart'; +import 'package:repaintexample/src/feature/quadtree/quadtree_camera.dart'; + +/// {@template quadtree_screen} +/// QuadTreeCollisionScreen widget. +/// {@endtemplate} +class QuadTreeCollisionScreen extends StatefulWidget { + /// {@macro quadtree_screen} + const QuadTreeCollisionScreen({ + super.key, // ignore: unused_element + }); + + @override + State createState() => + _QuadTreeCollisionScreenState(); +} + +/// State for widget QuadTreeCollisionScreen. +class _QuadTreeCollisionScreenState extends State { + final QuadTreeCollisionPainter painter = QuadTreeCollisionPainter(); + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar( + title: const Text('QuadTreeCollision'), + leading: BackButton( + onPressed: () => App.pop(context), + ), + ), + body: SafeArea( + child: RePaint( + painter: painter, + ), + ), + ); +} + +class CollisionRectObject { + CollisionRectObject({ + required this.id, + required this.rect, + required this.velocity, + }); + + final int id; + Rect rect; + Vector2d velocity; + + // Only small rects can be moved + bool get canBeMoved => rect.width < 128 && rect.height < 128; + + double get diagonalLength => + sqrt(rect.width * rect.width + rect.height * rect.height); + + Iterable get vertices { + return [ + Vector2d(rect.left, rect.bottom), + Vector2d(rect.right, rect.bottom), + Vector2d(rect.right, rect.top), + Vector2d(rect.left, rect.top), + ]; + } + + Vector2d get absoluteCenter => Vector2d(rect.center.dx, rect.center.dy); + + @override + int get hashCode { + const int prime1 = 73856093; + const int prime2 = 19349663; + const int prime3 = 83492791; + return _hashCode ??= (id * 37 + + (id * prime1) + + ((rect.left * prime2).round() << 8) + + ((rect.top * prime3).round()) << + 4); + } + + int? _hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is CollisionRectObject && + id == other.id && + rect == other.rect && + velocity == other.velocity; +} + +class Vector2d { + const Vector2d(this.x, this.y); + + Vector2d.zero() + : x = 0, + y = 0; + + final double x; + final double y; + + /// Negate. + Vector2d operator -() => Vector2d(-x, -y); + + /// Subtract two vectors. + Vector2d operator -(Vector2d other) => Vector2d(x - other.x, y - other.y); + + /// Add two vectors. + Vector2d operator +(Vector2d other) => Vector2d(x + other.x, y + other.y); + + /// Scale. + Vector2d operator /(double scale) => Vector2d(x / scale, y / scale); + + /// Scale. + Vector2d operator *(double scale) => Vector2d(x * scale, y * scale); + + /// Length. + double get length => sqrt(length2); + + /// Length squared. + double get length2 { + double sum; + sum = x * x; + sum += y * y; + return sum; + } + + /// Normalize this. + Vector2d normalized() { + final l = length; + if (l == 0.0) { + return Vector2d.zero(); + } + final d = 1.0 / l; + return Vector2d(x * d, y * d); + } + + /// Inner product. + double dot(Vector2d other) { + double sum; + sum = x * other.x; + sum += y * other.y; + return sum; + } + + Vector2d perpendicular() => Vector2d(-y, x); + + @override + String toString() => 'Vector2d($x, $y)'; +} + +class CollisionUtil { + // Object a inside object b + static ({Vector2d normal, double depth})? intercectRects( + CollisionRectObject a, CollisionRectObject b) { + Vector2d normal = Vector2d.zero(); + double depth = double.maxFinite; + + List verticesA = a.vertices.toList(); + List verticesB = b.vertices.toList(); + + var normalAndDepthA = CollisionUtil.getNormalAndDepth( + verticesA, + verticesB, + ); + + if (normalAndDepthA.depth < depth) { + depth = normalAndDepthA.depth; + normal = normalAndDepthA.normal; + } + var normalAndDepthB = CollisionUtil.getNormalAndDepth( + verticesB, + verticesA, + insverted: true, + ); + + if (normalAndDepthB.depth < depth) { + depth = normalAndDepthB.depth; + normal = normalAndDepthB.normal; + } + + Vector2d direction = a.absoluteCenter - b.absoluteCenter; + + if (direction.dot(normal) < 0) { + normal = -normal; + } + + return (normal: normal, depth: depth); + } + + static ({Vector2d normal, double depth}) getNormalAndDepth( + List verticesA, + List verticesB, { + bool insverted = false, + }) { + var normal = Vector2d.zero(); + double depth = double.maxFinite; + for (int i = 0; i < verticesA.length; i++) { + Vector2d va = verticesA[i]; + Vector2d vb = verticesA[(i + 1) % verticesA.length]; + + Vector2d edge = vb - va; + Vector2d axis = Vector2d(-edge.y, edge.x); + axis = axis.normalized(); + + final pA = projectVertices(insverted ? verticesB : verticesA, axis); + final pB = projectVertices(insverted ? verticesA : verticesB, axis); + + double axisDepth = min(pB.max - pA.min, pA.max - pB.min); + if (axisDepth < depth) { + depth = axisDepth; + normal = axis; + } + } + return (normal: normal, depth: depth); + } + + static ({double min, double max}) projectVertices( + List vertices, + Vector2d axis, + ) { + double min = double.maxFinite; + double max = -double.maxFinite; + for (var v in vertices) { + double proj = v.dot(axis); + + if (proj < min) { + min = proj; + } + if (proj > max) { + max = proj; + } + } + return (min: min, max: max); + } +} + +class CollidingPair { + T _a; + T _b; + + T get a => _a; + T get b => _b; + + int get hash => _hash; + int _hash; + + CollidingPair(this._a, this._b) : _hash = _a.hashCode ^ _b.hashCode; + + /// Sets the prospect to contain [a] and [b] instead of what it previously + /// contained. + void set(T a, T b) { + _a = a; + _b = b; + _hash = a.hashCode ^ b.hashCode; + } + + /// Sets the prospect to contain the content of [other]. + void setFrom(CollidingPair other) { + _a = other._a; + _b = other._b; + _hash = other._hash; + } + + /// Creates a new prospect object with the same content. + CollidingPair clone() => CollidingPair(_a, _b); +} + +mixin CollisionObjectsMixin on QuadTreeCameraMixin { + final collisionObjects = {}; + final Paint _rectPaint = Paint() + ..color = Colors.yellow.withAlpha(150) + ..style = PaintingStyle.fill + ..strokeWidth = 2; + final Paint _collidingPaint = Paint() + ..color = Colors.red.withAlpha(150) + ..style = PaintingStyle.fill + ..strokeWidth = 2; + + @override + void mountQtCamera() { + super.mountQtCamera(); + + addSideBounds(); + } + + // Bounds that will restrict the area where objects can move. + void addSideBounds() { + // Adding bounds to the screen box: + const double boundWidth = 128; + const double rectDimensions = 1024; + final boundLeft = Rect.fromLTWH( + cameraBoundary.center.dx - rectDimensions / 2 - boundWidth / 2, + cameraBoundary.center.dy - rectDimensions, + boundWidth, + rectDimensions * 3, + ); + final boundRight = Rect.fromLTWH( + cameraBoundary.center.dx + rectDimensions / 2 - boundWidth / 2, + cameraBoundary.center.dy - rectDimensions - boundWidth, + boundWidth, + rectDimensions * 3, + ); + final boundTop = Rect.fromLTWH( + cameraBoundary.center.dx - rectDimensions - boundWidth, + cameraBoundary.center.dy - rectDimensions / 2 - boundWidth / 2, + rectDimensions * 3, + boundWidth, + ); + final boundBottom = Rect.fromLTWH( + cameraBoundary.center.dx - rectDimensions, + cameraBoundary.center.dy + rectDimensions / 2 - boundWidth / 2, + rectDimensions * 3, + boundWidth, + ); + + quadTree.insert(boundLeft); + quadTree.insert(boundRight); + quadTree.insert(boundTop); + quadTree.insert(boundBottom); + } + + /// Draw points on the canvas. + void drawCollisionObjects(Size size, Canvas canvas) { + canvas.save(); + canvas.translate(-cameraBoundary.left, -cameraBoundary.top); + for (final e in collisionObjects.values.toList()) { + if (_collidingObjectIds.contains(e.id)) { + canvas.drawRect(e.rect, _collidingPaint); + } else { + canvas.drawRect(e.rect, _rectPaint); + } + } + canvas.restore(); + } + + void updateCollisionObjects(double delta) { + final dt = delta / 1000; + // Checking collisions: + _collidingPairHashes.clear(); + _collidingObjectIds.clear(); + quadTree.forEach((id, left, top, width, height) { + final obj = collisionObjects[id]; + if (obj == null) { + collisionObjects[id] = CollisionRectObject( + id: id, + rect: Rect.fromLTWH(left, top, width, height), + velocity: Vector2d.zero(), + ); + } else { + obj.rect = Rect.fromLTWH(left, top, width, height); + } + _checkCollideWith(collisionObjects[id]!); + return true; + }); + + // Manipulating colliding pairs: + for (final pair in _collidingPairHashes.values) { + final a = pair.a; + final b = pair.b; + if (!a.canBeMoved && !b.canBeMoved) { + continue; + } + final colisionResult = CollisionUtil.intercectRects(a, b); + if (colisionResult == null) { + continue; + } + + // Changing velocity depending on how deep objects intercet. + final depth = max(1.0, colisionResult.depth); + final velocityChange = colisionResult.normal * depth * dt * 64; + + Vector2d changeVelocityOnImmovableCollide( + Vector2d velocity, Vector2d change) { + return Vector2d( + (velocity.x.sign == change.x.sign) ? velocity.x : -(velocity.x), + (velocity.y.sign == change.y.sign) ? velocity.y : -(velocity.y), + ); + } + + if (a.canBeMoved) { + if (!b.canBeMoved) { + a.velocity = + changeVelocityOnImmovableCollide(a.velocity, velocityChange); + } + a.velocity = a.velocity + velocityChange; + } + // both objects gain same velocity change, but in opposite directions. + if (b.canBeMoved) { + final velocityChangeB = velocityChange * -1; + if (!a.canBeMoved) { + b.velocity = + changeVelocityOnImmovableCollide(b.velocity, velocityChangeB); + } + b.velocity = b.velocity + velocityChangeB; + } + } + + // Moving objects depending on their velocities: + for (final e in collisionObjects.values) { + quadTree.move(e.id, e.rect.left + e.velocity.x * dt, + e.rect.top + e.velocity.y * dt); + } + needsPaintQt = true; + } + + final _collidingPairHashes = >{}; + final _collidingObjectIds = {}; + + void _checkCollideWith( + CollisionRectObject object, + ) { + final queryResult = quadTree.query(object.rect); + for (final potentialId in queryResult.ids) { + if (potentialId == object.id) continue; + final other = collisionObjects[potentialId]; + if (other == null) continue; + final pair = CollidingPair(object, other); + if (_collidingPairHashes.containsKey(pair.hash)) { + // just checking that hash function is correct. + final existing = _collidingPairHashes[pair.hash]!; + debugger( + when: !((existing.a.id == pair.a.id && + existing.b.id == pair.b.id) || + (existing.a.id == pair.b.id && existing.b.id == pair.a.id))); + continue; + } + _collidingPairHashes[pair.hash] = pair; + _collidingObjectIds.add(object.id); + _collidingObjectIds.add(other.id); + } + } +} + +class QuadTreeCollisionPainter extends RePainterBase + with QuadTreeCameraMixin, CollisionObjectsMixin { + final HardwareKeyboard _keyboardManager = HardwareKeyboard.instance; + bool _spacebarPressed = false; + + @override + bool get needsPaint => needsPaintQt; + + @override + void mount(RePaintBox box, PipelineOwner owner) { + size = box.size; + mountQtCamera(); + + _keyboardManager.addHandler(onKeyboardEvent); + _spacebarPressed = + HardwareKeyboard.instance.isLogicalKeyPressed(LogicalKeyboardKey.space); + needsPaintQt = true; + } + + @override + void unmount() { + _keyboardManager.removeHandler(onKeyboardEvent); + unmountQtCamera(); + _spacebarPressed = false; + needsPaintQt = false; + } + + bool onKeyboardEvent(KeyEvent event) { + bool? isKeyDown = switch (event) { + KeyDownEvent _ => true, + KeyRepeatEvent _ => true, + KeyUpEvent _ => false, + _ => null, + }; + if (isKeyDown == null) return false; // Not a key press event. + switch (event.logicalKey) { + case LogicalKeyboardKey.keyW when isKeyDown: + case LogicalKeyboardKey.arrowUp when isKeyDown: + moveQtCamera(const Offset(0, -10)); + case LogicalKeyboardKey.keyS when isKeyDown: + case LogicalKeyboardKey.arrowDown when isKeyDown: + moveQtCamera(const Offset(0, 10)); + case LogicalKeyboardKey.keyA when isKeyDown: + case LogicalKeyboardKey.arrowLeft when isKeyDown: + moveQtCamera(const Offset(-10, 0)); + case LogicalKeyboardKey.keyD when isKeyDown: + case LogicalKeyboardKey.arrowRight when isKeyDown: + moveQtCamera(const Offset(10, 0)); + case LogicalKeyboardKey.space: + _spacebarPressed = isKeyDown; + default: + return false; // Not a key we care about. + } + return true; + } + + @override + void onPointerEvent(PointerEvent event) { + switch (event) { + case PointerHoverEvent hover: + if (!_spacebarPressed) return; + moveQtCamera(-hover.localDelta); + case PointerMoveEvent move: + if (!move.down) return; + _onClick(move.localPosition); + case PointerPanZoomUpdateEvent pan: + moveQtCamera(pan.panDelta); + case PointerDownEvent click: + _onClick(click.localPosition); + } + } + + bool _wasAdded = false; + + void _onClick(Offset point) { + if (_wasAdded) { + return; + } + _wasAdded = true; + Future.delayed(const Duration(milliseconds: 150), () { + _wasAdded = false; + }); + // Calculate the dot offset from the camera. + final cameraCenter = cameraBoundary.center; + const double dimension = 64; + final dot = Rect.fromCenter( + center: Offset( + point.dx + cameraCenter.dx - size.width / 2, + point.dy + cameraCenter.dy - size.height / 2, + ), + width: dimension, + height: dimension, + ); + quadTree.insert(dot); + needsPaintQt = true; + } + + @override + void update(RePaintBox box, Duration elapsed, double delta) { + // If the size of the box has changed, update the camera too. + if (box.size != size) { + updateCameraBoundary(box.size); + } + updateCollisionObjects(delta); + } + + final TextPainter _textPainter = TextPainter( + textAlign: TextAlign.left, + textDirection: TextDirection.ltr, + ); + final Paint _bgPaint = Paint()..color = Colors.lightBlue; + + int _spinnerIndex = 0; + static const List _spinnerSymbols = [ + '⠋', + '⠙', + '⠹', + '⠸', + '⠼', + '⠴', + '⠦', + '⠧', + '⠇', + '⠏', + ]; + + /// Draw current status. + void _drawStatus(Size size, Canvas canvas) { + _spinnerIndex++; + final nbsp = String.fromCharCode(160); + final status = StringBuffer() + ..write(_spinnerSymbols[_spinnerIndex = + _spinnerIndex % _spinnerSymbols.length]) + ..write(' | ') + ..write('World:') + ..write(nbsp) + ..write(quadTree.boundary.width.toStringAsFixed(0)) + ..write('x') + ..write(quadTree.boundary.height.toStringAsFixed(0)) + ..write(' | ') + ..write('Screen:') + ..write(nbsp) + ..write(size.width.toStringAsFixed(0)) + ..write('x') + ..write(size.height.toStringAsFixed(0)) + ..write(' | ') + ..write('Position:') + ..write(nbsp) + ..write(cameraBoundary.left.toStringAsFixed(0)) + ..write('x') + ..write(cameraBoundary.top.toStringAsFixed(0)) + ..write(' | ') + ..write('Points:') + ..write(nbsp) + ..write(collisionObjects.length) + ..write('/') + ..write(quadTree.length) + ..write(' | ') + ..write('Nodes:') + ..write(nbsp) + ..write(quadTree.nodes); + _textPainter + ..text = TextSpan( + text: status.toString(), + style: const TextStyle( + color: Colors.black, + fontSize: 12, + fontFamily: 'RobotoMono', + ), + ) + ..layout(maxWidth: size.width - 32); + final textSize = _textPainter.size; + canvas.drawRRect( + RRect.fromRectAndRadius( + Rect.fromLTWH( + 8, + size.height - textSize.height - 16 - 8, + size.width - 16, + textSize.height + 16, + ), + const Radius.circular(8), + ), + Paint()..color = Colors.white54, + ); + _textPainter.paint( + canvas, + Offset(16, size.height - textSize.height - 16), + ); + } + + final Paint _nodePaint = Paint() + ..color = Colors.white + ..strokeWidth = 0.1 + ..style = PaintingStyle.stroke; + + /// Draw the quadtree nodes on the canvas. + void _drawQuadTree(Size size, Canvas canvas) { + final cam = cameraBoundary; + final queue = Queue()..add(quadTree.root); + while (queue.isNotEmpty) { + final node = queue.removeFirst(); + if (node == null) continue; + if (!node.boundary.overlaps(cam)) continue; + if (node.subdivided) { + // Parent node is subdivided, add children to the queue. + queue + ..add(node.northWest) + ..add(node.northEast) + ..add(node.southWest) + ..add(node.southEast); + } else { + // Draw the leaf node. + final nodeBounds = Rect.fromLTWH( + node.boundary.left - cam.left, + node.boundary.top - cam.top, + node.boundary.width, + node.boundary.height, + ); + + // Check if any vertices are visible. + if (nodeBounds.right < 0 || + nodeBounds.left > size.width || + nodeBounds.bottom < 0 || + nodeBounds.top > size.height) { + continue; + } + final k = 1.0 / (node.depth + 1); + canvas.drawRect( + nodeBounds, + _nodePaint + ..strokeWidth = k * 2 + ..color = Colors.white.withValues(alpha: 1 - k), + ); + } + } + } + + @override + void paint(RePaintBox box, PaintingContext context) { + final size = box.size; + final canvas = context.canvas; + + canvas.drawRect(Offset.zero & size, _bgPaint); // Draw background + _drawQuadTree(size, canvas); // Draw quadtree nodes + drawCollisionObjects(size, canvas); // Draw points + _drawStatus(size, canvas); // Draw status + needsPaintQt = false; // Reset the flag. + } +} diff --git a/example/lib/src/feature/quadtree/quadtree_screen.dart b/example/lib/src/feature/quadtree/quadtree_screen.dart index 634c9bb..86c4784 100644 --- a/example/lib/src/feature/quadtree/quadtree_screen.dart +++ b/example/lib/src/feature/quadtree/quadtree_screen.dart @@ -8,6 +8,7 @@ import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:repaint/repaint.dart'; import 'package:repaintexample/src/common/widget/app.dart'; +import 'package:repaintexample/src/feature/quadtree/quadtree_camera.dart'; /// {@template quadtree_screen} /// QuadTreeScreen widget. @@ -25,32 +26,6 @@ class QuadTreeScreen extends StatefulWidget { /// State for widget QuadTreeScreen. class _QuadTreeScreenState extends State { final QuadTreePainter painter = QuadTreePainter(); - /* #region Lifecycle */ - @override - void initState() { - super.initState(); - // Initial state initialization - } - - @override - void didUpdateWidget(covariant QuadTreeScreen oldWidget) { - super.didUpdateWidget(oldWidget); - // Widget configuration changed - } - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - // The configuration of InheritedWidgets has changed - // Also called after initState but before build - } - - @override - void dispose() { - // Permanent removal of a tree stent - super.dispose(); - } - /* #endregion */ @override Widget build(BuildContext context) => Scaffold( @@ -68,46 +43,32 @@ class _QuadTreeScreenState extends State { ); } -class QuadTreePainter extends RePainterBase { - final QuadTree _quadTree = QuadTree( - boundary: const Rect.fromLTWH(0, 0, 100000, 100000), - capacity: 18, - ); - - final _camera = QuadTreeCamera( - boundary: Rect.zero, - ); - +class QuadTreePainter extends RePainterBase with QuadTreeCameraMixin { final HardwareKeyboard _keyboardManager = HardwareKeyboard.instance; bool _spacebarPressed = false; Float32List _points = Float32List(0); - Size _size = Size.zero; @override - bool get needsPaint => _needsPaint; - bool _needsPaint = false; + bool get needsPaint => needsPaintQt; @override void mount(RePaintBox box, PipelineOwner owner) { - _size = box.size; - _camera.set(Rect.fromCenter( - center: _quadTree.boundary.center, - width: _size.width, - height: _size.height, - )); + size = box.size; + mountQtCamera(); + _keyboardManager.addHandler(onKeyboardEvent); _spacebarPressed = HardwareKeyboard.instance.isLogicalKeyPressed(LogicalKeyboardKey.space); - _needsPaint = true; + needsPaintQt = true; } @override void unmount() { _keyboardManager.removeHandler(onKeyboardEvent); - _quadTree.clear(); + unmountQtCamera(); _spacebarPressed = false; - _needsPaint = false; + needsPaintQt = false; } bool onKeyboardEvent(KeyEvent event) { @@ -121,16 +82,16 @@ class QuadTreePainter extends RePainterBase { switch (event.logicalKey) { case LogicalKeyboardKey.keyW when isKeyDown: case LogicalKeyboardKey.arrowUp when isKeyDown: - _moveCamera(const Offset(0, -10)); + moveQtCamera(const Offset(0, -10)); case LogicalKeyboardKey.keyS when isKeyDown: case LogicalKeyboardKey.arrowDown when isKeyDown: - _moveCamera(const Offset(0, 10)); + moveQtCamera(const Offset(0, 10)); case LogicalKeyboardKey.keyA when isKeyDown: case LogicalKeyboardKey.arrowLeft when isKeyDown: - _moveCamera(const Offset(-10, 0)); + moveQtCamera(const Offset(-10, 0)); case LogicalKeyboardKey.keyD when isKeyDown: case LogicalKeyboardKey.arrowRight when isKeyDown: - _moveCamera(const Offset(10, 0)); + moveQtCamera(const Offset(10, 0)); case LogicalKeyboardKey.space: _spacebarPressed = isKeyDown; default: @@ -144,105 +105,43 @@ class QuadTreePainter extends RePainterBase { switch (event) { case PointerHoverEvent hover: if (!_spacebarPressed) return; - _moveCamera(-hover.localDelta); + moveQtCamera(-hover.localDelta); case PointerMoveEvent move: if (!move.down) return; _onClick(move.localPosition); case PointerPanZoomUpdateEvent pan: - _moveCamera(pan.panDelta); + moveQtCamera(pan.panDelta); case PointerDownEvent click: _onClick(click.localPosition); } } - void _moveCamera(Offset offset) { - if (offset == Offset.zero) return; - _needsPaint = true; - _camera.move(offset); - // Ensure the camera stays within the quadtree boundary. - if (_camera.boundary.width > _quadTree.boundary.width || - _camera.boundary.height > _quadTree.boundary.height) { - final canvasAspectRatio = _size.width / _size.height; - final quadTreeAspectRatio = - _quadTree.boundary.width / _quadTree.boundary.height; - if (canvasAspectRatio > quadTreeAspectRatio) { - _camera.set(Rect.fromCenter( - center: _camera.boundary.center, - width: _quadTree.boundary.width, - height: _quadTree.boundary.width / canvasAspectRatio, - )); - } else { - _camera.set(Rect.fromCenter( - center: _camera.boundary.center, - width: _quadTree.boundary.height * canvasAspectRatio, - height: _quadTree.boundary.height, - )); - } - } - if (_camera.boundary.left < _quadTree.boundary.left) { - _camera.set(Rect.fromLTWH( - 0, - _camera.boundary.top, - _camera.boundary.width, - _camera.boundary.height, - )); - } else if (_camera.boundary.right > _quadTree.boundary.right) { - _camera.set(Rect.fromLTWH( - _quadTree.boundary.right - _camera.boundary.width, - _camera.boundary.top, - _camera.boundary.width, - _camera.boundary.height, - )); - } - if (_camera.boundary.top < _quadTree.boundary.top) { - _camera.set(Rect.fromLTWH( - _camera.boundary.left, - 0, - _camera.boundary.width, - _camera.boundary.height, - )); - } else if (_camera.boundary.bottom > _quadTree.boundary.bottom) { - _camera.set(Rect.fromLTWH( - _camera.boundary.left, - _quadTree.boundary.bottom - _camera.boundary.height, - _camera.boundary.width, - _camera.boundary.height, - )); - } - } - void _onClick(Offset point) { // Calculate the dot offset from the camera. - final cameraCenter = _camera.boundary.center; + final cameraCenter = cameraBoundary.center; final dot = Rect.fromCenter( center: Offset( - point.dx + cameraCenter.dx - _size.width / 2, - point.dy + cameraCenter.dy - _size.height / 2, + point.dx + cameraCenter.dx - size.width / 2, + point.dy + cameraCenter.dy - size.height / 2, ), width: 2, height: 2, ); - _quadTree.insert(dot); - _needsPaint = true; + quadTree.insert(dot); + needsPaintQt = true; } @override void update(RePaintBox box, Duration elapsed, double delta) { // If the size of the box has changed, update the camera too. - if (box.size != _size) { - _size = box.size; - _camera.set(Rect.fromCenter( - center: _camera.boundary.center, - width: _size.width, - height: _size.height, - )); - _needsPaint = true; + if (box.size != size) { + updateCameraBoundary(box.size); } - if (!_needsPaint) return; // No need to update the points and repaint. + if (!needsPaintQt) return; // No need to update the points and repaint. - final boundary = _camera.boundary; - final result = _quadTree.query(boundary); + final boundary = cameraBoundary; + final result = quadTree.query(boundary); if (result.isEmpty) { _points = Float32List(0); } else { @@ -299,9 +198,9 @@ class QuadTreePainter extends RePainterBase { ..write(' | ') ..write('World:') ..write(nbsp) - ..write(_quadTree.boundary.width.toStringAsFixed(0)) + ..write(quadTree.boundary.width.toStringAsFixed(0)) ..write('x') - ..write(_quadTree.boundary.height.toStringAsFixed(0)) + ..write(quadTree.boundary.height.toStringAsFixed(0)) ..write(' | ') ..write('Screen:') ..write(nbsp) @@ -311,19 +210,19 @@ class QuadTreePainter extends RePainterBase { ..write(' | ') ..write('Position:') ..write(nbsp) - ..write(_camera.boundary.left.toStringAsFixed(0)) + ..write(cameraBoundary.left.toStringAsFixed(0)) ..write('x') - ..write(_camera.boundary.top.toStringAsFixed(0)) + ..write(cameraBoundary.top.toStringAsFixed(0)) ..write(' | ') ..write('Points:') ..write(nbsp) ..write(_points.length ~/ 2) ..write('/') - ..write(_quadTree.length) + ..write(quadTree.length) ..write(' | ') ..write('Nodes:') ..write(nbsp) - ..write(_quadTree.nodes); + ..write(quadTree.nodes); _textPainter ..text = TextSpan( text: status.toString(), @@ -370,8 +269,8 @@ class QuadTreePainter extends RePainterBase { /// Draw the quadtree nodes on the canvas. void _drawQuadTree(Size size, Canvas canvas) { - final cam = _camera.boundary; - final queue = Queue()..add(_quadTree.root); + final cam = cameraBoundary; + final queue = Queue()..add(quadTree.root); while (queue.isNotEmpty) { final node = queue.removeFirst(); if (node == null) continue; @@ -419,26 +318,10 @@ class QuadTreePainter extends RePainterBase { _drawQuadTree(size, canvas); // Draw quadtree nodes _drawPoints(size, canvas); // Draw points _drawStatus(size, canvas); // Draw status - _needsPaint = false; // Reset the flag. + needsPaintQt = false; // Reset the flag. } } -class QuadTreeCamera { - QuadTreeCamera({ - required Rect boundary, - }) : _boundary = boundary; - - /// The boundary of the camera. - Rect get boundary => _boundary; - Rect _boundary; - - /// Move the camera by the given offset. - void move(Offset offset) => _boundary = _boundary.shift(offset); - - /// Set the camera to the given boundary. - void set(Rect boundary) => _boundary = boundary; -} - /// Sort the list of points by the y-coordinate. // ignore: unused_element void _sortByY(Float32List list) {