使用Cardboard SDK开发一款VR弹球游戏 —— 实现对手功能

如果你喜欢我们的教程,欢迎加入泰然网Unity交流群201505161
『泰然网』原创,转载请注明出处。

今天先来介绍一下本系列教程的作者 —— 沈庆阳,就是下面这货!

沈庆阳现在是一名大三学生,但是精通于使用U3D和UE,在学校期间就完成过多次项目外包,这个系列介绍居然还有清华大学出版社联系他。

下面给大家展示一下最近做的射击游戏截图:


上一节的学习中,我们基本上完成了这个游戏,可以实现基本的游戏核心玩法。但还缺少的一个主要环节就是你的对手。那么这一节我们将主要讲解如何实现一个简单的AI对手,和一些游戏后期的处理问题。

思考:对手和你有什么不同

这个问题很值得商榷,只有搞清对手和你的不同才能快速的通过已有的玩家来创建一个对手。

首先,对手和玩家的操控方式不同。玩家是通过用户的输入来驱动,而你的对手是通过编写的AI脚本,或是网络中的对手来控制的(以后确实有编写网络对战的意图)。

其次,由于一些设计上的问题,在这个应用场景中,我们的碰撞体的反射设定并没有做到设计的完美,造成需要对player的一些参数进行调整。

(由于这篇教程是边做边写,在上一篇的教程里就遇到了推翻以前的部分设计的问题,所以希望和大家交流一下。)

那么明白了以上的区别之后,我们便可以开始对手AI的设计了。

创建一个你的敌人

首先,我们在project面板中找到_Prefab文件夹下的player这个预设,将它复制一份(快捷键:Ctrl+D),并重命名为Enemy。将Enemy拖到场景中,放到合适的位置。设置完成之后,如下图

敌人的设置

为了保持Enemy的碰撞体的设置,我们将Enemy这个物体绕着Y轴旋转180°,即将它的Rotate设置为(0,180,0)。

选中Enemy物体,点击右上方检视面板中的Tag下拉列表,点击AddTag。

添加Tag

点击下方的“+号”新建一个Tag叫Enemy

给新的Tag重命名

建立完成之后,在阶层面板中再次选中Enemy,在Tag中选择Enemy这一项。

将Tag赋予你的Enemy物体

将Enemy上的Player Movement脚本删除。新建一个脚本EnemyMove.cs并赋予Enemy物体。

EnemyMove.cs
public class EnemyMove : MonoBehaviour {
    private GameObject ball;
    private float thisX;
    // Use this for initialization
    void Start () {
        ball = GameObject.FindGameObjectWithTag("Ball");
        thisX = this.transform.position.x;
    }
    // Update is called once per frame
    void Update () {
    }
    void FixedUpdate()
    {
        transform.position = new Vector3(ball.transform.position.x + thisX, transform.position.y, transform.position.z);
    }
}

点击Play测试一下。

测试画面

WOW,敌人好强!简直就是一面墙一样,完全没法战胜!而且当球飞出去的时候,敌人也跟着飞出去了啊。好吧,很明显这样的对手AI是十分不合理的。(这里根本就没有AI好吧)。既然这样,我们需要给敌人设置一个移动速度,这样他就不会飞一般地去接球了。

修改以上脚本,修复这两个问题。

EnemyMove.cs
public class EnemyMove : MonoBehaviour {
    public float speed=0.15f;
    
    private GameObject ball;
    private float thisX;
    // Use this for initialization
    void Start () {
        ball = GameObject.FindGameObjectWithTag("Ball");
        thisX = this.transform.position.x;
    }
    
    // Update is called once per frame
    void Update () {
    
    }
    
    void FixedUpdate()
    {
        if(ball.transform.position.x<5.4&&ball.transform.position.x>-5.4)//判断球在场地中
        {
            if(ball.transform.position.x-transform.position.x+thisX>0)//球在左边
            {
                transform.Translate(Vector3.left * speed);
            }
            else if(ball.transform.position.x - transform.position.x + thisX < 0)
            {
                transform.Translate(Vector3.right * speed);
            }
       }
    }
}

