יום רביעי, 30 בנובמבר 2011

לו רק היתה לי ציפור כועסת... (פיזיקה במשחקים חלק 1)


הרבה מפתחי משחקים ברחבי העולם מקנאים מן הסתם בהצלחה של Angry Birds. מי לא רוצה לפתח משחק שמוכר מליונים של עותקים? (אפילו אם רק בדולר)


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


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


המנוע box2d הוא מנוע פופולארי למדי, הוא נבנה במקור ב C++, אבל יש לו המרות גם ל JAVA, גם ל objective C וגם לפלאש (actionscript). יש מן הסתם גם לשפות נוספות, לי אישית יצא לעבוד איתו בעיקר בפלאש, ובתכנות לאיפון. בתכנות לאיפון הוא מגיע בחבילה עם המנוע של cocos2d (שאולי גם עליו שווה לדבר בהזדמנות).


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


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


העולם הפיזיקאלי


 בואו נחשוב שנייה מה זה אומר להכניס עולם פיזיקאלי למשחק. אם כבר עבדתם בעבר על משחק שהוא לא פיזיקאלי, אז אתם יודעים שיש לכם במשחק אוביקטים שונים, שיש להם את הצד הויזואלי שלהם - מה שאנחנו רואים, שמכונה בדר"כ Sprite, ויש את הצד מאחורי מה שרואים, שכולל את המידע על האוביקט ואת השליטה בו.


לדוגמה בפלאש, אם יצרתם MovieClip שהוא סמל גרפי עם אפשרות לאנימציות, והצמדתם לו מחלקה (Class) אז המחלקה הזאת תחזיק את המידע ושליטה על האוביקט, וה MovieClip ייצג את הצד הויזואלי שלו, את ה Sprite שלו.


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


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


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

private function setupPhysicsWorld():void{
var gravity:b2Vec2 = new b2Vec2(0,30);
var allowSleep:Boolean=true;
PhysicsVars.world = new b2World(gravity, allowSleep);
PhysicsVars._contactListener = new MyContactListener()
PhysicsVars.world.SetContactListener(PhysicsVars._contactListener);
// debug draw
var debug_draw:b2DebugDraw = new b2DebugDraw();
var debug_sprite:Sprite = new Sprite();
this.addChild(debug_sprite);
debug_draw.SetSprite(debug_sprite);
debug_draw.SetDrawScale(PhysicsVars.RATIO);
debug_draw.SetFillAlpha(0.5);
debug_draw.SetLineThickness(3);
debug_draw.SetFlags(b2DebugDraw.e_shapeBit | b2DebugDraw.e_jointBit);
PhysicsVars.world.SetDebugDraw(debug_draw);
PhysicsVars._debugDrawSprite = debug_sprite;
}

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




var gravity:b2Vec2 = new b2Vec2(0,30);
var allowSleep:Boolean=true;
PhysicsVars.world = new b2World(gravity, allowSleep);

הנושא של כוח משיכה ברור לכולנו (אפשר להגיד אותו כוקטור 0, כדי שלא יהיה כוח משיכה) , הפרמטר השני פשוט קובע אם המנוע יוכל לתת לאוביקטים "לישון" כלומר אם הם היו בחוסר תנועה האם אפשר לותר על חישובים שנוגעים להם, עד שקרה משהו שגרם לשינוי, למשל שאוביקט אחר פגע בהם.

אתם אולי תוהים למה אני משתמש בערך 30. ווכן, 30 הוא ערך די גבוה, בהרבה מצבים הייתי מעדיף להשתמש ב 10, אבל למשחק הזה, אני רוצה שהגרביטציה תשפיע חזק על אוביקטים. עכשיו, אתם אולי תוהים למה זה ערך חיובי ולא שלילי - בצדק, ובכן כיוון שאני עובד בפלאש, ה y גדל כלפי מטה - ולכן גם הגרביטציה תמשוך לשם - כלומר ל y חיובי.

PhysicsVars._contactListener = new MyContactListener()
PhysicsVars.world.SetContactListener(PhysicsVars._contactListener);

