cocos2d-x棋牌项目-模块1:从 main 到首帧渲染(cocos2d-x 启动链路)
从 main 到首帧渲染(cocos2d-x 启动链路)
Key Words:
#cocos2d-x #AppDelegate #Application #Director #Scene #GLViewImpl #设计分辨率 #启动流程
文章目录
- 从 main 到首帧渲染(cocos2d-x 启动链路)
-
- 0. 项目说明
- 1. 背景
- 2. 启动入口(main)
- 3. AppDelegate 初始化流程
- 4. 首场景创建与接管
0. 项目说明
本文分析基于本人的 chessNcard_game 纸牌项目,项目使用 cocos2d-x 3.17.2 和 C++ 实现。
仓库地址:
- Gitee:chess-ncard-game
- GitHub:chessNcard_game
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或场景切换逻辑里调用。
小功能落地写法:
- 需要切场景时继续用同一个
Director调runWithScene或replaceScene。
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 里很常见的对象创建模式:
newinit()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 Scene 和 View 到底是什么关系
这是 cocos2d-x 初学阶段最容易混淆的点之一。
在这个项目里,可以先用一句话记:
Scene 是整棵场景树的最外层运行容器,View 是场景里的可见节点容器。
[!NOTE]
这里说的View,指的是项目里的GameView、PlayFieldView、StackView这种业务命名。
它不是AppDelegate.cpp里的GLView。GLView更接近“窗口/渲染表面”,GameView更接近“场景里的界面容器”。
对应到当前项目:
GameScene继承cocos2d::SceneGameView继承cocos2d::LayerPlayFieldView/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 不会膨胀成一个又管切场景、又管布局、又管点击规则的大类。
为什么 GameView 用 Layer,而不是也继承 Scene
因为它需要作为 GameScene 的子节点存在。Scene 是给 Director::runWithScene() 接管的顶层对象,通常不会再把一个 Scene 当成另一个 Scene 的普通子视图来组织业务。
而 Layer 很适合做这种“场景内的大容器”:
- 能加入场景树
- 能继续挂子节点
- 适合承载一整块 UI 和交互对象
在当前项目里怎么区分它们
可以用这个判断方式:
- 负责“整个游戏画面入口”的,是
GameScene - 负责“这页画面里具体摆什么”的,是
GameView - 负责“某个局部区域长什么样”的,是
PlayFieldView、StackView
所以这次如果只记一个结论,就记这个:
Scene 决定当前运行的是哪一页;View 决定这一页里面具体怎么摆。
4.5 首帧卡牌是怎么从关卡数据变成节点的
这一段是模块 1 的最后一个关键拼图。
前面已经知道:
AppDelegate负责把首场景跑起来GameScene负责把GameView和GameController组装起来
接下来要看的是:
首帧里那些牌,到底什么时候真正出现在屏幕上。
先看主调用链
代码来源: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->LevelConfigLevelConfig->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.jsonResources/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);
CardView 在 registerTouchListener() 里给自己挂了一个单点触摸监听器。
在 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可以保持很轻- 控制器可以统一处理规则
- 后面改点击规则时,不需要把业务判断塞进每张牌节点里
所以这一轮最值得记住的结论是:
当前项目的输入流不是“点了牌就直接改数据”,而是“节点捕获输入 -> 逐层回调上抛 -> 控制器统一处理”。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)