【Unity3d学习】智能巡逻兵游戏——订阅与发布模式的应用

写在前面

游戏规则与要求

  1. 创建一个地图和若干巡逻兵(使用动画);
  2. 每个巡逻兵走一个3~5个边的凸多边型,位置数据是相对地址。即每次确定下一个目标位置,用自己当前位置为原点计算;
  3. 巡逻兵碰撞到障碍物,则会自动选下一个点为目标;
  4. 巡逻兵在设定范围内感知到玩家,会自动追击玩家;
  5. 失去玩家目标后,继续巡逻;
  6. 计分:玩家每次甩掉一个巡逻兵计一分,与巡逻兵碰撞游戏结束;

以上只是老师课程网站的基本要求,可是仅仅把躲避巡逻兵作为目标来获取高分未免太过单调了,所以我还添加了一点胜利的条件:

  1. 地图共分为9个单元(9宫格形式),玩家所在的初始格子为安全区,对角线上的格子为目标区。达到目标区为胜利。
  2. 目标区开始的时候封闭,只有触发条件才能开启缺口:
    条件就是需要获取场景内的3个红球,获取方式是触碰加鼠标点击。

游戏实现

订阅发布模式

首先是课上讲到的订阅发布模式(观察者模式)的应用:
在这里插入图片描述
首先是需要定义一个信息发布者,也就是类似于现在的公众号、报刊中心等等。
我的理解是它只是作为一个平台去发布消息,但是真正消息的拥有者或者说触发者是别的类。一旦消息被触发,他就会发出一个通知,所有订阅了消息的人都会收到通知,然后去执行相应的动作。
所以说这个发布者可以看作是一个被动的角色,所有需要发布消息的类都通过发布者来实现。举个例子,某粉丝关注了微博大V,相当于订阅了该事件,当大V发微博的时候就会通过这个微博平台发布,粉丝就可以获取信息。注意的是,微博平台并不会替大V编辑消息内容,它只负责传递,真正通信的其实是除了发布平台的双方。

在这里插入图片描述
处理逻辑方面,就是当平台发布了消息,那么订阅者就会采取相应的措施,比如这个游戏中,平台发布了游戏结束的信息,那么订阅者可能就会停止工作,等待下一轮游戏开始。

发布者代码:

public class GameEventManager : MonoBehaviour
{
    // 玩家逃脱事件
    public delegate void EscapeEvent(GameObject patrol);
    public static event EscapeEvent OnGoalLost;
    // 巡逻兵追击事件
    public delegate void FollowEvent(GameObject patrol);
    public static event FollowEvent OnFollowing;
    // 游戏失败事件
    public delegate void GameOverEvent();
    public static event GameOverEvent GameOver;
    // 游戏胜利事件
    public delegate void WinEvent();
    public static event WinEvent Win;

    // 玩家逃脱
    public void PlayerEscape(GameObject patrol) {
        if (OnGoalLost != null) {
            OnGoalLost(patrol);
        }
    }

    // 巡逻兵追击
    public void FollowPlayer(GameObject patrol) {
        if (OnFollowing != null) {
            OnFollowing(patrol);
        }
    }

    // 玩家被捕
    public void OnPlayerCatched() {
        if (GameOver != null) {
            GameOver();
        }
    }

    // 时间结束
    public void Finished() {
        if (Win != null) {
            Win();
        } 
    }
}

订阅者代码

这里订阅者主要是Controller:

public class FirstController : MonoBehaviour, SceneController, Interaction{
	....
	....
	void OnEnable() {
        GameEventManager.OnGoalLost += OnGoalLost;
        GameEventManager.OnFollowing += OnFollowing;
        GameEventManager.GameOver += GameOver;
        GameEventManager.Win += Win;
    }

    void OnDisable() {
        GameEventManager.OnGoalLost -= OnGoalLost;
        GameEventManager.OnFollowing -= OnFollowing;
        GameEventManager.GameOver -= GameOver;
        GameEventManager.Win -= Win;
    }

    public void OnGoalLost(GameObject patrol) {
        patrolActionManager.Patrol(patrol);
        judger.addScore();
    }

    public void OnFollowing(GameObject patrol) {
        patrolActionManager.Follow(player, patrol);
    }

    public void GameOver() {
        state = 0;
        StopAllCoroutines();
        patrolFactory.PausePatrol();
        player.GetComponent<Animator>().SetTrigger("death");
        patrolActionManager.DestroyAllActions();
    }

