【AI桌游】卡卡颂(Carcassonne)AI对弈详细设计(一)
1. 概述:
卡卡颂(Carcassonne)是一款经典的德式板块放置桌游。玩家轮流抽取地形板块,拼接到不断蔓延的版图上,并用有限的"米宝"(Meeple,小木人)占领道路、城堡、修道院和草原。游戏结束时,谁的分高谁赢。
本项目的核心目标:让玩家打开浏览器就能和 AI 来一局。AI 的"大脑"不是写死的算法,而是真正的大语言模型(LLM)——你可以接自己的 OpenAI 兼容 API,也可以用系统内置的 SDK 作为兜底。
如下图所示:

1.1 技术栈一览
| 层级 | 技术选型 | 版本 | 为什么选它 |
|---|---|---|---|
| 全栈框架 | Next.js (App Router, standalone) | ^16.1.1 | 一套代码搞定前后端,API Route 天然适合做 LLM 代理 |
| 语言 | TypeScript | ^5 | 棋盘状态复杂,类型安全能救命 |
| 前端 UI | React 19 + Tailwind CSS 4 + shadcn/ui | ^19, ^4 | 原子化样式 + 无障碍组件,省掉大量 CSS 体力活 |
| 状态管理 | Zustand | ^5.0.6 | 比 Redux 轻,比 Context 好用,持久化一行代码搞定 |
| 数据库 | SQLite (Prisma ORM) | ^6.11.1 | 目前主要存用户配置,轻量零运维 |
| AI SDK | z-ai-web-dev-sdk | ^0.0.18 | 内置兜底,用户没配 API 也能玩 |
| 运行时 | Bun | — | 快,内置打包,开发体验好 |
| 部署 | Caddy | — | 自动 HTTPS,反向代理 Next.js standalone |
2. 系统架构:三层分工,各管一摊
2.1 整体架构
整个系统可以看作三层:表现层(浏览器里的 React)、调度层(Zustand Store)、代理层(Next.js API Route)。游戏的核心逻辑(怎么算分、怎么判定合法位置)全部跑在浏览器里,服务端只干一件事——帮前端把请求转发给 LLM。
关键设计:游戏状态完全在客户端。这意味着你关掉浏览器标签页,这局游戏就没了——但换来的是零服务端状态维护成本,不需要 WebSocket,不需要房间管理,单机就能跑。
2.2 目录结构
src/
├── app/ # Next.js App Router
│ ├── layout.tsx # 根布局:字体、元数据、全局样式注入
│ ├── page.tsx # 主页面:欢迎页 ↔ 游戏页 的切换中枢
│ ├── globals.css # Tailwind 自定义动画、主题变量
│ └── api/ # API 路由(服务端代理层)
│ ├── route.ts # GET /api 健康检查
│ └── carcassonne/
│ ├── ai-move/route.ts # POST:把棋盘状态发给 LLM,拿回 AI 决策
│ └── llm-test/route.ts # POST:测试 LLM 连通性
├── components/
│ ├── carcassonne/
│ │ ├── GameBoard.tsx # SVG 棋盘:~1000 行,视觉核心
│ │ ├── GameUI.tsx # 侧边栏:计分板、操作按钮、日志
│ │ ├── ConfigDialog.tsx # LLM 配置弹窗:增删改查 + 连接测试
│ │ └── ConfigPanel.tsx # 配置面板(备用入口)
│ └── ui/ # shadcn/ui 组件库(Button、Dialog、Input 等)
├── hooks/
│ ├── use-mobile.ts # 响应式:检测是否移动端
│ └── use-toast.ts # 全局 Toast 通知
├── lib/
│ ├── carcassonne/
│ │ ├── types.ts # 全游戏类型定义 + UnionFind 并查集
│ │ ├── tiles.ts # 25 种板块模板 + 牌堆工厂
│ │ └── engine.ts # 游戏引擎:纯函数,无副作用
│ ├── db.ts # Prisma 客户端单例
│ └── utils.ts # 通用工具(cn 合并类名等)
└── stores/
└── game-store.ts # Zustand:状态 + 动作 + AI 调度 + 持久化
3. 核心模块详细设计
3.1 类型系统:给棋盘世界建一套"语法"
3.1.1 基础枚举
type EdgeType = 'city' | 'road' | 'field'; // 板块边缘:城墙、道路、草地
type FeatureType = 'road' | 'city' | 'cloister' | 'field'; // 地形特征
type Player = 'human' | 'ai'; // 玩家阵营
type GamePhase = 'config' | 'playing' | 'place_tile' | 'place_meeple'
| 'ai_turn' | 'scoring' | 'game_over'; // 游戏阶段状态机
type Direction = 0 | 1 | 2 | 3; // 北、东、南、西(顺时针)
3.1.2 核心数据结构
| 结构 | 说明 |
|---|---|
Position { x, y } |
棋盘坐标系。起始板块固定在 (0,0),向东 x+1,向南 y+1 |
TileFeature { type, edges[], shield? } |
板块上的"地形特征"。比如一块"城堡+直路"板,可能有两个特征:一个城堡特征占北边,一个道路特征占东-南-西三边 |
TileTemplate { id, name, edges[4], features[], count } |
板块模板。edges[4] 按北-东-南-西顺序存储,count 表示这副牌里这种板有几张 |
Tile { id, templateId, rotation } |
板块实例。id 带计数器后缀(如 road_straight_3),确保 72 块板每块唯一 |
PlacedTile { tile, position, features[], edges[4] } |
已放置到棋盘上的板块。features 和 edges 是旋转后的实际值 |
PlacedMeeple { player, position, featureIndex } |
米宝实例。featureIndex 指向该板块上的第几个特征 |
LegalPlacement { position, rotation } |
合法放置方案:放哪儿 + 转多少度 |
MeepleOption { featureIndex, featureType, description } |
当前可放米宝的选项列表 |
ScoringEvent { player, points, featureType, description, returnedMeeples } |
一次得分事件:谁得了多少分、因为什么、哪些米宝回家了 |
LLMConfig { id, name, url, apiKey, modelId } |
大模型配置:名称、Base URL、密钥、模型 ID |
3.1.3 游戏状态全景
interface GameState {
board: Map<string, PlacedTile>; // 棋盘哈希表,key = "x,y",O(1) 查询
placedMeeples: PlacedMeeple[]; // 所有已放置的米宝
tileDeck: Tile[]; // 待抽牌堆(剩余板块)
currentTile: Tile | null; // 当前玩家手里的板块
discardedTiles: Tile[]; // 被丢弃的板块(无处可放时)
currentPlayer: Player; // 当前行动玩家
scores: Record<Player, number>; // 双方分数
remainingMeeples: Record<Player, number>; // 剩余米宝(每人初始 7 个)
phase: GamePhase; // 当前阶段,驱动 UI 切换
turn: number; // 回合计数
log: GameLogEntry[]; // 操作日志(倒序展示)
legalPlacements: LegalPlacement[]; // 当前板块的合法位置(预计算)
meepleOptions: MeepleOption[]; // 当前可放米宝的选项(预计算)
selectedPlacement: LegalPlacement | null; // 玩家鼠标悬停/点击选中的位置
scoringEvents: ScoringEvent[]; // 本回合触发的得分事件队列
aiThinking: boolean; // AI 是否正在"思考"(控制加载动画)
llmConfig: LLMConfig; // 当前使用的 LLM 配置
}
如下图所示:
3.1.4 Union-Find 并查集:连通性的"户籍管理系统"
卡卡颂的核心乐趣在于连通——两块不相邻的城堡,可能因为中间不断拼接的板块,最终连成一座巨型城市。怎么快速判断"这两块地是不是连在一起的"?答案是并查集(Union-Find)。
为什么不用 BFS/DFS? 因为棋盘每回合都在变,如果每次计分都重新遍历整个图,72 块板 × 每块 4 个特征 = 288 个节点,虽然不大,但并查集能做到近乎 O(1) 的连通查询,而且增量维护非常方便。
并查集的工作原理:
想象每个特征是一个"村民"。当两个相邻板块的同类型特征碰面时,这两个村民就认作同族(union)。每个族选一个"族长"(根节点)。查关系时,只要看族长是不是同一个人。
特征 ID 编码:"x,y,featureIndex",比如 (0,0) 板块的第 1 个特征,ID 就是 "0,0,1"。
class UnionFind {
makeSet(id): void // 新板块放上棋盘时,给它的每个特征"上户口"
find(id): string // 查族长(路径压缩:顺便把沿途村民的直属上级改成族长)
union(a, b): void // 合并两族(按秩合并:小族并入大族,树不会太深)
connected(a, b): boolean // 是不是同族?
getComponent(id): string[] // 查这个族所有成员
getAllRoots(): string[] // 所有族长名单
getComponentMembers(root): string[] // 指定族长的全部族人
}
路径压缩和按秩合并是两个经典优化。没有它们,并查集可能退化成链表;有了它们,查询复杂度接近 O(α(n)),α 是阿克曼函数的反函数,比 log 还慢,对于 72 块板的规模几乎可以视为常数。
3.2 板块模板系统:72 块板的"基因库"
3.2.1 25 种板块模板
标准卡卡颂基础版共 72 块板(含 1 块起始板)。系统用 25 种模板覆盖全部类型:
| 类别 | 模板ID | 名称 | 数量 | 四边 (N,E,S,W) | 设计意图 |
|---|---|---|---|---|---|
| 起始 | start |
起始板块 | 1 | C,R,F,R | 给游戏一个"种子",城堡在北,道路呈 T 形 |
| 直路 | road_straight |
直路 | 4 | R,F,R,F | 简单延伸,连接两端 |
| 弯路 | road_curve |
弯路 | 8 | R,R,F,F | 最灵活的道路组件,数量最多 |
| 丁字路 | road_t |
丁字路口 | 4 | R,R,R,F | 道路分叉,增加连通复杂度 |
| 十字路 | crossroads |
十字路口 | 1 | R,R,R,R | 唯一能让四条路交汇的板,稀缺 |
| 单城堡 | city1 |
单城堡 | 5 | C,F,F,F | 快速得分的小城堡 |
| 城堡+直路 | city1_road_ew |
城堡+直路 | 4 | C,R,F,R | 城堡与道路"和平共处" |
| 城堡+弯路 | city1_road_sw |
城堡+弯路 | 3 | C,F,R,R | 道路从城堡下方绕过 |
| 城堡+弯路2 | city1_road_es |
城堡+弯路2 | 3 | C,R,R,F | 镜像对称,增加变化 |
| 双城堡(邻) | city2_adj |
双城堡(邻) | 2 | C,C,F,F | 两个城堡背靠背,可能各自独立计分 |
| 双城堡(邻+盾) | city2_adj_shield |
双城堡(邻+盾) | 2 | C,C,F,F | 带盾牌的城堡分更高 |
| 双城堡(邻)+路 | city2_adj_road |
双城堡(邻)+路 | 2 | C,C,R,R | 城堡夹道路 |
| 双城堡(邻+盾)+路 | city2_adj_road_shield |
双城堡(邻+盾)+路 | 2 | 同上,加分更高 | |
| 双城堡(对) | city2_opp |
双城堡(对) | 3 | C,F,C,F | 南北对望,可能连成一线 |
| 双城堡(对)+路 | city2_opp_road |
双城堡(对)+路 | 3 | 中间夹直路 | |
| 三城堡 | city3 |
三城堡 | 3 | C,C,C,F | 几乎封闭,容易完成 |
| 三城堡+盾 | city3_shield |
三城堡+盾 | 2 | 高分版三城堡 | |
| 三城堡+路 | city3_road |
三城堡+路 | 2 | 三面城墙,一面道路出口 | |
| 三城堡+盾+路 | city3_road_shield |
三城堡+盾+路 | 1 | 最稀有的城堡板之一 | |
| 四城堡+盾 | city4_shield |
四城堡+盾 | 1 | C,C,C,C | 完全封闭的城堡,直接完成 |
| 修道院 | cloister |
修道院 | 4 | F,F,F,F | 被草地包围,周围 8 格填满即完成 |
| 修道院+路 | cloister_road |
修道院+路 | 2 | 修道院带一条道路,增加互动 | |
| 城堡+断头路 | city1_road_dead_s |
城堡+断头路 | 3 | C,F,R,F | 道路只连一边,形成"死胡同" |
| 双城堡(邻)+断头路 | city2_adj_road_dead |
双城堡(邻)+断头路 | 2 | 复杂地形,道路从城堡间伸出 |
3.2.2 板块工具函数
| 函数 | 作用 | 实现细节 |
|---|---|---|
createTileDeck() |
创建 72 块牌的牌堆 | 遍历 25 种模板,按 count 生成对应数量的 Tile 实例,ID 格式为 templateId_序号 |
shuffleDeck(deck) |
洗牌 | Fisher-Yates 算法:从后往前,随机交换当前位置与前面某个位置 |
getTemplate(templateId) |
查模板 | 用正则 replace(/_\d+$/, '') 去掉计数器后缀,匹配模板 ID |
rotateEdges(edges, rotation) |
旋转边缘 | rotation 为 0/1/2/3(代表 0°/90°/180°/270°),数组轮转 |
rotateFeatures(features, rotation) |
旋转特征 | 特征内部的 edges 索引按旋转角度偏移,模 4 运算 |
getTileDisplayInfo(templateId, rotation) |
获取渲染信息 | 返回旋转后的完整边缘数组和特征列表,供 SVG 绘制使用 |
3.3 游戏引擎:纯函数的"棋盘世界"
engine.ts 是整个项目最"硬核"的模块。它的设计哲学是纯函数——输入一个 GameState,输出一个新的 GameState,绝不修改输入,也不碰任何外部状态。
3.3.1 游戏阶段状态机
卡卡颂的一回合可以拆解为固定的阶段流转。理解这个状态机,就理解了整款游戏的骨架:
各阶段详解:
config:欢迎页。玩家在此配置 LLM API,点击开始后进入游戏。place_tile:人类回合的核心。玩家看到当前板块,棋盘上高亮所有合法位置(粉色),点击选中后变为绿色高亮。可以按旋转按钮调整方向。place_meeple:板块落地后,系统计算该板块上哪些特征可以合法放置米宝(不能放在已有米宝的连通特征上)。玩家选择特征或跳过。scoring:放置米宝后(或跳过后),系统检查是否有完成的特征(封闭的道路、城堡、被 8 块板包围的修道院)。如果有,立即计分,米宝回家。ai_turn:AI 的回合。前端通过 API 请求 LLM 决策,拿到结果后分步动画执行(先放板 → 再放米宝 → 显示得分)。game_over:72 块板全部抽完(或无法放置的板全部丢弃)后,进入终局。此时所有未完成的特征(未封闭的城堡、道路、修道院)和草原都要计分。
3.3.2 核心算法详解
算法一:合法放置计算 (getLegalPlacements)
这是每回合的第一步:玩家抽了一块板,系统要告诉他"这块板能放哪儿、能怎么转"。
关键细节:
- 候选位置来自"已有板块的邻居"。如果棋盘只有起始板
(0,0),候选位置就是(0,1)、(1,0)、(0,-1)、(-1,0)。 - 验证时,假设把当前板放在
(x,y)、旋转r,然后检查它的四条边:如果北边有板,当前板的北边类型必须等于邻居板的南边类型;东边有板,当前板的东边必须等于邻居板的西边……以此类推。 - 复杂度:候选位置最多 O(n),旋转 4 种,验证 4 条边,总复杂度 O(n),n 为已放置板块数(最大 72,完全可接受)。
算法二:特征连通性分析 (buildFeatureUnionFind)
每当地图变化(新板块放上),就要重建并查集,或者增量更新。当前实现是全量重建(72 块板规模下完全够用)。
算法三:特征完成检测 (analyzeFeatures)
完成检测是计分的前提。不同特征有不同的"完成"定义:
道路/城堡完成的直观理解:
想象一条道路,它的两端必须是"断头"(没有板块继续接)才算完成。在代码里,这意味着:遍历该道路连通分量里的每个特征,看这个特征占用了哪些边;如果某条边指向的位置没有板块,那就是一个"开口";如果所有边指向的位置都有板块,说明这条路被完全堵死了——它完成了。
城堡同理:一座城堡的城墙如果每段都挨着其他板块(或地图边缘,但卡卡颂规则里地图边缘不算封闭),说明它被完全包围了。
算法四:计分规则
| 特征类型 | 完成时得分 | 未完成时得分(终局) |
|---|---|---|
| 道路 | 连通分量包含几块板,得几分 | 同上,每块 1 分 |
| 城堡 | 每块板 2 分 + 盾牌数 × 2 分 | 每块板 1 分 + 盾牌数 × 1 分 |
| 修道院 | 周围 8 格全满,得 9 分 | 周围实际有板块的格子数(最少 1 分) |
| 草原 | 游戏中不得分 | 每座相邻的已完成城堡得 3 分 |
得分归属规则(最关键):
- 找到该连通分量上所有米宝。
- 统计每个玩家放了几个米宝。
- 米宝最多的玩家独得全部分数。如果平局(比如人类和 AI 各放 1 个),双方各得全部分数。
- 得分的米宝全部返还给玩家(回到剩余米宝池)。
算法五:米宝放置验证 (getValidMeeplePlacements)
这是卡卡颂最经典的规则之一:你不能把米宝放在已经有米宝的地形上。
为什么草原排最后? 因为草原是终局计分,而且一旦放下去就很难拿回来(草原永不完成),属于高风险投资。把非草原选项放前面,减少玩家误操作。
3.3.3 状态变更函数清单
| 函数 | 触发时机 | 作用 |
|---|---|---|
createInitialState() |
页面加载 | 生成空游戏状态,所有数组/Map 初始化 |
startGame(state) |
点击"开始游戏" | Fisher-Yates 洗牌 → 放置起始板到 (0,0) → 抽第一张牌 → 计算合法位置 → 进入 place_tile |
placeTile(state, placement) |
确认放置 | 把 currentTile 放入棋盘 → 重新计算并查集 → 计算可放米宝选项 → 进入 place_meeple |
placeMeeple(state, featureIndex) |
选择放米宝 | 扣减剩余米宝 → 加入 placedMeeples → 检测完成特征 → 生成 scoringEvents → 进入 scoring |
skipMeeple(state) |
点击跳过 | 不扣减米宝 → 直接检测完成特征 → 进入 scoring |
endTurn(state) |
得分动画结束 | 切换 currentPlayer → 抽下一张牌 → 如果无合法位置 → 丢弃并递归抽下一张 → 进入下一玩家的 place_tile 或 ai_turn |
finalScoring(board, meeples) |
进入 game_over |
遍历所有未完成特征计分 → 遍历所有草原计分 → 返回最终分数 |
更多推荐




所有评论(0)