第一次写日志还不太会写qaq

第一部分

楔子

在本篇博客中会记录第一次做3D游戏的心路历程以及一定量的代码(有过2D游戏的经验,后续可能会把之前做出来2D的游戏文件上传),成果可能会很粗糙,此项目主要是供做笔记所用

本项目会持续更新


人物跟随鼠标移动

经验引出

首先看看之前写过的2D人物跟随键盘移动的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void Movement()//移动
{
float horizontalMove;
float facedirection;

horizontalMove = Input.GetAxis("Horizontal");//得到负一或者一之间的数字(过程)
facedirection = Input.GetAxisRaw("Horizontal");//只得到 - 1 1 或者0(离散)
//角色移动
rb.velocity = new Vector2(horizontalMove * speed * Time.fixedDeltaTime , rb.velocity.y);//Time.deltatime是物理时钟运行百分比,同样能使画面更平滑,一般搭配FixedUpdate使用
//fixedDeltatime是FixedUpdate用
anim.SetFloat("running",Mathf.Abs(facedirection));//角色面向
if (facedirection != 0)
{
transform.localScale = new Vector3(facedirection, 1, 1);
}
Crouch();//判断趴下的函数,自己写的
}
  • 在Unity初始设置中Horizontal 这个水平轴其实就是X轴,也就是键盘上的AD键或方向箭头,当静止时为0,当按下A键时这个数值减小,返回一个小于0的数值,同理,D键为大于0的数值;物体就在X轴方向水平移动。
  • 本代码是通过动态获取horizontalMove的数据来控制人物速度的

本游戏代码实现(自动寻路系统)

前置工作

已经用AI中的Navigation完成bake

  • 先把场景里所有物品全部调为Navigation Static
  • 选择地面,将其Navigation Area改为Walkable,然后bake
  • 选择场景里其他物品,将其Navigation Area改为Not Walkable,然后bake
  • 给Player添加Nav Mesh Agent
  • 如果不想把物品调为Navigation Static ,也可以给物体添加 Nav Mesh Obstacle,再选择Carve进行实时切割(此方法也可用来创建可移动的场景)

MouseManager类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Events;
using System;

//[System.Serializable]
//public class EventVector3 : UnityEvent<Vector3> { }
public class MouseManager : MonoBehaviour
{
public static MouseManager Instance;

public Texture2D point, doorway, attack, target, arrow;//鼠标图标

RaycastHit hitInfo;
//public EventVector3 OnMouseClicked;
public event Action<Vector3> OnMouseClicked;
private void Awake()
{
if(Instance != null)
{
Destroy(gameObject);
}
Instance = this;
}//单例模式
void Update()
{
SetCursorTexture();
MouseControl();
}

void SetCursorTexture()
{
Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);

if(Physics.Raycast(ray,out hitInfo))
{
//切换鼠标贴图
switch (hitInfo.collider.gameObject.tag)
{
case "Ground":
//设置鼠标偏移(鼠标图标只有左上角有效)
Cursor.SetCursor(target,new Vector2(16,16), CursorMode.Auto);
break;
}
}
}

void MouseControl()
{

if (Input.GetMouseButtonDown(0) && hitInfo.collider != null)
{

if (hitInfo.collider.gameObject.CompareTag("Ground"))
{
OnMouseClicked?.Invoke(hitInfo.point);//不为空就会触发这个事件(invoke调用的是前面的)
}
}
}
}
  • Update没大写卡了一段时间…

  • invoke调用的是前面的事件,?.用法参考https://www.cnblogs.com/zbliao/p/12869528.html

  • 这里创建的RaycastHit对象用法可以参考https://blog.csdn.net/qq_30454411/article/details/79140318

    射线是在三维世界中从一个点沿一个方向发射的一条无限长的线。在射线的轨迹上,一旦与添加了碰撞器的模型发生碰撞,将停止发射。我们可以利用射线实现子弹击中目标的检测,鼠标点击拾取物体等功能。

PlayerController类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;
public class PlayerController : MonoBehaviour
{
private NavMeshAgent agent;

private void Awake()
{
agent = GetComponent<NavMeshAgent>();
//自动寻路代理,已经挂在主角上了
}

private void Start()
{
MouseManager.Instance.OnMouseClicked += MoveToTarget;//事件委托
}
public void MoveToTarget(Vector3 target)
{
agent.destination = target;
}
}

Post Processing与Shader Graph

