☰
用Python写集五福小游戏:随机抽取、集合去重与存档全解析
2026/10/12 23:48:48 网站建设 项目流程

第一次在朋友圈看到别人晒集五福的进度,我第一反应不是去到处扫卡,而是琢磨另一个问题:这个东西我能不能自己写一个?后来花了一个晚上的时间,用 Python 打出了一个能跑的“集五福”小游戏。也就是这次尝试,让我对随机逻辑、数据去重、文件持久化这些基础概念有了特别直观的理解。

今天的博文不是介绍什么高深框架,而是完整拆解这个小项目的设计思路、核心代码和调试过程。你不需要有很强的编程基础,只要学过 Python 语法基础,比如list、dict、while循环、random库,就可以跟着做出来。做完之后你会有一种很扎实的获得感:花里胡哨的网页活动,自己动手也能复刻个七七八八。

1. 项目整体设计与思路拆解

1.1 集五福的玩法本质是什么

先别急着写代码,我们把规则抽象一下。线上集福活动的核心规则特别简单:有一批卡片,系统每次从卡池里随机发一张给你,你收集到五张不同的卡就算达成目标。听起来容易,难点在于“随机”这两个字。如果一张卡总是重复出现,你抽了几十次还缺一张,体验就会变得非常急躁。

这个规则映射到程序里,其实就三个核心要素:

  • 一个卡池:包含五种福卡的全部类型。
  • 一个抽取动作:每次从卡池中随机取出一张。
  • 一个收集容器:保存你已经拥有的卡片,并且自动忽略重复项。

这三个要素分别对应 Python 里的列表、随机函数和集合。搞清楚这一点,代码结构就清晰了。很多人写这种游戏先从界面开始,这是反的,应该先写逻辑,把核心规则跑通,再加菜单和界面。版本 1 不需要任何 GUI,命令行交互足够用。

1.2 为什么用 Python 做这个项目

Python 作为入门语言,写这种小游戏最大的优势是开发速度快。从新建文件到跑通第一个抽卡动作,通常不会超过二十分钟。你不需要配置环境变量,也不需要引入重型依赖,一个标准库里的random模块就能完成最关键的功能。

此外,Python 数据结构表达能力很强。五种卡片用字典去存,每种卡片带权值、名称、说明,后面想调概率、加新卡,改动成本低。这种“先跑通,再优化”的开发节奏,很适合业余时间做小项目。我见过很多人一上来就想用游戏引擎,最后被对象树和场景管理劝退;相反,从命令行小游戏开始,每一步都能看到成果,动力反而更足。

1.3 这一版要完成哪些功能

这一版的功能目标,我定义为“最小可用集卡循环”:

  1. 程序启动后显示当前拥有哪些福卡。
  2. 用户输入指令抽卡,模拟一次随机抽取。
  3. 抽到新卡自动加入收藏,抽到已有卡片则提示重复。
  4. 集齐五种卡片时,提示完成并询问是否重新开始。
  5. 支持退出和查看背包两种基础操作。

不必要的功能一概不做。比如积分系统、好友赠送、浇花、扫一扫识别之类,全都是后话。先把这个循环跑通,后续再加什么都容易。

2. 核心数据设计:五张卡怎么存最舒服

2.1 福卡的语义设计

既然叫“集五福”,卡片就得有五张,而且得有个好听的名字。我在本地项目里用的是:健康福、平安福、好运福、团圆福、财富福。每张卡我还加了一句祝福语,展示的时候不至于太干。这个设计是模仿线上活动的包装,但实现起来很简单,就是一个带嵌套结构的字典列表。

FA_CARD_POOL = [ {"name": "健康福", "message": "身体倍儿棒,吃饭倍儿香"}, {"name": "平安福", "message": "出入平安,顺顺当当"}, {"name": "好运福", "message": "锦鲤附体,好运连连"}, {"name": "团圆福", "message": "家庭美满,围炉夜话"}, {"name": "财富福", "message": "财源广进,钱包鼓鼓"}, ]

这里有个很容易纠结的点:很多人会用list直接写死这五种卡,这没问题;但如果你想以后做概率加权,比如某张卡更稀有,那你就需要写成字典结构。提前用字典,后面改成加权抽取的时候,只需要加一个weight字段,而不用重写整个卡池。

提示:给数据加一层结构化包装,永远比直接写裸变量更值得。刚开始可能看不到好处,一旦项目从五张卡变成五十张卡,差距就是修改十行和修改二百行的区别。

