Компьютерная графика → Примитивный игровой движок на Java

Чтобы понять как работают игровые движки и сами игры, давайте попробуем сами написать простой 2D движок. 

Во всех играх есть основной игровой цикл, который непрерывно повторяется:

1. Чтение устройств ввода (клава, мышь, джойсткик, таймер)
2. Обновление игровых объектов
3. Отрисовка

И этот цикл повторяется пока игра запущена. FPS (frames per second) - сколько кадров в секунду отрисовал игровой движок или сколько циклов сделал данный цикл.

Для начала создадим класс Game, который будет основным. В нем же будет точка входа (метод main) в программу. 

public class Game {

    public static void main(String[] args) {
        Game game = new Game();
        game.start();
    }

    public void start(){

    }
}

Здесь мы создали один объект класса Game и вызвали метод start(). В этом методе мы должны запустить игровой цикл. 

Нам нужно окно для игры и панель, где мы будем рисовать саму игру. Создайте новый класс GameCanvas, который наслуедует от JPanel в новом файле:

import javax.swing.*;
import java.awt.*;

public class GameCanvas extends JPanel {
    private final Game game;

    public GameCanvas(Game game){
        this.game = game;
    }
}

Класс GameCanvas хранит при себе ссылку на объект Game, который ему передается при создании. Теперь, в классе Game создаем метод createWindow(), который создаст окно и панель GameCanvas. 

Сначала объявляем свойства window и gameCanvas:

public class Game {
    private JFrame window;
    private GameCanvas gameCanvas;

Затем создаем метод createWindow() и вызываем его в методе start():

public void start(){
    createWindow();
}

public void createWindow(){
    gameCanvas = new GameCanvas(this);

    window = new JFrame("Game");
    window.setSize(500, 500);
    window.setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE);
    window.setContentPane(gameCanvas);
    window.setVisible(true);
}

Если сейчас вы запустите класс Game, то у вас будет пустое окно. Идем дальше.

Игровой цикл

Игровой цикл будет у нас движим таймером, который мы запустим в методе startTimer():

private void startTimer() {
    Timer timer = new Timer(50, new AbstractAction(){
        @Override
        public void actionPerformed(ActionEvent e) {
            tick();
        }
    });
    timer.start();
}

private void tick(){

}

Убедитесь что класс Timer импортирован с пакета javax.swing, а не с пакета java.util:

import javax.swing.*;

Как вы видите в коде, таймер выполняется через каждые 50 миллисекунд и каждый раз запускает метод tick(). Метод tick() пока пустой, но в нем мы будем обновлять состояние игровых объектов и перерисовавыть экран. Теперь нужно вызвать метод startTimer() в методе start():

public void start(){
    createWindow();
    startTimer();
}

 

Игровые объекты

Любая игра состоит из объектов, с которыми так или иначе взаимодействует игрок и которые как-то отображается на экране. Конечно, есть объекты которые не отображаются, но отвечают за что-то.  Эти объекты называются игровыми объектами.

У нас в движке будет основной класс GameObject, который будет иметь основные свойства игрового объекта и от которого будут наследоваться все другие игровые объекты. 

Каждый игровой объект должен иметь ссылку на Игру, в которой он сейчас находится. И у каждого объекта есть позиция на экране. Так как у нас движок  2D, то у нас позиция это две координаты x и y. Как принято во всех игровых движках будем использовать класс Vector2, для хранения двух координат.

Создаем новый класс Vector2:

public class Vector2 {
    public int x, y;

    public Vector2(int x, int y){
        this.x = x;
        this.y = y;
    }
}

И создаем класс GameObject:

import java.awt.*;

public class GameObject {
    protected final Game game;
    public Vector2 position = new Vector2(0, 0);

    public GameObject(Game game){
        this.game = game;
    }
}

По умолчанию позиция у всех объектов 0,0.

Давайте сразу в класс GameObject добавим два метода, которые потом нам будут нужны:

   * Метод draw() - метод будет вызываться, когда нужно отрисовать игровой объект на экран. Каждый тип игрового объекта по своему будет отрисовывать себя.
   * Метод update() - метод будет вызываться, когда объекту следует проверить состояние игры, ввод, таймер и обновить свое состояние.

B итоге класс у нас такой:

import java.awt.*;

public class GameObject {
    protected final Game game;
    public Vector2 position = new Vector2(0, 0);

    public GameObject(Game game){
        this.game = game;
    }

    public void update(){

    }

    public void draw(Graphics g){

    }
}

