Unity开发一个FPS游戏之三

news2025/3/10 5:51:39

在前面的两篇博客中,我已实现了一个FPS游戏的大部分功能,包括了第一人称的主角运动控制,武器射击以及敌人的智能行为。这里我将继续完善这个游戏,包括以下几个方面:

  1. 增加一个真实的游戏场景,模拟一个废弃的工厂环境
  2. 完善NPC的智能行为,增加巡逻,警戒,躲避,战术编队等行为。
  3. 增加子弹与场景物体交互的效果。
  4. 游戏道具的拾取。
  5. 完善UI界面,增加场景的任务描述,任务完成或失败的判定展示。

以下是完成后的演示。

mission_critical

游戏场景设计

这是一个很大的课题,暂时不是我关注的重点,为此我先直接采用Unity商店的一个免费的资源,RPG/FPS Game Assets for PC/Mobile (Industrial Set v2.0) | 3D 工业场景 | Unity Asset Store。

NPC智能行为

在之前的博客,已经实现了NPC的部分智能行为,包括了随机搜索以及跟踪玩家的行为。在这里,我将添加巡逻,警戒,躲藏、战术编队等行为。

巡逻警戒

首先是实现巡逻功能,这个可以通过定义巡逻路线经过的关键点,然后利用Unity自带的寻路功能来实现。为此,我们需要对场景的道路进行烘培,点击Window->AI->Navigation,然后选择Bake选项,对场景的可导航路线进行烘培。烘培完成后,需要给Enemy这个预制件增加一个NavMesh Agent的组件,然后在代码中就可以指定目标点的坐标,让Agent自己导航到目的地。

其次是增加警戒模式,这个模式比较简单,就是传入一个警戒点的坐标,然后敌人就在警戒点不定期的随机搜索四周。以下是对WanderingAI的代码进行修改,增加巡逻和警戒这两个行为:

[Header("Enemy behavior")]
...
public bool patrolBehavior = false;
public Vector3[] patrolPoints = null;
public bool guardBehavior = false;
public float guardAngle = 60f;
public float guardMaxRotatePeriod = 10.0f;
public float initialGuardAngle = 0f;

private long _guardTS = 0;
private float _guardChangeTime = 0;

[Flags]
private enum EnemyStatus {  
    ...
    Patrol,
    Guard
}

void Start()
{
    _animator = GetComponent<Animator>();
    muzzleEffect = GetComponent<MuzzleEffect>();
    _agent = GetComponent<NavMeshAgent>();
    currentAmmo = ammo;
    if (patrolBehavior && patrolPoints.Length >= 2) {
        status = EnemyStatus.Patrol;
        _agent.autoBraking = false;
        _agent.destination = patrolPoints[destPoint];
        transform.forward = patrolPoints[destPoint] - patrolPoints[0];
        _animator.SetTrigger("E_Walk");
    } else if (guardBehavior) {
        status = EnemyStatus.Guard;
        transform.localEulerAngles = new Vector3(0f, initialGuardAngle, 0f);
        _guardTS = DateTime.UtcNow.Ticks;
        _guardChangeTime = UnityEngine.Random.Range(1.0f, guardMaxRotatePeriod);
        _animator.SetTrigger("E_Guard");
    } else {
        status = EnemyStatus.Idle;
    }
}

// Update is called once per frame
void Update()
{        
    if (status == EnemyStatus.AimLeft || status == EnemyStatus.AimRight) {
        AimMove();
    } else if (status == EnemyStatus.Reload || status == EnemyStatus.Death) {
        return;
    } else if (status == EnemyStatus.Aim) {
        TurnToPlayer();
    } else if (status == EnemyStatus.Hide) {
        Hide();
    } else if (status == EnemyStatus.Damage) {
        TurnToDamage();
        if (!_animator.GetBool("E_IsDamage")) {
            status = EnemyStatus.Idle;
            prevPlayerPosition = new Vector3(100f, 100f, 100f);
        }
    } else {
        if (status == EnemyStatus.Walk) {
            Walk();
        }
        if (status == EnemyStatus.Sprint) {
            Sprint();
        }
        if (status == EnemyStatus.Patrol) {
            Patrol();
        }
        if (status == EnemyStatus.Guard) {
            Guard();
        }
        DetectPlayer();
    }
}

void Patrol() {
    if (!_agent.pathPending && _agent.remainingDistance < 0.5f) {
        Vector3 prevPosition = patrolPoints[destPoint];
        destPoint = (destPoint + 1) % patrolPoints.Length;
        _agent.destination = patrolPoints[destPoint];
        transform.forward = patrolPoints[destPoint] - prevPosition;
    }
}

public void SetPatrolPoints(Vector3[] points) {
    patrolPoints = points;
}