2.2 用集合还是列表来存背包

这里是一个新手非常容易踩坑的地方。你要记录“已经拥有的卡片”,那么请你先想一想,玩这个游戏,同一种卡片拥有多张是否有意义?在集福规则里,一张健康福和十张健康福是等价的,系统只关心“你有没有”,不关心“你有几张”。

所以正确做法是用集合set()存已有卡片的名称集合。为什么不用列表?因为列表会存重复项,每次你都要自己判断if card in my_cards,判断本身倒是不费力,但代码会变得啰嗦。集合自带去重,add的时候如果元素已经存在,集合不会重复添加,而且in判断的执行效率也比列表高。虽然这里五张卡量级没有任何性能差别,但养成用正确数据结构的习惯,比代码本身更重要。

# 推荐 collected = set() collected.add("健康福") collected.add("健康福") # 不会重复 # 也可以但冗余 collected_list = [] if "健康福" not in collected_list: collected_list.append("健康福")

2.3 抽卡概率的两种玩法

最简单的方式,是用random.choice(pool)从五张卡里等概率选择一个。这适合版本 1 快速实现。但你玩过线上活动就知道,不同福卡的稀有度完全不一样,往往某一两张特别难抽。

要想实现“某些卡更难出”,就得引入概率权重设计。核心思路是:先构造一张带权重的表,计算总权重,生成一个0到总权重之间的随机数,然后逐项累积,确定随机数落在哪一段区间。

import random def draw_card(weighted_pool): total_weight = sum(card["weight"] for card in weighted_pool) rand_val = random.uniform(0, total_weight) cumulative = 0 for card in weighted_pool: cumulative += card["weight"] if rand_val <= cumulative: return card

我当时版本 1 先用了random.choice,因为结构简单、更容易调试。等你把完整流程跑通,再把这段加权逻辑替换进去,你会发现抽卡体验立刻有了层次感。建议读者也这样走:先跑通,再调优。

3. 完整代码实现与逐步讲解

3.1 代码目录与运行环境

先准备环境。我这边用的是 Python 3.10,其实 3.8 以上都行。不需要pip install任何第三方库,只需要标准库random、os和sys。这也是这个项目适合初学者的重要原因:零依赖,一个.py文件搞定全部功能。

项目结构非常简单:

collect_wufu/ └── wufu_game.py

所有代码写在一个文件里,总共一百多行。对于这个量级的练习项目,单文件是合理的。如果以后要加 GUI、加数据库,再拆分成多个模块不迟。

3.2 先搭建卡池函数模块

我们先把基础数据结构和几个独立的小函数写出来。独立函数方便单独测试。这里我写了一个显示当前背包的函数,一个抽取卡片的函数,一个判断是否集齐的函数。

import random import os FA_CARD_POOL = [ {"name": "健康福", "message": "身体倍儿棒,吃饭倍儿香"}, {"name": "平安福", "message": "出入平安,顺顺当当"}, {"name": "好运福", "message": "锦鲤附体,好运连连"}, {"name": "团圆福", "message": "家庭美满,围炉夜话"}, {"name": "财富福", "message": "财源广进,钱包鼓鼓"}, ] collected = set() total_draws = 0 def draw_one(): """从卡池随机抽一张卡""" return random.choice(FA_CARD_POOL) def add_card(card): """把抽到的卡加入背包,返回是否为新卡""" global total_draws total_draws += 1 name = card["name"] if name in collected: return False collected.add(name) return True def is_finished(): return len(collected) == len(FA_CARD_POOL) def show_status(): os.system("cls" if os.name == "nt" else "clear") print("========== 集五福 · 本地版 ==========") print("已抽次数:", total_draws) print("当前拥有:{}/{}".format(len(collected), len(FA_CARD_POOL))) for card in FA_CARD_POOL: mark = "✔" if card["name"] in collected else "✘" print(f"[{mark}] {card['name']}: {card['message']}") print("=====================================")

如果你是在 Windows 系统上运行,os.system("cls")会清屏;macOS 或 Linux 上则用clear。这里用os.name做了系统判断,在跨平台分享代码时能省去别人手动改代码的麻烦。

注意:show_status()函数内部使用了全局变量total_draws,读取全局变量不需要声明global,只有修改的时候才需要。类如add_card内部对total_draws做自增运算,所以必须写global total_draws。这个细节初学者特别容易漏,漏了就会看到UnboundLocalError: local variable 'total_draws' referenced before assignment。

