Gameplay 2 người Trung bình 6 phút đọc

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
  1. Mở rộng adapter:
  2. Bật draggable trên
  3. Drag handler
  4. : tween + resync
  5. Click-to-move
  6. Bẫy hay gặp
  7. Bài tập
  8. Bài tiếp theo

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 ô:

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

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

  1. 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.
  2. Đặt keyboard?.on("keydown-Z", () => …) để undo nước cuối — gợi ý: thêm method undo() cho adapter wrap game.undo(). Sau undo, gọi drawPieces() resync.
  3. 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".

Demo bài 5 — kéo-thả hoặc click 2 lần để đi (F lật bàn) ↓ Tải code bài này (.zip)

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?
Vì một nước trong cờ vua có thể thay đổi nhiều ô: nhập thành (vua + xe đồng thời), en-passant (xoá quân ở ô khác ô đến), phong cấp (quân biến đổi loại). Quản lý từng case trong scene là code rối + dễ sót. Chess.js đã là source-of-truth, gọi drawPieces() là idempotent. Cost destroy/recreate 32 Text object là không đáng — chess không phải bullet hell.
Tween 140ms / 180ms ở đâu ra?
Số tay chơi: 140ms 'snap' đủ thấy chuyển động nhưng không lè kè (Lichess dùng ~150ms). 'Revert' chậm hơn 40ms vì nó là phản hồi 'sai' — tốc độ chậm hơn giúp người dùng nhận ra ngay rằng nước không hợp lệ. Cả hai dùng 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?
Bài 5 hardcode 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?
Đã cover: trong handlePointer, nếu ô đích nằm trong legalMoves(selected) thì 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.