Cocos Creator 2D射击游戏开发实战:从零构建“幽灵射手”完整项目
2026/7/11 22:38:15 网站建设 项目流程

1. 项目概述:从“幽灵射手”开始你的Cocos Creator之旅

如果你刚接触Cocos Creator,或者已经看过一些教程但总感觉项目结构零零散散,那么从“幽灵射手”这个项目开始,会是一个绝佳的选择。这不是一个简单的“Hello World”,而是一个麻雀虽小、五脏俱全的2D射击游戏原型。它涵盖了从资源导入、场景搭建、角色控制、动画播放、碰撞检测到UI交互的完整链条。我之所以选择它作为讲义的开篇,是因为它能让你在动手实现一个有趣玩法的同时,系统地摸清Cocos Creator编辑器的工作流和核心概念,而不是孤立地学习某个按钮的功能。很多新手卡在“知道每个功能是什么,但不知道如何把它们串成一个游戏”的环节,“幽灵射手”正是为了解决这个问题而设计的。

简单来说,“幽灵射手”是一个2D俯视角的射击游戏。玩家控制一个角色,在场景中移动、瞄准并射击自动生成的幽灵敌人。敌人被击中后会播放死亡动画并消失,玩家需要躲避敌人的接触,同时尽可能多地得分。这个项目听起来简单,但它几乎用到了2D游戏开发中最基础、最核心的那一套技术栈。通过完成它,你不仅能做出一个可玩的游戏,更重要的是能建立起“用Cocos Creator做游戏”的完整思维框架。无论你后续想做RPG、塔防还是其他类型的游戏,这个框架都是通用的。

2. 项目核心设计思路与模块拆解

在动手写第一行代码之前,我们先花点时间把整个项目的骨架搭起来。一个好的开始是成功的一半,清晰的模块划分能让后续开发事半功倍,也能让你更容易理解代码之间的组织关系。

2.1 核心玩法循环与数据流设计

任何游戏都有一个核心循环。“幽灵射手”的核心循环非常清晰:输入 -> 逻辑更新 -> 渲染。具体到我们的项目,可以拆解为以下数据流:

  1. 玩家输入:通过键盘(WASD或方向键)控制角色移动,通过鼠标控制瞄准方向和射击。
  2. 游戏逻辑更新
    • 角色系统:根据输入更新玩家位置和旋转(朝向鼠标)。
    • 敌人系统:定时生成敌人,并让敌人以简单AI(如朝玩家移动)更新位置。
    • 战斗系统:检测子弹与敌人的碰撞。碰撞发生时,触发敌人的“受伤”逻辑(播放动画、销毁),并更新玩家分数。
    • 生命周期管理:管理子弹和敌人的生成与销毁,防止内存泄漏。
  3. 渲染反馈:将更新后的位置、旋转、动画状态实时渲染到屏幕上,并通过UI更新分数、生命值等信息。

这个数据流决定了我们脚本的职责划分。我们不会把所有代码都塞进一个脚本里,而是遵循“单一职责原则”,为不同的游戏对象创建独立的脚本。

2.2 关键游戏对象与组件规划

在Cocos Creator中,一切皆是节点(Node),而功能由组件(Component)赋予。我们需要提前规划好主要的节点和它们身上需要挂载的组件。

