在角色扮演游戏中,设计一个干净/灵活的方式让“角色”施展不同的法术

10
我正在为了娱乐和学习经验制作一个角色扮演游戏。目前,我的角色(一个巫师)正在施放法术的阶段。在施放法术之前,我使用策略模式来设置他们将要施放的法术。我之所以采用这种方式,是因为我想能够在后面添加不同的法术类型,而不必对角色/巫师类进行修改。
我的问题是 - 这是一个不好的设计吗?有更好/更清晰/更容易的方法吗?
我试图避免成为那种试图让所有东西都适应一种设计模式的人。但在这种情况下,我觉得这是一个不错的选择。
以下是我的代码,到目前为止只有两个法术类型:
public class Wizard : Creature
{
   public List<Spell> Spells { get; set; }

   public void Cast(Spell spell, Creature targetCreature)
   {
      spell.Cast(this, targetCreature);
   }
}

public abstract class Spell
{
   public string Name { get; set; }
   public int ManaCost { get; set; }
   public Spell(string name, int manaCost)
   {
      Name = name;
      ManaCost = manaCost;
   }
   public void Cast(Creature caster, Creature targetCreature)
   {
      caster.SubtractMana(ManaCost);
      ApplySpell(caster, targetCreature);
   }
   public abstract void ApplySpell(Creature caster, Creature targetCreature);
}

// increases the target's armor by 4
public class MageArmor : Spell
{
   public MageArmor() : base("Mage Armor", 4);
   public override void ApplySpell(caster, targetCreature)
   {
      targetCreature.AddAC(4);
   }
}

// target takes 7 damage
public class FireBall : Spell
{
   public FireBall() : base("Fire Ball", 5);
   public override void ApplySpell(caster, targetCreature)
   {
      targetCreature.SubtractHealth(7);
   }
}

现在要施法,我们需要做类似这样的事情:

Wizard wizard = new Wizard();
wizard.Cast(new Spell.MageArmor(), wizard); // i am buffing myself 

更新:根据下面回答的一些建议更新了代码


1
离题:从这段代码中我所看到的是,一个没有法力值的玩家也可以施放咒语 :) - erelender
我可能会将“WizardSpells”重命名为“Spells”或“SpellBook”。 “Wizard.WizardSpells”不太符合DRY原则。 #主观 - Rob Fonseca-Ensor
你将如何处理那些不以生物为目标,而是以位置(“火墙”,“召唤”)或一组目标(“群体治愈”)为目标的咒语? - PATRY Guillaume
@PATRY 是的,Jon 也提到了这一点。但我想那会是另一个问题吧? - mikedev
你如何应用物品属性? wizard.Equip(wizard.Inventory,"披风","帽子"); - Florian Doyon
spell.Cast(this, targetCreature); 在俄罗斯,法术施于你! - Benny Jobigan
10个回答

8

根据Willcodejavaforfood的建议,您可以设计一个SpellEffect类来描述法术可能产生的单个效果。您可以创建一个“词汇表”来描述:

法术属性:

  • 名称
  • 法力值消耗
  • 整个法术的目标限制(玩家、NPC、怪物等)
  • 法术的总持续时间(最长的SpellEffect持续时间)(10秒、5个刻度等)
  • 施法时间
  • 法术范围(5米、65单位等)
  • 失败率(5%、90%)
  • 再次施放此法术之前需要等待的时间(重铸时间)
  • 任何法术再次施放之前需要等待的时间(恢复时间)
  • 等等...

SpellEffect属性:

  • 效果类型(防御、攻击、增益、减益等)
  • 效果的目标(自身、队伍、目标、目标周围区域、目标线等)
  • 效果作用的属性或统计数据(hp、mana、max hp、strength、attack speed等)
  • 效果改变的属性值大小(+10、-500、5%等)
  • 效果持续时间(10秒、5个刻度等)
  • 等等。

我想象您的词汇表(上面括号中的单词)将在一组枚举中定义。也许为了表示SpellEffect类型,创建一个类层次结构而不是使用枚举可能是明智的,因为可能有一种SpellEffect类型不需要所有这些属性,或者也许每种基本的SpellEffect类型都有一些自定义逻辑,我没有考虑到。但那也可能会使事情变得过于复杂。KISS原则=)。

无论如何,重点是您正在将法术效果的特定信息提取到单独的数据结构中。这样做的美妙之处在于,您可以创建1个Spell类并使其保存要在激活时应用的SpellEffects列表。然后,法术可以在一次射击中执行多个功能(伤害敌人和治愈玩家,即生命吸取)。为每个法术创建一个新的Spell实例。当然,在某些时候,您必须实际创建这些法术。您可以轻松地组合一个法术编辑器实用程序来使其更容易。

