Создание игры в стиле GameBoy в 13 КБ

в 13:00, , рубрики: gameboy, gamejam, javascript, js13kgames, ruvds_переводы, TypeScript, Блог компании RUVDS.com, геймджем, Игры и игровые консоли, разработка игр

Создание игры в стиле GameBoy в 13 КБ - 1


В прошлом году я решил поучаствовать в гейм-джеме js13kgames. Это длящееся один месяц ежегодное соревнование по созданию с нуля игры на JavaScript, которая должна уместиться в 13 КБ (в zip). Места как будто не очень много, но с достаточным количеством креативности при таких ограничениях можно достичь многого. Просто взгляните на потрясающие примеры прошлых лет:

Хотя в прошлом году моя игра заняла не такое высокое место, я всё равно хотел бы поделиться своими открытиями, сделанными в процессе её разработки.

Мне захотелось сделать игру, напоминающую о ретроэпохе игр на портативных консолях с их уникальным квадратным экраном, низким разрешением и видом сверху вниз. Я решил реализовать быстрый геймплей в стиле action-RPG с простым, но увлекательным геймплеем, мотивирующим игрока продолжать игру. С музыкой всё было очевидно — звуковые эффекты должны быть похожими на звуки аркадных автоматов.

Поиграть в мою игру можно на странице Gravepassing сайта JS13KGames. Полный код выложен на GitHub.

В этой статье я расскажу о процессе разработки, различных компонентах и трудностях, с которыми я столкнулся по пути.

Источники вдохновения и графика

При создании графического стиля я ориентировался на смесь стиля 1990-х портативных и домашних консолей наподобие GameBoy Color и NES. Проект должен был не имитировать конкретную игру или стиль, а, скорее, напоминать об этой эпохе.

Создание игры в стиле GameBoy в 13 КБ - 2

Я решил, что один игровой блок будет состоять из тайлов размером 16x16 пикселей. На экране отображается сетка 10x10, что даёт разрешение 160x160 пикселей (с целочисленным увеличением масштаба, чтобы игра выглядела красиво на современных дисплеях). В результате получается нечто похожее на экран GameBoy, однако на нём использовались тайлы размером 8x8 пикселей, а разрешение составляло 160×144 пикселя.

Из-за ограничений по размеру игры я не стал использовать спрайтовые карты. Вместо этого для сборки спрайтов я использовал эмодзи уменьшенного размера.

Создание игры в стиле GameBoy в 13 КБ - 3

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

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

Создание игры в стиле GameBoy в 13 КБ - 4

Более того, не все системы поддерживают одинаковую версию Unicode, поэтому некоторые из новых эмодзи несовместимы со старыми системами. На скриншоте выше видно, что в Windows и Ubuntu эмодзи могильной плиты был заменён на гроб (⚰️), потому что могильная плита была введена в Unicode 13.0. Чтобы достичь этого, я создал небольшой скрипт, который рендерит все эмодзи, и проверял, рендерятся ли они правильно. Если нет, он заменялся на эквивалент, прописанный в созданной вручную конфигурации. Так как на разных системах эмодзи могут иметь разный размер, требовалась ручная настройка.

interface OptionalEmojiSet {
    // EMOJI
    e?: string;
    // POS
    pos?: [number, number];
    // SIZE
    size?: number;
}

export const alt: Record<string, OptionalEmojiSet> = {
    "🪦": { e: "⚰️", pos: [0, 4], size: .9},
    "⛓": { e: "👖" },
    "🪨": { e: "💀" },
    "🪵": { e: "🌳" },
    "🦴": { e: "💀" }
}


export const win: Record<string, OptionalEmojiSet> = {
    "🔥": { pos: [1, -1], size: 1 },
    "💣": { pos: [-1, -2]},
    "👱": { pos: [-1, 0]},
    "🕶": { size: 1.5, pos: [-1, -1]},
    "⬛️": { pos: [-1, 0]},
    "👖": { pos: [-0.5, 0]},
    "🐷": { pos: [-1, 0]},
    "🦋": { pos: [-1, 0]},
    "🐮": { pos: [-1, 0]},
    "👔": { pos: [-1, 0]},
    "👩": { pos: [-1, 0]},
    "🤖": { pos: [-1, 0]},
    "👚": { pos: [-1, 0]},
    "🐵": { pos: [-1, 0]},
    "☢️":  { pos: [5, 0]},
    "👹": { pos: [-2, 1]}
}

export const tux: Record<string, OptionalEmojiSet> = {
    "🧧": { e: "👔" }
}

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

Структура игрового экрана

Экран отображает сетку 10x10 из тайлов размером 16 x 16 пикселей, что даёт разрешение 160x160 пикселей. Очевидно, что на современных 4k-экранах при отображении игры в таком разрешении получился бы очень маленький квадратик, поэтому масштаб изображения максимально увеличивался на целочисленный множитель.

Создание игры в стиле GameBoy в 13 КБ - 5

