使用 SDL2 和 ZetScript 的游戏引擎
使用 SDL2 和 ZetScript 的示例游戏引擎
更新:也可以通过以下链接运行演示:https://zetscript.org/demos/zs_ge/engine.html
引言
本文旨在展示如何在 C++ 应用程序中使用 SDL2 和 ZetScript 脚本引擎轻松构建一个简单的游戏引擎。该引擎将提供一组用于绘制图形、读取按键和使用 Simple DirectMedia Layer (SDL) 播放声音的函数。之后,我们将展示一些关于如何制作《Invader》游戏演示的脚本代码片段。
引用注意:本文不会深入介绍本文中呈现的游戏引擎的函数实现细节,但会展示类头文件和主函数中的一些代码片段,以便对所有内容如何协同工作有一个基本的了解。
如何使用演示
为了运行《Invader》演示游戏,请执行以下步骤:
- 将 zs_ge-1.1.0-x86-win32.zip 解压到某个目录。
- 将invader.zs 拖到engine.exe上。
控件
- 左/右:移动飞船
- 空格键:开始游戏/射击
- F5:重新加载脚本invader.zs
- F9:切换全屏
- ESC:退出引擎
要求
要编译代码,您需要 ZetScript 库、SDL2 库、cmake 应用程序,以及 MinGW 或 Linux (gnu 4.8 编译器) 或 MSVC 2015/2017 或 build tools v141。
如果满足要求,请转到解压源代码的目录并执行以下操作:
cmake
在 cmake
操作之后,将创建配置文件。
引用注意:如果配置 MVC++ 项目,您还必须提供包含路径和库路径,以便找到 SDL 和 ZetScript。
Engine
本文介绍的引擎版本将绘制图形、精灵、字体,播放声音,并从键盘读取输入键。该引擎具有以下类:
输入
Image
Sprite
字体
Render
声音
SoundPlayer
输入
CInput
类实现读取按键的函数。
#define T_ESC Input::getInstance()->key[KEY_ESCAPE]
#define T_F5 Input::getInstance()->key[KEY_F5]
#define T_F9 Input::getInstance()->key[KEY_F9]
#define T_SPACE Input::getInstance()->key[KEY_SPACE]
#define TR_UP Input::getInstance()->keyR[KEY_UP]
#define TR_LEFT Input::getInstance()->keyR[KEY_LEFT]
#define TR_RIGHT Input::getInstance()->keyR[KEY_RIGHT]
#define TR_DOWN Input::getInstance()->keyR[KEY_DOWN]
typedef struct{
Uint32 codeKey;
}EventKey,EventRepeatKey;
class Input{
static Input *input;
SDL_Event Event;
public:
bool key[KEY_LAST];
bool keyR[KEY_LAST];
static CInput * getInstance();
static void destroy();
void update();
};
Image
CImage
类实现加载 bmp 图像和基于二进制索引创建动态图像的函数。
#pragma once
#include <SDL2/SDL.h>
#include <zetscript.h>
class Image{
protected:
SDL_Texture *texture;
int width,height;
static SDL_Texture * SurfaceToTexture(SDL_Surface *srf);
bool createSquarePixmap(const vector<int> & pixmap);
void destroy();
public:
Image();
Image(const vector<int> & pixmap);
bool load(const char *file);
SDL_Texture *getTexture();
// create image from script ...
bool createSquarePixmap(zetscript::CVectorScriptVariable *vector);
int getWidth();
int getHeight();
~Image();
};
Sprite
CSprite
类实现设置和更新 xy 坐标中的精灵,并绘制其当前帧的函数。
#include "Image.h"
class Sprite{
static int synch_time;
unsigned current_frame;
int current_time_frame,time_frame;
public:
typedef struct{
Image *image;
Uint32 color;
}FrameInfo;
static void synchTime();
int x, y;
int dx, dy;
int width, height;
std::vector<FrameInfo> frame;
static bool checkCollision(Sprite *spr1, Sprite *spr2);
static bool checkCollision(int offset_x,int offset_y,CSprite *spr1, CSprite *spr2);
CSprite();
void addFrame(CImage *fr, int rgb);
void setFrame(int n);
void setTimeFrame(int time);
int getWidth();
int getHeight();
Sprite::FrameInfo * getCurrentFrame();
void update();
~Sprite();
};
字体
CFont
类实现加载 bmp 字体的函数。用户需要提供每个字符的尺寸,以便正确对齐其渲染的所有字符。
#include "CImage.h"
class Font:public Image{
int totalchars_x, totalchars_y,totalchars;
SDL_Rect m_aux_rect;
int char_width,char_height;
public:
CFont();
bool load(const char * file,int char_width,int char_height);
int getCharWidth();
int getCharHeight();
int getTextWith(const string & str);
SDL_Rect * getRectChar(char c);
~CFont();
};
Render
CRender
类实现使用字体绘制图像、精灵和文本的函数。
class Render{
int width, height;
static Render *render;
SDL_Renderer *p_renderer = NULL;
SDL_Event event;
bool fullscreen;
SDL_Window* p_window = NULL;
Render();
~Render();
public:
static Render *getInstance();
static void destroy();
int getWidth();
int getHeight();
void createWindow(int width, int height);
void toggleFullscreen();
SDL_Renderer *getRenderer();
SDL_Window *getWindow();
void clear(Uint8 r, Uint8 g, Uint8 b);
void drawImage(int x, int y, CImage *img);
void drawImage(int x, int y, CImage *img, int color);
void drawText(int x,int y, CFont * font, const char * text);
void drawSprite(CSprite *spr);
void drawSprite(int x, int y, CSprite *spr);
void update();
};
CSound
CSound
类实现加载声音文件(仅支持 wave)的函数。
class Sound{
public:
Uint32 wav_length;
Uint8 *wav_buffer;
Sound();
bool load(string * file);
~Sound();
};
SoundPlayer
CSoundPlayer
实现播放 CSound
对象和初始化声音系统的函数。
#define MAX_PLAYING_SOUNDS 20
class SoundPlayer{
SDL_AudioSpec wav_spec;
typedef struct {
Uint8 *audio_pos;
Uint32 audio_len;
}SoundData;
static SoundData SoundData[MAX_PLAYING_SOUNDS];
static SoundPlayer * singleton;
SDL_AudioDeviceID dev;
SoundPlayer();
~SoundPlayer();
static void callbackAudio(void *userdata, Uint8* stream, int len);
public:
static SoundPlayer * getInstance();
static void destroy();
void setup(SDL_AudioFormat format=AUDIO_S16SYS,
Uint16 Freq=22050, Uint16 samples=4096, Uint8 channels=2);
void play(CSound *snd);
};
绑定 C++ 代码以供脚本使用
在最后几节中,我们看到了构成游戏引擎图形、输入和声音管理功能的类。本节也许是最令人兴奋的部分,我们将看到如何轻松地将 C++ 头文件的主要部分绑定到通过 ZetScript
API 在脚本端使用。
我们有 register_C_Class
和 register_C_SingletonClass
分别用于注册类和单例。register_C_VariableMember
、register_C_FunctionMember
用于注册其类成员。此外,register_C_FunctionMember
也用于注册 C 函数。
例如,我们通过以下方式注册 CImage
以便在脚本中使用:
registerNativeClass<CImage>("CImage")
同样的方式,注册其函数成员 CImage::load
:
registerNativeFunctionMember<CImage>("load",&CImage::load);
以下代码显示了我们在脚本端需要使用的所有函数和变量。我们将看到,只需几行代码,我们就注册了所有这些!
// Binds CImage class...
registerNativeClass<CImage>("Image")) return false;
// bind a custom constructor...
registerNativeFunctionMember<Image>("Image",static_cast<bool
(Image::*)(zetscript::CVectorScriptVariable * )>(&Image::createSquarePixmap));
registerNativeFunctionMember<Image>("load",&Image::load);
// Binds Sprite class and it members...
registerNativeClass<Sprite>("Sprite");
registerNativeFunction("checkCollision",static_cast<bool
(*)(int, int, Sprite *, Sprite *)>(&Sprite::checkCollision));
registerNativeFunction("checkCollision",static_cast<bool
(*)(Sprite *, Sprite *)>(&Sprite::checkCollision));
registerNativeVariableMember<Sprite>("x",&Sprite::x);
registerNativeVariableMember<Sprite>("y",&Sprite::y);
registerNativeVariableMember<Sprite>("dx",&Sprite::dx);
registerNativeVariableMember<Sprite>("dy",&Sprite::dy);
registerNativeVariableMember<Sprite>("width",&Sprite::width);
registerNativeVariableMember<Sprite>("height",&Sprite::height);
// Sprite functions
registerNativeFunctionMember<Sprite>("setTimeFrame",&Sprite::setTimeFrame);
registerNativeFunctionMember<Sprite>("update",&Sprite::update);
registerNativeFunctionMember<Sprite>("addFrame",&Sprite::addFrame);
// Binds Font class
registerNativeClass<Font>("Font");
registerNativeFunctionMember<Font>("load",&Font::load);
// Binds Sound class
registerNativeClass<Sound>("Sound");
registerNativeFunctionMember<Sound>("load",&Sound::load);
registerNativeSingletonClass<Render>("Render");
registerNativeFunction("getRender",Render::getInstance);
registerNativeFunctionMember<Render>
("drawImage",static_cast<void (Render:: *)(int, int, Image *)>(&Render::drawImage));
registerNativeFunctionMember<Render>
("drawImage",static_cast<void (Render:: *)(int, int, Image *,int)>(&Render::drawImage));
registerNativeFunctionMember<Render>("drawText",&Render::drawText);
registerNativeFunctionMember<Render>
("drawSprite",static_cast<void (Render:: *)(int, int, Sprite *)>(&Render::drawSprite));
registerNativeFunctionMember<Render>
("drawSprite",static_cast<void (Render:: *)(Sprite *)>(&Render::drawSprite));
registerNativeFunctionMember<Render>("getWidth",&Render::getWidth);
registerNativeFunctionMember<Render>("getHeight",&Render::getHeight);
// Binds singleton SoundPlayer...
registerNativeSingletonClass<SoundPlayer>("SoundPlayer");
registerNativeFunction("getSoundPlayer",&SoundPlayer::getInstance);
registerNativeFunctionMember<SoundPlayer>("play",&SoundPlayer::play);
// Binds input global vars...
registerNativeVariable("TR_UP",TR_UP);
registerNativeVariable("TR_DOWN",TR_DOWN);
registerNativeVariable("TR_LEFT",TR_LEFT);
registerNativeVariable("TR_RIGHT",TR_RIGHT);
registerNativeVariable("T_SPACE",T_SPACE);
加载脚本文件
ZetScript
提供一个名为 eval_file 的函数来加载脚本文件,如下面的代码所示:
zs.evalFile("file.zs");
绑定脚本函数
文件加载后,我们需要将加载文件中的脚本函数暴露给游戏引擎使用。脚本文件应实现这三个函数:
init
:初始化变量、结构、精灵、加载图像等。update
:将在 C++ 游戏引擎循环中调用。unload
:在游戏卸载时调用。
通过传递我们想要在脚本端使用的函数指针来绑定 script
函数,这是通过 bind_function
完成的。通常,所有 script
函数都不会传递任何参数,也不会返回值,因此函数指针将是 void(void)
。
以下代码显示了如何绑定这些函数:
std::function<void()> * init=zs.bindScriptFunction<void ()>("init");
std::function<void()> * update=zs.bindScriptFunction<void ()>("update");
std::function<void()> * unload=zs.bindScriptFunction<void ()>("unload");
游戏引擎循环
最后,我们展示了游戏引擎循环。代码如下:
render=Render::getInstance();
input=Input::getInstance();
(*init)(); // <-- it calls script function init.
do{
render->clear(0,0,0); // clears screen with black color
input->update(); // read events from keyboard.
(*update)(); // <-- it calls script function update.
render->update(); // flip screen
}while(!T_ESC);
(*unload)(); // <-- it calls script function unload
重新评估脚本文件
正如我们之前所说,出于开发目的,有时重新评估脚本文件很有趣。动态地重新评估比重新执行引擎要快,只需按一个简单的键。我们修改了之前呈现的代码 1.8,如下所示:
do{
// clear screen...
render->clear(0,0,0);
// update keyboard events...
input->update();
// if press F5 then reload file...
if(T_F5) {
try{
(*unload)();
//Now, the state is the same as we had before eval the script...
zetscript->evalFile(argv[1])){
// Recreate script functions...
init=bindScriptfunction<void ()>("init");
update=bind_function<void ()>("update");
unload=bind_function<void ()>("unload");
// Call init function...
(*init)();
}catch(std::exception & ex){
fprintf(stderr,"%s",ex.what());
}
}
try{
(*update)();
}catch(std::exception & ex){
fprintf(stderr,"%s",ex.what());
}
// update screen...
render->update();
}while(!T_ESC);
代码 1.10 实现了一个刷新行为,就像浏览器一样,即当用户按下 F5 键时,脚本会被重新评估。由于重新评估文件后内存会发生变化,因此必须重新创建 ini
、update
和 unload
绑定函数。
《Invader》游戏
我们已经展示了我们 C++ 游戏引擎的处理实现,该引擎可以处理从脚本文件中实现的已完成游戏。现在,我们将展示如何使用游戏引擎实现一个 Invader
游戏。
首先,正如我们之前提到的,引擎期望在脚本端实现三个函数:
init
update
unload
这三个函数在文件中声明,如下面的代码所示:
function init(){
}
function update(){
}
function unload(){
}
加载图像
引擎支持两种类型的图像:二进制图像和位图图像。
要加载位图,我们可以使用 CImage::load
,例如,《Invader》游戏加载标题游戏位图,名为 title.bmp。变量声明及其加载是通过以下方式完成的:
var invader_title;
function init(){
invader_title=new Image();
invader_title.load("invader_title.bmp");
}
要加载基于位的图像,我们必须使用我们注册的 CImage
构造函数 CImage::createScquarePixmap
。
如果我们记得列表 1.7 中的代码,我们注册了 CImage::createSquarePixmap
函数成员,并将其命名为 CImage
(与类名相同)。
registerNativeFunctionMember<Image>("Image",static_cast<bool
(Image::*)(zetscript::CVectorScriptVariable * )>(&Image::createSquarePixmap));
这意味着 CImage::createSquarePixmap
在脚本端被声明为 CImage
构造函数,因为在脚本端暴露的函数名与类名相同,即 CImage
。CVectorScriptVariable
是 ZetScript
的向量类型(有关更多信息,请访问 zetscript.org)。
例如,以下代码创建 CImage
,传递一个包含二进制整数的向量作为参数:
new Image( <-- Image constructor
[ // <-- vector variable
00100000100b
,10010001001b
,10111111101b
,11101110111b
,11111111111b
,01111111110b
,00100000100b
,01000000010b
]
)
这代表了以下图像:
二进制格式的 1 表示绘制,0 表示不绘制。
引用注意:游戏中实现了更多图像,但为了避免文章过于冗长,我省略了它们。
实现我们自定义的 Sprite 类
我们在列表 1.3 中看到了 Sprite
类。ZetScript
具有继承 C++ 类的功能。我们定义了 MyClassSprite
类,它继承了 Sprite
(来自 C++)。
//Defines CMySprite class. inherits CSprite (from C++)
class MySprite: Sprite{
constructor(){ // constructor with no parameters
this.time_life=0; // tells remaining time to have this sprite living
this.attack_time=0; // tells time to do next attack
this.points=undefined; // tells the value to add in the score
// when sprite is destroyed.
this.active=false; // tells if sprite is active or not
this.color=0xFFFFFF; // tells sprite color
}
constructor(_points,_color){ // constructor with arguments
this.active=false;
this.points=_points;
this.color=_color;
this.time_life = 0;
this.attack_time=0;
}
// adds image frame...
addFrame(_img){
super(_img, this.color); // <-- calls CImage::addFrame (from C++)
}
// updates sprite
update(){
if(this.time_life>0){
if(this.time_life < currentTime()){
this.active=false;
}
}
super(); // it calls CSprite::update (from C++)
}
};
Sprite 管理器
在为脚本定义了自定义的 MySprite
类之后,我们将介绍另一个重要类,它将管理同一类型精灵的逻辑流程。SpriteManager
将创建和更新一组相同类型的精灵。
在 invader
游戏中,我们有以下精灵类型:
- 敌方子弹精灵
- 我方子弹精灵
- 敌方精灵
为了更好地解释 SpriteManager
的工作原理,我们展示了 SpriteManager
类的简化代码,其中包含原始源代码中的重要内容。
class SpriteManager{
// it creates spritemanager with a vector of sprite type defined by max_sprites
constructor(max_sprites, image,_max_time_life){
this.x_base=0; // offset x of sprites
this.y_base=0; // offset y of sprites
this.dx=0; // dx update basex foreach iteration.
this.dy=0; // dy update basey foreach iteration.
this.next_mov=0; // tells time to do next move
this.sprite=[]; // vector of sprites
this.free_index=[]; // vector telling free sprite slots
this.max_time_life=_max_time_life; // tells time life for each sprite created.
for(var i=0; i < max_sprites; i++){
var spr=new CMySprite();
spr.addFrame(image);
this.sprite.add(spr);
this.free_index.add(i);
}
}
// it creates a sprite at start_x, start_y
create(start_x, start_y, _dx, _dy){
var index;
if(this.free_index.size()>0)
{
// pops the last value and set sprite as active ...
index= this.free_index.pop();
this.sprite[index].x= start_x;
this.sprite[index].y=start_y;
this.sprite[index].dy=_dy;
this.sprite[index].dx=_dx;
this.sprite[index].active=true;
if(this.max_time_life>0){
this.sprite[index].time_life=currentTime()+this.max_time_life;
}
}
}
// check collision of sprite given within sprites (to override)
checkCollision(spr)
{
}
// function doAttack (to override)
doAttack(spr){
}
// removes sprite at index i
remove(i){
this.sprite[i].active=false;
this.free_index.add(i);
}
update(){
for(var i=0; i < this.sprite.size(); i++)
{
var spr=this.sprite[i];
if(spr.active){
// check if sprite collides with SpriteManager::check_collision
this.checkCollision(spr);
// updates sprite
spr.update();
// check if sprite attacks with SpriteManager::doAttack
this.doAttack(spr);
// if sprites goes outside screen or its lifetime ends, remove it.
if(
(spr.y<-spr.height || spr.y>render.getHeight())
|| (spr.time_life>0 && spr.time_life<currentTime())
|| (spr.x<-spr.width || spr.x> render.getWidth())
){
this.remove(i);
}
render.drawSprite(this.x_base,this.y_base,spr); // paint always..
}
}
}
};
使用 SpriteManager
,我们可以将敌方子弹、我方子弹和爆炸实例创建为 SpriteManager
对象。
const MAX_ENEMY_BULLETS=20;
const MAX_HERO_BULLETS=10;
const MAX_EXPLOSIONS=20;
var enemy_bullet;
var hero_bullet;
var explosion;
function init(){
...
enemy_bullet=new SpriteManager(MAX_ENEMY_BULLETS,image[7],0);
hero_bullet=new SpriteManager(MAX_ENEMY_BULLETS,image[9],0);
explosion=new SpriteManager(MAX_EXPLOSIONS,image[8],200);
...
}
另一方面,敌方精灵管理器有自定义的 SpriteManager
实现,因为每个敌人都是一个用于动画的精灵,有两个帧。此外,敌方 SpriteManager
必须实现一些函数,如 doAttack
或 check_collision
。
接下来,以下代码展示了 EnemyManager
的简化实现(为了清楚地看到重写的 doAttack
和 checkcollision
):
class EnemyManager:SpriteManager{
var time_mov;
var y_top;
function constructor(){
this.sprite=[];
this.x_base=20;
this.y_base=50;
var x=0;
// creating 15*3 invaders...
for(var i=0; i < 3; i++){
//var j=0;
for(var j=0; j < 15; j++){
var color=0;
var frame1=0;
var frame2=0
if(i == 0){ // invader type 0
color = 0x00FF00; // green color
frame1=0; // image index 0 as frame1
frame2=1; // image index 1 as frame1
}else if(i==1){ // invader type 1
color = 0x00FFFF; // yellow color
frame1=2; // image index 2 as frame1
frame2=3; // image index 3 as frame1
}else if(i == 2){ // invader type 2
color = 0xFFFF00; // cyan color
frame1=4; // image index 4 as frame1
frame2=5; // image index 5 as frame1
}
// creates sprite and sets parameters
var spr=new CMySprite(100,color);
spr.active=true;
this.sprite.add(spr);
spr.addFrame(image[frame1]);
spr.addFrame(image[frame2]);
spr.setTimeFrame(500);
spr.x=x;
spr.y=18*i;
spr.attack_time=currentTime()+rand()%BASE_TIME_ATTACK+5000;
x+=16;
}
}
}
// implement check_collision. It check collision for all hero bullets...
function checkCollision(spr){
if(spr.active){
for(var i=0;i < hero_bullet.sprite.size(); i++){
if(hero_bullet.sprite[i].active){
if(checkCollision(this.x_base,this.y_base,spr,hero_bullet.sprite[i])){
hero_bullet.remove(i);
spr.active=false;
this.base_mov-=15;
score+=100;
if(top_score<score){
top_score=score;
}
explosion.create(spr.x,spr.y,0,0);
}
}
}
}
}
// implements doAttack for enemy sprite.
function doAttack(spr){
if(spr.attack_time>0){
if(spr.attack_time<currentTime()){
spr.attack_time=currentTime()+BASE_TIME_ATTACK+rand()%5000;
// it creates enemy bullet at enemy position with 2 as y velocity
enemy_bullet.create(spr.x,spr.y,0,2);
}
}
}
};
在列表 2.6 中,我们可以看到敌方的精灵管理器实现会检查每个精灵与我方子弹的碰撞,当我方子弹与某些敌方精灵碰撞时,它会增加分数并在敌方死亡的位置创建爆炸。
在 doAttack
函数中,它实现了敌方是否准备好攻击,这基本上是在敌方所在位置创建一个子弹。
Update 函数
update
函数是引擎在每次迭代中调用的主 update
函数,它将更新 init
函数中实例化的所有 SpriteManager
对象,如下面的代码所示:
hero_bullet.update();
explosion.update();
enemy.update();
enemy_bullet.update();
在同一个函数中,我们必须知道是否有敌方子弹与我方飞船精灵发生碰撞,然后执行相应的操作。
for(var i=0;i < enemy_bullet.sprite.size(); i++){
if(enemy_bullet.sprite[i].active){
if(checkCollision(hero,enemy_bullet.sprite[i])){
enemy_bullet.remove(i);
// do actions when hero is destroyed.
}
}
}
此外,update
函数将通过 main.cpp 中已注册的 T_LEFT
/T_RIGHT
变量来检查是否按下了左或右键。如果按下其中一个键,我方飞船将分别向左/右移动。关于我方子弹,如果按下空格键,我方将在我方飞船位置创建一个我方子弹,如下面的代码所示:
if(TR_LEFT){
hero.x--;
}
if(TR_RIGHT){
hero.x++;
}
if(T_SPACE){ // creates a bullet hero with -5 as velocity in y
hero_bullet.create(hero.x, hero.y, 0, -5);
}
结论
在本文中,我们看到了一个 C++ 应用程序的完整示例,它结合了 ZetScript 作为游戏引擎,使我们能够在脚本端绑定 C++ 函数和变量。我很想更深入地探讨游戏引擎的工作原理,但这会超出本文的主要目的,而且可能有点枯燥。
《Invader》游戏大约有 800 行代码,如果 SpriteMannager
是在 C++ 端实现的,那么代码量会更少,但我更愿意尽可能多地展示 C++ 与 ZetScript 的结合,以展示其优势。
历史
- 2020-09-XX ZetScript 游戏引擎 2.0.0:自 2.0 以来的更改
- 2018-02-26:将 invader 演示移植到在线版本,感谢 emscripten。
- 2018-02-21 ZetScript 游戏引擎 1.1.0:自 1.2 以来的更改
- 2017-11-29 ZetScript 游戏引擎 1.0.0:首次发布