בחנות כבר אפשר לקנות Pushers, אבל בינתיים רואים רק את המספר שלהם. בפרק הזה כל Pusher יהפוך לדמות מקל קטנה שהולכת על הלוח, מזיזה ידיים ורגליים ומסתובבת כאשר היא מגיעה לקצה. עדיין לא נחבר אותה לעיגול — פעולת הדחיפה תהיה הפרק הבא.
מדוע נצייר ב־Canvas?
- המשחק כבר מצויר כולו ב־
GameBoardView, ולכן הדמות יכולה להשתמש באותה לולאת פריימים. - אין צורך בקובצי תמונה או ב־sprite sheet בשלב הראשון.
- אתם רואים כיצד דמות מורכבת מראש, גוף וקווים לידיים ולרגליים.
- אפשר לשנות גודל, צבע וכיוון בעזרת מספרים פשוטים.
גובה הדמות יהיה 75% מרדיוס המטרה. העיגול הקטן הוא בקוטר השווה לרדיוס המטרה, ולכן כל הדמות נשארת קטנה מן העיגול שהיא תדחוף בעתיד.
1. יוצרים את Pusher
בחלון Android, תחת app > kotlin+java > com.example.collectcircles, צרו Java Class
בשם Pusher:
package com.example.collectcircles;
import android.graphics.Canvas;
import android.graphics.Paint;
public class Pusher {
private static final float WIDTH_PART_OF_HEIGHT = 0.50f;
private static final float SPEED_IN_HEIGHTS_PER_SECOND = 0.80f;
private float centerX;
private final float centerY;
private final float height;
private final int color;
private final long animationOffsetMillis;
private int direction = 1;
/**
* Creates one animated walker at the requested board position.
*
* @param centerX horizontal center on the board, in pixels
* @param centerY vertical center on the board, in pixels
* @param height character height, in pixels
* @param color color used to draw the character
* @param animationOffsetMillis phase offset that separates this animation
* from the other Pushers
*/
public Pusher(float centerX, float centerY, float height,
int color, long animationOffsetMillis) {
this.centerX = centerX;
this.centerY = centerY;
this.height = height;
this.color = color;
this.animationOffsetMillis = animationOffsetMillis;
}
}
direction יהיה 1 כאשר הדמות הולכת ימינה ו־-1 כאשר היא הולכת שמאלה. ה־offset גורם לדמויות שונות לא להניף את הרגליים בדיוק באותו רגע.
2. מזיזים את מרכז הדמות ומסתובבים בקצה
הוסיפו ל־Pusher:
/**
* Advances the walker and turns it around before it leaves the board.
*
* @param elapsedSeconds time since the previous frame, in seconds
* @param boardWidth current board width, in pixels
*/
public void update(double elapsedSeconds, float boardWidth) {
float speed = height * SPEED_IN_HEIGHTS_PER_SECOND;
centerX += direction * speed * elapsedSeconds;
// Clamp the center so the whole character remains inside the board.
float halfWidth = height * WIDTH_PART_OF_HEIGHT / 2f;
if (centerX > boardWidth - halfWidth) {
centerX = boardWidth - halfWidth;
direction = -1;
} else if (centerX < halfWidth) {
centerX = halfWidth;
direction = 1;
}
}
המהירות נמדדת ב”גבהים של דמות בשנייה”, ולכן דמות גדולה יותר אינה נראית כאילו היא זוחלת. לאחר החישוב אנחנו מתקנים את המיקום אם הקצה שלה עבר את גבול הלוח והופכים את הכיוון.
לבדיקה נוסיף getters ללא public, מפני שרק בדיקות מאותו package זקוקות להם:
/**
* Returns the current horizontal center for package-level unit tests.
*
* @return horizontal center on the board, in pixels
*/
float getCenterX() {
return centerX;
}
/**
* Returns the current walking direction for package-level unit tests.
*
* @return {@code 1} when walking right, or {@code -1} when walking left
*/
int getDirection() {
return direction;
}
3. מציירים גוף ומניעים גפיים
הוסיפו את פעולת הציור:
/**
* Draws one animation frame of the stick figure.
*
* @param canvas destination on which the character is drawn
* @param paint reusable Paint configured by this method before drawing
* @param animationTimeMillis monotonic animation time, in milliseconds
*/
public void draw(Canvas canvas, Paint paint, long animationTimeMillis) {
// Configure the shared Paint for an outlined figure with rounded joints.
paint.setColor(color);
paint.setStyle(Paint.Style.STROKE);
paint.setStrokeWidth(Math.max(2f, height * 0.06f));
paint.setStrokeCap(Paint.Cap.ROUND);
// Derive every body landmark from height so the figure scales uniformly.
float headRadius = height * 0.12f;
float headY = centerY - height * 0.30f;
float shouldersY = centerY - height * 0.12f;
float hipsY = centerY + height * 0.14f;
float footY = centerY + height * 0.42f;
// Convert time to a repeating offset; the phase shift keeps Pushers out of sync.
double phase = (animationTimeMillis + animationOffsetMillis) / 140.0;
float swing = (float) Math.sin(phase) * height * 0.12f;
// Draw the head and a short nose that points in the walking direction.
canvas.drawCircle(centerX, headY, headRadius, paint);
canvas.drawLine(
centerX + direction * headRadius,
headY,
centerX + direction * headRadius * 1.55f,
headY,
paint
);
// The torso joins the bottom of the head to the hips.
canvas.drawLine(centerX, headY + headRadius, centerX, hipsY, paint);
// Opposite swing signs make the arms alternate instead of moving together.
canvas.drawLine(centerX, shouldersY,
centerX + direction * swing, centerY + height * 0.05f, paint);
canvas.drawLine(centerX, shouldersY,
centerX - direction * swing, centerY + height * 0.05f, paint);
// The legs use the same phase in opposite directions to create a walking step.
canvas.drawLine(centerX, hipsY,
centerX + swing, footY, paint);
canvas.drawLine(centerX, hipsY,
centerX - swing, footY, paint);
}
הראש הוא עיגול, האף הוא קו קצר לכיוון ההליכה, הגוף הוא קו אנכי, והידיים והרגליים הן ארבעה קווים. sin מחזיר ערכים בין -1 ל־1 ולכן swing נע בצורה מחזורית מצד לצד. כאשר רגל אחת קדימה, השנייה אחורה.
הדמות מצוירת כ־STROKE, בעוד העיגולים מצוירים כ־FILL. כל אובייקט מכין את ה־Paint לפני שהוא מצייר, ולכן סדר הציור אינו משאיר בטעות את הסגנון הלא נכון לאובייקט הבא.
איך אנימציה ב־Canvas באמת עובדת?
ה־Canvas מצייר תמונה אחת, לא אנימציה
Canvas אינו זוכר דמות, יד או רגל מן הפריים הקודם ואינו מזיז אותן בעצמו. בכל קריאה ל־onDraw מציירים מחדש את כל המצב הנוכחי של הלוח. תחושת התנועה נוצרת כאשר Android מציג במהירות סדרה של תמונות שבהן הקואורדינטות שונות מעט.
לכן לא מוחקים את הרגל הישנה ולא גוררים קו שכבר צויר. מחשבים היכן הרגל צריכה להיות עכשיו, ומציירים את הפריים החדש:
זמן חדש → עדכון מצב המשחק → בקשת ציור → חישוב קואורדינטות → ציור הפריים
מפרידים בין update לבין draw
לשתי הפעולות תפקידים שונים:
update(elapsedSeconds, ...)משנה את מצב המשחק: למשל אתcenterXואתdirection.draw(canvas, paint, animationTimeMillis)קוראת את המצב ומציירת ממנו תמונה אחת. היא אינה מחליטה כמה זמן עבר ואינה עוצרת כדי להציג את התמונה.
במימוש שלנו שתי הפעולות קשורות לקצב הפריימים: postOnAnimation מבקש callback לקראת פריים התצוגה הבא, ה־callback קורא ל־update ול־invalidate(), ו־Android קורא ל־onDraw בזמן המתאים כדי להכין פריים. עם זאת, אין הבטחה לקריאת draw אחת בכל רענון פיזי של המסך. פריים עלול להתעכב או להישמט, וכמה בקשות ציור יכולות להתאחד לציור אחד.
לכן draw הוא תלוי־פריים, אך מצב המשחק אינו תלוי במספר הפריימים:
- במסך של 120Hz יהיו בדרך כלל יותר עדכונים וציורים מאשר במסך של 60Hz, אבל כל
elapsedSecondsיהיה קטן יותר. המרחק לשנייה יישאר זהה. - אם פריים הוחמץ, לא מציירים אותו מאוחר יותר. בעדכון הבא
elapsedSecondsכולל את הזמן הנוסף, ו־drawמצייר מיד את המיקום העדכני. drawכן מקבלת אתanimationTimeMillisבחשבון כדי לחשב את תנוחת הגפיים בפריים הנוכחי, אבל אסור לה לקדם מונה, לשנות יתרה או להחליט שהושלמה משימה.
כלל קצר: update קובע מהו מצב העולם עכשיו; draw קובע איך המצב הזה נראה בפריים ש־Android מצייר עכשיו.
כך מהירות ההליכה מחושבת לפי זמן אמיתי ולא לפי מספר קריאות הציור. בנוסף, בפרק הבא נוכל להחליף את חוקי התנועה בלי לכתוב מחדש את ציור הדמות.
שני מדדי הזמן בקוד
בפרק הזה משתמשים בזמן בשתי דרכים:
-
elapsedSecondsהוא הזמן מאז הפריים הקודם. מכפילים אותו במהירות כדי לחשב את המרחק שיש לעבור:centerX += direction * speed * elapsedSeconds;אם פריים התעכב, הדמות תתקדם מעט יותר בפריים הבא במקום להיראות איטית יותר.
-
animationTimeMillisהוא זמן מונוטוני שממשיך לעלות. ממנו מחשבים את תנודת הידיים והרגליים. משתמשים ב־SystemClock.elapsedRealtime()ולא בשעון ותאריך רגילים, מפני ששינוי השעה במכשיר אינו צריך לגרום לאנימציה לקפוץ.
הופכים זמן לתנועה מחזורית
השורה:
double phase = (animationTimeMillis + animationOffsetMillis) / 140.0;
הופכת מילישניות ל־פאזה — מיקום בתוך המחזור. לאחר מכן:
float swing = (float) Math.sin(phase) * height * 0.12f;
Math.sin מחזירה ערך בין -1 ל־1. הכפל ב־height * 0.12f הופך אותו להיסט אופקי שמתאים לגודל הדמות:
- כאשר
swingחיובי, גפה אחת נשלחת ימינה והגפה השנייה שמאלה. - כאשר
swingהוא 0, שתי הגפיים עוברות דרך מרכז הגוף. - כאשר
swingשלילי, הכיוונים מתחלפים.
מחזור שלם של sin הוא 2π, ולכן המחלק 140.0 יוצר מחזור הליכה של בערך 2π × 140, כלומר כ־880 מילישניות. מחלק קטן יותר יאיץ את הנפת הגפיים; משרעת גדולה יותר מ־0.12f תגדיל את אורך הצעד.
animationOffsetMillis מזיז כל Pusher לנקודה אחרת במחזור. כולם משתמשים באותו שעון, אבל אינם מניפים ידיים ורגליים בדיוק יחד.
מי מבקש את הפריים הבא?
לולאת האנימציה של GameBoardView עושה בכל פריים עבודה קצרה:
- קוראת זמן מונוטוני ומחשבת את
elapsedSeconds. - קוראת ל־
Game.update, שמעדכנת את מיקום הדמויות. - קוראת ל־
invalidate(), שמבקשת מ־Android לצייר מחדש. הקריאה אינה מפעילה אתonDrawמיד. - קוראת ל־
postOnAnimation(...), כדי לבקש callback לקראת פריים המסך הבא. - כאשר Android קורא ל־
onDraw, מועבר זמן האנימציה אלGame.drawומשם לכלPusher.draw.
אין להוסיף while ארוך או sleep בתוך update, draw או onDraw. פעולות כאלה חוסמות את ה־UI thread: המסך אינו מצויר, מגע אינו מטופל וכל היישום נראה תקוע. בכל פריים מבצעים מעט חישוב, מציירים ומחזירים מיד שליטה ל־Android.
מתכון לאנימציית Canvas נוספת
כאשר תרצו להנפיש אובייקט נוסף:
- שמרו בשדות רק את מצב האובייקט, למשל מרכז, כיוון, מהירות ושלב עבודה.
- עדכנו מצב מתמשך ב־
updateלפיelapsedSeconds. - לתנועה מחזורית, הפכו את הזמן לפאזה והשתמשו ב־
sin,cosאו התקדמות בין 0 ל־1. - חשבו ב־
drawאת הקואורדינטות של הפריים הנוכחי וציירו את כל האובייקט מחדש. - הגדירו ב־
Paintאת הצבע, הסגנון ועובי הקו שהאובייקט צריך; אל תניחו שהאובייקט הקודם השאיר אותם במצב מתאים. - השאירו את פעולות העדכון והציור קצרות וללא קוד חסום, קלט־פלט או יצירת לולאת המתנה.
4. מחזיקים Pushers בתוך Game
הוסיפו את השדה:
private final List<Pusher> pushers = new ArrayList<>();
הוסיפו לבנאי את מספר ה־Pushers ואת צבעם אחרי autonomous:
+/**
+ * Creates a game and restores its purchased Pushers only in Auto mode.
+ *
+ * @param autonomous whether this is an endless Auto game
+ * @param pusherCount number of purchased Pushers to restore
+ * @param pusherColor color used to draw the Pushers
+ */
public Game(...,
- int circleColor, boolean autonomous) {
+ int circleColor, boolean autonomous, int pusherCount,
+ int pusherColor) {
אי־התאמה זמנית צפויה: מיד לאחר שינוי חתימת הבנאי, Android Studio יסמן באדום את המקומות שעדיין קוראים ל־new Game(...) עם רשימת הפרמטרים הישנה. אין צורך ליצור בנאי נוסף רק כדי להשתיק את השגיאות באמצע הפרק.
- בסעיף 6 נעדכן את יצירת ה־
GameבתוךGameBoardViewוגם את הקריאה ל־Game.draw. - בסעיף 7 נעדכן את הקריאה מ־
MainActivity; בסוף סעיף 7 קוד היישום שוב יהיה תואם ויוכל להתקמפל. - בדיקות היחידה עדיין ישתמשו בבנאי הישן. בסעיף 8 נעדכן גם אותן; רק בסוף סעיף 8 כל הפרויקט, כולל
testDebugUnitTest, אמור להתקמפל ולעבור.
אחרי יצירת העיגולים הוסיפו:
if (autonomous) {
setPusherCount(pusherCount, boardWidth, boardHeight, pusherColor);
}
כעת הוסיפו את הפעולה שיוצרת רק את הדמויות החסרות:
/**
* Adds missing Pushers without recreating workers already on the board.
*
* @param requestedCount total number of Pushers that should exist
* @param boardWidth current board width, in pixels
* @param boardHeight current board height, in pixels
* @param pusherColor color used to draw newly created Pushers
*/
public void setPusherCount(int requestedCount, float boardWidth,
float boardHeight, int pusherColor) {
if (!autonomous) {
return;
}
float pusherHeight = target.getRadius() * 0.75f;
while (pushers.size() < requestedCount) {
int index = pushers.size();
float usableWidth = Math.max(1f, boardWidth - pusherHeight);
float x = pusherHeight / 2f
+ (index * pusherHeight * 1.4f) % usableWidth;
float y = boardHeight
- pusherHeight * (0.65f + index % 3);
pushers.add(new Pusher(
x,
y,
pusherHeight,
pusherColor,
index * 90L
));
}
}
ה־Pushers מתחילים בשלושה מסלולים אופקיים סמוך לתחתית הלוח. כאשר יש יותר משלושה, המסלולים חוזרים אבל נקודת ה־x וה־offset של האנימציה שונים.
5. מעדכנים ומציירים אותם
לפני
public void draw(Canvas canvas, Paint paint) {
target.draw(canvas, paint);
for (Circle circle : circles) {
circle.draw(canvas, paint);
}
}
public void update(...) {
// יצירת עיגולים בלבד
}
אחרי
+/**
+ * Draws the game objects using one animation clock for every Pusher.
+ *
+ * @param canvas destination on which the game is drawn
+ * @param paint reusable Paint configured by each drawable object
+ * @param animationTimeMillis monotonic animation time, in milliseconds
+ */
-public void draw(Canvas canvas, Paint paint) {
+public void draw(Canvas canvas, Paint paint,
+ long animationTimeMillis) {
target.draw(canvas, paint);
for (Circle circle : circles) {
circle.draw(canvas, paint);
}
+ for (Pusher pusher : pushers) {
+ pusher.draw(canvas, paint, animationTimeMillis);
+ }
}
+/**
+ * Updates circle spawning, then advances every Pusher on the board.
+ *
+ * @param elapsedSeconds time since the previous frame, in seconds
+ * @param boardWidth current board width, in pixels
+ * @param boardHeight current board height, in pixels
+ * @param circleColor color used for newly spawned circles
+ */
public void update(...) {
// יצירת העיגולים נשארת
+ // Advance each Pusher by the same elapsed frame time.
+ for (Pusher pusher : pushers) {
+ pusher.update(elapsedSeconds, boardWidth);
+ }
}
ה־Pushers מצוירים אחרונים ולכן נשארים נראים גם כאשר הם עוברים לרגע מעל עיגול. בפרק הבא נחליף את הסיור החופשי בתנועה מכוונת אל עיגול.
6. מעבירים את הזמן ואת מספר ה־Pushers מן ה־View
הוסיפו ל־colors.xml:
<color name="pusher_blue">#1565C0</color>
GameBoardView כבר קורא SystemClock.elapsedRealtime() בכל פריים. העבירו אותו גם לציור:
-game.draw(canvas, paint);
+game.draw(canvas, paint, SystemClock.elapsedRealtime());
שנו את startAutonomousGame כך שיקבל את המספר השמור:
+/**
+ * Starts an Auto board with every Pusher purchased so far.
+ *
+ * @param pusherCount number of purchased Pushers to place on the board
+ */
-public void startAutonomousGame() {
+public void startAutonomousGame(int pusherCount) {
שנו את createGame לקבל את המספר ולהעביר גם אותו וגם את הצבע:
לפני
/**
- * Builds either game mode with the same board measurements and colors.
*/
-private Game createGame(float targetRadius, boolean autonomous) {
return new Game(
getWidth(),
getHeight(),
targetRadius,
color(R.color.target_red),
color(R.color.target_cross),
dp(2f),
color(R.color.circle_green),
- autonomous
);
}
אחרי
/**
+ * Builds either game mode with the same colors and requested Pusher count.
+ *
+ * @param targetRadius target radius, in pixels
+ * @param autonomous whether to create an Auto game
+ * @param pusherCount number of purchased Pushers to restore
+ * @return a new game model sized for the current board
*/
+private Game createGame(float targetRadius, boolean autonomous,
+ int pusherCount) {
return new Game(
getWidth(),
getHeight(),
targetRadius,
color(R.color.target_red),
color(R.color.target_cross),
dp(2f),
color(R.color.circle_green),
+ autonomous,
+ pusherCount,
+ color(R.color.pusher_blue)
);
}
ב־startNewGame העבירו 0 Pushers למשחק הקצר:
- game = createGame(calculateTargetRadius(), false);
+ game = createGame(calculateTargetRadius(), false, 0);
ב־startAutonomousGame העבירו את המספר שהפעולה קיבלה:
- game = createGame(targetRadius, true);
+ game = createGame(targetRadius, true, pusherCount);
כדי שקנייה חדשה תופיע בלי לאתחל את הלוח, הוסיפו ל־GameBoardView:
/**
* Synchronizes the active game with the updated total of purchased Pushers.
* Only missing Pusher objects are added; existing ones are preserved.
*
* @param pusherCount total number of purchased Pushers
*/
public void setPusherCount(int pusherCount) {
if (game == null) {
return;
}
game.setPusherCount(
pusherCount,
getWidth(),
getHeight(),
color(R.color.pusher_blue)
);
invalidate();
}
7. מחברים את המספר מ־MainActivity
ב־showSelectedGameMode, עדכנו את פתיחת המשחק האוטונומי:
- binding.gameBoard.startAutonomousGame();
+ binding.gameBoard.startAutonomousGame(gameProgress.getPusherCount());
בנוסף ב- showPusherShop(), בתוך מאזין הלחיצה של הכפתור החיובי הוסיפו:
dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener(view -> {
if (gameProgress.buyPusher()) {
showProgress();
+ // Sync the active board with the updated saved Pusher count.
+ binding.gameBoard.setPusherCount(gameProgress.getPusherCount());
Toast.makeText(this, R.string.pusher_hired, Toast.LENGTH_SHORT).show();
dialog.dismiss();
}
});
הרכישה עצמה מתבצעת כבר בקריאה gameProgress.buyPusher() שבשורת ה־if:
- הפעולה בודקת שיש מספיק עיגולים ומחסירה את המחיר.
- היא מבצעת
pusherCount++ושומרת את המספר החדש ב־SharedPreferences. - רק אם כל זה הצליח היא מחזירה
true, ולכן נכנסים לגוף ה־if. - בשלב הזה
gameProgress.getPusherCount()כבר מחזירה את המספר הכולל המעודכן. binding.gameBoard.setPusherCount(...)אינה קונה Pusher ואינה מגדילה מונה. היא מסנכרנת את הלוח הפעיל עם המספר הכולל ומוסיפה רק את דמויות ה־Pusher שחסרות בו.
אם Auto כבוי, אין כרגע מודל אוטונומי לעדכן והפעולה אינה עושה דבר. מספר ה־Pushers עדיין נשמר, והם ייווצרו בפעם הבאה ש־Auto יופעל.
8. בדיקה ממוקדת
עדכנו את הקריאות לבנאי של Game בבדיקות ב-ExampleUnitTest והוסיפו לשתיהן 0, 0 עבור מספר וצבע ה־Pushers )(כ-2 פרמטרים אחרונים).
הוסיפו בדיקה לתנועה ולגבול:
/**
* Verifies that a long frame turns a Pusher around at the right edge.
*/
@Test
public void pusherTurnsAroundAndStaysInsideTheBoard() {
Pusher pusher = new Pusher(90, 50, 40, 0, 0);
pusher.update(10, 100);
assertTrue(pusher.getCenterX() <= 90);
assertEquals(-1, pusher.getDirection());
}
הריצו פעם אחת:
.\gradlew.bat testDebugUnitTest assembleDebug
9. בדיקה ידנית
- הפעילו Auto עוד לפני שקניתם Pusher. ודאו שהמשחק האוטונומי עצמו עובד: הוא מתחיל עם שלושה עיגולים ועיגולים חדשים ממשיכים להופיע עד לתקרה של 12.
- כאשר היתרה עדיין קטנה מ־64, פתחו Shop. ודאו שהמחיר המוצג הוא 64 ושכפתור Hire pusher מושבת, כך שלא ניתן לבצע את הקנייה.
- כבו Auto, הפעילו משחקים קצרים ואספו את העיגולים אל המטרה. חזרו על כך עד שביתרת
Circlesיש לפחות 64. - פתחו Shop וקנו את ה־Pusher הראשון. ודאו שהיתרה קטנה ב־64 ושמונה ה־Pushers עלה ל־1.
- הפעילו שוב Auto ובדקו שמופיעה דמות כחולה קטנה מן העיגולים הירוקים.
- צפו בידיים וברגליים: הן צריכות להתחלף בזמן ההליכה.
- המתינו עד שהדמות מגיעה לקצה. היא צריכה להישאר בתוך הלוח, להפנות את האף ולחזור.
- כבו Auto והפעילו משחק קצר. ה־Pusher אינו צריך להופיע במשחק הזמן.
ה־Pushers הם כעת דמויות מונפשות אמיתיות, אבל עדיין רק מטיילים. בפרק הבא כל Pusher יבחר עיגול פנוי, ילך אליו, ייצמד מאחוריו וידחוף אותו עד שהוא מוכל כולו במטרה.