Предисловие

Я получил тестовое задание от 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. Изометрическая сортировка

Одна из самых неприятных частей. Я не буду вдаваться в подробности сортировки тайлов в изометрии - про это уже много есть статей (правда на английском).

Допустим, у нас есть список всех объектов, которые надо создать, тогда алгоритм будет следующий:

  1. Найти AABB всех объектов;

  2. Отфильтровать Стены от Куриц;

  3. Выполнить топологическую сортировку;

  4. Создать объекты (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, но не стоит делать тестовое, если оно не оплачиваемое потому что сейчас давать фидбек - не модно. Вот какой фидбек я получил:

Спасибо, что выбрал время на выполнение нашего тестового задания. Мы запустили билд, ознакомились с кодом и обсудили его сильные и слабые стороны.

Мы оцениваем тестовое по нескольким параметрам:

  1. Все ли требования задания удовлетворены

  2. Насколько корректно написан алгоритм сортировки: объекты на сцене расположены на верных местах и в правильном порядке

  3. Насколько правильно реализован алгоритм распространения видимости

  4. Производительность демо-сцены: показывает ли она достаточный fps, в том числе при изменении масштаба и перемещении камеры

  5. Общий подход к решению задачи и его совместимость с нашими подходами к работе

  6. Чистота и эффективность кода

  7. Насколько легко удалось запустить сцену, требовались ли правки для запуска

К сожалению, по суммарной оценке твоё тестовое задание не соответствует нашим ожиданиям для данной должности, поэтому, мы не готовы предложить тебе сотрудничество.

Индивидуальный фидбек даёт прямой руководитель на собеседовании. В случае отказа мы считаем некорректным давать фидбек без возможности обсудить нюансы лично.

PS Может кто-то в комментариях подскажет что не так :)

Ссылка на проект: https://github.com/truenoob141/haxe_game

Комментарии (5)


  1. vesper-bot
    02.07.2025 08:16

    П.5 позволяет завалить любого. "Мы делаем не так, вы нам не подходите", а как это "так", они не скажут.


  1. atues
    02.07.2025 08:16

    Не расстраивайтесь! Они испугались того, что человек, ни разу до этого не юзавший Haxe, не только быстро разобрался, но и умудрился в срок "наваять" решение. Вы же их уделаете если займетесь этим по-настоящему :)


  1. pecheny
    02.07.2025 08:16

    Без текста тестового сложно что-то оценивать, иногда там бывают важные детали, которые легко пропустить.
    Иногда команда просто хочет человека, который попадает под "культурный контекст".
    Задание по косвенным признакам выглядит достаточно объемным, причем требуется выполнить его на определенном языке, поэтому сложно сказать, что именно важнее: понимание языка или, например, знание определенных алгоритмов.
    В код я разумеется не вчитывался, но конкретно в вашем случае случае могу сделать следующие замечания:

    1. так делать ни в коем случае нельзя с точки зрения языка. Результат работы парсера – анонимная структура, она не совместима с типом класса. Приложение просто падает на этом месте на любом статическом таргете, а на html работает по стечению обстоятельств.

    2. Плавающий кодстайл и значительное количество странного, неиспользуемого кода намекает на значительный вклад аишки. Аишка стоит существенно дешевле программиста на "высокой вилке", а если выход сопоставим, то зачем платить больше?

    3. Чисто визуально клики срабатывают тоже так себе – близко к границам (пикселя 3–4 на глаз может соседний объект удалить...


    1. TrueNoob141 Автор
      02.07.2025 08:16

      Спасибо за подробный ответ!

      1. Я понимаю, что без исходного задания сложно что-то сказать и мне бы хотелось выложить и его, но решил по этическим соображениям воздержаться - все таки моя цель не врожда с компанией. Хотя тут можно было бы поспорить;

      2. Этот момент я тоже обдумывал, но решил не плодить оверхед и написать самый простой вариант. Не совсем понял почему "анонимная структура" - это конкретный инстанс конкретного класса. В общем оверхед также можно оценить как "плохо" на мой взгляд - сколько кейсов было, когда написали код "наперед", а его все равно приходилось переписывать, но при этом еще разбирать. Изменить 1 класс проще, чем всю систему.

      3. Я действительно использовал ИИ-шку, но в основном только для отладочных целей и нескольких вопросов по языку. В остальном код писался руками. Если не затруднит, то не могли бы вы привести примеры разного код стайла? Возможно также повлияло то что код стайл банально не настроен и автоформатирование работает так себе.

      4. Я постарался охватить все кейсы при тестировании и не смог словить чтобы они отрабатывали как-то неправильно. Если бизнес логика как-то поменяется, то скорее всего сломается. Но более надежное поведение потребовало бы очень больших трудозатрат. По тем же причинам я не стал делать отрисовку пола потайлово и систему рендеринга только тех объектов, что на экране.

      Аишка стоит существенно дешевле программиста на "высокой вилке", а если выход сопоставим, то зачем платить больше?

      Так если аишка решает все проблемы, то зачем вообще платить человеку?) Вот только специалист не сможет просто вбить в аишку "сделай мне тестовое" и получить готовый результат. Однако, если она ускоряет работу (естественно не надо в слепую копи-паст делать), то ее использование на мой взгляд это плюс. Бизнесу важен результат.


      1. pecheny
        02.07.2025 08:16

        1. В указанной строчке поцедура присваивания. Тип переменной – инстанс класса. Тип, возвращаемый парсером – Anonymous Structure, но сигнатура –Dynamic. Тип Dynamic специальный, но для наглядности его можно сравнить с Any в ts, для него не работают проверки типа в момент компиляции, но это не делает значения совместимыми между собой. Если хочется использовать результат работы парсера напрямую, как контейнер данных, то для этого можно использовать typedef.

        2. Ну, например, приватные переменные кое-где начинаются с _, в остальных местах нет. Я даже не про форматирование. В целом, стиль написания очень разношерстный: где-то используются сеттеры, где-то поля, где-то функции-акцессоры. Много неиспользуемого кода.

        Не обязательно пенять на иишку, человек тоже может накопипастить что-то со стековерфлоу, конвертировав синтаксис в нужный язык. Проблема с иишкой в том, что такой результат она выдает намного дешевле. От человека действительно требуется понимание и контроль. Так вот, в данном решении всего этого не хватает. Решение здесь неаккуратное и глючное. В прод такое совать нельзя, в основной репо проекта коммитить тоже не стоит. Бизнес готов платить многаденяк за человека, который умеет контролировать иишку, а не за человека, которого самого надо контролировать.
        А сотни неиспользуемых строк закоммиченных в общий репо могут стоить намного дороже привнесенного решения, создавая дополнительную когнитивную нагрузку на команду.