libGDX: Часть 2. Архитектура игры

После рассмотрения жизненного цикла игры сразу стоит рассмотреть архитектуру (каркас). Вообще Роллингс и Моррис (Rollings and Dave Morris) в своей книге «Game Architecture and Design» подробно описывают создание игр с точки зрения архитектуры. В своё время я правда не особо проникся этой книгой, но вам может понравится. Я же опишу архитектуру, которую стараюсь использовать сам.

Разбиение приложения на компоненты со слабым связыванием — это не просто какой-то идеологических ход, такой подход действительно очень упрощает разработку. В частности, я предлагаю использовать заезженный паттерн — MVC. Часть материала брал с занятного сайта http://obviam.net/. Там вообще очень много полезной информации для разработчиков игр.

MVC

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

MVC

Примерно как всё в играх происходит? Игрок производит какую-то манипуляцию:

  1. Игрок нажимает на экран (или на клавиатуру).
  2. В controller обрабатывается нажатие. Здесь же по сути вся логика реализована: проверка на препятствия, отслеживание состояний объектов, изменение их состояний и т.д.
  3. То есть, controller изменяет состояние объектов (model‘s).
  4. После чего объекты отрисовываются (view).

MVC очень удачно подходит. Если ещё не поняли, поясню кое-какие моменты. Объекты (Model) абсолютно ничего не знают про рендеринг. Многие пишут, что объекты не должны и состояние менять сами, а за них это должен делать контроллер. Я к этому вопросы подошёл практически. Возьмите, к примеру, вашего персонажа, которому надо как-то описать логику обхода препятствий. Большинство скажет, что в контроллере сие дело надо реализовывать. Но почему? Ведь, когда вы идёте по улице, то обходите препятствие после собственных расчётов, а не мир или контроллер просчитывает это дело. Так что, часть логики взаимодействия с миром я бы посоветовал именно в сами объекты добавлять.

