制作第一个Cocos Creator 3D 游戏

在本节中,我们将向您介绍 Cocos Creator 中的一些 3D 功能,以及如何使用它们制作简单但完整的平台跳跃游戏。 以下教程展示了一个名为“注意你的脚步”的小游戏。这个游戏测试用户的反应速度,根据路面选择是跳一步还是两步。

制作第一个Cocos Creator 3D 游戏
推荐:将NSDT场景编辑器加入你的3D工具链
3D工具链:NSDT简石数字孪生

快速入门:制作您的第一个 3D 游戏

在本节中,我们将向您介绍 Cocos Creator 中的一些 3D 功能,以及如何使用它们制作简单但完整的平台跳跃游戏。

以下教程展示了一个名为“注意你的脚步”的小游戏。这个游戏测试用户的反应速度,根据路面选择是跳一步还是两步。

您可以在这里体验游戏的完成形式。

添加播放器

大多数游戏总是需要一个可控制的角色。在这里,我们将使我们在游戏中的主角,胶囊小姐/胶囊先生。

为了方便生产,让我们在这里回顾一下如何在编辑器中创建节点。

层次结构.png

“层次结构”面板将演示场景中的所有节点,您可以通过右键单击“层次结构”面板的弹出菜单来创建新节点。

创建播放器节点

首先,您需要创建一个名为“玩家”的空节点,然后创建另一个名为“Body”的节点来表示角色的模型节点。为了简单起见,我们使用内置胶囊模型作为我们的主角。

创建玩家节点

你可能会注意到,我们的播放器分为两个节点,划分的好处是当我们水平移动 Player 节点时,这个移动不会影响 Body 节点上的垂直动画(比如跳起来和跳下来)。整个跳跃动画与水平移动和垂直跳跃-下降动画相结合。

然后将 Player 节点置于 (0, 0, 0),以便它可以站在第一个块上。效果如下:

创建播放器

编写主角脚本

要使角色通过鼠标事件移动,需要编写一些自定义脚本。即使您没有任何编程经验,也不要担心,我们将提供所有代码,您需要做的就是将所有这些代码复制并粘贴到正确的位置。当然,我们将解释提供的所有代码,以帮助您尽快开始使用 Cocos Creator。

如何创建脚本组件

  1. 如果尚未创建脚本文件夹,请先右键单击资产中的资产文件夹,选择创建 -> 文件夹菜单以创建新文件夹,然后使用“脚本”重命名;
  2. 右键单击“脚本”文件夹,然后在弹出菜单中选择“创建 -> TypeScript -> NewComponent”以创建新的 TypeScript 组件。更多参考资料可以在官方 TypeScript 上找到。

将创建的组件重命名为并双击脚本文件以打开任何代码编辑器(例如 VSCode)。PlayerController

创建播放器脚本
注意:Cocos Creator 中的脚本名称区分大小写!如果大小写不正确,则不能根据组件的名称使用组件。

关于重命名:如果错误地输入了组件的名称,则需要更改文件名、类名和装饰器名称才能更正它。因此,如果您不熟悉整个操作,请考虑删除该文件并重新创建它。

编写脚本代码

在打开的脚本中,已经有一些预设的代码块,如下所示:PlayerController

import { _decorator, Component } from 'cc';
const { ccclass, property } = _decorator;

@ccclass("PlayerController")
export class PlayerController extends Component {
    /* class member could be defined like this */
    // dummy = '';

    /* use the `property` decorator if you want the member to be serializable */
    // @property
    // serializableDummy = 0;

    start () {
        // Your initialization goes here.
    }

    // update (deltaTime: number) {
    //     // Your update function goes here.
    // }
}

上面的代码演示了组件(脚本)的必要结构。任何从 继承的类都称为组件,可以附加到场景中的任何节点以控制节点的行为,有关更多详细信息,请参阅脚本。cc.Component

接下来,让我们优化代码,以便字符可以实际移动。PlayerController

侦听输入

我们需要监听计算机输入(鼠标、键盘或操纵杆等)来操纵游戏中的角色,而在 Cocos Creator 中,您可以通过聆听事件来做到这一点。input

input.on(Input.EventType.MOUSE_UP, this.onMouseUp, this);

上面的代码演示了我们如何监听 Input-System() 的鼠标向上事件(),当发出鼠标向上事件时,将调用此脚本中的 。MOUSE_UPinputonMouseUp

通常,我们会将所有初始代码放在组件的方法中,以确保输入事件可以在初始化后立即正确侦听。start

该方法的调用指示组件已正确初始化,您可以放心地使用它。start

因此,可以看到以下代码。

import { _decorator, Component, input, Input, EventMouse } from 'cc';
const { ccclass, property } = _decorator;

@ccclass("PlayerController")
export class PlayerController extends Component {

    start () {
        // Your initialization goes here.
        input.on(Input.EventType.MOUSE_UP, this.onMouseUp, this);
    }

    onMouseUp(event: EventMouse) {


    }
}

进行角色移动

为了使我们的播放器移动,我们必须向播放器添加一些额外的属性来描述它。想想高中物理课上关于立方体或球的一些问题。是的,事实上,我们改变了位置、速度和其他因素,以实现游戏开发中的可移动角色。

