CollectCircles 9 - מצב משחק אוטונומי


מתג שנשמר, לולאת עדכון ועיגולים שממשיכים להופיע

בפרק הקודם התחלנו לצבור עיגולים לאורך כמה משחקים. עכשיו נוסיף מצב משחק שני: במצב Auto אין קו סיום, ועיגולים חדשים ממשיכים להופיע עד לתקרה של 12 עיגולים על הלוח. עדיין אפשר לגרור כל עיגול בעצמנו, וכל איסוף ממשיך לעדכן את שני המונים.

פרק זה ארוך משמעותית מן הפרקים הקודמים. קחו את הזמן.

חזרה לפרק 8: חיסכון שנשמר ונצבר

התוצאה שנרצה לראות

  • במצב הרגיל Start מפעיל בדיוק את המשחק המהיר שכבר בנינו.
  • הפעלת Auto מפסיקה את הסיבוב הקצר ומתחילה מיד לוח אינסופי עם שלושה עיגולים.
  • עיגול חדש מופיע בערך פעם בחמש שניות, אך לעולם לא יהיו יותר מ־12 עיגולים על המסך.
  • כיבוי Auto מחזיר למסך שממתין ל־Start.
  • הבחירה נשמרת: אם סוגרים את היישום כאשר Auto פועל, הוא חוזר למצב זה בפתיחה הבאה.

“אינסופי” אינו אומר שיוצרים אינסוף אובייקטים. המשחק יכול להמשיך ללא סוף מפני שעיגולים שנאספו מוסרים ועיגולים חדשים נוצרים, אבל מספר העיגולים שחיים באותו רגע מוגבל.

1. שומרים גם את מצב המשחק

פתחו את GameProgress.java. הוסיפו מפתח ושדה:

// Key used to persist the selected game mode in SharedPreferences.
private static final String AUTONOMOUS_MODE_KEY = "autonomous_mode";

// Cached mode used by the current screen.
private boolean autonomousMode;

בסוף הבנאי טענו אותו. ערך ברירת המחדל הוא false, ולכן התקנה קיימת נפתחת קודם במצב המשחק המוכר:

autonomousMode = preferences.getBoolean(AUTONOMOUS_MODE_KEY, false);

הוסיפו getter ו־setter:

/**
 * Returns the game mode currently selected by the player.
 */
public boolean isAutonomousMode() {
    return autonomousMode;
}

/**
 * Updates the selected mode and saves it for the next app launch.
 */
public void setAutonomousMode(boolean autonomousMode) {
    this.autonomousMode = autonomousMode;
    preferences.edit()
            .putBoolean(AUTONOMOUS_MODE_KEY, autonomousMode)
            .apply();
}