Теперь переходим в класс Game. В любой игре игровых объектов может быть сколько угодно. Поэтому, нам нужно где-то держать все эти объекты. Будем держать их всех в классе Game, в свойства gameObjects типа Set, так как порядок объектов нам не важен: 
 

public class Game {
    private JFrame window;
    private GameCanvas gameCanvas;
    public Set<GameObject> gameObjects = new HashSet<>();

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

Допустим что у нас уже есть много игровых объектов. Нам нужно их при каждом игровом цикле(tick) обновлять и перерисовывать. Давайте так и сделаем. Откроем метод tick() в классе Game:

private void tick(){
    for(GameObject gameObject: gameObjects){
        gameObject.update();
    }
    gameCanvas.repaint();
}

Здесь мы обходим в цикле все игровые объекты и вызываем у них метод update(), чтобы они обновили свое состояние. Затем мы перерисовываем gameCanvas. Но пока класс GameCanvas у нас ничего не умеет рисовать. Нам нужно сделать так, чтобы GameCanvas умел отрисовывать все игровые объекты.

Откройте класс GameCanvas и в методе paint() для каждого игрового объекта нужно вызовите метод draw():

import javax.swing.*;
import java.awt.*;

public class GameCanvas extends JPanel {
    private final Game game;

    public GameCanvas(Game game){
        this.game = game;
    }

    @Override
    public void paint(Graphics g) {
        super.paint(g);
        for(GameObject gameObject: game.gameObjects){
            gameObject.draw(g);
        }
    }
}

Корабль

Давайте добавим первый номральный игровой объект. Это будет примитивный корабль, которые будет выглядеть как красный треугольник высотой в 70 пикселей и шириной в 50.

Создайте класс Ship, который наследуется от GameObject:

import java.awt.*;

public class Ship extends GameObject {
    public Ship(Game game) { 
        super(game);
    }

    @Override
    public void draw(Graphics g) {
        super.draw(g);

        int x = position.x;
        int y = position.y;

        g.setColor(Color.RED);
        g.drawLine(x-25, y, x+25, y);
        g.drawLine(x-25, y, x, y-70);
        g.drawLine(x+25, y, x, y-70);
    }
}

Так, как он наследуется от GameObject у него есть свойства game и position, которые мы можем использовать для отрисовки корабля. Как видно из кода, мы нарисовали три линии, относительно текущей координаты игрового объекта. Основной точкой корабля мы взяли нижнюю среднюю точку. 

Теперь в классе Game нам нужно создать корабль и добавить его в игровые объекты текущей игры. Создаем метод createInitialGameObjects() и вызываем его в методе start():
 

public void start(){
    createWindow();
    createInitialGameObjects();
    startTimer();
}

private void createInitialGameObjects() {
    Ship ship = new Ship(this);
    ship.position = new Vector2(250, 450);
    gameObjects.add(ship);
}

Если вы сейчас запустите класс Game, то увидите красный треугольник внизу экрана. Это наш корабль!!!

Давайте добавим возможность двигать кораблем.

Ввод и управление

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

Мы тоже будем хранить множество всех нажатых клавиш в классе Game в свойстве pressedKeys. Множество - потому что одновременно нажатых клавиш может быть несколько. Объвляем:

public class Game {
    private JFrame window;
    private GameCanvas gameCanvas;
    public Set<GameObject> gameObjects = new HashSet<>();
    public Set<Integer> pressedKeys = new HashSet<>();

У каждой клавиши на компе есть свой код типа Integer, мы будем их использовать. 

Теперь нам нужно добавить обработчик клавиатуры. Создадим метод initKeyListeners() и возовем его методе start():
 

public void start(){
    createWindow();
    initKeyListeners();
    createInitialGameObjects();
    startTimer();
}

private void initKeyListeners(){
    window.addKeyListener(new KeyAdapter() {
        @Override
        public void keyTyped(KeyEvent e) {

        }

        @Override
        public void keyPressed(KeyEvent e) {
            pressedKeys.add(e.getKeyCode());
        }

        @Override
        public void keyReleased(KeyEvent e) {
            pressedKeys.remove(e.getKeyCode());
        }
    });
}

Метод keyPressed вызывается когда клавиша зажимается, а метод keyReleased когда клавиша отпускается. Поэтому при зажатии мы добавляем код клавиши в множество, а при отпуске удаляем из множества. Таким образом всегда во множество pressedKeys у нас будут только зажатые в текущий момент клавиши. 

Теперь нам нужно двигать кораблем на клавиши влево и вправо. У клавиши "<-" код 37, а у клавиши "->" код 39, будем эти коды использовать, чтобы проверять зажаты ли стрелки. 

Откройте класс Ship и переопределите метод update() так:
 

@Override
public void update() {
    super.update();
    if(game.pressedKeys.contains(37)){
        position.x -= 10;
    }
    if(game.pressedKeys.contains(39)){
        position.x += 10;
    }
}

Как вы видите, если в нажатых клавишах есть код 37, мы меняем позицию корабля по X на -10, а если код 39, то на +10. Т.е. мы перемещаем корабль влево и вправо. Запустите класс Game и попробуйте нажать на стрелки вправо и влево, корабль должен двигаться. Разве не круто?! 

Стрельба

Щас будем палить из всех оружий корабля. Давайте сделаем так, чтобы при нажатии на клавишу "Пробел", корабль выпускал пулю? Пуля тоже отдельный игровой объект, так как у него свои логика работы и внешний вид. Поэтому создаем еще один класс наследующий GameObject - класс Shot:

import java.awt.*;

public class Shot extends GameObject{

