1. 项目概述从麻将游戏到核心坐标系的跨越最近在社区里看到不少朋友在用Cocos Creator做麻将游戏这让我想起了几年前自己刚接触引擎时被Vec2和Node这两个基础概念“折磨”的经历。当时想实现一个简单的牌桌布局让麻将牌能根据玩家位置动态排列结果在坐标计算和节点操作上卡了很久。很多新手包括当时的我容易陷入一个误区一上来就想搞复杂的游戏逻辑却忽略了引擎最底层的基石——坐标系和节点系统。这就像盖房子不打地基楼盖得越高后期维护和修改的成本就越大甚至可能因为一个坐标计算错误导致整个牌桌的渲染错乱。Vec2二维向量和Node节点就是Cocos Creator这座大厦里最核心的“钢筋”和“砖块”。无论你是做麻将牌的移动、吃碰杠动画还是UI按钮的定位、场景的切换几乎所有的可视化和交互逻辑都绕不开它们。Vec2定义了游戏世界里一切事物的位置、移动方向和距离而Node则是承载这些事物的容器和操作单元。理解它们不仅仅是记住几个API更是要建立起一套空间思维和节点树的管理意识。很多从cocoscreator assetsmanager源码里看到的精妙设计或者解决the requested module node:util这类环境配置问题后最终都要落到如何高效、正确地操作Vec2和Node上来。这篇内容我就结合自己踩过的坑和项目实战把这两个核心对象的属性与方法掰开揉碎了讲清楚让你在开发中无论是简单的布局还是复杂的动画都能心中有数手到擒来。2. 核心基石一深入理解Vec2二维向量2.1 Vec2的本质不只是x和y两个数字很多初学者会把Vec2简单理解为一个存储了x和y坐标的对象比如(100, 200)。这个理解没错但太表面了。在Cocos Creator乃至整个图形编程领域Vec2的本质是一个有方向的量。它既可以表示一个静态的点Position也可以表示一个动态的向量Vector比如速度、加速度、力或者从一个点到另一个点的方向。举个例子在麻将游戏中表示点一张麻将牌在牌桌上的位置new Vec2(150, 300)。表示向量玩家打出一张牌牌飞向牌池的方向和速度可以用一个Vec2来表示例如new Vec2(5, -2)表示每帧向右移动5像素向上移动2像素注意Cocos Creator的Y轴正方向是向上的。这种双重身份使得Vec2的运算充满了几何意义。当你对两个表示点的Vec2进行减法运算Vec2.subtract(pointA, pointB)时你得到的是一个从pointB指向pointA的向量。这个向量包含了方向和距离信息这对于计算麻将牌移动的轨迹、判断碰杠牌是否靠近等逻辑至关重要。注意Cocos Creator的坐标系原点(0, 0)默认在屏幕左下角X轴向右为正Y轴向上为正。这与一些其他引擎如Unity或2D绘图库可能不同务必在脑海中有清晰的空间图像。2.2 必须掌握的Vec2核心方法与实践知道是什么之后关键是怎么用。下面这些方法是你必须熟练掌握的我会配上麻将游戏中的实际场景来解释。1. 创建与赋值// 1. 直接创建 let pos1 new Vec2(100, 200); // 2. 使用静态方法创建常用于复制或基于已有向量计算 let pos2 Vec2.ZERO; // (0, 0) let pos3 Vec2.ONE; // (1, 1) let pos4 Vec2.clone(pos1); // 深拷贝pos1修改pos4不影响pos1 // 实战初始化一张新麻将牌的位置为牌墙的起始点 let cardStartPos new Vec2(50, 400);2. 向量运算核心中的核心这部分是逻辑计算的基础务必理解其几何意义。let v1 new Vec2(3, 4); let v2 new Vec2(1, 2); // 加法向量叠加。比如牌当前位置 移动速度向量 下一帧位置 let addResult new Vec2(); Vec2.add(addResult, v1, v2); // addResult (4, 6) // 减法获取方向向量。比如目标牌位置 - 当前牌位置 移动方向向量 let subResult new Vec2(); Vec2.subtract(subResult, v1, v2); // subResult (2, 2) // 乘法标量缩放向量长度。比如速度向量 * 时间系数 实际位移 let scaledVec new Vec2(); Vec2.multiplyScalar(scaledVec, v1, 2); // scaledVec (6, 8) // 点积Dot Product结果是一个标量。可用于判断两个向量的方向关系。 // v1·v2 |v1|*|v2|*cosθ。0表示夹角小于90度方向基本一致0表示大于90度0表示垂直。 let dotValue Vec2.dot(v1, v2); // 3*1 4*2 11 // 叉积Cross Product在2D中结果是一个标量其绝对值表示两向量围成平行四边形的面积。 // 符号可用于判断旋转方向顺时针/逆时针在判断点在线段哪一侧时很有用。 let crossValue Vec2.cross(v1, v2); // 3*2 - 4*1 2实战场景点积判断玩家手牌中是否有牌与打出的牌“接近”用于提示碰、杠。我们可以将每张手牌的位置与打出牌的位置做向量减法然后计算该向量与某个基准方向如向右的点积结合距离判断可以高效筛选出特定方向的牌。3. 向量属性与常用工具方法let v new Vec3(3, 4); // 长度模长向量的绝对值大小即点到原点的距离。 let length v.length(); // 5 (勾股定理: sqrt(3*3 4*4)) // 平方长度避免开方运算性能更好常用于比较距离因为开方函数sqrt较耗性能。 let lengthSqr v.lengthSqr(); // 25 // 归一化Normalize获取方向单位向量长度为1。这是计算方向时的标准操作。 let normalized new Vec2(); v.normalize(normalized); // normalized ≈ (0.6, 0.8) // 角度计算与X轴正方向的夹角弧度。 let angle v.signAngle(Vec2.RIGHT); // 计算v与(1,0)的夹角 // 旋转将向量旋转一定弧度。 let rotated new Vec2(); Vec2.rotate(rotated, v, Math.PI / 4); // 旋转45度 // 线性插值Lerp实现平滑移动动画的关键。 let start new Vec2(0, 0); let end new Vec2(100, 100); let currentPos new Vec2(); // 在start和end之间根据比例t0到1插值。t0.5时currentPos(50,50) Vec2.lerp(currentPos, start, end, 0.5);实战场景Lerp实现麻将牌从手牌区移动到出牌区的平滑动画。每一帧根据耗时计算一个比例t然后用Vec2.lerp更新牌的位置动画就会非常流畅。2.3 性能优化与常见坑点避免频繁创建新对象在update等每帧执行的函数中new Vec2()会产生大量垃圾对象触发GC垃圾回收导致卡顿。正确的做法是复用对象。// 错误示范在update中 update(dt) { this.node.position new Vec2(this.speedX * dt, this.speedY * dt); // 每帧都new性能杀手 } // 正确示范 private _tempVec2: Vec2 new Vec2(); // 声明一个成员变量复用 update(dt) { this._tempVec2.x this.speedX * dt; this._tempVec2.y this.speedY * dt; this.node.position this._tempVec2; // 赋值的是引用不会创建新对象 }理解“原地修改”与“返回新值”Vec2的实例方法如v.normalize()通常会修改自身并返回this而静态方法如Vec2.add(out, a, b)通常需要你提供一个out对象来存储结果。务必查看API文档区分清楚。let a new Vec2(3, 4); let b a.normalize(); // a自身被修改为(0.6,0.8)b和a是同一个对象 // 如果不想改变a应先克隆let b Vec2.clone(a).normalize();浮点数精度问题比较两个Vec2是否相等时不要直接使用或因为浮点数计算有精度误差。应该使用Vec2.equals或比较长度平方是否小于一个极小值如1e-6。if (Vec2.equals(posA, posB)) { ... } // 或 if (Vec2.subtract(new Vec2(), posA, posB).lengthSqr() 1e-6) { ... }3. 核心基石二Node节点全解析3.1 Node是什么场景图的原子与容器如果说Vec2是描述空间的尺子那么Node就是构建这个空间的乐高积木。在Cocos Creator中一切可见、可交互的东西都必须挂载在一个Node上。Node本身是空白的它通过添加不同的组件Component来获得功能比如Sprite组件让它显示图片Label组件让它显示文字Button组件让它可点击。Node以树形结构组织形成场景图。每个场景Scene都是一个根Node下面的所有UI元素、精灵、特效都是它的子节点。这种结构带来了两大核心好处坐标系统继承子节点的坐标position是相对于父节点的。移动父节点所有子节点会跟着一起移动。这在麻将游戏中非常有用比如你可以创建一个“手牌区”父节点所有手牌作为它的子节点这样只需移动“手牌区”就能整体移动所有牌。渲染顺序管理默认情况下节点树中靠后的节点同级中index值大的会渲染在靠前节点的上方。你可以通过修改setSiblingIndex来动态调整渲染层级。3.2 Node的核心属性控制节点的状态Node的属性是你控制其表现的主要手段理解每个属性的含义和关联至关重要。属性名类型说明实战应用场景positionVec3局部位置。相对于父节点坐标系的位置。设置麻将牌在牌桌上的精确坐标。worldPositionVec3世界位置。在整个场景根坐标系下的绝对位置。只读通过getWorldPosition获取。判断两张牌在全局场景中是否足够近以触发碰杠逻辑。anglenumber旋转角度。绕Z轴旋转的角度度。修改它等价于修改rotation的Z分量。让一张“碰”倒下的牌旋转90度。rotationQuat旋转。四元数表示的旋转适用于3D。在2D中用angle更直观。-scaleVec3缩放。节点的缩放系数。(1,1,1)为原始大小。实现牌被选中时略微放大的效果。width / heightnumber节点包围盒大小。受节点自身内容如Sprite大小和scale影响。用于碰撞检测的粗略判断或计算布局。activeboolean激活状态。为false时节点及其所有组件都会停止更新和渲染。牌被打出后将其从手牌节点中active设为false而非立即销毁。parentNode父节点。可以动态改变实现节点在树间的移动。将打出的牌从“手牌区”节点移动到“牌池”节点下。childrenNode[]子节点数组。遍历一个玩家所有的手牌节点。uuidstring全局唯一标识符。在编辑器中和运行时都保持唯一。用于在游戏存档中持久化记录特定的节点如某张特殊牌。重点解析position与worldPosition这是最容易混淆的点。position是相对的它决定了节点在父容器中的摆放。worldPosition是绝对的是节点在整个游戏世界中的真实坐标。它们通过父子关系关联节点.worldPosition 父节点.worldPosition 父节点坐标系下的节点.position当你需要做全局性的计算如物理碰撞、距离判断时一定要使用worldPosition。而调整局部布局时使用position更方便。3.3 Node的核心方法动态操作节点树属性定义了状态方法则用于动态改变状态和结构。1. 节点树的增删改查// 假设 this.node 是当前脚本挂载的节点 // 【增】添加子节点 let newNode new cc.Node(NewCard); this.node.addChild(newNode); // 添加到末尾 this.node.insertChild(newNode, 0); // 插入到指定索引最前面 // 【删】移除节点 newNode.removeFromParent(); // 从父节点移除但节点本身还在内存中 newNode.destroy(); // 立即销毁节点及其所有组件和子节点 // 注意通常建议先 removeFromParent再在下一帧或合适时机 destroy。 // 【查】查找节点 // 通过路径查找常用 let handArea this.node.getChildByName(HandArea); // 通过UUID查找 let specificNode cc.director.getScene().getChildByUuid(someUuid); // 查找所有符合特定条件的子节点例如所有标签为“Card”的 let cardNodes this.node.children.filter(child child.name.includes(Card));2. 坐标转换这是Node API中最实用也最容易出错的部分之一。// 将节点局部坐标系下的一个点转换到世界坐标系 let localPoint new Vec2(10, 10); let worldPoint new Vec2(); this.node.convertToWorldSpaceAR(localPoint, worldPoint); // AR表示锚点参考 // 将世界坐标系下的一个点转换到节点的局部坐标系 let pointInNodeSpace new Vec2(); this.node.convertToNodeSpaceAR(worldPoint, pointInNodeSpace); // 实战判断触摸点是否在牌上 input.on(Input.EventType.TOUCH_START, (event) { let touchPos event.getLocation(); // 获取屏幕触摸点世界坐标 let touchPosInNode this.cardNode.convertToNodeSpaceAR(touchPos); let cardSize this.cardNode.getContentSize(); // 判断触摸点是否在牌的矩形范围内简单矩形检测 if (Math.abs(touchPosInNode.x) cardSize.width/2 Math.abs(touchPosInNode.y) cardSize.height/2) { console.log(牌被点击了); } });3. 动作系统Action虽然现在更推荐使用性能更好的Tween系统但Action系统依然简单易用适合序列动画。// 让节点在2秒内移动到(200,300)的位置 let moveTo cc.moveTo(2, cc.v2(200, 300)); // 让节点在1秒内旋转360度 let rotateBy cc.rotateBy(1, 360); // 顺序执行先移动再旋转 let sequence cc.sequence(moveTo, rotateBy); // 同时执行移动和旋转同时进行 let spawn cc.spawn(moveTo, rotateBy); this.node.runAction(sequence); // 停止所有动作 this.node.stopAllActions();3.4 节点生命周期与事件响应每个Node都有其生命周期由引擎自动管理。作为开发者我们主要通过组件脚本中的生命周期回调函数来介入。export class CardComponent extends Component { // 当节点首次激活或所在场景加载时调用。用于初始化变量获取节点引用。 onLoad() { this.sprite this.getComponent(Sprite); this.originalPos this.node.position.clone(); } // 每帧渲染前调用。用于执行游戏逻辑如更新位置、状态判断。 update(dt: number) { // dt是上一帧到当前帧的时间间隔秒 if (this.isMoving) { this.node.position.x this.speed * dt; // 基于时间的移动避免帧率影响速度 } } // 当该组件或它的节点被销毁时调用。用于清理资源取消事件监听。 onDestroy() { input.off(Input.EventType.TOUCH_START, this.onTouch, this); } }事件监听是交互的基础。Node本身不直接发射事件但可以通过EventTarget或挂载的组件如Button来监听。// 监听节点上的触摸事件需要节点有可点击区域如Sprite this.node.on(Node.EventType.TOUCH_START, this.onTouchStart, this); // 在onDestroy中记得取消监听防止内存泄漏 this.node.off(Node.EventType.TOUCH_START, this.onTouchStart, this);4. Vec2与Node的协同实战一个麻将牌拖动示例理论讲得再多不如一个实战案例来得清晰。我们来实现一个麻将牌可以被手指拖动松开后根据位置自动吸附到最近牌槽的功能。这个例子会综合运用前面讲到的所有知识点。4.1 场景与节点设置创建一个场景包含一个背景节点。一个名为CardSlotContainer的空节点作为所有牌槽的父节点。在其下创建多个CardSlot节点可以是简单的Sprite代表可以放牌的位置。一个名为Card的节点挂载Sprite组件显示牌面并挂载我们即将编写的CardDrag脚本。4.2 脚本编写CardDrag.tsimport { _decorator, Component, Node, Vec2, Vec3, Input, EventTouch, UITransform } from cc; const { ccclass, property } _decorator; ccclass(CardDrag) export class CardDrag extends Component { // 在属性检查器中关联牌槽容器的节点 property(Node) slotContainer: Node null!; // 私有变量 private _startTouchPos: Vec2 new Vec2(); // 触摸起始点世界坐标 private _startNodePos: Vec3 new Vec3(); // 节点初始位置世界坐标 private _isDragging: boolean false; private _slotsWorldPos: Vec3[] []; // 存储所有牌槽的世界坐标 onLoad() { // 1. 预计算所有牌槽的世界坐标避免在拖动过程中重复计算 this.updateSlotPositions(); // 2. 监听触摸事件 this.node.on(Input.EventType.TOUCH_START, this.onTouchStart, this); this.node.on(Input.EventType.TOUCH_MOVE, this.onTouchMove, this); this.node.on(Input.EventType.TOUCH_END, this.onTouchEnd, this); this.node.on(Input.EventType.TOUCH_CANCEL, this.onTouchEnd, this); } // 更新牌槽位置数组 updateSlotPositions() { this._slotsWorldPos []; if (this.slotContainer) { for (let child of this.slotContainer.children) { let worldPos new Vec3(); child.getWorldPosition(worldPos); this._slotsWorldPos.push(worldPos); } } } onTouchStart(event: EventTouch) { this._isDragging true; // 获取触摸起点的世界坐标 event.getLocation(this._startTouchPos); // 获取节点当前的世界坐标 this.node.getWorldPosition(this._startNodePos); } onTouchMove(event: EventTouch) { if (!this._isDragging) return; // 获取当前触摸点的世界坐标 let currentTouchPos new Vec2(); event.getLocation(currentTouchPos); // 计算从触摸起点到当前点的偏移量世界坐标差 let delta new Vec2(); Vec2.subtract(delta, currentTouchPos, this._startTouchPos); // 将世界坐标的偏移量应用到节点的世界坐标上 let newWorldPos new Vec3(); Vec3.add(newWorldPos, this._startNodePos, new Vec3(delta.x, delta.y, 0)); // 将新的世界坐标设置回节点 this.node.setWorldPosition(newWorldPos); } onTouchEnd(event: EventTouch) { if (!this._isDragging) return; this._isDragging false; // 获取牌当前的世界坐标 let cardWorldPos new Vec3(); this.node.getWorldPosition(cardWorldPos); // 寻找最近的牌槽 let nearestSlotIndex -1; let minDistanceSqr Number.MAX_VALUE; const SNAP_DISTANCE 100; // 吸附的最大距离像素 for (let i 0; i this._slotsWorldPos.length; i) { let slotPos this._slotsWorldPos[i]; // 计算距离的平方避免开方运算 let dx cardWorldPos.x - slotPos.x; let dy cardWorldPos.y - slotPos.y; let distSqr dx * dx dy * dy; if (distSqr minDistanceSqr distSqr SNAP_DISTANCE * SNAP_DISTANCE) { minDistanceSqr distSqr; nearestSlotIndex i; } } // 如果找到符合条件的牌槽则吸附过去 if (nearestSlotIndex 0) { let targetSlotWorldPos this._slotsWorldPos[nearestSlotIndex]; // 使用Tween实现平滑的吸附动画 cc.tween(this.node) .to(0.2, { worldPosition: targetSlotWorldPos }, { easing: sineOut }) .start(); } else { // 如果没有找到则回到起始位置可选 cc.tween(this.node) .to(0.2, { worldPosition: this._startNodePos }) .start(); } } onDestroy() { // 务必移除事件监听 this.node.off(Input.EventType.TOUCH_START, this.onTouchStart, this); this.node.off(Input.EventType.TOUCH_MOVE, this.onTouchMove, this); this.node.off(Input.EventType.TOUCH_END, this.onTouchEnd, this); this.node.off(Input.EventType.TOUCH_CANCEL, this.onTouchEnd, this); } }4.3 关键点解析与优化建议坐标转换的运用在onTouchMove中我们直接使用世界坐标进行计算。因为触摸事件getLocation获取的是屏幕空间坐标可视为世界坐标而节点移动setWorldPosition也使用世界坐标这样避免了在拖动过程中频繁进行坐标转换逻辑更清晰性能也更好。性能优化在updateSlotPositions中我们预先计算并缓存了所有牌槽的世界坐标。在频繁触发的onTouchEnd中我们使用距离平方distSqr进行比较避免了耗时的开方运算Math.sqrt。这是游戏开发中非常经典的优化技巧。使用Tween替代Action在吸附动画中我们使用了cc.tween。Tween系统比旧的Action系统更灵活、更强大支持链式调用、丰富的缓动函数并且与引擎的属性系统结合更好是当前更推荐的动画实现方式。事件管理在onDestroy中移除事件监听是防止内存泄漏的好习惯。当节点被销毁时如果事件监听没有被移除回调函数可能仍然被引用导致相关对象无法被垃圾回收。5. 进阶技巧与深度问题排查掌握了基础之后我们来看看一些更深入的应用场景和那些容易让你调试到半夜的“坑”。5.1 矩阵变换理解position、rotation、scale的背后当你修改一个节点的position、rotation、scale时引擎内部实际上是在操作一个4x4的变换矩阵Node.worldMatrix。这个矩阵决定了节点如何从局部空间转换到世界空间。对于复杂的嵌套节点比如一个旋转了的父节点下的子节点直接计算最终的世界坐标会非常复杂而矩阵乘法帮我们搞定了一切。大多数时候你不需要直接操作矩阵但了解其存在有助于理解一些现象节点的worldPosition、worldRotation、worldScale都是从worldMatrix中提取出来的。性能提示如果一个节点及其所有父节点的变换属性在本帧都没有改变那么它的worldMatrix会被缓存复用不会重新计算。因此避免在update中频繁修改祖先节点的属性。5.2 节点遍历与查询优化当场景中节点很多时比如一桌麻将的所有牌和UI高效的节点查询非常重要。避免在update中频繁使用getChildByName或find这些方法是线性查找复杂度O(n)。如果必须频繁查找应在onLoad中缓存节点引用。// 不好 update(dt) { let scoreLabel this.node.getChildByName(ScoreLabel).getComponent(Label); scoreLabel.string this.score.toString(); } // 好 private scoreLabel: Label null!; onLoad() { this.scoreLabel this.node.getChildByName(ScoreLabel).getComponent(Label); } update(dt) { this.scoreLabel.string this.score.toString(); }使用uuid进行精确查找如果需要在不同脚本间传递或查找特定节点使用在编辑器中确定的uuid是最可靠的方式通过cc.director.getScene().getChildByUuid(uuid)获取。5.3 常见诡异问题与调试技巧节点位置“飘忽不定”或不对检查锚点Anchor是节点上定位的基准点默认为(0.5, 0.5)即中心。如果你以为position是节点的左上角那位置肯定不对。在编辑器里看清楚Sprite等组件的锚点设置。分清position和worldPosition这是最最常见的错误。在控制台打印这两个值对比一下。检查父节点你的节点是否被一个你不知道的父节点缩放或旋转了使用node.getWorldScale()和node.getWorldRotation()检查最终的世界变换。触摸事件不响应检查节点大小触摸事件依赖于节点的UITransform组件提供的尺寸。如果节点没有渲染组件如Sprite或手动添加的UITransform它可能没有有效大小导致无法触发触摸事件。可以在编辑器中添加一个UITransform组件并设置Content Size。检查节点层级是否被上层节点如一个全屏透明的按钮遮挡了检查节点的setSiblingIndex或父节点的children顺序。检查active和opacity节点或其父节点是否被设置为active: false或者透明度opacity为0这些都会影响事件。内存泄漏与节点管理destroy()不等于立即消失destroy()会标记节点为销毁但实际的销毁操作可能会延迟到帧结束。在destroy()之后立即访问节点属性会出错。移除事件监听在组件的onDestroy中务必移除该组件注册的所有事件监听包括系统事件和自定义事件。动态创建节点的管理对于在运行时大量创建和销毁的节点如特效、飘字考虑使用对象池。Cocos Creator提供了cc.NodePool可以极大地减少频繁创建销毁带来的性能开销和内存碎片。使用调试工具Cocos Creator编辑器调试在场景编辑器中可以实时查看每个节点的世界坐标、局部坐标、旋转角度等信息。浏览器开发者工具在浏览器中运行游戏使用控制台输出节点的属性使用调试器断点跟踪代码执行流程。绘制调试信息在复杂的位置计算中可以在update里用Graphics组件动态绘制线条或点可视化你的向量和坐标这是排查空间逻辑问题的大杀器。理解Vec2和Node就像是掌握了Cocos Creator开发中的“空间语法”和“结构语法”。它们看似简单但组合起来能构建出无限复杂的游戏世界。从麻将牌的精准摆放到角色技能的复杂位移底层逻辑都离不开对这些基础概念的扎实运用。多写多调试多思考“为什么这个坐标不对”你会发现自己对引擎的理解越来越深开发效率也会越来越高。