Cocos Creator 3.6 2D碰撞监听避坑指南:Sensor原理与实战

1. 项目概述:为什么我们需要这份避坑指南?

在Cocos Creator 3.6中开发2D游戏,尤其是涉及物理交互时,碰撞检测是绕不开的核心功能。无论是制作一个简单的平台跳跃游戏,还是一个复杂的物理解谜游戏,你都需要依赖BoxCollider2DCircleCollider2DPolygonCollider2D这些组件来定义物体的“物理边界”。而Sensor(传感器)属性,则是实现“触发区域”而非“物理阻挡”的关键。听起来很简单,对吧?但实际开发中,我见过太多开发者在这里栽跟头。

新手常犯的错误是,以为给节点挂上碰撞体,脚本里写上onBeginContact监听,一切就水到渠成。结果运行时发现,碰撞要么不触发,要么触发得乱七八糟,角色卡在墙里,或者触发区域毫无反应。更头疼的是,当你把Sensor勾选上,期望它只触发事件而不产生物理阻挡时,却发现角色依然被“无形的墙”挡住,或者物理反馈变得诡异。这些问题往往不是引擎的Bug,而是对碰撞系统的工作原理理解不透彻,以及对一些关键属性和设置组合使用不当造成的。

这份指南的目的,就是把我自己以及团队在多个2D项目实战中积累的、关于Cocos Creator 3.6 2D碰撞监听的经验和教训系统性地梳理出来。我们将从最基础的BoxCollider2D组件属性讲起,一直深入到Sensor模式的正确使用场景、与RigidBody2D的配合、以及如何高效可靠地监听碰撞事件。我会重点解释那些官方文档可能一笔带过,但实际开发中至关重要的“为什么”,并分享一系列可以直接“抄作业”的避坑技巧。无论你是刚接触Cocos Creator 2D物理的新手,还是已经踩过一些坑想寻求更优解的开发者,相信这篇指南都能让你对碰撞系统有全新的、更深刻的认识。

2. 核心组件深度解析:BoxCollider2D与它的伙伴们

在开始监听碰撞之前,我们必须先理解我们正在操作的对象。Cocos Creator 3.6的2D物理系统基于Box2D,这是一个久经考验的2D物理引擎。我们添加的每一个碰撞体组件,最终都会在底层转换为Box2D的b2Fixture对象。理解这一点,对后续排查问题至关重要。

2.1 BoxCollider2D:不只是个矩形框

BoxCollider2D是最常用的碰撞体,它代表一个轴对齐的矩形区域。在属性检查器中,它的核心属性远不止一个Size

关键属性拆解:

  1. Size(大小):定义了碰撞矩形的宽和高。这里第一个坑是:Size的单位是“世界单位”(World Unit),而不是像素。如果你的Sprite图片是100x100像素,你希望碰撞体和图片一样大,那么Size应该设置为多少?这取决于你的“像素-世界单位”换算关系。在Cocos Creator中,默认情况下,一个世界单位对应设计分辨率下的一个像素(具体取决于Canvas的配置)。但更可靠的做法是,通过脚本根据Sprite的UITransformSprite组件的实际尺寸来动态设置Size,以确保精确匹配。

  2. Offset(偏移):这个属性极其有用但常被忽略。它定义了碰撞体中心点相对于节点中心点的偏移。默认是(0,0),即碰撞体中心与节点中心重合。当你需要碰撞体与节点的视觉表现(如Sprite)不完全对齐时,就要用到它。例如,一个角色的脚底碰撞体,通常需要向下偏移。

  3. Density(密度)、Friction(摩擦力)、Restitution(弹性系数):这三个属性仅在碰撞体附着在RigidBody2D(刚体)组件上时才生效。它们共同决定了物体的物理特性。

    • Density:密度,与碰撞体的SizeRadius一起,计算刚体的质量(Mass)。质量 = 密度 * 面积。如果你希望一个物体更“重”、更难被推动,就增大它的密度。
    • Friction:摩擦力,范围0~1。影响物体在接触面上滑动时的阻力。0像冰面,1像粗糙的水泥地。两个接触物体的摩擦力会通过某种公式(如取平均值或最小值)综合计算。
    • Restitution:弹性,范围0~1。0表示完全非弹性碰撞(如橡皮泥),1表示完全弹性碰撞(理想情况,会永远弹跳)。它决定了碰撞后能量损失的程度。

