从 main 到首帧渲染(cocos2d-x 启动链路)


Key Words:

#cocos2d-x #AppDelegate #Application #Director #Scene #GLViewImpl #设计分辨率 #启动流程

文章目录

0. 项目说明

本文分析基于本人的 chessNcard_game 纸牌项目,项目使用 cocos2d-x 3.17.2C++ 实现。

仓库地址:

1. 背景

这一模块先不急着看发牌规则、点击判定、回退系统,先把程序是怎么启动起来的看明白。

很多初学者第一次看 cocos2d-x 项目都会疑惑两件事:

  • main.cpp 为什么只有两行关键代码,看起来不像“游戏入口”。
  • 游戏窗口、坐标系、首个场景、首帧节点,到底是谁创建出来的。

这个纸牌项目的启动链路很清晰,顺序就是:

main
-> Application::run()
-> AppDelegate::applicationDidFinishLaunching()
-> Director::runWithScene(scene)
-> GameScene::init()
-> GameView::init()
-> GameController::init()

先把这条链路建立起来,后面再看控制器、规则和动画,思路会稳定很多。

2. 启动入口(main)

代码来源:proj.ios_mac/mac/main.cpp

int main(int argc, char *argv[])
{
    AppDelegate app;
    return Application::getInstance()->run();
}

这里最容易误判的一点是:main 不是业务初始化中心,而是把程序交给 cocos2d-x 引擎。

2.1 AppDelegate app; 做了什么

AppDelegate 继承自 cocos2d::Application,这意味着项目把“应用级生命周期钩子”交给了 AppDelegate 来实现。

也就是说,后面真正重要的初始化逻辑,不写在 main 里,而是写在:

  • initGLContextAttrs()
  • applicationDidFinishLaunching()
  • applicationDidEnterBackground()
  • applicationWillEnterForeground()

2.2 为什么不是自己写 while 主循环

这里调用的是:

Application::getInstance()->run();

这行代码的意义是:启动 cocos2d-x 管理的应用主循环
渲染帧驱动、事件派发、场景调度、前后台切换,这些都不需要项目手写一个 while(true) 来完成。

这也是引擎项目和普通控制台程序最大的差别之一:
项目代码负责“注册和接线”,引擎负责“驱动循环”。

[!NOTE]
面试里如果被问“cocos2d-x 程序从哪里开始跑”,不要只答 main。更准确的说法是:main 构造 AppDelegate,真正把程序推进到引擎生命周期的是 Application::run()

3. AppDelegate 初始化流程

代码来源:Classes/AppDelegate.cpp

bool AppDelegate::applicationDidFinishLaunching() {
    auto director = Director::getInstance();
    auto glview = director->getOpenGLView();
    if(!glview) {
        glview = GLViewImpl::createWithRect("Test", cocos2d::Rect(0, 0, designResolutionSize.width, designResolutionSize.height), 0.5f);
        director->setOpenGLView(glview);
    }

    director->setDisplayStats(false);
    director->setAnimationInterval(1.0f / 60);

    glview->setDesignResolutionSize(
        designResolutionSize.width,
        designResolutionSize.height,
        ResolutionPolicy::FIXED_WIDTH);

	//我们自己的qipai游戏的Scene界面
    auto scene = qipai::GameScene::createScene();
    
    director->runWithScene(scene);

    return true;
}

这一段是整个项目真正的初始化主干,可以拆成 4 个动作。

3.1 取出 Director

Director 可以理解成 cocos2d-x 的运行调度中心。
后面的场景切换、帧率设置、GLView 绑定,都是通过它完成。

常用 API:

Director::getInstance()

影响什么:

  • 获取全局导演对象,后续渲染和场景调度都从这里接入。

开发范式:

  • 通常在 AppDelegate 或场景切换逻辑里调用。

小功能落地写法:

  • 需要切场景时继续用同一个 DirectorrunWithScenereplaceScene
Director::setAnimationInterval(1.0f / 60)

影响什么:

  • 控制逻辑帧推进频率,当前项目目标是 60 FPS。

