学习Unity(5)小游戏实例——牧师与魔鬼

游戏规则:

  • 你要运用智慧帮助3个牧师(方块)和3个魔鬼(圆球)渡河。
  • 船最多可以载2名游戏角色。
  • 船上有游戏角色时,你才可以点击这个船,让船移动到对岸。
  • 当有一侧岸的魔鬼数多余牧师数时(包括船上的魔鬼和牧师),魔鬼就会失去控制,吃掉牧师(如果这一侧没有牧师则不会失败),游戏失败。
  • 当所有游戏角色都上到对岸时,游戏胜利。

项目资源

https://github.com/csr632/Priests-and-devils

游戏截图:

开始游戏
游戏失败
游戏胜利

在Unity中体验

从Github中下载我的项目
将我的Asserts文件夹覆盖你的Unity项目中的Asserts文件夹。在你的Assets窗口中双击“ass”,然后就可以点击运行按钮了!


游戏架构

使用了MVC架构。

  • 场景中的所有GameObject就是Model,它们受到Controller的控制,比如说牧师和魔鬼受到MyCharacterController类的控制,船受到BoatController类的控制,河岸受到CoastController类的控制。
  • View就是UserGUI和ClickGUI,它们展示游戏结果,并提供用户交互的渠道(点击物体和按钮)。
  • Controller:除了刚才说的MyCharacterController、BoatController、CoastController以外,还有更高一层的Controller:FirstController(场景控制器),FirstController控制着这个场景中的所有对象,包括其加载、通信、用户输入。
    最高层的Controller是Director类,一个游戏中只能有一个实例,它控制着场景的创建、切换、销毁、游戏暂停、游戏退出等等最高层次的功能。

Director

Director的定义:

public class Director : System.Object {
    private static Director _instance;
    public SceneController currentSceneController { get; set; }

    public static Director getInstance() {
        if (_instance == null) {
            _instance = new Director ();
        }
        return _instance;
    }
}

Director是最高层的控制器,运行游戏时始终只有一个实例,它掌控着场景的加载、切换等,也可以控制游戏暂停、结束等等。

虽然Director控制着场景,但是它并不控制场景中的具体对象,控制场景对象的任务交给了SceneController(场景控制器),我们等一下会谈到。

Director类使用了单例模式。第一次调用Director.getInstance()时,会创建一个新的Director对象,保存在_instance,此后每次调用getInstance,都回返回_instance。也就是说Director最多只有一个实例。这样,我们在任何Script中的任何地方通过Director.getInstance()都能得到同一个Director对象,也就可以获得同一个currentSceneController,这样我们就可以轻易实现类与类之间的通信,比如说我在其他控制器中就可以使用Director.getInstance().somethingHappen()来告诉导演某一件事情发生了,导演就可以在somethingHappen()方法中做出对应的反应。


SceneController接口

SceneController接口定义:

public interface SceneController {
    void loadResources ();
}

interface(接口)不能直接用来创建对象!必须先有一个类实现(继承)它,在我的这个游戏中就是FirstController类。
SceneController 是用来干什么的呢?它是导演控制场景控制器的渠道。在上面的Director 类中,currentSceneController (FirstController类)就是SceneController的实现,所以Director可以调用SceneController接口中的方法,来实现对场景的生杀予夺。

在这个游戏中SceneController的定义非常简单,因为这个游戏做得并不完整。我们刚才说过导演可以加载、切换、销毁场景、暂停游戏,所以SceneController 还可以规定void switchScene()void destroyScene()void pause()这些方法,供给导演来调用。


Moveable

Moveable是一个可以挂载在GameObject上的类:

public class Moveable: MonoBehaviour {
    
    readonly float move_speed = 20;

    // change frequently
    int moving_status;  // 0->not moving, 1->moving to middle, 2->moving to dest
    Vector3 dest;
    Vector3 middle;

    void Update() {
        if (moving_status == 1) {
            transform.position = Vector3.MoveTowards (transform.position, middle, move_speed * Time.deltaTime);
            if (transform.position == middle) {
                moving_status = 2;
            }
        } else if (moving_status == 2) {
            transform.position = Vector3.MoveTowards (transform.position, dest, move_speed * Time.deltaTime);
            if (transform.position == dest) {
                moving_status = 0;
            }
        }
    }
    public void setDestination(Vector3 _dest) {
        dest = _dest;
        middle = _dest;
        if (_dest.y == transform.position.y) {  // boat moving
            moving_status = 2;
        }
        else if (_dest.y < transform.position.y) {  // character from coast to boat
            middle.y = transform.position.y;
        } else {                                // character from boat to coast
            middle.x = transform.position.x;
        }
        moving_status = 1;
    }

