本文件是对 43-BakudanBitoWithImprovedSequence 项目的从零开始中文讲解。
这是一个类似《炸弹超人(Bomberman)》的 C++ 小游戏,使用自制的 GameLib 框架运行在 Windows 上。

目录

  1. 整体结构总览
  2. 程序启动流程 main.cpp
  3. 序列(Scene)管理系统
  4. 游戏内部的子状态系统 Sequence::Game
  5. 输入系统 Pad
  6. 游戏核心逻辑 State
  7. 静态对象 StaticObject(格子)
  8. 动态对象 DynamicObject(玩家/敌人)
  9. 碰撞检测数学原理
  10. 二维数组模板 Array2D
  11. 完整序列状态机图
  12. 关键设计思想总结

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* 指针,不关心具体是 TitleGameOver 还是 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_ENDINGNEXT_GAME_OVERNEXT_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 坐标编码=(x16)+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_BRICKFLAG_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=axrA,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=bxrB,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<BrightAright>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. 完整序列状态机图

选择单人
按A键

选择双人
按A键

2秒后

B键

1P模式
敌人全灭

1P死亡

2P模式
有人死亡

继续

返回标题

1秒后
非最终关

1秒后
最终关通关

1秒后
还有命

1秒后
无命

再来一局

返回标题

2秒后

1秒后

按Q键

程序启动

创建 Sequence::Parent

Title 标题画面

Game::Parent
游戏管理器

Ready 准备阶段
显示 Ready.../GO!

Play 游戏进行中

Pause 暂停

Clear 通关

Failure 失败

Judge 胜负判定

Ending 结局画面

GameOver 游戏结束

程序结束

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 进行从零讲解。
重点放在两件事上:

  1. 整个游戏的"画面切换"是如何用一套叫做 Sequence(序列) 的 Parent/Child 状态机模式组织起来的,以及这一版相对于"基础版"做了什么"改进";
  2. 游戏内部(地图、炸弹、爆炸、玩家、敌人)的数据结构与核心算法。

一、这个项目到底是什么

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()。这个函数需要做四件事:

  1. 读取玩家的输入(按了哪个键);
  2. 根据当前画面的逻辑,更新内部数据(移动角色、倒计时等);
  3. 把当前画面画出来;
  4. 判断是否应该切换到另一个画面(比如倒计时结束了、按了确认键……)。
    最直接、最"新手"的写法是用一个大大的 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),但加上了"状态对象自己负责创建下一个状态对象"这个小技巧,使得状态切换的逻辑非常集中、清晰。
每个具体画面(TitleGameOver……):

  • 构造函数里做"进入这个画面时要做的事"(比如加载图片、初始化倒计时);
  • 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"的精髓所在——用同一套简单的接口/模式,递归地组织出有层次的状态机,而不需要发明新的机制。后面第七节会详细拆解这个类。

四、整体状态流转图

下面这张图展示外层的四个大画面是怎么互相切换的:

按A键
选择1P/2P并开始游戏

内层请求
NEXT_ENDING
(通关最终关卡)

内层请求
NEXT_GAME_OVER
(生命数耗尽)

内层请求
NEXT_TITLE
(暂停菜单或决胜后选择返回标题)

等待120帧(2秒)后

等待60帧(1秒)后

Title

GameParent

Ending

GameOver

下面这张图展示内层(也就是 GameParent 内部)的子状态是怎么切换的:

准备倒数120帧(2秒)结束

1P模式下
敌人已清空且自己活着

自己死亡

2P模式下
有一方死亡

按下B键

选择"继续游戏"

选择"返回标题"
(moveTo NEXT_TITLE)

还有下一关

已通最终关
(moveTo NEXT_ENDING)

生命数还大于0

生命数为0
(moveTo NEXT_GAME_OVER)

选择"再来一局"

选择"返回标题"
(moveTo NEXT_TITLE)

Ready

Play

Clear

Failure

Judge

Pause

图中内层状态画到 [*](结束)的那几条线,实际上并不是真的"结束程序",而是调用 parent->moveTo(...) 把"想跳到外层哪个画面"的意图记录下来,然后由 GameParent::update() 在每帧的最后统一处理——也就是上一张图里 GameParent --> Ending / GameOver / Title 这几条线。这个"先登记意图、再统一处理"的两段式设计,正是让内层状态完全不需要知道外层 TitleGameOverEnding 这些类的存在的关键,第七节会详细解释。

代码解析:靠"骗"编译器实现的伪多态 —— 从零理解静态绑定与类型双关

