Разработка тайловой игры на JavaScript (Robbo)

в 1:19, , рубрики: Без рубрики

Дорогие жители Хаброхабра!

В этот раз я принёс вам историю про javascript, atari и canvas! Игра называется Robbo и является портом одноименной игры 1989 года.
image

Сама игрушка. Игрушка с выключенным звуком. Ссылка на github.
Управление осуществляется стрелками. Если есть патроны, то shift+стрелка выстрелит в нужном направлении.

История

Кому не интересна предыстория — пролистывайте сразу следующие несколько абзацев до раздела «Техническая реализация».

В детстве у меня был компьютер Atari 130 XE. Родители купили его когда мне было 2 года. По их рассказам именно в этом возрасте я падал со стула с джойстиком, убегая от совы на втором уровне Robbo.

Robbo

Эта игра была написана польским програмистом Janus Pelc и выпущена в 1989 году. Это была моя любимая игра детства.

В институте моей маме преподавали программирование и она решила в 4 года научить меня бэйсику. Тогда и была предпринята первая попытка переписать роббо самостоятельно. Плохо было то, что я знал только операторы PRINT, INPUT и POKE. Ни к чему хорошему это не привело/ Z не мог даже представить как возможно написать такую игру, то что я делал было похоже на брутфорс по всем ходам. Пользователь нажал вправо и отпринтилось состояние где персонаж смещен на одну клетку вправо, то есть это была аскиарт стэйтмашина в чистом виде. Если бы я знал о текстовых квестах, то их бы таким методом реализовать вышло, но я не знал.

Следующая итерация была в девятом классе. Это был уже Visual Basic и AMD-K6 II 500. В то время журнал Upgrade решил выпустить свой первый номер с диском, а у меня как раз была некоторая реализация игры. В MS Paint была нарисована груда спрайтов с их фирменными пионерами, после чего я выслал им эту игрушку. Через несколько месяцев сдох винт и код был утерян. В коде были только массивы, даже структуры не использовались, один алгоритм обхода лабиринта по правилу правой руки NPC занимал восемь А4 страниц кода (я не шучу). Нормального интернета тогда ещё не было, только диалап.

В 11 классе всё на том же Visual Basic была написано новая версия. Тут код стал заметно лучше, я не расписывал разное поведение для 4 направлений движения.

Прошло 8 лет. Познание паттернов, чтение уймы литературы и практического опыта, изучение вереницы языков, любовь к функциональному стилю и js в частности, развитие HTML 5. И я вновь вернулся к Robbo. Все эти годы я иногда вечерами запускал эмулятор атари (Atari800Win) и играл в любимую игру детства. За 2 дня я написал код, где было намного меньше строк чем раньше. После чего наступило прозрение что это надо отрефакторить. Рефакторинг растянулся на несколько месяцев. Это некоммерческий проект, то есть хобби, потому я старался вылизывать код, хотя до сих пор остались огрехи за которые мне стыдно. Более подробно с чем я столкнулся я опишу в технической части.

GnuRobbo.

Есть похожий open source проект GnuRobbo. Они реализовывали robbo на c++ кроссплатформенно (сейчас есть даже рабочая версия под Андроид в гугл плэй). Последние несколько лет когда меня тянуло вспомнить robbo — я запускал именно эту реализацию. Когда я начал работу над этим проектом меня подмывало посмотреть как что сделано у них, так вот, при общей схожести (если переключить скин в classic) создаётся ощущение что это robbo, но на самом деле отличающихся мест чертовски много. Создаётся ощущение что разработка не была пропитана любовью, как бы странно это не звучало.

Техническо популярная часть.

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

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

Сами объекты получились предельно простыми. Вот, для примера код двери (door.js):

(function( R ){
    'use strict';
    R.objects.Door = {
        // дверь можно взорвать
        explodable: true,
        // если ткнуться в дверь, то случится поведение поедания двери
        eatable: true,
        eat: function( eater ){
            // если у того кто хочет съесть дверь есть хотя бы один ключ
            if( eater.keys > 0 ){
                // отнимем у него один ключ
                eater.set( 'keys', eater.keys - 1 );
                // уничтожим дверь
                this.game.setCell( this, 'Empty' );
                // и сыграем звук отворяющейся дверь
                this.game.playSound('door_default')
            }
            // функция eat вызывается извне, и если она возвращает false, то это значит
            // что объект на самом деле есть нельзя. В случае двери она сама уничтожает себя,
            // потому что в оригинальном роббо мы не едим дверь как болт, а только отворяем её.

            return false;
        }
    };
} )(window.R);

С таким подходом сделать простые объекты вышло быстро. А вот с лавой, бомбами, взрывами, пулями и телепортом пришлось повозиться.

