内容纲要

搭建场景:

  • 场景布局,设置static属性,设置Layer层,调整属性(Carmera,Lighting)

  • 一般复杂的场景为了渲染效果都会做的很精细(有很多三角形构成),但是碰撞检测这种内部事件就可以简单点,会有场景的一个低模mesh来表征碰撞,降低性能消耗 //mesh collider的convex选项表示该模型是个凸多边形,(一般我们创建的都是凸多边形,像山洞啊什么的可能就为凹多边形)

  • 把场景都设置为Static //光照 导航 遮挡都是静态的 //在unity中参与Lightmaps烘焙的物体必须都是静态的对象

  • 增加灯光,对场景进行烘焙,把烘焙后的内容作为贴图,贴到物体上

  • 设置light的效果:


添加灯光:

  • Lighting烘焙: 把Light的Baking属性设置为Baked, 在需要烘焙的物体设置为static,可以在Lighting->Scene中把Precomputed realtime GI(预计算的实时光照去掉) //烘焙完成后会生成物体的光照贴图的信息(会有一个Scene同名的文件夹)贴到被设置为Static的物体上

  • Light中backed Shadow Radius决定了back类型的光照照出来的阴影的散射半径,但其值为0时,表示不散射,此时hard Shadow和soft shadow没有差别,但该值越大,soft shadow的阴影散射的就越厉害

  • Light中选择realtime下soft shadow的表现形式受到反射次数等参数影响,且需要在Lighting->scene中打开precomputed realtime GI,关闭Baked GI的选项

  • Light中的Mixed光照模式,包含了实时计算和Baked两个功能,当物体没有动,光照没变化时,采用baked的光照信息;当物体运动时,使用precomputer teatime 实时计算光照shadows //听说用的不多?实现的是静态物体透视到动态物体上的阴影,因为静态物体不会动,所以阴影基本是固定的,bake好阴影贴图,动态的贴到动态物体上?

  • Light中的Culling Mask表示有灯光作用的物体标签类型,不选择中的标签不受光照影响

  • 增加一盏场景灯和报警灯,场景灯提供场景关照,报警灯当发现玩家后进行报警的视觉和声音效果:

  • 报警灯脚本:

    public class AlarmLight : MonoBehaviour
    {
    public float fadeSpeed = 2f;  //灯光报警fade的速度(默认2s变化一次)
    public float hightIntensity = 4f; //最高最低亮度
    public float lowIntensity = 0.5f;
    public float changeMargin = 0.2f; //插值阈值
    public bool alarmOn;
    
    private float targetIntensity; //目标亮度值
    private Light alarmLight;
    private AudioSource audioSource;
    
    private void Awake()
    {
        alarmLight = GetComponent<Light>();
        audioSource = GetComponent<AudioSource>();
        alarmLight.intensity = 0;
        targetIntensity = hightIntensity;
    }
    
    // Update is called once per frame
    void Update()
    {
        if (alarmOn)
        {
            if (!audioSource.isPlaying)
                audioSource.Play();
    
            alarmLight.intensity = Mathf.Lerp(alarmLight.intensity, targetIntensity, fadeSpeed * Time.deltaTime);
    
            if (Mathf.Abs(targetIntensity - alarmLight.intensity) < changeMargin)
            {
                if (targetIntensity == hightIntensity)
                    targetIntensity = lowIntensity;
                else
                    targetIntensity = hightIntensity;
            }
        }
        else
        {
            if (audioSource.isPlaying)
                audioSource.Stop();
    
            alarmLight.intensity = Mathf.Lerp(alarmLight.intensity, 0, fadeSpeed * Time.deltaTime);
        }
    }
    }

    烘焙场景Lightmaps

  • //烘焙场景是使用bake类型的灯光对场景进行烘焙,生成贴图贴到物体表面,用于表现灯光效果

  • Unity的官方解释:

  • 烘焙的意义:单独使用 Unity 实时光源的光线时,这些光线不会自动进行反射。为了使用全局光照等技术创建更逼真的场景,我们需要启用 Unity 的预计算光照解决方案;Unity 可以计算复杂的静态光照效果(使用称为全局光照(简称 GI)的技术)并将它们存储在称为光照贴图的纹理贴图中作为参考。这一计算过程称为烘焙。对光照贴图进行烘焙时,会计算光源对场景中静态对象的影响,并将结果写入纹理中,这些纹理覆盖在场景几何体上以营造出光照效果。

  • (这些光照贴图既可以包括照射到表面的直射光,也可以包括从场景内其他物体或表面反射的间接光。该光照纹理可与颜色(反照率)和浮雕(法线)之类的对象表面信息材质相关联的着色器一起使用。

  • 使用烘焙光照时,这些光照贴图在游戏过程中无法改变,因此称为“静态”。实时光源可以重叠并可在光照贴图场景上叠加使用,但不能实时改变光照贴图本身。

  • 通过这种方法,我们可在游戏中移动我们的光照,通过降低实时光计算量潜在提高性能,适应性能较低的硬件,如移动平台)

  • 预计算实时全局光照

  • 虽然静态光照贴图无法对场景中的光照条件变化作出反应,但预计算实时 GI 确实为我们提供了一种可以实时更新复杂场景光照的技术。

  • 通过这种方法,可创建具有丰富全局光照和反射光的光照环境,能够实时响光照变化。这方面的一个典型例子是一天的时间系统:光源的位置和颜色随时间变化。如果使用传统的烘焙光照,这是无法实现的

  • 优势和代价

  • 虽然可以同时使用烘焙 GI 光照和预计算实时 GI,但要注意,同时渲染两个系统的性能开销也是各自开销的总和。我们不仅需要在视频内存中存储两组光照贴图,而且还要在着色器中进行解码的处理。

  • 在什么情况下选择什么光照方法取决于项目的性质和目标硬件的性能。例如,在视频内存和处理能力局限性更大的移动端,烘焙 GI 光照方法可能具有更高性能。在具有专用图形硬件的独立计算机或最新款的游戏主机上,很可能可以使用预计算实时 GI,甚至同时使用这两个系统。

  • 必须根据特定项目和所需目标平台的性质来决定采用哪种方法。请记住,在面向一系列不同硬件时,通常情况下,性能最低的硬件将决定选取哪种方法。

  • 添加灯光:

  • Render Mode表示渲染的类型:Important表示逐像素渲染灯光,Not Impritant表示逐顶点渲染灯光,Auto表示自动

  • 设置渲染质量:Project Setting -> Quality面板,因为我们游戏场景中的资源偏多,可能存在效率问题,所以我们我们渲染质量选择Good; 设置Rendering中 Pixel Light Cout(逐像素渲染的灯光的数量),因为我们灯光都选择的Auto的类型,所有应该Unity会挑6盏灯逐像素渲染,其他都逐顶点渲染(Unity怎么挑的???)

  • 设置光照贴图的设置,之后就可以开始Bake了:

  • 具体参数可以参考Unity官方文档,2018开始Unity的Lightmapper中提供了一种新的烘焙方式Progressive,此方式进行渐进式的光照贴图烘焙,一个简单的场景烘焙时间都到10个小时起

  • 烘焙完成后可以在Scenes下找到和场景同名的文件夹中存放的就是烘焙后的数据,会作为贴图,贴到物体上

  • 烘焙完成后的贴图自动贴到了场景物体上,此时关闭所有灯光,物体依旧具有关照的效果


