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


מחלקת Game, בדיקת חפיפות ו-MotionEvent

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

חזרה ל-1

מה משתנה בשלב הזה

בשלב הקודם GameBoardView יצר מטרה ושלושה עיגולים במיקומים קבועים. כעת:

  1. מחלקת Game תחזיק את המטרה, את רשימת העיגולים ואת חוקי המשחק.
  2. GameBoardView תמשיך להיות אחראית לדברים ששייכים ל-Android: ציור וקבלת אירועי מגע.
  3. ברגע שהלוח יודע מה גודלו, הוא יוצר משחק אוטומטית. כך הגרסה כבר ניתנת למשחק, אף שכפתור Start יופעל רק בשיעור הבא.

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

1. יוצרים את מחלקת Game

צרו את Game.java:

package com.example.collectcircles;

import android.graphics.Canvas;
import android.graphics.Paint;

import java.util.ArrayList;
import java.util.List;
import java.util.Random;

public class Game {
    private static final int CIRCLE_COUNT = 5;

    private final Random random = new Random();
    private final Target target;
    private final List<Circle> circles = new ArrayList<>();
    private Circle selectedCircle;

    public Game(float boardWidth, float boardHeight, float targetRadius,
                int targetColor, int crossColor, float crossStrokeWidth,
                int circleColor) {
        target = new Target(
                boardWidth / 2f,
                boardHeight / 2f,
                targetRadius,
                targetColor,
                crossColor,
                crossStrokeWidth
        );

        createCircles(boardWidth, boardHeight, circleColor);
    }

    public void draw(Canvas canvas, Paint paint) {
        target.draw(canvas, paint);
        for (Circle circle : circles) {
            circle.draw(canvas, paint);
        }
    }

    public boolean selectCircleAt(float x, float y) {
        for (int i = circles.size() - 1; i >= 0; i--) {
            Circle circle = circles.get(i);
            if (circle.contains(x, y)) {
                selectedCircle = circle;
                return true;
            }
        }

        selectedCircle = null;
        return false;
    }

    public void moveSelectedCircle(float x, float y) {
        if (selectedCircle != null) {
            selectedCircle.setCenter(x, y);
        }
    }

    public boolean releaseSelectedCircle() {
        if (selectedCircle == null) {
            return false;
        }

        boolean collected = target.fullyContains(selectedCircle);
        if (collected) {
            circles.remove(selectedCircle);
        }
        selectedCircle = null;
        return collected;
    }

    public void cancelSelection() {
        selectedCircle = null;
    }

    public boolean isFinished() {
        return circles.isEmpty();
    }

    private void createCircles(float boardWidth, float boardHeight,
                               int circleColor) {
        float circleRadius = target.getRadius() / 2f;

        while (circles.size() < CIRCLE_COUNT) {
            float x = randomCoordinate(
                    circleRadius,
                    boardWidth - circleRadius
            );
            float y = randomCoordinate(
                    circleRadius,
                    boardHeight - circleRadius
            );
            Circle candidate =
                    new Circle(x, y, circleRadius, circleColor);

            if (!overlapsExistingCircle(candidate)) {
                circles.add(candidate);
            }
        }
    }

    private boolean overlapsExistingCircle(Circle candidate) {
        if (candidate.overlaps(target)) {
            return true;
        }

        for (Circle circle : circles) {
            if (candidate.overlaps(circle)) {
                return true;
            }
        }
        return false;
    }

    private float randomCoordinate(float minimum, float maximum) {
        return minimum + random.nextFloat() * (maximum - minimum);
    }
}

כיצד נוצרים חמישה עיגולים חוקיים?

הרדיוס של כל עיגול ירוק הוא חצי מרדיוס המטרה:

float circleRadius = target.getRadius() / 2f;

בכל סיבוב של הלולאה מוגרלים x ו-y. הגבולות מתחילים ברדיוס ומסתיימים ברוחב או בגובה פחות הרדיוס, ולכן כל העיגול נשאר בתוך הלוח.