שתי השורות הבאות מגדירות את המחלקה שתשמש בתור ה contactListener. ה contactListener מטפל ב contacts כלומר, במגעי בין אוביקטים. כל עוד אוביקטים לא נוגעים אחד בשני, הם מושפעים מה velocity שלהם (כלומר המהירות והכיוון שלהם), מהגרביטציה של העולם, אבל אין משהו שיכול לשנות את ההתנהגות שלהם בצורה דראסטית, אבל כששני אוביקטים נפגשים, הם משפיעים אחד על השני, ממש כמו שני כדורי סנוקר שיפגעו האחד בשני.

במקרה הזה ה MyContactListener הוא מחלקה שאני כתבתי שיורשת מ ContactListener וזאת על מנת שאני אוכל לשלוט בדברים מסוימים שקורים בהתנגשות בין אוביקטים. למשל במקרה שלי, אני שומר את כל הcontacts, את כל המגעים שנוצרו, כדי שאני אוכל לעבור עליהם ולטפל בהם. לדוגמה אם יריה פוגעת באויב, אני ארצה לדעת את זה כדי "להרוג". המנוע הפיזיקאלי אולי יגרום לאויב לזוז אחורה, כי היריה יותר מהירה וחזקה ממנו, אבל הוא לא יכול לדעת למשל, להעלים את האויב מהמסך. זה מסוג הדברים שאנחנו צריכים (ורוצים  לטפל בהם)

                       // debug draw
var debug_draw:b2DebugDraw = new b2DebugDraw();
var debug_sprite:Sprite = new Sprite();
this.addChild(debug_sprite);
debug_draw.SetSprite(debug_sprite);
debug_draw.SetDrawScale(PhysicsVars.RATIO);
debug_draw.SetFillAlpha(0.5);
debug_draw.SetLineThickness(3);
debug_draw.SetFlags(b2DebugDraw.e_shapeBit | b2DebugDraw.e_jointBit);
PhysicsVars.world.SetDebugDraw(debug_draw);
PhysicsVars._debugDrawSprite = debug_sprite;

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

אנחנו מגדירים פה מה יצוייר, אייך יצוייר ואיפה. אני לא אכנס לעומק של הדברים, רק אני אנצל את ההזדמנות להסביר מושג ה RATIO. אצלי אני מחזיק אותו בתוך מחלקה PhysicsVars במקום שהוא נגיש בקלות לכל מחלקה בפרויקט. נהוג לכנות אותו PTM_RATIO כשהכוונה ל Points to Meter Ratio, יחס בין נקודות למטרים.

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

העולם של המשחק שלנו הוא קצת שונה מהעולם האמיתי יש לנו פיקסלים, שאנו נתיחס אליהם כנקודות, ויש את היחידות של העולם הפיזיקאלי. עכשיו נניח שגובה הדמות שאנחנו משחקים במשחק היא64 פיקסלים, אם נגדיר את ה PTM_RATIO כשווה ל 1, זה אומר שהפיזיקה תתיחס אל הדמות כאילו הגובה שלה הוא 64 מטר, והבהתאמה, לכל שאר האוביקטים במשחק. זה יכול לגרום לכל הפיזיקה במשחק להתנהג באופן מוזר מאוד. אבל אם ה PTM_RATIO שווה ל 30 למשל, אז גובה הדמות שלי הוא קצת מעל 2 מטר, זה אולי נשמע לכם עדיין גבוה, אבל ברמה פיזיקאלית, ההבדל בין זה לבין 1.80, או אפילו 1.50 מטר אינו כזה גדול. אז אפשר לצפות שהפיזיקה תתנהג פחות או יותר כפי שאנחנו נצפה.

במקרה של פלאש אני משתמש באמת ב PTM_RATIO ששווה ל 30. כשאני מתכנת לאיפון אני משתמש ב 32, מסיבות טכניות של נוחות  (וגם  כי זה ה default ב template של cocos2d עם box2d)


לולאת המשחק הפיזיקאלי

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