开发范式:

  • 一般只在启动时设一次。

小功能落地写法:

  • 如果需要调试慢动作,可以临时把间隔改大。

3.2 创建并绑定 GLView

如果 Director 还没有窗口视图,就创建一个 GLViewImpl 并绑定进去。

这一步的本质不是“画游戏内容”,而是先把渲染载体准备好。
没有 GLView,后续 Scene 就没有地方显示。

GLViewImpl::createWithRect(...)

影响什么:

  • 创建桌面端窗口,给当前项目一个可见渲染区域。

开发范式:

  • Win/Mac/Linux 常见写法是 createWithRect,移动端通常用 create()

小功能落地写法:

  • 调整窗口初始大小时,一般从这里下手。

3.3 设置设计分辨率(Resolution Size)

项目里固定的是:

static cocos2d::Size designResolutionSize = cocos2d::Size(1080, 2080);

随后调用:

glview->setDesignResolutionSize(
    designResolutionSize.width,
    designResolutionSize.height,
    ResolutionPolicy::FIXED_WIDTH);

这一组配置和 Classes/utils/LayoutConstants.h 是对齐的:

constexpr float kDesignWidth = 1080.0f;
constexpr float kDesignHeight = 2080.0f;

说明这个项目从启动层到 UI 布局层,都在使用同一套设计坐标。

GLView::setDesignResolutionSize(..., FIXED_WIDTH)

影响什么:

  • 决定游戏内部使用哪套逻辑坐标,并指定缩放策略。

开发范式:

  • 启动时统一设置,业务层再按设计坐标摆控件和卡牌。

小功能落地写法:

  • 当前项目里 GameView::init() 直接把自身 contentSize 设为 1080 x 2080,说明后续布局就是围绕这套坐标设计的。

这里先记住一个结论:
当前项目优先固定宽度,所以横向设计坐标更稳定;不同实际屏幕的高度部分,由适配策略去处理。

当前项目里 1080 x 2080 不是随便写的

这组值不是只在 AppDelegate.cpp 出现一次,而是整套布局常量的基准:

代码来源:Classes/utils/LayoutConstants.h

constexpr float kDesignWidth = 1080.0f;
constexpr float kDesignHeight = 2080.0f;
constexpr float kPlayFieldHeight = 1500.0f;
constexpr float kStackHeight = 580.0f;

可以看到:

  • 全屏设计高度:2080
  • 底部区高度:580
  • 主牌区高度:1500

两块正好加起来:

580 + 1500 = 2080

这说明当前项目的界面就是按“上方主牌区 + 下方底部区”两大块来切的。

FIXED_WIDTH 放到当前项目里怎么理解

先不要上来背引擎术语,直接记项目里的效果:

  • 横向布局优先围绕 1080 这套宽度来写
  • x 坐标的设计基准更稳定
  • y 坐标虽然也按 2080 设计,但设备长宽比变化时,垂直可视范围更值得关注

所以当前项目很多关键位置都直接写死在这套设计坐标里,比如:

代码来源:Classes/utils/LayoutConstants.h

inline cocos2d::Vec2 trayCardPosition()
{
    return cocos2d::Vec2(360.0f, 280.0f);
}

inline cocos2d::Vec2 stackCardPosition()
{
    return cocos2d::Vec2(760.0f, 280.0f);
}

这两个点明显都落在底部区,因为它们的 y = 280,而底部区总高度是 580

GameView 为什么还能继续用 1080 x 2080

代码来源:Classes/views/GameView.cpp

setContentSize(cocos2d::Size(layout::kDesignWidth, layout::kDesignHeight));

这表示 GameView 把自己当成“整页游戏画面的主内容容器”,后续子节点都围绕这套设计尺寸来摆。

再看主牌区背景:

代码来源:Classes/views/PlayFieldView.cpp

background->setContentSize(cocos2d::Size(layout::kDesignWidth, layout::kPlayFieldHeight));
background->setPosition(cocos2d::Vec2(0.0f, layout::kStackHeight));

