2021-01-28

הצצה לפיתוח משחקי-מחשב

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

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

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

ברור!

פעם נתקלתי בהרצאה על הארכיטקטורה של משחק רשת המוני (אולי זה היה Second Life?) שלמרות שלא שיחקתי בו - הייתה מאוד מפתיעה: תארו שם ארכיטקטורה של Fat Client/Thin Server בה רוב ההחלטות של הסביבה המשותפת לכמה שחקנים מחושבות בצד הלקוח (המשחק המותקן על המחשב של השחקן) ואז הן נשלחות לשרת רק לצורך אימות (שזה לא Cheat, שאין חריגה מהכללים). באופן הזה הצליחו להעביר הרבה מעלויות החישוב לחומרה של השחקנים, ולפשט את ארכיטקטורת השרת.

לאחרונה, הבן שלי (בן 11 עוד מעט) נרשם לקורס תכנות ב Unity (פלטפורמה לפיתוח משחקי מחשב) והוא לומד לכתוב משחקי מחשב פשוטים. הקורס (סליחה על הביקורת) - פח, ולכן נכנסתי לתמוך בו ולעזור לו. בדרך נתקלתי בכל מיני בעיות / פתרונות מעניינים - שחשבתי ששווה לשתף אותם.

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

מפתחי משחקים לרוב עובדים על גבי מנועים+סביבות פיתוח ייעודיות. המוכרים שבהם הם:

  • Unreal Engine - ה High End למשחקי תלת-מימד עם גרפיקה מלוטשת. מודל התמחור הוא כ 5% מהכנסות החברה שמעל $1M - כלומר: ממש אחוזי מרווחי החברה.
  • Unity - אולי המנוע הפופולארי ביותר למשחקי תלת-מימד, וגם פופולארי למשחקי דו-מימד. הוא פשוט יותר מ Unreal וזול יותר (רישיון שנע בין חינם ל $200 - תלוי ברווחי החברה).
  • GameMaker Studio - מנוע פופולארי מאוד למשקי דו-מימד. מגיע עם מגוון רשיונות בין $39-$1500 למפתח.
במהלך העבודה עם הבן שלי, נתקלתי בפלטפורמה מעניינת נוספת בשם גודוט (Godot) שהיא סביבת פיתוח משחקים מבוססת קוד-פתוח וקהילה, שמתחרה ישירות ב Unity ואף צומחת ומתחילה לאיים עליה. למרות שאנחנו מצליחים להתקדם בגודוט בצורה מהירה יותר, לבן שלי עדיין חשוב להיות "מפתח Unity" - שם אנחנו משקיעים את רוב המאמץ....

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

Shift שהתעשייה הזו עוברת בשנים האחרונות היא צמיחה של המובייל כפלטפורמה המרכזית / הרווחית למשחקים. פלטפרמת המובייל מספקת הזדמנויות מחודשות למשחקי דו-מימד עם גרפיקה פשוטה יותר, ולמשחקי "אינדי". למשל: AmongUs פותח ומתוחזק ע"י 3 אנשים, ו Stardew Valley פותח ועוצב ע"י אדם יחיד

אם הצד העסקי של משחקי "אינדי" מעניין אתכם - אני ממליץ לצפות בקישורים. אלו סיפורים על סטארט-אפים עם כל ההזדמנויות לעשות כל טעות אפשרית / להיכשל (בעיקר: עסקית).


גרפיקה ברמת "סטודיו", דורשת עבודה רבה מאוד - שהרבה מעבר להישג ידם של מפתחי "אינדי"


חווית פיתוח המשחקים - מה היא שונה מעבודה ב IntelliJ?


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


סביבת העבודה המרכזית, בסביבות פיתוח של משחקים, היא ה Scene Editor (תלת-מימדית, במקרה של Unity) בה ניתן לערוך סצנות: להציב אובייקטים, להוסיף להם טקסטורות / אנימציות, ומאפיינים (גודל, התנהגויות). את הקוד כותבים בסקריפטים קצרים יחסית, המקושרים לאובייקטים או לאירועים עליהם - למשל: ()OnCollisionEnter - טיפול באירוע בו חפץ אחר במרחב נכנס ל"מרחב ההתנגשות" של האובייקט אחר. מודל הפיתוח הזה מאוד דומה ל Code Behind המקובל ב Forms.NET (למי שמכיר) בה אנו קודם כל "מציירים" את המסך (Form) ומציבים עליו פקדים (UI Controls) - ואז מוסיפים פיסות קוד לאירועים של הפקדים. למשל: לחיצה על כפתור.

