5. 处理装备

在 Knave 中,你有一定数量的库存“插槽”。这些插槽的数量由 CON + 10 决定。所有物品(除了硬币)都有一个 size,表示它占用多少插槽。你不能携带超过你拥有的插槽空间的物品。被使用或穿戴的物品也会计入插槽。

我们需要跟踪角色正在使用的物品:他们准备好的武器会影响他们造成的伤害。盾牌、头盔和盔甲会影响他们的防御。

我们在定义对象时已经设置了可能的“穿戴/使用位置”,这些是在前一节中定义的 enums.py 中:

# mygame/evadventure/enums.py

# ...

class WieldLocation(Enum):
    
    BACKPACK = "backpack"
    WEAPON_HAND = "weapon_hand"
    SHIELD_HAND = "shield_hand"
    TWO_HANDS = "two_handed_weapons"
    BODY = "body"  # 装甲
    HEAD = "head"  # 头盔

基本上,所有武器/盔甲位置都是独占的——每个位置只能放一个物品(或者没有)。BACKPACK 是特别的——它可以包含任意数量的物品(最多使用插槽的上限)。

5.1. EquipmentHandler 的实现

创建一个新模块 mygame/evadventure/equipment.py。

在默认的 Evennia 中,你捡起的所有东西都会“在”你的角色对象内(即,具有你的 .location)。这叫做你的 库存,没有限制。我们将继续在我们捡起物品时“将物品移动到我们身上”,但我们将通过一个 Equipment handler 添加更多功能。

处理程序是(就我们的目的而言)一个坐在另一个实体上的对象,包含执行特定功能的能力(在我们的例子中是管理装备)。

这是我们处理程序的起始代码:

# 在 mygame/evadventure/equipment.py 中 

from .enums import WieldLocation

class EquipmentHandler: 
    save_attribute = "inventory_slots"
    
    def __init__(self, obj): 
        # 这里的 obj 是我们在其上存储处理程序的角色 
        self.obj = obj 
        self._load() 
        
    def _load(self):
        """从 `self.obj` 的属性中加载我们的数据"""
        self.slots = self.obj.attributes.get(
            self.save_attribute,
            category="inventory",
            default={
                WieldLocation.WEAPON_HAND: None, 
                WieldLocation.SHIELD_HAND: None, 
                WieldLocation.TWO_HANDS: None, 
                WieldLocation.BODY: None,
                WieldLocation.HEAD: None,
                WieldLocation.BACKPACK: []
            } 
        )
    
    def _save(self):
        """将我们的数据保存回同一个属性"""
        self.obj.attributes.add(self.save_attribute, self.slots, category="inventory") 

这是一个简洁且功能齐全的小型处理程序。在分析它的工作原理之前,下面是我们如何将其添加到字符中的:

# mygame/evadventure/characters.py

# ... 

from evennia.utils.utils import lazy_property
from .equipment import EquipmentHandler 

# ... 

class EvAdventureCharacter(LivingMixin, DefaultCharacter):
    
    # ... 

    @lazy_property 
    def equipment(self):
        return EquipmentHandler(self)

服务器重新加载后,装备处理器现在可以通过字符实例访问:

character.equipment

@lazy_property 的工作原理是,直到有人实际尝试通过 character.equipment 来获取它时,它才会加载处理器。当这一点发生时,我们启动处理器并传入 self(字符实例本身)。这就是上面 EquipmentHandler 代码中的 __init__ 中的 .obj。

所以现在我们在角色上有一个处理器,而处理器也具有指向它所在角色的回指。

由于处理器本身只是一个常规的 Python 对象,我们需要使用 Character 来存储我们的数据——Knave 的“插槽”。我们必须将其保存到数据库中,因为我们希望服务器在重新加载后仍然记住它们。

使用 self.obj.attributes.add() 和 .get() 方法,我们将数据保存到具有特殊命名的 Attribute 中。由于我们使用了 category,因此与其他属性发生冲突的可能性很小。

