Skip to content

An Illustration to Arson Attack

neuront edited this page Apr 18, 2012 · 1 revision

在此以三国杀中结算较为复杂的 "火攻" (本项目中译为 arson attack) 作为例子, 说明玩家的出牌阶段以及动作栈以及动作帧的设计.

进入出牌阶段

帧的构造

当进入某个玩家的出牌阶段时, 动作栈的栈顶将是出牌阶段动作帧 (ext.src.frames.UseCards 的实例, 该类型继承自内核的使用卡牌帧 core.src.action_frames.UseCards). 该对象在玩家类的出牌阶段函数 (ext.src.player.Player.using_cards_stage 函数) 中被调用

def using_cards_stage(self, game_control):
    game_control.push_frame(ext.src.frames.UseCards(game_control, self))

ext.src.frames.UseCards 实例化时, 会用到

ext.src.frames._using_cards_map()

这是一个 Python 字典, 将字符串映射到函数, 表示允许的动作集.

函数 _using_cards_map 的实现为

def _using_cards_map():
    return {
        'slash': slash_action,
        'thunder slash': slash_action,
        # ...
        'steal': steal_action,
        'equip': equip.interface,
    }

而其中前若干是基本牌或锦囊, 而最后一项则是装备一张卡牌.

卡牌检测

当动作栈栈顶是出牌阶段帧时, 很自然地, 调用 GameControl 对象的 player_act 方法, 参数将被传递进该帧.

在下面代码中, gc 表示这个 GameControl 对象, players 是一个包含 2 个 ext.src.player.Player 实例的列表, 而 id 为 1 的卡牌则是一张 [火攻].

进行如下调用, 则可以让该玩家指定一个目标, 并使用火攻

gc.player_act({
    'token': players[0].token,
    'action': 'card',
    'targets': [players[1].player_id],
    'use': [1],
})

其中最后一项 'use': [1], 表示使用了 1 张卡牌, 该卡牌的 id 为 1.

进入 player_act 函数后, 首先会验证玩家 token 是否合法, 然后才将参数传递给栈顶的帧

def player_act(self, args):
    try:
        if not args['token'] in map( # DO token VERIFICATION HERE
                lambda p: p.token, self.action_stack.allowed_players()):
            return {
                       'code': ret_code.BAD_REQUEST,
                       'reason': ret_code.BR_PLAYER_FORBID,
                   }
        return self.action_stack.call(args) # PASSING ARGUMENTS
    except KeyError, e:
        return {
                   'code': ret_code.BAD_REQUEST,
                   'reason': ret_code.BR_MISSING_ARG % e.message,
               }
    except ValueError, e:
        return {
                   'code': ret_code.BAD_REQUEST,
                   'reason': ret_code.BR_WRONG_ARG % e.message,
               }

现在进入帧的 react 函数中, 在 ext.src.frames.UseCards.react 函数中, 首先会将 'action' 对应的动作值从 "使用一张卡牌" (也就是 'card') 转变为该卡牌的名字或转为 "装备"

def react(self, args):
    if args['action'] == 'card':
        cards = self.game_control.cards_by_ids(args['use'])
        if len(cards) != 1:
            raise ValueError('wrong cards')
        if equip.is_equipment(cards[0].name()): # check if equipment
            args['action'] = 'equip'
        else:                                   # change action to its name
            args['action'] = cards[0].name()
    r = core.UseCards.react(self, args)
    self._update_hint()
    return r

而在 core.src.action_frames.UseCards, 会根据 action 找到对应的入口, 然后调用相应的函数

def react(self, args):
    cards = []
    if 'use' in args:
        cards = self.game_control.cards_by_ids(args['use'])
        check_owner(self.player, cards)

    if not args['action'] in self.interface_map:
        return {
            'code': ret_code.BAD_REQUEST,
            'reason': ret_code.BR_INCORRECT_INTERFACE,
        }

    with card.InUseStatusRestore(cards):
        return self.interface_map[args['action']](self.game_control, args)

首先帧同样会验证参数, 包括很明显的, 用于检测玩家传入的 action 对应的值是否合法, 以及一次对卡牌持有者进行的验证 (check_owner 函数), 然后直接找到 action 对应的接口, 将参数传递给该接口 (请暂时忽略代码中的 with).

