Основы платформенной игры

C тех пор, как я начал реализовывать игру J-Rio год назад, я часто думал, что надо написать главу об основах создания платформенных игр, но до сегодняшнего дня у меня постоянно не хватало на это времени. Однако по мере накопления множества писем с вопросами о технических моментах J-rio и платформеров в целом стало очевидно, что такая глава будет очень полезной многим. Также, поскольку я не выкладывал исходники J-Rio, люди, интересующиеся тем, как работает игра, получат шанс заглянуть за кулисы и увидеть механизмы в действии, потому что апплет, который мы будем делать в данной главе, представляет собой, так сказать, облегченную версию игры J-Rio. Хочу только предупредить заранее, что перед чтением вы должны быть знакомы с главами "редактор уровней" и "скроллинг". Здесь будут использоваться описанные в этих главах технологии, но останавливаться на них мы не будем. Также вам следует заранее скачать исходный код, и по мере чтения туториала работать с ним. Построчно разбирать его мы не будем, а вместо этого сосредоточимся на наиболее важных проблемах. Во-первых, мы внимательно рассмотрим проектирование класса, которое я выбрал для реализации игры, во-вторых, поговорим о методах и атрибутах класса Player, где сфокусируемся на вещах, связанных с движениями игрока, и, в-третьих, изучим структуру и функции класса Level.

Проектирование классов платформенной игры

Для сложных игр (или любых других программ) очень важно иметь хорошую структуру классов, позволяющую программистам без труда добавлять в программу новые функции. В нашем случае (или в моем, когда я трудился над J-Rio) требуется возможность добавлять новых врагов и элементы уровня в уже готовую игру без каких-либо глобальных изменений кода, а также возможнсть легко создавать новые уровни. Думаю, мне удалось справиться с этими задачами, и поэтому именно эти решения мы применим здесь; возможно, есть более простые, но они вряд ли будут такими же эффективными. Взгляните, например, как легко создавать новые уровни, используя редактор уровней J-Rio.
Если вы уже скачали исходный код и распаковали содержимое zip-файла, то обнаружили следующие классы (заметьте, далее следует только обзор, детали будут потом):

Класс Main
Прежде всего класс Main реализует сам апплет. Это означает, что методы init() , start() , stop() , destroy() и paint(Graphics g) включены в главный поток исполнения игры внутри метода run() интерфейса Runnable. Он также содержит методы для обработки событий игрока ("клавиша нажата" и т.п.) и два атрибута: экземпляр класса Player и экземпляр дочернего класса от класса Level (в нашем случае это только класс LevelOne). В цикле run() мы управляем всей игрой: вызываем методы, рисующие игрока и уровень, "скроллим" уровень и игрока по необходимости, проверяем столкновения и так далее. Обратите внимание, что класс Main не реализует все эти методы, а только вызывает х из классов Player и Level.

Класс Player
Этот класс реализует поведение и атрибуты объекта игрока. Это значит, что здесь содержится информация о позиции игрока в игре, хранятся его изображения, используемые для анимации, а также ответственные за контроль движения игрока (детали будут объяснены позже).

Класс LevelElement
Игра типа J-rio состоит из множества разных типов элементов уровня с различными атрибутами, поведением и взаимодействием с игроком. Для примера возьмем "кирпичи с вопросом", которые имеют как минимум два состояния - "стукнутые" и "нестукнутые" :-), плавающие платформы и наиболее простые элементы - почва, которые вообще не имеют специфичного поведения. В данной главе мы сконцентрируемся именно на таком простом элементе, реализованному в классе Ground. Ведь несмотря на отличия разных видов элементов уровня, они имеют много общего в поведении, что реализовано в классе LevelElement, который является основным классом для всех элементов нашей платформенной игры. Основное поведение содержит позицию элемента, уникальный идентификатор, булева переменная "inSight", применяемая в скроллинге и раскраске элементов (подробнее см. в главе о скроллинге), а также Imag - объект для хранения элемента уровня GIF. Каждый элемент в J-Rio является производным от класса LevelElement, а структура внутренниих данных класса Level содержит только экземпляры дочерних классов класса LevelElement, которые определяются по их уникальным идентификаторам.

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

