• Уважаемые гости и новички, приветствуем Вас на нашем форуме
    Здесь вы можете найти ответы практически на все свои вопросы о серии игр «Готика» (в том числе различных модах на нее), «Ведьмак», «Ризен», «Древние свитки», «Эра дракона» и о многих других играх. Можете также узнать свежие новости о разработке новых проектов, восхититься творчеством наших форумчан, либо самим показать, что вы умеете. Ну и наконец, можете обсудить общие увлечения или просто весело пообщаться с посетителями «Таверны».

    Чтобы получить возможность писать на форуме, оставьте сообщение в этой теме.
    Удачи!
  • Друзья, доброго времени суток!
    Стартовал новый литературный конкурс от "Ордена Хранителей" - "Пираты Миртанского моря".
    Каждый может принять в нём участие и снискать славу и уважение, а в случае занятия призового места ещё и получить награду. Дерзайте
  • Дорогие друзья, год подходит к концу, и пришло время подвести его итоги и наградить достойных

    Не ленитесь, голосуйте в этой теме за тех форумчан, которые по вашему мнению больше всех проявили себя в этом году
    По желанию, аргументировать свой выбор можете в теме обсуждения голосования.

5. Виртуальная таблица. Пишем новый класс NPC.

Gratt


Модостроитель
Регистрация
14 Ноя 2014
Сообщения
3.301
Благодарности
4.638
Баллы
625
  • Первое сообщение
  • #1
Общий смысл vtable
Смысл виртуальных таблиц заключается в том, что вызов функции происходит в два этапа - 1. смещение к виртуальной таблице класса, в конструкторе которого был определен указатель, 2. определение адреса реализации по смещению в таблице.
C++:
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();           // Программа обратится к виртуальной таблице класса B со смещением 0
                            // и возьмет реализацию с выводом на экран "is B".
}

Рассмотрим чуть более подробно о генерации таблиц. В коде ниже представлены два класса, где базовый имеет 5 виртуальных функций. Таблица получит 5 адресов на реализации, смещение которых будет равным порядку объявления методов. То есть при создании экземпляра от ClassBase в переменную будет записан указатель к `vtable ClassBase`.

Теперь разберемся с производным ClassA. Казалось бы, по коду у него 2 функции. В действительности их столько же, сколько у базового. Если посмотрим в таблицу этого класса, то увидим, что необъявленные функции будут вызывать реализацию от ClassBase. А func3, func4 - собственную. Получается, что создавая экземпляр ClassA в переменную запишется смещение к `vtable ClassA`.
C++:
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

Постановка задачи
Так вот. Чтобы пример не был пресным, реализуем 2 нативных класса, которые будут работать в движке без единого хука.
В первую очередь напишем класс oCNpcEx, унаследованный от oCNpc, и внедрим в него ряд новых возможностей:
1. Если камера находится слишком близко к NPC, то сделаем его прозрачным.
2. При удерживании ПКМ научим персонажа ускорять темп бега.
3. Регенерация здоровья, маны, стамины.
4. Чтение/Запись изменяемых данных в сохранение.
5. Добавим блокировки регенерации в заданных событиях.

Во-вторых создадим производный класс oCObjectFactoryEx от oCObjectFactory, который используется движком для создания экземпляров тех или иных классов:
1. Добавим собственную реализацию виртуальной функции CreateNpc через которую будем создавать экземпляры oCNpcEx, другие наc интересовать не будут.
2. Запишем в игровой экземпляр zfactory новое значение oCObjectFactoryEx.

I. Болванка класса oCNpcEx и реализация oCObjectFactoryEx
Чтобы игра приняла класс как свой родной, опишем внутри него интерфейс zCLASS_UNION_DECLARATION. Он добавит стандартные жизненно важные свойства и функции.
А чтобы реализация вошла в силу, в качестве определения используется zCLASS_UNION_DEFINITION.
C++:
// .h file
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
  };
}
 
// .cpp file
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;
  }
}

Теперь необходимо связать класс oCObjectFactoryEx с движком. Создадим функцию, которая будет присваивать указателю движка значение нового экземпляра. А вызовем мы его из Game_DefineExternals в Application.cpp.
C++:
// .cpp file
namespace Gothic_II_Addon {
    
  // [ . . . Предыдущая реализация]
    
  void OnInitFactory() {
    zfactory = new oCObjectFactoryEx();
  }
}

// global namespace
void GameGlobal_OnInitFactory() {
  Gothic_II_Addon::OnInitFactory();
}

// Application.cpp
extern void GameGlobal_OnInitFactory();
void Game_DefineExternals() {
  GameGlobal_OnInitFactory();
}

Данный код уже можно скомпилировать. Во время старта новой игры будут создаваться экземпляры NPC от класса oCNpcEx через функцию oCObjectFactoryEx::CreateNpc.
Также после сохранения движок запомнит новый класс и будет подгружать его из сейва.

