Android Java

Dự án Android: bộ luật cờ tướng 2D có thể kiểm thử

Mã cản chân, Tượng cản mắt, Pháo cần ngòi, cung Tướng và xử lý bí nước trong mô hình Java độc lập.

Thực hànhDự án thực hành nâng cao26 phút đọcJava · Android · Canvas

Thích và đánh giá Bình luận và hỏi đáp
Pháo bắt quân khi có đúng một ngòi; nước đi còn phải giữ an toàn cho Tướng của mình.
Pháo bắt quân khi có đúng một ngòi; nước đi còn phải giữ an toàn cho Tướng của mình. Mở hình lớn ↗

Mã cản chân, Tượng cản mắt, Pháo cần ngòi, cung Tướng và xử lý bí nước trong mô hình Java độc lập. Các tệp nguồn, Gradle wrapper, hướng dẫn chạy và kiểm thử đi kèm trong ZIP.

Xem trọn bộ 5 dự án Android · Tải dự án Android đầy đủ (.zip) · Tải APK debug 1.1 để cài thử

1. Vì sao không chỉ đổi ảnh quân cờ vua?

Cờ tướng có 10 hàng, 9 cột và quân đứng ở giao điểm. Mã có chân, Tượng có mắt và không qua sông, Pháo có ngòi, Tướng/Sĩ bị giới hạn trong cung. Đổi bitmap của một game cờ vua không tạo ra cờ tướng đúng luật. Bài này tái sử dụng hợp đồng vị trí và kỹ thuật kiểm tra an toàn Vua, nhưng xây lại hình học di chuyển theo từng quân.

Tải ZIP ở đầu bài, giải nén rồi mở thư mục có settings.gradle bằng Android Studio. Trong SDK Manager cài Android SDK Platform 35; chọn Gradle JDK 17. Dự án khóa Android Gradle Plugin 8.6.1 và Gradle 8.7, minSdk 26. Chờ Sync rồi chạy module app trên Android 8.0 trở lên. Lần đầu cần mạng để tải công cụ; bản ứng dụng sau khi cài hoạt động ngoại tuyến. Không cần khóa API, .NET hay dịch vụ AI trả phí.

Windows:       gradlew.bat assembleDebug
Linux/macOS:   chmod +x gradlew
               ./gradlew assembleDebug
APK: app/build/outputs/apk/debug/app-debug.apk

Nếu báo không tìm thấy SDK, mở lại dự án bằng Android Studio để tạo local.properties theo máy; không chép đường dẫn SDK từ máy khác. Nếu Gradle báo sai phiên bản Java, kiểm tra Gradle JDK thay vì chỉ kiểm tra lệnh java -version. Cấu hình này là một bộ phiên bản tương thích để học, không khẳng định là phiên bản Android mới nhất.

2. Biểu diễn bàn và quy ước tọa độ

XiangqiEngine dùng mảng 90 ô. Hàng 0 là phía Đen, hàng 9 là phía Đỏ; chỉ số bằng row×9+column. Người chơi cầm Đỏ, quân Đỏ dương và quân Đen âm. Các loại 1–7 là Binh, Mã, Tượng, Xe, Pháo, Tướng, Sĩ. Trên Canvas, nhãn TG tránh nhầm Tướng với Tượng. Chỉ một bộ quy ước tồn tại từ engine tới UI; không đảo dấu quân khi quay bàn.

XiangqiEngine p = XiangqiEngine.initial();
System.out.println(p.rows());        // 10
System.out.println(p.columns());     // 9
System.out.println(p.legalMoves().size()); // 44
// Red/Đỏ ở hàng 9; side = 1 là Đỏ đến lượt.

Vị trí ban đầu có 44 nước hợp lệ; bộ thử dùng số đếm thủ công theo nhóm quân làm kiểm tra khói. Tuy vậy, tổng đúng có thể che hai lỗi bù nhau. Vì thế cần các thế rất nhỏ chỉ chứa hai Tướng, một quân thử và một số quân cản để kiểm tra từng điều kiện. Những thế này không nhất thiết đến từ một ván thi đấu, nhưng phải đủ hợp lệ cho câu hỏi đang xét.