游戏对象 (节点)核心组件职责说明
Player (玩家)Sprite(精灵), RigidBody2D(刚体), CircleCollider2D(圆形碰撞体), PlayerCtrl脚本代表玩家角色。Sprite用于显示,RigidBody2D用于物理移动(或使用代码直接控制Transform),Collider2D用于碰撞检测,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节点下的子节点,我们从零开始搭建。

  1. 设置背景:在层级管理器中,选中Canvas节点,在属性检查器中点击添加组件->渲染组件->Sprite。然后将一张背景图片(比如纯色图或游戏背景图)从资源管理器拖拽到Sprite Frame属性上。调整Size ModeCUSTOM,并手动将Size的宽高设置为与设计分辨率(如960x640)一致,确保背景铺满屏幕。
  2. 创建玩家节点:在Canvas下右键创建空节点,重命名为Player。为其添加Sprite组件,并赋予玩家角色的图片。接着,添加物理组件:RigidBody2DCircleCollider2D
    • RigidBody2D组件中,将Type设置为Dynamic(动态刚体,受物理模拟影响),Gravity Scale设置为0(因为我们是一个俯视角游戏,通常不需要重力)。
    • CircleCollider2D组件中,调整Radius使其大致匹配玩家精灵的轮廓。勾选Sensor这里是个关键点:对于玩家角色,我们通常不希望它被敌人“撞飞”,而只是检测到“接触”事件。因此,可以将玩家的碰撞体设为Sensor(传感器)。传感器会触发碰撞回调,但不会发生物理反弹。这完全取决于你想要的游戏手感。
  3. 创建敌人预制体(Prefab):这是重要的一步。我们不直接在场景里放很多敌人,而是创建一个模板。
    • 场景编辑器中创建一个名为Enemy的节点,挂好SpriteRigidBody2D(Type: Dynamic, Gravity Scale: 0)、BoxCollider2D
    • 然后,从层级管理器中将Enemy节点拖拽到资源管理器assets/prefabs文件夹内。你会看到一个Enemy.prefab文件生成。此时,场景中的这个Enemy节点会变成蓝色,表示它已经是预制体的实例。右键删除场景中的这个实例,我们将在游戏运行时通过代码动态生成它。
  4. 创建子弹预制体:同理,创建Bullet节点,添加SpriteRigidBody2D(Type: Dynamic, Gravity Scale: 0)、CircleCollider2D(Radius调小),并拖拽生成Bullet.prefab。删除场景实例。
  5. 创建UI:在Canvas下创建UI节点(可选,用于分组)。在其下创建两个Label节点,分别重命名为ScoreLabelHealthLabel。调整它们的位置、字体、大小和颜色。记得为这两个Label节点添加Widget组件,并设置好对齐方式(如左上角、右上角),确保在不同屏幕分辨率下位置相对固定。

3.2 物理世界与碰撞分组配置

