单人扑克游戏:地城恶棍的Python实现(附实现代码)

基于Numpy的单人扑克游戏:地城恶棍

[!] 本项目为原创内容,若有错误之处还需批评指正

由于篇幅过长,本文全代码文件位于另一个博客中:传送门



1.单人扑克牌游戏:地城恶棍

  • 翻译自BGG上的扑克游戏,通过一副标准的扑克牌构造一个地下城进行游戏,工具易得,规则简单,携带方便,也有一些RPG要素。(教程来源:传送门)

1.1 游戏场景

  • 取出所有小丑,红色的J,Q,K,A。将它们放在一边,它们不会在这个游戏中使用。

  • 将剩余的卡片洗牌并将正面朝下放在左侧。这个牌堆被称为地下城。


    [借用原链接教程的图,侵删]

1.2 游戏规则

  • 牌堆里的26个草花和黑桃,这些是怪物。他们的伤害等于他们的牌面数字。 (其中10是10,J是11,Q是12,K是13,A是14)
  • 牌堆上的9张方片是武器。每种武器都会造成与对应数字的伤害。 恶棍只能装备一个武器,这意味着如果你选择一个,你必须装备它,并丢弃你以前的武器。
  • 牌堆上的9张红桃是生命药水。每回合你只能使用一个生命药水,即使你拿到两个。你拿的第二个药水被丢弃(放回弃牌堆)。并且你的生命值无法超过20.
  • 您可以在任何地方放置你的弃牌(任何丢弃的牌),但我建议在地城右侧。卡片要面朝下丢弃。
  • 当你的生命达到0或者你穿过整个地牢时,游戏结束。

1.3 计分规则

  • 如果你的生命已经达到零,找到地牢中所有剩余的怪物,并从你的生命中减去它们的值,这个负值就是你的分数。

  • 如果你已经穿过整个地牢,你的分数就应该是你的正值,你的最后一张牌如果是红桃,那就是你最后的财宝,那么你的分数就是你的生命值+财宝价值。

1.4 游戏流程

  • 在你的第一个和之后的每个回合中,一个接一个地翻转卡片顶部的卡片,直到你面前有4张卡片面朝上形成一个房间。

  • 如果您愿意,您可以避开这个房间。如果您选择这样做,请在一次性拿走所有四张卡片,把它们放在地下城的底部。虽然您避免房间的次数不受限制,但您无法避开连续两个房间。

  • 如果您选择选择进入,则必须面对其中包含的四张卡中的3张。

  • 如果选择武器则必须装备它。将它正面朝上放在您和剩余的房卡之间。如果你装备了先前的武器,将它和它上面的任何怪物移动到弃牌牌堆上。

  • 如果选择生命药水则将其增加相应的数目的血量,然后将其放至弃牌堆。您的生命值不得超过20岁,您可能每回合不止一个生命药水。如果你在一个回合中服用两个生命药水,那么第二个就被丢弃,并且不会增加血量。

  • 如果你选择了怪物则可以用徒手或使用装备好的武器进行战斗。一旦你选择了3张牌(只剩下一张牌),轮到你了。将第四张卡面朝上放在您面前,作为下一个房间的一部分。

    • 如果你徒手与怪物战斗,从你的生命中减去它的攻击力,并将怪物移到丢弃牌组。

    • 如果你选择使用装备好的武器对抗怪兽,将怪物面朝上放在武器上方(同时也放在武器上任何其他怪物的顶部。确保错开怪物的位置,不会盖住武器的数字,类似于红心接龙的放置方式)

    • 如果武器的攻击力大于怪物攻击力,怪物被击败且你不会受伤;

    • 如果怪物的攻击力大于你武器的攻击力,你的血量要减去两者的差值;

    • 重要的是要注意,虽然你保留你的武器,直到他们被更换,一旦武器被用于怪物,武器只能用于干掉以前的怪物更低价值(小于等于)的怪物。


