Pascal游戏开发入门

1. 概览

前言

乱弹

常见的游戏开发有c/c++(Unreal), C#(Unity)等, Pascal语言的也有(https://wiki.freepascal.org/Game_Engine),但是和前者对比不够流行。
关于pascal的优势,网上都说时易于教学,可以培养良好的程序习惯云云,我只是听之而已。
如果说最后需要c++,为什么一开始就面对呢,非要用pascal绕一圈,得不偿失的。
以上仅是个人观点

为什么有这系列文章

在Pascal基础系列文章第一篇我曾写到闲的无聊,学习一下pascal, 目前也是如此。
如果为了学习游戏开发,快速上手自然要用Unreal或者Unity。 从基础做起,估计要学习图形学之类的知识。
但是我是闲情偶记(记录的记)

本系列关注于2D Code,不关注游戏性(关卡设计等)以及游戏相关资源(字体,美术,音乐音效等)的创建

环境

图形库选择

Pascal是跨平台的,如果要写的程序也要跨平台,可能需要使用OpenGL(相比Vulkan可以支持更多的旧设备)好一些。
但是为了偷懒,决定使用SDL2。优点如下

  • 使用广泛, 资料众多
  • 上手简单,跨平台

编辑器: lazarus 平台: ubuntu18.04

  • 安装sdl2

sudo apt install libsdl2-dev libsdl2-gfx-dev libsdl2-image-dev libsdl2-mixer-dev libsdl2-net-dev libsdl2-ttf-dev

  • 安装lazarus

sudo apt install lazarus make

  • sdl2的pascal语言绑定

https://github.com/ev1313/Pascal-SDL-2-Headers下载后直接包含到项目中即可

概览

开始之前需要了解游戏的大体的运行机制

初始化
获取输入
物理计算
渲染
结束

部分平台的markdown不支持flowchart…

这就是游戏的基本框架,看起来很简单.

第一个示例

创建一个窗口,5秒后会自动关闭

Program test01;
{$mode objfpc}{$H+}

Uses SysUtils,sdl2;

Var 
  pw : PSDL_Window;
  pr  : PSDL_Renderer;
Begin
  // init
  SDL_Init(SDL_INIT_VIDEO);
  If SDL_WasInit(SDL_INIT_VIDEO)<>0 Then writeln('video init');
  pw := SDL_CreateWindow('Hello',SDL_WINDOWPOS_CENTERED,SDL_WINDOWPOS_CENTERED,800,450,
        SDL_WINDOW_SHOWN);
  pr := SDL_CreateRenderer(pw,-1,0);
  // render
  SDL_SetRenderDrawColor(pr,0,0,0,255);
  SDL_RenderClear(pr);
  SDL_RenderPresent(pr);
  SDL_Delay(5000);
  // clean
  SDL_DestroyWindow(pw);
  SDL_DestroyRenderer(pr);
  SDL_Quit();
End.

接下来加入输入部分(暂时忽略物理计算)


  isRunning := true;
  While isRunning Do
    Begin
      // handle input
      If SDL_PollEvent(@e)=1 Then
        Begin
          Case e.Type_ Of 
            SDL_QUITEV:      isRunning := false;
          End;
        End;
      // TODO: do physics and then update

      // render
      SDL_SetRenderDrawColor(pr,0,0,0,255);
      SDL_RenderClear(pr);
      SDL_RenderPresent(pr);

    End;

使用OOP来整理一下

Type TGame = Class
  Private 
    pw : PSDL_Window;
    pr  : PSDL_Renderer;

  Public 
    isRunning: boolean;
    Procedure Init(title : String;x,y,h,w,flags:integer );
    Procedure Render();
    Procedure Update();
    Procedure HandleEvents();
    Procedure Clean();
End;
Var 
  g : TGame;
Begin
  g := TGame.Create;
  g.Init('Hello',SDL_WINDOWPOS_CENTERED,SDL_WINDOWPOS_CENTERED,600,400,SDL_WINDOW_SHOWN);
  While g.isRunning Do
    Begin
      g.HandleEvents;
      g.Update;
      g.Render;
    End;
  g.Clean;
  g.Free;
End.

makefile

