3月营地战棋项目总结

这是关于我在3月份参与的一个单人卡牌roguelike游戏项目的初期开发相关总结。由于在项目开发流程中,动画,UI等部分主要由另一名合作者完成,所以在本文的内容中,我将主要总结在游戏逻辑相关开发中的一些收获。因为是杂谈性质的文章,应该不会有什么技术细节,仅将一些思路抛砖引玉。如果有时间的话也可以另开博文简单讲讲这次卡牌设计的相关思路或者游戏本身的介绍什么的。游戏的下载可以访问旅法师营地并搜索营地战棋专区。

游戏流程概述

这次项目的玩法大致是炉石冒险+炉石酒馆战棋+炉石竞技场的神奇结合。开局选择英雄并进行若干次竞技场式选牌以确定牌池,之后依次与数个boss进行完整的1v1酒馆战棋战斗。每次战斗失败则游戏结束,胜利则获得一个宝藏并继续挑战。游戏的主体流程实现没有难点,主要难点集中在战棋模式战斗,操作的结算。

名词解释

以下名词解释基于读者对于炉石或类似卡牌游戏有充分了解,如读者对此类卡牌游戏毫无涉猎,接下来的内容可能难以阅读,请出门左转官网下载游戏一起成为无敌的恶魔猎手(此

扳机 : 一张卡牌触发效果的时机,例如:
钴制卫士 3星6/3 机械 每当你召唤一个机械,便获得圣盾
此处 “每当你召唤一个机械”,便是一个召唤时扳机,表示每当玩家召唤一个机械,就会触发场上的钴制卫士的效果,使钴制卫士获得圣盾。游戏中存在大量的其他扳机机制,例如回收机器人的随从死亡时扳机,钢铁武道家的回合结束扳机等。扳机在此次项目中的实现本应是基于观察者模式。每当一个扳机入场便注册为游戏结算事件的观察者,离场时反之。当游戏发生事件时便通知所有的观察者,例如对于钴制卫士,召唤随从时便被通知,判断召唤的是机械后便执行之后的获得圣盾语句。

技术总结

接下来的内容是一些技术相关的收获和教训总结

总体架构

由于本次项目是和他人合作,我和合作者早期的交流也不很适应,所以框架的设计讨论并不充分,采取的方案可能不是最优解,仅供抛砖引玉。
因为在早期分工中制定了一人负责游戏逻辑,一人负责UI与动画的分工,也考虑到项目的实用性及可移植性,采用了模仿web开发的前后端信息交互模式。对于战棋模式中的一场战斗,在战斗开始时战斗过程与结果便由后端即游戏逻辑部分代码计算完毕并将战斗过程按照一定格式传递给前端。前端得到信息后,根据信息让随从互相撞击,播放对应动画等。另一方面,对于玩家的操作,例如选择了随从并购买,则由前端传递给后端进行处理。后端会在玩家手牌中添加该随从并减少对应铸币。当然,后端也会再次返回信息告知前端操作的后续影响,例如将酒馆中的该随从移动到手牌里,或者触发了购买时扳机等。这样的优点自然是降低耦合度,避免逻辑运算和动画处理混杂,同时因为后端代码是纯C#,也可以方便移植至服务器,甚至改造成多人游戏做梦
当然这样也导致了一些问题,比如本次的合作者闲的没事手写UI写了7000行,我完全没能及时阻止。。
在后续的开发过程中,这几千行的UI代码大约要被简洁明了的UGUI替代吧。

卡牌效果的定义

首先,我们进行一下简单的设想,如果游戏中存在一些有着复杂描述信息的卡牌,简单的实现思路是什么?
最容易想到的实现就是定义一个抽象卡牌基类,然后在其中为每个扳机定义抽象方法,在具体卡牌子类中实现。这样做的缺点简直数不胜数。例如如果有200张卡牌,便要写200个不同的卡牌类,简直离谱。作为一个懒惰的程序员,必然不能这样写。
稍显高级的思路是使用策略模式,不定义200个卡牌,而是对于每种行为定义策略,在卡牌中通过对策略的引用来实现不同的行为,当然也可以随时更换策略。在这个项目中,我便参考了此思路。使用了委托结合策略模式的思路实现卡牌的复杂效果。

我们以上文中的钴制卫士为例,钴制卫士的效果是 召唤时扳机,如果召唤的是机械,就获得圣盾
事实上这段描述分为扳机和实际效果两部分,当扳机被通知时,扳机便将扳机得到的一些信息(例如此处的召唤的随从)传递给实际效果,实际效果部分判断这个随从是不是机械,如果是,则使钴制卫士获得圣盾。在理清了这个效果的本质后,一个 扳机-效果 的具体实现也就呼之欲出了。
显然,这里的效果事实上是一个委托,它的参数是扳机所要传递的信息(触发扳机的事件也即召唤的随从,扳机的所属者也即钴制卫士),内容则是使扳机的所属者钴制卫士获得圣盾。
结合上诉的策略模式思维,我们便可以将委托包装成卡牌的策略,使用枚举表示各种不同的扳机,得到包装后的扳机-委托对来实现复杂的卡牌描述
此处为委托对应的函数(已省略无关内容)

1
2
3
4
5
6
7
8
9
10
public static bool GainDivineShieldWhenSummonMech(GameEvent gameEvent)
{
if (gameEvent.targetCard.IsMinionType(MinionType.Mechs))
{
gameEvent.hostCard.effectsStay.Add(
new KeyWordEffect(Keyword.DivineShield));//获得圣盾
return true;
}
return false;
}

此处GameEvent为封装的游戏信息传递类,hostCard即钴制卫士,targetCard即召唤的随从
当游戏中发生了召唤随从事件时,钴制卫士就会被系统通知,调用上述函数并传入携带该事件具体信息的gameEvent参数,函数执行判断传入的随从是机械后即获得圣盾。
所以,只要将扳机与委托相互绑定并挂载在卡牌上,就能实现卡牌的复杂描述。

csv与反射机制的结合

在框架设计中,采用了csv这一文件格式来存储卡牌并生成。在游戏的resource文件夹中的chess.csv存储了所有棋子的攻击力,生命值,描述,关键词等信息。当游戏初始化时,后端会读取csv中的所有棋子信息并生成一系列卡牌原型,之后战斗中生成的新卡牌实例生成均采用工厂模式在卡牌工厂中由卡牌原型生成,这样能够在不增加过多运行时开销的前提下将卡牌的数据与代码分离,方便策划的修改。策划只需要知道unity的界面使用而不需掌握代码知识,就能简单使用excel等工具修改csv并在unity中测试游戏。
当然,csv能读取的信息属实有限,我们显然无法将刚才提到的扳机-委托对直接存储在csv中。于是,便可以借助反射机制的帮助。我们可以使用特定的格式在csv中记录卡牌的扳机-委托对中的扳机信息与委托的函数名,同样在初始化时读取并绑定在对应的卡牌原型上。

例如
钴制卫士在csv中的记录如下(已略去无关项)
钴制卫士,WhenMinionSummon:GainDivineShieldWhenSummonMech;,

此处WhenMinionSummon即为扳机名,后续的即为刚才的委托的函数名。这里表示的效果即为上文所述钴制卫士的效果描述。读取的实现也较为简单,从csv中读到字符串后进行分割,使用反射机制分别以字符串的内容得到对应的信息,赋值给对应的卡牌原型即可。
当然在后续的开发过程中,为了支持mod,我们对csv的结构作出了重大调整。同时创造了一种脚本语言用于mod制作者的开发过程。所以这里的csv语法与格式已经是过去式了,新的格式可能会在下一阶段的总结博文(如果有的话)中介绍一下吧。

后记

这次项目中自己的程序/策划水平和社会经验都有了一定的提升,也算是获得了一小群玩家的认可,总体还算是收获颇丰。
当然,上线将近一个月后的现在来看,早期的设计也有许多不足之处,等以后有时间再重构吧。