הערה שמתחילה ב־/** נקראת Javadoc. Android Studio מציג אותה למי שמשתמש בפעולה, ותגיות כמו @param,‏ @return ו־@throws מתעדות את החוזה שלה בדיוק במקום שבו זקוקים לו.

ה־setter מעדכן גם את השדה שבו המסך משתמש עכשיו וגם את העותק שייטען בפעם הבאה.

2. מוסיפים מתג בלי להקדיש לו שורה שלמה

נחליף את כפתור Start היחיד בשורה שמכילה את Start ואת מתג Auto. כך אנחנו מוסיפים בחירה חדשה בלי להקטין שוב את לוח המשחק. השינויים כמובן ב-activity_main.xml

לפני

    <!-- מתחיל סבב משחק חדש ומפעיל את מדידת הזמן. -->
    <com.google.android.material.button.MaterialButton
        android:id="@+id/startButton"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginTop="8dp"
        android:text="@string/start"
        android:textSize="16sp"
        app:cornerRadius="14dp"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/progressRow" />

    <!-- מסדר את שני כפתורי ההתראות זה לצד זה וחוסך מקום אנכי. -->
    <LinearLayout
        android:id="@+id/notificationActionsRow"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginTop="6dp"
        android:orientation="horizontal"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/startButton">

        <!-- מבקש הרשאת התראות במידת הצורך ומציג התראה מקומית לבדיקה. -->
        <com.google.android.material.button.MaterialButton
            android:id="@+id/localNotifButton"
            android:layout_width="0dp"
   

אחרי

+    <!-- מציג באותה שורה את כפתור Start ואת המתג למצב המשחק האוטונומי. -->
+    <LinearLayout
+        android:id="@+id/gameModeRow"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:layout_marginTop="8dp"
+        android:gravity="center_vertical"
+        android:orientation="horizontal"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toBottomOf="@id/progressRow">
+
         <!-- מתחיל סבב משחק חדש ומפעיל את מדידת הזמן. -->
         <com.google.android.material.button.MaterialButton
             android:id="@+id/startButton"
             android:layout_width="0dp"
             android:layout_height="wrap_content"
-            android:layout_marginTop="8dp"
+            android:layout_marginEnd="8dp"
+            android:layout_weight="1"
             android:text="@string/start"
             android:textSize="16sp"
-            app:cornerRadius="14dp"
-            app:layout_constraintEnd_toEndOf="parent"
-            app:layout_constraintStart_toStartOf="parent"
-            app:layout_constraintTop_toBottomOf="@id/progressRow" />
+            app:cornerRadius="14dp" />
+
+        <!-- מפעיל או מכבה את מצב המשחק האוטונומי. -->
+        <com.google.android.material.materialswitch.MaterialSwitch
+            android:id="@+id/autonomousSwitch"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:text="@string/autonomous_mode"
+            android:textColor="@color/title_purple"
+            android:textStyle="bold" />
+
+    </LinearLayout>
 
     <!-- מסדר את שני כפתורי ההתראות זה לצד זה וחוסך מקום אנכי. -->
     <LinearLayout
         android:id="@+id/notificationActionsRow"
         android:layout_width="0dp"
         android:layout_height="wrap_content"
         android:layout_marginTop="6dp"
         android:orientation="horizontal"
         app:layout_constraintEnd_toEndOf="parent"
         app:layout_constraintStart_toStartOf="parent"
-        app:layout_constraintTop_toBottomOf="@id/startButton">
+        app:layout_constraintTop_toBottomOf="@id/gameModeRow">
 
         <!-- מבקש הרשאת התראות במידת הצורך ומציג התראה מקומית לבדיקה. -->
         <com.google.android.material.button.MaterialButton
             android:id="@+id/localNotifButton"
             android:layout_width="0dp"
    

ב־strings.xml הוסיפו:

<string name="autonomous_mode">Auto</string>
<string name="autonomous_time">Time: autonomous</string>

3. מלמדים את Game ליצור שני סוגי משחק

נעבור לעבוד על המחלקה Game

עד עכשיו הבנאי תמיד יצר חמישה עיגולים לפי הקבוע CIRCLE_COUNT. כדי לתמוך בשני סוגי המשחק, נחליף אותו בארבעה קבועים, נוסיף שדה boolean ששומר אם המשחק אוטונומי, ונוסיף מונה חלקי לקצב הופעת העיגולים:

-private static final int CIRCLE_COUNT = 5;
+// Number of circles created for a regular timed game.
+private static final int TIMED_CIRCLE_COUNT = 5;
+// Number of circles shown when Auto mode starts.
+private static final int AUTONOMOUS_STARTING_CIRCLE_COUNT = 3;
+// Maximum number of circles allowed on the board at once.
+static final int MAX_CIRCLE_COUNT = 12;
+// Fraction of a new circle earned during one second of Auto mode.
+static final double AUTONOMOUS_SPAWN_RATE = 0.20;

 private final Random random = new Random();
 private final Target target;
 private final List<Circle> circles = new ArrayList<>();
+// True when this model represents an endless Auto game.
+private final boolean autonomous;

 private Circle selectedCircle;
+// Stores partial spawn progress until it reaches one complete circle.
+private double spawnProgress;
    

שנו את סוף חתימת הבנאי ואת יצירת העיגולים:

+/**
+ * Creates a timed or Auto game with the correct number of starting circles.
+ */
 public Game(float boardWidth, float boardHeight, float targetRadius,
             int targetColor, int crossColor, float crossStrokeWidth,
-            int circleColor) {
+            int circleColor, boolean autonomous) {
+    // Keep the selected mode for update() and isFinished().
+    this.autonomous = autonomous;
     // Keep the existing Target construction unchanged.

-    createCircles(boardWidth, boardHeight, circleColor);
+    int startingCircleCount = autonomous
+            ? AUTONOMOUS_STARTING_CIRCLE_COUNT
+            : TIMED_CIRCLE_COUNT;
+    createCircles(startingCircleCount, boardWidth, boardHeight, circleColor);
 }

מיד לאחר שינוי חתימת הבנאי, Android Studio יסמן זמנית באדום את הקריאה new Game(...) שב־GameBoardView, מפני שעדיין חסר בה הארגומנט החדש מסוג boolean. זו אינה טעות בשלב שביצעתם: בשלב 4 נעדכן את יצירת המשחק ונעביר לבנאי את הערך המתאים.

שנו גם את חתימת פעולת היצירה ואת תנאי הלולאה:

/**
 * Adds non-overlapping circles until the requested amount is reached.
 */
private void createCircles(int requestedCount, float boardWidth,
                           float boardHeight, int circleColor) {
    float circleRadius = target.getRadius() / 2f;

    while (circles.size() < requestedCount) {
        // Keep the existing loop body unchanged.
    }
}

כעת הוסיפו את לולאת העדכון של המודל:

/**
 * Advances Auto mode according to elapsed time and the on-screen limit.
 */
public void update(double elapsedSeconds, float boardWidth,
                   float boardHeight, int circleColor) {
    if (!autonomous) {
        return;
    }

    spawnProgress += elapsedSeconds * AUTONOMOUS_SPAWN_RATE;
    while (spawnProgress >= 1.0 && circles.size() < MAX_CIRCLE_COUNT) {
        createCircles(circles.size() + 1, boardWidth, boardHeight, circleColor);
        spawnProgress -= 1.0;
    }

    if (circles.size() == MAX_CIRCLE_COUNT) {
        spawnProgress = Math.min(spawnProgress, 1.0);
    }
}

spawnProgress הוא מונה חלקי. בקצב 0.20 הוא צובר חמישית עיגול בכל שנייה; לאחר חמש שניות הוא מגיע ל־1 ואפשר ליצור עיגול שלם. אם הלוח מלא, אנחנו לא שומרים “חוב” של עשרות עיגולים שיקפצו למסך ברגע שמתפנה מקום.

הוסיפו פעולה קטנה שתעזור לבדיקות:

/**
 * Returns the number of circles currently on the board.
 */
int getCircleCount() {
    return circles.size();
}

ולבסוף ודאו שמשחק אוטונומי לעולם אינו מדווח שהסתיים:

+/**
+ * Reports completion only for an empty timed game; Auto never ends itself.
+ */
 public boolean isFinished() {
-    return circles.isEmpty();
+    return !autonomous && circles.isEmpty();
 }

4. מוסיפים ל־GameBoardView לולאת פריימים

Canvas מצייר רק כאשר Android קורא ל־onDraw. כדי שעיגולים יופיעו גם כאשר המשתמש אינו נוגע במסך, נבקש פריים נוסף בעזרת postOnAnimation().

הוסיפו import:

import android.os.SystemClock;

הוסיפו שדות:

// True while the Auto frame callback should keep scheduling itself.
private boolean autonomousRunning;
// Timestamp of the previous frame, used to measure real elapsed time.
private long previousFrameMillis;

והוסיפו Runnable שמחשב כמה זמן באמת עבר:

// Repeats once per display frame while Auto mode is running.
private final Runnable autonomousFrame = new Runnable() {
    /**
     * Advances the model by one frame and requests the next drawing pass.
     */
    @Override
    public void run() {
        if (!autonomousRunning || game == null) {
            return;
        }

        long nowMillis = SystemClock.elapsedRealtime();
        double elapsedSeconds = (nowMillis - previousFrameMillis) / 1000.0;
        previousFrameMillis = nowMillis;

        game.update(elapsedSeconds, getWidth(), getHeight(), color(R.color.circle_green));
        invalidate();
        postOnAnimation(this);
    }
};

שנו את יצירת המשחק הרגיל כך שתעצור לולאה קודמת, תשתמש בפעולות העזר שנוסיף מיד אחר כך ותעביר false לבנאי:

לפני

public void startNewGame() {
    if (getWidth() == 0 || getHeight() == 0) {
        return;
    }

    float targetRadius = Math.min(
            dp(TARGET_RADIUS_DP),
            Math.min(getWidth(), getHeight()) / 5f
    );
    game = new Game(
            getWidth(),
            getHeight(),
            targetRadius,
            color(R.color.target_red),
            color(R.color.target_cross),
            dp(2f),
            color(R.color.circle_green)
    );
    invalidate();
}
    

אחרי

+/**
+ * Starts a timed game and cancels any Auto frame loop left running.
+ */
 public void startNewGame() {
+    stopAutonomousFrames();
     if (getWidth() == 0 || getHeight() == 0) {
         return;
     }

-    float targetRadius = Math.min(
-            dp(TARGET_RADIUS_DP),
-            Math.min(getWidth(), getHeight()) / 5f
-    );
-    game = new Game(
-            getWidth(),
-            getHeight(),
-            targetRadius,
-            color(R.color.target_red),
-            color(R.color.target_cross),
-            dp(2f),
-            color(R.color.circle_green)
-    );
+    game = createGame(calculateTargetRadius(), false);
     invalidate();
 }
    