我们的存储结构是一个 dict,其中的键基于我们可用的 WieldLocation 枚举,每个位置只能有一个物品,除了 WieldLocation.BACKPACK 位置,这里是一个列表。

5.2. 连接 EquipmentHandler

每当一个物体从一个位置移动到另一个位置时,Evennia 会在移动的对象、源位置和目的地上调用一组 hooks(方法)。所有移动的事物都是如此——无论是角色在房间之间移动还是物品从你的手中掉落到地面。

我们需要将新的 EquipmentHandler 绑定到这个系统中。通过阅读对象页面或查看 DefaultObject.move_to 的文档,我们将找到 Evennia 将会调用的 hooks。这里的 self 是移动的对象,从 source_location 到 destination:

  1. self.at_pre_move(destination) (如果返回 False,则中止)

  2. source_location.at_pre_object_leave(self, destination) (如果返回 False,则中止)

  3. destination.at_pre_object_receive(self, source_location) (如果返回 False,则中止)

  4. source_location.at_object_leave(self, destination)

  5. self.announce_move_from(destination)

  6. (这里发生移动)

  7. self.announce_move_to(source_location)

  8. destination.at_object_receive(self, source_location)

  9. self.at_post_move(source_location)

所有这些 hooks 都可以被重写以自定义移动行为。在这种情况下,我们有兴趣控制物品如何“进入”和“离开”我们的角色——在角色“内部”的东西就等同于他们“携带”它。我们有三个好的钩子可以使用。

  • .at_pre_object_receive - 用于检查你是否可以捡起某物,或者如果你的装备空间已满。

  • .at_object_receive - 用于将项目添加到 EquipmentHandler 中。

  • .at_object_leave - 用于从 EquipmentHandler 中移除物品。

你也可以想象使用 .at_pre_object_leave 来限制放置(被诅咒的?)物品,但我们将跳过这部分以简化教程。

# mygame/evadventure/characters.py 

# ... 

class EvAdventureCharacter(LivingMixin, DefaultCharacter): 

    # ... 
    
    def at_pre_object_receive(self, moved_object, source_location, **kwargs): 
        """在 Evennia 将对象放入此角色之前调用(也就是说,
        如果他们捡起某样东西)。如果返回 False,则移动被中止。
        
        """ 
        return self.equipment.validate_slot_usage(moved_object)
    
    def at_object_receive(self, moved_object, source_location, **kwargs): 
        """ 
        当对象“到达”角色时由 Evennia 调用。
        
        """
        self.equipment.add(moved_object)

    def at_object_leave(self, moved_object, destination, **kwargs):
        """ 
        当对象离开角色时由 Evennia 调用。 
        
        """
        self.equipment.remove(moved_object)

在上面,我们假设 EquipmentHandler(self.equipment)具有方法 .validate_slot_usage、.add 和 .remove。但我们还没有实际添加这些方法——我们只是给出了几个合理的名称!在我们可以使用这些方法之前,我们需要实际添加它们。

当你执行诸如 create/drop monster:NPC 的操作时,NPC 会短暂地出现在你的库存中,然后再被放下。因此,NPC 不是可以装备的有效物品,EquipmentHandler 会对此抱怨并引发 EquipmentError(我们在下面定义这个错误)。所以我们需要

5.3. 扩展 EquipmentHandler

5.3.1. .validate_slot_usage

让我们从实现我们上面所提到的第一个方法 validate_slot_usage 开始:

# mygame/evadventure/equipment.py 

from .enums import WieldLocation, Ability

class EquipmentError(TypeError):
    """所有类型的装备错误"""
    pass