添加Tag的管理类 //用来定义Tag的字符串的静态变量

  • 添加转场效果 //加载场景逐渐变亮,退出场景逐渐变暗

  • 使用RawImage遮挡这个画面实现,代码部分:

    public class ScreenFadeInOut : MonoBehaviour
    {
    public float fadeSpeed = 1.5f;
    
    private bool sceneStarting;
    private RawImage rawImage;
    
    // Start is called before the first frame update
    void Start()
    {
        sceneStarting = true;
        rawImage = this.GetComponent<RawImage>();
    }
    
    // Update is called once per frame
    void Update()
    {
        if (sceneStarting)
        {
            rawImage.color = Color.Lerp(rawImage.color, Color.clear, fadeSpeed * Time.deltaTime);
    
            if (rawImage.color.a <= 0.05f)
            {
                rawImage.color = Color.clear;
                sceneStarting = false;
                rawImage.enabled = false;
            }
        }
    }
    
    public void EndScene()
    {
        rawImage.enabled = true;
        rawImage.color = Color.Lerp(rawImage.color, Color.black, fadeSpeed * Time.deltaTime);
        if (rawImage.color.a > 0.95f)
            SceneManager.LoadScene(0);
    }
    }
  • 添加游戏控制器GameController //负责控制背景音乐播放,角色位置管理

  • 为GameController添加Audio文件, //这里我们添加了两个Audio都设置play ON Awake和loop,区别在于normal主音量值为1 ,Panic被发现时的音量值为0 //我们要实现的效果就是当玩家被发现时,主音量逐渐降低,Panic音量逐渐提高

  • 具体脚本:

    public class LastPlayerSighting : MonoBehaviour
    {
    public Vector3 position = new Vector3(1000f, 1000f, 1000f); //表示玩家最后一次被发现的位置,如果没有被发现,就设置为默认值
    public Vector3 resetPosition = new Vector3(1000f, 1000f, 1000f);
    public float lightHighIntensity = 0.25f;  //主灯光的亮度范围
    public float lightLowIntensity = 0f;
    public float lightFadeSpeed = 7f;
    public float musicFadeSpeed = 1f;  //音乐变化的fade速率
    public bool isPlayerFound = false;
    
    private AlarmLight alarmLightScript;
    private Light mainLight;          //主灯光
    private AudioSource mainMusic;  //主音乐和panic时播放的音乐
    private AudioSource panicMusic;
    private AudioSource[] sirens;  //报警音乐
    private const float muteVolume = 0f; //音乐的变化范围
    private const float normalVolume = 0.8f;
    
    // Start is called before the first frame update
    void Start()
    {
        alarmLightScript = GameObject.FindGameObjectWithTag(Tags.ALARM_LIGHT).GetComponent<AlarmLight>();
        mainLight = GameObject.FindGameObjectWithTag(Tags.MAIN_LIGHT).GetComponent<Light>();
        mainMusic = this.GetComponent<AudioSource>();
        panicMusic = this.transform.Find("Secondary_music").GetComponent<AudioSource>();
        //sirens = new AudioSource[];
    }
    
    // Update is called once per frame
    void Update()
    {
        isPlayerFound = (position != resetPosition);
    
        //当玩家被发现时,调低主灯光,打开报警灯,淡出主音乐,淡入panic音乐, 但玩家脱离危险后恢复;
        mainLight.intensity = Mathf.Lerp(mainLight.intensity, isPlayerFound ? lightLowIntensity : lightHighIntensity, lightFadeSpeed * Time.deltaTime);
        alarmLightScript.alarmOn = isPlayerFound;
        mainMusic.volume = Mathf.Lerp(mainMusic.volume, isPlayerFound ? muteVolume : normalVolume, musicFadeSpeed);
        panicMusic.volume = Mathf.Lerp(panicMusic.volume, isPlayerFound ? normalVolume : muteVolume, musicFadeSpeed);
    }
    }

    添加CCTV Carmera

  • //CCTV闭路电视 //碰撞的触发依赖于Riggdbody组件,没有Riggdbody就不会触发trigger和collision(为了防止两个单独的collider互相触发)

  • 添加Carmera的模型

  • 添加mesh collider和spot光源

  • 添加碰撞脚本:

    public class CCTVCollision : MonoBehaviour
    {
    
    private LastPlayerSighting lastPlayerSighting;
    
    private void Start()
    {
        lastPlayerSighting = GameObject.FindGameObjectWithTag(Tags.GAMECONTROLLER).GetComponent<LastPlayerSighting>();
    }
    
    private void OnTriggerStay(Collider other)
    {
        if (other.tag == Tags.PLAYER)
        {
            lastPlayerSighting.position = other.transform.position;
        }
    }
    
    private void OnTriggerExit(Collider other)
    {
        if (other.tag == Tags.PLAYER)
        {
            lastPlayerSighting.position = lastPlayerSighting.resetPosition;
        }
    }
    }
  • 添加Animator使其旋转, 注意Animator中可以设置curves调整动画变化的速率


