<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Web Console on heyaohua's Blog</title><link>https://blog.heyaohua.com/tags/web-console/</link><description>Recent content in Web Console on heyaohua's Blog</description><image><title>heyaohua's Blog</title><url>https://blog.heyaohua.com/og-image.png</url><link>https://blog.heyaohua.com/og-image.png</link></image><generator>Hugo</generator><language>zh-cn</language><lastBuildDate>Wed, 06 May 2026 15:45:00 +0800</lastBuildDate><atom:link href="https://blog.heyaohua.com/tags/web-console/index.xml" rel="self" type="application/rss+xml"/><item><title>OpenGame Web 控制中心设计：从提示词到可试玩游戏的任务化流水线</title><link>https://blog.heyaohua.com/posts/2026/05/opengame-web-console-design/</link><pubDate>Wed, 06 May 2026 15:45:00 +0800</pubDate><guid>https://blog.heyaohua.com/posts/2026/05/opengame-web-console-design/</guid><description>本文以 opengame-web-console 的实现为例，讲解如何设计一个 OpenGame Web 控制中心：任务队列、GDD 审核、SDK 调用、实时日志、M1-M4 生成流水线、质量门禁、自动修复、版本迭代与换皮。</description><content:encoded><![CDATA[<h2 id="背景">背景</h2>
<p>上一篇文章整理了 <a href="/posts/2026/05/opengame-technical-guide/">OpenGame</a> 的基本概念：它可以把自然语言提示词转成一个可运行的 Web 游戏工程。但如果只在命令行里执行 <code>opengame -p &quot;...&quot; --yolo</code>，很快会遇到几个工程问题：</p>
<ul>
<li>生成任务耗时长，需要队列和状态追踪。</li>
<li>Agent 输出很多日志，需要实时可视化。</li>
<li>游戏不是“一次生成就结束”，需要 GDD 审核、分阶段实现、失败修复、版本迭代。</li>
<li>生成结果需要可预览、可试玩、可换皮，而不是只留在本地目录。</li>
<li>失败原因需要结构化，否则很难知道是 API 中断、构建失败、资源缺失，还是画面质量不过关。</li>
</ul>
<p>所以我设计了一个 <code>opengame-web-console</code>：它不是简单包一层按钮，而是把 OpenGame SDK 放进一个任务化、可观察、可修复、可迭代的控制中心。</p>
<h2 id="结论">结论</h2>
<p>这个控制中心的核心设计可以概括为一句话：</p>
<blockquote>
<p>前端只负责提交需求和观察状态，后端把每一次游戏生成封装成任务，任务运行时负责规划、调用 SDK、执行门禁、自动修复、沉淀版本。</p>
</blockquote>
<p>整体链路如下：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>用户提示词
</span></span><span style="display:flex;"><span>  ↓
</span></span><span style="display:flex;"><span>Web Console
</span></span><span style="display:flex;"><span>  ↓
</span></span><span style="display:flex;"><span>POST /api/tasks
</span></span><span style="display:flex;"><span>  ↓
</span></span><span style="display:flex;"><span>TaskService 创建任务记录与 workspace
</span></span><span style="display:flex;"><span>  ↓
</span></span><span style="display:flex;"><span>TaskQueue 串行调度
</span></span><span style="display:flex;"><span>  ↓
</span></span><span style="display:flex;"><span>TaskRunner 调用 @opengame/sdk
</span></span><span style="display:flex;"><span>  ↓
</span></span><span style="display:flex;"><span>OpenGame Agent 在 workspace 中生成游戏
</span></span><span style="display:flex;"><span>  ↓
</span></span><span style="display:flex;"><span>Playable / Visual Gate
</span></span><span style="display:flex;"><span>  ↓
</span></span><span style="display:flex;"><span>成功：保存 previewEntry，可预览、试玩、迭代、换皮
</span></span><span style="display:flex;"><span>失败：记录 failureReason，生成修复 prompt，可重试
</span></span></code></pre></div><h2 id="为什么不是直接调用-cli">为什么不是直接调用 CLI</h2>
<p>直接调用 CLI 适合本地实验，但不适合做“控制中心”。控制中心需要解决的是长期运行问题：</p>
<table>
  <thead>
      <tr>
          <th>问题</th>
          <th>CLI 模式</th>
          <th>控制中心模式</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>多任务</td>
          <td>手动开多个终端</td>
          <td>任务队列统一调度</td>
      </tr>
      <tr>
          <td>状态</td>
          <td>看终端输出</td>
          <td>SQLite 持久化任务状态</td>
      </tr>
      <tr>
          <td>日志</td>
          <td>滚动文本易丢</td>
          <td>JSONL 持久化 + SSE 实时推送</td>
      </tr>
      <tr>
          <td>审核</td>
          <td>生成时无法插入人工关卡</td>
          <td>半自动模式先审 GDD</td>
      </tr>
      <tr>
          <td>失败</td>
          <td>人工读日志判断</td>
          <td>failureReason 结构化</td>
      </tr>
      <tr>
          <td>结果</td>
          <td>本地目录</td>
          <td>Web 预览、试玩、版本链</td>
      </tr>
      <tr>
          <td>复用</td>
          <td>复制目录手动改</td>
          <td>基于版本继续迭代</td>
      </tr>
  </tbody>