    public void reset() {
        moving_status = 0;
    }
}

GameObject挂载上Moveable以后,Controller就可以通过setDestination()方法轻松地让GameObject移动起来。

在这里我没有让物体直接移动到目的地dest,因为那样可能会直接穿过河岸物体。我用middle来保存一个中间位置,让物体先移动到middle,再移动到dest,这就实现了一个折线的移动,不会穿越河岸。moving_status记录着目前该物体处于哪种移动状态。


MyCharacterController

MyCharacterController封装了一个GameObject,表示游戏角色(牧师或恶魔)。

public class MyCharacterController {
    readonly GameObject character;
    readonly Moveable moveableScript;
    readonly ClickGUI clickGUI;
    readonly int characterType; // 0->priest, 1->devil

    // change frequently
    bool _isOnBoat;
    CoastController coastController;


    public MyCharacterController(string which_character) {
        
        if (which_character == "priest") {
            character = Object.Instantiate (Resources.Load ("Perfabs/Priest", typeof(GameObject)), Vector3.zero, Quaternion.identity, null) as GameObject;
            characterType = 0;
        } else {
            character = Object.Instantiate (Resources.Load ("Perfabs/Devil", typeof(GameObject)), Vector3.zero, Quaternion.identity, null) as GameObject;
            characterType = 1;
        }
        moveableScript = character.AddComponent (typeof(Moveable)) as Moveable;

        clickGUI = character.AddComponent (typeof(ClickGUI)) as ClickGUI;
        clickGUI.setController (this);
    }

    public void setName(string name) {
        character.name = name;
    }

    public void setPosition(Vector3 pos) {
        character.transform.position = pos;
    }

    public void moveToPosition(Vector3 destination) {
        moveableScript.setDestination(destination);
    }

    public int getType() {  // 0->priest, 1->devil
        return characterType;
    }

    public string getName() {
        return character.name;
    }

    public void getOnBoat(BoatController boatCtrl) {
        coastController = null;
        character.transform.parent = boatCtrl.getGameobj().transform;
        _isOnBoat = true;
    }

    public void getOnCoast(CoastController coastCtrl) {
        coastController = coastCtrl;
        character.transform.parent = null;
        _isOnBoat = false;
    }

    public bool isOnBoat() {
        return _isOnBoat;
    }

    public CoastController getCoastController() {
        return coastController;
    }

    public void reset() {
        moveableScript.reset ();
        coastController = (Director.getInstance ().currentSceneController as FirstController).fromCoast;
        getOnCoast (coastController);
        setPosition (coastController.getEmptyPosition ());
        coastController.getOnCoast (this);
    }
}

在构造函数中实例化了一个perfab,创建GameObject,因此我们每new MyCharacterController()一次,场景中就会多一个游戏角色。
构造函数还将clickGUI挂载到了这个角色上,以监测“鼠标点击角色”的事件。

MyCharacterController还定义了一些方法提供给场景控制器来调用,方法名已经能够表明这个方法是做什么的了。


BoatController和CoastController

BoatController和CoastController也类似MyCharacterController,封装了船GameObject和河岸GameObject。实现这两个类的难度主要在于它们是一种“容器”,游戏角色要进入它们的空位中。因此它们要提供getEmptyPosition()方法,给出自己的空位,让游戏角色能够移动到合适的位置。

/*-----------------------------------CoastController------------------------------------------*/
public class CoastController {
    readonly GameObject coast;
    readonly Vector3 from_pos = new Vector3(9,1,0);
    readonly Vector3 to_pos = new Vector3(-9,1,0);
    readonly Vector3[] positions;
    readonly int to_or_from;    // to->-1, from->1

    // change frequently
    MyCharacterController[] passengerPlaner;

    public CoastController(string _to_or_from) {
        positions = new Vector3[] {new Vector3(6.5F,2.25F,0), new Vector3(7.5F,2.25F,0), new Vector3(8.5F,2.25F,0), 
            new Vector3(9.5F,2.25F,0), new Vector3(10.5F,2.25F,0), new Vector3(11.5F,2.25F,0)};

        passengerPlaner = new MyCharacterController[6];

        if (_to_or_from == "from") {
            coast = Object.Instantiate (Resources.Load ("Perfabs/Stone", typeof(GameObject)), from_pos, Quaternion.identity, null) as GameObject;
            coast.name = "from";
            to_or_from = 1;
        } else {
            coast = Object.Instantiate (Resources.Load ("Perfabs/Stone", typeof(GameObject)), to_pos, Quaternion.identity, null) as GameObject;
            coast.name = "to";
            to_or_from = -1;
        }
    }