在当前的游戏中,我们想要实现这个行为:用户按下鼠标左/右键 -> 确定它是一/两步的位移 ->根据输入向前移动角色,直到到达目的地点。

因此,我们可以向脚本添加一个属性,如下所示:

// Whether to receive the jump command
private _startJump: boolean = false;

使用像 这样的布尔变量,我们可以标记当前字符是否在跳转中,以帮助我们区分方法中的不同分支逻辑。因为很明显,当没有收到输入时,我们不需要移动字符。_startJumpupdate

问:为什么我们在方法中处理它?update

答:该方法将由引擎在特定时间间隔内调用。如果我们游戏的 FPS 为 60(每秒 60 个渲染帧),那么该方法将每秒调用 60 次。我们可以尽可能多地模拟逼真的连续行为。updateupdateupdate

因此,我们可以按如下方式更改代码:

import { _decorator, Component, input, Input, EventMouse } from 'cc';
const { ccclass, property } = _decorator;

@ccclass("PlayerController")
export class PlayerController extends Component {

    // Whether to receive the jump command
    private _startJump: boolean = false;
    // The jump step count
    private _jumpStep: number = 0;

    start () {
        // Your initialization goes here.
        input.on(Input.EventType.MOUSE_UP, this.onMouseUp, this);
    }

    onMouseUp(event: EventMouse) {


    }

    update(dt: number): void {
        if( this._startJump){ // Handle branching logic of jump

        }
    }
}

很好,这里我们已经完成了播放器控制器的主框架,所以我们只需要考虑如何在方法中移动播放器。update

在牛顿力学中,我们已经了解到,如果一个物体要以匀速运动,那么它的位置必须像下面这样:

P_1 = P_0 + v*t

换句话说,我们可以将物体的当前位置加上速度乘以时间,然后我们可以得到新位置。

在我们的游戏中,当按下鼠标按钮时,如果跳跃步数为1,角色将直接移动1个单位,如果跳跃步数为2,则直接移动2个单位。因此,我们可以使用以下公式计算字符(_targetPos)的目标位置:

_targetPos = _curPos + step

可以看出,上面的公式包含三条信息:_targetPos、_curPos和 step,所以我们将这些属性记录在脚本中,以便在方法中使用它们。update

在组件中添加这些属性,如下所示:PlayerController

// The jump step
private _jumpStep: number = 0;
// Current position of the character
private _curPos: Vec3 = new Vec3();
// The target position of the character
private _targetPos: Vec3 = new Vec3();

的代码应如下所示:PlayerController

import { _decorator, Component, input, Input, EventMouse, Vec3 } from 'cc';
const { ccclass, property } = _decorator;

@ccclass("PlayerController")
export class PlayerController extends Component {

    // Whether to receive the jump command
    private _startJump: boolean = false;
    // The jump step
    private _jumpStep: number = 0;
    // Current position of the character
    private _curPos: Vec3 = new Vec3();
    // The target position of the character
    private _targetPos: Vec3 = new Vec3();
    start () {
        // Your initialization goes here.
        input.on(Input.EventType.MOUSE_UP, this.onMouseUp, this);
    }

    onMouseUp(event: EventMouse) {


    }

    update(dt: number): void {
        if( this._startJump){

        }
    }
}

我们的下一个任务是完成如何通过计算机化来获取这些信息。

首先,我们应该检查用户是否点击了鼠标右键或鼠标左键。

可以通过使用 of 获取方法中的鼠标按钮信息来进行这些确定。在这种情况下,如果单击左按钮,将返回 0,如果单击右按钮,将返回 2。onMouseUpgetButtonEventMouseEventMousegetButton

因此,让我们按如下方式扩展该方法:onMouseUp

onMouseUp(event: EventMouse) {
    if (event.getButton() === 0) {

    }
    else if (event.getButton() === 2) {

    }
}

向组件添加一个新方法,以帮助我们计算角色的目标位置和速度。由于输入步骤可能是1或2个步骤,所以我们在方法中添加一个数值参数以实现更好的可重用性。jumpByStepPlayerControllerstepjumpByStep

可 重用?这样的概念听起来很复杂! 不要紧张,这只是一些工程术语,当我们第一次学习它时,我们可以忽略它。 这只是为了更清楚地解释为什么我们添加了一个名为 .jumpByStep

因此,该方法如下所示:

jumpByStep(step: number) {

}

我们使用参数来指示跳跃步骤。step

在我们的游戏中,跳跃(升起和下降)必须是一个完整的步骤,除非跳跃过程完成,否则我们不接受任何输入,因此我们在跳跃时会忽略角色的输入。_startJump

之后,我们将计算角色在给定时间内从当前位置移动到目的地的速度是多少。

计算过程如下所示:

  1. 计算当前速度(_curJumpSpeed)
  2. 计算目标位置(_targetPos)

为了正确退出分支逻辑,我们必须记住开始跳转的时间(_curJumpTime),因为当这段时间过去时,我们认为整个跳转过程已经完成。if( this._startJump)

现在该方法如下所示:jumpByStep

