כבר יש לנו יתרת Circles שנשמרת, ומצב Auto שמייצר עוד עבודה. בפרק הזה נוכל להשתמש ביתרה: ה־Pusher הראשון עולה 64 עיגולים, השני 128, השלישי 256 וכן הלאה. כרגע הקנייה תגדיל את מונה ה־Pushers; בפרק הבא נצייר את העובדים הקטנים על הלוח.
מה חשוב בכלכלה שלנו
Circlesהיא יתרה שאפשר להוציא, ולכן היא קטנה לאחר קנייה.Lifetimeמודד כמה עיגולים נאספו אי פעם, ולכן קנייה אינה משנה אותו.- המחיר הבא תלוי במספר ה־Pushers שכבר נקנו.
- אי אפשר לקנות אם היתרה קטנה מן המחיר.
- כל קנייה נשמרת מיד, כמו האיסופים מן הפרקים הקודמים.
1. מחשבים את המחיר הבא
פתחו את GameProgress.java והוסיפו קבוע:
static final long FIRST_PUSHER_PRICE = 64;
המחירים הם:
64, 128, 256, 512, ...
כל מספר גדול פי 2 מקודמו. אם pusherCount הוא מספר ה־Pushers שכבר נקנו, המחיר הוא:
64 × 2ᵖᵘˢʰᵉʳᶜᵒᵘⁿᵗ
במספרים שלמים, הזזה שמאלה בביט אחד היא כפל ב־2. הוסיפו:
/**
* Calculates the price for a given number of owned Pushers.
*
* @param pusherCount number of Pushers already owned
* @return the next price, or {@link Long#MAX_VALUE} before overflow
* @throws IllegalArgumentException if pusherCount is negative
*/
static long priceForPusherCount(int pusherCount) {
if (pusherCount < 0) {
throw new IllegalArgumentException("pusherCount cannot be negative");
}
if (pusherCount >= 57) {
return Long.MAX_VALUE;
}
return FIRST_PUSHER_PRICE << pusherCount; // bit shift multiplication
}
/**
* Returns the price that follows the number of Pushers already owned.
*
* @return the price of the next Pusher
*/
public long getNextPusherPrice() {
return priceForPusherCount(pusherCount);
}
מדוע עוצרים ב־57? long הוא מספר סופי. לאחר מספיק הכפלות, התוצאה הייתה גולשת והופכת למספר שלילי. במקום לאפשר מחיר שלילי, אנחנו מחזירים את המחיר הגדול ביותר ש־long יכול לשמור. בעתיד יהיה אפשר להחליף רק את הפעולה הזאת בפונקציית מחיר איטית יותר.
2. מבצעים קנייה בלי לשנות את Lifetime
איסוף (קוד קיים. אין שינוי)
/**
* Records one collected circle in both the spendable and lifetime totals.
*/
public void recordCollectedCircle() {
if (circlesBalance < Long.MAX_VALUE) {
circlesBalance++;
}
if (lifetimeCircles < Long.MAX_VALUE) {
lifetimeCircles++;
}
preferences.edit()
.putLong(CIRCLES_BALANCE_KEY, circlesBalance)
.putLong(LIFETIME_CIRCLES_KEY, lifetimeCircles)
.apply();
}
איסוף מגדיל את שני המונים.
קנייה (פעולה דומה חדשה)
/**
* Buys one Pusher when the balance can cover its current price.
*
* @return true when the purchase succeeds; false when the balance is too low
*/
public boolean buyPusher() {
long price = getNextPusherPrice();
if (circlesBalance < price) {
return false;
}
circlesBalance -= price;
pusherCount++;
preferences.edit()
.putLong(CIRCLES_BALANCE_KEY, circlesBalance)
.putInt(PUSHER_COUNT_KEY, pusherCount)
.apply();
return true;
}
קנייה מקטינה רק את היתרה.
לפני buyPusher() הוסיפו גם פעולה שמאפשרת למסך לשאול אם הכפתור צריך להיות פעיל:
/**
* Checks affordability without changing the saved progress.
*
* @return true when the balance can cover the next Pusher's price
*/
public boolean canBuyPusher() {
return circlesBalance >= getNextPusherPrice();
}
buyPusher() בודקת שוב את התנאי, אף על פי שגם המסך יבדוק אותו. אסור שמחלקת הנתונים תסמוך על כך שמסך מסוים הסתיר או השבית כפתור.
לאחר קניית ה־Pusher הראשון, 64 עיגולים יורדים מן היתרה אבל Lifetime נשאר ללא שינוי. המחיר הבא הופך מיד ל־128.
3. מוסיפים כפתור Shop קטן
יש לנו כבר שורת פעולות. כדי לא להוסיף שורה נוספת, נקצר את הכיתוב Local notification ונחלק את אותה שורה בין שלושה כפתורים.
לפני
<com.google.android.material.button.MaterialButton
android:id="@+id/localNotifButton"
android:layout_weight="1"
- android:text="@string/local_notification_short"
⁞
<com.google.android.material.button.MaterialButton
android:id="@+id/inviteButton"
⁞
android:layout_weight="1"
android:text="@string/invite" />
אחרי
<com.google.android.material.button.MaterialButton
android:id="@+id/localNotifButton"
android:layout_weight="1"
+ android:text="@string/local_notification_tiny"
⁞
<com.google.android.material.button.MaterialButton
android:id="@+id/inviteButton"
⁞
android:layout_weight="1"
android:text="@string/invite" />
+<com.google.android.material.button.MaterialButton
+ android:id="@+id/shopButton"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_marginStart="3dp"
+ android:layout_weight="1"
+ android:minWidth="0dp"
+ android:text="@string/shop"
+ android:textSize="12sp"
+ app:cornerRadius="12dp" />
בשורת notificationActionsRow קיימים כרגע שני מרווחים בגודל 4dp: layout_marginEnd של localNotifButton ו־layout_marginStart של inviteButton. שנו את שני הערכים ל־3dp, ואז הוסיפו אחרי inviteButton את כפתור ה־Shop המלא שמופיע בהשוואה.
בכפתור Invite צריך להיות גם layout_marginEnd="3dp", כדי שיהיה רווח שווה משני צדדיו.
4. מוסיפים את מחרוזות החנות
ב־strings.xml הוסיפו:
<string name="local_notification_tiny">Local</string>
<string name="shop">Shop</string>
<string name="shop_title">Pusher shop</string>
<string name="shop_message">Pushers: %1$d\nCircles: %2$d\nNext pusher: %3$d circles</string>
<string name="hire_pusher">Hire pusher</string>
<string name="close">Close</string>
<string name="pusher_hired">Pusher hired!</string>
שלושת מקומות השמירה ב־shop_message מקבלים, לפי הסדר, את מספר ה־Pushers, היתרה והמחיר הבא.
5. מציגים דיאלוג רכישה
ב־MainActivity.java הוסיפו import:
import androidx.appcompat.app.AlertDialog;
חברו את הכפתור ב־onCreate:
binding.shopButton.setOnClickListener(view -> showPusherShop());
והוסיפו את הפעולה:
/**
* Opens the shop and handles the purchase of one Pusher.
*/
private void showPusherShop() {
AlertDialog dialog = new MaterialAlertDialogBuilder(this)
.setTitle(R.string.shop_title)
.setMessage(getString(
R.string.shop_message,
gameProgress.getPusherCount(),
gameProgress.getCirclesBalance(),
gameProgress.getNextPusherPrice()
))
.setNegativeButton(R.string.close, null)
.setPositiveButton(R.string.hire_pusher, null)
.create();
dialog.setOnShowListener(ignored -> {
dialog.getButton(AlertDialog.BUTTON_POSITIVE)
.setEnabled(gameProgress.canBuyPusher());
dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener(view -> {
if (gameProgress.buyPusher()) {
showProgress();
Toast.makeText(this, R.string.pusher_hired, Toast.LENGTH_SHORT).show();
dialog.dismiss();
}
});
});
dialog.show();
}
אנחנו יוצרים את הכפתור החיובי בעזרת ה־Builder, אבל מחליפים את פעולת הלחיצה רק לאחר שהדיאלוג מוצג. בשלב הזה אפשר לקבל את הכפתור עצמו, להשבית אותו כאשר אין מספיק עיגולים, ולסגור את הדיאלוג רק לאחר קנייה מוצלחת.
אין Activity או Fragment נוספים: החנות היא פעולה קטנה מעל מסך המשחק, ולכן דיאלוג מספיק ומפחית עומס על התלמיד.
6. בודקים את פונקציית המחיר
לאיזה קובץ מוסיפים בדיקה? בדיקה של Java טהור, שאינה זקוקה ל־Android, למכשיר או ל־Context, מוסיפים אל ExampleUnitTest.java שבתיקיית (test). בדיקה שזקוקה לסביבת Android — למשל יצירת GameProgress עם Context אמיתי או בדיקת SharedPreferences — מוסיפים אל ExampleInstrumentedTest.java שבתיקיית (androidTest) ומריצים במכשיר או באמולטור.
הפעולה priceForPusherCount() רק מחשבת ומחזירה מספר, ולכן הבדיקה שלה אינה זקוקה ל־Android. ב־Android Studio פתחו app > kotlin+java > com.example.collectcircles (test) > ExampleUnitTest, והוסיפו:
/**
* Verifies that prices double and stop safely before long overflow.
*/
@Test
public void pusherPricesDoubleWithoutOverflowing() {
assertEquals(64, GameProgress.priceForPusherCount(0));
assertEquals(128, GameProgress.priceForPusherCount(1));
assertEquals(256, GameProgress.priceForPusherCount(2));
assertEquals(Long.MAX_VALUE, GameProgress.priceForPusherCount(57));
}
הריצו בדיקה ובנייה אחת:
.\gradlew.bat testDebugUnitTest assembleDebug
7. בדיקה ידנית
- עם יתרה קטנה מ־64, פתחו Shop. ודאו שהמחיר הוא 64 ושכפתור Hire מושבת.
- אספו עד שהיתרה היא לפחות 64. אפשר לעשות זאת במשחקים קצרים או במצב Auto.
- פתחו Shop וקנו Pusher. ודאו ש־64 ירד מן Circles, Pushers עלה ל־1 ו־Lifetime לא השתנה.
- פתחו שוב את החנות. המחיר הבא צריך להיות 128.
- סגרו ופתחו את היישום. מספר ה־Pushers והיתרה צריכים להישמר.
- ודאו ששלושת הכפתורים Local, Invite ו־Shop נכנסים בשורה ואפשר ללחוץ על כל אחד מהם.
יש לנו כעת לולאה כלכלית ראשונה: אוספים עיגולים, צוברים יתרה וקונים כוח עבודה במחיר שעולה. בפרק הבא נצייר Pusher קטן ונלמד להניע את הידיים והרגליים שלו על ה־Canvas.