2. 规则与掷骰¶
在 EvAdventure 中,我们决定使用 Knave RPG 规则集。虽然这是商业产品,但根据创作共享 4.0 许可证发布,意味着可以出于任何目的共享和改编 Knave,甚至是商业用途。如果您不想购买但仍然想要跟随,可以在 这里找到一个免费的粉丝版。
2.2. 创建规则模块¶
创建一个新模块
mygame/evadventure/rules.py
大多数 RPG 有三大类规则:
角色生成规则,通常仅在角色创建时使用
常规游戏规则 - 掷骰和解决游戏场景
角色提升 - 获得和消费经验以提升角色
我们希望我们的 rules
模块涵盖尽可能多的方面,以便我们不必查阅规则书。
2.3. 掷骰¶
我们将首先制作一个掷骰器。让我们将所有掷骰逻辑组合在一个像这样的结构中(尚未实现的代码):
class EvAdventureRollEngine:
def roll(...):
# 获取某种类型和数量骰子的结果
def roll_with_advantage_or_disadvantage(...)
# 获取正常 d20 掷骰的结果,带有优势/劣势(或不带)
def saving_throw(...):
# 针对特定目标数值进行豁免检定
def opposed_saving_throw(...):
# 针对目标的防御进行对抗豁免检定
def roll_random_table(...):
# 针对随机表进行掷骰(稍后加载)
def morale_check(...):
# 对目标进行 2d6 士气检定
def heal_from_rest(...):
# 在休息时恢复 1d8,但不超过最大值。
def roll_death(...):
# 在 HP 降到 0 时掷骰确定惩罚。
dice = EvAdventureRollEngine()
这种结构(称为 单例)使我们将所有掷骰逻辑边集中在一个类中,然后在模块底部将其初始化为变量 dice
。这意味着我们可以在其他模块中这样做:
from .rules import dice
dice.roll("1d8")
2.3.1. 通用掷骰器¶
我们想能够执行 roll("1d20")
并从掷骰中返回一个随机结果。
# 在 mygame/evadventure/rules.py
from random import randint
class EvAdventureRollEngine:
def roll(self, roll_string):
"""
掷 XdY 骰子,其中 X 是骰子数量
Y 是每个骰子的面数。
参数:
roll_string (str): 形式为 XdY 的掷骰字符串。
返回:
int: 掷骰的结果。
"""
# 在 'd' 上分割 XdY 输入,一次性分割
number, diesize = roll_string.split("d", 1)
# 从字符串转换为整数
number = int(number)
diesize = int(diesize)
# 执行掷骰
return sum(randint(1, diesize) for _ in range(number))
randint
标准 Python 库模块在特定范围内生成随机整数。行
sum(randint(1, diesize) for _ in range(number))
的工作原理如下:
针对某个
number
次 …… 创建一个介于
1
和diesize
之间的随机整数 …… 并将所有这些整数的总和计算出来。
你可以以更不紧凑的方式写同样的东西:
rolls = []
for _ in range(number):
random_result = randint(1, diesize)
rolls.append(random_result)
return sum(rolls)
我们不期望最终用户调用此方法;如果真的如此,我们需要进行更多的输入验证 - 我们必须确保 number
或 diesize
是有效的输入,而不是疯狂的大,以免循环无限进行下去!
2.3.2. 使用优势掷骰¶
现在我们有了通用掷骰器,我们可以开始使用它进行更复杂的掷骰。
# 在 mygame/evadventure/rules.py
# ...
class EvAdventureRollEngine:
def roll(roll_string):
# ...
def roll_with_advantage_or_disadvantage(self, advantage=False, disadvantage=False):
if not (advantage or disadvantage) or (advantage and disadvantage):
# 正常掷骰 - 优势/劣势未设定或相互抵消
return self.roll("1d20")
elif advantage:
# 两次 d20 掷骰中较高的
return max(self.roll("1d20"), self.roll("1d20"))
else:
# 劣势 - 两次 d20 掷骰中较低的
return min(self.roll("1d20"), self.roll("1d20"))
min()
和 max()
函数是用来获取两个参数中最大/最小值的标准 Python 函数。
2.3.3. 豁免检定¶
我们希望豁免检定能够自己判断是否成功。这意味着它需要知道能力加值(如 STR +1
)。如果我们能仅通过将执行豁免检定的实体传递给这个方法,告诉他们需要什么类型的掷骰,并让它自行解决,那会很方便:
result, quality = dice.saving_throw(character, Ability.STR)
返回将是一个布尔值 True/False
,表示是否通过,以及一个 quality
值,告知我们是完美失败/成功。
为了让这个豁免检定方法更智能,我们需要再次思考希望如何存储角色数据。
对于我们的目的,使用 属性 来存储能力分数似乎合乎逻辑。为了方便,我们将其命名为与之前课程中设置的 枚举值 相同。因此,如果我们有一个枚举 STR = "strength"
,我们希望将该能力存储为角色的属性 strength
。
根据属性文档,我们可以使用 AttributeProperty
,使属性可通过 character.strength
访问,这就是我们将要做的。
简而言之,我们将创建豁免检定方法,假设我们能够通过 character.strength
、character.constitution
、character.charisma
等等来获得相关的能力值。
# 在 mygame/evadventure/rules.py
# ...
from .enums import Ability
class EvAdventureRollEngine:
def roll(...)
# ...
def roll_with_advantage_or_disadvantage(...)
# ...
def saving_throw(self, character, bonus_type=Ability.STR, target=15,
advantage=False, disadvantage=False):
"""
进行豁免检定,尝试超越一个目标。
参数:
character (Character): 一个角色(假设拥有以属性形式存储的能力加值)。
bonus_type (Ability): 一个有效能力加值枚举。
target (int): 目标数字。Knave 中始终为 15。
advantage (bool): 如果角色在此掷骰上有优势。
disadvantage (bool): 如果角色在此掷骰上有劣势。
返回:
tuple: 一个元组 (bool, Ability),指示是否成功并且质量为 None 或 Ability.CRITICAL_FAILURE/SUCCESS之一
"""
# 掷骰
dice_roll = self.roll_with_advantage_or_disadvantage(advantage, disadvantage)
# 确定我们是否有关键失败/成功
quality = None
if dice_roll == 1:
quality = Ability.CRITICAL_FAILURE
elif dice_roll == 20:
quality = Ability.CRITICAL_SUCCESS
# 确定加值
bonus = getattr(character, bonus_type.value, 1)
# 返回一个元组 (bool, quality)
return (dice_roll + bonus) > target, quality
getattr(obj, attrname, default)
函数是一个非常有用的 Python 工具,用于从对象获取属性,并在未定义该属性时获取默认值。
2.3.4. 对抗豁免检定¶
利用我们已经创建的基础,这个方法很简单。记住你必须超越的防御总是相关的加值 + 10。在 Knave 中,因此,如果敌人的防御是 STR +3
,你必须掷出高于 13
的结果。
# 在 mygame/evadventure/rules.py
from .enums import Ability
class EvAdventureRollEngine:
def roll(...):
# ...
def roll_with_advantage_or_disadvantage(...):
# ...
def saving_throw(...):
# ...
def opposed_saving_throw(self, attacker, defender,
attack_type=Ability.STR, defense_type=Ability.ARMOR,
advantage=False, disadvantage=False):
defender_defense = getattr(defender, defense_type.value, 1) + 10
result, quality = self.saving_throw(attacker, bonus_type=attack_type,
target=defender_defense,
advantage=advantage, disadvantage=disadvantage)
return result, quality
2.3.5. 士气检定¶
我们将假设 morale
值可以直接从生物体获取,作为 monster.morale
,- 我们稍后需要记住这样做!
在 Knave 中,生物体进行 2d6
的掷骰,如果结果等于或小于其士气,则不会逃跑或投降。标准士气值为 9。
# 在 mygame/evadventure/rules.py
class EvAdventureRollEngine:
# ...
def morale_check(self, defender):
return self.roll("2d6") <= getattr(defender, "morale", 9)
2.3.6. 治疗检定¶
为了能够处理治疗,我们需要对游戏实体的健康存储方式做出一些假设。我们需要 hp_max
(可用 HP 的总量)和 hp
(当前健康值)。我们假设这些将作为 obj.hp
和 obj.hp_max
可访问。
根据规则,角色在消耗配给,并有整夜的睡眠后,会恢复 1d8 + CON
HP。
# 在 mygame/evadventure/rules.py
from .enums import Ability
class EvAdventureRollEngine:
# ...
def heal_from_rest(self, character):
"""
一晚休息恢复 1d8 + CON HP
"""
con_bonus = getattr(character, Ability.CON.value, 1)
character.heal(self.roll("1d8") + con_bonus)
我们在这里做另一个假设 - character.heal()
是可执行的。我们告诉这个函数角色应该恢复多少健康,它会处理这一过程,确保不会恢复超过其最大 HP 的值。
了解可用的角色内容和需要的规则掷骰有点像“鸡和蛋”的问题。我们将确保在下一课程中实现匹配的 Character 类。
2.3.7. 在表上掷骰¶
我们偶尔需要在一个“表”上掷骰 - 一系列选择。我们需要支持两种主要表类型:
简单的每行一个元素的表(每个结果的几率相同):
结果 |
---|
item1 |
item2 |
item3 |
item4 |
这将简单地表示为一个普通的列表:
["item1", "item2", "item3", "item4"]
每个项目的范围(每个结果几率各不相同):
范围 |
结果 |
---|---|
1-5 |
item1 |
6-15 |
item2 |
16-19 |
item3 |
20 |
item4 |
这将表示为一个元组列表:
[("1-5", "item1"), ("6-15", "item2"), ("16-19", "item4"), ("20", "item5")]
我们还需要知道为获得表的结果而掷的骰子(这可能并不总是显而易见的,在某些游戏中,可能会请求更低的骰子来仅获得早期表结果等)。
# 在 mygame/evadventure/rules.py
from random import randint, choice
class EvAdventureRollEngine:
# ...
def roll_random_table(self, dieroll, table_choices):
"""
参数:
dieroll (str): 一个骰子掷骰字符串,如 "1d20"。
table_choices (iterable): 一个简单元素列表或元组列表。
返回:
Any: 从给定选择列表中随机结果。
引发:
RuntimeError: 如果掷骰结果在表之外。
"""
roll_result = self.roll(dieroll)
if isinstance(table_choices[0], (tuple, list)):
# 第一个元素是元组/列表;按 [("1-5", "item"),...] 形式对待
for (valrange, choice) in table_choices:
minval, *maxval = valrange.split("-", 1)
minval = abs(int(minval))
maxval = abs(int(maxval[0]) if maxval else minval)
if minval <= roll_result <= maxval:
return choice
# 如果达到这里,意味着我们设定了一个产生超出表界限的掷骰 - 抛出错误
raise RuntimeError("roll_random_table: Invalid die roll")
else:
# 简单的普通列表
roll_result = max(1, min(len(table_choices), roll_result))
return table_choices[roll_result - 1]
确保你理解这段代码的作用。
这可能会令人困惑:
minval, *maxval = valrange.split("-", 1)
minval = abs(int(minval))
maxval = abs(int(maxval[0]) if maxval else minval)
如果 valrange
是字符串 1-5
,那么 valrange.split("-", 1)
将会生成元组 ("1", "5")
。但如果字符串实际上是 "20"
(对于 RPG 表中的单个条目来说是可能的),这将导致错误,因为它只会分割出一个元素 - 而我们期望两个。
通过使用 *maxval
(带有 *
),maxval
被告知期望 0 或多个 元素在元组中。因此 1-5
的结果将是 ("1", ("5",))
,而 20
的结果将变为 ("20", ())
。在这一行:
maxval = abs(int(maxval[0]) if maxval else minval)
我们检查 maxval
是否实际上有一个值 ("5",)
,或者是空的 ()
。结果是 maxval
要么是 "5"
,要么是 minval
的值。
2.3.8. 死亡检定¶
虽然原版 Knave 建议 HP 降到 0 意味着立刻死亡,但我们将利用 Knave 的“美化版”可选规则的“死亡表”,使其变得稍微不那么惩罚性。我们还将将 2
的结果更改为“死亡”,因为我们在本教程中没有模拟“肢体残缺”:
掷骰 |
结果 |
-1d4 能力损失 |
---|---|---|
1-2 |
死亡 |
- |
3 |
虚弱 |
STR |
4 |
不稳 |
DEX |
5 |
疲弱 |
CON |
6 |
混乱 |
INT |
7 |
不安 |
WIS |
8 |
面部畸形 |
CHA |
所有非死亡值映射到某项六项能力中的 1d4 损失(但你会恢复 HP)。我们需要根据上述表进行映射。一个能力加值不能低于 -10,如果你低于这个值,你也会死亡。
# 在 mygame/evadventure/rules.py
death_table = (
("1-2", "dead"),
("3", "strength"),
("4", "dexterity"),
("5", "constitution"),
("6", "intelligence"),
("7", "wisdom"),
("8", "charisma"),
)
class EvAdventureRollEngine:
# ...
def roll_random_table(...)
# ...
def roll_death(self, character):
ability_name = self.roll_random_table("1d8", death_table)
if ability_name == "dead":
# TODO - 杀死角色!
pass
else:
loss = self.roll("1d4")
current_ability = getattr(character, ability_name)
current_ability -= loss
if current_ability < -10:
# TODO - 杀死角色!
pass
else:
# 恢复 1d4 健康,但造成 1d4 能力损失
self.heal(character, self.roll("1d4"))
setattr(character, ability_name, current_ability)
character.msg(
"你在与死亡的较量中幸存下来,尽管你恢复了一些健康,"
f"但永久失去了 {loss} {ability_name}。"
)
dice = EvAdventureRollEngine()
在这里,我们根据规则在“死亡表”上掷骰以查看会发生什么。如果他们幸存下来,我们给角色发送一条消息,让他们知道事情的经过。
我们目前还不清楚“杀死角色”的具体含义,因此将其标记为 TODO
,等待在以后的课程中处理。我们只知道在这里需要 做点什么 来结束角色的生命!
2.4. 测试¶
创建一个新模块
mygame/evadventure/tests/test_rules.py
测试 rules
模块也将展示一些在测试时非常有用的工具。
# 在 mygame/evadventure/tests/test_rules.py
from unittest.mock import patch
from evennia.utils.test_resources import BaseEvenniaTest
from .. import rules
class TestEvAdventureRuleEngine(BaseEvenniaTest):
def setUp(self):
"""在每个测试方法执行前调用"""
super().setUp()
self.roll_engine = rules.EvAdventureRollEngine()
@patch("evadventure.rules.randint")
def test_roll(self, mock_randint):
mock_randint.return_value = 4
self.assertEqual(self.roll_engine.roll("1d6"), 4)
self.assertEqual(self.roll_engine.roll("2d6"), 2 * 4)
# 其他规则方法的测试 ...
如前所述,运行特定的测试使用命令:
evennia test --settings settings.py evadventure.tests.test_rules
2.4.1. 模拟与修补¶
setUp
方法是测试类的特殊方法。在每个测试方法运行之前,它将执行。我们使用 super().setUp()
确保父类的此方法版本总是执行。然后我们创建一个新的 EvAdventureRollEngine
实例进行测试。
在我们的测试中,我们从 unittest.mock
库引入 patch
。这是一个非常有用的测试工具。通常,我们在 rules
中导入的 randint
将返回一个随机值。由于每次测试的值都不同,这很难进行测试。
通过使用 @patch
(这称为 装饰器),我们暂时将 rules.randint
替换为一个“模拟” - 一个虚假的实体。这个模拟会被传递给测试方法。然后我们在这个 mock_randint
上设置 .return_value = 4
。
为模拟添加 return_value
意味着每次调用该模拟时,它将返回 4。在测试期间,我们现在可以检查 self.assertEqual
,确保我们的 roll
方法始终返回一个结果,就像随机结果是 4 一样。
还有很多资源可以帮助理解 mock 的使用,可以参考 这篇文章 获得进一步帮助。
EvAdventureRollEngine
有很多方法需要测试。我们将这视为额外的练习!
2.5. 小结¶
这结束了 Knave 的所有核心规则机制 - 游戏进行中的规则。我们注意到,我们即将需要确定我们的 Character 实际如何存储数据。所以我们将在下一个课程中解决这个问题。