记录 FPS Zombies

记录 FPS Zombies

👹

是一篇 恐怖生存类游戏的 制作笔记

判断是否player在空中

  • 射线检测
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    void 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
    5
    float horizontalInput = Input.GetAxis("Horizontal");
    float verticalInput = Input.GetAxis("Vertical");

    Vector3 movement = new Vector3(horizontalInput, 0, verticalInput) * speed;
    transform.Translate(movement);

弹夹补充方案

1
2
3
4
5
6
7
8
//装载所需子弹
int amountNeeded = ammoClipMax - ammoClip;
//一个判断:如果所需的小于子弹库就只装载所需的,如果所需大于现有的 只能全部装载咯
int ammoAvailable = amountNeeded < ammo ? amountNeeded : ammo;
//现有的减去所需的
ammo -= ammoAvailable;
//装载弹夹
ammoClip += ammoAvailable

Terrain地图刷

  • Hierarchy添加Terrain地形
  • 在笔刷下按住shift是橡皮

skybox天空盒

  • 创建一个天空盒 要创建材料 然后选择skybox/panoramic 着色器
  • 如果出现纹理有一道白条
    • 将素材warp mode修改为clamp
    • 并取消generate mip maps

fog

  • 在lighting里开启
  • Mode
    • Linear : 线性 离相机越近的物体,雾的密度越小
    • ⭐ Exponential : 指数雾是一种更加真实的雾模式,它使用指数函数来控制雾的密度。离相机越远的物体,雾的密度增长速度越快
    • Exponential Squared : 指数平方雾是一种更加密集的雾模式,它使用指数平方函数来控制雾的密度。离相机越远的物体,雾的密度增长速度更快,同时雾的密度也更加密集

设计

  • 原则
    • 就近性 相关互联的元素应该紧密放置在一起
    • 连贯的自我风格,也就是说能让人知道这是同一个人做的,就像毕加索和莫奈的画
    • 颜色搭配建议从 网上找配好的色盘

游戏地图布局

PotPlayerMini64_XHeZITHrEZ.png

  • 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
      15
      void 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
    30
    void 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
    118
    using 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()
    {
    #region 状态机相关

    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;
    }

    #endregion
    }
    }

idle / wander 切换

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
case STATE.IDLE:
if (CanSeePlayer())
{
state = STATE.CHASE;
}
//Idle / wander 的状态切换
else if (Random.Range(0, 1000) < 5)
{
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;
//Idle / wander 的状态切换
else if (Random.Range(0, 1000) < 5)
{
state = STATE.IDLE;
/*清除路径和动画*/
agent.ResetPath();
}
break;

生成怪物の方案

1
2
3
4
5
6
7
8
9
10
11
12
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;
Instantiate(prfb, randomPoint, Quaternion.identity);
}
}
  • 会因为地形 将怪物生成到空中
    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
    using 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
      5
      if (Input.GetKeyDow(KeyCode.P))
      {
      GameObject rd = Instantiate(avatarPrfb);
      rd.transform.Find("Hips").GetComponent<Rigidbody>().AddForce(Camera.main.transform.forward * 一个系数);
      }

射击方案

  • 指定一个枪口坐标位置
  • 将射线发射脚本挂载到那个物件上
  • 该挂件起名叫做Shot direction
  • 僵尸用的是椭圆体碰撞体
  • PotPlayerMini64_9QYejMks3o.png
    • hitInfo是用来存储检测返回
    • 第一个if条件里是从以一个角度从一个点发射(有距离限制,out返回一个值)
    • 从hitInfo里获取有用信息 比如GameObject
    • 如果GameObject == “Zombie”,获取这个挂件上的ragdoll(本质是个有死亡动画的游戏物体)
    • 生成这个物体,并找到Hips(这上面有刚体),给刚体一个力 造成冲击的假象
    • 销毁原来的
    • 补充:为了测试原地死亡和被击退死亡的对比 所以加了Random.Range函数加以对比

准心瞄准

  • 在枪上面挂载点光源挂件
  • 把准心图片挂载到点光源的Cookie里
  • Spot Range调节大小
  • 把图片渲染模式调成clamp
  • 只在某个图层显示
    • 先设置怪物的图层,再在光源挂件的Culling mask选择触发图层

尸体下沉消失

1
2
//找到触发の这个点
Terrain.activeTerrain.SampleHeight(transform.position);

然后摧毁collider 和 游戏物体 和 AI导航

玩家生命值相关

  • 构成
    • 玩家脚本
      • 初始生命值
      • clamp函数限定血量计算公式,因为他这个包括加血和减血,包含一个形参
      • 这样的好处是可以为单独的怪物设置攻击力
    • 怪脚本
      • 首先创建一个公共函数
      • 获得玩家组件里的血量计算公式,并将怪自身的攻击力传进去
      • 调用的时候是在动画系统里面调用这个,在攻击最后一帧的时候触发公共函数(可读写的动画,需要去源fbx文件复制一份动画(最好是重命名一下,因为一般都不叫attack),找到attack动画按ctrl+d拿出来,而且还要放到动画状态机里)
      • 也就是说动画能够播放到那一帧 就扣血
        1
        player.GetComponent<PlayerScript>.TakeHit(damageForce);

胜利状态

1
2
3
4
public void TakeHit(float amount){
health = (int)Mathf.Clamp(health - amount,0,maxHealth);
//等生命值小于0了就可以播放动画、生成prefabs等等、胜利动画状态
}

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组件,给予动态的坐标,关键代码和截图如下(其中的数学逻辑挺复杂)
    • PotPlayerMini64_dooY1K0Ah3.png
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

  • 逻辑(是包含数学)
    • PotPlayerMini64_U02tVzNLHt.png

灯光系统

  • 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 贝塞尔曲线
    • PotPlayerMini64_Z81BbyRayc.png
    • 走动是可以感受到音量的变化的
    • 当僵尸死了,在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
    3
    public 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组件里的受伤害值和最大声明,如果受伤大于最大生命,则触发之前的代码
作者

发布于

2023-05-20

更新于

2023-11-23

许可协议