避坑提示1:很多开发者发现设置了FrictionRestitution却没效果,首要原因就是该碰撞体所在的节点没有RigidBody2D组件。没有刚体,碰撞体就是一个静态的“地形”,它的密度、摩擦、弹性属性不会被物理引擎用于动力学计算。

2.2 其他碰撞体:CircleCollider2D与PolygonCollider2D

  • CircleCollider2D(圆形碰撞体):属性很简单,主要是一个Radius(半径)。它适用于球类、圆形角色等。同样需要注意Offset来调整圆心位置。
  • PolygonCollider2D(多边形碰撞体):功能最强大,也最复杂。它允许你通过一系列顶点(Points)来定义任意凸多边形碰撞区域。这对于匹配不规则形状的精灵(如一个倾斜的岩石、一个三角形陷阱)非常有用。

PolygonCollider2D的巨坑:凸多边形限制与自动三角剖分Box2D的b2Fixture只支持凸多边形。如果你在Points里定义了一个凹多边形,Cocos Creator的物理系统(实际上是底层的Box2D)会**自动将其分割成多个凸多边形(通常是三角形)**来近似表示。这个过程对开发者是透明的,但会带来一个隐蔽的问题:一个PolygonCollider2D组件在底层可能对应多个b2Fixture

这意味着什么?当你进行射线检测(Raycast)并设置检测类型为All时,一条射线穿过这个凹多边形碰撞体,可能会报告多次命中,因为它穿过了多个三角剖分后的小凸多边形。同样,在复杂形状下,碰撞回调的逻辑可能需要额外处理。虽然对于大多数简单的碰撞检测和响应来说,引擎已经处理得很好,但如果你在做精确的射线拾取或需要知道具体碰撞到哪个“部分”时,就必须意识到这一点。

编辑技巧:在场景编辑器中,选中PolygonCollider2D并勾选Editing,你可以直观地拖拽顶点来编辑形状。按住Alt键拖拽,可以保持形状中心不变。对于Sprite组件,点击Regenerate Points按钮,可以基于精灵的透明度阈值(Threshold)自动生成一个轮廓多边形,这是快速匹配精灵形状的神器,但生成的顶点数可能较多,需酌情简化以优化性能。

2.3 碰撞体的通用属性:Tag, Group, Sensor