class EquipmentHandler: 

    # ... 
    
    @property
    def max_slots(self):
        """基于 CON 防御的最大插槽数量(CON + 10)""" 
        return getattr(self.obj, Ability.CON.value, 1) + 10
        
    def count_slots(self):
        """计算当前的插槽使用情况""" 
        slots = self.slots
        wield_usage = sum(
            getattr(slotobj, "size", 0) or 0
            for slot, slotobj in slots.items()
            if slot is not WieldLocation.BACKPACK
        )
        backpack_usage = sum(
            getattr(slotobj, "size", 0) or 0 for slotobj in slots[WieldLocation.BACKPACK]
        )
        return wield_usage + backpack_usage
    
    def validate_slot_usage(self, obj):
          """
          检查 obj 是否可以适合装备中,基于其大小。
          
          """
          if not inherits_from(obj, EvAdventureObject):
              # 在切换非 evadventure 对象时
              raise EquipmentError(f"{obj.key} 不是可以装备的物品。")
  
         size = obj.size
         max_slots = self.max_slots
         current_slot_usage = self.count_slots()
         return current_slot_usage + size <= max_slots

我们添加了两个辅助函数——max_slots 属性 和 count_slots,它是用于计算当前使用插槽数量的方法。让我们找出它们的工作原理。

5.3.2. .max_slots

对于 max_slots,请记住处理程序中的 .obj 是我们将此处理程序放置到的 EvAdventureCharacter 的回指。getattr 是一个用于检索对象上命名属性的 Python 方法。Enum 的 Ability.CON.value 是字符串 Constitution(如果你不记得,可以查阅第一次实用程序与枚举教程)。

所以为了更清楚,

getattr(self.obj, Ability.CON.value) + 10

等同于写:

getattr(your_character, "Constitution") + 10 

这与执行以下操作相同:

your_character.Constitution + 10 

在我们的代码中,我们写了 getattr(self.obj, Ability.CON.value, 1) - 这个额外的 1 意味着如果在 self.obj 上不存在属性 “Constitution”,我们就不会出现错误,而是返回 1。

5.3.3. .count_slots

在这个辅助方法中,我们使用了两种 Python 工具——sum() 函数和 列表推导。前者简单地将任何可迭代对象的值相加。后者是一种更有效的创建列表的方式:

new_list = [item for item in some_iterable if condition]
all_above_5 = [num for num in range(10) if num > 5]  # [6, 7, 8, 9]
all_below_5 = [num for num in range(10) if num < 5]  # [0, 1, 2, 3, 4]

为了更容易理解,尝试将上面的最后一行视为“对于范围 0-9 中的每个数字,选择所有小于 5 的数字并将它们制作成列表”。你也可以将这种推导直接嵌入函数调用中,如 sum(),而无需使用 [] 括起来。

在 count_slots 中,我们有这段代码:

wield_usage = sum(
    getattr(slotobj, "size", 0)
    for slot, slotobj in slots.items()
    if slot is not WieldLocation.BACKPACK
)

我们应该能够跟踪所有内容,除了 slots.items()。由于 slots 是一个 dict,我们可以使用 .items() 获取 (key, value) 对的序列。我们将其存储在 slot 和 slotobj 中。因此,上述内容可以理解为:“对 slots 中的每一对 slot 和 slotobj,检查它是哪个插槽位置。如果它 不是 背包,则获取其大小并将其添加到列表中。对所有这些大小求和”。

一种更不紧凑但可能更易理解的写法是:

backpack_item_sizes = [] 
for slot, slotobj in slots.items(): 
    if slot is not WieldLocation.BACKPACK:
       size = getattr(slotobj, "size", 0) 
       backpack_item_sizes.append(size)
wield_usage = sum(backpack_item_sizes)

对于实际上在 BACKPACK 插槽中的物品也是如此。总的尺寸被加在一起。

5.3.4. 验证插槽

有了这些辅助方法,validate_slot_usage 现在变得简单。我们使用 max_slots 查看我们可以携带多少。然后我们获取当前使用的插槽数量(使用 count_slots)并查看我们新的 obj 的大小是否超出了我们的容量。

