<一>制作第一个3D游戏

主要通过官方文档案例熟悉了解3D引擎中的一些基础术语和工具的用法。

新建项目

找一个坑少的版本3.8.3,新建一个空项目,就叫Mind-Your-Steps吧。

创建主角

这里主角就用编辑器自带的胶囊体模型。

  1. 新建一个空节点,命名为Player
  2. 在Player节点下创建主角模型重命名为Body。这里为啥不直接创建主角模型,主要是想通过脚本控制节点Player水平移动,给Body添加一个垂直方向的动画,结合起来就有一个向前跳跃的效果。
    创建好主角模型后发现在场景编辑中看不到。
    这里记录一下CocosCreator3D场景编辑中的一些基本操作和快捷方式使用(也会另起一篇文章,毕竟不常用老是忘):
    a:双击节点可以聚焦(节点到场景编辑中间),在场景编辑时想要看哪个节点,先双击聚焦它
    b:通过鼠标滚轮可以拉远/拉近场景,如果a还看不到就尝试滚动鼠标滚轮。
    c:按住alt键(Mac上是Opt键),同时按下鼠标左键拖动,可以360度无死角查看模型节点。
    d:按住鼠标右键,场景编辑器上会显示Q、W、E、A、S、D键,按住对应的键就可以在各个方向上平移场景。
    e:在场景编辑器右上角有个坐标轴控制小组件,点击对应的坐标轴就可以从该方向上观察场景。
    f:按住鼠标右键移动,就可以以摄像机为中心来观察各个方向的场景,就像转动眼睛一样。

创建跑道(地图)