#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++ 的"坑"和"冷知识"。我们从最基础的概念开始,一步一步把它拆开来看。

一、先说结论:这段代码到底在干什么

整段代码的"套路"是:

  1. new A() 真正创建一个 A 类型的对象(构造函数会往内存里写入字符 'A')。
  2. 用 C 风格强制转换 (C*),把这个 A* 指针硬转C* 类型,存到 c0 里。
  3. 调用 c0->foo(),进入的是 C::foo()
  4. C::foo() 内部去读 this->mTypeName,发现这个字节其实是 'A'(因为内存里本来就是 A 对象写下的内容)。
  5. 于是 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();

AC 之间没有任何继承关系(不是父子类)。当 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() 两条路径分别画出来:

new A() 分配1字节内存
调用A的构造函数
把这个字节写为字符 'A'

(C*)强制转换
c0 = 这块内存的地址
(数值不变,类型标签变成 C*)

调用 c0 -> foo()
foo()非虚函数
编译期绑定为 C::foo()

进入 C::foo()
读取 this -> mTypeName
实际读到的是 'A'

if (mTypeName == 'A') 成立
A* a = (C*)this 转回 A*

调用 a -> foo()
非虚函数,编译期绑定为 A::foo()

输出: a

new B() 分配1字节内存
调用B的构造函数
把这个字节写为字符 'B'

(C*)强制转换
c1 = 这块内存的地址
(数值不变,类型标签变成 C*)

调用 c1 -> foo()
foo()非虚函数
编译期绑定为 C::foo()

进入 C::foo()
读取 this -> mTypeName
实际读到的是 'B'

else if (mTypeName == 'B') 成立
B* b = (C*)this 转回 B*

调用 b -> foo()
非虚函数,编译期绑定为 B::foo()

输出: b

七、从"内存"的角度再看一眼

因为 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,且没开特殊优化的情况下)"看起来"能按上面分析的逻辑跑出 ab,但从 C++ 标准的角度看,它踩了至少两个大坑

  1. (C*)new A()AC 之间没有继承关系,把一个 A* 强行当成 C* 来用,违反了"严格别名规则"(strict aliasing rule)。标准并不保证这样转换之后访问对象是安全的。
  2. 通过一个并非真正 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_castvoid* 强行转换成对应的真实类型。
这种做法能跑,但本质上是在"绕过 C++ 的类型系统",用程序员自己脑子里记的"约定"去代替编译器的检查——一旦哪里写错(比如 mType 和实际指向的对象类型不一致),编译器完全发现不了,运行时则可能直接读出一堆垂圾数据甚至崩溃。
这正好和之前讨论过的"用 reinterpret_cast 把一种类型的指针硬转成另一种类型"是同一类问题——能跑,但是建立在"程序员自己保证不出错"这个脆弱的假设上,是一种未定义行为风险很高的写法。
所以这一类"靠强转 + 类型标记"的写法,应该被当成最后的手段,只有在语言本身实在没办法的时候才考虑。

3.2 真正的解法:继承(inheritance)

C++ 提供了一个语言层面、由编译器保证安全的机制,可以达到同样的效果——继承
核心思路是:

既然 TitleGameGameOverEnding 这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 实际指向的对象在不断变化(TitleGameGameOverEnding),输出的内容却完全不同——这就是"多态"在起作用。

六、前后结构对比图

6.1 之前:4个互不相关的类 + 4个指针

SequenceParent

-mTitle

-mGame

-mGameOver

-mEnding

+update()

Title

Game

GameOver

Ending

可以看到:TitleGameGameOverEnding 之间没有任何连线——它们彼此毫无关系,SequenceParent 必须分别认识它们每一个。

6.2 之后:统一继承自同一个基类 + 1个指针

«abstract»

SequenceChild

+update(parent)

Title

Game

GameOver

Ending

SequenceParent

-mChild

+update()

+changeChild(newChild)

现在 TitleGameGameOverEnding 全部"继承自" SequenceChild(图中 <|-- 箭头表示"继承",箭头指向基类)。SequenceParent 只需要认识 SequenceChild 这一种类型就够了——以后再加多少个新画面,SequenceParent 的代码一行都不用改

6.3 update() 调用流程对比

之前:

调用 parent.update()

mTitle 非空?

mTitle->update(this)

mGame 非空?

mGame->update(this)

mGameOver 非空?

mGameOver->update(this)

mEnding 非空?

mEnding->update(this)

报错
(理论上不可能发生)

之后:

调用 parent.update()

mChild 非空?

mChild->update(this)
自动跳到 Title/Game/
GameOver/Ending 各自的实现

报错处理