添加Laser Grid(激光栅栏)

  • //一般Main Camera上会挂一个Audio Listener用于收音(一个场景只允许由一个Audio Listener)

  • //在unity当前版本中Audio Source要设置Spatial Blend到3D,下面的3D Sound Settings才会生效(会有一个空间衰减的相关)

  • 添加laser的模型(并使其和场景匹配),box collider,Light(红色点光源)和Audio Source

  • 为Laser添加脚本,控制激光栅栏的开关和碰撞检测:

  • 激光栅栏的开关:

    public class LasterBlinking : MonoBehaviour
    {
    public float onTime;  //灯灭的时间间隔
    public float offTime;  //灯亮的时间间隔
    
    private float timer;  //流逝的时间
    private Renderer laserRenerer;
    private Light laserLight;
    
    // Start is called before the first frame update
    void Start()
    {
        laserRenerer = GetComponent<Renderer>();
        laserLight = GetComponent<Light>();
        timer = 0;
    }
    
    // Update is called once per frame
    void Update()
    {
        timer += Time.deltaTime;
        if (laserRenerer.enabled && timer >= onTime)
        {
            laserRenerer.enabled = false;
            laserLight.enabled = false;
            timer = 0;
        }
        else if (!laserRenerer.enabled && timer >= offTime)
        {
            laserRenerer.enabled = true;
            laserLight.enabled = true;
            timer = 0;
        }
    }
    }
  • 碰撞检测:

    public class LaserPlayerDetection : MonoBehaviour
    {
    private void OnTriggerStay(Collider other)
    {
        if (other.tag == Tags.PLAYER && this.GetComponent<Renderer>().enabled)
        {
            LastPlayerSighting.Instance.position = other.transform.position;
        }
    }
    }