Класс Level
Абстрактный класс Level, возможно, наиболее важный и сложный класс в игре. Он содержит метод, который переводит строковое представление уровня (получаемое в классе LevelOne) в представление внутренними данными уровня, являющееся двумерным массивом экземпляров LevelElement. Кроме того, здесь есть методы, отслеживающие столкновения игрока и элементов уровня. Как выглядит внутреннее представление, как оно создается и как обрабатываются столкновения, вы узнаете далее в этой главе.

Класс LevelOne
Данный класс содержит специальные определения уровня, в частности, определение самого уровня, состоящее из 25 строк, которые превратятся в 25 рядов уровня непосредственно в игре. Цветом заднего фона можно также управлять в этом классе, используя метод initializeColorArray().

Класс C_jump
Чтобы изменения размера игры, какого-либо элемента и т.д. не вызывали трудоемких затрат, все константы обычно записываются в отдельных класс, и в нашем случае этот класс называется C_jump.

Как классы работают вместе

Как я уже говорил, класс Main содержит экземпляр объекта Player, а также экземпляр дочернего класса от класса Level. Main также ответственен за управление всей игрой. Класс Player используется, чтобы хранить специфичные атрибуты игрока, и чтобы двигать, рисовать, анимировать и скроллить игрока правильно. Поскольку класс Level хранит внутреннее представление уровня, он рисует и прокручивает уровень, а еще проверяет наличие столкновений между игроком и элементами уровня. Уровень реализовывается как дочерний класс класса Level, в нашем случае это класс LevelOne. Когда мы создаем экземпляр класса LevelOne, строковое представление уровня переводится в двумерный массив объектов LevelElement, хранящийся в родительском классе LevelOne - Level. Каждый элемент уровня - дочерний класс класса LevelElement, в нашей платформенной игре такой класс только один - Ground.
Надеюсь, я понятно объяснил, почему я спроектировал классы таким образом, а если какие-то проблемы остались, обратитесь к главе "редактор уровней", где я использовал аналогичную архитектуру, ну, разве что немного проще. А теперь давайте подробнее рассмотрим классы Player и Level.

Детали класса Player

Контроль над анимацией и движениями игрока очень важен для нашей игры. Стоит заметить, что контролировать движением игрока в платформенной игре не так просто, как может показаться, ибо нам следует позаботиться о том, чтобы наш игрок останавливался или прыгал, сталкиваясь со стеной, и падал, когда достигает края платформы, останавливался в момент приземления и так далее. Поэтому я собираюсь объяснить, как я решил все эти проблемы, в следующей ниже части главы.
Прежде всего вы должны понять, что в этой платформенной игре движения игрока контролируются не прямым путем, используя логические метки. В нашем классе/объекте игрока нажатие клавиш на клавиатуре и результаты столкновения в классе Level только задают логическое значение четырех меток перемещения и не вызывают движения игрока непосредственно (другими словами, они не изменяют положения по осям x и y). Метод run() класса Main вызывает метод под названием playerMove() класса Player, который и перемещает игрока в соответствии со значениями меток движения. Посмотрите на эти метки:

Четыре метки движения и контроль над ними
Объект игрока имеет четрые, частично независимых метки движения:

  • walking_left: показывает, когда игрок должен двигаься влево, и в основном контролируется клавиатурой игрока-человека, так что его значение становится true в том случае, если нажата клавиша "стрелка влево", а "false" - когда эта клавиша отпущена. Однако на метку влияют также контроль столкновений, потому что игрок прекращает движение, если наткнется на стену с левой стороны.
  • walking_right: аналогично walking_left , только в противоположном направлении (думаю, в этом нет никаких сюрпризов :-)
  • jumping: эта метка обозначает, что игрок должен прыгать. Однако все немного сложнее, потому что, с одной стороны, движение происходит под влиянием двух контроллеров: когда игрок-человек нажимает или отпускает клавишу "a" и когда в действие вступает контроль столкновений (например, когда игрок бьется головой о стену). С другой стороны, игрок может не иметь возможности прыгнуть - если он еще находится в прыжке или падает вниз. Однако эти проблемы, конечно же, решаемые, и о них мы поговорим позже.
  • falling: на эту метку влияет только контроль столкновений класса Level (значение true , когда игрок достигает края платформы...). Здесь нам необходимы некоторые хитрые приемы, чтобы остановить движение игрока, когда он достигнет поверхности другой платформы (или, как минимум, откорректировать ошибки) и так далее.

