CollectCircles 8 - חיסכון שנצבר ונשמר


סופרים עיגולים, שומרים התקדמות ומפנים מקום על המסך

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

מה נבנה

  • Circles — היתרה הנוכחית. בפרק הבא נוכל להוציא ממנה עיגולים.
  • Lifetime — מספר העיגולים שנאספו אי פעם. קנייה עתידית לא תקטין אותו.
  • Pushers — מספר העובדים הקטנים שלנו. כרגע הוא מתחיל באפס; נלמד לקנות אותם בהמשך.
  • שורת פעולות קומפקטית, כדי שהמידע החדש לא יקטין יותר מדי את לוח המשחק.

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

1. יוצרים מחלקה ששומרת את ההתקדמות

בחלון Android, תחת app > kotlin+java > com.example.collectcircles, צרו Java Class בשם GameProgress:

package com.example.collectcircles;

import android.content.SharedPreferences;

public class GameProgress {

    // המפתחות קובעים תחת איזה שם יישמר כל ערך ב-SharedPreferences.
    private static final String CIRCLES_BALANCE_KEY = "circles_balance";
    private static final String LIFETIME_CIRCLES_KEY = "lifetime_circles";
    private static final String PUSHER_COUNT_KEY = "pusher_count";

    // אובייקט השמירה מאפשר לקרוא את ההתקדמות ולשמור אותה במכשיר.
    private final SharedPreferences preferences;

    // עותק של ההתקדמות בזיכרון, לשימוש מהיר כל עוד היישום פועל.
    private long circlesBalance;
    private long lifetimeCircles;
    private int pusherCount;

    public GameProgress(SharedPreferences preferences) {
        this.preferences = preferences;

        // טוענים את הערכים שנשמרו. אם עדיין אין ערך שמור, מתחילים מאפס.
        circlesBalance = preferences.getLong(CIRCLES_BALANCE_KEY, 0);
        lifetimeCircles = preferences.getLong(LIFETIME_CIRCLES_KEY, 0);
        pusherCount = preferences.getInt(PUSHER_COUNT_KEY, 0);
    }

    // מחזיר את מספר העיגולים שאפשר יהיה להוציא על קניות.
    public long getCirclesBalance() {
        return circlesBalance;
    }

    // מחזיר את מספר העיגולים הכולל שנאסף מאז תחילת המשחק.
    public long getLifetimeCircles() {
        return lifetimeCircles;
    }

    // מחזיר את מספר העובדים שנרכשו.
    public int getPusherCount() {
        return pusherCount;
    }

    // מעדכן את שני המונים לאחר שהשחקן אסף עיגול אחד.
    public void recordCollectedCircle() {
        // עוצרים בערך הגדול ביותר כדי שהמונה לא יגלוש למספר שלילי.
        if (circlesBalance < Long.MAX_VALUE) {
            circlesBalance++;
        }
        if (lifetimeCircles < Long.MAX_VALUE) {
            lifetimeCircles++;
        }

        // שומרים את הערכים החדשים במכשיר; apply מבצע את הכתיבה ברקע.
        preferences.edit()
                .putLong(CIRCLES_BALANCE_KEY, circlesBalance)
                .putLong(LIFETIME_CIRCLES_KEY, lifetimeCircles)
                .apply();
    }
}

המחלקה מחזיקה שני סוגים של מידע:

  1. המצב בזיכרון — השדות שבהם משתמשים בזמן שהיישום רץ.
  2. המצב השמור — הערכים ב־SharedPreferences, שיישארו גם לאחר סגירת היישום.

הבדיקה מול Long.MAX_VALUE אינה צפויה להיות חשובה במשחק רגיל, אבל היא מונעת מונה שהתמלא להתגלגל למספר שלילי.

אל תגדילו את המונים פעם אחת ב־Game ופעם נוספת ב־MainActivity. רק recordCollectedCircle() משנה את ההתקדמות, ולכן כל איסוף מוסיף בדיוק עיגול אחד.