private function enterFrameHandler(e:Event):void{
. . .
// 10. handling the step and all the basics
var timeStep:Number = 1/60;
var velocityIter:int=4;
var positionIter:int=4;
// 20. doing step
PhysicsVars.world.Step(timeStep, velocityIter, positionIter);
// 30. update actors
for each (var tmpActore:Actor in _allActors){
tmpActore.updateNow();
}
PhysicsVars.world.ClearForces();
if(GameData.kSHOW_DEBUG_DATA){
PhysicsVars.world.DrawDebugData();
}
// 50.check and handle collisions
checkAndHandleCollisions();  
       

                           .  .  .

}

אוקי, אז אם כבר בניתם משחק או שניים בפלאש, אתם בטח מזהים את הפורמט של פונקציה. הפונקציה enterFrameHandler היא פונקציה שנקראת כל פעם שקורה event (אירוע) מסוג Event.EnterFrame. במילים אחרות היא נקראת כל פעם שהמחלקה נכנסת לפריים חדש... בקיצור, היא קורה כל הזמן, זה קוד שכל הזמן קוראים לו  כל הזמן בלולאה.

                        // 10. handling the step and all the basics
var timeStep:Number = 1/60;
var velocityIter:int=4;
var positionIter:int=4;
// 20. doing step
PhysicsVars.world.Step(timeStep, velocityIter, positionIter);


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

זה בגדול גם מה שקורה ב step מתבצעים חישובים שנועדו לקבוע מה קרה לכל האוביקטים בעולם הפיזיקאלי באותו "רגע". 

הזמן של הרגע הוא 1/60 למעשה 1/60 שניה, זאת בגלל שהמשחק שלי שואף לרוץ בקצב של 60 פריימים לשניה (FPS). 

ה  velocityIter, וה positionIter משמשים לקבוע כמה פעמים במהלך הצעד הזה, העולם הפיזיקלי יחשב מחדש את המהירות והמיקום של האוביקטים. מה זה אומר.... קצת קשה להסביר, אבל נניח שיש לי כדור, שנגע בקיר. הכדור נמצא חלקו "בתוך" הקיר, והעולם הפיזיקאלי למעשה צריך להזיז אותו, ולתת לו אנרגית תנועה (שתשפיע על ה velocity שלו - המהירות והכיוון שלו). עכשיו יתכן שברגע שנזיז אותו הוא יפגע בכדור אחר, שיחזיר אותו לכיוון הקיר, וכן הלאה. 
מובן שבאמצע צעד אחד במשחק, שאמור לקחת 1/60 שניה, אין לנו זמן לבדוק את כל האפשרויות האלה עבור כל האוביקטים בלי סוף.  לכן אנחנו מגבילים את החישובים האלה, על מנת שהמשחק "לא יתקע". בגדול זה אומר שככל שהמספרים האלה יותר גדולים, יש יותר סיכוי שהמשחק יתקע ויעשה לאגים בגלל חישובים, אבל שהתוצאות תהיינה יותר מדויקות. לעומת זאת ככל שהמספרים יותר קטנים, אנחנו נראה יותר אוביקטים "בתוך" אוביקטים אחרים, ויותר דברים שלא יראו הגיוניים. אז צריך לבחור במשהו באמצע שיתן ביצועים טובים גם מבחינת חישובים וגם מבחינת אייך שהמשחק נראה. 

                      // 30. update actors
for each (var tmpActore:Actor in _allActors){
tmpActore.updateNow();
}

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

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

                        PhysicsVars.world.ClearForces();
if(GameData.kSHOW_DEBUG_DATA){
PhysicsVars.world.DrawDebugData();
}

אני לא אכנס למה השורה הזאת חשובה: 


 PhysicsVars.world.ClearForces();

אתם יכולים ברוב המקרים להתייחס אליה כמשהו נתון.

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

יתכן שנחזור לזה בהמשך, ואני אראה לכם דוגמה לאיך משחק נראה עם debug draw ובלעדיו.

                        // 50.check and handle collisions
checkAndHandleCollisions();  

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

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

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

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

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


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




2 תגובות:

  1. אני אהבתי,מציג נושא מאוד חשוב ומעניין,
    אשמח לקרוא עוד על נושאים כאלה.

    השבמחק