GameView、Node 与 zOrder(cocos2d-x 场景树与渲染层级)
Key Words:
#cocos2d-x #SceneGraph #Node #addChild #setPosition #zOrder #setLocalZOrder #GameView #PlayFieldView #CardView
文章目录
- [GameView、Node 与 zOrder(cocos2d-x 场景树与渲染层级)](#GameView、Node 与 zOrder(cocos2d-x 场景树与渲染层级))
-
- [1. 背景](#1. 背景)
- [2. 先把当前项目的主场景树画出来](#2. 先把当前项目的主场景树画出来)
- [3. `GameView` 这一层是怎么继续拆开的](#3.
GameView这一层是怎么继续拆开的) -
- [3.1 为什么 `CardView` 不挂进 `PlayFieldView`](#3.1 为什么
CardView不挂进PlayFieldView)
- [3.1 为什么 `CardView` 不挂进 `PlayFieldView`](#3.1 为什么
- [4. `zOrder` 在当前项目里到底控制什么](#4.
zOrder在当前项目里到底控制什么) -
- [4.1 静态 UI 层级](#4.1 静态 UI 层级)
- [4.2 动态卡牌层级](#4.2 动态卡牌层级)
- [5. 一个容易混淆但必须记住的点](#5. 一个容易混淆但必须记住的点)
- [6. `setPosition` 放到当前项目里,要先认清谁才是坐标根](#6.
setPosition放到当前项目里,要先认清谁才是坐标根) -
- [6.1 `Node::setPosition` 在这里影响什么](#6.1
Node::setPosition在这里影响什么) - [6.2 这也解释了 `CardView` 要直接挂在 `GameView`](#6.2 这也解释了
CardView要直接挂在GameView)
- [6.1 `Node::setPosition` 在这里影响什么](#6.1
- [7. 依赖关联链:`GameController` 怎样把模型状态翻成屏幕层级](#7. 依赖关联链:
GameController怎样把模型状态翻成屏幕层级) -
- [7.1 入口从哪里开始](#7.1 入口从哪里开始)
- [7.2 当前类为继续执行需要知道什么](#7.2 当前类为继续执行需要知道什么)
- [7.3 这个信息由谁提供](#7.3 这个信息由谁提供)
- [7.4 若条件不满足会发生什么](#7.4 若条件不满足会发生什么)
- [7.5 下一跳进入哪个类/函数](#7.5 下一跳进入哪个类/函数)
- [7.6 `Node::setLocalZOrder` 在这里怎么落地](#7.6
Node::setLocalZOrder在这里怎么落地)
- [8. 为什么运行时 zOrder 要分成 `1000 + y / 2500 / 3000 / 4000`](#8. 为什么运行时 zOrder 要分成
1000 + y / 2500 / 3000 / 4000) -
- [8.1 先别急着背数字,先看这四个桶分别在服务谁](#8.1 先别急着背数字,先看这四个桶分别在服务谁)
- [8.2 为什么主牌区用 `1000 + y`](#8.2 为什么主牌区用
1000 + y) - [8.3 为什么备用牌和顶部手牌反而用固定值](#8.3 为什么备用牌和顶部手牌反而用固定值)
- [8.4 为什么动画中的牌要临时抬到 `4000`](#8.4 为什么动画中的牌要临时抬到
4000) -
- [`MoveTo` + `setLocalZOrder` 在这里是怎么配合的](#
MoveTo+setLocalZOrder在这里是怎么配合的)
- [`MoveTo` + `setLocalZOrder` 在这里是怎么配合的](#
- [8.5 依赖关联链:为什么"遮挡判定"不是直接看 zOrder](#8.5 依赖关联链:为什么“遮挡判定”不是直接看 zOrder)
-
- 入口从哪里开始
- 当前类为继续执行需要知道什么
- 这个信息由谁提供
- 若条件不满足会发生什么
- 下一跳进入哪个类/函数
- [`Rect::intersectsRect` 在这里怎么落地](#
Rect::intersectsRect在这里怎么落地)
- [9. 小结](#9. 小结)
简介:结合纸牌项目真实代码,继续拆解
GameView、PlayFieldView、StackView与CardView的挂载关系,重点看统一坐标系、setPosition与控制器如何把模型状态刷新成屏幕层级。
1. 背景
模块 1 解决的是"程序怎么启动起来",模块 2 开始正式看"画面是怎么组织起来的"。
在 cocos2d-x 里,界面不是平铺代码往屏幕上扔,而是挂在一棵节点树上。
这个纸牌项目也一样,只不过它已经把这棵树拆得比较清楚:
GameScene负责入口场景GameView负责总视图容器PlayFieldView、StackView负责局部静态区域CardView负责动态牌面节点
这一模块要解决两个核心问题:
- 当前项目的场景树到底长什么样
zOrder到底控制了谁盖住谁
!NOTE
如果读到
setPosition、Touch::getLocation()、关卡 JSON 里的Position时,脑子里总在打架,建议先跳到补充阅读:
模块0:常见坐标体系(笛卡尔、屏幕、世界与局部坐标)那一篇先只讲坐标体系本身,不展开项目代码。先把概念补齐,再回来看模块 2 里的
GameView、CardView、setPosition和zOrder,阅读阻力会小很多。
2. 先把当前项目的主场景树画出来
代码来源:Classes/scenes/GameScene.cpp
cpp
_gameView = GameView::create();
addChild(_gameView, 1);
这说明整个游戏画面在场景树里的第一层关系其实很简单:
cpp
GameScene
└── GameView
也就是说,GameScene 并不直接管理牌区背景、状态栏、卡牌节点,它只把一个总视图 GameView 挂进来。
后面的 UI 组织工作,全部往 GameView 里面继续展开。
!NOTE
addChild先决定的是"父子关系",不是"绘制顺序"本身。只有当两个节点拥有同一个父节点时,zOrder 才能直接比较谁在前谁在后。
3. GameView 这一层是怎么继续拆开的
代码来源:Classes/views/GameView.cpp
cpp
_playFieldView = PlayFieldView::create();
addChild(_playFieldView, 1);
_stackView = StackView::create();
addChild(_stackView, 2);
addChild(statusPanel, 4);
这三次 addChild 已经把 GameView 的静态结构说得很清楚了:
cpp
GameScene
└── GameView
├── PlayFieldView (z = 1)
├── StackView (z = 2)
└── statusPanel (z = 4)
等关卡加载完后,GameView 还会继续把全部卡牌节点挂到自己下面:
cpp
addChild(cardView, 10);
所以更完整一点的结构其实是:
cpp
GameScene
└── GameView
├── PlayFieldView (z = 1)
├── StackView (z = 2)
├── statusPanel (z = 4)
└── CardView... (初始 z = 10,运行时会继续调整)
3.1 为什么 CardView 不挂进 PlayFieldView
这是当前项目一个很值得注意的设计。
直觉上看,主牌区的牌似乎应该挂进 PlayFieldView。
但项目没有这么做,而是把所有卡牌都直接挂在 GameView 下面。
这样做的好处是:
- 主牌区牌、备用牌、顶部手牌都能放到同一层兄弟节点体系里统一调 zOrder
- 动画移动时,不需要跨父节点搬运卡牌
- 控制器刷新位置和层级时,逻辑更集中
这说明 PlayFieldView 在当前项目里更偏"静态背景容器",而不是"所有主牌区牌的父节点"。
4. zOrder 在当前项目里到底控制什么
这一点在 模块一 也有提到,这次继续讲
先看静态 UI 的分层:
PlayFieldView:z = 1StackView:z = 2statusPanel:z = 4CardView:初始z = 10
这意味着从 GameView 这一层看,卡牌节点默认就会压在主牌区背景和底部区域之上。
但当前项目并没有停在"初始 z = 10"。
真正决定牌和牌之间前后关系的,是运行时控制器继续改写 zOrder:
代码来源:Classes/controllers/GameController.cpp
cpp
_gameView->setCardZOrder(cardId, 1000 + static_cast<int>(card->getCurrentPosition().y));
以及:
cpp
_gameView->setCardZOrder(cardId, 2500);
_gameView->setCardZOrder(cardId, 3000);
_gameView->setCardZOrder(cardId, 4000);
这表示当前项目的卡牌层级分成了两层理解:
4.1 静态 UI 层级
负责把背景区、底部区、状态栏这些大区域先摆好。
4.2 动态卡牌层级
负责让:
- 主牌区的不同牌互相遮挡
- 顶部手牌压过备用牌
- 动画中的牌临时升到最高层
这就是为什么模块 2 不只是学 addChild,还必须把 zOrder 一起看。
5. 一个容易混淆但必须记住的点
PlayFieldView 里面也有自己的内部层级:
代码来源:Classes/views/PlayFieldView.cpp
cpp
addChild(background, 0);
addChild(feltLines, 1);
addChild(headerBar, 2);
addChild(border, 3);
addChild(title, 4);
但这些 zOrder 只在 PlayFieldView 自己内部比较。
它们不会因为 title 是 z = 4,就压过 GameView 里 z = 10 的 CardView。
原因很简单:
title的父节点是PlayFieldViewCardView的父节点是GameView
真正先决定大层级顺序的,是父节点这一层:
PlayFieldView先作为GameView的z = 1子节点被访问- 然后才轮到更高层级的
CardView
所以一定要记住:
zOrder 先看兄弟关系,再看各自父节点所处的位置。
整个 GameScene 下来的根据 LocalZOrder 决定了一颗多叉树的逻辑结构, Render 渲染顺序是层序遍历决定了覆盖关系。
6. setPosition 放到当前项目里,要先认清谁才是坐标根
上轮把"谁挂在谁下面"理清了,这一轮继续往前走,会遇到一个更容易绕晕的问题:
PlayFieldView明明代表主牌区,为什么它自己没有被挪到y = 580CardView明明是主牌区里的牌,为什么它的位置却可以直接用运行态坐标
先看几段关键代码。
代码来源:Classes/views/GameView.cpp
cpp
setContentSize(cocos2d::Size(layout::kDesignWidth, layout::kDesignHeight));
_playFieldView = PlayFieldView::create();
addChild(_playFieldView, 1);
_stackView = StackView::create();
addChild(_stackView, 2);
statusPanel->setPosition(cocos2d::Vec2(
layout::kDesignWidth * 0.5f,
layout::kStackHeight - 56.0f));
代码来源:Classes/views/PlayFieldView.cpp
cpp
if (!Node::init()) {
return false;
}
background->setPosition(cocos2d::Vec2(0.0f, layout::kStackHeight));
headerBar->setPosition(cocos2d::Vec2(0.0f, layout::kDesignHeight - 128.0f));
title->setPosition(cocos2d::Vec2(26.0f, layout::kDesignHeight - 20.0f));
代码来源:Classes/views/StackView.cpp
cpp
if (!Node::init()) {
return false;
}
background->setPosition(cocos2d::Vec2::ZERO);
menu->setPosition(undoPos);
把这几段连起来看,当前项目真正拿来统一摆坐标的,不是 PlayFieldView,而是 GameView。
原因很直接:
GameView自己把尺寸设成了整页设计尺寸1080 x 2080PlayFieldView和StackView都只是Node::init(),没有给自己单独设置偏移- 真正发生位置变化的,是它们内部的背景、标题、按钮这些子节点
也就是说,当前项目更像这样:
cpp
GameView 坐标系(1080 x 2080)
├── StackView 自己在 (0, 0)
│ └── background 从 y = 0 开始画
├── PlayFieldView 自己也在 (0, 0)
│ └── background 从 y = 580 开始画
├── statusPanel 直接放在 y = 524
└── CardView 直接用运行态坐标摆放
这个结构有一个很关键的工程好处:
静态背景和动态卡牌共用同一套设计坐标。
所以控制器刷新卡牌时,不需要先算"相对 PlayFieldView 的局部坐标",而是可以直接把运行态坐标塞给 CardView。
代码来源:Classes/controllers/GameController.cpp
cpp
_gameView->setCardPosition(cardId, card->getCurrentPosition());
_gameView->setCardPosition(cardId, _gameView->getStackCardPosition());
_gameView->setCardPosition(cardId, _gameView->getTrayCardPosition());
6.1 Node::setPosition 在这里影响什么
常用 API:
setPosition(const cocos2d::Vec2&)setPosition(float x, float y)getPosition()
影响什么:
- 决定节点在父节点坐标系里的摆放位置
- 直接影响渲染位置、触摸判定位置、动画目标位置
当前项目里的开发范式:
- 全局布局常量先统一收口到
Classes/utils/LayoutConstants.h GameView对外暴露getTrayCardPosition()、getStackCardPosition()- 控制器不手写魔法值,而是通过
GameView和模型坐标刷新卡牌
代码来源:Classes/utils/LayoutConstants.h
cpp
inline cocos2d::Vec2 trayCardPosition()
{
return cocos2d::Vec2(360.0f, 280.0f);
}
inline cocos2d::Vec2 stackCardPosition()
{
return cocos2d::Vec2(760.0f, 280.0f);
}
小功能落地写法:
- 如果要把"回退按钮"再往左挪一点,改的是底部区控件位置
- 如果要让顶部手牌槽位整体右移,改的是
trayCardPosition() - 如果要让主牌区牌整体更靠上,改的是生成运行态坐标或关卡布局,而不是改
PlayFieldView父子关系
!NOTE
当前项目里
PlayFieldView更像"主牌区背景分组",不是"主牌区坐标原点"。这也是很多初学者第一次读这套代码时最容易看反的地方。
6.2 这也解释了 CardView 要直接挂在 GameView
如果 CardView 改成挂到 PlayFieldView 下面,马上会出现三类额外成本:
- 主牌区牌和底部区牌不再处于同一组兄弟节点,统一比较 zOrder 会变复杂
- 牌从主牌区飞到顶部手牌区时,需要处理跨父节点坐标转换
- 控制器里"按 zone 直接刷新位置"的写法会被拆成多套分支
所以当前项目的取舍很明确:
PlayFieldView、StackView负责静态区域绘制GameView负责做真正的动态卡牌容器
这个设计不算唯一答案,但放在这类"牌会跨区域移动"的项目里,确实更省事。
7. 依赖关联链:GameController 怎样把模型状态翻成屏幕层级
前面讲的都是"节点怎么挂"。
但真正让屏幕上每张牌各就各位的,不是 GameView::init(),而是控制器刷新流程。
代码来源:Classes/controllers/GameController.cpp
cpp
_gameView->buildCards(_gameModel);
refreshViewFromModel();
再往下看单张牌刷新:
cpp
if (card->getZone() == CZT_PLAYFIELD) {
_gameView->setCardPosition(cardId, card->getCurrentPosition());
_gameView->setCardZOrder(cardId, 1000 + static_cast<int>(card->getCurrentPosition().y));
return;
}
if (card->getZone() == CZT_STACK) {
_gameView->setCardPosition(cardId, _gameView->getStackCardPosition());
_gameView->setCardZOrder(cardId, 2500);
return;
}
if (card->getZone() == CZT_TRAY) {
_gameView->setCardPosition(cardId, _gameView->getTrayCardPosition());
_gameView->setCardZOrder(cardId, 3000);
return;
}
这一段很适合用"依赖关联链"来拆。
7.1 入口从哪里开始
入口在 loadLevelByIndex()。
它先把 GameModel 建好,再调用:
buildCards(_gameModel):先把所有CardView节点创建出来refreshViewFromModel():再根据当前业务状态刷新可见性、交互、位置和层级
也就是说,先有节点,再把节点摆到正确状态,不是边创建边把所有规则一次做完。
7.2 当前类为继续执行需要知道什么
refreshSingleCardView() 至少要知道四件事:
- 这张牌当前在哪个区域:
CZT_PLAYFIELD / CZT_STACK / CZT_TRAY - 这张牌当前坐标是什么:
card->getCurrentPosition() - 当前备用牌堆最上面是哪张:
nextStackCardId - 当前顶部手牌是哪张:
trayTopCardId
少任何一个条件,都没法决定"这张牌应该摆哪、显不显示、能不能点"。
7.3 这个信息由谁提供
GameModel提供卡牌区域、运行态坐标、顶部牌信息LayoutConstants提供固定槽位坐标GameView提供视图层封装接口,比如setCardPosition()、setCardZOrder()
这说明控制器并不是直接操作 CardView 成员变量,而是走了一层 GameView 包装。
这种写法的好处是:业务层只决定"应该怎样",视图层负责"具体怎么改节点"。
7.4 若条件不满足会发生什么
失败分支其实不少:
findCard(cardId)失败,直接return- 备用牌不是当前可抽那张,就隐藏并禁用交互
- 托盘区不是顶部那张,就隐藏并禁用交互
- 主牌区即使可见,也只有"未被覆盖且规则合法"的牌会开启交互
所以要把两个概念彻底分开:
- 能看见,不代表能点击
- 层级更高,也不代表当前合法
这也是当前项目里"渲染层级"和"业务规则"明确分层的体现。
7.5 下一跳进入哪个类/函数
控制器最终不会自己改 Node,而是继续跳到:
GameView::setCardPosition()GameView::setCardZOrder()
再由这两个接口落到 CardView:
代码来源:Classes/views/GameView.cpp
cpp
cardView->setPosition(position);
cardView->setLocalZOrder(zOrder);
到这里,模型状态才真正变成了屏幕上的"这张牌在哪、压住谁"。
7.6 Node::setLocalZOrder 在这里怎么落地
常用 API:
setLocalZOrder(int)getLocalZOrder()
影响什么:
- 控制同一父节点下兄弟节点的绘制前后顺序
- 间接影响遮挡关系,以及场景图优先级下的部分输入命中顺序
当前项目里的开发范式:
- 不让控制器直接拿
CardView*到处改 - 统一经由
GameView::setCardZOrder()下发层级 - 普通状态、顶部手牌、动画中卡牌分别给不同层级桶
小功能落地写法:
- 主牌区牌用
1000 + y,让更靠上的牌自然压住更靠下的牌 - 备用牌固定
2500 - 顶部手牌固定
3000 - 动画中的牌先抬到
4000,避免飞行动画被别的节点盖住
!Question\] 如果把所有 `CardView` 都改成挂到 `PlayFieldView` 下面,会不会更"语义正确"? 语义上更像,但工程上未必更划算。 因为当前项目的牌不是只待在主牌区不动,它们会进入托盘区、参与动画、和底部槽位共享统一层级。 在这种前提下,把动态牌统一挂在 `GameView` 下面,反而能减少坐标换算和跨父节点搬运。
8. 为什么运行时 zOrder 要分成 1000 + y / 2500 / 3000 / 4000
前面已经知道:真正控制卡牌前后关系的,不是初始 addChild(cardView, 10),而是运行时控制器重新分配层级。
代码来源:Classes/controllers/GameController.cpp
cpp
if (card->getZone() == CZT_PLAYFIELD) {
_gameView->setCardZOrder(cardId, 1000 + static_cast<int>(card->getCurrentPosition().y));
return;
}
if (card->getZone() == CZT_STACK) {
_gameView->setCardZOrder(cardId, 2500);
return;
}
if (card->getZone() == CZT_TRAY) {
_gameView->setCardZOrder(cardId, 3000);
return;
}
以及动画开始前:
cpp
_gameView->setCardZOrder(cardId, 4000);
这说明当前项目没有把所有牌都放进一条简单的 z = 1, 2, 3, 4... 链,而是人为划了四个"层级桶"。
8.1 先别急着背数字,先看这四个桶分别在服务谁
1000 + y:主牌区牌2500:备用牌堆当前可见那张3000:顶部手牌当前可见那张4000:正在移动或回退中的动画牌
这里可以做一个明确判断:
这些数字不是 cocos2d-x 的固定规则,而是当前项目自己设计出来的层级协议。
从代码组织上可以推断,这样做至少有两个目的:
- 先把静态 UI 的
1 / 2 / 4和动态卡牌层级彻底拉开 - 再把"主牌区常态 / 底部槽位 / 顶部手牌 / 动画瞬时最高层"拆成更稳定的几档
!NOTE
这里"是项目设计出来的协议"是根据代码分桶方式做的工程推断,不是引擎文档里的硬性规定。
8.2 为什么主牌区用 1000 + y
这是当前项目里最值得记的一组写法。
代码来源:Classes/controllers/GameController.cpp
cpp
_gameView->setCardZOrder(cardId, 1000 + static_cast<int>(card->getCurrentPosition().y));
它解决的是:主牌区内部,哪张牌应该压住哪张牌。
因为当前项目的主牌区牌是有上下错位摆放的,所以只要同属主牌区,通常就希望:
y更高的牌,显示在更前面y更低的牌,显示在更后面
把 y 直接叠到 zOrder 上,就能得到一个很自然的排序结果。
比如当前关卡配置里的主牌区 y,实际就是三层:
代码来源:Resources/configs/levels/level_1.json
cpp
"Position": {"x": 250, "y": 1000}
"Position": {"x": 300, "y": 800}
"Position": {"x": 350, "y": 600}
而主牌区牌进入运行态前,还会统一加一次 kStackHeight:
代码来源:Classes/services/GameModelFromLevelGenerator.cpp
cpp
if (zone == CZT_PLAYFIELD) {
initialPosition.y += layout::kStackHeight;
}
也就是当前项目里,主牌区常见运行态 y 会变成:
cpp
600 + 580 = 1180
800 + 580 = 1380
1000 + 580 = 1580
对应的 zOrder 就是:
cpp
2180 / 2380 / 2580
这就很符合纸牌叠放时的直觉:
- 更上面的牌,z 更大
- 更下面的牌,z 更小
同时,1000 这个基数又把主牌区牌整体抬到了静态 UI 之上,避免和 PlayFieldView、StackView、状态栏的低位 zOrder 混在一起。
8.3 为什么备用牌和顶部手牌反而用固定值
因为这两类牌的需求和主牌区不一样。
当前项目里:
- 备用牌堆只显示"当前可抽的那一张"
- 顶部手牌区只显示"当前顶部那一张"
也就是说,它们并不需要像主牌区那样,在一个区域内部继续比较多张牌的前后关系。
代码来源:Classes/controllers/GameController.cpp
cpp
const bool isCurrentStackCard = (cardId == nextStackCardId);
_gameView->setCardVisible(cardId, isCurrentStackCard);
_gameView->setCardInteraction(cardId, isCurrentStackCard);
_gameView->setCardZOrder(cardId, 2500);
以及:
cpp
const bool isTrayTop = (cardId == trayTopCardId);
_gameView->setCardVisible(cardId, isTrayTop);
_gameView->setCardInteraction(cardId, false);
_gameView->setCardZOrder(cardId, 3000);
因此固定值就够了。
从当前关卡布局也能看出,这个设计是够用的:
- 主牌区常见 z 落在
2180 / 2380 / 2580 - 备用牌固定
2500 - 顶部手牌固定
3000
这意味着:
- 顶部手牌会稳定压在主牌区牌之上
- 备用牌不需要和顶部手牌争层级,也不需要在自己内部再排序
这里也能看出一个工程思路:
不是所有区域都需要"连续排序",很多时候固定层级桶更省心。
8.4 为什么动画中的牌要临时抬到 4000
这是为了避免"移动过程被别的牌盖住"。
代码来源:Classes/controllers/GameController.cpp
cpp
_gameView->setCardVisible(cardId, true);
_gameView->setCardInteraction(cardId, false);
_gameView->setCardZOrder(cardId, 4000);
_gameView->moveCard(cardId, _gameView->getTrayCardPosition(), layout::kCardMoveDuration, [this, undoRecord]() {
_undoManager.pushRecord(undoRecord);
_isAnimating = false;
refreshViewFromModel();
});
回退时也是同样套路:
cpp
_gameView->setCardZOrder(movedCardId, 4000);
_gameView->moveCard(movedCardId, undoRecord.getSourcePosition(), layout::kCardMoveDuration, [this, undoRecord]() {
applyUndoRecord(undoRecord);
_isAnimating = false;
refreshViewFromModel();
});
这说明 4000 不是常态层级,而是一个临时动画保护层。
MoveTo + setLocalZOrder 在这里是怎么配合的
常用 API:
MoveTo::create(duration, targetPosition)Node::setLocalZOrder(int)Sequence::create(...)
影响什么:
MoveTo决定卡牌飞往哪里setLocalZOrder决定飞行过程中它会不会被别的节点压住
当前项目里的开发范式:
- 动画前先禁交互
- 动画前先抬高 zOrder
- 动画回调里再统一
refreshViewFromModel()
小功能落地写法:
- 如果后面要加"自动发牌飞行动画",也应该先给飞行中的牌一个临时最高层,再在回调里恢复常态分桶
8.5 依赖关联链:为什么"遮挡判定"不是直接看 zOrder
这一点非常容易误会。
很多初学者会以为:谁的 zOrder 大,谁就一定是"可点的最上层牌"。
但当前项目并不是这么做的。
代码来源:Classes/controllers/GameController.cpp
cpp
std::sort(playFieldCards.begin(), playFieldCards.end(), [](const CardModel* left, const CardModel* right) {
return left->getCurrentPosition().y > right->getCurrentPosition().y;
});
for (const CardModel* card : playFieldCards) {
const cocos2d::Vec2& pos = card->getCurrentPosition();
const cocos2d::Rect cardRect(pos.x - half.x, pos.y - half.y, cardSize.width, cardSize.height);
bool isCovered = false;
for (const cocos2d::Rect& higherRect : higherRects) {
if (cardRect.intersectsRect(higherRect)) {
isCovered = true;
break;
}
}
if (!isCovered) {
_operablePlayFieldCardIds.insert(card->getCardId());
}
}
再往后点:
代码来源:Classes/controllers/PlayFieldController.cpp
cpp
if (!GameRuleService::canMatch(*clickedCard, *trayTopCard)) {
outError = u8"匹配失败:点数需要和手牌顶部差 1";
return false;
}
这条链路说明,当前项目把"能不能点"拆成了两层:
- 几何遮挡层:这张牌有没有被更高位置的牌矩形覆盖
- 规则合法层:它和当前顶部手牌能不能匹配,这就是游戏规则
也就是说:
- zOrder 负责画面前后
intersectsRect负责几何覆盖判断canMatch负责业务规则合法性
这三件事是协作关系,不是同一件事。
入口从哪里开始
入口在 refreshViewFromModel() 里的:
rebuildOperablePlayFieldCardIds()refreshSingleCardView()
当前类为继续执行需要知道什么
控制器需要知道:
- 主牌区每张牌当前坐标
- 卡牌尺寸
- 哪些更高的牌已经占住了矩形区域
- 当前顶部手牌是哪张
这个信息由谁提供
GameModel提供主牌区牌和顶部手牌CardResConfig::getCardSize()提供矩形尺寸GameRuleService提供匹配规则
若条件不满足会发生什么
- 被覆盖,不能进入
_operablePlayFieldCardIds - 没被覆盖但规则不匹配,点击后仍然失败
- 正在动画中,整轮点击直接被
_isAnimating拦掉
下一跳进入哪个类/函数
通过遮挡筛选后,refreshSingleCardView() 才会:
cpp
_gameView->setCardInteraction(cardId, isOperable);
后续真正点击时,再进入:
cpp
PlayFieldController::handleCardClick(...)
所以更准确的说法是:
当前项目里"谁画在上面"和"谁当前能点击"并不是同一套算法。
Rect::intersectsRect 在这里怎么落地
常用 API:
Rect::intersectsRect(const Rect&)
影响什么:
- 决定两张牌在几何上是否互相遮住
- 直接影响主牌区可操作牌筛选
当前项目里的开发范式:
- 先按
y从高到低遍历主牌区牌 - 再把已经认为"更高"的牌矩形存进
higherRects - 后来的牌只要和这些矩形相交,就判为被覆盖
小功能落地写法:
- 如果以后要做"只允许点击最上层未遮挡卡牌"的别的小游戏,这种"排序 + 矩形相交"也是很常见的实现骨架
!Question\] 为什么当前项目里不能只靠 `zOrder` 判断一张主牌区卡牌能不能点击? 因为 `zOrder` 只解决"谁画在前面",不解决"业务上是否合法"。当前项目至少还叠了两层条件: * 这张牌有没有被更高位置的牌矩形覆盖 * 它和当前顶部手牌是否满足点数差 1 的规则 所以"渲染前后顺序"和"交互合法性"必须拆开看。
9. 小结
GameView才是当前项目真正统一坐标的根节点PlayFieldView和StackView更像静态背景分组,而不是动态卡牌父节点setPosition落到当前项目时,本质上是在同一套设计坐标里摆静态 UI 和动态牌refreshViewFromModel()负责把模型状态翻译成卡牌的可见性、交互、位置和 zOrder1000 + y / 2500 / 3000 / 4000是当前项目自己设计出来的四档运行时层级桶- 主牌区遮挡判定靠"排序 + 矩形相交",点击合法性还要再过规则判断,不是只看 zOrder
到这里,模块 2 的主线已经比较完整了:先看 GameScene -> GameView 的挂载结构,再看静态容器与动态卡牌怎样共处同一棵树,最后落到运行时 zOrder 分桶和交互判定的职责边界。继续往后读模块 3,再看关卡配置怎样变成运行态模型,会更顺。