// Создание круглого хитбокса
CircleHitbox(
radius: 20, // Радиус круга
position: Vector2(0, 0), // Позиция относительно родителя
anchor: Anchor.center, // Точка привязки
);
Круглый хитбокс идеально подходит для:
Круглых объектов (монеты, шары, пули)
Персонажей
Когда нужна одинаковая коллизия со всех сторон
Оптимизации производительности (проверка столкновений для кругов самая быстрая)
// Создание прямоугольного хитбокса
RectangleHitbox(
size: Vector2(100, 50), // Ширина и высота
position: Vector2(0, 0), // Позиция
angle: 0.5, // Можно повернуть хитбокс
);
Прямоугольный хитбокс полезен для:
Платформ
Стен
Прямоугольных предметов
Когда нужна точная прямоугольная область коллизии
// Создание полигонального хитбокса
PolygonHitbox([
Vector2(0, 0), // Первая точка
Vector2(100, 0), // Вторая точка
Vector2(50, 100), // Третья точка
]);
Полигональный хитбокс используется для:
Сложных форм
Треугольников
Многоугольных объектов
Когда нужна очень точная область коллизии
Важные особенности:
class ComplexEnemy extends PositionComponent with CollisionCallbacks {
@override
Future<void> onLoad() async {
// Можно добавить несколько хитбоксов к одному компоненту
add(CircleHitbox(radius: 30)); // Для тела
add(RectangleHitbox(
size: Vector2(20, 40),
position: Vector2(-10, -50),
)); // Для головы
}
}
// Активный хитбокс - проверяет столкновения с активными и пассивными
CircleHitbox(
collisionType: CollisionType.active,
);
// Пассивный хитбокс - проверяется только активными
RectangleHitbox(
collisionType: CollisionType.passive,
);
// Неактивный хитбокс - временно отключен
PolygonHitbox(
collisionType: CollisionType.inactive,
);
// Для статичных объектов лучше использовать пассивный тип
class Platform extends PositionComponent {
@override
Future<void> onLoad() async {
add(RectangleHitbox(
collisionType: CollisionType.passive, // Оптимизация
));
}
}
// Для движущихся объектов используем активный тип
class Player extends PositionComponent {
@override
Future<void> onLoad() async {
add(CircleHitbox(
collisionType: CollisionType.active,
));
}
}
Практические советы:
CircleHitbox для простых круглых объектов
RectangleHitbox для прямоугольных
PolygonHitbox только когда действительно нужна сложная форма
Используйте CollisionType.passive для статичных объектов
Минимизируйте количество активных хитбоксов
Используйте простые формы где возможно
Можно комбинировать несколько хитбоксов
Подгоняйте размер хитбокса под визуальную часть объекта
Учитывайте anchor point при позиционировании
Расширить пример SpaceShooter таким образом, чтобы враги не умирали, а замедлялись при столкновении. Аналогичным образом поменяйте игрока так, чтобы он был белым и после первого столкновения менял цвет на зеленый, после снова на белый и так чередовал.
Давайте разберём основные группы эффектов во Flame:
ScaleEffect.by / ScaleEffect.to - изменяют масштаб объекта
SizeEffect.by / SizeEffect.to - изменяют физический размер
// Увеличить размер в 2 раза
ScaleEffect.to(Vector2.all(2.0), EffectController(duration: 1.0))
// Изменить размер до конкретных значений
SizeEffect.to(Vector2(100, 50), EffectController(duration: 0.5))
MoveByEffect - перемещение на заданное расстояние
MoveToEffect - перемещение в конкретную точку
MoveAlongPathEffect - движение по заданному пути
// Движение вверх на 100 пикселей
MoveByEffect(Vector2(0, -100), EffectController(duration: 1.0))
// Движение по кривой
MoveAlongPathEffect(
Path()..quadraticBezierTo(100, 0, 50, -50),
EffectController(duration: 1.5),
)
RotateEffect.by - поворот на заданный угол
RotateEffect.to - поворот до заданного угла
RotateAroundEffect - вращение вокруг точки
// Поворот на 90 градусов
RotateEffect.by(tau/4, EffectController(duration: 1.0))
OpacityEffect.to - изменение прозрачности до значения
OpacityEffect.fadeIn / fadeOut - появление/исчезновение
// Плавное исчезновение
OpacityEffect.fadeOut(EffectController(duration: 0.5))
// Окрашивание в зелёный
ColorEffect(
Color(0xFF00FF00),
EffectController(duration: 1.0),
opacityFrom: 0.0,
opacityTo: 1.0,
)
SequenceEffect([
ScaleEffect.by(Vector2.all(1.5), EffectController(duration: 0.2)),
MoveByEffect(Vector2(0, -50), EffectController(duration: 0.5)),
OpacityEffect.fadeOut(EffectController(duration: 0.3)),
])
RemoveEffect - удаление объекта
GlowEffect - создание свечения (экспериментальный)
Для управления эффектами используются различные контроллеры:
// Линейное изменение
LinearEffectController(1.0)
// С замедлением в конце
CurvedEffectController(1.0, Curves.easeOut)
// Бесконечное повторение
InfiniteEffectController(LinearEffectController(1.0))
// С задержкой
DelayedEffectController(LinearEffectController(1.0), delay: 2.0)
Пример практического использования нескольких эффектов:
// Создаём эффект "подбора монетки"
void onCoinCollected() {
add(
SequenceEffect([
// Сначала монетка увеличивается
ScaleEffect.by(
Vector2.all(1.5),
EffectController(duration: 0.2),
),
// Затем поднимается вверх и исчезает
SequenceEffect([
MoveByEffect(
Vector2(0, -50),
EffectController(duration: 0.5),
),
OpacityEffect.fadeOut(
EffectController(duration: 0.3),
),
]),
// В конце удаляем объект
RemoveEffect(),
]),
);
}
Добавить эффект плавного появления врагам с длительностью 0.3 секунды
Общим поведением для всех частиц является то, что все они принимают аргумент lifespan
(время жизни). Это значение используется для того, чтобы ParticleSystemComponent
удалил себя, как только его внутренняя частица достигнет конца своей жизни. Время внутри самой частицы отслеживается с помощью класса Flame Timer
. Его можно настроить с помощью числа типа double, представленного в секундах (с точностью до микросекунд), передав его в соответствующий конструктор Particle
.
Particle(lifespan: .2); // будет жить 200мс.
Particle(lifespan: 4); // будет жить 4с.
Также можно сбросить время жизни частицы, используя метод setLifespan
, который также принимает double
в секундах.
final particle = Particle(lifespan: 2);
// ... спустя какое-то время.
particle.setLifespan(2) // будет жить еще 2с.
В течение своего времени жизни частица отслеживает время, которое она была жива, и предоставляет его через геттер progress, который возвращает значение между 0.0 и 1.0. Это значение можно использовать аналогично свойству value класса AnimationController
во Flutter.
final particle = Particle(lifespan: 2.0);
game.add(ParticleSystemComponent(particle: particle));
// Выведет значения от 0 до 1 с шагом .1: 0, 0.1, 0.2 ... 0.9, 1.0.
Timer.periodic(duration * .1, () => print(particle.progress));
Время жизни передается всем потомкам данной частицы, если она поддерживает какое-либо поведение вложенности.
Flame поставляется с несколькими встроенными поведениями частиц:
• TranslatedParticle
- перемещает своего потомка на заданный Vector2
• MovingParticle
- перемещает своего потомка между двумя предопределенными Vector2
, поддерживает Curve
• AcceleratedParticle
- позволяет создавать эффекты, основанные на базовой физике, такие как гравитация или демпфирование скорости.
• CircleParticle
- отрисовывает круги всех форм и размеров
• SpriteParticle
- отрисовывает Flame Sprite
в эффекте частицы
• ImageParticle
- отрисовывает dart:ui Image
в эффекте частицы
• ComponentParticle
- отрисовывает Flame Component
в эффекте частицы
• FlareParticle
- отрисовывает Flare
анимацию в эффекте частицы
Смотрите больше примеров того, как использовать встроенные поведения частиц вместе. Все реализации доступны в папке particles
в репозитории Flame
.
Просто перемещает базовую частицу в указанный Vector2
внутри рендерингового Canvas
. Не изменяет и не меняет ее позицию, рассмотрите использование MovingParticle
или AcceleratedParticle
, где требуется изменение позиции. Того же эффекта можно добиться, переместив слой Canvas
.
game.add(
ParticleSystemComponent(
particle: TranslatedParticle(
// Переместит дочерний эффект частицы в центр игрового canvas.
offset: game.size / 2,
child: Particle(),
),
),
);
Перемещает дочернюю частицу между from
и to Vector2
в течение ее времени жизни. Поддерживает Curve
через CurvedParticle
.
game.add(
ParticleSystemComponent(
particle: MovingParticle(
// Переместится из угла в угол игрового canvas.
from: Vector2.zero(),
to: game.size,
child: CircleParticle(
radius: 2.0,
paint: Paint()..color = Colors.red,
),
),
),
);
Базовая физическая частица, которая позволяет вам указать ее начальную позицию, скорость и ускорение, а цикл обновления сделает все остальное. Все три параметра указаны как Vector2
, которые можно рассматривать как векторы. Это особенно хорошо подходит для "взрывов", основанных на физике, но не ограничивается этим. Единицей значения Vector2
является логический px/с. Таким образом, скорость Vector2(0, 100)
переместит дочернюю частицу на 100 логических пикселей устройства каждую секунду игрового времени.
final rnd = Random();
Vector2 randomVector2() => (Vector2.random(rnd) - Vector2.random(rnd)) * 100;
game.add(
ParticleSystemComponent(
particle: AcceleratedParticle(
// Запустится в центре игрового canvas
position: game.canvasSize/2,
// Со случайной начальной скоростью Vector2(-100..100, 0..-100)
speed: Vector2(rnd.nextDouble() * 200 - 100, -rnd.nextDouble() * 100),
// Ускоряясь вниз, имитируя "гравитацию"
// speed: Vector2(0, 100),
child: CircleParticle(
radius: 2.0,
paint: Paint()..color = Colors.red,
),
),
),
);
Частица, которая отрисовывает круг с заданным Paint
в нулевом смещении переданного Canvas
. Используйте в сочетании с TranslatedParticle
, MovingParticle
или AcceleratedParticle
, чтобы добиться желаемого позиционирования.
game.add(
ParticleSystemComponent(
particle: CircleParticle(
radius: game.size.x / 2,
paint: Paint()..color = Colors.red.withValues(alpha: .5),
),
),
);
Позволяет встроить Sprite
в ваши эффекты частиц.
game.add(
ParticleSystemComponent(
particle: SpriteParticle(
sprite: Sprite('sprite.png'),
size: Vector2(64, 64),
),
),
);
Отрисовывает заданное dart:ui image
внутри дерева частиц.
// Во время инициализации игры
await Flame.images.loadAll(const [
'image.png',
]);
// ...
// Где-то во время игрового цикла
final image = await Flame.images.load('image.png');
game.add(
ParticleSystemComponent(
particle: ImageParticle(
size: Vector2.all(24),
image: image,
);
),
);
Масштабирует дочернюю частицу между 1 и to
в течение ее времени жизни.
game.add(
ParticleSystemComponent(
particle: ScalingParticle(
lifespan: 2,
to: 0,
curve: Curves.easeIn,
child: CircleParticle(
radius: 2.0,
paint: Paint()..color = Colors.red,
)
);
),
);
Частица, которая встраивает SpriteAnimation
. По умолчанию выравнивает stepTime SpriteAnimation
так, чтобы она полностью проигрывалась в течение времени жизни частицы. Можно переопределить это поведение с помощью аргумента alignAnimationTime
.
final spriteSheet = SpriteSheet(
image: yourSpriteSheetImage,
srcSize: Vector2.all(16.0),
);
game.add(
ParticleSystemComponent(
particle: SpriteAnimationParticle(
animation: spriteSheet.createAnimation(0, stepTime: 0.1),
);
),
);
Эта частица позволяет встраивать Component
в эффекты частиц. Component
может иметь свой собственный жизненный цикл обновления и может повторно использоваться в разных деревьях эффектов. Если все, что вам нужно, это добавить немного динамики к экземпляру определенного Component
, пожалуйста, рассмотрите возможность добавления его непосредственно в игру, без частицы посередине.
final longLivingRect = RectComponent();
game.add(
ParticleSystemComponent(
particle: ComponentParticle(
component: longLivingRect
);
),
);
class RectComponent extends Component {
void render(Canvas c) {
c.drawRect(
Rect.fromCenter(center: Offset.zero, width: 100, height: 100),
Paint()..color = Colors.red
);
}
void update(double dt) {
/// Будет вызван родителем [Particle]
}
}
Частица, которая может помочь вам, когда:
• Поведения по умолчанию недостаточно
• Необходима оптимизация сложных эффектов
• Нужны пользовательские easing'и
При создании она делегирует весь рендеринг предоставленному ParticleRenderDelegate
, который вызывается на каждом кадре для выполнения необходимых вычислений и отрисовки чего-либо на Canvas
.
game.add(
ParticleSystemComponent(
// Отрисовывает круг, который постепенно меняет свой цвет и размер в течение
// времени жизни частицы.
particle: ComputedParticle(
renderer: (canvas, particle) => canvas.drawCircle(
Offset.zero,
particle.progress * 10,
Paint()
..color = Color.lerp(
Colors.red,
Colors.blue,
particle.progress,
),
),
),
),
)
Основные параметры частиц:
count - количество частиц
lifespan - время жизни частицы
position - начальная позиция
speed - скорость движения
acceleration - ускорение
paint - цвет и стиль отрисовки
Чтобы использовать частицы в игре, добавьте их так же, как и хитбоксы.
import 'dart:math';
import 'package:flame/components.dart';
import 'package:flame/events.dart';
import 'package:flame/game.dart';
import 'package:flame/particles.dart';
import 'package:flutter/material.dart';
import 'package:flame/collisions.dart';
import 'package:flame/effects.dart';
import 'package:flame/src/experimental/geometry/shapes/rectangle.dart';
void main() {
runApp(GameWidget(game: MyGame()));
}
class MyGame extends FlameGame with TapCallbacks, HasCollisionDetection {
late Player player;
late MultiEnemySpawner multiEnemySpawner;
late EnemySpawner enemySpawner;
late TextComponent scoreText;
late TextComponent timerText;
late StartButton startButton;
late StopButton stopButton;
double timer = 0;
bool isTimerRunning = false;
@override
Future<void> onLoad() async {
player = Player()
..position = size / 2
..anchor = Anchor.center;
add(player);
enemySpawner = EnemySpawner();
add(enemySpawner);
multiEnemySpawner = MultiEnemySpawner();
add(multiEnemySpawner);
timerText = TextComponent(
text: 'Time: ${timer.toStringAsFixed(1)}',
textRenderer: TextPaint(
style: const TextStyle(fontSize: 20, color: Colors.white),
),
position: Vector2(10, 60),
anchor: Anchor.topLeft,
priority: 2,
);
add(timerText);
startButton = StartButton(
position: Vector2(size.x / 2, size.y - 50),
onPressed: startTimer,
);
add(startButton);
stopButton = StopButton(
position: Vector2(size.x / 2, size.y - 50),
onPressed: stopTimer,
);
stopButton.removeFromParent(); // Сначала кнопка остановки скрыта
}
@override
void update(double dt) {
super.update(dt);
if (isTimerRunning) {
timer += dt;
timerText.text = 'Time: ${timer.toStringAsFixed(1)}';
}
}
void startTimer() {
isTimerRunning = true;
timer = 0;
startButton.removeFromParent(); // Убираем кнопку старта
add(stopButton); // Добавляем кнопку остановки
}
void stopTimer() {
isTimerRunning = false;
stopButton.removeFromParent(); // Убираем кнопку остановки
add(startButton); // Добавляем кнопку старта
}
}
class FireParticleComponent extends PositionComponent with HasGameRef<MyGame> {
final Random _random = Random();
final List<AcceleratedParticle> _particles = [];
static const double maxY = 150;
@override
void update(double dt) {
super.update(dt);
for (int i = 0; i < 5; i++) {
_particles.add(
AcceleratedParticle(
acceleration: Vector2(0, 100),
speed: Vector2(
(_random.nextDouble() - 0.5) * 40,
20 + _random.nextDouble() * 20,
),
lifespan: 1 + _random.nextDouble(),
position: Vector2(0, 0),
child: CircleParticle(
radius: 1 + _random.nextDouble() * 2,
paint: Paint()..color = Colors.orange.withValues(alpha: 0.5),
),
),
);
}
_particles.forEach((particle) {
particle.speed += particle.acceleration * dt;
particle.position += particle.speed * dt;
});
_particles.removeWhere(
(particle) => particle.lifespan <= 0 || particle.position.y > maxY,
);
}
@override
void render(Canvas canvas) {
for (final particle in _particles) {
canvas.save();
canvas.translate(particle.position.x, particle.position.y);
particle.child.render(canvas);
canvas.restore();
}
}
}
// Компонент игрока
class Player extends PositionComponent
with DragCallbacks, CollisionCallbacks, HasGameRef<MyGame> {
Player() : super(size: Vector2.all(50)) {
_paint.color = Colors.white;
add(
CircleHitbox(
collisionType: CollisionType.active,
radius: size.x / 2, // Радиус круга
position: Vector2(0, 0), // Позиция относительно родителя
anchor: Anchor.center, // Точка привязки
),
);
add(FireParticleComponent()
..position = Vector2(size.x / 2, size.y)
..anchor = Anchor.bottomCenter);
}
final _paint = Paint();
bool _isDragged = false;
int collisions = 0;
@override
void onDragStart(DragStartEvent event) {
super.onDragStart(event);
_isDragged = true;
}
@override
void onCollision(Set<Vector2> intersectionPoints, PositionComponent other) {
super.onCollision(intersectionPoints, other);
collisions++; //collisions = collisions +1;
if (collisions % 2 == 1) {
_paint.color = Colors.green;
} else {
_paint.color = Colors.white;
}
//_paint.color = Colors.red;
}
@override
void onDragUpdate(DragUpdateEvent event) => position += event.localDelta;
@override
void onDragEnd(DragEndEvent event) {
super.onDragEnd(event);
_isDragged = false;
}
@override
void render(Canvas canvas) {
canvas.drawCircle(Offset(size.x / 2, size.y / 2), size.x / 2, _paint);
}
}
// Компонент врага
class Enemy extends PositionComponent
with CollisionCallbacks, HasGameRef<MyGame> {
double speed = 100.0;
late Vector2 velocity;
VoidCallback? onKilled; // Колбэк для обработки убийства врага
Enemy({super.position}) : super(size: Vector2.all(30)) {
anchor = Anchor.center;
// Начальное направление движения
velocity = Vector2(0, 1);
add(
RectangleHitbox(
collisionType: CollisionType.passive,
size: Vector2(size.x, size.y), // Ширина и высота
position: Vector2(0, 0), // Позиция
),
);
}
@override
void onLoad() {
add(RotateEffect.by(50, EffectController(duration: 3)));
}
@override
void render(Canvas canvas) {
canvas.drawRect(size.toRect(), Paint()..color = Colors.red);
}
@override
void onCollision(Set<Vector2> intersectionPoints, PositionComponent other) {
super.onCollision(intersectionPoints, other);
kill();
// if (speed > 0) {
// speed = speed - 5;
// }
}
@override
void update(double dt) {
super.update(dt);
position.add(velocity * speed * dt);
// Удаление врага, когда он выходит за нижний край экрана
if (position.y > gameRef.size.y) {
removeFromParent();
}
}
void kill() {
onKilled?.call(); // Вызываем колбэк при уничтожении врага
removeFromParent();
}
}
class EnemySpawner extends SpawnComponent with HasGameRef<MyGame> {
EnemySpawner()
: super(
factory: (data) {
return Enemy();
},
period: 1,
);
@override
Future<void> onLoad() async {
area = Rectangle.fromLTWH(0, 0, gameRef.size.x, 150);
super.onLoad();
}
}
class MultiEnemySpawner extends SpawnComponent with HasGameRef<MyGame> {
MultiEnemySpawner()
: super(multiFactory: (amount) => [Enemy(), Enemy(), Enemy()], period: 5);
@override
Future<void> onLoad() async {
area = Rectangle.fromLTWH(
gameRef.size.x / 4,
0,
gameRef.size.x / 4 * 3,
50,
);
super.onLoad();
}
}
// Кнопка старта таймера
class StartButton extends PositionComponent with TapCallbacks {
final VoidCallback onPressed;
StartButton({required Vector2 position, required this.onPressed})
: super(
priority: 4,
position: position,
size: Vector2(150, 50),
anchor: Anchor.center,
);
@override
void render(Canvas canvas) {
final paint = Paint()
..color = Colors.blue
..style = PaintingStyle.fill;
canvas.drawRect(size.toRect(), paint);
final textPainter = TextPainter(
text: TextSpan(
text: 'Start Timer',
style: const TextStyle(fontSize: 20, color: Colors.white),
),
textDirection: TextDirection.ltr,
);
textPainter.layout();
textPainter.paint(
canvas,
Offset(
size.x / 2 - textPainter.width / 2,
size.y / 2 - textPainter.height / 2,
),
);
}
@override
void onTapDown(TapDownEvent event) {
onPressed();
}
}
// Кнопка остановки таймера
class StopButton extends PositionComponent with TapCallbacks {
final VoidCallback onPressed;
StopButton({required Vector2 position, required this.onPressed})
: super(
priority: 4,
position: position,
size: Vector2(150, 50),
anchor: Anchor.center,
);
@override
void render(Canvas canvas) {
final paint = Paint()
..color = Colors.red
..style = PaintingStyle.fill;
canvas.drawRect(size.toRect(), paint);
final textPainter = TextPainter(
text: TextSpan(
text: 'Stop Timer',
style: const TextStyle(fontSize: 20, color: Colors.white),
),
textDirection: TextDirection.ltr,
);
textPainter.layout();
textPainter.paint(
canvas,
Offset(
size.x / 2 - textPainter.width / 2,
size.y / 2 - textPainter.height / 2,
),
);
}
@override
void onTapDown(TapDownEvent event) {
onPressed();
}
}
Заменить исчезновение врагов на эффект вращения при столкновении длительностью 1 секунду.
Дополнительное задание со звездочкой: Добавить визуальный эффект из частиц (небольшого количества), разлетающийся в разные стороны при уничтожении противника.