2.制作思路

  • 既然是一个扑克牌游戏,首先肯定是要制作一个扑克牌的API
    • 扑克牌可以用字典或者列表来表示,但是这样的话会浪费很多的空间。因此,本项目将使用Numpy来实现,这样的话能保证性能的情况下减少存储成本。
    • 一般情况下,扑克牌应该具有的功能为: 抽牌库顶的牌、洗牌、将牌放进牌库底、丢弃某张牌
  • 其他情况的话,根据游戏规则制作就OKK了
  • 本次制作在Jupyter Notebook中制作,所需要的库如下所示:
    import random as ra
    import numpy as np
    import os
    import re
    import time
    from IPython.display import clear_output #用于清空输出
    

3. 扑克牌API

  • 扑克牌API可以在本人的另一个博客中找到传送门

3.1 类成员

  • 类成员的话比较简单,主要存储的是牌库以及他们的特殊字符。
  • 这里将特殊字符作为静态成员变量,牌库作为公共成员变量
    class PlayingCard(object):
        #\033[31m是颜色标识符,让字体变成红色的
        prefixs = ['\033[31m♦\033[0m','♣','\033[31m♥\033[0m','♠']
        suffixs = [str(i) for i in range(1,11)] + ['J','Q','K']
        specials = ["♚","\033[31m♚\033[0m"]
    
        def __init__(self) -> None:
            """
            构建牌库[x,y] 
            获取公式
            x = 54 // 4
            y = 54 % 4
            
            x \in [0,13],其中0~9代表数值1~10, 10/11/12代表J/Q/K, 13代表特殊牌(即王牌)
            y \in [0,3],其中0代表方块,1代表梅花,2代表桃心,3代表黑桃
            [13,0] 小王; [13,1] 大王
            """
            self.library = np.linspace(1,54,54).astype(np.int8) #一个一维向量作为牌库
    

3.2 索引转换与检索

  • 由于我们使用的是一个一维向量来进行存储,因此我们需要计算出一维向量和扑克牌之间的映射关系
    def card2index(self,index) -> tuple:
        #索引与卡牌的关系
        assert not int((index-27)/27), "[WARN] Card %d is illegal!" % index
        index -= 1
        x,y = index//4,index % 4
        return (x,y)
    
    def card2str(self,index) -> str:
        #将索引转化为文本
        assert not int((index-27)/27), "[WARN] Card %d is illegal!" % index
        x,y = self.card2index(index)
        return self.prefixs[y] + self.suffixs[x]
    
    def getPrefix(self,index) -> int:
        """
        得到扑克牌花色
        其中0代表方块,1代表梅花,2代表桃心,3代表黑桃
        """
        assert not int((index-27)/27), "[WARN] Card %d is illegal!" % index
        return (index - 1) % 4
    
    def getNumber(self,index) -> int:
        #得到扑克牌数字
        assert not int((index-27)/27), "[WARN] Card %d is illegal!" % index
        return (index - 1) //4
    

3.3 洗牌与重置牌库

  • 由于牌库是一维数组,因此可以直接使用一维数组打乱的方式实现洗牌
  • 对于重置牌库,则可以暴力的重新构造一个有序的一维向量
    def shuffle(self,seed = None) -> np.array:
        #洗牌
        if seed is None:
            seed = ra.randint(0,2^16)
        ra.seed(seed)
        ra.shuffle(self.library)
        return self.library
    
    def reload(self) -> np.array:
        #获得一副新牌
        self.library = np.linspace(1,54,54).astype(np.int8)
        return self.library
    
  • 注意到这里洗牌方法使用了seed参数,这样可以让用户自己设定随机种子来保证多次洗牌的结果是相同的

