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


View Binding, עיגולים, ירושה ו-View מותאם אישית

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

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

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

מה נלמד

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

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

1. מוודאים ש-View Binding פעיל

פתחו את app/build.gradle.kts והוסיפו בתוך android:

buildFeatures {
    viewBinding = true
}

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

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

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

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

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

2. בונים את המסך ב-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 אנחנו יודעים מהם הרוחב והגובה שבתוכם מותר להציב מרכזים.

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

<string name="app_name">Collect Circles</string>
<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>

3. יוצרים את Circle

צרו את Circle.java. כל עיגול שומר את מרכזו, את הרדיוס ואת הצבע שלו:

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;

    public Circle(float centerX, float centerY, float radius, int color) {
        this.centerX = centerX;
        this.centerY = centerY;
        this.radius = radius;
        this.color = color;
    }

    public float getCenterX() {
        return centerX;
    }

    public float getCenterY() {
        return centerY;
    }

    public float getRadius() {
        return radius;
    }

    public void setCenter(float centerX, float centerY) {
        this.centerX = centerX;
        this.centerY = centerY;
    }

    public double distanceTo(Circle other) {
        return distanceTo(other.centerX, other.centerY);
    }

    public double distanceTo(float pointX, float pointY) {
        float dx = centerX - pointX;
        float dy = centerY - pointY;
        return Math.sqrt(dx * dx + dy * dy);
    }

    public boolean contains(float pointX, float pointY) {
        return distanceTo(pointX, pointY) <= radius;
    }

    public boolean overlaps(Circle other) {
        return distanceTo(other) < radius + other.radius;
    }

    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, שני העיגולים חדרו זה לתוך זה.

4. יוצרים Target extends Circle

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

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;

    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;
    }

    public boolean fullyContains(Circle circle) {
        return distanceTo(circle) + circle.getRadius() <= getRadius();
    }

    @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 הוא כולו בפנים.

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

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

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

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

    public GameBoardView(Context context) {
        this(context, null);
    }

    public GameBoardView(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public GameBoardView(Context context, AttributeSet attrs,
                         int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        setBackgroundColor(color(R.color.board_background));
    }

    @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))
        };
    }

    @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);
        }
    }

    private float dp(float value) {
        return value * getResources().getDisplayMetrics().density;
    }

    private int color(int colorResource) {
        return ContextCompat.getColor(getContext(), colorResource);
    }
}

הוסיפו בראש הקובץ את ה-imports של Context, Canvas, Paint, AttributeSet, View ו-ContextCompat. ההערות @Nullable ו-@ColorInt רק עוזרות ל-Android Studio לבדוק את הקוד.

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

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

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

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. בדיקה

הריצו:

.\gradlew.bat testDebugUnitTest assembleDebug

אם JAVA_HOME אינו מוגדר ב-PowerShell:

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

בדקו שמתקבלים:

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

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


השיעור הבא