Метки walking_left и walking_right, так же как и falling в паре с jumping не могут одновременно принимать значение true. Однако falling/jumping и walking_left/walking_right полностью независимы друг от друга и могут принимать значение true одновременно, потому что игрок может прыгать вправо и влево. Сейчас мы рассмотрим методы, которые устанавливают значения true и false для наших логических меток движения и метод playerMove(), который заставляет движение работать на нас.

Устанавливающие методы для walking_left and walking_right довольно просты, и ниже следует их код:

    // Метод, устанавливающий значение метки walking_left
    public void playerWalkLeft(boolean value)
    {
      walking_left = value;
    }

    // Метод, устанавливающий значение метки walking_right
    public void playerWalkRight(boolean value)
    {
      walking_right = value;
    }

В случае с меткой jumping дело обстоит сложнее, так как нам приходится думать о многих дополнительных вещах при установке значения. Прежде всего, игрок может прыгать, только если он уже не падает, то есть значение falling равно false, и если он еще не находится в прыжке, и другая логическая метка - jump_lock - тоже равна false. Еще одна вещь, которую мы будем использовать, это счетчик jump_counter, призванный контролировать длину прыжка. Этот счетчик должен быть сброшен на 0, когда игрок затевает новый прыжок; значения будут такими: jumping = false, jump_lock = false, jump_counter = true. Далее будут более подробные объяснения устройства счетчика. А теперь - код:

// Метод, устанавливающий значение метки jumping
public void playerJump(boolean value)
{

    // сбрасываем счетчик jump_counter, если игрок начинает новый прыжок
    if(!jumping && !jump_lock && value)
    {
      jump_counter = 0;
    }

    // игрок может прыгнуть, только если он уже не падает
    if(falling)
    {
      jumping = false;
    }
    else
    {
      jumping = value;
    }

}

Последний из четырех логических меток - это failing. Здесь важно следующее: если falling установлен на false, т.е. игрок приземляется на платформу, мы прекращаем прыжок (jump_lock и jumping должны получить значение false). Еще одна важная вещь: в некоторых случаях игрок не останавливает движение, касаясь поверхности платформы, а немного ниже, и поэтому кажется, что игрок стоит прямо в платформе. Таким образом, нам нужно подумать над исправлением этих ошибок. Также надо позаботиться и о том, чтобы игрок не мог прыгать и падать одновременно, но это делается в другом месте (в методе playerMove()).

    // Метод, устанавливающий значение метки falling
    public void playerFall(boolean value)
    {
      // мы хотим остановить падение
      if(!value)
      {
        // сбрасываем метки прыжка, чтобы сделать возможным новый прыжок
        if(jump_lock)
        {
          jump_lock = false;
          jumping = false;
        }

        // мы должны корректировать позицию игрока, чтобы он постоянно
        // находился на поверхности платформы. В этом случае нижняя позиция
        // игрока по модулю высоты элемента уровня
        // равна 0. Если это не так, игрок перемещается наверх
        // до нужной кондиции и игрок стоит на поверхности платформы
        while(y_pos_down%C_Jump.level_element_height != 0)
        {
          y_pos_down --;
          y_pos_up--;
        }
      }

      // устанавливаем значение falling
      falling = value;
    }

Движение и анимация игрока

Теперь, вооруженные знаниями о четырех метках движения и о том, как они устанавливаются, мы рассмотрим метод, который действительно двигает игрока в соответствии со значениями меток. Это происходит в методе playerMove(), но перед тем, как мы перейдем к исходному коду, я хотел бы сказать кое-что о некоторых переменных, которые я использовал в методе.

  • step_counter и picture_counter: эти переменные необходимы только для того, чтобы анимировать персонаж. Когда он делает 15 "шагов", и мы хотим поменять картинку с игроком, что означает поменять значение picture_counter, а метод paint() рисует нужную картинку.
  • jump_counter: этот счетчик используется для определения, насколько далеко может прыгнуть игрок и когда прыжок прекращается. Если значение jump_counter достигает определенного максимального значения, значение метки jumping становится равным false.