此外,您定义的每个SpellEffect都可以非常容易地使用System.Xml.Serialization的XmlSerializer类写入和从XML中加载。对于像SpellEffect这样的简单数据类,使用它非常轻松。您甚至可以将最终的Spell列表序列化为xml。例如:

<?xml header-blah-blah?>
<Spells>
  <Spell Name="Light Healing" Restriction="Player" Cost="100" Duration="0s"
         CastTime="2s" Range="0" FailRate="5%" Recast="10s" Recovery="5s">
    <SpellEffect Type="Heal" Target="Self" Stat="Hp" Degree="500" Duration="0s"/>
  </Spell>
  <Spell Name="Steal Haste" Restriction="NPC" Cost="500" Duration="120s"
         CastTime="10s" Range="100" FailRate="10%" Recast="15s" Recovery="8s">
    <SpellEffect Type="Buff" Target="Self" Stat="AttackSpeed" Degree="20%" Duration="120s"/>
    <SpellEffect Type="Debuff" Target="Target" Stat="AttackSpeed" Degree="-20%" Duration="60s"/>
  </Spell>
  ...
</Spells>

你也可以选择将数据放入数据库而不是xml中。Sqlite会更小、更快、更容易和免费。你还可以使用LINQ从xml或sqlite中查询你的拼写数据。
当然,对于你的怪物和其他东西,你也可以做类似的事情——至少对于它们的数据而言。我不确定逻辑部分。
如果你使用这种系统,你可以获得额外的好处,能够将你的生物/拼写系统用于其他游戏。如果你“硬编码”你的魔法,那么就做不到这一点。它还将允许您更改拼写(课程平衡、漏洞等)而无需重新构建和分发游戏可执行文件,只需要一个简单的xml文件。
哇塞!我现在真的对你的项目很感激,以及像我所描述的东西如何被实现。如果你需要任何帮助,让我知道!

2
我喜欢“然后咒语可以执行多个功能”的功能。《上古卷轴》允许您使用内置编辑器实现此功能 :) - PATRY Guillaume
哦,是啊,那将会很棒……你可以在游戏中制作自己独特的法术,而不仅仅是从一系列列表中选择。每个效果都可以有一个成本,这些成本加起来就是总法术成本。 - Benny Jobigan
ElderScroll有一个bug:如果你创建了一个消耗超过2*16法力值的咒语,那么只有溢出部分会被计算。你能说“轨道核弹咒语”吗 :) - PATRY Guillaume
非常感谢您的回答。我将尝试使用这种方法进行原型设计。 - mikedev
谢谢。您用简单的话描述了我几天来一直在尝试设计的东西。 - Matteo Bononi 'peorthyr'

3

不太清楚为什么要将其变成两个步骤,除非这将在用户界面中公开(即如果用户将设置“已装载的咒语”,并且稍后可以改变主意)。

此外,如果您确实会有一个属性而不仅仅是 wizard.Cast(new Spell.MageArmor(), wizard),那么拥有 SetSpell 方法有点奇怪 - 为什么不直接将 LoadedSpell 属性设为 public ?

最后,咒语是否实际上具有任何可变状态?您是否只能拥有一组固定的实例(享元/枚举模式)?我没有考虑内存使用情况(这是享元模式的常见原因),而是考虑其概念性质。感觉你想要的东西就像 Java 枚举 - 一组带有自定义行为的值。在 C# 中做到这一点比较困难,因为没有直接的语言支持,但仍然是可能的。

咒语内部的模式(具有施法者和目标)似乎是合理的,尽管如果您想要能够拥有区域效应咒语(具有目标位置而不是特定生物)或用于诅咒/祝福物品等的咒语时,您可能会发现它变得不太灵活。您还可能需要传递游戏世界的其余状态 - 例如,如果您有一个创建手下的咒语。


暗黑风格的法术,我喜欢它 :) - erelender
感谢您的反馈,Jon。我之所以使用SetSpell方法,是因为这是我学习策略模式的方式。我已经按照您的建议(在我的源代码中而不是在我的SO问题中)进行了清理,现在代码更加清晰易懂了。至于您最后一段话……您给了我很多思考的东西 :) - mikedev
1
我认为咒语可能确实有状态:比如持续时间较长的恢复咒语,或者需要充能时间的咒语呢? - Ed James
@Ed:好观点。我不确定是否会以相同的方式对其进行建模,但这绝对是需要考虑的事情。 - Jon Skeet
不,可能不是。更好的方法是拥有一个“Effect”对象或其他非交互式类,但这绝对是一种选择。我能想到的一个例子是像连锁闪电这样的技能,它可以在自身上简单地拥有一个“.Jump”方法,而不必每次想要跳跃时都实例化新的Spell和Effect对象! - Ed James