Бомба и взрыв

image
Бомба — это объект, который взрывается и разносит всё что находится на расстоянии одной клетки от него. В оригинальном роббо это действительно похоже на взрыв, а в gnuRobbo на всю красоту забили.
Было записано большое количество видео того как взрывается бомба в эмуляторе, после чего на бумагу была переписана раскадровка спрайтов взрыва. Два дня я созерцал цифры. За это время я узнал что аниация взрыва имеет 5 состояний сначала взрыв усиливается, а потом затухает, но делает он это не равномерно во всех направлениях, а по некоторому паттерну. На форуме гнуроббо кто-то уже замечал что взрывы отличаются и предполагали что это рэндом, но нет, это не мог быть рэндом. В те времена получение псевдослучайных чисел было дорогой забавой.

Насмотревшись на цифры достаточно я вывел закономерности и две матрицы взрывов. Матрица накладывается на соседние клетки и там где число больше нуля ставится объект взрыва с анимацией, соответствующей числу из матрицы. Если там уже был взрыв, то к его анимации добавляется число из матрицы, после чего делается max( 5, animation ).
1) Матрица момента после касания бомбы пулей или соседним взрывом:

0, 0, 0,
0, 0, 2,
5, 4, 5</code>
В этот момент бомба ещё жива как объект и визуально.

2) На следующем такте  накладывается матрица:
<source lang="dos">
5, 4, 5,
4, 3, 2,
0, 0, 0

Для одной бомбы результат получается идентичен оригинальной игре.
Для нескольких бомб — ещё не идентичен. Выглядит похоже на оригинальную анимацию, но ещё требует доработки (стыдно).

Сам взрыв же представлен объектом, занимающим одну клетку и уменьшающим свою анимацию на 1 за каждый шаг. После взрыва этот объект умеет позвать коллбэк или выставить на своё место заранее заданный другой объект.

Фактически взрывов в игре много:
Дым от разорвавшегося патрона, уничтожение объекта, взрыв бомбы, телепортация из, телепортация в (тут анимация взрыва идёт в обратном порядке)

Телепорт

image
Телепорт работает собственно как телепорт. Перемещает роббо из одной части карты в другую. Во входной точке роббо дематериализуется, а на выходе — материализуется обратно. Если войдёт справа, то выйдет с левой стороны. Интересные случаи начинаются когда в выходной точке нужный выход заблокирован, иногда и все стороны выхода закрыты, а иногда точку выхода могли взорвать. Телепорты не обязательно связаны попарно, они могут быть закольцованы и большим числом выходов. В своей реализации я сделал возможность выхода и не в другом телепорте, но это на будущее, когда буду делать игру со своей идеей. На эмуляторе я проверял все эти состояния и записывал что к чему приводит. Любовь к оптимизации пришла ко мне из ассемблера, потому я постароил таблицу начальное -> конечное состояние и свёл выбор выходной клетки к выражению без условного оператора. Это была больше игра в подбери выражение, в данном случае она не оправдана, но раз уж потратил время, то оставил. Фактически вышло что надо инкрементивно проделывать операцию xor текущего направления с 11|10|11|10.
У нас есть входное направление. Допустим мы вошли справа, что соответствует направлению 2 (вошли справа, значит шли в левую сторону). Алгоритм попробует клетки 2 (влево), 1 (вниз) (10^11 = 01), 3 (вверх) (01^10 = 11), 0 (вправо) (11^11 = 0), после чего вернёт направление в исходное состояние (в случае если все выходы заблокированы).

В виде алгоритма это выглядит так:

var tryCell, i;
for( i = 0; i < 4; i++ ){
    tryCell = this.game.getCell( R.addDirection( this.teleportX, this.teleportY, obj.direction ) );
    if( tryCell.is( 'Empty' ) )
        break;
    obj.direction = obj.direction ^ ( 3 - i % 2 );
}
Лава.

image
Объект, который двигается слева направо (или наоборот) всей строкой. Сметает на своём пути всё кроме стен. Сначала я пытался реализовать это поведение на уровне одной клетки, как у остальных объектов, но оказалось что гораздо проще в одном шаге одного объекта рассмотреть всех соседей на горизонтальной линии, ограниченной стенами, подвинуть их и сказать им что в этом шаге мира их действия больше не нужны.

Техническая часть

Код игры разделён на логические модули, основные: контроллер, вьюха, менеджер спрайтов, обработчик клавиатуры, объекты (по модулю на каждый отдельный объект и фабрика объектов).

