Простые приложения на С++
March 2

CRTP в игровых механиках

В мире разработки игр часто возникает необходимость создавать гибкие и производительные системы. Одним из мощных инструментов в C++ для достижения этой цели является CRTP — Curiously Recurring Template Pattern. Сегодня мы разберём, как CRTP может помочь в создании игровых механик, и реализуем простой пример с использованием библиотеки SFML.

Что такое CRTP?

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

Пример структуры CRTP:

template <typename Derived>
class Base {
public:
    void interface() {
        static_cast<Derived*>(this)->implementation();
    }
};

class Derived : public Base<Derived> {
public:
    void implementation() {
        // Реализация в производном классе
    }
};

Зачем использовать CRTP в играх?

  1. Производительность: Виртуальные функции добавляют накладные расходы из-за таблицы vtable. CRTP позволяет обойтись без них.
  2. Гибкость: Легко добавлять новые типы объектов с общей логикой.
  3. Типобезопасность: Компилятор проверяет корректность типов на этапе компиляции.

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

Cистема игровых объектов с SFML

Давайте создадим простую систему игровых сущностей с использованием CRTP и SFML. Мы реализуем базовый класс GameObject, от которого будут наследоваться Player и Enemy. Каждый объект будет обновляться и отрисовываться через SFML.

Перед началом убедитесь, что у вас установлена библиотека SFML.

Минимальные зависимости:

  • sfml-graphics
  • sfml-window
  • sfml-system

Создадим абстрактный базовый класс IGameObject и шаблонный класс с CRTP для реализации общей логики игровых объектов GameObject.

#pragma once
#include <SFML/Graphics.hpp>

// Абстрактный базовый класс для всех игровых объектов
class IGameObject {
public:
    // Виртуальный деструктор для корректного удаления объектов
    virtual ~IGameObject() = default; 
    // Чисто виртуальный метод для обновления состояния объекта
    virtual void update(float deltaTime) = 0; 
    // Чисто виртуальный метод для отрисовки объекта
    virtual void draw(sf::RenderWindow& window) = 0; 
    // Чисто виртуальный метод для получения позиции объекта
    virtual sf::Vector2f getPosition() const = 0; 
};
#pragma once

#include <SFML/Graphics.hpp>
// Шаблонный класс с CRTP для реализации общей логики игровых объектов
template <typename Derived>
class GameObject : public IGameObject {
public:
    // Переопределяем метод update, вызывая реализацию из производного класса
    void update(float deltaTime) override {
        static_cast<Derived*>(this)->updateImpl(deltaTime);
    }

    // Переопределяем метод draw, вызывая реализацию из производного класса
    void draw(sf::RenderWindow& window) override {
        static_cast<Derived*>(this)->drawImpl(window);
    }

    // Переопределяем метод getPosition, вызывая реализацию из производного класса
    sf::Vector2f getPosition() const override {
        return static_cast<const Derived*>(this)->getPositionImpl();
    }
};

В этом коде:

  • update обновляет состояние объекта.
  • draw отвечает за отрисовку.
  • getPosition возвращает позицию объекта.

Методы вызывают соответствующие реализации в производных классах через static_cast.

Теперь определим два игровых объекта: Player (игрок) и Enemy (враг).

#pragma once
#include "GameObject.h"
// Класс игрока, наследующийся от GameObject с использованием CRTP
class Player : public GameObject<Player> {
private:
    // Графический объект — круг для представления игрока
    sf::CircleShape shape; 
    // Позиция игрока на экране
    sf::Vector2f position = sf::Vector2f(200.0f, 200.0f); 
    // Скорость перемещения игрока
    float speed = 200.0f;  

public:
    // Конструктор игрока
    Player() { 
    // Устанавливаем радиус круга
        shape.setRadius(20.0f); 
    // Задаём зелёный цвет
        shape.setFillColor(sf::Color::Green);
    // Устанавливаем позицию круга
        shape.setPosition(position);     
    }

    // Реализация обновления состояния игрока
    void updateImpl(float deltaTime) {
        // Движение влево
        if (sf::Keyboard::isKeyPressed(sf::Keyboard::A)) position.x -= speed * deltaTime; 
        // Движение вправо
        if (sf::Keyboard::isKeyPressed(sf::Keyboard::D)) position.x += speed * deltaTime; 
        // Движение вверх
        if (sf::Keyboard::isKeyPressed(sf::Keyboard::W)) position.y -= speed * deltaTime;
        // Движение вниз
        if (sf::Keyboard::isKeyPressed(sf::Keyboard::S)) position.y += speed * deltaTime; 
        // Обновляем позицию графического объекта
        shape.setPosition(position); 
    }