导入角色模型和控制角色移动

  • 可以在project Settings中设置中自定义输入事件:

  • Size表示已经定义的Input事件数量

  • Name中的字段表示在Input.GetAxis()中填写的字符串

  • positive Button表示触发该事件的按键,Negative Button表示相反方向的按键(一般成对的按键可以使用); Alt Positive Button和Alt Negative Button表示另一个触发事件的按键

  • Snap勾选后会再按键按下后立即清0(没有惯性的作用,一般我们都不习惯使用惯性) //比如说ws键表征上下,在不勾选snap的情况下,先按w,Input.GetAxis()取得的值会从0变化到1,此时按下s,Input.GetAxis()取得的值会从1慢慢变化到-1,而不是立即变为0,然后从0变化到-1;

  • 导入人物模型,添加capsule collider(调整参数使其匹配角色),riggdbody(在Constraints中现在角色在y轴的移动,在x,y,z轴的旋转), audio source ,audio listener(去掉Main Carmer的audio listener);

  • 添加动画状态控制器, //动画状态会有不同的层,除了默认层外,其他层可以设置不同的权重进行混合(权重调整为1表示混合),和Mask,Mask表示参与动画混合的模型的部分(比如这个游戏中在喊叫时只有头部和右手参与动画混合)

  • 在本例中,红色的身体部分表示不支持IK(反向动力学),绿色身体部分表示支持IK: //(一般人体动力学是从人的盆骨开始发力,一直到身体,胳膊,手指,反向动力学正好相关,从神经末梢开始)

  • 添加Blend Tree,混合调整walk和run的状态:

  • 在Animator窗口下,右键添加Create State -> Form New Blend Tree添加新的混合树:

  • Blend Type指定该Blend Type的控制维度,下面的Parameter表示该维度下的坐标参考值,Motion是我们要添加的动画

  • 在motion窗口中:Threshold的值表示完全进入该状态下对应的坐标,后面的小时钟表示动画播放的速度,最后的两个小人表示是否是镜像的动画

  • AutoMate Thresholds是自动计算walk和run之间切换的临界点,如果不勾选AutoMate Thresholds,会出现compute Thresholds的选项,由计算机去计算不同动画之间切换的临界值: // 其中speed选项是按照动画状态下,人物的位移计算移动的距离,x、y、z选项分别计算移动时在三个坐标轴上的分量

  • 因为我们的移动使用Animator进行控制,所以任务的移动交由Animator进行完成:

    public class PlayerMovement : MonoBehaviour
    {
    
    public float speedDampTime = 0.5f;  //进行阻尼过渡的总时间
    public float rotationDampTime = 15f;
    
    private Rigidbody rigidbody;
    private Animator animator;
    
    private int sneakingBool;
    
    // Start is called before the first frame update
    void Start()
    {
        animator = this.GetComponent<Animator>();
        rigidbody = this.GetComponent<Rigidbody>();
        animator.SetLayerWeight(1, 1f);    //设置第一层的(默认层是第零层),的权重为1 //打开该层
    
        sneakingBool = Animator.StringToHash("Sneaking");  //获取Animator参数的hash值,之后可以通过   animator.SetBool(sneakingBool, sneak);传入该hashcode设置该值
    }
    
    private void FixedUpdate()
    {
        float h = Input.GetAxis("Horizontal");
        float v = Input.GetAxis("Vertical");
        bool sneak = Input.GetButton("Sneak");   //如果要判断按键是否按下,GetButton就可,要按按下的值要用GetAxis
    
        //潜行
        //animator.SetBool("Sneaking", sneak);
        animator.SetBool(sneakingBool, sneak);
    
        //移动
        if (h != 0 || v != 0)
        {
            animator.SetFloat("Speed", 5.6f, speedDampTime, Time.deltaTime);  //在这里我们无论向左右,向上下移动均是都会设置speed(因为我们的Animator的移动的状态只有walk和run两种状态)
        }
        else
        {
            animator.SetFloat("Speed", 0f);
        }
    
        //转向
        if (h != 0 || v != 0)
        {
            Vector3 targetDir = new Vector3(h , 0, v);  //详见下方解释
            Quaternion targetQuaternion = Quaternion.LookRotation(targetDir);
    
            Quaternion currentQuaternion = Quaternion.Lerp(rigidbody.rotation, targetQuaternion, rotationDampTime * Time.fixedDeltaTime);
    
            rigidbody.MoveRotation(currentQuaternion);
    
        }
    }
    }
  • 转向代码的解释:(h,0,v)可以表征一个二维平面的向量,该向量只表示方向,在这段代码中(h,v)的方向值就是我们的目标方向值,Quaternion.LookRotation只是把这个目标方向量化为了一个四元数,然后通过lerp方式进行插值计算,当我们移动到一个合适的位置时,松开按键,转向自然停止;

  • 给角色增加音频效果:

  • 脚步声:

        if (animator.GetCurrentAnimatorStateInfo(0).IsName("Locomotion")) //如果当前在移动的话
        {
            if (!audioSource.isPlaying)
                audioSource.Play();
        }
        else if (audioSource.isPlaying)
        {
            audioSource.Stop();
        }
  • 增加喊叫功能:

    public AudioClip shoutAudio;
    bool shout = Input.GetButtonDown("Attract");
        if (shout)
        {
            AudioSource.PlayClipAtPoint(shoutAudio, this.transform.position);
        }

Camera跟踪玩家控制:

  • //因为carmer和玩家之间的视角可能被障碍物遮挡,所以在合适的时候我们需要拉高摄像机

    public class CameraMovement : MonoBehaviour
    {
    public float smoothTime = 1.5f;
    private Transform player;
    
    private Vector3 relCameraPos;  //从player指向camera的向量
    private float relCameraPosMag;  //我们尽量保持的Camera和玩家之间的距离
    private float stepCoefficient = 0.2f; //步长系数
    private Vector3 targetPos;
    
    // Start is called before the first frame update
    void Start()
    {
        player = GameObject.FindGameObjectWithTag(Tags.PLAYER).transform;
        relCameraPos = this.transform.position - player.position;
        relCameraPosMag = relCameraPos.magnitude;
    }
    
    private void LateUpdate()
    {
        Vector3 positionCamera = player.transform.position + relCameraPos;  //Camera理应在的位置
    
        //当我们移动角色时,角色和Camera之间可能被墙或者其他障碍物遮挡,此时我们需要向上调整Camer的视角,使其能观察到玩家
        Vector3 playerUp = player.position + player.up * relCameraPosMag;  //玩家上方90度方向距离relCameraPosMag的位置,//这个是Camer的极限位置,在玩家头顶正上面,此时如果还有物体遮挡,那么保持这个玩家和Camera之间的距离就找不到一个合适的角度进行观察
    
        RaycastHit hitInfo;
        for (float i = 0; i <= 1; i += stepCoefficient)  //这里在Camera理应在的位置,和玩家头顶的极限位置之间进行了5次取点,依次拉高摄像头,直到可以看到玩家
        {
            targetPos = Vector3.Lerp(positionCamera, playerUp, i);  //这里如果要用Slerp做一个弧形调整,需要以player的transform为圆心,求Slerp时,先减去Player.transform,求出来的结果再加上player的transform表征真正的点,但是摄像机弧形位移视觉效果一般啊
            Physics.Raycast(targetPos, player.position - targetPos, out hitInfo);
            if (hitInfo.transform.tag == Tags.PLAYER)
                break;
        }
    
        this.transform.position = Vector3.Lerp(this.transform.position, targetPos, smoothTime * Time.deltaTime);
        this.transform.rotation = Quaternion.Slerp(this.transform.rotation, Quaternion.LookRotation(player.position - targetPos), smoothTime * Time.deltaTime);  //Quaternion.LookRotation(player.position - targetPos)这句和LookAt的旋转值一样,不过LookAt直接调整物体的rotation,在这里我们进行了个插值运算,角度差值一般用Slerp啊;
    }
    }

