חוויית ההוביט 2014

הוספת גיימפליי מ-WebRTC לחוויית ה-Hobbit

דניאל איזקסון
דניאל איקססון

בזמן לסרט ההוביט החדש 'ההוביט: קרב חמשת הצבאות' עבדנו על הרחבת הניסוי של Chrome בשנה שעברה, מסע דרך הארץ התיכונה, והוספת תכנים חדשים. הפעם התמקדנו בהרחבת השימוש ב-WebGL, מפני שיותר דפדפנים ומכשירים יכולים להציג את התוכן ולעבוד עם יכולות WebRTC ב-Chrome וב-Firefox. במסגרת הניסוי שביצענו מהשנה, היו לנו שלוש יעדים:

  • גיימפליי P2P באמצעות WebRTC ו-WebGL ב-Chrome ל-Android
  • יוצרים משחק רב-משתתפים שקל לשחק בו שמבוסס על קלט מגע
  • מארח ב-Google Cloud Platform

הגדרת המשחק

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

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

חלקים מהמשחק

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

  • ממשק API לניהול שחקנים בצד השרת מטפל במשתמשים, ביצירת התאמות, בסשנים ובנתונים סטטיסטיים של משחקים.
  • שרתים שיעזרו ליצור את החיבור בין השחקנים.
  • ממשק API לטיפול באותות ה-API של ערוצי AppEngine המשמש להתחברות ולתקשורת עם כל השחקנים בחדרי המשחקים.
  • מנוע של משחק JavaScript שמטפל בסנכרון של המצב ושל העברת ההודעות בפרוטוקול RTC בין שני השחקנים/העמיתים.
  • תצוגת המשחק WebGL.

ניהול שחקנים

כדי לתמוך במספר גדול של שחקנים, אנחנו משתמשים בחדרי משחקים מקבילים רבים בכל שדה קרב. הסיבה העיקרית שמגבילה את מספר השחקנים בכל חדר משחקים היא כדי לאפשר לשחקנים חדשים להגיע לראש הלידרבורד בזמן סביר. המגבלה קשורה גם לגודל של אובייקט ה-json שמתאר את חדר המשחקים שנשלח דרך Channel API שבו יש מגבלה של 32kb. עלינו לאחסן שחקנים, חדרים, תוצאות, פעילויות ואת הקשרים שלהם במשחק. לשם כך, קודם השתמשנו ב-NDB לישויות, והשתמשנו בממשק השאילתות כדי לטפל בקשרי גומלין. NDB הוא ממשק ל-Google Cloud Datastore. השימוש ב-NDB עבד מצוין בהתחלה, אבל מהר מאוד נתקלנו בבעיה באופן שבו היינו צריכים להשתמש בו. השאילתה פעלה מול הגרסה ה "מחויבת" של מסד הנתונים (הסבר מפורט על כתיבה ב-NDB במאמר המפורט) עשוי להימשך כמה שניות. אבל לישויות עצמן לא היה עיכוב כזה בזמן שהן מגיבות ישירות מהמטמון. אולי יהיה לך קל יותר להסביר את זה בעזרת קוד לדוגמה:

// example code to explain our issue with eventual consistency
def join_room(player_id, room_id):
    room = Room.get_by_id(room_id)
    
    player = Player.get_by_id(player_id)
    player.room = room.key
    player.put()
    
    // the player Entity is updated directly in the cache
    // so calling this will return the room key as expected
    player.room // = Key(Room, room_id)

    // Fetch all the players with room set to 'room.key'
    players_in_room = Player.query(Player.room == room.key).fetch()
    // = [] (an empty list of players)
    // even though the saved player above may be expected to be in the
    // list it may not be there because the query api is being run against the 
    // "committed" version and may still be empty for a few seconds

    return {
        room: room,
        players: players_in_room,
    }

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

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

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

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

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

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

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

הגדרת WebRTC

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

יש כמה ספריות של צד שלישי שאפשר להשתמש בהן לשירות האותות וגם מפשטות את ההגדרה של WebRTC. חלק מהאפשרויות הן PeerJS, SimpleWebRTC ו-PubNub WebRTC SDK. PubNub משתמשת בפתרון שרת מתארח, ולפרויקט הזה רצינו לארח ב-Google Cloud Platform. שתי הספריות האחרות משתמשות בשרתי LDAP.js שהיינו יכולים להתקין ב-Google Compute Engine, אבל נצטרך גם לוודא שהוא יכול לטפל באלפי משתמשים בו-זמנית, משהו שכבר ידענו שממשק ה-API של ערוצים יכול לעשות.