כמו ב Forms. NET - ניתן להכין ב Drag&Drop סצינות ("מסכים") למשחק פשוט, אך ככל שהמשחק יהיה מורכב יותר, אנו נעביר יותר שליטה על יצירת / הגדרות / ומיקום האובייקטים - לקוד.

את הקוד כותבים לרוב ב #C (מסתבר שזו שפה פופולרית בקרב מנועי-משחק), או בשפת סקריפטים ייעודית. רוב הכלים תומכים ביותר משפה אחת - אבל יש תמיד את השפה שהיא ה 1st Class Citizen שכדאי לדבוק בה.

בנוסף יש קונספט מקובל של "Visual Scripting" בו ניתן לתאר התנהגויות ללא כתיבת קוד:


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

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


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

את המשחק עצמו, ניתן "לארוז"/"לקמפל" לסביבות שונות. ל Windows/Mac/Linux, מובייל (iOS/Android) ואולי גם קונסולות ו/או HTML5 (בעיקר למשחקי דו-מימד). כלומר: המנועים מספקים סביבה שהיא באמת Multi-platform אלא אם כתבתם או צירפתם קוד ספציפי-לפלטפורמה (לרוב C++/C) - מה שסביר יותר במשחקים גדולים / מורכבים.



ניהול תנועה: הבסיס


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

למה לא? כי יש שונות גדולה בין משחק למשחק, על אף הדמיון.
  • כיצד מיוצגת דמות השחקן במשחק? - יש מקום לבחירות שונות.
  • יש המון אפשרויות כיצד לדייק את ההתנהגות של דמות השחקן. נראה שאין "ברירת-מחדל" שתספק אפילו 20% מהמשחקים. נראה זאת מיד.
בכתיבת הקוד, אגב, נראה שיש שימוש נרחב בפרדיגמה הקלאסית של ״גזור-הדבק-פצפץ׳״: יש הרבה דוגמאות קוד להעתיק מהן - ופחות הסברי-עומק איך הדברים עובדים מאחורי-הקלעים. בפוסט אשתדל דווקא להראות מעט קוד - ויותר להסביר מה קורה מאחורי הקלעים.

נתחיל: Platformer הוא משחק של דמות שזזה בעולם, קופצת, מתכופפת, נופלת, וכו'. כמו Super Mario. בואו נתבונן כיצד מנהלים תנועה של דמות פשוטה שכזו.

בצורה הנאיבית ביותר אנו מגדירים אובייקט המייצג את הדמות ("Player") עם תמונה/אנימציה מתאימה, מציבים אותה על המסך - ומזיזים אותה בעקבות קלט של השחקן, למשל: ימינה/שמאלה.