    public int getEmptyIndex() {
        for (int i = 0; i < passengerPlaner.Length; i++) {
            if (passengerPlaner [i] == null) {
                return i;
            }
        }
        return -1;
    }

    public Vector3 getEmptyPosition() {
        Vector3 pos = positions [getEmptyIndex ()];
        pos.x *= to_or_from;
        return pos;
    }

    public void getOnCoast(MyCharacterController characterCtrl) {
        int index = getEmptyIndex ();
        passengerPlaner [index] = characterCtrl;
    }

    public MyCharacterController getOffCoast(string passenger_name) {   // 0->priest, 1->devil
        for (int i = 0; i < passengerPlaner.Length; i++) {
            if (passengerPlaner [i] != null && passengerPlaner [i].getName () == passenger_name) {
                MyCharacterController charactorCtrl = passengerPlaner [i];
                passengerPlaner [i] = null;
                return charactorCtrl;
            }
        }
        Debug.Log ("cant find passenger on coast: " + passenger_name);
        return null;
    }

    public int get_to_or_from() {
        return to_or_from;
    }

    public int[] getCharacterNum() {
        int[] count = {0, 0};
        for (int i = 0; i < passengerPlaner.Length; i++) {
            if (passengerPlaner [i] == null)
                continue;
            if (passengerPlaner [i].getType () == 0) {  // 0->priest, 1->devil
                count[0]++;
            } else {
                count[1]++;
            }
        }
        return count;
    }

    public void reset() {
        passengerPlaner = new MyCharacterController[6];
    }
}

/*-----------------------------------BoatController------------------------------------------*/
public class BoatController {
    readonly GameObject boat;
    readonly Moveable moveableScript;
    readonly ClickGUI clickGUI;
    readonly Vector3 fromPosition = new Vector3 (5, 1, 0);
    readonly Vector3 toPosition = new Vector3 (-5, 1, 0);
    readonly Vector3[] from_positions;
    readonly Vector3[] to_positions;

    // change frequently
    int to_or_from; // to->-1; from->1
    MyCharacterController[] passenger = new MyCharacterController[2];

    public BoatController() {
        to_or_from = 1;

        from_positions = new Vector3[] { new Vector3 (4.5F, 1.5F, 0), new Vector3 (5.5F, 1.5F, 0) };
        to_positions = new Vector3[] { new Vector3 (-5.5F, 1.5F, 0), new Vector3 (-4.5F, 1.5F, 0) };

        boat = Object.Instantiate (Resources.Load ("Perfabs/Boat", typeof(GameObject)), fromPosition, Quaternion.identity, null) as GameObject;
        boat.name = "boat";

        moveableScript = boat.AddComponent (typeof(Moveable)) as Moveable;
        clickGUI = boat.AddComponent (typeof(ClickGUI)) as ClickGUI;
    }


    public void Move() {
        if (to_or_from == -1) {
            moveableScript.setDestination(fromPosition);
            to_or_from = 1;
        } else {
            moveableScript.setDestination(toPosition);
            to_or_from = -1;
        }
    }

    public int getEmptyIndex() {
        for (int i = 0; i < passenger.Length; i++) {
            if (passenger [i] == null) {
                return i;
            }
        }
        return -1;
    }

    public bool isEmpty() {
        for (int i = 0; i < passenger.Length; i++) {
            if (passenger [i] != null) {
                return false;
            }
        }
        return true;
    }

    public Vector3 getEmptyPosition() {
        Vector3 pos;
        int emptyIndex = getEmptyIndex ();
        if (to_or_from == -1) {
            pos = to_positions[emptyIndex];
        } else {
            pos = from_positions[emptyIndex];
        }
        return pos;
    }

    public void GetOnBoat(MyCharacterController characterCtrl) {
        int index = getEmptyIndex ();
        passenger [index] = characterCtrl;
    }

    public MyCharacterController GetOffBoat(string passenger_name) {
        for (int i = 0; i < passenger.Length; i++) {
            if (passenger [i] != null && passenger [i].getName () == passenger_name) {
                MyCharacterController charactorCtrl = passenger [i];
                passenger [i] = null;
                return charactorCtrl;
            }
        }
        Debug.Log ("Cant find passenger in boat: " + passenger_name);
        return null;
    }

