Быстрый и простой API, поддержка браузерами — это то, что делает Canvas привлекательным. Но, как это часто бывает, простота одновременно является и слабой стороной. Без труда, например, можно вывести прямоугольник, окружность, линию или навесить изображение. Но разработать на этой простой основе полезный контент — задача чуть сложнее.

Изображение - сила canvas

На примере разработки игры, показан подход к анимации и управлению игровым объектом.

Побудительным мотивом поиграться с разработкой игры на Canvas, стали два этих примера:

Примеры наглядные, но, погоняв пару минут шары и разорвав полдюжины сеток, азарт пропал. Нет сюжета, нет цели, нет смысла — в итоге прикольно, но не интересно.

Вводная часть


Работа с Canvas была описана в публикации HTML-страница на Canvas. В целом, точно такой же подход, включая архитектуру и средства разработки, применялся и здесь.

Совместимость проверялась на FF (Windows, Linux Mint), CR и IE (Windows). Проверка на доступных гаджетах тоже была, но без особого результата (об этом в конце).

Код на GitHub:
Arcad
Spots
Tens
Tetr

Демо на GitHub (с автономным режимом):
Arcad
Spots
Tens
Tetr

Игра — это управление моделью


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

Модель состоит из игровых объектов. Объекты из базовых примитивов: прямоугольников, окружностей, линий.

Модель и функционал, обеспечивающий динамику модели, ничего не знают о том, как модель будет показана. Функционал Canvas знает только то, как отображать статическое состояние модели, на момент отрисовки. В процессе отрисовки, изменения модели недопустимы. К счастью, за этим следить не надо (обеспечивается однопоточностью event loop-а).

Canvas API нативный, а потому быстрый, и может в реальном времени отобразить большое количество базовых примитивов. Поэтому количество (десятки и даже сотни) простых объектов не столь критичны для Canvas. А вот сложные вычислительные объекты, реализованные на JavaScript, это то, чего следует избегать. То-же самое можно отнести и к реализации алгоритмов изменения модели. Чем они будут проще и нативней (нативней, в данном случае, будут учитывать Canvas API), тем лучше. Если, по замыслу игры, в ней надо реализовать сложные и/или длительные по времени выполнения вычислительные алгоритмы, то, для таких целей, можно использовать web worker-ы (здесь не рассмотрено).

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

Разработка модели


Из тетриса получилось хорошее демо, чтобы протестировать подход (здесь я далеко не первый).

Концепция модели:
  • Блоки, состоящие из цветных ячеек, падают в колодец и переходят в общий пул. Заполненный ряд пула удаляется. Игра заканчивается, когда колодец заполнен и для нового блока нет свободного места.
  • Блоки можно вращать и двигать по горизонтали. Вращение и движение возможно только в свободном пространстве колодца. При вращении блока и при заполнении пула колодца, цвет ячеек сохраняется.
  • Базовый объект — ячейка. Ячейка имеет размер в пикселях. Этот размер является основной базой, на основании которой происходит вся отрисовка на Canvas.
  • Колодец — виртуальная сетка, состоящая из ячеек. Управление блоком и пулом — в изменении номера колонки и ряда ячейки в колодце.


Описание колодца и размера ячейки:
APELSERG.CONFIG.SET.CellSize = 20; // размер базового объекта в пикселях
APELSERG.CONFIG.SET.WellColumn = 5; // ширина колодца в базовых объектах
APELSERG.CONFIG.SET.WellRow = 20; // глубина колодца в базовых объектах

Базовый объект (ячейка):
APELSERG.MODEL.Cell = function (cellRow, cellCol, cellColor) {
    this.Row = cellRow; // номер ряда в колодце
    this.Col = cellCol; // номер колонки в колодце
    this.Color = cellColor; // цвет
}

Объект (блок):
APELSERG.MODEL.Block = function (blockType) {
    this.type = blockType; // номер внешнего вида блока
    this.idx = 0; // текущее состояние вращения блока (как повёрнут)
    this.cells = [[], [], [], []]; // четыре возможных состояния вращения
}

Видна избыточность модели блока, но за счёт этого решаются две задачи: 1. блок со всеми состояниями формируется за один раз (дальше управлять блоком просто); 2. сохраняются цвета ячеек при вращении блока.

