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

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

Daniel Isaksson
Daniel Isaksson

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

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

הגדרת המשחק

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

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

חלקים במשחק

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

  • ממשק API לניהול שחקנים בצד השרת מטפל במשתמשים, בהתאמת שחקנים, בסשנים ובנתונים הסטטיסטיים של המשחק.
  • שרתי משחקים שיעזרו ליצור את החיבור בין השחקנים.
  • ממשק API לטיפול באותות של AppEngine Channels API, שמשמשים להתחברות ולתקשורת עם כל השחקנים בחדרי המשחק.
  • מנוע משחקים של 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. זה נראה כמו קצת פריצה, אבל זה עבד, ול-memcache של AppEngine יש מערכת של עסקאות למפתחות באמצעות התכונה המעולה 'השוואה והגדרה', כך שהבדיקות עברו שוב.

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

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

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

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

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

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

הגדרת WebRTC

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

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

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

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

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

אנחנו גם צריכים שרתי STUN ו-TURN כדי לעזור ביצירת החיבור ולטפל ב-NATs ובחומות אש. מידע נוסף על הגדרת WebRTC זמין במאמר WebRTC בעולם האמיתי: STUN,‏ TURN ו-signaling באתר HTML5 Rocks.

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

Channel API

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

העבודה עם ה-Channel 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). כשהתחלנו בפיתוח, לא הגדרנו את תרשים הזרימה והמפרט הפונקציונלי, ולכן היה מאוד שימושי להוסיף תכונות ולוגיקה במהלך הפיתוח. לדוגמה, באב הטיפוס הראשון השתמשו במערכת עיבוד פשוטה של קנבס כדי להציג את הישויות בתוך רשת. כמה דילוגים מאוחר יותר, נוספה מערכת לזיהוי התנגשויות ומערכת לשחקנים בשליטת AI. באמצע הפרויקט יכולנו לעבור למערכת עיבוד גרפי תלת-ממדי בלי לשנות את שאר הקוד. כשחלקי הרשת היו שפועלים, אפשר היה לשנות את מערכת ה-AI כך שתשתמש בפקודות מרחוק.

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

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

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

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