中国象棋网页版

1版(棋子拖尾过于单调):

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>中国象棋</title>
<style>
  * { box-sizing: border-box; margin: 0; padding: 0; }
  body {
    font-family: "KaiTi", "STKaiti", "楷体", serif;
    background: #e8e4dc;
    background-image:
      radial-gradient(circle at 20% 30%, rgba(180,160,130,0.15) 0%, transparent 50%),
      radial-gradient(circle at 80% 70%, rgba(160,140,110,0.1) 0%, transparent 50%),
      radial-gradient(circle at 50% 50%, rgba(200,190,170,0.2) 0%, transparent 70%);
    min-height: 100vh;
    display: flex;
    flex-direction: column;
    align-items: center;
    padding: 30px 20px;
    color: #2c2c2c;
  }
  h1 {
    font-size: 38px;
    margin-bottom: 15px;
    letter-spacing: 12px;
    color: #1a1a1a;
    font-weight: normal;
    text-shadow: 1px 1px 0 rgba(0,0,0,0.08);
    position: relative;
  }
  h1::after {
    content: '';
    display: block;
    width: 60%;
    height: 2px;
    margin: 8px auto 0;
    background: linear-gradient(90deg, transparent, #555, transparent);
  }
  .info {
    display: flex;
    gap: 30px;
    margin-bottom: 20px;
    align-items: center;
    font-size: 18px;
  }
  .turn-indicator {
    padding: 10px 24px;
    border-radius: 4px;
    font-weight: bold;
    transition: all 0.3s;
    border: 1px solid #333;
    background: #f5f1ea;
    color: #2c2c2c;
    letter-spacing: 2px;
  }
  .turn-red { background: #c0392b; color: #f5f1ea; border-color: #8b2a1e; }
  .turn-black { background: #2c2c2c; color: #f5f1ea; border-color: #000; }
  button {
    background: #f5f1ea;
    color: #2c2c2c;
    border: 1px solid #333;
    padding: 10px 24px;
    border-radius: 4px;
    cursor: pointer;
    font-size: 16px;
    font-family: inherit;
    font-weight: bold;
    letter-spacing: 2px;
    transition: all 0.3s;
  }
  button:hover {
    background: #2c2c2c;
    color: #f5f1ea;
    transform: translateY(-2px);
  }
  .canvas-wrap {
    position: relative;
  }
  canvas {
    background: #f5f1ea;
    border: 1px solid #333;
    border-radius: 2px;
    box-shadow:
      0 0 0 8px #f5f1ea,
      0 0 0 9px #333,
      0 15px 40px rgba(0,0,0,0.3);
    cursor: pointer;
  }
  .status {
    margin-top: 18px;
    font-size: 20px;
    min-height: 28px;
    color: #c0392b;
    font-weight: bold;
    letter-spacing: 3px;
  }
  .captured {
    margin-top: 12px;
    display: flex;
    gap: 40px;
    font-size: 16px;
    color: #555;
  }
  .captured div { min-height: 30px; }
  .captured span {
    display: inline-block;
    margin: 0 3px;
    font-size: 22px;
    font-family: "KaiTi", "STKaiti", serif;
  }
  .fx-text {
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    pointer-events: none;
    font-family: "KaiTi", "STKaiti", "楷体", serif;
    font-weight: bold;
    opacity: 0;
    z-index: 10;
  }
  .fx-check {
    font-size: 72px;
    color: #c0392b;
    letter-spacing: 20px;
    text-shadow: 0 0 20px rgba(192, 57, 43, 0.6);
  }
  .fx-mate {
    font-size: 96px;
    color: #8b0000;
    letter-spacing: 30px;
    text-shadow: 0 0 30px rgba(139, 0, 0, 0.8), 0 0 60px rgba(192, 57, 43, 0.5);
  }
  @keyframes fxFlash {
    0%   { opacity: 0; transform: translate(-50%, -50%) scale(0.3); }
    30%  { opacity: 1; transform: translate(-50%, -50%) scale(1.2); }
    70%  { opacity: 1; transform: translate(-50%, -50%) scale(1); }
    100% { opacity: 0; transform: translate(-50%, -50%) scale(1.5); }
  }
  @keyframes fxShake {
    0%, 100% { transform: translate(0, 0); }
    10%  { transform: translate(-6px, -4px); }
    20%  { transform: translate(5px, 3px); }
    30%  { transform: translate(-4px, 5px); }
    40%  { transform: translate(6px, -3px); }
    50%  { transform: translate(-5px, 4px); }
    60%  { transform: translate(3px, -5px); }
    70%  { transform: translate(-3px, 3px); }
    80%  { transform: translate(4px, -2px); }
    90%  { transform: translate(-2px, 2px); }
  }
  .shake { animation: fxShake 0.5s ease-in-out; }
</style>
</head>
<body>
  <h1>中 國 象 棋</h1>
  <div class="info">
    <div class="turn-indicator" id="turnIndicator">红方走棋</div>
    <button id="restartBtn">重 新 开 始</button>
    <button id="undoBtn">悔 棋</button>
  </div>
  <div class="canvas-wrap" id="canvasWrap">
    <canvas id="board" width="540" height="600"></canvas>
    <div class="fx-text fx-check" id="fxCheck">将 軍</div>
    <div class="fx-text fx-mate" id="fxMate">绝 殺</div>
  </div>
  <div class="status" id="status"></div>
  <div class="captured">
    <div>红方被吃 · <span id="capturedByRed"></span></div>
    <div>黑方被吃 · <span id="capturedByBlack"></span></div>
  </div>

<script>
const CELL = 60;
const MARGIN_X = 30;
const MARGIN_Y = 30;
const COLS = 9;
const ROWS = 10;

const canvas = document.getElementById('board');
const ctx = canvas.getContext('2d');
const canvasWrap = document.getElementById('canvasWrap');

const RED = 'red';
const BLACK = 'black';

const PIECES = {
  red: {
    king: '帥', advisor: '仕', elephant: '相', horse: '傌',
    chariot: '俥', cannon: '炮', pawn: '兵'
  },
  black: {
    king: '將', advisor: '士', elephant: '象', horse: '馬',
    chariot: '車', cannon: '砲', pawn: '卒'
  }
};

let board = [];
let currentPlayer = RED;
let selected = null;
let legalMoves = [];
let gameOver = false;
let history = [];
let capturedByRed = [];
let capturedByBlack = [];

let effects = [];
let animating = false;
let checkAnimStart = 0;
let checkKingPos = null;
let checkmateAnimStart = 0;

function P(type, color) {
  return { type, color };
}

function initBoard() {
  board = [
    [P('chariot',BLACK),P('horse',BLACK),P('elephant',BLACK),P('advisor',BLACK),P('king',BLACK),P('advisor',BLACK),P('elephant',BLACK),P('horse',BLACK),P('chariot',BLACK)],
    [null,null,null,null,null,null,null,null,null],
    [null,P('cannon',BLACK),null,null,null,null,null,P('cannon',BLACK),null],
    [P('pawn',BLACK),null,P('pawn',BLACK),null,P('pawn',BLACK),null,P('pawn',BLACK),null,P('pawn',BLACK)],
    [null,null,null,null,null,null,null,null,null],
    [null,null,null,null,null,null,null,null,null],
    [P('pawn',RED),null,P('pawn',RED),null,P('pawn',RED),null,P('pawn',RED),null,P('pawn',RED)],
    [null,P('cannon',RED),null,null,null,null,null,P('cannon',RED),null],
    [null,null,null,null,null,null,null,null,null],
    [P('chariot',RED),P('horse',RED),P('elephant',RED),P('advisor',RED),P('king',RED),P('advisor',RED),P('elephant',RED),P('horse',RED),P('chariot',RED)]
  ];
  currentPlayer = RED;
  selected = null;
  legalMoves = [];
  gameOver = false;
  history = [];
  capturedByRed = [];
  capturedByBlack = [];
  effects = [];
  checkAnimStart = 0;
  checkKingPos = null;
  checkmateAnimStart = 0;
  hideFx('fxCheck');
  hideFx('fxMate');
  updateUI();
  draw();
}

function isInBoard(r, c) {
  return r >= 0 && r < ROWS && c >= 0 && c < COLS;
}

function inPalace(r, c, color) {
  if (c < 3 || c > 5) return false;
  if (color === RED) return r >= 7 && r <= 9;
  return r >= 0 && r <= 2;
}

function onOwnSide(r, color) {
  if (color === RED) return r >= 5;
  return r <= 4;
}

function getLegalMoves(r, c) {
  const piece = board[r][c];
  if (!piece) return [];
  const color = piece.color;
  const type = piece.type;
  const moves = [];
  const addMove = (nr, nc) => {
    if (!isInBoard(nr, nc)) return;
    const target = board[nr][nc];
    if (target && target.color === color) return;
    moves.push({ r: nr, c: nc, capture: !!target });
  };

  switch (type) {
    case 'king': {
      const dirs = [[-1,0],[1,0],[0,-1],[0,1]];
      for (const [dr, dc] of dirs) {
        const nr = r + dr, nc = c + dc;
        if (inPalace(nr, nc, color)) addMove(nr, nc);
      }
      break;
    }
    case 'advisor': {
      const dirs = [[-1,-1],[-1,1],[1,-1],[1,1]];
      for (const [dr, dc] of dirs) {
        const nr = r + dr, nc = c + dc;
        if (inPalace(nr, nc, color)) addMove(nr, nc);
      }
      break;
    }
    case 'elephant': {
      const dirs = [[-2,-2],[-2,2],[2,-2],[2,2]];
      for (const [dr, dc] of dirs) {
        const nr = r + dr, nc = c + dc;
        const mr = r + dr/2, mc = c + dc/2;
        if (!isInBoard(nr, nc)) continue;
        if (!onOwnSide(nr, color)) continue;
        if (board[mr][mc]) continue;
        addMove(nr, nc);
      }
      break;
    }
    case 'horse': {
      const hm = [
        {dr:-2,dc:-1,br:-1,bc:0}, {dr:-2,dc:1,br:-1,bc:0},
        {dr:2,dc:-1,br:1,bc:0}, {dr:2,dc:1,br:1,bc:0},
        {dr:-1,dc:-2,br:0,bc:-1}, {dr:1,dc:-2,br:0,bc:-1},
        {dr:-1,dc:2,br:0,bc:1}, {dr:1,dc:2,br:0,bc:1}
      ];
      for (const m of hm) {
        const nr = r + m.dr, nc = c + m.dc;
        const lr = r + m.br, lc = c + m.bc;
        if (!isInBoard(nr, nc)) continue;
        if (board[lr][lc]) continue;
        addMove(nr, nc);
      }
      break;
    }
    case 'chariot': {
      const dirs = [[-1,0],[1,0],[0,-1],[0,1]];
      for (const [dr, dc] of dirs) {
        let nr = r + dr, nc = c + dc;
        while (isInBoard(nr, nc)) {
          const target = board[nr][nc];
          if (!target) {
            moves.push({ r: nr, c: nc, capture: false });
          } else {
            if (target.color !== color) {
              moves.push({ r: nr, c: nc, capture: true });
            }
            break;
          }
          nr += dr; nc += dc;
        }
      }
      break;
    }
    case 'cannon': {
      const dirs = [[-1,0],[1,0],[0,-1],[0,1]];
      for (const [dr, dc] of dirs) {
        let nr = r + dr, nc = c + dc;
        let jumped = false;
        while (isInBoard(nr, nc)) {
          const target = board[nr][nc];
          if (!jumped) {
            if (!target) {
              moves.push({ r: nr, c: nc, capture: false });
            } else {
              jumped = true;
            }
          } else {
            if (target) {
              if (target.color !== color) {
                moves.push({ r: nr, c: nc, capture: true });
              }
              break;
            }
          }
          nr += dr; nc += dc;
        }
      }
      break;
    }
    case 'pawn': {
      const forward = color === RED ? -1 : 1;
      const nr = r + forward;
      if (isInBoard(nr, c)) addMove(nr, c);
      if (!onOwnSide(r, color)) {
        if (isInBoard(r, c - 1)) addMove(r, c - 1);
        if (isInBoard(r, c + 1)) addMove(r, c + 1);
      }
      break;
    }
  }
  return moves;
}

function findKing(color) {
  for (let r = 0; r < ROWS; r++) {
    for (let c = 0; c < COLS; c++) {
      if (board[r][c] && board[r][c].type === 'king' && board[r][c].color === color) return { r, c };
    }
  }
  return null;
}

function kingsFacing() {
  const rk = findKing(RED);
  const bk = findKing(BLACK);
  if (!rk || !bk || rk.c !== bk.c) return false;
  const col = rk.c;
  const minR = Math.min(rk.r, bk.r);
  const maxR = Math.max(rk.r, bk.r);
  for (let r = minR + 1; r < maxR; r++) {
    if (board[r][col]) return false;
  }
  return true;
}

function getLegalMovesFiltered(r, c) {
  const moves = getLegalMoves(r, c);
  return moves.filter(m => {
    const captured = board[m.r][m.c];
    const moving = board[r][c];
    board[m.r][m.c] = moving;
    board[r][c] = null;
    const inCheck = isKingInCheck(moving.color) || kingsFacing();
    board[r][c] = moving;
    board[m.r][m.c] = captured;
    return !inCheck;
  });
}

function isKingInCheck(color) {
  const king = findKing(color);
  if (!king) return true;
  const oppColor = color === RED ? BLACK : RED;
  for (let r = 0; r < ROWS; r++) {
    for (let c = 0; c < COLS; c++) {
      if (board[r][c] && board[r][c].color === oppColor) {
        const moves = getLegalMoves(r, c);
        if (moves.some(m => m.r === king.r && m.c === king.c)) return true;
      }
    }
  }
  return false;
}

function hasAnyLegalMove(color) {
  for (let r = 0; r < ROWS; r++) {
    for (let c = 0; c < COLS; c++) {
      if (board[r][c] && board[r][c].color === color) {
        if (getLegalMovesFiltered(r, c).length > 0) return true;
      }
    }
  }
  return false;
}

function drawBoard() {
  const grad = ctx.createLinearGradient(0, 0, canvas.width, canvas.height);
  grad.addColorStop(0, '#f8f4ec');
  grad.addColorStop(0.5, '#f0ebe0');
  grad.addColorStop(1, '#ede6d8');
  ctx.fillStyle = grad;
  ctx.fillRect(0, 0, canvas.width, canvas.height);

  ctx.strokeStyle = '#1a1a1a';
  ctx.lineWidth = 1.5;

  for (let r = 0; r < ROWS; r++) {
    ctx.beginPath();
    ctx.moveTo(MARGIN_X, MARGIN_Y + r * CELL);
    ctx.lineTo(MARGIN_X + (COLS - 1) * CELL, MARGIN_Y + r * CELL);
    ctx.stroke();
  }

  for (let c = 0; c < COLS; c++) {
    if (c === 0 || c === COLS - 1) {
      ctx.beginPath();
      ctx.moveTo(MARGIN_X + c * CELL, MARGIN_Y);
      ctx.lineTo(MARGIN_X + c * CELL, MARGIN_Y + (ROWS - 1) * CELL);
      ctx.stroke();
    } else {
      ctx.beginPath();
      ctx.moveTo(MARGIN_X + c * CELL, MARGIN_Y);
      ctx.lineTo(MARGIN_X + c * CELL, MARGIN_Y + 4 * CELL);
      ctx.stroke();
      ctx.beginPath();
      ctx.moveTo(MARGIN_X + c * CELL, MARGIN_Y + 5 * CELL);
      ctx.lineTo(MARGIN_X + c * CELL, MARGIN_Y + 9 * CELL);
      ctx.stroke();
    }
  }

  ctx.beginPath();
  ctx.moveTo(MARGIN_X, MARGIN_Y + 4 * CELL);
  ctx.lineTo(MARGIN_X + (COLS - 1) * CELL, MARGIN_Y + 4 * CELL);
  ctx.moveTo(MARGIN_X, MARGIN_Y + 5 * CELL);
  ctx.lineTo(MARGIN_X + (COLS - 1) * CELL, MARGIN_Y + 5 * CELL);
  ctx.stroke();

  ctx.beginPath();
  ctx.moveTo(MARGIN_X + 3 * CELL, MARGIN_Y);
  ctx.lineTo(MARGIN_X + 5 * CELL, MARGIN_Y + 2 * CELL);
  ctx.moveTo(MARGIN_X + 5 * CELL, MARGIN_Y);
  ctx.lineTo(MARGIN_X + 3 * CELL, MARGIN_Y + 2 * CELL);
  ctx.moveTo(MARGIN_X + 3 * CELL, MARGIN_Y + 7 * CELL);
  ctx.lineTo(MARGIN_X + 5 * CELL, MARGIN_Y + 9 * CELL);
  ctx.moveTo(MARGIN_X + 5 * CELL, MARGIN_Y + 7 * CELL);
  ctx.lineTo(MARGIN_X + 3 * CELL, MARGIN_Y + 9 * CELL);
  ctx.stroke();

  ctx.fillStyle = '#1a1a1a';
  ctx.font = 'bold 26px "KaiTi", "STKaiti", "楷体", serif';
  ctx.textAlign = 'center';
  ctx.textBaseline = 'middle';
  ctx.fillText('楚 河', MARGIN_X + 1.5 * CELL, MARGIN_Y + 4.5 * CELL);
  ctx.fillText('漢 界', MARGIN_X + 6.5 * CELL, MARGIN_Y + 4.5 * CELL);

  drawStarPoints();
}

function drawStarPoints() {
  const points = [
    [2,1],[2,7],[7,1],[7,7],
    [3,0],[3,2],[3,4],[3,6],[3,8],
    [6,0],[6,2],[6,4],[6,6],[6,8]
  ];
  for (const [r, c] of points) {
    drawStar(r, c);
  }
}

function drawStar(r, c) {
  const x = MARGIN_X + c * CELL;
  const y = MARGIN_Y + r * CELL;
  const s = 6;
  const g = 5;
  ctx.strokeStyle = '#1a1a1a';
  ctx.lineWidth = 1.2;
  const dirs = [];
  if (c > 0) dirs.push([-1,-1],[-1,1]);
  if (c < COLS - 1) dirs.push([1,-1],[1,1]);
  for (const [dx, dy] of dirs) {
    ctx.beginPath();
    ctx.moveTo(x + dx * g, y + dy * g);
    ctx.lineTo(x + dx * (g + s), y + dy * g);
    ctx.stroke();
    ctx.beginPath();
    ctx.moveTo(x + dx * g, y + dy * g);
    ctx.lineTo(x + dx * g, y + dy * (g + s));
    ctx.stroke();
  }
}

function drawPiece(r, c, type, color, highlight) {
  const x = MARGIN_X + c * CELL;
  const y = MARGIN_Y + r * CELL;
  const radius = 26;

  if (highlight) {
    ctx.beginPath();
    ctx.arc(x, y, radius + 5, 0, Math.PI * 2);
    ctx.fillStyle = 'rgba(192, 57, 43, 0.25)';
    ctx.fill();
  }

  const inkGrad = ctx.createRadialGradient(x - 3, y - 3, 2, x, y, radius);
  if (color === RED) {
    inkGrad.addColorStop(0, '#e8e0d4');
    inkGrad.addColorStop(0.6, '#d4c8b4');
    inkGrad.addColorStop(1, '#b8a88c');
  } else {
    inkGrad.addColorStop(0, '#e8e0d4');
    inkGrad.addColorStop(0.6, '#d0c4b0');
    inkGrad.addColorStop(1, '#b0a288');
  }
  ctx.beginPath();
  ctx.arc(x, y, radius, 0, Math.PI * 2);
  ctx.fillStyle = inkGrad;
  ctx.fill();

  ctx.beginPath();
  ctx.arc(x, y, radius, 0, Math.PI * 2);
  ctx.strokeStyle = '#1a1a1a';
  ctx.lineWidth = 2;
  ctx.stroke();

  ctx.beginPath();
  ctx.arc(x, y, radius - 4, 0, Math.PI * 2);
  ctx.strokeStyle = color === RED ? '#c0392b' : '#1a1a1a';
  ctx.lineWidth = 1;
  ctx.stroke();

  ctx.fillStyle = color === RED ? '#c0392b' : '#1a1a1a';
  ctx.font = 'bold 28px "KaiTi", "STKaiti", "楷体", serif';
  ctx.textAlign = 'center';
  ctx.textBaseline = 'middle';
  ctx.fillText(PIECES[color][type], x, y + 1);
}

function draw() {
  drawBoard();

  for (const move of legalMoves) {
    const x = MARGIN_X + move.c * CELL;
    const y = MARGIN_Y + move.r * CELL;
    if (move.capture) {
      ctx.beginPath();
      ctx.arc(x, y, 30, 0, Math.PI * 2);
      ctx.strokeStyle = 'rgba(192, 57, 43, 0.7)';
      ctx.lineWidth = 2.5;
      ctx.setLineDash([4, 3]);
      ctx.stroke();
      ctx.setLineDash([]);
    } else {
      ctx.beginPath();
      ctx.arc(x, y, 6, 0, Math.PI * 2);
      ctx.fillStyle = 'rgba(26, 26, 26, 0.5)';
      ctx.fill();
    }
  }

  for (let r = 0; r < ROWS; r++) {
    for (let c = 0; c < COLS; c++) {
      if (board[r][c]) {
        const p = board[r][c];
        drawPiece(r, c, p.type, p.color, selected && selected.r === r && selected.c === c);
      }
    }
  }

  const redKing = findKing(RED);
  const blackKing = findKing(BLACK);
  const now = performance.now();

  if (redKing && isKingInCheck(RED)) {
    if (!checkKingPos || checkKingPos.color !== RED || checkKingPos.r !== redKing.r || checkKingPos.c !== redKing.c) {
      checkAnimStart = now;
      checkKingPos = { r: redKing.r, c: redKing.c, color: RED };
    }
    const elapsed = now - checkAnimStart;
    const pulse = 0.5 + 0.5 * Math.sin(elapsed / 150);
    const x = MARGIN_X + redKing.c * CELL;
    const y = MARGIN_Y + redKing.r * CELL;
    const r = 32 + pulse * 8;
    const grad = ctx.createRadialGradient(x, y, 20, x, y, r);
    grad.addColorStop(0, `rgba(192, 57, 43, ${0.15 + pulse * 0.25})`);
    grad.addColorStop(0.7, `rgba(192, 57, 43, ${0.3 + pulse * 0.3})`);
    grad.addColorStop(1, 'rgba(192, 57, 43, 0)');
    ctx.beginPath();
    ctx.arc(x, y, r, 0, Math.PI * 2);
    ctx.fillStyle = grad;
    ctx.fill();
    ctx.beginPath();
    ctx.arc(x, y, 34 + pulse * 4, 0, Math.PI * 2);
    ctx.strokeStyle = `rgba(192, 57, 43, ${0.6 + pulse * 0.4})`;
    ctx.lineWidth = 2.5;
    ctx.stroke();
  } else {
    if (checkKingPos && checkKingPos.color === RED) { checkAnimStart = 0; checkKingPos = null; }
  }

  if (blackKing && isKingInCheck(BLACK)) {
    if (!checkKingPos || checkKingPos.color !== BLACK || checkKingPos.r !== blackKing.r || checkKingPos.c !== blackKing.c) {
      checkAnimStart = now;
      checkKingPos = { r: blackKing.r, c: blackKing.c, color: BLACK };
    }
    const elapsed = now - checkAnimStart;
    const pulse = 0.5 + 0.5 * Math.sin(elapsed / 150);
    const x = MARGIN_X + blackKing.c * CELL;
    const y = MARGIN_Y + blackKing.r * CELL;
    const r = 32 + pulse * 8;
    const grad = ctx.createRadialGradient(x, y, 20, x, y, r);
    grad.addColorStop(0, `rgba(192, 57, 43, ${0.15 + pulse * 0.25})`);
    grad.addColorStop(0.7, `rgba(192, 57, 43, ${0.3 + pulse * 0.3})`);
    grad.addColorStop(1, 'rgba(192, 57, 43, 0)');
    ctx.beginPath();
    ctx.arc(x, y, r, 0, Math.PI * 2);
    ctx.fillStyle = grad;
    ctx.fill();
    ctx.beginPath();
    ctx.arc(x, y, 34 + pulse * 4, 0, Math.PI * 2);
    ctx.strokeStyle = `rgba(192, 57, 43, ${0.6 + pulse * 0.4})`;
    ctx.lineWidth = 2.5;
    ctx.stroke();
  } else {
    if (checkKingPos && checkKingPos.color === BLACK) { checkAnimStart = 0; checkKingPos = null; }
  }
}

function drawEffects() {
  const now = performance.now();
  const remaining = [];

  for (const e of effects) {
    const elapsed = now - e.startTime;
    const progress = elapsed / e.duration;
    if (progress >= 1) continue;

    if (e.type === 'capture') {
      drawCaptureEffect(e, progress);
      remaining.push(e);
    } else if (e.type === 'checkmate') {
      drawCheckmateEffect(e, progress);
      remaining.push(e);
    } else if (e.type === 'inkTrail') {
      drawInkTrailEffect(e, progress);
      remaining.push(e);
    }
  }

  effects = remaining;
}

function drawCaptureEffect(e, p) {
  const x = e.x, y = e.y;
  const inkColor2 = 'rgba(40, 40, 40,';

  for (let i = 0; i < 3; i++) {
    const delay = i * 0.08;
    const pp = Math.max(0, Math.min(1, (p - delay) / (1 - delay)));
    if (pp <= 0) continue;
    const radius = 10 + pp * 55;
    const alpha = (1 - pp) * 0.8;
    ctx.beginPath();
    ctx.arc(x, y, radius, 0, Math.PI * 2);
    ctx.strokeStyle = inkColor2 + alpha.toFixed(2) + ')';
    ctx.lineWidth = 3 - pp * 2.5;
    ctx.stroke();
  }

  for (let i = 0; i < 14; i++) {
    const angle = (i / 14) * Math.PI * 2 + p * 0.8;
    const dist = p * 55;
    const px = x + Math.cos(angle) * dist;
    const py = y + Math.sin(angle) * dist;
    const size = (1 - p) * 5 + 1;
    ctx.beginPath();
    ctx.arc(px, py, size, 0, Math.PI * 2);
    ctx.fillStyle = inkColor2 + ((1 - p) * 0.7).toFixed(2) + ')';
    ctx.fill();
  }

  const coreGrad = ctx.createRadialGradient(x, y, 0, x, y, 30 * (1 - p * 0.6));
  coreGrad.addColorStop(0, inkColor2 + (0.5 * (1 - p)).toFixed(2) + ')');
  coreGrad.addColorStop(1, inkColor2 + '0)');
  ctx.beginPath();
  ctx.arc(x, y, 30 * (1 - p * 0.6), 0, Math.PI * 2);
  ctx.fillStyle = coreGrad;
  ctx.fill();
}

function drawCheckmateEffect(e, p) {
  const x = e.x, y = e.y;

  ctx.save();
  ctx.globalAlpha = Math.min(1, p * 3);

  for (let i = 0; i < 5; i++) {
    const delay = i * 0.06;
    const pp = Math.max(0, Math.min(1, (p - delay) / (1 - delay)));
    if (pp <= 0) continue;
    const radius = 15 + pp * 200;
    const alpha = (1 - pp) * 0.6;
    ctx.beginPath();
    ctx.arc(x, y, radius, 0, Math.PI * 2);
    ctx.strokeStyle = `rgba(30, 30, 30, ${alpha.toFixed(2)})`;
    ctx.lineWidth = 4 - pp * 3;
    ctx.stroke();
  }

  for (let i = 0; i < 5; i++) {
    const angle = (i / 5) * Math.PI * 2 - Math.PI / 2;
    const dist = Math.min(300, p * 600);
    ctx.beginPath();
    ctx.moveTo(x, y);
    ctx.lineTo(x + Math.cos(angle) * dist, y + Math.sin(angle) * dist);
    ctx.strokeStyle = `rgba(30, 30, 30, ${(1 - p) * 0.5})`;
    ctx.lineWidth = 2;
    ctx.stroke();
  }

  for (let i = 0; i < 30; i++) {
    const angle = (i / 30) * Math.PI * 2 + p * 1.5;
    const dist = p * 80 + Math.sin(i * 3 + p * 10) * 15;
    const px = x + Math.cos(angle) * dist;
    const py = y + Math.sin(angle) * dist;
    const size = (1 - p) * 8 + 2;
    const gray = 30 + (i % 3) * 25;
    ctx.beginPath();
    ctx.arc(px, py, size, 0, Math.PI * 2);
    ctx.fillStyle = `rgba(${gray}, ${gray}, ${gray}, ${(1 - p) * 0.8})`;
    ctx.fill();
  }

  const flashGrad = ctx.createRadialGradient(x, y, 0, x, y, 250 * Math.max(0.3, 1 - p));
  flashGrad.addColorStop(0, `rgba(255, 255, 255, ${Math.max(0, (0.6 - p * 0.6))})`);
  flashGrad.addColorStop(0.5, 'rgba(255, 255, 255, 0)');
  ctx.beginPath();
  ctx.arc(x, y, 250, 0, Math.PI * 2);
  ctx.fillStyle = flashGrad;
  ctx.fill();

  ctx.restore();
}

function drawInkTrailEffect(e, p) {
  const x = e.sx + (e.dx - e.sx) * p;
  const y = e.sy + (e.dy - e.sy) * p;
  const inkColor = 'rgba(40, 40, 40,';

  for (let i = 0; i < 6; i++) {
    const pp = Math.max(0, p - i * 0.04);
    const tx = e.sx + (e.dx - e.sx) * pp;
    const ty = e.sy + (e.dy - e.sy) * pp;
    const alpha = (1 - pp) * 0.4;
    const size = (1 - pp) * 18 + 3;
    ctx.beginPath();
    ctx.arc(tx, ty, size, 0, Math.PI * 2);
    ctx.fillStyle = inkColor + alpha.toFixed(2) + ')';
    ctx.fill();
  }

  ctx.beginPath();
  ctx.arc(x, y, 28, 0, Math.PI * 2);
  ctx.fillStyle = inkColor + '0.6)';
  ctx.fill();
}

function animate() {
  draw();
  drawEffects();

  const redKing = findKing(RED);
  const blackKing = findKing(BLACK);
  const redChecked = redKing && isKingInCheck(RED);
  const blackChecked = blackKing && isKingInCheck(BLACK);

  if (effects.length > 0 || redChecked || blackChecked) {
    animating = true;
    requestAnimationFrame(animate);
  } else {
    animating = false;
  }
}

function triggerAnimation() {
  if (!animating) {
    animating = true;
    requestAnimationFrame(animate);
  }
}

function getXY(r, c) {
  return { x: MARGIN_X + c * CELL, y: MARGIN_Y + r * CELL };
}

function addCaptureEffect(r, c, color) {
  const { x, y } = getXY(r, c);
  effects.push({
    type: 'capture',
    x, y,
    color,
    startTime: performance.now(),
    duration: 800
  });
}

function addInkTrailEffect(sr, sc, dr, dc, color) {
  const { x: sx, y: sy } = getXY(sr, sc);
  const { x: dx, y: dy } = getXY(dr, dc);
  effects.push({
    type: 'inkTrail',
    sx, sy, dx, dy,
    color,
    startTime: performance.now(),
    duration: 400
  });
}

function addCheckmateEffect(r, c) {
  const { x, y } = getXY(r, c);
  effects.push({
    type: 'checkmate',
    x, y,
    startTime: performance.now(),
    duration: 2000
  });
}

function showFx(id, duration) {
  const el = document.getElementById(id);
  el.style.animation = 'none';
  el.offsetHeight;
  el.style.animation = `fxFlash ${duration}ms ease-out forwards`;
}

function hideFx(id) {
  const el = document.getElementById(id);
  el.style.animation = 'none';
  el.style.opacity = '0';
}

function triggerShake() {
  canvasWrap.classList.remove('shake');
  void canvasWrap.offsetWidth;
  canvasWrap.classList.add('shake');
}

canvas.addEventListener('click', (e) => {
  if (gameOver) return;
  const rect = canvas.getBoundingClientRect();
  const x = (e.clientX - rect.left) * (canvas.width / rect.width);
  const y = (e.clientY - rect.top) * (canvas.height / rect.height);
  const c = Math.round((x - MARGIN_X) / CELL);
  const r = Math.round((y - MARGIN_Y) / CELL);
  if (!isInBoard(r, c)) return;

  const piece = board[r][c];

  if (selected) {
    const move = legalMoves.find(m => m.r === r && m.c === c);
    if (move) {
      makeMove(selected.r, selected.c, r, c);
      selected = null;
      legalMoves = [];
      checkGameEnd();
      return;
    }
    if (piece && piece.color === currentPlayer) {
      selected = { r, c };
      legalMoves = getLegalMovesFiltered(r, c);
      draw();
      return;
    }
    selected = null;
    legalMoves = [];
    draw();
    return;
  }

  if (piece && piece.color === currentPlayer) {
    selected = { r, c };
    legalMoves = getLegalMovesFiltered(r, c);
    draw();
  }
});

function makeMove(fr, fc, tr, tc) {
  const moved = board[fr][fc];
  const captured = board[tr][tc];
  history.push({ fr, fc, tr, tc, moved, captured, player: currentPlayer });

  addInkTrailEffect(fr, fc, tr, tc, moved.color);
  if (captured) {
    addCaptureEffect(tr, tc, moved.color);
    triggerShake();
  }

  board[tr][tc] = moved;
  board[fr][fc] = null;
  if (captured) {
    if (currentPlayer === RED) capturedByRed.push(captured.type);
    else capturedByBlack.push(captured.type);
  }
  currentPlayer = currentPlayer === RED ? BLACK : RED;
  updateUI();
  triggerAnimation();
}

function undo() {
  if (history.length === 0) return;
  const last = history.pop();
  board[last.fr][last.fc] = last.moved;
  board[last.tr][last.tc] = last.captured;
  if (last.captured) {
    if (last.player === RED) capturedByRed.pop();
    else capturedByBlack.pop();
  }
  currentPlayer = last.player;
  gameOver = false;
  selected = null;
  legalMoves = [];
  effects = [];
  checkAnimStart = 0;
  checkKingPos = null;
  hideFx('fxCheck');
  hideFx('fxMate');
  updateUI();
  draw();
}

function checkGameEnd() {
  const opp = currentPlayer === RED ? BLACK : RED;
  const oppKing = findKing(opp);
  const statusEl = document.getElementById('status');
  if (!oppKing) {
    gameOver = true;
    const winner = currentPlayer === RED ? '红方' : '黑方';
    statusEl.textContent = winner + ' 获胜 · 将军抽帅';
    addCheckmateEffect(oppKing?.r ?? 5, oppKing?.c ?? 4);
    showFx('fxMate', 2000);
    triggerShake();
    return;
  }
  if (!hasAnyLegalMove(currentPlayer)) {
    gameOver = true;
    const inCheck = isKingInCheck(currentPlayer);
    if (inCheck) {
      statusEl.textContent = '绝杀 · ' + (opp === RED ? '红方' : '黑方') + ' 获胜';
      const king = findKing(currentPlayer);
      if (king) {
        addCheckmateEffect(king.r, king.c);
      }
      showFx('fxMate', 2000);
      triggerShake();
    } else {
      statusEl.textContent = '和棋 · 困毙';
    }
    return;
  }
  if (isKingInCheck(currentPlayer)) {
    statusEl.textContent = '将 军';
    showFx('fxCheck', 1500);
  } else {
    statusEl.textContent = '';
  }
}

function updateUI() {
  const ind = document.getElementById('turnIndicator');
  if (gameOver) {
    ind.textContent = '游戏结束';
    ind.className = 'turn-indicator';
  } else if (currentPlayer === RED) {
    ind.textContent = '红方走棋';
    ind.className = 'turn-indicator turn-red';
  } else {
    ind.textContent = '黑方走棋';
    ind.className = 'turn-indicator turn-black';
  }
  document.getElementById('capturedByRed').textContent = capturedByRed.map(p => PIECES.black[p] || '?').join('');
  document.getElementById('capturedByBlack').textContent = capturedByBlack.map(p => PIECES.red[p] || '?').join('');
}

document.getElementById('restartBtn').addEventListener('click', initBoard);
document.getElementById('undoBtn').addEventListener('click', undo);

initBoard();
</script>
</body>
</html>

思索再三后决定重写棋子移动:

二版(新增了水墨粒子飞溅的效果):

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>中国象棋 · 水墨特效版</title>
<style>
  * { box-sizing: border-box; margin: 0; padding: 0; }
  body {
    font-family: "KaiTi", "STKaiti", "楷体", serif;
    background: #e8e4dc;
    background-image:
      radial-gradient(circle at 20% 30%, rgba(180,160,130,0.15) 0%, transparent 50%),
      radial-gradient(circle at 80% 70%, rgba(160,140,110,0.1) 0%, transparent 50%),
      radial-gradient(circle at 50% 50%, rgba(200,190,170,0.2) 0%, transparent 70%);
    min-height: 100vh;
    display: flex;
    flex-direction: column;
    align-items: center;
    padding: 30px 20px;
    color: #2c2c2c;
  }
  h1 {
    font-size: 38px;
    margin-bottom: 15px;
    letter-spacing: 12px;
    color: #1a1a1a;
    font-weight: normal;
    text-shadow: 1px 1px 0 rgba(0,0,0,0.08);
    position: relative;
  }
  h1::after {
    content: '';
    display: block;
    width: 60%;
    height: 2px;
    margin: 8px auto 0;
    background: linear-gradient(90deg, transparent, #555, transparent);
  }
  .info {
    display: flex;
    gap: 30px;
    margin-bottom: 20px;
    align-items: center;
    font-size: 18px;
  }
  .turn-indicator {
    padding: 10px 24px;
    border-radius: 4px;
    font-weight: bold;
    transition: all 0.3s;
    border: 1px solid #333;
    background: #f5f1ea;
    color: #2c2c2c;
    letter-spacing: 2px;
  }
  .turn-red { background: #c0392b; color: #f5f1ea; border-color: #8b2a1e; }
  .turn-black { background: #2c2c2c; color: #f5f1ea; border-color: #000; }
  button {
    background: #f5f1ea;
    color: #2c2c2c;
    border: 1px solid #333;
    padding: 10px 24px;
    border-radius: 4px;
    cursor: pointer;
    font-size: 16px;
    font-family: inherit;
    font-weight: bold;
    letter-spacing: 2px;
    transition: all 0.3s;
  }
  button:hover {
    background: #2c2c2c;
    color: #f5f1ea;
    transform: translateY(-2px);
  }
  .canvas-wrap {
    position: relative;
  }
  canvas {
    background: #f5f1ea;
    border: 1px solid #333;
    border-radius: 2px;
    box-shadow:
      0 0 0 8px #f5f1ea,
      0 0 0 9px #333,
      0 15px 40px rgba(0,0,0,0.3);
    cursor: pointer;
  }
  .status {
    margin-top: 18px;
    font-size: 20px;
    min-height: 28px;
    color: #c0392b;
    font-weight: bold;
    letter-spacing: 3px;
  }
  .captured {
    margin-top: 12px;
    display: flex;
    gap: 40px;
    font-size: 16px;
    color: #555;
  }
  .captured div { min-height: 30px; }
  .captured span {
    display: inline-block;
    margin: 0 3px;
    font-size: 22px;
    font-family: "KaiTi", "STKaiti", serif;
  }
  .fx-text {
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    pointer-events: none;
    font-family: "KaiTi", "STKaiti", "楷体", serif;
    font-weight: bold;
    opacity: 0;
    z-index: 10;
  }
  .fx-check {
    font-size: 72px;
    color: #c0392b;
    letter-spacing: 20px;
    text-shadow: 0 0 20px rgba(192, 57, 43, 0.6);
  }
  .fx-mate {
    font-size: 96px;
    color: #8b0000;
    letter-spacing: 30px;
    text-shadow: 0 0 30px rgba(139, 0, 0, 0.8), 0 0 60px rgba(192, 57, 43, 0.5);
  }
  @keyframes fxFlash {
    0%   { opacity: 0; transform: translate(-50%, -50%) scale(0.3); }
    30%  { opacity: 1; transform: translate(-50%, -50%) scale(1.2); }
    70%  { opacity: 1; transform: translate(-50%, -50%) scale(1); }
    100% { opacity: 0; transform: translate(-50%, -50%) scale(1.5); }
  }
  @keyframes fxShake {
    0%, 100% { transform: translate(0, 0); }
    10%  { transform: translate(-6px, -4px); }
    20%  { transform: translate(5px, 3px); }
    30%  { transform: translate(-4px, 5px); }
    40%  { transform: translate(6px, -3px); }
    50%  { transform: translate(-5px, 4px); }
    60%  { transform: translate(3px, -5px); }
    70%  { transform: translate(-3px, 3px); }
    80%  { transform: translate(4px, -2px); }
    90%  { transform: translate(-2px, 2px); }
  }
  .shake { animation: fxShake 0.5s ease-in-out; }
</style>
</head>
<body>
  <h1>中 國 象 棋</h1>
  <div class="info">
    <div class="turn-indicator" id="turnIndicator">红方走棋</div>
    <button id="restartBtn">重 新 开 始</button>
    <button id="undoBtn">悔 棋</button>
  </div>
  <div class="canvas-wrap" id="canvasWrap">
    <canvas id="board" width="540" height="600"></canvas>
    <div class="fx-text fx-check" id="fxCheck">将 軍</div>
    <div class="fx-text fx-mate" id="fxMate">绝 殺</div>
  </div>
  <div class="status" id="status"></div>
  <div class="captured">
    <div>红方被吃 · <span id="capturedByRed"></span></div>
    <div>黑方被吃 · <span id="capturedByBlack"></span></div>
  </div>

<script>
const CELL = 60;
const MARGIN_X = 30;
const MARGIN_Y = 30;
const COLS = 9;
const ROWS = 10;

const canvas = document.getElementById('board');
const ctx = canvas.getContext('2d');
const canvasWrap = document.getElementById('canvasWrap');

const RED = 'red';
const BLACK = 'black';

const PIECES = {
  red: {
    king: '帥', advisor: '仕', elephant: '相', horse: '傌',
    chariot: '俥', cannon: '炮', pawn: '兵'
  },
  black: {
    king: '將', advisor: '士', elephant: '象', horse: '馬',
    chariot: '車', cannon: '砲', pawn: '卒'
  }
};

let board = [];
let currentPlayer = RED;
let selected = null;
let legalMoves = [];
let gameOver = false;
let history = [];
let capturedByRed = [];
let capturedByBlack = [];

let effects = [];
let animating = false;
let checkAnimStart = 0;
let checkKingPos = null;
let checkmateAnimStart = 0;

function P(type, color) {
  return { type, color };
}

function initBoard() {
  board = [
    [P('chariot',BLACK),P('horse',BLACK),P('elephant',BLACK),P('advisor',BLACK),P('king',BLACK),P('advisor',BLACK),P('elephant',BLACK),P('horse',BLACK),P('chariot',BLACK)],
    [null,null,null,null,null,null,null,null,null],
    [null,P('cannon',BLACK),null,null,null,null,null,P('cannon',BLACK),null],
    [P('pawn',BLACK),null,P('pawn',BLACK),null,P('pawn',BLACK),null,P('pawn',BLACK),null,P('pawn',BLACK)],
    [null,null,null,null,null,null,null,null,null],
    [null,null,null,null,null,null,null,null,null],
    [P('pawn',RED),null,P('pawn',RED),null,P('pawn',RED),null,P('pawn',RED),null,P('pawn',RED)],
    [null,P('cannon',RED),null,null,null,null,null,P('cannon',RED),null],
    [null,null,null,null,null,null,null,null,null],
    [P('chariot',RED),P('horse',RED),P('elephant',RED),P('advisor',RED),P('king',RED),P('advisor',RED),P('elephant',RED),P('horse',RED),P('chariot',RED)]
  ];
  currentPlayer = RED;
  selected = null;
  legalMoves = [];
  gameOver = false;
  history = [];
  capturedByRed = [];
  capturedByBlack = [];
  effects = [];
  checkAnimStart = 0;
  checkKingPos = null;
  checkmateAnimStart = 0;
  hideFx('fxCheck');
  hideFx('fxMate');
  updateUI();
  draw();
}

function isInBoard(r, c) {
  return r >= 0 && r < ROWS && c >= 0 && c < COLS;
}

function inPalace(r, c, color) {
  if (c < 3 || c > 5) return false;
  if (color === RED) return r >= 7 && r <= 9;
  return r >= 0 && r <= 2;
}

function onOwnSide(r, color) {
  if (color === RED) return r >= 5;
  return r <= 4;
}

function getLegalMoves(r, c) {
  const piece = board[r][c];
  if (!piece) return [];
  const color = piece.color;
  const type = piece.type;
  const moves = [];
  const addMove = (nr, nc) => {
    if (!isInBoard(nr, nc)) return;
    const target = board[nr][nc];
    if (target && target.color === color) return;
    moves.push({ r: nr, c: nc, capture: !!target });
  };

  switch (type) {
    case 'king': {
      const dirs = [[-1,0],[1,0],[0,-1],[0,1]];
      for (const [dr, dc] of dirs) {
        const nr = r + dr, nc = c + dc;
        if (inPalace(nr, nc, color)) addMove(nr, nc);
      }
      break;
    }
    case 'advisor': {
      const dirs = [[-1,-1],[-1,1],[1,-1],[1,1]];
      for (const [dr, dc] of dirs) {
        const nr = r + dr, nc = c + dc;
        if (inPalace(nr, nc, color)) addMove(nr, nc);
      }
      break;
    }
    case 'elephant': {
      const dirs = [[-2,-2],[-2,2],[2,-2],[2,2]];
      for (const [dr, dc] of dirs) {
        const nr = r + dr, nc = c + dc;
        const mr = r + dr/2, mc = c + dc/2;
        if (!isInBoard(nr, nc)) continue;
        if (!onOwnSide(nr, color)) continue;
        if (board[mr][mc]) continue;
        addMove(nr, nc);
      }
      break;
    }
    case 'horse': {
      const hm = [
        {dr:-2,dc:-1,br:-1,bc:0}, {dr:-2,dc:1,br:-1,bc:0},
        {dr:2,dc:-1,br:1,bc:0}, {dr:2,dc:1,br:1,bc:0},
        {dr:-1,dc:-2,br:0,bc:-1}, {dr:1,dc:-2,br:0,bc:-1},
        {dr:-1,dc:2,br:0,bc:1}, {dr:1,dc:2,br:0,bc:1}
      ];
      for (const m of hm) {
        const nr = r + m.dr, nc = c + m.dc;
        const lr = r + m.br, lc = c + m.bc;
        if (!isInBoard(nr, nc)) continue;
        if (board[lr][lc]) continue;
        addMove(nr, nc);
      }
      break;
    }
    case 'chariot': {
      const dirs = [[-1,0],[1,0],[0,-1],[0,1]];
      for (const [dr, dc] of dirs) {
        let nr = r + dr, nc = c + dc;
        while (isInBoard(nr, nc)) {
          const target = board[nr][nc];
          if (!target) {
            moves.push({ r: nr, c: nc, capture: false });
          } else {
            if (target.color !== color) {
              moves.push({ r: nr, c: nc, capture: true });
            }
            break;
          }
          nr += dr; nc += dc;
        }
      }
      break;
    }
    case 'cannon': {
      const dirs = [[-1,0],[1,0],[0,-1],[0,1]];
      for (const [dr, dc] of dirs) {
        let nr = r + dr, nc = c + dc;
        let jumped = false;
        while (isInBoard(nr, nc)) {
          const target = board[nr][nc];
          if (!jumped) {
            if (!target) {
              moves.push({ r: nr, c: nc, capture: false });
            } else {
              jumped = true;
            }
          } else {
            if (target) {
              if (target.color !== color) {
                moves.push({ r: nr, c: nc, capture: true });
              }
              break;
            }
          }
          nr += dr; nc += dc;
        }
      }
      break;
    }
    case 'pawn': {
      const forward = color === RED ? -1 : 1;
      const nr = r + forward;
      if (isInBoard(nr, c)) addMove(nr, c);
      if (!onOwnSide(r, color)) {
        if (isInBoard(r, c - 1)) addMove(r, c - 1);
        if (isInBoard(r, c + 1)) addMove(r, c + 1);
      }
      break;
    }
  }
  return moves;
}

function findKing(color) {
  for (let r = 0; r < ROWS; r++) {
    for (let c = 0; c < COLS; c++) {
      if (board[r][c] && board[r][c].type === 'king' && board[r][c].color === color) return { r, c };
    }
  }
  return null;
}

function kingsFacing() {
  const rk = findKing(RED);
  const bk = findKing(BLACK);
  if (!rk || !bk || rk.c !== bk.c) return false;
  const col = rk.c;
  const minR = Math.min(rk.r, bk.r);
  const maxR = Math.max(rk.r, bk.r);
  for (let r = minR + 1; r < maxR; r++) {
    if (board[r][col]) return false;
  }
  return true;
}

function getLegalMovesFiltered(r, c) {
  const moves = getLegalMoves(r, c);
  return moves.filter(m => {
    const captured = board[m.r][m.c];
    const moving = board[r][c];
    board[m.r][m.c] = moving;
    board[r][c] = null;
    const inCheck = isKingInCheck(moving.color) || kingsFacing();
    board[r][c] = moving;
    board[m.r][m.c] = captured;
    return !inCheck;
  });
}

function isKingInCheck(color) {
  const king = findKing(color);
  if (!king) return true;
  const oppColor = color === RED ? BLACK : RED;
  for (let r = 0; r < ROWS; r++) {
    for (let c = 0; c < COLS; c++) {
      if (board[r][c] && board[r][c].color === oppColor) {
        const moves = getLegalMoves(r, c);
        if (moves.some(m => m.r === king.r && m.c === king.c)) return true;
      }
    }
  }
  return false;
}

function hasAnyLegalMove(color) {
  for (let r = 0; r < ROWS; r++) {
    for (let c = 0; c < COLS; c++) {
      if (board[r][c] && board[r][c].color === color) {
        if (getLegalMovesFiltered(r, c).length > 0) return true;
      }
    }
  }
  return false;
}

function drawBoard() {
  const grad = ctx.createLinearGradient(0, 0, canvas.width, canvas.height);
  grad.addColorStop(0, '#f8f4ec');
  grad.addColorStop(0.5, '#f0ebe0');
  grad.addColorStop(1, '#ede6d8');
  ctx.fillStyle = grad;
  ctx.fillRect(0, 0, canvas.width, canvas.height);

  ctx.strokeStyle = '#1a1a1a';
  ctx.lineWidth = 1.5;

  for (let r = 0; r < ROWS; r++) {
    ctx.beginPath();
    ctx.moveTo(MARGIN_X, MARGIN_Y + r * CELL);
    ctx.lineTo(MARGIN_X + (COLS - 1) * CELL, MARGIN_Y + r * CELL);
    ctx.stroke();
  }

  for (let c = 0; c < COLS; c++) {
    if (c === 0 || c === COLS - 1) {
      ctx.beginPath();
      ctx.moveTo(MARGIN_X + c * CELL, MARGIN_Y);
      ctx.lineTo(MARGIN_X + c * CELL, MARGIN_Y + (ROWS - 1) * CELL);
      ctx.stroke();
    } else {
      ctx.beginPath();
      ctx.moveTo(MARGIN_X + c * CELL, MARGIN_Y);
      ctx.lineTo(MARGIN_X + c * CELL, MARGIN_Y + 4 * CELL);
      ctx.stroke();
      ctx.beginPath();
      ctx.moveTo(MARGIN_X + c * CELL, MARGIN_Y + 5 * CELL);
      ctx.lineTo(MARGIN_X + c * CELL, MARGIN_Y + 9 * CELL);
      ctx.stroke();
    }
  }

  ctx.beginPath();
  ctx.moveTo(MARGIN_X, MARGIN_Y + 4 * CELL);
  ctx.lineTo(MARGIN_X + (COLS - 1) * CELL, MARGIN_Y + 4 * CELL);
  ctx.moveTo(MARGIN_X, MARGIN_Y + 5 * CELL);
  ctx.lineTo(MARGIN_X + (COLS - 1) * CELL, MARGIN_Y + 5 * CELL);
  ctx.stroke();

  ctx.beginPath();
  ctx.moveTo(MARGIN_X + 3 * CELL, MARGIN_Y);
  ctx.lineTo(MARGIN_X + 5 * CELL, MARGIN_Y + 2 * CELL);
  ctx.moveTo(MARGIN_X + 5 * CELL, MARGIN_Y);
  ctx.lineTo(MARGIN_X + 3 * CELL, MARGIN_Y + 2 * CELL);
  ctx.moveTo(MARGIN_X + 3 * CELL, MARGIN_Y + 7 * CELL);
  ctx.lineTo(MARGIN_X + 5 * CELL, MARGIN_Y + 9 * CELL);
  ctx.moveTo(MARGIN_X + 5 * CELL, MARGIN_Y + 7 * CELL);
  ctx.lineTo(MARGIN_X + 3 * CELL, MARGIN_Y + 9 * CELL);
  ctx.stroke();

  ctx.fillStyle = '#1a1a1a';
  ctx.font = 'bold 26px "KaiTi", "STKaiti", "楷体", serif';
  ctx.textAlign = 'center';
  ctx.textBaseline = 'middle';
  ctx.fillText('楚 河', MARGIN_X + 1.5 * CELL, MARGIN_Y + 4.5 * CELL);
  ctx.fillText('漢 界', MARGIN_X + 6.5 * CELL, MARGIN_Y + 4.5 * CELL);

  drawStarPoints();
}

function drawStarPoints() {
  const points = [
    [2,1],[2,7],[7,1],[7,7],
    [3,0],[3,2],[3,4],[3,6],[3,8],
    [6,0],[6,2],[6,4],[6,6],[6,8]
  ];
  for (const [r, c] of points) {
    drawStar(r, c);
  }
}

function drawStar(r, c) {
  const x = MARGIN_X + c * CELL;
  const y = MARGIN_Y + r * CELL;
  const s = 6;
  const g = 5;
  ctx.strokeStyle = '#1a1a1a';
  ctx.lineWidth = 1.2;
  const dirs = [];
  if (c > 0) dirs.push([-1,-1],[-1,1]);
  if (c < COLS - 1) dirs.push([1,-1],[1,1]);
  for (const [dx, dy] of dirs) {
    ctx.beginPath();
    ctx.moveTo(x + dx * g, y + dy * g);
    ctx.lineTo(x + dx * (g + s), y + dy * g);
    ctx.stroke();
    ctx.beginPath();
    ctx.moveTo(x + dx * g, y + dy * g);
    ctx.lineTo(x + dx * g, y + dy * (g + s));
    ctx.stroke();
  }
}

function drawPiece(r, c, type, color, highlight) {
  const x = MARGIN_X + c * CELL;
  const y = MARGIN_Y + r * CELL;
  const radius = 26;

  if (highlight) {
    ctx.beginPath();
    ctx.arc(x, y, radius + 5, 0, Math.PI * 2);
    ctx.fillStyle = 'rgba(192, 57, 43, 0.25)';
    ctx.fill();
  }

  const inkGrad = ctx.createRadialGradient(x - 3, y - 3, 2, x, y, radius);
  if (color === RED) {
    inkGrad.addColorStop(0, '#e8e0d4');
    inkGrad.addColorStop(0.6, '#d4c8b4');
    inkGrad.addColorStop(1, '#b8a88c');
  } else {
    inkGrad.addColorStop(0, '#e8e0d4');
    inkGrad.addColorStop(0.6, '#d0c4b0');
    inkGrad.addColorStop(1, '#b0a288');
  }
  ctx.beginPath();
  ctx.arc(x, y, radius, 0, Math.PI * 2);
  ctx.fillStyle = inkGrad;
  ctx.fill();

  ctx.beginPath();
  ctx.arc(x, y, radius, 0, Math.PI * 2);
  ctx.strokeStyle = '#1a1a1a';
  ctx.lineWidth = 2;
  ctx.stroke();

  ctx.beginPath();
  ctx.arc(x, y, radius - 4, 0, Math.PI * 2);
  ctx.strokeStyle = color === RED ? '#c0392b' : '#1a1a1a';
  ctx.lineWidth = 1;
  ctx.stroke();

  ctx.fillStyle = color === RED ? '#c0392b' : '#1a1a1a';
  ctx.font = 'bold 28px "KaiTi", "STKaiti", "楷体", serif';
  ctx.textAlign = 'center';
  ctx.textBaseline = 'middle';
  ctx.fillText(PIECES[color][type], x, y + 1);
}

function draw() {
  drawBoard();

  for (const move of legalMoves) {
    const x = MARGIN_X + move.c * CELL;
    const y = MARGIN_Y + move.r * CELL;
    if (move.capture) {
      ctx.beginPath();
      ctx.arc(x, y, 30, 0, Math.PI * 2);
      ctx.strokeStyle = 'rgba(192, 57, 43, 0.7)';
      ctx.lineWidth = 2.5;
      ctx.setLineDash([4, 3]);
      ctx.stroke();
      ctx.setLineDash([]);
    } else {
      ctx.beginPath();
      ctx.arc(x, y, 6, 0, Math.PI * 2);
      ctx.fillStyle = 'rgba(26, 26, 26, 0.5)';
      ctx.fill();
    }
  }

  for (let r = 0; r < ROWS; r++) {
    for (let c = 0; c < COLS; c++) {
      if (board[r][c]) {
        const p = board[r][c];
        drawPiece(r, c, p.type, p.color, selected && selected.r === r && selected.c === c);
      }
    }
  }

  const redKing = findKing(RED);
  const blackKing = findKing(BLACK);
  const now = performance.now();

  if (redKing && isKingInCheck(RED)) {
    if (!checkKingPos || checkKingPos.color !== RED || checkKingPos.r !== redKing.r || checkKingPos.c !== redKing.c) {
      checkAnimStart = now;
      checkKingPos = { r: redKing.r, c: redKing.c, color: RED };
    }
    const elapsed = now - checkAnimStart;
    const pulse = 0.5 + 0.5 * Math.sin(elapsed / 150);
    const x = MARGIN_X + redKing.c * CELL;
    const y = MARGIN_Y + redKing.r * CELL;
    const r = 32 + pulse * 8;
    const grad = ctx.createRadialGradient(x, y, 20, x, y, r);
    grad.addColorStop(0, `rgba(192, 57, 43, ${0.15 + pulse * 0.25})`);
    grad.addColorStop(0.7, `rgba(192, 57, 43, ${0.3 + pulse * 0.3})`);
    grad.addColorStop(1, 'rgba(192, 57, 43, 0)');
    ctx.beginPath();
    ctx.arc(x, y, r, 0, Math.PI * 2);
    ctx.fillStyle = grad;
    ctx.fill();
    ctx.beginPath();
    ctx.arc(x, y, 34 + pulse * 4, 0, Math.PI * 2);
    ctx.strokeStyle = `rgba(192, 57, 43, ${0.6 + pulse * 0.4})`;
    ctx.lineWidth = 2.5;
    ctx.stroke();
  } else {
    if (checkKingPos && checkKingPos.color === RED) { checkAnimStart = 0; checkKingPos = null; }
  }

  if (blackKing && isKingInCheck(BLACK)) {
    if (!checkKingPos || checkKingPos.color !== BLACK || checkKingPos.r !== blackKing.r || checkKingPos.c !== blackKing.c) {
      checkAnimStart = now;
      checkKingPos = { r: blackKing.r, c: blackKing.c, color: BLACK };
    }
    const elapsed = now - checkAnimStart;
    const pulse = 0.5 + 0.5 * Math.sin(elapsed / 150);
    const x = MARGIN_X + blackKing.c * CELL;
    const y = MARGIN_Y + blackKing.r * CELL;
    const r = 32 + pulse * 8;
    const grad = ctx.createRadialGradient(x, y, 20, x, y, r);
    grad.addColorStop(0, `rgba(192, 57, 43, ${0.15 + pulse * 0.25})`);
    grad.addColorStop(0.7, `rgba(192, 57, 43, ${0.3 + pulse * 0.3})`);
    grad.addColorStop(1, 'rgba(192, 57, 43, 0)');
    ctx.beginPath();
    ctx.arc(x, y, r, 0, Math.PI * 2);
    ctx.fillStyle = grad;
    ctx.fill();
    ctx.beginPath();
    ctx.arc(x, y, 34 + pulse * 4, 0, Math.PI * 2);
    ctx.strokeStyle = `rgba(192, 57, 43, ${0.6 + pulse * 0.4})`;
    ctx.lineWidth = 2.5;
    ctx.stroke();
  } else {
    if (checkKingPos && checkKingPos.color === BLACK) { checkAnimStart = 0; checkKingPos = null; }
  }
}

function drawEffects() {
  const now = performance.now();
  const remaining = [];

  for (const e of effects) {
    const elapsed = now - e.startTime;
    const progress = elapsed / e.duration;
    if (progress >= 1) continue;

    if (e.type === 'capture') {
      drawCaptureEffect(e, progress);
      remaining.push(e);
    } else if (e.type === 'checkmate') {
      drawCheckmateEffect(e, progress);
      remaining.push(e);
    } else if (e.type === 'inkTrail') {
      drawInkTrailEffect(e, progress);
      remaining.push(e);
    }
  }

  effects = remaining;
}

function drawCaptureEffect(e, p) {
  const x = e.x, y = e.y;
  const inkColor2 = 'rgba(40, 40, 40,';

  for (let i = 0; i < 3; i++) {
    const delay = i * 0.08;
    const pp = Math.max(0, Math.min(1, (p - delay) / (1 - delay)));
    if (pp <= 0) continue;
    const radius = 10 + pp * 55;
    const alpha = (1 - pp) * 0.8;
    ctx.beginPath();
    ctx.arc(x, y, radius, 0, Math.PI * 2);
    ctx.strokeStyle = inkColor2 + alpha.toFixed(2) + ')';
    ctx.lineWidth = 3 - pp * 2.5;
    ctx.stroke();
  }

  for (let i = 0; i < 14; i++) {
    const angle = (i / 14) * Math.PI * 2 + p * 0.8;
    const dist = p * 55;
    const px = x + Math.cos(angle) * dist;
    const py = y + Math.sin(angle) * dist;
    const size = (1 - p) * 5 + 1;
    ctx.beginPath();
    ctx.arc(px, py, size, 0, Math.PI * 2);
    ctx.fillStyle = inkColor2 + ((1 - p) * 0.7).toFixed(2) + ')';
    ctx.fill();
  }

  const coreGrad = ctx.createRadialGradient(x, y, 0, x, y, 30 * (1 - p * 0.6));
  coreGrad.addColorStop(0, inkColor2 + (0.5 * (1 - p)).toFixed(2) + ')');
  coreGrad.addColorStop(1, inkColor2 + '0)');
  ctx.beginPath();
  ctx.arc(x, y, 30 * (1 - p * 0.6), 0, Math.PI * 2);
  ctx.fillStyle = coreGrad;
  ctx.fill();
}

function drawCheckmateEffect(e, p) {
  const x = e.x, y = e.y;

  ctx.save();
  ctx.globalAlpha = Math.min(1, p * 3);

  for (let i = 0; i < 5; i++) {
    const delay = i * 0.06;
    const pp = Math.max(0, Math.min(1, (p - delay) / (1 - delay)));
    if (pp <= 0) continue;
    const radius = 15 + pp * 200;
    const alpha = (1 - pp) * 0.6;
    ctx.beginPath();
    ctx.arc(x, y, radius, 0, Math.PI * 2);
    ctx.strokeStyle = `rgba(30, 30, 30, ${alpha.toFixed(2)})`;
    ctx.lineWidth = 4 - pp * 3;
    ctx.stroke();
  }

  for (let i = 0; i < 5; i++) {
    const angle = (i / 5) * Math.PI * 2 - Math.PI / 2;
    const dist = Math.min(300, p * 600);
    ctx.beginPath();
    ctx.moveTo(x, y);
    ctx.lineTo(x + Math.cos(angle) * dist, y + Math.sin(angle) * dist);
    ctx.strokeStyle = `rgba(30, 30, 30, ${(1 - p) * 0.5})`;
    ctx.lineWidth = 2;
    ctx.stroke();
  }

  for (let i = 0; i < 30; i++) {
    const angle = (i / 30) * Math.PI * 2 + p * 1.5;
    const dist = p * 80 + Math.sin(i * 3 + p * 10) * 15;
    const px = x + Math.cos(angle) * dist;
    const py = y + Math.sin(angle) * dist;
    const size = (1 - p) * 8 + 2;
    const gray = 30 + (i % 3) * 25;
    ctx.beginPath();
    ctx.arc(px, py, size, 0, Math.PI * 2);
    ctx.fillStyle = `rgba(${gray}, ${gray}, ${gray}, ${(1 - p) * 0.8})`;
    ctx.fill();
  }

  const flashGrad = ctx.createRadialGradient(x, y, 0, x, y, 250 * Math.max(0.3, 1 - p));
  flashGrad.addColorStop(0, `rgba(255, 255, 255, ${Math.max(0, (0.6 - p * 0.6))})`);
  flashGrad.addColorStop(0.5, 'rgba(255, 255, 255, 0)');
  ctx.beginPath();
  ctx.arc(x, y, 250, 0, Math.PI * 2);
  ctx.fillStyle = flashGrad;
  ctx.fill();

  ctx.restore();
}

function drawInkTrailEffect(e, p) {
  const x = e.sx + (e.dx - e.sx) * p;
  const y = e.sy + (e.dy - e.sy) * p;
  const inkColor = 'rgba(40, 40, 40,';
  const dx = e.dx - e.sx;
  const dy = e.dy - e.sy;
  const angle = Math.atan2(dy, dx);
  const splatters = e.splatters;

  for (let i = 0; i < 8; i++) {
    const pp = Math.max(0, p - i * 0.035);
    const tx = e.sx + dx * pp;
    const ty = e.sy + dy * pp;
    const alpha = (1 - pp) * 0.5;
    const size = (1 - pp) * 20 + 4;
    ctx.beginPath();
    ctx.arc(tx, ty, size, 0, Math.PI * 2);
    ctx.fillStyle = inkColor + alpha.toFixed(2) + ')';
    ctx.fill();
  }

  for (let i = 0; i < 5; i++) {
    const pp = Math.max(0, p - i * 0.06);
    const tx = e.sx + dx * pp;
    const ty = e.sy + dy * pp;
    const spread = 8 * (1 - pp);
    const perpAngle = angle + Math.PI / 2;
    const offset = (Math.sin(i * 1.7 + pp * 5) * spread);
    const ox = tx + Math.cos(perpAngle) * offset;
    const oy = ty + Math.sin(perpAngle) * offset;
    const size = (1 - pp) * 10 + 2;
    const alpha = (1 - pp) * 0.35;
    ctx.beginPath();
    ctx.arc(ox, oy, size, 0, Math.PI * 2);
    ctx.fillStyle = inkColor + alpha.toFixed(2) + ')';
    ctx.fill();
  }

  for (let i = 0; i < splatters.length; i++) {
    const sp = splatters[i];
    const progress = (p - sp.delay) / (1 - sp.delay);
    if (progress <= 0 || progress > 1) continue;
    const sx = x + Math.cos(sp.angle) * (progress * 35 + 5) + sp.offsetX * progress;
    const sy = y + Math.sin(sp.angle) * (progress * 35 + 5) + sp.offsetY * progress;
    const size = (1 - progress) * sp.maxSize + sp.minSize;
    const alpha = (1 - progress) * sp.maxAlpha;
    ctx.beginPath();
    ctx.arc(sx, sy, size, 0, Math.PI * 2);
    ctx.fillStyle = inkColor + alpha.toFixed(2) + ')';
    ctx.fill();
  }

  for (let i = 0; i < e.clusters.length; i++) {
    const cl = e.clusters[i];
    const progress = (p - cl.delay) / (1 - cl.delay);
    if (progress <= 0 || progress > 1) continue;
    const cx = x + Math.cos(cl.angle) * (progress * 40);
    const cy = y + Math.sin(cl.angle) * (progress * 40);
    const size = (1 - progress) * cl.maxSize + cl.minSize;
    const alpha = (1 - progress) * cl.maxAlpha;
    ctx.beginPath();
    ctx.arc(cx, cy, size, 0, Math.PI * 2);
    ctx.fillStyle = `rgba(${cl.gray}, ${cl.gray}, ${cl.gray}, ${alpha.toFixed(2)})`;
    ctx.fill();
  }

  const headGrad = ctx.createRadialGradient(x, y, 0, x, y, 30);
  headGrad.addColorStop(0, inkColor + '0.7)');
  headGrad.addColorStop(0.6, inkColor + '0.4)');
  headGrad.addColorStop(1, inkColor + '0)');
  ctx.beginPath();
  ctx.arc(x, y, 30, 0, Math.PI * 2);
  ctx.fillStyle = headGrad;
  ctx.fill();

  ctx.beginPath();
  ctx.arc(x, y, 22, 0, Math.PI * 2);
  ctx.fillStyle = inkColor + '0.55)';
  ctx.fill();
}

function animate() {
  draw();
  drawEffects();

  const redKing = findKing(RED);
  const blackKing = findKing(BLACK);
  const redChecked = redKing && isKingInCheck(RED);
  const blackChecked = blackKing && isKingInCheck(BLACK);

  if (effects.length > 0 || redChecked || blackChecked) {
    animating = true;
    requestAnimationFrame(animate);
  } else {
    animating = false;
  }
}

function triggerAnimation() {
  if (!animating) {
    animating = true;
    requestAnimationFrame(animate);
  }
}

function getXY(r, c) {
  return { x: MARGIN_X + c * CELL, y: MARGIN_Y + r * CELL };
}

function addCaptureEffect(r, c, color) {
  const { x, y } = getXY(r, c);
  effects.push({
    type: 'capture',
    x, y,
    color,
    startTime: performance.now(),
    duration: 800
  });
}

function addInkTrailEffect(sr, sc, dr, dc, color) {
  const { x: sx, y: sy } = getXY(sr, sc);
  const { x: dx, y: dy } = getXY(dr, dc);
  const moveAngle = Math.atan2(dy - sy, dx - sx);

  const splatters = [];
  for (let i = 0; i < 12; i++) {
    const spreadAngle = moveAngle + Math.PI + (Math.random() - 0.5) * 1.8;
    splatters.push({
      angle: spreadAngle,
      delay: i * 0.07,
      offsetX: (Math.random() - 0.5) * 10,
      offsetY: (Math.random() - 0.5) * 10,
      maxSize: 6 + Math.random() * 5,
      minSize: 1 + Math.random() * 1.5,
      maxAlpha: 0.5 + Math.random() * 0.3
    });
  }

  const clusters = [];
  for (let i = 0; i < 4; i++) {
    const clusterAngle = moveAngle + Math.PI + (i / 4) * 0.8 - 0.4 + (Math.random() - 0.5) * 0.3;
    clusters.push({
      angle: clusterAngle,
      delay: i * 0.15,
      gray: 25 + Math.floor(Math.random() * 30),
      maxSize: 4 + Math.random() * 4,
      minSize: 0.8 + Math.random() * 1.2,
      maxAlpha: 0.4 + Math.random() * 0.3
    });
  }

  effects.push({
    type: 'inkTrail',
    sx, sy, dx, dy,
    color,
    splatters,
    clusters,
    startTime: performance.now(),
    duration: 400
  });
}

function addCheckmateEffect(r, c) {
  const { x, y } = getXY(r, c);
  effects.push({
    type: 'checkmate',
    x, y,
    startTime: performance.now(),
    duration: 2000
  });
}

function showFx(id, duration) {
  const el = document.getElementById(id);
  el.style.animation = 'none';
  el.offsetHeight;
  el.style.animation = `fxFlash ${duration}ms ease-out forwards`;
}

function hideFx(id) {
  const el = document.getElementById(id);
  el.style.animation = 'none';
  el.style.opacity = '0';
}

function triggerShake() {
  canvasWrap.classList.remove('shake');
  void canvasWrap.offsetWidth;
  canvasWrap.classList.add('shake');
}

canvas.addEventListener('click', (e) => {
  if (gameOver) return;
  const rect = canvas.getBoundingClientRect();
  const x = (e.clientX - rect.left) * (canvas.width / rect.width);
  const y = (e.clientY - rect.top) * (canvas.height / rect.height);
  const c = Math.round((x - MARGIN_X) / CELL);
  const r = Math.round((y - MARGIN_Y) / CELL);
  if (!isInBoard(r, c)) return;

  const piece = board[r][c];

  if (selected) {
    const move = legalMoves.find(m => m.r === r && m.c === c);
    if (move) {
      makeMove(selected.r, selected.c, r, c);
      selected = null;
      legalMoves = [];
      checkGameEnd();
      return;
    }
    if (piece && piece.color === currentPlayer) {
      selected = { r, c };
      legalMoves = getLegalMovesFiltered(r, c);
      draw();
      return;
    }
    selected = null;
    legalMoves = [];
    draw();
    return;
  }

  if (piece && piece.color === currentPlayer) {
    selected = { r, c };
    legalMoves = getLegalMovesFiltered(r, c);
    draw();
  }
});

function makeMove(fr, fc, tr, tc) {
  const moved = board[fr][fc];
  const captured = board[tr][tc];
  history.push({ fr, fc, tr, tc, moved, captured, player: currentPlayer });

  addInkTrailEffect(fr, fc, tr, tc, moved.color);
  if (captured) {
    addCaptureEffect(tr, tc, moved.color);
    triggerShake();
  }

  board[tr][tc] = moved;
  board[fr][fc] = null;
  if (captured) {
    if (currentPlayer === RED) capturedByRed.push(captured.type);
    else capturedByBlack.push(captured.type);
  }
  currentPlayer = currentPlayer === RED ? BLACK : RED;
  updateUI();
  triggerAnimation();
}

function undo() {
  if (history.length === 0) return;
  const last = history.pop();
  board[last.fr][last.fc] = last.moved;
  board[last.tr][last.tc] = last.captured;
  if (last.captured) {
    if (last.player === RED) capturedByRed.pop();
    else capturedByBlack.pop();
  }
  currentPlayer = last.player;
  gameOver = false;
  selected = null;
  legalMoves = [];
  effects = [];
  checkAnimStart = 0;
  checkKingPos = null;
  hideFx('fxCheck');
  hideFx('fxMate');
  updateUI();
  draw();
}

function checkGameEnd() {
  const opp = currentPlayer === RED ? BLACK : RED;
  const oppKing = findKing(opp);
  const statusEl = document.getElementById('status');
  if (!oppKing) {
    gameOver = true;
    const winner = currentPlayer === RED ? '红方' : '黑方';
    statusEl.textContent = winner + ' 获胜 · 将军抽帅';
    addCheckmateEffect(oppKing?.r ?? 5, oppKing?.c ?? 4);
    showFx('fxMate', 2000);
    triggerShake();
    return;
  }
  if (!hasAnyLegalMove(currentPlayer)) {
    gameOver = true;
    const inCheck = isKingInCheck(currentPlayer);
    if (inCheck) {
      statusEl.textContent = '绝杀 · ' + (opp === RED ? '红方' : '黑方') + ' 获胜';
      const king = findKing(currentPlayer);
      if (king) {
        addCheckmateEffect(king.r, king.c);
      }
      showFx('fxMate', 2000);
      triggerShake();
    } else {
      statusEl.textContent = '和棋 · 困毙';
    }
    return;
  }
  if (isKingInCheck(currentPlayer)) {
    statusEl.textContent = '将 军';
    showFx('fxCheck', 1500);
  } else {
    statusEl.textContent = '';
  }
}

function updateUI() {
  const ind = document.getElementById('turnIndicator');
  if (gameOver) {
    ind.textContent = '游戏结束';
    ind.className = 'turn-indicator';
  } else if (currentPlayer === RED) {
    ind.textContent = '红方走棋';
    ind.className = 'turn-indicator turn-red';
  } else {
    ind.textContent = '黑方走棋';
    ind.className = 'turn-indicator turn-black';
  }
  document.getElementById('capturedByRed').textContent = capturedByRed.map(p => PIECES.black[p] || '?').join('');
  document.getElementById('capturedByBlack').textContent = capturedByBlack.map(p => PIECES.red[p] || '?').join('');
}

document.getElementById('restartBtn').addEventListener('click', initBoard);
document.getElementById('undoBtn').addEventListener('click', undo);

initBoard();
</script>
</body>
</html>

随后发现了一个问题,水墨拖尾特效与棋子移动并不匹配:

问题在于棋子是一瞬间移到终点的,而拖尾则是随后缓慢出现的

又写了100多行

这下算是完成了:

复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>中国象棋 · 水墨特效版</title>
<style>
  * { box-sizing: border-box; margin: 0; padding: 0; }
  body {
    font-family: "KaiTi", "STKaiti", "楷体", serif;
    background: #e8e4dc;
    background-image:
      radial-gradient(circle at 20% 30%, rgba(180,160,130,0.15) 0%, transparent 50%),
      radial-gradient(circle at 80% 70%, rgba(160,140,110,0.1) 0%, transparent 50%),
      radial-gradient(circle at 50% 50%, rgba(200,190,170,0.2) 0%, transparent 70%);
    min-height: 100vh;
    display: flex;
    flex-direction: column;
    align-items: center;
    padding: 30px 20px;
    color: #2c2c2c;
  }
  h1 {
    font-size: 38px;
    margin-bottom: 15px;
    letter-spacing: 12px;
    color: #1a1a1a;
    font-weight: normal;
    text-shadow: 1px 1px 0 rgba(0,0,0,0.08);
    position: relative;
  }
  h1::after {
    content: '';
    display: block;
    width: 60%;
    height: 2px;
    margin: 8px auto 0;
    background: linear-gradient(90deg, transparent, #555, transparent);
  }
  .info {
    display: flex;
    gap: 30px;
    margin-bottom: 20px;
    align-items: center;
    font-size: 18px;
  }
  .turn-indicator {
    padding: 10px 24px;
    border-radius: 4px;
    font-weight: bold;
    transition: all 0.3s;
    border: 1px solid #333;
    background: #f5f1ea;
    color: #2c2c2c;
    letter-spacing: 2px;
  }
  .turn-red { background: #c0392b; color: #f5f1ea; border-color: #8b2a1e; }
  .turn-black { background: #2c2c2c; color: #f5f1ea; border-color: #000; }
  button {
    background: #f5f1ea;
    color: #2c2c2c;
    border: 1px solid #333;
    padding: 10px 24px;
    border-radius: 4px;
    cursor: pointer;
    font-size: 16px;
    font-family: inherit;
    font-weight: bold;
    letter-spacing: 2px;
    transition: all 0.3s;
  }
  button:hover {
    background: #2c2c2c;
    color: #f5f1ea;
    transform: translateY(-2px);
  }
  .canvas-wrap {
    position: relative;
  }
  canvas {
    background: #f5f1ea;
    border: 1px solid #333;
    border-radius: 2px;
    box-shadow:
      0 0 0 8px #f5f1ea,
      0 0 0 9px #333,
      0 15px 40px rgba(0,0,0,0.3);
    cursor: pointer;
  }
  .status {
    margin-top: 18px;
    font-size: 20px;
    min-height: 28px;
    color: #c0392b;
    font-weight: bold;
    letter-spacing: 3px;
  }
  .captured {
    margin-top: 12px;
    display: flex;
    gap: 40px;
    font-size: 16px;
    color: #555;
  }
  .captured div { min-height: 30px; }
  .captured span {
    display: inline-block;
    margin: 0 3px;
    font-size: 22px;
    font-family: "KaiTi", "STKaiti", serif;
  }
  .fx-text {
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    pointer-events: none;
    font-family: "KaiTi", "STKaiti", "楷体", serif;
    font-weight: bold;
    opacity: 0;
    z-index: 10;
  }
  .fx-check {
    font-size: 72px;
    color: #c0392b;
    letter-spacing: 20px;
    text-shadow: 0 0 20px rgba(192, 57, 43, 0.6);
  }
  .fx-mate {
    font-size: 96px;
    color: #8b0000;
    letter-spacing: 30px;
    text-shadow: 0 0 30px rgba(139, 0, 0, 0.8), 0 0 60px rgba(192, 57, 43, 0.5);
  }
  @keyframes fxFlash {
    0%   { opacity: 0; transform: translate(-50%, -50%) scale(0.3); }
    30%  { opacity: 1; transform: translate(-50%, -50%) scale(1.2); }
    70%  { opacity: 1; transform: translate(-50%, -50%) scale(1); }
    100% { opacity: 0; transform: translate(-50%, -50%) scale(1.5); }
  }
  @keyframes fxShake {
    0%, 100% { transform: translate(0, 0); }
    10%  { transform: translate(-6px, -4px); }
    20%  { transform: translate(5px, 3px); }
    30%  { transform: translate(-4px, 5px); }
    40%  { transform: translate(6px, -3px); }
    50%  { transform: translate(-5px, 4px); }
    60%  { transform: translate(3px, -5px); }
    70%  { transform: translate(-3px, 3px); }
    80%  { transform: translate(4px, -2px); }
    90%  { transform: translate(-2px, 2px); }
  }
  .shake { animation: fxShake 0.5s ease-in-out; }
</style>
</head>
<body>
  <h1>中 國 象 棋</h1>
  <div class="info">
    <div class="turn-indicator" id="turnIndicator">红方走棋</div>
    <button id="restartBtn">重 新 开 始</button>
    <button id="undoBtn">悔 棋</button>
  </div>
  <div class="canvas-wrap" id="canvasWrap">
    <canvas id="board" width="540" height="600"></canvas>
    <div class="fx-text fx-check" id="fxCheck">将 軍</div>
    <div class="fx-text fx-mate" id="fxMate">绝 殺</div>
  </div>
  <div class="status" id="status"></div>
  <div class="captured">
    <div>红方被吃 · <span id="capturedByRed"></span></div>
    <div>黑方被吃 · <span id="capturedByBlack"></span></div>
  </div>

<script>
const CELL = 60;
const MARGIN_X = 30;
const MARGIN_Y = 30;
const COLS = 9;
const ROWS = 10;

const canvas = document.getElementById('board');
const ctx = canvas.getContext('2d');
const canvasWrap = document.getElementById('canvasWrap');

const RED = 'red';
const BLACK = 'black';

const PIECES = {
  red: {
    king: '帥', advisor: '仕', elephant: '相', horse: '傌',
    chariot: '俥', cannon: '炮', pawn: '兵'
  },
  black: {
    king: '將', advisor: '士', elephant: '象', horse: '馬',
    chariot: '車', cannon: '砲', pawn: '卒'
  }
};

let board = [];
let currentPlayer = RED;
let selected = null;
let legalMoves = [];
let gameOver = false;
let history = [];
let capturedByRed = [];
let capturedByBlack = [];

let effects = [];
let animating = false;
let checkAnimStart = 0;
let checkKingPos = null;
let movingPiece = null;

function P(type, color) {
  return { type, color };
}

function initBoard() {
  board = [
    [P('chariot',BLACK),P('horse',BLACK),P('elephant',BLACK),P('advisor',BLACK),P('king',BLACK),P('advisor',BLACK),P('elephant',BLACK),P('horse',BLACK),P('chariot',BLACK)],
    [null,null,null,null,null,null,null,null,null],
    [null,P('cannon',BLACK),null,null,null,null,null,P('cannon',BLACK),null],
    [P('pawn',BLACK),null,P('pawn',BLACK),null,P('pawn',BLACK),null,P('pawn',BLACK),null,P('pawn',BLACK)],
    [null,null,null,null,null,null,null,null,null],
    [null,null,null,null,null,null,null,null,null],
    [P('pawn',RED),null,P('pawn',RED),null,P('pawn',RED),null,P('pawn',RED),null,P('pawn',RED)],
    [null,P('cannon',RED),null,null,null,null,null,P('cannon',RED),null],
    [null,null,null,null,null,null,null,null,null],
    [P('chariot',RED),P('horse',RED),P('elephant',RED),P('advisor',RED),P('king',RED),P('advisor',RED),P('elephant',RED),P('horse',RED),P('chariot',RED)]
  ];
  currentPlayer = RED;
  selected = null;
  legalMoves = [];
  gameOver = false;
  history = [];
  capturedByRed = [];
  capturedByBlack = [];
  effects = [];
  movingPiece = null;
  checkAnimStart = 0;
  checkKingPos = null;
  hideFx('fxCheck');
  hideFx('fxMate');
  updateUI();
  draw();
}

function isInBoard(r, c) {
  return r >= 0 && r < ROWS && c >= 0 && c < COLS;
}

function inPalace(r, c, color) {
  if (c < 3 || c > 5) return false;
  if (color === RED) return r >= 7 && r <= 9;
  return r >= 0 && r <= 2;
}

function onOwnSide(r, color) {
  if (color === RED) return r >= 5;
  return r <= 4;
}

function getLegalMoves(r, c) {
  const piece = board[r][c];
  if (!piece) return [];
  const color = piece.color;
  const type = piece.type;
  const moves = [];
  const addMove = (nr, nc) => {
    if (!isInBoard(nr, nc)) return;
    const target = board[nr][nc];
    if (target && target.color === color) return;
    moves.push({ r: nr, c: nc, capture: !!target });
  };

  switch (type) {
    case 'king': {
      const dirs = [[-1,0],[1,0],[0,-1],[0,1]];
      for (const [dr, dc] of dirs) {
        const nr = r + dr, nc = c + dc;
        if (inPalace(nr, nc, color)) addMove(nr, nc);
      }
      break;
    }
    case 'advisor': {
      const dirs = [[-1,-1],[-1,1],[1,-1],[1,1]];
      for (const [dr, dc] of dirs) {
        const nr = r + dr, nc = c + dc;
        if (inPalace(nr, nc, color)) addMove(nr, nc);
      }
      break;
    }
    case 'elephant': {
      const dirs = [[-2,-2],[-2,2],[2,-2],[2,2]];
      for (const [dr, dc] of dirs) {
        const nr = r + dr, nc = c + dc;
        const mr = r + dr/2, mc = c + dc/2;
        if (!isInBoard(nr, nc)) continue;
        if (!onOwnSide(nr, color)) continue;
        if (board[mr][mc]) continue;
        addMove(nr, nc);
      }
      break;
    }
    case 'horse': {
      const hm = [
        {dr:-2,dc:-1,br:-1,bc:0}, {dr:-2,dc:1,br:-1,bc:0},
        {dr:2,dc:-1,br:1,bc:0}, {dr:2,dc:1,br:1,bc:0},
        {dr:-1,dc:-2,br:0,bc:-1}, {dr:1,dc:-2,br:0,bc:-1},
        {dr:-1,dc:2,br:0,bc:1}, {dr:1,dc:2,br:0,bc:1}
      ];
      for (const m of hm) {
        const nr = r + m.dr, nc = c + m.dc;
        const lr = r + m.br, lc = c + m.bc;
        if (!isInBoard(nr, nc)) continue;
        if (board[lr][lc]) continue;
        addMove(nr, nc);
      }
      break;
    }
    case 'chariot': {
      const dirs = [[-1,0],[1,0],[0,-1],[0,1]];
      for (const [dr, dc] of dirs) {
        let nr = r + dr, nc = c + dc;
        while (isInBoard(nr, nc)) {
          const target = board[nr][nc];
          if (!target) {
            moves.push({ r: nr, c: nc, capture: false });
          } else {
            if (target.color !== color) {
              moves.push({ r: nr, c: nc, capture: true });
            }
            break;
          }
          nr += dr; nc += dc;
        }
      }
      break;
    }
    case 'cannon': {
      const dirs = [[-1,0],[1,0],[0,-1],[0,1]];
      for (const [dr, dc] of dirs) {
        let nr = r + dr, nc = c + dc;
        let jumped = false;
        while (isInBoard(nr, nc)) {
          const target = board[nr][nc];
          if (!jumped) {
            if (!target) {
              moves.push({ r: nr, c: nc, capture: false });
            } else {
              jumped = true;
            }
          } else {
            if (target) {
              if (target.color !== color) {
                moves.push({ r: nr, c: nc, capture: true });
              }
              break;
            }
          }
          nr += dr; nc += dc;
        }
      }
      break;
    }
    case 'pawn': {
      const forward = color === RED ? -1 : 1;
      const nr = r + forward;
      if (isInBoard(nr, c)) addMove(nr, c);
      if (!onOwnSide(r, color)) {
        if (isInBoard(r, c - 1)) addMove(r, c - 1);
        if (isInBoard(r, c + 1)) addMove(r, c + 1);
      }
      break;
    }
  }
  return moves;
}

function findKing(color) {
  for (let r = 0; r < ROWS; r++) {
    for (let c = 0; c < COLS; c++) {
      if (board[r][c] && board[r][c].type === 'king' && board[r][c].color === color) return { r, c };
    }
  }
  return null;
}

function kingsFacing() {
  const rk = findKing(RED);
  const bk = findKing(BLACK);
  if (!rk || !bk || rk.c !== bk.c) return false;
  const col = rk.c;
  const minR = Math.min(rk.r, bk.r);
  const maxR = Math.max(rk.r, bk.r);
  for (let r = minR + 1; r < maxR; r++) {
    if (board[r][col]) return false;
  }
  return true;
}

function getLegalMovesFiltered(r, c) {
  const moves = getLegalMoves(r, c);
  return moves.filter(m => {
    const captured = board[m.r][m.c];
    const moving = board[r][c];
    board[m.r][m.c] = moving;
    board[r][c] = null;
    const inCheck = isKingInCheck(moving.color) || kingsFacing();
    board[r][c] = moving;
    board[m.r][m.c] = captured;
    return !inCheck;
  });
}

function isKingInCheck(color) {
  const king = findKing(color);
  if (!king) return true;
  const oppColor = color === RED ? BLACK : RED;
  for (let r = 0; r < ROWS; r++) {
    for (let c = 0; c < COLS; c++) {
      if (board[r][c] && board[r][c].color === oppColor) {
        const moves = getLegalMoves(r, c);
        if (moves.some(m => m.r === king.r && m.c === king.c)) return true;
      }
    }
  }
  return false;
}

function hasAnyLegalMove(color) {
  for (let r = 0; r < ROWS; r++) {
    for (let c = 0; c < COLS; c++) {
      if (board[r][c] && board[r][c].color === color) {
        if (getLegalMovesFiltered(r, c).length > 0) return true;
      }
    }
  }
  return false;
}

function drawBoard() {
  const grad = ctx.createLinearGradient(0, 0, canvas.width, canvas.height);
  grad.addColorStop(0, '#f8f4ec');
  grad.addColorStop(0.5, '#f0ebe0');
  grad.addColorStop(1, '#ede6d8');
  ctx.fillStyle = grad;
  ctx.fillRect(0, 0, canvas.width, canvas.height);

  ctx.strokeStyle = '#1a1a1a';
  ctx.lineWidth = 1.5;

  for (let r = 0; r < ROWS; r++) {
    ctx.beginPath();
    ctx.moveTo(MARGIN_X, MARGIN_Y + r * CELL);
    ctx.lineTo(MARGIN_X + (COLS - 1) * CELL, MARGIN_Y + r * CELL);
    ctx.stroke();
  }

  for (let c = 0; c < COLS; c++) {
    if (c === 0 || c === COLS - 1) {
      ctx.beginPath();
      ctx.moveTo(MARGIN_X + c * CELL, MARGIN_Y);
      ctx.lineTo(MARGIN_X + c * CELL, MARGIN_Y + (ROWS - 1) * CELL);
      ctx.stroke();
    } else {
      ctx.beginPath();
      ctx.moveTo(MARGIN_X + c * CELL, MARGIN_Y);
      ctx.lineTo(MARGIN_X + c * CELL, MARGIN_Y + 4 * CELL);
      ctx.stroke();
      ctx.beginPath();
      ctx.moveTo(MARGIN_X + c * CELL, MARGIN_Y + 5 * CELL);
      ctx.lineTo(MARGIN_X + c * CELL, MARGIN_Y + 9 * CELL);
      ctx.stroke();
    }
  }

  ctx.beginPath();
  ctx.moveTo(MARGIN_X, MARGIN_Y + 4 * CELL);
  ctx.lineTo(MARGIN_X + (COLS - 1) * CELL, MARGIN_Y + 4 * CELL);
  ctx.moveTo(MARGIN_X, MARGIN_Y + 5 * CELL);
  ctx.lineTo(MARGIN_X + (COLS - 1) * CELL, MARGIN_Y + 5 * CELL);
  ctx.stroke();

  ctx.beginPath();
  ctx.moveTo(MARGIN_X + 3 * CELL, MARGIN_Y);
  ctx.lineTo(MARGIN_X + 5 * CELL, MARGIN_Y + 2 * CELL);
  ctx.moveTo(MARGIN_X + 5 * CELL, MARGIN_Y);
  ctx.lineTo(MARGIN_X + 3 * CELL, MARGIN_Y + 2 * CELL);
  ctx.moveTo(MARGIN_X + 3 * CELL, MARGIN_Y + 7 * CELL);
  ctx.lineTo(MARGIN_X + 5 * CELL, MARGIN_Y + 9 * CELL);
  ctx.moveTo(MARGIN_X + 5 * CELL, MARGIN_Y + 7 * CELL);
  ctx.lineTo(MARGIN_X + 3 * CELL, MARGIN_Y + 9 * CELL);
  ctx.stroke();

  ctx.fillStyle = '#1a1a1a';
  ctx.font = 'bold 26px "KaiTi", "STKaiti", "楷体", serif';
  ctx.textAlign = 'center';
  ctx.textBaseline = 'middle';
  ctx.fillText('楚 河', MARGIN_X + 1.5 * CELL, MARGIN_Y + 4.5 * CELL);
  ctx.fillText('漢 界', MARGIN_X + 6.5 * CELL, MARGIN_Y + 4.5 * CELL);

  drawStarPoints();
}

function drawStarPoints() {
  const points = [
    [2,1],[2,7],[7,1],[7,7],
    [3,0],[3,2],[3,4],[3,6],[3,8],
    [6,0],[6,2],[6,4],[6,6],[6,8]
  ];
  for (const [r, c] of points) {
    drawStar(r, c);
  }
}

function drawStar(r, c) {
  const x = MARGIN_X + c * CELL;
  const y = MARGIN_Y + r * CELL;
  const s = 6;
  const g = 5;
  ctx.strokeStyle = '#1a1a1a';
  ctx.lineWidth = 1.2;
  const dirs = [];
  if (c > 0) dirs.push([-1,-1],[-1,1]);
  if (c < COLS - 1) dirs.push([1,-1],[1,1]);
  for (const [dx, dy] of dirs) {
    ctx.beginPath();
    ctx.moveTo(x + dx * g, y + dy * g);
    ctx.lineTo(x + dx * (g + s), y + dy * g);
    ctx.stroke();
    ctx.beginPath();
    ctx.moveTo(x + dx * g, y + dy * g);
    ctx.lineTo(x + dx * g, y + dy * (g + s));
    ctx.stroke();
  }
}

function drawPiece(r, c, type, color, highlight) {
  const x = MARGIN_X + c * CELL;
  const y = MARGIN_Y + r * CELL;
  drawPieceAtXY(x, y, type, color, highlight, 1);
}

function drawPieceAtXY(x, y, type, color, highlight, scale) {
  const radius = 26 * scale;

  if (highlight) {
    ctx.beginPath();
    ctx.arc(x, y, radius + 5, 0, Math.PI * 2);
    ctx.fillStyle = 'rgba(192, 57, 43, 0.25)';
    ctx.fill();
  }

  const inkGrad = ctx.createRadialGradient(x - 3, y - 3, 2, x, y, radius);
  inkGrad.addColorStop(0, '#e8e0d4');
  inkGrad.addColorStop(0.6, '#d4c8b4');
  inkGrad.addColorStop(1, '#b8a88c');
  ctx.beginPath();
  ctx.arc(x, y, radius, 0, Math.PI * 2);
  ctx.fillStyle = inkGrad;
  ctx.fill();

  ctx.beginPath();
  ctx.arc(x, y, radius, 0, Math.PI * 2);
  ctx.strokeStyle = '#1a1a1a';
  ctx.lineWidth = 2;
  ctx.stroke();

  ctx.beginPath();
  ctx.arc(x, y, radius - 4, 0, Math.PI * 2);
  ctx.strokeStyle = color === RED ? '#c0392b' : '#1a1a1a';
  ctx.lineWidth = 1;
  ctx.stroke();

  ctx.fillStyle = color === RED ? '#c0392b' : '#1a1a1a';
  ctx.font = `bold ${28 * scale}px "KaiTi", "STKaiti", "楷体", serif`;
  ctx.textAlign = 'center';
  ctx.textBaseline = 'middle';
  ctx.fillText(PIECES[color][type], x, y + 1);
}

function draw() {
  drawBoard();

  for (const move of legalMoves) {
    const x = MARGIN_X + move.c * CELL;
    const y = MARGIN_Y + move.r * CELL;
    if (move.capture) {
      ctx.beginPath();
      ctx.arc(x, y, 30, 0, Math.PI * 2);
      ctx.strokeStyle = 'rgba(192, 57, 43, 0.7)';
      ctx.lineWidth = 2.5;
      ctx.setLineDash([4, 3]);
      ctx.stroke();
      ctx.setLineDash([]);
    } else {
      ctx.beginPath();
      ctx.arc(x, y, 6, 0, Math.PI * 2);
      ctx.fillStyle = 'rgba(26, 26, 26, 0.5)';
      ctx.fill();
    }
  }

  for (let r = 0; r < ROWS; r++) {
    for (let c = 0; c < COLS; c++) {
      if (board[r][c] && (!movingPiece || (movingPiece.toR !== r || movingPiece.toC !== c) && (movingPiece.fromR !== r || movingPiece.fromC !== c))) {
        const p = board[r][c];
        drawPiece(r, c, p.type, p.color, selected && selected.r === r && selected.c === c);
      }
    }
  }

  if (movingPiece) {
    const now = performance.now();
    const progress = Math.min(1, (now - movingPiece.startTime) / movingPiece.duration);
    const easeProgress = progress < 0.5 ? 2 * progress * progress : 1 - Math.pow(-2 * progress + 2, 2) / 2;

    if (movingPiece.captured) {
      const tcx = MARGIN_X + movingPiece.toC * CELL;
      const tcy = MARGIN_Y + movingPiece.toR * CELL;
      const capFade = Math.max(0, 1 - progress * 2.5);
      if (capFade > 0) {
        ctx.save();
        ctx.globalAlpha = capFade;
        drawPiece(movingPiece.toR, movingPiece.toC, movingPiece.captured.type, movingPiece.captured.color, false);
        ctx.restore();
      }
    }

    const sx = MARGIN_X + movingPiece.fromC * CELL;
    const sy = MARGIN_Y + movingPiece.fromR * CELL;
    const tx = MARGIN_X + movingPiece.toC * CELL;
    const ty = MARGIN_Y + movingPiece.toR * CELL;
    const ix = sx + (tx - sx) * easeProgress;
    const iy = sy + (ty - sy) * easeProgress;

    const angle = Math.atan2(ty - sy, tx - sx);
    ctx.save();
    ctx.translate(ix, iy);
    ctx.rotate(angle * 0.15 * (1 - easeProgress));
    ctx.translate(-ix, -iy);
    drawPieceAtXY(ix, iy, movingPiece.piece.type, movingPiece.piece.color, false, 1 + (1 - easeProgress) * 0.1);
    ctx.restore();

    if (easeProgress < 0.9) {
      const shadowGrad = ctx.createRadialGradient(ix, iy + 20, 0, ix, iy + 20, 25);
      shadowGrad.addColorStop(0, 'rgba(0,0,0,0.25)');
      shadowGrad.addColorStop(1, 'rgba(0,0,0,0)');
      ctx.beginPath();
      ctx.arc(ix, iy + 20, 25, 0, Math.PI * 2);
      ctx.fillStyle = shadowGrad;
      ctx.fill();
    }
  }

  const redKing = findKing(RED);
  const blackKing = findKing(BLACK);
  const now = performance.now();

  if (redKing && isKingInCheck(RED)) {
    if (!checkKingPos || checkKingPos.color !== RED || checkKingPos.r !== redKing.r || checkKingPos.c !== redKing.c) {
      checkAnimStart = now;
      checkKingPos = { r: redKing.r, c: redKing.c, color: RED };
    }
    const elapsed = now - checkAnimStart;
    const pulse = 0.5 + 0.5 * Math.sin(elapsed / 150);
    const x = MARGIN_X + redKing.c * CELL;
    const y = MARGIN_Y + redKing.r * CELL;
    const r = 32 + pulse * 8;
    const grad = ctx.createRadialGradient(x, y, 20, x, y, r);
    grad.addColorStop(0, `rgba(192, 57, 43, ${0.15 + pulse * 0.25})`);
    grad.addColorStop(0.7, `rgba(192, 57, 43, ${0.3 + pulse * 0.3})`);
    grad.addColorStop(1, 'rgba(192, 57, 43, 0)');
    ctx.beginPath();
    ctx.arc(x, y, r, 0, Math.PI * 2);
    ctx.fillStyle = grad;
    ctx.fill();
    ctx.beginPath();
    ctx.arc(x, y, 34 + pulse * 4, 0, Math.PI * 2);
    ctx.strokeStyle = `rgba(192, 57, 43, ${0.6 + pulse * 0.4})`;
    ctx.lineWidth = 2.5;
    ctx.stroke();
  } else {
    if (checkKingPos && checkKingPos.color === RED) { checkAnimStart = 0; checkKingPos = null; }
  }

  if (blackKing && isKingInCheck(BLACK)) {
    if (!checkKingPos || checkKingPos.color !== BLACK || checkKingPos.r !== blackKing.r || checkKingPos.c !== blackKing.c) {
      checkAnimStart = now;
      checkKingPos = { r: blackKing.r, c: blackKing.c, color: BLACK };
    }
    const elapsed = now - checkAnimStart;
    const pulse = 0.5 + 0.5 * Math.sin(elapsed / 150);
    const x = MARGIN_X + blackKing.c * CELL;
    const y = MARGIN_Y + blackKing.r * CELL;
    const r = 32 + pulse * 8;
    const grad = ctx.createRadialGradient(x, y, 20, x, y, r);
    grad.addColorStop(0, `rgba(192, 57, 43, ${0.15 + pulse * 0.25})`);
    grad.addColorStop(0.7, `rgba(192, 57, 43, ${0.3 + pulse * 0.3})`);
    grad.addColorStop(1, 'rgba(192, 57, 43, 0)');
    ctx.beginPath();
    ctx.arc(x, y, r, 0, Math.PI * 2);
    ctx.fillStyle = grad;
    ctx.fill();
    ctx.beginPath();
    ctx.arc(x, y, 34 + pulse * 4, 0, Math.PI * 2);
    ctx.strokeStyle = `rgba(192, 57, 43, ${0.6 + pulse * 0.4})`;
    ctx.lineWidth = 2.5;
    ctx.stroke();
  } else {
    if (checkKingPos && checkKingPos.color === BLACK) { checkAnimStart = 0; checkKingPos = null; }
  }
}

function drawEffects() {
  const now = performance.now();
  const remaining = [];

  for (const e of effects) {
    const elapsed = now - e.startTime;
    const progress = elapsed / e.duration;
    if (progress >= 1) continue;

    if (e.type === 'capture') {
      drawCaptureEffect(e, progress);
      remaining.push(e);
    } else if (e.type === 'checkmate') {
      drawCheckmateEffect(e, progress);
      remaining.push(e);
    } else if (e.type === 'inkTrail') {
      drawInkTrailEffect(e, progress);
      remaining.push(e);
    }
  }

  effects = remaining;
}

function drawCaptureEffect(e, p) {
  const x = e.x, y = e.y;
  const inkColor2 = 'rgba(40, 40, 40,';

  for (let i = 0; i < 3; i++) {
    const delay = i * 0.08;
    const pp = Math.max(0, Math.min(1, (p - delay) / (1 - delay)));
    if (pp <= 0) continue;
    const radius = 10 + pp * 55;
    const alpha = (1 - pp) * 0.8;
    ctx.beginPath();
    ctx.arc(x, y, radius, 0, Math.PI * 2);
    ctx.strokeStyle = inkColor2 + alpha.toFixed(2) + ')';
    ctx.lineWidth = 3 - pp * 2.5;
    ctx.stroke();
  }

  for (let i = 0; i < 14; i++) {
    const angle = (i / 14) * Math.PI * 2 + p * 0.8;
    const dist = p * 55;
    const px = x + Math.cos(angle) * dist;
    const py = y + Math.sin(angle) * dist;
    const size = (1 - p) * 5 + 1;
    ctx.beginPath();
    ctx.arc(px, py, size, 0, Math.PI * 2);
    ctx.fillStyle = inkColor2 + ((1 - p) * 0.7).toFixed(2) + ')';
    ctx.fill();
  }

  const coreGrad = ctx.createRadialGradient(x, y, 0, x, y, 30 * (1 - p * 0.6));
  coreGrad.addColorStop(0, inkColor2 + (0.5 * (1 - p)).toFixed(2) + ')');
  coreGrad.addColorStop(1, inkColor2 + '0)');
  ctx.beginPath();
  ctx.arc(x, y, 30 * (1 - p * 0.6), 0, Math.PI * 2);
  ctx.fillStyle = coreGrad;
  ctx.fill();
}

function drawCheckmateEffect(e, p) {
  const x = e.x, y = e.y;

  ctx.save();
  ctx.globalAlpha = Math.min(1, p * 3);

  for (let i = 0; i < 5; i++) {
    const delay = i * 0.06;
    const pp = Math.max(0, Math.min(1, (p - delay) / (1 - delay)));
    if (pp <= 0) continue;
    const radius = 15 + pp * 200;
    const alpha = (1 - pp) * 0.6;
    ctx.beginPath();
    ctx.arc(x, y, radius, 0, Math.PI * 2);
    ctx.strokeStyle = `rgba(30, 30, 30, ${alpha.toFixed(2)})`;
    ctx.lineWidth = 4 - pp * 3;
    ctx.stroke();
  }

  for (let i = 0; i < 5; i++) {
    const angle = (i / 5) * Math.PI * 2 - Math.PI / 2;
    const dist = Math.min(300, p * 600);
    ctx.beginPath();
    ctx.moveTo(x, y);
    ctx.lineTo(x + Math.cos(angle) * dist, y + Math.sin(angle) * dist);
    ctx.strokeStyle = `rgba(30, 30, 30, ${(1 - p) * 0.5})`;
    ctx.lineWidth = 2;
    ctx.stroke();
  }

  for (let i = 0; i < 30; i++) {
    const angle = (i / 30) * Math.PI * 2 + p * 1.5;
    const dist = p * 80 + Math.sin(i * 3 + p * 10) * 15;
    const px = x + Math.cos(angle) * dist;
    const py = y + Math.sin(angle) * dist;
    const size = (1 - p) * 8 + 2;
    const gray = 30 + (i % 3) * 25;
    ctx.beginPath();
    ctx.arc(px, py, size, 0, Math.PI * 2);
    ctx.fillStyle = `rgba(${gray}, ${gray}, ${gray}, ${(1 - p) * 0.8})`;
    ctx.fill();
  }

  const flashGrad = ctx.createRadialGradient(x, y, 0, x, y, 250 * Math.max(0.3, 1 - p));
  flashGrad.addColorStop(0, `rgba(255, 255, 255, ${Math.max(0, (0.6 - p * 0.6))})`);
  flashGrad.addColorStop(0.5, 'rgba(255, 255, 255, 0)');
  ctx.beginPath();
  ctx.arc(x, y, 250, 0, Math.PI * 2);
  ctx.fillStyle = flashGrad;
  ctx.fill();

  ctx.restore();
}

function easeInOut(t) {
  return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
}

function drawInkTrailEffect(e, p) {
  const easeP = easeInOut(Math.min(1, p));
  const x = e.sx + (e.dx - e.sx) * easeP;
  const y = e.sy + (e.dy - e.sy) * easeP;
  const inkColor = 'rgba(40, 40, 40,';
  const dx = e.dx - e.sx;
  const dy = e.dy - e.sy;
  const angle = Math.atan2(dy, dx);
  const splatters = e.splatters;

  for (let i = 0; i < 8; i++) {
    const rawP = Math.max(0, p - i * 0.035);
    const pp = easeInOut(rawP);
    const tx = e.sx + dx * pp;
    const ty = e.sy + dy * pp;
    const alpha = (1 - rawP) * 0.5;
    const size = (1 - rawP) * 20 + 4;
    ctx.beginPath();
    ctx.arc(tx, ty, size, 0, Math.PI * 2);
    ctx.fillStyle = inkColor + alpha.toFixed(2) + ')';
    ctx.fill();
  }

  for (let i = 0; i < 5; i++) {
    const rawP = Math.max(0, p - i * 0.06);
    const pp = easeInOut(rawP);
    const tx = e.sx + dx * pp;
    const ty = e.sy + dy * pp;
    const spread = 8 * (1 - rawP);
    const perpAngle = angle + Math.PI / 2;
    const offset = (Math.sin(i * 1.7 + rawP * 5) * spread);
    const ox = tx + Math.cos(perpAngle) * offset;
    const oy = ty + Math.sin(perpAngle) * offset;
    const size = (1 - rawP) * 10 + 2;
    const alpha = (1 - rawP) * 0.35;
    ctx.beginPath();
    ctx.arc(ox, oy, size, 0, Math.PI * 2);
    ctx.fillStyle = inkColor + alpha.toFixed(2) + ')';
    ctx.fill();
  }

  for (let i = 0; i < splatters.length; i++) {
    const sp = splatters[i];
    const progress = (p - sp.delay) / (1 - sp.delay);
    if (progress <= 0 || progress > 1) continue;
    const sx = x + Math.cos(sp.angle) * (progress * 35 + 5) + sp.offsetX * progress;
    const sy = y + Math.sin(sp.angle) * (progress * 35 + 5) + sp.offsetY * progress;
    const size = (1 - progress) * sp.maxSize + sp.minSize;
    const alpha = (1 - progress) * sp.maxAlpha;
    ctx.beginPath();
    ctx.arc(sx, sy, size, 0, Math.PI * 2);
    ctx.fillStyle = inkColor + alpha.toFixed(2) + ')';
    ctx.fill();
  }

  for (let i = 0; i < e.clusters.length; i++) {
    const cl = e.clusters[i];
    const progress = (p - cl.delay) / (1 - cl.delay);
    if (progress <= 0 || progress > 1) continue;
    const cx = x + Math.cos(cl.angle) * (progress * 40);
    const cy = y + Math.sin(cl.angle) * (progress * 40);
    const size = (1 - progress) * cl.maxSize + cl.minSize;
    const alpha = (1 - progress) * cl.maxAlpha;
    ctx.beginPath();
    ctx.arc(cx, cy, size, 0, Math.PI * 2);
    ctx.fillStyle = `rgba(${cl.gray}, ${cl.gray}, ${cl.gray}, ${alpha.toFixed(2)})`;
    ctx.fill();
  }

  const headGrad = ctx.createRadialGradient(x, y, 0, x, y, 30);
  headGrad.addColorStop(0, inkColor + '0.7)');
  headGrad.addColorStop(0.6, inkColor + '0.4)');
  headGrad.addColorStop(1, inkColor + '0)');
  ctx.beginPath();
  ctx.arc(x, y, 30, 0, Math.PI * 2);
  ctx.fillStyle = headGrad;
  ctx.fill();

  ctx.beginPath();
  ctx.arc(x, y, 22, 0, Math.PI * 2);
  ctx.fillStyle = inkColor + '0.55)';
  ctx.fill();
}

function animate() {
  if (movingPiece) {
    const now = performance.now();
    if (now - movingPiece.startTime >= movingPiece.duration) {
      finalizeMove();
    }
  }

  draw();
  drawEffects();

  if (movingPiece) {
    animating = true;
    requestAnimationFrame(animate);
    return;
  }

  const redKing = findKing(RED);
  const blackKing = findKing(BLACK);
  const redChecked = redKing && isKingInCheck(RED);
  const blackChecked = blackKing && isKingInCheck(BLACK);

  if (effects.length > 0 || redChecked || blackChecked) {
    animating = true;
    requestAnimationFrame(animate);
  } else {
    animating = false;
  }
}

function triggerAnimation() {
  if (!animating) {
    animating = true;
    requestAnimationFrame(animate);
  }
}

function getXY(r, c) {
  return { x: MARGIN_X + c * CELL, y: MARGIN_Y + r * CELL };
}

function addCaptureEffect(r, c, color) {
  const { x, y } = getXY(r, c);
  effects.push({
    type: 'capture',
    x, y,
    color,
    startTime: performance.now(),
    duration: 800
  });
}

function addInkTrailEffect(sr, sc, dr, dc, color, duration) {
  const { x: sx, y: sy } = getXY(sr, sc);
  const { x: dx, y: dy } = getXY(dr, dc);
  const moveAngle = Math.atan2(dy - sy, dx - sx);

  const splatters = [];
  for (let i = 0; i < 12; i++) {
    const spreadAngle = moveAngle + Math.PI + (Math.random() - 0.5) * 1.8;
    splatters.push({
      angle: spreadAngle,
      delay: i * 0.07,
      offsetX: (Math.random() - 0.5) * 10,
      offsetY: (Math.random() - 0.5) * 10,
      maxSize: 6 + Math.random() * 5,
      minSize: 1 + Math.random() * 1.5,
      maxAlpha: 0.5 + Math.random() * 0.3
    });
  }

  const clusters = [];
  for (let i = 0; i < 4; i++) {
    const clusterAngle = moveAngle + Math.PI + (i / 4) * 0.8 - 0.4 + (Math.random() - 0.5) * 0.3;
    clusters.push({
      angle: clusterAngle,
      delay: i * 0.15,
      gray: 25 + Math.floor(Math.random() * 30),
      maxSize: 4 + Math.random() * 4,
      minSize: 0.8 + Math.random() * 1.2,
      maxAlpha: 0.4 + Math.random() * 0.3
    });
  }

  effects.push({
    type: 'inkTrail',
    sx, sy, dx, dy,
    color,
    splatters,
    clusters,
    startTime: performance.now(),
    duration: duration
  });
}

function addCheckmateEffect(r, c) {
  const { x, y } = getXY(r, c);
  effects.push({
    type: 'checkmate',
    x, y,
    startTime: performance.now(),
    duration: 2000
  });
}

function showFx(id, duration) {
  const el = document.getElementById(id);
  el.style.animation = 'none';
  el.offsetHeight;
  el.style.animation = `fxFlash ${duration}ms ease-out forwards`;
}

function hideFx(id) {
  const el = document.getElementById(id);
  el.style.animation = 'none';
  el.style.opacity = '0';
}

function triggerShake() {
  canvasWrap.classList.remove('shake');
  void canvasWrap.offsetWidth;
  canvasWrap.classList.add('shake');
}

canvas.addEventListener('click', (e) => {
  if (gameOver || movingPiece) return;
  const rect = canvas.getBoundingClientRect();
  const x = (e.clientX - rect.left) * (canvas.width / rect.width);
  const y = (e.clientY - rect.top) * (canvas.height / rect.height);
  const c = Math.round((x - MARGIN_X) / CELL);
  const r = Math.round((y - MARGIN_Y) / CELL);
  if (!isInBoard(r, c)) return;

  const piece = board[r][c];

  if (selected) {
    const move = legalMoves.find(m => m.r === r && m.c === c);
    if (move) {
      makeMove(selected.r, selected.c, r, c);
      selected = null;
      legalMoves = [];
      return;
    }
    if (piece && piece.color === currentPlayer) {
      selected = { r, c };
      legalMoves = getLegalMovesFiltered(r, c);
      draw();
      return;
    }
    selected = null;
    legalMoves = [];
    draw();
    return;
  }

  if (piece && piece.color === currentPlayer) {
    selected = { r, c };
    legalMoves = getLegalMovesFiltered(r, c);
    draw();
  }
});

function makeMove(fr, fc, tr, tc) {
  const moved = board[fr][fc];
  const captured = board[tr][tc];
  history.push({ fr, fc, tr, tc, moved, captured, player: currentPlayer });

  board[fr][fc] = null;
  board[tr][tc] = null;

  const duration = 380;
  movingPiece = {
    piece: moved,
    fromR: fr, fromC: fc,
    toR: tr, toC: tc,
    captured: captured,
    player: currentPlayer,
    startTime: performance.now(),
    duration: duration
  };

  addInkTrailEffect(fr, fc, tr, tc, moved.color, duration);

  if (captured) {
    addCaptureEffect(tr, tc, moved.color);
    triggerShake();
  }

  triggerAnimation();
}

function finalizeMove() {
  if (!movingPiece) return;
  const { piece, toR, toC, captured, player } = movingPiece;
  board[toR][toC] = piece;
  movingPiece = null;

  if (captured) {
    if (player === RED) capturedByRed.push(captured.type);
    else capturedByBlack.push(captured.type);
  }
  currentPlayer = currentPlayer === RED ? BLACK : RED;
  updateUI();
  checkGameEnd();
}

function undo() {
  if (movingPiece) {
    board[movingPiece.fromR][movingPiece.fromC] = movingPiece.piece;
    board[movingPiece.toR][movingPiece.toC] = movingPiece.captured;
    movingPiece = null;
    history.pop();
    selected = null;
    legalMoves = [];
    effects = [];
    checkAnimStart = 0;
    checkKingPos = null;
    updateUI();
    draw();
    return;
  }
  if (history.length === 0) return;
  const last = history.pop();
  board[last.fr][last.fc] = last.moved;
  board[last.tr][last.tc] = last.captured;
  if (last.captured) {
    if (last.player === RED) capturedByRed.pop();
    else capturedByBlack.pop();
  }
  currentPlayer = last.player;
  gameOver = false;
  selected = null;
  legalMoves = [];
  effects = [];
  checkAnimStart = 0;
  checkKingPos = null;
  hideFx('fxCheck');
  hideFx('fxMate');
  updateUI();
  draw();
}

function checkGameEnd() {
  const opp = currentPlayer === RED ? BLACK : RED;
  const oppKing = findKing(opp);
  const statusEl = document.getElementById('status');
  if (!oppKing) {
    gameOver = true;
    const winner = currentPlayer === RED ? '红方' : '黑方';
    statusEl.textContent = winner + ' 获胜 · 将军抽帅';
    addCheckmateEffect(oppKing?.r ?? 5, oppKing?.c ?? 4);
    showFx('fxMate', 2000);
    triggerShake();
    return;
  }
  if (!hasAnyLegalMove(currentPlayer)) {
    gameOver = true;
    const inCheck = isKingInCheck(currentPlayer);
    if (inCheck) {
      statusEl.textContent = '绝杀 · ' + (opp === RED ? '红方' : '黑方') + ' 获胜';
      const king = findKing(currentPlayer);
      if (king) {
        addCheckmateEffect(king.r, king.c);
      }
      showFx('fxMate', 2000);
      triggerShake();
    } else {
      statusEl.textContent = '和棋 · 困毙';
    }
    return;
  }
  if (isKingInCheck(currentPlayer)) {
    statusEl.textContent = '将 军';
    showFx('fxCheck', 1500);
  } else {
    statusEl.textContent = '';
  }
}

function updateUI() {
  const ind = document.getElementById('turnIndicator');
  if (gameOver) {
    ind.textContent = '游戏结束';
    ind.className = 'turn-indicator';
  } else if (currentPlayer === RED) {
    ind.textContent = '红方走棋';
    ind.className = 'turn-indicator turn-red';
  } else {
    ind.textContent = '黑方走棋';
    ind.className = 'turn-indicator turn-black';
  }
  document.getElementById('capturedByRed').textContent = capturedByRed.map(p => PIECES.black[p] || '?').join('');
  document.getElementById('capturedByBlack').textContent = capturedByBlack.map(p => PIECES.red[p] || '?').join('');
}

document.getElementById('restartBtn').addEventListener('click', initBoard);
document.getElementById('undoBtn').addEventListener('click', undo);

initBoard();
</script>
</body>
</html>

保存成.txt后缀粘贴后改为.html后缀即可使用。

相关推荐
灯澜忆梦2 小时前
【基于GO的Web开发10】gin获取HTML-Form表单提交参数
前端·后端·golang·html·gin
伯远医学1 天前
RIP-seq 高分文献案例分享
java·前端·javascript·人工智能·算法·eclipse·html
人机(未转人工)1 天前
Flask + MySQL + Ollama(DeepSeek) 漏洞资产库 的网络安全资产分析平台的templates中(chat.html)渲染文件
mysql·flask·html
海兰1 天前
【应用】UUID v7 生成器 HTML 代码
css·html·css3
ly76891 天前
HTML5 从入门到工程实践:语义化、表单、多媒体、性能与项目规范详解
前端·html·html5
ClouGence2 天前
不用编程,物理老师也能用AI一键生成交互式课件
人工智能·html·aigc
winfredzhang2 天前
一个 HTML 文件的备忘录:纸间备忘(Memo on Paper)源码全解析
前端·html·导出·localstorage·备忘录·json和html
智商偏低2 天前
bindFramebuffer
前端·javascript·html
IMPYLH2 天前
HTML 的 <ins> 元素
前端·javascript·html