3. Mã và Tượng: ô trung gian quyết định

Với Mã đi chênh (2,1), ô chân nằm một bước theo hướng chênh 2; nếu chênh (1,2), chân nằm một bước theo cột. Ô chân có bất kỳ quân nào đều chặn hướng tương ứng, dù ô đích còn trống. Mã không “nhảy” như Knight cờ vua. Kiểm thử phải đặt quân cản đúng chân và ở một ô lân cận không phải chân, tránh một phép kiểm tra quá rộng làm cấm cả hai.

Tượng đi chéo đúng hai hàng và hai cột. Mắt là trung điểm; mắt phải trống, đồng thời đích phải còn ở phần sân của mình. Đỏ không tới hàng nhỏ hơn 5, Đen không tới hàng lớn hơn 4 theo quy ước mảng. Thực hiện cả hai điều kiện trước khi kiểm tra an toàn Tướng. Nếu chỉ vẽ đường sông trong UI mà không có luật engine, AI vẫn có thể đưa Tượng qua sông.

4. Xe, Pháo và số quân trên đường

Xe cần cùng hàng hoặc cột và không có quân ở giữa. Pháo đi vào ô trống theo điều kiện như Xe; để bắt quân thì cần đúng một quân ngòi ở giữa. Quân ngòi có thể thuộc bất kỳ bên nào. Không đếm ô đích vào số ngòi, vì ô đích chính là quân bị bắt. Đây là lỗi lệch một thường gặp khi dùng một hàm đếm đường cho cả Xe và Pháo.

ĐíchSố quân ở giữaPháo có đi được về mặt hình học?
Trống0Có
Trống1Không
Quân đối phương0Không
Quân đối phương1Có
Quân đối phương2Không

Tách hàm đếm screens khỏi điều kiện chọn đích. Sau khi đếm, bộ lọc chung vẫn cấm ăn quân cùng bên và cấm một nước làm lộ Tướng. Chẳng hạn Pháo bắt đúng một ngòi nhưng quân vừa rời ô lại là tấm chắn cuối giữa hai Tướng: nước ấy vẫn bất hợp lệ. Luật một quân không hoạt động độc lập với cấu trúc toàn bàn.

5. Cung, sông, Binh và hai Tướng đối mặt

Tướng đi một bước ngang hoặc dọc trong cung 3×3. Sĩ đi một bước chéo cũng trong cung. Binh trước sông chỉ tiến; sau sông được đi ngang một ô nhưng không lùi. Vị trí xuất phát của Đỏ ở phía dưới nên hướng tiến là giảm chỉ số hàng; Đen tăng hàng. Đừng hard-code “tiến luôn là +1” rồi chỉ đảo hình vẽ.

Hai Tướng cùng cột và không có quân chắn tạo quan hệ tấn công. Khi sinh nước, engine áp dụng thử rồi kiểm tra Tướng của bên vừa đi. Vì vậy việc dời tấm chắn sang bên phải bị cấm. Không thêm “bắt Tướng” làm cách kết thúc giao diện: game kết luận khi bên đến lượt không còn nước hợp lệ. Quan hệ Tướng bay dùng trong dò chiếu, còn danh sách nước không cho phép thực sự ăn Tướng.

6. Bí nước và phạm vi luật thi đấu

Trong cờ tướng, không còn nước hợp lệ là thua kể cả Tướng chưa bị chiếu. Điểm này khác stalemate hòa của cờ vua và được biểu diễn bằng lossOnStalemate(). Việc đưa khác biệt vào hợp đồng giúp cùng một bộ tìm kiếm có thể hỏi engine thay vì rải các câu if “đây là cờ tướng” khắp thuật toán AI.

Bản học tập tự hòa khi lặp ba lần hoặc 120 nửa nước không bắt quân. Đây là chính sách của dự án để tránh ván vô hạn, chưa phải phân xử đầy đủ luật WXF về trường chiếu, trường tróc hay đuổi quân. Không thể coi phát hiện lặp bàn đơn giản là đủ để quyết định bên nào phạm luật trong một cuộc đuổi kéo dài. Muốn nâng cấp cần lưu loại hành vi và chuỗi lặp, cùng bộ ca do người hiểu luật xác nhận.

