Епізод 07. Базовий об'єкт

Ми вже створили та інтегрували до фреймворка менеджер текстур, а також протестували його роботу. І це, звісно, дуже добре, але сам по собі менеджер текстур - це лише одна з частин фреймворку, і використовувати його так, як ми це зробили в попередньому епізоді не є правильно, ми це зробили лише для перевірки функціоналу.

В сьогоднішньому епізоді поставимо наступні цілі:

  1. виправимо помилки, допущені в попередньому епізоді
  2. розробимо клас базового ігрового об'єкта
  3. реалізуємо обробку усіх ігрових об'єктів на основі базового класу на рівні фреймворку
  4. реалізуємо завантажувач параметрів ігрового об'єкта
  5. спробуємо переписати демо проект з використанням базових об'єктів

Отже, почнемо з помилок. Перша помилка, про яку я згадував в минулому епізоді, пов'язана зі зміною заголовку вікна. Проаналізувавши код, я знайшов причину, чому не завжди відображався текст в заголовку. Коли завантажуються налаштування з файла і там є вказаний текст для заголовку, на той момент вікно ще не існує. Коли ж ми програмно встановлюємо новий текст заголовку і він при цьому такий самий, як вже був, в нас нічого не відбувається. Щоб це виправити, потрібно код методу TGOEngine.SetCaption() привести до наступного вигляду:

procedure TGOEngine.SetCaption(AValue: String);
begin
//  if FCaption=AValue then Exit; //<- цей рядок потрібно видалити або закоментувати
  FCaption:=AValue;
  SDL_SetWindowTitle(FWindow,PChar(UTF8String(FCaption)));
end;  

Наступна помилка в мене була банальна "очеп'ятка" - я помилився при написанні назви деструктора класу TGOTextureManager:

    destructor Drestroy;// override;

потрібно виправити на наступний рядок:

    destructor Destroy; override;

і натиснути на Ctrl+Shift+C для автоматичного виправлення назви в реалізації деструктора.

І ще одна неочевидна помилка, яку якщо не виправити, то з часом можуть бути значні витоки памяті. Пов'язана вона зі створенням текстур. Говорячи по-простому, ми створюємо текстури, але не звільняємо виділену під них пам'ять при знищенні менеджера текстур. А для виправлення цієї помилки нам потрібно зробити декілька кроків.

По-перше, потрібно змінити тип дженеріка TGOTextureMap на TGOTextureMapObj, щоб автоматично викликались деструктори об'єктів при знищенні. Також, потрібно описати клас-враппер для типу PSDL_Texture, щоб використовувати його з дженеріком. Інтерфейсну частину класу потрібно описати до дженеріка.

  TSDLTextureWrapper = class
    Texture : PSDL_Texture;
    constructor Create(aTexture : PSDL_Texture);
    destructor Destroy; override;
  end; 

І реалізація конструктора й деструктора:

constructor TSDLTextureWrapper.Create(aTexture: PSDL_Texture);
begin
  Texture := aTexture;
end;

destructor TSDLTextureWrapper.Destroy;
begin
  SDL_DestroyTexture(Texture);
  inherited Destroy;
end; 

Відповідно в описі дженеріка потрібно зробити наступні зміни:

TGOTextureMap = specialize TFPGMapObject; 

Далі ще декілька виправлень: в методі TGOTextureManager.Load() потрібно

FTextureMap[aID] := TmpTexture; 

змінити на

FTextureMap[aID] := TSDLTextureWrapper.Create(TmpTexture); 

Також усі виклики FTextureMap[aID] потрібно змінити на FTextureMap[aID].Texture.

Здається, поки що це всі помилки, які мені вдалось виявити. Якщо я щось пропустив, напишіть в коментарях що саме і де. А тепер, я думаю, можна приступати до реалізації наших нових цілей.

В реальній грі використовується велика кількість різних об'єктів, які мають графіку чи навіть анімацію, змінюють в залежності від ситуації своє положення або ж лишаються статичними. До таких об'єктів можна віднести в першу чергу самого ігрового персонажа, ворогів, босів, пастки, двері, бонуси і т.д. І хоча ці об'єкти досить різні, проте можна виділити спільні властивості і методи, які мають бути присутніми в кожного з них. Саме це я планую зробити, створивши базовий об'єкт, який буде батьківським класом для решти ігрових об'єктів. Також інтегруємо обробку ігрових об'єктів до фреймворку.

Так як усі ігрові об'єкти відображаються на екрані, вони потребують методу Draw(). Відповідно цей метод можна буде викликати у одноіменному методі фреймворка.

Оскільки об'єкт буде виводитись на екран, він повинен містити координати куди саме його виводити. А це властивості X та Y, які й будуть містити ці координати.