2. מוסיפים שורת מצב ומכווצים את כפתורי ההתראות

קוד מלא של activity_main.xml: בסעיף זה עדיף להעתיק את הקוד המלא ולעבור לשינוים שבסוף הסעיף בקובץ ה-Java
<?xml version="1.0" encoding="utf-8"?>
<!-- מיכל המסך הראשי: ממקם את כל הרכיבים זה ביחס לזה בעזרת אילוצים. -->
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@color/screen_background"
    android:paddingHorizontal="20dp"
    tools:context=".MainActivity">

    <!-- מציג את שם המשחק בראש המסך. -->
    <TextView
        android:id="@+id/titleText"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginTop="20dp"
        android:gravity="center"
        android:text="@string/game_title"
        android:textColor="@color/title_purple"
        android:textSize="26sp"
        android:textStyle="bold"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <!-- מציג את הזמן שחלף מאז תחילת הסבב הנוכחי. -->
    <TextView
        android:id="@+id/elapsedTimeText"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginTop="12dp"
        android:gravity="start"
        android:text="@string/elapsed_time_initial"
        android:textColor="@color/time_brown"
        android:textSize="16sp"
        app:layout_constraintEnd_toStartOf="@id/bestTimeText"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/titleText" />

    <!-- מציג את זמן השיא שנשמר מהסבבים הקודמים. -->
    <TextView
        android:id="@+id/bestTimeText"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:gravity="end"
        android:text="@string/best_time_initial"
        android:textColor="@color/title_purple"
        android:textSize="16sp"
        app:layout_constraintBaseline_toBaselineOf="@id/elapsedTimeText"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toEndOf="@id/elapsedTimeText" />

    <!-- מסדר את שלושת מוני ההתקדמות זה לצד זה בשורה אחת. -->
    <LinearLayout
        android:id="@+id/progressRow"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginTop="8dp"
        android:gravity="center"
        android:orientation="horizontal"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/elapsedTimeText">

        <!-- מציג את יתרת העיגולים הזמינה לקניות עתידיות. -->
        <TextView
            android:id="@+id/circlesBalanceText"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:gravity="start"
            android:text="@string/circles_balance_initial"
            android:textColor="@color/title_purple"
            android:textStyle="bold" />

        <!-- מציג כמה עיגולים נאספו בסך הכול מאז תחילת המשחק. -->
        <TextView
            android:id="@+id/lifetimeCirclesText"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:gravity="center"
            android:text="@string/lifetime_circles_initial"
            android:textColor="@color/time_brown" />

        <!-- מציג את מספר העובדים שנרכשו; בפרק הזה הערך עדיין אפס. -->
        <TextView
            android:id="@+id/pushersText"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:gravity="end"
            android:text="@string/pushers_initial"
            android:textColor="@color/title_purple" />

    </LinearLayout>

    <!-- מתחיל סבב משחק חדש ומפעיל את מדידת הזמן. -->
    <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"
            android:layout_height="wrap_content"
            android:layout_marginEnd="4dp"
            android:layout_weight="1"
            android:minWidth="0dp"
            android:text="@string/local_notification_short"
            android:textSize="12sp"
            app:cornerRadius="12dp" />

        <!-- שולח דרך Firebase הזמנה למכשיר אחר שהתקין את המשחק. -->
        <com.google.android.material.button.MaterialButton
            android:id="@+id/inviteButton"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_marginStart="4dp"
            android:layout_weight="1"
            android:minWidth="0dp"
            android:text="@string/invite"
            android:textSize="12sp"
            app:cornerRadius="12dp" />

    </LinearLayout>

    <!-- משטח המשחק המותאם אישית, שמצייר את העיגולים ומטפל בגרירתם. -->
    <com.example.collectcircles.GameBoardView
        android:id="@+id/gameBoard"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:layout_marginTop="16dp"
        android:layout_marginBottom="20dp"
        android:minHeight="320dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/notificationActionsRow"
        tools:background="@color/board_background" />

