返回总目录
第九章 战斗系统(Combat System)
在SRPG中,大多数情况是指角色与角色之间的战斗。而这种战斗一般有两种模式:
- 地图中直接战斗;
- 有专门的战斗场景。
这两种模式的战斗在数据上没有任何区别,只有战斗动画的区别。
就像之前描述的,SRPG也是RPG,所以战斗方式和回合制RPG的战斗几乎是相同的。主要区别是RPG每个回合需要手动操作(也有自动战斗的),直到战斗结束;而SRPG是自动战斗,且多数情况只有一个回合(额外回合也是由于技能、物品或剧情需要)。且RPG多数是多人战斗,而SRPG多数是每边就一个。
我们这一章就来写一个战斗系统。
文章目录
- 第九章 战斗系统(Combat System)
-
- 三 战斗动画(Combat Animation)
-
- 1 战斗动画控制器(Combat Animation Controller)
- 2 控制器属性(Controller Properties)
- 3 播放动画(Play Animation)
-
- 3.1 播放地图中的动画(Play Animation in Map)
- 3.2 开始/结束动画事件(Start/End Event)
- 3.3 每一次行动事件(Step Event)
- 4 测试(Test)
三 战斗动画(Combat Animation)
在之前,我们已经计算出所有的战斗数据,而且每一步的动画也进行了保存。这样,在这里就可以它们为基准来播放需要的动画。
1 战斗动画控制器(Combat Animation Controller)
同样,我们创建一个组件来控制播放动画(
CombatAnimaController
):
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Events;
namespace DR.Book.SRPG_Dev.CombatManagement
{
using DR.Book.SRPG_Dev.Maps;
[DisallowMultipleComponent, RequireComponent(typeof(Combat))]
[AddComponentMenu("SRPG/Combat System/Combat Anima Controller")]
public class CombatAnimaController : MonoBehaviour
{
private Combat m_Combat;
public Combat combat
{
get
{
if (m_Combat == null)
{
m_Combat = GetComponent<Combat>();
}
return m_Combat;
}
}
// TODO 其它属性与方法
}
}
它将依赖于
Combat
组件,所以添加了Attribute(
RequireComponent(typeof(Combat))
)。
2 控制器属性(Controller Properties)
在播放每一步(或者说角色每一次行动)时,我们需要一个时间间隔。如果连续播放可能会对感官有影响。
[SerializeField]
private float m_AnimationInterval = 1f;
/// <summary>
/// 每个动画的间隔时间
/// </summary>
public float animationInterval
{
get { return m_AnimationInterval; }
set { m_AnimationInterval = value; }
}
另外,在播放时我们采用
Coroutine
(当然,你也可以使用
void Update()
,并使用参数控制播放进程)。
private Coroutine m_AnimaCoroutine;
public bool isAnimaRunning
{
get { return m_AnimaCoroutine != null; }
}
在最后,我们添加一些
Combat
的代理。
public bool isCombatLoaded
{
get { return combat.isLoaded; }
}
public bool isBattleCalced
{
get { return combat.stepCount > 0; }
}
public int stepCount
{
get { return combat.stepCount; }
}
/// <summary>
/// 初始化战斗双方
/// </summary>
/// <param name="mapClass0"></param>
/// <param name="mapClass1"></param>
/// <returns></returns>
public bool LoadCombatUnit(MapClass mapClass0, MapClass mapClass1)
{
return combat.LoadCombatUnit(mapClass0, mapClass1);
}
这些属性中,有一些不是必须的,这取决于你如何控制动画播放。
3 播放动画(Play Animation)
关于动画的播放,要了解一些情况:
- 有一些动画并不是所有的游戏中都存在的(例如躲闪和爆击动画),相当一部分都只是弹出文字提醒,除此之外防守者没有动画;
- 在地图上的攻击动画,需要注意方向问题,是使用四方向还是八方向?使用四方向时,斜线要注意朝向;
- 还要记住,游戏的UI还没有写,动画中可能要通知UI播放UI动画;
- 地图上的动画与单独场景的动画只会播放一个,取决于设置。
创建播放动画方法:
/// <summary>
/// 运行动画
/// </summary>
/// <param name="combat"></param>
/// <param name="inMap"></param>
public void PlayAnimas(bool inMap)
{
if (combat == null || !isCombatLoaded || isAnimaRunning)
{
return;
}
// 如果没有计算,则先计算
if (!isBattleCalced)
{
combat.BattleBegin();
if (!isBattleCalced)
{
Debug.LogError("CombatAnimaController -> calculate error! check the `Combat` code.");
return;
}
}
m_AnimaCoroutine = StartCoroutine(RunningAnimas(inMap));
}
RunningAnimas(inMap)
是播放动画的具体实现,我们暂时先不考虑单独场景的动画。
private IEnumerator RunningAnimas(bool inMap)
{
if (inMap)
{
// 在地图中
yield return RunningAnimasInMap();
}
else
{
// TODO 单独场景
}
m_AnimaCoroutine = null;
}
3.1 播放地图中的动画(Play Animation in Map)
RunningAnimasInMap()
是播放地图中的动画。
private IEnumerator RunningAnimasInMap()
{
CombatUnit unit0 = combat.GetCombatUnit(0);
CombatUnit unit1 = combat.GetCombatUnit(1);
List<CombatStep> steps = combat.steps;
// TODO 具体实现
}
在播放地图中的动画之前我们要先确定两个单位的方向。
- 图 9.3 战斗单位在地图中的方向
我们不说上下左右四个方向,而是看斜线方向。 (图 9.3) 标记了四个点,在一般的SRPG中,这四个点位的方向为:
- 1 右朝向(Right)
- 2 右朝向(Right)
- 3 上朝向(Up)
- 4 上朝向(Up)
你会观察到,横向距离和纵向距离的大小决定着方向,但当两个距离相等的时候方向以横向距离为准,这是因为在视觉上更合理。
基于以上建立获取方向方法:
/// <summary>
/// 获取方向
/// </summary>
/// <param name="cellPosition0"></param>
/// <param name="cellPosition1"></param>
/// <returns></returns>
protected Direction GetAnimaDirectionInMap(Vector3Int cellPosition0, Vector3Int cellPosition1)
{
Vector3Int offset = cellPosition1 - cellPosition0;
if (Mathf.Abs(offset.x) < Mathf.Abs(offset.y))
{
return offset.y > 0 ? Direction.Up : Direction.Down;
}
else
{
return offset.x > 0 ? Direction.Right : Direction.Left;
}
}
在
RunningAnimasInMap()
中填充获取方向:
Direction[] dirs = new Direction[2];
dirs[0] = GetAnimaDirectionInMap(unit0.mapClass.cellPosition, unit1.mapClass.cellPosition);
dirs[1] = GetAnimaDirectionInMap(unit1.mapClass.cellPosition, unit0.mapClass.cellPosition);
有了方向之后,我们可以播放每一次行动的动画了:
yield return null;
int curIndex = 0;
CombatStep step;
while (curIndex < steps.Count)
{
step = steps[curIndex];
// 根据动画不同,播放时间应该是不同的
// 这需要一些参数或者算法来控制
// (例如一些魔法,在配置表中加上一个特效的变量,
// 人物施法动画是这个,特效还要另算,需要计算在内)
// 这里我只是简单的定义为同时播放
float len0 = RunAniamAndGetLengthInMap(step.atkVal, step.defVal, dirs);
float len1 = RunAniamAndGetLengthInMap(step.defVal, step.atkVal, dirs);
float wait = Mathf.Max(len0, len1);
yield return new WaitForSeconds(wait);
yield return new WaitForSeconds(animationInterval);
curIndex++;
}
这之中,有一个方法
RunAniamAndGetLengthInMap
,它的作用是播放动画并获取动画的长度:
/// <summary>
/// 运行动画,并返回长度
/// </summary>
/// <param name="combat"></param>
/// <param name="actor"></param>
/// <param name="other"></param>
/// <param name="dirs"></param>
/// <returns></returns>
protected virtual float RunAniamAndGetLengthInMap(CombatVariable actor, CombatVariable other, Direction[] dirs)
{
CombatUnit actorUnit = combat.GetCombatUnit(actor.position);
if (actorUnit == null || actorUnit.mapClass == null)
{
return 0f;
}
ClassAnimatorController actorAnima = actorUnit.mapClass.animatorController;
Direction dir = dirs[actor.position];
float length = 0.5f;
switch (actor.animaType)
{
case CombatAnimaType.Prepare:
actorAnima.PlayPrepareAttack(dir, actorUnit.weaponType);
break;
case CombatAnimaType.Attack:
case CombatAnimaType.Heal:
actorAnima.PlayAttack();
length = actorAnima.GetAttackAnimationLength(dir, actorUnit.weaponType);
break;
case CombatAnimaType.Evade:
actorAnima.PlayEvade();
length = actorAnima.GetEvadeAnimationLength(dir);
break;
case CombatAnimaType.Damage:
actorAnima.PlayDamage();
length = actorAnima.GetDamageAnimationLength(dir);
// TODO 受到爆击的额外动画,假定是晃动
// if (other.crit)
// {
// CommonAnima.PlayShake(actorUnit.mapClass.gameObject);
// length = Mathf.Max(length, CommonAnima.shakeLength);
// }
break;
case CombatAnimaType.Dead:
// TODO 播放死亡动画,我把死亡忘记了
break;
default:
break;
}
return length;
}
到这里,就可以完成动画的播放了。不过不要忘记,我们在整个播放动画的过程中可能存在UI的动画;而且,动画播放结束时需要通知其它地方,我们已经结束动画播放。
3.2 开始/结束动画事件(Start/End Event)
我们希望动画在开始或结束的时候能够通知其它对象来执行其它操作,使用一个事件是比较好的方式。
常见的事件可以使用委托或者
UnityEvent
,各有利弊。我这里使用
UnityEvent
,它在
UnityEngine.Events
命名空间中。
- 使用
的好处之一是,在Inspector面板中可以可视化操作。UnityEvent
- 使用
的好处之一是,可以有返回值,比如返回Delegate
,可以更方便的配合IEnumerator
做动画处理。Coroutine
创建Unity事件:
/// <summary>
/// 当动画播放开始/结束时。
/// Args:
/// CombatAnimaController combatAnima,
/// bool inMap, // 是否是地图动画
/// </summary>
[Serializable]
public class OnAnimaPlayEvent : UnityEvent<CombatAnimaController, bool> { }
创建事件字段与属性:
[SerializeField]
private OnAnimaPlayEvent m_OnPlayEvent = new OnAnimaPlayEvent();
/// <summary>
/// 当动画播放开始时。
/// Args:
/// CombatAnimaController combatAnima,
/// bool inMap, // 是否是地图动画
/// </summary>
public OnAnimaPlayEvent onPlay
{
get
{
if (m_OnPlayEvent == null)
{
m_OnPlayEvent = new OnAnimaPlayEvent();
}
return m_OnPlayEvent;
}
set { m_OnPlayEvent = value; }
}
[SerializeField]
private OnAnimaPlayEvent m_OnStopEvent = new OnAnimaPlayEvent();
/// <summary>
/// 当动画播放结束时。
/// Args:
/// CombatAnimaController combatAnima,
/// bool inMap, // 是否是地图动画
/// </summary>
public OnAnimaPlayEvent onStop
{
get
{
if (m_OnStopEvent == null)
{
m_OnStopEvent = new OnAnimaPlayEvent();
}
return m_OnStopEvent;
}
set { m_OnStopEvent = value; }
}
在播放动画
RunningAnimas(bool inMap)
中使用它:
/// <summary>
/// 开始运行动画
/// </summary>
/// <param name="combat"></param>
/// <param name="inMap"></param>
/// <returns></returns>
private IEnumerator RunningAnimas(bool inMap)
{
onPlay.Invoke(this, inMap);
if (inMap)
{
// 在地图中
yield return RunningAnimasInMap();
}
else
{
// TODO 单独场景
}
onStop.Invoke(this, inMap);
m_AnimaCoroutine = null;
}
有了开始/结束事件之后,你可以在事件中使用我们的消息事件系统发送消息,干一些事情(例如打开一些必要的UI,结束时如果角色死亡就回收等)。
3.3 每一次行动事件(Step Event)
我们在每一次行动的时候也要通知UI进行更新,这使得我们也要在每一次行动时能够干一些其它事情(例如播放声音,UI血量变化等)。
创建行动事件:
/// <summary>
/// 当每一次行动开始/结束时。
/// Args:
/// CombatAnimaController combatAnima,
/// int index, // step的下标
/// float wait, // 每一次行动的动画播放时间
/// bool end // step的播放开始还是结束
/// </summary>
[Serializable]
public class OnAnimaStepEvent : UnityEvent<CombatAnimaController, int, float, bool> { }
[SerializeField]
private OnAnimaStepEvent m_OnStepEvent = new OnAnimaStepEvent();
/// <summary>
/// 当每一次行动开始/结束时。
/// Args:
/// CombatAnimaController combatAnima,
/// int index, // step的下标
/// float wait, // 每一次行动的动画播放时间
/// bool end // step的播放开始还是结束
/// </summary>
public OnAnimaStepEvent onStep
{
get
{
if (m_OnStepEvent == null)
{
m_OnStepEvent = new OnAnimaStepEvent();
}
return m_OnStepEvent;
}
set { m_OnStepEvent = value; }
}
我们在行动开始和结束时分别触发这个事件:
private IEnumerator RunningAnimasInMap()
{
// 省略其它代码
yield return null;
int curIndex = 0;
CombatStep step;
while (curIndex < steps.Count)
{
// 省略其它代码
onStep.Invoke(this, curIndex, wait, false);
yield return new WaitForSeconds(wait);
onStep.Invoke(this, curIndex, animationInterval, true);
yield return new WaitForSeconds(animationInterval);
curIndex++;
}
}
到这里,我们几乎完成了这个组件的编写,不过我还需要测试它。
4 测试(Test)
在
EditorTestCombat
中,我们来修改或添加代码。
- 首先,是修改字段,我们不再需要
组件,而是换成Combat
组件:CombatAnimaController
//public Combat m_Combat; private CombatAnimaController m_CombatAnimaController;
- 其次,在
中修改初始化方法(将关于void Start()
全部注释掉并替换成Combat
):CombatAnimaController
private void Start() { // 省略其它 //m_Combat = m_Map.gameObject.GetComponent<Combat>(); //if (m_Combat == null) //{ // m_Combat = m_Map.gameObject.AddComponent<Combat>(); //} // GetOrAdd 和上面注释的相同, // 只是组件换成了CombatAnimaController。 m_CombatAnimaController = Combat.GetOrAdd(m_Map.gameObject); m_CombatAnimaController.onPlay.AddListener(CombatAnimaController_onPlay); m_CombatAnimaController.onStop.AddListener(CombatAnimaController_onStop); m_CombatAnimaController.onStep.AddListener(CombatAnimaController_onStep); // 省略其它 ItemModel model = ModelManager.models.Get<ItemModel>(); m_TestClass1.role.AddItem(model.CreateItem(0)); m_TestClass2.role.AddItem(model.CreateItem(1)); // 这里原来是Combat相关代码,全部删除或注释 ReloadCombat(); }
、CombatAnimaController_onPlay
和CombatAnimaController_onStop
是对应事件:CombatAnimaController_onStep
private void CombatAnimaController_onPlay(CombatAnimaController combatAnima, bool inMap) { if (m_DebugInfo) { Debug.LogFormat("Begin Battle Animations: {0} animations", combatAnima.combat.stepCount); } } private void CombatAnimaController_onStop(CombatAnimaController combatAnima, bool inMap) { combatAnima.combat.BattleEnd(); if (m_DebugInfo) { Debug.Log("End Battle Animations"); } } private void CombatAnimaController_onStep(CombatAnimaController combatAnima, int index, float wait, bool end) { if (!m_DebugInfo || !m_DebugStep) { return; } CombatStep step = combatAnima.combat.steps[index]; CombatVariable var0 = step.GetCombatVariable(0); CombatVariable var1 = step.GetCombatVariable(1); Debug.LogFormat("({4}, {5}) -> Animation Type: ({0}, {1}), ({2}, {3})", var0.position.ToString(), var0.animaType.ToString(), var1.position.ToString(), var1.animaType.ToString(), index, end ? "End" : "Begin"); }
是读取单位:ReloadCombat
private void ReloadCombat() { if (m_DebugInfo) { Debug.Log("--------------------"); Debug.Log("Reload Combat."); } if (!m_CombatAnimaController.LoadCombatUnit(m_TestClass1, m_TestClass2)) { Debug.LogError("Reload Combat Error: Check the code."); } }
- 最后,添加
与void Update()
:void OnDestroy()
private void Update() { if (!m_Map || !m_CombatAnimaController || !m_TestClass1 || !m_TestClass2) { return; } if (Input.GetMouseButtonDown(0)) { m_CombatAnimaController.PlayAnimas(true); } if (Input.GetMouseButtonDown(1)) { ReloadCombat(); } } private void OnDestroy() { if (m_Map != null && m_CombatAnimaController != null) { m_CombatAnimaController.onPlay.RemoveListener(CombatAnimaController_onPlay); m_CombatAnimaController.onStop.RemoveListener(CombatAnimaController_onStop); m_CombatAnimaController.onStep.RemoveListener(CombatAnimaController_onStep); } }
运行游戏,查看动画是否按顺序正常播放,并查看Console面板输出是否正确(如图 9.4)。
- 图 9.4 Test Combat Animation Console
我们已经完成了大部分内容,下一节我们再回过头来看看数据的计算,因为之前只是简单的写了物理攻击的计算。