Для уменьшения эмодзи я использовал отдельный мелкий canvas, на котором каждый из спрайтов рендерился в исходном разрешении 16x16 пикселей. Затем эти спрайты можно было рендерить на большом canvas игры. Это позволило мне генерировать временные листы спрайтов, чтобы проверять правильность генерации тайлов.

По умолчанию в canvas использовалось сглаживание изображений, то есть при увеличении масштаба спрайта он сглаживался при помощи антиалиасинга. Чтобы избежать этого, мне нужно было отключить его.

context.imageSmoothingEnabled = false;

▍ Оптимизация Canvas

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

createImageBitmap(ctx.getImageData(0, 0, ctx.canvas.width, ctx.canvas.height)) // Это ускоряет последующий рендеринг

QuadTree

В процессе игры многие игровые объекты появляются и исчезают на экране. Основная часть игровой логики заключается в определении того, какие объекты близки друг к другу и в определении коллизий или расстояния до них — это относится ко всем взаимодействиям наподобие движения, распознавания коллизий, преследования врагами игрока и так далее; включает в себя логику поиска объектов в пределах определённого расстояния. Самое наивное решение этой задачи заключалось бы в проверке всех хранящихся в памяти объектов. К сожалению, для этого требуется множество сравнений. Если вы хотите выполнять это действие для каждого из элементов, то в каждом кадре придётся делать O(n2) сравнений.

Благодаря QuadTree (дереву квадрантов) мы можем выполнять предварительные вычисления для оптимизации этого процесса. Дерево хранит в своих узлах наши элементы, пока узел не достигнет своего предельного размера. В этом случае оно разделяет пространство на четыре подпространства. Затем каждый раз, когда мы хотим найти все элементы в заданной области, можно просто отбросить объекты за пределами границы. Весь процесс рекурсивен, поэтому мы получаем структуру, в которой очень легко находить элементы.

Создание игры в стиле GameBoy в 13 КБ - 6

Сама по себе рекурсивная реализация не очень длинна и легко умещается в сто строк.

import { GameObject } from "../modules/GameObjects/GameObject";
import { Rectangle } from "../modules/Primitives";

export class QuadTree {
    objects: Set<GameObject> = new Set();
    subtrees: QuadTree[] = [];
    constructor(private _boundary: Rectangle, private limit: number = 10) {
    }

    get boundary() {
        return this._boundary;
    }

    get subTrees() {
        return this.subtrees;
    }

    private subdivide() {
        const p1 = this.boundary.p1;
        const mid = this.boundary.center;
        const w = this.boundary.width;
        const h = this.boundary.height;
        this.subtrees = [
            new QuadTree(new Rectangle(p1, mid), this.limit),
            new QuadTree(new Rectangle(p1.add(w/2, 0), mid.add(w/2, 0)), this.limit),
            new QuadTree(new Rectangle(p1.add(0, h/2), mid.add(0, h/2)), this.limit),
            new QuadTree(new Rectangle(p1.add(w/2, h/2), mid.add(w/2, h/2)), this.limit),
        ];
        
        // теперь мы должны добавить все имеющиеся точки
        this.objects.forEach(o => {
            this.subtrees.forEach(t =>
                t.add(o)
            )
        })
    }

    add(obj: GameObject) {
        if (!this.boundary.isIntersectingRectangle(obj.getBoundingBox())) {
            return;
        }

        if (this.objects.size + 1 < this.limit || this.boundary.width < 10 || this.boundary.height < 10) {
            this.objects.add(obj);
        } else {
            if (!this.subtrees.length) {
                this.subdivide();
            }
            this.subtrees.forEach(t => {
               t.add(obj); 
            });
        }
    }

    getInArea(boundary: Rectangle): Set<GameObject> {
        if (!this.boundary.isIntersectingRectangle(boundary)) {
            return new Set();
        }
        if (this.subtrees.length) {
            const s = new Set<GameObject>();
            for (const tree of this.subTrees) {
                tree.getInArea(boundary).forEach(obj => s.add(obj));
            }
            return s;
        }
        const points = new Set<GameObject>();
        this.objects.forEach(obj => {
            if (boundary.isIntersectingRectangle(obj.getBoundingBox())) {
                points.add(obj);
            }
        });
        return points;
    }
}

Эффект дизеринга

Создание игры в стиле GameBoy в 13 КБ - 7

Для создания ретроэстетики вместо изменения непрозрачности теней я воспользовался дизерингом.

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

Этот эффект часто использовался в старых играх для консолей и компьютеров, в которых палитра была ограничена. Это заставляло разработчиков идти на хитрости и применять методики наподобие дизеринга, чтобы обмануть наш взгляд и заставить его думать, что цветов больше, чем на самом деле видно на экране. Наверно, самым известным примером использования дизеринга стала GameBoy Camera, в которой упорядоченный дизеринг применялся для отображения сделанных снимков.

Я планирую написать отдельную статью об этом эффекте и о способах его применения, а пока можете посмотреть его код на CodePen:

Создание игры в стиле GameBoy в 13 КБ - 8

Dither

Постобработка