הסיבה היא שהקואורדינטות מתארות את המרכז, לא את הפינה של העיגול. אם מרכז עיגול ברדיוס r נמצא ב-x = 0, חצי ממנו ייחתך מחוץ ללוח. הטווח r <= x <= width - r משאיר מרחק של רדיוס שלם מכל שפה; אותו היגיון חל על y.

המועמד מתקבל רק אם:

  • הוא אינו חופף למטרה;
  • הוא אינו חופף לשום עיגול ירוק שכבר התקבל.

בשתי הבדיקות משתמשים בפעולה מהשיעור הקודם:

distance < r1 + r2

המרחק בין מרכזי שני עיגולים והרדיוסים שלהם

כאן d נמדד בין מרכז המועמד למרכז העצם שכבר קיים. אם d < r1 + r2, אין מספיק מקום לשני הרדיוסים על הקטע שבין המרכזים ולכן השטחים חופפים. המועמד נפסל ומגרילים אחר במקומו. אם d == r1 + r2, העיגולים רק משיקים; לפי החוק שבחרנו זהו מיקום חוקי.

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

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

2. תפיסה, תנועה ושחרור

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

moveSelectedCircle מעדכנת את מרכז העיגול לפי מקום האצבע.

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

בעת שחרור משתמשים בבדיקה המחמירה שיצרנו ב-Target:

boolean collected = target.fullyContains(selectedCircle);

רק אם מתקיים:

distance + smallRadius <= targetRadius

העיגול נמחק. עיגול שרק נוגע במטרה, או נמצא בה באופן חלקי, נשאר על הלוח.

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

אל תשתמשו כאן ב-target.overlaps(selectedCircle). חפיפה היא בדיוק המצב שבו גם עיגול שרק נכנס מעט למטרה היה נעלם, וכך חוק המשחק היה סותר את ההנחיה “הכניסו את כל העיגול”.

3. מחליפים את תצוגת הדוגמה ב-Game

ב-GameBoardView הוסיפו:

import android.view.MotionEvent;

החליפו את השדות של תצוגת הדוגמה:

 private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);

-private Target target;
-private Circle[] previewCircles = new Circle[0];
+private Game game;

החליפו את onSizeChanged. כעת גודל הלוח האמיתי מועבר ל-Game:

@Override
protected void onSizeChanged(int width, int height,
                             int oldWidth, int oldHeight) {
    super.onSizeChanged(width, height, oldWidth, oldHeight);

    float targetRadius = Math.min(
            dp(TARGET_RADIUS_DP),
            Math.min(width, height) / 5f
    );
    game = new Game(
            width,
            height,
            targetRadius,
            color(R.color.target_red),
            color(R.color.target_cross),
            dp(2f),
            color(R.color.circle_green)
    );
}

המטרה נשארת במרכז. Math.min שומר על רדיוס של 64dp במסך רגיל, אך מקטין אותו אם הלוח צר או נמוך במיוחד.

גם onDraw מתקצרת:

@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);

    if (game == null) {
        return;
    }

    game.draw(canvas, paint);
}

4. מטפלים באירועי מגע

הוסיפו ל-GameBoardView:

@Override
public boolean onTouchEvent(MotionEvent event) {
    if (game == null) {
        return false;
    }

    switch (event.getActionMasked()) {
        case MotionEvent.ACTION_DOWN:
            boolean selected =
                    game.selectCircleAt(event.getX(), event.getY());
            if (selected) {
                getParent().requestDisallowInterceptTouchEvent(true);
            }
            return selected;

        case MotionEvent.ACTION_MOVE:
            game.moveSelectedCircle(event.getX(), event.getY());
            invalidate();
            return true;

        case MotionEvent.ACTION_UP:
            game.moveSelectedCircle(event.getX(), event.getY());
            game.releaseSelectedCircle();
            getParent().requestDisallowInterceptTouchEvent(false);
            invalidate();
            performClick();
            return true;

        case MotionEvent.ACTION_CANCEL:
            game.cancelSelection();
            getParent().requestDisallowInterceptTouchEvent(false);
            invalidate();
            return true;

        default:
            return super.onTouchEvent(event);
    }
}

