CRTP в игровых механиках
В мире разработки игр часто возникает необходимость создавать гибкие и производительные системы. Одним из мощных инструментов в C++ для достижения этой цели является CRTP — Curiously Recurring Template Pattern. Сегодня мы разберём, как CRTP может помочь в создании игровых механик, и реализуем простой пример с использованием библиотеки SFML.
Что такое CRTP?
CRTP — это шаблонный паттерн в C++, где базовый класс является шаблоном, а производный класс передаётся в качестве параметра шаблона самому себе. Это позволяет базовому классу вызывать методы производного класса без использования виртуальных функций, что даёт выигрыш в производительности за счёт статической диспетчеризации.
template <typename Derived> class Base { public: void interface() { static_cast<Derived*>(this)->implementation(); } }; class Derived : public Base<Derived> { public: void implementation() { // Реализация в производном классе } };
Зачем использовать CRTP в играх?
- Производительность: Виртуальные функции добавляют накладные расходы из-за таблицы vtable. CRTP позволяет обойтись без них.
- Гибкость: Легко добавлять новые типы объектов с общей логикой.
- Типобезопасность: Компилятор проверяет корректность типов на этапе компиляции.
В игровых механиках это особенно полезно для систем вроде управления объектами (игроки, враги, снаряды), где нужно минимизировать задержки и упростить расширение.
Cистема игровых объектов с SFML
Давайте создадим простую систему игровых сущностей с использованием CRTP и SFML. Мы реализуем базовый класс GameObject, от которого будут наследоваться Player и Enemy. Каждый объект будет обновляться и отрисовываться через SFML.
Перед началом убедитесь, что у вас установлена библиотека SFML.
Создадим абстрактный базовый класс 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) для каждого класса, который содержит виртуальные функции. Эта таблица хранит адреса реализаций виртуальных методов для конкретного класса.