
前阵子清理旧硬盘我翻出一个十几年前用纯标准库写的小脚本居然还能原地跑起来旁边那个去年上线的内部工具反而因为某个依赖升级已经躺了一个多月没人敢碰。这件事直接催生了 caveman 这个项目——一个不依赖任何第三方包、全程只用标准库实现的终端生存游戏。你要扮演一名原始人在食物和体力都有限的前提下尽可能活得更久中间穿插各种随机事件。整个项目就一条纪律能自己动手解决的绝不引入外部依赖。1. caveman是什么一个原始人的终端生存游戏1.1 为什么选“原始人”题材零依赖工程的隐喻选原始人这个题材不是突然对史前生活来了兴趣而是原始人的生存状态和我在项目里给自己定的工程纪律高度重合。原始人没有现成的工具就自己削石头没有火种就自己钻木取火没有房子就自己找山洞。这就是货真价实的“零依赖生存”。放在写程序这件事上就是不引第三方库、不搭框架、不用脚手架生成的魔法代码只靠一门语言的标准能力和一点数据结构常识把一个有完整逻辑的东西做出来。说实话这种“硬碰硬”的做法会把很多平时被框架藏起来的底层问题重新暴露到你眼前。比如我自己用 Vue 写过不少后台页面但从来没认真想过一次状态机到底怎么流转直到在这个几百行的终端游戏里手写状态分发我才对“程序是怎么从一个状态跳到另一个状态”这件事有了真正扎实的直觉。这就是这个项目对我最大的价值。从练手角度来说生存模拟也是再合适不过的题材。它麻雀虽小五脏俱全有明确的主循环、有状态流转、有随机事件、有数值系统、有存档需求还要处理终端输入输出和异常情况。这些东西在 Web 项目里往往被框架拆得七零八落但在这个小游戏里你绕不开任何一个环节。1.2 核心设定四项数值、四类行动、一个目标我先把整个游戏的设定一口气讲清楚后面章节再展开代码。caveman 的核心是四项属性每一项都直接影响游戏的走向属性初始值作用与归零后果生命值100生命归零直接死亡打猎受伤、极端事件都会扣体力100每次行动都要消耗体力过低时行动成功率明显下降食物80每天固定消耗食物归零后开始持续扣生命心态60影响随机事件池和行动收益归零后判定角色放弃生存玩家每个回合可以从四类行动里选一个行动主要效果风险打猎食物20~35体力-1515%概率受伤生命-10~20采集食物8~15体力-8几乎无风险休息体力25无探索触发一个随机事件收益与风险完全取决于事件池游戏目标很朴素生存满 30 天就算通关解锁“火种”结局之后可以选择继续挑战更长天数。第 30 天这个数字不是随便定的——它刚好需要你认真规划食物和体力的节奏又不会长到让人失去耐心。实测下来大多数玩家在 15 到 20 天左右迎来第一次死亡之后才会慢慢摸到数值运营的规律。2. 技术选型用纯标准库硬扛一个完整游戏2.1 被依赖“坑”过之后的执念先声明我不是框架过敏的极端派日常工作里该用的包我照样用。但如果你也经历过“版本升级后全线崩溃”“两个包互相要求不同版本的 Python”“一个 hello world 项目装出上百兆依赖”这些事那你应该能理解为什么我会想做一个反向实验。caveman 的技术选型出奇地简单语言Python 3.8外部依赖零仅使用标准库random、json、time、os、ctypes运行方式python caveman.py选 Python 而不是 Go 或 Rust不是因为它性能好而是它的标准库覆盖了终端游戏需要的几乎全部能力输入输出有input()和print()随机事件有random存档有json转场延时用time一个外部包都别想跑。2.2 零依赖真正省下的是什么很多人觉得零依赖只是“少装几个包”的事但实际省掉的东西远不止磁盘空间。第一是省掉了环境问题。任何一台装好 Python 3.8 的机器不管 Ubuntu、macOS 还是 Windows把项目拖过去就能跑。不会有“在你机器上是好的啊”这种黑话也不会有pip install到一半爆红的尴尬。第二是省掉了升级焦虑。没有依赖就没有“要不要升级、升了会不会坏、坏了跟谁哭”的纠结。依赖树越深你身上背的隐性债务越重这个道理在做一个长期维护的小工具时尤其明显。第三是省掉了安全审计。标准库的漏洞面和供应链风险比几十个第三方包低得多。哪怕项目再小只要接入网络生态依赖链安全就永远是个问题。当然零依赖也有代价。我没有现成的 colorama做终端颜色得自己拼 ANSI 转义序列没有现成的 prompt_toolkit输入防呆就得自己写。这些“麻烦”恰恰是项目想让你体会的部分——你以为你在省时间其实你只是在把风险往后挪。尝过一次自己手写方案的踏实感之后你对“依赖”这两个字的理解会完全不一样。2.3 四个模块的职责划分虽然是零依赖我也没有把代码全塞进一个文件。caveman 分了四个模块按职责清晰切分caveman/ ├── main.py # 入口启动、菜单、游戏主循环 ├── game_data.py # 纯数据属性常量、行动表、事件表、状态定义 ├── engine.py # 核心逻辑状态机、随机事件、数值结算、死亡判定 └── ui.py # 终端交互打印、输入解析、ANSI颜色处理这个分法不是拍脑袋定的。game_data.py里全是“不会变的东西”我把它当游戏策划表engine.py是“会跑的逻辑”相当于后端服务ui.py是“给人看的东西”相当于前端。哪怕零依赖的小项目把数据和逻辑分开、把展示和核心分开都会让后面的调试和扩展轻松十倍。后面讲到往 Web 迁移时你会看到这个决定有多关键。3. 核心代码拆解状态机、事件引擎和存档系统3.1 字典驱动的轻量状态机与主循环终端游戏的核心是一个循环显示当前状态 → 接收输入 → 结算逻辑 → 输出结果 → 切到下一个状态。为了避免写出一大坨 if-elif我用了一个字典把状态和对应的处理函数绑起来class GameStates: MENU menu NEW_GAME new_game DAY_START day_start ACTION action RESOLVE resolve NIGHT night GAME_OVER game_over STATE_HANDLERS { GameStates.MENU: handle_menu, GameStates.NEW_GAME: handle_new_game, GameStates.DAY_START: handle_day_start, GameStates.ACTION: handle_action, GameStates.RESOLVE: handle_resolve, GameStates.NIGHT: handle_night, GameStates.GAME_OVER: handle_game_over, }主循环因此变得非常短def run(): state GameStates.MENU while state ! GameStates.EXIT: handler STATE_HANDLERS[state] state handler(game, state) if game.player.hp 0: state GameStates.GAME_OVER这里有个细节值得多说一句每个 handler 的返回值就是下一个状态而不是在函数内部直接改全局状态。这样做的好处是游戏流程变成了一条可以被完整打印出来的链路。你能把“状态 A → 状态 B → 状态 C”的序列记录下来想复现任何一局游戏的任何一步只要照着状态流转顺序走一遍即可。后来我发现这个设计在排查 bug 时帮了大忙——你能精确说出“在第几天、处于哪个状态时出了问题”而不是笼统地喊一句“好像哪里崩了”。3.2 加权随机事件引擎与可复现种子随机事件是游戏的灵魂。如果每个回合都是固定的加减数值玩家很快摸清规律游戏就失去了紧张感。但我也不希望纯靠random.random()乱来否则会出现玩家连续撞上三个坏事件然后暴毙的情况这种体验非常糟糕。caveman 的事件系统是这样的每个事件带一个weight权重engine 根据玩家当前状态动态调整权重然后做加权随机选择。EVENTS [ {id: find_fruit, name: 发现野果, weight: 10, effect: {food: 12, mood: 5}}, {id: bear_attack, name: 遭遇黑熊, weight: 5, effect: {hp: -25, mood: -10}}, {id: rain, name: 暴雨, weight: 8, effect: {food: -5, mood: -5}}, {id: tribe, name: 遇见同族, weight: 6, effect: {food: 10, mood: 12}}, ] def pick_event(events, player): pool [] weight_sum 0 for ev in events: w ev[weight] # 动态权重食物极少时更容易找到食物重伤后遭遇黑熊概率降低 if ev[id] find_fruit and player.food 20: w int(w * 2.5) if ev[id] bear_attack and player.hp 30: w int(w * 0.3) pool.append((ev, w)) weight_sum w r random.uniform(0, weight_sum) cur 0 for ev, w in pool: cur w if r cur: return ev return pool[-1][0]实际写的时候我加了一个很多人会忽略的设计在新建游戏时用当前时间种下random.seed()并把种子写进存档。这样如果玩家想复现某一局的“第几天触发了什么事件”只要读档时输入当时的种子即可。后来这个功能成了我朋友做数值平衡的利器——他能拿着同一串事件序列反复调参数而不是每一局都碰上完全不同的随机数根本没法对比。3.3 数值结算的先后顺序与统一clamp数值结算我写在一个函数里每天执行一次。这里最需要注意的是顺序体力低于 0 时行动成功率下降但体力本身不能长期为负食物归零之后每回合扣生命生命值又可能在同一个结算周期里因为事件被扣到 0。我在代码里用了“先算增益、再算消耗、最后统一 clamp”的原则def apply_effects(player, effects, *, use_clampTrue): player.hp effects.get(hp, 0) player.food effects.get(food, 0) player.mood effects.get(mood, 0) player.stamina effects.get(stamina, 0) if use_clamp: player.hp max(0, min(player.hp, 100)) player.food max(0, min(player.food, 100)) player.mood max(0, min(player.mood, 100)) player.stamina max(0, min(player.stamina, 100))“统一 clamp”这四个字看着简单但它避免了大量 if 嵌套。比如你打猎获得了食物同时又被事件扣了生命如果分两步判断边界很容易出现“食物显示成 120”这类灵异数字。所有数值先无脑加减最后一次性限制在合法范围内逻辑会干净很多。死亡判定我放在每天的夜间结算之后而不是在事件发生时立刻判定。这样玩家还有一次“当回合抢救”的机会——实际玩起来就是白天被熊拍了一下只剩 2 点血但你当天选择休息夜间结算时血量会被拉回安全区。这种细节会显著影响手感属于“看似不写也能跑、写过才知道差别”的部分。3.4 存档读档原子写入与防御式校验存档功能我用标准库的json模块这个选择几乎不需要思考Python 对 JSON 的原生支持就是零依赖而且格式可读、可改、可调试。def save_game(game, pathcaveman_save.json): data { version: 1, day: game.day, seed: game.seed, player: { hp: game.player.hp, food: game.player.food, stamina: game.player.stamina, mood: game.player.mood, }, } tmp_path path .tmp with open(tmp_path, w, encodingutf-8) as f: json.dump(data, f, ensure_asciiFalse, indent2) os.replace(tmp_path, path)这里有两个经验很值得分享。第一我用临时文件加os.replace的写法先写临时文件再用原子替换而不是直接打开原文件写入。这样即使写入过程中程序突然崩溃也不会留下一个写了一半的坏存档。这个模式在做任何配置持久化的场景都适用。第二ensure_asciiFalse。很多人在纯 ASCII 环境里写中文存档时忘了这个参数存出来的文件全是\uXXXX转义虽然也能读但排查问题的时候极其痛苦——你能在终端里看到中文但打开文件全是火星文。读档的异常处理比写档更重要。玩家可能会手动改存档、删字段、把数字改成字符串我在load_game里做了三层防御文件不存在、JSON 解析失败、字段缺失或类型错误每一层都给出明确提示而不是抛一个刺眼的 stack trace。具体代码逻辑就是 try-except 逐个兜住再配合一个validate_player_data()把非法数值拉回默认区间。这些防御代码看着冗余但真遇到手欠的玩家时你会庆幸自己写了它们。4. 没有断点调试器怎么靠打桩排掉两个顽固bug4.1 读入为空的坑一次完整的排查链路这是个很典型的终端输入坑。游戏里我给了选项“1. 打猎 2. 采集 3. 休息 4. 探索”玩家输入 2 后回车程序却提示“无效选择”。我当时的第一反应是“玩家看错选项了”但连续收到两次反馈后我开始认真排查。先把代码里所有关键位置加上打印用最土的方式看数据到底长什么样raw input(请选择行动 ) print(fDEBUG|action| raw{raw!r})输出结果让我愣住——raw 显示为2看起来没有任何问题。于是继续往下追发现真正做行动分发时用的不是这个 raw而是另一个变量。那个变量来自更早写的一段代码choice input()[:1]只取第一个字符。问题就出在这里——旧版本里残留了两个连续且没有赋值的input()调用第一个把换行符连同数字一起吃掉第二个实际读到的是残留状态导致各种“选项不存在”。这个坑的根因不是解析逻辑而是“输入路径不统一”。修复方案也简单做一个全局统一的输入入口函数所有终端输入一律走它。def ask_number(prompt, min_val, max_val): while True: raw input(prompt).strip() if not raw.isdigit(): print(请输入数字。) continue val int(raw) if min_val val max_val: return val print(f有效范围是 {min_val}~{max_val}。)关键就是strip()和isdigit()的组合。strip()去掉首尾空白包括回车产生的换行符isdigit()挡住所有非数字内容之后再用int()转换。从那之后全项目所有用户输入都走这个函数再没出现过输入解析的诡异问题。回头看如果我当时不把打印打在“数据变换的边界”而是直接去看那句input()的代码不知道还得绕多久。4.2 恶意存档造成的运行时崩溃第二个 bug 更像边界问题。测试时我做了个骚操作用文本编辑器打开存档把体力从 30 改成 -999想看看读档的容错能力。结果程序直接崩了抛了个 ValueError——apply_effects里有max(0, ...)保护运行时但读档时没对存档数值做合法性校验-999 直接进入主循环后面“体力过低降低行动成功率”的百分比计算就炸了。修复方式也很直接在load_game里加一层统一校验所有数值不在 0 到 100 之间就强制拉回默认值而不是信任存档数据。这次教训让我记住了外部输入永远是攻击面。存档文件就是终端游戏的输入边界你永远不知道玩家会往里写什么。这个意识放到任何“用户可编辑配置”的场景都成立值得养成统一的防御习惯。4.3 原始人调试三板斧既然整个项目不接断点调试器我总结了一套“原始人调试法”核心是三句话打桩要打在数据变换的边界不要只打在业务逻辑内部。输入、输出、存档加载、数值结算这四个点是大多数数据的必经之路在那里打印一条链路能看穿。打印必须带上下文标签。我习惯用DEBUG|main|action这样的前缀几行日志一起出现时能一眼看出先后顺序而不是在满屏文本里人肉搜索。复现比修复更重要。caveman 把随机种子写进存档的设计就是为了让任何一局游戏都能完整复现。你复现不了的问题拿不到稳定复现路径的修复都只是一次侥幸。5. 实测试玩与数值平衡调校5.1 第一轮测试所有人都在12天饿死把完整版跑起来后我拉了几个朋友试玩收集到的反馈高度集中前几次尝试几乎全部在第 10 到 15 天之间食物耗尽死亡。这个结果其实是数值设计失衡造成的——初始食物 80每天消耗 10理论上第八天就断粮打猎虽然有 20~35 的食物产出但体力消耗和受伤风险太高采集产出又太低玩家陷入“越打猎越亏、越采集越不够”的负反馈循环。我把数值整体调了一轮目标是让“均衡发展”成为收益最高的策略而不是逼玩家无脑打猎。调整项调整前调整后初始食物80120每日食物消耗108打猎食物产出20~3520~40采集心态加成无心态不低于70时产出×1.2黑熊事件权重53这轮调整之后第一次死亡的时间从平均 13 天延长到 21 天左右同时“选择多样性”增加——玩家不再无脑选打猎而是会根据当前心态和体力在打猎、采集、休息之间做取舍。从产品角度看这才算有了可玩性。5.2 终端显示ANSI颜色在Windows下的兼容性终端游戏拼完数值还得拼信息呈现。我希望食物和体力这些关键数值一眼能被看到于是给 ui.py 加了 ANSI 颜色支持class Color: RED \033[91m GREEN \033[92m YELLOW \033[93m CYAN \033[96m RESET \033[0m这里踩了一个非常经典的坑Windows 的老版 cmd.exe 不认 ANSI 转义序列会把\033[91m当普通文字打印出来满屏都是乱码。在零依赖项目里没有 colorama 可用解决办法是写一个enable_ansi()函数用 ctypes 调用 Windows API 开启虚拟终端处理非 Windows 平台直接跳过def enable_ansi(): if os.name nt: import ctypes kernel32 ctypes.windll.kernel32 kernel32.SetConsoleMode(kernel32.GetStdHandle(-11), 7)十行不到但能让你在 Windows 上得到和 macOS、Linux 一致的颜色体验。另外我还给部分提示文本加了逐字延时打印让随机事件出现时有一点“卡点”的节奏感——纯粹是体验层面的锦上添花但也让游戏多了点“活着”的感觉而不是一坨文本瞬间砸在玩家脸上。5.3 让数据替你做平衡性决定调数值不能拍脑袋。我给游戏加了个极简统计模块每局结束记录生存天数、死亡原因、每日事件序列跑 30 局之后直接看“饿死”“被熊打死”“心态崩溃”在死亡原因里的占比。比如开局阶段黑熊事件权重过高导致玩家三天内被杀穿这个信号会在统计表里自动浮出来而不是等某个玩家偶然吐槽。统计驱动调参这个习惯在写任何带数值系统的程序时都适用。你永远应该先收集数据再动手改参数否则改完都不知道是改对了还是改巧了。6. 这个项目的延伸价值与学习路线6.1 往Web迁移时哪些代码可以原封不动搬走caveman 做完以后我一直在琢磨怎么让它“走出去”。最自然的方向是 Web把 engine.py 里的状态机和事件系统原封不动搬到一个后端跑一个轻量接口浏览器只负责展示。这一步之所以轻松是因为当时就刻意把数据处理和终端交互完全隔离了。ui.py 只是薄薄一层壳里面所有逻辑都在 engine.py 和 game_data.py 里。只要 ui 层和 engine 层的边界清晰换一个前端就只是“换一层皮”。这也是我在前文强调模块划分的原因你现在写的每一行代码到底属于“逻辑”还是“展示”直接决定了未来迁移的成本。6.2 新手和老手分别怎么拿这个项目练手如果你是零基础想拿 caveman 当入门练习我建议的阅读顺序是先读 game_data.py——里面全是数据字典读起来像看说明书再读 engine.py 里的pick_event和apply_effects理解输入输出和函数的作用然后看 main.py 的主循环理解程序是怎么一直转起来的最后才碰 ui.py 的输入解析。读的过程中遇到看不懂的就加一行 print 打印变量值或者改一个数字看看会发生什么。不用怕改坏代码——caveman 最不怕的就是改坏存档能重来程序能重启每一次试错都在把“感觉”换成“经验”。如果你已经是熟练工我反而建议你做点“不合理”的事比如把 ui.py 换成 JSON 输出让这个游戏变成可以被其他程序调用的引擎或者给事件池加一个简单的“学习算法”让角色根据前几天的天气调整行动偏好。这些玩法听起来跟“原始人”风格不太搭但能把一个简单的东西在不同场景里用得恰到好处才是所有高级技巧的起点。我做完这个项目最大的体会是依赖可以少思考不能少工具可以原始逻辑必须清晰。