private void Guard() {
    float interval = (DateTime.UtcNow.Ticks - _guardTS)/10000000.0f;
    if (interval >= _guardChangeTime) {
        _guardTS = DateTime.UtcNow.Ticks;
        _guardChangeTime = UnityEngine.Random.Range(1.0f, guardMaxRotatePeriod);
        float rotateAngle = UnityEngine.Random.Range(-guardAngle, guardAngle);
        transform.localEulerAngles = new Vector3(0f, initialGuardAngle + rotateAngle, 0f);
    }
}

从以上代码可以看到,要设定敌人的行为是巡逻,需要传入巡逻点的坐标,然后依次把巡逻点的坐标设置为Agent的目的地即可,在巡逻的过程中,敌人同样需要检测玩家,如果发现玩家则改变为瞄准行为,这个和之前的随机搜索的行为切换是保持一致的。当行为是警戒时,将随机不定时的转动一些角度来探测玩家。

装弹躲藏

进一步改进敌人在交战时的行为,当敌人打完子弹时,现在的行为是站定了换子弹,为了提高难度,我们可以设计当敌人打完子弹,自动跑到一些隐藏点来重新装弹,之后再出来和玩家交战。为此我们需要设计一些隐藏点。我采取的方法是在场景中选取一些隐藏点,在每个地点放置一个小的正方体Game Object,然后在游戏装载场景时,读取这些Game Object的坐标,然后把这些Object都设置为SetActive(false)进行隐藏,把这些坐标传给敌人,然后当需要跑去隐藏点时,判断距离最近并且不被玩家发现的隐藏点并自动导航。代码改动如下:

private Vector3[] _hidePoints;

private enum EnemyStatus {  
    ...
    Hide
}

public void SetHidePoints(List<Vector3> points) {
    _hidePoints = points.ToArray();
}

private void Shoot() {
    if (currentAmmo==0) {
        Hide();
        return;
    }
    ...
}

private Vector3 FindNearestHidePoint() {
    GameObject player = GameObject.Find("Player");
    Vector3 playerPosition = player.transform.position;
    float distance = 0f;
    Vector3 selectedPosition = Vector3.up;
    RaycastHit info = new RaycastHit();
    int layermask = LayerMask.GetMask("Character", "Default");
    for (int i=0;i<_hidePoints.Length;i++) {
        Vector3 hidePosition = _hidePoints[i];
        float tempDistance = (transform.position - hidePosition).magnitude;
        if (tempDistance > eyeviewDistance) {
            continue;
        } else {
            float playerDistance = (playerPosition - hidePosition).magnitude;
            if (tempDistance > playerDistance) {
                continue;
            } else {
                Physics.Raycast(hidePosition+Vector3.up, playerPosition - hidePosition - Vector3.up, out info, eyeviewDistance, layermask);
                if (info.collider.name == "Player") {
                    continue;
                } else {
                    if (distance == 0 || tempDistance < distance) {
                        distance = tempDistance;
                        selectedPosition = hidePosition;
                    }
                }
            }
        }
    }
    return selectedPosition;
}

private void Hide() {
    if (status == EnemyStatus.Hide) {
        if (!_agent.pathPending && _agent.remainingDistance < 0.1f) {
            _agent.isStopped = true;
            if (currentAmmo == 0) {
                status = EnemyStatus.Reload;
                corReload = StartCoroutine(Reload());
            }
        } else {
            return;
        }
    } else {
        Vector3 hidePoint = FindNearestHidePoint();
        if (hidePoint != Vector3.up) {
            transform.forward = hidePoint - transform.position;
            _agent.isStopped = false;
            _agent.destination = hidePoint;
            _agent.speed = sprintSpeed;
            status = EnemyStatus.Hide;
            _animator.SetTrigger("E_Sprint");
        } else {
            status = EnemyStatus.Reload;
            corReload = StartCoroutine(Reload());
        }
    }
}

听觉识别

当射击开火时,可以设置在某个范围内的敌人会识别声音的方向,并跑到交火的位置附近进行增援。

修改WanderingAI的代码,增加一个方法

public void HearShoot(Vector3 position) {
    if (status == EnemyStatus.Walk || status == EnemyStatus.Idle || status == EnemyStatus.Patrol) {
        status = EnemyStatus.Sprint;
        _agent.destination = position;
        _agent.isStopped = false;
        _animator.SetTrigger("E_Sprint");
        _agent.updateRotation = true;
    }
}

然后当玩家开火时,我们可以检测在开火的某个范围内,是否有敌人存在,如果有,则调用敌人的WanderingAI的HearShoot方法,把玩家当前的坐标通知给敌人,如果敌人当前处在空闲,随机搜索或巡逻状态时,则会切换为快跑状态,快速跑到玩家位置进行攻击。

修改玩家的PlayerController的代码,在Shoot函数中增加以下部分:

[Header("Notify enemy")]
public float soundDistance = 30.0f;

