CollectCircles 1 - המסך, המחלקות והציור


View Binding, עיגולים, ירושה ו-View מותאם אישית קרדיט ליורם על לרעיון המקורי ולשיעורים 1–3 בנושא חיישנים ו-Canvas ב-MAUI

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

אם אתם יוצרים את הפרויקט בעצמכם, בחרו בתבנית Empty Views Activity והשאירו את שם הפעילות MainActivity. משנים את הקבצים שהתבנית יצרה; אין צורך להוסיף Activity נוסף.

חובה לקרוא לפרויקט בדיוק CollectCircles ולוודא שבמסך היצירה מופיע ה־package com.example.collectcircles. במסך יצירת הפרויקט ערך זה הופך גם ל־Gradle applicationId — זהו ה־”Android package name” ש־Firebase בודק. בפרקי ההתראות המתקדמים היישום פונה לפונקציות serverless שהמורה הכין, והן מקבלות רק את היישום שנרשם בשם חבילה זה. שם פרויקט או package שונים יגרמו לקריאות להידחות. אם התחלתם מפרויקט בשם אחר, בצעו תחילה את מדריך שינוי שם הפרויקט וה־package. אם רק התחלתם - עדיף ליצור פרוייקט חדש בשם הנכון. חשוב: נבחר min sdk 31 אם שכחתם ניתן להתאים זאת אחר כך.

מה נלמד

  • כיצד XML ו-Java מתחברים בעזרת View Binding.
  • כיצד יוצרים View שמצייר בעצמו בעזרת Canvas.
  • כיצד מחלקת Circle מרכזת נתונים ופעולות משותפים.
  • כיצד Target extends Circle מוסיפה התנהגות בעזרת ירושה ו-@Override.
  • ההבדל בין חפיפה של שני עיגולים לבין הכלה מלאה של עיגול בתוך המטרה.

במהלך הפרק נבנה את המשחק בשלוש שכבות. ה-XML קובע היכן לוח המשחק נמצא; Circle ו-Target מתארות מה מצויר ומהם חוקי הגאומטריה; GameBoardView יודעת כיצד Android מצייר את המודל על המסך. ההפרדה הזו חשובה: בהמשך נוכל לשנות את מצב המשחק בלי לבנות מחדש את כל רכיבי המסך.

1. הפעלת View Binding ב־Gradle

בחלון Android פתחו את קובץ המודול:

  • Gradle Scripts > build.gradle.kts (Module :app)

בתוך הבלוק android, הוסיפו:

 android {
     namespace = "com.example.collectcircles"
     compileSdk {
         ......
         ..
     }
 
+    buildFeatures {
+        viewBinding = true
+    }
+
     defaultConfig {
         applicationId = "com.example.collectcircles"
         ...
     }
 }

זו הפעולה הטכנית הראשונה בשיעור. לאחר השינוי בצעו Gradle Sync.

ההגדרה גורמת ל-Android ליצור מחלקת binding לכל קובץ layout. מהשם activity_main.xml נוצרת המחלקה ActivityMainBinding, ובה שדה עם טיפוס מתאים לכל View שיש לו android:id.

ב-MainActivity מנפחים את המסך ומציגים אותו כך:

השורה תופיע באדום: אשרו את הצעת ה-import

 public class MainActivity extends AppCompatActivity {
+private ActivityMainBinding binding;

בתחילת onCreate, החליפו את טעינת ה-layout:

-setContentView(R.layout.activity_main);
+binding = ActivityMainBinding.inflate(getLayoutInflater());
+setContentView(binding.getRoot());

כך נוכל לגשת לכפתור בשם binding.startButton, בלי findViewById ובלי המרה ידנית לטיפוס הנכון.

2. יוצרים את Circle

בחלון Android, תחת app > kotlin+java > com.example.collectcircles, צרו Java Class בשם Circle. כל עיגול שומר את מרכזו, את הרדיוס ואת הצבע שלו:

הבלוקים שמתחילים ב־/** הם Javadoc: תיעוד ש־Android Studio מציג בעת השלמה אוטומטית ובחלון Quick Documentation. התגיות @param ו־@return מסבירות למי שקורא לפעולה אילו ערכים היא מקבלת ומה היא מחזירה; הערות // נשמור לפרטי המימוש שבתוך הפעולה.

package com.example.collectcircles;

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

public class Circle {
    private float centerX;
    private float centerY;
    private final float radius;
    private final int color;

    /**
     * יוצר עיגול במיקום, בגודל ובצבע הנתונים.
     *
     * @param centerX מיקום מרכז העיגול בציר האופקי
     * @param centerY מיקום מרכז העיגול בציר האנכי
     * @param radius רדיוס העיגול
     * @param color צבע העיגול
     */
    public Circle(float centerX, float centerY, float radius, int color) {
        this.centerX = centerX;
        this.centerY = centerY;
        this.radius = radius;
        this.color = color;
    }

    /**
     * @return מיקום מרכז העיגול בציר האופקי
     */
    public float getCenterX() {
        return centerX;
    }

    /**
     * @return מיקום מרכז העיגול בציר האנכי
     */
    public float getCenterY() {
        return centerY;
    }

    /**
     * @return רדיוס העיגול
     */
    public float getRadius() {
        return radius;
    }

    /**
     * מעביר את מרכז העיגול לנקודה חדשה.
     *
     * @param centerX מיקום המרכז החדש בציר האופקי
     * @param centerY מיקום המרכז החדש בציר האנכי
     */
    public void setCenter(float centerX, float centerY) {
        this.centerX = centerX;
        this.centerY = centerY;
    }

    /**
     * מחשב את המרחק בין מרכז העיגול למרכזו של עיגול אחר.
     *
     * @param other העיגול האחר
     * @return המרחק בין מרכזי העיגולים
     */
    public double distanceTo(Circle other) {
        return distanceTo(other.centerX, other.centerY);
    }

    /**
     * מחשב את המרחק בין מרכז העיגול לנקודה נתונה.
     *
     * @param pointX מיקום הנקודה בציר האופקי
     * @param pointY מיקום הנקודה בציר האנכי
     * @return המרחק ממרכז העיגול לנקודה
     */
    public double distanceTo(float pointX, float pointY) {
        float dx = centerX - pointX;
        float dy = centerY - pointY;
        return Math.sqrt(dx * dx + dy * dy);
    }

    /**
     * בודק אם נקודה נמצאת בתוך העיגול או על היקפו.
     *
     * @param pointX מיקום הנקודה בציר האופקי
     * @param pointY מיקום הנקודה בציר האנכי
     * @return {@code true} אם הנקודה בתוך העיגול או על היקפו
     */
    public boolean contains(float pointX, float pointY) {
        return distanceTo(pointX, pointY) <= radius;
    }

    /**
     * בודק אם לעיגול ולעיגול אחר יש שטח משותף.
     *
     * @param other העיגול האחר
     * @return {@code true} אם העיגולים חופפים
     */
    public boolean overlaps(Circle other) {
        return distanceTo(other) < radius + other.radius;
    }

    /**
     * מצייר את העיגול על משטח הציור הנתון.
     *
     * @param canvas משטח הציור
     * @param paint הגדרות הצבע והסגנון שישמשו לציור
     */
    public void draw(Canvas canvas, Paint paint) {
        paint.setStyle(Paint.Style.FILL);
        paint.setColor(color);
        canvas.drawCircle(centerX, centerY, radius, paint);
    }
}

רעיון גאומטרי אחד, שלוש בדיקות שונות

המרחק בין שתי נקודות מחושב לפי משפט פיתגורס. אם מרכזי העיגולים הם (x1, y1) ו-(x2, y2), ההפרשים האופקי והאנכי הם ניצבים במשולש ישר-זווית, ולכן אורך הקטע המחבר בין המרכזים הוא:

dx = x1 - x2
dy = y1 - y2
distance = sqrt(dx * dx + dy * dy)

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

בתרשים, d הוא תמיד המרחק בין המרכזים. r1 ו-r2 הם המרחקים מן המרכז אל ההיקף. אותו d מאפשר לענות על שלוש שאלות, אבל אסור להחליף בין התנאים:

השאלה התנאי הפעולה בקוד השימוש במשחק
האם נקודת מגע בתוך עיגול? d <= r1 contains(x, y) בחירת העיגול שנגעו בו
האם שני עיגולים חופפים? d < r1 + r2 overlaps(other) יצירת פתיחה בלי עיגולים חופפים
האם עיגול קטן כולו במטרה? d + r2 <= r1 fullyContains(circle) איסוף עיגול בשחרור

בבדיקת נקודת מגע אפשר לדמיין שהנקודה היא עיגול שרדיוסו אפס. הקוד משתמש ב-<=, ולכן גם נגיעה בדיוק בהיקף נחשבת נגיעה בעיגול. לעומת זאת, בבדיקת החפיפה בחרנו ב-<: כאשר d == r1 + r2, ההיקפים רק נוגעים בנקודה אחת ואין שטח משותף. לכן overlaps מחזירה false במקרה הזה.

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

3. יוצרים Target extends Circle

המטרה היא עיגול, אבל היא גם יודעת לצייר צלב ולבדוק הכלה מלאה. במקום לשכפל את כל הקוד של Circle, נשתמש בירושה. באותו package צרו Java Class בשם Target:

package com.example.collectcircles;

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

public class Target extends Circle {
    private final int crossColor;
    private final float crossStrokeWidth;

    /**
     * יוצר מטרה עגולה שבמרכזה מצויר צלב.
     *
     * @param centerX מיקום מרכז המטרה בציר האופקי
     * @param centerY מיקום מרכז המטרה בציר האנכי
     * @param radius רדיוס המטרה
     * @param color צבע המטרה
     * @param crossColor צבע הצלב
     * @param crossStrokeWidth עובי קווי הצלב
     */
    public Target(float centerX, float centerY, float radius, int color,
                  int crossColor, float crossStrokeWidth) {
        super(centerX, centerY, radius, color);
        this.crossColor = crossColor;
        this.crossStrokeWidth = crossStrokeWidth;
    }

    /**
     * בודק אם עיגול נמצא במלואו בתוך המטרה.
     *
     * @param circle העיגול שאותו בודקים
     * @return {@code true} אם כל העיגול נמצא בתוך המטרה
     */
    public boolean fullyContains(Circle circle) {
        return distanceTo(circle) + circle.getRadius() <= getRadius();
    }

    /**
     * מצייר את גוף המטרה ואת הצלב שבמרכזה.
     *
     * @param canvas משטח הציור
     * @param paint הגדרות הצבע והסגנון שישמשו לציור
     */
    @Override
    public void draw(Canvas canvas, Paint paint) {
        super.draw(canvas, paint);

        paint.setStyle(Paint.Style.STROKE);
        paint.setStrokeWidth(crossStrokeWidth);
        paint.setColor(crossColor);

        canvas.drawLine(
                getCenterX(), getCenterY() - getRadius(),
                getCenterX(), getCenterY() + getRadius(), paint
        );
        canvas.drawLine(
                getCenterX() - getRadius(), getCenterY(),
                getCenterX() + getRadius(), getCenterY(), paint
        );
    }
}

super(...) מפעיל את הבנאי של Circle. בתוך draw, הקריאה super.draw(...) מציירת תחילה את גוף העיגול האדום; לאחר מכן מציירים שני קווים לבנים. @Override מבקש מהקומפיילר לוודא שאנחנו באמת דורסים פעולה שקיימת במחלקת האב.