    public GameObject getGameobj() {
        return boat;
    }

    public int get_to_or_from() { // to->-1; from->1
        return to_or_from;
    }

    public int[] getCharacterNum() {
        int[] count = {0, 0};
        for (int i = 0; i < passenger.Length; i++) {
            if (passenger [i] == null)
                continue;
            if (passenger [i].getType () == 0) {    // 0->priest, 1->devil
                count[0]++;
            } else {
                count[1]++;
            }
        }
        return count;
    }

    public void reset() {
        moveableScript.reset ();
        if (to_or_from == -1) {
            Move ();
        }
        passenger = new MyCharacterController[2];
    }
}

另外一个需要注意的是MyCharacterController、BoatController、CoastController有一些方法名是重复的,比如说getOnBoat在MyCharacterController和BoatController中都有(BoatController中的GetOnBoat是我当时手抖了,第一个字母应该小写)。看起来似乎功能有点重复,为什么不只用一个函数操控游戏角色的上船呢?原因是不要在一个类中操作另一个类,那会加强两个类之间的耦合性。MyCharacterController中的getOnBoat()只应该操作MyCharacterController中的成员,BoatController中的GetOnBoat()只应该操作BoatController中的成员。
我们在FirstController中想让游戏角色上船的时候,两个类的getOnBoat都要调用:

whichCoast.getOffCoast(characterCtrl.getName());
characterCtrl.moveToPosition (boat.getEmptyPosition());
characterCtrl.getOnBoat (boat);
boat.GetOnBoat (characterCtrl);

UserAction

这个接口实际上使用了门面模式。
FirstController必须要实现这个接口才能对用户的输入做出反应。

public interface UserAction {
    void moveBoat();
    void characterIsClicked(MyCharacterController characterCtrl);
    void restart();
}

在这个游戏中,对用户输入做出反应,有这三个方法就够了。
UserAction是如何得到用户的输入的呢?原来,在ClickGUI和UserGUI这两个类中,都保存了一个UserAction的引用。当ClickGUI监测到用户点击GameObject的时候,就会调用这个引用的characterIsClicked方法,这样FirstController就知道哪一个游戏角色被点击了。UserGUI同理,只不过它监测的是“用户点击Restart按钮”的事件。

门面模式的好处:通过一套接口(UserAction)来定义Controller与GUI交互的渠道,这样实现Controller类的程序员只需要实现UserAction接口,他的代码就可以被任何支持这个接口的GUI类所使用;实现GUI类的程序员也不需要知道Controller的实现方式,它只需要调用接口中的方法,后面的事情就交给Controller吧!


ClickGUI

ClickGUI类是用来监测用户点击,并调用SceneController进行响应的。

public class ClickGUI : MonoBehaviour {
    UserAction action;
    MyCharacterController characterController;

    public void setController(MyCharacterController characterCtrl) {
        characterController = characterCtrl;
    }

    void Start() {
        action = Director.getInstance ().currentSceneController as UserAction;
    }

    void OnMouseDown() {
        if (gameObject.name == "boat") {
            action.moveBoat ();
        } else {
            action.characterIsClicked (characterController);
        }
    }
}

我们可以看到UserAction action实际上是FirstController的对象,它实现了UserAction接口。ClickGUI与FirstController打交道,就是通过UserAction接口的API。ClickGUI不知道这些API是怎么被实现的,但它知道FirstController类一定有这些方法。


可以做的扩展:

  • 游戏失败以后不能再响应用户点击的事件,用户只能点击Restart。
  • 增加计时的功能(这应该由SceneController来控制)。
  • 增加暂停/恢复游戏的功能(这应该由Director来控制)。
  • 在开始游戏之前做一个欢迎界面,与用户进行交互(这就是另一个场景了)。
  • 让用户可以在游戏中切换到欢迎界面,再切换回游戏界面的时候,游戏状态要和之前一样(场景的切换)。用户可以在游戏中放弃游戏,回到欢迎页面(场景的销毁)。
  • 让用户能够在欢迎界面指定有几个牧师几个恶魔,然后开始游戏。(运行时决定场景的创建)
  • 增加一种更难的模式,开始3秒以后牧师和恶魔外观相同,玩家需要凭借记忆来操作。
  • 美化游戏对象!
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,544评论 6 501
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,430评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,764评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,193评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,216评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,182评论 1 299
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,063评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,917评论 0 274
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,329评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,543评论 2 332
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,722评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,425评论 5 343
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,019评论 3 326
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,671评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,825评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,729评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,614评论 2 353

推荐阅读更多精彩内容