jumpByStep(step: number) {
    if (this._startJump) {
        return;
    }
    this._startJump = true; // Whether to start to jump
    this._jumpStep = step; // Jump steps for this time
    this._curJumpTime = 0; // Reset the jump time
    this._curJumpSpeed = this._jumpStep / this._jumpTime; // Current jump step
    this.node.getPosition(this._curPos); // use 'getPosition` to get the current position of the character
    // target position = current position + steps
    Vec3.add(this._targetPos, this._curPos, new Vec3(this._jumpStep, 0, 0));  
}
  • 问:什么是 ?Vec3.add
  • Vec3是 Cocos Creator 中的一个 3D 矢量类,用于记录和计算 3D 矢量。
  • add是一个在中添加两个 3D 向量的方法,我们可以注意到没有,因为是一个静态方法Vec3thisadd
  • Vec3.add(this._targetPos, this._curPos, new Vec3(this._jumpStep, 0, 0));这个代码块表明我们使用目标位置()来存储当前位置()的加法结果和一个新的vector(),它代表x轴上的跳跃步长。_targetPos_curPosnew Vec3(this._jumpStep, 0, 0)

当然,我们可以简单地在 X 轴上移动角色,但实际上,在 3D 游戏开发中,物体可能会沿着 3 个不同的轴移动,你越熟悉 3D 矢量的使用,我们掌握 3D 引擎的速度就越快。

此时,我们的代码可能如下所示:

import { _decorator, Component, input, Input, EventMouse, Vec3 } from 'cc';
const { ccclass, property } = _decorator;

@ccclass("PlayerController")
export class PlayerController extends Component {

    // Whether received jump command
    private _startJump: boolean = false;
    // The jump step count
    private _jumpStep: number = 0;
    // Current jump time
    private _curJumpTime: number = 0;
    // Total jump time
    private _jumpTime: number = 0.1;
    // current jump speed
    private _curJumpSpeed: number = 0;
    // current position of the
    private _curPos: Vec3 = new Vec3();
    // The difference of the current frame movement position during each jump
    private _deltaPos: Vec3 = new Vec3(0, 0, 0);
    // Target position of the character
    private _targetPos: Vec3 = new Vec3();

    start () {
        // Your initialization goes here.
        input.on(Input.EventType.MOUSE_UP, this.onMouseUp, this);
    }

    onMouseUp(event: EventMouse) {
        if (event.getButton() === 0) {

        }
        else if (event.getButton() === 2 ) {

        }
    }

    jumpByStep(step: number) {
        if (this._startJump) {
            return;
        }
        this._startJump = true;
        this._jumpStep = step;
        this._curJumpTime = 0;
        this._curJumpSpeed = this._jumpStep / this._jumpTime;
        this.node.getPosition(this._curPos);
        Vec3.add(this._targetPos, this._curPos, new Vec3(this._jumpStep, 0, 0));
    }


    update(dt: number): void {
        if( this._startJump){

        }
    }
}

令人着迷的是,现在我们有了计算角色操作所需的所有数据,然后我们可以在方法中移动它。update

还记得我们上面谈到的牛顿问题吗?是时候真正解决这个问题了。

首先,我们需要检查整个跳跃持续时间(_curJumpTime)是否超过了我们预定义的时间(_jumpTime)。

这里的测试很简单:我们在输入时将_curJumpTime设置为 0,因此只需要:update

Jump time = last jump time + frame interval

该方法恰好提供一个参数,例如帧间隔。updatedeltaTime

所以我们只是添加,就是这样:_curJumpTimedeltaTime

update (deltaTime: number) {
    if (this._startJump) {
        this._curJumpTime += deltaTime;        
    }
}
您可以看到我们使用 '+=' 运算符,它等效于 .this._curJumpTime = this._curJumpTime + deltaTime

当大于我们预定义的 时,这意味着跳转结束,因此我们需要两个分支分别处理跳转结束和跳转状态:_curJumpTime_jumpTime

update (deltaTime: number) {
    if (this._startJump) {
        this._curJumpTime += deltaTime; 
        if (this._curJumpTime > this._jumpTime) { // Jump ends


        } else { // Jumping

        }
    }
}

最后,我们优化不同分支的逻辑:

  • 跳跃结束:结束跳跃过程,迫使角色移动到目标位置
  • 跳跃时:根据速度向前移动角色

所以我们的方法将看起来像这样:update

update (deltaTime: number) {
    if (this._startJump) {
        this._curJumpTime += deltaTime.
        if (this._curJumpTime > this._jumpTime) { // end of jump
            // end
            this.node.setPosition(this._targetPos); // force move to target position
            this._startJump = false; // mark the end of the jump
        } else { // jump in progress
            // tween
            this.node.getPosition(this._curPos); // Get the current position 
            this._deltaPos.x = this._curJumpSpeed * deltaTime; // calculate the length of this frame that should be displaced
            Vec3.add(this._curPos, this._curPos, this._deltaPos); // add the current position to the length of the displacement
            this.node.setPosition(this._curPos); // set the position after displacement
        }
    }
}

伟大!你已经完成了这个游戏的核心代码——角色控制。

如果您仍然觉得此解释有点困难,请单击“获取帮助和支持”告知我们。

完整的播放器控制器

我们已经有了完整的 PlayerController 代码,因此我们只需要将其附加到节点即可使其工作。

如果仍然觉得困难,请尝试将以下代码复制并粘贴到项目中的 PlayerController.ts 文件中。

import { _decorator, Component, Vec3, input, Input, EventMouse, Animation } from 'cc';
const { ccclass, property } = _decorator;