2
自然而然地,使用命令模式来封装“咒语”是很合适的(基本上就是你所做的)。但你会遇到两个问题:
1)你必须重新编译以添加更多的咒语
- 你可以枚举每一个咒语可能采取的所有可能行动,然后以某种外部格式(XML、数据库)定义这些咒语,这些格式在启动时加载到应用程序中。西方RPG通常是这样编码的——“咒语”由“应用咒语效果#1234,参数为1000”,“播放动画#2345”等组成。 - 你可以将游戏状态暴露给脚本语言,并编写你的咒语(你也可以将第一种想法与之结合,使得在大多数情况下,你的脚本咒语只是调用代码中预定义的效果)。《魔法风云对决》(Xbox 360上的M:TG游戏)就是用这种方法编写的。 - 或者你可以忍受它(我就是这么做的……)
2)如果你的法术目标不是生物,会发生什么?
  • 如果你正在将游戏状态暴露给你的法术脚本,那么这不是一个问题,因为你的脚本可以在你暴露的上下文中任意操作。

  • 否则,你最好使用通用类型。

我通常会做以下类似的事情(不仅仅是在游戏中,我一直在使用这种模式来表示多代理系统中的行为):

public interface IEffect<TContext>
{
  public void Apply(TContext context);
}

public class SingleTargetContext
{
  public Creature Target { get; set; }
}
public class AoEContext
{
  public Point Target { get; set; }
}
// etc.

这种模式的优点在于它非常灵活,可以完成那些更固定的模型无法完成的“奇怪”操作,这通常是你期望咒语能够完成的事情。你可以将它们链接在一起。你可以有一个效果,将触发效果添加到你的目标上——非常适合像荆棘光环这样的操作。你可以有一个可逆效果(带有额外的取消方法),用于表示增益效果。

不过,那篇关于《行星之战》的文章真的很棒。好到我会两次链接它!


2
我不会为每个咒语使用子类化。我会尝试通过使用XML或JSON将其放在磁盘上并动态创建它们。
--编辑以澄清(希望如此)--
这种方法需要尽可能提前计划。您必须定义属性,例如:
- 名称 - 描述 - 持续时间 - 目标(自身、区域、其他) - 类型(奖励、伤害、诅咒) - 效果(例如:1d6冰霜伤害,+2护甲等级,-5伤害抵抗力)
将所有这些行为包装在通用的咒语类中应该使其非常灵活,并且更加直观易懂。

1
我怎样在法术有不同行为的同时实现这一点呢?例如,魔法护甲会增加目标的护甲值,而火球术则会对目标造成伤害。 - mikedev
显然,这需要一些仔细的规划。看看纸笔RPG,你会发现每件事都被精心定义了。有不同类型的奖励、伤害等。 - willcodejavaforfood
1
如果你在角色上有通用属性,那么大多数效果可以表示为一个属性名称和属性变化的对。通过名称查找属性,将变化添加到其中。适用于治疗、伤害、护甲等等。这些聚合可能占据了你法术的90%。其他10%可以通过继承来完成,或者也许是一个更复杂的通用方案。 - Kylotan
我同意willcodejavaforfood的观点。我写了一个详细的描述,说明这个方法可能如何运作。我简直无法掩饰我的兴奋之情。 - Benny Jobigan
@Kylotan和Benny - 是的,那正是我想要的 :) - willcodejavaforfood

1
我认为这种模式最大的问题是所有法术都必须记得减去它们的法力值消耗。那么怎么样呢:
public abstract class Spell
{
   public string Name { get; set; }
   public int ManaCost { get; set; }
   public Spell(string name, int manaCost)
   {
      Name = name;
      ManaCost = manaCost;
   }

   public void Cast(Creature caster, Creature targetCreature)
   {
       caster.SubtractMana(ManaCost); //might throw NotEnoughManaException? 
       ApplySpell(caster, targetCreature);
   }

   protected abstract void ApplySpell(Creature caster, Creature targetCreature);
}

另外,Wizard应该扩展PlayerCharacter,而PlayerCharacter应该扩展Creature吗?


谢谢你。在我的源代码中,我实际上有预处理和后处理方法。我的预处理方法是检查是否有足够的法力值,但我喜欢你的做法。是的,Wizard扩展了Creature...更新的源代码。 - mikedev

1

我认为你的设计看起来很好。由于每个 Spell 类基本上都是一个围绕函数的包装器(这更恰当地属于命令模式,而不是策略模式),因此您可以完全摆脱 Spell 类,并只使用带有一些反射的函数来查找咒语方法并添加一些元数据。像这样:

public delegate void Spell(Creature caster, Creature targetCreature);

public static class Spells
{
    [Spell("Mage Armor", 4)]
    public static void MageArmor(Creature caster, Creature targetCreature)
    {
        targetCreature.AddAC(4);
    }

    [Spell("Fire Ball", 5)]
    public static void FireBall(Creature caster, Creature targetCreature)
    {
        targetCreature.SubtractHealth(7);
    }
}

当您需要添加另一个行为,例如触发动画或呈现精灵到咒语时会发生什么? - Rob Fonseca-Ensor

1
由于某种原因,“咒语”对我来说更像是一个命令模式。但我从未设计过游戏,所以……

0

我倾向于认为你的法术和物品不应该是类,而应该是效果的组合。

这是我的看法,欢迎拓展。基本上使用了组合方法和法术效果的两阶段评估,因此每个类都可以添加特定的抵抗力。

[Serializable]
class Spell
{
    string Name { get; set; }
    Dictionary<PowerSource, double> PowerCost { get; set; }
    Dictionary<PowerSource, TimeSpan> CoolDown { get; set; }
    ActionProperty[] Properties { get; set; }
    ActionEffect Apply(Wizzard entity)
    {
        // evaluate
        var effect = new ActionEffect();
        foreach (var property in Properties)
        {
            entity.Defend(property,effect);
        }

        // then apply
        entity.Apply(effect);

        // return the spell total effects for pretty printing
        return effect;
    }
}

internal class ActionEffect
{
    public Dictionary<DamageKind,double> DamageByKind{ get; set;}       
    public Dictionary<string,TimeSpan> NeutralizedActions{ get; set;}       
    public Dictionary<string,double> EquipmentDamage{ get; set;}
    public Location EntityLocation{ get; set;} // resulting entity location
    public Location ActionLocation{ get; set;} // source action location (could be deflected for example)
}

[Serializable]
class ActionProperty
{
    public DamageKind DamageKind { get;  set; }
    public double? DamageValue { get; set;}
    public int? Range{ get; set;}
    public TimeSpan? duration { get; set; }
    public string Effect{ get; set}
}

[Serializable]
class Wizzard
{
    public virtual void Defend(ActionProperty property,ActionEffect totalEffect)
    {
        // no defence   
    }
    public void Apply(ActionEffect effect)
    {
        // self damage
        foreach (var byKind in effect.DamageByKind)
        {
            this.hp -= byKind.Value;
        }
        // let's say we can't move for X seconds
        foreach (var neutralized in effect.NeutralizedActions)
        {
            Actions[neutralized.Key].NextAvailable += neutralized.Value;
        }

        // armor damage?
        foreach (var equipmentDamage in effect.EquipmentDamage)
        {
            equipment[equipmentDamage.Key].Damage += equipmentDamage.Value;
        }
    }
}

[Serializable]
class RinceWind:Wizzard
{
    public override void Defend(ActionProperty property, ActionEffect totalEffect)
    {
        // we have resist magic !
        if(property.DamageKind==DamageKind.Magic)
        {
            log("resited magic!");
            double dmg = property.DamageValue - MagicResistance;
            ActionProperty resistedProperty=new ActionProperty(property);
            resistedProperty.DamageValue = Math.Min(0,dmg);                
            return;
        }           
        base.Receive(property, totalEffect);
    }
}

我也写了一个类似的建议,但你比我早1分钟提交了。=P 不过,我更注重于XML方面的内容。 - Benny Jobigan
想要制作一个地牢类游戏吗? :D - Florian Doyon

0
首先:对于任何事情,总有更好/更干净/更简单的方法。
但在我看来,您已经很好地抽象出了您的挑战,这可以成为进一步改进的坚实基础。

0

我可能漏掉了什么,但是WizardSpells、LoadedSpell和SetSpell这个三元组似乎可以更清晰地表达。具体来说,我还没有在你的代码中看到列表被使用。我可能会将可用于巫师的咒语添加到列表中,并使用LearnNewSpell(Spell newSpell)检查LoadSpell是否使用该列表中的咒语。
此外,如果您将拥有多种类型的施法者,您可能会考虑在咒语上添加一些额外的关于施法者类型的信息。


LoadedSpell和SetSpell并不需要,但我把它们放在那里是因为最近学习策略模式时是这样做的。它已经从我的源代码中删除了(但没有从问题中删除)。沿着你的思路,WizardSpells是角色当前知道的法术列表。但在这个例子中没有使用它。 - mikedev
如果你需要进一步的建议,可能需要更新问题。 - Rob Fonseca-Ensor
好的,已经在问题中更新了源代码。 - mikedev

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接