Нам буде потрібно не лише статичні об'єкти (як наприклад, двері або пастки), а й динамічні. А для цього порібно буде відповідний метод, назвемо його Update(). До речі, в основному класі TGOEngine напевно, теж варто перейменувати метод GameLogic() на Update() - так буде більш зручно і зрозуміліше.

Оскільки об'єкт може бути знищений (напр. ворог або предмет, який можна підібрати), потрібен метод, який буде встановлювати відповідну ознаку. Назвемо його Clear().

Також потрібні будуть конструктор і деструктор. В конструкторі будемо передавати початкове положення об'єкта, його розміри та ідентифікатор текстури з менеджера текстур. А оскільки в нас нічого не створюється в конструкторі, то деструктора поки немає потреби описувати. За потреби від буде переназначений в класа-нащадках.

Попередня заготовка в нас вже є, тепер можна спробувати створити об'єкти на основі цього класу. Але я пропустив ще один момент - якщо буде використоуватись тайлшіт або спрайтшіт, нам потрібно також інформацію про поточний фрагмент зображення, яке буде виводитись функцією Draw(). Як я згадував в минулому епізоді, такий фрагмент можна задати номерами рядка і стовбця, тому також додамо ці поля. Оскільки доступ до цих полів також повинен бути і в класах-нащадках, їх потрібно розмістити в секції protected. Цей клас буде абстрактним - основна його задача полягає в тому, щоб задати дизайн для усіх класів-нащадків. Так ми зможемо використовувати будь-який об'єкт, що наслідує базовий клас у фреймворку по його посиланню.

  TGOBaseObject = class
  protected
    FX, FY: Integer;  //позиція об'єкта
    FW, FH: Integer;  //розміри об'єкта
    FCol, FRow: Integer; //стовбець і рядок фрагмента зображення
    FFlip: Integer; //ознака віддзеркалення для малювання  
    FId: String;
  public
    constructor Create(x, y, Width, Height: Integer; Id: String);
    procedure Draw();
    procedure Update(); virtual; abstract;
    procedure Clear(); virtual; abstract;
  end;

В конструкторі буде відбуватись початкова ініціалізація полів, тут все просто:

 constructor TGOBaseObject.Create(x, y, width, height: Integer;
          Id: String);
    begin
      FX := x;
      FY := y;
      FW := width;
      FH := height;
      FId:= Id;
      FRow:=1;
      FCol:=1;
      FFlip := SDL_FLIP_NONE;
    end;

Метод Draw буде використовувати менеджер текстур для виводу потрібного зображення. Взагалі цей метод за потреби буде перекриватись нащадками, але поки для тесту опишемо його так:

procedure TGOBaseObject.Draw;
begin
  GoEngine.TextureManager.DrawFrame(FId, Fx, Fy, Fw, Fh, FRow, FCol, FFlip);
end;

А щоб ми могли в класах-нащадках базового об'єкта використовувати віддзеркалення при виведенні на екран не підключаючи додатково модуля SDL2, я опишу на початку модуля uGOEngie декілька додаткових констант:

  //константи для віддзеркалення зображень
  GO_FLIP_NONE = SDL_FLIP_NONE;
  GO_FLIP_H = SDL_FLIP_HORIZONTAL;
  GO_FLIP_V = SDL_FLIP_VERTICAL;
  GO_FLIP_D = SDL_FLIP_HORIZONTAL OR SDL_FLIP_VERTICAL; 

Сам по собі базовий об'єкт практично нічого не вміє, тому всю роботу будуть на себе брати його нащадки. А для того, щоб інтегрувати роботу з нащадками базового об'єкту в наш фреймворк, скористаємось ще одним дженеріком - списком об'єктів. Опишемо його наступним чином:

TGOObjectsList = specialize TFPGObjectList;

Тепер створимо для тесту нащадка базового класу, назвемо його TPlayer. Для цього перейдемо в код проекту і вже там його й опишемо. Перекриємо метод батьківського об'єкта Update() для того, щоб створити анімацію і перенесемо відповідні розрахунки поточного кадру з базового класу.

І опишемо відповідну властивість в базовому класі:

property GameObjects: TGOObjectsList read FGameObjects;        //список об'єктів

Ця властивість буде теж лише для читання. Натискаємо Ctrl+Shift+C для автоматичного доповнення коду і далі нам потрібно в конструкторі прописати код для створення списку об'єктів, а в деструкторі не забути звільнити пам'ять. Я зробив це біля коду створення/знищення менеджера текстур.

//в конструкторі: 
//...
FGameObjects := TGOObjectsList.Create;   

//...
//І в деструкторі:
FreeAndNil(FGameObjects); 

Але це ще не все. Щоб наш фреймворк міг опрацьовувати усі об'єкти, нам потрібно додати трошки коду у відповідні методи нашого фреймворку, а саме:

procedure TGOEngine.Update;
var
  gobj: TGOBaseObject;