private void Shoot() {
    if (_input.shoot && _currentBulletVolume>0) {
        if (!_animator.GetBool("IsReload")) {
            long nowTS = DateTime.UtcNow.Ticks;
            float shootInterval = (nowTS - _input.shootPressTS)/10000000.0f;
            
            if (shootInterval >= 0.08 || _input.firstShoot) {
                _animator.SetTrigger("Shoot");
                bullet = Instantiate(bulletPrefab, _muzzle.position, _muzzle.rotation);
                bullet.GetComponent<Rigidbody>().velocity = _muzzle.forward * 800.0f;
                casing = Instantiate(casingPrefab, _eject.position, _eject.rotation);
                muzzleEffect.Effect(_muzzle.position);
                _input.shootPressTS = nowTS;
                _input.firstShoot = false;
                _currentBulletVolume--;
                ExecuteEvents.Execute<IGameMessage>(_gameManager, null, (x,y)=>x.ArmoMessage(_currentBulletVolume));
                // Simulate the enemy can hear the shoot sound
                _spottedEnemies = Physics.OverlapSphere(transform.position, soundDistance, LayerMask.GetMask("Enemy"));
                for (int i=0;i<_spottedEnemies.Length;i++) {
                    WanderingAI behavior = _spottedEnemies[i].gameObject.GetComponent<WanderingAI>();
                    behavior.HearShoot(transform.position);
                }
            }
        }  
    }
}

战术组队

考虑最简单的一种三角形编队,即领队走在最前面,后面另外两个人和领队呈三角形分布。当发现玩家时,即采取包抄的战术。

对WanderingAI代码改动一下,增加和编队相关的几个属性:

[Header("Enemy Role")]
public bool formation = false;
public int formationType = 0;  // formation type 0: triangle
public int roleId = 0; // role 0: leader, 1: left follower, 2: right follower
public float followDistance = 5.0f;  // How far the follower behind the leader

private GameObject _leader;
private Vector3 _adjustFormationDistance;
private List<GameObject> _teamMembers = new List<GameObject>();

public void SetLeader(GameObject leader) {
    _leader = leader;
}

public void AddMember(GameObject member) {
    _teamMembers.Add(member);
}

如果设置了formation为true,那么即表示这个NPC处于编队状态,formationType控制不同的编队类型,当前只实现三角形编队,roleID为0表示这个NPC是领队,领队初始行为是随机搜索,其他roleID的NPC是队员,需要跟着领队,因此设置了一个followDistance来设置其跟随领队的距离。_adjustFormationDistance用于根据followDistance来生成一个Vector3向量,进行距离调整。如果NPC是领队,那么需要保存队员的GameObject到一个list中,如果NPC是队员,那么需要保存领队的GameObject,这样可以方便互相之间的通讯。

增加一个新的状态值,用于表示队员NPC是否处于跟随队长的状态:

[Flags]
private enum EnemyStatus {  
    ...
    FollowLeader
}

在Start函数中,对formation状态进行初始化设置

if (formation) {
    status = EnemyStatus.Walk;
    _animator.SetTrigger("E_Walk");
    _adjustFormationDistance = new Vector3(followDistance, 0f, 0f);
}

初始设置编队时,我们可以通过初始化NPC的坐标来保持三角形编队,但是当后续游戏进行时,如果队员遇到障碍物时,需要避让障碍物之后仍能保持队形。我采取一个简便的方法来解决这个问题,那就是让队员遇到障碍物时去到队长当前的位置,而不是随机转向,之后再重新调整队员位置,使得能维持队形。

重新修改Walk方法,并新增一个FollowLeader的方法,代码如下:

void Walk() {
    if (sprintTS != 0) {
        long nowTS = DateTime.UtcNow.Ticks;
        float interval = (nowTS - sprintTS)/10000000.0f;
        if (interval >= sprintSearchPeriod) {
            sprintTS = 0;
        }
    }

    
    float distance = DetectObstacle(transform.forward);
    if (distance < obstacleRange) {
        // For NPC, if not the leader, should not random rotate, need to follow to the current leader position.
        if (formation && roleId != 0 && _leader) {
            _agent.destination = _leader.transform.position;
            _agent.isStopped = false;
            status = EnemyStatus.FollowLeader;
            return;
        } else {
            float angle;
            if (sprintTS == 0) {
                angle = UnityEngine.Random.Range(-randomAngle, randomAngle);
            } else {
                angle = UnityEngine.Random.Range(-sprintSearchAngle, sprintSearchAngle);
            }
            transform.Rotate(0, angle, 0);
        }
    }

    
    if (formation && roleId != 0 && _leader) {
        float angle = Vector3.Angle(_leader.transform.forward, _leader.transform.position - transform.position);
        if (angle >= 30) {
            transform.forward = _leader.transform.forward;
        }
    }

    transform.Translate(0, 0, speed * Time.deltaTime);
}