</androidx.constraintlayout.widget.ConstraintLayout>
זו הגרסה המפורטת. מומלץ לעיין כדי להבין את השינוי בגדול, ואז לקפל ולהמשיך לשינויים שבקובץ ה-JAVA

פתחו app > res > layout > activity_main.xml. שורת הזמנים נשארת במקומה. מתחתיה נוסיף שלושה מונים, ואחר כך נסדר את שני כפתורי ההתראות באותה שורה.

בצעו שלושה שינויים ב־XML:

  1. הוסיפו את progressRow מיד לפני startButton.
  2. החליפו את startButton בגרסה המעודכנת, שהאילוץ העליון שלה מצביע אל progressRow.
  3. החליפו את localNotifButton ואת inviteButton הנפרדים ב־notificationActionsRow, שמכילה את שני הכפתורים.

אל תמחקו את gameBoard שנמצא ביניהם בקובץ הקיים. מיד אחרי הקוד הגדול נעדכן רק אילוץ אחד שלו.

להלן ה-XML של שלושת הרכיבים החדשים או המעודכנים. כיוון שיש עוד הרבה שינויים קטנים, מקופל גם (לעיל) התוכן המלא

<LinearLayout
    android:id="@+id/progressRow"
    android:layout_width="0dp"
    android:layout_height="wrap_content"
    android:layout_marginTop="8dp"
    android:gravity="center"
    android:orientation="horizontal"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toBottomOf="@id/elapsedTimeText">

    <TextView
        android:id="@+id/circlesBalanceText"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_weight="1"
        android:gravity="start"
        android:text="@string/circles_balance_initial"
        android:textColor="@color/title_purple"
        android:textStyle="bold" />

    <TextView
        android:id="@+id/lifetimeCirclesText"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_weight="1"
        android:gravity="center"
        android:text="@string/lifetime_circles_initial"
        android:textColor="@color/time_brown" />

    <TextView
        android:id="@+id/pushersText"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_weight="1"
        android:gravity="end"
        android:text="@string/pushers_initial"
        android:textColor="@color/title_purple" />
</LinearLayout>

<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"
        android:layout_height="wrap_content"
        android:layout_marginEnd="4dp"
        android:layout_weight="1"
        android:minWidth="0dp"
        android:text="@string/local_notification_short"
        android:textSize="12sp"
        app:cornerRadius="12dp" />

    <com.google.android.material.button.MaterialButton
        android:id="@+id/inviteButton"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginStart="4dp"
        android:layout_weight="1"
        android:minWidth="0dp"
        android:text="@string/invite"
        android:textSize="12sp"
        app:cornerRadius="12dp" />
</LinearLayout>

עדכנו את האילוץ העליון של gameBoard:

-app:layout_constraintTop_toBottomOf="@id/inviteButton"
+app:layout_constraintTop_toBottomOf="@id/notificationActionsRow"

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

<string name="circles_balance_initial">Circles: 0</string>
<string name="lifetime_circles_initial">Lifetime: 0</string>
<string name="pushers_initial">Pushers: 0</string>
<string name="circles_balance_format">Circles: %1$d</string>
<string name="lifetime_circles_format">Lifetime: %1$d</string>
<string name="pushers_format">Pushers: %1$d</string>
<string name="local_notification_short">Local notification</string>

layout_weight="1" מחלק את רוחב השורה באופן שווה. כך לא נדרש מסך רכישה נוסף, ולוח המשחק עדיין מקבל את רוב הגובה.

בדיקה: בשלב זה האפליקציה אמורה לרוץ (ללא שינוי פונקצינאלי - אבל ב-UI נראה שינוי)

3. מודיעים על כל עיגול שנאסף

Game כבר מחזיר true כאשר עיגול שוחרר כולו בתוך המטרה. GameBoardView צריך להעביר את האירוע לפעילות מיד, ולא להמתין עד שכל חמשת העיגולים ייאספו.