main:main.pas
	fpc -gh -Fusdl2 -Fl. main.pas

代码参考
https://gitee.com/tom-cat/sdl-hello/tree/v1.1

2. 渲染图片

渲染静态图片

新增一个Texture,然后Render出来
创建Texture,并获取尺寸

procedure TGame.Init(title: string; x, y, h, w, flags: integer);
begin
  .....

  pt := IMG_LoadTexture(pr, 'assets/run.png');

  SDL_QueryTexture(pt, nil, nil, @srcRect.w, @srcRect.h);
  destRect.x := srcRect.x;
  destRect.y := srcRect.y;
  destRect.w := srcRect.w;
  destRect.h := srcRect.h;

  ......
end;  

渲染出来

procedure TGame.Render();
begin
  SDL_SetRenderDrawColor(pr, 238, 238, 238, 255);
  SDL_RenderClear(pr);
  
  SDL_RenderCopy(pr, pt, @srcRect, @destRect);

  SDL_RenderPresent(pr);
end; 

渲染动画

渲染动画就就快速交替渲染多张图片

procedure TGame.Update();
begin
  srcRect.x := 96 * (round(SDL_GetTicks() / 100) mod 8);
end;

动画反转

本例中,如果人物需要朝相反方向行走,不用再搞一套素材

SDL_RenderCopyEx(pr, pt, @srcRect, @destRect,0, nil, SDL_FLIP_HORIZONTAL);

代码整理

代码味道

  • Texture有多个,不能简单的使用变量。要有一个Texture容器
  • 渲染时的Rect要和Texture的Render在一起防止错乱

新增一个TextureManager来统一的管理Texture,并解决以上两个问题

type
  TTextureDict = specialize TFPGMap<string, PSDL_Texture>;

  TTextureManager = class
  private
    textureMap: TTextureDict;
  public
    destructor Destroy();
    function Load(filename: string; id: string; pr: PSDL_Renderer): boolean;
    procedure Draw(id: string; x, y, w, h: integer; pr: PSDL_Renderer;
      flip: integer = 0);
    procedure DrawFrame(id: string; x, y, w, h, row, frame: integer;
      pr: PSDL_Renderer; flip: integer = 0);
  end;

由于多个TextureManager是不合适的 所以改为单例模式

private
constructor Init;
public
    class function Instance: TTextureManager;

完整代码见 https://gitee.com/tom-cat/sdl-hello/tree/v2.0/

3. 游戏对象管理

游戏中有很多类对象,例如:角色,敌人,NPC,陷阱,子弹,门等等.跟踪并处理它们之间的交互是一个有难度的事情。为了尽可能简化并使之容易维护,本节将尝试使用OOP(抽象继承多态等)来管理组织游戏对象

抽象与继承

首先定义一个公共的基类TGameObject

type TGameObject = class
protected
    x, y: integer;
    w, h: integer;
    currentFrame, currentRow: integer;
    textureId: string;
public
	procedure Load(x,y,w,h:integer;textureId:string);
	procedure Draw(pr:PSDL_Renderer);
	procedure Update();
	procedure Clean;
end;

然后游戏中出现的对象都可以继承TGameObject
根据需要可以覆盖对应的方法

TPlayer = class(TGameObject)
end;

TEnemy = class(TGameObject)

end;

多态

接下来在Game中定义对象的列表,由于定义的是TGameObjectList,利用继承的特性就可以存储所有子类对象

type Game = class
private
objects:TGameObjectList;
end;

利用OOP的多态 修改Update,Render,这样就实现职责分离(每个对象负责自己的Draw,Update,Clean)

procedure TGame.Update();
var
  i: integer;
begin
  for i := 0 to objects.Count - 1 do
  begin
    objects[i].Update();
  end;
end; 


procedure TGame.Render();
var
  i: integer;
begin
  SDL_RenderClear(pr);
  for i := 0 to objects.Count - 1 do
  begin
    objects[i].Draw(pr);
  end;

  SDL_RenderPresent(pr);
end;     

对象的初始化也需要对应修改一下