7. Mã nguồn bộ luật và cách kiểm tra

Môi trường thực hành. Dự án Android Java đầy đủ được tải bằng liên kết ZIP ở đầu bài. Android Studio, JDK 17, AGP 8.6.1, Gradle 8.7, compileSdk 35 và thiết bị API 26 trở lên. Dùng Activity/Canvas/SQLite gốc; xem README để chạy và kiểm tra. Tệp dưới đây là một thành phần của dự án.

Java · Android · Canvas · XiangqiEngine.javaTải mã nguồn
package vn.kienutt.xiangqi;

import java.util.ArrayList;
import java.util.List;

/** Rows 0..9, columns 0..8. Positive pieces are Red. */
public final class XiangqiEngine implements GamePosition {
  public static final int PAWN = 1,
      HORSE = 2,
      ELEPHANT = 3,
      ROOK = 4,
      CANNON = 5,
      KING = 6,
      ADVISOR = 7;
  public static final String INITIAL =
      "rheakaehr/9/1c5c1/p1p1p1p1p/9/9/P1P1P1P1P/1C5C1/9/RHEAKAEHR w";
  private final int[] board;
  private final int turn, half;
  private final List<String> history;

  private XiangqiEngine(int[] b, int turn, int half, List<String> h) {
    this.board = b;
    this.turn = turn;
    this.half = half;
    this.history = h;
  }

  public static XiangqiEngine initial() {
    return fromFen(INITIAL);
  }

  public static XiangqiEngine fromFen(String fen) {
    String[] p = fen.trim().split(" +"), rows = p[0].split("/");
    if (rows.length != 10) throw new IllegalArgumentException("Cần 10 hàng");
    int[] b = new int[90];
    int red = 0, black = 0;
    for (int r = 0; r < 10; r++) {
      int c = 0;
      for (char ch : rows[r].toCharArray()) {
        if (ch >= '1' && ch <= '9') c += ch - '0';
        else {
          int t = " phercka".indexOf(Character.toLowerCase(ch));
          if (t < 1 || c >= 9) throw new IllegalArgumentException("Quân sai");
          int v = Character.isUpperCase(ch) ? t : -t;
          b[r * 9 + c++] = v;
          if (v == KING) red++;
          if (v == -KING) black++;
        }
      }
      if (c != 9) throw new IllegalArgumentException("Hàng sai");
    }
    if (red != 1 || black != 1 || p.length < 2 || !(p[1].equals("w") || p[1].equals("b")))
      throw new IllegalArgumentException("Thiếu tướng hoặc lượt đi");
    XiangqiEngine x =
        new XiangqiEngine(
            b,
            p[1].equals("w") ? 1 : -1,
            p.length > 2 ? Integer.parseInt(p[2]) : 0,
            new ArrayList<>());
    x.history.add(x.key());
    return x;
  }

  public static XiangqiEngine restore(String text) {
    String[] p = text.split("\n");
    XiangqiEngine x = fromFen(p[0]);
    if (p.length > 1) {
      x.history.clear();
      for (int i = 1; i < p.length; i++) x.history.add(p[i]);
      if (!x.history.get(x.history.size() - 1).equals(x.key()))
        throw new IllegalArgumentException("Lịch sử sai");
    }
    return x;
  }

  @Override
  public int rows() {
    return 10;
  }

  @Override
  public int columns() {
    return 9;
  }

  @Override
  public int side() {
    return turn;
  }

  @Override
  public int pieceAt(int square) {
    return board[square];
  }

  @Override
  public boolean lossOnStalemate() {
    return true;
  }

  private static boolean inside(int r, int c) {
    return r >= 0 && r < 10 && c >= 0 && c < 9;
  }

  private static boolean palace(int r, int c, int side) {
    return c >= 3 && c <= 5 && (side == 1 ? r >= 7 && r <= 9 : r >= 0 && r <= 2);
  }