@ccclass("PlayerController")
export class PlayerController extends Component {
    /* class member could be defined like this */
    // dummy = '';

    /* use the `property` decorator if you want the member to be serializable */
    // @property
    // serializableDummy = 0;

    // for fake tween
    // Whether the jump command is received or not
    private _startJump: boolean = false.
    // Jump step length
    private _jumpStep: number = 0.
    // current jump time
    private _curJumpTime: number = 0.
    // length of each jump
    private _jumpTime: number = 0.1.
    // current jump speed
    private _curJumpSpeed: number = 0.
    // current character position
    private _curPos: Vec3 = new Vec3().
    // the difference in position of the current frame movement during each jump
    private _deltaPos: Vec3 = new Vec3(0, 0, 0).
    // target position of the character
    private _targetPos: Vec3 = new Vec3().

    start () {
        // Your initialization goes here.
        input.on(Input.EventType.MOUSE_UP, this.onMouseUp, this);
    }

    onMouseUp(event: EventMouse) {
        if (event.getButton() === 0) {
            this.jumpByStep(1);
        }
        else if (event.getButton() === 2) {
            this.jumpByStep(2);
        }

    }

    jumpByStep(step: number) {
        if (this._startJump) {
            return;
        }
        this._startJump = true;
        this._jumpStep = step;
        this._curJumpTime = 0;
        this._curJumpSpeed = this._jumpStep / this._jumpTime;
        this.node.getPosition(this._curPos);
        Vec3.add(this._targetPos, this._curPos, new Vec3(this._jumpStep, 0, 0));
    }

    update (deltaTime: number) {
        if (this._startJump) {
            this._curJumpTime += deltaTime;
            if (this._curJumpTime > this._jumpTime) {
                // end
                this.node.setPosition(this._targetPos);
                this._startJump = false;
            } else {
                // tween
                this.node.getPosition(this._curPos);
                this._deltaPos.x = this._curJumpSpeed * deltaTime;
                Vec3.add(this._curPos, this._curPos, this._deltaPos);
                this.node.setPosition(this._curPos);
            }
        }
    }
}

现在我们可以 将组件添加到 角色节点 .在“层次结构”面板上选择节点,然后单击“检查器”面板上的“添加组件”按钮,然后选择“自定义脚本”-“> PlayerController ”将其添加到节点中。PlayerControllerPlayerPlayerPlayer

添加播放器控制器组合

要在运行时在屏幕上正确显示内容,我们需要调整摄像机的一些参数,选择主摄像机节点,并将其更改为 (0, 0, 13) 和 (50,90,255,255):PositionColor

相机设置

然后点击 预览 顶部工具栏中心的按钮。

“播放”按钮

通过在打开的浏览器中单击鼠标左键或鼠标右键,您可能会看到以下屏幕:

玩家移动

有关更多预览功能,请参阅 Project Preview & Debugging。

添加角色动画

我们可以注意到,从上述执行的结果来看,角色的水平移动是乏味的。为了让角色感觉像跳跃,你可以添加一个垂直动画。有关动画编辑器的更多信息,请参阅动画系统。

在“层次结构”面板中选择节点,然后单击编辑器底部动画编辑器”中的“添加动画剪辑”按钮,以创建动画剪辑并将其命名。BodyonStep

玩家移动

进入动画编辑模式,为创建的剪辑添加属性轨迹,然后添加三个关键帧,每个关键帧的位置值应为(0, 0, 0), (0, 0.5, 0), (0, 0, 0);position

添加关键帧
注意:退出动画编辑模式时记得保存动画剪辑,否则您的工作将丢失。

我们可以使用资源管理器来创建剪辑。右键单击“资源”面板以创建名为 的新动画剪辑,并将其添加到节点的属性中。为了使录制过程更容易,我们可以调整编辑器的布局。twoStepAnimationBody

注意:请注意,当动画剪辑无法拖动到属性时,请检查包含中的导入部分。Animationimport {...} from "cc"PlayerControllerAnimation
从资产添加动画

进入动画编辑模式,在下拉菜单中选择剪辑,如步骤2,然后添加属性的三个关键帧,分别是(0,0,0),(0,1,0),(0,0,0)。twoStepPosition

编辑第二个剪辑

导入并引用 中的组件,因为我们需要根据不同的步骤播放不同的动画剪辑。AnimationPlayerController

首先,将节点的组件引用到 。AnimationBodyPlayerController

 @property({type: Animation})
 public BodyAnim: Animation | null = null;

将节点拖动到“检查器”面板的属性中。BodyAnimation

拖动到动画复合

将以下代码添加到跳转方法以播放动画。jumpByStep

 if (this.BodyAnim) {
     if (step === 1) {
         this.BodyAnim.play('oneStep');
     } else if (step === 2) {
         this.BodyAnim.play('twoStep');
     }
 }

点击 预览 按钮通过单击鼠标左键或右键在打开的浏览中查看新的跳跃动画。

使用跳转预览

跑道升级

为了让我们的游戏更有趣,我们需要一条很长的跑道供玩家前进。复制大量 Cube 节点显然不是一个好主意,我们可以通过脚本自动创建跑道。

游戏管理器