void FollowLeader() {
    if (_agent.remainingDistance < 0.5f) {
        _agent.isStopped = true;
        randomSearch = true;
        _animator.SetTrigger("E_Walk");
        status = EnemyStatus.Walk;
        // Reformation
        Vector3 direction;
        if (roleId == 1) {
            direction = _leader.transform.position - _adjustFormationDistance - transform.position;
        } else {
            direction = _leader.transform.position + _adjustFormationDistance - transform.position;
        }
        transform.forward = direction;
    } else {
        FaceTarget();
    }
}

当队员遇到障碍时,设置状态为FollowLeader,并且通过NavmeshAgent自动导航到队长当前位置。当处于FollowLeader状态时,如果接近队长位置,则重新调整方向,设置状态为Walk,然后在Walk方法中判断队员与队长的方向的夹角和距离,如果满足条件则重新调整方向,使得重新维持队形不变。

最后就是考虑到当队长NPC挂掉时,要通知队员解散编队,为此定义一个Deformation方法

public void DeFormation() {
    foreach (GameObject _member in _teamMembers) {
        if (_member) {
            WanderingAI _behavior = _member.GetComponent<WanderingAI>();
            _behavior.formation = false;
            _behavior.roleId = 0;
        }
    }
}

考虑到当前游戏场景的位置比较狭小,不方便队员在发现玩家时进行包抄,所以暂时不添加更多的战术功能,只实现一个编队搜索。

子弹与场景物体的交互

为了使游戏更真实,我们可以让子弹在射击到水泥墙面或者金属物体时产生碰撞效果和留下弹痕,当射击到油罐之类的物体时,可以引爆油罐。

下面来实现子弹撞击物体的效果。

模拟灰尘溅射

首先是模拟子弹撞击水泥物体之后产生的灰尘溅射。在Asset的Texture目录下导入一张灰尘的图片,设置里面要勾选Alpha is Transparancy。例如下图:

在Asset的Material目录下,新建一个material文件,命名为M_Smoke。Shader选择Particles/Standard Surface,Rendering mode选择Fade。Main Option勾选Two Sided和Camera Fading。把刚才导入的材质贴图拖动到Maps的Albedo贴图中。这样就定义好材质了。

新建一个空的GameObject,命名为Impact Effect,保存为Prefab,然后进行编辑。

新增一个子物体,命名为Concrete Effect,添加一个Particle System的Component,具体设置如下:

模拟弹孔

在Asset的Texture目录导入一张弹孔的图片作为Texture,如下图:

在Asset的Material目录新建一个材质文件,命名为M_Impact_Concrete,shader选择Legacy Shaders/Particles/VertexLit Blended,然后把之前新建的Texture拖动到Particle Texture中。

在Impact Effect这个Prefab下新建一个子物体,命名为Bullet Hole,新建一个Particle System的Component,设置如下:

模拟水泥碎片

同样是找一个水泥碎片的图片导入到Asset的Texture目录中,如下图:

然后同样是新建一个材质,如之前弹孔的操作步骤。

在Impact Effect这个Prefab下新建一个子物体,命名为Concrete Debris,添加Particle system,配置如下:

 

代码实现

最后就是在Impact Effect这个Prefab上新增一个脚本代码,播放声音以及控制多长时间销毁,代码如下:

public class Impact : MonoBehaviour
{
    [Header("Impact Despawn Timer")]
	//How long before the impact is destroyed
	public float despawnTimer = 10.0f;

    [Header("Audio")]
	public AudioClip[] impactSounds;
	public AudioSource audioSource;

    // Start is called before the first frame update
    void Start()
    {
        StartCoroutine (DespawnTimer ());
        //Get a random impact sound from the array
		audioSource.clip = impactSounds
			[Random.Range(0, impactSounds.Length)];
		//Play the random impact sound
		audioSource.Play();
    }

    // Update is called once per frame
    void Update()
    {
        
    }

    private IEnumerator DespawnTimer() {
		//Wait for set amount of time
		yield return new WaitForSeconds (despawnTimer);
		//Destroy the impact gameobject
		Destroy (gameObject);
	}
}

编辑Bullet的脚本文件,增加以下代码:

...
[Header("Impact Effect Prefabs")]
public Transform []	concreteImpactPrefabs;

private void OnCollisionEnter(Collision collision) {
    ...
    if (collision.gameObject.CompareTag("Concrete")) 
    {
        //Instantiate random impact prefab from array
        Instantiate (concreteImpactPrefabs [0], transform.position, 
            Quaternion.LookRotation (collision.contacts [0].normal));
        //Destroy bullet object
        Destroy(gameObject);
    }
    ...
}

然后我们把场景中的水泥墙的预制件的Tag都设置为Concrete,这样当子弹发射碰撞到水泥墙时,就能看到相应的效果了。

实现爆炸效果

当子弹打中油桶之类的物体时,可以触发爆炸效果。

首先需要制作油桶爆炸后分成的碎块效果,可以通过Blender来实现。

把模型导入到Blender,然后安装Cell Fracture插件,选择物体->快速效果->Cell Fracture,调整Souce Limit的数值,这个表示物体破碎为多少块,这样插件就会自动进行随机的破碎,然后我们只保留破碎后的物体即可,导出为FBX格式。如下图所示:

