Хотя данная статься имеет больше актуальности для мобильных устройств, для компьютеров оптимизация так же ощущается довольно заметно
Больше года я разрабатываю свою игру на движке Flame, который написан на языке dart и полностью интегрирован во Flutter. Хочу показать вам пару картинок, как сейчас всё это выглядит.
Всё это сделано стандартным набором программ: Tiled и движок Flame.
Вот проблемы, которые вы получите при работе с данными компонентами gameDev’а, и дальше — пути их решения. (Исходники положу в самом низу)
Tiled
Свободная программа для создания тайловых карт. Также здесь можно создавать слои препятствий с любыми свойствами. Я думаю, она всем известна — супер удобная, претензий к ней нет.
Карта на данный момент у меня такая: 297*297 тайлов. Каждый тайл — это квадрат 32*32 пикселя, т. е. моя карта 9504 пикселя на 9504. Плюс к этому она содержит 14 слоёв тайлов и 4 слоя объектов. И вот тут начинаются проблемы с отображением этого всего.
Вообще сделаем такую ремарку: во Flame есть чтение проектов Tiled прямо из коробки. Проблема в том, что сам класс, который создаётся после прочтения проекта, получается слишком тяжёлым. Из-за этого даже проект 30*30 тайлов читается около 300 миллисекунд.
Поэтому работать напрямую с проектами, как говорится в примерах на сайте Flame, очень невыгодно. т. е. нельзя просто сделать в Tiled много разных нумерованных карт и загружать их прямо во время рендеринга, когда вы подбегаете близко к краю экрана. Игра попросту зафризится на полсекунды и вы, конечно, это увидите. Я проверял это, и даже при двух слоях тайлов наблюдается такая картина. Играть так долго точно никто не захочет и через пять минут просто выключит постоянно фризищуюся поделку.
Вторая проблема после очень долгого чтения *.tmx это то, что удобно рисовать большую карту именно на большом полотне в Tiled. А разделить в программе Tiled огромную карту на части нельзя. И даже если мы разделим — не решим проблему долгой загрузки.
Можно отрендерить её в PNG прямо из программы Tiled и загружать обычным Flame.images.load('name'), но тут есть один нюанс: такая большая карта, как 9500 на 9500, будет жрать практически все FPS на андроиде при отображении. Flame очень плохо переносит большого размера PositionComponent.
Сейчас покажу, насколько удобно работать именно на большом холсте. Это приближенный кусок:
А вот это вся карта целиком (первая картинка вон там сверху слева):
Вот при таком масштабе сразу понятно, что и где должно находиться в твоей игре и можешь сделать большой и интересный мир.
Решения проблем загрузки игровой карты и оптимизация отображения в Tiled
Нам придётся сделать отдельный модуль для прекомпиляции всей карты из файла *.tmx в множество мелких файлов, разбитых по столбцам и ячейкам.
Я наткнулся здесь на статью по оптимизации отображения тайлов во Flame. Это не решает проблему долгой загрузки карт, но даёт отправную точку классом ImageBatchCompiler из пакета flame_tiled_utils (https://pub.dev/packages/flame_tiled_utils).
Исходя из того, что на ходу читать *.tmx очень затратно, нам приходится заранее скомпилировать её в простые составные, чтобы на ходу их читать. Тем более всё равно придётся дробить такую большую картину карты на ячейки. В итоге мы с помощью доработок ImageBatchCompiler прекомпилируем карту в PNG размером ваших ячеек с названиями ваших столбцов и ячеек. Это первый шаг. Плюс к этому я разделил названиями классы тайлов на те, что над игроком и на те что под ним:
Вот так я выделил то, что будет над игроком. В итоге у меня есть две картинки на ячейку: ту, что под игроком:
И ту, что над:
Мы будем создавать их обычным Sprite и для нижних ставить priority = 0, а для верхних картинок — priority = max
В итоге у нас уже получается вкусная картинка:
Следует добавить, что вам надо добавить пустую картинку в проект такого же размера и добавлять её в каждую ячейку во время прекомпиляции PNG. При компиляции картинки она обрезается до минимально возможных размеров, и если у вас допустим только одна верхушка дерева, то получится картинка 32*32, а куда её потом помещать на игровую карты будет совсем не ясно.
Теперь надо разобраться с анимированными тайлами. Они прекомпилируются так же в текстовые файлы: файлы НАД игроком и файлы ПОД ним. Я сделал свой собственный тип файлов *_high.anim и *_down.anim. Внутри файлов — обычный xml, который содержит путь к изображению, высоту/ширину тайла, длительность каждой анимации и список ее позиций в ячейке, если там много одинаковых анимаций. При игре мы будем создавать SpriteAnimationComponent на основе этих файлов. Как ни странно - создание SpriteAnimationComponent не расходует сильно ресурсы и создаются они моментально
Теперь осталось разобраться со слоями объектов в Tiled. Я подхожу здесь точно так же: поочерёдно прохожу по всем ячейкам и записываю все линии, которые входят в эту ячейку, если линия выходит за ячейку — нахожу пересечение и записываю эту точку как нужную. Таким образом я получаю краткий список всех объектов, включая все их свойства и прочее в моём формате файлов *.objXml. Эти файлы я буду читать на ходу. Они содержат начала координат на карте Tiled и все дальнейшие точки по порядку относительно начала. Они будут превращаться в мой собственный новый класс столкновений, который хранит просто лист точек и возможность поворачиваться, умножаться на вектор и отдавать правильные точки на игровой карте в зависимости от того - может ли этот объект менять своё местоположение. Как правило - бОльшая часть (намного бОльшая) объектов столкновений в игре - статично. Поэтому нет смысла держать класс встроенный PolygonHitbox, который и обновляется каждый тик, и хранит в себе кучу проверок и так далее. Нам просто надо знать список точек по порядку, по которым мы будем строить линии когда мы окажемся игроком в близости от этого объекта.
Flame
Обсчёт столкновений — самое важное и ответственное, что есть в игре. Это написано и в упомянутой выше статье.
Во‑первых, все классы у Flame довольно громоздкие. Не обходит это и класс столкновений (ShapeHitbox). Там и ретрейсинг сразу вшит, и прочее, прочее.
Во‑вторых, во Flame НЕЛЬЗЯ создавать одну линию как объект для столкновения, можно толькр выпуклые объекты, т.е минимум полигон из трёх точек.
В‑третьих, нет суммарного вызова на определённый тип предметов, т. е., попав в узкий переулок, вы будете получать сигналы от игры сначала о столкновении слева, потом справа. Всё это приводит к тому, что пользоваться такими ограничениями не имеет смысла.
В четвёртых: по какой-то причине вам дают не те точки, которые находятся внутри вашего тела, а просто точку пересечения с вашей линией. Из этого следует - что основываясь на этой информации вы не можете вообще ничего сделать, кроме как сказать что да - мы сейчас с чем-то столкнулись. Эта информация не несёт вообще никакого смысла, если нам надо отработать поведение столкновения. Должны передаваться точки, которые находятся внутри моего объекта, чтобы он сменил позиции и перестал включать в себя препятствие, чтобы оно было на границе моего хитбокса.
Минусы от невозможности создать такие вещи — все заборы или линии берега приходится делать кубиками, что увеличивает количество точек и линий столкновений в два раза! И ровно во столько же раз увеличивается обсчёт на возможность столкновений. Невозможность создать НЕвыпуклые многоугольники также заставляет дробить закутки и дома с одним открытым входом и прочие комнаты, помещения и т.д. и т.п. Если вы начнёту заходить в вершину треугольника - вы получите просто две точки на границе вас, а не ту вершину, которая сейчас внутри вашего хитбокса. Вот картинка:
Желтые точки это то, что даст нам Flame из коробки. Зелёная точка - эта точка, которая нам нужна, которая при перемещении должна оказаться вне нашего тела. Фиолетавая линия - нахождение пересечения центра, нашей точки и любой грани нашего тела. Чёрная стрелка - нормаль этой грани, по которой мы должны двигаться. Я даю в своём механизме фиолетовые точки (середина от зелёной и жёлтой), потому что при нахождении к границам объекта иногда непонятно куда двигаться, либо влево, либо вниз и персонаж застревает у края стены. Фиолетовые точки сглаживают это и таких склеек нет.
В итоге я полностью удалил из игры встроенный механизм и создал свой класс объектов, который просто содержит список точек по порядку. А в механизм обсчёта я добавил вначале сложение для каждого двигающегося объекта всех столкновений с препятствиями, а потом уже вызов его колбэка с совокупностью этих точек. Поэтому если вы ударяетесь в данный момент и справа, и слева — вы можете понять, что варианта выхода из этой ситуации два: либо вверх, либо вниз.
Дополнительный плюс делить объекты по ячейкам - отсекать ненужные проверки на пересечения если препятствие принадлежит другой ячейке.
В итоге я пришёл к такому механизму, что у меня есть два принципиально разных класса: объект столкновения и любой другой триггер — меч противника и прочее, что не нуждается во всех точках соприкосновения и должно вызывать коллбэк сразу. Точки объектов сначала добавляются в массив, только потом обрабатываются, столкновения с другими предметами же обрабатывается сразу.
Итоги
Tiled — отличный механизм для создания огромных и интерактивных карт. Но приходится всё это прекомпилировать. Это недолго и нестрашно, но от этого никуда не деться, при более-менее большой карте вам надо оптимизировать прогрузку *.tmx. Да и честно — это делается пару раз, и карта особо часто не меняется, уж тем более после того, как попадёт в google play.
Flame — отличный движок с точки зрения отображения и создания, а также простоты работы с анимированными компонентами, так как есть единая система взаимодействия компонентов, камеры, кэша файлов и создания HUD прямо стандартными классами Flutter (class Joystick extends StatelessWidget => return Center(child: joystick())). Минусы — всё, что связано с обработкой коллизий объектов. К сожалению, проще самому переписать полностью весь механизм, но поверьте — это всё супер несложно.
А что там у меня в игре
После прекомпиляции у меня 2699 файлов общим весом 20 мегабайт.
Из-за того, что нельзя получить доступ к asset'ам из другого потока на android, мне приходится при саааааамом первом запуске игры копировать *.anim и *.objXml во внутреннюю память телефона, чтобы при следующих запусках двумя другими потоками читать это и передавать в главный поток для кэша. В итоге загрузка всех этих файлов происходит за 3 секунды. Потом начинается игра, и мы видим 9 прямоугольников вокруг игрока, которые бесшовно прогружаются по ходу изменения позиции. Каждый прямоугольник 352*288 пикселей. На телефоне FPS в самых тяжёлых моментах не опускается ниже 40 FPS, а так постоянно 60. Нет никаких глазу заметных прифризов и прочего при передвижении или столкновениях.
Технические возможности
Можно создавать и крутить/увеличивать ЛЮБЫЕ объекты вообще и столкновения будут определяться хорошо классом MapObstacle или наследуя от него. Столкновения все складываются, и только потом отдаётся команда на обработку. В обработчик входят только точки, которые находятся внутри вашего персонажа. Т.е. логика работы со столкновениями предполагает, что вы будете менять позицию так, чтобы эта точка оказалась на границе вашего двигающегося тела.
Можно рисовать любую вообще кару, не большее 9600*9600 пикселей в Tiled, потому что больше просто не даст загрузить сам Flame. После создания карты вы должны прекомпилировать её на минифайлы по размерам вашей ячейки. И всё, потом только при смене позиции персонажа менять ячейки.
На данные момент есть баг в прекомпиляции объектов - иногда появляются дыра при дроблении объекта из карты *.tmx в ObjXml. Но задача здесь чисто аналитическая, и решается. Может выложу решение когда доберусь до него:
Исходники
Вспомогательные классы
class LoadedColumnRow
{
LoadedColumnRow(this.column, this.row);
int column;
int row;
@override
bool operator ==(Object other) {
return other is LoadedColumnRow && other.column == column && other.row == row;
}
@override
int get hashCode => column.hashCode ^ row.hashCode;
}
Vector2 f_pointOfIntersect(Vector2 a1, Vector2 a2, Vector2 b1, Vector2 b2)
{
double s1_x, s1_y, s2_x, s2_y;
s1_x = a2.x - a1.x;
s1_y = a2.y - a1.y;
s2_x = b2.x - b1.x;
s2_y = b2.y - b1.y;
double s, t;
s = (-s1_y * (a1.x - b1.x) + s1_x * (a1.y - b1.y)) /
(-s2_x * s1_y + s1_x * s2_y);
t = (s2_x * (a1.y - b1.y) - s2_y * (a1.x - b1.x)) /
(-s2_x * s1_y + s1_x * s2_y);
if (s >= 0 && s <= 1 && t >= 0 && t <= 1) {
return Vector2(a1.x + (t * s1_x), a1.y + (t * s1_y));
}
return Vector2.zero();
}
class PointCust extends PositionComponent
{
PointCust({required super.position,this.color});
final ShapeHitbox hitbox = CircleHitbox();
Color? color;
@override
void onLoad()
{
priority = 800;
size = Vector2(5, 5);
hitbox.paint.color = color ?? BasicPalette.green.color;
hitbox.renderShape = true;
add(hitbox);
Future.delayed(const Duration(milliseconds: 30),(){
removeFromParent();
});
}
}
Класс компилятора изображений и анимаций
import 'dart:io';
import 'dart:ui';
import 'package:flame/components.dart';
import 'package:flame/experimental.dart';
import 'package:flame/extensions.dart';
import 'package:flame/flame.dart';
import 'package:flame_tiled/flame_tiled.dart';
import 'package:flame_tiled_utils/flame_tiled_utils.dart';
import 'package:game_flame/components/physic_vals.dart';
enum RenderCompileMode{
Background,
Foreground,
All
}
Future processTileType(
{required RenderableTiledMap tileMap,
required TileProcessorFunc addTiles,
required List<String> layersToLoad,
required RenderCompileMode renderMode,
bool clear = true}) async
{
for (final layer in layersToLoad) {
final tileLayer = tileMap.getLayer<TileLayer>(layer);
final tileData = tileLayer?.data;
if (tileData != null) {
int xOffset = 0;
int yOffset = 0;
for (var tileId in tileData) {
bool isNeedAdd = true;
if (tileId != 0) {
final tileset = tileMap.map.tilesetByTileGId(tileId);
final firstGid = tileset.firstGid;
if (firstGid != null) {
tileId = tileId - firstGid; //+ 1;
}
final tileData = tileset.tiles[tileId];
if(renderMode == RenderCompileMode.Background && (tileData.class_ == 'high' || tileLayer!.name.startsWith('xx'))) {
isNeedAdd = false;
}
if(renderMode == RenderCompileMode.Foreground){
if(tileData.class_ != 'high' && !tileLayer!.name.startsWith('xx')) {
isNeedAdd = false;
}
}
if(isNeedAdd){
final position = Vector2(xOffset.toDouble() * tileMap.map.tileWidth,
yOffset.toDouble() * tileMap.map.tileWidth);
final tileProcessor = TileProcessor(tileData, tileset);
await addTiles(
tileProcessor,
position,
Vector2(tileMap.map.tileWidth.toDouble(),
tileMap.map.tileWidth.toDouble()));
}
}
xOffset++;
if (xOffset == tileLayer?.width) {
xOffset = 0;
yOffset++;
}
}
}
}
if (clear) {
tileMap.map.layers
.removeWhere((element) => layersToLoad.contains(element.name));
for (var rl in tileMap.renderableLayers) {
rl.refreshCache();
}
}
}
class IntPoint
{
IntPoint(this.x, this.y);
int x; //column
int y; //row
@override
bool operator ==(other)
{
if(other is IntPoint){
if(x != other.x || y != other.y){
return false;
}
return true;
}else{
return false;
}
}
@override
int get hashCode => x.hashCode + y.hashCode;
}
class AnimationPos
{
String sourceImg = '';
final List<IntPoint> spritePos = [];
final List<double> stepTimes = [];
int width = 0;
int height = 0;
@override
operator ==(other)
{
if(other is AnimationPos){
if(spritePos.length == other.spritePos.length){
for(int i=0;i<spritePos.length;i++){
if(spritePos[i] != other.spritePos[i]){
return false;
}
}
}else{
return false;
}
if(stepTimes.length == other.stepTimes.length){
for(int i=0;i<stepTimes.length;i++){
if(stepTimes[i] != other.stepTimes[i]){
return false;
}
}
}else{
return false;
}
if(sourceImg != other.sourceImg){
return false;
}
}else{
return false;
}
return true;
}
@override
int get hashCode => sourceImg.hashCode + spritePos[0].hashCode + stepTimes[0].hashCode;
}
class MySuperAnimCompiler { //Доработанный ImageBatchCompiler
List<Map<Sprite?, List<Vector2>>> _mapsSprite = [];
Map<Sprite?, List<Vector2>> _allSpriteMap = {};
Map<AnimationPos, List<Vector2>> _animations = {};
Future addTile(Vector2 position, TileProcessor tileProcessor) async
{
var animation = await tileProcessor.getSpriteAnimation();
if (animation == null) {
var sprite = await tileProcessor.getSprite();
_allSpriteMap.putIfAbsent(sprite, () => []);
_allSpriteMap[sprite]!.add(position);
} else {
AnimationPos pos = AnimationPos();
pos.sourceImg = tileProcessor.tileset.image!.source!;
pos.width = tileProcessor.tileset.tileWidth!;
pos.height = tileProcessor.tileset.tileHeight!;
Image image = await Flame.images.load(pos.sourceImg);
int maxColumn = image.width ~/ pos.width;
for (final frame in tileProcessor.tile.animation) {
pos.stepTimes.add(frame.duration / 1000);
pos.spritePos.add(
IntPoint(frame.tileId % maxColumn, frame.tileId ~/ maxColumn));
}
_animations.putIfAbsent(pos, () => []);
_animations[pos]!.add(position);
}
}
void addLayer() {
_mapsSprite.add(_allSpriteMap);
_allSpriteMap = {};
}
Future<void> compile(String path) async
{
print('start compile! $path');
final nullImage = await Flame.images.load('null_image-352px.png');
for (int cols = 0; cols < GameConsts.maxColumn; cols++) {
for (int rows = 0; rows < GameConsts.maxRow; rows++) {
bool isWas = false;
var position = Vector2(cols * GameConsts.lengthOfTileSquare.x,
rows * GameConsts.lengthOfTileSquare.y);
Rectangle rec = Rectangle.fromPoints(position, Vector2(
position.x + GameConsts.lengthOfTileSquare.x,
position.y + GameConsts.lengthOfTileSquare.y));
final composition = ImageCompositionExt();
for (int i = 0; i < _mapsSprite.length; i++) {
var currentSprites = _mapsSprite[i];
for (final spr in currentSprites.keys) {
if (spr == null) {
continue;
}
for (final pos in currentSprites[spr]!) {
if (!rec.containsPoint(pos + Vector2.all(1))) {
continue;
}
composition.add(spr.image, pos - position, source: spr.src);
isWas = true;
}
}
}
if (isWas) {
composition.add(
nullImage, Vector2.all(0), source: nullImage.getBoundingRect());
final composedImage = composition.compose();
var byteData = await composedImage.toByteData(
format: ImageByteFormat.png);
File file = File('assets/metaData/$cols-${rows}_$path.png');
file.writeAsBytesSync(byteData!.buffer.asUint8List());
}
}
}
for (int cols = 0; cols < GameConsts.maxColumn; cols++) {
for (int rows = 0; rows < GameConsts.maxRow; rows++) {
var position = Vector2(cols * GameConsts.lengthOfTileSquare.x,
rows * GameConsts.lengthOfTileSquare.y);
Rectangle rec = Rectangle.fromPoints(position, Vector2(
position.x + GameConsts.lengthOfTileSquare.x,
position.y + GameConsts.lengthOfTileSquare.y));
bool isStartFile = false;
for (final anim in _animations.keys) {
String animText = '';
List<Vector2> currentPoints = _animations[anim]!;
for (final point in currentPoints) {
if (!rec.containsPoint(point + Vector2.all(1))) {
continue;
}
if (animText == '') {
animText = '<an src="${anim.sourceImg}" w="${anim.width}" h="${anim.height}" >\n';
for (int i = 0; i < anim.stepTimes.length; i++) {
animText +=
'<fr dr="${anim.stepTimes[i]}" cl="${anim.spritePos[i]
.x}" rw="${anim.spritePos[i].y}"/>\n';
}
}
animText +=
'<ps x="${point.x}" y="${point.y}"/>\n';
}
if (animText != '') {
File file = File('assets/metaData/$cols-${rows}_$path.anim');
if(!isStartFile){
isStartFile = true;
file.writeAsStringSync('<p>\n', mode: FileMode.append);
}
file.writeAsStringSync(animText, mode: FileMode.append);
file.writeAsStringSync('</an>\n', mode: FileMode.append);
}
}
if (isStartFile) {
File file = File('assets/metaData/$cols-${rows}_$path.anim');
file.writeAsStringSync('</p>\n', mode: FileMode.append);
}
}
}
}
}
Класс столкновений
abstract class DCollisionEntity extends Component
{
List<Vector2> _vertices;
DCollisionType collisionType;
bool isSolid;
bool isStatic;
bool isLoop;
double angle = 0;
Vector2 scale = Vector2(1, 1);
Vector2 _center = Vector2(0, 0);
Set<Vector2> obstacleIntersects = {};
LoadedColumnRow? _myCoords;
KyrgyzGame game;
int? column;
int? row;
double width = 0;
double height = 0;
bool onlyForPlayer = false;
Vector2 _minCoords = Vector2(0, 0);
Vector2 _maxCoords = Vector2(0, 0);
Vector2? transformPoint;
List<Vector2> get vertices => _vertices;
DCollisionEntity(this._vertices,
{required this.collisionType, required this.isSolid, required this.isStatic
, required this.isLoop, required this.game, this.column, this.row, this.transformPoint})
{
if (isStatic) {
int currCol = column ?? vertices[0].x ~/ GameConsts.lengthOfTileSquare.x;
int currRow = row ?? vertices[0].y ~/ GameConsts.lengthOfTileSquare.y;
_myCoords = LoadedColumnRow(currCol, currRow);
game.gameMap.collisionProcessor.addStaticCollEntity(
LoadedColumnRow(currCol, currRow), this);
} else {
game.gameMap.collisionProcessor.addActiveCollEntity(this);
}
for (int i = 0; i < vertices.length; i++) {
if (vertices[i].x < _minCoords.x) {
_minCoords.x = vertices[i].x;
}
if (vertices[i].x > _maxCoords.x) {
_maxCoords.x = vertices[i].x;
}
if (vertices[i].y < _minCoords.y) {
_minCoords.y = vertices[i].y;
}
if (vertices[i].y > _maxCoords.y) {
_maxCoords.y = vertices[i].y;
}
}
width = _maxCoords.x - _minCoords.x;
height = _maxCoords.y - _minCoords.y;
_center = (_maxCoords + _minCoords) / 2;
transformPoint ??= _center;
}
Vector2 getMinVector()
{
if(isStatic){
return _minCoords;
}
var par = parent as PositionComponent;
if(par.isFlippedHorizontally){
return Vector2(_getPointFromRawPoint(_maxCoords).x, _getPointFromRawPoint(_minCoords).y);
}else{
return _getPointFromRawPoint(_minCoords);
}
}
Vector2 getMaxVector()
{
if(isStatic){
return _maxCoords;
}
var par = parent as PositionComponent;
if(par.isFlippedHorizontally){
return Vector2(_getPointFromRawPoint(_minCoords).x, _getPointFromRawPoint(_maxCoords).y);
}else{
return _getPointFromRawPoint(_maxCoords);
}
}
doDebug(Color? color) {
for (int i = 0; i < vertices.length; i++) {
if(parent == null){
return;
}
PointCust p = PointCust(
position: getPoint(i), color: color);
game.gameMap.add(p);
}
}
@override
void onRemove() {
if (!isStatic) {
game.gameMap.collisionProcessor.removeActiveCollEntity(this);
} else {
game.gameMap.collisionProcessor.removeStaticCollEntity(_myCoords);
}
}
bool onComponentTypeCheck(DCollisionEntity other);
void onCollisionStart(Set<Vector2> intersectionPoints,
DCollisionEntity other);
void onCollisionEnd(DCollisionEntity other);
void onCollision(Set<Vector2> intersectionPoints, DCollisionEntity other);
Vector2 _getPointFromRawPoint(Vector2 rawPoint)
{
if(isStatic){
return rawPoint;
}else {
var temp = parent as PositionComponent;
Vector2 posAnchor = temp.positionOfAnchor(temp.anchor);
Vector2 point = rawPoint - transformPoint!;
if(temp.isFlippedHorizontally){
point.x *= -1;
}
point.x *= scale.x;
point.y *= scale.y;
if(angle != 0){
point = _rotatePoint(point,temp.isFlippedHorizontally);
}
point += transformPoint!;
return point + posAnchor;
}
}
Vector2 getCenter() {
return _getPointFromRawPoint(_center);
}
int getVerticesCount() {
return vertices.length;
}
Vector2 getPoint(int index) {
return _getPointFromRawPoint(vertices[index]);
}
Vector2 _rotatePoint(Vector2 point, bool isHorizontalFlip) {
double radian = angle * pi / 180;
isHorizontalFlip ? radian *= -1 : radian;
point.x = point.x * cos(radian) - point.y * sin(radian);
point.y = point.x * sin(radian) + point.y * cos(radian);
return point;
}
@override
void update(double dt) {
// doDebug();
super.update(dt);
}
}
Полный прекомпил исходной карты *.tmx в картинки, анимации и любые какие угодно объекты
Future compileAll(LoadedColumnRow colRow) async
{
if (colRow.column != 0 && colRow.row != 0) {
return;
}
var fileName = 'top_left_bottom-slice.tmx'; //Имя вашей карты из Tiled
var tiled = await TiledComponent.load(fileName, Vector2.all(320));
var layersLists = tiled.tileMap.renderableLayers;
if (true) {
MySuperAnimCompiler compilerAnimationBack = MySuperAnimCompiler();
MySuperAnimCompiler compilerAnimation = MySuperAnimCompiler();
for (var a in layersLists) {
if (a.layer.type != LayerType.tileLayer) {
continue;
}
await processTileType(
clear: false,
renderMode: RenderCompileMode.Background,
tileMap: tiled.tileMap,
addTiles: (tile, position, size) async {
compilerAnimationBack.addTile(position, tile);
},
layersToLoad: [a.layer.name]);
compilerAnimationBack.addLayer();
await processTileType(
clear: false,
renderMode: RenderCompileMode.Foreground,
tileMap: tiled.tileMap,
addTiles: (tile, position, size) async {
compilerAnimation.addTile(position, tile);
},
layersToLoad: [a.layer.name]);
compilerAnimation.addLayer();
}
print('start compile!');
await compilerAnimation.compile('high');
await compilerAnimationBack.compile('down');
}
// tiled = await TiledComponent.load(fileName, Vector2.all(320));
Set<String> loadedFiles = {};
for(var layer in layersLists){
if(layer.layer.type == LayerType.objectGroup){
var objs = tiled.tileMap.getLayer<ObjectGroup>(layer.layer.name);
if (objs != null) {
for (int cols = 0; cols < GameConsts.maxColumn; cols++) {
for (int rows = 0; rows < GameConsts.maxRow; rows++) {
var positionCurs = Vector2(
cols * GameConsts.lengthOfTileSquare.x,
rows * GameConsts.lengthOfTileSquare.y);
String newObjs = '';
Rectangle rec = Rectangle.fromPoints(positionCurs, Vector2(
positionCurs.x + GameConsts.lengthOfTileSquare.x,
positionCurs.y + GameConsts.lengthOfTileSquare.y));
for (final obj in objs.objects) {
if (obj.name == '') {
continue;
}
Rectangle objRect = Rectangle.fromPoints(
Vector2(obj.x, obj.y),
Vector2(obj.x + obj.width, obj.y + obj.height));
if (isIntersect(rec, objRect)) {
newObjs +=
'<o nm="${obj.name}" cl="${obj.type}" x="${obj
.x}" y="${obj.y}" w="${obj.width}" h="${obj
.height}"';
for(final props in obj.properties){
newObjs += ' ${props.name}="${props.value}"';
}
newObjs += '/>';
newObjs += '\n';
}
}
if (newObjs != '') {
File file = File('assets/metaData/$cols-$rows.objXml');
if (!loadedFiles.contains('assets/metaData/$cols-$rows.objXml')) {
loadedFiles.add('assets/metaData/$cols-$rows.objXml');
file.writeAsStringSync('<p>\n', mode: FileMode.append);
}
file.writeAsStringSync(newObjs, mode: FileMode.append);
}
}
}
}
print('END OF OBJS COMPILE');
print('start grounds compile');
if (objs != null) {
Map<LoadedColumnRow, List<GroundSource>> objsMap = {};
for (final obj in objs.objects) {
if (obj.name != '') {
continue;
}
bool isLoop = false;
List<Vector2> points = [];
if (obj.isPolygon) {
isLoop = true;
for (final point in obj.polygon) {
points.add(Vector2(point.x + obj.x, point.y + obj.y));
}
}
if (obj.isPolyline) {
for (final point in obj.polyline) {
points.add(Vector2(point.x + obj.x, point.y + obj.y));
}
}
if (obj.isRectangle) {
isLoop = true;
points.add(Vector2(obj.x, obj.y));
points.add(Vector2(obj.x, obj.y + obj.height));
points.add(Vector2(obj.x + obj.width, obj.y + obj.height));
points.add(Vector2(obj.x + obj.width, obj.y));
}
int minCol = GameConsts.maxColumn;
int minRow = GameConsts.maxRow;
int maxCol = 0;
int maxRow = 0;
for (final point in points) {
minCol = min(minCol, point.x ~/ (GameConsts.lengthOfTileSquare.x));
minRow = min(minRow, point.y ~/ (GameConsts.lengthOfTileSquare.y));
maxCol = max(maxCol, point.x ~/ (GameConsts.lengthOfTileSquare.x));
maxRow = max(maxRow, point.y ~/ (GameConsts.lengthOfTileSquare.y));
}
bool isReallyLoop = minCol == maxCol && minRow == maxRow && isLoop;
for (int currColInCycle = minCol; currColInCycle <= maxCol; currColInCycle++) {
for (int currRowInCycle = minRow; currRowInCycle <= maxRow; currRowInCycle++) {
Vector2 topLeft = Vector2(currColInCycle * GameConsts.lengthOfTileSquare.x,
currRowInCycle * GameConsts.lengthOfTileSquare.y);
Vector2 topRight = Vector2(
(currColInCycle + 1) * GameConsts.lengthOfTileSquare.x,
currRowInCycle * GameConsts.lengthOfTileSquare.y);
Vector2 bottomLeft = Vector2(currColInCycle * GameConsts.lengthOfTileSquare.x,
(currRowInCycle + 1) * GameConsts.lengthOfTileSquare.y);
Vector2 bottomRight = Vector2(
(currColInCycle + 1) * GameConsts.lengthOfTileSquare.x,
(currRowInCycle + 1) * GameConsts.lengthOfTileSquare.y);
List<Vector2> coord = [];
for (int i = -1; i < points.length - 1; i++) {
if (!isLoop && i == -1) {
continue;
}
int tF, tS;
if (i == -1) {
tF = points.length - 1;
tS = 0;
} else {
tF = i;
tS = i + 1;
}
List<Vector2> tempCoord = [];
if (points[tF].x >= topLeft.x && points[tF].x <= topRight.x
&& points[tF].y >= topLeft.y && points[tF].y <= bottomLeft
.y) {
coord.add(points[tF]);
}
Vector2 answer = f_pointOfIntersect(
topLeft, topRight, points[tF], points[tS]);
if (answer != Vector2.zero()) {
tempCoord.add(answer);
}
answer = f_pointOfIntersect(
topRight, bottomRight, points[tF], points[tS]);
if (answer != Vector2.zero()) {
tempCoord.add(answer);
}
answer = f_pointOfIntersect(
bottomRight, bottomLeft, points[tF], points[tS]);
if (answer != Vector2.zero()) {
tempCoord.add(answer);
}
answer = f_pointOfIntersect(
bottomLeft, topLeft, points[tF], points[tS]);
if (answer != Vector2.zero()) {
tempCoord.add(answer);
}
if (tempCoord.length == 1) {
coord.add(tempCoord[0]);
if(coord.length > 1){
GroundSource newPoints = GroundSource();
newPoints.isLoop = false;
newPoints.points = List.unmodifiable(coord);
objsMap.putIfAbsent(LoadedColumnRow(currColInCycle, currRowInCycle), () => []);
objsMap[LoadedColumnRow(currColInCycle, currRowInCycle)]!.add(newPoints);
coord.clear();
}
} else {
if (tempCoord.length == 2) {
coord.clear();
if (points[tF].distanceTo(tempCoord[0]) >
points[tF].distanceTo(tempCoord[1])) {
coord.add(tempCoord[1]);
coord.add(tempCoord[0]);
} else {
coord.add(tempCoord[0]);
coord.add(tempCoord[1]);
}
GroundSource newPoints = GroundSource();
newPoints.isLoop = false;
newPoints.points = List.unmodifiable(coord);
objsMap.putIfAbsent(LoadedColumnRow(currColInCycle, currRowInCycle), () => []);
objsMap[LoadedColumnRow(currColInCycle, currRowInCycle)]!.add(newPoints);
coord.clear();
} else if(tempCoord.length > 2){
print('CRITICAL ERROR IN PRECOMPILE GROUND!!!');
}
}
}
if (points[points.length - 1].x >= topLeft.x &&
points[points.length - 1].x <= topRight.x
&& points[points.length - 1].y >= topLeft.y &&
points[points.length - 1].y <= bottomLeft.y && !isLoop) {
coord.add(points[points.length - 1]);
}
if(coord.isNotEmpty) {
GroundSource newPoints = GroundSource();
newPoints.isLoop = isReallyLoop;
newPoints.points = List.unmodifiable(coord);
objsMap.putIfAbsent(
LoadedColumnRow(currColInCycle, currRowInCycle), () =>
[
]);
objsMap[LoadedColumnRow(currColInCycle, currRowInCycle)]!.add(
newPoints);
}
}
}
}
for(final key in objsMap.keys){
File file = File('assets/metaData/${key.column}-${key.row}.objXml');
if(!loadedFiles.contains('assets/metaData/${key.column}-${key.row}.objXml')){
file.writeAsStringSync('<p>\n', mode: FileMode.append);
loadedFiles.add('assets/metaData/${key.column}-${key.row}.objXml');
}
for(int i=0;i<objsMap[key]!.length;i++){
if(objsMap[key]![i].points.isEmpty){
continue;
}
file.writeAsStringSync('\n<o lp="${objsMap[key]![i].isLoop ? '1' : '0'}" nm="" p="', mode: FileMode.append);
for(int j=0;j<objsMap[key]![i].points.length;j++){
if(j > 0){
file.writeAsStringSync(' ', mode: FileMode.append);
}
file.writeAsStringSync('${objsMap[key]![i].points[j].x},${objsMap[key]![i].points[j].y}', mode: FileMode.append);
}
file.writeAsStringSync('"/>', mode: FileMode.append);
}
}
}
}
}
for(final key in loadedFiles){
File file = File(key);
file.writeAsStringSync('\n</p>', mode: FileMode.append);
}
}
Ну и собственно чтение и создание всех файлов на лету на наш игровой экран при изменении позиции персонажа:
Динамическая загрузка объектов
//custMap - это менеджер всех файлов, которые должны отображаться на экране.
//Имено он должен сам следить и удалять те элементы, которые пропали из вида
//Двигающиеся объекты удаляют себя сами. Поэтому надо проверять - не был ли он уже
//загружен
Future<void> generateMap(LoadedColumnRow colRow) async
{
if (colRow.column >= GameConsts.maxColumn || colRow.row >= GameConsts.maxRow) {
return;
}
if (colRow.column < 0 || colRow.row < 0) {
return;
}
custMap.allEls.putIfAbsent(colRow, () => []);
if (KyrgyzGame.cachedMapPngs.contains('${colRow.column}-${colRow.row}_down.png')) {
Image _imageDown = await Flame.images.load(
'metaData/${colRow.column}-${colRow.row}_down.png'); //KyrgyzGame.cachedImgs['$column-${row}_down.png']!;
var spriteDown = SpriteComponent(
sprite: Sprite(_imageDown),
position: Vector2(colRow.column * GameConsts.lengthOfTileSquare.x,
colRow.row * GameConsts.lengthOfTileSquare.y),
size: GameConsts.lengthOfTileSquare + Vector2.all(1),
priority: 0,
);
custMap.allEls[colRow]!.add(spriteDown);
custMap.add(spriteDown);
}
if (KyrgyzGame.cachedAnims.containsKey('${colRow.column}-${colRow.row}_down.anim')) {
var objects = KyrgyzGame.cachedAnims['${colRow.column}-${colRow.row}_down.anim']!;
for (final obj in objects) {
Vector2 sourceSize = Vector2(double.parse(obj.getAttribute('w')!),double.parse(obj.getAttribute('h')!));
Image srcImage = KyrgyzGame.cachedImgs[obj.getAttribute('src')!]!;
final List<Sprite> spriteList = [];
final List<double> stepTimes = [];
for (final anim in obj.findAllElements('fr')) {
spriteList.add(Sprite(srcImage, srcSize: sourceSize,
srcPosition: Vector2(
double.parse(anim.getAttribute('cl')!) * sourceSize.x,
double.parse(anim.getAttribute('rw')!) * sourceSize.y)));
stepTimes.add(double.parse(anim.getAttribute('dr')!));
}
var sprAnim = SpriteAnimation.variableSpriteList(
spriteList, stepTimes: stepTimes);
for (final anim in obj.findAllElements('ps')) {
var ss = SpriteAnimationComponent(animation: sprAnim,
position: Vector2(double.parse(anim.getAttribute('x')!),
double.parse(anim.getAttribute('y')!)),
size: Vector2(sourceSize.x+1, sourceSize.y+1),
priority: GamePriority.ground + 1);
custMap.allEls[colRow]!.add(ss);
custMap.add(ss);
}
}
}
if (KyrgyzGame.cachedMapPngs.contains('${colRow.column}-${colRow.row}_high.png')) {
Image _imageHigh = await Flame.images.load(
'metaData/${colRow.column}-${colRow.row}_high.png'); //KyrgyzGame.cachedImgs['$column-${row}_high.png']!;
var spriteHigh = SpriteComponent(
sprite: Sprite(_imageHigh),
position: Vector2(colRow.column * GameConsts.lengthOfTileSquare.x,
colRow.row * GameConsts.lengthOfTileSquare.y),
priority: GamePriority.high - 1,
size: GameConsts.lengthOfTileSquare + Vector2.all(1),
);
custMap.allEls[colRow]!.add(spriteHigh);
custMap.add(spriteHigh);
}
if (KyrgyzGame.cachedAnims.containsKey('${colRow.column}-${colRow.row}_high.anim')) {
var objects = KyrgyzGame.cachedAnims['${colRow.column}-${colRow.row}_high.anim']!;
for (final obj in objects) {
Vector2 srcSize = Vector2(double.parse(obj.getAttribute('w')!),double.parse(obj.getAttribute('h')!));
Image srcImage = KyrgyzGame.cachedImgs[obj.getAttribute('src')!]!;
final List<Sprite> spriteList = [];
final List<double> stepTimes = [];
for (final anim in obj.findAllElements('fr')) {
spriteList.add(Sprite(srcImage, srcSize: srcSize,
srcPosition: Vector2(
double.parse(anim.getAttribute('cl')!) * srcSize.x,
double.parse(anim.getAttribute('rw')!) * srcSize.y)));
stepTimes.add(double.parse(anim.getAttribute('dr')!));
}
var sprAnim = SpriteAnimation.variableSpriteList(
spriteList, stepTimes: stepTimes);
for (final anim in obj.findAllElements('ps')) {
var ss = SpriteAnimationComponent(animation: sprAnim,
position: Vector2(double.parse(anim.getAttribute('x')!),
double.parse(anim.getAttribute('y')!)),
size: Vector2(srcSize.x+1, srcSize.y+1),
priority: GamePriority.high);
custMap.allEls[colRow]!.add(ss);
custMap.add(ss);
}
}
}
if (KyrgyzGame.cachedObjXmls.containsKey('${colRow.column}-${colRow.row}.objXml')) {
var objects = KyrgyzGame.cachedObjXmls['${colRow.column}-${colRow.row}.objXml']!;
for (final obj in objects) {
String? name = obj.getAttribute('nm');
switch (name) {
case '':
var points = obj.getAttribute('p')!;
var pointsList = points.split(' ');
List<Vector2> temp = [];
for(final sources in pointsList){
if(sources == ''){
continue;
}
temp.add(Vector2(double.parse(sources.split(',')[0]),double.parse(sources.split(',')[1])));
}
if(temp.isNotEmpty) {
var ground = Ground(temp, collisionType: DCollisionType.passive,
isSolid: false,
isStatic: true,
isLoop: obj.getAttribute('lp')! == '1',
game: myGame!,
column: colRow.column,
row: colRow.row);
custMap.allEls[colRow]!.add(ground);
custMap.add(ground);
}
break;
default:
_createLiveObj(obj, name, colRow);
break;
}
}
}
}
Future _createLiveObj(XmlElement obj, String? name, LoadedColumnRow colRow) async
{
Vector2 position = Vector2(
double.parse(obj.getAttribute('x')!),
double.parse(obj.getAttribute('y')!)
);
if (custMap.loadedLivesObjs.contains(position)) {
return;
}
switch (name) {
case 'enemy':
custMap.loadedLivesObjs.add(position);
custMap.add(GrassGolem(
position, GolemVariant.Grass, priority: GamePriority.player - 2));
break;
case 'wgolem':
custMap.loadedLivesObjs.add(position);
custMap.add(GrassGolem(
position, GolemVariant.Water, priority: GamePriority.player - 2));
break;
case 'gold':
var temp = LootOnMap(itemFromId(2), position: position);
custMap.allEls[colRow]!.add(temp);
custMap.add(temp);
break;
case 'strange_merchant':
var temp = StrangeMerchant(position,StrangeMerchantVariant.black, priority: GamePriority.player - 2);
custMap.allEls[colRow]!.add(temp);
custMap.add(temp);
break;
case 'chest':
var temp = Chest(1, myItems: [itemFromId(2)], position: position);
custMap.allEls[colRow]!.add(temp);
custMap.add(temp);
break;
case 'fObelisk':
var temp = FlyingHighObelisk(
position, colRow.column, colRow.row, priority: GamePriority.high - 1);
custMap.allEls[colRow]!.add(temp);
custMap.add(temp);
var temp2 = FlyingDownObelisk(
position, colRow.column, colRow.row, priority: GamePriority.player - 2);
custMap.allEls[colRow]!.add(temp2);
custMap.add(temp2);
break;
case 'sObelisk':
var temp = StandHighObelisk(position, priority: GamePriority.high - 1);
custMap.allEls[colRow]!.add(temp);
custMap.add(temp);
var temp2 = StandDownObelisk(
position, priority: GamePriority.player - 2);
custMap.allEls[colRow]!.add(temp2);
custMap.add(temp2);
break;
case 'telep':
var targetPos = obj.getAttribute('tar')!.split(',');
Vector2 target = Vector2(double.parse(targetPos[0]), double.parse(targetPos[1]));
Vector2 telSize = Vector2(double.parse(obj.getAttribute('w')!), double.parse(obj.getAttribute('h')!));
var temp = Teleport(size: telSize, position: position, targetPos: target);
custMap.allEls[colRow]!.add(temp);
custMap.add(temp);
}
}
Процесс нахождения коллизий
import 'dart:math' as math;
import 'package:flame/components.dart';
import 'package:flame/image_composition.dart';
import 'package:game_flame/abstracts/enemy.dart';
import 'package:game_flame/abstracts/hitboxes.dart';
import 'package:game_flame/abstracts/obstacle.dart';
import 'package:game_flame/abstracts/player.dart';
import 'package:game_flame/abstracts/utils.dart';
import 'package:game_flame/components/physic_vals.dart';
import 'package:game_flame/components/tile_map_component.dart';
class DCollisionProcessor
{
final List<DCollisionEntity> _activeCollEntity = [];
final Map<LoadedColumnRow,List<DCollisionEntity>> _staticCollEntity = {};
Map<LoadedColumnRow, List<DCollisionEntity>> _potentialActiveEntity = {};
Set<LoadedColumnRow> _contactNests = {};
void addActiveCollEntity(DCollisionEntity entity)
{
_activeCollEntity.add(entity);
}
void removeActiveCollEntity(DCollisionEntity entity)
{
_activeCollEntity.remove(entity);
}
void addStaticCollEntity(LoadedColumnRow colRow, DCollisionEntity entity)
{
_staticCollEntity.putIfAbsent(colRow, () => []);
_staticCollEntity[colRow]!.add(entity);
}
void removeStaticCollEntity(LoadedColumnRow? colRow)
{
_staticCollEntity.remove(colRow);
}
void clearActiveCollEntity()
{
_activeCollEntity.clear();
}
void clearStaticCollEntity()
{
_staticCollEntity.clear();
}
void updateCollisions()
{
_potentialActiveEntity.clear();
for(DCollisionEntity entity in _activeCollEntity){
entity.obstacleIntersects = {};
if(entity.collisionType == DCollisionType.inactive) {
continue;
}
_contactNests.clear();
int minCol = 0;
int maxCol = 0;
int minRow = 0;
int maxRow = 0;
for(int i=0;i<entity.getVerticesCount();i++){
if(i==0){
minCol = entity.getPoint(i).x ~/ GameConsts.lengthOfTileSquare.x;
maxCol = entity.getPoint(i).x ~/ GameConsts.lengthOfTileSquare.x;
minRow = entity.getPoint(i).y ~/ GameConsts.lengthOfTileSquare.y;
maxRow = entity.getPoint(i).y ~/ GameConsts.lengthOfTileSquare.y;
continue;
}
minCol = math.min(minCol, entity.getPoint(i).x ~/ GameConsts.lengthOfTileSquare.x);
maxCol = math.max(maxCol, entity.getPoint(i).x ~/ GameConsts.lengthOfTileSquare.x);
minRow = math.min(minRow, entity.getPoint(i).y ~/ GameConsts.lengthOfTileSquare.y);
maxRow = math.max(maxRow, entity.getPoint(i).y ~/ GameConsts.lengthOfTileSquare.y);
}
for(int col = minCol; col <= maxCol; col++){
for(int row = minRow; row <= maxRow; row++){
var lcr = LoadedColumnRow(col, row);
_potentialActiveEntity.putIfAbsent(lcr, () => []);
_potentialActiveEntity[lcr]!.add(entity);
_contactNests.add(lcr);
}
}
for(final lcr in _contactNests){
if(_staticCollEntity.containsKey(lcr)) {
for(final other in _staticCollEntity[lcr]!){
if(other.collisionType == DCollisionType.inactive){
continue;
}
if(entity.collisionType == DCollisionType.passive && other.collisionType == DCollisionType.passive){
continue;
}
if(entity.parent != null && entity.parent !is MainPlayer && other.onlyForPlayer){
continue;
}
if(!entity.onComponentTypeCheck(other) && !other.onComponentTypeCheck(entity)) {
continue;
}
_calcTwoEntities(entity, other, other is MapObstacle);
}
}
}
}
final listOfList = List.unmodifiable(_potentialActiveEntity.values.toList());
for(int key = 0; key < listOfList.length; key++){
Set<int> removeList = {};
for(int i = 0; i < listOfList[key].length; i++){
for(int j = 0; j < listOfList[key].length; j++){
if(i == j || removeList.contains(j)){
continue;
}
if(listOfList[key][i].collisionType == DCollisionType.passive && listOfList[key][j].collisionType == DCollisionType.passive){
continue;
}
if(!listOfList[key][i].onComponentTypeCheck(listOfList[key][j]) && !listOfList[key][j].onComponentTypeCheck(listOfList[key][i])){
continue;
}
if(listOfList[key][j] is MapObstacle){
if(listOfList[key][i].parent != null && listOfList[key][i].parent is KyrgyzEnemy && listOfList[key][j].onlyForPlayer){
continue;
}
_calcTwoEntities(listOfList[key][i], listOfList[key][j],true);
continue;
}
if(listOfList[key][i] is MapObstacle){
if(listOfList[key][j].parent != null && listOfList[key][j].parent is KyrgyzEnemy && listOfList[key][i].onlyForPlayer){
continue;
}
_calcTwoEntities(listOfList[key][j], listOfList[key][i],true);
continue;
}
_calcTwoEntities(listOfList[key][j], listOfList[key][i],false);
}
removeList.add(i);
}
}
for(DCollisionEntity entity in _activeCollEntity){
if(entity.obstacleIntersects.isNotEmpty){
entity.onCollisionStart(entity.obstacleIntersects, entity); ///obstacleIntersects есть только у ГроундХитбоксов, поэтому можно во втором аргументе передать лажу
entity.obstacleIntersects.clear();
}
}
}
}
//Активные entity ВСЕГДА ПРЯМОУГОЛЬНИКИ залупленные
void _calcTwoEntities(DCollisionEntity entity, DCollisionEntity other, bool isMapObstacle)
{
Set<int> insidePoints = {};
if(isMapObstacle) { //Если у вас все обекты столкновения с препятсвием с землёй квадратные, иначе делать что ближе от центра твоего тела
for (int i = 0; i < other.getVerticesCount(); i++) {
Vector2 otherFirst = other.getPoint(i);
if(otherFirst.x <= entity.getMaxVector().x
&& otherFirst.x >= entity.getMinVector().x
&& otherFirst.y <= entity.getPoint(1).y
&& otherFirst.y >= entity.getPoint(0).y){
insidePoints.add(i);
}
}
}
_finalInterCalc(entity, other,insidePoints,isMapObstacle);
}
void _finalInterCalc(DCollisionEntity entity, DCollisionEntity other,Set<int> insidePoints, bool isMapObstacle)
{
for (int i = -1; i < other.getVerticesCount() - 1; i++) {
if (!other.isLoop && i == -1) {
continue;
}
int tFirst, tSecond;
if (i == -1) {
tFirst = other.getVerticesCount() - 1;
} else {
tFirst = i;
}
tSecond = i + 1;
Vector2 otherFirst = other.getPoint(tFirst);
Vector2 otherSecond = other.getPoint(tSecond);
if (isMapObstacle) {
if(insidePoints.contains(tFirst) && insidePoints.contains(tSecond)) {
entity.obstacleIntersects.add((otherFirst + otherSecond) / 2);
continue;
}
List<Vector2> tempBorderLines = [];
for(int i= - 1; i<entity.getVerticesCount() - 1; i++){
if (!entity.isLoop && i == -1) {
continue;
}
int tF, tS;
if (i == -1) {
tF = entity.getVerticesCount() - 1;
} else {
tF = i;
}
tS = i + 1;
Vector2 point = f_pointOfIntersect(entity.getPoint(tF), entity.getPoint(tS)
, otherFirst, otherSecond);
if (point != Vector2.zero()) {
tempBorderLines.add(point);
}
}
if (tempBorderLines.length == 2) {
entity.obstacleIntersects.add((tempBorderLines[0] + tempBorderLines[1]) / 2);
}else if(tempBorderLines.length == 1){
Vector2 absVec;
if(insidePoints.contains(tFirst)){
absVec = tempBorderLines[0] + otherFirst;
}else{
absVec = tempBorderLines[0] + otherSecond;
}
absVec /= 2;
entity.obstacleIntersects.add(absVec);
}
} else {
for(int i=-1; i<entity.getVerticesCount() - 1; i++){
if (!entity.isLoop && i == -1) {
continue;
}
int tF, tS;
if (i == -1) {
tF = entity.getVerticesCount() - 1;
tS = i + 1;
}else{
tF = i;
tS = i + 1;
}
Vector2 tempPos = f_pointOfIntersect(entity.getPoint(tF), entity.getPoint(tS), otherFirst, otherSecond);
if(tempPos != Vector2.zero()){
if (entity.onComponentTypeCheck(other)) {
entity.onCollisionStart({otherFirst}, other);
}
if (other.onComponentTypeCheck(entity)) {
other.onCollisionStart({otherFirst}, entity);
}
return;
}
}
}
}
}
//return point of intersects of two lines from 4 points
При моих оптимизациях при 100 движущихся и ещё 20 статичных объектах FPS на телефоне — 30. Это, я думаю, предел оптимизаций, однако такое количество врагов вряд ли вообще нужно в игре:
Если кому-то поможет моя статья — буду рад.
Комментарии (36)
JordanCpp
16.12.2023 14:25Всё круто. Только у меня вопрос. Как тогда работали игры типа baldur gate, Arcanum, fallout, на железе на порядок слабее? Оверхед на старте от используемых фреймворков и инструментов? Я замечу, что многие старые игры не использовали ускорение 3d. К примеру fallout tactics идёт комфортно на pentium 233 mhz, Arcanum 400 mhz, disciples 2 300 mhz. Это всё при рисовании картинки на ЦПУ.
Я понимаю, что увеличилась цветность, разрешение экрана. Но все остальное осталось прежним, вывод спрайтов, анимации. На железе котрое просто не сравнимо с аналогичным 2000-ых годов.
Zara6502
16.12.2023 14:25ну как минимум никакого PNG
JordanCpp
16.12.2023 14:25Неужто в движке Flame используется настолько неэффективно gpu, для простой 2D графики?
ASGAlex
16.12.2023 14:25GPU пофигу, он на расслабоне большую часть времени, даже если у вас FPS низкий.
Тут надо вообще начинать с того, что Flutter считает за FPS. Если у вас процессор не успевает обсчитать всю логику за 16 милисекунд, то команды на графический процессор будут формироваться дольше и позже, и GPU очень-очень быстро отрисует вам кадр, но команду на отрисовку он получит позже, чем должен.
Конкретно у Flame, по моим ощущениям, проблемы в том, что:
Разработчики движка и игр на движке как-то в основном мыслят играми уровня "три в ряд", по моим ощущениям. Мы сделали игру, где скачет пять шариков и один квадратик - ура, мы счастливы, это успех, наш движок супер! Это не даёт команде смотреть на некоторые проблемы под другим углом
Как следствие, некоторые архитектурные решения не оптимальны, и в "нагруженных" сценариях приводят к драматическому уменьшению FPS. Но об этом мало кто знает, потому что "мы делаем три в ряд, у нас всё хорошо, Flame - зрелый движок для разработки игр"
На текущий момент цель команды - это стабилизировать имеющуюся ветку разработки, так что на какие-то радикальные архитектурные изменения они не готовы, максимум - вставлять костыли, чтобы предоставить workaround для самостоятельного фикса проблемы. Поэтому ожидать каких-то радикальных улучшений в плане скорости сейчас не стоит.
При этом ребята жестко озабочены следованием какого-то архитектурного плана, которого надо придерживаться, так что даже предложив рабочее решение, которое решает проблему и даёт новые фичи - есть риск получить отказ, потому что "эта архитектура недостаточно хорошо, это решение предлагает пользователю делать игры не так, как их дОлжно делать" и т.п. С этим приходится мириться.
Часть модулей там просто никто не поддерживает, команда маленькая и рук не хватает. Тот же модуль для работы с Tiled ни то жив ни то мёртв, в чатиках порой возникает какое-то движение, какие-то фиксы мелкие, но это всё и близко не напоминает какой-то мощный прогресс.
Flame в основном состоит из "максимально общих решений". Взять те же столкновения объектов. В множестве игр будет достаточно расчитывать столкновения между квадратами и прямоугольниками, а то и просто кругами, но даже такие фигуры Flame обратабывает как абстрактные многоугольники - более общий и более дорогой алгоритм, но зато точно везде будет работать. Аналогично с общим игровым циклом - в процессе там происходит много всякого, что можно бы вообще отключить до возникновения какой-то особой ситуации (например, ресайз окна), но по-умолчанию Flame выполняет этот код на каждом тике - "ну а мало ли чего?" - вот возможности процессора и улетают в трубу...
Вот... и если бы перед разработчиками стояла задача написать конкретную игру с конкретной мезханикой - всё было бы гораздо проще и оптимизированнее, чем при подходе "мы пишем архитектурно и идеологически правильный движок, обязательно делаем ревью каждого мерджреквеста, обязательно пишем документацию и автотесты... и да. нас всего 5 человек, из них трое большую часть времени заняты на основной работе" =)
JordanCpp
16.12.2023 14:25Спасибо за ответы. Стало более понятно, почему так происходит. Даже современное железо, не сможет вытянуть не оптимизированный код. Оптимизация forever!
ASGAlex
16.12.2023 14:25Угу... Я тут недавно писал о том, что даже forEach можно заставить в определённых условиях работать в 15 раз быстрее... Если найти в системе штук 10 таких мест и все затюнить - тоже получится прирост. В общем, каждая кроха может внести свой вклад.
Но языки высокого уровня как-то приучают людей решать проблемы "грубыми мазками", часто отдавая приоритет красивым интерфейсам и синтаксическому сахару, когда во главе угла стоять должно совсем другое. Но более быстрый код порою хрен прочтёшь, даже если это ты сам писал всего лишь вчера :-)
JordanCpp
16.12.2023 14:25Не сказал бы, что нужно писать замудренный код. Просто воспользоваться железом эффективно, не рисовать того, что не попадает на экран. Батчинг геометрии, а не по тайлу выводить, упаковать картинки тайлов на лету в одну большую и использовать меньше переключений текстур. И хорошо бы, что бы это было под капотом движка.
Это не сверх технологии.
ASGAlex
16.12.2023 14:25Ну я ж не говорю, что он специально получается запутанный, просто появляется куча дополнительных каких-то телодвижений, нужность которых с первого взгляда не очевидна, особенно для нового человека, который впервые зашел на проект и не вполне понимает, какая проблема этим куском решается, и что вообще такая проблема есть. Вот и кажется при первом взгляде "о боже, что за макаронный монстр, я бы проще сделал!"...
yarston
16.12.2023 14:25Когда язык для каждого класса типа Point(x, y) выделяет место на куче, в каждом экземпляре класса хранит кучу служебных данных, а в любом контейнере вместо собственно точек - ссылки на кучу, а потом ещё долго и упорно чистит образовавшийся мусор, сложновато сделать что-то сравнимое с олдскульными движками по эффективности. Сейчас наверное самое перспективное выносить физику в вычислительные шейдеры - это и быстро и кроссплатформенно.
Zara6502
16.12.2023 14:25а минус за что? вы попробуйте в любой из указанных игр спрайты сделать в PNG и посмотрите как у вас всё встанет колом.
drakkonne Автор
16.12.2023 14:25Какие ещё варианты с поддержкой прозрачности? Я знаю только gif, но не смотрел различия в весе файлов
Onito
16.12.2023 14:25Вообще юзать пнг по крайней мере в релизной версии это зашквар. В мобилках так и подавно. Если у вас 20мбат пнгешек то это несколько сотен метров в raw формате которые очень больно обрабатываются мобильными ГПУ. Вам нужно смотреть в сторону сжатых форматов типа astc. Так же могу порекомендовать почитать про ktx.
Onito
16.12.2023 14:25Правда я уверен что этот движок ничего не знает ни про какие сжатые форматы и это так и будет без толку занимать сотни метров памяти и насиловать не топовые ГПУ.
P. S. Ktx vulkan нужен конечно же.
drakkonne Автор
16.12.2023 14:25Благодарю, очень интересно будет посмотреть. В первый раз слышу про такие форматы
drakkonne Автор
16.12.2023 14:25Понял. Но это вес всех пнг, а так на экране только девять маленьких картинок суммарным разрешением 1024 на 760. Так что я думаю можно пренебречь здесь уже оптимизацией. Да и у Flame нет никаких трудностей в отображении ПНГ на игровом поле от слова совсем.
viruseg
16.12.2023 14:25Просто к сведенью. Видеокарта не работает с png от слова совсем. Браузер каждую png распаковывает в битмап. 1024x760 RGBA8 UNorm это 3.0 MB. На некоторых мобильных устройствах размер текстуры будет изменён до квадрата двойки по большей стороне. Т.е. станет 1024x1024 4.0 MB. А сжатая текстура будет весить 0.7 MB в ETC2 и отправляется в память видеокарты как есть. Но сжатые текстуры придётся делать в разных форматах под разные платформы. В идеале этим должен заниматься движок на котором делается игра.
ASGAlex
16.12.2023 14:25Ну тут нет каких-то особых способов повлиять.
Можно либо полезть в исходники Skia и посмотреть, что же она делает с картинками под капотом, получить некое знание, и жить с ним дальше, т.к. повлиять на этот процесс всё равно не получится на уровне Dart. Мы просто сможем сказать "да, всё плохо" или "да забейте, всё хорошо".
Либо не разрабатывать свой продукт на инструментах, не дающих такого детального контроля, раз для продукта так критично даже то, в каком формате файлы улетают.
На уровне языка Dart есть только разделение на растризованную картинку и объект с записанными командами отрисовки. Всё. Не важно, грузим мы в Flutter jpg, png, bitmap или поток байтов со звуковой карты :-) В конечном итоге он пойдёт на видеокарту либо в одном либо в другом формате, и на то, что у этих форматов под капотом мы никак воздействовать не можем.
Zara6502
16.12.2023 14:25Ну тут нужно ресурсы Baldur's Gate изучать, я не в курсе какой формат они использовали, но точно не PNG. Вообще редко в играх был какой-то распространенный формат графики, все пытались оптимизировать данные как по весу, так и по скорости. Иногда мелькали PCX, TGA, а GIF слишком тяжёлый для использования в играх.
ASGAlex
16.12.2023 14:25Привет! Спасибо за упоминание моей статьи)) Однако, хочу заметить, что она относительно старая, и есть более свежие наработки, в которых решена проблема бесконечной динамически подгружающейся карты, вопрос столкновения с неограниченным числом статических объектов + отдельно был сделан тюнинг столкновения между динамическими объектами.
Вот эта статья, а вот демка, которая даже в мобильном браузере работает на приличных FPS, можно по-зумить, увидеть как динамически подгружаются рядомстоящие карты. а все пространство без карт заполняется динамически сгенерированным контентом.
Вот вам и ответ на вопрос, "как тогда работали игры типа baldur gate, Arcanum, fallout, на железе на порядок слабее" - алгоритмы!
Я пока стараюсь сильно об этой либе не кричать, потому что документацию ещё не описал нормально, хотя что-то уже есть + тестирую её в прикладных условиях и по ходу вношу некоторые изменения... некоторые. правда, приходится контрибьютить непосредственно в Flame, что несколько дольше.
В общем, если заморочиться оптимизацией и подобрать алгоритмы, то даже на одном потоке можно иметь в игре 500к объектов и более - и всё будет работать шустро даже в условиях браузера и JS.
Но я бы не сказал, что переписывать Broadphase было легко. Я полтора года назад начал этим развлекаться, и ещё , как видите, в процессе.... Так что со своей стороны все-таки не рекомендую начинать этот путь с нуля, если у вас есть лимиты во времени, сроках, ресурсах и т.п.
drakkonne Автор
16.12.2023 14:25Да, но даже с тюнингом нет вообще понятия столкновение с землёй и какая-то возможность работать с этим. Нет сложения этих сил на тело, нельзя создать любое тело для столкновения и так далее. Всё это не позволяет сделать более менее приятно ощущаемую игру. Надеюсь, что допишите движок. В плане отображения на экране и классов, которые с этим связаны - использовать Flame ну очень приятно. Да и я считаю это основным в игровых движках - именно картинка. Логику ты можешь с нуля сделать под себя и пользоваться
ASGAlex
16.12.2023 14:25Ну я про физику сейчас вообще не думаю: если система неудовлетворительно справляется с задачей сказать, перекрываются ли объекты А и Б, а также испытывает трудности с их отображением в достаточном количестве - то о какой тут физике вообще можно говорить)) Хотя у них же есть физический движок forge2D, но мне даже не интересно смотреть, честно, что там по производительности, после того что я увидел в базовом collision detection, обработке касаний с жестами и в рейтрейсинге...
viruseg
16.12.2023 14:25Ваша демка, что на i7-7700, что на Snapdragon 835 при попытке зума уходит в загрузку на 30 секунд. Я, конечно, не эксперт, но с такими таймингами тут оптимизацией даже не пахнет. И там, и там Chrome последней версии.
ASGAlex
16.12.2023 14:25Ну если резко отзумить на максималку - то это всё равно что попытаться в Morrowind открыть сразу всю карту и жаловаться, что она долго прогружается. А мне же надо продемонстрировать как-то чисто техническую возможность отзумить ооооочень далеко.
В рамках конкретной игровой механики должно стоять своё ограничение на максимальный зум, в зависимости от целесообразности, а при слишком больших "отзумах" вообще нужно показывать упрощённую карту с меньшей детализацией, но это отдельная и пока не реализованная история.
ASGAlex
16.12.2023 14:25А, да, и в каждом квадратике с кирпичами - по 400 объектов, для которых теперь проигрывается анимация и считаются столкновения + потенциальная игровая логика. Итого, сейчас на экране чуть менее чем 30 * 15 * 400 = 18 0000 объектов, котрые все пришлось прогрузить и активировать, а за экраном ещё такая же куча объектов, которые предзагружены, но не рендерятся, а при сдвиге в сторону к ним докидывается ещё приличная пачка...
В общем да, без проработки механизма различных уровней детализации на больших отзумах ничего делать будет невозможно. Но без оптимизаций вообще вы и свои 3 FPS на таких зумах не получите. Тут же не просто тупо картинка, её зумить как раз нет никаких проблем.
JordanCpp
16.12.2023 14:25Плюс к этому она содержит 14 слоёв тайлов и 4 слоя объектов. И вот тут начинаются проблемы с отображением этого всего.
Для чего такое количество слоёв. Какую задачу это решает?
ASGAlex
16.12.2023 14:25Такой слоёный пирог нужен, чтобы сделать иерархию объектов по глубине, считайте это осью Z из 3D графики.
drakkonne Автор
16.12.2023 14:25Во-первых - в ран тайме компилировать картинку я не вижу смысла - так как у меня карта не меняется. Во-вторых - если карта рандомная - можно заранее сделать различные подложки и прочее, а лабиринт загружать уже походу - это не затратно. Поэтому в итоге Вы всё равно будете рисовать карту, чтобы это было Вам удобно, потому что она никак не повлияет ФПС в игре, потому что превратится просто в картинку. А почему так много слоёв получается - на одном слое земля, на один слой выше - грязь, на один слой выше ведро, на один слой выше - ствол дерева, на один слой выше - гриб для красоты. Ещё слой выше - листья, которые анимированно падают с дерева. Уже 6 слоёв. Допустим - это всё на горе, т.е. все остальные слои должны быть ниже этих. Уже 12 слоёв. Добавляем ещё слои для функции автокарты и в итоге получаем примерно такое же количество) Иначе невозможно сделать интересную картинку
JordanCpp
16.12.2023 14:25Сколько слоев в Arcanum, не знаете? Картинка довольно интересная. И автокарта имеется, деревья, камни.
JordanCpp
16.12.2023 14:25В fallout tactics вообще 3 уровня тайлов, под землёй, на земле и на крыше. И так же есть и анимация и полно анимированных тайлов. И системные требования просто смешные.
Возможно сам подход к разработке и излишнее усложнение реализации, диктует повышение требований к железу?
Я просто не могу отделаться от впечатления прошлых подобных игр и их графики, в сравнении с графикой в данной статье.
drakkonne Автор
16.12.2023 14:25Нет, понятное дело. Фаллаут тактикс не написан на flutter) Всё дело в этом. На компьютере даже когда просто заполняю всё неудаляемыми объектами для дебага у меня 80 фпс. На телефоне это будет уже около 15-ти. У меня сейчас тоже всё прекрасно работает с любым количеством слоёв. Просто в самом начале так проще рисовать. И вообще так рисовать именно проще. А что касается красоты - это купленные ассеты, поскольку я СОВСЕМ не художник, я сугубо программист. Поэтому я не могу нарисовать классный пейзаж морской и впихнуть его в игру. Приходится лепить просто из кубиков. А из кубиков такого вот механизма как в аркануме в пустыне ты не сделаешь)
Да и требования - я все тесты делаю помимо своего Самсунга А32 на Xiaomi A1 семилетней давности - и там тоже всё супер быстро работает. Так что требования у меня тоже сейчас смешные) Не говоря про любой ПК, даже супер старый.
Была бы игра для компьютера - я бы делал её на с++, естественно)
ASGAlex
16.12.2023 14:25Тут уж надо проводить деконструкцию каждой конкретной игры... Я в своих поделках, в целом, тем же Арканумом и Фолычем вдохновляюсь, но хочется сделать при этом какую-то реюзабельную архитектуру, потому что сама игра, может ,и не будет иметь столько ценности, сколько возможность собрать на ее движке ещё что-нибудь.
JordanCpp
16.12.2023 14:25Где нарушилась преемственность в программировании, когда то, что рисовалось быстро, теперь требует каких то серьёзных вычислений, причём с той же самой картинкой.
ASGAlex
16.12.2023 14:25Блин, у нас тут Ворд и Ексель в гуглодоках на жабасурипте в браузере требуют 16 гигов оперухи и новый i7, когда всё то же самое работало у меня на пентиум 1 и win 95. Я с каждой новостью о выходе нового процессора всё больше содрагаюсь, что софт теперь станет ещё более херовым, и придется свой комп обновлять просто чтобы работало то, что работало и раньше.
«Нужно бежать со всех ног, чтобы только оставаться на месте, а чтобы куда-то попасть, надо бежать как минимум вдвое быстрее!»
BalabaIgor
Очень познавательно!