MotionEvent מתאר רצף מגע:

  • ACTION_DOWN - האצבע נגעה במסך; מחפשים עיגול שמכיל את נקודת המגע.
  • ACTION_MOVE - האצבע זזה; מרכז העיגול עוקב אחריה.
  • ACTION_UP - האצבע שוחררה; בודקים אם העיגול נאסף.
  • ACTION_CANCEL - Android ביטלה את הרצף; משחררים את הבחירה בלי לאסוף.

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

אירוע שינוי במודל מדוע מציירים מחדש?
ACTION_DOWN נשמרת הבחירה עדיין לא השתנה מיקום
ACTION_MOVE מרכז העיגול משתנה כדי לראות את העיגול במקום החדש
ACTION_UP העיגול נמחק או נשאר כדי להציג את תוצאת השחרור
ACTION_CANCEL הבחירה מתאפסת כדי לסיים את הרצף במצב נקי

invalidate() מבקשת מ-Android לקרוא שוב ל-onDraw. אין צורך בלולאת ציור שרצה כל הזמן: מציירים מחדש רק כאשר העיגול זז או משתנה.

requestDisallowInterceptTouchEvent(true) מבקשת מן ההורה לא לקחת לעצמו את אירועי ההמשך באמצע גרירה.

ACTION_CANCEL אינו “כישלון נדיר” שאפשר להתעלם ממנו. הוא עשוי להגיע כאשר רכיב אב משתלט על המחווה או כשהמערכת קוטעת אותה. איפוס הבחירה שם מונע מצב שבו עיגול ישן נשאר מסומן ונע במגע הבא.

מי מקבל את המגע?

Circle אינה יורשת מ-View, ולכן Android אינה שולחת אליה אירועי מגע ישירות. זה מכוון: יש לנו GameBoardView אחד שמייצג את כל לוח המשחק, והוא מצייר בתוכו את כל העיגולים. כאשר מגיע מגע, הלוח שואל את Game איזה Circle מכיל את נקודת המגע.

flowchart TD
    A["Finger touches the screen"] --> B["Android creates MotionEvent"]
    B --> C["GameBoardView.onTouchEvent(event)"]

    C -->|ACTION_DOWN| D["Game.selectCircleAt(x, y)"]
    D --> E["Circle.contains(x, y)"]
    E --> F["Game remembers selectedCircle"]

    C -->|ACTION_MOVE| G["Game.moveSelectedCircle(x, y)"]
    G --> H["Circle.setCenter(x, y)"]
    H --> I["GameBoardView.invalidate()"]
    I --> J["GameBoardView.onDraw(canvas)"]

    C -->|ACTION_UP| K["Game.releaseSelectedCircle()"]
    K --> L["Target.fullyContains(selectedCircle)"]
    L -->|true| M["Remove the circle"]
    L -->|false| N["Keep the circle"]

החלוקה הזו שומרת על תפקידים פשוטים:

  • GameBoardView מכירה את מנגנון המגע והציור של Android.
  • Game מנהלת את רשימת העיגולים ואת העיגול שנבחר.
  • Circle יודעת לחשב אם נקודה נמצאת בתוכה ולעדכן את המרכז שלה.
  • Target יודעת לבדוק אם עיגול נמצא בתוכה במלואו.

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

הוסיפו גם:

@Override
public boolean performClick() {
    super.performClick();
    return true;
}

הקריאה מתוך ACTION_UP שומרת על החוזה של View גם עבור כלי נגישות.

5. בונים ובודקים

$env:JAVA_HOME = 'C:\Program Files\Android\Android Studio\jbr'
.\gradlew.bat testDebugUnitTest assembleDebug

6. בדיקה ידנית

  1. הפעילו את היישום. חמישה עיגולים צריכים להופיע מיד.
  2. ודאו שאף עיגול אינו מתחיל על המטרה או על עיגול אחר.
  3. גררו עיגול ושימו לב שהוא עוקב אחר האצבע.
  4. שחררו אותו כשהוא חופף רק לחלק מן המטרה - הוא צריך להישאר.
  5. גררו אותו כך שיהיה כולו בתוך המטרה - הוא צריך להיעלם.
  6. אספו את כל החמישה. בשלב זה הלוח נשאר ריק; הודעת הסיום תתווסף בשיעור הבא.

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


השיעור הבא