<一>制作第一个3D游戏
主要通过官方文档案例熟悉了解3D引擎中的一些基础术语和工具的用法。
新建项目
找一个坑少的版本3.8.3,新建一个空项目,就叫Mind-Your-Steps吧。
创建主角
这里主角就用编辑器自带的胶囊体模型。
- 新建一个空节点,命名为Player
- 在Player节点下创建主角模型重命名为Body。这里为啥不直接创建主角模型,主要是想通过脚本控制节点Player水平移动,给Body添加一个垂直方向的动画,结合起来就有一个向前跳跃的效果。
创建好主角模型后发现在场景编辑中看不到。
这里记录一下CocosCreator3D场景编辑中的一些基本操作和快捷方式使用(也会另起一篇文章,毕竟不常用老是忘):
a:双击节点可以聚焦(节点到场景编辑中间),在场景编辑时想要看哪个节点,先双击聚焦它
b:通过鼠标滚轮可以拉远/拉近场景,如果a还看不到就尝试滚动鼠标滚轮。
c:按住alt键(Mac上是Opt键),同时按下鼠标左键拖动,可以360度无死角查看模型节点。
d:按住鼠标右键,场景编辑器上会显示Q、W、E、A、S、D键,按住对应的键就可以在各个方向上平移场景。
e:在场景编辑器右上角有个坐标轴控制小组件,点击对应的坐标轴就可以从该方向上观察场景。
f:按住鼠标右键移动,就可以以摄像机为中心来观察各个方向的场景,就像转动眼睛一样。
创建跑道(地图)
用很多长度不同的立方体来组成跑道,让主角在跑道上跳跃。
在场景中cv很多立方体创建不同的跑道这样搞很明显需要大量的工作,下乘之作。
跑道实现思路:
创建一个立方体的预制体,在脚本中通过预制体动态创建实例。
创建一个管理跑道生成的脚本GameMgr.
- 创建脚本组件GameMgr
- 在场景中创建一个空节点GameMgr,把脚本GameMgr挂载到该节点上
- 创建立方体预制体
- 编辑脚本组件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.实现生成跑道 - 预览运行一下
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.适配触屏设备
...