Android Java

Game nông trại 2D Android: gieo hạt, tưới cây và lưu tiến độ

Xây game Canvas Java có 12 ô đất, hai loại cây, ví xu, năng lượng và kiểm thử thời gian bằng đồng hồ truyền vào.

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

Thích và đánh giá Bình luận và hỏi đáp
Mỗi ô đất chuyển từ trống tới gieo, tưới, chín và thu hoạch; ví chỉ nhận thưởng một lần.
Mỗi ô đất chuyển từ trống tới gieo, tưới, chín và thu hoạch; ví chỉ nhận thưởng một lần. Mở hình lớn ↗

Xây game Canvas Java có 12 ô đất, hai loại cây, ví xu, năng lượng và kiểm thử thời gian bằng đồng hồ truyền vào. 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. Đặc tả một vòng chơi hoàn chỉnh

Nông trại trong dự án có 12 ô đất, hai loại cây, ví xu và năng lượng. Người chơi gieo hạt, tưới, chờ chín rồi thu hoạch. Cà rốt mất 30 giây, giá hạt 3 xu, bán được 8; ngô mất 60 giây, giá 5, bán được 14. Mỗi hành động hợp lệ tiêu hao một năng lượng; năng lượng hồi một đơn vị mỗi 10 giây, tối đa 20. Đây là game 2D Android gốc, khác với các mô phỏng web trên trang.

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 luật khỏi Canvas và đồng hồ thật

FarmCore là lớp Java không import Android: chứa ví, năng lượng, loại cây, mốc gieo và cờ tưới. FarmActivity giữ View, Handler, thao tác chạm và lưu dữ liệu. Mọi hàm cần thời gian đều nhận now, vì vậy kiểm thử có thể cho thời gian nhảy tới 29.999 hoặc 30.000 ms mà không chờ thật nửa phút. Truyền thời gian cũng tránh việc nhiều hàm cùng gọi đồng hồ ở các thời điểm hơi khác trong một thao tác.

FarmCore farm = new FarmCore(0);
farm.sow(0, 1, 0);           // Cà rốt, ô 0
farm.water(0, 1000);
System.out.println(farm.ready(0, 29999)); // false
System.out.println(farm.ready(0, 30000)); // true
farm.harvest(0, 30000);      // ví: 30 - 3 + 8 = 35

3. Máy trạng thái của một ô đất

Một ô đi từ Trống sang Đã gieo, sau đó có cờ Đã tưới và tiến độ tăng theo thời gian. Chỉ ô có cây, đã tưới và đạt tiến độ 1 mới thu hoạch được. Sau thu hoạch, loại cây trở về 0, thời điểm gieo và cờ tưới được xóa. Trạng thái “đã chín” không cần lưu thành một cờ có thể lệch khỏi đồng hồ: nó được suy ra khi đọc.

Thao tácTiền điều kiệnTác động
GieoĐất trống, đủ xu, đủ năng lượngTrừ hạt và 1 năng lượng; ghi mốc gieo
TướiCó cây, chưa tưới, còn năng lượngBật cờ tưới; trừ 1 năng lượng
Thu hoạchĐủ thời gian, đã tưới, còn năng lượngCộng giá bán một lần rồi xóa cây

Nhấn Tưới lần hai không tốn thêm năng lượng. Nhấn Thu hoạch sau khi cây đã bị xóa không cộng thêm xu. Đây là cách thiết kế chống tác động lặp ở tầng luật, không dựa riêng vào việc vô hiệu hóa nút trong UI. Khi chuyển sang game có mạng, nguyên tắc này vẫn cần nhưng phải thực hiện tại máy chủ với mã yêu cầu và giao dịch phù hợp.

4. Tiến độ và hồi năng lượng

Tiến độ là (now−plantedAt)/growTime, được chặn về [0,1]. Tốc độ chín vì vậy không phụ thuộc số khung hình. Năng lượng dùng số khoảng 10 giây đã trôi qua; nếu chỉ cộng một đơn vị ở mỗi lần mở app thì đóng app lâu vẫn chỉ hồi một, trái với đặc tả. Khi đầy, mốc hồi được dịch tới hiện tại để không tích vô hạn thời gian dự trữ rồi nhận lại ngay sau khi tiêu hao.

Đồng hồ lưu qua lần đóng/mở dùng thời gian lịch trên máy. Cách này hỗ trợ cây tiếp tục lớn lúc app không mở mà không cần service chạy nền. Tuy nhiên, người dùng chỉnh đồng hồ có thể làm cây chín sớm hoặc trì hoãn tiến độ. Bản học tập không chống gian lận. Nếu cần kinh tế nhiều người chơi, lấy mốc tin cậy từ máy chủ; không giải quyết bằng một câu if trong Canvas.

5. Vẽ bàn đất và đổi chạm thành ô chọn

Activity bố trí lưới 3 hàng × 4 cột. Ô đất là hình chữ nhật bo góc, cây là hình tròn tăng kích thước theo tiến độ, thanh nhỏ phía dưới cho biết mức lớn. Màu và nhãn giúp phân biệt Đất trống, Cần tưới, Đang lớn, Thu hoạch. Khi thay bằng sprite, giữ nguyên các trường mô hình và điều kiện ready; ảnh hoạt họa không được quyết định số xu nhận.

