CollectCircles 3 - זמן, שיא וסיום המשחק


SystemClock, Runnable, SharedPreferences והודעת סיום קרדיט ליורם על לרעיון המקורי ולשיעורים 1–3 בנושא חיישנים ו-Canvas ב-MAUI

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

חזרה ל-2: מצב משחק, עיגולים אקראיים וגרירה

כדי שהערות בעברית משולבות אנגלית יוצגו בצורה קריאה, יש לבחור בהצגה Right-To-Left ואז never show again (עד שלא מגדירים השאלה מופיעה למעלה כל הזמן). ההמלצה בקוד מסחרי היא להערות באנגלית. כאן ההערות בעברית כדי להקל על העומס.

מה נשלים

  • כפתור Start שיוצר משחק ומתחיל את המדידה.
  • לחיצה נוספת שמתחילה משחק חדש ומאפסת את הזמן.
  • מדידת זמן בעזרת שעון מונוטוני.
  • רענון תווית הזמן ב-UI בלי להשתמש במספר ה-ticks כזמן.
  • עצירה כשהעיגול האחרון נאסף.
  • שמירת השיא ב-SharedPreferences.
  • הסרת עדכונים מתוזמנים כאשר המסך אינו פעיל.

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

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

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

1. Start הופך להיות נקודת ההתחלה

בשיעור 1 השבתנו זמנית את הכפתור, מפני שעדיין לא היה משחק שאפשר להתחיל. כעת הסירו מ-onCreate את השורה הזאת:

binding.startButton.setEnabled(false);

אחרי ההסרה הכפתור חוזר לברירת המחדל שלו, enabled = true, ויוכל לקבל לחיצות. ה-OnClickListener שנחבר בהמשך השיעור יקרא אז ל-startGame(). אם משאירים את השורה, הכפתור נשאר אפור ומושבת גם אם חיברנו לו listener תקין.

אל תחליפו את השורה ב-setEnabled(true) במקום אחר. בשלב הזה אין עוד מצב שבו הכפתור אמור להיות מושבת, ולכן עדיף להסיר את ההשבתה הזמנית ולא להשאיר הוראות סותרות בקוד.

בגרסה הקודמת GameBoardView יצר משחק מתוך onSizeChanged, מפני שהיינו צריכים גרסה ניתנת למשחק לפני בניית המסך הסופי. כעת הסירו מ-GameBoardView את onSizeChanged הזה והוסיפו:

/**
 * מתחיל סיבוב חדש לפי הגודל הנוכחי של לוח המשחק.
 * אם הלוח עדיין לא נמדד, הפעולה אינה משנה את המשחק.
 */
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();
}

כעת פתיחת היישום מציגה את מסך הבקרה ולוח ריק. רק לחיצה על Start יוצרת חמישה עיגולים. אותה פעולה תשמש גם ל-Restart.

הבדיקה של רוחב וגובה מונעת יצירת משחק לפני ש-Android סיימה למדוד את ה-View. איננו יוצרים עוד משחק אוטומטית ב-onSizeChanged, מפני ששינוי גודל של לוח הוא אירוע תצוגה ולא בקשה להתחיל סיבוב. כעת רק פעולה מפורשת של המשתמש משנה את מצב המשחק מ”לפני התחלה” ל”פעיל”.

2. מודיעים לפעילות שהמשחק הסתיים (עדיין ב-GameBoardView)

ה-View יודע מתי הרשימה התרוקנה, אבל MainActivity היא המקום הטבעי לעדכן תוויות, לשמור העדפות ולהציג dialog. ב-GameBoardView נשמור Runnable שהפעילות תיתן ל-View:

private Runnable onGameFinished;

/**
 * מגדיר פעולה שתופעל לאחר איסוף העיגול האחרון.
 *
 * @param listener הפעולה שתופעל בסיום המשחק, או {@code null} להסרתה
 */
public void setOnGameFinishedListener(Runnable listener) {
    onGameFinished = listener;
}