用很多长度不同的立方体来组成跑道,让主角在跑道上跳跃。
在场景中cv很多立方体创建不同的跑道这样搞很明显需要大量的工作,下乘之作。
跑道实现思路:
创建一个立方体的预制体,在脚本中通过预制体动态创建实例。
创建一个管理跑道生成的脚本GameMgr.

  1. 创建脚本组件GameMgr
  2. 在场景中创建一个空节点GameMgr,把脚本GameMgr挂载到该节点上
  3. 创建立方体预制体
  4. 编辑脚本组件GameMgr
    a.跑道不应该是连续的Cube组成的,中间给他搞一些空的地方,这里定义一个枚举
    enum BlockType { None,//空地 Stone,//石块 }
    b.定义石块预制体的属性
    @property(Prefab) pre_cube: Prefab = null;
    c.定义一个_road列表存储创建的石块类型,方便查找当前位置是石块还是空地。
    d.定义一个roadLength属性表示跑道的长度
    @property({type:CCInteger,displayName:"跑道长度"}) road_length:number = 50;
    e.实现生成跑道
  5. 预览运行一下

    6.为了运行时有一个比较好的场景视图,需要对Camera做一些调整。

编写主角控制脚本

思路:通过鼠标响应(左/右键按下)控制角色移动
逻辑:
1.按下鼠标左键/右键 -> 判定是位移一步/两步 -> 将角色根据输入向前移动直到到达目标点。
2.因为是移动一步或两步,也就是说每次移动的距离是固定的;并且在整个移动过程中需要匹配主角的跳跃,也就是说跳跃动画的时间和移动的时间要匹配,不然会很奇怪。
3.跳跃/移动中点击鼠标不会另外做回调响应,不然可能会中断上一步的操作导致出现一些奇怪的现象。
4.在update中处理主角的移动逻辑,在游戏开发中,update 会以特定的时间间隔进行,如60 FPS(即每秒渲染 60 帧),那么update 每秒就会被调用 60 次。通过这种方式可以尽可能的去模拟现实中连续的行为。
5.添加一个用于计算目标位置、速度的方法 jumpByStep
6.给主角添加一个跳跃的动画,因为主角移动的逻辑节点是Player,所以跳跃动画就在主角模型Body上处理,这里用Tween去做一个缓动的动画。

新建PlayerController脚本组件并挂载到Player节点上,编辑脚本。

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

@ccclass('PlayerController')
export class PlayerController extends Component {
  private _isJumping: boolean = false;//是否跳跃中
  private _targetPos: Vec3 = v3();//目标位置
  private _curPos: Vec3 = v3();//当前位置
  private _jumpStep: number = 0;//跳跃步长,一步还是两步,根据步长计算目标位置
  private _curJumpTime: number = 0;//当前跳跃时间
  private _jumpTime: number = 0.2;//跳跃持续时间/每次跳跃的时长,匹配移动时长
  private _moveSpeed: number = 0;//移动速度
  private _deltaPos: Vec3 = v3();//每帧移动的距离
  start() {
      //输入监听
      input.on(Input.EventType.MOUSE_UP, this.onMouseUp, this);
  }
  onMouseUp(event: EventMouse) {
      //如果是鼠标左键,getButton 方法会返回 0,而如果是右键,则返回 2
      if (event.getButton() == 0) {
          //这里不要直接给_jumpStep赋值,因为需要判断是否正在跳跃中,否则会多次调用
          this.jumpByStep(1);
      } else if (event.getButton() == 2) {
          this.jumpByStep(2);
      }
  }
  /**
   * 辅助计算跳跃移动的信息(目标位置,跳跃时间等等),实现跳跃动画
   * @param step 
   * @returns 
   */
  jumpByStep(step: number) {
      if (this._isJumping) {
          //跳跃是一个完整的步骤,如果正在跳跃中不做任何响应
          return;
      }
      this._isJumping = true;
      this._jumpStep = step;
      //跳跃动画
      tween(this.node.getChildByName("Body"))
          .to(this._jumpTime * 0.5, { position: v3(0, this._jumpStep * 0.5, 0) })
          .to(this._jumpTime * 0.5, { position: v3(0, 0, 0) })
          .start();
      this._curJumpTime = 0;//重置跳跃时间
      this._moveSpeed = this._jumpStep / this._jumpTime;//计算移动速度
      this.node.getPosition(this._curPos);//获取当前位置
      Vec3.add(this._targetPos, this._curPos, v3(this._jumpStep, 0, 0));//计算目标位置
  }


  update(deltaTime: number) {
      if (!this._isJumping) return;//如果没有跳跃,则不处理任何跳跃移动的逻辑
      this._curJumpTime += deltaTime;//计算当前跳跃时间
      if (this._curJumpTime >= this._jumpTime) {//如果已经完成一次完整的跳跃,则结束跳跃
          this.node.setPosition(this._targetPos);//强制逐句移动到目标位置
          this._isJumping = false;
      } else {
          this.node.getPosition(this._curPos);//获取当前位置
          this._deltaPos.x = this._moveSpeed * deltaTime;//计算每帧移动的距离
          Vec3.add(this._curPos, this._curPos, this._deltaPos);//计算当前位置
          this.node.setPosition(this._curPos);//逐帧移动到当前位置
      }
  }
}


相机跟随

随着主角移动,相机没有跟随,所以主角移动到屏幕外后就看不到后面的跑道和主角了。
这里把相机设置为主角的子节点,相机就可以跟随主角移动了。

搭建UI

ui是必不可少的一部分,比较开始界面、游戏界面(计分等)、结算界面

开始界面

编辑UI界面时可以切换2D场景视图。

创建游戏状态机

一般的游戏都可以分为3个阶段(状态):初始化、游戏中、结算
创建一个Enum.ts,定义各种枚举。
新建一个游戏状态枚举:

/**
 * 游戏状态
 */
enum GameState{
    GS_INIT,//显示游戏开始界面,初始化一些资源
    GS_PLAYING,//隐藏游戏菜单界面,开始游戏
    GS_END,//显示游戏结束界面
};

通过游戏状态的切换处理游戏的状态逻辑。
例如:为了在游戏开始前不让用户操作角色,需要动态地开启和关闭角色对鼠标消息的监听。
在PlayerController添加一个状态动态开/关鼠标事件监听的函数setInputActive。

    /**
     * 是否开启关闭输入
     * @param active 
     */
    setInputActive(active: boolean) {
        if (active) {
            input.on(Input.EventType.MOUSE_UP, this.onMouseUp, this);
        } else {
            input.off(Input.EventType.MOUSE_UP, this.onMouseUp, this);
        }
    }

完善GameMgr脚本:

import { _decorator, CCInteger, Component, instantiate, Node, Prefab, v3, Vec3 } from 'cc';
import { PlayerController } from './PlayerController';
const { ccclass, property } = _decorator;

enum BlockType {
    None,//空地
    Stone,//石块
}
@ccclass('GameMgr')
export class GameMgr extends Component {
    @property(Prefab)
    pre_cube: Prefab = null;
    @property({ type: CCInteger, displayName: "跑道长度" })
    road_length: number = 50;
    @property({ type: PlayerController })
    public playerCtrl: PlayerController | null = null;
    @property({ type: Node })
    public startMenu: Node | null = null;

    _road: BlockType[] = [];
    _curState: GameState = null;


    start() {
        this.curState = GameState.GS_INIT;
    }
    /**
     * 初始化
     */
    init() {
        //激活开始菜单
        this.startMenu.active = true;
        //生成跑道
        this.generateRoad();
        //关闭玩家输入
        this.playerCtrl.setInputActive(false);
        //重置玩家位置
        this.playerCtrl.node.position = Vec3.ZERO;
    }

    set curState(state: GameState) {
        this._curState = state;
        switch (this._curState) {
            case GameState.GS_INIT:
                this.init();
                break;
            case GameState.GS_PLAYING:
                this.startMenu.active = false;
                // 设置 active 为 true 时会直接开始监听鼠标事件,此时按钮的鼠标抬起事件还未派发
                // 会出现游戏开始的瞬间人物已经开始移动的现象
                // 这里做一个延迟处理去避免这种情况
                setTimeout(() => {
                    this.playerCtrl.setInputActive(true);
                }, 0.1);
                break;
            case GameState.GS_END:

                break;
        }
    }

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

    /**
     * 生成跑道
     */
    generateRoad() {
        //在生成新的跑道前先清空之前的跑道
        this.node.removeAllChildren();
        this._road = [];
        //第一个格子一定是石头,确保主角开始时是站在石头上
        this._road.push(BlockType.Stone);
        //初始化跑道每一格的类型
        for (let i = 1; i < this.road_length; i++) {
            //因为主逻辑是一步两步,所以要避免生产两个连续的空地
            if (this._road[i - 1] == BlockType.None) {
                this._road.push(BlockType.Stone);
            } else {
                this._road.push(Math.floor(Math.random() * 2));
            }
        }
        //根据跑道类型生产跑道
        for (let j = 0; j < this._road.length; j++) {
            let road_type = this._road[j];
            let block: Node = null;
            if (road_type == BlockType.Stone) {
                block = instantiate(this.pre_cube);
            };
            if (block) {
                block.parent = this.node;
                block.position = v3(j, -1.5, 0);
            }
        }
    }

    update(deltaTime: number) {

    }
}


完善游戏结束逻辑

思路:如果主角跳到空方块或是超过了最大长度值都结束,在每次跳跃结束时派发一个事件通知GameMgr,在回调中判断是否踩空或者是不是跑道走完了,为了尽量减少耦合,这里的事件系统不直接用node.emit/on,新建一个ts文件,定义一个可全局调用的对象EventMgr,继承至cc的EventTarget,就可以直接使用该对象来派发或监听事件,另外定义一个事件名的枚举来定位每个不同的事件。
到这里基本就结束了,后续再完善一下,比如显示步数、添加结算界面、更换一个稍微好点的主角模型、添加光影增效等等。
下一篇记录:
1.显示步数
2.更换主角模型
3.添加光影
4.增加游戏难度,如角色在原地停留1秒就算失败
5.改为无限模式,动态删除已经跑过的跑道,延长后面的跑道
6.添加结算界面,统计玩家跳跃步数和所花的时间
7.增加一些道具
8.添加一些特效,例如角色运动时的拖尾、落地时的灰尘
9.添加音效
10.适配触屏设备
...

posted @ 2024-10-23 11:39  EricShx  阅读(36)  评论(0)    收藏  举报