עד עכשיו כל משחק הסתיים לאחר חמישה עיגולים, ורק זמן השיא נשמר. בשלב הזה נוסיף למשחק התקדמות ארוכת טווח: כל עיגול שנאסף יתווסף גם ליתרה שאפשר יהיה להוציא בעתיד וגם למונה שלא יורד לעולם. בסוף השיעור אפשר לסגור את היישום, לפתוח אותו שוב ולראות שהמונה נשמר.
מה נבנה
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();
}
}
המחלקה מחזיקה שני סוגים של מידע:
- המצב בזיכרון — השדות שבהם משתמשים בזמן שהיישום רץ.
- המצב השמור — הערכים ב־
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:
- הוסיפו את
progressRowמיד לפניstartButton. - החליפו את
startButtonבגרסה המעודכנת, שהאילוץ העליון שלה מצביע אלprogressRow. - החליפו את
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. מריצים ובודקים
הריצו את האפליקציה ובדקו:
- פתחו את היישום וודאו שמופיעים
Circles: 0,Lifetime: 0ו־Pushers: 0. - התחילו משחק והכניסו עיגול אחד למטרה. שני מוני העיגולים צריכים להפוך ל־1 מיד.
- סיימו את המשחק. המונים צריכים להציג 5, וחלון זמן הסיום צריך להופיע כרגיל.
- התחילו משחק נוסף ואספו עיגול. שני המונים צריכים להציג 6.
- סגרו את היישום לגמרי ופתחו אותו שוב. הערכים 6 ו־0 צריכים להיטען מחדש.
- בדקו שכפתורי Local notification ו־Invite עדיין פועלים.
כעת למשחק יש שני מדדי הצלחה: זמן טוב בסיבוב קצר והתקדמות שמצטברת לאורך זמן. בפרק הבא נוסיף מצב אוטונומי שבו עיגולים ממשיכים להופיע בלי לסיים את המשחק.