3.3 主循环与用户交互

主程序应该是整个项目的骨架。我设计成最简单的文本菜单循环:输入1抽卡,输入2查看背包,输入3重置游戏,输入4退出。

def main(): while True: show_status() if is_finished(): print("恭喜你集齐了五福!🎉") print("按 3 重置游戏,按 4 退出") print("请选择操作:") print("1. 抽一张卡") print("2. 查看背包") print("3. 重置游戏") print("4. 退出") choice = input("> ").strip() if choice == "1": card = draw_one() is_new = add_card(card) if is_new: print(f"\n抽到:{card['name']}!{card['message']}") else: print(f"\n抽到重复的 {card['name']},差一点点运气……") input("按回车继续...") elif choice == "2": show_backpack() input("按回车继续...") elif choice == "3": reset_game() print("背包已重置") input("按回车继续...") elif choice == "4": print("下次再战,祝你好运!") break else: print("无效输入,请重新选择") input("按回车继续...")

主循环的写法其实没有什么技巧含量,但它把整个游戏串起来了。很多人写代码容易陷入“逻辑完美主义”,觉得主函数应该很优雅、很精炼。但实际上游戏项目的主循环本质上就是一个状态机,不同指令对应不同动作,状态用collected集合表达。只要状态清楚、动作清楚,主循环写成这样反而最好理解。

3.4 辅助函数补齐

刚才主循环里出现了show_backpack和reset_game两个还没定义的函数,现在补齐。show_backpack只展示集合里的元素;reset_game负责把背包清空、抽卡次数归零。这里注意,既然我们用全局变量存状态,重置函数就必须同时重新赋值这两个变量。

def show_backpack(): if not collected: print("背包空空如也,快去抽卡吧~") return print("当前背包:") for name in sorted(collected): print(" ·", name) def reset_game(): """重置游戏状态""" global collected, total_draws collected = set() total_draws = 0

为什么要用global?因为collected = set()这个语句是在函数内部给变量重新绑定了新对象,Python 会认为它是一个局部变量。如果不加global声明,程序运行到这句就会报错。而在入口处collected已经定义,你表面上看起来是在“修改”,但解释器不这么理解,它看到=就认为是局部变量。这是 Python 作用域规则里最经典的一个陷阱。

3.5 完整代码的结构拼接

把所有代码拼到一起,这个版本就能跑了。我当时跑通第一个“集齐五福”的时候,顺手截了个图发到群里,朋友说这么简单的东西也值得发。但说实话,做这类项目的成就感,不在于它的技术含量,而在于你亲手把一套规则变成了可交互的程序。

这里再补一个让代码更规范的细节:可以加上if __name__ == "__main__"的入口判断。这样当你以后把函数模块给别人复用时,不会因为模块被导入而自动执行主循环。虽然当前项目是单文件,但这个习惯从第一天开始养成,一点也不亏。

if __name__ == "__main__": main()

4. 图形界面版本与体验升级

4.1 为什么从命令行升级到窗口版

命令行的版本跑通以后,你会立即发现一个痛点:视觉效果太朴素。抽到一个新卡,只是一行文字;没有卡片样式,没有颜色,没有那种“开卡”的兴奋感。这倒不是必须解决的需求,但如果你想把它分享给朋友,命令行版本说服力不够。

我用 Python 自带的tkinter做了一个图形界面版本。这个版本最大意义在于:你不需要额外安装任何 GUI 库,Python 标准库自带,改造成本很低。当然,功能上它依然只是“展示卡片”和“按钮抽卡”,没有复杂的动画,但对于一个本地练习项目,已经足够。

4.2 做一个最小可用的 tkinter 界面

下面是简化后的核心代码。思路很简单:初始化一个窗口,左侧放一个标签用来展示卡片文字,右侧放按钮,点击按钮调用抽卡逻辑,更新标签内容。

import tkinter as tk import random POOL = ["健康福", "平安福", "好运福", "团圆福", "财富福"] root = tk.Tk() root.title("集五福 · 桌面版") root.geometry("420x300") card_label = tk.Label(root, text="点击下方按钮抽卡", font=("微软雅黑", 20)) card_label.pack(pady=40) result_label = tk.Label(root, text="", font=("微软雅黑", 12)) result_label.pack() def draw_once(): card = random.choice(POOL) card_label.config(text=card) result_label.config(text="抽到了:" + card) btn = tk.Button(root, text="抽一张", command=draw_once, width=15, height=2) btn.pack(pady=20) root.mainloop()