procedure TGame.Init(title: string; x, y, h, w, flags: integer);     
  b := (TTextureManager.Instance()).Load('assets/platform/arc2.png', 'animate', pr);

  go := TGameObject.Create;
  go.Load(0, 0, 40, 40, 'animate');
  objects.Add(go);

  player := TPlayer.Create;
  player.Load(50, 50, 40, 40, 'animate');
  objects.Add(player); 

end;

完整代码 https://gitee.com/tom-cat/sdl-hello/tree/v3.0/

4. 移动和用户输入

前言

目前已经可以在屏幕上渲染图片,并使用OOP的特性管理组织不同类的游戏对象
接下来需要添加用户控制功能.
常见的输入设备有: 鼠标 键盘 手柄

移动与向量

游戏的很多对象,即使没有用户输入,也要能移动。比如背景层,敌人等等。

为了处理移动的距离与方向,需要引入向量

使用向量修改TGameObject

  TGameObject = class
  ....
  public
    position: TVector2; // 位置
    velocity: TVector2; // 速度
    acceleration: TVector2; //加速度
  end;

然后修改Update函数

procedure TGameObject.Update();
begin
  velocity := velocity + acceleration;
  position := position + velocity;
end;

用户输入捕获与管理

新增一个TInputHandle类专门用于管理用户的各种输入

  TInputHandle = class
  public
    isQuit: boolean;
    destructor Destroy(); override;
    procedure Update;
    procedure Reset;
    function IsKeyDown(scancode: TSDL_Scancode): boolean;

  private
    keyState: PUInt8;
    procedure OnKeyEvent();

  end;

为了简单,目前只处理键盘事件

procedure TInputHandle.Update;
var
  e: TSDL_Event;
begin
  while SDL_PollEvent(@e) = 1 do
  begin
    case e.Type_ of
      SDL_QUITEV: isQuit := True;    
      SDL_KEYUP, SDL_KEYDOWN: onKeyEvent;
    end;
  end;

end;

procedure TInputHandle.onKeyEvent();
begin
  keyState := SDL_GetKeyboardState(nil);
  if isKeyDown(SDL_SCANCODE_ESCAPE) then
    isQuit := True;
end;

function TInputHandle.isKeyDown(scancode: TSDL_ScanCode): boolean;
begin
  if keyState <> nil then
    Result := keyState[scancode] > 0
  else
    Result := False;
end;

有了这些,我们就可以在每个TGameObject子类中使用不同的代码响应事件

procedure TPlayer.HandleInput;

var
  v: TVector2;
  f: integer;
begin
  v.x := 0;
  v.y := 0;
  f := 0;
  if TInputHandle.Instance().IsKeyDown(SDL_SCANCODE_RIGHT) then
    v.x := v.x + 1;
  if TInputHandle.Instance().IsKeyDown(SDL_SCANCODE_LEFT) then
    v.x := v.x - 1;

  if v.x >= 0 then
    f := SDL_FLIP_NONE
  else
    f := SDL_FLIP_HORIZONTAL;

  if v.isZero then
    angle := 0
  else
    angle := round(RadToDeg(arccos(abs(v.x) / v.length)));

  writelnlog('angle:%f', [angle]);
  flip := f;

  if samevalue(v.x, 0) then
    velocity.x := lerp(velocity.x, 0, acceleration.x *2)
  else
    velocity.x := lerp(velocity.x, v.x * speed, acceleration.x);

  writelnlog('player v:%s,velocity:%s', [v.toString, velocity.toString]);
end;

完整代码

5. 游戏状态管理与有限状态机

前言

启动画面显示开发商和发行商,然后显示加载中,后台进行初始化。成功后显示菜单页面,其中可以选择开始游戏和游戏设置。点击开始则进入到各个游戏场景,游戏中可以暂停和恢复游戏, 角色死亡显示GameOver画面。

不同的状态下需要渲染的游戏对象是不一样的。都放到Game类中管理不太合适。一旦TGameObject对象变化,维护起来非常容易出错。

解决思路就是每个状态管理自己的TGameObject列表, 为了协调不同状态之间的切换和过度。新增一个状态管理类。

于是就有了本文的有限状态机