begin
  //якщо список об'єктів порожній, виходимо з метода 
  if FGameObjects.Count=0 then Exit;
  for gobj in FGameObjects do
  begin
    gobj.Update();
    SDL_Delay(100);
  end;
end;

procedure TGOEngine.Draw;
var
  gobj: TGOBaseObject;
begin
  //встановимо колір вікна в голубий
  SDL_SetRenderDrawColor(FRenderer, 0, 128, 255, 255);
  //очистити вікно
  SDL_RenderClear(FRenderer);

  //вивести потрібні об'єкти, якщо список не порожній
  if FGameObjects.Count<>0 then
     for gobj in FGameObjects do gobj.Draw();

  //показати вікно на екран
  SDL_RenderPresent(FRenderer);
end;  

На цьому етапі вже можна спробувати протестувати нашу роботу. Тож перейдемо в файл нашого тестового проекту і напишемо там наступний код:

  TPlayer = class(TGOBaseObject)
  private
    FFrame: integer;
    FDirectionX,
    FDirectionY: ShortInt; //-1 рухаємось вліво, +1 рухаємось вправо
  constructor Create(x, y, Width, Height: Integer; Id: String);
  published
    property Frame : integer read FFrame default 0;
    procedure Update; override;
  end; 
  
  
constructor TPlayer.Create(x, y, Width, Height: Integer; Id: String);
begin
  FDirectionX:=5;
  FDirectiony:=5;
  inherited Create(x, y, Width, Height, Id);
end; 

  
procedure TPlayer.Update;
begin
  //тут буде прораховуватись ігрова логіка
  //спочатку змінюємо за потреби напрямок руху
 if (FX>(GoEngine.Width-FW)) or (FDirectionX=0) then FDirectionX:=-5
    else if FX <=0 then FDirectionX:=5;
 if (FY>=(GoEngine.Height-FH))  or (FDirectionY=0) then FDirectionY:=-5
    else if FY <=0 then FDirectionY:=5;
 //а тепер координати
 FX:=FX+FDirectionX;
 FY:=FY+FDirectionY;
 //визначаємо віддзеркалення в залежності від напрямку руху
 if (FDirectionx>0) then
    if (FDirectionY>0) then FFlip:=GO_FLIP_NONE
    else FFlip:=GO_FLIP_V
 else if (FDirectionY>0) then FFlip:=GO_FLIP_D
    else FFlip:=GO_FLIP_H;
 //змінюємо поточний кадр анімації
 if FCol >=7 then begin
  FCol :=0;
  Inc(FRow);
  if FRow>=9 then FRow:=0
     else Inc(FRow);
 end else Inc(FCol);
end;

Коротко поясню цей код. Ми описали клас TPlayer, який наслідує від батьківського базового об'єкта всі його методи і властивості, і доповнили ще двома приватними властивостями для визначення напрямку руху на екрані. Для початкової ініціалізації напрямку руху нам потрібно змінити конструктор предка, а також перекрити батьківський метод Update(), в якому прораховується напрямок руху, координати і кадр для виводу на екран. Залишилось лише створити відповідні об'єкти і додати їх в список об'єктів фреймворка. В коді це буде виглядати так:

    GoEngine.TextureManager.Load('assets'+DirectorySeparator+'tux.png','Tux');
    GoEngine.TextureManager.Load('assets'+DirectorySeparator+'tuxfire.png','Tux Fire');
    for i :=0 to 4 do begin
      GoEngine.GameObjects.Add(TPlayer.Create(Random(GoEngine.Width),Random(GoEngine.Height),32,32,'Tux'));
      GoEngine.GameObjects.Add(TPlayer.Create(Random(GoEngine.Width),Random(GoEngine.Height),32,32,'Tux Fire'));
    end; 

Нарешті, можемо запустити і подивитись чи все працює коректно. В результаті на екрані має з'явитись вісім пінгвінів, які рухаються по екрану, змінюючи напрямок біля країв. В залежності від напрямку руху зображення пінгвінів може бути дзеркально відображене по горизонталі, вертикалі або діагоналі. Можна змінити кількість ітерацій циклу і створити більшу кількість об'єктів - і всі вони будуть автоматично опрацьовуватись і промальовуватись на екрані. Я для проби створив 2000 об'єктів, і тестова програма відпрацювала без проблем, усі об'єкти коректно опрацьовувались на рівні фреймворку.