בתוך ACTION_UP ב-GameBoardView, החליפו את כל הקוד הישן של המקרה הזה בקטע הבא. חשוב להחליף ולא רק להוסיף: בכל שחרור מזיזים את העיגול אל מיקום האצבע האחרון, ואז קוראים ל-releaseSelectedCircle() פעם אחת בלבד ושומרים את התוצאה:

case MotionEvent.ACTION_UP:
    game.moveSelectedCircle(event.getX(), event.getY());
    boolean collected = game.releaseSelectedCircle();
    getParent().requestDisallowInterceptTouchEvent(false);
    invalidate();
    performClick();
    if (collected
            && game.isFinished()
            && onGameFinished != null) {
        onGameFinished.run();
    }
    return true;

שלושת התנאים חשובים:

  1. העיגול אכן נאסף בשחרור הנוכחי.
  2. לא נשאר אף עיגול.
  3. הפעילות אכן מסרה פעולה להרצה.

כך ה-View אינו מכיר SharedPreferences או dialogs, והפעילות אינה צריכה לבדוק את רשימת העיגולים בכל תנועה.

זהו callback קטן: ה-GameBoardView מפרסמת את העובדה “המשחק הסתיים” בלי להחליט מה משמעותה בממשק. MainActivity בוחרת לעצור זמן, לשמור שיא ולהציג הודעה. Runnable מתאים כאן מפני שאין צורך להעביר נתונים; הפעילות יכולה לחשב את הזמן הסופי מן השעון שלה.

3. מוסיפים מחרוזות למסך הסופי

הוסיפו ל-strings.xml:

<string name="elapsed_time_format">Time: %1$.3f s</string>
<string name="best_time_format">Best: %1$.3f s</string>
<string name="restart">Restart</string>
<string name="completion_title">Game complete!</string>
<string name="completion_message">You collected every circle in %1$.3f seconds.</string>
<string name="ok">OK</string>

%1$.3f פירושו: קחו את הפרמטר הראשון והציגו אותו כמספר עשרוני עם שלוש ספרות אחרי הנקודה. הטקסט נמצא במשאבים, ולכן אפשר לתרגם אותו בעתיד בלי לערוך Java.

4. מכינים את השעון ואת השיא

ב-MainActivity הוסיפו imports:

import android.content.SharedPreferences;
import android.os.SystemClock;

import com.google.android.material.dialog.MaterialAlertDialogBuilder;

הוסיפו קבועים ושדות:

private static final String PREFERENCES_NAME =
        "collect_circles_preferences";
private static final String BEST_TIME_KEY = "best_time_millis";
private static final long TIMER_REFRESH_MILLIS = 16;
private static final long NO_BEST_TIME = Long.MAX_VALUE;

private SharedPreferences preferences;
private boolean gameRunning;
private long gameStartTimeMillis;
private long bestTimeMillis;

אנחנו שומרים זמן באלפיות שנייה בתוך long. הערך Long.MAX_VALUE משמש כסימן “עדיין אין שיא”. כל זמן משחק אמיתי יהיה קטן ממנו, ולכן התוצאה הראשונה תהפוך אוטומטית לשיא.

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

מדוע SystemClock.elapsedRealtime()?

System.currentTimeMillis() מייצג שעה ותאריך ויכול להשתנות אם המשתמש או הרשת מתקנים את שעון המכשיר. למדידת משך נשתמש ב:

SystemClock.elapsedRealtime()

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

elapsed = currentElapsedRealtime - gameStartElapsedRealtime

איננו שומרים “כמה פעמים ה-Runnable רצה”. מערכת ההפעלה אינה מבטיחה שכל רענון יתרחש בדיוק בזמן; ה-UI יכול להיות עסוק ואף להיעצר כשהיישום ברקע. הפרש בין שתי קריאות שעון נשאר נכון גם כאשר דילגנו על רענונים.

5. מרעננים את התווית בעזרת Runnable

