更新日期:2024年6月12日。
项目源码:在第四章发布
索引
- 简介
- 地块(Block)
- 一、定义地块类
- 二、地块类型
- 三、地块渲染
- 四、地块索引
- 关卡(Level)
- 一、定义关卡类
- 二、关卡基础属性
- 三、地块集合
- 四、关卡初始化
- 五、关卡销毁
- 六、回合制逻辑
简介
本章我们将从零开始
实现关卡
,不过,我们的目的是实现关卡与地块
的基本逻辑,更复杂的功能我们打算在开发关卡编辑器
时再做涉及,正所谓一步一个坎,就没有迈不过去的高山。
地块(Block)
地块作为关卡的组成元素,他是一个正方形的格子(当然也可以是菱形,针对斜视角游戏,不过这不在框架的支持范畴内,但做出一定的修改即可实现),每一个地块都有属于他自己的一些属性。
一、定义地块类
首先,我们定义地块类Block
:
/// <summary>
/// 地块
/// </summary>
[DisallowMultipleComponent]
public class Block : HTBehaviour
{
}
Block
继承至HTBehaviour
,使得它可以挂载到游戏物体上,然后作为一个地块对象(为不需要一个物体上多次挂载的组件定义DisallowMultipleComponent是一个好习惯,这将提高容错率,如果你的代码会给别人使用的话)。
很多同学热衷于在类继承链上规避
MonoBehaviour
,其实完全没有这个必要,只要你不滥用MonoBehaviour
的生命周期,它将提供给你几大优势:
1.属性可编辑(Inspector
面板);
2.属性可调试(Inspector
面板);
3.对象可追踪(Scene
里面有则有,无则无);
4.对象可销毁(当确定不再使用时,Destroy
它,而不用置null后交给GC)。
二、地块类型
经过深思熟虑,我们将地块类型划分为如下几种:
- 地面
- 山体
- 森林
- 湖泊
- 雪地
- 障碍
编写代码:
/// <summary>
/// 地块
/// </summary>
[DisallowMultipleComponent]
public class Block : HTBehaviour
{
/// <summary>
/// 类型
/// </summary>
[Label("类型")] public BlockType Type;
}
/// <summary>
/// 地块类型
/// </summary>
public enum BlockType
{
/// <summary>
/// 地面
/// </summary>
[Remark("地面")]
Ground = 0,
/// <summary>
/// 山体
/// </summary>
[Remark("山体")]
Moutain = 1,
/// <summary>
/// 森林
/// </summary>
[Remark("森林")]
Forest = 2,
/// <summary>
/// 湖泊
/// </summary>
[Remark("湖泊")]
Water = 3,
/// <summary>
/// 雪地
/// </summary>
[Remark("雪地")]
Snow = 4,
/// <summary>
/// 障碍
/// </summary>
[Remark("障碍")]
Obstacle = 5
}
然后,我们设计每种类型的地块拥有的交互权限如下:
类型 | 角色可行走 | 角色可攻击(站在上面的敌人或穿过它攻击其他敌人) |
---|---|---|
地面 | 是 | 是 |
山体 | 受限(拥有飞檐走壁可行走,行走速度减1/2 ) | 是 |
森林 | 受限(行走速度减1/2 ) | 是 |
湖泊 | 受限(拥有踏水神行可行走,行走速度减1/2 ) | 是 |
雪地 | 受限(行走速度减2/3 ) | 是 |
障碍 | 否 | 受限(拥有隔山打牛可穿透障碍进行攻击 ) |
需注意的是,任意地块,当上面站有敌人时,将不可行走,不可跨越,当站有队友时,不可行走,可跨越。
也即是说,我们可以使用角色摆出特定的阵型,以拦住敌方的行走路线,或达到包围的效果。
三、地块渲染
地块的渲染我们决定选择SpriteRenderer
,且一个地块仅渲染一张图片,那么,在Block
类中需要持有地块渲染器的引用,我们添加代码:
/// <summary>
/// 地块
/// </summary>
[DisallowMultipleComponent]
public class Block : HTBehaviour
{
/// <summary>
/// 目标渲染器
/// </summary>
[Label("目标渲染器")] public SpriteRenderer Target;
/// <summary>
/// 类型
/// </summary>
[Label("类型")] public BlockType Type;
}
四、地块索引
然后,我们想一想地块还需要些什么属性,哦对了,我想我们在后续一定会需要检索地块(也即是根据索引找到一个地块),而我们的关卡采用二维平铺布局,所有地块存储的数据结构应当是一个二维数组
最合适,所以地块的索引我们定义为二维的下标Vector2Int
:
/// <summary>
/// 地块
/// </summary>
[DisallowMultipleComponent]
public class Block : HTBehaviour
{
/// <summary>
/// 目标渲染器
/// </summary>
[Label("目标渲染器")] public SpriteRenderer Target;
/// <summary>
/// 类型
/// </summary>
[Label("类型")] public BlockType Type;
/// <summary>
/// 位置
/// </summary>
[Label("位置")] public Vector2Int Pos;
}
地块类的属性暂时就想到这么多,不必追求一次就考虑全面,后续根据情况补充即可,接下来我们定义关卡类。
关卡(Level)
关卡用于容纳并绘制一系列地块
,以及后期容纳角色
等其他的一系列东西。
一、定义关卡类
我们定义关卡类Level
:
/// <summary>
/// 关卡
/// </summary>
[DisallowMultipleComponent]
public class Level : SingletonBehaviourBase<Level>
{
}
Level
继承至单例行为基类SingletonBehaviourBase
,使得它可以挂载到游戏物体上,并作为单例始终全局唯一(同一时刻,场景中的关卡肯定只能有一个)。
二、关卡基础属性
我们先为关卡设计一些基础的属性:
/// <summary>
/// 关卡索引
/// </summary>
[Label("关卡索引")] public int Index;
/// <summary>
/// 关卡名称
/// </summary>
[Label("关卡名称")] public string Name;
/// <summary>
/// 关卡背景音乐
/// </summary>
[Label("关卡背景音乐")] public AudioClip BGAudio;
/// <summary>
/// 地图
/// </summary>
[Label("地图")] public AStarGrid Map;
/// <summary>
/// 地图尺寸
/// </summary>
[Label("地图尺寸")] public Vector2Int MapSize;
/// <summary>
/// 角色根节点
/// </summary>
[Label("角色根节点")] public Transform RolesRoot;
/// <summary>
/// 特效根节点
/// </summary>
[Label("特效根节点")] public Transform EffectsRoot;
属性名称 | 属性详解 |
---|---|
关卡索引 | 关卡唯一标识符,也用作保存、加载关卡时的索引 |
地图 | 所有地块对象的根节点,此处类型为AStarGrid(A*寻路网格) ,兼并寻路功能 |
地图尺寸 | 地图的尺寸,用于描述地图的宽、高 |
角色根节点 | 所有角色对象的根节点 |
特效根节点 | 所有特效对象的根节点 |
将地块、角色、特效
都归于单一的根节点,即方便管理所有对象,又方便统一划层,也即是规定渲染器的遮挡层,这三者的遮挡层关系应当是:特效 > 角色 > 地块
,具体如何实现遮挡,我们先不考虑这么多。
完事后我们的Level在层级面板应当是这样的(三个子对象也即是地块、角色、特效
的根节点):
三、地块集合
定义一个二维数组存储所有地块:
/// <summary>
/// 所有的地块
/// </summary>
public Block[,] Blocks { get; private set; }
此处注意,将其定义为property
的原因是,使其规避序列化
功能(因为不需要序列化,关卡在初始化时搜寻所有地块即可),且提升访问安全性。
四、关卡初始化
然后,定义一个初始化方法
,用于在关卡加载到场景中后,执行他自身的所有初始化操作,此处我们避开MonoBehaviour
的生命周期方法Awake
、Start
,因为在此处他们是不受控的,这也是我上面所提到的不滥用生命周期的另一个意思。
/// <summary>
/// 初始化
/// </summary>
public virtual void Initialize()
{
//Map为所有地块根节点,即可从Map搜寻所有地块
Blocks = new Block[MapSize.x, MapSize.y];
Block[] blocks = Map.GetComponentsInChildren<Block>(true);
for (int i = 0; i < blocks.Length; i++)
{
//地块的Pos下标,即代表了在二维数组中的索引,我们后续会开发关卡编辑器,Pos的赋值交由编辑器来完成,所以这里只管取Pos值
Block block = blocks[i];
Blocks[block.Pos.x, block.Pos.y] = block;
}
}
Initialize
方法作为主动调用方法,在我们自行加载关卡完成后主动调用即可。
五、关卡销毁
同理,应当定义一个销毁方法
,用于在关卡销毁时执行一些操作,虽然我们现在还没有需要做的(地块、角色、特效等都属于关卡物体子节点,会跟着一起销毁,无需额外操作),但事先将其规划好总没错。
/// <summary>
/// 销毁
/// </summary>
public virtual void Dispose()
{
}
六、回合制逻辑
为了实现回合制逻辑,我们先定义如下几个属性:
/// <summary>
/// 当前的回合
/// </summary>
[PropertyDisplay("当前的回合")]
public int Round { get; private set; } = 1;
/// <summary>
/// 当前回合的行动阵营
/// </summary>
[PropertyDisplay("当前回合的行动阵营")]
public RoleCamp RoundCamp { get; private set; } = RoleCamp.Player;
/// <summary>
/// 关卡状态
/// </summary>
[PropertyDisplay("关卡状态")]
public LevelState State { get; private set; } = LevelState.InProgress;
/// <summary>
/// 角色阵营
/// </summary>
public enum RoleCamp
{
/// <summary>
/// 玩家
/// </summary>
Player = 0,
/// <summary>
/// 敌人
/// </summary>
Enemy = 1
}
/// <summary>
/// 关卡状态
/// </summary>
public enum LevelState
{
/// <summary>
/// 进行中
/// </summary>
InProgress,
/// <summary>
/// 已通关
/// </summary>
Passed,
/// <summary>
/// 已失败
/// </summary>
Failed
}
此处应该很好理解了,我们按字面意思来就行了,根据最初的设计,每一个回合:玩家先行动,然后是敌人行动,敌人行动完毕后此回合结束,进入下一回合(循环往复)。
此时,我们发现,完整的回合制逻辑在未编写角色(Role)类前,并不太好写出来,所以我们先放下,将复杂的事情留到后面一步步拆解。
接下来我们准备引入角色(Role)类,不过,看了一眼窗外,今日天色已晚,不宜working…
那么,择日再战吧。