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

🧠 AI 大脑

⚙️ Next.js 服务端

🖥️ 浏览器端

fetch JSON

优先调用

失败回退

测试连接

page.tsx
主页面

GameBoard.tsx
SVG 棋盘渲染

GameUI.tsx
侧边操作面板

game-store.ts
Zustand 全局状态

/api/carcassonne/ai-move
AI 决策代理

/api/carcassonne/llm-test
连接测试

用户配置的
OpenAI 兼容 API

z-ai-web-dev-sdk
内置兜底 SDK

关键设计:游戏状态完全在客户端。这意味着你关掉浏览器标签页,这局游戏就没了——但换来的是零服务端状态维护成本,不需要 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] } 已放置到棋盘上的板块。featuresedges旋转后的实际值
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)。每个族选一个"族长"(根节点)。查关系时,只要看族长是不是同一个人。

板块 (0,1) 北边

板块 (0,1) 南边

板块 (0,0) 北边

union: 相邻同类型

union: 同一板块内
特征连通

特征 A
city

特征 B
city

特征 C
city

特征 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 游戏阶段状态机

卡卡颂的一回合可以拆解为固定的阶段流转。理解这个状态机,就理解了整款游戏的骨架:

打开页面

点击"开始游戏"

玩家选中位置并确认放置

旋转板块(玩家调整方向)

玩家选择放置米宝

玩家点击"跳过"不放米宝

人类得分结算完毕,切换为 AI 回合

人类得分结算完毕,切换为 AI 回合

AI 完成放置+米宝

AI 得分结算完毕,切换为人类回合

AI 得分结算完毕,切换为人类回合(理论上不会连续 AI)\n注:实际代码中通过 currentPlayer 判断

牌堆耗尽且无法放置

牌堆耗尽

终局计分完成

config

place_tile

place_meeple

scoring

ai_turn

game_over

人类玩家回合:
从牌堆抽板 → 显示合法位置
→ 点击选中 → 可旋转 → 确认

AI 玩家回合:
构造 Prompt → 请求 LLM
→ 解析决策 → 执行放置
→ 自动选择米宝

各阶段详解

  • config:欢迎页。玩家在此配置 LLM API,点击开始后进入游戏。
  • place_tile:人类回合的核心。玩家看到当前板块,棋盘上高亮所有合法位置(粉色),点击选中后变为绿色高亮。可以按旋转按钮调整方向。
  • place_meeple:板块落地后,系统计算该板块上哪些特征可以合法放置米宝(不能放在已有米宝的连通特征上)。玩家选择特征或跳过。
  • scoring:放置米宝后(或跳过后),系统检查是否有完成的特征(封闭的道路、城堡、被 8 块板包围的修道院)。如果有,立即计分,米宝回家。
  • ai_turn:AI 的回合。前端通过 API 请求 LLM 决策,拿到结果后分步动画执行(先放板 → 再放米宝 → 显示得分)。
  • game_over:72 块板全部抽完(或无法放置的板全部丢弃)后,进入终局。此时所有未完成的特征(未封闭的城堡、道路、修道院)和草原都要计分。
3.3.2 核心算法详解

算法一:合法放置计算 (getLegalPlacements)

这是每回合的第一步:玩家抽了一块板,系统要告诉他"这块板能放哪儿、能怎么转"。

必须满足

必须满足

开始: 当前板块 + 当前棋盘

Step 1: 收集候选位置

遍历棋盘上所有已放置板块
收集它们的上下左右空位
去重后得到候选集

Step 2: 四向旋转验证

对每个候选位置
尝试 rotation = 0,1,2,3

验证规则

至少有一个相邻板块
不能孤零零放野外

所有相邻边类型匹配
北对南、东对西、类型一致

通过验证 → 加入合法列表

返回 LegalPlacement[]