לפני הקוד: מהו Runnable?

Runnable הוא ממשק Java קטן שמייצג פעולה שאפשר להריץ. יש בו פעולה אחת בלבד, run():

public interface Runnable {
    /**
     * מריץ את הפעולה שהאובייקט מייצג.
     */
    void run();
}

אפשר לחשוב על אובייקט מסוג Runnable כעל פתק שעליו כתוב “כאשר יבקשו ממני לרוץ, זה הקוד שאבצע”. יצירת האובייקט אינה מפעילה את הקוד, והיא גם אינה יוצרת thread חדש. מישהו אחר צריך להפעיל את run() או למסור את ה-Runnable למנגנון שיפעיל אותה מאוחר יותר.

לדוגמה:

Runnable sayHello = new Runnable() {
    /**
     * כותבת הודעת ברכה ליומן לצורך הדגמת Runnable.
     */
    @Override
    public void run() {
        Log.d("CollectCircles", "Hello");
    }
};

sayHello.run();

החלק new Runnable() { ... } יוצר אובייקט ממחלקה אנונימית שמממשת את הממשק. הקוד שבתוך run() הוא הפעולה ששמרנו באובייקט, ורק השורה sayHello.run() מפעילה אותה. בהמשך הלימודים תוכלו לכתוב פעולה קצרה כזאת גם בעזרת lambda, אבל כאן הצורה המלאה עוזרת לראות מה בדיוק נוצר ומה בדיוק רץ.

ב-Android יש הבדל חשוב בין שתי דרכי ההפעלה שבהן נשתמש:

  • timerUpdate.run() מפעילה את הפעולה מיד, ב-thread שבו נמצאים כרגע.
  • someView.postDelayed(timerUpdate, delay) מכניסה בקשה לתור של ה-UI thread, כדי להפעיל את הפעולה לאחר שעבר לפחות זמן ההשהיה המבוקש.

postDelayed אינה עוצרת את היישום ומחכה. היא רושמת עבודה לעתיד ומחזירה מיד, כך שה-UI יכול להמשיך לטפל בציור ובלחיצות. מכיוון שהעבודה נשלחת דרך View, היא תרוץ ב-UI thread ושם מותר לה לעדכן TextView.

בפרק הזה אותו רעיון מופיע בשני תפקידים: onGameFinished הוא פעולה שנשמרת כ-callback ומופעלת פעם אחת כשהמשחק מסתיים; timerUpdate היא פעולה שמתזמנת את עצמה שוב ושוב כל עוד המשחק פעיל.

הוסיפו שדה:

private final Runnable timerUpdate = new Runnable() {
    /**
     * מרעננת את הזמן המוצג ומתזמנת רענון נוסף כל עוד המשחק פעיל.
     */
    @Override
    public void run() {
        if (!gameRunning) {
            return;
        }

        showElapsedTime(
                SystemClock.elapsedRealtime() - gameStartTimeMillis
        );
        binding.elapsedTimeText.postDelayed(
                this,
                TIMER_REFRESH_MILLIS
        );
    }
};

בקריאה ל-postDelayed, המילה this נמצאת בתוך run() של המחלקה האנונימית ולכן היא מצביעה על אובייקט ה-Runnable עצמו, כלומר על timerUpdate. בכל הרצה הפעולה מעדכנת את התווית ומכניסה לתור את ההרצה הבאה שלה בעוד כ-16ms. כך נוצרת שרשרת רענונים שמתאימה בקירוב לקצב של 60 רענונים בשנייה.

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

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

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

6. מחברים את המסך ב-onCreate

בתוך onCreate, מצאו את כל בלוק ה־insets שהתבנית של Android Studio יצרה. אל תדביקו מיד אחרי השורה שמחשבת את systemBars. המשיכו עד השורה });, שסוגרת את ה־lambda ואת הקריאה ל־setOnApplyWindowInsetsListener, והוסיפו את קוד האתחול אחריה. הדיפ הבא מציג גם את הקוד הקיים וגם את המקום המדויק של התוספת:

         binding = ActivityMainBinding.inflate(getLayoutInflater());
         setContentView(binding.getRoot());

         ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main), (v, insets) -> {
             Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars());
             v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom);
             return insets;
         }); // כאן מסתיים ה-listener של ה-insets