这两行非常关键,意思是:

  • 主牌区宽度就是整页宽度 1080
  • 主牌区高度是 1500
  • 它不是从 y = 0 开始画,而是从 y = 580 开始画

也就是主牌区整体被放到了底部区的上面。

为什么关卡里的主牌区坐标要 +kStackHeight

这是当前项目坐标体系里最值得记住的一个设计。

关卡配置文件里,主牌区牌的位置是这样写的:

代码来源:Resources/configs/levels/level_1.json

"Position": {"x": 250, "y": 1000}

这个 y = 1000 不是“整页全局坐标”,而是主牌区局部坐标
真正变成运行态坐标时,会统一上移一个底部区高度:

代码来源:Classes/services/GameModelFromLevelGenerator.cpp

cocos2d::Vec2 initialPosition = config.position;
if (zone == CZT_PLAYFIELD) {
    initialPosition.y += layout::kStackHeight;
}

因为:

  • 主牌区不是从全屏底部开始,而是从 y = 580 开始
  • 所以主牌区内部的牌,如果配置写的是 (250, 1000)
  • 转成全局运行态后,就会变成 (250, 1580)

也就是:

1000 + 580 = 1580

这就是前面学习路线里提到的“为什么主牌区坐标会做一次 +kStackHeight 偏移”。

这套坐标分层到底解决了什么问题

它解决的是“配置层不要关心整页 UI 拼装细节”。

如果关卡 JSON 直接写全局坐标,那么:

  • 关卡配置会和 UI 布局强耦合
  • 底部区高度一改,所有关卡坐标都要跟着改

现在这样分层之后:

  • 关卡配置只关心“主牌区内部怎么摆”
  • UI 布局层只关心“主牌区整体放在哪”
  • 运行时生成器负责把两套坐标拼起来

这就是一个很典型的工程取舍:
配置坐标写局部空间,运行时再映射到场景全局空间。

3.4 创建并运行首场景

最后两行最关键:

auto scene = qipai::GameScene::createScene();
director->runWithScene(scene);

这表示引擎开始接管当前场景树。
从这一刻开始,后续的节点渲染、输入分发、动作播放,都会围绕 GameScene 这一棵树展开。

4. 首场景创建与接管

代码来源:Classes/scenes/GameScene.cpp

bool GameScene::init()
{
    if (!Scene::init()) {
        return false;
    }

    _gameView = GameView::create();
    addChild(_gameView, 1);

    _gameController = std::unique_ptr<GameController>(new (std::nothrow) GameController());
    if (!_gameController->init(_gameView, "configs/levels/level_1.json")) {
        return false;
    }

    return true;
}

这里能看出当前棋牌游戏项目不是“场景里直接写完所有逻辑”,而是做了基本分层:

  • GameScene 负责组装
  • GameView 负责总视图
  • GameController 负责初始化业务控制流程

4.1 GameScene::createScene() 为什么又调 create()

createScene() 只是一个更语义化的入口:

cocos2d::Scene* GameScene::createScene()
{
    return GameScene::create();
}

真正创建对象、调用 init()、接入 autorelease 机制的是 CREATE_FUNC(GameScene) 展开的 create()

这是一种 cocos2d-x 里很常见的对象创建模式:

  • new
  • init()
  • autorelease()

后面在 GameView::create() 里也能看到完全相同的套路。

4.2 为什么先建 GameView,再建 GameController

顺序不能反。因为控制器初始化时马上就要拿到视图对象并做三件事:

  • 绑定点击回调
  • 扫描关卡配置
  • 构建首轮卡牌并刷新界面

对应代码在 Classes/controllers/GameController.cpp

_gameView = gameView;
_gameView->setOnCardClickCallback([this](int cardId) {
    handleCardClick(cardId);
});
_gameView->setOnUndoClickCallback([this]() {
    handleUndoClick();
});

return loadLevelByIndex(_currentLevelIndex);

loadLevelByIndex() 里又会继续做:

_gameView->buildCards(_gameModel);
refreshViewFromModel();

这说明首帧不是 GameScene 自己画出来的,而是 GameScene 组装好 View 和 Controller 后,由 Controller 驱动 View 把牌面搭出来。

