战利品表、随机地图和怪物 - 第一部分






4.97/5 (34投票s)
Boss xy 如何掉落物品 abc,稀有怪物从哪里来?
引言
这是我在 CodeProject 上的第一篇文章,所以请对我温柔点。;)
你是否曾想过,像《魔兽世界》、《Rift》以及许多其他 MMO 游戏,或者像《暗黑破坏神》这样的 H&S 游戏中的战利品系统是如何运作的?
这个问题在我脑海中萦绕了很久,直到我终于坐下来,开始逻辑性地思考它。在我思考一个这样的系统必须满足的要求时,我发现几乎所有游戏中发生的事情,从供应商出售的物品,到怪物死亡时掉落的战利品,甚至怪物的刷新(是稀有怪物还是普通怪物,任何类型的精英生物,等等)都可以归入同一类:它们是一种随机生成的…东西。即使是随机生成的地图,也无非是“掉落地图片段”。
几乎所有游戏都有“偶尔”发生的事情。如果你对“if (new Random().Next(1,10) < 5) ...
”这样的代码感到满意,那么你可能应该停止阅读了——但如果你想要更多,如果你想能够仅仅“设计”事物发生的概率,如果你不想看到if
...else if
...else if
...这样的结构在随机值之间穿梭,那么这篇文章对你来说将是一颗明珠。我非常有信心。继续吧。请阅读!
我将向你展示我用于“战利品问题”的全能解决方案,这是一个名为“RDS
”(随机分布系统)的类库,它创建递归的战利品表、概率、结果集,并拥有一系列属性来控制其行为。令我惊讶的是,完成后的库比我最初想象的要小得多。这些类精简、快速且易于理解;RDS 似乎比其各部分的总和更有价值。只需几行代码,你就可以创建奇妙的随机内容!
没有花哨的图形,没有设计器,只有核心代码为你创建战利品。完全取决于你和你的想象力,这些类能为你做什么,以及你可能想编写哪些设计器来创建这些表格(基于 SQL,基于文件,等等)。如果你能在这里分享一些你对这些类的想法,那将是非常棒的。
背景
在本文的第一部分,我将深入探讨开发 RDS 的理论。第二部分将把这一切变为现实。所以,如果你想先看看它能为你做什么,也许你想从第二部分开始,然后再详细了解第一部分。这取决于你的个人偏好,但请注意,在第二部分中你会遇到一些术语和方法,没有第一部分的内容你可能无法完全理解。
让我们来看看游戏中发生的事情,然后将其分解成技术术语。我将以两个几乎每个人都知道的游戏(《魔兽世界》和《暗黑破坏神》)为例,这样你就可以在我的描述中想象出画面。
《魔兽世界》中的例子
当《魔兽世界》中一个(普通户外)怪物死亡时,战利品看起来是这样的
- 3.56 金币
- 3 丝绸布
- 双手斧(绿色)
如果你运气好,斧头可能是蓝色甚至史诗级的。
从这次掉落中可以看出的设计要求
- “金币”掉落有随机的“数量”(例如“2 到 6 个金币”,或者更好的是:完全动态地基于一个公式,该公式包含区域等级、怪物等级、玩家等级来确定金币数量的范围)。
- 物品数量是随机的(也可能只有一两个丝绸布 -> 这不等于金币数量!金币只是加到你角色的钱包里,而布料显然是三个物品(=“silkweave:cloth”或类似名称的对象实例),因为它们会被放入你的背包,并且堆叠可以分成更小的堆叠)。
当你击杀一个团队副本中的 Boss 时(无论这是一个团队副本还是 5 人副本),你会有一些保证掉落的物品和一些随机掉落的物品。
- 252 金币
- 史诗级护甲 1(保证)
- 史诗级护甲 2(保证)
- 稀有制作图纸(随机)
- 0 到 3 个随机魔法(绿色)物品
从这次掉落中可以看出设计要求
- 我们需要能够保证特定数量的物品掉落,即使它们来自同一个战利品表(史诗级护甲)——> 不仅仅是最小数量,甚至需要能够进行次数的掉落查询。
- 我们需要能够有随机数量的掉落(0 到 3 个随机魔法物品)。
让我们把这些变成一些代码属性。
我们需要一个类来保存“表”。我们将其命名为RDSTable
。在玩家看来,这就是LootTable
。这样的表将包含一个可以掉落的物品(或更好的:对象)列表。不涉及太多细节,我们知道我们想允许开发者在这样的表中放入几乎任何物品,所以我们需要一个接口。我们为它选择名称IRDSObject
。下一步是声明我们表的内容为IENumerable<IRDSObject> rdsContents;
在我们的类中。现在我们可以将任意数量的对象放入列表中进行选择。到目前为止并不难,对吧?
好的,我们需要了解IRDSObject
的哪些信息?这里必须包含什么?我们知道,它将有一个掉落的probability
。我们知道,会涉及count
。我们知道,它可以always
掉落。但当它总是掉落时…作为对比…包含一个开关,使物品成为unique
掉落,这是否是一个好主意?即它只能作为结果出现一次?是的,这个想法很好。我们添加它。为了增加灵活性,我们还将添加一个enabled
属性,这样我们就可以按需“关闭”我们表的内容的一部分,而无需修改表本身。
目前,我们的接口将如下所示(我删除了这里的注释以使代码更紧凑。在可下载的源代码中,代码当然是完全文档化的)。
所有属性都带有rds
前缀,以便在 IntelliSense 中将它们分组,并避免命名冲突,因为“Count
”和“Enabled
”在 C# 中是相当常见的名称。随时重命名它们或使用显式接口实现。我个人更喜欢按前缀分组(因为我所有的文本框都以txt
开头,我的列表框以lst
开头,等等)。
public interface IRDSObject
{
double rdsProbability { get; set; } // The chance for this item to drop
bool rdsUnique { get; set; } // Only drops once per query
bool rdsAlways { get; set; } // Drops always
bool rdsEnabled { get; set; } // Can it drop now?
}
概率如何工作
为什么概率是double
?因为它更容易通过乘法和除法进行动态修改,例如,如果玩家角色有修饰符(如《暗黑破坏神》中强大的MagicFind),那么每个物品的掉落概率就可以在运行时与角色的MagicFind加成动态相乘。
概率既不是百分比也不是绝对值。它是一个值,用于描述相对于表中其他值的命中几率。
我给你举个简单的例子
Item 1 - Probability 1
Item 2 - Probability 1
Item 3 - Probability 1
所有三个物品将有相同的掉落几率。
Item 1 - Probability 10
Item 2 - Probability 5
Item 3 - Probability 1.5
总和为 16.5 - 如果你从这个表中计算 16 次掉落,你可能会得到 10 次物品 1,5 次物品 2,也许第 16 次会是单个物品 3。
你明白了?结果将只是取一个随机值,并在表的内容中循环,直到命中第一个大于随机值的值。这就是命中的物品。我将在本文后面解释Result
方法的确切功能(和递归)。
构建一个表
好的,那么让我们来看看我们的RDSTable
类。如果我们从一个接口开始,那么我们就可以让任何类成为我们游戏项目中的RDSTable
。我们不想给开发者肩上施加太多设计规则。如果他需要一些自己的基类启用 RDS 功能,那么他就可以做到。除了我们RDSTable
的内容之外,我们当然还需要一个结果集。正如我们在上面的例子中所见,我们期望的是多个IRDSObject
,因此Result
也将是IEnumerable<IRDSObject>
。
现在是其中一个绝妙的想法:如果IRDSTable
派生自IRDSObject
怎么办?太棒了!现在RDSTable
内容中的每个条目都可以是另一个(子)表!这是我们打到的一个 Jackpot - 我们让它递归!这使我们能够设计“主题”表,例如,我们将所有史诗级世界掉落放在一个表中(并且此表中的每个史诗级物品都有自己的概率),所有稀有物品放在第二个表中,所有绿色物品放在第三个表中,所有白色物品放在第四个表中。然后,我们设置一个“主表”,其中包含这四个表作为子表,并且每个子表都有自己的属性、概率和值。
因此,IRDSTable
的第一个版本看起来像这样。
public interface IRDSTable : IRDSObject
{
int rdsCount { get; set; } // How many items shall drop from this table?
IEnumerable<IRDSObject> rdsContents { get; } // The contents of the table
IEnumerable<IRDSObject> rdsResult { get; } // The Result set
}
Count
是IRDSTable
接口的一部分,而不是IRDSObject
的一部分,因为我们想问“这个表应该掉落多少个条目?”,而不是“这个物品掉落多少次?”。在上面的《魔兽世界》示例中(丝绸布),我们可以假设所有“布料”物品都在同一个表中,它们的掉落概率根据怪物等级公式动态计算(丝绸掉落等级在 20 到 30 之间,而魔纹布只从 31 级以上掉落),该公式只是将所有不适合该怪物等级掉落的布料类型的概率设置为零。
更多细节
在我们把一些已知的东西放入接口之后,现在我们有了一个基础,可以开始考虑细节了。
我们仍然缺少大量功能,我们无法告诉系统掉落“0 到 3 个绿色物品”,我们无法控制物品的掉落(即它们被结果评估“命中”)并且我们没有可能在结果计算发生之前立即修改概率。当然,我们对结果集在计算之后也没有控制权。
我们还缺少像金币掉落这样的东西。我们只能掉落实现IRDSObject
的对象。但我们没有值。我们先从这个开始,因为它真的很简单。我们想掉落任何类型的值。“任何类型”?好吧,泛型现在登场了。我们将一个IRDSValue<T>
接口添加到我们的模型中,它也派生自IRDSObject
,并添加了一个T Value
属性。这就是我们在结果中存储金币数量的地方。
public interface IRDSValue<T> : IRDSObject
{
T rdsValue { get; }
}
现在我们可以将整数、双精度数、字符串或任何其他对象作为“值”添加到我们的表中。
控制内容
这一步非常重要。我们需要用一些额外的功能来扩展IRDSObject
接口。我们希望能够在计算结果之前遍历表中所有项目的概率,我们希望知道项目何时被结果计算器“命中”,也许,我们甚至想有机会在整个结果集返回给调用者之前对其进行检查。
为此,我们在IRDSObject
接口中添加了一些事件,使我们能够控制这些事情。
我保留了此代码片段中事件的注释,它们很好地解释了每个事件何时发生。
/// <summary>
/// Occurs before all the probabilities of all items of the current RDSTable
/// are summed up together.
/// This is the moment to modify any settings immediately before a result is calculated.
/// </summary>
event EventHandler rdsPreResultEvaluation;
/// <summary>
/// Occurs when this RDSObject has been hit by the Result procedure.
/// (This means, this object will be part of the result set).
/// </summary>
event EventHandler rdsHit;
/// <summary>
/// Occurs after the result has been calculated and the result set is complete, but before
/// the RDSTable's Result method exits.
/// </summary>
event ResultEventHandler rdsPostResultEvaluation;
void OnRDSPreResultEvaluation(EventArgs e);
void OnRDSHit(EventArgs e);
void OnRDSPostResultEvaluation(ResultEventArgs e);
再一点理论,然后我们将最终看到结果计算的代码,它将把所有部分组合在一起。
实现接口
该库的接口层次结构非常简单,如下所示。
该库包含所有接口的完整实现。它们都以其接口的名称命名,没有前导的“I
”,因此RDSObject
类实现了IRDSObject
,RDSTable
-> IRDSTable
,依此类推。查看附件的源代码以及我构造的构造函数。
实现易于阅读且直观。
库中的关键类是RDSTable
类,它包含 RDS 使用的结果计算实现。我们将在接下来的章节中仔细研究这个核心功能。
当你使用 RDS 时,你不需要实现这些接口,只需让你的游戏对象和怪物基类派生自RDSObject
,你就可以将它们中的每一个添加到任何结果集中。
空值:RDSNullValue 类
我们还有一个遗留问题,即“0 到 3 个绿色物品”功能。我不想随机化Count
属性,我选择了更好的方法。Null
值!我们只需创建一个名为RDSNullValue : RDSObject
的类,该类可以添加到每个战利品表中,并具有自己的概率。这样,我们就可以轻松解决这个问题。我们创建绿色掉落的表,其Count
为3
,只需向表中添加一个RDSNullValue
,并赋予一个给定的概率,使其只返回“无”。这就是“0 到 3”的实现方式。
为简单起见,该表可能如下所示。
Null - Probability 1
Green Item - Probability 2
所以,理论上,每三次掉落都是一次null
掉落 - 但当然,我们会遇到三次都命中绿色物品的查询,并且会有两次甚至三次命中null
的掉落。你可以通过修改绿色物品或null
值的概率来非常容易地增加/减少“null
”几率。
RDSNullValue
类非常非常简单,但它解决了许多问题,因为它允许我们在需要时掉落“无”。
/// <summary>
/// This is the default class for a "null" entry in a RDSTable.
/// It just contains a value that is null (if added to a table of RDSValue objects),
/// but is a class as well and can be checked via a "if (obj is RDSNullValue)..." construct
/// </summary>
public class RDSNullValue : RDSValue<object>
{
public RDSNullValue(double probability)
: base(null, probability, false, false, true) { }
}
随机数生成器
哦,这是一个可以写书的话题。计算机无法创建“真正的”随机数以及所有这些东西…我不想深入探讨这场哲学讨论。是的,它们不是真正的随机,但它们或多或少是不可预测的。无论如何,我决定将这个决定推迟,并创建了一个static
类RDSRandomizer
。默认情况下,它只使用 .NET 的Random
类。如果你想使用System.Security.Cryptography
命名空间中的RNGCryptoServiceProvider
类,你也可以这样做。RDSRandomizer
类允许通过SetRandomizer()
方法交换使用的随机数生成器。你应该问自己的唯一问题是:“我真的需要它吗?”。在你的运行游戏中,没有人能分辨出怪物的掉落离“史诗物品”有多近。只要你不处理真钱赌博(如扑克软件或赌场软件)…在“普通的趣味游戏”中,一个标准的随机数生成器…嗯…足够随机了。
为了允许开发者更改使用的随机数生成器,该方法接受任何派生自 .NETRandom
类的类。Random
的几乎所有方法都是virtual
的,因为微软也有同样的想法:人们可能想改变这一点。所以,请随意创建自己的随机数生成器,只要它派生自Random
,你就可以使用SetRandomizer()
方法替换我的默认实现。
RDSRandom
具有一些在大多数游戏中随时有用的方法,这里是方法的快速概述。
public static double GetDoubleValue(double max) // From 0.0 (incl) to max (excl)
public static double GetDoubleValue(double min, double max) // From min (incl) to max (excl)
public static int GetIntValue(int max) // From 0 (incl) to max (excl)
public static int GetIntValue(int min, int max) // From min (incl) to max (excl)
// Rolls a given number of dice with a given number of sides per dice.
// Result contains as first entry the sum of the roll
// and then all the dice values
// Example: RollDice(2,6) rolls 2 6-sided dice and the result will look like this
// {9, 5, 4} ... 9 is the sum, one rolled a 5, the second one a 4
public static IEnumerable<int> RollDice(int dicecount, int sidesperdice)
// A simple method to check for any percent chance.
// The value must be between 0.0 and 1.0, so a 10% chance is NOT "10", it's "0.10"
public static bool IsPercentHit(double percent)
通过这几个简单的方法,你可以轻松完成游戏中大部分不依赖于RDSTables
的随机操作,并且还有一个很棒的补充,你可能已经将默认的 .NET 随机数生成器替换为你自己的。
结果计算如何工作
我想现在是时候解释结果计算在实现中是如何工作的了,以免造成更多困惑。所以,我将只展示Result
实际做什么,以帮助你想象。
我实现了Result
为一个 getter,是的,我知道,有些人会说这不好,但说实话,我真的很喜欢。如果你认为它更适合你的风格,随时将其转换为一个方法。
result
方法的代码已经很好地注释了,但我会在代码之后添加进一步的解释。
// Any unique drops are added here when they are hit.
// Anything contained here can not drop a second time.
private List<IRDSObject> uniquedrops = new List<IRDSObject>();
// Calculate the result
public virtual IEnumerable<IRDSObject> rdsResult
{
get
{
// The return value, a list of hit objects
List<IRDSObject> rv = new List<IRDSObject>();
uniquedrops = new List<IRDSObject>();
// Do the PreEvaluation on all objects contained in the current table
// This is the moment where those objects might disable themselves.
foreach (IRDSObject o in mcontents)
o.OnRDSPreResultEvaluation(EventArgs.Empty);
// Add all the objects that are hit "Always" to the result
// Those objects are really added always, no matter what "Count"
// is set in the table! If there are 5 objects "always", those 5 will
// drop, even if the count says only 3.
foreach (IRDSObject o in mcontents.Where(e => e.rdsAlways && e.rdsEnabled))
AddToResult(rv, o);
// Now calculate the real dropcount, this is the table's count minus the
// number of Always-drops.
// It is possible, that the remaining drops go below zero, in which case
// no other objects will be added to the result here.
int alwayscnt = mcontents.Count(e => e.rdsAlways && e.rdsEnabled);
int realdropcnt = rdsCount - alwayscnt;
// Continue only, if there is a Count left to be processed
if (realdropcnt > 0)
{
for (int dropcount = 0; dropcount < realdropcnt; dropcount++)
{
// Find the objects, that can be hit now
// This is all objects, that are Enabled and that have not
// already been added through the Always flag
IEnumerable<IRDSObject> dropables = mcontents.Where(e => e.rdsEnabled && !e.rdsAlways);
// This is the magic random number that will decide, which object is hit now
double hitvalue = RDSRandom.GetDoubleValue(dropables.Sum(e => e.rdsProbability));
// Find out in a loop which object's probability hits the random value...
double runningvalue = 0;
foreach (IRDSObject o in dropables)
{
// Count up until we find the first item that exceeds the hitvalue...
runningvalue += o.rdsProbability;
if (hitvalue < runningvalue)
{
// ...and the oscar goes too...
AddToResult(rv, o);
break;
}
}
}
}
// Now give all objects in the result set the chance to interact with
// the other objects in the result set.
ResultEventArgs rea = new ResultEventArgs(rv);
foreach (IRDSObject o in rv)
o.OnRDSPostResultEvaluation(rea);
// Return the set now
return rv;
}
}
分步解释
- 列表
uniquedrops
包含所有命中且设置为rdsUnique = true
的物品。 - 首先,我们调用当前表中所有条目的
OnRDSPreResultEvaluation
方法。这是你可以禁用条目、修改概率等的点,在随机数生成器选择“黄金值”之前,你需要做任何你需要做的事情。 - 然后,所有(启用的)设置为
rdsAlways = true
的物品都会被添加到结果集中。不需要随机数生成器…永远就是永远,但是:如果表有,比如说,Count = 5
,而你有 2 个物品设置为rdsAlways = true
,这意味着,只有另外三个物品将从表的其余部分中选取,以避免超过 5 的掉落上限。你可以在代码中找到计算realdropcount
的地方。 - 下一步是评估所有“可掉落”的物品。这是所有设置为
rdsEnabled = true
且未设置为rdsAlways = true
的物品,因为那些已经被添加了。 - 然后我们循环剩余的物品数量(
realdropcount
),并为每个物品生成一个RDSRandom
值。while
循环会一直计数,直到runningvalue
超过hitvalue
。这就是我们命中的物品。它将被添加到结果集中,并且OnRDSHit
事件将被触发(这是由下面的AddToResult
方法完成的)。 - 最后,对于结果集中的每个物品,都会触发
OnRDSPostResultEvaluation
。有时你可能想在最终返回给调用者之前查看结果集以修改它。
AddToResult
在此进行了关键操作。
- 当您设置了表-表-表-表结构时,它会创建递归。
- 它负责
rdsUnique = true
掉落。 - 它引入了迄今为止尚未显示的
RDSCreateableObject
概念(稍后解释)。
private void AddToResult(List<IRDSObject> rv, IRDSObject o)
{
if (!o.rdsUnique || !uniquedrops.Contains(o))
{
if (o.rdsUnique)
uniquedrops.Add(o);
if (!(o is RDSNullValue))
{
if (o is IRDSTable)
{
rv.AddRange(((IRDSTable)o).rdsResult);
}
else
{
// INSTANCECHECK
// Check if the object to add implements IRDSObjectCreator.
// If it does, call the CreateInstance() method and add its return value
// to the result set. If it does not, add the object o directly.
IRDSObject adder = o;
if (o is IRDSObjectCreator)
adder = ((IRDSObjectCreator)o).rdsCreateInstance();
rv.Add(adder);
o.OnRDSHit(EventArgs.Empty);
}
}
else
o.OnRDSHit(EventArgs.Empty);
}
}
分步
- 首先是唯一性检查。如果它是
rdsUnique = true
并且尚未包含在唯一列表中,则添加它。如果它已经包含,则跳过它(这就是if (!unique || !contained)...
语句)。 - 接下来是
NullValue
检查。NullValue
不会被添加到结果集中。 - 然后是递归检查。如果命中物品是另一个(子)表,则将此表的(递归的)结果
.AddRange
。在那里一切都会再次发生…事件、命中、结果。 - 如果它不是一个表,则将其添加到结果中。
在下一章中,我将解释IRDSObjectCreator
接口,这是该系统非常重要的一部分。
由于RDSNullValue
可以被命中,我决定也为NullValue
对象触发OnRDSHit
事件,即使在大多数情况下,默认的null
值将被使用,但它允许你派生自己的null
值,甚至可以在命中时对其做出反应。考虑在你的游戏中禁用某些内容,当任何掉落几率xy
结果为null
值时,也就是说“对未发生的事情做出反应”。
IRDSObjectCreator 接口
这是非常重要的一件事。你向你的表中添加引用。所以,如果你多次查询一个表,结果集中总会返回相同的引用。这对于掉落金币或其他非生命体来说并不是什么关键问题。但当你掉落生命体时,比如怪物或地图片段,这就很关键了。如果所有掉落的怪物都是同一个引用,我们就会让我们的游戏英雄很容易。如果他杀死其中一个,它们都会立即死亡 。所以我们需要为每个掉落的对象创建一个新实例。这时这个接口(或实现它的
RDSCreatableObject
类)就派上用场了。
它只提供一个方法:CreateInstance()
。这个方法当然是virtual
的,所以它可以(也应该)被覆盖。默认情况下,它只返回该对象类型的默认构造函数的new()
。
查看RDSCreateableObject
的代码以获得更好的理解。
/// <summary>
/// This class is a special derived version of an RDSObject.
/// It implements the IRDSObjectCreator interface, which can be used
/// to create custom instances of classes
/// when they are hit by the random engine.
/// The RDSTable class checks for this interface before a result is added to the result set.
/// If it is implemented, this object's CreateInstance method is called,
/// and with this tweak it is possible
/// to enter completely new instances into the result set at the moment they are hit.
/// </summary>
public class RDSCreatableObject : RDSObject, IRDSObjectCreator
{
/// <summary>
/// Creates an instance of the object where this method is implemented in.
/// Only paramaterless constructors are supported in the base implementation.
/// Override (without calling base.CreateInstance()) to instantiate more complex constructors.
/// </summary>
/// <returns>A new instance of an object of the type where this method is implemented
/// </returns>
public virtual IRDSObject rdsCreateInstance()
{
return (IRDSObject)Activator.CreateInstance(this.GetType());
}
}
如果你需要默认构造函数以外的任何东西,你应该覆盖这个方法。
现在你已经看到了 RDS 所包含的所有类和接口。对象模型也非常简单,它看起来像这样。
现在,请阅读本文的第二部分,它将重点介绍使用该库的一些有趣示例,包括随机地图、怪物生成、物品掉落,甚至游戏运行时发生的随机事件。
摘要
我们创建了一个 RDS,它允许我们做到这些事情。
- 以给定的概率,在一个递归结构中掉落任意数量的…东西。
- 掉落无。
- 在某些事情发生时响应事件(或覆盖)。
- 模拟游戏行业巨头的战利品行为。
- 添加值或引用,重新创建生命对象的实例。
- 用更复杂的东西替换默认 .net 随机数生成器的选项。
- 基本上,你可以将你游戏中所有的随机决策和几率委托给 RDS。
我们拥有制作和玩游戏所需的一切。到目前为止你还没有看到的是,这一切是如何实现的。幸运的是,有第二部分,它将正好做到这一点!
快来看看!
请在此处 继续阅读第二部分。
你的,
迈克
历史
- 2012-07-13 初稿完成
- 2012-10-05 删除了错误链接的笑脸