Template — 2048 小游戏
完整 2048 starter,含游戏状态机 / 排行榜 / 撤销 / 重开。 基于 2048 完整 tutorial。
目录结构
hashee-2048-game/├── src/│ ├── index.ts│ ├── game.ts # 2048 状态机│ └── leaderboard.ts├── sql/schema.sql├── package.json├── tsconfig.json├── .env.example└── README.mdpackage.json
{ "name": "hashee-2048-game", "version": "0.1.0", "private": true, "type": "module", "scripts": { "dev": "tsx --env-file=.env src/index.ts", "build": "tsc", "db:setup": "psql $DATABASE_URL < sql/schema.sql" }, "dependencies": { "@hasheeai/agent-sdk-ts": "^0.2.0", "pg": "^8.13.0", "ulid": "^2.4.0" }, "devDependencies": { "@types/node": "^22.0.0", "@types/pg": "^8.11.0", "tsx": "^4.20.0", "typescript": "^5.7.0" }}src/game.ts — 状态机
export type Game = { board: number[][]; score: number; bestScore: number; movesCount: number; gameOver: boolean; won: boolean; history: number[][][];};
export 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(b: number[][]): boolean { const empties: [number, number][] = []; for (let r = 0; r < 4; r++) for (let c = 0; c < 4; c++) if (b[r][c] === 0) empties.push([r, c]); if (empties.length === 0) return false; const [r, c] = empties[Math.floor(Math.random() * empties.length)]; b[r][c] = Math.random() < 0.9 ? 2 : 4; return true;}
export 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 = (m: number[][], n: number) => { let r = m; for (let i = 0; i < n; i++) r = r[0].map((_, c) => r.map((row) => row[c]).reverse()); return r; }; const compactRow = (row: 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;}
export function undo(g: Game): boolean { const last = g.history.pop(); if (!last) return false; g.board = last; g.gameOver = false; return true;}src/leaderboard.ts
import { Pool } from "pg";const db = new Pool({ connectionString: process.env.DATABASE_URL });
export async function recordScore(userId: string, score: number) { await db.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]);}
export async function topN(n = 10) { const r = await db.query( "SELECT user_id, best_score FROM leaderboard_2048 ORDER BY best_score DESC LIMIT $1", [n], ); return r.rows as { user_id: string; best_score: number }[];}src/index.ts
import { HasheeAgent } from "@hasheeai/agent-sdk-ts";import { ulid } from "ulid";import { newGame, move, undo, type Game } from "./game";import { recordScore, topN } from "./leaderboard";
const agent = await HasheeAgent.init({ agentId: process.env.HASHEE_AGENT_ID!, token: process.env.HASHEE_AGENT_TOKEN!, baseUrl: "https://api.hashee.ai", connectionMode: "websocket",});
const games = new Map<string, { game: Game; artifactId: string; convId: string; revision: number; userId: string }>();
agent.addMessageHandler(async (msg) => { if (msg.payload?.type !== "text") return; const text = msg.payload.text.trim(); if (text === "/play 2048") return startGame(msg); if (text === "/leaderboard 2048") return showBoard(msg);});
async function startGame(msg: any) { const game = newGame(); const artifactId = ulid(); games.set(`${msg.conversation_id}:${msg.sender_id}`, { game, artifactId, convId: msg.conversation_id, revision: 0, userId: msg.sender_id, }); await agent.sendArtifact(msg.conversation_id, { artifact: { artifact_id: artifactId, subtype: "app", title: "2048", payload: render(game), capability_flags: { allows_response: true }, }, });}
async function showBoard(msg: any) { const top = await topN(10); await agent.sendArtifact(msg.conversation_id, { artifact: { subtype: "info", title: "2048 排行榜 (Top 10)", payload: { body: top.map((r, i) => `${i + 1}. @<U:${r.user_id}> — ${r.best_score}`).join("\n"), }, }, });}
agent.addEventHandler(async (event) => { if (event.type !== "artifact_response") return; const ref = event.payload.ref_artifact; const game = [...games.values()].find((g) => g.artifactId === ref); if (!game) return;
let mutated = false; if (event.payload.action === "move") { const dir = event.payload.payload?.direction; if (!["up", "down", "left", "right"].includes(dir)) return; mutated = move(game.game, dir); } else if (event.payload.action === "undo") { mutated = undo(game.game); } else if (event.payload.action === "restart") { Object.assign(game.game, newGame()); mutated = true; } if (!mutated) return;
game.revision++; await agent.updateArtifact(game.convId, { ref_artifact: game.artifactId, based_on_revision: game.revision - 1, artifact: { payload: render(game.game) }, });
if (game.game.gameOver) { await recordScore(game.userId, game.game.score); }});
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.addStatusHandler((s) => console.log(`[hashee] ${s}`));console.log("[2048] up");sql/schema.sql
CREATE TABLE IF NOT EXISTS leaderboard_2048 ( user_id TEXT PRIMARY KEY, best_score INT NOT NULL, updated_at TIMESTAMPTZ DEFAULT NOW());跑
docker compose up -d pgnpm installnpm run db:setupnpm run dev私聊 Agent 说 “/play 2048”,滑动方向键玩;“/leaderboard 2048” 看排行榜。