返回总目录
在SRPG中,大多数情况是指角色与角色之间的战斗。而这种战斗一般有两种模式:
地图中直接战斗;
有专门的战斗场景。
这两种模式的战斗在数据上没有任何区别,只有战斗动画的区别。
就像之前描述的,SRPG也是RPG,所以战斗方式和回合制RPG的战斗几乎是相同的。主要区别是RPG每个回合需要手动操作(也有自动战斗的),直到战斗结束;而SRPG是自动战斗,且多数情况只有一个回合(额外回合也是由于技能、物品或剧情需要)。且RPG多数是多人战斗,而SRPG多数是每边就一个。
我们这一章就来写一个战斗系统。
在计算战斗数据时,我们应该知道每一个回合需要可能改变的属性:
生命值(受到伤害,是否死亡);
魔法值(使用技能);
耐久度(使用武器);
除了这些,我们在战斗中还应该知道一些信息:
角色是否可攻击;
角色是否行动过;
角色攻击是否爆击。
当然,为了之后播放动画还应该知道:
以上所提到的都可能在每次行动时改变。
而播放动画,我们用一个枚举来判断:
namespace DR.Book.SRPG_Dev.CombatManagement
{
public enum CombatAnimaType
{
Unknow,
Prepare, // 准备
Attack, // 攻击
Heal, //治疗
Evade, // 躲闪
Damage, // 受到攻击
Dead // 死亡
// 其它自定义
}
}
基于以上,我们来创建一个计算数据的类。
这里要建立的变量,就是每一个回合每个角色可能改变的变量。其中,position
是不变的,代表位置。
创建战斗变量CombatVariable
:
using System;
namespace DR.Book.SRPG_Dev.CombatManagement
{
[Serializable]
public struct CombatVariable
{
///
/// 位置
///
public int position;
///
/// 生命值
///
public int hp;
///
/// 魔法值
///
public int mp;
///
/// 是否可攻击
///
public bool canAtk;
///
/// FE4武器耐久度(机器人大战技能数量)
///
public int durability;
///
/// 动画类型
///
public CombatAnimaType animaType;
///
/// 是否爆击
///
public bool crit;
///
/// 是否行动过
///
public bool action;
///
/// 是否已经死亡
///
public bool isDead
{
get { return hp <= 0; }
}
public CombatVariable(int position, int hp, int mp, bool canAtk, CombatAnimaType animaType)
{
this.position = position;
this.hp = hp;
this.mp = mp;
this.canAtk = canAtk;
this.durability = 0;
this.animaType = animaType;
this.crit = false;
this.action = false;
}
public CombatVariable(int position, int hp, int mp, bool canAtk, int durability, CombatAnimaType animaType)
{
this.position = position;
this.hp = hp;
this.mp = mp;
this.canAtk = canAtk;
this.durability = durability;
this.animaType = animaType;
this.crit = false;
this.action = false;
}
}
}
这里变量并没有考虑声音问题,还应有一个变量指代使用哪个音频,武器的声音可能是不同的。
我们的战斗不是只有一个角色,且为了播放动画每一次行动都要被记录,也就是说每一次行动后的结果也要被记录。
创建战斗每一步结果CombatStep
:
using UnityEngine;
namespace DR.Book.SRPG_Dev.CombatManagement
{
///
/// 战斗每一步结果
///
public class CombatStep
{
///
/// 当前进攻方
///
public CombatVariable atkVal { get; private set; }
///
/// 当前防守方
///
public CombatVariable defVal { get; private set; }
public CombatStep(CombatVariable atker, CombatVariable defer)
{
this.atkVal = atker;
this.defVal = defer;
}
///
/// 根据位置获取战斗变量
///
///
///
public CombatVariable GetCombatVariable(int position)
{
if (atkVal.position == position)
{
return atkVal;
}
if (defVal.position == position)
{
return defVal;
}
Debug.LogError("CombatStep -> position is out of range.");
return default(CombatVariable);
}
}
}
其中,你要注意的是,CombatStep
不是每一个回合,因为每一个回合包含两次进攻与防守(各进攻一次与防守一次)。
有了战斗单位(CombatUnit
)、战斗变量(CombatVariable
)和战斗每一步结果(CombatStep
), 已经初步做好了战斗的准备。在不考虑群攻的情况下,我们先来建立战斗类Combat
。
创建战斗主类Combat
:
using System.Collections.Generic;
using UnityEngine;
namespace DR.Book.SRPG_Dev.CombatManagement
{
using DR.Book.SRPG_Dev.Maps;
using DR.Book.SRPG_Dev.Models;
[DisallowMultipleComponent]
[AddComponentMenu("SRPG/Combat System/Combat")]
public class Combat : MonoBehaviour
{
public CombatUnit unit0 { get; protected set; } // 如果是群攻,所有unit用List或数组
public CombatUnit unit1 { get; protected set; } // 如果是群攻,所有unit用List或数组
public List<CombatStep> steps { get; protected set; }
public bool isLoaded
{
get { return unit0.mapClass != null && unit1.mapClass != null; }
}
public int stepCount
{
get { return steps.Count; }
}
private void Awake()
{
unit0 = new CombatUnit(0);
unit1 = new CombatUnit(1);
steps = new List<CombatStep>();
}
private void OnDestroy()
{
unit0.Dispose();
unit0 = null;
unit1.Dispose();
unit1 = null;
steps = null;
}
public bool LoadCombatUnit(MapClass mapClass0, MapClass mapClass1)
{
return unit0.Load(mapClass0) && unit1.Load(mapClass1);
}
public CombatUnit GetCombatUnit(int position)
{
switch (position)
{
case 0:
return unit0;
case 1:
return unit1;
default:
Debug.LogError("Combat -> GetCombatUnit: index is out of range.");
return null;
}
}
// TODO 开始战斗、计算战斗和结束战斗
}
}
当战斗开始的时候,我们要做所有关于战斗的准备工作。并且角色进入战斗状态。
创建方法:
///
/// 开始战斗
///
public void BattleBegin()
{
if (!isLoaded)
{
Debug.LogError("Combat -> StartBattle: please load combat unit first.");
return;
}
if (stepCount > 0)
{
Debug.LogError("Combat -> StartBattle: battle is not end.");
return;
}
// TODO
}
然后进行准备工作:
首先,我们要明确攻击方与防守方,这里我们按速度判断:
// 根据速度初始化攻击者与防守者
CombatUnit atker;
CombatUnit defer;
if (unit0.speed >= unit1.speed)
{
atker = unit0;
defer = unit1;
}
else
{
atker = unit1;
defer = unit0;
}
其次,我们要知道防守方是否可反击,这里用是否在装备武器攻击范围内决定:
// 是否可反击
bool canDeferAtk = false;
if (defer.role.equipedWeapon != null)
{
Vector3Int offset = defer.mapClass.cellPosition - atker.mapClass.cellPosition;
int dist = Mathf.Abs(offset.x) + Mathf.Abs(offset.y);
WeaponUniqueInfo defInfo = defer.role.equipedWeapon.uniqueInfo;
// 如果在反击范围内
if (dist >= defInfo.minRange && dist <= defInfo.maxRange)
{
canDeferAtk = true;
}
}
最后,创建我们这一步结果,并播放准备动画:
// 准备阶段
CombatStep firstStep = new CombatStep(
new CombatVariable(atker.position, atker.hp, atker.mp, true, atker.durability, CombatAnimaType.Prepare),
new CombatVariable(defer.position, defer.hp, defer.mp, canDeferAtk, defer.durability, CombatAnimaType.Prepare));
steps.Add(firstStep);
CalcBattle(firstStep.atkVal, firstStep.defVal);
CalcBattle(firstStep.atkVal, firstStep.defVal);
是计算的具体实现,这是我们接下来的工作。准备工作到这里就完成了,之后应该能够计算我们的数据。
我们在计算每一步的时候,先不要考虑过多因素,先确保能够正确运行,做一个最简单的物理攻击。
创建方法:
///
/// 计算战斗数据
///
private void CalcBattle(CombatVariable atkVal, CombatVariable defVal)
{
CombatUnit atker = GetCombatUnit(atkVal.position);
CombatUnit defer = GetCombatUnit(defVal.position);
// 攻击方动画
atkVal.animaType = CombatAnimaType.Attack;
// TODO
}
在计算时主要考虑的就是是否命中攻击(真实命中 = 攻击方命中 - 防守方回避),而其播放的动画只有三个:攻击方攻击,防守方受伤和防守方躲闪。
首先,判断是否命中:
// 真实命中率 = 攻击者命中 - 防守者回避
int realHit = atker.hit - defer.avoidance;
// 概率是否击中
int hitRate = UnityEngine.Random.Range(0, 100);
bool isHit = hitRate <= realHit;
其次,命中与躲闪;如果命中时再判断是否爆击(如果按照《FE4》应该先判断爆击,如果爆击了100%命中),然后播放动画:
if (isHit)
{
bool crit = false; // TODO 是否爆击
int realAtk = atker.atk; // TODO 爆击后伤害
// 掉血 = 攻击者攻击力 - 防守者防御力
// 最少掉一滴血
int damageHp = Mathf.Max(1, realAtk - defer.def);
defVal.hp = Mathf.Max(0, defVal.hp - damageHp);
atkVal.crit = crit;
defVal.animaType = CombatAnimaType.Damage;
}
else
{
defVal.animaType = CombatAnimaType.Evade;
}
再次,要注意武器耐久度的变化(只有玩家会减少耐久度):
// 只有玩家才会减低耐久度
if (atker.role.attitudeTowards == AttitudeTowards.Player)
{
// 攻击者武器耐久度-1
atkVal.durability = Mathf.Max(0, atkVal.durability - 1);
}
然后,完成这一步:
// 攻击者行动过了
atkVal.action = true;
CombatStep step = new CombatStep(atkVal, defVal);
steps.Add(step);
最后,交换攻击者防御者,继续下一轮战斗:
// 如果战斗没有结束,交换攻击者与防守者
if (!IsBattleEnd(atkVal, defVal))
{
if (defVal.canAtk)
{
CalcBattle(defVal, atkVal);
}
else
{
// 如果防守方不可反击
defVal.action = true;
if (!IsBattleEnd(defVal, atkVal))
{
CalcBattle(atkVal, defVal);
}
}
}
else
{
// TODO 如果死亡,播放死亡动画(我把死亡动画忘记了)
// if (defVal.isDead) 播放死亡动画
}
IsBattleEnd(atkVal, defVal)
是判断是否战斗结束的,等一下我们来完成它。
战斗结束的基本标准是有一方死亡或者行动过了,你可以自己定规则。
创建方法:
///
/// 战斗是否结束
///
///
///
///
private bool IsBattleEnd(CombatVariable atkVal, CombatVariable defVal)
{
// 防守者死亡
if (defVal.isDead)
{
return true;
}
// 如果防守者行动过了
if (defVal.action)
{
//CombatUnit atker = GetCombatUnit(atkVal.position);
//CombatUnit defer = GetCombatUnit(defVal.position);
// TODO 是否继续攻击,必要时需要在 CombatVariable 加入其它控制变量
// 比如,触发过技能或物品了
// atker.role.skill/item 包含继续战斗的技能或物品
// defer.role.skill/item 包含继续战斗的技能或物品
//if ( 已经触发过继续战斗技能或物品 )
//{
// // return true;
//}
}
return false;
}
在战斗结束时(播放动画完毕后),我们需要把改变的属性传回角色中。我们先写一个直观简单的,日后再来修改,要记得有这个工作(尤其是缺失了经验值的获取)。
创建方法:
///
/// 战斗结束
///
public void BattleEnd()
{
if (stepCount > 0)
{
CombatStep result = steps[stepCount - 1];
CombatVariable unit0Result = result.GetCombatVariable(0);
CombatVariable unit1Result = result.GetCombatVariable(1);
// TODO 经验值战利品将结果传回角色中
unit0.mapClass.OnBattleEnd(unit0Result.hp, unit0Result.mp, unit0.durability);
unit1.mapClass.OnBattleEnd(unit1Result.hp, unit1Result.mp, unit1.durability);
steps.Clear();
}
unit0.ClearMapClass();
unit1.ClearMapClass();
}
传送回角色的方法暂时命名为public void OnBattleEnd(int hp, int mp, int durability)
。
在MapClass
中和Role
中添加此方法:
///
/// 战斗结束,此方法在MapClass中
///
///
///
///
public void OnBattleEnd(int hp, int mp, int durability)
{
role.OnBattleEnd(hp, mp, durability);
if (role.isDead)
{
// TODO 死亡
}
}
///
/// 战斗结束,此方法在Role中
///
///
///
///
public void OnBattleEnd(int hp, int mp, int durability)
{
self.hp = hp;
self.mp = mp;
if (attitudeTowards == AttitudeTowards.Player)
{
equipedWeapon.durability = durability;
}
}
最后几点说明,如果你要在Inspector面板显示每次战斗结果:
在CombatStep
将属性改成字段,加上[SerializeField]
;
在Combat
中,将steps
属性更改成字段,加上[SerializeField]
;
将CombatStep
于CombatVariable
类上加上[Serializable]
;
最好自定义UnityEditor
使其成为只读属性。
// 例如在CombatStep中更改属性,这样改其它地方就不用变了:
[SerializeField]
private CombatVariable m_AtkVal;
public CombatVariable atkVal
{
get { return m_AtkVal; }
private set { m_AtkVal = value; }
}
我们来写一个文件来测试我们的数据计算是否正确。初始化的变量代码和之前的寻路测试代码差不多。
不过在这之前, 你要检查配置文件读取的信息是否正确或缺失。并为MapGraph
添加Combat
组件。
检查好之后创建测试文件:
#region ---------- File Info ----------
/// **********************************************************************
/// Copyright (C) 2018 DarkRabbit(ZhangHan)
///
/// File Name: EditorTestCombat.cs
/// Author: DarkRabbit
/// Create Time: Fri, 16 Nov 2018 17:47:31 GMT
/// Modifier:
/// Module Description:
/// Version: V1.0.0
/// **********************************************************************
#endregion ---------- File Info ----------
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace DR.Book.SRPG_Dev.CombatManagement.Testing
{
using DR.Book.SRPG_Dev.Maps;
using DR.Book.SRPG_Dev.Models;
using DR.Book.SRPG_Dev.Framework;
public class EditorTestCombat : MonoBehaviour
{
public string m_ConfigDirectory;
public MapGraph m_Map;
public Combat m_Combat;
public MapClass m_TestClassPrefab;
public bool m_DebugInfo = true;
public bool m_DebugStep = true;
private MapClass m_TestClass1;
private MapClass m_TestClass2;
#region Unity Callback
#if UNITY_EDITOR
private void Awake()
{
if (string.IsNullOrEmpty(m_ConfigDirectory))
{
m_ConfigDirectory = Application.streamingAssetsPath + "/Config";
}
ConfigLoader.rootDirectory = m_ConfigDirectory;
}
private void Start()
{
if (m_Map == null)
{
m_Map = GameObject.FindObjectOfType<MapGraph>();
}
if (m_Map == null)
{
Debug.LogError("EditorTestCombat -> Map was not found.");
return;
}
m_Combat = m_Map.gameObject.GetComponent<Combat>();
if (m_Combat == null)
{
m_Combat = m_Map.gameObject.AddComponent<Combat>();
}
m_Map.InitMap();
if (m_TestClassPrefab == null)
{
Debug.LogError("EditorTestCombat -> Class Prefab is null.");
return;
}
m_TestClass1 = m_Map.CreateMapObject(m_TestClassPrefab, new Vector3Int(5, 5, 0)) as MapClass;
m_TestClass2 = m_Map.CreateMapObject(m_TestClassPrefab, new Vector3Int(6, 5, 0)) as MapClass;
if (!m_TestClass1.Load(0, RoleType.Unique) || !m_TestClass2.Load(1, RoleType.Unique))
{
Debug.LogError("EditorTestCombat -> Load role Error.");
return;
}
ItemModel model = ModelManager.models.Get<ItemModel>();
m_TestClass1.role.AddItem(model.CreateItem(0));
m_TestClass2.role.AddItem(model.CreateItem(1));
Debug.LogFormat("{0}.hp = {1}, {0}.atk = {4}, {2}.hp = {3}, {2}.atk = {5}",
m_TestClass1.role.character.info.name,
m_TestClass1.role.hp,
m_TestClass2.role.character.info.name,
m_TestClass2.role.hp,
m_TestClass1.role.attack,
m_TestClass2.role.attack);
m_Combat.LoadCombatUnit(m_TestClass1, m_TestClass2);
m_Combat.BattleBegin();
for (int i = 0; i < m_Combat.stepCount; i++)
{
CombatVariable var0 = m_Combat.steps[i].atkVal;
CombatVariable var1 = m_Combat.steps[i].defVal;
Debug.LogFormat("({4}) -> Animation Type: ({0}, {1}), ({2}, {3})",
var0.position.ToString(),
var0.animaType.ToString(),
var1.position.ToString(),
var1.animaType.ToString(),
i);
}
m_Combat.BattleEnd();
Debug.LogFormat("{0}.hp = {1}, {2}.hp = {3}",
m_TestClass1.role.character.info.name,
m_TestClass1.role.hp,
m_TestClass2.role.character.info.name,
m_TestClass2.role.hp);
}
#endif
#endregion
}
}
创建好组件,将它挂载在一个新的物体上,并将其他测试物体设置成不显示(图 9.1)。
点击运行按钮,查看Console面板的信息是否正确(图 9.2)。
图中所显示的数据包含了生命值与攻击力,数值就像我们之前说的那样,这取决于配置文件:
主角攻击力 = 武器(id = 0, attack = 4) + 人物(id = 0, str = 3) + 职业(id = 0, str = 6) = 13
步兵敌人1攻击力 = 武器(id = 1, attack = 6) + 人物(id = 1, str = 2) + 职业(id = 0, str = 6) = 14
能够打印出信息(图 9.2),并且正确就没有问题了。