PostProcessing

  • URP默认自带PostProcessing

功能

相机后处理,画面质感提升

使用方法

  • 层级栏右击可以找到Volume,添加Volume可以进行画质
  • 在Volume脚本下新建配置文件,然后在Add Override中添加自己想要的效果(别忘了在Scene中打开PostProcessing)

用Post processing改变了下镜头质感但依然会有视角遮挡的情况

这就要引出

Shader Graph

  • URP默认自带ShaderGraph了

  • shader是上色器,render是渲染,material是材料,不要弄混

功能

通过图标这种直观的方式创建shader

使用方法

  • 右键在URP分类里找到ShaderGraph
  • 创建后双击在Inspector中打开编辑器即可使用

ShaderGraph具体使用方法篇幅过长,之后有机会会补上

  • 建立好自己想要的Shader后附在材质上
  • 在PipelineSetting中UniversalRenderPipelineAsset_Renderer里Add RendererFeature,设置Depth Test为Greater,然后选择材质和图层
  • 再一次Add RendererFeature,选择图层(此步是为了防止自己遮挡自己)

第二部分

敌人

  • 提示:导入项目后正常在edit里面把项目都升级为URP敌人才可正常显示

前置工作

  • 加NavMeshAgent并同Player一样设置遮挡材质
  • 适当改一下MouseManger代码使鼠标移动到身上时变为攻击贴图(这里就不贴代码了,略过)

人物追随敌人

使用协程

在PlayController里面增加两个函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
private void EventAttack(GameObject target)
{
if (target != null)
{
attackTarget = target;
//开启协程
StartCoroutine(MoveToAttackTarget());
}
}

IEnumerator MoveToAttackTarget()
{
agent.isStopped = false;

transform.LookAt(attackTarget.transform);//先转向目标
//TODO:修改攻击范围参数
while (Vector3.Distance(attackTarget.transform.position, transform.position) > 1)
{
agent.destination = attackTarget.transform.position;
yield return null;
}//判断攻击距离是否足够

agent.isStopped = true;
//攻击

if (lastAttackTime < 0)
{
anim.SetTrigger("Attack");
//重置冷却时间
lastAttackTime = 0.5f;

}
}
  • 使用协程是为了能够判断攻击距离是否足够,如果不用携程会一直卡在while循环里(因为yield return null是下一帧继续协程,中间留了移动的时间)
  • 关于协程详细的介绍可以看https://blog.csdn.net/qq_28849871/article/details/75810066
  • 如果想在寻敌过程中打断需要StopAllCoroutines()这个函数来关闭协程序
  • 关于Animator中的触发器,他只会触发一次,但是如果动画开启了循环会一直播放,解决方法是关闭动画循环或者在触发器返回时开启退出时间

中途改一下相机设置,让玩家可以自己改变摄像机

  • 选择Cinemachine中的freelook
  • 添加Follow和LookAt的物体,改变X和Y的Axis
  • 在Orbits中通过改变Rig改变视角大小,也可以切换Binding Mode来摄像机固定人物的一个方向

追击Player

先是一段EnemyController的代码,也算是基本骨架了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;

public enum EnemyStates { GUARD , PATROL , CHASE , DEAD }

//这个注解为需要在物体上有挂载这个类型的组件,若无则自动挂载
[RequireComponent(typeof(NavMeshAgent))]
public class EnemyController : MonoBehaviour
{
private EnemyStates enemyStates;
private NavMeshAgent agent;
private Animator anim;
[Header("Basic Setting")]
public bool isGuard;
public float sightRadius;
private float speed;
private GameObject attackTarget;
bool isWalk;
bool isChase;
bool isFollow;

private void Awake()
{
agent = GetComponent<NavMeshAgent>();
speed = agent.speed;
anim = GetComponent<Animator>();
}

private void Update()
{
SwitchStates();
SwitchAnimation();
}
private void SwitchAnimation()
{
anim.SetBool("Walk", isWalk);
anim.SetBool("Chase", isChase);
anim.SetBool("Follow", isFollow);
}

void SwitchStates()
{
//如果发现player,切换到CHASE
if (FoundPlayer())
{
enemyStates = EnemyStates.CHASE;
}
switch (enemyStates)
{
case EnemyStates.GUARD:
break;
case EnemyStates.PATROL:
break;
case EnemyStates.CHASE:
//TODO:追Player
//TODO:拉脱回到上一个状态
//TODO;在攻击范围内攻击
//TODO:配合动画
isWalk = false;
isChase = true;
agent.speed = speed;
if (!FoundPlayer())
{
isFollow = false;
//因为超范围后怪物目的地是人物当时脱离的位置,所以会有一定延迟,所以要加上这句代码
agent.destination = transform.position;

}
else
{
isFollow = true;
agent.destination = attackTarget.transform.position;
}
break;
case EnemyStates.DEAD:
break;
}
}

bool FoundPlayer()
{
var colliders = Physics.OverlapSphere(transform.position, sightRadius);

foreach(var target in colliders)
{
if (target.CompareTag("Player"))
{
attackTarget = target.gameObject;
return true;
}
}
attackTarget = null;
return false;
}
}