II. Функция отсечения NPC камерой
74733
Поскольку в игре УЖЕ работают наши NPC, то и делать с ними можно все что захотим :). Для начала реализуем алгоритм, при котором NPC, мешающийся в фокусе, будет отсекаться путем изменения прозрачности модели.
В класс oCNpcEx добавим виртуальный метод ProcessNpc. Он вызывается каждую отрисовку кадра для всех NPC.
C++:
// .h file
  class oCNpcEx : public oCNpc {
  public:
    zCLASS_UNION_DECLARATION( oCNpcEx );
    CTimer TimerAI;
    
    virtual void ProcessNpc();
  };

Формула отсечения будет такой: Если камера входит в область равную 0.8 от радиуса bbox'а, то модель начинает выцветать с продолжительностью до 0.5 от точки начала выцветания.
Также исключим из алгоритма главного героя.
C++:
void oCNpcEx::ProcessNpc() {
    // Привязка таймера к циклу NPC
    TimerAI.Attach();

    if( this != player ) {
      // Определяем bbox модели. Вычисляем его фактический центр
      // суммированием локального центра с положенем NPC в мире.
      zTBBox3D BBox3D                  = GetModel()->bbox3D;
      zVEC3       VobCenter               = GetPositionWorld() + BBox3D.GetCenter();
      zVEC3       CameraPosition       = ogame->GetCameraVob()->GetPositionWorld();
      float          DistanceToCamera  = VobCenter.Distance( CameraPosition );

      // Далее вычисляем общий радиус модели через длину разницы
      // максимума и минимума координат. По условию нас интересует
      // 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;

        // Если камера находится внутри ближней к NPC границе отсечения,
        // то прозрачность персонажа всегда 0
        if( DistanceToCamera < FadeDistanceEnd )
          visualAlpha = 0.0f;

        // Иначе работаем по формулам отношений, где определяем
        // количество прозрачности в заданном положении камеры.
        else {
          float FadeLengthMax = FadeDistanceBegin - FadeDistanceEnd;
          float FadeLength        = DistanceToCamera  - FadeDistanceEnd;
          visualAlpha                = 1.0f / FadeLengthMax * FadeLength;
        }
      }
    }
    // После работы нашего алгоритма
    // также можем выполнить и родной.
    oCNpc::ProcessNpc();
  }

III. Спринт
Данная функция будет использоваться только в рамках главного героя. Создадим невиртуальную функцию ProcessSprint, которую будем вызывать из предыдущей ProcessNpc.
По условию нам необходимо зажать ПКМ для активации спритна. Сам спринт будет ускорять штатную анимацию бега в 1.5 раза.
C++:
// .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) из списка анимаций anictrl, где s_runl - массив идентификаторов.
    // Далее проверяем активна ли анимация текущего 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();
  }



IV. Регенерация атрибутов
И теперь к восстановлению показателей. Доопределим оставшиеся поля класса.
Введем 2 функции: ProcessRegen для процедуры регенерации и OnDamage, которая срабатывает в момент получения урона. В ней будем приостанавливать регенерацию здоровья на 5 секунд и маны на 2 секунды.
C++:
// .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() {
    // ID таймеров регенерации
    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 );

    // Для каждого блока проверяется, не стоит ли блокировка на 
    // регенерацию и меньше ли параметр чем достигаемое значение
    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++;
    }
    
    // А этот таймер снимает блокировки с регенов.
    // Каждую секунду он понижает блокировки на 1.
    // И когда значение становится 0 - регенерация
    // снова начинает работать.
    if( TimerAI( UnlockID, 1000 ) ) {
      if( LockRegenHpTime )
        LockRegenHpTime--;

      if( LockRegenManaTime )
        LockRegenManaTime--;

      if( LockRegenStaminaTime )
        LockRegenStaminaTime--;
    }
  }

  void oCNpcEx::OnDamage( oSDamageDescriptor& damage ) {
    oCNpc::OnDamage( damage ); // Вызываем оригинальный обработчик урона в классе oCNpc
    LockRegenHpTime    = 5;         // Приостановка регенерации здоровья на 5 секунд
    LockRegenManaTime = 2;        // Приостановка регенерации здоровья на 2 секунды

  }

  void oCNpcEx::ProcessNpc() {
    // [. . . Предыдущий код]
    ProcessRegen();
  }



V. Сохранение и загрузка значений
Новые поля классов являются динамическими и могут меняться непрерывно в течение всего игрового процесса. Будет правильно, если мы будем записывать их состояния в файл сохранения.
Добавляем 2 виртуальный метода Archive и Unarchive.
C++:
// .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 ) {
    // Сохраняем оригинальные данные NPC в сейв
    oCNpc::Archive( ar );

    // Сохраняем наши данные в сейв
    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 ) {
    // Читаем оригинальные данные NPC из сейва
    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 );
  }
Все. Компилируем и начинаем новую игру.



