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

הוספת משחק WebRTC לחוויית ההוביט

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.

ניהול שחקנים

כדי לתמוך במספר גדול של שחקנים, אנחנו משתמשים במספר רב של חדרי משחק מקבילים בכל משחק Battlegrounds. הסיבה העיקרית להגבלת מספר השחקנים בכל חדר משחק היא לאפשר לשחקנים חדשים להגיע לראש טבלת הישגי השחקנים המובילים בזמן סביר. המגבלה קשורה גם לגודל של אובייקט ה-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 השיקה לאחרונה את התכונה הפשוטה Click-to-deploy, ואחת מהאפשרויות היא אשכול 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.

היו כמה חששות לגבי זמן האחזור ועמידות ה-Channels 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 כדי לקבל התראות על אירועי משחק.

בעבודה עם Channels 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.

מנוע המשחק

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

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

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

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

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