之前要走 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) 时:

  1. 编译器知道 mChild 的类型是 SequenceChild*,但不会直接把它当成 SequenceChild::update(因为 update 是纯虚函数,没有实现)。
  2. 运行时会先去对象内部找到那个"虚函数表指针(vptr)"。
  3. 通过虚函数表查到 update 实际对应的是哪个函数地址。
  4. 如果 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;每个具体画面(TitleGameGameOverEnding)继承这个基类并实现自己的 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()virtualdelete p 会先去查"虚函数表",发现 p 实际指向的是 Title,于是先调用 ~Title(),再调用 ~Child()——两个析构函数都跑了,安全。
    原书里还提到一种更"高级"的做法:如果项目里有规定"基类指针永远不直接 delete,要 delete 的话一定是通过派生类指针 delete",那么可以把基类析构函数设为 protected不加 virtual,这样"对称性"更好(new 的时候是 Title,delete 的时候也一定是 Title)。但如果你还不熟悉这个规定的含义,直接加 virtual 永远是更安全的选择——这也是本书给读者的建议。

二、继承的箭头方向怎么理解——"猫和狗"的类比

把所有这一层的类画成一张图,会是这样(箭头方向是"从派生类指向基类"):

继承

继承

继承

继承
身份①: 我是顶层的一个子流程

持有指针

继承

持有指针
身份②: 我是Game内部的家长

Sequence::Child
(抽象基类)
virtual update(Parent*) = 0

Sequence::Title

Sequence::GameOver

Sequence::Ending

Sequence::Game::Parent
(身份特殊,见下文)

Sequence::Parent
只有一个指针: mChild

Sequence::Game::Child
(抽象基类)
virtual update(Game内的Parent*) = 0

Sequence::Game::Clear

可能会觉得奇怪:明明是"Child 是基类,TitleEnding 等是从 Child 派生出来的",为什么图上的箭头不是从 Child 指向 Title,而是反过来?
书里给了两种理解方式:
理解方式①:"哺乳类"是从"猫和狗"里抽象出来的概念
现实世界里,根本不存在一种动物叫"哺乳类"——猫、狗这些具体的动物才是先存在的,“哺乳类"只是人类事后总结出来的一个抽象概念,用来描述"猫、狗这些东西的共同特征”。
同理:TitleEndingGameOver 这些"具体的画面"才是先有需求的,Child 只是事后从它们身上抽象出的"共同特征"(都有一个 update 函数)。所以箭头"从具体指向抽象",反而更符合"概念诞生的顺序"。
理解方式②:“箭头指向你需要去查阅的头文件”
Title 这个类的时候,必须先打开 Sequence/Child.h 看一眼"基类长什么样、有哪些函数要实现"。所以可以把箭头理解成"派生类要去看基类的头文件"——箭头指向"需要被参照的对象"。
这两种理解方式选一种顺眼的记住就行,不影响实际写代码。

三、Parent 的最终形态:1个指针 + 1行代码

有了 Sequence::ChildSequence::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 的众多"子流程"之一(和 TitleEndingGameOver 平级),所以它继承自 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::ChildSequence::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

这里有两处看起来"风格不一致"的写法,初学者很容易困惑:

  1. 继承的时候写的是 Sequence::Child(带 Sequence:: 前缀)。
  2. 成员变量的类型写的是 Game::Child(带 Game:: 前缀),而不是单独的 Child,也不是 Sequence::Game::Child
    为什么不能简简单单地都只写 Child?答案藏在 C++ 的"名字查找顺序"规则里。

6.1 规则:“继承来的基类名字” 优先级高于 “外层命名空间”

C++ 在类的内部查找一个名字时,大致按下面的顺序找:

  1. 当前函数/代码块内的局部变量
  2. 当前类自身 + 它所有的基类(这一步会用到一个叫"注入类名"的机制:一个类的名字,在它自己内部,本身就相当于一个指向"自己"的别名)
  3. 一层一层往外的命名空间(由近到远)
    关键点在第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 这个命名空间,而是直接用 GameParentGamePlay 这样的类名来代替——这样命名空间嵌套带来的所有陷阱都不会发生。
  • 对语言细节越熟悉,越容易写出"对自己来说很自然,但别人完全看不懂"的代码。如果是一个人做东西,怎么深入研究都可以;但如果以后要和别人协作开发游戏,就必须考虑到"不是所有人都对这些语言细节很熟悉",过度使用这些技巧反而会让代码变得不好维护。

本章核心收获总结