    public void Win() {
        state = 2;
        StopAllCoroutines();
        patrolFactory.PausePatrol();
        patrolActionManager.DestroyAllActions();
        
    }
}

这里定义了几种事件:游戏结束、玩家被捕、巡逻兵追击、玩家逃脱事件。每一种分别执行不同的行为,这些行为需要在订阅者中实现。通知者发出通知时,就会由订阅者执行相应的代码。
(注:其实很直观的(不正确)一个理解就是,通知者调用了订阅者的方法,在之前的游戏中我们都是直接由通知者指定调用某个类的方法,因为此时订阅者往往就是固定的一个类,但是订阅模式中,由于订阅者可能会很多,每个订阅者响应的事件以及对应的方法可能不同,会增加通知者的负担,但是如果在中间增加一层抽象,也就是说,通知者只管发布,不需要知道具体订阅者,订阅者接受通知并且执行方法,这样就能够减少耦合,使类的职责更加单一。)

巡逻兵设计

这里巡逻兵用了资源商店一个僵尸的模型:
在这里插入图片描述
僵尸模型上加了两个胶囊体碰撞盒(在模型的不同的部件上添加),一个主要是检测自身的物理碰撞(需要在主体上添加刚体属性),也就是实际碰撞的检测,另一个范围比较大的碰撞盒主要是Trigger检测,检测是否有玩家进入视野区域。
对于这两个碰撞器也需要分别加上碰撞检测处理的脚本:

物理碰撞:

public class PatrolCollision : MonoBehaviour
{
    public float time = 0;
    void OnCollisionEnter(Collision collision) {
    //     Debug.Log(collision.gameObject.name);
        if (collision.gameObject.tag == "Player") {
            this.GetComponent<Animator>().SetTrigger("attack");
            Singleton<GameEventManager>.Instance.OnPlayerCatched();
        } else {
            
            if (collision.gameObject.name != "Plane") {
                
                this.GetComponent<PatrolData>().onCollison = true;
            }
            if (collision.gameObject.name == "Zombie") {
                this.GetComponent<PatrolData>().withTeammate = true;
            }
        }
    }
    void OnCollisionStay(Collision collision) {
        
        if (collision.gameObject.name != "Plane") {
            time += Time.deltaTime;
            if (time > 1.5) {
                this.GetComponent<PatrolData>().onCollison = true;
                time = 0;
            }
        }
    }
}

这里用到的是onCollision的检测,也就是说使用了刚体本身的物理碰撞系统,会产生反弹的效果。当僵尸碰到非玩家的其他物体时,就会将自身属性设置为碰撞,然后会选择一些避障的措施。而如果触碰的是玩家,就会执行攻击这个动作,并且发出一个通知“玩家被捕”,游戏就会结束了。

触发碰撞检测:

public class SeePlayer : MonoBehaviour {
    public FirstController controller;
    void OnTriggerEnter(Collider collider) {
        controller = Director.getInstance().currentSceneController as FirstController;
        if (collider.gameObject.tag == "Player") {
            
            this.gameObject.transform.parent.GetComponent<PatrolData>().seePlayer = true;
            this.gameObject.transform.parent.GetComponent<PatrolData>().player = collider.gameObject;
        }
    }

    private void OnTriggerExit(Collider collider) {
        if (collider.gameObject.tag == "Player") {
            this.gameObject.transform.parent.GetComponent<Animator>().SetBool("track", false);
            this.gameObject.transform.parent.GetComponent<PatrolData>().seePlayer = false;
            this.gameObject.transform.parent.GetComponent<PatrolData>().player = null;
        }
    }
}

这里用的是OnTrigger的检测,需要在碰撞盒上勾选isTrigger的选项:
在这里插入图片描述
这样物体碰撞的时候就不会使用刚体的碰撞检测,也就是说虽然碰撞了,但是依旧会穿过物体,此时执行的是用户自己设定的逻辑。仅仅只是检测碰撞,并没有物理的碰撞处理。

僵尸巡逻行为:

public class PatrolAction : SSAction {
    private float x, z;
    private bool turn = false;
    private PatrolData info;

    public static PatrolAction GetAction(Vector3 pos) {
        PatrolAction action = CreateInstance<PatrolAction>();
        action.x = pos.x;
        action.z = pos.z;
        return action;
    }

    public override void Start() {
        info = this.gameObject.GetComponent<PatrolData>();
    }

