cocos2d-x棋牌项目-模块2:GameView、Node 与 zOrder

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)
    • [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)
    • [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 在这里是怎么配合的)
      • [8.5 依赖关联链:为什么"遮挡判定"不是直接看 zOrder](#8.5 依赖关联链:为什么“遮挡判定”不是直接看 zOrder)
    • [9. 小结](#9. 小结)

简介:结合纸牌项目真实代码,继续拆解 GameViewPlayFieldViewStackViewCardView 的挂载关系,重点看统一坐标系、setPosition 与控制器如何把模型状态刷新成屏幕层级。

1. 背景

模块 1 解决的是"程序怎么启动起来",模块 2 开始正式看"画面是怎么组织起来的"。

在 cocos2d-x 里,界面不是平铺代码往屏幕上扔,而是挂在一棵节点树上。

这个纸牌项目也一样,只不过它已经把这棵树拆得比较清楚:

  • GameScene 负责入口场景
  • GameView 负责总视图容器
  • PlayFieldViewStackView 负责局部静态区域
  • CardView 负责动态牌面节点

这一模块要解决两个核心问题:

  • 当前项目的场景树到底长什么样
  • zOrder 到底控制了谁盖住谁

!NOTE

如果读到 setPositionTouch::getLocation()、关卡 JSON 里的 Position 时,脑子里总在打架,建议先跳到补充阅读:
模块0:常见坐标体系(笛卡尔、屏幕、世界与局部坐标)

那一篇先只讲坐标体系本身,不展开项目代码。先把概念补齐,再回来看模块 2 里的 GameViewCardViewsetPositionzOrder,阅读阻力会小很多。

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 的分层:

  • PlayFieldViewz = 1
  • StackViewz = 2
  • statusPanelz = 4
  • CardView:初始 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 自己内部比较。

它们不会因为 titlez = 4,就压过 GameViewz = 10CardView

原因很简单:

  • title 的父节点是 PlayFieldView
  • CardView 的父节点是 GameView

真正先决定大层级顺序的,是父节点这一层:

  • PlayFieldView 先作为 GameViewz = 1 子节点被访问
  • 然后才轮到更高层级的 CardView

所以一定要记住:

zOrder 先看兄弟关系,再看各自父节点所处的位置。

整个 GameScene 下来的根据 LocalZOrder 决定了一颗多叉树的逻辑结构, Render 渲染顺序是层序遍历决定了覆盖关系。

6. setPosition 放到当前项目里,要先认清谁才是坐标根

上轮把"谁挂在谁下面"理清了,这一轮继续往前走,会遇到一个更容易绕晕的问题:

  • PlayFieldView 明明代表主牌区,为什么它自己没有被挪到 y = 580
  • CardView 明明是主牌区里的牌,为什么它的位置却可以直接用运行态坐标

先看几段关键代码。

代码来源: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 2080
  • PlayFieldViewStackView 都只是 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 直接刷新位置"的写法会被拆成多套分支

所以当前项目的取舍很明确:

  • PlayFieldViewStackView 负责静态区域绘制
  • 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 之上,避免和 PlayFieldViewStackView、状态栏的低位 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;
}

这条链路说明,当前项目把"能不能点"拆成了两层:

  1. 几何遮挡层:这张牌有没有被更高位置的牌矩形覆盖
  2. 规则合法层:它和当前顶部手牌能不能匹配,这就是游戏规则

也就是说:

  • 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 才是当前项目真正统一坐标的根节点
  • PlayFieldViewStackView 更像静态背景分组,而不是动态卡牌父节点
  • setPosition 落到当前项目时,本质上是在同一套设计坐标里摆静态 UI 和动态牌
  • refreshViewFromModel() 负责把模型状态翻译成卡牌的可见性、交互、位置和 zOrder
  • 1000 + y / 2500 / 3000 / 4000 是当前项目自己设计出来的四档运行时层级桶
  • 主牌区遮挡判定靠"排序 + 矩形相交",点击合法性还要再过规则判断,不是只看 zOrder

到这里,模块 2 的主线已经比较完整了:先看 GameScene -> GameView 的挂载结构,再看静态容器与动态卡牌怎样共处同一棵树,最后落到运行时 zOrder 分桶和交互判定的职责边界。继续往后读模块 3,再看关卡配置怎样变成运行态模型,会更顺。

相关推荐
mxwin4 小时前
Unity Shader 渲染队列 (Render Queue):控制 Geometry、Transparent、Overlay 等队列确保半透明物体渲染正确
unity·游戏引擎
mxwin5 小时前
Unity Shader Alpha Test 与 Alpha Blend:透明度测试与混合的实现及排序问题
unity·游戏引擎
FairGuard手游加固7 小时前
FairGuard支持HybridCLR热更DLL加密
游戏·unity·游戏引擎
海海不瞌睡(捏捏王子)7 小时前
Unity GUI优化
unity·游戏引擎
mascon10 小时前
unity mcp 使用
unity·游戏引擎
心前阳光10 小时前
Unity之语音提问,语音答复
unity·游戏引擎
mxwin12 小时前
Unity Shader UV 坐标与纹理平铺Tiling & Offset 深度解析
unity·游戏引擎·shader·uv
七夜zippoe1 天前
OpenClaw 内置工具详解
unity·ai·游戏引擎·openclaw·内置工具
mxwin1 天前
Unity Shader 细节贴图技术在不增加显存开销的前提下,有效提升近距离纹理细节的渲染质量
unity·游戏引擎·贴图