Предисловие
Я получил тестовое задание от Volka Games написать простую изометрическую игру. С учетом того что я ни разу даже не слышал о такой вещи как Haxe хотелось сразу отказать, но высокая вилка сделала свое дело.Сделав его за отведенный срок я получил отказ. Как всегда без внятной обратной связи. Поэтому хочу поделиться результатом своей работы.
Ссылка на проект со всем кодом будет в самом конце
Выбор графической библиотеки
Если с языком всё ясно и выбрать нельзя, то графики на него достаточно много. И здесь всё очень просто:
"Copilot, какие графические библиотеки есть для Haxe и в чем их отличие?"
Выбор почти сразу пал на OpenFL. Просто по причине того что он проще в освоении и ближе по API к OpenGL, который мне немного известен.
Часть 1. Input
Для передвижения камеры по сцене и ее масштабирования я написал класс CameraInput
// Скорость передвижения камеры (пиксель за кадр)
private var cameraSpeed:Float = Constants.TILE_WIDTH * 10;
// Степень масштабирования камеры
private var cameraZoomSpeed:Float = 0.05;
// Ограничение масштабирования (просто scale)
private var minScale:Float = 0.5;
private var maxScale:Float = 3;
// Время предыдущего кадра
// По сути нужен он только для получении времени прошедшего между кадрами
// для передвижения по сцене независимо от FPS
private var lastTime:Float;
// Особенность Haxe - "корень программы"
// Нужен для подписки на события и получения ширины/высота окна
private var stage:Stage;
// Главный контейнер, который будет двигаться для получения эффекта "Камеры"
private var container:DisplayObjectContainer;
Теперь простая обработка нажатий клавиш: подписываемся на нужные события и устанавливаем состояние нажатия клавиши в true/false:
private var keysDown:IntMap<Bool> = new IntMap<Bool>();
stage.addEventListener(KeyboardEvent.KEY_DOWN, onKeyDown);
stage.addEventListener(KeyboardEvent.KEY_UP, onKeyUp);
stage.addEventListener(Event.ENTER_FRAME, onEnterFrame);
// Клавиша нажата
private function onKeyDown(e:KeyboardEvent):Void
{
keysDown.set(e.keyCode, true);
}
// Клавишу отпустили
private function onKeyUp(e:KeyboardEvent):Void
{
keysDown.set(e.keyCode, false);
}
// Вызывается каждый кадр
private function onEnterFrame(e:Event):Void {
// Получаем время, прошедшее между кадрами
var now = haxe.Timer.stamp();
var deltaTime = now - lastTime;
lastTime = now;
var moved = false;
var moveStep = cameraSpeed * deltaTime;
// Если клавиша нажата, то сдвигаем всю сцену на заданную скорость
// с компенсацией лагов за счет deltaTime
if (keysDown.exists(Keyboard.A) && keysDown.get(Keyboard.A)) {
container.x += moveStep;
moved = true;
} else if (...) {
...
}
// Если камера была сдвинута, то надо сообщить всем желающим об этом
// В данном случае это необходимо для перерисовки сцены
if (moved) {
dispatchEvent(new OnCameraMovedEvent());
}
}
Достаточно просто? Теперь масштабирование!
stage.addEventListener(MouseEvent.MOUSE_WHEEL, onMouseWheel);
private function onMouseWheel(e:MouseEvent):Void {
var delta:Float = e.delta;
// В 2025 году браузеры все еще плохо работают с разным вводом, поэтому
// такой небольшой костыль, который приводит delta к разумным значениям
// в Chrome (тестировалось только на Firefox/Chrome/Standalone)
if (Math.abs(delta) > 10) delta = delta / 100;
// Вычисляем scale
var zoomChange:Float = delta * cameraZoomSpeed;
var newScale:Float = container.scaleX + zoomChange;
// ... и ограничиваем его
if (newScale < minScale) newScale = minScale;
else if (newScale > maxScale) newScale = maxScale;
// Далее надо отцентрировать камеру, чтобы масштабирование
// происходило в угол карты
// Получаем центр экрана
var stageCenterX = stage.stageWidth / 2;
var stageCenterY = stage.stageHeight / 2;
// Используем нативный метод и получим локальные координаты
// до масштабирования
var localBefore = container.globalToLocal(new openfl.geom.Point(stageCenterX, stageCenterY));
// Применяем scale
container.scaleX = newScale;
container.scaleY = newScale;
// Теперь получим уже получим координаты "после"
var localAfter = container.globalToLocal(new openfl.geom.Point(stageCenterX, stageCenterY));
// И сдвинем наш контейнер на разницу "до" и "после" - этого достаточно
container.x += (localAfter.x - localBefore.x) * newScale;
container.y += (localAfter.y - localBefore.y) * newScale;
// Как и раньше сообщим о то что камера сдвинулась для перерисовки сцены
dispatchEvent(new OnCameraMovedEvent());
}
Осталась последняя и самая интересная часть - клик! Здесь я бы хотел отслеживать по какому тайлу произошло нажатие для дальнейшей бизнес логики:
stage.addEventListener(MouseEvent.CLICK, onMouseClick);
private function onMouseClick(e:MouseEvent):Void {
var tileWidth = Constants.TILE_WIDTH;
var tileHeight = Constants.TILE_HEIGHT;
var screenX = e.stageX;
var screenY = e.stageY;
var local = container.globalToLocal(new Point(screenX, screenY));
var sx = local.x;
var sy = local.y;
// Выше ничего интересного - просто находим локальные координаты сцены
// и с помощью стандартного подхода к изометрии получаем координаты тайла
var x = Math.round((sy / (tileHeight / 2) + sx / (tileWidth / 2)) / 2);
var y = Math.round((sy / (tileHeight / 2) - sx / (tileWidth / 2)) / 2);
// И соответственно событие, куда ж без него
dispatchEvent(new OnClickEvent(x, y));
}
Изометрия и ее формулы (x; y)
Если бы это была не изометрия, а просто квадратная сетка, то формулы были бы вида:
tileX = x * tileWidth
tileY = y * tileHeight
Но для изометрии большинство формул сводится к x + y и x - y -
это достаточно стандартный подход для изометрических игр. Так для вычисления тайла в экранных координатах можно написать:
(tileX - tileY) * (tileWidth / 2); // x
(tileX + tileY) * (tileHeight / 2); // y
Исходя из таких формул, или даже подхода, высчитывается и клик по тайлу!
Часть 2. Сцена
Это, наверное, самая важная часть игры - отрисовать пол!
Задача: Отрисовать пол в изометрии, который будет ограничен размерами mapWidth/mapHeight
. Казалось бы что проще? Вот только есть нюанс - размер сетки 500x500! При попытке отрисовать единой фигурой приложение просто вылетает, а если рисовать потайлово, то это потребовало бы какого-то куллинга (показывать только то что на экране). В реальном проекте второй подход наверное был бы предпочтительным, т.к. навряд ли пол будет одной сплошной текстурой. Тут я бы рекомендовал обратить внимание на quad-tree.
Но в данном случае я решил проблему по другому: Достаточно отрисовать пол так, чтобы он вмещал в себя весь экран, и сдвигать его, ограничивания позицию при приближении к границам карты.
// Задаем задний фон
stage.align = StageAlign.TOP_LEFT;
stage.scaleMode = StageScaleMode.NO_SCALE;
stage.color = 0x0000FF;
// Вызывается каждый раз, когда меняется камера или изменяется размер окна
public function redraw():Void {
var scaleX = container.scaleX;
var scaleY = container.scaleY;
var w = stage.stageWidth;
var h = stage.stageHeight;
// Создаем пол один раз или при изменении размеров экрана
if (floor == null || w != lastStageWidth || h != lastStageHeight) {
lastStageWidth = w;
lastStageHeight = h;
// Удаляем старый объект
if (floor != null && floor.parent != null) {
floor.parent.removeChild(floor);
}
// Создаем новый
floor = new Shape();
// Получаем позиции тайлов из экранных координат
// без ограничения по размеру карты и без учета положения камеры
// Это нужно для вычисления размера пола
var lt = screenToTileUnclampedFromZero(0, 0);
var rt = screenToTileUnclampedFromZero(w, 0);
var rb = screenToTileUnclampedFromZero(w, h);
var lb = screenToTileUnclampedFromZero(0, h);
var i0 = lt.x;
var j0 = rt.y;
// +1 это небольшой запас
var i1 = rb.x + 1;
var j1 = lb.y + 1;
// Получаем координаты вершин "ромба" - изометрического пола
// Всё тоже по тому же принципу x-y и x+y
var p0 = { x: (i0 - j0) * (tileWidth / 2), y: (i0 + j0) * (tileHeight / 2)}; // top
var p1 = { x: (i1 - j0) * (tileWidth / 2), y: (i1 + j0) * (tileHeight / 2)}; // right
var p2 = { x: (i1 - j1) * (tileWidth / 2), y: (i1 + j1) * (tileHeight / 2)}; // down
var p3 = { x: (i0 - j1) * (tileWidth / 2), y: (i0 + j1) * (tileHeight / 2)}; // left
// Отрисовка
floor.graphics.clear();
floor.graphics.beginFill(0x00FF00);
floor.graphics.moveTo(p0.x, p0.y);
floor.graphics.lineTo(p1.x, p1.y);
floor.graphics.lineTo(p2.x, p2.y);
floor.graphics.lineTo(p3.x, p3.y);
floor.graphics.endFill();
// Добавляем в самый корневой элемент самым первым элементом
// для порядка отрисовки
stage.addChildAt(floor, 0);
}
// Получаем по стандартным правилам текущую видимую область в тайлах
// Т.е. с учетом сцены, положения камеры и масштабирования, clamp-ом
var viewport = getViewport();
// Тот же самый viewport, но неограниченный по (0; 0) и (mapWidth; mapHeight)
var lt = screenToTileUnclamped(0, 0);
var rt = screenToTileUnclamped(w, 0);
var rb = screenToTileUnclamped(w, h);
var lb = screenToTileUnclamped(0, h);
// Вычисляем кол-во отображаемых тайлов на экране
var dx = (rb.x - lt.x) / 2;
var dy = (lb.y - rt.y) / 2;
// Ограничиваем по X
var cx:Float;
if (viewport.maxX >= gridWidth) {
cx = viewport.maxX - dx - 1;
} else {
cx = viewport.minX + dx;
}
// Ограничиваем по Y
var cy:Float;
if (viewport.maxY >= gridHeight) {
cy = viewport.maxY - dy - 1;
} else {
cy = viewport.minY + dy;
}
// Задаем итоговую позицию (смещаем):
// Камера - половина экрана + центр тайла в экранных координатах * масштаб
// В зависимости от проекта здесь могут добавляться разные цифры как
// -tileHeight / 2 - в этом нет ничего такого
floor.x = container.x - w / 2 + MapUtils.getTileScreenX(cx, cy, tileWidth) * scaleX;
floor.y = container.y - h / 2 + (MapUtils.getTileScreenY(cx, cy, tileHeight) - tileHeight / 2) * scaleY;
// Вызываем событие перерисовки сцены (это необязательно)
// Я использую событие чтобы скрыть лишние объекты вне экрана
dispatchEvent(new OnSceneRedrawEvent(
viewport.minX,
viewport.maxX,
viewport.minY,
viewport.maxY));
}
// screenToTile и getViewport приведу в кратком виде:
function screenToTile(screenX:Float, screenY:Float) {
var local = scene.globalToLocal(new openfl.geom.Point(screenX, screenY));
var sx = local.x;
var sy = local.y;
var x = Math.floor((sy / (tileHeight / 2) + sx / (tileWidth / 2)) / 2)
// (optional) Clamp
x = Std.int(Math.max(0, Math.min(mapWidth - 1, i)));
...
}
function getViewport() {
var lt = screenToTile(0, 0, scaleX, scaleY);
...
return { minX: Std.int(Math.max(0, lt.x)), ... }
}
Часть 3. Создание объектов
В игре будет присутствовать два вида объектов и все одной высоты tileLength
:
Стены - объекты, у которых есть положение и размер в тайлах 1x1, 12x24 и т.д.
Курицы - объекты, которые всегда занимают один тайл и в отличие от стен располагаются по центру. Курицы могут "забираться" на стены и не блокируют обзор.
Игра предполагает сетку размером 500х500 и огромное кол-во объектов на ней. В связи с этим просто так создать объект нельзя - отрисовка каждого будет съедать весь FPS. Поэтому было решено использовать Tilemap и батчить текстуры, но обо всем по порядку.
OpenFL уже обладает Tilemap, но для того чтобы его использовать необходимо объединить все ассеты в один атлас - сделать из множества картинок одну большую. В этом случае они также будут отрисовываться за один вызов отрисовки. Определение вызова отрисовки и батчинг немного выходят за рамки статьи, но вкратце - это за n раз нарисовать как можно больше, чтобы не напрягать компьютер.
Объединить в атлас можно "руками" хоть в Paint. Но не хотелось бы так мучаться. Поэтому я написал код, который делает это на старте:
Все размещаемые объекты на сцене имеют формат 1x1.png и т.д. и 0x0 для куриц, где первая цифра - это размер в тайлах по X, а вторая - по Y (width и height).
function buildAtlasFromAssets(maxAtlasWidth:Int = 2048):Void {
// Находим все ассеты по маске
var files = Assets.list();
var pngs = files.filter(f -> ~/^assets\/(\d+)x(\d+)\.png$/.match(f));
// Загружаем все ассеты
var images = [];
for (file in pngs) {
var bmp = Assets.getBitmapData(file);
images.push({bmp: bmp, w: bmp.width, h: bmp.height, name: file});
}
// Сортируем по высоте (необязательно)
images.sort((a, b) -> b.h - a.h);
// Размещаем построчно спрайты в атласе
// Совет: Лучше не первышать размеры 2048x2048
// А если такая потребность появится, то сделать несколько атласов
var positions = [];
var x = 0;
var y = 0;
var rowHeight = 0;
var atlasWidth = 0;
var atlasHeight = 0;
for (img in images) {
if (x + img.w > maxAtlasWidth) {
// Следующая строка
x = 0;
y += rowHeight;
rowHeight = 0;
}
positions.push({x: x, y: y, img: img});
if (x + img.w > atlasWidth) atlasWidth = x + img.w;
if (y + img.h > atlasHeight) atlasHeight = y + img.h;
if (img.h > rowHeight) rowHeight = img.h;
x += img.w;
}
// Создаем атлас и Tileset (OpenFL)
var atlas = new BitmapData(atlasWidth, atlasHeight, true, 0x00000000);
var tileset = new Tileset(atlas);
// Копируем картинки в вычисленные позиции и
// запоминаем название спрайты -> его index
for (pos in positions) {
atlas.copyPixels(pos.img.bmp, pos.img.bmp.rect, new openfl.geom.Point(pos.x, pos.y));
var rect = new openfl.geom.Rectangle(pos.x, pos.y, pos.img.w, pos.img.h);
var re = ~/(\d+)x(\d+)/;
if (re.match(pos.img.name)) {
var key = re.matched(0);
var idx = tileset.addRect(rect);
tileIndexMap.set(key, idx);
}
}
// Далее запоминаем все сделанное и создаем Tilemap
// нужного размера
this.tileset = tileset;
var mw = Math.ceil((mapWidth + mapHeight) * (tileWidth / 2));
var mh = Math.ceil((mapWidth + mapHeight) * (tileHeight / 2));
this.tilemap = new Tilemap(mw, mh, tileset);
// Небольшой сдвиг чтобы карта была по центру
this.tilemap.x = (mw + tileWidth) / -2;
// И немного ниже, чтобы тайлы не обрезались
// все таки Tilemap - это квадрат и разместив объект в позиции (0; 0)
// он вылезет за пределы этого квадрата. Поэтому отнимаем макс. высоту тайла
this.tilemap.y = -tileHeight / 2 - tileLength;
this.parent.addChild(tilemap);
}
Далее создадим объекты. Создание Стен и Куриц не сильно отличаются:
public function createObject(tileX:Int, tileY:Int, width:Int, height:Int):GameObject {
// Ищем тайл в ранее созданном Tileset (атласе)
var key = width + "x" + height;
var tileIdx = tileIndexMap.get(key);
if (tileIdx == null) {
trace("Tile not registered for " + key);
return null;
}
// Получаем размеры спрайта
var rect = tileset.getRect(tileIdx);
// width/height - размер в тайлах
// Получаем позицию объекта в экранных координатах
// isoToScreen - это все те же (tileX - tileY) * (tileWidth / 2)
var baseI = tileX + width - 1;
var baseJ = tileY + height - 1;
var basePos = isoToScreen(baseI, baseJ, tileWidth, tileHeight);
// Вычисляем сдвиг, чтобы объект располагался по тайлам и по центру
// Позиция объекта - это его минимальная (tileX; tileY)
// также чуть поднимаем по высоте +tileLength/2
var offsetX = basePos.x - rect.width / 2 + tileWidth / 2;
var offsetY = basePos.y - rect.height / 2 + tileHeight / 2 + tileLength / 2;
// Из-за того что ранее сдвинули карту, то и объект сдвинем соответственно
var mapWidth = Math.ceil((mapWidth + mapHeight) * (tileWidth / 2));
offsetX += mapWidth / 2;
// Финальные расчеты сдвига
var ox = ((width - height) / 2) * (tileWidth / 2);
var oy = ((width + height) / 2 - 1) * (tileHeight / 2);
offsetX -= ox;
offsetY -= oy;
// Создаем, добавляем тайл и возвращаем модель нашего объекта
var tile = new Tile(tileIdx, offsetX, offsetY);
tilemap.addTile(tile);
return new GameObject(tile, tileX, tileY, width, height);
}
Курицы создаются аналогичным образом, но с особенностями размера и позиционирования:
public function createPointObject(tileX:Int, tileY:Int, isUp:Bool):GameObject {
var key = "0x0";
var tileIdx = tileIndexMap.get(key);
if (tileIdx == null) {
trace("Tile not registered for " + key);
return null;
}
var rect = tileset.getRect(tileIdx);
var baseI = tileX;
var baseJ = tileY;
var basePos = isoToScreen(baseI, baseJ, tileWidth, tileHeight);
var offsetX = basePos.x - rect.width / 2 + tileWidth / 2;
var offsetY = basePos.y;
var mapWidth = Math.ceil((mapWidth + mapHeight) * (tileWidth / 2));
offsetX += mapWidth / 2;
// Главное отличие! При создании курицы она может быть на стене
// Т.е. ее положение это высота тайла (хорошо что высота одинакова для всего)
if (isUp)
offsetY -= tileLength;
var tile = new Tile(tileIdx, offsetX, offsetY);
tilemap.addTile(tile);
return new GameObject(tile, tileX, tileY, 1, 1);
}
Часть 4. Изометрическая сортировка
Одна из самых неприятных частей. Я не буду вдаваться в подробности сортировки тайлов в изометрии - про это уже много есть статей (правда на английском).
Допустим, у нас есть список всех объектов, которые надо создать, тогда алгоритм будет следующий:
Найти AABB всех объектов;
Отфильтровать Стены от Куриц;
Выполнить топологическую сортировку;
Создать объекты (
GameObject
);
Топологическая сортировка в двух словах на простом - это когда мы перебираем абсолютно все объекты и сравниваем друг с другом. В обычной сортировки не каждый объект сравнивается с другим.
Реализация метода для рассчета AAB:
public function worldSpace(tileX:Int, tileY:Int, width:Int, height:Int) {
// Задаем курицам размер 1x1, чтобы общий алгоритм работал и для них
if (width == 0) width = 1;
if (height == 0) height = 1;
return {
xMin: tileX * tileWidth,
yMin: tileY * tileHeight,
zMin: 0,
xMax: tileX * tileWidth + width * tileWidth,
yMax: tileY * tileHeight + height * tileHeight,
zMax: tileLength
};
}
Для того, чтобы хранить данные о сортировке я выделил их в отдельный класс:
class SortData {
// Координаты в тайлах
public var x:Int;
public var y:Int;
// Размер в тайлах
public var w:Int;
public var h:Int;
// Флаг курица это или нет
public var isPointObject:Bool;
// Стоит ли курица на стене или нет
public var isUp:Bool;
// Если курица стоит, то на ком
public var parentObject:TileInfo;
// AABB
public var minX:Float;
public var maxX:Float;
public var minY:Float;
public var maxY:Float;
public var minZ:Float;
public var maxZ:Float;
// Флаг, нужный для топологической сортировки
public var isoVisitedFlag:Int;
// Тайлы, которые должны отображаться позади
public var tilesBehind:Array<TileInfo>;
// Результат топологической сортировки
public var isoDepth:Int;
public function new() {
}
}
Сам сортировщик с комментариями:
class IsoSorter {
private var _sortDepth:Int;
public function sortTiles(gameObjectTiles:Array<TileInfo>) {
// Сравниваем все объекты друг с другом
// и отмечаем куриц
var pObjects = new Array<TileInfo>();
for (i in 0...gameObjectTiles.length) {
var a = gameObjectTiles[i];
var behindIndex = 0;
for (j in 0...gameObjectTiles.length) {
// С собой не сравниваем
if (i == j) {
continue;
}
var b = gameObjectTiles[j];
// TODO: На самом деле isBehind и intersects можно объединить
// Проверяем находится ли объект позади
var isBehind = isBehind(a, b);
// Проверяем пересечение объектов
var isIntersect = intersects(a, b);
// Если объект пересекаются и это курица, то запоминаем стену
if (a.sortData.isPointObject && !b.sortData.isPointObject && isIntersect) {
a.sortData.isUp = true;
a.sortData.parentObject = b;
pObjects.push(a);
}
// Иначе запоминаем что тайл b позади a
else if (isBehind && (!b.sortData.isPointObject || !isIntersect)) {
a.sortData.tilesBehind[behindIndex++] = b;
}
}
// Сбрасываем флаг посещения a для дальнейшей сортировки
a.sortData.isoVisitedFlag = 0;
}
// Заготавливаем куриц
for (a in pObjects) {
// Для корректных вычислений приподнимаем куриц
// Здесь бы стоило написать tileLength / tileHeight,
// но я забыл
a.x -= 4;
a.y -= 4;
// Стена, на которой стоит курица, всегда находится позади
if (a.sortData.parentObject != null) {
a.sortData.tilesBehind.push(a.sortData.parentObject);
}
for (b in gameObjectTiles) {
// С другими курицами не сортируем!
// Курица может быть в курице
// Да, мне нравится слово курица
if (b.sortData.isPointObject) {
continue;
}
if (isBehind(a, b)) {
a.sortData.tilesBehind.push(b);
}
}
// Возвращаем курицу на место
a.x += 4;
a.y += 4;
}
// Посещаем каждый тайл и ищем индекс для сортировки
_sortDepth = 0;
for (i in 0...gameObjectTiles.length) {
visitNode(gameObjectTiles[i]);
}
// Sort by depth
gameObjectTiles.sort(function(a, b) {
return a.sortData.isoDepth - b.sortData.isoDepth;
});
}
function intersects(as:TileInfo, bs:TileInfo):Bool {
var a = as.sortData;
var b = bs.sortData;
return a.x < b.x + b.w &&
a.x + a.w > b.x &&
a.y < b.y + b.h &&
a.y + a.h > b.y;
}
// По хорошему убрать бы здесь рекурсию
private function visitNode(tile:TileInfo):Void {
// Посещаем все тайлы и присваиваем isoDepth
// Те тайлы что позади посещаются первыми
// таким образом гарантируется их порядок
var n = tile.sortData;
if (n.isoVisitedFlag == 0) {
n.isoVisitedFlag = 1;
var behindLength:Int = n.tilesBehind.length;
for (i in 0...behindLength) {
if (n.tilesBehind[i] == null) {
break;
} else {
visitNode(n.tilesBehind[i]);
n.tilesBehind[i] = null;
}
}
n.isoDepth = _sortDepth++;
}
}
private function isBehind(as:TileInfo, bs:TileInfo):Bool {
// Это оказалось одной из самых сложных частей
// Проверяем какой тайл позади благодаря ряду условий
var a = as.sortData;
var b = bs.sortData;
return
(
b.maxX <= a.minX &&
b.maxY > a.minY && b.minY < a.maxY &&
b.maxZ > a.minZ && b.minZ < a.maxZ
) ||
(
b.maxY <= a.minY &&
b.maxX > a.minX && b.minX < a.maxX &&
b.maxZ > a.minZ && b.minZ < a.maxZ
) ||
(
b.maxZ <= a.minZ &&
b.maxX > a.minX && b.minX < a.maxX &&
b.maxY > a.minY && b.minY < a.maxY
) ||
(
b.maxX <= a.maxX &&
b.maxY <= a.maxY
);
}
}
Всё! Сортировка завершена - осталось создать объекты:
for (i in 0...gameObjectTiles.length) {
var tileInfo = gameObjectTiles[i];
var sortData = tileInfo.sortData;
// Создаем курицу
if (sortData.isPointObject) {
var go = objectFactory.createPointObject(sortData.x, sortData.y, sortData.isUp);
if (go == null) {
continue;
}
// Для будущих механик я решил запомнить куриц, стоящих на стене
if (sortData.parentObject != null) {
sortData.parentObject.addPointObject(go);
} else {
// И куриц, которые находятся на земле
// Тут стоит обратить внимание, что несколько куриц могут быть на одном тайле
var list = unparentPointObjects[go.y][go.x];
if (list == null) {
list = [];
unparentPointObjects[go.y][go.x] = list;
}
list.push(go);
}
// С помощью viewport из MainScene я отключаю
// объекты, находящиеся вне экрана при перерисовки сцены
renderObjects.push(go);
continue;
}
// Создаем стену
var instance = objectFactory.createObject(sortData.x, sortData.y, sortData.w, sortData.h);
if (instance == null) {
continue;
}
renderObjects.push(instance);
// Сохраняем в глобальную карту типа Array<Array<GameObject>>
// где ее размеры это mapWidth/mapHeight
if (!sortData.isPointObject) {
for (h in 0...instance.height) {
var y = instance.y + h;
for (w in 0...instance.width) {
var x = instance.x + w;
map[y][x].go = instance;
}
}
}
}
Часть 5. Зона видимости (туман войны)
К изначальному заданию прилагался алгоритм зоны видимости. Так что это довольно простая часть (хотя проверить ее достаточно проблемно).
visibleX и visibleY - позиция, где находится "игрок" - всегда видимая точка
В игре также присутствует зона блокировки - эти просто набор тайлов, заданый вручную, которые блокируют обзор.
// Основной метод, который вычисляет
public function calculateVisibility(map:Array<Array<TileInfo>>):Void {
// Игрок всегда видит себя
map[visibleY][visibleX].visibility = TileVisibility.VISIBLE;
// Выполняем поиск в ширину начиная с исходной клетки
var queue = [{x: visibleX, y: visibleY}];
while (queue.length > 0) {
var current = queue.shift();
// Рассматриваем соседние клетки
for (n in getNeighbors(current.x, current.y)) {
var nTile = map[n.y][n.x];
if (n.x < 0 || n.x >= mapWidth || n.y < 0 || n.y >= mapHeight) continue;
if (nTile.visibility != TileVisibility.INVISIBLE) continue;
var visibility = calculateTileVisibility(nTile, map);
nTile.visibility = visibility;
// Добавляем в очередь видимый или полувидимые клетки
if (visibility == TileVisibility.VISIBLE || visibility == TileVisibility.SEMIVISIBLE) {
queue.push({x: n.x, y: n.y});
}
}
}
}
// Просто 4 клетки по бокам. Диагональные не учитывем.
private function getNeighbors(x:Int, y:Int):Array<{x:Int, y:Int}> {
var neighbors = [
{x: x - 1, y: y},
{x: x + 1, y: y},
{x: x, y: y - 1},
{x: x, y: y + 1}
];
return neighbors.filter(function(n) {
return n.x >= 0 && n.x < mapWidth && n.y >= 0 && n.y < mapHeight;
});
}
public function calculateTileVisibility(tile:TileInfo, map:Array<Array<TileInfo>>):TileVisibility {
var x = tile.x;
var y = tile.y;
// Игрок всегда виден
if (x == visibleX && y == visibleY) {
return TileVisibility.VISIBLE;
}
// 1. В зоне блокировки
if (tile.isLocked) {
return TileVisibility.BLOCKED;
}
var go = tile.go;
// 2. Касается занятого заблокированного тайла и сам занят тем же объектом – заблокирован
for (n in getNeighbors(x, y)) {
var nTile = map[n.y][n.x];
if (!nTile.hasVisibility || nTile.visibility != TileVisibility.BLOCKED) {
continue;
}
if (nTile.go != null && nTile.go == go) {
return TileVisibility.BLOCKED;
}
}
// 3. Касается свободного видимого тайла – видимый
for (n in getNeighbors(x, y)) {
var nTile = map[n.y][n.x];
if (!nTile.hasVisibility || nTile.visibility != TileVisibility.VISIBLE) {
continue;
}
if (nTile.go == null) {
return TileVisibility.VISIBLE;
}
}
// 4. Касается занятого видимого тайла и сам занят тем же объектом – видимый
for (n in getNeighbors(x, y)) {
var nTile = map[n.y][n.x];
if (!nTile.hasVisibility || nTile.visibility != TileVisibility.VISIBLE) {
continue;
}
if (nTile.go != null && nTile.go == go) {
return TileVisibility.VISIBLE;
}
}
// 5. Касается занятого видимого тайла – полувидимый
for (n in getNeighbors(x, y)) {
var nTile = map[n.y][n.x];
if (!nTile.hasVisibility || nTile.visibility != TileVisibility.VISIBLE) {
continue;
}
if (nTile.go != null) {
return TileVisibility.SEMIVISIBLE;
}
}
// 6. Касается занятого полувидимого тайла и сам занят тем же объектом – полувидимы
for (n in getNeighbors(x, y)) {
var nTile = map[n.y][n.x];
if (!nTile.hasVisibility || nTile.visibility != TileVisibility.SEMIVISIBLE) {
continue;
}
if (nTile.go != null && nTile.go == go) {
return TileVisibility.SEMIVISIBLE;
}
}
// 7. Тайл невидимый
return TileVisibility.INVISIBLE;
}
Здесь особо нечего рассказывать - есть последовательный алгоритм и его надо просто реализовать.
Часть 6. Интерактив
Интерактивным действием будет клик по стене или курице, который приводит к их уничтожению. При этом если кликнуть по стене, то и все курицы на ней уничтожаются
Честно признаюсь - здесь можно было написать лучше, но уже было лень.
private function onClick(event:OnClickEvent):Void {
// Находим объект, по которому кликнули
var n = Math.round(Constants.TILE_LENGTH / Constants.TILE_HEIGHT);
var target = findGameObjectForClick(event.tileX, event.tileY);
if (target == null || target.go == null) {
return;
}
// Если у объекта есть родитель, то это курица на стене
// Уничтожаем курицу и удаляем связь стены с ней
// Также удаляем из очереди на рендер - больше нет курицы!
if (target.parent != null) {
objectFactory.destroyObject(target.go);
target.parent.removePointObject(target.go);
renderObjects.remove(target.go);
return;
}
// Уничтожаем стену или курицу на полу
destroyObject(target.go);
}
// Уничтожение стены и одиноких куриц
private function destroyObject(go:GameObject) {
if (go == null) {
return;
}
// Уничтожаем первую курицу на полу (их может быть несколько)
var pointerObjectList = unparentPointObjects[go.y][go.x];
if (pointerObjectList != null && pointerObjectList.length > 0) {
var go = pointerObjectList[pointerObjectList.length - 1];
renderObjects.remove(go);
objectFactory.destroyObject(go);
pointerObjectList.remove(go);
// Обновляем видимость на карте если требуется по логике игры
// updateVisibility();
return;
}
// Удаляем стену
// Обращаю внимание, что всю информацию можно получить из главного
// тайла - у точки, где насполагается стена (go.x; go.y)
// В остальных тайлах информации о курицах нету
var root = map[go.y][go.x];
for (go in root.getPointObjects()) {
renderObjects.remove(go);
objectFactory.destroyObject(go);
}
renderObjects.remove(go);
objectFactory.destroyObject(go);
// Вычищаем информацию о стене из каждого тайла
for (h in 0...go.height) {
var y = root.y + h;
if (y < 0 || y >= map.length) {
continue;
}
for (w in 0...go.width) {
var x = root.x + w;
if (x < 0 || y >= map[y].length) {
continue;
}
var t = map[y][x];
t.go = null;
t.clearPointObjects();
}
}
// Обновляем видимость на карте
updateVisibility();
}
Самый некрасивый код - найти объект для удаления. Он некрасив в основном из-за куриц, стоящих на стене, хотя логика довольно проста:
Проверь курицу на стене - от +8 до 0 тайлов от текущего (две высоты) и вниз
Проверь стену - от +4 до 0 (одна высота) тайлов вниз.
private function findGameObjectForClick(tileX:Int, tileY:Int):{ go: GameObject, parent:TileInfo } {
var n = Math.round(Constants.TILE_LENGTH / Constants.TILE_HEIGHT);
// Ищем курицу на стене
var parentedObject = tryFindParentedPointObject(tileX, tileY, n);
if (parentedObject != null) {
return parentedObject;
}
// Раз курица не найдена, то ищем стену или курицу на полу
for (i in 0...n + 1) {
var x = tileX + n - i;
var y = tileY + n - i;
if (y < 0 || y >= map.length) {
continue;
}
var row = map[y];
if (x < 0 || x >= row.length) {
continue;
}
// Проверяем что это курица на полу
var tile = row[x];
var po = unparentPointObjects[y][x];
if (po != null && po.length > 0) {
// Возвращаем первую попавшуюся в тайле курицу
destroyObject(po[0]);
return { go: po[0], parent: null };
}
// Тайл пустой - идем дальше
if (tile.go == null) {
continue;
}
// Найдена стена
return { go: tile.go, parent: null };
}
return null;
}
private function tryFindParentedPointObject(tileX:Int, tileY:Int, n:Int):{ go:GameObject, parent:TileInfo } {
// Ищем курицу на стене
for (i in 0...n) {
var x = tileX + n * 2 - i;
var y = tileY + n * 2 - i;
if (y < 0 || y >= map.length || x < 0 || x >= map[0].length) {
continue;
}
var tile = map[y][x];
if (tile.go == null) {
continue;
}
var rootTile = map[tile.go.y][tile.go.x];
var childs = rootTile.getPointObjects();
for (child in childs) {
if (child.x == x && child.y == y) {
return { go: child, parent: rootTile };
}
}
}
return null;
}
Послесловие
Игра выдает стабильные 60fps, но не стоит делать тестовое, если оно не оплачиваемое потому что сейчас давать фидбек - не модно. Вот какой фидбек я получил:
Спасибо, что выбрал время на выполнение нашего тестового задания. Мы запустили билд, ознакомились с кодом и обсудили его сильные и слабые стороны.
Мы оцениваем тестовое по нескольким параметрам:
Все ли требования задания удовлетворены
Насколько корректно написан алгоритм сортировки: объекты на сцене расположены на верных местах и в правильном порядке
Насколько правильно реализован алгоритм распространения видимости
Производительность демо-сцены: показывает ли она достаточный fps, в том числе при изменении масштаба и перемещении камеры
Общий подход к решению задачи и его совместимость с нашими подходами к работе
Чистота и эффективность кода
Насколько легко удалось запустить сцену, требовались ли правки для запуска
К сожалению, по суммарной оценке твоё тестовое задание не соответствует нашим ожиданиям для данной должности, поэтому, мы не готовы предложить тебе сотрудничество.
Индивидуальный фидбек даёт прямой руководитель на собеседовании. В случае отказа мы считаем некорректным давать фидбек без возможности обсудить нюансы лично.
PS Может кто-то в комментариях подскажет что не так :)
Ссылка на проект: https://github.com/truenoob141/haxe_game