Vùng chạm lấy hàng/cột từ khoảng cách tới góc lưới và kích thước cell. Kiểm tra biên trước khi đổi thành chỉ số; chạm vào viền ngoài không được chọn ô 12 không tồn tại. Các nút hành động luôn dùng ô selected. Nên hiển thị ô đang chọn rõ ràng để người dùng không gieo nhầm khi bàn đất dày hơn trên điện thoại nhỏ.

6. Cập nhật theo vòng đời và lưu trạng thái

Handler chỉ chạy nhịp cập nhật giao diện khi Activity resume. onPause bỏ callback và lưu FarmCore.encode vào SharedPreferences; onResume đọc thời gian hiện tại và vẽ lại. Việc cây lớn khi app đóng được tính từ mốc gieo, không có vòng lặp bí mật chạy suốt đêm. Bundle giữ trạng thái khi xoay màn hình; dữ liệu mã hóa có số phiên bản để có thể thay đổi định dạng sau này.

restore kiểm tra số trường, loại cây và phạm vi năng lượng. Dữ liệu hỏng được xử lý bằng khởi tạo trạng thái mới trong app mẫu. Trong sản phẩm thực, cần thông báo/khôi phục bản sao thay vì lặng lẽ mất tiến độ. Tương tự, mã hóa chuỗi không có nghĩa là mã hóa bảo mật; SharedPreferences ở đây chỉ là lưu cục bộ trong vùng riêng của ứng dụng.

7. Mã giao diện thực tế và tệp logic đi kèm

Tệp dưới là FarmActivity.java; lớp FarmCore và kiểm thử nằm trong ZIP. Đọc onCreate để thấy cấu trúc màn hình, listener nút để thấy lệnh đi vào model, rồi onDraw để thấy cách cùng một trạng thái được trình bày. Không sao chép riêng Activity vào một app trống mà bỏ FarmCore hoặc manifest.

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 · FarmActivity.javaTải mã nguồn
package vn.kienutt.farm;

import android.app.Activity;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.view.MotionEvent;
import android.view.View;
import android.widget.Button;
import android.widget.LinearLayout;
import android.widget.TextView;

public final class FarmActivity extends Activity {
  private FarmCore farm;
  private TextView status, message;
  private PlotView plots;
  private int selected = 0;
  private final Handler timer = new Handler(Looper.getMainLooper());
  private final Runnable tick =
      new Runnable() {
        public void run() {
          farm.tick(System.currentTimeMillis());
          refresh();
          timer.postDelayed(this, 1000);
        }
      };

  private int dp(int n) {
    return (int) (n * getResources().getDisplayMetrics().density + .5f);
  }

  @Override
  public void onCreate(Bundle state) {
    super.onCreate(state);
    String data =
        state != null
            ? state.getString("farm")
            : getPreferences(MODE_PRIVATE).getString("farm", null);
    try {
      farm = data == null ? new FarmCore(System.currentTimeMillis()) : FarmCore.restore(data);
    } catch (RuntimeException e) {
      farm = new FarmCore(System.currentTimeMillis());
    }
    if (state != null) selected = state.getInt("selected", 0);
    LinearLayout root = new LinearLayout(this);
    root.setOrientation(LinearLayout.VERTICAL);
    root.setBackgroundColor(Color.rgb(238, 246, 230));
    root.setOnApplyWindowInsetsListener(
        (v, i) -> {
          v.setPadding(
              dp(12) + i.getSystemWindowInsetLeft(),
              dp(8) + i.getSystemWindowInsetTop(),
              dp(12) + i.getSystemWindowInsetRight(),
              dp(8) + i.getSystemWindowInsetBottom());
          return i;
        });
    TextView title = new TextView(this);
    title.setText("Nông trại nhỏ · Android Java");
    title.setTextSize(23);
    root.addView(title);
    status = new TextView(this);
    status.setTextSize(17);
    root.addView(status);
    plots = new PlotView();
    root.addView(plots, new LinearLayout.LayoutParams(-1, 0, 1));
    for (String[] row :
        new String[][] {
          {"Cà rốt · 3 xu / 30 giây", "Ngô · 5 xu / 60 giây"}, {"Tưới cây", "Thu hoạch"}
        }) {
      LinearLayout line = new LinearLayout(this);
      for (String label : row) {
        Button b = new Button(this);
        b.setText(label);
        b.setOnClickListener(
            v -> {
              long now = System.currentTimeMillis();
              String result =
                  label.startsWith("Cà")
                      ? farm.sow(selected, 1, now)
                      : label.startsWith("Ngô")
                          ? farm.sow(selected, 2, now)
                          : label.startsWith("Tưới")
                              ? farm.water(selected, now)
                              : farm.harvest(selected, now);
              message.setText(result);
              save();
              refresh();
            });
        line.addView(b, new LinearLayout.LayoutParams(0, -2, 1));
      }
      root.addView(line);
    }
    message = new TextView(this);
    message.setText("Chạm ô đất, gieo hạt, tưới rồi chờ cây chín. Mỗi 10 giây hồi 1 năng lượng.");
    root.addView(message);
    setContentView(root);
    refresh();
  }

