Files
flame/examples/lib/stories/collision_detection/quadtree_example.dart
Luan Nico b79fee0ae2 chore: Update min Dart constraint to 3.8 (#3676)
Update min Dart constraint to 3.8, which will enable us to use the
fancier collection literals.

This requires bumping the min Flutter version as well:

<img width="1892" height="1122" alt="image"
src="https://github.com/user-attachments/assets/7c7b07fc-4d96-4987-824d-9a7133ecfb85"
/>
2025-08-10 12:42:31 -04:00

439 lines
11 KiB
Dart

import 'dart:math';
import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/events.dart';
import 'package:flame/extensions.dart';
import 'package:flame/game.dart';
import 'package:flame/input.dart';
import 'package:flame/layers.dart';
import 'package:flutter/material.dart' hide Image, Draggable;
import 'package:flutter/services.dart';
const tileSize = 8.0;
class QuadTreeExample extends FlameGame
with HasQuadTreeCollisionDetection, KeyboardEvents, ScrollDetector {
QuadTreeExample();
static const description = '''
In this example the standard "Sweep and Prune" algorithm is replaced by
"Quad Tree". Quad Tree is often a more efficient approach of handling collisions,
its efficiency is shown especially on huge maps with big amounts of collidable
components.
Some bricks are highlighted when placed on an edge of a quadrant. It is
important to understand that handling hitboxes on edges requires more
resources.
Blue lines visualize the quad tree's quadrant positions.
Use WASD to move the player and use the mouse scroll to change zoom.
Hold direction button and press space to fire a bullet.
Notice that bullet will fly above water but collides with bricks.
Also notice that creating a lot of bullets at once leads to generating new
quadrants on the map since it becomes more than 25 objects in one quadrant.
Press O button to rescan the tree and optimize it, removing unused quadrants.
Press T button to toggle player to collide with other objects.
''';
static const mapSize = 300;
static const bricksCount = 8000;
late final Player player;
final staticLayer = StaticLayer();
@override
Future<void> onLoad() async {
super.onLoad();
const mapWidth = mapSize * tileSize;
const mapHeight = mapSize * tileSize;
initializeCollisionDetection(
mapDimensions: const Rect.fromLTWH(0, 0, mapWidth, mapHeight),
minimumDistance: 10,
);
final random = Random();
final spriteBrick = await Sprite.load(
'retro_tiles.png',
srcPosition: Vector2.all(0),
srcSize: Vector2.all(tileSize),
);
final spriteWater = await Sprite.load(
'retro_tiles.png',
srcPosition: Vector2(0, tileSize),
srcSize: Vector2.all(tileSize),
);
for (var i = 0; i < bricksCount; i++) {
final x = random.nextInt(mapSize);
final y = random.nextInt(mapSize);
final brick = Brick(
position: Vector2(x * tileSize, y * tileSize),
size: Vector2.all(tileSize),
priority: 0,
sprite: spriteBrick,
);
world.add(brick);
staticLayer.components.add(brick);
}
staticLayer.reRender();
camera = CameraComponent.withFixedResolution(
world: world,
width: 500,
height: 250,
);
player = Player(
position: Vector2.all(mapSize * tileSize / 2),
size: Vector2.all(tileSize),
priority: 2,
);
world.add(player);
camera.follow(player);
final brick = Brick(
position: player.position.translated(0, -tileSize * 2),
size: Vector2.all(tileSize),
priority: 0,
sprite: spriteBrick,
);
world.add(brick);
staticLayer.components.add(brick);
final water1 = Water(
position: player.position.translated(0, tileSize * 2),
size: Vector2.all(tileSize),
priority: 0,
sprite: spriteWater,
);
world.add(water1);
final water2 = Water(
position: player.position.translated(tileSize * 2, 0),
size: Vector2.all(tileSize),
priority: 0,
sprite: spriteWater,
);
world.add(water2);
final water3 = Water(
position: player.position.translated(-tileSize * 2, 0),
size: Vector2.all(tileSize),
priority: 0,
sprite: spriteWater,
);
world.add(water3);
world.add(QuadTreeDebugComponent(collisionDetection));
world.add(LayerComponent(staticLayer));
camera.viewport.add(FpsTextComponent());
}
final elapsedMicroseconds = <double>[];
final _playerDisplacement = Vector2.zero();
var _fireBullet = false;
static const stepSize = 1.0;
@override
KeyEventResult onKeyEvent(
KeyEvent event,
Set<LogicalKeyboardKey> keysPressed,
) {
for (final key in keysPressed) {
if (key == LogicalKeyboardKey.keyW && player.canMoveTop) {
_playerDisplacement.setValues(0, -stepSize);
player.position.translate(0, -stepSize);
}
if (key == LogicalKeyboardKey.keyA && player.canMoveLeft) {
_playerDisplacement.setValues(-stepSize, 0);
player.position.translate(-stepSize, 0);
}
if (key == LogicalKeyboardKey.keyS && player.canMoveBottom) {
_playerDisplacement.setValues(0, stepSize);
player.position.translate(0, stepSize);
}
if (key == LogicalKeyboardKey.keyD && player.canMoveRight) {
_playerDisplacement.setValues(stepSize, 0);
player.position.translate(stepSize, 0);
}
if (key == LogicalKeyboardKey.space) {
_fireBullet = true;
}
if (key == LogicalKeyboardKey.keyT) {
final collisionType = player.hitbox.collisionType;
if (collisionType == CollisionType.active) {
player.hitbox.collisionType = CollisionType.inactive;
} else if (collisionType == CollisionType.inactive) {
player.hitbox.collisionType = CollisionType.active;
}
}
if (key == LogicalKeyboardKey.keyO) {
collisionDetection.broadphase.tree.optimize();
}
}
if (_fireBullet && !_playerDisplacement.isZero()) {
final bullet = Bullet(
position: player.position,
displacement: _playerDisplacement * 50,
);
add(bullet);
_playerDisplacement.setZero();
_fireBullet = false;
}
return KeyEventResult.handled;
}
@override
void onScroll(PointerScrollInfo info) {
camera.viewfinder.zoom += info.scrollDelta.global.y.sign * 0.08;
camera.viewfinder.zoom = camera.viewfinder.zoom.clamp(0.05, 5.0);
}
}
//#region Player
class Player extends SpriteComponent
with CollisionCallbacks, HasGameReference<QuadTreeExample> {
Player({
required super.position,
required super.size,
required super.priority,
});
bool canMoveLeft = true;
bool canMoveRight = true;
bool canMoveTop = true;
bool canMoveBottom = true;
final hitbox = RectangleHitbox();
@override
Future<void> onLoad() async {
sprite = await Sprite.load(
'retro_tiles.png',
srcSize: Vector2.all(tileSize),
srcPosition: Vector2(tileSize * 3, tileSize),
);
add(hitbox);
}
@override
void onCollisionStart(
Set<Vector2> intersectionPoints,
PositionComponent other,
) {
final myCenter = Vector2(
position.x + tileSize / 2,
position.y + tileSize / 2,
);
if (other is GameCollidable) {
final diffX = myCenter.x - other.cachedCenter.x;
if (diffX < 0) {
canMoveRight = false;
} else if (diffX > 0) {
canMoveLeft = false;
}
final diffY = myCenter.y - other.cachedCenter.y;
if (diffY < 0) {
canMoveBottom = false;
} else if (diffY > 0) {
canMoveTop = false;
}
final newPos = Vector2(position.x + diffX / 3, position.y + diffY / 3);
position = newPos;
}
super.onCollisionStart(intersectionPoints, other);
}
@override
void onCollisionEnd(PositionComponent other) {
canMoveLeft = true;
canMoveRight = true;
canMoveTop = true;
canMoveBottom = true;
super.onCollisionEnd(other);
}
}
class Bullet extends PositionComponent with CollisionCallbacks, HasPaint {
Bullet({required super.position, required this.displacement}) {
paint.color = Colors.deepOrange;
priority = 10;
size = Vector2.all(1);
add(RectangleHitbox());
}
final Vector2 displacement;
@override
void render(Canvas canvas) {
canvas.drawCircle(Offset.zero, 1, paint);
}
@override
void update(double dt) {
final d = displacement * dt;
position = Vector2(position.x + d.x, position.y + d.y);
super.update(dt);
}
@override
bool onComponentTypeCheck(PositionComponent other) {
if (other is Player || other is Water) {
return false;
}
return super.onComponentTypeCheck(other);
}
@override
void onCollisionStart(
Set<Vector2> intersectionPoints,
PositionComponent other,
) {
if (other is Brick) {
removeFromParent();
}
super.onCollisionStart(intersectionPoints, other);
}
}
//#endregion
//#region Environment
class Brick extends SpriteComponent
with CollisionCallbacks, GameCollidable, UpdateOnce {
Brick({
required super.position,
required super.size,
required super.priority,
required super.sprite,
}) {
initCenter();
initCollision();
}
bool rendered = false;
@override
void renderTree(Canvas canvas) {
if (!rendered) {
super.renderTree(canvas);
}
}
}
class Water extends SpriteComponent
with CollisionCallbacks, GameCollidable, UpdateOnce {
Water({
required super.position,
required super.size,
required super.priority,
required super.sprite,
}) {
initCenter();
initCollision();
}
}
mixin GameCollidable on PositionComponent {
void initCollision() {
add(RectangleHitbox(collisionType: CollisionType.passive));
}
void initCenter() {
cachedCenter = Vector2(
position.x + tileSize / 2,
position.y + tileSize / 2,
);
}
late final Vector2 cachedCenter;
}
//#endregion
//#region Utils
mixin UpdateOnce on PositionComponent {
bool updateOnce = true;
@override
void updateTree(double dt) {
if (updateOnce) {
super.updateTree(dt);
updateOnce = false;
}
}
}
class StaticLayer extends PreRenderedLayer {
StaticLayer();
List<PositionComponent> components = [];
@override
void drawLayer() {
for (final element in components) {
if (element is Brick) {
element.rendered = false;
element.renderTree(canvas);
element.rendered = true;
}
}
}
}
class LayerComponent extends PositionComponent {
LayerComponent(this.layer);
StaticLayer layer;
@override
void render(Canvas canvas) {
layer.render(canvas);
}
}
class QuadTreeDebugComponent extends PositionComponent with HasPaint {
QuadTreeDebugComponent(QuadTreeCollisionDetection cd) {
dbg = QuadTreeNodeDebugInfo.init(cd);
paint.color = Colors.blue;
paint.style = PaintingStyle.stroke;
priority = 10;
}
late final QuadTreeNodeDebugInfo dbg;
final _boxPaint = Paint()
..style = PaintingStyle.stroke
..color = Colors.lightGreenAccent
..strokeWidth = 1;
@override
void render(Canvas canvas) {
final nodes = dbg.nodes;
for (final node in nodes) {
canvas.drawRect(node.rect, paint);
final nodeElements = node.ownElements;
final shouldPaint = !node.noChildren && nodeElements.isNotEmpty;
for (final box in nodeElements) {
if (shouldPaint) {
canvas.drawRect(box.aabb.toRect(), _boxPaint);
}
}
}
}
}
//#endregion