物理碰撞是游戏交互的核心,配置错误会导致检测不到碰撞。

  1. 打开物理设置:顶部菜单项目->项目设置->物理->物理系统
  2. 配置碰撞矩阵:这是重中之重。滚动到碰撞矩阵部分。你会看到一个矩阵表,行列都是分组名(如DEFAULTPLAYERENEMYBULLET等)。
    • 首先,在分组管理中,添加我们需要的分组:PlayerEnemyBulletDEFAULT分组已存在。
    • 然后,在碰撞矩阵中,勾选需要发生碰撞的分组交集。例如:
      • BulletEnemy必须勾选(子弹打敌人)。
      • PlayerEnemy通常也勾选(敌人碰到玩家扣血)。
      • BulletPlayer通常不勾选(避免子弹打到自己)。
      • BulletBullet通常不勾选。
    • 这个矩阵决定了哪些物体之间会进行碰撞检测。勾选=检测,不勾选=忽略。
  3. 为物体指定分组:回到场景或预制体,为每个物体的碰撞体组件(如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和相机下,必须使用CameraComponentscreenToWorld方法进行精确转换。这是新手常踩的坑。

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.up(Y轴正方向),如果你的子弹资源朝向是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 挂载脚本与属性绑定

  1. 挂载PlayerCtrl:在层级管理器中选中Player节点,在属性检查器底部点击添加组件->自定义脚本->PlayerCtrl。挂载后,你会看到脚本中声明为@property的变量(如bulletPrefab)出现在了属性面板上。
  2. 绑定预制体:从资源管理器中将assets/prefabs/Bullet.prefab拖拽到Player节点上PlayerCtrl组件的Bullet Prefab属性框中。这一步至关重要,否则脚本无法实例化子弹。
  3. 挂载GameManager:在Canvas节点或一个专门的空节点(如GameRoot)上挂载GameManager脚本。同样,需要将enemyPrefab属性绑定到Enemy.prefab,将scoreLabelhealthLabel属性分别拖拽绑定到场景中对应的UILabel节点上。
  4. 配置敌人和子弹:对于Enemy.prefabBullet.prefab,你需要分别打开它们(双击预制体文件),然后在根节点上挂载EnemyCtrlBulletCtrl脚本,并设置相应的速度、生命值等属性。修改预制体后,记得保存(Ctrl+S)。

5.2 连接最后的拼图:玩家受伤

目前,敌人会移动并碰撞玩家,但玩家还没有受伤的逻辑。我们需要修改PlayerCtrl脚本,监听与敌人的碰撞。 在PlayerCtrl.tsstart方法中添加碰撞监听:

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 常见问题排查与调试技巧

即使按照步骤操作,第一次运行时也难免遇到问题。这里是一些常见坑点和排查方法:

  1. 节点找不到或为null

    • 现象:控制台报错Cannot read property 'xxx' of null
    • 排查:检查属性绑定是否正确。在属性检查器中确认@property引用的节点或预制体是否成功关联(显示为蓝色链接)。对于通过代码查找的节点(如getChildByName),确认节点名称拼写无误,且在该时间点节点已存在于场景树中。
  2. 碰撞检测不生效

    • 现象:子弹穿过敌人,没有触发任何效果。
    • 排查步骤
      • 第一步:检查碰撞分组。确保子弹和敌人的碰撞体Group属性分别设置为BulletEnemy
      • 第二步:检查碰撞矩阵。在项目设置->物理->碰撞矩阵中,确认Bullet行和Enemy列的交叉点是否被勾选。
      • 第三步:检查刚体类型。确保子弹和敌人的刚体Type不是Static(静态)。静态刚体不会因碰撞而运动,但通常能检测到碰撞。DynamicKinematic都可以。
      • 第四步:检查碰撞体尺寸。在场景编辑器中勾选显示物理调试(通常在场景编辑器工具栏),可以看到碰撞体的轮廓(绿色线框)。确保碰撞体大小合适且彼此能接触。
      • 第五步:检查监听事件。确认在脚本的startonLoad中正确注册了BEGIN_CONTACT事件。
  3. 子弹方向错误

    • 现象:子弹不朝鼠标方向发射,或者朝奇怪的角度飞。
    • 排查:检查BulletCtrl中速度向量的计算。确认你使用的方向向量(如this.node.up)与你的子弹资源美术朝向匹配。在start中打印一下this.node.upthis.node.right的值看看。同时,检查PlayerCtrl中玩家朝向的计算是否正确。
  4. 敌人生成位置不对或看不到

    • 现象:敌人生成在屏幕内奇怪的地方,或者根本看不到。
    • 排查:检查GameManager_spawnEnemy方法的坐标计算。spawnRange的值是否合理(应大于屏幕半宽/高)。生成的位置是相对于GameManager节点本身的。如果GameManager节点不在世界原点,需要调整坐标计算。可以在生成后打印一下enemyNode.position确认。
  5. 游戏运行卡顿

    • 现象:随着敌人增多,游戏越来越卡。
    • 排查:这可能是性能问题。首先,确保子弹和敌人在销毁时(destroy)确实被移除了。其次,检查是否有内存泄漏,比如事件监听没有在onDestroy中移除。最后,可以考虑使用对象池(Object Pool)来管理子弹和敌人的频繁创建与销毁,这是游戏开发中优化性能的必备技巧。

调试利器

  • 浏览器开发者工具:在浏览器中运行游戏(Cocos Creator提供预览功能),按F12打开。Console标签页查看日志和报错。Sources标签页可以打断点调试TypeScript代码。
  • Cocos Creator控制台:编辑器下方的控制台面板会显示所有日志、警告和错误,是首要查看的地方。
  • 物理调试绘制:如前所述,在场景编辑器打开物理调试,可视化碰撞体。

当你一步步解决了上述问题,看到玩家在场景中移动、旋转,发射子弹击退来袭的幽灵,分数随之上涨时,“幽灵射手”项目的核心框架就算成功搭建起来了。这个过程中,你实践了从场景搭建、资源管理、物理配置、脚本编写到调试排错的全流程。这不仅仅是完成了一个小游戏,更是构建起了使用Cocos Creator进行游戏开发的基础认知和肌肉记忆。后续的章节,我们会在此基础上,深入动画系统、UI系统、音频管理、数据持久化以及更复杂的游戏机制,让你手中的这个“幽灵射手”变得越来越丰满、精致。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询