    public override void Update(){
        if (Director.getInstance().currentSceneController.getState() == 1) {
            PatrolWalk();
            if (!info.tracking && info.seePlayer && info.patrolArea == info.playerArea) {
               this.destroy = true;
               this.enable = false;
               this.callBack.SSActionEvent(this);
               this.gameObject.GetComponent<PatrolData>().tracking = true;
               Singleton<GameEventManager>.Instance.FollowPlayer(this.gameObject); 
            }
        }
    }

    void PatrolWalk() {
        if (turn) {
            x = this.transform.position.x + Random.Range(-7f, 7f);
            z = this.transform.position.z + Random.Range(-7f, 7f);
            this.transform.LookAt(new Vector3(x, 0, z));
            this.gameObject.GetComponent<PatrolData>().onCollison = false;
            turn = false;
        }
        float distance = Vector3.Distance(transform.position, new Vector3(x, 0, z));

        if (this.gameObject.GetComponent<PatrolData>().onCollison) {
            float angle = Random.Range(175, 185);
            this.transform.Rotate(Vector3.up, angle);
            GameObject tmp = new GameObject();
            tmp.transform.position = this.transform.position;
            tmp.transform.rotation = this.transform.rotation;
            tmp.transform.Translate(0, 0, Random.Range(0.5f, 2f));
            x = tmp.transform.position.x;
            z = tmp.transform.position.z;
            this.transform.LookAt(new Vector3(x, 0, z));
            this.gameObject.GetComponent<PatrolData>().onCollison = false;
            Destroy(tmp);
        } else if (distance <= 0.1) {
            turn = true;
        } else {
            this.transform.Translate(0, 0, Time.deltaTime );
        }
    } 
}

巡逻行为则是按照游戏规则:

  • 每个巡逻兵走一个3~5个边的凸多边型,位置数据是相对地址。即每次确定下一个目标位置,用自己当前位置为原点计算;
  • 巡逻兵碰撞到障碍物,则会自动选下一个点为目标;
  • 巡逻兵在设定范围内感知到玩家,会自动追击玩家;
  • 失去玩家目标后,继续巡逻;

每次随机选择周围的一个点作为目标前进。

僵尸追击动作代码:

public class TrackingAction : SSAction
{
    private float speed = 2.5f;          // 跟随玩家的速度
    private GameObject player;           // 玩家
    private PatrolData info;             // 巡逻兵数据

    public static TrackingAction GetAction(GameObject player) {
        TrackingAction action = CreateInstance<TrackingAction>();
        action.player = player;
        return action;
    }

    public override void Start() {
        info = this.gameObject.GetComponent<PatrolData>();
        this.gameObject.GetComponent<Animator>().SetBool("track", true);
    }

    public override void Update() {
        FirstController controller = Director.getInstance().currentSceneController as FirstController;

        if (controller.getState() == 1) {

            transform.position = Vector3.MoveTowards(this.transform.position, player.transform.position, speed * Time.deltaTime);
            this.transform.LookAt(player.transform.position);
            
            if (info.tracking && (!(info.seePlayer && info.patrolArea == info.playerArea) || (info.onCollison && !info.withTeammate))) {
                this.destroy = true;    
                this.enable = false;
                this.callBack.SSActionEvent(this);
                this.gameObject.GetComponent<PatrolData>().tracking = false;
                this.gameObject.GetComponent<PatrolData>().withTeammate = false;
                Singleton<GameEventManager>.Instance.PlayerEscape(this.gameObject);
            }
        }
    }
}

每次都跟着玩家的位置进行移动,知道玩家走出范围,才恢复巡逻姿态。
巡逻与追击的不同之处在于:执行动画不同、速度不同、行走的方向不同(有无目的性)

动作管理器

就是根据不同的调用,执行不同的动作代码。

public class PatrolActionManager : SSActionManager, ISSActionCallback
{
    public PatrolAction patrol;
    public TrackingAction follow;

    // 巡逻
    public void Patrol(GameObject ptrl) {
        this.patrol = PatrolAction.GetAction(ptrl.transform.position);
        this.RunAction(ptrl, patrol, this);
    }

    // 追击
    public void Follow(GameObject player, GameObject patrol) {
        this.follow = TrackingAction.GetAction(player);
        this.RunAction(patrol, follow, this);
    }

    //停止所有动作
    public void DestroyAllActions() {
        DestroyAll();
    }

    public void SSActionEvent(SSAction source){ }
}

游戏场景设计

主要是用Cube围成一个九宫格的形状,中间使用了多个Cude进行分隔,作为墙壁。
在这里插入图片描述
另外还在每一个格子中添加了一个方形碰撞器区域,简单限制了僵尸的活动范围,不至于全图追着你乱跑。(但是这里有一个bug,就是僵尸在边边的时候偶尔会卡出去,到别的格子去)