Блоки создаются в режиме игры:
APELSERG.MODEL.GetNewBlock = function() {
    var newBlock = APELSERG.CONFIG.PROC.NextBlock; // ранее сформированный блок нужен для предпросмотра
    APELSERG.CONFIG.PROC.NextBlock = APELSERG.MODEL.GetBlock(); // новый блок - в предпросмотр 
    if (!APELSERG.MODEL.CheckBlockCross(newBlock)) { // проверка, что колодец не заполнен
        APELSERG.CONFIG.PROC.GameStop = true;  // завершить игру
        window.clearTimeout(APELSERG.CONFIG.PROC.TimeoutID); // сбросить таймер
        APELSERG.CONFIG.SetResult(); // записать результат
    }
    return newBlock;
}

Новый блок:
APELSERG.MODEL.GetBlock = function() {
    var blockType = APELSERG.MODEL.GetBlockType(); // выбрать тип случайным образом
    var newBlock = new APELSERG.MODEL.Block(blockType); // пустой объект для блока
    var newColor = "";
    
    // начальная позиция
    var baseRow = 1;
    var baseCol = Math.round(APELSERG.CONFIG.SET.WellColumn / 2);
    
    switch (blockType) {
        case 101:
            //-- [1]
            newColor = APELSERG.MODEL.GetCellColor();
            newBlock.cells[0][0] = new APELSERG.MODEL.Cell(baseRow, baseCol, newColor);
            newBlock.cells[1][0] = new APELSERG.MODEL.Cell(baseRow, baseCol, newColor);
            newBlock.cells[2][0] = new APELSERG.MODEL.Cell(baseRow, baseCol, newColor);
            newBlock.cells[3][0] = new APELSERG.MODEL.Cell(baseRow, baseCol, newColor);
            break;

    // Далее описание всех типов блоков

Здесь можно добавить блоки любой конфигурации. Главное присвоить блоку новый тип и добавить этот тип в массив в функции APELSERG.MODEL.GetBlockType().

Управление моделью


Управление происходит с клавиатуры типовым образом:
window.addEventListener('keydown', function (event) {
...
}

Блоки падают по таймеру (а не по window.requestAnimationFrame). В массиве APELSERG.CONFIG.SET.LevelTick хранится величина периода времени для текущего уровня:
APELSERG.MAIN.Animation = function (makeStep) {
    if (makeStep) {
        APELSERG.MODEL.BlockShift('DOWN'); // окончание игры срабатывает здесь - устанавливается флаг после проверки нового блока
    }

    APELSERG.CANVA.WellRewrite(APELSERG.CONFIG.PROC.CellPool);

    if (!APELSERG.CONFIG.PROC.GameStop && !APELSERG.CONFIG.PROC.GamePause) {
        APELSERG.MAIN.RequestAnimationFrame(function () {
            APELSERG.MAIN.Animation(true);
        });
    }
}

APELSERG.MAIN.RequestAnimationFrame = function (callback) {
    if (APELSERG.CONFIG.PROC.FastDownFlag) { // сброс как последовательный DOWN
        APELSERG.CONFIG.PROC.TimeoutID = window.setTimeout(callback, 10);
    }
    else {
        APELSERG.CONFIG.PROC.TimeoutID = window.setTimeout(callback, APELSERG.CONFIG.SET.LevelTick[APELSERG.CONFIG.SET.Level]);
    }
}

Для проверки — может ли двигаться или вращаться блок в заданном направлении — создаётся виртуальный блок и проверяются условия. Если условия проверки выполняются, то реальный блок перемещается на новое место. Функция проверки:
APELSERG.MODEL.CheckBlockCross = function(block) {
    var canShift = true;
    //-- проверить границы колодца
    //--
    for (var n = 0 in block.cells[block.idx]) {
        if (block.cells[block.idx][n].Col < 1 || block.cells[block.idx][n].Col > APELSERG.CONFIG.SET.WellColumn ||
            block.cells[block.idx][n].Row < 1 || block.cells[block.idx][n].Row > APELSERG.CONFIG.SET.WellRow) {
            canShift = false;
            break;
        }
    }
    //-- проверить границы пула
    //--
    if (canShift) {
        for (var n = 0 in block.cells[block.idx]) {
            for (var q = 0 in APELSERG.CONFIG.PROC.CellPool) {
                var cell = APELSERG.CONFIG.PROC.CellPool[q];
                if (block.cells[block.idx][n].Col == cell.Col && block.cells[block.idx][n].Row == cell.Row) {
                    canShift = false;
                    break;
                }
            }
            if (!canShift) {
                break;
            }
        }
    }
    return canShift;
}

Смещение блока происходит простым персчётом номеров ячеек:
APELSERG.MODEL.ShiftBlockColumn = function(block, num) {
    for (var x = 0 in block.cells) {
        for (var n = 0 in block.cells[x]) {
            block.cells[x][n].Col = block.cells[x][n].Col + num;
        }
    }
}

При достижении дна — ячейки из блока перемещаются в пул колодца функцией APELSERG.MODEL.DropBlockToPool(). При этом начисляются очки.

Функционал, который реализован, но, на мой взгляд, не очень удался (в настройках его нет):
  • APELSERG.CONFIG.PROC.FastDownFlag = false. Если установить в true, то падение будет не моментальным, а визуализированным.
  • APELSERG.CONFIG.SET.ShowFullRow = false. Если установить в true, то будет показан заполненный ряд перед удалением.
  • APELSERG.CONFIG.SET.SlideToFloor = false. Если установить в true, то блок после сброса будет «скользить» по полу и перейдёт в пул только по тику таймера.


Отрисовка на Canvas


За отрисовку модели на Canvas отвечает функция APELSERG.CANVA.WellRewrite(). Она очень простая и хорошо документированная. Всё что она делает — очищает Canvas и последовательно отрисовывает примитивы модели.

Интерфейс пользователя


Настройки, результаты, помощь

После того, как модель ожила, стало ясно, что для целостной игры одной ожившей модели мало. Так появились модули UI:
  • APELSERG.UI. Для обеспечения интерфейсов к настройкам, результатам и помощи.
  • APELSERG.LANG. Для обеспечения простой локализации.

Это типовой динамический DOM, возможно не самый удачный (код простой, описывать не буду).

Локальное хранилище

Для поддержания интереса к игре, необходимо хранить настройки, результаты и, возможно, полезен автономный режим. Для хранения используется localStorage. Технологически, всё реализовано типовым образом, но полезно проследить связь с глобальными объектами APELSERG.CONFIG.SET и APELSERG.CONFIG.RESULT.

Несколько замечаний:
  • Для каждого домена используется свой localStorage.
  • Стоит с большой осторожностью применять localStorage.clear() — очистит весь localStorage для текущего домена.

Конфигурация храниться в двух объектах:
  • CONFIG.SET — статические настройки, которые применяются в момент старта приложения (сохраняются в localStorage).
  • CONFIG.PROC — динамические настройки, которые применяются в процессе работы приложения (не сохраняются в localStorage).

Имя для хранения должно быть уникальным и формируется из комбинации нескольких статических переменных:
APELSERG.CONFIG.SET.Version = "0-1-0"
APELSERG.CONFIG.SET.LocalStorageName = "APELSERG-ArcadPlain";

APELSERG.CONFIG.GetLocalStorageConfigName = function () {
    return APELSERG.CONFIG.SET.LocalStorageName + "-Config-" + APELSERG.CONFIG.SET.Version;
}

Конфигурация сохраняется при её изменении. Делается это просто (даже на отдельную функцию не потянуло). В функции APELSERG.UI.ApplySettings() (модуль UI), добавлены две строчки:
var configName = APELSERG.CONFIG.GetLocalStorageConfigName();
localStorage[configName] = JSON.stringify(APELSERG.CONFIG.SET);

При старте приложения проверяется наличие в localStorage сохранённой конфигурации и, если конфигурация была сохранена, она восстанавливается:
APELSERG.CONFIG.GetConfigOnLoad = function () {
    if (APELSERG.CONFIG.PROC.LoadFromWeb) {
        var configName = APELSERG.CONFIG.GetLocalStorageConfigName();
        if (localStorage[configName] !== undefined) {
            APELSERG.CONFIG.SET = JSON.parse(localStorage[configName]);
        }
    }
}

LocalStorage может быть пустым или не использоваться вовсе. Пустым localStorage бывает: 1. при первом запуске; 2. если не был сохранён; 3. если был очищен. Очистка конфигурации бывает нужна, в основном, в процессе разработки. Например, конфигурация изменилась — были добавлены или убраны переменные, а приложение продолжает работать, как будто не видит изменений, так как из хранилища восстанавливается старый объект конфигурации.

Если запуск приложения был произведён с локального диска, то локальное хранилище отключается. Так сделано, потому что браузеры не очень хорошо поддерживают этот режим. Но остальная функциональность приложения сохраняется. Запуск с локального диска контролируется при старте:
window.location.protocol == "file:" ? APELSERG.CONFIG.PROC.LoadFromWeb = false : APELSERG.CONFIG.PROC.LoadFromWeb = true;

Результаты хранятся в APELSERG.CONFIG.RESULT. Функционально, хранение результатов идентично хранению конфигурации.

Автономная работа

Режим автономной работы (Application Cache или AppCache) позволяет продолжать работу с веб-приложением при отключённом интернете. В общем виде, настройка разных условий автономного режима, может быть достаточно сложной. Но, в нашем случае, это одна из самых простых процедур.

Надо подготовить файл манифеста для автономного режима (game_arcad_plain.appcache.txt):
CACHE MANIFEST
# Ver 0.1.0
# 001
game_tetr_plain.htm
game_tetr_plain_canva.js
game_tetr_plain_config.js
game_tetr_plain_lang.js
game_tetr_plain_main.js
game_tetr_plain_model.js
game_tetr_plain_model_blocks.js
game_tetr_plain_ui.js


Надо добавить в HTML элемент веб-страницы ссылку на этот файл:
<html manifest="game_arcad_plain.appcache.txt">

Тонкий момент с расширением «txt». Рекомендуется расширение «appcache» или «manifest» с MIME-типом «text/cache-manifest». В демо так сделано, потому что было лениво любопытно.

"# 001" нужен для перезагрузки файлов на клиенте по инициативе сервера. Если на сервере обновили файлы, то они не попадут на клиента, пока не изменится файл манифеста. А что в нём можно изменить? — комментарий на "# 002".

Другие игры


После того, как была разработана первая игра, остальные игры штамповались по образцу. Процентов 80 кода оставалось идентичным, а изменения касались, в основном, только модели (модель и код управления стали проще). Поэтому описывать эти игры отдельно не имеет смысла, за исключением нескольких нюансов:
  • Поскольку в этих играх есть постоянно движущийся объект (шарик), анимация выполнена на window.requestAnimationFrame.
  • Шарик движется медленнее/быстрее не за счёт изменения интервала времени, а за счёт изменения приращения координат по X и Y. Интервалы времени не измеряются (а, по-хорошему бы, надо).
  • Отскок считается от центра шара, с учётом диаметра. Ускорение и направление выбирается случайным образом. При обратном движении, шар ракетку не видит. Толщина блока/ракетки не могут быть тоньше размера шара, иначе, на больших скоростях, могут быть сквозные проскоки.


Краткие тестовые выводы


Что хорошо:
  • Анимация на Canvas проста. Модель делает работу с Canvas ещё проще.
  • Локальное хранилище и режим автономной работы просты в использовании.


Что не очень хорошо:
  • Canvas плохо дружит с тач-скринами. Даже если приложение реагирует на тач-скрин, всё-равно поведение на клик мыши на 20-ти дюймовом дисплее, и на тыканье пальцем в экран смартфона будут отличаться. Поэтому демо-игры предназначены, в основном, для использования на десктопных системах, а управление ориентировано на клавиатуру. Позитив в том, что имея, относительно несложное, работающее десктопное приложение, можно приступить к его дальнейшей адаптации.
  • Canvas, периодически, немного лагает. Механизмы для борьбы отсутствуют.


Полезные ссылки


HTML5 Canvas
HTML5 Local Storage
HTML5 Application Cache

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