    public Shot(Game game) {
        super(game);
    }

    @Override
    public void draw(Graphics g) {
        super.draw(g);
        g.fillRect(position.x, position.y, 2, 5);
    }

    @Override
    public void update() {
        super.update();
        position.y -= 10;
    }
}

Класс Shot будет представлять пулю корабля. Он унаследовал свойство position от GameObject. В методе draw() мы рисуем небольшой прямоугольник. Так у нас будет выглядеть пуля. А в методе update() мы будем уменьшать позицию объекта по на -10, чтобы пуля поднималась вверх постоянно. Помните когда вызывается метод update() ? При каждом тике, т.е. постоянно через каждые 50 миллисекунд каждая пуля будет подниматься вверх на 10 пикселей. 

Теперь нам нужно при нажатии на пробел, создавать новый объект пули и добавлять его и игровые объекты нашей игры. Откройте класс Ship и добавьте в методе update следующий код:

if(game.pressedKeys.contains(32)){
    Shot shot = new Shot(game);
    shot.position = new Vector2(position.x, position.y - 70);
    game.newGameObjects.add(shot);
}

32 - код пробела. Создаем пулю, указываем его позиция выше текущей позиции кораблю. И добавляем в newGameObjects. Это еще одна переменная, которую нужно создать в классе Game:

public class Game {
    private JFrame window;
    private GameCanvas gameCanvas;
    public Set<GameObject> gameObjects = new HashSet<>();
    public Set<GameObject> newGameObjects = new HashSet<>();
    public Set<Integer> pressedKeys = new HashSet<>();

newGameObjects - это новые объекты, которые нужно добавить в gameObjects при следующем цикле. Обновляем метод tick():

private void tick(){
    gameObjects.addAll(newGameObjects);
    newGameObjects.clear();
    for(GameObject gameObject: gameObjects){
        gameObject.update();
    }
    gameCanvas.repaint();
}

Теперь при нажатии на клавишу "Пробел" корабль создает игровой объект пули. Затем пуля уже сама по себе обновляется и отрисовывается как отдельный игровой объект. Запустите программу и постреляйте. 

Текстуры

Текстура - это уже нарисованное изображение, которое накладывается на игровые объекты.

Давайте в конце попробуем вместе треугольника, показывать изображение корабля. Например: 

Скачайте эту картинку и скопируйте в корень проекта. Затем откройте класс Ship. Картинку будем хранить в переменной texture типа BufferedImage и в конструкторе класса Ship считаем картинку из файла ship.png:
 

public class Ship extends GameObject {
    private BufferedImage texture;

    public Ship(Game game) {
        super(game);
        try {
            texture = ImageIO.read(new File("ship.png"));
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

Теперь в методе draw() класса Ship,вместо треугольника будем рисовать эту текстуру:

@Override
public void draw(Graphics g) {
    super.draw(g);
    g.drawImage(texture, position.x - 25, position.y - 70, null);
}

Запустите игру и посмотрите на ваш красивый корабль!

Задание

Надеюсь после этого урока, вы поняли как примерно работают игровые движки. 

Каждому из вас нужно ровно 100 раз повторить этот урок, каждый раз измеряя время выполнения. Вы должны уметь его написать закрытыми глазами в самом конце. Записывайте время каждого выполнения по порядку. Потом покажете мне и на следующей паре каждый из вас при мне должен написать эту игру. 

Удачи!

1664 0
Alisher Alikulov