【Unity连载】斗兽棋—棋类游戏开发演示(1)

序言

“黄梅时节家家雨,青草池塘处处蛙。有约不来过夜半,闲敲棋子落灯花。”

“象棋终日乐悠悠,苦被严亲一旦丢。兵卒坠河皆不救,将军溺水一齐休。马行千里随波去,象入三川逐浪游。炮响一声天地震,忽然惊起卧龙愁。”

棋类游戏是最早的“电子游戏”。从开发者的视角来说,虽然棋类游戏的玩法是相对简单的回合制,内容也远没有电子游戏那样复杂;但它们的玩法中的经典元素,却非常适合于游戏开发过程中的入门级和中级训练。具体到使用Unity开发而言,棋类游戏主要依赖基本的编程思想和简易算法来实现,这在游戏开发的早期学习中有着莫大的好处——只需要制作(或者说“复刻”)一款经典棋类游戏,即可接触并掌握游戏开发领域中的许多通用技能,而避免过早地成为只会调用插件和游戏引擎API,缺乏自力更生能力的“调库怪”。

在这篇教程中,请大家跟随Vic的视角,一起来制作一款经典童年棋类游戏——斗兽棋!同时,制作过程中搭建的基本框架,也可以被方便地改造为其它战棋类游戏,例如中国象棋、跳棋、军棋等。

第一章 项目初始

1.1 游戏规则介绍

斗兽棋是一种简单有趣的棋类游戏。

双方各有八只棋子,依强弱顺序为象、狮、虎、豹、狼、狗、猫、鼠。双方轮流走棋,每只动物每次能够走一格,前后左右都可以。

较强的兽可吃较弱的兽,但鼠能吃象而象不能吃鼠;当同类的兽相遇时,主动走棋的一方能够吃掉另一方。

棋盘横七列,纵九行。棋子放在格子中。双方各有三个陷阱和一个兽穴。如果一方的动物进入了对方的兽穴便胜出,任何一方都不能进入自己的兽穴。一只动物若处于对方的陷阱中,则为虚弱状态——对方的任何一只动物都可以把它吃掉。

棋盘中间有两条小河。狮、虎可以横向或纵向跳过河,而且可以直接把对岸的动物吃掉。只有鼠可以下水,在水中的鼠可以阻止狮、虎跳河。鼠可以在水中吃掉对方的鼠,但不能在入水或出水的同时吃子。

1.2 建立项目

