目录
初始环境处理
环境搭建
烘焙实现导航网络
鼠标控制人物移动
鼠标指针切换
相机跟随及视角转换
后处理
实现人物被遮挡也可以呈现半透明的效果
战斗系统
设置敌人的基本属性和状态
攻击敌人
敌人的追击
人物基本属性和数值
带有数值的伤害
守卫状态、死亡状态
泛型单例模式(供后续的Manager使用)
接口实现观察者模式的订阅和广播
制作不同类型的敌人
可远程丢石和被石头反击的怪物
战斗系统完整代码
怪物血条
等级系统
场景切换
使用json文件保存和读取数据
开始界面的UI、动画及相关逻辑
编辑
完整视频演示:使用unity实现的3D RPG游戏_网络游戏热门视频 (bilibili.com)
目录
初始环境处理
环境搭建
烘焙实现导航网络
鼠标控制人物移动
鼠标指针切换
相机跟随及视角转换
后处理
实现人物被遮挡也可以呈现半透明的效果
战斗系统
设置敌人的基本属性和状态
攻击敌人
敌人的追击
人物基本属性和数值
带有数值的伤害
守卫状态、死亡状态
泛型单例模式(供后续的Manager使用)
接口实现观察者模式的订阅和广播
制作不同类型的敌人
可远程丢石和被石头反击的怪物
战斗系统完整代码
怪物血条
等级系统
场景切换
使用json文件保存和读取数据
开始界面的UI、动画及相关逻辑
安装URP
先安装URP
↑安装URP的unity package
新建一个渲染管线:
在edit里设置渲染管线:
升级URP,就可以将人物原本丢失的材质补回来。
然后在rendering-lighting-environment里可以更换天空盒
然后可以在此设置天空盒:
设定阴影渲染
这个设置可以设置最大渲染的阴影的距离,超过这个距离则不再渲染,这个可以节省资源
这里可以调整阴影的各种参数:
这个地方可以修改虚实阴影分成几级,比如这里,超过1级12.5m渲染出来的阴影就会比较虚了。
注意要开启HDR,方便后期进行后处理
这里还可以添加抗锯齿
在window-lighting添加光照文件:
修改模式为烘培,图形为GPU
然后点击右下角的生成光照:
然后点击生成光照:
注意到此时光变成了蓝紫色,这是因为光照默认是用skybox的颜色的,可以在environment这里进行修改:
按住v可以更好的设置景物: ctrl+shift也可以进行吸附
调整相机角度可以通过在scene场景中先找好角度,在选中相机的情况下 然后按下ctrl+shift+f来让相机对准:
可以创建一个空物体作为分界线,以下的物体全是人物:
安装polybrush,在sample里导入含urp的shader
在tools里打开:
这个可以实现多重对称笔刷
柔化可以让起伏更加平滑
第三个刷色,直接刷色无法上色是因为地面并不是polybrush的material
想要上色可以在polybrush自带的material里的shader右键添加材质:
然后将该材质托给地面,则此时可以上色了
这就可以用来初期规划不同的地方用来做什么
希望整个场景变成某一颜色就选中flood
第四个
将预制体放上去后即可实现点击即出prefabs:
按住ctrl去点按即可删除
接下来我们扩大地图,但是单纯的扩大size,物体的顶点数并不会增多,这里可以使用插件:probuilder:
可以按住shift时可以查看其详细信息
按住alt选中上面那个
以此添加plane
不过要注意这个物体的中心在角落:
progrid的使用:
还可以实现展示x或y或z平面的网格。
这里的2代表的是一个格子0.2,移动物体也是按照一次移动0.2实现的。
在tools里可以关闭
点击这个按钮可以使得网格平面由四格变为三格,可以通过创建材质使得其有颜色:
烘焙地面:
对地面选择navigation static
对树的区域同样选中,但是烘焙的时候要选择not walkable:
烘焙成功后 但是不可行走的区域不够精细
为了改变这点
为了使得不可 行走的区域变得精细,需要调整人物的胖瘦高矮
并且在导航这一块也要修改:
上面是静态的障碍物,无法移动的,如果想实现动态的障碍物可以给物体添加障碍物的组件,并且把carve勾选上,就可以实现这块区域在烘焙的时候会被切开来
创建一个鼠标点击事件,将人物拖拽进来,并且选择destionation
随后用ScrennPointToRay,获取鼠标所点击的位置
然后给地面设置tag,给MouseManager添加这段代码:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System;
using UnityEngine.Events;
[System.Serializable]
public class EventVector3 : UnityEvent { }//声明一个事件
public class MouseManager : MonoBehaviour
{
public EventVector3 OnMouseClicked;
RaycastHit hitInfo;
private void Update()
{
Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);//获取射线
Physics.Raycast(ray, out hitInfo);//将射线信息输出给hitInfo
MouseControl();
}
void SetCursorTexture()
{
}
void MouseControl()
{
if (Input.GetMouseButtonDown(0) && hitInfo.collider != null)
{
if (hitInfo.collider.CompareTag("Ground"))
{
OnMouseClicked?.Invoke(hitInfo.point);//点击之后将该位置传给OnMouseClicked设置的位置
}
}
}
}
即可实现人物走向鼠标点击的地方。
这里可以调整速度和方向:
auto braking实现刹车效果。
如果以后切换场景或者新建游戏或者多个场景,则不方便拖拽和保存,接下来介绍另外一种方式,将人物移动的这个函数注册到event当中, 无论切换场景还是新建角色,都可以将人物添加到OnMouseClicked的这个event当中。
为了改变上面所说的,此处将将MouseManager设定为单例模式
用event的方式替代上个注释中的方法,
event事件的使用方式就是需要有别人注册它。
当调用这个方法时,所有注册了这个事件的函数都会被调用!
接下来在PlayerController中写一个MoveToTarget函数:
我们需要实现将这个函数的方法注册到mouseManager刚才的那个事件中,只要那个事件一启用,它就会调用这个函数。
总结一下就是,当我们点击到地面时,它会执行所有注册到该事件上的函数,并通过Invoke传入该函数所需的参数:
OnMouseClicked?.Invoke(hitInfo.point);
//点击之后将该位置传给OnMouseClicked这个事件注册的函数并进行调用
于是接下来就可以实现鼠标点击则人物移动的方式了。
将图片导入进来并更改为Cursor
选择Point,然后再Apply一下
第二个参数用来记录偏移值,是因为要使图片的中心才具有点击效果。
随后即可调整实现鼠标变成指针的效果。
此处先补充一下三个功能:
Move to view
把你选中的游戏对象移动到Scene的中心点:
Align With View
这个用法不移动scene,是将被选中的物体移动到和scene的中心点一致
就要把普通游戏对象和Camera 分开来说了
选中普通的游戏对象:将对象的中心点移动到Scene的中心点一致
选中Camera:让Game视角和Scene视角一致
Align With View to Selected
这个是移动Scene的,把Scene移动到和选中物体的中心点一致
选中普通的游戏对象:将Scene的中心点移动到和对象的中心点一致
选中Camera:让Scene视角和Game视角一致
创建cinemachine并调整:
先将cvm关掉,启用cinemachine中的freelook
将人物拖拽进去,此时可以看到有三个红圈
有三个圈,代表摄像机可以在这三个维度中自由的旋转:
运行游戏即可实现场景的视角切换。
我们将y轴的移动用鼠标滚轮实现(这个名称是源于setting里面的input manager里的名称设定)
修改三个圈的大小
三个圈的实际使用起来的效果如下所示:
如果想在修改中保留数值:
如果想人物移动时相机跟着人物移动的方向可以修改bindingmode:
此处一个小细节,添加一些TODO: FIXME:作为待办事项
给远处添加雾,添加完雾后效果如下所示:
注意要在这里启用:
在Main Camera的rending里也要启用post-processing
开启bloom后效果:
然后添加ToneMapping,:模式ACES
畸变的效果:
此处只需要移动
景深
FocalLength 远处虚化距离,Apeture虚化程度
接下来实现人物在树或者其他遮挡物之后会有一个大概轮廓的效果。
添加一个菲尼尔效果:
这里需要一个颜色,所以创建color:
打开着色器并创建color
接下来希望这个颜色和菲尼尔有一个乘法的效果,这样可以把颜色加到菲尼尔现象当中,
再把color放上来
将其连起来
希望这个菲尼尔效果可以有一些噪点让效果看上去更实际,所以添加一些额外常用的节点。
添加dither,调整参数使其出现密密麻麻的小点,可以用来填充球心
添加一个alpha用于遮挡阈值
最终设定如下所示:
接下来回到游戏 ,实现一个人物走到树木后面就应用这个材质,这就涉及到URP
这个是一个render feature,意思是在渲染的时候会有一些feature可以使用。
接下来给玩家添加player的图层,并且在urp中选择:
这样子就可以实现人物在树后面时也有这种效果:
效果如下:
但是人物在镜头前面的时候会有问题,这时候我们在前景也添加一个render
添加后恢复正常。
此处还有个问题就是无法点击树后面,此处可以将tree选成ignore Raycast即可。
下载rpg monster包并导入,之后要更新一下材质。并写入代码。
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
{
public EnemyStates enemyStates;
private NavMeshAgent agent;
// Start is called before the first frame update
void Start()
{
agent = GetComponent();
}
// Update is called once per frame
void Update()
{
SwitchState();
}
void SwitchState()//实现一个简单的状态切换
{
switch (enemyStates)
{
case EnemyStates.GUARD:
break;
case EnemyStates.PATROL:
break;
case EnemyStates.CHASE:
break;
case EnemyStates.DEAD:
break;
}
}
}
添加tag和layer。
需要为敌人也实现遮挡剔除,因此在pipeline中设定的层除了player多一个enemy即可。
在实现攻击敌人之前先为玩家增添好基础的攻击和移动的动画控制器,此处比较简单就略过了。
再设定一个点击事件:
在playerController中注册该函数:
MoveToEnemy像这种函数可以使用alt+enter填充
可以使用alt+enter实现
给玩家添加攻击动画,注意从攻击到跑步的切换一定要将exit time设置为1,这样才能使得动画播放完毕。
StopAllCoroutines();//使得人物在走向目标的过程中也可以通过点击去往其他地方,打断操作
agent.isStopped = false;//加上这条语句,就解决了人物一旦攻击之后就无法行动的问题
随后在playercontroller中实现攻击后朝敌人移动的代码:
void EventAttack(GameObject target)
{
if (target != null)
{
attackTarget = target;
StartCoroutine(MoveToAttackTarget());
}
}
IEnumerator MoveToAttackTarget()
{
//为防止这次点击后下次agent无法行动了,在这次点击的开头使用复原
agent.isStopped = false;
transform.LookAt(attackTarget.transform);
while (Vector3.Distance(transform.position, attackTarget.transform.position) > 1)
{
agent.destination = attackTarget.transform.position;
yield return null;
}
//当到达指定地点时,命令agent停止
agent.isStopped = true;
//攻击具有cd时间
if (lastAttackTime < 0)
{
animator.SetTrigger("Attack");
lastAttackTime = 0.5f;
}
}
}
走向敌人的方式通过协程进行,加入lastAttackTime用于攻击cd的冷却。
由于导航系统自带的destination会不断移动,所以需要实现当距离为1时停止移动的话使用agent
isStopped为true来执行。相对应的,该协程的初始需要设定isStop为false。
bug:
执行以上的代码后会发现,人物一旦攻击敌人之后再也无法通过点击地面移动,这是因为isStopped设置为了false,所以需要在点击地面移动
效果:
(鼠标指针显示不出来)
此时MouseManger
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Events;
using System;
//[System.Serializable]
//public class EventVector3 : UnityEvent { }//声明一个事件
public class MouseManager : MonoBehaviour
{
//public EventVector3 OnMouseClicked;
public event Action OnMouseClicked;
public event Action OnEnemyClicked;
RaycastHit hitInfo;
public static MouseManager Instance;
public Texture2D point, doorway, attack, target, arrow;
private void Awake()
{
if (Instance != null) Destroy(gameObject);
Instance = this;
}
private void Update()
{
SetCursorTexture();
MouseControl();
}
void SetCursorTexture()
{
Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);//获取射线
//将射线信息输出给hitInfo
if (Physics.Raycast(ray, out hitInfo)) {
switch (hitInfo.collider.gameObject.tag)
{
case "Ground":
Cursor.SetCursor(target, new Vector2(16,16), CursorMode.Auto);
break;
case "Enemy":
Cursor.SetCursor(attack, new Vector2(16, 16), CursorMode.Auto);
break;
}
}
}
void MouseControl()
{
if (Input.GetMouseButtonDown(0) && hitInfo.collider != null)
{
if (hitInfo.collider.CompareTag("Ground"))
{
OnMouseClicked?.Invoke(hitInfo.point);
//点击之后将该位置传给OnMouseClicked这个事件注册的函数并进行调用
}
if (hitInfo.collider.CompareTag("Enemy"))
{
OnEnemyClicked?.Invoke(hitInfo.collider.gameObject);
//点击之后将该位置传给OnMouseClicked这个事件注册的函数并进行调用
}
}
}
}
时设定isStopped为true。
除此之外还有一个bug,当进入走向攻击敌人的状态时无法移动,只能攻击完了才能移动,在此添加即可:
随后即可实现攻击敌人:
为敌人添加检测玩家的代码:
bool FoundPlayer()
{
var colliders = Physics.OverlapSphere(transform.position, sightRadius);
foreach(var target in colliders)
{
if (target.CompareTag("Player"))
{
return true;
}
}
return false;
}
在切换状态时调用:
别忘了给玩家添加碰撞体
补充一点,我们在这里对hierarchy中的player和enemy进行了修改,想要使这些修改覆盖到project里面的可以使用override。
接下来实现chase,在写之前可以通过这样的方式记录要做的事情,方便整理逻辑。
接下来给敌人添加动画控制器,由于敌人有很多运动,运动的切换全看逻辑而不是像player那样的由用户输入,因此此处给enemy的动画添加层级,方便管理。
新建一个攻击层
weight代表权重,override代表覆盖,additive代表叠加。
在attack layer中的基础状态设置为空,因为这样只有在进入攻击状态时,攻击状态的动画才会覆盖普通状态的动画。
Chase和Follow分别作为上面那两个切换的条件。
到此就可以实现敌人的追击了。
加入画线代码使得监视范围可视化。
private void OnDrawGizmosSelected()//有selected代表被选中才会有圆
{
Gizmos.color = Color.blue;
Gizmos.DrawWireSphere(transform.position, sightRadius);
}
接下来实现敌人的巡逻,写一个随即范围
void GetNewWayPoint()
{
float RandomX = Random.Range(-patrolRange, patrolRange);
float RandomZ = Random.Range(-patrolRange, patrolRange);
wayPoint = new Vector3(RandomX, transform.position.y, RandomZ) + transformPos;
}
书写巡逻的函数:
case EnemyStates.PATROL:
isChase = false;
agent.speed = speed * 0.5f;//设置速度为0.5,用乘法的话运算效率比除法高
if (Vector3.Distance(wayPoint, transform.position) <= agent.stoppingDistance)
{
//如果到了一个新的地方则获取新的巡逻点
isWalk = false;
GetNewWayPoint();
}
else
{
isWalk = true;
agent.destination = wayPoint;
}
break;
stopping distance是agent里面自带的:
初始化的时候设定巡逻状态:
void Start()
{
if (isGuard)
{
enemyStates = EnemyStates.GUARD;
}
else
{
enemyStates = EnemyStates.PATROL;
GetNewWayPoint();
}
}
这里运行的时候不小心出了一个bug,到最后发现是speed没有初始化。
接下来就可以实现怪物的移动,但是存在一个问题,如果因为障碍物的原因导致某个地方无法去到,
这个api可以看是否能到目标点,能到则返回true:
加入这段代码即可:
这样人物就不会撞墙了
接下来实现敌人到某个点后会停留,然后巡逻
(记得两个都需要初始化)
每到一个需要巡逻的点后会停留,停留时间够长则到新的点
由getNewWayPoint执行恢复初始化
效果如下:
但是我们注意到一点,敌人远离玩家的时候,脱战时只会停留在原地:
我们可以实现一个敌人此时会在原地站一会,如果时间到则回归到原来的状态:
此时即可实现脱战后回到巡逻点:
此时enemy controller完整代码如下:
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 Settings")]
public float sightRadius;
private GameObject attackTarget;
private float speed;//物体追击速度,当非追击时速度减半
// Start is called before the first frame update
bool isWalk;
bool isChase;
bool isFollow;
bool isGuard;
Vector3 wayPoint;
Vector3 transformPos;
public float lookAtTime=3;//敌人达到某个点后需要停留的时间
private float remainLookAtTime;//敌人此时剩余需要停留的时间
[Header("Patrol State")]
public float patrolRange=8;
void Awake()
{
agent = GetComponent();
anim = GetComponent();
transformPos = transform.position;
transformPos.y = 0;
speed = agent.speed;
remainLookAtTime = lookAtTime;
}
void Start()
{
if (isGuard)
{
enemyStates = EnemyStates.GUARD;
}
else
{
GetNewWayPoint();
enemyStates = EnemyStates.PATROL;
}
}
// Update is called once per frame
void Update()
{
SwitchState();
SwitchAnimation();
//agent.destination = new Vector3(-10, 0, 0);
}
void SwitchAnimation()
{
anim.SetBool("Walk", isWalk);
anim.SetBool("Chase", isChase);
anim.SetBool("Follow", isFollow);
}
void SwitchState()//实现一个简单的状态切换
{
if (FoundPlayer())
{
enemyStates = EnemyStates.CHASE;
Debug.Log("找到player");
}
switch (enemyStates)
{
case EnemyStates.GUARD:
break;
case EnemyStates.PATROL:
Debug.Log("wayPoint is " + wayPoint.x + " " + wayPoint.y + " " + wayPoint.z);
Debug.Log("enemy is patrol");
isChase = false;
agent.speed = speed * 0.5f;//设置速度为0.5,用乘法的话运算效率比除法高
if (Vector3.Distance(wayPoint, transform.position) <= agent.stoppingDistance)
{
//如果到了一个新的地方则获取新的巡逻点
isWalk = false;
if (remainLookAtTime > 0)//停留的时间是否还有剩余?
remainLookAtTime -= Time.deltaTime;
else
GetNewWayPoint();//没有剩余则执行到新的地方
Debug.Log("enemy reach");
}
else
{
isWalk = true;
agent.destination = wayPoint;
Debug.Log("enemy patroling");
}
break;
case EnemyStates.CHASE:
//TODO:追击player
//TODO:在攻击范围内则攻击
//TODO:配合动画
isWalk = false;
isChase = true;
if (FoundPlayer())
{
isFollow = true;
agent.destination = attackTarget.transform.position;
}
else
{
isFollow = false;
if (remainLookAtTime > 0)
{
agent.destination = transform.position;
remainLookAtTime -= Time.deltaTime;
}
else//等待时间结束后,根据玩家原来的状态,回归到原来的状态
{
if (isGuard)
enemyStates = EnemyStates.GUARD;
else
enemyStates = EnemyStates.PATROL;
}
}
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;
}
}
return false;
}
void GetNewWayPoint()
{
remainLookAtTime = lookAtTime;
float RandomX = Random.Range(-patrolRange, patrolRange);
float RandomZ = Random.Range(-patrolRange, patrolRange);
Vector3 randomPoint = new Vector3(RandomX, transform.position.y, RandomZ) + transformPos;
NavMeshHit hit;
wayPoint = NavMesh.SamplePosition(randomPoint, out hit, patrolRange, 1) ? hit.position : transform.position;
}
private void OnDrawGizmosSelected()//有selected代表被选中才会有圆
{
Gizmos.color = Color.blue;
Gizmos.DrawWireSphere(transform.position, sightRadius);
}
}
playerController代码如下:
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;
public class PlayerController : MonoBehaviour
{
private NavMeshAgent agent;
private Animator animator;
private GameObject attackTarget;
private float lastAttackTime = 0f;
// Start is called before the first frame update
void Awake()
{
agent = GetComponent();
animator = GetComponent();
}
void Start()
{
MouseManager.Instance.OnMouseClicked += MoveToTarget;
MouseManager.Instance.OnEnemyClicked += EventAttack;
}
void Update()
{
animator.SetFloat("Speed", agent.velocity.sqrMagnitude);
lastAttackTime -= Time.deltaTime;
}
void MoveToTarget(Vector3 target)
{
agent.isStopped = false;//加上这条语句,就解决了人物一旦攻击之后就无法行动的问题
agent.destination = target;
StopAllCoroutines();//使得人物在走向目标的过程中也可以通过点击去往其他地方,打断操作
}
void EventAttack(GameObject target)
{
if (target != null)
{
attackTarget = target;
StartCoroutine(MoveToAttackTarget());
}
}
IEnumerator MoveToAttackTarget()
{
//为防止这次点击后下次agent无法行动了,在这次点击的开头使用复原
agent.isStopped = false;
transform.LookAt(attackTarget.transform);
//TODO:此处还需将大于1的这个数值修改为根据武器而定
while (Vector3.Distance(transform.position, attackTarget.transform.position) > 1)
{
agent.destination = attackTarget.transform.position;
yield return null;
}
//当到达指定地点时,命令agent停止
agent.isStopped = true;
//攻击具有cd时间
if (lastAttackTime < 0)
{
animator.SetTrigger("Attack");
lastAttackTime = 0.5f;
}
}
}
接下来使用scripts objects实现人物数值的存储和调用。
这种类型的代码可以生成一个asset文件保存到项目当中,非常适合用来存储一系列模板。
创建文件夹以便分类,创建一个脚本以SO(scriptObject)可以让别人一眼就知道是SO文件。
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 basseDefence;
public int currentDefence;
}
然后即可创建该类型文件:
然后就可以在这里改数据
playerdata是scriptobject类型不能挂接到物体身上,此处写一个脚本进行管理。
访问和更改数据的时候用脚本.物体.变量的名字太麻烦,此处用属性书写。
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 CurrrentHealth//为间接可以将其写在一个片段里
{
get { if (characterData != null) return characterData.currentHealth; else return 0; }
set { characterData.maxHealth = 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.currentDefence; else return 0; }
set { characterData.currentDefence = value; }
}
#endregion
}
在首尾添加region可以方便管理。
然后就可以添加脚本并把数据文件放入:
然后就可以在其他脚本里面这样用 ,修改的东西会被修改回数据。
运行脚本时可以看到数据被修改了。
接下来引入攻击数据,创建一个attackdata的SO
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
[CreateAssetMenu(fileName ="New Attack",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 criticalMultipler;//暴击加伤百分比
public float criticalChance;
}
随后在character stats中声明该变量并创建数据文件,然后赋值
在CharacterStats中加入isCritical的判断,但是不想显示在inspector中则加入该属性
在之前的文件中:
改成:
这样子就实现了玩家的攻击范围
接下来实现敌人的攻击范围:
首先在chase中加入攻击的代码:
创建一个变量用于攻击cd的冷却。
Random.Value会返回0到1里的值,可以用此来实现百分比的判断。
void Attack()
{
transform.LookAt(attackTarget.transform.position);
if (TargetInAttackRange())
{
//近身攻击动画
anim.SetTrigger("Attack");
}
if (TargetInSkillRange())
{
//技能攻击动画
anim.SetTrigger("Skill");
}
}
接下来在动画器里加入攻击动画,和判断条件,暴击时使用暴击动画
非暴击时使用非暴击动画:
(除此之外攻击动作一定要完成才能退出,因此这里exit time为1)
然后别忘了设定暴击的判定:
但是此处出现了一个问题,当怪物进入攻击时,是禁止移动的,还需要添加条件当怪物不攻击时,让它移动:
在人物动画里attack2作为暴击的动画,条件的设置和之前一样。
玩家的冷却时间更新:
在character stats里设置伤害数值
判断攻击的方式使用帧事件的方式实现
判断是否暴击的话在攻击之前就要产生判断 所以在attackevent里面实现
hit函数 真是件调用的函数
hit
在特定的时间添加关键帧,并设定函数:
此时即可实现攻击时实现暴击的效果。
接下来实现怪物的暴击,
当我们想在slime的动画里添加关键帧时发是只读
将文件夹里的动画使用ctrl+d复制一份,然后在动画机里替换即可。
之后就可以被编辑了,添加关键帧:
接下来即可实现怪物攻击调用的代码。
接下来补充守卫状态的代码:
用sqr也可以计算距离
接下来即可实现让怪物脱战时回到原来的状态,但是出问题在于回去时脸朝外:
记录下初始的旋转四元数
然后这个可以实现缓慢转动然后
接下来即可实现回去后仍然朝向原来的地方了。
case EnemyStates.GUARD:
isChase = false;
if (transform.position != guardPos)
{
isWalk = true;
agent.destination = guardPos;
if (Vector3.SqrMagnitude(guardPos - transform.position) < agent.stoppingDistance)
{
isWalk = false;
transform.rotation = Quaternion.Lerp(transform.rotation, guardRotation, 0.01f);
}
}
break;
接下来添加受击:
添加一个死亡层级,并把权重设置为1,并建立基础空状态
导入动画并设定条件
(死亡动画由anystate进入)
在死亡动画勾选中为防止其重复播放动画,取消勾选:
给玩家添加一样的死亡层级:
在代码中添加一些设定
(注意此处加else 否则会再次进入chase状态)
受击的动画触发条件则在此处实现:
之后即可实现怪物被杀并播放死亡动画,但是此处存在一个问题,怪物被杀到消失之前仍然可以点击攻击,这是因为我们是通过鼠标事件点击是否有collider,因此怪物死亡时需要立马关掉collider即可。
人物的死亡与此同理设定即可。
注意到每次加载时需要重新设置血量。
case EnemyStates.DEAD:
collider.enabled = false;
//agent.enabled = false;
agent.radius = 0;
Destroy(gameObject, 2f);
break;
创建一个gamemanager,会变成齿轮的样子
此处public是为了此后所有的数据都通过gamemanager进行访问,这样可以方便集中管理数据。
那我们什么时候进行赋值呢,我们这里用观察者模式,反向注册的方法,让player在生成的时候告诉gamemanager我是playerstats。
写完了这个方法,我们希望在player中进行调用。
如果我们希望在player生成的时候就使用,那我们应该将gamemanager像mousemanager一样使用单例模式。这样可以直接使用,就不需要创建并赋值。
但是,在这个游戏中会有很多的manager,我们都希望它是单例模式然后进行访问,每一个都像mousemanager一样去写的话会很麻烦,因此此处使用泛型的单例模式。
如果将所有的manager都继承自这个泛型单例,就可以省下很多的步骤。
举个例子,此处使用单例,并且在后面加约束,约束是T就是那样的单例。
(此处涉及到的相关 知识是泛型约束的概念,所谓的泛型约束,实际上就是约束的类型T。使T必须遵循一定的规则。比如T必须继承自某个类,或者T必须实现某个接口等等。那么怎么给泛型指定约束?其实也很简单,只需要where关键字,加上约束的条件。)
具体可以查看这个文章的第五点:
C#泛型详解 - .NET开发菜鸟 - 博客园 (cnblogs.com)
用这个返回当前的单例模式是否已生成。
可以用这个在之后的订阅注册中判断是否已经生成。这个bool值的变量是通常的泛型模式中会用到的。
注意到上面有一个destroy函数,有一个函数在destroy调用时也会一起调用,就是onDestroy,如果一个场景中有多个单例模式,我们需要将它销毁。
如果当前这个事例被销毁就将它设置为空。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Singleton : MonoBehaviour where T : Singleton
{
private static T instance;
public static T Instance
{
get { return instance; }
}
protected virtual void Awake()
{
if (instance != null) Destroy(gameObject);
else instance = (T)this;
}
public static bool IsInitialized
{
get { return instance != null; }
}
protected virtual void OnDestroy()
{
if (instance == this)
{
instance = null;
}
}
}
于是此处就可以在此将之前的mouseManager继承为单例:
然后这两个就可以不需要了
并且此时也会警告:
此处有一点,通常来说,我们不希望在场景转换的时候manager被消除掉,因此我们需要在awake中写一些东西。
(base.Awake是算数)
同理这里gamemanager也可以继承
然后就可以在playercontroller中直接使用并注册,将信息传递进去。
然后接下来就可以实现一个广播的效果,如果player死了,就执行一个广播,通知所有敌人玩家死了。
有关观察者的详解在此:
(172条消息) 学过的设计模式_晴夏。的博客-CSDN博客
定义一个观察者接口:
所有的观察者都需要继承该接口,并实现该接口,这样才可以作为观察者。
接下来在enemy controller中继承:
继承之后需要实现接口
以后会有多个敌人,都会实现接口。
我们创建一个列表,收集所有实现加载了这个接口的函数方法,那么就代表它是一个敌人,需要订阅游戏结束。
加入列表和移出列表:
在enemycontroller中的启用和销毁调用该代码:
这里有一点需要注意,当人物消失时,
所以结束游戏时会出现这样的报错:
怎么实现广播呢?
然后当死亡时执行该方法:
在怪物的win条件中增加
但是光这样不行,因为即使此时敌人的动画被设定为了win,但此时enemy的update函数仍然会继续进行,切换状态到攻击状态。
因此需要设定一个变量:
因此此处加入判断条件:
但是这样子仍然不行,因为此时处于attack layer中
而attack layer的动画会覆盖掉上面的动画:
所以此处还得是这个动画:
除了原有的史莱姆外,我们新增一种不会巡逻的乌龟,可将玩家推开的兽人,可以远程投掷石头的石头人。
在增添不同种类的敌人时,先解决一个问题,我们产生新的怪物时不能直接简单的复制,因为每个怪物都应该有一个自己特有的数据。如果直接复制则会产生复制出来的敌人公用同一个数据的问题。
复制模板数据然后复制即可
-使用 Animator override controller 制作 乌龟 敌人
对于动画器使用override创建的controller,可以直接覆盖动画即可。
对乌龟的数据设定如下:
-添加 兽人 和 石头人 的素材简单摆放
下载兽人和石头人素材
对于兽人的动画,创建动画器时不使用override而是直接复制,因为使用override,如果修改会对原版也产生修改,所以使用复制的版本。
可以修改兽人和石头人材质,修改其金属质感和光滑程度(光滑会决定反光程度)
创建脚本和一些数据
让它继承enemycontroller动画
然后添加帧事件
在attack01中添加这个帧事件:
attack02同理:
将enemycontroller中的attacktarget改成protected,以便于继承。
attck02有两个函数需要执行,除了上面的攻击,此处还实现一个推开的函数:
击飞:
修改一下数据
attakforce改为15
接下来实现玩家被击飞时实现眩晕的效果:
在玩家这里添加dizzy眩晕动画:
在kick off里添加眩晕的代码
实测发现动画播放速度太慢,则调整播放动画速度:
此处还有个问题,处于眩晕状态时,人物依然可以移动。我们希望被攻击的时候不会发生移动,
所以此处选中hit状态,添加add behaviour。
添加animation behaviour文件夹。
把该脚本放进去。
动画状态机的脚本与普通脚本不同,继承的是stateMachineBehaviour。
我们在动画进入时,设定agent的isStopped为真代表无法移动,结束时再将其设定为可以移动。
除此之外,物体正在移动时,isStopped也必须设置为true,这是因为
每当我们点击去往某个地方时,agent的isStopped都是false,意味着此时将会变得可以移动。
所以我们需要保证动画运行时候agent也是不能移动的,才这样:
这样就实现了眩晕时候无法再次移动。
此处有另外一个问题,当敌人攻击玩家时,如果玩家移动敌人会跟着移动,那么就会出现敌人边攻击边移动。
实现这个办法只需要在attack的动画中添加之前那个无法移动的behaviour即可。
前面设置的史莱姆同理(史莱姆设置之后乌龟不需要再次设置)。
接下来,当玩家杀死敌人时会出现一个报错。 这是因为敌人死的时候这个命令还在执行,
这是因为死亡时我们设置了:
回忆一下此处我们设置它为false是因为不希望死了之后还挡住人,那只需要设置半径为0即可。
这样子就可以消除bug。
此处补充一个,人物旋转时 相机可能就不会跟着旋转,设定下面这个选项可以使得游戏运行时相机始终看着人物前方。
兽人代码:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;
public class Grunt : EnemyController
{
[Header("Basic Setting")]
public float kickForce = 1f;
public void KickOff()
{
Debug.Log("执行推开函数");
if (attackTarget)
{
Debug.Log("目标存在,推开");
if(attackTarget.GetComponent().isStopped == true)
{
Debug.Log("玩家此时agent静止");
}
transform.LookAt(attackTarget.transform);
Vector3 direction = attackTarget.transform.position - transform.position;
direction.Normalize();
attackTarget.GetComponent().isStopped = true;
attackTarget.GetComponent().velocity = kickForce*direction;
attackTarget.GetComponent().SetTrigger("Dizzy");
}
}
}
我们希望实现玩家在敌人的前方120度的范围内攻击才会受到伤害,否则不会受到伤害。
方法如下: 判断两个向量的点积如果大于0.5,则说明在范围内。
此处虽然可以不使用拓展方法,但是为了使用这个用法,所以使用下面的写法:
扩展方法就是在现有的类中去延展方法:
后面那个则是所需的参数
使用起来方法如下:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;
public class Grunt : EnemyController
{
[Header("Basic Setting")]
public float kickForce = 1f;
public void KickOff()
{
Debug.Log("执行推开函数");
if (attackTarget)
{
Debug.Log("目标存在,推开");
if(attackTarget.GetComponent().isStopped == true)
{
Debug.Log("玩家此时agent静止");
}
transform.LookAt(attackTarget.transform);
Vector3 direction = attackTarget.transform.position - transform.position;
direction.Normalize();
attackTarget.GetComponent().isStopped = true;
attackTarget.GetComponent().velocity = kickForce*direction;
attackTarget.GetComponent().SetTrigger("Dizzy");
}
}
}
创建一个石头人的动画器,源自重载的兽人animator。
再写一个代码,继承自enemycontroller。为了子类能使用这个变量,将其设定为protected
设定数据:
最终代码如下:
kick off会存在一个问题,玩家静止时无法改变其速度,也就是无法击退,要将auto braking 的勾选关掉。
接下来对之前代码产生的一些小问题进行修复:
回顾一下我们是怎么让玩家进行攻击的?:
当点击时会触发attack事件,然后执行移动,当敌人在玩家的攻击范围以外时,就会将敌人所在位置设置为目标点。当敌人进入玩家攻击范围时,设置玩家停止。(但是此时玩家的目标点仍然是敌人的position。)
此处修改停止距离,
设计一个变量获取初始时的停止距离:
然后此处修改停止距离为武器半径距离。
但是平常的时候我们希望停止距离是玩家的半径。
接下来修复另外一个问题,当人物受伤或者玩家受伤动画时,此时仍然可以触发攻击效果。
在gethit的behaviour里面添加
stop agent 的状态机代码:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;
public class StopAgent : StateMachineBehaviour
{
// OnStateEnter is called when a transition starts and the state machine starts to evaluate this state
override public void OnStateEnter(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
{
animator.GetComponent().isStopped = true;
}
// OnStateUpdate is called on each Update frame between OnStateEnter and OnStateExit callbacks
override public void OnStateUpdate(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
{
animator.GetComponent().isStopped = true;
}
// OnStateExit is called when a transition ends and the state machine finishes evaluating this state
override public void OnStateExit(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
{
animator.GetComponent().isStopped = false;
}
// OnStateMove is called right after Animator.OnAnimatorMove()
//override public void OnStateMove(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
//{
// // Implement code that processes and affects root motion
//}
// OnStateIK is called right after Animator.OnAnimatorIK()
//override public void OnStateIK(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
//{
// // Implement code that sets up animation IK (inverse kinematics)
//}
}
为石头人boss添加丢石头
将石头预制件导入,添加代码碰撞体
石头代码:
将石头作为prefab
在石头人这里添加变量
并将手的位置赋值进去,以及rock预制体
记得补充石头人的攻击范围还有扔石头的力:
当手举到最高点时候添加rock函数:
帧事件中的扔石头:
注意,石头要添加mesh collider,并且还要勾选上convex才行:
接下来即可实现扔石头。
但是有一个问题,石头人开始做丢石头的动作时,如果这个时候人物远离怪物的距离,则虽然会进行丢石头的动画,但是不会产生石头。
这是因为上面这里:
如果attackTarget为空,则岩石也无法生成。
所以加一句话:
这样即使玩家此时走出范围依旧有效。
除此之外,此处还有一个攻击范围的问题,我们希望离玩家远时用skii,近时用attack的话,那我们要保证同时只有一个条件为true。
如下图所示 ,添加条件保证同时只有一个条件成立。
还有一个问题,注意到只有玩家处于敌人视野时才会进入攻击状态,因此如果视野为10,但是丢石头的skil range为20,那么此时是没有用的。
因此如果想扩大石头人的攻击范围,得同时修改石头人的视野范围:
接下来添加一些石头的状态:
石头需要产生伤害,但是注意到之前的take damage用的函数参数是两个states,此处重载一个:
然后在石头函数里写一个碰撞函数:
记得对石头状态初始化:
这样就可以实现石头的击退合受伤效果。
但是有个问题在于玩家被击晕后会自动回到原来的destiantion。修改此处即可,将动画状态机的exit函数注释掉即可,这样就不会自动回去。
另一方面,石头可以用来反击石头人。
思路就是可以反推回石头对石头人造成伤害,当石头触碰到玩家或者速度小于1时,则将石头状态改为hitnothing,即可以反推的状态。
在玩家点击时如果点击的是石头则将其传入event中,然后让石头反推回去。
具体的用法就是:
先给石头添加Attackable标签
在鼠标点击事件里添加一个控制函数,如果点击的是可攻击类的标签则将其传回event事件。
状态改为public
在player的hit中修改,如果触碰的是可攻击的物体则调用石头进行攻击。
并且当石头状态为hitNothing时才能反击。(虽然这个条件可以删除)
并且当攻击了石头后,石头自动变为hitEnemy状态。
接下来设定石头什么时候进入hitnothing的状态呢?有两种,一个是没碰到任何事物
一个是通过速度来判断:
可以暂停下来通过后面这个按钮逐帧播放进行调试:
逐帧判断之后发现石头刚生成的一瞬间速度是为0的。然后初始时设立状态为one,这样防止刚开始时就将其状态设置为hitNothing。
同理,当player刚赋予岩石速度时,就要设置成Vector3.one,以防止其进入hitNothing状态。
除此之外,此处有个问题,在于玩家会穿过石头,但是玩家有了agent,不能直接添加rigidbody,否则会产生冲突,因此此时使用isKinematic的rigidbody,防止其受力的效果。(添加了rigidbody后就可以和石头产生碰撞效果。)
这样就可以实现产生石头和石头的反击。
接下来实现石头消失后的粒子效果:
关闭循环,设定持续时间,设定重力效果,以及随时间出来多少个
实现碰撞:
设定粒子形状:
调整大小:
调整碎片炸裂角度:
然后在rock消失前产生碎片即可。
最终效果:
石头人代码:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;
public class Golem : EnemyController
{
[Header("Skill")]
public float kickForce = 20;
public GameObject rockPrefab;
public Transform handPos;
public bool agentIsStopped;
public void ThrowRock()
{
if (attackTarget == null) attackTarget = FindObjectOfType().gameObject;
if (attackTarget != null)
{
var rock = Instantiate(rockPrefab,handPos.position,Quaternion.identity);
rock.GetComponent().target = attackTarget;
}
}
public void KickOff()
{
Debug.Log("执行推开函数");
if (attackTarget)
{
Debug.Log("目标存在,推开");
//if (attackTarget.GetComponent().isStopped == true)
//{
//}
agentIsStopped = attackTarget.GetComponent().isStopped;
transform.LookAt(attackTarget.transform);
Vector3 direction = attackTarget.transform.position - transform.position;
direction.Normalize();
attackTarget.GetComponent().velocity = kickForce * direction;
Vector3 velocity = attackTarget.GetComponent().velocity;
Debug.Log("kickForce:" + kickForce);
Debug.Log("direction:" + direction.x+" "+direction.y+" "+direction.z);
Debug.Log("velocity:" + velocity.x + " " + velocity.y + " " + velocity.z);
//attackTarget.GetComponent().isStopped = true;
attackTarget.GetComponent().SetTrigger("Dizzy");
}
}
}
石头代码:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;
public class Rock : MonoBehaviour
{
public enum RockStates { HitPlayer,HitEnemy,HitNothing};
Rigidbody rb;
[Header("Basic Settings")]
public float force=10;
public GameObject target;
private Vector3 direction;
public RockStates rockStates;
int rockDamage = 10;
public GameObject breakPatrical;
private void Start()
{
rb = GetComponent();
rockStates = RockStates.HitPlayer;
FlyToTarget();
rb.velocity = Vector3.one;
}
private void FixedUpdate()
{
if (rb.velocity.sqrMagnitude < 1)
{
rockStates = RockStates.HitNothing;
}
Debug.Log("rockStates:" + rockStates);
}
public void FlyToTarget()
{
direction = (target.transform.position - transform.position + Vector3.up).normalized;
rb.AddForce(direction * force,ForceMode.Impulse);
}
private void OnCollisionEnter(Collision collision)
{
switch (rockStates)
{
case RockStates.HitPlayer:
if (collision.gameObject.CompareTag("Player"))
{
collision.gameObject.GetComponent().isStopped = true;
collision.gameObject.GetComponent().velocity = direction * force;
collision.gameObject.GetComponent().SetTrigger("Dizzy");
collision.gameObject.GetComponent().TakeDamage(rockDamage,collision.gameObject.GetComponent());
rockStates = RockStates.HitNothing;
}
break;
case RockStates.HitEnemy:
if (collision.gameObject.GetComponent())
{
var otherStats=collision.gameObject.GetComponent();
otherStats.TakeDamage(rockDamage, otherStats);
Instantiate(breakPatrical, transform.position, Quaternion.identity);
Destroy(gameObject);
}
break;
}
}
}
CharaStats:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class CharacterStats : MonoBehaviour
{
public CharacterData_SO characterData;
public CharacterData_SO templateData;
#region Read from Data_SO
private void Awake()
{
if (templateData != null) characterData = Instantiate(templateData);
}
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.currentDefence; else return 0; }
set { characterData.currentDefence = value; }
}
#endregion
public AttackData_SO attackData;
[HideInInspector]
public bool isCritical;
public void TakeDamage(CharacterStats attacker,CharacterStats defener)
{
int damage = Mathf.Max(attacker.CurrentDamage() - defener.CurrentDefence,0);
//Debug.Log("受到" + damage + "伤害!");//受到多少伤害
CurrentHealth = Mathf.Max(CurrentHealth - damage,0);
if (attacker.isCritical)
{
defener.GetComponent().SetTrigger("Hit");
}
}
public void TakeDamage(int damage,CharacterStats defener)
{
int currentDamage = Mathf.Max(damage - defener.CurrentDefence, 0);
CurrentHealth = Mathf.Max(CurrentHealth - currentDamage, 0);
}
private int CurrentDamage()
{
float coreDamage = UnityEngine.Random.Range(attackData.minDamage, attackData.maxDamage);
if (isCritical)
{
coreDamage *= attackData.criticalMultipler;
//Debug.Log("暴击!" + coreDamage);
}
else
{
//Debug.Log("没暴击!" + coreDamage);
}
return (int)coreDamage;
}
}
playercontroller:
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;
public class PlayerController : MonoBehaviour
{
private NavMeshAgent agent;
private Animator animator;
private GameObject attackTarget;
private float lastAttackTime = 0f;
private CharacterStats characterStats;
private bool isDead;
// Start is called before the first frame update
void Awake()
{
agent = GetComponent();
animator = GetComponent();
characterStats = GetComponent();
}
void Start()
{
MouseManager.Instance.OnMouseClicked += MoveToTarget;
MouseManager.Instance.OnEnemyClicked += EventAttack;
characterStats.CurrentHealth = characterStats.MaxHealth;
GameManager.Instance.RigisterPlayer(characterStats);//将信息注册进去
}
void Update()
{
lastAttackTime -= Time.deltaTime;
isDead = characterStats.CurrentHealth == 0;
SwitchAnimation();
if (isDead) GameManager.Instance.NotifyObservers();
Vector3 des = agent.destination;
//Debug.Log("des:" + des.x + " " + des.y + " " + des.z);
}
private void SwitchAnimation()
{
animator.SetFloat("Speed", agent.velocity.sqrMagnitude);
animator.SetBool("Death", isDead);
}
void MoveToTarget(Vector3 target)
{
StopAllCoroutines();//使得人物在走向目标的过程中也可以通过点击去往其他地方,打断操作
if (isDead) return;
agent.isStopped = false;//加上这条语句,就解决了人物一旦攻击之后就无法行动的问题
agent.destination = target;
}
void EventAttack(GameObject target)
{
if (isDead) return;
if (target != null)
{
attackTarget = target;
characterStats.isCritical = UnityEngine.Random.value < characterStats.attackData.criticalChance;
StartCoroutine(MoveToAttackTarget());
}
}
IEnumerator MoveToAttackTarget()
{
//为防止这次点击后下次agent无法行动了,在这次点击的开头使用复原
agent.isStopped = false;
transform.LookAt(attackTarget.transform);
while (Vector3.Distance(transform.position, attackTarget.transform.position) > characterStats.attackData.attackRange)
{
agent.destination = attackTarget.transform.position;
yield return null;
}
//当到达指定地点时,命令agent停止
agent.isStopped = true;
//攻击具有cd时间
if (lastAttackTime < 0)
{
animator.SetBool("Critical", characterStats.isCritical);
animator.SetTrigger("Attack");
lastAttackTime =characterStats.attackData.coolDown;
}
}
void Hit()
{
if (attackTarget.CompareTag("Attackable"))
{
if (attackTarget.GetComponent() && attackTarget.GetComponent().rockStates == Rock.RockStates.HitNothing)
{
attackTarget.GetComponent().velocity = Vector3.one;
attackTarget.GetComponent().rockStates = Rock.RockStates.HitEnemy;//一旦发动攻击则将状态设置为hitEnemy
attackTarget.GetComponent().AddForce(transform.forward * 20, ForceMode.Impulse);
}
}
else {
var targetStats = attackTarget.GetComponent();
targetStats.TakeDamage(characterStats, targetStats);
}
}
}
添加血条
添加一个画布,可以看到默认的方式,以2d形式查看是覆盖整个面的。
我们想要用世界坐标的,修改:然后
reset position。
创建文件夹添加包:
将包中的拖进来
然后将这个square放进ui里,然后再创建一个子图片,并修改
然后将其作为预制体
创建一个代码并在此将prefab拖入:(而不是挂接到物体身上再拖入)
创建UI的代码
并在project中为其赋值好
为所有敌人的预制体添加该脚本并调整血条位置,然后创建空物体用作指代血条位置:
接下来我们需要在take damage处修改UI:
此处使用action实现,每个action的事件触发的时候,能够激活所有订阅它的函数。
在此处创建一个事件:
尖括号里的int代表订阅它的函数必须有两个int型的参数。
上面实现了启用事件的方法,下面写事件具体执行的方法:修改血条以及添加订阅。
但是此处还没有生成,接下来写生成:
(初始时血条不可见,攻击时才可见)
(选中函数按下f2键可以统一修改选中的函数。)
然后为每个怪物添加UI脚本,并将位置赋值
这样子即可实现血条:
但是血条不会跟着人走,因此此处修改。
让血条的位置跟着玩家身上的位置走,但是朝向朝向玩家的反向。
接下来希望实现过一段时间,血条消失,用一个计时器
记得在脚本里添加这个更新使得血条的位置可以跟随玩家移动,视角也可以转换
private void LateUpdate()
{
if (UIbar != null)
{
UIbar.position = barPoint.position;
UIbar.forward = -cam.forward;
}
}
完整代码:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
public class HealthBarUI : MonoBehaviour
{
public GameObject healthUIPrefab;
public Transform barPoint;//固定在怪物上方的UI的位置
Image healthSlider;
Transform UIbar;//当前UI位置
Transform cam;//用于使当前UI朝向对应的方向
CharacterStats currentStats;
public bool alwaysVisible;//该参数实现是否可见
public float visibleTime;
void Awake()
{
currentStats = GetComponent();
currentStats.UpdateHealthBarOnAttack += UpdateHealthBar;
}
void OnEnable()
{
//实现对所有的
cam = Camera.main.transform;
foreach(Canvas canvas in FindObjectsOfType
在characterStats中添加代码:
using System;
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 currentDefence;
[Header("Kill")]
public int killPoint;
[Header("Level")]
public int currentLevel;
public int maxLevel;
public int baseExp;
public int currentExp;
public float levelBuff;
public float LevelMultiplier
{
get { return 1 + (currentLevel - 1) * levelBuff; }
}
public void UpdateExp(int point)
{
currentExp += point;
if (currentExp >= baseExp)
LevelUp();
}
private void LevelUp()
{
//所有想提升的数据方法
currentLevel = Mathf.Clamp(currentLevel + 1, 0, maxLevel);
baseExp += (int)(baseExp * LevelMultiplier);
maxHealth = (int)(maxHealth * LevelMultiplier);
currentHealth = maxHealth;
Debug.Log("Levelup" + currentLevel + " Max Health:" + maxHealth);
}
}
然后再设定数据:
加入time可以实现随时间旋转的效果:
然后用这个创建材质,创建Quad并放入
开了这个才能看到动画效果
关掉传送门的阴影
将参数color中的模式改成HDR
,即可在inspector窗口中修改亮度了
为了方便修改cell density,设定一个变量作为其输入:
这样可以使得强度更亮。
接下来实现传送门代码的实现:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class TransitionDestination : MonoBehaviour
{
public enum DestinationTag
{
ENTER,A,B,C
}
public DestinationTag destinationTag;
}
这个设定传送门的一些信息
下面这个设定逻辑:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class TransitionPoint : MonoBehaviour
{
public enum TransitionType
{
SameScene,DifferentScene
}
private bool canTrans;
[Header("Transition Info")]
public string sceneName;
public TransitionType transitionType;
public TransitionDestination.DestinationTag destinationTag;
void OnTriggerStay(Collider other)
{
if (other.CompareTag("Player"))
canTrans = true;
}
private void OnTriggerExit(Collider other)
{
if (other.CompareTag("Player"))
canTrans = false;
}
void Update()
{
if (Input.GetKeyDown(KeyCode.E) && canTrans)
{
//TODO:
SceneController.Instance.TransitionToDestination(this);
}
}
}
再写一个场景内位置的转换:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.SceneManagement;
public class SceneController : Singleton
{
GameObject player;
public void TransitionToDestination(TransitionPoint transitionPoint)
{
switch (transitionPoint.transitionType)
{
case TransitionPoint.TransitionType.SameScene:
StartCoroutine(Transition(SceneManager.GetActiveScene().name, transitionPoint.destinationTag));
break;
case TransitionPoint.TransitionType.DifferentScene:
break;
}
}
IEnumerator Transition(string sceneName,TransitionDestination.DestinationTag destinationTag)
{
player = GameManager.Instance.playerStats.gameObject;
player.transform.SetPositionAndRotation(GetDestionation(destinationTag).transform.position, GetDestionation(destinationTag).transform.rotation);
yield return null;
}
//此处以组件作为返回类型,是因为我们需要该组件的位置和朝向,而一个函数只能有一个返回值,此处通过获取该组件然后得到其位置和朝向
private TransitionDestination GetDestionation(TransitionDestination.DestinationTag destinationTag)
{
var entrances = FindObjectsOfType();
for(int i = 0; i < entrances.Length; i++)
{
if (entrances[i].destinationTag == destinationTag)
return entrances[i];
}
return null;
}
}
这个点设定的tag是A,代表传送的目标点是A
在这个门下面的子物体设定的就是该点是什么点:
传送门的鼠标设定:
以及移动的位置调用:
为了使得位置的移动比较精确,可以修改传送门的trigger大小:
防止传送后人走回去,设定停止:
跨场景的切换
除此之外,还需要在传送门的inspetor中设定传送的目标点和名字。
为使得跨场景传送后仍然有玩家,需要将几个manager跨场景保留:(GameManager、MouseManager同理)
并且还要设定玩家的preafab。
为使得传送回来仍然可以有相机跟随:
效果如下:
对于玩家来说,我们将其数据的存储方式改为和其他敌人一样,用template data赋值给character data的模式。
记得apply to prefab
使用PlayerPrefs来存储数据:
查阅手册可得它只能保留这三种类型的值:
这里只能保存string类型的,要怎么保存SO类型的呢?
使用JSON可以将SO序列化存储后导出成string的形式,
官方手册里:
先将其SO保存成json类型,然后再保存成string类型即可保存在磁盘中。
代码如下:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class SaveManager : Singleton
{
protected override void Awake()
{
base.Awake();
DontDestroyOnLoad(this);
}
private void Update()
{
if (Input.GetKeyDown(KeyCode.S))
{
SavePlayerData();
}
if (Input.GetKeyDown(KeyCode.L))
{
LoadPlayerData();
}
}
public void SavePlayerData()
{
Save(GameManager.Instance.playerStats.characterData, GameManager.Instance.playerStats.characterData.name);
}
public void LoadPlayerData()
{
Load(GameManager.Instance.playerStats.characterData, GameManager.Instance.playerStats.characterData.name);
}
public void Save(Object data,string key)
{
var jsonData = JsonUtility.ToJson(data,true);
PlayerPrefs.SetString(key, jsonData);
PlayerPrefs.Save();
}
public void Load(Object data,string key)
{
if (PlayerPrefs.HasKey(key))
{
JsonUtility.FromJsonOverwrite(PlayerPrefs.GetString(key), data);
}
}
}
保存后存储下的数据:
场景切换时自动保存数据,如果切换到不同场景则自动读取数据:
这样就可以实现切换场景仍然保留数据
创建一个新的场景,然后设定画布
然后设定下名字和一个游戏的按钮:
之后为了做一些特效,此处就选择这个效果, 并将main camera放进去
这个时候的渲染模式就是把画布放在摄像机前面了一样,此时如下图所示,按钮就会被地面所挡住
此处可以实现一个点击开始游戏之后,摄像机前移而字后移的效果,那就是把画布先放到前面作为渲染相机的模式,然后再改为作为world space的模式。
接下来设定场景中的按钮:达到按下显色的效果
点击开始游戏后需要进行场景切换和生成,此处不能调用之前那个scene,因此我们新写一个协程:
还需要在初始场景设定一个传送门。
新的协程:
将读取入口的操作放在一直存在的GameManager:
新的协程:
这样即可实现new game。
接下来实现continue game:
存储时多一个把当前场景也存进去:
然后 将continuegame的函数在此实现
而加载数据,放在PlayerController中的Start一开始去实现:
此处对之前的一个地方做更改,将注册信息放在OnEnable中实现:
然后在MainMenu中添加场景的转换函数:
此处在添加一个回到初始界面的场景操作:
然后当按下Esc键时,调用该函数:
接下来就可以进行从开始界面进入标题界面,再从标题界面进入游戏界面了。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.Playables;
public class MainMenu : MonoBehaviour
{
Button newGameBtn;
Button continueBtn;
Button quitBin;
PlayableDirector director;
private void Awake()
{
newGameBtn = transform.GetChild(1).GetComponent
然后为相机添加时间轴,然后将camera拖拽进来,并录制:
选中camera,在position处右键添加key,然后录制相机移动。
将人物和移动动画拖拽进来:
并设置移动动画
添加override track并设定动画:
设定好时间线后在脚本里控制:
按下开始键时播放时间线
在时间线结束的事件添加开始游戏的订阅:(添加订阅需要参数匹配所以这里给newgame添加了参数)
为防止播放动画时,玩家可以按键,所以这时候用时间线操控eventSystem的不可操控性:
创建一个图片,按下shift并选中即可覆盖整个面。
然后虽然可以通过调整图片的alpha实现渐入渐出,此处实现其他的功能。
这个canvas group可以实现是否可以互动,是否遮挡射线。
渐入渐出就通过控制此处的alpha实现。
接下来怎么实现在游戏的运行过程中透明度从0变到1呢,这种一个事件伴随另一个事件发生通常使用协程:
public class SceneFader : MonoBehaviour
{
CanvasGroup canvasGroup;
public float fadeInDuration=2.5f;
public float fadeOutDuration=2.5f;
// Start is called before the first frame update
private void Awake()
{
canvasGroup = GetComponent();
DontDestroyOnLoad(gameObject);//一直用所以不删掉
}
public IEnumerator FadeOutIn()
{
yield return FadeOut(fadeOutDuration);
yield return FadeIn(fadeInDuration);
}
public IEnumerator FadeOut(float time)
{
while (canvasGroup.alpha < 1)
{
canvasGroup.alpha += Time.deltaTime / time;
yield return null;
}
}
public IEnumerator FadeIn(float time)
{
while (canvasGroup.alpha !=0)
{
canvasGroup.alpha -= Time.deltaTime / time;
yield return null;
}
}
}
在场景控制器LoadLevel时调用SceneFader中的渐入渐出协程
然后给scene controller添加即可:
注意别忘了初始设定alpha为0:
此处补充一点,把指针修改一下,其他情况默认为鼠标状态:
玩家死亡时,我们希望场景控制器也做出一些动作(回到初始场景),所以此处实现调用观察者模式接口:
然后在需要实现的函数里添加加载主场景即可:
但是此处有一点需要注意,玩家死亡是一个状态,当玩家死亡时,可能会持续不断的执行该协程,因此这里设置一个变量判断是否播放过。
设定一个变量初始设为true,然后:
每次切换场景后销毁该SceneFader防止再次产生。