【unity demo】使用unity制作射击游戏demo (上)
使用unity制作射击游戏demo:搭建关卡场景,制作简单动画和粒子效果,基于unity物理系统实现玩家角色的基本移动,拾取,交互,射击等
1.配置vs code开发环境
主要是安装unity对应的版本,并配置相应的ide,目前我用的是unity 2021.3.21。
通过edit-prefreneces面板,external tools选项中配置ide环境,自动使用vs code来打开工程中的代码文档。
2.游戏文档
即游戏设计文档(Game Design Document, GDD),我们需要预先对待实现的完整demo进行设计,包括5个部分:
-
概念 :
一个通过躲避场景中巡逻和警惕的敌人,并能够进行第三人称视角射击的demo。 -
机制 :
1)敌人会在场景中沿指定路线巡逻,并存在警惕范围,当主角进入到敌人的警惕范围后,敌人会自动改变巡逻路线,向主角移动
2)敌人接触到主角后,会减扣主角的生命值
3)主角能够通过射击抵御接近的敌人,能拾取物品 -
用户接口 :
1)通过WSAD控制角色移动,鼠标控制摄像机方向,使用左键射击
2)角色通过接触,拾取物体
3)简单HUD(抬头显示,Head Up Display),显示玩家的血量和剩余的子弹数 -
剧情 :
demo暂无剧情
-
表现风格:
使用unity的默认3d模型进行搭建,不使用自定义shader和贴图作为额外材质
3.搭建关卡
3.1. 创建基本场景
我们使用默认的平面和立方体,通过缩放,搭建基本的场地模型。
为更便于进行场景中的物体管理,我们创建一个命名为专门管理环境的空物体,并将构成场景的组件全部拖入。
3.2. 创建预制件
接下来我们将在场景的四个角,放置四个用阻挡敌人,并提供射击窗口的掩体。
我们同样使用unity的基本模型去搭建这四个掩体,但假定四个掩体是完全相同的结构,我们同样的搭建操作要重复执行四次,实在是有点太折磨了。
这里我们使用到unity的pregab机制,通过空物体创建预制件,并进行保存,这样我们下一次需要搭建同样的掩体时,直接使用保存好的预制件即可。
把最上级的空物体拖入到文件夹内,创建可使用的预制件。
使用预制件创建四个掩体。
改变其中一个实例,并将其应用到预制件上,能看到全部的预制件实例都会实时更新。
在场景中央加上四个斜坡和立方体组成的平台,在右下角加入第一个拾取物。基本场景搭建完成。
3.3. 制作模型动画
在上一步中我们加入了胶囊体作为拾取物,现在我们希望这个拾取物能够在场景中动起来。
通过window-animation-animation,打开动画面板,并固定到console面板旁。
选中要创建动画的物体,创建动画。
插入5个控制旋转的关键帧,这四个关键帧只做了旋转的变化。
可以看到在从旋转一圈回到初始位置再执行第二次循环时,旋转有些停顿,我们切到曲线模式查看动作状态。
可以看到我们在1段的变化率不如2段的变化率稳定,所以我们得重新插帧进行调整。
现在我们场景中就有了能够旋转的胶囊了。
3.4. 创建粒子效果
创建particle system对象,将其位置调整到与胶囊体一致。
4.建立角色基本控制功能
首先我们建立代表玩家控制角色的淡蓝色胶囊体,挂上rigidbody组件,并在rigidbody组件内设置xy轴的旋转约束。
通过rigidbody组件,我们能让挂在其的胶囊体与unity的物理系统进行交互。
移动我们的game object有三种常见的方法:
- 通过调整transform的值来进行移动和旋转
- 使用rigidbody组件,给game object添加力
- 直接使用现成组件,如character controller和FirstPersonController
4.1. 修改transform实现玩家角色移动
首先我们需要获取到玩家的输入,若需要自定控制移动方向的按键,则需要通过input manager面板,完成简单的输入配置。
这里我们就使用fps中绝对经典的wasd移动模型(这里与认知中的有所不同,ws为前后移动,ad为旋转,已在unity中默认配置好),并赋予一个跟随玩家移动的摄像机,通过鼠标的指向来控制跟随相机的朝向。
在Input manager面板中,我们可以看到unity已经默认配置好了输入信息。
以水平轴为例,当我们在c#中查询对应的输入轴时(通过Input.GetAxis()方法查询特定轴的输入情况,而非查询对应的按键是否按下):
- 若左键或A被按下,则会返回-1
- 若右键或D被按下,则会返回1
- 对应按键未按下,则返回0
通过transform控制玩家移动的脚本如下:
逻辑很简单,获取对应轴的参数,乘以移动速度和表示帧隔时间的Time.deltaTime。
如果直接用帧率来乘,玩家间设备差异带来的帧数差异就会带来移动差异,所以这里使用了两帧间的间隔时间来抹掉帧数差异。
//class: PlayerBehavior
public float moveSpeed = 1.0f;
public float rotateSpeed = 60f;
private float vInput;
private float hInput;
// 初始化部分无需写入
void Start()
{
}
void Update()
{
vInput = Input.GetAxis("Vertical") * moveSpeed;
hInput = Input.GetAxis("Horizontal" ) * rotateSpeed;
this.transform.Translate(Vector3.forward * vInput * Time.deltaTime);
this.transform.Rotate(Vector3.up * hInput * Time.deltaTime);
}
4.1.1 玩家角色倒地问题
我们很快发现,在测试中会存在玩家角色很容易倒地开摆的问题。
显然这个是一个物理系统相关的问题,我们检查rigidbody组件发现,旋转轴锁定的选项选错了。
将其修改为冻结x,z轴方向上的旋转,问题解决。
4.2. 使摄像机跟随玩家
新建脚本,并挂载给摄像机。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class CameraBehavior : MonoBehaviour
{
//camOffset:相对于玩家角色的位置,摄像机位置的偏移值
public Vector3 camOffset = new Vector3(0f, 1.2f, -2.6f);
private Transform target;
void Start()
{
target = GameObject.Find("Player").transform;
}
// 注意lateupdate也是monoBehavior提供的默认方法,其更新频率与帧率一致,但更新顺序在update之后
// 使用LateUpdate以确保在玩家更新位置后,摄像机能及时跟上
void LateUpdate()
{
this.transform.position = target.TransformPoint(camOffset);
this.transform.LookAt(target);
}
}
4.3. 使用rigidbody组件实现玩家角色移动
在unity的运动类型中,主要包括两种:
- kinematic运动:即运动学运动,非物理运动。即该类运动不会受到unity物理机制的影响,一般常用于骨骼控制,而非game
object在场景中的位置和旋转变化。 - non-kinematic运动:即物理运动,非运动学运动。该类运动主要通过向挂载了rigidbody的物体施加力来实现,而非修改transform。
在4.1中,我们主要实现了一种混合了transform和rigidbody的移动机制,即一种混合了kinematic和non-kinematic的移动机制,unity也不建议我们对这两种运动机制进行混用。
接下来我们使用rigidbody提供的方法,实现玩家角色的移动。
public class PlayerBehavior : MonoBehaviour
{
public float moveSpeed = 1.0f;
public float rotateSpeed = 60f;
private float vInput;
private float hInput;
private Rigidbody _rb;
// Start is called before the first frame update
void Start()
{
_rb = GetComponent<Rigidbody>();
}
// Update is called once per frame
void Update()
{
vInput = Input.GetAxis("Vertical") * moveSpeed;
hInput = Input.GetAxis("Horizontal" ) * rotateSpeed;
// this.transform.Translate(Vector3.forward * vInput * Time.deltaTime);
// this.transform.Rotate(Vector3.up * hInput * Time.deltaTime);
}
//面向物理系统专门的update方法FixedUpdate,该方法独立于帧率
void FixedUpdate()
{
Vector3 rotation = Vector3.up * hInput;
//Time.fixedDeltaTime:用于求解两次fixedupdate之间的时间差
Quaternion angleRot = Quaternion.Euler(rotation * Time.fixedDeltaTime);
_rb.MovePosition(this.transform.position + this.transform.forward * vInput * Time.fixedDeltaTime);
_rb.MoveRotation(_rb.rotation * angleRot);
}
}
4.4. 设置接触拾取
在物理系统的运用中,我们发现除了rigidbody组件外,game object往往还包含了一个collider组件。
collider组件规定了物体的碰撞体,物理材质,重心,半径,高度等信息。
当两个用于collider组件的object发生碰撞时,就会同时发出onCollisionEnter消息。
挂载给拾取物game object的脚本如下:
public class ItemBehavior : MonoBehaviour
{
//当不启用isTrigger属性的物体相互碰撞时,unity会调用OnCollisionEnter方法,执行碰撞逻辑
void OnCollisionEnter(Collision collision)
{
if(collision.gameObject.name == "Player")
{
//注意此时我们将拾取物胶囊体和粒子效果一起挂载在一个父级空物体下
//所以这里执行的是对父级物体的删除
//我们无法直接通过monobehaviour类来获取父级,得通过transform来获取挂载对象的父级
Destroy(this.transform.parent.gameObject);
Debug.Log("Item collected!");
}
}
}
4.5. 添加第一个哨兵敌人
现在我们需要在场景中添加第一个敌人,让他充当哨兵的职责。
虽然目前这个敌人尚不能主动移动,但它有一个很重要的职责,发现进入到侦查范围内的玩家角色。
我们刚刚提到collider组件有一个isTrigger属性:
当其没有开启时,collider会执行正常的碰撞检测,碰撞体间想相互碰撞的物体会被弹开(或发生体积间的交互),从而改变物体的运动状态,并调用OnCollisionEnter方法。
当其开启时,该collider则会成为无实体的状态,碰撞体发生碰撞后不会发生体积上的交互,物体能够按照其原有的运动状态进行运动。此外发送的消息也会发生变化,对应物体从进入collider到离开collider的过程,依次触发的消息(方法)为:OnTriggerEnter, OnTriggerStay, OnTriggerExit。
首先我们在场景中创建一个enemy对象,并为其添加一个sphere collider,并启动isTrigger,radius设置为8.
挂载给敌人实体的脚本如下:
public class EnemyBehavior : MonoBehaviour
{
void OnTriggerEnter(Collider other)
{
if (other.name == "Player")
{
Debug.Log("Player found within guard range!");
}
}
void OnTriggerStay(Collider other)
{
if (other.name == "Player")
{
Debug.Log("Player stay in guard range!");
}
}
void OnTriggerExit(Collider other)
{
if (other.name == "Player")
{
Debug.Log("Player exit guard range!");
}
}
}
我们可以更进一步,让敌人在感受到玩家接近的时候直接打醒十二分精神,颜色提亮一个亮度。当玩家退出警戒范围时,敌人的颜色随之减淡。
public class EnemyBehavior : MonoBehaviour
{
public Color ActivateColor;
private Color CustomColor;
private Renderer render;
void Start()
{
render = this.GetComponent<Renderer>();
CustomColor = new Color(0.58f, 0.0f, 0.632f, 1f);
}
void OnTriggerEnter(Collider other)
{
if (other.name == "Player")
{
Debug.Log("Player found within guard range!");
render.material.SetColor("_Color", ActivateColor);
}
}
void OnTriggerStay(Collider other)
{
if (other.name == "Player")
{
Debug.Log("Player stay in guard range!");
}
}
void OnTriggerExit(Collider other)
{
if (other.name == "Player")
{
Debug.Log("Player exit guard range!");
render.material.SetColor("_Color", CustomColor);
}
}
}
4.6. 实现玩家跳跃
更新玩家控制脚本如下:
public class PlayerBehavior : MonoBehaviour
{
public float moveSpeed = 1.0f;
public float rotateSpeed = 60f;
//新增跳跃速度public变量
public float jumpVelocity = 5.0f;
private float vInput;
private float hInput;
private Rigidbody _rb;
void Start()
{
_rb = GetComponent<Rigidbody>();
}
void Update()
{
vInput = Input.GetAxis("Vertical") * moveSpeed;
hInput = Input.GetAxis("Horizontal" ) * rotateSpeed;
}
void FixedUpdate()
{
//跳跃控制逻辑
if(Input.GetKeyDown(KeyCode.Space))
{
_rb.AddForce(Vector3.up * jumpVelocity, ForceMode.Impulse);
}
Vector3 rotation = Vector3.up * hInput;
Quaternion angleRot = Quaternion.Euler(rotation * Time.fixedDeltaTime);
_rb.MovePosition(this.transform.position + this.transform.forward * vInput * Time.fixedDeltaTime);
_rb.MoveRotation(_rb.rotation * angleRot);
}
}
很快我们发现,跳是可以跳的,但是这个跳跃成功的情况非常的偶发,这个小比(可)崽(爱)子怎么完全不听空格的指挥?
上图虽然无法看出来键盘的情况,但实际上我一直都在狂按空格。
显然这是因为FixedUpdate是不像Update那样是按每游戏帧的顺序执行的。所以我们这里要再调整一下逻辑,把监测space键的部分逻辑移动到update里面。
public class PlayerBehavior : MonoBehaviour
{
public float moveSpeed = 1.0f;
public float rotateSpeed = 60f;
public float jumpVelocity = 5.0f;
private float vInput;
private float hInput;
private float JInput;
private Rigidbody _rb;
void Start()
{
_rb = GetComponent<Rigidbody>();
}
void Update()
{
vInput = Input.GetAxis("Vertical") * moveSpeed;
hInput = Input.GetAxis("Horizontal" ) * rotateSpeed;
if (Input.GetKeyDown(KeyCode.Space))
{
JInput = jumpVelocity;
Debug.Log("jump jump jump!");
}
}
void FixedUpdate()
{
_rb.AddForce(Vector3.up * JInput, ForceMode.Impulse);
JInput = 0f;
Vector3 rotation = Vector3.up * hInput;
Quaternion angleRot = Quaternion.Euler(rotation * Time.fixedDeltaTime);
_rb.MovePosition(this.transform.position + this.transform.forward * vInput * Time.fixedDeltaTime);
_rb.MoveRotation(_rb.rotation * angleRot);
}
}
但很快我们又发现了新的问题,即连续按空格会实现持续上跳,螺旋升天。
处理的方法很简单,我们需要检查玩家角色是否着地,着地后才会允许执行下一次跳跃逻辑。
4.6.1 使用mask layer实现玩家落地检查
接下来我们通过添加一个独立的layer,并将environment指派给该层,我们将把该层作为遮罩层,判断玩家角色是否触地。
首先我们随意点击一个game object实体,添加一个名为Ground的layer。
确保全部子级的所属层都进行了调整。
更新后的角色控制脚本如下:
public class PlayerBehavior : MonoBehaviour
{
public float moveSpeed = 1.0f;
public float rotateSpeed = 60f;
public float jumpVelocity = 5.0f;
//新增两个public变量,一个指定离地距离判断精度,一个指定mask layer
public float distanceToGround = 0.1f;
public LayerMask groundLayer;
private float vInput;
private float hInput;
private float JInput;
private Rigidbody _rb;
private CapsuleCollider _col;
void Start()
{
_rb = GetComponent<Rigidbody>();
_col = GetComponent<CapsuleCollider>();
}
void Update()
{
vInput = Input.GetAxis("Vertical") * moveSpeed;
hInput = Input.GetAxis("Horizontal" ) * rotateSpeed;
//判断条件更新,确保同时满足落地和空格触发,才会进行跳跃
if (IsGrounded() && Input.GetKeyDown(KeyCode.Space))
{
JInput = jumpVelocity;
Debug.Log("jump jump jump!");
}
}
void FixedUpdate()
{
_rb.AddForce(Vector3.up * JInput, ForceMode.Impulse);
JInput = 0f;
Vector3 rotation = Vector3.up * hInput;
Quaternion angleRot = Quaternion.Euler(rotation * Time.fixedDeltaTime);
_rb.MovePosition(this.transform.position + this.transform.forward * vInput * Time.fixedDeltaTime);
_rb.MoveRotation(_rb.rotation * angleRot);
}
//通过private方法判断胶囊体是否地面layer mask的距离在精度范围内,返回布尔值
private bool IsGrounded()
{
Vector3 capsuleBottom = new Vector3(_col.bounds.center.x, _col.bounds.min.y, _col.bounds.center.z);
bool grounded = Physics.CheckCapsule(_col.bounds.center, capsuleBottom, distanceToGround, groundLayer, QueryTriggerInteraction.Ignore);
return grounded;
}
}
至此连跳的问题被解决了,我们的角色再也无法奋力空蹬了。
4.7. 实现子弹发射
既然是做FPS的demo,那最关键的必然得是S的环节,接下来我们通过实例化的方式实现射击功能。
逻辑上也很简单:
触发射击后,我们会在指定位置实例化子弹的实体,并使之向固定方向移动。
实现上,我们使用Instantiate方法,并给其提供子弹的预制件对象,生成位置和旋转。
首先我们创建子弹实体的预制件,记得要添加rigidbody组件。
玩家角色控制的代码,更新部分如下:
public class PlayerBehavior : MonoBehaviour
{
//其余定义部分不变
public GameObject bullet;
public float bulletSpeed = 100f;
private bool bulletTrigger = false;
void Start()
{
_rb = GetComponent<Rigidbody>();
_col = GetComponent<CapsuleCollider>();
}
void Update()
{
//同样的,获取鼠标输入的部分移入Update中
//通过bulletTrigger作为触发器,确保FixedUpdate帧能够获取到子弹发射的信号
if(Input.GetMouseButtonDown(0)){
bulletTrigger = true;
}
//其余部分不变
}
void FixedUpdate()
{
if(bulletTrigger)
{
//使用预制件实例化子弹实体
GameObject newBullet = Instantiate(bullet, this.transform.position + new Vector3(1, 0, 0), this.transform.rotation) as GameObject;
Rigidbody bulletRB = newBullet.GetComponent<Rigidbody>();
bulletRB.velocity = this.transform.forward * bulletSpeed;
bulletTrigger = false;
}
//其余部分不变
}
private bool IsGrounded()
{
//不变
}
}
4.7.1 变换为第一人称视角
我们可以明显的看到,玩家角色自己的存在反而挡住了子弹。所以我们需要对摄像机跟随的脚本做一些调整:
主要的改动就是,摄像机的偏移和观察方向,以及子弹的生成位置。
相机的脚本更新如下:
public class CameraBehavior : MonoBehaviour
{
//在inspector面板中配置偏移量,使摄像机一直在玩家角色前方
public Vector3 camOffset = new Vector3(0f, 1.0f, 1.0f);
private Transform target;
void Start()
{
target = GameObject.Find("Player").transform;
}
// 注意lateupdate也是monoBehavior提供的默认方法,其更新频率与帧率一致,但更新顺序在update之后
void LateUpdate()
{
this.transform.position = target.TransformPoint(camOffset);
// 这里更新为,观察当前朝向远处的一个位置
this.transform.LookAt(target.position + target.transform.forward * 10);
}
}
另外,我们还对玩家角色的预制件进行了一些调整,增加了一个胶囊体作为武器的表示。
这样就不会出现玩家角色阻碍瞄准和射击的情况了。
4.7.2 设置子弹自动消失
现在虽然子弹和视角都正确了,但是胡乱射击一通上地上会有很多子弹实体在滚来滚去,一方面很容易误触发碰撞事件,且大量子弹实体也会占用多余的内存和物理计算消耗。
通过设置定时消失,我们让系统定时去自动销毁场景中已经生成的子弹实体。
给子弹预制件挂载一个脚本:根据延迟自动摧毁生成的实体
public class BulletBehavior : MonoBehaviour
{
public float onscreenDelay = 3f;
void Start()
{
Destroy(this.gameObject, onscreenDelay);
}
}
那么到目前为止,系统的基本功能已经实现完成。
由于到这个部分的时候,本篇博文已经显著地出现了篇幅过长的情况,剩下的部分:
如子弹和物体的交互,HUD显示,游戏管理器,敌人AI等。
我将会放到下篇进行讲解。
【unity demo】使用unity制作射击游戏demo (下)
开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!
更多推荐
所有评论(0)