+     
+        // ממשיכים בתוך onCreate, אבל מחוץ ל-listener של ה-insets.
+        preferences = getSharedPreferences(
+                PREFERENCES_NAME,
+                MODE_PRIVATE
+        );
+        bestTimeMillis = preferences.getLong(
+                BEST_TIME_KEY,
+                NO_BEST_TIME
+        );
+        showBestTime();
+
+        binding.startButton.setOnClickListener(view -> startGame());
+        binding.gameBoard.setOnGameFinishedListener(this::finishGame);
     } // כאן מסתיים onCreate
    

בתוך ה־insets callback צריכים להישאר רק חישוב systemBars, עדכון ה־padding והפקודה return insets. אם getSharedPreferences,‏ showBestTime() או חיבורי הכפתורים נמצאים לפני return insets, הם הודבקו בתוך ה־callback בטעות. העבירו אותם אל מתחת ל־});. הבדיקה הזאת חשובה גם אם היישום עדיין מצליח לרוץ: ה־callback שייך לעדכוני פריסת המסך ועלול לפעול שוב, ואילו אתחול המסך צריך להתבצע פעם אחת ישירות מתוך onCreate.

גם כאן View Binding נותן גישה ישירה ובטוחה לפי טיפוס ל-startButton, ל-gameBoard ולתוויות.

this::finishGame היא method reference: כאשר ה-View מודיע שהמשחק הסתיים, Android מפעילה את finishGame() של הפעילות.

7. מתחילים או מאתחלים משחק

הוסיפו:

/**
 * יוצר סיבוב חדש, מאפס את זמן ההתחלה ומפעיל שרשרת רענון יחידה.
 */
private void startGame() {
    binding.gameBoard.startNewGame();
    gameStartTimeMillis = SystemClock.elapsedRealtime();
    gameRunning = true;

    binding.startButton.setText(R.string.restart);
    // 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);
    timerUpdate.run();
}

removeCallbacks מונעת מצב שבו לחיצה מהירה על Restart משאירה שתי שרשראות עדכון פעילות. לאחר מכן timerUpdate.run() מעדכנת מיד ומתחילה שרשרת אחת חדשה.

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

8. מסיימים, משווים ושומרים (עבודה על MainActivity.java)

8.1 פעולות תצוגה הקטנות

הוסיפו בסוף המחלקה:

/**
 * מציג בתווית הזמן את משך הסיבוב באלפיות שנייה.
 *
 * @param elapsedMillis משך הסיבוב באלפיות שנייה
 */
private void showElapsedTime(long elapsedMillis) {
    binding.elapsedTimeText.setText(getString(
            R.string.elapsed_time_format,
            millisecondsToSeconds(elapsedMillis)
    ));
}

/**
 * מציג את השיא השמור, או את ערך ברירת המחדל אם עדיין אין שיא.
 */
private void showBestTime() {
    if (bestTimeMillis == NO_BEST_TIME) {
        binding.bestTimeText.setText(R.string.best_time_initial);
        return;
    }

    binding.bestTimeText.setText(getString(
            R.string.best_time_format,
            millisecondsToSeconds(bestTimeMillis)
    ));
}

/**
 * ממיר משך מאלפיות שנייה לשניות לצורך תצוגה.
 *
 * @param milliseconds משך באלפיות שנייה
 * @return אותו משך בשניות
 */
private double millisecondsToSeconds(long milliseconds) {
    return milliseconds / 1000.0;
}

שמירת הזמן ב-long והמרתו ל-double רק לצורך תצוגה מונעת מאיתנו לערבב יחידות. שם הפרמטר milliseconds מזכיר באיזו יחידה הפעולה מקבלת את הערך.