新建一个Unity项目(版本:2019.4.10f1),取名为Animal Checker(斗兽棋)。初始你会看到一个名为SampleScene的空场景。此时可以在Assets目录下新建几个文件夹,如Prefabs(预制体)、Scripts(C#代码)、Pictures(图片)等。

这里Vic还建立了一个名为TestField的文件夹,用来在开发过程中暂存各种临时性的零散文件,避免项目文件管理混乱。

1.3 自力更生——对外部代码坚决Say No

正如Vic在序言中所说,本项目的关键在于【自力更生】——除了Unity官方APIC#标准库(.NET Framework)DOTween(可选)之外,不会使用任何外部插件和代码,实现真正意义上的从零开发。

这里要再次强调,在Unity学习阶段,不建议从网上四处搜索,将完成度很高的现成内容拼凑成自己的游戏。这只会给自己带来虚假的自信而非真实的进步,还容易导致自己的项目被大量的错误和警告淹没。

最好不要听信任何【3天教会你制作一款游戏】的缥缈承诺。

1.4 美术资源

这个项目的核心内容只需要用到12张图片资源。分别是

动物棋子8张卡通风格图:象、狮、虎、豹、狼、狗、猫、鼠;

棋盘方格4种类型:普通地面、小河、陷阱、兽穴。

新建一个文件夹Pictures,将这些图片导入其中,图片类型为Sprite。

当然,在实际开发过程中还可以使用更多的美术资源,对游戏的方方面面进行美化。

1.5 导入插件(可选)

为了使行棋时的棋子移动效果平滑美观,在项目的初始会导入DOTween作为动画插件。

DOTween是Unity领域最为高效和安全的插件(可能没有之一),故在这里可以放心使用而不必担心引发问题。如果你希望不引入任何插件,也可以跳过这里并继续,但在之后有关棋子移动的部分,将进行瞬间移动而非平滑移动,有损游戏观感,除此之外没有影响。

在Asset Store中搜索DOTween,可以看到这个插件是免费的,下载并一键导入即可。

导入DOTween后,只需要按照提示弹窗操作即可顺利启用。

配置完成后,DOTween的相关内容会出现在Plugins和Resources文件夹中。

第二章 搭建界面

2.1 搭建棋盘

斗兽棋的棋盘由7x9=63个棋盘方格组成。通过几行编辑器脚本,即可快速地制作出符合要求的棋盘。

在场景中新建一个按钮,清除按钮上的文字,将按钮命名为Square(方格)。重置Square的RectTransform信息,可以看到按钮变成了100x100的正方形。

新建文件夹Editor,在其中创建脚本CreateBoard.cs,内容如下。

using UnityEngine;
using UnityEditor;

public class CreateBoard : MonoBehaviour
{
    public static GameObject square;

    [MenuItem("棋盘/创建棋盘")]//在Unity顶部工具栏增加选项卡
    public static void Create()
    {
        square = GameObject.Find("Square");//以这个按钮为基准进行复制
        for (int col = 0; col < 7; col++)//7列
        {
            for (int row = 0; row < 9; row++)//9行
            {
                //棋子边长为100,设定两个棋子的中心点间距为105,这样棋子之间有宽度为5的空隙
                float posx = 105 * col;
                float posy = 105 * row;
                GameObject creation = Instantiate(square, new Vector3(posx, posy, 0), Quaternion.identity);//创建棋盘上的各个棋子
                creation.transform.SetParent(GameObject.Find("Canvas").transform);//置于Canvas下
                creation.name = $"{col},{row}";//以棋盘坐标的形式,自动为棋子命名
            }
        }
    }
}

保存脚本后,在Unity主界面工具栏选取【棋盘/创建棋盘】,即可一键完成棋盘的搭建。每个棋盘方格的名称,代表了它在棋盘上的坐标。

现在,整理界面。删除Squares按钮,将新创建的各个棋盘方格按钮归拢到一个空物体ChessBoard下,将整个棋盘移动到镜头的中央;找到先前导入的4种棋盘方格图片,按照斗兽棋的棋盘样式张贴到按钮上;调整摄像机背景设置为纯色,不看默认天空盒。

此外,棋盘方格在游戏中是不需要点击反馈效果的,因此我们将全部63个方格上的Button组件的反馈效果设置为None。

脚本CreateBoard.cs的使命已经结束,此时可以删除。

完成以上操作后,棋盘界面的制作全部完成。

2.2 制作棋子

棋子的制作与棋盘方格类似,同样是正方形按钮的形式,长宽设置为90x90,比棋盘方格(100x100)略小;按钮图片采用先前导入的8种动物图片。

全部8种动物的棋子制作完成后,将它们复制一份成为16个棋子,在Image组件上加入适当的颜色滤镜,以便区分蓝方和红方。

最后,为16个棋子手动输入名字,将它们保存为预制体,存放在Prefabs文件夹内。

到这里,斗兽棋游戏所需的游戏界面元素基本上制作完毕,可以开始编写游戏代码,实现斗兽棋所需的功能了。

第三章 构建游戏对象

3.1 坐标

正如围棋用"A15""D13"等表示棋盘上的点,中国象棋用"车四平三""兵五进一"等说法表示棋步的执行;要想实现斗兽棋的游戏功能,首先需要对棋盘上的各个方格进行坐标化处理。

新建代码文件GameBasic.cs,将新文件中的Unity默认内容删除。在这个文件中,我们将对斗兽棋游戏所需的一些基本概念和对象进行定义。

首先写入一个值类型Location。这是一个抽象的结构体类型,用来表达棋盘方格和棋子的【坐标】概念,内容如下:

using UnityEngine;
using System;

# region Location:坐标
/// <summary>
/// 一个棋盘格或棋子的坐标。
/// </summary>
[Serializable]
public struct Location
{
    public int x;//横坐标,表示棋盘上的列,范围从0至6
    public int y;//纵坐标,表示棋盘上的行,范围从0至8
    public Vector2Int Vector => new Vector2Int(x, y);//允许以Unity二维向量形式表示一个棋盘坐标——这能够方便坐标之间距离的计算

    public override string ToString()
    {
        return $"({x},{y})";
    }

    //IsNear方法:判断两个坐标是否是相邻坐标
    public bool IsNear(Location other)
    {
        if (Vector2Int.Distance(Vector, other.Vector) == 1)
        {
            return true;
        }
        return false;
    }
    public static bool IsNear(Location a, Location b)
    {
        return a.IsNear(b);
    }

    //坐标值的合法范围
    public const int Xmin = 0;
    public const int Xmax = 6;
    public const int Ymin = 0;
    public const int Ymax = 8;

    //IsValid方法:判断一个坐标是否是合法坐标,即位于棋盘范围内的坐标
    public bool IsValid()
    {
        if (x >= Xmin && x <= Xmax && y >= Ymin && y <= Ymax)
        {
            return true;
        }
        return false;
    }
    private static bool IsValid(int x, int y)
    {
        if (x >= Xmin && x <= Xmax && y >= Ymin && y <= Ymax)
        {
            return true;
        }
        return false;
    }

    //构造函数:使用一组x和y的值创建新坐标
    public Location(int x, int y)
    {
        this.x = x;
        this.y = y;
        if (!IsValid(x, y))
        {
            Debug.LogWarning($"正在创建一个超出棋盘范围的方格:({x},{y})");
        }
    }

    public static bool operator ==(Location a, Location b)
    {
        return a.Vector == b.Vector;
    }

    public static bool operator !=(Location a, Location b)
    {
        return a.Vector != b.Vector;
    }

    public override bool Equals(object obj)
    {
        return base.Equals(obj);
    }

    public override int GetHashCode()
    {
        return base.GetHashCode();
    }
}
#endregion

在定义这个Location类型时,我们需要具有一定的预见性,为这个数据类型加入必要的功能,用以满足之后的游戏逻辑编写需要。例如在Location的内部方法中,你可以看到用于判断两个坐标是否相邻的IsNear()方法、用于判断一个坐标是否合法的IsValid()方法,甚至还有重载过的等号==,用以判定两个坐标是否相等。这些方法将会在后续的程序中发挥作用。

3.2 阵营

斗兽棋是由两名玩家进行对战的棋类,棋子分为蓝方和红方;棋盘上的大部分方格是”中立“的,但是陷阱和兽穴则各有所属。因此,我们需要定义【阵营】数据类型。继续在GameBasic.cs中写入代码,用一个枚举类型Camp来定义游戏中的三种阵营: 蓝方红方中立。

#region Camp:阵营
/// <summary>
/// 玩家阵营。
/// </summary>
public enum Camp
{
    Neutral, Blue, Red
}
#endregion

3.3 动物种类

斗兽棋中共有8种不同的动物种类,同样适合以枚举类型进行定义。定义一个枚举类型Animal

#region Animal:动物类型
/// <summary>
/// 动物类型
/// </summary>
public enum Animal
{
    Rat = 0,
    Cat = 1,
    Dog = 2,
    Wolf = 3,
    Leopard = 4,
    Tiger = 5,
    Lion = 6,
    Elephant = 7
}
#endregion

8种动物的枚举数值(0-7)按由弱至强的顺序排列。

3.4 地形类型

棋盘方格的类型分为地面Land小河River陷阱Trap兽穴Cave四种。和前面一样,定义一个枚举类型SquareType,表示棋盘方格的地形种类。

#region SquareType:地形类型
/// <summary>
/// 棋盘方格的地形类型。
/// </summary>
public enum SquareType
{
    Land, River, Trap, Cave
}
#endregion

3.5 棋盘方格

有了上述这些抽象性的游戏概念定义,我们就可以来对每个棋盘方格进行具体的定义了。思考一下,如何描述一个棋盘方格呢?

不难想到,一个棋盘方格的属性信息,总共包含坐标地形类型和所属阵营这三项内容。描述起来就像这样:

图中的方格1,是坐标为(0,0)地面方格,阵营为中立

图中的方格2,是坐标为(3,1)陷阱方格,阵营为蓝方;

图中的方格3,是坐标为(3,8)兽穴方格,阵营为红方

(注意:在本项目的棋盘描述中,规定下方为蓝方,左下角的方格坐标为(0,0)。后续内容皆以此为准)

新建脚本Square.cs,定义棋盘方格并实现其功能。这是本项目中的第一个游戏脚本,它将作为组件被挂载在场景中的每一个棋盘方格(按钮)上。

using UnityEngine;
using UnityEngine.UI;
using UnityEditor;
using System;

public class Square : MonoBehaviour
{
    public Location location;
    public SquareType type;
    public Camp camp;

    private void Start()
    {
        GetComponent<Button>().onClick.AddListener(OnSquareClicked);
    }

    public override string ToString()
    {
        return $"棋盘方格坐标:{location},地形类型:{type},阵营:{camp}"
    }

    public void OnSquareClicked()
    {
        Debug.Log(this);
    }
}

将这个组件挂载到之前创建的每一个棋盘方格上。

可以看到,每个方格上的Square组件都有三个可填写的配置项,分别表示对应方格的坐标、地形类型和所属阵营。与2.1小节类似,我们使用编辑器脚本来自动填写这些信息——不过这里的代码稍显繁琐,因此你可以选择不使用脚本,而是手动为63个方格进行填写。

自动填写方格信息的脚本如下——这些内容写在任意C#文件内效果均相同,这里Vic写在了Square.cs的后面。注意需要加入【using System】和【using UnityEditor】指令。

using UnityEngine;
using UnityEngine.UI;
using UnityEditor;
using System;

public class Square : MonoBehaviour
{
    public Location location;
    public SquareType type;
    public Camp camp;

    private void Start()
    {
        GetComponent<Button>().onClick.AddListener(OnSquareClicked);
    }

    public override string ToString()
    {
        return $"棋盘方格坐标:{location},地形类型:{type},阵营:{camp}"
    }

    public void OnSquareClicked()
    {
        Debug.Log(this);
    }

    [MenuItem("棋盘/初始化棋盘方格")]
    public static void InitSquares()
    {
        var squares = FindObjectsOfType<Square>();
        foreach (var square in squares)
        {
            //自动填写方格坐标
            string name = square.gameObject.name;
            string[] locationValues = name.Split(',');
            int x = Convert.ToInt32(locationValues[0]);
            int y = Convert.ToInt32(locationValues[1]);
            square.location = new Location(x, y);

            //自动填写方格地形
            var location = square.location;

            //地形:小河
            if ((location.x == 1 || location.x == 2 || location.x == 4 || location.x == 5) && location.y >= 3 && location.y <= 5)
            {
                square.type = SquareType.River;
            }
            //地形:兽穴
            else if (location.Vector == new Vector2Int(3, 0) || location.Vector == new Vector2Int(3, 8))
            {
                square.type = SquareType.Cave;
            }
            //地形:陷阱
            else if (location.IsNear(new Location(3, 0)) || location.IsNear(new Location(3, 8)))
            {
                square.type = SquareType.Trap;
            }
            //地形:地面
            else
            {
                square.type = SquareType.Land;
            }

            //自动填写方格阵营
            var type = square.type;

            //阵营:中立
            if (type == SquareType.Land || type == SquareType.River)
            {
                square.camp = Camp.Neutral;
            }
            else
            {
                //阵营:蓝方
                if (square.location.y <= 1)
                {
                    square.camp = Camp.Blue;
                }
                //阵营:红方
                else
                {
                    square.camp = Camp.Red;
                }
            }
        }
    }
}

保存脚本后,在Unity工具栏选择【棋盘/初始化棋盘方格】选项卡,即可一键完成对所有棋盘方格的信息填写;

此时你可以选中不同的方格,检查Square组件上的信息是否填写正确,是否与棋盘方格的在棋盘上的实际位置匹配,如下图。

检查无误后,即可删去刚刚使用过的临时性脚本内容。

Square.cs中包含了OnSquareClicked方法,用以对棋盘方格的被点击事件作出响应。由于我们的游戏还没有具体功能,所以这个方法的内容暂时只是打印出一条控制台日志。

运行游戏,用鼠标左键单击棋盘上的各个方格,可以看到打印出了若干日志。日志将会显示出你单击的方格的具体信息。

到这里,我们已经以程序的方式将棋盘上的所有格子都纳入了管理,同时还能够对每个格子的被点击事件进行接收,并进行正确的响应。这为我们将要实现的游戏功能打下了非常可靠的基础。

3.6 棋盘

在配置好所有的棋盘方格之后,我们还需要一个针对整个棋盘的管理模块——或者说,一个能够对棋盘上的每个方格进行查询和访问的入口。

添加脚本ChessBoard.cs,用于对棋盘的整体管理。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System;

public class ChessBoard : MonoBehaviour
{
    public static ChessBoard Get = null;

    public List<Square> squares;

    public Square this[int x, int y]
    {
        get
        {
            foreach (Square square in squares)
            {
                if (square.location.x == x && square.location.y == y)
                {
                    return square;
                }
            }
            return null;
        }
    }
    public Square this[Location location]
    {
        get
        {
            return this[location.x, location.y];
        }
    }

    private void Awake()
    {
        Get = this;
    }
}

ChessBoard是一个全局唯一的管理器组件,使用单例模式。这个脚本采用C#的索引机制,实现了对棋盘方格的查询功能。例如,ChessBoard.Get[3,0]表示的就是坐标为(3,0)的蓝方兽穴。

创建游戏物体GameCtrl,再创建它的子物体ChessBoard,挂载ChessBoard组件。

将全部63个棋盘方格上的Square组件填入到ChessBoard组件上的Squares列表中。

和前面的两次自动化操作类似,你既可以编写临时性脚本进行自动填写,也可以手动填写。

到这里,ChessBoard组件配置完成。它扮演的是一个被动接受请求的查询器角色,将在后续的代码中发挥作用。

3.7 棋子

有了完善的棋盘,现在终于可以开始描述和定义棋子了。但与悠然不动的棋盘方格不同,棋子是游戏逻辑中的核心元素,需要承担和执行大量的功能;因此,棋子的相关代码远比前面的内容要复杂,并且需要在开发过程中不断补充更多的功能。

创建脚本Chessman.cs,开始定义棋子。第一个版本的Chessman.cs内容如下。

using System.Collections;
using System.Collections.Generic;
using System;
using UnityEngine;
using UnityEngine.UI;
using DG.Tweening;//DOTween

public class Chessman : MonoBehaviour
{
    public Location location;//棋子的坐标
    public Animal animal;//棋子的动物类型
    public Camp camp;//棋子的阵营

    public override string ToString()
    {
        return $"棋子坐标:{location} 动物类型:{animal} 阵营:{camp}";
    }

    /// <summary>
    /// 获取当前场上的全部棋子,或者某一方的全部棋子。
    /// </summary>
    /// <param name="camp">Neutral:查询全部棋子; Blue or Red: 查询一方的全部棋子</param>
    /// <returns></returns>
    public static List<Chessman> All(Camp camp = Camp.Neutral)
    {
        List<Chessman> ret = new List<Chessman>();
        var chessmen = FindObjectsOfType<Chessman>();
        foreach (var chessman in chessmen)
        {
            if (camp == Camp.Neutral || camp == chessman.camp)
            {
                ret.Add(chessman);
            }
        }
        return ret;
    }
    /// <summary>
    /// 清除场上的全部棋子。
    /// </summary>
    public static void ClearAll()
    {
        var all = All();
        for (int i = all.Count - 1; i >= 0; i--)
        {
            all[i].ExitFromBoard();
        }
    }
    /// <summary>
    /// 依照坐标查询,找到位于相应坐标上的棋子。
    /// </summary>
    /// <param name="location"></param>
    /// <returns></returns>
    public static Chessman GetChessman(Location location)
    {
        foreach (var chessman in All())
        {
            if (chessman.location.Equals(location))
            {
                return chessman;
            }
        }
        return null;
    }
    /// <summary>
    /// 棋子所在的方格。
    /// </summary>
    public Square Square => ChessBoard.Get[location];

    /// <summary>
    /// 初始化棋子
    /// </summary>
    public void Start()
    {
        if (camp == Camp.Neutral)
        {
            Debug.LogError("棋子阵营不能为中立。");
            return;
        }
        MoveTo(location);
        GetComponent<Button>().onClick.AddListener(OnChessmanClicked);
    }

    /// <summary>
    /// 使棋子移动到指定坐标。这会删除目标位置上的另一个棋子。
    /// </summary>
    /// <param name="target">目标坐标</param>
    public void MoveTo(Location target)
    {
        try
        {
            Square square = ChessBoard.Get[target.x, target.y];//定位目标棋盘格
            if (square.Chessman != this)
            {
                square.RemoveChessman();//删除目标位置上已有的棋子
            }
            location = target;//修改自身坐标为新的坐标
            transform.DOMove(square.transform.position, 0.35f);//执行移动
            //transform.position = square.transform.position;//无DOTween时以此替代上一行
        }
        catch (Exception ex)
        {
            Debug.LogError($"移动棋子失败.{ex.Message}");
        }
    }

    private void OnChessmanClicked()
    {
        Debug.Log(this);
    }

    /// <summary>
    /// 使这个棋子退场。
    /// </summary>
    public void ExitFromBoard()
    {
        Destroy(gameObject);
    }
}

与前面对棋盘方格的定义过程类似,Chessman.cs中包含了描述棋子所需的全部信息(坐标、动物类型、阵营),并包含了棋子所需的一组原始功能。这些功能包括:

·获取当前场上的全部棋子:All()

·获取当前场上某一阵营的全部棋子:All(Camp camp)

·清空场上的全部棋子:ClearAll()

·查询位于某一坐标的棋子:GetChessman(Location location)

·令一个棋子移动到另一坐标:MoveTo(Location target)  如果该位置上有其它棋子,则该棋子将被取代,即“吃掉”。

·令一个棋子退场:ExitFromBoard()

眼下,我们尚未在任何地方调用棋子的这些功能,它们将在后面的章节中发挥作用。

Square.cs也需要进行扩充,以支持棋子的相关功能。

扩充后的Square.cs在棋盘方格与棋子之间建立了联系,能够查询访问移除位于棋盘方格上的棋子。扩充后的内容如下。

using UnityEngine;
using UnityEngine.UI;

public class Square : MonoBehaviour
{
    public Location location;
    public SquareType type;
    public Camp camp;
    public Chessman Chessman => Chessman.GetChessman(location);//通过此属性,可以访问位于此方格上的棋子

    private void Start()
    {
        GetComponent<Button>().onClick.AddListener(OnSquareClicked);
    }

    public override string ToString()
    {
        return $"棋盘方格坐标:{location},地形类型:{type},阵营:{camp}";
    }

    public void OnSquareClicked()
    {
        Debug.Log(this);
    }

    /// <summary>
    /// 移除棋盘方格上的棋子(如果有的话)。
    /// </summary>
    public void RemoveChessman()
    {
        if (Chessman != null)
        {
            Chessman.ExitFromBoard();
        }
    }
}

在Prefabs文件夹内找到2.2小节中制作的16个棋子预制体,为它们挂载Chessman组件,然后在每个棋子上填写Animal动物类型Camp阵营Location坐标信息,如图。由于只有16个棋子,工作量很小,这里就不需要使用脚本了。

 全部16个棋子的坐标如下表。

目前版本的Chessman.cs包含Start方法,能够将自身直接初始化到预定的坐标位置。

进行测试,将全部16个棋子预制体拖到场景内的Canvas-ChessBoard物体下。

运行游戏,可以看到所有的棋子都在游戏开始时出现在了各自的方格上,可见它们的位置已经成功初始化。

试试用鼠标左键点击蓝猫。与棋盘方格的情况类似,此时将会执行Chessman.cs中的OnSquareClicked方法,在日志中显示相应棋子的介绍信息。

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值