Android Java
Dự án Android: xây dựng bộ luật cờ vua 2D bằng Java
Mô hình bàn cờ, lọc nước tự chiếu, nhập thành, en passant, phong cấp và kiểm chứng bằng perft; kèm toàn bộ dự án Android.
Thích và đánh giá Bình luận và hỏi đápMô hình bàn cờ, lọc nước tự chiếu, nhập thành, en passant, phong cấp và kiểm chứng bằng perft; kèm toàn bộ dự án Android. 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. Sản phẩm cần hoàn thành và cách chạy
Bài đầu xây dựng phần khó nhất của game cờ vua: một vị trí có thể sinh đúng các nước đi hợp lệ. Người dùng cầm Trắng; ứng dụng có bàn 8×8, chọn quân, đánh dấu ô đích, phong cấp và lưu ván. ZIP đã gồm cả giao diện và máy đánh để bạn chạy ngay, nhưng ở bài này chỉ tập trung kiểm chứng luật. Đọc tiếp bài AI sau khi hiểu vì sao một nước nhìn có vẻ đúng hình học vẫn có thể bị cấm.
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. Tách vị trí, nước đi và giao diện
Move chứa ô đi, ô đến và loại quân phong cấp. GamePosition là hợp đồng giữa bộ luật và thuật toán tìm kiếm. ChessEngine cài hợp đồng ấy, còn ChessActivity chỉ đọc trạng thái và gửi lựa chọn của người chơi. Không đặt luật quân Mã trong phương thức vẽ: khi thay màu bàn hay hình quân, tập nước hợp lệ phải giữ nguyên.
Mảng 64 phần tử dùng chỉ số row * 8 + column. Hàng 0 là hàng 8, cột 0 là a; e2 tương ứng hàng 6, cột 4. Quân Trắng dương, quân Đen âm; trị tuyệt đối từ 1 đến 6 lần lượt là Tốt, Mã, Tượng, Xe, Hậu, Vua. Cách biểu diễn này gọn, nhưng chỉ có ý nghĩa khi mọi hàm cùng tuân thủ hệ tọa độ.
ChessEngine start = ChessEngine.initial();
System.out.println(start.legalMoves().size()); // 20
ChessEngine after = start.play("e2e4");
// start vẫn là vị trí ban đầu; after là ảnh chụp mới.
System.out.println(after.side()); // -1: Đen đến lượt
3. Từ nước giả hợp lệ tới nước hợp lệ
Bước một sinh các đích theo hình học: Mã đi chữ L; Tượng đi chéo; Xe đi ngang/dọc; Hậu kết hợp hai cách. Với quân trượt, gặp quân cùng màu thì dừng trước ô đó, gặp quân đối phương thì có thể bắt và phải dừng. Không sinh nước bắt Vua. Bước hai áp dụng thử từng nước lên bản sao, xác định Vua của bên vừa đi và loại nước khiến Vua bị tấn công.
Phân biệt attacked với legalMoves. Một ô bị Tốt khống chế theo đường chéo kể cả khi ô trống; Tốt không tấn công ô đi thẳng. Không dùng sinh nước hợp lệ của đối phương để hỏi Vua có bị chiếu không, vì điều đó tạo vòng đệ quy giữa hai phép kiểm tra. Bộ dò tấn công làm việc trực tiếp với vị trí quân và hướng tấn công.
4. Nhập thành, bắt tốt qua đường và phong cấp
| Tình huống | Dữ liệu phải kiểm tra | Sau khi đi |
|---|---|---|
| Nhập thành | Quyền còn hiệu lực, Xe đúng ô, đường trống, Vua không ở/qua/đến ô bị đánh | Dời cả Vua và Xe; xóa quyền tương ứng |
| En passant | Ô mục tiêu chỉ tồn tại ngay sau một nước Tốt đi hai ô | Xóa Tốt ở ô bên cạnh rồi kiểm tra an toàn Vua |
| Phong cấp | Tốt đến hàng cuối | Sinh bốn lựa chọn Hậu, Xe, Tượng, Mã |
En passant là ca thử hữu ích cho tư duy kiểm thử: ô đích trống nhưng một quân ở ô khác biến mất. Việc xóa quân ấy có thể mở đường Xe đối phương chiếu Vua, nên không thể chỉ kiểm tra ô đích. Với nhập thành, chỉ kiểm tra vị trí sau cùng cũng thiếu: Vua không được đi xuyên qua ô bị tấn công. Quyền nhập thành là lịch sử, không suy lại từ việc Vua và Xe hiện đứng đúng ô.
Bản mã giữ bit quyền nhập thành, ô en passant và bộ đếm nửa nước trong mỗi ảnh chụp. Khi quân Xe bị bắt ở ô gốc, quyền liên quan cũng mất. Khi chọn phong cấp, giao diện đưa bốn nút thay vì tự đổi mọi Tốt thành Hậu; bốn nước có cùng ô đi/đến nhưng khác trường promotion.
5. Lưu vị trí khác với lưu toàn bộ ván
FEN mô tả vị trí, bên đi và các thông tin cần cho nhập thành/en passant. Tuy nhiên, một FEN đơn lẻ không cho biết vị trí trước đó đã lặp bao nhiêu lần. Vì thế encode lưu thêm lịch sử khóa vị trí; restore kiểm tra dữ liệu trước khi khôi phục. Giao diện lưu bản mã hóa vào SharedPreferences, còn danh sách Đi lại được giữ trong Bundle khi xoay màn hình.
Không coi chính sách hòa của bản mẫu là bộ luật trọng tài hoàn chỉnh. Game tự hòa ở lần lặp thứ ba hoặc 100 nửa nước không có bắt quân/đi Tốt; đây là quy ước để bài tập kết thúc rõ ràng. Chưa áp dụng đủ thủ tục yêu cầu hòa FIDE, mọi thế cờ chết hay phân biệt ô en passant không thể bắt trong khóa lặp. Luật thi đấu và quy ước sản phẩm phải được mô tả riêng.
6. Đọc mã nguồn bộ luật
Tệp dưới đây là ChessEngine.java thực tế trong ZIP. Để biên dịch cần thêm Move.java và GamePosition.java. Trình tự đọc nên là initial/fromFen → sinh nước → attacked → after → legalMoves; các hàm mã hóa và đánh giá có thể đọc sau. Tải một tệp phù hợp để tham khảo, nhưng muốn chạy app hãy tải cả dự án.
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.
package vn.kienutt.chess;
import java.util.ArrayList;
import java.util.List;
/** Immutable board snapshots. Positive pieces are White; row 0 is rank 8. */
public final class ChessEngine implements GamePosition {
public static final int PAWN = 1, KNIGHT = 2, BISHOP = 3, ROOK = 4, QUEEN = 5, KING = 6;
public static final String INITIAL = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1";
private final int[] board;
private final int turn, rights, ep, half, full;
private final List<String> history;
private ChessEngine(
int[] board, int turn, int rights, int ep, int half, int full, List<String> history) {
this.board = board;
this.turn = turn;
this.rights = rights;
this.ep = ep;
this.half = half;
this.full = full;
this.history = history;
}
public static ChessEngine initial() {
return fromFen(INITIAL);
}
public static ChessEngine fromFen(String fen) {
String[] parts = fen.trim().split(" +");
if (parts.length < 4) throw new IllegalArgumentException("FEN thiếu trường");
String[] rows = parts[0].split("/");
if (rows.length != 8) throw new IllegalArgumentException("FEN cần 8 hàng");
int[] b = new int[64];
int whiteKings = 0, blackKings = 0;
for (int r = 0; r < 8; r++) {
int c = 0;
for (char ch : rows[r].toCharArray()) {
if (ch >= '1' && ch <= '8') c += ch - '0';
else {
int t = " pnbrqk".indexOf(Character.toLowerCase(ch));
if (t < 1 || c >= 8) throw new IllegalArgumentException("Quân FEN sai");
b[r * 8 + c++] = Character.isUpperCase(ch) ? t : -t;
if (t == KING) {
if (Character.isUpperCase(ch)) whiteKings++;
else blackKings++;
}
}
}
if (c != 8) throw new IllegalArgumentException("Hàng FEN sai");
}
if (whiteKings != 1 || blackKings != 1 || !(parts[1].equals("w") || parts[1].equals("b")))
throw new IllegalArgumentException("FEN cần hai vua và lượt đi");
int cr = 0;
if (parts[2].contains("K")) cr |= 1;
if (parts[2].contains("Q")) cr |= 2;
if (parts[2].contains("k")) cr |= 4;
if (parts[2].contains("q")) cr |= 8;
int ep = parts[3].equals("-") ? -1 : square(parts[3]);
ChessEngine p =
new ChessEngine(
b,
parts[1].equals("w") ? 1 : -1,
cr,
ep,
parts.length > 4 ? Integer.parseInt(parts[4]) : 0,
parts.length > 5 ? Integer.parseInt(parts[5]) : 1,
new ArrayList<>());
if (p.half < 0 || p.full < 1) throw new IllegalArgumentException("Bộ đếm FEN sai");
p.history.add(p.key());
return p;
}
public static int square(String name) {
if (!name.matches("[a-h][1-8]")) throw new IllegalArgumentException("Ô cờ sai");
return (8 - (name.charAt(1) - '0')) * 8 + name.charAt(0) - 'a';
}
public static String name(int sq) {
return "" + (char) ('a' + sq % 8) + (8 - sq / 8);
}
public static String uci(Move m) {
return name(m.from) + name(m.to) + (m.promotion == 0 ? "" : " pnbrqk".charAt(m.promotion));
}
public static ChessEngine restore(String text) {
String[] lines = text.split("\n");
ChessEngine p = fromFen(lines[0]);
if (lines.length > 1) {
p.history.clear();
for (int i = 1; i < lines.length; i++) p.history.add(lines[i]);
if (!p.history.get(p.history.size() - 1).equals(p.key()))
throw new IllegalArgumentException("Lịch sử sai");
}
return p;
}
@Override
public int rows() {
return 8;
}
@Override
public int columns() {
return 8;
}
@Override
public int side() {
return turn;
}
@Override
public int pieceAt(int sq) {
return board[sq];
}
@Override
public boolean lossOnStalemate() {
return false;
}
private static boolean inside(int r, int c) {
return r >= 0 && r < 8 && c >= 0 && c < 8;
}
private void add(List<Move> out, int from, int to) {
if (Math.abs(board[from]) == PAWN && (to / 8 == 0 || to / 8 == 7))
for (int t : new int[] {QUEEN, ROOK, BISHOP, KNIGHT}) out.add(new Move(from, to, t));
else out.add(new Move(from, to));
}
@Override
public List<Move> legalMoves() {
List<Move> pseudo = new ArrayList<>(), legal = new ArrayList<>();
for (int from = 0; from < 64; from++) {
int p = board[from];
if (p * turn <= 0) continue;
int r = from / 8, c = from % 8, t = Math.abs(p);
if (t == PAWN) {
int nr = r - turn;
if (inside(nr, c) && board[nr * 8 + c] == 0) {
add(pseudo, from, nr * 8 + c);
int start = turn == 1 ? 6 : 1;
if (r == start && board[(r - 2 * turn) * 8 + c] == 0)
add(pseudo, from, (r - 2 * turn) * 8 + c);
}
for (int dc : new int[] {-1, 1})
if (inside(nr, c + dc)) {
int to = nr * 8 + c + dc;
if ((board[to] * turn < 0 && Math.abs(board[to]) != KING)
|| (to == ep && board[to] == 0 && board[to + turn * 8] == -turn * PAWN))
add(pseudo, from, to);
}
} else if (t == KNIGHT) {
for (int[] d :
new int[][] {{-2, -1}, {-2, 1}, {-1, -2}, {-1, 2}, {1, -2}, {1, 2}, {2, -1}, {2, 1}}) {
int rr = r + d[0], cc = c + d[1];
if (inside(rr, cc)
&& board[rr * 8 + cc] * turn <= 0
&& Math.abs(board[rr * 8 + cc]) != KING) add(pseudo, from, rr * 8 + cc);
}
} else if (t == KING) {
for (int dr = -1; dr <= 1; dr++)
for (int dc = -1; dc <= 1; dc++)
if (dr != 0 || dc != 0) {
int rr = r + dr, cc = c + dc;
if (inside(rr, cc)
&& board[rr * 8 + cc] * turn <= 0
&& Math.abs(board[rr * 8 + cc]) != KING) add(pseudo, from, rr * 8 + cc);
}
int home = turn == 1 ? 60 : 4, ks = turn == 1 ? 1 : 4, qs = turn == 1 ? 2 : 8;
if (from == home && !check(turn)) {
if ((rights & ks) != 0
&& board[home + 3] == turn * ROOK
&& board[home + 1] == 0
&& board[home + 2] == 0
&& !attacked(home + 1, -turn)
&& !attacked(home + 2, -turn)) add(pseudo, from, home + 2);
if ((rights & qs) != 0
&& board[home - 4] == turn * ROOK
&& board[home - 1] == 0
&& board[home - 2] == 0
&& board[home - 3] == 0
&& !attacked(home - 1, -turn)
&& !attacked(home - 2, -turn)) add(pseudo, from, home - 2);
}
} else {
for (int dr = -1; dr <= 1; dr++)
for (int dc = -1; dc <= 1; dc++) {
if (dr == 0 && dc == 0) continue;
boolean diagonal = dr != 0 && dc != 0;
if ((t == BISHOP && !diagonal) || (t == ROOK && diagonal)) continue;
for (int rr = r + dr, cc = c + dc; inside(rr, cc); rr += dr, cc += dc) {
int to = rr * 8 + cc, target = board[to];
if (target * turn > 0) break;
if (Math.abs(target) != KING) add(pseudo, from, to);
if (target != 0) break;
}
}
}
}
for (Move m : pseudo) if (!apply(m, false).check(turn)) legal.add(m);
return legal;
}
private boolean attacked(int sq, int side) {
int r = sq / 8, c = sq % 8;
for (int dc : new int[] {-1, 1})
if (inside(r + side, c + dc) && board[(r + side) * 8 + c + dc] == side * PAWN) return true;
for (int[] d :
new int[][] {{-2, -1}, {-2, 1}, {-1, -2}, {-1, 2}, {1, -2}, {1, 2}, {2, -1}, {2, 1}})
if (inside(r + d[0], c + d[1]) && board[(r + d[0]) * 8 + c + d[1]] == side * KNIGHT)
return true;
for (int dr = -1; dr <= 1; dr++)
for (int dc = -1; dc <= 1; dc++) {
if (dr == 0 && dc == 0) continue;
for (int rr = r + dr, cc = c + dc, n = 1; inside(rr, cc); rr += dr, cc += dc, n++) {
int p = board[rr * 8 + cc];
if (p == 0) continue;
if (p * side > 0) {
int t = Math.abs(p);
if (t == QUEEN
|| (t == KING && n == 1)
|| (t == BISHOP && dr != 0 && dc != 0)
|| (t == ROOK && (dr == 0 || dc == 0))) return true;
}
break;
}
}
return false;
}
private boolean check(int side) {
for (int i = 0; i < 64; i++) if (board[i] == side * KING) return attacked(i, -side);
return true;
}
@Override
public boolean inCheck() {
return check(turn);
}
private ChessEngine apply(Move m, boolean remember) {
int[] b = board.clone();
int piece = b[m.from], captured = b[m.to], nr = rights, newEp = -1;
if (Math.abs(piece) == PAWN && m.to == ep && captured == 0 && m.from % 8 != m.to % 8) {
captured = b[m.to + turn * 8];
b[m.to + turn * 8] = 0;
}
b[m.from] = 0;
b[m.to] = m.promotion == 0 ? piece : turn * m.promotion;
if (Math.abs(piece) == KING) {
nr &= turn == 1 ? ~3 : ~12;
if (Math.abs(m.to - m.from) == 2) {
int rookFrom = m.to > m.from ? m.from + 3 : m.from - 4,
rookTo = m.to > m.from ? m.from + 1 : m.from - 1;
b[rookTo] = b[rookFrom];
b[rookFrom] = 0;
}
}
if (m.from == 63 || m.to == 63) nr &= ~1;
if (m.from == 56 || m.to == 56) nr &= ~2;
if (m.from == 7 || m.to == 7) nr &= ~4;
if (m.from == 0 || m.to == 0) nr &= ~8;
if (Math.abs(piece) == PAWN && Math.abs(m.to - m.from) == 16) newEp = (m.from + m.to) / 2;
List<String> hist = remember ? new ArrayList<>(history) : history;
ChessEngine next =
new ChessEngine(
b,
-turn,
nr,
newEp,
Math.abs(piece) == PAWN || captured != 0 ? 0 : half + 1,
full + (turn == -1 ? 1 : 0),
hist);
if (remember) hist.add(next.key());
return next;
}
@Override
public ChessEngine after(Move m) {
return apply(m, true);
}
public ChessEngine play(String uci) {
for (Move m : legalMoves()) if (uci(m).equals(uci)) return after(m);
throw new IllegalArgumentException("Nước không hợp lệ: " + uci);
}
private String placement() {
StringBuilder s = new StringBuilder();
for (int r = 0; r < 8; r++) {
if (r > 0) s.append('/');
int n = 0;
for (int c = 0; c < 8; c++) {
int p = board[r * 8 + c];
if (p == 0) n++;
else {
if (n > 0) {
s.append(n);
n = 0;
}
char ch = " pnbrqk".charAt(Math.abs(p));
s.append(p > 0 ? Character.toUpperCase(ch) : ch);
}
}
if (n > 0) s.append(n);
}
return s.toString();
}
private String castle() {
String s = "";
if ((rights & 1) != 0) s += "K";
if ((rights & 2) != 0) s += "Q";
if ((rights & 4) != 0) s += "k";
if ((rights & 8) != 0) s += "q";
return s.isEmpty() ? "-" : s;
}
private String key() {
return placement()
+ " "
+ (turn == 1 ? "w" : "b")
+ " "
+ castle()
+ " "
+ (ep < 0 ? "-" : name(ep));
}
public String fen() {
return key() + " " + half + " " + full;
}
@Override
public String encode() {
return fen() + "\n" + String.join("\n", history);
}
@Override
public boolean drawn() {
if (half >= 100) return true;
int repeats = 0;
for (String h : history) if (h.equals(key())) repeats++;
if (repeats >= 3) return true;
int minors = 0, bishopColor = -1;
boolean bishopsOnly = true;
for (int i = 0; i < 64; i++) {
int t = Math.abs(board[i]);
if (t == 0 || t == KING) continue;
if (t == PAWN || t == ROOK || t == QUEEN) return false;
minors++;
if (t == KNIGHT) bishopsOnly = false;
else {
int color = (i / 8 + i % 8) % 2;
if (bishopColor == -1) bishopColor = color;
else if (bishopColor != color) bishopsOnly = false;
}
}
return minors <= 1 || bishopsOnly;
}
@Override
public int evaluate() {
int[] value = {0, 100, 320, 335, 500, 900, 0};
int sum = 0;
for (int i = 0; i < 64; i++) {
int p = board[i], t = Math.abs(p);
if (t == 0) continue;
int center = 14 - (Math.abs(2 * (i % 8) - 7) + Math.abs(2 * (i / 8) - 7));
int bonus = (t == KNIGHT || t == BISHOP) ? center * 3 : 0;
if (t == PAWN) bonus += (p > 0 ? 6 - i / 8 : i / 8 - 1) * 7;
sum += (p > 0 ? 1 : -1) * (value[t] + bonus);
}
return turn * sum;
}
}7. Perft: đếm cây nước đi để phát hiện lỗi luật
Perft đếm số lá ở độ sâu d bằng cách duyệt mọi nước hợp lệ, không chạy AI và không cắt tỉa alpha–beta. Từ thế ban đầu, ba mức đầu là 20, 400, 8902. Bộ thử còn dùng thế Kiwipete với 48 và 2039 lá ở hai mức đầu để chạm tới nhập thành và nhiều tương tác quân. Một con số đúng chưa chứng minh toàn bộ luật, nhưng con số sai chỉ ra lỗi trước khi tối ưu máy đánh.
static long perft(GamePosition p, int depth) {
if (depth == 0) return 1;
long total = 0;
for (Move m : p.legalMoves()) {
total += perft(p.after(m), depth - 1);
}
return total;
}
Trong ZIP có tests/EngineChecks.java và lệnh javac ở README. Bộ thử kiểm tra thêm Xe nhập thành, Tốt bị xóa sau en passant, bốn phong cấp, chiếu hết, stalemate và khôi phục ván. Khi sửa engine, nếu tổng lá lệch hãy in số lá theo từng nước gốc; nhánh sai thu hẹp vị trí cần xem thay vì đọc lại mọi luật.
8. Bài tập nâng cấp và tiêu chí nghiệm thu
Bài tập một: thêm nhãn a–h, 1–8 mà không đổi chỉ số ô. Kiểm tra cùng chuỗi nước vẫn tạo cùng FEN. Bài tập hai: viết một thế en passant khiến lộ Xe chiếu Vua và chứng minh nước ấy bị loại. Bài tập ba: chuẩn hóa khóa lặp theo khả năng bắt en passant thực sự, rồi dựng hai FEN chỉ khác ô en passant không sử dụng được. Cần giải thích vì sao chúng có cùng tập khả năng đi tiếp.
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.
Tham khảo Laws of Chess của FIDE để phân biệt luật đi với thủ tục thi đấu. Bài tiếp theo dùng chính bộ luật này trong AI alpha–beta và giao diện Android.
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.