通常,一个场景包含各种功能和不同类型的节点,存储在场景中的所有节点都可以作为我们游戏中最重要的数据。有时我们需要动态创建、访问和删除这些节点。虽然我们可以在 Cocos Creator 中按方法找到一个节点,但实际上该方法必须枚举场景中的所有节点,这将导致精度低。在商业开发中,我们使用一些单个组件来管理某种节点或我们称之为管理器的某些节点数据。findfind

例如,在一个游戏中,我们可能有很多角色,我们可能要创建一个新角色,删除已经死亡的角色,或者查询一个角色的状态,在这种情况下,我们可以创建一个命名的类来管理所有角色并支持上述功能。ActorManager

在这个游戏中,我们声明了一个名为GameManager的新节点组件来管理所有相关节点或数据。

在这种情况下,我们将所有功能(例如跑道生成和删除)放在 GameManager 中,以使数据收集更加方便。

这种凝聚力在游戏开发中很常见,当您看到其他一些组件(例如ConfigManager或NetworkManager)时,请不要感到惊讶。

当然,在复杂的游戏中还有更复杂的结构。这些复杂而巨大的设计概念可以提高我们应用程序的稳定性和可维护性。

因此,让我们继续创建这样一个管理器。

创建游戏管理器

  1. 右键单击“层次结构”面板以创建名为 的新节点。GameManager
  2. 右键单击“资源”面板上的文件夹,创建名为 的 TypeScript 组件。assets/ScriptsGameManager
  3. 将组件添加到“游戏管理器”节点。GameManager

创建预制件

对于需要复制的节点,我们可以将它们作为动态资产的模板保存到预制件资产中。

预制件是引擎的内置资产类型,其主要目的是提供实例化某种节点的可能性。想象一下,如果我们的游戏中有 1000 个敌人,单独创建它们需要花费大量时间,为了解决这个问题,我们可以动态克隆预制件 1000 次。

将基本立方体节点拖动到“资源”面板以创建预制件。

创建多维数据集预制件

添加自动跑道创建代码

玩家需要一条长跑道,理想的方法是动态增加跑道的长度,以便它可以永远运行。这里为了方便起见,首先我们生成一条固定长度的跑道,跑道长度可以自己定义。此外,我们可以在跑道上生成一些坑,当玩家跳到坑时,我们可以在 GameOver 上生成一些坑。

首先,我们需要定义一些常量来指示当前坐标是代表坑还是岩石。在 Typescript 中,这可以通过枚举来完成。

通过使用关键字 + 枚举的名称,我们定义了以下枚举,其中表示一个坑,表示一个可行走的表面。enumBT_NONEBT_STONE

enum BlockType{
    BT_NONE,
    BT_STONE, 
};

同样,我们需要定义游戏管理器将使用哪些预制件或主体来创建地图。

// The runway prefab
@property({type: Prefab})
public cubePrfb: Prefab | null = null;

编辑器将识别为预制件,以便我们可以在检查器面板中将 Cube.prefab 分配给此属性。@property({type: Prefab})

像这样的语法称为装饰器。Cocos Creator 中有各种类型的装饰器可以使用它来自定义各种效果来装饰自定义组件。@property

为了更容易快速确定当前位置是立方体还是坑,我们使用数组描述整个轨道

_road: BlockType[] = [];

因此,定义了一个基本的游戏管理器,它应该看起来像这样:

import { _decorator, Component, Prefab } from 'cc';
const { ccclass, property } = _decorator;

// Track grid type, pit (BT_NONE) or solid road (BT_STONE)
enum BlockType {
    BT_NONE,
    BT_STONE,
};

@ccclass("GameManager")
export class GameManager extends Component {

    // The runway prefab
    @property({type: Prefab})
    public cubePrfb: Prefab | null = null;
    // Total road length
    @property
    public roadLength = 50;
    private _road: BlockType[] = [];

    start () {
        this.generateRoad();
    }

    generateRoad() {

    }
}

然后我们按照以下原则生成整个跑道。

  • 跑道上的第一个位置必须BT_STONE,以确保玩家不会在游戏开始时掉落。
  • 在第一个位置之后,我们将随机生成一个立方体或一个坑。考虑到我们角色跳跃能力差(最多2步),我们无法生成2个或连续移动坑,所以当我们生成新的位置时,我们应该检查之前的位置是岩石还是坑,如果是坑,我们必须在当前位置放一块石头。
  • 基于以上两个原理,我们将生成的区块保存到数组中,以快速查询某个位置是岩石还是坑。_road
  • 最后,我们根据道路信息将整条道路实例化到场景中,完成整个地图。_road

因此,生成映射的方法如下:generateRoad