把导出的模型添加到Assets->Models目录,然后拖动到场景,再拖动到Assets->Prefabs目录,保存为一个Prefab。需要注意,这个Prefab下的所有碎片子物体,需要全部添加Rigidbody组件,并且不要勾选Kinematic。如果我们希望破碎后保留这些碎片,那么还需要增加Mesh Collider组件并勾选Vertex,不然的话破碎之后时看不到这些碎片的。

为了实现爆炸的粒子效果,我们可以下载Unity Asset Store里面的Particle Pack插件,里面提供了很多效果的粒子系统实现,非常好用。

对于原始的未爆炸的油桶Prefab,为其新增一个Explode的Tag,并且添加一个名为Debris的脚本文件,代码如下:

public class Debris : MonoBehaviour
{
    public GameObject Explosive_debris;
    // Start is called before the first frame update
    void Start()
    {

    }

    // Update is called once per frame
    void Update()
    {
        
    }

    public void OnDestroy()
    {
        GameObject o = Instantiate(Explosive_debris, transform.position, transform.rotation);
        o.GetComponent<Explode>().Explodsion();
        Destroy(gameObject);
    }
}

然后把我们刚才制作的爆炸后的油桶Prefab拖动到这个Script的Explosive_debris中。

在新的爆炸后的油桶Prefab上新增一个名为Explode的脚本文件,代码如下:

public class Explode : MonoBehaviour
{
    public GameObject explodeEffect;
    public float force = 500f;
    public float radius = 5f;
    public float destroyAfter = 2f;
    // Start is called before the first frame update
    void Start()
    {
        
    }

    // Update is called once per frame
    void Update()
    {
        
    }

    public void Explodsion() {
        StartCoroutine(PlayExplodeEffect ());
        var rbs = GetComponentsInChildren<Rigidbody>();
        foreach(var rb in rbs) {
            rb.AddExplosionForce(1000f, transform.position, 10f);
        }
    }

    private IEnumerator PlayExplodeEffect () {
        GameObject o = Instantiate(explodeEffect, transform.position, transform.rotation);
        yield return new WaitForSeconds (destroyAfter);
        Destroy(o);
    }
}

 然后把Particle Pack的BigExplosion拖动到这个Script的explosionEffect中。

最后就是修改一下Bullet的脚本文件,代码如下:

private void OnCollisionEnter(Collision collision) {
    ...
    if (collision.gameObject.CompareTag("Explode")) {
        Debris dest = collision.gameObject.GetComponent<Debris>();
        if (dest != null) {
            dest.OnDestroy();
        }
    }
    Destroy(this.gameObject);
}

现在爆炸的效果就制作好了。 

游戏道具的拾取

为了增加游戏的趣味性,需要设计一些游戏道具,例如医药包,弹药等。

从网上找一个白色十字的3D模型作为医药包,导入到Assets->Models目录,然后保存为Prefab。

编辑这个Prefab,添加Rigidbody和Box Collider组件,其中Rigidbody需要勾选Is Kinematic,Box Collider需要勾选Is Trigger。

在Assets->Scripts目录新增一个Pickup的代码文件,代码如下:

[RequireComponent(typeof(Rigidbody), typeof(Collider))]
public class Pickup : MonoBehaviour
{
    [Tooltip("Frequency at which the item will move up and down")]
    public float VerticalBobFrequency = 1f;

    [Tooltip("Distance the item will move up and down")]
    public float BobbingAmount = 1f;

    [Tooltip("Rotation angle per second")] public float RotatingSpeed = 360f;

    [Tooltip("Sound played on pickup")] public AudioClip PickupSfx;
    [Tooltip("VFX spawned on pickup")] public GameObject PickupVfxPrefab;

    public Rigidbody PickupRigidbody { get; private set; }

    Collider m_Collider;
    Vector3 m_StartPosition;
    bool m_HasPlayedFeedback;

    protected virtual void Start()
    {
        PickupRigidbody = GetComponent<Rigidbody>();
        m_Collider = GetComponent<Collider>();

        // ensure the physics setup is a kinematic rigidbody trigger
        PickupRigidbody.isKinematic = true;
        m_Collider.isTrigger = true;

        // Remember start position for animation
        m_StartPosition = transform.position;
    }

    void Update()
    {
        // Handle bobbing
        float bobbingAnimationPhase = ((Mathf.Sin(Time.time * VerticalBobFrequency) * 0.5f) + 0.5f) * BobbingAmount;
        transform.position = m_StartPosition + Vector3.up * bobbingAnimationPhase;

        // Handle rotating
        transform.Rotate(Vector3.up, RotatingSpeed * Time.deltaTime, Space.Self);
    }

    void OnTriggerEnter(Collider other)
    {
        PlayerController pickingPlayer = other.GetComponent<PlayerController>();

        if (pickingPlayer != null)
        {
            OnPicked(pickingPlayer);
        }
    }

