记录 FPS Zombies
👹
是一篇 恐怖生存类游戏的 制作笔记
判断是否player在空中
- 射线检测
1
2
3
4
5
6
7
8
9
10
11void Update()
{
RaycastHit hitInfo;
if (Physics.SphereCast(transform.position, _collider.radius, Vector3.down, out hitInfo,
(_collider.height / 2f) - _collider.radius + .01f))
{
Debug.Log("现在应该是接触");
}
Debug.Log("现在应该是空中");
}
移动方案
- GetAxis
1
2
3
4
5float horizontalInput = Input.GetAxis("Horizontal");
float verticalInput = Input.GetAxis("Vertical");
Vector3 movement = new Vector3(horizontalInput, 0, verticalInput) * speed;
transform.Translate(movement);
弹夹补充方案
1 | //装载所需子弹 |
Terrain地图刷
- Hierarchy添加Terrain地形
- 在笔刷下按住shift是橡皮
skybox天空盒
- 创建一个天空盒 要创建材料 然后选择skybox/panoramic 着色器
- 如果出现纹理有一道白条
- 将素材warp mode修改为clamp
- 并取消generate mip maps
fog
- 在lighting里开启
- Mode
- Linear : 线性 离相机越近的物体,雾的密度越小
- ⭐ Exponential : 指数雾是一种更加真实的雾模式,它使用指数函数来控制雾的密度。离相机越远的物体,雾的密度增长速度越快
- Exponential Squared : 指数平方雾是一种更加密集的雾模式,它使用指数平方函数来控制雾的密度。离相机越远的物体,雾的密度增长速度更快,同时雾的密度也更加密集
设计
- 原则
- 就近性 相关互联的元素应该紧密放置在一起
- 连贯的自我风格,也就是说能让人知道这是同一个人做的,就像毕加索和莫奈的画
- 颜色搭配建议从 网上找配好的色盘
游戏地图布局
- Open 无序挑战
- Linear 起点-旅程-结束(可以藏彩蛋,或是不同结局周目)
- Spoke and Hub
导出包放到其他项目
这将有利于简洁
- 选中素材 export package(一定要ctrl+s)
自动导航
烘焙
- 点击Terrain游戏物体,在window/AI/Navigation
- 选择Bake
- 没有烘焙到的物体需要把他们名字右边的标签设置成Navigation Static 再次Bake
- 包括跌落的高度
- Height Mesh用来解决角色浮空,需要重新烘焙
烘焙完成后
- 需要为游戏物体添加一个Nav Mesh Agent组件才可以使用
导航的使用
- 要注意 动画播放速度 与 移动速度 的和谐
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15void Start(){
agent = GetComponent<NavMeshAgent>();
}
void Update(){
agent.SetDestination(/*一个位置*/);
//判断距离停止
if(agent.remainingDistance > agent.stoppingDistance){
//切换动画 等
}
else{
//切换动画 等
}
}
- 要注意 动画播放速度 与 移动速度 的和谐
扩展角色状态机
- 首先考虑 状态之间的关系
- 一个Switch-枚举结构的判断-控制动画状态机对上面的代码改进,因为僵尸会卡住
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
30void Update()
{
switch (state)
{
case STATE.IDLE:
state = STATE.WANDER;
break;
//⭐这个状态是游逛
case STATE.WANDER:
if (!agent.hasPath)
{
float newX = transform.position.x + Random.Range(-5, 5);
float newZ = transform.position.z + Random.Range(-5, 5);
//这段代码的意思是获取某个Y值 因为terrain是不平的
float newY = Terrain.activeTerrain.SampleHeight(new Vector3(newX, 0, newZ));
Vector3 dest = new Vector3(newX, newY, newZ);
agent.SetDestination(dest);
//实时游荡
agent.stoppingDistance = 0;
}
break;
case STATE.CHASE:
break;
case STATE.ATTACK:
break;
case STATE.DEAD:
break;
}
}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
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;
public class CopyCode : MonoBehaviour
{
private NavMeshAgent agent;
enum STATE
{
IDLE,
WANDER,
ATTACK,
CHASE,
DEAD
};
//Default State
private STATE state = STATE.IDLE;
// Start is called before the first frame update
void Start()
{
agent = GetComponent<NavMeshAgent>();
}
float DistanceToPlayer()
{
//return Vector3.Distance(被追逐对象的位置,自己的位置);
}
bool CanSeePlayer()
{
if (DistanceToPlayer() < 10) return true;
return false;
}
bool ForgetPlayer()
{
if (DistanceToPlayer() > 20)
{
return true;
}
return false;
}
void Update()
{
switch (state)
{
case STATE.IDLE:
if (CanSeePlayer())
{
state = STATE.CHASE;
}
else
{
state = STATE.WANDER;
}
break;
//这个状态是游逛
case STATE.WANDER:
if (!agent.hasPath)
{
float newX = transform.position.x + Random.Range(-5, 5);
float newZ = transform.position.z + Random.Range(-5, 5);
//这段代码的意思是获取某个Y值 因为terrain是不平的
float newY = Terrain.activeTerrain.SampleHeight(new Vector3(newX, 0, newZ));
Vector3 dest = new Vector3(newX, newY, newZ);
agent.SetDestination(dest);
//实时游荡
agent.stoppingDistance = 0;
/*切换动画*/
}
if (CanSeePlayer()) state = STATE.CHASE;
break;
case STATE.CHASE:
//追逐
agent.SetDestination( /*被追逐的坐标*/);
agent.stoppingDistance = 5;
/*切换动画 很多条件哦*/
//小于停止距离就攻击
if (agent.remainingDistance < agent.stoppingDistance && !agent.pathPending /*防止出现bug*/)
{
state = STATE.ATTACK;
}
if (ForgetPlayer())
{
state = STATE.WANDER;
agent.ResetPath();
}
break;
case STATE.ATTACK:
/*切换动画*/
transform.LookAt( /*玩家坐标*/);
if (DistanceToPlayer() > agent.stoppingDistance + 2 /* 加2是因为两个状态切换 由于距离一样导致冲突
*/)
{
state = STATE.CHASE;
}
break;
case STATE.DEAD:
break;
}
}
}
idle / wander 切换
1 | case STATE.IDLE: |
生成怪物の方案
1 | public GameObject prfb; |
- 会因为地形 将怪物生成到空中
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25using UnityEngine;
using UnityEngine.AI;
public class Spawn : MonoBehaviour
{
public GameObject prfb;
public int num;
public float spawnRadius;
void Start()
{
for (int i = 0; i < num; i++)
{
//挂载物体的位置 周围的随量
Vector3 randomPoint = transform.position + Random.insideUnitSphere * spawnRadius;
NavMeshHit hit;
if (NavMesh.SamplePosition(randomPoint, out hit, 10f, NavMesh.AllAreas))
Instantiate(prfb, hit.position, Quaternion.identity);
else i--;
}
}
}
Ragdoll系统(avatar系统)
hierarchy右键3d create ragdoll
- 用这个系统做了一个死亡动画
- 施加一个力 力的方向是相机的朝向乘以一个力度系数
1
2
3
4
5if (Input.GetKeyDow(KeyCode.P))
{
GameObject rd = Instantiate(avatarPrfb);
rd.transform.Find("Hips").GetComponent<Rigidbody>().AddForce(Camera.main.transform.forward * 一个系数);
}
- 施加一个力 力的方向是相机的朝向乘以一个力度系数
射击方案
- 指定一个枪口坐标位置
- 将射线发射脚本挂载到那个物件上
- 该挂件起名叫做Shot direction
- 僵尸用的是椭圆体碰撞体
- hitInfo是用来存储检测返回
- 第一个if条件里是从以一个角度从一个点发射(有距离限制,out返回一个值)
- 从hitInfo里获取有用信息 比如GameObject
- 如果GameObject == “Zombie”,获取这个挂件上的ragdoll(本质是个有死亡动画的游戏物体)
- 生成这个物体,并找到Hips(这上面有刚体),给刚体一个力 造成冲击的假象
- 销毁原来的
- 补充:为了测试原地死亡和被击退死亡的对比 所以加了Random.Range函数加以对比
准心瞄准
- 在枪上面挂载点光源挂件
- 把准心图片挂载到点光源的Cookie里
- Spot Range调节大小
- 把图片渲染模式调成clamp
- 只在某个图层显示
- 先设置怪物的图层,再在光源挂件的Culling mask选择触发图层
尸体下沉消失
1 | //找到触发の这个点 |
然后摧毁collider 和 游戏物体 和 AI导航
玩家生命值相关
- 构成
- 玩家脚本
- 初始生命值
- clamp函数限定血量计算公式,因为他这个包括加血和减血,包含一个形参
- 这样的好处是可以为单独的怪物设置攻击力
- 怪脚本
- 首先创建一个公共函数
- 获得玩家组件里的血量计算公式,并将怪自身的攻击力传进去
- 调用的时候是在动画系统里面调用这个,在攻击最后一帧的时候触发公共函数(可读写的动画,需要去源fbx文件复制一份动画(最好是重命名一下,因为一般都不叫attack),找到attack动画按ctrl+d拿出来,而且还要放到动画状态机里)
- 也就是说动画能够播放到那一帧 就扣血
1
player.GetComponent<PlayerScript>.TakeHit(damageForce);
- 玩家脚本
胜利状态
1 | public void TakeHit(float amount){ |
GameStats
- 类似一个单例模式 用于记录状态 例如玩家死了 及设置false,然后加判断 就不执行某些代码了
- 一般来说 扩展代码 切换状态 再它们上面加if(){ /**/ return;} 是不会出什么错的
自适应UI
- Canvas Scaler👉UI Scale Mode 👉 Scale With Screen Size
数值溢出相关
- 在单例里面加个布尔判断
- 加个判断,并在子弹逻辑里面最后一句后面使得变成false
- 在动画系统里面的最后一帧(动画播放完)再调用单例里面的方法,让它变成true
雷达相关🦅
- P71/P72
- 大致逻辑 雷达的注册脚本
- 注册每个对象,并存到List里,包括坐标,图标等
- 删除对象,创建一个新的list,就凭着变量的gameobject来,遍历来找到并销毁,再跳出本次循环,其他不是的,存进新的list,最后移出旧List所有元素,并把新list加到旧List
- 雷达的更新脚本
- 在Update里面更新,如果 遍历出distance,也即是包含了方向的一个矢量 (有个mapScale系数)
- 使其每个点成为雷达的子物体
- 并获取到RectTransform组件,给予动态的坐标,关键代码和截图如下(其中的数学逻辑挺复杂)
1 | ro.icon.transform.position = new Vector3( RadarPos.x + rt.pivot.x , RadarPos.z + rt.pivot.y , 0) + this.transform.position; |
- 雷达遮罩 mask组件
- 医疗包、子弹显示在地图 也是类似原理,只不过换了图标(把MakeRaderObject.cs脚本挂载prefab上)
罗盘方向条🦅
-P74
- 逻辑(是包含数学)
灯光系统
- intensity 强度
- 反射探针 为场景中需要生成更加真实的反射效果的物体提供额外的信息支持
- 让火炬的阴影展现在地面上 MeshRender的Lighting的cast shadow 选成 Two sided
粒子
想象成花洒
- 飙血效果(子弹打在身上减血)
- 生成一个blood,位置是打击的那个点 在射线打击会返回一个点的坐标
- 并让他lookAt玩家(lookAt这么好用的么)
被攻击时屏幕有受伤状态
- 首先有一张图片,对它进行录制透明度的过渡动画(unity也有类似PR的关键帧)
- 在动画状态机里会自动创建
- 把代码写在玩家的代码的生命计算函数里,也就是加血减血的那个,生成一个GameObject,并使得它成为Canvas的组件,并在两秒后销毁
- 随机产生在canvas上,首先应该拿到canvas的宽高,然后使用一个vector3的矢量(z值是0)来随机产生一个坐标给受伤动画
- 随机修改localscale让他产生不同大小
1
2.rect.width;
.rect.height;
音乐
- 免费网站
- Volume Rolloff 贝塞尔曲线
- 走动是可以感受到音量的变化的
- 当僵尸死了,在STATE.DEAD里应该 销毁其音源
- 教程里是循环遍历每个AudioSource[] ,然后将它们音量设置成0
- 想让僵尸,狼叫随机产生,可以直接单独写个脚本 让其随机产生
- addComponent
音频渲染模式默认是2D,解决方式是加个if(bool),改变成3d,改变最大距离,并设置为子物体,并且让子物体的localPosition = Vector3.zero
粒子shader
- 粒子旁边呈现方块,使用新版shader渲染
- particle/priority addition
后处理
- P85
- 给相机添加 post-process layer
- bloom 光晕 不是很好用
- depth of field 景深 (用来表示玩家心理)
按钮
关卡加载
按钮点击一个事件,触发一个函数 切换场景1
2
3public void PlayGame(){
SceneManager.LoadScene("GameLevel");
}光标隐藏 cursorIsLocked = falese
两个controller的冲突
1
2
3
4//用标签find到物体
if(objs.Length > 1){
Destroy(gameObject);
}有时觉得构建功能完整 可以来回横跳的软件架构是一种很难的事,而且有时destroy游戏物体导致后面的逻辑无法触发也会造成冲突
调节音量
- 一个slider 的数值传进一个函数(float),进而改变音量
- 不要忘记在awake时让音量等于滑块的初始值 这样在场景切换不会导致bug,你以为很简单的逻辑其实要思考缜密,并且可以来回横跳
续命(3条命)
- 用一个if判断(写在Takeit方法里)
- 记录重生点(本身在游戏刚开始就应该记录)
- 销毁死亡的
- 生成新的
- 开启相机
- 状态恢复
存档位置保存
- ontrigger方法
给僵尸增加生命值
- if判断,大致逻辑
- 僵尸的逻辑代码里有最大生命和受伤值
- 每当击中僵尸.get组件里的受伤害值和最大声明,如果受伤大于最大生命,则触发之前的代码
记录 FPS Zombies