赞
踩
“黄梅时节家家雨,青草池塘处处蛙。有约不来过夜半,闲敲棋子落灯花。”
“象棋终日乐悠悠,苦被严亲一旦丢。兵卒坠河皆不救,将军溺水一齐休。马行千里随波去,象入三川逐浪游。炮响一声天地震,忽然惊起卧龙愁。”
棋类游戏是最早的“电子游戏”。从开发者的视角来说,虽然棋类游戏的玩法是相对简单的回合制,内容也远没有电子游戏那样复杂;但它们的玩法中的经典元素,却非常适合于游戏开发过程中的入门级和中级训练。具体到使用Unity开发而言,棋类游戏主要依赖基本的编程思想和简易算法来实现,这在游戏开发的早期学习中有着莫大的好处——只需要制作(或者说“复刻”)一款经典棋类游戏,即可接触并掌握游戏开发领域中的许多通用技能,而避免过早地成为只会调用插件和游戏引擎API,缺乏自力更生能力的“调库怪”。
在这篇教程中,请大家跟随Vic的视角,一起来制作一款经典童年棋类游戏——斗兽棋!同时,制作过程中搭建的基本框架,也可以被方便地改造为其它战棋类游戏,例如中国象棋、跳棋、军棋等。
斗兽棋是一种简单有趣的棋类游戏。
双方各有八只棋子,依强弱顺序为象、狮、虎、豹、狼、狗、猫、鼠。双方轮流走棋,每只动物每次能够走一格,前后左右都可以。
较强的兽可吃较弱的兽,但鼠能吃象而象不能吃鼠;当同类的兽相遇时,主动走棋的一方能够吃掉另一方。
棋盘横七列,纵九行。棋子放在格子中。双方各有三个陷阱和一个兽穴。如果一方的动物进入了对方的兽穴便胜出,任何一方都不能进入自己的兽穴。一只动物若处于对方的陷阱中,则为虚弱状态——对方的任何一只动物都可以把它吃掉。
棋盘中间有两条小河。狮、虎可以横向或纵向跳过河,而且可以直接把对岸的动物吃掉。只有鼠可以下水,在水中的鼠可以阻止狮、虎跳河。鼠可以在水中吃掉对方的鼠,但不能在入水或出水的同时吃子。
新建一个Unity项目(版本:2019.4.10f1),取名为Animal Checker(斗兽棋)。初始你会看到一个名为SampleScene的空场景。此时可以在Assets目录下新建几个文件夹,如Prefabs(预制体)、Scripts(C#代码)、Pictures(图片)等。
这里Vic还建立了一个名为TestField的文件夹,用来在开发过程中暂存各种临时性的零散文件,避免项目文件管理混乱。
正如Vic在序言中所说,本项目的关键在于【自力更生】——除了Unity官方API、C#标准库(.NET Framework)和DOTween(可选)之外,不会使用任何外部插件和代码,实现真正意义上的从零开发。
这里要再次强调,在Unity学习阶段,不建议从网上四处搜索,将完成度很高的现成内容拼凑成自己的游戏。这只会给自己带来虚假的自信而非真实的进步,还容易导致自己的项目被大量的错误和警告淹没。
最好不要听信任何【3天教会你制作一款游戏】的缥缈承诺。
这个项目的核心内容只需要用到12张图片资源。分别是
动物棋子8张卡通风格图:象、狮、虎、豹、狼、狗、猫、鼠;
棋盘方格4种类型:普通地面、小河、陷阱、兽穴。
新建一个文件夹Pictures,将这些图片导入其中,图片类型为Sprite。
当然,在实际开发过程中还可以使用更多的美术资源,对游戏的方方面面进行美化。
为了使行棋时的棋子移动效果平滑美观,在项目的初始会导入DOTween作为动画插件。
DOTween是Unity领域最为高效和安全的插件(可能没有之一),故在这里可以放心使用而不必担心引发问题。如果你希望不引入任何插件,也可以跳过这里并继续,但在之后有关棋子移动的部分,将进行瞬间移动而非平滑移动,有损游戏观感,除此之外没有影响。
在Asset Store中搜索DOTween,可以看到这个插件是免费的,下载并一键导入即可。
导入DOTween后,只需要按照提示弹窗操作即可顺利启用。
配置完成后,DOTween的相关内容会出现在Plugins和Resources文件夹中。
斗兽棋的棋盘由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的使命已经结束,此时可以删除。
完成以上操作后,棋盘界面的制作全部完成。
棋子的制作与棋盘方格类似,同样是正方形按钮的形式,长宽设置为90x90,比棋盘方格(100x100)略小;按钮图片采用先前导入的8种动物图片。
全部8种动物的棋子制作完成后,将它们复制一份成为16个棋子,在Image组件上加入适当的颜色滤镜,以便区分蓝方和红方。
最后,为16个棋子手动输入名字,将它们保存为预制体,存放在Prefabs文件夹内。
到这里,斗兽棋游戏所需的游戏界面元素基本上制作完毕,可以开始编写游戏代码,实现斗兽棋所需的功能了。
正如围棋用"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()方法,甚至还有重载过的等号==,用以判定两个坐标是否相等。这些方法将会在后续的程序中发挥作用。
斗兽棋是由两名玩家进行对战的棋类,棋子分为蓝方和红方;棋盘上的大部分方格是”中立“的,但是陷阱和兽穴则各有所属。因此,我们需要定义【阵营】数据类型。继续在GameBasic.cs中写入代码,用一个枚举类型Camp来定义游戏中的三种阵营: 蓝方、红方和中立。
- #region Camp:阵营
- /// <summary>
- /// 玩家阵营。
- /// </summary>
- public enum Camp
- {
- Neutral, Blue, Red
- }
- #endregion
斗兽棋中共有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)按由弱至强的顺序排列。
棋盘方格的类型分为地面Land、小河River、陷阱Trap和兽穴Cave四种。和前面一样,定义一个枚举类型SquareType,表示棋盘方格的地形种类。
- #region SquareType:地形类型
- /// <summary>
- /// 棋盘方格的地形类型。
- /// </summary>
- public enum SquareType
- {
- Land, River, Trap, Cave
- }
- #endregion
有了上述这些抽象性的游戏概念定义,我们就可以来对每个棋盘方格进行具体的定义了。思考一下,如何描述一个棋盘方格呢?
不难想到,一个棋盘方格的属性信息,总共包含坐标、地形类型和所属阵营这三项内容。描述起来就像这样:
图中的方格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方法,用以对棋盘方格的被点击事件作出响应。由于我们的游戏还没有具体功能,所以这个方法的内容暂时只是打印出一条控制台日志。
运行游戏,用鼠标左键单击棋盘上的各个方格,可以看到打印出了若干日志。日志将会显示出你单击的方格的具体信息。
到这里,我们已经以程序的方式将棋盘上的所有格子都纳入了管理,同时还能够对每个格子的被点击事件进行接收,并进行正确的响应。这为我们将要实现的游戏功能打下了非常可靠的基础。
在配置好所有的棋盘方格之后,我们还需要一个针对整个棋盘的管理模块——或者说,一个能够对棋盘上的每个方格进行查询和访问的入口。
添加脚本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组件配置完成。它扮演的是一个被动接受请求的查询器角色,将在后续的代码中发挥作用。
有了完善的棋盘,现在终于可以开始描述和定义棋子了。但与悠然不动的棋盘方格不同,棋子是游戏逻辑中的核心元素,需要承担和执行大量的功能;因此,棋子的相关代码远比前面的内容要复杂,并且需要在开发过程中不断补充更多的功能。
创建脚本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方法,在日志中显示相应棋子的介绍信息。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。