    // Реализация отрисовки игрока
    void drawImpl(sf::RenderWindow& window) const {
    // Отрисовываем круг в окне
        window.draw(shape); 
    }

    // Реализация получения позиции игрока
    sf::Vector2f getPositionImpl() const {
    // Возвращаем текущую позицию
        return position; 
    }
};
#pragma once  
#include "GameObject.h"  
// Класс врага, наследующийся от GameObject с использованием CRTP  
class Enemy : public GameObject<Enemy> {  
private:  
// Графический объект — прямоугольник для врага
    sf::RectangleShape shape;
// Позиция врага на экране
   sf::Vector2f position = {400.0f, 300.0f}; 
// Скорость перемещения врага  
   float speed = 100.0f;     

public:
// Конструктор врага
   Enemy() { 
// Устанавливаем размер прямоугольника 
       shape.setSize({ 30.0f, 30.0f });
// Задаём красный цвет 
       shape.setFillColor(sf::Color::Red); 
// Устанавливаем позицию прямоугольника
       shape.setPosition(position);           
   }  

   // Реализация обновления состояния врага  
   void updateImpl(float deltaTime) {  
// Движение врага по горизонтали  
       position.x += speed * deltaTime;
// Разворот при достижении границ  
       if (position.x > 700 || position.x < 100) speed = -speed; 
// Обновляем позицию графического объекта 
       shape.setPosition(position);  
   }  

   // Реализация отрисовки врага  
   void drawImpl(sf::RenderWindow& window) const { 
// Отрисовываем прямоугольник в окне
       window.draw(shape);   
   }  

   // Реализация получения позиции врага  
   sf::Vector2f getPositionImpl() const { 
// Возвращаем текущую позицию 
       return position;  
   }  
};

Теперь соберём всё вместе в главном файле.

#include <SFML/Graphics.hpp> 
#include <vector>            
#include <memory>            
#include "IGameObject.h"     
#include "GameObject.h"      
#include "Player.h"          
#include "Enemy.h"           

// Основная функция программы
int main() {
// Создаём окно 800x600
    sf::RenderWindow window(sf::VideoMode(800, 600), "CRTP Game Example"); 
// Ограничиваем частоту кадров до 60 FPS
    window.setFramerateLimit(60); 

    // Вектор умных указателей для хранения всех игровых объектов
    std::vector<std::unique_ptr<IGameObject>> objects;
// Добавляем игрока в вектор
    objects.push_back(std::make_unique<Player>()); 
// Добавляем врага в вектор
    objects.push_back(std::make_unique<Enemy>());  
// Часы для расчёта времени между кадрами
    sf::Clock clock; 
// Главный игровой цикл
    while (window.isOpen()) { 
        sf::Event event;
// Обрабатываем события
        while (window.pollEvent(event)) { 
// Закрываем окно при нажатии на крестик
            if (event.type == sf::Event::Closed) window.close(); 
        }
// Вычисляем время с последнего кадра
        float deltaTime = clock.restart().asSeconds(); 

        // Обновляем состояние всех объектов
        for (auto const& obj : objects) {
            obj->update(deltaTime);
        }
// Очищаем окно перед отрисовкой
        window.clear(); 
        // Отрисовываем все объекты
        for (auto const& obj : objects) {
            obj->draw(window);
        }
        // Отображаем нарисованное на экране
        window.display(); 
    }

    return 0; 
}

Как это работает?

Player управляется клавишами W, A, S, D и отображается как зелёный круг.

Enemy движется автоматически влево-вправо и отображается как красный квадрат.

Базовый класс GameObject через CRTP вызывает методы конкретных классов (Player или Enemy) без виртуальных функций.

Преимущества подхода

Скорость: Отсутствие vtable ускоряет вызовы методов.

Расширяемость: Чтобы добавить новый тип объекта (например, Projectile), достаточно унаследовать его от GameObject<Projectile> и реализовать нужные методы.

Простота: Логика обновления и отрисовки сосредоточена в одном месте.

Итог

CRTP — это мощный инструмент для игровых движков, где важны производительность и гибкость. В примере с SFML мы создали систему объектов, которую легко расширить без потери скорости. Попробуйте добавить новые сущности вроде снарядов или бонусов — это займёт всего несколько строк кода!

vtable (от англ. virtual table) — это механизм в C++, который используется для реализации динамического полиморфизма через виртуальные функции. Когда вы объявляете функцию в базовом классе как virtual, компилятор создаёт специальную таблицу указателей (vtable) для каждого класса, который содержит виртуальные функции. Эта таблица хранит адреса реализаций виртуальных методов для конкретного класса.

Телеграмм канал - Программирование игр С++