כדי לא להעתיק את רשימת הצבעים לשתי פעולות, חלצו את יצירת המודל:

/**
 * Builds either game mode with the same board measurements and colors.
 */
private Game createGame(float targetRadius, boolean autonomous) {
    return new Game(
            getWidth(),
            getHeight(),
            targetRadius,
            color(R.color.target_red),
            color(R.color.target_cross),
            dp(2f),
            color(R.color.circle_green),
            autonomous
    );
}

הוסיפו את פעולות המצב האוטונומי:

/**
 * Creates a fresh Auto game and starts its frame loop.
 */
public void startAutonomousGame() {
    if (getWidth() == 0 || getHeight() == 0) {
        return;
    }

    float targetRadius = calculateTargetRadius();
    game = createGame(targetRadius, true);
    resumeAutonomousGame();
    invalidate();
}

/**
 * Resumes frame updates without replacing the current game.
 */
public void resumeAutonomousGame() {
    if (game == null || autonomousRunning) {
        return;
    }
    autonomousRunning = true;
    previousFrameMillis = SystemClock.elapsedRealtime();
    postOnAnimation(autonomousFrame);
}

/**
 * Pauses frame updates while preserving the current game.
 */
public void pauseAutonomousGame() {
    stopAutonomousFrames();
}

/**
 * Stops all updates and removes the current game from the board.
 */
public void clearGame() {
    stopAutonomousFrames();
    game = null;
    invalidate();
}

/**
 * Cancels the scheduled callback so only one Auto loop can exist.
 */
private void stopAutonomousFrames() {
    autonomousRunning = false;
    removeCallbacks(autonomousFrame);
}

חלצו גם את חישוב הרדיוס שהוסר מ־startNewGame() לפעולה, כדי ששני המצבים יקבלו מטרה באותו גודל בדיוק:

/**
 * Calculates the target size shared by timed and Auto games.
 */
private float calculateTargetRadius() {
    return Math.min(
            dp(TARGET_RADIUS_DP),
            Math.min(getWidth(), getHeight()) / 5f
    );
}

הערה: בנקודה זו המשחק שוב מתקמפל

5. מחברים את המתג ב־MainActivity

אחרי showProgress() ב־onCreate, קבעו את מצב המתג לפני שמוסיפים לו listener. כך הטעינה הראשונית לא תיראה כמו שינוי שביצע המשתמש:

// Restore the saved switch position before attaching its listener.
binding.autonomousSwitch.setChecked(gameProgress.isAutonomousMode());
// Build the screen that matches the restored mode.
showSelectedGameMode();

הוסיפו listener לצד listeners של הכפתורים:

// Persist mode changes and immediately rebuild the game screen.
binding.autonomousSwitch.setOnCheckedChangeListener((button, enabled) -> {
    gameProgress.setAutonomousMode(enabled);
    showSelectedGameMode();
});

והוסיפו את הפעולה שמחליפה בין המצבים:

/**
 * Stops the previous mode and configures the selected game mode.
 */
private void showSelectedGameMode() {
    // Removes every queued execution of this exact Runnable from the View's message queue.
    // This stops future timer updates but does not delete or disable timerUpdate,
    // so the same Runnable can be started again later with timerUpdate.run().
    binding.elapsedTimeText.removeCallbacks(timerUpdate);
    gameRunning = false;

    if (gameProgress.isAutonomousMode()) {
        binding.startButton.setEnabled(false);
        binding.elapsedTimeText.setText(R.string.autonomous_time);
        // Wait until GameBoardView has valid width and height measurements.
        binding.gameBoard.post(() -> {
            if (gameProgress.isAutonomousMode()) {
                binding.gameBoard.startAutonomousGame();
            }
        });
    } else {
        binding.startButton.setEnabled(true);
        binding.elapsedTimeText.setText(R.string.elapsed_time_initial);
        binding.gameBoard.clearGame();
    }
}

הבדיקה השנייה של isAutonomousMode() מתבצעת מפני שה־Runnable ירוץ רק לאחר שה־View יסיים להימדד. אם המשתמש הספיק לכבות Auto בזמן הקצר הזה, לא נפתח בטעות משחק אוטונומי ישן.

ב־onStart ממשיכים שעון של משחק רגיל או את לולאת Auto:

+/**
+ * Resumes the update loop that belongs to the selected game mode.
+ */
 @Override
 protected void onStart() {
     super.onStart();
-    if (gameRunning) {
+    // Resume only the loop that belongs to the selected game mode.
+    if (gameProgress.isAutonomousMode()) {
+        binding.gameBoard.resumeAutonomousGame();
+    } else if (gameRunning) {
         binding.elapsedTimeText.removeCallbacks(timerUpdate);
         timerUpdate.run();
     }
 }

וב־onStop עוצרים את שניהם:

/**
 * Stops both update loops while the Activity is not visible.
 */
@Override
protected void onStop() {
    binding.elapsedTimeText.removeCallbacks(timerUpdate);
    binding.gameBoard.pauseAutonomousGame();
    super.onStop();
}

עצירת הפריימים ברקע אינה מבטלת את מצב Auto השמור. היא רק מונעת מהמסך לצייר כאשר אי אפשר לראות אותו. בפרק האופליין נחשב את ההתקדמות שעברה בזמן הזה בלי לצייר אלפי פריימים מיותרים.

6. בודקים את קצב היצירה ואת התקרה

ב־ExampleUnitTest.java הוסיפו:

import static org.junit.Assert.assertEquals;

והוסיפו בדיקה:

/**
 * Verifies the Auto spawn rate and the maximum on-screen circle count.
 */
@Test
public void autonomousGameSpawnsSlowlyAndStopsAtTheScreenCap() {
    Game game = new Game(1000, 1000, 100,
            0, 0, 1, 0, true);

    assertEquals(3, game.getCircleCount());

    game.update(5.1, 1000, 1000, 0);
    assertEquals(4, game.getCircleCount());

    game.update(1000, 1000, 1000, 0);
    assertEquals(Game.MAX_CIRCLE_COUNT, game.getCircleCount());
}

כדי לוודא שלולאת העדכון החדשה אינה משנה את המשחק הקצר, הוסיפו גם:

/**
 * Verifies that elapsed time does not add circles to a timed game.
 */
@Test
public void timedGameDoesNotSpawnMoreCircles() {
    Game game = new Game(1000, 1000, 100,
            0, 0, 1, 0, false);

    game.update(1000, 1000, 1000, 0);

    assertEquals(5, game.getCircleCount());
}

הריצו (פעם ראשונה שלנו) בדיקות UnitTests

.\gradlew.bat testDebugUnitTest assembleDebug lintDebug

אם הפקודה אינה עובדת ומחזירה JAVA_HOME is not set או אינה מוצאת את java, נסו:

$env:JAVA_HOME = "C:\Program Files\Android\Android Studio\jbr"
$env:Path = "$env:JAVA_HOME\bin;$env:Path"
.\gradlew.bat testDebugUnitTest assembleDebug lintDebug

אם הבלוק הזה עובד, סימן שה־JDK של Android Studio קיים אך אינו מוגדר עבור מסוף חדש. כדי שלא יהיה צורך בשתי שורות ההגדרה בכל פעם, חפשו ב־Windows את Edit environment variables for your account, הגדירו משתנה משתמש בשם JAVA_HOME עם הערך:

C:\Program Files\Android\Android Studio\jbr

לאחר מכן הוסיפו למשתנה המשתמש Path את התיקייה:

C:\Program Files\Android\Android Studio\jbr\bin

בצעו signup מ-windows ואז כנסו שוב ובידקו שנית את ה־Terminal, ואז ודאו שהפקודה המקוצרת עובדת גם ללא שתי שורות ההגדרה שמעליה.

7. בדיקה ידנית

  1. פתחו את היישום במצב הרגיל וודאו ש־Start עדיין יוצר חמישה עיגולים והשעון עובד.
  2. הפעילו Auto. Start צריך להיות מושבת, תווית הזמן צריכה להציג Time: autonomous, ושלושה עיגולים צריכים להופיע מיד.
  3. המתינו כחמש שניות וודאו שמופיע עיגול נוסף בלי נגיעה במסך.
  4. גררו עיגולים למטרה. מוני Circles ו־Lifetime צריכים לגדול, והמשחק אינו מציג חלון סיום.
  5. השאירו את המשחק פתוח עד שהלוח מגיע ל־12 עיגולים. ודאו שאינו עובר את התקרה.
  6. כבו Auto. הלוח צריך להתנקות ו־Start צריך לחזור לפעולה.
  7. הפעילו Auto, סגרו את היישום ופתחו אותו שוב. המתג והלוח האוטונומי צריכים לחזור.
  8. ודאו שכפתורי ההתראות עדיין נכנסים בשורה ואינם מכסים את הלוח.

כעת קיימים באותה Activity שני משחקים: אתגר קצר שהמשתמש מתחיל, ומשחק אוטונומי שממשיך לייצר עבודה. בפרק הבא נוסיף חנות ונאפשר להוציא את יתרת העיגולים על ה־Pusher הראשון.

המשך לפרק 10: חנות ה־Pushers