8.2 finishGame הקורא לפעולות הקטנות

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

/**
 * עוצר את הסיבוב הפעיל, מעדכן שיא במידת הצורך ומציג הודעת סיום.
 */
private void finishGame() {
    if (!gameRunning) {
        return;
    }

    long finalTimeMillis =
            SystemClock.elapsedRealtime() - gameStartTimeMillis;
    gameRunning = false;
    binding.elapsedTimeText.removeCallbacks(timerUpdate);
    showElapsedTime(finalTimeMillis);

    if (finalTimeMillis < bestTimeMillis) {
        bestTimeMillis = finalTimeMillis;
        preferences.edit()
                .putLong(BEST_TIME_KEY, bestTimeMillis)
                .apply();
        showBestTime();
    }

    new MaterialAlertDialogBuilder(this)
            .setTitle(R.string.completion_title)
            .setMessage(getString(
                    R.string.completion_message,
                    millisecondsToSeconds(finalTimeMillis)
            ))
            .setPositiveButton(R.string.ok, null)
            .show();
}

רק תוצאה קטנה מן השיא הקודם נשמרת. SharedPreferences שומרת את הערך בקובץ פרטי של היישום, ולכן הוא נשאר גם אחרי סגירה ופתיחה מחדש. apply() מעדכנת את האובייקט מיד וכותבת לאחסון בלי לחסום את ה-UI thread.

הבדיקה if (!gameRunning) return הופכת את finishGame לבטוחה גם אם callback יגיע פעמיים בטעות: רק המעבר הראשון מפעיל עצירה, שמירה ו-dialog. לאחר gameRunning = false, שרשרת הרענון נעצרת והזמן המוצג מתקבע על התוצאה הסופית.

בחרנו ב-apply() ולא ב-commit(): אין צורך לעצור את ה-UI עד שהכתיבה לדיסק מסתיימת. הערך בזיכרון מתעדכן מיד, והכתיבה הקטנה מתבצעת ברקע.

הרצה לבדיקה הקוד בשלב זה אמור להתקמפל ולרוץ.

9. ניקוי בטוח במחזור החיים

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

/**
 * מפעיל מחדש את רענון הזמן כאשר הפעילות חוזרת למסך.
 */
@Override
protected void onStart() {
    super.onStart();
    if (gameRunning) {
        binding.elapsedTimeText.removeCallbacks(timerUpdate);
        timerUpdate.run();
    }
}

/**
 * מסיר עדכוני זמן מתוזמנים כאשר הפעילות אינה גלויה.
 */
@Override
protected void onStop() {
    binding.elapsedTimeText.removeCallbacks(timerUpdate);
    super.onStop();
}
מהו מחזור החיים, ומתי כל פעולה נקראת?

Android מנהלת את ה־Activity לפי המצב שלה על המסך. היא קוראת לפעולות מחזור החיים בעצמה; אנחנו לא קוראים ל־onStart() או ל־onStop() ישירות.

  • בפתיחה הראשונה נקראות, לפי הסדר, onCreate(),‏ onStart() ואז onResume(). לאחר מכן המסך גלוי והמשתמש יכול לעבוד איתו.
  • כשעוברים זמנית למסך אחר, Android קוראת תחילה ל־onPause(). אם ה־Activity כבר אינה גלויה כלל — למשל לאחר לחיצה על Home או מעבר ליישום אחר — נקראת גם onStop().
  • כשחוזרים ל־Activity שנעצרה, נקראות onRestart(),‏ onStart() ואז onResume(). לכן onStart() יכולה להיקרא פעמים רבות על אותו אובייקט, ולא רק בפתיחה הראשונה.
  • אם Android יוצרת את ה־Activity מחדש, למשל אחרי שינוי תצורה, היא מתחילה שוב ב־onCreate(). שדות רגילים חוזרים לערכי ההתחלה שלהם, אלא אם שמרנו את המצב במנגנון מתאים.