再次点击Play按钮运行测试,怎么样,我可以赢了啊!但是为什么对手的动作看起来那么别扭呢,一直在跟着球在运动。正常情况下玩游戏的话,我应该是将球击出去之后就回到中间去,这样当球回来的时候,击球才方便,这是一个策略。还有的就是预判,对球的轨迹进行预先的估计,估计球快要到你的位置的时候球在哪,然后事先去哪个位置。还有,当球的速度设置的比对手移动的速度要小的时候,对手就成了无敌的了?

以上的问题,就不一一解决了,这里先提出一个思路。

首先是解决一直跟着球走的问题,这个问题可以使用Unity的Broadcast系统,在每次玩家击球和对手击球的时候使用Collider上的脚本Send一条消息,然后AI再根据这条消息判断是谁的击球,如果是对手(Enemy物体)的击球的话,Enemy物体回到平台中间(相对的只移动X的坐标),在玩家击球之后,Enemy的AI再进行相应的判断。

关于预判的问题,可以在击球的一瞬间从球沿着球的移动方向使用Raycast来发射一条射线,当射线碰撞到Edge的时候,在碰撞点沿着反射角再发射一条射线,直到射线Hit到Enemy一侧的Collider为止(这个Collider跟我们一开始设置的那面墙很相似)。至此,我们的AI可以完美的预判球将要到的位置了,但这样又和开挂有什么区别了呢?人用的是肉眼和经验来预判碰撞位置,而AI则是直接通过游戏引擎来获取碰撞位置,这绝对是作弊!所以,我们要开始弱化AI。通过给碰撞点加一个随机数范围,如(-10到10)来表示判断的误差,然后再在判断的误差上面加上失误的误差如(-5到5)来调整游戏的平衡。

如果读者们有兴趣来实现这些的话,完全可以动手一试,听起来不是很复杂,不是么?

判断输赢的DeadZone

莫名其妙地球

每次看到球就这样地飞向那遥远的天边,是否有种莫名其妙地伤感呢。对啊,这是因为我们还没有写输赢的判定!按照以往的设计,玩家的这边和对手的那边都应该加上两个碰撞体来判断球是否越过了平台。于是依照这个实战的第一个教程,我们开始写输赢的判定。

首先,在玩家的一方和对手的一方分别建立两个Cube,并拉伸到覆盖住球后方的位置,确保当球飞过了平台之后一定会进入Cube所围成的Trigger区域。

Trigger区域即DeadZone

将上面的两个Cube分别重命名为PlayerDeadZone和EnemyDeadZone。

新建一个脚本DeadZone.cs

public class DeadZone : MonoBehaviour {
    public string type;
    GameObject gm;
    GameStatus gs;
    // Use this for initialization
    void Start () {
        gm = GameObject.FindGameObjectWithTag("GameManager");
        gs = gm.GetComponent<GameStatus>();
    }

    void OnTriggerEnter(Collider other)
    {
        if (other.tag == "Ball")
        {
            if (type == "Player")
            {
                gs.setGameStatus("lose");
                Debug.Log("You lose");
            }
            else if (type == "Enemy")
            {
                gs.setGameStatus("win");
                Debug.Log("You win");
            }
        }
    }
}

这里的DeadZone主要是用于判断输赢,具体的对于输赢之后的处理要交给GameManager来做。这样做虽然有些麻烦,但是条理清晰,以后要在项目中进行升级也方便。

这里我们选用Debug.Log(String)的方式来进行测试,结果出乎意料。没想到球碰到玩家后方的DeadZone的时候球停止不动了。于是回到Ball上面的脚本,发现以前写过这样的几句话。

if(gs.getGameStatus()!="win" && gs.getGameStatus() != "lose")

当游戏不是Win或Lose的状态的时候才会对球进行移动。哈,这就是编写GameManager的好处嘛。