和环境的交互:

  • 添加控制激光栅栏的开关控制台: //BoxCollider表示碰撞体阻止玩家移动到控制台内部,sphere Collider才表示交互区域

  • 控制脚本:

    public class LaserSwitchDeactivation : MonoBehaviour
    {
    public Material unlockMat;
    public GameObject laser;
    private void OnTriggerStay(Collider other)
    {
        if (other.gameObject.tag == Tags.PLAYER && Input.GetButtonDown("Switch"))
        {
            laser.SetActive(false);  //关闭激光栅栏
            transform.Find("prop_switchUnit_screen").GetComponent<Renderer>().material = unlockMat;
            this.GetComponent<AudioSource>().Play();
        }
    }
    }
  • 添加钥匙和控制脚本:

    public class KeyPickUp : MonoBehaviour
    {
    public AudioClip audioClip;
    private void OnTriggerEnter(Collider other)
    {
        if (other.transform.tag == Tags.PLAYER)
        {
            other.GetComponent<PlayerInvertory>().HasKey = true;
            AudioSource.PlayClipAtPoint(audioClip, this.transform.position);  //因为最后碰撞到物体后,对钥匙卡进行了销毁,所以这里使用AudioSource的静态方法播放Audio Clicp,否则Destroy会销毁Audio Source的组件
            Destroy(this.gameObject); 
        }
    }
    }
  • 添加单开门:

  • 开门的动作除了播放一个完整的状态,还可以用动画控制器在两个状态之间进行Lerp切换:

  • SignalDoorClose和SignalDoorOpen都只记录了sliderdoor的子节点door_generic_slide_panel(动画控制器可以只记录子节点的状态)的初始状态,然后通过在两个状态之间的转化,进行动画效果: //也就是说,动画不一定都由动画Animation来实现,也可以由中间的转换过程实现:

  • 动画控制脚本:

    public class DoorAnimation : MonoBehaviour
    {
    public bool requireKey;   //开门是否需要钥匙
    public AudioClip doorSwitchClip;
    public AudioClip accessDeniedClip;  //不能开门的音频
    
    private Animator animator;
    private AudioSource audioSource;
    private int cout;
    
    // Start is called before the first frame update
    void Start()
    {
        animator = this.GetComponent<Animator>();
        audioSource = this.GetComponent<AudioSource>();
    }
    
    private void OnTriggerEnter(Collider other)
    {
        if (other.transform.tag == Tags.PLAYER)
        {
            if (requireKey)
            {
                if (!other.GetComponent<PlayerInvertory>().HasKey)
                {
                    audioSource.clip = accessDeniedClip;
                    audioSource.Play();
                    return;
                }
    
                audioSource.clip = doorSwitchClip;
                audioSource.Play();
                cout++;
            }
            else
            {
                audioSource.clip = doorSwitchClip;
                audioSource.Play();
                cout++;
            }
        }
        else if (other.transform.tag == Tags.ENEMY)
        {
            cout++;
        }
    
        animator.SetBool("Open", cout > 0); //因为我们玩家和敌人同时进入collider区域的时候,我们不希望某一方离开碰撞区域后,门就关闭,我们希望当碰撞区域没人的时候我们再关闭,所以这里的cout计数就表示碰撞区域内的可移动物体数量,当所有人都离开时,我们才把门关上
    }
    
    private void OnTriggerExit(Collider other)
    {
        if (other.gameObject.tag == Tags.PLAYER || other.gameObject.tag == Tags.ENEMY)
        {
            cout = Mathf.Max(0, cout - 1);
        }
    
        animator.SetBool("Open", cout > 0);
    }
    }

主角和敌人的交互:

  • 玩家死亡控制脚本:

    public class PlayerHealth : MonoBehaviour
    {
    public float health = 100f;
    public float resetAfterDeathTime = 5f;
    public AudioClip deathClip;
    
    private Animator animator;
    private float timer;
    private bool isPlayerDead = false;
    private ScreenFadeInOut screenFadeIn;
    
    // Start is called before the first frame update
    void Start()
    {
        animator = this.GetComponent<Animator>();
        screenFadeIn = GameObject.FindGameObjectWithTag(Tags.FADER).GetComponent<ScreenFadeInOut>();
    }
    
    // Update is called once per frame
    void Update()
    {
        if (health <= 0f)
        {
            if (isPlayerDead)
            {
                timer += Time.deltaTime;
                //场景变黑,关卡重置
                LastPlayerSighting.Instance.position = LastPlayerSighting.Instance.resetPosition;
    
                if (timer >= resetAfterDeathTime)
                {
                    screenFadeIn.EndScene();
                }
            }
            else
            {
                //垂死状态
                animator.SetBool("Dead", true);
                isPlayerDead = true;
                AudioSource.PlayClipAtPoint(deathClip, this.transform.position);
            }
        }
    }
    
    public void TakeDamage(float amout)
    {
        health -= amout;
    }
    }