知识点 一句话总结
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::ChildSequence::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::ChildPause 继承自 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* 在类型上可行,但有一个实际问题:

  • 游戏内的场景(如 PlayPause)需要调用 Game::Parent 特有的功能
    (比如"减少生命值"、"设置胜者"等)
  • 顶层场景(如 TitleEnding)需要调用顶层 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 图表示继承关系:

Base

+update(Base* parent) : Base

Child

+update(Base* p) : Base

+update(Parent* parent) : Base

GameChild

+update(Base* p) : Base

+update(GameParent* parent) : Base

Parent

GameParent

Title

Ending

GameOver

Play

Pause

Judge

代码实现:基类中集中处理 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 只在 ChildGameChild 各写一次,所有派生子场景完全不用关心类型转换。

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. 类继承关系总结(本章场景系统)

继承

继承

继承

继承

继承

继承

继承

继承

继承

继承

Base

+update(Base*) : Base

Child

+update(Base* p) : Base

+update(Parent*) : Base

GameChild

+update(Base* p) : Base

+update(GameParent*) : Base

Parent

GameParent

Title

Ending

GameOver

GamePlay

GamePause

GameJudge

9. 总结


概念 核心要点
虚函数原理 对象内存里有 vptr,指向 vtable,调用时查表分发
memset 的危险 清零 vptr,虚函数调用崩溃
继承的内存代价 每个对象多一个 vptr(通常 4~8 字节)
继承的速度代价 虚函数调用需查表,一帧万次以上需考虑优化
继承的编译代价 必须 include 基类头文件,破坏封装,拖慢编译
何时用继承 "合算"时用,不是"能用"时就用
is-a 原则 派生类必须是基类"的一种",不能仅因共享代码就继承
纯虚函数 = 0,强制派生类实现,防止误用基类
抽象类 含纯虚函数,不能直接 new,只能 new 派生类
设计哲学 让错误根本无法发生,胜过让人记住"不能这样做"

第N章总结:用继承改良场景(序列)切换

本章核心思想

这章讲的是一个游戏开发中非常常见的问题:如何管理游戏的多个"场景"(画面)之间的切换
例如从"游戏中"切换到"结局"或"游戏结束"画面。
作者把每个游戏画面叫做序列(シーケンス / Sequence),并用继承来重构这个切换机制。

从零理解:什么是"序列切换"?

想象一个简单的游戏流程:

开始画面 → 游戏中 → 结局画面
                  ↘ 游戏结束画面

每个画面都是一个独立的"状态",游戏在不同状态之间跳转。
如果直接用 if/else 或者 switch 来判断当前是哪个状态,代码很快就会变得混乱。
继承的解决方案:
把所有画面都做成一个基类(父类)的子类,
主程序只需要知道"基类",不需要知道具体是哪个子类。

继承的类结构

继承

继承

只知道基类

«抽象基类»

Sequence

+update() : void

+draw() : void

+next() : Sequence

Parent

-Sequence* current

+run() : void

Ending

+update() : void

+draw() : void

+next() : Sequence

GameOver

+update() : void

+draw() : void

+next() : Sequence

关键点:Parent(主控类)只持有 Sequence*(基类指针),不知道 EndingGameOver 的存在。

为什么"不知道派生类"是好事?

书中特别强调:

Parent 甚至不知道 EndingGameOver 的存在,没有 #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

main.cpp

parent.h

gameover.h

sequence.h(基类)

ending.h

Parent(绿色)只依赖 Sequence(蓝色),完全看不到具体的派生类。

场景切换的流程(状态机)

游戏启动

2帧后
next() 返回 new Ending

3帧后
wantsQuit() 返回 true

GameOver

Ending

关于"附录内容"(おまけ)

书中提到附录里介绍了更复杂的实现技巧,并说:

如果你在读那部分时犯困了,说明你的潜意识认为它不是必须的。
没有它照样能做游戏。
作者这里是在提醒初学者不要过度设计。继承是一个工具,用得不好反而带来混乱。
真正重要的不是语言特性本身,而是在合适的地方合适地使用它

本章核心要点总结


要点 说明
用基类指针管理所有场景 Sequence* 统一接口,运行时多态决定行为
Parent 不 include 派生类头文件 降低编译依赖,写代码时更专注
场景切换由 next() 决定 返回 nullptr = 继续,返回新对象 = 切换
只有 main.cpp 知道具体类 负责创建初始场景,之后交给 Parent 托管
继承不是万能的 用得不好会制造混乱,不需要就不用

下一章预告

作者说下一章是"脱线(离题)"章节,内容是播放声音
只想知道"怎么调用播放声音的函数"的人,几页就能读完。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