5.4. .add 和 .remove

我们将使 .add 方法将物品放入 BACKPACK 位置,并使 remove 方法从任何地方(即使是手中)将其删除。

# mygame/evadventure/equipment.py 

from .enums import WieldLocation, Ability

# ... 

class EquipmentHandler: 

    # ... 
     
    def add(self, obj):
        """
        将物品放入背包中。
        """
        if self.validate_slot_usage(obj):
	        self.slots[WieldLocation.BACKPACK].append(obj)
	        self._save()

    def remove(self, obj_or_slot):
        """
        从一个插槽中移除特定物品或物品。

        返回从库存中移除的 0 个、1 个或多个物品的列表。
        """
        slots = self.slots
        ret = []
        if isinstance(obj_or_slot, WieldLocation):
            # 一个插槽;如果这失败,obj_or_slot 必须是物品
            if obj_or_slot is WieldLocation.BACKPACK:
                # 清空整个背包
                ret.extend(slots[obj_or_slot])
                slots[obj_or_slot] = []
            else:
                ret.append(slots[obj_or_slot])
                slots[obj_or_slot] = None
        elif obj_or_slot in self.slots.values():
            # 物品在使用/穿戴槽
            for slot, objslot in slots.items():
                if objslot is obj_or_slot:
                    slots[slot] = None
                    ret.append(objslot)
        elif obj_or_slot in slots[WieldLocation.BACKPACK]:  # 物品在背包槽中
            try:
                slots[WieldLocation.BACKPACK].remove(obj_or_slot)
                ret.append(obj_or_slot)
            except ValueError:
                pass
        if ret:
            self._save()
        return ret

在 .add 中,我们利用 validate_slot_usage 进行双重检查,以确保我们确实可以装下该物品,然后将其添加到背包中。

在 .remove 中,我们允许通过 WieldLocation 或显式指出要移除的对象来清空。注意,首先的 if 语句检查 obj_or_slot 是否是一个插槽。所以如果失败,那么其他的 elif 语句可以安全地假设它必须是一个对象!

任何被移除的物体都将被返回。如果我们给了 BACKPACK 作为插槽,我们将清空背包并返回其内部所有的物品。

每当我们更改装备配置时,我们必须确保 _save() 结果,否则在服务器重新加载后它将丢失。

5.5. 移动物品

借助 .remove() 和 .add(),我们可以将物品放入 BACKPACK 装备位置并将其取出。我们还需要从背包中抓取物品并使用或穿戴它。我们在 EquipmentHandler 中添加一个 .move 方法来实现这一点:

# mygame/evadventure/equipment.py 

from .enums import WieldLocation, Ability

# ... 

class EquipmentHandler: 

    # ... 
    
    def move(self, obj): 
         """将物体从背包移动到其预期的 `inventory_use_slot`。""" 
         
        # 确保首先从装备/背包中移除,以避免双重添加
        self.remove(obj) 
        if not self.validate_slot_usage(obj):
            return

        slots = self.slots
        use_slot = getattr(obj, "inventory_use_slot", WieldLocation.BACKPACK)

        to_backpack = []
        if use_slot is WieldLocation.TWO_HANDS:
            # 双手武器不能与单手使用的物品或盾牌共存
            to_backpack = [slots[WieldLocation.WEAPON_HAND], slots[WieldLocation.SHIELD_HAND]]
            slots[WieldLocation.WEAPON_HAND] = slots[WieldLocation.SHIELD_HAND] = None
            slots[use_slot] = obj
        elif use_slot in (WieldLocation.WEAPON_HAND, WieldLocation.SHIELD_HAND):
            # 如果添加单手武器或盾,则不能使用双手武器
            to_backpack = [slots[WieldLocation.TWO_HANDS]]
            slots[WieldLocation.TWO_HANDS] = None
            slots[use_slot] = obj
        elif use_slot is WieldLocation.BACKPACK:
            # 它属于背包,所以回到背包
            to_backpack = [obj]
        else:
            # 对于其他(身体、头部),只需替换掉之前的物品
            to_backpack = [slots[use_slot]]
            slots[use_slot] = obj
       
        for to_backpack_obj in to_backpack:
            # 将物品放入背包
            if to_backpack_obj:
                slots[WieldLocation.BACKPACK].append(to_backpack_obj)
       
        # 存储新状态
        self._save() 

