状态化交互 — 2048 小游戏
投票卡是”几乎无状态”的小程序;游戏是”重状态”的——每次操作都改变 4×4 棋盘、 分数、历史栈。这一页用 2048 作为完整案例,带你实现一个真正能玩的聊天内 小游戏。
游戏规则简介
经典 2048:4×4 棋盘 + 按方向键合并相同数字方块;每步随机生成一个 2 或 4; 合并后的数字累加到分数;目标拼出 2048(不强制,可继续玩)。 任何方向都不能再合并 = Game Over。
Agent 端状态机
type Game = { board: number[][]; // 4×4,0 表示空 score: number; bestScore: number; movesCount: number; gameOver: boolean; won: boolean; // 拼出 2048 时 history: number[][][]; // 每步前的快照(用于撤销)};
function newGame(): Game { const board = Array.from({ length: 4 }, () => Array(4).fill(0)); spawnTile(board); spawnTile(board); return { board, score: 0, bestScore: 0, movesCount: 0, gameOver: false, won: false, history: [] };}
function spawnTile(board: number[][]): boolean { const empties: [number, number][] = []; for (let r = 0; r < 4; r++) for (let c = 0; c < 4; c++) if (board[r][c] === 0) empties.push([r, c]); if (empties.length === 0) return false; const [r, c] = empties[Math.floor(Math.random() * empties.length)]; board[r][c] = Math.random() < 0.9 ? 2 : 4; return true;}
function move(g: Game, dir: "up" | "down" | "left" | "right"): boolean { const before = g.board.map((row) => [...row]); let moved = false; let scoreGain = 0;
const rotate = (b: number[][], times: number) => { let r = b; for (let i = 0; i < times; i++) r = r[0].map((_, idx) => r.map((row) => row[idx]).reverse()); return r; }; const compactRow = (row: number[]): { row: number[]; gain: number } => { const arr = row.filter((x) => x !== 0); let gain = 0; for (let i = 0; i < arr.length - 1; i++) { if (arr[i] === arr[i + 1]) { arr[i] *= 2; gain += arr[i]; arr.splice(i + 1, 1); } } while (arr.length < 4) arr.push(0); return { row: arr, gain }; };
const times = { left: 0, up: 1, right: 2, down: 3 }[dir]; let b = rotate(g.board, times); for (let r = 0; r < 4; r++) { const { row, gain } = compactRow(b[r]); if (row.some((v, i) => v !== b[r][i])) moved = true; b[r] = row; scoreGain += gain; } b = rotate(b, (4 - times) % 4);
if (!moved) return false;
g.history.push(before); if (g.history.length > 5) g.history.shift(); g.board = b; g.score += scoreGain; g.bestScore = Math.max(g.bestScore, g.score); g.movesCount += 1; if (b.some((r) => r.some((v) => v === 2048))) g.won = true; spawnTile(g.board); if (!canMove(g.board)) g.gameOver = true; return true;}
function canMove(b: number[][]): boolean { for (let r = 0; r < 4; r++) for (let c = 0; c < 4; c++) { if (b[r][c] === 0) return true; if (c < 3 && b[r][c] === b[r][c + 1]) return true; if (r < 3 && b[r][c] === b[r + 1][c]) return true; } return false;}
function undo(g: Game): boolean { const last = g.history.pop(); if (!last) return false; g.board = last; g.gameOver = false; return true;}Agent 启动 + Artifact 生命周期
const games = new Map<string, { game: Game; artifactId: string; conversationId: string; revision: number }>();
async function startGame(conversationId: string, userId: string) { const game = newGame(); const artifactId = ulid(); games.set(`${conversationId}:${userId}`, { game, artifactId, conversationId, revision: 0 });
await agent.sendArtifact(conversationId, { artifact: { artifact_id: artifactId, subtype: "app", title: "2048", payload: render(game), capability_flags: { allows_response: true }, }, });}
function render(g: Game) { return { ui: "2048", board: g.board, score: g.score, best: g.bestScore, moves: g.movesCount, game_over: g.gameOver, won: g.won, can_undo: g.history.length > 0, };}处理用户操作
agent.addEventHandler(async (event) => { if (event.type !== "artifact_response") return;
const game = findGameByArtifactId(event.payload.ref_artifact); if (!game) return;
const action = event.payload.action; let mutated = false;
switch (action) { case "move": { const dir = event.payload.payload?.direction; if (!["up", "down", "left", "right"].includes(dir)) return; mutated = move(game.game, dir); break; } case "undo": mutated = undo(game.game); break; case "restart": { Object.assign(game.game, newGame()); mutated = true; break; } default: return; }
if (!mutated) return; game.revision += 1; await agent.updateArtifact(game.conversationId, { ref_artifact: game.artifactId, based_on_revision: game.revision - 1, artifact: { payload: render(game.game) }, });});客户端 UI 约定
payload.ui = "2048" 客户端按以下结构渲染(V1 内置 Game2048 组件):
┌──────────────────────────────────┐│ 2048 分数 1248 最高 4096 │├──┬──┬──┬──┐ ││ │ 2│ │ │ [ 撤销 ] │├──┼──┼──┼──┤ ││ 2│ 4│ │ │ [ 重开 ] │├──┼──┼──┼──┤ ││ 4│ 8│16│ │ │├──┼──┼──┼──┤ ⬆ ⬇ ⬅ ➡ ││ 8│16│32│64│ 滑动或点方向键 │└──┴──┴──┴──┘ │└──────────────────────────────────┘客户端:
- 滑动手势 / 方向键触发 send
artifact_response { action: "move", payload: { direction: "up" } } - 撤销按钮:
{ action: "undo" } - 重开按钮:
{ action: "restart" }
游戏结束时叠加 “Game Over” + 重开按钮。
触发”开始游戏”
agent.addMessageHandler(async (msg) => { if (msg.payload?.type !== "text") return; const text = msg.payload.text.trim().toLowerCase(); if (text === "/play 2048" || text === "玩 2048") { await startGame(msg.conversation_id, msg.sender_id); }});多用户、多游戏并发
每个 (conversation_id, user_id) 一个独立游戏 instance(同一群里
不同用户各玩各的;同一私聊里同一时刻一个游戏)。
如果想让”同一群里多人合作 / 对战玩同一游戏”,把 key 改成
(conversation_id, "shared") 即可——所有用户操作合并到同一棋盘。
排行榜(持久化)
把 best score 写到外部 DB:
import { Pool } from "pg";const pool = new Pool({ connectionString: process.env.DATABASE_URL });
async function updateLeaderboard(userId: string, score: number) { await pool.query(` INSERT INTO leaderboard_2048 (user_id, best_score, updated_at) VALUES ($1, $2, NOW()) ON CONFLICT (user_id) DO UPDATE SET best_score = GREATEST(leaderboard_2048.best_score, EXCLUDED.best_score), updated_at = NOW(); `, [userId, score]);}
// 在 game over 时调if (game.gameOver) { await updateLeaderboard(userId, game.score);}提供 /leaderboard 2048 命令返回排行榜:
async function showLeaderboard(conversationId: string) { const r = await pool.query(` SELECT user_id, best_score FROM leaderboard_2048 ORDER BY best_score DESC LIMIT 10 `); await agent.sendArtifact(conversationId, { artifact: { subtype: "info", title: "2048 排行榜", payload: { body: r.rows.map((row, i) => `${i + 1}. <U:${row.user_id}> ${row.best_score}`).join("\n"), }, }, });}性能与配额
| 项 | 数字 |
|---|---|
| 单局平均 update 次数 | 200-500(典型一局走完) |
| 单 update 平均字节 | ~500B(4×4 棋盘 + 元数据) |
| 单局总数据量 | ~150 KB |
| 100 update 上限 | 接近时 SDK warn;如要继续,应 endGame + startGame 新 artifact |
100 次 update 是 artifact lifecycle 上限。一局完整 2048 ~300 步会超。 实战做法:每 80 步主动结束当前 artifact,再开一个新的(保留 game 状态在 Agent 端,仅 artifact ID 变更)。
下一步
- Artifact 高级 — 100 update 上限的 workaround
- 设计准则 — UX 优化
- 部署到 Cloudflare Workers — 把 game state 存 Durable Objects