</table>
<p>因此控制中心的第一原则是：<strong>不要把 OpenGame 当成一个一次性命令，而要当成一个异步任务运行时</strong>。</p>
<h2 id="技术栈">技术栈</h2>
<p>控制中心使用的技术栈比较克制：</p>
<table>
  <thead>
      <tr>
          <th>模块</th>
          <th>技术</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Web 框架</td>
          <td>Next.js</td>
      </tr>
      <tr>
          <td>UI</td>
          <td>React</td>
      </tr>
      <tr>
          <td>数据库</td>
          <td>SQLite / better-sqlite3</td>
      </tr>
      <tr>
          <td>Agent 调用</td>
          <td><code>@opengame/sdk</code></td>
      </tr>
      <tr>
          <td>实时事件</td>
          <td>Server-Sent Events</td>
      </tr>
      <tr>
          <td>可玩性检查</td>
          <td>Playwright</td>
      </tr>
      <tr>
          <td>图片处理</td>
          <td>sharp</td>
      </tr>
      <tr>
          <td>资源生成</td>
          <td>OpenGame SDK MCP tool</td>
      </tr>
      <tr>
          <td>任务产物</td>
          <td>本地 workspace 目录</td>
      </tr>
  </tbody>
</table>
<p>运行时数据默认放在：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>data/
</span></span><span style="display:flex;"><span>├── app.db
</span></span><span style="display:flex;"><span>└── runs/
</span></span><span style="display:flex;"><span>    └── &lt;taskId&gt;/
</span></span><span style="display:flex;"><span>        ├── workspace/
</span></span><span style="display:flex;"><span>        └── events.jsonl
</span></span></code></pre></div><p>这里的 <code>workspace/</code> 是每个任务的独立生成目录，<code>events.jsonl</code> 是任务运行日志。</p>
<h2 id="组件设计">组件设计</h2>
<h3 id="前端组件">前端组件</h3>
<p>前端围绕一个 Workbench 展开：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>Workbench
</span></span><span style="display:flex;"><span>├── TaskCreateForm        # 创建任务：标题、提示词、自动/半自动、full/mvp
</span></span><span style="display:flex;"><span>├── TaskList              # 任务队列
</span></span><span style="display:flex;"><span>├── StatusBadge           # 状态展示
</span></span><span style="display:flex;"><span>├── GddReviewPanel        # 半自动模式下审核 GDD
</span></span><span style="display:flex;"><span>├── TaskPipelineReport    # M1-M4 与 Gate 报告
</span></span><span style="display:flex;"><span>├── TaskQualityReport     # 可玩性与视觉质量报告
</span></span><span style="display:flex;"><span>├── TaskPreviewPanel      # iframe 预览
</span></span><span style="display:flex;"><span>├── TaskPlayLink          # 打开沉浸式试玩页
</span></span><span style="display:flex;"><span>├── VersionIterateForm    # 基于当前版本继续迭代
</span></span><span style="display:flex;"><span>├── VersionList           # 版本链
</span></span><span style="display:flex;"><span>├── SkinManager           # 导出占位资源包、上传换皮包
</span></span><span style="display:flex;"><span>└── TaskLogPanel          # 实时日志
</span></span></code></pre></div><p>前端不直接碰 OpenGame SDK。它只调用控制中心 API，并通过 SSE 或定时刷新同步任务状态。</p>
<h3 id="后端组件">后端组件</h3>
<p>后端核心组件如下：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>Container
</span></span><span style="display:flex;"><span>├── env                  # 数据目录配置
</span></span><span style="display:flex;"><span>├── db                   # SQLite
</span></span><span style="display:flex;"><span>├── TaskRepository       # tasks 表读写
</span></span><span style="display:flex;"><span>├── GameRepository       # games 与版本链
</span></span><span style="display:flex;"><span>├── WorkspaceManager     # 为 task 创建 workspace
</span></span><span style="display:flex;"><span>├── TaskQueue            # 串行队列
</span></span><span style="display:flex;"><span>├── SdkClient            # @opengame/sdk 封装
</span></span><span style="display:flex;"><span>├── TaskRunner           # SDK 流式消息转 TaskEvent
</span></span><span style="display:flex;"><span>├── TaskService          # 任务生命周期编排
</span></span><span style="display:flex;"><span>├── PlayableChecker      # 浏览器可玩性检查
</span></span><span style="display:flex;"><span>├── VisualQualityChecker # 视觉与资源质量检查
</span></span><span style="display:flex;"><span>└── SkinService          # 换皮与资源包
</span></span></code></pre></div><p>其中 <code>TaskService</code> 是最重要的编排层。它不负责具体写游戏代码，而是负责决定什么时候规划、什么时候实现、什么时候检查、什么时候修复、什么时候把任务标记为成功或失败。</p>
<h2 id="api-设计">API 设计</h2>
<p>控制中心 API 可以分为五类。</p>
<h3 id="任务-api">任务 API</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>GET  /api/tasks
</span></span><span style="display:flex;"><span>POST /api/tasks
</span></span><span style="display:flex;"><span>GET  /api/tasks/:taskId
</span></span><span style="display:flex;"><span>POST /api/tasks/:taskId/retry
</span></span><span style="display:flex;"><span>GET  /api/tasks/events
</span></span><span style="display:flex;"><span>GET  /api/tasks/:taskId/events
</span></span></code></pre></div><p>创建任务的请求体：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-json" data-lang="json"><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">&#34;title&#34;</span>: <span style="color:#f1fa8c">&#34;太空塔防&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">&#34;prompt&#34;</span>: <span style="color:#f1fa8c">&#34;创建一个太空主题塔防游戏...&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">&#34;automationMode&#34;</span>: <span style="color:#f1fa8c">&#34;auto&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">&#34;pipelineMode&#34;</span>: <span style="color:#f1fa8c">&#34;full&#34;</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><code>automationMode</code> 有两种：</p>
<table>
  <thead>
      <tr>
          <th>模式</th>
          <th>行为</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>auto</code></td>
          <td>规划 GDD 后自动进入实现</td>
      </tr>
      <tr>
          <td><code>semi_auto</code></td>
          <td>规划 GDD 后暂停，等待人工审核</td>
      </tr>
  </tbody>