在这里,我们记得每个 EvAdventureObject 具有一个 inventory_use_slot 属性,告诉我们它将放置的位置。因此,我们只需将对象移动到该插槽,替换之前位于该位置的物品。任何被替换的物品都会回到背包中,只要它确实是一个物品,而不是 None,在将物品移动到空槽的情况下。

5.6. 获取所有物品

为了可视化我们的库存,我们需要一些方法来获取我们所携带的所有物品。

# mygame/evadventure/equipment.py 

from .enums import WieldLocation, Ability

# ... 

class EquipmentHandler: 

    # ... 

    def all(self):
        """
        获取库存中所有物品,无论其位置是什么。
        """
        slots = self.slots
        lst = [
            (slots[WieldLocation.WEAPON_HAND], WieldLocation.WEAPON_HAND),
            (slots[WieldLocation.SHIELD_HAND], WieldLocation.SHIELD_HAND),
            (slots[WieldLocation.TWO_HANDS], WieldLocation.TWO_HANDS),
            (slots[WieldLocation.BODY], WieldLocation.BODY),
            (slots[WieldLocation.HEAD], WieldLocation.HEAD),
        ] + [(item, WieldLocation.BACKPACK) for item in slots[WieldLocation.BACKPACK]]
        return lst

在这里,我们获取所有装备位置,并将它们的内容合并到一个包含元组的列表中 [(item, WieldLocation), ...]。这方便显示。

5.7. 武器和盔甲

让 EquipmentHandler 轻松告诉你当前正在使用的武器及所有穿戴装备提供的 防护 级别是很方便的。否则你必须每次都查找哪个物品位于哪个装备插槽,并手动加总盔甲插槽。

# mygame/evadventure/equipment.py 

from .enums import WieldLocation, Ability
from .objects import get_bare_hand

# ... 

class EquipmentHandler: 

    # ... 
    
    @property
    def armor(self):
        slots = self.slots
        return sum(
            (
                # 装甲通过其防护值列出,因此我们从中减去 10
                # (在 Knave 中 11 是没有盔甲的基础值)
                getattr(slots[WieldLocation.BODY], "armor", 1),
                # 盾牌和头盔通过其盔甲加成列出
                getattr(slots[WieldLocation.SHIELD_HAND], "armor", 0),
                getattr(slots[WieldLocation.HEAD], "armor", 0),
            )
        )

    @property
    def weapon(self):
        # 首先检查双手武器,然后是单手武器;这两者
        # 不应该同时出现(在 `move` 方法中检查)。
        slots = self.slots
        weapon = slots[WieldLocation.TWO_HANDS]
        if not weapon:
            weapon = slots[WieldLocation.WEAPON_HAND]
        # 如果我们仍然没有武器,则返回 None
        if not weapon:
            weapon = get_bare_hand()
        return weapon

在 .armor() 方法中,我们在每个相关的装备插槽(身体、盾牌、头部)中取出物品(如果有),并获取它们的 armor 属性。然后再将它们全部相加使用 sum()。

在 .weapon() 方法中,我们简单地检查可能的武器插槽(单手或双手)是否有物品。如果没有,我们就回退到在对象教程中创建的“光秃的手”对象。

5.7.1. 修复角色类

因此,我们添加了我们的装备处理器,它可以验证我们放入的内容。然而,这在游戏中创建 NPC 等物品时会导致问题,例如使用如下操作

create/drop monster:evadventure.npcs.EvAdventureNPC