  private int screens(int from, int to) {
    int dr = Integer.signum(to / 9 - from / 9), dc = Integer.signum(to % 9 - from % 9), n = 0;
    for (int r = from / 9 + dr, c = from % 9 + dc; r != to / 9 || c != to % 9; r += dr, c += dc)
      if (board[r * 9 + c] != 0) n++;
    return n;
  }

  private boolean geometric(int from, int to, boolean attack) {
    if (from == to) return false;
    int p = board[from],
        side = Integer.signum(p),
        t = Math.abs(p),
        r = from / 9,
        c = from % 9,
        rr = to / 9,
        cc = to % 9,
        dr = rr - r,
        dc = cc - c;
    switch (t) {
      case PAWN:
        return dr == -side && dc == 0
            || dr == 0 && Math.abs(dc) == 1 && (side == 1 ? r <= 4 : r >= 5);
      case HORSE:
        if (Math.abs(dr) == 2 && Math.abs(dc) == 1)
          return board[(r + Integer.signum(dr)) * 9 + c] == 0;
        if (Math.abs(dr) == 1 && Math.abs(dc) == 2)
          return board[r * 9 + c + Integer.signum(dc)] == 0;
        return false;
      case ELEPHANT:
        return Math.abs(dr) == 2
            && Math.abs(dc) == 2
            && (side == 1 ? rr >= 5 : rr <= 4)
            && board[(r + dr / 2) * 9 + c + dc / 2] == 0;
      case ADVISOR:
        return Math.abs(dr) == 1 && Math.abs(dc) == 1 && palace(rr, cc, side);
      case KING:
        if (attack && dc == 0 && board[to] == -side * KING && screens(from, to) == 0) return true;
        return Math.abs(dr) + Math.abs(dc) == 1 && palace(rr, cc, side);
      case ROOK:
        return (dr == 0 || dc == 0) && screens(from, to) == 0;
      case CANNON:
        if (dr != 0 && dc != 0) return false;
        return screens(from, to) == (board[to] == 0 && !attack ? 0 : 1);
      default:
        return false;
    }
  }

  private boolean check(int side) {
    int king = -1;
    for (int i = 0; i < 90; i++) if (board[i] == side * KING) king = i;
    if (king < 0) return true;
    for (int i = 0; i < 90; i++) if (board[i] * side < 0 && geometric(i, king, true)) return true;
    return false;
  }

  @Override
  public boolean inCheck() {
    return check(turn);
  }

  @Override
  public List<Move> legalMoves() {
    List<Move> out = new ArrayList<>();
    for (int from = 0; from < 90; from++)
      if (board[from] * turn > 0)
        for (int to = 0; to < 90; to++) {
          if (board[to] * turn > 0 || Math.abs(board[to]) == KING || !geometric(from, to, false))
            continue;
          Move m = new Move(from, to);
          if (!apply(m, false).check(turn)) out.add(m);
        }
    return out;
  }

  private XiangqiEngine apply(Move m, boolean remember) {
    int[] b = board.clone();
    int captured = b[m.to];
    b[m.to] = b[m.from];
    b[m.from] = 0;
    List<String> h = remember ? new ArrayList<>(history) : history;
    XiangqiEngine x = new XiangqiEngine(b, -turn, captured != 0 ? 0 : half + 1, h);
    if (remember) h.add(x.key());
    return x;
  }

  @Override
  public XiangqiEngine after(Move move) {
    return apply(move, true);
  }

  private String placement() {
    StringBuilder s = new StringBuilder();
    for (int r = 0; r < 10; r++) {
      if (r > 0) s.append('/');
      int empty = 0;
      for (int c = 0; c < 9; c++) {
        int p = board[r * 9 + c];
        if (p == 0) empty++;
        else {
          if (empty > 0) {
            s.append(empty);
            empty = 0;
          }
          char ch = " phercka".charAt(Math.abs(p));
          s.append(p > 0 ? Character.toUpperCase(ch) : ch);
        }
      }
      if (empty > 0) s.append(empty);
    }
    return s.toString();
  }

