游戏开发世嘉新人培训教材学习: 爆弾ビト(炸弹人)游戏代码全面解析
本文件是对
43-BakudanBitoWithImprovedSequence项目的从零开始中文讲解。
这是一个类似《炸弹超人(Bomberman)》的 C++ 小游戏,使用自制的 GameLib 框架运行在 Windows 上。
目录
- 整体结构总览
- 程序启动流程 main.cpp
- 序列(Scene)管理系统
- 3.1 什么是"序列"
- 3.2 外层父节点 Sequence::Parent
- 3.3 内层子节点 Sequence::Child(接口)
- 3.4 各场景详解
- 游戏内部的子状态系统 Sequence::Game
- 4.1 Game::Parent(游戏管理器)
- 4.2 游戏内各子状态
- 输入系统 Pad
- 游戏核心逻辑 State
- 6.1 地图初始化
- 6.2 每帧更新 update()
- 6.3 炸弹与爆炸机制
- 6.4 连锁爆炸的时序问题与解决方案
- 静态对象 StaticObject(格子)
- 动态对象 DynamicObject(玩家/敌人)
- 碰撞检测数学原理
- 二维数组模板 Array2D
- 完整序列状态机图
- 关键设计思想总结
1. 整体结构总览
项目的文件目录大致如下:
43-BakudanBitoWithImprovedSequence/
├── main.cpp ← 程序入口
├── Pad.h / Pad.cpp ← 键盘/手柄输入封装
├── Square.h / Square.cpp ← 矩形碰撞辅助类
├── Image.h / Image.cpp ← 图片封装
├── Array2D.h ← 二维数组模板
├── Sequence/ ← 场景管理
│ ├── Parent.h/cpp ← 最外层场景管理器
│ ├── Child.h ← 子场景接口
│ ├── Title.h/cpp ← 标题画面
│ ├── GameOver.h/cpp ← 游戏结束画面
│ ├── Ending.h/cpp ← 通关结局画面
│ └── Game/ ← 游戏中的子状态
│ ├── Parent.h/cpp ← 游戏内管理器
│ ├── Child.h ← 游戏子状态接口
│ ├── Ready.h/cpp ← 准备阶段
│ ├── Play.h/cpp ← 游戏进行中
│ ├── Pause.h/cpp ← 暂停画面
│ ├── Clear.h/cpp ← 关卡通关
│ ├── Failure.h/cpp ← 失败/死亡
│ └── Judge.h/cpp ← 胜负判定(2P模式)
└── Game/ ← 游戏核心逻辑
├── State.h/cpp ← 游戏状态(地图+所有对象)
├── StaticObject.h/cpp ← 静态格子(墙/砖/炸弹/道具)
└── DynamicObject.h/cpp← 动态角色(玩家/敌人)
整体可以理解为三层嵌套的状态机:
程序
└── Sequence::Parent(外层)
├── Title(标题)
├── Sequence::Game::Parent(中层——游戏中)
│ ├── Ready(准备)
│ ├── Play(进行中)
│ ├── Pause(暂停)
│ ├── Clear(通关)
│ ├── Failure(失败)
│ └── Judge(判定)
├── GameOver(游戏结束)
└── Ending(结局)
2. 程序启动流程 main.cpp
#include "GameLib/Framework.h"
using namespace GameLib;
#include "Pad.h"
#include "Sequence/Parent.h"
// GameLib 框架会每帧自动调用这个函数,相当于游戏的主循环
namespace GameLib{
void Framework::update(){
// 第一帧时,Sequence::Parent 还不存在,先创建它
if ( !Sequence::Parent::instance() ){
Sequence::Parent::create();
setFrameRate( 60 ); // 设置帧率为 60fps
}
// 每帧调用父节点的 update,驱动整个游戏
Sequence::Parent::instance()->update();
// 按 Q 键则请求退出
if ( Pad::isOn( Pad::Q ) ){
requestEnd();
}
// 如果退出被请求,销毁父节点(释放内存)
if ( isEndRequested() ){
Sequence::Parent::destroy();
}
}
}
要点说明:
Framework::update()是框架提供的回调,每帧执行一次(60fps = 每秒执行60次)。Sequence::Parent使用了单例模式(Singleton)——全局只存在一个实例,通过create/destroy/instance管理生命周期。- 整个游戏逻辑全部委托给
Sequence::Parent::instance()->update(),main.cpp 本身非常干净。
3. 序列(Scene)管理系统
3.1 什么是"序列"
“序列”(Sequence)在这里就是游戏场景的意思。游戏在不同时刻显示不同的画面:
标题画面 → 游戏中 → 游戏结束 / 通关结局
每个画面都是一个"子序列"对象,由父节点持有并切换。
3.2 外层父节点 Sequence::Parent
头文件 Sequence/Parent.h:
namespace Sequence{
class Child; // 前向声明:Child 是所有子场景的基类
class Parent{
public:
// 下一个要切换到的场景枚举
enum NextSequence{
NEXT_TITLE, // 标题画面
NEXT_GAME, // 进入游戏
NEXT_GAME_OVER,// 游戏结束
NEXT_ENDING, // 通关结局
NEXT_NONE, // 不切换(待机)
};
// 游戏模式(单人/双人)
enum Mode{
MODE_1P,
MODE_2P,
MODE_NONE,
};
void update(); // 每帧调用
void moveTo( NextSequence );// 请求切换到下一个场景
Mode mode() const; // 获取当前模式
void setMode( Mode ); // 设置模式
// 单例接口
static void create();
static void destroy();
static Parent* instance();
private:
Parent(); // 私有构造,防止外部 new
~Parent();
NextSequence mNextSequence; // 当前帧结束后要切换到哪里
Mode mMode; // 1P还是2P
Child* mChild; // 当前运行的子场景(多态指针)
static Parent* mInstance; // 单例实例
};
} //namespace Sequence
实现文件 Sequence/Parent.cpp:
// 单例初始化为空
Parent* Parent::mInstance = 0;
// 创建单例
void Parent::create(){
ASSERT( !mInstance ); // 确保不被重复创建
mInstance = new Parent();
}
// 销毁单例
void Parent::destroy(){
ASSERT( mInstance );
SAFE_DELETE( mInstance ); // 安全删除(先判断不为空)
}
// 获取单例
Parent* Parent::instance(){
return mInstance;
}
// 构造函数:一开始显示标题画面
Parent::Parent() :
mNextSequence( NEXT_NONE ),
mMode( MODE_NONE ),
mChild( 0 ){
mChild = new Title(); // 第一个子场景是标题
}
// 析构函数:删除当前子场景
Parent::~Parent(){
SAFE_DELETE( mChild );
}
// 每帧更新
void Parent::update(){
mChild->update( this ); // 让当前子场景更新,并把自己传过去让它能请求切换
// 根据子场景请求,决定切换到哪个场景
switch ( mNextSequence ){
case NEXT_TITLE:
SAFE_DELETE( mChild );
mChild = new Title(); // 切换到标题
break;
case NEXT_GAME:
SAFE_DELETE( mChild );
mChild = new Game::Parent( mMode ); // 切换到游戏(带模式参数)
break;
case NEXT_GAME_OVER:
SAFE_DELETE( mChild );
mChild = new GameOver(); // 切换到游戏结束
break;
case NEXT_ENDING:
SAFE_DELETE( mChild );
mChild = new Ending(); // 切换到结局
}
mNextSequence = NEXT_NONE; // 切换完毕,重置为"不切换"
}
// 子场景调用此函数来请求切换
void Parent::moveTo( NextSequence next ){
ASSERT( mNextSequence == NEXT_NONE ); // 同一帧不允许请求两次切换
mNextSequence = next;
}
核心设计模式:先 update,后切换
每帧的执行顺序是:
mChild->update(this) ← 子场景运行逻辑,可能调用 parent->moveTo(...)
↓
switch(mNextSequence) ← 父节点检查是否要切换
↓
delete 旧子场景,new 新子场景
↓
mNextSequence = NEXT_NONE
这样保证了同一帧内子场景的逻辑完整执行完毕,再进行切换,避免混乱。
3.3 内层子节点 Sequence::Child(接口)
namespace Sequence{
class Parent;
// 这是所有子场景必须实现的接口(抽象基类)
class Child{
public:
virtual ~Child(){} // 虚析构,保证子类能正确被 delete
virtual void update( Parent* ) = 0; // 纯虚函数,子类必须实现
};
} //namespace Sequence
这是多态的体现:Parent 只持有 Child* 指针,不关心具体是 Title、GameOver 还是 Ending,统一调用 update()。
3.4 各场景详解
标题画面 Title
void Title::update( Parent* parent ){
// 上下键移动光标(在"单人"/"双人"之间选择)
if ( Pad::isTriggered( Pad::U ) ){
--mCursorPosistion;
if ( mCursorPosistion < 0 ){ // 超出上边界,循环到底部
mCursorPosistion = 1;
}
}else if ( Pad::isTriggered( Pad::D ) ){
++mCursorPosistion;
if ( mCursorPosistion > 1 ){ // 超出下边界,循环到顶部
mCursorPosistion = 0;
}
}else if ( Pad::isTriggered( Pad::A ) ){
// 按下确认键,根据光标位置设置模式并切换到游戏
parent->moveTo( Parent::NEXT_GAME );
if ( mCursorPosistion == 0 ){
parent->setMode( Parent::MODE_1P );
}else if ( mCursorPosistion == 1 ){
parent->setMode( Parent::MODE_2P );
}else{
HALT( "arienai" ); // 不应该到这里,报错
}
}
// 绘制:图片 + 文字 + 光标
mImage->draw();
Framework f = Framework::instance();
f.drawDebugString( 0, 0, "[タイトル]" );
f.drawDebugString( 1, 2, "ヒトリデアソブ" ); // 单人游玩
f.drawDebugString( 1, 3, "フタリデコロシアウ" ); // 双人对战
f.drawDebugString( 0, mCursorPosistion + 2, ">" ); // 光标
}
游戏结束 GameOver
void GameOver::update( Parent* parent ){
if ( mCount == 60 ){ // 等待 60 帧 = 1 秒后自动返回标题
parent->moveTo( Parent::NEXT_TITLE );
}
mImage->draw();
Framework::instance().drawDebugString( 0, 0, "ゲームオーバー" );
++mCount; // 每帧计数
}
结局画面 Ending
void Ending::update( Parent* parent ){
if ( mCount == 120 ){ // 等待 120 帧 = 2 秒后返回标题
parent->moveTo( Parent::NEXT_TITLE );
}
mImage->draw();
Framework f = Framework::instance();
f.drawDebugString( 0, 0, "おめでとう! このゲームをせいはしました" );
f.drawDebugString( 0, 1, "つくったひと ひらやまたかし" );
++mCount;
}
4. 游戏内部的子状态系统 Sequence::Game
游戏进行中,还有更细致的子状态管理,结构和外层完全相同,只是多了游戏专用的状态。
4.1 Game::Parent(游戏管理器)
头文件 Sequence/Game/Parent.h(关键部分):
class Parent : public Sequence::Child{ // 它本身也是外层的 Child
public:
typedef Sequence::Parent GrandParent; // 给外层父节点起别名,方便书写
enum NextSequence{
NEXT_CLEAR, // 通关当前关卡
NEXT_READY, // 准备阶段(显示 Ready... Go!)
NEXT_PAUSE, // 暂停
NEXT_PLAY, // 游戏进行中
NEXT_FAILURE, // 失败(被炸死)
NEXT_JUDGE, // 胜负判定(2P模式专用)
NEXT_ENDING, // 通关全部关卡,进入结局
NEXT_GAME_OVER, // 生命用完,游戏结束
NEXT_TITLE, // 返回标题
NEXT_NONE,
};
// ...
State* mState; // 游戏状态(地图+角色数据)
int mStageID; // 当前关卡编号(1P模式从第1关开始)
int mLife; // 剩余生命数
static const int FINAL_STAGE = 2; // 最终关卡是第2关
static const int INITIALI_LIFE_NUMBER = 2; // 初始生命数
};
构造函数:
Parent::Parent( GrandParent::Mode mode ) :
mState( 0 ),
mStageID( 0 ),
mLife( INITIALI_LIFE_NUMBER ), // 从 2 条命开始
mNextSequence( NEXT_NONE ),
mChild( 0 ){
if ( mode == GrandParent::MODE_1P ){
mStageID = 1; // 1P 模式从第 1 关开始(0 是2P专用地图)
}else{
mStageID = 0; // 2P 模式用第 0 关地图
}
mChild = new Ready(); // 最先进入准备阶段
}
每帧更新(状态切换逻辑):
void Parent::update( GrandParent* parent ){
mChild->update( this ); // 当前内部子状态运行
switch ( mNextSequence ){
case NEXT_CLEAR:
SAFE_DELETE( mChild );
mChild = new Clear();
++mStageID; // 进入下一关
break;
case NEXT_READY:
SAFE_DELETE( mChild );
mChild = new Ready();
break;
case NEXT_PAUSE:
SAFE_DELETE( mChild );
mChild = new Pause();
break;
case NEXT_PLAY:
SAFE_DELETE( mChild );
mChild = new Play();
break;
case NEXT_FAILURE:
SAFE_DELETE( mChild );
mChild = new Failure();
--mLife; // 减一条命
break;
case NEXT_JUDGE:
SAFE_DELETE( mChild );
mChild = new Judge();
break;
case NEXT_ENDING:
SAFE_DELETE( mChild );
parent->moveTo( GrandParent::NEXT_ENDING ); // 通知外层父节点
break;
case NEXT_GAME_OVER:
SAFE_DELETE( mChild );
parent->moveTo( GrandParent::NEXT_GAME_OVER );
break;
case NEXT_TITLE:
SAFE_DELETE( mChild );
parent->moveTo( GrandParent::NEXT_TITLE );
break;
}
mNextSequence = NEXT_NONE;
}
注意 NEXT_ENDING、NEXT_GAME_OVER、NEXT_TITLE 这三种情况——Game::Parent 自己没有这些场景,它会把请求"向上冒泡"到外层的 Sequence::Parent。
4.2 游戏内各子状态
Ready(准备阶段)
void Ready::update( Parent* parent ){
// 第一帧才触发:开始加载地图数据
if ( !mStarted ){
parent->startLoading(); // 创建 State 对象(加载地图)
mStarted = true;
}
if ( mCount >= 120 ){ // 2 秒后进入 Play
parent->moveTo( Parent::NEXT_PLAY );
}else if ( mCount >= 60 ){ // 前 1 秒显示 "GO!"
Framework::instance().drawDebugString( 0, 1, "GO!" );
}else{ // 后 1 秒显示 "Ready..."
Framework::instance().drawDebugString( 0, 1, "Ready..." );
}
parent->drawState(); // 已经渲染地图(让玩家提前看到)
++mCount;
}
Play(游戏进行中)
void Play::update( Parent* parent ){
State* state = parent->state();
// 检查游戏状态
bool cleared = state->hasCleared(); // 敌人全灭?
bool die1P = !state->isAlive( 0 ); // 1P死了?
bool die2P = !state->isAlive( 1 ); // 2P死了?
// 调试快捷键(测试用)
if ( kb.isTriggered( '1' ) ){ die2P = true; } // 强制杀死2P
if ( kb.isTriggered( '2' ) ){ die1P = true; } // 强制杀死1P
if ( kb.isTriggered( 'c' ) ){ cleared = true; }// 强制通关
// 1P模式下的判断
if ( parent->mode() == Parent::MODE_1P ){
if ( cleared && !die1P ){
parent->moveTo( Parent::NEXT_CLEAR ); // 通关
}else if ( die1P ){
parent->moveTo( Parent::NEXT_FAILURE ); // 失败
}
}else{ // 2P模式
if ( die1P || die2P ){ // 有人死了就判断胜负
parent->moveTo( Parent::NEXT_JUDGE );
if ( die1P && die2P ){
parent->setWinner( Parent::PLAYER_NONE ); // 平局
}else if ( die1P ){
parent->setWinner( Parent::PLAYER_2 ); // 2P赢
}else{
parent->setWinner( Parent::PLAYER_1 ); // 1P赢
}
}
}
// B 键暂停
if ( Pad::isTriggered( Pad::B ) ){
parent->moveTo( Parent::NEXT_PAUSE );
}
// 更新游戏逻辑
state->update();
state->draw();
}
Clear(通关一关)
void Clear::update( Parent* parent ){
if ( mCount == 60 ){ // 1 秒后决定去哪里
if ( parent->hasFinalStageCleared() ){ // 最终关卡清了?
parent->moveTo( Parent::NEXT_ENDING ); // 去结局
}else{
parent->moveTo( Parent::NEXT_READY ); // 继续下一关
}
}
parent->drawState();
Framework::instance().drawDebugString( 0, 0, "クリアー!" );
++mCount;
}
hasFinalStageCleared() 的判断很简单:
bool Parent::hasFinalStageCleared() const {
return ( mStageID > FINAL_STAGE ); // mStageID 在 NEXT_CLEAR 时已经 ++
}
Failure(失败死亡)
void Failure::update( Parent* parent ){
if ( mCount == 60 ){ // 1 秒后判断
if ( parent->lifeNumber() == 0 ){ // 没命了
parent->moveTo( Parent::NEXT_GAME_OVER );
}else{
parent->moveTo( Parent::NEXT_READY ); // 还有命,重试本关
}
}
++mCount;
}
Pause(暂停)
暂停菜单有两个选项:继续游戏 / 返回标题。
void Pause::update( Parent* parent ){
// 上下键选择菜单项(同 Title 的逻辑,在 0 和 1 之间循环)
// ...
if ( Pad::isTriggered( Pad::A ) ){
if ( mCursorPosistion == 0 ){
parent->moveTo( Parent::NEXT_PLAY ); // 继续游戏
}else if ( mCursorPosistion == 1 ){
parent->moveTo( Parent::NEXT_TITLE ); // 返回标题
}
}
parent->drawState(); // 先画游戏画面
mImage->draw(); // 再叠加暂停半透明画面
// ...
}
Judge(2P胜负判定)
void Judge::update( Parent* parent ){
// 显示胜者
Parent::PlayerID winner = parent->winner();
if ( winner == Parent::PLAYER_1 ){
f.drawDebugString( 0, 1, "1Pの勝ち!" );
}else if ( winner == Parent::PLAYER_2 ){
f.drawDebugString( 0, 1, "2Pの勝ち!" );
}else{
f.drawDebugString( 0, 1, "引き分け!" );
}
// 菜单:再来一局 / 返回标题
if ( mCursorPosistion == 0 ){
parent->moveTo( Parent::NEXT_READY ); // 再战
}else{
parent->moveTo( Parent::NEXT_TITLE ); // 回标题
}
}
5. 输入系统 Pad
Pad 是对键盘和手柄输入的统一封装。
// Pad.h 定义了按钮枚举
class Pad{
public:
enum Button{
A, // 确认/放炸弹:1P 对应 'd',2P 对应 'l'
B, // 取消/暂停:1P 对应 'x',2P 对应 ','
U, // 上:1P 对应 'w',2P 对应 'i'
D, // 下:1P 对应 'z',2P 对应 'm'
L, // 左:1P 对应 'a',2P 对应 'j'
R, // 右:1P 对应 's',2P 对应 'k'
Q, // 退出:1P/2P 都是 'q'
};
static bool isOn( Button, int playerID = 0 ); // 按钮是否按住
static bool isTriggered( Button, int playerID = 0 );// 按钮是否刚刚按下(只在第一帧返回true)
};
isOn 和 isTriggered 的区别:
isOn:只要按键按着就返回true,每帧都触发。用于移动(持续走路)。isTriggered:只在按下的那一帧返回true,下一帧就false了。用于菜单选择、放炸弹(避免连续触发)。
键位映射表(以 playerID 为索引,button 为列):
| Button | 1P (pid=0) | 2P (pid=1) |
|---|---|---|
| A | d | l |
| B | x | , |
| U | w | i |
| D | z | m |
| L | a | j |
| R | s | k |
| Q | q | q |
手柄也支持:优先键盘,同时检测手柄,任一触发即视为按下。
6. 游戏核心逻辑 State
State 是整个游戏世界的数据容器,包含地图和所有角色。
6.1 地图初始化
地图大小固定为 19 × 15 19 \times 15 19×15 的格子。
static const int WIDTH = 19;
static const int HEIGHT = 15;
关卡配置数据:
struct StageData{
int mEnemyNumber; // 敌人数量
int mBrickRate; // 砖块生成概率(百分比)
int mItemPowerNumber;// 爆炸力道具数
int mItemBombNumber; // 炸弹数量道具数
};
static StageData gStageData[] = {
{ 2, 50, 10, 10, }, // 第0关(2P对战):2敌,50%砖块
{ 3, 70, 1, 0, }, // 第1关(1P第1关):3敌,70%砖块
{ 5, 30, 0, 1, }, // 第2关(1P最终关):5敌,30%砖块
};
地图格子生成规则(重要):
for ( int y = 0; y < HEIGHT; ++y ){
for ( int x = 0; x < WIDTH; ++x ){
StaticObject& o = mStaticObjects( x, y );
if ( x==0 || y==0 || x==WIDTH-1 || y==HEIGHT-1 ){
o.setFlag( StaticObject::FLAG_WALL ); // 边界是永久墙
}else if ( (x%2==0) && (y%2==0) ){
o.setFlag( StaticObject::FLAG_WALL ); // 棋盘式固定墙(不可摧毁)
}else if ( y + x < 4 ){
// 左上角3格保持为空地(玩家1出生点)
}else if ( (stageID==0) && (y+x > WIDTH+HEIGHT-6) ){
// 2P模式:右下角也空出来(玩家2出生点)
}else{
// 其余位置:掷骰子决定是砖块还是空地
if ( f.getRandom(100) < stageData.mBrickRate ){
o.setFlag( StaticObject::FLAG_BRICK ); // 砖块(可摧毁)
}
// 否则就是空地
}
}
}
地图示意(以 5 × 5 5 \times 5 5×5 简化版为例,W=墙,B=砖,空=地板):
W W W W W
W . B . W
W B W B W ← (2,2) 是固定墙(偶数列偶数行)
W . B . W
W W W W W
道具随机放入砖块中:
// 用"洗牌算法"的前 N 个来随机选砖块放道具
for ( int i = 0; i < powerNumber + bombNumber; ++i ){
// 从 i 之后随机选一个位置,与 i 交换(保证不重复)
int swapped = f.getRandom( brickNumber - 1 - i ) + i;
// 交换 brickList[i] 和 brickList[swapped]
unsigned t = brickList[i];
brickList[i] = brickList[swapped];
brickList[swapped] = t;
// 把道具标记设置到这个砖块
int x = brickList[i] >> 16; // 高16位存 x 坐标
int y = brickList[i] & 0xffff; // 低16位存 y 坐标
// 前 powerNumber 个放爆炸力道具,其余放炸弹数量道具
if ( i < powerNumber ){
mStaticObjects(x,y).setFlag( StaticObject::FLAG_ITEM_POWER );
}else{
mStaticObjects(x,y).setFlag( StaticObject::FLAG_ITEM_BOMB );
}
}
这里有个巧妙的数据编码:
坐标编码 = ( x ≪ 16 ) + y 坐标编码 = (x \ll 16) + y 坐标编码=(x≪16)+y
即把 x 存在一个 32 位整数的高 16 位,y 存在低 16 位,这样一个 unsigned 就能存一个 ( x , y ) (x, y) (x,y) 坐标对。
6.2 每帧更新 update()
State::update() 的执行顺序非常重要:
1. 爆弹计时 & 爆炸开始/结束判定
2. 清除上一帧的火焰标记
3. 重新扩散本帧的火焰
4. 统计每位玩家的在场炸弹数
5. 每个动态对象(玩家/敌人):
a. 处理"刚放下的炸弹先穿过"逻辑
b. 检测周围9格的墙
c. 移动
d. 检测移动后是否被炸/拾取道具
e. 放炸弹
6. 敌人和玩家的碰撞判定
6.3 炸弹与爆炸机制
炸弹用 StaticObject 的标志位表示,不是一个单独的对象。
炸弹的状态转换:
放置炸弹 → FLAG_BOMB 置位,mCount = 0
↓(每帧 mCount++)
经过 EXPLOSION_TIME(180帧=3秒) → FLAG_EXPLODING 置位,mCount = 0
(或者被其他爆炸波及 FLAG_FIRE_X/Y → 立即 FLAG_EXPLODING)
↓(每帧 mCount++)
经过 EXPLOSION_LIFE(60帧=1秒) → 清除 FLAG_EXPLODING 和 FLAG_BOMB
爆炸波扩散(setFire 函数):
爆炸从炸弹位置向四个方向(上下左右)展开,每个方向最多扩展 mBombPower 格:
void State::setFire( int x, int y ){
StaticObject& o = mStaticObjects( x, y );
int power = o.mBombOwner->mBombPower; // 爆炸力(格数)
// 向左扩散
int end = ( x - power < 0 ) ? 0 : ( x - power ); // 不越界
for ( int i = x-1; i >= end; --i ){
StaticObject& to = mStaticObjects( i, y );
to.setFlag( StaticObject::FLAG_FIRE_X ); // 标记横向火焰
if ( to.checkFlag( FLAG_WALL | FLAG_BRICK | FLAG_BOMB ) ){
break; // 碰到墙/砖/炸弹,火焰停止(但标记已经设置)
}else{
to.resetFlag( FLAG_ITEM_BOMB | FLAG_ITEM_POWER ); // 烧掉道具
}
}
// 向右、向上、向下扩散同理(代码重复4次)
}
火焰有两个标志:FLAG_FIRE_X(横向火)和 FLAG_FIRE_Y(纵向火),爆炸中心同时有 FLAG_EXPLODING。渲染时用不同图片区分这三种状态。
6.4 连锁爆炸的时序问题与解决方案
这是代码中最复杂的部分,原代码有详细注释,这里用图示说明。
问题描述:
假设地图上有以下情况(A、B 是炸弹,X 是砖块):
A B X X
B 先爆炸,波及 X(第一块砖),X 开始"燃烧倒计时"。同时 A 的爆炸波到达 B 触发连锁。最终 B 的爆炸波在 X 燃烧完之前到达 X,将其标记为烧落(mCount=0),如果此时 X 已经消失,A 的下一帧爆炸波就会穿过去烧到后面的 X。
正确行为:X 应该等所有可能的爆炸都结束才消失
解决方法:
当一个爆炸刚刚开始(o.mCount == 0),对它波及到的砖块重新设置 mCount = 0,让砖块的燃烧计时"重置",延迟消失时间。
// 爆炸的第二次循环(只在 o.mCount == 0 时执行)
if ( (o.mCount == 0) && to.checkFlag(StaticObject::FLAG_BRICK) ){
to.mCount = 0; // 重置砖块的燃烧计时
}
7. 静态对象 StaticObject(格子)
每个地图格子都是一个 StaticObject,通过**位标志(bitmask)**记录自身状态。
enum Flag{
FLAG_WALL = ( 1 << 0 ), // bit0 = 1:永久墙(混凝土)
FLAG_BRICK = ( 1 << 1 ), // bit1 = 1:砖块(可摧毁)
FLAG_BOMB = ( 1 << 2 ), // bit2 = 1:有炸弹
FLAG_ITEM_BOMB = ( 1 << 3 ), // bit3 = 1:有炸弹数量道具
FLAG_ITEM_POWER= ( 1 << 4 ), // bit4 = 1:有爆炸力道具
FLAG_FIRE_X = ( 1 << 5 ), // bit5 = 1:横向火焰经过
FLAG_FIRE_Y = ( 1 << 6 ), // bit6 = 1:纵向火焰经过
FLAG_EXPLODING = ( 1 << 7 ), // bit7 = 1:爆炸进行中(爆炸中心)
};
这些操作如下:
void setFlag( unsigned f ){
mFlags |= f; // 用"或"把对应位设为 1
}
void resetFlag( unsigned f ){
mFlags &= ~f; // 用"与非"把对应位清为 0
}
bool checkFlag( unsigned f ) const {
return ( mFlags & f ) ? true : false; // 用"与"检查是否有某个位
}
例子:一个格子既有砖块又有爆炸力道具
mFlags = FLAG_BRICK ∣ FLAG_ITEM_POWER = 0 b 00010010 = 18 \text{mFlags} = \text{FLAG\_BRICK} \,|\, \text{FLAG\_ITEM\_POWER} = 0b00010010 = 18 mFlags=FLAG_BRICK∣FLAG_ITEM_POWER=0b00010010=18
8. 动态对象 DynamicObject(玩家/敌人)
8.1 坐标系统说明
游戏内部不使用像素坐标,而使用一套放大了 1000 倍的内部单位坐标。
每个地图格子大小为 16 像素。在内部单位中:
内部单位格子大小 = 16 × 1000 = 16000 \text{内部单位格子大小} = 16 \times 1000 = 16000 内部单位格子大小=16×1000=16000
一个格子的中心在内部单位中:
格子 ( x , y ) 的中心 = ( x × 16000 + 8000 , y × 16000 + 8000 ) \text{格子}(x, y)\text{的中心} = (x \times 16000 + 8000,\ y \times 16000 + 8000) 格子(x,y)的中心=(x×16000+8000, y×16000+8000)
角色的半径(碰撞体)为 6000 6000 6000 内部单位(约 6 像素)。
为什么要放大 1000 倍?
因为 C++ 整数除法会截断小数。如果速度是 0.5 像素/帧,整数就变成了 0,角色完全不动。放大 1000 倍后,速度可以是 500(内部单位),每帧移动 500 内部单位,相当于 0.5 像素,精度更高。
速度参数:
static const int PLAYER_SPEED = 1000; // 每帧移动 1 内部单位 = 1 像素
static const int ENEMY_SPEED = 500; // 每帧移动 0.5 像素(更慢)
static const int HALF_SIZE = 6000; // 碰撞体半径 = 6 像素
8.2 移动与碰撞检测
void DynamicObject::move( const int* wallsX, int* wallsY, int wallNumber ){
int dx, dy;
getVelocity( &dx, &dy ); // 获取本帧移动量(内部单位)
// 分别检测 X 和 Y 方向的碰撞
int movedX = mX + dx;
int movedY = mY + dy;
bool hitX = false, hitY = false;
for ( int i = 0; i < wallNumber; ++i ){
// 假设只移动X:(movedX, mY) 和墙碰了吗?
if ( isIntersectWall( movedX, mY, wallsX[i], wallsY[i] ) ){
hitX = true;
}
// 假设只移动Y:(mX, movedY) 和墙碰了吗?
if ( isIntersectWall( mX, movedY, wallsX[i], wallsY[i] ) ){
hitY = true;
}
}
if ( hitX && !hitY ){
mY = movedY; // 只有X方向碰壁,那就只移动Y(沿墙滑动)
}else if ( !hitX && hitY ){
mX = movedX; // 只有Y方向碰壁,只移动X
}else{
// 两个方向都碰壁,或都没碰壁,整体尝试移动
bool hit = false;
for ( int i = 0; i < wallNumber; ++i ){
if ( isIntersectWall( movedX, movedY, wallsX[i], wallsY[i] ) ){
hit = true;
}
}
if ( !hit ){
mX = movedX;
mY = movedY; // 可以移动
}
// 否则完全不动
}
// 敌人碰到墙就随机换方向
if ( hit && mType == TYPE_ENEMY ){
// 随机选 上/下/左/右 之一
switch ( Framework::instance().getRandom(4) ){
case 0: mDirectionX = 1; break;
case 1: mDirectionX = -1; break;
case 2: mDirectionY = 1; break;
case 3: mDirectionY = -1; break;
}
}
}
这种"分别检测 X 和 Y 方向,允许滑墙"的做法是 2D 游戏中经典的碰撞响应技巧,使得角色可以沿着墙壁流畅滑动而不被卡住。
9. 碰撞检测数学原理
游戏中所有碰撞都是轴对齐矩形(AABB)碰撞检测。
两个矩形 A 和 B,各自有中心坐标和半径:
A : 中心 ( a x , a y ) , 半径 r A A:\quad \text{中心}(ax, ay),\quad \text{半径 } r_A A:中心(ax,ay),半径 rA
B : 中心 ( b x , b y ) , 半径 r B B:\quad \text{中心}(bx, by),\quad \text{半径 } r_B B:中心(bx,by),半径 rB
它们各自的左右上下边界为:
A l e f t = a x − r A , A r i g h t = a x + r A A_{left} = ax - r_A,\quad A_{right} = ax + r_A Aleft=ax−rA,Aright=ax+rA
B l e f t = b x − r B , B r i g h t = b x + r B B_{left} = bx - r_B,\quad B_{right} = bx + r_B Bleft=bx−rB,Bright=bx+rB
两个矩形在 X 方向有重叠的条件:
A l e f t < B r i g h t 且 A r i g h t > B l e f t A_{left} < B_{right} \quad \text{且} \quad A_{right} > B_{left} Aleft<Bright且Aright>Bleft
同理 Y 方向也要有重叠,则两者相交:
bool DynamicObject::isIntersectWall( int x, int y, int wallX, int wallY ){
int wx = convertCellToInner( wallX ); // 墙的中心 X(内部单位)
int wy = convertCellToInner( wallY ); // 墙的中心 Y
int al = x - HALF_SIZE; // 角色左边界
int ar = x + HALF_SIZE; // 角色右边界
int bl = wx - 8000; // 墙左边界(一格 = 16000,半格 = 8000)
int br = wx + 8000; // 墙右边界
if ( (al < br) && (ar > bl) ){ // X 方向有重叠
int at = y - HALF_SIZE;
int ab = y + HALF_SIZE;
int bt = wy - 8000;
int bb = wy + 8000;
if ( (at < bb) && (ab > bt) ){ // Y 方向也有重叠
return true; // 碰撞
}
}
return false;
}
10. 二维数组模板 Array2D
Array2D<T> 是一个简单的二维数组封装:
template< class T > class Array2D{
public:
Array2D() : mArray( 0 ){}
~Array2D(){
SAFE_DELETE_ARRAY( mArray ); // 析构时自动释放内存
}
void setSize( int size0, int size1 ){
SAFE_DELETE_ARRAY( mArray );
mSize0 = size0;
mSize1 = size1;
mArray = new T[ size0 * size1 ]; // 分配 size0*size1 个元素
}
// 重载 () 运算符,让 a(x,y) 代替 a[y][x]
T& operator()( int index0, int index1 ){
return mArray[ index1 * mSize0 + index0 ]; // 行优先存储
}
// const 版本(用于 const 对象)
const T& operator()( int index0, int index1 ) const {
return mArray[ index1 * mSize0 + index0 ];
}
private:
T* mArray; // 底层一维数组
int mSize0; // 第一维大小(列数 = WIDTH)
int mSize1; // 第二维大小(行数 = HEIGHT)
};
内存布局(以 3 × 2 3 \times 2 3×2 为例):
逻辑上是:
(0,0) (1,0) (2,0)
(0,1) (1,1) (2,1)
实际在内存中(一维数组):
[0] [1] [2] [3] [4] [5]
(0,0) (1,0) (2,0) (0,1) (1,1) (2,1)
↑ index = y * mSize0 + x = 1*3+0 = 3 → (0,1)
这是最常见的**行优先(Row-major)**二维数组布局。
11. 完整序列状态机图
12. 关键设计思想总结
| 设计概念 | 说明 | 代码体现 |
|---|---|---|
| 状态机(State Machine) | 每个场景是一个状态,父节点管理切换 | Sequence::Parent + Child |
| 多态(Polymorphism) | 父节点不关心子节点具体类型 | Child 接口 + virtual update |
| 单例(Singleton) | 全局只有一个 Parent 实例 | create/destroy/instance |
| 位标志(Bitmask) | 一个整数同时记录多种状态 | StaticObject::mFlags |
| 内部坐标系 | 放大 1000 倍提高整数运算精度 | 1像素 = 1000 内部单位 |
| 先更新后切换 | 同帧内完整执行逻辑再切换场景 | update → switch → NEXT_NONE |
| AABB 碰撞 | 轴对齐矩形交叉检测 | isIntersectWall / isIntersect |
| 随机洗牌 | Fisher-Yates 算法放置道具/敌人 | swapped = getRandom(…) + i |
这个项目是一个非常典型的游戏状态机教学案例。它把场景切换、游戏逻辑、渲染分离得很清晰。即使代码规模不大,它使用的设计模式在工业级游戏引擎中也是通用的。
BakudanBito(爆弹人)—— “改进版 Sequence” 游戏架构详解
本文针对项目
44-BakudanBitoWithImprovedSequence1进行从零讲解。
重点放在两件事上:
- 整个游戏的"画面切换"是如何用一套叫做 Sequence(序列) 的 Parent/Child 状态机模式组织起来的,以及这一版相对于"基础版"做了什么"改进";
- 游戏内部(地图、炸弹、爆炸、玩家、敌人)的数据结构与核心算法。
一、这个项目到底是什么
BakudanBito(バクダンビト)是一个基于自制 2D 游戏引擎 GameLib(提供窗口、绘图、输入、声音等底层功能)编写的炸弹人(Bomberman)类型小游戏:玩家在一个由"墙、砖块、炸弹、火焰"组成的格子地图里移动,放置炸弹炸毁砖块和敌人(或对面玩家),目标是清空所有敌人过关,或者在双人模式下把对方炸死。
项目名里的 “WithImprovedSequence” 说明,这一版的重点是展示一种**“改进版"的画面状态机写法**——也就是"标题画面 → 游戏画面 → 准备/对战/暂停/结算/过关/失败 → 结局/GameOver → 回到标题"这一整套流程的代码组织方式。理解这套"Sequence"模式,是理解整个项目的关键,也是后面所有具体游戏逻辑的"骨架”。
二、项目目录结构总览(精简版)
下面只列出和游戏逻辑直接相关的文件,GameLib/ 这个底层引擎文件夹内容很多(图形、输入、音频、文件IO……),本文不展开讲解,只在用到的地方简单提一句。
44-BakudanBitoWithImprovedSequence1/
├── main.cpp 入口:把每帧的 update() 全部丢给 Sequence::Parent
├── Pad.h / Pad.cpp 输入抽象:把键盘+摇杆映射成逻辑按键(A,B,U,D,L,R,Q)
├── Image.h / Image.cpp 简易图片加载与绘制(自带透明度混合)
├── File.h / File.cpp 二进制文件读取(供 Image 使用,本文不细讲)
├── Square.h / Square.cpp 旧版的碰撞工具类(本版本中其实没人用了)
├── Array2D.h 二维数组模板容器
│
├── Game/ 游戏世界本身的数据与规则
│ ├── State.h / State.cpp 一局游戏的"总状态":建图、每帧更新、绘制
│ ├── StaticObject.h / .cpp 格子对象:墙/砖块/炸弹/道具/火焰(用位标志表示)
│ └── DynamicObject.h / .cpp 会动的对象:玩家、敌人(移动、碰撞、绘制)
│
├── Sequence/ "改进版"的双层 Parent-Child 状态机
│ ├── Child.h 外层状态的公共接口
│ ├── Parent.h / .cpp 外层状态机:持有当前状态、负责切换
│ ├── Title.h / .cpp 标题画面
│ ├── GameOver.h / .cpp 游戏结束画面
│ ├── Ending.h / .cpp 通关结局画面
│ │
│ └── Game/ 内层:游戏进行中的子状态机
│ ├── Child.h 内层状态的公共接口
│ ├── Parent.h / .cpp 内层状态机 = 它同时也是外层的一个具体状态
│ ├── Ready.h / .cpp "准备开始"倒数
│ ├── Play.h / .cpp 游戏主循环(真正在玩的状态)
│ ├── Pause.h / .cpp 暂停菜单
│ ├── Judge.h / .cpp 双人对战的胜负判定画面
│ ├── Clear.h / .cpp 过关画面
│ └── Failure.h / .cpp 失败画面
│
└── GameLib/ 底层引擎框架(图形/输入/声音/文件IO等,本文不展开)
可以先记住一个大致印象:Sequence/ 文件夹里的内容,决定了"现在屏幕上显示的是哪个画面";Game/ 文件夹里的内容,决定了"游戏世界里到底发生了什么"。两者通过 Sequence/Game/Parent 这个类连接起来。
三、核心设计思想:什么是"Sequence(序列)"模式
3.1 要解决的问题
一个游戏通常有很多"画面"或者说"状态":标题画面、游戏画面、暂停画面、结算画面、Game Over 画面……
每一帧(比如 60fps,也就是每秒 60 次),引擎都会调用一次 update()。这个函数需要做四件事:
- 读取玩家的输入(按了哪个键);
- 根据当前画面的逻辑,更新内部数据(移动角色、倒计时等);
- 把当前画面画出来;
- 判断是否应该切换到另一个画面(比如倒计时结束了、按了确认键……)。
最直接、最"新手"的写法是用一个大大的switch:
enum SceneID { TITLE, GAME, GAME_OVER, ENDING };
SceneID currentScene = TITLE;
void update(){
switch ( currentScene ){
case TITLE:
// ……标题画面的全部逻辑都写在这里……
if ( /* 按下确认键 */ ){ currentScene = GAME; }
break;
case GAME:
// ……游戏画面的全部逻辑都写在这里,可能几百行……
break;
case GAME_OVER:
// ……
break;
// ……
}
}
这样写有几个明显的缺点:
- 所有画面的代码都挤在同一个函数里,文件会变得巨大;
- 每个画面需要的"私有数据"(比如标题画面的光标位置、游戏画面的地图状态)都要混在一起声明成全局变量或者一个大结构体的成员;
- 新增一个画面,就要去这个大
switch里再加一个case,牵一发动全身。
3.2 "Sequence"模式的解法:把每个画面变成一个类
核心思路非常简单:
- 定义一个抽象接口
Child,规定"任何一个画面"都必须实现一个update()函数; - 这个
update()函数的返回值就是"下一帧应该用哪个画面对象";- 如果还停留在当前画面,就返回
this(自己); - 如果要切换画面,就
return new 另一个画面类(...);
- 如果还停留在当前画面,就返回
- 再定义一个管理者
Parent,它只做一件事:持有"当前画面"的指针,每帧调用它的update(),并根据返回值决定是否要把旧画面销毁、换成新画面。
用伪代码表示就是:
class Child{
public:
virtual ~Child(){}
// 返回值:下一帧该用的状态。如果不切换,就返回 this。
virtual Child* update( Parent* parent ) = 0;
};
class Parent{
public:
void update(){
Child* nextChild = mChild->update( this );
if ( nextChild != mChild ){ // 发生了切换
delete mChild; // 销毁旧画面对象(连带它的私有数据)
mChild = nextChild; // 换成新画面对象
}
}
private:
Child* mChild; // 当前画面
};
这就是这个项目里反复出现的 “Parent/Child” 结构。它本质上是设计模式里的状态模式(State Pattern),但加上了"状态对象自己负责创建下一个状态对象"这个小技巧,使得状态切换的逻辑非常集中、清晰。
每个具体画面(Title、GameOver……):
- 在构造函数里做"进入这个画面时要做的事"(比如加载图片、初始化倒计时);
- 在
update()里做"这一帧要做的事"(读输入、更新倒计时、画面绘制),并在满足条件时return new 下一个画面; - 在析构函数里做"离开这个画面时要做的事"(比如释放图片)。
由于Parent::update()在切换时会自动delete旧对象,所以"进入画面时分配的资源"会在"离开画面时"被自动释放——资源的生命周期和画面对象的生命周期完全绑定,不容易忘记释放。
3.3 这一版"改进"在哪里?——双层结构
这个项目里实际上有两层这样的 Parent/Child 结构:
- 外层(
Sequence::Parent/Sequence::Child):管理"标题、游戏中、GameOver、结局"这几个大画面之间的切换; - 内层(
Sequence::Game::Parent/Sequence::Game::Child):管理"游戏中"这个大画面内部的子状态——准备、对战、暂停、结算、过关、失败——之间的切换。
而连接两层的关键角色是Sequence::Game::Parent: - 对外层来说,它就是外层的一个具体的
Sequence::Child("游戏中"这个大状态); - 对内层来说,它自己又扮演了
Parent的角色,持有内层当前的子状态Sequence::Game::Child*。
也就是说,Sequence::Game::Parent同时身兼"外层的一个棋子"和"内层的管理者"两种身份。这正是本文标题里"改进版 Sequence"的精髓所在——用同一套简单的接口/模式,递归地组织出有层次的状态机,而不需要发明新的机制。后面第七节会详细拆解这个类。
四、整体状态流转图
下面这张图展示外层的四个大画面是怎么互相切换的:
下面这张图展示内层(也就是 GameParent 内部)的子状态是怎么切换的:
图中内层状态画到 [*](结束)的那几条线,实际上并不是真的"结束程序",而是调用 parent->moveTo(...) 把"想跳到外层哪个画面"的意图记录下来,然后由 GameParent::update() 在每帧的最后统一处理——也就是上一张图里 GameParent --> Ending / GameOver / Title 这几条线。这个"先登记意图、再统一处理"的两段式设计,正是让内层状态完全不需要知道外层 Title、GameOver、Ending 这些类的存在的关键,第七节会详细解释。
代码解析:靠"骗"编译器实现的伪多态 —— 从零理解静态绑定与类型双关
#include <iostream>
using namespace std;
class A {
public:
A() : mTypeName('A') {}
void foo() { cout << "a" << endl; }
char mTypeName;
};
class B {
public:
B() : mTypeName('B') {}
void foo() { cout << "b" << endl; }
char mTypeName;
};
class C {
public:
C() : mTypeName('C') {}
void foo() {
if (mTypeName == 'A') {
A* a = (A*)this;
a->foo();
} else if (mTypeName == 'B') {
B* b = (B*)this;
b->foo();
} else {
cout << "c" << endl;
}
}
char mTypeName;
};
int main() {
C* c0 = (C*)new A();
C* c1 = (C*)new B();
c0->foo();
c1->foo();
while (true);
return 0;
}
这段代码看起来很短,但里面藏了好几个 C++ 的"坑"和"冷知识"。我们从最基础的概念开始,一步一步把它拆开来看。
一、先说结论:这段代码到底在干什么
整段代码的"套路"是:
- 用
new A()真正创建一个A类型的对象(构造函数会往内存里写入字符'A')。 - 用 C 风格强制转换
(C*),把这个A*指针硬转成C*类型,存到c0里。 - 调用
c0->foo(),进入的是C::foo()。 C::foo()内部去读this->mTypeName,发现这个字节其实是'A'(因为内存里本来就是 A 对象写下的内容)。- 于是
C::foo()又把this转回A*,调用A::foo(),打印"a"。c1(由new B()而来)同理,最终打印"b"。
整段代码本质上是利用了"非虚函数调用是在编译期就决定好的"这个特性 + 三个类内存布局恰好完全相同这两个事实,"凑"出了一种看起来像多态、但其实完全是"碰巧能跑"的写法。它是未定义行为(Undefined Behavior, UB),后面会专门解释。
二、背景知识 1:静态绑定 vs 动态绑定
要理解这段代码,最关键的一点是搞清楚:c0->foo() 到底调用的是谁的 foo()?
C++ 里函数调用分两种"绑定"方式:
- 静态绑定(编译期绑定 / 早绑定)
适用于:没有virtual关键字的普通成员函数。
规则:调用哪个函数,只看指针/引用的"声明类型"(静态类型),编译器在编译阶段就把调用地址写死了,完全不管这个指针运行时实际指向什么对象。 - 动态绑定(运行期绑定 / 晚绑定)
适用于:带virtual关键字的函数。
规则:通过对象内部的"虚函数表(vtable)",在运行时根据对象的"实际类型"决定调用谁。
这段代码里,A::foo()、B::foo()、C::foo()全部都不是virtual!
所以: c0的声明类型是C*,那么c0->foo()在编译期就被写死为"调用C::foo()",跟c0实际指向的是不是真的C对象毫无关系。- 同理,
C::foo()内部的a->foo(),因为a的声明类型是A*,所以编译期就写死为"调用A::foo()"。
这就是整段代码能"运作"的第一块拼图:调用谁的函数,完全由指针的类型决定,不需要对象本身"真的是"那个类型。
三、背景知识 2:C 风格强制转换 (C*)xxx 的本质
C* c0 = (C*)new A();
A 和 C 之间没有任何继承关系(不是父子类)。当 C 风格转换作用在两个毫无关系的指针类型之间时,编译器没法做"安全"的 static_cast(因为根本不知道怎么转),于是会退化成 reinterpret_cast。reinterpret_cast 做的事情非常"暴力":
不做任何运行时检查、不做任何地址调整,指针里存的内存地址数值原封不动,只是从现在开始,编译器换一副"眼镜",把这块内存看成另一种类型。
所以c0这个指针变量里存的地址,跟new A()返回的地址是同一个数字,只是类型标签从A*变成了C*。
四、背景知识 3:为什么 A、B、C 的内存布局"长得一模一样"
这是整段代码能"凑巧跑通"的第二块拼图,也是最关键的一块。
观察一下三个类:
class A {
public:
A() : mTypeName('A') {}
void foo() { cout << "a" << endl; }
char mTypeName; // 唯一的数据成员
};
- 三个类都没有
virtual函数 → 都没有虚函数表指针(vtable pointer)。
(如果有虚函数,类的开头会被编译器悄悄插入一个隐藏的指针,那布局就完全不一样了,下面"拓展思考"会讲。) - 三个类都只有一个数据成员:
char mTypeName。 char类型对齐要求是 1 字节,没有额外的填充(padding)。
所以:
| 类 | 数据成员 | 成员偏移量(offset) | sizeof(类) |
|---|---|---|---|
| A | mTypeName | 0 | 1 |
| B | mTypeName | 0 | 1 |
| C | mTypeName | 0 | 1 |
三个类的内存布局完全相同:都是"1 个字节,里面放一个 char"。这意味着——不管你拿哪种类型的指针去读这块内存的第一个字节,读到的内容都是一样的。这就是为什么"把 A 对象的内存当成 C 对象来读 mTypeName"居然能读出正确的值(‘A’)。
五、完整代码 + 逐行详细注释
下面是带有详细中文注释的完整代码,逻辑和原题完全一致,可以直接编译运行(注意:因为最后有死循环,运行后程序不会自己结束)。
#include <iostream>
using namespace std;
// ============ 类 A ============
class A {
public:
// 构造函数:创建 A 对象时,把成员 mTypeName 初始化为字符 'A'
// 也就是说,只要 new A(),这块内存里就一定会被写入字符 'A'(ASCII 65)
A() : mTypeName('A') {}
// 普通成员函数(注意:没有 virtual!)
// 这意味着:调用 a->foo() 这种写法时,编译器在"编译期"就已经
// 把它写死为"调用 A::foo()",运行时不会再做任何判断
void foo() { cout << "a" << endl; }
// 公共数据成员:是 A 类里唯一的成员变量
// 因此它在内存中的偏移量(offset)必然是 0
char mTypeName;
};
// ============ 类 B ============
// 跟 A 几乎是"复制粘贴",唯一区别是字符变成了 'B',打印的内容变成 "b"
class B {
public:
B() : mTypeName('B') {}
void foo() { cout << "b" << endl; }
char mTypeName; // 同样是唯一成员,偏移量也是 0
};
// ============ 类 C ============
// C 的 foo() 比较特别:它会"检查"自己内存里的第一个字节,
// 然后把 this 指针强行解释成 A* 或 B*,再去调用对应的 foo()
class C {
public:
C() : mTypeName('C') {}
void foo() {
// 读取 this 指向内存的第一个字节(偏移量0)
// —— 这一步本身没有问题,因为 C::mTypeName 确实在偏移量0
if (mTypeName == 'A') {
// 把"声明类型为 C*"的 this 指针,强行转换成 A* 类型
// 注意:这只是改变了"编译器看待这块内存的方式",
// 指针对应的内存地址数值完全没有变化
A* a = (A*)this;
// a 的声明类型是 A*,foo() 不是虚函数
// → 编译期就被绑定为调用 A::foo()
a->foo();
} else if (mTypeName == 'B') {
// 同理,强行转换为 B*
B* b = (B*)this;
// 编译期绑定为调用 B::foo()
b->foo();
} else {
// 如果第一个字节既不是 'A' 也不是 'B',走这个分支
cout << "c" << endl;
}
}
char mTypeName; // C 类唯一的成员,偏移量为 0
};
int main() {
// -------- 第一步:处理 c0 --------
// 1) new A():在堆上分配 sizeof(A) = 1 字节内存,
// 并调用 A 的构造函数,把这 1 个字节写成字符 'A'
// 2) (C*):把这个 A* 指针强行转换成 C* 类型
// 地址数值不变,c0 现在"指向"那块本来属于 A 对象的内存,
// 但编译器从此把它当成一个 C 对象来看
C* c0 = (C*)new A();
// -------- 第二步:处理 c1 --------
// 同理:真正分配的是一个 B 对象,内存里写的是字符 'B',
// 然后把这个 B* 指针强转成 C*
C* c1 = (C*)new B();
// -------- 第三步:调用 c0->foo() --------
// c0 的"声明类型"是 C*,foo() 不是虚函数
// → 编译器在编译期就把这一行写死为"调用 C::foo()",
// 完全不关心 c0 实际指向的是不是真的 C 对象
//
// 进入 C::foo() 后:
// this 实际指向的是"A 对象专用"的那 1 字节内存,内容是 'A'
// 读取 mTypeName 得到 'A' → 走第一个 if 分支
// 把 this 转回 A* → 调用 A::foo()(编译期绑定)→ 打印 "a"
c0->foo();
// -------- 第四步:调用 c1->foo() --------
// 同理,c1 的声明类型是 C* → 调用 C::foo()
// this 实际指向"B 对象专用"的那 1 字节内存,内容是 'B'
// 读取 mTypeName 得到 'B' → 走 else if 分支
// 转回 B* → 调用 B::foo()(编译期绑定)→ 打印 "b"
c1->foo();
// -------- 死循环 --------
// 程序会永远卡在这里,不会再往下执行 return 0
// (运行后需要手动结束进程,比如 Ctrl + C)
while (true);
return 0;
}
六、执行流程图解
下面用流程图把 c0->foo() 和 c1->foo() 两条路径分别画出来:
七、从"内存"的角度再看一眼
因为 A、B、C 三个类都没有虚函数表,都只有一个 char 成员,所以每个对象在内存里就是孤孤单单的 1 个字节。下面画出 c0 对应的那块内存:
堆内存中,由 new A() 分配出来的那 1 个字节:
偏移量 0
+--------+
对象内容: | 'A' | <- A 的构造函数把这里写成了字符 'A' (ASCII 65)
+--------+
^
|
c0 (强转后类型为 C*) 也指向这同一个地址
当执行 C::foo() 里的 "mTypeName" 时:
- 编译器认为: C 对象的 mTypeName 成员也位于偏移量 0
- 所以读到的,仍然是偏移量 0 处那个字节的内容
- 结果: 读到 'A',跟 A::mTypeName 的值完全一致
「这只是因为 A、B、C 的内存布局碰巧完全相同,才能读出"看起来正确"的值」
c1(来自 new B())的内存图是一样的,只是那一个字节的内容是 'B'。
八、最终运行结果
a
b
输出之后,程序会卡在 while (true); 这一行,永远不会执行到 return 0;,进程会一直占用 CPU、不会退出,需要手动终止(比如 Ctrl + C 或者在任务管理器里结束进程)。
九、非常重要:这是"未定义行为"(Undefined Behavior, UB)!
虽然这段代码在大多数常见编译器(GCC / Clang / MSVC,且没开特殊优化的情况下)"看起来"能按上面分析的逻辑跑出 a 和 b,但从 C++ 标准的角度看,它踩了至少两个大坑:
(C*)new A():A和C之间没有继承关系,把一个A*强行当成C*来用,违反了"严格别名规则"(strict aliasing rule)。标准并不保证这样转换之后访问对象是安全的。- 通过一个并非真正
C对象的指针去调用C::foo(),并访问mTypeName:标准也不保证这种"指鸡说鸭"的访问一定能拿到"看起来正确"的值。
也就是说:它能跑出预期结果,纯属"三个类的内存布局恰好长得一样 + 函数都不是虚函数"这两个事实凑在一起的巧合。一旦编译器版本、优化选项、类的成员顺序等任何一个条件发生变化,这段代码就可能输出完全不同的结果,甚至直接崩溃。
结论:这种写法只适合用来"理解静态绑定和内存布局的原理",绝对不要在真实项目里这样写代码。
十、拓展思考:如果给 foo() 加上 virtual 会怎样?
如果把 A::foo()、B::foo()、C::foo() 全部改成 virtual void foo(),情况会彻底改变:
- 一旦某个类有虚函数,编译器会在每个对象的最开头悄悄插入一个隐藏的指针——指向该类的"虚函数表(vtable)"。
- 这样一来,
char mTypeName就不再位于偏移量 0,而是被挤到了"vtable 指针"的后面(通常是偏移量 8,在 64 位系统上)。 - 此时
c0->foo()会变成动态绑定:通过this指向内存的最开头去查"vtable 指针",再从A类的 vtable 里找foo的真实地址。
但是c0的内存里存的"vtable 指针",对应的是A类的 vtable;而代码里却把它当成C*来调用——C::foo()内部访问的this->mTypeName,偏移量计算方式可能跟A完全不一致,很容易读到一堆垂圾数据,甚至因为通过错误的 vtable 调用了不存在的函数而直接崩溃(段错误)。
也就是说:这段代码之所以"恰好能跑",恰恰是因为它精心避开了"虚函数"这个会让一切崩盘的因素。
十一、一句话总结
这段代码利用了"普通成员函数的调用在编译期就已经写死、与对象的真实类型无关"这一特性,再加上"A、B、C 三个类碰巧拥有完全相同的内存布局(都只有一个 char 成员、都没有 vtable)“,伪造出了一种"看起来像多态、靠读取一个’类型标签字节’来分发调用"的效果。但这本质上是对编译器的"欺骗”,属于未定义行为,仅适合用来加深对静态绑定 vs 动态绑定、C 风格强转的本质、以及对象内存布局这三个知识点的理解。
第10章笔记:用"继承"让流程切换的代码变得更优雅
这一章在讲的是:游戏里"标题画面 → 游戏画面 → 游戏结束画面 → 结局画面"这种流程切换(シーケンス遷移),之前的写法有一个很明显的"坏味道",这一章要用 C++ 的**继承(继承 / inheritance)**来把它改得更干净。下面从零开始,一步一步拆解。
一、这一章要解决什么问题
在前面的章节里,作者写了一个"炸弹人"游戏的例子。游戏的整体流程被分成好几个"子流程"(也叫"子画面"):
Title(标题画面)Game(游戏画面)GameOver(游戏结束画面)Ending(结局画面)
这几个"画面"在游戏的不同阶段会轮流登场——但任何时刻,只会有一个画面在工作。例如玩家在标题画面按下开始键后,标题画面就"下台",游戏画面"上台",以此类推。
负责"管理这些画面、记录现在是哪个画面在工作"的,是一个叫SequenceParent(流程的"家长")的类。问题就出在这个类的设计上。
二、回顾:之前的写法(多个指针 + if-else)
2.1 之前的设计长什么样
之前的 SequenceParent 类大概是这样设计的(下面是还原出来的简化版本,加了详细注释,可以直接编译运行):
#include <iostream>
// 前向声明:先告诉编译器"以后会有这个类",
// 这样下面的类里才能使用 SequenceParent* 这种指针类型
class SequenceParent;
// ↓↓↓ 这4个类彼此之间没有任何关系,是4个完全独立的类 ↓↓↓
class Title {
public:
// 每个类都各自定义一个 update 函数,
// 函数名一样、参数也一样,但这4个类之间没有任何"亲缘关系"
void update( SequenceParent* parent ) {
std::cout << "[标题画面] 更新中..." << std::endl;
}
};
class Game {
public:
void update( SequenceParent* parent ) {
std::cout << "[游戏画面] 更新中..." << std::endl;
}
};
class GameOver {
public:
void update( SequenceParent* parent ) {
std::cout << "[游戏结束画面] 更新中..." << std::endl;
}
};
class Ending {
public:
void update( SequenceParent* parent ) {
std::cout << "[结局画面] 更新中..." << std::endl;
}
};
// ↓↓↓ "家长"类:它必须同时认识上面这4种完全不同的类型 ↓↓↓
class SequenceParent {
public:
SequenceParent()
: mTitle( nullptr ), mGame( nullptr ),
mGameOver( nullptr ), mEnding( nullptr ) {}
void update() {
// 任意时刻,这4个指针里只有1个不是 nullptr,
// 但代码必须老老实实地把4个都问一遍!
if ( mTitle ) {
mTitle->update( this );
} else if ( mGame ) {
mGame->update( this );
} else if ( mGameOver ) {
mGameOver->update( this );
} else if ( mEnding ) {
mEnding->update( this );
} else {
// 理论上不应该走到这里:4个指针全是空
std::cout << "错误: 没有任何画面在运行(不应该发生)" << std::endl;
}
}
// 4个指针,但同一时刻只有1个会被用到
Title* mTitle;
Game* mGame;
GameOver* mGameOver;
Ending* mEnding;
};
int main() {
SequenceParent parent;
Title title;
parent.mTitle = &title; // 手动指定"当前在用的是标题画面"
parent.update(); // 输出: [标题画面] 更新中...
return 0;
}
2.2 这种写法到底差在哪
观察一下 update() 函数:
if ( mTitle ) {
mTitle->update( this );
} else if ( mGame ) {
mGame->update( this );
} else if ( mGameOver ) {
mGameOver->update( this );
} else if ( mEnding ) {
mEnding->update( this );
} else {
// 报错:理论上不可能
}
这4个分支:
- 函数名一样(都叫
update) - 参数一样(都是
this) - 唯一的区别只是"问的是哪个指针"
也就是说,SequenceParent根本不关心"现在到底是哪个具体的画面",它只是想说:“不管你是谁,把update跑一下”。但代码却被迫写成"先一个个去问,你是不是你、你是不是你……"。
更糟糕的是: - 如果以后又加一个新画面
Pause(暂停画面),就要再加一个mPause指针,再加一个else if。画面越多,if-else链就越长。 - 任意时刻有 3 个指针永远是
nullptr,纯属"占着位置不干活",是一种浪费。 - 编译器没有任何办法帮你检查"是不是真的只有1个指针非空"——这完全靠程序员自己小心维护,一旦哪里写错(比如同时设置了两个),就会出现很诡异的 bug。
三、能不能只用"一个"指针?
3.1 一个"歪招":void* + 类型标记
一种常见的"偷懒"思路是:
用一个
void*指针指向"当前的画面对象",再额外用一个变量记录"这个指针实际指向的是哪种类型"(比如mType == TITLE)。需要用的时候,根据mType的值,用reinterpret_cast把void*强行转换成对应的真实类型。
这种做法能跑,但本质上是在"绕过 C++ 的类型系统",用程序员自己脑子里记的"约定"去代替编译器的检查——一旦哪里写错(比如mType和实际指向的对象类型不一致),编译器完全发现不了,运行时则可能直接读出一堆垂圾数据甚至崩溃。
这正好和之前讨论过的"用reinterpret_cast把一种类型的指针硬转成另一种类型"是同一类问题——能跑,但是建立在"程序员自己保证不出错"这个脆弱的假设上,是一种未定义行为风险很高的写法。
所以这一类"靠强转 + 类型标记"的写法,应该被当成最后的手段,只有在语言本身实在没办法的时候才考虑。
3.2 真正的解法:继承(inheritance)
C++ 提供了一个语言层面、由编译器保证安全的机制,可以达到同样的效果——继承。
核心思路是:
既然
Title、Game、GameOver、Ending这4个类"长得很像"(都有一个update函数,参数和用法完全一致),那就给它们找一个共同的"祖先类",让它们全部"继承"自这个祖先类。SequenceParent以后只需要认识这一个"祖先类",就能间接操作所有的子画面了。
四、继承到底是什么——从零理解
4.1 基类 / 派生类
- 基类(base class,也叫父类):被继承的那个类,定义了"这一类东西"共同拥有的东西。
- 派生类(derived class,也叫子类):继承基类的类,写法是
class 子类名 : public 基类名 { ... }。
派生类会自动拥有基类里定义的所有内容(除了私有成员有访问限制),还可以额外添加自己独有的东西。
打个比方:
“动物"是基类,规定"所有动物都会叫”。"狗"和"猫"是派生类,它们各自继承了"会叫"这个特点,但"狗叫"和"猫叫"的具体声音是不一样的——这就引出了下一个概念。
4.2 虚函数与多态
如果基类里某个函数被声明成 virtual(虚函数),那么:
- 基类只规定"必须有这个函数",但具体怎么做,由每个派生类自己决定(这叫"重写/覆盖",override)。
- 当你通过基类的指针去调用这个函数时,C++ 会在运行时自动判断"这个指针实际指向的是哪个派生类的对象",然后跳去执行那个派生类自己写的版本。
这种"同一行代码,运行时根据实际类型自动跳到不同实现"的能力,叫做多态(polymorphism)。
如果基类里的虚函数写成virtual void update( SequenceParent* parent ) = 0;(末尾加= 0),就叫纯虚函数: - 表示基类完全不提供实现,只规定"必须有这个函数"。
- 拥有纯虚函数的类叫抽象类,不能直接
new出抽象类本身的对象,只能new它的某个派生类。
这正好符合我们的需求:SequenceChild(新设计的"祖先类")只规定"所有子画面必须有update函数",具体每个画面怎么update,各自实现。
五、改造后的设计(完整可运行示例)
下面是用继承改造后的版本。和前面"4个指针 + if-else"的版本相比,请特别留意 SequenceParent 里只有1个指针,update() 里只有1行有效代码。
#include <iostream>
// 前向声明:让 SequenceChild 里可以使用 SequenceParent* 这种指针类型
class SequenceParent;
// ===================== 抽象基类:所有"子画面"的共同祖先 =====================
class SequenceChild {
public:
// 虚析构函数:当通过 SequenceChild* 这种基类指针 delete 一个
// 派生类对象时(比如 delete 一个实际是 Title 的对象),
// 如果析构函数不是 virtual,只会调用 SequenceChild 的析构函数,
// Title 自己的析构函数不会被调用——可能导致资源没释放干净。
// 加上 virtual 后,delete 时会自动调用"实际类型"对应的析构函数。
virtual ~SequenceChild() {}
// 纯虚函数:只规定"必须有 update",具体实现交给每个派生类
virtual void update( SequenceParent* parent ) = 0;
};
// ===================== 流程管理者 =====================
class SequenceParent {
public:
SequenceParent() : mChild( nullptr ) {}
~SequenceParent() {
delete mChild; // 程序结束时,释放当前的子画面对象
}
// 切换"当前正在运行的子画面"
void changeChild( SequenceChild* newChild ) {
delete mChild; // 先释放旧画面占用的内存
mChild = newChild; // 再切换成新画面
}
// 游戏每一帧都会调用这个函数
void update() {
if ( mChild ) {
// 不管 mChild 实际指向的是 Title、Game、
// GameOver 还是 Ending,这一行代码永远长这样!
// 运行时会自动跳到"实际类型"对应的 update 实现。
mChild->update( this );
} else {
std::cout << "错误: 当前没有任何子画面在运行" << std::endl;
}
}
private:
// 只有1个指针!这是和之前写法最大的区别
SequenceChild* mChild;
};
// ===================== 结局画面 =====================
// ": public SequenceChild" 表示 Ending "继承自" SequenceChild
class Ending : public SequenceChild {
public:
// override 关键字:明确告诉编译器"这是在重写基类的虚函数",
// 如果函数签名和基类不匹配,编译器会直接报错(避免手误写错)
void update( SequenceParent* parent ) override {
std::cout << "[结局画面] 游戏结束,感谢游玩!" << std::endl;
// 这里不再切换到别的画面,流程到此结束
}
};
// ===================== 游戏结束画面 =====================
class GameOver : public SequenceChild {
public:
GameOver() : mCount( 0 ) {}
void update( SequenceParent* parent ) override {
std::cout << "[游戏结束画面] 第 " << mCount << " 帧" << std::endl;
++mCount;
if ( mCount >= 2 ) {
// 经过2帧后,切换到结局画面
parent->changeChild( new Ending() );
}
}
private:
int mCount;
};
// ===================== 游戏画面 =====================
class Game : public SequenceChild {
public:
Game() : mCount( 0 ) {}
void update( SequenceParent* parent ) override {
std::cout << "[游戏画面] 第 " << mCount << " 帧" << std::endl;
++mCount;
if ( mCount >= 3 ) {
// 经过3帧后,切换到游戏结束画面
parent->changeChild( new GameOver() );
}
}
private:
int mCount;
};
// ===================== 标题画面 =====================
class Title : public SequenceChild {
public:
Title() : mCount( 0 ) {}
void update( SequenceParent* parent ) override {
std::cout << "[标题画面] 第 " << mCount << " 帧" << std::endl;
++mCount;
if ( mCount >= 3 ) {
// 经过3帧后,切换到游戏画面
parent->changeChild( new Game() );
}
}
private:
int mCount;
};
int main() {
SequenceParent parent;
// 一开始,把"标题画面"设为当前的子画面
parent.changeChild( new Title() );
// 模拟游戏循环跑10帧
// (真实游戏里这里是 while(true) 的无限循环)
for ( int i = 0; i < 10; ++i ) {
parent.update();
}
return 0;
}
运行结果
[标题画面] 第0帧
[标题画面] 第1帧
[标题画面] 第2帧
[游戏画面] 第0帧
[游戏画面] 第1帧
[游戏画面] 第2帧
[游戏结束画面] 第0帧
[游戏结束画面] 第1帧
[结局画面] 游戏结束,感谢游玩!
[结局画面] 游戏结束,感谢游玩!
从头到尾,SequenceParent::update() 里只写了一行 mChild->update( this ),但因为 mChild 实际指向的对象在不断变化(Title → Game → GameOver → Ending),输出的内容却完全不同——这就是"多态"在起作用。
六、前后结构对比图
6.1 之前:4个互不相关的类 + 4个指针
可以看到:Title、Game、GameOver、Ending 之间没有任何连线——它们彼此毫无关系,SequenceParent 必须分别认识它们每一个。
6.2 之后:统一继承自同一个基类 + 1个指针
现在 Title、Game、GameOver、Ending 全部"继承自" SequenceChild(图中 <|-- 箭头表示"继承",箭头指向基类)。SequenceParent 只需要认识 SequenceChild 这一种类型就够了——以后再加多少个新画面,SequenceParent 的代码一行都不用改。
6.3 update() 调用流程对比
之前:
之后:
之前要走 4 层 if-else,之后只需要 1 次判断 + 1次调用,且新增画面时这张图完全不用变。
七、底层原理小贴士:为什么一个指针能"变身"成不同的版本
这部分稍微深入一点,理解上面的代码已经足够使用了,看不懂可以跳过。mChild->update( this ) 这一行,在运行时大致是这样工作的:
mChild (类型: SequenceChild*)
|
v
+----------------------+
| 实际对象 (比如Title) |
|------------------------|
| vptr ----------+ | <- 对象内部偷偷藏了一个"虚函数表指针"
| | |
| mCount = 2 | | <- Title 自己的数据
+------------------------+
|
v
+---------------------+
| Title 的虚函数表 |
|---------------------|
| update -> Title::update |
| ~SequenceChild -> ~Title |
+---------------------+
调用 mChild->update(this) 时:
- 编译器知道
mChild的类型是SequenceChild*,但不会直接把它当成SequenceChild::update(因为update是纯虚函数,没有实现)。 - 运行时会先去对象内部找到那个"虚函数表指针(vptr)"。
- 通过虚函数表查到
update实际对应的是哪个函数地址。 - 如果
mChild实际指向的是一个Title对象,虚函数表里update对应的就是Title::update;如果实际指向Game对象,就对应Game::update。
所以同一行代码mChild->update(this),会根据mChild实际指向的对象类型,自动跳转到不同的函数——这就是"多态"在底层的样子。
| 对比项 | 之前(多个指针 + if-else) | 之后(继承 + 1个指针) |
|---|---|---|
| 需要的指针数量 | N 个(N = 子画面种类数) | 1 个 |
| update() 核心代码 | N 个 if-else 分支 | 1 行: mChild->update(this) |
| 新增一种画面时 | 加新指针 + 加新分支 | 让新画面继承基类即可, Parent 代码不变 |
| 安全性 | 全靠程序员自己保证"只有1个非空" | 语言机制保证 mChild 始终指向1个合法对象 |
八、小结
- 之前的写法用"4个指针 + if-else"来管理"同一时刻只有1个会用到"的几个画面,是典型的重复代码 + 不易扩展的坏味道。
- "用
void*+ 类型标记 +reinterpret_cast"虽然也能解决问题,但属于"绕开类型系统"的歪招,编译器无法帮忙检查,风险较高,应当当作最后手段。 - 真正的解法是继承:给所有"子画面"找一个共同的抽象基类
SequenceChild,定义一个纯虚函数update;每个具体画面(Title、Game、GameOver、Ending)继承这个基类并实现自己的update。 - 改造之后,
SequenceParent只需要保存1个SequenceChild*指针,update()只需要写1行mChild->update(this),依靠多态自动调用到正确的版本——以后再增加新画面,SequenceParent的代码完全不用修改。
第10章笔记(完结篇):Child 基类的最终形态 + 嵌套命名空间的"名字陷阱"
这一节是上一节"用继承改造流程切换"的最终落地版本。前面已经讲清楚了"为什么要用继承",这一节给出真正写出来的代码,并且引出了一个非常经典、也非常容易踩坑的 C++ 知识点——嵌套命名空间里的名字查找规则。
一、Sequence::Child 的最终定义
经过前面的讨论,"子流程基类"被放进一个独立的头文件 Sequence/Child.h 里,内容大致如下:
// 文件: Sequence/Child.h
namespace Sequence {
// 前向声明: Child 里要用到"指向 Parent 的指针"这个类型,
// 此时还不需要 Parent 的完整定义,声明一下就够了
class Parent;
// 所有"子流程"的共同基类
class Child {
public:
// 纯虚函数(末尾 "= 0"):
// 只规定"每个子流程必须有 update",具体怎么做由各个派生类自己写
virtual void update( Parent* parent ) = 0;
// 虚析构函数: 函数体是空的就够了,
// 但前面的 "virtual" 这个关键字绝对不能漏掉!
virtual ~Child() {}
};
} // namespace Sequence
关于"虚析构函数"的一个小知识点
为什么析构函数也要写成 virtual?
想象一下:有一个指针
Child* p,它实际指向的是一个Title对象。如果执行delete p;:
- 如果
~Child()不是virtual:只会调用~Child(),Title自己的析构函数不会被调用——Title里如果有自己申请的资源(比如new出来的内存),就会泄漏。- 如果
~Child()是virtual:delete p会先去查"虚函数表",发现p实际指向的是Title,于是先调用~Title(),再调用~Child()——两个析构函数都跑了,安全。
原书里还提到一种更"高级"的做法:如果项目里有规定"基类指针永远不直接 delete,要 delete 的话一定是通过派生类指针 delete",那么可以把基类析构函数设为protected且不加virtual,这样"对称性"更好(new 的时候是 Title,delete 的时候也一定是 Title)。但如果你还不熟悉这个规定的含义,直接加virtual永远是更安全的选择——这也是本书给读者的建议。
二、继承的箭头方向怎么理解——"猫和狗"的类比
把所有这一层的类画成一张图,会是这样(箭头方向是"从派生类指向基类"):
可能会觉得奇怪:明明是"Child 是基类,Title、Ending 等是从 Child 派生出来的",为什么图上的箭头不是从 Child 指向 Title,而是反过来?
书里给了两种理解方式:
理解方式①:"哺乳类"是从"猫和狗"里抽象出来的概念
现实世界里,根本不存在一种动物叫"哺乳类"——猫、狗这些具体的动物才是先存在的,“哺乳类"只是人类事后总结出来的一个抽象概念,用来描述"猫、狗这些东西的共同特征”。
同理:Title、Ending、GameOver 这些"具体的画面"才是先有需求的,Child 只是事后从它们身上抽象出的"共同特征"(都有一个 update 函数)。所以箭头"从具体指向抽象",反而更符合"概念诞生的顺序"。
理解方式②:“箭头指向你需要去查阅的头文件”
写 Title 这个类的时候,必须先打开 Sequence/Child.h 看一眼"基类长什么样、有哪些函数要实现"。所以可以把箭头理解成"派生类要去看基类的头文件"——箭头指向"需要被参照的对象"。
这两种理解方式选一种顺眼的记住就行,不影响实际写代码。
三、Parent 的最终形态:1个指针 + 1行代码
有了 Sequence::Child,Sequence::Parent 就可以写成下面这样:
// 文件: Sequence/Parent.h
#include "Sequence/Child.h"
namespace Sequence {
class Parent {
public:
Parent() : mChild( nullptr ) {}
~Parent() { delete mChild; }
// 切换"当前正在工作的子流程"
// 不需要关心 mChild 之前到底是 Title、GameOver 还是别的什么,
// 反正直接 delete 掉就对了
void changeChild( Child* newChild ) {
delete mChild;
mChild = newChild;
}
// 每帧调用一次
void update() {
// 以前是4层 if-else,现在只有这一行!
mChild->update( this );
}
private:
// 只有1个指针,不用担心"是不是同时 new 了两个子流程"这种问题
Child* mChild;
};
} // namespace Sequence
update() 函数里"曾经那4重 if-else 的灾难性代码",现在变成了一行 mChild->update( this );。
四、继承的代价:头文件必须互相 #include
继承虽然好用,但有一个绕不开的代价:要继承一个类,必须先看到这个类的"完整定义"(只有前向声明是不够的)。这意味着:
凡是"在头文件里用
: public 某个类"的地方,这个头文件必须#include那个基类所在的头文件。
用文件依赖关系画出来,大概是这样:
Sequence/Child.h <- 定义抽象基类 Sequence::Child
^
| #include (因为要继承,必须看到完整定义)
|
+-- Sequence/Title.h class Title : public Sequence::Child {...}
+-- Sequence/Ending.h class Ending : public Sequence::Child {...}
+-- Sequence/GameOver.h class GameOver : public Sequence::Child {...}
+-- Sequence/Game/Parent.h
class Parent : public Sequence::Child {...}
(这个文件还要再 #include "Sequence/Game/Child.h")
Sequence/Game/Child.h <- 定义 Game 内部的抽象基类 Sequence::Game::Child
^
| #include
|
+-- Sequence/Game/Clear.h
class Clear : public Child {...} (这里的Child指Game::Child)
如果只是"持有一个指针"(比如以前的 Title* mTitle;),其实只需要前向声明 class Title; 就够了,完整定义放在 .cpp 文件里 #include 即可。但继承不行——基类的完整结构必须在编译派生类的时候就摆在那里。这就是继承带来的"头文件传染"问题:用得越多,头文件之间互相 #include 的关系就越密集。
五、多层级结构:每一层都有自己的"Child基类"
上面的类图里,Sequence::Game::Parent 这个类很特殊——它同时属于两套继承体系:
- 身份①:站在"最外层"的角度看,
Game::Parent只是Sequence::Parent的众多"子流程"之一(和Title、Ending、GameOver平级),所以它继承自Sequence::Child。 - 身份②:站在"Game 内部"的角度看,
Game::Parent自己又是一个"家长",它内部还有一堆子流程(比如Clear——通关画面),需要管理这些内部子流程,所以它自己持有一个Sequence::Game::Child*指针。
为此,需要再建一个独立的基类Sequence::Game::Child,专门给Game内部的子流程(比如Clear)继承:
// 文件: Sequence/Game/Child.h
namespace Sequence {
namespace Game {
class Parent; // 前向声明
class Child {
public:
virtual void update( Parent* parent ) = 0; // 这里的Parent是 Sequence::Game::Parent
virtual ~Child() {}
};
} // namespace Game
} // namespace Sequence
然后,Sequence::Game::Clear 就这样定义:
// 文件: Sequence/Game/Clear.h
#include "Sequence/Game/Child.h"
namespace Sequence {
namespace Game {
// 这里的"Child"指的是 Sequence::Game::Child(本文件同名空间下的那个)
class Clear : public Child {
public:
Clear();
~Clear();
void update( Parent* parent ) override; // Parent = Sequence::Game::Parent
// ...(其余成员省略)...
};
} // namespace Game
} // namespace Sequence
注意:整个项目里出现了两个名字都叫 Child 的类——Sequence::Child 和 Sequence::Game::Child,它们是完全不同的两个类,分别管理两个不同层级的流程切换。之所以可以重名而不冲突,是因为它们处在不同的命名空间里,“自己所在的命名空间优先"这条基本规则保证了 Clear 里写的 Child 会优先匹配到"离自己最近"的 Sequence::Game::Child。
——但是!这条"基本规则"在 Game::Parent 身上却有一个例外,这就是接下来要讲的"陷阱”。
六、命名空间的陷阱:同一个"Child",到底指的是哪个?
把 Sequence::Game::Parent 的关键部分单独抽出来,大概是这样:
#include "Sequence/Child.h"
namespace Sequence {
namespace Game {
class Child; // 前向声明 Sequence::Game::Child
class Parent : public Sequence::Child {
Game::Child* mChild;
};
} // namespace Game
} // namespace Sequence
这里有两处看起来"风格不一致"的写法,初学者很容易困惑:
- 继承的时候写的是
Sequence::Child(带Sequence::前缀)。 - 成员变量的类型写的是
Game::Child(带Game::前缀),而不是单独的Child,也不是Sequence::Game::Child。
为什么不能简简单单地都只写Child?答案藏在 C++ 的"名字查找顺序"规则里。
6.1 规则:“继承来的基类名字” 优先级高于 “外层命名空间”
C++ 在类的内部查找一个名字时,大致按下面的顺序找:
- 当前函数/代码块内的局部变量
- 当前类自身 + 它所有的基类(这一步会用到一个叫"注入类名"的机制:一个类的名字,在它自己内部,本身就相当于一个指向"自己"的别名)
- 一层一层往外的命名空间(由近到远)
关键点在第2步——基类的范围,会比"外层命名空间"更早被搜索到。
6.2 用这个规则解释两处写法
问题1:为什么 Parent 内部,单独写 Child 会指向 Sequence::Child,而不是 Sequence::Game::Child?
在 Sequence::Game::Parent 内部查找 "Child":
第1步: 局部变量里有没有叫 Child 的? -> 没有
第2步: Parent 自身 + 它的基类(Sequence::Child)里,
有没有叫 "Child" 的东西?
-> 有! Sequence::Child 的"注入类名"就是 Child,
它代表 Sequence::Child 自己。
-> ★ 在这一步就找到了,搜索立刻停止! ★
(第3步: 外层命名空间 Sequence::Game 里也有一个 Child,
但已经轮不到它了)
结论: 在 Parent 内部, 单独的 "Child" == Sequence::Child
也就是说,如果你图省事,在 Game::Parent 里写 Child* mChild;,编译器实际理解成的是 Sequence::Child* mChild;——这跟你想要的 Sequence::Game::Child* 完全不是一回事!(Sequence::Child 还是个抽象类,甚至无法直接实例化,这种错误很容易在编译阶段就暴露出来,但定位起来会让人摸不着头脑。)
所以书里特意写成 Game::Child* mChild;——多写一层 Game::,明确"跳过"基类范围里的那个 Child,强制从外层命名空间 Sequence::Game 里去找 Child,这才能拿到正确的 Sequence::Game::Child。
问题2:为什么继承的时候,要写 Sequence::Child,而不能直接写 Child?
在 "class Parent : public Child" 这一行查找 "Child":
注意: 这一行是在"宣布"Parent 要继承谁,
此时 Parent 还没有任何基类,
所以"规则6.1的第2步(基类范围)"在这里完全不适用!
第1步: 局部变量 -> 不适用(这不是函数体)
第2步: (跳过,因为还没有基类)
第3步: 外层命名空间 Sequence::Game 里,
有没有叫 "Child" 的东西?
-> 有! 前面用 "class Child;" 前向声明过的
Sequence::Game::Child
-> 找到了, 停止搜索
结论: 如果写 "class Parent : public Child",
这里的 Child 会被解析成 Sequence::Game::Child!
但作者想要的是"外层"的 Sequence::Child!
所以这里必须明确写成 Sequence::Child,用完整路径"跳过"命名空间 Game 里那个同名的 Child,直接指向最外层的 Sequence::Child。
6.3 用一段完整代码"亲眼验证"这个规则
下面这段代码可以直接编译运行。它的关键是 demonstrateNameLookup() 函数——如果这个函数能编译通过,就证明了上面分析的查找顺序是真实存在的:
#include <iostream>
namespace Sequence {
// 前向声明: Child 里要用到"指向 Parent 的指针"
class Parent;
// ===== 顶层的"子流程"基类 =====
class Child {
public:
virtual ~Child() {}
virtual void update( Parent* parent ) = 0;
};
// ===== 顶层的"家长" =====
class Parent {
public:
Parent() : mChild( nullptr ) {}
~Parent() { delete mChild; }
void changeChild( Child* newChild ) {
delete mChild;
mChild = newChild;
}
void update() {
if ( mChild ) {
mChild->update( this );
}
}
private:
Child* mChild;
};
// ===== 顶层子流程: Ending(放在Title前面,因为Title要用到它) =====
class Ending : public Child {
public:
void update( Parent* /*parent*/ ) override {
std::cout << "[Ending] The End." << std::endl;
}
};
// ===== 顶层子流程: Title =====
class Title : public Child {
public:
Title() : mCount( 0 ) {}
void update( Parent* parent ) override {
std::cout << "[Title] frame " << mCount << std::endl;
++mCount;
if ( mCount >= 2 ) {
// 2帧之后,切换到Ending
// (注意: 这一行会 delete 掉当前这个 Title 对象,
// 也就是 delete this。之后这个函数立刻结束,
// 不再访问任何成员变量,所以是安全的)
parent->changeChild( new Ending() );
}
}
private:
int mCount;
};
// ===== 第二层: Game 命名空间,自己有一套 Child/Parent =====
namespace Game {
// 前向声明: Game::Child 里要用到"指向 Game::Parent 的指针"
class Parent;
// 注意: 这个类也叫 Child,但它是 Sequence::Game::Child,
// 和外层的 Sequence::Child 是完全不同的两个类!
class Child {
public:
virtual ~Child() {}
virtual void update( Parent* parent ) = 0; // Parent = Sequence::Game::Parent
};
// Game::Parent 的"双重身份":
// 身份①: 我是顶层 Sequence 的一个子流程 -> 继承 Sequence::Child
// 身份②: 我自己又是"家长" -> 持有 Game::Child*
class Parent : public Sequence::Child {
public:
Parent() : mChild( nullptr ) {}
~Parent() { delete mChild; }
// 这里必须写 "Game::Child*",不能只写 "Child*"!
// 原因见下面的 demonstrateNameLookup()
void setChild( Game::Child* newChild ) {
delete mChild;
mChild = newChild;
}
// 实现身份①要求的纯虚函数(来自 Sequence::Child)
void update( Sequence::Parent* /*parent*/ ) override {
std::cout << "[Game::Parent] 把update转发给内部子流程..." << std::endl;
if ( mChild ) {
mChild->update( this );
}
}
// ===== 验证命名空间名字解析规则的小实验 =====
void demonstrateNameLookup() {
// 在 Game::Parent 内部,单独写 "Child" 到底是谁?
Child* p = nullptr;
// 如果下面这一行能通过编译,
// 就证明了上面的 "Child" 其实就是 Sequence::Child(基类自己)
Sequence::Child* proof1 = p;
(void)proof1;
// 必须写 "Game::Child" 才能表示 Sequence::Game::Child
Game::Child* g = nullptr;
Sequence::Game::Child* proof2 = g;
(void)proof2;
std::cout << "[验证通过] 在 Game::Parent 内部:" << std::endl;
std::cout << " 单独写 Child == Sequence::Child" << std::endl;
std::cout << " 写 Game::Child == Sequence::Game::Child" << std::endl;
}
private:
Game::Child* mChild;
};
// ===== Game 内部的具体子流程: Clear(通关画面) =====
// 这里的 "Child" 指 Sequence::Game::Child
// (因为 Clear 本身没有继承自 Sequence::Child,
// 所以不会触发上面那个"陷阱")
class Clear : public Child {
public:
void update( Parent* /*parent*/ ) override {
std::cout << "[Game::Clear] 你赢了!" << std::endl;
}
};
} // namespace Game
} // namespace Sequence
int main() {
// ---- 演示顶层流程切换 ----
Sequence::Parent topParent;
topParent.changeChild( new Sequence::Title() );
topParent.update(); // [Title] frame 0
topParent.update(); // [Title] frame 1 -> 切换到 Ending
topParent.update(); // [Ending] The End.
std::cout << "========" << std::endl;
// ---- 演示 Game::Parent 的"双重身份" ----
Sequence::Game::Parent gameParent;
gameParent.setChild( new Sequence::Game::Clear() );
gameParent.update( nullptr );
std::cout << "========" << std::endl;
// ---- 验证命名空间名字解析规则 ----
gameParent.demonstrateNameLookup();
return 0;
}
运行结果
[Title] frame 0
[Title] frame 1
[Ending] The End.
========
[Game::Parent] 把update转发给内部子流程...
[Game::Clear] 你赢了!
========
[验证通过] 在 Game::Parent 内部:
单独写 Child == Sequence::Child
写 Game::Child == Sequence::Game::Child
注:为了让示例代码不过于冗长,这里只演示了顶层的
Title -> Ending两个状态,省略了GameOver和真正的Game(只用一个简化的Game::Parent+Game::Clear来演示双重身份和命名空间问题)。完整的四状态切换逻辑和上一篇笔记里的示例是完全一样的。
七、作者的建议与本章总结
书里在讲完这个"命名空间陷阱"之后,给出了一个挺实在的反思:
- 这一类"连名字到底指什么都要仔细推敲"的问题,完全是"用了太多命名空间 + 继承"才会出现的。如果不追求"层级结构上的洁癖",其实完全可以不创建
Game这个命名空间,而是直接用GameParent、GamePlay这样的类名来代替——这样命名空间嵌套带来的所有陷阱都不会发生。 - 对语言细节越熟悉,越容易写出"对自己来说很自然,但别人完全看不懂"的代码。如果是一个人做东西,怎么深入研究都可以;但如果以后要和别人协作开发游戏,就必须考虑到"不是所有人都对这些语言细节很熟悉",过度使用这些技巧反而会让代码变得不好维护。
本章核心收获总结
| 知识点 | 一句话总结 |
|---|---|
| Sequence::Child | 所有子流程的公共基类,含纯虚函数 update 和虚析构函数 |
| 虚析构函数 | 通过基类指针 delete 派生类对象时,保证派生类的析构函数也被调用 |
| 继承箭头方向 | “派生类(具体)指向基类(抽象)”,可以理解为"猫狗->哺乳类"或"要去查阅基类头文件" |
| Parent最终形态 | 只需要1个基类指针 mChild,update() 只需要1行 mChild->update(this) |
| 继承的代价 | 继承需要基类的完整定义,因此基类头文件必须被派生类头文件 #include |
| 双重身份 | Game::Parent 既是顶层Sequence的子流程(继承Sequence::Child),又是Game内部的家长(持有Game::Child*) |
| 命名空间陷阱 | 在 Game::Parent 内部,单独写 “Child” 会优先匹配"继承来的基类"(Sequence::Child),而不是"外层命名空间里同名的类"(Sequence::Game::Child) |
场景切换代码的精简与重构
来源:游戏编程教材附录章节——“おまけ:シーケンス移動コードを短くする”
核心主题:用"子场景返回下一个场景指针"的方式,代替父场景中的大型switch分支
1. 背景:什么是"场景系统"
在游戏中,通常有这样几个画面(场景):
标题画面(Title) → 游戏主体(Game) → 游戏结束(GameOver) → 结局(Ending)
这些画面之间的跳转就叫做场景切换(シーケンス遷移)。
程序上通常用一个父场景(Parent)来管理当前激活的子场景(Child):
Parent(父,管理者)
└── mChild(当前子场景,比如 Title、Game、GameOver...)
父场景每帧调用子场景的 update(),子场景说"我想去哪里",父场景负责切换。
2. 旧设计:子场景通过 enum 告诉父场景去哪里
旧的工作流程
子场景 Title 父场景 Parent
│ │
│ moveTo(NEXT_GAME) ──────► │
│ │ switch(mNextSequence)
│ │ {
│ │ case NEXT_TITLE: new Title;
│ │ case NEXT_GAME: new Game::Parent;
│ │ case NEXT_ENDING: new Ending;
│ │ ...
│ │ }
子场景调用父场景提供的 moveTo() 方法,传入一个枚举值(如 NEXT_GAME),父场景根据这个枚举用 switch 判断该 new 哪个类。
旧代码示意
// 父场景 Parent::update() 中的 switch 分支(旧设计)
void Parent::update() {
mChild->update(this); // 子场景执行,可能调用 moveTo()
// 根据子场景设置的枚举,决定切换到哪个场景
switch (mNextSequence) {
case NEXT_TITLE:
SAFE_DELETE(mChild); // 删除当前子场景
mChild = new Title; // 创建标题场景
break;
case NEXT_GAME:
SAFE_DELETE(mChild);
mChild = new Game::Parent; // 创建游戏场景
break;
case NEXT_GAME_OVER:
SAFE_DELETE(mChild);
mChild = new GameOver; // 创建游戏结束场景
break;
case NEXT_ENDING:
SAFE_DELETE(mChild);
mChild = new Ending; // 创建结局场景
break;
}
}
3. 问题分析:谁才应该决定"去哪里"?
3.1 问题的本质
书中说得很清楚:
问题的根本不在于代码长短,而在于职责分配错了。
请思考一个问题:"知道下一个场景是谁"的,到底是谁?
答案是:子场景自己。
比如标题画面,它知道"玩家按了开始键,所以下一个场景是 Game"。父场景根本不关心,它只是个"中间人",却写了一堆switch来替子场景做决定。
这就是职责错位:子场景该做的事,被父场景抢去做了。
3.2 为什么不能让子场景直接修改 mChild?
有人可能想:那让子场景直接改父场景的 mChild 不就好了?
// 错误示范:子场景直接 delete 自己
void Title::update(Parent* parent) {
if (/* 开始按钮被按下 */) {
SAFE_DELETE(parent->mChild); // 这里 delete 的是"自己"!
parent->mChild = new Game::Parent;
}
}
这会导致严重错误!
SAFE_DELETE(parent->mChild)删除的正是this(Title 自己)- 函数还在运行中,
this指针就消失了 - 这叫做类的自杀(クラスの自殺),行为未定义,非常危险
执行 SAFE_DELETE(parent->mChild) 时:
↓
parent->mChild == this (Title自身)
↓
delete this ←── 函数还没返回!this 就没了!
4. 新设计:子场景通过返回值传递"下一个场景"
4.1 核心思路
既然子场景知道"下一个是谁",就让子场景 new 出下一个场景,然后通过函数返回值交给父场景。
- 没有切换 → 子场景返回
this(指向自己,表示继续) - 需要切换 → 子场景
new出下一个场景并返回
父场景拿到返回值之后: - 如果返回值
== mChild(没变)→ 不做任何事 - 如果返回值
!= mChild(变了)→ 删掉旧的,指向新的
4.2 新设计的代码
子场景基类(Child.h)
// Child.h
// 子场景基类,update() 的返回值从 void 改为 Child*
class Child {
public:
virtual ~Child() {}
// 返回值:
// 返回 this → 没有切换,继续当前场景
// 返回其他指针 → 需要切换到新场景
virtual Child* update(Parent* parent) = 0;
};
子场景 Title 实现
// Title.cpp
Child* Title::update(Parent* parent) {
Child* next = this; // 默认:继续自己(没有切换)
if (/* 玩家按下了开始按钮 */) {
next = new Game::Parent; // 切换到游戏场景
}
// ... 其他逻辑(渲染标题、播放音乐等)
return next; // 返回"下一个场景"(可能是自己,也可能是新场景)
}
父场景 Parent 实现(简化版)
// Parent.cpp
void Parent::update() {
// 让子场景执行,同时拿到"下一个场景"的指针
Child* next = mChild->update(this);
if (next != mChild) {
// 返回值和当前子场景不同,说明要切换了
SAFE_DELETE(mChild); // 删除旧场景
mChild = next; // 指向新场景
}
// 如果相同,什么都不做,继续当前场景
}
5. 新旧设计对比
流程对比图
旧设计(enum + switch):
子场景 父场景
│ │
│ moveTo(NEXT_GAME) ──►│
│ │ switch(mNextSequence)
│ │ case NEXT_GAME: new Game::Parent
│ │ case NEXT_TITLE: new Title
│ │ ......(每加一个场景就要加一个 case)
│ │
新设计(返回指针):
子场景 父场景
│ │
│ return new Game::Parent ──►│
│ │ if (next != mChild)
│ │ delete mChild
│ │ mChild = next
│ │(父场景完全不知道具体是哪个类!)
| 对比项 | 旧设计(switch) | 新设计(返回指针) |
|---|---|---|
| 父场景需要知道所有子场景类型 | 是,全部枚举值都在父场景 | 否,父场景只知道基类 Child* |
| 新增场景时需要修改父场景 | 是,要加 case |
否,只改子场景即可 |
父场景需要 #include 子场景头文件 |
是 | 否 |
#include 集中在哪里 |
集中在父场景(如 6:2:2) | 分散到各子场景(如 4:3:3) |
| 代码可读性 | switch 随场景增多而膨胀 | 父场景极简,职责清晰 |
6. 完整可运行的 C++ 示例
下面是一个精简但可运行的演示,模拟"标题 → 游戏 → 游戏结束"的场景切换流程。
// sequence_demo.cpp
// 演示:用"返回值传递下一个场景"代替 switch 的新场景切换设计
// 编译:g++ -std=c++17 sequence_demo.cpp -o sequence_demo
#include <iostream>
#include <string>
#include <cassert>
// ========== 前向声明 ==========
class Parent;
class Child;
// ========== SAFE_DELETE 宏 ==========
// 安全删除指针并置为 nullptr,防止悬垂指针
#define SAFE_DELETE(p) do { delete (p); (p) = nullptr; } while(0)
// ========== 子场景基类 ==========
class Child {
public:
virtual ~Child() {}
// 核心:返回"下一个场景"
// 返回 this → 继续当前场景
// 返回新场景指针 → 切换到新场景
virtual Child* update(Parent* parent) = 0;
// 调试用:返回场景名称
virtual std::string name() const = 0;
};
// ========== 父场景(管理者) ==========
class Parent {
public:
Child* mChild = nullptr; // 当前激活的子场景
// 每帧调用一次
void update() {
if (!mChild) return;
// 让子场景执行,并接收"下一个场景"
Child* next = mChild->update(this);
// 如果下一个场景和当前不同,说明需要切换
if (next != mChild) {
std::cout << "[Parent] 切换:" << mChild->name()
<< " → " << (next ? next->name() : "nullptr") << "\n";
SAFE_DELETE(mChild); // 删除旧场景
mChild = next; // 指向新场景
}
}
bool hasChild() const { return mChild != nullptr; }
};
// ========== 具体子场景:标题画面 ==========
class GameScene; // 前向声明,Title 需要 new GameScene
class Title : public Child {
int mTimer = 0; // 模拟:3帧后自动切换到游戏(代替"按下开始键")
public:
std::string name() const override { return "Title"; }
Child* update(Parent* parent) override;
};
// ========== 具体子场景:游戏结束画面 ==========
class GameOver : public Child {
int mTimer = 0;
public:
std::string name() const override { return "GameOver"; }
Child* update(Parent* parent) override {
std::cout << " [GameOver] 执行中...\n";
mTimer++;
if (mTimer >= 2) {
// 游戏结束 2 帧后,切换回标题
return new Title;
}
return this; // 继续自己
}
};
// ========== 具体子场景:游戏主体 ==========
class GameScene : public Child {
int mTimer = 0;
public:
std::string name() const override { return "GameScene"; }
Child* update(Parent* parent) override {
std::cout << " [GameScene] 执行中...\n";
mTimer++;
if (mTimer >= 3) {
// 游戏 3 帧后,切换到游戏结束
return new GameOver;
}
return this; // 继续自己
}
};
// ========== Title::update 在 GameScene 定义后实现 ==========
Child* Title::update(Parent* parent) {
std::cout << " [Title] 执行中...\n";
mTimer++;
if (mTimer >= 3) {
// 标题 3 帧后,切换到游戏
return new GameScene;
}
return this; // 继续自己
}
// ========== 主函数 ==========
int main() {
Parent parent;
parent.mChild = new Title; // 初始场景:标题
std::cout << "=== 游戏开始(初始场景:Title)===\n\n";
// 模拟游戏主循环,最多跑 20 帧
for (int frame = 0; frame < 20 && parent.hasChild(); ++frame) {
std::cout << "--- 第 " << frame + 1 << " 帧 ---\n";
parent.update();
}
// 清理
SAFE_DELETE(parent.mChild);
std::cout << "\n=== 程序结束 ===\n";
return 0;
}
预期输出
=== 游戏开始(初始场景:Title)===
--- 第 1 帧 ---
[Title] 执行中...
--- 第 2 帧 ---
[Title] 执行中...
--- 第 3 帧 ---
[Title] 执行中...
[Parent] 切换:Title → GameScene
--- 第 4 帧 ---
[GameScene] 执行中...
--- 第 5 帧 ---
[GameScene] 执行中...
--- 第 6 帧 ---
[GameScene] 执行中...
[Parent] 切换:GameScene → GameOver
--- 第 7 帧 ---
[GameOver] 执行中...
--- 第 8 帧 ---
[GameOver] 执行中...
[Parent] 切换:GameOver → Title
...(循环)
7. 局限性:只能切换同层级的场景
书中特别提醒了一个重要限制:这种方法只适用于同一层级的场景切换。
什么是"层级"?
Sequence::Parent(顶层父场景)
└── mChild → Sequence::Child(顶层子场景,如 Title、Ending)
Sequence::Game::Parent(游戏内父场景)
└── mChild → Sequence::Game::Child(游戏内子场景,如 Pause、Battle)
这是两个层级,它们的 Child 基类是完全不同的类型:
- 顶层:
Sequence::Child - 游戏内:
Sequence::Game::Child
跨层级切换的问题
假如玩家在暂停画面(Pause)按了"返回标题",想从游戏内直接跳到标题:
Pause::update() {
return new Title; // 编译错误!
// Pause 的返回类型是 Sequence::Game::Child*
// Title 是 Sequence::Child 类型
// 这两个类型毫无关系,无法返回!
}
Sequence::Child 和 Sequence::Game::Child 是平行的继承体系,互不兼容。
解决方案:混合使用两种方式
对于跨层级切换,依然沿用旧的 moveTo() 方式,由父场景代为处理。
// Sequence::Game::Parent::update() 中
// ——同层级切换用新方式(返回指针)
if (nextChild != mChild) {
SAFE_DELETE(mChild);
mChild = nextChild;
}
// ——跨层级切换(如切回标题)仍用 moveTo() + switch
switch (mNextSequence) {
case NEXT_ENDING:
next = new Ending;
break;
case NEXT_GAME_OVER:
next = new GameOver;
break;
case NEXT_TITLE:
next = new Title;
break;
}
mNextSequence = NEXT_NONE;
return next;
用一张图总结:
Sequence::Parent
│
┌─────────────┼─────────────┐
│ │ │
Title Ending GameOver
(顶层Child) (顶层Child) (顶层Child)
│
Sequence::Game::Parent
│
┌─────────┴─────────┐
│ │
Battle Pause
(游戏内Child) (游戏内Child)
同层级(如 Title→Ending):用"返回指针"新方式 ✓
跨层级(如 Pause→Title):还是要用 moveTo() 旧方式 ✓
8. 关于 #include 依赖的变化
书中还提到了一个值得注意的副产品:头文件依赖的位置发生了转移。
| 旧设计 | 新设计 |
|---|---|
父场景必须 #include 所有子场景的头文件 |
父场景只需要基类 Child.h |
子场景不需要 #include 其他场景头文件 |
子场景需要 #include 下一个场景的头文件 |
#include 集中在父场景(比例约 6:2:2) |
#include 分散在各子场景(比例约 4:3:3) |
总数没有减少,但分布更均匀了,协作更方便,多人开发时每人只需要改自己负责的子场景。
9. 总结:设计哲学
这个改动背后有一个重要的软件设计原则:
让最了解信息的那个人来做决定。
“下一个场景是谁”——子场景最清楚,所以让子场景来决定。父场景只做"执行切换"这一件事,不需要知道具体是哪个子场景类。
这样:
- 父场景代码极简,几乎不需要修改
- 新增场景时,只需要新增一个子类,改对应的
update()返回值即可 - 整个系统更容易扩展和维护
跨层级场景切换的统一化处理
来源:游戏编程教材附录——“おまけ:階層間シーケンス移動も短くする”
核心主题:用公共基类 +dynamic_cast实现跨层级场景切换的统一管理
1. 上一节遗留的问题回顾
上一节我们实现了"子场景通过返回值告诉父场景切换目标"的新方式。但有一个限制:
这种方式只能处理同层级内的切换,跨层级切换仍然需要旧的
moveTo() + switch方式。
为什么跨层级切换不能统一?
因为不同层级的场景,其基类是不同的类型:
顶层子场景基类: Sequence::Child
游戏内子场景基类:Sequence::Game::Child
Title 继承自 Sequence::Child,Pause 继承自 Sequence::Game::Child,
这两个基类之间毫无关系,update() 的返回类型自然不同,没法互换。
2. 解决思路:引入一个所有场景共同的祖先基类
如果我们在所有层级之上,再建立一个统一的根基类(书中叫 Sequence::Base),
那么 update() 的返回值就可以统一用 Base* 表示,从而实现跨层级传递。
// 最顶层的公共基类
class Base {
public:
virtual ~Base() {}
// 返回值和参数都是 Base*,所有场景都能表示
virtual Base* update(Base* parent) = 0;
};
这样做的直觉
旧方式:
Pause::update(Game::Parent* parent) → Game::Child*
↑ 只认识 Game::Parent ↑ 只能返回游戏内场景
新方式:
Pause::update(Base* parent) → Base*
↑ 可以是任何父场景 ↑ 可以返回任何场景(包括 Title!)
这样,Pause 就有能力直接返回 new Title,而不必再绕道告诉父场景"我要去标题"。
3. 问题:父场景的功能各不相同
虽然统一成 Base* 在类型上可行,但有一个实际问题:
- 游戏内的场景(如
Play、Pause)需要调用Game::Parent特有的功能
(比如"减少生命值"、"设置胜者"等) - 顶层场景(如
Title、Ending)需要调用顶层Parent的功能
如果update()的参数只有Base*,就无法直接调用这些特有功能。
方案一:万能信号函数(不推荐)
给 Base 加一个通用信号函数:
class Base {
public:
// 用两个 int 传递任意语义,非常不直观
virtual void receiveSignal(int signalType, int value) {}
};
然后各父场景自己解释信号含义:
// Game::Parent 中定义信号枚举
namespace Game {
class Parent {
public:
enum Signal {
SIGNAL_REDUCE_LIFE, // 减少生命
SIGNAL_SET_WINNER, // 设置胜者
};
};
}
// 子场景调用时:
void Play::update(Base* parent) {
parent->receiveSignal(Game::Parent::SIGNAL_SET_WINNER, 1);
}
缺点:信号编号不直观,容易搞混,调试困难,书中作者明确说"怎么想都不方便"。
方案二:强制转换 reinterpret_cast(危险,不推荐)
Base* Pause::update(Base* p) {
// 强行把 Base* 转换成 Game::Parent*
Game::Parent* parent = reinterpret_cast<Game::Parent*>(p);
// 问题:如果 p 实际上不是 Game::Parent,程序行为未定义!
}
reinterpret_cast 是"无脑强转",没有任何安全检查,可以把任何指针转成任何类型,非常危险。
方案三:dynamic_cast(安全转换,书中介绍但最终不推荐使用)
Base* Title::update(Base* p) {
// dynamic_cast 会在运行时检查类型
// 如果 p 真的是 Parent*,转换成功
// 如果不是,返回 nullptr
Parent* parent = dynamic_cast<Parent*>(p);
ASSERT(parent); // 如果转换失败(nullptr),立刻报错停止
// ...
}
这比 reinterpret_cast 安全,但如果每个子场景的 update() 都要写一次 dynamic_cast,代码重复很多。
4. 最终方案:分层重定义基类,把 dynamic_cast 集中到一个地方
核心思路:
让每个层级拥有自己的专用基类,这个专用基类内部做好
dynamic_cast,
这样派生的子场景写update()时完全不用关心转换,直接接收正确类型的指针。
类的结构设计
Base (所有场景的公共祖先)
├── Child (顶层子场景的基类,update 参数是 Parent*)
│ ├── Title
│ ├── Ending
│ └── GameOver
├── Parent (顶层父场景)
├── Game::Child (游戏内子场景的基类,update 参数是 Game::Parent*)
│ ├── Game::Play
│ ├── Game::Pause
│ ├── Game::Judge
│ ├── Game::Clear
│ └── Game::Failure
└── Game::Parent (游戏内父场景)
用 Mermaid 图表示继承关系:
代码实现:基类中集中处理 dynamic_cast
// ===== 公共祖先基类 =====
class Base {
public:
virtual ~Base() {}
virtual Base* update(Base* parent) = 0;
};
// ===== 顶层子场景基类 =====
// 继承自 Base,内部把 Base* 转换为 Parent*,再调用真正的 update
class Child : public Base {
public:
// 这个函数是 Base 要求实现的,外部调用这个
Base* update(Base* p) override {
// 在基类这里统一做一次 dynamic_cast
Parent* parent = dynamic_cast<Parent*>(p);
ASSERT(parent); // 类型不对就报错,一定要是 Parent
// 转换成功后调用真正的 update(参数已经是 Parent* 了)
return update(parent);
}
// 子场景只需要实现这个:参数直接就是 Parent*,无需关心转换
virtual Base* update(Parent* parent) = 0;
};
// ===== 游戏内子场景基类 =====
class GameChild : public Base {
public:
Base* update(Base* p) override {
// 同样在基类统一做 dynamic_cast,转换为 Game::Parent*
GameParent* parent = dynamic_cast<GameParent*>(p);
ASSERT(parent);
return update(parent);
}
// 游戏内子场景只需实现这个:参数直接是 GameParent*
virtual Base* update(GameParent* parent) = 0;
};
这样,dynamic_cast 只在 Child 和 GameChild 各写一次,所有派生子场景完全不用关心类型转换。
5. 跨层级切换的实现:GameParent::update()
现在看最关键的部分:Game::Parent 如何处理"子场景返回一个不属于本层级的指针"。
// Game::Parent 的 update(参数是顶层 Parent*,因为它自己是顶层 Child 的派生)
Base* GameParent::update(Parent* parent) {
Base* next = this; // 默认:继续自己
// 调用当前游戏内子场景,拿到"下一个"
Base* nextChild = mChild->update(this);
if (nextChild != mChild) {
// 返回值变了,说明要切换
// 尝试把返回值转换成 GameChild*
GameChild* casted = dynamic_cast<GameChild*>(nextChild);
if (casted) {
// 转换成功:说明是游戏内的场景切换(如 Play → Pause)
SAFE_DELETE(mChild);
mChild = casted;
} else {
// 转换失败(返回 nullptr):说明子场景要跳出游戏层级
// 例如 Play 直接返回 new Title
// GameParent 自己也该结束了,把这个指针往上传
next = nextChild;
}
}
// ... 其他逻辑
return next;
}
流程图解
游戏内子场景(如 Play)
│
│ return new Pause ← 层级内切换
▼
GameParent::update()
│
├─ dynamic_cast<GameChild*>(nextChild) 成功?
│ 是 → mChild = Pause(层级内切换)
│ 否 ↓
│
│ return new Title ← 跨层级切换(Play 直接返回 Title)
▼
GameParent::update()
│
├─ dynamic_cast<GameChild*>(nextChild) 失败(Title 不是 GameChild)
│
└─ next = nextChild(把 Title 这个指针原样往上传)
│
▼
顶层 Parent::update() 收到 Title*,按层级内切换处理
6. 完整可运行的 C++ 示例
// cross_layer_sequence.cpp
// 演示:用公共基类 + dynamic_cast 实现跨层级场景切换
// 编译:g++ -std=c++17 cross_layer_sequence.cpp -o cross_layer_sequence
#include <iostream>
#include <string>
#include <cassert>
// ===== SAFE_DELETE 宏 =====
#define SAFE_DELETE(p) do { delete (p); (p) = nullptr; } while(0)
// ===== ASSERT 宏(调试断言) =====
#define ASSERT(cond) do { \
if (!(cond)) { \
std::cerr << "ASSERT failed: " #cond \
<< " at " << __FILE__ << ":" << __LINE__ << "\n"; \
std::abort(); \
} \
} while(0)
// ===================================================
// 前向声明
// ===================================================
class Base;
class Parent;
class GameParent;
// ===================================================
// 公共祖先基类 Base
// ===================================================
class Base {
public:
virtual ~Base() {}
// 所有场景统一的接口,返回值和参数都是 Base*
virtual Base* update(Base* parent) = 0;
virtual std::string name() const = 0;
};
// ===================================================
// 顶层子场景基类 Child
// 内部做 dynamic_cast,子类只需实现 update(Parent*)
// ===================================================
class Child : public Base {
public:
// 外部调用这个(Base 接口)
Base* update(Base* p) final {
// 在基类统一转换:确保 p 真的是 Parent*
Parent* parent = dynamic_cast<Parent*>(p);
ASSERT(parent); // 类型不匹配时立刻报错
return update(parent); // 调用子类实现的版本
}
// 子类实现这个(参数已经是具体类型)
virtual Base* update(Parent* parent) = 0;
};
// ===================================================
// 游戏内子场景基类 GameChild
// 内部做 dynamic_cast,子类只需实现 update(GameParent*)
// ===================================================
class GameChild : public Base {
public:
Base* update(Base* p) final {
GameParent* parent = dynamic_cast<GameParent*>(p);
ASSERT(parent);
return update(parent);
}
virtual Base* update(GameParent* parent) = 0;
};
// ===================================================
// 游戏内父场景 GameParent(同时是顶层的一个 Child)
// ===================================================
class Play; // 前向声明
class GameParent : public Child {
public:
Base* mChild = nullptr; // 当前游戏内子场景
GameParent(); // 实现在后面(需要 Play 的完整定义)
~GameParent() {
SAFE_DELETE(mChild);
}
std::string name() const override { return "GameParent"; }
// 作为顶层 Child,实现 update(Parent*)
Base* update(Parent* parent) override;
};
// ===================================================
// 游戏内子场景:Play(正常游玩)
// ===================================================
class Pause; // 前向声明
class Play : public GameChild {
int mTimer = 0;
public:
std::string name() const override { return "Play"; }
Base* update(GameParent* parent) override;
};
// ===================================================
// 游戏内子场景:Pause(暂停画面)
// 从 Pause 可以直接跳回顶层 Title(跨层级!)
// ===================================================
class Title; // 前向声明
class Pause : public GameChild {
int mTimer = 0;
public:
std::string name() const override { return "Pause"; }
Base* update(GameParent* parent) override;
};
// ===================================================
// 顶层子场景:Title(标题画面)
// ===================================================
class Title : public Child {
int mTimer = 0;
public:
std::string name() const override { return "Title"; }
Base* update(Parent* parent) override {
std::cout << " [Title] 执行中...\n";
mTimer++;
if (mTimer >= 2) {
std::cout << " [Title] 切换到 GameParent\n";
return new GameParent; // 进入游戏
}
return this;
}
};
// ===================================================
// 顶层子场景:Ending(结局画面)
// ===================================================
class Ending : public Child {
public:
std::string name() const override { return "Ending"; }
Base* update(Parent* parent) override {
std::cout << " [Ending] 结局播放完毕,程序结束\n";
return nullptr; // 返回 nullptr 表示整个程序结束
}
};
// ===================================================
// 顶层父场景 Parent
// ===================================================
class Parent : public Base {
public:
Base* mChild = nullptr;
~Parent() { SAFE_DELETE(mChild); }
std::string name() const override { return "Parent"; }
Base* update(Base* /*p*/) override {
if (!mChild) return nullptr;
Base* next = mChild->update(this); // 注意:this 就是 Parent*
if (next != mChild) {
std::cout << "[Parent] 切换:" << mChild->name()
<< " → " << (next ? next->name() : "nullptr") << "\n";
SAFE_DELETE(mChild);
mChild = next;
}
return this;
}
bool hasChild() const { return mChild != nullptr; }
};
// ===================================================
// 延迟实现:GameParent 构造和 update
// ===================================================
GameParent::GameParent() {
mChild = new Play; // 游戏开始时,子场景是 Play
}
Base* GameParent::update(Parent* parent) {
Base* next = this; // 默认:GameParent 继续存在
// 调用游戏内子场景
Base* nextChild = mChild->update(this);
if (nextChild != mChild) {
// 尝试转换为 GameChild*:是游戏内场景吗?
GameChild* casted = dynamic_cast<GameChild*>(nextChild);
if (casted) {
// 游戏内切换(如 Play → Pause)
std::cout << " [GameParent] 层级内切换:" << mChild->name()
<< " → " << casted->name() << "\n";
SAFE_DELETE(mChild);
mChild = casted;
} else {
// 跨层级切换(如 Pause 返回了 Title 或 Ending)
// GameParent 自身也结束,把指针往上传给顶层 Parent
std::cout << " [GameParent] 跨层级切换,传给上层:" << nextChild->name() << "\n";
next = nextChild;
// GameParent 自身的 mChild 已经没用了,但不 delete nextChild
// nextChild 的所有权转交给上层
}
}
return next;
}
// ===================================================
// 延迟实现:Play::update 和 Pause::update
// ===================================================
Base* Play::update(GameParent* parent) {
std::cout << " [Play] 执行中...\n";
mTimer++;
if (mTimer == 2) {
std::cout << " [Play] 进入暂停\n";
return new Pause; // 层级内切换:Play → Pause
}
return this;
}
Base* Pause::update(GameParent* parent) {
std::cout << " [Pause] 暂停中...\n";
mTimer++;
if (mTimer >= 2) {
std::cout << " [Pause] 跳回标题!(跨层级)\n";
return new Title; // 跨层级切换:Pause → Title(顶层!)
}
return this;
}
// ===================================================
// 主函数
// ===================================================
int main() {
Parent root;
root.mChild = new Title; // 初始场景
std::cout << "=== 游戏开始 ===\n\n";
for (int frame = 0; frame < 20; ++frame) {
std::cout << "--- 第 " << frame + 1 << " 帧 ---\n";
Base* result = root.update(nullptr);
if (!root.hasChild()) {
std::cout << "[Parent] 没有子场景了,退出\n";
break;
}
}
std::cout << "\n=== 程序结束 ===\n";
return 0;
}
预期输出
=== 游戏开始 ===
--- 第 1 帧 ---
[Title] 执行中...
--- 第 2 帧 ---
[Title] 执行中...
[Title] 切换到 GameParent
[Parent] 切换:Title → GameParent
--- 第 3 帧 ---
[Play] 执行中...
--- 第 4 帧 ---
[Play] 执行中...
[Play] 进入暂停
[GameParent] 层级内切换:Play → Pause
--- 第 5 帧 ---
[Pause] 暂停中...
--- 第 6 帧 ---
[Pause] 暂停中...
[Pause] 跳回标题!(跨层级)
[GameParent] 跨层级切换,传给上层:Title
[Parent] 切换:GameParent → Title
--- 第 7 帧 ---
[Title] 执行中...
...
7. 作者的真实态度:这套方案值得用吗?
书中作者在介绍完这套方案后,出人意料地说:
“那么,真的有必要做到这一步吗?”
他给出了几个理由,说明这套dynamic_cast方案在实际项目中不一定值得用:
7.1 更简单的方案其实够用
“每个层级各自定义基类”(即上一节的分层方法)——
- 不需要
dynamic_cast这种相对冷门的特性 - 继承关系更简单,更容易阅读
- 代码虽然长一些,但规模不大、人数不多时完全够用
7.2 冷门特性 = 更多 bug 风险
使用的人越少的特性,编译器的 bug 越难被发现、越晚被修复。
dynamic_cast在游戏开发中使用率很低——作者说问一圈游戏程序员,八成会说"我没用过",他自己也是其中之一。
新硬件平台早期的编译器经常有 bug,越冷门的特性越危险。
7.3 某些平台根本不支持
有些嵌入式/游戏平台的编译器不支持 dynamic_cast(需要开启 RTTI,而 RTTI 在某些平台默认关闭甚至不可用)。
结论
| 简单分层方案 | dynamic_cast 方案 | |
|---|---|---|
| 代码长度 | 较长 | 较短 |
| 可读性 | 好,直观 | 需要理解 dynamic_cast |
| 跨层级切换 | 仍需 switch | 完全统一 |
| 编译器兼容性 | 好 | 依赖 RTTI,部分平台不支持 |
| 实际使用率 | 高 | 低(80% 的游戏程序员没用过) |
| 书中推荐 | 是(后续示例用此方案) | 否(仅作知识介绍) |
最终结论:书中后续示例代码放弃了 dynamic_cast,继续沿用简单的分层方案。
8. 作者的额外展望:彻底不写代码的方案
作者最后还提到了一种终极方案:
用文本文件定义场景跳转关系,写一个解析器来执行它。
# 假想的场景跳转描述文件
Title --[按下开始]--> Game
Game --[通关]------> Ending
Game --[失败]------> GameOver
Pause --[返回标题]--> Title
这样的文件没有指针、没有 new、没有数组,出 bug 的概率极低。
甚至可以做成图形界面工具,用鼠标连线来定义跳转关系。
不过作者也承认:实现这样一个通用系统极其困难,目前没有见过完全实现的例子。
“最终还是有’直接写代码更快’的场景,这类情况不会消失。”
9. 总结:三种方案一览
场景切换方案演进:
第一代(旧)
子场景 moveTo(enum) → 父场景 switch(enum) { case XXX: new XXX; }
问题:父场景代码膨胀,职责错位
第二代(上一节)
子场景 return new NextScene → 父场景 if(next != mChild) 切换
限制:只能处理同层级切换
第三代(本节,dynamic_cast 方案)
公共基类 Base* + 各层基类内部 dynamic_cast
子场景可以返回任意层级的指针,父场景用 dynamic_cast 判断处理
代价:需要 RTTI,冷门,部分平台不支持
实用建议:
小团队/小规模 → 第二代够用
需要跨层级且平台支持 → 第三代
终极目标 → 文本/图形化配置(目前难以通用实现)
继承与虚函数:从零理解底层原理
来源:游戏编程教材附录——“おまけ:継承についてもう少し”
核心主题:虚函数是如何实现的、继承的代价、继承的正确使用时机、纯虚函数
1. 引子:一个危险的代码习惯
书中开篇提到一种常见的错误写法:
void SomeClass::initialize() {
// memset:把 this 开始的 sizeof(SomeClass) 个字节全部强制清零
memset(this, 0, sizeof(SomeClass));
}
memset 是 C 语言标准库函数,它做的事情等价于:
// memset 的等价展开
char* p = reinterpret_cast<char*>(this); // 把 this 当成字节数组
for (int i = 0; i < sizeof(SomeClass); ++i) {
p[i] = 0; // 不管什么类型,一律清零
}
在纯 C 语言时代,这没有问题。但在 C++ 中,对含有虚函数的类使用 memset 会造成致命错误。
为什么?读完下面的内容就会明白。
2. 虚函数是怎么实现的?——用 C 手动模拟
2.1 目标
假设有三个类 A、B、C,A 和 B 都继承自 C。通过 C* 指针调用 foo(),希望:
- 指针实际指向 A → 调用
A::foo() - 指针实际指向 B → 调用
B::foo()
2.2 不用继承,能做到吗?
这个好像不对哦不同类型之前转换c++ 好像是未定义行为
先看一个不用继承的朴素尝试:
// 朴素版本(无继承):通过 reinterpret_cast 强制转换,但无法区分实际类型
#include <iostream>
using namespace std;
class A {
public:
void foo() { cout << "a" << endl; }
};
class B {
public:
void foo() { cout << "b" << endl; }
};
class C {
public:
void foo() { cout << "c" << endl; }
};
int main() {
// 强行把 A* 当成 C* 使用
C* c0 = reinterpret_cast<C*>(new A());
C* c1 = reinterpret_cast<C*>(new B());
c0->foo(); // 输出 c(错!应该输出 a)
c1->foo(); // 输出 c(错!应该输出 b)
return 0;
}
结果:两次都调用了 C::foo(),输出两个 c。因为 C++ 不知道这个指针背后"真正是谁"。
2.3 解决关键:在对象里存一个"我是谁"的标记
要让 C* 知道背后是 A 还是 B,只需要在对象的内存里存一个类型标记:
// 手动模拟虚函数:用 mTypeName 记录"自己真实的类型"
#include <iostream>
using namespace std;
class A {
public:
A() : mTypeName('A') {} // 构造时写入标记:我是 A
void foo() { cout << "a" << endl; }
char mTypeName; // 类型标记字段
};
class B {
public:
B() : mTypeName('B') {} // 构造时写入标记:我是 B
void foo() { cout << "b" << endl; }
char mTypeName;
};
class C {
public:
C() : mTypeName('C') {}
void foo() {
// 根据类型标记,手动分发到正确的函数
if (mTypeName == 'A') {
A* a = reinterpret_cast<A*>(this); // 转回 A*
a->foo(); // 调用 A::foo()
} else if (mTypeName == 'B') {
B* b = reinterpret_cast<B*>(this); // 转回 B*
b->foo(); // 调用 B::foo()
} else {
cout << "c" << endl; // 真正的 C 自己的行为
}
}
char mTypeName;
};
int main() {
C* c0 = (C*)new A(); // A 的 mTypeName 是 'A'
C* c1 = (C*)new B(); // B 的 mTypeName 是 'B'
c0->foo(); // 输出 a(正确)
c1->foo(); // 输出 b(正确)
return 0;
}
这次正确了!原理是:每个对象在内存最开头都有一个 mTypeName 字段,记录自己的真实身份,C::foo() 读取这个字段再决定调用谁。
2.4 C++ 编译器做的事,本质上一样
C++ 的 virtual 关键字让编译器自动做了上面这些事,只是实现更高效:
- 不用
if-else链,而是用虚函数表(vtable) - 每个对象不存类型名字符,而是存一个指向虚函数表的指针(vptr),通常占 4 或 8 字节
对象在内存中的布局(含虚函数):
┌─────────────────────┐
│ vptr(虚函数表指针) │ ← 编译器自动加入,指向该类的 vtable
├─────────────────────┤
│ 成员变量 1 │
├─────────────────────┤
│ 成员变量 2 │
└─────────────────────┘
虚函数表(vtable,每个类一张,全局存放):
┌──────────────────────────┐
│ &A::foo(函数指针) │
├──────────────────────────┤
│ &A::bar(函数指针) │
└──────────────────────────┘
调用虚函数时:
1. 从对象里取出 vptr
2. 通过 vptr 找到 vtable
3. 从 vtable 里取出对应函数的地址
4. 跳转调用
2.5 为什么 memset 会摧毁虚函数?
现在答案很清晰了:
memset(this, 0, sizeof(SomeClass))
↓
把对象所有字节清零
↓
vptr 也被清成了 0(nullptr)
↓
调用虚函数时,从 nullptr 读取 vtable → 程序崩溃!
memset 抹掉了对象的"我是谁"信息,所以调用虚函数时就迷路了。dynamic_cast 也依赖这套类型信息,memset 同样会让它失效。
3. 继承的代价
书中明确说:继承不是免费的,它有以下代价:
3.1 内存开销
每个含虚函数的对象,额外多一个 vptr,通常是 4 字节(32位)或 8 字节(64位)。
对于大量小对象(比如粒子系统里的数千个粒子),这个开销会累积起来。
3.2 速度开销
每次调用虚函数,都要多走"查表"这一步:
普通函数调用:
直接跳转到固定地址(编译期确定)
虚函数调用:
读 vptr → 查 vtable → 取函数地址 → 跳转
这个开销本身很小,但如果一帧调用上万次虚函数,就值得考虑优化了。
书中给出的经验参考:
| 每帧调用次数 | 建议 |
|---|---|
| 约 100 次以下 | 几乎没影响,放心用 |
| 约 1000 次 | 开始考虑是否值得 |
| 超过 10000 次 | 应该寻找替代方案 |
dynamic_cast 同样有运行时开销,它需要在运行时检查类型关系,不是免费的。
3.3 编译依赖开销
定义派生类时,必须 #include 基类的头文件。这会导致:
- 编译时间变长(文件互相包含,依赖链变长)
- 基类的实现细节对派生类的使用者"可见",破坏封装性
“让不需要知道的人不知道”,这就是**封装(カプセル化)**的意义。
4. 继承应该在什么时候用?
书中的核心观点:
“继承应该在’合算’的时候用,而不是’能用’的时候用。”
4.1 继承的五个目的
| 序号 | 目的 | 说明 |
|---|---|---|
| 1 | 对外隐藏派生类 | 使用者只需知道基类,不关心背后具体是哪个派生类 |
| 2 | 消除重复代码 | 多个类有相同逻辑,提取到基类里写一次 |
| 3 | 帮助使用者理解派生类 | 看基类就能大概了解派生类的性质 |
| 4 | 减少派生类作者的工作量 | 基类写好框架,派生类只填空 |
| 5 | 防止派生类作者犯错 | 基类强制规定必须做的事,派生类无法绕过 |
4.2 目的一:隐藏具体类型
最经典的用法。Parent 不需要知道 mChild 到底是 Title 还是 GameOver,它只需要知道"这是一个 Child"并调用 update()。
书中用便利店做比喻:
便利店(Parent)不关心今天值班的是哪位店员(Title / GameOver)
只要"店员"这个角色存在,会做该做的事就够了
4.3 目的二:消除重复代码(注意"is-a"原则)
把多个类的共同逻辑放进基类,很直觉。但有一个重要前提:
派生类必须和基类是"is-a"(是一种)的关系,而不仅仅是"has-a"(有相同代码)的关系。
正确示例:
Title is-a Child (标题画面是一种子场景)✓
GameOver is-a Child (游戏结束是一种子场景)✓
Durian is-a Fruit (榴莲是一种水果) ✓
错误示例:
Human is-a ThinkingReed(人是一种"会思考的芦苇"?)✗
(即使人和芦苇有某些共同属性,也不应该这样建模)
书中警告:只是因为"有相同代码"就建立继承关系,是一种常见的设计错误。即使共同代码再多,只要说不出"A 是 B 的一种",就不该用继承。
4.4 目的三~五:帮助和保护派生类作者
当团队里有水平参差不齐的程序员时,继承是一种"强制规范"的手段:
// 基类强制规定:所有子场景必须实现 update()
class Child {
public:
virtual Base* update(Parent* parent) = 0; // 纯虚函数,必须实现
};
熟练程序员写好基类框架,新手只需填写派生类的具体内容,不用担心遗漏必要步骤。
书中说:“继承是不信任他人的思想的体现”——正因为不相信每个人都会做对,才把规则强制写进基类。
5. 纯虚函数(Pure Virtual Function)
5.1 什么是纯虚函数?
在虚函数声明后面加 = 0:
class Base {
public:
virtual void update(Parent*) = 0; // 纯虚函数:没有实现,必须由派生类提供
};
= 0的意思是"这个函数没有实体,不存在默认实现"- 含有纯虚函数的类叫做抽象类(abstract class),无法直接
new - 没有纯虚函数、可以直接
new的类叫做具象类(concrete class)
Base* b = new Base(); // 编译错误!Base 是抽象类,不能实例化
Base* b = new Title(); // 正确,Title 是具象类(实现了所有纯虚函数)
5.2 为什么要用纯虚函数,而不是普通虚函数?
假设不加 = 0,给基类写一个默认实现:
class Base {
public:
// 忘记写 virtual 了!
void update(Parent*) {
// 默认处理...
}
};
漏写 virtual 的后果:通过基类指针调用 update(),永远只会调用这个默认版本,派生类的 update() 永远不会被调用。这个 bug 很难察觉。
但如果用纯虚函数 = 0,漏写 virtual 根本就编译不通过(因为纯虚函数必须加 virtual)。
普通虚函数(有默认实现):
风险:漏写 virtual → 编译通过,但运行时行为错误,很难发现
纯虚函数(= 0):
保护:漏写 virtual → 编译直接报错,立刻发现
保护:无法 new 抽象类 → 防止误用
书中的设计哲学:
“人是一定会做被允许做的事的生物。想要防止错误,最好的办法是让错误根本无法发生。”
| 普通虚函数 | 纯虚函数(= 0) | |
|---|---|---|
| 基类可以有默认实现 | 是 | 否 |
| 基类可以被 new | 是 | 否(抽象类) |
| 漏写 virtual 的风险 | 高(运行时 bug) | 低(编译报错) |
| 派生类必须实现 | 否(可选) | 是(强制) |
| 推荐程度 | 谨慎使用 | 优先使用 |
6. 完整可运行示例:虚函数底层原理演示
// vtable_demo.cpp
// 演示虚函数的底层原理:vptr + vtable
// 以及纯虚函数、抽象类的使用
// 编译:g++ -std=c++17 vtable_demo.cpp -o vtable_demo
#include <iostream>
#include <string>
#include <cassert>
// ============================================================
// Part 1:手动模拟虚函数(不用 C++ 继承机制)
// ============================================================
namespace Manual {
// 三个独立的类,没有继承关系
class A {
public:
A() : mTypeName('A') {} // 构造时标记自己是 A
void foo() { std::cout << "Manual A::foo()\n"; }
char mTypeName; // 类型标记(手动 vptr 的简化版)
};
class B {
public:
B() : mTypeName('B') {}
void foo() { std::cout << "Manual B::foo()\n"; }
char mTypeName;
};
// C 充当"基类"角色,内部手动分发
class C {
public:
C() : mTypeName('C') {}
void foo() {
// 根据类型标记手动分发,模拟虚函数的 vtable 查找
if (mTypeName == 'A') {
A* a = reinterpret_cast<A*>(this);
a->foo();
} else if (mTypeName == 'B') {
B* b = reinterpret_cast<B*>(this);
b->foo();
} else {
std::cout << "Manual C::foo()\n";
}
}
char mTypeName;
};
void demo() {
std::cout << "=== 手动模拟多态 ===\n";
// 注意:这里成立是因为 A/B/C 的内存布局第一个字节都是 mTypeName
C* c0 = (C*)new A(); // c0 背后是 A,mTypeName = 'A'
C* c1 = (C*)new B(); // c1 背后是 B,mTypeName = 'B'
c0->foo(); // 输出:Manual A::foo()
c1->foo(); // 输出:Manual B::foo()
delete (A*)c0;
delete (B*)c1;
}
} // namespace Manual
// ============================================================
// Part 2:C++ 虚函数(编译器自动管理 vptr + vtable)
// ============================================================
namespace WithVirtual {
// 抽象基类:含纯虚函数,不能被 new
class Animal {
public:
virtual ~Animal() {} // 析构函数也要是虚函数!否则通过基类指针 delete 时不完整
// 纯虚函数:强制派生类实现,Animal 本身不能被实例化
virtual std::string speak() const = 0;
// 普通虚函数:有默认行为,派生类可选择覆盖
virtual std::string describe() const {
return "我是某种动物";
}
// 非虚函数:不会被覆盖,永远调用基类版本
void breathe() const {
std::cout << " 呼吸中...\n";
}
};
class Dog : public Animal {
public:
std::string speak() const override {
return "汪汪!";
}
// describe() 没有覆盖,使用基类默认实现
};
class Cat : public Animal {
public:
std::string speak() const override {
return "喵~";
}
std::string describe() const override {
return "我是猫,高傲的生物";
}
};
void demo() {
std::cout << "\n=== C++ 虚函数演示 ===\n";
// 通过基类指针操作不同派生类
Animal* animals[2];
animals[0] = new Dog();
animals[1] = new Cat();
for (int i = 0; i < 2; ++i) {
std::cout << animals[i]->speak() << "\n"; // 虚函数:调用各自的版本
std::cout << animals[i]->describe() << "\n"; // 虚函数:Dog 用默认,Cat 用自己的
animals[i]->breathe(); // 非虚函数:永远调用 Animal::breathe()
}
// 通过基类指针 delete 时,因为析构函数是 virtual,会正确调用派生类析构函数
for (int i = 0; i < 2; ++i) {
delete animals[i];
}
}
} // namespace WithVirtual
// ============================================================
// Part 3:memset 对虚函数的破坏演示(仅注释说明,不实际执行)
// ============================================================
namespace DangerousMemset {
class BadClass {
public:
BadClass() {}
virtual void doSomething() {
std::cout << "BadClass::doSomething()\n";
}
void unsafeInit() {
// 危险!memset 会把 vptr 清零!
// memset(this, 0, sizeof(BadClass));
// 之后调用任何虚函数 → 程序崩溃(访问 nullptr)
std::cout << " [跳过] memset 演示(会崩溃,仅说明原理)\n";
}
};
class SafeClass {
public:
int mValue = 0;
std::string mName;
void safeInit() {
// 正确做法:逐个成员初始化,或在构造函数中初始化
mValue = 0;
mName = "";
std::cout << " SafeClass 安全初始化完成\n";
}
// 这个类没有虚函数,memset 相对安全(但仍不推荐)
};
void demo() {
std::cout << "\n=== memset 危险性说明 ===\n";
BadClass obj;
obj.doSomething(); // 正常调用
obj.unsafeInit(); // 不实际执行 memset,仅说明
SafeClass safe;
safe.safeInit();
}
} // namespace DangerousMemset
// ============================================================
// Part 4:sizeof 验证 vptr 的存在
// ============================================================
namespace SizeofDemo {
class NoVirtual {
int mX; // 4 字节
};
class WithVirtual {
int mX; // 4 字节
virtual void foo() {} // vptr:额外 4 或 8 字节
};
class TwoVirtuals {
int mX;
virtual void foo() {}
virtual void bar() {} // 同一个类多个虚函数,vptr 只有一个!
};
void demo() {
std::cout << "\n=== sizeof 验证 vptr 开销 ===\n";
std::cout << "NoVirtual 大小:" << sizeof(NoVirtual) << " 字节\n";
std::cout << "WithVirtual 大小:" << sizeof(WithVirtual) << " 字节(多了 vptr)\n";
std::cout << "TwoVirtuals 大小:" << sizeof(TwoVirtuals) << " 字节(仍只有一个 vptr)\n";
// 典型输出(64位系统):
// NoVirtual 大小:4 字节
// WithVirtual 大小:16 字节(4字节int + 4字节对齐填充 + 8字节vptr)
// TwoVirtuals 大小:16 字节(多个虚函数共用同一个 vptr)
}
} // namespace SizeofDemo
// ============================================================
// 主函数
// ============================================================
int main() {
Manual::demo();
WithVirtual::demo();
DangerousMemset::demo();
SizeofDemo::demo();
return 0;
}
7. vptr 与 vtable 的内存示意图
含虚函数的对象在内存中的布局:
┌──────────────────────┐
│ vptr │ ← 8 字节(64位),自动由编译器插入
│ (虚函数表指针) │ 指向该类型的 vtable
├──────────────────────┤
│ 成员变量 mX │ ← 用户定义的成员
├──────────────────────┤
│ 成员变量 mY │
└──────────────────────┘
Dog 的 vtable(全局静态,每个类只有一张):
┌──────────────────────────────────┐
│ [0] &Dog::speak() (函数指针)│
├──────────────────────────────────┤
│ [1] &Animal::describe() (继承)│
└──────────────────────────────────┘
Cat 的 vtable:
┌──────────────────────────────────┐
│ [0] &Cat::speak() │
├──────────────────────────────────┤
│ [1] &Cat::describe() │
└──────────────────────────────────┘
调用 animal->speak() 的过程:
1. 取 animal 对象的 vptr
2. 找到对应的 vtable
3. 取 vtable[0](speak 的函数指针)
4. 跳转执行
8. 类继承关系总结(本章场景系统)
9. 总结
| 概念 | 核心要点 |
|---|---|
| 虚函数原理 | 对象内存里有 vptr,指向 vtable,调用时查表分发 |
| memset 的危险 | 清零 vptr,虚函数调用崩溃 |
| 继承的内存代价 | 每个对象多一个 vptr(通常 4~8 字节) |
| 继承的速度代价 | 虚函数调用需查表,一帧万次以上需考虑优化 |
| 继承的编译代价 | 必须 include 基类头文件,破坏封装,拖慢编译 |
| 何时用继承 | "合算"时用,不是"能用"时就用 |
| is-a 原则 | 派生类必须是基类"的一种",不能仅因共享代码就继承 |
| 纯虚函数 | = 0,强制派生类实现,防止误用基类 |
| 抽象类 | 含纯虚函数,不能直接 new,只能 new 派生类 |
| 设计哲学 | 让错误根本无法发生,胜过让人记住"不能这样做" |
第N章总结:用继承改良场景(序列)切换
本章核心思想
这章讲的是一个游戏开发中非常常见的问题:如何管理游戏的多个"场景"(画面)之间的切换,
例如从"游戏中"切换到"结局"或"游戏结束"画面。
作者把每个游戏画面叫做序列(シーケンス / Sequence),并用继承来重构这个切换机制。
从零理解:什么是"序列切换"?
想象一个简单的游戏流程:
开始画面 → 游戏中 → 结局画面
↘ 游戏结束画面
每个画面都是一个独立的"状态",游戏在不同状态之间跳转。
如果直接用 if/else 或者 switch 来判断当前是哪个状态,代码很快就会变得混乱。
继承的解决方案:
把所有画面都做成一个基类(父类)的子类,
主程序只需要知道"基类",不需要知道具体是哪个子类。
继承的类结构
关键点:Parent(主控类)只持有 Sequence*(基类指针),不知道 Ending 和 GameOver 的存在。
为什么"不知道派生类"是好事?
书中特别强调:
Parent甚至不知道Ending或GameOver的存在,没有#include它们的头文件。
这意味着什么?
| 好处 | 说明 |
|---|---|
| 降低耦合 | Parent 不依赖具体场景,新增场景不需要改动 Parent |
| 编译变快 | 不 #include 就不需要重新编译 |
| 代码更清晰 | 写 Parent.cpp 时,脑子里完全不需要想 Ending 怎么实现的 |
| 易于扩展 | 加一个新场景(如 StageSelect)只需新建一个子类,Parent 完全不用改 |
用代码理解这个设计
下面是一个完整可运行的示例,展示这种"Parent只知道基类"的设计思路。
// sequence.h —— 基类头文件,所有人都能看
#ifndef SEQUENCE_H
#define SEQUENCE_H
class Sequence {
public:
virtual ~Sequence() = default;
// 更新逻辑(每帧调用)
virtual void update() = 0;
// 绘制(每帧调用)
virtual void draw() const = 0;
// 返回下一个场景;返回 nullptr 表示继续当前场景
// 返回新对象表示切换场景
virtual Sequence* next() = 0;
// 判断是否应该退出整个游戏
virtual bool wantsQuit() const { return false; }
};
#endif // SEQUENCE_H
// ending.h —— 只有"需要知道结局场景"的人才 include 这个
#ifndef ENDING_H
#define ENDING_H
#include "sequence.h"
#include <iostream>
class Ending : public Sequence {
public:
void update() override {
std::cout << "[Ending] 更新中...\n";
++mTimer;
}
void draw() const override {
std::cout << "[Ending] 绘制结局画面\n";
}
Sequence* next() override {
// 播放了3帧后切换到空(结束游戏)
if (mTimer >= 3) {
mWantsQuit = true;
}
return nullptr; // nullptr = 继续当前场景
}
bool wantsQuit() const override { return mWantsQuit; }
private:
int mTimer = 0;
bool mWantsQuit = false;
};
#endif // ENDING_H
// gameover.h
#ifndef GAMEOVER_H
#define GAMEOVER_H
#include "sequence.h"
#include "ending.h" // GameOver 知道 Ending,因为它要切换过去
#include <iostream>
class GameOver : public Sequence {
public:
void update() override {
std::cout << "[GameOver] 更新中...\n";
++mTimer;
}
void draw() const override {
std::cout << "[GameOver] 绘制游戏结束画面\n";
}
Sequence* next() override {
// 显示了2帧后切换到结局
if (mTimer >= 2) {
return new Ending(); // 返回新场景对象,触发切换
}
return nullptr;
}
private:
int mTimer = 0;
};
#endif // GAMEOVER_H
// parent.h —— 主控类,只 include sequence.h,不知道 Ending/GameOver
#ifndef PARENT_H
#define PARENT_H
#include "sequence.h"
class Parent {
public:
// 注意:构造函数接受一个 Sequence* 指针
// Parent 自己不知道这是什么具体类型
explicit Parent(Sequence* initialSequence);
~Parent();
// 运行一帧,返回 false 表示应该退出
bool runOneFrame();
private:
Sequence* mCurrent; // 当前场景,只持有基类指针
};
#endif // PARENT_H
// parent.cpp —— 关键:这里没有 #include "ending.h" 或 "gameover.h"
#include "parent.h"
Parent::Parent(Sequence* initialSequence)
: mCurrent(initialSequence) {}
Parent::~Parent() {
delete mCurrent;
}
bool Parent::runOneFrame() {
if (!mCurrent) return false;
mCurrent->update();
mCurrent->draw();
// 询问当前场景:下一个场景是什么?
Sequence* nextSeq = mCurrent->next();
if (nextSeq != nullptr) {
// 有新场景 → 切换
delete mCurrent;
mCurrent = nextSeq;
}
// 询问是否退出
return !mCurrent->wantsQuit();
}
// main.cpp —— 只有 main 知道所有具体类(因为它负责创建初始场景)
#include "parent.h"
#include "gameover.h" // main.cpp 知道初始场景是 GameOver
int main() {
// 创建初始场景(GameOver)交给 Parent 管理
// 之后 Parent 完全不知道这是 GameOver,它只看到 Sequence*
Parent parent(new GameOver());
// 游戏主循环
while (parent.runOneFrame()) {
// 继续运行
}
std::cout << "游戏结束。\n";
return 0;
}
运行结果:
[GameOver] 更新中...
[GameOver] 绘制游戏结束画面
[GameOver] 更新中...
[GameOver] 绘制游戏结束画面
[Ending] 更新中...
[Ending] 绘制结局画面
[Ending] 更新中...
[Ending] 绘制结局画面
[Ending] 更新中...
[Ending] 绘制结局画面
游戏结束。
包含关系图(谁 include 谁)
下面展示各文件的包含依赖关系,注意 parent.cpp 是孤立的,它只知道 sequence.h:
Parent(绿色)只依赖Sequence(蓝色),完全看不到具体的派生类。
场景切换的流程(状态机)
关于"附录内容"(おまけ)
书中提到附录里介绍了更复杂的实现技巧,并说:
如果你在读那部分时犯困了,说明你的潜意识认为它不是必须的。
没有它照样能做游戏。
作者这里是在提醒初学者不要过度设计。继承是一个工具,用得不好反而带来混乱。
真正重要的不是语言特性本身,而是在合适的地方合适地使用它。
本章核心要点总结
| 要点 | 说明 |
|---|---|
| 用基类指针管理所有场景 | Sequence* 统一接口,运行时多态决定行为 |
| Parent 不 include 派生类头文件 | 降低编译依赖,写代码时更专注 |
| 场景切换由 next() 决定 | 返回 nullptr = 继续,返回新对象 = 切换 |
| 只有 main.cpp 知道具体类 | 负责创建初始场景,之后交给 Parent 托管 |
| 继承不是万能的 | 用得不好会制造混乱,不需要就不用 |
下一章预告
作者说下一章是"脱线(离题)"章节,内容是播放声音。
只想知道"怎么调用播放声音的函数"的人,几页就能读完。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐
所有评论(0)