    protected virtual void OnPicked(PlayerController playerController)
    {
        PlayPickupFeedback();
    }

    public void PlayPickupFeedback()
    {
        if (m_HasPlayedFeedback)
            return;

        if (PickupSfx)
        {
            //AudioUtility.CreateSFX(PickupSfx, transform.position, AudioUtility.AudioGroups.Pickup, 0f);
        }

        if (PickupVfxPrefab)
        {
            var pickupVfxInstance = Instantiate(PickupVfxPrefab, transform.position, Quaternion.identity);
        }

        m_HasPlayedFeedback = true;
    }
}

这个代码定义了所有道具的基本Pickup行为

对于医药包道具的Pickup,除了基本行为之外,还有个特殊的行为就是给玩家加血,因此定义一个HealthPickup的代码,如下:

public class HealthPickup : Pickup
{
    [Header("Parameters")] [Tooltip("Amount of health to heal on pickup")]
    public int HealAmount = 2;

    protected override void OnPicked(PlayerController player) {
        player.Heal(HealAmount);
        Destroy(gameObject);
    }
}

把这个代码添加到医药包Prefab上。

当玩家触碰到医药包时,就会回调PlayerController脚本的Heal方法,因此添加一个Heal方法,代码如下:

public void Heal(int point) {
    if (_health+point >= PlayerHealth) {
        _health = PlayerHealth;
    } else {
        _health += point;
    }
    float healthValue = (float) _health/PlayerHealth;
    ExecuteEvents.Execute<IGameMessage>(_gameManager, null, (x,y)=>x.HealthMessage(healthValue));
}

之后我们可以实现一个木箱子的破碎效果,当木箱子被打破后,就会露出医药包。具体定义木箱子破碎的过程和之前的类似,只是破碎效果不要选择爆炸,而是DustExplodsion。

完善UI界面

任务简介

最后就是完善整个游戏的UI界面,在游戏一开始增加任务介绍,当任务完成或失败时展示相应的界面。

首先是增加任务简介,在GameScreen这个Prefab下新建一个Text TMP,命名为Task,然后输入任务简介。编辑GameLevelLoader脚本文件,增加代码如下:

...
private long _startTick = 0;
private GameObject _task;

void Start()
{
    ...
    _startTick = DateTime.UtcNow.Ticks;
    _task = GameObject.Find("GameScreen/Task");
}

void Update()
{
    long nowTS = DateTime.UtcNow.Ticks;
    float interval = (nowTS - _startTick)/10000000.0f;
    if (interval >= 10f) {
        if (_task) {
            _task.SetActive(false);
        }
    }
}

这个代码表示在游戏开始的头10秒显示这一关任务的简介。

任务完成或失败的场景显示

当这一关任务完成或失败时,应该显示对应的场景。

在Assets->Scenes的目录下,新建两个场景,分别命名为WinScene和LoseScene,每个场景都增加一个Canvas,然后在Canvas下新增一个背景图片,一个文字描述和一个按钮,如下图所示:

定义一个消息接口文件GameFlowMessage,用来传递设定场景任务以及消灭敌人等消息的传递,如以下代码:

public interface IGameFlowMessage : IEventSystemHandler
{
    void KillEnemyMessage();
    void FindTargetMessage();
    void SetObjectiveMessage(int killEnemyNumber, int findTargetNumber);
    void PlayerDeathMessage();    
}

 然后定义一个GameFlowController脚本文件,实现以上接口的方法,如以下代码:

public class GameFlowController : MonoBehaviour, IGameFlowMessage
{
    [Header("Win")] [Tooltip("This string has to be the name of the scene you want to load when winning")]
    public string WinSceneName = "WinScene";

    [Header("Lose")] [Tooltip("This string has to be the name of the scene you want to load when winning")]
    public string LoseSceneName = "LoseScene";

    private int _killEnemyNumber;
    private int _currentKillEnemy = 0;
    private int _findTargetNumber;
    private int _currentFindTarget = 0;

    public void KillEnemyMessage() {
        _currentKillEnemy++;
        Debug.LogFormat("Kill Enemy, current:{0}, target:{1}", _currentKillEnemy, _killEnemyNumber);
        if (_currentKillEnemy == _killEnemyNumber && _currentFindTarget == _findTargetNumber) {
            WinOrLose(true);
        }
    }

    public void FindTargetMessage() {
        _currentFindTarget++;
        if (_currentKillEnemy == _killEnemyNumber && _currentFindTarget == _findTargetNumber) {
            WinOrLose(true);
        }
    }

    public void SetObjectiveMessage(int killEnemyNumber, int findTargetNumber) {
        _killEnemyNumber = killEnemyNumber;
        _findTargetNumber = findTargetNumber;
    }

    public void PlayerDeathMessage() {
        WinOrLose(false);
    }

