从零开始做一个 Chess 小游戏:Vue3 + .NET 10 + SQL Server
从零开始做一个 Chess 小游戏:Vue3 + .NET 10 + SQL Server
本文按实现顺序,把一个「单机自对弈 + 自动存谱 + 历史回放」的国际象棋小游戏从零讲透。技术栈:
- 前端:Vue 3 + Vite + TypeScript + chess.js
- 后端:ASP.NET Core 10 + EF Core
- 数据库:SQL Server LocalDB
最终效果:左侧下棋,右侧独立查看历史棋谱;每一步自动写入 SQL Server。
1. 工程搭建
1.1 后端
dotnet new webapi -n ChessApi -o backend/ChessApi
cd backend
dotnet new sln -n Chess --format sln
dotnet sln Chess.sln add ChessApi/ChessApi.csproj
cd ChessApi
dotnet add package Microsoft.EntityFrameworkCore.SqlServer
dotnet add package Microsoft.EntityFrameworkCore.Design
appsettings.json 配置 LocalDB:
{
"ConnectionStrings": {
"Default": "Server=(localdb)\\MSSQLLocalDB;Database=ChessGame;Trusted_Connection=True;TrustServerCertificate=True"
},
"Urls": "http://localhost:5188"
}
先创建数据库:
sqlcmd -S "(localdb)\MSSQLLocalDB" -E -Q "IF DB_ID('ChessGame') IS NULL CREATE DATABASE ChessGame;"
1.2 前端(全程 TypeScript)
使用官方 vue-ts 模板,业务代码统一 .ts / <script setup lang="ts">:
npm create vite@latest frontend -- --template vue-ts
cd frontend
npm install
npm install chess.js
npm install -D @vitejs/plugin-vue vue-tsc
vite.config.ts:
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
port: 5173,
proxy: {
'/api': 'http://localhost:5188',
},
},
})
package.json 脚本:
{
"scripts": {
"dev": "vite --host 127.0.0.1 --port 5173",
"build": "vue-tsc --noEmit && vite build",
"typecheck": "vue-tsc --noEmit"
}
}
目录最终为:
chess/
backend/
Chess.sln
ChessApi/
frontend/
src/
types/chess.ts # 共享类型
assets/pieces.ts # 棋子 SVG(TS 模块)
api/client.ts # 带泛型的 REST 封装
App.vue # lang="ts"
main.ts
components/ChessBoard.vue
components/Piece.vue
2. 后端数据模型
国际象棋存谱最少两张表:对局头、着法明细。
2.1 实体
namespace ChessApi.Models;
public class Game
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Fen { get; set; } =
"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1";
public string Pgn { get; set; } = "";
public string Status { get; set; } = "playing"; // playing|checkmate|stalemate|draw
public string? Winner { get; set; } // white|black|draw
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public List<MoveRecord> Moves { get; set; } = [];
}
public class MoveRecord
{
public int Id { get; set; }
public Guid GameId { get; set; }
public int MoveNumber { get; set; }
public string San { get; set; } = "";
public string From { get; set; } = "";
public string To { get; set; } = "";
public string FenAfter { get; set; } = "";
public DateTime PlayedAt { get; set; } = DateTime.UtcNow;
}
说明:
Fen:当前局面,前端棋盘直接load(fen)Pgn:整盘棋谱文本San/From/To:单步着法,供逐步回放FenAfter:该步之后的局面,便于校验或跳播
2.2 DbContext
using ChessApi.Models;
using Microsoft.EntityFrameworkCore;
namespace ChessApi.Data;
public class ChessDbContext(DbContextOptions<ChessDbContext> options) : DbContext(options)
{
public DbSet<Game> Games => Set<Game>();
public DbSet<MoveRecord> Moves => Set<MoveRecord>();
protected override void OnModelCreating(ModelBuilder mb)
{
mb.Entity<Game>(e =>
{
e.HasKey(x => x.Id);
e.Property(x => x.Fen).HasMaxLength(100);
e.Property(x => x.Status).HasMaxLength(20);
e.Property(x => x.Pgn).HasColumnType("nvarchar(max)");
e.HasMany(x => x.Moves)
.WithOne()
.HasForeignKey(m => m.GameId)
.OnDelete(DeleteBehavior.Cascade);
});
mb.Entity<MoveRecord>(e =>
{
e.HasKey(x => x.Id);
e.Property(x => x.San).HasMaxLength(16);
e.Property(x => x.From).HasMaxLength(2);
e.Property(x => x.To).HasMaxLength(2);
e.Property(x => x.FenAfter).HasMaxLength(100);
});
}
}
2.3 Program.cs
using ChessApi.Data;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<ChessDbContext>(o =>
o.UseSqlServer(builder.Configuration.GetConnectionString("Default")));
builder.Services.AddControllers();
builder.Services.AddCors(o => o.AddDefaultPolicy(p =>
p.WithOrigins("http://localhost:5173", "http://127.0.0.1:5173")
.AllowAnyHeader().AllowAnyMethod()));
var app = builder.Build();
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<ChessDbContext>();
db.Database.EnsureCreated(); // 开发期直接建表
}
app.UseCors();
app.MapControllers();
app.Run();
职责划分:
- 前端用 chess.js 负责合法性校验
- 后端只负责持久化与查询
3. 后端 API
public record MoveDto(
string From, string To, string San, string Fen, string Pgn,
string? Status, string? Winner);
[ApiController]
[Route("api/[controller]")]
public class GamesController(ChessDbContext db) : ControllerBase
{
// 开新局
[HttpPost]
public async Task<IActionResult> Create()
{
var game = new Game();
db.Games.Add(game);
await db.SaveChangesAsync();
return Ok(new { game.Id, game.Fen, game.Pgn, game.Status });
}
// 追加一步(自动存谱)
[HttpPost("{id:guid}/moves")]
public async Task<IActionResult> AddMove(Guid id, [FromBody] MoveDto move)
{
var game = await db.Games.Include(g => g.Moves)
.FirstOrDefaultAsync(g => g.Id == id);
if (game is null) return NotFound();
if (game.Status is not "playing") return BadRequest("对局已结束");
game.Fen = move.Fen;
game.Pgn = move.Pgn;
game.UpdatedAt = DateTime.UtcNow;
if (!string.IsNullOrEmpty(move.Status))
{
game.Status = move.Status;
game.Winner = move.Winner;
}
db.Moves.Add(new MoveRecord
{
GameId = id,
MoveNumber = game.Moves.Count + 1,
San = move.San,
From = move.From,
To = move.To,
FenAfter = move.Fen,
});
await db.SaveChangesAsync();
return Ok(new { game.Status, game.Winner, moveCount = game.Moves.Count + 1 });
}
// 历史列表
[HttpGet]
public async Task<IActionResult> List([FromQuery] int take = 50)
{
var list = await db.Games
.OrderByDescending(g => g.UpdatedAt)
.Take(Math.Clamp(take, 1, 100))
.Select(g => new
{
g.Id, g.Status, g.Winner, g.CreatedAt, g.UpdatedAt,
moveCount = g.Moves.Count,
})
.ToListAsync();
return Ok(list);
}
// 详情(回放用)
[HttpGet("{id:guid}")]
public async Task<IActionResult> Get(Guid id)
{
var game = await db.Games.AsNoTracking()
.FirstOrDefaultAsync(g => g.Id == id);
if (game is null) return NotFound();
var moves = await db.Moves.AsNoTracking()
.Where(m => m.GameId == id)
.OrderBy(m => m.MoveNumber)
.Select(m => new { m.MoveNumber, m.San, m.From, m.To, m.FenAfter })
.ToListAsync();
return Ok(new
{
game.Id, game.Fen, game.Pgn, game.Status, game.Winner,
game.CreatedAt, game.UpdatedAt, moves,
});
}
}
接口一览:
| 方法 | 路径 | 作用 |
|---|---|---|
| POST | /api/games |
创建对局 |
| POST | /api/games/{id}/moves |
保存一步 |
| GET | /api/games |
历史列表 |
| GET | /api/games/{id} |
对局详情与着法 |
4. 前端类型与 API 封装
先在 src/types/chess.ts 集中声明类型:
import type { Square } from 'chess.js'
export type PieceType = 'k' | 'q' | 'r' | 'b' | 'n' | 'p'
export type PieceColor = 'w' | 'b'
export type GameStatus = 'playing' | 'checkmate' | 'stalemate' | 'draw'
export type Winner = 'white' | 'black' | 'draw' | null
export type MovePayload = {
from: string
to: string
san: string
fen: string
pgn: string
status?: GameStatus
winner?: Exclude<Winner, null>
}
export type GameCreated = {
id: string
fen: string
pgn: string
status: GameStatus
}
export type GameSummary = {
id: string
status: GameStatus | string
winner: Winner
createdAt: string
updatedAt: string
moveCount: number
}
export type MoveRow = {
moveNumber: number
san: string
from: Square | string
to: Square | string
fenAfter: string
}
export type GameDetail = {
id: string
fen: string
pgn: string
status: GameStatus | string
winner: Winner
moves: MoveRow[]
}
export type SaveMoveBody = {
from: string
to: string
san: string
fen: string
pgn: string
status?: string
winner?: string
}
src/api/client.ts 用泛型解析响应,避免 any:
import type { GameCreated, GameDetail, GameSummary, SaveMoveBody } from '../types/chess'
const API: string = import.meta.env.VITE_API_URL ?? 'http://localhost:5188'
async function parseJson<T>(r: Response, err: string): Promise<T> {
if (!r.ok) throw new Error(err)
return r.json() as Promise<T>
}
export async function createGame(): Promise<GameCreated> {
const r = await fetch(`${API}/api/games`, { method: 'POST' })
return parseJson<GameCreated>(r, '创建对局失败')
}
export async function saveMove(
id: string,
body: SaveMoveBody,
): Promise<{ status: string; winner: string | null; moveCount: number }> {
const r = await fetch(`${API}/api/games/${id}/moves`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
return parseJson(r, '保存着法失败')
}
export async function listGames(): Promise<GameSummary[]> {
const r = await fetch(`${API}/api/games`)
return parseJson<GameSummary[]>(r, '加载历史失败')
}
export async function getGame(id: string): Promise<GameDetail> {
const r = await fetch(`${API}/api/games/${id}`)
return parseJson<GameDetail>(r, '对局不存在')
}
5. 棋子绘制(classical SVG + TS 模块)
棋子 SVG 放在 src/assets/pieces.ts,用联合类型约束键名:
import type { PieceColor, PieceType } from '../types/chess'
type SvgPair = Record<PieceColor, string>
export const PIECE_SVG: Record<PieceType, SvgPair> = {
// k/q/r/b/n/p 各含 w、b 两套 path
r: {
w: `<!-- 白车 SVG -->`,
b: `<!-- 黑车:实心轮廓 -->`,
},
// ...
}
export function pieceSvg(type: PieceType, color: PieceColor): string {
return PIECE_SVG[type][color]
}
Piece.vue:
<script setup lang="ts">
import { computed } from 'vue'
import { pieceSvg } from '../assets/pieces'
import type { PieceColor, PieceType } from '../types/chess'
const props = defineProps<{ type: PieceType; color: PieceColor }>()
const svg = computed(() => pieceSvg(props.type, props.color))
</script>
<template>
<svg class="piece-svg" viewBox="0 0 45 45" xmlns="http://www.w3.org/2000/svg" v-html="svg" />
</template>
要点:
viewBox="0 0 45 45",对齐 classical / CBurnett- 白棋
fill="#fff"+ 黑描边;黑棋fill="#000" - 黑车用实心轮廓,避免缩放时白线分层发虚
- 棋子组件 props 用
PieceType/PieceColor,编译期拦住非法类型
6. 棋盘组件:渲染 + 走子
ChessBoard.vue 负责:
- 根据 FEN 渲染 64 格与棋子
- 点击选子、高亮合法目标
- 走子后向外抛事件(含新 FEN / PGN / 终局状态)
6.1 棋盘样式
参考 chess.com 绿盘:
.sq.light { background: #eeeed2; }
.sq.dark { background: #769656; }
.sq.selected { background: #f6f669 !important; }
.sq.check { background: #e15b5b !important; }
格子生成:
const files = ['a','b','c','d','e','f','g','h']
const ranks = [8,7,6,5,4,3,2,1]
模板双层循环 rank × file,坐标格 id 为 file + rank,如 e2。
6.2 用 chess.js 管规则(TypeScript)
import { Chess, type Square } from 'chess.js'
import type { GameStatus, MovePayload, Winner } from '../types/chess'
const selected = ref<Square | null>(null)
const targets = ref<Set<Square>>(new Set())
const game = new Chess()
const emit = defineEmits<{ move: [payload: MovePayload] }>()
watch(() => props.fen, (fen: string) => {
game.load(fen)
selected.value = null
targets.value = new Set()
}, { immediate: true })
选子与走子:
function onSquare(sq: Square): void {
if (!props.interactive || game.isGameOver()) return
if (selected.value) {
if (targets.value.has(sq)) {
doMove(selected.value, sq)
return
}
if (sq === selected.value) {
clearSel()
return
}
}
const p = game.get(sq)
if (!p || p.color !== game.turn()) {
clearSel()
return
}
// 将军时只允许走王
if (game.isCheck() && p.type !== 'k') {
clearSel()
return
}
const moves = game.moves({ square: sq, verbose: true })
if (!moves.length) {
clearSel()
return
}
selected.value = sq
targets.value = new Set(moves.map(m => m.to))
}
function doMove(from: Square, to: Square): void {
const promo = legalMoves(from).some(m => m.to === to && m.promotion)
? 'q' as const
: undefined
const move = game.move({ from, to, promotion: promo })
if (!move) return
let status: GameStatus | undefined
let winner: Exclude<Winner, null> | undefined
if (game.isCheckmate()) {
status = 'checkmate'
winner = game.turn() === 'w' ? 'black' : 'white'
} else if (game.isDraw()) {
status = game.isStalemate() ? 'stalemate' : 'draw'
winner = 'draw'
}
emit('move', {
from, to,
san: move.san,
fen: game.fen(),
pgn: game.pgn(),
status, winner,
})
}
逻辑说明:
moves({ square, verbose:true })只返回合法着法(含易位、吃过路兵等)- 升变默认升后,避免弹窗打断单机体验
- 将军时限制只能选王;被将军王格标红
- 走子成功后把完整局面与棋谱交给父组件落库
7. 页面编排:对弈区 + 历史区
App.vue 维护两套互不影响的状态。
7.1 对弈状态
import type { GameStatus, MovePayload, Winner } from './types/chess'
const gameId = ref<string | null>(null)
const fen = ref<string>(START_FEN)
const sans = ref<string[]>([])
const status = ref<GameStatus>('playing')
const winner = ref<Winner>(null)
开局:
async function newGame(): Promise<void> {
const g = await createGame()
gameId.value = g.id
fen.value = g.fen
sans.value = []
status.value = 'playing'
winner.value = null
}
走子后自动存库:
async function onMove(payload: MovePayload): Promise<void> {
fen.value = payload.fen
sans.value = [...sans.value, payload.san]
if (payload.status) status.value = payload.status
if (payload.winner) winner.value = payload.winner
await saveMove(gameId.value!, {
from: payload.from,
to: payload.to,
san: payload.san,
fen: payload.fen,
pgn: payload.pgn,
status: payload.status,
winner: payload.winner,
})
await refreshHistory()
}
7.2 历史回放状态(独立)
import type { GameDetail, GameSummary } from './types/chess'
import type { Square } from 'chess.js'
const history = ref<GameSummary[]>([])
const viewing = ref<GameDetail | null>(null)
const viewFen = ref<string>(START_FEN)
const viewCursor = ref<number>(0)
打开一盘历史:
async function openHistory(id: string): Promise<void> {
const g = await getGame(id)
viewing.value = g
viewCursor.value = g.moves.length
applyView()
}
function applyView(): void {
if (!viewing.value) return
const c = new Chess()
const hist = viewing.value.moves
for (let i = 0; i < viewCursor.value && i < hist.length; i++) {
c.move({
from: hist[i].from as Square,
to: hist[i].to as Square,
promotion: 'q',
})
}
viewFen.value = c.fen()
}
模板结构:
<section class="play">
<ChessBoard :fen="fen" :interactive="status === 'playing'" @move="onMove" />
<!-- 当前着法列表 -->
</section>
<aside class="history">
<!-- 历史列表:只显示 moveCount > 0 -->
<ChessBoard v-if="viewing" :fen="viewFen" :interactive="false" />
<!-- 回放按钮 + PGN 文本 -->
</aside>
关键点:回放棋盘用 viewFen,对弈棋盘用 fen,两套状态隔离。
8. 数据流总览
用户点击格子
↓
ChessBoard + chess.js 校验并执行
↓
emit({ from,to,san,fen,pgn,status,winner })
↓
App 更新左侧局面与着法列表
↓
POST /api/games/{id}/moves
↓
SQL Server:更新 Games,插入 Moves
↓
GET /api/games 刷新右侧历史
↓
点击历史 → GET /api/games/{id} → 独立小棋盘逐步回放
9. 启动与验证
# 后端
cd backend
dotnet run --project ChessApi
# 前端
cd frontend
npm run dev
验证步骤:
- 打开页面,应自动
POST /api/games开局 - 走
e2e4,Moves表出现San=e4 Games.Fen / Games.Pgn同步更新- 右侧历史出现该局,可逐步回放
10. 实现要点汇总
- 前端全链路 TypeScript:
vue-ts模板、types/chess.ts共享类型、vue-tsc类型检查 - 规则:chess.js +
Square/MovePayload类型 - 棋盘:FEN 驱动渲染;点击 → 合法目标 → move → 回写 FEN
- 棋子:
pieces.ts用Record<PieceType, …>约束 SVG - 存谱:每步 REST 写入
Games + Moves - 回放:独立状态机,按
from/to重放,不影响当前对局 - 将军约束:业务层限制仅可走王;棋盘高亮被将军王格
按上述顺序实现,即可从空项目得到一个可玩、可存、可回看的 Chess 小游戏。

浙公网安备 33010602011771号