Я имею ввиду именно живые объекты (если можно так сказать про виртуальных персонажей (: ). Логику неодушевлённых предметов можно и в контроллере делать. Перейдём к практической части. За основу берём проект из введения.

Игровые компоненты

В данной статье покажу как разбить на части приложение. Немного про объекты мира расскажу.

Создание мира и его объектов

Добавьте к проекту package suvitruf.libgdxtutorial.model. Здесь у нас будут объекты мира. Добавьте в этот пакет класс Brick и зададим базовые свойства:

package suvitruf.libgdxtutorial.model;

//импорт нужных либ
import com.badlogic.gdx.math.Rectangle;
import com.badlogic.gdx.math.Vector2;

public class Brick {
  //размер объекта
  static final float SIZE = 1f;
  //координаты
  Vector2 position = new Vector2();
  Rectangle bounds = new Rectangle();
	
  public Brick(Vector2 pos) {
    this.position = pos;
    this.bounds.width = SIZE;
    this.bounds.height = SIZE;
  }
  
  public Rectangle getBounds() {
    return bounds;
  }

  public Vector2 getPosition() {
    return position;
  }
}

У блока нет никакой логики, он представляет собой…ммм…просто кирпич. Он ни с чем не взаимодействует, но с ним могут взаимодействовать живые объекты. Мы используем тип Vector2 от libgdx. Это позволяет нам работать лишь с Евклидовыми векторами. Мы будем использовать векторы для позиционирования, вычисления скорости и для движения (ну да, кирпич не двигается…но наш персонаж будет).

Далее добавим класс (добавьте класс Player к пакету suvitruf.libgdxtutorial.model), который будет являть собой нашего персонажа.

package suvitruf.libgdxtutorial.model;

import com.badlogic.gdx.math.Rectangle;
import com.badlogic.gdx.math.Vector2;

public class Player {

  //состояние
  public enum State {
    NONE, WALKING, DEAD
  }

 
  //скорость движения
  public static final float SPEED = 2f;
  //размер	
  public static final float SIZE = 0.7f; 
	
  //позиция в мире
  Vector2 position = new Vector2();
  //используется для вычисления движения
  Vector2 velocity = new Vector2();
  //прямоугольник, в который вписан игрок
  //будет использоваться в будущем для нахождения коллизий (столкновение со стенкой и т.д.
  Rectangle 	bounds = new Rectangle();
  //текущее состояние
  State		state = State.NONE;

  public Player(Vector2 position) {		
    this.position = position;
    this.bounds.height = SIZE;
    this.bounds.width = SIZE;
  }

  public Rectangle getBounds() {
    return bounds;
  }

  public Vector2 getVelocity() {
    return velocity;
  }

  public Vector2 getPosition() {
    return position;
  }

  //обновления движения
  public void update(float delta) {
    position.add(velocity.tmp().mul(delta)); 
  }
}

Теперь нам нужно создать мир, в котором будут все эти объекты. Добавляем в пакет suvitruf.libgdxtutorial.model класс World. Мир условно делится на клетки. К примеру создадим мир 8×5.

package suvitruf.libgdxtutorial.model;


import com.badlogic.gdx.math.Vector2;
import com.badlogic.gdx.utils.Array;

public class World {
  //массив блоков
  Array<Brick> bricks = new Array<Brick>();
  //наш персонаж
  public Player player;
	
  //ширина мира
  public int width;
  //высота мира
  public int height;
	
  //получить массив блоков
  public Array<Brick> getBricks() {
    return bricks;
  }
  //получить игрока
  public Player getPlayer() {
    return player;
  }
	
  public World() {
    width = 8;
    height = 5;
    createWorld();
  }

  //создадим тестовый мир какой-нибудь
  public void createWorld() {
    player = new Player(new Vector2(6,2));
    bricks.add(new Brick(new Vector2(0, 0)));
    bricks.add(new Brick(new Vector2(1, 0)));
    bricks.add(new Brick(new Vector2(2, 0)));
    bricks.add(new Brick(new Vector2(3, 0)));
    bricks.add(new Brick(new Vector2(4, 0)));
    bricks.add(new Brick(new Vector2(5, 0)));
    bricks.add(new Brick(new Vector2(6, 0)));
    bricks.add(new Brick(new Vector2(7, 0)));
  
		
  }
}

World — модель мира. По сути он является контейнером для объектов, что логично (:

Контроллер

Создадим пакет suvitruf.libgdxtutorial.controller и добавим в него класс WorldController. В этом классе как раз и будет реализована логика вся. По идеи в контроллере производятся изменения состояний объектов мира. И главное, в зависимости от действий юзера будут манипуляции с объектом Player.

package suvitruf.libgdxtutorial.controller;

import java.util.HashMap;
import java.util.Map;
import suvitruf.libgdxtutorial.model.*;

public class WorldController {

  //направление движения
  enum Keys {
    LEFT, RIGHT, UP, DOWN
  }
  //игрок
  public Player player;
	
  //куда движемся...игрок может двигаться одновременно по 2-м направлениям
  static Map<Keys, Boolean> keys = new HashMap<WorldController.Keys, Boolean>();

  //первоначально стоим
  static {
    keys.put(Keys.LEFT, false);
    keys.put(Keys.RIGHT, false);
    keys.put(Keys.UP, false);
    keys.put(Keys.DOWN, false);
  };

  public WorldController(World world) {
    this.player = world.getPlayer();
  }
  
  //флаг устанавливаем, что движемся влево
  public void leftPressed() {
    keys.get(keys.put(Keys.LEFT, true));
  }

  //флаг устанавливаем, что движемся вправо	
  public void rightPressed() {
    keys.get(keys.put(Keys.RIGHT, true));
  }
  
  //флаг устанавливаем, что движемся вверх	
  public void upPressed() {
    keys.get(keys.put(Keys.UP, true));
  }
	
  //флаг устанавливаем, что движемся вниз
  public void downPressed() {
    keys.get(keys.put(Keys.DOWN, true));
  }
	
  //освобождаем флаги
  public void leftReleased() {
    keys.get(keys.put(Keys.LEFT, false));
  }	
  public void rightReleased() {
    keys.get(keys.put(Keys.RIGHT, false));
  }	
  public void upReleased() {
    keys.get(keys.put(Keys.UP, false));
  }	
  public void downReleased() {
    keys.get(keys.put(Keys.DOWN, false));
  }
	
  //главный метод класса...обновляем состояния объектов здесь
  public void update(float delta) {
    processInput();
    player.update(delta);
  }

  public void resetWay(){
    rightReleased();
    leftReleased();
    downReleased();
    upReleased();
  }

  //в зависимости от выбранного направления движения выставляем новое направление движения для персонажа
  private void processInput() {
    if (keys.get(Keys.LEFT)) 
      player.getVelocity().x = -Player.SPEED;

    if (keys.get(Keys.RIGHT))
      player.getVelocity().x =Player.SPEED;	
		
    if (keys.get(Keys.UP)) 
      player.getVelocity().y = Player.SPEED;
		
		
    if (keys.get(Keys.DOWN))
      player.getVelocity().y = -Player.SPEED;

    //если не выбрано направление, то стоим на месте
    if ((keys.get(Keys.LEFT) && keys.get(Keys.RIGHT)) || (!keys.get(Keys.LEFT) && (!keys.get(Keys.RIGHT)))) 
      player.getVelocity().x = 0;					
    if ((keys.get(Keys.UP) && keys.get(Keys.DOWN)) || (!keys.get(Keys.UP) && (!keys.get(Keys.DOWN)))) 
      player.getVelocity().y = 0;			
		
  }
}

Рендеринг

И так, про контроллер и объекты мира поговорили. Теперь нужно отрисовать объекты наши. Для этого создадим пакет suvitruf.libgdxtutorial.view, а в нём класс WorldRenderer.

package suvitruf.libgdxtutorial.view;

import suvitruf.libgdxtutorial.model.*;
import com.badlogic.gdx.graphics.Color;
import com.badlogic.gdx.graphics.OrthographicCamera;
import com.badlogic.gdx.graphics.glutils.ShapeRenderer;
import com.badlogic.gdx.graphics.glutils.ShapeRenderer.ShapeType;
import com.badlogic.gdx.math.Rectangle;

public class WorldRenderer {
  public static float CAMERA_WIDTH = 8f;
  public static  float CAMERA_HEIGHT = 5f;
	
  private World world;
  public OrthographicCamera cam;
  ShapeRenderer renderer = new ShapeRenderer();

	
  public int width;
  public int height;
  public float ppuX;	// пикселей на точку мира по X 
  public float ppuY;	// пикселей на точку мира по Y 
  	
  public void setSize (int w, int h) {
    this.width = w;
    this.height = h;  
    ppuX = (float)width / CAMERA_WIDTH;
    ppuY = (float)height / CAMERA_HEIGHT;
  }
  //установка камеры
  public void SetCamera(float x, float y){
    this.cam.position.set(x, y,0);	
    this.cam.update();
  }
	
  public WorldRenderer(World world) {
		
    this.world = world;
    this.cam = new OrthographicCamera(CAMERA_WIDTH, CAMERA_HEIGHT);
    //устанавливаем камеру по центру
    SetCamera(CAMERA_WIDTH / 2f, CAMERA_HEIGHT / 2f);

  }
  
  //основной метод, здесь мы отрисовываем все объекты мира
  public void render() {
    drawBricks();
    drawPlayer() ;
		
  }

  //отрисовка кирпичей
  private void drawBricks() {
    renderer.setProjectionMatrix(cam.combined);
    //тип устанавливаем...а данном случае с заливкой
    renderer.begin(ShapeType.FilledRectangle);
    //прогоняем блоки
    for (Brick brick : world.getBricks()) {
      Rectangle rect =  brick.getBounds();
      float x1 =  brick.getPosition().x + rect.x;
      float y1 =  brick.getPosition().y + rect.y;
      renderer.setColor(new Color(0, 0, 0, 1));
      //и рисуем блоки
      renderer.filledRect(x1, y1, rect.width, rect.height);
    }
		
    renderer.end();
  }

  //отрисовка персонажа по аналогии
  private void drawPlayer() {
    renderer.setProjectionMatrix(cam.combined);
    Player player = world.getPlayer();
    renderer.begin(ShapeType.Rectangle);
		
    Rectangle rect = player.getBounds();
    float x1 = player.getPosition().x + rect.x;
    float y1 = player.getPosition().y + rect.y;
    renderer.setColor(new Color(1, 0, 0, 1));
    renderer.rect(x1, y1, rect.width, rect.height);
    renderer.end();
  }
	
}

ppuX и ppuY очень важны…Ведь мир у нас 8на5, а экран телефона в пикслеях не соответствует этим размерам. Поэтому нужны эти переменные, которые при рендеринге будут корректировать координаты объектов для вывода на реальный экран телефона.

OrthographicCamera cam — камера, которая используется для того, чтобы «посмотреть» на мир. В текущем примере мир очень маленький, и он влезает в камеру, но когда у нас будет большой уровень, и персонаж будет перемещается в нем, мы должны будем менять положение камеры. Собственно, там и расчёт координат изменится…В будущих статьях остановлюсь на этом.

GameScreen

Теперь осталось лишь связать все наши компоненты вместе. Для этого создадим пакет suvitruf.libgdxtutorial.screens, а в нём класс GameScreen.

GameScreen реализует интерфейс Screen, который очень походит на ApplicationListener, но у этого есть 2 важных ключевых отличия (два метода). show() – вызывается, когда становится активным. hide()– вызывается, когда активным становится другой экран.

package suvitruf.libgdxtutorial.screens;
import suvitruf.libgdxtutorial.model.*;
import suvitruf.libgdxtutorial.controller.*;
import suvitruf.libgdxtutorial.view.*;

import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.InputProcessor;
import com.badlogic.gdx.Screen;
import com.badlogic.gdx.Application.ApplicationType;

import com.badlogic.gdx.graphics.GL10;

public class GameScreen implements Screen, InputProcessor {
	private World 			world;
	private WorldRenderer 	renderer;
	private WorldController	controller;

	private int width, height;
	
	@Override
	public void show() {

		world = new World();
		renderer = new WorldRenderer(world);
		controller = new WorldController(world);
		Gdx.input.setInputProcessor(this);

	}
	@Override
	public boolean touchDragged(int x, int y, int pointer) {
		ChangeNavigation(x,y);
		return false;
	}

	
	public boolean touchMoved(int x, int y) {
		return false;
	}

	@Override
	public boolean mouseMoved(int x, int y) {
		return false;
	}
	
	@Override
	public boolean keyTyped(char character) {
		return false;
	}
	
	@Override
	public void resize(int width, int height) {
		renderer.setSize(width, height);
		this.width = width;
		this.height = height;
	}

	@Override
	public void hide() {
		Gdx.input.setInputProcessor(null);
	}

	@Override
	public void pause() {
	}

	@Override
	public void resume() {
	}

	@Override
	public void dispose() {		
		Gdx.input.setInputProcessor(null);
	}


	@Override
	public boolean keyDown(int keycode) {
		
		return true;
	}

	@Override
	public void render(float delta) {

		Gdx.gl.glClearColor(1, 1, 1, 1);
		Gdx.gl.glClear(GL10.GL_COLOR_BUFFER_BIT);

		controller.update(delta);
		renderer.render();
	}
	@Override
	public boolean keyUp(int keycode) {

		return true;
	}
	
	private void ChangeNavigation(int x, int y){
		controller.resetWay();
		if(height-y >  controller.player.getPosition().y * renderer.ppuY)
			controller.upPressed();
		
		if(height-y <  controller.player.getPosition().y * renderer.ppuY)
			controller.downPressed();
		
		if ( x< controller.player.getPosition().x * renderer.ppuX) 
			controller.leftPressed();
			
		if (x> (controller.player.getPosition().x +Player.SIZE)* renderer.ppuX)
			controller.rightPressed();
			
	}
	
	@Override
	public boolean touchDown(int x, int y, int pointer, int button) {

		if (!Gdx.app.getType().equals(ApplicationType.Android))
			return false;
		ChangeNavigation(x,y);
		return true;
	} 
	
	@Override
	public boolean touchUp(int x, int y, int pointer, int button) {
		if (!Gdx.app.getType().equals(ApplicationType.Android))
			return false;
		controller.resetWay();
		
		return true;
	}
	
	@Override
	public boolean scrolled(int amount) {
		return false;
	}
}

Этот класс отзывается на действия юзера. По названиям методов, думаю, понятно всё. В ChangeNavigation() мы определяем и устанавливаем направление движения персонажа. В данном примере, если вы кликаете левее игрока, то двигаетесь влево, если вверх то вверх и т.д.

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

libgdxlesson2

Собственно всё. Можете скачать исходники урока Libgdxtutorial-lesson2.rar.