在画布上画东西(UI的制作)

由于在游戏中我们总不能让玩家去查看控制台的信息来知道自己的输赢,我们要把输赢的信息在屏幕上显示出来,这样,就需要制作一个UI界面。

在Unity3D 4.6之后更新的UGUI系统里面,使用的是Canvas系统。UI中的所有内容都存在于一个Canvas上。这样一来

![Uploading 8_953659.png . . .],UI的布局十分方便。

首先,我们创建一个Canvas。在菜单栏选中GameObject->UI->Canvas。

新建一个画布

这时,场景中会多出一个Canvas的物体。我们选中Canvas,再用同样的方法在其上添加一个UI->Text的子物体。并且给Canvas加上一个名叫Canvas的Tag。
点击Play按钮进行测试。

文字跑哪去了

我的天哪!文字在左下角!而且只有一个镜头有!等等,这样可不行。我们做的是VR游戏,要做到左右两个镜头都能显示,而且,还得有视觉差!
别急,因为UGUI默认的UI使用的是Screen Space Overlay,参照的是整个屏幕,既然如此,肯定不会参照两个相机的。我们选中Canvas物体,在检视面板中点击Screen Space Overlay将其更改为World Space。

更改画布的渲染模式

World Space,顾名思义就是以世界为参考,这样的话这个Canvas画布就是一个名副其实的物体了。可以对它像操作一个Cube一样进行操作。那么下面我们就调整Canvas的Scale和位置来将其移动到Cardboard摄像机前正确的位置。

再次测试一下。

再次测试

完美!但是文字的背景好像有点白。文字设置成黑色的,下面的看不见,白色的上面的看不清,灰色的上下都看不清,尴尬。好吧,下面我们选中Text物体,在他的检视面板中搜索Outline这个组件,并添加。

添加一个描边

添加之后还可以同理添加Shadow这个组件。

添加阴影之后的效果

看起来应该是好多了。可是每次一开局都看到You Win,玩起来倒是挺不好意思的。
另外由于我们在一开始不想没时间将手机放入Cardboard中而导致球早早地飞了起来,所以要增加一个按钮来作为开始的按钮。所以,我们再次回到Canvas物体上,右键点击Canvas物体,选择UI->Button

添加按钮

将Button重命名为Start,并调整它在画布上的大小至合适。并修改其Tag为Button。

修改按钮的参数

选择Canvas中Button的子物体Text,并修改其Tag为“Text”。

找到这个Text

修改Text的Text为“Start!”

将按钮的Text改为Start!

给Button新建一个名为StartGame的脚本

Button上的脚本

现在进行测试,所以脚本的内容如下:

public class StartGame : MonoBehaviour {

    // Use this for initialization
    void Start () {

    }

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

    }

    public void onButtonPress()
    {
        Debug.Log("WOW!You Press ME With The Force!");
    }
}

回到Unity3D的窗口中,在阶层面板选中Button,查看检视面板,在Button的Button组件下找到On Click()这个域,点击下面的那个中间有点点的一个圈。

添加响应事件

选择Button物体

选择对应的物体

点击NoFunction的下拉列表,选择我们刚写的OnButtonPress()函数

选择要响应的函数

在阶层面板中重新选中Canvas,找到EventCamera这一项,点击右方的选择按钮,在场景中选择我们的MainCamera。
这时你的场景中应该自动新建了一个EventSystem的物体,你可以在阶层面板中找到。如果没有也不要紧,可以自己新建一个嘛。新建的方法是新建一个空物体,添加EventSystem的组件即可。
在阶层面板中找到Event System物体。添加Gaze Input Module组件。确保他的排序是在Touch Input Module之上。

添加凝视的事件系统

测试之前先将你的Quad设置为Disable,就是选中Quad物体,在检视面板中将Quad这个单词左边的那个钩钩去掉。
点击Play,当你的视线对准Start!按钮的时候是不是有神奇的事情发生呢?

迷之Log

“WOW!你刚刚用原力按了我!”