generateRoad() {
    // Prevent the track from being the old track when the game is restarted
    // Therefore, the old track needs to be removed and the old track data cleared
    this.node.removeAllChildren();
    this._road = [];
    // Make sure that the character is standing on the real road when the game is running
    this._road.push(BlockType.BT_STONE);

    // Determine the type of track for each frame
    for (let i = 1; i < this.roadLength; i++) {
        // If the previous track is a pit, then this one must not be a pit
        if (this._road[i-1] === BlockType.BT_NONE) {
            this._road.push(BlockType.BT_STONE);
        } else {
            this._road.push(Math.floor(Math.random() * 2));
        }
    }

    // Generate tracks based on runway
    for (let j = 0; j < this._road.length; j++) {
        let block: Node = this.spawnBlockByType(this._road[j]);
        // Determine if a road was generated, as spawnBlockByType may return a pit (with a value of null)
        if (block) {
            this.node.addChild(block);
            block.setPosition(j, -1.5, 0);
        }
    }
}
Math.floor:这个方法是 Typescript 数学库的方法之一:我们知道 floor 意味着 floor,这意味着取此方法参数的“floor”,即向下舍入。:再次随机是标准数学库的方法之一,用于随机化 0 到 1 之间的十进制数,请注意值的范围是 [0, 1)。 所以简单地意味着它从 [0, 2) 中取一个随机数并向下舍入到 0 或 1,这对应于枚举中声明的 和 。 顺便说一下,在 Typescript 枚举中,如果不为枚举赋值,则枚举值将从 0 开始按顺序赋值。Math.randomMath.floor(Math.random() * 2)BT_NONEBT_STONEBlockType

生成石头的方法如下:spawnBlockByType

spawnBlockByType(type: BlockType) {
    if (!this.cubePrfb) {
        return null;
    }

    let block: Node | null = null;
    // The runway is generated only for real roads
    switch(type) {
        case BlockType.BT_STONE:
            block = instantiate(this.cubePrfb);
            break;
    }

    return block;
}

我们可以注意到,当块类型为 Stone 时,我们将使用该方法克隆新的预制件,另一方面,如果块类型不是 Stone,则我们不执行任何操作。instantiate

instantiate是在 Cocos Creator 中克隆预制件的方法。您不仅可以使用它来克隆预制件,还可以克隆其他类型的对象!

的整个代码如下:GameManager

import { _decorator, Component, Prefab, instantiate, Node, CCInteger } from 'cc';
const { ccclass, property } = _decorator;

// The runway type, pit (BT_NONE) or solid road (BT_STONE)
enum BlockType {
    BT_NONE,
    BT_STONE,
};

@ccclass("GameManager")
export class GameManager extends Component {

    // The stone prefab
    @property({type: Prefab})
    public cubePrfb: Prefab | null = null;
    // Length of the runway
    @property
    public roadLength = 50;
    private _road: BlockType[] = [];

    start () {
        this.generateRoad();
    }

    generateRoad() {
        // Prevent the track from being the old track when the game is restarted
        // Therefore, the old track needs to be removed and the old track data cleared
        this.node.removeAllChildren();
        this._road = [];
        // Make sure that the character is standing on the real road when the game is running
        this._road.push(BlockType.BT_STONE);

        // Determine the type of track for each frame
        for (let i = 1; i < this.roadLength; i++) {
            // If the previous track is a pit, then this one must not be a pit
            if (this._road[i-1] === BlockType.BT_NONE) {
                this._road.push(BlockType.BT_STONE);
            } else {
                this._road.push(Math.floor(Math.random() * 2));
            }
        }

        // Generate tracks based on runway
        for (let j = 0; j < this._road.length; j++) {
            let block: Node = this.spawnBlockByType(this._road[j]);
            // Determine if a road was generated, as spawnBlockByType may return a pit (with a value of null)
            if (block) {
                this.node.addChild(block);
                block.setPosition(j, -1.5, 0);
            }
        }
    }

    spawnBlockByType(type: BlockType) {
        if (!this.cubePrfb) {
            return null;
        }

        let block: Node | null = null;
        // The runway is generated only for real roads
        switch(type) {
            case BlockType.BT_STONE:
                block = instantiate(this.cubePrfb);
                break;
        }

        return block;
    }

    // update (deltaTime: number) {
    //     // Your update function goes here.
    // }
}

将预制的 Cube.prefab 拖到“检查器”面板的 CubePrfb 属性上。

分配多维数据集预制件

您可以更改“检查器”面板的 roadLength 属性来控制跑道的长度。

现在,点击 预览 按钮在打开的浏览器中查看自动创建的跑道。

以玩家为中心的摄像机

我们可以注意到摄像机不跟随播放器节点,因此我们看不到它后面的跑道。我们可以通过将摄像机设置为播放器的子节点来避免这种情况。

将摄像机拖动到播放器

因此,摄像机将跟随玩家的动作,现在通过单击“预览”按钮,我们可以观看整个跑道。

添加“开始”菜单

用户界面(UI)是游戏的重要组成部分,开发人员可以通知用户一些重要信息,例如数据,状态等,以便用户可以制定策略。我们还可以添加游戏名称、介绍、制作人员和其他信息。

请参考 2D/UI 等相关文档,以帮助了解 UI 作为 2D 部件的机制。

然后,我们将以“开始”菜单为例描述一个 UI 工作流。

在“层次结构”面板中创建一个新的“按钮”节点,并将其重命名为播放按钮”

“创建”按钮

可以注意到,引擎会在“层次结构”面板中自动创建一个“画布”节点、一个“播放器”按钮节点和一个标签”节点。这是因为任何包含 UI 组件的节点只有在节点下时才可见,因此编辑器将创建一个 Canvas,以防没有。Canvas

将标签节点中组件的字符串属性从“按钮”更改为“播放”。cc.Label

您可能会注意到,当您创建 UI 节点时,Cocos Creator 会自动在“画布”下添加新的相机节点。 这是《Cocos Creator》中的一项新功能。新的摄像机将负责UI/2D部分,因此引擎的渲染管线可以更有效地处理UI/2D内容。