Здається, ніби це все вже досить непогано працює, і можна створити багато об'єктів і додати їх до фреймворка лише одним рядком коду, але є одне "але". Для прикладу, якщо ми схочемо розширити властивості нашого класу і в конструкторі передати набагато більше параметрів, ніж має конструктор базового об'єкту. Щоразу писати новий конструктор з більшою кількістю параметрів? Не найкраще рішення. Щоб вирішити цю проблему, напишемо ще один допоміжний клас, який буде передаватись в конструктор базового об'єкта замість всього списку параметрів, а вже з його властивостей параметри будуть присвоюватись відповідним полям об'єкта. В майбутньому в нащадках цього класу можна зробити, наприклад, завантаження з JSON-об'єкта або рядка тексту, розділеного певними сепараторами чи взагалі з таблиці бази даних. Тут важливо не звідки будуть братись дані, а як вони будуть передаватись в параметри об'єкта при створенні. Оскільки базовий клас має невелику кількість параметрів, то й клас-завантажувач параметрів теж повинен мати ту ж саму кількість.

  { TGOObjLoader }

  TGOObjLoader = class
   protected
    FX, FY: Integer;  //позиція об'єкта
    FW, FH: Integer;  //розміри об'єкта
    FCol, FRow: Integer; //стовбець і рядок фрагмента зображення
    FFlip: Integer; //ознака віддзеркалення для малювання
    FId: String;
   public
    constructor Create(x, y, Width, Height: Integer; Id: String);
    property X : Integer read FX write FX default 0;
    property Y : Integer read FY write FY default 0;
    property Width : Integer read FW write FW default 0;
    property Height : Integer read FH write FH default 0;
    property Column : Integer read FCol write FCol default 1;
    property Row : Integer read FRow write FRow default 1;
    property Id : String read FId write FId;
    property Flip : Integer read FFlip write FFlip default GO_FLIP_NONE;
  end;

Конструктор буде просто присвоювати значення відповідним полям з параметрів:

constructor TGOObjLoader.Create(x, y, Width, Height: Integer; Id: String);
begin
  FX := x;
  FY := y;
  FW := Width;
  FH := Height;
  FId := Id;
  FRow := 1;
  FCol := 1;
  FFlip := SDL_FLIP_NONE;
end; 

Тепер залишилось лише змінити конструктор базового об'єкта, змінивши список параметрів на посилання на завантажувач і в самому конструкторі теж змінити присвоєння на відповідні поля завантажувача:

constructor Create(const ObjLoader : TGOObjLoader);
...
constructor TGOBaseObject.Create(const ObjLoader: TGOObjLoader);
begin
  FX := ObjLoader.X;
  FY := ObjLoader.Y;
  FW := ObjLoader.Width;
  FH := ObjLoader.Height;
  FId := ObjLoader.Id;
  FRow := ObjLoader.Row;
  FCol := ObjLoader.Column;
  FFlip := ObjLoader.Flip;
end; 

Хоча на перший погляд може здаватись що завантажувач - це просто лишній код, але насправді він робить використання нащадків базового об'єкта більш гнучким і простішим до розширення їх можливостей.

Нарешті, спробуємо протестувати все, що ми сьогодні зробили. Відповідно, сама тестова програма буде виглядати приблизно так:

var
  i: Integer;
  aLoader : TGOObjLoader;
begin
  Randomize;
  if GoEngine<>nil then begin
    GoEngine.Caption:='демо 07';
    GoEngine.TextureManager.Load('assets'+DirectorySeparator+'tux.png','Tux');
    GoEngine.TextureManager.Load('assets'+DirectorySeparator+'tuxfire.png','Tux Fire');
    aLoader:=TGOObjLoader.Create(0,0,32,32,'Tux');
    for i :=0 to 10000 do begin
      aLoader.X := Random(GoEngine.Width);
      aLoader.Y := Random(GoEngine.Height);
      aLoader.Id := 'Tux';
      GoEngine.GameObjects.Add(TPlayer.Create(aLoader));
      aLoader.X := Random(GoEngine.Width);
      aLoader.Y := Random(GoEngine.Height);
      aLoader.Id := 'Tux Fire';
      GoEngine.GameObjects.Add(TPlayer.Create(aLoader));
    end;
    GoEngine.Run;
    FreeAndNil(aLoader);
  end;
end.

Для тесту я вирішив створити по 10000 об'єктів кожного виду, і перевірити як буде справлятись комп'ютер з такою кількістю об'єктів. І з чотирма, і 20000 об'єктів все опрацьовується на відмінно, і з апаратрим прискоренням, і навіть на одному процесорі, що не може не радувати!

Якщо маєте якісь питання, зауваження чи пропозиції, пишіть в коментарях - я обов'язково їх розгляну і спробую відповісти.

Як для одного епізоду, ми охопили досить великий об'єм матеріалу, тому на сьогодні цього буде досить. Код проекту можна скачати на Github (в розділі Releases окремі архіви до кожного епізоду), а весь процес можна подивитись на відео:

← попередня частина | наступна частина →

P.S. Наступного разу додамо керування переміщенням об'єктів і ще дещо цікаве! Не пропустіть!

Немає коментарів:

Дописати коментар