// Этот метод двигает игрока в соответствии со значениями меток движения
public void playerMove()
{
    // Метка walking_left равна true
    if(walking_left)
    {
      // Изменяем позицию игрока по оси x
      x_pos_left -= walk_x_speed;
      x_pos_right -= walk_x_speed;

      // Изменяем игровую позицию (важно для скроллинга)
      game_x_position -= walk_x_speed;

      // Изменяем картинку игрока через 15 шагов
      if (step_counter%15 == 0)
      {
        // Меняем значение picture_counter
        picture_counter ++;

        // Сбрасываем значение, если значение равно 2 (есть только две картинки)
        if(picture_counter == 2)
        {
          picture_counter = 0;
        }

        // Сбрасываем счетчик шагов
        step_counter = 1;
      }
      // Либо увеличиваем значение счетчика шагов
      else
      {
        step_counter ++;
      }

      // Сообщает, если игрок смотрит влево (важно только для анимации)
      look_left = true;
    }
    // Метка walking_right равна true
    else if(walking_right)
    {
      ... Все то же, что и для walking_left, только в другую сторону...
    }

    // Значение jumping равно true
    if(jumping)
    {
      // Эта переменная существует для того, чтобы избежать повторного прыжка,
      // если игрок уже в полете
      jump_lock = true;

      // Убеждаемся, что значение falling (падение) равно false, потому что контроль столкновений
      // делает его равным true, даже если игрок прыгает
      falling = false;

      // jump_counter все еще меньше 30
      if(jump_counter < 30)
      {
        // игрок прыгает со скоростью 2
        y_pos_up -= jump_y_speed;
        y_pos_down -= jump_y_speed;
        jump_counter ++;
      }
      // Если значение jump_counter больше 30, но меньше 40
      // меняем скорость прыжка на 1
      else if (jump_counter < 40)
      {
        y_pos_up -= jump_y_speed2;
        y_pos_down -= jump_y_speed2;
        jump_counter++;
      }
      // Если значение jump_counter больше 40, игрок не может
      // продолжать взлетать и значение jumping становится равным false
      else
      {
        jumping = false;
      }
    }

    // Если игрок падает, двигаем его вниз
    if(falling)
    {
      y_pos_up += fall_y_speed;
      y_pos_down += fall_y_speed;
    }
}

Надеюсь, что смог показать вам наиболее важные и новые части класса игрока, и перемещение с прорисовкой персонажа больше не являются проблемой. Но, как мне кажется, вам нужно получше изучить исходный код класса, чтобы понять все, потому что этот класс совсем не прост. А теперь давайте перейдем к еще более серьезному классу Level.

Структура и функции класса Level

В этой части туториала мы поговорим о классе Level и связанных с ним классах LevelOne и LevelElement. Вначале мы рассмотрим на "язык определения" уровня платформенной игры и то, как такие определения переводятся во внутреннее представление данных. В основном здесь используются идеи, о которых я уже рассказывал в главе о редакторе уровней, и если что-то там осталось для вас непонятным, перечитайте ее снова. Потом я покажу вам некоторые детали контроля столкновений сежду элементами уровня и игроком. Такие темы, как скроллинг и отображение должны быть уже полностью понятны.

Язык определения и внутренняя структура данных уровня

Одна из основных целей при проектировании нашего класса - позволить легко писать новые уровни и добавлять их в игру. По этой причине мы создаем наши уровни специальным "языком определения", который прост в применении и может использоваться пользователями. Уровни, созданные таким образом, затем переводятся в структуру данных, которая может использоваться компьютером. В нашем специальном языке уровень состоит из 25 рядов, представленных 25 строками. Длина этих строк варьируется, но все они должны быть одинаковыми. Каждый элемент уровня, который мы хотим использовать, реализован как дочерний класс класса LevelElement, имеет уникальный символьный идентификатор, и там, где этот символ имеется в уровне, описанном языком определения, элемент будет сгенерирован во внутренней структуре данных уровня. Синтаксический анализатор в классе Level (метод initializeLevel()) должен быть способен переводить строковые определения во внутренние структуру данных. Эта структура состоит из двумерного массива с нулевыми указателями в местах, где в игре никаких элементов нет, и с экземплярами дочерних классов LevelElement там, где элементы уровня есть. Эта идея хорошо объяснена в главе "редактор уровней".

