从零开始做一个 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>

要点:

  1. viewBox="0 0 45 45",对齐 classical / CBurnett
  2. 白棋 fill="#fff" + 黑描边;黑棋 fill="#000"
  3. 黑车用实心轮廓,避免缩放时白线分层发虚
  4. 棋子组件 props 用 PieceType / PieceColor,编译期拦住非法类型

6. 棋盘组件:渲染 + 走子

ChessBoard.vue 负责:

  1. 根据 FEN 渲染 64 格与棋子
  2. 点击选子、高亮合法目标
  3. 走子后向外抛事件(含新 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,
  })
}

逻辑说明:

  1. moves({ square, verbose:true }) 只返回合法着法(含易位、吃过路兵等)
  2. 升变默认升后,避免弹窗打断单机体验
  3. 将军时限制只能选王;被将军王格标红
  4. 走子成功后把完整局面与棋谱交给父组件落库

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

验证步骤:

  1. 打开页面,应自动 POST /api/games 开局
  2. 走 e2e4,Moves 表出现 San=e4
  3. Games.Fen / Games.Pgn 同步更新
  4. 右侧历史出现该局,可逐步回放

10. 实现要点汇总

  1. 前端全链路 TypeScript:vue-ts 模板、types/chess.ts 共享类型、vue-tsc 类型检查
  2. 规则:chess.js + Square / MovePayload 类型
  3. 棋盘:FEN 驱动渲染;点击 → 合法目标 → move → 回写 FEN
  4. 棋子:pieces.ts 用 Record<PieceType, …> 约束 SVG
  5. 存谱:每步 REST 写入 Games + Moves
  6. 回放:独立状态机,按 from/to 重放,不影响当前对局
  7. 将军约束:业务层限制仅可走王;棋盘高亮被将军王格

按上述顺序实现,即可从空项目得到一个可玩、可存、可回看的 Chess 小游戏。

posted @ 2026-10-12 16:29  hélium  阅读(18)  评论(0)    收藏  举报