增加敌人,添加碰撞体,Animator,导航智能体:

  • 敌人的Animator:第一层是一个移动状态的blend tree: //通过speed和Angular Speed控制Enemy的移动

  • 第二层和第一层进行混合播放: //带有Mask和Ik的混合层,通过PlayerInSight变量控制状态改变

  • 对身体的上半身进行了混合,并勾选了反向动力学选项,可以由手从后向前驱动

  • IK示意图://黄色表示正向动力学,绿色可以表征反向动力学:(当要抬起手时,我们控制手向上移动,然后通过反向动力学,控制连带关节向上移动)

  • 第三层是一个枪的动作,影响不大:

  • 为敌人添加其他移动组件:

  • 注意Rigidbody中使用Is Kinematic由Transform组件控制角色移动,Nav Mesh Agent中Stopping Distance,不要设置为0;


敌人视角控制逻辑:

  • 敌人视角控制脚本:

    public class EnemySight : MonoBehaviour
    {
    public float fieldOfViewAngle = 110f;  //Fov角度,是一个全视角,我们在用的时候要使用半视角去判断; (一般人的视角大概就是120度左右) 
    public bool isPlayerInSight { get; private set; } //玩家是否被发现
    public Vector3 personalLastSighting { get; private set; } //记录玩家被本敌人发现(听见)的位置,如果时看见,直接更新LastPlayerSighting中的position
    public bool isPlayerFoundOut { get; private set; } //玩家被别人发现
    
    private Vector3 previousSighting; //记录上一帧是玩家被发现的位置,主要用于检测玩家是否被"外部"发现
    private NavMeshAgent navMeshAgent;
    private SphereCollider sphereCollider;
    private Animator animator;
    private GameObject player;
    private Animator playerAniamtor;
    private PlayerHealth playerHealth;
    
    // Start is called before the first frame update
    void Start()
    {
        navMeshAgent = GetComponent<NavMeshAgent>();
        animator = GetComponent<Animator>();
        player = GameObject.FindGameObjectWithTag(Tags.PLAYER);
        playerAniamtor = player.GetComponent<Animator>();
        sphereCollider = GetComponent<SphereCollider>();
        playerHealth = player.GetComponent<PlayerHealth>();
    
        previousSighting = LastPlayerSighting.Instance.resetPosition;
        personalLastSighting = LastPlayerSighting.Instance.resetPosition;
    }
    
    // Update is called once per frame
    void Update()  //update中主要判断玩家是否被外部发现:
    {
        previousSighting = LastPlayerSighting.Instance.position;
        if (previousSighting != LastPlayerSighting.Instance.resetPosition)  
        {
            isPlayerFoundOut = true;
        }
        else
            isPlayerFoundOut = false;
    }
    
    private void OnTriggerStay(Collider other)
    {
        if (other.gameObject.tag == Tags.PLAYER)
        {
            isPlayerInSight = false;
    
            //判断玩家是否在敌人前方视野范围内
            float angle = Vector3.Angle(this.transform.forward, other.transform.position - this.transform.position); //注意Angle的返回值总是小于180度非负度  //如果要取得带符号的角度值,使用SignedAngle方法
    
            if (angle < fieldOfViewAngle / 2)
            {
                RaycastHit hitInfo;
                Physics.Linecast(this.transform.position, other.transform.position, out hitInfo); //如果玩家和敌人之间有障碍物遮挡,那么敌人是看不到玩家的
                if (hitInfo.transform.tag == Tags.PLAYER && !playerHealth.isPlayerDead)
                {
                    LastPlayerSighting.Instance.position = other.transform.position;
                        isPlayerInSight = true;
                }
            }
    
            //判断玩家的脚本声和呼叫声能否被敌人听见;
            if (playerAniamtor.GetCurrentAnimatorStateInfo(0).IsName("Locomotion")  
                || playerAniamtor.GetCurrentAnimatorStateInfo(1).IsName("Shout"))  //如果玩家在移动或者喊叫,这里使用Animator进行状态的判断,也可以从玩家的脚本处获取玩家的状态,但是这个状态体现在Animator上
            {
                //计算敌人和玩家之间的最短路径:
                NavMeshPath path = new NavMeshPath();
                navMeshAgent.CalculatePath(other.transform.position, path);  //计算指定点的路径并存储生成的路径 //此功能可用于提前规划路径,以避免在需要路径时延迟游戏玩法。另一个用途是在移动代理之前检查目标位置是否可达
    
                float pathDistance = 0f;
                if (path.status == NavMeshPathStatus.PathComplete) //可以找到这条路径
                {
                    for (int i = 1; i < path.corners.Length; i++)  // path.corners至少包含起点和终点两个位置
                    {
                        pathDistance += Vector3.Distance(path.corners[i], path.corners[i - 1]);
                    }
                }
    
             //   print(pathDistance);
                if (pathDistance != 0 && pathDistance < sphereCollider.radius) //如果nav的距离小于我的collider的警戒距离,我也不关心
                {
                    personalLastSighting = other.transform.position;  //表示听见的位置,需要去确认下那是否有人啊
                 //   navMeshAgent.SetDestination(other.transform.position); //这个现在交给AI状态机去做,本脚本只更新状态
                }
            }
    
        }
    }
    
    private void OnTriggerExit(Collider other)
    {
        if (other.transform.tag == Tags.PLAYER)
        {
            isPlayerInSight = false;
            personalLastSighting = LastPlayerSighting.Instance.resetPosition;
        }
    }
    }