4.3 GameView::init() 为什么算首帧准备的一部分

因为它先把静态 UI 容器建好了:

  • 主牌区 PlayFieldView
  • 底部区 StackView
  • 状态栏 statusPanel

代码来源:Classes/views/GameView.cpp

setContentSize(cocos2d::Size(layout::kDesignWidth, layout::kDesignHeight));

_playFieldView = PlayFieldView::create();
addChild(_playFieldView, 1);

_stackView = StackView::create();
addChild(_stackView, 2);

这一步的意义是:
先把舞台搭好,再让控制器把运行时卡牌数据放进来。

4.4 SceneView 到底是什么关系

这是 cocos2d-x 初学阶段最容易混淆的点之一。
在这个项目里,可以先用一句话记:

Scene 是整棵场景树的最外层运行容器,View 是场景里的可见节点容器。

[!NOTE]
这里说的 View,指的是项目里的 GameViewPlayFieldViewStackView 这种业务命名。
它不是 AppDelegate.cpp 里的 GLViewGLView 更接近“窗口/渲染表面”,GameView 更接近“场景里的界面容器”。

对应到当前项目:

  • GameScene 继承 cocos2d::Scene
  • GameView 继承 cocos2d::Layer
  • PlayFieldView / StackView 继承 cocos2d::Node

代码来源:Classes/scenes/GameScene.h

class GameScene : public cocos2d::Scene {

代码来源:Classes/views/GameView.h

class GameView : public cocos2d::Layer {

代码来源:Classes/views/PlayFieldView.h

class PlayFieldView : public cocos2d::Node {
先理解成“舞台”和“舞台里的画面层”
  • Scene 更像一整页正在运行的游戏画面
  • View 更像这页画面里的某一块可视区域或某一组节点

所以关系不是“二选一”,而是“包含关系”:

GameScene
└── GameView
    ├── PlayFieldView
    ├── StackView
    └── CardView...

项目里的实际组装代码就是:

代码来源:Classes/scenes/GameScene.cpp

_gameView = GameView::create();
addChild(_gameView, 1);

这行代码已经把关系说透了:
GameView 不是和 GameScene 平级,而是 GameScene 的子节点。

为什么项目里不直接把所有节点都塞进 GameScene

因为 Scene 的职责偏“场景入口与接管”,而 View 的职责偏“界面组织与显示管理”。

当前项目里:

  • GameScene 负责创建和组装
  • GameView 负责统一管理可见 UI 和卡牌节点
  • GameController 负责业务逻辑和状态刷新

这样做的好处是,Scene 不会膨胀成一个又管切场景、又管布局、又管点击规则的大类。

为什么 GameViewLayer,而不是也继承 Scene

因为它需要作为 GameScene 的子节点存在。
Scene 是给 Director::runWithScene() 接管的顶层对象,通常不会再把一个 Scene 当成另一个 Scene 的普通子视图来组织业务。

Layer 很适合做这种“场景内的大容器”:

  • 能加入场景树
  • 能继续挂子节点
  • 适合承载一整块 UI 和交互对象
在当前项目里怎么区分它们

可以用这个判断方式:

  • 负责“整个游戏画面入口”的,是 GameScene
  • 负责“这页画面里具体摆什么”的,是 GameView
  • 负责“某个局部区域长什么样”的,是 PlayFieldViewStackView

所以这次如果只记一个结论,就记这个:

Scene 决定当前运行的是哪一页;View 决定这一页里面具体怎么摆。

4.5 首帧卡牌是怎么从关卡数据变成节点的

这一段是模块 1 的最后一个关键拼图。
前面已经知道:

  • AppDelegate 负责把首场景跑起来
  • GameScene 负责把 GameViewGameController 组装起来

接下来要看的是:
首帧里那些牌,到底什么时候真正出现在屏幕上。

先看主调用链

代码来源:Classes/scenes/GameScene.cpp

_gameController = std::unique_ptr<GameController>(new (std::nothrow) GameController());
if (!_gameController->init(_gameView, "configs/levels/level_1.json")) {
    return false;
}

也就是说,GameScene 创建完 GameView 后,马上把它交给 GameController 初始化。
真正把“关卡数据 -> 屏幕节点”串起来的,是控制器。

第一步:控制器先把关卡加载成 GameModel

代码来源:Classes/controllers/GameController.cpp

const std::string& levelPath = _levelConfigPaths[levelIndex];
LevelConfig levelConfig;
if (!LevelConfigLoader::loadLevelConfig(levelPath, levelConfig, &loadError)) {
    return false;
}

_gameModel = GameModelFromLevelGenerator::generateGameModel(levelConfig);

这一步先做两次转换:

  • level_1.json -> LevelConfig
  • LevelConfig -> GameModel

也就是说,屏幕上还没出现卡牌节点之前,项目先得到的是一份运行时数据模型

第二步:GameView 根据模型批量创建 CardView

代码来源:Classes/controllers/GameController.cpp

_gameView->buildCards(_gameModel);
refreshViewFromModel();

这两行要连在一起看。
第一行不是“把界面完全刷新完”,而是先把节点对象批量建出来

代码来源:Classes/views/GameView.cpp

const std::vector<int> allCardIds = gameModel.getAllCardIds();
for (int cardId : allCardIds) {
    const CardModel* cardModel = gameModel.findCard(cardId);
    CardView* cardView = CardView::create(*cardModel);
    addChild(cardView, 10);
    _cardViews[cardId] = cardView;
}

这里说明一件很重要的事:
首帧时,项目会先为所有卡牌都创建对应的 CardView 节点,而不是只给当前可见牌创建节点。

第三步:单张 CardView 在创建时做了什么

代码来源:Classes/views/CardView.cpp

_cardId = cardModel.getCardId();
setAnchorPoint(cocos2d::Vec2(0.5f, 0.5f));

cocos2d::Sprite* background = cocos2d::Sprite::create(CardResConfig::getCardBackgroundPath());
setContentSize(cardSize);
addChild(background, 1);

CardSpriteComposerService::composeFace(this, cardModel.getFace(), cardModel.getSuit());
setPosition(cardModel.getCurrentPosition());

registerTouchListener();

这一步会完成单牌节点的基础搭建:

  • 记录卡牌 ID
  • 创建底图
  • 拼装点数和花色
  • 设置初始位置
  • 注册点击监听

可以把它理解成:
CardView::create() 负责把“牌长出来”,但还没有决定它此刻能不能点、该不该显示、应该压在谁上面。

第四步:refreshViewFromModel() 再统一同步状态

代码来源:Classes/controllers/GameController.cpp

const int trayTopCardId = _gameModel.getTrayTopCardId();
const int nextStackCardId = _gameModel.peekNextStackCardId();
rebuildOperablePlayFieldCardIds();

for (int cardId : cardIds) {
    refreshSingleCardView(cardId, trayTopCardId, nextStackCardId);
}

这里才是“首帧最终落位”的关键。
它会根据模型状态,逐张卡牌同步:

  • 可见还是隐藏
  • 能不能点击
  • 应该摆在哪
  • 层级压在谁上面

例如:

if (card->getZone() == CZT_STACK) {
    const bool isCurrentStackCard = (cardId == nextStackCardId);
    _gameView->setCardVisible(cardId, isCurrentStackCard);
    _gameView->setCardInteraction(cardId, isCurrentStackCard);
    _gameView->setCardPosition(cardId, _gameView->getStackCardPosition());
    _gameView->setCardZOrder(cardId, 2500);
}

这说明堆牌区虽然可能有很多张牌节点已经建好了,但首帧只会显示当前可抽的那一张。

为什么要分成“先建节点,再同步状态”两步

这是一个很典型、也很实用的工程做法。

如果把“创建节点”和“决定状态”揉在一起,后面做这些操作会更难维护:

  • 动画移动
  • 回退恢复
  • 显隐切换
  • 层级调整

现在这样分两步后:

  • buildCards() 负责节点生命周期
  • refreshViewFromModel() 负责节点状态同步

这会让后面的点击、动画、回退都能复用同一套刷新逻辑。

所以这一轮最值得记住的结论是:

首帧卡牌生成不是一步到位,而是“模型先行,节点批量创建,最后按模型统一刷新状态”。

4.6 为什么关卡路径不写 Resources/ 也能找到文件

这个问题表面上看是在问这行代码:

代码来源:Classes/controllers/GameController.cpp

const std::string candidate = cocos2d::StringUtils::format("configs/levels/level_%d.json", i);

但其实 StringUtils::format() 只是在拼字符串。
真正决定“去哪里找文件”的,不是这行代码,而是 cocos2d::FileUtils 的搜索路径机制。

先说结论

当前项目写:

"configs/levels/level_1.json"

而不是:

"Resources/configs/levels/level_1.json"

是因为 cocos2d-x 默认会把“资源根目录”加入搜索路径。
项目传给 isFileExist()getStringFromFile() 的,其实是相对于资源根目录的相对路径

FileUtils 初始化时就把默认资源根目录塞进去了

代码来源:cocos2d/cocos/platform/CCFileUtils.cpp

bool FileUtils::init()
{
    _searchPathArray.push_back(_defaultResRootPath);
    _searchResolutionsOrderArray.push_back("");
    return true;
}

这段代码说明:FileUtils 启动时会先把 _defaultResRootPath 放进搜索路径数组。
所以后面传入 configs/levels/level_1.json 时,不会直接拿这个裸字符串去查文件系统,而是会拼到默认资源根目录下面查。

不同平台的“默认资源根目录”实现不一样

Windows 和 Linux 比较直接,就是显式指向可执行文件旁边的 Resources/ 目录。

代码来源:cocos2d/cocos/platform/win32/CCFileUtils-win32.cpp

_defaultResRootPath = s_resourcePath + "Resources/";

代码来源:cocos2d/cocos/platform/linux/CCFileUtils-linux.cpp

_defaultResRootPath = appPath.substr(0, appPath.find_last_of('/'));
_defaultResRootPath += "/Resources/";

Apple 平台不是手动拼 "Resources/",而是通过 NSBundle 去查主应用包里的资源目录。

代码来源:cocos2d/cocos/platform/apple/CCFileUtils-apple.mm

NSString* fullpath = [pimpl_->getBundle() pathForResource:[NSString stringWithUTF8String:file.c_str()]
                                                   ofType:nil
                                              inDirectory:[NSString stringWithUTF8String:path.c_str()]];

也就是说,在 macOS / iOS 上,相对路径最终会落到 .app 包的资源目录里查。

当前项目为什么恰好能这样用

因为构建脚本已经把项目的 Resources 目录声明成应用资源了。

代码来源:CMakeLists.txt

set(GAME_RES_FOLDER
    "${CMAKE_CURRENT_SOURCE_DIR}/Resources"
    )
if(APPLE OR WINDOWS)
    cocos_mark_multi_resources(common_res_files RES_TO "Resources" FOLDERS ${GAME_RES_FOLDER})
endif()

这意味着项目里的:

  • Resources/configs/levels/level_1.json
  • Resources/res/cards/card_general.png

在运行时都会被放进应用资源根目录下。

以当前 macOS 构建产物为例,实际文件位置就是:

build-cmake-x86-vscode/bin/QipaiGame/Debug/QipaiGame.app/Contents/Resources/configs/levels/level_1.json

所以代码里只需要写:

"configs/levels/level_1.json"

而不需要再手动写一层 Resources/

如果手动写成 Resources/configs/... 会怎样

大概率就会多套一层路径,变成去找:

[资源根目录]/Resources/configs/levels/level_1.json

这通常是错的,因为资源根目录本身就已经是 Resources 这一层了。

所以这里最值得记住的结论是:

Resources 不是业务代码里手写出来的路径前缀,而是构建系统和 FileUtils 默认处理掉的资源根目录。

4.7 点击事件是怎样从 CardView 上抛到 GameController

模块 1 到这里,启动链路其实还差最后一个收尾点:
首帧出来之后,点击一张牌,事件是怎么一路传到控制器的。

这一段要重点看“回调绑定关系”,而不是只看触摸 API 本身。

先看卡牌点击的完整链路

当前项目的链路是:

Touch
-> CardView
-> GameView
-> GameController

也就是说:

  • CardView 负责捕获点击
  • GameView 负责把子节点点击继续往上转发
  • GameController 负责真正处理业务逻辑
第一步:CardView 自己监听触摸

代码来源:Classes/views/CardView.cpp

_touchListener = cocos2d::EventListenerTouchOneByOne::create();
_touchListener->setSwallowTouches(true);

CardViewregisterTouchListener() 里给自己挂了一个单点触摸监听器。
onTouchBegan 里,它先做两件事:

  • 这张牌当前是否允许交互
  • 触摸点是否落在自己的包围盒内

代码来源:Classes/views/CardView.cpp

if (!_interactionEnabled || !isVisible()) {
    return false;
}

const cocos2d::Vec2 worldPoint = touch->getLocation();
const bool touched = getBoundingBox().containsPoint(worldPoint);

只有命中并且允许交互,事件才继续。

第二步:CardView 不处理业务,只上抛 cardId

代码来源:Classes/views/CardView.cpp

if (_onClickCallback != nullptr && _interactionEnabled) {
    _onClickCallback(_cardId);
}

这里很关键:
CardView 点击后没有自己判断“能不能匹配”“要不要移到手牌区”,它只把 _cardId 往上交。

这说明当前项目的 View 层职责很克制:

  • 负责输入捕获
  • 负责轻量点击反馈动画
  • 不负责游戏规则
第三步:GameView 给每张牌挂统一转发回调

代码来源:Classes/views/GameView.cpp

cardView->setOnClickCallback([this](int clickedCardId) {
    if (_onCardClickCallback != nullptr) {
        _onCardClickCallback(clickedCardId);
    }
});

这段代码出现在 buildCards() 里。
也就是说,每一张 CardView 创建出来时,GameView 都给它挂了同一种“继续往上转发”的回调。

所以 GameView 在这里扮演的角色不是“判断规则”,而是总视图事件汇聚层

第四步:GameController 在初始化时接走这个回调

代码来源:Classes/controllers/GameController.cpp

_gameView->setOnCardClickCallback([this](int cardId) {
    handleCardClick(cardId);
});

到这里,点击事件才真正进入控制器。
后面的规则判断、动画触发、回退记录,都是从 handleCardClick(cardId) 开始。

这就是为什么前面一直强调:

View 负责“把事件送上来”,Controller 负责“决定事件意味着什么”。

回退按钮也是完全同样的上抛思路

底部回退按钮不是 CardView,但设计方法完全一样。

代码来源:Classes/views/StackView.cpp

cocos2d::MenuItemLabel* undoItem = cocos2d::MenuItemLabel::create(undoLabel, [this](cocos2d::Ref* sender) {
    if (_undoCallback != nullptr) {
        _undoCallback();
    }
});

然后 GameView 接走它:

代码来源:Classes/views/GameView.cpp

_stackView->setUndoCallback([this]() {
    if (_onUndoClickCallback != nullptr) {
        _onUndoClickCallback();
    }
});

最后控制器再接走:

代码来源:Classes/controllers/GameController.cpp

_gameView->setOnUndoClickCallback([this]() {
    handleUndoClick();
});

所以回退链路其实是:

MenuItemLabel
-> StackView
-> GameView
-> GameController
为什么这种“逐层上抛”结构很重要

因为它把三种职责拆开了:

  • 输入捕获:View 层
  • 事件汇聚:总视图层
  • 业务决策:Controller 层

这样做的好处是:

  • CardView 可以保持很轻
  • 控制器可以统一处理规则
  • 后面改点击规则时,不需要把业务判断塞进每张牌节点里

所以这一轮最值得记住的结论是:

当前项目的输入流不是“点了牌就直接改数据”,而是“节点捕获输入 -> 逐层回调上抛 -> 控制器统一处理”。

Logo

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

更多推荐