在“画布”节点下创建一个名为“开始菜单”的空节点,并将“播放按钮”拖到该节点下。我们可以通过单击场景工具栏上的 2D/3D 按钮切换到 2D 编辑视图来编辑 UI。有关场景编辑的更多详细信息,请参阅场景面板。

2D 视图

创建一个新的精灵节点,命名为 作为背景图像,并将其拖到播放按钮上。BGStartMenu

创建BG精灵

然后在“检查器”面板中将组件的属性设置为 (200, 200),并将资源拖动到其 SpriteFrame 属性。ContentSizecc.UITransforminternal/default_ui/default_sprite_splash

更改精灵框架

在“层次结构”面板上的“开始菜单”节点下创建一个名为“标题”的“标签”节点,并将其用作“开始”菜单的标题。

添加标题标签

如下图所示,调整“检查器”面板上的“标题”节点的其他属性,例如“打开”、“”、“”等“等:PositionColorStringFontSize

修改标题

添加所需的“提示”节点并调整“播放按钮”后,将完成一个简单的开始菜单。

修改标题

添加游戏状态

对于大多数游戏,我们可以大致将其分解为 3 种不同的状态:初始化、播放和结算。就像在任何棋盘游戏中一样,放置棋子的过程称为初始化;演奏的过程称为演奏;当两个玩家完成游戏以解决胜利/失败时,最终状态称为解决状态。

同样,我们可以使用枚举来定义游戏状态。

enum GameState{
    GS_INIT,
    GS_PLAYING,
    GS_END,
};
  • GS_INIT(初始化):显示游戏菜单并初始化部分资源。
  • GS_PLAYING(播放):隐藏游戏菜单,用户可以开始控制角色。
  • GS_END(结束):结束游戏并显示开始菜单。

通过读取枚举可以轻松通知当前游戏状态,并且我们可以在状态更改时处理一些逻辑。GameState

为了允许用户在游戏状态而不是游戏初始化中控制角色,我们必须自动启用/禁用侦听鼠标事件。因此,请按如下方式修改脚本:PlayerController

start () {
    // Your initialization goes here.
    // input.on(Input.EventType.MOUSE_UP, this.onMouseUp, this);
}

setInputActive(active: boolean) {
    if (active) {
        input.on(Input.EventType.MOUSE_UP, this.onMouseUp, this);
    } else {
        input.off(Input.EventType.MOUSE_UP, this.onMouseUp, this);
    }
}

并在脚本中引用脚本。PlayerControllerGameManager

@property({type: PlayerController})
public playerCtrl: PlayerController | null = null;

保存脚本后返回 Cocos Creator,将附加脚本的 Player 节点拖动到检查器面板中 GameManager 节点的属性中。PlayerControllerplayerCtrl

同时,我们需要在脚本中引用 StartMenu 节点来动态启用/禁用开始菜单。GameManager

@property({type: Node})
public startMenu: Node | null = null;

保存所有脚本后返回编辑器,并将“开始菜单”节点拖动到“检查器”面板上“游戏管理器”节点的属性中。startMenu

将玩家添加到游戏管理器

添加状态更改代码

将开关状态代码添加到游戏管理器脚本,并将初始化代码添加到方法中。start

start () {
    this.curState = GameState.GS_INIT;
}

init() {
    // Active start menu
    if (this.startMenu) {
        this.startMenu.active = true;
    }
    // Generate the runway
    this.generateRoad();
    if(this.playerCtrl){
        // Disable user input
        this.playerCtrl.setInputActive(false);
        // Reset the player's position
        this.playerCtrl.node.setPosition(Vec3.ZERO);
    }
}

set curState (value: GameState) {
    switch(value) {
        case GameState.GS_INIT:
            this.init();
            break;
        case GameState.GS_PLAYING:
            if (this.startMenu) {
                this.startMenu.active = false;
            }
            // When active is set to true, it will start listening for mouse events directly, while mouse up events are not yet dispatched
            // What will happen is that the character will start moving the moment the game starts
            // Therefore, a delay is needed here
            setTimeout(() => {
                if (this.playerCtrl) {
                    this.playerCtrl.setInputActive(true);
                }
            }, 0.1);
            break;
        case GameState.GS_END:
            break;
    }
}

这里我们可以看到在新添加的方法中,它初始化跑道,激活开始菜单,同时禁用用户输入。无论如何,当游戏状态更改为 .initGS_INIT

像这样的访问器第一次让我们在设置游戏状态时更加安全。set curState

将事件侦听器添加到“播放”按钮

为了在单击“播放”按钮后立即开始游戏,我们需要侦听其单击事件。在脚本中添加以下响应代码,作为在用户单击按钮后进入“正在播放”状态的方法。GameManager

onStartButtonClicked() {
    this.curState = GameState.GS_PLAYING;
}

然后在“层次结构”面板中选择“PlayButton”节点,在“检查器”面板中向组件添加响应方法,并将“游戏管理器”节点拖动到属性上。cc.Buttoncc.Node

播放按钮检查器