  private void save() {
    getPreferences(MODE_PRIVATE).edit().putString("farm", farm.encode()).apply();
  }

  private void refresh() {
    status.setText(
        farm.coins() + " xu · " + farm.energy() + "/20 năng lượng · Ô " + (selected + 1));
    plots.invalidate();
  }

  @Override
  public void onResume() {
    super.onResume();
    timer.post(tick);
  }

  @Override
  public void onPause() {
    timer.removeCallbacks(tick);
    save();
    super.onPause();
  }

  @Override
  public void onSaveInstanceState(Bundle b) {
    b.putString("farm", farm.encode());
    b.putInt("selected", selected);
    super.onSaveInstanceState(b);
  }

  private final class PlotView extends View {
    private final Paint p = new Paint(Paint.ANTI_ALIAS_FLAG);
    private float cell, left, top;

    PlotView() {
      super(FarmActivity.this);
      setContentDescription("12 ô đất. Chạm để chọn ô trồng cây.");
    }

    @Override
    protected void onDraw(Canvas c) {
      super.onDraw(c);
      cell = Math.min(getWidth() / 4f, getHeight() / 3f);
      left = (getWidth() - cell * 4) / 2;
      top = (getHeight() - cell * 3) / 2;
      long now = System.currentTimeMillis();
      p.setTextAlign(Paint.Align.CENTER);
      for (int i = 0; i < 12; i++) {
        float x = left + (i % 4) * cell, y = top + (i / 4) * cell;
        p.setColor(i == selected ? Color.rgb(35, 126, 83) : Color.rgb(125, 80, 48));
        c.drawRoundRect(x + 4, y + 4, x + cell - 4, y + cell - 4, dp(10), dp(10), p);
        p.setColor(Color.WHITE);
        p.setTextSize(cell * .15f);
        c.drawText("Ô " + (i + 1), x + cell / 2, y + cell * .21f, p);
        int kind = farm.crop(i);
        if (kind == 0) {
          c.drawText("Đất trống", x + cell / 2, y + cell * .58f, p);
          continue;
        }
        double progress = farm.progress(i, now);
        p.setColor(kind == 1 ? Color.rgb(255, 166, 61) : Color.rgb(245, 222, 74));
        float rad = (float) (cell * (.1 + .11 * progress));
        c.drawCircle(x + cell / 2, y + cell * .5f, rad, p);
        p.setColor(Color.WHITE);
        c.drawText(
            farm.ready(i, now) ? "Thu hoạch!" : farm.watered(i) ? "Đã tưới" : "Cần tưới",
            x + cell / 2,
            y + cell * .8f,
            p);
        p.setColor(Color.rgb(69, 174, 113));
        c.drawRect(
            x + cell * .1f,
            y + cell * .87f,
            (float) (x + cell * .1f + cell * .8f * progress),
            y + cell * .92f,
            p);
      }
    }

    @Override
    public boolean onTouchEvent(MotionEvent e) {
      if (e.getAction() != MotionEvent.ACTION_UP) return true;
      performClick();
      int col = (int) Math.floor((e.getX() - left) / cell),
          row = (int) Math.floor((e.getY() - top) / cell);
      if (col >= 0 && col < 4 && row >= 0 && row < 3) {
        selected = row * 4 + col;
        refresh();
      }
      return true;
    }

    @Override
    public boolean performClick() {
      super.performClick();
      return true;
    }
  }
}

8. Kiểm thử thời gian mà không chờ thật

tests/FarmChecks.java kiểm tra chi phí gieo, không trồng chồng cây, tưới lặp, biên 29.999/30.000 ms, lưu/khôi phục, thu hoạch chỉ một lần, năng lượng không vượt trần và dữ liệu lỗi. Ca “đủ thời gian nhưng chưa tưới” phải chưa được thu hoạch; nếu chỉ thử đủ thời gian sau khi đã tưới, thiếu cờ tưới có thể không bị phát hiện.

Bài tập một: thêm cây dâu 90 giây, giá hạt 7 và giá bán 20; tính lãi ròng và năng lượng cần trước khi sửa bảng. Bài tập hai: thêm kho hàng để thu hoạch tăng nông sản, bán mới tăng xu; viết một chuỗi chứng minh không thể nhận cả giá bán ngay lúc thu hoạch rồi nhận lần hai khi bán. Bài tập ba: chuyển 12 ô sang bảng SQLite khi có nhiều nông trại, xác định giao dịch cần bao gồm cả ví và ô đất.

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.

Đọc thêm tài liệu gốc: bộ công cụ AGP 8.6, vẽ Custom View với Canvas và SQLite trên Android. Các lớp và dữ liệu trong bài được xây dựng cho thực hành, không phải mã của các tổ chức được dẫn nguồn.

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.