问题在于,当 NPC 创建时,它会短暂地出现在你的库存中,然后被放下,因此在这期间代码会在你身上执行(假设你是 EvAdventureCharacter):

# mygame/evadventure/characters.py
# ... 

class EvAdventureCharacter(LivingMixin, DefaultCharacter): 

    # ... 

    def at_object_receive(self, moved_object, source_location, **kwargs): 
        """ 
        当对象“到达”角色时由 Evennia 调用。
        
        """
        self.equipment.add(moved_object)

这意味着 EquipmentHandler 会检查 NPC,并且由于它不是有效的装备物品,会引发 EquipmentError,导致创建失败。由于我们希望能够轻松创建 NPC 等对象,我们将使用 try...except 语句处理此错误,如下所示:

# mygame/evadventure/characters.py
# ... 
from evennia import logger 
from .equipment import EquipmentError

class EvAdventureCharacter(LivingMixin, DefaultCharacter): 

    # ... 

    def at_object_receive(self, moved_object, source_location, **kwargs): 
        """ 
        当对象“到达”角色时由 Evennia 调用。
        
        """
        try:
            self.equipment.add(moved_object)
        except EquipmentError:
            logger.log_trace()

使用 Evennia 的 logger.log_trace() 我们捕捉错误并将其记录到服务器日志中。这允许你查看这里是否存在真正的错误,但一旦功能正常,如果这些错误信息变得冗余,你也可以用 pass 替换 logger.log_trace() 中的这一行,以隐藏这些错误。

5.8. 额外功分

这涵盖了装备处理程序的基本功能。还有其他有用的方法可以添加:

  • 给定一个物品,找出它当前所在的装备插槽

  • 制作一个字符串表示当前装备

  • 获取背包中的所有物品(仅此而已)

  • 从背包中获取所有可装备的物品(武器、盾牌)

  • 从背包中获取所有可用物品(使用位置为 BACKPACK 的物品)

尝试添加这些功能。完整示例可在evennia/contrib/tutorials/evadventure/equipment.py中找到。

5.9. 单元测试

创建一个新模块 mygame/evadventure/tests/test_equipment.py。

要测试 EquipmentHandler,最简单的方法是创建一个 EvAdventureCharacter(到现在为止,它应该可以通过 .equipment 使用 EquipmentHandler)和一些测试对象;然后测试将这些对象传入处理程序的方法。

# mygame/evadventure/tests/test_equipment.py 

from evennia.utils import create 
from evennia.utils.test_resources import BaseEvenniaTest 

from ..objects import EvAdventureObject, EvAdventureHelmet, EvAdventureWeapon
from ..enums import WieldLocation
from ..characters import EvAdventureCharacter

class TestEquipment(BaseEvenniaTest): 
    
    def setUp(self): 
        self.character = create.create_object(EvAdventureCharacter, key='testchar')
        self.helmet = create.create_object(EvAdventureHelmet, key="helmet") 
        self.weapon = create.create_object(EvAdventureWeapon, key="weapon") 
         
    def test_add_remove(self): 
        self.character.equipment.add(self.helmet)
        self.assertEqual(
            self.character.equipment.slots[WieldLocation.BACKPACK],
            [self.helmet]
        )
        self.character.equipment.remove(self.helmet)
        self.assertEqual(self.character.equipment.slots[WieldLocation.BACKPACK], []) 
        
    # ... 

5.10. 总结

处理程序 是用于将功能组合在一起的有用工具。在我们花时间创建 EquipmentHandler 之后,我们就不需要再担心物品插槽的问题了——处理程序会为我们“处理”所有细节。只要我们调用它的方法,细节就可以被遗忘。

我们还学习了如何使用 hooks 将 Knave 的自定义装备处理绑定到 Evennia。

通过 Characters、Objects,现在的 Equipment 到位后,我们应该能继续进行角色生成——玩家将能够创建自己的角色!