type
Post
status
Published
date
Jan 22, 2023
slug
game_dev-01
summary
《世嘉新人培训教材—游戏开发》学习笔记
tags
C++
Game
category
GameDev
icon
password
学习资料来自《世嘉新人培训教材—游戏开发》[日]平山尚 著 罗水东 译

🎮1.1 第一个游戏:实例代码分析

👉 简单介绍

 
这本书的第一章主要由三个部分构成:(注:和原书分类不一致,是根据个人理解分类)
  1. 实例代码的分析(结构和功能)
  1. 添加游戏开发中的重要功能:读取场景数据
  1. C++知识点
这篇文章主要讲解 1.实例代码的分析(结构与功能),会展示不同代码块的源码,并分别分析它们的主要功能和逻辑,提出需要注意的点。
 
(其中下划线和带星号的部分是我没有理解全面的地方,连同最后的疑难问题,我会尝试在下一个同主题文章中解答)

🦝实例代码分析(结构和功能)

🦈关于游戏程序的最简单总结:

游戏程序:
  1. 获取输入
  1. 将输入更新到游戏中
  1. 显示结果
简单代码:
while(true) { getInput(); updateGame(); draw(); }

《箱子搬运工》小游戏代码分析

这里分析一下《箱子搬运工》小游戏代码的大致结构
💥代码由《世嘉新人培训教材—游戏开发》一书提供,可以自行使用(包括商用),作者和笔者不承担任何责任,使用时请尊重他人劳动成果,请不要损毁世嘉和笔者的名誉。
 

一)include头部文件

#include <iostream> using namespace std;

二)场景数据常量

//#墙 _空白区 .终点 o砖块 p人 const char gStageData[] = "\ ########\n\ # .. p #\n\ # oo #\n\ # #\n\ ########"; const int gStageWidth = 8; const int gStageHeight = 5;
注意点:
  • 为什么不一开始用0代表空间,1代表玩家:先用容易理解的字符串进行赋值,游戏开始后再将其转化为其他形式,这样做更简单。
  • 因为这里的值后续都不需要修改,所以使用全局变量const,可避免很多bug
  • 全局变量可以在程序中的任何地方被访问,所以命名时可以以g开头,方便识别
 

三)枚举类型

enum Object { OBJ_SPACE, OBJ_WALL, OBJ_GOAL, OBJ_BLOCK, OBJ_BLOCK_ON_GOAL, OBJ_MAN, OBJ_MAN_ON_GOAL, OBJ_UNKNOWN, };
注意点:
  • 枚举类型数组的作用:保存场景中的所有状态(在一个容量等于“宽*高”的枚举类型数组中)
  • 另一种存放数据的方法:将是否是目的地的信息*存放在另一个数组中,或者通过位运算*将两种信息保存在同一个数组里
 

四)函数原型(声明需要使用的函数)

//读取场景字符串并将其转化成Object数组 void initialize( Object* state, int w, int h, const char* stageData ); //画面的绘制 void draw( const Object* state, int w, int h ); //*为什么这里用的是const //更新 void update( Object* state, char input, int w, int h ); //检查是否通关 bool checkClear( const Object* state, int w, int h );

五)main函数(游戏主逻辑,会调用上述函数)

int main(){ //创建状态数组,注意是一维数组 Object* state = new Object[ gStageWidth * gStageHeight ]; //分配状态数组空间 initialize( state, gStageWidth, gStageHeight, gStageData ); //舞台初始化 //主循环 while ( true ){ //首先绘制 draw( state, gStageWidth, gStageHeight ); //通关检测 if ( checkClear(state, gStageWidth, gStageHeight ) ){ break; //通关检测 } //提示如何操作+获取输入 cout << "a:left s:right w:up z:down. command?" << endl; //操作说明 char input; cin >> input; //更新 update( state, input, gStageWidth, gStageHeight ); } //通关祝贺信息 cout << "Congratulation's! you won." << endl; //析构 delete[] state; state = 0; //为了避免运行完一闪而过,这里添加一个无限循环。命令行中按下Ctrl-C即可终止 while( true ){ ; } return 0; }
注意点:
  • 枚举类型是一种用于列举的类型,所以可以通过new生成,也可以用作参数和返回值*
  • 枚举类型内部的变量只能通过枚举类型来赋值,如果直接赋值会导致编译错误
  • 逻辑上Object应该是一个二维数组,但是这里使用了一维数组的创建方式,原因是二维数组无法通过new动态创建,会丧失灵活性。
  • 养成及时释放空间的习惯,注意是delete[]而非delete
  • 释放空间后最好将指针赋值为0,很大程度上避免指针相关的bug
  • 注意主循环中函数的顺序:draw() → checkClear() → getInput → update()
  • getInput之前需要先检测是否通关,避免响应输入之前已经通关了却没有被检测出来
 

六)初始化场景

