- Платформа игры
- Gothic ½
- Автор(ы)
-
@Gratt
Общий смысл виртуальной таблицы (vtable)
Виртуальные таблицы (или vtable) нужны для того, чтобы при вызове виртуальной функции программа могла понять, какую именно реализацию этой функции нужно вызвать — от базового класса или от производного. Этот процесс происходит в два шага:
Теперь разберёмся подробнее, как формируется таблица. В примере ниже — базовый класс с пятью виртуальными методами. Таблица будет содержать пять адресов функций, отсортированных по порядку объявления.
Когда создаётся объект ClassBase, в него помещается указатель на vtable ClassBase.
Теперь рассмотрим наследника ClassA. Хотя у него явно определены только два метода, его таблица всё равно содержит пять слотов. Неопределённые методы просто указывают на реализацию из базового класса. А для переопределённых методов — свои адреса.
Таким образом, создавая экземпляр ClassA, в нём будет ссылка на vtable ClassA.
Постановка задачи
Чтобы пример был не теоретическим, реализуем два полноценных нативных класса, которые будут работать в движке Gothic напрямую — без единого хука.
Первым идёт новый класс oCNpcEx, унаследованный от oCNpc, с дополнительными возможностями:
Вторым будет класс oCObjectFactoryEx, унаследованный от oCObjectFactory. Это ключевая фабрика, которая отвечает за создание игровых объектов.
Что в нём меняем:
Заготовка классов: oCNpcEx и oCObjectFactoryEx
Чтобы движок принял новые классы как "родные", нужно правильно описать интерфейс. Для этого используем zCLASS_UNION_DECLARATION — он задаёт базовую структуру, важную для сериализации и жизненного цикла объекта. А подключаем реализацию через zCLASS_UNION_DEFINITION.
Заготовка классов .h файл:
Реализация .cpp файл:
Теперь нужно связать наш класс-фабрику с движком. Для этого создаём функцию, которая подменит глобальный zfactory на нашу реализацию, и вызываем её из Game_DefineExternals.
Подключение в движке:
Что в итоге?
Код уже можно собирать. При старте новой игры движок будет использовать oCObjectFactoryEx и создавать NPC как экземпляры oCNpcEx.
При сохранении игры движок также корректно сохранит ссылку на наш класс — и при загрузке из сейва он будет работать без проблем.
Функция отсечения NPC камерой
В игре уже функционируют наши NPC, так что мы можем свободно взаимодействовать с ними. Для начала реализуем механизм, при котором мешающийся в фокусе NPC будет становиться прозрачным.
Для этого добавим в класс oCNpcEx виртуальный метод ProcessNpc, который будет вызываться каждый кадр для всех NPC.
Формула отсечения следующая: если камера входит в область, равную 0.8 от радиуса bbox’а, модель начинает выцветать. Полная прозрачность наступает при 0.5 от этой дистанции. Главный герой не участвует в этом алгоритме.
Спринт
Эта функция предназначена для главного героя. Мы создадим невиртуальную функцию ProcessSprint, которая будет вызываться из ProcessNpc. По условию: при зажатой ПКМ запускается спринт, ускоряющий анимацию бега в 1.5 раза.
Регенерация атрибутов
Теперь займёмся восстановлением характеристик. Расширим класс, добавив необходимые поля и реализуем две функции: ProcessRegen — отвечает за регенерацию, и OnDamage — вызывается в момент получения урона, временно блокируя восстановление. Здоровье будет приостанавливаться на 5 секунд, а мана — на 2.
Сохранение и загрузка значений
Поскольку значения новых полей постоянно меняются во время игры, их нужно сохранять в файл. Для этого определим виртуальные методы Archive и Unarchive.
Заключение
Пример показал, как можно реализовать дополнительную функциональность, не затрагивая память напрямую и не используя хаки. Создание наследников возможно от любых классов — хоть от oCItem, хоть от zCRenderer. Это простой, но гибкий способ модификации игрового поведения средствами движка.
Виртуальные таблицы (или vtable) нужны для того, чтобы при вызове виртуальной функции программа могла понять, какую именно реализацию этой функции нужно вызвать — от базового класса или от производного. Этот процесс происходит в два шага:
- Определяется смещение к таблице виртуальных функций (vtable), на которую указывает указатель, установленный в конструкторе.
- По нужному смещению в этой таблице находится адрес соответствующей реализации функции.
Код:
class A {
virtual void print() { cmd << "is A" << endl; }
};
class B : public A {
virtual void print() { cmd << "is B" << endl; }
};
void func() {
A* a = new B(); // Указатель на vtable будет установлен в конструкторе B
a->print(); // Вызовется print из B по смещению 0 в vtable B
}
Теперь разберёмся подробнее, как формируется таблица. В примере ниже — базовый класс с пятью виртуальными методами. Таблица будет содержать пять адресов функций, отсортированных по порядку объявления.
Когда создаётся объект ClassBase, в него помещается указатель на vtable ClassBase.
Теперь рассмотрим наследника ClassA. Хотя у него явно определены только два метода, его таблица всё равно содержит пять слотов. Неопределённые методы просто указывают на реализацию из базового класса. А для переопределённых методов — свои адреса.
Таким образом, создавая экземпляр ClassA, в нём будет ссылка на vtable ClassA.
Код:
class ClassBase {
virtual void Func1();
virtual void Func2();
virtual void Func3();
virtual void Func4();
virtual void Func5();
};
class ClassA : public ClassBase {
virtual void Func3();
virtual void Func4();
};
// Виртуальные таблицы.
[vtable ClassBase]
call void ClassBase::Func1
call void ClassBase::Func2
call void ClassBase::Func3
call void ClassBase::Func4
call void ClassBase::Func5
[vtable ClassA]
call void ClassBase::Func1
call void ClassBase::Func2
call void ClassA::Func3
call void ClassA::Func4
call void ClassBase::Func5
Постановка задачи
Чтобы пример был не теоретическим, реализуем два полноценных нативных класса, которые будут работать в движке Gothic напрямую — без единого хука.
Первым идёт новый класс oCNpcEx, унаследованный от oCNpc, с дополнительными возможностями:
- Если камера подойдёт слишком близко к NPC — он станет полупрозрачным.
- При зажатой ПКМ персонаж начнёт бегать быстрее.
- Добавим регенерацию здоровья, маны и стамины.
- Сделаем возможность сохранять/загружать новые параметры.
- Добавим возможность временной блокировки регенерации по событию.
Вторым будет класс oCObjectFactoryEx, унаследованный от oCObjectFactory. Это ключевая фабрика, которая отвечает за создание игровых объектов.
Что в нём меняем:
- Переопределим виртуальный метод CreateNpc, чтобы он создавал наш oCNpcEx вместо стандартного NPC.
- Свяжем новый класс с движком, передав его в глобальный экземпляр zfactory.
Заготовка классов: oCNpcEx и oCObjectFactoryEx
Чтобы движок принял новые классы как "родные", нужно правильно описать интерфейс. Для этого используем zCLASS_UNION_DECLARATION — он задаёт базовую структуру, важную для сериализации и жизненного цикла объекта. А подключаем реализацию через zCLASS_UNION_DEFINITION.
Заготовка классов .h файл:
Код:
namespace Gothic_II_Addon {
class oCNpcEx : public oCNpc {
public:
zCLASS_UNION_DECLARATION( oCNpcEx );
};
class oCObjectFactoryEx : public oCObjectFactory {
zCLASS_UNION_DECLARATION( oCObjectFactoryEx )
public:
virtual oCNpc* CreateNpc( int index ); // Создание только наших NPC
};
}
Код:
namespace Gothic_II_Addon {
// В определении интерфейса нам важны первые два параметра. Целевой класс и его предок
zCLASS_UNION_DEFINITION( oCNpcEx, oCNpc, 0, 0 );
zCLASS_UNION_DEFINITION( oCObjectFactoryEx, oCObjectFactory, 0, 0 );
// Реализация виртуального метода, создающего наших NPC
oCNpc* oCObjectFactoryEx::CreateNpc( int index ) {
oCNpc* npc = new oCNpcEx();
if( index != zPAR_INDEX_UNDEF )
npc->InitByScript( index, 0 );
return npc;
}
}
Теперь нужно связать наш класс-фабрику с движком. Для этого создаём функцию, которая подменит глобальный zfactory на нашу реализацию, и вызываем её из Game_DefineExternals.
Подключение в движке:
Код:
// Внутри namespace Gothic_II_Addon
void OnInitFactory() {
zfactory = new oCObjectFactoryEx();
}
// В глобальном пространстве имён
void GameGlobal_OnInitFactory() {
Gothic_II_Addon::OnInitFactory();
}
// В Application.cpp
extern void GameGlobal_OnInitFactory();
void Game_DefineExternals() {
GameGlobal_OnInitFactory();
}
Что в итоге?
Код уже можно собирать. При старте новой игры движок будет использовать oCObjectFactoryEx и создавать NPC как экземпляры oCNpcEx.
При сохранении игры движок также корректно сохранит ссылку на наш класс — и при загрузке из сейва он будет работать без проблем.
Функция отсечения NPC камерой
В игре уже функционируют наши NPC, так что мы можем свободно взаимодействовать с ними. Для начала реализуем механизм, при котором мешающийся в фокусе NPC будет становиться прозрачным.
Для этого добавим в класс oCNpcEx виртуальный метод ProcessNpc, который будет вызываться каждый кадр для всех NPC.
Код:
// .h file
class oCNpcEx : public oCNpc {
public:
zCLASS_UNION_DECLARATION( oCNpcEx );
CTimer TimerAI;
virtual void ProcessNpc();
};
Формула отсечения следующая: если камера входит в область, равную 0.8 от радиуса bbox’а, модель начинает выцветать. Полная прозрачность наступает при 0.5 от этой дистанции. Главный герой не участвует в этом алгоритме.
Код:
void oCNpcEx::ProcessNpc() {
// Привязка таймера к циклу NPC
TimerAI.Attach();
if( this != player ) {
// Определяем bbox модели. Вычисляем центр,
// прибавляя локальный центр к позиции в мире.
zTBBox3D BBox3D = GetModel()->bbox3D;
zVEC3 VobCenter = GetPositionWorld() + BBox3D.GetCenter();
zVEC3 CameraPosition = ogame->GetCameraVob()->GetPositionWorld();
float DistanceToCamera = VobCenter.Distance( CameraPosition );
// Считаем радиус через длину вектора (maxs - mins).
// Далее берём 0.8 от этой длины — начало исчезновения,
// и 0.5 от неё — конец (полная прозрачность).
float FadeDistanceBegin = ( BBox3D.maxs - BBox3D.mins ).Length() * 0.8f;
float FadeDistanceEnd = FadeDistanceBegin * 0.5f;
// Если персонаж вне зоны — полностью видимый.
if( DistanceToCamera > FadeDistanceBegin ) {
if( visualAlphaEnabled ) {
visualAlpha = 1.0f;
visualAlphaEnabled = False;
}
}
else {
if( !visualAlphaEnabled )
visualAlphaEnabled = True;
// Если ближе ближнего порога — полностью прозрачный.
if( DistanceToCamera < FadeDistanceEnd )
visualAlpha = 0.0f;
else {
// Прозрачность в пределах порога по формуле.
float FadeLengthMax = FadeDistanceBegin - FadeDistanceEnd;
float FadeLength = DistanceToCamera - FadeDistanceEnd;
visualAlpha = 1.0f / FadeLengthMax * FadeLength;
}
}
}
// Вызов родной реализации после нашего алгоритма
oCNpc::ProcessNpc();
}
Спринт
Эта функция предназначена для главного героя. Мы создадим невиртуальную функцию ProcessSprint, которая будет вызываться из ProcessNpc. По условию: при зажатой ПКМ запускается спринт, ускоряющий анимацию бега в 1.5 раза.
Код:
// .h file
class oCNpcEx : public oCNpc {
public:
zCLASS_UNION_DECLARATION( oCNpcEx );
CTimer TimerAI;
int LockRegenStaminaTime; // Блокировка восстановления стамины (секунды)
bool32 SprintEnabled; // Активен ли спринт
int StaminaMax; // Максимум стамины
int Stamina; // Текущая стамина
oCNpcEx(); // Конструктор по умолчанию
void ProcessSprint(); // Обработка спринта
virtual void ProcessNpc();
};
// .cpp file
oCNpcEx::oCNpcEx() : oCNpc() {
LockRegenStaminaTime = 0;
SprintEnabled = False;
StaminaMax = Stamina = 40;
}
void oCNpcEx::ProcessSprint() {
// ID таймера трат стамины
static const uint SpendStaminaID = 0;
if( this != player )
return;
// Получаем анимацию бега по fmode
zCModelAni* RunAni = GetModel()->GetAniFromAniID( anictrl->s_runl[fmode] );
bool32 AniIsActive = GetModel()->IsStateActive( RunAni );
bool32 CanSprint = zinput->GetMouseButtonPressedRight() && Stamina && AniIsActive;
// Активация
if( CanSprint && !SprintEnabled ) {
RunAni->fpsRate = RunAni->fpsRateSource * 1.5;
SprintEnabled = True;
}
// Деактивация
else if( !CanSprint && SprintEnabled ) {
RunAni->fpsRate = RunAni->fpsRateSource;
SprintEnabled = False;
}
// Привязка таймера к игровому времени
TimerAI.Suspend( SpendStaminaID, ogame->singleStep );
// Трата стамины каждые 100мс при активном спринте
if( SprintEnabled ) {
LockRegenStaminaTime = 2;
if( Stamina && TimerAI( SpendStaminaID, 100, TM_PRIMARY ) )
Stamina–;
}
// Используем бар задержки дыхания для отображения стамины
if( Stamina != StaminaMax ) {
screen->InsertItem( ogame->swimBar );
ogame->swimBar->SetMaxRange( 0, StaminaMax );
ogame->swimBar->SetRange( 0, StaminaMax );
ogame->swimBar->SetPreview( Stamina );
ogame->swimBar->SetValue( Stamina );
// Рендер и удаление
ogame->swimBar->Render();
screen->RemoveItem( ogame->swimBar );
}
}
void oCNpcEx::ProcessNpc() {
// [… Предыдущий код …]
ProcessSprint();
}
Регенерация атрибутов
Теперь займёмся восстановлением характеристик. Расширим класс, добавив необходимые поля и реализуем две функции: ProcessRegen — отвечает за регенерацию, и OnDamage — вызывается в момент получения урона, временно блокируя восстановление. Здоровье будет приостанавливаться на 5 секунд, а мана — на 2.
Код:
// .h file
class oCNpcEx : public oCNpc {
public:
zCLASS_UNION_DECLARATION( oCNpcEx );
CTimer TimerAI;
// Эти параметры определяют, сколько единиц в секунду будет восстанавливаться
float RegenHpIntensity;
float RegenManaIntensity;
float RegenStaminaIntensity;
// Эти переменные указывают, сколько секунд ещё запрещена регенерация
int LockRegenHpTime;
int LockRegenManaTime;
int LockRegenStaminaTime;
bool32 SprintEnabled;
int StaminaMax;
int Stamina;
oCNpcEx();
void ProcessSprint();
void ProcessRegen(); // Новая функция для регенерации (не виртуальная)
virtual void ProcessNpc();
virtual void OnDamage( oSDamageDescriptor& damage ); // Вызывается при получении урона
};
Код:
// .cpp file
oCNpcEx::oCNpcEx() : oCNpc() {
RegenHpIntensity = 1.0f;
RegenManaIntensity = 1.0f;
RegenStaminaIntensity = 1.0f;
LockRegenHpTime = 0;
LockRegenManaTime = 0;
LockRegenStaminaTime = 0;
SprintEnabled = False;
StaminaMax = Stamina = 40;
}
void oCNpcEx::ProcessRegen() {
// Уникальные идентификаторы таймеров для каждой из характеристик
static const uint RegenHpID = 1;
static const uint RegenManaID = 2;
static const uint RegenStaminaID = 3;
static const uint UnlockID = 4;
// При паузе в игре таймеры также приостанавливаются
TimerAI.Suspend( RegenHpID, ogame->singleStep );
TimerAI.Suspend( RegenManaID, ogame->singleStep );
TimerAI.Suspend( RegenStaminaID, ogame->singleStep );
TimerAI.Suspend( UnlockID, ogame->singleStep );
// Если регенерация HP не заблокирована и текущее значение меньше максимума
if( !LockRegenHpTime && attribute[NPC_ATR_HITPOINTS] < attribute[NPC_ATR_HITPOINTSMAX] ) {
// Вычисляем интервал времени на основе интенсивности
int HpIntensity = ( 1000.0f / RegenHpIntensity );
if( TimerAI( RegenHpID, HpIntensity ) )
attribute[NPC_ATR_HITPOINTS]++;
}
// Аналогичная логика для регенерации маны
if( !LockRegenManaTime && attribute[NPC_ATR_MANA] < attribute[NPC_ATR_MANAMAX] ) {
int ManaIntensity = ( 1000.0f / RegenManaIntensity );
if( TimerAI( RegenManaID, ManaIntensity ) )
attribute[NPC_ATR_MANA]++;
}
// И для выносливости
if( !LockRegenStaminaTime && Stamina < StaminaMax ) {
int StaminaIntensity = ( 1000.0f / RegenStaminaIntensity );
if( TimerAI( RegenStaminaID, StaminaIntensity ) )
Stamina++;
}
// Отдельный таймер, который каждую секунду уменьшает блокировки
if( TimerAI( UnlockID, 1000 ) ) {
if( LockRegenHpTime )
LockRegenHpTime--;
if( LockRegenManaTime )
LockRegenManaTime--;
if( LockRegenStaminaTime )
LockRegenStaminaTime--;
}
}
void oCNpcEx::OnDamage( oSDamageDescriptor& damage ) {
oCNpc::OnDamage( damage ); // Вызываем стандартную обработку урона
LockRegenHpTime = 5; // Запрещаем восстановление здоровья на 5 секунд
LockRegenManaTime = 2; // И маны на 2 секунды
}
void oCNpcEx::ProcessNpc() {
// [... код, не относящийся к теме, опущен]
ProcessRegen(); // Добавляем обработку регенерации
}
Сохранение и загрузка значений
Поскольку значения новых полей постоянно меняются во время игры, их нужно сохранять в файл. Для этого определим виртуальные методы Archive и Unarchive.
Код:
// .h file
class oCNpcEx : public oCNpc {
public:
zCLASS_UNION_DECLARATION( oCNpcEx );
CTimer TimerAI;
float RegenHpIntensity;
float RegenManaIntensity;
float RegenStaminaIntensity;
int LockRegenHpTime;
int LockRegenManaTime;
int LockRegenStaminaTime;
bool32 SprintEnabled;
int StaminaMax;
int Stamina;
oCNpcEx();
void ProcessSprint();
void ProcessRegen();
virtual void ProcessNpc();
virtual void OnDamage( oSDamageDescriptor& damage );
virtual void Archive( zCArchiver& ar ); // Вызывается при записи NPC в сохранение
virtual void Unarchive( zCArchiver& ar ); // Вызывается при загрузке NPC из сохранения
};
Код:
// .cpp file
void oCNpcEx::Archive( zCArchiver& ar ) {
oCNpc::Archive( ar ); // Сначала сохраняем базовые поля NPC
// Сохраняем значения новых полей
ar.WriteFloat( "REGENHPINTENSITY", RegenHpIntensity );
ar.WriteFloat( "REGENMANAINTENSITY", RegenManaIntensity );
ar.WriteFloat( "REGENSTAMINAINTENSITY", RegenStaminaIntensity );
ar.WriteInt ( "LOCKREGENHPTIME", LockRegenHpTime );
ar.WriteInt ( "LOCKREGENMANATIME", LockRegenManaTime );
ar.WriteInt ( "LOCKREGENSTAMINATIME", LockRegenStaminaTime );
ar.WriteInt ( "STAMINAMAX", StaminaMax );
ar.WriteInt ( "STAMINA", Stamina );
}
void oCNpcEx::Unarchive( zCArchiver& ar ) {
oCNpc::Unarchive( ar ); // Загружаем базовые поля NPC
// Загружаем значения новых переменных
ar.ReadFloat( "REGENHPINTENSITY", RegenHpIntensity );
ar.ReadFloat( "REGENMANAINTENSITY", RegenManaIntensity );
ar.ReadFloat( "REGENSTAMINAINTENSITY", RegenStaminaIntensity );
ar.ReadInt ( "LOCKREGENHPTIME", LockRegenHpTime );
ar.ReadInt ( "LOCKREGENMANATIME", LockRegenManaTime );
ar.ReadInt ( "LOCKREGENSTAMINATIME", LockRegenStaminaTime );
ar.ReadInt ( "STAMINAMAX", StaminaMax );
ar.ReadInt ( "STAMINA", Stamina );
}
Заключение
Пример показал, как можно реализовать дополнительную функциональность, не затрагивая память напрямую и не используя хаки. Создание наследников возможно от любых классов — хоть от oCItem, хоть от zCRenderer. Это простой, но гибкий способ модификации игрового поведения средствами движка.
Симпатии:
Toshbam