这样牵扯到一个问题, 卡牌是不是火攻, 究竟在何处, 如何判别? 按照上面的写法, 无论玩家传入的什么卡牌 (甚至即使没有传入卡牌信息, 在 react 函数中也会自动变为一个空的卡牌序列), 只要传递的字典中 action 键对应的是 arson attack, 就能进入火攻对应的函数, 而不是根据卡牌本身确定执行的动作. 很自然地, 一种替代的设计方案是, 每个卡牌有一个 use 方法, 比如, 一张火攻卡牌, 该函数就对应于火攻函数. 但是三国杀游戏中, 许多角色技能能够变换卡牌使用方式, 比如卧龙诸葛的火计, 可以将任意红色卡牌当作火攻使用, 因此, 在这种设计下, 如果某张非火攻的红色卡牌被使用了, 就需要扩充其 use 函数, 设计会变得复杂. (稍后将会讨论如何设计类似火计这样的技能)

目前的设计是, 当参数被传递入火攻对应的函数 (ext.src.sleevecards.arson_attack.arson_attack_action 函数), 在这个函数中作第一步验证

def arson_attack_action(gc, args):
    cards = gc.cards_by_ids(args['use'])
    checking.only_one_card_named_as(cards, 'arson attack') # check if the card is legal
    return arson_attack_check(gc, args)

检测卡牌是否正是 1 张火攻, 然后进入函数 arson_attack_check

def arson_attack_check(game_control, args):
    targets_ids = args['targets']
    cards = game_control.cards_by_ids(args['use'])
    user = game_control.player_by_token(args['token'])
    checking.only_one_target(targets_ids)                        # CHECK
    target = game_control.player_by_id(targets_ids[0])
    checking.valid_target(user, target, 'arson attack', cards)   # CHECK
    checking.forbid_target_no_card_on_hand(target, game_control) # CHECK

    game_control.use_cards_for_players(user, targets_ids, args['action'], cards)
    game_control.push_frame(_ArsonAttack(game_control, user, target, cards))
    return { 'code': ret_code.OK }

上面的函数又会进一步检测, 如是否仅 1 名角色作为目标, 以及此角色是否能成为这张火攻的目标, 最后还包括没有手牌的玩家无法被火攻.

如果设计卧龙诸葛的 [火计] 技能, 使用任何红色手牌当作 [火攻] 来使用, 则可跳过 arson_attack_action 检查卡牌, 而是自行检查卡牌然后使用 arson_attack_check, 就可以达成火攻了.

三国杀规则中, 火攻的目标可以是自己. 但是, 当玩家自身仅持有用来火攻的这 1 张火攻卡牌时, 该玩家是无法对自己进行火攻的, 玩家这时火攻应该失败, 但如果失败, 则玩家这张火攻卡牌又没有使用, 因此仍属于该玩家. 为了解决这个问题, 在卡牌 (core.src.card.Card) 类中引入了卡牌状态. 初始时, 卡牌状态是 NORMAL, 当玩家尝试使用一张卡牌时, 这张卡牌的状态将被标记为 IN_USE, 而计算玩家手牌时, 仅计入状态为 NORMAL 的卡牌, 这样当玩家仅有 1 张火攻牌来火攻自己时, 手中状态为 NORMAL 的牌数量为 0, 因此无法火攻自己. 为了确保发生错误时卡牌的状态将恢复为 NORMAL, 在 core.src.card 模块中又引入了可以用于 (刚才那个被忽略的) with 块的 StatusRestore

class StatusRestore:
    def __init__(self, cards):
        self.cards = cards

    def __exit__(self, etype, eobj, tb):
        [c.restore() for c in self.cards]
        return False

class InUseStatusRestore(StatusRestore):
    def __init__(self, cards):
        StatusRestore.__init__(self, cards)

    def __enter__(self):
        [c.using() for c in self.cards]

火攻流程

火攻开始首先会压入 _ArsonAttack 帧实例. 这是一个控制帧, 它并不直接响应玩家动作. 它在入栈后, 首先被激活

class _ArsonAttack(frames.FrameBase):
    # ... other members

    def activated(self):
        self.resume = self.after_target_show_a_card
        self.game_control.push_frame(_TargetShowCard(self.game_control,
                                                     self.target))