3.4 取卡牌、插卡牌与丢弃卡牌

  • 在很多个扑克游戏中,都喜欢在牌库顶抽走卡牌,在牌库底加入卡牌。因此设置这两个方法是有必要的。
  • 需要注意的是,在牌库中随机位置抽卡和随机位置加入卡牌可以通过“取卡/插卡 + 打乱”的组合来实现,因此不需要额外设计。
  • 丢弃卡片则是需要在牌库中丢弃掉某几张不需要的卡,这一点适用于某些规则之中。
  • 由于这几个操作需要判断卡牌是否在牌库中,所以特意加入了一个私有方法来判断卡牌是否在牌库里
    def _inLibrary(self,index) -> bool:
        #判断是否在卡里?
        return np.where(self.library == index)[0].size > 0
    
    def __len__(self):
        #重写len方法,即 len() 函数会返回牌库中剩余卡片数
        return len(self.library)
    
    def pop(self) -> int:
        #取出牌库顶牌
        if len(self) == 0:
            return 0
        pop = self.library[0]
        self.library = self.library[1:]
        return pop
    
    def insert(self,index:int) -> np.array:
        #在牌库底插入牌
        assert not int((index-27)/27), "[WARN] Card %d is illegal!" % index
        assert not self._inLibrary(index), "[WARN] Card %d is already in library!" % index
        self.library = np.concatenate([self.library,np.array([index])])
        return self.library
    
    def drop(self,indexList) -> np.array:
        #丢弃某张牌
        for index in indexList if type(indexList) == list else [indexList]:
            assert self._inLibrary(index), "[WARN] Card %d is not in library!" % index
            self.library = np.delete(self.library,
                            np.where(self.library == index))
        return self.library
    
    
  • 由于是一维数组的缘故,因此用户在使用的时候需要先将卡牌转化为索引,随后再输入到drop方法中

3.5 卡牌可视化

  • 由于这个API是用一维数组实现的,因此很难直观地了解到里面到底有什么牌

  • 所以,特意设计了这么一个可视化的方法来进行查看

  • 这个方法通过重写__str__方法来实现,因此可以直接通过对实例使用print()来查看

    def __str__(self):
        string = "Card Summary: %d\n" % len(self)
        for x in range(13): #用于显示每一行(本来可以继续压缩的,但是怕可读性不行)
            string += "\t".join([self.prefixs[y] + self.suffixs[x] for y in range(4) 
                              if self._inLibrary(4 * x + y + 1)]) + "\n"
        string += "\t".join([self.specials[i] for i in [0,1] if self._inLibrary(53 + i)])
        return string
    
    

3.6 具体展示

  • 以下是一个简单的展示环节

    card = PlayingCard() #构建牌库
    print(card) #展示牌库
    
    card.shuffle(seed = 42) #随机打乱
    
    #抽出牌库顶前18张牌
    for i in range(18):
        card.pop()
    print(card)
    


4. 游戏本体

4.1 配置文件

  • 作为一个游戏,首先就需要有一个配置文件
    class Config(object):
        def __init__(self,maxHealth) -> None:
            self.maxHealth = maxHealth  #最大生命值
            self.health = maxHealth #现在的生命值
            self.weapon = 0  #武器
            self.finalKill = 0 #最后击杀的怪物
            
        def configReload(self):
            #刷新配置,用于重置游戏
            self.health = self.maxHealth 
            self.weapon = 0
            self.finalKill = 0
            print("[INFO] Player Health: %d/%d" % (self.health,self.maxHealth))
    

4.2 游戏主体

  • 构建游戏的主体,用来寄存游戏中出现的变量。
  • 该游戏中设置了保存路径,用于保存每次游戏的得分
  • 该类中内置了reload()方法,来快速重置游戏
    class Game(Config):
        def __init__(self,maxHealth:int = 20,savePath = './checkpoint',seed = None) -> None:
            super().__init__(maxHealth)
            self.card = PlayingCard()
            self.room = np.zeros(4,dtype = np.int8) #游戏中的地牢房间,0代表空
            self._step = 0  #游戏回合
            self.actions = None
            self._isSwitch = False #一个回合中只能刷新一次房间
            self.playTime = time.strftime("%Y-%m-%d=%H-%M", time.localtime()) #游戏时间
            self.fileName = os.path.join(savePath,self.playTime) #保存文件的目录
            if not os.path.exists(savePath):
                os.mkdir(savePath)
            with open(self.fileName,'w') as file: pass #创建文件
            
            if seed is None:
                seed = ra.randint(0,2^16)
            self.reload(seed) #使用随机种子刷新房间
            
            
        def reload(self,seed = None) -> None:
            """
            该游戏不需要红色J,K,Q,A以及小王大王
            可以设定随机种子来保证公平对战
            """
            if seed is None:
                seed = ra.randint(0,2^16)
            self.configReload()
            self.card.reload()
            self.card.drop([(2 * i + 1) for i in [0,1,20,21,22,23,24,25]] + [53,54])
            self.card.shuffle(seed)
            print("[INFO] The game initialized successfully!")
    