    public void WinOrLose(bool win) {
        Cursor.lockState = CursorLockMode.None;
        Cursor.visible = true;

        if (win) {
            SceneManager.LoadScene(WinSceneName);
        } else {
            SceneManager.LoadScene(LoseSceneName);
        }
    }
}

把这个Script加到GameLoader这个GameObject上面,然后编辑GameLevelLoader脚本,设定这一关任务,代码如下:

...
private GameFlowController _gameFlowController;

void Start()
{
    ...
    _gameFlowController = GetComponent<GameFlowController>();
    _gameFlowController.SetObjectiveMessage(9, 0);
}

修改NPC的WanderingAI代码,当NPC被消灭时,发送KillEnemyMessage,代码如下:

...
private GameObject _gameLoader;

void Start()
{
    ...
    _gameLoader = GameObject.Find("GameLoader");
}

private IEnumerator Death() {
    ...
    ExecuteEvents.Execute<IGameFlowMessage>(_gameLoader, null, (x,y)=>x.KillEnemyMessage());
}

总结

以上就是一个FPS游戏的制作过程,后续将继续完善玩家的换武器,以及关卡的设计。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1569525.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

[C#]OpenCvSharp利用MatchTemplate实现多目标匹配

【效果展示】 原图 模板图 匹配结果&#xff1a; 【实现部分代码】 using System; using System.Collections.Generic; using System.ComponentModel; using System.Data; using System.Drawing; using System.Linq; using System.Text; using System.Threading.Tasks; using…

Flutter仿Boss-4.短信验证码界面

效果 简述 在移动应用开发中&#xff0c;处理短信验证码是确保用户身份验证和安全性的重要步骤。本文将介绍如何使用Flutter构建一个短信验证码界面&#xff0c;让用户输入通过短信发送到他们手机的四位验证码。 依赖项 在这个项目中&#xff0c;我们将使用以下依赖项&#…

关于Tomcat双击startup.bat 闪退的解决⽅法

详解Tomcat双击startup.bat 闪退的解决⽅法 作为⼀个刚学习Tomcat的程序猿来说&#xff0c;这是会经常出现的错误。 1.环境变量问题 1.1 ⾸先需要确认java环境是否配置正确&#xff0c;jdk是否安装正确 winR打开cmd&#xff0c;输⼊java 或者 javac 出现下图所⽰就说明jdk配置正…

单元测试 mockito(二)

1.返回指定值 2.void返回值指定插桩 3.插桩的两种方式 when(obj.someMethod()).thenXxx():其中obj可以是mock对象 doXxx().wien(obj).someMethod():其中obj可以是mock/spy对象 spy对象在没有插桩时是调用真实方法的,写在when中会导致先执行一次原方法,达不到mock的目的&#x…

走进车厂 | 移远通信以前沿车载技术,照亮智能网联汽车产业创新发展之路

无钥匙自动解锁方便快捷、实时路况导航精准高效、语音指令轻松控制车辆、车载娱乐系统丰富多样……随着智能化、数字化浪潮的不断推进&#xff0c;现如今的汽车出行焕然一新。 正如我们所见&#xff0c;汽车产业正在经历前所未有的变革。物联网、车联网等前沿技术的发展和应用&…

GDAL源码剖析(九)之GDAL体系架构

GDAL源码剖析&#xff08;九&#xff09;之GDAL体系架构_gdal 源码-CSDN博客 在GDAL库中包含栅格数据的读写&#xff0c;矢量数据的读写&#xff0c;以及栅格和矢量数据的相关算法。下面主要对GDAL中栅格数据和矢量数据的体系架构做一个简单的说明。本人英文很烂&#xff0c;有…

力扣热题100_链表_21_合并两个有序链表

文章目录 题目链接解题思路解题代码 题目链接 21. 合并两个有序链表 将两个升序链表合并为一个新的 升序 链表并返回。新链表是通过拼接给定的两个链表的所有节点组成的。 示例 1&#xff1a; 输入&#xff1a;l1 [1,2,4], l2 [1,3,4] 输出&#xff1a;[1,1,2,3,4,4] 示例…

NIUSHOP完美运营版商城 虚拟商品全功能商城 全能商城小程序 智慧商城系统 全品类百货商城

完美运营版商城/拼团/团购/秒杀/积分/砍价/实物商品/虚拟商品等全功能商城 干干净净 没有一丝多余收据 还没过手其他站 还没乱七八走的广告和后门 后台可以自由拖曳修改前端UI页面 还支持虚拟商品自动发货等功能 挺不错的一套源码 前端UNIAPP 后端PHP 一键部署版本 源码免费…

【opencv】教程代码 —TrackingMotion 角点检测

角点检测执行角点检测并在可能的角点周围画一个圆对图像中的角点位置进行检测和细化Shi-Tomasi方法检测图像角点 1. cornerDetector_Demo.cpp 角点检测 /*** function cornerDetector_Demo.cpp* brief Demo code for detecting corners using OpenCV built-in functions* 使用 …

CSRF介绍及Python实现

CSRF 文章目录 CSRF1. CSRF是什么&#xff1f;2. CSRF可以做什么&#xff1f;3. CSRF漏洞现状4. CSRF的原理5. 举例说明6. CSRF的防御Python示例 1. CSRF是什么&#xff1f; CSRF&#xff08;Cross-Site Request Forgery&#xff09;&#xff0c;中文名称&#xff1a;跨站请求…

基于H2O AutoML与集成学习策略的房屋售价预测模型研究与实现

项目简述&#xff1a; 本项目采用H2O AutoML工具&#xff0c;针对加州房屋销售价格预测问题进行了深入研究与建模。项目以Kaggle提供的加州房屋 交易数据集为基础&#xff0c;通过数据清洗、特征工程、模型训练与评估等步骤&#xff0c;构建了一种基于集成学习策略的房价预测模…

最优控制理论笔记 - 01数学准备

目录 一、向量和矩阵的微分 1. 向量对标量的导数 2. 矩阵对标量的导数 2.1 矩阵对标量的导数的运算公式 2.2 标量函数对向量的导数&#xff1a; 2.3 向量函数对向量的导数 二、函数极值的问题 三、有约束条件的函数极值问题 四、n元函数的Taylor 一、向量和矩阵的微分 …

Redis 缓存雪崩、穿透、击穿、预热

在实际工程中&#xff0c;Redis 缓存问题常伴随高并发场景出现。例如&#xff0c;电商大促、活动报名、突发新闻时&#xff0c;由于缓存失效导致大量请求访问数据库&#xff0c;导致雪崩、击穿、穿透等问题。因此&#xff0c;新系统上线前需预热缓存&#xff0c;以应对高并发&a…

RabbitMQ3.x之九_Docker中安装RabbitMQ

RabbitMQ3.x之_Docker中安装RabbitMQ 文章目录 RabbitMQ3.x之_Docker中安装RabbitMQ1. 官网2. 安装1 .拉取镜像2. 运行容器 3. 访问 1. 官网 rabbitmq - Official Image | Docker Hub 2. 安装 1 .拉取镜像 docker pull rabbitmq:3.13.0-management2. 运行容器 # latest Rabb…

LLM端侧部署系列 | 如何将阿里千问大模型Qwen部署到手机上?实战演示(下篇)

引言 简介 编译Android可用的模型 转换权重 生成配置文件 模型编译 编译apk 修改配置文件 绑定android library 配置gradle 编译apk 手机上运行 安装 APK 植入模型 效果实测 0. 引言 清明时节雨纷纷&#xff0c;路上行人欲断魂。 小伙伴们好&#xff0c;我是《小…

iOS-App:App Store新的审核政策,在应用隐私清单中声明和解释使用特定API的原因

App Store新的审核政策&#xff0c;在应用隐私清单中声明和解释使用特定API的原因 设备/引擎&#xff1a;Mac&#xff08;11.6&#xff09;/Mac Mini 开发工具&#xff1a;终端 开发需求&#xff1a;苹果官方邮件通知&#xff0c; App Store新的审核政策&#xff0c;在应用隐…

Transformer学习: Transformer小模块学习--位置编码,多头自注意力,掩码矩阵

提示&#xff1a;文章写完后&#xff0c;目录可以自动生成&#xff0c;如何生成可参考右边的帮助文档 Transformer学习 1 位置编码模块1.1 PE代码1.2 测试PE1.3 原文代码 2 多头自注意力模块2.1 多头自注意力代码2.2 测试多头注意力 3 未来序列掩码矩阵3.1 代码3.2 测试掩码 1 …

绿联 安装halo博客,使用MySQL数据库

绿联 安装halo博客&#xff0c;使用MySQL数据库 1、镜像 halohub/halo:2 halo2系列已支持halohub/halo:2直接拉取最新版本&#xff0c;因此更新容器可以使用相同镜像重新编辑的方式进行升级。 安装前准备&#xff1a; 绿联 安装Mysql 5.7版本数据库 绿联 安装phpmyadmin管理M…

基本线段树以及相关例题

1.线段树的概念 线段树是一种二叉树&#xff0c;也就是对于一个线段&#xff0c;我们会用一个二叉树来表示。 这个其实就是一个线段树&#xff0c;我们会将其每次从中间分开&#xff0c;其左孩子就是左边的集合的和&#xff0c;其右孩子就是右边集合的和&#xff1b; 我们可以…

VSCode常用修改默认设置(settings.json)

❓ 问题1 我现在在vscode中鼠标选中某个单词&#xff0c;相同的单词都会自动出现一个高亮背景色&#xff0c;我需要怎么关闭这个功能呢&#xff1f; ⚠️ 注意 selectionHighlight 这个是鼠标双击后的高亮匹配&#xff0c;可以保留默认开启的配置&#xff0c;不用去改它。 …