Определение уровня находится в классе LevelOne. Этот класс используется для определения всех специфичных вещей, типа цвет заднего фона, самого уровня, и так далее, а реальная функциональность реализована в унаследованных методах родительского класса Level. Ниже представлен уровень на языке определения:

/**
Пояснения:
":": представляет позицию в уровне, где никакой элемент не должен быть сгенерирован
"g": представляет позицию в уровне, где должен быть сгенерирован объект ground
*/

// Строковое представление уровня
// строки 1 - 10 опущены, потому что они не содержат никаких важных данных
public static final String row11 = "::::::::::::::::::::::::::::::::::::::::g::";
public static final String row12 = ":::::::::::::::::::::::::::::::::::::::::::";
public static final String row13 = ":::::::::::::::::::::::::::::::::::::::::::";
public static final String row14 = "::::::::::::::::::::::::::::::::::::g::::::";
public static final String row15 = ":::::::::::::::::::::::::::::::::::::::::::";
public static final String row16 = ":::::::::::::::::::::::::::::::::::::::::::";
public static final String row17 = "::::::::::::gggg::::::::::::::::g::::::::::";
public static final String row18 = ":::::::::::::::::::::::::::::::::::::::::::";
public static final String row19 = ":::::::::::::::::::::::gggg::::::::::::::::";
public static final String row20 = ":::::::::::::::::::::::::::::::::::::::::::";
public static final String row21 = "::::::gggg:::::::::::::::::::::::::::::::::";
public static final String row22 = ":::::::::::::::::::::::::::::::::::::::::::";
public static final String row23 = ":::::::::::::::::::::::::::::::::::::::::::";
public static final String row24 = ":::::::::::::::::::::::::::::::::::::::::::";
public static final String row25 = "ggggggggggggggggggggggggggggggggggggggggggg";

Как вы можете видеть, создавать новые уровни таким путем легко (см. также редактор уровней для J-Rio). Но как эти простые уровни на языке определения трансформируются в данные, понятные компьютеру? Это реализуется в методе initializeLevel(String [] строковое определение) класса Level. Этот метод анализирует строковые данные и генерирует из них двумерный массив с элементами уровня. Этот массив в основном используется для контроля столкновений. Все указатели на элементы уровня впоследствии хранятся в одномерном массиве, чтобы сделать скроллинг и отображение уровня более эффективным.

// Метод для анализа строкового определения уровня и генерирования двумерных и одномерных массивов
public void initializeLevel(String [] definitions)
{

    // Объявление массива для столкновений
    collision_array = new LevelElement [C_Jump.number_of_level_lines] [definitions[0].length()];

    // Объявление некоторых атрибутов уровня: длина уровня и края слева и справа
    level_length = definitions[0].length() * C_Jump.level_element_width;
    left_level_border = 0;
    right_level_border = C_Jump.applet_width;

    // Счетчик для учета количества элементов уровня,
    // требуется для объявления одномерного массива
    int elements_counter = 0;

    // Для всех строк определения уровня:
    for(int i=0; i<definitions.length; i++)
    {
      // Генерируем массив текущей строки
      char [] definition_line = definitions[i].toCharArray();

      // Для всех элементов массива:
      for(int j=0; j<definition_line.length; j++)
      {
        // Переводим символы в элементы уровня
        if(definition_line[j] == ':')
        {
          collision_array[i][j] = null;
        }
        // Генерируем элемент Ground
        else if(definition_line[j] == 'g')
        {
          // Важно: позиция в строковом определении (i, j)
          // переводится в конкретную пиксельную позицию
          Ground element = new Ground(j*C_Jump.level_element_width,
            i*C_Jump.level_element_height, ground, parent, C_Jump.ground_id);

          // Сохраняем элемент в массиве столкновений
          collision_array[i][j] = element;

          // Увеличиваем значение счетчика элементов
          elements_counter ++;
        }
      }
    }

    // Копируем указатели элементов уровня в одномерный массив
    // Код опущен за ненадобностью :-)

}

Проверка на столкновения игрока и элементов уровня

