跳转到内容

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.md

package.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()
);

Terminal window
docker compose up -d pg
npm install
npm run db:setup
npm run dev

私聊 Agent 说 “/play 2048”,滑动方向键玩;“/leaderboard 2048” 看排行榜。

相关