如果想让监视的范围可视化也可以写下面这个函数

1
2
3
4
5
6
7
//让选定的范围显示的函数
private void OnDrawGizmosSelected()
{
Gizmos.color = Color.blue;
//注意Sphere是全填充球形,WireSphere才是显示线状球形
Gizmos.DrawWireSphere(transform.position, sightRadius);
}

Patrol Randomly随机巡逻

EnemyController的case Patrol中更新代码大致如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
case EnemyStates.PATROL:

isChase = false;
agent.speed = speed * 0.5f;//注意乘法比除法开耗要小
//判断是否到了随机巡逻点
if (Vector3.Distance(wayPoint, transform.position) <= agent.stoppingDistance)
{
isWalk = false;
//到了目标点停一会
if (remainLookAtTime > 0)
remainLookAtTime -= Time.deltaTime;
else
GetNewWayPoint();
}
else
{
isWalk = true;
agent.destination = wayPoint;
}
break;

更新新函数

1
2
3
4
5
6
7
8
9
10
11
12
13
void GetNewWayPoint()
{
remainLookAtTime = lookAtTime;
float randomX = Random.Range(-patrolRange, patrolRange);//Random是Unity自己的
float randomZ = Random.Range(-patrolRange, patrolRange);
//在3D游戏中y轴是高度
Vector3 randomPoint = new Vector3(guardPos.x + randomX,transform.position.y,guardPos.z + randomZ);

//判断目标点是不是不可行走区域
NavMeshHit hit;
//第四个参数是Navigation里面的层级
wayPoint = NavMesh.SamplePosition(randomPoint, out hit, patrolRange, 1) ? hit.position : transform.position;
}

数据

使用ScriptObjectable

人物基本属性

  • CharacterData_SO脚本
1
2
3
4
5
6
7
8
9
10
11
12
13
14
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

//此注解让菜单选项中出现这个代码文件
[CreateAssetMenu(fileName = "New Data", menuName = "Character Stats/Data")]
public class CharacterData_SO : ScriptableObject
{
[Header("Stats Info")]
public int maxHealth;
public int currentHealth;
public int baseDefence;
public int curentDefence;
}
  • CharacterStats脚本
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class CharacterStats : MonoBehaviour
{
public CharacterData_SO characterData;

#region Read from Data_SO
public int MaxHealth {
get
{
if (characterData != null)
return characterData.maxHealth;
else
return 0;
}
set
{
characterData.maxHealth = value;
}
}

public int CurrentHealth
{
get
{
if (characterData != null)
return characterData.currentHealth;
else
return 0;
}
set
{
characterData.currentHealth = value;
}
}

public int BaseDefence
{
get
{
if (characterData != null)
return characterData.baseDefence;
else
return 0;
}
set
{
characterData.baseDefence = value;
}
}

public int CurrentDefence
{
get
{
if (characterData != null)
return characterData.curentDefence;
else
return 0;
}
set
{
characterData.curentDefence = value;
}
}
#endregion
}

攻击属性

添加Attack_SO类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

[CreateAssetMenu (fileName= "New Data", menuName = "Attack/Attack Data")]
public class AttackData_SO : ScriptableObject
{
public float attackRange;
public float skillRange;
public float coolDown;
public int minDamage;
public int maxDamage;

//暴击
//暴击百分比
public float criticalMutiplier;
//暴击率
public float criticalChance;

}