区域碰撞检测代码:

public class AreaCollision : MonoBehaviour {
    public int areaNum = 0;
    public FirstController controller;
    private void Awake() {
        
    }
    void OnTriggerEnter(Collider collider) {
        controller = Director.getInstance().currentSceneController as FirstController;
        if (collider.gameObject.transform.parent != null && collider.gameObject.transform.parent.tag == "Player") {
            controller.playerArea = areaNum;
            
        }
    }
    private void OnTriggerStay(Collider collider)
    {
        if (areaNum == 9) {
            if (collider.gameObject.tag == "Player") {
                controller.playerArea = areaNum;  
                Singleton<GameEventManager>.Instance.Finished();
            }
            
        }
    }
    private void OnTriggerExit(Collider collider) {
        if (collider.gameObject.tag == "Patrol") {
            collider.gameObject.GetComponent<PatrolData>().onCollison = true;
            
        } else if (collider.gameObject.transform.parent != null && collider.gameObject.transform.parent.tag == "Player") {
            controller = Director.getInstance().currentSceneController as FirstController;
            controller.playerArea = 0;
        }
    }
}

当玩家进入某个区域就会标记号码,位于该区域的僵尸看到了玩家就会去追,别的区域则不会隔着墙追。当玩家进入目标的安全区中,就会获得游戏胜利,发出通知。
而僵尸本来就会在区域内,如果想要离开就会被标记为碰撞,执行向后转的避障行为,所以一定程度上限制了僵尸的活动区域。
另外在区域内会放置3个红色小球,作为触发“开门”的条件。

小球碰撞检测

public class BallCollision : MonoBehaviour {
    float time = 0;
    bool start = false;
    
    void OnCollisionEnter(Collision collision) {
        if (collision.gameObject.tag == "Player") {
            Debug.Log("Click to pick it!");
            time = 0; 
            start = false;
            // if (Input.GetButtonDown("Fire1")) {
            //     Destroy(this.gameObject);
            // }
        }
            
    }

    void OnCollisionStay(Collision collision) {
        if (collision.gameObject.tag == "Player") {
            if (start == true) time += Time.deltaTime;
            if (Input.GetButtonDown("Fire1")) {
                start = true;
            }
            if (start == true && time > 0.6) {
                time = 0;
                start = false;
                FirstController controller = Director.getInstance().currentSceneController as FirstController;
                controller.addBall();
                Destroy(this.gameObject);
            }
        }
    }
}

触碰着小球并且按下鼠标则视为拾取小球成功,这里做了一个延迟的检测,目的是让击打的动作动画能够播放完毕之后,小球才消失。

Controller与UI设计

Controller设计根据之前的类的说明,已经差不多描述完了,主要加载资源,然后让僵尸随即移动,并且检测玩家所在区域。还有控制玩家的移动。
玩家的移动是通过UI检测用户输入的键盘信息(WASD或者方向键)来移动人物,鼠标点击来执行击打的动作。
UI还有就是根据游戏状态来显示界面,比如游戏结束输出提示,胜利也同样如此。

public class FirstController : MonoBehaviour, SceneController, Interaction{

    public int playerArea;
    public PatrolActionManager patrolActionManager;
    Judger judger;
    public UI ui;
    public PatrolFactory patrolFactory;
    public GameObject player;
    private List<GameObject> patrols; 
    private GameObject moveWall;
    private int state = 0;
    bool flag = true;
    private int ballCount = 0;
    void Awake() {
        Director director = Director.getInstance();
        director.currentSceneController = this;
        judger = gameObject.AddComponent<Judger>();
        patrolFactory = gameObject.AddComponent<PatrolFactory>();
        playerArea = 5;
        patrolActionManager = gameObject.AddComponent<PatrolActionManager>();
        gameObject.AddComponent<GameEventManager>();
        ui = gameObject.AddComponent<UI>() as UI;
        
    }
    private void Start()
    {
        loadResources();
        patrolFactory.StartPatrol();
        for (int i = 0; i < patrols.Count; i++) {
            patrolActionManager.Patrol(patrols[i]);
        }
        moveWall = GameObject.FindGameObjectWithTag("move");
    }
    public void loadResources() {
        Instantiate(Resources.Load<GameObject>("Prefabs/Plane")).name = "Plane";
        player = Instantiate(Resources.Load("Prefabs/Player"), new Vector3(13, 0, 13), Quaternion.identity) as GameObject;
        player.name = "Player";
        Instantiate(Resources.Load<GameObject>("Prefabs/Ball"), new Vector3(8, 0.5f, -17), Quaternion.identity).name = "Ball";
        Instantiate(Resources.Load<GameObject>("Prefabs/Ball"), new Vector3(-18, 0.5f, 12), Quaternion.identity).name = "Ball";
        Instantiate(Resources.Load<GameObject>("Prefabs/Ball"), new Vector3(-4, 0.5f, -17), Quaternion.identity).name = "Ball";
        patrols = patrolFactory.getPatrols();
        Camera.main.GetComponent<CameraView>().follow = player;
    }
    void Update() {
        if (state != 1) return;
        if (ballCount == 3 && flag) {
            moveWall.transform.localPosition += new Vector3(0,0,-2);
            flag = false;
        }
        for (int i = 0; i < patrols.Count; i++) {
            patrols[i].GetComponent<PatrolData>().playerArea = playerArea;
        }
    }
    public int getState() {
        return state;
    }
    public void changeState(int a) {
        state = a;
    }