4.3 游戏主循环

  • 这个游戏是一个回合制的游戏,因此可以构建一个 step() 方法,来表示每轮游戏
        def step(self) -> int:
            result = self._isEnd() #判断游戏是否结束
            if result != 0:
                score = self._getScore(result) #如果结束了则开始计分
                print("[INFO] %s Score: %d" %\
                    ("Victory!" if result == 1 else "Defeated.",score))
                #修改保存的名字
                os.rename(self.fileName,self.fileName + "=Score%d" % score)
                return 1 
            
            self._updateRoom() #更新房间
            ans = -1 #记录玩家的输入结果是否合理
            while(ans == -1):
                clear_output(wait = True) #清除屏幕
                print(self)     #显示游戏界面
                time.sleep(0.2) #等待一段时间,防止输出错位
                actions = input( "\n1-4: Choose card (e.g. \"1 2 4\") \n0: Switch room\n"+\
                        "\nPlease input the number to choose your actions:")
                ans = self._decode(actions) #分析玩家的输入
                
            
            #动作0表示逃避房间
            if self.actions == 0: 
                self._switchRoom()
                print("[INFO] You switch the room!")
                input("Press \"Enter\" key to continute")
                return 0
            
            #否则,根据玩家输入顺序来触发动作
            for action in self.actions:
                if self.health <= 0:
                    return 0  #已经死亡,退出回合
                self._action(action)
                self.room[action - 1] = 0 #将房间情况,表示已完成
                time.sleep(0.2)
    
            #回合数+1,重置刷新次数并退出
            self._step += 1
            self._isSwitch = False
            input("Press \"Enter\" key to continute")
            return 0
    

4.4 游戏结束与计分

  • 根据游戏规则,游戏结束计分情况如下:
    • 如果你的生命已经达到零,找到地牢中所有剩余的怪物,并从你的生命中减去它们的值,这个负值就是你的分数。
    • 如果你已经穿过整个地牢,你的分数就应该是你的正值,你的最后一张牌如果是红桃,那就是你最后的财宝,那么你的分数就是你的生命值+财宝价值。
  • 因此,可以设计出
        def _isEnd(self) -> int:
            """
            游戏结束机制:
            1. 卡池只剩一张,返回1
            2. 生命值少于等于0,返回-1
            """
            if len(self.card) == 0: return 1
            if self.health <= 0: return -1
            return 0
        
        def _getScore(self,index) -> int:
            if index == 1: #卡尺只剩一张的情况
                index = self.card.pop()  #抽出来,判断是不是红桃
                prefix = self.card.getPrefix(index)
                suffix = self.card.getNumber(index) + 1
                if prefix == 2:
                    return self.health + suffix #是红桃则作为宝藏
                else:
                    self._action(index)  #否则,自动执行(可能会挂掉)
                    return self.health
            else:  #自己生命值清空的情况
                score = self.health
                index = self.card.pop()
                while(index != 0):  #牌库中的怪物
                    prefix = self.card.getPrefix(index)
                    suffix = self.card.getNumber(index) + 1
                    score -= suffix if prefix %2 == 1 else 0 #减去所有怪物的伤害
                    index = self.card.pop()
                for index in self.room: #房间中的怪物
                    if index == 0: continue
                    prefix = self.card.getPrefix(index)
                    suffix = self.card.getNumber(index) + 1
                    score -= suffix if prefix %2 == 1 else 0 #减去所有怪物的伤害
                return score
    

4.5 刷新房间与替换房间

  • 房间由一个长度为4的一维向量构成,其中空房间由0表示,因此可以将牌的索引直接替换0来实现
        def _updateRoom(self) -> None:
            """
            更新地牢房间:
            在牌库中抽牌,直至四张牌朝上形成一个房间
            """
            for index in np.where(self.room == 0)[0]:
                self.room[index] = self.card.pop()
    
        def _switchRoom(self) -> None:
            self._isSwitch = True
            for index in self.room:
                self.card.insert(index)
                self.room = np.zeros(4,dtype = np.int8)
    