这个版本大概只有四十行,但它已经是一个完整的 GUI 应用。很多初学者被“图形界面”四个字吓到,实际上 tkinter 的标签、按钮、布局就是几个组件的组合,根本没有那么复杂。你唯一需要记住的,是mainloop()会让窗口保持响应状态,只处理事件,不用手动写循环。

4.3 给图形界面添加收藏统计

光抽卡没有收藏进度,游戏体验依然不够完整。可以加一句状态提示,在每次抽卡后更新,显示“已拥有 3/5”。我习惯把这种状态逻辑单独写一个函数,界面只负责调用,不要和抽卡逻辑混在一起。

collected = set() def draw_once_action(): card = random.choice(POOL) collected.add(card) card_label.config(text=card) status_label.config(text=f"当前进度:{len(collected)} / {len(POOL)}") if len(collected) == len(POOL): status_label.config(text="集齐五福!程序即将关闭") btn.config(state="disabled") root.after(1500, root.destroy)

这里出现了一个很实用的功能:root.after(1500, root.destroy),意思是 1500 毫秒后自动执行销毁窗口。这比用time.sleep()处理等待要优雅得多,因为sleep会卡住整个界面事件循环,按钮和标签都会变成“假死”状态。GUI 编程的时间等待,优先用after,这是新手最容易踩的坑。

4.4 界面资源和压缩打包

图形版本做好之后,如果想让朋友直接运行,最好打成可执行文件。我常用的工具是PyInstaller,一条命令就能把包含依赖的程序打包成独立的.exe文件。命令如下:

pip install pyinstaller pyinstaller -F -w wufu_gui.py

解释一下参数含义:-F打包成单个可执行文件,-w表示运行时不会弹出控制台黑框。如果你需要自定义程序图标,可以加-i icon.ico。不过要特别注意,打包出来的文件大小可能超出预期,因为 Python 解释器本身要一起被塞进去。这属正常现象,不用纠结。

提示:打包工具不会帮你处理资源文件路径。如果你在代码里读取了本地图片,最好用sys._MEIPASS机制处理好路径,否则打包出的程序在别的机器上会报找不到文件。单文件打包的典型坑,第一次打的人几乎都会遇到。

5. 常见问题与调试心得

5.1 为什么抽了一百次还集不齐五福

这是一个概率问题,也是版本 1 最容易暴露的问题。如果你用random.choice等概率抽取,理论上五张卡各有 20% 的概率,但实际运行中经常会出现某张卡长时间不出。这并不代表代码有 bug,而是随机数本身的波动特性。

把需求再拔高一点,线下活动的“集齐难度”通常是故意调过的。如果我希望某张卡概率低到 5%,其余四张平分剩余概率,就必须用前面提到的加权抽取方式。改法很容易,核心是体会到“游戏体验本质上靠概率设计,而不是靠随机本身”。

5.2 函数里修改全局变量报错怎么办

这个坑上文提过,再展开一点。当你写出这段代码:

collected = set() def add_card(card_name): collected.add(card_name)

它是能正常运行的,因为collected.add是调用对象方法,并没有重新绑定变量。但一旦你写成:

def reset_game(): collected = set()

Python 就会把它当局部变量。遇到这种问题,我的排查建议是:先看函数内部有没有给这个变量赋值的等于号,有的话,基本就是作用域问题。要么在最前面加global collected,要么把函数改成返回值的形式,避免使用全局变量。对初学者,我更推荐后者,因为显式传参和返回,会让数据流向更清楚。

5.3 程序一运行就闪退

如果双击.py文件后窗口一闪而过,最常见的原因是程序执行到了异常但没有被看到,运行直接退出了。解决方案有两种。第一种,在终端中手动运行文件,异常信息会停留在终端里,方便排查。第二种,在主循环外面套一层try/except,捕获异常后打印到窗口并暂停:

if __name__ == "__main__": try: main() except Exception as e: print("程序运行出错:", e) input("按回车退出...")

这个写法不解决 bug,但能让你看清 bug。对初学者来说,让错误可见比修复错误本身更重要。

5.4 输入内容没反应或者乱码

版本 1 的用户输入是我用input()实现的。如果用户输入的是数字但带空格,比如" 1 ",那choice == "1"判断就不成立,程序会一直提示无效输入。解决办法是取输入后立刻执行.strip()去除两端空白,这是我上面代码里已经用到的小细节。