Постобработка была важным этапом, она придала игре реалистичный ретровид. При отображении цветных изображений из-за своей архитектуры старые ЭЛТ-дисплеи создавали уникальные артефакты.

Создание игры в стиле GameBoy в 13 КБ - 9

Для имитации ретростиля я накладывал на каждый игровой пиксель разновидность фильтра Байера с небольшим размытием, а затем смешивал их при помощи режима смешивания color-burn. Это придало игре оригинальный стиль. К сожалению, она стала намного темнее, но поскольку основная тема связана со смертью, мне показалось это уместным.

Создание игры в стиле GameBoy в 13 КБ - 10

Вот отвечающий за этот эффект фрагмент кода:

// отрисовываем canvas постэффекта, если это не было сделано ранее, с небольшим размытием между цветными "пикселями"
if (!this.postCanvas) {
    this.postCanvas = document.createElement('canvas');
    const m = this.game.MULTIPLIER;
    this.postCanvas.width = this.postCanvas.height = m;
    const ctx = this.postCanvas.getContext('2d')!;
    ctx.filter = 'blur(1px)';
    ctx.fillStyle = "red";
    ctx.fillRect(m/2, 0,  m / 2, m/2);
    ctx.fillStyle = "green";
    ctx.fillRect(0, 0, m/2, m);
    ctx.fillStyle = "blue";
    ctx.fillRect(m/2, m/2, m/2, m/2);

    this.pattern = ctx.createPattern(this.postCanvas, "repeat")!;

}


// выполняем смешивание в режиме color-burn
this.ctx.globalAlpha = 0.6;
this.ctx.globalCompositeOperation = "color-burn";
this.ctx.fillStyle = this.pattern;
this.ctx.fillRect(0, 0, this.width, this.height);

// откатываемся к исходному режиму смешения.
this.ctx.globalCompositeOperation = "source-over";
this.ctx.globalAlpha = 1;

Аудио

Для генерации музыки я использовал WebAudio API, имеющий архитектуру на основе нодов, которую можно использовать для комбинирования генераторов частот, фильтров, эффектов и других аудионодов.

Создание игры в стиле GameBoy в 13 КБ - 11

Я реализовал простой аудиотрекер, считывающий заранее записанные партитуры и назначающий аудио. Дорожку можно записать как строку из нот MIDI, закодированных в виде символов UTF-8:

Создание игры в стиле GameBoy в 13 КБ - 12

Композиция состоит из нескольких дорожек. Они имеют один BPM, но могут иметь разные временные разделения, что упрощает итерирование музыкальных идей.

Если вы хотите больше узнать об использовании Web Audio, то крайне рекомендую свою статью по этой теме: Playing with MIDI in JavaScript.

А если вы хотите больше узнать о MIDI и о том, как использовать его в браузере, то рекомендую вводную статью о моей MIDI-библиотеке, написанной на TypeScript: Introducing MIDIVal: the easiest way to interact with MIDI in your browser.

Оптимизация размера пакета

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

Ограничение равно 13312 байтам (13 * 1024), а мой пакет без сжатия изначально занимал примерно 14 500 байтов. То есть мне нужно было урезать его на 1,5 КБ.

Я начал использовать uglify в потоке сборки, из-за чего были удалены некоторые console.log и сократились некоторые имена. Я немного поэкспериментировал с параметрами, но они не очень хорошо справились с заменой имён моих классов и методов. Я решил использовать собственное решение.

Модификатор кода для TypeScript

К счастью, вся кодовая база была написана на TypeScript. Благодаря этому я смог легко переименовать большинство имён классов, свойств и функций при помощи простого модификатора кода, написанного с использованием ts-morph.

TS-morph — это простой инструмент, помогающий с навигацией по AST-дереву TypeScript. Вместо ручной навигации по узлам можно легко извлечь из файла все символы и автоматически изменить их во всех местах.

let name = [65, 65];
let generateNextName = () => {
  if (name[1] === 90) {
    name[0]++;
    name[1] = 65;
  }
  name[1]++;
  return nameString();
}

classes.forEach(i => {
    const name = i.getName();
    const nextName = generateNextName();
    if (name === nextName) {
      return;
    }

    // Переименование класса
    console.log(name, '->', nextName);
    i.rename(nextName, {
      renameInComments: false,
      renameInStrings: false
    });
});

Показанный выше код изменяет все имена классов на AA, AB, AC и так далее. Поскольку мы используем типизированный язык, то знаем, где используется класс, поэтому все его использования тоже автоматически переименовываются. То же самое происходит с методами и свойствами классов. Вот мой полный модификатор кода.

▍ Roadroller

Чтобы ещё сильнее уменьшить размер, я воспользовался roadroller. Это инструмент, ужимающий JavaScript при помощи сложных методик сжатия наподобие побайтового кодера rANS, из-за чего исходный код становится полностью нечитаемым, но очень эффективным. Он вырезал несколько килобайтов моего кода, и он без проблем стал умещаться в ограничение.

Автор:
ru_vds

Источник

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


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