4.6 游戏界面展示

  • 游戏界面同样可以通过重构__str__方法来进行展示
        def __str__(self):
            string = "Step:%d\t Left: %d" % (len(self),len(self.card)) + "\n" + "=" * 40
            string += "\nRoom:\t" +"\t".join([self.card.card2str(index) for index in self.room])
            string += "\n\n\nWeapon:\t%s" % (self.card.card2str(self.weapon) if self.weapon else "None")
            string += " (" + self.card.card2str(self.finalKill) + ")" if self.finalKill else ""
            string += "\nHealth:\t%d/%d" % (self.health,self.maxHealth)
            return string
    
  • 可视化的结果如下:
    请添加图片描述

4.7 玩家输入检测

  • 考虑到玩家输入的情况可能千变万化,因此需要一个比较好的函数来识别玩家的输入
  • 在这里使用了正则表达式来进行动作识别,并判断玩家输入是否合理
        def _decode(self,action):
            if not action: return -1 #如果没有输入
            #将字符串中的数字提取出来
            action = [int(index) for index in re.findall(r"\d",action)]
            
            #判断每个数字是否合理
            for index in action:
                if index == 0:
                    if not self._isSwitch:
                        self.actions = 0
                        return 1
                    else:
                        print("[WARN] You can't switch room twice in one round!")
                        input("Press \"Enter\" key to continute")
                        return -1
        
                if index not in [1,2,3,4]:
                    print("[WARN] Error action %d" % index)
                    input("Press \"Enter\" key to continute")
                    return -1
    
            #游戏中一定要选择三张牌,否则报错
            if len(action) != 3: 
                print("[WARN] The correct action length must be 3! Found %d" % len(action))
                input("Press \"Enter\" key to continute")
                return -1
            
            #将最后正确的动作返回到类中
            self.actions = action
            return 1
    

4.8 执行动作

  • 由于游戏规则的限制,执行动作方法中会有比较多的分支语句
        def _action(self,action):
            """其中0代表方块,1代表梅花,2代表桃心,3代表黑桃"""
            index = self.room[action - 1]
            prefix = self.card.getPrefix(index)
            suffix = self.card.getNumber(index) + 1
    
            if suffix == 1: suffix = 14  #A代表14
                
            if prefix % 2 == 1: #方块和黑桃代表怪物
                print("[INFO] You meet the monster %s!" % self.card.card2str(index))
                if self.weapon != 0:
                    damaged = suffix - self.card.getNumber(self.weapon 
                                            if self.finalKill == 0 else self.finalKill) - 1
                else:
                    damaged = suffix
                if damaged > 0:
                    self.health -= damaged #扣除血量
                    print("[INFO] Oh no! You lost %d health! (%d/%d)" %\
                        (damaged,self.health,self.maxHealth))
                    
                else:
                    self.finalKill = index #成功击杀怪物,但是你的武器会扣除伤害
                    print("[INFO] You successfully kill the monster!")
                    print("[INFO] But your weapon can only kill monsters below the value of %d" % suffix)
                    
            
            if prefix == 2: #桃心代表加血
                self.health = min(self.maxHealth,self.health + suffix)
                print("[INFO] You meet %s and restored %d health! (%d/%d)" %\
                    (self.card.card2str(index),suffix,self.health,self.maxHealth))
                
            
            if prefix == 0: #方块代表武器
                print("[INFO] You find the new weapon %s!" % self.card.card2str(index))
                self.finalKill = 0
                self.weapon = index  #重新装配武器
    

5 游戏展示

5.1 游戏代码

  • 在三个类的加持下,游戏的主代码非常简单
    game = Game()
    
    while(game.step() == 0):
        pass
    
  • 游戏画面如下:
    • 第一回合
      在这里插入图片描述

    • 第二回合
      在这里插入图片描述

    • 第三回合
      在这里插入图片描述

    • 不小心输错了的情况下,也会有错误反馈

      在这里插入图片描述

    • 游戏结束(这分惨得很)
      在这里插入图片描述


  • 3
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值