这三个属性是所有类型碰撞体共有的,是配置碰撞行为的关键。

  1. Tag(标签):一个数字标识。在碰撞回调函数中,你可以通过contact.collider.tag来获取碰到的是哪个物体。这是区分碰撞对象类型最直接的方式。例如,你可以定义Tag.Player = 1,Tag.Enemy = 2,Tag.Coin = 3。在监听器里通过判断tag来执行不同逻辑(玩家碰到金币加分,碰到敌人扣血)。

  2. Group(分组):这是一个更强大的碰撞过滤机制。它本身不直接定义谁能碰谁,而是需要与**碰撞矩阵(Collision Matrix)**配合使用。

    • 你可以在项目设置 -> 物理 -> 碰撞矩阵中,配置不同分组之间是否能够发生碰撞。
    • 例如,你可以设置“玩家子弹”分组和“玩家”分组不相撞(避免误伤自己),但“玩家子弹”和“敌人”分组可以相撞。
    • 分组是位掩码(Bit Mask),这意味着一个碰撞体可以属于多个分组(通过位运算设置),从而实现复杂的碰撞关系。
  3. Sensor(传感器):这是本指南的重中之重。勾选后,该碰撞体就变成了一个“触发器”。它不会产生任何物理阻挡效果(即物体可以穿透它),但会正常触发碰撞回调事件(onBeginContact,onEndContact

    • 典型用途:检测区域(如陷阱触发范围、金币收集区域、剧情触发点)、技能攻击范围、非物理性的交互物体(如穿过一个会加血的泉水)。
    • 核心误区Sensor不参与物理求解。这意味着,即使一个高速运动的刚体穿过Sensor,也不会像碰到普通碰撞体那样被减速、反弹或停止。它只是“感知”到有物体进入或离开。

3. 物理世界的基石:RigidBody2D与碰撞监听

碰撞体定义了形状,但要让它们“动起来”并相互影响,或者让我们的代码知道它们何时接触,就需要RigidBody2D(刚体)组件和事件监听。

3.1 RigidBody2D:赋予物体物理灵魂

RigidBody2D组件为节点赋予了物理属性,如质量、速度、角速度、阻尼等。它决定了物体如何响应力和碰撞。

  • 类型(Type)
    • Static:静态刚体。质量无限大,不会因受力而运动。通常用于地面、墙壁等固定不动的物体。静态刚体之间永远不会发生碰撞
    • Dynamic:动态刚体。完全受物理引擎控制,会受力、会碰撞、会运动。你的玩家、敌人、可移动箱子通常用这个。
    • Kinematic:运动学刚体。你可以通过代码直接设置其位置或速度,但它不会受重力等力的影响。它可以推动Dynamic刚体,但不会被Dynamic刚体推动。常用于平台、电梯,或者需要精确控制移动的敌人(避免物理引擎的抖动)。
  • 开启碰撞监听:这是最关键的一步!在RigidBody2D组件的属性中,有一个Enabled Contact Listener复选框。你必须勾选它,该刚体及其上的所有碰撞体才会触发碰撞回调事件。这是新手最常忽略的一点,导致写了半天监听函数却毫无反应。

3.2 碰撞监听的回调函数

当两个都开启了Enabled Contact Listener的刚体发生碰撞时,引擎会回调一系列函数。我们需要在挂载了刚体组件的节点上,编写脚本并实现这些回调。

主要回调函数:

  1. onBeginContact:当两个碰撞体开始接触的瞬间调用。只调用一次。
  2. onEndContact:当两个碰撞体结束接触(分离)的瞬间调用。只调用一次。
  3. onPreSolve:在物理引擎求解本次碰撞的接触点之前调用。每一帧可能调用多次(如果接触持续)。你可以在这里修改碰撞信息(如禁用某个方向的碰撞)。
  4. onPostSolve:在物理引擎求解完本次碰撞后调用。你可以在这里获取碰撞产生的冲量(Impulse)信息。

对于大多数游戏逻辑(如拾取物品、触发陷阱、受到伤害),我们只需要用到onBeginContactonEndContact

回调函数的基本结构:

import { _decorator, Component, Collider2D, IPhysics2DContact } from 'cc'; const { ccclass, property } = _decorator; @ccclass('PlayerController') export class PlayerController extends Component { // 假设这个脚本挂在玩家角色(带有RigidBody2D)的节点上 onBeginContact (selfCollider: Collider2D, otherCollider: Collider2D, contact: IPhysics2DContact | null) { // selfCollider: 属于本节点的碰撞体(玩家可能有多个碰撞体,如身体和脚部传感器) // otherCollider: 与之发生碰撞的另一个碰撞体 // contact: 碰撞信息对象,包含法线、切线等,有时可能为null(例如Sensor之间的接触) console.log(`碰到了: ${otherCollider.node.name}`); // 通过Tag判断 if (otherCollider.tag === 3) { // 假设3是金币Tag this.pickCoin(otherCollider.node); } } onEndContact (selfCollider: Collider2D, otherCollider: Collider2D, contact: IPhysics2DContact | null) { console.log(`离开了: ${otherCollider.node.name}`); } pickCoin(coinNode: Node) { // 加分、播放音效、销毁节点等 coinNode.destroy(); } }

4. Sensor的正确用法与高级避坑技巧

现在,我们进入最核心的部分:如何正确使用Sensor,以及如何避免与之相关的各种坑。

4.1 何时使用Sensor?经典场景剖析

场景一:角色脚下的地面检测(用于跳跃判定)这是一个经典用法。你通常不会希望角色的脚部碰撞体把角色“推”离地面,因为跳跃和下落是由速度/重力控制的。你只需要知道“是否在地面上”。

  • 做法:在角色脚部(例如,从角色中心向下偏移一些的位置)添加一个细长的BoxCollider2D,并勾选Sensor。同时,地面的碰撞体是普通的(非Sensor)。
  • 逻辑:在脚本的onBeginContactonEndContact中,通过otherCollider的Tag判断是否是地面,并维护一个isOnGround的布尔值。当isOnGround为true时,才允许执行跳跃操作。
  • 避坑:确保脚部Sensor的Group与地面的Group在碰撞矩阵中是允许碰撞的。并且,角色主刚体(身体)的碰撞体不要与地面发生阻挡,否则角色会浮空或卡住。通常通过分组或设置身体碰撞体为Sensor(如果不需要物理阻挡)来实现。

场景二:非物理性的触发区域例如,一个宝箱的开启区域、一个存档点、一个剧情对话触发点。

  • 做法:在触发区域节点上添加一个BoxCollider2D并勾选Sensor。这个节点不需要RigidBody2D组件(因为它自己不动)。但是,想要触发它的物体(如玩家)必须有RigidBody2D且开启Enabled Contact Listener
  • 逻辑:在触发区域节点的脚本里监听onBeginContact。当玩家进入时,显示对话或开启宝箱。
  • 避坑:如果触发区域也需要移动(比如一个移动的伤害区域),那么它也需要一个RigidBody2D,并且类型通常设为Kinematic,以便通过代码控制移动,同时保持Sensor属性。

场景三:攻击判定框对于近战攻击,你可以在武器挥动的轨迹上放置一个Sensor碰撞体。

  • 做法:攻击时,通过代码动态激活(collider.enabled = true)这个Sensor碰撞体,持续几帧后关闭。Sensor的Group设置为与敌人分组可碰撞,与玩家分组不碰撞。
  • 逻辑:在Sensor的onBeginContact中,判断碰到的如果是敌人(通过Tag或Group),则调用敌人的受伤函数。由于是Sensor,它不会阻挡武器的运动,也不会把敌人推开(除非你在受伤逻辑里主动添加力)。
  • 避坑注意一帧内多次触发。如果攻击Sensor在一帧内与同一个敌人碰撞体持续接触,onBeginContact可能只调用一次。但如果Sensor快速扫过敌人,可能触发一次。为了确保一次攻击只造成一次伤害,通常需要在攻击逻辑中维护一个“已命中目标列表”,在攻击结束后清空。

4.2 Sensor与普通碰撞体的混合使用:层级与顺序问题

一个刚体节点可以挂载多个碰撞体组件。例如,一个玩家角色可能有:

  1. 一个主要的BoxCollider2D(非Sensor),用于与墙壁、平台的物理阻挡。
  2. 一个脚部的BoxCollider2D(Sensor),用于地面检测。
  3. 一个身体周围的CircleCollider2D(Sensor),用于拾取物品。

这里有一个重要的原则:物理引擎处理碰撞时,并不区分Sensor和非Sensor的先后顺序,它只关心碰撞对(pair)中是否有Sensor

  • 如果碰撞对中至少有一个是Sensor,则只触发回调,不产生物理冲量
  • 如果碰撞对中两者都不是Sensor,则既触发回调,又产生物理冲量(阻挡、反弹等)。

因此,在上面的例子中,玩家的脚部Sensor碰到地面(非Sensor),会触发onBeginContact但不会产生推力。玩家的身体非Sensor碰撞体碰到墙壁(非Sensor),会触发回调同时被墙壁挡住。

4.3 常见坑点与解决方案实录

坑点1:Sensor勾选了,但物体还是被挡住了

  • 排查步骤
    1. 确认双方:检查发生碰撞的两个碰撞体。是不是只把其中一个设成了Sensor?如果另一个是普通碰撞体且附着在动态刚体上,物理阻挡依然会发生(因为碰撞对中有一个非Sensor)。对于触发区域,通常需要双方都是Sensor,或者区域是Sensor而进入的物体是非Sensor但你不关心它的物理阻挡(比如玩家,他的阻挡由其他碰撞体处理)。
    2. 检查刚体类型:如果Sensor所在的节点有RigidBody2D,并且类型是Dynamic,它仍然会受到重力等力的影响而下落。如果你希望一个触发区域悬浮在空中静止不动,应该不添加刚体,或者添加刚体并设置为StaticKinematic
    3. 检查碰撞分组:确认两个碰撞体的Group在项目设置的碰撞矩阵中允许碰撞。如果不允许,连onBeginContact都不会触发。

坑点2:onBeginContact/onEndContact不触发

  • 排查清单
    1. 刚体监听开关:这是最最最常见的原因!请确保发生碰撞的两个刚体节点上的RigidBody2D组件,其Enabled Contact Listener属性都已勾选。少一个都不行!
    2. 脚本挂载与函数名:确保脚本挂载在拥有刚体的节点上,并且函数名拼写完全正确(onBeginContact,onEndContact),参数数量和类型也要正确。
    3. 碰撞分组:双方分组必须允许碰撞(碰撞矩阵对应位置打勾)。
    4. 至少一方非静态:两个Static类型的刚体之间永远不会产生碰撞事件。
    5. 节点或组件未激活:检查节点和碰撞体组件的enabled属性是否为true。

坑点3:Sensor的回调中contact参数为null

  • 现象:在onBeginContact中,第三个参数contact有时是null
  • 原因:根据Box2D的设计和Cocos Creator的封装,当碰撞对中至少有一个是Sensor时,contact参数可能为null。因为Sensor不产生物理接触点(Manifold),所以没有详细的接触信息(如法线、穿透深度)。
  • 解决方案:如果你的逻辑不需要contact信息(比如只是判断“进入了某个区域”),那么直接忽略它即可。如果需要法线等信息,那么这种碰撞场景可能不适合用Sensor,或者你需要通过其他方式(如计算两个节点的位置)来推断。

坑点4:一帧内多次回调与性能

  • 问题:当两个物体复杂交错,或者有多个碰撞体时,一帧内可能触发大量碰撞回调,影响性能。
  • 优化
    • 精简碰撞体形状:用简单的BoxCircle代替复杂的Polygon
    • 合并逻辑:在onBeginContact中避免做耗时的操作(如查找节点、复杂计算)。可以将碰撞信息(如otherCollider.tag)存入一个数组,在updatelateUpdate中统一处理。
    • 使用分组过滤:善用碰撞矩阵,让不必要的物体之间根本不会进行碰撞检测,这是最有效的优化。

5. 实战:构建一个完整的2D角色碰撞系统

让我们通过一个具体的例子,将上述所有知识串联起来。我们要创建一个2D平台跳跃角色,他拥有:

  1. 身体碰撞体(与墙壁、平台物理阻挡)。
  2. 脚部传感器(检测是否在地面,允许跳跃)。
  3. 头部传感器(检测是否顶到天花板,防止连跳时“粘”在天花板上)。
  4. 侧边传感器(可选,用于检测是否贴墙,实现蹬墙跳)。

5.1 节点与组件结构

Player (节点,挂载RigidBody2D, type=Dynamic, 开启Enabled Contact Listener) ├── Sprite (视觉表现) ├── BodyCollider (BoxCollider2D, 非Sensor, Group=Player) ├── FootSensor (BoxCollider2D, Sensor=true, Group=Player) └── HeadSensor (BoxCollider2D, Sensor=true, Group=Player)

在项目设置的碰撞矩阵中,确保Player分组与Ground(地面)、Wall(墙壁)分组允许碰撞。

5.2 核心脚本实现

import { _decorator, Component, RigidBody2D, Collider2D, Vec2, Input, input, KeyCode } from 'cc'; const { ccclass, property } = _decorator; // 定义标签常量,避免魔法数字 enum Tag { GROUND = 1, WALL = 2, ENEMY = 3, } @ccclass('PlayerController') export class PlayerController extends Component { // 属性绑定 @property(RigidBody2D) rigidBody: RigidBody2D | null = null; @property(Collider2D) footSensor: Collider2D | null = null; // 在编辑器中拖拽赋值 @property(Collider2D) headSensor: Collider2D | null = null; // 角色状态 private _isOnGround: boolean = false; private _isOnCeiling: boolean = false; private _moveSpeed: number = 200; private _jumpForce: number = 350; // 接触到的地面/天花板碰撞体集合(用于处理多个接触点) private _groundContacts: Set<Collider2D> = new Set(); private _ceilingContacts: Set<Collider2D> = new Set(); start() { if (this.rigidBody) { this.rigidBody.enabledContactListener = true; // 确保开启 } // 初始化输入监听 input.on(Input.EventType.KEY_DOWN, this.onKeyDown, this); } onBeginContact(selfCollider: Collider2D, otherCollider: Collider2D) { // 判断是哪个传感器触发的碰撞 if (selfCollider === this.footSensor && otherCollider.tag === Tag.GROUND) { this._groundContacts.add(otherCollider); this._isOnGround = this._groundContacts.size > 0; // console.log('脚部接触地面'); } if (selfCollider === this.headSensor && otherCollider.tag === Tag.GROUND) { this._ceilingContacts.add(otherCollider); this._isOnCeiling = this._ceilingContacts.size > 0; // console.log('头部接触天花板'); } // 可以在这里添加碰到敌人等的逻辑 if (otherCollider.tag === Tag.ENEMY) { this.takeDamage(); } } onEndContact(selfCollider: Collider2D, otherCollider: Collider2D) { if (selfCollider === this.footSensor && otherCollider.tag === Tag.GROUND) { this._groundContacts.delete(otherCollider); this._isOnGround = this._groundContacts.size > 0; // console.log('脚部离开地面'); } if (selfCollider === this.headSensor && otherCollider.tag === Tag.GROUND) { this._ceilingContacts.delete(otherCollider); this._isOnCeiling = this._ceilingContacts.size > 0; // console.log('头部离开天花板'); } } onKeyDown(event: any) { switch(event.keyCode) { case KeyCode.SPACE: this.jump(); break; case KeyCode.KEY_A: this.moveLeft(); break; case KeyCode.KEY_D: this.moveRight(); break; } } jump() { // 只有在地面且不顶头时才允许跳跃 if (this._isOnGround && !this._isOnCeiling && this.rigidBody) { let velocity = this.rigidBody.linearVelocity; velocity.y = this._jumpForce; // 直接设置Y轴速度实现跳跃 this.rigidBody.linearVelocity = velocity; // 或者使用 applyForce,但直接设置速度更直接 // this.rigidBody.applyForceToCenter(new Vec2(0, this.jumpForce), true); } } moveLeft() { if (this.rigidBody) { let velocity = this.rigidBody.linearVelocity; velocity.x = -this._moveSpeed; this.rigidBody.linearVelocity = velocity; } } moveRight() { if (this.rigidBody) { let velocity = this.rigidBody.linearVelocity; velocity.x = this._moveSpeed; this.rigidBody.linearVelocity = velocity; } } takeDamage() { // 受伤逻辑,例如减少生命值、播放动画、击退等 console.log('Player takes damage!'); // 简单的击退效果 if (this.rigidBody) { this.rigidBody.applyLinearImpulse(new Vec2(-50, 100), this.rigidBody.getWorldCenter(), true); } } update(deltaTime: number) { // 可以在update中处理持续的逻辑,例如根据输入持续移动 // 但注意,直接每帧设置速度可能会与物理引擎冲突,更好的做法是在FixedUpdate中处理 // 对于简单的键盘控制,也可以在keyDown/keyUp中设置一个状态标志,在这里应用力 } onDestroy() { // 记得移除输入监听 input.off(Input.EventType.KEY_DOWN, this.onKeyDown, this); } }

5.3 关键实现细节与避坑总结

  1. 使用Set管理多个接触点:角色可能同时站在多个地面瓦片上。用Set来存储所有当前接触的地面碰撞体,只有当Set为空时,_isOnGround才为false。这比简单的布尔标志更可靠,避免了因为一个传感器同时接触/离开多个地面块而导致的状态错误。
  2. 跳跃逻辑:我们直接修改刚体的线性速度来实现跳跃,这比施加力更直接、响应更快。同时,我们检查了_isOnCeiling,防止角色顶着头时还能起跳(这会导致角色被卡住)。
  3. 移动逻辑:同样通过直接设置速度来实现。对于更复杂的移动(如加速度、摩擦),应该在fixedUpdate中使用applyForce
  4. 受伤击退:展示了如何在碰撞回调中应用冲量(applyLinearImpulse)来实现击退效果。注意冲量是瞬间的力,适合表现打击感。
  5. 性能与内存:在onDestroy中移除事件监听是良好习惯。Set的使用也避免了数组的频繁增删操作。

通过这样一个完整的例子,你应该能够清晰地看到BoxCollider2DSensorRigidBody2D以及碰撞监听是如何协同工作,构建出一个响应灵敏、逻辑严谨的2D角色控制器的。记住,理解每个组件和参数背后的物理意义,是避开所有坑洞、实现精准控制的不二法门。在实际项目中,你可能还需要处理更复杂的情况,比如斜坡、移动平台、单向平台等,但掌握了这些基础知识,你就有能力去研究和实现任何复杂的碰撞交互了。