另外还有一个中文字体显示问题,主要在 Windows 控制台中容易遇到:中文显示成方块或者乱码。一般是因为系统编码不是 UTF-8。你可以在文件第一行加编码声明,但对于 Python 3 来说,更稳妥的方式是确保源码文件用 UTF-8 保存,并在控制台里执行chcp 65001切换代码页。这类环境问题排查通常很烦人,但和代码逻辑无关,别钻牛角尖。

5.5 随机性测试不能“只跑一次”

很多初学者验证抽卡概率时,只运行一次程序,看到结果和预期不符就开始怀疑代码。正确的做法是做一个蒙特卡洛模拟:循环执行一万次抽卡,统计每种卡的出现频率,看看是否接近设置的概率。这个思路在游戏开发后期特别重要,因为线上活动需要保持“奖池概率”的透明度和稳定性。

from collections import Counter sim_results = Counter(random.choice(POOL) for _ in range(10000)) for k, v in sim_results.items(): print(k, v / 10000)

运行大幅抽样以后,你才会真正理解随机不等于平均,也会对概率设计产生更实际的感觉。这比背再多概率公式都管用。

6. 玩法扩展与长期优化方向

6.1 每日任务与抽卡次数限制

真实集福活动不会让你无限抽卡,否则一天就集齐了。版本 2 可以加入一个简单的次数限制:每天只能抽三次,抽完就提示“明天再来”。实现方式是用datetime记录上一次重置抽卡次数的日期,当日期变化时把剩余次数恢复。这样游戏的耐玩度会明显提升,也让你第一次体验到“限制资源”对游戏节奏的影响。

6.2 数据持久化:重启游戏不丢进度

读者如果玩过几次,一定会发现当前版本重启后背包就清空了。保存进度是绕不开的需求。最简单的方案是存成 JSON 文件。每个数据项都是字符串或数字,天然适合 JSON 序列化。

import json def save_game(): data = { "collected": list(collected), "total_draws": total_draws, } with open("save.json", "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) def load_game(): global collected, total_draws try: with open("save.json", "r", encoding="utf-8") as f: data = json.load(f) collected = set(data.get("collected", [])) total_draws = data.get("total_draws", 0) except FileNotFoundError: pass

这里有个容易忽略的细节:JSON 只能保存基本类型,set不能直接序列化,所以保存时转成列表,读取时再转回集合。每次进主循环前自动加载,每次抽卡后自动保存,玩家的本地存档也就有了基础模型。再往前一步,你还可以做卡片收藏记录、统计最稀有的卡是多少抽才出,这些都会让项目越来越有料。

6.3 添加好友互赠和“沾福气”

如果你连朋友之间的玩法也想模拟,纯本地版可以做一个最简单的“赠送模拟”:复制一份背包状态文件给朋友,朋友导入后,如果拥有你没有的卡,可以选择一张赠与你。考虑到安全性和复杂度,本地版不必做得太认真,毕竟真正的线上活动需要服务端和数据库来保证数据不被篡改。但作为理解“协作式收集”玩法的动手实验,它很有价值。

6.4 用日志系统记录抽卡历史

最后建议加一个简单的抽卡日志。每次抽卡后把结果、时间戳写入一个log.txt。别小看这个功能,它就是以后做数据分析和概率验证的基础。如果你以后想对比不同概率方案对“平均几天集齐”的影响,日志是不可或缺的证据。

import datetime def write_log(card_name, is_new): timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") with open("log.txt", "a", encoding="utf-8") as f: f.write(f"[{timestamp}] {card_name} new={is_new}\n")

把日志写进游戏以后,你会发现“调试游戏”一下子变成了“分析游戏数据”。这种视角的转变,比任何教程都更能催生你做项目的兴趣。

我个人做了几轮之后,最深的体会不是“代码有多么优雅”,而是把一套规则尤其是随机规则,准确翻译成程序逻辑,这件事本身需要持续的迭代和验证。我从最开始等概率抽取,到后来调整权重,再到加入平滑次数限制机制,每一步改动的底层逻辑都是围绕着玩家体验展开的。版本 1 只是迈出去的第一小步,但正是这一步让我意识到:看似复杂的线上玩法,剥掉包装和商业系统以后,核心逻辑往往就是几个数据结构和三五个函数的事。建议感兴趣的朋友今晚就打开编辑器,哪怕只写一个抽卡函数,也比刷满一百条教程强得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询