Hebrew version · English chapter index
Previous chapter · Next chapter
On a touch screen, swipe right for the next chapter and left for the previous chapter.
We already have a Circles balance that is saved, and an Auto mode that generates more work. In this chapter we can use the balance: the first Pusher costs 64 circles, the second 128, the third 256 and so on. At the moment the purchase will increase the Pushers counter; in the next chapter we will draw the little workers on the board.
What is important in our economy
Circlesis a spendable balance, so it decreases after a purchase.Lifetimemeasures how many circles have ever been collected, so buying does not change it.- The next price depends on how many Pushers are already owned.
- You cannot buy if the balance is less than the price.
- Every purchase is saved immediately, like the collections from the previous chapters.
1. Calculate the next price
Open GameProgress.java and add a constant:
static final long FIRST_PUSHER_PRICE = 64;
The prices are:
64, 128, 256, 512, ...
Each price is twice the previous price. If pusherCount is the number of Pushers already bought, the price is:
64 × 2ᵖᵘˢʰᵉʳᶜᵒᵘⁿᵗ
For integers, shifting left by one bit multiplies by 2. Add:
/**
* 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);
}
Why stop at 57? long has a limited range. After enough multiplications, the result would overflow and become negative. Instead of allowing a negative price, we return the largest price that long can represent. Later, we could replace just this method with a price function that grows more slowly.
2. Make a purchase without changing Lifetime
Collection (Existing code. No change)
/**
* 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();
}
Collection increases both the counters.
Buy (new similar method)
/**
* 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;
}
Buying only decreases the balance.
Before buyPusher(), also add a method the screen can use to decide whether the button should be enabled:
/**
* 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() checks the condition again, even though the screen will also check it. The data class must not trust that a particular screen has hidden or disabled a button.
After buying the first Pusher, 64 circles are deducted from the balance but Lifetime remains unchanged. The next price immediately becomes 128.
3. Add a small Shop button
We already have an action row. To avoid adding another row, shorten the Local notification label and divide the existing row among three buttons.
Before
<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" />
After
<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" />
The notificationActionsRow currently has two 4dp margins: layout_marginEnd on localNotifButton and layout_marginStart on inviteButton. Change both to 3dp, then add the full Shop button shown in the comparison after inviteButton.
The Invite button should also have layout_marginEnd="3dp", giving it equal spacing on both sides.
4. Add the store strings
In strings.xml add:
<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>
The three placeholders in shop_message take the Pusher count, balance, and next price, in that order.
5. Display a purchase dialog
In MainActivity.java add import:
import androidx.appcompat.app.AlertDialog;
Connect the button in onCreate:
binding.shopButton.setOnClickListener(view -> showPusherShop());
And add the method:
/**
* 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();
}
We create the positive button using the Builder, but only change the click action after the dialog is displayed. At this stage you can get the button itself, disable it when there are not enough circles, and close the dialog only after a successful purchase.
No additional Activity or Fragment is needed: the shop is a small interaction over the game screen, so a dialog is enough and keeps the work manageable.
6. Checking the price function
Which file do you add a test to? A pure Java test, which does not require Android, a device or Context, is added to ExampleUnitTest.java in the (test) folder. A test that needs the Android environment — for example, creating GameProgress with a real Context or testing SharedPreferences — is added to ExampleInstrumentedTest.java in the (androidTest) folder and run on the device or emulator.
The method priceForPusherCount() only calculates and returns a number, so its test does not need Android. In Android Studio, open app > kotlin+java > com.example.collectcircles (test) > ExampleUnitTest, and add:
/**
* 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));
}
Run one test and build:
.\gradlew.bat testDebugUnitTest assembleDebug
7. Manual inspection
- With a balance less than 64, open Shop. Make sure the price is 64 and the Hire button is disabled.
- Collect until the balance is at least 64. This can be done in short games or in Auto mode.
- Open Shop and buy Pusher. Check that 64 dropped from Circles, Pushers went up to 1 and Lifetime didn’t change.
- Open the store again. The next price should be 128.
- Close and open the application. The number of Pushers and the balance should be maintained.
- Check that Local, Invite, and Shop fit on one row and each button responds to clicks.
We now have an initial economic loop: collect circles, build a balance, and buy workers at increasing prices. In the next chapter, we will draw a small Pusher and animate its arms and legs on Canvas.
Continue to Chapter 11: Drawing a walking Pusher
Gemini tutor
Open Gem: CollectCircles Idle Lab to get hints, diagnostic questions and explanations appropriate to the stage you are on.