//////////以后可能会需要传入高度值,但是目前没用到,所以把参数变量名height先注释掉 void initialize( Object* state, int width, int /* height */, const char* stageData ){ const char* d = stageData; //数据读取位置 int x = 0; int y = 0; while ( *d != '\0' ){ //不等于NULL Object t; //临时变量,并没有特殊的含义而且被频繁使用一般可以命名为t。否则弄一个长命名读起来也费劲,没有必要 switch ( *d ){ case '#': t = OBJ_WALL; break; case ' ': t = OBJ_SPACE; break; case 'o': t = OBJ_BLOCK; break; case 'O': t = OBJ_BLOCK_ON_GOAL; break; case '.': t = OBJ_GOAL; break; case 'p': t = OBJ_MAN; break; case 'P': t = OBJ_MAN_ON_GOAL; break; case '\n': x = 0; ++y; t = OBJ_UNKNOWN; break; //换行处理 default: t = OBJ_UNKNOWN; break; } ++d; //数组地址递增 if ( t != OBJ_UNKNOWN ){ //这个if处理的意义在如果遇到未定义的元素值就跳过它 state[ y*width + x ] = t; //写入 ++x; } } }
注意点:
  • 初始化部分的代码主要逻辑是:逐个读取字符并将其转换为Object类型。将stageData里的字符串转化为Object类型写入state数组中。
  • 最后的if语句是为了忽略非法输入。最好的方法是通过场景数据自行计算出宽度和高度值,我们可以封装一个函数来实现这个功能*
 

七)绘制

void draw( const Object* state, int width, int height ){ const char font[] = {' ', '#', '.', 'o', 'O', 'p', 'P'}; //Object的可能值 for ( int y = 0; y < height; ++y ){ for ( int x=0; x < width; ++x ){ Object o = state[ y*width + x ]; cout << font[ o ]; } cout << endl; } }
注意点:
  • draw()的功能和initialize()正好相反,是把Object数组里的内容转化成字符,再通过cout输出。这部分在一些游戏中也是类似的,只不过是把程序内的数据转化为图像渲染出来。
  • 这里的思路是,创建一个char array,并且数组中字符的顺序对应Object数组的内元素的顺序,这样在for循环中,o的int值就代表了与之对应的字符的index,font[o]可以直接打印出我们想要的字符。另一种方法是在for循环中用switch进行处理:
Object o = state[y*width + x]; switch (o) { case OBJ_SPACE : cout << " "; break; case OBJ_WALL : cout << "#"; break; case OBJ_GOAL : cout << "."; break; case OBJ_BLOCK : cout << "o"; break; case OBJ_BLOCK_ON_GOAL : cout << "O"; break; case OBJ_MAN : cout << "p"; break; case OBJ_MAN_ON_GOAL : cout << "P"; break; }
 

八)更新

Update部分往往是游戏的核心,所以代码也比较长,我会逐步讲解。
🦁参数
//第一个参数就是其他函数中的state,不过这里因为会频繁使用, //就缩写为s。另外两个参数w,h分别表示width,height void update( Object* s, char input, int w, int h ){
  • 一般来说,用单个的字母作为参数变量名不是很好的习惯。但是如果该变量所在的代码很短,使用频率也很高,单个字母不容易和其他变量搞混,那只要在一开始加上详细的注释,就可以使用单个字母来命名。
 
🐯输入
//移动量变换(变量名中的d可以理解为difference或者delta) int dx = 0; int dy = 0; switch ( input ){ case 'a': dx = -1; break; //向左 case 's': dx = 1; break; //右 case 'w': dy = -1; break; //上。Y朝下为正 case 'z': dy = 1; break; //下。 }
 
🐱检测玩家位置
//查询小人的坐标 int i = -1; for ( i = 0; i < w * h; ++i ){ if ( s[ i ] == OBJ_MAN || s[ i ] == OBJ_MAN_ON_GOAL ){ break; } } int x = i % w; //x是对宽度的余数 int y = i / w; //y是对宽度的商
注意点:
  • 有方法可以不需要检索玩家位置,而且运行起来更快:将玩家位置保存在某个变量中。
  • 书中提到的方法:创建一个Satge类等,并在其中设置一个成员变量,用于查找结束时保存玩家的位置,那么就可以在保持代码简洁的同时保证处理效率。* 但是这个方法可能也会导致错误,即通过查找获取的玩家位置和保存在变量中的玩家位置可能会不一样(*是在何种情况中呢?)所以在不影响速度的情况下可以每次都检查一遍玩家位置。
  • 代码中的
    • if ( s[ i ] == OBJ_MAN || s[ i ] == OBJ_MAN_ON_GOAL )
      将玩家的情况分为了两类,显得不够简洁,可以封装在isPlayer()这样的函数中或者单独将“是否是目的地”的信息保存到另外的变量中(*可以之后尝试)
 
