跳转到内容

状态化交互 — 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 变更)。

下一步