关键细节

  • 候选位置来自"已有板块的邻居"。如果棋盘只有起始板 (0,0),候选位置就是 (0,1)(1,0)(0,-1)(-1,0)
  • 验证时,假设把当前板放在 (x,y)、旋转 r,然后检查它的四条边:如果北边有板,当前板的北边类型必须等于邻居板的南边类型;东边有板,当前板的东边必须等于邻居板的西边……以此类推。
  • 复杂度:候选位置最多 O(n),旋转 4 种,验证 4 条边,总复杂度 O(n),n 为已放置板块数(最大 72,完全可接受)。

算法二:特征连通性分析 (buildFeatureUnionFind)

每当地图变化(新板块放上),就要重建并查集,或者增量更新。当前实现是全量重建(72 块板规模下完全够用)。

北-南接触

东-西接触

开始: 当前棋盘 + 所有已放置米宝

Step 1: 上户口

为每个 PlacedTile 的每个 TileFeature
创建并查集节点
ID = 'x,y,featureIndex'

Step 2: 找邻居

遍历所有相邻板块对
如 (0,0) 和 (0,1)

检查接触边

比较 (0,0) 的北边特征
和 (0,1) 的南边特征

比较 (0,0) 的东边特征
和 (1,0) 的西边特征

类型相同?

union(特征A, 特征B)
合并到同一连通分量

保持独立

返回 UnionFind 实例

算法三:特征完成检测 (analyzeFeatures)

完成检测是计分的前提。不同特征有不同的"完成"定义:

道路 / 城堡

修道院

草原

遍历每个连通分量的根节点

特征类型?

检查该连通分量中
每个成员特征的每条边

所有边都指向有板块的位置?

特征完成!
道路: 每块1分
城堡: 每块2分+盾牌

未完成,终局再算

检查周围8个格子
是否全部有板块

修道院完成! 9分

未完成,终局按周围板数算

游戏中永不判定完成
终局按相邻已完成城堡计分

道路/城堡完成的直观理解

想象一条道路,它的两端必须是"断头"(没有板块继续接)才算完成。在代码里,这意味着:遍历该道路连通分量里的每个特征,看这个特征占用了哪些边;如果某条边指向的位置没有板块,那就是一个"开口";如果所有边指向的位置都有板块,说明这条路被完全堵死了——它完成了。

城堡同理:一座城堡的城墙如果每段都挨着其他板块(或地图边缘,但卡卡颂规则里地图边缘不算封闭),说明它被完全包围了。

算法四:计分规则

特征类型 完成时得分 未完成时得分(终局)
道路 连通分量包含几块板,得几分 同上,每块 1 分
城堡 每块板 2 分 + 盾牌数 × 2 分 每块板 1 分 + 盾牌数 × 1 分
修道院 周围 8 格全满,得 9 分 周围实际有板块的格子数(最少 1 分)
草原 游戏中不得分 每座相邻的已完成城堡得 3 分

得分归属规则(最关键):

  1. 找到该连通分量上所有米宝。
  2. 统计每个玩家放了几个米宝。
  3. 米宝最多的玩家独得全部分数。如果平局(比如人类和 AI 各放 1 个),双方各得全部分数
  4. 得分的米宝全部返还给玩家(回到剩余米宝池)。

算法五:米宝放置验证 (getValidMeeplePlacements)

这是卡卡颂最经典的规则之一:你不能把米宝放在已经有米宝的地形上

草原

道路/城堡/修道院

新板块已放置到棋盘

获取该板块旋转后的特征列表

构建包含新板块的临时并查集
(复用 buildFeatureUnionFind)

遍历该板块的每个特征

该特征连通分量上
是否已有其他米宝?

非法选项,跳过

合法选项,加入列表

特征类型?

草原选项排在列表末尾
优先级最低

按特征类型排序展示

返回 MeepleOption[]

为什么草原排最后? 因为草原是终局计分,而且一旦放下去就很难拿回来(草原永不完成),属于高风险投资。把非草原选项放前面,减少玩家误操作。

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_tileai_turn
finalScoring(board, meeples) 进入 game_over 遍历所有未完成特征计分 → 遍历所有草原计分 → 返回最终分数

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