מה קורה כאשר הדמות פוגשת בקיר? בפיתוח נאיבי (ללא מנוע של משחקי מחשב)  - הדמות תעבור דרך הקיר. אולי אפילו תמחק (ויזואלית") את הקטע שדרכו עברה.

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

הבסיס לתנועה "טבעית/נעימה לעין" של מנועי-המשחק הוא מנוע פיסיקלי (Physics Engine, נקרא בעבר מנוע דינמי) שכנראה מגיע עם כל מנוע-משחקים שמכבד את עצמו. למשל, המנוע של Godot שהוא הפשוט יותר להבנה (מול Unity), מגיע עם כמה טיפוסי בסיס המסייעים לחבר משחק למנוע הפיסיקלי:
  • StaticBody - מתאר אובייקט ססטי בסביבה שלא נע, אך אפשר להתנגש בו.
  • KinematicBody - גוף שנע, ויכול להתנגש באובייקטים - אך התנועה שלו מנוהלת ע"י הקוד שלנו. המנוע הפיסיקלי מספק פונקציות עזר לניהול תנועה / גילוי וטיפול בהתנגשויות - אך הלוגיקה של התנועה מנוהלת על ידנו. ידע בסיסי של מכניקה ברמת התיכון, מאוד עוזרת בכדי ליצור התנהגות הגיונית ונעימה.
  • RigidBody - גוף המנוהל לגמרי ע"י המנוע הפיסיקלי - ובדיוק רב.
    • כולל אלמנטים כגון:
      • כח-כבידה
      • התנגשות אלסטית ("קפיצה לאחור" כאשר גוף אחד מתנגש בשני, כאשר המהירות והמסה של כל גוף משפיעה על ההתנהגות)
      • מומנטים (חלקים שונים בגוף נעים באופן שונה) - מה שגורם לסיבוב / סחרור של גופים.

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

כמובן ש RigidBody נשמע המשוכלל / "הטוב" ביותר - וייצור חוויה "מגניבה", אך יש סיבות טובות לצמצם את השימוש בו:
  • חישוביות CPU/GPU - הזיזו על על המסך כמה עשרות אובייקטים כאלו - והשבתתם מחשב ממוצע. 
    • התנגשויות מרובות, למשל, יכולים לגרום "לתקיעה" קצרה של המשחק, בגלל החישוביות המורכבת של אירוע שכזה. יש צורך במומחיות מסוימת בכדי לגרום למשחק לעבור אירועים כאלו ללא הפרעה.
  • התנהגות (לא) צפויה - מנוע פיסיקלי מחשב התנהגויות בדיוק ריאליסטי רב - אך הוא יצור גם תנועות שונות ובלתי-צפויות - שיובילו למצבי-קצה שלא צפיתם. זה עלול להיות מקור לבאגים בתצוגה ואף ממש במשחקיות, שקשה לצפות, וקשה לתקן. 
ההמלצה המקובלת היא  להשתמש בטיפוסים הפשוטים ביותר שיספקו חוויה "מספיק טובה", ולשמור את השימוש ב RigidBody בעיקר עבור אלמנטים "מגניבים" שאתם מוכנים להשקיע בהם הרבה.

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

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


בכדי לפשט את העבודה של המנוע הפיסיקלי, לכל אובייקט במשחק יהיה CollisionShape שיעטוף אותו וייצג אותו מבחינת התנגשויות. ה CollisionShape יהיה לרוב צורה פשוטה לחישוב, כגון מלבן, או אליפסה (בתלת מימד: תיבה, גליל, כדור, וכו') - מקסימום פוליגון / הרכבה של כמה פוליגונים.

במקום לחשב התנגשות של bitmap של אובייקטים שונים במשחק (קשה!), המנוע יחשב התנגשויות רק על בסיס ה CollisionShape - שהוא קירוב קל לחישוב של צורת האובייקט.

דמות השחקן היא של רובוט, אבל מבחינת התנגשויות / המנוע הפיסיקלי - מדובר באליפסה פשוטה.




ניהול תנועה של שחקן


נראה שאין תחליף להסתכל על מעט קוד. הנה ההתנהגות שהקוד שלנו יאפשר:




דוגמת הקוד הבאה מנהלת תנועה בסיסית מאוד של שחקן (רובוט) שיכול לנוע ולקפץ על פלטפורמות. הרובוט הוא אובייקט מסוג KinematicBody2D, כלומר: אנו שולטים בתנועה. הסיומת 2D מציינת שזהו גוף בעולם דו-מימדי, רק X ו Y - שהוא קצת יותר פשוט מעולם תלת-מימדי. הקוד כתוב ב GDScript - שזו ואריאציה של פייטון:




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


    1. היחס ביניהם מתאר את הכיוון (זווית θ).
    2. ההרכבה שלהם ("פיתגורס") תתאר את הגודל (m - מלשון magnitude).
  2. המשחק רץ ב event_loop בו מקבלים input, מטפלים ב events, מפעילים את המנוע הפיסיקלי - ומעדכנים את התמונה על המסך. כל עדכון תמונה על המסך נקרא "פריים" (frame) כאשר השאיפה היא ל 60 פריימים בשניה.
    1. ()physics_process_ הוא המעורבות של האובייקט בשלב המנוע הפיסיקלי, כאשר הפרמטר delta מבטא את הזמן שעבר מאז הטיפול הקודם של המנוע הפיסיקלי. חשוב לנרמל כל תנועה מתמשכת לקצב הפריימים ע"י הכפלה ב delta, אחרת התנועה תושפע משינוי בקצב הפריימים.
  3. אנו קולטים קלט מהמשתמש, תנועה ימינה או שמאלה - וקובעים תנועה על ציר ה X.
    1. elif הוא קיצור ל else if (בפייטון)
    2. כאשר פונים שמאלה נרצה להפוך את הצלמית של הדמות בכדי לספק מידה מינימלית של ריאליזם.
  4. הוספת כבידה: כפי שאפשר לראות את מערכת הצירים בתרשים של הוקטור, ערכי y חיוביים הם למטה, ולכן הוספה ל velocity.y מוסיפה מהירות כלפי מטה.
    1. זה חוסר דיוק מבחינת הפיסיקה, כי הכבידה היא בעצם כח שגורם לתאוצה, ולא מהירות קבועה. עבור חווית משחק בסיסית זה מספיק טוב - וזה יותר פשוט מאשר הדמייה של תאוצה.
  5. כאן אנו נעזרים במנוע הפיסיקלי לבצע את התנועה: אנו מספקים את מהירות הדמות (וקטור) ואיזה כיוון במסך מייצג למעלה (למה לא שמו default?) - והמנוע ינהל את הזזת הדמות בפריים הנוכחי + ניהול ההתנגשויות. אם השחק מתנגש בקיר - הוא ייעצר.
    1. אם אנו רוצים חווית התנגשות עשירה יותר ("קפיצה לאחור" או השפעה על העצמים האחרים) עלינו להשתמש בפונקציות "נמוכות" יותר ברמת ההפשטה שלהן - ויש כאלו.
  6. אם השחקן לחץ על "Jump" (ממופה למקש הרווח), והדמות נמצאת על הרצפה (הפשטה שניתנת לנו מהמנוע הפיסיקילי, מכיוון שסיפקנו לו מה הוא "למעלה" עבורנו) - נרצה להזניק את הדמות למעלה בקפיצה.
    1. מכיוון שזו אינה תנועה מתמשכת, אלא חד פעמית - אנחנו לא מנרמלים אותה לקצב הפריימים (delta) . 

כל משחק צריך להתאים לעצמו את חווית התנועה ולדייק אותה למקרים שמתמודדים איתם, ולכן זה לא משהו שהצליחו לספק "out of the box". ב Unity יש הפשטה מעט שונה של CharacterController, המבוסס RigidBody - ולמרות עבודה טובה שעשו לצמצם סיבוך של RigidBody - הקוד מורכב יותר, ולכן בחרתי דווקא לספק דוגמה מ Godot.

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


הפונקציה lerp (קיצור של linear interpolation) עושה ממוצע בין הפרמטר הראשון לפרמטר השני - ביחס בין 0.0-1.0 (הפרמטר השלישי, במקרה שלנו 0.2 או 0.1 בהתאמה), והיא עוזרת להגדיר הדרגתיות. למשל: האצה / האטה או גדילה / סיבוב הדרגתיים. (נתקלתי גם בשימוש מעניין שלה בדיגיטציה של צורות גיאומטריות - אבל זה חומר לפוסט נפרד).

בשורה הראשונה (האצה) אנחנו עושים lerp ("ממוצע") בין המהירות הנוכחית (שגדלה בכל פריים) למהירות המירבית (direction * speed) הרצויה שלנו. שינוי כיוון ידרוש האצה מחדש.

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

הנה סרטון שמציג שמדגים בצורה מעט יותר ברורה את החוויה של האצה / האטה בדמות שחקן:






משהו לסיום: תנועה אוטונומית "חכמה" של דמות "אוייב"


אוקי. גרמנו לדמות השחקן שלנו לזוז. השחקן האנושי (והנבון) מכווין אותה.

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

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




השאלה המעניינת היא כיצד האויב לא נתקל במכשולים, ו"יוצא טמבל"?

כאן נתקלתי בדפוס מעניין שחשבתי לשתף, שנקרא "Context-Based Steering". אדלג על הקוד ואסתפק בהסבר עקרוני.

התוצאה היא תנועה אוטונומית ו"חכמה" של אויבים שלא מתנגשים במכשולים - הישג יפה לקוד פשוט יחסית:


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

הרעיון הוא כזה:


  • משתמשים בוקטור / מערך של התנועות האפשריות של כל אוייב. במקרה שלנו 8 כיוונים (אפשר לדייק ולהגדיר גם יותר).
  • מחזיקים שני וקטורים כאלו:
    • וקטור רצונות (interest) - באיזה כיוון יש שחקן, אליו האויב רוצה להגיע.
    • וקטור חששות (danger) - באיזה כיוון יש מכשול, לשם לא כדאי לנוע.
  • הדרך לעדכן את המערך הוא בעזרת כלי שימושי הנקרא Ray-Casting: שולחים קרן דמיונית מנקודה מסוימת (מרכז האויב) בכיוון מסוים (אחד מ 8 הכיוונים שלנו) - ומקבלים באיזה אוביקט נתקלנו ראשון (אם בכלל). Ray-Casting היא פונקציה שימושית למגוון של סיטואציות.
    • בוקטור הרצונות - אנו ממלאים את המרחקים לשחקן (בכיוונים שאכן נתקלנו בשחקן): מרחק קטן = רצון גדול, ולהיפך.
    • בוקטור החששות - אנו ממלאים את המרחקים למכשול (בכיוונים שאכן יש מכשול): מרחק קטן = חשש גדול, ולהיפך.
זה מביא אותנו למצב כזה:

  • עכשיו אנחנו מחסרים את וקטור הסכנות מוקטור הרצונות (וריאציה אחרת: מבטלים את כל הרצונות שבכיוון שלהם יש איזשהו חשש) - ואז מניעים את האויב בכיוון המועדף עליו ביותר.
    • הרצונות חלשים מדי? האויב מפסיק לרדוף וחוזר למצב סטטי או "שיטוט".
    • השחקן נמצא מאחורי מכשול? אין שום כיוון טוב לנוע אליו? נוע בכיוון אקראי, עד שהמצב ישתנה.
  • עצם ההרצה של אלגוריתם פשוט שכזה - מספיקה במקרים רבים לספק התנהגות "אינטלגנטית" של אויבים, שמספקת אתגר והנאה לשחקנים. אותי זה מרשים!
  • כמובן שבכל משחק צריך "לשחק" עם הפרמטרים, ולעתים להוסיף קצת tweaks עד שמגיעים להתנהגות רצויה וטובה - אבל זה הבסיס.
באנימציה שלמעלה ("מכוניות מרוץ אוטונומיות במסלול"), וקטור הרצונות נקבע לפי המשך המסלול: איזה כיוון לוקח את המכונית "הלאה" להמשך המסלול. את המסלול מגדירים בעזרת כלי / מבנה שנקרא PathFollow - ממנו אפשר לבקש בכל נקודה מה כיוון ההמשך.

מקווה שההסבר מספיק ברור. אפשר למצוא הסבר מלא ומפורט על  Context-based steering בלינק הבא.


סיכום


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

האם זה מעניין בלי יישום קונקרטי?
- אני מקווה שכן. עבורי זה נושא מעניין.

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

הפקות יקרות של משחקים מדי פעם מחדשות ב AI מעניין / מתקדם יותר - אבל זה קורה רק מדי פעם.


שיהיה בהצלחה!




2021-01-02

מתי כדאי להימנע מ Mock Objects?

אני משתדל בבלוג שלי להביא רעיונות חדשים ולא טריוויאלים.

במקרה הזה לא מדובר ברעיון חדש בכלל (הנה פוסט מ 2012) - אבל ככל הנראה בהחלט לא טריוויאלי.

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

אני יכול לתת כמה דוגמאות כאלו, אבל הפעם אתמקד באחת: שימוש שגוי ב Mock Objects.

האמת שהבעיה היא לא דווקא ב Mock Objects, כאשר אומרים "Mock Objects" הכוונה לרוב היא ל Stubs או ל Fakes - אבל ההבחנה לא חשובה. אני מדבר על השימוש המוגזם בכל סוגי ה Test Doubles.


לכאורה, כשלומדים לכתוב Unit Test מתחילים עם בדיקות פשוטות וישירות. כשרוצים "להתקדם" ומחפשים "מה אפשר לעשות מעבר?" מגיעים לעולם של Test Doubles - וה Frameworks השונים שעוזרים ליצור ולנהל אותם (כמו Mockito, SinonJS, MSW, ועוד עשרות), ונוצרת הרגשה שאנו "עושים משהו מתקדם יותר".

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

דווקא המומחים בכתיבת בדיקות-יחידה ממעטים בלהשתמש Mocks, וזו מיומנות שכנראה לא טריוויאלי לרכוש.



האם Mocks הם תמיד רעיון רע?

ברור שלא.

אני אצמד להבחנה של Uncle Bob שמאוד נכונה בעיני:

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


Mocks בין מערכות - הם חשובים, ואף נדרשים בכדי לבצע בדיקות בצורה יעילה

מהן "מערכות"? - לצורך העניין, נתחיל במיקרו-שירותים או מערכות צד-שלישי.

כאשר יש לנו בארגון כ 50 מיקרו-שירותים ואנו כותבים בדיקה המפעילה מספר של מיקרו-שירותים (נקרא לבדיקה כזו "System Test") אזי:

  • Scope הבדיקה הוא גדול: בדיקה בודדת מפעילה כנראה מאות או אלפי שורות של קוד.
    • קשה מאוד להתמקד במקרי קצה בתוך ה Flow, והנטיה האנושית היא לא באמת לבדוק מקרי קצה.
    • כשהבדיקה נופלת לא ברור לרוב מה נכשל - צריך להתחיל ולחקור. כלומר: כישלון של בדיקה מוביל לעבודה משמעותית נוספת - לפני שאפשר לתקן את הקוד.
  • סביר יותר ויותר שזמני הריצה של הבדיקה יהיו גבוהים.
    • נחשיב בדיקה שאורכת יותר מ 2 שניות - כבדיקה ארוכה. 2 שניות הן המון זמן מחשוב, אולי כדאי לחשוב עליהן כ 2,000,000,000 ננושניות - ולזכור שמחשבים בימנו מבצעים בננו-שנייה פעולה.
    • כאשר יש לנו הרבה בדיקות (דבר טוב!) והבדיקות אורכות זמן רב => זמן ההמתנה לתוצאות הבדיקה אורך => תדירות הרצת הבדיקות פוחתת => גדל הזמן הממוצע מכתיבה של קוד שגוי - עד שאנו מגלים זאת => Feedback cycle ארוך יותר.
    • "סטנדרט הזהב" להרצה של בדיקות טוען שהמתנה של יותר מ 10 דקות להרצה של בדיקות אינו סביר. לאחרונה אני רואה התפשרות על המדד הזה, ויש כאלו שגם מדברים על 15 דקות של הרצה כזמן סביר / רצוי.
מכאן, אפשר לכתוב הרבה בדיקות, שירוצו הרבה זמן - ולהתדרדר ב Feedback cycle של המפתח.

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

אי אפשר לבדוק מיקרו-שירות ברצינות בלי שהוא יקרא לשירותים שהוא תלוי בהם. לכן חייבים לכתוב Mocks שידמו את המערכת / מיקרו-שירותים האחרים שהשירות שלנו תלוי בהם - בזמן שבודקים את השירות.
ה Scope המצומצם של בדיקת מיקרו-שירות בודד - רק תשפר לנו את המדדים החשובים:
יכולת התמקדות הבדיקה במקרי קצה, זמני איתור תקלה, וזמני הריצה של הבדיקה.

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


Mocks בתוך מערכת - הם טלאי (Patch), שיש לצמצם את השימוש בו.

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

לרוב הבעיה נובעת מכך שאין הפרדה בין:

  • לוגיקה שהשירות מבצע - להלן "Pure Business Logic" (הכתובים כ Pure functions, כמובן)
  • לוגיקה של תקשורת עם שירותים אחרים - להלן "Integration Logic".
ההפרדה הזו קלה בעת כתיבת קוד חדש - וכמעט בלתי אפשרית על גבי קוד קיים שכתוב כך.

כאשר עושים את ההפרדה - קל לכתוב בדיקות יחידה בלי Mocks.
כאשר לא עושים את ההפקדה - קשה מאוד לכתוב בדיקות יחידה, ואז מגיע שימוש מופרז ב Mocks.

ככל אצבע, אני מחשיב שימוש ב Mocks כמופרז אם יותר מ 10% מבדיקות היחידה שלנו משתמשות ב Mocks.

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

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


העבודה הנוספת בהפרדה בין לוגיקה עסקית ללוגיקה של אינטגרציה:
  • דורשת מודעות ותשומת לב.
  • מוסיפה מעט עבודה בעת הקידוד (נאמר: 10-15%)
אבל:
  • משפרת את המודולוריות (ומכאן - ה Design) של הקוד
  • מאפשר לבדוק אותו בצורה יעילה הרבה יותר, הן מבחינת עומק הבדיקות, והן מבחינת זמן שמושקע בכתיבת בדיקות. 
ברוב המקרים, ההשקעה בהפרדת הקוד תחזיר את עצמה כבר לאחר סיום כתיבת הבדיקות (בדיקות פשוטות יותר = חסכון זמן בעת כתיבת הבדיקות), ובוודאי ובוודאי שתחזיר את עצמה לאורך זמן - כאשר המערכת צריכה לעבור שינויים משמעותיים.

מה הבעיה בשימוש ב Mocks בבדיקות -יחידה?

הנה דוגמה טיפוסית ל Heavily mocked test, ראיתי אינספור כאלו בחיי - ואראה כנראה (אולי הפוסט יעזור?) עוד אינספור בעתיד:



מה הבעיה בבדיקה הזו?
  • היא רצה ומצליחה!
  • אם מחקתי כמה שורות קוד בפונקציה הנבדקת ()doSomething - היא נכשלת. כלומר: היא בודקת משהו.
  • השתמשתי ב mocks frameworks בצורה יעילה - וחסכתי המון קוד לו הייתי כותב את ה Mocks בעצמי.
מה עוד אפשר לבקש?!

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


בעיה: לא ברור מה בדיוק נבדק, מה הצלחת הרצה של הבדיקה - באמת אומרת.

כשאני קורא את קוד הבדיקה, גם בלי obfuscation ושמות משמעותיים - אני מבין שבוצעה פעולה, אבל אני לא יכול לדעת מה חלקה של הפונקציה ()doSomething בעבודה - ומה חלקם של ה Mocks שלה. 

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

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

כאשר עושים refactoring במערכת - אי אפשר להבין אלו בדיקות Mock Heavy עומדות לאבד את ערכן.
כאשר הבדיקות הללו נשברות ומתקנים אותן כחלק משינוי - קשה מאוד לוודא שאנחנו משמרים את הערך שלהם. הכלל בגלל שמה שנבדק הוא משתמע ואינו גלוי. 

לכן, זו היא בעיה בתהליך / בתבנית - ולא בקוד הספציפי. 


בעיה: הבדיקה בודקת איך דברים קרו (מבנה), לא מה קרה (התנהגות).


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

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

"האא! הבדיקות לא גילו את זה כי זה היה באג ב SQL" - הוא סוג התירוץ שאנו מספרים לעצמנו במקרים האלו. "אולי כדאי להוסיף גם בדיקה גם על מבנה השאילתא" (בבקשה: לא!)

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

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

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

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



בעיות נוספות

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



לגיקים שבינינו: הכוונה ל Port = "נמל". לא IP Address port :-)




סיכום


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

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

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

חבל לראות את כל הקוד שנכתב בלי הפרדה של לוגיקה - מה שיהפוך את בדיקות היחידה לקשות יותר וליעילות פחות.

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


שיהיה בהצלחה!