🦊移动
//移动 //移动后的坐标(t并没有特定的代表含义) int tx = x + dx; int ty = y + dy; //判断坐标的极端值。不允许超出合理值范围 if ( tx < 0 || ty < 0 || tx >= w || ty >= h ){ return; } //A.该方向上是空白或者终点。小人则移动 int p = y*w + x; //人员位置 int tp = ty*w + tx; //目标位置(TargetPosition) if ( s[ tp ] == OBJ_SPACE || s[ tp ] == OBJ_GOAL ){ s[ tp ] = ( s[ tp ] == OBJ_GOAL ) ? OBJ_MAN_ON_GOAL : OBJ_MAN; //如果该位置是终点,则将该位置值变为“终点上站着人” s[ p ] = ( s[ p ] == OBJ_MAN_ON_GOAL ) ? OBJ_GOAL : OBJ_SPACE; //如果该位置已经是“终点上站着人”,则变为“终点” //B.如果该方向上是箱子。并且该方向的下下个格子是空白或者终点,则允许移动 }else if ( s[ tp ] == OBJ_BLOCK || s[ tp ] == OBJ_BLOCK_ON_GOAL ){ //检测同方向上的下下个格子是否位于合理值范围 int tx2 = tx + dx; int ty2 = ty + dy; if ( tx2 < 0 || ty2 < 0 || tx2 >= w || ty2 >= h ){ //按键无效 return; } int tp2 = ( ty + dy )*w + ( tx + dx ); //下下个格子 if ( s[ tp2 ] == OBJ_SPACE || s[ tp2 ] == OBJ_GOAL ){ //按顺序替换 s[ tp2 ] = ( s[ tp2 ] == OBJ_GOAL ) ? OBJ_BLOCK_ON_GOAL : OBJ_BLOCK; // if the step next to the next step(same direction)is goal(without block on it): s[tp2] -> block on goal s[ tp ] = ( s[ tp ] == OBJ_BLOCK_ON_GOAL ) ? OBJ_MAN_ON_GOAL : OBJ_MAN; //If the next step is block on the goal: Man pushes the block out of the goal, and it stands on the goal. s[ p ] = ( s[ p ] == OBJ_MAN_ON_GOAL ) ? OBJ_GOAL : OBJ_SPACE; // if the position right now is person on the goal: turn the block to the goal again. } }
注意点:
  • 命名习惯中:dx中的d是“difference”,tx中的t是“temporary”
  • 这里最重要的就是搞清楚人物移动有哪几种具体情况,这里有两大情况玩家可以移动:
    • A.Target Position是空白或者目的地:
      判断tp是空白还是目的地,然后改变tp和p的Object值
      B.Target Position是箱子,并且沿该方向的下一个网格是空白或者目的地:
      判断tp有没有箱子和tp2是不是空白或者目的地,然后逐步改变tp2,tp,p的位置
  • 如果将“是否在目的地”的信息储存在别处,代码会更加简洁(*之后可尝试)
  • 作者省略了tp为墙壁的代码,但是为了让代码更容易理解应当加上
  • 注意检测推动的有效性
 

九)通关检测

//如果没有方块了则判定为通关 bool checkClear( const Object* s, int width, int height ){ for ( int i = 0; i < width*height; ++i ){ if ( s[ i ] == OBJ_BLOCK ){ return false; } } return true; }
注意点:
  • 如果场景数据出错,导致目的地的数量比箱子还多,也依然会判定通过。所以初始化时必须仔细检查场景数据。
 

🐃总结归纳

回顾一下这160行代码的主要结构:
  1. 包含头文件 #inclue ..
  1. 枚举类型 enum xx {};
  1. 数据场景常量
  1. 函数声明
  1. 游戏主逻辑(main函数):初始化initialize() loop: draw()→checkClear()→getInput→update()
  1. 初始化函数initialize():逐个读取字符并将其转换为Object类型
  1. 绘制函数draw():是把Object数组里的内容转化成字符,再通过cout输出
  1. 更新函数update():输入,检测玩家位置,移动
  1. 通关检测函数

🐄待解答问题

(下一篇本主题文章的时候会尝试解答,如果找到正确答案会将问题划去)
  1. 如何调试来看看到枚举类型的名字?
  1. main函数中,为什么最后只释放了枚举类型的指针?stageData为什么不需要释放?

下一章提要

💡
添加游戏开发中的重要功能:读取场景数据
游戏开发学习笔记01 - [1.1 实例代码的分析游戏开发学习笔记01 - [1.1 实例代码的分析

tricky_tree
tricky_tree
Animation &. Game // Learn to share, share to learn.