1. 项目概述从“幽灵射手”开始你的Cocos Creator之旅如果你刚接触Cocos Creator或者已经看过一些教程但总感觉项目结构零零散散那么从“幽灵射手”这个项目开始会是一个绝佳的选择。这不是一个简单的“Hello World”而是一个麻雀虽小、五脏俱全的2D射击游戏原型。它涵盖了从资源导入、场景搭建、角色控制、动画播放、碰撞检测到UI交互的完整链条。我之所以选择它作为讲义的开篇是因为它能让你在动手实现一个有趣玩法的同时系统地摸清Cocos Creator编辑器的工作流和核心概念而不是孤立地学习某个按钮的功能。很多新手卡在“知道每个功能是什么但不知道如何把它们串成一个游戏”的环节“幽灵射手”正是为了解决这个问题而设计的。简单来说“幽灵射手”是一个2D俯视角的射击游戏。玩家控制一个角色在场景中移动、瞄准并射击自动生成的幽灵敌人。敌人被击中后会播放死亡动画并消失玩家需要躲避敌人的接触同时尽可能多地得分。这个项目听起来简单但它几乎用到了2D游戏开发中最基础、最核心的那一套技术栈。通过完成它你不仅能做出一个可玩的游戏更重要的是能建立起“用Cocos Creator做游戏”的完整思维框架。无论你后续想做RPG、塔防还是其他类型的游戏这个框架都是通用的。2. 项目核心设计思路与模块拆解在动手写第一行代码之前我们先花点时间把整个项目的骨架搭起来。一个好的开始是成功的一半清晰的模块划分能让后续开发事半功倍也能让你更容易理解代码之间的组织关系。2.1 核心玩法循环与数据流设计任何游戏都有一个核心循环。“幽灵射手”的核心循环非常清晰输入 - 逻辑更新 - 渲染。具体到我们的项目可以拆解为以下数据流玩家输入通过键盘WASD或方向键控制角色移动通过鼠标控制瞄准方向和射击。游戏逻辑更新角色系统根据输入更新玩家位置和旋转朝向鼠标。敌人系统定时生成敌人并让敌人以简单AI如朝玩家移动更新位置。战斗系统检测子弹与敌人的碰撞。碰撞发生时触发敌人的“受伤”逻辑播放动画、销毁并更新玩家分数。生命周期管理管理子弹和敌人的生成与销毁防止内存泄漏。渲染反馈将更新后的位置、旋转、动画状态实时渲染到屏幕上并通过UI更新分数、生命值等信息。这个数据流决定了我们脚本的职责划分。我们不会把所有代码都塞进一个脚本里而是遵循“单一职责原则”为不同的游戏对象创建独立的脚本。2.2 关键游戏对象与组件规划在Cocos Creator中一切皆是节点Node而功能由组件Component赋予。我们需要提前规划好主要的节点和它们身上需要挂载的组件。游戏对象 (节点)核心组件职责说明Player (玩家)Sprite精灵, RigidBody2D刚体, CircleCollider2D圆形碰撞体, PlayerCtrl脚本代表玩家角色。Sprite用于显示RigidBody2D用于物理移动或使用代码直接控制TransformCollider2D用于碰撞检测PlayerCtrl脚本处理移动、瞄准和射击输入。Enemy (敌人)Sprite, RigidBody2D, BoxCollider2D方形碰撞体, EnemyCtrl脚本代表幽灵敌人。需要Sprite显示不同状态移动、死亡Collider2D用于被子弹击中EnemyCtrl脚本控制移动AI和生命值。Bullet (子弹)Sprite, RigidBody2D, CircleCollider2D, BulletCtrl脚本代表玩家发射的子弹。通常由PlayerCtrl脚本实例化BulletCtrl脚本控制其飞行逻辑和碰撞处理。GameManager (游戏管理器)GameManager脚本一个常驻节点上的脚本负责全局状态管理如敌人生成计时、分数更新、游戏开始/结束逻辑。它通常不渲染任何东西是一个“幕后总管”。UI Canvas (UI画布)Canvas画布, Widget挂件, Label文本, Button按钮包含显示分数、生命值的Label以及可能存在的开始、重新开始按钮。Widget组件确保UI元素能自适应屏幕。注意关于使用**物理系统RigidBody还是直接变换Transform**控制移动这里有个关键选择。对于“幽灵射手”这种需要精确碰撞检测子弹打中敌人的项目我强烈建议启用物理系统。虽然学习曲线稍陡但它能帮你省去大量手动计算碰撞的麻烦并且是行业通用做法。本讲义将基于物理系统来构建。2.3 资源目录结构规划一个清晰的项目资源结构是团队协作和个人项目维护的基石。在assets目录下建议你这样组织assets/ ├── scripts/ # 存放所有TypeScript脚本 │ ├── PlayerCtrl.ts │ ├── EnemyCtrl.ts │ ├── BulletCtrl.ts │ └── GameManager.ts ├── textures/ # 存放所有图片资源精灵、UI图 │ ├── player/ │ ├── enemy/ │ ├── bullet/ │ └── ui/ ├── prefabs/ # 存放预制体Prefab如Enemy.prefab, Bullet.prefab ├── animations/ # 存放动画剪辑AnimationClip如enemy_die.anim ├── scenes/ # 存放场景文件如Main.scene └── sounds/ # 存放音效可选在编辑器里创建好这些文件夹并把对应的资源拖进去。养成“资源不入库不生效”的习惯即所有用到的图片、声音等都必须先导入到assets目录下的相应位置编辑器才能识别和使用。3. 场景搭建与基础组件配置实操现在我们打开Cocos Creator创建一个新项目选择2D模板开始真正的动手环节。3.1 构建基础游戏场景首先删除场景中默认的Canvas节点下的子节点我们从零开始搭建。设置背景在层级管理器中选中Canvas节点在属性检查器中点击添加组件-渲染组件-Sprite。然后将一张背景图片比如纯色图或游戏背景图从资源管理器拖拽到Sprite Frame属性上。调整Size Mode为CUSTOM并手动将Size的宽高设置为与设计分辨率如960x640一致确保背景铺满屏幕。创建玩家节点在Canvas下右键创建空节点重命名为Player。为其添加Sprite组件并赋予玩家角色的图片。接着添加物理组件RigidBody2D和CircleCollider2D。在RigidBody2D组件中将Type设置为Dynamic动态刚体受物理模拟影响Gravity Scale设置为0因为我们是一个俯视角游戏通常不需要重力。在CircleCollider2D组件中调整Radius使其大致匹配玩家精灵的轮廓。勾选Sensor这里是个关键点对于玩家角色我们通常不希望它被敌人“撞飞”而只是检测到“接触”事件。因此可以将玩家的碰撞体设为Sensor传感器。传感器会触发碰撞回调但不会发生物理反弹。这完全取决于你想要的游戏手感。创建敌人预制体Prefab这是重要的一步。我们不直接在场景里放很多敌人而是创建一个模板。在场景编辑器中创建一个名为Enemy的节点挂好Sprite、RigidBody2DType: Dynamic, Gravity Scale: 0、BoxCollider2D。然后从层级管理器中将Enemy节点拖拽到资源管理器的assets/prefabs文件夹内。你会看到一个Enemy.prefab文件生成。此时场景中的这个Enemy节点会变成蓝色表示它已经是预制体的实例。右键删除场景中的这个实例我们将在游戏运行时通过代码动态生成它。创建子弹预制体同理创建Bullet节点添加Sprite、RigidBody2DType: Dynamic, Gravity Scale: 0、CircleCollider2DRadius调小并拖拽生成Bullet.prefab。删除场景实例。创建UI在Canvas下创建UI节点可选用于分组。在其下创建两个Label节点分别重命名为ScoreLabel和HealthLabel。调整它们的位置、字体、大小和颜色。记得为这两个Label节点添加Widget组件并设置好对齐方式如左上角、右上角确保在不同屏幕分辨率下位置相对固定。3.2 物理世界与碰撞分组配置物理碰撞是游戏交互的核心配置错误会导致检测不到碰撞。打开物理设置顶部菜单项目-项目设置-物理-物理系统。配置碰撞矩阵这是重中之重。滚动到碰撞矩阵部分。你会看到一个矩阵表行列都是分组名如DEFAULT、PLAYER、ENEMY、BULLET等。首先在分组管理中添加我们需要的分组PlayerEnemyBullet。DEFAULT分组已存在。然后在碰撞矩阵中勾选需要发生碰撞的分组交集。例如Bullet和Enemy必须勾选子弹打敌人。Player和Enemy通常也勾选敌人碰到玩家扣血。Bullet和Player通常不勾选避免子弹打到自己。Bullet和Bullet通常不勾选。这个矩阵决定了哪些物体之间会进行碰撞检测。勾选检测不勾选忽略。为物体指定分组回到场景或预制体为每个物体的碰撞体组件如CircleCollider2D的Group属性选择对应的分组。例如玩家碰撞体选Player敌人碰撞体选Enemy子弹碰撞体选Bullet。实操心得很多新手遇到的“碰撞检测失灵”问题十有八九出在碰撞矩阵没配好或者分组没设对。务必花时间理解这个矩阵的逻辑它定义的是“检测关系”而非“物理阻挡关系”。阻挡关系由刚体类型和碰撞体是否传感器共同决定。4. 核心脚本编写与功能实现场景和资源准备好后我们进入代码部分。所有脚本都放在assets/scripts下使用TypeScript编写。4.1 玩家控制脚本 (PlayerCtrl.ts)这是游戏的核心输入控制器。import { _decorator, Component, Node, input, Input, EventMouse, Vec2, Vec3, RigidBody2D, PhysicsSystem2D, Prefab, instantiate, KeyCode } from cc; const { ccclass, property } _decorator; ccclass(PlayerCtrl) export class PlayerCtrl extends Component { // 将子弹预制体暴露给编辑器方便赋值 property(Prefab) bulletPrefab: Prefab | null null; // 移动速度 property moveSpeed: number 300; // 子弹发射间隔秒 property shootInterval: number 0.2; // 私有属性 private _rigidBody: RigidBody2D | null null; private _shootTimer: number 0; private _moveDirection: Vec2 new Vec2(0, 0); start() { // 获取刚体组件 this._rigidBody this.node.getComponent(RigidBody2D); // 启用鼠标和键盘输入 input.on(Input.EventType.MOUSE_MOVE, this._onMouseMove, this); input.on(Input.EventType.MOUSE_DOWN, this._onMouseDown, this); // 也可以监听键盘按下/抬起事件来控制移动更平滑 input.on(Input.EventType.KEY_DOWN, this._onKeyDown, this); input.on(Input.EventType.KEY_UP, this._onKeyUp, this); } onDestroy() { // 记得移除监听防止内存泄漏 input.off(Input.EventType.MOUSE_MOVE, this._onMouseMove, this); input.off(Input.EventType.MOUSE_DOWN, this._onMouseDown, this); input.off(Input.EventType.KEY_DOWN, this._onKeyDown, this); input.off(Input.EventType.KEY_UP, this._onKeyUp, this); } update(deltaTime: number) { // 更新射击计时器 if (this._shootTimer 0) { this._shootTimer - deltaTime; } // 应用移动速度到刚体 if (this._rigidBody) { let velocity new Vec2(this._moveDirection.x, this._moveDirection.y); velocity.multiplyScalar(this.moveSpeed); this._rigidBody.linearVelocity velocity; } // 玩家朝向鼠标仅旋转Z轴 if (PhysicsSystem2D.instance) { let pos this.node.worldPosition; let mousePos input.getMousePosition(); // 获取的是屏幕坐标 // 将屏幕坐标转换为世界坐标这里需要Camera组件假设主相机在Canvas下 // 简化处理假设Canvas适配模式为FIXED_WIDTH且原点在中心 // 实际项目中建议使用Camera的screenToWorld方法 let worldPos new Vec3(mousePos.x - 480, mousePos.y - 320, 0); // 960x640分辨率中心原点 let direction new Vec3(worldPos.x - pos.x, worldPos.y - pos.y, 0); let angle Math.atan2(direction.y, direction.x) * 180 / Math.PI; this.node.setRotationFromEuler(0, 0, angle); } } private _onMouseMove(event: EventMouse) { // 实时瞄准已经在update中通过鼠标位置实现 } private _onMouseDown(event: EventMouse) { this._shoot(); } private _onKeyDown(event: any) { switch(event.keyCode) { case KeyCode.KEY_W: case KeyCode.ARROW_UP: this._moveDirection.y 1; break; case KeyCode.KEY_S: case KeyCode.ARROW_DOWN: this._moveDirection.y -1; break; case KeyCode.KEY_A: case KeyCode.ARROW_LEFT: this._moveDirection.x -1; break; case KeyCode.KEY_D: case KeyCode.ARROW_RIGHT: this._moveDirection.x 1; break; } } private _onKeyUp(event: any) { switch(event.keyCode) { case KeyCode.KEY_W: case KeyCode.ARROW_UP: if (this._moveDirection.y 1) this._moveDirection.y 0; break; case KeyCode.KEY_S: case KeyCode.ARROW_DOWN: if (this._moveDirection.y -1) this._moveDirection.y 0; break; case KeyCode.KEY_A: case KeyCode.ARROW_LEFT: if (this._moveDirection.x -1) this._moveDirection.x 0; break; case KeyCode.KEY_D: case KeyCode.ARROW_RIGHT: if (this._moveDirection.x 1) this._moveDirection.x 0; break; } } private _shoot() { if (this._shootTimer 0 || !this.bulletPrefab) { return; // 冷却中或无预制体 } // 实例化子弹 let bulletNode instantiate(this.bulletPrefab); if (bulletNode) { // 设置子弹初始位置为玩家当前位置 bulletNode.setWorldPosition(this.node.worldPosition); // 设置子弹的初始朝向与玩家一致即鼠标方向 bulletNode.setWorldRotation(this.node.worldRotation); // 将子弹添加到场景中通常挂在Canvas或一个动态节点下 this.node.parent?.addChild(bulletNode); // 重置射击计时器 this._shootTimer this.shootInterval; } } }代码解析与注意事项输入处理我们同时监听了鼠标和键盘事件。鼠标用于瞄准和点击射击键盘用于控制移动。_moveDirection是一个二维向量用于累积按键输入的方向。移动实现在update中我们将计算好的速度向量赋值给刚体的linearVelocity。这是物理移动的方式。你也可以选择直接修改node.position但那样就绕过了物理引擎碰撞检测可能需要手动处理。朝向计算Math.atan2(y, x)是计算点(x, y)与原点连线与x轴夹角的标准方法返回弧度值再转换为角度。注意Cocos Creator中旋转角度是顺时针为正。子弹实例化使用instantiate方法从预制体创建实例。务必设置其世界坐标和旋转否则它会出现在原点。添加为当前节点的同级子节点是常见做法。坐标转换示例中鼠标坐标转换世界坐标是简化版。在实际复杂UI和相机下必须使用CameraComponent的screenToWorld方法进行精确转换。这是新手常踩的坑。4.2 子弹控制脚本 (BulletCtrl.ts)子弹需要向前飞并在碰到敌人或飞出屏幕后销毁自己。import { _decorator, Component, Node, RigidBody2D, Vec2, Contact2DType, IPhysics2DContact, Collider2D } from cc; const { ccclass, property } _decorator; ccclass(BulletCtrl) export class BulletCtrl extends Component { property speed: number 500; property lifeTime: number 2; // 子弹存活时间避免一直存在 private _rigidBody: RigidBody2D | null null; private _timer: number 0; start() { this._rigidBody this.node.getComponent(RigidBody2D); // 设置子弹初始速度方向为其正前方即X轴正方向因为子弹图片通常朝右 if (this._rigidBody) { let velocity new Vec2(this.speed, 0); // 将局部速度转换到世界方向 let worldVelocity this.node.up.clone(); // 假设子弹朝上根据美术资源调整 worldVelocity.multiplyScalar(this.speed); this._rigidBody.linearVelocity worldVelocity; } // 监听碰撞开始事件 let collider this.node.getComponent(Collider2D); if (collider) { collider.on(Contact2DType.BEGIN_CONTACT, this._onBeginContact, this); } } update(deltaTime: number) { // 生命周期计时 this._timer deltaTime; if (this._timer this.lifeTime) { this.node.destroy(); } // 简单边界检查飞出屏幕则销毁可选更优解是用物理系统的边界 let pos this.node.worldPosition; if (Math.abs(pos.x) 600 || Math.abs(pos.y) 400) { // 超出一定范围 this.node.destroy(); } } private _onBeginContact(selfCollider: Collider2D, otherCollider: Collider2D, contact: IPhysics2DContact | null) { // 判断碰撞到的物体是否是敌人通过分组或标签这里假设通过分组 // 可以在编辑器里为敌人预制体添加一个自定义标签Tag这里用分组判断 if (otherCollider.group 2) { // 假设2是Enemy分组的值具体看项目设置 // 触发敌人受伤逻辑可以通过发送消息或获取组件调用 let enemyCtrl otherCollider.node.getComponent(EnemyCtrl); // 获取脚本组件 if (enemyCtrl enemyCtrl.takeDamage) { enemyCtrl.takeDamage(1); // 假设造成1点伤害 } // 子弹碰撞后销毁 this.node.destroy(); } } }关键点速度设置子弹的速度在其start生命周期中设置一次。方向需要根据节点的当前旋转来计算。示例中使用了node.upY轴正方向如果你的子弹资源朝向是X轴则用node.right。碰撞检测我们在start中为子弹的碰撞体注册了BEGIN_CONTACT事件监听器。当碰撞发生时回调函数_onBeginContact会被调用。碰撞处理在回调中我们通过otherCollider获取碰撞到的另一个碰撞体进而获取其节点和上面的脚本组件并调用其受伤方法如takeDamage。这是一种松耦合的通信方式。销毁无论是命中敌人、超时还是飞出屏幕最终都要调用this.node.destroy()来销毁子弹节点释放资源。4.3 敌人控制脚本 (EnemyCtrl.ts)敌人需要自动朝玩家移动并有生命值概念。import { _decorator, Component, Node, RigidBody2D, Vec2, Vec3, tween, v3, UIOpacity } from cc; const { ccclass, property } _decorator; ccclass(EnemyCtrl) export class EnemyCtrl extends Component { property moveSpeed: number 150; property maxHealth: number 3; private _rigidBody: RigidBody2D | null null; private _currentHealth: number 0; private _playerNode: Node | null null; start() { this._rigidBody this.node.getComponent(RigidBody2D); this._currentHealth this.maxHealth; // 简单起见假设玩家节点有特定名字或标签。更好的方式是通过GameManager获取。 this._playerNode this.node.scene.getChildByName(Player); } update(deltaTime: number) { if (!this._playerNode || !this._rigidBody) return; // 计算朝向玩家的方向 let enemyPos this.node.worldPosition; let playerPos this._playerNode.worldPosition; let direction new Vec3(playerPos.x - enemyPos.x, playerPos.y - enemyPos.y, 0); direction.normalize(); // 归一化得到单位方向向量 // 将方向向量应用到速度上 let velocity new Vec2(direction.x, direction.y); velocity.multiplyScalar(this.moveSpeed); this._rigidBody.linearVelocity velocity; // 敌人也可以朝向玩家可选 let angle Math.atan2(direction.y, direction.x) * 180 / Math.PI; this.node.setRotationFromEuler(0, 0, angle); } // 受伤方法由子弹碰撞调用 takeDamage(damage: number) { this._currentHealth - damage; // 可以在这里播放受击特效或音效 if (this._currentHealth 0) { this._die(); } } private _die() { // 播放死亡动画如果有 // 这里简单实现一个渐隐消失的效果 let opacity this.node.getComponent(UIOpacity) || this.node.addComponent(UIOpacity); tween(opacity) .to(0.3, { opacity: 0 }) .call(() { // 通知GameManager加分 let gameMgr this.node.scene.getComponentInChildren(GameManager); if (gameMgr gameMgr.addScore) { gameMgr.addScore(10); } this.node.destroy(); }) .start(); } }实现细节寻路AI这里实现了最简单的“直冲玩家”的AI。在update中每帧计算敌人位置指向玩家位置的方向向量归一化后乘以速度设置为刚体的速度。更复杂的AI如巡逻、逃跑可以在此基础上扩展状态机。生命值与受伤takeDamage是一个公共方法供子弹碰撞时调用。当生命值归零调用_die()方法。死亡处理死亡时通常需要播放动画、音效并通知游戏逻辑如加分。示例使用了缓动Tween实现一个简单的渐隐效果并在回调中销毁节点。务必在动画播放完毕后再销毁节点否则动画会中断。查找玩家示例中通过节点名查找玩家这在简单场景中可行。更健壮的做法是让GameManager持有玩家节点的引用或者使用“标签Tag”系统。4.4 游戏管理器脚本 (GameManager.ts)GameManager作为全局单例管理游戏状态和规则。import { _decorator, Component, Node, director, Prefab, instantiate, Label } from cc; const { ccclass, property } _decorator; ccclass(GameManager) export class GameManager extends Component { // 敌人预制体 property(Prefab) enemyPrefab: Prefab | null null; // 敌人生成间隔秒 property spawnInterval: number 2.0; // 敌人生成位置范围相对于屏幕中心 property spawnRange: number 400; // UI引用 property(Label) scoreLabel: Label | null null; property(Label) healthLabel: Label | null null; // 游戏状态 private _score: number 0; private _playerHealth: number 3; private _spawnTimer: number 0; private _isGameOver: boolean false; start() { this._updateUI(); // 初始化游戏状态 this._score 0; this._playerHealth 3; this._isGameOver false; } update(deltaTime: number) { if (this._isGameOver) return; // 敌人生成计时 this._spawnTimer deltaTime; if (this._spawnTimer this.spawnInterval this.enemyPrefab) { this._spawnTimer 0; this._spawnEnemy(); } } private _spawnEnemy() { let enemyNode instantiate(this.enemyPrefab!); this.node.addChild(enemyNode); // 将敌人生成为GameManager的子节点方便管理 // 在屏幕边缘随机位置生成 let side Math.floor(Math.random() * 4); // 0:上, 1:右, 2:下, 3:左 let x 0, y 0; switch(side) { case 0: // 上 x (Math.random() - 0.5) * 2 * this.spawnRange; y this.spawnRange; break; case 1: // 右 x this.spawnRange; y (Math.random() - 0.5) * 2 * this.spawnRange; break; case 2: // 下 x (Math.random() - 0.5) * 2 * this.spawnRange; y -this.spawnRange; break; case 3: // 左 x -this.spawnRange; y (Math.random() - 0.5) * 2 * this.spawnRange; break; } enemyNode.setPosition(x, y); } // 提供给其他脚本调用的公共方法 addScore(points: number) { this._score points; this._updateUI(); } playerTakeDamage(damage: number) { if (this._isGameOver) return; this._playerHealth - damage; this._updateUI(); if (this._playerHealth 0) { this._gameOver(); } } private _updateUI() { if (this.scoreLabel) { this.scoreLabel.string Score: ${this._score}; } if (this.healthLabel) { this.healthLabel.string Health: ${this._playerHealth}; } } private _gameOver() { this._isGameOver true; console.log(Game Over! Final Score:, this._score); // 这里可以显示游戏结束UI暂停游戏等 // director.pause(); // 暂停游戏 // 显示重新开始按钮等 } // 重新开始游戏的方法可由UI按钮调用 restartGame() { director.loadScene(director.getScene().name); // 重新加载当前场景 } }GameManager的核心作用全局状态管理分数、玩家生命值、游戏是否结束等。敌人生成使用计时器定期在屏幕外随机位置实例化敌人预制体。UI更新提供方法更新分数和生命值的显示。游戏流程控制游戏开始、结束、重新开始的逻辑。通信枢纽其他脚本如EnemyCtrl可以通过查找GameManager实例来调用其公共方法如addScore实现松耦合的通信。5. 脚本挂载、配置与最终调试写完脚本后我们需要将它们挂载到对应的节点上并配置好公开的属性。5.1 挂载脚本与属性绑定挂载PlayerCtrl在层级管理器中选中Player节点在属性检查器底部点击添加组件-自定义脚本-PlayerCtrl。挂载后你会看到脚本中声明为property的变量如bulletPrefab出现在了属性面板上。绑定预制体从资源管理器中将assets/prefabs/Bullet.prefab拖拽到Player节点上PlayerCtrl组件的Bullet Prefab属性框中。这一步至关重要否则脚本无法实例化子弹。挂载GameManager在Canvas节点或一个专门的空节点如GameRoot上挂载GameManager脚本。同样需要将enemyPrefab属性绑定到Enemy.prefab将scoreLabel和healthLabel属性分别拖拽绑定到场景中对应的UILabel节点上。配置敌人和子弹对于Enemy.prefab和Bullet.prefab你需要分别打开它们双击预制体文件然后在根节点上挂载EnemyCtrl和BulletCtrl脚本并设置相应的速度、生命值等属性。修改预制体后记得保存CtrlS。5.2 连接最后的拼图玩家受伤目前敌人会移动并碰撞玩家但玩家还没有受伤的逻辑。我们需要修改PlayerCtrl脚本监听与敌人的碰撞。 在PlayerCtrl.ts的start方法中添加碰撞监听start() { // ... 其他初始化代码 ... let collider this.node.getComponent(Collider2D); if (collider) { collider.on(Contact2DType.BEGIN_CONTACT, this._onPlayerContact, this); } }然后添加碰撞处理函数private _onPlayerContact(selfCollider: Collider2D, otherCollider: Collider2D, contact: IPhysics2DContact | null) { if (otherCollider.group 2) { // 假设2是Enemy分组 // 通知GameManager玩家受伤 let gameMgr this.node.scene.getComponentInChildren(GameManager); if (gameMgr gameMgr.playerTakeDamage) { gameMgr.playerTakeDamage(1); } // 可以添加玩家受击无敌时间、闪烁效果等 } }同时别忘了在onDestroy中移除这个监听。5.3 常见问题排查与调试技巧即使按照步骤操作第一次运行时也难免遇到问题。这里是一些常见坑点和排查方法节点找不到或为null现象控制台报错Cannot read property xxx of null。排查检查属性绑定是否正确。在属性检查器中确认property引用的节点或预制体是否成功关联显示为蓝色链接。对于通过代码查找的节点如getChildByName确认节点名称拼写无误且在该时间点节点已存在于场景树中。碰撞检测不生效现象子弹穿过敌人没有触发任何效果。排查步骤第一步检查碰撞分组。确保子弹和敌人的碰撞体Group属性分别设置为Bullet和Enemy。第二步检查碰撞矩阵。在项目设置-物理-碰撞矩阵中确认Bullet行和Enemy列的交叉点是否被勾选。第三步检查刚体类型。确保子弹和敌人的刚体Type不是Static静态。静态刚体不会因碰撞而运动但通常能检测到碰撞。Dynamic或Kinematic都可以。第四步检查碰撞体尺寸。在场景编辑器中勾选显示物理调试通常在场景编辑器工具栏可以看到碰撞体的轮廓绿色线框。确保碰撞体大小合适且彼此能接触。第五步检查监听事件。确认在脚本的start或onLoad中正确注册了BEGIN_CONTACT事件。子弹方向错误现象子弹不朝鼠标方向发射或者朝奇怪的角度飞。排查检查BulletCtrl中速度向量的计算。确认你使用的方向向量如this.node.up与你的子弹资源美术朝向匹配。在start中打印一下this.node.up或this.node.right的值看看。同时检查PlayerCtrl中玩家朝向的计算是否正确。敌人生成位置不对或看不到现象敌人生成在屏幕内奇怪的地方或者根本看不到。排查检查GameManager中_spawnEnemy方法的坐标计算。spawnRange的值是否合理应大于屏幕半宽/高。生成的位置是相对于GameManager节点本身的。如果GameManager节点不在世界原点需要调整坐标计算。可以在生成后打印一下enemyNode.position确认。游戏运行卡顿现象随着敌人增多游戏越来越卡。排查这可能是性能问题。首先确保子弹和敌人在销毁时destroy确实被移除了。其次检查是否有内存泄漏比如事件监听没有在onDestroy中移除。最后可以考虑使用对象池Object Pool来管理子弹和敌人的频繁创建与销毁这是游戏开发中优化性能的必备技巧。调试利器浏览器开发者工具在浏览器中运行游戏Cocos Creator提供预览功能按F12打开。Console标签页查看日志和报错。Sources标签页可以打断点调试TypeScript代码。Cocos Creator控制台编辑器下方的控制台面板会显示所有日志、警告和错误是首要查看的地方。物理调试绘制如前所述在场景编辑器打开物理调试可视化碰撞体。当你一步步解决了上述问题看到玩家在场景中移动、旋转发射子弹击退来袭的幽灵分数随之上涨时“幽灵射手”项目的核心框架就算成功搭建起来了。这个过程中你实践了从场景搭建、资源管理、物理配置、脚本编写到调试排错的全流程。这不仅仅是完成了一个小游戏更是构建起了使用Cocos Creator进行游戏开发的基础认知和肌肉记忆。后续的章节我们会在此基础上深入动画系统、UI系统、音频管理、数据持久化以及更复杂的游戏机制让你手中的这个“幽灵射手”变得越来越丰满、精致。