抽象状态基类

  TGameState = class
  public
    stateId: string;
    procedure Update; virtual;
    procedure Render(); virtual;
    function OnExit: boolean; virtual;
    function OnEnter: boolean; virtual;
  protected
    objects: TGameObjectList
  end;

每个状态负责自己的TGameObject列表,并管理其渲染,更新

状态机

  TGameStateMachine = class
  public
    constructor Create;
    procedure PushState(state: TGameState);
    procedure ChangeState(state: TGameState);
    procedure PopState();
    procedure PopAndChangeState(state: TGameState);
    procedure Update;
    procedure Render();
  private
    states: TGameStateList;
  end;

状态机负责管理各种状态以及它们之间的过渡转换, Update和Render接口用于调用各个状态的Update和Render。并提供给TGame使用

实现状态

实现菜单状态前先要解决菜单项的绘制以及事件响应

  TButtonClickEvent = procedure of object;

  TMenuButton = class(TGameObject)
  public
    isReleased: boolean;
    OnClick: TButtonClickEvent;
    procedure Update; override;
  end;

在Update函数中判断鼠标的位置是否在矩形中以及左键是否按下来触发点击事件

procedure TMenuButton.Update;
var
  mp: TVector2;
begin
  currentFrame := Ord(MOUSE_OUT);
  mp := (TInputHandle.Instance()).GetMousePosition();
  if (mp.x < position.x + w) and (mp.x > position.x) and
  (mp.y < position.y + h) and (mp.y > position.y) then
  begin
    if not (TInputHandle.Instance()).isMouseButtonDown(Ord(MOUSE_LEFT)) then
    begin
      isReleased := True;
      currentFrame := Ord(MOUSE_OVER);
    end
    else
      if isReleased then
      begin
        writeln('mouse click');
        currentFrame := Ord(MOUSE_CLICKED);
        if Assigned(OnClick) then
          OnClick();

        isReleased := False;
      end;
  end
end;

接下来先实现刚进入时显示菜单的状态

  TMenuState = class(TGameState)
  public
    constructor Create(r: PSDL_Renderer);
    function OnEnter: boolean; override;
  private
    procedure PlayClick;
    procedure ExitClick;
    procedure CreateBackground;
  end;

OnEnter 用于创建TGameObjectList并绑定点击事件

function TMenuState.OnEnter: boolean;
var
  btn: TMenuButton;
begin
  (TTextureManager.Instance()).Load(pr, 'play_btn', 'assets/play_button.png');
  (TTextureManager.Instance()).Load(pr, 'exit_btn', 'assets/exit_button.png');

  CreateBackground();

  btn := TMenuButton.Create;
  btn.Load(100, 100, 400, 100, 'play_btn');
  btn.OnClick := @self.PlayClick;
  objects.Add(btn);

  btn := TMenuButton.Create;
  btn.Load(100, 300, 400, 100, 'exit_btn');
  btn.OnClick := @self.ExitClick;
  objects.Add(btn);
  Result := True;
end;

在每个点击事件代码中去创建目的状态

procedure TMenuState.PlayClick;
var
  state: TGameState;
begin
  readyState := TPlayState.Create(self.pr);
end;

并在基类的Update中处理状态的过渡

procedure TGameState.Update;
var
  i: integer;
begin
  writelnlog(stateId);
  if Assigned(readyState) then
  begin
    writelnlog(readyState.stateId);
    (TGameStateMachine.Instance()).changeState(readyState);
    exit;
  end;
  for i := 0 to objects.Count - 1 do
    objects[i].Update;
end;

最后就是修改TGame类,去除TGameObjectList的管理

procedure TGame.Update();
begin
  if not isRunning then
    exit;  
  (TGameStateMachine.Instance()).Update;
end;
procedure TGame.Render();
begin
  if not isRunning then
    exit;
  SDL_RenderClear(pr);
  (TGameStateMachine.Instance()).Render();
  SDL_RenderPresent(pr);
end;
procedure TGame.Init(title: string; x, y, h, w, flags: integer);
var state:TGameState;
begin
  .....
  state := TMenuState.Create(pr);
  (TGameStateMachine.Instance()).pushState(state);
  ....
end;

至于PlayState,PauseState依葫芦画瓢实现就可以了

完整代码

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值