+private Runnable onCircleCollected;
private Runnable onGameFinished;

+public void setOnCircleCollectedListener(Runnable listener) {
+    onCircleCollected = listener;
+}

// ACTION_UP
+if (collected && onCircleCollected != null) {
+    onCircleCollected.run();
+}
if (collected && game.isFinished()
        && onGameFinished != null) {
    onGameFinished.run();
}

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

4. מחברים את ההתקדמות ל־MainActivity

הוסיפו שדה:

private GameProgress gameProgress;

אחרי יצירת preferences ב־onCreate, טענו והציגו את ההתקדמות:

preferences = getSharedPreferences(PREFERENCES_NAME, MODE_PRIVATE);
+gameProgress = new GameProgress(preferences);
bestTimeMillis = preferences.getLong(BEST_TIME_KEY, NO_BEST_TIME);
showBestTime();
+showProgress();

בתוך onCreate, אחרי חיבור הכפתור inviteButton ומיד לפני האירוע הקיים של סיום המשחק, הוסיפו את השורה המסומנת ב־+:

        binding.localNotifButton.setOnClickListener(
                view -> requestPermissionAndShowNotification()
        );
        binding.inviteButton.setOnClickListener(view -> sendInvite());
+       binding.gameBoard.setOnCircleCollectedListener(this::recordCollectedCircle);
        binding.gameBoard.setOnGameFinishedListener(this::finishGame);
    } // כאן מסתיים onCreate

והוסיפו שתי פעולות:

private void recordCollectedCircle() {
    gameProgress.recordCollectedCircle();
    showProgress();
}

private void showProgress() {
    // getString טוענת את תבנית הטקסט circles_balance_format מתוך strings.xml.
    // הארגומנט השני מחליף את %1$d שבתבנית בערך המספרי של היתרה.
    // את הטקסט המוכן מעבירים אל setText כדי לעדכן את ה-TextView.
    binding.circlesBalanceText.setText(getString(
            R.string.circles_balance_format,
            gameProgress.getCirclesBalance()
    ));

    // באותה דרך מציגים את מספר העיגולים הכולל שנאסף.
    binding.lifetimeCirclesText.setText(getString(
            R.string.lifetime_circles_format,
            gameProgress.getLifetimeCircles()
    ));

    // ולבסוף מציגים את מספר העובדים שנרכשו.
    binding.pushersText.setText(getString(
            R.string.pushers_format,
            gameProgress.getPusherCount()
    ));
}

במחרוזת Circles: %1$d, הסימון %1$ מפנה לארגומנט הראשון שמגיע אחרי מזהה המחרוזת, והאות d אומרת לעצב אותו כמספר שלם עשרוני. לכן getString(R.string.circles_balance_format, 7) מחזירה את הטקסט Circles: 7. השימוש בתבנית מתוך strings.xml שומר את הטקסט מחוץ לקוד ומאפשר לתרגם אותו בלי לשנות את MainActivity.

הפעילות אינה מחשבת בעצמה את הערכים החדשים. היא מבקשת מ־GameProgress לשנות את המצב, ואחר כך מציגה את המצב המעודכן.

5. מריצים ובודקים

הריצו את האפליקציה ובדקו:

  1. פתחו את היישום וודאו שמופיעים Circles: 0,‏ Lifetime: 0 ו־Pushers: 0.
  2. התחילו משחק והכניסו עיגול אחד למטרה. שני מוני העיגולים צריכים להפוך ל־1 מיד.
  3. סיימו את המשחק. המונים צריכים להציג 5, וחלון זמן הסיום צריך להופיע כרגיל.
  4. התחילו משחק נוסף ואספו עיגול. שני המונים צריכים להציג 6.
  5. סגרו את היישום לגמרי ופתחו אותו שוב. הערכים 6 ו־0 צריכים להיטען מחדש.
  6. בדקו שכפתורי Local notification ו־Invite עדיין פועלים.

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

המשך לפרק 9: מצב משחק אוטונומי