文档/概览

国际象棋

一个国际象棋棋盘——合法走法会亮起,手中的棋子会说明某个格子为什么被拒绝,兵会询问要升变成什么,机器人用真实的拖拽回应。

编辑此页

本页由 AI 翻译,可能存在不准确之处。 阅读英文原文

Live

正在加载演示…

<script setup lang="ts">
import { provide } from 'vue';
import { DnDProvider, type Announcements } from '@vue-dnd-kit/core';
import Board from './Board.vue';
import { NAMES, type Piece } from './chess';
import { GAME, createGame } from './game';

// Provided above the provider: the overlays it renders read the game too.
const game = createGame();
provide(GAME, game);

// Screen readers hear pieces and squares, not ids.
const name = (data: unknown) => {
  const piece = data as Piece;
  return `${piece.color === 'w' ? 'white' : 'black'} ${NAMES[piece.kind]}`;
};
const from = (data: unknown) => (data as Piece).square;
const stays = (data: unknown) => `The ${name(data)} stays on ${from(data)}.`;

const announcements: Partial<Announcements> = {
  onDragStart: ({ active }) => {
    const keys = active.sensor === 'keyboard' ? ' Arrow keys go through its moves.' : '';
    return `Picked up the ${name(active.data)} on ${from(active.data)}.${keys}`;
  },
  // Over an illegal square, the reason the overlay shows: "f7, pinned".
  onDragOver: ({ active, over }) =>
    over ? `${over.id}${over.accepted ? '' : `, ${game.whyNot(active.id, String(over.id)).toLowerCase()}`}` : '',
  onDragEnd: ({ active, over, dropped }) => {
    if (!dropped || !over) return stays(active.data);
    const turn = game.last.value;
    const takes = turn?.captured ? `, takes the ${name(turn.captured)}` : '';
    const becomes = turn?.promotion ? ` and becomes a ${NAMES[turn.promotion]}` : '';
    return `The ${name(active.data)} moves to ${over.id}${takes}${becomes}.`;
  },
  onDragCancel: ({ active }) => stays(active.data),
  onDragResume: ({ active }) => `Still holding the ${name(active.data)}.`,
};
</script>

<template>
  <!-- Touch drags vibrate on a legal square, and when a drop is refused. -->
  <DnDProvider :announcements="announcements" :haptics="{ enter: true, refuse: true }">
    <Board />
  </DnDProvider>
</template>

看点

  • Square.vue:64 个格子中的每一个都是放置目标,它的 accept 是一个函数。拖动棋子时会询问它,所以格子只接收这枚棋子的合法走法;放到其他任何地方都会被拒绝,棋子飞回原处。合法的格子根据 isAccepting 亮起:游戏自身没有任何状态在跟踪拖拽。
  • Piece.vue:一个普通可拖拽元素,不轮到它这一方时为 disabled。keyboardMode: 'droppables' 让方向键在接收这枚棋子的格子之间跳转:也就是它的合法走法,别无其他。snapCenterToCursor 让棋子保持在指针下方居中,触摸会立即开始拖拽(activation)。canDrag 让你在代码走棋时碰不到机器人的棋子和棋盘,而不需要禁用任何东西:由代码发起的拖拽没有输入事件,所以不会为它们询问 canDrag。
  • chess.ts 和 game.ts:规则和对局,其中没有任何拖放代码:考虑将军的合法走法、王车易位、吃过路兵、升变、子力不足的和棋、棋谱记法、悔棋。onDrop 中的一步棋会生成新的局面,浮层落到被移动的棋子上。

手中的棋子

PieceOverlay.vue 是棋子的 overlay 组件。它读取每个浮层都会收到的 props,并说明放到它下方的格子上会发生什么:在被拒绝的格子上方(设置了 over,accepted 为 false),棋子变红并给出原因;在吃子的格子上方,它显示一个靶标以及要吃掉的棋子。放置一旦确定(status: 'dropping'),提示消失,棋子落定。

PieceOverlay.vue
const props = defineProps<{
  active: Active;
  over: Over | null;
  accepted: boolean;
  status: DnDStatus;
}>();