敌人移动控制:

  • 控制脚本:

    public class EnemyMovement : MonoBehaviour
    {
    private EnemySight enemySight;
    private Animator animator;
    private NavMeshAgent navMeshAgent;
    private float smoothTime = 0.5f;
    
    // Start is called before the first frame update
    void Start()
    {
        enemySight = this.GetComponent<EnemySight>();
        animator = this.GetComponent<Animator>();
        navMeshAgent = this.GetComponent<NavMeshAgent>();
    }
    
    // Update is called once per frame
    void Update()
    {
        float speed = 0f;
        float turn = 0f;
    
        if (enemySight.isPlayerInSight)  //玩家被看到,则转向玩家方向
        {
            Vector3 targetPositon = LastPlayerSighting.Instance.position - this.transform.position;
    
            turn = Vector3.SignedAngle(this.transform.forward, targetPositon, this.transform.up);
        }
        else    //否则如果在导航阶段就根据导航动作,匹配速度,否则就都为0
        {
            //求速度,desiredVelocity和我们前方向上的投影,desiredVelocity是目标速度(既包含方向,也有角度)
            speed = Vector3.Project(navMeshAgent.desiredVelocity, transform.forward).magnitude;
    
            //求角度,desiredVelocity和forward的夹角
            turn = Vector3.SignedAngle(transform.forward, navMeshAgent.desiredVelocity, transform.up);
        }
        animator.SetFloat("Speed", speed, smoothTime, Time.deltaTime);
        animator.SetFloat("AngularSpeed", turn * Mathf.Deg2Rad);
        animator.SetBool("PlayerInSight", enemySight.isPlayerInSight);
    }
    
    private void OnAnimatorMove()
    {
        navMeshAgent.velocity = animator.deltaPosition / Time.deltaTime;           //我们根据导航的目标速度navMeshAgent.desiredVelocity来修正动画的播放状态,然后根据动画的实际移动距离来控制导航移动速度和玩家旋转角度
        this.transform.rotation = animator.rootRotation;
    }
    }
  • 关于导航速度和动画速度的匹配关系:

  • 敌人的移动速度由desiredVelocity决定,desiredVelocity速度的最大值受到NavMeshAgent.Speed限制

  • desiredVelocity计算并决定敌人的移动速度和转向角度

  • 将这两个数值设置给动画控制器

  • 动画控制器采用了ApplyRootMotion选项,所以实际的位移和旋转控制由脚本的OnAnimatorMove控制

  • OnAnimatorMove中,动画控制器根据传入的Speed算出位置偏移量,并据此设置实际的导航速度,旋转类似

  • 所以,归根到底,速度受导航navMeshAgent.desiredVelocity控制(导航知道要去哪),动画只是配合,然后匹配动画和导航


敌人射击控制:

  • 首先进行场景搭建,在敌人右手上挂把枪,给枪添加Light组件用于表示射击时的灯光,给枪再添加个子节点,用于表示子弹射出去的射线,用LineRenderer实现

  • 在humanoid_weapon_shoot的预制体的Animation中指定了随着这个动画播发过程中的Animator中的参数Shot随着播放时间其值得变化,我新加了个变量666,通过在Curves选项中控制666的值随时间的变化曲线,在变量的向左向右按钮可以选择特征点,然后可以在Events中指定在这个时间点的处理程序:On666;

  • 在我们的程序中,我们使用另外一种方法:直接读取Animator中的Shot变量的变化:

    public class EnemyShoot : MonoBehaviour
    {
    public AudioClip fireClip;
    
    private Animator animator;
    private bool isShoting = false;
    private Transform player;
    private float harm = 50f;
    private Transform gun;
    private LineRenderer gunLaser;
    
    // Start is called before the first frame update
    void Start()
    {
        animator = GetComponent<Animator>();
        player = GameObject.FindGameObjectWithTag(Tags.PLAYER).transform;
        gun = this.transform.Find("char_robotGuard_skeleton/char_robotGuard_Hips/char_robotGuard_Spine/char_robotGuard_RightShoulder/char_robotGuard_RightArm/char_robotGuard_RightForeArm/char_robotGuard_RightHand/prop_sciFiGun_low");
        gunLaser = gun.Find("fx_laserShot").GetComponent<LineRenderer>();
    }
    
    // Update is called once per frame
    void Update()
    {
        float shot = animator.GetFloat("Shot");
        if (shot > 0.5 && !isShoting)
        {
            isShoting = true;
    
            //伤害计算
            float r = this.GetComponent<SphereCollider>().radius;
            float d = (player.position - this.transform.position).magnitude;
            float factor = 1 - d / r;  //伤害系数,距离敌人越近,伤害越高,超出敌人感知范围就没伤害
            player.GetComponent<PlayerHealth>().TakeDamage(harm * factor);
    
            //实现射击效果
            gunLaser.enabled = true;
            gunLaser.SetPosition(0, gun.transform.position);
            gunLaser.SetPosition(1, player.position + Vector3.up * 1.5f); //玩家的position是在脚底的位置,所以这里加了一个1.5f的高度
    
            gun.GetComponent<Light>().intensity = 1f;
            AudioSource.PlayClipAtPoint(fireClip, this.transform.position);
        }
        else
        {
            isShoting = false;
            gunLaser.enabled = false;
    
            gun.GetComponent<Light>().intensity = Mathf.Lerp(gun.GetComponent<Light>().intensity, 0f, Time.deltaTime);  //发送激光后,Light的淡出效果
        }
    }
    
    void OnShotting()   //我们在shoot的animation指定的时间对于的事件,可以通过反射准确调用到
    {
        Debug.Log("Shotting!");
    }
    
    private void OnAnimatorIK(int layerIndex)   //animator在每一帧都会有针对Ik的回调 , layerIndex传入的是进行IK的层编号
    {
    //    print(layerIndex);
        animator.SetIKPosition(AvatarIKGoal.RightHand, player.transform.position + 1.5f * Vector3.up);  //SetIKPosition,设置反向动力学四肢的位置,AvatarIKGoal是个枚举值LeftFoot = 0, RightFoot = 1, LeftHand = 2,    RightHand = 3,设置这4个点的位置,然后由动画系统去计算反向的肢体结构的位置
        animator.SetIKPositionWeight(AvatarIKGoal.RightHand, 1f);
    }
    }