Модель смешана с основным контроллером, а непосредственно объекты являются маленькими контроллерами, отвечающими только за своё поведение. Отдельно стоит вьюха, которая инициализирует кэнвасы, следит за обновлением спрайтов, скроллит игровое поле и запрашивает у R.sprites отрисовку необходимого изображения в нужной позиции.

Изначально мне показалось что перебирать всё игровое поле и смотреть является ли объект живым — не самый оптимальный подход. Так родился список активных объектов. В шаге мира обрабатывается только их поведение.

В контроллер были вынесены общие функции работы с картой, такие как:
swap — поменять местами два объекта. Активно используется при перемещении одиночных объектов (шаг, одиночный выстрел, npc)
getCell( x, y ) — вернёт объект с заданной позицией
getCell( obj ) -> вернёт объект, находящийся в координатах obj.x, obj.y

setCell( x, y, obj, data ) — ставит объект в заданную позицию. Obj может быть как другим объектом, так и именем объекта (например, 'Explosion'). В случае имени объекта — позовётся фабрика объектов.
setCell( obj1, obj2, data ) -> делает то же самое, только координаты выдирает из obj1.x, obj1.y

Всё выглядело просто, пока не началось тестирование оригинального геймплея. Тут выяснилось что в оригинальной игре если упереться камнем в летающую вверх-вниз птицу справа, то в следующем шаге роббо умрёт, а если слева, то проскочит над ней. Пришлось сортировать actionObjects в соответствии с порядком обхода оригинальным игры.

Спрайты

Перерисовывать всё игровое поле на каждом шаге было бы неправильно с точки зрения производительности (хотя и не критично для современных компьютеров). Здесь я реализовал хэш по изменным спрайтам объектов, который состоит из координат key: y объекта, а в нём лежит хэш всех объектов, измененных с последней отрисовки в этой строке с key: object.x, а value: самим объектом. Этим я убил сразу двух зайцев: не делается лишний обход всего поля и отрисовывается измененная клетка только один раз, даже если там было несколько изменений (например, птица передвинулась в клетку вниз, а потом её поглотила лава, и всё это за один шаг мира).

Сами спрайты были сначала честно утянуты из gnu robbo, но очень скоро я понял что это было сделано зря. Проще было сразу делать оригинальные, чем возиться и играть в пикселькэтчинг (некоторые они допиксельартили до большего разрешения, неоторые доантиалиасили, а некоторые придумали свои), также у них не хватало процентов 80 спрайтов стен (их в игре оказалось заметно больше, чем я предполагал).

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

Отдельной главы заслуживает палитра спрайтов.

Я не сразу заметил что в оригинальном роббо на каждом уровне используется свой набор цветов.
Это очень частое решение тех лет разработки игр и демосцены. Памяти было мало, а визуальных эффектов уже хотелось, потому в старых компьютерах была простая возможность поменять соответствие номера из палитры к непосредственно цвету.
Решение было сделано в лоб — в конфиг каждого уровня добавились все цвета, выдранные из оригинальных уровней игры, все спрайты были приведены к 4 общим цветам. На загрузке уровня идёт попиксельное перекрашивание всей картинки в новую цветовую схему.

Буквально на прошлой неделе я сделал отдельный проект (будет пост, если интересно), который позволяет производить изменение палитры без попиксельных манипуляций. Поразительно, но этот метод работает в IE7.
Но эту технологию я ещё не втянул в текущий код игры.

Заключение

В игре ещё есть некоторые огрехи.

В планах:

  • Доразрулить взрыв группы бомб.
  • Сделать поддержку старых браузеров.
  • Вытянуть цвета общего фона вокруг игрового поля и цифр из оригинальной игры.
  • Сделать кончающиеся жизни (сейчас это уже олдскул, но ведь и игрушка сама из тех времён).
  • Доделать поддержку тач управления для мобильных платформ, сделать поддержку smart tv для игры с пульта телевизора.
  • Ну и, конечно, портировать финальный мультик.

В отдалённых планах:

  • Сделать редактор уровней с редактором объектов, где пользователь сможет нарисовать свой объект, нарисовать ему цепочку спрайтов и выбрать различные поведения из обширного предефайнд списка. Это даст возможность сделать из роббо сокобан, пакмэна (тут надо ещё межклеточное перемещение продумать), боулдердаш, тетрис, да хоть арканоид.
  • Сделать общую базу уровней, где пользователи смогут добавлять свои, а другие смогут голосовать за понравившиеся.

Я пообещал себе выложить этот релиз до нового года и написать в общих чертах о разработке. Вроде удалось. Есть ещё места, за которые мне стыдно (в частности вся работа с загрузкой уровней, анимация смены уровней).
Всех с наступающими праздниками!

Автор: Zibx

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js