    public void PlayerPick() {
        player.GetComponent<Animator>().SetTrigger("attack");
    }
    public void movePlayer(Vector3 pos) {
        if (pos.x != 0 || pos.z != 0) {
            player.GetComponent<Animator>().SetBool("run", true);
        } else {
            player.GetComponent<Animator>().SetBool("run", false);
        }
        pos.x *= -2*Time.deltaTime;
        pos.z *= -2*Time.deltaTime;
        
        player.transform.Rotate(Vector3.up, -pos.x*50, Space.Self);
        // new Vector3(player.transform.localPosition.x + pos.x, player.transform.position.y, player.transform.localPosition.z + pos.z)
        // player.transform.LookAt();
        player.transform.Translate(-0.05f*pos.x, 0, -pos.z * 2);

    }

    public void addBall() {
        ballCount += 1;
    }
    public void reset() {
        SceneManager.LoadScene("Scenes/SampleScene");
    }
    public int GetScore() {
        return 1;
    }
}

玩家运动主要是通过检测键盘按下来判断移动的距离,这里前后主要是控制运动,左右是控制方向,也就是说光按左右会原地打转,需要配合前后才会运动起来。
实现也不难,只需要获取按下左右键对应的x,再将其乘以一个系数,作为旋转角度,然后使用rotate的方式旋转就好,而移动则是通过Translate的函数,默认是参照本地坐标系运动,所以只需要设置z坐标(本地坐标系的前进方向)运动即可。

镜头跟随设置

为了更好的获取玩家运动的画面,使用了镜头跟随的设置(这里参考了学长学姐的代码)。
由于玩家运动是通过本地坐标系运动,也就是说类似于第一人称运动,简单的坐标跟随是不行的,还需要连同坐标轴也一并跟随,实现镜头随着人转动的的视角,也就是类似吃鸡游戏的第三人称视角,人物的转动,也会使得镜头转动,区别于上帝视角(绝对坐标系运动)
所以不仅位置需要跟随,连角度也要跟随。

public class CameraView : MonoBehaviour
{
    public GameObject follow;
    public float smothing = 5f;
    Vector3 offset;
    Quaternion rotate = Quaternion.Euler(30, 0, 0);

    void Start()
    {
        offset = new Vector3(0, 3, -3);
        
    }

    void FixedUpdate()
    {
        // Vector3 target = follow.transform.localPosition + offset;
        Vector3 target = follow.transform.TransformPoint(offset);
        Vector3 tmp = rotate.eulerAngles + follow.transform.rotation.eulerAngles;
        Quaternion r = Quaternion.Euler(tmp);
        transform.position = Vector3.Lerp(transform.position, target, smothing * Time.deltaTime);
        transform.rotation = Quaternion.Lerp(transform.rotation, r, smothing/2 * Time.deltaTime);
    }
}

使用到了Lerp函数,直观来讲就是通过内插的方法使其出现从快到慢的一个变化过度,使得过度更加自然不生硬。
还有就是摄像头永远位于目标的后上方,但是目标的坐标轴是在变换的,所以需要用到本地坐标与绝对坐标的变换函数:TransformPoint,将摄像头与目标的相对位置转化为绝对位置,才能确定摄像头移动的位置。
至于旋转角度,则需要摄像机的x轴朝向与目标的x轴相同,(因为摄像机会有向下的朝向,所以另外两个坐标的角度不同)

最终效果

画面经过压缩,画质差,不代表实际效果。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

本次实验到此结束!

  • 3
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值