实现敌人的AI与团队智能: //chase 追踪 //patrol 巡逻

  • 状态机:

    public class State<T>
    {
    public virtual void Enter(T e)
    {
    
    }
    public virtual void Update(T e)
    {
    
    }
    
    public virtual void Exit(T e)
    {
    
    }
    }
    public class StateMachine<TOwner> : MonoBehaviour
    {
    private State<TOwner> curstate = null;
    private TOwner owner;
    
    public void Init(TOwner owner, State<TOwner> state)
    {
        this.owner = owner;
        ChangeState(state);
    }
    
    public void ChangeState(State<TOwner> state)
    {
        if (curstate != null)
        {
            curstate.Exit(this.owner);
        }
        curstate = state;
        curstate.Enter(this.owner);
    }
    
    // Update is called once per frame
    void Update()
    {
        curstate.Update(owner);
    }
    }
  • 敌人的AI状态:

    public class EnemyAI : StateMachine<EnemyAI>
    {
    public float patrolWaitTime;
    public float patrolSpeed;
    public float chaseWaitTime;
    public float chaseSpeed;
    public Transform[] wayPoints;
    
    private float patrolWaitTimer;
    private float chaseWaitTimer;
    private int wayPointIndex;
    private EnemySight enemySight;
    private NavMeshAgent navMeshAgent;
    
    class PatrolState : State<EnemyAI>
    {
        public override void Enter(EnemyAI e)
        {
            e.navMeshAgent.speed = e.patrolSpeed;
        }
    
        public override void Update(EnemyAI e)
        {
            //如果看见玩家进入射击状态
            if (e.enemySight.isPlayerInSight)
            {
                e.ChangeState(new ShotState());
                return;
            }
    
            // 如果听见玩家,或玩家被其他监事器发现,进入追踪状态
            if (e.enemySight.personalLastSighting != LastPlayerSighting.Instance.resetPosition || e.enemySight.isPlayerFoundOut)
            {
                e.ChangeState(new ChaseState());
                return;
            }
    
            //自己状态更新,路点模式
            if (e.navMeshAgent.remainingDistance < e.navMeshAgent.stoppingDistance)
            {
                e.patrolWaitTimer += Time.deltaTime;
    
                if (e.patrolWaitTimer >= e.patrolWaitTime)
                {
                    e.wayPointIndex = (e.wayPointIndex + 1) % e.wayPoints.Length;
                    e.patrolWaitTimer = 0;
                    e.navMeshAgent.destination = e.wayPoints[e.wayPointIndex].position;
                }
            }
        }
    
        public override void Exit(EnemyAI e)
        {
            e.patrolWaitTimer = 0;
        }
    }
    
    //追踪状态:
    class ChaseState : State<EnemyAI>
    {
        public override void Enter(EnemyAI e)
        {
            e.navMeshAgent.speed = e.chaseSpeed;
            e.navMeshAgent.SetDestination(LastPlayerSighting.Instance.position);
        }
    
        public override void Update(EnemyAI e)
        {
            if (e.enemySight.isPlayerInSight) //看到玩家进入射击状态
            {
                e.ChangeState(new ShotState());  
                return;
            }
    
            if (e.navMeshAgent.remainingDistance < e.navMeshAgent.stoppingDistance)
            {
                e.chaseWaitTimer += Time.deltaTime;
    
                if (e.chaseWaitTimer >= e.chaseWaitTime)
                {
                    e.ChangeState(new PatrolState());  //到了位置没发现玩家,重新开始巡逻
                }
            }
        }
    
        public override void Exit(EnemyAI e)
        {
            e.chaseWaitTimer = 0;
            if (!e.enemySight.isPlayerInSight && e.enemySight.personalLastSighting == LastPlayerSighting.Instance.resetPosition)
            {
                LastPlayerSighting.Instance.position = LastPlayerSighting.Instance.resetPosition;  //如果退出的时候还没有发现玩家,重置公共的position
            }
        }
    }
    
    class ShotState : State<EnemyAI>
    {
        public override void Enter(EnemyAI e)
        {
            e.navMeshAgent.destination = e.transform.position;  //因为NaveMeshAgent的组件我们几个脚本都在用,所以当开始射击玩家时,我们让destination为当前坐标,表示停止移动
        }
    
        public override void Update(EnemyAI e)
        {
            if (!e.enemySight.isPlayerInSight)  //射击状态下,如果玩家不在视线范围内后,切换状态
            {
                if (e.enemySight.personalLastSighting != LastPlayerSighting.Instance.resetPosition)
                    e.ChangeState(new ChaseState());
                else
                    e.ChangeState(new PatrolState());
            }
        }
    
        public override void Exit(EnemyAI e)
        {
    
        }
    }
    
    private void Start()
    {
        navMeshAgent = this.GetComponent<NavMeshAgent>();
        enemySight = this.GetComponent<EnemySight>();
        base.Init(this, new PatrolState());
    }
    }

1 对 “Stealth”的想法;

  1. Pingback: viagra

发表评论