Давайте приступим к последней, и, возможно, самой исчерпывающей теме этой главы. Все, о чем мы говорили до этого, важно для контроля столкновений, потому что он тесно связан с контролем движения и внутренней структурой данных игры.
Идея состоит в следующем: единственная вещь, которую нам необходимо сделать - это определить позицию игрока в двумерном массиве, т.е. в какой колонке и ряду находится игрок, и проверить, есть ли в этом месте какой-либо объект (т.е. не нулевой указатель). Если мы обнаружили, что на позиции игрока находится элемент уровня, нам нужно что-то сделать с метками движения игрока, в нашем случае задать им значение false. Как вы помните, у нас есть четыре метки движения, поэтому мы должны тестировать на четыре возможных коллизии: верхнее столкновение (важно при прыжках), нижнее (важно при падении), а также право и левое. Я реализовал несколько методов. Метод testForPlayerCollisions() используется для управления контролем столкновений и решает, что делать, если столкновение произошло. Помимо этого, существует еще четыре специализированных метода ( testCollisionUp , -Down , ...), которые применяются для поиска внутри 2D массива, находится ли на определенной позиции какой-либо объект. Ниже представлен код метода testForPlayerCollisions(Player player), а также метода testCollisionDown (в качестве примера одного из методов, осуществляющих поиск внутри массива на наличие элемента уровня на данной позиции и возвращении этого элемента в вызывающий метод).

// Метод, проверяющий наличие столкновений между игроком и элементами уровня
public void testForPlayerCollisions(Player player)
{

    // Узнаем некоторые специальные значения позиции игрока
    int player_game_pos = player.getGameXPosition();
    int player_down_pos = player.getYPosDown();
    int player_up_pos = player.getYPosUp();

    int player_left = player_game_pos - (C_Jump.player_image_width/2);
    int player_right = player_game_pos + (C_Jump.player_image_width/2);

    // Проверка на столкновения снизу
    LevelElement down_element = testCollisionDown(player_game_pos, player_down_pos);

    // Если под игроком есть элемент, падение прекращается
    if(down_element != null)
    {
      player.playerFall(false);
    }
    // Если возвращается null, падение продолжается
    else
    {
      player.playerFall(true);
    }

    // Проверка на столкновения сверху
    LevelElement upper_element = testCollisionUp(player_game_pos, player_up_pos);

    // Прыжок прекращается при наличии элемента
    if(upper_element != null)
    {
      player.playerJump(false);
    }

    // Проверка на столкновение с левой стороны
    LevelElement left_element = testCollisionLeft(player_left, player_down_pos);

    // Останавливаем движение игрока налево
    if(left_element != null)
    {
      player.playerWalkLeft(false);
    }

    // Проверка на столкновение с правой стороны
    LevelElement right_element = testCollisionRight(player_right, player_down_pos);

    // Останавливаем движение игрока направо
    if(right_element != null)
    {
      player.playerWalkRight(false);
    }
}

// Метод проверяет, есть ли какой-либо элемент на позиции, которой достиг игрок,
// и при наличии он возвращается в вызывающий класс
public LevelElement testCollisionDown(int game_pos, int player_y_down)
{
    // Переводим позицию игрока в позицию в массиве (ряд и колонка)
    int col = game_pos / C_Jump.level_element_width;
    int row = player_y_down / C_Jump.level_element_height;

    try
    {
      // Return element at this position or null
      if(collision_array[row][col] != null)
      {
        return collision_array[row][col];
      }
      else
      {
        return null;
      }
    }
    catch (ArrayIndexOutOfBoundsException ex)
    {
      return null;
    }

}

Вот так!

На этой ноте я хотел бы закончить главу. Хочу верить, что смог помочь вам. Я знаю, что эта глава оказалась не такой простой, как другие главы этого туториала (мне и самому было непросто написать ее), но, как говорится, игра стоит свеч. Также мне хочется узнать, что было наиболее полезным для вас, а что не очень: возможно, некоторые детали нуждаются в более подробном разъяснении. Если не затруднит, напишите мне письмо по этому поводу. А теперь, как уже у нас заведено, можете скачивать исходный код, посмотреть на работающую версию нашего апплета или поиграть в J-Rio - ведь эта игра построена на этих самых принципах. Удачи в разработке собственных платформенных игр!

Скачать исходный код апплета
Запустить апплет

Fabian Birzele, 2001-2003.
перевод и веб-дизайн: В.Мурзагалин, 2004.