חפיפה והכלה מלאה אינן אותה בדיקה. לחפיפה מספיק distance < r1 + r2. כדי שעיגול קטן יהיה כולו בתוך המטרה, גם הקצה הרחוק שלו חייב להישאר בתוך רדיוס המטרה: distance + smallRadius <= targetRadius.

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

לדוגמה, אם רדיוס המטרה הוא 100, רדיוס העיגול הוא 30, והמרחק בין המרכזים הוא 80, המרכז אכן בתוך המטרה (80 < 100) ויש חפיפה. ובכל זאת הקצה הרחוק נמצא במרחק 110, ולכן העיגול אינו מוכל במלואו. רק כאשר d <= 70 הוא כולו בפנים.

4. יוצרים לוח שמצייר את עצמו

הכנות

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

<string name="game_title">Speed Collector</string>
<string name="elapsed_time_initial">Time: 0.000 s</string>
<string name="best_time_initial">Best: —</string>
<string name="start">Start</string>

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

<color name="screen_background">#FFFDFB</color>
<color name="board_background">#F2EDFF</color>
<color name="title_purple">#6A3DA3</color>
<color name="time_brown">#795548</color>
<color name="target_red">#E53935</color>
<color name="target_cross">#FFFFFFFF</color>
<color name="circle_green">#43A047</color>

יצירת הלוח

באותו package צרו Java Class בשם GameBoardView. ב-Android אין צורך לחקות את ההפרדה של MAUI בין GraphicsView לבין IDrawable: מחלקה שיורשת מ-View יכולה להיות משטח הציור בעצמה.

החלקים החשובים במחלקה הם:

public class GameBoardView extends View {
    private static final float TARGET_RADIUS_DP = 64f;
    private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
    private Target target;
    private Circle[] previewCircles = new Circle[0];

    /**
     * יוצר לוח משחק מתוך קוד Java.
     *
     * @param context ההקשר שבו הלוח פועל
     */
    public GameBoardView(Context context) {
        this(context, null);
    }

    /**
     * יוצר לוח משחק מתוך XML.
     *
     * @param context ההקשר שבו הלוח פועל
     * @param attrs המאפיינים שנקראו מן ה-XML
     */
    public GameBoardView(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    /**
     * יוצר לוח משחק מתוך XML עם סגנון ברירת מחדל.
     *
     * @param context ההקשר שבו הלוח פועל
     * @param attrs המאפיינים שנקראו מן ה-XML
     * @param defStyleAttr מאפיין הסגנון שיוחל כברירת מחדל
     */
    public GameBoardView(Context context, AttributeSet attrs,
                         int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        setBackgroundColor(color(R.color.board_background));
    }

    /**
     * מתאים את המטרה ואת עיגולי הדוגמה למידות החדשות של הלוח.
     *
     * @param width רוחב הלוח החדש בפיקסלים
     * @param height גובה הלוח החדש בפיקסלים
     * @param oldWidth רוחב הלוח הקודם בפיקסלים
     * @param oldHeight גובה הלוח הקודם בפיקסלים
     */
    @Override
    protected void onSizeChanged(int width, int height,
                                 int oldWidth, int oldHeight) {
        float centerX = width / 2f;
        float centerY = height / 2f;
        float targetRadius = Math.min(
                dp(64f),
                Math.min(width, height) / 5f
        );
        float circleRadius = targetRadius / 2f;

        target = new Target(
                centerX, centerY, targetRadius,
                color(R.color.target_red),
                color(R.color.target_cross),
                dp(2f)
        );

        previewCircles = new Circle[] {
                new Circle(circleRadius * 1.5f, circleRadius * 1.5f,
                        circleRadius, color(R.color.circle_green)),
                new Circle(width - circleRadius * 1.5f, circleRadius * 2.2f,
                        circleRadius, color(R.color.circle_green)),
                new Circle(circleRadius * 1.8f, height - circleRadius * 1.7f,
                        circleRadius, color(R.color.circle_green))
        };
    }

    /**
     * מצייר את המטרה ואת עיגולי הדוגמה על הלוח.
     *
     * @param canvas משטח הציור שסיפקה מערכת Android
     */
    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        if (target == null) {
            return;
        }

        target.draw(canvas, paint);
        for (Circle circle : previewCircles) {
            circle.draw(canvas, paint);
        }
    }