在这里我们可以注意到点击事件的元素 [0] 分为三个部分,它们是:

  1. 节点:这里我们拖拽游戏管理器节点,这意味着按钮事件将被游戏管理器节点接收
  2. 组件:拖动游戏管理器节点后,可以通过下拉菜单在游戏管理器上选择节点,这里选择同名组件。GameManager
  3. 事件处理程序:通过下拉菜单,选择按钮的响应事件,这里你必须选择上面添加的方法作为按钮点击的响应onStartButtonClicked
注意:以上三个步骤必须严格按照1->2->3的顺序执行。

现在,通过单击“预览”按钮,我们可以开始游戏。

添加游戏逻辑

目前,游戏角色只是沉闷地向前奔跑,我们需要添加游戏规则,使其运行更具挑战性。

角色需要在每次跳转结束时发送一条消息,并以当前位置作为参数发送一条消息,并记录它在脚本中跳了多少步:PlayerController

 private _curMoveIndex = 0;
 // ...
 jumpByStep(step: number) {
     // ...

     this._curMoveIndex += step;
 }

在每次跳转结束时都会发送消息:

 onOnceJumpEnd() {
     this.node.emit('JumpEnd', this._curMoveIndex);
 }

监听脚本中的角色结束跳转事件,根据规则确定胜出者,加一个失败和结束判断,如果跳转到空块或超过最大长度值则结束:GameManager

 checkResult(moveIndex: number) {
     if (moveIndex < this.roadLength) {
         // Jumped on the pit
         if (this._road[moveIndex] == BlockType.BT_NONE) {
             this.curState = GameState.GS_INIT;
         }
     } else {    // Skipped maximum length
         this.curState = GameState.GS_INIT;
     }
 }

侦听角色跳转消息并调用判断函数:

 start () {
     this.curState = GameState.GS_INIT;
     // '?.' is Typescript's optional chain writing
     // The equivalent of: 
     // if(this.playerCtrl ! = null) this.playerCtrl.node.on('JumpEnd', this.onPlayerJumpEnd, this).        
     // Optional chains are written in a more concise way
     this.playerCtrl?.node.on('JumpEnd', this.onPlayerJumpEnd, this);        
 }

 // ...
 onPlayerJumpEnd(moveIndex: number) {
     this.checkResult(moveIndex);
 }

如果此时预览游戏,会发现重启游戏时存在判断错误,这是重启时没有重置属性值造成的。所以我们需要在脚本中添加一个函数:_curMoveIndexPlayerController.tsresetPlayerController

 reset() {
     this._curMoveIndex = 0;
 }

然后调用脚本的函数以重置 .resetinitGameManager_curMoveIndexPlayerController.ts

 init() {
     // ...
     this.playerCtrl.reset();
 }

步数显示

我们可以在 UI 上显示当前的步数,这样在跳转过程中观看步数增加将是一种很大的成就感。

在“画布”下创建一个名为“步骤”的新“标签”节点,并调整位置、字体大小和其他属性。

步骤标签

在脚本中引用此标签:GameManager

 @property({type: Label})
 public stepsLabel: Label | null = null;

保存脚本并返回到编辑器后,将“步骤”节点拖到属性检查器中 GameManager 的 stepsLabel 属性框中

步骤标签到游戏管理器

将当前步骤计数数据更新到“步骤”节点。由于我们现在没有 GameOver UI n,所以游戏结束后我们会跳回开始菜单,所以我们需要在开始屏幕中看到最后一次跳转,所以我们需要在进入 Play 状态时将步数重置为 0。

 // GameManager.ts
 set curState (value: GameState) {
     switch(value) {
         case GameState.GS_INIT:
             this.init();
             break;
         case GameState.GS_PLAYING:
             if (this.startMenu) {
                 this.startMenu.active = false;
             }

             if (this.stepsLabel) {
                 this.stepsLabel.string = '0';   // Reset the number of steps to 0
             }
             setTimeout(() => {      // Directly set active will start listening to mouse events directly, here the delay is done
                 if (this.playerCtrl) {
                     this.playerCtrl.setInputActive(true);
                 }
             }, 0.1);
             break;
         case GameState.GS_END:
             break;
     }
 }

然后,在响应字符跳转的函数中,将步数更新为 Label 控件onPlayerJumpEnd

 onPlayerJumpEnd(moveIndex: number) {
     if (this.stepsLabel) {
         // Because in the last step there may be a jump with a large pace, but at this time, whether the jump is a large pace or a small pace should not increase the score more
         this.stepsLabel.string = '' + (moveIndex >= this.roadLength ? this.roadLength : moveIndex);
     }
     this.checkResult(moveIndex);
 }

结论

至此,您已经基本掌握了本章的大部分内容,接下来您可以通过改进美术和游戏玩法来改进游戏,为此我们还准备了一个高级章节作为选项。如果您对 Cocos Creator 的其他功能感兴趣,可以单击左侧的摘要。

如果您有任何反馈或建议,可以访问我们的论坛或请求有关GIT的问题。

3D建模学习工作室整理翻译,转载请标明出处!

上一篇:Cocos Creator:2D 游戏示例 (mvrlink.com)

下一篇:Cocos Creator:处理触摸事件 (mvrlink.com)

NSDT场景编辑器 | NSDT 数字孪生 | GLTF在线编辑器 | 3D模型在线转换 | UnrealSynth虚幻合成数据生成器 | 3D模型自动纹理化工具
2023 power by nsdt©鄂ICP备2023000829号