במשחק שלנו onStop() מפסיקה רק את עדכון תווית הזמן, מפני שאין צורך לצייר מסך שאינו גלוי. ערך ההתחלה gameStartTimeMillis אינו משתנה, ולכן הזמן עצמו ממשיך לחלוף. כשחוזרים, onStart() מפעילה שוב את timerUpdate, והחישוב מול SystemClock.elapsedRealtime() מציג מיד את הזמן העדכני.

לא כדאי להמתין ל־onDestroy() לצורך הניקוי הזה. Android אינה מבטיחה שהיא תיקרא לפני שהתהליך ייהרג, ובכל מקרה onStop() היא הנקודה שבה המסך כבר אינו גלוי. מידע שצריך לשרוד סגירה של התהליך חייב להישמר באחסון מתאים; לכן השיא נשמר ב־SharedPreferences, בעוד שמצב המשחק הנוכחי עדיין זמני בשלב הזה.

onStop מסירה את העדכון המתוזמן וכך אינה משאירה עבודה שמחזיקה את הפעילות מאחורי הקלעים. אם חוזרים לאותה פעילות בזמן שהמשחק פעיל, onStart מפעילה את הרענון מחדש. הזמן שחלף עדיין נכון, מפני שאיננו סופרים ticks - אנחנו קוראים שוב את השעון המונוטוני.

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

בגרסה הלימודית איננו שומרים משחק פעיל מול יצירה מחדש של ה-Activity, למשל לאחר שינוי תצורה. השיא נשמר מפני שהוא ב-SharedPreferences, אך מצב הסיבוב הנוכחי הוא זמני. טיפול בכך בעזרת ViewModel או savedInstanceState הוא הרחבה אפשרית, ולא דרישה להבנת מנגנון המשחק הבסיסי.

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

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

  • במסך הראשוני מוצגים כותרת, Time: 0.000 s, שיא או Best: —, כפתור Start ולוח ריק.
  • Start יוצר בדיוק חמישה עיגולים ירוקים שאינם חופפים זה לזה או למטרה.
  • גרירה מזיזה את העיגול לפי מקום האצבע.
  • שחרור עיגול כשהוא כולו בתוך המטרה מוחק אותו.
  • נגיעה או חפיפה חלקית במטרה אינה מוחקת את העיגול.
  • איסוף העיגול האחרון עוצר את הזמן ומציג הודעת סיום.
  • הזמן המוצג מפסיק להשתנות אחרי הסיום.
  • תוצאה טובה יותר מעדכנת את השיא.
  • תוצאה איטית יותר אינה מחליפה את השיא.
  • לאחר סגירת היישום ופתיחתו מחדש השיא עדיין מוצג.
  • Restart יוצר חמישה מיקומים חדשים ומאפס את הזמן.

הפרויקט הושלם: מסך XML שמחובר ב-View Binding, ציור ב-Canvas, מודל עיגולים עם ירושה, משחק מגע מלא, זמן מונוטוני ושיא שנשמר במכשיר.

הזרימה המלאה כעת היא: XML מקצה שטח ללוח; Game יוצר מודל חוקי בעזרת בדיקות מרחק; GameBoardView מתרגמת מגע לשינויים במודל ומציירת אותו; MainActivity מנהלת את גבולות הסיבוב, הזמן והשמירה. כל שכבה יודעת רק את מה שנחוץ לתפקידה, ולכן אפשר להסביר, לבדוק ולשנות כל חלק בלי לערבב את כל הפרויקט במחלקה אחת.

מחשבה להמשך

אחרי שהמשחק עובד, כדאי לעצור ולשאול אם Circle היה צריך להיות View, האם Target באמת צריך לרשת ממנו, ומה פירוש המילה Model בפרויקט כזה. המשיכו אל מחשבה ביקורתית על עצמים, Views וירושה.

המשך

מורה־עזר ב־Gemini

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