在EnemyController中增加三个函数Attack,TargetInAttackRange和TargetInSkillRange

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
void Attack()
{
transform.LookAt(attackTarget.transform);
if (TargetInAttackRange())
{
//近身攻击动画
anim.SetTrigger("Attack");
}
else if(TargetInSkillRange()){
//技能攻击动画
anim.SetTrigger("Skill");
}
}
bool TargetInAttackRange()
{
if (attackTarget != null)
return Vector3.Distance(attackTarget.transform.position, transform.position) <= characterStats.attackData.attackRange;
else
return false;
}

bool TargetInSkillRange()
{
if (attackTarget != null)
return Vector3.Distance(attackTarget.transform.position, transform.position) <= characterStats.attackData.skillRange;
else
return false;
}

在CharacterData里添加暴击判断,并更新EnemyController的case CHASE中代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
case EnemyStates.CHASE:
isWalk = false;
isChase = true;
agent.speed = speed;
if (!FoundPlayer())
{
//拉脱回到上一个状态
isFollow = false;
//到了目标点停一会
if (remainLookAtTime > 0)
{
//因为超范围后怪物目的地是人物当时脱离的位置,所以会有一定延迟,所以要加上这句代码
agent.destination = transform.position;
remainLookAtTime -= Time.deltaTime;
}//如果拉脱回到原来的状态
else if (isGuard)
enemyStates = EnemyStates.GUARD;
else
enemyStates = EnemyStates.PATROL;

}
else
{
isFollow = true;
agent.isStopped = false;
agent.destination = attackTarget.transform.position;
}
if(TargetInAttackRange() || TargetInSkillRange())//如果处在近距和远距攻击范围内
{
isFollow = false;
agent.isStopped = true;//如果不停会把人推走

if(lastAttackTime < 0)
{
lastAttackTime = characterStats.attackData.coolDown;

//暴击
characterStats.isCritical = Random.value < characterStats.attackData.criticalChance;//value随机返回0到1
//执行攻击
Attack();
}
}
break;

攻击数值计算

在CharacterStats中添加以下代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#region Character Combat


public void TakeDamage(CharacterStats attaker , CharacterStats defener)
{
int damage = Mathf.Max(attaker.CurrentDamage() - defener.CurrentDefence,0);//取和0之间谁更大
CurrentHealth = Mathf.Max(CurrentHealth - damage, 0);

//TODO:Update UI
//TODO:经验update
}

private int CurrentDamage()
{
float coreDamage = UnityEngine.Random.Range(attackData.minDamage, attackData.maxDamage);

if (isCritical)
{
coreDamage *= attackData.criticalMutiplier;
Debug.Log("暴击" + coreDamage);
}

return (int)coreDamage;
}

#endregion

在EnemyController和PlayController中添加hit方法

1
2
3
4
5
6
7
8
9
10
11
void Hit()
{
//要确保我们没跑开,否则会报错
if (attackTarget != null)
{

var targetStats = attackTarget.GetComponent<CharacterStats>();

targetStats.TakeDamage(characterStats, targetStats);
}
}
  • 在PlayController中的Hit方法不用加判断攻击对象是否为空
  • 在动画里添加事件调用Hit函数

守卫状态和死亡状态

守卫状态

EnemyController的switch处加如下代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
case EnemyStates.GUARD:
isChase = false;

if(transform.position != guardPos)
{
isWalk = true;
agent.isStopped = false;
agent.destination = guardPos;
//SqrMagnitude也能计算两三维向量坐标点距离,开销会略小
//原因是Distance其实是SqrMagnitude的开方
if (Vector3.SqrMagnitude(guardPos - transform.position) <= agent.stoppingDistance*agent.stoppingDistance)
{
isWalk = false;
transform.rotation = Quaternion.Lerp(transform.rotation,guardRotation,0.01f);//为了让它不立刻转过来而是慢慢转过来所以调用这个函数
}
}
break;

死亡状态

其实无论玩家还是怪物死亡就是加动画,这个代码就不贴了,下面贴一个case DEAD把switch那补全

1
2
3
4
5
6
case EnemyStates.DEAD:
coll.enabled = false;
agent.enabled = false;

Destroy(gameObject, 2f);
break;
  • 注意由于2秒后才摧毁物体,所以在这期间需要关掉一些可能会出现bug的组件
  • 动画图层权重的问题,其实就是优先级
  • 在Anystates后连接,会出现一个“过渡到自己的选项”,相当于动画循环,一般死亡动画要关闭
  • 触发器触发的动画回来时一般要勾选有退出时间并设定退出时间为1,这样又能完整播放又不会让动画循环