ב.12 — גילוי התנגשויות
כמעט כל משחק בנוי על מפגשים בין עצמים: הכדור פוגע במחבט, החללית נתקלת במטאור, הדמות אוספת מטבע. המשחק חייב לדעת ברגע שמפגש כזה קורה כדי להגיב לו, למשל להוסיף נקודות, להוריד חיים או להסיר את המטבע מהמסך. הבעיה של זיהוי מפגש כזה נקראת גילוי התנגשויות — Collision Detection, והיא נושא הפרק.
מבחינת התוכנית, שני עצמים "נפגשים" כאשר הם מגיעים לאותו אזור בחלון. כדי להגיב לכך צריך קודם לבדוק אם הם חופפים. לכל ספרייט כבר יש מלבן, ולכן הבדיקה הפשוטה ביותר היא אם שני המלבנים חופפים; אך מלבן הוא רק קירוב לצורת התמונה. הבחירה בדרך הבדיקה תלויה בצורת העצמים: מסגרת מלבנית היא פשוטה ומהירה, מעגל מתאים לעצם עגול, ומסכה מאפשרת להתחשב בצורת הפיקסלים שבתמונה ולכן היא המדויקת ביותר. נכיר את שלוש הדרכים לפי הסדר הזה, ולבסוף נראה כיצד להגיב לתוצאה.
המצגות: התנגשויות Rect (עותק מקומי) · Pygame Sprite (עותק מקומי)
חפיפה בין מלבנים
נתחיל בבדיקה הפשוטה ביותר, שמשתמשת במלבני ה־Rect שכבר יש לנו. בכל פריים, לאחר עדכון המיקומים, בודקים את המלבנים. המתודה colliderect() מחזירה True אם הם חופפים בשטח. בדוגמה הבאה המלבן b מתחיל בתוך תחום המלבן a, ולכן הם חופפים:
a = pygame.Rect(100, 100, 60, 60)
b = pygame.Rect(140, 120, 60, 60)
print(a.colliderect(b))
פלט
True
נגיעה בקצה בלבד, בלי חפיפת שטח, אינה התנגשות בבדיקת מלבנים. בין ספרייטים בעלי rect אפשר לכתוב:
hit = pygame.sprite.collide_rect(star, other)
הפונקציה collide_rect() פשוט משווה בין star.rect ו־other.rect, בדיוק כמו colliderect(). לבדיקת מלבנים יש חיסרון: המסגרת כוללת גם אזורים שקופים של התמונה. לכן שתי מסגרות עשויות לחפוף גם כאשר הצורות הצבועות אינן נוגעות, והשחקן יראה "פגיעה" שנראית לא הוגנת. שתי הדרכים הבאות מדייקות יותר.
בדיקה באמצעות מעגל
לעצם עגול, כמו כדור או כוכב, מלבן משאיר פינות ריקות גדולות, ומעגל מתאר את הצורה טוב יותר. אפשר להתאים לעצם כזה מעגל בדיקה לפי המרכז והרדיוס שלו: שני עצמים מתנגשים כשהמרחק בין מרכזיהם קטן מסכום הרדיוסים. לכל ספרייט נגדיר מאפיין radius. מרכז מעגל הבדיקה הוא rect.center. לאחר יצירת העצמים, למשל:
star.radius = 30
other.radius = 30
hit = pygame.sprite.collide_circle(star, other)
הרדיוס מתאר את אזור הבדיקה שבחרנו, ולא נובע בהכרח מכל הפיקסלים של התמונה. כאשר מגדירים אותו במפורש, אפשר לשלוט בהתאמה לצורה. אם אינו מוגדר, Pygame מחשבת מעגל מתוך המלבן; בדוגמאות נגדיר אותו כדי שהמשמעות תהיה ברורה.
בדיקה באמצעות מסכה
הבדיקה המדויקת ביותר מתחשבת בצורה האמיתית של התמונה, פיקסל אחר פיקסל. לשם כך משתמשים במסכה — Mask: מבנה ששומר, לכל פיקסל בתמונה, אם הוא נחשב שייך לצורה (צבוע) או לא (שקוף). שתי מסכות חופפות רק אם יש פיקסל צבוע אחד לפחות שמשותף לשתיהן. יוצרים את המסכה מהתמונה לאחר שינוי גודלה, כדי שתתאים לפיקסלים שמצוירים בפועל, ואז בודקים חפיפה בין שתי המסכות:
star.mask = pygame.mask.from_surface(star.image)
other.mask = pygame.mask.from_surface(other.image)
point = pygame.sprite.collide_mask(star, other)
hit = point is not None
collide_mask() מחזירה None אם אין חפיפה, או נקודת חפיפה ביחס למסכה הראשונה אם יש. היא אינה מחזירה ערך בוליאני, ולכן ההשוואה ל־None מבטאת במפורש מה בודקים.
איך המסכה יודעת אילו פיקסלים "צבועים"? בתמונה עם שקיפות לפי פיקסל, from_surface() משתמשת בסף Alpha (רכיב השקיפות של כל פיקסל, שהכרנו בפרק המשטחים והצבעים: 0 שקוף לגמרי ו־255 אטום לגמרי); ברירת המחדל היא 127. פיקסלים עם Alpha גדול מהסף נכנסים למסכה. אם לתמונה יש רקע אטום, גם הוא ייכלל — לכן חשוב שהתמונה תייצג נכון את גבולות העצם. כשמשנים את התמונה, למשל את גודלה, יש לעדכן גם את המסכה ואת המלבן.
דוגמה מלאה: ספרייט עם radius ו־mask
עד כאן הגדרנו את radius ואת mask מבחוץ, אחרי יצירת הספרייטים. כשעובדים עם מחלקה, המקום הטבעי להם הוא הבנאי: שניהם נגזרים מהתמונה, ולכן מחשבים אותם מיד אחרי שנקבעו image ו־rect. כך כל מופע חדש של המחלקה מוכן לכל שלוש שיטות הבדיקה, בלי שנשכח להגדיר משהו. זו גם הדרך שבה המחלקות במצגת בנויות. הרדיוס כאן הוא מחצית רוחב התמונה, כלומר 30 פיקסלים לכוכב של 60×60; אפשר גם לקבוע מספר קבוע אם רוצים אזור בדיקה קטן או גדול יותר.
import pygame
class MovingImage(pygame.sprite.Sprite):
def __init__(self, image, center):
super().__init__()
self.image = image
self.rect = self.image.get_rect(center=center)
self.radius = self.rect.width // 2
self.mask = pygame.mask.from_surface(self.image)
def update(self, dx, dy):
self.rect.move_ip(dx, dy)
def draw(self, surface):
surface.blit(self.image, self.rect)
pygame.init()
screen = pygame.display.set_mode((640, 400))
pygame.display.set_caption("Collisions")
clock = pygame.time.Clock()
image = pygame.image.load("img/star.png").convert_alpha()
image = pygame.transform.scale(image, (60, 60))
star = MovingImage(image, (320, 200))
other = MovingImage(image, (370, 200))
running = True
while running:
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
if not running:
break
keys = pygame.key.get_pressed()
dx = 3 * (keys[pygame.K_RIGHT] - keys[pygame.K_LEFT])
dy = 3 * (keys[pygame.K_DOWN] - keys[pygame.K_UP])
star.update(dx, dy)
hit = pygame.sprite.collide_mask(star, other) is not None
color = (190, 225, 250) if hit else (17, 43, 65)
screen.fill(color)
star.draw(screen)
other.draw(screen)
pygame.display.update()
clock.tick(60)
pygame.quit()
התוכנית בודקת חפיפה במסכות. כדי לנסות את שתי השיטות האחרות מחליפים רק את שורת הבדיקה, כי המחלקה כבר מספקת את כל מה שנדרש:
hit = pygame.sprite.collide_rect(star, other)
hit = pygame.sprite.collide_circle(star, other)
hit = pygame.sprite.collide_mask(star, other) is not None
הזיזו את הכוכב באיטיות לעבר הכוכב השני בכל אחת מהשיטות: בבדיקת מלבנים הרקע יתבהר עוד לפני שהקצוות נוגעים, בבדיקת מעגל קצת מאוחר יותר, ובבדיקת מסכות רק כשקצה של כוכב אחד נוגע בפיקסל צבוע של השני. אם משנים את התמונה במהלך המשחק, למשל את גודלה, יש לחשב מחדש גם את rect, את radius ואת mask.
מגיבים לתוצאה
בדיקת החפיפה מספקת רק תוצאה, אמת או שקר; הקוד שלנו קובע כיצד להגיב לה, וכאן נמצא ה"משחק" עצמו. בתוכנית שלמעלה התגובה היא בחירת צבע הרקע לפי ההתנגשות, לאחר עדכון המיקומים ולפני הציור:
hit = pygame.sprite.collide_mask(star, other) is not None
color = (190, 225, 250) if hit else (17, 43, 65)
screen.fill(color)
שינוי הרקע מתבצע באותו פריים שבו זוהתה החפיפה, מפני שבוחרים את הצבע לפני fill().
גילוי התנגשות אינו עוצר עצם ואינו מסיר אותו בעצמו. תגובת התוכנית נקבעת בקוד שלנו. בפרק הבא נראה גם הסרה בעקבות התנגשות.
תיעוד התנגשויות ספרייטים · תיעוד מסכות
קוד להורדה: הדוגמה המלאה. בדוגמאות המשתמשות בתמונה, שמרו את הכוכב בתוך img/star.png לצד הקובץ והריצו מתוך התיקייה שלו.