备注:这里也许由于Cardboard的SDK的一些问题或者是我本地的问题,导致Gaze的Input不能生效,只能点击屏幕来按按钮。但当视线不对准按钮的时候,在屏幕上点击按钮无效,视线对准按钮的时候点击屏幕才有效。

开始游戏

做了这么多的工作,还是没有完成开始游戏的这个功能。那么下面我们就着手写开始游戏的这个重要的脚本吧。

public class StartGame : MonoBehaviour {
    public float waitTime = 10f;
    GameObject gm, quad, canvas,text;
    GameStatus gs;
    Text tx;
    private bool start;
    float time = 0f;
    // Use this for initialization
    void Start () {
        gm = GameObject.FindGameObjectWithTag("GameManager");
        gs = gm.GetComponent<GameStatus>();
        quad = GameObject.FindGameObjectWithTag("Quad");
        canvas = GameObject.FindGameObjectWithTag("Canvas");
        text = GameObject.FindGameObjectWithTag("Text");
        tx = text.GetComponent<Text>();
        time = 0f;

        start = false;
    }

    void Awake()
    {
        start = false;
        time = 0f;
    }

    void Update () {
        if(start)
        {
            time += Time.deltaTime;
            tx.text = (((int)(waitTime - time)).ToString());
            if(time > waitTime)
            {
                gs.setGameStatus("begin");
                canvas.SetActive(false);
                start = false;
            }
        }

    }

    public void onButtonPress()
    {
        start = true;
    }
}

修改GameStatus.cs脚本

public class GameStatus : MonoBehaviour {
    string gameStatus;
    GameObject canvas, text;

    public string getGameStatus()
    {
        return gameStatus;
    }

    public void setGameStatus(string gs)
    {
        gameStatus = gs;
    }
    // Use this for initialization
    void Start () {
        gameStatus = "wait";

        canvas = GameObject.FindGameObjectWithTag("Canvas");
        text = GameObject.FindGameObjectWithTag("Text");
    }

    void FixedUpdate()
    {
        if(gameStatus=="win")
        {
            canvas.SetActive(true);
            text.GetComponent<Text>().text= "You Win!Play Again!";
        }
        else if(gameStatus=="lose")
        {
            canvas.SetActive(true);
            text.GetComponent<Text>().text = "You Lose!Play Again!";
        }
    }
}

修改BallMove.cs脚本

public class ballMove : MonoBehaviour {
    Vector3 direction;
    GameObject gm;
    GameStatus gs;
    public float speed = 0.1f;
    Vector3 originPos;
    //用于设置球移动的方向
    public void setDirection(Vector3 dir)
    {
        direction = dir;
    }
    //外部用于获取球移动的方向
    public Vector3 getDirection()
    {
        return this.direction;
    }
    //为了节约性能 不适用Update函数而使用FixedUpdate函数
    void FixedUpdate()
    {
        if(gs.getGameStatus()!="win" && gs.getGameStatus() != "lose")
        {
            if(gs.getGameStatus() == "begin")
            {
                transform.Translate(direction * speed);
            }   
        }
        if (gs.getGameStatus() == "win" || gs.getGameStatus() == "lose")
        {
            this.transform.position = originPos;
        }
        //Debug.Log(direction);
    }
    // Use this for initialization
    void Start () {
        gm = GameObject.FindGameObjectWithTag("GameManager");
        gs = gm.GetComponent<GameStatus>();

        originPos = this.transform.position;

        directionVector dv=new directionVector();
        direction =dv.returnVector(250,1);
    }
}

开始点击Play测试吧!
首先,点击Start,下方会倒计时。

倒计时

当玩家赢了和输了的时候,球会自动回到中央并提醒重新开始。

重新开始

至此,我们已经完成了这个游戏的所有脚本功能。后面可以根据文章中提出的问题进行一些优化和发挥等。
下面的几节,我们将对VR游戏开发中的UI、输入方式和优化等进行讲解。

标签: unity, vr

?>