01:Create project 创建项目导入素材
创建项目使用3D再转URP,,,,直接创建URP,就没有办法把现有的项目升级到URP
- package manager安装universal pipeline
- 添加渲染管线
- 项目设置
生成两个文件
UniversalRenderPipelineAsset :用于设置渲染效果参数集合htm
UniversalRenderPipelineAsset_Renderer:定义渲染的方式(类型,形式)blog
插入素材时确保支持LWRP或URP渲染管线
预制体的材质是粉色说明还没适配当前渲染管线
edit-render pipeline往后点,第一个是全部素材升级到urp,第二个是选中的素材,,一般用第一个
02:Build Level 尝试熟悉基本工具
修改天空盒
修改草地渲染颜色
阴影
shadow是max distance是在50距离内渲染阴影,,,也可以下面分级,分2级,在8.4m距离高精度渲染,41.6内低精度渲染,,超出不渲染
渲染质量开启HDR,可选择抗锯齿
v键按住移动物体自动吸附最近的顶点
选中相机或者物体,,,在摁ctrl shift F,自动将物体设置为你当前视角的位姿坐标。
分栏区分环境中的物体
03:PolyBrush 发挥创意构建场景
使用polybrush修改地面terrian,,
刷上不同颜色表明设置不同位置的有不同的素材,功能
刷素材的功能默认把复制的素材当成接触到的面的物体的子物体
terrian的面和顶点非常少,,,直接放大ground也没用,添加plane也没用,需要使用probuilder插件,可以创建多面片的平面
将平面切割为三角形
progrid可以现实更多参考线,移动物体时,按照设置的默认移动距离
每次都移动设定的0.2距离
04:Navigation智能导航地图烘焙
地面需要设置哪里可以走哪里不能走,,,再window-navigation选项进行设置
蓝色区域能上
选中树木设置not walkable
人物要设置navigation,然后navigation修改人物navigation碰撞单位的大小
05:MouseManager 鼠标控制人物移动
想要点击控制,其实就是创建一个事件
通过创建一个事件,获取鼠标点击的vector3类型值,然后传递给事件指定的gameobject的component的navmeshagent.destination的目标地点
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Events;
[System.Serializable]
public class EventVector3 : UnityEvent<Vector3> {
} //继承了vector3类型
//这个class不是集成monobehaviour,需要被系统序列化才能在inspector现实出来
public class MouseManager : MonoBehaviour
{
public EventVector3 OnMouseClicked;
RaycastHit hitInfo;
void Update()
{
SetCursorTexture();
MouseControl();
}
void SetCursorTexture()
{
Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);//从屏幕点击位置创建射线
if (Physics.Raycast(ray, out hitInfo))//创建raycast,获取hitInfo
{
}
}
void MouseControl()
{
if(Input.GetMouseButtonDown(0) && hitInfo.collider != null) //点击鼠标左键是0并且点击的东西碰撞体不为空
{
if(hitInfo.collider.gameObject.CompareTag("Ground"))
OnMouseClicked?.Invoke(hitInfo.point);//?.代表判断当前的事件是否为空,如果不为空,执行invoke启动传入事件这个坐标点
}
}
}
ScreenPointToRay和raycast联合工作,就是摄像机那个锥体尖尖作为发射点,屏幕点击哪里,锥体大的那个面就产生一个点,出发点和这个点连成一条线,线与环境的交点就是想要的点。
speed移动速度
angular speed转动速度
acceleration加速度
stopping distance停止距离,,如果是近战武器就距离点击位置近一点停下来,意味着距离目的地1m的地方停下来,,,如果是长柄武器,可以停的位置远一点
auto braking 自动刹车
06:SetCursor 设置鼠标指针
单例模式3D RPG 核心功能 (学习笔记 全) (M_Studio教程)
什么是单例模式?
答:单例模式(Singleton),也叫单子模式,是一种常用的软件设计模式。在应用这个模式时,单例对象的类必须保证只有一个实例存在。许多时候整个系统只需要拥有一个的全局对象,这样有利于我们协调系统整体的行为。比如在某个服务器程序中,该服务器的配置信息存放在一个文件中,这些配置数据由一个单例对象统一读取,然后服务进程中的其他对象再通过这个单例对象获取这些配置信息。这种方式简化了在复杂环境下的配置管理。
什么时候用到?
答:当游戏中的某一个游戏对象永远只有一个实例的时候,那么可以使用单例模式。
所有自身变量的获取全部放到awake里面实现获取赋值,以免出现空引用
PlayerController挂在狗身上。
如果使用了单例,就不需要拖拽复制了,,,因此mouseManager脚本需要把event类删了,serializable删掉,,删了就不能用event了,需要使用action方法
action是返回值为void的内置委托类型,如果遇到这类委托可以直接使用无需自己定义
创建单例,必须是static类型
单例模式调用,,,,就是:类名.Instance.action,这个action联想有闪电符号
因为Action定义了类型是Vector3,所以订阅了action委托的MoveToTarget()也必须是Vector3
在max size还可以修改图标大小
设置鼠标图片
case "Ground":
Cursor.SetCursor(target, new Vector2(16, 16), CursorMode.Auto);
//(图片,偏移值(因为鼠标照理说只有一个点的点击位置有效,需要设定这个点击的偏移量),模式自动)
break;
附加:Resources学习
可以不通过拖拽的方式,加载图片,,,、
直接通过Resources.load<>直接加载,,,,注意,加载路径只能在resources文件夹下面
附加:action学习
创建一个event Action函数,设置一个条件(函数)来激活这个函数。
创建一个函数再Start中注册到这个Action
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System;
public class action : MonoBehaviour
{
public bool trigger;
public Action<string> TriggerAction;
// Start is called before the first frame update
void Start()
{
TriggerAction += DebugLogPrint;
}
// Update is called once per frame
void Update()
{
TriggerIsTrue();
}
void TriggerIsTrue()
{
if (trigger == true)
{
TriggerAction("trigger changed");
trigger = false;
}
}
void DebugLogPrint(string ActionString)
{
Debug.Log(ActionString);
}
}
//点击一次trigger按键,就触发一次订阅函数DebugLogPrint的输出
07:Cinemachine & Post Processing 摄像机跟踪和后处理
处理摄像机位置
插入virtual camera
urp 后处理 volume
需要打开post-processing和camera的post-processing
如果调用volume还是没有反应说明渲染管线没有开启
电影渲染深度设置。
08:Animator 动画控制器
插入一个简单的blend tree
点击add motion field添加新的运动,,,拖动speed滑条,会根据速度播放不同的动画
private NavMeshAgent agent;
private Animator anim;
void Awake()
{
agent = GetComponent<NavMeshAgent>();
anim = GetComponent<Animator>();//获取动画
}
void Start()
{
MouseManager.Instance.OnMouseClicked += MoveToTarget;
}
void Update()
{
SwitchAnimation();
}
void SwitchAnimation()
{
anim.SetFloat("Speed", agent.velocity.sqrMagnitude);//设置浮点数,,因为speed是浮点数
//创建blend tree的时候会创建参数parameter,,这个parameter的名字就是这里的第一个参数
//第二个是获取agent的速度,,因为速度是vector3,要通过velocity.sqrMagnitude转换成浮点数
}
09:Shader Graph 遮挡剔除
创建一个shader graph,右键选中这个shader graph创建一个material
创建一个alpha通道
右键-create node-Fresnel effect(一个光圈的效果)
如果希望创建一个可调整的光圈颜色 ,并且在inspector中可以查看,需要创建一个color参数,
可以创建要给multiply节点,从而将颜色和fresnel效果相乘。
Dither噪点,创建一个float类型来接到Dither上,然后连接到Alpha值,右下角就有效果了
刚刚创建的两个参数都显示了
保存shader要点这个按钮save asset
urp是可编辑渲染管线,党人物移动到树后面需要使用shader效果
add renderer fearture – renderer objects添加渲染物体
创建一个player的layer层,这样这个渲染特点只对player层使用这个效果,在下方选中材质,应用深度选项,选择greater(比当前深度更大就应用),去掉writer depth
前后两个状态应用的材质不同,在前面的时候才不会自己遮挡自己,显示遮挡材质
人物移动到树后面,没有办法点击树后面的地面,因此需要修改遮挡问题:两个办法:
1、所有树-layer-Ignore raycast取消射线遮挡
2、关闭所有树的mesh collider,如果爆装备是弹出物品, 有collider会弹
10:Enemy Set States 设置敌人的基本属性和状态
确保人物身上有这个组件,因此在前面加一个RequireCompoment,类型为typeof,NavMeshAgent,,,拖拽到人物身上时,如果没有这个脚本,就自动添加这个组件
拖拽上去就自动添加了navmeshagent
点击敌人,敌人需要添加box collider
enemy需要添加layer和tag用于遮挡剔除和点击操作
11:Player Attack 实现攻击动画
直接创建Action并注册,直接在注册到事件时写一个函数名,,,然后点击前面的灯泡,自动创建一个函数
人物移动到敌人面前,需要不断判断距离是否小于攻击距离,,不能直接用while,需要用一个协程
协程在当前主程序运行时,开启另一段逻辑处理,协程主要用于代码等待
Unity中的协程方法通过yield这个特殊的属性可以在任何位置、任意时刻暂停。也可以在指定的时间或事件后继续执行,而不影响上一次执行的就结果,提供了极大地便利性和实用性。
协程在每次执行时都会新建一个(伪)新线程来执行,而不会影响主线程的执行情况。
**值得注意的是,协程并不会在Unity中开辟新的线程来执行,其执行仍然发生在主线程中。当我们有较为耗时的操作时,可以将该操作分散到几帧或者几秒内完成,而不用在一帧内等这个操作完成后再执行其他操作。 **
yield return null也可以写成yield return 0或yield return 1,,作用是暂缓一帧,下一帧接着往下处理,后面的数字不起作用,无论是几,都是下一帧接着处理
IEnumerator CaculateResult()
{
for (int i = 0; i < 10000; i++)
{
//内部循环计算
//在这里的yield会让改内部循环计算每帧执行一次,而不会等待10000次循环结束后再跳出
//yield return null;
}
//如果取消内部的yield操作,仅在for循环外边写yield操作,则会执行完10000次循环后再结束,相当于直接调用了一个函数,而非协程。
//yield return null;
}
选中函数名,按住Ctrl + R可以批量改名字
创建好了协程之后,,,调整人物攻击动画,在animator中添加一个trigger变量,拖拽一个攻击动画进来,设置动画的转移make transition
创建好了动画,在代码中根据CD时间和距离通过协程赋值trigger触发。
private void EventAttack(GameObject target)
{
if (target != null)
{
attackTarget = target;
//打开协程
StartCoroutine(MoveToAttackTarget());
}
}
IEnumerator MoveToAttackTarget()
{
agent.isStopped = false;
//lookat转向朝向攻击目标
transform.LookAt(attackTarget.transform);
//判断目标与人物的距离
while(Vector3.Distance(attackTarget.transform.position, transform.position)>1)
{
agent.destination = attackTarget.transform.position;
yield return null;
}
//用agent.isStopped判断是否真的停了。
agent.isStopped = true;
if (lastAttackTime < 0)
{
anim.SetTrigger("Attack");
//重置冷却时间
lastAttackTime = 0.5f;
}
}
//需要在设置agent的函数中修改isStopped为false,,否则走到敌人面前就不能再走动了
12:FoundPlayer 找到Player追击
插入一个Freelook相机,用于实现普遍的视角调整相机
三个圈创建了一个空间范围,摄像机可以自由移动
可以查看很多输入方法,比如鼠标的x、y值
设置敌人在视野范围内追踪player,,如果用distance其实对于物体很多的时候消耗很大,不如使用触发器
使用foreach和Physics.OverlapSphere
之后给player添加上碰撞体
13Enemy Animator设置敌人的动画控制器
预制体说明
original prefab:新建的prefab和原来的是独立的,无关的
prefab variant:新建的prefab和原来的prefab部分关联。新建的Prefab是variant,他可以有自己独立的属性。可以把共同属性作为原始的prefab然后拖过来形成性的prefab再添加新的属性。
对player新添加了一个collider,但是之前由player创建的prefab没有这个,可以通过这个方法应用到prefab上产生变化,,或者直接重写prefab
可以在GameObject上对产生修改的组件应用到Prefab,也可以直接重写Overrides-Apply all
调整动画权重,权重为1基本上是覆盖,下面additive是叠加,,用多个层避免动画连的跟蜘蛛网一样关联脚本变量与动画控制参数,用于调整动画
14: Patrol Randomly 随机巡逻点
//可视化显示一些范围,例如敌人的追击范围
//只显示选中物体的gizmos
private void OnDrawGizmosSelected()
{
Gizmos.color = Color.blue;
Gizmos.DrawWireSphere(transform.position, sightRadius);
}
unity中C#使用乘法比除法开销小。
设置了巡航,可能自动生成的巡航点路线上有东西会卡住模型运动。
void GetNewWayPoint()
{
remainLookAtTime = lookAtTime;
float randomX = Random.Range(-patrolRange, patrolRange);
float randomZ = Random.Range(-patrolRange, patrolRange);
Vector3 randomPoint = new Vector3(guardPos.x + randomX, transform.position.y, guardPos.z + randomZ);
NavMeshHit hit;
wayPoint = NavMesh.SamplePosition(randomPoint, out hit, patrolRange, 1) ? hit.position : transform.position;
}
15:CharacterStats 人物基本属性和数值
数值相关的文件,可以生成要给Script Asset文件,类似pipeline asset文件,存储一系列的资源数值
合理命名,看文件就知道是干什么
关于脚本是否要继承MonoBehavior
想这个问题前先考虑你的脚本是用来干嘛。是用来挂载到物体上的组件?还是普通类 如果只是用来定义class,**靠继承来管理 Enemy(敌人)类及其子类的各种数据,则不需要继承MonoBehaviour**。 如果是写一个脚本组件,而且需要**挂到物体上使用的,那一定要继承MonoBehaviour**。所有通过组件扩展功能的都必然要继承MonoBehaviour,否则无法挂上去。 只要想清楚了每一个class属于功能组件还是单纯的、独立的类,就可以很好的处理这个问题。 能用单纯脚本实现的功能,尽量不要和Unity扯上关系,即避免继承MonoBehavior,保持纯粹性。
关于ScriptableObject类
1.为什么某些情况下使用MonoBehaviour很不好:
- 运行时刻修改了数据一退出就全部丢失了。
- 这个深有感触,目前都是靠Copy Component Values来解决,很麻烦。其实有这样的需求的时候大部分就说明这个脚本存储的是很多数据,就应该考虑使用ScriptableObject,而不是MonoBehaviour。说到底是因为这些对象不是Assets
- 当实例化新的对象的时候,这个MonoBehaviour也在内存中多了一份实例,浪费空间
- 在场景和项目之间很难共享
- 在概念上就很难定义这种对象,明明是为了存储一些数据和设置等,但却要作为一个Component附着在Gameobject或Prefab上,不能独立存在
2.ScriptableObject是我们的rescue!
- 在内部实现上它仍然继承自MonoBehaviour,但它不必附着在某个对象上作为一个Component
- 我们也不能(当然初衷就是不愿意)把它赋给Gameobject或Prefab
- 可以被serialised,而且可以自动有类似MonoBehavior的面板,很方便
- 可以被放到.asset文件中,也就是说我们可以自定义asset的类型。Unity内置的asset资源有材质、贴图、音频等等,现在依靠ScriptableObject我们可以自定义新的资源类型,来存储我们自己的数据
- 可以解决某些多态问题
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
//在create菜单中创建一个子集菜单
[CreateAssetMenu(fileName = "New Data", menuName = "Character Stats/Data")]
public class CharactoreData_SO : ScriptableObject
{
}
用这个流程,可以创建一个资源文件
右侧图片上面的Script别忘了选择CharacterData_SO
可以通过这一个ScriptableObject文件创建多个人物的数值文件,因为不是MonoBehvaior,无法挂到物体上,所以需要单独写管理脚本,用于读取管理相关数值
可以创建继承monobehavior的脚本管理数值,并且可以发现能够读取CharactorData_SO的类型
因为不希望逐级访问:CharacterStats.characterData.maxHealth,所以我们需要用properties属性方法
//这样通过CharacterData.MaxHealth可以直接读到characterData的数据
public class CharacterStats : MonoBehaviour
{
public CharacterData_SO characterData;
//如果不希望inspector赋值、希望有初始赋值、并且按照一定的规则取值赋值
//如果只有get,意味着只可读,如果只有set,意味着只可写。如果都有就是可读可写
public int MaxHealth
{
get
{
if (characterData != null)
return characterData.maxHealth;
else return 0;
}
set
{
characterData.maxHealth = value;
}
}
//通过CharacterData.MaxHealth直接读取asset的值,也可以通过CharacterData.MaxHealth=xx
//直接给asset里面的值赋值,xx=value,value表示外部对这个属性的赋值
//value是关键字,不是变量
}
//可以通过#region和#endregion标注出大块代码的作用,并且折叠起来
#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.baseDefense; else return 0; }
set {
characterData.baseDefense = value; }
}
public int CurrentDefence
{
get {
if (characterData != null) return characterData.currentDefense; else return 0; }
set {
characterData.currentDefense = value; }
}
#endregion
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
//在create菜单中创建一个子集菜单
[CreateAssetMenu(fileName = "New Data", menuName = "Character Stats/Data")]
public class CharacterData_SO : ScriptableObject
{
[Header("Stats Info")]
public int maxHealth;
public int currentHealth;
public int baseDefense;
public int currentDefense;
}
类的特点
这样的操作对于修改数值,只要去修改asset这个数值模板就行
- 创建继承ScriptableObject的脚本
- 依据该脚本创建数值模板Asset
- 创建继承MonoBehavior的脚本类用于读写数值模板,并且挂载到人物身上
- 给该脚本赋值要读取的数值模板Asset
- 在物体Manager脚本编写代码创建读取数值模板的类变量,通过GetComponent<读取数值模板的类>()给该类赋值
- 通过这个类读取写入数值模板
16:AttackData 攻击属性
同样创建攻击数值的Asset文件,
[HideInInspector]
可以保证public的变量在别的文件可以访问,但是inspector看不见
17:Execute Attack 实现攻击数值计算
//需要计算攻击减掉防御,因此需要传入两个CharacterStats变量。
public void TakeDamage(CharacterStats attacker, CharacterStats defender)
{
int damage = Mathf.Max(attacker.CurrentDamage() - defender.CurrentDefence, 1);//如果是攻击低于防御,则产生负数伤害,需要跟1比较,产生一个最低1点的伤害
}
private int CurrentDamage()
{
float coreDamage = UnityEngine.Random.Range(attackData.minDamage, attackData.maxDamage);
if(isCritical)
{
coreDamage *= attackData.criticalMultiplier;
Debug.Log("暴击" + coreDamage);
}
return (int)coreDamage;
}
对于简单游戏,不会使用是否产生了碰撞而触发伤害,会在动画执行到某一时刻执行一个事件,事件调用一个函数方法触发伤害来计算生命值
在动画过程中的某一帧插入事件,事件选择需要触发的函数
因为史莱姆是fbx包含了动画,所以是只读,无法编辑插入事件,需要手动复制一个动画出来才能编辑,选中fbx下面的动画ctrl + D,复制一份就可以编辑了,需要在Animator窗口重新把之前的两个战斗Animation修改一下。
直接把刚刚复制出来的拖拽过来就行
18:Guard & Dead 守卫状态和死亡状态
ScriptableObject只要游戏不退出,数值会始终记录,,,运行试玩需要修改数字
if(Vector3.SqrMagnitude(guardPos-transform.position) <= agent.stoppingDistance)
{
isWalk = false;
transform.rotation = Quaternion.Lerp(transform.rotation, guardRotation,);
//lerp函数后面有个t是标准化的数值,意味着从t-1逐渐转到对应角度。
}
//理论上 sqrMagnitude的开销比distance小
lerp源码
public static float Lerp(float a, float b, float t){
return (b-a)*t;
}
guard目标拉脱之后,回到位置,需要调整朝向。
死亡动画从任何状态都可以开始播放,因此从any state开始连接,,,另外要取消勾选can transistion to self,不然会持续播放
19:泛型单例模式 Singleton
通过一个GameManager控制整个游戏流程,包括人物死亡带来的影响。
GameManager默认齿轮图标
在GameManager中public一些变量,方便其他脚本获取
这个脚本希望通过观察者模式反向注册的方法,在player在生成的时候,告诉GameManager它是PlayerStats
游戏中的Manger都要是单例模式,这样只有一个, 方便访问
因此要写一个泛型的单例模式,,,所有Manager都继承这个泛型单例,就很省事
创建泛型单例脚本,放在Tools文件夹
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
//需要在SingleTon后面加一个T,表示Type,传进来任何一个Type都行
//做一个约束,表示T继承SingleTon<T>类型,where是关键词
//我要创建一个泛型函数,但是里面的泛型不知道是什么,我要通过where进行约束
public class SingleTon<T> : MonoBehaviour where T : SingleTon<T>
{
//这句话意味着SingleTon<T>继承MonoBehaviour
//并且where限制传入的T必须是继承SingleTon<T>
}
,,,,找”通用“那节课
虚函数的讲解
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
//做一个约束,传入的必须是SingleTon<T>
public class SingleTon<T> : MonoBehaviour where T : SingleTon<T>
{
//自己的成员变量
private static T instance;
//外部可以读取赋值的变量
public static T Instance
{
get {
return instance; }
}
//使用protected表明只有继承这个类的类可以修改Awake函数,同时使用virtual关键字表明虚函数可以重写。
//使用virtual时,调用的时候,实际调用的时继承类的版本
protected virtual void Awake()
{
if(instance != null)
Destroy(gameObject);
else
instance = (T)this;
//无论哪个类继承,这句话都表明instance赋值是那个类的本体this
}
//player等通过该函数的调用,知晓manager是否初始化生成了
//只有初始化了,才能在这个manager中获取数值等等,所以这个函数在泛型单例中都有
public static bool IsInitialized
{
get {
return instance != null; }
}
//如果场景中有多个单例模式,是需要销毁的。
//当monoBehavior被销毁时,这个函数调用一次——即当游戏结束运行时-此函数被调用
//如果子类继承后需要使用OnDestory销毁时候的方法,就可以直接重写这个函数
protected virtual void OnDestroy()
{
//如果当前实例被销毁了
if(instance == this)
{
instance = null;
}
}
}
观察者模式(事件的收发,例如课程中主角死亡后怪物的欢呼动画)
装饰器模式(也就是开发中我们往物体上挂载组件来拓展物体功能,目前unity是有这一套的框架了,不过如果后续要优化的话可能就要自己写了)
其他还有一些像抽象工厂、模板模式等等因为自己理解不太够和在开发中遇到的不够就不细说
通常单例模式不希望在切换场景的时候被销毁,通常重写Awake,使用override和base关键词
//[System.Serializable]
//public class EventVector3 : UnityEvent<Vector3> { } //继承了vector3类型
//继承SingleTon<>类型,并传入了一个属于SingleTon类的子类MouseManager
public class MouseManager : SingleTon<MouseManager>
{
//重写override
protected override void Awake()
{
//base表示基于原有父类函数之上额外运行
//意味着父类函数里面的东西都保留
//然后可以额外添加内容
base.Awake();
DontDestroyOnLoad(this);
}
附加:接口
接口的方式使用观察者订阅和广播的方法
接口:实现接口的任何类,必须拥有其所有的方法和属性,,作为交换,通过使用多态其他类可将实现类视作接口,接口不是类,不能有自己的实例
继承关系是is-a,接口使用实现关系,一个类实现一个接口
接口通常在类外部声明,声明接口时,通常对每个接口使用一个脚本
接口的声明通常在大写字母I开头后面跟另一个大写字母开头的名称
接口通常描述实现类具备的某种功能,因此名称结尾多是able
//实现IKillable接口的任何类,必须有一个与这个签名匹配的公共函数
public interface IKillalbe
{
void kill();
}
public interface IDamageble<T>
{
void Damage(T damageTaken);
}
//为了实现接口,类必须公开声明这个接口中存在的所有方法、属性、事件和所引起
接口允许跨多个类定义通用功能
可以根据类实现的接口,对类的用于做出假设
要实现接口,只需在类具有的任何继承之后添加一个逗号后跟接口的名称,如果类不是从其他类继承而来,就不需要逗号\
可以从多个接口中实现多个特殊函数,,,只是因为不能继承多个类。
可以使用接口用于跨多个互不相关的类定义通用功能,例如车和墙都可破坏,但是继承同一个物体没有意义,但是继承一个接口含有damage函数就有意义
public class Avatar : MonoBehavior, IKillable, IDamageable<float>
{
//声明两个接口所需要的两个函数
//函数主题与接口相互独立,可以自由书写
public void kill()
{
//Do something fun
}
public void Damage(float damageTaken)
{
//Do something fun
}
}
20:Observer Pattern 接口实现观察者模式的订阅和广播
接口的方式使用观察者订阅和广播的方法
在Tools文件夹下面创建IEndGameObserver的C#脚本,I表示接口
//using那些命名空间也可以删掉
//class改成interface
//因为不是class,不需要继承monobehavior
public interface IEndGameObserver
{
void EndNotify();
}
如果类实现接口的时候不重新声明或实现接口的函数,会提示报错
流程:
- 创建接口
- 类订阅接口,编写接口函数实现
- Manager创建列表和广播方法
- 实现类在实例化的时候注册到Manager的列表,销毁的时候从列表删除
- 一个物体在某个时刻触发Manager的广播方法,这样所有注册到Manager列表的实现类都受到了广播
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class GameManager : SingleTon<GameManager>
{
//直接创建列表,用于收集接口
public List<IEndGameObserver> endGameObservers = new List<IEndGameObserver>();
public void AddObserver(IEndGameObserver observer)
{
endGameObservers.Add(observer);
}
public void RemoveObserver(IEndGameObserver observer)
{
endGameObservers.Remove(observer);
}
//如何广播呢?
//遍历每一个订阅进来的实例,然后调用他们实现的接口方法
public void NotifyObservers()
{
foreach(var observer in endGameObservers)
{
observer.EndNotify();
}
}
}
void Update()
{
isDead = characterStats.characterData.currentHealth == 0;
//player如果死亡就调用manager的广播事件,广播告诉所有注册的enemy
if (isDead)
GameManager.Instance.NotifyObservers();
SwitchAnimation();
//每帧都剪掉时间增量
lastAttackTime -= Time.deltaTime;
}
void OnEnable()
{
GameManager.Instance.AddObserver(this);
}
//OnDisable与OnDestroy不一样,OnDisable是在销毁完成后执行
void OnDisalbe()
{
GameManager.Instance.RemoveObserver(this);
}
public void EndNotify()
{
//获胜动画
//停止所有移动
//停止Agent
anim.SetBool("Win", true);
playerDead = true;
isChase = false;
isWalk = false;
isFollow = false;
attackTarget = null;
//如果不单独提出来重新同步一下动画参数,会因为前面update判断plaer死亡不在执行SwitchAnimation动画
//因此一直播放Attack Layer的动画,但是Attack Layer是权重1的比例override第一层的动画
//即便Base Layer是win的状态,依然无法播放win的动画
anim.SetBool("Walk", isWalk);
anim.SetBool("Chase", isChase);
anim.SetBool("Follow", isFollow);
}
在这里遇到一个问题,如果在OnEnable()中注册,会报错,需要放到Start中,暂时不知道是哪里的问题
报错说明没有找到GameManager,无法注册到GameManager
应该设置为当场景加载好了,敌人加载好了,再注册到GameManager
OnEnable在场景加载时才会用到
21:More Enemies 制作更多的敌人
如果每一个敌人都使用Enemy Data Asset的话,其实是共享数值
需要通过ScriptableObject复制出多个数值文件
public CharacterData_SO templateData;
public CharacterData_SO characterData;
private void Awake()
{
//如果当前模板数据不为空,意味着要调用这个数据了
if (templateData != null)
characterData = Instantiate(templateData);
}
Instantiate(Object original):克隆物体original,其Position和Rotation取默认值,何为默认值呢?就是预制体的position,这里的position是世界坐标,无父物体
创建一个乌龟敌人,所有的参数设置同前文,同样挂载EnemyController
可以直接复制动画Animator修改,也可以创建一个重写Animator
有个加号
直接选择需要重写的Animator拖进去就行
拖拽所有需要重写的动画进来
注意:override controller不能修改动画内的已经放好的动画和逻辑,不然会改变生成它的那个动画Animator
22:Setup Grunt 设置兽人士兵
因为动画不同,所以要设置不同的攻击逻辑,例如当player要穿过时,吧人物推开,然后在攻击
Grunt继承EnemyController
public class Grunt : EnemyController
{
[Header("Skill")]
public float kickForce = 10;
public void KickOff()
{
if(attackTarget != null)
{
transform.LookAt(attackTarget.transform);
var targetStats = attackTarget.GetComponent<CharacterStats>();
this.GetComponent<CharacterStats>().TakeDamage(characterStats, targetStats);
//获得player相对敌人的方向并单位化
Vector3 direction = attackTarget.transform.position - transform.position;
direction.Normalize();
//直接获取player的agent,让他停下,然后在让他的速度变为向量*力,反向推走player
attackTarget.GetComponent<NavMeshAgent>().isStopped = true;
attackTarget.GetComponent<NavMeshAgent>().velocity = direction * kickForce;
attackTarget.GetComponent<Animator>().SetTrigger("Dizzy");
}
}
}
在兽人动画中添加额外的事件用于触发KickOff(),同时给人物加入Dizzy的眩晕动画,设置播放速度快一点
可以在Animator中直接创建触发逻辑的代码(状态机)
可以直接在右侧添加行为
默认自带的状态机
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
//相当于自带一个状态机行为
public class StopAgent : StateMachineBehaviour
{
//当前动画状态进入的时候执行一次
// OnStateEnter is called when a transition starts and the state machine starts to evaluate this state
//这个animator就是当前脚本挂在的物体身上的animator
override public void OnStateEnter(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
{
animator.GetComponent<NavMeshAgent>().isStopped = true;
}
//动画持续播放过程中调用的方法
//因为人物点一次攻击目标,就会把isStoped修改为false,所以要持续运行
// OnStateUpdate is called on each Update frame between OnStateEnter and OnStateExit callbacks
override public void OnStateUpdate(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
{
animator.GetComponent<NavMeshAgent>().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<NavMeshAgent>().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
//}
//IK人物
// 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)
//}
}
如果player把enemy杀死了,可能会报错,因为enemy死亡后,CharacterStates.Dead在循环中会关闭agent,但是StopAgent脚本中OnStateUpdate函数会一直getComponent
可以把死亡时候的函数取消enemy死亡关闭agent改为enemy死亡,agent半径 = 0,从而避免挡住人物
附加:扩展方法
P11
通过扩展方法可以向类型添加功能,而不必创建DriveType或更改原始类型
扩展方法非常适用于需要向类添加功能,但是不能编辑类的情况
- 例如我们无法访问transform的源码,但是我们想要使用函数轻松重置Transform的位置、旋转和缩放,最理想的情况可以将该函数放在Transfrom类中,但是我们无法添加到这个类,将这个类添加到派生类也没有意义
扩展方法必须放在非泛型静态类中,需要在参数中使用this关键字使其作为非静态方法
简单来说,就是为一个修改不了的类添加更多自定义的静态方法
使用的时候,只需要将其视为所扩展的类的成员
public static class ExtensionMethods
{
//this后跟要扩展的类,和随便一个变量名
//第一个参数将是调用对象,因此当我们调用这个函数时,无需提供这个参数
//第一个参数规定了这个方法属于哪个类,,,,,,当然也可以添加一些其他的参数
public static void ResetTransformation(this transform trans)
{
trans.position = Vector3.zero;
trans.localRotation = Quaternion.identity;
trans.localScale = new Vector3(1,1,1);
}
}
//尽管函数声明具有参数,但是调用时没有参数,参数隐式的成为了Transform实例
public class SomeClass: MonoBehavior
{
void Start()
{
transform.ResetTransformation();
}
}
23:Extension Method 扩展方法
如果Player跑到敌人后界面,发生的攻击不产生伤害
在现有的类、现有的函数延展一个方法,实现一个个性化的方法
任何扩展方法都不会继承任何类,并且合格扩展方法必须是一个静态类
public static class ExtensionMethod
{
public static bool IsFacingTarget(this Transform transform, Transform target)
{
}
}
使用Enemy向前的向量 和 Enemy指向Player的向量做Dot运算
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public static class ExtensionMethod
{
private const float dotThreshold = 0.5f;
//函数名后面跟this后的第一个类就是要扩展的类,逗号隔开的才是函数的变量,在传入target的transform
public static bool IsFacingTarget(this Transform transform, Transform target)
{
//获取Enemy指向Player的向量的单位向量
var vectorToTarget = target.position - transform.position;
vectorToTarget.Normalize();
//点积获得角度投影的模场值,如果在0-1说明在前180°内,反之在后180°内,,,这里设置为>=0.5的角度范围内
float dot = Vector3.Dot(transform.forward, vectorToTarget);
return dot >= dotThreshold;
}
}
void Hit()
{
//因为敌人是被动攻击,需要先判断身边有没有player,如果没有player可能会报错
//还需要判断是否在前方,才能触发攻击
if (attackTarget != null && transform.IsFacingTarget(attackTarget.transform))
{
var targetStats = attackTarget.GetComponent<CharacterStats>();
targetStats.TakeDamage(characterStats, targetStats);
}
}
24:Setup Golem 设置石头人Boss
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;
public class Golem : EnemyController
{
[Header("Skill")]
public float kickForce = 25;
public void KickOff()
{
if (attackTarget != null && transform.IsFacingTarget(attackTarget.transform))
{
var targetStats = attackTarget.GetComponent<CharacterStats>();
transform.LookAt(attackTarget.transform);
Vector3 direction = attackTarget.transform.position - transform.position;
direction.Normalize();
attackTarget.GetComponent<NavMeshAgent>().isStopped = true;
attackTarget.GetComponent<NavMeshAgent>().velocity = direction * kickForce;
attackTarget.GetComponent<Animator>().SetTrigger("Dizzy");
targetStats.TakeDamage(characterStats, targetStats);
}
}
}
25:Throw Rocks 设置可以扔出的石头
Mesh Collider用法
网格碰撞体 (Mesh Collider) 采用网格资源 (Mesh Asset) 并基于该网格构建其碰撞体 (Collider)。对于碰撞检测,这比将基元用于复杂网格要精确得多。标记为 凸体 (Convex) 的网格碰撞体 (Mesh Collider)可以与其他网格碰撞体 (Mesh Collider) 碰撞。
属性: | 功能: |
---|---|
为触发器 (is Trigger) | 如果启用,此碰撞体 (Collider) 则用于触发事件,会由物理引擎忽略。 |
材质 (Material) | 引用可确定此碰撞体 (Collider) 与其他碰撞体 (Collider) 的交互方式的物理材质 (Physics Material)。 |
网格 (Mesh) | 对用于碰撞的网格的引用。 |
平滑球体碰撞 (Smooth Sphere Collisions) | 启用此项时,会使碰撞网格法线平滑。应对平滑表面(例如没有硬边缘的丘陵地形)启用此项以使球体滚动更平滑。 |
凸体 (Convex) | 如果启用,则此网格碰撞体 (Mesh Collider) 会与其他网格碰撞体 (Mesh Collider) 碰撞。凸体网格碰撞体 (Convex Mesh Collider) 限制为 255 个三角形。 |
因为生成石头直接抛出的话,生成位置比较高,Player可能距离比较远,所以很有可能直接扔到地上,所以需要加一个向上的速度
同时为刚体添加一个瞬间施加的力,选用impulse
public class Rock : MonoBehaviour
{
private Rigidbody rb;
[Header("Basic Settings")]
public float force;
public GameObject target;
private Vector3 direction;
public bool isThrowed = false;
private float time;
private void Start()
{
//生成的时候获取刚体
rb = GetComponent<Rigidbody>();
}
//自己写了一个用于8s后清除场景内的石头
private void Update()
{
time += Time.deltaTime;
if(time>8)
{
Destroy(this.gameObject);
}
}
//生成的时候就要飞向目标
public void FlyToTarget()
{
//很极限的情况,如果生成了石头,player跑了,需要处理一下
if(target == null)
{
target = FindObjectOfType<PlayerController>().gameObject;
}
//在生成方向的时候添加一个向上的方向,让抛物线高一点,避免直接落到地上没有弧线。
direction = (target.transform.position - transform.position + Vector3.up).normalized;
rb.AddForce(direction * force, ForceMode.Impulse);
}
}
可以选择Hierarchy数的时候通过键盘左右方向键快速展开堆叠物体child
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;
public class Golem : EnemyController
{
[Header("Skill")]
public float kickForce = 25;
//创建一个变量拿到石头prefab
public GameObject rockPrefab;
[SerializeField]
private GameObject rock;
//找到举手的位置
public Transform handPos;
//生成石头
public void GenerateRock()
{
//if(attackTarget != null)
//{
rock = Instantiate(rockPrefab, handPos.position, Quaternion.identity);
StartCoroutine(RockWithHand());
//}
}
//扔石头,,扔石头之前一直都开启协程,保证石头跟着手走
public void ThrowRock()
{
rock.GetComponent<Rock>().isThrowed = true;
StopCoroutine(RockWithHand());
rock.GetComponent<Rock>().target = attackTarget;
rock.GetComponent<Rock>().FlyToTarget();
}
IEnumerator RockWithHand()
{
while (!rock.GetComponent<Rock>().isThrowed)
{
rock.transform.position = handPos.position;
rock.transform.rotation = handPos.rotation;
yield return null;
}
}
}
26:Kick it Back 反击石头人
player攻击石头能够反击石头人
创建枚举类型后-创建用于记录状态的变量也要是枚举类型
石头没有CharacterStats脚本,没有数值,因此需要在CharacterStats脚本重载TakeDamage函数,产生出伤害
石头生成的时候如果人物已经死了,,依然要把石头扔出来,但是player死了,会广播信息,Golem收到信息会修改AttackTarget为null并传给Rock的Target,此时必须在FlyToTarget修改Target不管Player死没死都获取目标。
private void OnCollisionEnter(Collision other)
{
if(other.gameObject.CompareTag("Attackable"))
{
Instantiate(breakEffect, other.transform.position, Quaternion.identity);
Instantiate(breakEffect, transform.position, Quaternion.identity);
Destroy(gameObject);
Destroy(other.gameObject);
}
switch(rockStates)
{
case RockStates.HitPlayer:
//判断是否撞到了player同时判断player是否正好在两个反击石头的动作
//如果撞到了但是没有反击石头动作,就打中player,否则反击
if (other.gameObject.CompareTag("Player") &&
!other.gameObject.GetComponent<Animator>().GetCurrentAnimatorStateInfo(0).IsName("Attack Base") &&
!other.gameObject.GetComponent<Animator>().GetCurrentAnimatorStateInfo(0).IsName("Attack02"))
{
other.gameObject.GetComponent<NavMeshAgent>().isStopped = true;
other.gameObject.GetComponent<NavMeshAgent>().velocity = (target.transform.position - transform.position).normalized * force;
other.gameObject.GetComponent<Animator>().SetTrigger("Dizzy");
//因为石头没有CharacterStats,所以CharacterStats脚本中的TakeDamage函数没有办法产生效果,因此需要重载这个函数
other.gameObject.GetComponent<CharacterStats>().TakeDamage(damage, target.GetComponent<CharacterStats>());
rockStates = RockStates.HitNothing;
}
break;
case RockStates.HitEnemy:
//GetComponent默认有一个静态bool用于标记是否获取组件成功
if(other.gameObject.GetComponent<Golem>())
{
//gameObject.GetComponent<Collider>().enabled = false;
//获取Golem的数值进行修改
var otherStats = other.gameObject.GetComponent<CharacterStats>();
otherStats.TakeDamage(damage, otherStats);
Instantiate(breakEffect,transform.position,Quaternion.identity);
Destroy(gameObject);
}
break;
case RockStates.HitNothing:
break;
}
}
Player需要编写代码用于给HitNothing的石头添加力反推回去,修改石头的HitEnemy状态
刚体调用update最好调用FixedUpdate,,因为调用的时间频率不同
Update和FixedUpdate的区别:
- update跟当前平台的帧数有关,而FixedUpdate是真实时间,所以处理物理逻辑的时候要把代码放在FixedUpdate而不是Update。
- Update是在每次渲染新的一帧的时候才会调用,也就是说,这个函数的更新频率和设备的性能有关以及被渲染的物体(可以认为是三角形的数量)。在性能好的机器上可能fps 30,差的可能小些。这会导致同一个游戏在不同的机器上效果不一致,有的快有的慢。因为Update的执行间隔不一样了。
- 而FixedUpdate,是在固定的时间间隔执行,不受游戏帧率的影响。有点想Tick。所以处理Rigidbody的时候最好用FixedUpdate。
PS:FixedUpdate的时间间隔可以在项目设置中更改,Edit->ProjectSetting->time 找到Fixedtimestep。就可以修改了。
//如果石头扔出去了,并且速度很小,才认为什么都没有扔中
void FixedUpdate()
{
if (isThrowed && rb.velocity.sqrMagnitude < 1f)
{
rockStates = RockStates.HitNothing;
}
}
暂停的快捷键 ctrl + shift + p
用这个按钮逐帧播放
当石头速度小于1时,会修改石头为HitNothing但是可能会有问题,需要给Player反击与Golem扔之前设置其velocity为Vector.One
另外人物会从石头上穿过去,需要添加rigibody组件,并且勾选isKinematic选项,否则会跟NavMeshAgent冲突,走到斜坡会调用isGravity往下
void Hit()
{
//可能存在飞过来的时候石头已经被销毁了,,但是再执行下去会报错,需要判断一下石头有没有被销毁
if(attackTarget != null && attackTarget.CompareTag("Attackable"))
{
//这样不判断状态是否为HitNothing,,使得即便是空中飞过来的石头也可以回击
if (attackTarget.GetComponent<Rock>())
{
attackTarget.GetComponent<Rock>().rockStates = Rock.RockStates.HitEnemy;
attackTarget.GetComponent<Rigidbody>().velocity = Vector3.one;
attackTarget.GetComponent<Rigidbody>().AddForce(transform.forward * 20, ForceMode.Impulse);
}
}
else if(attackTarget != null)
{
var targetStats = attackTarget.GetComponent<CharacterStats>();
targetStats.TakeDamage(characterStats, targetStats);
}
}
石头碰撞到物体后需要添加粒子破碎效果
生成时播放一次,Play on Awake
可以通过GetCurrentAnimatorStateInfo获取动画层里面的参数,获取第0层获取下面的正在播放的动画判断是否是Jump
void Update()
{
//Press the space bar to tell the Animator to trigger the Jump Animation
if (Input.GetKeyDown(KeyCode.Space))
{
m_Animator.SetTrigger("Jump");
}
//判断是否是第0层的Jump动画在播放
//When entering the Jump state in the Animator, output the message in the console
if (m_Animator.GetCurrentAnimatorStateInfo(0).IsName("Jump"))
{
Debug.Log("Jumping");
}
}
27:Health Bar 设置血条显示
创建canvas
这个是覆盖屏幕的,不适合用于显示小怪血量
需要选择世界坐标,并添加相机
我们希望能够在每个怪物头上都有这个生命条
插入一个package—2D Sprite,安装好就可以插入2D物体了
再这个Bar Holder下再创建要给Image,选择类型为Filled然后修改Fill Method为Horizontal,就变成可以拖动的条了
创建要给脚本用于显示敌人血量,每个敌人都要挂载,为了避免每一次挂好脚本都要再给人物挂GameObject,可以在脚本本身直接赋值
直接进入每个敌人的Prefab创建一个HealthBar Point
摄像机和血条的更新都应该是人物移动后的下一帧再移动,这样的渲染模式比较适合LateUpdate
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
public class HealthBarUI : MonoBehaviour
{
public GameObject healthUIPrefab;
public Transform barPoint;
public bool alwayVisible;
public float visibleTime;
public float timeLeft;
Image healthSlider;
Transform UIbar;
//需要适中保持UI朝向摄像机,以为这跟摄像机的forward是反向的。。
Transform cam;
//需要注册到里面的Action
CharacterStats currentStats;
private void Awake()
{
currentStats = GetComponent<CharacterStats>();
currentStats.UpdateHealthBarOnAttack += UpdateHealthBar;
}
private void OnEnable()
{
//获取环境中的相机的transform
cam = Camera.main.transform;
//除了这个UI可能还有其他UI,因此要遍历一下每一个canvas
//因为只有一个Slime挂在了这个脚本
//这个函数给每个canvas都创建了UIbar。。。有点问题??不太实用??
foreach(var canvas in FindObjectsOfType<Canvas>())
{
if(canvas.renderMode == RenderMode.WorldSpace)
{
UIbar = Instantiate(healthUIPrefab, canvas.transform).transform;
healthSlider = UIbar.GetChild(0).GetComponent<Image>();
UIbar.gameObject.SetActive(alwayVisible);
}
}
}
private void UpdateHealthBar(int currentHealth, int maxHealth)
{
if (currentHealth <= 0)
Destroy(UIbar.gameObject);
UIbar.gameObject.SetActive(true);
timeLeft = visibleTime;
float silderPercent = (float)currentHealth / maxHealth;
healthSlider.fillAmount = silderPercent;
}
//update是每一帧都执行,LateUpdate是上一帧渲染之后才执行
private void LateUpdate()
{
if(UIbar != null)
{
UIbar.position = barPoint.position;
UIbar.forward = -cam.forward;
if(timeLeft<=0 && !alwayVisible)
{
UIbar.gameObject.SetActive(false);
}
else
{
timeLeft -= Time.deltaTime;
}
}
}
}
还需要再CharacterStats里创建一个Action,每一次产生血量变化都触发事件修改UI
public event Action<int, int> UpdateHealthBarOnAttack;
//需要计算攻击减掉防御,因此需要传入两个CharacterStats变量。
public void TakeDamage(CharacterStats attacker, CharacterStats defender)
{
int damage = Mathf.Max(attacker.CurrentDamage() - defender.CurrentDefence, 1);//如果是攻击低于防御,则产生负数伤害,需要跟1比较,产生一个最低1点的伤害
CurrentHealth = Mathf.Max(CurrentHealth - damage, 0);
if(attacker.isCritical)
{
//被暴击播放动画
defender.GetComponent<Animator>().SetTrigger("Hit");
}
//TODO:update UI
//问号的意思意味着判断订阅它的是否为空?
UpdateHealthBarOnAttack?.Invoke(CurrentHealth, MaxHealth);
//TODO:level up
}
28:Player LevelUp 玩家升级系统
再ScriptableObject脚本里面重新更新一下CharacterData_SO的设计,包括经验升级
敌人不用考虑升级相关的变量,不赋值即可,,人物也不用考虑killPoint
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
//在create菜单中创建一个子集菜单
[CreateAssetMenu(fileName = "New Data", menuName = "Character Stats/Data")]
public class CharacterData_SO : ScriptableObject
{
[Header("Stats Info")]
public int maxHealth;
public int currentHealth;
public int baseDefense;
public int currentDefense;
[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
{
//当前等级-1,,然后乘一个levelBuff,
//例如升级1级+10%,升级2级+20%
get {
return 1 + (currentLevel - 1) * levelBuff; }
}
//创建函数,怪物死亡时更新经验值
public void UpdateExp(int point)
{
currentExp += point;
//如果大于当前每级经验条数值,就升级
if(currentExp>= baseExp)
{
LevelUp();
}
}
private void LevelUp()
{
//等级升级限制,,,保证等级始终在0和最大经验等级之间不会超过
currentLevel = Mathf.Clamp(currentLevel + 1, 0, maxLevel);
baseExp += (int)(baseExp * LevelMultiplier);
maxHealth = (int)(maxHealth * LevelMultiplier);
currentHealth = maxHealth; ;
Debug.Log("LEVEL UP!" + currentLevel + "Max Health:" + maxHealth);
}
}
敌人被攻击时判断是否增加经验
public void TakeDamage(CharacterStats attacker, CharacterStats defender)
{
int damage = Mathf.Max(attacker.CurrentDamage() - defender.CurrentDefence, 1);//如果是攻击低于防御,则产生负数伤害,需要跟1比较,产生一个最低1点的伤害
CurrentHealth = Mathf.Max(CurrentHealth - damage, 0);
if(attacker.isCritical)
{
//被暴击播放动画
defender.GetComponent<Animator>().SetTrigger("Hit");
}
//TODO:update UI
//问号的意思意味着判断订阅它的是否为空?
UpdateHealthBarOnAttack?.Invoke(CurrentHealth, MaxHealth);
//TODO:level up
if(CurrentHealth == 0)
{
attacker.characterData.UpdateExp(characterData.killPoint);
}
}
//函数重载
public void TakeDamage(int damage, CharacterStats defender)
{
int rockdamage = Mathf.Max(damage - defender.CurrentDefence, 1);//如果是攻击低于防御,则产生负数伤害,需要跟1比较,产生一个最低1点的伤害
CurrentHealth = Mathf.Max(CurrentHealth - rockdamage, 0);
UpdateHealthBarOnAttack?.Invoke(CurrentHealth, MaxHealth);
if (CurrentHealth <= 0)
{
GameManager.Instance.PlayerStats.characterData.UpdateExp(characterData.killPoint);
}
}
29:Player UI 添加玩家信息显示
创建Image希望能够在左上角作为锚点
按住alt 和 shift可以直接移动到左上角并设置左上角为锚点
UI的canvas会根据屏幕尺寸、像素进行缩放,需要选canvas scaler调整缩放模式
匹配宽度长度按照一样的权重
创建一个像样的UI,添加脚本HealthBarUI
public class PlayerHealthUI : MonoBehaviour
{
Text levelText;
Image healthSlider;
Image expSlider;
private void Awake()
{
levelText = transform.GetChild(2).GetComponent<Text>();
healthSlider = transform.GetChild(0).GetChild(0).GetComponent<Image>();
expSlider = transform.GetChild(1).GetChild(0).GetComponent<Image>();
}
private void Update()
{
//可以更改string显示格式,比如01 02,,,30,,,因此需要在ToString函数中访问重载
levelText.text = "Level " + GameManager.Instance.PlayerStats.characterData.currentLevel.ToString("00");
UpdateHealth();
UpdateExp();
}
void UpdateHealth()
{
//调用GameManager来获取player的数据
float sliderPercent = (float)GameManager.Instance.PlayerStats.CurrentHealth / GameManager.Instance.PlayerStats.MaxHealth;
healthSlider.fillAmount = sliderPercent;
}
void UpdateExp()
{
float expPercent = (float)GameManager.Instance.PlayerStats.characterData.currentExp / GameManager.Instance.PlayerStats.characterData.baseExp;
expSlider.fillAmount = expPercent;
}
}
gameManager非常有用,可以获取player的数据(前提是将player注册到gameManager了)
30:Create Portal 创建传送门
通过shader Graph创建一个传送门
可以在shader Graph创建有光的和没有光的shader graph,lit有光,unlit无光
在Shader Graph中修改main preview为quad
位面效果
可以看作一套东西
通过时间控制扭曲输出到Voronoi图在输出到Emission自发光
创建一个纹理,然后添加一个Texture2D,创建一个中心到外渐变的遮罩mashk,在添加一些颜色
颜色不明显可以在Shader Graph中的color选择HDR模式
颜色不够强,就用power增强
为了方便空物体被看见可以添加标识
代码逻辑,Portal选择Destination,意味着来到这个门就回到Destination那个点,例如A点,,此时在另一个Portal的Destination定义为A点,就是我们要去的那个点
然后选择Portal选择mesh convex然后选择is trigger用于碰撞事件触发
附加:UV学习
uv是什么
本身是没有纹理的,但是附上纹理就能看到东西了
映射到物体身上,如果是512512就是一一映射,不是512512就没办法映射。需要单位化映射
渲染顺序如下
31:Transition 实现同场景内传送
场景如果灯光不亮,说明PipelineSettings没设置完整
同一个物体被灯光照亮限制了,只能被4个物体照亮,调高
Scene切换不要命名SceneManager,因为Unity有个自带类叫这个名字,容易冲突
场景加载一般用异步,放置卡顿和出错,同时还可以使用进度条
需要使用协程,因为AsyncOperation有个变量是isDone,判断是否加载完了,从0-90%代表加载完了,最后10%是将场景启用
public bool canTrans;
//按下E传送
private void Update()
{
if(Input.GetKeyDown(KeyCode.E) && canTrans)
{
//TODO:SceneController 传送
//如果可以传送直接调用SceneController进行传送
SceneController.Instance.TransitionToDestination(this);
}
}
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
//加载场景需要使用loadScene,需要使用这个命名空间
using UnityEngine.SceneManagement;
//获取TransitionPoint的场景名称和传送终点
//如果是同场景就不用加载,如果是异场景,需要异步加载
public class SceneController : SingleTon<SceneController>
{
private GameObject player;
//创建函数方法,加载一个TransitionPoint的参数用于传送
public void TransitionToDestination(TransitionPoint transitionPoint)
{
switch(transitionPoint.transitionType)
{
case TransitionPoint.TransitionType.SameScene:
//直接通过SceneManager获得激活了的场景名称,然后开启协程
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(GetDestination(destinationTag).gameObject.transform.position, GetDestination(destinationTag).gameObject.transform.rotation);
yield return null;
}
}
private TransitionDestination GetDestination(TransitionDestination.DestinationTag destinationTag)
{
//这个是一个数组
//用于寻找场景中的所有含有TransitionDestination的物体,返回这个数组
var entrances = FindObjectsOfType<TransitionDestination>();
//遍历这个数组,找到和我传进来的tag一样的那个物体
for(int i = 0;i< entrances.Length;i++)
{
//如果找到了有终点标签的那个就返回这个TransitionDestination
if (entrances[i].destinationTag == destinationTag)
return entrances[i];
}
return null;
}
}
传送的时候需要关闭人物的Agent移动
附加:Unity 中 的Serializable 和 SerializeField
Serializable 序列化的是可序列化的类或结构。并且只能序列化非抽象非泛型的自定义的类
SerializeField 是强制对私有字段序列化,当 Unity 对脚本进行序列化时,仅对公共字段进行序列化。 如果还需要 Unity 对私有字段进行序列化, 可以将 SerializeField 属性添加到这些字段。
在Unity3d中Unity3D 中提供了非常方便的功能可以帮助用户将 成员变量 在Inspector中显示,并且定义Serialize关系。也就是说凡是显示在Inspector 中的属性都同时具有Serialize功能(序列化的意思是说再次读取Unity时序列化的变量是有值的,不需要你再次去赋值,因为它已经被保存下来)。
32:Different Scene 跨场景传送
设置Portal的destination type为different Scene,destination tag设置为Enter,然后设置要去的目标场景的Portal的DestinationPoint的destinationTag为enter
场景转换,需要使用协程,并且Api选择异步加载场景LoadSceneAsync
switch(transitionPoint.transitionType)
{
case TransitionPoint.TransitionType.SameScene:
//直接通过SceneManager获得激活了的场景名称,然后开启协程
StartCoroutine(Transition(SceneManager.GetActiveScene().name, transitionPoint.DestinationTag));
break;
case TransitionPoint.TransitionType.DifferentScene:
//获取要传送场景的名字,
StartCoroutine(Transition(transitionPoint.sceneName, transitionPoint.DestinationTag));
break;
}
IEnumerator Transition(string sceneName,TransitionDestination.DestinationTag destinationTag)
{
//TODO:保存角色数值
//判断是否是不同场景传送
if(sceneName != SceneManager.GetActiveScene().name)
{
//基本的理解:我在这一帧是否需要等待什么事件完成
//事件完成之后,执行yield return下面的命令
yield return SceneManager.LoadSceneAsync(sceneName);
//每一次传送好,需要把人物生成出来,经验血量任务记录背包等等都需要配套复制过来,然后加载到人物身上
yield return Instantiate(playerPrefab, GetDestination(destinationTag).gameObject.transform.position, GetDestination(destinationTag).gameObject.transform.rotation);
//从协程中跳出去,中断协程
yield break;
}
else
{
player = GameManager.Instance.PlayerStats.gameObject;
playerAgent = player.GetComponent<NavMeshAgent>();
playerAgent.enabled = false;
player.transform.SetPositionAndRotation(GetDestination(destinationTag).gameObject.transform.position, GetDestination(destinationTag).gameObject.transform.rotation);
playerAgent.enabled = true;
yield return null;
}
}
因为Build Setting没有打开Scene,从而传送失败
把两个Scene选中拖拽过来
直接传送会出错,因为新场景中没有SceneController等Manager
protected override void Awake()
{
base.Awake();
DontDestroyOnLoad(this);
}
if(sceneName != SceneManager.GetActiveScene().name)
{
yield return SceneManager.LoadSceneAsync(sceneName);
//加载完场景当前脚本的物体就消失了,没有办法执行后面的命令
//所以加载完成场景告诉它不要删除当前这个物体,需要重写Override Awake
yield return Instantiate(playerPrefab, GetDestination(destinationTag).gameObject.transform.position, GetDestination(destinationTag).gameObject.transform.rotation);
yield break;
}
同理,MouseManager和GameManager也要设置DontDestroyOnLoad(this);这样不需要再其他场景单独拖拽这些Manager
发现个小问题,OnDestroy设置了t,实际运行的时候不完全是准确的t就销毁物体了
此时切换场景后无法移动,是因为playerController的MoveToTarget订阅了MouseManager的OnMouseClicked,而Player在转换场景的时候,被销毁了,所以需要重新注册
private void OnEnable()
{
MouseManager.Instance.OnMouseClicked += MoveToTarget;
MouseManager.Instance.OnEnemyClicked += EventAttack;
}
void Start()
{
//放到Start是为了从菜单加载游戏后在执行,,因为一开始要把人物设置为Disabled
GameManager.Instance.RigisterPlayer(characterStats);
}
private void OnDisable()
{
if (!MouseManager.IsInitialized) return;
MouseManager.Instance.OnMouseClicked -= MoveToTarget;
MouseManager.Instance.OnEnemyClicked -= EventAttack;
}
切换场景时,摄像机跟随也会丢失,需要通过GameManager在第一时间把Player的LookAtPoint传给CinemaChine
private CinemachineFreeLook followCamera;
public void RigisterPlayer(CharacterStats player)
{
PlayerStats = player;
followCamera = FindObjectOfType<CinemachineFreeLook>();
if(followCamera!=null)
{
followCamera.Follow = player.transform.GetChild(2);
followCamera.LookAt = player.transform.GetChild(2);
}
}
33: Save Data 保存数据
切换场景时,player再次生成了一份数据,我们需要修改逻辑让player从template拿到数据,然后从保存数据修改他,放到CharacterData里
可以用Json配合二进制方法保存数据
用PlayerPrefs配合JsonUtility.ToJson,PlayerPrefs是Unity引擎自带的存储数据的方法,在硬盘上产生数据
只有这三种数据类型,float,int,string
我们通过JsonUtility将ScriptableObject 转换为Json
把json写的漂亮点。就是竖排格式
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class SaveManager : SingleTon<SaveManager>
{
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);
}
//为了提高灵活性,而不是只能接受CharacterData类型的文件,需要用ToJson把文件转换为Object
//Object是所有类型的基类
//无论是monobehavior还是ScriptableObjec都可以传进来
public void Save(Object data,string key)
{
//协程json格式
var jsonData = JsonUtility.ToJson(data,true);
PlayerPrefs.SetString(key, jsonData);
PlayerPrefs.Save();
}
public void Load(Object data, string key)
{
//从json写文件
if(PlayerPrefs.HasKey(key))
{
//拿到PlayerPrefs里保存的key的值,写入data里
JsonUtility.FromJsonOverwrite(PlayerPrefs.GetString(key), data);
}
}
}
IEnumerator Transition(string sceneName,TransitionDestination.DestinationTag destinationTag)
{
//TODO:保存角色数值
SaveManager.Instance.SavePlayerData();
//判断是否是不同场景传送
if(sceneName != SceneManager.GetActiveScene().name)
{
//基本的理解:我在这一帧是否需要等待什么事件完成
//事件完成之后,执行yield return下面的命令
yield return SceneManager.LoadSceneAsync(sceneName);
//加载完场景当前脚本的物体就消失了,没有办法执行后面的命令
//所以加载完成场景告诉它不要删除当前这个物体,需要重写Override Awake
//每一次传送好,需要把人物生成出来,经验血量任务记录背包等等都需要配套复制过来,然后加载到人物身上
yield return Instantiate(playerPrefab, GetDestination(destinationTag).gameObject.transform.position, GetDestination(destinationTag).gameObject.transform.rotation);
//加载好了player再覆盖血量经验等
SaveManager.Instance.LoadPlayerData();
//从协程中跳出去,中断协程
yield break;
}
else
{
player = GameManager.Instance.PlayerStats.gameObject;
playerAgent = player.GetComponent<NavMeshAgent>();
playerAgent.enabled = false;
player.transform.SetPositionAndRotation(GetDestination(destinationTag).gameObject.transform.position, GetDestination(destinationTag).gameObject.transform.rotation);
playerAgent.enabled = true;
yield return null;
}
}
34:Main Menu 制作主菜单
创建新场景,添加人物等,需要对人物解包,这样就不用因为player的修改而可能覆盖prefab
为了让菜单看起来更立体,同时还有后处理,需要设置Canvas为Screen Space – Camera
先设置Canvas距离为1,再改为世界坐标World Space
按钮颜色
Unity Editor下使用 Application.Quit()为什么程序没有退出?
因为Editor下使用 UnityEditor.EditorApplication.isPlaying = false 结束退出,只有当工程打包编译后的程序使用Application.Quit()才奏效
可以这样设置为第一个默认选中的
调整按钮选择的方向,比如只能上下选中,或者方向键左右选中
通常使用GameManager来寻找场景入口,因为从头到尾都没有被删除
public Transform GetEntrance()
{
foreach(var item in FindObjectsOfType<TransitionDestination>())
{
if(item.destinationTag == TransitionDestination.DestinationTag.ENTER)
{
return item.transform;
}
}
return null;
}
public void TransitionToFirstLevel()
{
StartCoroutine(LoadLevel("GameScene_01_Forest"));
}
IEnumerator LoadLevel(string scene)
{
//场景不为空,才传送
if(scene != "")
{
yield return SceneManager.LoadSceneAsync(scene);
yield return player = Instantiate(playerPrefab,GameManager.Instance.GetEntrance().position, GameManager.Instance.GetEntrance().rotation);
//保存游戏
SaveManager.Instance.SavePlayerData();
yield break;
}
}
将各类Manager添加到第一个Menu场景,因为是单例模式,所以不用担心其他场景有,发现重复就删除了
第二个继续游戏,需要在SaveManager中添加代码,用于记录保存的场景。
public class SaveManager : SingleTon<SaveManager>
{
//初始设置键值
string sceneName = "level";
//设置一个property,返回playerPrefs搜索到的场景名字的value
//通常property的名字一样,但是首字母大写
public string SceneName {
get {
return PlayerPrefs.GetString(sceneName); } }
public void TransitionToLoadGame()
{
StartCoroutine(LoadLevel(SaveManager.Instance.SceneName));
}
传送完人物后,让人物自己获得自己的CharacterData记录,自己load
void Start()
{
//放到Start是为了从菜单加载游戏后在执行,,因为一开始要把人物设置为Disabled
GameManager.Instance.RigisterPlayer(characterStats);
SaveManager.Instance.LoadPlayerData();
}
返回首页
private void Update()
{
if(Input.GetKeyDown(KeyCode.Escape))
{
SceneController.Instance.
}
if(Input.GetKeyDown(KeyCode.S))
{
SavePlayerData();
}
if (Input.GetKeyDown(KeyCode.L))
{
LoadPlayerData();
}
}
35:SceneFader 场景转换的渐入渐出
Timeline,可以用来制作castthing,比如一个场景触发,播放动画,播放过程玩家无法操作人物,播放指定动画,显示字母等
需要选中物体创建timeline
拖拽物体到timeline左边的资源库,创建animation track
点击红色开始录制,选中物体的位置关键帧,
添加两帧就可以播放了
然后创建Animation Track因为player有Animator,可以直接拖拽赋值,
然后把player动画拖到timeline上
修改动画播放为loop
此时人物位置可能有问题,需要我们添加一个override timeline然后修改player位置
然后需要编写函数让他在点击开始游戏才播放动画,不然一点运行自动开始
//需要使用命名空间来操作Playables
using UnityEngine.Playables;
public class MainMenu : MonoBehaviour
{
PlayableDirector PlayableDirector;
private void Awake()
{
newGameBtn = transform.GetChild(1).GetComponent<Button>();
continueBtn = transform.GetChild(2).GetComponent<Button>();
quitBtn = transform.GetChild(3).GetComponent<Button>();
//直接为quitBtn的自带的OnClick函数添加监听的函数,用于退出
quitBtn.onClick.AddListener(QuitGame);
//点击newGame先播放动画,再运行newGame函数
newGameBtn.onClick.AddListener(PlayTimeline);
continueBtn.onClick.AddListener(ContinueGame);
//给PlayableDirector赋值
director = FindObjectOfType<PlayableDirector>();
//为动画播放结束的Action添加委托
director.stopped += NewGame;
}
void PlayTimeline()
{
director.Play();
}
//obj为了跟director的Action配合使用
void NewGame(PlayableDirector obj)
{
PlayerPrefs.DeleteAll();
//转换场景
//创建任务,
SceneController.Instance.TransitionToFirstLevel();
}
}
有闪电符号说明是action
播放动画的时候关闭EventSystem,用Timeline实现,删除Active,然后eventSystem就自动灰了,播放完毕就可以用了
为切换场景的淡入淡出添加一个Fade Canvas,插入Image,在image位置哪里,按下alt选择拉伸到整个屏幕
,,,,,可以使用图片α值调整淡入淡出
这里使用Canvas添加Canvas Group脚本来实现
,α值调整,是否可以互动,是否阻挡射线
需要把这个蒙版Canvas制作一个预制体,然后α0-1再1-0,就是淡入淡出
一般类似这种伴随着一个事件同步运行的,都要使用协程实现
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
public class SceneFader : MonoBehaviour
{
CanvasGroup canvasGroup;
public float fadeInDuration;
public float fadeOutDuration;
private void Awake()
{
canvasGroup = GetComponent<CanvasGroup>();
DontDestroyOnLoad(this);
}
IEnumerator FadeOutIn()
{
//直接返回两个协程
yield return FadeOut(fadeOutDuration);
yield return FadeIn(fadeInDuration);
}
//public是为了能够在SceneController中来实现场景相关的淡入淡出
//淡出
public IEnumerator FadeOut(float time)
{
while(canvasGroup.alpha<1)
{
//保证在指定时间范围内从0变1,一帧的时间/总共想要运行的时间 = 总共运行多少帧
canvasGroup.alpha += Time.deltaTime / time;
yield return null;
}
}
//淡入
public IEnumerator FadeIn(float time)
{
while (canvasGroup.alpha != 0)
{
//保证在指定时间范围内从0变1,一帧的时间/总共想要运行的时间 = 总共运行多少帧
canvasGroup.alpha -= Time.deltaTime / time;
yield return null;
}
//播放完销毁canvas
Destroy(gameObject);
}
}
IEnumerator LoadLevel(string scene)
{
//先生成prefab
SceneFader fade = Instantiate(sceneFaderPrefab);
//场景不为空,才传送
if(scene != "")
{
yield return StartCoroutine(fade.FadeOut(2.5f));
yield return SceneManager.LoadSceneAsync(scene);
yield return player = Instantiate(playerPrefab,GameManager.Instance.GetEntrance().position, GameManager.Instance.GetEntrance().rotation);
//保存游戏
SaveManager.Instance.SavePlayerData();
yield return StartCoroutine(fade.FadeIn(2.5f));
yield break;
}
}
当人物死亡后,需要广播让SceneController也接收广播,因此SceneController需要继承IEndGameObserver
36:Build & Run打包及运行
- 确认所有场景是否加载
- 确认场景顺序,对于有些切换场景依靠标号很重要
- 这事不同的压缩方法
- 屏幕模式,全屏或窗口
- other settings中可以选择ILCPP,可以是游戏大小更小,还可以防止反编译
- build前,需要清除存档文件,
今天的文章3D RPG 核心功能 (学习笔记 全) (M_Studio教程)分享到此就结束了,感谢您的阅读。
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
如需转载请保留出处:https://bianchenghao.cn/89685.html