const hint = computed(() => {
  const square = props.over ? String(props.over.id) : null;
  if (props.status !== 'dragging' || !square || square === piece.value.square) return null;
  if (props.accepted) {
    const taken = game.takes(piece.value.id, square);
    return taken ? { type: 'capture', text: `Takes ${NAMES[taken.kind]}` } : null;
  }
  // "Pinned", "King in check", "Not a legal move"
  return { type: 'refused', text: game.whyNot(piece.value.id, square) };
});

Board.vue 根据 useDnD() 的 active 和 over,从棋子原来的格子到它下方的格子画一个箭头。provider 的 haptics 在触摸拖拽经过合法格子时以及放置被拒绝时振动;index.vue 中的 announcements 说出同样的原因:“f7, pinned”。

升变:等待回答的放置

在最后一横排上方,手中的兵会显示它将升变成的棋子:格子自己的 overOverlay(PromotionOverlay.vue)取代了棋子的浮层。松开时,格子的 onBeforeDrop 返回一个 promise,浮层在格子上等待,同时旁边页面上的普通元素 PromotionPicker.vue 询问要升变成哪个棋子。选择一个棋子回答 'accept',Escape 或 Cancel 回答 'reject'(兵飞回原处),Keep dragging 回答 'continue':兵重新跟随指针,下一次单击放下它并再次询问。

Square.vue
makeDroppable(el, {
  id: props.square,
  accept: ({ active }) => game.canMove(active.id, props.square),
  // Shown instead of the piece's overlay while a pawn is over the last rank.
  overOverlay: computed(() =>
    held.value?.kind === 'P' && isLastRank(props.square) ? PromotionOverlay : undefined,
  ),
  // A promise: the drop waits on the square until the picker answers.
  onBeforeDrop: ({ active }) =>
    game.promotes(active.id, props.square) ? game.askPromotion(props.square) : 'accept',
  onDrop: ({ active }) => game.move(String(active.id), props.square),
});

放置目标的 onBeforeDrop 收到普通的拖拽事件,并且只在它的 accept 接受了该拖拽时才会被询问;可排序列表的 onBeforeDrop 收到列表的 SortableDropEvent,其中带有放置的 result。目标的 overOverlay 在 onDrop 之后还会为落位再读取一次:它检查的是拿起时的兵(active.data),而不是对局,因为此时在对局中它已经是后了。

机器人、回放和王车易位:由代码执行的拖拽

autoplay.ts 用 @vue-dnd-kit/actions 走棋:机器人的走法(bot.ts,一个贪心算法)、Replay 的走法,以及每次王车易位中车的那一步,包括你的易位。每一步都是真实的拖拽:被拿起,逐帧滑过棋盘,经过格子的 accept 和 onDrop 放下,并像你的拖拽一样被播报和动画。脚本化的升变会自带升变的棋子,所以选择器不会打开;Escape 会像取消其他拖拽一样取消脚本化的拖拽。

autoplay.ts
const { startDrag } = useDnDActions();

const drag = startDrag(id); // from the piece's center
for (let frame = 1; frame <= FRAMES; frame++) {
  const t = easeOut(frame / FRAMES);
  const point = { x: start.x + (end.x - start.x) * t, y: start.y + (end.y - start.y) * t };
  if (!(await drag.moveTo(point))) return false; // cancelled meanwhile (Escape)
}
return drag.drop(); // true once it has landed, false if the square refused it

王车易位分两步走,就像手动走棋那样:王走两列,然后它的车被拖过王,所以车从不跳跃。

吃子、悔棋和新对局

被吃掉的棋子进入它这一方的 Tray.vue:一个只为 animateChanges 而使用的可排序列表(disabled,那里不能拖拽任何东西)。它的 enter 是一个函数:新棋子从它被吃掉的格子飞进来,其他棋子让出空间。

Tray.vue
makeSortableList(tray, {
  items: () => props.pieces,
  getKey: (piece) => `taken-${piece.id}`,
  disabled: true,
  animateChanges: { enter: flyIn, leave: false },
});

Undo 和 New game 不经拖拽就改变局面,所以 glide.ts 手动为它们添加动画(FLIP):每枚棋子从原来的位置(格子上或托盘中)飞到现在的位置。与机器人对弈时,Undo 会把你的走法连同它的回应一起撤销。

延伸阅读:决定放置 · 浮层 · 程序化拖拽 · 数据变化:animateChanges · 无障碍