
TAMX 是一个个人 Tamagotchi 项目它把经典的电子宠物概念搬进了代码世界。很多人以为这类项目只是做几个按钮点击后让宠物的“饥饿值”“快乐值”变化一下就够了。真正动手写 TAMX 这样的项目时才会发现核心难点并不是按钮交互而是如何让宠物在一段时间没有被操作时仍然按照真实时间产生状态变化。换句话说电子宠物本质上是一个“时间驱动”的状态机。这篇文章会围绕 TAMX 的最小工程闭环展开先讲清楚时间衰减机制再完成一个基于 Python 3、SQLite 和 Flask 的可运行版本。学完后你会理解状态机如何在个人小项目中落地也能把同样的模式扩展到提醒机器人、桌面小组件或浏览器通知等场景。适合阅读这篇文章的读者有两类。第一类是刚学完 Python 基础想做一个小项目补齐“数据存储 状态更新 Web 展示”经验的开发者。第二类是看过很多状态机理论但不知道什么时候该用状态机的同学。TAMX 是一个足够小但完整的例子它包含了数据结构设计、时间计算、持久化、HTTP 接口和前端页面能在半天内跑通。1. 理解 TAMX 的核心电子宠物本质是一个时间驱动的状态机1.1 为什么不能只用“点击后立刻变化”的简单逻辑如果只做一个界面点“喂食”按钮后hunger减 20点“玩耍”按钮后happiness加 15那么整个系统里只有“用户事件”一个驱动源。这种设计的问题在于宠物离开用户操作后就是静止的。早晨打开电脑时它是什么样晚上回来它还是什么样。这不符合电子宠物的基本认知宠物会在你离开的时候继续饿、继续无聊、继续变脏。因此 TAMX 需要引入第二个驱动源时间。系统必须知道“距离上次更新过去了多少分钟”并根据这段时间计算出各项属性的变化量。这个过程通常叫“时间衰减”或“状态推进”。它让宠物即使没有被任何人操作也会随着真实时间发生变化。1.2 状态机与事件驱动模型TAMX 中的宠物可以看作一个有限状态机它包含三部分。第一部分是状态集合。宠物的状态包括饥饿值、快乐值、精力值、清洁值、健康值、年龄、存活状态以及一个非常重要的字段last_update。last_update记录了上次状态计算的时间点它是时间驱动逻辑的基础。第二部分是事件集合。事件分两类用户动作和时钟推进。用户动作包括喂食、玩耍、清洁、睡觉时钟推进代表“系统检测到当前时间已经晚于上次更新时间”。第三部分是迁移函数。每次事件发生后系统根据当前状态和事件内容计算下一状态。例如用户点击“喂食”迁移函数把饥饿值降低系统发现距离上次更新已经过了 120 分钟迁移函数就按衰减规则把饥饿值提高。用文字表达迁移逻辑就是当前状态 外部事件 - 迁移函数 - 下一个状态 当前状态 当前时间 - 衰减计算 - 推进后的状态这是一个非常典型的“事件驱动状态机”模型TAMX 只是把它应用到了虚拟宠物上。1.3 时间驱动的最小设计决策实现时间驱动有两种常见方案。方案一常驻循环。程序每秒刷新一次主动把宠物状态推进到当前时间。这种方案直观但在个人项目中很浪费资源而且如果程序没有启动宠物在离线期间的状态就无法推进。方案二惰性更新。不额外启动循环而是在每次读取或操作宠物时先根据last_update与当前时间的时间差计算应有的状态变化再把结果写回数据库。这种方案不依赖后台任务程序重启后也能正确补算离线时间。TAMX 采用惰性更新。它更适合本地小项目也更容易理解。后面的状态引擎会围绕这个思路展开。2. 环境准备用 Python 3 SQLite Flask 搭出最小工程2.1 技术选型背后的考虑TAMX 这类个人项目不需要一开始就引入大型框架。选型原则是依赖少、启动快、数据持久化简单。Python 3状态引擎和 HTTP 接口的编写成本低标准库已经覆盖 SQLite 操作适合快速验证逻辑。SQLite单文件数据库不需要单独安装数据库服务。TAMX 的数据量很小一张pets表就足够SQLite 能很好满足需求。Flask可以同时提供 JSON API 和简单 HTML 页面。开发环境里一个进程就能跑起来方便调试。这个组合不是唯一选择但非常适合描述 TAMX 的核心逻辑。实际项目中也可以用 Node.js lowdb或者 Java JPA但核心的状态迁移思想是通用的。2.2 目录结构与依赖初始化建议先创建一个干净的目录tamx/ ├── app.py ├── pet_service.py ├── init_db.py ├── requirements.txt └── templates/ └── index.html各文件职责如下文件职责init_db.py初始化 SQLite 数据库创建pets表并插入默认宠物pet_service.py核心状态引擎负责时间衰减、动作处理和写回数据库app.pyFlask 应用提供 Web 页面和 JSON APIrequirements.txtPython 依赖列表templates/index.html宠物状态展示与操作按钮页面创建虚拟环境并安装依赖python -m venv venv source venv/bin/activate # Windows 使用 venv\Scripts\activate pip install flask pip freeze requirements.txt如果你的网络环境不允许pip freeze requirements.txt直接覆盖也可以手动创建requirements.txtFlask2.0启动前先确认 Flask 安装成功python -c import flask; print(flask.__version__)这一步能提前发现 Python 环境问题避免后面运行app.py时才报ModuleNotFoundError。3. 数据模型用一张表保存宠物的全部状态3.1 字段设计与取值范围TAMX 的状态数据可以用 SQLite 单表保存。字段设计直接影响状态引擎的计算复杂度所以每个字段都要有明确的范围和含义。字段类型含义初始值取值范围变化逻辑idINTEGER主键自动-每条宠物记录唯一nameTEXT宠物名字tamx-用户可修改hungerINTEGER饥饿值00-100随时间增加喂食后减少happinessINTEGER快乐值800-100随时间减少玩耍后增加energyINTEGER精力值800-100随时间减少睡觉后增加cleanlinessINTEGER清洁值800-100随时间减少清洁后增加hpINTEGER健康值1000-100饥饿或太脏时减少否则缓慢恢复ageINTEGER年龄0-每 60 分钟增加 1aliveINTEGER存活状态10 或 1hp 0时置为 0last_updateINTEGER上次更新时间戳当前时间-每次计算后更新created_atINTEGER创建时间戳当前时间-只写一次hunger的初始值是 0 而不是 50是因为“饥饿”和“饱腹”是相反的。0 表示不饿数值越高表示越饿。这样在规则表达上更直观饥饿值超过 80宠物会因为太饿而掉健康。3.2 建表 SQL 与初始化脚本创建init_db.py内容如下import sqlite3 import time DB_PATH tamx.db def init_db(): conn sqlite3.connect(DB_PATH) try: conn.execute( CREATE TABLE IF NOT EXISTS pets ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, hunger INTEGER NOT NULL DEFAULT 0, happiness INTEGER NOT NULL DEFAULT 80, energy INTEGER NOT NULL DEFAULT 80, cleanliness INTEGER NOT NULL DEFAULT 80, hp INTEGER NOT NULL DEFAULT 100, age INTEGER NOT NULL DEFAULT 0, alive INTEGER NOT NULL DEFAULT 1, last_update INTEGER NOT NULL, created_at INTEGER NOT NULL ) ) now int(time.time()) cursor conn.execute( SELECT COUNT(*) FROM pets ) if cursor.fetchone()[0] 0: conn.execute( INSERT INTO pets (name, hunger, happiness, energy, cleanliness, hp, age, alive, last_update, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?), (tamx, 0, 80, 80, 80, 100, 0, 1, now, now) ) conn.commit() finally: conn.close() if __name__ __main__: init_db() print(TAMX database initialized.)这段脚本有两个关键点。第一CREATE TABLE IF NOT EXISTS保证了重复执行不会报错。第二插入默认宠物时会先检查表中是否有数据避免每次执行都插入多条记录。实际项目中DB_PATH应该从环境变量读取这里先保持简单。执行初始化python init_db.py运行后目录下会出现tamx.db文件。可以用 sqlite3 命令确认表结构sqlite3 tamx.db .schema pets如果系统没有sqlite3命令也可以用 Python 查看import sqlite3 conn sqlite3.connect(tamx.db) for row in conn.execute(PRAGMA table_info(pets)): print(row) conn.close()4. 状态引擎基于时间差计算属性衰减4.1 核心思路惰性更新代替死循环TAMX 的状态引擎不需要每秒都跑。每次读取宠物或执行动作时先执行“同步”步骤。同步步骤根据last_update与当前时间的差值计算这段时间内各项属性的变化然后更新last_update并写回数据库。这样做有三个好处。第一节省资源。没有请求时程序不做任何事本地服务不会空转。第二离线补算。开发时关闭程序两小时再启动读取宠物时会一次性把两小时的衰减结果计算出来不需要程序一直在线。第三逻辑集中。所有状态变更都经过同一个同步函数不容易出现多处修改状态导致规则不一致的问题。4.2 衰减规则公式化TAMX 的衰减规则如下表属性衰减规则饥饿值每 10 分钟增加 1 点快乐值每 15 分钟减少 1 点精力值每 20 分钟减少 1 点清洁值每 30 分钟减少 1 点健康值当饥饿值大于等于 80 或清洁值小于等于 20 时每 5 分钟减少 2 点否则每 30 分钟恢复 1 点年龄每 60 分钟增加 1 岁死亡条件健康值小于等于 0这里面有一个需要仔细处理的点健康值的变更方向取决于其他属性所以必须先计算饥饿值和清洁值再计算健康值。如果先计算健康值就会拿到过期的饥饿和清洁数据。4.3 Python 实现 PetService创建pet_service.pyPut the whole core state engine hereimport sqlite3 import time DB_PATH tamx.db def _clamp(value, low0, high100): return max(low, min(high, value)) class PetService: def __init__(self, db_pathDB_PATH): self.db_path db_path def get_connection(self): conn sqlite3.connect(self.db_path, timeout10) conn.row_factory sqlite3.Row return conn def load_pet(self, pet_id1): conn self.get_connection() try: row conn.execute( SELECT * FROM pets WHERE id ?, (pet_id,) ).fetchone() if row is None: raise ValueError(fpet {pet_id} not found) pet dict(row) finally: conn.close() self.sync_state(pet) self.save_pet(pet) return pet def save_pet(self, pet): conn self.get_connection() try: conn.execute( UPDATE pets SET hunger ?, happiness ?, energy ?, cleanliness ?, hp ?, age ?, alive ?, last_update ? WHERE id ? , ( pet[hunger], pet[happiness], pet[energy], pet[cleanliness], pet[hp], pet[age], pet[alive], pet[last_update], pet[id], ), ) conn.commit() finally: conn.close() def sync_state(self, pet): if not pet[alive]: return now int(time.time()) minutes (now - pet[last_update]) // 60 if minutes 0: return pet[hunger] _clamp(pet[hunger] minutes // 10) pet[happiness] _clamp(pet[happiness] - minutes // 15) pet[energy] _clamp(pet[energy] - minutes // 20) pet[cleanliness] _clamp(pet[cleanliness] - minutes // 30) if pet[hunger] 80 or pet[cleanliness] 20: pet[hp] pet[hp] - (minutes // 5) * 2 else: pet[hp] _clamp(pet[hp] minutes // 30) pet[age] minutes // 60 pet[last_update] now if pet[hp] 0: pet[hp] 0 pet[alive] 0 def perform_action(self, pet_id, action): pet self.load_pet(pet_id) if not pet[alive]: return pet if action feed: pet[hunger] _clamp(pet[hunger] - 20) pet[cleanliness] _clamp(pet[cleanliness] - 10) elif action play: pet[happiness] _clamp(pet[happiness] 15) pet[energy] _clamp(pet[energy] - 10) pet[hunger] _clamp(pet[hunger] 5) elif action clean: pet[cleanliness] _clamp(pet[cleanliness] 30) elif action sleep: pet[energy] _clamp(pet[energy] 40) pet[hunger] _clamp(pet[hunger] 5) else: raise ValueError(funknown action: {action}) pet[last_update] int(time.time()) self.save_pet(pet) return pet这段代码有几个细节值得注意。健康值计算部分没有使用_clamp包住所有情况是因为当健康值降到 0 时需要让alive变为 0。如果直接用_clamp到 0 后再判断逻辑也可以但要注意hp 0的判断要放在最后。sync_state里的minutes是用整除得到的整数这样可以让所有属性变化都是整数避免小数累积导致的浮点误差。个人项目使用分钟粒度已经足够不需要精确到秒。perform_action会先调用load_pet。这一步很关键它能保证用户执行动作时宠物已经完成了所有离线状态推进避免用户看到的属性是旧值。4.4 动作效果设计动作效果应该让玩家有“照顾宠物”的感觉同时不能让某个操作过于强。TAMX 的动效设计如下动作效果副作用喂食饥饿值 -20清洁值 -10玩耍快乐值 15精力值 -10饥饿值 5清洁清洁值 30无睡觉精力值 40饥饿值 5喂养会弄脏宠物玩耍会消耗精力睡觉会消耗食物这些副作用让宠物养成需要综合考虑。比如一直睡觉会导致饥饿值上升一直玩耍会导致精力下降如果疏忽太久健康值就会下降。5. 展示与交互用 Flask 提供 API 和页面5.1 API 路由设计为了让前端页面和命令行都能操作 TAMXFlask 应用需要提供 JSON API。路由设计如下方法路径说明GET/返回页面GET/api/pet获取宠物最新状态会触发时间同步POST/api/pet/feed喂食POST/api/pet/play玩耍POST/api/pet/clean清洁POST/api/pet/sleep睡觉GET /api/pet会触发load_pet因此每次请求都会先完成时间衰减计算。用户打开页面时看到的一定是最新状态。5.2 Flask 应用实现创建app.pyfrom flask import Flask, jsonify, render_template from pet_service import PetService app Flask(__name__) service PetService() app.route(/) def index(): return render_template(index.html) app.get(/api/pet) def get_pet(): return jsonify(service.load_pet()) app.post(/api/pet/action) def do_action(action): if action not in (feed, play, clean, sleep): return jsonify({error: unknown action}), 400 pet service.perform_action(1, action) return jsonify(pet) if __name__ __main__: app.run(debugTrue)这里的service.perform_action(1, action)直接使用了固定宠物 ID。个人项目可以这样简化但如果后续支持多宠物需要从登录会话、URL 路径或请求参数中获取宠物 ID。5.3 简单的 Web 页面示例创建templates/index.html页面不需要复杂核心是展示状态和按钮!DOCTYPE html html langzh head meta charsetUTF-8 titleTAMX/title /head body h1TAMX 状态/h1 pre idstatus加载中.../pre button onclickdoAction(feed)喂食/button button onclickdoAction(play)玩耍/button button onclickdoAction(clean)清洁/button button onclickdoAction(sleep)睡觉/button script async function load() { const resp await fetch(/api/pet); const data await resp.json(); document.getElementById(status).textContent JSON.stringify(data, null, 2); } async function doAction(action) { await fetch(/api/pet/ action, { method: POST }); load(); } load(); /script /body /html这个页面每次点击按钮后都会重新加载状态。由于load_pet内部会自动同步时间所以即使宠物已经离线几小时打开页面时也能看到准确变化。6. 运行与验证检查宠物会饿、会恢复、会死亡6.1 初始化并启动服务在项目目录下执行python init_db.py python app.pyFlask 默认监听127.0.0.1:5000。终端输出会显示开发服务器地址。在浏览器打开http://127.0.0.1:5000/可以看到宠物状态。6.2 通过 curl 验证状态变化获取当前状态curl http://127.0.0.1:5000/api/pet预期输出类似{ id: 1, name: tamx, hunger: 0, happiness: 80, energy: 80, cleanliness: 80, hp: 100, age: 0, alive: 1, last_update: 1730000000, created_at: 1730000000 }执行喂食动作curl -X POST http://127.0.0.1:5000/api/pet/feed为了验证时间衰减可以把last_update改到两小时前再请求一次。如果没有 sqlite3 命令可以用 Python 模拟sqlite3 tamx.db UPDATE pets SET last_update strftime(%s,now) - 7200 WHERE id1; curl http://127.0.0.1:5000/api/pet两小时等于 120 分钟。按规则饥饿值会增加 12 点快乐值减少 8 点精力值减少 6 点清洁值减少 4 点年龄增加 2 岁。如果初始饥饿值是 0则更新后 hunger 会是 12。6.3 持久化验证关闭 Flask 服务再次执行python app.py curl http://127.0.0.1:5000/api/pet如果上次请求已经触发状态写入重启后数据会保留在 SQLite 中不会回到初始值。这一点能验证持久化是否正常。如果需要验证死亡逻辑可以手动把hp改成 5再把last_update改成 30 分钟前sqlite3 tamx.db UPDATE pets SET hp5, last_updatestrftime(%s,now) - 1800 WHERE id1; curl http://127.0.0.1:5000/api/pet如果此时饥饿值大于等于 80 或清洁值小于等于 20健康值会减少 12 点宠物死亡alive变为 0。7. 常见问题与排查路径7.1 宠物状态一直不变现象等待很长时间后GET /api/pet返回的属性和之前完全一样。检查路径查看last_update是否已经是当前时间。如果load_pet被调用过同步函数已经把时间戳更新到当前时间这是正常现象。查看sync_state中的minutes是否为 0。时间差少于 60 秒时整数除法会得到 0所以 30 秒内状态不变是预期行为。确认数据库文件路径。如果app.py和pet_service.py使用不同的 DB_PATH可能会出现读到旧数据库的情况。解决方案统一在项目根目录运行命令并使用同一个pet_service.py。7.2 SQLite 报 database is locked现象多个请求同时写入时日志出现sqlite3.OperationalError: database is locked。原因SQLite 对并发写入有限制。个人项目中如果在调试模式下同时打开多个浏览器标签页可能触发并发写。检查与处理确认代码中每次连接都使用sqlite3.connect(self.db_path, timeout10)timeout可以等待锁释放。可以在初始化时打开 WAL 模式减少读写互斥conn.execute(PRAGMA journal_modeWAL;)个人项目不推荐引入独立数据库服务如果后续需要更高并发再迁移到 PostgreSQL。7.3 时间衰减幅度不对现象感觉 10 分钟内饥饿值没有增加 1 点或者变化速度过快。检查路径确认时间戳单位。代码使用int(time.time())结果是秒。不能混用毫秒时间戳。确认时间差计算逻辑。minutes (now - last_update) // 60之后所有衰减规则都基于minutes。注意动作方法会重置last_update。执行喂食后衰减会从动作完成时间重新开始计算这是符合预期的。7.4 宠物意外死亡或死亡后仍可操作现象alive已经为 0但 API 仍返回成功或者健康值降到 0 后没有触发死亡。检查路径sync_state中if not pet[alive]: return保证了死亡后不再推进状态。perform_action中加载宠物后要判断if not pet[alive]: return pet避免对死亡宠物执行动作。save_pet会把alive字段写回数据库所以确认所有写库字段都包含alive。常见问题汇总表问题现象常见原因检查方式处理建议状态一直不变时间差小于 1 分钟查看last_update和最新请求时间等待超过 1 分钟后再验证状态呈现跳变数据库存在多个宠物请求 ID 不一致查看pets表内容明确宠物 ID 参数数据库锁错误多进程或多请求并发写打开 WAL 模式增加 timeout当前阶段限制并发写动作无效宠物已死亡查看alive字段前端置灰按钮后端返回明确错误属性超过 0-100未使用_clamp包裹检查动作代码所有属性修改后都做边界裁剪8. 延伸方向与工程建议8.1 从个人玩具到长期运行服务的改造点TAMX 目前是一个典型的本地个人项目。如果要让它长期运行或者接入更多通知渠道需要考虑以下几点。在“推送提醒”方面可以使用APScheduler或系统cron定时请求/api/pet在运行时检查饥饿值、清洁值等是否达到阈值然后发送 Webhook 或桌面通知。需要注意定时任务不能直接修改last_update否则时间同步就失去意义。在“日志和监控”方面建议在perform_action和sync_state中增加日志输出记录每次动作、时间差和属性变化。这样出现异常时可以回放状态变化过程。在“配置外置”方面DB_PATH、端口、宠物 ID 不应该写死在代码里。可以改成从环境变量读取import os DB_PATH os.getenv(TAMX_DB, tamx.db)这样可以避免部署时修改代码。在“部署”方面Flask 自带服务器只适合开发环境。长期运行可以使用gunicorn或waitress作为 WSGI 服务器并通过 systemd 或 Docker 管理进程。8.2 开发与上线检查清单无论把 TAMX 当练习项目还是准备部署到服务器都可以对照这份清单检查。[ ] 数据库初始化脚本可重复执行不会重复插入宠物。[ ] 所有属性修改都经过_clamp边界处理范围保持在 0-100。[ ] 时间戳统一使用 UTC 秒不混用本地时间字符串。[ ] 每次读取宠物前都执行时间同步并写回数据库。[ ] 宠物死亡后不可执行任何动作后端有权限校验。[ ] 错误日志能打印出宠物 ID、动作名称和时间差信息。[ ] 数据库连接设置了超时时间避免并发写死锁。[ ] 部署环境通过环境变量配置数据库路径和端口。[ ] 定时提醒任务不会阻塞主业务线程异常时能自动重试。8.3 下一步可以尝试的方向TAMX 的核心状态机设计已经可以支撑更多玩法。可以增加“心情等级”。当快乐值低于 20、精力值低于 20 或清洁值低于 20 时宠物的界面表现不同例如显示不同的文案或颜色。可以增加“成长系统”。当年龄达到一定值时宠物会从幼年期进入成年期不同阶段有不同的衰减系数和动作效果。可以增加“背包系统”。喂食不再只是固定减 20 饥饿值而是使用背包中的食物道具不同道具的效果不同。可以增加“多宠物支持”。把固定宠物 ID 改为请求参数或用户绑定关系在一张pets表的基础上增加owner_id字段即可扩展。这些扩展都会回到同一个核心问题如何设计状态迁移规则。TAMX 的意义不在于它是一个完美的电子宠物产品而在于它用很小的代码量演示了“时间驱动状态机”这个通用抽象。之后无论是做自动化监控、订阅提醒还是做游戏角色属性系统都能直接复用这套思路。