Chess Lab #5 — Kéo-thả quân & animation di chuyển mượt
Cho người chơi vừa kéo-thả vừa click để đi quân. Tween về ô đích khi drop hợp lệ, tween về chỗ cũ khi drop sai. Mỗi nước thành công đồng bộ lại render từ chess.js để xử lý mọi flag (capture, en-passant, castling, promotion mặc định).
Mục lục
Bốn bài qua, demo của ta là một bàn cờ "đẹp nhưng câm". Bài này, mỗi nước trong đầu bạn được thực thi: kéo Tốt e2 lên e4, Mã g1 ra f3, Tượng… và cả ván cờ Sicilian mở màn nhỏ trên trang anhtu.dev. Click cũng chơi được — chọn quân, click ô đích, đi.
Mở rộng adapter: tryMove
tryMove(from: Square, to: Square, promotion: PieceSymbol = "q"): Move | null {
try {
return this.game.move({ from, to, promotion }) as Move;
} catch {
return null;
}
}
chess.js v1 ném exception khi nước không hợp lệ — try/catch quy đổi
thành null cho call site dễ dùng. promotion: 'q' mặc định — nếu Tốt
sang hàng cuối, tự phong Hậu. Bài 6 thay bằng modal cho người chơi chọn.
Bật draggable trên pieces
Trong drawPieces, sau khi tạo Text:
piece.setInteractive({ useHandCursor: true, draggable: true });
piece.setData("square", square);
this.pieces.set(square, piece);
setData("square", …) lưu ô gốc của quân ngay trên GameObject — khi sự
kiện drag bắn về ta đọc lại bằng piece.getData("square"). Tránh phải
duy trì map ngược "GameObject → Square" tay.
Đăng ký 3 listener trong create:
this.input.on("dragstart", this.handleDragStart, this);
this.input.on("drag", this.handleDrag, this);
this.input.on("dragend", this.handleDragEnd, this);
Drag handler
Đây là nơi nhiều thứ phải khớp tay đúng nhịp:
private handleDragStart(_, gameObject) {
const piece = gameObject as Phaser.GameObjects.Text;
const from = piece.getData("square") as Square;
const owner = this.chess.pieceAt(from);
if (!owner || owner.color !== this.chess.turn()) {
// Không phải lượt — huỷ drag.
this.input.setDragState(_, 0);
return;
}
this.dragging = true;
this.selectSquare(from); // hiện chấm xanh / vòng đỏ trong khi drag
piece.setDepth(DEPTH_DRAGGING); // nâng lên trên mọi quân khác
}
private handleDrag(_, gameObject, dragX, dragY) {
(gameObject as Phaser.GameObjects.Text).setPosition(dragX, dragY);
}
private handleDragEnd(pointer, gameObject) {
this.dragging = false;
const piece = gameObject as Phaser.GameObjects.Text;
const from = piece.getData("square") as Square;
const to = this.pixelToSquare(pointer.x, pointer.y);
const legal = to ? this.chess.legalMoves(from).some(m => m.to === to) : false;
if (legal && to) {
this.clearSelection();
const target = this.squarePixel(to);
this.tweens.add({
targets: piece, x: target.x, y: target.y - 2,
duration: 140, ease: "Cubic.Out",
onComplete: () => this.applyMove(from, to, false),
});
} else {
// Drop sai → tween về ô gốc.
const home = this.squarePixel(from);
this.tweens.add({
targets: piece, x: home.x, y: home.y - 2,
duration: 180, ease: "Cubic.Out",
onComplete: () => piece.setDepth(DEPTH_PIECE),
});
this.clearSelection();
}
}
Bốn DEPTH_* là Z-order khi vẽ — DEPTH_DRAGGING = 10 cao hơn
DEPTH_PIECE = 2 đảm bảo quân đang kéo luôn nằm trên quân khác (đi qua
quân địch trên đường kéo).
applyMove: tween + resync
private applyMove(from: Square, to: Square, clickFlow: boolean): void {
const move = this.chess.tryMove(from, to);
if (!move) { this.drawPieces(); return; }
if (clickFlow) {
// Click flow chưa có tween → tween nhanh trước khi resync.
const piece = this.pieces.get(from);
if (piece) {
const target = this.squarePixel(to);
piece.setDepth(DEPTH_DRAGGING);
this.tweens.add({
targets: piece, x: target.x, y: target.y - 2,
duration: 140, ease: "Cubic.Out",
onComplete: () => this.drawPieces(),
});
return;
}
}
this.drawPieces();
}
Quan trọng: sau mỗi nước thành công, gọi drawPieces() để fully
redraw từ chess.js state. Lý do: một nước có thể đổi nhiều ô:
- Capture thường: quân ở ô đến biến mất.
- En-passant: quân bị bắt nằm ở ô khác, không phải ô đến.
- Nhập thành: vua đi 2 ô + xe nhảy qua. Cần update 2 quân.
- Promotion: Tốt biến thành Hậu — Text glyph phải đổi.
Quản lý từng case bằng tay là rước nợ. Chess.js đã đúng — copy nó về UI.
Click-to-move
Vẫn giữ click pattern bài 4, thêm một nhánh "click ô đích để đi":
if (this.selected) {
const legal = this.chess.legalMoves(this.selected).some(m => m.to === square);
if (legal) {
const from = this.selected;
this.clearSelection();
this.applyMove(from, square, true); // clickFlow = true
return;
}
// ... đổi quân hoặc bỏ chọn như bài 4
}
Phaser bắn pointerdown trước khi dragstart, nhưng nếu pointer di
chuyển vài px sau đó thì dragstart lên ngay. Field dragging được set
trong handleDragStart và check ngay đầu handlePointer — đảm bảo
click-flow và drag-flow không giẫm chân nhau.
Mục tiêu bài đã đạt
Chơi được ván 2 người trên cùng máy. Drag-thả mượt, click cũng đi được. Nước hợp lệ tween 140ms về ô đích, sai thì revert 180ms. Khi gặp Tốt sang hàng 8, tự phong Hậu — bài 6 sẽ thay bằng modal cho phép chọn.
Bẫy hay gặp
Bẫy 1 — Tween về ô đích nhưng vị trí sai 1 pixel sau resync
Vì makePieceText render glyph với offset y=-2 (căn thị
giác). Khi tween, target phải là {x: tx, y: ty - 2}; nếu
quên trừ 2, quân sẽ "giật" 2 pixel khi tween xong rồi
drawPieces() đặt lại đúng vị trí. Nhỏ nhưng dễ nhận ra.
Bẫy 2 — Quên reset DEPTH sau revert
Trong revert, onComplete phải set lại
piece.setDepth(DEPTH_PIECE). Quên thì quân vừa drag-fail
vẫn nằm ở DEPTH 10 — nước sau drag quân khác đi qua sẽ bị che. Bug
hiển thị, không phải logic.
Bẫy 3 — Drag bắt cả khi không phải lượt
Phaser không tự biết "lượt" của ai — bạn phải check trong
handleDragStart. Quên check, người chơi có thể kéo Tốt
đen khi đang lượt trắng → tryMove() sẽ trả null và quân
bay về vị trí cũ — UX nhức mắt. Tốt nhất là chặn ngay từ
setDragState(pointer, 0).
Bài tập
- Thêm âm thanh "click" rất ngắn khi quân đặt xuống ô (sau tween snap).
Gợi ý: tạm thời dùng
new Audio("data:audio/...")inline. Bài 13 sẽ nâng cấp lên Phaser sound manager đúng chuẩn. - Đặt
keyboard?.on("keydown-Z", () => …)để undo nước cuối — gợi ý: thêm methodundo()cho adapter wrapgame.undo(). Sau undo, gọidrawPieces()resync. - Thí một ván Sicilian Najdorf 6 nước đầu, nhấn F sau nước 3 để đổi phía. Có hint nào sai vị trí không? (Test cho bẫy 3 ở bài 4 + revert trong bài này.)
Bài tiếp theo
Bài 6 — Luật đặc biệt: phong cấp, nhập thành, en-passant: modal chọn quân phong cấp khi Tốt vào hàng 8, animate đồng thời vua + xe khi nhập thành, highlight đặc biệt cho en-passant. Sau bài 6, mọi luật cờ đã được tôn trọng từ phía UI — không còn case nào "chess.js đúng mà demo trông sai".
Câu hỏi thường gặp
Vì sao lại fully redraw 32 quân sau mỗi nước thay vì chỉ di chuyển con đang đi?
Tween 140ms / 180ms ở đâu ra?
Cubic.Out để giảm tốc cuối, cho cảm giác 'đặt' xuống thay vì xô.Promotion mặc định là Hậu — nếu tôi muốn phong Mã thì sao?
promotion: 'q'. Bài 6 sẽ thêm modal: khi move.flags có 'p' (promotion), pause flow lại, hỏi người chơi chọn Q/R/B/N rồi mới gọi game.move(). Để bài 5 gọn, chấp nhận Hậu mặc định — ~99% trường hợp người chơi muốn Hậu.Tôi click vào quân địch khi đang chọn quân mình — có nên coi là 'ăn quân' không?
handlePointer, nếu ô đích nằm trong legalMoves(selected) thì là nước hợp lệ (ăn quân) → thực hiện. Nếu không (ví dụ quân địch ngoài tầm), thì rơi xuống nhánh 'không hợp lệ' và bỏ chọn. UX này đồng nhất giữa drag và click.