VI. Заключение
Следуя данному примеру можно реализовать собственные классы от любого другого. Будь то oCItem или zCRenderer. Да, фактически рендер тоже можно повесить на другие рельсы, будь время и желание. А пока такой вот простенький класс, с которым можно делать что угодно и как угодно.
Собственно главная мысль, которую я пытался передать, - демонстрация нативности инструментов. Без костылей, без патчинга памяти и даже без хуков. так что дерзайте. Исходники выложу ниже.
 

Вложения

  • Sources.7z
    3,1 KB · Просмотры: 154
Последнее редактирование:

Jr13San


Модостроитель
Регистрация
1 Апр 2010
Сообщения
462
Благодарности
285
Баллы
230
Не знаю, почему люди до сих пор сюда пишут вопросы не совсем по теме, но да ладно.
Я в принципе уже смирился, да и на форуме тут пишут, что с камерой гемор работать.
А зря, попробуй этот вариант для Г2а:
C++:
// временная блокировка поворота
// (по умолчанию - выкл)
BOOL bTurnLock = FALSE;


//*******************************
// Перехват функции поворота ГГ
//*******************************
//0x0069A940 public: void __thiscall oCAIHuman::PC_Turnings(int)
static void __fastcall PC_Turnings(oCAIHuman* _this, void* vt, int action);
static CInvoke <void(__thiscall*)(oCAIHuman*, int)> pPC_Turnings(0x0069A940, PC_Turnings, IVK_AUTO);
static void __fastcall PC_Turnings(oCAIHuman* _this, void* vt, int action)
{
    // если запрет снят
    if (!bTurnLock)
        // разрешаем поворот, вызывая оригинальную функцию
        pPC_Turnings(_this, action);
}


// Старт анимаций
void StartAniEvent()
{
    // получаем указатель на AI-камеру
    zCAICamera* aicam = ogame->GetCameraAI();

    // если указателя на аи-камеру или на игрока нет
    if (!aicam || !player)
        // выходим
        return;

    // Иначе:
    // Меняем режим камеры.
    // Здесь targetVobList - массив целей (обычно пустой),
    // но в режиме диалога может содержать ГГ и его оппонента,
    // но фокус камеры всё равно работает на ГГ и с пустым массивом
    aicam->SetMode(Z "CAMMODINVENTORY", aicam->targetVobList);

    // временно запрещаем изменять выбранный режим камеры
    zCAICamera::bCamChanges = 0;

    if (player->human_ai)
        // прерываем анимации поворота
        player->human_ai->StopTurnAnis();

    // блокируем управление персонажем
    player->movlock = TRUE;

    // блокируем повороты персонажа
    bTurnLock = TRUE;

    // вызываем проигрывание анимации для игрока
    player->GetEM(FALSE)->OnMessage(
        new oCMsgConversation(oCMsgConversation::EV_PLAYANI_NOOVERLAY, "S_CLAPHANDS"),
        player);
}

// Прерывание анимаций, возвращение в нормальный режим
void BreakAniEvent()
{
    // если указатель на игрока есть
    if (player)
    {
        // разблокируем управление персонажем
        player->movlock = FALSE;

        // и очищаем его очередь эвент-менеджера
        player->ClearEM();
    }

    // а также разрешаем повороты
    bTurnLock = FALSE;

    // разрешаем изменять режимы камеры,
    // возвращая её в нормальный режим
    zCAICamera::bCamChanges = 1;

    // получаем указатель на модель
    zCModel* model = player->GetModel();

    // если указателя нет
    if (!model)
        // выходим
        return;

    // Иначе, пробегаемся по всем активным анимациям
    for (int i = 0; i < model->numActiveAnis; i++)
    {
        // берём указатель на анимацию
        zCModelAniActive* ani = model->aniChannels[i];

        // и быстро завершаем её проигрывание
        model->FadeOutAni(ani);
    }
}


void Game_Loop()
{
    if (zKeyToggled(KEY_NUMPAD1))
    {
        // запуск анимаций
        StartAniEvent();
    }

    if (zKeyToggled(KEY_NUMPAD2))
    {
        // остановка анимаций
        BreakAniEvent();
    }
}

Замечания:
Возможно ещё придётся доработать "защиту от дурака", например, запретив сохранение в этом режиме или как вариант, не запрещать, а просто добавить снятие временных ограничений, используя полностью или частично функцию "BreakAniEvent()", в событие окончания загрузки сейва "LoadEnd()".

Ну и естественно перед запуском события нужно ещё проверить - жив ли ГГ, не находится ли он в каком-либо неподходящем состоянии, в диалоге, в состоянии использования предмета, мобси-объекта и т.д. Это всё на ваше усмотрение.
 
Последнее редактирование:

СырGuy

Участник форума
Регистрация
4 Мар 2022
Сообщения
29
Благодарности
5
Баллы
45
Как удалить сообщение свое на форуме?)
Я прочитал ответ на свой вопрос
 
Последнее редактирование:
Сверху Снизу