  private String key() {
    return placement() + " " + (turn == 1 ? "w" : "b");
  }

  @Override
  public String encode() {
    return key() + " " + half + "\n" + String.join("\n", history);
  }

  @Override
  public boolean drawn() {
    int count = 0;
    for (String s : history) if (s.equals(key())) count++;
    return count >= 3 || half >= 120;
  }

  @Override
  public int evaluate() {
    int[] value = {0, 100, 400, 200, 900, 450, 0, 200};
    int sum = 0;
    for (int i = 0; i < 90; i++) {
      int p = board[i], t = Math.abs(p);
      if (t == 0) continue;
      int bonus = 0;
      if (t == PAWN) {
        boolean crossed = p > 0 ? i / 9 <= 4 : i / 9 >= 5;
        bonus = (crossed ? 80 : 0) + (p > 0 ? 9 - i / 9 : i / 9) * 5;
      }
      if (t == HORSE || t == CANNON) bonus = 12 - Math.abs(i % 9 - 4) * 3;
      sum += (p > 0 ? 1 : -1) * (value[t] + bonus);
    }
    return turn * sum;
  }
}

Chạy tests/EngineChecks.java theo README. Các ca hiện kiểm tra 44 nước đầu, chân Mã, mắt Tượng, qua sông, cung, các mức ngòi Pháo, Binh trước/sau sông, đối mặt Tướng và bí nước. Mỗi ca nên giải thích điều gì thay đổi khi thêm đúng một quân cản. Nếu chỉ dùng một ván dài, lỗi ở lượt đầu có thể làm mọi kiểm tra phía sau không còn đúng tiền đề.

8. Bài tập suy luận và chuyển sang AI

Bài tập một: dựng một Pháo có ngòi và ô đích là quân cùng màu; bộ đếm đúng một vẫn không cho ăn. Bài tập hai: đặt một quân chắn giữa hai Tướng, cho quân ấy đi dọc cùng cột và đi ngang; giải thích nước nào vẫn chắn. Bài tập ba: ghi tệp chứa vị trí trước/sau mỗi nước rồi phát lại; kiểm tra encode/restore bảo toàn bên đi và lịch sử dùng cho hòa.

Bản 1.1 đã dựng APK debug bằng bộ công cụ Android đầy đủ; lint không có lỗi. Dự án này đã qua 2 ca JUnit/Robolectric, gồm mở màn hình, pause/resume và tái tạo Activity trên Android API 35 mô phỏng trong JVM. Hai dự án quản lý còn kiểm thử trực tiếp SQLiteOpenHelper với SQLite native. Chưa kiểm tra trên điện thoại thật; hãy chạy thêm checklist thiết bị trong README, nhất là thao tác cảm ứng, cỡ chữ và độ trễ AI.

Đối chiếu phạm vi luật với tài liệu luật trên website WXF. Tiếp tục ở bài AI cờ tướng và giao diện cảm ứng.

9. Chạy lại kiểm thử và dựng APK

Mở Terminal tại thư mục dự án. Trên Windows chạy lệnh dưới; trên Linux/macOS thay gradlew.bat bằng ./gradlew. assembleDebug tạo APK; lintDebug kiểm tra tĩnh; testDebugUnitTest chạy các ca JUnit và Robolectric. Ba việc này bổ sung cho nhau, không đồng nghĩa đã thử trên mọi điện thoại.

gradlew.bat assembleDebug lintDebug testDebugUnitTest
# APK: app/build/outputs/apk/debug/app-debug.apk
# Test: app/build/reports/tests/testDebugUnitTest/index.html

ZIP có đầy đủ mã kiểm thử trong app/src/test/java và kiểm tra logic/SQL ở thư mục tests. Chỉnh một luật hoặc một truy vấn, chạy lại bộ ca liên quan trước khi dựng APK mới. Thư viện kiểm thử chỉ dùng lúc phát triển, không được đóng vào ứng dụng. APK cung cấp trên trang là bản debug học tập.