此时, 它压入一个目标玩家展示手牌帧, 并将自己的resume 函数修改为 after_target_show_a_card

def after_target_show_a_card(self, args):
    show_suit = self.game_control.cards_by_ids(args['discard'])[0].suit()
    self.resume = self.user_discard_card
    self.game_control.push_frame(_UserDiscardSameSuit(self.game_control,
                                                      self.user, show_suit))

此函数被调用时 (也就是目标玩家已经展示了一张手牌后), 它再压入火攻玩家弃牌帧, 并将自己的 resume 函数改为

def user_discard_card(self, args):
    if args['method'] != 'abort':
        damage.Damage(self.game_control, self.user, self.target,
                      'arson attack', self.cards, 'fire', 1
                      ).add_cleaner(lambda dmg, gc: self.done(None)
                      ).operate(self.game_control)
    else:
        self.done(None)

此函数中会检测玩家是否弃牌并造成了伤害, 若否, 则直接弹出自己, 若造成了伤害, 则产生一个伤害对象并开始结算伤害, 并将本帧的弹出动作放入伤害对象的清理工作中去.

上述过程中用到了另外两类帧 _TargetShowCard_UserDiscardSameSuit, 下面详述.

展示一张手牌

_TargetShowCard 帧是对 core.src.action_frames.ShowCards 的包装, 不过并没有重写其 react 函数.

当展示卡牌帧为栈顶时, 通过对 GameControl.player_act 作如下调用, 使火攻目标展示一张手牌

gc.player_act({
    'token': players[1].token,
    'discard': [6],
})

其中, 'discard': [6], 表示该玩家展示了 1 张卡牌, 卡牌 id 为 6. 对于客户端而言, 由于展示卡牌与弃牌并没有区别 (都是选择卡牌, 然后确定), 因此都使用 'discard' 这个键.

core.src.action_frames.ShowCards 中, react 实现为

def react(self, args):
    cards = args['discard']
    check_owner(self.player, self.game_control.cards_by_ids(cards))
    self.cards_check(cards)
    self.game_control.show_cards(self.player, cards)
    return self.done(args)

开始仍然是例行检查, 然后通过调用 game_control.show_cards 告知 GameControl 实例记录下展示卡牌的事件. 最后调入 self.done. 这个函数是在模块内的基类 FrameBase 中实现的

def done(self, result):
    self.game_control.pop_frame(result)
    return { 'code': ret_code.OK }

然后, 这个请求通过 game_control.pop_frame 调入 core.src.action_stack.ActionStack.pop

def pop(self, result):
    stack_top = self.frames.pop()
    stack_top.destructed()
    self.frames[-1].resume(result)

其中的 result 参数也就是调用 done 函数时传入的参数, 即客户端传入 GameControl.player_act 的参数 args. 当前栈顶被弹出时, 这个 result 被交给了此时栈顶的 resume 方法. 回顾刚才 _ArsonAttack 的流程, 此时其 resume 因该是 after_target_show_a_card, 通过上一帧的结果, 函数从 args 中可以获得玩家展示的手牌花色信息.

火攻方弃牌

而在弃牌帧 _UserDiscardSameSuit (继承自 core.src.action_frame.DiscardCards) 中, react 的实现如下

def react(self, args):
    if args['method'] == 'abort':
        return self.done(args)
    return core.src.action_frame.DiscardCards.react(self, args)

其父类的 react 方法则实现为

def react(self, args):
    cards_ids = args['discard']
    check_owner(self.player, self.game_control.cards_by_ids(cards_ids))
    self.cards_check(cards_ids)
    self.game_control.discard_cards_by_ids(self.player, cards_ids)
    return self.done(args)

即, 只要 args 中的 'method' 值不为 'abort', 则进入父类的 react 方法, 无论如何, 在检查 (检查卡牌数为 1 且花色与展示的手牌相同) 通过后, 均将客户端传来的 args 通过 done 函数交给 _ArsonAttack 帧. 此时, _ArsonAttack 帧的 resume 方法应被设为了 user_discard_card, 此函数根据 'method' 值来决定是否造成伤害, 此时火攻结算完毕.

Clone this wiki locally