用 coding-agent 驱动放置游戏:状态机与桌面应用实现 📅 发布时间:2026/8/31 1:18:49 👁 浏览次数: 在 Show HN 上出现了一个很有意思的题目idle desktop incremental game driven by coding-agent。把“放置类增量游戏”和“coding-agent”放在一起初看像是一个脑洞细想却很合理放置游戏的核心是挂机时资源自动增长coding-agent 的工作方式恰好也是“无人值守持续产出”。给它一个任务清单它在后台分析代码、修改文件、运行测试、反复调试最终产出提交或 PR。下面按工程实现思路把这个创意拆成可落地方案先梳理放置游戏与 coding-agent 的映射关系再确定桌面端技术选型实现一个最小可运行原型讨论如何把真实 coding-agent 接进游戏循环最后给出排错、数值平衡和安全建议。这个原型适合三类读者想用游戏化方式观察 AI 编程代理工作的人想练习 Electron React 状态机架构的开发者以及研究放置游戏数值系统和事件驱动设计的工程师。原始 Show HN 项目没有公开完整实现细节所以这里的代码是一种可以自行复现的典型实现路径不是对原项目的搬运。1. 先想清楚放置游戏和 coding-agent 为什么能放进同一个循环动手写代码之前最关键的一步不是选框架而是确认两个领域在机制上真的能对齐。如果只是把“agent 在工作”的动画贴在界面上玩法是空的真正让游戏成立的是数值循环这个循环必须由 coding-agent 的工作流驱动。1.1 放置游戏的核心机制是“挂机收益”不是“点击”放置游戏也叫增量游戏代表作是 Cookie Clicker 一类。玩家的基本操作很快退居次要位置核心体验变成离开一段时间后回来看到资源涨了一大截然后决定把资源花在哪里。这个循环通常由三个部分组成基础产率单位时间自动产生多少资源。升级项把累计资源换成更高产率形成指数增长。随机事件或阶段性门槛让玩家在某个时间点必须做出选择。把这套逻辑套到 coding-agent 上资源不是“饼干”而是“开发进度”或“代币”产率不是“每秒饼干数”而是“agent 每秒能推进多少任务完成度”升级项是“更快的模型档位”“更多的并行 agent”“更高的测试通过率”。映射关系一旦成立整个玩法就自然浮现了。1.2 coding-agent 的工作流本身就是一条事件流真实世界里一个 coding-agent 的工作过程并不是持续匀速产生代码而是一条离散事件链任务输入 - 代码分析 - 编辑文件 - 运行测试 - 修复失败 - 再次测试 - 提交完成这些事件天然带有不确定性测试可能失败依赖可能缺失重构可能引入新问题。对放置游戏来说这种不确定性恰恰是数值系统需要的东西。没有随机性游戏就只是单纯的数字累加有失败概率玩家才会为了“降低失败率”去购买升级项。所以这里的关键设计判断是把 coding-agent 建模成一个状态机而不是一个简单的收益函数。状态机的每一次状态迁移都是游戏进度的一次产出或扣减状态迁移的随机概率就是游戏里需要被玩家“优化”的变量。1.3 用一张映射表把两个领域对齐在实现之前先把两套概念逐项对齐避免后面写代码时东改西改放置游戏要素coding-agent 映射示例数值基础产率agent 每秒推进的任务完成度0.5 进度点/秒主资源开发代币 tokens累计后购买升级升级项模型档位 / 并行数 / 工具链速度提升 20%随机事件测试失败、调试失败编辑阶段 20% 失败中期目标完成一次大型重构任务累计进度达到阈值重置机制重建仓库、引入新任务主题解锁更高倍率游戏循环用一句话概括就是任务投入给 agentagent 按状态机产出事件事件折算成进度和代币代币购买升级升级反过来提高 agent 的产出效率和成功率。后面所有代码都是围绕这条主线展开的。2. 技术选型桌面壳、游戏循环和 agent 接口怎么组合这个项目有两个技术约束必须同时满足一是要能做成桌面应用二是要能方便地跟 coding-agent 交互。先决定桌面壳再决定 agent 的接入方式最后才是 React 组件怎么写。2.1 桌面壳选择不要一开始就上重方案桌面端有三种常见选择各有取舍。方案安装包体积开发效率子进程能力适合场景Electron较大约 100MB 起步高前端技术栈直接复用主进程天然支持 Node APIspawn 子进程方便快速原型、依赖 Node 生态Tauri较小几 MB 到十几 MB中前端 Rust 两层需要通过 Rust command 包装追求体积和内存占用纯 Web最小最高没有直接子进程能力只验证玩法不做桌面壳这个原型推荐 Electron。原因很简单真实 coding-agent 几乎都以 CLI 进程形态存在Electron 主进程可以直接用 Node 的child_process启动和管理子进程不需要额外封装一层本地服务。Tauri 能做得更轻但每一步进程调用都要跨 Rust 边界原型阶段会多出不少噪音。等玩法确认后再考虑用 Tauri 重写外壳。2.2 游戏循环不能只依赖 setInterval很多新手在写“每秒增长”时直接从setInterval(() tokens 1, 1000)开始这在真实桌面应用里会有三个隐患系统进入省电模式后setInterval可能被节流时间基准不可靠。窗口切后台再切回来interval 可能补发多个回调造成数值突跳。React 重新渲染和游戏状态更新耦合在一起界面一卡游戏逻辑也跟着卡。稳妥的做法是固定每 1000ms 触发一次 tick但在 tick 内部用真实时间差dt计算收益同时给dt设一个上限防止离线或卡顿后一次性追算太多。let lastTickAt Date.now(); setInterval(() { const now Date.now(); const dt Math.min((now - lastTickAt) / 1000, 5); lastTickAt now; tickGame(dt); }, 1000);Math.min(..., 5)的意思是即使程序卡了 30 秒也只按 5 秒计算收益。放置游戏需要“挂机收益”但不需要“卡顿补偿”后者会把数值曲线打乱也让离线恢复行为变得难以测试。2.3 coding-agent 接入的三种方式根据开发阶段不同agent 接口可以分成三种实现方式彼此之间可以切换。接入方式优点缺点适合阶段模拟状态机确定性高、零成本、可反复测试不产生真实代码效果是假的原型、数值调节、自动化测试CLI 子进程真实、直接复用现有 agent 工具输出格式不稳定、资源不可控、耗时不可控接入真实 agentHTTP / 流式 API可控性强、能追踪 token 消耗需要 key、有网络依赖线上玩法或服务端结算原型阶段先写模拟状态机把游戏循环、升级、存档全部跑通下一步再替换成 CLI 子进程。两套接口要抽象成同一个事件类型否则后面切换时 React 层和数值层都要推倒重写。2.4 环境准备与依赖清单开发环境按以下版本准备Node.js 20 LTS 或更高npm 10 或 pnpm 9Git 2.30 以上。如果后续要切 Tauri才需要安装 Rust 工具链。node -v npm -v git --version创建项目并安装基础依赖npm create vitelatest idle-agent -- --template react-ts cd idle-agent npm install npm install -D electron electron-builder npm install zustand这里选 Vite 的react-ts模板是因为它默认支持 TypeScript状态管理用 zustand体积小而且能方便地在 React 组件外部读写状态。Electron 主要负责主进程、子进程管理和窗口创建渲染进程继续用 Web 技术。3. 实现最小可运行原型模拟 agent 驱动放置循环原型阶段的目标不是做完整游戏而是用一个最简闭环证明“coding-agent - 事件流 - 数值增长 - 升级 - 再产出”是通的。先定义类型再写 tick再写状态机、升级和存档最后接上 React 渲染。3.1 先定义 GameState、AgentState 和 UpgradeDef类型先行能避免后面大量返工。尤其是 agent 的phase字段它决定了整个状态机的节点集合后期加阶段也要从这里改起。// src/game/types.ts export type AgentPhase | idle | analyzing | editing | running-tests | debugging | pr-ready | failed; export interface AgentState { id: string; name: string; phase: AgentPhase; currentTask: string; progressInTask: number; // 0 到 100 tasksDone: number; tasksFailed: number; } export interface PlayerState { tokens: number; totalProgress: number; agentLevel: number; speedMultiplier: number; agentCount: number; } export interface GameState { player: PlayerState; agents: AgentState[]; events: AgentEvent[]; } export interface AgentEvent { type: task-started | task-complete | task-failed | test-failed; task: string; ts: number; } export type UpgradeEffect | { kind: speed; multiplier: number } | { kind: agent-count; add: number } | { kind: success-rate; bonus: number }; export interface UpgradeDef { id: string; name: string; description: string; baseCost: number; costGrowth: number; maxLevel?: number; effect: UpgradeEffect; }progressInTask是 agent 当前阶段的完成度范围 0 到 100。它和totalProgress不是一个东西前者是单任务阶段进度后者是玩家累计收益。混在一起会导致升级效果和任务完成判定互相干扰。3.2 用 tick 函数驱动每秒生产生产公式决定数值增长曲线先写一个足够简单但可扩展的版本// src/game/ticks.ts import type { AgentState, PlayerState } from ./types; import { advanceAgent } from ./agentSim; export function computeProduction(player: PlayerState): number { const basePerAgent 0.5; const levelMultiplier 1 player.agentLevel * 0.25; const totalMultiplier levelMultiplier * player.speedMultiplier; return player.agentCount * basePerAgent * totalMultiplier; } export function tickGame( player: PlayerState, agents: AgentState[], dt: number, events: AgentEvent[] ): void { const production computeProduction(player); player.tokens production * dt; player.totalProgress production * dt; for (const agent of agents) { advanceAgent(agent, dt, Math.random, events); } }computeProduction返回值是“每秒代币数”乘以dt就是这一帧的增量。agentLevel和speedMultiplier分开保存是为了区分两种不同的升级来源等级来自重置或里程碑倍率来自可重复购买的升级项。数值设计上它们分别代表“长周期成长”和“短周期消费”。3.3 用状态机模拟一个 coding-agent 的工作过程模拟器是这段代码的重心。它不写真实代码但必须模拟出 coding-agent 的行为节奏和失败概率。// src/game/agentSim.ts import type { AgentPhase, AgentState } from ./types; const PHASE_SECONDS: RecordAgentPhase, number { idle: 3, analyzing: 8, editing: 14, running-tests: 6, debugging: 10, pr-ready: 2, failed: 4, }; const SAMPLE_TASKS [ fix: 修正缓存键过期策略, feat: 增加 JSON Schema 校验, refactor: 拆分订单模块, test: 补充支付回调测试, ]; export function advanceAgent( agent: AgentState, dt: number, rng: () number, events: AgentEvent[] ): void { const phaseSeconds PHASE_SECONDS[agent.phase]; agent.progressInTask (dt / phaseSeconds) * 100; if (agent.progressInTask 100) return; agent.progressInTask 0; switch (agent.phase) { case idle: agent.phase analyzing; agent.currentTask SAMPLE_TASKS[Math.floor(rng() * SAMPLE_TASKS.length)]; events.push({ type: task-started, task: agent.currentTask, ts: Date.now() }); break; case analyzing: agent.phase editing; break; case editing: { const roll rng(); if (roll 0.2) { agent.phase debugging; events.push({ type: test-failed, task: agent.currentTask, ts: Date.now() }); } else { agent.phase running-tests; } break; } case running-tests: agent.phase pr-ready; agent.tasksDone 1; events.push({ type: task-complete, task: agent.currentTask, ts: Date.now() }); break; case debugging: { const roll rng(); if (roll 0.75) { agent.phase editing; } else { agent.phase failed; agent.tasksFailed 1; events.push({ type: task-failed, task: agent.currentTask, ts: Date.now() }); } break; } case pr-ready: case failed: agent.phase idle; break; } }这个状态机里有两个重要的参数编辑阶段 20% 概率进入调试调试阶段 75% 概率修复成功。这两个概率直接决定玩家的“挫败感”和“升级价值”。如果不再买任何升级长期来看约有一半的循环会带着一次失败事件这对放置游戏来说节奏偏紧但作为初始参数可以接受后面靠升级项去调节。这里把rng作为参数传入而不是直接调用Math.random是为了测试时能注入固定随机数让状态迁移可以被断言。真实项目里状态机逻辑越确定越容易写单元测试。3.4 升级项成本函数和效果叠加升级系统是放置游戏的消费出口。成本公式采用最常见的指数增长// src/game/upgrades.ts import type { UpgradeDef } from ./types; export const UPGRADES: UpgradeDef[] [ { id: speed-1, name: 更快的分析器, description: agent 每秒产出提升 20%, baseCost: 15, costGrowth: 1.6, effect: { kind: speed, multiplier: 1.2 }, }, { id: agent-2, name: 第二个 agent, description: 解锁并行 agentagent 数量 1, baseCost: 120, costGrowth: 4.5, maxLevel: 2, effect: { kind: agent-count, add: 1 }, }, { id: lucky-1, name: 测试修复包, description: 调试阶段失败率降低 5%, baseCost: 80, costGrowth: 2.2, effect: { kind: success-rate, bonus: 0.05 }, }, ]; export function costOf(upgrade: UpgradeDef, currentLevel: number): number { return Math.floor(upgrade.baseCost * Math.pow(upgrade.costGrowth, currentLevel)); }costGrowth决定同一升级项第 N 次购买的价格。1.6 表示价格每买一次乘 1.6这个系数越大玩家买几轮后就不得不去换更长线的目标。agent-2的costGrowth设为 4.5 且maxLevel: 2因为并行 agent 是质变型升级不应该允许无限堆叠否则数值会失控。升级效果建议设计成叠加乘数而不是替代原有值。比如速度升级是speedMultiplier * 1.2新价格基于当前等级这样既有指数成本又有指数收益玩家会有“再过一会儿就能买”的期待感。3.5 存档版本号、序列化和自动保存放置游戏最怕丢存档。存档系统至少要包含三件事版本号、序列化格式、读取时的数据校验。// src/game/save.ts import type { GameState } from ./types; const SAVE_KEY idle-agent-save; const SAVE_VERSION 1; export function saveGame(state: GameState): void { const payload { version: SAVE_VERSION, savedAt: Date.now(), state, }; localStorage.setItem(SAVE_KEY, JSON.stringify(payload)); } export function loadGame(): GameState | null { const raw localStorage.getItem(SAVE_KEY); if (!raw) return null; try { const payload JSON.parse(raw); if (payload.version ! SAVE_VERSION) { // 版本不匹配时先尝试迁移原型阶段简单处理为丢弃 return null; } return sanitizeGameState(payload.state); } catch { return null; } } function sanitizeGameState(state: unknown): GameState | null { if (!state || typeof state ! object) return null; const s state as PartialGameState; if (typeof s.player?.tokens ! number) return null; if (!Array.isArray(s.agents)) return null; return state as GameState; }localStorage在 Electron 渲染进程里可用原型阶段足够。生产环境建议改成主进程写文件用electron-store或者自己走 IPC 保存到用户数据目录这样存档位置更稳定也方便做多份备份。自动保存频率建议 15 秒一次不要每秒写。写频率过高会频繁触发磁盘 IO桌面端体验拉胯。3.6 UI 层把状态渲染成上下三块面板界面不需要花哨三个区域足够顶部资源数字中间 agent 状态和升级按钮底部滚动日志。状态管理用 zustand因为 zustand 允许在 React 组件外部更新状态游戏 tick 循环和组件渲染可以解耦。// src/App.tsx import { useEffect } from react; import { create } from zustand; import { tickGame } from ./game/ticks; import { UPGRADES, costOf } from ./game/upgrades; import { saveGame, loadGame } from