אחד מהיתרונות העיקריים של השימוש ב-Google Cloud Platform במקרה הזה הוא התאמה לעומס (scaling). התאמת המשאבים הדרושים לפרויקט AppEngine מטופלת בקלות דרך Google Developers Console, ולא נדרשת עבודה נוספת כדי להרחיב את שירות האותות בעת שימוש ב-Channel API.

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

לא בחרנו להשתמש בספרייה של צד שלישי כדי לעזור ל-WebRTC, ולכן נאלצנו לפתח ספרייה משלנו. למזלנו, יכולנו להשתמש שוב בחלק גדול מהעבודה שעשינו בפרויקט CubeSlam. כאשר שני השחקנים מצטרפים לסשן, הסשן מוגדר כ'פעיל' ושני השחקנים ישתמשו במזהה הסשן הפעיל כדי ליזום את החיבור מקצה לקצה (P2P) דרך ה-Channel API. לאחר מכן, כל התקשורת בין שני הנגנים תטופל באמצעות RTCDataChannel.

אנחנו זקוקים גם ל-STUN ולשרתי TURN כדי לעזור ביצירת החיבור ובהתמודדות עם חומות אש ו-NAT. תוכל לקרוא מידע נוסף על הגדרת WebRTC במאמר WebRTC בעולם האמיתי: STUN, TURN ואותות. תוכל לקרוא מידע נוסף על הגדרת WebRTC.

גם מספר שרתי הפנייה שמוגדרים לשימוש צריך לאפשר התאמה לעומס (scaling) בהתאם לנפח התנועה. כדי לטפל בבעיה, בדקנו את מנהל הפריסה של Google. הוא מאפשר לנו לפרוס משאבים באופן דינמי ב-Google Compute Engine ולהתקין שרתים של TURN באמצעות תבנית. התכונה עדיין בגרסת אלפא, אבל מבחינתנו היא עבדה בצורה חלקה. עבור שרת TURN אנחנו משתמשים ב-coturn, שהוא יישום מהיר, יעיל ולכאורה אמין מאוד של STUN/TURN.

ממשק ה-API של ערוצים

ה-Channel API משמש לשליחת כל התקשורת אל חדר המשחקים וממנו בצד הלקוח. ממשק ה-API לניהול הנגן שלנו משתמש בממשק ה-API של הערוץ לצורך קבלת התראות על אירועי משחקים.

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

var que = [];  // [seq, packet...]
var seq = 0;
var rcv = -1;

function send(message) {
  var packet = JSON.stringify({
    seq: seq++,
    msg: message
  });
  channel.send(packet);
}

function recv(packet) {
  var data = JSON.parse(packet);

  if (data.seq <= rcv) {
    // ignoring message, older or already received
  } else if (data.seq > rcv + 1) {
    // message from the future. queue it up.
    que.push(data.seq, packet);
  } else {
    // message in order! update the rcv index and emit the message
    rcv = data.seq;
    emit('message', data.message);

    // and now that we have updated the `rcv` index we 
    // will check the que for any other we can send
    setTimeout(flush, 10);
  }
}

function flush() {
  for (var i=0; i<que.length; i++) {
    var seq = que[i];
    var packet = que[i+1];
    if (data.seq == rcv + 1) {
      recv(packet);
      return; // wait for next flush
    }
  }
}

רצינו גם שממשקי ה-API השונים של האתר יהיו מודולריים ומופרדים מאירוח האתר, והתחלנו להשתמש במודולים המובנים ב-GAE. לצערנו, אחרי שהתחלנו לעבוד עם מפתחי הפיתוח, הבנו ש-Channel API לא פועל עם מודולים בכלל בסביבת הייצור. במקום זאת, עברנו להשתמש במכונות GAE נפרדות ונתקלנו בבעיות CORS, שאילצו אותנו להשתמש בגשר PostMessage של iframe.

מנוע המשחק

כדי שמנוע המשחק יהיה דינמי ככל האפשר, יצרנו את האפליקציה ממשק הקצה בגישה של entity-component-system (ECS). כשהתחלנו את הפיתוח, ה-wireframes והמפרט הפונקציונלי לא הוגדרו, כך שעזר מאוד להוסיף תכונות ולוגיקה עם התקדמות הפיתוח. לדוגמה, אב הטיפוס הראשון השתמש במערכת פשוטה לעיבוד קנבס כדי להציג את הישויות ברשת. כמה חזרות מאוחר יותר, נוספה מערכת להתנגשויות ואחת לנגנים שנשלטים על ידי AI. באמצע הפרויקט יכולנו לעבור למערכת רינדור תלת-ממד בלי לשנות את שאר הקוד. כשחלקי הרשת הופעלו ופעלו במערכת AI, ניתן היה לשנות אותם כדי להשתמש בפקודות מרחוק.

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

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

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

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