데모 불러오는 중…
<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: 일반 draggable이며, 그 편의 차례가 아니면disabled입니다.keyboardMode: 'droppables'를 쓰면 화살표 키가 기물을 받는 칸, 즉 합법적인 수 사이만 건너뜁니다.snapCenterToCursor는 기물을 포인터 아래 가운데에 두고, 터치하면 바로 드래그됩니다(activation).canDrag는 아무것도 비활성화하지 않고도 봇의 기물과, 코드가 두는 동안의 체스판에 손대지 못하게 합니다. 코드가 시작한 드래그에는 입력 이벤트가 없으므로 그런 드래그에는canDrag를 묻지 않습니다.- **
chess.ts**와game.ts: 드래그 앤 드롭은 전혀 없는 규칙과 게임입니다. 체크를 고려한 합법적인 수, 캐슬링, 앙파상, 프로모션, 기물 부족에 의한 무승부, 기보 표기, 실행 취소.onDrop에서 수를 두면 새 국면이 만들어지고, 오버레이는 움직인 기물 위에 안착합니다.
손에 든 기물
**PieceOverlay.vue**는 기물의 overlay 컴포넌트입니다. 모든 오버레이가 받는 props를 읽고, 그 아래
칸에 드롭하면 무슨 일이 일어날지 알려 줍니다. 거부된 칸 위(over가 있고 accepted가 false)에서는
기물이 빨갛게 변하며 이유를 보여 주고, 기물을 잡는 수 위에서는 표적과 무엇을 잡는지 보여 줍니다.
드롭이 정해지면(status: 'dropping') 힌트가 사라지고 기물이 내려앉습니다.
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로, 기물이 출발한 칸에서 그 아래 칸까지 화살표를 그립니다.
프로바이더의 haptics는 터치 드래그가 합법적인 칸에 올 때와 드롭이 거부될 때 진동하고, index.vue의
announcements는 같은 이유를 읽어 줍니다. "f7, pinned".
프로모션: 답을 기다리는 드롭
마지막 랭크 위에서는 손에 든 폰이 승진할 기물을 보여 줍니다. 칸 자체의 overOverlay
(PromotionOverlay.vue)가 기물의 오버레이를 대신합니다. 놓으면 칸의 onBeforeDrop이 promise를
반환하고, 그 옆에 있는 페이지의 일반 요소인 **PromotionPicker.vue**가 어떤 기물로 할지 묻는 동안
오버레이는 칸 위에서 기다립니다. 기물을 고르면 'accept', Escape나 Cancel은 'reject'(폰이 원래
자리로 돌아갑니다), Keep dragging은 'continue'로 답합니다. 폰이 다시 포인터를 따라가고, 다음
클릭에 드롭되며 다시 묻습니다.
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은 드롭의 result가 담긴 리스트의 SortableDropEvent를 받습니다.
대상의 overOverlay는 안착을 위해 onDrop 뒤에 한 번 더 읽힙니다. 그래서 게임(그때는 이미 퀸이 되어
있습니다)이 아니라 집을 때의 폰(active.data)을 확인합니다.
봇, 리플레이, 캐슬링: 코드가 재생하는 드래그
**autoplay.ts**는 @vue-dnd-kit/actions로 수를 둡니다. 봇의 수(bot.ts, 탐욕적인 봇),
Replay의 수, 그리고 직접 둔 것을 포함한 모든 캐슬링의 룩 이동입니다. 각각이 실제 드래그입니다.
집히고, 한 프레임씩 체스판 위를 미끄러지고, 칸의 accept와 onDrop을 거쳐 드롭되며, 직접 하는
드래그처럼 안내되고 애니메이션됩니다. 스크립트로 하는 프로모션은 승진할 기물을 함께 지정하므로 선택기가
열리지 않습니다. Escape는 다른 드래그처럼 스크립트 드래그도 취소합니다.
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는 함수입니다. 새 기물은 잡힌
칸에서 날아오고, 나머지는 자리를 내줍니다.
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 ·
접근성