    /**
     * ממיר יחידות dp לפיקסלים בהתאם לצפיפות המסך.
     *
     * @param value הגודל ביחידות dp
     * @return הגודל המתאים בפיקסלים
     */
    private float dp(float value) {
        return value * getResources().getDisplayMetrics().density;
    }

    /**
     * קורא ערך צבע ממשאבי היישום.
     *
     * @param colorResource מזהה משאב הצבע
     * @return ערך הצבע שבו אפשר להשתמש לציור
     */
    private int color(int colorResource) {
        return ContextCompat.getColor(getContext(), colorResource);
    }
}
  • הוסיפו/השאירו בראש הקובץ את package com.example.collectcircles;
  • הוסיפו בראש הקובץ את ה-imports של Context, Canvas, Paint, View, AttributeSet, View ו-ContextCompat. (כל רכיב שמופיע באדום וניתן להוסיף לו import)

שלושת הבנאים מאפשרים ל-Android ליצור את ה-View גם מתוך XML. הפעולה dp(...) ממירה יחידות צפיפות לפיקסלים, ו-color(...) קוראת צבע מן המשאבים במקום לקודד אותו ישירות ב-Java. onSizeChanged מתאימה את האובייקטים לגודל האמיתי של הלוח. onDraw מקבלת Canvas, שהוא משטח הציור של Android. האובייקט Paint קובע צבע וסגנון ציור, ו-ANTI_ALIAS_FLAG מחליק את שולי העיגולים.

שימו לב לחלוקת העבודה: Circle.draw יודעת כיצד עיגול יחיד נראה, ואילו GameBoardView.onDraw יודעת באיזה סדר לצייר את כל העצמים. איננו שומרים פיקסלים של תמונה; אנחנו שומרים מצב - מרכזים, רדיוסים וצבעים - ומציירים ממנו מחדש בכל פעם ש-Android מבקשת זאת.

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

בדיקת ביניים

אוסף השינויים שביצענו עד אמור לרוץ (גם אם אין שינוי ורואים רק Hello World!)

5. בונים את המסך ב-XML

רוצים ללמוד כיצד לבנות את אותו מסך בגרירה ובחיבור constraints בעזרת העכבר? עברו אל CollectCircles 1א - בניית המסך בעזרת Layout Editor. אפשר לבצע את שיעור הרשות עכשיו או לחזור אליו מאוחר יותר.

החליפו את התוכן של activity_main.xml. המסך מכיל כותרת, שתי תוויות זמן, כפתור ולוח משחק שתופס את יתרת הגובה:

<?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" />

    <com.google.android.material.button.MaterialButton
        android:id="@+id/startButton"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginTop="12dp"
        android:text="@string/start"
        android:textSize="18sp"
        app:cornerRadius="14dp"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/elapsedTimeText" />

    <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/startButton"
        tools:background="@color/board_background" />

</androidx.constraintlayout.widget.ConstraintLayout>

0dp ב-ConstraintLayout אינו אומר “גודל אפס”. הוא אומר שהרכיב יקבל את הגודל שנקבע על-ידי האילוצים שלו. לכן gameBoard נמתח בין הכפתור לבין תחתית המסך.

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

6. משתמשים ב-binding מתוך הפעילות

בסוף onCreate הוסיפו:

+// View Binding gives us a typed reference generated from activity_main.xml.
+// The button becomes active in the next teaching step, when a playable game exists.
+binding.startButton.setEnabled(false);

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

7. בדיקה

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

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

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


המשך

מורה־עזר ב־Gemini

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