</table>
<p><code>pipelineMode</code> 也有两种：</p>
<table>
  <thead>
      <tr>
          <th>模式</th>
          <th>行为</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>mvp</code></td>
          <td>只跑 M1 playable core</td>
      </tr>
      <tr>
          <td><code>full</code></td>
          <td>跑 M1-M4，再跑 final gate</td>
      </tr>
  </tbody>
</table>
<h3 id="gdd-api">GDD API</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>GET  /api/tasks/:taskId/gdd
</span></span><span style="display:flex;"><span>PUT  /api/tasks/:taskId/gdd
</span></span><span style="display:flex;"><span>POST /api/tasks/:taskId/gdd/approve
</span></span></code></pre></div><p>半自动模式下，任务会先进入 <code>awaiting_gdd_review</code>。用户可以在页面里编辑 GDD，再点击确认，后端才继续进入实现阶段。</p>
<h3 id="预览与试玩-api">预览与试玩 API</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>GET /api/tasks/:taskId/preview
</span></span><span style="display:flex;"><span>GET /api/tasks/:taskId/preview/assets/*
</span></span><span style="display:flex;"><span>GET /api/tasks/:taskId/play
</span></span><span style="display:flex;"><span>GET /tasks/:taskId/play
</span></span></code></pre></div><p><code>preview</code> 是嵌在控制台里的预览，<code>play</code> 是更沉浸的试玩页。控制台不会要求用户进入服务器目录找 <code>index.html</code>，而是把生成产物代理成 Web 路由。</p>
<h3 id="版本-api">版本 API</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>GET  /api/tasks/:taskId/versions
</span></span><span style="display:flex;"><span>POST /api/tasks/:taskId/iterate
</span></span><span style="display:flex;"><span>POST /api/tasks/:taskId/versions/:versionId/promote
</span></span></code></pre></div><p>每次迭代不是覆盖原任务，而是复制旧 workspace，生成一个新版本任务：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>V1 workspace
</span></span><span style="display:flex;"><span>  ↓ clone
</span></span><span style="display:flex;"><span>V2 workspace
</span></span><span style="display:flex;"><span>  ↓ 修改 prompt：基于 V1 做增量需求
</span></span><span style="display:flex;"><span>V2 进入任务队列
</span></span></code></pre></div><p>这样可以保留历史版本，也可以随时把某个版本提升为当前版本。</p>
<h3 id="换皮-api">换皮 API</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>GET  /api/tasks/:taskId/skin/placeholders
</span></span><span style="display:flex;"><span>POST /api/tasks/:taskId/skin
</span></span><span style="display:flex;"><span>GET  /api/tasks/:taskId/skin/active/*
</span></span></code></pre></div><p>生成游戏时，Agent 会被要求创建 <code>skin-manifest.json</code>，声明哪些 PNG 资源可以替换。控制中心可以导出占位资源包，用户替换后上传 zip，后端会创建一个新的成功版本。</p>
<h2 id="任务状态机">任务状态机</h2>
<p>任务状态设计如下：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>queued
</span></span><span style="display:flex;"><span>  ↓
</span></span><span style="display:flex;"><span>running
</span></span><span style="display:flex;"><span>  ├─ planning
</span></span><span style="display:flex;"><span>  │   ├─ auto       → m1_mvp
</span></span><span style="display:flex;"><span>  │   └─ semi_auto  → awaiting_gdd_review
</span></span><span style="display:flex;"><span>  │                    ↓ approve
</span></span><span style="display:flex;"><span>  │                  queued → running
</span></span><span style="display:flex;"><span>  ├─ m1_mvp
</span></span><span style="display:flex;"><span>  ├─ m2_complete_loop
</span></span><span style="display:flex;"><span>  ├─ m3_visual_audio
</span></span><span style="display:flex;"><span>  ├─ m4_polish
</span></span><span style="display:flex;"><span>  ├─ final_gate
</span></span><span style="display:flex;"><span>  └─ repair
</span></span><span style="display:flex;"><span>      ↓
</span></span><span style="display:flex;"><span>succeeded / failed
</span></span></code></pre></div><p>状态字段分两层：</p>
<ul>
<li><code>status</code> 表示任务总体状态，例如 <code>queued</code>、<code>running</code>、<code>awaiting_gdd_review</code>、<code>succeeded</code>、<code>failed</code>。</li>
<li><code>currentStage</code> 表示当前流水线阶段，例如 <code>planning</code>、<code>m1_mvp</code>、<code>repair</code>。</li>
</ul>
<p>这样 UI 可以同时展示“任务还在 running”和“现在跑到 M3 visual/audio”。</p>
<h2 id="生成流水线">生成流水线</h2>
<p>控制中心把一次生成拆成规划和实现两大段。</p>
<h3 id="1-planning先写-gdd">1. Planning：先写 GDD</h3>
<p>Planning 阶段只做一件事：根据用户 prompt 生成 <code>docs/opengame-gdd.md</code>。</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>用户 prompt
</span></span><span style="display:flex;"><span>  ↓
</span></span><span style="display:flex;"><span>buildOpenGamePlanningPrompt()
</span></span><span style="display:flex;"><span>  ↓
</span></span><span style="display:flex;"><span>@opengame/sdk 在 planning-workspace 运行
</span></span><span style="display:flex;"><span>  ↓
</span></span><span style="display:flex;"><span>输出 docs/opengame-gdd.md
</span></span><span style="display:flex;"><span>  ↓
</span></span><span style="display:flex;"><span>复制到主 workspace
</span></span></code></pre></div><p>这里有一个小设计：规划阶段运行在 <code>planning-workspace</code>，不是直接在主 workspace 里写。这样可以避免规划 Agent 误生成实现文件。规划完成后，只把 GDD 文档复制回主 workspace。</p>
<p>如果文件没写成功，系统还会尝试从 Agent 输出中的标记恢复：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>BEGIN_OPENGAME_GDD
</span></span><span style="display:flex;"><span>...
</span></span><span style="display:flex;"><span>END_OPENGAME_GDD
</span></span></code></pre></div><p>这能降低“Agent 说它写了文件，但文件实际不存在”的失败率。</p>
<h3 id="2-m1-m4分阶段实现">2. M1-M4：分阶段实现</h3>
<p>完整模式会跑四个实现阶段：</p>
<table>
  <thead>
      <tr>
          <th>阶段</th>
          <th>目标</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>M1 playable core</td>
          <td>最小可玩核心：输入、规则、胜负、重开</td>
      </tr>
      <tr>
          <td>M2 complete loop</td>
          <td>菜单、结算、三关或难度递进</td>
      </tr>
      <tr>
          <td>M3 visual and audio</td>
          <td>PNG 资源、UI 状态、动画反馈、音效</td>
      </tr>
      <tr>
          <td>M4 polish</td>
          <td>移动端、性能、加载、引导、最终观感</td>
      </tr>
  </tbody>
</table>
<p>每个阶段都会把 GDD、用户原始需求、上一阶段门禁证据一起喂给 Agent：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>GDD + userPrompt + stageGoals + previousEvidence
</span></span><span style="display:flex;"><span>  ↓
</span></span><span style="display:flex;"><span>buildOpenGameStagePrompt()
</span></span><span style="display:flex;"><span>  ↓
</span></span><span style="display:flex;"><span>TaskRunner 调用 @opengame/sdk
</span></span><span style="display:flex;"><span>  ↓
</span></span><span style="display:flex;"><span>Agent 修改 workspace
</span></span><span style="display:flex;"><span>  ↓
</span></span><span style="display:flex;"><span>Gate 检查
</span></span><span style="display:flex;"><span>  ↓
</span></span><span style="display:flex;"><span>通过则进入下一阶段
</span></span><span style="display:flex;"><span>失败则进入 repair
</span></span></code></pre></div><p>这个设计的好处是让 Agent 不必一次性完成所有要求。M1 先保可玩，M2 补完整循环，M3 做表现，M4 做收尾。</p>
<h2 id="sdk-调用设计">SDK 调用设计</h2>
<p>控制中心通过 <code>SdkClient</code> 封装 <code>@opengame/sdk</code>：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>TaskRunner
</span></span><span style="display:flex;"><span>  ↓
</span></span><span style="display:flex;"><span>SdkClient.runTask({ prompt, cwd })
</span></span><span style="display:flex;"><span>  ↓
</span></span><span style="display:flex;"><span>query({
</span></span><span style="display:flex;"><span>  prompt,
</span></span><span style="display:flex;"><span>  options: {
</span></span><span style="display:flex;"><span>    cwd,
</span></span><span style="display:flex;"><span>    permissionMode: &#34;yolo&#34;,
</span></span><span style="display:flex;"><span>    debug: true,
</span></span><span style="display:flex;"><span>    mcpServers: {
</span></span><span style="display:flex;"><span>      &#34;opengame-assets&#34;: assetServer
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>})
</span></span></code></pre></div><p>关键点有三个：</p>
<ol>
<li><code>cwd</code> 指向任务 workspace，Agent 的所有文件操作都限制在这个任务目录里。</li>
<li><code>permissionMode: &quot;yolo&quot;</code> 允许 Agent 在任务目录内执行必要命令，适合受控的服务器环境。</li>
<li>注入 <code>opengame-assets</code> MCP Server，让 Agent 可以调用 <code>generate_game_asset</code> 生成 PNG 游戏资源。</li>
</ol>
<p>TaskRunner 会把 SDK 的流式消息转成统一的 <code>TaskEvent</code>：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>SDK stream message
</span></span><span style="display:flex;"><span>  ↓
</span></span><span style="display:flex;"><span>mapSdkMessageToTaskEvents()
</span></span><span style="display:flex;"><span>  ↓
</span></span><span style="display:flex;"><span>task.log / task.completed / task.failed
</span></span><span style="display:flex;"><span>  ↓
</span></span><span style="display:flex;"><span>写入 events.jsonl
</span></span><span style="display:flex;"><span>  ↓
</span></span><span style="display:flex;"><span>通过 SSE 推送到前端
</span></span></code></pre></div><p>为了避免任务卡死，TaskRunner 还做了两层超时：</p>
<ul>
<li>idle timeout：一段时间没有新消息，认为 SDK 卡住。</li>
<li>wall timeout：单次尝试总运行时间超限。</li>
</ul>
<p>对 API 中断和 timeout 这类可重试错误，最多尝试 3 次。</p>
<h2 id="实时日志设计">实时日志设计</h2>
<p>日志有两条路径：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>TaskEvent
</span></span><span style="display:flex;"><span>  ├── append events.jsonl       # 持久化
</span></span><span style="display:flex;"><span>  └── emitTaskEvent()           # 内存事件总线
</span></span><span style="display:flex;"><span>        ↓
</span></span><span style="display:flex;"><span>      /api/tasks/events         # 全局 SSE
</span></span><span style="display:flex;"><span>      /api/tasks/:id/events     # 单任务 SSE
</span></span><span style="display:flex;"><span>        ↓
</span></span><span style="display:flex;"><span>      TaskLogPanel / Workbench
</span></span></code></pre></div><p>为什么既要 JSONL，又要 SSE？</p>
<ul>
<li>SSE 负责实时体验。</li>
<li>JSONL 负责刷新后还能看到历史日志。</li>
<li>如果 SSE 推送失败，不应该影响任务执行，所以日志写入也是 best effort。</li>
</ul>
<h2 id="质量门禁">质量门禁</h2>
<p>一次 Agent 生成结束后，控制中心不会立刻标记成功，而是跑 gate。</p>
<h3 id="static-preview-gate">Static Preview Gate</h3>
<p>先检查是否能找到可预览入口：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>npm run build
</span></span><span style="display:flex;"><span>  ↓
</span></span><span style="display:flex;"><span>resolvePreviewEntry()
</span></span><span style="display:flex;"><span>  ↓
</span></span><span style="display:flex;"><span>index.html / dist/index.html / 其他可预览入口
</span></span></code></pre></div><p>如果找不到入口，任务失败，失败原因会归类为 <code>build_failed</code> 或相关类型。</p>
<h3 id="playable-gate">Playable Gate</h3>
<p>Playable Gate 会启动一个临时预览服务器，再用 Playwright 打开页面：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>startPreviewServer()
</span></span><span style="display:flex;"><span>  ↓
</span></span><span style="display:flex;"><span>Playwright chromium
</span></span><span style="display:flex;"><span>  ↓
</span></span><span style="display:flex;"><span>监听页面错误、console error、资源 404
</span></span><span style="display:flex;"><span>  ↓
</span></span><span style="display:flex;"><span>检查 Loading 是否消失
</span></span><span style="display:flex;"><span>  ↓
</span></span><span style="display:flex;"><span>检查 canvas 是否空白
</span></span></code></pre></div><p>这一步解决的是“能构建但不能玩”的问题。</p>
<h3 id="visual-gate">Visual Gate</h3>
<p>Visual Gate 更偏游戏观感：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>打开预览页
</span></span><span style="display:flex;"><span>  ↓
</span></span><span style="display:flex;"><span>截图
</span></span><span style="display:flex;"><span>  ↓
</span></span><span style="display:flex;"><span>采样 canvas 像素
</span></span><span style="display:flex;"><span>  ↓
</span></span><span style="display:flex;"><span>检查 skin-manifest.json
</span></span><span style="display:flex;"><span>  ↓
</span></span><span style="display:flex;"><span>检查 PNG 是否存在、尺寸是否足够、是否被实际引用
</span></span></code></pre></div><p>它会拒绝几类结果：</p>
<ul>
<li>没有 canvas。</li>
<li>canvas 全空白。</li>
<li>canvas 颜色过于单一，看起来像占位图。</li>
<li>没有 <code>skin-manifest.json</code>。</li>
<li>manifest 里的资源缺失。</li>
<li>active asset 不是 PNG。</li>
<li>资源太小或未被游戏引用。</li>
</ul>
<h2 id="自动修复逻辑">自动修复逻辑</h2>
<p>如果 Gate 失败，控制中心不会马上结束，而是进入 repair：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>Gate failed
</span></span><span style="display:flex;"><span>  ↓
</span></span><span style="display:flex;"><span>buildOpenGameRepairPrompt(errorMessage, gddDraft, stage)
</span></span><span style="display:flex;"><span>  ↓
</span></span><span style="display:flex;"><span>Agent 在原 workspace 中修复
</span></span><span style="display:flex;"><span>  ↓
</span></span><span style="display:flex;"><span>再次运行 Gate
</span></span><span style="display:flex;"><span>  ↓
</span></span><span style="display:flex;"><span>最多修复 2 次
</span></span></code></pre></div><p>修复 prompt 的核心要求是：</p>
<ul>
<li>不要从零重写。</li>
<li>在现有 workspace 中做最小修复。</li>
<li>保证 preview entry 存在。</li>
<li>保证所有本地 script、style、image、skin asset 都存在。</li>
<li>如果是视觉质量失败，就补 PNG、改布局、修按钮反馈。</li>
<li>如果是缺资源，就优先创建缺失路径。</li>
</ul>
<p>对“预览 HTML 引用了不存在的本地资源”这种问题，还有专门的 targeted repair prompt，要求 Agent 先创建这些精确路径，避免越修越偏。</p>
<h2 id="资源生成-mcp">资源生成 MCP</h2>
<p>控制中心为 OpenGame SDK 注入了一个 <code>generate_game_asset</code> 工具：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>Agent
</span></span><span style="display:flex;"><span>  ↓ call generate_game_asset
</span></span><span style="display:flex;"><span>  ↓
</span></span><span style="display:flex;"><span>Asset MCP Server
</span></span><span style="display:flex;"><span>  ↓
</span></span><span style="display:flex;"><span>Image Provider
</span></span><span style="display:flex;"><span>  ├── tongyi
</span></span><span style="display:flex;"><span>  ├── doubao
</span></span><span style="display:flex;"><span>  ├── openai-compat
</span></span><span style="display:flex;"><span>  └── n1n
</span></span><span style="display:flex;"><span>  ↓
</span></span><span style="display:flex;"><span>写入 workspace 内 PNG 文件
</span></span></code></pre></div><p>工具入参包括：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-json" data-lang="json"><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">&#34;prompt&#34;</span>: <span style="color:#f1fa8c">&#34;space tower turret&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">&#34;outputPath&#34;</span>: <span style="color:#f1fa8c">&#34;public/skins/active/turret.png&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">&#34;size&#34;</span>: <span style="color:#f1fa8c">&#34;1024*1024&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">&#34;kind&#34;</span>: <span style="color:#f1fa8c">&#34;sprite&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">&#34;transparentBackground&#34;</span>: <span style="color:#ff79c6">true</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">&#34;styleGuide&#34;</span>: <span style="color:#f1fa8c">&#34;dark sci-fi pixel art&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">&#34;negativePrompt&#34;</span>: <span style="color:#f1fa8c">&#34;text, logo, watermark&#34;</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>如果图片 provider 超时、限流或临时失败，系统会用 sharp 生成一个 fallback PNG。这样任务不会因为图片服务偶发不可用而完全中断。</p>
<h2 id="版本链设计">版本链设计</h2>
<p>游戏生成成功后，它不只是一个 task，也是一个 game version。</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>games
</span></span><span style="display:flex;"><span>└── gameId
</span></span><span style="display:flex;"><span>    ├── V1 taskId
</span></span><span style="display:flex;"><span>    ├── V2 taskId
</span></span><span style="display:flex;"><span>    └── V3 taskId
</span></span></code></pre></div><p>当用户基于某个版本继续迭代时：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>选择 V1
</span></span><span style="display:flex;"><span>  ↓
</span></span><span style="display:flex;"><span>输入 changeNote
</span></span><span style="display:flex;"><span>  ↓
</span></span><span style="display:flex;"><span>复制 V1 workspace 到 version-2
</span></span><span style="display:flex;"><span>  ↓
</span></span><span style="display:flex;"><span>构造迭代 prompt
</span></span><span style="display:flex;"><span>  ↓
</span></span><span style="display:flex;"><span>创建 V2 task
</span></span><span style="display:flex;"><span>  ↓
</span></span><span style="display:flex;"><span>进入同一套队列与 Gate
</span></span></code></pre></div><p>这个设计比“覆盖式修改”稳很多。每次迭代都有完整产物、日志、质量报告和回退路径。</p>
<h2 id="换皮设计">换皮设计</h2>
<p>换皮不是让用户直接上传任意图片覆盖目录，而是依赖 <code>skin-manifest.json</code>。</p>
<p>manifest 结构大致是：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-json" data-lang="json"><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">&#34;version&#34;</span>: <span style="color:#bd93f9">1</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">&#34;assets&#34;</span>: [
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>      <span style="color:#ff79c6">&#34;key&#34;</span>: <span style="color:#f1fa8c">&#34;player&#34;</span>,
</span></span><span style="display:flex;"><span>      <span style="color:#ff79c6">&#34;label&#34;</span>: <span style="color:#f1fa8c">&#34;玩家角色&#34;</span>,
</span></span><span style="display:flex;"><span>      <span style="color:#ff79c6">&#34;path&#34;</span>: <span style="color:#f1fa8c">&#34;public/skins/active/player.png&#34;</span>,
</span></span><span style="display:flex;"><span>      <span style="color:#ff79c6">&#34;placeholderPath&#34;</span>: <span style="color:#f1fa8c">&#34;public/skins/placeholders/player.png&#34;</span>,
</span></span><span style="display:flex;"><span>      <span style="color:#ff79c6">&#34;mimeType&#34;</span>: <span style="color:#f1fa8c">&#34;image/png&#34;</span>
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>  ]
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>控制中心可以：</p>
<ol>
<li>根据 manifest 导出占位资源 zip。</li>
<li>用户替换 zip 内同路径 PNG。</li>
<li>上传 zip。</li>
<li>后端复制当前 workspace。</li>
<li>只替换 manifest 中声明的资源。</li>
<li>重新 <code>npm run build</code>。</li>
<li>创建一个新的成功版本。</li>
</ol>
<p>换皮本质上也是版本迭代，只是它不调用大模型。</p>
<h2 id="失败分类">失败分类</h2>
<p>失败不能只存一段错误文本。控制中心会把失败归类为 <code>failureReason</code>，例如：</p>
<table>
  <thead>
      <tr>
          <th>类型</th>
          <th>含义</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>api_terminated</code></td>
          <td>上游 API 中断</td>
      </tr>
      <tr>
          <td><code>timeout</code></td>
          <td>SDK 或任务超时</td>
      </tr>
      <tr>
          <td><code>build_failed</code></td>
          <td>构建失败</td>
      </tr>
      <tr>
          <td><code>playable_check_failed</code></td>
          <td>浏览器可玩性检查失败</td>
      </tr>
      <tr>
          <td><code>visual_quality_failed</code></td>
          <td>视觉质量检查失败</td>
      </tr>
      <tr>
          <td><code>cancelled</code></td>
          <td>被取消</td>
      </tr>
      <tr>
          <td><code>recovered</code></td>
          <td>进程重启后恢复失败状态</td>
      </tr>
  </tbody>
</table>
<p>结构化失败原因有两个作用：</p>
<ul>
<li>UI 可以显示更友好的失败说明。</li>
<li>retry 可以判断从哪个阶段恢复，例如只重跑 gate，还是回到 planning。</li>
</ul>
<h2 id="进程重启恢复">进程重启恢复</h2>
<p>服务启动时会扫描所有 <code>running</code> 任务：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>server boot
</span></span><span style="display:flex;"><span>  ↓
</span></span><span style="display:flex;"><span>recoverRunningTasks()
</span></span><span style="display:flex;"><span>  ↓
</span></span><span style="display:flex;"><span>running → failed
</span></span><span style="display:flex;"><span>  ↓
</span></span><span style="display:flex;"><span>failureReason = recovered
</span></span><span style="display:flex;"><span>  ↓
</span></span><span style="display:flex;"><span>写入 task.failed 日志
</span></span></code></pre></div><p>这样做很朴素，但非常重要。因为 Node 进程重启后，内存里的队列已经没了，继续显示 <code>running</code> 会误导用户。把它们标记为可解释的失败状态，再允许用户 retry，是更可靠的选择。</p>
<h2 id="总体逻辑图">总体逻辑图</h2>
<p>完整控制中心可以这样理解：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>┌────────────────────┐
</span></span><span style="display:flex;"><span>│     React UI        │
</span></span><span style="display:flex;"><span>│ create/list/log/play│
</span></span><span style="display:flex;"><span>└─────────┬──────────┘
</span></span><span style="display:flex;"><span>          │ HTTP / SSE
</span></span><span style="display:flex;"><span>          ▼
</span></span><span style="display:flex;"><span>┌────────────────────┐
</span></span><span style="display:flex;"><span>│   Next.js API       │
</span></span><span style="display:flex;"><span>│ route handlers      │
</span></span><span style="display:flex;"><span>└─────────┬──────────┘
</span></span><span style="display:flex;"><span>          │
</span></span><span style="display:flex;"><span>          ▼
</span></span><span style="display:flex;"><span>┌────────────────────┐
</span></span><span style="display:flex;"><span>│   TaskService       │
</span></span><span style="display:flex;"><span>│ lifecycle director  │
</span></span><span style="display:flex;"><span>└──────┬───────┬──────┘
</span></span><span style="display:flex;"><span>       │       │
</span></span><span style="display:flex;"><span>       │       ├──────────────┐
</span></span><span style="display:flex;"><span>       ▼       ▼              ▼
</span></span><span style="display:flex;"><span>┌──────────┐ ┌──────────┐ ┌───────────────┐
</span></span><span style="display:flex;"><span>│ SQLite   │ │ JSONL Log│ │ Workspace FS  │
</span></span><span style="display:flex;"><span>└──────────┘ └──────────┘ └───────────────┘
</span></span><span style="display:flex;"><span>       │
</span></span><span style="display:flex;"><span>       ▼
</span></span><span style="display:flex;"><span>┌────────────────────┐
</span></span><span style="display:flex;"><span>│    TaskQueue        │
</span></span><span style="display:flex;"><span>└─────────┬──────────┘
</span></span><span style="display:flex;"><span>          ▼
</span></span><span style="display:flex;"><span>┌────────────────────┐
</span></span><span style="display:flex;"><span>│    TaskRunner       │
</span></span><span style="display:flex;"><span>└─────────┬──────────┘
</span></span><span style="display:flex;"><span>          ▼
</span></span><span style="display:flex;"><span>┌────────────────────┐
</span></span><span style="display:flex;"><span>│  @opengame/sdk      │
</span></span><span style="display:flex;"><span>│  + asset MCP        │
</span></span><span style="display:flex;"><span>└─────────┬──────────┘
</span></span><span style="display:flex;"><span>          ▼
</span></span><span style="display:flex;"><span>┌────────────────────┐
</span></span><span style="display:flex;"><span>│ Generated Web Game  │
</span></span><span style="display:flex;"><span>└─────────┬──────────┘
</span></span><span style="display:flex;"><span>          ▼
</span></span><span style="display:flex;"><span>┌────────────────────┐
</span></span><span style="display:flex;"><span>│ Playable/Visual Gate│
</span></span><span style="display:flex;"><span>└──────┬────────┬─────┘
</span></span><span style="display:flex;"><span>       │        │
</span></span><span style="display:flex;"><span>       ▼        ▼
</span></span><span style="display:flex;"><span>  succeeded   repair / failed
</span></span></code></pre></div><h2 id="设计取舍">设计取舍</h2>
<h3 id="1-串行队列而不是并发队列">1. 串行队列，而不是并发队列</h3>
<p>当前实现使用单队列串行执行。这样吞吐低一些，但更稳：</p>
<ul>
<li>避免多个 Agent 同时抢 CPU、网络和图片 provider。</li>
<li>避免 Playwright 与构建进程把服务器打满。</li>
<li>更容易定位日志和失败原因。</li>
</ul>
<p>等单任务链路稳定后，再考虑按资源配额做并发。</p>
<h3 id="2-本地-sqlite而不是一开始上-postgresql">2. 本地 SQLite，而不是一开始上 PostgreSQL</h3>
<p>控制中心首先是单机工具，SQLite 足够简单可靠。任务元数据放 SQLite，产物放文件系统，日志放 JSONL，正好符合静态产物生成场景。</p>
<p>如果后续要多人使用、分布式队列、多机执行，再迁到 PostgreSQL + Redis Queue 会更合适。</p>
<h3 id="3-gate-失败自动修复但限制次数">3. Gate 失败自动修复，但限制次数</h3>
<p>自动修复有价值，但不能无限循环。当前最多修复 2 次。这样可以覆盖资源缺失、构建小错误、空白画面等常见问题，同时避免 Agent 在错误方向上无限消耗。</p>
<h3 id="4-半自动-gdd-审核是必要入口">4. 半自动 GDD 审核是必要入口</h3>
<p>全自动适合快速试验，但复杂游戏最好先审 GDD。GDD 是后续 M1-M4 的源头，如果需求理解错了，后面生成得越完整，返工越重。</p>
<h2 id="小结">小结</h2>
<p>OpenGame Web 控制中心真正解决的不是“给命令行加一个网页按钮”，而是把 AI 生成游戏这件事产品化：</p>
<ul>
<li>用任务队列承接长耗时生成。</li>
<li>用 GDD 把模糊需求变成工程计划。</li>
<li>用 M1-M4 把一次性生成拆成可控阶段。</li>
<li>用 Playwright 和视觉检查把“可玩”变成门禁。</li>
<li>用 repair prompt 把失败转成下一轮修复输入。</li>
<li>用版本链和换皮把一次生成变成可持续迭代。</li>
</ul>
<p>这套设计也可以迁移到其他 Agent 应用：只要任务耗时长、结果需要验收、失败需要自动修复，就应该把 Agent 调用包进类似的控制中心，而不是停留在一次性脚本调用。</p>
]]></content:encoded></item></channel></rss>